Web Scraping skill for Claude Code

Polite scrapers that notice when sites change. Claude checks robots.txt with a bundled script and reviews terms before writing any code, then looks for an API, sitemap, or embedded JSON before parsing HTML, reaching for Playwright only when content is client-rendered. Fetchers honor crawl delays and Retry-After, cache raw responses, resume from checkpoints, and fail the run when field null rates show the layout changed.

  • nameweb-scraping
  • size183 lines · ≈2,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds polite, resilient web scrapers — robots.txt and terms checks, official API and embedded JSON first, HTTP clients with selectolax or BeautifulSoup before headless browsers, Playwright with network interception when JavaScript is required, per-host rate limits with backoff, caching, checkpointing, and validated output. Use when extracting data from websites, writing a crawler or Scrapy spider, when a scraper breaks or gets 403/429 responses, or when deciding between requests and Playwright.

Try asking

  • Scrape product names and prices from this catalog site into a CSV
  • Our scraper started returning empty prices last week. Figure out what changed
  • Should I use requests or Playwright for this site? It loads products with JavaScript

Use it when

  • You need data from a website that has no suitable API
  • A scraper breaks, returns empty fields, or gets 429 responses
  • You are choosing between plain HTTP and a headless browser
  • A crawl must resume after interruptions without refetching

Skip it when

  • You are testing your own web app in a browser — use playwright-e2e instead
  • The provider offers an official API for the data — use that API directly with its client library

How to check the result

robots_check.py reports every target URL as allowed, a small run produces records with source URLs and timestamps, and per-field null rates stay near zero.

The common mistake it prevents

Scraping the rendered DOM with a headless browser when the page already fetches the same data as clean JSON from an endpoint you could call directly.

~/.claude/skills/web-scraping/SKILL.md

--- name: web-scraping description: Builds polite, resilient web scrapers — robots.txt and terms checks, official API and embedded JSON first, HTTP clients with selectolax or… license: MIT ---

A scraper is done when it collects only data you are permitted to collect, at a rate the site would not notice, survives restarts without re-fetching everything, detects when the page layout changes, and writes validated records with their source URL and fetch time.

Ground rules

  • Check permission first: robots.txt, the site’s terms, and whether an official API or data export exists. If terms forbid automated access, stop and tell the user.
  • Never bypass authentication, paywalls, CAPTCHAs, or bot protection, and never scrape behind a login without the account owner’s and site’s permission.
  • Personal data (names, emails, profiles) triggers privacy law (GDPR, CCPA); collect only what the user has a lawful basis for, and say so when unclear.
  • Identify yourself: a descriptive User-Agent with contact info.
  • Default rate: at most 1 request/second per host, concurrency 1–2 per host, unless robots.txt or the owner allows more. Honor Crawl-delay and Retry-After.
  • This is general information, not legal advice; for commercial or large-scale scraping, recommend the user get legal review.

Workflow

- [ ] 1. Permission check (robots.txt script, terms, API availability)
- [ ] 2. Find the cleanest data source (API > embedded JSON > HTML > browser)
- [ ] 3. Prototype extraction on 3–5 saved pages
- [ ] 4. Build the fetcher: rate limit, retries, caching, checkpointing
- [ ] 5. Validate records; track field null rates
- [ ] 6. Run small, inspect, then scale
- [ ] 7. Monitor for breakage on scheduled runs

1. Permission check

python3 scripts/robots_check.py "AcmePriceBot/1.0 (+mailto:[email protected])" \
  https://example.com/products/123 https://example.com/search?q=shoes

The script reports allow/disallow per URL, Crawl-delay, request rate, and sitemaps, and exits 1 if anything is disallowed or robots.txt is unreachable.

2. Find the cleanest source

Check in this order (open DevTools Network tab, filter Fetch/XHR):

SourceHow to spot itWhy
Official API / bulk exportDeveloper docs, /api/ linksStable, permitted, structured
Sitemapssitemap.xml from robots.txtComplete URL list without crawling
JSON endpoints the page callsXHR responses in DevToolsStructured, no HTML parsing
Embedded JSON<script type="application/ld+json">, __NEXT_DATA__, window.__INITIAL_STATE__Full data in the first response
Server-rendered HTMLData visible in “View Source”Simple HTTP + parser
Client-rendered onlyData absent from View SourceNeeds a browser (Playwright)

