P", "tag": ["html", "head", "body"]}},
+ {"function": "scrapling::find", "case": "duplicate_and_boolean_attributes",
+ "request": {"html": " ", "tag": "input"}},
+ {"function": "scrapling::extract", "case": "malformed_table_recovery",
+ "request": {"html": "
A BC", "selectors": [{"name": "table", "css": "table", "html": True}]}},
+ {"function": "scrapling::extract", "case": "misnested_formatting_recovery",
+ "request": {"html": "
onetwo threetail", "selectors": [{"name": "body", "css": "body", "html": True}]}},
+ {"function": "scrapling::extract", "case": "foreign_content_serialization",
+ "request": {"html": "T
",
+ "selectors": [{"name": "svg", "css": "svg", "html": True}]}},
+ {"function": "scrapling::extract", "case": "entities_and_invalid_codepoints",
+ "request": {"html": "
© ' ¬anentity;
",
+ "selectors": [{"name": "text", "xpath": "//p/text()", "all": True},
+ {"name": "html", "css": "p", "html": True}]}},
+ {"function": "scrapling::extract", "case": "comments_removed_and_text_merged",
+ "request": {"html": "
abd
",
+ "selectors": [{"name": "text", "xpath": "//p/text()", "all": True},
+ {"name": "html", "css": "p", "html": True}]}},
+ {"function": "scrapling::extract", "case": "template_nested_content",
+ "request": {"html": "
TP", "selectors": [
+ {"name": "template", "css": "template", "html": True},
+ {"name": "td", "xpath": "//template//td", "all": True},
+ ]}},
+ # CSS is translated with cssselect 1.5 then evaluated as XPath.
+ {"function": "scrapling::css", "case": "sibling_text_pseudo",
+ "request": {"html": "
H xAB C
D
", "query": "h1 + p::text"}},
+ {"function": "scrapling::css", "case": "general_sibling_attr_pseudo",
+ "request": {"html": "H A
B
", "query": "h1 ~ p::attr(data-x)"}},
+ {"function": "scrapling::css", "case": "nth_not_and_attribute_operators",
+ "request": {"html": "",
+ "query": "li:nth-child(2):not(.x)[data-v|='en']", "first": True}},
+ {"function": "scrapling::css", "case": "grouped_selector_document_order",
+ "request": {"html": "P
H Q
", "query": "h1, p"}},
+ {"function": "scrapling::find-similar", "case": "subselector_scope_cannot_escape",
+ "request": {"html": "outside ", "anchor": "section",
+ "selectors": [{"name": "escaped", "css": "body a", "all": True},
+ {"name": "inside", "css": "a", "all": True}]}},
+ # XPath 1.0 axes, functions, coercion, scalar quirks, and errors.
+ {"function": "scrapling::xpath", "case": "ancestor_axis_reverse_position",
+ "request": {"html": " ", "query": "//p/ancestor::*[1]/@id"}},
+ {"function": "scrapling::xpath", "case": "preceding_axis_reverse_position",
+ "request": {"html": "A
B
C
", "query": "//p[@id='c']/preceding::p[1]/@id"}},
+ {"function": "scrapling::xpath", "case": "following_axis_document_order",
+ "request": {"html": "A
B
C
", "query": "//i/following::p/text()"}},
+ {"function": "scrapling::xpath", "case": "attribute_wildcard_order",
+ "request": {"html": "P
", "query": "//p/@*"}},
+ {"function": "scrapling::xpath", "case": "predicate_string_functions",
+ "request": {"html": " Alpha beta
Gamma
",
+ "query": "//p[starts-with(normalize-space(.), 'Alpha') and string-length(normalize-space(.)) = 10]"}},
+ {"function": "scrapling::xpath", "case": "predicate_arithmetic_and_round",
+ "request": {"html": "1 2 3 4 ", "query": "//i[position() = round(last() div 2)]"}},
+ {"function": "scrapling::xpath", "case": "global_parenthesized_position",
+ "request": {"html": "", "query": "(//p)[2]"}},
+ {"function": "scrapling::xpath", "case": "string_scalar_splits_into_text_nodes",
+ "request": {"html": "Alpha
", "query": "string(//p)"}},
+ {"function": "scrapling::xpath", "case": "false_scalar_becomes_empty",
+ "request": {"html": "Alpha
", "query": "boolean(//nope)"}},
+ {"function": "scrapling::xpath", "case": "true_scalar_type_error",
+ "request": {"html": "Alpha
", "query": "boolean(//p)"}},
+ {"function": "scrapling::xpath", "case": "number_scalar_type_error",
+ "request": {"html": "Alpha
", "query": "count(//p)"}},
+ {"function": "scrapling::xpath", "case": "unknown_function_error",
+ "request": {"html": "Alpha
", "query": "no-such-function()"}},
+]
+
+
+def gen_behavior(output: Path = WORKER) -> None:
+ from src.handlers import create_handlers
+
+ handlers = create_handlers(lambda: {})
+ for name in ("basic", "edge", "messy"):
+ CORPUS[name] = (WORKER / "tests/corpus" / f"{name}.html").read_text()
+ n = 0
+ for entry in MATRIX:
+ req = dict(entry["request"])
+ # Named corpus file, or (to-markdown's scope-root/pseudo-selector
+ # fixtures) inline HTML that names no corpus entry at all.
+ req["html"] = CORPUS.get(req["html"], req["html"])
+ fn = entry["function"]
+ try:
+ resp = {"ok": asyncio.run(handlers[fn.split("::", 1)[1].replace("-", "_")](req))}
+ except Exception as exc: # noqa: BLE001 — error text is part of the contract
+ resp = {"err": str(exc)}
+ dump(
+ output / "tests/golden/behavior" / fn.split("::")[1] / (entry["case"] + ".json"),
+ {
+ "function": wire_id(fn),
+ "case": entry["case"],
+ "request": req,
+ **resp,
+ },
+ )
+ n += 1
+ print(f"wrote {n} behavior fixtures")
+
+
+class _BrowserFixtureHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self) -> None: # noqa: N802 - stdlib handler API
+ body = (WORKER / "tests/corpus/browser_visual.html").read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_args) -> None:
+ pass
+
+
+class _ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
+ daemon_threads = True
+
+
+def gen_browser(output: Path = WORKER) -> None:
+ """Generate deterministic screenshot fixtures through the public wrapper."""
+ from PIL import Image
+ from src.handlers import create_handlers
+
+ server = _ThreadingServer(("127.0.0.1", 0), _BrowserFixtureHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ origin = f"http://127.0.0.1:{server.server_port}"
+ cfg = {"defaults": {"headless": True, "network_idle": False, "proxy": "", "include_html": False}}
+ handler = create_handlers(lambda: cfg)["screenshot"]
+ cases = [
+ ("dynamic-viewport-png", {"fetcher": "dynamic", "format": "png", "full_page": False}),
+ ("dynamic-full-png", {"fetcher": "dynamic", "format": "png", "full_page": True}),
+ ("stealthy-viewport-png", {"fetcher": "stealthy", "format": "png", "full_page": False}),
+ ("stealthy-full-jpeg", {"fetcher": "stealthy", "format": "jpeg", "full_page": True}),
+ ]
+ destination = output / "tests/golden/browser"
+ destination.mkdir(parents=True, exist_ok=True)
+ records = []
+ try:
+ for name, options in cases:
+ request = {"url": f"{origin}/visual", "retries": 1, **options}
+ response = asyncio.run(handler(request))
+ blocks = []
+ image_index = 0
+ for block in response["content"]:
+ if block["type"] != "image":
+ blocks.append({**block, "text": block["text"].replace(origin, "{origin}")})
+ continue
+ image_index += 1
+ data = base64.b64decode(block["data"])
+ suffix = "jpg" if block["mime"] == "image/jpeg" else "png"
+ filename = f"{name}-{image_index}.{suffix}"
+ (destination / filename).write_bytes(data)
+ with Image.open(destination / filename) as image:
+ dimensions = [image.width, image.height]
+ blocks.append({
+ "type": "image",
+ "mime": block["mime"],
+ "file": filename,
+ "bytes": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "dimensions": dimensions,
+ })
+ records.append({
+ "case": name,
+ "request": {**request, "url": "{origin}/visual"},
+ "response": {
+ "content": blocks,
+ "url": response["url"].replace(origin, "{origin}"),
+ "mime": response["mime"],
+ },
+ })
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+ dump(destination / "manifest.json", {"cases": records})
+ print(f"wrote {len(cases)} browser fixtures")
+
+
+def check_browser() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ output = Path(tmp)
+ gen_browser(output)
+ fresh = output / "tests/golden/browser"
+ committed = WORKER / "tests/golden/browser"
+ names = {path.name for path in fresh.iterdir()} | {path.name for path in committed.iterdir()}
+ drift = [name for name in names if not (fresh / name).exists() or not (committed / name).exists()
+ or (fresh / name).read_bytes() != (committed / name).read_bytes()]
+ if drift:
+ raise SystemExit(f"browser goldens are stale: {sorted(drift)}")
+ print("browser goldens are current")
+
+
+def check() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ output = Path(tmp)
+ gen_schemas(output)
+ gen_behavior(output)
+ generated = output / "tests/golden"
+ committed = WORKER / "tests/golden"
+ drift = []
+ for fresh in generated.rglob("*.json"):
+ relative = fresh.relative_to(generated)
+ checked_in = committed / relative
+ if not checked_in.exists() or fresh.read_bytes() != checked_in.read_bytes():
+ drift.append(str(relative))
+ generated_behavior = {
+ path.relative_to(generated / "behavior") for path in (generated / "behavior").rglob("*.json")
+ }
+ committed_behavior = {
+ path.relative_to(committed / "behavior") for path in (committed / "behavior").rglob("*.json")
+ }
+ drift.extend(str(path) for path in generated_behavior ^ committed_behavior)
+ if drift:
+ raise SystemExit(f"goldens are stale: {sorted(set(drift))}")
+ print("goldens are current")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "mode",
+ choices=["schemas", "behavior", "browser", "check", "browser-check"],
+ nargs="?",
+ default="schemas",
+ )
+ parser.add_argument(
+ "--parser-runtime",
+ action="store_true",
+ help="verify immutable parser inputs but not host fonts, locale, or timezone",
+ )
+ args = parser.parse_args()
+ random.seed(0)
+ if os.environ.get("PYTHONHASHSEED") != "0":
+ os.execve(sys.executable, [sys.executable, *sys.argv], {**os.environ, "PYTHONHASHSEED": "0"})
+ verify_command = [sys.executable, HERE / "verify_oracle.py"]
+ if args.parser_runtime:
+ verify_command.append("--parser-runtime")
+ subprocess.run(verify_command, check=True)
+ {
+ "schemas": gen_schemas,
+ "behavior": gen_behavior,
+ "browser": gen_browser,
+ "check": check,
+ "browser-check": check_browser,
+ }[args.mode]()
diff --git a/browser/scripts/verify_oracle.py b/browser/scripts/verify_oracle.py
new file mode 100644
index 000000000..85ee7b1b5
--- /dev/null
+++ b/browser/scripts/verify_oracle.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""Write or verify the frozen standalone-worker oracle manifest."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import importlib.metadata
+import json
+import locale
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+
+WORKER = Path(__file__).resolve().parent.parent
+REPO = WORKER.parent
+MANIFEST = WORKER / "oracle/manifest.json"
+LOCK = WORKER / "oracle/requirements.lock"
+ASSET_SUFFIXES = {".dat", ".json", ".pem", ".txt", ".xz", ".zip"}
+BROWSERS = (
+ (
+ "chromium-linux-x64",
+ "pw-chromium-1223-linux-x64.zip",
+ "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-linux64.zip",
+ ),
+ (
+ "chromium-headless-shell-linux-x64",
+ "pw-headless-1223-linux-x64.zip",
+ "https://cdn.playwright.dev/builds/cft/148.0.7778.96/linux64/chrome-headless-shell-linux64.zip",
+ ),
+ (
+ "ffmpeg-linux-x64",
+ "pw-ffmpeg-1011-linux-x64.zip",
+ "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip",
+ ),
+ (
+ "chromium-linux-arm64",
+ "pw-chromium-1223-linux-arm64.zip",
+ "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-linux-arm64.zip",
+ ),
+ (
+ "chromium-headless-shell-linux-arm64",
+ "pw-headless-1223-linux-arm64.zip",
+ "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1223/chromium-headless-shell-linux-arm64.zip",
+ ),
+ (
+ "ffmpeg-linux-arm64",
+ "pw-ffmpeg-1011-linux-arm64.zip",
+ "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux-arm64.zip",
+ ),
+)
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def file_record(path: Path, name: str | None = None) -> dict[str, object]:
+ return {"path": name or str(path), "size": path.stat().st_size, "sha256": sha256(path)}
+
+
+def records_digest(records: list[dict[str, object]]) -> str:
+ digest = hashlib.sha256()
+ for record in records:
+ digest.update(str(record["path"]).encode())
+ digest.update(b"\0")
+ digest.update(str(record["sha256"]).encode())
+ digest.update(b"\0")
+ return digest.hexdigest()
+
+
+def source_version() -> str:
+ # The CI bot bumps this after every scrapling merge; reading it keeps the
+ # recorded version from silently lying (pyproject.toml is itself one of
+ # the fingerprinted files, so a bump already forces a re-freeze).
+ pyproject = (REPO / "scrapling/pyproject.toml").read_text()
+ match = re.search(r'(?m)^version = "([^"]+)"$', pyproject)
+ if not match:
+ raise SystemExit("cannot read version from scrapling/pyproject.toml")
+ return match.group(1)
+
+
+def source_manifest() -> dict[str, object]:
+ # Fingerprint only the source the oracle executes (gen_goldens.py imports
+ # scrapling/src directly; its dependencies come from oracle/
+ # requirements.lock, not the worker's own metadata). Worker metadata —
+ # iii.worker.yaml, README, permissions, pyproject — is deliberately
+ # excluded: PR CI runs on the merge commit, so any main-side churn in
+ # those files would break every open PR's freeze without touching parse
+ # behavior. The version label is provenance, not a compared input.
+ output = subprocess.check_output(
+ ["git", "ls-files", "-z", "scrapling/src"], cwd=REPO
+ )
+ paths = [Path(item.decode()) for item in output.split(b"\0") if item]
+ files = [file_record(REPO / path, str(path)) for path in paths]
+ return {"version": source_version(), "sha256": records_digest(files), "files": files}
+
+
+def canonical_name(value: str) -> str:
+ return re.sub(r"[-_.]+", "-", value).lower()
+
+
+def package_manifest() -> tuple[list[dict[str, object]], list[dict[str, object]], str]:
+ packages = []
+ assets = []
+ runtime_digest = hashlib.sha256()
+ for distribution in sorted(
+ importlib.metadata.distributions(),
+ key=lambda item: canonical_name(item.metadata["Name"]),
+ ):
+ name = canonical_name(distribution.metadata["Name"])
+ files = []
+ for relative in sorted(distribution.files or (), key=str):
+ path = Path(distribution.locate_file(relative))
+ if not path.is_file():
+ continue
+ record = file_record(path, str(relative))
+ files.append(record)
+ if path.suffix.lower() in ASSET_SUFFIXES:
+ assets.append({"package": name, **record})
+ relative_name = str(relative)
+ if not relative_name.startswith("../../../bin/") and not relative_name.endswith(
+ ".dist-info/RECORD"
+ ):
+ for value in (name, relative_name, str(record["sha256"])):
+ runtime_digest.update(value.encode())
+ runtime_digest.update(b"\0")
+ packages.append(
+ {
+ "name": name,
+ "version": distribution.version,
+ "files": len(files),
+ "bytes": sum(int(item["size"]) for item in files),
+ "sha256": records_digest(files),
+ }
+ )
+ return packages, assets, runtime_digest.hexdigest()
+
+
+def font_manifest() -> list[dict[str, object]]:
+ output = subprocess.check_output(["fc-list", "--format=%{file}\n"], text=True)
+ paths = sorted({Path(item) for item in output.splitlines() if item})
+ return [file_record(path) for path in paths]
+
+
+def timezone_manifest() -> dict[str, object]:
+ path = Path("/etc/localtime").resolve()
+ prefix = Path("/usr/share/zoneinfo")
+ try:
+ name = str(path.relative_to(prefix))
+ except ValueError:
+ name = os.environ.get("TZ", str(path))
+ return {"name": name, **file_record(path)}
+
+
+def browser_manifest(archive_dir: Path) -> list[dict[str, object]]:
+ records = []
+ for name, filename, url in BROWSERS:
+ path = archive_dir / filename
+ if not path.is_file():
+ raise SystemExit(f"missing browser oracle archive: {path}")
+ records.append({"name": name, "url": url, **file_record(path, filename)})
+ return records
+
+
+def snapshot(archive_dir: Path) -> dict[str, object]:
+ packages, assets, parser_runtime_sha256 = package_manifest()
+ fonts = font_manifest()
+ certifi = importlib.import_module("certifi")
+ executable = Path(sys.executable).resolve()
+ return {
+ "format": 1,
+ "source": source_manifest(),
+ "python": {
+ "version": sys.version.split()[0],
+ "implementation": sys.implementation.name,
+ "executable": file_record(executable),
+ "parser_runtime_sha256": parser_runtime_sha256,
+ "packages": packages,
+ "requirements_lock": file_record(LOCK, "oracle/requirements.lock"),
+ },
+ "browser": {
+ "playwright_revision": "1223",
+ "chromium_version": "148.0.7778.96",
+ "archives": browser_manifest(archive_dir),
+ },
+ "assets": assets,
+ "host": {
+ "locale": locale.setlocale(locale.LC_ALL, ""),
+ "locale_environment": {
+ key: os.environ.get(key, "")
+ for key in ("LANG", "LC_ALL", "LC_CTYPE")
+ },
+ "timezone": timezone_manifest(),
+ "ca_bundle": file_record(Path(certifi.where()), "certifi/cacert.pem"),
+ "fonts_sha256": records_digest(fonts),
+ "fonts": fonts,
+ },
+ "determinism": {"PYTHONHASHSEED": "0", "random_seed": 0},
+ }
+
+
+def verify_archives(expected: dict[str, object], archive_dir: Path) -> None:
+ actual = browser_manifest(archive_dir)
+ if actual != expected["browser"]["archives"]:
+ raise SystemExit("browser oracle archives differ from oracle/manifest.json")
+
+
+def verify(archive_dir: Path | None) -> None:
+ expected = json.loads(MANIFEST.read_text())
+ # Archive bytes are release inputs, not required to regenerate parse-only
+ # goldens. Reuse the frozen entries while comparing everything local.
+ current = snapshot(archive_dir or Path("/nonexistent")) if archive_dir else None
+ if current is None:
+ packages, assets, parser_runtime_sha256 = package_manifest()
+ fonts = font_manifest()
+ certifi = importlib.import_module("certifi")
+ executable = Path(sys.executable).resolve()
+ current = {
+ **expected,
+ "source": source_manifest(),
+ "python": {
+ "version": sys.version.split()[0],
+ "implementation": sys.implementation.name,
+ "executable": file_record(executable),
+ "parser_runtime_sha256": parser_runtime_sha256,
+ "packages": packages,
+ "requirements_lock": file_record(LOCK, "oracle/requirements.lock"),
+ },
+ "assets": assets,
+ "host": {
+ "locale": locale.setlocale(locale.LC_ALL, ""),
+ "locale_environment": {
+ key: os.environ.get(key, "")
+ for key in ("LANG", "LC_ALL", "LC_CTYPE")
+ },
+ "timezone": timezone_manifest(),
+ "ca_bundle": file_record(Path(certifi.where()), "certifi/cacert.pem"),
+ "fonts_sha256": records_digest(fonts),
+ "fonts": fonts,
+ },
+ }
+ if current != expected:
+ report_diff(expected, current)
+ raise SystemExit("oracle environment differs from oracle/manifest.json")
+ if archive_dir:
+ verify_archives(expected, archive_dir)
+ print("oracle environment verified")
+
+
+def verify_parser_runtime() -> None:
+ """Verify inputs that can affect parse differentials, excluding host/browser data."""
+ expected = json.loads(MANIFEST.read_text())
+ packages, assets, parser_runtime_sha256 = package_manifest()
+
+ def compared_source(source: dict[str, object]) -> dict[str, object]:
+ # The version label tracks scrapling/pyproject.toml, which the CI bot
+ # bumps after every merge; it is provenance, not a parse input.
+ return {key: source[key] for key in ("sha256", "files")}
+
+ current = {
+ "source": compared_source(source_manifest()),
+ "python": {
+ "version": sys.version.split()[0],
+ "implementation": sys.implementation.name,
+ "parser_runtime_sha256": parser_runtime_sha256,
+ "packages": [
+ {"name": item["name"], "version": item["version"]}
+ for item in packages
+ ],
+ "requirements_lock": file_record(LOCK, "oracle/requirements.lock"),
+ },
+ "assets": assets,
+ }
+ frozen = {
+ "source": compared_source(expected["source"]),
+ "python": {
+ "version": expected["python"]["version"],
+ "implementation": expected["python"]["implementation"],
+ "parser_runtime_sha256": expected["python"]["parser_runtime_sha256"],
+ "packages": [
+ {"name": item["name"], "version": item["version"]}
+ for item in expected["python"]["packages"]
+ ],
+ "requirements_lock": expected["python"]["requirements_lock"],
+ },
+ "assets": expected["assets"],
+ }
+ if current != frozen:
+ report_diff(frozen, current)
+ raise SystemExit("parser oracle runtime differs from oracle/manifest.json")
+ print("parser oracle runtime verified")
+
+
+def report_diff(frozen: object, current: object, path: str = "", budget: list[int] | None = None) -> None:
+ """Print the leaf paths where the snapshots disagree (first 20)."""
+ if budget is None:
+ budget = [20]
+ if budget[0] <= 0:
+ return
+ if isinstance(frozen, dict) and isinstance(current, dict):
+ for key in sorted(set(frozen) | set(current)):
+ if key not in frozen:
+ budget[0] -= 1
+ print(f"diff {path}.{key}: only in current", file=sys.stderr)
+ elif key not in current:
+ budget[0] -= 1
+ print(f"diff {path}.{key}: only frozen", file=sys.stderr)
+ else:
+ report_diff(frozen[key], current[key], f"{path}.{key}", budget)
+ return
+ if isinstance(frozen, list) and isinstance(current, list):
+ if len(frozen) != len(current):
+ budget[0] -= 1
+ print(f"diff {path}: {len(frozen)} frozen items vs {len(current)} current", file=sys.stderr)
+ for index, (a, b) in enumerate(zip(frozen, current)):
+ report_diff(a, b, f"{path}[{index}]", budget)
+ return
+ if frozen != current:
+ budget[0] -= 1
+ print(f"diff {path}: frozen={frozen!r} current={current!r}", file=sys.stderr)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--write", action="store_true")
+ parser.add_argument("--archive-dir", type=Path)
+ parser.add_argument("--parser-runtime", action="store_true")
+ args = parser.parse_args()
+ if args.write:
+ if not args.archive_dir:
+ parser.error("--write requires --archive-dir")
+ MANIFEST.write_text(json.dumps(snapshot(args.archive_dir), indent=2) + "\n")
+ print(f"wrote {MANIFEST}")
+ elif args.parser_runtime:
+ if args.archive_dir:
+ parser.error("--parser-runtime does not accept --archive-dir")
+ verify_parser_runtime()
+ else:
+ verify(args.archive_dir)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/browser/skills/SKILL.md b/browser/skills/SKILL.md
index 5835192f2..378ca5e4f 100644
--- a/browser/skills/SKILL.md
+++ b/browser/skills/SKILL.md
@@ -3,13 +3,20 @@ name: browser
description: >-
Interactive Chromium sessions for reading and driving real web pages: open a
URL, read the page as text, click and type, and read the page's own console
- and network history. Reach for it when a task involves a running web app,
- especially "why is this page broken".
+ and network history. Also scrapes: HTTP and browser fetching, screenshots,
+ persistent sessions, crawling, and CSS/XPath/regex parsing of HTML you
+ already have. Reach for it when a task involves a running web app,
+ especially "why is this page broken", or when pulling data off the web.
---
# browser
-The browser worker runs real Chromium sessions on the bus. Start a session,
+The browser worker does two things. It runs real Chromium sessions on the bus
+(`browser::*`), and it parses HTML natively without a browser
+(`browser::*` — CSS/XPath/regex queries, element search,
+HTML→Markdown, over any HTML string you already have).
+
+Start a session,
navigate, and the page becomes data: `browser::snapshot` returns an
accessibility outline whose `[ref=eN]` handles feed straight into
`browser::act`, and everything the page logs (console calls, uncaught
@@ -38,9 +45,16 @@ on navigation; re-snapshot before acting after any page change.
## Boundaries
-- One-shot fetching and scraping belong to `web::fetch` (plain HTTP) and the
- scrapling worker (stealth fetching and bulk extraction). Do not start a
- browser session just to read a static page once.
+- Do not start a browser session just to read a page once. One-shot fetching
+ is `browser::fetch` (no browser) or `browser::dynamic-fetch`
+ (Chromium, when the page needs JS). Sessions are for flows that need state
+ between steps.
+- Parsing HTML you already have needs neither a session nor a fetch: use the
+ `browser::*` parse function below. Starting Chromium to run a
+ CSS selector over a string you are already holding is pure waste.
+- `solve_cloudflare` is available on `browser::stealthy-fetch` and stealthy
+ Scrapling sessions. Use `browser::handoff` for challenges in an interactive
+ session or when automated solving does not clear the page.
- Attach mode reaches the user's real browser profile with its logged-in
sessions. It is disabled unless `allow_attach` is set, and adoption is
exclusive (one session per tab) so two sessions never fight over a tab.
@@ -107,6 +121,85 @@ on navigation; re-snapshot before acting after any page change.
- `browser::styles::read` / `browser::styles::write` — computed styles and
live inline CSS edits on one element.
+### Fetching, sessions and crawl
+
+These reach the network, so they need approval. All return one envelope —
+`{status, url, headers, cookies, encoding}` — and can extract or render inline
+via `selectors` / `format: markdown|text` / `include_html`, so you rarely need
+a second call to parse what you fetched. Each takes a single `url` or a bulk
+`urls` list.
+
+- `browser::fetch` — plain HTTP, no browser. The default choice: fastest,
+ cheapest. Safe mode uses bounded native HTTP; certified compat mode uses the
+ frozen curl-impersonate wire behavior.
+- `browser::dynamic-fetch` — real Chromium over CDP, for pages that
+ need JavaScript. Supports `wait_selector` (+ `wait_selector_state`),
+ `network_idle`, and a plain `wait`.
+- `browser::stealthy-fetch` — same, plus masking of the automation
+ tells a page can read. Escalate here only when `dynamic-fetch` is detected.
+- `browser::screenshot-url` — page as image tiles (≤1024px wide, ≤6
+ tiles); says so in the caption when a page is taller than the budget.
+- `browser::session-open` / `session-fetch` / `session-close` /
+ `session-list` — keep cookies and browser state across fetches.
+ HTTP, dynamic and stealthy types are private FIFO sessions with UUID4 hex
+ ids; they never appear in `browser::sessions::list` and reject interactive
+ ids. Close sessions when done.
+- `browser::crawl` — breadth-first from `start_urls`, same-domain by
+ default, capped by `max_pages` (20) and `max_depth` (2). The response holds
+ only a ≤10-item sample; read the rest from the stream it names.
+
+Safe mode refuses private, loopback and cloud-metadata addresses on every one
+of these connections (including redirects and crawl hops). To scrape a local
+dev server the operator must set `browser.scrapling.allow_loopback` in worker
+config. Compat mode reproduces the standalone worker's unrestricted network
+behavior and is for trusted calls.
+
+### HTML parsing — no session, no browser, no network
+
+These take an `html` string and never touch Chromium. Use them on HTML from
+any source (a fetch body, a file, a page you already read).
+
+- `browser::extract` — declarative selector list in one call:
+ each entry names a `css`/`xpath`/`regex` plus optional `attr`/`html`/`all`,
+ and the response is a `{name: value}` map. The right default when pulling
+ several fields off one document.
+- `browser::css` / `browser::xpath` — one query;
+ `first: true` returns a scalar, otherwise an array. `attr` pulls an
+ attribute instead of text.
+- `browser::regex` — regex over the document's visible text.
+- `browser::find` — element search by tag/attribute filters
+ (+ optional text regex), BeautifulSoup-style.
+- `browser::find-by-text` / `browser::find-by-regex` —
+ find elements by their visible text. Responses carry generated css/xpath
+ selectors for each hit, so you can feed one straight back into a query.
+- `browser::find-similar` — give one example element, get its
+ structural siblings. The fast path for "extract every card/row on this
+ page" without hand-writing a selector.
+- `browser::describe` — inspect the first match: attributes,
+ class list, generated selectors, parent/child/sibling counts.
+- `browser::to-markdown` — HTML → compact Markdown (or text), with
+ an optional CSS scope and a main-content cleaner. Use it to shrink a page
+ before putting it in context.
+
+`adaptive: true` persists element identities in the configured SQLite file.
+Parse calls are auto-allowed, so do not assume parsing is side-effect-free
+when adaptive tracking is enabled. Safe mode enforces the configured database
+quota; compat mode preserves the standalone worker's unbounded behavior.
+
+### Safe and compat modes
+
+`browser.scrapling.security_mode` defaults to `safe`. Safe mode keeps SSRF,
+TLS, proxy, response-size, timeout, and adaptive-database policy checks; an
+option the safe backend cannot enforce is refused with an actionable error.
+Compat is eligible only on certified Linux x86_64/aarch64 builds containing
+the frozen curl-impersonate and Chromium artifacts. Other targets reject it,
+and a Tier-1 build missing an artifact reports a capability error instead of
+silently using the safe transport.
+
+Native ids are `browser::`. Map `scrapling::screenshot` to
+`browser::screenshot-url`; `browser::screenshot` is the interactive-session
+function. Crawl's default stream is `browser::crawl`.
+
## Workflow: inspect before acting
1. Snapshot first; act on refs from the latest snapshot, never from memory
diff --git a/browser/src/config.rs b/browser/src/config.rs
index 83e63316d..e5f76f014 100644
--- a/browser/src/config.rs
+++ b/browser/src/config.rs
@@ -13,6 +13,101 @@ use serde::{Deserialize, Serialize};
pub type SharedConfig = Arc>;
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum SecurityMode {
+ #[default]
+ Safe,
+ Compat,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(default)]
+pub struct ScraplingDefaults {
+ pub impersonate: String,
+ pub headless: bool,
+ pub network_idle: bool,
+ pub proxy: String,
+ pub include_html: bool,
+}
+
+impl Default for ScraplingDefaults {
+ fn default() -> Self {
+ Self {
+ impersonate: "chrome".to_string(),
+ headless: true,
+ network_idle: false,
+ proxy: String::new(),
+ include_html: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(default)]
+pub struct ScraplingConfig {
+ pub security_mode: SecurityMode,
+ pub chromium_executable: String,
+ pub allow_loopback: bool,
+ pub defaults: ScraplingDefaults,
+ pub max_bulk_concurrency: u64,
+ pub max_sessions: u64,
+ pub session_idle_timeout_s: u64,
+ pub adaptive_storage_path: String,
+ pub adaptive_max_bytes: u64,
+ /// Append the browser::* scraping guidance to agent system prompts.
+ /// Hot-applies: flipping it in the console binds/unbinds the
+ /// pre-generate hook live, no restart (same knob the Python scrapling
+ /// worker and the fp/web workers carry).
+ pub inject_guidance: bool,
+}
+
+impl Default for ScraplingConfig {
+ fn default() -> Self {
+ Self {
+ security_mode: SecurityMode::Safe,
+ chromium_executable: String::new(),
+ allow_loopback: false,
+ defaults: ScraplingDefaults::default(),
+ max_bulk_concurrency: 5,
+ max_sessions: 8,
+ session_idle_timeout_s: 900,
+ adaptive_storage_path: "./data/scrapling/elements.db".to_string(),
+ adaptive_max_bytes: 268_435_456,
+ inject_guidance: true,
+ }
+ }
+}
+
+impl ScraplingConfig {
+ pub fn startup_snapshot(&self) -> ScraplingStartupConfig {
+ ScraplingStartupConfig {
+ max_sessions: self.max_sessions,
+ session_idle_timeout_s: self.session_idle_timeout_s,
+ adaptive_storage_path: self.adaptive_storage_path.clone(),
+ }
+ }
+
+ pub fn adaptive_quota(&self) -> Option {
+ (self.security_mode == SecurityMode::Safe).then_some(self.adaptive_max_bytes)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScraplingStartupConfig {
+ pub max_sessions: u64,
+ pub session_idle_timeout_s: u64,
+ pub adaptive_storage_path: String,
+}
+
+pub const fn scrapling_compat_supported() -> bool {
+ cfg!(all(
+ feature = "scrapling-compat",
+ target_os = "linux",
+ any(target_arch = "x86_64", target_arch = "aarch64")
+ ))
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct WorkerConfig {
@@ -62,6 +157,19 @@ pub struct WorkerConfig {
/// browser and adopt its tabs. Off by default: attaching reaches the
/// user's real profile with its logged-in sessions, so it is opt-in.
pub allow_attach: bool,
+ /// Native Scrapling compatibility settings. These are isolated from the
+ /// interactive browser runtime above.
+ pub scrapling: ScraplingConfig,
+ /// Internal compatibility projection for routes that have not yet moved
+ /// to the private Scrapling runtime. It is populated from `scrapling` and
+ /// is deliberately absent from the public configuration schema/wire.
+ #[serde(skip)]
+ #[schemars(skip)]
+ pub allow_loopback: bool,
+ /// Internal compatibility projection; see `allow_loopback`.
+ #[serde(skip)]
+ #[schemars(skip)]
+ pub max_bulk_concurrency: u64,
}
impl Default for WorkerConfig {
@@ -82,6 +190,9 @@ impl Default for WorkerConfig {
allowed_schemes: vec!["http".to_string(), "https".to_string(), "file".to_string()],
max_snapshot_nodes: 2_000,
allow_attach: false,
+ scrapling: ScraplingConfig::default(),
+ allow_loopback: false,
+ max_bulk_concurrency: 5,
}
}
}
@@ -97,17 +208,41 @@ impl WorkerConfig {
/// under a `browser` wrapper or flat — accept both.
pub fn from_json(v: &serde_json::Value) -> Result {
let inner = v.get("browser").unwrap_or(v);
- serde_json::from_value(inner.clone()).map_err(|e| format!("invalid browser config: {e}"))
+ let mut config: WorkerConfig = serde_json::from_value(inner.clone())
+ .map_err(|e| format!("invalid browser config: {e}"))?;
+ config.validate()?;
+ config.sync_scrapling_projection();
+ Ok(config)
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).expect("WorkerConfig serializes")
}
- pub fn into_shared(self) -> SharedConfig {
+ pub fn into_shared(mut self) -> SharedConfig {
+ self.sync_scrapling_projection();
Arc::new(ArcSwap::from_pointee(self))
}
+ pub fn validate(&self) -> Result<(), String> {
+ self.validate_with_compat_support(scrapling_compat_supported())
+ }
+
+ fn validate_with_compat_support(&self, compat_supported: bool) -> Result<(), String> {
+ if self.scrapling.security_mode == SecurityMode::Compat && !compat_supported {
+ return Err(
+ "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64"
+ .to_string(),
+ );
+ }
+ Ok(())
+ }
+
+ fn sync_scrapling_projection(&mut self) {
+ self.allow_loopback = self.scrapling.allow_loopback;
+ self.max_bulk_concurrency = self.scrapling.max_bulk_concurrency;
+ }
+
/// Clamp a caller-supplied timeout to the configured ceiling, defaulting
/// when omitted.
pub fn clamp_timeout(&self, requested: Option) -> u64 {
@@ -139,6 +274,32 @@ mod tests {
assert_eq!(c.allowed_schemes, vec!["http", "https", "file"]);
assert_eq!(c.max_snapshot_nodes, 2_000);
assert!(!c.allow_attach);
+ assert_eq!(c.scrapling.security_mode, SecurityMode::Safe);
+ assert_eq!(c.scrapling.chromium_executable, "");
+ assert!(!c.scrapling.allow_loopback);
+ assert_eq!(c.scrapling.defaults.impersonate, "chrome");
+ assert!(c.scrapling.defaults.headless);
+ assert!(!c.scrapling.defaults.network_idle);
+ assert_eq!(c.scrapling.defaults.proxy, "");
+ assert!(!c.scrapling.defaults.include_html);
+ assert_eq!(c.scrapling.max_bulk_concurrency, 5);
+ assert_eq!(c.scrapling.max_sessions, 8);
+ assert_eq!(c.scrapling.session_idle_timeout_s, 900);
+ assert_eq!(
+ c.scrapling.adaptive_storage_path,
+ "./data/scrapling/elements.db"
+ );
+ assert_eq!(c.scrapling.adaptive_max_bytes, 268_435_456);
+ assert_eq!(c.scrapling.adaptive_quota(), Some(268_435_456));
+ }
+
+ #[test]
+ fn compat_keeps_the_oracles_unbounded_adaptive_storage() {
+ let config = ScraplingConfig {
+ security_mode: SecurityMode::Compat,
+ ..ScraplingConfig::default()
+ };
+ assert_eq!(config.adaptive_quota(), None);
}
#[test]
@@ -168,6 +329,121 @@ mod tests {
assert!(!c.headless);
}
+ #[test]
+ fn nested_scrapling_values_parse_without_changing_interactive_values() {
+ let value = serde_json::json!({
+ "browser": {
+ "headless": false,
+ "max_sessions": 3,
+ "scrapling": {
+ "allow_loopback": true,
+ "max_bulk_concurrency": 2,
+ "max_sessions": 7,
+ "session_idle_timeout_s": 45,
+ "adaptive_storage_path": "/tmp/scrapling-test.db",
+ "adaptive_max_bytes": 1024,
+ "defaults": {
+ "impersonate": "firefox",
+ "headless": false,
+ "network_idle": true,
+ "proxy": "http://proxy.test:8080",
+ "include_html": true
+ }
+ }
+ }
+ });
+
+ let config = WorkerConfig::from_json(&value).unwrap();
+ assert!(!config.headless);
+ assert_eq!(config.max_sessions, 3);
+ assert!(config.scrapling.allow_loopback);
+ assert_eq!(config.scrapling.max_bulk_concurrency, 2);
+ assert_eq!(config.scrapling.max_sessions, 7);
+ assert_eq!(config.scrapling.session_idle_timeout_s, 45);
+ assert_eq!(
+ config.scrapling.adaptive_storage_path,
+ "/tmp/scrapling-test.db"
+ );
+ assert_eq!(config.scrapling.adaptive_max_bytes, 1024);
+ assert_eq!(config.scrapling.defaults.impersonate, "firefox");
+ assert!(!config.scrapling.defaults.headless);
+ assert!(config.scrapling.defaults.network_idle);
+ assert_eq!(config.scrapling.defaults.proxy, "http://proxy.test:8080");
+ assert!(config.scrapling.defaults.include_html);
+ assert!(config.allow_loopback);
+ assert_eq!(config.max_bulk_concurrency, 2);
+ }
+
+ #[test]
+ fn serialized_config_exposes_scrapling_settings_only_in_nested_block() {
+ let value = WorkerConfig::default().to_json();
+ assert!(value.get("scrapling").is_some());
+ assert!(value.get("allow_loopback").is_none());
+ assert!(value.get("max_bulk_concurrency").is_none());
+ }
+
+ #[test]
+ fn startup_snapshot_owns_frozen_session_and_adaptive_values() {
+ let mut config = WorkerConfig::default();
+ config.scrapling.max_sessions = 6;
+ config.scrapling.session_idle_timeout_s = 123;
+ config.scrapling.adaptive_storage_path = "/tmp/first.db".to_string();
+
+ let snapshot = config.scrapling.startup_snapshot();
+ config.scrapling.max_sessions = 9;
+ config.scrapling.session_idle_timeout_s = 456;
+ config.scrapling.adaptive_storage_path = "/tmp/second.db".to_string();
+
+ assert_eq!(snapshot.max_sessions, 6);
+ assert_eq!(snapshot.session_idle_timeout_s, 123);
+ assert_eq!(snapshot.adaptive_storage_path, "/tmp/first.db");
+ }
+
+ #[test]
+ fn compat_is_explicitly_rejected_when_target_is_not_tier_one() {
+ let mut config = WorkerConfig::default();
+ config.scrapling.security_mode = SecurityMode::Compat;
+ let error = config.validate_with_compat_support(false).unwrap_err();
+ assert_eq!(
+ error,
+ "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64"
+ );
+ }
+
+ #[test]
+ fn compat_validation_tracks_the_compiled_target() {
+ let result = WorkerConfig::from_json(&serde_json::json!({
+ "scrapling": {"security_mode": "compat"}
+ }));
+ if scrapling_compat_supported() {
+ assert_eq!(
+ result.unwrap().scrapling.security_mode,
+ SecurityMode::Compat
+ );
+ } else {
+ assert_eq!(
+ result.unwrap_err(),
+ "browser.scrapling.security_mode=compat is unsupported on this target; compat requires Tier-1 Linux x86_64 or aarch64"
+ );
+ }
+ }
+
+ #[test]
+ fn safe_mode_is_accepted_on_every_target() {
+ WorkerConfig::default()
+ .validate_with_compat_support(false)
+ .unwrap();
+ }
+
+ #[test]
+ fn unknown_security_mode_is_rejected() {
+ let error = WorkerConfig::from_json(&serde_json::json!({
+ "scrapling": {"security_mode": "unsafe"}
+ }))
+ .unwrap_err();
+ assert!(error.contains("unknown variant `unsafe`"), "{error}");
+ }
+
#[test]
fn clamp_timeout_defaults_and_ceils() {
let c = WorkerConfig::default();
@@ -183,5 +459,38 @@ mod tests {
assert!(props.get("executable").is_some());
assert!(props.get("headless").is_some());
assert!(props.get("allowed_schemes").is_some());
+ let scrapling = &props["scrapling"];
+ assert_eq!(
+ scrapling["default"],
+ serde_json::to_value(WorkerConfig::default().scrapling).unwrap()
+ );
+ assert_eq!(
+ scrapling["allOf"][0]["$ref"],
+ "#/definitions/ScraplingConfig"
+ );
+ let scrapling_properties = &s["definitions"]["ScraplingConfig"]["properties"];
+ let names: std::collections::BTreeSet<_> = scrapling_properties
+ .as_object()
+ .unwrap()
+ .keys()
+ .map(String::as_str)
+ .collect();
+ assert_eq!(
+ names,
+ std::collections::BTreeSet::from([
+ "adaptive_max_bytes",
+ "adaptive_storage_path",
+ "allow_loopback",
+ "chromium_executable",
+ "defaults",
+ "inject_guidance",
+ "max_bulk_concurrency",
+ "max_sessions",
+ "security_mode",
+ "session_idle_timeout_s",
+ ])
+ );
+ assert!(props.get("allow_loopback").is_none());
+ assert!(props.get("max_bulk_concurrency").is_none());
}
}
diff --git a/browser/src/configuration.rs b/browser/src/configuration.rs
index 7bef16766..fc086899f 100644
--- a/browser/src/configuration.rs
+++ b/browser/src/configuration.rs
@@ -68,7 +68,11 @@ struct OnConfigChangeResponse {
ok: bool,
}
-pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result<(), Error> {
+pub fn register_config_trigger(
+ iii: &IIIClient,
+ config: SharedConfig,
+ guidance: crate::scrapling::GuidanceState,
+) -> Result<(), Error> {
let cfg = config.clone();
let engine = iii.clone();
iii.register_function(
@@ -76,8 +80,9 @@ pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result<
RegisterFunction::new_async(move |_req: OnConfigChangeRequest| {
let cfg = cfg.clone();
let engine = engine.clone();
+ let guidance = guidance.clone();
async move {
- on_config_change(&engine, &cfg).await;
+ on_config_change(&engine, &cfg, &guidance).await;
Ok::(OnConfigChangeResponse { ok: true })
}
})
@@ -96,10 +101,19 @@ pub fn register_config_trigger(iii: &IIIClient, config: SharedConfig) -> Result<
Ok(())
}
-async fn on_config_change(iii: &IIIClient, config: &SharedConfig) {
+async fn on_config_change(
+ iii: &IIIClient,
+ config: &SharedConfig,
+ guidance: &crate::scrapling::GuidanceState,
+) {
match fetch_config(iii).await {
Ok(cfg) => {
+ crate::scrapling::adaptive::configure_quota(cfg.scrapling.adaptive_quota());
+ let inject_guidance = cfg.scrapling.inject_guidance;
config.store(std::sync::Arc::new(cfg));
+ // Hot-apply: flipping browser.scrapling.inject_guidance in the
+ // console binds/unbinds the pre-generate guidance hook live.
+ crate::scrapling::apply_guidance(iii, guidance, inject_guidance);
tracing::info!("browser configuration reloaded");
}
Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"),
diff --git a/browser/src/lib.rs b/browser/src/lib.rs
index 8b3f763e5..ddfbfcb87 100644
--- a/browser/src/lib.rs
+++ b/browser/src/lib.rs
@@ -1,12 +1,16 @@
//! Library surface for the `browser` worker: interactive Chromium sessions
-//! on the iii bus. The binary (`src/main.rs`) is a thin boot sequence;
-//! everything testable lives here.
+//! on the iii bus, plus the native `browser::*` HTML-parsing
+//! surface. The binary (`src/main.rs`) is a thin boot sequence; everything
+//! testable lives here.
pub mod config;
pub mod configuration;
pub mod events;
pub mod functions;
+pub mod logging;
pub mod manifest;
+pub mod scrapling;
pub mod session;
pub mod snapshot;
+pub mod ssrf;
pub mod ui;
diff --git a/browser/src/logging.rs b/browser/src/logging.rs
new file mode 100644
index 000000000..482704729
--- /dev/null
+++ b/browser/src/logging.rs
@@ -0,0 +1,99 @@
+//! Log-filter construction for the worker binary.
+//!
+//! chromiumoxide 0.9.1's protocol bindings lag the system Chromium, so events
+//! carrying enum values added since (e.g. DOM.pseudoElementAdded with
+//! `overscroll-backdrop` on Chrome 151) fail its untagged-enum deserialize and
+//! its handler WARN-spams "WS Invalid message" on every such frame — dozens
+//! per page load. The frames are dropped either way (`ignore_invalid_messages`
+//! defaults on; command responses can't fail this way, only events), so the
+//! WARN carries no signal an operator can act on. Demote that module to
+//! `error` unless the operator's RUST_LOG addresses chromiumoxide explicitly.
+//!
+//! ponytail: known ceiling — a Network.loadingFailed carrying one of the
+//! Chrome-151 corsError values is still silently dropped before our listener;
+//! recovering it needs chromiumoxide regenerated against the newer protocol
+//! (no such release yet; 0.9.1 is current).
+
+use tracing_subscriber::EnvFilter;
+
+/// The worker's env filter: RUST_LOG (default `info`), with
+/// `chromiumoxide::handler` demoted to `error` unless RUST_LOG mentions
+/// chromiumoxide — an explicit operator directive always wins.
+pub fn env_filter(rust_log: Option<&str>) -> EnvFilter {
+ let mut filter = match rust_log {
+ Some(directives) => EnvFilter::new(directives),
+ None => EnvFilter::new("info"),
+ };
+ if rust_log.is_none_or(|directives| !directives.contains("chromiumoxide")) {
+ filter = filter.add_directive(
+ "chromiumoxide::handler=error"
+ .parse()
+ .expect("static directive parses"),
+ );
+ }
+ filter
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use std::sync::{Arc, Mutex};
+ use tracing_subscriber::layer::SubscriberExt;
+
+ #[derive(Clone, Default)]
+ struct Buffer(Arc>>);
+
+ impl Write for Buffer {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result {
+ self.0.lock().unwrap().extend_from_slice(buf);
+ Ok(buf.len())
+ }
+ fn flush(&mut self) -> std::io::Result<()> {
+ Ok(())
+ }
+ }
+
+ fn captured(filter: EnvFilter) -> String {
+ let buffer = Buffer::default();
+ let writer = buffer.clone();
+ let subscriber = tracing_subscriber::registry().with(filter).with(
+ tracing_subscriber::fmt::layer()
+ .with_writer(move || writer.clone())
+ .with_ansi(false),
+ );
+ tracing::subscriber::with_default(subscriber, || {
+ tracing::warn!(target: "chromiumoxide::handler", "WS Invalid message");
+ tracing::error!(target: "chromiumoxide::handler", "WS Connection error");
+ tracing::warn!(target: "browser::session", "worker warn passes");
+ });
+ let bytes = buffer.0.lock().unwrap().clone();
+ String::from_utf8(bytes).unwrap()
+ }
+
+ #[test]
+ fn default_filter_drops_the_invalid_message_spam_but_keeps_errors() {
+ let out = captured(env_filter(None));
+ assert!(
+ !out.contains("WS Invalid message"),
+ "spam not dropped:\n{out}"
+ );
+ assert!(
+ out.contains("WS Connection error"),
+ "real errors lost:\n{out}"
+ );
+ assert!(
+ out.contains("worker warn passes"),
+ "worker warns lost:\n{out}"
+ );
+ }
+
+ #[test]
+ fn explicit_chromiumoxide_directive_in_rust_log_wins() {
+ let out = captured(env_filter(Some("info,chromiumoxide=warn")));
+ assert!(
+ out.contains("WS Invalid message"),
+ "operator's explicit directive was overridden:\n{out}"
+ );
+ }
+}
diff --git a/browser/src/main.rs b/browser/src/main.rs
index f9273e8fb..372243884 100644
--- a/browser/src/main.rs
+++ b/browser/src/main.rs
@@ -1,6 +1,7 @@
//! `browser` binary entry: connect, register configuration + fetch the
-//! authoritative value, register the five `browser::*` trigger types and
-//! twelve functions, start the idle sweep, then sleep until Ctrl+C.
+//! authoritative value, register the `browser::*` trigger types and functions
+//! plus the native `browser::*` parse surface, start the idle
+//! sweep, then sleep until Ctrl+C.
use std::sync::Arc;
use std::time::Duration;
@@ -13,7 +14,7 @@ use iii_sdk::{register_worker, InitOptions};
use browser::config::WorkerConfig;
use browser::events::{self, IiiDeliverer};
use browser::session::Sessions;
-use browser::{configuration, functions, manifest};
+use browser::{configuration, functions, manifest, scrapling};
#[derive(Parser, Debug)]
#[command(
@@ -53,11 +54,9 @@ async fn wait_for_shutdown_signal() -> Result<()> {
#[tokio::main]
async fn main() -> Result<()> {
+ let rust_log = std::env::var("RUST_LOG").ok();
tracing_subscriber::fmt()
- .with_env_filter(
- tracing_subscriber::EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
- )
+ .with_env_filter(browser::logging::env_filter(rust_log.as_deref()))
.init();
let cli = Cli::parse();
@@ -120,10 +119,19 @@ async fn main() -> Result<()> {
.await
.map_err(anyhow::Error::msg)
.context("loading browser configuration")?;
+ let scrapling_startup = cfg.scrapling.startup_snapshot();
+ scrapling::adaptive::configure(&scrapling_startup.adaptive_storage_path)
+ .map_err(anyhow::Error::msg)
+ .context("configuring Scrapling adaptive storage")?;
+ scrapling::adaptive::configure_quota(cfg.scrapling.adaptive_quota());
tracing::info!(
headless = cfg.headless,
max_sessions = cfg.max_sessions,
console_buffer = cfg.console_buffer,
+ scrapling_security_mode = ?cfg.scrapling.security_mode,
+ scrapling_max_sessions = scrapling_startup.max_sessions,
+ scrapling_session_idle_timeout_s = scrapling_startup.session_idle_timeout_s,
+ scrapling_adaptive_storage_path = %scrapling_startup.adaptive_storage_path,
"loaded browser configuration"
);
let shared = cfg.into_shared();
@@ -138,27 +146,45 @@ async fn main() -> Result<()> {
let sessions = Sessions::new(shared.clone(), emitter, iii.clone());
functions::register_all(&iii, &sessions);
- configuration::register_config_trigger(&iii, shared.clone())
+ // Scrapling owns a private HTTP/dynamic/stealthy registry. Its ids never
+ // enter or control the interactive browser::sessions::* registry.
+ let scrapling_ctx = Arc::new(scrapling::net::Ctx::new(sessions.clone(), iii.clone()));
+ scrapling::register_all(&iii, &scrapling_ctx);
+ // The guidance hook FUNCTION is registered above (inert without a
+ // binding); the binding follows the inject_guidance knob — applied here
+ // at boot and re-applied by the config-change handler, so console flips
+ // take effect without a restart.
+ let guidance = scrapling::GuidanceState::default();
+ scrapling::apply_guidance(&iii, &guidance, shared.load().scrapling.inject_guidance);
+
+ configuration::register_config_trigger(&iii, shared.clone(), guidance)
.context("registering configuration change trigger")?;
// Injectable console UI — after the browser::* functions so the console
// can attribute the assets.
browser::ui::register(&iii);
- // Idle sweep: stop sessions nobody has touched for idle_stop_ms.
+ // Idle sweep closes metadata and backends together in both registries.
let sweep_sessions = sessions.clone();
+ let sweep_ctx = scrapling_ctx.clone();
let sweep = tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
loop {
tick.tick().await;
sweep_sessions.sweep_idle().await;
+ for id in sweep_ctx.http.sweep_idle() {
+ tracing::info!(session = %id, "scrapling session reaped (idle)");
+ }
}
});
- tracing::info!("browser ready: browser::* sessions + console capture + pick");
+ tracing::info!(
+ "browser ready: browser::* sessions + console capture + pick, browser::* parsing"
+ );
wait_for_shutdown_signal().await?;
tracing::info!("browser shutting down");
sweep.abort();
+ scrapling_ctx.http.close_all().await;
sessions.stop_all().await;
iii.shutdown_async().await;
Ok(())
diff --git a/browser/src/manifest.rs b/browser/src/manifest.rs
index f0bbc37fd..4d39daa54 100644
--- a/browser/src/manifest.rs
+++ b/browser/src/manifest.rs
@@ -17,7 +17,8 @@ pub fn build_manifest() -> ModuleManifest {
version: env!("CARGO_PKG_VERSION").to_string(),
description:
"Interactive Chromium sessions on the iii bus. Navigate, act, read the page console, \
- pick elements."
+ pick elements. Also parses HTML natively without a browser \
+ (browser::* — css/xpath/regex, element search, markdown)."
.to_string(),
default_config: WorkerConfig::default().to_json(),
supported_targets: vec![env!("TARGET").to_string()],
diff --git a/browser/src/scrapling/adaptive.rs b/browser/src/scrapling/adaptive.rs
new file mode 100644
index 000000000..19403f93a
--- /dev/null
+++ b/browser/src/scrapling/adaptive.rs
@@ -0,0 +1,685 @@
+//! Scrapling 0.4.9 Smart Element Tracking over the shared compatibility DOM.
+
+use std::collections::HashMap;
+use std::hash::Hash;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{OnceLock, RwLock};
+use std::time::Duration;
+
+use rusqlite::{params, types::ValueRef, Connection};
+use serde::{Deserialize, Serialize};
+use serde_json::Map;
+
+use crate::scrapling::dom::{self, Doc, ElementRef};
+use crate::scrapling::query::{self, QueryResult};
+
+const DEFAULT_PATH: &str = "./data/scrapling/elements.db";
+const MIN_SCORE: f64 = 40.0;
+
+// Delta from psl 2.1.180 to tld 0.13.2's frozen 2026-03-06 PSL. The source
+// snapshot's SHA-256 is
+// abf32ce9987d505b89765d76f35760543851235508f1f426b5b259a2062b5f68.
+const FROZEN_ADDED_EXACT: &str = "1cooldns.com
+auth.cognito-idp.eusc-de-east-1.on.amazonwebservices.eu
+blob.core.usgovcloudapi.net
+bumbleshrimp.com
+com.kh
+corespeed.app
+ddnsguru.com
+discourse.diy
+drive-platform.com
+drive-platform.io
+dynuddns.com
+dynuddns.net
+dynuhosting.com
+edu.kh
+eu-west-1.convex.cloud
+eu-west-1.convex.site
+file.core.usgovcloudapi.net
+file.core.windows.net
+gov.kh
+hue.vn
+imagine.diy
+intouch.email
+kdns.fr
+keenetic.io
+keenetic.link
+keenetic.name
+keenetic.pro
+kh
+miren.app
+miren.systems
+ms.fun
+ms.show
+my.be
+mybox.company
+mybox.me
+mybox.page
+mysynology.net
+net.kh
+opik.net
+org.kh
+pivohosting.com
+roxa.org
+s3-website.dualstack.us-gov-east-1.amazonaws.com
+s3-website.dualstack.us-gov-west-1.amazonaws.com
+sandbox.deno.net
+shiptoday.app
+shiptoday.build
+sol.site
+spawnbase.app
+spryt.net
+transfer-webapp.ap-southeast-7.on.aws
+transfer-webapp.mx-central-1.on.aws
+us-east-1.convex.cloud
+us-east-1.convex.site
+usgovtrafficmanager.net
+web.core.usgovcloudapi.net
+web.core.windows.net
+wiredbladehosting.com";
+const FROZEN_ADDED_WILDCARD_BASES: &str = "aa.crm.dev
+ab.crm.dev
+ac.crm.dev
+ad.crm.dev
+ae.crm.dev
+af.crm.dev
+begetcdn.cloud
+ci.crm.dev
+pa.crm.dev
+pb.crm.dev
+pc.crm.dev
+pd.crm.dev
+pe.crm.dev
+pf.crm.dev";
+const FROZEN_REMOVED_EXACT: &[&str] = &["12chars.dev", "12chars.it", "12chars.pro", "mazeplay.com"];
+
+static STORAGE_PATH: OnceLock> = OnceLock::new();
+// `u64::MAX` means oracle-compatible unbounded storage.
+static MAX_BYTES: AtomicU64 = AtomicU64::new(u64::MAX);
+
+fn configured_path() -> &'static RwLock {
+ STORAGE_PATH.get_or_init(|| RwLock::new(PathBuf::from(DEFAULT_PATH)))
+}
+
+/// Set the process-wide adaptive database path, matching the standalone
+/// worker's one-time `storage.configure(...)` boot setting.
+pub fn configure(path: impl AsRef) -> Result<(), String> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+ *configured_path()
+ .write()
+ .map_err(|error| error.to_string())? = path.to_path_buf();
+ Ok(())
+}
+
+/// Apply the live safe-mode storage ceiling. Compat passes `None` to retain
+/// the standalone worker's unbounded behavior.
+pub fn configure_quota(max_bytes: Option) {
+ MAX_BYTES.store(max_bytes.unwrap_or(u64::MAX), Ordering::Relaxed);
+}
+
+fn storage_path() -> Result {
+ configured_path()
+ .read()
+ .map(|path| path.clone())
+ .map_err(|error| error.to_string())
+}
+
+pub fn css_query<'a>(
+ doc: &'a Doc,
+ scope: Option>,
+ selector: &str,
+ domain: Option<&str>,
+ identifier: &str,
+ auto_save: bool,
+) -> Result>, String> {
+ let mut storage = Storage::open(domain)?;
+ if selector.contains(',') {
+ let selectors = cssselect::parse(selector)
+ .map_err(|error| format!("Invalid CSS selector '{selector}': {error}"))?;
+ let mut results = Vec::new();
+ for parsed in selectors {
+ let direct = query::css_query(doc, scope, &parsed.canonical())?;
+ results.extend(storage.resolve(doc, direct, identifier, auto_save)?);
+ }
+ Ok(results)
+ } else {
+ let direct = query::css_query(doc, scope, selector)?;
+ storage.resolve(doc, direct, identifier, auto_save)
+ }
+}
+
+pub fn xpath_query<'a>(
+ doc: &'a Doc,
+ scope: Option>,
+ selector: &str,
+ domain: Option<&str>,
+ identifier: &str,
+ auto_save: bool,
+) -> Result>, String> {
+ let direct = crate::scrapling::xpath::xpath_query(doc, scope, selector)?;
+ Storage::open(domain)?.resolve(doc, direct, identifier, auto_save)
+}
+
+struct Storage {
+ connection: Connection,
+ domain: String,
+}
+
+impl Storage {
+ fn open(domain: Option<&str>) -> Result {
+ let path = storage_path()?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+ let connection = Connection::open(path).map_err(|error| error.to_string())?;
+ connection
+ .busy_timeout(Duration::from_secs(5))
+ .map_err(|error| error.to_string())?;
+ connection
+ .pragma_update(None, "journal_mode", "WAL")
+ .map_err(|error| error.to_string())?;
+ connection
+ .execute_batch(
+ "CREATE TABLE IF NOT EXISTS storage (\n\
+ id INTEGER PRIMARY KEY,\n\
+ url TEXT,\n\
+ identifier TEXT,\n\
+ element_data TEXT,\n\
+ UNIQUE (url, identifier)\n\
+ );",
+ )
+ .map_err(|error| error.to_string())?;
+ Ok(Self {
+ connection,
+ domain: base_url(domain),
+ })
+ }
+
+ fn resolve<'a>(
+ &mut self,
+ doc: &'a Doc,
+ direct: Vec>,
+ identifier: &str,
+ auto_save: bool,
+ ) -> Result>, String> {
+ if !direct.is_empty() {
+ if auto_save {
+ self.save(result_element(&direct[0]), identifier)?;
+ }
+ return Ok(direct);
+ }
+
+ let Some(saved) = self.retrieve(identifier)? else {
+ return Ok(Vec::new());
+ };
+ let relocated = relocate(doc, &saved);
+ if auto_save {
+ if let Some(first) = relocated.first().copied() {
+ self.save(first, identifier)?;
+ }
+ }
+ Ok(relocated.into_iter().map(QueryResult::Element).collect())
+ }
+
+ fn save(&mut self, element: ElementRef<'_>, identifier: &str) -> Result<(), String> {
+ let bytes = serde_json::to_vec(&ElementData::from_element(element))
+ .map_err(|error| error.to_string())?;
+ let transaction = self
+ .connection
+ .transaction()
+ .map_err(|error| error.to_string())?;
+ transaction
+ .execute(
+ "INSERT OR REPLACE INTO storage (url, identifier, element_data) VALUES (?, ?, ?)",
+ params![self.domain, identifier, bytes],
+ )
+ .map_err(|error| error.to_string())?;
+ let max_bytes = MAX_BYTES.load(Ordering::Relaxed);
+ if max_bytes != u64::MAX {
+ let pages: u64 = transaction
+ .pragma_query_value(None, "page_count", |row| row.get(0))
+ .map_err(|error| error.to_string())?;
+ let page_size: u64 = transaction
+ .pragma_query_value(None, "page_size", |row| row.get(0))
+ .map_err(|error| error.to_string())?;
+ if pages.saturating_mul(page_size) > max_bytes {
+ return Err(format!(
+ "adaptive storage quota exceeded ({max_bytes} bytes); raise browser.scrapling.adaptive_max_bytes or disable adaptive mode"
+ ));
+ }
+ }
+ transaction.commit().map_err(|error| error.to_string())
+ }
+
+ fn retrieve(&self, identifier: &str) -> Result, String> {
+ let mut statement = self
+ .connection
+ .prepare("SELECT element_data FROM storage WHERE url = ? AND identifier = ?")
+ .map_err(|error| error.to_string())?;
+ let mut rows = statement
+ .query(params![self.domain, identifier])
+ .map_err(|error| error.to_string())?;
+ let Some(row) = rows.next().map_err(|error| error.to_string())? else {
+ return Ok(None);
+ };
+ let value = row.get_ref(0).map_err(|error| error.to_string())?;
+ let bytes = match value {
+ ValueRef::Blob(bytes) | ValueRef::Text(bytes) => bytes,
+ _ => return Err("adaptive element_data is neither BLOB nor TEXT".to_string()),
+ };
+ serde_json::from_slice(bytes)
+ .map(Some)
+ .map_err(|error| error.to_string())
+ }
+}
+
+fn result_element<'a>(result: &QueryResult<'a>) -> ElementRef<'a> {
+ match result {
+ QueryResult::Element(element) => *element,
+ QueryResult::Text { parent, .. } => *parent,
+ }
+}
+
+fn base_url(domain: Option<&str>) -> String {
+ let Some(raw) = domain.filter(|value| !value.is_empty()) else {
+ return "default".to_string();
+ };
+ let lower = raw.to_lowercase();
+ let parsed = url::Url::parse(&lower).or_else(|_| url::Url::parse(&format!("http://{lower}")));
+ let Some(host) = parsed
+ .ok()
+ .and_then(|url| url.host_str().map(str::to_string))
+ else {
+ return "default".to_string();
+ };
+ frozen_registrable_domain(&host).unwrap_or_else(|| "default".to_string())
+}
+
+fn frozen_registrable_domain(host: &str) -> Option {
+ let exact = FROZEN_ADDED_EXACT
+ .lines()
+ .filter(|rule| host == *rule || host.ends_with(&format!(".{rule}")))
+ .max_by_key(|rule| rule.len());
+ let wildcard = FROZEN_ADDED_WILDCARD_BASES
+ .lines()
+ .filter_map(|base| {
+ let prefix = host.strip_suffix(&format!(".{base}"))?;
+ (!prefix.is_empty()).then_some((base, prefix))
+ })
+ .max_by_key(|(base, _)| base.len());
+
+ let frozen_suffix = match (exact, wildcard) {
+ (Some(rule), Some((base, prefix))) if base.len() + prefix.len() + 1 > rule.len() => {
+ let wildcard_label = prefix.rsplit('.').next()?;
+ format!("{wildcard_label}.{base}")
+ }
+ (Some(rule), _) => rule.to_string(),
+ (None, Some((base, prefix))) => {
+ let wildcard_label = prefix.rsplit('.').next()?;
+ format!("{wildcard_label}.{base}")
+ }
+ (None, None) => String::new(),
+ };
+ if !frozen_suffix.is_empty() {
+ return registrable_for_suffix(host, &frozen_suffix);
+ }
+
+ for rule in FROZEN_REMOVED_EXACT {
+ if host == *rule || host.ends_with(&format!(".{rule}")) {
+ return Some((*rule).to_string());
+ }
+ }
+ if host == "goo"
+ || host.ends_with(".goo")
+ || host == "wolterskluwer"
+ || host.ends_with(".wolterskluwer")
+ {
+ return None;
+ }
+ if host.ends_with(".cns.joyent.com") {
+ return Some("joyent.com".to_string());
+ }
+ if let Some(prefix) = host.strip_suffix(".kh") {
+ if !prefix.is_empty() {
+ let label = prefix.rsplit('.').next()?;
+ return Some(format!("{label}.kh"));
+ }
+ }
+
+ let suffix = psl::suffix(host.as_bytes())?;
+ if !suffix.is_known() {
+ return None;
+ }
+ psl::domain_str(host)
+ .or_else(|| std::str::from_utf8(suffix.trim().as_bytes()).ok())
+ .map(str::to_string)
+}
+
+fn registrable_for_suffix(host: &str, suffix: &str) -> Option {
+ if host == suffix {
+ return Some(suffix.to_string());
+ }
+ let prefix = host.strip_suffix(&format!(".{suffix}"))?;
+ let label = prefix.rsplit('.').next()?;
+ Some(format!("{label}.{suffix}"))
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ElementData {
+ tag: String,
+ attributes: Map,
+ text: Option,
+ path: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ parent_name: Option,
+ #[serde(default, skip_serializing_if = "Map::is_empty")]
+ parent_attribs: Map,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ parent_text: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ siblings: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ children: Vec,
+}
+
+impl ElementData {
+ fn from_element(element: ElementRef<'_>) -> Self {
+ let attributes = element
+ .attrs()
+ .filter_map(|(name, value)| {
+ let value = value.trim();
+ (!value.is_empty()).then(|| {
+ (
+ name.to_string(),
+ serde_json::Value::String(value.to_string()),
+ )
+ })
+ })
+ .collect();
+ let raw_text = dom::leading_text(element);
+ let text = (!raw_text.is_empty()).then(|| raw_text.trim().to_string());
+ let mut path = vec![element.name().to_string()];
+ let mut current = element;
+ while let Some(parent) = dom::parent_element(current) {
+ path.push(parent.name().to_string());
+ current = parent;
+ }
+ path.reverse();
+
+ let parent = dom::parent_element(element);
+ let parent_name = parent.map(|value| value.name().to_string());
+ let parent_attribs = parent
+ .map(|value| dom::attrs_json(value))
+ .unwrap_or_default();
+ let parent_text = parent.and_then(|value| {
+ let raw = dom::leading_text(value);
+ (!raw.is_empty()).then(|| raw.trim().to_string())
+ });
+ let siblings = parent
+ .map(|value| {
+ dom::element_children(value)
+ .into_iter()
+ .filter(|child| child.id() != element.id())
+ .map(|child| child.name().to_string())
+ .collect()
+ })
+ .unwrap_or_default();
+ let children = dom::element_children(element)
+ .into_iter()
+ .map(|child| child.name().to_string())
+ .collect();
+
+ Self {
+ tag: element.name().to_string(),
+ attributes,
+ text,
+ path,
+ parent_name,
+ parent_attribs,
+ parent_text,
+ siblings,
+ children,
+ }
+ }
+}
+
+fn relocate<'a>(doc: &'a Doc, saved: &ElementData) -> Vec> {
+ let mut best = f64::NEG_INFINITY;
+ let mut matches = Vec::new();
+ for element in dom::descendant_elements(doc.root()) {
+ let score = similarity(saved, &ElementData::from_element(element));
+ if score > best {
+ best = score;
+ matches.clear();
+ matches.push(element);
+ } else if score == best {
+ matches.push(element);
+ }
+ }
+ if best >= MIN_SCORE {
+ matches
+ } else {
+ Vec::new()
+ }
+}
+
+fn similarity(original: &ElementData, candidate: &ElementData) -> f64 {
+ let mut score = f64::from(original.tag == candidate.tag);
+ let mut checks = 1usize;
+
+ if let Some(text) = original.text.as_deref().filter(|value| !value.is_empty()) {
+ score += string_ratio(text, candidate.text.as_deref().unwrap_or(""));
+ checks += 1;
+ }
+
+ score += map_ratio(&original.attributes, &candidate.attributes);
+ checks += 1;
+ for name in ["class", "id", "href", "src"] {
+ if let Some(value) =
+ string_attr(&original.attributes, name).filter(|value| !value.is_empty())
+ {
+ score += string_ratio(
+ value,
+ string_attr(&candidate.attributes, name).unwrap_or(""),
+ );
+ checks += 1;
+ }
+ }
+
+ score += sequence_ratio(&original.path, &candidate.path);
+ checks += 1;
+
+ if let Some(parent_name) = original
+ .parent_name
+ .as_deref()
+ .filter(|value| !value.is_empty())
+ {
+ if let Some(candidate_parent) = candidate.parent_name.as_deref() {
+ score += string_ratio(parent_name, candidate_parent);
+ checks += 1;
+ score += map_ratio(&original.parent_attribs, &candidate.parent_attribs);
+ checks += 1;
+ if let Some(parent_text) = original
+ .parent_text
+ .as_deref()
+ .filter(|value| !value.is_empty())
+ {
+ score += string_ratio(parent_text, candidate.parent_text.as_deref().unwrap_or(""));
+ checks += 1;
+ }
+ }
+ }
+
+ if !original.siblings.is_empty() {
+ score += sequence_ratio(&original.siblings, &candidate.siblings);
+ checks += 1;
+ }
+
+ round2((score / checks as f64) * 100.0)
+}
+
+fn string_attr<'a>(map: &'a Map, name: &str) -> Option<&'a str> {
+ map.get(name).and_then(serde_json::Value::as_str)
+}
+
+fn map_ratio(left: &Map, right: &Map) -> f64 {
+ let left_keys: Vec<&str> = left.keys().map(String::as_str).collect();
+ let right_keys: Vec<&str> = right.keys().map(String::as_str).collect();
+ let left_values: Vec<&str> = left
+ .values()
+ .filter_map(serde_json::Value::as_str)
+ .collect();
+ let right_values: Vec<&str> = right
+ .values()
+ .filter_map(serde_json::Value::as_str)
+ .collect();
+ sequence_ratio(&left_keys, &right_keys) * 0.5
+ + sequence_ratio(&left_values, &right_values) * 0.5
+}
+
+fn round2(value: f64) -> f64 {
+ format!("{value:.2}").parse().expect("formatted f64")
+}
+
+fn string_ratio(left: &str, right: &str) -> f64 {
+ sequence_ratio(
+ &left.chars().collect::>(),
+ &right.chars().collect::>(),
+ )
+}
+
+fn sequence_ratio(left: &[T], right: &[T]) -> f64 {
+ let total = left.len() + right.len();
+ if total == 0 {
+ return 1.0;
+ }
+ let mut positions: HashMap<&T, Vec> = HashMap::new();
+ for (index, item) in right.iter().enumerate() {
+ positions.entry(item).or_default().push(index);
+ }
+ if right.len() >= 200 {
+ let threshold = right.len() / 100 + 1;
+ positions.retain(|_, indexes| indexes.len() <= threshold);
+ }
+
+ let mut queue = vec![(0, left.len(), 0, right.len())];
+ let mut blocks = Vec::new();
+ while let Some((left_start, left_end, right_start, right_end)) = queue.pop() {
+ let (i, j, size) = longest_match(
+ left,
+ right,
+ &positions,
+ left_start,
+ left_end,
+ right_start,
+ right_end,
+ );
+ if size == 0 {
+ continue;
+ }
+ if left_start < i && right_start < j {
+ queue.push((left_start, i, right_start, j));
+ }
+ if i + size < left_end && j + size < right_end {
+ queue.push((i + size, left_end, j + size, right_end));
+ }
+ blocks.push((i, j, size));
+ }
+ blocks.sort_unstable();
+ let matches: usize = blocks.into_iter().map(|(_, _, size)| size).sum();
+ 2.0 * matches as f64 / total as f64
+}
+
+#[allow(clippy::too_many_arguments)]
+fn longest_match(
+ left: &[T],
+ right: &[T],
+ positions: &HashMap<&T, Vec>,
+ left_start: usize,
+ left_end: usize,
+ right_start: usize,
+ right_end: usize,
+) -> (usize, usize, usize) {
+ let (mut best_i, mut best_j, mut best_size) = (left_start, right_start, 0usize);
+ let mut previous = HashMap::new();
+ for (i, item) in left.iter().enumerate().take(left_end).skip(left_start) {
+ let mut current = HashMap::new();
+ if let Some(indexes) = positions.get(item) {
+ for &j in indexes {
+ if j < right_start {
+ continue;
+ }
+ if j >= right_end {
+ break;
+ }
+ let size = if j == 0 {
+ 1
+ } else {
+ previous.get(&(j - 1)).copied().unwrap_or(0) + 1
+ };
+ current.insert(j, size);
+ if size > best_size {
+ (best_i, best_j, best_size) = (i + 1 - size, j + 1 - size, size);
+ }
+ }
+ }
+ previous = current;
+ }
+ while best_i > left_start && best_j > right_start && left[best_i - 1] == right[best_j - 1] {
+ best_i -= 1;
+ best_j -= 1;
+ best_size += 1;
+ }
+ while best_i + best_size < left_end
+ && best_j + best_size < right_end
+ && left[best_i + best_size] == right[best_j + best_size]
+ {
+ best_size += 1;
+ }
+ (best_i, best_j, best_size)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct UnlimitedOnDrop;
+
+ impl Drop for UnlimitedOnDrop {
+ fn drop(&mut self) {
+ configure_quota(None);
+ }
+ }
+
+ #[test]
+ fn sequence_matcher_compares_unicode_code_points() {
+ assert_eq!(string_ratio("é", "è"), 0.0);
+ }
+
+ #[test]
+ fn sequence_matcher_does_not_extend_before_right_index_zero() {
+ assert_eq!(sequence_ratio(b"xx", b"x"), 2.0 / 3.0);
+ }
+
+ #[test]
+ fn safe_quota_rolls_back_an_oversized_save() {
+ let path = std::env::temp_dir().join(format!(
+ "browser-adaptive-quota-{}.db",
+ uuid::Uuid::new_v4().simple()
+ ));
+ configure(&path).unwrap();
+ configure_quota(Some(1));
+ let _reset = UnlimitedOnDrop;
+ let doc = dom::parse("saved identity
");
+ let error = css_query(&doc, None, "p", Some("example.com"), "p", true).unwrap_err();
+ assert!(error.contains("adaptive storage quota exceeded"), "{error}");
+
+ for suffix in ["", "-wal", "-shm"] {
+ let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
+ }
+ }
+}
diff --git a/browser/src/scrapling/browserforge.rs b/browser/src/scrapling/browserforge.rs
new file mode 100644
index 000000000..7968fa8a5
--- /dev/null
+++ b/browser/src/scrapling/browserforge.rs
@@ -0,0 +1,630 @@
+//! Frozen BrowserForge 1.2.4 header generator used by Scrapling 0.4.9.
+
+use std::collections::{HashMap, HashSet};
+use std::io::Read;
+use std::sync::{Mutex, OnceLock};
+
+use serde::Deserialize;
+use serde_json::{Map, Value};
+
+const MISSING: &str = "*MISSING_VALUE*";
+const INPUT_JSON: &str = include_str!("../../vendor/browserforge-1.2.4/input-network.json");
+const HEADER_JSON: &str = include_str!("../../vendor/browserforge-1.2.4/header-network.json");
+
+const CHROME_ORDER: &[&str] = &[
+ "Host",
+ "Connection",
+ "Content-Length",
+ "Cache-Control",
+ "sec-ch-ua",
+ "sec-ch-ua-mobile",
+ "sec-ch-ua-platform",
+ "Origin",
+ "Content-Type",
+ "Upgrade-Insecure-Requests",
+ "User-Agent",
+ "Accept",
+ "Sec-Fetch-Site",
+ "Sec-Fetch-Mode",
+ "Sec-Fetch-User",
+ "Sec-Fetch-Dest",
+ "Referer",
+ "Accept-Encoding",
+ "Accept-Language",
+ "Cookie",
+ ":method",
+ ":authority",
+ ":scheme",
+ ":path",
+ "content-length",
+ "cache-control",
+ "sec-ch-ua",
+ "sec-ch-ua-mobile",
+ "sec-ch-ua-platform",
+ "origin",
+ "content-type",
+ "upgrade-insecure-requests",
+ "user-agent",
+ "accept",
+ "sec-fetch-site",
+ "sec-fetch-mode",
+ "sec-fetch-user",
+ "sec-fetch-dest",
+ "referer",
+ "accept-encoding",
+ "accept-language",
+ "cookie",
+ "priority",
+];
+
+const FIREFOX_ORDER: &[&str] = &[
+ "Host",
+ "User-Agent",
+ "Accept",
+ "Accept-Language",
+ "Accept-Encoding",
+ "Content-Type",
+ "Content-Length",
+ "Origin",
+ "Connection",
+ "Referer",
+ "Cookie",
+ "Upgrade-Insecure-Requests",
+ "Sec-Fetch-Dest",
+ "Sec-Fetch-Mode",
+ "Sec-Fetch-Site",
+ "Sec-Fetch-User",
+ "Priority",
+ ":method",
+ ":path",
+ ":authority",
+ ":scheme",
+ "user-agent",
+ "accept",
+ "accept-language",
+ "accept-encoding",
+ "content-type",
+ "content-length",
+ "origin",
+ "referer",
+ "cookie",
+ "upgrade-insecure-requests",
+ "sec-fetch-dest",
+ "sec-fetch-mode",
+ "sec-fetch-site",
+ "sec-fetch-user",
+ "priority",
+ "te",
+];
+
+#[derive(Deserialize)]
+struct Network {
+ nodes: Vec,
+}
+
+#[derive(Deserialize)]
+struct Node {
+ name: String,
+ #[serde(rename = "parentNames")]
+ parent_names: Vec,
+ #[serde(rename = "possibleValues")]
+ possible_values: Vec,
+ #[serde(rename = "conditionalProbabilities")]
+ probabilities: Value,
+}
+
+#[derive(Clone, Copy)]
+enum Profile {
+ Http,
+ Browser,
+}
+
+static INPUT: OnceLock> = OnceLock::new();
+static HEADERS: OnceLock> = OnceLock::new();
+static RNG: OnceLock> = OnceLock::new();
+static HTTP_DEFAULT_UA: OnceLock> = OnceLock::new();
+static BROWSER_DEFAULTS: OnceLock> = OnceLock::new();
+
+fn network(
+ slot: &'static OnceLock>,
+ source: &'static str,
+) -> Result<&'static Network, String> {
+ match slot.get_or_init(|| serde_json::from_str(source).map_err(|error| error.to_string())) {
+ Ok(network) => Ok(network),
+ Err(error) => Err(error.clone()),
+ }
+}
+
+fn input_network() -> Result<&'static Network, String> {
+ network(&INPUT, INPUT_JSON)
+}
+
+fn header_network() -> Result<&'static Network, String> {
+ network(&HEADERS, HEADER_JSON)
+}
+
+fn rng() -> &'static Mutex {
+ RNG.get_or_init(|| Mutex::new(PythonRandom::from_os()))
+}
+
+fn lock_rng() -> std::sync::MutexGuard<'static, PythonRandom> {
+ rng()
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+pub fn default_user_agent() -> Result {
+ HTTP_DEFAULT_UA
+ .get_or_init(|| {
+ generate(Profile::Http, &mut lock_rng())?
+ .into_iter()
+ .find(|(name, _)| name == "User-Agent")
+ .map(|(_, value)| value)
+ .ok_or_else(|| "BrowserForge produced no User-Agent".to_string())
+ })
+ .clone()
+}
+
+pub fn generate_http_headers() -> Result, String> {
+ // Importing Scrapling's static engine initializes this cached value before
+ // it generates per-request headers. The otherwise-unused draw is visible
+ // when deterministic RNG fixtures make multiple requests.
+ default_user_agent()?;
+ generate(Profile::Http, &mut lock_rng())
+}
+
+pub fn initialize_browser_defaults() -> Result<(), String> {
+ BROWSER_DEFAULTS
+ .get_or_init(|| {
+ // `_config_tools.py` imports fingerprints (which initializes the
+ // HTTP default) and then generates Chromium and Chrome defaults.
+ default_user_agent()?;
+ let mut random = lock_rng();
+ generate(Profile::Browser, &mut random)?;
+ generate(Profile::Browser, &mut random)?;
+ Ok(())
+ })
+ .clone()
+}
+
+pub(crate) fn randint(first: u32, last: u32) -> u32 {
+ debug_assert!(first <= last);
+ first + lock_rng().randbelow(last - first + 1)
+}
+
+fn generate(profile: Profile, random: &mut PythonRandom) -> Result, String> {
+ let input = input_network()?;
+ let mut constraints = HashMap::>::new();
+ constraints.insert("*DEVICE".to_string(), vec!["desktop".to_string()]);
+ constraints.insert(
+ "*OPERATING_SYSTEM".to_string(),
+ match profile {
+ Profile::Http => vec!["windows", "macos", "linux"],
+ Profile::Browser => vec!["linux"],
+ }
+ .into_iter()
+ .map(str::to_string)
+ .collect(),
+ );
+ let browser_values = &input
+ .nodes
+ .iter()
+ .find(|node| node.name == "*BROWSER_HTTP")
+ .ok_or("BrowserForge input network has no *BROWSER_HTTP node")?
+ .possible_values;
+ let browser_http = match profile {
+ Profile::Browser => browser_values
+ .iter()
+ .filter(|value| browser_allowed(value, profile, "chrome"))
+ .cloned()
+ .collect(),
+ Profile::Http => ["chrome", "firefox", "edge"]
+ .into_iter()
+ .flat_map(|name| {
+ browser_values
+ .iter()
+ .filter(move |value| browser_allowed(value, profile, name))
+ .cloned()
+ })
+ .collect(),
+ };
+ constraints.insert("*BROWSER_HTTP".to_string(), browser_http);
+
+ let mut sample = Map::new();
+ if !consistent_sample(input, &constraints, &mut sample, 0, random) {
+ return Err("No headers based on this input can be generated. Please relax or change some of the requirements you specified.".to_string());
+ }
+ generate_unrestricted(header_network()?, &mut sample, random)?;
+
+ let browser_http = sample
+ .get("*BROWSER_HTTP")
+ .and_then(Value::as_str)
+ .ok_or("BrowserForge sample has no *BROWSER_HTTP")?;
+ let add_sec_fetch = browser_http
+ .split_once('|')
+ .and_then(|(browser, _)| browser.split_once('/').map(|(name, _)| name))
+ .map(|name| matches!(name, "chrome" | "firefox" | "edge"))
+ .ok_or("BrowserForge produced an invalid *BROWSER_HTTP")?;
+ sample.insert(
+ "accept-language".to_string(),
+ Value::String("en-US;q=1.0".to_string()),
+ );
+ if add_sec_fetch {
+ for (name, value) in [
+ ("sec-fetch-mode", "same-site"),
+ ("sec-fetch-dest", "navigate"),
+ ("sec-fetch-site", "?1"),
+ ("sec-fetch-user", "document"),
+ ] {
+ sample.insert(name.to_string(), Value::String(value.to_string()));
+ }
+ }
+
+ let visible = sample
+ .into_iter()
+ .filter_map(|(name, value)| {
+ let value = value.as_str()?.to_string();
+ (!(name.eq_ignore_ascii_case("connection") && value == "close")
+ && !name.starts_with('*')
+ && value != MISSING)
+ .then_some((name, value))
+ })
+ .collect::>();
+ let user_agent = visible
+ .get("User-Agent")
+ .or_else(|| visible.get("user-agent"))
+ .map(String::as_str)
+ .ok_or("Failed to find User-Agent in generated response")?;
+ // BrowserForge checks Chrome before Edge. Their order tables are equal for
+ // the fields this frozen dataset emits, so preserve that quirk directly.
+ let order = if user_agent.contains("Firefox") || user_agent.contains("FxiOS") {
+ FIREFOX_ORDER
+ } else if user_agent.contains("Chrome") || user_agent.contains("CriOS") {
+ CHROME_ORDER
+ } else {
+ return Err("Failed to find browser in User-Agent".to_string());
+ };
+ let mut seen = HashSet::new();
+ Ok(order
+ .iter()
+ .filter(|name| seen.insert(**name))
+ .filter_map(|name| {
+ visible
+ .get(*name)
+ .map(|value| (pascalize(name), value.clone()))
+ })
+ .collect())
+}
+
+fn browser_allowed(value: &str, profile: Profile, wanted: &str) -> bool {
+ let Some((browser, http)) = value.split_once('|') else {
+ return false;
+ };
+ if http != "2" {
+ return false;
+ }
+ let Some((name, version)) = browser.split_once('/') else {
+ return false;
+ };
+ let major = version
+ .split('.')
+ .next()
+ .and_then(|value| value.parse::().ok());
+ if name != wanted {
+ return false;
+ }
+ match (profile, name, major) {
+ (Profile::Browser, "chrome", Some(148)) => true,
+ (Profile::Http, "chrome", Some(148)) => true,
+ (Profile::Http, "firefox", Some(version)) => version >= 142,
+ (Profile::Http, "edge", Some(version)) => version >= 140,
+ _ => false,
+ }
+}
+
+fn consistent_sample(
+ network: &Network,
+ constraints: &HashMap>,
+ sample: &mut Map,
+ depth: usize,
+ random: &mut PythonRandom,
+) -> bool {
+ if depth == network.nodes.len() {
+ return true;
+ }
+ let node = &network.nodes[depth];
+ let possibilities = constraints
+ .get(&node.name)
+ .map(Vec::as_slice)
+ .unwrap_or(&node.possible_values);
+ let mut banned = HashSet::new();
+ loop {
+ let Some(value) = sample_restricted(node, sample, possibilities, &banned, random) else {
+ return false;
+ };
+ sample.insert(node.name.clone(), Value::String(value.clone()));
+ if consistent_sample(network, constraints, sample, depth + 1, random) {
+ return true;
+ }
+ banned.insert(value);
+ sample.shift_remove(&node.name);
+ }
+}
+
+fn generate_unrestricted(
+ network: &Network,
+ sample: &mut Map,
+ random: &mut PythonRandom,
+) -> Result<(), String> {
+ for node in &network.nodes {
+ if sample.contains_key(&node.name) {
+ continue;
+ }
+ let probabilities = probability_table(node, sample)
+ .ok_or_else(|| format!("BrowserForge has no probabilities for {}", node.name))?;
+ let choices = probabilities.keys().map(String::as_str).collect::>();
+ let value = sample_value(&choices, probabilities, random)
+ .ok_or_else(|| format!("BrowserForge has no value for {}", node.name))?;
+ sample.insert(node.name.clone(), Value::String(value));
+ }
+ Ok(())
+}
+
+fn sample_restricted(
+ node: &Node,
+ sample: &Map,
+ possibilities: &[String],
+ banned: &HashSet,
+ random: &mut PythonRandom,
+) -> Option {
+ let probabilities = probability_table(node, sample)?;
+ let choices = possibilities
+ .iter()
+ .map(String::as_str)
+ .filter(|value| !banned.contains(*value) && probabilities.contains_key(*value))
+ .collect::>();
+ sample_value(&choices, probabilities, random)
+}
+
+fn probability_table<'a>(
+ node: &'a Node,
+ sample: &Map,
+) -> Option<&'a Map> {
+ let mut current = &node.probabilities;
+ for parent in &node.parent_names {
+ let table = current.as_object()?;
+ let parent = sample.get(parent)?.as_str()?;
+ current = table
+ .get("deeper")
+ .and_then(Value::as_object)
+ .and_then(|deeper| deeper.get(parent))
+ .or_else(|| table.get("skip"))?;
+ }
+ current.as_object()
+}
+
+fn sample_value(
+ choices: &[&str],
+ probabilities: &Map,
+ random: &mut PythonRandom,
+) -> Option {
+ let first = choices.first()?;
+ let anchor = random.random();
+ let mut cumulative = 0.0;
+ for choice in choices {
+ cumulative += probabilities.get(*choice)?.as_f64()?;
+ if cumulative > anchor {
+ return Some((*choice).to_string());
+ }
+ }
+ Some((*first).to_string())
+}
+
+fn pascalize(name: &str) -> String {
+ if name.starts_with(':') || name.starts_with("sec-ch-ua") {
+ return name.to_string();
+ }
+ if matches!(name, "dnt" | "rtt" | "ect") {
+ return name.to_ascii_uppercase();
+ }
+ name.split('-')
+ .map(|part| {
+ let mut chars = part.chars();
+ chars
+ .next()
+ .map(|first| {
+ first
+ .to_uppercase()
+ .chain(chars.flat_map(char::to_lowercase))
+ .collect::()
+ })
+ .unwrap_or_default()
+ })
+ .collect::>()
+ .join("-")
+}
+
+struct PythonRandom {
+ state: [u32; 624],
+ index: usize,
+}
+
+impl PythonRandom {
+ fn from_os() -> Self {
+ let mut bytes = [0u8; 624 * 4];
+ if std::fs::File::open("/dev/urandom")
+ .and_then(|mut file| file.read_exact(&mut bytes))
+ .is_ok()
+ {
+ let mut key = [0u32; 624];
+ for (word, bytes) in key.iter_mut().zip(bytes.chunks_exact(4)) {
+ *word = u32::from_ne_bytes(bytes.try_into().expect("four-byte chunk"));
+ }
+ return Self::from_key(&key);
+ }
+ let fallback = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos() as u32;
+ Self::from_key(&[fallback, std::process::id()])
+ }
+
+ #[cfg(test)]
+ fn seeded(seed: u32) -> Self {
+ Self::from_key(&[seed])
+ }
+
+ fn from_key(key: &[u32]) -> Self {
+ let mut random = Self {
+ state: [0; 624],
+ index: 624,
+ };
+ random.init_genrand(19_650_218);
+ let mut i = 1usize;
+ let mut j = 0usize;
+ for _ in 0..624.max(key.len()) {
+ let previous = random.state[i - 1];
+ random.state[i] = (random.state[i]
+ ^ (previous ^ (previous >> 30)).wrapping_mul(1_664_525))
+ .wrapping_add(key[j])
+ .wrapping_add(j as u32);
+ i += 1;
+ j += 1;
+ if i >= 624 {
+ random.state[0] = random.state[623];
+ i = 1;
+ }
+ if j >= key.len() {
+ j = 0;
+ }
+ }
+ for _ in 0..623 {
+ let previous = random.state[i - 1];
+ random.state[i] = (random.state[i]
+ ^ (previous ^ (previous >> 30)).wrapping_mul(1_566_083_941))
+ .wrapping_sub(i as u32);
+ i += 1;
+ if i >= 624 {
+ random.state[0] = random.state[623];
+ i = 1;
+ }
+ }
+ random.state[0] = 0x8000_0000;
+ random.index = 624;
+ random
+ }
+
+ fn init_genrand(&mut self, seed: u32) {
+ self.state[0] = seed;
+ for index in 1..624 {
+ let previous = self.state[index - 1];
+ self.state[index] = 1_812_433_253u32
+ .wrapping_mul(previous ^ (previous >> 30))
+ .wrapping_add(index as u32);
+ }
+ }
+
+ fn word(&mut self) -> u32 {
+ if self.index >= 624 {
+ for index in 0..624 {
+ let y = (self.state[index] & 0x8000_0000)
+ | (self.state[(index + 1) % 624] & 0x7fff_ffff);
+ self.state[index] = self.state[(index + 397) % 624]
+ ^ (y >> 1)
+ ^ if y & 1 == 0 { 0 } else { 0x9908_b0df };
+ }
+ self.index = 0;
+ }
+ let mut value = self.state[self.index];
+ self.index += 1;
+ value ^= value >> 11;
+ value ^= (value << 7) & 0x9d2c_5680;
+ value ^= (value << 15) & 0xefc6_0000;
+ value ^= value >> 18;
+ value
+ }
+
+ fn random(&mut self) -> f64 {
+ let high = (self.word() >> 5) as u64;
+ let low = (self.word() >> 6) as u64;
+ ((high << 26) | low) as f64 / 9_007_199_254_740_992.0
+ }
+
+ fn getrandbits(&mut self, bits: u32) -> u32 {
+ debug_assert!((1..=32).contains(&bits));
+ self.word() >> (32 - bits)
+ }
+
+ fn randbelow(&mut self, upper: u32) -> u32 {
+ debug_assert!(upper > 0);
+ let bits = 32 - upper.leading_zeros();
+ loop {
+ let value = self.getrandbits(bits);
+ if value < upper {
+ return value;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cpython_seed_zero_and_frozen_header_sample_match() {
+ let mut random = PythonRandom::seeded(0);
+ assert_eq!(random.random(), 0.8444218515250481);
+ let headers = generate(Profile::Http, &mut PythonRandom::seeded(0)).unwrap();
+ assert_eq!(
+ headers,
+ vec![
+ ("sec-ch-ua".into(), "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"".into()),
+ ("sec-ch-ua-mobile".into(), "?0".into()),
+ ("sec-ch-ua-platform".into(), "\"macOS\"".into()),
+ ("Upgrade-Insecure-Requests".into(), "1".into()),
+ ("User-Agent".into(), "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into()),
+ ("Accept".into(), "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7".into()),
+ ("Sec-Fetch-Site".into(), "?1".into()),
+ ("Sec-Fetch-Mode".into(), "same-site".into()),
+ ("Sec-Fetch-User".into(), "document".into()),
+ ("Sec-Fetch-Dest".into(), "navigate".into()),
+ ("Accept-Encoding".into(), "gzip, deflate, br, zstd".into()),
+ ("Accept-Language".into(), "en-US;q=1.0".into()),
+ ]
+ );
+ }
+
+ #[test]
+ fn cpython_randint_click_sequence_matches() {
+ let mut random = PythonRandom::seeded(0);
+ assert_eq!(26 + random.randbelow(3), 27);
+ assert_eq!(25 + random.randbelow(3), 26);
+ assert_eq!(100 + random.randbelow(101), 105);
+ }
+
+ #[test]
+ fn browser_profile_matches_frozen_linux_chromium() {
+ let headers = generate(Profile::Browser, &mut PythonRandom::seeded(0)).unwrap();
+ assert_eq!(
+ headers
+ .iter()
+ .find(|(name, _)| name == "User-Agent")
+ .map(|(_, value)| value.as_str()),
+ Some("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
+ );
+ }
+
+ #[test]
+ fn ten_thousand_seed_header_corpus_matches_browserforge() {
+ let mut hash = 14_695_981_039_346_656_037u64;
+ for seed in 0..10_000 {
+ for (name, value) in generate(Profile::Http, &mut PythonRandom::seeded(seed)).unwrap() {
+ for byte in name.bytes().chain([0]).chain(value.bytes()).chain([255]) {
+ hash = (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211);
+ }
+ }
+ }
+ assert_eq!(hash, 0xa96f_eabb_879e_a69c);
+ }
+}
diff --git a/browser/src/scrapling/cdp.rs b/browser/src/scrapling/cdp.rs
new file mode 100644
index 000000000..76ea832b1
--- /dev/null
+++ b/browser/src/scrapling/cdp.rs
@@ -0,0 +1,842 @@
+//! Private raw Chrome DevTools Protocol connection foundation.
+//!
+//! Chromium's `--remote-debugging-pipe` protocol is UTF-8 JSON terminated by
+//! a NUL byte. The browser reads commands from descriptor 3 and writes events
+//! and responses to descriptor 4. Launch code owns creating/inheriting those
+//! descriptors; this module owns framing, routing, cancellation and teardown.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::future::Future;
+use std::io::{Read, Write};
+use std::pin::Pin;
+use std::process::{Child, Command, ExitStatus};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard, Weak};
+use std::task::{Context, Poll};
+use std::thread::{self, JoinHandle};
+
+use futures::{SinkExt, StreamExt};
+use serde_json::{Map, Value};
+use tokio::sync::{broadcast, mpsc, oneshot, watch};
+use tokio_tungstenite::tungstenite::Message;
+
+pub const REMOTE_DEBUGGING_PIPE_ARG: &str = "--remote-debugging-pipe";
+pub const MAX_CDP_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum CdpError {
+ Closed,
+ Transport(String),
+ InvalidMessage(String),
+ Protocol {
+ code: i64,
+ message: String,
+ data: Option,
+ },
+ UnsupportedTransport(String),
+}
+
+impl fmt::Display for CdpError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Closed => f.write_str("CDP connection is closed"),
+ Self::Transport(message) | Self::InvalidMessage(message) => f.write_str(message),
+ Self::Protocol { code, message, .. } => write!(f, "CDP error {code}: {message}"),
+ Self::UnsupportedTransport(message) => f.write_str(message),
+ }
+ }
+}
+
+impl std::error::Error for CdpError {}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct CdpEvent {
+ pub session_id: Option,
+ pub method: String,
+ pub params: Value,
+}
+
+#[derive(Debug)]
+pub enum EventError {
+ Closed,
+}
+
+impl fmt::Display for EventError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Closed => f.write_str("CDP event stream is closed"),
+ }
+ }
+}
+
+impl std::error::Error for EventError {}
+
+pub struct EventReceiver {
+ receiver: broadcast::Receiver,
+ disconnected: watch::Receiver,
+ session_id: Option,
+ /// Deliver events from EVERY session, not just `session_id`. Needed by
+ /// the child-target router: auto-attached OOPIF/worker targets announce
+ /// themselves tagged with their PARENT page's sessionId, which a
+ /// root-scoped receiver would filter out.
+ any_session: bool,
+}
+
+impl EventReceiver {
+ pub async fn recv(&mut self) -> Result {
+ loop {
+ if *self.disconnected.borrow() {
+ return Err(EventError::Closed);
+ }
+ tokio::select! {
+ changed = self.disconnected.changed() => {
+ if changed.is_err() || *self.disconnected.borrow() {
+ return Err(EventError::Closed);
+ }
+ }
+ event = self.receiver.recv() => match event {
+ Ok(event) if self.any_session || event.session_id == self.session_id => {
+ return Ok(event);
+ }
+ Ok(_) => {}
+ Err(broadcast::error::RecvError::Closed) => return Err(EventError::Closed),
+ // Lagged is recoverable: the receiver skipped old events
+ // and can keep going. Surfacing it as an error made every
+ // consumer treat a busy page as a dead connection (the
+ // child-target router died forever; waits hard-failed
+ // pages that loaded fine). A waiter that missed its event
+ // falls back to its own deadline instead.
+ Err(broadcast::error::RecvError::Lagged(_)) => {}
+ }
+ }
+ }
+ }
+}
+
+struct Pending {
+ session_id: Option,
+ sender: oneshot::Sender>,
+}
+
+enum WriterCommand {
+ Frame(Vec),
+ Shutdown,
+}
+
+#[derive(Default)]
+struct ProcessState {
+ child: Option,
+ status: Option,
+}
+
+struct Inner {
+ next_id: AtomicU64,
+ closed: AtomicBool,
+ pending: Mutex>,
+ events: broadcast::Sender,
+ disconnected: watch::Sender,
+ writer_tx: mpsc::UnboundedSender,
+ writer_thread: Mutex>>,
+ reader_thread: Mutex >>,
+ websocket_task: Mutex >>,
+ process: Mutex,
+}
+
+impl Inner {
+ fn terminate(&self, reason: CdpError) {
+ if self.closed.swap(true, Ordering::AcqRel) {
+ return;
+ }
+ let callbacks = std::mem::take(&mut *lock(&self.pending));
+ for (_, pending) in callbacks {
+ let _ = pending.sender.send(Err(reason.clone()));
+ }
+ let _ = self.writer_tx.send(WriterCommand::Shutdown);
+ let _ = self.disconnected.send(true);
+ self.terminate_process();
+ }
+
+ fn terminate_process(&self) {
+ let mut process = lock(&self.process);
+ let Some(mut child) = process.child.take() else {
+ return;
+ };
+ let status = match child.try_wait() {
+ Ok(Some(status)) => Some(status),
+ Ok(None) => child.kill().and_then(|()| child.wait()).ok(),
+ Err(_) => child.kill().and_then(|()| child.wait()).ok(),
+ };
+ process.status = status;
+ }
+
+ fn route(&self, message: Value) -> Result<(), CdpError> {
+ let object = message
+ .as_object()
+ .ok_or_else(|| CdpError::InvalidMessage("CDP message is not an object".to_string()))?;
+ let session_id = object
+ .get("sessionId")
+ .and_then(Value::as_str)
+ .map(str::to_string);
+
+ if object.get("id").and_then(Value::as_i64) == Some(-9999) {
+ return Ok(());
+ }
+ if object.contains_key("id") {
+ let id = object.get("id").and_then(Value::as_u64).ok_or_else(|| {
+ CdpError::InvalidMessage("CDP response has an invalid id".to_string())
+ })?;
+ let mut pending = lock(&self.pending);
+ // Command ids are client-global, so the id alone identifies the
+ // command. Chrome emits some id-bearing ERROR frames untagged
+ // (e.g. "Session with given id not found") — dropping those on a
+ // sessionId mismatch orphans the await forever. Only a frame
+ // tagged with a DIFFERENT session is rejected.
+ let matches_session = pending
+ .get(&id)
+ .is_some_and(|callback| session_id.is_none() || callback.session_id == session_id);
+ if !matches_session {
+ return Ok(());
+ }
+ let callback = pending.remove(&id).expect("checked pending command");
+ let result = if let Some(error) = object.get("error").and_then(Value::as_object) {
+ Err(CdpError::Protocol {
+ code: error.get("code").and_then(Value::as_i64).unwrap_or(0),
+ message: error
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or("Unknown protocol error")
+ .to_string(),
+ data: error.get("data").cloned(),
+ })
+ } else {
+ Ok(object.get("result").cloned().unwrap_or(Value::Null))
+ };
+ let _ = callback.sender.send(result);
+ return Ok(());
+ }
+
+ let method = object
+ .get("method")
+ .and_then(Value::as_str)
+ .ok_or_else(|| CdpError::InvalidMessage("CDP event has no method".to_string()))?;
+ let _ = self.events.send(CdpEvent {
+ session_id,
+ method: method.to_string(),
+ params: object.get("params").cloned().unwrap_or(Value::Null),
+ });
+ Ok(())
+ }
+}
+
+impl Drop for Inner {
+ fn drop(&mut self) {
+ self.closed.store(true, Ordering::Release);
+ let _ = self.writer_tx.send(WriterCommand::Shutdown);
+ let _ = self.disconnected.send(true);
+ if let Some(task) = lock(&self.websocket_task).take() {
+ task.abort();
+ }
+ self.terminate_process();
+ }
+}
+
+#[derive(Clone)]
+pub struct CdpClient {
+ inner: Arc,
+}
+
+impl fmt::Debug for CdpClient {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("CdpClient")
+ .field("closed", &self.is_closed())
+ .finish_non_exhaustive()
+ }
+}
+
+impl CdpClient {
+ pub fn from_pipe(reader: R, writer: W, child: Option) -> Result
+ where
+ R: Read + Send + 'static,
+ W: Write + Send + 'static,
+ {
+ let (inner, writer_rx) = new_inner(child);
+
+ let writer_inner = Arc::downgrade(&inner);
+ let writer_thread = thread::Builder::new()
+ .name("scrapling-cdp-writer".to_string())
+ .spawn(move || writer_loop(writer, writer_rx, writer_inner))
+ .map_err(|error| CdpError::Transport(error.to_string()))?;
+ *lock(&inner.writer_thread) = Some(writer_thread);
+
+ let reader_inner = Arc::downgrade(&inner);
+ let reader_thread = match thread::Builder::new()
+ .name("scrapling-cdp-reader".to_string())
+ .spawn(move || reader_loop(reader, reader_inner))
+ {
+ Ok(thread) => thread,
+ Err(error) => {
+ inner.terminate(CdpError::Transport(error.to_string()));
+ if let Some(thread) = lock(&inner.writer_thread).take() {
+ let _ = thread.join();
+ }
+ return Err(CdpError::Transport(error.to_string()));
+ }
+ };
+ *lock(&inner.reader_thread) = Some(reader_thread);
+ Ok(Self { inner })
+ }
+
+ #[cfg(unix)]
+ /// Spawn one Chrome process with CDP mapped to fd3/fd4.
+ ///
+ /// `CommandExt::pre_exec` has no removal API, so `command` is single-use
+ /// after this call, including when spawning fails.
+ pub fn launch_pipe(command: &mut Command) -> Result {
+ use std::fs::File;
+ use std::os::fd::AsRawFd;
+ use std::os::unix::process::CommandExt;
+
+ let (child_commands, parent_commands) = pipe_cloexec()?;
+ let (parent_events, child_events) = pipe_cloexec()?;
+ let child_commands = dup_cloexec(child_commands.as_raw_fd())?;
+ let child_events = dup_cloexec(child_events.as_raw_fd())?;
+ let command_fd = child_commands.as_raw_fd();
+ let event_fd = child_events.as_raw_fd();
+
+ if !command
+ .get_args()
+ .any(|argument| argument == REMOTE_DEBUGGING_PIPE_ARG)
+ {
+ command.arg(REMOTE_DEBUGGING_PIPE_ARG);
+ }
+ // SAFETY: the closure calls only async-signal-safe libc functions and
+ // captures raw integers. Both sources are duplicated above fd 4, so
+ // mapping one cannot clobber the other.
+ unsafe {
+ command.pre_exec(move || {
+ if libc::dup2(command_fd, 3) == -1 || libc::dup2(event_fd, 4) == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+
+ let child = command
+ .spawn()
+ .map_err(|error| CdpError::Transport(format!("launching Chrome: {error}")))?;
+ drop(child_commands);
+ drop(child_events);
+ let reader = File::from(parent_events);
+ let writer = File::from(parent_commands);
+ Self::from_pipe(reader, writer, Some(child))
+ }
+
+ #[cfg(not(unix))]
+ pub fn launch_pipe(_command: &mut Command) -> Result {
+ Err(CdpError::UnsupportedTransport(
+ "--remote-debugging-pipe launch is supported only on Unix".to_string(),
+ ))
+ }
+
+ pub async fn connect_websocket_url(url: &str) -> Result {
+ let scheme = url.split(':').next().unwrap_or_default();
+ if !matches!(scheme, "ws" | "wss") {
+ return Err(CdpError::UnsupportedTransport(format!(
+ "cdp_url must use ws:// or wss://, got '{url}'"
+ )));
+ }
+ let (socket, _) = tokio_tungstenite::connect_async(url)
+ .await
+ .map_err(|error| CdpError::Transport(format!("connecting cdp_url '{url}': {error}")))?;
+ let (inner, writer_rx) = new_inner(None);
+ let task_inner = Arc::downgrade(&inner);
+ let task = tokio::spawn(websocket_loop(socket, writer_rx, task_inner));
+ *lock(&inner.websocket_task) = Some(task);
+ Ok(Self { inner })
+ }
+
+ pub fn session(&self, session_id: impl Into) -> CdpSession {
+ CdpSession {
+ client: self.clone(),
+ session_id: session_id.into(),
+ }
+ }
+
+ pub fn send(&self, method: &str, params: Value) -> Result {
+ self.send_to(None, method, params)
+ }
+
+ fn send_to(
+ &self,
+ session_id: Option,
+ method: &str,
+ params: Value,
+ ) -> Result {
+ if self.is_closed() {
+ return Err(CdpError::Closed);
+ }
+ if method.is_empty() {
+ return Err(CdpError::InvalidMessage(
+ "CDP method cannot be empty".to_string(),
+ ));
+ }
+ let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed) + 1;
+ let mut message = Map::new();
+ message.insert("id".to_string(), Value::from(id));
+ message.insert("method".to_string(), Value::from(method));
+ message.insert("params".to_string(), params);
+ if let Some(value) = &session_id {
+ message.insert("sessionId".to_string(), Value::from(value.clone()));
+ }
+ let mut frame = serde_json::to_vec(&Value::Object(message))
+ .map_err(|error| CdpError::InvalidMessage(error.to_string()))?;
+ if frame.len() > MAX_CDP_MESSAGE_BYTES {
+ return Err(CdpError::InvalidMessage(format!(
+ "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes"
+ )));
+ }
+ frame.push(0);
+
+ let (sender, receiver) = oneshot::channel();
+ lock(&self.inner.pending).insert(id, Pending { session_id, sender });
+ if self
+ .inner
+ .writer_tx
+ .send(WriterCommand::Frame(frame))
+ .is_err()
+ {
+ lock(&self.inner.pending).remove(&id);
+ self.inner.terminate(CdpError::Closed);
+ return Err(CdpError::Closed);
+ }
+ // Close the TOCTOU with terminate(): it may have drained `pending`
+ // between the is_closed check above and the insert, leaving this
+ // entry to never complete. Re-check after the insert — whichever side
+ // runs second cleans up.
+ if self.is_closed() {
+ lock(&self.inner.pending).remove(&id);
+ return Err(CdpError::Closed);
+ }
+ Ok(CdpCommand {
+ id,
+ receiver,
+ inner: Arc::downgrade(&self.inner),
+ completed: false,
+ deadline: None,
+ })
+ }
+
+ pub fn subscribe(&self) -> EventReceiver {
+ EventReceiver {
+ receiver: self.inner.events.subscribe(),
+ disconnected: self.inner.disconnected.subscribe(),
+ session_id: None,
+ any_session: false,
+ }
+ }
+
+ /// Subscribe to events from every session (see `EventReceiver::any_session`).
+ pub fn subscribe_any(&self) -> EventReceiver {
+ EventReceiver {
+ receiver: self.inner.events.subscribe(),
+ disconnected: self.inner.disconnected.subscribe(),
+ session_id: None,
+ any_session: true,
+ }
+ }
+
+ pub fn is_closed(&self) -> bool {
+ self.inner.closed.load(Ordering::Acquire)
+ }
+
+ pub fn close(&self) -> Result<(), CdpError> {
+ self.inner.terminate(CdpError::Closed);
+ join_thread(&self.inner.writer_thread)?;
+ join_thread(&self.inner.reader_thread)?;
+ Ok(())
+ }
+
+ pub fn process_status(&self) -> Option {
+ lock(&self.inner.process).status
+ }
+
+ #[cfg(test)]
+ pub fn pending_count(&self) -> usize {
+ lock(&self.inner.pending).len()
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct CdpSession {
+ client: CdpClient,
+ session_id: String,
+}
+
+impl CdpSession {
+ pub fn id(&self) -> &str {
+ &self.session_id
+ }
+
+ pub fn send(&self, method: &str, params: Value) -> Result {
+ self.client
+ .send_to(Some(self.session_id.clone()), method, params)
+ }
+
+ pub fn subscribe(&self) -> EventReceiver {
+ EventReceiver {
+ receiver: self.client.inner.events.subscribe(),
+ disconnected: self.client.inner.disconnected.subscribe(),
+ session_id: Some(self.session_id.clone()),
+ any_session: false,
+ }
+ }
+}
+
+/// Hard ceiling on any single CDP command round-trip. A blocked renderer
+/// main thread (`while(1){}` with the hang monitor disabled, a wedged
+/// browser process) simply never answers, and an unbounded await here is
+/// how one-shot fetches hang and session actors wedge forever. Generous on
+/// purpose: real commands answer in milliseconds and even a tarpit
+/// navigation is bounded by Chrome's own ~5-minute network timeout region.
+const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
+
+pub struct CdpCommand {
+ id: u64,
+ receiver: oneshot::Receiver>,
+ inner: Weak,
+ completed: bool,
+ deadline: Option>>,
+}
+
+impl Future for CdpCommand {
+ type Output = Result;
+
+ fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll {
+ match Pin::new(&mut self.receiver).poll(context) {
+ Poll::Ready(Ok(result)) => {
+ self.completed = true;
+ Poll::Ready(result)
+ }
+ Poll::Ready(Err(_)) => {
+ self.completed = true;
+ Poll::Ready(Err(CdpError::Closed))
+ }
+ Poll::Pending => {
+ let deadline = self
+ .deadline
+ .get_or_insert_with(|| Box::pin(tokio::time::sleep(COMMAND_TIMEOUT)));
+ match deadline.as_mut().poll(context) {
+ Poll::Ready(()) => {
+ self.completed = true;
+ if let Some(inner) = self.inner.upgrade() {
+ lock(&inner.pending).remove(&self.id);
+ }
+ Poll::Ready(Err(CdpError::Transport(format!(
+ "CDP command timed out after {}s (renderer or browser unresponsive)",
+ COMMAND_TIMEOUT.as_secs()
+ ))))
+ }
+ Poll::Pending => Poll::Pending,
+ }
+ }
+ }
+ }
+}
+
+impl Drop for CdpCommand {
+ fn drop(&mut self) {
+ if self.completed {
+ return;
+ }
+ if let Some(inner) = self.inner.upgrade() {
+ lock(&inner.pending).remove(&self.id);
+ }
+ }
+}
+
+fn new_inner(child: Option) -> (Arc, mpsc::UnboundedReceiver) {
+ let (writer_tx, writer_rx) = mpsc::unbounded_channel();
+ // Sized for bursty pages: every session's events share this channel, and
+ // an overflow only costs the laggard skipped events (recv treats Lagged
+ // as recoverable), but skipping is still worth avoiding.
+ let (events, _) = broadcast::channel(4096);
+ let (disconnected, _) = watch::channel(false);
+ let inner = Arc::new(Inner {
+ next_id: AtomicU64::new(0),
+ closed: AtomicBool::new(false),
+ pending: Mutex::new(HashMap::new()),
+ events,
+ disconnected,
+ writer_tx,
+ writer_thread: Mutex::new(None),
+ reader_thread: Mutex::new(None),
+ websocket_task: Mutex::new(None),
+ process: Mutex::new(ProcessState {
+ child,
+ status: None,
+ }),
+ });
+ (inner, writer_rx)
+}
+
+fn writer_loop(
+ mut writer: W,
+ mut commands: mpsc::UnboundedReceiver,
+ inner: Weak,
+) {
+ while let Some(command) = commands.blocking_recv() {
+ match command {
+ WriterCommand::Frame(frame) => {
+ if let Err(error) = writer.write_all(&frame).and_then(|()| writer.flush()) {
+ if let Some(inner) = inner.upgrade() {
+ inner.terminate(CdpError::Transport(format!("writing CDP pipe: {error}")));
+ }
+ break;
+ }
+ }
+ WriterCommand::Shutdown => break,
+ }
+ }
+}
+
+async fn websocket_loop(
+ socket: tokio_tungstenite::WebSocketStream,
+ mut commands: mpsc::UnboundedReceiver,
+ inner: Weak,
+) where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+ let (mut writer, mut reader) = socket.split();
+ loop {
+ tokio::select! {
+ command = commands.recv() => match command {
+ Some(WriterCommand::Frame(mut frame)) => {
+ if frame.last() == Some(&0) {
+ frame.pop();
+ }
+ let text = match String::from_utf8(frame) {
+ Ok(text) => text,
+ Err(error) => {
+ terminate_weak(&inner, CdpError::InvalidMessage(error.to_string()));
+ break;
+ }
+ };
+ if let Err(error) = writer.send(Message::Text(text.into())).await {
+ terminate_weak(&inner, CdpError::Transport(format!("writing cdp_url: {error}")));
+ break;
+ }
+ }
+ Some(WriterCommand::Shutdown) | None => {
+ let _ = writer.send(Message::Close(None)).await;
+ let _ = writer.close().await;
+ break;
+ }
+ },
+ incoming = reader.next() => match incoming {
+ Some(Ok(Message::Text(text))) => {
+ if !route_websocket_message(&inner, text.as_bytes()) {
+ break;
+ }
+ }
+ Some(Ok(Message::Binary(bytes))) => {
+ if !route_websocket_message(&inner, &bytes) {
+ break;
+ }
+ }
+ Some(Ok(Message::Ping(bytes))) => {
+ if let Err(error) = writer.send(Message::Pong(bytes)).await {
+ terminate_weak(&inner, CdpError::Transport(format!("writing cdp_url pong: {error}")));
+ break;
+ }
+ }
+ Some(Ok(Message::Pong(_))) => {}
+ Some(Ok(Message::Close(_))) | None => {
+ terminate_weak(&inner, CdpError::Closed);
+ break;
+ }
+ Some(Ok(Message::Frame(_))) => {}
+ Some(Err(error)) => {
+ terminate_weak(&inner, CdpError::Transport(format!("reading cdp_url: {error}")));
+ break;
+ }
+ }
+ }
+ }
+}
+
+fn route_websocket_message(inner: &Weak, bytes: &[u8]) -> bool {
+ let message = match serde_json::from_slice(bytes) {
+ Ok(message) => message,
+ Err(error) => {
+ terminate_weak(
+ inner,
+ CdpError::InvalidMessage(format!("invalid CDP JSON: {error}")),
+ );
+ return false;
+ }
+ };
+ let Some(inner) = inner.upgrade() else {
+ return false;
+ };
+ match inner.route(message) {
+ Ok(()) => true,
+ Err(error) => {
+ inner.terminate(error);
+ false
+ }
+ }
+}
+
+fn terminate_weak(inner: &Weak, error: CdpError) {
+ if let Some(inner) = inner.upgrade() {
+ inner.terminate(error);
+ }
+}
+
+fn reader_loop(reader: R, inner: Weak) {
+ let mut frames = NulFrames::new(reader);
+ loop {
+ let result = match frames.next() {
+ Ok(Some(frame)) => serde_json::from_slice(&frame)
+ .map_err(|error| CdpError::InvalidMessage(format!("invalid CDP JSON: {error}"))),
+ Ok(None) => {
+ if let Some(inner) = inner.upgrade() {
+ inner.terminate(CdpError::Closed);
+ }
+ return;
+ }
+ Err(error) => Err(error),
+ };
+ let Some(inner) = inner.upgrade() else {
+ return;
+ };
+ match result.and_then(|message| inner.route(message)) {
+ Ok(()) => {}
+ Err(error) => {
+ inner.terminate(error);
+ return;
+ }
+ }
+ }
+}
+
+struct NulFrames {
+ reader: R,
+ pending: Vec,
+}
+
+impl NulFrames {
+ fn new(reader: R) -> Self {
+ Self {
+ reader,
+ pending: Vec::new(),
+ }
+ }
+
+ fn next(&mut self) -> Result>, CdpError> {
+ loop {
+ if let Some(end) = self.pending.iter().position(|byte| *byte == 0) {
+ if end > MAX_CDP_MESSAGE_BYTES {
+ return Err(CdpError::InvalidMessage(format!(
+ "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes"
+ )));
+ }
+ let mut remainder = self.pending.split_off(end + 1);
+ std::mem::swap(&mut remainder, &mut self.pending);
+ remainder.pop();
+ return Ok(Some(remainder));
+ }
+ if self.pending.len() > MAX_CDP_MESSAGE_BYTES {
+ return Err(CdpError::InvalidMessage(format!(
+ "CDP message exceeds {MAX_CDP_MESSAGE_BYTES} bytes"
+ )));
+ }
+ let mut buffer = [0; 8192];
+ match self.reader.read(&mut buffer) {
+ Ok(0) if self.pending.is_empty() => return Ok(None),
+ Ok(0) => {
+ return Err(CdpError::InvalidMessage(
+ "CDP pipe closed during a frame".to_string(),
+ ));
+ }
+ Ok(count) => self.pending.extend_from_slice(&buffer[..count]),
+ Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+ Err(error) => {
+ return Err(CdpError::Transport(format!("reading CDP pipe: {error}")));
+ }
+ }
+ }
+ }
+}
+
+fn join_thread(slot: &Mutex >>) -> Result<(), CdpError> {
+ if let Some(thread) = lock(slot).take() {
+ thread
+ .join()
+ .map_err(|_| CdpError::Transport("CDP transport thread panicked".to_string()))?;
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn pipe_cloexec() -> Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd), CdpError> {
+ use std::os::fd::FromRawFd;
+
+ let mut descriptors = [-1; 2];
+ #[cfg(any(target_os = "linux", target_os = "android"))]
+ // SAFETY: `descriptors` points to space for exactly two file descriptors.
+ let result = unsafe { libc::pipe2(descriptors.as_mut_ptr(), libc::O_CLOEXEC) };
+ #[cfg(not(any(target_os = "linux", target_os = "android")))]
+ // SAFETY: `descriptors` points to space for exactly two file descriptors.
+ let result = unsafe { libc::pipe(descriptors.as_mut_ptr()) };
+ if result == -1 {
+ return Err(CdpError::Transport(format!(
+ "creating Chrome CDP pipe: {}",
+ std::io::Error::last_os_error()
+ )));
+ }
+ // SAFETY: successful `pipe2` returned two new, uniquely owned descriptors.
+ let pipes = unsafe {
+ (
+ std::os::fd::OwnedFd::from_raw_fd(descriptors[0]),
+ std::os::fd::OwnedFd::from_raw_fd(descriptors[1]),
+ )
+ };
+ #[cfg(not(any(target_os = "linux", target_os = "android")))]
+ for descriptor in [&pipes.0, &pipes.1] {
+ use std::os::fd::AsRawFd;
+
+ // SAFETY: the descriptor is owned and live for this call.
+ if unsafe { libc::fcntl(descriptor.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } == -1 {
+ return Err(CdpError::Transport(format!(
+ "setting close-on-exec on Chrome CDP pipe: {}",
+ std::io::Error::last_os_error()
+ )));
+ }
+ }
+ Ok(pipes)
+}
+
+#[cfg(unix)]
+fn dup_cloexec(descriptor: std::os::fd::RawFd) -> Result {
+ use std::os::fd::FromRawFd;
+
+ // Keep both pre-exec source descriptors above Chrome's fixed fd3/fd4.
+ // SAFETY: `descriptor` is live for this call and `fcntl` creates a new fd.
+ let duplicated = unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 5) };
+ if duplicated == -1 {
+ return Err(CdpError::Transport(format!(
+ "duplicating Chrome CDP pipe: {}",
+ std::io::Error::last_os_error()
+ )));
+ }
+ // SAFETY: successful `fcntl(F_DUPFD_CLOEXEC)` returned a new owned fd.
+ Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(duplicated) })
+}
+
+fn lock(mutex: &Mutex) -> MutexGuard<'_, T> {
+ mutex
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+}
diff --git a/browser/src/scrapling/crawl.rs b/browser/src/scrapling/crawl.rs
new file mode 100644
index 000000000..25a908527
--- /dev/null
+++ b/browser/src/scrapling/crawl.rs
@@ -0,0 +1,896 @@
+//! `browser::crawl` — breadth-first crawl from one or more start
+//! URLs, extracting per page and streaming the results (crawl.py:73-190).
+//!
+//! The frontier walk is parameterised over the fetch step, so the whole
+//! algorithm — ordering, dedup, the depth and page caps, per-page error
+//! isolation — is testable against a canned link graph with no network and no
+//! browser. Only the closure passed in at registration does I/O.
+
+use std::collections::{HashSet, VecDeque};
+use std::time::Duration;
+
+use futures::stream::{FuturesUnordered, StreamExt};
+use futures::FutureExt;
+use serde_json::{json, Value};
+
+use crate::config::SecurityMode;
+use crate::scrapling::dom;
+use crate::scrapling::page::{serialize_page, PageData};
+
+/// The RPC response carries only a sample; the full set goes to the stream.
+const SAMPLE_MAX: usize = 10;
+/// Ceiling on the per-page politeness delay (see the note where it is read).
+const MAX_DOWNLOAD_DELAY_SECS: f64 = 300.0;
+
+#[derive(Debug)]
+pub struct CrawlOpts {
+ pub start_urls: Vec,
+ pub fetcher: String,
+ pub allowed_domains: Vec,
+ pub same_domain: bool,
+ pub max_pages: i64,
+ pub max_depth: i64,
+ pub concurrency: usize,
+ pub download_delay: Duration,
+ pub stream_name: Value,
+}
+
+impl CrawlOpts {
+ /// Defaults are crawl.py's, which are hardcoded there rather than
+ /// configurable: 20 pages, depth 2, same-domain on, no delay.
+ pub fn from_payload(payload: &Value, max_concurrency: usize) -> Result {
+ Self::from_payload_for_mode(payload, max_concurrency, SecurityMode::Safe)
+ }
+
+ pub fn from_payload_for_mode(
+ payload: &Value,
+ max_concurrency: usize,
+ mode: SecurityMode,
+ ) -> Result {
+ let mut start_urls = crawl_strings(payload.get("start_urls"), "decode")?;
+ if start_urls.is_empty() {
+ if let Some(value) = payload.get("url").filter(|value| json_truthy(value)) {
+ if let Some(url) = value.as_str() {
+ start_urls.push(url.to_string());
+ } else {
+ return Err(format!(
+ "'{}' object has no attribute 'decode'",
+ python_type(value)
+ ));
+ }
+ }
+ }
+ if start_urls.is_empty() {
+ return Err("provide `start_urls`".to_string());
+ }
+ let fetcher = match payload.get("fetcher") {
+ None => "http".to_string(),
+ Some(Value::String(value)) => value.clone(),
+ Some(value) => {
+ return Err(format!(
+ "unknown fetcher: {} (use http|stealthy|dynamic)",
+ python_repr(value)
+ ))
+ }
+ };
+ if !matches!(fetcher.as_str(), "http" | "stealthy" | "dynamic") {
+ return Err(format!(
+ "unknown fetcher: {fetcher} (use http|stealthy|dynamic)"
+ ));
+ }
+ let concurrency_ceiling = max_concurrency.max(1).min(i64::MAX as usize) as i64;
+ Ok(Self {
+ start_urls,
+ fetcher,
+ allowed_domains: crawl_strings(payload.get("allowed_domains"), "lower")?
+ .into_iter()
+ // Normalize to the same punycode form host_of/normalize_link
+ // produce, so an IDN allow-entry matches IDN candidate hosts.
+ .map(|value| ascii_host(&value))
+ .collect(),
+ same_domain: payload.get("same_domain").is_none_or(json_truthy),
+ max_pages: python_int(payload, "max_pages", 20)?,
+ max_depth: python_int(payload, "max_depth", 2)?,
+ // The caller may ask for less, never for more than the server cap.
+ concurrency: python_int(payload, "concurrency", concurrency_ceiling)?
+ .clamp(1, concurrency_ceiling) as usize,
+ // Clamped for the same reason the fetch timeouts are:
+ // `from_secs_f64` panics on an unrepresentable value, and a panic
+ // in a detached handler drops the invocation and hangs the caller.
+ download_delay: crawl_delay(payload.get("download_delay"), mode)?,
+ stream_name: payload
+ .get("stream_name")
+ .filter(|value| json_truthy(value))
+ .cloned()
+ .unwrap_or_else(|| json!("browser::crawl")),
+ })
+ }
+}
+
+fn python_type(value: &Value) -> &'static str {
+ match value {
+ Value::Null => "NoneType",
+ Value::Bool(_) => "bool",
+ Value::Number(value) if value.is_f64() => "float",
+ Value::Number(_) => "int",
+ Value::String(_) => "str",
+ Value::Array(_) => "list",
+ Value::Object(_) => "dict",
+ }
+}
+
+pub(crate) fn python_repr(value: &Value) -> String {
+ match value {
+ Value::Null => "None".into(),
+ Value::Bool(true) => "True".into(),
+ Value::Bool(false) => "False".into(),
+ Value::String(value) => format!("'{value}'"),
+ Value::Number(value) => value.to_string(),
+ Value::Array(values) => format!(
+ "[{}]",
+ values
+ .iter()
+ .map(python_repr)
+ .collect::>()
+ .join(", ")
+ ),
+ Value::Object(values) => format!(
+ "{{{}}}",
+ values
+ .iter()
+ .map(|(key, value)| format!("'{key}': {}", python_repr(value)))
+ .collect::>()
+ .join(", ")
+ ),
+ }
+}
+
+fn crawl_strings(value: Option<&Value>, method: &str) -> Result, String> {
+ let Some(value) = value.filter(|value| json_truthy(value)) else {
+ return Ok(Vec::new());
+ };
+ let values = match value {
+ Value::String(value) => return Ok(value.chars().map(String::from).collect()),
+ Value::Array(values) => values
+ .iter()
+ .map(|value| {
+ value.as_str().map(str::to_string).ok_or_else(|| {
+ format!(
+ "'{}' object has no attribute '{method}'",
+ python_type(value)
+ )
+ })
+ })
+ .collect(),
+ Value::Object(values) => Ok(values.keys().cloned().collect()),
+ _ => Err(format!("'{}' object is not iterable", python_type(value))),
+ }?;
+ Ok(values)
+}
+
+fn python_int(payload: &Value, key: &str, default: i64) -> Result {
+ match payload.get(key) {
+ None | Some(Value::Null) => Ok(default),
+ Some(Value::Bool(value)) => Ok(i64::from(*value)),
+ Some(Value::Number(value)) => Ok(value
+ .as_i64()
+ .or_else(|| {
+ value
+ .as_u64()
+ .map(|value| value.min(i64::MAX as u64) as i64)
+ })
+ .or_else(|| value.as_f64().map(|value| value as i64))
+ .unwrap_or(default)),
+ Some(Value::String(value)) => value
+ .trim()
+ .parse()
+ .map_err(|_| format!("invalid literal for int() with base 10: '{}'", value)),
+ Some(value) => Err(format!(
+ "int() argument must be a string, a bytes-like object or a real number, not '{}'",
+ python_type(value)
+ )),
+ }
+}
+
+fn crawl_delay(value: Option<&Value>, mode: SecurityMode) -> Result {
+ let value = match value {
+ None | Some(Value::Null | Value::Bool(false)) => 0.0,
+ Some(Value::Bool(true)) => 1.0,
+ Some(Value::Number(value)) => value.as_f64().unwrap_or_default(),
+ Some(Value::String(value)) if value.is_empty() => 0.0,
+ Some(Value::String(value)) => value
+ .trim()
+ .parse::()
+ .map_err(|_| format!("could not convert string to float: '{value}'"))?,
+ Some(Value::Array(value)) if value.is_empty() => 0.0,
+ Some(Value::Object(value)) if value.is_empty() => 0.0,
+ Some(Value::Array(_)) => {
+ return Err("float() argument must be a string or a real number, not 'list'".into())
+ }
+ Some(Value::Object(_)) => {
+ return Err("float() argument must be a string or a real number, not 'dict'".into())
+ }
+ };
+ let value = if value <= 0.0 {
+ 0.0
+ } else if mode == SecurityMode::Safe {
+ value.min(MAX_DOWNLOAD_DELAY_SECS)
+ } else {
+ value
+ };
+ Duration::try_from_secs_f64(value).map_err(|_| "timestamp too large to convert".to_string())
+}
+
+/// Host with a leading `www.` folded away, so `example.com` and
+/// `www.example.com` count as one site (crawl.py `_same_site`).
+fn fold_www(host: &str) -> &str {
+ host.strip_prefix("www.").unwrap_or(host)
+}
+
+/// Same site if either host is the other, or a subdomain of it, after folding
+/// `www.` — the relationship holds in both directions, as in the reference.
+pub fn same_site(a: &str, b: &str) -> bool {
+ let (a, b) = (fold_www(a), fold_www(b));
+ a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}"))
+}
+
+/// Normalize a bare domain to its lowercase ASCII/punycode form, matching
+/// what `host_of` yields for a full URL. Falls back to the lowercased input
+/// for anything url can't parse as a host.
+fn ascii_host(domain: &str) -> String {
+ url::Url::parse(&format!("http://{domain}"))
+ .ok()
+ .and_then(|url| url.host_str().map(str::to_lowercase))
+ .unwrap_or_else(|| domain.to_lowercase())
+}
+
+pub fn host_of(raw: &str) -> Option {
+ // Use url::Url's host, not a raw netloc scan: extracted links are
+ // re-serialized through url::Url (punycode), so an IDN seed scanned raw
+ // (`münchen.example`) would never match a candidate (`xn--mnchen-3ya…`)
+ // and the crawl would follow zero links. Normalizing both sides the same
+ // way keeps IDN crawls working. The non-default port is kept, matching
+ // urllib's netloc (so `e.com:8443` and `e.com:9443` stay distinct sites).
+ let url = url::Url::parse(raw).ok()?;
+ let host = url.host_str()?.to_lowercase();
+ Some(match url.port() {
+ Some(port) => format!("{host}:{port}"),
+ None => host,
+ })
+}
+
+/// Should we follow this link? (crawl.py `_domain_ok`)
+pub fn domain_ok(candidate: &str, opts: &CrawlOpts, seed_hosts: &[String]) -> bool {
+ let Some(host) = host_of(candidate) else {
+ return false;
+ };
+ if !opts.allowed_domains.is_empty() {
+ return opts
+ .allowed_domains
+ .iter()
+ .any(|d| host == *d || host.ends_with(&format!(".{d}")));
+ }
+ if opts.same_domain {
+ return seed_hosts.iter().any(|s| same_site(&host, s));
+ }
+ true
+}
+
+/// Absolutise against the page URL and drop the `#fragment`, so `p#a` and
+/// `p#b` collapse onto one already-seen URL (crawl.py uses `urldefrag`).
+pub fn normalize_link(base: &str, href: &str) -> Option {
+ let base = url::Url::parse(base).ok()?;
+ let mut joined = base.join(href).ok()?;
+ joined.set_fragment(None);
+ if !matches!(joined.scheme(), "http" | "https") {
+ return None;
+ }
+ Some(joined.to_string())
+}
+
+/// Every `` on the page, absolutised and fragment-stripped.
+pub fn extract_links(html: &str, base: &str) -> Vec {
+ let doc = dom::parse(html);
+ dom::descendant_elements(doc.root())
+ .into_iter()
+ .filter(|element| element.name() == "a")
+ .filter_map(|element| element.attr("href"))
+ .filter_map(|href| normalize_link(base, href))
+ .collect()
+}
+
+pub fn fetch_payload(payload: &Value, url: &str) -> Value {
+ const FETCH_KEYS: &[&str] = &[
+ "impersonate",
+ "proxy",
+ "headless",
+ "network_idle",
+ "solve_cloudflare",
+ "real_chrome",
+ "wait_selector",
+ "timeout",
+ "useragent",
+ ];
+ let mut request = serde_json::Map::new();
+ for key in FETCH_KEYS {
+ if let Some(value) = payload.get(*key) {
+ request.insert((*key).into(), value.clone());
+ }
+ }
+ request.insert("url".into(), json!(url));
+ if let Some(value) = payload.get("selectors").filter(|value| json_truthy(value)) {
+ request.insert("selectors".into(), value.clone());
+ }
+ if let Some(value) = payload.get("format").filter(|value| json_truthy(value)) {
+ request.insert("format".into(), value.clone());
+ for key in ["main_content_only", "css_selector"] {
+ if let Some(value) = payload.get(key).filter(|value| !value.is_null()) {
+ request.insert(key.into(), value.clone());
+ }
+ }
+ }
+ Value::Object(request)
+}
+
+pub struct CrawlOutcome {
+ /// A bounded SAMPLE for the RPC response — never the full set. Use
+ /// `item_count` for the real total; `items.len()` caps at `SAMPLE_MAX`.
+ pub items: Vec,
+ /// Pages that produced a result, matching crawl.py's `stats["items"]`.
+ pub item_count: usize,
+ pub crawled: usize,
+ pub errors: usize,
+ pub stopped: &'static str,
+}
+
+/// Walk the frontier. `fetch` does the I/O; `emit` receives every item (in
+/// completion order) for streaming. Neither is allowed to abort the crawl:
+/// a failing page becomes an `{url, error}` item, exactly as in the reference
+/// where `visit()` never raises.
+pub async fn run(
+ opts: &CrawlOpts,
+ payload: &Value,
+ fetch: F,
+ mut emit: E,
+) -> CrawlOutcome
+where
+ F: Fn(String) -> Fut,
+ Fut: std::future::Future>,
+ E: for<'a> FnMut(
+ &'a Value,
+ ) -> std::pin::Pin + Send + 'a>>,
+{
+ let seed_hosts: Vec = opts.start_urls.iter().filter_map(|u| host_of(u)).collect();
+ let include_html = crate::scrapling::page::include_html(payload);
+
+ let mut frontier: VecDeque<(String, i64)> =
+ opts.start_urls.iter().map(|u| (u.clone(), 0)).collect();
+ let mut seen: HashSet = opts.start_urls.iter().cloned().collect();
+ let mut items = Vec::new();
+ let (mut crawled, mut errors, mut started, mut item_count) = (0usize, 0usize, 0usize, 0usize);
+
+ let mut pending = FuturesUnordered::new();
+ loop {
+ while pending.len() < opts.concurrency && (started as i64) < opts.max_pages {
+ let Some((url, depth)) = frontier.pop_front() else {
+ break;
+ };
+ started += 1;
+ let fut = fetch(url.clone());
+ pending.push(async move { (url, depth, fut.await) });
+ }
+ let Some(first) = pending.next().await else {
+ break;
+ };
+
+ // `asyncio.wait(..., FIRST_COMPLETED)` returns every task that is
+ // already done, not just the one that woke the scheduler. Process that
+ // whole batch before refilling the pool or applying the delay.
+ let mut done = vec![first];
+ while let Some(Some(completed)) = pending.next().now_or_never() {
+ done.push(completed);
+ }
+ for (url, depth, result) in done {
+ let item = match result {
+ Ok(page) => match serialize_page(&page, payload, include_html) {
+ Ok(serialized) => {
+ if depth < opts.max_depth {
+ for link in extract_links(&page.html, &page.url) {
+ if seen.contains(&link) || !domain_ok(&link, opts, &seed_hosts) {
+ continue;
+ }
+ seen.insert(link.clone());
+ frontier.push_back((link, depth + 1));
+ }
+ }
+ reduce_page(&url, serialized, include_html)
+ }
+ Err(e) => {
+ errors += 1;
+ json!({"url": url, "error": e})
+ }
+ },
+ Err(e) => {
+ errors += 1;
+ json!({"url": url, "error": e})
+ }
+ };
+ crawled += 1;
+ if item.get("error").is_none() {
+ item_count += 1;
+ }
+ emit(&item).await;
+ if items.len() < SAMPLE_MAX {
+ items.push(item);
+ }
+ }
+ if !opts.download_delay.is_zero() {
+ tokio::time::sleep(opts.download_delay).await;
+ }
+ }
+
+ CrawlOutcome {
+ items,
+ item_count,
+ crawled,
+ errors,
+ // Anything left in the frontier means the page cap, not exhaustion,
+ // ended the crawl.
+ stopped: if frontier.is_empty() {
+ "done"
+ } else {
+ "max_pages"
+ },
+ }
+}
+
+fn reduce_page(url: &str, page: Value, include_html: bool) -> Value {
+ let mut item = serde_json::Map::new();
+ item.insert("url".into(), json!(url));
+ item.insert(
+ "status".into(),
+ page.get("status").cloned().unwrap_or(Value::Null),
+ );
+ if page.get("extracted").is_some_and(json_truthy) {
+ item.insert("extracted".into(), page["extracted"].clone());
+ }
+ if page.get("content").is_some_and(|value| !value.is_null()) {
+ item.insert("content".into(), page["content"].clone());
+ }
+ if include_html && page.get("html").is_some_and(|value| !value.is_null()) {
+ item.insert("html".into(), page["html"].clone());
+ }
+ Value::Object(item)
+}
+
+pub(crate) fn json_truthy(value: &Value) -> bool {
+ match value {
+ Value::Null => false,
+ Value::Bool(value) => *value,
+ Value::Number(value) => value.as_f64() != Some(0.0),
+ Value::String(value) => !value.is_empty(),
+ Value::Array(value) => !value.is_empty(),
+ Value::Object(value) => !value.is_empty(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::cell::RefCell;
+
+ fn opts(payload: Value) -> CrawlOpts {
+ CrawlOpts::from_payload(&payload, 5).unwrap()
+ }
+
+ fn page(url: &str, html: &str) -> PageData {
+ PageData {
+ status: Some(200),
+ url: url.to_string(),
+ html: html.to_string(),
+ ..Default::default()
+ }
+ }
+
+ /// A canned site: url -> html. Anything not in the map is a fetch error.
+ async fn run_on(o: &CrawlOpts, site: &[(&str, &str)], payload: &Value) -> CrawlOutcome {
+ let order = RefCell::new(Vec::new());
+ let out = run(
+ o,
+ payload,
+ |url| {
+ order.borrow_mut().push(url.clone());
+ let found = site
+ .iter()
+ .find(|(u, _)| *u == url)
+ .map(|(u, h)| page(u, h));
+ async move { found.ok_or_else(|| "404 not found".to_string()) }
+ },
+ |_| Box::pin(async {}),
+ )
+ .await;
+ out
+ }
+
+ #[test]
+ fn same_site_folds_www_both_directions() {
+ assert!(same_site("example.com", "www.example.com"));
+ assert!(same_site("blog.example.com", "example.com"));
+ assert!(same_site("example.com", "blog.example.com"));
+ assert!(!same_site("example.com", "example.org"));
+ assert!(!same_site("notexample.com", "example.com"));
+ }
+
+ #[test]
+ fn links_are_absolutised_and_fragment_stripped() {
+ let links = extract_links(
+ r#"1 2 3
+ 4 "#,
+ "https://e.com/start",
+ );
+ assert_eq!(
+ links,
+ vec![
+ "https://e.com/a",
+ "https://e.com/a", // #x stripped -> same url, deduped by `seen`
+ "https://o.com/b",
+ ]
+ );
+ }
+
+ #[test]
+ fn allowed_domains_overrides_same_domain() {
+ let o = opts(json!({"url": "https://e.com/", "allowed_domains": ["o.com"]}));
+ let seeds = vec!["e.com".to_string()];
+ assert!(domain_ok("https://o.com/x", &o, &seeds));
+ assert!(domain_ok("https://sub.o.com/x", &o, &seeds));
+ assert!(!domain_ok("https://e.com/x", &o, &seeds));
+ }
+
+ #[tokio::test]
+ async fn breadth_first_order_and_fragment_dedup() {
+ let o = opts(json!({"url": "https://e.com/", "max_depth": 2, "concurrency": 1}));
+ let site = [
+ (
+ "https://e.com/",
+ r#"a b a "#,
+ ),
+ ("https://e.com/a", r#"c "#),
+ ("https://e.com/b", ""),
+ ("https://e.com/c", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ // seed, then depth 1 (a, b), then depth 2 (c) — and /a only once
+ assert_eq!(out.crawled, 4);
+ assert_eq!(out.errors, 0);
+ assert_eq!(out.stopped, "done");
+ }
+
+ #[tokio::test]
+ async fn max_depth_zero_visits_only_the_seeds() {
+ let o = opts(json!({"url": "https://e.com/", "max_depth": 0}));
+ let site = [
+ ("https://e.com/", r#"a "#),
+ ("https://e.com/a", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ assert_eq!(out.crawled, 1);
+ assert_eq!(out.stopped, "done");
+ }
+
+ #[tokio::test]
+ async fn page_cap_stops_and_reports_max_pages() {
+ let o = opts(json!({"url": "https://e.com/", "max_pages": 2, "max_depth": 3}));
+ let site = [
+ ("https://e.com/", r#"a b "#),
+ ("https://e.com/a", ""),
+ ("https://e.com/b", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ assert_eq!(out.crawled, 2);
+ assert_eq!(out.stopped, "max_pages");
+ }
+
+ #[tokio::test]
+ async fn a_failing_page_never_sinks_the_crawl() {
+ let o = opts(json!({"url": "https://e.com/", "max_depth": 1}));
+ // /missing is not in the canned site -> fetch error
+ let site = [
+ (
+ "https://e.com/",
+ r#"x y "#,
+ ),
+ ("https://e.com/ok", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ assert_eq!(out.crawled, 3);
+ assert_eq!(out.errors, 1);
+ let err_item = out
+ .items
+ .iter()
+ .find(|i| i.get("error").is_some())
+ .expect("the failed page is still reported");
+ assert_eq!(err_item["url"], json!("https://e.com/missing"));
+ assert_eq!(err_item["error"], json!("404 not found"));
+ }
+
+ #[tokio::test]
+ async fn off_site_links_are_not_followed_by_default() {
+ let o = opts(json!({"url": "https://e.com/", "max_depth": 2}));
+ let site = [
+ ("https://e.com/", r#"x "#),
+ ("https://other.com/x", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ assert_eq!(out.crawled, 1, "off-site link must not be crawled");
+ }
+
+ #[tokio::test]
+ async fn response_items_are_sampled_but_every_item_is_emitted() {
+ let o = opts(json!({"url": "https://e.com/p0", "max_pages": 30, "max_depth": 1}));
+ // one seed linking to 20 pages
+ let links: String = (1..=20)
+ .map(|i| format!(r#"p "#))
+ .collect();
+ let mut site: Vec<(String, String)> = vec![("https://e.com/p0".into(), links)];
+ for i in 1..=20 {
+ site.push((format!("https://e.com/p{i}"), String::new()));
+ }
+ let refs: Vec<(&str, &str)> = site.iter().map(|(u, h)| (u.as_str(), h.as_str())).collect();
+
+ let emitted = RefCell::new(0usize);
+ let out = run(
+ &o,
+ &json!({}),
+ |url| {
+ let found = refs
+ .iter()
+ .find(|(u, _)| *u == url)
+ .map(|(u, h)| page(u, h));
+ async move { found.ok_or_else(|| "missing".to_string()) }
+ },
+ |_| {
+ *emitted.borrow_mut() += 1;
+ Box::pin(async {})
+ },
+ )
+ .await;
+
+ assert_eq!(out.crawled, 21);
+ assert_eq!(*emitted.borrow(), 21, "every page is streamed");
+ assert_eq!(out.items.len(), SAMPLE_MAX, "the response only samples");
+ // The reported count is the real one, not the sample size — reporting
+ // items=10 for a 21-page crawl would silently cap forever.
+ assert_eq!(out.item_count, 21);
+ }
+
+ #[tokio::test]
+ async fn item_count_counts_successes_only_and_errors_count_separately() {
+ let o = opts(json!({"url": "https://e.com/", "max_depth": 1}));
+ let site = [
+ (
+ "https://e.com/",
+ r#"x y "#,
+ ),
+ ("https://e.com/ok", ""),
+ ];
+ let out = run_on(&o, &site, &json!({})).await;
+ assert_eq!(out.crawled, 3, "every visit counts as crawled");
+ assert_eq!(out.errors, 1);
+ assert_eq!(out.item_count, 2, "the failed page is not an item");
+ }
+
+ #[test]
+ fn concurrency_is_clamped_to_the_server_cap() {
+ let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/"}), 5).unwrap();
+ assert_eq!(
+ o.concurrency, 5,
+ "the oracle defaults to the configured ceiling"
+ );
+ let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/", "concurrency": 99}), 5)
+ .unwrap();
+ assert_eq!(o.concurrency, 5);
+ let o = CrawlOpts::from_payload(&json!({"url": "https://e.com/", "concurrency": 0}), 5)
+ .unwrap();
+ assert_eq!(o.concurrency, 1);
+ }
+
+ #[test]
+ fn domain_keys_include_the_port_like_urllib_netloc() {
+ assert_eq!(
+ host_of("https://e.com:8443/a").as_deref(),
+ Some("e.com:8443")
+ );
+ assert!(!same_site("e.com:8443", "e.com:9443"));
+ }
+
+ #[tokio::test]
+ async fn negative_page_cap_starts_nothing() {
+ let o = opts(json!({"url": "https://e.com/", "max_pages": -1}));
+ let out = run_on(&o, &[("https://e.com/", "")], &json!({})).await;
+ assert_eq!(out.crawled, 0);
+ assert_eq!(out.stopped, "max_pages");
+ }
+
+ #[tokio::test]
+ async fn crawl_sample_has_the_reduced_wrapper_shape() {
+ let o = opts(json!({"url": "https://e.com/"}));
+ let out = run_on(
+ &o,
+ &[("https://e.com/", "Hi ")],
+ &json!({"selectors": [{"name": "h", "css": "h1"}]}),
+ )
+ .await;
+ assert_eq!(
+ out.items,
+ vec![json!({
+ "url": "https://e.com/",
+ "status": 200,
+ "extracted": {"h": "Hi"},
+ })]
+ );
+ }
+
+ #[tokio::test]
+ async fn a_completion_batch_is_recorded_before_refilling_the_pool() {
+ let o = opts(json!({
+ "start_urls": ["https://e.com/1", "https://e.com/2", "https://e.com/3"],
+ "concurrency": 2,
+ "max_depth": 0,
+ }));
+ let events = RefCell::new(Vec::new());
+ let out = run(
+ &o,
+ &json!({}),
+ |url| {
+ events.borrow_mut().push(format!("start:{url}"));
+ async move { Ok(page(&url, "")) }
+ },
+ |item| {
+ events
+ .borrow_mut()
+ .push(format!("emit:{}", item["url"].as_str().unwrap()));
+ Box::pin(async {})
+ },
+ )
+ .await;
+ assert_eq!(out.crawled, 3);
+
+ let events = events.into_inner();
+ let third_start = events
+ .iter()
+ .position(|event| event == "start:https://e.com/3")
+ .unwrap();
+ assert_eq!(
+ events[..third_start]
+ .iter()
+ .filter(|event| event.starts_with("emit:"))
+ .count(),
+ 2,
+ "the oracle processes every task returned by FIRST_COMPLETED before refilling: {events:?}"
+ );
+ }
+
+ #[test]
+ fn missing_start_urls_and_bad_fetcher_are_rejected() {
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({}), 5).unwrap_err(),
+ "provide `start_urls`"
+ );
+ assert!(
+ CrawlOpts::from_payload(&json!({"url": "u", "fetcher": "carrier"}), 5)
+ .unwrap_err()
+ .contains("unknown fetcher")
+ );
+ }
+
+ #[test]
+ fn wrapper_coercions_and_errors_match_python() {
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({"url": "https://e.com/", "max_pages": [1]}), 5)
+ .unwrap_err(),
+ "int() argument must be a string, a bytes-like object or a real number, not 'list'"
+ );
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({"url": "https://e.com/", "max_depth": "x"}), 5)
+ .unwrap_err(),
+ "invalid literal for int() with base 10: 'x'"
+ );
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({"url": "https://e.com/", "fetcher": null}), 5)
+ .unwrap_err(),
+ "unknown fetcher: None (use http|stealthy|dynamic)"
+ );
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({"start_urls": [1]}), 5).unwrap_err(),
+ "'int' object has no attribute 'decode'"
+ );
+
+ let options = opts(json!({
+ "start_urls": "ab",
+ "allowed_domains": {"EXAMPLE.COM": true},
+ "same_domain": 0,
+ "max_pages": " 2 "
+ }));
+ assert_eq!(options.start_urls, ["a", "b"]);
+ assert_eq!(options.allowed_domains, ["example.com"]);
+ assert!(!options.same_domain);
+ assert_eq!(options.max_pages, 2);
+ }
+
+ #[test]
+ fn per_page_payload_only_forwards_the_wrapper_allowlist() {
+ let request = fetch_payload(
+ &json!({
+ "url": "https://old.invalid",
+ "headers": {"x": "silently excluded by the oracle crawl wrapper"},
+ "impersonate": "chrome",
+ "timeout": 9,
+ "selectors": [{"name": "h", "css": "h1"}],
+ "format": "text",
+ "main_content_only": false,
+ "css_selector": "main",
+ "include_html": true,
+ }),
+ "https://e.com/page",
+ );
+ assert_eq!(
+ request,
+ json!({
+ "impersonate": "chrome",
+ "timeout": 9,
+ "url": "https://e.com/page",
+ "selectors": [{"name": "h", "css": "h1"}],
+ "format": "text",
+ "main_content_only": false,
+ "css_selector": "main",
+ })
+ );
+ }
+
+ #[test]
+ fn an_absurd_download_delay_is_clamped_not_panicked_on() {
+ for v in [1e20, f64::MAX] {
+ let o = opts(json!({"url": "https://e.com/", "download_delay": v}));
+ assert!(o.download_delay <= Duration::from_secs_f64(MAX_DOWNLOAD_DELAY_SECS));
+ }
+ assert_eq!(
+ opts(json!({"url": "https://e.com/", "download_delay": f64::NAN})).download_delay,
+ Duration::ZERO
+ );
+ assert_eq!(
+ CrawlOpts::from_payload_for_mode(
+ &json!({"url": "https://e.com/", "download_delay": 301}),
+ 5,
+ SecurityMode::Compat,
+ )
+ .unwrap()
+ .download_delay,
+ Duration::from_secs(301)
+ );
+ assert_eq!(
+ CrawlOpts::from_payload_for_mode(
+ &json!({"url": "https://e.com/", "download_delay": -1}),
+ 5,
+ SecurityMode::Compat,
+ )
+ .unwrap()
+ .download_delay,
+ Duration::ZERO
+ );
+ assert_eq!(
+ CrawlOpts::from_payload(&json!({"url": "https://e.com/", "download_delay": [1]}), 5,)
+ .unwrap_err(),
+ "float() argument must be a string or a real number, not 'list'"
+ );
+ }
+
+ #[test]
+ fn stream_name_defaults_to_our_namespace() {
+ assert_eq!(opts(json!({"url": "u"})).stream_name, "browser::crawl");
+ assert_eq!(
+ opts(json!({"url": "u", "stream_name": 3})).stream_name,
+ json!(3)
+ );
+ }
+}
diff --git a/browser/src/scrapling/dom.rs b/browser/src/scrapling/dom.rs
new file mode 100644
index 000000000..c25cfb3cc
--- /dev/null
+++ b/browser/src/scrapling/dom.rs
@@ -0,0 +1,521 @@
+//! Libxml-compatible HTML tree used by every Scrapling parser operation.
+
+use std::collections::HashMap;
+use std::hash::{Hash, Hasher};
+
+use serde_json::Value;
+use xmloxide::html::{parse_html_with_options, HtmlParseOptions};
+use xmloxide::serial::html::serialize_html_subtree;
+use xmloxide::tree::NodeKind;
+use xmloxide::{Document, NodeId};
+
+const LXML_MIXED_CONTENT_TAGS: &[&str] = &[
+ "body",
+ "div",
+ "p",
+ "span",
+ "a",
+ "b",
+ "i",
+ "u",
+ "s",
+ "strike",
+ "tt",
+ "big",
+ "small",
+ "cite",
+ "q",
+ "kbd",
+ "ins",
+ "del",
+ "em",
+ "strong",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "li",
+ "dt",
+ "dd",
+ "td",
+ "th",
+ "caption",
+ "pre",
+ "code",
+ "label",
+ "button",
+ "legend",
+ "address",
+ "blockquote",
+ "form",
+];
+const LXML_RAW_TEXT_TAGS: &[&str] = &["script", "style", "title", "textarea"];
+
+#[derive(Debug, Clone)]
+pub struct Doc {
+ pub tree: Document,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ElementRef<'a> {
+ doc: &'a Doc,
+ id: NodeId,
+}
+
+impl PartialEq for ElementRef<'_> {
+ fn eq(&self, other: &Self) -> bool {
+ std::ptr::eq(self.doc, other.doc) && self.id == other.id
+ }
+}
+
+impl Eq for ElementRef<'_> {}
+
+impl Hash for ElementRef<'_> {
+ fn hash(&self, state: &mut H) {
+ std::ptr::from_ref(self.doc).hash(state);
+ self.id.hash(state);
+ }
+}
+
+impl<'a> ElementRef<'a> {
+ pub fn id(self) -> NodeId {
+ self.id
+ }
+
+ pub fn doc(self) -> &'a Doc {
+ self.doc
+ }
+
+ pub fn name(self) -> &'a str {
+ self.doc.tree.node_name(self.id).unwrap_or("")
+ }
+
+ pub fn attr(self, name: &str) -> Option<&'a str> {
+ self.doc.tree.attribute(self.id, name)
+ }
+
+ pub fn attrs(self) -> impl Iterator- + 'a {
+ self.doc
+ .tree
+ .attributes(self.id)
+ .iter()
+ .map(|attr| (attr.name.as_str(), attr.value.as_str()))
+ }
+}
+
+pub fn parse(input: &str) -> Doc {
+ let cleaned = input.trim().replace('\0', "");
+ let trim_implicit_leading = (cleaned.starts_with("Excellent");
+ let span = first(&doc, "span");
+ assert_eq!(leading_text(span), "CONDITION: Excellent");
+ assert_eq!(outer_html(span), "
CONDITION: Excellent ");
+
+ let doc = parse("a\0b
");
+ assert_eq!(get_all_text(first(&doc, "p"), "\n", false, &[], true), "ab");
+
+ let doc = parse(" b");
+ assert_eq!(outer_html(doc.root()), "b");
+ let doc = parse(" b
");
+ assert_eq!(outer_html(first(&doc, "div")), " b
");
+ }
+
+ #[test]
+ fn empty_input_keeps_the_oracles_explicit_html_root() {
+ let doc = parse("");
+ assert_eq!(doc.root().name(), "html");
+ assert_eq!(outer_html(doc.root()), "");
+
+ let doc = parse(">");
+ assert_eq!(outer_html(doc.root()), ">");
+ }
+
+ #[test]
+ fn text_runs_and_ignored_subtrees_match_scrapling() {
+ let doc = parse("abc
");
+ assert_eq!(
+ get_all_text(first(&doc, "div"), "\n", false, &["script", "style"], true),
+ "a\nb\nc"
+ );
+ let doc = parse("leadx tail
");
+ assert_eq!(leading_text(first(&doc, "p")), "lead");
+ }
+
+ #[test]
+ fn blank_text_uses_libxmls_legacy_content_model() {
+ let doc = parse(" a b
c e
");
+ let runs: Vec<_> = merged_text_children(first(&doc, "main"))
+ .into_iter()
+ .map(|(_, value)| value)
+ .collect();
+ assert_eq!(runs, [" ", " ", " "]);
+ assert_eq!(
+ merged_text_children(first(&parse(" \n "), "pre")),
+ [(true, " \n ".to_string())]
+ );
+ }
+
+ #[test]
+ fn attributes_and_recovery_are_ordered_and_libxml_serialized() {
+ let doc = parse(" ");
+ let input = first(&doc, "input");
+ assert_eq!(
+ attrs_json(input).keys().collect::>(),
+ ["z", "disabled", "a", "checked"]
+ );
+ assert_eq!(outer_html(input), " ");
+
+ let doc = parse("A BC");
+ assert_eq!(
+ outer_html(first(&doc, "table")),
+ "
"
+ );
+ }
+
+ #[test]
+ fn template_contents_are_ordinary_children() {
+ let doc = parse("
x
");
+ let template = first(&doc, "template");
+ assert_eq!(
+ element_children(template)
+ .into_iter()
+ .map(ElementRef::name)
+ .collect::
>(),
+ ["p"]
+ );
+ }
+}
diff --git a/browser/src/scrapling/egress_gate.rs b/browser/src/scrapling/egress_gate.rs
new file mode 100644
index 000000000..89ae33cf4
--- /dev/null
+++ b/browser/src/scrapling/egress_gate.rs
@@ -0,0 +1,260 @@
+use std::net::SocketAddr;
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::ssrf::{check_target, parse_target, SsrfPolicy};
+
+const MAX_HEADER_BYTES: usize = 64 * 1024;
+
+/// Local HTTP/CONNECT proxy used by safe-mode Chromium. Every connection is
+/// resolved, checked, and then pinned to the checked address before dialing.
+pub struct EgressGate {
+ address: SocketAddr,
+ stop: Option>,
+ task: Option>,
+}
+
+impl EgressGate {
+ pub async fn start(policy: SsrfPolicy) -> Result {
+ let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
+ .await
+ .map_err(|e| format!("starting browser egress gate: {e}"))?;
+ let address = listener
+ .local_addr()
+ .map_err(|e| format!("reading browser egress gate address: {e}"))?;
+ let (stop, mut stopped) = oneshot::channel();
+ let task = tokio::spawn(async move {
+ loop {
+ tokio::select! {
+ _ = &mut stopped => break,
+ accepted = listener.accept() => match accepted {
+ Ok((socket, _)) => {
+ tokio::spawn(handle(socket, policy));
+ }
+ Err(_) => break,
+ }
+ }
+ }
+ });
+ Ok(Self {
+ address,
+ stop: Some(stop),
+ task: Some(task),
+ })
+ }
+
+ pub fn proxy_url(&self) -> String {
+ format!("http://{}", self.address)
+ }
+
+ pub async fn close(mut self) {
+ if let Some(stop) = self.stop.take() {
+ let _ = stop.send(());
+ }
+ if let Some(task) = self.task.take() {
+ let _ = task.await;
+ }
+ }
+}
+
+impl Drop for EgressGate {
+ fn drop(&mut self) {
+ if let Some(stop) = self.stop.take() {
+ let _ = stop.send(());
+ }
+ if let Some(task) = self.task.take() {
+ task.abort();
+ }
+ }
+}
+
+async fn handle(mut client: TcpStream, policy: SsrfPolicy) {
+ if let Err((status, detail)) = proxy(&mut client, policy).await {
+ let body = format!("browser egress denied: {detail}\n");
+ let _ = client
+ .write_all(
+ format!(
+ "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ )
+ .as_bytes(),
+ )
+ .await;
+ }
+}
+
+async fn proxy(client: &mut TcpStream, policy: SsrfPolicy) -> Result<(), (&'static str, String)> {
+ let request = read_head(client).await?;
+ let split = request
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .map(|position| position + 4)
+ .ok_or_else(|| ("400 Bad Request", "incomplete request headers".to_string()))?;
+ let head = std::str::from_utf8(&request[..split]).map_err(|_| {
+ (
+ "400 Bad Request",
+ "request headers are not UTF-8".to_string(),
+ )
+ })?;
+ let first_end = head
+ .find("\r\n")
+ .ok_or_else(|| ("400 Bad Request", "missing request line".to_string()))?;
+ let mut request_line = head[..first_end].split_whitespace();
+ let method = request_line.next().unwrap_or_default();
+ let target = request_line.next().unwrap_or_default();
+ let version = request_line.next().unwrap_or_default();
+ if request_line.next().is_some() || !version.starts_with("HTTP/") {
+ return Err(("400 Bad Request", "invalid request line".to_string()));
+ }
+
+ let (parsed, connect) = if method.eq_ignore_ascii_case("CONNECT") {
+ (parse_target(&format!("https://{target}/")), true)
+ } else {
+ (parse_target(target), false)
+ };
+ let parsed = parsed.map_err(|e| ("400 Bad Request", e))?;
+ let resolved = check_target(&parsed, &policy)
+ .await
+ .map_err(|e| ("403 Forbidden", e.message))?;
+ let mut upstream = TcpStream::connect(SocketAddr::new(resolved.address, resolved.port))
+ .await
+ .map_err(|e| {
+ (
+ "502 Bad Gateway",
+ format!("connecting to {}: {e}", parsed.hostname),
+ )
+ })?;
+
+ if connect {
+ client
+ .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
+ .await
+ .map_err(|e| ("502 Bad Gateway", e.to_string()))?;
+ } else {
+ let path = match parsed.url.query() {
+ Some(query) => format!("{}?{query}", parsed.url.path()),
+ None => parsed.url.path().to_string(),
+ };
+ // The upstream socket is pinned to the FIRST validated host; only one
+ // request may ever travel on it. Strip the client's connection
+ // management and force `Connection: close` so a keep-alive client
+ // cannot send a second (differently-addressed) request down this
+ // pinned socket.
+ let mut forwarded = format!("{method} {path} {version}\r\n");
+ for line in head[first_end + 2..].split("\r\n") {
+ if line.is_empty() {
+ continue;
+ }
+ let name = line.split(':').next().unwrap_or_default().trim();
+ if name.eq_ignore_ascii_case("connection")
+ || name.eq_ignore_ascii_case("proxy-connection")
+ || name.eq_ignore_ascii_case("keep-alive")
+ {
+ continue;
+ }
+ forwarded.push_str(line);
+ forwarded.push_str("\r\n");
+ }
+ forwarded.push_str("Connection: close\r\n\r\n");
+ upstream
+ .write_all(forwarded.as_bytes())
+ .await
+ .map_err(|e| ("502 Bad Gateway", e.to_string()))?;
+ }
+ if request.len() > split {
+ upstream
+ .write_all(&request[split..])
+ .await
+ .map_err(|e| ("502 Bad Gateway", e.to_string()))?;
+ }
+ tokio::io::copy_bidirectional(client, &mut upstream)
+ .await
+ .map_err(|e| ("502 Bad Gateway", e.to_string()))?;
+ Ok(())
+}
+
+async fn read_head(client: &mut TcpStream) -> Result, (&'static str, String)> {
+ let mut request = Vec::with_capacity(1024);
+ while !request.windows(4).any(|window| window == b"\r\n\r\n") {
+ if request.len() == MAX_HEADER_BYTES {
+ return Err((
+ "431 Request Header Fields Too Large",
+ "request headers exceed 64 KiB".to_string(),
+ ));
+ }
+ let start = request.len();
+ let end = (start + 4096).min(MAX_HEADER_BYTES);
+ request.resize(end, 0);
+ let read = client
+ .read(&mut request[start..end])
+ .await
+ .map_err(|e| ("400 Bad Request", e.to_string()))?;
+ request.truncate(start + read);
+ if read == 0 {
+ return Err((
+ "400 Bad Request",
+ "connection closed before headers".to_string(),
+ ));
+ }
+ }
+ Ok(request)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn proxies_http_and_blocks_metadata_connects() {
+ let origin = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
+ .await
+ .unwrap();
+ let origin_address = origin.local_addr().unwrap();
+ let server = tokio::spawn(async move {
+ let (mut socket, _) = origin.accept().await.unwrap();
+ let request = read_head(&mut socket).await.unwrap();
+ let request = String::from_utf8(request).unwrap();
+ assert!(request.starts_with("GET /path?q=1 HTTP/1.1\r\n"));
+ // Exactly one Connection header, forced to close.
+ assert_eq!(request.matches("Connection:").count(), 1);
+ assert!(request.contains("\r\nConnection: close\r\n"));
+ socket
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+ .await
+ .unwrap();
+ });
+ let gate = EgressGate::start(SsrfPolicy {
+ allow_loopback: true,
+ })
+ .await
+ .unwrap();
+ let gate_address = gate.address;
+ let mut client = TcpStream::connect(gate_address).await.unwrap();
+ client
+ .write_all(
+ format!(
+ "GET http://{origin_address}/path?q=1 HTTP/1.1\r\nHost: {origin_address}\r\nConnection: close\r\n\r\n"
+ )
+ .as_bytes(),
+ )
+ .await
+ .unwrap();
+ let mut response = String::new();
+ client.read_to_string(&mut response).await.unwrap();
+ assert!(response.ends_with("\r\n\r\nok"));
+ server.await.unwrap();
+
+ let mut client = TcpStream::connect(gate_address).await.unwrap();
+ client
+ .write_all(b"CONNECT 169.254.169.254:80 HTTP/1.1\r\nHost: 169.254.169.254\r\n\r\n")
+ .await
+ .unwrap();
+ let mut response = String::new();
+ client.read_to_string(&mut response).await.unwrap();
+ assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n"));
+ gate.close().await;
+ }
+}
diff --git a/browser/src/scrapling/fetch.rs b/browser/src/scrapling/fetch.rs
new file mode 100644
index 000000000..64b79154c
--- /dev/null
+++ b/browser/src/scrapling/fetch.rs
@@ -0,0 +1,1617 @@
+//! `browser::fetch` — the no-browser HTTP tier (core.py's
+//! `fetch_raw(tier="http")`).
+//!
+//! Safe mode uses `reqwest` + rustls and refuses options that would pretend to
+//! provide curl-cffi wire parity or bypass its egress/TLS policy. Certified
+//! Tier-1 compat builds instead link the frozen curl-impersonate engine behind
+//! the `scrapling-compat` feature.
+//!
+//! In safe mode redirects are followed by hand (`Policy::none`) so every hop
+//! goes back through the SSRF check — the initial URL being public says
+//! nothing about where hop 3 points. Each hop also pins the socket to the
+//! address we validated, which closes the DNS-rebinding window between the
+//! check and the connect.
+
+use std::net::SocketAddr;
+use std::time::Duration;
+
+use serde_json::{json, Map, Value};
+
+use crate::scrapling::page::PageData;
+use crate::ssrf::{check_target, parse_target, SsrfPolicy};
+
+#[cfg(feature = "scrapling-compat")]
+mod curl_compat;
+#[cfg(feature = "scrapling-compat")]
+pub(crate) use curl_compat::CompatSession;
+
+/// Chrome-ish default headers. The python worker's `stealthy_headers` (on by
+/// default) adds a realistic header set plus a Google referer; this is the
+/// header-level part of that, which is all we can honestly offer.
+const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \
+ Chrome/131.0.0.0 Safari/537.36";
+const DEFAULT_ACCEPT: &str =
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
+
+/// Ceilings on caller-supplied durations. `Duration::from_secs_f64` panics on
+/// unrepresentable input, and an hour is already far past any sane fetch.
+const MAX_TIMEOUT_SECS: f64 = 3_600.0;
+const MAX_RETRY_DELAY_SECS: f64 = 60.0;
+/// Total wall-clock budget for one `fetch` call. Without it, `timeout` is
+/// per-request and multiplies out across redirect hops and retries — the
+/// reference passes `timeout` to curl's `CURLOPT_TIMEOUT`, which is a TOTAL
+/// budget, so a caller asking for 30s must not be able to wait 46 minutes.
+const TOTAL_BUDGET_MULTIPLIER: u32 = 3;
+/// Upper clamp for `max_redirects` on the safe tier. The schema is a bare
+/// integer, so oversized values must clamp rather than panic; anything past
+/// this is a redirect loop, not a fetch.
+const MAX_REDIRECTS: i64 = 100;
+/// Cap on a single response body. Without it one URL can OOM the worker and
+/// take every live browser session with it.
+const MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
+
+/// Headers that must not survive a redirect to a different origin. reqwest
+/// strips these itself when it follows redirects; we follow them by hand (to
+/// re-check each hop against the SSRF policy), so stripping is ours to do.
+/// curl has done this since CVE-2018-1000007.
+fn is_sensitive_header(name: &str) -> bool {
+ let n = name.to_ascii_lowercase();
+ matches!(
+ n.as_str(),
+ "authorization" | "cookie" | "proxy-authorization" | "www-authenticate"
+ )
+}
+
+/// Same origin = same scheme, host and effective port.
+fn same_origin(a: &url::Url, b: &url::Url) -> bool {
+ a.scheme() == b.scheme()
+ && a.host_str() == b.host_str()
+ && a.port_or_known_default() == b.port_or_known_default()
+}
+
+fn redirected_request(status: u16, method: &str, send_body: bool) -> (String, bool) {
+ if matches!(status, 301..=303) {
+ (
+ if method == "post" { "get" } else { method }.to_string(),
+ false,
+ )
+ } else {
+ (method.to_string(), send_body)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct HttpOptions {
+ pub mode: HttpMode,
+ pub method: String,
+ pub timeout: Duration,
+ /// Preserve curl-cffi's signed millisecond value for compat setopt error
+ /// parity. Safe mode only consumes the bounded `Duration` above.
+ pub compat_timeout_ms: i64,
+ pub follow_redirects: bool,
+ pub compat_follow_redirects: i64,
+ pub max_redirects: i64,
+ pub retries: i64,
+ pub retry_delay: Duration,
+ pub retry_delay_negative: bool,
+ pub proxy: Option,
+ pub proxies: Vec<(String, String)>,
+ pub proxy_auth: Option<(String, String)>,
+ pub auth: Option<(String, String)>,
+ pub impersonate: Option,
+ pub http3: bool,
+ pub verify: bool,
+ pub headers: Vec<(String, String)>,
+ pub cookies: Vec<(String, String)>,
+ pub params: Vec<(String, String)>,
+ pub body: Option,
+ /// Shared cookie jar for `session-fetch`. Each hop still builds its own
+ /// pinned client, so the jar is what carries cookies across requests (and
+ /// across redirects) on a persistent HTTP session.
+ pub jar: Option>,
+ #[cfg(feature = "scrapling-compat")]
+ pub(crate) compat_session: Option,
+}
+
+#[derive(Clone, Debug)]
+pub enum Body {
+ Form(Value),
+ Json(Value),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum HttpMode {
+ Safe,
+ Compat,
+}
+
+fn str_pairs(payload: &Value, key: &str) -> Vec<(String, String)> {
+ payload
+ .get(key)
+ .and_then(Value::as_object)
+ .map(|m| {
+ m.iter()
+ .map(|(k, v)| {
+ let s = match v {
+ Value::String(s) => s.clone(),
+ other => other.to_string(),
+ };
+ (k.clone(), s)
+ })
+ .collect::>()
+ })
+ .unwrap_or_default()
+}
+
+fn python_scalar(value: &Value) -> String {
+ match value {
+ Value::String(value) => value.clone(),
+ Value::Bool(value) => if *value { "True" } else { "False" }.to_string(),
+ Value::Null => "None".to_string(),
+ Value::Number(value) => value.to_string(),
+ Value::Array(_) | Value::Object(_) => python_repr(value),
+ }
+}
+
+fn python_repr(value: &Value) -> String {
+ match value {
+ Value::Null => "None".to_string(),
+ Value::Bool(value) => if *value { "True" } else { "False" }.to_string(),
+ Value::Number(value) => value.to_string(),
+ Value::String(value) => {
+ let quote = if value.contains('\'') && !value.contains('"') {
+ '"'
+ } else {
+ '\''
+ };
+ let escaped = value
+ .replace('\\', "\\\\")
+ .replace(quote, &format!("\\{quote}"));
+ format!("{quote}{escaped}{quote}")
+ }
+ Value::Array(values) => format!(
+ "[{}]",
+ values
+ .iter()
+ .map(python_repr)
+ .collect::>()
+ .join(", ")
+ ),
+ Value::Object(values) => format!(
+ "{{{}}}",
+ values
+ .iter()
+ .map(|(name, value)| format!(
+ "{}: {}",
+ python_repr(&Value::String(name.clone())),
+ python_repr(value)
+ ))
+ .collect::>()
+ .join(", ")
+ ),
+ }
+}
+
+fn python_json(value: &Value) -> String {
+ match value {
+ Value::Array(values) => format!(
+ "[{}]",
+ values
+ .iter()
+ .map(python_json)
+ .collect::>()
+ .join(", ")
+ ),
+ Value::Object(values) => format!(
+ "{{{}}}",
+ values
+ .iter()
+ .map(|(name, value)| format!(
+ "{}: {}",
+ serde_json::to_string(name).expect("JSON string serialization cannot fail"),
+ python_json(value)
+ ))
+ .collect::>()
+ .join(", ")
+ ),
+ other => other.to_string(),
+ }
+}
+
+fn params_pairs(payload: &Value) -> Vec<(String, String)> {
+ let mut pairs = Vec::new();
+ if let Some(params) = payload.get("params").and_then(Value::as_object) {
+ for (name, value) in params {
+ if let Value::Array(values) = value {
+ pairs.extend(
+ values
+ .iter()
+ .map(|value| (name.clone(), python_scalar(value))),
+ );
+ } else {
+ let value = if matches!(value, Value::Bool(_) | Value::Object(_)) {
+ python_json(value)
+ } else {
+ python_scalar(value)
+ };
+ pairs.push((name.clone(), value));
+ }
+ }
+ }
+ pairs
+}
+
+fn pair(payload: &Value, key: &str) -> Result, String> {
+ let Some(value) = payload.get(key).filter(|value| !value.is_null()) else {
+ return Ok(None);
+ };
+ let values = value
+ .as_array()
+ .ok_or_else(|| format!("{key} must be [username, password]"))?;
+ if values.len() < 2 {
+ return Err(format!(
+ "not enough values to unpack (expected 2, got {})",
+ values.len()
+ ));
+ }
+ if values.len() > 2 {
+ return Err("too many values to unpack (expected 2)".to_string());
+ }
+ let string = |index: usize| {
+ values[index]
+ .as_str()
+ .map(str::to_owned)
+ .ok_or_else(|| format!("{key} must be [username, password]"))
+ };
+ Ok(Some((string(0)?, string(1)?)))
+}
+
+impl HttpOptions {
+ /// Read the request. Defaults mirror scrapling's own
+ /// (`engines/static.py`): 30s timeout, 3 attempts, 1s between them,
+ /// 30 redirect hops.
+ pub fn from_payload(payload: &Value) -> Result {
+ Self::from_payload_for_mode(payload, HttpMode::Safe)
+ }
+
+ pub fn from_payload_for_mode(payload: &Value, mode: HttpMode) -> Result {
+ if mode == HttpMode::Compat && !cfg!(feature = "scrapling-compat") {
+ return Err(
+ "browser::fetch compat HTTP engine is not compiled into this binary; rebuild the Tier-1 target with feature `scrapling-compat` and the certified curl-impersonate artifacts"
+ .to_string(),
+ );
+ }
+ if mode == HttpMode::Safe {
+ let reject = |field: &str, enabled: bool, reason: &str| -> Result<(), String> {
+ if enabled {
+ Err(format!(
+ "safe mode refuses `{field}`: {reason}; use a certified compat build or remove the option"
+ ))
+ } else {
+ Ok(())
+ }
+ };
+ if let Some(value) = payload.get("impersonate").filter(|value| !value.is_null()) {
+ let value = value.as_str().ok_or("impersonate must be a string")?;
+ reject(
+ "impersonate",
+ !matches!(value, "" | "chrome"),
+ "the safe engine implements only its bounded Chrome header profile",
+ )?;
+ }
+ reject(
+ "http3",
+ payload
+ .get("http3")
+ .and_then(Value::as_bool)
+ .unwrap_or(false),
+ "the safe reqwest engine has no certified HTTP/3 transport",
+ )?;
+ reject(
+ "verify:false",
+ payload.get("verify").and_then(Value::as_bool) == Some(false),
+ "TLS certificate verification cannot be disabled",
+ )?;
+ reject(
+ "proxy",
+ payload
+ .get("proxy")
+ .and_then(Value::as_str)
+ .is_some_and(|value| !value.is_empty()),
+ "a caller proxy can resolve or route to addresses outside the egress policy",
+ )?;
+ reject(
+ "proxies",
+ payload
+ .get("proxies")
+ .and_then(Value::as_object)
+ .is_some_and(|value| !value.is_empty()),
+ "per-scheme proxies bypass address pinning",
+ )?;
+ reject(
+ "proxy_auth",
+ payload.get("proxy_auth").is_some_and(|v| !v.is_null()),
+ "proxy authentication is unavailable when caller proxies are refused",
+ )?;
+ reject(
+ "stealthy_headers:true",
+ payload.get("stealthy_headers").and_then(Value::as_bool) == Some(true),
+ "the safe reqwest engine cannot reproduce BrowserForge's generated header fingerprint",
+ )?;
+ }
+ let method = payload
+ .get("method")
+ .and_then(Value::as_str)
+ .unwrap_or("get")
+ .to_ascii_lowercase();
+ if !matches!(method.as_str(), "get" | "post" | "put" | "delete") {
+ return Err(format!("unsupported method: {method}"));
+ }
+ // `timeout` is SECONDS on this tier (it is milliseconds on the browser
+ // tiers — the schemas say so explicitly, and the two disagree on
+ // purpose because the underlying libraries do).
+ //
+ // The upper clamp is not cosmetic: `Duration::from_secs_f64` PANICS on
+ // a value it cannot represent, and the schema declares a bare
+ // `number`, so `{"timeout": 1e20}` would panic inside the handler.
+ // The SDK spawns handlers detached, so that panic would drop the
+ // invocation entirely and hang the caller with no result.
+ let timeout = payload
+ .get("timeout")
+ .and_then(Value::as_f64)
+ .filter(|value| value.is_finite())
+ .unwrap_or(30.0);
+ let compat_timeout_ms = (timeout * 1_000.0) as i64;
+ let timeout = if mode == HttpMode::Safe {
+ if timeout > 0.0 {
+ timeout.min(MAX_TIMEOUT_SECS)
+ } else {
+ 30.0
+ }
+ } else {
+ timeout.clamp(0.0, MAX_TIMEOUT_SECS)
+ };
+ let raw_retry_delay = payload
+ .get("retry_delay")
+ .and_then(Value::as_f64)
+ .filter(|value| value.is_finite())
+ .unwrap_or(1.0);
+ let retry_delay_negative = raw_retry_delay < 0.0;
+ let retry_delay = if mode == HttpMode::Safe {
+ raw_retry_delay.clamp(0.0, MAX_RETRY_DELAY_SECS)
+ } else {
+ raw_retry_delay.clamp(0.0, (u64::MAX / 2) as f64)
+ };
+ let params = params_pairs(payload);
+ let body = match (payload.get("json"), payload.get("data")) {
+ (Some(j), _) if !j.is_null() => Some(Body::Json(j.clone())),
+ (_, Some(d)) if !d.is_null() => Some(Body::Form(d.clone())),
+ _ => None,
+ };
+ let raw_max_redirects = payload
+ .get("max_redirects")
+ .and_then(Value::as_i64)
+ .unwrap_or(30);
+ if mode == HttpMode::Safe && raw_max_redirects < 0 {
+ return Err(format!(
+ "safe mode refuses `max_redirects:{raw_max_redirects}`: unlimited or invalid redirect counts exceed the bounded request policy; use a non-negative limit"
+ ));
+ }
+ let explicit_impersonate = payload
+ .get("impersonate")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ if mode == HttpMode::Compat
+ && payload
+ .get("proxy")
+ .and_then(Value::as_str)
+ .is_some_and(|value| !value.is_empty())
+ && payload
+ .get("proxies")
+ .and_then(Value::as_object)
+ .is_some_and(|value| !value.is_empty())
+ {
+ return Err("Cannot specify both 'proxy' and 'proxies'".to_string());
+ }
+ let impersonate = if mode == HttpMode::Compat {
+ explicit_impersonate.or_else(|| Some("chrome".to_string()))
+ } else {
+ None
+ };
+ let compat_default_ua = (mode == HttpMode::Compat)
+ .then(crate::scrapling::browserforge::default_user_agent)
+ .transpose()?;
+ let impersonate = impersonate.filter(|value| !value.is_empty());
+ let headers = stealthy_headers(
+ payload,
+ mode,
+ impersonate.is_some(),
+ compat_default_ua.as_deref(),
+ )?;
+ Ok(Self {
+ mode,
+ method,
+ timeout: Duration::from_secs_f64(timeout),
+ compat_timeout_ms,
+ follow_redirects: payload
+ .get("follow_redirects")
+ .and_then(Value::as_bool)
+ .unwrap_or(true),
+ compat_follow_redirects: match payload.get("follow_redirects").and_then(Value::as_bool)
+ {
+ Some(true) => 1,
+ Some(false) => 0,
+ None => 4,
+ },
+ max_redirects: raw_max_redirects,
+ retries: payload.get("retries").and_then(Value::as_i64).unwrap_or(3),
+ retry_delay: Duration::from_secs_f64(retry_delay),
+ retry_delay_negative,
+ proxy: payload
+ .get("proxy")
+ .and_then(Value::as_str)
+ .filter(|p| !p.is_empty())
+ .map(str::to_string),
+ proxies: str_pairs(payload, "proxies"),
+ proxy_auth: pair(payload, "proxy_auth")?,
+ auth: pair(payload, "auth")?,
+ impersonate,
+ http3: payload
+ .get("http3")
+ .and_then(Value::as_bool)
+ .unwrap_or(false),
+ verify: payload
+ .get("verify")
+ .and_then(Value::as_bool)
+ .unwrap_or(true),
+ headers,
+ cookies: str_pairs(payload, "cookies"),
+ params,
+ body,
+ jar: None,
+ #[cfg(feature = "scrapling-compat")]
+ compat_session: None,
+ })
+ }
+
+ fn method_allows_body(&self) -> bool {
+ self.method != "get"
+ }
+}
+
+/// Caller headers, with browser-ish defaults filled in underneath unless
+/// `stealthy_headers: false`. Caller-supplied values always win.
+fn stealthy_headers(
+ payload: &Value,
+ mode: HttpMode,
+ impersonation_enabled: bool,
+ compat_default_ua: Option<&str>,
+) -> Result, String> {
+ let mut headers = str_pairs(payload, "headers");
+ let stealth = payload
+ .get("stealthy_headers")
+ .and_then(Value::as_bool)
+ .unwrap_or(true);
+ let has = |h: &[(String, String)], name: &str| {
+ h.iter().any(|(key, _)| key.eq_ignore_ascii_case(name))
+ };
+ if !stealth {
+ if mode == HttpMode::Compat && !impersonation_enabled && !has(&headers, "user-agent") {
+ headers.push((
+ "User-Agent".into(),
+ compat_default_ua.unwrap_or(DEFAULT_UA).into(),
+ ));
+ }
+ return Ok(headers);
+ }
+ if mode == HttpMode::Compat && impersonation_enabled {
+ if !has(&headers, "referer") {
+ headers.push(("referer".into(), "https://www.google.com/".into()));
+ }
+ } else if mode == HttpMode::Compat {
+ let supplied = headers
+ .iter()
+ .map(|(name, _)| name.to_ascii_lowercase())
+ .collect::>();
+ if !supplied.contains("referer") {
+ headers.push(("referer".into(), "https://www.google.com/".into()));
+ }
+ for (name, value) in crate::scrapling::browserforge::generate_http_headers()? {
+ if !supplied.contains(&name.to_ascii_lowercase()) {
+ headers.push((name, value));
+ }
+ }
+ } else {
+ if !has(&headers, "user-agent") {
+ headers.push(("user-agent".into(), DEFAULT_UA.into()));
+ }
+ if !has(&headers, "accept") {
+ headers.push(("accept".into(), DEFAULT_ACCEPT.into()));
+ }
+ if !has(&headers, "accept-language") {
+ headers.push(("accept-language".into(), "en-US,en;q=0.9".into()));
+ }
+ }
+ Ok(headers)
+}
+
+fn build_client(
+ hostname: &str,
+ address: SocketAddr,
+ opts: &HttpOptions,
+) -> Result {
+ let mut b = reqwest::Client::builder()
+ // Manual redirects: every hop is re-validated (see module docs).
+ .redirect(reqwest::redirect::Policy::none())
+ .pool_max_idle_per_host(0)
+ .timeout(opts.timeout);
+ if let Some(jar) = &opts.jar {
+ b = b.cookie_provider(jar.clone());
+ }
+ match &opts.proxy {
+ // Through a proxy we cannot pin the socket — the proxy does its own
+ // resolution, so `.resolve()` would be ignored and the pin would be a
+ // false comfort. The proxy ENDPOINT itself is blocklist-checked
+ // separately (see `check_proxy`): it is the address this worker
+ // actually dials, so validating only the target would leave an
+ // internal proxy usable as a pivot into the private network.
+ Some(p) => {
+ b = b.proxy(reqwest::Proxy::all(p).map_err(|e| format!("invalid proxy: {e}"))?);
+ }
+ None => {
+ b = b.resolve(hostname, address);
+ }
+ }
+ b.build().map_err(|e| e.to_string())
+}
+
+fn charset_of(headers: &reqwest::header::HeaderMap) -> Option {
+ let ct = headers.get(reqwest::header::CONTENT_TYPE)?.to_str().ok()?;
+ ct.split(';')
+ .filter_map(|p| p.split_once('='))
+ .find(|(k, _)| k.trim().eq_ignore_ascii_case("charset"))
+ .map(|(_, v)| v.trim().trim_matches('"').to_ascii_lowercase())
+}
+
+#[cfg(feature = "scrapling-compat")]
+fn charset_of_raw(headers: &Map) -> Option {
+ let content_type = headers.get("content-type")?.as_str()?;
+ content_type
+ .split(';')
+ .filter_map(|part| part.split_once('='))
+ .find(|(name, _)| name.trim().eq_ignore_ascii_case("charset"))
+ .map(|(_, value)| value.trim().trim_matches('"').to_ascii_lowercase())
+}
+
+fn decode_body(bytes: &[u8], encoding: Option<&str>) -> String {
+ let decoder = encoding
+ .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes()))
+ .unwrap_or(encoding_rs::UTF_8);
+ decoder.decode(bytes).0.into_owned()
+}
+
+fn checked_body_len(current: usize, incoming: usize) -> Option {
+ current
+ .checked_add(incoming)
+ .filter(|total| *total <= MAX_BODY_BYTES)
+}
+
+async fn bounded_body(mut response: reqwest::Response, url: &str) -> Result, String> {
+ if let Some(len) = response.content_length() {
+ if len > MAX_BODY_BYTES as u64 {
+ return Err(format!(
+ "response body is {len} bytes, over the {MAX_BODY_BYTES}-byte cap ({url})"
+ ));
+ }
+ }
+ let mut body = Vec::new();
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(|error| format!("reading body of {url}: {error}"))?
+ {
+ if checked_body_len(body.len(), chunk.len()).is_none() {
+ return Err(format!(
+ "response body exceeded the {MAX_BODY_BYTES}-byte cap while streaming ({url})"
+ ));
+ }
+ body.extend_from_slice(&chunk);
+ }
+ Ok(body)
+}
+
+fn flatten_headers(headers: &reqwest::header::HeaderMap) -> Map {
+ // Repeated headers (Set-Cookie, Vary, Link, ...) are joined with ", ",
+ // matching what the compat/curl engine produces — last-value-wins would
+ // silently drop data.
+ let mut out = Map::new();
+ for k in headers.keys() {
+ let joined = headers
+ .get_all(k)
+ .iter()
+ .filter_map(|v| v.to_str().ok())
+ .collect::>()
+ .join(", ");
+ out.insert(k.as_str().to_string(), json!(joined));
+ }
+ out
+}
+
+/// Cookies the response set, as a flat name -> value map.
+fn response_cookies(headers: &reqwest::header::HeaderMap) -> Map {
+ let mut out = Map::new();
+ for v in headers.get_all(reqwest::header::SET_COOKIE) {
+ let Ok(s) = v.to_str() else { continue };
+ let pair = s.split(';').next().unwrap_or("");
+ if let Some((name, value)) = pair.split_once('=') {
+ out.insert(name.trim().to_string(), json!(value.trim()));
+ }
+ }
+ out
+}
+
+/// Validate a caller-supplied proxy endpoint against the same blocklist the
+/// targets go through. When a proxy is configured it — not the target — is
+/// what this worker connects to, so skipping this check would let
+/// `{"proxy": "http://10.0.0.5:3128"}` reach straight into the private
+/// network while the target check looked at an unrelated public host.
+///
+/// `parse_target` only admits http/https, which also rejects `socks5h://…`
+/// (whose whole point is proxy-side DNS we cannot inspect).
+pub async fn check_proxy(opts: &HttpOptions, policy: &SsrfPolicy) -> Result<(), String> {
+ let Some(raw) = &opts.proxy else {
+ return Ok(());
+ };
+ let target =
+ parse_target(raw).map_err(|e| format!("proxy is not a usable http(s) endpoint: {e}"))?;
+ check_target(&target, policy)
+ .await
+ .map_err(|r| format!("proxy refused: {}", r.message))?;
+ Ok(())
+}
+
+/// One attempt: walk the redirect chain, SSRF-checking every hop.
+async fn attempt(
+ url: &str,
+ opts: &HttpOptions,
+ policy: &SsrfPolicy,
+ deadline: std::time::Instant,
+) -> Result {
+ let mut current = url.to_string();
+ let mut method = opts.method.clone();
+ let mut send_body = opts.method_allows_body();
+ let mut cookies = Map::new();
+ // Cookies set by responses along the chain, replayed host-only: a cookie
+ // is only sent back to the exact host that set it. That covers
+ // login/consent flows that bounce through the same host without ever
+ // leaking a value cross-host.
+ let mut hop_cookies: std::collections::HashMap> =
+ std::collections::HashMap::new();
+ let origin = parse_target(url)?.url.clone();
+ // Schema declares a bare integer; an oversized value must clamp, not
+ // panic in try_from. Negative is rejected at parse for safe mode.
+ let max_redirects = opts.max_redirects.clamp(0, MAX_REDIRECTS) as u32;
+ for hop in 0..=max_redirects {
+ if std::time::Instant::now() >= deadline {
+ return Err(format!(
+ "exceeded the total budget while following redirects (hop {hop}) fetching {url}"
+ ));
+ }
+ let target = parse_target(¤t)?;
+ // Cross-origin hop: drop credentials the caller scoped to the origin
+ // they addressed. An open redirect on a trusted host would otherwise
+ // hand an Authorization bearer to whoever it points at.
+ let cross_origin = !same_origin(&origin, &target.url);
+ let resolved = check_target(&target, policy).await.map_err(|r| r.message)?;
+ let client = build_client(
+ &target.hostname,
+ SocketAddr::new(resolved.address, resolved.port),
+ opts,
+ )?;
+
+ let request_method = reqwest::Method::from_bytes(method.to_uppercase().as_bytes())
+ .map_err(|e| e.to_string())?;
+ let mut req = client.request(request_method, target.url.clone());
+ for (k, v) in &opts.headers {
+ if cross_origin && is_sensitive_header(k) {
+ continue;
+ }
+ req = req.header(k, v);
+ }
+ let mut jar = Map::new();
+ // A manual Cookie header makes reqwest skip its cookie store for the
+ // request, so when a session jar exists its cookies must be merged in
+ // here or they would be silently suppressed by per-request cookies.
+ if let Some(session_jar) = &opts.jar {
+ use reqwest::cookie::CookieStore;
+ if let Some(header) = session_jar.cookies(&target.url) {
+ if let Ok(s) = header.to_str() {
+ for pair in s.split("; ") {
+ if let Some((k, v)) = pair.split_once('=') {
+ jar.insert(k.to_string(), json!(v));
+ }
+ }
+ }
+ }
+ }
+ if !cross_origin {
+ for (k, v) in &opts.cookies {
+ jar.insert(k.clone(), json!(v));
+ }
+ }
+ // Server-set cookies from earlier hops to this same host override the
+ // caller's on a name collision — that's what a cookie jar does.
+ if let Some(set) = hop_cookies.get(&target.hostname) {
+ for (k, v) in set {
+ jar.insert(k.clone(), v.clone());
+ }
+ }
+ if !jar.is_empty() {
+ let jar = jar
+ .iter()
+ .map(|(k, v)| format!("{k}={}", v.as_str().unwrap_or_default()))
+ .collect::>()
+ .join("; ");
+ req = req.header(reqwest::header::COOKIE, jar);
+ }
+ if hop == 0 && !opts.params.is_empty() {
+ req = req.query(&opts.params);
+ }
+ if !cross_origin {
+ if let Some((username, password)) = &opts.auth {
+ req = req.basic_auth(username, Some(password));
+ }
+ }
+ // The wrapper forwards data/json for POST, PUT, and DELETE. DELETE
+ // bodies are unusual but explicitly supported by Scrapling.
+ if send_body {
+ match &opts.body {
+ Some(Body::Json(v)) => req = req.json(v),
+ Some(Body::Form(v)) => {
+ let form: Vec<(String, String)> = v
+ .as_object()
+ .map(|m| {
+ m.iter()
+ .map(|(k, val)| {
+ let s = match val {
+ Value::String(s) => s.clone(),
+ other => other.to_string(),
+ };
+ (k.clone(), s)
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ req = req.form(&form);
+ }
+ None => {}
+ }
+ }
+
+ let resp = req.send().await.map_err(|e| {
+ if e.is_timeout() {
+ format!("timeout after {:?} fetching {current}", opts.timeout)
+ } else {
+ format!("transport error fetching {current}: {e}")
+ }
+ })?;
+
+ let status = resp.status();
+ let headers = resp.headers().clone();
+ for (k, v) in response_cookies(&headers) {
+ hop_cookies
+ .entry(target.hostname.clone())
+ .or_default()
+ .insert(k.clone(), v.clone());
+ cookies.insert(k, v);
+ }
+
+ if opts.follow_redirects && status.is_redirection() {
+ if let Some(loc) = headers
+ .get(reqwest::header::LOCATION)
+ .and_then(|l| l.to_str().ok())
+ {
+ (method, send_body) = redirected_request(status.as_u16(), &method, send_body);
+ current = target
+ .url
+ .join(loc)
+ .map_err(|e| format!("bad redirect target {loc}: {e}"))?
+ .to_string();
+ continue;
+ }
+ }
+
+ let encoding = charset_of(&headers);
+ let final_url = resp.url().to_string();
+ let body = bounded_body(resp, ¤t).await?;
+ let html = decode_body(&body, encoding.as_deref());
+ return Ok(PageData {
+ status: Some(status.as_u16()),
+ url: final_url,
+ headers: flatten_headers(&headers),
+ cookies,
+ encoding,
+ html,
+ captured_xhr: vec![],
+ });
+ }
+ Err(format!(
+ "too many redirects (>{}) starting at {url}",
+ max_redirects
+ ))
+}
+
+/// Fetch one URL, retrying transport failures. HTTP error statuses are NOT
+/// retried — a 404 is an answer, and re-asking produces the same 404 while
+/// costing the caller their timeout budget.
+pub async fn fetch_page(
+ url: &str,
+ opts: &HttpOptions,
+ policy: &SsrfPolicy,
+) -> Result {
+ if opts.mode == HttpMode::Compat {
+ #[cfg(feature = "scrapling-compat")]
+ {
+ if let Some(session) = opts.compat_session.clone() {
+ return curl_compat::fetch_page_with_session(
+ url.to_string(),
+ opts.clone(),
+ session,
+ )
+ .await;
+ }
+ return curl_compat::fetch_page(url.to_string(), opts.clone()).await;
+ }
+ #[cfg(not(feature = "scrapling-compat"))]
+ {
+ return Err(
+ "browser::fetch compat HTTP engine is not compiled into this binary".to_string(),
+ );
+ }
+ }
+ check_proxy(opts, policy).await?;
+ // `opts.timeout` is per REQUEST; across redirect hops and retries it
+ // multiplies out (3 retries x 31 hops x 30s ≈ 46 minutes by default).
+ // The reference hands `timeout` to curl's CURLOPT_TIMEOUT, which is a
+ // total budget, so bound the whole call too.
+ let budget = opts.timeout * TOTAL_BUDGET_MULTIPLIER;
+ let started = std::time::Instant::now();
+ let deadline = started + budget;
+ let mut last = String::new();
+ let attempts = opts.retries.max(0) as u32;
+ for i in 0..attempts {
+ if started.elapsed() >= budget {
+ return Err(if last.is_empty() {
+ format!("exceeded the total budget of {budget:?} fetching {url}")
+ } else {
+ last
+ });
+ }
+ match attempt(url, opts, policy, deadline).await {
+ Ok(page) => return Ok(page),
+ Err(e) => {
+ // A refused host is a verdict, not a flake: retrying just
+ // repeats the same DNS answer and the same rejection.
+ if e.contains("refusing to dial")
+ || e.contains("is in ")
+ || e.starts_with("scheme not allowed")
+ || e.starts_with("url is not a valid")
+ || e.starts_with("unsupported method")
+ {
+ return Err(e);
+ }
+ last = e;
+ if i + 1 < attempts && !opts.retry_delay.is_zero() {
+ tokio::time::sleep(opts.retry_delay).await;
+ }
+ }
+ }
+ }
+ if attempts == 0 {
+ Err("No active session available.".to_string())
+ } else {
+ Err(last)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+ use std::sync::mpsc;
+
+ fn options_with_proxy_for_validation(raw: &str) -> HttpOptions {
+ let mut options = HttpOptions::from_payload(&json!({})).unwrap();
+ options.proxy = Some(raw.to_string());
+ options
+ }
+
+ fn read_request(stream: &mut std::net::TcpStream) -> String {
+ let mut request = Vec::new();
+ let mut chunk = [0u8; 4096];
+ let mut expected = None;
+ loop {
+ let read = stream.read(&mut chunk).unwrap();
+ request.extend_from_slice(&chunk[..read]);
+ if expected.is_none() {
+ if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
+ let headers = String::from_utf8_lossy(&request[..end]);
+ let length = headers
+ .lines()
+ .find_map(|line| {
+ line.to_ascii_lowercase()
+ .strip_prefix("content-length:")
+ .and_then(|value| value.trim().parse::().ok())
+ })
+ .unwrap_or(0);
+ expected = Some(end + 4 + length);
+ }
+ }
+ if read == 0 || expected.is_some_and(|length| request.len() >= length) {
+ break;
+ }
+ }
+ String::from_utf8(request).unwrap()
+ }
+
+ fn safe_server() -> (String, mpsc::Receiver) {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ let (sender, receiver) = mpsc::channel();
+ std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ sender.send(read_request(&mut stream)).unwrap();
+ stream
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n",
+ )
+ .unwrap();
+ });
+ (format!("http://{address}/safe"), receiver)
+ }
+
+ #[test]
+ fn method_defaults_to_get_and_rejects_unknown() {
+ assert_eq!(HttpOptions::from_payload(&json!({})).unwrap().method, "get");
+ assert_eq!(
+ HttpOptions::from_payload(&json!({"method": "POST"}))
+ .unwrap()
+ .method,
+ "post"
+ );
+ assert_eq!(
+ HttpOptions::from_payload(&json!({"method": "patch"})).unwrap_err(),
+ "unsupported method: patch"
+ );
+ }
+
+ #[test]
+ fn safe_mode_refuses_network_options_it_cannot_enforce() {
+ let cases = [
+ (json!({"http3": true}), "http3"),
+ (json!({"verify": false}), "verify:false"),
+ (json!({"proxy": "http://proxy.test"}), "proxy"),
+ (
+ json!({"proxies": {"https": "http://proxy.test"}}),
+ "proxies",
+ ),
+ (json!({"proxy_auth": ["u", "p"]}), "proxy_auth"),
+ (json!({"stealthy_headers": true}), "stealthy_headers:true"),
+ ];
+ for (payload, option) in cases {
+ let error = HttpOptions::from_payload_for_mode(&payload, HttpMode::Safe).unwrap_err();
+ assert!(error.contains(option), "{option}: {error}");
+ }
+ assert!(HttpOptions::from_payload_for_mode(
+ &json!({"impersonate": "chrome"}),
+ HttpMode::Safe
+ )
+ .is_ok());
+ assert!(HttpOptions::from_payload_for_mode(
+ &json!({"impersonate": "firefox"}),
+ HttpMode::Safe
+ )
+ .unwrap_err()
+ .contains("impersonate"));
+ }
+
+ #[test]
+ fn ordered_fields_and_basic_auth_are_preserved() {
+ let options = HttpOptions::from_payload_for_mode(
+ &json!({
+ "headers": {"x-second": "2", "x-first": "1"},
+ "params": {"z": "last", "a": [1, 2]},
+ "cookies": {"second": "2", "first": "1"},
+ "auth": ["user", "pass"]
+ }),
+ HttpMode::Safe,
+ )
+ .unwrap();
+ assert_eq!(options.headers[0], ("x-second".into(), "2".into()));
+ assert_eq!(options.headers[1], ("x-first".into(), "1".into()));
+ assert_eq!(options.params[0], ("z".into(), "last".into()));
+ assert_eq!(options.params[1], ("a".into(), "1".into()));
+ assert_eq!(options.params[2], ("a".into(), "2".into()));
+ assert_eq!(options.cookies[0], ("second".into(), "2".into()));
+ assert_eq!(options.auth, Some(("user".into(), "pass".into())));
+ }
+
+ #[test]
+ fn params_match_curl_cffi_json_and_doseq_coercion() {
+ let options = HttpOptions::from_payload(&json!({
+ "params": {
+ "bool": true,
+ "none": null,
+ "obj": {"a": 1},
+ "arr": [true, null, {"z": 2}]
+ }
+ }))
+ .unwrap();
+ assert_eq!(
+ options.params,
+ vec![
+ ("bool".into(), "true".into()),
+ ("none".into(), "None".into()),
+ ("obj".into(), r#"{"a": 1}"#.into()),
+ ("arr".into(), "True".into()),
+ ("arr".into(), "None".into()),
+ ("arr".into(), "{'z': 2}".into()),
+ ]
+ );
+ }
+
+ #[test]
+ fn delete_accepts_the_same_body_inputs_as_post_and_put() {
+ let options = HttpOptions::from_payload_for_mode(
+ &json!({"method": "delete", "json": {"delete": true}}),
+ HttpMode::Safe,
+ )
+ .unwrap();
+ assert!(matches!(options.body, Some(Body::Json(_))));
+ assert!(options.method_allows_body());
+ }
+
+ #[test]
+ fn safe_refuses_unlimited_redirects_while_compat_preserves_minus_one() {
+ let error =
+ HttpOptions::from_payload_for_mode(&json!({"max_redirects": -1}), HttpMode::Safe)
+ .unwrap_err();
+ assert!(error.contains("max_redirects:-1"), "{error}");
+
+ let options =
+ HttpOptions::from_payload_for_mode(&json!({"max_redirects": -1}), HttpMode::Compat);
+ if cfg!(feature = "scrapling-compat") {
+ assert_eq!(options.unwrap().max_redirects, -1);
+ } else {
+ assert!(options.unwrap_err().contains("not compiled"));
+ }
+ }
+
+ #[cfg(feature = "scrapling-compat")]
+ #[test]
+ fn compat_option_errors_match_the_wrapper_oracle() {
+ assert_eq!(
+ HttpOptions::from_payload_for_mode(
+ &json!({"proxy": "http://a", "proxies": {"http": "http://b"}}),
+ HttpMode::Compat,
+ )
+ .unwrap_err(),
+ "Cannot specify both 'proxy' and 'proxies'"
+ );
+ assert_eq!(
+ HttpOptions::from_payload_for_mode(&json!({"auth": ["u"]}), HttpMode::Compat)
+ .unwrap_err(),
+ "not enough values to unpack (expected 2, got 1)"
+ );
+ }
+
+ #[test]
+ fn timeout_is_seconds_on_this_tier() {
+ let o = HttpOptions::from_payload(&json!({"timeout": 2.5})).unwrap();
+ assert_eq!(o.timeout, Duration::from_millis(2500));
+ // default 30s, and a nonsense value falls back rather than becoming 0
+ assert_eq!(
+ HttpOptions::from_payload(&json!({})).unwrap().timeout,
+ Duration::from_secs(30)
+ );
+ assert_eq!(
+ HttpOptions::from_payload(&json!({"timeout": 0}))
+ .unwrap()
+ .timeout,
+ Duration::from_secs(30)
+ );
+ }
+
+ #[test]
+ fn retries_preserve_zero_attempt_quirk() {
+ assert_eq!(HttpOptions::from_payload(&json!({})).unwrap().retries, 3);
+ assert_eq!(
+ HttpOptions::from_payload(&json!({"retries": 0}))
+ .unwrap()
+ .retries,
+ 0
+ );
+ }
+
+ #[cfg(feature = "scrapling-compat")]
+ #[test]
+ fn compat_preserves_signed_curl_values_and_retry_quirks() {
+ let timeout = HttpOptions::from_payload_for_mode(
+ &json!({"timeout": -1, "retries": 1, "stealthy_headers": false}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(timeout.compat_timeout_ms, -1000);
+
+ let redirects = HttpOptions::from_payload_for_mode(
+ &json!({"max_redirects": -2, "retries": 1, "stealthy_headers": false}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(redirects.max_redirects, -2);
+
+ let retries = HttpOptions::from_payload_for_mode(
+ &json!({"retries": -1, "stealthy_headers": false}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(retries.retries, -1);
+
+ let delay = HttpOptions::from_payload_for_mode(
+ &json!({"retry_delay": -1, "stealthy_headers": false}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert!(delay.retry_delay_negative);
+ }
+
+ #[cfg(feature = "scrapling-compat")]
+ #[test]
+ fn compat_does_not_silently_replace_explicit_empty_impersonation() {
+ let generated =
+ HttpOptions::from_payload_for_mode(&json!({"impersonate": ""}), HttpMode::Compat)
+ .unwrap();
+ assert_eq!(generated.impersonate, None);
+ assert!(generated
+ .headers
+ .iter()
+ .any(|(name, value)| name == "referer" && value == "https://www.google.com/"));
+ assert!(generated
+ .headers
+ .iter()
+ .any(|(name, value)| name == "User-Agent" && value.contains("Mozilla/5.0")));
+
+ let without_stealth = HttpOptions::from_payload_for_mode(
+ &json!({"impersonate": "", "stealthy_headers": false}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(without_stealth.impersonate, None);
+ assert_eq!(
+ without_stealth.headers,
+ vec![(
+ "User-Agent".into(),
+ crate::scrapling::browserforge::default_user_agent().unwrap()
+ )]
+ );
+ }
+
+ #[test]
+ fn stealthy_headers_fill_defaults_but_never_override_caller() {
+ let o = HttpOptions::from_payload(&json!({"headers": {"User-Agent": "mine"}})).unwrap();
+ assert_eq!(o.headers[0], ("User-Agent".into(), "mine".into()));
+ assert!(
+ !o.headers.iter().any(|(name, _)| name == "user-agent"),
+ "must not add a second, case-variant UA header"
+ );
+ assert!(o.headers.iter().any(|(name, _)| name == "accept"));
+
+ let off = HttpOptions::from_payload(&json!({"stealthy_headers": false})).unwrap();
+ assert!(off.headers.is_empty());
+ }
+
+ #[test]
+ fn json_body_wins_over_data_and_only_on_post_put() {
+ let o = HttpOptions::from_payload(&json!({"json": {"a": 1}, "data": {"b": 2}})).unwrap();
+ assert!(matches!(o.body, Some(Body::Json(_))));
+ }
+
+ #[test]
+ fn charset_parsed_from_content_type() {
+ let mut h = reqwest::header::HeaderMap::new();
+ h.insert(
+ reqwest::header::CONTENT_TYPE,
+ "text/html; charset=ISO-8859-1".parse().unwrap(),
+ );
+ assert_eq!(charset_of(&h).as_deref(), Some("iso-8859-1"));
+ assert_eq!(charset_of(&reqwest::header::HeaderMap::new()), None);
+ }
+
+ #[test]
+ fn streamed_body_limit_is_checked_even_without_content_length() {
+ assert_eq!(
+ checked_body_len(MAX_BODY_BYTES - 1, 1),
+ Some(MAX_BODY_BYTES)
+ );
+ assert_eq!(checked_body_len(MAX_BODY_BYTES, 1), None);
+ assert_eq!(checked_body_len(usize::MAX, 1), None);
+ }
+
+ #[test]
+ fn set_cookie_headers_flatten_to_name_value() {
+ let mut h = reqwest::header::HeaderMap::new();
+ h.append(
+ reqwest::header::SET_COOKIE,
+ "sid=abc; Path=/; HttpOnly".parse().unwrap(),
+ );
+ h.append(
+ reqwest::header::SET_COOKIE,
+ "theme=dark; Max-Age=60".parse().unwrap(),
+ );
+ let c = response_cookies(&h);
+ assert_eq!(c.get("sid").unwrap(), &json!("abc"));
+ assert_eq!(c.get("theme").unwrap(), &json!("dark"));
+ }
+
+ #[test]
+ fn absurd_durations_are_clamped_not_panicked_on() {
+ // `Duration::from_secs_f64` panics on an unrepresentable value, and
+ // the SDK spawns handlers detached — so a panic here would drop the
+ // invocation and hang the caller with no result at all.
+ for v in [1e20, f64::MAX, 1e308] {
+ let o = HttpOptions::from_payload(&json!({"timeout": v, "retry_delay": v})).unwrap();
+ assert!(o.timeout <= Duration::from_secs_f64(MAX_TIMEOUT_SECS));
+ assert!(o.retry_delay <= Duration::from_secs_f64(MAX_RETRY_DELAY_SECS));
+ }
+ // NaN and infinity fall back to the default rather than clamping.
+ let o = HttpOptions::from_payload(&json!({"timeout": f64::NAN})).unwrap();
+ assert_eq!(o.timeout, Duration::from_secs(30));
+ }
+
+ #[test]
+ fn sensitive_headers_are_recognised_case_insensitively() {
+ for h in [
+ "Authorization",
+ "authorization",
+ "COOKIE",
+ "Proxy-Authorization",
+ ] {
+ assert!(is_sensitive_header(h), "{h} must be treated as sensitive");
+ }
+ for h in ["accept", "user-agent", "x-custom"] {
+ assert!(!is_sensitive_header(h));
+ }
+ }
+
+ #[test]
+ fn same_origin_compares_scheme_host_and_effective_port() {
+ let u = |s: &str| url::Url::parse(s).unwrap();
+ assert!(same_origin(&u("https://a.test/x"), &u("https://a.test/y")));
+ // default port is the same origin as the explicit one
+ assert!(same_origin(
+ &u("https://a.test/"),
+ &u("https://a.test:443/")
+ ));
+ assert!(!same_origin(&u("https://a.test/"), &u("http://a.test/")));
+ assert!(!same_origin(&u("https://a.test/"), &u("https://b.test/")));
+ assert!(!same_origin(
+ &u("https://a.test/"),
+ &u("https://a.test:8443/")
+ ));
+ }
+
+ #[test]
+ fn redirect_method_and_body_rules_match_curl() {
+ for status in 301..=303 {
+ assert_eq!(
+ redirected_request(status, "post", true),
+ ("get".into(), false)
+ );
+ for method in ["put", "delete"] {
+ assert_eq!(
+ redirected_request(status, method, true),
+ (method.into(), false)
+ );
+ }
+ }
+ for status in [307, 308] {
+ for method in ["post", "put", "delete"] {
+ assert_eq!(
+ redirected_request(status, method, true),
+ (method.into(), true)
+ );
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn cross_origin_redirect_drops_credentials_body_and_initial_query() {
+ let destination = TcpListener::bind("127.0.0.1:0").unwrap();
+ let destination_address = destination.local_addr().unwrap();
+ let origin = TcpListener::bind("127.0.0.1:0").unwrap();
+ let origin_address = origin.local_addr().unwrap();
+ let (sender, requests) = mpsc::channel();
+
+ let first_sender = sender.clone();
+ std::thread::spawn(move || {
+ let (mut stream, _) = origin.accept().unwrap();
+ first_sender.send(read_request(&mut stream)).unwrap();
+ write!(
+ stream,
+ "HTTP/1.1 302 Found\r\nLocation: http://{destination_address}/end\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
+ )
+ .unwrap();
+ });
+ std::thread::spawn(move || {
+ let (mut stream, _) = destination.accept().unwrap();
+ sender.send(read_request(&mut stream)).unwrap();
+ stream
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+ .unwrap();
+ });
+
+ let options = HttpOptions::from_payload(&json!({
+ "method": "post",
+ "data": {"a": "b"},
+ "params": {"q": 1},
+ "auth": ["u", "p"],
+ "cookies": {"sid": "x"},
+ "headers": {"X-Test": "kept"},
+ "follow_redirects": true,
+ "retries": 1,
+ "stealthy_headers": false
+ }))
+ .unwrap();
+ let page = fetch_page(
+ &format!("http://{origin_address}/start"),
+ &options,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap();
+
+ let first = requests.recv().unwrap();
+ assert!(first.starts_with("POST /start?q=1 HTTP/1.1\r\n"), "{first}");
+ assert!(first
+ .to_ascii_lowercase()
+ .contains("authorization: basic dtpw"));
+ assert!(first.to_ascii_lowercase().contains("cookie: sid=x"));
+ assert!(first.ends_with("a=b"), "{first}");
+
+ let second = requests.recv().unwrap();
+ let lower = second.to_ascii_lowercase();
+ assert!(second.starts_with("GET /end HTTP/1.1\r\n"), "{second}");
+ assert!(!lower.contains("authorization:"), "{second}");
+ assert!(!lower.contains("cookie:"), "{second}");
+ assert!(lower.contains("x-test: kept"), "{second}");
+ assert!(!second.contains("?q=1"), "{second}");
+ assert_eq!(page.html, "ok");
+ }
+
+ #[tokio::test]
+ async fn redirect_to_metadata_is_refused_before_the_second_request() {
+ let origin = TcpListener::bind("127.0.0.1:0").unwrap();
+ let origin_address = origin.local_addr().unwrap();
+ std::thread::spawn(move || {
+ let (mut stream, _) = origin.accept().unwrap();
+ let _ = read_request(&mut stream);
+ stream
+ .write_all(
+ b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
+ )
+ .unwrap();
+ });
+ let options = HttpOptions::from_payload(&json!({
+ "follow_redirects": true,
+ "retries": 3,
+ "retry_delay": 0,
+ "stealthy_headers": false
+ }))
+ .unwrap();
+ let error = fetch_page(
+ &format!("http://{origin_address}/redirect"),
+ &options,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert!(error.contains("link-local"), "{error}");
+ }
+
+ #[tokio::test]
+ async fn a_private_proxy_is_refused_before_any_request() {
+ // The proxy is the address this worker actually dials, so validating
+ // only the (public) target would let an internal proxy be used as a
+ // pivot into the private network.
+ let opts = options_with_proxy_for_validation("http://169.254.169.254:3128");
+ let err = check_proxy(
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: false,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert!(err.starts_with("proxy refused:"), "got: {err}");
+ assert!(err.contains("link-local"), "got: {err}");
+ }
+
+ #[tokio::test]
+ async fn a_socks_proxy_is_refused_because_its_dns_is_uninspectable() {
+ let opts = options_with_proxy_for_validation("socks5h://127.0.0.1:1080");
+ let err = check_proxy(
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert!(err.contains("not a usable http(s) endpoint"), "got: {err}");
+ }
+
+ #[tokio::test]
+ async fn invalid_proxy_errors_do_not_echo_credentials() {
+ let raw = "http://user:pass@[";
+ let opts = options_with_proxy_for_validation(raw);
+ let check_err = check_proxy(
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ !check_err.contains("user:pass"),
+ "credentials leaked: {check_err}"
+ );
+ assert!(
+ check_err.contains("not a usable http(s) endpoint"),
+ "got: {check_err}"
+ );
+
+ let build_err =
+ match build_client("example.com", SocketAddr::from(([1, 1, 1, 1], 443)), &opts) {
+ Ok(_) => panic!("invalid proxy unexpectedly built a client"),
+ Err(error) => error,
+ };
+ assert!(
+ !build_err.contains("user:pass"),
+ "credentials leaked: {build_err}"
+ );
+ assert!(build_err.contains("invalid proxy"), "got: {build_err}");
+ }
+
+ #[tokio::test]
+ async fn no_proxy_configured_is_not_an_error() {
+ let opts = HttpOptions::from_payload(&json!({})).unwrap();
+ assert!(check_proxy(
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: false
+ }
+ )
+ .await
+ .is_ok());
+ }
+
+ #[tokio::test]
+ async fn a_public_proxy_passes_the_check() {
+ let opts = options_with_proxy_for_validation("http://1.1.1.1:8080");
+ assert!(check_proxy(
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: false
+ }
+ )
+ .await
+ .is_ok());
+ }
+
+ #[tokio::test]
+ async fn ssrf_rejection_is_not_retried_and_names_the_range() {
+ let opts = HttpOptions::from_payload(&json!({"retries": 3, "retry_delay": 0})).unwrap();
+ let policy = SsrfPolicy {
+ allow_loopback: false,
+ };
+ let err = fetch_page("http://169.254.169.254/latest/meta-data/", &opts, &policy)
+ .await
+ .unwrap_err();
+ assert!(err.contains("link-local"), "got: {err}");
+ }
+
+ #[tokio::test]
+ async fn safe_engine_streams_unknown_length_and_sends_delete_body() {
+ let (url, request) = safe_server();
+ let options = HttpOptions::from_payload(&json!({
+ "method": "delete",
+ "json": {"delete": true},
+ "stealthy_headers": false,
+ "retries": 1
+ }))
+ .unwrap();
+ let page = fetch_page(
+ &url,
+ &options,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap();
+ let raw = request.recv().unwrap();
+ assert!(raw.starts_with("DELETE /safe HTTP/1.1\r\n"), "{raw}");
+ assert!(raw.ends_with("{\"delete\":true}"), "{raw}");
+ assert_eq!(page.html, "hello");
+ }
+
+ #[tokio::test]
+ async fn fetches_a_real_page_over_the_wire() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = [0; 4096];
+ let _ = stream.read(&mut request).unwrap();
+ let body = b"Example Domain ";
+ write!(
+ stream,
+ "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ )
+ .unwrap();
+ stream.write_all(body).unwrap();
+ });
+ let url = format!("http://{address}/");
+ let opts = HttpOptions::from_payload(&json!({"stealthy_headers": false})).unwrap();
+ let page = fetch_page(
+ &url,
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .expect("hermetic origin should be reachable");
+
+ assert_eq!(page.status, Some(200));
+ assert_eq!(page.url, url);
+ assert_eq!(
+ page.html,
+ "Example Domain "
+ );
+ assert_eq!(page.headers["content-type"], "text/html; charset=utf-8");
+
+ // And the shared envelope on top of it: inline extraction + render.
+ let out = crate::scrapling::page::serialize_page(
+ &page,
+ &json!({"selectors": [{"name": "title", "css": "h1"}], "format": "text"}),
+ false,
+ )
+ .unwrap();
+ assert_eq!(out["extracted"]["title"], json!("Example Domain"));
+ assert_eq!(out["status"], json!(200));
+ assert_eq!(out["content"], "Example Domain");
+ }
+
+ #[tokio::test]
+ async fn non_http_scheme_refused() {
+ let opts = HttpOptions::from_payload(&json!({})).unwrap();
+ let err = fetch_page(
+ "file:///etc/passwd",
+ &opts,
+ &SsrfPolicy {
+ allow_loopback: true,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert!(err.contains("scheme not allowed"), "got: {err}");
+ }
+}
diff --git a/browser/src/scrapling/fetch/curl_compat.rs b/browser/src/scrapling/fetch/curl_compat.rs
new file mode 100644
index 000000000..30316b671
--- /dev/null
+++ b/browser/src/scrapling/fetch/curl_compat.rs
@@ -0,0 +1,961 @@
+use std::collections::HashMap;
+use std::ffi::{c_char, c_long, c_void, CStr, CString};
+use std::ptr;
+use std::sync::{Arc, Mutex, OnceLock};
+
+use curl_impersonate_sys as curl;
+use serde_json::{Map, Value};
+
+use super::{charset_of_raw, python_scalar, Body, HttpOptions};
+use crate::scrapling::page::PageData;
+
+static GLOBAL: OnceLock> = OnceLock::new();
+
+fn global_init() -> Result<(), String> {
+ GLOBAL
+ .get_or_init(|| {
+ let code = unsafe { curl::curl_global_init(curl::CURL_GLOBAL_DEFAULT) };
+ if code == curl::CURLE_OK {
+ Ok(())
+ } else {
+ Err(format!("curl_global_init failed with code {code}"))
+ }
+ })
+ .clone()
+}
+
+#[derive(Debug)]
+struct Easy(*mut curl::CURL);
+
+// libcurl permits moving an easy handle between threads as long as only one
+// thread uses it at a time. CompatSession's mutex provides that exclusion.
+unsafe impl Send for Easy {}
+
+impl Easy {
+ fn new() -> Result {
+ global_init()?;
+ let handle = unsafe { curl::curl_easy_init() };
+ if handle.is_null() {
+ Err("curl_easy_init returned null".to_string())
+ } else {
+ Ok(Self(handle))
+ }
+ }
+}
+
+impl Drop for Easy {
+ fn drop(&mut self) {
+ unsafe { curl::curl_easy_cleanup(self.0) };
+ }
+}
+
+#[derive(Debug)]
+struct SessionState {
+ easy: Easy,
+ cookies: Vec,
+}
+
+impl SessionState {
+ fn new() -> Result {
+ Ok(Self {
+ easy: Easy::new()?,
+ cookies: Vec::new(),
+ })
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct CompatSession(Arc>);
+
+impl CompatSession {
+ pub(crate) fn new() -> Result {
+ SessionState::new().map(|state| Self(Arc::new(Mutex::new(state))))
+ }
+}
+
+struct Slist(*mut curl::curl_slist);
+
+impl Slist {
+ fn new() -> Self {
+ Self(ptr::null_mut())
+ }
+
+ fn append(&mut self, value: &CString) -> Result<(), String> {
+ let next = unsafe { curl::curl_slist_append(self.0, value.as_ptr()) };
+ if next.is_null() {
+ Err("curl_slist_append ran out of memory".to_string())
+ } else {
+ self.0 = next;
+ Ok(())
+ }
+ }
+}
+
+impl Drop for Slist {
+ fn drop(&mut self) {
+ if !self.0.is_null() {
+ unsafe { curl::curl_slist_free_all(self.0) };
+ }
+ }
+}
+
+#[derive(Default)]
+struct Transfer {
+ body: Vec,
+ headers: Vec,
+}
+
+unsafe extern "C" fn write_body(
+ data: *mut c_char,
+ size: usize,
+ count: usize,
+ userdata: *mut c_void,
+) -> usize {
+ let Some(length) = size.checked_mul(count) else {
+ return 0;
+ };
+ let transfer = &mut *(userdata.cast::());
+ transfer
+ .body
+ .extend_from_slice(std::slice::from_raw_parts(data.cast::(), length));
+ length
+}
+
+unsafe extern "C" fn write_header(
+ data: *mut c_char,
+ size: usize,
+ count: usize,
+ userdata: *mut c_void,
+) -> usize {
+ let Some(length) = size.checked_mul(count) else {
+ return 0;
+ };
+ let transfer = &mut *(userdata.cast::());
+ transfer
+ .headers
+ .extend_from_slice(std::slice::from_raw_parts(data.cast::(), length));
+ length
+}
+
+unsafe fn set_long(easy: &Easy, option: curl::CURLoption, value: c_long) -> Result<(), String> {
+ code(curl::curl_easy_setopt(easy.0, option, value))
+}
+
+unsafe fn set_ptr(easy: &Easy, option: curl::CURLoption, value: *mut c_void) -> Result<(), String> {
+ code(curl::curl_easy_setopt(easy.0, option, value))
+}
+
+unsafe fn set_str(easy: &Easy, option: curl::CURLoption, value: &CString) -> Result<(), String> {
+ code(curl::curl_easy_setopt(easy.0, option, value.as_ptr()))
+}
+
+fn code(value: curl::CURLcode) -> Result<(), String> {
+ if value == curl::CURLE_OK {
+ Ok(())
+ } else {
+ let detail = unsafe { CStr::from_ptr(curl::curl_easy_strerror(value)) }.to_string_lossy();
+ Err(format!("curl: ({value}) {detail}"))
+ }
+}
+
+fn impersonation_alias(value: &str) -> &str {
+ match value {
+ "chrome" => "chrome146",
+ "edge" => "edge101",
+ "safari" | "safari_beta" => "safari2601",
+ "safari_ios" | "safari_ios_beta" => "safari260_ios",
+ "chrome_android" => "chrome131_android",
+ "firefox" => "firefox147",
+ "tor" => "tor145",
+ other => other,
+ }
+}
+
+fn request_url(url: &str, params: &[(String, String)]) -> Result {
+ let mut parsed = url::Url::parse(url).map_err(|error| error.to_string())?;
+ let mut merged: Vec<(String, String)> = parsed
+ .query_pairs()
+ .map(|(name, value)| (name.into_owned(), value.into_owned()))
+ .collect();
+ let mut old_counts = HashMap::new();
+ let mut new_counts = HashMap::new();
+ for (name, _) in &merged {
+ *old_counts.entry(name.clone()).or_insert(0usize) += 1;
+ }
+ for (name, _) in params {
+ *new_counts.entry(name.clone()).or_insert(0usize) += 1;
+ }
+ for (name, value) in params {
+ if old_counts.get(name.as_str()) == Some(&1) && new_counts.get(name.as_str()) == Some(&1) {
+ if let Some(existing) = merged.iter_mut().find(|(key, _)| key == name) {
+ existing.1 = value.clone();
+ continue;
+ }
+ }
+ merged.push((name.clone(), value.clone()));
+ }
+ if !params.is_empty() {
+ parsed.query_pairs_mut().clear().extend_pairs(merged.iter());
+ }
+ CString::new(parsed.as_str()).map_err(|_| "URL contains a NUL byte".to_string())
+}
+
+fn selected_proxy<'a>(url: &str, options: &'a HttpOptions) -> Option<&'a str> {
+ if let Some(proxy) = options.proxy.as_deref() {
+ return Some(proxy);
+ }
+ let parsed = url::Url::parse(url).ok()?;
+ let scheme = parsed.scheme();
+ let host = parsed.host_str();
+ let lookup = |wanted: &str| {
+ options
+ .proxies
+ .iter()
+ .find(|(key, _)| key == wanted)
+ .map(|(_, value)| value.as_str())
+ };
+ if let Some(host) = host {
+ if let Some(proxy) =
+ lookup(&format!("{scheme}://{host}")).or_else(|| lookup(&format!("all://{host}")))
+ {
+ return Some(proxy);
+ }
+ }
+ lookup(scheme).or_else(|| lookup("all"))
+}
+
+fn body_bytes(body: &Body) -> Result<(Vec, &'static str), String> {
+ match body {
+ Body::Json(value) => serde_json::to_vec(value)
+ .map(|bytes| (bytes, "application/json"))
+ .map_err(|error| error.to_string()),
+ Body::Form(Value::Object(values)) => {
+ let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+ for (name, value) in values {
+ serializer.append_pair(name, &python_scalar(value));
+ }
+ Ok((
+ serializer.finish().into_bytes(),
+ "application/x-www-form-urlencoded",
+ ))
+ }
+ Body::Form(value) => Ok((
+ python_scalar(value).into_bytes(),
+ "application/octet-stream",
+ )),
+ }
+}
+
+fn response_headers(raw: &[u8]) -> Map {
+ let text = String::from_utf8_lossy(raw);
+ let block = text
+ .rsplit("\r\n\r\n")
+ .find(|block| block.starts_with("HTTP/"))
+ .unwrap_or("");
+ let mut headers = Map::new();
+ for line in block.lines().skip(1) {
+ if let Some((name, value)) = line.split_once(':') {
+ let name = name.trim().to_ascii_lowercase();
+ let value = value.trim();
+ if let Some(existing) = headers.get(&name).and_then(Value::as_str) {
+ let combined = format!("{existing}, {value}");
+ headers.insert(name, combined.into());
+ } else {
+ headers.insert(name, value.into());
+ }
+ }
+ }
+ headers
+}
+
+fn response_cookies(raw: &[u8]) -> Map {
+ let mut cookies = Map::new();
+ let text = String::from_utf8_lossy(raw);
+ let block = text
+ .rsplit("\r\n\r\n")
+ .find(|block| block.starts_with("HTTP/"))
+ .unwrap_or("");
+ for line in block.lines().skip(1) {
+ // Header names are case-insensitive; match any casing, not just the
+ // two common spellings.
+ let Some(value) = line
+ .split_once(':')
+ .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
+ .map(|(_, value)| value)
+ else {
+ continue;
+ };
+ if let Some((name, value)) = value.trim().split(';').next().unwrap_or("").split_once('=') {
+ cookies.insert(name.trim().to_string(), value.trim().into());
+ }
+ }
+ cookies
+}
+
+fn cookie_key(line: &str) -> Option<(&str, &str, &str)> {
+ let fields = line.split('\t').collect::>();
+ (fields.len() == 7).then(|| (fields[0], fields[2], fields[5]))
+}
+
+fn apply_cookie_changes(cookies: &mut Vec, changes: *mut curl::curl_slist) {
+ let mut current = changes;
+ while !current.is_null() {
+ let raw = unsafe { CStr::from_ptr((*current).data) }.to_string_lossy();
+ if let Some((action, value)) = raw.split_once('\t') {
+ if let Some(key) = cookie_key(value) {
+ cookies.retain(|existing| cookie_key(existing) != Some(key));
+ if action == "SET" {
+ cookies.push(value.to_string());
+ }
+ }
+ }
+ current = unsafe { (*current).next };
+ }
+ if !changes.is_null() {
+ unsafe { curl::curl_slist_free_all(changes) };
+ }
+}
+
+fn request_cookie_lines(url: &str, options: &HttpOptions) -> Result, String> {
+ let parsed = url::Url::parse(url).map_err(|error| error.to_string())?;
+ let mut host = parsed
+ .host_str()
+ .ok_or_else(|| "URL has no host".to_string())?
+ .to_ascii_lowercase();
+ if !host.contains('.') && host.parse::().is_err() {
+ host.push_str(".local");
+ }
+ options
+ .cookies
+ .iter()
+ .map(|(name, value)| {
+ CString::new(format!("{host}\tFALSE\t/\tFALSE\t0\t{name}\t{value}"))
+ .map_err(|_| "cookie contains a NUL byte".to_string())
+ })
+ .collect()
+}
+
+fn one_attempt(
+ url: &str,
+ options: &HttpOptions,
+ session: &mut SessionState,
+) -> Result {
+ // curl-cffi reports setopt failures with the option number, caller value,
+ // and libcurl's option hex. Keep these validations ahead of handle setup
+ // so the Rust binding has the same deterministic error without relying on
+ // undefined behavior at the variadic FFI boundary.
+ if options.compat_timeout_ms < 0 {
+ return Err(format!(
+ "Failed to setopt 155 {}, curl: (43) setopt 0x9b got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.",
+ options.compat_timeout_ms
+ ));
+ }
+ if options.max_redirects < -1 {
+ return Err(format!(
+ "Failed to setopt 68 {}, curl: (43) setopt 0x44 got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.",
+ options.max_redirects
+ ));
+ }
+ let easy = &session.easy;
+ unsafe { curl::curl_easy_reset(easy.0) };
+ let final_url = request_url(url, &options.params)?;
+ let mut keepalive = vec![final_url.clone()];
+ let mut transfer = Transfer::default();
+ let mut error_buffer = [0u8; 256];
+
+ unsafe {
+ set_str(easy, curl::CURLOPT_URL, &final_url)?;
+ set_long(easy, curl::CURLOPT_NOSIGNAL, 1)?;
+ set_long(
+ easy,
+ curl::CURLOPT_TIMEOUT_MS,
+ options.compat_timeout_ms as c_long,
+ )?;
+ set_long(
+ easy,
+ curl::CURLOPT_FOLLOWLOCATION,
+ options.compat_follow_redirects as c_long,
+ )?;
+ set_long(
+ easy,
+ curl::CURLOPT_MAXREDIRS,
+ options.max_redirects as c_long,
+ )?;
+ let accept_encoding = CString::new("gzip, deflate, br, zstd").unwrap();
+ set_str(easy, curl::CURLOPT_ACCEPT_ENCODING, &accept_encoding)?;
+ keepalive.push(accept_encoding);
+ set_ptr(
+ easy,
+ curl::CURLOPT_ERRORBUFFER,
+ error_buffer.as_mut_ptr().cast(),
+ )?;
+ set_ptr(
+ easy,
+ curl::CURLOPT_WRITEDATA,
+ (&mut transfer as *mut Transfer).cast(),
+ )?;
+ code(curl::curl_easy_setopt(
+ easy.0,
+ curl::CURLOPT_WRITEFUNCTION,
+ write_body as unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize,
+ ))?;
+ set_ptr(
+ easy,
+ curl::CURLOPT_HEADERDATA,
+ (&mut transfer as *mut Transfer).cast(),
+ )?;
+ code(curl::curl_easy_setopt(
+ easy.0,
+ curl::CURLOPT_HEADERFUNCTION,
+ write_header as unsafe extern "C" fn(*mut c_char, usize, usize, *mut c_void) -> usize,
+ ))?;
+ }
+
+ let empty = CString::new("").unwrap();
+ let clear = CString::new("ALL").unwrap();
+ let session_cookies = session
+ .cookies
+ .iter()
+ .map(|value| CString::new(value.as_str()).expect("libcurl cookie lines contain no NUL"))
+ .collect::>();
+ let request_cookies = request_cookie_lines(url, options)?;
+ unsafe {
+ set_str(easy, curl::CURLOPT_COOKIEFILE, &empty)?;
+ set_str(easy, curl::CURLOPT_COOKIELIST, &clear)?;
+ for cookie in session_cookies.iter().chain(&request_cookies) {
+ set_str(easy, curl::CURLOPT_COOKIELIST, cookie)?;
+ }
+ }
+ keepalive.extend([empty, clear]);
+
+ if let Some(target) = options.impersonate.as_deref() {
+ let target = CString::new(impersonation_alias(target))
+ .map_err(|_| "impersonate contains a NUL byte".to_string())?;
+ let result = unsafe { curl::curl_easy_impersonate(easy.0, target.as_ptr(), 1) };
+ if result != curl::CURLE_OK {
+ return Err(format!(
+ "Impersonating {} is not supported",
+ options.impersonate.as_deref().unwrap_or_default()
+ ));
+ }
+ keepalive.push(target);
+ }
+ unsafe {
+ if options.http3 {
+ set_long(
+ easy,
+ curl::CURLOPT_HTTP_VERSION,
+ curl::CURL_HTTP_VERSION_3ONLY,
+ )?;
+ }
+ set_long(easy, curl::CURLOPT_SSL_VERIFYPEER, options.verify as c_long)?;
+ set_long(
+ easy,
+ curl::CURLOPT_SSL_VERIFYHOST,
+ if options.verify { 2 } else { 0 },
+ )?;
+ }
+
+ for (option, credentials, auth_option) in [
+ (
+ curl::CURLOPT_USERPWD,
+ options.auth.as_ref(),
+ curl::CURLOPT_HTTPAUTH,
+ ),
+ (
+ curl::CURLOPT_PROXYUSERPWD,
+ options.proxy_auth.as_ref(),
+ curl::CURLOPT_PROXYAUTH,
+ ),
+ ] {
+ if let Some((username, password)) = credentials {
+ let value = CString::new(format!("{username}:{password}"))
+ .map_err(|_| "credentials contain a NUL byte".to_string())?;
+ unsafe {
+ set_str(easy, option, &value)?;
+ set_long(easy, auth_option, curl::CURLAUTH_BASIC)?;
+ }
+ keepalive.push(value);
+ }
+ }
+ if let Some(proxy) = selected_proxy(url, options) {
+ let value = CString::new(proxy).map_err(|_| "proxy contains a NUL byte".to_string())?;
+ unsafe { set_str(easy, curl::CURLOPT_PROXY, &value)? };
+ keepalive.push(value);
+ }
+
+ let mut header_values = Vec::new();
+ let mut headers = Slist::new();
+ for (name, value) in &options.headers {
+ let line = if value.is_empty() {
+ format!("{name};")
+ } else {
+ format!("{name}: {value}")
+ };
+ let value = CString::new(line).map_err(|_| format!("header {name} contains a NUL byte"))?;
+ headers.append(&value)?;
+ header_values.push(value);
+ }
+ let expect = CString::new("Expect:").unwrap();
+ headers.append(&expect)?;
+ header_values.push(expect);
+
+ let mut body = None;
+ if options.method_allows_body()
+ && (options.body.is_some() || matches!(options.method.as_str(), "post" | "put"))
+ {
+ let (bytes, content_type) = match &options.body {
+ Some(value) => {
+ let (bytes, content_type) = body_bytes(value)?;
+ (bytes, Some(content_type))
+ }
+ None => (Vec::new(), None),
+ };
+ if let Some(content_type) = content_type {
+ if !options
+ .headers
+ .iter()
+ .any(|(name, _)| name.eq_ignore_ascii_case("content-type"))
+ {
+ let value = CString::new(format!("Content-Type: {content_type}")).unwrap();
+ headers.append(&value)?;
+ header_values.push(value);
+ }
+ }
+ unsafe {
+ set_ptr(
+ easy,
+ curl::CURLOPT_POSTFIELDS,
+ bytes.as_ptr().cast_mut().cast(),
+ )?;
+ set_long(easy, curl::CURLOPT_POSTFIELDSIZE, bytes.len() as c_long)?;
+ }
+ body = Some(bytes);
+ }
+ let method = CString::new(options.method.to_ascii_uppercase()).unwrap();
+ if options.method != "get" {
+ unsafe { set_str(easy, curl::CURLOPT_CUSTOMREQUEST, &method)? };
+ }
+ if !headers.0.is_null() {
+ unsafe { set_ptr(easy, curl::CURLOPT_HTTPHEADER, headers.0.cast())? };
+ }
+
+ let result = unsafe { curl::curl_easy_perform(easy.0) };
+ let mut changes = ptr::null_mut();
+ if unsafe { curl::curl_easy_getinfo(easy.0, curl::CURLINFO_COOKIECHANGES, &mut changes) }
+ == curl::CURLE_OK
+ {
+ apply_cookie_changes(&mut session.cookies, changes);
+ }
+ drop((body, method, header_values, keepalive));
+ if result != curl::CURLE_OK {
+ let detail = error_buffer
+ .split(|byte| *byte == 0)
+ .next()
+ .and_then(|bytes| std::str::from_utf8(bytes).ok())
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned)
+ .unwrap_or_else(|| unsafe {
+ CStr::from_ptr(curl::curl_easy_strerror(result))
+ .to_string_lossy()
+ .into_owned()
+ });
+ return Err(format!(
+ "Failed to perform, curl: ({result}) {detail}. See https://curl.se/libcurl/c/libcurl-errors.html first for more details."
+ ));
+ }
+
+ let mut status = 0 as c_long;
+ let mut effective: *mut c_char = ptr::null_mut();
+ unsafe {
+ code(curl::curl_easy_getinfo(
+ easy.0,
+ curl::CURLINFO_RESPONSE_CODE,
+ &mut status,
+ ))?;
+ code(curl::curl_easy_getinfo(
+ easy.0,
+ curl::CURLINFO_EFFECTIVE_URL,
+ &mut effective,
+ ))?;
+ }
+ let effective = if effective.is_null() {
+ url.to_string()
+ } else {
+ unsafe { CStr::from_ptr(effective) }
+ .to_string_lossy()
+ .into_owned()
+ };
+ let headers = response_headers(&transfer.headers);
+ let encoding = charset_of_raw(&headers).or_else(|| Some("utf-8".to_string()));
+ let html = super::decode_body(&transfer.body, encoding.as_deref());
+ Ok(PageData {
+ status: u16::try_from(status).ok(),
+ url: effective,
+ headers,
+ cookies: response_cookies(&transfer.headers),
+ encoding,
+ html,
+ captured_xhr: vec![],
+ })
+}
+
+fn blocking_fetch_in_session(
+ url: &str,
+ options: &HttpOptions,
+ session: &mut SessionState,
+) -> Result {
+ let attempts = options.retries.max(0) as usize;
+ if attempts == 0 {
+ return Err("No active session available.".to_string());
+ }
+ let mut last = String::new();
+ for attempt in 0..attempts {
+ match one_attempt(url, options, session) {
+ Ok(page) => return Ok(page),
+ Err(error) => {
+ last = error;
+ if attempt + 1 < attempts && !options.retry_delay.is_zero() {
+ std::thread::sleep(options.retry_delay);
+ } else if attempt + 1 < attempts && options.retry_delay_negative {
+ return Err("sleep length must be non-negative".to_string());
+ }
+ }
+ }
+ }
+ Err(last)
+}
+
+fn blocking_fetch(url: &str, options: &HttpOptions) -> Result {
+ let mut session = SessionState::new()?;
+ blocking_fetch_in_session(url, options, &mut session)
+}
+
+pub async fn fetch_page(url: String, options: HttpOptions) -> Result {
+ tokio::task::spawn_blocking(move || blocking_fetch(&url, &options))
+ .await
+ .map_err(|error| format!("curl worker failed: {error}"))?
+}
+
+pub(crate) async fn fetch_page_with_session(
+ url: String,
+ options: HttpOptions,
+ session: CompatSession,
+) -> Result {
+ tokio::task::spawn_blocking(move || {
+ let mut session = session
+ .0
+ .lock()
+ .map_err(|_| "curl session lock is poisoned".to_string())?;
+ blocking_fetch_in_session(&url, &options, &mut session)
+ })
+ .await
+ .map_err(|error| format!("curl worker failed: {error}"))?
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+ use std::sync::mpsc;
+
+ use serde_json::json;
+
+ use super::*;
+ use crate::scrapling::fetch::HttpMode;
+
+ fn one_request_server() -> (String, mpsc::Receiver) {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ let (sender, receiver) = mpsc::channel();
+ std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut bytes = Vec::new();
+ let mut chunk = [0u8; 4096];
+ let mut expected = None;
+ loop {
+ let read = stream.read(&mut chunk).unwrap();
+ if read == 0 {
+ break;
+ }
+ bytes.extend_from_slice(&chunk[..read]);
+ if expected.is_none() {
+ if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
+ let headers = String::from_utf8_lossy(&bytes[..end]);
+ let length = headers
+ .lines()
+ .find_map(|line| {
+ line.to_ascii_lowercase()
+ .strip_prefix("content-length:")
+ .and_then(|value| value.trim().parse::().ok())
+ })
+ .unwrap_or(0);
+ expected = Some(end + 4 + length);
+ }
+ }
+ if expected.is_some_and(|length| bytes.len() >= length) {
+ break;
+ }
+ }
+ sender.send(String::from_utf8(bytes).unwrap()).unwrap();
+ stream
+ .write_all(
+ b"HTTP/1.1 201 Created\r\nContent-Type: text/plain; charset=iso-8859-1\r\nSet-Cookie: sid=abc; Path=/\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK",
+ )
+ .unwrap();
+ });
+ (format!("http://{address}/endpoint"), receiver)
+ }
+
+ fn cookie_server(count: usize) -> (String, mpsc::Receiver) {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ let (sender, receiver) = mpsc::channel();
+ std::thread::spawn(move || {
+ for index in 0..count {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = Vec::new();
+ let mut chunk = [0u8; 4096];
+ while !request.windows(4).any(|part| part == b"\r\n\r\n") {
+ let read = stream.read(&mut chunk).unwrap();
+ request.extend_from_slice(&chunk[..read]);
+ }
+ sender.send(String::from_utf8(request).unwrap()).unwrap();
+ let cookie = if index == 0 {
+ "Set-Cookie: stored=server; Path=/\r\n"
+ } else {
+ ""
+ };
+ write!(
+ stream,
+ "HTTP/1.1 200 OK\r\n{cookie}Content-Length: 2\r\nConnection: close\r\n\r\nok"
+ )
+ .unwrap();
+ }
+ });
+ (format!("http://{address}"), receiver)
+ }
+
+ #[tokio::test]
+ async fn persistent_session_reuses_server_cookies_but_not_request_cookies() {
+ let (base, requests) = cookie_server(3);
+ let session = CompatSession::new().unwrap();
+ let base_options = HttpOptions::from_payload_for_mode(
+ &json!({"impersonate":"", "stealthy_headers":false, "retries":1}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ fetch_page_with_session(format!("{base}/set"), base_options.clone(), session.clone())
+ .await
+ .unwrap();
+ let mut temporary = base_options.clone();
+ temporary.cookies = vec![("once".to_string(), "request".to_string())];
+ fetch_page_with_session(format!("{base}/temporary"), temporary, session.clone())
+ .await
+ .unwrap();
+ fetch_page_with_session(format!("{base}/again"), base_options, session)
+ .await
+ .unwrap();
+
+ let first = requests.recv().unwrap();
+ let second = requests.recv().unwrap().to_ascii_lowercase();
+ let third = requests.recv().unwrap().to_ascii_lowercase();
+ assert!(!first.to_ascii_lowercase().contains("cookie:"));
+ assert!(second.contains("stored=server"), "{second}");
+ assert!(second.contains("once=request"), "{second}");
+ assert!(third.contains("stored=server"), "{third}");
+ assert!(!third.contains("once=request"), "{third}");
+ }
+
+ #[test]
+ fn compat_sends_delete_body_and_preserves_ordered_inputs() {
+ let (url, request) = one_request_server();
+ let payload = json!({
+ "method": "delete",
+ "json": {"delete": true},
+ "params": {"b": [2, 3], "a": "x"},
+ "headers": {"x-second": "2", "x-first": "1"},
+ "cookies": {"second": "2", "first": "1"},
+ "auth": ["user", "pass"],
+ "impersonate": "chrome136",
+ "stealthy_headers": false,
+ "retries": 1,
+ "include_html": true
+ });
+ let options = HttpOptions::from_payload_for_mode(&payload, HttpMode::Compat).unwrap();
+
+ let page = blocking_fetch(&url, &options).unwrap();
+ let raw = request.recv().unwrap();
+ assert!(
+ raw.starts_with("DELETE /endpoint?b=2&b=3&a=x HTTP/1.1\r\n"),
+ "{raw}"
+ );
+ assert!(raw.ends_with("{\"delete\":true}"), "{raw}");
+ assert!(
+ raw.contains("Authorization: Basic dXNlcjpwYXNz\r\n"),
+ "{raw}"
+ );
+ assert!(raw.contains("Cookie: second=2; first=1\r\n"), "{raw}");
+ assert!(raw.find("x-second: 2").unwrap() < raw.find("x-first: 1").unwrap());
+ assert_eq!(page.status, Some(201));
+ assert_eq!(page.cookies["sid"], json!("abc"));
+ assert_eq!(page.encoding.as_deref(), Some("iso-8859-1"));
+ assert_eq!(page.html, "OK");
+ let envelope = crate::scrapling::page::serialize_page(&page, &payload, true).unwrap();
+ assert_eq!(envelope["status"], json!(201));
+ assert_eq!(envelope["url"], json!(format!("{url}?b=2&b=3&a=x")));
+ assert_eq!(envelope["headers"]["set-cookie"], json!("sid=abc; Path=/"));
+ assert_eq!(envelope["cookies"]["sid"], json!("abc"));
+ assert_eq!(envelope["encoding"], json!("iso-8859-1"));
+ // HTML normalization belongs to the shared compatibility DOM. The
+ // HTTP engine contract at this boundary is the decoded response body.
+ assert_eq!(page.html, "OK");
+ }
+
+ #[test]
+ fn unsupported_impersonation_matches_oracle_error() {
+ let options = HttpOptions::from_payload_for_mode(
+ &json!({"impersonate": "bogus", "retries": 1}),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(
+ blocking_fetch("http://127.0.0.1:1", &options).unwrap_err(),
+ "Impersonating bogus is not supported"
+ );
+ }
+
+ #[test]
+ fn invalid_signed_setopt_values_match_oracle_errors() {
+ let timeout = HttpOptions::from_payload_for_mode(
+ &json!({
+ "timeout": -1,
+ "retries": 1,
+ "impersonate": "chrome136",
+ "stealthy_headers": false
+ }),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(
+ blocking_fetch("http://127.0.0.1:1", &timeout).unwrap_err(),
+ "Failed to setopt 155 -1000, curl: (43) setopt 0x9b got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details."
+ );
+
+ let redirects = HttpOptions::from_payload_for_mode(
+ &json!({
+ "max_redirects": -2,
+ "retries": 1,
+ "impersonate": "chrome136",
+ "stealthy_headers": false
+ }),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(
+ blocking_fetch("http://127.0.0.1:1", &redirects).unwrap_err(),
+ "Failed to setopt 68 -2, curl: (43) setopt 0x44 got bad argument. See https://curl.se/libcurl/c/libcurl-errors.html first for more details."
+ );
+ }
+
+ #[test]
+ fn negative_retry_values_match_oracle_errors() {
+ let retries = HttpOptions::from_payload_for_mode(
+ &json!({
+ "retries": -1,
+ "impersonate": "chrome136",
+ "stealthy_headers": false
+ }),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(
+ blocking_fetch("http://127.0.0.1:1", &retries).unwrap_err(),
+ "No active session available."
+ );
+
+ let retry_delay = HttpOptions::from_payload_for_mode(
+ &json!({
+ "retries": 2,
+ "retry_delay": -1,
+ "impersonate": "chrome136",
+ "stealthy_headers": false
+ }),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ assert_eq!(
+ blocking_fetch("http://127.0.0.1:1", &retry_delay).unwrap_err(),
+ "sleep length must be non-negative"
+ );
+ }
+
+ #[test]
+ fn form_values_use_python_urlencode_coercion() {
+ let body = Body::Form(json!({
+ "bool": true,
+ "none": null,
+ "obj": {"a": 1},
+ "arr": [1, 2]
+ }));
+ let (bytes, content_type) = body_bytes(&body).unwrap();
+ assert_eq!(content_type, "application/x-www-form-urlencoded");
+ assert_eq!(
+ String::from_utf8(bytes).unwrap(),
+ "bool=True&none=None&obj=%7B%27a%27%3A+1%7D&arr=%5B1%2C+2%5D"
+ );
+ }
+
+ #[test]
+ fn only_final_response_headers_and_cookies_are_exposed() {
+ let raw = b"HTTP/1.1 302 Found\r\nSet-Cookie: stale=1\r\nX-Hop: first\r\n\r\nHTTP/1.1 200 OK\r\nSet-Cookie: final=2\r\nSet-Cookie: other=3\r\nX-Hop: second\r\nX-Hop: third\r\n\r\n";
+ let headers = response_headers(raw);
+ let cookies = response_cookies(raw);
+ assert_eq!(headers["x-hop"], json!("second, third"));
+ assert_eq!(headers["set-cookie"], json!("final=2, other=3"));
+ assert_eq!(
+ cookies,
+ json!({"final": "2", "other": "3"})
+ .as_object()
+ .unwrap()
+ .clone()
+ );
+ }
+
+ #[test]
+ fn compat_decodes_content_encoding_like_curl_cffi() {
+ const GZIP_OK: &[u8] = &[
+ 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xf3, 0xf7, 0x06, 0x00,
+ 0x2d, 0xd9, 0x36, 0xd7, 0x02, 0x00, 0x00, 0x00,
+ ];
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = [0u8; 8192];
+ let _ = stream.read(&mut request).unwrap();
+ stream
+ .write_all(
+ format!(
+ "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ GZIP_OK.len()
+ )
+ .as_bytes(),
+ )
+ .unwrap();
+ stream.write_all(GZIP_OK).unwrap();
+ });
+ let options = HttpOptions::from_payload_for_mode(
+ &json!({
+ "retries": 1,
+ "impersonate": "chrome136",
+ "stealthy_headers": false
+ }),
+ HttpMode::Compat,
+ )
+ .unwrap();
+ let page = blocking_fetch(&format!("http://{address}/gzip"), &options).unwrap();
+ assert_eq!(page.html, "OK");
+ assert_eq!(page.encoding.as_deref(), Some("utf-8"));
+ }
+}
diff --git a/browser/src/scrapling/inject_guidance.rs b/browser/src/scrapling/inject_guidance.rs
new file mode 100644
index 000000000..0b5003ffa
--- /dev/null
+++ b/browser/src/scrapling/inject_guidance.rs
@@ -0,0 +1,132 @@
+//! harness::hook::pre-generate guidance: teach agents the scrapling surface —
+//! which fetch tier to reach for, what needs no browser at all, and which
+//! capabilities this worker does NOT have. Bound with on_error: fail_open
+//! (pre_generate defaults fail-CLOSED and a missing guidance line must never
+//! abort a turn).
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+pub const GUIDANCE_HOOK_ID: &str = "browser::inject-guidance";
+pub const GUIDANCE_HOOK_DESC: &str =
+ "Internal: appends browser::* scraping and HTML parsing guidance to the agent system prompt.";
+
+pub const GUIDANCE: &str = "\
+## Scraping and HTML parsing (browser::*)
+Use `browser::fetch` for plain HTTP, `browser::dynamic-fetch` for JavaScript, \
+and `browser::stealthy-fetch` for automation masking. Use \
+`browser::screenshot-url` to capture a URL; `browser::screenshot` captures an \
+existing interactive session. `browser::session-open`, \
+`browser::session-fetch`, `browser::session-close`, and \
+`browser::session-list` preserve cookies and browser state. `browser::crawl` \
+walks same-domain links and streams results. For HTML already in hand, use \
+`browser::extract`, `browser::css`, `browser::xpath`, `browser::regex`, \
+`browser::find`, `browser::find-by-text`, `browser::find-by-regex`, \
+`browser::find-similar`, `browser::describe`, or `browser::to-markdown`. \
+Fetching functions require approval; parse functions do not. Adaptive \
+queries persist identities in SQLite. `solve_cloudflare` is supported by \
+stealthy fetches; use `browser::handoff` for human-only steps in an \
+interactive session.";
+
+#[derive(Debug, Default, Deserialize, JsonSchema)]
+pub struct GenerateContext {
+ #[serde(default)]
+ pub system_prompt: String,
+}
+
+#[derive(Debug, Default, Deserialize, JsonSchema)]
+pub struct PreGenerateEvent {
+ #[serde(default)]
+ pub generate: GenerateContext,
+}
+
+#[derive(Debug, Default, Serialize, JsonSchema)]
+pub struct PreGenerateMutations {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub system_prompt: Option,
+}
+
+#[derive(Debug, Serialize, JsonSchema)]
+pub struct PreGenerateResponse {
+ pub mutations: PreGenerateMutations,
+}
+
+/// Empty base → {} (preserve harness prompt); else full replacement base+guidance.
+pub fn mutations_for(base: &str) -> PreGenerateResponse {
+ if base.is_empty() {
+ return PreGenerateResponse {
+ mutations: PreGenerateMutations::default(),
+ };
+ }
+ PreGenerateResponse {
+ mutations: PreGenerateMutations {
+ system_prompt: Some(format!("{base}\n\n{GUIDANCE}")),
+ },
+ }
+}
+
+pub async fn handle(event: PreGenerateEvent) -> Result {
+ Ok(mutations_for(&event.generate.system_prompt))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn empty_base_preserves_harness_prompt() {
+ let v = serde_json::to_value(mutations_for("")).unwrap();
+ assert_eq!(v, serde_json::json!({"mutations": {}}));
+ }
+
+ #[test]
+ fn nonempty_base_appends_guidance() {
+ let v = serde_json::to_value(mutations_for("BASE")).unwrap();
+ let s = v["mutations"]["system_prompt"].as_str().unwrap();
+ assert!(s.starts_with("BASE\n\n## Scraping and HTML parsing"));
+ }
+
+ /// The guidance is what routes agents to these functions, so it must not
+ /// promise capabilities the worker does not have, and every Scrapling id
+ /// it names must actually be registered.
+ #[test]
+ fn guidance_names_only_real_functions_and_no_absent_capability() {
+ let registered: Vec<&str> = crate::scrapling::STATIC_IDS
+ .iter()
+ .map(|id| id.trim_start_matches("browser::"))
+ .collect();
+ for word in [
+ "fetch",
+ "dynamic-fetch",
+ "stealthy-fetch",
+ "screenshot-url",
+ "session-open",
+ "session-fetch",
+ "session-close",
+ "session-list",
+ "crawl",
+ "extract",
+ "css",
+ "xpath",
+ "regex",
+ "find",
+ "find-by-text",
+ "find-by-regex",
+ "find-similar",
+ "describe",
+ "to-markdown",
+ ] {
+ assert!(
+ GUIDANCE.contains(word),
+ "guidance never mentions `{word}`, so agents will not find it"
+ );
+ assert!(registered.contains(&word), "`{word}` is not registered");
+ }
+ assert!(
+ GUIDANCE.contains("Adaptive queries persist identities in SQLite"),
+ "adaptive persistence must be disclosed"
+ );
+ assert!(GUIDANCE.contains("`solve_cloudflare` is supported"));
+ assert!(GUIDANCE.contains("browser::handoff"));
+ }
+}
diff --git a/browser/src/scrapling/markdown.rs b/browser/src/scrapling/markdown.rs
new file mode 100644
index 000000000..1d53e58b8
--- /dev/null
+++ b/browser/src/scrapling/markdown.rs
@@ -0,0 +1,1235 @@
+//! scrapling::to-markdown rendering: html/markdown/text extraction, with an
+//! optional main-content sanitizer and CSS scope
+//! (`Convertor._extract_content`, scrapling/core/shell.py, normative).
+
+use std::collections::HashSet;
+
+use xmloxide::tree::NodeKind;
+use xmloxide::NodeId;
+
+use crate::scrapling::dom::{self, Doc, ElementRef};
+use crate::scrapling::query;
+
+/// `_strip_noise_tags` + the tag half of `_sanitize_for_ai`'s `_HIDDEN_XPATH`
+/// union: `` is dropped the same way as the noise tags — folding
+/// it into one name-set costs nothing since detaching is order-independent
+/// (matching is by predicate only, never by "is a sibling already gone").
+fn is_noise_tag(name: &str) -> bool {
+ matches!(name, "script" | "style" | "noscript" | "svg" | "template")
+}
+
+/// The `contains(@style, ...)` half of `_HIDDEN_XPATH` (shell.py) — plain
+/// substring tests, not CSS parsing.
+const HIDDEN_STYLE_SUBSTRINGS: &[&str] = &[
+ "display:none",
+ "display: none",
+ "visibility:hidden",
+ "visibility: hidden",
+ "opacity:0",
+ "opacity: 0",
+ "font-size:0",
+ "font-size: 0",
+ "height:0",
+ "height: 0",
+ "width:0",
+ "width: 0",
+];
+
+/// `_ZWC_PATTERN` (shell.py): zero-width/invisible-formatting characters
+/// stripped from text nodes by the prompt-injection sanitizer.
+const ZERO_WIDTH_CHARS: [char; 6] = [
+ '\u{200b}', '\u{200c}', '\u{200d}', '\u{feff}', '\u{2060}', '\u{180e}',
+];
+
+fn is_hidden(doc: &Doc, id: NodeId) -> bool {
+ is_noise_tag(doc.tree.node_name(id).unwrap_or(""))
+ || doc.tree.attribute(id, "aria-hidden") == Some("true")
+ || doc
+ .tree
+ .attribute(id, "style")
+ .is_some_and(|s| HIDDEN_STYLE_SUBSTRINGS.iter().any(|pat| s.contains(pat)))
+}
+
+/// `_strip_noise_tags` + `_sanitize_for_ai`, fused into one in-place pass over
+/// a tree the caller already cloned (never called on the caller's own doc):
+/// detach every noise/hidden/aria-hidden/template element (`drop_tree` —
+/// lxml keeps a dropped element's TAIL text by splicing it onto the
+/// predecessor, but html5ever/scraper never attaches trailing text to an
+/// element in the first place — it's already an ordinary sibling text node —
+/// so a plain `detach()` reproduces that splice for free), then strip
+/// zero-width chars from every surviving text node.
+///
+/// `scope_id` (the element `render` is about to treat as the page — `body`,
+/// or the whole document if there's no `body`) is exempt from the hidden
+/// check: `_HIDDEN_XPATH` is rooted at `.//` (XPath descendant axis, self
+/// excluded), so `` or `` never drops itself in Python, only matching *descendants* do.
+/// Oracle-probed: both yield their content, not `""`. The name-based checks
+/// (script/style/noscript/svg/template) need no such exemption in practice —
+/// `scope_id` is always a `` or the document's `` root, never one
+/// of those tag names — but exempting it from the whole predicate uniformly
+/// is simpler than splitting the check in two, and is a no-op for that half.
+fn sanitize_main_content(doc: &mut Doc, scope_id: NodeId) {
+ let drop_ids: Vec<_> = doc
+ .tree
+ .descendants(doc.tree.root())
+ .filter(|id| *id != scope_id && doc.tree.is_element(*id) && is_hidden(doc, *id))
+ .collect();
+ for id in drop_ids {
+ doc.tree.remove_node(id);
+ }
+
+ let text_ids: Vec<_> = doc
+ .tree
+ .descendants(doc.tree.root())
+ .filter(|id| matches!(doc.tree.node(*id).kind, NodeKind::Text { .. }))
+ .collect();
+ for id in text_ids {
+ let cleaned: String = doc
+ .tree
+ .node_text(id)
+ .unwrap_or("")
+ .chars()
+ .filter(|c| !ZERO_WIDTH_CHARS.contains(c))
+ .collect();
+ doc.tree.set_text_content(id, &cleaned);
+ }
+}
+
+/// First `` in document order, else the whole document's root element
+/// (`page.css("body").first or page`, shell.py).
+fn body_or_root(doc: &Doc) -> ElementRef<'_> {
+ doc.first_by_tag("body").unwrap_or_else(|| doc.root())
+}
+
+/// Collapse runs of `\n`, then `\r`, then `\t`, then space — in that order,
+/// each to a single occurrence (the `Convertor._extract_content` text-mode
+/// loop, shell.py: sequential `re.sub(f"[{s}]+", s, ...)` per character).
+/// markdownify's `all_whitespace_re` = `[\t \r\n]+` → single space. Crucially
+/// ASCII-only: NBSP (and other Unicode spaces) are NOT collapsed, unlike
+/// Rust's `split_whitespace`, which treats U+00A0 as whitespace.
+fn collapse_ascii_ws(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let mut prev_ws = false;
+ for ch in s.chars() {
+ if matches!(ch, '\t' | ' ' | '\r' | '\n') {
+ if !prev_ws {
+ out.push(' ');
+ prev_ws = true;
+ }
+ } else {
+ out.push(ch);
+ prev_ws = false;
+ }
+ }
+ out
+}
+
+fn collapse_whitespace(mut s: String) -> String {
+ for c in ['\n', '\r', '\t', ' '] {
+ let mut out = String::with_capacity(s.len());
+ let mut prev_was_c = false;
+ for ch in s.chars() {
+ if ch == c {
+ if prev_was_c {
+ continue;
+ }
+ prev_was_c = true;
+ } else {
+ prev_was_c = false;
+ }
+ out.push(ch);
+ }
+ s = out;
+ }
+ s
+}
+
+// The public wrapper exposes markdownify with no option overrides. This is a
+// repository-owned port of markdownify 1.2.3's complete default conversion
+// behavior. Its first step deliberately reparses the lxml-serialized subtree
+// with the same tree shape as BeautifulSoup 4.15's `html.parser` builder.
+// That second parse is observable for raw-text elements such as .
+
+#[derive(Clone, Debug)]
+enum MarkdownNodeKind {
+ Document,
+ Element {
+ name: String,
+ attrs: Vec<(String, String)>,
+ },
+ Text(String),
+ Comment,
+ Doctype,
+}
+
+#[derive(Clone, Debug)]
+struct MarkdownNode {
+ kind: MarkdownNodeKind,
+ parent: Option,
+ children: Vec,
+}
+
+#[derive(Debug)]
+struct MarkdownTree {
+ nodes: Vec,
+}
+
+impl MarkdownTree {
+ fn new() -> Self {
+ Self {
+ nodes: vec![MarkdownNode {
+ kind: MarkdownNodeKind::Document,
+ parent: None,
+ children: Vec::new(),
+ }],
+ }
+ }
+
+ fn append(&mut self, parent: usize, kind: MarkdownNodeKind) -> usize {
+ let id = self.nodes.len();
+ self.nodes.push(MarkdownNode {
+ kind,
+ parent: Some(parent),
+ children: Vec::new(),
+ });
+ self.nodes[parent].children.push(id);
+ id
+ }
+
+ fn name(&self, id: usize) -> Option<&str> {
+ match &self.nodes[id].kind {
+ MarkdownNodeKind::Document => Some("[document]"),
+ MarkdownNodeKind::Element { name, .. } => Some(name),
+ _ => None,
+ }
+ }
+
+ fn attr(&self, id: usize, name: &str) -> Option<&str> {
+ match &self.nodes[id].kind {
+ MarkdownNodeKind::Element { attrs, .. } => attrs
+ .iter()
+ .find(|(key, _)| key == name)
+ .map(|(_, value)| value.as_str()),
+ _ => None,
+ }
+ }
+
+ fn element_children_recursive(&self, id: usize, names: &[&str], out: &mut Vec) {
+ for child in &self.nodes[id].children {
+ if self.name(*child).is_some_and(|name| names.contains(&name)) {
+ out.push(*child);
+ }
+ self.element_children_recursive(*child, names, out);
+ }
+ }
+
+ fn sibling_index(&self, id: usize) -> Option<(usize, usize)> {
+ let parent = self.nodes[id].parent?;
+ let index = self.nodes[parent]
+ .children
+ .iter()
+ .position(|candidate| *candidate == id)?;
+ Some((parent, index))
+ }
+
+ fn previous_sibling(&self, id: usize) -> Option {
+ let (parent, index) = self.sibling_index(id)?;
+ index.checked_sub(1).map(|i| self.nodes[parent].children[i])
+ }
+
+ fn next_sibling(&self, id: usize) -> Option {
+ let (parent, index) = self.sibling_index(id)?;
+ self.nodes[parent].children.get(index + 1).copied()
+ }
+
+ fn previous_element_sibling(&self, id: usize) -> Option {
+ let (parent, index) = self.sibling_index(id)?;
+ self.nodes[parent].children[..index]
+ .iter()
+ .rev()
+ .copied()
+ .find(|candidate| self.name(*candidate).is_some())
+ }
+
+ fn has_ancestor(&self, id: usize, name: &str) -> bool {
+ let mut cursor = self.nodes[id].parent;
+ while let Some(parent) = cursor {
+ if self.name(parent) == Some(name) {
+ return true;
+ }
+ cursor = self.nodes[parent].parent;
+ }
+ false
+ }
+}
+
+const VOID_TAGS: &[&str] = &[
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
+ "track", "wbr",
+];
+
+fn decode_html_parser_entities(value: &str) -> String {
+ html_escape::decode_html_entities(value).into_owned()
+}
+
+fn find_tag_end(input: &str, start: usize) -> usize {
+ let bytes = input.as_bytes();
+ let mut quote = None;
+ let mut i = start;
+ while i < bytes.len() {
+ match bytes[i] {
+ b'\'' | b'"' if quote.is_none() => quote = Some(bytes[i]),
+ current if quote == Some(current) => quote = None,
+ b'>' if quote.is_none() => return i,
+ _ => {}
+ }
+ i += 1;
+ }
+ bytes.len()
+}
+
+fn parse_start_tag(raw: &str) -> (String, Vec<(String, String)>, bool) {
+ let bytes = raw.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() && bytes[i].is_ascii_whitespace() {
+ i += 1;
+ }
+ let name_start = i;
+ while i < bytes.len() && !bytes[i].is_ascii_whitespace() && !matches!(bytes[i], b'/' | b'>') {
+ i += 1;
+ }
+ let name = raw[name_start..i].to_ascii_lowercase();
+ let mut attrs: Vec<(String, String)> = Vec::new();
+ let mut self_closing = false;
+
+ while i < bytes.len() {
+ while i < bytes.len() && bytes[i].is_ascii_whitespace() {
+ i += 1;
+ }
+ if i >= bytes.len() {
+ break;
+ }
+ if bytes[i] == b'/' {
+ self_closing = true;
+ i += 1;
+ continue;
+ }
+ let attr_start = i;
+ while i < bytes.len()
+ && !bytes[i].is_ascii_whitespace()
+ && !matches!(bytes[i], b'=' | b'/' | b'>')
+ {
+ i += 1;
+ }
+ if attr_start == i {
+ i += 1;
+ continue;
+ }
+ let attr_name = raw[attr_start..i].to_ascii_lowercase();
+ while i < bytes.len() && bytes[i].is_ascii_whitespace() {
+ i += 1;
+ }
+ let mut value = String::new();
+ if i < bytes.len() && bytes[i] == b'=' {
+ i += 1;
+ while i < bytes.len() && bytes[i].is_ascii_whitespace() {
+ i += 1;
+ }
+ if i < bytes.len() && matches!(bytes[i], b'\'' | b'"') {
+ let quote = bytes[i];
+ i += 1;
+ let value_start = i;
+ while i < bytes.len() && bytes[i] != quote {
+ i += 1;
+ }
+ value = decode_html_parser_entities(&raw[value_start..i]);
+ if i < bytes.len() {
+ i += 1;
+ }
+ } else {
+ let value_start = i;
+ while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'/' {
+ i += 1;
+ }
+ value = decode_html_parser_entities(&raw[value_start..i]);
+ }
+ }
+ // html.parser/BeautifulSoup keeps the last duplicate attribute.
+ if let Some(existing) = attrs.iter_mut().find(|(key, _)| key == &attr_name) {
+ existing.1 = value;
+ } else {
+ attrs.push((attr_name, value));
+ }
+ }
+ (name, attrs, self_closing)
+}
+
+/// Parse the already libxml-serialized selection the way the default
+/// BeautifulSoup HTMLParserTreeBuilder observes it. Libxml has already done
+/// malformed-markup recovery, so this stage primarily needs HTMLParser's
+/// tokenization, entity handling, raw-text behavior, and sibling tree.
+fn parse_markdown_tree(input: &str) -> MarkdownTree {
+ let mut tree = MarkdownTree::new();
+ let mut stack = vec![0usize];
+ let mut i = 0usize;
+ let bytes = input.as_bytes();
+
+ while i < bytes.len() {
+ if bytes[i] != b'<' {
+ let end = input[i..]
+ .find('<')
+ .map_or(bytes.len(), |offset| i + offset);
+ if end > i {
+ let text = decode_html_parser_entities(&input[i..end]);
+ tree.append(*stack.last().unwrap(), MarkdownNodeKind::Text(text));
+ }
+ i = end;
+ continue;
+ }
+ if input[i..].starts_with("")
+ .map_or(bytes.len(), |offset| i + 4 + offset + 3);
+ tree.append(*stack.last().unwrap(), MarkdownNodeKind::Comment);
+ i = end;
+ continue;
+ }
+ if input[i..].get(..2).is_some_and(|s| s == "") {
+ let end = find_tag_end(input, i + 2);
+ let name = input[i + 2..end]
+ .split_ascii_whitespace()
+ .next()
+ .unwrap_or("")
+ .trim_end_matches('/')
+ .to_ascii_lowercase();
+ if let Some(position) = stack
+ .iter()
+ .rposition(|node| tree.name(*node) == Some(name.as_str()))
+ {
+ stack.truncate(position);
+ }
+ i = end.saturating_add(1);
+ continue;
+ }
+ if input[i..].get(..2).is_some_and(|s| s == " 0 {
+ tree.append(
+ node,
+ MarkdownNodeKind::Text(input[i..i + offset].to_string()),
+ );
+ }
+ let close_start = i + offset;
+ i = find_tag_end(input, close_start + 2).saturating_add(1);
+ continue;
+ }
+ }
+ if !self_closing && !VOID_TAGS.contains(&name.as_str()) {
+ stack.push(node);
+ }
+ }
+ tree
+}
+
+fn heading_number(name: &str) -> Option {
+ let digits: String = name
+ .strip_prefix('h')?
+ .chars()
+ .take_while(char::is_ascii_digit)
+ .collect();
+ (!digits.is_empty()).then(|| digits.parse().ok()).flatten()
+}
+
+fn is_block_name(name: Option<&str>) -> bool {
+ name.is_some_and(|name| {
+ heading_number(name).is_some()
+ || matches!(
+ name,
+ "p" | "blockquote"
+ | "article"
+ | "div"
+ | "section"
+ | "ol"
+ | "ul"
+ | "li"
+ | "dl"
+ | "dt"
+ | "dd"
+ | "table"
+ | "thead"
+ | "tbody"
+ | "tfoot"
+ | "tr"
+ | "td"
+ | "th"
+ )
+ })
+}
+
+fn removes_whitespace_outside(tree: &MarkdownTree, node: Option) -> bool {
+ node.is_some_and(|id| is_block_name(tree.name(id)) || tree.name(id) == Some("pre"))
+}
+
+fn block_content(tree: &MarkdownTree, node: usize) -> bool {
+ match &tree.nodes[node].kind {
+ MarkdownNodeKind::Element { .. } => true,
+ MarkdownNodeKind::Text(text) => !text.trim().is_empty(),
+ _ => false,
+ }
+}
+
+fn next_block_content(tree: &MarkdownTree, id: usize) -> Option {
+ let (parent, index) = tree.sibling_index(id)?;
+ tree.nodes[parent].children[index + 1..]
+ .iter()
+ .copied()
+ .find(|candidate| block_content(tree, *candidate))
+}
+
+fn normalize_markdown_text(text: &str) -> String {
+ let chars: Vec = text.chars().collect();
+ let mut out = String::with_capacity(text.len());
+ let mut i = 0;
+ while i < chars.len() {
+ if matches!(chars[i], ' ' | '\t' | '\r' | '\n') {
+ let mut has_newline = false;
+ while i < chars.len() && matches!(chars[i], ' ' | '\t' | '\r' | '\n') {
+ has_newline |= matches!(chars[i], '\r' | '\n');
+ i += 1;
+ }
+ out.push(if has_newline { '\n' } else { ' ' });
+ } else {
+ out.push(chars[i]);
+ i += 1;
+ }
+ }
+ out
+}
+
+fn escape_markdown_text(text: &str) -> String {
+ text.replace('*', r"\*").replace('_', r"\_")
+}
+
+fn trim_ascii_markdown(value: &str) -> &str {
+ value.trim_matches([' ', '\t', '\r', '\n'])
+}
+
+fn chomp(value: &str) -> (&'static str, &'static str, &str) {
+ let prefix = if value.starts_with(' ') { " " } else { "" };
+ let suffix = if value.ends_with(' ') { " " } else { "" };
+ (prefix, suffix, value.trim())
+}
+
+fn split_boundary_newlines(value: &str) -> (String, String, String) {
+ let leading = value.bytes().take_while(|byte| *byte == b'\n').count();
+ let remainder = &value[leading..];
+ if remainder.is_empty() {
+ return ("\n".repeat(leading), String::new(), String::new());
+ }
+ let trailing = remainder
+ .bytes()
+ .rev()
+ .take_while(|byte| *byte == b'\n')
+ .count();
+ let content_end = remainder.len() - trailing;
+ (
+ "\n".repeat(leading),
+ remainder[..content_end].to_string(),
+ "\n".repeat(trailing),
+ )
+}
+
+fn indent_lines(value: &str, prefix: &str, empty_prefix: &str) -> String {
+ value
+ .split('\n')
+ .map(|line| {
+ if line.is_empty() {
+ empty_prefix.to_string()
+ } else {
+ format!("{prefix}{line}")
+ }
+ })
+ .collect::>()
+ .join("\n")
+}
+
+struct MarkdownConverter<'a> {
+ tree: &'a MarkdownTree,
+}
+
+impl<'a> MarkdownConverter<'a> {
+ fn process(&self) -> String {
+ self.process_tag(0, &HashSet::new())
+ }
+
+ fn process_element(&self, id: usize, parent_tags: &HashSet) -> String {
+ match &self.tree.nodes[id].kind {
+ MarkdownNodeKind::Text(_) => self.process_text(id, parent_tags),
+ MarkdownNodeKind::Element { .. } | MarkdownNodeKind::Document => {
+ self.process_tag(id, parent_tags)
+ }
+ MarkdownNodeKind::Comment | MarkdownNodeKind::Doctype => String::new(),
+ }
+ }
+
+ fn can_ignore_child(&self, child: usize, remove_inside: bool) -> bool {
+ match &self.tree.nodes[child].kind {
+ MarkdownNodeKind::Element { .. } => false,
+ MarkdownNodeKind::Comment | MarkdownNodeKind::Doctype => true,
+ MarkdownNodeKind::Text(text) => {
+ if !text.trim().is_empty() {
+ return false;
+ }
+ let previous = self.tree.previous_sibling(child);
+ let next = self.tree.next_sibling(child);
+ (remove_inside && (previous.is_none() || next.is_none()))
+ || removes_whitespace_outside(self.tree, previous)
+ || removes_whitespace_outside(self.tree, next)
+ }
+ MarkdownNodeKind::Document => true,
+ }
+ }
+
+ fn process_tag(&self, id: usize, parent_tags: &HashSet) -> String {
+ let name = self.tree.name(id).unwrap_or("");
+ let remove_inside = is_block_name(Some(name));
+ let mut child_context = parent_tags.clone();
+ child_context.insert(name.to_string());
+ if heading_number(name).is_some() || matches!(name, "td" | "th") {
+ child_context.insert("_inline".to_string());
+ }
+ if matches!(name, "pre" | "code" | "kbd" | "samp") {
+ child_context.insert("_noformat".to_string());
+ }
+
+ let mut child_strings: Vec = self.tree.nodes[id]
+ .children
+ .iter()
+ .copied()
+ .filter(|child| !self.can_ignore_child(*child, remove_inside))
+ .map(|child| self.process_element(child, &child_context))
+ .filter(|value| !value.is_empty())
+ .collect();
+
+ if name != "pre" && !self.tree.has_ancestor(id, "pre") {
+ let mut collapsed = vec![String::new()];
+ for child in child_strings {
+ let (mut leading, content, trailing) = split_boundary_newlines(&child);
+ if collapsed.last().is_some_and(|last| !last.is_empty()) && !leading.is_empty() {
+ let previous = collapsed.pop().unwrap();
+ leading = "\n".repeat(2.min(previous.len().max(leading.len())));
+ }
+ collapsed.extend([leading, content, trailing]);
+ }
+ child_strings = collapsed;
+ }
+ let text = child_strings.concat();
+ self.convert_tag(id, name, text, parent_tags)
+ }
+
+ fn process_text(&self, id: usize, parent_tags: &HashSet) -> String {
+ let MarkdownNodeKind::Text(raw) = &self.tree.nodes[id].kind else {
+ unreachable!()
+ };
+ let mut text = if parent_tags.contains("pre") {
+ raw.clone()
+ } else {
+ normalize_markdown_text(raw)
+ };
+ if !parent_tags.contains("_noformat") {
+ text = escape_markdown_text(&text);
+ }
+
+ let parent = self.tree.nodes[id].parent;
+ let previous = self.tree.previous_sibling(id);
+ let next = self.tree.next_sibling(id);
+ if removes_whitespace_outside(self.tree, previous)
+ || (parent.is_some_and(|node| is_block_name(self.tree.name(node)))
+ && previous.is_none())
+ {
+ text = text.trim_start_matches([' ', '\t', '\r', '\n']).to_string();
+ }
+ if removes_whitespace_outside(self.tree, next)
+ || (parent.is_some_and(|node| is_block_name(self.tree.name(node))) && next.is_none())
+ {
+ text = text.trim_end().to_string();
+ }
+ text
+ }
+
+ fn inline(&self, text: String, markup: &str, parent_tags: &HashSet) -> String {
+ if parent_tags.contains("_noformat") {
+ return text;
+ }
+ let (prefix, suffix, inner) = chomp(&text);
+ if inner.is_empty() {
+ String::new()
+ } else {
+ format!("{prefix}{markup}{inner}{markup}{suffix}")
+ }
+ }
+
+ fn convert_tag(
+ &self,
+ id: usize,
+ name: &str,
+ text: String,
+ parent_tags: &HashSet