Lightweight, always-stealth web-scraping agent. Driven by a text LLM in a ReAct loop; same tools also callable directly via A2A and MCP for deterministic single-tool usage.
┌──────────────────────────────────────┐
│ HTTPS :443 (TLS, optional API_KEY) │
└────────────────────┬─────────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
A2A direct A2A freeform MCP /mcp
(skill_id JSON) (NL task → ReAct) (mount_mcp)
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────┐
│ Tool registry (app/agent/tools.py) │
│ fetch_url · render_page · fill_form · click_and_wait │
│ scroll_to · extract · download_file · switch_engine │
│ finish │
└────────┬─────────────────────────┬─────────────────────┘
│ │
curl_cffi path Playwright path
(chrome131 JA3) (rebrowser-Chromium / FF)
│ │
│ ┌──────┴───────┐
│ │ ghost-cursor │
│ │ humanized KB │
│ └──────┬───────┘
│ │
└──── cookie sync ────────┘
│
storage_state.{engine}.json
(S3 + /tmp fallback, debounced 10s)
│
optional tun2socks → SOCKS proxy
optional Xvfb + x11vnc + noVNC (HEADED)
curl_cffi
with impersonate=chrome131 — defeats Cloudflare/PerimeterX/DataDome at
the protocol layer.rebrowser-playwright
(CDP-leak-patched Chromium, default), vanilla Playwright Firefox
(fallback for Chrome-specific bot rules), and opt-in
cloakbrowser (stealth
Chromium fork with binary-level fingerprint patches for Cloudflare
Turnstile / reCAPTCHA v3 / FingerprintJS-style gates). LLM picks per
task via switch_engine. The registry of supported engines lives in
app/browser/engines.py. CloakBrowser is gated by CLOAK_ENABLED;
its binary downloads from CloakHQ at first launch (BINARY-LICENSE.md
prohibits redistribution in a public Docker image). Camoufox is
deferred (PyYAML CLoader incompatibility on Python 3.12).python-ghost-cursor
Bezier mouse trajectories + inline humanized keyboard (typo simulation,
Gaussian inter-key delays, RNG seeded per task_id).task_id. Local /tmp/state fallback.call_llm → execute_tool → loop. Default 25 steps max, 25k chars per HTML observation.tun2socks (transparent, fwmark
100 + tun0). Set SOCKS_PROXY=....HEADED=true boots Xvfb + x11vnc + noVNC on :6901
for live-watch debugging via the dashboard.app/
├── server/
│ ├── config.py pydantic-settings
│ ├── a2a.py WebScraperAgentExecutor (direct dispatch + ReAct)
│ ├── main.py FastAPI + lifespan + mount_mcp(...)
│ └── __main__.py uvicorn entry
├── agent/
│ ├── prompts.py SYSTEM_PROMPT for the ReAct loop
│ ├── tools.py 9 @tool functions + SCRAPER_TOOLS / TOOLS_BY_ID
│ └── graph.py LangGraph StateGraph
├── browser/
│ ├── context.py rebrowser-Chromium + vanilla-Firefox runtimes
│ ├── state.py storage_state save/load (S3 + /tmp), debounce
│ ├── cursor.py ghost-cursor wrapper
│ ├── typing.py humanized keyboard
│ ├── cookies.py curl_cffi ↔ Playwright cookie sync
│ └── detect.py anti-bot phrase scanner
└── http/
└── session.py per-task curl_cffi AsyncSession pool
| Tool | Purpose |
|---|---|
fetch_url | TLS-impersonated HTTP. First choice for static HTML, JSON APIs. |
render_page | Browser navigate + wait + snapshot. |
fill_form | Multi-field humanized form fill ± submit. |
click_and_wait | Click selector, wait for state. |
scroll_to | Humanized scroll. |
extract | Apply schema to HTML — selectors (CSS) or llm mode. |
download_file | Stream URL bytes to a run artifact. |
switch_engine | Flip between Chromium / Firefox / Cloak per task. |
finish | Terminate the ReAct loop with a final answer. |
Same tools, three callable shapes:
{"skill_id": "fetch_url", "params": {...}}
as the message text. Tool runs synchronously, no LLM cost.mount_mcp(...) exposes every tool as an MCP tool at /mcp.
Frontier-LLM clients (Claude Desktop, Cursor, Windsurf) use their own
LLM and call tools deterministically.The image is multi-arch (linux/amd64,linux/arm64) and ships from
superbizon007/web-scraper-agent.
Native arch only — required on Apple Silicon because QEMU emulation
breaks Chromium's new_page under linux/amd64.
cd apps/web_scraper_agent
docker build \
--build-context a2a_pkg=../../packages/a2a_agent \
-f Dockerfile.ubuntu \
-t superbizon007/web-scraper-agent:latest \
.
Import into a local k3d cluster:
k3d image import superbizon007/web-scraper-agent:latest -c forfetch-local
cd apps/web_scraper_agent
./docker_hub_push.sh
Reads version from pyproject.toml, runs docker buildx build --platform linux/amd64,linux/arm64 --push against both :VERSION and
:latest tags.
docker run -d --rm \
--name web-scraper \
--shm-size=2g \
-p 9443:443 \
-e API_KEY=test123 \
-e LLM_API_KEY=$OPENAI_API_KEY \
superbizon007/web-scraper-agent:latest
Smoke test (skill_id direct dispatch):
curl -sk https://localhost:9443/ \
-H "Authorization: Bearer test123" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{
"id":"task-1",
"message":{"role":"user","messageId":"m1",
"parts":[{"type":"text","text":"{\"skill_id\":\"fetch_url\",\"params\":{\"url\":\"https://example.com\"}}"}]
}}}'
Freeform (ReAct loop) — same shape, but the message text is a NL task instead of a JSON skill_id envelope.
docker run -d --rm \
--name web-scraper-headed \
--shm-size=2g \
-p 9443:443 -p 6901:6901 \
-e HEADED=true \
-e API_KEY=test123 \
-e LLM_API_KEY=$OPENAI_API_KEY \
superbizon007/web-scraper-agent:latest
Open http://localhost:6901/vnc.html for the noVNC client.
docker run -d --rm \
--name web-scraper-proxied \
--cap-add=NET_ADMIN --device=/dev/net/tun \
--shm-size=2g \
-p 9443:443 \
-e SOCKS_PROXY=socks5://user:[email protected]:1080 \
-e API_KEY=test123 \
-e LLM_API_KEY=$OPENAI_API_KEY \
superbizon007/web-scraper-agent:latest
The cloak engine is available by default — the ReAct LLM picks it
via switch_engine when blocked_signals indicate a Turnstile /
reCAPTCHA v3 / FingerprintJS gate. The stealth-Chromium binary is
not baked into the image (its BINARY-LICENSE.md prohibits
redistribution in a public Docker artifact); the entrypoint downloads
it from CloakHQ on first boot (~200 MB, ~30 s on a fast link). Mount
a volume at /cache/cloakbrowser so the download survives pod
restarts:
docker run -d --rm \
--name web-scraper-cloak \
--shm-size=2g \
-p 9443:443 \
-e API_KEY=test123 \
-e LLM_API_KEY=$OPENAI_API_KEY \
-v cloakbrowser-cache:/cache/cloakbrowser \
superbizon007/web-scraper-agent:latest
To force cloak as the default engine for every task: pass
-e DEFAULT_ENGINE=cloak. To opt out entirely (no outbound traffic
to CloakHQ at any point): pass -e CLOAK_ENABLED=false. Startup
fail-fasts if DEFAULT_ENGINE=cloak and CLOAK_ENABLED=false.
NET_ADMIN + /dev/net/tun are required for tun2socks to set up the
tun0 device and iptables mangle rules.
Core seeds and launches the agent automatically — see
apps/agents_mcp/app/seed.py for the published agent definition. The
agent registers as web_scraper_agent with capability tag
{"stealth": true}.
| Var | Default | Notes |
|---|---|---|
LLM_URL | https://api.openai.com/v1 | OpenAI-compatible base URL. Used by ReAct loop and extract LLM mode. |
LLM_API_KEY | "" | Bearer for the LLM. |
LLM_MODEL | gpt-4.1-mini | Cheap text model is fine; tools have selectors mode. |
| Var | Default | Notes |
|---|---|---|
API_KEY | "" | Inbound bearer; auth skipped when unset. |
PORT | 443 | Both A2A JSON-RPC and /mcp mount on this port. |
SSL_CERTFILE | /tmp/agent.crt | Self-signed cert auto-generated at startup if missing. |
SSL_KEYFILE | /tmp/agent.key | |
SSL_CA_CERTFILE | /tmp/agent-ca.crt | |
AGENT_URL | (auto) | Public URL published in AgentCard. Core injects in-cluster URL in prod. |
| Var | Default | Notes |
|---|---|---|
CURL_IMPERSONATE | chrome131 | curl_cffi profile; should match shipped Chromium major. |
DEFAULT_ENGINE | chromium | chromium | firefox | cloak. ReAct loop can flip per task via switch_engine. Startup fails fast if the selected engine isn't available. |
CLOAK_ENABLED | true | cloak engine is available by default; the ReAct LLM decides when to escalate. Set false to opt out entirely (compliance, no outbound traffic to CloakHQ). First use triggers ~200 MB binary download. |
HEADED | false | true boots Xvfb + x11vnc + noVNC; browser headless=False. |
| Var | Default | Notes |
|---|---|---|
SOCKS_PROXY | unset | socks5://user:pass@host:port; routed transparently via tun2socks. |
PROXY_BYPASS_PRIVATE | 1 | Skip RFC1918 ranges. Set 0 to force everything through proxy. |
PROXY_BYPASS_CIDRS | unset | Comma-separated extra CIDRs to exclude from the proxy. |
| Var | Default | Notes |
|---|---|---|
S3_BUCKET | unset | Optional. Without it, artifacts go to /tmp/artifacts only. |
S3_ENDPOINT_URL | unset | For S3-compatibles (MinIO, R2). |
S3_ACCESS_KEY | unset | Run-scoped STS creds preferred (Core injects). |
S3_SECRET_KEY | unset | |
S3_SESSION_TOKEN | unset | |
S3_PREFIX | "" | e.g. agent-sessions/{session_id} — keys nested under this. |
RUN_ARTIFACTS_ROOT | runs | Top-level prefix for artifact uploads. |
| Var | Default | Notes |
|---|---|---|
MAX_STEPS | 25 | Hard ceiling on tool calls per task. |
MAX_HTML_CHARS | 25000 | Truncate HTML observations passed back to the LLM. |
HISTORY_WINDOW | 10 | (reserved for summarization) |
SUMMARY_THRESHOLD | 15 | (reserved for summarization) |
| Var | Default | Notes |
|---|---|---|
HUMAN_RELAY_AGENT_URL | unset | Points to hil_agent for captcha / 2FA escalation. |
HUMAN_RELAY_AGENT_API_KEY | unset | |
HUMAN_INPUT_TIMEOUT | 86400 | Seconds (24h). |
| Var | Default | Notes |
|---|---|---|
TRAJECTORY_DIR | unset | If set, dumps per-turn LLM I/O for debugging. |
AGENT_NAME | web.scraper | |
AGENT_VERSION | 0.1.0 | |
AGENT_ID | web_scraper_agent | Registry key Core uses to discover the agent. |
AGENT_DESCRIPTION | (long) | Published in the AgentCard. |
tests/test_fingerprint_check.py is a manual / nightly smoke that
catches the day TLS impersonation or webdriver patches silently break.
Six checks:
python-*.https://www.cloudflare.com/) doesn't return
a "Just a moment" interstitial via fetch_url.navigator.webdriver not flagged on Chromium.navigator.webdriver not flagged on Firefox.Run against a live container:
docker run -d --rm --name web-scraper-smoke --shm-size=2g \
-p 9443:443 -e API_KEY=test123 \
superbizon007/web-scraper-agent:latest
AGENT_URL=https://localhost:9443 API_KEY=test123 \
python3 tests/test_fingerprint_check.py
# exit 0 = green; exit 1 = at least one regression
Re-run after every curl_cffi, rebrowser-playwright, or base-image bump.
All five plan phases shipped:
| Phase | Scope |
|---|---|
| 1 | skeleton + curl_cffi tools + dual-path executor + mount_mcp |
| 2 | Chromium + humanized cursor/keyboard + cookie sync + bot-signal detection |
| 3 | Firefox engine + switch_engine (vanilla Playwright FF; Camoufox deferred) |
| 4 | extract LLM mode + storage_state.{engine}.json persistence |
| 5 | tun2socks proxy verify + HEADED Xvfb/VNC verify + fingerprint regression suite |
See specs/PLAN_WEB_SCRAPER_AGENT.md for the full plan.
The agent image exposes an MCP (Model Context Protocol) server on the same port as A2A at /mcp. Any MCP client — Claude Desktop, Cursor, Windsurf, ChatGPT Connectors, OpenAI Agents SDK — can list and invoke these tools using the pod's API_KEY as Bearer.
In production, Core proxies https://web-scraper-agent.agents.forfetch.ai/mcp → the worker pod's /mcp.
| Tool (skill_id) | Description |
|---|---|
fetch_url | TLS-impersonated HTTP fetch. First choice for static HTML, JSON APIs, sitemaps. |
render_page | Navigate the stealth browser (Chromium or Firefox) to a URL; wait, snapshot HTML + screenshot. |
fill_form | Multi-field humanized form fill (text/select/check) with optional submit. |
click_and_wait | Humanized click on a selector and wait for the next page state. |
scroll_to | Humanized incremental scroll to trigger lazy-load. |
extract | Apply a CSS-selector schema to HTML and return structured data. |
download_file | Stream a binary URL directly to a run-scoped artifact. |
switch_engine | Flip the active browser engine between chromium (default), firefox, and cloak (opt-in). Call when blocked_signals indicate a bot challenge. Returns error if the requested engine isn't available. |
list_files | List customer-uploaded files visible to this agent (file_ref envelopes). |
read_file | Read a customer-uploaded file by file_ref (text|bytes|meta). |
apply_storage_state | Replay a Playwright storage_state.json upload on the next browser context. |
finish | Terminate the ReAct loop with a final answer. |
scrape | Run a freeform scraping task via the LLM-driven ReAct loop. Pass the task as a text string. |
Input schemas are authored in apps/agents_mcp/app/seed.py. Skills without an explicit schema advertise a single {task: str} freeform parameter.
Content type
Image
Digest
sha256:c8d1cb32b…
Size
1.1 GB
Last updated
4 months ago
docker pull superbizon007/web-scraper-agent