Complete HTTP API reference. One binary, local install, every capability a POST away.
# Build and start
cargo build --release
./target/release/aginxbrowser
# Verify the service
curl http://127.0.0.1:8089/health
# → {"status":"ok","engine":"diting","version":"0.3.1","commit":"a1b2c3d",...}
# Fetch a page
curl -sS -X POST http://127.0.0.1:8089/fetch \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
# Create an interactive session
curl -sS -X POST http://127.0.0.1:8089/session/create \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'Listens on 127.0.0.1:8089 by default (loopback-only — the dogfood shape needs nothing wider); override via the AGINXBROWSER_BIND environment variable. A non-loopback bind (LAN, container) is explicit operator intent — pair it with AGINXBROWSER_TOKEN (#246).
Health check. Also the build-identity call: version + commit answer "which source is this binary" (compare against the release tag to verify doc/tag/binary/source are the same commit), v8 is the JS kernel version actually executing scripts (the engine truth behind the UA string — "15.0.274.2" doesn't depend on which persona a session carried), and ua/tls say what the instance presents to sites — the UA browser traffic carries (AGINXBROWSER_UA override, else the pinned persona; imported sessions keep the copied request's own UA by design) and the default TLS fingerprint ("off" in non-stealth builds). commit is "unknown" for git-less builds. When AGINXBROWSER_TOKEN is set, this detail requires the token — without it /health answers exactly {"status":"ok"} (liveness for supervisors/nginx, no build fingerprint to the public, #246).
curl http://127.0.0.1:8089/healthResponse:
{
"status": "ok",
"engine": "diting",
"version": "0.3.1",
"commit": "a1b2c3d",
"v8": "15.0.274.2",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"tls": "chrome145",
"capabilities": { "screenshot": true, "stealth": true, "captcha_solver": false, "ffmpeg": { "version": "8.1.1" } }
}Fetch a page and return its content. Supports tiered rendering, automatic Cloudflare bypass, TLS fingerprint switching, and JS data extraction.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| format | string | "markdown" |
Output format: markdown / html / text |
|
| selector | string | null |
CSS selector; only extract the matching region | |
| wait_secs | u64 | null |
Extra seconds to wait after page load (let JS rendering finish) | |
| use_proxy | bool | false |
Route through the AGINXBROWSER_PROXY proxy. Set true for overseas sites |
|
| cookies | string[] | object[] | [] |
Cookies injected before navigation: "name=value" strings (may carry ; Domain=…; Path=/; Secure attributes) or cookie objects {"name","value","domain","path","secure","httpOnly","sameSite"}. Entries declaring Domain= anchor at that domain, so sibling-domain login state (.taobao.com / .tmall.com style) survives injection instead of being dropped by RFC 6265 domain checks |
|
| max_chars | usize | 50000 |
Truncate content to this many characters. 0 = unlimited |
|
| auto_bypass_challenge | bool | true |
Automatically detect and bypass Cloudflare Turnstile challenges | |
| render_tier | string | "auto" |
Rendering strategy (see below) | |
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode), see below | |
| js_extract | object | null |
JS data extraction (see below) | |
| sanitize | bool | true |
Strip prompt-injection carriers from the text/markdown output (see below) | |
| capture_xhr | string[] | null |
Return the page's script-initiated XHR/fetch response bodies as a first-class xhr field. Entries are URL substrings; [] = every XHR/fetch. Forces the browser tier |
render_tier options:
| Value | Description |
|---|---|
auto |
Direct HTTP fetch first; automatically fall back to the browser when content is insufficient (recommended, default) |
http |
Pure HTTP, no browser. Fastest but cannot capture JS-rendered content |
browser |
Force the full JS browser. Slowest but most reliable. obscura is still accepted and is not the name to send |
JS-heavy SPA sites (cls.cn, juejin-class feeds, WeChat articles) render their content client-side: with
autothe HTTP pass returns a small skeleton and the sufficiency gate usually catches it, but a site that returns a plausible-looking stub defeats the heuristic. When you know the target is an SPA, pass"render_tier":"browser"explicitly — you skip a wasted HTTP round-trip and get the rendered page directly. Thetierfield in the response tells you which path served a given fetch.WeChat article text (
mp.weixin.qq.com, no explicitselector): the article body is server-rendered inside#js_contentbut hidden until WeChat's own JS reveals it, so plainbodytext is just the title/byline shell.format:"text"/"markdown"reads the#js_contentcontainer and returns the fuller of the two extractions — the full article comes back even when the reveal script never finishes.
tls_fingerprint options (requires --features stealth):
| Value | Description |
|---|---|
null |
Default Chrome145 |
"chrome145" |
Chrome 145 |
"firefox133" |
Firefox 133 |
"firefox147" |
Firefox 147 |
"safari17_5" |
Safari 17.5 |
"safari18" |
Safari 18 |
"safari26" |
Safari 26 |
"edge145" |
Edge 145 |
js_extract format:
{
"expression": "JSON.stringify(window.__INITIAL_STATE__)",
"timeout_ms": 5000
}| Field | Type | Default | Description |
|---|---|---|---|
| expression | string | — | JS expression evaluated in the page context |
| timeout_ms | u64 | 5000 |
Timeout waiting for a non-null result (milliseconds) |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL (after redirects) |
| title | string? | Page title |
| content | string | Fetched content (markdown/html/text) |
| truncated | bool | Whether content was truncated by max_chars |
| tier | string? | Which path served the page: "http" (plain HTTP + conversion, ~100ms) or "browser" (V8 render) — present under render_tier: "auto" too, so callers can see why a fetch was fast or slow |
| redirected_from | string[]? | The redirect trail: redirected_from[0] is the URL you asked for, url is where the content actually came from (absent when no redirect happened) |
| js_extract_result | any? | JS extraction result (only present when js_extract is set) |
| sanitize_report | object? | What the injection stripper removed (only present when sanitize fired — see below) |
| xhr | object[] | Script-initiated response bodies (only present when capture_xhr is set — see below) |
| captcha_event | object? | CAPTCHA event (only present when a CAPTCHA is detected; covers Cloudflare/Google/Baidu challenge pages plus Taobao/Tmall risk-control signals — punish redirects, x5sec, and MTop FAIL_SYS_USER_VALIDATE/RGV587 replies even when they arrive as HTTP 200) |
sanitize — injection stripping (default on):
Page text is untrusted input, and a reading tool owes its caller content that doesn't carry instructions aimed at the reader. On text/markdown output (raw html is never touched) three carriers are stripped:
- Zero-width/steganographic characters (
family) — never legitimate page prose. - Hidden-span text — the live DOM is probed for elements a human can't see (
opacity:0, sub-4px font) whose textinnerTexthappily carries. Whole-hidden containers (SSR-pending reveals, WeChat#js_content) are guarded: when hidden text is more than half the extraction, nothing is removed. - Instruction-shaped lines — lines matching curated injection phrasings (EN/CN: "ignore previous instructions" class, chat markup like
<|im_start|>) are dropped whole, because the payload continues past the matched phrase.
Removal is observable, never silent: when anything fires, sanitize_report says what — {"zero_width_removed": 1, "hidden_spans_removed": 1, "patterns_hit": {"ignore_previous_instructions": 1}}. It's a heuristic, not a firewall; to study the payload itself, pass "sanitize": false. The selector parameter is the CSS-narrowing half of the story: restrict the extraction to the content region and the chrome's injected noise never enters the text at all.
capture_xhr — the page's own API face:
Usually the cleanest read of a JS-heavy page isn't the rendered DOM but the JSON APIs the page itself calls. capture_xhr returns those response bodies alongside the text:
curl -sS -X POST http://127.0.0.1:8089/fetch \
-H "Content-Type: application/json" \
-d '{"url":"https://spa.example.com/list","capture_xhr":["/api/"],"wait_secs":2}'Each row is {"url", "method", "status", "mime", "body", "body_truncated", "request_headers"}. request_headers is the request's outbound header set (lowercased) — the signed customs the page's JS put on the call. Bodies are capped at min(max_chars, 8000) chars per entry (at most 20 entries) so a page's API traffic can't flood the context; binary (base64) bodies are skipped. wait_secs matters here: the page's fetches need a moment to land in the network log.
captcha_event format:
| Field | Type | Description |
|---|---|---|
| engine | string | Name of the search engine that triggered the CAPTCHA (empty for /fetch) |
| captcha_type | string | cloudflare_turnstile / recaptcha_v2 / hcaptcha / slider / unknown |
| url | string | URL that triggered the CAPTCHA |
| detected_at | u64 | Wall-clock time of detection (unix seconds) |
| hit_count | u32 | Consecutive CAPTCHA hits for this engine — the backoff-ladder step driving the suspension duration (1 → 5 min, 2 → 10 min, 3 → 30 min, 4+ → 1 h). Always 1 on the /fetch path |
| auto_solve_attempted | bool | Whether auto-solve was attempted |
| auto_solve_succeeded | bool | Whether auto-solve succeeded |
Example — basic fetch:
curl -sS -X POST http://127.0.0.1:8089/fetch \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'{
"url": "https://example.com/",
"title": "Example Domain",
"content": "# Example Domain\n\nThis domain is for use in illustrative examples...",
"truncated": false,
"tier": "http"
}The tier field reports which strategy served the request: "http" (plain HTTP + conversion) or "browser" (V8 render) — including under render_tier: "auto", so callers can see why a fetch was fast or slow.
Example — extract structured data from an SPA:
curl -sS -X POST http://127.0.0.1:8089/fetch \
-H "Content-Type: application/json" \
-d '{
"url": "https://spa-site.example.com",
"js_extract": {
"expression": "JSON.stringify(window.__INITIAL_STATE__)",
"timeout_ms": 3000
}
}'Example — extract a specific region (CSS selector):
curl -sS -X POST http://127.0.0.1:8089/fetch \
-H "Content-Type: application/json" \
-d '{"url":"https://github.com/trending","format":"text","selector":"article","use_proxy":true}'Caching: /fetch has an in-process cache (key includes url/format/selector/cookies/use_proxy/max_chars/render_tier/tls_fingerprint); TTL is controlled by AGINXBROWSER_CACHE_TTL_SECS (default 600s, 0 disables). Repeat fetches of the same URL hit the cache (~0.01s vs ~1s on first fetch).
Security: built-in SSRF protection (blocks non-http(s) schemes and private-network/loopback IPs), DNS rebinding protection, and tracker blocking (stealth mode). An RFC 9309 robots.txt checker ships built in but is off by default (AGINXBROWSER_HONOR_ROBOTS=1 to opt in).
Load a page and click the specified element (element.click()), returning the page text after the click.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| selector | string | ✅ | — | CSS selector |
| wait_secs | u64 | null |
Extra seconds to wait after page load | |
| use_proxy | bool | false |
Route through a proxy | |
| cookies | string[] | object[] | [] |
Cookies injected before navigation ("name=value" strings or cookie objects, same semantics as /fetch) |
|
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL |
| selector | string | The selector used |
| clicked | bool | Whether the click succeeded |
| text_after | string? | Page text after the click |
Example:
curl -sS -X POST http://127.0.0.1:8089/click \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","selector":"a"}'Execute arbitrary JavaScript on the page and return the result. Supports async/Promise.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| script | string | ✅ | — | JS expression or async IIFE |
| wait_secs | u64 | null |
Extra seconds to wait after page load | |
| use_proxy | bool | false |
Route through a proxy | |
| cookies | string[] | object[] | [] |
Cookies injected before navigation ("name=value" strings or cookie objects, same semantics as /fetch) |
|
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL |
| result | any | JS execution result |
The
scriptparameter of/evalsupports async functions: returned Promises are awaited automatically. Ideal for dynamically rendered React/Vue-style pages — wait for rendering to finish, then extract data.
Example — async script (wait for dynamic rendering):
curl -sS -X POST http://127.0.0.1:8089/eval \
-H "Content-Type: application/json" \
-d '{
"url":"https://github.com/trending",
"script":"(async()=>{await new Promise(r=>setTimeout(r,4000));return Array.from(document.querySelectorAll(\"article.Box-row\")).slice(0,5).map(a=>a.querySelector(\"h2 a\")?.textContent?.trim())})()",
"use_proxy":true
}'Native aggregated search with optional automatic content fetching. Agents go from "search" to "read" in one step.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| q | string | ✅ | — | Search query |
| fetch_top | usize | 0 |
Fetch full page content for the top N results. 0 = snippets only |
|
| categories | string | "general" |
Search category, comma-separated: general / images / news. images returns direct image links |
|
| language | string | "zh-CN" |
Language | |
| max_results | usize | 10 |
Maximum number of results | |
| max_chars_per | usize | 4000 |
Per-result content truncation in characters. 0 = unlimited |
|
| wait_secs | u64 | 3 |
Seconds to wait for JS rendering per page while fetching content | |
| use_proxy | bool | false |
Whether to route content fetching through a proxy (overseas sites) | |
| engines | string[] | [] |
Restrict to these engine names (e.g. ["baidu"]). Empty = all engines serving categories. Unknown names → 400 with the valid list |
|
| time_range | string | — | Freshness window: day / week / month / year. Honored by engines with dated results (bing_news passes a server-side freshness window); others ignore it. Invalid values → 400 |
Unknown request fields are rejected with a 400 — a typo'd parameter name (
count,limit,num) fails loudly instead of being silently ignored.
Built-in search engines (GET /engines returns this list with live health):
| Engine | Categories | Description |
|---|---|---|
| baidu | general | Baidu HTML SERP (browser UA; the old tn=json API is wall-dead) |
| bing | general | Bing HTML parsing |
| sogou | general | Sogou web search |
| sogou_wechat | general, news | Sogou WeChat article search |
| duckduckgo | general | DuckDuckGo HTML (stealth fingerprint) |
| wikipedia | general | Wikipedia MediaWiki search API (language edition follows language; CN-blocked → direct-first/proxy-retry) |
| bing_news | general, news | Bing News infinite-scroll fragment; time_range via server-side freshness window |
| baidu_images | images | Baidu Images acjson JSON |
| bing_images | images | Bing Images images/async |
| arxiv | academic | arXiv API (full-text matching — academic queries only, not the general pool) |
| openalex | academic | OpenAlex works API — cross-publisher literature (DOI links, inverted-index abstracts); occasional anonymous 429s degrade to a skipped engine, arXiv still serves the category |
| huggingface | general, ai | Hugging Face models search |
| github | general, code | GitHub repository search |
| stackexchange | general, code | StackExchange API |
| mdn | code | MDN Web Docs v1 search API (code queries only — CJK fuzzy-match noise keeps it out of the general pool) |
| npm | general, packages | npm registry |
| pypi | general, packages | PyPI |
| rubygems | packages | RubyGems registry (no-offset API — paged client-side; CN-blocked → direct-first/proxy-retry) |
| hn | general | Hacker News via the Algolia API (story links with points/comment counts; time_range maps to a created_at filter; Ask-HN threads fall back to the HN item URL) |
meilisearch additionally registers when AGINXBROWSER_MEILI_URL + AGINXBROWSER_MEILI_INDEX are set (private index). Google search is not included — it requires a proxy and steady CAPTCHA clearance from mainland China; use duckduckgo/bing instead.
Engines are queried concurrently and results merged with deduplication: identical URLs (after normalization) merge into a single entry, engines lists the source engines, and score accumulates. GET /engines returns every engine's name, categories, and live suspension state — call it to discover valid names for the engines filter.
CAPTCHA progressive backoff: when an engine triggers a CAPTCHA it pauses automatically, with the pause duration escalating on consecutive hits (default 5 min → 10 min → 30 min → 1 h) and resetting after a successful search. Override the ladder with AGINXBROWSER_CAPTCHA_BACKOFF_SECS (comma-separated seconds; a single value is flat). Set AGINXBROWSER_CAPTCHA_POLICY=demote to bench the engine for the process lifetime instead of the timed ladder (research loops that would otherwise wait out 300s/3600s walls). Set the CAPTCHA_SOLVER_API_KEY environment variable to enable automatic CAPTCHA solving.
Disabling noisy legs: AGINXBROWSER_DISABLE_ENGINES=bing_news,sogou_wechat (comma names) skips registration. AGINXBROWSER_BING_NEWS=0 is an alias that drops only bing_news — useful on CN boxes where infinitescrollajax returns Object-moved on both direct and proxy paths.
Response fields:
| Field | Type | Description |
|---|---|---|
| query | string | Search query |
| number_of_results | usize | Number of results returned (equals results.length) |
| results | array | Result list |
| captcha_events | array | List of CAPTCHA events (see captcha_event format above — each carries detected_at + hit_count) |
| engine_errors | object | Per-engine reason an engine contributed nothing: CAPTCHA suspension (with resume countdown), transient fetch/parse failure, or a task panic. Absent when every eligible engine answered |
Each entry in results:
| Field | Type | Description |
|---|---|---|
| title | string | Title |
| url | string | Link |
| snippet | string | Search snippet |
| engines | string[] | Source engines |
| score | float | Combined score |
| content | string? | Page content (only present within the fetch_top range) |
| content_truncated | bool | Whether the content was truncated |
| fetch_error | string? | Reason content fetching failed |
| image_url | string? | Direct link to the image binary (downloadable straight to jpg/png with curl -o). Only with categories=images |
| source_url | string? | URL of the page hosting the image (provenance/copyright) |
| width | u32? | Image width (px) |
| height | u32? | Image height (px) |
With
categories=images, theurlfield equalsimage_url(the direct image link, convenient for immediate download);snippetis empty. Baidu Images prefersobjURL(original image, highest quality) and falls back to a CDN-proxied direct link when unavailable.
Example — search + fetch top 3 pages:
curl -sS -X POST http://127.0.0.1:8089/search \
-H "Content-Type: application/json" \
-d '{"q":"macbook 价格","fetch_top":3,"max_chars_per":2000}'Example — restrict to WeChat articles, today's news only:
curl -sS -X POST http://127.0.0.1:8089/search \
-H "Content-Type: application/json" \
-d '{"q":"A股 午评","engines":["sogou_wechat","bing_news"],"categories":"news","time_range":"day","fetch_top":2}'If engines names an engine the server doesn't know, the response is a 400 carrying the valid list:
{"error":"unknown engine \"wechat\"; valid engines: baidu, baidu_images, bing, bing_images, sogou, sogou_wechat, ... (also check GET /engines)"}Example — image search (returns direct links, downloadable straight from curl):
curl -sS -X POST http://127.0.0.1:8089/search \
-H "Content-Type: application/json" \
-d '{"q":"蔚来ES8 酒红内饰 后排视角","categories":"images","max_results":10}'{
"query": "蔚来ES8 酒红内饰 后排视角",
"number_of_results": 20,
"results": [
{
"title": "蔚来ES8 酒红内饰后排实拍",
"url": "https://n.sinaimg.cn/.../img.jpg",
"engines": ["baidu_images"],
"score": 20.0,
"image_url": "https://n.sinaimg.cn/.../img.jpg",
"source_url": "https://auto.sina.com.cn/...",
"width": 1920,
"height": 1080
}
]
}
# Download the image
curl -sL -o cabin_ref.jpg "<image_url>"Discover the search-engine vocabulary plus live health. Returns every registered engine with the categories it serves and its current suspension state — call this before using /search's engines filter, or to see who is currently benched by a CAPTCHA.
curl -sS http://127.0.0.1:8089/engines{
"engines": [
{ "name": "baidu", "categories": ["general"], "suspended": false, "captcha_count": 0, "available": true },
{ "name": "sogou_wechat", "categories": ["general", "news"], "suspended": true,
"suspend_remaining_secs": 184, "captcha_count": 1, "available": false }
],
"available_engines": ["baidu", "bing", "sogou", "..."],
"search_obs": {
"engine_dispatch_total": 120,
"captcha_suspend_total": 3,
"captcha_suspend_share": 0.025,
"bing_news_attempt_total": 40,
"bing_news_dual_path_fail_total": 38,
"bing_news_dual_path_fail_rate": 0.95,
"captcha_policy": "suspend",
"captcha_backoff_secs": [300, 600, 1800, 3600]
}
}suspended: true means the engine hit a CAPTCHA and is in progressive backoff — /search skips it (the response's engine_errors says so, with the resume countdown) until the suspension expires. available / available_engines are the usable set right now. search_obs counters track CAPTCHA-suspend share and bing_news dual-path challenge fail rate over the process lifetime (also on /doctor).
Body-validation failures on the direct→proxy fetch path are classified as geo-substitute / captcha / portal / other in logs and engine_errors (no longer a single "body failed validation"). For challenge-class rejects — and for bing_news infinitescrollajax — the proxy retry uses a ≤2s budget; a dual-path challenge sentences the host so the next call fails immediately instead of re-paying both legs.
Stream a file from a URL to disk. Unlike /fetch (which returns page content for reading), /download saves the raw bytes — use it for binaries, archives, datasets, documents. The body streams chunk-by-chunk to disk (never buffered in memory), with SHA-256 computed incrementally so integrity is verifiable in one call.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | File URL (http/https only) |
| filename | string | auto | Output filename. Auto resolution: Content-Disposition header → URL path tail → "download" |
|
| resume | bool | false |
Continue an interrupted download when a local partial file exists. Server support is probed via Range: bytes=N-: 206 appends, 200 restarts |
|
| use_proxy | bool | false |
Route through proxy (auto-enabled for known blocked domains like github.com) | |
| cookies | string[] | object[] | [] |
Cookies to send (["name=value", ...] or cookie objects) for gated downloads |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL after redirects |
| path | string | Absolute path of the completed file on disk |
| filename | string | Resolved filename |
| size_bytes | u64 | Bytes written by this call (append counts only appended portion) |
| content_type | string? | Response Content-Type |
| sha256 | string | SHA-256 over the complete file content |
| resumed | bool | Whether an existing partial file was continued via Range/206 |
Behavior notes:
- Files land in
AGINXBROWSER_DOWNLOAD_DIR(default: current working directory). In-flight data is written to<filename>.part, then renamed on success. - Same SSRF policy as
/fetch: loopback / private / link-local targets are rejected unlessAGINXBROWSER_ALLOW_PRIVATE_NETWORK=1(all-or-nothing) orAGINXBROWSER_ALLOW_NETWORK=<cidrs>(scoped allowlist — listed ranges open, cloud-metadata endpoints stay blocked). - Redirects are followed (up to 20 hops), each hop re-validated against SSRF.
- A 30s stall timeout aborts if no bytes arrive (dead connection instead of hang). Hard cap: 4 GB per call.
- Filenames are sanitized (path traversal stripped, length capped).
Example — download and verify:
curl -sS -X POST http://127.0.0.1:8089/download \
-H "Content-Type: application/json" \
-d '{"url":"https://github.com/obsidianmd/obsidian-releases/releases/download/v1.5.3/Obsidian-1.5.3-macOS.dmg","resume":true}'Render the page's post-JS DOM into a PNG screenshot (returned as base64). Requires building with --features screenshot (not included by default; see the build section).
Does not use /fetch's tiered rendering — it always drives the JS browser through full execution, then renders the result with the built-in diting engine (CSS cascade + Taffy box layout + CPU paint, no Chromium). Pass "engine": "blitz" to opt into the Blitz reference pipeline for comparison renders — that requires building with --features blitz-reference (blitz is not compiled in by default).
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| width | u32 | 1280 |
Viewport width (CSS px) | |
| height | u32 | 800 |
Viewport height (CSS px; serves as a lower bound when full_page is set) |
|
| scale | f32 | 1.0 |
Device pixel ratio; higher is sharper but yields larger PNGs | |
| full_page | bool | true |
Capture the entire scrollable page (tracks content height, capped at 16000px) | |
| wait_secs | u64 | null |
Extra seconds to wait after load (for JS rendering) | |
| selector | string | null |
CSS selector; capture the specified element region instead of the full page (see below) | |
| selector_all | bool | false |
Used with selector: skip cropping and return coordinates of all matches |
|
| use_proxy | bool | false |
Route through the AGINXBROWSER_PROXY proxy |
|
| cookies | string[] | object[] | [] |
Cookies injected before navigation ("name=value" strings or cookie objects, same semantics as /fetch) |
|
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL (after redirects) |
| title | string? | Page title |
| width | u32 | Actual PNG pixel width rendered (differs from the requested value when full_page tracks content height or selector crops) |
| height | u32 | Actual PNG pixel height rendered |
| image_base64 | string | base64-encoded PNG. Decode with base64 -d, or use directly as data:image/png;base64,... |
| format | string | Always "png" |
| selector_rects | object[]? | Present only when the request includes selector. One {x, y, width, height} per element, in CSS px with the page's top-left corner as origin (not viewport coordinates) |
Selector mode (element-level screenshot + coordinates):
selector+selector_all=false(default): the image is cropped to the border box of the first matching element;selector_rectscontains exactly one entry (the cropped region).selector+selector_all=true: the image renders as a normal full page;selector_rectsreturns coordinates for every match — the agent can consume just the coordinates without the image.- Coordinates come from diting's post-layout Taffy border boxes, accumulated along the layout tree into absolute page coordinates.
⚠️ Inline element limitation: inline elements containing only text (e.g.<a>文字</a>) have no standalone Taffy box — crop mode errors out advising you to pick a block-level ancestor, andselector_allmode returns0x0. Inline elements containing block-level or replaced content (<a><img>etc.) fall back to the union of their descendants' boxes. Selectors targeting block-level containers (div/section/li, etc.) yield reliable coordinates.
Example — screenshot a Baidu search:
curl -sS -X POST http://127.0.0.1:8089/screenshot \
-H "Content-Type: application/json" \
-d '{"url":"https://www.baidu.com/s?wd=蔚来ES8","full_page":true,"wait_secs":2}' \
| jq -r .image_base64 | base64 -d > baidu.pngExample — crop the first search result + get coordinates of all results:
# Crop just the first .result under #content_left
curl -sS -X POST http://127.0.0.1:8089/screenshot \
-H "Content-Type: application/json" \
-d '{"url":"https://www.baidu.com/s?wd=蔚来ES8","selector":"#content_left .result"}' \
| jq -r .image_base64 | base64 -d > first-result.png
# Skip the image — just want page coordinates for all 9 results
curl -sS -X POST http://127.0.0.1:8089/screenshot \
-H "Content-Type: application/json" \
-d '{"url":"https://www.baidu.com/s?wd=蔚来ES8","selector":"#content_left .result","selector_all":true,"full_page":false,"width":100,"height":100}' \
| jq -c '.selector_rects'
# [{"x":150,"y":2843,"width":608,"height":153}, {"x":150,"y":3016,"width":608,"height":69}, ...]{
"url": "https://www.baidu.com/s?wd=蔚来ES8",
"title": "蔚来ES8_百度搜索",
"width": 1280,
"height": 800,
"image_base64": "iVBORw0KGgo...",
"format": "png"
}Screenshots are the agent's "visual input" — CSS rendering on complex sites is approximate (not Chromium pixel-perfect). Sub-resources such as images are not fetched separately (
<img>may be missing from screenshots); text and layout are reliable.
Render a page's animation timelines to an MP4 video (returned as base64). Requires building with --features screenshot and ffmpeg on the server's PATH.
The page's scripts must expose their timelines in window.__timelines — objects with duration() and pause(t) (a paused GSAP timeline registered there works as-is):
const tl = gsap.timeline({ paused: true });
tl.from("#box", { opacity: 0, x: -200, duration: 2, ease: "power2.out" });
window.__timelines = { main: tl };Each frame seeks every registered timeline to t = i/fps and paints the viewport — the frame values carry no wall clock, so output is deterministic across runs. The full path runs in-process: seek → viewport band paint → raw RGBA piped into ffmpeg → H.264/yuv420p MP4.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL (must populate window.__timelines) |
| fps | f64 | 24 |
Frames per second | |
| width | u32 | 1280 |
Viewport width (CSS px; floored to even — yuv420p) | |
| height | u32 | 720 |
Viewport height (CSS px) | |
| hold_tail_secs | f64 | 0.5 |
Freeze the final timeline state for this many extra seconds | |
| max_duration_secs | f64 | 120 |
Safety cap on timeline + hold tail (longer timelines error instead of encoding) | |
| wait_timelines_ms | u64 | 10000 |
How long to wait for window.__timelines to appear |
|
| use_proxy | bool | false |
Route through the AGINXBROWSER_PROXY proxy |
|
| cookies | string[] | object[] | [] |
Cookies injected before navigation (same semantics as /fetch) |
|
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) | |
| narration | object[] | [] |
Voiceover clips: {url, start_secs, volume} — each fetched, delayed to its start time, all mixed into one AAC track. Any TTS output works (mp3/wav/ogg/m4a, probed by content). Fetch failure is an error, never a silent video |
|
| audio | object | null |
Background music: {url, volume, fade_out_secs, loop_audio} — looped to cover the video, volume-scaled, faded out at the tail |
|
| subtitles_srt | string | null |
Inline SRT text muxed as a soft (toggleable) mov_text track — no libass needed for muxing. Cap 64 KiB | |
| subtitles_language | string | null |
ISO language tag for the subtitle track ("eng", "zh") |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL |
| title | string? | Page title |
| frames | u32 | Frames written to the encoder |
| timeline_secs | f64 | Longest registered timeline, seconds |
| duration_secs | f64 | Total video length = timeline + hold tail |
| width / height | u32 | Encoded pixel size |
| video_base64 | string | base64-encoded MP4 (H.264, yuv420p). Decode with base64 -d, or use directly as data:video/mp4;base64,... |
| has_audio | bool | Whether an audio track (BGM and/or narration) was muxed in |
| has_subtitles | bool | Whether a soft subtitle track was muxed in |
| format | string | Always "mp4" |
Audio details: audio URLs are fetched through the page's own HTTP client (http/https only, ≤16 MiB, 8 s timeout, same SSRF gate as subresources). One audio source rides a plain -af volume/adelay/afade chain; two or more (e.g. BGM + narration clips) are mixed with amix=normalize=0 so narration isn't halved for having quiet music under it. With ≥2 audio inputs the subtitle stream is mapped explicitly. Burned-in captions need no engine support — register a second __timelines entry that writes caption text/opacity in pause(t), and it seeks along with everything else.
Example:
curl -sS -X POST http://127.0.0.1:8089/video \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/anim.html","fps":20,"width":800,"height":450}' \
| jq -r .video_base64 | base64 -d > anim.mp4Errors carry the failure reason verbatim: no __timelines before wait_timelines_ms, zero timeline duration, the duration cap, ffmpeg missing from PATH, or ffmpeg exiting non-zero (with its stderr tail).
Cut a rendered page into a set of pages and package as PDF (default), per-page PNGs, or an image-based PPTX/DOCX. Requires building with --features screenshot.
Two slicing modes, picked by whether selector is set:
- Print (no
selector): fixed-height pages over the whole document (default 794×1123, A4 @96dpi), breaking at top-level block boundaries where possible — the break lands on the deepest block bottom that fits, with a half-page floor so pages never collapse to slivers. A remainder shorter than 64px merges into the previous page instead of a near-blank tail. - Slides (
selectorset): one page per CSS-selector match, sized to that element. Build the deck as plain HTML with one.slidediv per slide; each match becomes a page at its own height.
Each page is painted as a viewport band off the live tree's layout — same primitive the timeline pump rides, no Chromium. The PDF is image-based: per-page JPEG (jpeg_quality) embedded via DCTDecode, one page object per page with its own MediaBox in points (px→pt at 96dpi), so variable-height pages need no normalization. PPTX and DOCX are the same pages re-packaged: PPTX as one slide per page (deck-sized to the largest page, images anchored top-left), DOCX as one page-sized section per page with zero margins — Word sizes every section independently, so each page keeps its exact height. Both containers are written by hand (stored-ZIP, fixed timestamps — byte-deterministic), zero new dependencies.
format="pptx-native" is the editable tier: instead of rasterizing pages, each selector match is walked element-by-element off the live tree (gBCR + computed style from the same layout cache the band paints ride) and mapped to native DrawingML — text becomes real text runs (<a:t> with font family/size/weight/color/alignment), background boxes become shapes (solid fill, border-radius → roundRect, CSS gradients → gradFill with the angle converted), <img> becomes a p:pic with the fetched bytes as a media part. Slides mode only: selector is required, one slide per match, and the deck is sized to the largest slide. What a text box can't express (per-glyph inline styling, z-index reordering, transforms, borders) degrades by omission — the element still lands as an editable shape. Note: the engine doesn't expand the CSS background shorthand into background-image yet, so gradient slides must use the background-image longhand.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| format | string | "pdf" |
"pdf" (base64 PDF), "png" (one base64 PNG per page), "pptx" (one slide per page), "pptx-native" (editable: element-level DrawingML; requires selector), or "docx" (one page-sized section per page) |
|
| width | u32 | 794 |
Page width (CSS px) | |
| height | u32 | 1123 |
Page height (CSS px) — print pagination only; slides size each page to its element | |
| selector | string | null |
CSS selector; set → slides mode, unset → print mode | |
| max_pages | usize | 50 |
Safety cap on emitted pages (more pages errors instead of rendering) | |
| jpeg_quality | u8 | 90 |
JPEG quality 1-100 for page embedding in PDF/PPTX/DOCX (PNG format ignores it) | |
| use_proxy | bool | false |
Route through the AGINXBROWSER_PROXY proxy |
|
| cookies | string[] | object[] | [] |
Cookies injected before navigation (same semantics as /fetch) |
|
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) |
Response fields:
| Field | Type | Description |
|---|---|---|
| url | string | Final URL |
| title | string? | Page title |
| pages | usize | Page count |
| width / height | u32 | Requested page size (slides pages vary in height — each PNG self-describes) |
| pdf_base64 | string? | base64 PDF (set when format="pdf"). Decode with base64 -d, or use directly as data:application/pdf;base64,... |
| pages_base64 | string[] | One base64 PNG per page (non-empty when format="png") |
| pptx_base64 | string? | base64 PPTX, one slide per page (set when format="pptx" or "pptx-native") |
| docx_base64 | string? | base64 DOCX, one page-sized section per page (set when format="docx") |
| format | string | "pdf" / "png" / "pptx" / "pptx-native" / "docx" |
Examples:
# Print mode: paginate an article into A4 pages
curl -sS -X POST http://127.0.0.1:8089/pdf \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/long-article.html"}' \
| jq -r .pdf_base64 | base64 -d > article.pdf
# Slides mode: one .slide per page, as PNGs
curl -sS -X POST http://127.0.0.1:8089/pdf \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/deck.html","selector":".slide","format":"png"}' \
| jq -r '.pages_base64[0]' | base64 -d > slide-0.png
# Slides mode → PowerPoint deck (HTML .slide divs become real slides)
curl -sS -X POST http://127.0.0.1:8089/pdf \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/deck.html","selector":".slide","format":"pptx"}' \
| jq -r .pptx_base64 | base64 -d > deck.pptx
# Slides mode → editable PowerPoint (text runs, shapes, gradients, images)
curl -sS -X POST http://127.0.0.1:8089/pdf \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/deck.html","selector":".slide","format":"pptx-native"}' \
| jq -r .pptx_base64 | base64 -d > deck.pptxErrors carry the reason verbatim: a selector that matches nothing, more pages than max_pages, or a document with no content height.
Firecrawl-compatible endpoint. Existing Firecrawl clients can migrate by simply changing the base URL.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | ✅ | — | Target URL |
| formats | string[] | ["markdown"] |
Output formats: ["markdown"] / ["html"] / ["markdown","html"] |
|
| onlyMainContent | bool | false |
Main content only (parameter accepted, not yet implemented) | |
| waitFor | u64 | null |
Milliseconds to wait for JS rendering | |
| timeout | u32 | null |
Timeout in milliseconds (parameter accepted) | |
| actions | object[] | [] |
Actions to perform before scraping (see below) | |
| selector | string | null |
CSS selector | |
| tls_fingerprint | string | null |
TLS fingerprint (stealth mode) |
actions format:
[
{"type": "click", "selector": "button.accept"},
{"type": "wait", "milliseconds": 1000}
]| type | Fields | Description |
|---|---|---|
click |
selector |
Click an element (anchor links navigate to the target page) |
wait |
milliseconds |
Wait the given number of milliseconds |
screenshot |
— | Screenshot the rendered page, returned as a base64 data-URI (requires the screenshot feature) |
scroll |
— | Scroll the page |
writeText |
text, selector |
Type text into the matching element |
pressKey |
key |
Press a key (Enter submits the enclosing GET form) |
When any actions are present, /v1/scrape follows a single-page session flow: navigate once → execute actions in order → extract from the final state of that page. All actions operate on the same page. When the request includes a screenshot action (or formats contains "screenshot"), the response's data.screenshot carries a data:image/png;base64,... screenshot; the field is omitted when the screenshot feature is not enabled.
Response (Firecrawl format; HTTP 200 for both success and failure):
{
"success": true,
"data": {
"markdown": "...",
"html": "...",
"metadata": {
"title": "Example Domain",
"sourceURL": "https://example.com/",
"description": "...",
"statusCode": 200
}
}
}Persistent browser sessions with indexed interaction. Each session gets its own V8 runtime + page context, and is reclaimed automatically after 8 minutes of inactivity.
Lets AI agents browse the web the way a human does: open a page → inspect state → click/type → collect results.
Create an interactive browser session.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | null |
Initial URL (optional); start_url is accepted as an alias — creation navigates there before the session id is handed back |
|
| use_proxy | bool | false |
Route through a proxy | |
| cookies | string[] | object[] | [] |
Cookies injected before navigation ("name=value",... or cookie objects) so the session starts already logged in |
|
| persistent | bool | false |
Persist login state to the server-side store: if the session idles out or the server restarts, the same session_id revives logged-in on the next call (session/{id}/close drops the snapshot; idle expiry keeps it) |
|
| account | string | null |
Run as a named login identity (see Named Accounts): a private cookie jar seeded from the account record, written back after every action — concurrent logins on different accounts never clobber each other, and none touch the anonymous shared jar. The identity's device persona (own UA + hardware fingerprint) rides every session |
Response:
{"session_id": "s_1", "url": "https://example.com/"}Turn a DevTools "Copy as cURL" command into a logged-in browser session — the credential-transfer path that works on a stock headless engine (no extension, no debug port). The human does the hard part of a login (CAPTCHA, SMS, slider) in their own Chrome, opens DevTools → Network, right-clicks any authenticated request → Copy → Copy as cURL, and pastes the command here. The engine parses out the Cookie header / -b jar, injects it into a fresh session anchored at the copied request's URL — the agent continues from where the human left off, no password or second login. bash, PowerShell and cmd copy flavors all parse.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| curl | string | ✅ | — | The copied cURL command |
| use_proxy | bool | false |
Route the session's traffic through the AGINXBROWSER_PROXY proxy |
|
| account | string | null |
Attach the session to a named account: the imported login lands in the account's private jar and is written back under its name — one import per identity, no clobbering. On first import the copied command's User-Agent becomes the identity's persona (its device UA, reused by every later session) |
Response:
{
"session_id": "s_1",
"url": "https://example.com/member/home",
"host": "example.com",
"cookie_count": 12,
"method": "GET",
"has_body": false,
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/145.0.0.0",
"authorization_prefix": "Bearer eyJhbGciOi…",
"expires_in_secs": 480,
"note": "login state injected; navigate with session tools"
}The session_id is an ordinary session — drive it with the session API. authorization_prefix previews only the first 16 chars (the full header is a credential; the response never echoes whole cookies). method/has_body report what kind of request was copied. Cookies anchor on the copied request's host; other subdomains of the site may re-authenticate — that is the site's device binding, not a lost credential.
Tips: an XHR on the logged-in page usually carries the most complete cookie set (document requests sometimes miss the httpOnly API-session pair). -b FILE cookie jars are rejected — the server cannot read your disk. Treat a pasted command like a password: it carries the login state verbatim.
Derive a new session carrying the source's full login state — cookies, localStorage/sessionStorage, viewport pin, dialog policy, proxy and keepalive flags — with the source left untouched. Use it to snapshot a logged-in state before risky actions, or to run the same login in parallel sessions. The manual cookies → session/create round-trip this replaces is where a hand-edited cookie string clobbers a working login.
Response:
{"session_id": "s_2", "cloned_from": "s_1", "url": "https://example.com/dashboard", "viewport": {"width": 390, "height": 844, "mobile": true}, "expires_in_secs": 431}viewport is null when the source never pinned one.
List live sessions — the discovery twin of /session/create (reuse an idle session instead of spawning a fresh V8 thread per step). Entries carry idle age and the eviction budget; most recently active first.
Response:
{"count": 1, "sessions": [{"session_id": "s_1", "idle_secs": 19, "expires_in_secs": 460}]}Sessions are process-global and shared across callers — that's what makes "one instance per machine, every agent shares it" work.
The full listing — every live session with its current page URL, so an agent resuming work can find "the session that was on the publish page" without guessing ids. Each entry asks its session thread for the URL with a 250ms budget, so a session pinned mid-eval reports "url": null instead of stalling the listing.
Response:
{
"count": 2,
"sessions": [
{"session_id": "s_1", "url": "https://example.com/publish", "idle_secs": 12, "expires_in_secs": 468, "keepalive": false, "persistent": true},
{"session_id": "s_2", "url": null, "idle_secs": 3, "expires_in_secs": 477, "keepalive": false, "persistent": false}
]
}expires_in_secs is absent for keepalive sessions (they never auto-evict); persistent marks sessions whose login state is snapshotted and revivable. Close entries with POST /session/{id}/close. /session/list remains the lightweight sibling (id + idle age only, no per-session round-trips).
Restarts and upgrades — on SIGTERM/SIGINT every live session (persistent or not) is flushed to the store before the process exits, and the next process revives them under their own ids before it opens its listener: a binary swap costs no login state and no session ids, and GET /sessions right after a restart already shows the fleet. Restored sessions keep their original flags — a non-persistent session that then idle-expires takes its restart snapshot with it (a later touch of the id is SESSION_NOT_FOUND, not a revival with stale cookies). The flush is budgeted (60s overall, 10s per session): a session stuck mid-command keeps whatever its last per-command snapshot held. An explicit close still means done.
Eager restore gate / store isolation — parallel dogfood ports that share the default ~/.aginxbrowser/cache.db will otherwise revive every leftover session (including keepalive SPAs) into the new process and can wedge it. Pin a per-instance database with AGINXBROWSER_STORE_PATH or AGINXBROWSER_COOKIE_STORE_DIR / AGINXBROWSER_STORAGE_DIR (snapshots live in that store). When the store must stay shared, set AGINXBROWSER_RESTORE_SESSIONS=0 to skip eager restore at serve start (snapshots remain for lazy revive-on-first-touch), or skip-keepalive to restore only non-keepalive snapshots. Default remains all (#88 upgrade continuity).
Anonymous traffic shares one process-global cookie jar — deliberate (repeat-visitor cookies cut CAPTCHA rates) but it makes multi-account work impossible: two logins on one site clobber each other's session cookies, last writer wins. A named account is the fix — Chrome-profile semantics: one jar per identity, not a site×account matrix. taobao-scraper and taobao-publisher are two accounts; each keeps its own cookies and login state, written back to the store after every action, surviving idle eviction and server restarts. Sessions on the same account share one live jar (two tabs, one profile); different accounts never touch each other — and none touch the anonymous shared jar, since shared cookie history is itself a risk-control linkage signal.
Attach a session with account on POST /session/create or POST /import/curl (1-64 chars of [a-zA-Z0-9_-]). The account is the persistence — account sessions don't need persistent: true.
Per-account device persona — an identity is one stable device, not just one cookie jar. On first use an account draws a persona — a User-Agent from the Windows/macOS Chrome-145 pool plus a hardware seed that drives its screen/devicePixelRatio/GPU/canvas fingerprint — remembers both in its record, and reuses them for every later session, restarts included. Jar isolation alone can't stop risk-control linkage: two logins sharing one fingerprint read as "one device with two accounts". import_curl seeds the persona from the copied command's real User-Agent (the device the site already saw alongside those cookies). The pool is pinned to Chrome 145 so UA and the default TLS handshake never disagree; the hardware seed itself stays server-side — GET /accounts reports only the persona UA (persona_ua).
List accounts:
curl -sS http://127.0.0.1:8089/accounts{
"count": 2,
"accounts": [
{"name": "taobao-publisher", "domains": ["taobao.com", "tmall.com"], "cookie_count": 34, "updated_at": 1762934400, "persona_ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", "verify_url": "https://www.taobao.com/", "verify_predicate": "!!document.querySelector('.user-nick')", "verify_last": {"logged_in": true, "url": "https://www.taobao.com/", "checked_at": 1762934401}},
{"name": "taobao-scraper", "domains": ["taobao.com"], "cookie_count": 29, "updated_at": 1762933900, "persona_ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}
]
}Metadata only — cookie values are credentials and never leave the server.
Check login (teach-once):
curl -sS -X POST http://127.0.0.1:8089/account/verify \
-H 'content-type: application/json' \
-d '{"name": "taobao-scraper", "url": "https://www.taobao.com/", "predicate": "!!document.querySelector(\".user-nick\")"}'{"name": "taobao-scraper", "logged_in": true, "url": "https://www.taobao.com/", "checked_at": 1762934455, "verify_url": "https://www.taobao.com/"}The first call teaches the spec — url (a page that shows login state) and predicate (a JS expression truthy when logged in). It's remembered on the account; later calls can be bare ({"name": "taobao-scraper"}) and rerun the spec. The probe runs in a scratch session as the account — private jar, same egress — so a verify doubles as a cookie refresh. A probe failure (network, predicate throw) returns an error and is not a logout verdict; the last successful verdict rides in verify_last.
Log an account in (login wizard):
curl -sS -X POST http://127.0.0.1:8089/account/login \
-H 'content-type: application/json' \
-d '{"name": "xhs-main", "url": "https://www.xiaohongshu.com/login"}'{"name": "xhs-main", "status": "waiting", "needs": ["qr"], "markers": ["qr:login-qrcode-img"],
"verdict": "login", "url": "https://www.xiaohongshu.com/login", "session_id": "s_42",
"expires_in_secs": 1793,
"handoff": "human step (qr) — open /live?session=s_42 ...; when done, re-call account_login with the same arguments (the account's shared jar already holds the finished login)"}One call opens the login page as the account (implicitly created on first use; private jar + persona + recorded egress from the start) and probes the generic gates the page shows — password / sms / qr / slider — with markers carrying the evidence strings. The three statuses:
logged_in— no gate detected and the passedpredicatefired: the automatic bounce happened, and the verify spec (url= the post-login landing URL,predicateas given) is stamped. Cookies are already written back (every successful action captures the account jar).waiting— a human step is outstanding (needsnon-empty). The session stays open (30 min ttl) for the/live?session=<id>handoff or scriptedsession_wait/session_input. When the human finishes, re-call with the same arguments — the account's shared jar already holds the login, so the second call probes clean, the predicate fires, and verify is stamped. Idempotent by jar semantics, no state machine.opened— nopredicategiven: page is open, gates reported; drive it withsession_input/session_waitonsession_idand finish withaccount_verifyto stamp the spec.
use_proxy seeds a fresh account's egress; an account with an existing record always reuses its recorded egress (one identity, one exit — changing it is a risk-control linkage signal). timeout_ms (default 60000, clamped 1000..120000) is the wait budget for the automatic bounce only — it is never spent while a human step is outstanding. The wizard never fills credentials or beats risk control; when a site rejects the environment, needs/verdict say so and import_curl (human logs in their own Chrome) remains the fallback.
Delete an account:
curl -sS -X DELETE http://127.0.0.1:8089/accounts/taobao-scraper{"deleted": "taobao-scraper"} — stored record AND live jar. Cookie values are credentials; delete means gone. Unknown names get HTTP 404.
Navigate to a new URL.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | Target URL |
Response:
{"url": "https://example.com/page2", "title": "Page 2"}When the navigation lands on an anti-bot wall (punish page / _____tmd_____/punish), a challenge field joins the response — risk-control pages answer like ordinary pages, the flag is the machine-readable verdict: {"url": "https://punish.taobao.com/", "title": "...", "challenge": "punish"}.
When HTTP redirects happened in flight, redirected_from joins the response with the full trail (issue #102): redirected_from[0] is the URL you asked for, url is where the document actually came from. The trail alone separates a login bounce (login.example.com → example.com/dashboard), a parameter-error rewrite (…?err=invalid destination) and a direct landing — same final URL, three different situations. Absent when the navigation involved no redirect.
When a navigation exceeds its deadline (AGINXBROWSER_NAV_TIMEOUT_MS, default 30s), the error names the phase that died instead of a bare timer: document fetch in flight / document received, not yet committed / document committed, scripts/settle still running / loaded after the deadline raced, plus the active document URL and the navigation generation — navigation exceeded 30000ms deadline (document fetch in flight; active document: https://old.example/; requested: https://new.example/; navigation #3 — /network rows carry the generation as "nav"). If the attempt never committed, the session's url rolls back to the document still running, so navigate's failure report, /state and eval describe the same page (#101).
Replace the session's document-start preload group (issue #96). Sources run before each new document's own scripts — including inline <script> tags — which is the only hook that beats pages whose signing layer captures window.fetch/XHR natives at HTML-parse time (xhs's inline jsvmp: its API calls go out through references saved at parse time, so any eval-based patch lands too late and the calls fire unsigned/invalid-signed → 406).
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
| scripts | string[] | ❌ | Full JS sources, in order (default []). Alone, [] clears the group |
| recipe | string | ❌ | Builtin recipe name appended after scripts (e.g. "xhs-sign") — opt-in document-start wrapper (#84). Unknown names are an error, not a silent no-op. Not auto-mounted by host; platform policy lives in workflow/. Kept for back-compat |
| recipes | string[] | ❌ | Builtin recipe names appended after recipe (same resolve path as flow preload recipes:[]). Final order: scripts, then recipe, then recipes[] |
| apply_now | bool | ❌ | Also evaluate the same sources on the current document (default false). Document-start alone only arms the next navigation — set this when attaching a recipe to an already-open page (mirrors flow preload apply_now) |
Set the group before the session's first navigate (create without url → preload → navigate) and it applies to every navigation from then on, set_content included.
curl -sS -X POST http://127.0.0.1:8089/session/s_42/preload \
-H "Content-Type: application/json" \
-d '{"scripts":["window.__patched = true; const F = window.fetch; window.fetch = function(u,o){ /* sign/re-record */ return F.call(this,u,o); };"]}'
# → {"count": 1}
# multiple named recipes (HTTP parity with flow preload recipes:[])
curl -sS -X POST http://127.0.0.1:8089/session/s_42/preload \
-H "Content-Type: application/json" \
-d '{"recipes":["x-p256","x-bearer","x-auth-ctx"],"apply_now":true}'Response: {"count": N}
Platform signing / logout-fuse policy lives in workflow/ (delivery unit = engine + flow), not in the engine's default navigation path. Host auto-mount on *.xiaohongshu.com / *.xhslink.com was removed 2026-10-12.
The engine still ships the maintained xhs-sign source for explicit callers:
| Recipe | How to mount | What it does |
|---|---|---|
xhs-sign (alias xhs) |
POST /session/{id}/preload {"recipe":"xhs-sign"}, or flow step {"op":"preload","args":{"recipe":"xhs-sign"}}, or paste JS via flow preload/eval |
xhs signing wrapper: swallows the page's own x-s/x-t (computed in-engine, rejected 406 by the server) and re-signs both at send time via the page's own _webmsxyw; fuses /sso/logout (unsigned-API 406s misread as session death) with a synthetic 200; mounts itself as the fetch/XHR natives before the inline jsvmp can capture them |
xhs-qrcode |
flow / HTTP preload {"recipes":["xhs-sign","xhs-qrcode"]} (used by xhs-login) |
vendored qrcode-generator for scannable SVG QR — keeps ~20KB out of flow.json |
x-p256 |
flow / HTTP preload {"recipes":["x-p256","x-bearer","x-auth-ctx"],"apply_now":true} (x-reply / x-follow) |
shared in-page P-256 for X write headers; apply_now evals on the current document when attaching to a live session |
x-bearer |
flow preload {"recipe":"x-bearer","apply_now":true} (all five x-* flows); HTTP {"recipe":"x-bearer","apply_now":true} or in recipes:[] |
shared bearer probe — installs abxProbeBearer (12h localStorage cache / scrape from live main.*.js) |
x-auth-ctx |
flow / HTTP preload {"recipes":["x-p256","x-bearer","x-auth-ctx"],"apply_now":true} (x-reply / x-follow) |
shared txid ctx + session bind — installs abxEnsureAuthCtx + abxB64keep / abxDerEncode / abxAuthHeaders / abxTxidGen |
Observability counters live on window for post-hoc checks via eval: __xs_installed (mounted), __xs_swallowed (page signatures swallowed), __xs_signed (fresh signatures issued), __xs_fuse (logout attempts defused).
navigate's preloads field remains on the wire but stays empty while no host auto-mounts apply (user / recipe= scripts are not listed there). Prefer create-without-url → preload → navigate so document-start beats the page's parse-time capture. The former kill switch AGINXBROWSER_DISABLE_SITE_PRELOAD=1 is obsolete (nothing auto-mounts).
Get the current page state as an indexed list of interactive elements.
Response format (compact text):
url=https://example.com/login
title=Login
viewport=1280x800
[0] <a href="/home" rect=[24,16,52x19]>Home</a>
[1] <input type=email placeholder=Email rect=[24,60,232x22] />
[2] <input type=password placeholder=Password rect=[24,100,232x22] />
[3] <button id=submit rect=[24,140,88x28]>Sign In</button>
[4] <a href="/forgot" rect=[120,144,110x19]>Forgot password?</a>
Index numbers [N] feed click / input operations. rect=[x,y,w,h] is the element's position relative to the current viewport (y changes with scrolling) — use it to judge whether an element is in view and needs a scroll before click.
Click an interactive element by index.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
| index | usize | ✅ | Element index (from /state) |
Response:
{"url": "https://example.com/dashboard", "clicked": true, "text_after": "Dashboard …"}text_after is the landed page's text after the click (capped at 2000 chars) — before/after evidence in one response, same contract as /click.
Click at page coordinates via the real mouse chain: pointerdown/mousedown → pointerup/mouseup → click, each hit-tested through elementFromPoint at the given viewport position — the shape canvas/map pages and custom widgets listen for. click_count: 2 also synthesizes dblclick.
| Field | Type | Default | Description |
|---|---|---|---|
| x | number | ✅ | Viewport X in CSS pixels |
| y | number | ✅ | Viewport Y in CSS pixels |
| button | string | "left" |
left / right / middle |
| click_count | u32 | 1 |
Chrome click count: 2 adds dblclick, 3+ sets detail |
Response: {"url": "...", "x": 120, "y": 120}
Press at from, glide through steps mousemove events, release at to — drags a marker/canvas selection the way a real pointer would, so move-driven widgets (map markers, drag handles, sliders) track every intermediate position. The trajectory is humanized by default: minimum-jerk easing, perpendicular wobble, jittered per-step timing, a grip pause after press and a settle pause before release, occasional hesitation, and sometimes an overshoot-and-correct for long drags — the shapes anti-bot checks score for. Pass humanize: false for the exact linear interpolation.
| Field | Type | Default | Description |
|---|---|---|---|
| from | object | ✅ | {"x":…,"y":…} press point |
| to | object | ✅ | {"x":…,"y":…} release point |
| steps | u32 | 24 humanized / 10 linear |
Move events (clamped 1..200) |
| delay_ms | u64 | 18 humanized / 30 linear |
Mean pause between moves (clamped 0..1000; jittered when humanizing) |
| humanize | bool | true |
false switches back to exact linear interpolation |
Response: {"url": "...", "from": {"x":120,"y":120}, "to": {"x":300,"y":220}, "steps": 24, "humanized": true}
Type text into an input field by index. The element is focused first; after writing the value, input + change events are dispatched (bubbling), so framework-bound forms (Vue/React models, validation listeners) see the text.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
| index | usize | ✅ | Element index |
| text | string | ✅ | Text to enter — any characters (newlines, quotes, backslashes) ride through as-is |
| events | string | "standard" |
standard → input + change after the value lands; full → focus, per-character keydown/keypress/input/keyup cycles, trailing change, then blur — the complete human typing gesture. The tail blur is what commits on forms that save in onBlur (React capture-phase blur listeners never saw script-typed text before, #100). In full mode a \n in the text types as the Enter key (key:'Enter', keyCode 13 — the "type + Enter submit" encoding): a textarea keeps the newline in its value, a single-line <input> appends nothing; the page's Enter listeners (search bars, chat inputs) fire before the blur, and if the key wasn't canceled the input's form submits implicitly (Chrome's default action — requestSubmit, so validation and the form's own submit listeners run; a preventDefault on the key events or the submit event vetoes it, like Chrome). \r is never typed |
Response:
{"filled": true, "tag": "input", "id": "q", "name": "q", "value": "the typed text"}A refused fill answers filled:false with a reason naming the branch: readonly, disabled, detached, no-element (index no longer maps to a live node — refresh state), wrong-tag (index points at a non-input element), or script-error (the fill script didn't complete — a page handler threw or the watchdog terminated a runaway listener).
Select files on a file input programmatically (Playwright setInputFiles semantics). Each file is built from its base64 content and assigned through input.files, then input + change dispatch so framework onChange handlers fire. The page's own FormData(form)/submit serialization then uploads them as real multipart parts. Selector-addressed because file inputs are routinely hidden — the /state index may not include them. The body limit is the same as /eval (AGINXBROWSER_MAX_BODY_BYTES, default 64 MiB).
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
| selector | string | ✅ | CSS selector for the file input, e.g. input[type=file] |
| files | array | ✅ | {name, content_base64, mime_type?, last_modified?} — one entry per file (multi-select) |
Response:
{"set": true, "selector": "input[type=file]", "count": 2,
"value": "C:\\fakepath\\a.png",
"files": [{"name": "a.png", "size": 2, "type": "image/png"}, ...]}set: false with an error field (no element / not a file input / invalid base64) instead of a throw.
Scroll the page.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| direction | string | "down" |
up or down |
|
| amount | u32 | 3 |
Number of viewport heights to scroll |
Response:
{"scrolled": true}Execute JavaScript within the session.
Request fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| script | string | ✅ | — | JS code (async supported) |
| timeout_ms | u64 | 5000 |
Await budget for the script's promise, clamped 100..120000. Slow page-side work (an upload driven through the page's own fetch) legitimately outlives the default — raise it per call instead of retrying blind |
Response:
{"result": "..."}Error semantics: a script whose promise never settles does not come back as {result: null} — it's an HTTP 504 with {"error": "EVAL_TIMEOUT: expression did not settle within 5000ms — the script may still be running; verify side effects before retrying"}. The message is a warning on purpose: the script may have completed its side effects after the budget expired (a submit that landed, a write that went through), so verify before retrying rather than firing it again. Genuine JS null still returns 200 {"result": null}; a throwing/rejecting script returns EVAL_ERROR with name, message, throw position and first stack frame.
Body limit: request bodies up to 64 MiB by default (raise with AGINXBROWSER_MAX_BODY_BYTES); oversized bodies get a structured 413 {"error": "EVAL_BODY_TOO_LARGE: request body exceeded the 67108864-byte limit ..."} instead of a bare-text rejection. The same limit covers /screenshot, /video and /pdf.
Close the session and release its resources. For a persistent session this also drops the on-disk login snapshot — idle expiry keeps it, an explicit close does not.
Response:
{"ok": true}Export the session's cookies as full Set-Cookie strings (["name=value; Domain=example.com; Path=/", ...], flags and Expires included). Use it to persist login state — store it, then pass cookies to a future session_create to start the session already logged in, no re-login needed. The full form (not bare name=value pairs) is what makes cross-subdomain logins survive the round-trip: a .taobao.com cookie re-anchors at its own domain on the way back in, where a bare pair would be scoped to whatever page you happen to open. Expires rides along too, so a stored snapshot never revives a cookie the server already retired (#203 — risk-control faces like taobao's answer a stale-token replay with sessionExpired).
Response:
{"url": "https://example.com/dashboard", "cookies": ["sessionid=abc123; Domain=example.com; Path=/", "csrftoken=xyz; Domain=example.com; Path=/; HttpOnly"]}Login-state reuse loop:
# 1. Log in normally in one session (session_create -> input -> click)
# 2. Export the cookies
curl -sS http://127.0.0.1:8089/session/$SID/cookies | jq -r .cookies[]
# 3. Next time, create the session with cookies directly — no login required
curl -sS -X POST http://127.0.0.1:8089/session/create \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/dashboard","cookies":["sessionid=abc123; Domain=example.com; Path=/"]}'🔒 The hosted instance never persists any cookie to disk — cookies live only in session memory and are wiped when the session is reclaimed after 8 minutes idle. Callers hold their own login state (use a throwaway account, not your main one).
Metadata-only view (?meta=true) — the observability twin of the value-bearing export (issue #102). Answers "what auth state exists" without a single credential leaving the server: which cookies are present, how each is scoped (domain/path/secure/httpOnly/sameSite), when it dies (expires, Unix seconds — null = session cookie) and whether it's host-anchored (hostOnly = set without a Domain= attribute, sent to the exact host only). The payload carries no value key at all — values are credentials, and this face is for checking which login landed, not for reusing it.
curl -sS "http://127.0.0.1:8089/session/$SID/cookies?meta=true" | jq .{"url": "https://example.com/dashboard", "cookies": [{"name": "sessionid", "domain": "example.com", "path": "/", "secure": true, "httpOnly": true, "sameSite": "Lax", "expires": 1761284509, "hostOnly": false}]}Export the session's recorded action log. Every page-changing command since create — navigate / click / input / scroll / eval — was recorded in order, in memory only (reads like state and cookies are not recorded; the log dies with the session).
Query parameters:
| Field | Type | Default | Description |
|---|---|---|---|
| format | string | bash |
bash → a runnable curl script replaying every recorded action against a fresh session; jsonl → the raw action log, one JSON object per line; json → a flow.json document — the same recording as editable ops ({op, args}) with cookies/storage stripped, for replay via POST /flow/run |
Response (format=jsonl):
{"action":"create","url":"https://example.com/login","use_proxy":false,"cookies":[]}
{"action":"input","index":1,"text":"user@example.com","ok":true}
{"action":"click","index":3,"ok":true}
Response (format=bash, the default) — a self-contained replay script:
#!/usr/bin/env bash
# aginxbrowser session replay — recorded actions re-run as plain curl.
# No LLM in the loop: replay costs zero model tokens.
set -eu
BASE="${AGINXBROWSER_URL:-http://127.0.0.1:8089}"
POST() { curl -sS -X POST "$BASE/$1" -H 'Content-Type: application/json' -d "$2"; }
SID=$(POST session/create '{"url":"https://example.com/login","cookies":[],"use_proxy":false}' | sed -n 's/.*"session_id":"\([^"]*\)".*/\1/p')
[ -n "$SID" ] || { echo "session create failed" >&2; exit 1; }
POST "session/$SID/input" '{"index":1,"text":"user@example.com"}' > /dev/null
POST "session/$SID/click" '{"index":3}' > /dev/null
echo '--- final state ---'
POST "session/$SID/state" '{}'
echoRun it anywhere with curl (override the target with AGINXBROWSER_URL), from cron, in CI, on another machine. What an agent figured out interactively becomes a deterministic, auditable script — re-running it costs zero model tokens.
Response (format=json) — the recording as a flow document:
{
"create": { "url": "https://example.com/login" },
"steps": [
{ "op": "input", "args": { "index": 1, "text": "user@example.com" } },
{ "op": "click", "args": { "index": 3 } }
]
}This is the authoring entry point for flows: drive a session by hand, export as json, then curate (prune probe evals, promote URLs to {{vars}}, add wait steps and expect assertions, mark extraction evals with save) and replay via POST /flow/run. Cookies and storage are stripped — a flow is a shareable asset. ok:false probe actions are dropped. wait steps are not part of the recording (the recorder logs actions, not your waiting), so waits are hand-added during curation.
⚠️ Treat an exported script like credentials — it embeds any cookies the session was created with.Index caveat:
click/inputreplay the index from the original run's/stateoutput. If the page's element order changed, an index may land elsewhere. The script is a readable, editable starting point, not a guaranteed selector — fix the index (or swap in a selector of your own) and re-run.
Run a flow — a recorded/edited JSON session script — to completion server-side, zero model tokens in the loop. The engine stays LLM-free: a flow is pure data ({{var}} substitution, wait predicates, expect checks — no LLM calls anywhere).
Request body:
| Field | Type | Default | Description |
|---|---|---|---|
| flow | object | — | Inline flow document (see below) |
| name | string | — | Or run a named server-side workflow: workflow/<name>/flow.json on disk (override the directory with AGINXBROWSER_WORKFLOW_DIR). The engine binary carries no flows — the repo's samples and the release tarball ship the workflow/ directory as files beside the binary. An unknown name errors back with the list of installed workflows — that error is the discovery call |
| vars | object | {} |
Values for {{placeholders}}; wins over the flow's own vars defaults |
| session_id | string | — | Reuse a live session (e.g. from POST /import/curl) instead of creating a fresh one — that's how login state and flows compose |
One of flow / name is required.
Flow document:
{
"vars": { "handle": "aginxbrowser" },
"create": { "url": "https://x.com/{{handle}}", "use_proxy": true },
"steps": [
{ "op": "wait", "args": { "predicate": "document.querySelectorAll('article').length >= 1", "timeout_ms": 20000 } },
{ "op": "eval", "args": { "script": "JSON.stringify({...})" },
"expect": { "selector": "article" }, "save": "profile" }
]
}vars— default values for{{placeholders}}increateand step args; requestvarsoverride them. Substituting an undeclared var fails fast with a receipt (no session created). A placeholder name may be a dotted path into a saved result or an object var —{{token.json.access_token}}walks object keys (and array indices);args_jsonis additionally exposed as anargsobject so steps can reference{{args.title}}directly.create— passed toPOST /session/createsemantics (url / cookies / use_proxy / viewport / …); skipped entirely whensession_idis given.steps[]—opis one of the session verbs:navigate,set_content,click,click_xy,drag,input,set_files,scroll,viewport,wait,eval,screenshot,state,cookies,close, plus the engine-sidehttp(below). (evalsteps accept an optionaltimeout_ms, same clamp as the eval endpoint;screenshotsteps accept an optionaldpr, device pixel ratio 1.0–3.0 for Retina-sharp bitmaps).set_filestakesselector+filesin the same shape assession_set_files({name, content_base64, mime_type?, last_modified?}); a recorded export emits names-only skeletons, and replay fails loudly on a spec withoutcontent_base64instead of silently uploading a 0-byte file — fill content in, or route it through a{{var}}.expect(optional) gates the step:url_contains,selector,text_contains,eval_truthy— all must hold.save(optional) stores the step's result under that key in the receipt — and into the substitutable vars, so a later step reads one field off it via{{name.json.field}}.httpsteps — an engine-side HTTP request for API-driven flows (WeChat OA publish, any server-to-server endpoint): no page is involved, so CORS never applies and credentials never touch page context or a session network log. Args:url(required),method(default GET),timeout_ms(clamped 1s..120s), and exactly one body form —json(value-interpolated: a string that is exactly{{path}}embeds the referenced VALUE, so a quote-heavy HTML body splices in as a legal JSON string; partial placeholders stay literal forbody),multipart(array of{name, filename?, content_type?, content_base64 | text}), or rawbodytext. Optionalheaders(string values). The step rides the/fetchposture: per-domain quota gate, SSRF deny-set, env proxy, a fresh cookie-less client, redirects NOT followed. Result shape{status, headers, body, json}—jsonis the parsed body when it parses, else null.- On failure the flow aborts at the failing step and the receipt carries
status:"failed",failed_step,reason, the pageurl, a viewportscreenshot(base64), everythingsavedso far — and the session stays alive for manual takeover (session_idin the receipt).
Response (ok):
{
"status": "ok",
"session_id": "s_7",
"steps_done": 2,
"saved": { "profile": "{\"url\":\"https://x.com/aginxbrowser\",...}" }
}Response (failed):
{
"status": "failed",
"session_id": "s_8",
"failed_step": 0,
"reason": "wait timeout: predicate never became truthy within 15000ms",
"url": "https://www.zhihu.com/question/603518666",
"screenshot": "<base64 png>",
"steps_done": 0,
"saved": {}
}The repo ships three sample flows under workflow/ (xcom-profile, juejin-post, zhihu-answer) — one runs green logged-out, one green, one fails on a 403 wall on purpose, each with a flow.md explaining itself. A workflow name is one path segment of lowercase/digits/dashes; anything else is rejected before it touches the filesystem. Drop a directory in to deploy — no rebuild.
Install a third-party flow from a DupHub remote into the server's workflow directory, making it runnable by name via POST /flow/run. The wire is the dup protocol's templates scope: GET {base}/api/templates/{name}/dup/manifest ({files: {relative path → sha256}, hash}) then GET {base}/api/templates/{name}/dup/file/{path} (raw bytes, anonymous). The base remote is env-configured (AGINXBROWSER_DUPHUB_URL, default https://duphub.com) — never a request parameter, so an install can't be aimed at an arbitrary URL.
Install semantics, in order — every failure leaves the filesystem untouched:
- name gate — same as
flow_run: lowercase/digits/dashes, one path segment - manifest validation — non-empty, contains
flow.json, every path relative with ordinary components only (.., leading/, backslashes die here), ≤ 64 files - fetch + verify — each file's bytes must sha256-match the manifest's promise (≤ 4 MiB a file, ≤ 8 MiB total); a package that fails anywhere is not installed
- flow.json validation — must parse and carry a
stepsarray, before anything is written - atomic landing — everything stages in
workflow/.installing-<name>/, then swaps intoworkflow/<name>/, replacing any previous install
| Body | Type | Default | Notes |
|---|---|---|---|
| name | string | — | DupHub template name = the directory it lands in |
Response:
{
"name": "xhs-post",
"source": "https://duphub.com",
"manifest_hash": "…",
"files": ["flow.json", "templates/body.html"],
"bytes": 4211,
"steps": 9,
"eval_steps": 4,
"installed_at": "workflow/xhs-post",
"available_now": true,
"disclosure": "third-party flow: its scripts execute with this engine's privileges inside session pages on flow_run — review the steps before running"
}eval_steps counts steps carrying args.script — the disclosure line is the point: a third-party flow's scripts run with this engine's privileges in session pages, so review before flow_run. Uploading is the dup CLI's job (credentials stay there); this endpoint only ever reads.
Flow discovery — POST /flow/install takes a name; this is how you learn the names. Lists flow packages on the configured DupHub (AGINXBROWSER_DUPHUB_URL): the market's kind=agx-flow filter plus a client-side re-filter, so clones (avatar templates) never surface as installable flows. A hub whose listings predate the kind protocol reports kind_filter: "unsupported" rather than guessing.
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
| q | string | — | optional substring; the hub's filter over name/description |
Response:
{
"hub": "https://duphub.com",
"query": "",
"total": 2,
"flows": [
{"name": "xhs-post", "description": "post to xhs", "version": "1.2.0", "downloads": 41}
],
"install": "flow_install(<name>)"
}The session's network request log for the current page — every document, subresource and script-initiated fetch()/XHR the page actually issued, one compact row each. This is the sniffer surface.
Query parameters:
| Field | Type | Default | Description |
|---|---|---|---|
| filter | string | — | media → only playback/stream requests (HLS .m3u8, DASH .mpd, .mp4, .flv, .ts, .webm, audio), classified by URL suffix or response Content-Type |
| include_bodies | bool | false |
Add an xhr array: the page's script-initiated (XHR/fetch) responses with their retained text bodies — the page's own API face. Binary (base64) bodies are skipped |
| include_headers | bool | false |
Add each request's outbound header set to its row — headers on the compact rows, request_headers on the xhr rows. What the page's JS actually sent (customs like x-s/x-s-common included), lowercased. Off by default: headers can carry tokens |
| url_contains | string | — | With include_bodies: only body rows whose URL contains this substring |
| body_max_chars | usize | 4000 |
Per-body character cap for include_bodies (0 = unlimited) |
Response (default):
{
"url": "https://example.com/watch",
"nav": 2,
"total": 14,
"requests": [
{"method":"GET","url":"https://example.com/watch","status":200,"type":"Document","size":51234,"nav":2},
{"method":"GET","url":"https://cdn.example/api/resolve","status":200,"type":"Fetch","size":210,"nav":2},
{"method":"GET","url":"https://h5api.example.com/rest","status":0,"type":"Fetch","size":0,"nav":1,
"error":"CORS error: Origin 'https://example.com' not in Access-Control-Allow-Origin ''"}
]
}nav is the navigation generation (#101): the top-level value is the current one, and each row carries the generation that issued it — a row with a lower nav belongs to an earlier navigation attempt (e.g. a timed-out navigation whose log has not been reset yet), so "which attempt made this request" is a filter, not a guess.
status: 0 rows are requests that never produced a servable response — SSRF-blocked, URL-blocklisted, CORS-refused, or dead at the transport layer. The error field says which, so a page whose API calls all die at a gate no longer reads as "never issued a request".
The response always carries an in_flight array ([] when quiet) — script-initiated requests dispatched but not yet settled, {"url", "method", "age_ms"} per row. Request-log rows land at completion, so a hung fetch (page-internal timeout, WAF holding the connection) is invisible there; in_flight is the "still executing?" answer — check it before re-issuing a save that may already be flying. A challenges count is always present too (0 = clean) — anti-bot hits answer 200 and hide among successful rows.
Response (?filter=media) — the playback-link sniffer. Links embedded in page HTML are often decoys; these are the requests the player actually made, so they are what plays:
{
"url": "https://example.com/watch",
"media": [
{"url":"https://cdn.example/v/master.m3u8?token=1","kind":"hls","status":200,"mime":"application/vnd.apple.mpegurl","type":"Fetch"}
]
}Response (?include_bodies=true) — a sibling xhr array joins the default response, one row per script-initiated response with its retained body: {"url":"https://api.example/items","method":"GET","status":200,"mime":"application/json","body":"{\"items\":[…]}","body_truncated":false,"nav":2}. The same face /fetch's capture_xhr returns statelessly, read live off the session. Rows whose body carries risk-control markers (FAIL_SYS_USER_VALIDATE, RGV587, x5secdata) are tagged "challenge": "punish" — those bodies answer 200 like any other API, the tag is what separates them from success.
Response (?include_headers=true) — every compact row gains a headers object (and xhr rows a request_headers object) holding the request's outbound header set, lowercased — the engine defaults plus whatever the page's JS set (x-s, x-s-common, signed tokens). /fetch's capture_xhr rows always carry request_headers. Two honest gaps: status: 0 failure rows never reached the wire, so their set is {}; and a stealth-mode document request snapshots only the headers this code explicitly set on the builder — the emulation's transport-layer defaults merge inside wreq at send time and are not duplicated into the snapshot.
One-call risk-control report: did this session hit an anti-bot wall? Taobao/tmall's x5 risk control answers 200 like a normal response — either a redirect onto a punish page (_____tmd_____/punish, punish.taobao.com) or an MTop API body carrying FAIL_SYS_USER_VALIDATE / RGV587 / x5secdata. Detection only — the engine surfaces walls, it does not auto-bypass them.
Response:
{
"url": "https://shop.example/item",
"total": 1,
"events": [
{"url":"https://h5api.m.taobao.com/h5/mtop.taobao.shop.simple.item.fetch/1.0/","method":"GET","status":200,"kind":"punish","via":"body"}
],
"account": "taobao-scraper",
"handoff": "anti-bot wall detected — hand this session to a human: open the live view (web/live.html), solve the challenge there, then retry the same request in this session"
}via says how the wall was detected: "url" = the navigation/request landed on the punish page, "body" = a 200-status API response swallowed the challenge. account (which identity got walled — matters when scraper and publisher run as different accounts) and handoff are present only when there are hits; a clean session returns {"url", "total": 0, "events": []} plus a note saying what zero means — no known challenge signatures in this session's traffic, i.e. absence of a wall, not a login verdict. The login question is answered elsewhere (session_verdict / account_verify). The human handoff works because the session keeps its cookies and persona: solving in the live view sets the x5sec cookie, and the retry rides it.
The same traffic as a full HAR 1.2 document (application/json) — opens in Chrome DevTools or any HAR viewer. Retained response bodies are inlined as content.text (large/opaque bodies base64, content.encoding: "base64"); bodies beyond the retention limits are absent. Timing phases we do not measure are -1.
curl -sS http://127.0.0.1:8089/session/$SID/har -o page.harSnapshot the session's localStorage + sessionStorage for the current origin — the half of login state that cookies can't carry (many sites keep the session token in web storage). Call it before the session idles out, then feed the snapshot back via session/create's storage field to restore the logged-in state in a new session. Account sessions don't need the round-trip: the record's captured storage replays automatically onto the captured origin (an explicit storage param wins).
Response:
{"url": "https://example.com", "local_storage": {"token": "eyJ..."}, "session_storage": {}}The session's recent page console output (log / info / warn / error, plus dialog entries for auto-answered alert/confirm/prompt) as a ring buffer of the last 500 entries, newest last — captures output from page scripts, clicks, evals and navigations alike. The fastest way to see why a page misbehaves: click the button, call this, read the error.
Query parameters (all optional, combinable):
| Field | Type | Default | Description |
|---|---|---|---|
| level | string | — | Only entries at this exact level (e.g. error, dialog) |
| since_ts | u64 | — | Only entries at or after this Unix epoch millisecond |
| url_contains | string | — | Only entries whose page URL contains this substring |
| limit | usize | — | Keep only the most recent N matches |
Response: {"url": "...", "total": 3, "matched": 1, "messages": [{"ts_ms": 1712, "level": "error", "text": "TypeError: x is not a function", "url": "https://example.com/"}]} — total is the whole ring, matched what the filters let through.
Inspect or steer how the session answers window.alert / confirm / prompt. Dialogs never block the page — they are answered immediately under the session policy (default: dismiss) and logged to the console ring with level: "dialog".
| Field | Type | Required | Description |
|---|---|---|---|
| action | string | ✅ | list → current policy + every dialog entry seen; accept → subsequent dialogs answer OK/true; dismiss → answer cancel/false (the default) |
| prompt_text | string | What window.prompt returns when accepted; persists for the session |
Response: {"policy": "accept", "prompt_text": null, "dialogs": [{"ts_ms": 1712, "level": "dialog", "text": "{\"dialog\":\"alert\",\"message\":\"修改成功\"}"}]}
Set the session's viewport (device emulation). Scripts see innerWidth/innerHeight move, media queries like (max-width: 600px) re-evaluate, and mobile: true flips pointer: coarse / hover: none in matchMedia and reports navigator.maxTouchPoints = 5. Omitted fields keep their current value; the override survives navigation.
curl -sS -X POST http://127.0.0.1:8089/session/$SID/viewport \
-H "Content-Type: application/json" -d '{"width": 390, "height": 844, "mobile": true}'Response: {"viewport": {"width": 390, "height": 844, "mobile": true}}
Screenshot the session's current DOM state (mutations from clicks/evals included) as a base64 PNG. Mirrors POST /screenshot's response shape (image_base64), so existing consumers work against either. The body is optional — omit it for a viewport-sized capture.
| Field | Type | Default | Description |
|---|---|---|---|
| width | u32 | current | Render width in CSS pixels |
| height | u32 | current | Render height in CSS pixels |
| full_page | bool | false |
Capture the full scrollable page instead of the viewport |
| selector | string | — | Capture only that element's box |
| selector_all | bool | false |
With selector: capture every match instead of the first |
| dpr | f32 | 1.0 |
Device pixel ratio (Retina/HiDPI): rasterize at device resolution — the bitmap comes back width·dpr × height·dpr with CSS geometry intact. Clamped to 1.0–3.0. Render-only: page JS still sees window.devicePixelRatio 1. The /live panel sends the display's real ratio automatically |
Response: {"url": "...", "width": 1280, "height": 800, "image_base64": "iVBOR...", "format": "png"} — with dpr: 2, width/height are the device-pixel bitmap dims (e.g. 2560×1600)
Poll cost (#189): the no-argument default path (no
width/height/full_page/selector) paints the live tree's viewport band and caches the frame under a damage signature — realm generation, dom epoch, layout rev, scroll offset, viewport,dpr. A repeated poll on an unchanged page returns the cached PNG (base64 cost, milliseconds) instead of re-rasterizing; any DOM mutation, layout change, scroll, viewport resize, navigation, or differentdprre-rasterizes. Explicit-size/full_page/selectorrequests always render fresh.
Wait until a CSS selector matches or a JS predicate turns truthy, with a timeout. The page's event loop keeps running while waiting (fetches, timers, promise chains progress), so this replaces blind sleeps for async content: navigate, wait for .price-card, then click/read. Exactly one of selector / predicate.
| Field | Type | Default | Description |
|---|---|---|---|
| selector | string | — | CSS selector to wait for (e.g. .price-card) |
| predicate | string | — | JS expression polled until truthy (e.g. document.querySelectorAll('.card').length >= 3) |
| timeout_ms | u64 | 10000 |
Give up after this long (max 120000) |
Response: {"matched": true, "elapsed_ms": 743, "detail": {"tag": "div", "text": "..."}} — on expiry, an error naming the selector/predicate.
# 1. Create a session
SID=$(curl -sS -X POST http://127.0.0.1:8089/session/create \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/login"}' | jq -r .session_id)
# 2. Inspect the page state
curl -sS -X POST http://127.0.0.1:8089/session/$SID/state
# 3. Enter the username
curl -sS -X POST http://127.0.0.1:8089/session/$SID/input \
-H "Content-Type: application/json" \
-d '{"index":1,"text":"user@example.com"}'
# 4. Enter the password
curl -sS -X POST http://127.0.0.1:8089/session/$SID/input \
-H "Content-Type: application/json" \
-d '{"index":2,"text":"mypassword"}'
# 5. Click Sign In
curl -sS -X POST http://127.0.0.1:8089/session/$SID/click \
-H "Content-Type: application/json" \
-d '{"index":3}'
# 6. Inspect the post-login state
curl -sS -X POST http://127.0.0.1:8089/session/$SID/state
# 7. Close the session
curl -sS -X POST http://127.0.0.1:8089/session/$SID/closeAginxBrowser fetches on demand — one page when an agent asks, not bulk crawling — and by default does not consult robots.txt on any path: real-time acquisition is not crawling, and robots.txt is crawler etiquette. The full RFC 9309 checker ships built in and, when the operator opts in with AGINXBROWSER_HONOR_ROBOTS=1, applies to every autonomous path — /fetch, /click, /eval, /screenshot, /download, the /search fetch_top body-grab (denied results keep their entry; fetch_error carries the reason), and the Firecrawl-compatible /v1/scrape. A disallowed URL then returns HTTP 403 with the matched rule in the error, so the agent can see exactly why:
{"error": "robots.txt disallows /yinnho/aginxbrowser/pulse on https://github.com (matched `Disallow: /*/*/pulse`). This instance checks robots.txt (AGINXBROWSER_HONOR_ROBOTS=1); remove it to skip the check."}Semantics (RFC 9309 subset):
| robots.txt outcome | Result |
|---|---|
Rule disallows the path (longest match wins, ties → Allow; */$ wildcards honored) |
403 with the matched rule |
| 404 / 410 | allowed (no rules exist) |
| body parses to no applicable rules | allowed |
| other 4xx fetching robots.txt | allowed — the server declines to serve rules to us; RFC 9309 / Google semantics read that as "no rules apply" |
| 5xx / network failure fetching robots.txt (one retry) | 403 while the server is in trouble, short negative cache (300s) so recovery is quick — treating a failing robots.txt as allow-all is how Lightpanda #3156 got sites hammered |
private / loopback host (127.0.0.1, RFC1918, .local, …) |
exempt (operator's own network) |
Scope notes:
- Interactive sessions are exempt by design.
/session/{id}/navigate,click,input,scrolldrive a browser the way a person at a keyboard does; robots.txt governs autonomous fetching, not browser interaction. - The robots.txt fetch itself uses the honest product User-Agent (
aginxbrowser/<version> (+https://browser.aginx.net)) — the name robots.txt group matching keys on is never a borrowed one. If the site's TLS stack is older than the default client can speak (a CBC-only TLS 1.2 server, obscura#769), the fetch takes one final ride on the stealth transport's BoringSSL stack — a different cipher shelf, the same honest name. - Policies are cached per host (default 1h; refusals 5min so a dead endpoint doesn't lock the host out for an hour).
Operator opt-in (the stance belongs to whoever runs the instance, not to each caller):
export AGINXBROWSER_HONOR_ROBOTS=1 # opt in to robots.txt checks
export AGINXBROWSER_ROBOTS_TTL_SECS=3600 # per-host policy cache TTLaginxbrowser doctor and GET /doctor both report the active stance.
AginxBrowser is a real-time retrieval tool, not a crawler — budgets enforce that on how much any caller can fetch:
- Per-domain rate: 20 pages/minute per registrable domain (
AGINXBROWSER_DOMAIN_RATE_PER_MIN). Subdomains share one budget, so rotatingwww./api./ random subdomains doesn't escape. A private/loopback host is exempt (the operator's own network), and a domain's window resets when the minute rolls over. - Per-session page budget: 200 pages per interactive session (
AGINXBROWSER_SESSION_PAGE_LIMIT). Every navigation counts, plus clicks that change the page; reads on the current page (state/scroll/eval/typing) are free. An over-budget session refuses further navigations but stays interactive until closed.
Counted surfaces: /fetch, /click, /eval, /screenshot, /download, the /search fetch_top body-grab (an over-budget item keeps its entry; fetch_error carries the reason), /v1/scrape (both plain and actions paths), and session navigations/clicks. Subresources a page pulls are never counted.
An over-budget request returns HTTP 429 with the stance in the message:
{"error": "rate limit: example.com is capped at 20 pages/min — aginxbrowser does real-time lookups for agents, not site crawling. Slow down, or self-host and tune AGINXBROWSER_DOMAIN_RATE_PER_MIN."}Attempts count even when the fetch itself fails — a rate limit that only counted successes would be one you escape by hammering 404s.
Operator tuning (hosted runs are tighter; your instance is yours):
export AGINXBROWSER_DOMAIN_RATE_PER_MIN=20 # pages per domain per minute, 0 disables
export AGINXBROWSER_SESSION_PAGE_LIMIT=200 # pages per session, 0 disablesEvery successful fetch and search is persisted to a local SQLite database at ~/.aginxbrowser/cache.db (WAL mode, 0600) — the record that backs the fetch receipts.
- Pages: one row per fetched URL — title, extracted content, serving tier, fetch time — deduplicated by normalized URL and FTS5-indexed (Chinese substrings work: CJK text is indexed per character)
- Searches: whole result sets per
(query, categories)pair - TTL: pages 30 days, search results 7 days; expired rows are purged lazily on writes
- Drift receipts: every page row stores its
content_hashplus the previous sample's hash. The/fetchresponse surfaces them ascontent_hashandchanged_since_prev— the cheapest drift detector for origins serving frozen bodies (a rate-limited 200 that never changes reads asfalseacross consecutive samples)
| Env | Default | Meaning |
|---|---|---|
AGINXBROWSER_STORE |
on | Set 0 to disable persistence entirely |
AGINXBROWSER_STORE_PATH |
~/.aginxbrowser/cache.db |
Database file location (page/search rows and session snapshots). Prefer a per-instance path when running parallel serves |
AGINXBROWSER_COOKIE_STORE_DIR / AGINXBROWSER_STORAGE_DIR |
(unset) | When set and STORE_PATH is unset, the store file is <dir>/cache.db — isolates snapshots so restore_all cannot revive foreign sessions into a "fresh" instance |
AGINXBROWSER_RESTORE_SESSIONS |
all |
Eager serve-start restore: all/1/on (default) revive every snapshot; 0/off skip eager restore (lazy on touch); skip-keepalive restore only non-keepalive snapshots |
AGINXBROWSER_STORE_TTL_HOURS |
720 |
Page rows time-to-live |
AGINXBROWSER_STORE_SEARCH_TTL_HOURS |
168 |
Search-result rows time-to-live |
AGINXBROWSER_STORE_SCOPE |
global |
global = one shared pool, right for single-user instances; session = rows are scoped per session — set this on multi-client deployments |
This is the durable layer; the short-lived in-process /fetch cache (AGINXBROWSER_CACHE_TTL_SECS) sits in front of it and serves repeat requests of the same URL inside the TTL without re-paying the network.
When a search engine or target site throws up a CAPTCHA, AginxBrowser will:
- Detect the CAPTCHA type (Cloudflare Turnstile, reCAPTCHA v2, hCaptcha, slider)
- Report it via the
captcha_eventfield so the caller knows - Auto-solve it (if the
CAPTCHA_SOLVER_API_KEYenvironment variable is set)
Configuration:
# Set your 2captcha API key
export CAPTCHA_SOLVER_API_KEY=your_api_key_here
# Optional: switch the CAPTCHA solving service (default 2captcha)
export CAPTCHA_SOLVER_SERVICE=2captchaOnce configured, /fetch and /search automatically submit CAPTCHAs to 2captcha and inject the token — no manual intervention needed.
Render markdown into a deterministic, self-contained HTML document — the document layer, so agents never write HTML by hand. Fenced code blocks tagged archify carry typed zero-coordinate diagram JSON (sequence / workflow / architecture / dataflow / lifecycle) rendered to inline SVG by the layout engine; Mermaid sources must be translated to archify JSON by the caller.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| markdown | string | ✅ | — | Markdown source. Fenced archify blocks become inline-SVG diagrams |
| theme | string | "light" |
Color scheme: light / dark — baked into the artifact at generation time |
|
| preset | string | "classic" |
Visual preset: classic / signal-flow / blueprint / editorial, orthogonal to theme (a bare theme alone is accepted too) |
|
| quality | string | "standard" |
showcase runs the delivery-gate composition audit (route crossings, label clearance, rhythm) — it grades the receipt without changing an artifact byte |
|
| motion | bool | false |
Bake the declarative entrance choreography into the artifact (CSS keyframes, zero scripts) |
Response: { "html": "<!DOCTYPE html>…", "receipt": { … } } — the receipt carries the artifact sha256, bytes, diagrams count, the preset/quality/motion settings, and the audit checks/diagnostics when showcase grading ran.
The output is deterministic — same input, same bytes — and the receipt carries the artifact's sha256 so that's verifiable. Guided-view tabs and the window.agxViewer runtime (focus / ego views / route / reach) ship inside the artifact. Diagram vocabulary adapted from archify (MIT, itself based on Cocoon-AI's architecture-diagram-generator).
curl -sS -X POST http://127.0.0.1:8089/render_markdown \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\\n\\nworld","theme":"dark","quality":"showcase"}'| Variable | Default | Description |
|---|---|---|
AGINXBROWSER_TOKEN |
unset | Opt-in auth gate for the whole HTTP surface (#162). Unset = no gate (local dev keeps zero-config access). Set = every route requires the token via Authorization: Bearer <t> or ?token=<t>; the human takeover view carries it too (/live?session=…&token=…). /health alone answers without a token, but only {"status":"ok"} — the build-identity detail (version/commit/UA/TLS/capabilities) needs the token (#246). Value must be 16+ chars of [A-Za-z0-9_-] (URL-safe so the header and query forms are the same string) — an invalid shape refuses to boot rather than serve a half-open surface |
AGINXBROWSER_BIND |
127.0.0.1:8089 |
HTTP server listen address |
AGINXBROWSER_STEALTH |
Enabled | 0 disables stealth (for diagnostics) |
AGINXBROWSER_UA |
macOS Chrome145 persona | Spoofed User-Agent for browser traffic (a pinned persona from the fingerprint pool — see /health's ua for what this instance presents; search-engine transports keep their own defaults). Startup logs a fingerprint mismatch warning when the UA's browser family/major version disagrees with the TLS fingerprint (default chrome145) — an intentionally coherent pair avoids a WAF tell |
AGINXBROWSER_ACCEPT_LANGUAGE |
zh-CN,zh;q=0.9,en;q=0.8 |
Accept-Language header |
AGINXBROWSER_CACHE_TTL_SECS |
600 |
/fetch cache TTL (seconds); 0 disables |
AGINXBROWSER_DOWNLOAD_DIR |
. |
Directory where /download saves files |
AGINXBROWSER_MAX_BODY_BYTES |
67108864 (64 MiB) |
Max request body for /eval-family POST endpoints (/session/{id}/eval, /screenshot, /video, /pdf). Oversized bodies get a structured 413 EVAL_BODY_TOO_LARGE naming the limit |
AGINXBROWSER_WORKFLOW_DIR |
./workflow |
Where flow_run(name=…) looks for <name>/flow.json — the engine binary carries no built-in flows, this directory is the whole story; drop a directory in to deploy, no rebuild. POST /flow/install lands installed flows here too |
AGINXBROWSER_DUPHUB_URL |
https://duphub.com |
Base remote for POST /flow/install — a private DupHub instance replaces this. Env-only, never a request parameter |
AGINXBROWSER_PROXY |
None | Proxy address (used when use_proxy:true, and applied automatically for browser/session navigations to known-blocked domains) |
CAPTCHA_SOLVER_API_KEY |
None | 2captcha API key; enables automatic CAPTCHA solving when set |
CAPTCHA_SOLVER_SERVICE |
2captcha |
CAPTCHA solving service |
| HTTP Status | Scenario |
|---|---|
| 400 | Invalid CSS selector syntax, URL parse failure |
| 404 | Element not found |
| 502 | Target site unreachable (DNS/connection failure) |
| 504 | Request timed out |
| 500 | Other internal errors |
Directly fetchable in stealth mode — no cookies needed:
# Extract title and body with /eval
curl -sS -X POST http://127.0.0.1:8089/eval -H 'Content-Type: application/json' -d '{
"url": "https://mp.weixin.qq.com/s/xxxxx",
"script": "({title:document.querySelector(\"#activity-name\")?.textContent?.trim(), body:document.querySelector(\"#js_content\")?.innerText})"
}'
# Search WeChat articles with /search and auto-fetch content
curl -sS -X POST http://127.0.0.1:8089/search -H 'Content-Type: application/json' \
-d '{"q":"AI人工智能","categories":"news","fetch_top":3,"max_chars_per":2000}'# Create session → inspect page → input → click → inspect result
SID=$(curl -sS -X POST http://127.0.0.1:8089/session/create \
-d '{"url":"https://example.com/login"}' | jq -r .session_id)
curl -sS -X POST http://127.0.0.1:8089/session/$SID/input \
-d '{"index":1,"text":"user@example.com"}'
curl -sS -X POST http://127.0.0.1:8089/session/$SID/click \
-d '{"index":3}'
curl -sS -X POST http://127.0.0.1:8089/session/$SID/stateauto_bypass_challenge is on by default: "Just a moment..." pages are detected automatically and the tool waits for the cf_clearance cookie:
curl -sS -X POST http://127.0.0.1:8089/fetch -H 'Content-Type: application/json' -d '{
"url": "https://cloudflare-protected-site.com"
}'curl -sS -X POST http://127.0.0.1:8089/fetch -H 'Content-Type: application/json' -d '{
"url": "https://spa-site.example.com",
"js_extract": {
"expression": "JSON.stringify(window.__INITIAL_STATE__)",
"timeout_ms": 3000
}
}'Some sites check TLS fingerprints; if Chrome gets blocked, try Firefox/Safari instead:
curl -sS -X POST http://127.0.0.1:8089/fetch -H 'Content-Type: application/json' -d '{
"url": "https://strict-site.com",
"tls_fingerprint": "firefox133",
"use_proxy": true
}'