Scraping & ingestion tooling — GitHub research and adoption decisions¶
2026-06-18. Three verified GitHub research passes (anti-bot/stealth fetch, structured/PDF ingestion, proxy/clean-IP) → concrete ADOPT/PORT/AVOID calls and what we wired in. All stars/licenses/dates were pulled live via
gh repo viewon 2026-06-18 — not estimates. Pairs withacquisition_fetch_tiers.md(the escalation ladder this populates).
We build our own thin tools on top of proven libraries rather than taking heavy
frameworks — the box is fragile (no Node, no AGPL, no large model downloads,
Windows + system Edge). The pattern: adopt a focused, permissively-licensed,
pure-Python library for the hard primitive, wrap it behind our config-driven
registry_spec / registry_parsers so the spec side never changes.
Adopted this session¶
| Tool | Slug | License | Role | Status |
|---|---|---|---|---|
| patchright | Kaliiiiiiiiii-Vinyzu/patchright-python |
Apache-2.0 | drop-in Playwright that patches the Runtime.enable CDP leak (top 2026 bot signal); used by browser_fetch when present |
wired (guarded import → optional) |
| camelot | camelot-dev/camelot |
MIT | tabular-PDF extraction → registry_parsers.parse_pdf_table (format: pdf_table); unlocks the ~116 PDF endpoints |
wired |
| pdfplumber | jsvine/pdfplumber |
MIT | ruleless-PDF fallback behind camelot | installed |
| curl_cffi | lexiforest/curl_cffi |
MIT | TLS/JA3 impersonation; the auto-fallback fetch tier | already in use |
1 — Anti-bot / stealth fetch¶
The 2026 reality: Cloudflare keys on (a) TLS/JA3 fingerprint, (b) the Runtime.enable
CDP leak / headless tells, and © ASN/IP reputation. Different tiers beat
different layers; nothing beats © except changing the egress IP (see §3).
- ADOPT — patchright (~1.4k★, Apache-2.0, active): pure-pip Playwright fork,
supports
channel="msedge", avoidsRuntime.enableentirely. The license-clean, no-Node way to lower the automation fingerprint. Wired intobrowser_fetch. - KEEP — curl_cffi (5.8k★, MIT): make it the default tier; most registers fall to TLS impersonation with no browser at all.
- ADOPT (selective) — Scrapling (
D4Vinci/Scrapling, 64k★, BSD-3): greatStealthyFetcher+ retry framework, but its stealth backend is Camoufox (a Firefox download) → adopt only on a non-fragile host / CI, not the main box. - PORT technique only — rebrowser-patches (Node, no license): the canonical
Runtime.enablefix; consume it via patchright, don't vendor it. - AVOID here: FlareSolverr (Docker sidecar; can't solve Turnstile), undetected-chromedriver (GPL, stale, Selenium-based), nodriver/zendriver (AGPL), cloudscraper (defeated by modern CF), playwright_stealth (abandoned; JS-injection is itself a detection vector), camoufox direct (Firefox download), primp (Rust; duplicates curl_cffi).
2 — Structured ingestion & PDF tables¶
The biggest untapped data is the 116 catalogued PDF endpoints and the CKAN/Socrata/ArcGIS portal families.
- ADOPT — camelot (~3.8k★, MIT, now ships pure Windows wheels —
opencv-headless + pypdfium2, no Ghostscript): the PDF-table extractor. Wired as
format: pdf_table. Proven live on the HK CR money-lender PDF (lattice → cleanMLR No / English Name / Chinese Name / Expiryrows). Text-PDF only; scanned PDFs route to OCR/vision. - ADOPT — pdfplumber (10.4k★, MIT): fallback for ruleless tables (explicit
column x-coords) when camelot's
streammis-aligns. - ADOPT — ckanapi (
ckan/ckanapi, MIT) + sodapy (afeld/sodapy, MIT): first-class CKAN Action-API and Socrata SODA clients → stop hand-rollingoffset/limitand$offset/$limitper portal. (We already paginate both generically; these are the clean upgrade when a portal misbehaves.) - PORT — pyesridump (
openaddresses/pyesridump, MIT, ~600 LOC): ArcGIS FeatureServer paging (exceededTransferLimit+ OID fallback) → a futureformat: arcgis. - ADOPT as small utils: jmespath (declarative JSON field paths — make the JSON column map a JMESPath string), selectolax (fast HTML tables if it ever gets hot).
- AVOID for ingestion: scrapegraphai (requires py≥3.12; we're 3.11; nondeterministic), crawl4ai (browser+litellm+torch-heavy), trafilatura (discards tables — it's for the news/RAG line, not registers), tabula-py (needs a JVM), datasette (publish, not ingest).
3 — Proxy / clean-IP (for ASN-reputation blocks like MV)¶
tourism.gov.mv 403s us even with a stealth headed browser because the block is
ASN/subnet reputation (datacenter IP), which fingerprinting cannot fix. The
honest landscape:
- FIRST, $0 — look for the API / open-data backend, or request allowlisting.
This is the DMW
master-api.dmw.gov.phpattern already in this project. A public.govlicensed-entity register is public-interest data; an allowlist request from a named anti-trafficking project is the cleanest, most durable fix. Try this on MV before spending anything. - The real technical fix — a cheap PAYG residential proxy behind a tiny custom
rotator. Real ISP IPs are scored low-risk by CF. Picks (pricing approx, verify):
Evomi (~\(0.49/GB, genuine no-card free trial → start here), **IPRoyal** (1 GB
non-expiring, ideal for bursty civic scraping), **PacketStream / DataImpulse**
(strong consent-based sourcing ethics — load-bearing for *this* project). ~\)0–5/mo
at our volume. Integration is just
user:pass@host:port. - Free options — one quick probe each, expect to fail: Tor (every exit IP is on
a list CF ingests → usually challenged;
torproject/stemis the NEWNYM lever, curl_cffi viasocks5h://127.0.0.1:9050keeps TLS impersonation), Windscribe-free / ProtonVPN-free (datacenter IPs, brand-flagged). Never route a CF target through Cloudflare WARP — it egresses Cloudflare's own AS13335, which CF-fronted sites block. - AVOID — free public-proxy pools entirely: ~all are datacenter IPs already
CF-scored high-risk, 95%+ dead in a day, and carry MITM/honeypot risk that is
unacceptable for an anti-trafficking tool. (Borrow the
jhao104/proxy_poolgetter→tester→API architecture only, pointed at a paid residential source.) - Rotation: write ~15 lines, don't take a dependency — every middleware lib loses to a small provider-agnostic rotator on a fragile Windows box.
Integration shape (provider-agnostic)¶
The IP source is what you pay for; the wiring is trivial and identical across sources — only the egress IP changes, the TLS impersonation stays:
# curl_cffi (any http/https/socks5h proxy):
requests.get(url, proxies={"https": "http://user:pwd@host:port"}, impersonate="chrome")
# Playwright/Edge (note: Chromium ignores inline user:pass and SOCKS-with-auth):
chromium.launch(channel="msedge", proxy={"server": "http://host:port",
"username": "user", "password": "pwd"})
DUECARE_PROXY) in the fetch tier so a
residential proxy or Tor can be slotted in without code changes.
The integrated fetch ladder (after this session)¶
urllib→ 2.curl_cffi(TLS impersonation) → 3.patchright/Playwright Edge (headless) →fetch_via: agenticheaded patchright → 4. (+ optionalDUECARE_PROXYresidential IP at any tier) → 5. screenshot + Gemma-4 vision (llm_scrape.vision_extract) → 6. agentic Gemma-4 browser (agentic_browse).
Operating principle unchanged: escalate only as far as the source forces, prefer deterministic tiers, request official access before fighting IPs, and never fabricate a blocked fetch — log it, drop a tier, or leave it pending.
4 — Company-registry / source-list goldmine: OpenSanctions (built)¶
The highest-leverage find from the company-registry research pass is not a library
but a curated source list. opensanctions/opensanctions (MIT code/metadata;
the consolidated data is CC-BY-NC) maintains ~445 per-registry crawler definitions
under datasets/<cc>/<name>/<name>.yml across 97 country dirs. Each YAML names a
real official SOURCE — landing url, data.url + data.format endpoint,
publisher (with an official flag + country), update frequency, and classifying
tags (list.sanction / list.pep / list.debarment / reg.warn / …). That is a
ready-made directory of government registries and screening lists we can fold into
our own catalog.
Built — scripts/harvest_opensanctions_sources.py (+10 tests on verbatim
GPPB/GovHK fixtures): reads the metadata YAMLs from a local clone, classifies each
(registry / debarment / sanctions / pep / regulatory / other), maps it to our
catalog-source shape, and writes a propose-only staging file under
reports/opensanctions_sources/ (gitignored). It never downloads the CC-BY-NC
entity data — only the source pointers (MIT metadata) — and never touches the live
catalog; merge is the separate explicit merge_entity_sources.py step.
Live result against the real clone (2026-06-18): 400 source pointers, 371 with a
usable data endpoint, 347 official — 49 direct registries/debarment/regulatory
(e.g. AU Sanctions on Sponsors of Skilled Foreign Worker Visas, Brazil CEIS
disreputable/suspended companies, Cyprus company registry, Czech Business
Register) plus 199 PEP/sanctions screening lists that feed entity_screen.py /
adverse_media.py. Run:
gh repo clone opensanctions/opensanctions ../opensanctions # ~445 YAMLs
python scripts/harvest_opensanctions_sources.py --clone ../opensanctions --stats
python scripts/harvest_opensanctions_sources.py --clone ../opensanctions \
--out reports/opensanctions_sources/proposed_sources.yaml # propose-only
Onboarded as runnable specs (verified live). The harvest gives data.url +
format but not the field map (that's in OpenSanctions' crawler.py, not the
metadata), so onboarding is: harvest → fetch the endpoint → author the field map
against the real response → test it resolves. 9 onboarded into
registry_specs.yaml (= 22,729 entities), each proven against the live source:
| spec id | entities | what |
|---|---|---|
tw_strategic_trade_entities |
11,655 | TW MOEA strategic high-tech-commodity entity list (official CSV) |
si_kpk_business_restrictions |
7,927 | SI businesses barred from public-sector contracting (KPK JSON) |
afdb_debarred |
1,334 | African Development Bank debarred entities (official HTML table) |
us_special_legislative_excl |
515 | US statutory exclusions — Huawei etc. (crawled Google Sheet CSV) |
br_bcb_disqualified |
426 | BR persons disqualified from senior financial roles (BCB Gepad OData) |
us_dod_chinese_military |
397 | US DoD §1260H Chinese military companies (crawled Google Sheet CSV) |
no_nbim_exclusions |
205 | Norwegian sovereign-fund (NBIM) exclusion list (official HTML table) |
ohchr_settlement_companies |
158 | UN OHCHR settlement-linked business enterprises (crawled Google Sheet CSV) |
ph_gppb_blacklist |
112 | PH suppliers debarred from govt procurement (GPPB CBR JSON) |
All auto-wire into the acquisition cascade (--registry <id>; 29 registries total).
Provenance is honest per spec: official-portal (TW/AfDB/NBIM/PH/SI/BR) vs.
OpenSanctions-crawled published Google Sheet (US/OHCHR). The remaining endpoints are
scaffolded as a verification work-queue by scripts/opensanctions_to_specs.py
(+8 tests; excludes already-promoted endpoints by url so the queue shrinks as we go):
it fills url/format/entity_type/jurisdiction from the harvest and leaves fields: an
explicit TODO + _needs_verification: true (never a fabricated map), writing
propose-only reports/opensanctions_sources/draft_specs.yaml. Queue after batch 2:
332 structured endpoints (151 html_table, 84 json, 57 csv, 27 xlsx, 13 pdf_table);
22 registry/debarment/regulatory remain. Probed-but-skipped (no fabrication): AU
ABF / GB PSC bulk / CA / SHU / GE / TJ / FinCEN / DC (JS-rendered or narrative, no
parseable table), GB-disq / GB-FCA / SEC / WorldBank / IADB / Kosovo / EBRD (401/403/
406/500), FR AMF (entity in free text), CY / US-SAM (two-step download), ADB / SK
(empty / needs OData pagination). Promote a draft by fetching it, mapping the keys,
and moving the block into registry_specs.yaml.
Adjacent, not yet built: GLEIF LEI Golden Copy (CC0 bulk; canonical entity-id to join registries on) and nomenklatura / followthemoney (MIT; entity dedup + screening data model) — both clean adoptions for the screening side.
Reusable capability — the tooling scout (built)¶
So the next "find proven tools/repos for X" pass is a repeatable command, not a
bespoke research run: scripts/tooling_scout.py (gh search across many
queries → score vs these constraints → ranked ADOPT/CONSIDER/AVOID with blocker
flags) driven by the tooling-scout agent (.claude/agents/tooling-scout.md,
invoke via the Agent tool) which gh+web-verifies the top picks and cross-checks this
doc so it never re-pitches what we already adopted.
5 — Pre-OCR image enhancement (built, ported from OpenSearch-VL)¶
shawn0728/OpenSearch-VL (222★, Apache-2.0, active — a Qwen3-VL multimodal
deep-search agent recipe) is AVOID as a stack for us: Qwen3-VL-centric (we're
Gemma 4), and its Megatron/verl/sglang training needs multi-GPU on 8–32B —
impossible on the box, violates "no large model downloads". But its tool
environment is a clean PORT: the agent recovers from imperfect inputs with
crop / perspective_correct / super_resolution / sharpen before reading
them. We had the opposite gap — the process/extraction harness queued blurry, skewed
document photos straight to Gemma-4 vision.
Built — scripts/image_enhance.py (+16 tests): deterministic, CPU-only,
quality-gated enhancement on the OpenCV camelot already pulls in (no new dep, no
model). quality_report() measures blur (variance of Laplacian), skew, resolution,
contrast; enhance_for_ocr() applies only what's needed — deskew /
denoise+sharpen / clahe (contrast) / Lanczos upscale — plus crop and 4-point
perspective_correct. Every op returns a new array (immutability) and degrades to a
logged no-op if cv2 is absent. enhance_b64() sits exactly at the
llm_scrape.vision_extract base64 boundary and is wired in as an opt-in
scrape_page(..., enhance_image=True) step that records vision_enhanced_ops and
can never break the vision path. Live proof on a degraded synthetic scan: skew
−7.2°→0.1°, 460×300→1533×1000, contrast 4.3→13.3. This also enriches the Gemma-4
multimodal + function-calling story: the agent gains crop/enhance tools to
recover a bad input before answering.
NOTE for a future training pass: OpenSearch-VL's fatal-aware GRPO (mask tokens after a fatal tool failure instead of penalizing the whole rollout) is a good idea for our agentic trajectory curation; its open SearchVL-SFT-36k / RL-8k datasets (Apache-2.0) are reference tool-use data, though general-VQA not trafficking.