33 KiB
Phase 3e — Stealth hardening (the in-house "undetectable" moat)
Part of Phase 3 — WebURL Crawler & Crawl Billing. See
00-umbrella-plan.md. Depends on03a(Scrapling StealthyFetcher tier +CrawlOutcome) and03b(app-wide proxy provider). Precedes03d(captcha) in the escalation order — hardening avoids challenges; captcha solving is the paid last resort for the ones we can't avoid. Touches03conly via the deferred paid-unblocker tier (its own billing, decided if/when built).
Architecture reconciliation (read before building). Earlier drafts of this plan referenced a
FetchStrategyseam / strategy-object contract(url, ctx) -> CrawlOutcome. That seam does not exist in shipped code.03a–03dshipped a hardcoded 3-tier ladder insideWebCrawlerConnector.crawl_url(app/proprietary/web_crawler/connector.py): plain methods_crawl_with_async_fetcher/_crawl_with_dynamic/_crawl_with_stealthy(_sync)wrapped by_run_tier_with_proxy_retry, every tier returning the sharedCrawlOutcome.03etherefore implements levers not as strategy classes but as: (1) a centralized per-tier kwargs builder (app/proprietary/web_crawler/stealth.py— proprietary, since it's bypass-specific tuning; the generic block classifier stays Apache-2 inapp/utils/crawl/) that turns config →StealthyFetcher/AsyncFetcherkwargs, imported by both the connector and03f's harness so there's no test-vs-prod drift; (2) small helpers around the existing ladder (block classifier, strategy memory); and (3) the deferred paid tier as a config-gated extra ladder step, not aFetchStrategy. The captcha precedent holds: generic config/plumbing →app/utils/; actual bypass logic (WebGL spoof JS, humanize choreography) →app/proprietary/web_crawler/.
Implementation note. Same convention as
03a–03d: Phases 1–2 are SHIPPED (live code saysworkspace_id/Workspace— substitute for any oldsearch_space_*/SearchSpacecitations and grep the new name); citations also predate03a'scrawl_urlrefactor; locate code by symbol/grep, not absolute lines. Scrapling references point at the on-disk (gitignored)references/Scrapling/checkout pinned toscrapling[fetchers]>=0.4.9(pyproject.toml:91).
Build status. Sliced for risk (see "Build slicing" below). Slice A (SHIPPED in this pass): centralized stealth kwargs builder + fingerprint flags + geoip-from-proxy-location + block classifier (additive
CrawlOutcome.block_type) + fonts/Xvfb packages in the image. Slice B / C (deferred): headed-Xvfb runtime path, humanization/WebGLinit_scriptassets, persistent profiles, strategy memory, paid-unblocker stub. Effectiveness of every lever is graded in03f(manual), not unit-tested here.First baseline evidence (
03f, 2026-06-30 — headless, rotating residential, captcha OFF, Slice-A only withCRAWL_GEOIP_MATCH_ENABLED=false). Suite S = 6 PASS / 4 FAIL (full table in03f). The 4 fails map cleanly onto this plan's deferred levers and confirm the predicted ceiling — they are not Slice-A regressions:
- iphey "Unreliable" → the predicted geoip-incoherence tell (browser tz/locale vs proxy exit geo). It's now the live regression test for §1 geoip coherence — flipping
CRAWL_GEOIP_MATCH_ENABLEDis the expected fix to validate next.- CreepJS
hasHeadlessWorkerUA: true(headless 33%) → the Web Workernavigator.userAgentstill leaksHeadlessChrome(main-thread UA is clean). A concrete, plausibly-fixable Slice-B target (worker UA override viainit_script), beyond today's Slice-A flags.- FingerprintJS Pro "tampering detected, access denied" → the documented commercial/device-fingerprint wall (§2c WebGL/GPU gap). Needs Slice B/C or the §8 paid tier; expected to stay red on the free stack.
- incolumitas → a single legacy
fpscanner WEBDRIVERcheck (all modern checks pass); lowest priority. Its IP classifier also flip-floppedis_datacentertrue/false across rotations, exposing that theanonymous_proxiespool mixes datacenter+residential exits (a proxy-provider variable, not a code issue).
Objective
Push the Universal WebURL Crawler as far toward "undetectable" as the free / in-house stack allows, so we hold a scraping moat for the next ~4–6 months without a third-party unblocker (ZenRows/ScrapFly/Bright Data) or a source-patched browser (CloakBrowser — rejected on licensing). Everything here is runtime/config-level stealth layered on Scrapling's patchright-Chromium (03a engine note): geoip fingerprint coherence, persistent profiles, headed execution, real fonts, and behavioral humanization — plus a block classifier + per-domain strategy memory so the ladder learns and the cheap tiers get skipped once a domain's working strategy is known. The paid-unblocker tier is defined here as a deferred config-gated ladder step (the explicit escape hatch for when in-house maintenance gets too costly), not built now.
The realistic ceiling (be honest with downstream devs)
Scrapling already ships strong runtime stealth by default: DEFAULT_ARGS + STEALTH_ARGS (incl. --disable-blink-features=AutomationControlled), a persistent context, and navigator.webdriver masking via patchright. Be precise about what patchright actually is: it patches the Playwright driver to remove automation leaks — it never calls Runtime.enable, disables Console.enable, and reaches closed shadow roots (verified: patchright-python, Scrappey). It hides "I'm automated," not "this is a different machine." It does not spoof canvas, WebGL, audio, screen, or GPU. With 03b residential proxies + this plan's coherence/humanization on top, the crawler reliably handles Cloudflare (via 03a solve_cloudflare) and the long tail of moderate anti-bot — which is most CI targets.
The device-fingerprint wall (the WebGL/GPU gap — see §2c). Our worker has no GPU, so Chromium's WebGL renderer reports Google SwiftShader / Mesa llvmpipe — a string anti-bot vendors treat as ">95% bot" (ipasis, Scrappey). Scrapling exposes only allow_webgl (on/off — and off is itself a tell), no renderer spoof; CloakBrowser fixes this at the C++ level (--fingerprint-gpu-renderer) and we cannot. Concretely, without a real GPU or a source-patched binary we cannot:
- Pass pure device-fingerprint bot checks — FingerprintJS bot-detection, CreepJS / BrowserScan "is this a real device." CloakBrowser passes these (BrowserScan 4/4); our SwiftShader renderer fails them.
- Reach human-level reCAPTCHA v3 scores (~0.7–0.9) on our own browser — we land ~0.1–0.5. (Note:
03d's paid solver sidesteps this for token challenges — the token is minted on the vendor's real-browser infra, so its score isn't ours. The gap only bites where the site itself reads our live v3 score.) - Max out the device-fingerprint component of DataDome / Kasada / Akamai — one of several reasons we plateau below managed APIs.
Numbers (2026 benchmarks — use these in any user-facing copy). Free stack + residential: Basic (CF) ~99%; Medium (CF Pro / PerimeterX) ~90%; Enterprise (DataDome / Kasada / Akamai) ~60–75% initially, decaying toward ~60% within 24h as their ML clusters the fingerprint (managed APIs hold ~89%) (scrapewise DataDome 2026). So the honest line is "partial and decaying, not SLA-grade," — neither "can't beat" nor "beats everything." For CI targets the top-tier defenses are the exception, so the in-house moat is a sound 4–6 month bet, with the deferred paid tier (§8) as the evidence-driven escape hatch (03f's scorecard is the trigger). Do not promise "beats everything."
Levers (wired via the centralized kwargs builder + ladder helpers — callers stay outcome-only)
1. Geoip fingerprint coherence (match the browser to the proxy exit IP)
A residential IP in Berlin behind an en-US/America/New_York browser is an instant tell. Make the fingerprint cohere with the proxy's exit geo:
- Primary source (Slice A, no network call): the configured
RESIDENTIAL_PROXY_LOCATION(03b,config:1048). Map that string (best-effort: ISO-3166 alpha-2 likeus/de, or a common country name) → a representative(locale, timezone_id). Coarse country granularity is fine (per "Risks": wrong-but-coherent beats default-mismatched); unknown/empty → skip (leave Scrapling's system default). No exit-IP resolution — avoids a per-crawl network round-trip + failure mode. (Ageoip2/MaxmindLite exit-IP path is a later refinement, not MVP.) - Pass the matched values into StealthyFetcher via the kwargs builder:
locale=(drivesnavigator.language+Accept-Language) andtimezone_id=(confirmed real kwargs onStealthyFetcher.fetch). These flow into the Playwright context. - The stealthy tier already captures the chosen proxy endpoint once (
_crawl_with_stealthy_sync:proxy = get_proxy_url(), the same value handed to03d's solver for IP-coherence) — reuse that capturedproxy/location, don't re-callget_proxy_url()(which rotates on a pool-backed provider,03b). - Caveat (be honest): Scrapling applies
locale/timezone_idvia Playwright CDP emulation, which advanced systems can flag as emulation — CloakBrowser sets these as binary flags precisely to avoid "detectable CDP emulation" (references/CloakBrowser/README.md:309). Still strictly better than a mismatched default (wrong-but-coherent beatsUTC+en-USbehind a Berlin IP), but it's a weaker variant than a source-patched browser, not parity.
2. Fingerprint flags Scrapling already exposes (turn them on)
hide_canvas— Scrapling adds random noise to canvas ops (verified in the installedStealthyFetcher.fetchdocstring). Not "free safe": random-per-call noise is itself a signal on FingerprintJS/CreepJS-class detectors — an unstable canvas hash is as loud asnavigator.webdriver=true(dev.to); CloakBrowser's own FPJS fix is--fingerprint-noise=false. Default OFF; gate per-domain and validate in03f. Harmless against Cloudflare/moderate, potentially harmful against device-fingerprint graders. (Canvas stealth that actually helps must be deterministic per persistent profile, not random — out of scope here.)block_webrtc=True— forces WebRTC to respect the proxy, preventing the real local IP leak that unmasks proxied browsers (:41). This one is genuinely cheap + safe — keep default TRUE.dns_over_https— Scrapling can route DNS via Cloudflare DoH to stop the DNS leak that unmasks proxied browsers (same anti-leak class asblock_webrtc; confirmed kwarg onStealthyFetcher.fetch). Trade-off: it adds a DNS round-trip, a (small) latency cost — so per the "no speed regression" constraint it ships off by default (CRAWL_DNS_OVER_HTTPS, default FALSE), pre-wired for operators who prefer leak-safety over the marginal latency. (Idea sourced from the Camoufox-based FlareSolverr alternativesreferences/Byparr-main/references/trawl-dev, which treat DNS/WebRTC leak coherence as first-class.)google_search=True(default) — sets a Google referer so the first hit looks like organic arrival (:45); override per-need viaextra_headers(:46).additional_args=— last-priority Playwright context overrides for anything not surfaced as a first-class param (:51).
2b. HTTP-tier TLS fingerprint (the AsyncFetcher tier — impersonate)
The cheap static tier (03a tier 1) is the first thing every crawl hits. 03a already ships impersonate="chrome" on it (app/proprietary/web_crawler/connector.py, the AsyncFetcher.get call) — Scrapling's static engine selects a matching curl_cffi browser profile (references/Scrapling/scrapling/engines/static.py:36–47), so the HTTP tier's JA3/JA4/HTTP-2 already matches a real Chrome and coheres with the browser tiers' UA. 03e's remaining work here is not to add it, but to (a) fold the impersonate profile into the centralized per-tier kwargs builder (below) so it stays the single source of truth, and (b) keep the chosen profile coherent with the proxy exit / UA. 03f §S3 validates the shipped parity against tls.peet.ws.
2c. WebGL / GPU renderer (the biggest free-stack gap — partial mitigation only)
This is the "device-fingerprint wall" from the ceiling section. On a GPU-less worker the WebGL renderer string is Google SwiftShader / ANGLE (Mesa, llvmpipe…) — an instant tell on any detector that reads WEBGL_debug_renderer_info. Scrapling gives us no renderer knob (only allow_webgl on/off, and off is a tell). Three options, increasing strength + cost:
- DIY JS spoof (optional, OFF by default — Slice B). An
init_scriptoverridesgetParameterforUNMASKED_VENDOR_WEBGL/UNMASKED_RENDERER_WEBGLto a believable, platform-coherent value (e.g. an Intel/ANGLE renderer that matches the spoofed Windows UA), held consistent per persistent profile (§3). Impl note: Scrapling'sinit_scriptis an absolute path to a.jsfile, not an inline string (confirmed inStealthyFetcher.fetchdocstring) — so this ships as a bundled.jsasset inapp/proprietary/web_crawler/(next to03d's captcha logic) passed by absolute path; the kwargs builder resolves the path whenCRAWL_WEBGL_SPOOF_ENABLED. Must also patchFunction.prototype.toStringso the override still returns[native code]— otherwise the spoof itself is the signal (exactly thetoString()probe Kasada catalogs, web-scraping-guide). This defeats string-based WebGL checks (the common case) but not render-output pixel-hash or GPU-perf probes (FingerprintJS-grade) — a software rasterizer's actual draw output still differs and can't be faked in JS. It is the brittle config-level approach patchright/CloakBrowser explicitly warn rots; treat it as a cheap, test-gated win, not the moat. - Real GPU passthrough (hosted workers only).
--device /dev/dri+ ANGLE/EGL so WebGL reports a genuine renderer with real render output, defeating even output/perf probes. Best technical fix; costs GPU instances + image complexity (livekit/egress GPU notes). A hosted-tier escalation, off for self-hosted. - Defer to the paid unblocker (§8). Route the residual device-fingerprint-gated domains (flagged by the §7 classifier as in-house-unreachable) to the external unblocker. The pragmatic moat-bounding fallback.
Decision for this MVP: ship option 1 as an opt-in flag (CRAWL_WEBGL_SPOOF_ENABLED, default FALSE), name option 2 as the hosted escalation, and lean on §8 for the rest. Quantify all three in 03f.
3. Persistent per-domain profiles (look like a returning human)
Scrapling defaults to a temporary user-data dir (fresh = suspicious). Use user_data_dir= (confirmed real kwarg; launch_persistent_context) to keep a persistent profile per domain (or per domain+proxy-geo), so cookies/localStorage/site-trust carry across crawls and the browser presents as a returning visitor rather than a brand-new incognito session. Store profiles under a configured dir (shared_tmp-style volume so API + worker share them).
- Concurrency hazard (Slice C design item — why this is deferred, not a one-liner): Chromium hard-locks a
user_data_dir; two crawls touching the same profile dir at once crash (SingletonLock). Our crawler runs concurrently (multiple connectors/chats across workers), so a naive per-domain dir collides. The fix needs one of: (a) an async lock keyed by profile dir so same-domain crawls serialize (adds latency, can deadlock under load), (b) per-(domain, worker, pid)dirs + a periodic prune (loses some cross-run trust, no contention), or (c) persistent only when the strategy memory (§7) says a domain needs it, temp otherwise. Decide in Slice C alongside §7; Slice A keeps Scrapling's temp default.
4. Headed execution under Xvfb (defeat headless tells)
headless defaults to hidden (stealth_chrome.py:19/71). Many WAFs flag headless Chromium. Run headful (headless=False) inside a virtual framebuffer (Xvfb) in the Docker worker so the browser is "visible" to itself but needs no real display. Gate behind a config flag (off by default for self-hosted, on for hosted workers that have Xvfb installed).
5. Real fonts (canvas/emoji hash realism)
A minimal container has almost no fonts, making canvas/emoji fingerprint hashes obviously synthetic. Install real font packages so font-enumeration + canvas/emoji hashes resemble a real desktop. Use the set CloakBrowser ships as proven against Kasada/Akamai emoji-canvas hashing (README.md:737–739): fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic (CJK), fonts-wqy-zenhei (CJK), fonts-tlwg-loma-otf — plus fonts-dejavu/fonts-liberation for Latin coverage. Dockerfile change only (apt-get install fonts-*).
6. Behavioral humanization (DIY — Scrapling has no humanize for the Chromium engine)
Unlike Camoufox, the patchright-Chromium StealthyFetcher exposes no built-in humanize (verified: zero matches in the StealthyFetcher param set). So humanization is custom, injected via the existing hooks:
page_action=(runs after navigation) — randomized mouse moves/curves, scrolls, hover-before-click, and small think-time delays before extraction. This is the same hook03duses for token injection —_crawl_with_stealthy_synccurrently builds exactly onepage_action(the captcha injector), so Slice B must compose two callables (humanize → optional captcha solve) into the singlepage_actionkwarg, not overwrite it.init_script=(an absolute path to a.jsfile executed on page creation — not inline JS, see §2c) — early shims for any residual JS tells not covered by patchright (and the §2c WebGL spoof, if enabled).- Tunable
wait/network_idle(:29,27) so dwell time isn't robotically constant. - Concrete rules (from CloakBrowser's reCAPTCHA notes,
README.md:1280–1314— encode these): use nativetime.sleep, neverpage.wait_for_timeout()(it emits CDP traffic detectors flag); preferpage.type()overfill()for any input (real keystrokes vs. value-set); dwell 15s+ before triggering score-based checks; space repeat hits to score-gated domains 30s+; keep a stable identity per domain (persistent profile §3 + consistent spoofed values §2c) so a score-based system sees a returning device, not a fresh one each visit.
7. Block classifier + per-domain strategy memory (the "learning ladder")
Two small in-house pieces make the ladder smart instead of brute-force:
- Block classifier (Slice A — additive, pure, unit-testable). Inspect the fetched page (
status+ body markers; cookies/headers are not threaded through_build_resulttoday, so MVP is status + body-marker based) and label the outcome:OK/CLOUDFLARE/CAPTCHA_RECAPTCHA/CAPTCHA_HCAPTCHA/DATADOME/KASADA/RATE_LIMITED/EMPTY/UNKNOWN. Concrete markers — adopt the set proven inreferences/trawl-dev/packages/tiers/src/detect.ts(a near-identical classifier): Cloudflare/DDoS-Guard →cf-mitigated-style title strings"just a moment"/"checking your browser"/"enable javascript and cookies to continue"/"verify you are human", DOM idschallenge-running/cf-challenge-running/turnstile-wrapper, andddos-guard.net; Turnstile →class="cf-turnstile"/challenges.cloudflare.com/turnstile; hCaptcha →class="h-captcha"/hcaptcha.com/1/api; reCAPTCHA →class="g-recaptcha"/google.com/recaptcha/recaptcha.net; DataDome →datadomescript/cookie; status202/403/429as a bot-gate (202is an IMDb-style pre-gate, perdetect.ts:isBlocked). Invariant: the classifier is purely additive — it attachesCrawlOutcome.block_typeand feeds telemetry/routing; it MUST NOT change whenSUCCESSis returned, because03cbills onstatus == SUCCESS(CrawlOutcomedocstring). Wiring mirrors03d's provencaptcha_statepattern: a per-callblock_statedict mutated in_build_result(which hasraw_html+status) and stamped onto everyCrawlOutcomereturn path incrawl_url. - Per-domain strategy memory (Slice C — needs a new dependency + control-flow change). Cache the strategy that last succeeded per domain (Redis key
crawl:strategy:{domain}with TTL). Next crawl starts at the known-good tier, skipping cheaper tiers that always fail there. Why deferred:WebCrawlerConnector()is currently constructed with no args and is Redis/DB-free; this requires injecting a cache client (best-effort, no-op when absent) and lettingcrawl_urlskip ladder tiers — a control-flow change to the tuned ladder. No DB migration (Redis, best-effort, self-healing on miss), but it is a deliberate design item, not a flag flip. - Solved-session cache (Slice C refinement — speed-positive, the strongest version of the memory above). Beyond remembering which tier won, cache the solved session itself — the
cf_clearance/__cf_bmcookies + the UA that cleared the challenge — in Redis (crawl:session:{domain}with TTL), and replay it on the next crawl via Scrapling'scookies=input, so a returning hit skips the expensive solve entirely (the patternreferences/trawl-devTier 2 uses for its ~500ms repeat path:browser/src/session.tssession:{domain}save/load/invalidate;tiers/src/tier2.tsreplays cookies, and on a re-challenge markssession-expired→ invalidate → escalate). Net effect is faster, not slower (it removes a solve), so it fits the "no speed regression" bar. Caveat:cf_clearanceis IP+UA-bound, so it pays off most behind sticky/static proxies (the03bstatic-proxy future) and the cache key should include proxy-geo; under rotating residential the replayed cookie may not match the new exit IP (best-effort, self-healing via thesession-expiredinvalidate).
8. Deferred: paid-unblocker tier (the escape hatch, NOT built now)
The moat strategy is explicit: maintain in-house bypass for ~4–6 months, then move hostile targets to a paid unblocker if demand/maintenance justifies it. That switch is evidence-driven, not a guess — 03f's manual scorecard quantifies the free-stack ceiling over time and is the documented trigger for flipping this tier. Pre-wire the seam so the switch is a config flip, not a refactor:
- Define (but do not implement) a paid-unblocker tier — a
_crawl_with_paid_unblocker(url) -> dict | Nonemethod returning the same shape as the existing tiers, appended last incrawl_url's ladder (after the stealthy tier) and active only when an env flag + API key are set. (Same plain-method shape as today's tiers — there is noFetchStrategycontract to satisfy.) - It would call an external unblocker (ZenRows/ScrapFly/Bright Data Web Unlocker) for the residual
DATADOME/KASADA/reCAPTCHA-Enterprisedomains the block classifier flags as unreachable in-house. - Its own billing (cost-plus pass-through, decided at build time) — separate from
03c's flat crawl unit and03d's per-solve unit, because unblocker pricing is per-request and provider-specific. - Until built, those domains simply return non-
SUCCESS(free under03c). This keeps the umbrella's "WebURL Crawler is the moat" honest while bounding our maintenance risk.
Config / env changes
Add (all default OFF / conservative; next to the 03b/03c knobs in config/__init__.py + .env.example):
CRAWL_GEOIP_MATCH_ENABLED(default FALSE; Slice A — mapsRESIDENTIAL_PROXY_LOCATION→locale/timezone_id, no exit-IP lookup).CRAWL_BLOCK_WEBRTC(default TRUE — cheap, safe; Slice A),CRAWL_HIDE_CANVAS(default FALSE — random canvas noise can itself be a tell; opt-in +03f-validated, see §2; Slice A), andCRAWL_GOOGLE_SEARCH_REFERER(default TRUE — Scrapling'sgoogle_search; Slice A).CRAWL_DNS_OVER_HTTPS(default FALSE — anti DNS-leak, but adds a DNS round-trip; default-off to honor the "no speed regression" bar, pre-wired for leak-safety-first operators; §2; Slice A).CRAWL_WEBGL_SPOOF_ENABLED(default FALSE) + optionalCRAWL_WEBGL_VENDOR/CRAWL_WEBGL_RENDERERoverride strings (§2c;toString-safe JS spoof, defeats string-checks only).CRAWL_PERSISTENT_PROFILES_DIR(unset → Scrapling's temp default; set → per-domain profiles).CRAWL_HEADED_XVFB_ENABLED(default FALSE; requires Xvfb in the image).CRAWL_HUMANIZE_ENABLED(default TRUE) + dwell/jitter bounds.CRAWL_STRATEGY_MEMORY_TTL_S(default e.g.86400; 0 → disabled).CRAWL_PAID_UNBLOCKER_ENABLED(default FALSE) + provider/key (deferred tier).
Docker changes (surfsense_backend/Dockerfile)
- Install Xvfb + the proven font set (
fonts-noto-color-emoji,fonts-unifont,fonts-ipafont-gothic,fonts-wqy-zenhei,fonts-tlwg-loma-otf,fonts-dejavu,fonts-liberation; §5) in the worker image. (Note from03a: also drop the stale "+ Camoufox" comment near:112;scrapling installonly fetches Chromium.) - Headed runs need the browser launched under
xvfb-run(or an Xvfb display in the worker entrypoint), gated byCRAWL_HEADED_XVFB_ENABLED.
Build slicing (risk-staged; 03f validates effectiveness)
The levers split by risk/testability. Slice A is built now (prod-safe, config-gated, defaults preserve today's behavior, wiring + classifier unit-tested). B/C are deferred (need infra or architectural design; mostly only verifiable in 03f).
- Slice A (SHIPPED): centralized kwargs builder → fingerprint flags (
block_webrtcON,hide_canvasOFF,google_searchON,dns_over_httpsOFF); geoip coherence fromRESIDENTIAL_PROXY_LOCATION; fonts + Xvfb packages in the Dockerfile; additive block classifier (CrawlOutcome.block_type, classified in_build_resultand the static tier's>=400early-return so the first/cheapest-tier bot-gate isn't lost). Licensing split: the stealth kwargs builder is proprietary (app/proprietary/web_crawler/stealth.py— bypass tuning); the block classifier stays Apache-2 (app/utils/crawl/classifier.py— generic passive telemetry from public markers).extra_headers/additional_argswere intentionally NOT wired — they're last-priority override hatches with no clean env representation (dict-in-env) and no concrete need yet; add them when a real use appears rather than speculative config (YAGNI). - Slice B (next): headed-Xvfb runtime path (
headless=False+ entrypoint), humanizationpage_action(composed with03d's injector), WebGL-spoofinit_script.jsasset (proprietary). - Slice C (defer): per-domain persistent profiles (concurrency design, §3), per-domain strategy memory + tier-skipping (
crawl_urlcontrol-flow change + cache injection, §7), solved-session cache (cf_clearancereplay viacookies=, §7 — speed-positive, best with sticky proxies), warm browser pool (a hosted-scale latency win —references/trawl-devbrowser/src/pool.tskeeps N warm instances with an acquire-timeout→429; Scrapling'sStealthySessionalready has page pooling), paid-unblocker config stub (§8). GPU passthrough stays infra/hosted-only (§2c).
Work items
Slice A (this pass):
- Config +
.env.example— add the Slice-A knobs (below); geoip reusesRESIDENTIAL_PROXY_LOCATION. app/proprietary/web_crawler/stealth.py(proprietary — bypass tuning) —StealthConfig+get_stealth_config()snapshot; coarseRESIDENTIAL_PROXY_LOCATION → (locale, timezone_id)map;build_stealthy_kwargs(cfg)returning the config-derivedStealthyFetcherkwargs. Single source of truth imported by the connector and03f's harness (no test-vs-prod drift). The AsyncFetcher tier already carriesimpersonate="chrome"(shipped03a, §2b) — leave it; document it as the static-tier coherence anchor.app/utils/crawl/classifier.py(Apache-2) —BlockTypeenum +classify_block(status, html) -> BlockType(pure, status + body-marker based).- Connector wiring — merge
build_stealthy_kwargs(...)into_crawl_with_stealthy_sync's kwargs (existingheadless/network_idle/block_ads/solve_cloudflare/proxy/captchapage_actionpreserved; lever keys never collide); addblock_typetoCrawlOutcome; thread a per-callblock_statedict (classify in_build_resultand the static tier's>=400early-return path, stamp on everycrawl_urlreturn) — additive only, SUCCESS predicate unchanged. - Docker — Xvfb + the proven font set in the worker image; drop the stale "+ Camoufox" comment.
- Tests + lints — unit-test the builder (geoip map, flag wiring, snapshot) + classifier (each marker); update connector tests for
block_type; run suite.
Slice B / C (deferred — tracked above): WebGL-spoof init_script, humanization composer, headed-Xvfb runtime, persistent profiles, strategy memory + tier-skip, paid-unblocker stub, richer (domain, block_type, winning_strategy, attempts, latency) instrumentation.
Risks / trade-offs
- Arms race / maintenance. Fingerprint bypasses rot as WAFs update; this is exactly the cost the deferred paid tier (§8) hedges. Instrumentation (work item 9) plus
03f's scorecard are what tell us when in-house upkeep stops being worth it. - WebGL/GPU is a structural gap, not a bug (§2c). The free stack cannot present a real consumer GPU; the JS spoof defeats string checks but not render-output/perf probes, and is itself brittle (rots on Chrome updates, must stay
toString-clean). Plan for device-fingerprint-grade targets to need GPU-passthrough workers or the §8 paid tier — don't expect parity with CloakBrowser here. - Headed/Xvfb cost. Headful browsers use more CPU/RAM than headless; gate per-flag and only escalate to headed when the block classifier says cheaper tiers fail for a domain (per-domain memory keeps it from being the default).
- Profile growth. Persistent profiles accumulate disk; add a size/TTL cap and periodic prune.
- Geoip accuracy. A coarse country→locale map is fine; over-fitting per-city tz isn't worth it. Wrong-but-coherent beats default-mismatched.
- No silver bullet. Reiterate the ceiling (§"realistic ceiling") in any user-facing copy: the crawler is "best-effort undetectable," not guaranteed.
Prior art evaluated — why not Camoufox (decision: skip for now)
references/Byparr-main and references/trawl-dev are FlareSolverr alternatives built on Camoufox — an MPL-2.0, open-source, C++/Juggler-patched Firefox that natively closes our biggest gaps: hardware-accurate WebGL renderer/vendor spoofing (bundled webgl_data.db, §2c), geoip=True locale/tz/geo coherence from the proxy IP (§1), humanize=True mouse, plus font/WebRTC/audio/screen spoofing — all at the engine level (no detectable JS shims). It is effectively the CloakBrowser-class capability without CloakBrowser's licensing problem.
Decision (this MVP): do not adopt Camoufox. Rationale: Scrapling dropped Camoufox in favor of patchright-Chromium for its StealthyFetcher (scrapling stealthy docs; confirmed in source — references/Scrapling/scrapling/engines/_browsers/_stealth.py imports patchright), so wiring Camoufox would mean a separate proprietary fetch tier (camoufox+playwright, outside Scrapling), a Firefox engine (a few Chrome-only targets won't fit), a heavy extra binary, and tracking an actively-but-experimentally-developed project. Revisit trigger: if 03f's scorecard shows the patchright tier's device-fingerprint ceiling (§2c) is blocking real CI targets, a Camoufox tier is the prime free escalation to evaluate before the §8 paid unblocker.
Out of scope (hand-offs)
- Cloudflare solving (
03a), reCAPTCHA/hCaptcha solving + its per-solve billing (03d), proxy rotation (03b), flat crawl billing (03c). Forward note for03d(free solver fallback): the Camoufox-based references solve reCAPTCHA-v2 / Turnstile free in-browser (audio-STT —references/trawl-dev/packages/tiers/src/solvers/{stt,turnstile}.ts) instead of a paid solver.03dshipped with paidcaptchatoolsas primary; a free STT path is a possible fallback-before-paid later, but audio STT is brittle/Google-rate-limited, so paid stays primary (and this doesn't change03e). - Logged-in / account-based bypass (sticky/static proxies + credential management) — deferred to the platform-actor work (umbrella Phase 8 +
03bstatic-proxy hand-off). Public data only this MVP. - Building the paid-unblocker provider integration — deferred (§8 leaves only the seam + flag).
- Measuring undetectability (the scorecard that grades these levers) →
03f(manual harness). This plan builds the levers;03ftests them. - Platform-specific structured extractors (Google Maps, LinkedIn public, …) — these sit on top of this hardened fetch core as Phase-8 actors; this plan only delivers the core they depend on.