3. HTTP + parser (default)

import json, time, random
import httpx
from selectolax.parser import HTMLParser

HEADERS = {"User-Agent": "AcmePriceBot/1.0 (+mailto:[email protected])", "Accept-Language": "en"}

def fetch(client: httpx.Client, url: str, max_tries: int = 5) -> httpx.Response:
    for attempt in range(max_tries):
        r = client.get(url)
        if r.status_code in (429, 503):
            wait = float(r.headers.get("Retry-After", 2 ** attempt)) + random.random()
            time.sleep(min(wait, 120))
            continue
        r.raise_for_status()
        return r
    raise RuntimeError(f"gave up on {url} after {max_tries} tries")

def parse_product(html: str, url: str) -> dict:
    tree = HTMLParser(html)
    ld = tree.css_first('script[type="application/ld+json"]')
    if ld:                                            # structured data first
        data = json.loads(ld.text())
        if isinstance(data, dict) and data.get("@type") == "Product":
            offer = data.get("offers") or {}
            return {"url": url, "name": data.get("name"), "price": offer.get("price"),
                    "currency": offer.get("priceCurrency")}
    title = tree.css_first("h1")
    price = tree.css_first("[data-testid='price'], .price")
    return {"url": url, "name": title.text(strip=True) if title else None,
            "price": price.text(strip=True) if price else None, "currency": None}

with httpx.Client(headers=HEADERS, timeout=20, follow_redirects=True, http2=False) as client:
    for url in urls:
        rec = parse_product(fetch(client, url).text, url)
        time.sleep(1.0)                               # per-host politeness delay

Selectors: prefer stable attributes (data-testid, itemprop, semantic tags) over generated class names (.css-1x2y3z). Resolve relative links with urllib.parse.urljoin. BeautifulSoup (bs4 with lxml) is fine when speed is not a concern.

Browser (only when required)

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(user_agent="AcmePriceBot/1.0 (+mailto:[email protected])")
    page.route("**/*.{png,jpg,jpeg,webp,gif,woff2,mp4}", lambda route: route.abort())
    captured = []
    page.on("response", lambda r: captured.append(r) if "/api/products" in r.url and r.ok else None)
    page.goto("https://example.com/category/shoes", wait_until="domcontentloaded")
    page.wait_for_selector("[data-testid='product-card']")
    rows = [r.json() for r in captured]               # prefer the JSON the page fetched
    browser.close()

Capture the page’s own API responses instead of scraping the rendered DOM when possible. Reuse one browser and context; a new browser per page is slow and memory hungry.

4. Scale safely

ConcernApproach
PolitenessPer-host delay or token bucket; asyncio.Semaphore(2) per host for async
RetriesOnly on timeouts, 429, 5xx; exponential backoff with jitter; cap attempts
403 / bot challengeStop and reassess permission; do not rotate proxies or spoof to evade
CachingStore raw responses (HTML/JSON) keyed by URL + date; re-parse without re-fetching
Conditional requestsSend If-None-Match / If-Modified-Since when re-crawling
CheckpointingRecord completed URLs (SQLite table or JSONL); resume skips them
DedupeCanonical URL (strip tracking params, fragments) as the key
PaginationStop on empty page, repeated page, or max page; never loop unbounded

Scrapy, for larger crawls, with politeness built in:

# settings.py
ROBOTSTXT_OBEY = True
USER_AGENT = "AcmePriceBot/1.0 (+mailto:[email protected])"
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
DOWNLOAD_DELAY = 1.0
CONCURRENT_REQUESTS_PER_DOMAIN = 2
HTTPCACHE_ENABLED = True
RETRY_HTTP_CODES = [429, 500, 502, 503, 504]

5. Validate output

Every record carries url, fetched_at (UTC), and the extracted fields. Validate types (price parses to a number, currency is ISO 4217) and track per-field null rates per run. A field going from 0% to 80% null means the layout changed: fail the run instead of writing garbage.

