operator field manual
Learn the techniques
Every technique from the talk — description, working code, and a worked example — mapped to the target it defeats. Code is verified against current (2026) library APIs. Use it only against targets you're authorized to scrape.
Orientation
You submit the data, not the code. A validation engine scores what you extract against hidden ground truth. Every defense here is a real-world scraping obstacle, beatable by exactly the techniques below.
Rules of engagement
Only scrape targets you are authorized to. greynet's seven sites are synthetic practice targets built for this — nothing here touches a real third party. Register a team, get a bearer token, beat the defenses, POST your scraped JSON to /api/submit/<challenge>.
How you're scored
Completeness (primary): records × fields matched vs ground truth — OCR/text fields use fuzzy matching, so minor OCR errors still earn partial credit. Speed (tiebreak): first authenticated request → best submission. Stealth (modifier): request-timing humanization; hammering or perfectly-periodic traffic grades poorly.
Submit the WHOLE dataset as one JSON. Partial dumps score only what they contain — the response tells you `matched X / N`.
Architect Execution Pipelines
Pick the cheapest tool that works: a raw HTTP client against the hidden API beats a browser every time — until the site needs JavaScript or a real TLS fingerprint. Escalate only as far as you must.
Direct API requests (the fast path)
Most sites render from a JSON endpoint their own front-end calls. Open DevTools → Network, find it, and hit it directly — no browser needed. This is the single highest-leverage move in scraping.
import requests
# The page is JS-rendered, but the data is here:
r = requests.get(
"https://target.example/api/megacorp/listings",
params={"region": "apac", "page": 1},
headers={"Authorization": "Bearer <token>", "Cookie": "og_session=..."},
)
for job in r.json()["items"]:
print(job["uuid"], job["title"])curl_cffi — impersonate a browser's TLS/JA3 fingerprint
Plain requests/httpx have a Python TLS fingerprint that anti-bot systems flag instantly. curl_cffi sends Chrome's exact TLS/JA3 + HTTP/2 fingerprint, so a header-screened API treats you like a real browser — without the cost of running one.
from curl_cffi import requests
# Impersonate the latest Chrome (TLS/JA3 + HTTP2 + headers):
r = requests.get("https://target.example/api/specimen/dossier/<uuid>",
impersonate="chrome") # or "chrome124", "safari17_0", ...
print(r.json())
# A Session keeps cookies AND the impersonated fingerprint across requests:
s = requests.Session(impersonate="chrome")
s.get("https://target.example/") # collect cookies
data = s.get("https://target.example/api/asic/lots").json()Use `impersonate="chrome"` for the newest profile; pin a version (e.g. `chrome124`) for reproducibility. This is how you beat the Specimen/ASIC fingerprint gates from a plain script.
Playwright — a real browser for JS-rendered content
When data only appears after JavaScript runs (AJAX tables, detail popups), drive a real browser. Playwright is the modern standard across Python/Node.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://target.example/salvage")
page.wait_for_selector("table tbody tr")
rows = page.locator("table tbody tr")
for i in range(rows.count()):
print(rows.nth(i).inner_text())
browser.close()Patchright — undetected, drop-in Playwright
Vanilla Playwright leaks automation via the Chrome DevTools Protocol; many WAFs detect it. Patchright is a patched drop-in — change only the import. For maximum stealth its docs recommend a persistent context with the real Chrome channel, headed.
from patchright.sync_api import sync_playwright # only the import changes
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir="/tmp/pw-profile",
channel="chrome", # real Chrome, not bundled Chromium
headless=False, # headed is far less detectable
no_viewport=True,
)
page = ctx.new_page()
page.goto("https://target.example")Headed Chrome on a server with Xvfb
Headless mode has tells. To run a *headed* browser on a server with no display, wrap it in Xvfb (a virtual framebuffer). Launch the browser with headless=False.
# Run a headed browser on a headless box:
xvfb-run -a --server-args="-screen 0 1920x1080x24" python scrape.py
# inside scrape.py: p.chromium.launch(headless=False)Speed reality check: headless is usually FASTER, not slower. It skips compositing/paint and the display pipeline, so on a recurring million-record job the throughput gap is large. Headed-via-Xvfb is a *detection* tool, not a performance one — reach for it only when a target actually flags headless (the OmniCorp nudge, fingerprint gates). Default to headless; the wall-clock you feel scraping is the origin's latency (slow servers, streamed/drip responses, full-screen loaders), not your render mode. Scale workers to hide that latency — don't switch to headed hoping it's quicker.
Stealth & Behavior
Looking like a browser isn't enough — you must behave like a human. Spoof a coherent fingerprint and emulate real mouse/scroll/idle behavior before you touch protected endpoints.
Spoof a coherent browser fingerprint
A real visitor has a consistent UA, locale, timezone, viewport and geolocation. Mismatches (a 'US tourist' on a Manila timezone) get flagged. Set them all on the Playwright context.
ctx = browser.new_context(
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
locale="en-US",
timezone_id="America/Los_Angeles", # match your proxy's country
viewport={"width": 1920, "height": 1080},
geolocation={"latitude": 36.17, "longitude": -115.14}, # Las Vegas
permissions=["geolocation"],
extra_http_headers={"Accept-Language": "en-US,en;q=0.9"},
)
# Some sites read navigator.webdriver — hide it (Patchright does this for you):
ctx.add_init_script("Object.defineProperty(navigator,'webdriver',{get:()=>undefined})")Humanized mouse wander
Behavioral gates reject instant jumps, perfectly straight lines, and perfectly periodic timing. Move in a curved path with jitter and a variable cadence.
import random, time
def human_move(page, x0, y0, x1, y1, steps=40):
for i in range(steps):
t = i / steps
x = x0 + (x1 - x0) * t + random.uniform(-6, 6) # curve + jitter
y = y0 + (y1 - y0) * t + random.uniform(-6, 6)
page.mouse.move(x, y)
time.sleep(random.uniform(0.012, 0.05)) # variable cadenceScroll + idle reading ('floor surveillance')
Some gates require evidence you browsed before hitting the data — scrolling and dwelling. Emulate it, then make the protected call.
for _ in range(6):
page.mouse.wheel(0, random.randint(200, 500)) # scroll the page
time.sleep(random.uniform(0.4, 1.2)) # idle-read
time.sleep(4) # dwell before the cageManage Digital Identities
Rotate IPs to survive per-IP limits and reach geo-locked content, build session trust the way a real user does, and burn through email-verified signups with disposable inboxes.
Proxy rotation (datacenter / mobile / residential)
A site that blocks an IP after N hits forces you to rotate egress IPs. Residential/mobile pools assign a fresh IP per request; datacenter pools are cheaper but easier to flag. Rotate from a pool and back off on failure.
import itertools, requests
POOL = [
"http://user:pass@p1.residential.example:8000",
"http://user:pass@p2.residential.example:8000",
# free lists (free-proxy-list.net, ...) work too — just flakier
]
rotor = itertools.cycle(POOL)
def fetch(url):
proxy = next(rotor)
return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=20)ctx = browser.new_context(proxy={
"server": "http://p1.example:8000", "username": "user", "password": "pass",
})Session trust-building (cookie prefetch)
Don't jump straight to the data. Visit the pages a real user would first to collect the cookies the protected endpoint requires — then carry them on every request.
s = requests.Session()
s.get("https://target.example/megacorp") # sets og_session
for hotel in ("mirage-royale", "oasis-grand", "zenith-tower"):
s.post(f"https://target.example/api/casino/partner/{hotel}") # comp cookies
# now the ledger trusts the session:
ledger = s.get("https://target.example/api/casino/ledger?page=1").json()Burner emails to clear signup walls
Email-verified registration is beaten with a disposable inbox: register with a throwaway address, poll the inbox for the code, verify. Use any online temp-mail (mailinator, temp-mail.org, mail.tm's API).
import requests, re
BASE = "https://target.example"
# 1) solve the signup CAPTCHA image (see §05), then register:
requests.post(f"{BASE}/api/megacorp/account/register", json={
"email": "ghost@temp-mail.org", "password": "p@ssw0rd",
"captchaId": cid, "captchaText": solved})
# 2) read the code from your burner inbox (here, the site's own inbox API):
msgs = requests.get(f"{BASE}/api/mail/inbox",
params={"address": "ghost@temp-mail.org"}).json()["messages"]
code = re.search(r"\b(\d{6})\b", msgs[0]["text"]).group(1)
# 3) verify + sign in:
requests.post(f"{BASE}/api/megacorp/account/verify",
json={"email": "ghost@temp-mail.org", "code": code})Browser Searching
Find targets and enrich data without burning paid Google/Serper credits — self-host a metasearch engine or use a free library.
ddgs (formerly duckduckgo_search)
A free metasearch library — no API key. The duckduckgo_search package was renamed to ddgs; the API is the same.
from ddgs import DDGS
for hit in DDGS().text("deep-sea salvage shipwreck registry", max_results=5):
print(hit["title"], hit["href"], hit["body"])SearXNG (self-hosted metasearch)
Run your own metasearch engine (Docker) and query it over HTTP. JSON output is disabled by default — enable it in settings.yml before scripting against it.
search:
formats:
- html
- json # required for the JSON API belowimport requests
r = requests.get("http://localhost:8080/search",
params={"q": "RMS Republic wreck coordinates", "format": "json"})
for hit in r.json()["results"][:5]:
print(hit["title"], hit["url"])Bypass Automated Verification
CAPTCHAs are solvable programmatically: transcribe audio challenges with Whisper, OCR text/arithmetic challenges, and classify image-grid challenges with a vision model.
Audio CAPTCHA → ffmpeg + Faster Whisper
Audio challenges (reCAPTCHA's audio option, or our 'intercept' clips) are speech. Normalize with ffmpeg to 16 kHz mono, then transcribe with Faster Whisper (a CTranslate2 reimplementation — ~4× faster than openai-whisper).
import subprocess
from faster_whisper import WhisperModel
subprocess.run(["ffmpeg", "-y", "-i", "intercept.wav",
"-ar", "16000", "-ac", "1", "clean.wav"], check=True)
# device="cuda", compute_type="float16" on GPU; "cpu"/"int8" works too.
model = WhisperModel("large-v3", device="cpu", compute_type="int8")
segments, info = model.transcribe("clean.wav", beam_size=5)
transcript = " ".join(s.text for s in segments).strip()
print(transcript) # -> "victor echo zulu kilo"Google Cloud Speech-to-Text is the managed alternative (`speech.SpeechClient().recognize(...)`) if you'd rather not run a model. Faster Whisper is free and offline.
Text / arithmetic CAPTCHA → OCR
Distorted-text and 'x + y = ?' challenges are images: OCR them, then read the characters or compute the sum.
import pytesseract, re
from PIL import Image
txt = pytesseract.image_to_string(Image.open("challenge.png"))
nums = list(map(int, re.findall(r"\d+", txt))) # "9 + 9 = ?" -> [9, 9]
answer = nums[0] + nums[1] # -> 18Image-grid challenge → CLIP
For 'select all squares with a bus' style challenges, a vision-language model scores each tile against text labels and picks the best match — zero-shot, no training. CLIP (OpenAI) uses a softmax over labels: the probabilities sum to 1, so it answers 'which one'.
import torch, clip
from PIL import Image
device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-B/32", device=device)
img = preprocess(Image.open("tile.png")).unsqueeze(0).to(device)
labels = ["a bus", "a traffic light", "a crosswalk", "a bicycle"]
text = clip.tokenize([f"a photo of {l}" for l in labels]).to(device)
with torch.no_grad():
logits_per_image, _ = model(img, text)
probs = logits_per_image.softmax(dim=-1).cpu().numpy()[0] # softmax: sums to 1
print(labels[int(probs.argmax())])Image challenge → SigLIP (sigmoid, better zero-shot)
SigLIP (Google) replaces CLIP's softmax with a sigmoid loss, so each label gets an INDEPENDENT probability — it answers 'is X present?' per tile, which is exactly what 'select all squares with a bus' needs (multiple tiles can match). It usually beats CLIP at zero-shot. SigLIP 2 adds stronger multilingual encoders. Run it via 🤗 Transformers.
import torch
from transformers import AutoProcessor, AutoModel
from PIL import Image
model = AutoModel.from_pretrained("google/siglip2-base-patch16-224")
processor = AutoProcessor.from_pretrained("google/siglip2-base-patch16-224")
image = Image.open("tile.png")
labels = ["a bus", "a traffic light", "a crosswalk", "a bicycle"]
# SigLIP was trained with this template + padding="max_length":
inputs = processor(text=[f"a photo of {l}" for l in labels],
images=image, padding="max_length", return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
# sigmoid (NOT softmax) -> independent per-label probabilities:
probs = torch.sigmoid(outputs.logits_per_image)[0]
present = [l for l, p in zip(labels, probs) if p > 0.5] # select ALL matches
print(present, "| best:", labels[int(probs.argmax())])Rule of thumb: CLIP softmax → pick the single best label; SigLIP sigmoid → threshold each label independently (ideal for multi-select grids). `google/siglip-base-patch16-224` is the v1 id.
Extract High-Value Data (OCR)
Sites hide prices, emails, coordinates and IDs inside images and scanned PDFs to deter scrapers. OCR lifts the text back out — strictly for data you're authorized to extract.
Tesseract (pytesseract)
The classic OCR engine — fast, tiny, great on clean rasterized text like our salary/slip/chart images.
import pytesseract
from PIL import Image
text = pytesseract.image_to_string(Image.open("salary.png"))
print(text) # "$109,000 - $178,000 / h.recruiter@omnicorp.example"RapidOCR (ONNX, robust on noisy images)
An ONNX-Runtime OCR toolkit that's more robust than Tesseract on low-contrast / noisy scans — ideal for the muddy sonar logs and watermarked surveillance slips.
from rapidocr_onnxruntime import RapidOCR
engine = RapidOCR()
result, elapse = engine("sonar.png")
for box, text, score in (result or []):
print(text, round(score, 2)) # "LAT -22.706", "LON 73.705", ...The newer meta-package is `pip install rapidocr` (`from rapidocr import RapidOCR`), which can swap ONNX/OpenVINO/Torch backends. `rapidocr-onnxruntime` shown above is the stable, widely-used build.
Scanned PDFs & SVG-rendered fields — rasterize → OCR
PyMuPDF (import name fitz) extracts a PDF's text layer instantly. Image-only 'scanned' PDFs have no text layer — detect that, rasterize the page, then OCR it. Real-world targets lean on scanned PDFs constantly.
import fitz # PyMuPDF
import pytesseract
from PIL import Image
import io
doc = fitz.open("scan.pdf")
page = doc[0]
text = page.get_text()
if not text.strip(): # image-only (scanned) PDF
pix = page.get_pixmap(dpi=200)
img = Image.open(io.BytesIO(pix.tobytes("png")))
text = pytesseract.image_to_string(img)
print(text)greynet renders its OCR-only fields (agent emails, vitals charts, comp slips, settlement docs, dossiers) as **runtime SVG** drawn from glyph *outlines* — there's no selectable text in the markup, so reading the <svg> source gets you nothing. Same move as a scanned page: rasterize the SVG, then OCR the raster.
import cairosvg, pytesseract, io, requests
from PIL import Image
svg = requests.get(f"{BASE}/api/syndicate/dossier/{uuid}").content
png = cairosvg.svg2png(bytestring=svg, scale=2) # rasterize (or use resvg / a headless screenshot)
text = pytesseract.image_to_string(Image.open(io.BytesIO(png)))
print(text) # "REAL NAME ... STANDING BOUNTY $260,000"Numbers render with thousands separators ($260,000) and some fields are redacted ("——") — strip commas before casting, and submit an empty value for a redaction rather than guessing.
Orchestrate & Scale
A single sequential worker can't finish in time — but hammering trips the breaker. Scale with capped concurrency + exponential backoff, then fan out across processes and VMs, each with its own identity.
Capped concurrency + exponential backoff
Run many requests at once, but cap them with a semaphore so you don't trip the fragile-origin breaker. On 429/503, back off exponentially with jitter and retry — never hammer.
import asyncio, random, httpx
async def fetch(client, url, sem):
async with sem:
for attempt in range(5):
r = await client.get(url)
if r.status_code in (429, 503):
await asyncio.sleep(2 ** attempt + random.random()) # backoff + jitter
continue
return r.json()
async def main(urls):
sem = asyncio.Semaphore(8) # tune below the breaker threshold
async with httpx.AsyncClient(timeout=30) as client:
return await asyncio.gather(*(fetch(client, u, sem) for u in urls))When (and why) to scale — the ladder
Don't reach for VMs first. Climb the ladder only when the rung below it is the actual bottleneck — each step adds cost and operational surface.
- 1.
Single worker, sequential — correct first. If it finishes in time, stop here.
- 2.
Concurrency (async + semaphore) — when latency-bound: the origin is slow / drip-streamed and you're waiting, not computing. One IP, many in-flight requests, capped under the breaker.
- 3.
Multi-process — when CPU-bound: OCR, CLIP/SigLIP, and PoW solving saturate a core. Fan across cores on one box.
- 4.
Multi-VM — when IP-budget-bound: you've hit the per-(team, IP) rotation/rate gate, or need geo-diverse egress. Each VM carries its own identity + proxy.
Diagnose before you climb: if doubling concurrency doesn't speed you up, you're not latency-bound — adding VMs won't help either. The challenges here are mostly latency-bound (clunky origins, drip-streamed registries, full-screen loaders), so rung 2 buys the most; rung 4 only matters once a rotation/geo gate caps a single IP.
Single worker → multi-VM
When one box (or one IP budget) isn't enough, fan out: a coordinator pushes work to a shared queue; N workers across VMs pull it, each behind its own proxy/identity so no single (token, IP) trips the rotation gate.
import redis, json
r = redis.Redis(host="coordinator.internal", port=6379)
# producer (once):
for uuid in all_uuids:
r.lpush("queue:lots", uuid)
# worker (run on each VM, with a DISTINCT proxy/identity):
while (item := r.rpop("queue:lots")):
data = scrape_lot(item.decode(), proxy=MY_PROXY) # your scraper + your IP
r.lpush("results", json.dumps(data))Distributing across IPs is exactly how you beat the per-(team, IP) rotation gate at speed: each VM/proxy gets its own fresh request budget.
Orchestrate & monitor the fleet over SSH
A control node provisions the worker VMs, ships code + a per-VM identity (proxy, locale, fingerprint seed), starts the workers, and watches their health — all over SSH. Keep it boring: one command to deploy, one stream to monitor. asyncssh fans the same op across every host concurrently.
import asyncio, asyncssh
HOSTS = ["10.0.0.11", "10.0.0.12", "10.0.0.13"] # worker VMs
PROXIES = {h: f"http://proxy-{i}.pool:8000" for i, h in enumerate(HOSTS)}
async def deploy(host):
async with asyncssh.connect(host, username="ops") as c:
await asyncssh.scp("worker.py", (c, "/opt/worker.py")) # ship code
# each VM gets a DISTINCT egress identity -> beats the rotation gate
await c.run(f"COORD=coordinator.internal PROXY={PROXIES[host]} "
f"nohup python /opt/worker.py >/var/log/w.log 2>&1 &", check=True)
return f"{host}: launched"
print(await asyncio.gather(*(deploy(h) for h in HOSTS)))Monitoring is the other half: tail each worker's heartbeat (rate, error %, last-success age) so a wedged or blacklisted VM is visible immediately — not three hours into a million-record run. Pull the queue depth + per-host counters the workers push to Redis.
import asyncssh, asyncio
async def health(host):
async with asyncssh.connect(host, username="ops") as c:
r = await c.run("tail -n1 /var/log/w.log", check=False)
return host, r.stdout.strip()
async def watch():
while True:
rows = await asyncio.gather(*(health(h) for h in HOSTS))
for host, line in rows:
print(f"[{host}] {line}") # rate / errors / last-ok age
await asyncio.sleep(10) # 10s poll, jittered in practiceTreat a flagged VM like a flagged identity: rotate it out (new proxy / fresh box), don't retry through the same egress and deepen the ban. The control node should drain that host's in-flight work back to the queue so no records are silently dropped.
Defeat Payload & Markup Tricks
Real sites don't hand you clean JSON. They base64/XOR the payload, drip it over a slow stream, charge CPU at the door with proof-of-work, salt the HTML with honeypots, and leave fields blank. None of it is real protection — each is reversible with a few lines. Here's how to read through all of it.
Deobfuscate the payload (base64 / XOR)
Obfuscation is encoding, not encryption — the client must be able to read it, so you can too. Spot it in the response shape: an encoding/alg field and a single opaque data/enc blob. base64 is a straight decode. XOR-with-a-known-key (here the record's own uuid) is a byte-wise loop — fully reversible because the key ships with the data.
import base64, json
# Specimen: { "encoding": "base64", "data": "<blob>" }
body = session.get(f"/api/specimen/dossier/{uuid}", headers=fp).json()
dossier = json.loads(base64.b64decode(body["data"]))
# ASIC: { "alg": "xor-b64", "enc": "<blob>" } — key is the lot uuid
def xor_b64(enc: str, key: str) -> dict:
raw = base64.b64decode(enc)
out = bytes(b ^ ord(key[i % len(key)]) for i, b in enumerate(raw))
return json.loads(out)
detail = xor_b64(resp["enc"], lot_uuid) # buyer id, clearing price, documentsThe browser does the exact same reversal in JS (`atob` + a charCodeAt XOR loop) — read the site's own client code to recover the scheme, then port those few lines to your scraper.
Drain a simulated-slow stream (NDJSON)
A clunky origin doesn't return the page at once — it drips records line-by-line over a long-lived response. Don't .json() it (that buffers and stalls); stream it and parse each NDJSON line as it lands. You must read to completion to get every record, and the per-record delay is wall-clock you hide with concurrency across pages — not by switching to a headed browser.
import requests, json
records = []
with requests.get(url, headers=hdrs, stream=True, timeout=120) as r:
for line in r.iter_lines(): # arrives one wreck at a time
if not line:
continue
obj = json.loads(line)
if "stream" in obj: # first line is meta {page,totalPages,total}
continue
records.append(obj) # drip — keep reading to the endSolve proof-of-work at the door (ALTCHA · Argon2id)
A PoW gate makes every client burn CPU before it serves data — a hashcash challenge. This site runs ALTCHA with the **Argon2id** key-derivation (not the SHA-256 default): the server publishes a salt, nonce, the Argon2 cost parameters, and the key *prefix* of a secret counter; you brute-force counters — running Argon2id over nonce‖uint32be(counter) — until the derived key matches, then submit the winning counter for a clearance cookie. Argon2id is *memory-hard* (~19 MB per attempt), so a GPU/ASIC farm can't out-run a normal browser; read the cost params off the challenge instead of hard-coding them.
import requests, struct
from argon2.low_level import hash_secret_raw, Type # pip install argon2-cffi
s = requests.Session()
ch = s.get("/api/casino/pow").json() # {parameters, signature}
p = ch["parameters"]
salt, nonce, prefix = bytes.fromhex(p["salt"]), bytes.fromhex(p["nonce"]), p["keyPrefix"]
def derive(counter): # mirror the server's KDF exactly
return hash_secret_raw(
secret=nonce + struct.pack(">I", counter), # message = nonce ‖ uint32be(counter)
salt=salt,
time_cost=p["cost"], memory_cost=p["memoryCost"],
parallelism=p["parallelism"], hash_len=p["keyLength"], type=Type.ID,
)
def solve():
for counter in range(0, 5_000): # ~tens of attempts; each is memory-hard
key = derive(counter)
if key.hex().startswith(prefix):
return counter, key.hex()
counter, derived = solve()
s.post("/api/casino/pow",
json={"challenge": ch, "solution": {"counter": counter, "derivedKey": derived}})
# pow_ok cookie is now set on the session -> the ledger opens
bets = s.get("/api/casino/ledger?page=1").json()In a browser the same work runs over a WASM Argon2id (a few dozen memory-hard attempts, ~2 s). Solve once, reuse the cookie for the whole run — re-solving per request is the trap the gate is counting on.
See through HTML honeypots & obfuscated markup
Hard-to-parse HTML salts the DOM with decoy rows (hidden by CSS) and wedges junk text into real values, so a naive tbody tr + textContent scrape ingests garbage and a SP-\d+ regex mis-captures. Three robust fixes: (1) prefer the clean JSON API the table is built from; (2) read *rendered* text (inner_text), which honors display:none; (3) if you must parse raw HTML, drop nodes that aren't displayed before extracting.
from bs4 import BeautifulSoup
import re
soup = BeautifulSoup(html, "html.parser")
# 1. skip decoy rows (hidden via opaque utility classes)
for junk in soup.select(".idx-decoy, .tok-x"):
junk.decompose()
rows = []
for tr in soup.select("tbody tr"):
cells = [td.get_text(strip=True) for td in tr.select("td")]
if not cells:
continue
rows.append(cells)
# real designations now read clean: SP-3001, not SP-30<junk>01# inner_text() excludes display:none honeypots; visible rows only
for tr in page.locator("tbody tr:visible").all():
print(tr.inner_text()) # decoys + junk fragments never appearThe decoy rows carry fake uuids that match no record, so even if you do ingest them the scorer drops them — the only cost is wasted requests if you go on to fetch their detail pages. Filter early.
Page through ASP.NET __VIEWSTATE / __doPostBack
Classic WebForms registries (Secretary-of-State business search, court dockets) have no ?page=N. The current search lives in an opaque __VIEWSTATE hidden field you must echo back on every postback, validated by an __EVENTVALIDATION tag. To turn the page: scrape both hidden fields out of the HTML and re-POST them with __EVENTARGUMENT=Page$Next. Hand-editing the page number fails the tag — round-trip the blob verbatim.
import requests, re
from bs4 import BeautifulSoup
s = requests.Session() # (clear the search CAPTCHA first; route via a US proxy)
html = s.post(URL, data={"txtBusinessName": "", "SearchType": "StartsWith",
"ddlNameType": "Business Name"}).text
def hidden(html, name):
return re.search(rf'name="{name}"\s+value="([^"]*)"', html).group(1)
rows = []
while True:
soup = BeautifulSoup(html, "html.parser")
rows += [[td.get_text(strip=True) for td in tr.select("td")] for tr in soup.select("tbody tr")]
m = re.search(r"page <b>(\d+)</b> of <b>(\d+)</b>", html)
cur, last = (int(m[1]), int(m[2])) if m else (1, 1)
if cur >= last:
break
html = s.post(URL, data={ # re-POST the scraped state
"__VIEWSTATE": hidden(html, "__VIEWSTATE"),
"__EVENTVALIDATION": hidden(html, "__EVENTVALIDATION"),
"__EVENTARGUMENT": "Page$Next",
}).textThe registry runs to 100k entities — sequential postback paging is a single worker's whole afternoon. This is the canonical place to fan out across workers / VMs (split the alphabet or the page range) and merge.
Handle sparse & incomplete records honestly
Not every record is complete: some wrecks have unknown coordinates (the sonar log reads "———"), some charts have a redacted vital, some dossiers have no protocol on file. The ground truth for those fields is empty — so leave them empty. The scorer gives full credit for a correctly-empty field and ZERO for inventing a value. Don't hallucinate to fill a blank.
def parse_coord(ocr_text: str):
m = re.search(r"-?\d+\.\d+", ocr_text)
return float(m.group()) if m else "" # "———" -> "" (matches empty truth)
wreck["latitude"] = parse_coord(lat_text)
wreck["longitude"] = parse_coord(lon_text)
# A confident wrong number scores 0; an honest blank scores 1.All data is synthetic. greynet teaches these techniques for authorized scraping education only.