@octocrawl/cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +2138 -343
  3. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -385,6 +385,13 @@ function withEnvironmentProxy(policy, env) {
385
385
  const egressProxy = environmentProxy(env);
386
386
  return egressProxy === null ? policy : { ...policy, egressProxy };
387
387
  }
388
+ function egressProxies(raw) {
389
+ const servers = (raw ?? "").split(/[\s,]+/).filter((entry3) => entry3 !== "").map((entry3, i) => proxyServer(`W2L_EGRESS_PROXIES entry ${i + 1}`, entry3));
390
+ return servers.filter((server, i) => servers.findIndex((other) => other.endpoint === server.endpoint) === i);
391
+ }
392
+ function withPoolProxy(policy, server) {
393
+ return { ...policy, origin: "operator", egressProxy: { source: "pool", https: server, http: server, noProxy: policy.egressProxy?.noProxy ?? [] } };
394
+ }
388
395
  function proxyFor(url, policy) {
389
396
  const proxy = policy.origin === "operator" ? policy.egressProxy ?? null : null;
390
397
  if (proxy === null)
@@ -451,7 +458,7 @@ function proxyServer(name, raw) {
451
458
  throw new ProxyConfigError(`${name} is not a valid proxy URL.`);
452
459
  }
453
460
  if (url.protocol !== "http:" && url.protocol !== "https:") {
454
- throw new ProxyConfigError(`${name} names a ${url.protocol.slice(0, -1)} proxy; W2L supports http:// and https:// proxies.`);
461
+ throw new ProxyConfigError(`${name} names a ${url.protocol.slice(0, -1)} proxy; Octocrawl supports http:// and https:// proxies.`);
455
462
  }
456
463
  const endpoint = `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
457
464
  return {
@@ -547,7 +554,11 @@ function isResearchUserAgent(userAgent) {
547
554
  return /\bw2l-research\b/.test(userAgent) || userAgent.startsWith(`${SEC_DECLARED_NAME} `);
548
555
  }
549
556
  function robotsAgent(userAgent) {
550
- return userAgent.startsWith(`${SEC_DECLARED_NAME} `) ? `${userAgent} ${RESEARCH_PRODUCT_TOKEN}` : userAgent;
557
+ const declared3 = userAgent.startsWith(`${SEC_DECLARED_NAME} `) ? `${userAgent} ${RESEARCH_PRODUCT_TOKEN}` : userAgent;
558
+ return `${declared3} ${PRODUCT_ROBOTS_TOKEN}`;
559
+ }
560
+ function isOctocrawlRobotsGroup(matchedAgent) {
561
+ return matchedAgent === PRODUCT_ROBOTS_TOKEN || matchedAgent === RESEARCH_PRODUCT_TOKEN;
551
562
  }
552
563
  function operatorContact(env) {
553
564
  const contact = (env["W2L_CONTACT"] ?? "").trim();
@@ -639,7 +650,7 @@ function checkIdentityHonesty(identity, sent) {
639
650
  }
640
651
  return { honest: mismatches.length === 0, mismatches };
641
652
  }
642
- var RESEARCH_UA_COMMENT, RESEARCH_USER_AGENT, MAX_CONTACT_LENGTH, SEC_DECLARED_NAME, RESEARCH_PRODUCT_TOKEN, CHROME_MAJOR_FLOOR, BROWSER_FINGERPRINT, MOBILE_BROWSER_FINGERPRINT, PREVIEW_PRODUCT_TOKEN, MODE_IDENTITIES;
653
+ var RESEARCH_UA_COMMENT, RESEARCH_USER_AGENT, MAX_CONTACT_LENGTH, SEC_DECLARED_NAME, RESEARCH_PRODUCT_TOKEN, PRODUCT_ROBOTS_TOKEN, CHROME_MAJOR_FLOOR, BROWSER_FINGERPRINT, MOBILE_BROWSER_FINGERPRINT, PREVIEW_PRODUCT_TOKEN, MODE_IDENTITIES;
643
654
  var init_compliance = __esm({
644
655
  "packages/contracts/dist/compliance.js"() {
645
656
  "use strict";
@@ -648,6 +659,7 @@ var init_compliance = __esm({
648
659
  MAX_CONTACT_LENGTH = 200;
649
660
  SEC_DECLARED_NAME = "W2L Research";
650
661
  RESEARCH_PRODUCT_TOKEN = "w2l-research";
662
+ PRODUCT_ROBOTS_TOKEN = "octocrawl";
651
663
  CHROME_MAJOR_FLOOR = 128;
652
664
  BROWSER_FINGERPRINT = {
653
665
  locale: "en-US",
@@ -1577,11 +1589,11 @@ function readSchema(value, at = "schema") {
1577
1589
  if (depth > 0)
1578
1590
  unsupported(where, key, "only the root may declare it");
1579
1591
  } else if (!SCHEMA_KEYS.has(key)) {
1580
- unsupported(where, key, "W2L extraction does not support it");
1592
+ unsupported(where, key, "Octocrawl extraction does not support it");
1581
1593
  }
1582
1594
  }
1583
1595
  if (rec.$schema !== void 0 && (typeof rec.$schema !== "string" || !SCHEMA_DIALECTS.test(rec.$schema))) {
1584
- unsupported(where, "$schema", `W2L follows JSON Schema draft-07, 2019-09 and 2020-12, not ${JSON.stringify(rec.$schema)}`);
1596
+ unsupported(where, "$schema", `Octocrawl follows JSON Schema draft-07, 2019-09 and 2020-12, not ${JSON.stringify(rec.$schema)}`);
1585
1597
  }
1586
1598
  if (rec.$id !== void 0 && typeof rec.$id !== "string")
1587
1599
  invalid(where, "$id must be a string");
@@ -1669,7 +1681,7 @@ function readSchema(value, at = "schema") {
1669
1681
  const union = branches;
1670
1682
  const nullable2 = union.length === 2 && union.some(isNullSchema);
1671
1683
  if (!nullable2 && !union.every(isPrimitiveSchema))
1672
- unsupported(where, key, "W2L maps a schema-or-null union or a union of primitive types, not a union of objects, arrays or references");
1684
+ unsupported(where, key, "Octocrawl maps a schema-or-null union or a union of primitive types, not a union of objects, arrays or references");
1673
1685
  union.forEach((branch, index) => visit(branch, depth + 1, `${where}.${key}[${index}]`));
1674
1686
  }
1675
1687
  };
@@ -1699,7 +1711,7 @@ function readListFormat(rec, name) {
1699
1711
  };
1700
1712
  if (rec.itemSelector === void 0) {
1701
1713
  if (rec.fields !== void 0)
1702
- throw new RequestError(`${name}.fields needs an itemSelector: without one, W2L finds the list and its fields itself`);
1714
+ throw new RequestError(`${name}.fields needs an itemSelector: without one, Octocrawl finds the list and its fields itself`);
1703
1715
  return { type: "list" };
1704
1716
  }
1705
1717
  const itemSelector = selectorOf2(rec.itemSelector, `${name}.itemSelector`);
@@ -1945,7 +1957,7 @@ function readSelectors(value, name) {
1945
1957
  }
1946
1958
  function headerRefusal(name) {
1947
1959
  if (name === "user-agent" || name.startsWith("sec-ch-") || name.startsWith("sec-fetch-"))
1948
- return `headers.${name} is refused: the User-Agent and client hints are W2L's declared identity`;
1960
+ return `headers.${name} is refused: the User-Agent and client hints are Octocrawl's declared identity`;
1949
1961
  if (CREDENTIAL_HEADERS.has(name))
1950
1962
  return `headers.${name} is refused: credentials are not sent as headers; mode 'authed' carries your own session on the record`;
1951
1963
  if (TRANSPORT_HEADERS.has(name))
@@ -2011,7 +2023,7 @@ function readParsers(value) {
2011
2023
  const name = `parsers[${index}]`;
2012
2024
  const type = typeof entry3 === "string" ? entry3 : entry3 !== null && typeof entry3 === "object" && !Array.isArray(entry3) ? entry3.type : void 0;
2013
2025
  if (type === "image")
2014
- throw new RequestError(`${name} is refused: W2L reads no image as a document (no OCR)`, "unsupported_parameter", { parameters: [name] });
2026
+ throw new RequestError(`${name} is refused: Octocrawl reads no image as a document (no OCR)`, "unsupported_parameter", { parameters: [name] });
2015
2027
  if (type !== "pdf")
2016
2028
  throw new RequestError(`${name} must be "pdf" or { type: "pdf", mode, maxPages, pages, pageMarkers }`);
2017
2029
  if (parsers.length > 0)
@@ -2023,7 +2035,7 @@ function readParsers(value) {
2023
2035
  const rec = entry3;
2024
2036
  rejectUnknownKeys(rec, PDF_PARSER_KEYS, name);
2025
2037
  if (rec.mode === "ocr")
2026
- throw new RequestError(`${name}.mode "ocr" is refused: W2L reads a PDF's text layer and runs no OCR`, "unsupported_parameter", { parameters: [`${name}.mode`] });
2038
+ throw new RequestError(`${name}.mode "ocr" is refused: Octocrawl reads a PDF's text layer and runs no OCR`, "unsupported_parameter", { parameters: [`${name}.mode`] });
2027
2039
  if (rec.mode !== void 0 && rec.mode !== "fast" && rec.mode !== "auto")
2028
2040
  throw new RequestError(`${name}.mode must be "fast" or "auto"`);
2029
2041
  const maxPages = rec.maxPages;
@@ -2260,6 +2272,7 @@ function parseCrawlStartRequest(body) {
2260
2272
  const maxConcurrency = readConcurrency(rec.maxConcurrency);
2261
2273
  const idempotencyKey = readIdempotencyKey(rec.idempotencyKey);
2262
2274
  const webhook = readWebhook(rec.webhook);
2275
+ const ignoreRobotsTxt = readBoolean(rec.ignoreRobotsTxt, "ignoreRobotsTxt");
2263
2276
  const req = {
2264
2277
  url: readUrl(rec.url),
2265
2278
  mode,
@@ -2276,6 +2289,7 @@ function parseCrawlStartRequest(body) {
2276
2289
  ...maxConcurrency === void 0 ? {} : { maxConcurrency },
2277
2290
  ...idempotencyKey === void 0 ? {} : { idempotencyKey },
2278
2291
  ...webhook === void 0 ? {} : { webhook },
2292
+ ...ignoreRobotsTxt === void 0 ? {} : { ignoreRobotsTxt },
2279
2293
  ...page,
2280
2294
  ...readAttribution(rec)
2281
2295
  };
@@ -2313,6 +2327,7 @@ function parseMapRequest(body) {
2313
2327
  }
2314
2328
  const includePaths = readPathPatterns(rec.includePaths, "includePaths");
2315
2329
  const excludePaths = readPathPatterns(rec.excludePaths, "excludePaths");
2330
+ const ignoreRobotsTxt = readBoolean(rec.ignoreRobotsTxt, "ignoreRobotsTxt");
2316
2331
  return {
2317
2332
  url: readUrl(rec.url),
2318
2333
  ...rec.mode === void 0 ? {} : { mode: rec.mode },
@@ -2323,6 +2338,7 @@ function parseMapRequest(body) {
2323
2338
  ...scope,
2324
2339
  ...includePaths === void 0 ? {} : { includePaths },
2325
2340
  ...excludePaths === void 0 ? {} : { excludePaths },
2341
+ ...ignoreRobotsTxt === void 0 ? {} : { ignoreRobotsTxt },
2326
2342
  ...readAttribution(rec)
2327
2343
  };
2328
2344
  }
@@ -2488,23 +2504,23 @@ var init_api = __esm({
2488
2504
  }
2489
2505
  };
2490
2506
  REFUSAL_HINTS = {
2491
- stealth: "W2L does not offer a stealth mode or stealth proxies; a proxy or session you own (mode authed) is the supported route",
2492
- ignoreRobotsTxt: "robots.txt is always read; a robotsOverride with a recorded reason fetches one URL past its rule, on the record",
2493
- hostedSkipTlsVerification: "a hosted server verifies every certificate; run W2L locally to use skipTlsVerification, which is recorded in the trace and a tls_unverified warning",
2494
- useIndex: "W2L keeps no URL index: a map reads the sitemaps the site declares and its start page, on the record; crawl reads further pages",
2507
+ stealth: "Octocrawl has no stealth option on a request: a provider's stealth or challenge solving runs only on a server started with an access grant that names it (--access-grant, ADR 0005); a proxy or session you own (mode authed) is the other route",
2508
+ ignoreRobotsTxt: "robots.txt is always read and recorded; on a local server a URL a scrape or batch names is fetched whatever it says, and ignoreRobotsTxt on a crawl or map fetches the links it disallows, on the record",
2509
+ hostedSkipTlsVerification: "a hosted server verifies every certificate; run Octocrawl locally to use skipTlsVerification, which is recorded in the trace and a tls_unverified warning",
2510
+ useIndex: "Octocrawl keeps no URL index: a map reads the sitemaps the site declares and its start page, on the record; crawl reads further pages",
2495
2511
  actions: "actions run on scrape and batch, where each page named gets the same steps; a crawl or a map does not take them"
2496
2512
  };
2497
2513
  PAGE_KEYS = ["onlyMainContent", "waitFor", "timeout", "maxFileBytes", "includeTags", "excludeTags", "headers", "mobile", "skipTlsVerification", "fastMode", "blockAds", "removeBase64Images", "parsers", "maxAge", "minAge", "storeInCache", "lockdown"];
2498
2514
  ATTRIBUTION_KEYS = ["origin", "integration"];
2499
2515
  SCRAPE_KEYS = ["url", "mode", "allowlistedDomains", "formats", "includeLinks", "debug", "robotsOverride", "actions", "handoff", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2500
2516
  CRAWL_SCOPE_KEYS = ["regexOnFullURL", "ignoreQueryParameters", "deduplicateSimilarURLs", "crawlEntireDomain", "allowSubdomains", "allowExternalLinks"];
2501
- CRAWL_KEYS = ["url", "mode", "maxPages", "maxDepth", "useCached", "allowlistedDomains", "formats", "includeLinks", "includePaths", "excludePaths", ...CRAWL_SCOPE_KEYS, "sitemap", "maxConcurrency", "idempotencyKey", "webhook", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2517
+ CRAWL_KEYS = ["url", "mode", "maxPages", "maxDepth", "useCached", "allowlistedDomains", "formats", "includeLinks", "includePaths", "excludePaths", ...CRAWL_SCOPE_KEYS, "sitemap", "maxConcurrency", "idempotencyKey", "webhook", "ignoreRobotsTxt", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2502
2518
  BATCH_SCOPE_NOOP_KEYS = { allowExternalLinks: "allowExternalLinks", includeSubdomains: "allowSubdomains" };
2503
2519
  BATCH_KEYS = ["urls", "mode", "formats", "includeLinks", "robotsOverrides", "maxConcurrency", "ignoreInvalidURLs", "allowExternalLinks", "includeSubdomains", "idempotencyKey", "appendToId", "webhook", "actions", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2504
2520
  BATCH_APPEND_KEYS = ["urls", "appendToId", "ignoreInvalidURLs", "allowExternalLinks", "includeSubdomains", "idempotencyKey", "robotsOverrides", ...ATTRIBUTION_KEYS];
2505
2521
  ROBOTS_OVERRIDE_KEYS = ["reason", "recordedBy"];
2506
2522
  MAP_SCOPE_KEYS = ["includeSubdomains", "ignoreQueryParameters", "regexOnFullURL", "crawlEntireDomain", "deduplicateSimilarURLs"];
2507
- MAP_KEYS = ["url", "mode", "limit", "timeout", "search", "sitemap", ...MAP_SCOPE_KEYS, "includePaths", "excludePaths", ...ATTRIBUTION_KEYS];
2523
+ MAP_KEYS = ["url", "mode", "limit", "timeout", "search", "sitemap", ...MAP_SCOPE_KEYS, "includePaths", "excludePaths", "ignoreRobotsTxt", ...ATTRIBUTION_KEYS];
2508
2524
  WEBHOOK_KEYS = ["url", "headers", "metadata", "events", "secretEnv"];
2509
2525
  WEBHOOK_RESERVED_HEADERS = /* @__PURE__ */ new Set(["content-type", "content-length", "host", "connection", "transfer-encoding"]);
2510
2526
  WEBHOOK_SECRET_ENV = /^W2L_WEBHOOK_SECRET_[A-Z0-9_]+$/;
@@ -2585,7 +2601,7 @@ function parseFirecrawlScrapeRequest(body) {
2585
2601
  function parseFirecrawlCrawlRequest(body) {
2586
2602
  const rec = asRecord2(body);
2587
2603
  const problems = noProblems();
2588
- checkShimKeys(rec, "", ["url", ...SHIM_ATTRIBUTION, "limit", "maxDepth", "includePaths", "excludePaths", "ignoreSitemap", "sitemapOnly", "scrapeOptions", ...SHIM_CRAWL_SCOPE_OPTIONS, "allowBackwardLinks", "crawlEntireDomain", "idempotencyKey", "webhook"], problems);
2604
+ checkShimKeys(rec, "", ["url", ...SHIM_ATTRIBUTION, "limit", "maxDepth", "includePaths", "excludePaths", "ignoreSitemap", "sitemapOnly", "scrapeOptions", ...SHIM_CRAWL_SCOPE_OPTIONS, "allowBackwardLinks", "crawlEntireDomain", "idempotencyKey", "webhook", "ignoreRobotsTxt"], problems);
2589
2605
  let pageOptions2 = { parsers: shimParsers(void 0, void 0, "scrapeOptions.") };
2590
2606
  if (rec.scrapeOptions !== void 0) {
2591
2607
  const options = rec.scrapeOptions;
@@ -2626,6 +2642,8 @@ function parseFirecrawlCrawlRequest(body) {
2626
2642
  native.crawlEntireDomain = rec.crawlEntireDomain;
2627
2643
  if (rec.idempotencyKey !== void 0)
2628
2644
  native.idempotencyKey = rec.idempotencyKey;
2645
+ if (rec.ignoreRobotsTxt !== void 0)
2646
+ native.ignoreRobotsTxt = rec.ignoreRobotsTxt;
2629
2647
  if (rec.webhook !== void 0)
2630
2648
  native.webhook = rec.webhook;
2631
2649
  const parsed = parseCrawlStartRequest(native);
@@ -3017,9 +3035,9 @@ var init_evidenceRecord = __esm({
3017
3035
  EVIDENCE_SCHEMA_VERSION = "w2l.evidence/1";
3018
3036
  keysOf = () => (keys) => keys;
3019
3037
  EVIDENCE_RECORD_KEYS = {
3020
- record: keysOf()(["schemaVersion", "requestedUrl", "finalUrl", "redirectChain", "fetchedAt", "httpStatus", "status", "reason", "lane", "robotsDecision", "rawSha256", "contentEncoding", "outputSha256", "extractor", "fieldEvidence", "artifacts", "proxy", "identity", "pageActions"]),
3038
+ record: keysOf()(["schemaVersion", "requestedUrl", "finalUrl", "redirectChain", "fetchedAt", "httpStatus", "status", "reason", "lane", "robotsDecision", "rawSha256", "contentEncoding", "outputSha256", "extractor", "fieldEvidence", "artifacts", "proxy", "identity", "pageActions", "access"]),
3021
3039
  redirectChain: keysOf()(["urls", "complete"]),
3022
- robotsDecision: keysOf()(["decision", "robotsUrl", "robotsSha256", "unreachable", "crawlDelayMs", "userOverride"]),
3040
+ robotsDecision: keysOf()(["decision", "robotsUrl", "robotsSha256", "unreachable", "crawlDelayMs", "userOverride", "overrideBasis"]),
3023
3041
  outputSha256: keysOf()(["markdown", "json"]),
3024
3042
  extractor: keysOf()(["name", "version", "commit"]),
3025
3043
  fieldEvidence: keysOf()(["source", "locator"]),
@@ -3027,7 +3045,8 @@ var init_evidenceRecord = __esm({
3027
3045
  identity: keysOf()(["userAgent", "mode", "contact", "device", "requestHeaders"]),
3028
3046
  pageActions: keysOf()(["steps", "scriptRan"]),
3029
3047
  pageActionStep: keysOf()(["type", "outcome"]),
3030
- requestHeader: keysOf()(["name", "valueSha256"])
3048
+ requestHeader: keysOf()(["name", "valueSha256"]),
3049
+ access: keysOf()(["route", "executor", "executorVersion", "profile", "externalCostUsd"])
3031
3050
  };
3032
3051
  }
3033
3052
  });
@@ -3243,7 +3262,7 @@ function evaluateRobots(robots, userAgent, path) {
3243
3262
  return { allowed: true, appliedRules: [], matchedAgent: null, crawlDelayMs: null };
3244
3263
  }
3245
3264
  const ua = userAgent.toLowerCase();
3246
- const matchedAgent = group.agents.find((a) => a !== "*" && ua.includes(a)) ?? group.agents.find((a) => a === "*") ?? null;
3265
+ const matchedAgent = group.agents.filter((a) => a !== "*" && ua.includes(a)).sort((a, b) => b.length - a.length)[0] ?? group.agents.find((a) => a === "*") ?? null;
3247
3266
  const appliedRules = group.rules.filter((r) => globMatches(r.tokens, path)).sort((a, b) => b.patternLength === a.patternLength ? Number(b.allow) - Number(a.allow) : b.patternLength - a.patternLength);
3248
3267
  return {
3249
3268
  allowed: isAllowed(group.rules, path),
@@ -3660,6 +3679,14 @@ var init_resilient = __esm({
3660
3679
  });
3661
3680
 
3662
3681
  // packages/http-core/dist/gate.js
3682
+ function perimeterXChallenge(head, lower) {
3683
+ if (!lower.includes("_pxappid") || !PX_CAPTCHA_SCRIPT.test(lower))
3684
+ return null;
3685
+ const signals = ["px_captcha_script", "px_app_id"];
3686
+ if (PX_CAPTCHA_CONTAINER.test(head))
3687
+ signals.push("px_captcha_container");
3688
+ return signals;
3689
+ }
3663
3690
  function challengePlatformBeyondJsd(lower) {
3664
3691
  const marker = "/cdn-cgi/challenge-platform";
3665
3692
  for (let at = lower.indexOf(marker); at !== -1; at = lower.indexOf(marker, at + marker.length)) {
@@ -3692,6 +3719,9 @@ function classifyGate(res) {
3692
3719
  const lower = head.toLowerCase();
3693
3720
  const contentful = res.contentful === true;
3694
3721
  if (res.status === 429) {
3722
+ const px2 = perimeterXChallenge(head, lower);
3723
+ if (px2 !== null)
3724
+ return { reason: "captcha", signals: [...px2, "status_429"] };
3695
3725
  return { reason: "rate_limit", signals: ["status_429"] };
3696
3726
  }
3697
3727
  if (res.status === 451) {
@@ -3718,6 +3748,12 @@ function classifyGate(res) {
3718
3748
  if (/<form\b[^>]*\bhidden\b/i.test(head) && /\bname\s*=\s*["']js_challenge["']/i.test(head) && /\bname\s*=\s*["']jsc_token["']/i.test(head)) {
3719
3749
  return { reason: "bot_detected_generic", signals: ["reddit_js_verification"] };
3720
3750
  }
3751
+ if (/<title[^>]*>\s*reddit - prove your humanity\s*<\/title>/.test(lower) && lower.includes("google.com/recaptcha/api.js")) {
3752
+ return { reason: "captcha", signals: ["reddit_captcha_title", "recaptcha_script"] };
3753
+ }
3754
+ const px = perimeterXChallenge(head, lower);
3755
+ if (px !== null)
3756
+ return { reason: "captcha", signals: px };
3721
3757
  if (contentful)
3722
3758
  return null;
3723
3759
  const captcha = matched(lower, CAPTCHA_MARKERS);
@@ -3792,7 +3828,7 @@ function nextLane(reason2, from) {
3792
3828
  return from === "browser_proxy" ? null : "browser_proxy";
3793
3829
  }
3794
3830
  }
3795
- var HEAD_BYTES, VENDOR_HEADERS, CF_STRONG_MARKERS, CF_INTERSTITIAL_PAIR, CAPTCHA_MARKERS, LOGIN_INTENT_PHRASES, PASSWORD_INPUT, TITLE_OR_H1, BARE_LOGIN_HEADING, GEO_MARKERS, BOT_STRONG_MARKERS, BOT_WEAK_MARKERS, GATE_SHAPED_STATUS, CF_JSD_PATH;
3831
+ var HEAD_BYTES, VENDOR_HEADERS, CF_STRONG_MARKERS, CF_INTERSTITIAL_PAIR, CAPTCHA_MARKERS, LOGIN_INTENT_PHRASES, PASSWORD_INPUT, TITLE_OR_H1, BARE_LOGIN_HEADING, GEO_MARKERS, BOT_STRONG_MARKERS, BOT_WEAK_MARKERS, GATE_SHAPED_STATUS, CF_JSD_PATH, PX_CAPTCHA_SCRIPT, PX_CAPTCHA_CONTAINER;
3796
3832
  var init_gate = __esm({
3797
3833
  "packages/http-core/dist/gate.js"() {
3798
3834
  "use strict";
@@ -3826,7 +3862,9 @@ var init_gate = __esm({
3826
3862
  ["challenges.cloudflare.com/turnstile", "widget_turnstile_script"],
3827
3863
  ["funcaptcha", "widget_funcaptcha"],
3828
3864
  ["arkoselabs", "widget_arkose"],
3829
- ["data-sitekey", "widget_sitekey"]
3865
+ ["data-sitekey", "widget_sitekey"],
3866
+ // PerimeterX's press-and-hold prompt, as Walmart words it: the widget's own instruction.
3867
+ ["activate and hold the button", "text_activate_and_hold"]
3830
3868
  ];
3831
3869
  LOGIN_INTENT_PHRASES = [
3832
3870
  "sign in to continue",
@@ -3860,6 +3898,7 @@ var init_gate = __esm({
3860
3898
  ["unusual traffic from your computer network", "text_unusual_traffic"],
3861
3899
  ["automated queries", "text_automated_queries"],
3862
3900
  ["are you a robot", "text_are_you_a_robot"],
3901
+ ["robot or human?", "text_robot_or_human"],
3863
3902
  ["verify you are human", "text_verify_you_are_human"],
3864
3903
  ["verifying you are human", "text_verifying_you_are_human"],
3865
3904
  ["bot detected", "text_bot_detected"],
@@ -3878,6 +3917,8 @@ var init_gate = __esm({
3878
3917
  ];
3879
3918
  GATE_SHAPED_STATUS = [403, 429, 503];
3880
3919
  CF_JSD_PATH = "/cdn-cgi/challenge-platform/scripts/jsd/";
3920
+ PX_CAPTCHA_SCRIPT = /\/captcha\/captcha\.js|captcha\.px-(?:cdn|cloud)\.net\/[^"'\s<>]*captcha\.js/;
3921
+ PX_CAPTCHA_CONTAINER = /<[a-z][^>]*\sid\s*=\s*["']?px-captcha(?=["'\s>/])/i;
3881
3922
  }
3882
3923
  });
3883
3924
 
@@ -4149,18 +4190,47 @@ var init_access2 = __esm({
4149
4190
  });
4150
4191
 
4151
4192
  // packages/http-core/dist/vendor.js
4193
+ function evaluateAccessCapability(capability, granted = []) {
4194
+ const forever = REFUSED_FOREVER.find((e) => e.capability === capability);
4195
+ if (forever !== void 0) {
4196
+ return { capability, decision: "refused", reason: `${capability} is never enabled (ADR 0005): ${forever.principle}` };
4197
+ }
4198
+ const deferred = DEFERRED.find((e) => e.capability === capability);
4199
+ if (deferred !== void 0) {
4200
+ return {
4201
+ capability,
4202
+ decision: "deferred",
4203
+ source: deferred.source,
4204
+ restartWhen: deferred.restartWhen,
4205
+ reason: `${capability} is deferred (ADR 0005; ${deferred.source}, "${deferred.row}"); it restarts when: ${deferred.restartWhen}`
4206
+ };
4207
+ }
4208
+ if (AUTHORIZABLE.some((e) => e.capability === capability)) {
4209
+ return granted.includes(capability) ? { capability, decision: "granted" } : { capability, decision: "not_granted", reason: `${capability} is off unless a grant names it (ADR 0005)` };
4210
+ }
4211
+ return { capability, decision: "unknown", reason: `${capability} is not an access capability ADR 0005 names` };
4212
+ }
4152
4213
  function evaluateVendorPolicy(offers, policy = DEFAULT_VENDOR_POLICY) {
4153
- const authorized = new Set(policy.authorized ?? []);
4214
+ const authorized = [...new Set(policy.authorized ?? [])];
4215
+ const operational = OPERATIONAL_POLICY_KEYS;
4216
+ const declined = authorized.filter((key) => !operational.includes(key)).map((key) => evaluateAccessCapability(key, authorized)).filter((v) => v.decision === "refused" || v.decision === "deferred" || v.decision === "unknown");
4154
4217
  const enabled = [];
4218
+ const withheld = [];
4155
4219
  for (const offer of offers) {
4156
- if (REFUSED_CAPABILITIES.includes(offer.capability)) {
4220
+ const access = VENDOR_CAPABILITY_ACCESS[offer.capability];
4221
+ if (access !== void 0) {
4222
+ if (evaluateAccessCapability(access, authorized).decision === "granted") {
4223
+ enabled.push({ capability: offer.capability, enableKey: access, optOutRequired: offer.vendorDefaultOn });
4224
+ } else {
4225
+ withheld.push(offer.capability);
4226
+ }
4157
4227
  continue;
4158
4228
  }
4159
4229
  if (offer.enableKey === null) {
4160
4230
  enabled.push({ capability: offer.capability, enableKey: null, optOutRequired: offer.vendorDefaultOn });
4161
4231
  continue;
4162
4232
  }
4163
- if (authorized.has(offer.enableKey)) {
4233
+ if (authorized.includes(offer.enableKey)) {
4164
4234
  enabled.push({
4165
4235
  capability: offer.capability,
4166
4236
  enableKey: offer.enableKey,
@@ -4168,20 +4238,99 @@ function evaluateVendorPolicy(offers, policy = DEFAULT_VENDOR_POLICY) {
4168
4238
  });
4169
4239
  }
4170
4240
  }
4171
- const unauthorized = [...authorized].filter((key) => !offers.some((o) => o.enableKey === key));
4172
- return { enabled, unauthorized, refused: [...REFUSED_CAPABILITIES] };
4241
+ const vendorKeys = /* @__PURE__ */ new Set([...operational, ...Object.values(VENDOR_CAPABILITY_ACCESS)]);
4242
+ const offered = new Set(offers.flatMap((o) => [o.enableKey, VENDOR_CAPABILITY_ACCESS[o.capability]]));
4243
+ const declinedKeys = new Set(declined.map((v) => v.capability));
4244
+ const unauthorized = authorized.filter((key) => vendorKeys.has(key) && !declinedKeys.has(key) && !offered.has(key));
4245
+ return { enabled, unauthorized, withheld, declined, refused: REFUSED_FOREVER.map((e) => e.capability) };
4173
4246
  }
4174
- var REFUSED_CAPABILITIES, DEFAULT_VENDOR_POLICY;
4247
+ var REFUSED_FOREVER, DEFERRED, AUTHORIZABLE, VENDOR_CAPABILITY_ACCESS, DEFAULT_VENDOR_POLICY, OPERATIONAL_POLICY_KEYS, AUTHORIZABLE_POLICY_KEYS;
4175
4248
  var init_vendor = __esm({
4176
4249
  "packages/http-core/dist/vendor.js"() {
4177
4250
  "use strict";
4178
- REFUSED_CAPABILITIES = [
4179
- "fingerprint_spoofing",
4180
- "cdp_patching",
4181
- "captcha_solving",
4182
- "identity_rotation"
4251
+ REFUSED_FOREVER = [
4252
+ {
4253
+ capability: "identity_rotation",
4254
+ principle: "cycling identities to outlast a block, including answering a 429 with another IP and carrying on"
4255
+ },
4256
+ {
4257
+ capability: "patch_user_chrome",
4258
+ principle: "modifying, patching or injecting into the person's own Chrome"
4259
+ },
4260
+ {
4261
+ capability: "in_session_route_switch",
4262
+ principle: "switching executor or egress inside an established interactive session without saying so"
4263
+ },
4264
+ {
4265
+ capability: "secrets_in_records",
4266
+ principle: "writing proxy credentials, cookies, solver tokens or a cdpEndpoint into a record or a log"
4267
+ }
4268
+ ];
4269
+ DEFERRED = [
4270
+ {
4271
+ capability: "own_browser_engine",
4272
+ source: "ROADMAP.md:263",
4273
+ row: "An own browser fork or engine, and broad custom-fingerprint research",
4274
+ restartWhen: "A maintained project stops working for a class of tasks that PA's set shows matters"
4275
+ },
4276
+ {
4277
+ capability: "own_fingerprint_patches",
4278
+ source: "ROADMAP.md:263",
4279
+ row: "An own browser fork or engine, and broad custom-fingerprint research",
4280
+ restartWhen: "A maintained project stops working for a class of tasks that PA's set shows matters"
4281
+ },
4282
+ {
4283
+ capability: "own_captcha_model",
4284
+ source: "ROADMAP.md:264",
4285
+ row: "An in-house CAPTCHA model",
4286
+ restartWhen: "Only if solver cost or coverage blocks paying users"
4287
+ },
4288
+ {
4289
+ capability: "own_residential_network",
4290
+ source: "ROADMAP.md:265",
4291
+ row: "An own residential IP network",
4292
+ restartWhen: "Not restarted; PA uses providers"
4293
+ },
4294
+ {
4295
+ capability: "camoufox",
4296
+ source: "ROADMAP.md:266",
4297
+ row: "A second stealth engine (Camoufox)",
4298
+ restartWhen: "Patchright leaves a clear class of PA tasks unsolved"
4299
+ },
4300
+ {
4301
+ capability: "hosted_browser_cluster",
4302
+ source: "ROADMAP.md:267",
4303
+ row: "A hosted browser cluster beyond Cloud Run's instance cap",
4304
+ restartWhen: "PH's phase 2 shows browser-lane demand that the instance cap cannot serve, and a key holder will pay for it"
4305
+ }
4306
+ ];
4307
+ AUTHORIZABLE = [
4308
+ { capability: "compatible_transport", tier: "standard", thirdPartyCost: false },
4309
+ { capability: "egress_sessions", tier: "standard", thirdPartyCost: false },
4310
+ { capability: "enhanced_browser", tier: "enhanced", thirdPartyCost: false },
4311
+ { capability: "vendor_remote_browser", tier: "enhanced", thirdPartyCost: true },
4312
+ { capability: "vendor_unlock_html", tier: "enhanced", thirdPartyCost: true },
4313
+ { capability: "vendor_captcha_solving", tier: "enhanced", thirdPartyCost: true },
4314
+ { capability: "vendor_stealth", tier: "enhanced", thirdPartyCost: true },
4315
+ { capability: "third_party_captcha_solver", tier: "enhanced", thirdPartyCost: true }
4183
4316
  ];
4317
+ VENDOR_CAPABILITY_ACCESS = {
4318
+ fingerprint_spoofing: "vendor_stealth",
4319
+ cdp_patching: "vendor_stealth",
4320
+ captcha_solving: "vendor_captcha_solving",
4321
+ identity_rotation: "identity_rotation"
4322
+ };
4184
4323
  DEFAULT_VENDOR_POLICY = { authorized: [] };
4324
+ OPERATIONAL_POLICY_KEYS = [
4325
+ "session_persistence",
4326
+ "live_view_handoff",
4327
+ "residential_proxy",
4328
+ "retry_orchestration"
4329
+ ];
4330
+ AUTHORIZABLE_POLICY_KEYS = [
4331
+ ...OPERATIONAL_POLICY_KEYS,
4332
+ ...AUTHORIZABLE.map((e) => e.capability)
4333
+ ];
4185
4334
  }
4186
4335
  });
4187
4336
 
@@ -4193,14 +4342,19 @@ function evaluateProviderGate(provider, robots, path, unreachable = null) {
4193
4342
  appliedRules: [],
4194
4343
  refusedCapabilities: []
4195
4344
  };
4196
- const refused = provider.capabilities.filter((c) => REFUSED_CAPABILITIES.includes(c));
4197
- if (refused.length > 0) {
4345
+ const verdicts = provider.capabilities.flatMap((c) => {
4346
+ const access = VENDOR_CAPABILITY_ACCESS[c];
4347
+ return access === void 0 ? [] : [{ capability: c, verdict: evaluateAccessCapability(access, provider.grants ?? []) }];
4348
+ });
4349
+ const blocked = verdicts.filter((v) => v.verdict.decision !== "granted");
4350
+ if (blocked.length > 0) {
4351
+ const forever = blocked.some((v) => v.verdict.decision === "refused");
4198
4352
  return {
4199
4353
  ...base,
4200
4354
  allowed: false,
4201
- refusal: "refused_capability",
4202
- reason: `Provider ${provider.id} offers ${refused.join(", ")}. Routing to it would buy the stealth layer we declined to build \u2014 changing whose machine runs the browser does not change what the browser is doing.`,
4203
- refusedCapabilities: refused
4355
+ refusal: forever ? "refused_capability" : "ungranted_capability",
4356
+ reason: `Provider ${provider.id} offers ${blocked.map((v) => v.capability).join(", ")}: ` + blocked.map((v) => "reason" in v.verdict ? v.verdict.reason : v.capability).join("; ") + ". Changing whose machine runs the browser does not change what the browser is doing.",
4357
+ refusedCapabilities: blocked.map((v) => v.capability)
4204
4358
  };
4205
4359
  }
4206
4360
  if (provider.declaredUserAgent === null) {
@@ -4253,8 +4407,124 @@ var init_provider = __esm({
4253
4407
  "use strict";
4254
4408
  init_robots();
4255
4409
  init_vendor();
4410
+ }
4411
+ });
4412
+
4413
+ // packages/http-core/dist/accessGrant.js
4414
+ function isRecord(v) {
4415
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4416
+ }
4417
+ function usd(v) {
4418
+ if (v === void 0 || v === null)
4419
+ return null;
4420
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
4421
+ }
4422
+ function normalizeAccessGrant(input) {
4423
+ if (!isRecord(input)) {
4424
+ return { ok: false, problems: [{ field: "grant", kind: "invalid", reason: "a grant is a JSON object" }] };
4425
+ }
4426
+ const problems = [];
4427
+ const tier = input.tier ?? "standard";
4428
+ if (!ACCESS_TIERS.includes(tier)) {
4429
+ problems.push({ field: "tier", kind: "invalid", reason: `tier is one of ${ACCESS_TIERS.join(", ")}` });
4430
+ }
4431
+ const names = input.capabilities ?? [];
4432
+ const capabilities = [];
4433
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
4434
+ problems.push({ field: "capabilities", kind: "invalid", reason: "capabilities is a list of capability names" });
4435
+ } else {
4436
+ for (const name of new Set(names)) {
4437
+ const field3 = `capabilities.${name}`;
4438
+ const verdict = evaluateAccessCapability(name, [name]);
4439
+ if (verdict.decision === "refused") {
4440
+ problems.push({ field: field3, kind: "refused", reason: verdict.reason });
4441
+ } else if (verdict.decision === "deferred") {
4442
+ problems.push({ field: field3, kind: "deferred", reason: verdict.reason, source: verdict.source, restartWhen: verdict.restartWhen });
4443
+ } else if (verdict.decision === "unknown") {
4444
+ problems.push({ field: field3, kind: "unknown_capability", reason: verdict.reason });
4445
+ } else {
4446
+ const entry3 = AUTHORIZABLE.find((e) => e.capability === name);
4447
+ if (entry3.tier === "enhanced" && tier !== "enhanced") {
4448
+ problems.push({ field: field3, kind: "tier_too_low", reason: `${name} needs tier enhanced; ${String(tier)} adds no third-party cost and no enhanced browser` });
4449
+ }
4450
+ capabilities.push(entry3.capability);
4451
+ }
4452
+ }
4453
+ }
4454
+ const budgetIn = input.budget ?? {};
4455
+ let perRequestUsd = null;
4456
+ let perRunUsd = null;
4457
+ if (!isRecord(budgetIn)) {
4458
+ problems.push({ field: "budget", kind: "invalid", reason: "budget is an object with perRequestUsd and perRunUsd" });
4459
+ } else {
4460
+ const req = usd(budgetIn.perRequestUsd);
4461
+ const run = usd(budgetIn.perRunUsd);
4462
+ if (req === void 0)
4463
+ problems.push({ field: "budget.perRequestUsd", kind: "invalid", reason: "a positive number of US dollars, or null" });
4464
+ if (run === void 0)
4465
+ problems.push({ field: "budget.perRunUsd", kind: "invalid", reason: "a positive number of US dollars, or null" });
4466
+ perRequestUsd = req ?? null;
4467
+ perRunUsd = run ?? null;
4468
+ }
4469
+ const costed = capabilities.filter((c) => AUTHORIZABLE.find((e) => e.capability === c).thirdPartyCost);
4470
+ if (costed.length > 0 && perRunUsd === null && !problems.some((p) => p.field === "budget.perRunUsd")) {
4471
+ problems.push({
4472
+ field: "budget.perRunUsd",
4473
+ kind: "budget_required",
4474
+ reason: `${costed.join(", ")} can spend a third party's money; a grant naming them needs a positive perRunUsd`
4475
+ });
4476
+ }
4477
+ const scopeIn = input.scope ?? {};
4478
+ let hosts = null;
4479
+ if (!isRecord(scopeIn) || scopeIn.hosts !== void 0 && scopeIn.hosts !== null && (!Array.isArray(scopeIn.hosts) || scopeIn.hosts.some((h) => typeof h !== "string" || h.length === 0))) {
4480
+ problems.push({ field: "scope.hosts", kind: "invalid", reason: "scope.hosts is a list of host names, or null" });
4481
+ } else if (Array.isArray(scopeIn.hosts)) {
4482
+ hosts = scopeIn.hosts;
4483
+ }
4484
+ const att = input.attestation;
4485
+ let attestation = null;
4486
+ if (att !== void 0 && att !== null) {
4487
+ if (!isRecord(att) || [att.principal, att.at, att.statement].some((v) => typeof v !== "string" || v.trim().length === 0)) {
4488
+ problems.push({ field: "attestation", kind: "invalid", reason: "attestation needs principal, at and statement" });
4489
+ } else {
4490
+ attestation = { principal: att.principal, at: att.at, statement: att.statement };
4491
+ }
4492
+ }
4493
+ const enhancedOnly = capabilities.filter((c) => AUTHORIZABLE.find((e) => e.capability === c).tier === "enhanced");
4494
+ if (enhancedOnly.length > 0 && attestation === null && !problems.some((p) => p.field === "attestation")) {
4495
+ problems.push({
4496
+ field: "attestation",
4497
+ kind: "attestation_required",
4498
+ reason: `${enhancedOnly.join(", ")} needs an attestation: who accepted it, when, and the statement they accepted`
4499
+ });
4500
+ }
4501
+ if (problems.length > 0)
4502
+ return { ok: false, problems };
4503
+ return { ok: true, grant: { tier, capabilities, budget: { perRequestUsd, perRunUsd }, scope: { hosts }, attestation } };
4504
+ }
4505
+ function accessGrantFromText(text2) {
4506
+ let input;
4507
+ try {
4508
+ input = JSON.parse(text2);
4509
+ } catch {
4510
+ throw new Error("access grant: not valid JSON");
4511
+ }
4512
+ const result = normalizeAccessGrant(input);
4513
+ if (!result.ok) {
4514
+ throw new Error(`access grant refused (ADR 0005):
4515
+ ${result.problems.map((p) => ` - ${p.field}: ${p.reason}`).join("\n")}`);
4516
+ }
4517
+ if (result.grant.scope.hosts !== null) {
4518
+ throw new Error("access grant refused: scope.hosts is not enforced yet, so a grant that names hosts would reach every host; leave scope out");
4519
+ }
4520
+ return result.grant;
4521
+ }
4522
+ var ACCESS_TIERS;
4523
+ var init_accessGrant = __esm({
4524
+ "packages/http-core/dist/accessGrant.js"() {
4525
+ "use strict";
4256
4526
  init_vendor();
4257
- init_vendor();
4527
+ ACCESS_TIERS = ["standard", "enhanced", "my_browser"];
4258
4528
  }
4259
4529
  });
4260
4530
 
@@ -4310,12 +4580,12 @@ var init_failureClass = __esm({
4310
4580
  // packages/http-core/dist/governance.js
4311
4581
  function hostMatchesAllowlist(host, entry3) {
4312
4582
  const h = host.toLowerCase();
4313
- const normalized = entry3.toLowerCase();
4314
- if (normalized.startsWith("*.")) {
4315
- const suffix = normalized.slice(1);
4316
- return h === normalized.slice(2) || h.endsWith(suffix);
4583
+ const normalized2 = entry3.toLowerCase();
4584
+ if (normalized2.startsWith("*.")) {
4585
+ const suffix = normalized2.slice(1);
4586
+ return h === normalized2.slice(2) || h.endsWith(suffix);
4317
4587
  }
4318
- return h === normalized;
4588
+ return h === normalized2;
4319
4589
  }
4320
4590
  function evaluateGovernance(url, policy) {
4321
4591
  let host;
@@ -4343,7 +4613,8 @@ var init_governance = __esm({
4343
4613
  "packages/http-core/dist/governance.js"() {
4344
4614
  "use strict";
4345
4615
  MODE_CHANNELS = {
4346
- standard: ["http", "browser_local"],
4616
+ // http_compat: the http lane over the browser-compatible transport (ADR 0005 compatible_transport); its identity is the standard one.
4617
+ standard: ["http", "http_compat", "browser_local"],
4347
4618
  research: ["http", "browser_local", "provider"],
4348
4619
  authed: ["http", "browser_local", "provider", "authed_session", "handoff"]
4349
4620
  };
@@ -4416,6 +4687,10 @@ function serialize(input) {
4416
4687
  field(c, "robots.override");
4417
4688
  field(c, r.override.reason);
4418
4689
  nullableField(c, r.override.recordedBy ?? null);
4690
+ if (r.override.basis !== void 0) {
4691
+ field(c, "robots.override.basis");
4692
+ field(c, r.override.basis);
4693
+ }
4419
4694
  }
4420
4695
  const total = c.reduce((n, arr) => n + arr.length, 0);
4421
4696
  const out = new Uint8Array(total);
@@ -4621,6 +4896,7 @@ var init_dist2 = __esm({
4621
4896
  init_access2();
4622
4897
  init_provider();
4623
4898
  init_vendor();
4899
+ init_accessGrant();
4624
4900
  init_failureClass();
4625
4901
  init_governance();
4626
4902
  init_compliance2();
@@ -7620,10 +7896,76 @@ function collectDeclaredProductFacts(doc) {
7620
7896
  function fillPriceFromText(facts, doc) {
7621
7897
  fillMissing(facts, factsFromText(doc));
7622
7898
  }
7899
+ function isLabelled(el, doc) {
7900
+ if (el.previousElementSibling !== null && tagOf(el.previousElementSibling) === "label")
7901
+ return true;
7902
+ if (el.closest("label") !== null || (el.getAttribute("aria-label") ?? "").trim() !== "")
7903
+ return true;
7904
+ if (["radiogroup", "listbox"].includes((el.getAttribute("role") ?? "").toLowerCase()))
7905
+ return true;
7906
+ const id = el.getAttribute("id");
7907
+ return id !== null && id !== "" && qsa(doc, "label[for]").some((label) => label.getAttribute("for") === id);
7908
+ }
7909
+ function isProductOption(el) {
7910
+ const form = el.closest("form");
7911
+ if (form !== null && CART_FORM.test(`${form.getAttribute("action") ?? ""} ${form.getAttribute("id") ?? ""} ${form.getAttribute("class") ?? ""}`))
7912
+ return true;
7913
+ return [el, el.parentElement, ...Array.from(el.children)].some((node) => node !== null && OPTION_NAME.test(`${node.getAttribute("name") ?? ""} ${node.getAttribute("id") ?? ""} ${node.getAttribute("class") ?? ""}`));
7914
+ }
7915
+ function markOptionGroups(doc, excluded = /* @__PURE__ */ new Set()) {
7916
+ const marked = /* @__PURE__ */ new Map();
7917
+ const outside = (el) => ![...excluded].some((ex) => ex.contains(el));
7918
+ const place = (replaced, values) => {
7919
+ const unique = [...new Set(values.map((v) => v.replace(/\s+/g, " ").trim()).filter((v) => v !== ""))];
7920
+ if (unique.length < 2)
7921
+ return;
7922
+ const placeholder = doc.createElement("span");
7923
+ replaced.parentNode?.insertBefore(placeholder, replaced);
7924
+ marked.set(placeholder, unique.join(", "));
7925
+ };
7926
+ for (const select of qsa(doc, "select")) {
7927
+ if (!outside(select) || !isLabelled(select, doc) || !isProductOption(select))
7928
+ continue;
7929
+ const choices = qsa(select, "option").filter((option) => option.getAttribute("value") !== "").map((option) => textOf(option).trim());
7930
+ const counts = choices.every((choice, at) => /^\d+$/.test(choice) && Number(choice) === Number(choices[0]) + at) && [0, 1].includes(Number(choices[0]));
7931
+ if (!counts)
7932
+ place(select, choices);
7933
+ }
7934
+ for (const group of qsa(doc, "div,span,ul,fieldset,p")) {
7935
+ const kids = Array.from(group.children);
7936
+ if (kids.length < 2 || !kids.every((kid) => tagOf(kid) === "button" && (kid.getAttribute("type") ?? "").toLowerCase() !== "submit"))
7937
+ continue;
7938
+ if (!outside(group) || !isLabelled(group, doc) || !isProductOption(group))
7939
+ continue;
7940
+ place(kids[0], kids.map((kid) => textOf(kid)));
7941
+ }
7942
+ return marked;
7943
+ }
7944
+ function settleOptionGroups(marked, keep) {
7945
+ for (const [placeholder, values] of marked) {
7946
+ if (keep && placeholder.parentNode !== null) {
7947
+ const text2 = placeholder.ownerDocument.createTextNode(` ${values} `);
7948
+ optionValues.add(text2);
7949
+ placeholder.parentNode.replaceChild(text2, placeholder);
7950
+ } else
7951
+ placeholder.parentNode?.removeChild(placeholder);
7952
+ }
7953
+ }
7623
7954
  function isRootish(doc, el) {
7624
7955
  return el === null || el === doc.body || el === doc.documentElement;
7625
7956
  }
7957
+ function describes(block) {
7958
+ const unlabelled = (node) => {
7959
+ if (node.nodeType === 3)
7960
+ return optionValues.has(node) ? "" : node.textContent ?? "";
7961
+ if (node.nodeType !== 1 || tagOf(node) === "label" || tagOf(node) === "a")
7962
+ return "";
7963
+ return Array.from(node.childNodes).map(unlabelled).join("");
7964
+ };
7965
+ return /[\p{L}\p{N}]/u.test(unlabelled(block.el));
7966
+ }
7626
7967
  function selectProduct(doc, blocks) {
7968
+ const described2 = blocks.filter(describes);
7627
7969
  const scope = microdataProductScope(doc);
7628
7970
  if (scope && !isRootish(doc, scope))
7629
7971
  return scope;
@@ -7633,11 +7975,10 @@ function selectProduct(doc, blocks) {
7633
7975
  const lca = commonAncestor(heading2, price);
7634
7976
  if (!isRootish(doc, lca)) {
7635
7977
  const region = lca;
7636
- const prose3 = blocks.filter((b) => region.contains(b.el));
7637
- if (prose3.length > 0)
7978
+ if (described2.some((b) => region.contains(b.el)))
7638
7979
  return region;
7639
7980
  for (let wider = region.parentElement; !isRootish(doc, wider); wider = wider.parentElement) {
7640
- if (blocks.some((b) => wider.contains(b.el)))
7981
+ if (described2.some((b) => wider.contains(b.el)))
7641
7982
  return wider;
7642
7983
  }
7643
7984
  return region;
@@ -7651,7 +7992,7 @@ function selectProduct(doc, blocks) {
7651
7992
  }
7652
7993
  return null;
7653
7994
  }
7654
- var CURRENCY_SYMBOLS, CURRENCY_CODES, AMOUNT, PRICE_RE;
7995
+ var CURRENCY_SYMBOLS, CURRENCY_CODES, AMOUNT, PRICE_RE, CART_FORM, OPTION_NAME, optionValues;
7655
7996
  var init_product = __esm({
7656
7997
  "packages/extract-tf/dist/product.js"() {
7657
7998
  "use strict";
@@ -7660,6 +8001,9 @@ var init_product = __esm({
7660
8001
  CURRENCY_CODES = "(?:USD|EUR|GBP|JPY|CNY|RMB|AUD|CAD|CHF|HKD|SGD|INR|KRW|BRL|MXN|SEK|NOK|DKK|PLN|TRY|ZAR)";
7661
8002
  AMOUNT = "(?:(?<![\\d.,'\u2019])\\d{1,3}(?:[\\s'\u2019]\\d{3}){1,4}(?:[.,]\\d{1,3})?|\\d{1,12}(?:[.,]\\d{1,3}){0,4})";
7662
8003
  PRICE_RE = new RegExp(`(?:${CURRENCY_SYMBOLS}|${CURRENCY_CODES})\\s{0,3}${AMOUNT}|${AMOUNT}\\s{0,3}(?:${CURRENCY_SYMBOLS}|${CURRENCY_CODES})`, "u");
8004
+ CART_FORM = /cart|basket|bag/i;
8005
+ OPTION_NAME = /swatch|variant|variation|attribute/i;
8006
+ optionValues = /* @__PURE__ */ new WeakSet();
7663
8007
  }
7664
8008
  });
7665
8009
 
@@ -7669,10 +8013,10 @@ function hasToken(attr2, tokens) {
7669
8013
  return tokens.some((t) => lower.split(/[\s_-]+/).includes(t));
7670
8014
  }
7671
8015
  function hasRecommendationToken(attr2) {
7672
- const normalized = attr2.toLowerCase().replace(/([a-z])([A-Z])/g, "$1-$2").split(/\s+/).filter((t) => t.length > 0);
8016
+ const normalized2 = attr2.toLowerCase().replace(/([a-z])([A-Z])/g, "$1-$2").split(/\s+/).filter((t) => t.length > 0);
7673
8017
  const camelSplit = attr2.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
7674
8018
  const parts = camelSplit.split(/[\s_]+/).filter((t) => t.length > 0);
7675
- const all = /* @__PURE__ */ new Set([...normalized, ...parts]);
8019
+ const all = /* @__PURE__ */ new Set([...normalized2, ...parts]);
7676
8020
  return RECOMMENDATION_TOKENS.some((t) => all.has(t));
7677
8021
  }
7678
8022
  function isRecommendationHeading(text2) {
@@ -7685,14 +8029,17 @@ function isProductGrid(el) {
7685
8029
  const kids = Array.from(el.children);
7686
8030
  if (kids.length < 3)
7687
8031
  return false;
8032
+ const pricedByTag = /* @__PURE__ */ new Map();
7688
8033
  let priced = 0;
7689
8034
  for (const kid of kids) {
7690
8035
  if (qsa(kid, "a").length === 0)
7691
8036
  continue;
7692
- if (looksLikePrice(textOf(kid)))
7693
- priced++;
8037
+ if (!looksLikePrice(textOf(kid)))
8038
+ continue;
8039
+ priced++;
8040
+ pricedByTag.set(tagOf(kid), (pricedByTag.get(tagOf(kid)) ?? 0) + 1);
7694
8041
  }
7695
- return priced >= 3 && priced >= kids.length * 0.6;
8042
+ return Math.max(0, ...pricedByTag.values()) >= 3 && priced >= kids.length * 0.6;
7696
8043
  }
7697
8044
  function recommendationRegion(heading2) {
7698
8045
  const rank = Number(/^h([1-6])$/.exec(tagOf(heading2))?.[1] ?? "6");
@@ -7706,6 +8053,8 @@ function recommendationRegion(heading2) {
7706
8053
  return region;
7707
8054
  }
7708
8055
  function pruneRecommendations(doc) {
8056
+ const h1s = qsa(doc, "h1");
8057
+ const holdsH1 = (el) => h1s.length === 1 && el.contains(h1s[0]);
7709
8058
  for (const heading2 of qsa(doc, "h1,h2,h3,h4,h5,h6")) {
7710
8059
  if (!heading2.isConnected)
7711
8060
  continue;
@@ -7718,14 +8067,14 @@ function pruneRecommendations(doc) {
7718
8067
  for (const el of containers.reverse()) {
7719
8068
  if (!el.isConnected)
7720
8069
  continue;
7721
- if (isProductGrid(el))
8070
+ if (!holdsH1(el) && isProductGrid(el))
7722
8071
  detach(el);
7723
8072
  }
7724
8073
  for (const el of qsa(doc, "[id],[class]")) {
7725
8074
  if (!el.isConnected)
7726
8075
  continue;
7727
8076
  const attr2 = `${el.getAttribute("id") ?? ""} ${el.getAttribute("class") ?? ""}`;
7728
- if (hasRecommendationToken(attr2))
8077
+ if (hasRecommendationToken(attr2) && !holdsH1(el))
7729
8078
  detach(el);
7730
8079
  }
7731
8080
  }
@@ -7969,18 +8318,93 @@ var init_prune = __esm({
7969
8318
  function collapsed(text2) {
7970
8319
  return text2.replace(/\s+/g, " ").trim();
7971
8320
  }
8321
+ function hydrationPassages(data, out) {
8322
+ const stack = [data];
8323
+ while (stack.length > 0 && out.length < HYDRATION_MAX_PASSAGES) {
8324
+ const node = stack.pop();
8325
+ if (typeof node === "string") {
8326
+ const text2 = collapsed(node);
8327
+ if (text2.length >= HYDRATION_TEXT_MIN_CHARS && (text2.match(/ /g) ?? []).length >= HYDRATION_TEXT_MIN_SPACES)
8328
+ out.push(text2);
8329
+ } else if (Array.isArray(node)) {
8330
+ for (const item of node)
8331
+ stack.push(item);
8332
+ } else if (typeof node === "object" && node !== null) {
8333
+ for (const value of Object.values(node))
8334
+ stack.push(value);
8335
+ }
8336
+ }
8337
+ }
8338
+ function recordName(item) {
8339
+ if (typeof item !== "object" || item === null || Array.isArray(item))
8340
+ return null;
8341
+ const record3 = item;
8342
+ const name = typeof record3.name === "string" ? record3.name : typeof record3.title === "string" ? record3.title : null;
8343
+ if (name === null)
8344
+ return null;
8345
+ const text2 = collapsed(name).toLowerCase();
8346
+ return text2.length >= LIST_NAME_MIN_CHARS ? text2 : null;
8347
+ }
8348
+ function collectRecordLists(data, out) {
8349
+ const stack = [data];
8350
+ while (stack.length > 0 && out.length < LIST_MAX_LISTS) {
8351
+ const node = stack.pop();
8352
+ if (Array.isArray(node)) {
8353
+ const names = /* @__PURE__ */ new Set();
8354
+ for (const item of node) {
8355
+ if (typeof item === "object" && item !== null)
8356
+ stack.push(item);
8357
+ const name = names.size < LIST_MAX_NAMES ? recordName(item) : null;
8358
+ if (name !== null)
8359
+ names.add(name);
8360
+ }
8361
+ if (names.size >= LIST_MIN_RECORDS)
8362
+ out.push([...names]);
8363
+ } else if (typeof node === "object" && node !== null) {
8364
+ for (const value of Object.values(node))
8365
+ if (typeof value === "object" && value !== null)
8366
+ stack.push(value);
8367
+ }
8368
+ }
8369
+ }
8370
+ function hydrationShown(raw, doc) {
8371
+ const passages = [];
8372
+ for (const text2 of raw.hydrationJson) {
8373
+ try {
8374
+ hydrationPassages(JSON.parse(text2), passages);
8375
+ } catch {
8376
+ }
8377
+ }
8378
+ if (passages.length === 0 || doc.body === null)
8379
+ return false;
8380
+ const seen = collapsed(textOf(doc.body));
8381
+ return passages.every((passage) => seen.includes(passage));
8382
+ }
8383
+ function recordLists(raw) {
8384
+ const lists = [];
8385
+ for (const text2 of raw.hydrationJson) {
8386
+ try {
8387
+ collectRecordLists(JSON.parse(text2), lists);
8388
+ } catch {
8389
+ }
8390
+ }
8391
+ return lists;
8392
+ }
7972
8393
  function rawSignals(doc) {
7973
8394
  const markers = /* @__PURE__ */ new Set();
7974
8395
  let scriptChars = 0;
7975
8396
  let hydration = false;
8397
+ const hydrationJson = [];
7976
8398
  for (const script of qsa(doc, "script")) {
7977
8399
  const text2 = script.textContent ?? "";
7978
8400
  if (script.getAttribute("src") === null)
7979
8401
  scriptChars += text2.length;
7980
8402
  const type = (script.getAttribute("type") ?? "").toLowerCase();
7981
- if (script.id === "__NEXT_DATA__" || type === "application/json" && text2.length >= HYDRATION_MIN_CHARS)
8403
+ if (script.id === "__NEXT_DATA__" || type === "application/json" && text2.length >= HYDRATION_MIN_CHARS) {
7982
8404
  hydration = true;
7983
- else if (text2.length >= 64 && HYDRATION_SCRIPT.test(text2))
8405
+ if (text2.length <= HYDRATION_MAX_JSON_CHARS)
8406
+ hydrationJson.push(text2);
8407
+ } else if (text2.length >= 64 && HYDRATION_SCRIPT.test(text2))
7984
8408
  hydration = true;
7985
8409
  }
7986
8410
  if (hydration)
@@ -8005,7 +8429,7 @@ function rawSignals(doc) {
8005
8429
  }
8006
8430
  if (qsa(doc, '[aria-busy="true"]').length > 0)
8007
8431
  markers.add("aria_busy");
8008
- return { scriptChars, markers: Array.from(markers) };
8432
+ return { scriptChars, markers: Array.from(markers), hydrationJson };
8009
8433
  }
8010
8434
  function countEmptyTables(doc) {
8011
8435
  let empty = 0;
@@ -8015,8 +8439,20 @@ function countEmptyTables(doc) {
8015
8439
  }
8016
8440
  return empty;
8017
8441
  }
8018
- function detectRenderSignals(raw, cleaned) {
8019
- const textChars = cleaned.body ? collapsed(textOf(cleaned.body)).length : 0;
8442
+ function partialRecordList(lists, visible) {
8443
+ let partial = null;
8444
+ for (const names of lists) {
8445
+ const shown = names.filter((name) => visible.includes(name)).length;
8446
+ if (shown < LIST_MIN_SHOWN || shown * 2 > names.length)
8447
+ continue;
8448
+ if (partial === null || names.length - shown > partial.declared - partial.shown)
8449
+ partial = { declared: names.length, shown };
8450
+ }
8451
+ return partial;
8452
+ }
8453
+ function detectRenderSignals(raw, cleaned, context = {}) {
8454
+ const visible = cleaned.body ? collapsed(textOf(cleaned.body)) : "";
8455
+ const textChars = visible.length;
8020
8456
  const emptyTables = countEmptyTables(cleaned);
8021
8457
  const markers = raw.markers;
8022
8458
  const has2 = (m) => markers.includes(m);
@@ -8025,26 +8461,30 @@ function detectRenderSignals(raw, cleaned) {
8025
8461
  reason2 = "empty_table_with_scripts";
8026
8462
  else if (has2("app_root_empty") && raw.scriptChars >= 500)
8027
8463
  reason2 = "empty_app_root";
8028
- else if (textChars < 300 && raw.scriptChars >= 2e3)
8464
+ else if (textChars < 300 && raw.scriptChars >= 2e3 && context.contentShown !== true)
8029
8465
  reason2 = "script_shell";
8030
8466
  else if (has2("js_fallback_marker") && raw.scriptChars > textChars)
8031
8467
  reason2 = "js_fallback";
8032
8468
  else if (has2("noscript_notice") && raw.scriptChars > textChars && (textChars < NOTICE_MAX_TEXT || has2("hydration_state")))
8033
8469
  reason2 = "js_fallback";
8034
- else if (has2("hydration_state") && textChars < 1500 && raw.scriptChars > textChars * 2)
8470
+ else if (has2("hydration_state") && textChars < 1500 && raw.scriptChars > textChars * 2 && !(context.productShown === true && context.hydrationShown === true))
8035
8471
  reason2 = "hydration_shell";
8036
8472
  else if (has2("aria_busy") && raw.scriptChars >= 1e3)
8037
8473
  reason2 = "aria_busy";
8474
+ const listRecords = reason2 === null && context.listing === true && raw.hydrationJson.length > 0 ? partialRecordList(recordLists(raw), visible.toLowerCase()) : null;
8475
+ if (listRecords !== null)
8476
+ reason2 = "hydration_list_partial";
8038
8477
  return {
8039
8478
  textChars,
8040
8479
  scriptChars: raw.scriptChars,
8041
8480
  emptyTables,
8042
8481
  markers,
8482
+ ...listRecords === null ? {} : { listRecords },
8043
8483
  clientRendered: reason2 !== null,
8044
8484
  reason: reason2
8045
8485
  };
8046
8486
  }
8047
- var APP_ROOT_SELECTOR, JS_FALLBACK_CLASS, HYDRATION_SCRIPT, APP_ROOT_MAX_TEXT, HYDRATION_MIN_CHARS, NOTICE_MAX_TEXT;
8487
+ var APP_ROOT_SELECTOR, JS_FALLBACK_CLASS, HYDRATION_SCRIPT, APP_ROOT_MAX_TEXT, HYDRATION_MIN_CHARS, NOTICE_MAX_TEXT, HYDRATION_TEXT_MIN_CHARS, HYDRATION_TEXT_MIN_SPACES, HYDRATION_MAX_JSON_CHARS, HYDRATION_MAX_PASSAGES, LIST_MIN_RECORDS, LIST_NAME_MIN_CHARS, LIST_MIN_SHOWN, LIST_MAX_NAMES, LIST_MAX_LISTS;
8048
8488
  var init_render = __esm({
8049
8489
  "packages/extract-tf/dist/render.js"() {
8050
8490
  "use strict";
@@ -8055,6 +8495,15 @@ var init_render = __esm({
8055
8495
  APP_ROOT_MAX_TEXT = 200;
8056
8496
  HYDRATION_MIN_CHARS = 2e3;
8057
8497
  NOTICE_MAX_TEXT = 1500;
8498
+ HYDRATION_TEXT_MIN_CHARS = 80;
8499
+ HYDRATION_TEXT_MIN_SPACES = 8;
8500
+ HYDRATION_MAX_JSON_CHARS = 5e6;
8501
+ HYDRATION_MAX_PASSAGES = 200;
8502
+ LIST_MIN_RECORDS = 10;
8503
+ LIST_NAME_MIN_CHARS = 12;
8504
+ LIST_MIN_SHOWN = 3;
8505
+ LIST_MAX_NAMES = 500;
8506
+ LIST_MAX_LISTS = 50;
8058
8507
  }
8059
8508
  });
8060
8509
 
@@ -8857,6 +9306,8 @@ var init_detectList = __esm({
8857
9306
  function hasVisibleBuyBox(doc) {
8858
9307
  const h1s = qsa(doc, "h1");
8859
9308
  const prices = visiblePrices(doc);
9309
+ if (h1s.length === 0)
9310
+ return hasBuyBoxUnderH2(doc, prices);
8860
9311
  if (h1s.length !== 1 || prices.length !== 1 || prices[0].closest("li") !== null)
8861
9312
  return false;
8862
9313
  let heading2 = null;
@@ -8868,6 +9319,35 @@ function hasVisibleBuyBox(doc) {
8868
9319
  }
8869
9320
  return heading2 === h1s[0];
8870
9321
  }
9322
+ function hasBuyBoxUnderH2(doc, prices) {
9323
+ const h2s = qsa(doc, "h2");
9324
+ if (h2s.length !== 1 || h2s[0].closest("a") !== null)
9325
+ return false;
9326
+ const all = qsa(doc, "*");
9327
+ const priceSet = new Set(prices);
9328
+ let price = null;
9329
+ for (const el of all.slice(all.indexOf(h2s[0]) + 1)) {
9330
+ if (/^h[1-6]$/.test(tagOf(el)))
9331
+ return false;
9332
+ if (priceSet.has(el)) {
9333
+ price = el;
9334
+ break;
9335
+ }
9336
+ }
9337
+ if (price === null || price.closest("li") !== null)
9338
+ return false;
9339
+ const block = commonAncestor(h2s[0], price);
9340
+ if (block === null || prices.some((other) => other !== price && block.contains(other)))
9341
+ return false;
9342
+ if (prices.length - 1 >= PRODUCT_CARDS)
9343
+ return false;
9344
+ for (let up = price.parentElement; up !== null && up !== doc.body; up = up.parentElement) {
9345
+ const siblings = Array.from(up.parentElement?.children ?? []).filter((sibling) => sibling !== up && tagOf(sibling) === tagOf(up) && prices.some((p) => sibling.contains(p)));
9346
+ if (siblings.length >= 2)
9347
+ return false;
9348
+ }
9349
+ return true;
9350
+ }
8871
9351
  function countAll(doc) {
8872
9352
  const textLength = (el) => (el?.textContent ?? "").replace(/\s+/g, " ").trim().length;
8873
9353
  const textChars = textLength(doc.body);
@@ -8895,35 +9375,53 @@ function normalizeTypeName(raw) {
8895
9375
  const last = /[#/]([^#/]+)$/.exec(trimmed)?.[1] ?? /^([^:]+):(.+)$/.exec(trimmed)?.[2] ?? trimmed;
8896
9376
  return last.toLowerCase();
8897
9377
  }
8898
- function collectJsonLdTypes(node, out) {
9378
+ function collectPageTypes(root, out) {
9379
+ for (const node of Array.isArray(root) ? root : [root]) {
9380
+ if (typeof node !== "object" || node === null)
9381
+ continue;
9382
+ const record3 = node;
9383
+ const t = record3["@type"];
9384
+ for (const type of typeof t === "string" ? [t] : Array.isArray(t) ? t : [])
9385
+ if (typeof type === "string")
9386
+ out.push(normalizeTypeName(type));
9387
+ if (Array.isArray(record3["@graph"]))
9388
+ collectPageTypes(record3["@graph"], out);
9389
+ }
9390
+ }
9391
+ function collectJsonLdTypes(node, out, listed, inList = false) {
8899
9392
  if (Array.isArray(node)) {
8900
9393
  for (const item of node)
8901
- collectJsonLdTypes(item, out);
9394
+ collectJsonLdTypes(item, out, listed, inList);
8902
9395
  return;
8903
9396
  }
8904
9397
  if (typeof node !== "object" || node === null)
8905
9398
  return;
8906
9399
  const t = node["@type"];
8907
- if (typeof t === "string") {
8908
- out.push(normalizeTypeName(t));
8909
- } else if (Array.isArray(t)) {
8910
- for (const item of t)
8911
- if (typeof item === "string")
8912
- out.push(normalizeTypeName(item));
9400
+ const types = (typeof t === "string" ? [t] : Array.isArray(t) ? t.filter((item) => typeof item === "string") : []).map(normalizeTypeName);
9401
+ for (const type of types) {
9402
+ if (inList && type === "product")
9403
+ listed.products++;
9404
+ else
9405
+ out.push(type);
8913
9406
  }
9407
+ const list2 = inList || types.includes("itemlist");
8914
9408
  for (const value of Object.values(node)) {
8915
9409
  if (typeof value === "object" && value !== null)
8916
- collectJsonLdTypes(value, out);
9410
+ collectJsonLdTypes(value, out, listed, list2);
8917
9411
  }
8918
9412
  }
8919
9413
  function collectPageSignals(doc) {
8920
9414
  const jsonLdTypes = [];
9415
+ const listed = { products: 0 };
9416
+ const pageTypes = [];
8921
9417
  for (const el of qsa(doc, 'script[type="application/ld+json"]')) {
8922
9418
  const text2 = (el.textContent ?? "").trim();
8923
9419
  if (text2.length === 0)
8924
9420
  continue;
8925
9421
  try {
8926
- collectJsonLdTypes(JSON.parse(text2), jsonLdTypes);
9422
+ const parsed = JSON.parse(text2);
9423
+ collectJsonLdTypes(parsed, jsonLdTypes, listed);
9424
+ collectPageTypes(parsed, pageTypes);
8927
9425
  } catch {
8928
9426
  }
8929
9427
  }
@@ -8940,7 +9438,9 @@ function collectPageSignals(doc) {
8940
9438
  itempropTokens,
8941
9439
  itemTypeTokens,
8942
9440
  postArticles: qsa(doc, "article.post").length,
8943
- productCards: productCards(doc)
9441
+ productCards: productCards(doc),
9442
+ listedProducts: listed.products,
9443
+ pageTypes
8944
9444
  };
8945
9445
  }
8946
9446
  function productCards(doc) {
@@ -8953,9 +9453,11 @@ function productCards(doc) {
8953
9453
  });
8954
9454
  if (outer.length < PRODUCT_CARDS)
8955
9455
  return false;
8956
- const classes = (el) => splitTokens(el.getAttribute("class") ?? "").sort().join(" ");
8957
- const shapes = new Set(outer.map((el) => `${tagOf(el)} ${classes(el)}`));
8958
- if (shapes.size !== 1 || classes(outer[0]) === "" || outer.some((el) => el.querySelector("h1") !== null))
9456
+ const classes = (el) => splitTokens(el.getAttribute("class") ?? "");
9457
+ const exact = new Set(outer.map((el) => `${tagOf(el)} ${classes(el).sort().join(" ")}`)).size === 1 && classes(outer[0]).length > 0;
9458
+ const siblings = outer.every((el) => el.parentElement === outer[0].parentElement) && new Set(outer.map(tagOf)).size === 1;
9459
+ const shared = classes(outer[0]).some((name) => outer.every((el) => classes(el).includes(name)));
9460
+ if (!(exact || siblings && shared) || outer.some((el) => el.querySelector("h1") !== null))
8959
9461
  return false;
8960
9462
  for (const card of outer) {
8961
9463
  for (let up = card.parentElement; up !== null; up = up.parentElement) {
@@ -8968,7 +9470,7 @@ function productCards(doc) {
8968
9470
  if (all.some((el, at) => at < first2 && /^h[2-6]$/.test(tagOf(el)) && isRecommendationHeading(textOf(el))))
8969
9471
  return false;
8970
9472
  const h1s = qsa(doc, "h1");
8971
- return !(h1s.length === 1 && visiblePrices(doc).some((price) => !outer.some((card) => card.contains(price))));
9473
+ return !(h1s.length === 1 && visiblePrices(doc).some((price) => price.closest("header, nav") === null && !outer.some((card) => card.contains(price))));
8972
9474
  }
8973
9475
  function hasToken2(values, needle) {
8974
9476
  return values.includes(needle);
@@ -8982,7 +9484,7 @@ function countTokens(values, needles) {
8982
9484
  }
8983
9485
  function hasProductSignals(s) {
8984
9486
  const strong = hasToken2(s.jsonLdTypes, "product") || hasToken2(s.itemTypeTokens, "product") || hasToken2(s.itempropTokens, "product") || hasToken2(s.itempropTokens, "offer");
8985
- if (strong)
9487
+ if (strong || s.listedProducts > 0)
8986
9488
  return true;
8987
9489
  return countTokens(s.itempropTokens, PRICE_ITEMPROPS) >= 2;
8988
9490
  }
@@ -8999,8 +9501,100 @@ function hasForumSignals(s) {
8999
9501
  return true;
9000
9502
  return hasToken2(s.jsonLdTypes, "forum") || hasToken2(s.itemTypeTokens, "forum");
9001
9503
  }
9002
- function routeByCounts(c, s) {
9003
- if (s.productCards && !c.buyBox && !hasToken2(s.jsonLdTypes, "product")) {
9504
+ function namedNotListing(el) {
9505
+ const name = `${el.getAttribute("id") ?? ""} ${el.getAttribute("class") ?? ""}`.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
9506
+ return name.split(/[\s_-]+/).some((part) => NOT_LISTING_NAMES.some((word) => part.startsWith(word)));
9507
+ }
9508
+ function leadsWithLink(card) {
9509
+ let lead = 0;
9510
+ const walk = (node) => {
9511
+ for (const child of Array.from(node.childNodes)) {
9512
+ if (child.nodeType === 3) {
9513
+ lead += ((child.textContent ?? "").match(/[\p{L}\p{N}]/gu) ?? []).length;
9514
+ if (lead > CARD_LEAD_CHARS)
9515
+ return false;
9516
+ } else if (child.nodeType === 1) {
9517
+ const el = child;
9518
+ const href = tagOf(el) === "a" ? (el.getAttribute("href") ?? "").trim() : "";
9519
+ if (href !== "" && !href.startsWith("#"))
9520
+ return true;
9521
+ const found = walk(el);
9522
+ if (found !== null)
9523
+ return found;
9524
+ }
9525
+ }
9526
+ return null;
9527
+ };
9528
+ return walk(card) === true;
9529
+ }
9530
+ function largestCardGroup(doc) {
9531
+ let best = [];
9532
+ const inNamed = /* @__PURE__ */ new Map();
9533
+ const insideNamed = (el) => {
9534
+ const unsettled = [];
9535
+ let answer = false;
9536
+ for (let up = el; up !== null; up = up.parentElement) {
9537
+ const known = inNamed.get(up);
9538
+ if (known !== void 0) {
9539
+ answer = known;
9540
+ break;
9541
+ }
9542
+ unsettled.push(up);
9543
+ }
9544
+ for (const up of unsettled.reverse()) {
9545
+ answer ||= namedNotListing(up);
9546
+ inNamed.set(up, answer);
9547
+ }
9548
+ return answer;
9549
+ };
9550
+ for (const el of qsa(doc, "*")) {
9551
+ if (el.children.length < GRID_CARDS || insideNamed(el))
9552
+ continue;
9553
+ const templates = /* @__PURE__ */ new Map();
9554
+ for (const kid of Array.from(el.children)) {
9555
+ if (!CARD_TAGS.has(tagOf(kid)) || namedNotListing(kid) || !isCard(kid) || !leadsWithLink(kid))
9556
+ continue;
9557
+ const template = `${tagOf(kid)} ${kid.getAttribute("class") ?? ""}`;
9558
+ const group = templates.get(template);
9559
+ if (group === void 0)
9560
+ templates.set(template, [kid]);
9561
+ else
9562
+ group.push(kid);
9563
+ }
9564
+ for (const group of templates.values())
9565
+ if (group.length > best.length)
9566
+ best = group;
9567
+ }
9568
+ return best;
9569
+ }
9570
+ function hasCardGrid(doc) {
9571
+ const cards = largestCardGroup(doc);
9572
+ if (cards.length < GRID_CARDS)
9573
+ return false;
9574
+ const collapsedLength = (el) => textOf(el).replace(/\s+/g, " ").trim().length;
9575
+ const cardSet = new Set(cards);
9576
+ const blocks = classifyBlocks(doc);
9577
+ const blockSet = new Set(blocks.map((b) => b.el));
9578
+ let outside = 0;
9579
+ for (const block of blocks) {
9580
+ let counted = true;
9581
+ for (let up = block.el.parentElement; up !== null; up = up.parentElement) {
9582
+ if (cardSet.has(up) || blockSet.has(up)) {
9583
+ counted = false;
9584
+ break;
9585
+ }
9586
+ }
9587
+ if (counted && !cardSet.has(block.el) && !STRUCTURAL_BLOCKS.has(tagOf(block.el)))
9588
+ outside += block.length;
9589
+ }
9590
+ const inside = cards.reduce((sum, card) => sum + collapsedLength(card), 0);
9591
+ return inside >= outside * 2;
9592
+ }
9593
+ function routeByCounts(c, s, doc) {
9594
+ let grid;
9595
+ const cardGrid = () => grid ??= hasCardGrid(doc);
9596
+ const listedOnly = s.listedProducts >= PRODUCT_CARDS && COLLECTION_PAGE_TYPES.some((type) => hasToken2(s.pageTypes, type)) && (s.productCards || !hasToken2(s.itemTypeTokens, "product"));
9597
+ if ((s.productCards || listedOnly) && !c.buyBox && !hasToken2(s.jsonLdTypes, "product")) {
9004
9598
  return { type: "collection", strategy: "article" };
9005
9599
  }
9006
9600
  if (hasProductSignals(s)) {
@@ -9012,8 +9606,8 @@ function routeByCounts(c, s) {
9012
9606
  if (hasForumSignals(s))
9013
9607
  return { type: "forum", strategy: "article" };
9014
9608
  if (c.buyBox && !hasArticleSignals(s))
9015
- return { type: "product", strategy: "product" };
9016
- if (c.main >= 1 && c.p >= 3 && c.headings >= 2) {
9609
+ return { type: "product", strategy: "product", buyBox: true };
9610
+ if (c.main >= 1 && c.p >= 3 && c.headings >= 2 && !cardGrid()) {
9017
9611
  return { type: "article", strategy: "article" };
9018
9612
  }
9019
9613
  const textInTables = c.tableChars >= c.textChars * 0.5;
@@ -9035,13 +9629,15 @@ function routeByCounts(c, s) {
9035
9629
  if (c.textChars > 3e3 && c.headings >= 5 && c.a >= 20) {
9036
9630
  return { type: "collection", strategy: "article" };
9037
9631
  }
9632
+ if (cardGrid())
9633
+ return { type: "collection", strategy: "article" };
9038
9634
  return { type: "article", strategy: "article" };
9039
9635
  }
9040
9636
  function pageSignalsFor(doc) {
9041
9637
  return collectPageSignals(doc);
9042
9638
  }
9043
9639
  function routePage(doc, signals = collectPageSignals(doc)) {
9044
- return routeByCounts(countAll(doc), signals);
9640
+ return routeByCounts(countAll(doc), signals, doc);
9045
9641
  }
9046
9642
  function selectList(doc) {
9047
9643
  let best = null;
@@ -9086,9 +9682,39 @@ function textOutsideLinks(node) {
9086
9682
  return text2;
9087
9683
  }
9088
9684
  function isCard(item) {
9089
- if (qsa(item, "a[href]").length === 0)
9685
+ const links = qsa(item, "a[href]");
9686
+ if (links.length === 0)
9090
9687
  return false;
9091
- return (textOutsideLinks(item).match(/[\p{L}\p{N}]/gu) ?? []).length >= 3;
9688
+ if ((textOutsideLinks(item).match(/[\p{L}\p{N}]/gu) ?? []).length >= 3)
9689
+ return true;
9690
+ return links.some((link2) => qsa(link2, "h2,h3,h4,h5,h6").some((heading2) => /[\p{L}\p{N}]/u.test(textOf(heading2))));
9691
+ }
9692
+ function alikeCards(cards) {
9693
+ const byTag = /* @__PURE__ */ new Map();
9694
+ for (const card of cards) {
9695
+ const group = byTag.get(tagOf(card));
9696
+ if (group === void 0)
9697
+ byTag.set(tagOf(card), [card]);
9698
+ else
9699
+ group.push(card);
9700
+ }
9701
+ let most = 0;
9702
+ for (const group of byTag.values()) {
9703
+ const classes = group.map((card) => new Set(splitTokens(card.getAttribute("class") ?? "")));
9704
+ const classless = classes.every((set2) => set2.size === 0);
9705
+ const shared = [...classes[0]].some((name) => classes.every((set2) => set2.has(name)));
9706
+ if (classless || shared) {
9707
+ most = Math.max(most, group.length);
9708
+ continue;
9709
+ }
9710
+ const exact = /* @__PURE__ */ new Map();
9711
+ for (const set2 of classes) {
9712
+ const key = [...set2].sort().join(" ");
9713
+ exact.set(key, (exact.get(key) ?? 0) + 1);
9714
+ }
9715
+ most = Math.max(most, ...exact.values());
9716
+ }
9717
+ return most;
9092
9718
  }
9093
9719
  function selectCardList(doc) {
9094
9720
  const h1s = qsa(doc, "h1");
@@ -9104,14 +9730,7 @@ function selectCardList(doc) {
9104
9730
  continue;
9105
9731
  if (el.closest("header") !== null)
9106
9732
  continue;
9107
- const templates = /* @__PURE__ */ new Map();
9108
- for (const kid of Array.from(el.children)) {
9109
- if (!isCard(kid))
9110
- continue;
9111
- const template = `${kid.tagName} ${kid.getAttribute("class") ?? ""}`;
9112
- templates.set(template, (templates.get(template) ?? 0) + 1);
9113
- }
9114
- const cards = Math.max(0, ...templates.values());
9733
+ const cards = alikeCards(Array.from(el.children).filter(isCard));
9115
9734
  if (cards >= 3 && cards > bestCards) {
9116
9735
  bestCards = cards;
9117
9736
  best = el;
@@ -9235,10 +9854,11 @@ function elementCounts(doc) {
9235
9854
  }
9236
9855
  return counts;
9237
9856
  }
9238
- var PRODUCT_CARDS, PRICE_ITEMPROPS, ARTICLE_TYPES, DETECTED_LIST_ITEMS, DETECTED_ITEM_CHARS;
9857
+ var PRODUCT_CARDS, PRICE_ITEMPROPS, ARTICLE_TYPES, GRID_CARDS, CARD_TAGS, CARD_LEAD_CHARS, STRUCTURAL_BLOCKS, NOT_LISTING_NAMES, COLLECTION_PAGE_TYPES, DETECTED_LIST_ITEMS, DETECTED_ITEM_CHARS;
9239
9858
  var init_route = __esm({
9240
9859
  "packages/extract-tf/dist/route.js"() {
9241
9860
  "use strict";
9861
+ init_classify();
9242
9862
  init_detectList();
9243
9863
  init_dom();
9244
9864
  init_prune();
@@ -9247,6 +9867,12 @@ var init_route = __esm({
9247
9867
  PRODUCT_CARDS = 3;
9248
9868
  PRICE_ITEMPROPS = ["price", "offers", "sku", "gtin", "mpn", "brand"];
9249
9869
  ARTICLE_TYPES = ["article", "newsarticle", "blogposting", "report", "scholarlyarticle", "techarticle"];
9870
+ GRID_CARDS = 4;
9871
+ CARD_TAGS = /* @__PURE__ */ new Set(["li", "div", "article", "section"]);
9872
+ CARD_LEAD_CHARS = 24;
9873
+ STRUCTURAL_BLOCKS = /* @__PURE__ */ new Set(["li", "td", "th"]);
9874
+ NOT_LISTING_NAMES = ["comment", "reply", "replies", "reference", "reflist", "cite", "citation", "footnote", "endnote"];
9875
+ COLLECTION_PAGE_TYPES = ["collectionpage", "searchresultspage"];
9250
9876
  DETECTED_LIST_ITEMS = 3;
9251
9877
  DETECTED_ITEM_CHARS = 40;
9252
9878
  }
@@ -9322,9 +9948,9 @@ function deliveryLocation(doc) {
9322
9948
  const raw = text(doc, selector);
9323
9949
  if (raw === null || /^update\s+location$/i.test(raw))
9324
9950
  continue;
9325
- const normalized = clean(raw.replace(/^deliver(?:ing|y)?\s+to\s+/i, "").replace(/[–—-]?\s*update\s+location\s*$/i, "").replace(/\b(Singapore)\s*(\d{6})\b/i, "$1 $2"));
9326
- if (normalized !== null)
9327
- return { value: normalized, selector };
9951
+ const normalized2 = clean(raw.replace(/^deliver(?:ing|y)?\s+to\s+/i, "").replace(/[–—-]?\s*update\s+location\s*$/i, "").replace(/\b(Singapore)\s*(\d{6})\b/i, "$1 $2"));
9952
+ if (normalized2 !== null)
9953
+ return { value: normalized2, selector };
9328
9954
  }
9329
9955
  return null;
9330
9956
  }
@@ -9392,8 +10018,8 @@ function verifyProductIdentity(doc, url, selectedVariants) {
9392
10018
  const requestedId = amazonAsin(url) ?? "";
9393
10019
  const witnesses = [];
9394
10020
  const asin = (value) => {
9395
- const normalized = clean(value)?.toUpperCase();
9396
- return normalized && /^[A-Z0-9]{10}$/.test(normalized) ? normalized : null;
10021
+ const normalized2 = clean(value)?.toUpperCase();
10022
+ return normalized2 && /^[A-Z0-9]{10}$/.test(normalized2) ? normalized2 : null;
9397
10023
  };
9398
10024
  const add = (value, path) => {
9399
10025
  const id = asin(value);
@@ -9645,8 +10271,8 @@ function clean2(value) {
9645
10271
  const next = (value ?? "").replace(/\s+/g, " ").trim();
9646
10272
  return next.length > 0 ? next : null;
9647
10273
  }
9648
- function field2(raw, source, path, normalized = raw) {
9649
- return { raw, normalized, source, path, status: "confirmed" };
10274
+ function field2(raw, source, path, normalized2 = raw) {
10275
+ return { raw, normalized: normalized2, source, path, status: "confirmed" };
9650
10276
  }
9651
10277
  function record2(value) {
9652
10278
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -10234,7 +10860,10 @@ function pickTitle(doc, main) {
10234
10860
  const docTitle = doc.querySelector("title");
10235
10861
  return docTitle ? textOf(docTitle).trim() : null;
10236
10862
  }
10237
- function confidenceOf(blocksLen, main, totalBlocks, mainLength, pageType, favorPrecision, favorRecall, product) {
10863
+ function showsBuyBox(main, price) {
10864
+ return main.querySelector("h1,h2") !== null && textOf(main).replace(/\s+/g, " ").includes(price.replace(/\s+/g, " ").trim());
10865
+ }
10866
+ function confidenceOf(blocksLen, main, totalBlocks, mainLength, pageType, productShown, favorPrecision, favorRecall, product) {
10238
10867
  let conf = totalBlocks > 0 ? blocksLen / totalBlocks : 0;
10239
10868
  if (main && ["ARTICLE", "MAIN"].includes(main.tagName))
10240
10869
  conf = Math.min(1, conf + 0.15);
@@ -10244,6 +10873,8 @@ function confidenceOf(blocksLen, main, totalBlocks, mainLength, pageType, favorP
10244
10873
  conf = Math.min(conf, 0.75);
10245
10874
  if (product !== null && main !== null && product.name !== null && product.price !== null && product.name.source !== "text" && product.price.source !== "text") {
10246
10875
  conf = Math.max(conf, 0.6);
10876
+ } else if (productShown) {
10877
+ conf = Math.max(conf, BUY_BOX_CONFIDENCE);
10247
10878
  }
10248
10879
  if (favorPrecision)
10249
10880
  conf = Math.min(conf, 0.85);
@@ -10251,7 +10882,16 @@ function confidenceOf(blocksLen, main, totalBlocks, mainLength, pageType, favorP
10251
10882
  conf = Math.max(conf, 0.3);
10252
10883
  return Math.round(conf * 100) / 100;
10253
10884
  }
10254
- var DEFAULT_CLASSIFY, ExtractTf, extractTf;
10885
+ function showsDeclaredProduct(main, product) {
10886
+ if (main === null || product === null || product.name === null || product.price === null)
10887
+ return false;
10888
+ if (product.name.source === "text" || product.price.source === "text")
10889
+ return false;
10890
+ const text2 = textOf(main).replace(/\s+/g, " ");
10891
+ const price = product.price.value.replace(/[^\d.,]/g, "");
10892
+ return text2.includes(product.name.value.replace(/\s+/g, " ").trim()) && price !== "" && text2.includes(price);
10893
+ }
10894
+ var DEFAULT_CLASSIFY, BUY_BOX_CONFIDENCE, DESCRIPTION_MIN_CHARS, ExtractTf, extractTf;
10255
10895
  var init_extract = __esm({
10256
10896
  "packages/extract-tf/dist/extract.js"() {
10257
10897
  "use strict";
@@ -10272,6 +10912,8 @@ var init_extract = __esm({
10272
10912
  minTextLength: 25,
10273
10913
  maxLinkDensity: 0.2
10274
10914
  };
10915
+ BUY_BOX_CONFIDENCE = 0.45;
10916
+ DESCRIPTION_MIN_CHARS = 150;
10275
10917
  ExtractTf = class {
10276
10918
  extract(html2, options = {}) {
10277
10919
  const { favorPrecision = false, favorRecall = false, pruneSelectors, includeSelectors, blockAds = true } = options;
@@ -10291,10 +10933,13 @@ var init_extract = __esm({
10291
10933
  const fetchPreloads = Array.from(doc.document.querySelectorAll("link[rel][as]")).filter((link2) => (link2.getAttribute("rel") ?? "").toLowerCase().split(/\s+/).includes("preload") && (link2.getAttribute("as") ?? "").trim().toLowerCase() === "fetch").length;
10292
10934
  const raw = rawSignals(doc.document);
10293
10935
  const excluded = namedBy(doc.document, pruneSelectors ?? []);
10936
+ const optionGroups = markOptionGroups(doc.document, excluded);
10294
10937
  cleanTree(doc.document, excluded);
10295
10938
  pruneTree(doc.document, { blockAds });
10296
10939
  detachAll(excluded);
10297
10940
  const decision = amazonProduct ? { type: "product", strategy: "product" } : routePage(doc.document, signals);
10941
+ settleOptionGroups(optionGroups, decision.type === "product");
10942
+ const dataShown = decision.buyBox === true && raw.hydrationJson.length > 0 && hydrationShown(raw, doc.document);
10298
10943
  if (decision.type === "product")
10299
10944
  pruneRecommendations(doc.document);
10300
10945
  const classifyOptions = { ...DEFAULT_CLASSIFY, favorPrecision };
@@ -10354,6 +10999,7 @@ var init_extract = __esm({
10354
10999
  }
10355
11000
  const blocks = classifyBlocks(doc.document, classifyOptions);
10356
11001
  const mainLength = main ? textOf(main).length : 0;
11002
+ const productShown = decision.buyBox === true && main !== null && product !== null && product.price !== null && showsBuyBox(main, product.price.value) && blocks.some((b) => main.contains(b.el) && !/^h[1-6]$/.test(b.el.tagName.toLowerCase()) && b.length >= DESCRIPTION_MIN_CHARS);
10357
11003
  const adapter = amazonProduct ? adapterFor(doc.document, options.url, product) : preliminaryAdapter;
10358
11004
  const selection = includeSelectors !== void 0 && includeSelectors.length > 0 ? selectionBody(html2, includeSelectors, pruneSelectors) : null;
10359
11005
  const selected = selection !== null && !selection.blank;
@@ -10362,7 +11008,7 @@ var init_extract = __esm({
10362
11008
  mainHtml: selection !== null ? selection.html : main ? outerHtml(main) : "",
10363
11009
  baseUrl,
10364
11010
  metadata,
10365
- confidence: selected ? 1 : confidenceOf(blocks.filter((b) => main?.contains(b.el)).length, main, blocks.length, mainLength, decision.type, favorPrecision, favorRecall, product),
11011
+ confidence: selected ? 1 : confidenceOf(blocks.filter((b) => main?.contains(b.el)).length, main, blocks.length, mainLength, decision.type, productShown, favorPrecision, favorRecall, product),
10366
11012
  // Escalate only when a strategy produced nothing at all. Routing to a
10367
11013
  // non-article strategy is not by itself an escalation reason.
10368
11014
  escalate: main === null,
@@ -10375,7 +11021,12 @@ var init_extract = __esm({
10375
11021
  adapterValidation: amazonValidation ?? adapter.validation,
10376
11022
  emptyTableShells,
10377
11023
  fetchPreloads,
10378
- render: detectRenderSignals(raw, doc.document),
11024
+ render: detectRenderSignals(raw, doc.document, {
11025
+ productShown,
11026
+ hydrationShown: dataShown,
11027
+ contentShown: showsDeclaredProduct(main, product),
11028
+ listing: decision.type === "listing" || decision.type === "collection"
11029
+ }),
10379
11030
  labelledValues: main ? collectLabelledValues(main) : [],
10380
11031
  timings: { parseMs, extractMs: Math.max(0, performance.now() - extractionStart) }
10381
11032
  };
@@ -11627,11 +12278,25 @@ function errorCode3(error) {
11627
12278
  return null;
11628
12279
  }
11629
12280
  function robotsOverrideWarning(decision, override) {
11630
- const rules = decision.appliedRules.map((rule) => rule.pattern).join(", ");
12281
+ const robotsUrl = decision.robotsUrl ?? "robots.txt";
12282
+ const verdict = decision.unreachable === void 0 ? `${robotsUrl} disallows this URL (rule ${decision.appliedRules.map((rule) => rule.pattern).join(", ")})` : `${robotsUrl} could not be read (${decision.unreachable}), which counts as a complete disallow`;
11631
12283
  const who = override.recordedBy === void 0 ? "" : ` by ${override.recordedBy}`;
12284
+ const why = override.basis === "user_named_url" ? "it was fetched because the request named it: robots.txt binds the links a crawl or map discovers, not the URLs a person names" : override.basis === "ignore_robots_txt" ? "it was fetched because the crawl or map was started with ignoreRobotsTxt" : `it was fetched under an override recorded${who}: ${override.reason}`;
12285
+ return { code: "robots_overridden", message: `${verdict}; ${why}` };
12286
+ }
12287
+ function applicableOverride(override, decision) {
12288
+ if (override?.basis !== void 0 && isOctocrawlRobotsGroup(decision.matchedUserAgentGroup))
12289
+ return void 0;
12290
+ return override;
12291
+ }
12292
+ function overriddenDetail(url, decision, override) {
11632
12293
  return {
11633
- code: "robots_overridden",
11634
- message: `${decision.robotsUrl ?? "robots.txt"} disallows this URL (rule ${rules}); it was fetched under an override recorded${who}: ${override.reason}`
12294
+ url,
12295
+ appliedRules: decision.appliedRules,
12296
+ ...decision.unreachable === void 0 ? {} : { unreachable: decision.unreachable },
12297
+ reason: override.reason,
12298
+ ...override.recordedBy === void 0 ? {} : { recordedBy: override.recordedBy },
12299
+ basis: override.basis ?? "robots_override"
11635
12300
  };
11636
12301
  }
11637
12302
  function robotsOverrideApplied(trace, warning) {
@@ -11809,11 +12474,13 @@ var init_robotsLookup = __esm({
11809
12474
  path = parsed.pathname + parsed.search;
11810
12475
  } catch {
11811
12476
  }
11812
- const match = evaluateRobots(cached.robots, robotsAgent(userAgent), path);
12477
+ const agent = robotsAgent(userAgent);
12478
+ const match = evaluateRobots(cached.robots, agent, path);
12479
+ const own = matchRobotsGroup(cached.robots, agent)?.agents.find((token) => isOctocrawlRobotsGroup(token));
11813
12480
  return {
11814
12481
  robotsUrl: cached.robotsUrl,
11815
12482
  robotsSha256: cached.sha256,
11816
- matchedUserAgentGroup: match.matchedAgent,
12483
+ matchedUserAgentGroup: own ?? match.matchedAgent,
11817
12484
  appliedRules: match.appliedRules.map((r) => ({ pattern: r.pattern, allow: r.allow })),
11818
12485
  decision: match.allowed ? "allowed" : "disallowed",
11819
12486
  skippedFetch: false,
@@ -11930,7 +12597,7 @@ var init_provider2 = __esm({
11930
12597
  { at: 0, lane: "provider", event: "provider_selected", detail: { provider: this.provider.id } }
11931
12598
  ];
11932
12599
  const preflight = evaluateProviderGate(this.provider, null, "/");
11933
- if (preflight.refusal === "refused_capability" || preflight.refusal === "undeclared_user_agent") {
12600
+ if (preflight.refusal === "refused_capability" || preflight.refusal === "ungranted_capability" || preflight.refusal === "undeclared_user_agent") {
11934
12601
  return this.denied(url, start, trace, preflight, {
11935
12602
  robotsUrl: null,
11936
12603
  robotsSha256: null,
@@ -12404,14 +13071,14 @@ var init_provider2 = __esm({
12404
13071
  });
12405
13072
 
12406
13073
  // packages/cli/src/run.ts
12407
- import { mkdir as mkdir9, readFile as readFile6, writeFile as writeFile8 } from "fs/promises";
13074
+ import { mkdir as mkdir9, readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
12408
13075
  import { join as join12 } from "path";
12409
13076
 
12410
13077
  // packages/api/dist/app.js
12411
13078
  import { Hono } from "hono";
12412
13079
  import { streamSSE } from "hono/streaming";
12413
13080
  import { createNodeWebSocket } from "@hono/node-ws";
12414
- import { createHash as createHash14 } from "crypto";
13081
+ import { createHash as createHash15 } from "crypto";
12415
13082
 
12416
13083
  // packages/runtime/dist/taskStore.js
12417
13084
  function assertId(label, value) {
@@ -13323,6 +13990,7 @@ var CrawlOrchestrator = class {
13323
13990
  frontierOptions;
13324
13991
  workerCount;
13325
13992
  signal;
13993
+ scrapeErrorCostUsd;
13326
13994
  shutdownSignal;
13327
13995
  sitemapSource;
13328
13996
  onStep;
@@ -13335,6 +14003,7 @@ var CrawlOrchestrator = class {
13335
14003
  this.frontierOptions = options;
13336
14004
  this.workerCount = Math.max(1, options.workerCount ?? 4);
13337
14005
  this.signal = options.signal;
14006
+ this.scrapeErrorCostUsd = options.scrapeErrorCostUsd === void 0 ? null : options.scrapeErrorCostUsd;
13338
14007
  this.shutdownSignal = options.shutdownSignal;
13339
14008
  this.sitemapSource = options.sitemapSource;
13340
14009
  this.onStep = options.onStep;
@@ -13552,7 +14221,7 @@ var CrawlOrchestrator = class {
13552
14221
  } catch (error) {
13553
14222
  if (stopped())
13554
14223
  throw error;
13555
- outcome = { result: scrapeErrorResult(item.url, error, Date.now() - scrapeStartedAt), links: [] };
14224
+ outcome = { result: scrapeErrorResult(item.url, error, Date.now() - scrapeStartedAt, this.scrapeErrorCostUsd), links: [] };
13556
14225
  }
13557
14226
  result = outcome.result;
13558
14227
  links = outcome.links.length > 0 ? outcome.links : linksOf(outcome.result);
@@ -13954,7 +14623,7 @@ function newAttempt(id, taskId, startedAt, recoveredFromAttemptId = null) {
13954
14623
  recoveredFromAttemptId
13955
14624
  };
13956
14625
  }
13957
- function scrapeErrorResult(url, error, wallMs) {
14626
+ function scrapeErrorResult(url, error, wallMs, externalCostUsd) {
13958
14627
  const name = error instanceof Error ? error.name : typeof error;
13959
14628
  const message2 = error instanceof Error ? error.message : String(error);
13960
14629
  return {
@@ -13971,7 +14640,7 @@ function scrapeErrorResult(url, error, wallMs) {
13971
14640
  truncatedAt: null,
13972
14641
  compliance: null,
13973
14642
  evidence: { finalUrl: url, httpStatus: null, redirectChain: [], contentType: null, rawBodySha256: null, artifacts: [] },
13974
- usage: { ...EMPTY_USAGE, wallMs, bytesWire: null },
14643
+ usage: { ...EMPTY_USAGE, wallMs, bytesWire: null, externalCostUsd },
13975
14644
  trace: [{ at: wallMs, lane: "http", event: "scrape_error", detail: { name, error: message2.slice(0, 500) } }]
13976
14645
  };
13977
14646
  }
@@ -15657,9 +16326,40 @@ function toEvidenceRecord(result, request7, output, options = {}) {
15657
16326
  device: requested ? declaredDevice(result) : null,
15658
16327
  requestHeaders: requested ? sentCustomHeaders(result) : null
15659
16328
  },
15660
- pageActions: pageActions(result)
16329
+ pageActions: pageActions(result),
16330
+ access: evidenceAccess(result)
15661
16331
  };
15662
16332
  }
16333
+ function evidenceAccess(result) {
16334
+ const event = (name) => result.trace.find((e) => e.event === name)?.detail;
16335
+ const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
16336
+ const hit = event("cache_hit");
16337
+ const stored = hit?.externalCostUsd;
16338
+ const externalCostUsd = hit === void 0 ? result.usage.externalCostUsd ?? null : typeof stored === "number" ? stored : null;
16339
+ const route = (r, executor, executorVersion = null, profile = null) => ({ route: r, executor, executorVersion, profile, externalCostUsd });
16340
+ const laneRan = result.trace.some((e) => e.event === "identity_sent" || e.event === "identity_declared" || e.event === "provider_selected");
16341
+ if (!laneRan)
16342
+ return route(null, null);
16343
+ switch (result.lane) {
16344
+ case "http": {
16345
+ const transport = event("transport");
16346
+ return transport === void 0 ? route("http", "undici") : route("http_compat", text2(transport.library), text2(transport.version), text2(transport.profile));
16347
+ }
16348
+ case "browser_local":
16349
+ case "browser_proxy": {
16350
+ const engine = event("browser_engine");
16351
+ if (event("session_attached") !== void 0)
16352
+ return route("authed_browser", "playwright");
16353
+ return engine !== void 0 && engine.engine === "patchright" ? route("enhanced_browser", "patchright", text2(engine.version)) : route("browser", "playwright");
16354
+ }
16355
+ case "browser_local_authed": {
16356
+ const read2 = event("user_browser_read");
16357
+ return read2 === void 0 ? route("authed_browser", "playwright") : route("user_browser", text2(read2.browser));
16358
+ }
16359
+ case "provider":
16360
+ return route("vendor", text2(event("provider_selected")?.provider));
16361
+ }
16362
+ }
15663
16363
  function pageActions(result) {
15664
16364
  if (result.actions === void 0)
15665
16365
  return null;
@@ -15704,11 +16404,13 @@ function robotsDecision(result) {
15704
16404
  robotsSha256: signed.robotsSha256,
15705
16405
  unreachable: signed.unreachable ?? null,
15706
16406
  crawlDelayMs: signed.crawlDelayMs ?? null,
15707
- // A recorded robots override set the disallow aside; the record carries it.
15708
- userOverride: signed.override !== void 0
16407
+ // An override set the disallow aside; the record carries it and on whose word.
16408
+ userOverride: signed.override !== void 0,
16409
+ overrideBasis: signed.override === void 0 ? null : signed.override.basis ?? "robots_override"
15709
16410
  };
15710
16411
  }
15711
16412
  const detail = [...result.trace].reverse().find((event) => event.event === "robots_checked")?.detail;
16413
+ const overridden = result.trace.find((event) => event.event === "robots_overridden");
15712
16414
  const decision = detail?.decision;
15713
16415
  if (decision !== "allowed" && decision !== "disallowed" && decision !== "no_robots")
15714
16416
  return null;
@@ -15721,10 +16423,14 @@ function robotsDecision(result) {
15721
16423
  robotsSha256: typeof detail?.robotsSha256 === "string" ? detail.robotsSha256 : null,
15722
16424
  unreachable: typeof detail?.unreachable === "string" ? detail.unreachable : null,
15723
16425
  crawlDelayMs: typeof detail?.crawlDelayMs === "number" ? detail.crawlDelayMs : null,
15724
- // The HTTP lane mints no record; its trace says when a recorded override set the disallow aside.
15725
- userOverride: result.trace.some((event) => event.event === "robots_overridden")
16426
+ // The HTTP lane mints no record; its trace says when an override set the disallow aside, and on whose word.
16427
+ userOverride: overridden !== void 0,
16428
+ overrideBasis: overridden === void 0 ? null : overrideBasisOf(overridden.detail?.basis)
15726
16429
  };
15727
16430
  }
16431
+ function overrideBasisOf(value) {
16432
+ return value === "user_named_url" || value === "ignore_robots_txt" ? value : "robots_override";
16433
+ }
15728
16434
  function identityEvent(trace) {
15729
16435
  const detail = trace.find((event) => event.event === "identity_sent")?.detail;
15730
16436
  if (detail === void 0)
@@ -15816,7 +16522,7 @@ function pageCacheKey(url, parts) {
15816
16522
  return requestFingerprint({ v: 1, url: href, ...parts });
15817
16523
  }
15818
16524
  function cacheHitResult(hit) {
15819
- const event = { at: 0, lane: hit.result.lane, event: "cache_hit", detail: { cachedAt: hit.fetchedAt, ageMs: hit.ageMs } };
16525
+ const event = { at: 0, lane: hit.result.lane, event: "cache_hit", detail: { cachedAt: hit.fetchedAt, ageMs: hit.ageMs, externalCostUsd: hit.result.usage.externalCostUsd ?? null } };
15820
16526
  return { ...hit.result, trace: [...hit.result.trace, event] };
15821
16527
  }
15822
16528
  function cacheMissResult(url, bounds) {
@@ -15834,7 +16540,7 @@ function cacheMissResult(url, bounds) {
15834
16540
  truncatedAt: null,
15835
16541
  compliance: null,
15836
16542
  evidence: { finalUrl: url, httpStatus: null, redirectChain: [], contentType: null, rawBodySha256: null, artifacts: [] },
15837
- usage: { wallMs: 0, bytesWire: 0, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, contentTokens: null, browserMs: 0, externalCostUsd: null },
16543
+ usage: { wallMs: 0, bytesWire: 0, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, contentTokens: null, browserMs: 0, externalCostUsd: 0 },
15838
16544
  trace: [{ at: 0, lane: "http", event: "cache_miss", detail: { lockdown: true, ...bounds } }]
15839
16545
  };
15840
16546
  }
@@ -15937,6 +16643,8 @@ ${title ?? ""}`.toLowerCase();
15937
16643
  const verdict = await this.sources.robotsVerdict(canonicalUrl, scope);
15938
16644
  if (typeof verdict === "string")
15939
16645
  return verdict;
16646
+ if (verdict.octocrawl === true)
16647
+ return "octocrawl";
15940
16648
  return verdict.unreachable === void 0 ? "disallowed" : { unreachable: verdict.unreachable };
15941
16649
  } catch (error) {
15942
16650
  if (cancelled())
@@ -16024,6 +16732,8 @@ ${title ?? ""}`.toLowerCase();
16024
16732
  seen.refused++;
16025
16733
  }
16026
16734
  };
16735
+ const ignoreRobots = spec.ignoreRobotsTxt === true;
16736
+ const linkVerdict = (verdict) => typeof verdict === "object" ? "unreachable" : verdict;
16027
16737
  const titleOf = (found) => found.via === "link" ? found.text : found.entry.title;
16028
16738
  const offer = async (result, found) => {
16029
16739
  if (!result.accepted) {
@@ -16084,7 +16794,11 @@ ${title ?? ""}`.toLowerCase();
16084
16794
  refused.robotsUnchecked++;
16085
16795
  return false;
16086
16796
  }
16087
- if (verdict === "disallowed" || typeof verdict === "object") {
16797
+ if (verdict === "octocrawl") {
16798
+ refuseRobots(canonicalUrl, "disallowed");
16799
+ return false;
16800
+ }
16801
+ if ((verdict === "disallowed" || typeof verdict === "object") && !ignoreRobots) {
16088
16802
  refuseRobots(canonicalUrl, verdict);
16089
16803
  return false;
16090
16804
  }
@@ -16093,22 +16807,25 @@ ${title ?? ""}`.toLowerCase();
16093
16807
  limitReached = true;
16094
16808
  return true;
16095
16809
  }
16096
- add(canonicalUrl, found, verdict);
16810
+ add(canonicalUrl, found, linkVerdict(verdict));
16097
16811
  return true;
16098
16812
  };
16099
16813
  let startPage = null;
16100
16814
  let clientRendered = false;
16101
16815
  if (sitemapMode !== "only") {
16102
- const startVerdict = await verdictFor(startCanonical);
16816
+ const fullStartVerdict = await verdictFor(startCanonical);
16817
+ const startVerdict = fullStartVerdict === "octocrawl" ? "disallowed" : fullStartVerdict;
16818
+ const startRefused = fullStartVerdict === "octocrawl" || !ignoreRobots;
16103
16819
  const blank = { url: spec.url, finalUrl: null, httpStatus: null, lane: "http", rawBodySha256: null, linksFound: 0, title: null, description: null };
16104
- if (startVerdict === "disallowed" || typeof startVerdict === "object") {
16820
+ if ((startVerdict === "disallowed" || typeof startVerdict === "object") && startRefused) {
16105
16821
  refuseRobots(startCanonical, startVerdict);
16106
16822
  startPage = typeof startVerdict === "object" ? { ...blank, status: "failed", failureReason: "policy_denied", robots: "unreachable", robotsUnreachable: startVerdict.unreachable } : { ...blank, status: "failed", failureReason: "policy_denied", robots: "disallowed" };
16107
16823
  } else if (startVerdict === "unchecked") {
16108
16824
  refused.robotsUnchecked++;
16109
16825
  startPage = { ...blank, status: "failed", failureReason: "timeout", robots: null };
16110
16826
  } else {
16111
- const startLink = { url: startCanonical, via: ["start"], robots: startVerdict };
16827
+ const startLink = { url: startCanonical, via: ["start"], robots: linkVerdict(startVerdict) };
16828
+ const startRobots = { robots: linkVerdict(startVerdict), ...typeof startVerdict === "object" ? { robotsUnreachable: startVerdict.unreachable } : {} };
16112
16829
  links.push(startLink);
16113
16830
  byKey.set(keyOf(startCanonical), startLink);
16114
16831
  const keepStartIfMatching = () => {
@@ -16125,7 +16842,7 @@ ${title ?? ""}`.toLowerCase();
16125
16842
  if (cancelled() || Date.now() < deadlineAt)
16126
16843
  throw error;
16127
16844
  timedOut = true;
16128
- startPage = { ...blank, status: "failed", failureReason: "timeout", robots: startVerdict };
16845
+ startPage = { ...blank, status: "failed", failureReason: "timeout", ...startRobots };
16129
16846
  keepStartIfMatching();
16130
16847
  }
16131
16848
  if (read2 !== null) {
@@ -16139,7 +16856,7 @@ ${title ?? ""}`.toLowerCase();
16139
16856
  status: result.status,
16140
16857
  failureReason: result.failureReason,
16141
16858
  lane: "http",
16142
- robots: startVerdict,
16859
+ ...startRobots,
16143
16860
  rawBodySha256: result.evidence.rawBodySha256,
16144
16861
  linksFound: read2.links.length,
16145
16862
  title: title ?? null,
@@ -16269,8 +16986,9 @@ function nonEmpty(value) {
16269
16986
  }
16270
16987
 
16271
16988
  // packages/api/dist/engine.js
16272
- import { existsSync as existsSync3, mkdirSync as mkdirSync8, readdirSync, writeFileSync } from "fs";
16273
- import { mkdir as mkdir8, readFile as readFile5, writeFile as writeFile7 } from "fs/promises";
16989
+ import { createHash as createHash13 } from "crypto";
16990
+ import { existsSync as existsSync3, mkdirSync as mkdirSync8, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync } from "fs";
16991
+ import { mkdir as mkdir8, readFile as readFile6, writeFile as writeFile8 } from "fs/promises";
16274
16992
  import { isIP as isIP4 } from "net";
16275
16993
  import { homedir as homedir2 } from "os";
16276
16994
  import { join as join10 } from "path";
@@ -16444,6 +17162,36 @@ ${prose2(5, 23)}
16444
17162
  <div class="g-recaptcha" data-sitekey="fixture"></div>`
16445
17163
  }))
16446
17164
  };
17165
+ var ROBOT_OR_HUMAN_FACT = "The exhibit opens with a quiz called Robot or human? that asks visitors to date each portrait.";
17166
+ var staticArticleRobotOrHuman = {
17167
+ truth: {
17168
+ id: "static-article-robot-or-human",
17169
+ target: "/static/article-robot-or-human",
17170
+ kind: "fixture",
17171
+ category: "static",
17172
+ mustContain: [ROBOT_OR_HUMAN_FACT, ARTICLE_FACT],
17173
+ mustNotContain: B,
17174
+ expectedLane: "http",
17175
+ emptyIsLegit: false,
17176
+ expectedMainTokens: { min: 260, max: 900 },
17177
+ budget: budget(2e3),
17178
+ expectedStatus: "success",
17179
+ notes: `Ordinary article on a PerimeterX-protected site: the sensor sets window._pxAppId on every page (Walmart's home page did on 2026-10-05), and the prose says "Robot or human?". Neither is the challenge, so a contentful page must stay success.`
17180
+ },
17181
+ respond: () => html(htmlPage({
17182
+ title: "Kiln temperatures and glaze vitrification",
17183
+ headExtra: "<script>window._pxAppId='PXfixture1'</script>",
17184
+ bodyHtml: `<article>
17185
+ <h1>Kiln temperatures and glaze vitrification</h1>
17186
+ <p>${ROBOT_OR_HUMAN_FACT}</p>
17187
+ <p>${ARTICLE_FACT}</p>
17188
+ ${prose2(6, 11)}
17189
+ <h2>Estuary sediment</h2>
17190
+ <p>${ARTICLE_FACT_2}</p>
17191
+ ${prose2(5, 23)}
17192
+ </article>`
17193
+ }))
17194
+ };
16447
17195
  var CJK_FACT = "\u7A91\u6E29\u8FBE\u5230\u4E00\u5343\u4E8C\u767E\u56DB\u5341\u5EA6\u540E\u91C9\u9762\u5F00\u59CB\u73BB\u5316\u3002";
16448
17196
  var staticCjk = {
16449
17197
  truth: {
@@ -17363,6 +18111,71 @@ var blockLoginWallBenefits = {
17363
18111
  </body></html>`
17364
18112
  })
17365
18113
  };
18114
+ var blockPerimeterX = {
18115
+ truth: {
18116
+ id: "block-perimeterx",
18117
+ target: "/block/perimeterx",
18118
+ kind: "fixture",
18119
+ category: "blocked",
18120
+ mustContain: [],
18121
+ mustNotContain: ["Robot or human?", "Activate and hold the button"],
18122
+ expectedLane: "http",
18123
+ emptyIsLegit: false,
18124
+ expectedMainTokens: null,
18125
+ budget: budget(500),
18126
+ expectedStatus: "blocked",
18127
+ expectedBlockReason: "captcha",
18128
+ notes: "Shaped like the PerimeterX press-and-hold page Walmart served with HTTP 200 (after a 307 to /blocked) on 2026-10-05, which browser_local returned as success. The widget renders into #px-captcha from the captcha script the page loads, configured by window._pxAppId. captcha, not bot_detected_generic: the button needs a person to press and hold it, and the browser lane had already run the page's JS when it got this page."
18129
+ },
18130
+ respond: () => ({
18131
+ status: 200,
18132
+ headers: { "content-type": "text/html; charset=utf-8" },
18133
+ body: `<!doctype html><html lang="en"><head><title>Robot or human?</title>
18134
+ <script>window._pxAppId = 'PXfixture1'; window._pxJsClientSrc = '/px/' + window._pxAppId + '/init.js'; window._pxFirstPartyEnabled = true;
18135
+ var captchajs = "/px/" + window._pxAppId + "/captcha/captcha.js?a=c&m=0&g=b"</script>
18136
+ </head><body><div id="sign-in-widget"><a class="header-logo" href="/" aria-label="Home Page"></a>
18137
+ <h1 class="heading">Robot or human?</h1>
18138
+ <div class="re-captcha"><p class="bot-message" id="message">Activate and hold the button to confirm that you\u2019re human. Thank You!</p>
18139
+ <div id="px-captcha" style="margin:16px"></div></div>
18140
+ <div class="lite-footer"><a href="/terms">Terms of Use</a> <a href="/privacy">Privacy Policy</a><p>\xA9 Fixture Stores, Inc.</p></div>
18141
+ </div><script id="blockScript"></script><script>document.getElementById('blockScript').src = captchajs</script></body></html>`
18142
+ })
18143
+ };
18144
+ var blockPerimeterX429 = {
18145
+ truth: {
18146
+ id: "block-perimeterx-429",
18147
+ target: "/block/perimeterx-429",
18148
+ kind: "fixture",
18149
+ category: "blocked",
18150
+ mustContain: [],
18151
+ mustNotContain: ["Press & Hold"],
18152
+ expectedLane: "http",
18153
+ emptyIsLegit: false,
18154
+ expectedMainTokens: null,
18155
+ budget: budget(500),
18156
+ expectedStatus: "blocked",
18157
+ expectedBlockReason: "captcha",
18158
+ notes: "Shaped like the PerimeterX stock block template Wayfair (and its sibling stores) served with HTTP 429 and no Retry-After on 2026-10-05. The status says rate limit, the page is the press-and-hold challenge: captcha, because a person can get through it and slowing down does not."
18159
+ },
18160
+ respond: () => ({
18161
+ status: 429,
18162
+ headers: { "content-type": "text/html" },
18163
+ body: `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
18164
+ <meta name="description" content="px-captcha"><title>Access to this page has been denied</title></head>
18165
+ <body><script>
18166
+ window._pxVid = ''; window._pxUuid = '00000000-0000-4000-8000-000000000000'; window._pxAppId = 'PXfixture2';
18167
+ window._pxHostUrl = '/fixture2/xhr'; window._pxJsClientSrc = '/fixture2/init.js'; window._pxFirstPartyEnabled = true;
18168
+ var pxCaptchaSrc = '/fixture2/captcha/captcha.js?a=c&u=00000000-0000-4000-8000-000000000000&v=&m=0';
18169
+ var script = document.createElement('script'); script.src = pxCaptchaSrc; script.onerror = onScriptError; document.head.appendChild(script);
18170
+ function onScriptError() { script = document.createElement('script');
18171
+ script.src = 'https://captcha.px-cloud.net/PXfixture2/captcha.js?a=c&u=00000000-0000-4000-8000-000000000000&v=&m=0';
18172
+ document.head.appendChild(script); }
18173
+ window._pxOnError = function () { var div = document.createElement('div');
18174
+ div.innerHTML = '<div>Before we continue...</div><div>Press & Hold to confirm you are a human (and not a bot).</div>';
18175
+ document.body.appendChild(div); };
18176
+ </script></body></html>`
18177
+ })
18178
+ };
17366
18179
  var SOFT404_TEXT = "We could not find that page";
17367
18180
  var soft404 = {
17368
18181
  truth: {
@@ -17784,6 +18597,7 @@ var homePage = {
17784
18597
  var FIXTURES = [
17785
18598
  staticArticle,
17786
18599
  staticArticleWithWidget,
18600
+ staticArticleRobotOrHuman,
17787
18601
  staticCjk,
17788
18602
  staticTable,
17789
18603
  t_thead,
@@ -17821,6 +18635,8 @@ var FIXTURES = [
17821
18635
  blockRateLimit,
17822
18636
  blockLoginWall,
17823
18637
  blockLoginWallBenefits,
18638
+ blockPerimeterX,
18639
+ blockPerimeterX429,
17824
18640
  soft404,
17825
18641
  redirectToHome,
17826
18642
  redirectChain,
@@ -18021,6 +18837,264 @@ var EgressRoute = class {
18021
18837
  }
18022
18838
  };
18023
18839
 
18840
+ // packages/bench/dist/compatTransport.js
18841
+ init_dist();
18842
+ init_egress();
18843
+ var COMPAT_LIBRARY = { name: "impit", version: "0.14.5" };
18844
+ var COMPAT_PROFILES = {
18845
+ chrome142: {
18846
+ browser: "chrome142",
18847
+ headers: [
18848
+ ["sec-ch-ua", '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"'],
18849
+ ["sec-ch-ua-mobile", "?0"],
18850
+ ["sec-ch-ua-platform", '"macOS"'],
18851
+ ["upgrade-insecure-requests", "1"],
18852
+ ["user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
18853
+ ["accept", "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"],
18854
+ ["sec-fetch-site", "none"],
18855
+ ["sec-fetch-mode", "navigate"],
18856
+ ["sec-fetch-user", "?1"],
18857
+ ["sec-fetch-dest", "document"],
18858
+ ["accept-encoding", "gzip, deflate, br, zstd"],
18859
+ ["accept-language", "en-US,en;q=0.9"],
18860
+ ["priority", "u=0, i"]
18861
+ ]
18862
+ }
18863
+ };
18864
+ var DEFAULT_COMPAT_PROFILE = "chrome142";
18865
+ function compatIdentity(profile = DEFAULT_COMPAT_PROFILE) {
18866
+ const sent = new Map(COMPAT_PROFILES[profile].headers);
18867
+ return {
18868
+ mode: "standard",
18869
+ userAgent: sent.get("user-agent"),
18870
+ clientHints: { "sec-ch-ua": sent.get("sec-ch-ua"), "sec-ch-ua-mobile": sent.get("sec-ch-ua-mobile"), "sec-ch-ua-platform": sent.get("sec-ch-ua-platform") },
18871
+ respectsRobots: true,
18872
+ lane: "browser_local",
18873
+ device: "desktop"
18874
+ };
18875
+ }
18876
+ function prepareCompatIdentity(profile = DEFAULT_COMPAT_PROFILE) {
18877
+ const headers = Object.fromEntries(COMPAT_PROFILES[profile].headers.map(([name, value]) => [name, value]));
18878
+ const sentHeaders = { headers: Object.entries(headers).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name)) };
18879
+ return { mode: "standard", identity: compatIdentity(profile), headers, identityHeaders: headers, customHeaders: {}, sentHeaders };
18880
+ }
18881
+ var impit;
18882
+ function loadImpit() {
18883
+ impit ??= import("impit").catch((error) => {
18884
+ impit = void 0;
18885
+ throw new Error(`the compatible transport needs impit ${COMPAT_LIBRARY.version}, which did not load on ${process.platform}-${process.arch}: ${error instanceof Error ? error.message : String(error)}`);
18886
+ });
18887
+ return impit;
18888
+ }
18889
+ var PROXY_VARIABLES = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"];
18890
+ var NO_PROXY_ANY = "*,0.0.0.0/0,::/0";
18891
+ function withLaneProxyOnly(direct, build) {
18892
+ const saved = PROXY_VARIABLES.map((name) => [name, process.env[name]]);
18893
+ for (const name of PROXY_VARIABLES)
18894
+ delete process.env[name];
18895
+ if (direct) {
18896
+ process.env.NO_PROXY = NO_PROXY_ANY;
18897
+ process.env.no_proxy = NO_PROXY_ANY;
18898
+ }
18899
+ try {
18900
+ return build();
18901
+ } finally {
18902
+ for (const [name, value] of saved) {
18903
+ if (value === void 0)
18904
+ delete process.env[name];
18905
+ else
18906
+ process.env[name] = value;
18907
+ }
18908
+ }
18909
+ }
18910
+ var COMPAT_REQUEST_CEILING_MS = 10 * 6e4;
18911
+ function impitTimeoutMs(deadlineAt, now = Date.now()) {
18912
+ return deadlineAt === void 0 ? COMPAT_REQUEST_CEILING_MS : Math.max(1e3, deadlineAt - now + 1e3);
18913
+ }
18914
+ function decodingFault(message2) {
18915
+ return /kind: Decode\b/.test(message2) && !/hyper::Error|h2::|IncompleteBody|TimedOut|ConnectionReset|ConnectionAborted|BrokenPipe/.test(message2);
18916
+ }
18917
+ var IMPIT_DECODES = /* @__PURE__ */ new Set(["gzip", "deflate", "br", "zstd"]);
18918
+ function normalized(error) {
18919
+ const message2 = error instanceof Error ? error.message : String(error);
18920
+ const name = error instanceof Error ? error.name : "";
18921
+ const as = (n, code) => Object.assign(new Error(message2.split("\n")[0] ?? message2), { name: n, ...code === void 0 ? {} : { code }, cause: error });
18922
+ if (name === "TimeoutError" || name === "AbortError")
18923
+ return error instanceof Error ? error : as("TimeoutError");
18924
+ if (/InvalidCertificate|certificate|handshake|tls|ssl/i.test(message2)) {
18925
+ const code = /expired/i.test(message2) ? "CERT_HAS_EXPIRED" : /not yet valid/i.test(message2) ? "CERT_NOT_YET_VALID" : /NotValidForName|name mismatch|hostname/i.test(message2) ? "HOSTNAME_MISMATCH" : /not trusted|UnknownIssuer|self.signed/i.test(message2) ? "CERT_UNTRUSTED" : "ERR_SSL_COMPAT_TRANSPORT";
18926
+ return as("CompatTlsError", code);
18927
+ }
18928
+ if (/dns error|failed to lookup|nodename nor servname|Name or service not known/i.test(message2))
18929
+ return as("DnsLookupError");
18930
+ return as("ConnectError");
18931
+ }
18932
+ var CompatTransport = class {
18933
+ networkPolicy;
18934
+ profile;
18935
+ clients = /* @__PURE__ */ new Map();
18936
+ constructor(networkPolicy, profile = DEFAULT_COMPAT_PROFILE) {
18937
+ this.networkPolicy = networkPolicy;
18938
+ this.profile = profile;
18939
+ }
18940
+ async clientFor(url, ignoreTlsErrors) {
18941
+ const proxy = proxyFor(url, this.networkPolicy);
18942
+ const auth = proxy?.username === void 0 ? "" : `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password ?? "")}@`;
18943
+ const proxyUrl = proxy === null ? void 0 : proxy.url.replace("://", `://${auth}`);
18944
+ const key = `${proxyUrl ?? "direct"}|${ignoreTlsErrors}`;
18945
+ let client = this.clients.get(key);
18946
+ if (client === void 0) {
18947
+ const { Impit } = await loadImpit();
18948
+ client = this.clients.get(key) ?? withLaneProxyOnly(proxyUrl === void 0, () => new Impit({ browser: COMPAT_PROFILES[this.profile].browser, followRedirects: false, ...proxyUrl === void 0 ? {} : { proxyUrl }, ...ignoreTlsErrors ? { ignoreTlsErrors: true } : {} }));
18949
+ this.clients.set(key, client);
18950
+ }
18951
+ return client;
18952
+ }
18953
+ async fetch(url, options) {
18954
+ let response;
18955
+ const client = await this.clientFor(url, options.ignoreTlsErrors === true);
18956
+ const cookie = options.cookies === void 0 ? "" : await options.cookies.header(url);
18957
+ const extraHeaders = { ...options.extraHeaders ?? {}, ...cookie === "" ? {} : { cookie } };
18958
+ const controller2 = new AbortController();
18959
+ const abort = () => controller2.abort(options.signal?.reason);
18960
+ if (options.signal?.aborted === true)
18961
+ abort();
18962
+ else
18963
+ options.signal?.addEventListener("abort", abort, { once: true });
18964
+ let headersTimedOut = false;
18965
+ const headersTimer = setTimeout(() => {
18966
+ headersTimedOut = true;
18967
+ controller2.abort();
18968
+ }, options.headersTimeoutMs);
18969
+ try {
18970
+ response = await client.fetch(url, {
18971
+ redirect: "manual",
18972
+ timeout: impitTimeoutMs(options.deadlineAt),
18973
+ signal: controller2.signal,
18974
+ ...Object.keys(extraHeaders).length === 0 ? {} : { headers: extraHeaders }
18975
+ });
18976
+ } catch (error) {
18977
+ options.signal?.removeEventListener("abort", abort);
18978
+ if (headersTimedOut && options.signal?.aborted !== true)
18979
+ throw Object.assign(new Error(`no response headers within ${options.headersTimeoutMs} ms`), { name: "HeadersTimeoutError", cause: error });
18980
+ throw normalized(error);
18981
+ } finally {
18982
+ clearTimeout(headersTimer);
18983
+ }
18984
+ if (options.cookies !== void 0) {
18985
+ const headers = response.headers;
18986
+ await options.cookies.keep(url, headers.getSetCookie?.() ?? (headers.get("set-cookie") === null ? [] : [headers.get("set-cookie")]));
18987
+ }
18988
+ const coding = response.headers.get("content-encoding")?.trim() ?? null;
18989
+ const bodiless = response.status < 200 || response.status === 204 || response.status === 304;
18990
+ const decodedFrom = !bodiless && coding !== null && IMPIT_DECODES.has(coding) ? coding : null;
18991
+ if (decodedFrom !== null)
18992
+ options.onDecoded?.(decodedFrom);
18993
+ const header = (name) => decodedFrom !== null && name.toLowerCase() === "content-encoding" ? null : response.headers.get(name);
18994
+ let bytes;
18995
+ const readBody = async () => {
18996
+ const cap = options.capFor(response.headers.get("content-type"), decodedFrom);
18997
+ const reader = response.body.getReader();
18998
+ if (cap === null) {
18999
+ await reader.cancel().catch(() => {
19000
+ });
19001
+ return new Uint8Array();
19002
+ }
19003
+ const declared3 = decodedFrom === null ? Number(response.headers.get("content-length") ?? NaN) : NaN;
19004
+ if (Number.isSafeInteger(declared3) && declared3 > cap) {
19005
+ await reader.cancel().catch(() => {
19006
+ });
19007
+ throw new BodyTooLargeError(cap, declared3);
19008
+ }
19009
+ const started = performance.now();
19010
+ const chunks = [];
19011
+ let total = 0;
19012
+ try {
19013
+ while (true) {
19014
+ let timer;
19015
+ const stalled = new Promise((_, reject) => {
19016
+ timer = setTimeout(() => reject(Object.assign(new Error("body read timed out"), { name: "BodyTimeoutError" })), options.bodyTimeoutMs);
19017
+ });
19018
+ let step;
19019
+ try {
19020
+ step = await Promise.race([reader.read(), stalled]);
19021
+ } finally {
19022
+ clearTimeout(timer);
19023
+ }
19024
+ if (step.done)
19025
+ break;
19026
+ total += step.value.byteLength;
19027
+ if (total > cap)
19028
+ throw new BodyTooLargeError(cap);
19029
+ chunks.push(step.value);
19030
+ }
19031
+ } catch (error) {
19032
+ await reader.cancel().catch(() => {
19033
+ });
19034
+ if (error instanceof BodyTooLargeError || error instanceof Error && error.name === "BodyTimeoutError")
19035
+ throw error;
19036
+ if (error instanceof Error && error.name === "AbortError")
19037
+ throw error;
19038
+ const message2 = error instanceof Error ? error.message : String(error);
19039
+ if (decodedFrom !== null && decodingFault(message2))
19040
+ throw new ContentDecodingError(decodedFrom, decodedFrom, "impit_decode");
19041
+ const timedOut = error instanceof Error && error.name === "TimeoutError" || /TimedOut/.test(message2);
19042
+ throw Object.assign(new Error(message2.split("\n")[0] ?? message2), { name: timedOut ? "BodyTimeoutError" : "SocketError", cause: error });
19043
+ }
19044
+ options.onBodyRead?.(Math.max(0, performance.now() - started));
19045
+ const out = new Uint8Array(total);
19046
+ let offset = 0;
19047
+ for (const chunk of chunks) {
19048
+ out.set(chunk, offset);
19049
+ offset += chunk.byteLength;
19050
+ }
19051
+ return out;
19052
+ };
19053
+ const bodyBytes = () => bytes ??= readBody().finally(() => options.signal?.removeEventListener("abort", abort));
19054
+ const bodyText = async () => {
19055
+ try {
19056
+ return new TextDecoder().decode(await bodyBytes());
19057
+ } catch (error) {
19058
+ if (error instanceof ContentDecodingError)
19059
+ return "";
19060
+ throw error;
19061
+ }
19062
+ };
19063
+ return { status: response.status, headers: { get: header }, bodyBytes, bodyText };
19064
+ }
19065
+ };
19066
+ function compatHostsChoice(env, grant, hosted) {
19067
+ const raw = (env.W2L_COMPAT_HOSTS ?? "").split(",").map((value) => value.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
19068
+ if (raw.length === 0)
19069
+ return [];
19070
+ const invalid = raw.filter((host) => {
19071
+ try {
19072
+ return new URL(`http://${host}/`).hostname !== host || host.includes("*");
19073
+ } catch {
19074
+ return true;
19075
+ }
19076
+ });
19077
+ if (invalid.length > 0)
19078
+ throw new Error(`W2L_COMPAT_HOSTS takes host names, such as example.com: not ${invalid.join(", ")}`);
19079
+ if (hosted)
19080
+ throw new Error("W2L_COMPAT_HOSTS is refused on a hosted server (ADR 0005: the compatible transport resolves host names outside the hosted address check)");
19081
+ if (!(grant?.capabilities ?? []).includes("compatible_transport")) {
19082
+ throw new Error("W2L_COMPAT_HOSTS needs an access grant that names compatible_transport (ADR 0005; --access-grant or W2L_ACCESS_GRANT)");
19083
+ }
19084
+ return [...new Set(raw)].sort();
19085
+ }
19086
+ function compatHostListed(hosts, url) {
19087
+ if (hosts.length === 0)
19088
+ return false;
19089
+ let host;
19090
+ try {
19091
+ host = new URL(url).hostname.toLowerCase().replace(/\.$/, "");
19092
+ } catch {
19093
+ return false;
19094
+ }
19095
+ return hosts.some((listed) => host === listed || host.endsWith(`.${listed}`));
19096
+ }
19097
+
18024
19098
  // packages/bench/dist/subjects/resilientHttp.js
18025
19099
  init_robotsLookup();
18026
19100
 
@@ -18292,6 +19366,7 @@ async function readFileResponse(response, bytes, context) {
18292
19366
  var ResilientHttpSubject = class {
18293
19367
  fileStore;
18294
19368
  previewProductToken;
19369
+ compat;
18295
19370
  meta = {
18296
19371
  id: "resilient-http",
18297
19372
  displayName: "Resilient HTTP (redirect+retry \xD7 extract-tf)",
@@ -18319,12 +19394,16 @@ var ResilientHttpSubject = class {
18319
19394
  * `fileStore`: where files (PDF, CSV, ...) are saved as received; without one a file is read but not saved.
18320
19395
  * `previewProductToken`: the hosted public preview's standard User-Agent carries PREVIEW_PRODUCT_TOKEN (previewIdentity).
18321
19396
  * `robotsCache`: a robots.txt cache shared with the crawl's sitemap reader, so one robots.txt read serves both; without one the subject keeps its own.
19397
+ * `compat`: the browser-compatible transport (compatTransport.ts) in place of undici, with its profile's identity; standard mode on a local server only.
18322
19398
  */
18323
- constructor(mode = "standard", networkPolicy, scheduler, localPreviewProxyUrl, localPreviewRobotsException = false, fileStore = null, previewProductToken = false, robotsCache) {
19399
+ constructor(mode = "standard", networkPolicy, scheduler, localPreviewProxyUrl, localPreviewRobotsException = false, fileStore = null, previewProductToken = false, robotsCache, compat = null) {
18324
19400
  this.fileStore = fileStore;
18325
19401
  this.previewProductToken = previewProductToken;
19402
+ this.compat = compat;
18326
19403
  this.networkPolicy = networkPolicy ?? defaultNetworkPolicy();
18327
- this.prepared = prepareHttpIdentity(mode, this.networkPolicy.contact ?? null, null, "desktop", void 0, previewProductToken);
19404
+ if (compat !== null && (mode !== "standard" || previewProductToken || localPreviewProxyUrl !== void 0))
19405
+ throw new Error(`the compatible transport sends its profile's standard identity: not for ${mode !== "standard" ? `mode ${mode}` : "the hosted preview"}`);
19406
+ this.prepared = compat !== null ? prepareCompatIdentity(compat.profile) : prepareHttpIdentity(mode, this.networkPolicy.contact ?? null, null, "desktop", void 0, previewProductToken);
18328
19407
  if (localPreviewRobotsException && !localPreviewProxyUrl)
18329
19408
  throw new Error("Local platform exception requires a loopback proxy");
18330
19409
  this.localPreviewRobotsException = localPreviewRobotsException;
@@ -18336,27 +19415,50 @@ var ResilientHttpSubject = class {
18336
19415
  this.route = new EgressRoute(this.networkPolicy, this.egress, this.localPreviewProxy);
18337
19416
  this.robotsCache = robotsCache ?? new RobotsOriginCache(this.networkPolicy, (url) => this.dispatcherFor(url));
18338
19417
  const maxBodyBytes = this.networkPolicy.maxBodyBytes;
18339
- this.fetcherFor = (initialUrl, headers, validators, signal, onBodyRead, onRequestWait, onEnvProxy, maxFileBytes = fileByteCap(this.networkPolicy), wire, routes = null) => async (url, init) => {
19418
+ this.fetcherFor = (initialUrl, headers, validators, signal, onBodyRead, onRequestWait, onEnvProxy, maxFileBytes = fileByteCap(this.networkPolicy), wire, routes = null, onResponseCoding, deadlineAt, cookies) => async (url, init) => {
18340
19419
  await this.scheduler.beforeRequest(new URL(url).origin, init.signal ?? signal, onRequestWait);
18341
19420
  const envProxy = this.envProxyFor(url);
18342
19421
  if (envProxy !== null)
18343
19422
  onEnvProxy?.(url, envProxy);
19423
+ if (this.compat !== null) {
19424
+ onResponseCoding?.(null);
19425
+ return this.compat.fetch(url, {
19426
+ ...cookies === void 0 ? {} : { cookies },
19427
+ signal: init.signal ?? signal,
19428
+ headersTimeoutMs: init.headersTimeoutMs,
19429
+ bodyTimeoutMs: init.bodyTimeoutMs,
19430
+ capFor: (contentType, decodedFrom) => {
19431
+ const kind = classifyContentType(contentType);
19432
+ return kind === "unsupported" ? null : kind === "page" ? decodedFrom === null ? maxBodyBytes : this.networkPolicy.maxDecompressedBytes : maxFileBytes;
19433
+ },
19434
+ extraHeaders: url === initialUrl ? validators.etag ? { "if-none-match": validators.etag } : validators.lastModified ? { "if-modified-since": validators.lastModified } : {} : {},
19435
+ ignoreTlsErrors: routes !== null,
19436
+ ...deadlineAt === void 0 ? {} : { deadlineAt },
19437
+ onDecoded: (coding) => onResponseCoding?.(coding),
19438
+ ...onBodyRead === void 0 ? {} : { onBodyRead }
19439
+ });
19440
+ }
18344
19441
  const customNames = wire === void 0 ? [] : Object.keys(wire.headers);
18345
19442
  const sameOrigin2 = new URL(url).origin === new URL(initialUrl).origin;
18346
19443
  if (wire !== void 0 && customNames.length > 0 && !sameOrigin2)
18347
19444
  wire.onWithheld(url, customNames);
19445
+ const cookie = cookies === void 0 ? "" : await cookies.header(url);
18348
19446
  const response = await request5(url, {
18349
19447
  dispatcher: this.dispatcherFor(url, routes),
18350
19448
  method: "GET",
18351
19449
  headersTimeout: init.headersTimeoutMs,
18352
19450
  bodyTimeout: init.bodyTimeoutMs,
18353
19451
  // The identity after the custom headers, so it is never overridden. Validators are bound to one representation; never forward on redirects.
18354
- headers: { ...wire !== void 0 && sameOrigin2 ? wire.headers : {}, ...headers, ...url === initialUrl ? validators.etag ? { "if-none-match": validators.etag } : validators.lastModified ? { "if-modified-since": validators.lastModified } : {} : {} },
19452
+ headers: { ...wire !== void 0 && sameOrigin2 ? wire.headers : {}, ...headers, ...url === initialUrl ? validators.etag ? { "if-none-match": validators.etag } : validators.lastModified ? { "if-modified-since": validators.lastModified } : {} : {}, ...cookie === "" ? {} : { cookie } },
18355
19453
  signal: init.signal ?? signal
18356
19454
  }).catch((error) => {
18357
19455
  throw proxyRefusal(error) ?? error;
18358
19456
  });
18359
19457
  const responseHeaders = response.headers;
19458
+ if (cookies !== void 0) {
19459
+ const setCookie = responseHeaders["set-cookie"];
19460
+ await cookies.keep(url, typeof setCookie === "string" ? [setCookie] : setCookie ?? []);
19461
+ }
18360
19462
  const header = (name) => {
18361
19463
  const v = responseHeaders[name.toLowerCase()];
18362
19464
  return typeof v === "string" ? v : Array.isArray(v) ? v[0] ?? null : null;
@@ -18396,6 +19498,13 @@ var ResilientHttpSubject = class {
18396
19498
  * request's custom headers (`headers`) before it.
18397
19499
  */
18398
19500
  preparedFor(url, options) {
19501
+ if (this.compat !== null) {
19502
+ if (options.mobile === true || Object.keys(options.headers ?? {}).length > 0)
19503
+ throw new Error("the compatible transport sends its profile's headers alone: no custom headers, no mobile identity");
19504
+ const prepared2 = prepareCompatIdentity(this.compat.profile);
19505
+ prepared2.identity.respectsRobots = this.prepared.identity.respectsRobots;
19506
+ return prepared2;
19507
+ }
18399
19508
  const prepared = prepareHttpIdentity(this.prepared.mode, this.networkPolicy.contact ?? null, new URL(url).hostname, options.mobile === true ? "mobile" : "desktop", options.headers, this.previewProductToken);
18400
19509
  prepared.identity.respectsRobots = this.prepared.identity.respectsRobots;
18401
19510
  return prepared;
@@ -18408,10 +19517,11 @@ var ResilientHttpSubject = class {
18408
19517
  envProxyFor(url) {
18409
19518
  return this.route.viaOperatorProxy(url);
18410
19519
  }
18411
- async fetch(url, deadlineMs, signal, validators = {}, onRetryAfter, options = {}, onRobotsOverride) {
19520
+ /** `cookieSession`: the task's cookies (ADR 0005 `egress_sessions`), sent and kept on every hop; absent, none are. */
19521
+ async fetch(url, deadlineMs, signal, validators = {}, onRetryAfter, options = {}, onRobotsOverride, cookieSession) {
18412
19522
  if (this.localPreviewRobotsException && !isLocalPreviewProxyTarget(url))
18413
19523
  throw new Error("Local platform exception is limited to fixed platform hosts");
18414
- const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter });
19524
+ const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter, ...cookieSession === void 0 ? {} : { cookieSession } });
18415
19525
  const start = Date.now();
18416
19526
  const monotonicStart = performance.now();
18417
19527
  const origin = new URL(url).origin;
@@ -18546,18 +19656,18 @@ var ResilientHttpSubject = class {
18546
19656
  event: "robots_disallowed",
18547
19657
  detail: { url, appliedRules: robotsDecision2.appliedRules, ...robotsDecision2.unreachable === void 0 ? {} : { unreachable: robotsDecision2.unreachable } }
18548
19658
  });
18549
- const override = options.robotsOverride;
19659
+ const override = applicableOverride(options.robotsOverride, robotsDecision2);
18550
19660
  if (robotsDecision2.unreachable !== void 0 && cached?.error?.tls === true) {
18551
19661
  trace.push({ at: Date.now() - start, lane: "http", event: "request_failed", detail: { reason: "tls_error", url: robotsDecision2.robotsUrl, error: cached.error.name, ...cached.error.code === null ? {} : { code: cached.error.code } } });
18552
19662
  return timedDenied("tls_error");
18553
19663
  }
18554
- if (override === void 0 || robotsDecision2.unreachable !== void 0)
19664
+ if (override === void 0)
18555
19665
  return timedDenied("policy_denied");
18556
19666
  trace.push({
18557
19667
  at: Date.now() - start,
18558
19668
  lane: "http",
18559
19669
  event: "robots_overridden",
18560
- detail: { url, appliedRules: robotsDecision2.appliedRules, reason: override.reason, ...override.recordedBy === void 0 ? {} : { recordedBy: override.recordedBy } }
19670
+ detail: overriddenDetail(url, robotsDecision2, override)
18561
19671
  });
18562
19672
  overrideWarning = robotsOverrideWarning(robotsDecision2, override);
18563
19673
  onRobotsOverride?.(robotsOverrideApplied(trace, overrideWarning));
@@ -18568,8 +19678,12 @@ var ResilientHttpSubject = class {
18568
19678
  const host = new URL(url).origin;
18569
19679
  if (cooldownWaitMs > 0)
18570
19680
  trace.push({ at: Date.now() - start, lane: "http", event: "host_cooldown_wait", detail: { host, waitMs: cooldownWaitMs } });
19681
+ if (this.compat !== null)
19682
+ trace.push({ at: Date.now() - start, lane: "http", event: "transport", detail: { library: COMPAT_LIBRARY.name, version: COMPAT_LIBRARY.version, profile: this.compat.profile } });
18571
19683
  const transportStart = performance.now();
18572
19684
  const maxFileBytes = fileByteCap(this.networkPolicy, options.maxFileBytes);
19685
+ const transportCoding = { decoded: null };
19686
+ const cookies = execution.cookieSession === void 0 ? void 0 : new SessionCookies(execution.cookieSession);
18573
19687
  const out = await resilientFetch(url, this.fetcherFor(url, prepared.identityHeaders, validators, signal, (ms) => {
18574
19688
  bodyReadMs += ms;
18575
19689
  }, (intervalMs, cooldownMs) => {
@@ -18577,11 +19691,13 @@ var ResilientHttpSubject = class {
18577
19691
  cooldownWaitMs += cooldownMs;
18578
19692
  pacingWaitMs += intervalMs + cooldownMs;
18579
19693
  }, (target, proxy) => {
18580
- trace.push({ at: Date.now() - start, lane: "http", event: "egress_proxy", detail: { url: target, proxy, source: "environment" } });
19694
+ trace.push({ at: Date.now() - start, lane: "http", event: "egress_proxy", detail: { url: target, proxy, source: this.networkPolicy.egressProxy?.source ?? "environment" } });
18581
19695
  }, maxFileBytes, {
18582
19696
  headers: prepared.customHeaders,
18583
19697
  onWithheld: (to, names) => trace.push({ at: Date.now() - start, lane: "http", event: "custom_headers_withheld", detail: { to, names: [...names] } })
18584
- }, relaxed), {
19698
+ }, relaxed, (coding) => {
19699
+ transportCoding.decoded = coding;
19700
+ }, deadlineAt, cookies), {
18585
19701
  signal,
18586
19702
  deadlineAt,
18587
19703
  onRetryAfter: (target, retryAt2) => {
@@ -18604,6 +19720,8 @@ var ResilientHttpSubject = class {
18604
19720
  transportMs = Math.max(0, performance.now() - transportStart - pacingWaitMs);
18605
19721
  return null;
18606
19722
  });
19723
+ if (cookies !== void 0)
19724
+ trace.push({ at: Date.now() - start, lane: "http", event: "session_cookies", detail: cookies.detail() });
18607
19725
  if (out === null)
18608
19726
  return timedDenied("timeout", this.scheduler.retryAt(host));
18609
19727
  const fetchedAt = out.status === null ? null : (/* @__PURE__ */ new Date()).toISOString();
@@ -18643,7 +19761,10 @@ var ResilientHttpSubject = class {
18643
19761
  ...file2 === void 0 ? {} : { file: file2 }
18644
19762
  };
18645
19763
  };
18646
- const contentEncoding = out.kind === "ok" && classifyContentType(contentType) !== "unsupported" ? contentEncodingLabel(out.headers?.get("content-encoding")) : void 0;
19764
+ const contentEncoding = out.kind === "ok" && classifyContentType(contentType) !== "unsupported" ? contentEncodingLabel(out.headers?.get("content-encoding") ?? transportCoding.decoded) : void 0;
19765
+ const transportDecoded = transportCoding.decoded;
19766
+ if (transportDecoded !== null && out.kind === "ok")
19767
+ trace.push({ at: Date.now() - start, lane: "http", event: "transport_decoded", detail: { contentEncoding: transportDecoded, by: COMPAT_LIBRARY.name } });
18647
19768
  let wire;
18648
19769
  let bytes;
18649
19770
  let undecoded = false;
@@ -18691,7 +19812,7 @@ var ResilientHttpSubject = class {
18691
19812
  transportMs += Math.max(0, bodyReadMs - bodyReadBeforeFinal);
18692
19813
  const file = contentful ? detectFile(contentType, bytes, responseFileName(out.finalUrl, out.headers?.get("content-disposition") ?? null)) : null;
18693
19814
  const body = file === null ? new TextDecoder().decode(bytes) : "";
18694
- const rawBodySha256 = file === null && !undecoded ? sha256Utf8(body) : null;
19815
+ const rawBodySha256 = file === null && !undecoded && out.status !== null ? sha256Utf8(body) : null;
18695
19816
  const rawArtifacts = rawBodySha256 === null ? [] : await captureRawHtml(body, rawBodySha256);
18696
19817
  const base = {
18697
19818
  requestedUrl: url,
@@ -18717,13 +19838,13 @@ var ResilientHttpSubject = class {
18717
19838
  },
18718
19839
  usage: {
18719
19840
  wallMs,
18720
- bytesWire: wire.byteLength,
19841
+ bytesWire: transportDecoded === null ? wire.byteLength : null,
18721
19842
  bytesDecompressed: file === null ? Buffer.byteLength(body) : bytes.byteLength,
18722
19843
  requestCount: out.requestCount,
18723
19844
  attemptCount: out.attemptCount,
18724
19845
  contentTokens: null,
18725
19846
  browserMs: 0,
18726
- externalCostUsd: null
19847
+ externalCostUsd: 0
18727
19848
  },
18728
19849
  trace
18729
19850
  };
@@ -18863,7 +19984,8 @@ var ResilientHttpSubject = class {
18863
19984
  markers: render.markers,
18864
19985
  emptyTables: render.emptyTables,
18865
19986
  textChars: render.textChars,
18866
- scriptChars: render.scriptChars
19987
+ scriptChars: render.scriptChars,
19988
+ ...render.listRecords === void 0 ? {} : { listRecords: render.listRecords }
18867
19989
  }
18868
19990
  });
18869
19991
  return [{
@@ -18991,7 +20113,7 @@ var ResilientHttpSubject = class {
18991
20113
  attemptCount: 0,
18992
20114
  contentTokens: null,
18993
20115
  browserMs: 0,
18994
- externalCostUsd: null
20116
+ externalCostUsd: 0
18995
20117
  },
18996
20118
  trace
18997
20119
  };
@@ -19046,13 +20168,85 @@ function proxyRefusal(error) {
19046
20168
  refusal.name = "ProxyConnectError";
19047
20169
  return refusal;
19048
20170
  }
20171
+ var SessionCookies = class {
20172
+ session;
20173
+ requestsWithCookies = 0;
20174
+ kept = 0;
20175
+ constructor(session) {
20176
+ this.session = session;
20177
+ }
20178
+ async header(url) {
20179
+ const header = await this.session.cookieHeader(url);
20180
+ if (header !== "")
20181
+ this.requestsWithCookies++;
20182
+ return header;
20183
+ }
20184
+ async keep(url, setCookies) {
20185
+ if (setCookies.length > 0)
20186
+ this.kept += await this.session.store(url, setCookies);
20187
+ }
20188
+ detail() {
20189
+ return { session: this.session.id, requestsWithCookies: this.requestsWithCookies, kept: this.kept };
20190
+ }
20191
+ };
19049
20192
 
19050
20193
  // packages/bench/dist/subjects/browserLocal.js
19051
20194
  init_dist();
19052
20195
  init_dist3();
19053
20196
  init_dist2();
19054
- init_egress();
20197
+ import { chromium as chromium2 } from "playwright";
20198
+
20199
+ // packages/bench/dist/subjects/browserEngine.js
19055
20200
  import { chromium } from "playwright";
20201
+ var PLAYWRIGHT_ENGINE = { name: "playwright", launch: (options) => chromium.launch(options) };
20202
+ function browserEngineChoice(env, grant, hosted) {
20203
+ const raw = (env.W2L_BROWSER_ENGINE ?? "").trim().toLowerCase();
20204
+ if (raw === "" || raw === "playwright")
20205
+ return "playwright";
20206
+ if (raw !== "patchright")
20207
+ throw new Error(`W2L_BROWSER_ENGINE is playwright or patchright, not ${raw}`);
20208
+ if (hosted)
20209
+ throw new Error("W2L_BROWSER_ENGINE=patchright is refused on a hosted server (ADR 0005: the enhanced browser runs only in a local Octocrawl instance)");
20210
+ if (!(grant?.capabilities ?? []).includes("enhanced_browser")) {
20211
+ throw new Error("W2L_BROWSER_ENGINE=patchright needs an access grant that names enhanced_browser (ADR 0005; --access-grant or W2L_ACCESS_GRANT)");
20212
+ }
20213
+ return "patchright";
20214
+ }
20215
+ var PATCHRIGHT_MODULE = ["patch", "right"].join("");
20216
+ var PATCHRIGHT_INSTALL = "install it where Octocrawl runs: npm install octocrawl patchright (or, with npx, npx -p octocrawl -p patchright octocrawl ...), then npx patchright install chromium";
20217
+ async function loadPatchrightEngine() {
20218
+ const { createRequire: createRequire2 } = await import("module");
20219
+ const { join: join13 } = await import("path");
20220
+ const { pathToFileURL: pathToFileURL3 } = await import("url");
20221
+ let mod;
20222
+ let from = import.meta.url;
20223
+ try {
20224
+ mod = await import(PATCHRIGHT_MODULE);
20225
+ } catch {
20226
+ try {
20227
+ const resolved = createRequire2(join13(process.cwd(), "package.json")).resolve(PATCHRIGHT_MODULE);
20228
+ mod = await import(pathToFileURL3(resolved).href);
20229
+ from = pathToFileURL3(resolved).href;
20230
+ } catch {
20231
+ throw new Error(`W2L_BROWSER_ENGINE=patchright needs the optional patchright package; ${PATCHRIGHT_INSTALL}`);
20232
+ }
20233
+ }
20234
+ const chromiumApi = mod.chromium ?? mod.default?.chromium;
20235
+ if (chromiumApi === void 0)
20236
+ throw new Error("the patchright package found has no chromium export");
20237
+ let version = null;
20238
+ try {
20239
+ version = createRequire2(from)(`${PATCHRIGHT_MODULE}/package.json`).version ?? null;
20240
+ } catch {
20241
+ }
20242
+ return { name: "patchright", version, launch: async (options) => await chromiumApi.launch(options) };
20243
+ }
20244
+ async function browserEngineFor(name) {
20245
+ return name === "patchright" ? await loadPatchrightEngine() : PLAYWRIGHT_ENGINE;
20246
+ }
20247
+
20248
+ // packages/bench/dist/subjects/browserLocal.js
20249
+ init_egress();
19056
20250
  init_robotsLookup();
19057
20251
 
19058
20252
  // packages/bench/dist/subjects/browserActions.js
@@ -19198,10 +20392,10 @@ async function runPageActions(actions, ctx) {
19198
20392
  const guard = async () => {
19199
20393
  const landed = await refusedDocument();
19200
20394
  if (landed !== null)
19201
- throw new StepFailure("navigation_refused", `the step led the page to ${landed.url}, which W2L does not fetch (${landed.reason}); it is not read`);
20395
+ throw new StepFailure("navigation_refused", `the step led the page to ${landed.url}, which Octocrawl does not fetch (${landed.reason}); it is not read`);
19202
20396
  const refused = ctx.takeRefusedNavigation();
19203
20397
  if (refused !== null)
19204
- throw new StepFailure("navigation_refused", `the step led the page to ${refused.url}, which W2L does not fetch (${refused.reason}); the request was not sent`);
20398
+ throw new StepFailure("navigation_refused", `the step led the page to ${refused.url}, which Octocrawl does not fetch (${refused.reason}); the request was not sent`);
19205
20399
  };
19206
20400
  try {
19207
20401
  const detail = await runStep(action, ctx, result, artifacts, index, guard);
@@ -19217,7 +20411,7 @@ async function runPageActions(actions, ctx) {
19217
20411
  if (failure2.code !== "navigation_refused") {
19218
20412
  const landed = await refusedDocument().catch(() => null);
19219
20413
  if (landed !== null)
19220
- failure2 = new StepFailure("navigation_refused", `the step led the page to ${landed.url}, which W2L does not fetch (${landed.reason}); it is not read`);
20414
+ failure2 = new StepFailure("navigation_refused", `the step led the page to ${landed.url}, which Octocrawl does not fetch (${landed.reason}); it is not read`);
19221
20415
  }
19222
20416
  if (failure2.code === "navigation_refused")
19223
20417
  dropSince(result, artifacts, before);
@@ -19321,9 +20515,10 @@ async function runStep(action, ctx, result, artifacts, index, guard) {
19321
20515
  case "executeJavascript": {
19322
20516
  let value;
19323
20517
  try {
19324
- value = await bounded(ctx, page.evaluate(`(async () => {
20518
+ const expression = `(async () => {
19325
20519
  ${action.script}
19326
- })()`));
20520
+ })()`;
20521
+ value = await bounded(ctx, ctx.evaluateScript === void 0 ? page.evaluate(expression) : ctx.evaluateScript(expression));
19327
20522
  } catch (error) {
19328
20523
  if (signal?.aborted || error instanceof StepFailure)
19329
20524
  throw error;
@@ -20144,6 +21339,9 @@ var PAGE_CLOSE_MS = 2e3;
20144
21339
  async function closePage(page, timeoutMs = PAGE_CLOSE_MS) {
20145
21340
  return closeWithin(page.close(), timeoutMs);
20146
21341
  }
21342
+ function contextCookie(c) {
21343
+ return { name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite };
21344
+ }
20147
21345
  async function closeWithin(closing, timeoutMs) {
20148
21346
  const settled2 = closing.then(() => true, () => true);
20149
21347
  let timer;
@@ -20163,6 +21361,7 @@ var BrowserLocalSubject = class {
20163
21361
  fileStore;
20164
21362
  previewProductToken;
20165
21363
  adHosts;
21364
+ browserEngine;
20166
21365
  meta = {
20167
21366
  id: "browser-local",
20168
21367
  displayName: "browser-local (playwright chromium \xD7 extract-tf)",
@@ -20201,7 +21400,9 @@ var BrowserLocalSubject = class {
20201
21400
  accessConfig;
20202
21401
  /** The operator's environment proxy for every context this subject opens (local mode). */
20203
21402
  envProxy;
20204
- constructor(mode = "standard", access, headed = false, networkPolicy, managedProfileDir = null, scheduler, publicPreferenceState = null, browserAllowedHosts, onRenderedHtml, fileStore = null, previewProductToken = false, adHosts = AD_HOSTS) {
21403
+ /** The launched engine's package version, once known (Patchright only). */
21404
+ engineVersion = null;
21405
+ constructor(mode = "standard", access, headed = false, networkPolicy, managedProfileDir = null, scheduler, publicPreferenceState = null, browserAllowedHosts, onRenderedHtml, fileStore = null, previewProductToken = false, adHosts = AD_HOSTS, browserEngine = "playwright") {
20205
21406
  this.mode = mode;
20206
21407
  this.headed = headed;
20207
21408
  this.managedProfileDir = managedProfileDir;
@@ -20211,6 +21412,10 @@ var BrowserLocalSubject = class {
20211
21412
  this.fileStore = fileStore;
20212
21413
  this.previewProductToken = previewProductToken;
20213
21414
  this.adHosts = adHosts;
21415
+ this.browserEngine = browserEngine;
21416
+ if (browserEngine !== "playwright" && (access != null || managedProfileDir !== null)) {
21417
+ throw new Error("the enhanced browser runs only in the public browser lane, never with a saved login or a managed profile");
21418
+ }
20214
21419
  if (publicPreferenceState !== null && (mode !== "standard" || access != null || managedProfileDir !== null)) {
20215
21420
  throw new Error("anonymous public preference state is only available to the standard public browser");
20216
21421
  }
@@ -20239,8 +21444,9 @@ var BrowserLocalSubject = class {
20239
21444
  ledger() {
20240
21445
  return this.chain.toLedger();
20241
21446
  }
20242
- async fetch(url, deadlineMs, signal, onRetryAfter, options = {}, onRobotsOverride) {
20243
- const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter });
21447
+ /** `cookieSession`: the task's cookies (ADR 0005 `egress_sessions`): the context starts with them and leaves its own there. */
21448
+ async fetch(url, deadlineMs, signal, onRetryAfter, options = {}, onRobotsOverride, cookieSession) {
21449
+ const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter, ...cookieSession === void 0 ? {} : { cookieSession } });
20244
21450
  const start = Date.now();
20245
21451
  const monotonicStart = performance.now();
20246
21452
  let queueMs = 0;
@@ -20326,11 +21532,14 @@ var BrowserLocalSubject = class {
20326
21532
  const routeMatch = "**/*";
20327
21533
  let actionsRoute = null;
20328
21534
  let popupCloser = null;
21535
+ let taskCookies = null;
20329
21536
  try {
20330
21537
  throwIfExecutionStopped(execution);
20331
21538
  await raceWithSignal(assertSafeUrl(url, this.networkPolicy), signal);
20332
21539
  const managedContext = this.managedProfileDir === null ? null : await raceWithSignal(this.getManagedContext(execution), signal);
20333
21540
  const browser = managedContext?.browser() ?? await raceWithSignal(this.getBrowser(execution), signal);
21541
+ if (this.browserEngine !== "playwright")
21542
+ trace.push({ at: Date.now() - start, lane: "browser_local", event: "browser_engine", detail: { engine: this.browserEngine, version: this.engineVersion } });
20334
21543
  throwIfExecutionStopped(execution);
20335
21544
  const version = browser.version();
20336
21545
  const major = Number(version.split(".")[0] ?? CHROME_MAJOR_FLOOR);
@@ -20361,15 +21570,15 @@ var BrowserLocalSubject = class {
20361
21570
  }
20362
21571
  });
20363
21572
  const host = this.hostOf(url);
20364
- const override = options.robotsOverride;
20365
- const overridden = identity.respectsRobots && robotsDecision2.decision === "disallowed" && robotsDecision2.unreachable === void 0 && override !== void 0;
21573
+ const override = applicableOverride(options.robotsOverride, robotsDecision2);
21574
+ const overridden = identity.respectsRobots && robotsDecision2.decision === "disallowed" && override !== void 0 && !(robotsDecision2.unreachable !== void 0 && cachedRobots?.error?.tls === true);
20366
21575
  if (override !== void 0 && overridden) {
20367
- trace.push({ at: Date.now() - start, lane: "browser_local", event: "robots_disallowed", detail: { url, appliedRules: robotsDecision2.appliedRules } });
21576
+ trace.push({ at: Date.now() - start, lane: "browser_local", event: "robots_disallowed", detail: { url, appliedRules: robotsDecision2.appliedRules, ...robotsDecision2.unreachable === void 0 ? {} : { unreachable: robotsDecision2.unreachable } } });
20368
21577
  trace.push({
20369
21578
  at: Date.now() - start,
20370
21579
  lane: "browser_local",
20371
21580
  event: "robots_overridden",
20372
- detail: { url, appliedRules: robotsDecision2.appliedRules, reason: override.reason, ...override.recordedBy === void 0 ? {} : { recordedBy: override.recordedBy } }
21581
+ detail: overriddenDetail(url, robotsDecision2, override)
20373
21582
  });
20374
21583
  onRobotsOverride?.(robotsOverrideApplied(trace, robotsOverrideWarning(robotsDecision2, override)));
20375
21584
  }
@@ -20430,7 +21639,7 @@ var BrowserLocalSubject = class {
20430
21639
  attemptCount: 0,
20431
21640
  contentTokens: null,
20432
21641
  browserMs: 0,
20433
- externalCostUsd: null
21642
+ externalCostUsd: 0
20434
21643
  },
20435
21644
  trace
20436
21645
  };
@@ -20549,6 +21758,13 @@ var BrowserLocalSubject = class {
20549
21758
  detail: { cookieCount: userCookies.length, ...storedOrigins === 0 ? {} : { localStorageOrigins: storedOrigins }, sessionSha256: this.access.sessionSha256 }
20550
21759
  });
20551
21760
  }
21761
+ if (execution.cookieSession !== void 0 && this.accessConfig?.session === void 0 && context !== this.managedContext) {
21762
+ const cookies = await raceWithSignal(execution.cookieSession.browserCookies(url), signal);
21763
+ if (cookies.length > 0)
21764
+ await context.addCookies(cookies.map((c) => ({ ...c, ...c.expires < 0 ? { expires: -1 } : {} })));
21765
+ const startedWith = cookies.length === 0 ? [] : (await raceWithSignal(context.cookies(), signal)).map(contextCookie);
21766
+ taskCookies = { session: execution.cookieSession, startedWith };
21767
+ }
20552
21768
  throwIfExecutionStopped(execution);
20553
21769
  const pendingPage = context.newPage();
20554
21770
  void pendingPage.then((created) => {
@@ -20637,7 +21853,7 @@ var BrowserLocalSubject = class {
20637
21853
  trace.push({ at: Date.now() - start, lane: "browser_local", event: "navigate", detail: { url: navigationUrl, attempt: attemptCount, timeoutMs: navigationTimeoutMs } });
20638
21854
  const envProxy = proxyFor(navigationUrl, this.networkPolicy);
20639
21855
  if (envProxy !== null)
20640
- trace.push({ at: Date.now() - start, lane: "browser_local", event: "egress_proxy", detail: { url: navigationUrl, proxy: envProxy.endpoint, source: "environment" } });
21856
+ trace.push({ at: Date.now() - start, lane: "browser_local", event: "egress_proxy", detail: { url: navigationUrl, proxy: envProxy.endpoint, source: this.networkPolicy.egressProxy?.source ?? "environment" } });
20641
21857
  try {
20642
21858
  documents.restart();
20643
21859
  response = await page.goto(navigationUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeoutMs });
@@ -20768,8 +21984,11 @@ var BrowserLocalSubject = class {
20768
21984
  });
20769
21985
  };
20770
21986
  context.on("page", popupCloser);
21987
+ const actionPage = page;
20771
21988
  ran.actions = await runPageActions(options.actions, {
20772
21989
  page,
21990
+ // Patchright's evaluate takes (pageFunction, arg, options, isolatedContext) and defaults to an isolated world.
21991
+ evaluateScript: this.browserEngine === "patchright" ? (expression) => actionPage.evaluate(expression, void 0, void 0, false) : (expression) => actionPage.evaluate(expression),
20773
21992
  execution,
20774
21993
  trace,
20775
21994
  at: () => Date.now() - start,
@@ -20823,7 +22042,7 @@ var BrowserLocalSubject = class {
20823
22042
  const late = refusedNavigations.shift();
20824
22043
  const last = options.actions.length - 1;
20825
22044
  if (late !== void 0 && ran.actions.result.failed === void 0) {
20826
- ran.actions.result.failed = { index: last, type: options.actions[last].type, code: "navigation_refused", message: `after the steps, the page tried to go to ${late.url}, which W2L does not fetch (${late.reason}); the page stayed where it was` };
22045
+ ran.actions.result.failed = { index: last, type: options.actions[last].type, code: "navigation_refused", message: `after the steps, the page tried to go to ${late.url}, which Octocrawl does not fetch (${late.reason}); the page stayed where it was` };
20827
22046
  dropLastStep(ran.actions);
20828
22047
  }
20829
22048
  for (const loadedUrl of documents.loaded.slice(ran.actions.checkedDocuments)) {
@@ -20832,7 +22051,7 @@ var BrowserLocalSubject = class {
20832
22051
  continue;
20833
22052
  trace.push({ at: Date.now() - start, lane: "browser_local", event: "navigation_refused", detail: { url: loadedUrl, reason: landed } });
20834
22053
  if (ran.actions.result.failed === void 0) {
20835
- ran.actions.result.failed = { index: last, type: options.actions[last].type, code: "navigation_refused", message: `after the steps, the page loaded ${loadedUrl}, which W2L does not fetch (${landed}); it is not read` };
22054
+ ran.actions.result.failed = { index: last, type: options.actions[last].type, code: "navigation_refused", message: `after the steps, the page loaded ${loadedUrl}, which Octocrawl does not fetch (${landed}); it is not read` };
20836
22055
  dropLastStep(ran.actions);
20837
22056
  }
20838
22057
  return this.notRead(url, start, trace, finalUrl);
@@ -20915,7 +22134,7 @@ var BrowserLocalSubject = class {
20915
22134
  navigationFollowupCount: variantFollowups,
20916
22135
  contentTokens: null,
20917
22136
  browserMs,
20918
- externalCostUsd: null,
22137
+ externalCostUsd: 0,
20919
22138
  timings: { retryWaitMs, totalMs: wallMs }
20920
22139
  },
20921
22140
  trace
@@ -21087,7 +22306,7 @@ var BrowserLocalSubject = class {
21087
22306
  attemptCount: 1,
21088
22307
  contentTokens: null,
21089
22308
  browserMs: wallMs,
21090
- externalCostUsd: null
22309
+ externalCostUsd: 0
21091
22310
  },
21092
22311
  trace
21093
22312
  };
@@ -21095,6 +22314,11 @@ var BrowserLocalSubject = class {
21095
22314
  signal?.removeEventListener("abort", onAbort);
21096
22315
  if (page !== void 0)
21097
22316
  await closePage(page);
22317
+ if (taskCookies !== null && context !== void 0) {
22318
+ const held = await Promise.race([context.cookies().catch(() => null), new Promise((done) => setTimeout(() => done(null), PAGE_CLOSE_MS))]);
22319
+ const changes = held === null ? { kept: 0, removed: 0 } : await taskCookies.session.storeBrowserChanges(taskCookies.startedWith, held.map(contextCookie));
22320
+ trace.push({ at: Date.now() - start, lane: "browser_local", event: "session_cookies", detail: { session: taskCookies.session.id, startedWith: taskCookies.startedWith.length, ...changes, read: held !== null } });
22321
+ }
21098
22322
  await session?.detach().catch(() => {
21099
22323
  });
21100
22324
  if (context !== void 0 && requestRoute !== null)
@@ -21161,7 +22385,7 @@ var BrowserLocalSubject = class {
21161
22385
  ...this.networkPolicy.egressProxy ? { envProxy: proxyFor(finalUrl, this.networkPolicy)?.endpoint ?? null } : {}
21162
22386
  },
21163
22387
  // The file's bytes are counted; what crossed the wire (compressed) is not measured.
21164
- usage: { wallMs: at(), bytesWire: null, bytesDecompressed: 0, requestCount: attemptCount, attemptCount, contentTokens: null, browserMs: at(), externalCostUsd: null },
22388
+ usage: { wallMs: at(), bytesWire: null, bytesDecompressed: 0, requestCount: attemptCount, attemptCount, contentTokens: null, browserMs: at(), externalCostUsd: 0 },
21165
22389
  trace
21166
22390
  };
21167
22391
  };
@@ -21248,7 +22472,7 @@ var BrowserLocalSubject = class {
21248
22472
  artifacts: [],
21249
22473
  ...this.networkPolicy.egressProxy ? { envProxy: proxyFor(finalUrl, this.networkPolicy)?.endpoint ?? null } : {}
21250
22474
  },
21251
- usage: { wallMs, bytesWire: null, bytesDecompressed: 0, requestCount: attemptCount, attemptCount, contentTokens: null, browserMs: wallMs, externalCostUsd: null },
22475
+ usage: { wallMs, bytesWire: null, bytesDecompressed: 0, requestCount: attemptCount, attemptCount, contentTokens: null, browserMs: wallMs, externalCostUsd: 0 },
21252
22476
  trace
21253
22477
  };
21254
22478
  }
@@ -21284,7 +22508,7 @@ var BrowserLocalSubject = class {
21284
22508
  truncatedAt: null,
21285
22509
  compliance: null,
21286
22510
  evidence: { finalUrl, httpStatus: null, redirectChain: [], contentType: null, rawBodySha256: null, artifacts: [] },
21287
- usage: { wallMs: Date.now() - start, bytesWire: 0, bytesDecompressed: 0, requestCount: 1, attemptCount: 1, contentTokens: null, browserMs: Date.now() - start, externalCostUsd: null },
22511
+ usage: { wallMs: Date.now() - start, bytesWire: 0, bytesDecompressed: 0, requestCount: 1, attemptCount: 1, contentTokens: null, browserMs: Date.now() - start, externalCostUsd: 0 },
21288
22512
  trace
21289
22513
  };
21290
22514
  }
@@ -21325,7 +22549,7 @@ var BrowserLocalSubject = class {
21325
22549
  attemptCount: 0,
21326
22550
  contentTokens: null,
21327
22551
  browserMs: wallMs,
21328
- externalCostUsd: null
22552
+ externalCostUsd: 0
21329
22553
  },
21330
22554
  trace
21331
22555
  };
@@ -21351,7 +22575,10 @@ var BrowserLocalSubject = class {
21351
22575
  ];
21352
22576
  if (this.activeExecutions === 0)
21353
22577
  throw new DOMException("Browser startup abandoned", "AbortError");
21354
- return chromium.launch({ headless: !this.headed, timeout: 3e4, ...args.length === 0 ? chromiumProxyLaunchOptions(this.envProxy) : { args } });
22578
+ const engine = await browserEngineFor(this.browserEngine);
22579
+ if (engine.version !== void 0)
22580
+ this.engineVersion = engine.version;
22581
+ return engine.launch({ headless: !this.headed, timeout: 3e4, ...args.length === 0 ? chromiumProxyLaunchOptions(this.envProxy) : { args } });
21355
22582
  };
21356
22583
  const pending = launch().then(async (browser) => {
21357
22584
  if (this.activeExecutions === 0) {
@@ -21377,7 +22604,7 @@ var BrowserLocalSubject = class {
21377
22604
  if (this.managedContext !== null)
21378
22605
  return this.managedContext;
21379
22606
  if (this.managedContextPromise === null) {
21380
- const pending = chromium.launchPersistentContext(this.managedProfileDir, { headless: !this.headed, timeout: 3e4, ...chromiumProxyLaunchOptions(this.envProxy) }).then(async (context) => {
22607
+ const pending = chromium2.launchPersistentContext(this.managedProfileDir, { headless: !this.headed, timeout: 3e4, ...chromiumProxyLaunchOptions(this.envProxy) }).then(async (context) => {
21381
22608
  if (this.activeExecutions === 0) {
21382
22609
  if (this.managedContextPromise === pending)
21383
22610
  this.managedContextPromise = null;
@@ -21491,6 +22718,7 @@ function freshEntry() {
21491
22718
  latencyTotalMs: 0,
21492
22719
  latencySamplesMs: [],
21493
22720
  costTotalUsd: 0,
22721
+ costUnknownAttempts: 0,
21494
22722
  lastFailureClass: null
21495
22723
  };
21496
22724
  }
@@ -21504,7 +22732,10 @@ function applyOutcome(entry3, outcome) {
21504
22732
  if (entry3.latencySamplesMs.length > MAX_LATENCY_SAMPLES) {
21505
22733
  entry3.latencySamplesMs = entry3.latencySamplesMs.slice(-MAX_LATENCY_SAMPLES);
21506
22734
  }
21507
- entry3.costTotalUsd += outcome.costUsd;
22735
+ if (outcome.costUsd === null)
22736
+ entry3.costUnknownAttempts += 1;
22737
+ else
22738
+ entry3.costTotalUsd += outcome.costUsd;
21508
22739
  entry3.lastFailureClass = outcome.failureClass;
21509
22740
  }
21510
22741
  var MemoryRoutingHistory = class {
@@ -21532,7 +22763,8 @@ function rankVendors(domainHistory, vendorIds) {
21532
22763
  score: EXPLORE_BONUS,
21533
22764
  successRate: 0,
21534
22765
  medianLatencyMs: null,
21535
- totalCostUsd: 0
22766
+ totalCostUsd: 0,
22767
+ costUnknown: false
21536
22768
  });
21537
22769
  continue;
21538
22770
  }
@@ -21544,13 +22776,15 @@ function rankVendors(domainHistory, vendorIds) {
21544
22776
  mid = samples.length % 2 === 0 ? (samples[half - 1] + samples[half]) / 2 : samples[half];
21545
22777
  }
21546
22778
  const cost = entry3.costTotalUsd;
21547
- const score = successRate + 0.3 / (1 + mid / 1e3) + 0.1 / (1 + cost / 0.1);
22779
+ const costUnknown = entry3.costUnknownAttempts > 0;
22780
+ const score = successRate + 0.3 / (1 + mid / 1e3) + (costUnknown ? 0 : 0.1 / (1 + cost / 0.1));
21548
22781
  scores.push({
21549
22782
  vendorId: id,
21550
22783
  score,
21551
22784
  successRate,
21552
22785
  medianLatencyMs: mid,
21553
- totalCostUsd: cost
22786
+ totalCostUsd: cost,
22787
+ costUnknown
21554
22788
  });
21555
22789
  }
21556
22790
  return scores.sort((a, b) => b.score - a.score);
@@ -21661,6 +22895,10 @@ var FileSessionStore = class _FileSessionStore {
21661
22895
  };
21662
22896
 
21663
22897
  // packages/bench/dist/routing/ladder.js
22898
+ function attemptOf(channel, id, result, ordinal, startedAt) {
22899
+ return { channel: id, result, ordinal, startedAt, endedAt: (/* @__PURE__ */ new Date()).toISOString(), ...channel.vendorId === void 0 ? {} : { vendorId: channel.vendorId } };
22900
+ }
22901
+ var HTTP_CHANNELS = /* @__PURE__ */ new Set(["http", "http_compat"]);
21664
22902
  function summarize(channelsTried, attempts) {
21665
22903
  const cost = {
21666
22904
  knownSubtotal: attempts.reduce((sum, item) => sum + (item.result.usage.externalCostUsd ?? 0), 0),
@@ -21716,7 +22954,7 @@ function withLowContentYield(result, channelsTried) {
21716
22954
  return result;
21717
22955
  if (result.warnings?.some((warning) => warning.code === "low_content_yield") === true)
21718
22956
  return result;
21719
- return { ...result, warnings: [...result.warnings ?? [], lowContentYieldWarning(result, channelsTried.some((channel) => channel !== "http"))] };
22957
+ return { ...result, warnings: [...result.warnings ?? [], lowContentYieldWarning(result, channelsTried.some((channel) => !HTTP_CHANNELS.has(channel)))] };
21720
22958
  }
21721
22959
  function withRenderedLowYield(result) {
21722
22960
  if (!CONTENTFUL_STATUS.has(result.status) || result.warnings?.some((warning) => warning.code === "low_content_yield") === true)
@@ -21811,11 +23049,11 @@ var LadderRunner = class {
21811
23049
  execution.onRobotsOverride?.(applied);
21812
23050
  } };
21813
23051
  try {
21814
- return carryRobotsOverride(await this.runWithinBudget(url, session, rungs, options, progress), progress.robotsOverrides);
23052
+ return withRunCost(carryRobotsOverride(await this.runWithinBudget(url, session, rungs, options, progress), progress.robotsOverrides));
21815
23053
  } catch (error) {
21816
23054
  if (!deadlineReached(scope))
21817
23055
  throw error;
21818
- return carryRobotsOverride(deadlineOutcome(url, progress, null), progress.robotsOverrides);
23056
+ return withRunCost(carryRobotsOverride(deadlineOutcome(url, progress, null), progress.robotsOverrides));
21819
23057
  } finally {
21820
23058
  scope.dispose();
21821
23059
  }
@@ -21866,7 +23104,7 @@ var LadderRunner = class {
21866
23104
  return finish2(this.governanceRefusal(url, decision.reason ?? "governance refused this url"), false);
21867
23105
  }
21868
23106
  const permitted = new Set(decision.permittedChannels);
21869
- const local = this.channels.filter((c) => c.vendorId === void 0 && permitted.has(c.id));
23107
+ const local = this.channels.filter((c) => c.vendorId === void 0 && permitted.has(c.id) && (c.serves?.(url, options) ?? true));
21870
23108
  const providers = this.channels.filter((c) => c.vendorId !== void 0 && permitted.has(c.id));
21871
23109
  let ordered = [...local, ...await raceWithSignal(this.orderProviders(url, providers), execution.signal)];
21872
23110
  const sessionFirst = effectiveSession !== null && effectiveSession.vendor === "browser_local_authed" && sessionCoversHost(effectiveSession.domain, safeHost(url)) ? ordered.find((c) => c.id === "authed_session") ?? null : null;
@@ -21911,8 +23149,9 @@ var LadderRunner = class {
21911
23149
  return finish2(identityBlock, false);
21912
23150
  }
21913
23151
  channelsTried.push(channel.id);
23152
+ const startedAt2 = (/* @__PURE__ */ new Date()).toISOString();
21914
23153
  const result = await raceWithSignal(channel.fetch(url, effectiveSession, execution, options), execution.signal);
21915
- attempts.push({ channel: channel.id, result });
23154
+ attempts.push(attemptOf(channel, channel.id, result, attempts.length + 1, startedAt2));
21916
23155
  if (result.usage.deadlineExceeded === true || result.failureReason === "timeout" && deadlineReached(execution)) {
21917
23156
  return deadlineOutcome(url, progress, result);
21918
23157
  }
@@ -21955,7 +23194,7 @@ var LadderRunner = class {
21955
23194
  detail: { domain: snapshot.domain, vendor: channel.vendorId }
21956
23195
  });
21957
23196
  }
21958
- const qualityEvent = channel.id === "http" ? qualityEscalationEvent(result) : null;
23197
+ const qualityEvent = HTTP_CHANNELS.has(channel.id) ? qualityEscalationEvent(result) : null;
21959
23198
  const thinHttp = qualityEvent !== null;
21960
23199
  const emptyConfirmed = !thinHttp && worseThanBest && bestSize === 0;
21961
23200
  if (thinHttp || worseThanBest) {
@@ -22080,7 +23319,7 @@ var LadderRunner = class {
22080
23319
  const outcome = {
22081
23320
  contentful: CONTENTFUL_STATUS.has(result.status),
22082
23321
  wallMs: result.usage.wallMs,
22083
- costUsd: result.usage.externalCostUsd ?? 0,
23322
+ costUsd: result.usage.externalCostUsd,
22084
23323
  failureClass: cls
22085
23324
  };
22086
23325
  await this.history?.record(safeHost(url), vendorId, outcome);
@@ -22148,8 +23387,9 @@ var LadderRunner = class {
22148
23387
  summary: summarize([...channelsTried, `${channel.id}(retry)`], attempts)
22149
23388
  };
22150
23389
  }
23390
+ const retryStartedAt = (/* @__PURE__ */ new Date()).toISOString();
22151
23391
  const retry = await raceWithSignal(channel.fetch(url, snapshot, execution, options), execution.signal);
22152
- attempts.push({ channel: `${channel.id}(retry)`, result: retry });
23392
+ attempts.push(attemptOf(channel, `${channel.id}(retry)`, retry, attempts.length + 1, retryStartedAt));
22153
23393
  ladderTrace.push({
22154
23394
  at: retry.usage.wallMs,
22155
23395
  event: "ladder_step",
@@ -22199,7 +23439,7 @@ var LadderRunner = class {
22199
23439
  attemptCount: 0,
22200
23440
  contentTokens: null,
22201
23441
  browserMs: 0,
22202
- externalCostUsd: null
23442
+ externalCostUsd: 0
22203
23443
  },
22204
23444
  trace: [{ at: 0, lane: "http", event: "governance_refusal", detail: { reason: reason2 } }]
22205
23445
  };
@@ -22262,7 +23502,13 @@ function deadlineOutcome(url, progress, returned) {
22262
23502
  const event = { at, lane: base.lane, event: "deadline_exceeded", detail: evidence === null ? detail : { ...detail, evidence: evidence.channel } };
22263
23503
  result = { ...base, status: "failed", failureReason: "timeout", blockReason: null, budgetExceeded: null, markdown: evidence?.result.markdown ?? null, usage: { ...base.usage, contentTokens: null, deadlineExceeded: true }, trace: [...base.trace, event] };
22264
23504
  }
22265
- return { result, channelsTried, handoffRequested: false, ladderTrace, summary: { ...summarize(channelsTried, attempts), totalMs: at } };
23505
+ const summary = summarize(channelsTried, attempts);
23506
+ const providerUnmeasured = interrupted !== null && attempts.length < channelsTried.length && laneOf(interrupted) === "provider";
23507
+ const costed = providerUnmeasured ? { ...summary, externalCostUsd: null, externalCost: { ...summary.externalCost, unknown: true } } : summary;
23508
+ return { result, channelsTried, handoffRequested: false, ladderTrace, summary: { ...costed, totalMs: at } };
23509
+ }
23510
+ function withRunCost(run) {
23511
+ return { ...run, result: { ...run.result, usage: { ...run.result.usage, externalCostUsd: run.summary.externalCostUsd } } };
22266
23512
  }
22267
23513
  function noMainContentEvidence(attempts) {
22268
23514
  for (let i = attempts.length - 1; i >= 0; i--) {
@@ -22288,7 +23534,8 @@ function deadlineFailure(url, lane, wallMs) {
22288
23534
  compliance: null,
22289
23535
  evidence: { finalUrl: url, httpStatus: null, redirectChain: [], contentType: null, rawBodySha256: null, artifacts: [] },
22290
23536
  // The interrupted rung's traffic was not measured; the ladder summary holds what was.
22291
- usage: { wallMs, bytesWire: null, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, contentTokens: null, browserMs: 0, externalCostUsd: null },
23537
+ // A vendor rung may have billed before the deadline; a local one calls no third party.
23538
+ usage: { wallMs, bytesWire: null, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, contentTokens: null, browserMs: 0, externalCostUsd: lane === "provider" ? null : 0 },
22292
23539
  trace: []
22293
23540
  };
22294
23541
  }
@@ -22335,7 +23582,7 @@ function identityRefusedResult(url, channel, event, issues) {
22335
23582
  attemptCount: 0,
22336
23583
  contentTokens: null,
22337
23584
  browserMs: 0,
22338
- externalCostUsd: null
23585
+ externalCostUsd: 0
22339
23586
  },
22340
23587
  trace: [
22341
23588
  {
@@ -22401,6 +23648,10 @@ function sessionRejection(url, result) {
22401
23648
  return { ...result, status: "blocked", blockReason: "login_wall", failureReason: null };
22402
23649
  }
22403
23650
 
23651
+ // packages/bench/dist/ladderCli.js
23652
+ init_dist2();
23653
+ import { readFileSync } from "fs";
23654
+
22404
23655
  // packages/bench/dist/fileStore.js
22405
23656
  import { randomUUID as randomUUID2 } from "crypto";
22406
23657
  import { mkdir as mkdir4, rename as rename3, rm, stat, writeFile as writeFile4 } from "fs/promises";
@@ -22441,9 +23692,9 @@ init_provider2();
22441
23692
  init_dist2();
22442
23693
  var playwrightConnector = async (wsUrl, deadlineMs, signal) => {
22443
23694
  throwIfExecutionStopped({ signal, deadlineAt: deadlineMs });
22444
- const { chromium: chromium3 } = await import("playwright");
23695
+ const { chromium: chromium4 } = await import("playwright");
22445
23696
  const remaining = deadlineMs === void 0 ? 3e4 : Math.max(1, deadlineMs - Date.now());
22446
- const pending = chromium3.connectOverCDP(wsUrl, { timeout: remaining });
23697
+ const pending = chromium4.connectOverCDP(wsUrl, { timeout: remaining });
22447
23698
  void pending.then((browser) => {
22448
23699
  if (signal?.aborted)
22449
23700
  void browser.close().catch(() => {
@@ -22734,7 +23985,7 @@ var CdpVendorTransport = class {
22734
23985
  };
22735
23986
 
22736
23987
  // packages/bench/dist/vendors/connect.js
22737
- async function connectVendor(ops, connector = playwrightConnector, resume, deadlineMs, signal) {
23988
+ async function connectVendor(ops, connector = playwrightConnector, resume, deadlineMs, signal, grants = []) {
22738
23989
  const transport = new CdpVendorTransport(ops, connector);
22739
23990
  if (resume !== void 0 && resume !== null) {
22740
23991
  transport.useResumedSession(resume);
@@ -22747,6 +23998,7 @@ async function connectVendor(ops, connector = playwrightConnector, resume, deadl
22747
23998
  // here: the three-layer split means the vendor adapter declares what it
22748
23999
  // CAN do, policy decides what we WILL use, and this list is the result.
22749
24000
  capabilities: ops.decision.enabled.map((c) => c.capability),
24001
+ grants,
22750
24002
  honoursCallerUserAgent: false
22751
24003
  };
22752
24004
  return { declaration, transport };
@@ -22760,20 +24012,22 @@ var BROWSERBASE_CAPABILITIES = [
22760
24012
  { capability: "retry_orchestration", vendorDefaultOn: false, enableKey: "retry_orchestration" },
22761
24013
  { capability: "session_persistence", vendorDefaultOn: false, enableKey: "session_persistence" },
22762
24014
  { capability: "live_view_handoff", vendorDefaultOn: true, enableKey: "live_view_handoff" },
22763
- { capability: "captcha_solving", vendorDefaultOn: true, enableKey: "captcha_solving" },
22764
- { capability: "fingerprint_spoofing", vendorDefaultOn: false, enableKey: "fingerprint_spoofing" }
24015
+ { capability: "captcha_solving", vendorDefaultOn: true, enableKey: "vendor_captcha_solving" },
24016
+ { capability: "fingerprint_spoofing", vendorDefaultOn: false, enableKey: "vendor_stealth" }
22765
24017
  ];
22766
24018
  function browserbaseSessionBody(decision, projectId, resume) {
22767
24019
  const persistEnabled = decision.enabled.some((c) => c.capability === "session_persistence");
24020
+ const solveEnabled = decision.enabled.some((c) => c.capability === "captcha_solving");
24021
+ const stealthEnabled = decision.enabled.some((c) => c.capability === "fingerprint_spoofing");
22768
24022
  return {
22769
24023
  ...projectId === void 0 ? {} : { projectId },
22770
24024
  browserSettings: {
22771
- // Policy-level structural refusal. Browserbase defaults both to true;
22772
- // leaving the default in place would be buying the refused capability
22773
- // by omission, so the wire always says false.
22774
- solveCaptchas: false,
22775
- // The vendor's stealth mode forges fingerprint signals. Same refusal.
22776
- advancedStealth: false,
24025
+ // Browserbase defaults solveCaptchas to true, so the off state is an
24026
+ // explicit false, never an omission.
24027
+ solveCaptchas: solveEnabled,
24028
+ // Without the grant the body keeps the opt-out it has always sent;
24029
+ // with it, the current field (`verified` replaced `advancedStealth`).
24030
+ ...stealthEnabled ? { verified: true } : { advancedStealth: false },
22777
24031
  ...persistEnabled && resume?.browserbaseContextId !== void 0 ? { context: { id: resume.browserbaseContextId, persist: true } } : {}
22778
24032
  }
22779
24033
  };
@@ -22876,23 +24130,25 @@ var STEEL_CAPABILITIES = [
22876
24130
  { capability: "retry_orchestration", vendorDefaultOn: false, enableKey: "retry_orchestration" },
22877
24131
  { capability: "session_persistence", vendorDefaultOn: false, enableKey: "session_persistence" },
22878
24132
  { capability: "live_view_handoff", vendorDefaultOn: true, enableKey: "live_view_handoff" },
22879
- { capability: "captcha_solving", vendorDefaultOn: false, enableKey: "captcha_solving" },
22880
- { capability: "fingerprint_spoofing", vendorDefaultOn: true, enableKey: "fingerprint_spoofing" }
24133
+ { capability: "captcha_solving", vendorDefaultOn: false, enableKey: "vendor_captcha_solving" },
24134
+ { capability: "fingerprint_spoofing", vendorDefaultOn: true, enableKey: "vendor_stealth" }
22881
24135
  ];
22882
24136
  function steelSessionBody(decision, resume) {
22883
24137
  const persistEnabled = decision.enabled.some((c) => c.capability === "session_persistence");
24138
+ const solveEnabled = decision.enabled.some((c) => c.capability === "captcha_solving");
24139
+ const stealthEnabled = decision.enabled.some((c) => c.capability === "fingerprint_spoofing");
22884
24140
  return {
22885
24141
  // Steel injects a synthetic fingerprint BY DEFAULT. skipFingerprintInjection
22886
- // is the opt-out; the policy layer's structural refusal of
22887
- // fingerprint_spoofing makes this unconditional, exactly as with
22888
- // Browserbase's solveCaptchas. A session body without it would be buying
22889
- // the refused capability by omission.
24142
+ // is the opt-out, sent unless the grant names `vendor_stealth` (ADR 0005);
24143
+ // a body without it would buy the capability by omission. Solving follows
24144
+ // `vendor_captcha_solving`. humanizeInteractions stays off under any
24145
+ // grant: ADR 0005 grants no behaviour simulation.
22890
24146
  stealthConfig: {
22891
- skipFingerprintInjection: true,
22892
- autoCaptchaSolving: false,
24147
+ skipFingerprintInjection: !stealthEnabled,
24148
+ autoCaptchaSolving: solveEnabled,
22893
24149
  humanizeInteractions: false
22894
24150
  },
22895
- solveCaptcha: false,
24151
+ solveCaptcha: solveEnabled,
22896
24152
  // First use persists the profile so the create response's profileId can
22897
24153
  // be saved and resumed later; a later run passes the saved profileId.
22898
24154
  ...persistEnabled ? { persistProfile: true } : {},
@@ -22963,8 +24219,11 @@ function buildChannels(mode, opts = {}) {
22963
24219
  if (preview && mode !== "standard")
22964
24220
  throw new Error(`the preview product token is for standard mode, not ${mode}`);
22965
24221
  const http = new ResilientHttpSubject(mode, opts.networkPolicy, originScheduler, opts.localPreviewProxyUrl, opts.localPreviewRobotsException === true, fileStore, preview, opts.robotsCache);
22966
- const plainBrowser = new BrowserLocalSubject(mode, null, opts.headed === true, opts.networkPolicy, null, originScheduler, opts.publicPreferenceState ?? null, opts.browserAllowedHosts, opts.onRenderedHtml, fileStore, preview);
24222
+ const plainBrowser = new BrowserLocalSubject(mode, null, opts.headed === true, opts.networkPolicy, null, originScheduler, opts.publicPreferenceState ?? null, opts.browserAllowedHosts, opts.onRenderedHtml, fileStore, preview, void 0, opts.browserEngine ?? "playwright");
22967
24223
  const declared3 = preview ? identityBundleFrom(previewIdentity(modeIdentity(mode))) : identityForRoute(mode);
24224
+ if (opts.compatTransport === true && (preview || opts.localPreviewProxyUrl !== void 0))
24225
+ throw new Error("the compatible transport is for a local server, not the hosted preview");
24226
+ const compat = opts.compatTransport === true && mode === "standard" ? new ResilientHttpSubject(mode, opts.networkPolicy, originScheduler, void 0, false, fileStore, false, opts.robotsCache, new CompatTransport(opts.networkPolicy ?? defaultNetworkPolicy())) : null;
22968
24227
  const authedSubjects = /* @__PURE__ */ new Map();
22969
24228
  const authedSubjectFor = (session) => {
22970
24229
  const key = `${session.domain}\0${sessionFingerprint(session)}`;
@@ -22991,18 +24250,24 @@ function buildChannels(mode, opts = {}) {
22991
24250
  {
22992
24251
  id: "http",
22993
24252
  identity: declared3,
22994
- fetch: (url, _session, execution, options) => opts.localSubjects?.http !== void 0 ? opts.localSubjects.http.fetch(url, execution?.deadlineAt, execution?.signal, execution, options) : http.fetch(url, execution?.deadlineAt, execution?.signal, {}, execution?.onRetryAfter, options, execution?.onRobotsOverride),
24253
+ fetch: (url, _session, execution, options) => opts.localSubjects?.http !== void 0 ? opts.localSubjects.http.fetch(url, execution?.deadlineAt, execution?.signal, execution, options) : http.fetch(url, execution?.deadlineAt, execution?.signal, {}, execution?.onRetryAfter, options, execution?.onRobotsOverride, execution?.cookieSession),
22995
24254
  close: async () => {
22996
24255
  await http.teardown();
22997
24256
  await opts.localSubjects?.http?.teardown?.();
22998
24257
  }
22999
24258
  },
24259
+ ...compat === null ? [] : [{
24260
+ id: "http_compat",
24261
+ identity: identityBundleFrom(compatIdentity()),
24262
+ fetch: (url, _session, execution, options) => compat.fetch(url, execution?.deadlineAt, execution?.signal, {}, execution?.onRetryAfter, options, execution?.onRobotsOverride, execution?.cookieSession),
24263
+ close: () => compat.teardown()
24264
+ }],
23000
24265
  {
23001
24266
  id: "browser_local",
23002
24267
  identity: declared3,
23003
24268
  waitsFor: true,
23004
24269
  // No session here, ever: the plain rung is the public browser.
23005
- fetch: (url, _session, execution, options) => opts.localSubjects?.browser_local !== void 0 ? opts.localSubjects.browser_local.fetch(url, execution?.deadlineAt, execution?.signal, execution, options) : plainBrowser.fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options, execution?.onRobotsOverride),
24270
+ fetch: (url, _session, execution, options) => opts.localSubjects?.browser_local !== void 0 ? opts.localSubjects.browser_local.fetch(url, execution?.deadlineAt, execution?.signal, execution, options) : plainBrowser.fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options, execution?.onRobotsOverride, execution?.cookieSession),
23006
24271
  close: async () => {
23007
24272
  await plainBrowser.teardown();
23008
24273
  await opts.localSubjects?.browser_local?.teardown?.();
@@ -23056,7 +24321,7 @@ function buildChannels(mode, opts = {}) {
23056
24321
  attemptCount: 0,
23057
24322
  contentTokens: null,
23058
24323
  browserMs: 0,
23059
- externalCostUsd: null
24324
+ externalCostUsd: 0
23060
24325
  },
23061
24326
  trace
23062
24327
  };
@@ -23072,6 +24337,8 @@ function buildChannels(mode, opts = {}) {
23072
24337
  }
23073
24338
  if (mode === "standard")
23074
24339
  return channels;
24340
+ if (!(opts.vendorPolicy?.authorized ?? []).includes("vendor_remote_browser"))
24341
+ return channels;
23075
24342
  const named = new Set((process.env.W2L_VENDORS ?? "").split(",").map((name) => name.trim().toLowerCase()).filter((name) => name !== ""));
23076
24343
  const bbKey = opts.keys?.browserbase ?? (named.has("browserbase") ? process.env.BROWSERBASE_API_KEY : void 0) ?? "";
23077
24344
  const steelKey = opts.keys?.steel ?? (named.has("steel") ? process.env.STEEL_API_KEY : void 0) ?? "";
@@ -23099,7 +24366,7 @@ function buildChannels(mode, opts = {}) {
23099
24366
  return connected;
23100
24367
  if (pending === null) {
23101
24368
  opts.onVendorConnect?.(vendorId);
23102
- pending = connectVendor(ops, opts.vendorConnector, pendingResume ?? null, execution?.deadlineAt, execution?.signal).then((c) => {
24369
+ pending = connectVendor(ops, opts.vendorConnector, pendingResume ?? null, execution?.deadlineAt, execution?.signal, opts.vendorPolicy?.authorized ?? []).then((c) => {
23103
24370
  connected = c;
23104
24371
  return c;
23105
24372
  }).catch((error) => {
@@ -23148,7 +24415,7 @@ function buildChannels(mode, opts = {}) {
23148
24415
  attemptCount: 0,
23149
24416
  contentTokens: null,
23150
24417
  browserMs: 0,
23151
- externalCostUsd: null
24418
+ externalCostUsd: 0
23152
24419
  },
23153
24420
  trace: [
23154
24421
  {
@@ -23250,7 +24517,7 @@ function pageFromUserBrowser(read2, prior, options) {
23250
24517
  attemptCount: 0,
23251
24518
  contentTokens: null,
23252
24519
  browserMs: wallMs,
23253
- externalCostUsd: null
24520
+ externalCostUsd: 0
23254
24521
  },
23255
24522
  trace
23256
24523
  };
@@ -23535,6 +24802,10 @@ function validateCdpEndpoint(value) {
23535
24802
  throw new Error("invalid CDP discovery endpoint");
23536
24803
  return u.href;
23537
24804
  }
24805
+ function publicSession(session) {
24806
+ const { profileDir: _dir, cdpEndpoint: _endpoint, ...publicFields } = session;
24807
+ return publicFields;
24808
+ }
23538
24809
 
23539
24810
  // packages/bench/dist/recipeExecutor.js
23540
24811
  import { createHash as createHash10 } from "crypto";
@@ -23543,7 +24814,7 @@ import { createHash as createHash10 } from "crypto";
23543
24814
  import { createHash as createHash9 } from "crypto";
23544
24815
  import { mkdir as mkdir6, rmdir as rmdir2 } from "fs/promises";
23545
24816
  import { join as join8, resolve as resolve3 } from "path";
23546
- import { chromium as chromium2 } from "playwright";
24817
+ import { chromium as chromium3 } from "playwright";
23547
24818
  init_egress();
23548
24819
 
23549
24820
  // packages/bench/dist/recipeExecutor.js
@@ -23566,7 +24837,7 @@ import { mkdir as mkdir7, writeFile as writeFile6 } from "fs/promises";
23566
24837
  init_dist();
23567
24838
 
23568
24839
  // packages/sdk/dist/version.js
23569
- var SDK_VERSION = "0.3.0";
24840
+ var SDK_VERSION = "0.3.1";
23570
24841
 
23571
24842
  // packages/sdk/dist/watcher.js
23572
24843
  init_dist();
@@ -23575,9 +24846,170 @@ init_dist();
23575
24846
  var SDK_ORIGIN = `js-sdk@${SDK_VERSION}`;
23576
24847
 
23577
24848
  // packages/bench/dist/benchmarkGate.js
23578
- import { existsSync as existsSync2, readFileSync } from "fs";
24849
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
23579
24850
  import { execFileSync } from "child_process";
23580
24851
 
24852
+ // packages/bench/dist/cookieSession.js
24853
+ import { randomUUID as randomUUID3 } from "crypto";
24854
+ import { readFile as readFile4, rename as rename5, rm as rm2, writeFile as writeFile7 } from "fs/promises";
24855
+ import { CookieJar } from "tough-cookie";
24856
+ var SAME_SITE = { strict: "Strict", lax: "Lax", none: "None" };
24857
+ var TaskCookieSession = class {
24858
+ file;
24859
+ sessionId = randomUUID3();
24860
+ // Lenient about what a site sends: a cookie it would not keep is skipped, never an error.
24861
+ jar = new CookieJar(void 0, { looseMode: true });
24862
+ /** Done once the file, if any, has been read; every method waits for it. */
24863
+ ready;
24864
+ writing = Promise.resolve();
24865
+ /** Set when the task ended: a page still finishing afterwards changes the session in memory alone. */
24866
+ closed = false;
24867
+ /** `file`: where the session lives between runs of its task; without one it lives in memory alone. */
24868
+ constructor(file) {
24869
+ this.file = file;
24870
+ this.ready = file === void 0 ? Promise.resolve() : this.load(file);
24871
+ }
24872
+ /** An opaque id for the record, unrelated to any cookie value; a session read from its file keeps the id it had. */
24873
+ get id() {
24874
+ return this.sessionId;
24875
+ }
24876
+ /** Deletes a task's session file: the task has ended, and its cookies go with it. */
24877
+ static async remove(file) {
24878
+ await rm2(file, { force: true });
24879
+ }
24880
+ /**
24881
+ * The task has ended: deletes the file after the writes already queued, and writes nothing again,
24882
+ * so a page that finishes after the task (one a cancel or a deadline stopped waiting for) cannot put it back.
24883
+ */
24884
+ async close() {
24885
+ this.closed = true;
24886
+ const file = this.file;
24887
+ if (file === void 0)
24888
+ return;
24889
+ this.writing = this.writing.then(() => rm2(file, { force: true })).catch(() => {
24890
+ });
24891
+ await this.writing;
24892
+ }
24893
+ async load(file) {
24894
+ let text2;
24895
+ try {
24896
+ text2 = await readFile4(file, "utf8");
24897
+ } catch {
24898
+ return;
24899
+ }
24900
+ try {
24901
+ const saved = JSON.parse(text2);
24902
+ if (saved.version !== 1 || typeof saved.id !== "string" || saved.jar === void 0)
24903
+ return;
24904
+ this.jar = await CookieJar.deserialize({ ...saved.jar, looseMode: true });
24905
+ this.sessionId = saved.id;
24906
+ } catch {
24907
+ }
24908
+ }
24909
+ /** Writes the session to its file after a change, one write at a time. */
24910
+ persist() {
24911
+ const file = this.file;
24912
+ if (file === void 0)
24913
+ return Promise.resolve();
24914
+ this.writing = this.writing.then(async () => {
24915
+ if (this.closed)
24916
+ return;
24917
+ const temporary = `${file}.${randomUUID3()}.tmp`;
24918
+ try {
24919
+ await writeFile7(temporary, JSON.stringify({ version: 1, id: this.sessionId, jar: await this.jar.serialize() }), { mode: 384 });
24920
+ await rename5(temporary, file);
24921
+ } catch {
24922
+ await rm2(temporary, { force: true }).catch(() => {
24923
+ });
24924
+ }
24925
+ }).catch(() => {
24926
+ });
24927
+ return this.writing;
24928
+ }
24929
+ async cookieHeader(url) {
24930
+ await this.ready;
24931
+ return this.jar.getCookieString(url).catch(() => "");
24932
+ }
24933
+ async store(url, setCookies) {
24934
+ await this.ready;
24935
+ let kept = 0;
24936
+ for (const line of setCookies) {
24937
+ const cookie = await this.jar.setCookie(line, url, { ignoreError: true }).catch(() => void 0);
24938
+ if (cookie !== void 0 && cookie.TTL() > 0)
24939
+ kept++;
24940
+ }
24941
+ if (setCookies.length > 0)
24942
+ await this.persist();
24943
+ return kept;
24944
+ }
24945
+ async browserCookies(url) {
24946
+ await this.ready;
24947
+ const cookies = await this.jar.getCookies(url).catch(() => []);
24948
+ return cookies.map((cookie) => ({
24949
+ name: cookie.key,
24950
+ value: cookie.value,
24951
+ // A host-only cookie names its host alone; a domain cookie, with the leading dot, its subdomains too.
24952
+ domain: cookie.hostOnly === true ? cookie.domain ?? "" : `.${cookie.domain ?? ""}`,
24953
+ path: cookie.path ?? "/",
24954
+ expires: cookie.expires instanceof Date ? Math.floor(cookie.expires.getTime() / 1e3) : -1,
24955
+ httpOnly: cookie.httpOnly,
24956
+ secure: cookie.secure,
24957
+ sameSite: SAME_SITE[(cookie.sameSite ?? "").toLowerCase()] ?? "Lax"
24958
+ }));
24959
+ }
24960
+ async storeBrowserChanges(startedWith, held) {
24961
+ const key = (c) => `${c.name}\0${c.domain}\0${c.path}`;
24962
+ const before = new Map(startedWith.map((c) => [key(c), c]));
24963
+ const after = new Map(held.map((c) => [key(c), c]));
24964
+ await this.ready;
24965
+ let kept = 0;
24966
+ let removed = 0;
24967
+ for (const c of held) {
24968
+ const was = before.get(key(c));
24969
+ if (was !== void 0 && was.value === c.value) {
24970
+ if (was.expires === c.expires || await this.currentValue(c) !== was.value)
24971
+ continue;
24972
+ }
24973
+ if (await this.setLine(c, false))
24974
+ kept++;
24975
+ }
24976
+ for (const c of startedWith) {
24977
+ if (after.has(key(c)))
24978
+ continue;
24979
+ if (await this.currentValue(c) !== c.value)
24980
+ continue;
24981
+ await this.setLine(c, true);
24982
+ removed++;
24983
+ }
24984
+ if (kept + removed > 0)
24985
+ await this.persist();
24986
+ return { kept, removed };
24987
+ }
24988
+ /** The session's value now for the cookie with this name, domain and path; undefined when it holds none. */
24989
+ async currentValue(c) {
24990
+ const same = (now) => now.name === c.name && now.domain === c.domain && now.path === c.path;
24991
+ return (await this.browserCookies(`${c.secure ? "https" : "http"}://${c.domain.replace(/^\./, "")}${c.path}`)).find(same)?.value;
24992
+ }
24993
+ /** The cookie as the site would have set it (or deleted it): a host-only one without Domain, which an IP address cannot take. */
24994
+ async setLine(c, remove) {
24995
+ const host = c.domain.replace(/^\./, "");
24996
+ if (host === "")
24997
+ return false;
24998
+ const path = c.path === "" ? "/" : c.path;
24999
+ const line = [
25000
+ `${c.name}=${remove ? "" : c.value}`,
25001
+ `Path=${path}`,
25002
+ ...c.domain.startsWith(".") ? [`Domain=${host}`] : [],
25003
+ ...remove ? ["Expires=Thu, 01 Jan 1970 00:00:00 GMT"] : c.expires > 0 ? [`Expires=${new Date(c.expires * 1e3).toUTCString()}`] : [],
25004
+ ...c.httpOnly ? ["HttpOnly"] : [],
25005
+ ...c.secure ? ["Secure"] : [],
25006
+ `SameSite=${c.sameSite}`
25007
+ ].join("; ");
25008
+ const stored = await this.jar.setCookie(line, `${c.secure ? "https" : "http"}://${host}${path}`, { ignoreError: true }).catch(() => void 0);
25009
+ return stored !== void 0 && stored.TTL() > 0;
25010
+ }
25011
+ };
25012
+
23581
25013
  // packages/bench/dist/scrapeAtom.js
23582
25014
  var LadderScrapeAtom = class {
23583
25015
  runner;
@@ -23611,6 +25043,7 @@ init_egress();
23611
25043
  init_robotsLookup();
23612
25044
 
23613
25045
  // packages/bench/dist/sitemapSource.js
25046
+ init_dist();
23614
25047
  init_dist2();
23615
25048
  import { createHash as createHash12 } from "crypto";
23616
25049
  import { gunzipSync } from "zlib";
@@ -23628,6 +25061,7 @@ var HttpSitemapSource = class {
23628
25061
  route;
23629
25062
  robots;
23630
25063
  ownRobots;
25064
+ ignoreRobotsTxt;
23631
25065
  /** The Crawl-delay of each origin whose robots.txt a file of this source was judged by (the strictest one seen). */
23632
25066
  crawlDelays = /* @__PURE__ */ new Map();
23633
25067
  /** When this source's last request to each origin left (monotonic). */
@@ -23641,6 +25075,7 @@ var HttpSitemapSource = class {
23641
25075
  this.routes = new EgressRoutes(this.policy);
23642
25076
  this.route = new EgressRoute(this.policy, this.routes);
23643
25077
  this.ownRobots = options.robots === void 0;
25078
+ this.ignoreRobotsTxt = options.ignoreRobotsTxt === true;
23644
25079
  this.robots = options.robots ?? new RobotsOriginCache(this.policy, (url) => this.route.dispatcherFor(url));
23645
25080
  }
23646
25081
  async load(request7, context = {}) {
@@ -23752,7 +25187,7 @@ var HttpSitemapSource = class {
23752
25187
  this.crawlDelays.set(origin, Math.max(this.crawlDelays.get(origin) ?? 0, decision.crawlDelayMs));
23753
25188
  }
23754
25189
  record3.robots = decision.decision === "allowed" ? "allowed" : decision.decision === "no_robots" ? "no_robots" : "disallowed";
23755
- if (decision.decision === "disallowed") {
25190
+ if (decision.decision === "disallowed" && (!this.ignoreRobotsTxt || isOctocrawlRobotsGroup(decision.matchedUserAgentGroup))) {
23756
25191
  return { record: { ...record3, kind: "refused", error: decision.unreachable === void 0 ? null : `robots_unreachable_${decision.unreachable}` }, locs: null };
23757
25192
  }
23758
25193
  const response = await this.fetch(url, identity.headers, scope);
@@ -23907,6 +25342,95 @@ init_dist3();
23907
25342
  init_dist();
23908
25343
  init_dist2();
23909
25344
 
25345
+ // packages/api/dist/egressPool.js
25346
+ init_dist();
25347
+ import { connect as netConnect } from "net";
25348
+ import { connect as tlsConnect } from "tls";
25349
+ var EGRESS_COOLDOWN_MS = 10 * 6e4;
25350
+ var MAX_EGRESS_SWITCHES = 2;
25351
+ var EgressPool = class {
25352
+ now;
25353
+ egresses;
25354
+ cooldownUntil = /* @__PURE__ */ new Map();
25355
+ next = 0;
25356
+ constructor(servers, policy, now = Date.now) {
25357
+ this.now = now;
25358
+ if (servers.length === 0)
25359
+ throw new Error("an egress pool needs at least one proxy");
25360
+ this.egresses = servers.map((server) => ({ id: server.endpoint, policy: withPoolProxy(policy, server), server }));
25361
+ }
25362
+ get size() {
25363
+ return this.egresses.length;
25364
+ }
25365
+ /** The egress with this id, when it is in the pool. */
25366
+ byId(id) {
25367
+ return this.egresses.find((egress) => egress.id === id);
25368
+ }
25369
+ /** Whether this egress is cooling down after a failure. */
25370
+ cooling(id) {
25371
+ return (this.cooldownUntil.get(id) ?? 0) > this.now();
25372
+ }
25373
+ /**
25374
+ * The next egress in turn that is not cooling down and is not `except`; when every other one is
25375
+ * cooling, the one whose cooldown ends first, so a task always has an egress.
25376
+ */
25377
+ pick(except) {
25378
+ const candidates2 = this.egresses.filter((egress) => egress.id !== except);
25379
+ const pool = candidates2.length === 0 ? this.egresses : candidates2;
25380
+ for (let i = 0; i < this.egresses.length; i++) {
25381
+ const egress = this.egresses[(this.next + i) % this.egresses.length];
25382
+ if (!pool.includes(egress) || this.cooling(egress.id))
25383
+ continue;
25384
+ this.next = (this.next + i + 1) % this.egresses.length;
25385
+ return egress;
25386
+ }
25387
+ return [...pool].sort((a, b) => (this.cooldownUntil.get(a.id) ?? 0) - (this.cooldownUntil.get(b.id) ?? 0))[0];
25388
+ }
25389
+ /** The egress failed its probe: it cools down. */
25390
+ fail(id) {
25391
+ this.cooldownUntil.set(id, this.now() + EGRESS_COOLDOWN_MS);
25392
+ }
25393
+ };
25394
+ function egressInDoubt(outcome) {
25395
+ const results = [outcome.result, ...(outcome.audit?.summary.attempts ?? []).map((attempt) => attempt.result)];
25396
+ return outcome.result.status === "failed" && results.every((result) => (result.evidence.httpStatus ?? null) === null);
25397
+ }
25398
+ async function probeEgress(server, timeoutMs = 5e3) {
25399
+ const url = new URL(server.url);
25400
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
25401
+ return new Promise((resolve4) => {
25402
+ let settled2 = false;
25403
+ let socket;
25404
+ const done = (verdict) => {
25405
+ if (settled2)
25406
+ return;
25407
+ settled2 = true;
25408
+ clearTimeout(timer);
25409
+ socket?.destroy();
25410
+ resolve4(verdict);
25411
+ };
25412
+ const timer = setTimeout(() => done("unreachable"), timeoutMs);
25413
+ const auth = server.username === void 0 ? "" : `Proxy-Authorization: Basic ${Buffer.from(`${server.username}:${server.password ?? ""}`).toString("base64")}\r
25414
+ `;
25415
+ const onConnect = () => socket.write(`CONNECT octocrawl-egress-probe.invalid:443 HTTP/1.1\r
25416
+ Host: octocrawl-egress-probe.invalid:443\r
25417
+ ${auth}\r
25418
+ `);
25419
+ socket = url.protocol === "https:" ? tlsConnect({ host: url.hostname, port, servername: url.hostname }, onConnect) : netConnect({ host: url.hostname, port }, onConnect);
25420
+ let head = "";
25421
+ socket.on("data", (chunk) => {
25422
+ head += chunk.toString("latin1");
25423
+ const status = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(head);
25424
+ if (status !== null)
25425
+ done(status[1] === "407" ? "credentials_refused" : "works");
25426
+ else if (head.length > 4096)
25427
+ done("works");
25428
+ });
25429
+ socket.on("error", () => done("unreachable"));
25430
+ socket.on("close", () => done(head === "" ? "unreachable" : "works"));
25431
+ });
25432
+ }
25433
+
23910
25434
  // packages/api/dist/hints.js
23911
25435
  init_dist();
23912
25436
  var FAST_MODE_DECLINED_HINT = "the http lane asked for the browser lane; fastMode declined it; retry without fastMode";
@@ -23945,13 +25469,13 @@ function egressHint(result, host) {
23945
25469
  if (denied2 === void 0)
23946
25470
  return null;
23947
25471
  const why = denied2.detail?.error ?? denied2.detail?.reason;
23948
- return `the egress policy refused ${host}${typeof why === "string" ? ` (${why})` : ""} and nothing was fetched; W2L reaches public addresses, and a local server the addresses its policy allowlists`;
25472
+ return `the egress policy refused ${host}${typeof why === "string" ? ` (${why})` : ""} and nothing was fetched; Octocrawl reaches public addresses, and a local server the addresses its policy allowlists`;
23949
25473
  }
23950
25474
  function laneEscalatedHint(run, host) {
23951
25475
  const { result } = run;
23952
- if (result.status !== "success" && result.status !== "partial" || !BROWSER_LANES.has(result.lane) || !run.channelsTried.includes("http"))
25476
+ if (result.status !== "success" && result.status !== "partial" || !BROWSER_LANES.has(result.lane) || !run.channelsTried.some((channel) => HTTP_CHANNELS.has(channel)))
23953
25477
  return null;
23954
- const http = run.summary?.attempts.find((attempt) => attempt.channel === "http")?.result;
25478
+ const http = run.summary?.attempts.find((attempt) => HTTP_CHANNELS.has(attempt.channel))?.result;
23955
25479
  if (http === void 0)
23956
25480
  return null;
23957
25481
  const reason2 = http.status === "blocked" ? http.blockReason : http.status === "failed" && http.failureReason === "http_error" ? "http_error" : null;
@@ -23979,11 +25503,16 @@ function robotsHint(result, host) {
23979
25503
  return null;
23980
25504
  const detail = disallowed.detail ?? {};
23981
25505
  if (typeof detail.unreachable === "string") {
23982
- return `robots.txt of ${host} could not be read (${detail.unreachable}), which counts as a complete disallow; W2L asks for it again after five minutes, and a robotsOverride does not set that aside`;
25506
+ return `robots.txt of ${host} could not be read (${detail.unreachable}), which counts as a complete disallow; Octocrawl asks for it again after five minutes. ${ROBOTS_ROUTE}`;
23983
25507
  }
23984
25508
  const rules = (Array.isArray(detail.appliedRules) ? detail.appliedRules : []).filter((rule) => rule !== null && typeof rule === "object" && typeof rule.pattern === "string").filter((rule) => rule.allow === false).map((rule) => rule.pattern);
23985
- return `robots.txt of ${host} disallows this URL for W2L's identity (rule ${rules.length === 0 ? "unknown" : rules.join(", ")}); a robotsOverride with a recorded reason fetches it on the record`;
25509
+ const group = result.trace.find((event) => event.event === "robots_checked")?.detail?.matchedGroup;
25510
+ if (typeof group === "string" && isOctocrawlRobotsGroup(group)) {
25511
+ return `robots.txt of ${host} disallows this URL for Octocrawl by name (User-agent: ${group}; rule ${rules.length === 0 ? "unknown" : rules.join(", ")}): the site owner's opt-out, which a named URL and ignoreRobotsTxt do not set aside; only a robotsOverride with your recorded reason does, on a local server`;
25512
+ }
25513
+ return `robots.txt of ${host} disallows this URL for Octocrawl's identity (rule ${rules.length === 0 ? "unknown" : rules.join(", ")}). ${ROBOTS_ROUTE}`;
23986
25514
  }
25515
+ var ROBOTS_ROUTE = "A local Octocrawl server fetches a URL a scrape or batch names whatever robots.txt says, and a crawl or map started there with ignoreRobotsTxt fetches the links it disallows, each on the record; a hosted server obeys robots.txt for every URL";
23987
25516
  function agentHintsFor(req, run) {
23988
25517
  const { result } = run;
23989
25518
  const hints = [];
@@ -23998,16 +25527,16 @@ function agentHintsFor(req, run) {
23998
25527
  }
23999
25528
  if (result.status === "blocked" && result.blockReason === "login_wall") {
24000
25529
  const rejected = run.ladderTrace?.find((event) => event.event === "ladder_session_rejected");
24001
- hints.push(rejected === void 0 ? "the page asks for a login; W2L does not create accounts; use mode authed with your own session" : `${host} refused your saved login for ${String(rejected.detail?.domain ?? host)} (expired or signed out); sign in to it again in Chrome and run octocrawl login import ${String(rejected.detail?.domain ?? host)}`);
25530
+ hints.push(rejected === void 0 ? "the page asks for a login; Octocrawl does not create accounts; use mode authed with your own session" : `${host} refused your saved login for ${String(rejected.detail?.domain ?? host)} (expired or signed out); sign in to it again in Chrome and run octocrawl login import ${String(rejected.detail?.domain ?? host)}`);
24002
25531
  }
24003
25532
  if (result.status === "blocked" && result.blockReason !== null && GATES.has(result.blockReason)) {
24004
- hints.push(`${host} gates automated access on the lanes tried (${run.channelsTried.join(", ")}); W2L does not solve challenges or change its identity; a proxy or session you own is the supported route, or, on your own machine, getting through the check yourself in your own Chrome: handoff: true on a scrape (octocrawl scrape --handoff), or a batch handoff (octocrawl batch --handoff, POST /v1/batches/:id/handoff)`);
25533
+ hints.push(`${host} gates automated access on the lanes tried (${run.channelsTried.join(", ")}); Octocrawl does not solve challenges or change its identity unless this server's access grant names a provider that does (--access-grant, ADR 0005); a proxy or session you own is the supported route, or, on your own machine, getting through the check yourself in your own Chrome: handoff: true on a scrape (octocrawl scrape --handoff), or a batch handoff (octocrawl batch --handoff, POST /v1/batches/:id/handoff)`);
24005
25534
  }
24006
25535
  const escalated = laneEscalatedHint(run, host);
24007
25536
  if (escalated !== null)
24008
25537
  hints.push(escalated);
24009
25538
  if (result.status === "failed" && result.failureReason === "tls_error") {
24010
- hints.push(`the certificate of ${host} did not verify and W2L keeps verification on; a local server takes skipTlsVerification for one request, recorded in the trace and a tls_unverified warning, and a hosted server refuses it`);
25539
+ hints.push(`the certificate of ${host} did not verify and Octocrawl keeps verification on; a local server takes skipTlsVerification for one request, recorded in the trace and a tls_unverified warning, and a hosted server refuses it`);
24011
25540
  }
24012
25541
  if (result.status === "failed" && result.failureReason === "timeout") {
24013
25542
  hints.push(`no lane answered within the request's deadline; raise timeout (up to ${DEFAULT_SCRAPE_TIMEOUT_MS} ms)`);
@@ -24024,7 +25553,7 @@ function agentHintsFor(req, run) {
24024
25553
  hints.push(`the content was cut at character ${result.truncatedAt ?? "unknown"}; ask for rawHtml or a narrower includeTags`);
24025
25554
  }
24026
25555
  const fastModeDeclined = req.fastMode === true && result.lane === "http" && httpLaneAskedForBrowser(result);
24027
- const browserTried = run.channelsTried.some((channel) => channel !== "http");
25556
+ const browserTried = run.channelsTried.some((channel) => !HTTP_CHANNELS.has(channel));
24028
25557
  if (!fastModeDeclined && result.warnings?.some((warning) => warning.code === "client_rendered_suspected")) {
24029
25558
  hints.push(`the page fills its data with JavaScript; the browser lane ${browserTried ? "was tried" : "was not tried"}`);
24030
25559
  }
@@ -24037,9 +25566,9 @@ function agentHintsFor(req, run) {
24037
25566
  const shellHinted = result.warnings?.some((warning) => warning.code === "client_rendered_suspected" || warning.code === "low_content_yield") === true;
24038
25567
  if (result.status === "failed" && result.failureReason === "empty_unverified" && !shellHinted && !fastModeDeclined) {
24039
25568
  if (result.file?.kind === "pdf")
24040
- hints.push("the PDF has no text layer, and W2L runs no OCR");
25569
+ hints.push("the PDF has no text layer, and Octocrawl runs no OCR");
24041
25570
  else if (result.file === void 0)
24042
- hints.push("W2L found no main content on the page; onlyMainContent: false returns the whole page's Markdown as content, and includeTags names the elements to read instead");
25571
+ hints.push("Octocrawl found no main content on the page; onlyMainContent: false returns the whole page's Markdown as content, and includeTags names the elements to read instead");
24043
25572
  }
24044
25573
  if (result.status === "failed" && result.failureReason === "http_error") {
24045
25574
  const status = result.evidence.httpStatus;
@@ -24069,7 +25598,7 @@ init_dist3();
24069
25598
  init_dist2();
24070
25599
 
24071
25600
  // packages/api/dist/chromeLogin.js
24072
- import { readFile as readFile4 } from "fs/promises";
25601
+ import { readFile as readFile5 } from "fs/promises";
24073
25602
  import { homedir, userInfo } from "os";
24074
25603
  import { join as join9 } from "path";
24075
25604
  init_dist2();
@@ -24087,7 +25616,7 @@ function chromeUserDataDir(platform = process.platform, env = process.env, home
24087
25616
  async function chromeEndpoint(userDataDir) {
24088
25617
  let text2;
24089
25618
  try {
24090
- text2 = await readFile4(join9(userDataDir, "DevToolsActivePort"), "utf8");
25619
+ text2 = await readFile5(join9(userDataDir, "DevToolsActivePort"), "utf8");
24091
25620
  } catch {
24092
25621
  throw new ChromeLoginError(`Chrome is not accepting a connection (no DevToolsActivePort in ${userDataDir}): ${ENABLE_HINT}`);
24093
25622
  }
@@ -24398,7 +25927,7 @@ async function readPage(connection, browser, url, options) {
24398
25927
  const host = new URL(url).hostname;
24399
25928
  const started = Date.now();
24400
25929
  let sawGate = null;
24401
- const gone = (error) => error instanceof ChromeLoginError && !/navigated or closed/i.test(error.message) && /Session with given id not found|No session with given id|No target with given id|closed the connection|Target closed|target not found|did not answer Target\.getTargetInfo/i.test(error.message) ? new HandoffNotThrough(`the tab for ${url} was closed, or Chrome quit, before W2L read it`, sawGate) : null;
25930
+ const gone = (error) => error instanceof ChromeLoginError && !/navigated or closed/i.test(error.message) && /Session with given id not found|No session with given id|No target with given id|closed the connection|Target closed|target not found|did not answer Target\.getTargetInfo/i.test(error.message) ? new HandoffNotThrough(`the tab for ${url} was closed, or Chrome quit, before Octocrawl read it`, sawGate) : null;
24402
25931
  const ended = (error) => gone(error) ?? (error instanceof ChromeLoginError ? new HandoffNotThrough(`${url} was not read: ${error.message}`, sawGate) : error);
24403
25932
  let targetId;
24404
25933
  try {
@@ -24454,7 +25983,7 @@ async function readPage(connection, browser, url, options) {
24454
25983
  while (Date.now() - started < waitMs) {
24455
25984
  await new Promise((resolve4) => setTimeout(resolve4, pollMs));
24456
25985
  if (options.signal?.aborted === true)
24457
- throw new HandoffNotThrough(`the handoff of ${url} was cancelled before W2L read it`, sawGate);
25986
+ throw new HandoffNotThrough(`the handoff of ${url} was cancelled before Octocrawl read it`, sawGate);
24458
25987
  if (navigation !== null && gone(navigation) !== null)
24459
25988
  throw gone(navigation);
24460
25989
  let state;
@@ -24542,7 +26071,7 @@ async function readPage(connection, browser, url, options) {
24542
26071
  browser
24543
26072
  };
24544
26073
  }
24545
- const where = last === null ? "it never loaded" : !sameSite(last.state.href, host) ? `it was on ${safeHost2(last.state.href)}, not ${host}` : stillGated(last, options) !== null ? `it still showed a check (${stillGated(last, options).reason}: ${stillGated(last, options).signals.join(", ")})` : clear >= CLEAR_READS && heard.act === null ? "the page showed no check, and you did not click on it to have it read (W2L reads a page in your Chrome only once you act in its tab; a site you are signed into is read with your login through octocrawl login import and mode authed)" : "it was not yet the page: still loading, at a sign-in step, or not answering 2xx";
26074
+ const where = last === null ? "it never loaded" : !sameSite(last.state.href, host) ? `it was on ${safeHost2(last.state.href)}, not ${host}` : stillGated(last, options) !== null ? `it still showed a check (${stillGated(last, options).reason}: ${stillGated(last, options).signals.join(", ")})` : clear >= CLEAR_READS && heard.act === null ? "the page showed no check, and you did not click on it to have it read (Octocrawl reads a page in your Chrome only once you act in its tab; a site you are signed into is read with your login through octocrawl login import and mode authed)" : "it was not yet the page: still loading, at a sign-in step, or not answering 2xx";
24546
26075
  throw new HandoffNotThrough(`${url} was not through within ${Math.round(waitMs / 1e3)} s: ${where}`, sawGate);
24547
26076
  } catch (error) {
24548
26077
  throw ended(error);
@@ -25673,7 +27202,7 @@ async function extractStructured(result, format, execution = {}, modelConfig = s
25673
27202
  return { status: "incomplete", data, schemaSha256, evidence, issues, modelUsage: null };
25674
27203
  }
25675
27204
  if (modelConfig === null) {
25676
- issues.push({ code: "model_unavailable", message: "model fallback requested but W2L extraction model is not configured" }, ...missingIssues);
27205
+ issues.push({ code: "model_unavailable", message: "model fallback requested but Octocrawl extraction model is not configured" }, ...missingIssues);
25677
27206
  return { status: "incomplete", data, schemaSha256, evidence, issues, modelUsage: null };
25678
27207
  }
25679
27208
  const errors = deterministicValid ? [] : validate.errors ?? [];
@@ -25943,6 +27472,9 @@ function compactScrapeResponse(next, req, formats = requestedFormats(req, next),
25943
27472
  var LoginsUnavailableError = class extends Error {
25944
27473
  name = "LoginsUnavailableError";
25945
27474
  };
27475
+ var SessionsUnavailableError = class extends Error {
27476
+ name = "SessionsUnavailableError";
27477
+ };
25946
27478
  var HandoffUnavailableError = class extends Error {
25947
27479
  name = "HandoffUnavailableError";
25948
27480
  };
@@ -25960,7 +27492,45 @@ var NO_SITEMAP = {
25960
27492
  close: async () => {
25961
27493
  }
25962
27494
  };
27495
+ var EGRESS_FILE = "egress.json";
27496
+ var COOKIE_SESSION_NAME = /^cookie-session(?:\.[0-9a-f]+)?\.json(?:\..+\.tmp)?$/;
27497
+ function cookieSessionFileFor(taskDir, route) {
27498
+ return join10(taskDir, `cookie-session.${createHash13("sha256").update(route).digest("hex").slice(0, 16)}.json`);
27499
+ }
27500
+ function removeCookieSessions(taskDir, keep) {
27501
+ let names;
27502
+ try {
27503
+ names = readdirSync(taskDir);
27504
+ } catch {
27505
+ return;
27506
+ }
27507
+ for (const name of names)
27508
+ if (COOKIE_SESSION_NAME.test(name) && join10(taskDir, name) !== keep)
27509
+ rmSync(join10(taskDir, name), { force: true });
27510
+ }
27511
+ function bindTaskEgress(pool, file) {
27512
+ let saved;
27513
+ try {
27514
+ const parsed = JSON.parse(readFileSync3(file, "utf8")).egress;
27515
+ if (typeof parsed === "string")
27516
+ saved = parsed;
27517
+ } catch {
27518
+ }
27519
+ const kept = saved === void 0 ? void 0 : pool.byId(saved);
27520
+ if (kept !== void 0 && !pool.cooling(kept.id))
27521
+ return kept;
27522
+ const egress = pool.pick(saved);
27523
+ writeTaskEgress(file, egress.id);
27524
+ return egress;
27525
+ }
27526
+ function writeTaskEgress(file, id) {
27527
+ writeFileSync(file, `${JSON.stringify({ egress: id })}
27528
+ `, { mode: 384 });
27529
+ }
25963
27530
  var DEFAULT_WORKER_COUNT = 4;
27531
+ function fetchedPastRobots(result) {
27532
+ return result.compliance?.robots.override !== void 0 || result.trace.some((event) => event.event === "robots_overridden");
27533
+ }
25964
27534
  function createApiEngine(options = {}) {
25965
27535
  const taskRoot = options.taskRoot ?? ".w2l/api";
25966
27536
  prepareTaskRoot(taskRoot);
@@ -25969,6 +27539,10 @@ function createApiEngine(options = {}) {
25969
27539
  const shutdownController = new AbortController();
25970
27540
  const monitorControllers = /* @__PURE__ */ new Map();
25971
27541
  const sessionBroker = new SessionBroker(new FileSessionBrokerStore(join10(taskRoot, "b3-sessions.json")));
27542
+ const managedSessionsServed = () => {
27543
+ if (options.hosted === true)
27544
+ throw new SessionsUnavailableError("this server keeps no managed sessions: run Octocrawl on your own machine (octocrawl serve) for them");
27545
+ };
25972
27546
  const userChrome = options.hosted === true ? null : options.userChrome ?? null;
25973
27547
  const handoffs = /* @__PURE__ */ new Set();
25974
27548
  const handoffClosing = new AbortController();
@@ -25976,6 +27550,29 @@ function createApiEngine(options = {}) {
25976
27550
  const savedLogins = options.hosted === true || (options.sessionsFile ?? null) === null ? null : readOnlySessions(new FileSessionStore(options.sessionsFile));
25977
27551
  const sessionsFor = (mode) => mode === "authed" ? savedLogins : null;
25978
27552
  const headed = options.headed === true;
27553
+ const accessGrant = options.accessGrant ?? null;
27554
+ const compatHosts = options.compatHosts ?? [];
27555
+ const egressSessions = (accessGrant?.capabilities ?? []).includes("egress_sessions");
27556
+ const egressProxyList = options.egressProxies ?? [];
27557
+ if (egressProxyList.length > 0 && options.hosted === true)
27558
+ throw new Error("egress proxies are refused on a hosted engine (ADR 0005)");
27559
+ if (egressProxyList.length > 0 && !egressSessions)
27560
+ throw new Error("egress proxies need an access grant that names egress_sessions (ADR 0005)");
27561
+ if (compatHosts.length > 0 && options.hosted === true)
27562
+ throw new Error("the compatible transport is refused on a hosted engine (ADR 0005)");
27563
+ if (compatHosts.length > 0 && !(accessGrant?.capabilities ?? []).includes("compatible_transport"))
27564
+ throw new Error("the compatible transport needs an access grant that names compatible_transport (ADR 0005)");
27565
+ const compatOptionsRefusal = (page) => {
27566
+ const wire = [...page.headers !== void 0 && Object.keys(page.headers).length > 0 ? ["headers"] : [], ...page.mobile === true ? ["mobile"] : []];
27567
+ return wire.length === 0 ? null : wire.join(", ");
27568
+ };
27569
+ const compatTakes = (url, page) => compatHostListed(compatHosts, url) && compatOptionsRefusal(page) === null;
27570
+ const grantedBudget = (budget2) => {
27571
+ const cap = accessGrant?.budget.perRunUsd ?? null;
27572
+ if (cap === null)
27573
+ return budget2;
27574
+ return { ...budget2, maxCostUsd: budget2.maxCostUsd === null ? cap : Math.min(budget2.maxCostUsd, cap) };
27575
+ };
25979
27576
  const basePolicy = options.networkPolicy ?? localNetworkPolicy();
25980
27577
  const networkPolicy = {
25981
27578
  ...basePolicy,
@@ -25984,9 +27581,10 @@ function createApiEngine(options = {}) {
25984
27581
  // The operator's file cap: the policy's own, else W2L_MAX_FILE_BYTES, else the default.
25985
27582
  maxFileBytes: basePolicy.maxFileBytes ?? maxFileBytesFromEnv(process.env)
25986
27583
  };
27584
+ const egressPool = egressProxyList.length === 0 ? null : new EgressPool(egressProxyList, networkPolicy);
25987
27585
  const fileStore = new FileStore(join10(taskRoot, "files"));
25988
27586
  const pageCache = PageCache.open(taskRoot);
25989
- const cachePlanFor = (url, mode, page, formats, channels, robotsOverride) => {
27587
+ const cachePlanFor = (url, mode, page, formats, channels, robotsOverride, robotsSetAside2) => {
25990
27588
  if (mode === "authed")
25991
27589
  return null;
25992
27590
  if (page.actions !== void 0)
@@ -25999,14 +27597,15 @@ function createApiEngine(options = {}) {
25999
27597
  const { timeout: _timeout, ...shape } = fetchOptions(page, formats);
26000
27598
  const rungs = channels.map((channel) => channel.vendorId === void 0 ? channel.id : `${channel.id}(${channel.vendorId})`);
26001
27599
  const build = { extractor: EXTRACTOR_VERSION, pdf: PDF_TEXT_VERSION, file: FILE_TEXT_VERSION, commit: sourceCommitFromEnv() };
26002
- const key = pageCacheKey(url, { mode, fetch: shape, rungs, fastMode: page.fastMode === true, robotsOverride: robotsOverride ?? null, build });
26003
- return { key, bounds: lookup3 ? { minAgeMs: page.minAge ?? 0, maxAgeMs: page.maxAge ?? null } : null, lockdown: page.lockdown === true, store };
27600
+ const access = accessGrant === null ? void 0 : { tier: accessGrant.tier, capabilities: [...accessGrant.capabilities].sort() };
27601
+ const key = pageCacheKey(url, { mode, fetch: shape, rungs, fastMode: page.fastMode === true, robotsOverride: robotsOverride ?? null, build, ...access === void 0 ? {} : { access } });
27602
+ return { key, bounds: lookup3 ? { minAgeMs: page.minAge ?? 0, maxAgeMs: page.maxAge ?? null } : null, lockdown: page.lockdown === true, store, robotsSetAside: robotsSetAside2 };
26004
27603
  };
26005
27604
  const consultCache = (plan, url, policy) => {
26006
27605
  if (plan === null || plan.bounds === null || !evaluateGovernance(url, policy).allowed)
26007
27606
  return { kind: "fetch", missed: false };
26008
27607
  const hit = pageCache.lookup(plan.key, plan.bounds);
26009
- if (hit !== null)
27608
+ if (hit !== null && (plan.robotsSetAside || !fetchedPastRobots(hit.result)))
26010
27609
  return { kind: "hit", result: cacheHitResult(hit) };
26011
27610
  if (plan.lockdown)
26012
27611
  return { kind: "lockdown_miss", result: cacheMissResult(url, plan.bounds) };
@@ -26040,7 +27639,7 @@ function createApiEngine(options = {}) {
26040
27639
  }
26041
27640
  if (refusal.kind === "syntax")
26042
27641
  throw new RequestError(`${name} entry is not a valid CSS selector: ${selector}`);
26043
- throw new RequestError(`${name} entry uses ${refusal.reason}, which W2L does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`${name}[${index}]`] });
27642
+ throw new RequestError(`${name} entry uses ${refusal.reason}, which Octocrawl does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`${name}[${index}]`] });
26044
27643
  }
26045
27644
  if (parts > MAX_SELECTOR_PARTS) {
26046
27645
  throw new RequestError(`${name} must hold at most ${MAX_SELECTOR_PARTS} selector parts in all, and holds ${parts} (a tag name, *, a class, an id, an attribute test and a pseudo-class each count as one)`);
@@ -26061,7 +27660,7 @@ function createApiEngine(options = {}) {
26061
27660
  }
26062
27661
  if (refusal.kind === "syntax")
26063
27662
  throw new RequestError(`attributes selectors[${index}].selector is not a valid CSS selector: ${selector}`);
26064
- throw new RequestError(`attributes selectors[${index}].selector uses ${refusal.reason}, which W2L does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`formats[${at}].selectors[${index}]`] });
27663
+ throw new RequestError(`attributes selectors[${index}].selector uses ${refusal.reason}, which Octocrawl does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`formats[${at}].selectors[${index}]`] });
26065
27664
  }
26066
27665
  if (parts > MAX_SELECTOR_PARTS) {
26067
27666
  throw new RequestError(`attributes selectors must hold at most ${MAX_SELECTOR_PARTS} selector parts in all, and hold ${parts} (a tag name, *, a class, an id, an attribute test and a pseudo-class each count as one)`);
@@ -26082,22 +27681,25 @@ function createApiEngine(options = {}) {
26082
27681
  }
26083
27682
  if (refusal.kind === "syntax")
26084
27683
  throw new RequestError(`list ${path} is not a valid CSS selector: ${selector}`);
26085
- throw new RequestError(`list ${path} uses ${refusal.reason}, which W2L does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`formats[${at}].${path}`] });
27684
+ throw new RequestError(`list ${path} uses ${refusal.reason}, which Octocrawl does not match: ${selector} (supported: ${SUPPORTED_SELECTORS})`, "unsupported_parameter", { parameters: [`formats[${at}].${path}`] });
26086
27685
  }
26087
27686
  if (parts > MAX_SELECTOR_PARTS)
26088
27687
  throw new RequestError(`list selectors must hold at most ${MAX_SELECTOR_PARTS} selector parts in all, and hold ${parts}`);
26089
27688
  };
27689
+ const hosted = options.hosted === true;
27690
+ const robotsSetAside = !hosted && options.allowRobotsOverride !== false;
26090
27691
  const checkRobotsOverride = (parameter, value) => {
26091
- if (options.allowRobotsOverride === false && value !== void 0) {
26092
- throw new RequestError(`unsupported parameter: ${parameter} (this server takes no robots override; a recorded override is for a local W2L server)`, "unsupported_parameter", { parameters: [parameter] });
27692
+ if (!robotsSetAside && value !== void 0 && value !== false) {
27693
+ throw new RequestError(`unsupported parameter: ${parameter} (this server obeys robots.txt for every URL; setting it aside is for a local Octocrawl server)`, "unsupported_parameter", { parameters: [parameter] });
26093
27694
  }
26094
27695
  };
26095
- const hosted = options.hosted === true;
27696
+ const namedUrlOverride = robotsSetAside ? { reason: "the request named this URL", basis: "user_named_url" } : void 0;
27697
+ const ignoreRobotsOverride = (ignore) => ignore === true && robotsSetAside ? { reason: "the crawl or map was started with ignoreRobotsTxt", basis: "ignore_robots_txt" } : void 0;
26096
27698
  const checkHostedOptions = (req) => {
26097
27699
  if (hosted && req.skipTlsVerification === true)
26098
27700
  throw new RequestError("skipTlsVerification is not available in hosted mode", "invalid_request", void 0, [REFUSAL_HINTS.hostedSkipTlsVerification]);
26099
27701
  if (hosted && req.actions !== void 0)
26100
- throw new RequestError("actions are not available in hosted mode: run W2L locally to use them");
27702
+ throw new RequestError("actions are not available in hosted mode: run Octocrawl locally to use them");
26101
27703
  };
26102
27704
  const jobEvents = new JobEventHub();
26103
27705
  const jobWebhooks = new JobWebhooks(deliveryStore, { hosted, allowHttpLoopback: options.webhookPolicy?.allowHttpLoopback ?? !hosted });
@@ -26112,7 +27714,7 @@ function createApiEngine(options = {}) {
26112
27714
  throw error;
26113
27715
  });
26114
27716
  await scrapesDirReady;
26115
- await writeFile7(join10(scrapesDir, `${record3.scrapeId}.json`), JSON.stringify(record3, null, 2));
27717
+ await writeFile8(join10(scrapesDir, `${record3.scrapeId}.json`), JSON.stringify(record3, null, 2));
26116
27718
  };
26117
27719
  const mapsDir = join10(taskRoot, "maps");
26118
27720
  let mapsDirReady = null;
@@ -26122,7 +27724,7 @@ function createApiEngine(options = {}) {
26122
27724
  throw error;
26123
27725
  });
26124
27726
  await mapsDirReady;
26125
- await writeFile7(join10(mapsDir, `${record3.response.id}.json`), JSON.stringify(record3));
27727
+ await writeFile8(join10(mapsDir, `${record3.response.id}.json`), JSON.stringify(record3));
26126
27728
  };
26127
27729
  const mapMaxLimit = options.mapMaxLimit ?? (hosted ? HOSTED_MAP_MAX_LIMIT : MAX_MAP_LIMIT);
26128
27730
  const mapMaxTimeoutMs = options.mapMaxTimeoutMs ?? (hosted ? HOSTED_MAP_MAX_TIMEOUT_MS : MAX_MAP_TIMEOUT_MS);
@@ -26131,12 +27733,13 @@ function createApiEngine(options = {}) {
26131
27733
  const defaultMaxPages = options.defaultMaxPages ?? null;
26132
27734
  const workerCount = Math.max(1, options.workerCount ?? DEFAULT_WORKER_COUNT);
26133
27735
  const robotsCaches = /* @__PURE__ */ new Map();
26134
- const robotsCacheFor = (mode) => {
26135
- const existing = robotsCaches.get(mode);
27736
+ const robotsCacheFor = (mode, egress) => {
27737
+ const key = egress === void 0 ? mode : `${mode}|${egress.id}`;
27738
+ const existing = robotsCaches.get(key);
26136
27739
  if (existing !== void 0)
26137
27740
  return existing;
26138
- const cache = new RobotsOriginCache(networkPolicy);
26139
- robotsCaches.set(mode, cache);
27741
+ const cache = new RobotsOriginCache(egress?.policy ?? networkPolicy);
27742
+ robotsCaches.set(key, cache);
26140
27743
  return cache;
26141
27744
  };
26142
27745
  const inflight = /* @__PURE__ */ new Map();
@@ -26161,24 +27764,27 @@ function createApiEngine(options = {}) {
26161
27764
  const activeScrapes = /* @__PURE__ */ new Set();
26162
27765
  const crawlControllers = /* @__PURE__ */ new Map();
26163
27766
  const runningCrawls = /* @__PURE__ */ new Map();
26164
- const createChannels = options.channelsFor ?? ((mode) => {
26165
- const channels = buildChannels(mode, { headed, networkPolicy, originScheduler, publicPreferenceState: options.publicPreferenceState, browserAllowedHosts: options.browserAllowedHosts, fileStore, robotsCache: robotsCacheFor(mode) });
26166
- return options.httpOnly ? channels.filter((channel) => channel.id === "http") : channels;
27767
+ const createChannels = options.channelsFor ?? ((mode, egress) => {
27768
+ const channels = buildChannels(mode, { headed, networkPolicy: egress?.policy ?? networkPolicy, originScheduler, publicPreferenceState: options.publicPreferenceState, browserAllowedHosts: options.browserAllowedHosts, fileStore, robotsCache: robotsCacheFor(mode, egress), vendorPolicy: { authorized: accessGrant?.capabilities ?? [] }, browserEngine: options.browserEngine ?? "playwright", compatTransport: compatHosts.length > 0 });
27769
+ return options.httpOnly ? channels.filter((channel) => HTTP_CHANNELS.has(channel.id)) : channels;
26167
27770
  });
26168
27771
  const channelsByMode = /* @__PURE__ */ new Map();
26169
27772
  const historiesByMode = /* @__PURE__ */ new Map();
26170
- const channelsFor = (mode) => {
26171
- const existing = channelsByMode.get(mode);
27773
+ const channelsFor = (mode, egress) => {
27774
+ const key = egress === void 0 ? mode : `${mode}|${egress.id}`;
27775
+ const existing = channelsByMode.get(key);
26172
27776
  if (existing !== void 0)
26173
27777
  return existing;
26174
- const channels = createChannels(mode);
26175
- channelsByMode.set(mode, channels);
27778
+ const channels = createChannels(mode, egress);
27779
+ channelsByMode.set(key, channels);
26176
27780
  return channels;
26177
27781
  };
26178
- const channelsForUrl = (mode, url, page = {}, formats = []) => {
26179
- const channels = channelsFor(mode);
27782
+ const channelsForUrl = (mode, url, page = {}, formats = [], oneUrl = true, compat = true, egress) => {
27783
+ const channels = channelsFor(mode, egress);
26180
27784
  const policy = options.channelPolicy?.(url) ?? "ladder";
26181
- let selected = policy === "ladder" ? channels : channels.filter((channel) => channel.id === (policy === "http_only" ? "http" : "browser_local"));
27785
+ let selected = policy === "ladder" ? channels : channels.filter((channel) => policy === "http_only" ? HTTP_CHANNELS.has(channel.id) : channel.id === "browser_local");
27786
+ if (!compat || oneUrl && !compatHostListed(compatHosts, url))
27787
+ selected = selected.filter((channel) => channel.id !== "http_compat");
26182
27788
  if (selected.length === 0)
26183
27789
  throw new RequestError(`capture channel unavailable for ${policy}`);
26184
27790
  const filtered = [];
@@ -26208,10 +27814,10 @@ function createApiEngine(options = {}) {
26208
27814
  if (page.fastMode === true) {
26209
27815
  if (policy === "browser_only")
26210
27816
  throw new RequestError("fastMode is not available for this URL: it is served by the browser lane only");
26211
- const kept = selected.filter((channel) => channel.id === "http");
27817
+ const kept = selected.filter((channel) => HTTP_CHANNELS.has(channel.id));
26212
27818
  if (kept.length === 0)
26213
27819
  throw new RequestError("fastMode is not available for this URL: no http rung is configured for it");
26214
- const dropped = selected.filter((channel) => channel.id !== "http").map(name);
27820
+ const dropped = selected.filter((channel) => !HTTP_CHANNELS.has(channel.id)).map(name);
26215
27821
  if (dropped.length > 0)
26216
27822
  filtered.push({ reason: "fastMode", dropped });
26217
27823
  selected = kept;
@@ -26223,6 +27829,19 @@ function createApiEngine(options = {}) {
26223
27829
  filtered.push({ reason: wire.join(", "), dropped });
26224
27830
  selected = selected.filter((channel) => channel.vendorId === void 0);
26225
27831
  }
27832
+ if (selected.some((channel) => channel.id === "http_compat")) {
27833
+ const refusal = compatOptionsRefusal(page);
27834
+ if (refusal !== null) {
27835
+ filtered.push({ reason: refusal, dropped: ["http_compat"] });
27836
+ selected = selected.filter((channel) => channel.id !== "http_compat");
27837
+ } else if (oneUrl && selected.some((channel) => channel.id === "http")) {
27838
+ filtered.push({ reason: "compatible_transport", dropped: ["http"] });
27839
+ selected = selected.filter((channel) => channel.id !== "http");
27840
+ }
27841
+ }
27842
+ if (selected.some((channel) => channel.id === "http_compat") && selected.some((channel) => channel.id === "http")) {
27843
+ selected = selected.map((channel) => channel.id === "http" ? { ...channel, serves: (target, fetch2) => !compatTakes(target, fetch2) } : channel.id === "http_compat" ? { ...channel, serves: (target, fetch2) => compatTakes(target, fetch2) } : channel);
27844
+ }
26226
27845
  return { channels: selected, filtered };
26227
27846
  };
26228
27847
  const historyFor = (mode) => {
@@ -26369,7 +27988,7 @@ function createApiEngine(options = {}) {
26369
27988
  const read2 = await chrome.read(url, { ...waitMs === void 0 ? {} : { waitMs }, ...hooks.onWaiting === void 0 ? {} : { onWaiting: hooks.onWaiting }, ...hooks.onConfirm === void 0 ? {} : { onConfirm: hooks.onConfirm }, ...hooks.onHidden === void 0 ? {} : { onHidden: hooks.onHidden }, signal, ...judged });
26370
27989
  const result = pageFromUserBrowser(read2, prior, fetchOpts);
26371
27990
  if (!CONTENTFUL_STATUS.has(result.status))
26372
- return { reason: `the page W2L read in Chrome was ${result.status} (${result.blockReason ?? result.failureReason ?? "no reason"}), not the page` };
27991
+ return { reason: `the page Octocrawl read in Chrome was ${result.status} (${result.blockReason ?? result.failureReason ?? "no reason"}), not the page` };
26373
27992
  return { result };
26374
27993
  } catch (error) {
26375
27994
  if (!(error instanceof HandoffNotThrough))
@@ -26381,14 +28000,14 @@ function createApiEngine(options = {}) {
26381
28000
  if (req.handoff === void 0)
26382
28001
  return;
26383
28002
  if (userChrome === null)
26384
- throw new RequestError("handoff: this server does not hand pages to a person; run W2L on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI)", "unsupported_parameter", { parameters: ["handoff"] });
28003
+ throw new RequestError("handoff: this server does not hand pages to a person; run Octocrawl on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI)", "unsupported_parameter", { parameters: ["handoff"] });
26385
28004
  const unread = unreadByPerson(fetchOptions(req, req.formats));
26386
28005
  if (unread !== null)
26387
28006
  throw new RequestError(`handoff: the request asks for ${unread}, which a page read in your own Chrome cannot give`, "unsupported_parameter", { parameters: ["handoff"] });
26388
28007
  }
26389
28008
  async function handOffScrape(req, prior, context, hooks) {
26390
28009
  if (handoffClosing.signal.aborted)
26391
- return { reason: "W2L is shutting down" };
28010
+ return { reason: "Octocrawl is shutting down" };
26392
28011
  const signal = AbortSignal.any([...context.signal === void 0 ? [] : [context.signal], ...hooks.signal === void 0 ? [] : [hooks.signal], shutdownController.signal, handoffClosing.signal]);
26393
28012
  let chrome;
26394
28013
  try {
@@ -26406,17 +28025,17 @@ function createApiEngine(options = {}) {
26406
28025
  if (userChrome === null || !handoffResult(result) || unreadByPerson(fetchOptions(req, req.formats)) !== null)
26407
28026
  return result;
26408
28027
  const blockReason = result.blockReason;
26409
- const stopped = `${result.requestedUrl} stopped at a ${blockReason.replace(/_/g, " ")} W2L does not pass`;
26410
- const rationale = tried ? `${stopped}, and handed to you in your own Chrome it was not read there (the handoff_not_through warning says why): send the request again with handoff to try once more, with a longer handoff.waitMs if you needed more time` : `${stopped}: send the request again with handoff: true (octocrawl scrape --handoff) to get through it yourself in your own Chrome, and W2L reads the page there`;
28028
+ const stopped = `${result.requestedUrl} stopped at a ${blockReason.replace(/_/g, " ")} Octocrawl does not pass`;
28029
+ const rationale = tried ? `${stopped}, and handed to you in your own Chrome it was not read there (the handoff_not_through warning says why): send the request again with handoff to try once more, with a longer handoff.waitMs if you needed more time` : `${stopped}: send the request again with handoff: true (octocrawl scrape --handoff) to get through it yourself in your own Chrome, and Octocrawl reads the page there`;
26411
28030
  return { ...result, handoff: { reason: HANDOFF_REASONS[blockReason], liveViewUrl: null, rationale } };
26412
28031
  }
26413
28032
  async function handOff2(taskId, req, hooks) {
26414
28033
  if (!UUID.test(taskId))
26415
28034
  return null;
26416
28035
  if (userChrome === null)
26417
- throw new HandoffUnavailableError("this server does not hand pages to a person: run W2L on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI) to open them in your Chrome");
28036
+ throw new HandoffUnavailableError("this server does not hand pages to a person: run Octocrawl on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI) to open them in your Chrome");
26418
28037
  if (handoffClosing.signal.aborted)
26419
- throw new HandoffUnavailableError("W2L is shutting down");
28038
+ throw new HandoffUnavailableError("Octocrawl is shutting down");
26420
28039
  if (!existsSync3(join10(taskRoot, taskId, "checkpoint.sqlite")))
26421
28040
  return null;
26422
28041
  const store = SqliteTaskStore.open(join10(taskRoot, taskId));
@@ -26469,7 +28088,7 @@ function createApiEngine(options = {}) {
26469
28088
  ...json2 === void 0 ? {} : { json: json2 }
26470
28089
  };
26471
28090
  if (shutdownController.signal.aborted || handoffClosing.signal.aborted) {
26472
- items.push({ id: step.id, url: step.url, through: false, status: step.status, reason: "W2L shut down before the page was stored" });
28091
+ items.push({ id: step.id, url: step.url, through: false, status: step.status, reason: "Octocrawl shut down before the page was stored" });
26473
28092
  continue;
26474
28093
  }
26475
28094
  const { audit: _stoppedAudit, ...kept } = step;
@@ -26504,23 +28123,55 @@ function createApiEngine(options = {}) {
26504
28123
  const mode = defaultApiMode(task.mode);
26505
28124
  const stored = task.batch ?? task.crawl;
26506
28125
  const selection = stored === void 0 || !hosted ? stored : { ...stored, skipTlsVerification: false };
26507
- const rungs = channelsForUrl(mode, task.seedUrl, selection ?? {}, selection?.formats ?? []);
26508
- const runner = new LadderRunner(rungs.channels, { mode, ...req.policyAllowlist.length ? { allowlistedDomains: req.policyAllowlist } : {} }, historyFor(mode), null, task.batch === void 0 ? null : sessionsFor(mode), { channelsFiltered: rungs.filtered });
26509
- const robotsOverrideFor = options.allowRobotsOverride === false || task.batch?.robotsOverrides === void 0 ? null : robotsOverrideLookup(task.batch.robotsOverrides);
26510
- const ladder = new LadderScrapeAtom(runner, robotsOverrideFor === null ? fetchOptions(selection, selection?.formats) : (url) => {
28126
+ const egressFile = join10(task.taskDir, EGRESS_FILE);
28127
+ const firstEgress = egressPool === null || mode === "authed" ? void 0 : bindTaskEgress(egressPool, egressFile);
28128
+ let switches = 0;
28129
+ const lanes = [];
28130
+ const routeOf = (egress) => {
28131
+ const env = networkPolicy.egressProxy ?? null;
28132
+ return egress !== void 0 ? `pool:${egress.id}` : env === null ? "direct" : `env:${(env.https ?? env.http)?.endpoint ?? ""}`;
28133
+ };
28134
+ const endCookieSession = async (ended) => {
28135
+ if (!ended)
28136
+ return;
28137
+ await Promise.all(lanes.map((lane) => lane.cookieSession?.close()));
28138
+ removeCookieSessions(task.taskDir);
28139
+ rmSync(egressFile, { force: true });
28140
+ };
28141
+ const runnerFor = (lane) => new LadderRunner(lane.channels, { mode, ...req.policyAllowlist.length ? { allowlistedDomains: req.policyAllowlist } : {} }, historyFor(mode), null, task.batch === void 0 ? null : sessionsFor(mode), { channelsFiltered: lane.filtered });
28142
+ const recordedOverrideFor = !robotsSetAside || task.batch?.robotsOverrides === void 0 ? null : robotsOverrideLookup(task.batch.robotsOverrides);
28143
+ const namedOverride = task.batch === void 0 ? ignoreRobotsOverride(task.crawl?.ignoreRobotsTxt) : namedUrlOverride;
28144
+ const robotsOverrideFor = recordedOverrideFor === null && namedOverride === void 0 ? null : (url) => recordedOverrideFor?.(url) ?? namedOverride;
28145
+ const ladderFor = (lane) => new LadderScrapeAtom(runnerFor(lane), robotsOverrideFor === null ? fetchOptions(selection, selection?.formats) : (url) => {
26511
28146
  const robotsOverride = robotsOverrideFor(url);
26512
28147
  return { ...fetchOptions(selection, selection?.formats), ...robotsOverride === void 0 ? {} : { robotsOverride } };
26513
28148
  });
26514
- const atom = selection === void 0 ? ladder : {
28149
+ const laneFor = (egress) => {
28150
+ const rungs = channelsForUrl(mode, task.seedUrl, selection ?? {}, selection?.formats ?? [], false, true, egress);
28151
+ const sessionFile = cookieSessionFileFor(task.taskDir, routeOf(egress));
28152
+ return { egress, rungs, ladder: ladderFor(rungs), cookieSession: egressSessions && mode !== "authed" ? new TaskCookieSession(sessionFile) : void 0 };
28153
+ };
28154
+ removeCookieSessions(task.taskDir, cookieSessionFileFor(task.taskDir, routeOf(firstEgress)));
28155
+ let current = laneFor(firstEgress);
28156
+ lanes.push(current);
28157
+ const closeLadders = async () => {
28158
+ await Promise.all(lanes.map((lane) => lane.ladder.close()));
28159
+ };
28160
+ const pageAtom = selection === void 0 ? { scrape: (url, context) => {
28161
+ const lane = current;
28162
+ return lane.ladder.scrape(url, lane.cookieSession === void 0 ? context : { ...context, cookieSession: lane.cookieSession });
28163
+ }, close: closeLadders } : {
26515
28164
  async scrape(url, context) {
26516
- const page = createExecutionScope({ ...context, deadlineAt: Math.min(context?.deadlineAt ?? Infinity, Date.now() + (selection.timeout ?? DEFAULT_SCRAPE_TIMEOUT_MS)) });
28165
+ const lane = current;
28166
+ const page = createExecutionScope({ ...context, deadlineAt: Math.min(context?.deadlineAt ?? Infinity, Date.now() + (selection.timeout ?? DEFAULT_SCRAPE_TIMEOUT_MS)), ...lane.cookieSession === void 0 ? {} : { cookieSession: lane.cookieSession } });
26517
28167
  const formats = selection.formats ?? ["markdown"];
26518
28168
  const wants = (name) => hasFormat(formats, name);
26519
28169
  const custom = customJsonFormat(formats);
26520
- const plan = cachePlanFor(url, mode, selection, selection.formats, rungs.channels, robotsOverrideFor?.(url));
28170
+ const cachePlan = cachePlanFor(url, mode, selection, selection.formats, lane.rungs.channels, recordedOverrideFor?.(url), robotsOverrideFor?.(url) !== void 0);
28171
+ const plan = lane.cookieSession === void 0 || cachePlan?.lockdown === true ? cachePlan : null;
26521
28172
  const answer = consultCache(plan, url, { mode, ...req.policyAllowlist.length ? { allowlistedDomains: req.policyAllowlist } : {} });
26522
28173
  const { outcome, json: json2 } = await (async () => {
26523
- const outcome2 = answer.kind === "fetch" ? await ladder.scrape(url, page).then((fetched) => ({ ...fetched, result: afterFetch(plan, answer, fetched.result) })) : { result: answer.result, links: answer.result.links ?? [], cached: answer.kind === "hit" };
28174
+ const outcome2 = answer.kind === "fetch" ? await lane.ladder.scrape(url, page).then((fetched) => ({ ...fetched, result: afterFetch(plan, answer, fetched.result) })) : { result: answer.result, links: answer.result.links ?? [], cached: answer.kind === "hit" };
26524
28175
  const json3 = wants("json") ? await extractStructured(extractionInput(outcome2.result), custom, page, structuredModelConfigFromEnv()) : void 0;
26525
28176
  return { outcome: outcome2, json: json3 };
26526
28177
  })().finally(() => page.dispose());
@@ -26542,11 +28193,45 @@ function createApiEngine(options = {}) {
26542
28193
  ...json2 === void 0 ? {} : { json: json2 }
26543
28194
  } };
26544
28195
  },
26545
- close: () => ladder.close()
28196
+ close: closeLadders
28197
+ };
28198
+ const atom = egressPool === null || firstEgress === void 0 ? pageAtom : {
28199
+ async scrape(url, context) {
28200
+ let used = current;
28201
+ let outcome = await pageAtom.scrape(url, context);
28202
+ const events = [];
28203
+ while (egressInDoubt(outcome)) {
28204
+ let reason2;
28205
+ if (current === used) {
28206
+ if (switches >= MAX_EGRESS_SWITCHES)
28207
+ break;
28208
+ const verdict = await probeEgress(used.egress.server);
28209
+ if (verdict === "works")
28210
+ break;
28211
+ reason2 = verdict;
28212
+ if (current === used) {
28213
+ egressPool.fail(used.egress.id);
28214
+ switches++;
28215
+ const next = egressPool.pick(used.egress.id);
28216
+ writeTaskEgress(egressFile, next.id);
28217
+ current = laneFor(next);
28218
+ lanes.push(current);
28219
+ await used.cookieSession?.close();
28220
+ }
28221
+ } else {
28222
+ reason2 = "moved_with_task";
28223
+ }
28224
+ events.push({ at: 0, lane: outcome.result.lane, event: "egress_switched", detail: { from: used.egress.id, to: current.egress.id, reason: reason2, switches } });
28225
+ used = current;
28226
+ outcome = await pageAtom.scrape(url, context);
28227
+ }
28228
+ return events.length === 0 ? outcome : { ...outcome, result: { ...outcome.result, trace: [...outcome.result.trace, ...events] } };
28229
+ },
28230
+ close: () => pageAtom.close()
26546
28231
  };
26547
28232
  const controller2 = new AbortController();
26548
28233
  crawlControllers.set(task.id, controller2);
26549
- const sitemapSource = req.sitemap === "skip" ? void 0 : new HttpSitemapSource({ mode, device: selection?.mobile === true ? "mobile" : "desktop", networkPolicy, scheduler: originScheduler, robots: robotsCacheFor(mode) });
28234
+ const sitemapSource = req.sitemap === "skip" ? void 0 : new HttpSitemapSource({ mode, device: selection?.mobile === true ? "mobile" : "desktop", networkPolicy: firstEgress?.policy ?? networkPolicy, scheduler: originScheduler, robots: robotsCacheFor(mode, firstEgress), ignoreRobotsTxt: ignoreRobotsOverride(task.crawl?.ignoreRobotsTxt) !== void 0 });
26550
28235
  const webhook = webhookOf(task);
26551
28236
  const kind = jobKindOf(task);
26552
28237
  const onStep = async (step) => {
@@ -26563,6 +28248,8 @@ function createApiEngine(options = {}) {
26563
28248
  const orchestrator = new CrawlOrchestrator({
26564
28249
  store,
26565
28250
  atom,
28251
+ // A page whose scrape threw called a provider only if this mode has one: otherwise its third-party cost is a known 0.
28252
+ scrapeErrorCostUsd: channelsFor(mode).some((channel) => channel.vendorId !== void 0) ? null : 0,
26566
28253
  workerCount,
26567
28254
  perHostConcurrency: Math.min(4, networkPolicy.perHostConcurrency),
26568
28255
  perHostMinDelayMs: networkPolicy.perHostMinDelayMs,
@@ -26585,7 +28272,7 @@ function createApiEngine(options = {}) {
26585
28272
  ...task.batch ? { seedUrls: task.batch.urls } : {},
26586
28273
  taskDir: task.taskDir,
26587
28274
  mode,
26588
- budget: task.budget,
28275
+ budget: grantedBudget(task.budget),
26589
28276
  maxDepth: req.maxDepth,
26590
28277
  allowlistedDomains: req.allowlistedDomains,
26591
28278
  resumeFrom: req.resume ? task.id : null,
@@ -26605,6 +28292,8 @@ function createApiEngine(options = {}) {
26605
28292
  }
26606
28293
  await finishJob(task, store);
26607
28294
  inflight.delete(task.id);
28295
+ const after = await store.getTask(task.id);
28296
+ await endCookieSession(after !== null && isTerminalStatus(after.status));
26608
28297
  await store.close();
26609
28298
  }).catch(async (error) => {
26610
28299
  inflight.delete(task.id);
@@ -26612,6 +28301,7 @@ function createApiEngine(options = {}) {
26612
28301
  runningCrawls.delete(task.id);
26613
28302
  await markCrawlFailed(store, task.id);
26614
28303
  await finishJob(task, store, error instanceof Error ? error.message : String(error));
28304
+ await endCookieSession(true);
26615
28305
  await store.close();
26616
28306
  });
26617
28307
  runningCrawls.set(task.id, orchestrator);
@@ -26631,6 +28321,10 @@ function createApiEngine(options = {}) {
26631
28321
  } else {
26632
28322
  if (task !== null && webhookOf(task) !== void 0 && isTerminalStatus(task.status))
26633
28323
  await reconcileFinished(task, store);
28324
+ if (task !== null && isTerminalStatus(task.status)) {
28325
+ removeCookieSessions(taskDir);
28326
+ rmSync(join10(taskDir, EGRESS_FILE), { force: true });
28327
+ }
26634
28328
  void store.close();
26635
28329
  }
26636
28330
  }).catch(() => {
@@ -26651,26 +28345,29 @@ function createApiEngine(options = {}) {
26651
28345
  const deadlineAt = req.timeout === void 0 ? context.deadlineAt ?? Date.now() + DEFAULT_SCRAPE_TIMEOUT_MS : Math.min(context.deadlineAt ?? Infinity, Date.now() + req.timeout);
26652
28346
  const scope = createExecutionScope({ ...context, signal: context.signal ? AbortSignal.any([context.signal, shutdownController.signal]) : shutdownController.signal, deadlineAt });
26653
28347
  const mode = defaultApiMode(req.mode);
26654
- const rungs = channelsForUrl(mode, req.url, req, req.formats ?? []);
28348
+ const rungs = channelsForUrl(mode, req.url, req, req.formats ?? [], true, true, mode === "authed" ? void 0 : egressPool?.pick());
26655
28349
  const policy = {
26656
28350
  mode,
26657
28351
  ...req.allowlistedDomains !== void 0 && req.allowlistedDomains.length > 0 ? { allowlistedDomains: req.allowlistedDomains } : {}
26658
28352
  };
26659
28353
  const runner = new LadderRunner(rungs.channels, policy, historyFor(mode), null, sessionsFor(mode), { channelsFiltered: rungs.filtered });
26660
- const plan = record3 ? cachePlanFor(req.url, mode, req, req.formats, rungs.channels, req.robotsOverride) : null;
28354
+ const robotsOverride = req.robotsOverride ?? (record3 ? namedUrlOverride : void 0);
28355
+ const plan = record3 ? cachePlanFor(req.url, mode, req, req.formats, rungs.channels, req.robotsOverride, robotsOverride !== void 0) : null;
26661
28356
  const operation = (async () => {
26662
28357
  const answer = consultCache(plan, req.url, policy);
26663
- const run = answer.kind === "fetch" ? await runner.run(req.url, void 0, scope, { ...fetchOptions(req, req.formats), ...req.robotsOverride === void 0 ? {} : { robotsOverride: req.robotsOverride } }).then((fetched) => ({ ...fetched, result: afterFetch(plan, answer, fetched.result) })) : { result: answer.result, ...untriedAudit(Math.round(performance.now() - overallStart)) };
28358
+ const run = answer.kind === "fetch" ? await runner.run(req.url, void 0, scope, { ...fetchOptions(req, req.formats), ...robotsOverride === void 0 ? {} : { robotsOverride } }).then((fetched) => ({ ...fetched, result: afterFetch(plan, answer, fetched.result) })) : { result: answer.result, ...untriedAudit(Math.round(performance.now() - overallStart)) };
26664
28359
  const handed = req.handoff !== void 0 && answer.kind === "fetch" && handoffResult(run.result) ? await handOffScrape(req, run.result, context, hooks) : null;
26665
28360
  const read2 = handed !== null && "result" in handed ? handed.result : null;
26666
28361
  const result = handed === null ? withHandoffHint(run.result, req) : "reason" in handed ? withHandoffHint({ ...run.result, warnings: [...run.result.warnings ?? [], { code: "handoff_not_through", message: `Handed to you in your Chrome, the page was not read: ${handed.reason}.` }] }, req, true) : answer.kind === "fetch" && answer.missed && plan?.bounds != null ? withCacheMiss(handed.result, plan.bounds) : handed.result;
26667
28362
  const agentHints = read2 !== null ? agentHintsFor(req, { channelsTried: [result.lane], result }) : agentHintsFor(req, { ...run, result });
28363
+ const summary = read2 === null ? run.summary : { ...run.summary, ...summarize(run.channelsTried, [...run.summary.attempts, { channel: read2.lane, result: read2, ordinal: run.summary.attempts.length + 1 }]) };
26668
28364
  const full = {
26669
28365
  ...result,
28366
+ // The answer's third-party spend is the whole call's: a page read in the person's Chrome after a provider tried it still cost what the provider charged.
28367
+ usage: { ...result.usage, externalCostUsd: summary.externalCostUsd },
26670
28368
  channelsTried: run.channelsTried,
26671
28369
  ladderTrace: run.ladderTrace,
26672
- // The call's totals count the read in the person's Chrome as one more attempt, after the stopped run's.
26673
- summary: read2 === null ? run.summary : { ...run.summary, ...summarize(run.channelsTried, [...run.summary.attempts, { channel: read2.lane, result: read2 }]) },
28370
+ summary,
26674
28371
  ...agentHints.length === 0 ? {} : { agentHints }
26675
28372
  };
26676
28373
  const shapeScope = handed !== null && "result" in handed ? createExecutionScope({ signal: AbortSignal.any([...context.signal === void 0 ? [] : [context.signal], shutdownController.signal]) }) : scope;
@@ -26700,6 +28397,8 @@ function createApiEngine(options = {}) {
26700
28397
  }
26701
28398
  }
26702
28399
  async function runMap(req, context) {
28400
+ checkRobotsOverride("ignoreRobotsTxt", req.ignoreRobotsTxt);
28401
+ const robotsOverride = ignoreRobotsOverride(req.ignoreRobotsTxt);
26703
28402
  if (req.limit !== void 0 && req.limit > mapMaxLimit)
26704
28403
  throw new RequestError(`limit must be at most ${mapMaxLimit} on this server`);
26705
28404
  if (req.timeout !== void 0 && req.timeout > mapMaxTimeoutMs)
@@ -26708,20 +28407,21 @@ function createApiEngine(options = {}) {
26708
28407
  if (readsPage && options.channelPolicy?.(req.url) === "browser_only")
26709
28408
  throw new RequestError("map is not available for this URL: this server reads it with the browser lane only");
26710
28409
  const mode = req.mode ?? "standard";
26711
- const rungs = readsPage ? channelsForUrl(mode, req.url, { fastMode: true }, ["rawHtml"]) : null;
28410
+ const egress = egressPool?.pick();
28411
+ const rungs = readsPage ? channelsForUrl(mode, req.url, { fastMode: true }, ["rawHtml"], true, false, egress) : null;
26712
28412
  const requestedAt = (/* @__PURE__ */ new Date()).toISOString();
26713
28413
  const id = crypto.randomUUID();
26714
28414
  const signal = context.signal ? AbortSignal.any([context.signal, shutdownController.signal]) : shutdownController.signal;
26715
28415
  const userAgentFor = (url) => prepareHttpIdentity(mode, networkPolicy.contact ?? null, new URL(url).hostname).identity.userAgent;
26716
- const robots = robotsCacheFor(mode);
28416
+ const robots = robotsCacheFor(mode, egress);
26717
28417
  const lookups = /* @__PURE__ */ new Map();
26718
28418
  const runner = rungs === null ? null : new LadderRunner(rungs.channels, { mode }, historyFor(mode), null, null, { channelsFiltered: rungs.filtered });
26719
- const sitemap = req.sitemap === "skip" ? null : new HttpSitemapSource({ mode, networkPolicy, scheduler: originScheduler, robots });
28419
+ const sitemap = req.sitemap === "skip" ? null : new HttpSitemapSource({ mode, networkPolicy: egress?.policy ?? networkPolicy, scheduler: originScheduler, robots, ignoreRobotsTxt: robotsOverride !== void 0 });
26720
28420
  const sources = {
26721
28421
  async readStartPage(url, scope) {
26722
28422
  if (runner === null)
26723
28423
  throw new Error("a sitemap-only map reads no page");
26724
- const run = await runner.run(url, void 0, scope, fetchOptions(void 0, ["rawHtml"]));
28424
+ const run = await runner.run(url, void 0, scope, { ...fetchOptions(void 0, ["rawHtml"]), ...robotsOverride === void 0 ? {} : { robotsOverride } });
26725
28425
  const result = run.result;
26726
28426
  const links = typeof result.rawHtml === "string" ? collectLinkDetails(result.rawHtml, result.evidence.finalUrl || url) : [];
26727
28427
  const clientRendered = result.warnings?.some((warning) => warning.code === "client_rendered_suspected") === true || httpLaneAskedForBrowser(result);
@@ -26739,7 +28439,7 @@ function createApiEngine(options = {}) {
26739
28439
  lookups.set(origin, lookup3);
26740
28440
  }
26741
28441
  const decision = robots.decision(await lookup3, url, userAgent);
26742
- return decision.decision === "disallowed" ? { disallowed: true, ...decision.unreachable === void 0 ? {} : { unreachable: decision.unreachable } } : decision.decision;
28442
+ return decision.decision === "disallowed" ? { disallowed: true, ...decision.unreachable === void 0 ? {} : { unreachable: decision.unreachable }, ...isOctocrawlRobotsGroup(decision.matchedUserAgentGroup) ? { octocrawl: true } : {} } : decision.decision;
26743
28443
  },
26744
28444
  identity: { mode, userAgent: userAgentFor(req.url) }
26745
28445
  };
@@ -26758,7 +28458,8 @@ function createApiEngine(options = {}) {
26758
28458
  excludePaths: req.excludePaths,
26759
28459
  regexOnFullURL: req.regexOnFullURL,
26760
28460
  crawlEntireDomain: req.crawlEntireDomain,
26761
- deduplicateSimilarURLs: req.deduplicateSimilarURLs
28461
+ deduplicateSimilarURLs: req.deduplicateSimilarURLs,
28462
+ ignoreRobotsTxt: robotsOverride !== void 0
26762
28463
  }, { signal });
26763
28464
  const agentHints = mapAgentHints(run, mapMaxTimeoutMs);
26764
28465
  const { elapsedMs, ...rest } = run;
@@ -26788,7 +28489,7 @@ function createApiEngine(options = {}) {
26788
28489
  if (!UUID.test(id))
26789
28490
  return null;
26790
28491
  try {
26791
- return JSON.parse(await readFile5(join10(mapsDir, `${id}.json`), "utf8"));
28492
+ return JSON.parse(await readFile6(join10(mapsDir, `${id}.json`), "utf8"));
26792
28493
  } catch (error) {
26793
28494
  if (error.code === "ENOENT")
26794
28495
  return null;
@@ -26799,7 +28500,7 @@ function createApiEngine(options = {}) {
26799
28500
  if (!UUID.test(scrapeId))
26800
28501
  return null;
26801
28502
  try {
26802
- return JSON.parse(await readFile5(join10(scrapesDir, `${scrapeId}.json`), "utf8"));
28503
+ return JSON.parse(await readFile6(join10(scrapesDir, `${scrapeId}.json`), "utf8"));
26803
28504
  } catch (error) {
26804
28505
  if (error.code === "ENOENT")
26805
28506
  return null;
@@ -26807,6 +28508,7 @@ function createApiEngine(options = {}) {
26807
28508
  }
26808
28509
  },
26809
28510
  async startCrawl(req) {
28511
+ checkRobotsOverride("ignoreRobotsTxt", req.ignoreRobotsTxt);
26810
28512
  checkFileCap(req);
26811
28513
  checkSelectors(req);
26812
28514
  checkAttributeSelectors(req.formats);
@@ -26840,7 +28542,7 @@ function createApiEngine(options = {}) {
26840
28542
  budget: {
26841
28543
  maxPages: req.maxPages ?? defaultMaxPages,
26842
28544
  maxWallMs: null,
26843
- maxCostUsd: null,
28545
+ maxCostUsd: accessGrant?.budget.perRunUsd ?? null,
26844
28546
  maxTokens: null
26845
28547
  },
26846
28548
  crawl: {
@@ -26859,6 +28561,7 @@ function createApiEngine(options = {}) {
26859
28561
  allowExternalLinks: req.allowExternalLinks === true,
26860
28562
  sitemap: req.sitemap ?? "include",
26861
28563
  maxConcurrency: req.maxConcurrency ?? null,
28564
+ ...req.ignoreRobotsTxt === true ? { ignoreRobotsTxt: true } : {},
26862
28565
  ...pageOptions(req),
26863
28566
  // The destination is registered first (`job:<taskId>`); the task stores everything but the header values.
26864
28567
  ...webhookConfig === void 0 ? {} : { webhook: jobWebhooks.register(taskId, webhookConfig, req.webhookPayloadFormat) }
@@ -26939,7 +28642,7 @@ function createApiEngine(options = {}) {
26939
28642
  taskDir,
26940
28643
  mode: defaultApiMode(req.mode),
26941
28644
  status: "pending",
26942
- budget: { maxPages: null, maxWallMs: options.batchMaxWallMs ?? null, maxCostUsd: null, maxTokens: null },
28645
+ budget: { maxPages: null, maxWallMs: options.batchMaxWallMs ?? null, maxCostUsd: accessGrant?.budget.perRunUsd ?? null, maxTokens: null },
26943
28646
  batch,
26944
28647
  ...attributionOf(req),
26945
28648
  createdAt: now,
@@ -27078,8 +28781,11 @@ function createApiEngine(options = {}) {
27078
28781
  if (latest?.status === "running")
27079
28782
  await store.putAttempt({ ...latest, status: "cancelled", endedAt: now });
27080
28783
  crawlControllers.get(taskId)?.abort();
27081
- if (!inflight.has(taskId))
28784
+ if (!inflight.has(taskId)) {
27082
28785
  await finishJob(task, store);
28786
+ removeCookieSessions(join10(taskRoot, taskId));
28787
+ rmSync(join10(taskRoot, taskId, EGRESS_FILE), { force: true });
28788
+ }
27083
28789
  }
27084
28790
  } finally {
27085
28791
  await store.close();
@@ -27117,7 +28823,7 @@ function createApiEngine(options = {}) {
27117
28823
  },
27118
28824
  async importLogin(req) {
27119
28825
  if (options.hosted === true || (options.sessionsFile ?? null) === null || userChrome === null)
27120
- throw new LoginsUnavailableError("this server does not save logins: run W2L on your own machine (octocrawl serve, the local MCP host, or octocrawl login import)");
28826
+ throw new LoginsUnavailableError("this server does not save logins: run Octocrawl on your own machine (octocrawl serve, the local MCP host, or octocrawl login import)");
27121
28827
  try {
27122
28828
  loginDomain(req.site);
27123
28829
  } catch (error) {
@@ -27242,25 +28948,32 @@ function createApiEngine(options = {}) {
27242
28948
  },
27243
28949
  retryDelivery: (id) => deliveryStore.replayDeadLetter(id),
27244
28950
  async createManagedSession(input) {
28951
+ managedSessionsServed();
27245
28952
  const profileDir = join10(taskRoot, "profiles", crypto.randomUUID());
27246
- return sessionBroker.createManagedSession({ ...input, profileDir });
28953
+ return publicSession(await sessionBroker.createManagedSession({ ...input, profileDir }));
27247
28954
  },
27248
28955
  async authorizeManagedSession(sessionRef, accountRef) {
27249
- return sessionBroker.markAuthorized(sessionRef, accountRef);
28956
+ managedSessionsServed();
28957
+ return publicSession(await sessionBroker.markAuthorized(sessionRef, accountRef));
27250
28958
  },
27251
28959
  async revokeManagedSession(sessionRef) {
28960
+ managedSessionsServed();
27252
28961
  await sessionBroker.revoke(sessionRef);
27253
28962
  },
27254
28963
  async getManagedSession(sessionRef) {
27255
- return sessionBroker.getSession(sessionRef);
28964
+ managedSessionsServed();
28965
+ return publicSession(await sessionBroker.getSession(sessionRef));
27256
28966
  },
27257
28967
  async renewManagedSession(sessionRef, expiresAt) {
27258
- return sessionBroker.renewExpired(sessionRef, expiresAt);
28968
+ managedSessionsServed();
28969
+ return publicSession(await sessionBroker.renewExpired(sessionRef, expiresAt));
27259
28970
  },
27260
28971
  async requestManagedHandoff(sessionRef, reason2, expiresAt) {
27261
- return sessionBroker.requestHandoff(sessionRef, reason2, expiresAt);
28972
+ managedSessionsServed();
28973
+ return publicSession(await sessionBroker.requestHandoff(sessionRef, reason2, expiresAt));
27262
28974
  },
27263
28975
  async captureManagedSession(input) {
28976
+ managedSessionsServed();
27264
28977
  const access = await sessionBroker.grant({ ...input, origin: input.url });
27265
28978
  if (access.kind !== "granted")
27266
28979
  return access;
@@ -27399,7 +29112,7 @@ function withoutFetchUsage(response) {
27399
29112
  ...timings.modelMs === void 0 ? {} : { modelMs: timings.modelMs },
27400
29113
  ...timings.totalMs === void 0 ? {} : { totalMs: timings.totalMs }
27401
29114
  };
27402
- return { ...response, usage: { ...usage2, bytesWire: 0, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, browserMs: 0, externalCostUsd: null, ...own === void 0 ? {} : { timings: own } } };
29115
+ return { ...response, usage: { ...usage2, bytesWire: 0, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, browserMs: 0, externalCostUsd: 0, ...own === void 0 ? {} : { timings: own } } };
27403
29116
  }
27404
29117
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27405
29118
  function prepareTaskRoot(taskRoot) {
@@ -27466,7 +29179,7 @@ async function appendedWithoutStep(taskId, store) {
27466
29179
  return await store.countCompletedSteps(taskId) < batch.urls.length ? { ...task, batch } : null;
27467
29180
  }
27468
29181
  function activeCrawlOptions(task) {
27469
- const { formats, includeLinks, includePaths, excludePaths, maxDepth, allowlistedDomains, useCached, regexOnFullURL, ignoreQueryParameters, deduplicateSimilarURLs, crawlEntireDomain, allowSubdomains, allowExternalLinks, sitemap, maxConcurrency, webhook: _webhook, ...page } = task.crawl ?? {};
29182
+ const { formats, includeLinks, includePaths, excludePaths, maxDepth, allowlistedDomains, useCached, regexOnFullURL, ignoreQueryParameters, deduplicateSimilarURLs, crawlEntireDomain, allowSubdomains, allowExternalLinks, sitemap, maxConcurrency, ignoreRobotsTxt, webhook: _webhook, ...page } = task.crawl ?? {};
27470
29183
  return {
27471
29184
  maxPages: task.budget.maxPages,
27472
29185
  maxDepth: maxDepth ?? null,
@@ -27482,6 +29195,7 @@ function activeCrawlOptions(task) {
27482
29195
  allowExternalLinks: allowExternalLinks ?? false,
27483
29196
  regexOnFullURL: regexOnFullURL ?? false,
27484
29197
  maxConcurrency: maxConcurrency ?? null,
29198
+ ignoreRobotsTxt: ignoreRobotsTxt === true,
27485
29199
  scrapeOptions: { formats: formats ?? ["markdown"], includeLinks: includeLinks === true, ...page }
27486
29200
  };
27487
29201
  }
@@ -27530,7 +29244,7 @@ function handoffRequestOf(step) {
27530
29244
  return {
27531
29245
  reason: HANDOFF_REASONS[blockReason],
27532
29246
  liveViewUrl: null,
27533
- rationale: `${step.url} stopped at a ${blockReason.replace(/_/g, " ")} W2L does not pass: POST /v1/batches/${step.taskId}/handoff (MCP hand_off_batch, or octocrawl batch --handoff) opens it in your own Chrome, where you get through it, and W2L reads the page there`
29247
+ rationale: `${step.url} stopped at a ${blockReason.replace(/_/g, " ")} Octocrawl does not pass: POST /v1/batches/${step.taskId}/handoff (MCP hand_off_batch, or octocrawl batch --handoff) opens it in your own Chrome, where you get through it, and Octocrawl reads the page there`
27534
29248
  };
27535
29249
  }
27536
29250
  async function stepsOf(store, taskId, kind) {
@@ -27663,7 +29377,7 @@ function readOnlySessions(store) {
27663
29377
  }
27664
29378
 
27665
29379
  // packages/api/dist/auth.js
27666
- import { createHash as createHash13, timingSafeEqual as timingSafeEqual2 } from "crypto";
29380
+ import { createHash as createHash14, timingSafeEqual as timingSafeEqual2 } from "crypto";
27667
29381
  function bearerTokenMatcher(tokens) {
27668
29382
  const accepted = tokens.map(digest);
27669
29383
  return (presented) => {
@@ -27675,7 +29389,7 @@ function bearerTokenMatcher(tokens) {
27675
29389
  };
27676
29390
  }
27677
29391
  function digest(token) {
27678
- return createHash13("sha256").update(token, "utf8").digest();
29392
+ return createHash14("sha256").update(token, "utf8").digest();
27679
29393
  }
27680
29394
 
27681
29395
  // packages/api/dist/jobStream.js
@@ -27910,7 +29624,7 @@ function createApp(engine, options = {}) {
27910
29624
  app.use("*", async (c, next) => {
27911
29625
  if (c.req.method !== "POST" || !RATE_LIMITED_POSTS.has(c.req.path))
27912
29626
  return next();
27913
- const key = tokens.length === 0 ? "local" : createHash14("sha256").update(presentedToken(c), "utf8").digest("hex");
29627
+ const key = tokens.length === 0 ? "local" : createHash15("sha256").update(presentedToken(c), "utf8").digest("hex");
27914
29628
  const retryAfterSeconds = limiter.retryAfterSeconds(key);
27915
29629
  if (retryAfterSeconds === null)
27916
29630
  return next();
@@ -28237,7 +29951,13 @@ function createApp(engine, options = {}) {
28237
29951
  const body = await c.req.json();
28238
29952
  if (typeof body.workspaceId !== "string" || typeof body.accountRef !== "string" || typeof body.originScope !== "string")
28239
29953
  return fail(c, "invalid_request", "workspaceId, accountRef, and originScope are required");
28240
- return c.json(await engine.createManagedSession({ workspaceId: body.workspaceId, accountRef: body.accountRef, originScope: body.originScope, expiresAt: typeof body.expiresAt === "string" ? body.expiresAt : null }), 201);
29954
+ try {
29955
+ return c.json(await engine.createManagedSession({ workspaceId: body.workspaceId, accountRef: body.accountRef, originScope: body.originScope, expiresAt: typeof body.expiresAt === "string" ? body.expiresAt : null }), 201);
29956
+ } catch (error) {
29957
+ if (error instanceof SessionsUnavailableError)
29958
+ return fail(c, "conflict", error.message);
29959
+ throw error;
29960
+ }
28241
29961
  });
28242
29962
  app.post("/v1/sessions/:id/authorize", async (c) => {
28243
29963
  const body = await c.req.json();
@@ -28260,8 +29980,8 @@ function createApp(engine, options = {}) {
28260
29980
  app.get("/v1/sessions/:id", async (c) => {
28261
29981
  try {
28262
29982
  return c.json(await engine.getManagedSession(c.req.param("id")), 200);
28263
- } catch {
28264
- return fail(c, "not_found", "session not found");
29983
+ } catch (error) {
29984
+ return error instanceof SessionsUnavailableError ? fail(c, "conflict", error.message) : fail(c, "not_found", "session not found");
28265
29985
  }
28266
29986
  });
28267
29987
  app.post("/v1/sessions/:id/renew", async (c) => {
@@ -28286,7 +30006,13 @@ function createApp(engine, options = {}) {
28286
30006
  const body = await c.req.json();
28287
30007
  if (typeof body.workspaceId !== "string" || typeof body.accountRef !== "string" || typeof body.url !== "string")
28288
30008
  return fail(c, "invalid_request", "workspaceId, accountRef, and url are required");
28289
- return c.json(await engine.captureManagedSession({ sessionRef: c.req.param("id"), workspaceId: body.workspaceId, accountRef: body.accountRef, url: body.url }), 200);
30009
+ try {
30010
+ return c.json(await engine.captureManagedSession({ sessionRef: c.req.param("id"), workspaceId: body.workspaceId, accountRef: body.accountRef, url: body.url }), 200);
30011
+ } catch (error) {
30012
+ if (error instanceof SessionsUnavailableError)
30013
+ return fail(c, "conflict", error.message);
30014
+ throw error;
30015
+ }
28290
30016
  });
28291
30017
  app.post("/fc/v1/scrape", async (c) => {
28292
30018
  const req = parseFirecrawlScrapeRequest(await c.req.json());
@@ -28342,8 +30068,40 @@ function createApp(engine, options = {}) {
28342
30068
  }
28343
30069
 
28344
30070
  // packages/api/dist/listen.js
30071
+ init_dist2();
30072
+ import { readFileSync as readFileSync4 } from "fs";
28345
30073
  init_dist();
28346
30074
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
30075
+ function readEgressProxies(env, grant, hosted) {
30076
+ const proxies = egressProxies(env.W2L_EGRESS_PROXIES);
30077
+ if (proxies.length === 0)
30078
+ return [];
30079
+ if (hosted)
30080
+ throw new Error("W2L_EGRESS_PROXIES is refused on a hosted server (ADR 0005: a hosted server connects direct, to addresses it checked)");
30081
+ if (!(grant?.capabilities ?? []).includes("egress_sessions"))
30082
+ throw new Error("W2L_EGRESS_PROXIES needs an access grant that names egress_sessions (ADR 0005; --access-grant or W2L_ACCESS_GRANT)");
30083
+ return proxies;
30084
+ }
30085
+ function readAccessGrant(argv, env, hosted) {
30086
+ const source = (readFlag(argv, "--access-grant") ?? env["W2L_ACCESS_GRANT"] ?? "").trim();
30087
+ if (source === "")
30088
+ return null;
30089
+ let text2;
30090
+ try {
30091
+ text2 = source.startsWith("{") ? source : readFileSync4(source, "utf8");
30092
+ } catch (error) {
30093
+ throw new Error(`access grant: cannot read ${source}: ${error instanceof Error ? error.message : String(error)}`);
30094
+ }
30095
+ const grant = accessGrantFromText(text2);
30096
+ if (hosted && grant.tier === "my_browser") {
30097
+ throw new Error("access grant refused: tier my_browser reads the person's own Chrome, which a hosted server does not have");
30098
+ }
30099
+ return grant;
30100
+ }
30101
+ function accessGrantNotice(grant) {
30102
+ const usd2 = (value) => value === null ? "none" : `${value} USD`;
30103
+ return `access grant (ADR 0005): tier ${grant.tier}; ${grant.capabilities.length === 0 ? "no capabilities" : grant.capabilities.join(", ")}; run budget ${usd2(grant.budget.perRunUsd)}; per-request budget ${usd2(grant.budget.perRequestUsd)} (not enforced yet)${grant.attestation === null ? "" : `; accepted by ${grant.attestation.principal}`}`;
30104
+ }
28347
30105
  function jobStreamsEnabled(env) {
28348
30106
  return (env["W2L_JOB_STREAMS"] ?? "").trim().toLowerCase() !== "off";
28349
30107
  }
@@ -28354,6 +30112,13 @@ function parseListen(argv, env = process.env) {
28354
30112
  const tokens = readTokens(argv, env);
28355
30113
  const rateLimit = parseRateLimit(argv, env);
28356
30114
  const workerCount = parseWorkerCount(env);
30115
+ const accessGrant = readAccessGrant(argv, env, hosted);
30116
+ const browserEngine = browserEngineChoice(env, accessGrant, hosted);
30117
+ const engineNotice = browserEngine === "playwright" ? [] : [`browser engine: ${browserEngine} on the public browser rung (ADR 0005 enhanced_browser); saved logins and managed sessions keep stock Playwright`];
30118
+ const compatHosts = compatHostsChoice(env, accessGrant, hosted);
30119
+ const egressList = readEgressProxies(env, accessGrant, hosted);
30120
+ const egressNotice = egressList.length === 0 ? [] : [`egress proxies (ADR 0005 egress_sessions): ${egressList.map((proxy) => proxy.endpoint).join(", ")}; a batch or crawl keeps one for its run and moves on only when the proxy itself fails its probe (at most 2 times), never after a block, a challenge, a 429 or a site's reset; a scrape takes the next healthy one`];
30121
+ const compatNotice = compatHosts.length === 0 ? [] : [`compatible transport (ADR 0005 compatible_transport): ${COMPAT_LIBRARY.name} ${COMPAT_LIBRARY.version}, profile ${DEFAULT_COMPAT_PROFILE}, in place of the http rung for standard-mode pages on ${compatHosts.join(", ")} and their subdomains; a request with custom headers or mobile keeps the http rung`];
28357
30122
  if (hosted) {
28358
30123
  if (tokens.length === 0) {
28359
30124
  throw new Error("hosted mode requires --token, W2L_API_TOKEN or W2L_API_TOKENS");
@@ -28370,10 +30135,14 @@ function parseListen(argv, env = process.env) {
28370
30135
  mapMaxLimit: HOSTED_MAP_MAX_LIMIT,
28371
30136
  mapMaxTimeoutMs: HOSTED_MAP_MAX_TIMEOUT_MS,
28372
30137
  allowRobotsOverride: false,
28373
- notices: [hostedProxyNotice(env), ...jobStreamsEnabled(env) ? [] : [JOB_STREAMS_OFF_NOTICE]].filter((notice) => notice !== null),
30138
+ notices: [hostedProxyNotice(env), ...jobStreamsEnabled(env) ? [] : [JOB_STREAMS_OFF_NOTICE], ...accessGrant === null ? [] : [accessGrantNotice(accessGrant)]].filter((notice) => notice !== null),
28374
30139
  ...rateLimit === void 0 ? {} : { rateLimit },
28375
30140
  delivery: deliveryConfig("hosted", env),
28376
- jobStreams: jobStreamsEnabled(env)
30141
+ jobStreams: jobStreamsEnabled(env),
30142
+ accessGrant,
30143
+ browserEngine,
30144
+ compatHosts,
30145
+ egressProxies: egressList
28377
30146
  };
28378
30147
  }
28379
30148
  const networkPolicy = withOperatorContact(withEnvironmentProxy(tunedPolicy(localNetworkPolicy(), env), env), env);
@@ -28392,10 +30161,14 @@ function parseListen(argv, env = process.env) {
28392
30161
  mapMaxLimit: MAX_MAP_LIMIT,
28393
30162
  mapMaxTimeoutMs: MAX_MAP_TIMEOUT_MS,
28394
30163
  allowRobotsOverride: true,
28395
- notices: [...networkPolicy.egressProxy ? [describeEgressProxy(networkPolicy.egressProxy)] : [], ...jobStreamsEnabled(env) ? [] : [JOB_STREAMS_OFF_NOTICE]],
30164
+ notices: [...networkPolicy.egressProxy ? [describeEgressProxy(networkPolicy.egressProxy)] : [], ...jobStreamsEnabled(env) ? [] : [JOB_STREAMS_OFF_NOTICE], ...accessGrant === null ? [] : [accessGrantNotice(accessGrant)], ...engineNotice, ...compatNotice, ...egressNotice],
28396
30165
  ...rateLimit === void 0 ? {} : { rateLimit },
28397
30166
  delivery: deliveryConfig("local", env),
28398
- jobStreams: jobStreamsEnabled(env)
30167
+ jobStreams: jobStreamsEnabled(env),
30168
+ accessGrant,
30169
+ browserEngine,
30170
+ compatHosts,
30171
+ egressProxies: egressList
28399
30172
  };
28400
30173
  }
28401
30174
  function deliveryConfig(mode, env = process.env) {
@@ -28480,12 +30253,18 @@ function readFlag(argv, name) {
28480
30253
 
28481
30254
  // packages/api/dist/cli.js
28482
30255
  import { serve } from "@hono/node-server";
28483
- import { readFileSync as readFileSync2 } from "fs";
30256
+ import { readFileSync as readFileSync5 } from "fs";
28484
30257
  import { join as join11 } from "path";
28485
30258
  import { pathToFileURL as pathToFileURL2 } from "url";
28486
30259
  async function runApiServer(argv, env = process.env, options = {}) {
28487
30260
  const log = options.log ?? ((line) => console.log(line));
28488
30261
  const listen = parseListen([...argv], env);
30262
+ if (listen.browserEngine === "patchright") {
30263
+ const engine2 = await loadPatchrightEngine();
30264
+ log(`w2l-api: patchright ${engine2.version ?? "(version unknown)"} loaded`);
30265
+ }
30266
+ if (listen.compatHosts.length > 0)
30267
+ await loadImpit();
28489
30268
  const taskRoot = env.W2L_TASK_ROOT ?? ".w2l/api";
28490
30269
  const servesLogins = listen.mode === "local" && isLoopbackAuthority(listen.host.includes(":") && !listen.host.startsWith("[") ? `[${listen.host}]` : listen.host, false);
28491
30270
  if (listen.mode === "local" && !servesLogins)
@@ -28502,7 +30281,11 @@ async function runApiServer(argv, env = process.env, options = {}) {
28502
30281
  // A page a check stopped is handed to the person in their own Chrome by a server that answers them alone.
28503
30282
  userChrome: servesLogins ? {} : null,
28504
30283
  webhookPolicy: { allowHttpLoopback: listen.delivery.allowHttpLoopback },
28505
- workerCount: listen.workerCount
30284
+ workerCount: listen.workerCount,
30285
+ accessGrant: listen.accessGrant,
30286
+ browserEngine: listen.browserEngine,
30287
+ compatHosts: listen.compatHosts,
30288
+ egressProxies: listen.egressProxies
28506
30289
  });
28507
30290
  const app = createApp(engine, { loopbackOnly: servesLogins, tokens: listen.tokens, exposeInternalErrors: listen.mode === "local", jobStreams: listen.jobStreams, ...listen.rateLimit === void 0 ? {} : { rateLimit: listen.rateLimit } });
28508
30291
  const deliveryStore = DeliveryStore.open(join11(taskRoot, "section-b-control.sqlite"));
@@ -28510,7 +30293,7 @@ async function runApiServer(argv, env = process.env, options = {}) {
28510
30293
  networkPolicy: listen.delivery.networkPolicy,
28511
30294
  allowHttpLoopback: listen.delivery.allowHttpLoopback,
28512
30295
  ...listen.delivery.proxyUrl === void 0 ? {} : { proxyUrl: listen.delivery.proxyUrl },
28513
- ...listen.delivery.caFile === void 0 ? {} : { ca: readFileSync2(listen.delivery.caFile) }
30296
+ ...listen.delivery.caFile === void 0 ? {} : { ca: readFileSync5(listen.delivery.caFile) }
28514
30297
  });
28515
30298
  const workerController = new AbortController();
28516
30299
  const workerLoop = worker.run(workerController.signal).catch((error) => {
@@ -28588,6 +30371,7 @@ var KINDS = {
28588
30371
  allowExternalLinks: "boolean",
28589
30372
  includeSubdomains: "boolean",
28590
30373
  handoff: "boolean",
30374
+ ignoreRobotsTxt: "boolean",
28591
30375
  waitFor: "int",
28592
30376
  timeout: "int",
28593
30377
  maxFileBytes: "int",
@@ -28772,8 +30556,8 @@ function usage(command) {
28772
30556
  }
28773
30557
  const synopsis = {
28774
30558
  crawl: "usage: octocrawl crawl <url> [options] [--out <dir>] | octocrawl crawl --resume <taskId>",
28775
- scrape: "usage: octocrawl scrape <url> [options] [--markdown] [--out <dir>] [--handoff]\n\n--handoff: a page a captcha, a challenge or a login wall stops opens in a new tab of your own Chrome\n(remote debugging on at chrome://inspect/#remote-debugging; click Allow); get through it there and click on the page,\nand W2L answers with it. W2L passes no check itself.",
28776
- batch: "usage: octocrawl batch <url>... [--urls-file <file>] [options] [--out <dir>] [--handoff]\n\n--handoff: when the batch ends, each page a captcha, a challenge or a login wall stopped opens in a new tab\nof your own Chrome (remote debugging on at chrome://inspect/#remote-debugging; click Allow once); get through\nit there and W2L reads the page. W2L passes no check itself.",
30559
+ scrape: "usage: octocrawl scrape <url> [options] [--markdown] [--out <dir>] [--handoff]\n\n--handoff: a page a captcha, a challenge or a login wall stops opens in a new tab of your own Chrome\n(remote debugging on at chrome://inspect/#remote-debugging; click Allow); get through it there and click on the page,\nand Octocrawl answers with it. Octocrawl passes no check itself.",
30560
+ batch: "usage: octocrawl batch <url>... [--urls-file <file>] [options] [--out <dir>] [--handoff]\n\n--handoff: when the batch ends, each page a captcha, a challenge or a login wall stopped opens in a new tab\nof your own Chrome (remote debugging on at chrome://inspect/#remote-debugging; click Allow once); get through\nit there and Octocrawl reads the page. Octocrawl passes no check itself.",
28777
30561
  map: "usage: octocrawl map <url> [options]"
28778
30562
  };
28779
30563
  const lines = optionKeys(command).map((key) => {
@@ -28805,7 +30589,7 @@ var LOGIN_USAGE = [
28805
30589
  "`octocrawl scrape <url> --mode authed` (and the API and MCP in mode authed) read the page signed in as you.",
28806
30590
  "First open chrome://inspect/#remote-debugging in Chrome 144 or later and turn on",
28807
30591
  '"Allow remote debugging for this browser instance"; Chrome then asks "Allow remote debugging?": click Allow.',
28808
- "W2L connects once, reads that site's cookies and the localStorage of its tabs you have open",
30592
+ "Octocrawl connects once, reads that site's cookies and the localStorage of its tabs you have open",
28809
30593
  "(a site that keeps its login there needs a tab of it open), disconnects, and loads nothing in your tabs.",
28810
30594
  "Logins are kept in W2L_SESSIONS_FILE, else ~/.w2l/sessions.json (readable by you alone)."
28811
30595
  ].join("\n");
@@ -28890,7 +30674,7 @@ ${LOGIN_USAGE}`);
28890
30674
  }
28891
30675
 
28892
30676
  // packages/cli/src/run.ts
28893
- var CLI_VERSION = "0.3.0";
30677
+ var CLI_VERSION = "0.3.1";
28894
30678
  async function runCli(argv, io) {
28895
30679
  const [name, ...rest] = argv;
28896
30680
  if (name === void 0 || name === "--help" || name === "help") {
@@ -28918,7 +30702,14 @@ ${usage(null)}`);
28918
30702
  }
28919
30703
  if (command === "batch" && line.cli.urlsFile !== void 0) line.urls.push(...await urlsFrom(line.cli.urlsFile));
28920
30704
  const taskRoot = line.cli.taskRoot ?? io.env.W2L_TASK_ROOT ?? ".w2l/cli";
28921
- const listen = parseListen([], { ...io.env, W2L_API_HOST: "127.0.0.1" });
30705
+ let listen;
30706
+ try {
30707
+ listen = parseListen([], { ...io.env, W2L_API_HOST: "127.0.0.1" });
30708
+ if (listen.browserEngine === "patchright") await loadPatchrightEngine();
30709
+ if (listen.compatHosts.length > 0) await loadImpit();
30710
+ } catch (error) {
30711
+ throw new UsageError(error instanceof Error ? error.message : String(error));
30712
+ }
28922
30713
  for (const notice of listen.notices) io.stderr(`octocrawl: ${notice}`);
28923
30714
  const engine = createApiEngine({
28924
30715
  taskRoot,
@@ -28929,7 +30720,11 @@ ${usage(null)}`);
28929
30720
  userChrome: {},
28930
30721
  webhookPolicy: { allowHttpLoopback: listen.delivery.allowHttpLoopback },
28931
30722
  workerCount: listen.workerCount,
28932
- resumeOnStart: false
30723
+ resumeOnStart: false,
30724
+ accessGrant: listen.accessGrant,
30725
+ browserEngine: listen.browserEngine,
30726
+ compatHosts: listen.compatHosts,
30727
+ egressProxies: listen.egressProxies
28933
30728
  });
28934
30729
  try {
28935
30730
  return await runCommand(engine, command, line.urls, line.body, line.cli, io);
@@ -29025,8 +30820,8 @@ async function finish(engine, kind, taskId, cli, io) {
29025
30820
  function handoffPrompts(command, io) {
29026
30821
  return {
29027
30822
  onWaiting: (url, check) => io.stderr(`octocrawl ${command}: ${url} shows a ${check.replace(/_/g, " ")}: get through it in the Chrome tab that opened (click Allow if Chrome asks)`),
29028
- onConfirm: (url) => io.stderr(`octocrawl ${command}: ${url} shows no check in your Chrome: click on the page if it is the one to read (W2L reads it only once you act in its tab)`),
29029
- onHidden: (url) => io.stderr(`octocrawl ${command}: the Chrome tab W2L opened for ${url} is not in front: switch to it (clicks in another tab or window are not seen)`),
30823
+ onConfirm: (url) => io.stderr(`octocrawl ${command}: ${url} shows no check in your Chrome: click on the page if it is the one to read (Octocrawl reads it only once you act in its tab)`),
30824
+ onHidden: (url) => io.stderr(`octocrawl ${command}: the Chrome tab Octocrawl opened for ${url} is not in front: switch to it (clicks in another tab or window are not seen)`),
29030
30825
  ...io.signal === void 0 ? {} : { signal: io.signal }
29031
30826
  };
29032
30827
  }
@@ -29056,8 +30851,8 @@ async function settled(engine, kind, taskId, signal) {
29056
30851
  }
29057
30852
  async function writeOut(dir, pages, report, io) {
29058
30853
  await mkdir9(dir, { recursive: true });
29059
- await writeFile8(join12(dir, "results.jsonl"), pages.map((page) => JSON.stringify(page)).join("\n") + (pages.length > 0 ? "\n" : ""));
29060
- if (report !== null) await writeFile8(join12(dir, "report.json"), JSON.stringify(report, null, 2));
30854
+ await writeFile9(join12(dir, "results.jsonl"), pages.map((page) => JSON.stringify(page)).join("\n") + (pages.length > 0 ? "\n" : ""));
30855
+ if (report !== null) await writeFile9(join12(dir, "report.json"), JSON.stringify(report, null, 2));
29061
30856
  let markdowns = 0;
29062
30857
  let tables = 0;
29063
30858
  const rows = [EVIDENCE_COLUMNS.join(",")];
@@ -29065,17 +30860,17 @@ async function writeOut(dir, pages, report, io) {
29065
30860
  const stem = `${String(n + 1).padStart(4, "0")}-${slug(page.url ?? page.requestedUrl ?? "")}`;
29066
30861
  const markdownFile = typeof page.markdown === "string" ? `${stem}.md` : null;
29067
30862
  if (markdownFile !== null) {
29068
- await writeFile8(join12(dir, markdownFile), page.markdown);
30863
+ await writeFile9(join12(dir, markdownFile), page.markdown);
29069
30864
  markdowns++;
29070
30865
  }
29071
30866
  rows.push(evidenceRow(page, markdownFile).map(csvField).join(","));
29072
30867
  for (const table of page.tables ?? []) {
29073
30868
  if (table.omitted !== void 0) continue;
29074
- await writeFile8(join12(dir, `${stem}.table-${table.tableIndex}.csv`), table.csv);
30869
+ await writeFile9(join12(dir, `${stem}.table-${table.tableIndex}.csv`), table.csv);
29075
30870
  tables++;
29076
30871
  }
29077
30872
  }
29078
- await writeFile8(join12(dir, "results.csv"), rows.map((row) => `${row}\r
30873
+ await writeFile9(join12(dir, "results.csv"), rows.map((row) => `${row}\r
29079
30874
  `).join(""));
29080
30875
  io.stderr(`octocrawl: wrote ${pages.length} results (results.jsonl, results.csv), ${markdowns} Markdown files and ${tables} CSV tables to ${dir}`);
29081
30876
  }
@@ -29132,11 +30927,11 @@ function slug(url) {
29132
30927
  return text2.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "page";
29133
30928
  }
29134
30929
  async function urlsFrom(file) {
29135
- return (await readFile6(file, "utf8")).split(/\r?\n/).map((line) => line.replace(/#.*/, "").trim()).filter((line) => line !== "");
30930
+ return (await readFile7(file, "utf8")).split(/\r?\n/).map((line) => line.replace(/#.*/, "").trim()).filter((line) => line !== "");
29136
30931
  }
29137
30932
  async function serve2(argv, io) {
29138
30933
  if (argv.includes("--help")) {
29139
- io.stdout("usage: octocrawl serve [--port <n>] [--host <addr>] [--hosted --token <t>] [--rate-limit-per-minute <n>] [--task-root <dir>]\nRuns the local API (as w2l-api does) on --task-root, else W2L_TASK_ROOT, else .w2l/api, until SIGINT.");
30934
+ io.stdout("usage: octocrawl serve [--port <n>] [--host <addr>] [--hosted --token <t>] [--rate-limit-per-minute <n>] [--task-root <dir>] [--access-grant <file>]\nRuns the local API (as w2l-api does) on --task-root, else W2L_TASK_ROOT, else .w2l/api, until SIGINT. --access-grant (or W2L_ACCESS_GRANT) names what enhanced access may do (ADR 0005).");
29140
30935
  return 0;
29141
30936
  }
29142
30937
  const rest = [...argv];