Gotchas

  • Prices and numbers come localized (1.234,56 €); parse with the page’s locale.
  • Sites serve different content by geography, language, cookie consent, or A/B test; pin Accept-Language and note it.
  • wait_until="networkidle" hangs on pages with long-polling or analytics beacons; wait for a specific selector instead.
  • Infinite scroll usually calls a paginated JSON endpoint; use that directly.
  • Character encoding: trust response.encoding only if the header sets it; otherwise let the parser detect from <meta charset>.
  • Storing full HTML of pages with personal data creates a privacy liability; store only needed fields and set retention.

Output

**Target:** example.com product pages (robots.txt allows /products/, crawl-delay 2 s)
**Source used:** JSON-LD Product blocks (fallback CSS for 3% of pages)
**Run:** 4,812 URLs from sitemap, 1 req/2 s, 0 disallowed, 14 retries (429), 0 failures
**Output:** data/products_2026-10-19.parquet (url, name, price, currency, fetched_at)
**Quality:** price null rate 0.4%; name null rate 0.0%
**Notes:** terms permit non-commercial use; no personal data collected

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/robots_check.py 85 lines · 3.1 KB
#!/usr/bin/env python3
"""Check robots.txt rules for one or more URLs before scraping (stdlib only).

Usage:
  python3 scripts/robots_check.py "MyScraper/1.0 (+mailto:[email protected])" https://example.com/products/1 [more URLs...]

Prints, per host: whether robots.txt was found, Crawl-delay / Request-rate for the
user agent, and sitemaps; per URL: allowed or disallowed.
Exit code 0 if every URL is allowed, 1 if any is disallowed, 2 on usage error.
Note: an unreachable robots.txt (network error, 5xx) is reported as UNKNOWN and treated
as "do not crawl yet" (exit 1); a 404 robots.txt means everything is allowed.
"""
import sys
import urllib.error
import urllib.request
import urllib.robotparser
from urllib.parse import urlsplit


def load_robots(base, user_agent):
    url = f"{base}/robots.txt"
    rp = urllib.robotparser.RobotFileParser()
    rp.set_url(url)
    req = urllib.request.Request(url, headers={"User-Agent": user_agent})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            body = resp.read().decode("utf-8", errors="replace")
        rp.parse(body.splitlines())
        return rp, "found"
    except urllib.error.HTTPError as exc:
        if exc.code in (401, 403):
            rp.disallow_all = True
            return rp, f"HTTP {exc.code} (treat as disallow all)"
        if 400 <= exc.code < 500:
            rp.allow_all = True
            return rp, f"HTTP {exc.code} (no robots.txt: allow all)"
        return None, f"HTTP {exc.code} (server error: unknown)"
    except (urllib.error.URLError, TimeoutError, OSError) as exc:
        return None, f"unreachable ({exc})"


def main():
    if len(sys.argv) < 3:
        print(__doc__.strip(), file=sys.stderr)
        return 2
    user_agent = sys.argv[1]
    urls = sys.argv[2:]
    by_host = {}
    for u in urls:
        parts = urlsplit(u)
        if parts.scheme not in ("http", "https") or not parts.netloc:
            print(f"error: not an http(s) URL: {u}", file=sys.stderr)
            return 2
        by_host.setdefault(f"{parts.scheme}://{parts.netloc}", []).append(u)

    status = 0
    for base, host_urls in by_host.items():
        rp, state = load_robots(base, user_agent)
        print(f"{base}/robots.txt: {state}")
        if rp is None:
            for u in host_urls:
                print(f"  UNKNOWN     {u}")
            status = 1
            continue
        delay = rp.crawl_delay(user_agent)
        rate = rp.request_rate(user_agent)
        sitemaps = rp.site_maps() or []
        if delay is not None:
            print(f"  crawl-delay: {delay} s")
        if rate is not None:
            print(f"  request-rate: {rate.requests} per {rate.seconds} s")
        if delay is None and rate is None:
            print("  no crawl-delay set: default to <= 1 request/second per host")
        for sm in sitemaps:
            print(f"  sitemap: {sm}")
        for u in host_urls:
            ok = rp.can_fetch(user_agent, u)
            print(f"  {'ALLOWED   ' if ok else 'DISALLOWED'}  {u}")
            if not ok:
                status = 1
    return status


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc