Web Scraping skill for Codex
Scrapers that stay polite and survive layout changes. Codex looks for an API, JSON endpoint or sitemap before parsing HTML, then builds a scraper that honours robots.txt, throttles per host, retries 429s with backoff and checkpoints every record. Parsers raise on missing fields instead of writing blank rows, and the run is checked against the live page before scaling up.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- web-scraping Installs to ~/.agents/skills/web-scraping/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install web-scraping The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project web-scraping Installs to .agents/skills/web-scraping/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/web-scraping.zipunzip -o web-scraping.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) web-scraping PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $web-scraping, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds polite, resumable web scrapers with requests or httpx plus BeautifulSoup, and Crawlee (Python or Node) for multi-page crawls - robots.txt and rate limits honoured, retries with backoff, stable CSS selectors, checkpointed output to JSONL or CSV, and validation of every scraped field. Use when asked to scrape a site, extract listings or prices from HTML pages, crawl a sitemap or paginated catalogue, or fix a scraper that broke, got blocked or returns empty fields.
Use it when
- Data you need is only published as HTML pages
- A crawl must follow pagination or links across hundreds of pages
- An existing scraper broke, got rate limited or returns empty fields
Use something else when
- The page needs clicks, logins or form filling to reach the data: use browser-automation
- You only need to know when a page or feed changes: use rss-feed-monitoring
How to check the result
Spot-check every field on ten records against the live page, confirm the empty-field rate is under one percent, and kill and restart the run to prove it resumes without duplicates.
The mistake it prevents
Treating HTTP 200 as success when the body is a consent wall or block page, which fills the dataset with silent blanks.
A good scraper asks for the fewest pages possible, at a pace the site would not notice, identifies itself, survives a crash halfway through without refetching everything, and fails loudly when the page layout changes instead of writing empty rows.
Before writing code
- Look for a better source. Check for a public API, a JSON endpoint the page itself
calls (browser DevTools, Network tab, filter Fetch/XHR), an RSS feed, a sitemap
(
/sitemap.xml), or a data export. Parsing JSON is ten times sturdier than parsing HTML. - Check permission. Read
/robots.txtand the site’s terms. Scraping behind a login, collecting personal data, or republishing copyrighted content needs the user’s explicit confirmation that they have the right to do it. Never bypass CAPTCHAs, paywalls or access controls; stop and report instead. - Decide whether JavaScript is needed.
curl -s URL | grep -c 'expected text'. If the data is in the raw HTML (or in a<script type="application/ld+json">or__NEXT_DATA__blob), use plain HTTP. Only if it is rendered client-side, use a browser ($browser-automationor Crawlee’sPlaywrightCrawler). - Read
AGENTS.mdfor the project’s Python or Node tooling. Installing packages needs network; in a Codex sandbox with network off, ask for approval first. Every fetch in this skill needs network access too.
Tool choice
| Job | Default |
|---|---|
| A few known URLs, static HTML | httpx or requests + BeautifulSoup (lxml parser) |
| Crawl many pages, follow links, queue, retries, persistence | Crawlee (BeautifulSoupCrawler in Python, CheerioCrawler in Node) |
| Client-rendered pages | Crawlee PlaywrightCrawler, or call the underlying JSON API directly |
| One-off table from one page | pandas.read_html(url) then check it |
Politeness defaults
- One request at a time per host, with a delay of 1 to 3 seconds plus jitter. Use the
Crawl-delayfrom robots.txt when it is larger. - A descriptive
User-Agentwith contact info (acme-research-bot/1.0 (+mailto:[email protected])). Do not impersonate a browser to evade a block. - Honour
429and503withRetry-After; back off exponentially; stop the run after repeated 403s instead of rotating IPs or headers. - Cache raw responses to disk during development so parser iterations do not refetch.
- Scrape off-peak for large jobs and cap the total (
max_pages) on every run.
Plain HTTP scraper (Python)
import json, random, time
from pathlib import Path
from urllib import robotparser
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
UA = "acme-catalog-bot/1.0 (+mailto:[email protected])"
BASE = "https://example.com"
rp = robotparser.RobotFileParser(urljoin(BASE, "/robots.txt"))
rp.read()
delay = max(rp.crawl_delay(UA) or 0, 1.5)
session = requests.Session()
session.headers["User-Agent"] = UA
session.mount("https://", HTTPAdapter(max_retries=Retry(
total=5, backoff_factor=2, status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"], respect_retry_after_header=True)))
out = Path("products.jsonl")
done = {json.loads(l)["url"] for l in out.open()} if out.exists() else set()
def parse(html: str, url: str) -> dict:
soup = BeautifulSoup(html, "lxml")
title = soup.select_one("h1.product-title")
price = soup.select_one("[data-testid=price]")
if not title or not price:
raise ValueError(f"layout changed? missing fields on {url}")
return {"url": url, "title": title.get_text(strip=True),
"price": price.get_text(strip=True)}
def scrape(urls: list[str]) -> None:
with out.open("a", encoding="utf-8") as f:
for url in urls:
if url in done or not rp.can_fetch(UA, url):
continue
r = session.get(url, timeout=(5, 30))
r.raise_for_status()
f.write(json.dumps(parse(r.text, url), ensure_ascii=False) + "\n")
f.flush() # checkpoint per record
time.sleep(delay + random.uniform(0, 1))
Key points: the output file doubles as the checkpoint, the parser raises on missing fields, and timeouts are always set (requests has no default timeout).
Crawlee crawl (Python)
import asyncio
from crawlee.crawlers import BeautifulSoupCrawler, BeautifulSoupCrawlingContext
async def main() -> None:
crawler = BeautifulSoupCrawler(max_requests_per_crawl=500, max_request_retries=3)
@crawler.router.default_handler
async def handle(ctx: BeautifulSoupCrawlingContext) -> None:
await ctx.enqueue_links(selector="a.pagination-next, a.product-link")
h1 = ctx.soup.select_one("h1.product-title")
if h1:
await ctx.push_data({"url": ctx.request.url, "title": h1.get_text(strip=True)})
await crawler.run(["https://example.com/catalog"])
await crawler.export_data("products.json")
asyncio.run(main())
Node equivalent: new CheerioCrawler({ requestHandler: async ({ $, request, enqueueLinks, pushData }) => {...}, maxRequestsPerCrawl: 500, maxConcurrency: 2 }).
Crawlee stores its queue and datasets in ./storage; a rerun resumes unless you purge it.
Cap concurrency (ConcurrencySettings(max_concurrency=2) in Python) because Crawlee scales
up aggressively by default. Crawlee also checks robots.txt only when you enable it
(respect_robots_txt_file=True in recent versions); verify for your installed version.
Selector rules
| Prefer | Avoid |
|---|---|
[data-testid=price], [itemprop=price], semantic ids | div > div:nth-child(3) > span |
JSON-LD (script[type="application/ld+json"]) parsed with json.loads | Regex over HTML |
Text anchors: find the <dt> labelled “SKU”, take the next <dd> | Class names with hashes (css-1x9k2ab) |
| Pagination via the “next” link | Guessing ?page=N until a 404 |
Data hygiene
- Normalise as you go: prices to
Decimalplus currency, dates to ISO 8601, whitespace collapsed, relative URLs resolved withurljoin. - Store the source URL and fetch timestamp on every record.
- Deduplicate by a stable key (canonical URL or SKU), not by the whole row.
- Personal data (names, emails, phone numbers) is out of scope unless the user confirms a lawful basis; never collect more fields than asked.
Gotchas
response.textguesses encoding from headers; for mojibake, useresponse.contentwith BeautifulSoup and let it sniff, or setr.encoding = r.apparent_encoding.- A 200 page can still be a block page or consent wall. Assert on a content marker, not on the status code.
- Infinite calendars, faceted filters and session IDs in URLs create endless crawls.
Restrict
enqueue_linkswith selectors or glob patterns and strip query noise. - Sites serve different HTML to different locales or currencies; pin
Accept-Language. - Lazy-loaded lists often have a JSON endpoint with
?offset=; use it instead of scrolling.
Verification
- Run against 5 to 10 URLs first and inspect every field by eye against the live page
- Count records with empty or default fields; more than 1 percent means a selector is wrong
- Rerun: already-scraped URLs are skipped and nothing is duplicated
- Kill the run midway and restart: it resumes from the checkpoint
- Request log shows the configured delay and no burst of retries
When a check fails, save the failing page’s HTML to debug/ and fix the selector against
that file, not against repeated live fetches.
Report
**Source:** example.com/catalog (robots.txt allows /catalog, crawl-delay 2 s)
**Method:** requests + BeautifulSoup, 1 req / 2-3 s, retries on 429/5xx
**Output:** products.jsonl, 1,284 records (1,290 URLs; 6 returned 404)
**Field quality:** price missing on 3 records (out of stock pages), listed in missing.csv
**Rerun:** `python scrape.py` resumes; delete products.jsonl to start over What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Web Scraping"
short_description: "Scrapers that stay polite and survive layout changes"
default_prompt: "$web-scraping Scrape the product name and price from every page in this catalogue into a CSV" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.