@octocrawl/cli 0.3.1 → 0.3.2

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 +1964 -359
  3. package/package.json +19 -2
package/dist/cli.js CHANGED
@@ -10,14 +10,32 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // packages/contracts/dist/status.js
13
- var CONTENTFUL_STATUS;
13
+ var RESULT_STATUS, CONTENTFUL_STATUS, BLOCK_REASON;
14
14
  var init_status = __esm({
15
15
  "packages/contracts/dist/status.js"() {
16
16
  "use strict";
17
+ RESULT_STATUS = [
18
+ "success",
19
+ "partial",
20
+ "empty_verified",
21
+ "blocked",
22
+ "failed",
23
+ "cancelled",
24
+ "budget_exceeded",
25
+ "duplicate"
26
+ ];
17
27
  CONTENTFUL_STATUS = /* @__PURE__ */ new Set([
18
28
  "success",
19
29
  "partial"
20
30
  ]);
31
+ BLOCK_REASON = [
32
+ "cloudflare_challenge",
33
+ "captcha",
34
+ "rate_limit",
35
+ "login_wall",
36
+ "geo_restricted",
37
+ "bot_detected_generic"
38
+ ];
21
39
  }
22
40
  });
23
41
 
@@ -46,6 +64,21 @@ var init_tokens = __esm({
46
64
  });
47
65
 
48
66
  // packages/contracts/dist/result.js
67
+ function givenUpPaidCalls(trace) {
68
+ return trace.filter((event) => event.event === "paid_calls").map((event) => {
69
+ const calls = Array.isArray(event.detail?.calls) ? event.detail.calls : [];
70
+ return { ...event, detail: { ...event.detail, calls: calls.map((call) => ({ ...call, answer: false })) } };
71
+ });
72
+ }
73
+ function carryPaidCalls(error, event) {
74
+ if (typeof error === "object" && error !== null && Object.isExtensible(error))
75
+ Object.defineProperty(error, "paidCalls", { value: event, enumerable: false, configurable: true });
76
+ return error;
77
+ }
78
+ function paidCallsOfError(error) {
79
+ const event = typeof error === "object" && error !== null ? error.paidCalls : void 0;
80
+ return typeof event === "object" && event !== null && event.event === "paid_calls" ? event : null;
81
+ }
49
82
  var init_result = __esm({
50
83
  "packages/contracts/dist/result.js"() {
51
84
  "use strict";
@@ -1268,6 +1301,20 @@ function cacheStateOf(trace) {
1268
1301
  const cachedAt = event.detail?.cachedAt;
1269
1302
  return { cacheState: "hit", ...typeof cachedAt === "string" ? { cachedAt } : {} };
1270
1303
  }
1304
+ function readAccessChoice(rec, takesMyBrowser) {
1305
+ if (rec.lane !== void 0 && !REQUEST_LANES.includes(rec.lane))
1306
+ throw new RequestError(`lane must be one of: ${REQUEST_LANES.join(", ")}`);
1307
+ if (rec.access === void 0)
1308
+ return rec.lane === void 0 ? {} : { lane: rec.lane };
1309
+ if (!ACCESS_CHOICES.includes(rec.access))
1310
+ throw new RequestError(`access must be one of: ${ACCESS_CHOICES.join(", ")}`);
1311
+ const access = rec.access;
1312
+ if (access === "my-browser" && !takesMyBrowser)
1313
+ throw new RequestError("access my-browser reads pages in your own Chrome, one you name at a time: a crawl does not take it; list the pages and send them as a batch", "unsupported_parameter", { parameters: ["access"] });
1314
+ if (rec.lane !== void 0 && access !== "my-browser")
1315
+ throw new RequestError(`lane my-browser and access ${access} ask for two different routes: send one`, "unsupported_parameter", { parameters: ["lane", "access"] });
1316
+ return access === "my-browser" ? { access, lane: "my-browser" } : { access };
1317
+ }
1271
1318
  function warningOf(warnings) {
1272
1319
  return warnings === void 0 || warnings.length === 0 ? void 0 : warnings.map((warning) => warning.message).join(" ");
1273
1320
  }
@@ -2224,6 +2271,7 @@ function parseScrapeRequest(body) {
2224
2271
  if (rec.handoff !== void 0 && typeof rec.handoff !== "boolean" && (rec.handoff === null || typeof rec.handoff !== "object" || Array.isArray(rec.handoff)))
2225
2272
  throw new RequestError("handoff must be true or { waitMs }");
2226
2273
  const handoff = rec.handoff === void 0 || rec.handoff === false ? void 0 : rec.handoff === true ? {} : parseBatchHandoffRequest(rec.handoff);
2274
+ const choice = readAccessChoice(rec, true);
2227
2275
  const mode = readMode(rec.mode);
2228
2276
  const page = readPageOptions(rec, mode);
2229
2277
  checkMobileMode(mode, page.mobile);
@@ -2237,6 +2285,7 @@ function parseScrapeRequest(body) {
2237
2285
  ...page,
2238
2286
  ...robotsOverride === void 0 ? {} : { robotsOverride },
2239
2287
  ...handoff === void 0 ? {} : { handoff },
2288
+ ...choice,
2240
2289
  ...readAttribution(rec)
2241
2290
  };
2242
2291
  checkScreenshotViewport(req.mobile, req.formats, req.actions);
@@ -2273,9 +2322,11 @@ function parseCrawlStartRequest(body) {
2273
2322
  const idempotencyKey = readIdempotencyKey(rec.idempotencyKey);
2274
2323
  const webhook = readWebhook(rec.webhook);
2275
2324
  const ignoreRobotsTxt = readBoolean(rec.ignoreRobotsTxt, "ignoreRobotsTxt");
2325
+ const { access } = readAccessChoice(rec, false);
2276
2326
  const req = {
2277
2327
  url: readUrl(rec.url),
2278
2328
  mode,
2329
+ ...access === void 0 ? {} : { access },
2279
2330
  maxPages: readBound(rec.maxPages, "maxPages", 1),
2280
2331
  maxDepth: readBound(rec.maxDepth, "maxDepth", 0),
2281
2332
  useCached,
@@ -2384,6 +2435,9 @@ function parseBatchStartRequest(body) {
2384
2435
  const mode = readMode(rec.mode);
2385
2436
  if (mode === "authed" && webhook !== void 0)
2386
2437
  throw new RequestError("webhook is not available in mode 'authed': pages read with your session are not sent to another address; read them from the batch");
2438
+ const choice = readAccessChoice(rec, true);
2439
+ if (choice.lane !== void 0 && webhook !== void 0)
2440
+ throw new RequestError("webhook is not available on lane my-browser: pages read in your own Chrome are not sent to another address; read them from the batch", "unsupported_parameter", { parameters: ["lane", "webhook"] });
2387
2441
  const page = readPageOptions(rec, mode);
2388
2442
  checkMobileMode(mode, page.mobile);
2389
2443
  const req = {
@@ -2401,6 +2455,7 @@ function parseBatchStartRequest(body) {
2401
2455
  ...idempotencyKey === void 0 ? {} : { idempotencyKey },
2402
2456
  ...appendToId === void 0 ? {} : { appendToId },
2403
2457
  ...webhook === void 0 ? {} : { webhook },
2458
+ ...choice,
2404
2459
  ...readAttribution(rec)
2405
2460
  };
2406
2461
  checkScreenshotViewport(req.mobile, req.formats, req.actions);
@@ -2437,7 +2492,7 @@ function parseCrawlPageQuery(query) {
2437
2492
  ...query.includeDuplicates === void 0 ? {} : { includeDuplicates: query.includeDuplicates === "true" }
2438
2493
  };
2439
2494
  }
2440
- var CRAWL_MODES, DEFAULT_SCRAPE_TIMEOUT_MS, MIN_SCRAPE_TIMEOUT_MS, MAX_WAIT_FOR_MS, MAX_CACHE_AGE_MS, WEBHOOK_EVENTS, MAX_WEBHOOK_URL_LENGTH, MAX_WEBHOOK_HEADERS, MAX_WEBHOOK_HEADERS_BYTES, MAX_WEBHOOK_METADATA_ENTRIES, MAX_WEBHOOK_METADATA_VALUE_LENGTH, MAX_WEBHOOK_METADATA_BYTES, DEFAULT_MAP_LIMIT, MAX_MAP_LIMIT, DEFAULT_MAP_TIMEOUT_MS, MAX_MAP_TIMEOUT_MS, HOSTED_MAP_MAX_LIMIT, HOSTED_MAP_MAX_TIMEOUT_MS, MAP_SEARCH_MAX_CHARS, MAP_SEARCH_MAX_WORDS, IDEMPOTENCY_KEY_HEADERS, MAX_IDEMPOTENCY_KEY_LENGTH, HANDOFF_REASONS, MAX_HANDOFF_WAIT_MS, BATCH_ERRORS_MAX_LIMIT, WS_TOKEN_PROTOCOL_PREFIX, API_ERROR_STATUS, RATE_LIMITED_CODE, RATE_LIMITED_STATUS, RequestError, REFUSAL_HINTS, PAGE_KEYS, ATTRIBUTION_KEYS, SCRAPE_KEYS, CRAWL_SCOPE_KEYS, CRAWL_KEYS, BATCH_SCOPE_NOOP_KEYS, BATCH_KEYS, BATCH_APPEND_KEYS, ROBOTS_OVERRIDE_KEYS, MAP_SCOPE_KEYS, MAP_KEYS, WEBHOOK_KEYS, WEBHOOK_RESERVED_HEADERS, WEBHOOK_SECRET_ENV, WEBHOOK_HEADERS_MESSAGE, WEBHOOK_METADATA_MESSAGE, WEBHOOK_EVENTS_MESSAGE, utf8Bytes, SCHEMA_STRUCTURE, SCHEMA_NUMBERS, SCHEMA_COUNTS, SCHEMA_ANNOTATIONS, SCHEMA_ROOT, SCHEMA_KEYS, SCHEMA_TYPES, PRIMITIVE_TYPES, SCHEMA_DIALECTS, STRING_FORMATS, FORMAT_NAMES, SCREENSHOT_FULL_PAGE_ALIAS, JSON_FORMAT_KEYS, ATTRIBUTES_FORMAT_KEYS, ATTRIBUTE_SELECTOR_KEYS, SCREENSHOT_FORMAT_KEYS, SCREENSHOT_VIEWPORT_KEYS, MIN_SCREENSHOT_VIEWPORT, ATTRIBUTE_NAME, ATTRIBUTES_SELECTORS_MESSAGE, SCREENSHOT_VIEWPORT_MESSAGE, SCREENSHOT_ENTRIES_MESSAGE, FORMAT_ENTRY_MESSAGE, LIST_FORMAT_KEYS, LIST_FIELD_KEYS, LIST_ATTRIBUTE, MAX_REQUEST_HEADERS, MAX_REQUEST_HEADER_VALUE_LENGTH, HEADER_NAME, CREDENTIAL_HEADERS, TRANSPORT_HEADERS, ATTRIBUTION_LABEL, PDF_PARSER_KEYS, ACTION_KEYS;
2495
+ var CRAWL_MODES, DEFAULT_SCRAPE_TIMEOUT_MS, MIN_SCRAPE_TIMEOUT_MS, MAX_WAIT_FOR_MS, MAX_CACHE_AGE_MS, ACCESS_CHOICES, WEBHOOK_EVENTS, MAX_WEBHOOK_URL_LENGTH, MAX_WEBHOOK_HEADERS, MAX_WEBHOOK_HEADERS_BYTES, MAX_WEBHOOK_METADATA_ENTRIES, MAX_WEBHOOK_METADATA_VALUE_LENGTH, MAX_WEBHOOK_METADATA_BYTES, DEFAULT_MAP_LIMIT, MAX_MAP_LIMIT, DEFAULT_MAP_TIMEOUT_MS, MAX_MAP_TIMEOUT_MS, HOSTED_MAP_MAX_LIMIT, HOSTED_MAP_MAX_TIMEOUT_MS, MAP_SEARCH_MAX_CHARS, MAP_SEARCH_MAX_WORDS, IDEMPOTENCY_KEY_HEADERS, MAX_IDEMPOTENCY_KEY_LENGTH, HANDOFF_REASONS, MAX_HANDOFF_WAIT_MS, BATCH_ERRORS_MAX_LIMIT, WS_TOKEN_PROTOCOL_PREFIX, API_ERROR_STATUS, RATE_LIMITED_CODE, RATE_LIMITED_STATUS, RequestError, REFUSAL_HINTS, PAGE_KEYS, ATTRIBUTION_KEYS, SCRAPE_KEYS, REQUEST_LANES, CRAWL_SCOPE_KEYS, CRAWL_KEYS, BATCH_SCOPE_NOOP_KEYS, BATCH_KEYS, BATCH_APPEND_KEYS, ROBOTS_OVERRIDE_KEYS, MAP_SCOPE_KEYS, MAP_KEYS, WEBHOOK_KEYS, WEBHOOK_RESERVED_HEADERS, WEBHOOK_SECRET_ENV, WEBHOOK_HEADERS_MESSAGE, WEBHOOK_METADATA_MESSAGE, WEBHOOK_EVENTS_MESSAGE, utf8Bytes, SCHEMA_STRUCTURE, SCHEMA_NUMBERS, SCHEMA_COUNTS, SCHEMA_ANNOTATIONS, SCHEMA_ROOT, SCHEMA_KEYS, SCHEMA_TYPES, PRIMITIVE_TYPES, SCHEMA_DIALECTS, STRING_FORMATS, FORMAT_NAMES, SCREENSHOT_FULL_PAGE_ALIAS, JSON_FORMAT_KEYS, ATTRIBUTES_FORMAT_KEYS, ATTRIBUTE_SELECTOR_KEYS, SCREENSHOT_FORMAT_KEYS, SCREENSHOT_VIEWPORT_KEYS, MIN_SCREENSHOT_VIEWPORT, ATTRIBUTE_NAME, ATTRIBUTES_SELECTORS_MESSAGE, SCREENSHOT_VIEWPORT_MESSAGE, SCREENSHOT_ENTRIES_MESSAGE, FORMAT_ENTRY_MESSAGE, LIST_FORMAT_KEYS, LIST_FIELD_KEYS, LIST_ATTRIBUTE, MAX_REQUEST_HEADERS, MAX_REQUEST_HEADER_VALUE_LENGTH, HEADER_NAME, CREDENTIAL_HEADERS, TRANSPORT_HEADERS, ATTRIBUTION_LABEL, PDF_PARSER_KEYS, ACTION_KEYS;
2441
2496
  var init_api = __esm({
2442
2497
  "packages/contracts/dist/api.js"() {
2443
2498
  "use strict";
@@ -2452,6 +2507,7 @@ var init_api = __esm({
2452
2507
  MIN_SCRAPE_TIMEOUT_MS = 1e3;
2453
2508
  MAX_WAIT_FOR_MS = 6e4;
2454
2509
  MAX_CACHE_AGE_MS = 31536e7;
2510
+ ACCESS_CHOICES = ["standard", "enhanced", "my-browser"];
2455
2511
  WEBHOOK_EVENTS = ["started", "page", "completed", "failed", "cancelled"];
2456
2512
  MAX_WEBHOOK_URL_LENGTH = 2048;
2457
2513
  MAX_WEBHOOK_HEADERS = 32;
@@ -2512,11 +2568,12 @@ var init_api = __esm({
2512
2568
  };
2513
2569
  PAGE_KEYS = ["onlyMainContent", "waitFor", "timeout", "maxFileBytes", "includeTags", "excludeTags", "headers", "mobile", "skipTlsVerification", "fastMode", "blockAds", "removeBase64Images", "parsers", "maxAge", "minAge", "storeInCache", "lockdown"];
2514
2570
  ATTRIBUTION_KEYS = ["origin", "integration"];
2515
- SCRAPE_KEYS = ["url", "mode", "allowlistedDomains", "formats", "includeLinks", "debug", "robotsOverride", "actions", "handoff", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2571
+ SCRAPE_KEYS = ["url", "mode", "allowlistedDomains", "formats", "includeLinks", "debug", "robotsOverride", "actions", "handoff", "lane", "access", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2572
+ REQUEST_LANES = ["my-browser"];
2516
2573
  CRAWL_SCOPE_KEYS = ["regexOnFullURL", "ignoreQueryParameters", "deduplicateSimilarURLs", "crawlEntireDomain", "allowSubdomains", "allowExternalLinks"];
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];
2574
+ CRAWL_KEYS = ["url", "mode", "access", "maxPages", "maxDepth", "useCached", "allowlistedDomains", "formats", "includeLinks", "includePaths", "excludePaths", ...CRAWL_SCOPE_KEYS, "sitemap", "maxConcurrency", "idempotencyKey", "webhook", "ignoreRobotsTxt", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2518
2575
  BATCH_SCOPE_NOOP_KEYS = { allowExternalLinks: "allowExternalLinks", includeSubdomains: "allowSubdomains" };
2519
- BATCH_KEYS = ["urls", "mode", "formats", "includeLinks", "robotsOverrides", "maxConcurrency", "ignoreInvalidURLs", "allowExternalLinks", "includeSubdomains", "idempotencyKey", "appendToId", "webhook", "actions", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2576
+ BATCH_KEYS = ["urls", "mode", "lane", "access", "formats", "includeLinks", "robotsOverrides", "maxConcurrency", "ignoreInvalidURLs", "allowExternalLinks", "includeSubdomains", "idempotencyKey", "appendToId", "webhook", "actions", ...PAGE_KEYS, ...ATTRIBUTION_KEYS];
2520
2577
  BATCH_APPEND_KEYS = ["urls", "appendToId", "ignoreInvalidURLs", "allowExternalLinks", "includeSubdomains", "idempotencyKey", "robotsOverrides", ...ATTRIBUTION_KEYS];
2521
2578
  ROBOTS_OVERRIDE_KEYS = ["reason", "recordedBy"];
2522
2579
  MAP_SCOPE_KEYS = ["includeSubdomains", "ignoreQueryParameters", "regexOnFullURL", "crawlEntireDomain", "deduplicateSimilarURLs"];
@@ -2694,8 +2751,16 @@ function wrapJobWebhook(envelope, result) {
2694
2751
  }
2695
2752
  function readShimScrapeOptions(rec, prefix, keys, problems) {
2696
2753
  const takesActions = prefix === "";
2697
- checkShimKeys(rec, prefix, [...keys, "formats", "parsers", "parsePDF", ...SHIM_PAGE_OPTIONS, ...takesActions ? ["actions"] : []], problems);
2754
+ checkShimKeys(rec, prefix, [...keys, "formats", "parsers", "parsePDF", "proxy", ...SHIM_PAGE_OPTIONS, ...takesActions ? ["actions"] : []], problems);
2698
2755
  const mapped = {};
2756
+ if (rec.proxy !== void 0) {
2757
+ if (rec.proxy === "basic")
2758
+ mapped.access = "standard";
2759
+ else if (rec.proxy === "stealth" || rec.proxy === "auto" || rec.proxy === "enhanced")
2760
+ mapped.access = "enhanced";
2761
+ else
2762
+ throw new RequestError(`${prefix}proxy must be one of: basic, stealth, auto, enhanced`);
2763
+ }
2699
2764
  for (const key of SHIM_PAGE_OPTIONS)
2700
2765
  if (rec[key] !== void 0)
2701
2766
  mapped[key] = rec[key];
@@ -3046,7 +3111,12 @@ var init_evidenceRecord = __esm({
3046
3111
  pageActions: keysOf()(["steps", "scriptRan"]),
3047
3112
  pageActionStep: keysOf()(["type", "outcome"]),
3048
3113
  requestHeader: keysOf()(["name", "valueSha256"]),
3049
- access: keysOf()(["route", "executor", "executorVersion", "profile", "externalCostUsd"])
3114
+ access: keysOf()(["route", "executor", "executorVersion", "profile", "externalCostUsd", "completion", "egress", "session", "paidCalls", "grant"]),
3115
+ accessEgress: keysOf()(["proxy", "source", "switchedFrom", "exit"]),
3116
+ accessEgressExit: keysOf()(["ip", "country", "observedAt"]),
3117
+ accessSession: keysOf()(["id"]),
3118
+ accessPaidCall: keysOf()(["provider", "rung", "capabilities", "ceilingUsd", "chargedUsd", "reportedCostUsd", "outcome", "reason", "answer"]),
3119
+ accessGrant: keysOf()(["sha256", "tier", "attestedAt"])
3050
3120
  };
3051
3121
  }
3052
3122
  });
@@ -3751,11 +3821,15 @@ function classifyGate(res) {
3751
3821
  if (/<title[^>]*>\s*reddit - prove your humanity\s*<\/title>/.test(lower) && lower.includes("google.com/recaptcha/api.js")) {
3752
3822
  return { reason: "captcha", signals: ["reddit_captcha_title", "recaptcha_script"] };
3753
3823
  }
3824
+ if (/<title[^>]*>\s*best buy international: select your country/.test(lower) && lower.includes("intl=nosplash")) {
3825
+ return { reason: "geo_restricted", signals: ["bestbuy_country_selector"] };
3826
+ }
3754
3827
  const px = perimeterXChallenge(head, lower);
3755
3828
  if (px !== null)
3756
3829
  return { reason: "captcha", signals: px };
3830
+ const wall = res.status >= 200 && res.status < 300 ? shortWallPage(res.body) : null;
3757
3831
  if (contentful)
3758
- return null;
3832
+ return wall === null ? null : { reason: "bot_detected_generic", signals: wall };
3759
3833
  const captcha = matched(lower, CAPTCHA_MARKERS);
3760
3834
  if (captcha.length > 0) {
3761
3835
  return { reason: "captcha", signals: captcha };
@@ -3771,6 +3845,8 @@ function classifyGate(res) {
3771
3845
  signals.push("heading_sign_in");
3772
3846
  return { reason: "login_wall", signals };
3773
3847
  }
3848
+ if (wall !== null)
3849
+ return { reason: "bot_detected_generic", signals: wall };
3774
3850
  const geo = matched(lower, GEO_MARKERS);
3775
3851
  if (geo.length > 0) {
3776
3852
  return { reason: "geo_restricted", signals: geo };
@@ -3799,6 +3875,104 @@ function classifyGate(res) {
3799
3875
  }
3800
3876
  return null;
3801
3877
  }
3878
+ function shortWallPage(body) {
3879
+ if (!/\b(?:reference|incident|unusual|suspicious)\b/i.test(body))
3880
+ return null;
3881
+ const page = pageText(body);
3882
+ if (page === null)
3883
+ return null;
3884
+ if (AKAMAI_REFERENCE.test(page.text))
3885
+ return ["akamai_reference", "short_page"];
3886
+ if (page.headings.some((h) => UNUSUAL_ACTIVITY.test(h)) && AUTOMATED_TRAFFIC.test(page.text))
3887
+ return ["heading_unusual_activity", "text_automated_traffic", "short_page"];
3888
+ return null;
3889
+ }
3890
+ function pageText(body) {
3891
+ const visible = [];
3892
+ let visibleLength = 0;
3893
+ const headings = [];
3894
+ let heading2 = null;
3895
+ let inHead = false;
3896
+ let at = 0;
3897
+ const text2 = (raw) => {
3898
+ if (raw.length === 0)
3899
+ return;
3900
+ if (heading2 !== null)
3901
+ heading2.parts.push(raw);
3902
+ if (inHead)
3903
+ return;
3904
+ visible.push(raw);
3905
+ visibleLength += collapse(raw).length + 1;
3906
+ };
3907
+ while (at < body.length) {
3908
+ const open = body.indexOf("<", at);
3909
+ if (open < 0) {
3910
+ text2(body.slice(at));
3911
+ break;
3912
+ }
3913
+ text2(body.slice(at, open));
3914
+ if (visibleLength > WALL_PAGE_MAX_TEXT * 4)
3915
+ return null;
3916
+ if (body.startsWith("<!--", open)) {
3917
+ const close = body.indexOf("-->", open + 4);
3918
+ if (close < 0)
3919
+ break;
3920
+ at = close + 3;
3921
+ continue;
3922
+ }
3923
+ const end = body.indexOf(">", open + 1);
3924
+ if (end < 0)
3925
+ break;
3926
+ const tag = /^<(\/?)([a-z][a-z0-9-]*)(?=[\s/>])/i.exec(body.slice(open, Math.min(end + 1, open + 64)));
3927
+ at = end + 1;
3928
+ if (tag === null)
3929
+ continue;
3930
+ const [, closing, tagName] = tag;
3931
+ const name = tagName.toLowerCase();
3932
+ if (name === "head") {
3933
+ inHead = closing === "";
3934
+ continue;
3935
+ }
3936
+ if (name === "body") {
3937
+ inHead = false;
3938
+ continue;
3939
+ }
3940
+ if (HEADING_ELEMENTS.has(name)) {
3941
+ if (closing === "")
3942
+ heading2 = { name, parts: [] };
3943
+ else if (heading2 !== null && heading2.name === name) {
3944
+ headings.push(collapse(decodeEntities(heading2.parts.join(" "))));
3945
+ heading2 = null;
3946
+ }
3947
+ continue;
3948
+ }
3949
+ if (closing === "" && HIDDEN_ELEMENTS.has(name) && body[end - 1] !== "/") {
3950
+ const closer = new RegExp(`</${name}`, "gi");
3951
+ closer.lastIndex = at;
3952
+ const close = closer.exec(body)?.index ?? -1;
3953
+ if (close < 0)
3954
+ break;
3955
+ const closeEnd = body.indexOf(">", close);
3956
+ if (closeEnd < 0)
3957
+ break;
3958
+ at = closeEnd + 1;
3959
+ }
3960
+ }
3961
+ const visibleText = collapse(decodeEntities(visible.join(" ")));
3962
+ return visibleText.length > WALL_PAGE_MAX_TEXT ? null : { text: visibleText, headings };
3963
+ }
3964
+ function collapse(text2) {
3965
+ return text2.replace(/\s+/g, " ").trim();
3966
+ }
3967
+ function decodeEntities(text2) {
3968
+ const named = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
3969
+ return text2.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (whole, dec, hex, name) => {
3970
+ const code = dec !== void 0 ? Number(dec) : hex !== void 0 ? parseInt(hex, 16) : null;
3971
+ if (code !== null)
3972
+ return code <= 1114111 ? String.fromCodePoint(code) : whole;
3973
+ return named[name.toLowerCase()] ?? whole;
3974
+ });
3975
+ }
3802
3976
  function escalationForBlock(reason2, from) {
3803
3977
  const trigger = `blocked:${reason2}`;
3804
3978
  const to = nextLane(reason2, from);
@@ -3828,7 +4002,7 @@ function nextLane(reason2, from) {
3828
4002
  return from === "browser_proxy" ? null : "browser_proxy";
3829
4003
  }
3830
4004
  }
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;
4005
+ 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, WALL_PAGE_MAX_TEXT, AKAMAI_REFERENCE, UNUSUAL_ACTIVITY, AUTOMATED_TRAFFIC, HIDDEN_ELEMENTS, HEADING_ELEMENTS;
3832
4006
  var init_gate = __esm({
3833
4007
  "packages/http-core/dist/gate.js"() {
3834
4008
  "use strict";
@@ -3919,6 +4093,12 @@ var init_gate = __esm({
3919
4093
  CF_JSD_PATH = "/cdn-cgi/challenge-platform/scripts/jsd/";
3920
4094
  PX_CAPTCHA_SCRIPT = /\/captcha\/captcha\.js|captcha\.px-(?:cdn|cloud)\.net\/[^"'\s<>]*captcha\.js/;
3921
4095
  PX_CAPTCHA_CONTAINER = /<[a-z][^>]*\sid\s*=\s*["']?px-captcha(?=["'\s>/])/i;
4096
+ WALL_PAGE_MAX_TEXT = 1500;
4097
+ AKAMAI_REFERENCE = /\b(?:reference|incident)\b\s*(?:number|id|no\.?)?\s*[:#]?\s*#?\s*\d{1,3}\.[0-9a-f]{6,8}\.\d{10}\.[0-9a-f]{6,12}\b/i;
4098
+ UNUSUAL_ACTIVITY = /\b(?:unusual|suspicious) (?:activity|traffic)\b/i;
4099
+ AUTOMATED_TRAFFIC = /\bautomated (?:traffic|access|requests|queries|browsing)\b/i;
4100
+ HIDDEN_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "svg"]);
4101
+ HEADING_ELEMENTS = /* @__PURE__ */ new Set(["h1", "h2", "h3", "title"]);
3922
4102
  }
3923
4103
  });
3924
4104
 
@@ -4411,6 +4591,55 @@ var init_provider = __esm({
4411
4591
  });
4412
4592
 
4413
4593
  // packages/http-core/dist/accessGrant.js
4594
+ function accessGrantRef(grant) {
4595
+ return { sha256: grant.sha256 ?? null, tier: grant.tier, attestedAt: grant.attestation?.at ?? null };
4596
+ }
4597
+ function tariffCeilingUsd(tariff, floorMs = 0) {
4598
+ const sessionMs = Math.max(tariff.maxSessionMs ?? 0, tariff.minBilledMs, tariff.maxSessionMs === null ? 0 : floorMs);
4599
+ const step = tariff.billingIncrementMs > 0 ? tariff.billingIncrementMs : 1;
4600
+ const billedMs = Math.ceil(sessionMs / step) * step;
4601
+ return tariff.perCallUsd + tariff.perHourUsd * billedMs / 36e5;
4602
+ }
4603
+ function readTariff(vendor, input, problems) {
4604
+ const field3 = `tariffs.${vendor}`;
4605
+ if (!isRecord(input)) {
4606
+ problems.push({ field: field3, kind: "invalid", reason: "a tariff is an object of prices and limits" });
4607
+ return null;
4608
+ }
4609
+ const known = /* @__PURE__ */ new Set([...TARIFF_PRICES, ...TARIFF_LIMITS]);
4610
+ if (input.perGbUsd !== void 0 || input.maxBytes !== void 0) {
4611
+ problems.push({ field: `${field3}.perGbUsd`, kind: "invalid", reason: "a per-GB price (a provider's proxy bandwidth) has no ceiling: the bytes its browser receives across every target cannot be counted from here; turn the provider's proxies off and leave perGbUsd and maxBytes out" });
4612
+ }
4613
+ for (const key of Object.keys(input))
4614
+ if (!known.has(key) && key !== "perGbUsd" && key !== "maxBytes")
4615
+ problems.push({ field: `${field3}.${key}`, kind: "invalid", reason: `unknown tariff field; one of ${[...known].join(", ")}` });
4616
+ const price = (key) => {
4617
+ const v = input[key];
4618
+ if (v === void 0 || v === null)
4619
+ return 0;
4620
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
4621
+ problems.push({ field: `${field3}.${key}`, kind: "invalid", reason: "a non-negative number of US dollars" });
4622
+ return 0;
4623
+ }
4624
+ return v;
4625
+ };
4626
+ const limit = (key) => {
4627
+ const v = input[key];
4628
+ if (v === void 0 || v === null)
4629
+ return null;
4630
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
4631
+ problems.push({ field: `${field3}.${key}`, kind: "invalid", reason: "a positive whole number" });
4632
+ return null;
4633
+ }
4634
+ return v;
4635
+ };
4636
+ const tariff = { perCallUsd: price("perCallUsd"), perHourUsd: price("perHourUsd"), maxSessionMs: limit("maxSessionMs"), minBilledMs: limit("minBilledMs") ?? 0, billingIncrementMs: limit("billingIncrementMs") ?? 1 };
4637
+ if (TARIFF_PRICES.every((key) => input[key] === void 0 || input[key] === null))
4638
+ problems.push({ field: field3, kind: "invalid", reason: "a tariff names at least one price: perCallUsd or perHourUsd" });
4639
+ if (tariff.perHourUsd > 0 && tariff.maxSessionMs === null)
4640
+ problems.push({ field: `${field3}.maxSessionMs`, kind: "invalid", reason: "a per-hour price needs the longest session a call may hold, or its cost has no ceiling" });
4641
+ return tariff;
4642
+ }
4414
4643
  function isRecord(v) {
4415
4644
  return typeof v === "object" && v !== null && !Array.isArray(v);
4416
4645
  }
@@ -4498,9 +4727,20 @@ function normalizeAccessGrant(input) {
4498
4727
  reason: `${enhancedOnly.join(", ")} needs an attestation: who accepted it, when, and the statement they accepted`
4499
4728
  });
4500
4729
  }
4730
+ const tariffsIn = input.tariffs ?? {};
4731
+ const tariffs = {};
4732
+ if (!isRecord(tariffsIn)) {
4733
+ problems.push({ field: "tariffs", kind: "invalid", reason: "tariffs is an object of provider ids to their prices" });
4734
+ } else {
4735
+ for (const [vendor, value] of Object.entries(tariffsIn)) {
4736
+ const tariff = readTariff(vendor, value, problems);
4737
+ if (tariff !== null)
4738
+ tariffs[vendor] = tariff;
4739
+ }
4740
+ }
4501
4741
  if (problems.length > 0)
4502
4742
  return { ok: false, problems };
4503
- return { ok: true, grant: { tier, capabilities, budget: { perRequestUsd, perRunUsd }, scope: { hosts }, attestation } };
4743
+ return { ok: true, grant: { tier, capabilities, budget: { perRequestUsd, perRunUsd }, scope: { hosts }, attestation, tariffs } };
4504
4744
  }
4505
4745
  function accessGrantFromText(text2) {
4506
4746
  let input;
@@ -4517,14 +4757,17 @@ ${result.problems.map((p) => ` - ${p.field}: ${p.reason}`).join("\n")}`);
4517
4757
  if (result.grant.scope.hosts !== null) {
4518
4758
  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
4759
  }
4520
- return result.grant;
4760
+ return { ...result.grant, sha256: sha256Utf8(text2) };
4521
4761
  }
4522
- var ACCESS_TIERS;
4762
+ var ACCESS_TIERS, TARIFF_PRICES, TARIFF_LIMITS;
4523
4763
  var init_accessGrant = __esm({
4524
4764
  "packages/http-core/dist/accessGrant.js"() {
4525
4765
  "use strict";
4766
+ init_hash();
4526
4767
  init_vendor();
4527
4768
  ACCESS_TIERS = ["standard", "enhanced", "my_browser"];
4769
+ TARIFF_PRICES = ["perCallUsd", "perHourUsd"];
4770
+ TARIFF_LIMITS = ["maxSessionMs", "minBilledMs", "billingIncrementMs"];
4528
4771
  }
4529
4772
  });
4530
4773
 
@@ -4606,7 +4849,7 @@ function evaluateGovernance(url, policy) {
4606
4849
  if (permitted === void 0) {
4607
4850
  return { allowed: false, permittedChannels: [], reason: `unknown mode: ${policy.mode}` };
4608
4851
  }
4609
- return { allowed: true, permittedChannels: permitted, reason: null };
4852
+ return { allowed: true, permittedChannels: policy.enhanced === true && !permitted.includes("provider") ? [...permitted, "provider"] : permitted, reason: null };
4610
4853
  }
4611
4854
  var MODE_CHANNELS;
4612
4855
  var init_governance = __esm({
@@ -4885,6 +5128,67 @@ var init_sitemap = __esm({
4885
5128
  }
4886
5129
  });
4887
5130
 
5131
+ // packages/http-core/dist/spend.js
5132
+ function createSpendLedger(capUsd, alreadySettledUsd = 0) {
5133
+ if (capUsd !== null && (!Number.isFinite(capUsd) || capUsd < 0))
5134
+ throw new Error("a spend cap is a non-negative number of US dollars, or null");
5135
+ const ledger = new Ledger(capUsd, null);
5136
+ if (Number.isFinite(alreadySettledUsd) && alreadySettledUsd > 0)
5137
+ ledger.settledUsd = alreadySettledUsd;
5138
+ return ledger;
5139
+ }
5140
+ var EPSILON, Ledger;
5141
+ var init_spend = __esm({
5142
+ "packages/http-core/dist/spend.js"() {
5143
+ "use strict";
5144
+ EPSILON = 1e-9;
5145
+ Ledger = class _Ledger {
5146
+ capUsd;
5147
+ parent;
5148
+ settledUsd = 0;
5149
+ reservedUsd = 0;
5150
+ constructor(capUsd, parent) {
5151
+ this.capUsd = capUsd;
5152
+ this.parent = parent;
5153
+ }
5154
+ fits(amountUsd) {
5155
+ const own = this.capUsd === null || this.settledUsd + this.reservedUsd + amountUsd <= this.capUsd + EPSILON;
5156
+ return own && (this.parent === null || this.parent.fits(amountUsd));
5157
+ }
5158
+ add(field3, amountUsd) {
5159
+ this[field3] += amountUsd;
5160
+ this.parent?.add(field3, amountUsd);
5161
+ }
5162
+ reserve(ceilingUsd) {
5163
+ if (!Number.isFinite(ceilingUsd) || ceilingUsd < 0 || !this.fits(ceilingUsd))
5164
+ return null;
5165
+ this.add("reservedUsd", ceilingUsd);
5166
+ let open = true;
5167
+ return {
5168
+ settle: (reportedUsd) => {
5169
+ if (!open)
5170
+ return 0;
5171
+ open = false;
5172
+ const charged = typeof reportedUsd === "number" && Number.isFinite(reportedUsd) && reportedUsd >= 0 ? reportedUsd : ceilingUsd;
5173
+ this.add("reservedUsd", -ceilingUsd);
5174
+ this.add("settledUsd", charged);
5175
+ return charged;
5176
+ },
5177
+ release: () => {
5178
+ if (!open)
5179
+ return;
5180
+ open = false;
5181
+ this.add("reservedUsd", -ceilingUsd);
5182
+ }
5183
+ };
5184
+ }
5185
+ child(capUsd) {
5186
+ return new _Ledger(capUsd, this);
5187
+ }
5188
+ };
5189
+ }
5190
+ });
5191
+
4888
5192
  // packages/http-core/dist/index.js
4889
5193
  var init_dist2 = __esm({
4890
5194
  "packages/http-core/dist/index.js"() {
@@ -4903,6 +5207,7 @@ var init_dist2 = __esm({
4903
5207
  init_ledger();
4904
5208
  init_execution2();
4905
5209
  init_sitemap();
5210
+ init_spend();
4906
5211
  }
4907
5212
  });
4908
5213
 
@@ -6208,12 +6513,12 @@ function collectLinkDetails(html2, baseUrl) {
6208
6513
  doc.close();
6209
6514
  return [...byUrl.values()];
6210
6515
  }
6211
- function collapse(text2) {
6516
+ function collapse2(text2) {
6212
6517
  return (text2 ?? "").replace(/\s+/g, " ").trim();
6213
6518
  }
6214
6519
  function anchorText(a) {
6215
- const imageAlt = qsa(a, "img[alt]").map((img) => collapse(img.getAttribute("alt"))).find((alt) => alt.length > 0);
6216
- for (const candidate of [collapse(a.textContent), collapse(a.getAttribute("aria-label")), collapse(a.getAttribute("title")), imageAlt ?? ""]) {
6520
+ const imageAlt = qsa(a, "img[alt]").map((img) => collapse2(img.getAttribute("alt"))).find((alt) => alt.length > 0);
6521
+ for (const candidate of [collapse2(a.textContent), collapse2(a.getAttribute("aria-label")), collapse2(a.getAttribute("title")), imageAlt ?? ""]) {
6217
6522
  if (candidate.length === 0)
6218
6523
  continue;
6219
6524
  const points = Array.from(candidate);
@@ -8211,7 +8516,7 @@ function pruneTree(doc, options = {}) {
8211
8516
  detachAll(namedBy(doc, options.selectors ?? []));
8212
8517
  void tagOf;
8213
8518
  }
8214
- var MANUALLY_CLEANED, CLEANED_SELECTOR, CMP_ID_TOKENS, CMP_CLASS_TOKENS, AD_TOKENS, RECOMMENDATION_HEADINGS, RECOMMENDATION_TOKENS, NEVER_SHOWN, FORM_CONTENT_MIN_TEXT, FORM_CONTROLS;
8519
+ var MANUALLY_CLEANED, IN_CONTENT_NAVIGATION, SITE_HIDDEN, CLEANED_SELECTOR, CMP_ID_TOKENS, CMP_CLASS_TOKENS, AD_TOKENS, RECOMMENDATION_HEADINGS, RECOMMENDATION_TOKENS, NEVER_SHOWN, FORM_CONTENT_MIN_TEXT, FORM_CONTROLS;
8215
8520
  var init_prune = __esm({
8216
8521
  "packages/extract-tf/dist/prune.js"() {
8217
8522
  "use strict";
@@ -8244,7 +8549,9 @@ var init_prune = __esm({
8244
8549
  // filing keeps off the page.
8245
8550
  "ix\\:header"
8246
8551
  ];
8247
- CLEANED_SELECTOR = MANUALLY_CLEANED.join(",");
8552
+ IN_CONTENT_NAVIGATION = ['[role="navigation"]', "#p-lang-btn", ".mw-portlet", ".vector-menu"];
8553
+ SITE_HIDDEN = [".mw-hidden-catlinks"];
8554
+ CLEANED_SELECTOR = [...MANUALLY_CLEANED, ...IN_CONTENT_NAVIGATION, ...SITE_HIDDEN].join(",");
8248
8555
  CMP_ID_TOKENS = [
8249
8556
  "cookie",
8250
8557
  "consent",
@@ -8709,19 +9016,19 @@ function extractListRecords(html2, url, spec, page = 1, budget2 = { records: MAX
8709
9016
  doc.close();
8710
9017
  return records;
8711
9018
  }
8712
- function resolveListSpec(html2, request7) {
8713
- if (request7.itemSelector !== void 0 && request7.fields !== void 0)
8714
- return { spec: { type: "list", itemSelector: request7.itemSelector, fields: request7.fields } };
8715
- if (lastResolved !== null && lastResolved.html === html2 && lastResolved.request === request7)
9019
+ function resolveListSpec(html2, request8) {
9020
+ if (request8.itemSelector !== void 0 && request8.fields !== void 0)
9021
+ return { spec: { type: "list", itemSelector: request8.itemSelector, fields: request8.fields } };
9022
+ if (lastResolved !== null && lastResolved.html === html2 && lastResolved.request === request8)
8716
9023
  return lastResolved.resolved;
8717
- const resolved = detectSpec(html2, request7);
8718
- lastResolved = { html: html2, request: request7, resolved };
9024
+ const resolved = detectSpec(html2, request8);
9025
+ lastResolved = { html: html2, request: request8, resolved };
8719
9026
  return resolved;
8720
9027
  }
8721
- function detectSpec(html2, request7) {
8722
- if (request7.itemSelector !== void 0) {
8723
- const fields = detectFields(html2, request7.itemSelector) ?? [];
8724
- return { spec: { type: "list", itemSelector: request7.itemSelector, fields }, detected: { fields, alternatives: [] } };
9028
+ function detectSpec(html2, request8) {
9029
+ if (request8.itemSelector !== void 0) {
9030
+ const fields = detectFields(html2, request8.itemSelector) ?? [];
9031
+ return { spec: { type: "list", itemSelector: request8.itemSelector, fields }, detected: { fields, alternatives: [] } };
8725
9032
  }
8726
9033
  const [best, ...rest] = detectLists(html2);
8727
9034
  if (best === void 0)
@@ -10173,10 +10480,10 @@ function collectAmazonProductFacts(doc, url, declared3) {
10173
10480
  const offersRaw = jsonNode?.offers;
10174
10481
  const offer = record(Array.isArray(offersRaw) ? offersRaw[0] : offersRaw);
10175
10482
  const titleSelector = qs(doc, "#productTitle") ? "#productTitle" : "#title";
10176
- const pageText = text(doc, "body") ?? "";
10483
+ const pageText2 = text(doc, "body") ?? "";
10177
10484
  const documentTitle = clean(text(doc, "title")) ?? "";
10178
- const subscriptionPlan = pageText.match(/\bPlan:\s*(Blink\s+(?:AI\s+)?(?:basic|plus))\b/i)?.[1] ?? documentTitle.match(/\b(Blink\s+(?:AI\s+)?(?:basic|plus))\s+plan\b/i)?.[1] ?? null;
10179
- const subscription = subscriptionPlan !== null || /\bsubscription plan\b/i.test(pageText) && /\bBilling:\s*(?:Monthly|Annual)\b/i.test(pageText);
10485
+ const subscriptionPlan = pageText2.match(/\bPlan:\s*(Blink\s+(?:AI\s+)?(?:basic|plus))\b/i)?.[1] ?? documentTitle.match(/\b(Blink\s+(?:AI\s+)?(?:basic|plus))\s+plan\b/i)?.[1] ?? null;
10486
+ const subscription = subscriptionPlan !== null || /\bsubscription plan\b/i.test(pageText2) && /\bBilling:\s*(?:Monthly|Annual)\b/i.test(pageText2);
10180
10487
  const extractedVariants = variants(doc);
10181
10488
  const identity = verifyProductIdentity(doc, url, extractedVariants);
10182
10489
  if (!identity.identityMatch) {
@@ -10206,7 +10513,7 @@ function collectAmazonProductFacts(doc, url, declared3) {
10206
10513
  const store = first(doc, ["#bylineInfo", '[data-feature-name="bylineInfo"]']);
10207
10514
  const brand = store === null ? null : domFact(store.value.replace(/^Brand:\s*/i, "").replace(/^Visit the\s+/i, "").replace(/\s+Store$/i, ""), store.selector);
10208
10515
  const sellerHit = first(doc, ["#sellerProfileTriggerId", "#merchant-info", '.tabular-buybox-text[tabular-attribute-name="Sold by"]', "#merchantInfoFeature_feature_div .offer-display-feature-text-message"]);
10209
- const seller = sellerHit === null ? /\bSold\s*by\s*Blink\b/i.test(pageText) || /SoldbyBlink/i.test(pageText.replace(/\s+/g, "")) ? domFact("Blink", "body:Sold by") : null : domFact(sellerHit.value, sellerHit.selector);
10516
+ const seller = sellerHit === null ? /\bSold\s*by\s*Blink\b/i.test(pageText2) || /SoldbyBlink/i.test(pageText2.replace(/\s+/g, "")) ? domFact("Blink", "body:Sold by") : null : domFact(sellerHit.value, sellerHit.selector);
10210
10517
  const availabilityHit = first(doc, ["#availability span", "#availability", "#outOfStock"]);
10211
10518
  const deliveryHit = deliveryLocation(doc);
10212
10519
  const ratingRaw = attr(doc, "#acrPopover", "title") ?? text(doc, "#acrPopover .a-icon-alt") ?? text(doc, '[data-hook="rating-out-of-text"]');
@@ -10710,7 +11017,7 @@ function dublinCore(metas) {
10710
11017
  for (const [field3, name] of DC_FIELDS) {
10711
11018
  const value = metaNamed(metas, name);
10712
11019
  if (value !== null)
10713
- out[field3] = collapse2(value);
11020
+ out[field3] = collapse3(value);
10714
11021
  }
10715
11022
  return out;
10716
11023
  }
@@ -10741,7 +11048,7 @@ function metaProperties(metas, name) {
10741
11048
  continue;
10742
11049
  const value = declared(attribute(meta, "content"));
10743
11050
  if (value !== null)
10744
- values.push(collapse2(value));
11051
+ values.push(collapse3(value));
10745
11052
  }
10746
11053
  }
10747
11054
  return values;
@@ -10753,7 +11060,7 @@ function resolvedUrl(value, baseUrl) {
10753
11060
  return value;
10754
11061
  }
10755
11062
  }
10756
- function collapse2(value) {
11063
+ function collapse3(value) {
10757
11064
  return value.replace(ASCII_WHITESPACE, " ");
10758
11065
  }
10759
11066
  function pageTitle(document2) {
@@ -10891,11 +11198,45 @@ function showsDeclaredProduct(main, product) {
10891
11198
  const price = product.price.value.replace(/[^\d.,]/g, "");
10892
11199
  return text2.includes(product.name.value.replace(/\s+/g, " ").trim()) && price !== "" && text2.includes(price);
10893
11200
  }
10894
- var DEFAULT_CLASSIFY, BUY_BOX_CONFIDENCE, DESCRIPTION_MIN_CHARS, ExtractTf, extractTf;
11201
+ function headingsOnly(region) {
11202
+ const isHeading = (el) => /^h[1-6]$/.test(el.tagName.toLowerCase());
11203
+ let headingChars = 0;
11204
+ let prose3 = 0;
11205
+ const stack = [{ node: region, inHeading: isHeading(region) }];
11206
+ while (stack.length > 0) {
11207
+ const { node, inHeading } = stack.pop();
11208
+ for (const child of Array.from(node.childNodes)) {
11209
+ if (child.nodeType === 3) {
11210
+ const chars = (child.textContent ?? "").replace(/\s+/g, " ").trim().length;
11211
+ if (inHeading)
11212
+ headingChars += chars;
11213
+ else
11214
+ prose3 += chars;
11215
+ if (prose3 >= MIN_PROSE_CHARS || headingChars >= HEADINGS_ONLY_MAX_CHARS)
11216
+ return false;
11217
+ continue;
11218
+ }
11219
+ if (child.nodeType !== 1)
11220
+ continue;
11221
+ const el = child;
11222
+ const tag = el.tagName.toLowerCase();
11223
+ if (tag === "script" || tag === "style" || tag === "template" || tag === "noscript")
11224
+ continue;
11225
+ if (tag === "a" && (el.getAttribute("href") ?? "").startsWith("#"))
11226
+ continue;
11227
+ if (!inHeading && tag === "img" && linkTarget(el.getAttribute("src") ?? "", null) !== null)
11228
+ return false;
11229
+ stack.push({ node: el, inHeading: inHeading || isHeading(el) });
11230
+ }
11231
+ }
11232
+ return true;
11233
+ }
11234
+ var DEFAULT_CLASSIFY, BUY_BOX_CONFIDENCE, DESCRIPTION_MIN_CHARS, ExtractTf, extractTf, HEADINGS_ONLY_MAX_CHARS, MIN_PROSE_CHARS;
10895
11235
  var init_extract = __esm({
10896
11236
  "packages/extract-tf/dist/extract.js"() {
10897
11237
  "use strict";
10898
11238
  init_dom();
11239
+ init_markdown();
10899
11240
  init_prune();
10900
11241
  init_render();
10901
11242
  init_selectors();
@@ -11009,9 +11350,11 @@ var init_extract = __esm({
11009
11350
  baseUrl,
11010
11351
  metadata,
11011
11352
  confidence: selected ? 1 : confidenceOf(blocks.filter((b) => main?.contains(b.el)).length, main, blocks.length, mainLength, decision.type, productShown, favorPrecision, favorRecall, product),
11012
- // Escalate only when a strategy produced nothing at all. Routing to a
11013
- // non-article strategy is not by itself an escalation reason.
11014
- escalate: main === null,
11353
+ // Escalate when a strategy produced nothing at all, or the article
11354
+ // cascade a region that only names the page (headingsOnly; a region the
11355
+ // last resort found is a list's). Routing to a non-article strategy is
11356
+ // not by itself an escalation reason.
11357
+ escalate: main === null || strategy === "article" && headingsOnly(main),
11015
11358
  ...lastResort ? { lastResort: true } : {},
11016
11359
  pageType: decision.type,
11017
11360
  strategy,
@@ -11035,6 +11378,8 @@ var init_extract = __esm({
11035
11378
  }
11036
11379
  };
11037
11380
  extractTf = new ExtractTf();
11381
+ HEADINGS_ONLY_MAX_CHARS = 100;
11382
+ MIN_PROSE_CHARS = 3;
11038
11383
  }
11039
11384
  });
11040
11385
 
@@ -11043,7 +11388,7 @@ var EXTRACTOR_VERSION;
11043
11388
  var init_version = __esm({
11044
11389
  "packages/extract-tf/dist/version.js"() {
11045
11390
  "use strict";
11046
- EXTRACTOR_VERSION = "extract-tf/14";
11391
+ EXTRACTOR_VERSION = "extract-tf/16";
11047
11392
  }
11048
11393
  });
11049
11394
 
@@ -11972,7 +12317,7 @@ var init_errorPage = __esm({
11972
12317
  // packages/bench/dist/egress.js
11973
12318
  import { lookup as lookup2 } from "dns/promises";
11974
12319
  import { isIP as isIP3 } from "net";
11975
- import { Agent as Agent2, Pool, ProxyAgent } from "undici";
12320
+ import { Agent as Agent2, Pool, ProxyAgent, request as request5 } from "undici";
11976
12321
  function defaultNetworkPolicy() {
11977
12322
  return localNetworkPolicy();
11978
12323
  }
@@ -12086,6 +12431,16 @@ function createGuardedDispatcher(policy, resolve4 = lookup2, tls = {}) {
12086
12431
  maxHeaderSize: MAX_RESPONSE_HEADER_BYTES
12087
12432
  });
12088
12433
  }
12434
+ async function fetchThroughProxy(server, url, timeoutMs = 1e4, maxBytes = 64 * 1024) {
12435
+ const dispatcher = createProxyDispatcher(server);
12436
+ try {
12437
+ const answer = await request5(url, { method: "GET", dispatcher, signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json, text/plain;q=0.9" } });
12438
+ const body = await readCappedBody(answer.body, maxBytes);
12439
+ return { status: answer.statusCode, body: Buffer.from(body).toString("utf8") };
12440
+ } finally {
12441
+ await dispatcher.close();
12442
+ }
12443
+ }
12089
12444
  function createProxyDispatcher(server, tls = {}) {
12090
12445
  return new ProxyAgent({
12091
12446
  uri: server.url,
@@ -12354,7 +12709,7 @@ var init_robotsLookup = __esm({
12354
12709
  let pending = this.pending.get(key);
12355
12710
  if (pending === void 0) {
12356
12711
  const controller2 = new AbortController();
12357
- const request7 = (async () => {
12712
+ const request8 = (async () => {
12358
12713
  const scope = createExecutionScope({ signal: controller2.signal, deadlineAt: Date.now() + (this.networkPolicy.robotsTimeoutMs ?? ROBOTS_TIMEOUT_MS) });
12359
12714
  let entry3 = { robotsUrl, robots: null, sha256: null, absent: false };
12360
12715
  try {
@@ -12433,10 +12788,10 @@ var init_robotsLookup = __esm({
12433
12788
  this.byOrigin.set(key, { entry: entry3, expiresAt });
12434
12789
  return entry3;
12435
12790
  })();
12436
- pending = { promise: request7, controller: controller2, users: 0 };
12791
+ pending = { promise: request8, controller: controller2, users: 0 };
12437
12792
  this.pending.set(key, pending);
12438
12793
  const current = pending;
12439
- void request7.finally(() => {
12794
+ void request8.finally(() => {
12440
12795
  if (this.pending.get(key) === current)
12441
12796
  this.pending.delete(key);
12442
12797
  }).catch(() => {
@@ -12497,6 +12852,123 @@ var init_robotsLookup = __esm({
12497
12852
  }
12498
12853
  });
12499
12854
 
12855
+ // packages/bench/dist/browserSettle.js
12856
+ async function waitForRenderedStability(page, options = {}) {
12857
+ const maxMs = options.maxMs ?? 1500;
12858
+ const minMs = Math.min(options.minMs ?? 500, maxMs);
12859
+ const sampleMs = options.sampleMs ?? 100;
12860
+ const started = Date.now();
12861
+ const deadline = started + maxMs;
12862
+ const loadingDeadline = started + Math.max(maxMs, options.loadingMaxMs ?? maxMs);
12863
+ let observedSince = Date.now();
12864
+ let previous = "";
12865
+ let stableRounds = 0;
12866
+ let loading = false;
12867
+ let loadingSeen = false;
12868
+ const extends_ = loadingDeadline > deadline;
12869
+ const outcome = () => ({ loadingSeen, stillLoading: loading, waitedMs: Date.now() - started });
12870
+ const bound = () => extends_ && loading ? loadingDeadline : deadline;
12871
+ while (Date.now() < bound()) {
12872
+ let snapshot;
12873
+ try {
12874
+ snapshot = await page.evaluate(`(() => {
12875
+ const root = document.documentElement
12876
+ const body = document.body
12877
+ return JSON.stringify({
12878
+ size: root?.outerHTML.length ?? 0,
12879
+ text: body?.innerText ?? '',
12880
+ loading: ${LOADING_PROBE},
12881
+ })
12882
+ })()`);
12883
+ } catch (error) {
12884
+ if (!isNavigationError(error))
12885
+ throw error;
12886
+ observedSince = Date.now();
12887
+ previous = "";
12888
+ stableRounds = 0;
12889
+ if (extends_) {
12890
+ loading = true;
12891
+ loadingSeen = true;
12892
+ }
12893
+ await page.waitForTimeout(Math.min(sampleMs, Math.max(1, bound() - Date.now())));
12894
+ continue;
12895
+ }
12896
+ const current = typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot);
12897
+ loading = isLoading(current);
12898
+ loadingSeen ||= loading;
12899
+ if (current === previous)
12900
+ stableRounds++;
12901
+ else
12902
+ stableRounds = 0;
12903
+ previous = current;
12904
+ if (Date.now() - observedSince >= minMs && stableRounds >= 2 && !(extends_ && loading))
12905
+ return outcome();
12906
+ await page.waitForTimeout(Math.min(sampleMs, Math.max(1, bound() - Date.now())));
12907
+ }
12908
+ return outcome();
12909
+ }
12910
+ function loadingWaitEvent(outcome, at, lane) {
12911
+ if (outcome === void 0 || !outcome.loadingSeen)
12912
+ return null;
12913
+ return { at, lane, event: "loading_wait", detail: { waitedMs: outcome.waitedMs, cleared: !outcome.stillLoading } };
12914
+ }
12915
+ function withStillLoadingWarning(result) {
12916
+ if (result.status !== "success" && result.status !== "partial")
12917
+ return result;
12918
+ const waited = result.trace.find((event) => event.event === "loading_wait" && event.detail?.cleared === false);
12919
+ if (waited === void 0)
12920
+ return result;
12921
+ const seconds = Math.round(Number(waited.detail?.waitedMs ?? 0) / 100) / 10;
12922
+ const warning = { code: "page_still_loading", message: `The page still showed a loading indicator when it was read, after ${seconds} s: its data may not be in this answer.` };
12923
+ return { ...result, warnings: [...result.warnings ?? [], warning] };
12924
+ }
12925
+ function isLoading(snapshot) {
12926
+ try {
12927
+ return JSON.parse(snapshot).loading === true;
12928
+ } catch {
12929
+ return false;
12930
+ }
12931
+ }
12932
+ function isNavigationError(error) {
12933
+ return error instanceof Error && /Execution context was destroyed|because of a navigation|page is navigating and changing the content|Cannot find context with specified id/i.test(error.message);
12934
+ }
12935
+ var LOADING_PAGE_TEXT_MAX, LOADING_PROBE, LOADING_WAIT_MAX_MS;
12936
+ var init_browserSettle = __esm({
12937
+ "packages/bench/dist/browserSettle.js"() {
12938
+ "use strict";
12939
+ LOADING_PAGE_TEXT_MAX = 4e3;
12940
+ LOADING_PROBE = `(() => {
12941
+ try {
12942
+ const body = document.body
12943
+ if (body === null || (body.innerText ?? '').length > ${LOADING_PAGE_TEXT_MAX}) return false
12944
+ const shown = (el) => {
12945
+ const box = el.getBoundingClientRect()
12946
+ if (box.width < 1 || box.height < 1 || box.right <= 0 || box.bottom <= 0) return false
12947
+ if (typeof el.checkVisibility === 'function') return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
12948
+ const style = getComputedStyle(el)
12949
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
12950
+ }
12951
+ for (const el of body.querySelectorAll('[aria-busy="true"]')) if (shown(el)) return true
12952
+ const message = /^(?:(?:loading|fetching|please wait|one moment|searching|retrieving)(?:\\s+[\\w-]+){0,3}\\s*(?:\\.{2,3}|\\u2026)|loading|fetching|please wait)$/i
12953
+ const trailing = /\\b(?:loading|fetching)\\s*(?:\\.{2,3}|\\u2026)$/i
12954
+ // 4 is NodeFilter.SHOW_TEXT, named so that a page replacing NodeFilter cannot break the probe.
12955
+ const walker = document.createTreeWalker(body, 4)
12956
+ let seen = 0
12957
+ for (let node = walker.nextNode(); node !== null && seen < 20000; node = walker.nextNode(), seen++) {
12958
+ const text = (node.textContent ?? '').trim()
12959
+ if (text.length === 0 || text.length > 48 || !(message.test(text) || trailing.test(text))) continue
12960
+ const parent = node.parentElement
12961
+ if (parent !== null && parent.closest('button, a') === null && shown(parent)) return true
12962
+ }
12963
+ return false
12964
+ } catch {
12965
+ return false
12966
+ }
12967
+ })()`;
12968
+ LOADING_WAIT_MAX_MS = 8e3;
12969
+ }
12970
+ });
12971
+
12500
12972
  // packages/bench/dist/routing/identity.js
12501
12973
  function identityCompromised(trace) {
12502
12974
  return trace.some((t) => t.event === "identity_mismatch" || t.event === "identity_unobserved");
@@ -12552,6 +13024,7 @@ var init_provider2 = __esm({
12552
13024
  init_robotsLookup();
12553
13025
  init_identity();
12554
13026
  init_errorPage();
13027
+ init_browserSettle();
12555
13028
  defaultRobotsFetcher = robotsFetcherVia();
12556
13029
  ProviderSubject = class {
12557
13030
  provider;
@@ -12585,7 +13058,7 @@ var init_provider2 = __esm({
12585
13058
  async fetch(url, deadlineMs, signal, onRetryAfter, options = {}) {
12586
13059
  const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter });
12587
13060
  try {
12588
- return await this.fetchWithinBudget(url, scope, options);
13061
+ return withStillLoadingWarning(await this.fetchWithinBudget(url, scope, options));
12589
13062
  } finally {
12590
13063
  scope.dispose();
12591
13064
  }
@@ -12658,6 +13131,9 @@ var init_provider2 = __esm({
12658
13131
  try {
12659
13132
  res = await raceWithSignal(this.transport.fetch(url, execution.deadlineAt, execution.signal), execution.signal);
12660
13133
  fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
13134
+ const waited = loadingWaitEvent(res.settle, Date.now() - start, "provider");
13135
+ if (waited !== null)
13136
+ trace.push(waited);
12661
13137
  if (res.status === 429 || res.status === 503) {
12662
13138
  const delay = parseRetryAfterMs(res.headers["retry-after"] ?? null);
12663
13139
  if (delay !== null)
@@ -13139,7 +13615,8 @@ CREATE TABLE IF NOT EXISTS attempts (
13139
13615
  content_tokens_unknown INTEGER NOT NULL DEFAULT 0,
13140
13616
  budget_exceeded TEXT,
13141
13617
  recovered_from_attempt_id TEXT,
13142
- discovery_json TEXT
13618
+ discovery_json TEXT,
13619
+ charged_usd REAL
13143
13620
  );
13144
13621
 
13145
13622
  CREATE TABLE IF NOT EXISTS steps (
@@ -13164,6 +13641,21 @@ CREATE INDEX IF NOT EXISTS steps_attempt ON steps(attempt_id);
13164
13641
  CREATE INDEX IF NOT EXISTS steps_task_created_id ON steps(task_id, created_at, id);
13165
13642
  CREATE INDEX IF NOT EXISTS steps_task_status_created_id ON steps(task_id, status, created_at, id);
13166
13643
  CREATE INDEX IF NOT EXISTS attempts_task ON attempts(task_id);
13644
+
13645
+ CREATE TABLE IF NOT EXISTS list_pages (
13646
+ task_id TEXT NOT NULL REFERENCES tasks(id),
13647
+ canonical_url TEXT NOT NULL,
13648
+ step INTEGER NOT NULL,
13649
+ page INTEGER NOT NULL,
13650
+ page_url TEXT NOT NULL,
13651
+ html TEXT NOT NULL,
13652
+ state TEXT NOT NULL,
13653
+ items TEXT,
13654
+ item_refs TEXT,
13655
+ count INTEGER,
13656
+ created_at TEXT NOT NULL,
13657
+ PRIMARY KEY (task_id, canonical_url, step, page)
13658
+ );
13167
13659
  `;
13168
13660
  var SqliteTaskStore = class _SqliteTaskStore {
13169
13661
  db;
@@ -13202,6 +13694,10 @@ var SqliteTaskStore = class _SqliteTaskStore {
13202
13694
  this.db.exec("ALTER TABLE attempts ADD COLUMN discovery_json TEXT");
13203
13695
  } catch {
13204
13696
  }
13697
+ try {
13698
+ this.db.exec("ALTER TABLE attempts ADD COLUMN charged_usd REAL");
13699
+ } catch {
13700
+ }
13205
13701
  try {
13206
13702
  this.db.exec("ALTER TABLE tasks ADD COLUMN batch_json TEXT");
13207
13703
  } catch {
@@ -13260,9 +13756,9 @@ var SqliteTaskStore = class _SqliteTaskStore {
13260
13756
  throw new Error(`putAttempt: unknown task ${attempt.taskId}`);
13261
13757
  }
13262
13758
  this.db.prepare(`INSERT INTO attempts (
13263
- id, task_id, status, started_at, ended_at, pages_fetched, wall_ms, cost_usd, cost_unknown, content_tokens, content_tokens_unknown, budget_exceeded, recovered_from_attempt_id, discovery_json
13759
+ id, task_id, status, started_at, ended_at, pages_fetched, wall_ms, cost_usd, cost_unknown, content_tokens, content_tokens_unknown, budget_exceeded, recovered_from_attempt_id, discovery_json, charged_usd
13264
13760
  ) VALUES (
13265
- @id, @task_id, @status, @started_at, @ended_at, @pages_fetched, @wall_ms, @cost_usd, @cost_unknown, @content_tokens, @content_tokens_unknown, @budget_exceeded, @recovered_from_attempt_id, @discovery_json
13761
+ @id, @task_id, @status, @started_at, @ended_at, @pages_fetched, @wall_ms, @cost_usd, @cost_unknown, @content_tokens, @content_tokens_unknown, @budget_exceeded, @recovered_from_attempt_id, @discovery_json, @charged_usd
13266
13762
  )
13267
13763
  ON CONFLICT(id) DO UPDATE SET
13268
13764
  task_id = excluded.task_id,
@@ -13277,7 +13773,8 @@ var SqliteTaskStore = class _SqliteTaskStore {
13277
13773
  content_tokens_unknown = excluded.content_tokens_unknown,
13278
13774
  budget_exceeded = excluded.budget_exceeded,
13279
13775
  recovered_from_attempt_id = excluded.recovered_from_attempt_id,
13280
- discovery_json = excluded.discovery_json`).run({
13776
+ discovery_json = excluded.discovery_json,
13777
+ charged_usd = excluded.charged_usd`).run({
13281
13778
  id: attempt.id,
13282
13779
  task_id: attempt.taskId,
13283
13780
  status: attempt.status,
@@ -13291,7 +13788,8 @@ var SqliteTaskStore = class _SqliteTaskStore {
13291
13788
  content_tokens_unknown: attempt.contentTokensUnknown === true ? 1 : 0,
13292
13789
  budget_exceeded: attempt.budgetExceeded,
13293
13790
  recovered_from_attempt_id: attempt.recoveredFromAttemptId ?? null,
13294
- discovery_json: attempt.discovery === void 0 || attempt.discovery === null ? null : JSON.stringify(attempt.discovery)
13791
+ discovery_json: attempt.discovery === void 0 || attempt.discovery === null ? null : JSON.stringify(attempt.discovery),
13792
+ charged_usd: attempt.chargedUsd ?? null
13295
13793
  });
13296
13794
  }
13297
13795
  async getAttempt(attemptId) {
@@ -13411,6 +13909,20 @@ var SqliteTaskStore = class _SqliteTaskStore {
13411
13909
  LIMIT 1`).get(taskId, canonicalUrl);
13412
13910
  return row === void 0 ? null : stepFromRow(row);
13413
13911
  }
13912
+ async putPageRead(taskId, canonicalUrl, page) {
13913
+ assertId("taskId", taskId);
13914
+ this.db.prepare(`INSERT INTO list_pages (task_id, canonical_url, step, page, page_url, html, state, items, item_refs, count, created_at)
13915
+ VALUES (@task_id, @canonical_url, @step, @page, @page_url, @html, @state, @items, @item_refs, @count, @created_at)
13916
+ ON CONFLICT(task_id, canonical_url, step, page) DO UPDATE SET
13917
+ page_url = excluded.page_url, html = excluded.html, state = excluded.state, items = excluded.items, item_refs = excluded.item_refs, count = excluded.count, created_at = excluded.created_at`).run({ task_id: taskId, canonical_url: canonicalUrl, step: page.step, page: page.page, page_url: page.url, html: page.html, state: page.state, items: page.items, item_refs: page.itemRefs, count: page.count, created_at: (/* @__PURE__ */ new Date()).toISOString() });
13918
+ }
13919
+ async listPagesRead(taskId, canonicalUrl) {
13920
+ const rows = this.db.prepare(`SELECT step, page, page_url, html, state, items, item_refs, count FROM list_pages WHERE task_id = ? AND canonical_url = ? ORDER BY step, page`).all(taskId, canonicalUrl);
13921
+ return rows.map((row) => ({ step: row.step, page: row.page, url: row.page_url, html: row.html, state: row.state, items: row.items, itemRefs: row.item_refs, count: row.count }));
13922
+ }
13923
+ async clearPagesRead(taskId, canonicalUrl) {
13924
+ this.db.prepare(`DELETE FROM list_pages WHERE task_id = ? AND canonical_url = ?`).run(taskId, canonicalUrl);
13925
+ }
13414
13926
  async close() {
13415
13927
  this.db.close();
13416
13928
  }
@@ -13445,7 +13957,8 @@ function attemptFromRow(row) {
13445
13957
  ...row.content_tokens_unknown === 1 ? { contentTokensUnknown: true } : {},
13446
13958
  budgetExceeded: row.budget_exceeded,
13447
13959
  ...row.recovered_from_attempt_id ? { recoveredFromAttemptId: row.recovered_from_attempt_id } : {},
13448
- ...row.discovery_json ? { discovery: JSON.parse(row.discovery_json) } : {}
13960
+ ...row.discovery_json ? { discovery: JSON.parse(row.discovery_json) } : {},
13961
+ ...typeof row.charged_usd === "number" ? { chargedUsd: row.charged_usd } : {}
13449
13962
  };
13450
13963
  }
13451
13964
  function stepFromRow(row) {
@@ -14036,6 +14549,10 @@ var CrawlOrchestrator = class {
14036
14549
  let cachedPages = 0;
14037
14550
  let costUsd = 0;
14038
14551
  let costUnknown = false;
14552
+ let spend;
14553
+ let priorChargedUsd = 0;
14554
+ let chargedUsd = 0;
14555
+ let unboundedCost = false;
14039
14556
  let contentTokens = 0;
14040
14557
  let contentTokensUnknown = false;
14041
14558
  let budgetExceeded = null;
@@ -14048,6 +14565,10 @@ var CrawlOrchestrator = class {
14048
14565
  wallMs: this.clock.now() - startedAtMs,
14049
14566
  costUsd: costUnknown ? null : costUsd,
14050
14567
  costUnknown,
14568
+ // What the ledger settled and holds reserved in this run, calls of pages never written (interrupted, cut) and
14569
+ // reservations in flight included, so a crash between steps leaves the next run no room it did not have; at least
14570
+ // what the written pages were charged, costs reported outside the ledger included.
14571
+ ...spend === void 0 ? {} : { chargedUsd: Math.max(chargedUsd, spend.settledUsd + spend.reservedUsd - priorChargedUsd) },
14051
14572
  contentTokens,
14052
14573
  contentTokensUnknown,
14053
14574
  budgetExceeded,
@@ -14070,6 +14591,11 @@ var CrawlOrchestrator = class {
14070
14591
  const opened = await this.openRun(spec, startedAt);
14071
14592
  task = opened.task;
14072
14593
  attempt = opened.attempt;
14594
+ if (spec.budget.maxCostUsd !== null || (spec.budget.maxCostPerPageUsd ?? null) !== null) {
14595
+ const openedId = attempt.id;
14596
+ priorChargedUsd = (await this.store.listAttempts(task.id)).filter((earlier) => earlier.id !== openedId).reduce((sum, earlier) => sum + (earlier.chargedUsd ?? 0), 0);
14597
+ spend = createSpendLedger(spec.budget.maxCostUsd, priorChargedUsd);
14598
+ }
14073
14599
  const stored = task.batch === void 0 ? task.crawl : void 0;
14074
14600
  if (task.batch === void 0)
14075
14601
  discovery = { ...EMPTY_CRAWL_DISCOVERY };
@@ -14162,8 +14688,8 @@ var CrawlOrchestrator = class {
14162
14688
  stopping = true;
14163
14689
  break;
14164
14690
  }
14165
- const spent = { wallMs: now - startedAtMs, costUsd, costUnknown, tokens: contentTokens, tokensUnknown: contentTokensUnknown };
14166
- const hit = budgetHit(spec.budget, spent);
14691
+ const spent = { wallMs: now - startedAtMs, costUsd, costUnknown, chargedUsd: priorChargedUsd + chargedUsd, unboundedCost, tokens: contentTokens, tokensUnknown: contentTokensUnknown };
14692
+ const hit = budgetHit(spec.budget, spent, spend);
14167
14693
  if (hit !== null) {
14168
14694
  budgetExceeded = hit;
14169
14695
  if (hit === "time")
@@ -14208,6 +14734,7 @@ var CrawlOrchestrator = class {
14208
14734
  let links;
14209
14735
  let audit;
14210
14736
  let cachedPage = false;
14737
+ let pageLedger;
14211
14738
  if (reusable && cached.result !== null) {
14212
14739
  result = cached.result;
14213
14740
  links = linksOf(cached.result);
@@ -14216,12 +14743,29 @@ var CrawlOrchestrator = class {
14216
14743
  } else {
14217
14744
  const scrapeStartedAt = Date.now();
14218
14745
  let outcome;
14746
+ const pagesRead = listsPages(runningTask) ? await this.store.listPagesRead(runningTask.id, item.canonicalUrl) : [];
14747
+ pageLedger = spend?.child(spec.budget.maxCostPerPageUsd ?? null);
14748
+ const pageSpend = pageLedger === void 0 ? {} : { spend: pageLedger };
14749
+ const pageScope = !listsPages(runningTask) ? { ...scope, ...pageSpend } : {
14750
+ ...scope,
14751
+ ...pageSpend,
14752
+ onListPage: (page) => {
14753
+ void this.store.putPageRead(runningTask.id, item.canonicalUrl, page).catch(() => {
14754
+ });
14755
+ },
14756
+ ...pagesRead.length === 0 ? {} : { listResume: { pages: pagesRead } }
14757
+ };
14219
14758
  try {
14220
- outcome = await raceWithSignal(this.atom.scrape(item.url, scope), scope.signal);
14759
+ outcome = await raceWithSignal(this.atom.scrape(item.url, pageScope), scope.signal);
14221
14760
  } catch (error) {
14222
14761
  if (stopped())
14223
14762
  throw error;
14224
14763
  outcome = { result: scrapeErrorResult(item.url, error, Date.now() - scrapeStartedAt, this.scrapeErrorCostUsd), links: [] };
14764
+ const paid = paidCallsOfError(error);
14765
+ if (paid !== null)
14766
+ outcome = { ...outcome, result: { ...outcome.result, trace: [...outcome.result.trace, paid] } };
14767
+ if (pageLedger !== void 0)
14768
+ outcome = { ...outcome, result: { ...outcome.result, usage: { ...outcome.result.usage, externalCostChargedUsd: pageLedger.settledUsd } } };
14225
14769
  }
14226
14770
  result = outcome.result;
14227
14771
  links = outcome.links.length > 0 ? outcome.links : linksOf(outcome.result);
@@ -14269,6 +14813,8 @@ var CrawlOrchestrator = class {
14269
14813
  pagesInFlight--;
14270
14814
  const step = { id: this.newId(), taskId: runningTask.id, attemptId: runningAttempt.id, url: item.url, canonicalUrl: item.canonicalUrl, depth: item.depth, status: stepStatusFromResult(result.status), lane: result.lane, contentHash: result.evidence.rawBodySha256, cached: cachedPage, result, audit, createdAt: at, updatedAt: at };
14271
14815
  await this.store.putStep(step);
14816
+ if (listsPages(runningTask) && !stoppedAtCheck(result))
14817
+ await this.store.clearPagesRead(runningTask.id, item.canonicalUrl);
14272
14818
  taskUrls.add(item.canonicalUrl);
14273
14819
  if (reserved) {
14274
14820
  newPagesReserved--;
@@ -14279,6 +14825,10 @@ var CrawlOrchestrator = class {
14279
14825
  else
14280
14826
  pagesFetched += 1;
14281
14827
  if (!cachedPage) {
14828
+ const tried = audit?.summary.attempts ?? [{ result }];
14829
+ const reported2 = result.usage.externalCostChargedUsd ?? tried.reduce((sum, { result: attempt2 }) => sum + (attempt2.usage.externalCostUsd ?? 0), 0);
14830
+ chargedUsd += Math.max(reported2, pageLedger?.settledUsd ?? 0);
14831
+ unboundedCost ||= tried.some(({ result: attempt2 }) => attempt2.usage.externalCostUsd === null && attempt2.usage.externalCostChargedUsd === void 0);
14282
14832
  const meter = audit?.summary;
14283
14833
  if (meter !== void 0) {
14284
14834
  costUsd += meter.externalCost.knownSubtotal;
@@ -14491,6 +15041,12 @@ var CrawlOrchestrator = class {
14491
15041
  return { ...record3, sources: loaded.sources, files: loaded.files, listed: loaded.urls.length, enqueued: offered.enqueued, truncated: loaded.truncated };
14492
15042
  }
14493
15043
  };
15044
+ function stoppedAtCheck(result) {
15045
+ return result.status === "blocked" && (result.actions?.lists ?? []).some((list2) => list2.stoppedBy === "challenge");
15046
+ }
15047
+ function listsPages(task) {
15048
+ return (task.batch?.actions ?? []).some((action) => action.type === "paginate");
15049
+ }
14494
15050
  function seedBatchUrls(frontier, urls, completed) {
14495
15051
  for (const url of urls) {
14496
15052
  const canonical = canonicalizeUrl(url);
@@ -14580,13 +15136,20 @@ function absoluteHref(href, base) {
14580
15136
  return href;
14581
15137
  }
14582
15138
  }
14583
- function budgetHit(budget2, spent) {
15139
+ function budgetHit(budget2, spent, spend) {
14584
15140
  if (budget2.maxWallMs !== null && spent.wallMs >= budget2.maxWallMs)
14585
15141
  return "time";
14586
- if (budget2.maxCostUsd !== null && spent.costUnknown)
14587
- return "cost_unknown";
14588
- if (budget2.maxCostUsd !== null && spent.costUsd >= budget2.maxCostUsd)
14589
- return "cost";
15142
+ if (budget2.maxCostUsd !== null && spend !== void 0) {
15143
+ if (spent.unboundedCost)
15144
+ return "cost_unknown";
15145
+ if (Math.max(spent.chargedUsd, spend.settledUsd) + spend.reservedUsd >= budget2.maxCostUsd)
15146
+ return "cost";
15147
+ } else {
15148
+ if (budget2.maxCostUsd !== null && spent.costUnknown)
15149
+ return "cost_unknown";
15150
+ if (budget2.maxCostUsd !== null && spent.costUsd >= budget2.maxCostUsd)
15151
+ return "cost";
15152
+ }
14590
15153
  if (budget2.maxTokens !== null && spent.tokensUnknown)
14591
15154
  return "tokens_unknown";
14592
15155
  if (budget2.maxTokens !== null && spent.tokens >= budget2.maxTokens)
@@ -14663,7 +15226,8 @@ function duplicateResult(url, { html: _html, rawHtml: _rawHtml, images: _images,
14663
15226
  requestCount: prior.usage.requestCount,
14664
15227
  attemptCount: prior.usage.attemptCount,
14665
15228
  browserMs: prior.usage.browserMs,
14666
- externalCostUsd: prior.usage.externalCostUsd
15229
+ externalCostUsd: prior.usage.externalCostUsd,
15230
+ ...prior.usage.externalCostChargedUsd === void 0 ? {} : { externalCostChargedUsd: prior.usage.externalCostChargedUsd }
14667
15231
  },
14668
15232
  trace: [
14669
15233
  ...prior.trace,
@@ -16262,8 +16826,8 @@ var IdempotencyStore = class _IdempotencyStore {
16262
16826
  this.db.close();
16263
16827
  }
16264
16828
  };
16265
- function requestFingerprint(request7) {
16266
- return createHash4("sha256").update(canonicalJson(request7)).digest("hex");
16829
+ function requestFingerprint(request8) {
16830
+ return createHash4("sha256").update(canonicalJson(request8)).digest("hex");
16267
16831
  }
16268
16832
  function canonicalJson(value) {
16269
16833
  if (Array.isArray(value))
@@ -16286,7 +16850,7 @@ init_dist();
16286
16850
  init_dist3();
16287
16851
  init_dist2();
16288
16852
  var EVERY_HOP_LANES = /* @__PURE__ */ new Set(["http"]);
16289
- function toEvidenceRecord(result, request7, output, options = {}) {
16853
+ function toEvidenceRecord(result, request8, output, options = {}) {
16290
16854
  const { evidence } = result;
16291
16855
  const httpStatus = typeof evidence.httpStatus === "number" && evidence.httpStatus >= 100 && evidence.httpStatus <= 599 ? evidence.httpStatus : null;
16292
16856
  const requested = httpStatus !== null || result.usage.requestCount > 0;
@@ -16321,7 +16885,7 @@ function toEvidenceRecord(result, request7, output, options = {}) {
16321
16885
  proxy: evidence.envProxy ?? null,
16322
16886
  identity: {
16323
16887
  userAgent,
16324
- mode: result.compliance?.mode ?? identityEvent(result.trace)?.mode ?? request7.mode,
16888
+ mode: result.compliance?.mode ?? identityEvent(result.trace)?.mode ?? request8.mode,
16325
16889
  contact: userAgent === null ? null : declaredContact(userAgent),
16326
16890
  device: requested ? declaredDevice(result) : null,
16327
16891
  requestHeaders: requested ? sentCustomHeaders(result) : null
@@ -16330,16 +16894,40 @@ function toEvidenceRecord(result, request7, output, options = {}) {
16330
16894
  access: evidenceAccess(result)
16331
16895
  };
16332
16896
  }
16897
+ var READ_STATUSES = /* @__PURE__ */ new Set(["success", "partial", "empty_verified"]);
16333
16898
  function evidenceAccess(result) {
16334
16899
  const event = (name) => result.trace.find((e) => e.event === name)?.detail;
16335
16900
  const text2 = (value) => typeof value === "string" && value !== "" ? value : null;
16336
16901
  const hit = event("cache_hit");
16337
16902
  const stored = hit?.externalCostUsd;
16338
16903
  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 });
16904
+ const read2 = READ_STATUSES.has(result.status);
16905
+ const own = (r) => r === "http" || r === "http_compat" || r === "browser" || r === "enhanced_browser" || r === "authed_browser";
16906
+ const laneEvent = (name) => [...result.trace].reverse().find((e) => e.event === name && sameLaneFamily(e.lane, result.lane))?.detail;
16907
+ const egress = (r) => {
16908
+ if (!own(r))
16909
+ return null;
16910
+ const proxy = laneEvent("egress_proxy");
16911
+ if (proxy === void 0)
16912
+ return typeof result.evidence.httpStatus === "number" ? { proxy: null, source: "direct", switchedFrom: null, exit: null } : null;
16913
+ const moved = [...result.trace].reverse().find((e) => e.event === "egress_switched")?.detail;
16914
+ const source = proxy.source === "environment" ? "environment" : "pool";
16915
+ const seen = source === "pool" ? [...result.trace].reverse().find((e) => e.event === "egress_exit" && e.detail?.proxy === proxy.proxy)?.detail : void 0;
16916
+ const ip = text2(seen?.ip);
16917
+ const observedAt = text2(seen?.observedAt);
16918
+ const exit = ip === null || observedAt === null ? null : { ip, country: text2(seen?.country), observedAt };
16919
+ return { proxy: text2(proxy.proxy), source, switchedFrom: text2(moved?.from), exit };
16920
+ };
16921
+ const session = (r) => {
16922
+ const id = own(r) ? text2(laneEvent("session_cookies")?.session) : null;
16923
+ return id === null ? null : { id };
16924
+ };
16925
+ const paid = paidCalls(result.trace);
16926
+ const route = (r, executor, executorVersion = null, profile = null, completion = "unattended") => ({ route: r, executor, executorVersion, profile, externalCostUsd, completion: read2 && r !== null ? completion : null, egress: egress(r), session: session(r), paidCalls: paid.calls, grant: paid.grant });
16340
16927
  const laneRan = result.trace.some((e) => e.event === "identity_sent" || e.event === "identity_declared" || e.event === "provider_selected");
16341
16928
  if (!laneRan)
16342
16929
  return route(null, null);
16930
+ const userBrowser = (detail) => route("user_browser", text2(detail.browser), null, null, detail.sawGate != null && detail.act != null ? "handed_to_person" : "user_browser");
16343
16931
  switch (result.lane) {
16344
16932
  case "http": {
16345
16933
  const transport = event("transport");
@@ -16349,17 +16937,44 @@ function evidenceAccess(result) {
16349
16937
  case "browser_proxy": {
16350
16938
  const engine = event("browser_engine");
16351
16939
  if (event("session_attached") !== void 0)
16352
- return route("authed_browser", "playwright");
16940
+ return route("authed_browser", "playwright", null, null, "authorized_session");
16353
16941
  return engine !== void 0 && engine.engine === "patchright" ? route("enhanced_browser", "patchright", text2(engine.version)) : route("browser", "playwright");
16354
16942
  }
16355
16943
  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));
16944
+ const handed = event("user_browser_read");
16945
+ return handed === void 0 ? route("authed_browser", "playwright", null, null, "authorized_session") : route("user_browser", text2(handed.browser), null, null, "handed_to_person");
16946
+ }
16947
+ case "my_browser": {
16948
+ const opened = event("user_browser_read");
16949
+ return opened === void 0 ? route(null, null) : userBrowser(opened);
16358
16950
  }
16359
16951
  case "provider":
16360
16952
  return route("vendor", text2(event("provider_selected")?.provider));
16361
16953
  }
16362
16954
  }
16955
+ function paidCalls(trace) {
16956
+ const usd2 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
16957
+ const text2 = (value) => typeof value === "string" && value !== "";
16958
+ const calls = [];
16959
+ let grant = null;
16960
+ for (const event of trace) {
16961
+ if (event.event !== "paid_calls")
16962
+ continue;
16963
+ for (const call of Array.isArray(event.detail?.calls) ? event.detail.calls : []) {
16964
+ const { provider, rung, capabilities, ceilingUsd, chargedUsd, reportedCostUsd, outcome, reason: reason2, answer } = call;
16965
+ if (!text2(provider) || !text2(rung) || !Array.isArray(capabilities) || !capabilities.every(text2) || !usd2(ceilingUsd) || !usd2(chargedUsd))
16966
+ continue;
16967
+ if (!(reportedCostUsd === null || usd2(reportedCostUsd)) || !(outcome === null || RESULT_STATUS.includes(outcome)) || !(reason2 === null || text2(reason2)) || typeof answer !== "boolean")
16968
+ continue;
16969
+ calls.push({ provider, rung, capabilities, ceilingUsd, chargedUsd, reportedCostUsd, outcome, reason: reason2, answer });
16970
+ }
16971
+ const named = event.detail?.grant;
16972
+ if (named != null && text2(named.tier) && (named.sha256 === null || typeof named.sha256 === "string" && /^[0-9a-f]{64}$/.test(named.sha256)) && (named.attestedAt === null || text2(named.attestedAt))) {
16973
+ grant = { sha256: named.sha256, tier: named.tier, attestedAt: named.attestedAt };
16974
+ }
16975
+ }
16976
+ return calls.length === 0 ? { calls: null, grant: null } : { calls, grant };
16977
+ }
16363
16978
  function pageActions(result) {
16364
16979
  if (result.actions === void 0)
16365
16980
  return null;
@@ -16562,7 +17177,7 @@ function untriedAudit(wallMs) {
16562
17177
  // packages/runtime/dist/map.js
16563
17178
  init_dist();
16564
17179
  init_dist2();
16565
- var READ_STATUSES = /* @__PURE__ */ new Set(["success", "empty_verified"]);
17180
+ var READ_STATUSES2 = /* @__PURE__ */ new Set(["success", "empty_verified"]);
16566
17181
  var MapRunner = class {
16567
17182
  sources;
16568
17183
  constructor(sources) {
@@ -16918,7 +17533,7 @@ ${title ?? ""}`.toLowerCase();
16918
17533
  }
16919
17534
  }
16920
17535
  await preferHttps();
16921
- const startFailed = startPage !== null && !READ_STATUSES.has(startPage.status);
17536
+ const startFailed = startPage !== null && !READ_STATUSES2.has(startPage.status);
16922
17537
  const unreadable = sitemap?.files.filter((file) => file.kind === "unreadable" || file.kind === "refused") ?? [];
16923
17538
  const noneListed = sitemapMode === "only" && sitemapStarted && sitemap.error === null && unreadable.length === 0 && !sitemap.files.some((file) => file.kind === "urlset" || file.kind === "index");
16924
17539
  const sitemapFailed = sitemap !== null && (sitemap.error !== null || sitemap.truncated === "files" || unreadable.length > 0 || noneListed);
@@ -16989,7 +17604,7 @@ function nonEmpty(value) {
16989
17604
  import { createHash as createHash13 } from "crypto";
16990
17605
  import { existsSync as existsSync3, mkdirSync as mkdirSync8, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync } from "fs";
16991
17606
  import { mkdir as mkdir8, readFile as readFile6, writeFile as writeFile8 } from "fs/promises";
16992
- import { isIP as isIP4 } from "net";
17607
+ import { isIP as isIP5 } from "net";
16993
17608
  import { homedir as homedir2 } from "os";
16994
17609
  import { join as join10 } from "path";
16995
17610
 
@@ -18729,7 +19344,7 @@ init_errorPage();
18729
19344
  init_dist();
18730
19345
  init_dist3();
18731
19346
  init_dist2();
18732
- import { ProxyAgent as ProxyAgent2, request as request5 } from "undici";
19347
+ import { ProxyAgent as ProxyAgent2, request as request6 } from "undici";
18733
19348
 
18734
19349
  // packages/bench/dist/contentEncoding.js
18735
19350
  import { promisify } from "util";
@@ -18837,6 +19452,9 @@ var EgressRoute = class {
18837
19452
  }
18838
19453
  };
18839
19454
 
19455
+ // packages/bench/dist/compatBenefitHosts.js
19456
+ var COMPAT_BENEFIT_HOSTS = ["fred.stlouisfed.org", "www.idealo.de", "www.investing.com", "www.ironmountain.com", "www.wayfair.com"];
19457
+
18840
19458
  // packages/bench/dist/compatTransport.js
18841
19459
  init_dist();
18842
19460
  init_egress();
@@ -19064,7 +19682,12 @@ var CompatTransport = class {
19064
19682
  }
19065
19683
  };
19066
19684
  function compatHostsChoice(env, grant, hosted) {
19067
- const raw = (env.W2L_COMPAT_HOSTS ?? "").split(",").map((value) => value.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
19685
+ const granted = (grant?.capabilities ?? []).includes("compatible_transport");
19686
+ if (env.W2L_COMPAT_HOSTS === void 0 || env.W2L_COMPAT_HOSTS.trim() === "")
19687
+ return granted && !hosted ? [...COMPAT_BENEFIT_HOSTS].sort() : [];
19688
+ if (env.W2L_COMPAT_HOSTS.trim().toLowerCase() === "none")
19689
+ return [];
19690
+ const raw = env.W2L_COMPAT_HOSTS.split(",").map((value) => value.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
19068
19691
  if (raw.length === 0)
19069
19692
  return [];
19070
19693
  const invalid = raw.filter((host) => {
@@ -19078,7 +19701,7 @@ function compatHostsChoice(env, grant, hosted) {
19078
19701
  throw new Error(`W2L_COMPAT_HOSTS takes host names, such as example.com: not ${invalid.join(", ")}`);
19079
19702
  if (hosted)
19080
19703
  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")) {
19704
+ if (!granted) {
19082
19705
  throw new Error("W2L_COMPAT_HOSTS needs an access grant that names compatible_transport (ADR 0005; --access-grant or W2L_ACCESS_GRANT)");
19083
19706
  }
19084
19707
  return [...new Set(raw)].sort();
@@ -19443,7 +20066,7 @@ var ResilientHttpSubject = class {
19443
20066
  if (wire !== void 0 && customNames.length > 0 && !sameOrigin2)
19444
20067
  wire.onWithheld(url, customNames);
19445
20068
  const cookie = cookies === void 0 ? "" : await cookies.header(url);
19446
- const response = await request5(url, {
20069
+ const response = await request6(url, {
19447
20070
  dispatcher: this.dispatcherFor(url, routes),
19448
20071
  method: "GET",
19449
20072
  headersTimeout: init.headersTimeoutMs,
@@ -19518,7 +20141,7 @@ var ResilientHttpSubject = class {
19518
20141
  return this.route.viaOperatorProxy(url);
19519
20142
  }
19520
20143
  /** `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) {
20144
+ async fetch(url, deadlineMs, signal, validators = {}, onRetryAfter, options = {}, onRobotsOverride, cookieSession, onTrace) {
19522
20145
  if (this.localPreviewRobotsException && !isLocalPreviewProxyTarget(url))
19523
20146
  throw new Error("Local platform exception is limited to fixed platform hosts");
19524
20147
  const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter, ...cookieSession === void 0 ? {} : { cookieSession } });
@@ -19532,7 +20155,7 @@ var ResilientHttpSubject = class {
19532
20155
  try {
19533
20156
  permit = await this.scheduler.acquire(origin, scope.signal);
19534
20157
  throwIfExecutionStopped(scope);
19535
- return markDeadline(await this.fetchWithinBudget(url, { ...scope, onRobotsOverride }, validators, monotonicStart, permit.queueMs, permit.cooldownWaitMs, options, relaxed, permit.limitedByConcurrency ? permit.concurrencyWaitMs : void 0));
20158
+ return markDeadline(await this.fetchWithinBudget(url, { ...scope, onRobotsOverride, onTrace }, validators, monotonicStart, permit.queueMs, permit.cooldownWaitMs, options, relaxed, permit.limitedByConcurrency ? permit.concurrencyWaitMs : void 0));
19536
20159
  } catch (error) {
19537
20160
  if (!scope.signal.aborted && (deadlineMs === void 0 || Date.now() < deadlineMs))
19538
20161
  throw error;
@@ -19595,7 +20218,7 @@ var ResilientHttpSubject = class {
19595
20218
  }
19596
20219
  };
19597
20220
  };
19598
- const trace = [];
20221
+ const trace = observedTrace(execution.onTrace);
19599
20222
  const prepared = this.preparedFor(url, options);
19600
20223
  const honest = recordHttpIdentity(prepared, trace, 0);
19601
20224
  if (!honest) {
@@ -20156,6 +20779,27 @@ function bodyReadFailure(error) {
20156
20779
  return "connection_error";
20157
20780
  return null;
20158
20781
  }
20782
+ function observedTrace(onTrace) {
20783
+ const trace = [];
20784
+ if (onTrace === void 0)
20785
+ return trace;
20786
+ Object.defineProperty(trace, "push", {
20787
+ configurable: true,
20788
+ writable: true,
20789
+ enumerable: false,
20790
+ value: (...events) => {
20791
+ const length = Array.prototype.push.apply(trace, events);
20792
+ for (const event of events) {
20793
+ try {
20794
+ onTrace(event);
20795
+ } catch {
20796
+ }
20797
+ }
20798
+ return length;
20799
+ }
20800
+ });
20801
+ return trace;
20802
+ }
20159
20803
  function discard(body) {
20160
20804
  body.on("error", () => {
20161
20805
  });
@@ -20190,6 +20834,34 @@ var SessionCookies = class {
20190
20834
  }
20191
20835
  };
20192
20836
 
20837
+ // packages/bench/dist/subjects/listMerge.js
20838
+ init_dist3();
20839
+ function mergeListPages(pages, list2) {
20840
+ if (pages.length === 0)
20841
+ return null;
20842
+ const records = [];
20843
+ const seen = /* @__PURE__ */ new Set();
20844
+ let page = 0;
20845
+ let cut2 = false;
20846
+ const { spec, detected } = resolveListSpec(pages[0].html, list2);
20847
+ for (const scrape of spec === null ? [] : pages) {
20848
+ const budget2 = { records: MAX_LIST_RECORDS - records.length, chars: MAX_LIST_VALUE_CHARS - records.reduce((sum, record3) => sum + Object.values(record3.values).reduce((n, value) => n + (value?.length ?? 0), 0), 0) };
20849
+ const read2 = extractListRecords(scrape.html, scrape.url, spec, page + 1, budget2);
20850
+ const key = read2.itemText ?? JSON.stringify(read2.map((record3) => record3.values));
20851
+ if (read2.length > 0 && seen.has(key))
20852
+ continue;
20853
+ seen.add(key);
20854
+ page++;
20855
+ records.push(...read2);
20856
+ if (read2.cut === true) {
20857
+ cut2 = true;
20858
+ break;
20859
+ }
20860
+ }
20861
+ const valued = spec !== null && records.some((record3) => record3.missing.length < spec.fields.length);
20862
+ return { list: listExtraction(spec, records, page, cut2, detected), spec, valued };
20863
+ }
20864
+
20193
20865
  // packages/bench/dist/subjects/browserLocal.js
20194
20866
  init_dist();
20195
20867
  init_dist3();
@@ -20259,8 +20931,8 @@ init_dist();
20259
20931
  init_dist2();
20260
20932
  import { createHash as createHash7 } from "crypto";
20261
20933
  var SCREENSHOT_TIMEOUT_MS = 3e4;
20262
- function screenshotViewport(request7, identity, fingerprint) {
20263
- const asked = request7?.viewport;
20934
+ function screenshotViewport(request8, identity, fingerprint) {
20935
+ const asked = request8?.viewport;
20264
20936
  if (asked === void 0)
20265
20937
  return { viewport: fingerprint.viewport, issues: [] };
20266
20938
  const issues = identityBundleIssues({ ...identityBundleFrom(identity, fingerprint), viewport: asked });
@@ -20293,9 +20965,9 @@ function imageSize(bytes) {
20293
20965
  }
20294
20966
  return null;
20295
20967
  }
20296
- async function captureScreenshot(page, request7, viewport, deviceScaleFactor, execution, trace, at, refusal = null) {
20297
- const fullPage = request7.fullPage === true;
20298
- const quality = request7.quality ?? null;
20968
+ async function captureScreenshot(page, request8, viewport, deviceScaleFactor, execution, trace, at, refusal = null) {
20969
+ const fullPage = request8.fullPage === true;
20970
+ const quality = request8.quality ?? null;
20299
20971
  const contentType = quality === null ? "image/png" : "image/jpeg";
20300
20972
  const failed = (error) => {
20301
20973
  trace.push({ at: at(), lane: "browser_local", event: "screenshot_failed", detail: { fullPage, viewport, contentType, quality, error } });
@@ -20584,11 +21256,12 @@ async function pageState(ctx, itemSelector) {
20584
21256
  const changing = JSON.stringify(first2) !== JSON.stringify(second);
20585
21257
  if (second.items !== null) {
20586
21258
  const items = hash3(second.items.map((item, i) => `${item.refs}${steady(first2.items?.[i]?.text ?? "", item.text)}`).join("\0"));
20587
- return { html: html2, url, state: `${withoutHash(url)}\0${items}`, items, changing };
21259
+ const itemRefs = second.items.some((item) => item.refs !== "") ? hash3(second.items.map((item) => item.refs).join("\0")) : null;
21260
+ return { html: html2, url, state: `${withoutHash(url)}\0${items}`, items, itemRefs, changing };
20588
21261
  }
20589
21262
  const stillThere = new Set(first2.refs);
20590
21263
  const page = hash3(`${steady(first2.text, second.text)}${second.refs.filter((ref) => stillThere.has(ref)).join(" ")}`);
20591
- return { html: html2, url, state: `${withoutHash(url)}\0${page}`, items: null, changing };
21264
+ return { html: html2, url, state: `${withoutHash(url)}\0${page}`, items: null, itemRefs: null, changing };
20592
21265
  }
20593
21266
  var STEADY_READ_WAIT_MS = 3e3;
20594
21267
  async function steadyPageState(ctx, itemSelector) {
@@ -20742,41 +21415,110 @@ async function paginate(action, ctx, index, result, guard) {
20742
21415
  let itemsRead = action.itemSelector === void 0 ? null : 0;
20743
21416
  let items = null;
20744
21417
  let stoppedBy;
21418
+ let challenge;
21419
+ const resumed = (ctx.execution.listResume?.pages ?? []).filter((read2) => read2.step === index).sort((a, b) => a.page - b.page);
21420
+ const resumedUrls = new Set(resumed.map((read2) => withoutHash(read2.url)));
21421
+ const addressed = resumed.length >= 2 && resumedUrls.size === resumed.length;
21422
+ const shownAgain = resumed.map(() => false);
21423
+ let replayed = 0;
21424
+ let fresh = 0;
21425
+ for (const read2 of resumed) {
21426
+ result.scrapes.push({ url: read2.url, html: read2.html, step: index });
21427
+ pages++;
21428
+ if (itemsRead !== null)
21429
+ itemsRead = read2.count === null ? itemsRead : itemsRead + read2.count;
21430
+ }
21431
+ if (resumed.length > 0)
21432
+ ctx.trace.push({ at: ctx.at(), lane: "browser_local", event: "list_resumed", detail: { step: index, pages: resumed.length, from: resumed[resumed.length - 1].url, addressed } });
21433
+ const refsCounts = /* @__PURE__ */ new Map();
21434
+ for (const page of resumed)
21435
+ if (page.itemRefs !== null)
21436
+ refsCounts.set(page.itemRefs, (refsCounts.get(page.itemRefs) ?? 0) + 1);
21437
+ const knownBy = { content: 0, links: 0, address: 0 };
21438
+ const kept = (read2) => {
21439
+ const left = (match) => resumed.findIndex((page, i) => !shownAgain[i] && match(page));
21440
+ let hit = left((page) => read2.state === page.state || read2.items !== null && read2.items === page.items);
21441
+ if (hit !== -1) {
21442
+ knownBy.content++;
21443
+ return hit;
21444
+ }
21445
+ if (read2.itemRefs !== null && refsCounts.get(read2.itemRefs) === 1)
21446
+ hit = left((page) => page.itemRefs === read2.itemRefs);
21447
+ if (hit !== -1) {
21448
+ knownBy.links++;
21449
+ return hit;
21450
+ }
21451
+ if (addressed)
21452
+ hit = left((page) => withoutHash(page.url) === withoutHash(read2.url));
21453
+ if (hit !== -1)
21454
+ knownBy.address++;
21455
+ return hit;
21456
+ };
21457
+ const tell = (read2) => {
21458
+ try {
21459
+ ctx.execution.onListPage?.({ step: index, page: pages, ...read2 });
21460
+ } catch {
21461
+ }
21462
+ };
20745
21463
  try {
20746
21464
  for (; ; ) {
20747
- let { html: html2, url, state, items: listed } = await steadyPageState(ctx, action.itemSelector);
21465
+ let read2 = await steadyPageState(ctx, action.itemSelector);
21466
+ let { html: html2, url, state, items: listed } = read2;
20748
21467
  const unchanged = () => state === lastState || listed !== null && listed === lastListed && ctx.loadedDocuments().length === loadsAtClick;
20749
21468
  if (unchanged()) {
20750
21469
  const until = comeBackUntil(ctx);
20751
21470
  while (unchanged() && Date.now() < until) {
20752
21471
  await abortableSleep(250, ctx.execution.signal);
20753
- ({ html: html2, url, state, items: listed } = await steadyPageState(ctx, action.itemSelector));
21472
+ read2 = await steadyPageState(ctx, action.itemSelector);
21473
+ ({ html: html2, url, state, items: listed } = read2);
20754
21474
  }
20755
21475
  }
20756
21476
  lastState = state;
20757
21477
  lastListed = listed;
20758
- if (seenStates.has(state)) {
20759
- stoppedBy = "repeat";
21478
+ const onPage = action.itemSelector === void 0 ? null : await bounded(ctx, ctx.page.locator(action.itemSelector).count());
21479
+ const gate = classifyGate({ status: 200, header: () => null, body: html2, contentful: true });
21480
+ if (gate !== null) {
21481
+ challenge = { page: pages + 1, url, reason: gate.reason, signals: gate.signals };
21482
+ ctx.trace.push({ at: ctx.at(), lane: "browser_local", event: "list_challenge", detail: { step: index, page: pages + 1, url, reason: gate.reason, signals: [...gate.signals] } });
21483
+ stoppedBy = "challenge";
20760
21484
  break;
20761
21485
  }
20762
- seenStates.add(state);
20763
- if (listed !== null && seenItems.has(listed)) {
20764
- if (++alreadyRead >= 2) {
20765
- stoppedBy = "repeat";
20766
- break;
20767
- }
20768
- } else {
21486
+ const held = kept(read2);
21487
+ if (held !== -1) {
21488
+ shownAgain[held] = true;
21489
+ replayed++;
20769
21490
  alreadyRead = 0;
21491
+ seenStates.add(state);
20770
21492
  if (listed !== null)
20771
21493
  seenItems.add(listed);
20772
- result.scrapes.push({ url, html: html2, step: index });
20773
- pages++;
20774
- if (action.itemSelector !== void 0) {
20775
- items = await bounded(ctx, ctx.page.locator(action.itemSelector).count());
20776
- itemsRead = (itemsRead ?? 0) + items;
21494
+ items = onPage;
21495
+ } else {
21496
+ if (seenStates.has(state)) {
21497
+ stoppedBy = "repeat";
21498
+ break;
21499
+ }
21500
+ seenStates.add(state);
21501
+ if (listed !== null && seenItems.has(listed)) {
21502
+ if (++alreadyRead >= 2) {
21503
+ stoppedBy = "repeat";
21504
+ break;
21505
+ }
21506
+ } else {
21507
+ alreadyRead = 0;
21508
+ if (listed !== null)
21509
+ seenItems.add(listed);
21510
+ result.scrapes.push({ url, html: html2, step: index });
21511
+ pages++;
21512
+ if (onPage !== null) {
21513
+ items = onPage;
21514
+ itemsRead = (itemsRead ?? 0) + onPage;
21515
+ }
21516
+ fresh++;
21517
+ if (onPage !== 0)
21518
+ tell({ url, html: html2, state, items: listed, itemRefs: read2.itemRefs, count: action.itemSelector === void 0 ? null : items });
20777
21519
  }
20778
21520
  }
20779
- if (pages >= max) {
21521
+ if (pages >= max && (replayed >= resumed.length || fresh > 0)) {
20780
21522
  stoppedBy = "max";
20781
21523
  break;
20782
21524
  }
@@ -20798,7 +21540,14 @@ async function paginate(action, ctx, index, result, guard) {
20798
21540
  throw error;
20799
21541
  stoppedBy = "deadline";
20800
21542
  }
20801
- return { index, type: "paginate", stoppedBy, rounds: pages, items, itemsRead };
21543
+ if (resumed.length > 0) {
21544
+ const event = ctx.trace.find((item) => item.event === "list_resumed" && item.detail?.step === index);
21545
+ if (event?.detail !== void 0) {
21546
+ event.detail.replayed = replayed;
21547
+ event.detail.knownBy = knownBy;
21548
+ }
21549
+ }
21550
+ return { index, type: "paginate", stoppedBy, rounds: pages, items, itemsRead, ...resumed.length === 0 ? {} : { resumed: resumed.length }, ...challenge === void 0 ? {} : { challenge } };
20802
21551
  }
20803
21552
  async function bounded(ctx, operation) {
20804
21553
  const left = timeLeft(ctx);
@@ -20881,49 +21630,8 @@ function withoutHash(url) {
20881
21630
  return at === -1 ? url : url.slice(0, at);
20882
21631
  }
20883
21632
 
20884
- // packages/bench/dist/browserSettle.js
20885
- async function waitForRenderedStability(page, options = {}) {
20886
- const maxMs = options.maxMs ?? 1500;
20887
- const minMs = Math.min(options.minMs ?? 500, maxMs);
20888
- const sampleMs = options.sampleMs ?? 100;
20889
- const deadline = Date.now() + maxMs;
20890
- let observedSince = Date.now();
20891
- let previous = "";
20892
- let stableRounds = 0;
20893
- while (Date.now() < deadline) {
20894
- let snapshot;
20895
- try {
20896
- snapshot = await page.evaluate(`(() => {
20897
- const root = document.documentElement
20898
- const body = document.body
20899
- return JSON.stringify({
20900
- size: root?.outerHTML.length ?? 0,
20901
- text: body?.innerText ?? '',
20902
- })
20903
- })()`);
20904
- } catch (error) {
20905
- if (!isNavigationError(error))
20906
- throw error;
20907
- observedSince = Date.now();
20908
- previous = "";
20909
- stableRounds = 0;
20910
- await page.waitForTimeout(Math.min(sampleMs, Math.max(1, deadline - Date.now())));
20911
- continue;
20912
- }
20913
- const current = typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot);
20914
- if (current === previous)
20915
- stableRounds++;
20916
- else
20917
- stableRounds = 0;
20918
- previous = current;
20919
- if (Date.now() - observedSince >= minMs && stableRounds >= 2)
20920
- return;
20921
- await page.waitForTimeout(Math.min(sampleMs, Math.max(1, deadline - Date.now())));
20922
- }
20923
- }
20924
- function isNavigationError(error) {
20925
- return error instanceof Error && /Execution context was destroyed|because of a navigation|page is navigating and changing the content|Cannot find context with specified id/i.test(error.message);
20926
- }
21633
+ // packages/bench/dist/subjects/browserLocal.js
21634
+ init_browserSettle();
20927
21635
 
20928
21636
  // packages/bench/dist/browserLayout.js
20929
21637
  init_dist3();
@@ -21137,11 +21845,11 @@ var MainFrameDocuments = class {
21137
21845
  return { chain: hops.length > 1 ? hops : [], complete };
21138
21846
  }
21139
21847
  onResponse(response) {
21140
- const request7 = response.request();
21141
- if (!request7.isNavigationRequest())
21848
+ const request8 = response.request();
21849
+ if (!request8.isNavigationRequest())
21142
21850
  return;
21143
21851
  try {
21144
- if (request7.frame() !== this.page.mainFrame())
21852
+ if (request8.frame() !== this.page.mainFrame())
21145
21853
  return;
21146
21854
  } catch {
21147
21855
  return;
@@ -21175,8 +21883,8 @@ var MainFrameDocuments = class {
21175
21883
  };
21176
21884
  function requestedUrls(response) {
21177
21885
  const urls = [];
21178
- for (let request7 = response.request(); request7 !== null; request7 = request7.redirectedFrom())
21179
- urls.unshift(request7.url());
21886
+ for (let request8 = response.request(); request8 !== null; request8 = request8.redirectedFrom())
21887
+ urls.unshift(request8.url());
21180
21888
  return urls;
21181
21889
  }
21182
21890
  function sameDocument(a, b) {
@@ -21329,9 +22037,9 @@ var CustomHeaderGate = class {
21329
22037
  return [...reported2.filter(({ name }) => !this.names.has(name)), ...this.entries].sort((a, b) => a.name.localeCompare(b.name));
21330
22038
  }
21331
22039
  };
21332
- function sentHeadersOf(gate, request7) {
21333
- const reported2 = asSentHeaders(request7?.headers() ?? {});
21334
- return gate === null || request7 === null ? reported2 : gate.sent(request7.url(), reported2);
22040
+ function sentHeadersOf(gate, request8) {
22041
+ const reported2 = asSentHeaders(request8?.headers() ?? {});
22042
+ return gate === null || request8 === null ? reported2 : gate.sent(request8.url(), reported2);
21335
22043
  }
21336
22044
  var CAPTURE_RESERVE_MS = 1e3;
21337
22045
  var CAPTURE_ATTEMPTS = 3;
@@ -21445,8 +22153,8 @@ var BrowserLocalSubject = class {
21445
22153
  return this.chain.toLedger();
21446
22154
  }
21447
22155
  /** `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 } });
22156
+ async fetch(url, deadlineMs, signal, onRetryAfter, options = {}, onRobotsOverride, cookieSession, listing) {
22157
+ const scope = createExecutionScope({ signal, deadlineAt: deadlineMs, onRetryAfter, ...cookieSession === void 0 ? {} : { cookieSession }, ...listing?.onListPage === void 0 ? {} : { onListPage: listing.onListPage }, ...listing?.listResume === void 0 ? {} : { listResume: listing.listResume } });
21450
22158
  const start = Date.now();
21451
22159
  const monotonicStart = performance.now();
21452
22160
  let queueMs = 0;
@@ -21458,7 +22166,7 @@ var BrowserLocalSubject = class {
21458
22166
  const finish2 = (result) => {
21459
22167
  const totalMs = Math.max(0, performance.now() - monotonicStart);
21460
22168
  const lead = [...robots.overrideWarning === null ? [] : [robots.overrideWarning], ...tlsWarning === null ? [] : [tlsWarning]];
21461
- const done = withListCaveat(withActions(result, ran.actions, options.list));
22169
+ const done = withStillLoadingWarning(withListCaveat(withActions(result, ran.actions, options.list)));
21462
22170
  return {
21463
22171
  ...done,
21464
22172
  ...lead.length === 0 ? {} : { warnings: [...lead, ...done.warnings ?? []] },
@@ -21700,11 +22408,11 @@ var BrowserLocalSubject = class {
21700
22408
  const hasCustomHeaders = Object.keys(customHeaders).length > 0;
21701
22409
  if (allowedHosts !== null) {
21702
22410
  requestRoute = async (route) => {
21703
- const request7 = route.request();
21704
- const target = request7.url();
22411
+ const request8 = route.request();
22412
+ const target = request8.url();
21705
22413
  let allowed = false;
21706
22414
  try {
21707
- allowed = hostedBrowserRequestAllowed(target, request7.resourceType(), allowedHosts);
22415
+ allowed = hostedBrowserRequestAllowed(target, request8.resourceType(), allowedHosts);
21708
22416
  if (allowed)
21709
22417
  await assertSafeUrl(target, this.networkPolicy);
21710
22418
  } catch {
@@ -21825,9 +22533,9 @@ var BrowserLocalSubject = class {
21825
22533
  const documents = new MainFrameDocuments(page);
21826
22534
  const shownPage = page;
21827
22535
  let settledLoads = 0;
21828
- const settle = async (maxMs) => {
22536
+ const settle = async (maxMs, loadingMaxMs = maxMs) => {
21829
22537
  settledLoads = documents.loads;
21830
- await raceWithSignal(waitForRenderedStability(shownPage, { maxMs }), signal);
22538
+ return await raceWithSignal(waitForRenderedStability(shownPage, { maxMs, loadingMaxMs }), signal);
21831
22539
  };
21832
22540
  let previousRequestAtMs = null;
21833
22541
  let observedDelayMs = null;
@@ -21896,7 +22604,12 @@ var BrowserLocalSubject = class {
21896
22604
  }
21897
22605
  continue;
21898
22606
  }
21899
- await settle(remainingTimeout(execution, 1500));
22607
+ const callerWaits = (options.waitFor ?? 0) > 0 || (options.actions?.length ?? 0) > 0;
22608
+ const usual = remainingTimeout(execution, 1500);
22609
+ const settled2 = await settle(usual, callerWaits ? usual : execution.deadlineAt === void 0 ? LOADING_WAIT_MAX_MS : Math.max(1, Math.min(LOADING_WAIT_MAX_MS, execution.deadlineAt - CAPTURE_RESERVE_MS - Date.now())));
22610
+ const waited = callerWaits ? null : loadingWaitEvent(settled2, Date.now() - start, "browser_local");
22611
+ if (waited !== null)
22612
+ trace.push(waited);
21900
22613
  throwIfExecutionStopped(execution);
21901
22614
  if (status2 === 200 && variantFollowups === 0 && requestedAmazonAsin !== null) {
21902
22615
  const variant = await raceWithSignal(page.evaluate((asin) => ({
@@ -21957,22 +22670,22 @@ var BrowserLocalSubject = class {
21957
22670
  if (options.actions !== void 0 && options.actions.length > 0) {
21958
22671
  const own = page;
21959
22672
  actionsRoute = async (route) => {
21960
- const request7 = route.request();
21961
- if (!request7.isNavigationRequest())
22673
+ const request8 = route.request();
22674
+ if (!request8.isNavigationRequest())
21962
22675
  return route.fallback();
21963
22676
  let frame = null;
21964
22677
  try {
21965
- frame = request7.frame();
22678
+ frame = request8.frame();
21966
22679
  } catch {
21967
22680
  frame = null;
21968
22681
  }
21969
22682
  if (frame !== null && frame !== frame.page().mainFrame())
21970
22683
  return route.fallback();
21971
- const reason2 = await this.refuseNavigation(request7.url(), identity, execution, relaxedRoutes, overriddenUrl).catch((error) => `it could not be checked (${error instanceof Error ? error.message.slice(0, 120) : String(error)})`);
22684
+ const reason2 = await this.refuseNavigation(request8.url(), identity, execution, relaxedRoutes, overriddenUrl).catch((error) => `it could not be checked (${error instanceof Error ? error.message.slice(0, 120) : String(error)})`);
21972
22685
  if (reason2 === null)
21973
22686
  return route.fallback();
21974
- refusedNavigations.push({ url: request7.url(), reason: reason2 });
21975
- trace.push({ at: Date.now() - start, lane: "browser_local", event: "navigation_refused", detail: { url: request7.url(), reason: reason2 } });
22687
+ refusedNavigations.push({ url: request8.url(), reason: reason2 });
22688
+ trace.push({ at: Date.now() - start, lane: "browser_local", event: "navigation_refused", detail: { url: request8.url(), reason: reason2 } });
21976
22689
  return route.fulfill({ status: 204, body: "" });
21977
22690
  };
21978
22691
  await context.route(routeMatch, actionsRoute);
@@ -22645,49 +23358,48 @@ var BrowserLocalSubject = class {
22645
23358
  function withActions(result, ran, list2) {
22646
23359
  if (ran === void 0)
22647
23360
  return result;
22648
- const paginated = list2 === void 0 ? [] : ran.result.lists.filter((run) => run.type === "paginate").map((run) => run.index);
22649
- const pages = ran.result.scrapes.filter((scrape) => scrape.step !== void 0 && paginated.includes(scrape.step));
22650
- if (pages.length > 0) {
22651
- const records = [];
22652
- const seen = /* @__PURE__ */ new Set();
22653
- let page = 0;
22654
- let cut2 = false;
22655
- const { spec, detected } = resolveListSpec(pages[0].html, list2);
22656
- for (const scrape of spec === null ? [] : pages) {
22657
- const budget2 = { records: MAX_LIST_RECORDS - records.length, chars: MAX_LIST_VALUE_CHARS - records.reduce((sum, record3) => sum + Object.values(record3.values).reduce((n, value) => n + (value?.length ?? 0), 0), 0) };
22658
- const read2 = extractListRecords(scrape.html, scrape.url, spec, page + 1, budget2);
22659
- const key = read2.itemText ?? JSON.stringify(read2.map((record3) => record3.values));
22660
- if (read2.length > 0 && seen.has(key))
23361
+ if (result.status === "blocked" && result.blockReason !== null) {
23362
+ const reason2 = result.blockReason;
23363
+ const signals = result.trace.find((event) => event.event === "gate_detected")?.detail?.signals ?? [];
23364
+ const last = ran.result.scrapes.at(-1);
23365
+ for (const run of ran.result.lists) {
23366
+ if (run.type !== "paginate" || run.stoppedBy === "challenge" || last === void 0 || last.step !== run.index || run.items !== 0)
22661
23367
  continue;
22662
- seen.add(key);
22663
- page++;
22664
- records.push(...read2);
22665
- if (read2.cut === true) {
22666
- cut2 = true;
22667
- break;
22668
- }
23368
+ ran.result.scrapes.pop();
23369
+ run.rounds--;
23370
+ run.items = null;
23371
+ run.stoppedBy = "challenge";
23372
+ run.challenge = { page: run.rounds + 1, url: last.url, reason: reason2, signals };
23373
+ result = { ...result, trace: [...result.trace, { at: result.usage.wallMs, lane: "browser_local", event: "list_challenge", detail: { step: run.index, page: run.rounds + 1, url: last.url, reason: reason2, signals, by: "page_verdict" } }] };
22669
23374
  }
22670
- const merged = listExtraction(spec, records, page, cut2, detected);
22671
- const valued = spec !== null && records.some((record3) => record3.missing.length < spec.fields.length);
22672
- const rescued = valued && result.status === "failed" && result.failureReason === "empty_unverified";
23375
+ }
23376
+ const paginated = list2 === void 0 ? [] : [...ran.result.lists.filter((run) => run.type === "paginate").map((run) => run.index), ...ran.result.failed?.type === "paginate" ? [ran.result.failed.index] : []];
23377
+ const pages = ran.result.scrapes.filter((scrape) => scrape.step !== void 0 && paginated.includes(scrape.step));
23378
+ if (pages.length > 0) {
23379
+ const merged = mergeListPages(pages, list2);
23380
+ const rescued = merged.valued && result.status === "failed" && result.failureReason === "empty_unverified";
22673
23381
  result = {
22674
23382
  ...result,
22675
- list: merged,
23383
+ list: merged.list,
22676
23384
  ...rescued ? { status: "success", failureReason: null } : {},
22677
- trace: [...result.trace, { at: result.usage.wallMs, lane: "browser_local", event: "list_extracted", detail: { records: merged.records.length, incomplete: merged.incomplete, pages: merged.pages, truncated: merged.truncated } }]
23385
+ trace: [...result.trace, { at: result.usage.wallMs, lane: "browser_local", event: "list_extracted", detail: { records: merged.list.records.length, incomplete: merged.list.incomplete, pages: merged.list.pages, truncated: merged.list.truncated } }]
22678
23386
  };
22679
23387
  }
22680
23388
  const failed = ran.result.failed !== void 0 && CONTENTFUL_STATUS.has(result.status);
22681
- const short = ran.result.lists.filter((list3) => list3.stoppedBy === "max" || list3.stoppedBy === "deadline");
23389
+ const short = ran.result.lists.filter((list3) => list3.stoppedBy === "max" || list3.stoppedBy === "deadline" || list3.stoppedBy === "challenge");
23390
+ const why = (list3) => list3.stoppedBy === "max" ? `its limit of ${list3.rounds} ${list3.type === "paginate" ? "pages" : "rounds"}` : list3.stoppedBy === "challenge" ? `a check the site put up at page ${list3.challenge?.page ?? list3.rounds + 1} (${list3.challenge?.reason ?? "unknown"})` : "the deadline";
22682
23391
  const warnings = short.length === 0 ? result.warnings : [...result.warnings ?? [], {
22683
23392
  code: "list_not_exhausted",
22684
- message: `The list ${short.map((list3) => `of step ${list3.index} (${list3.type})`).join(" and ")} stopped before its end (${short.map((list3) => list3.stoppedBy === "max" ? `its limit of ${list3.rounds} ${list3.type === "paginate" ? "pages" : "rounds"}` : "the deadline").join("; ")}): more items may follow.`
23393
+ message: `The list ${short.map((list3) => `of step ${list3.index} (${list3.type})`).join(" and ")} stopped before its end (${short.map(why).join("; ")}): more items may follow.`
22685
23394
  }];
23395
+ const checked = ran.result.lists.find((list3) => list3.challenge !== void 0)?.challenge;
23396
+ const blocked = checked !== void 0 && result.status !== "blocked";
22686
23397
  return {
22687
23398
  ...result,
22688
23399
  ...warnings === void 0 ? {} : { warnings },
22689
23400
  actions: ran.result,
22690
23401
  ...failed ? { status: "failed", failureReason: "action_failed", blockReason: null } : {},
23402
+ ...blocked ? { status: "blocked", failureReason: null, blockReason: checked.reason, budgetExceeded: null } : {},
22691
23403
  evidence: { ...result.evidence, artifacts: [...result.evidence.artifacts, ...ran.artifacts] }
22692
23404
  };
22693
23405
  }
@@ -22925,6 +23637,18 @@ function summarize(channelsTried, attempts) {
22925
23637
  artifacts: attempts.flatMap((item) => item.result.evidence.artifacts)
22926
23638
  };
22927
23639
  }
23640
+ function strongerRungMayAnswer(result, channelId) {
23641
+ if (result.status !== "failed")
23642
+ return null;
23643
+ const status = result.evidence.httpStatus;
23644
+ if (result.failureReason === "http_error" && (status === 403 || status === 405))
23645
+ return `http_${status}`;
23646
+ if (result.failureReason === "empty_unverified" && !HTTP_CHANNELS.has(channelId))
23647
+ return "empty_unverified";
23648
+ if (result.failureReason === "connection_error" && HTTP_CHANNELS.has(channelId))
23649
+ return "connection_error";
23650
+ return null;
23651
+ }
22928
23652
  function resultRequestsEscalation(result) {
22929
23653
  return result.escalations.some((e) => e.improved === null) || qualityEscalationEvent(result) !== null;
22930
23654
  }
@@ -23040,7 +23764,7 @@ var LadderRunner = class {
23040
23764
  */
23041
23765
  async run(url, session, execution = {}, options = {}) {
23042
23766
  const scope = createExecutionScope(execution);
23043
- const progress = { startedAt: performance.now(), channelsTried: [], ladderTrace: [], attempts: [], robotsOverrides: [] };
23767
+ const progress = { startedAt: performance.now(), channelsTried: [], ladderTrace: [], attempts: [], robotsOverrides: [], charged: null, paidCalls: [], grant: null, ledger: null, steppedPast: null };
23044
23768
  for (const filtered of this.options.channelsFiltered ?? []) {
23045
23769
  progress.ladderTrace.push({ at: 0, event: "ladder_channels_filtered", channel: "\u2014", detail: { reason: filtered.reason, dropped: [...filtered.dropped] } });
23046
23770
  }
@@ -23049,11 +23773,11 @@ var LadderRunner = class {
23049
23773
  execution.onRobotsOverride?.(applied);
23050
23774
  } };
23051
23775
  try {
23052
- return withRunCost(carryRobotsOverride(await this.runWithinBudget(url, session, rungs, options, progress), progress.robotsOverrides));
23776
+ return withRunCost(carryRobotsOverride(await this.runWithinBudget(url, session, rungs, options, progress), progress.robotsOverrides), progress);
23053
23777
  } catch (error) {
23054
23778
  if (!deadlineReached(scope))
23055
- throw error;
23056
- return withRunCost(carryRobotsOverride(deadlineOutcome(url, progress, null), progress.robotsOverrides));
23779
+ throw progress.paidCalls.length === 0 ? error : carryPaidCalls(error, paidCallsEvent(progress, null, Math.max(0, performance.now() - progress.startedAt), "provider"));
23780
+ return withRunCost(carryRobotsOverride(deadlineOutcome(url, progress, null), progress.robotsOverrides), progress);
23057
23781
  } finally {
23058
23782
  scope.dispose();
23059
23783
  }
@@ -23073,13 +23797,19 @@ var LadderRunner = class {
23073
23797
  };
23074
23798
  };
23075
23799
  const failedAnswer = (result) => {
23076
- if (result.status !== "failed" || result.markdown !== null || classifyFetchFailure(result) !== null)
23800
+ if (result.status !== "failed" || result.markdown !== null)
23077
23801
  return result;
23078
- const kept = noMainContentEvidence(attempts);
23079
- if (kept === null || kept.result === result)
23802
+ const notImproved = (kept2) => ({ ...kept2, escalations: kept2.escalations.map((e) => e.improved === null ? { ...e, improved: false } : e) });
23803
+ const kept = classifyFetchFailure(result) === null ? noMainContentEvidence(attempts) : null;
23804
+ if (kept !== null && kept.result !== result) {
23805
+ ladderTrace.push({ at: result.usage.wallMs, event: "ladder_evidence_kept", channel: kept.channel, detail: { kept: kept.channel, failed: channelsTried.at(-1) ?? null, reason: result.failureReason } });
23806
+ return notImproved(kept.result);
23807
+ }
23808
+ const stepped = progress.steppedPast;
23809
+ if (stepped === null || stepped.result === result || result.evidence.httpStatus != null && result.failureReason !== "provider_error")
23080
23810
  return result;
23081
- ladderTrace.push({ at: result.usage.wallMs, event: "ladder_evidence_kept", channel: kept.channel, detail: { kept: kept.channel, failed: channelsTried.at(-1) ?? null, reason: result.failureReason } });
23082
- return { ...kept.result, escalations: kept.result.escalations.map((e) => e.improved === null ? { ...e, improved: false } : e) };
23811
+ ladderTrace.push({ at: result.usage.wallMs, event: "ladder_evidence_kept", channel: stepped.channel, detail: { kept: stepped.channel, failed: channelsTried.at(-1) ?? null, reason: result.failureReason ?? result.blockReason } });
23812
+ return notImproved(stepped.result);
23083
23813
  };
23084
23814
  const sessionsPermitted = this.policy.mode === "authed";
23085
23815
  let effectiveSession = null;
@@ -23148,9 +23878,11 @@ var LadderRunner = class {
23148
23878
  });
23149
23879
  return finish2(identityBlock, false);
23150
23880
  }
23151
- channelsTried.push(channel.id);
23152
23881
  const startedAt2 = (/* @__PURE__ */ new Date()).toISOString();
23153
- const result = await raceWithSignal(channel.fetch(url, effectiveSession, execution, options), execution.signal);
23882
+ const fetched = await this.paidFetch(channel, channel.id, url, effectiveSession, execution, options, progress, () => channelsTried.push(channel.id));
23883
+ if (fetched === null)
23884
+ continue;
23885
+ const result = fetched;
23154
23886
  attempts.push(attemptOf(channel, channel.id, result, attempts.length + 1, startedAt2));
23155
23887
  if (result.usage.deadlineExceeded === true || result.failureReason === "timeout" && deadlineReached(execution)) {
23156
23888
  return deadlineOutcome(url, progress, result);
@@ -23258,10 +23990,13 @@ var LadderRunner = class {
23258
23990
  });
23259
23991
  continue;
23260
23992
  }
23261
- return await this.attemptHandoff(url, result, channelsTried, channel, effectiveSession, ladderTrace, best, attempts, execution, options);
23993
+ return await this.attemptHandoff(url, result, channelsTried, channel, effectiveSession, ladderTrace, best, attempts, execution, options, progress);
23262
23994
  }
23263
23995
  const cls = classifyFetchFailure(result);
23264
23996
  const subjectAsked = resultRequestsEscalation(result);
23997
+ const stronger = cls === null && !subjectAsked && best === null && channel !== sessionFirst && channel.id !== "authed_session" ? strongerRungMayAnswer(result, channel.id) : null;
23998
+ if (stronger !== null && (result.evidence.httpStatus != null || result.markdown !== null))
23999
+ progress.steppedPast = { channel: channel.id, result };
23265
24000
  ladderTrace.push({
23266
24001
  at: result.usage.wallMs,
23267
24002
  event: "ladder_step",
@@ -23270,10 +24005,10 @@ var LadderRunner = class {
23270
24005
  vendorId: channel.vendorId ?? null,
23271
24006
  status: result.status,
23272
24007
  failureClass: cls,
23273
- escalate: subjectAsked ? "subject_escalations" : null
24008
+ escalate: subjectAsked ? "subject_escalations" : stronger
23274
24009
  }
23275
24010
  });
23276
- if (cls === null && !subjectAsked) {
24011
+ if (cls === null && !subjectAsked && stronger === null) {
23277
24012
  if (best !== null)
23278
24013
  break;
23279
24014
  return finish2(withLowContentYield(failedAnswer(result), channelsTried), false);
@@ -23324,7 +24059,50 @@ var LadderRunner = class {
23324
24059
  };
23325
24060
  await this.history?.record(safeHost(url), vendorId, outcome);
23326
24061
  }
23327
- async attemptHandoff(url, result, channelsTried, channel, session, ladderTrace, best, attempts, execution, options) {
24062
+ /**
24063
+ * One rung's fetch. A provider is called only at a known price ceiling, reserved from the run's ledger first and settled
24064
+ * after it, at the price it reported or at the ceiling, a call that threw or was cut included (ROADMAP PA item 4). Null
24065
+ * when it was not called, with the reason in the trace; `onCall` hears of the call just before it goes out.
24066
+ */
24067
+ async paidFetch(channel, label, url, session, execution, options, progress, onCall) {
24068
+ const { ladderTrace } = progress;
24069
+ let reservation = null;
24070
+ const ceiling = channel.vendorId === void 0 ? void 0 : channel.priceCeilingUsd ?? null;
24071
+ if (ceiling === null) {
24072
+ ladderTrace.push({ at: 0, event: "ladder_channel_skipped", channel: label, detail: { vendorId: channel.vendorId ?? null, reason: "no price ceiling: the access grant names no tariff for this provider, and a provider whose cost has no upper bound is not called" } });
24073
+ return null;
24074
+ }
24075
+ if (ceiling !== void 0) {
24076
+ const ledger = execution.spend ?? (progress.ledger ??= createSpendLedger(null));
24077
+ reservation = ledger.reserve(ceiling);
24078
+ if (reservation === null) {
24079
+ ladderTrace.push({ at: 0, event: "ladder_channel_skipped", channel: label, detail: { vendorId: channel.vendorId ?? null, reason: "budget: its price ceiling does not fit what the run (or this request) has left", ceilingUsd: ceiling, settledUsd: ledger.settledUsd, reservedUsd: ledger.reservedUsd, capUsd: ledger.capUsd } });
24080
+ return null;
24081
+ }
24082
+ }
24083
+ onCall();
24084
+ let result;
24085
+ try {
24086
+ result = await raceWithSignal(channel.fetch(url, session, execution, options), execution.signal);
24087
+ } catch (error) {
24088
+ if (reservation !== null && ceiling !== void 0) {
24089
+ const charged2 = reservation.settle(null);
24090
+ const call2 = paidCall(progress, channel, label, ceiling, charged2, null, null);
24091
+ ladderTrace.push({ at: Math.max(0, performance.now() - progress.startedAt), event: "spend_settled", channel: label, detail: { vendorId: channel.vendorId ?? null, ceilingUsd: ceiling, chargedUsd: charged2, basis: "ceiling", ended: "without_an_answer", call: call2 } });
24092
+ }
24093
+ throw error;
24094
+ }
24095
+ if (reservation === null || ceiling === void 0)
24096
+ return result;
24097
+ const charged = reservation.settle(result.usage.externalCostUsd);
24098
+ const call = paidCall(progress, channel, label, ceiling, charged, result.usage.externalCostUsd, result);
24099
+ return {
24100
+ ...result,
24101
+ usage: { ...result.usage, externalCostChargedUsd: (result.usage.externalCostChargedUsd ?? 0) + charged },
24102
+ trace: [...result.trace, { at: result.usage.wallMs, lane: result.lane, event: "spend_settled", detail: { vendorId: channel.vendorId ?? null, ceilingUsd: ceiling, chargedUsd: charged, basis: result.usage.externalCostUsd === null ? "ceiling" : "reported", call } }]
24103
+ };
24104
+ }
24105
+ async attemptHandoff(url, result, channelsTried, channel, session, ladderTrace, best, attempts, execution, options, progress) {
23328
24106
  ladderTrace.push({
23329
24107
  at: result.usage.wallMs,
23330
24108
  event: "ladder_step",
@@ -23388,7 +24166,14 @@ var LadderRunner = class {
23388
24166
  };
23389
24167
  }
23390
24168
  const retryStartedAt = (/* @__PURE__ */ new Date()).toISOString();
23391
- const retry = await raceWithSignal(channel.fetch(url, snapshot, execution, options), execution.signal);
24169
+ const paid = await this.paidFetch(channel, `${channel.id}(retry)`, url, snapshot, execution, options, progress, () => {
24170
+ });
24171
+ if (paid === null) {
24172
+ const tried2 = [...channelsTried, `${channel.id}(retry)`];
24173
+ ladderTrace.push({ at: result.usage.wallMs, event: "ladder_handoff_retry_failed", channel: `${channel.id}(retry)`, detail: { status: null, reason: "not called: no price ceiling, or one the budget cannot hold" } });
24174
+ return { result: sanitizeResult(best ?? result), channelsTried: tried2, handoffRequested: false, ladderTrace, summary: summarize(tried2, attempts) };
24175
+ }
24176
+ const retry = paid;
23392
24177
  attempts.push(attemptOf(channel, `${channel.id}(retry)`, retry, attempts.length + 1, retryStartedAt));
23393
24178
  ladderTrace.push({
23394
24179
  at: retry.usage.wallMs,
@@ -23498,17 +24283,45 @@ function deadlineOutcome(url, progress, returned) {
23498
24283
  result = { ...best.result, status: "partial", failureReason: null, blockReason: null, budgetExceeded: null, usage: { ...best.result.usage, deadlineExceeded: true }, trace: [...best.result.trace, event] };
23499
24284
  } else {
23500
24285
  const evidence = noMainContentEvidence(attempts);
23501
- const base = evidence?.result ?? (returned?.status === "failed" ? returned : deadlineFailure(url, laneOf(interrupted), at));
23502
- const event = { at, lane: base.lane, event: "deadline_exceeded", detail: evidence === null ? detail : { ...detail, evidence: evidence.channel } };
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] };
24286
+ const stepped = evidence === null && (returned === null || returned.evidence.httpStatus == null) ? progress.steppedPast : null;
24287
+ const base = evidence?.result ?? stepped?.result ?? (returned?.status === "failed" ? returned : deadlineFailure(url, laneOf(interrupted), at));
24288
+ const kept = evidence?.channel ?? stepped?.channel ?? null;
24289
+ const event = { at, lane: base.lane, event: "deadline_exceeded", detail: kept === null ? detail : { ...detail, evidence: kept } };
24290
+ result = { ...base, status: "failed", failureReason: "timeout", blockReason: null, budgetExceeded: null, markdown: evidence?.result.markdown ?? stepped?.result.markdown ?? null, usage: { ...base.usage, contentTokens: null, deadlineExceeded: true }, trace: [...base.trace, event] };
23504
24291
  }
23505
24292
  const summary = summarize(channelsTried, attempts);
23506
24293
  const providerUnmeasured = interrupted !== null && attempts.length < channelsTried.length && laneOf(interrupted) === "provider";
23507
24294
  const costed = providerUnmeasured ? { ...summary, externalCostUsd: null, externalCost: { ...summary.externalCost, unknown: true } } : summary;
23508
24295
  return { result, channelsTried, handoffRequested: false, ladderTrace, summary: { ...costed, totalMs: at } };
23509
24296
  }
23510
- function withRunCost(run) {
23511
- return { ...run, result: { ...run.result, usage: { ...run.result.usage, externalCostUsd: run.summary.externalCostUsd } } };
24297
+ function paidCall(progress, channel, rung, ceilingUsd, chargedUsd, reportedCostUsd, result) {
24298
+ progress.charged = (progress.charged ?? 0) + chargedUsd;
24299
+ progress.grant ??= channel.grant ?? null;
24300
+ const call = progress.paidCalls.length + 1;
24301
+ progress.paidCalls.push({
24302
+ provider: channel.vendorId ?? channel.id,
24303
+ rung,
24304
+ capabilities: [...channel.grantCapabilities ?? []],
24305
+ ceilingUsd,
24306
+ chargedUsd,
24307
+ reportedCostUsd,
24308
+ outcome: result?.status ?? null,
24309
+ reason: result === null ? null : result.failureReason ?? result.blockReason ?? result.budgetExceeded ?? null,
24310
+ call
24311
+ });
24312
+ return call;
24313
+ }
24314
+ function withRunCost(run, progress) {
24315
+ const { externalCostChargedUsd: _attempt, ...usage2 } = run.result.usage;
24316
+ const result = { ...run.result, usage: { ...usage2, externalCostUsd: run.summary.externalCostUsd, ...progress.charged === null ? {} : { externalCostChargedUsd: progress.charged } } };
24317
+ if (progress.paidCalls.length === 0)
24318
+ return { ...run, result };
24319
+ const own = result.trace.find((event) => event.event === "spend_settled")?.detail?.call;
24320
+ return { ...run, result: { ...result, trace: [...result.trace, paidCallsEvent(progress, own, result.usage.wallMs, result.lane)] } };
24321
+ }
24322
+ function paidCallsEvent(progress, answer, at, lane) {
24323
+ const calls = progress.paidCalls.map(({ call, ...paid }) => ({ ...paid, answer: call === answer }));
24324
+ return { at, lane, event: "paid_calls", detail: { calls, grant: progress.grant } };
23512
24325
  }
23513
24326
  function noMainContentEvidence(attempts) {
23514
24327
  for (let i = attempts.length - 1; i >= 0; i--) {
@@ -23690,6 +24503,7 @@ init_provider2();
23690
24503
 
23691
24504
  // packages/bench/dist/vendors/cdp.js
23692
24505
  init_dist2();
24506
+ init_browserSettle();
23693
24507
  var playwrightConnector = async (wsUrl, deadlineMs, signal) => {
23694
24508
  throwIfExecutionStopped({ signal, deadlineAt: deadlineMs });
23695
24509
  const { chromium: chromium4 } = await import("playwright");
@@ -23764,8 +24578,9 @@ async function navigateOnce(browser, url, deadlineMs, signal) {
23764
24578
  throwIfExecutionStopped(scope);
23765
24579
  const timeout = navigationTimeout(deadlineMs, Date.now());
23766
24580
  const response = await raceWithSignal(page.goto(url, { waitUntil: "domcontentloaded", timeout }), scope.signal);
23767
- await raceWithSignal(waitForRenderedStability(page, {
23768
- maxMs: deadlineMs === void 0 ? 1500 : Math.min(1500, Math.max(1, deadlineMs - Date.now()))
24581
+ const settle = await raceWithSignal(waitForRenderedStability(page, {
24582
+ maxMs: deadlineMs === void 0 ? 1500 : Math.min(1500, Math.max(1, deadlineMs - Date.now())),
24583
+ loadingMaxMs: deadlineMs === void 0 ? LOADING_WAIT_MAX_MS : Math.min(LOADING_WAIT_MAX_MS, Math.max(1, deadlineMs - 1e3 - Date.now()))
23769
24584
  }), scope.signal);
23770
24585
  throwIfExecutionStopped(scope);
23771
24586
  const headers = {};
@@ -23792,7 +24607,8 @@ async function navigateOnce(browser, url, deadlineMs, signal) {
23792
24607
  finalUrl: page.url(),
23793
24608
  headers,
23794
24609
  sentUserAgent,
23795
- sentClientHints
24610
+ sentClientHints,
24611
+ settle
23796
24612
  };
23797
24613
  } finally {
23798
24614
  scope.signal.removeEventListener("abort", abort);
@@ -23906,6 +24722,7 @@ var CdpVendorTransport = class {
23906
24722
  // subject treats that as unobserved, never as agreement.
23907
24723
  sentUserAgent: res.sentUserAgent,
23908
24724
  sentClientHints: res.sentClientHints,
24725
+ ...res.settle === void 0 ? {} : { settle: res.settle },
23909
24726
  declaredUserAgent: declared3,
23910
24727
  // Neither vendor states a per-request price in its API response, and
23911
24728
  // an estimate in this field would read as a measurement.
@@ -24006,6 +24823,7 @@ async function connectVendor(ops, connector = playwrightConnector, resume, deadl
24006
24823
 
24007
24824
  // packages/bench/dist/vendors/browserbase.js
24008
24825
  init_dist2();
24826
+ var BROWSERBASE_MIN_SESSION_MS = 6e4;
24009
24827
  var BROWSERBASE_CAPABILITIES = [
24010
24828
  { capability: "headless_browser", vendorDefaultOn: true, enableKey: null },
24011
24829
  { capability: "datacenter_proxy", vendorDefaultOn: true, enableKey: null },
@@ -24015,12 +24833,13 @@ var BROWSERBASE_CAPABILITIES = [
24015
24833
  { capability: "captcha_solving", vendorDefaultOn: true, enableKey: "vendor_captcha_solving" },
24016
24834
  { capability: "fingerprint_spoofing", vendorDefaultOn: false, enableKey: "vendor_stealth" }
24017
24835
  ];
24018
- function browserbaseSessionBody(decision, projectId, resume) {
24836
+ function browserbaseSessionBody(decision, projectId, resume, sessionTimeoutMs) {
24019
24837
  const persistEnabled = decision.enabled.some((c) => c.capability === "session_persistence");
24020
24838
  const solveEnabled = decision.enabled.some((c) => c.capability === "captcha_solving");
24021
24839
  const stealthEnabled = decision.enabled.some((c) => c.capability === "fingerprint_spoofing");
24022
24840
  return {
24023
24841
  ...projectId === void 0 ? {} : { projectId },
24842
+ ...sessionTimeoutMs === void 0 ? {} : { timeout: Math.ceil(Math.max(sessionTimeoutMs, BROWSERBASE_MIN_SESSION_MS) / 1e3), proxies: false, keepAlive: false },
24024
24843
  browserSettings: {
24025
24844
  // Browserbase defaults solveCaptchas to true, so the off state is an
24026
24845
  // explicit false, never an omission.
@@ -24073,7 +24892,7 @@ function browserbaseOps(config, api = fetchVendorApi, policy = {}) {
24073
24892
  return { browserbaseContextId: id };
24074
24893
  },
24075
24894
  async createSession(resume, deadlineMs, signal) {
24076
- const body = browserbaseSessionBody(decision, config.projectId, resume ?? null);
24895
+ const body = browserbaseSessionBody(decision, config.projectId, resume ?? null, config.sessionTimeoutMs);
24077
24896
  const res = await api({
24078
24897
  method: "POST",
24079
24898
  url: `${base}/v1/sessions`,
@@ -24123,6 +24942,7 @@ function browserbaseOps(config, api = fetchVendorApi, policy = {}) {
24123
24942
 
24124
24943
  // packages/bench/dist/vendors/steel.js
24125
24944
  init_dist2();
24945
+ var STEEL_MIN_SESSION_MS = 15e3;
24126
24946
  var STEEL_CAPABILITIES = [
24127
24947
  { capability: "headless_browser", vendorDefaultOn: true, enableKey: null },
24128
24948
  { capability: "datacenter_proxy", vendorDefaultOn: true, enableKey: null },
@@ -24133,11 +24953,12 @@ var STEEL_CAPABILITIES = [
24133
24953
  { capability: "captcha_solving", vendorDefaultOn: false, enableKey: "vendor_captcha_solving" },
24134
24954
  { capability: "fingerprint_spoofing", vendorDefaultOn: true, enableKey: "vendor_stealth" }
24135
24955
  ];
24136
- function steelSessionBody(decision, resume) {
24956
+ function steelSessionBody(decision, resume, sessionTimeoutMs) {
24137
24957
  const persistEnabled = decision.enabled.some((c) => c.capability === "session_persistence");
24138
24958
  const solveEnabled = decision.enabled.some((c) => c.capability === "captcha_solving");
24139
24959
  const stealthEnabled = decision.enabled.some((c) => c.capability === "fingerprint_spoofing");
24140
24960
  return {
24961
+ ...sessionTimeoutMs === void 0 ? {} : { timeout: Math.max(sessionTimeoutMs, STEEL_MIN_SESSION_MS), useProxy: false },
24141
24962
  // Steel injects a synthetic fingerprint BY DEFAULT. skipFingerprintInjection
24142
24963
  // is the opt-out, sent unless the grant names `vendor_stealth` (ADR 0005);
24143
24964
  // a body without it would buy the capability by omission. Solving follows
@@ -24177,7 +24998,7 @@ function steelOps(config, api = fetchVendorApi, policy = {}) {
24177
24998
  return null;
24178
24999
  },
24179
25000
  async createSession(resume, deadlineMs, signal) {
24180
- const body = steelSessionBody(decision, resume ?? null);
25001
+ const body = steelSessionBody(decision, resume ?? null, config.sessionTimeoutMs);
24181
25002
  const res = await api({
24182
25003
  method: "POST",
24183
25004
  url: `${base}/v1/sessions`,
@@ -24250,7 +25071,7 @@ function buildChannels(mode, opts = {}) {
24250
25071
  {
24251
25072
  id: "http",
24252
25073
  identity: declared3,
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),
25074
+ 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, execution?.onTrace),
24254
25075
  close: async () => {
24255
25076
  await http.teardown();
24256
25077
  await opts.localSubjects?.http?.teardown?.();
@@ -24259,7 +25080,7 @@ function buildChannels(mode, opts = {}) {
24259
25080
  ...compat === null ? [] : [{
24260
25081
  id: "http_compat",
24261
25082
  identity: identityBundleFrom(compatIdentity()),
24262
- fetch: (url, _session, execution, options) => compat.fetch(url, execution?.deadlineAt, execution?.signal, {}, execution?.onRetryAfter, options, execution?.onRobotsOverride, execution?.cookieSession),
25083
+ fetch: (url, _session, execution, options) => compat.fetch(url, execution?.deadlineAt, execution?.signal, {}, execution?.onRetryAfter, options, execution?.onRobotsOverride, execution?.cookieSession, execution?.onTrace),
24263
25084
  close: () => compat.teardown()
24264
25085
  }],
24265
25086
  {
@@ -24267,7 +25088,7 @@ function buildChannels(mode, opts = {}) {
24267
25088
  identity: declared3,
24268
25089
  waitsFor: true,
24269
25090
  // No session here, ever: the plain rung is the public browser.
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),
25091
+ 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, execution === void 0 ? void 0 : { onListPage: execution.onListPage, listResume: execution.listResume }),
24271
25092
  close: async () => {
24272
25093
  await plainBrowser.teardown();
24273
25094
  await opts.localSubjects?.browser_local?.teardown?.();
@@ -24327,7 +25148,7 @@ function buildChannels(mode, opts = {}) {
24327
25148
  };
24328
25149
  return skip;
24329
25150
  }
24330
- return authedSubjectFor(session).fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options, execution?.onRobotsOverride);
25151
+ return authedSubjectFor(session).fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options, execution?.onRobotsOverride, void 0, execution === void 0 ? void 0 : { onListPage: execution.onListPage, listResume: execution.listResume });
24331
25152
  },
24332
25153
  close: async () => {
24333
25154
  for (const subject of authedSubjects.values())
@@ -24335,7 +25156,7 @@ function buildChannels(mode, opts = {}) {
24335
25156
  }
24336
25157
  });
24337
25158
  }
24338
- if (mode === "standard")
25159
+ if (mode === "standard" && opts.enhanced !== true)
24339
25160
  return channels;
24340
25161
  if (!(opts.vendorPolicy?.authorized ?? []).includes("vendor_remote_browser"))
24341
25162
  return channels;
@@ -24345,7 +25166,7 @@ function buildChannels(mode, opts = {}) {
24345
25166
  const routedPolicy = opts.robotsFetcher === void 0 && opts.networkPolicy?.egressProxy ? opts.networkPolicy : null;
24346
25167
  let providerRoutes = null;
24347
25168
  const providerRobots = opts.robotsFetcher ?? (routedPolicy === null ? void 0 : robotsFetcherVia((url) => (providerRoutes ??= new EgressRoutes(routedPolicy)).dispatcherFor(url)));
24348
- const vendorChannel = (vendorId, ops) => {
25169
+ const vendorChannel = (vendorId, ops, sessionFloorMs = 0) => {
24349
25170
  let connected = null;
24350
25171
  let pending = null;
24351
25172
  let establishedResume = null;
@@ -24361,6 +25182,11 @@ function buildChannels(mode, opts = {}) {
24361
25182
  if (establishedResume !== null)
24362
25183
  pendingResume = establishedResume;
24363
25184
  };
25185
+ const tariff = opts.vendorTariffs?.[vendorId] ?? null;
25186
+ const connectOwn = (execution) => {
25187
+ opts.onVendorConnect?.(vendorId);
25188
+ return connectVendor(ops, opts.vendorConnector, pendingResume ?? null, execution?.deadlineAt, execution?.signal, opts.vendorPolicy?.authorized ?? []);
25189
+ };
24364
25190
  const ensureConnected = async (execution) => {
24365
25191
  if (connected !== null)
24366
25192
  return connected;
@@ -24376,7 +25202,7 @@ function buildChannels(mode, opts = {}) {
24376
25202
  }
24377
25203
  return await pending;
24378
25204
  };
24379
- return {
25205
+ const raw = {
24380
25206
  id: "provider",
24381
25207
  vendorId,
24382
25208
  identity: identityForRoute(mode, { resume: true }),
@@ -24434,13 +25260,19 @@ function buildChannels(mode, opts = {}) {
24434
25260
  } else {
24435
25261
  await preparePersistence(execution);
24436
25262
  }
24437
- const { declaration, transport } = await ensureConnected(execution);
24438
- if (pendingResume !== null) {
24439
- transport.useResumedSession(pendingResume);
25263
+ const { declaration, transport } = tariff !== null ? await connectOwn(execution) : await ensureConnected(execution);
25264
+ try {
25265
+ if (pendingResume !== null) {
25266
+ transport.useResumedSession(pendingResume);
25267
+ }
25268
+ const { ProviderSubject: ProviderSubject2 } = await Promise.resolve().then(() => (init_provider2(), provider_exports));
25269
+ const subject = new ProviderSubject2(declaration, transport, mode, null, providerRobots);
25270
+ return await subject.fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options);
25271
+ } finally {
25272
+ if (tariff !== null)
25273
+ await transport.close().catch(() => {
25274
+ });
24440
25275
  }
24441
- const { ProviderSubject: ProviderSubject2 } = await Promise.resolve().then(() => (init_provider2(), provider_exports));
24442
- const subject = new ProviderSubject2(declaration, transport, mode, null, providerRobots);
24443
- return subject.fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options);
24444
25276
  },
24445
25277
  close: async () => {
24446
25278
  if (connected !== null) {
@@ -24452,14 +25284,28 @@ function buildChannels(mode, opts = {}) {
24452
25284
  await providerRoutes?.close();
24453
25285
  }
24454
25286
  };
25287
+ const grantCapabilities = [.../* @__PURE__ */ new Set(["vendor_remote_browser", ...ops.decision.enabled.flatMap((c) => VENDOR_CAPABILITY_ACCESS[c.capability] ?? [])])];
25288
+ return {
25289
+ ...raw,
25290
+ grantCapabilities,
25291
+ ...opts.vendorGrant === void 0 ? {} : { grant: opts.vendorGrant },
25292
+ priceCeilingUsd: tariff === null ? null : tariffCeilingUsd(tariff, sessionFloorMs),
25293
+ fetch: async (url, session, execution, options) => {
25294
+ if (tariff === null || tariff.maxSessionMs === null)
25295
+ return raw.fetch(url, session, execution, options);
25296
+ return raw.fetch(url, session, { ...execution, deadlineAt: Math.min(execution?.deadlineAt ?? Number.POSITIVE_INFINITY, Date.now() + tariff.maxSessionMs) }, options);
25297
+ }
25298
+ };
24455
25299
  };
24456
25300
  if (bbKey !== "" || opts.vendorOps?.browserbase !== void 0) {
24457
- const ops = opts.vendorOps?.browserbase ?? browserbaseOps({ apiKey: bbKey }, void 0, opts.vendorPolicy);
24458
- channels.push(vendorChannel("browserbase", ops));
25301
+ const sessionTimeoutMs = opts.vendorTariffs?.browserbase?.maxSessionMs ?? null;
25302
+ const ops = opts.vendorOps?.browserbase ?? browserbaseOps({ apiKey: bbKey, ...sessionTimeoutMs === null ? {} : { sessionTimeoutMs } }, void 0, opts.vendorPolicy);
25303
+ channels.push(vendorChannel("browserbase", ops, BROWSERBASE_MIN_SESSION_MS));
24459
25304
  }
24460
25305
  if (steelKey !== "" || opts.vendorOps?.steel !== void 0) {
24461
- const ops = opts.vendorOps?.steel ?? steelOps({ apiKey: steelKey }, void 0, opts.vendorPolicy);
24462
- channels.push(vendorChannel("steel", ops));
25306
+ const sessionTimeoutMs = opts.vendorTariffs?.steel?.maxSessionMs ?? null;
25307
+ const ops = opts.vendorOps?.steel ?? steelOps({ apiKey: steelKey, ...sessionTimeoutMs === null ? {} : { sessionTimeoutMs } }, void 0, opts.vendorPolicy);
25308
+ channels.push(vendorChannel("steel", ops, STEEL_MIN_SESSION_MS));
24463
25309
  }
24464
25310
  return channels;
24465
25311
  }
@@ -24481,13 +25327,18 @@ init_dist();
24481
25327
  init_dist3();
24482
25328
  init_dist2();
24483
25329
  init_errorPage();
24484
- var LANE = "browser_local_authed";
24485
25330
  function pageFromUserBrowser(read2, prior, options) {
25331
+ const LANE = prior === null ? "my_browser" : "browser_local_authed";
24486
25332
  const { html: body, finalUrl, wallMs } = read2;
25333
+ const whole = read2.document ?? body;
24487
25334
  const status = read2.status ?? 200;
24488
25335
  const trace = [
24489
- { at: 0, lane: LANE, event: "handoff_from", detail: { status: prior.status, blockReason: prior.blockReason, failureReason: prior.failureReason, lane: prior.lane, rawBodySha256: prior.evidence.rawBodySha256 } },
24490
- ...robotsOf(prior),
25336
+ ...prior === null ? [] : [
25337
+ { at: 0, lane: LANE, event: "handoff_from", detail: { status: prior.status, blockReason: prior.blockReason, failureReason: prior.failureReason, lane: prior.lane, rawBodySha256: prior.evidence.rawBodySha256 } },
25338
+ ...robotsOf(prior, LANE),
25339
+ // The stopped run's provider calls were paid for: they stay on the page's record, none of them the page the person read.
25340
+ ...givenUpPaidCalls(prior.trace)
25341
+ ],
24491
25342
  { at: 0, lane: LANE, event: "identity_sent", detail: { mode: "authed", headers: [], by: "user_browser" } },
24492
25343
  { at: 0, lane: LANE, event: "identity_unobserved", detail: { reason: `the person's own browser (${read2.browser}) sent the request; its headers were not seen` } },
24493
25344
  { at: wallMs, lane: LANE, event: "user_browser_read", detail: { browser: read2.browser, status: read2.status, sawGate: read2.sawGate, act: read2.act, waitedMs: wallMs } }
@@ -24504,14 +25355,14 @@ function pageFromUserBrowser(read2, prior, options) {
24504
25355
  redirectChain: [],
24505
25356
  redirectChainComplete: false,
24506
25357
  contentType: read2.contentType,
24507
- rawBodySha256: sha256Utf8(body),
25358
+ rawBodySha256: sha256Utf8(whole),
24508
25359
  artifacts: [],
24509
25360
  fetchedAt: read2.fetchedAt
24510
25361
  },
24511
25362
  usage: {
24512
25363
  wallMs,
24513
25364
  bytesWire: null,
24514
- bytesDecompressed: Buffer.byteLength(body),
25365
+ bytesDecompressed: Buffer.byteLength(whole),
24515
25366
  // W2L sent none: the person's browser made the requests, as many as it did.
24516
25367
  requestCount: 0,
24517
25368
  attemptCount: 0,
@@ -24521,7 +25372,7 @@ function pageFromUserBrowser(read2, prior, options) {
24521
25372
  },
24522
25373
  trace
24523
25374
  };
24524
- const gate = classifyGate({ status, header: () => null, body });
25375
+ const gate = classifyGate({ status, header: () => null, body: whole });
24525
25376
  const errorPage = errorPageEvidence(status, read2.contentType, body, finalUrl, options);
24526
25377
  const errorPageFields = { markdown: errorPage?.markdown ?? null, ...errorPage === null ? {} : { links: errorPage.links } };
24527
25378
  const blocked = (verdict) => {
@@ -24540,7 +25391,8 @@ function pageFromUserBrowser(read2, prior, options) {
24540
25391
  trace.push({ at: wallMs, lane: LANE, event: "extract", detail: { pageType: extracted.pageType, strategy: extracted.strategy, confidence: extracted.confidence, escalate: extracted.escalate, linkCount: links.length, ...options.onlyMainContent === false ? { onlyMainContent: false } : {}, ...tagOptions(options) } });
24541
25392
  let wholePage = null;
24542
25393
  let listPage = false;
24543
- if ((extracted.escalate || extracted.lastResort === true) && gate !== null)
25394
+ const judged = whole === body || gate === null ? extracted : extractTf.extract(whole, { url: finalUrl, pruneSelectors: options.excludeTags, includeSelectors: options.includeTags, blockAds: options.blockAds });
25395
+ if ((judged.escalate || judged.lastResort === true) && gate !== null)
24544
25396
  return blocked(gate);
24545
25397
  if (extracted.escalate && !selectionAsked(options)) {
24546
25398
  wholePage = wholePageMarkdown(body, finalUrl, options);
@@ -24549,7 +25401,7 @@ function pageFromUserBrowser(read2, prior, options) {
24549
25401
  return { ...base, status: "failed", failureReason: "empty_unverified", blockReason: null, budgetExceeded: null, lane: LANE, escalations: [], markdown: wholePage, ...wholePage === null ? {} : { links } };
24550
25402
  }
24551
25403
  }
24552
- const decisive = classifyGate({ status, header: () => null, body, contentful: true });
25404
+ const decisive = classifyGate({ status, header: () => null, body: whole, contentful: true });
24553
25405
  if (decisive !== null)
24554
25406
  return blocked(decisive);
24555
25407
  const markdown = wholePageAsked(options) || listPage ? wholePage ?? htmlToMarkdown(body, { baseUrl: finalUrl, exclude: options.excludeTags, ...markdownOptions(options) }) : htmlToMarkdown(extracted.mainHtml, { baseUrl: extracted.baseUrl, ...markdownOptions(options) });
@@ -24579,11 +25431,30 @@ function pageFromUserBrowser(read2, prior, options) {
24579
25431
  adapterValidation: extracted.adapterValidation,
24580
25432
  labelledValues: extracted.labelledValues
24581
25433
  },
24582
- ...htmlFormats(body, body, extracted.mainHtml, options),
25434
+ ...htmlFormats(whole, body, extracted.mainHtml, options),
24583
25435
  usage: { ...base.usage, contentTokens: estimateTokens(markdown) }
24584
25436
  });
24585
25437
  }
24586
- function robotsOf(prior) {
25438
+ function unreadInUserBrowser(url, outcome, message2, wallMs) {
25439
+ return {
25440
+ requestedUrl: url,
25441
+ status: outcome.status,
25442
+ failureReason: outcome.status === "failed" ? outcome.failureReason : null,
25443
+ blockReason: outcome.status === "blocked" ? outcome.blockReason : null,
25444
+ budgetExceeded: null,
25445
+ lane: "my_browser",
25446
+ escalations: [],
25447
+ markdown: null,
25448
+ truncated: false,
25449
+ truncatedAt: null,
25450
+ compliance: null,
25451
+ evidence: { finalUrl: url, httpStatus: null, redirectChain: [], redirectChainComplete: false, contentType: null, rawBodySha256: null, artifacts: [], fetchedAt: (/* @__PURE__ */ new Date()).toISOString() },
25452
+ usage: { wallMs, bytesWire: null, bytesDecompressed: 0, requestCount: 0, attemptCount: 0, contentTokens: null, browserMs: wallMs, externalCostUsd: 0 },
25453
+ trace: [{ at: wallMs, lane: "my_browser", event: "user_browser_unread", detail: { reason: message2 } }],
25454
+ warnings: [{ code: "my_browser_not_read", message: message2 }]
25455
+ };
25456
+ }
25457
+ function robotsOf(prior, LANE) {
24587
25458
  const signed = prior.compliance?.robots;
24588
25459
  if (signed !== void 0) {
24589
25460
  return [
@@ -24837,7 +25708,7 @@ import { mkdir as mkdir7, writeFile as writeFile6 } from "fs/promises";
24837
25708
  init_dist();
24838
25709
 
24839
25710
  // packages/sdk/dist/version.js
24840
- var SDK_VERSION = "0.3.1";
25711
+ var SDK_VERSION = "0.3.2";
24841
25712
 
24842
25713
  // packages/sdk/dist/watcher.js
24843
25714
  init_dist();
@@ -25047,7 +25918,7 @@ init_dist();
25047
25918
  init_dist2();
25048
25919
  import { createHash as createHash12 } from "crypto";
25049
25920
  import { gunzipSync } from "zlib";
25050
- import { request as request6 } from "undici";
25921
+ import { request as request7 } from "undici";
25051
25922
  init_egress();
25052
25923
  init_robotsLookup();
25053
25924
  var HEADERS_TIMEOUT_MS = 1e4;
@@ -25078,14 +25949,14 @@ var HttpSitemapSource = class {
25078
25949
  this.ignoreRobotsTxt = options.ignoreRobotsTxt === true;
25079
25950
  this.robots = options.robots ?? new RobotsOriginCache(this.policy, (url) => this.route.dispatcherFor(url));
25080
25951
  }
25081
- async load(request7, context = {}) {
25952
+ async load(request8, context = {}) {
25082
25953
  const scope = createExecutionScope(context);
25083
- const soft = request7.softDeadlineAt === void 0 ? null : createExecutionScope({ signal: scope.signal, deadlineAt: request7.softDeadlineAt });
25954
+ const soft = request8.softDeadlineAt === void 0 ? null : createExecutionScope({ signal: scope.signal, deadlineAt: request8.softDeadlineAt });
25084
25955
  const work = soft ?? scope;
25085
- const timedOut = () => soft !== null && !scope.signal.aborted && (soft.signal.aborted || Date.now() >= request7.softDeadlineAt);
25086
- const details = request7.accept !== void 0 || request7.softDeadlineAt !== void 0;
25956
+ const timedOut = () => soft !== null && !scope.signal.aborted && (soft.signal.aborted || Date.now() >= request8.softDeadlineAt);
25957
+ const details = request8.accept !== void 0 || request8.softDeadlineAt !== void 0;
25087
25958
  try {
25088
- const seed = new URL(request7.seedUrl);
25959
+ const seed = new URL(request8.seedUrl);
25089
25960
  const identity = this.identityFor(seed.hostname);
25090
25961
  const result = { identity: { mode: this.mode, userAgent: identity.userAgent }, sources: [], files: [], urls: [], truncated: null };
25091
25962
  let robots;
@@ -25102,7 +25973,7 @@ var HttpSitemapSource = class {
25102
25973
  const queue = (declared3.length > 0 ? declared3 : [`${seed.origin}/sitemap.xml`]).map((url) => ({ url, depth: 0 }));
25103
25974
  const seen = /* @__PURE__ */ new Set();
25104
25975
  while (queue.length > 0) {
25105
- if (result.files.length >= request7.maxFiles) {
25976
+ if (result.files.length >= request8.maxFiles) {
25106
25977
  result.truncated = "files";
25107
25978
  break;
25108
25979
  }
@@ -25127,12 +25998,12 @@ var HttpSitemapSource = class {
25127
25998
  continue;
25128
25999
  }
25129
26000
  let left = 0;
25130
- const accept = request7.accept;
26001
+ const accept = request8.accept;
25131
26002
  for (const [i, url] of file.locs.entries()) {
25132
26003
  if (seen.has(url))
25133
26004
  continue;
25134
26005
  const entry3 = entryOf(url, next.url, file.details?.[i]);
25135
- if (result.urls.length >= request7.maxUrls) {
26006
+ if (result.urls.length >= request8.maxUrls) {
25136
26007
  if (accept === void 0) {
25137
26008
  left++;
25138
26009
  continue;
@@ -25147,7 +26018,7 @@ var HttpSitemapSource = class {
25147
26018
  continue;
25148
26019
  result.urls.push(entry3);
25149
26020
  }
25150
- if (result.urls.length >= request7.maxUrls && (left > 0 || queue.length > 0)) {
26021
+ if (result.urls.length >= request8.maxUrls && (left > 0 || queue.length > 0)) {
25151
26022
  result.truncated = "urls";
25152
26023
  break;
25153
26024
  }
@@ -25273,7 +26144,7 @@ var HttpSitemapSource = class {
25273
26144
  try {
25274
26145
  await this.scheduler.beforeRequest(origin, scope.signal);
25275
26146
  this.lastRequestAt.set(origin, performance.now());
25276
- const response = await request6(current, {
26147
+ const response = await request7(current, {
25277
26148
  dispatcher: this.route.dispatcherFor(current),
25278
26149
  method: "GET",
25279
26150
  // A copy per hop: undici's ProxyAgent writes `host` into the object it is given, so a reused one sends the first hop's Host to every later one.
@@ -25337,6 +26208,9 @@ function discard2(body) {
25337
26208
  body.destroy();
25338
26209
  }
25339
26210
 
26211
+ // packages/bench/dist/index.js
26212
+ init_browserSettle();
26213
+
25340
26214
  // packages/api/dist/engine.js
25341
26215
  init_dist3();
25342
26216
  init_dist();
@@ -25346,15 +26220,21 @@ init_dist2();
25346
26220
  init_dist();
25347
26221
  import { connect as netConnect } from "net";
25348
26222
  import { connect as tlsConnect } from "tls";
26223
+ import { isIP as isIP4 } from "net";
25349
26224
  var EGRESS_COOLDOWN_MS = 10 * 6e4;
25350
26225
  var MAX_EGRESS_SWITCHES = 2;
25351
26226
  var EgressPool = class {
25352
26227
  now;
26228
+ echoUrl;
26229
+ echo;
25353
26230
  egresses;
25354
26231
  cooldownUntil = /* @__PURE__ */ new Map();
26232
+ exits = /* @__PURE__ */ new Map();
25355
26233
  next = 0;
25356
- constructor(servers, policy, now = Date.now) {
26234
+ constructor(servers, policy, now = Date.now, echoUrl = null, echo = (server, url) => fetchThroughProxy(server, url)) {
25357
26235
  this.now = now;
26236
+ this.echoUrl = echoUrl;
26237
+ this.echo = echo;
25358
26238
  if (servers.length === 0)
25359
26239
  throw new Error("an egress pool needs at least one proxy");
25360
26240
  this.egresses = servers.map((server) => ({ id: server.endpoint, policy: withPoolProxy(policy, server), server }));
@@ -25386,14 +26266,51 @@ var EgressPool = class {
25386
26266
  }
25387
26267
  return [...pool].sort((a, b) => (this.cooldownUntil.get(a.id) ?? 0) - (this.cooldownUntil.get(b.id) ?? 0))[0];
25388
26268
  }
25389
- /** The egress failed its probe: it cools down. */
26269
+ /** The egress failed its probe: it cools down, and where it leaves from is asked again when it is next used. */
25390
26270
  fail(id) {
25391
26271
  this.cooldownUntil.set(id, this.now() + EGRESS_COOLDOWN_MS);
26272
+ this.exits.delete(id);
26273
+ }
26274
+ /**
26275
+ * Where this egress leaves from, as the echo URL saw it: asked once and kept for EGRESS_COOLDOWN_MS, since a
26276
+ * provider's exit may change. Null without an echo URL, and when the echo did not answer or named no address:
26277
+ * unknown stays unknown.
26278
+ */
26279
+ exitOf(id) {
26280
+ const egress = this.byId(id);
26281
+ if (this.echoUrl === null || egress === void 0)
26282
+ return Promise.resolve(null);
26283
+ const known = this.exits.get(id);
26284
+ if (known !== void 0 && this.now() - known.at < EGRESS_COOLDOWN_MS)
26285
+ return known.exit;
26286
+ const echoUrl = this.echoUrl;
26287
+ const exit = this.echo(egress.server, echoUrl).then((answer) => answer.status >= 200 && answer.status < 300 ? parseEchoExit(answer.body, new Date(this.now()).toISOString()) : null).catch(() => null);
26288
+ this.exits.set(id, { at: this.now(), exit });
26289
+ return exit;
25392
26290
  }
25393
26291
  };
26292
+ function parseEchoExit(body, observedAt) {
26293
+ const text2 = body.trim();
26294
+ let ip = null;
26295
+ let country = null;
26296
+ try {
26297
+ const parsed = JSON.parse(text2);
26298
+ if (parsed !== null && typeof parsed === "object") {
26299
+ const address = [parsed.ip, parsed.origin, parsed.query].find((value) => typeof value === "string");
26300
+ const last = typeof address === "string" ? address.split(",").map((part) => part.trim()).at(-1) ?? "" : "";
26301
+ ip = isIP4(last) === 0 ? null : last;
26302
+ const code = [parsed.country_code, parsed.countryCode, parsed.country].find((value) => typeof value === "string" && /^[A-Za-z]{2}$/.test(value));
26303
+ country = typeof code === "string" ? code.toUpperCase() : null;
26304
+ }
26305
+ } catch {
26306
+ ip = isIP4(text2) === 0 ? null : text2;
26307
+ }
26308
+ return ip === null ? null : { ip, country, observedAt };
26309
+ }
26310
+ var NETWORK_FAILURES = /* @__PURE__ */ new Set(["timeout", "dns_error", "connection_error", "tls_error"]);
25394
26311
  function egressInDoubt(outcome) {
25395
26312
  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);
26313
+ return outcome.result.status === "failed" && NETWORK_FAILURES.has(outcome.result.failureReason ?? "") && results.every((result) => (result.evidence.httpStatus ?? null) === null);
25397
26314
  }
25398
26315
  async function probeEgress(server, timeoutMs = 5e3) {
25399
26316
  const url = new URL(server.url);
@@ -25447,7 +26364,7 @@ function fileHint(file) {
25447
26364
  var QUALITY_EVENTS = /* @__PURE__ */ new Set(["quality_low_yield", "quality_client_rendered"]);
25448
26365
  var GATES = /* @__PURE__ */ new Set(["cloudflare_challenge", "captcha", "bot_detected_generic"]);
25449
26366
  var MAX_AGENT_HINTS = 5;
25450
- var BROWSER_LANES = /* @__PURE__ */ new Set(["browser_local", "browser_local_authed", "browser_proxy"]);
26367
+ var BROWSER_LANES = /* @__PURE__ */ new Set(["browser_local", "browser_local_authed", "browser_proxy", "my_browser"]);
25451
26368
  function httpLaneAskedForBrowser(result) {
25452
26369
  return result.escalations.some((hop) => hop.improved === null) || result.trace.some((event) => QUALITY_EVENTS.has(event.event));
25453
26370
  }
@@ -25605,7 +26522,7 @@ init_dist2();
25605
26522
  var ChromeLoginError = class extends Error {
25606
26523
  };
25607
26524
  var CDP_COMMAND_TIMEOUT_MS = 3e4;
25608
- var ENABLE_HINT = 'open chrome://inspect/#remote-debugging in Chrome (144 or later), turn on "Allow remote debugging for this browser instance", then run this again';
26525
+ var ENABLE_HINT = 'open chrome://inspect/#remote-debugging in Chrome (144 or later), turn on "Allow remote debugging for this browser instance", then run this again (while it is on, every page sees navigator.webdriver true: turn it off when you are done)';
25609
26526
  function chromeUserDataDir(platform = process.platform, env = process.env, home = homedir()) {
25610
26527
  if (platform === "darwin")
25611
26528
  return join9(home, "Library", "Application Support", "Google", "Chrome");
@@ -25888,9 +26805,11 @@ var READ_TIMEOUT_MS = 1e4;
25888
26805
  var WORLD = "w2l-handoff";
25889
26806
  var HandoffNotThrough = class extends Error {
25890
26807
  check;
25891
- constructor(message2, check) {
26808
+ kind;
26809
+ constructor(message2, check, kind = "timeout") {
25892
26810
  super(message2);
25893
26811
  this.check = check;
26812
+ this.kind = kind;
25894
26813
  }
25895
26814
  };
25896
26815
  var STATE = `JSON.stringify({
@@ -25902,6 +26821,27 @@ var STATE = `JSON.stringify({
25902
26821
  hidden: document.visibilityState === 'hidden',
25903
26822
  field: (() => { const el = document.activeElement; if (!el) return null; if (el.isContentEditable) return 'edit:' + String(el.textContent).slice(0, 500); return ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName) ? el.tagName + ':' + String(el.value).slice(0, 500) : null })(),
25904
26823
  })`;
26824
+ var SHOWN_HTML = `(() => {
26825
+ const root = document.documentElement
26826
+ if (!root) return JSON.stringify({ href: location.href, shown: '' })
26827
+ const copy = new DOMParser().parseFromString('', 'text/html').importNode(root, true)
26828
+ const originals = root.querySelectorAll('body *')
26829
+ const copies = copy.querySelectorAll('body *')
26830
+ const unshown = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT', 'LINK', 'META', 'TITLE', 'BASE'])
26831
+ const gone = []
26832
+ // In document order: what is inside an element already left out comes right after it, and goes with it.
26833
+ let last = null
26834
+ for (let i = 0; i < originals.length; i++) {
26835
+ const el = originals[i]
26836
+ if ((last !== null && last.contains(el)) || unshown.has(el.tagName) || el.closest('svg') !== null) continue
26837
+ const style = getComputedStyle(el)
26838
+ const hidden = style.display === 'none' || ((style.visibility === 'hidden' || style.visibility === 'collapse')
26839
+ && !Array.from(el.querySelectorAll('*')).some((inner) => getComputedStyle(inner).visibility === 'visible'))
26840
+ if (hidden) { gone.push(copies[i]); last = el }
26841
+ }
26842
+ for (const el of gone) el.remove()
26843
+ return JSON.stringify({ href: location.href, shown: copy.outerHTML })
26844
+ })()`;
25905
26845
  async function openUserChrome(options = {}, signal) {
25906
26846
  const endpoint = await chromeEndpoint(options.userDataDir ?? chromeUserDataDir());
25907
26847
  const connection = await (options.connect ?? connectCdp)(endpoint, options.approveTimeoutMs ?? 12e4, signal);
@@ -25918,17 +26858,251 @@ async function openUserChrome(options = {}, signal) {
25918
26858
  }
25919
26859
  return {
25920
26860
  read: (url, readOptions = {}) => readPage(connection, browser, url, readOptions),
26861
+ readList: (url, readOptions) => readList(connection, browser, url, readOptions),
26862
+ allow: (scope, allowOptions = {}) => allowSites(connection, scope, allowOptions),
25921
26863
  close: () => connection.close()
25922
26864
  };
25923
26865
  }
26866
+ function isAborted(signal) {
26867
+ return signal?.aborted === true;
26868
+ }
26869
+ var ANSWER = `document.documentElement.dataset.octocrawl || ''`;
26870
+ function scopePage(scope) {
26871
+ const escape = (text2) => text2.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
26872
+ const sites = scope.hosts.map((host) => `<li><code>${escape(host)}</code></li>`).join("");
26873
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Octocrawl asks to read pages</title>
26874
+ <style>body{font:15px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;color:#111}button{font:inherit;padding:.5rem 1rem;margin-right:.5rem}#state{margin-top:1rem;font-weight:600}</style></head>
26875
+ <body><h1>Octocrawl asks to read pages in this Chrome</h1>
26876
+ <p>Task: ${escape(scope.task)}</p><p>Sites it may read, signed in as you where you are:</p><ul>${sites}</ul>
26877
+ <p>It reads only pages on these sites, only while this page stays open. A page that shows a check waits for you.</p>
26878
+ <button id="allow">Allow reading these sites</button><button id="revoke">Revoke</button><p id="state"></p>
26879
+ <script>
26880
+ const root = document.documentElement, state = document.getElementById('state')
26881
+ document.getElementById('allow').onclick = () => { if (root.dataset.octocrawl !== 'revoked') { root.dataset.octocrawl = 'allowed'; state.textContent = 'Allowed. Close this page or click Revoke to stop.' } }
26882
+ document.getElementById('revoke').onclick = () => { root.dataset.octocrawl = 'revoked'; state.textContent = 'Revoked. Octocrawl reads nothing more here.' }
26883
+ </script></body></html>`;
26884
+ }
26885
+ async function allowSites(connection, scope, options) {
26886
+ const waitMs = options.waitMs ?? 6e5;
26887
+ const log = options.log ?? (() => void 0);
26888
+ const pollMs = options.pollMs ?? 500;
26889
+ const refused = (message2, kind) => new HandoffNotThrough(message2, null, kind);
26890
+ let targetId;
26891
+ try {
26892
+ targetId = (await connection.send("Target.createTarget", { url: "about:blank" })).targetId;
26893
+ } catch (error) {
26894
+ throw refused(`Octocrawl could not open its page in Chrome: ${error instanceof Error ? error.message : String(error)}`, "chrome");
26895
+ }
26896
+ const closeTab2 = () => connection.send("Target.closeTarget", { targetId }).then(() => void 0, () => void 0);
26897
+ const answerOf = async (sessionId, world) => {
26898
+ try {
26899
+ await connection.send("Target.getTargetInfo", { targetId }, void 0, READ_TIMEOUT_MS);
26900
+ const answer = await connection.send("Runtime.evaluate", { expression: ANSWER, returnByValue: true }, sessionId);
26901
+ const active = await connection.send("Runtime.evaluate", { expression: "navigator.userActivation.hasBeenActive", contextId: world, returnByValue: true }, sessionId);
26902
+ return { answer: String(answer.result?.value ?? ""), active: active.result?.value === true };
26903
+ } catch {
26904
+ return null;
26905
+ }
26906
+ };
26907
+ try {
26908
+ const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
26909
+ await connection.send("Target.activateTarget", { targetId }).catch(() => void 0);
26910
+ await connection.send("Runtime.evaluate", { expression: `document.open(); document.write(${JSON.stringify(scopePage(scope))}); document.close()` }, sessionId);
26911
+ const world = (await connection.send("Page.createIsolatedWorld", { frameId: targetId, worldName: WORLD }, sessionId)).executionContextId;
26912
+ log("shown", { targetId, hosts: scope.hosts.length });
26913
+ const wired = await connection.send("Runtime.evaluate", { expression: `typeof document.getElementById('allow')?.onclick === 'function' && typeof document.getElementById('revoke')?.onclick === 'function'`, returnByValue: true }, sessionId).then((r) => r.result?.value === true, () => false);
26914
+ log("ready", { buttons: wired });
26915
+ const started = Date.now();
26916
+ let last = { answer: "", active: false };
26917
+ const lastSeen = () => !wired ? "the page's buttons did not answer (its own script did not run), so no click could allow the sites" : last.answer === "allowed" ? "the page answered allowed, but no click of yours on it was seen (Chrome did not count one)" : last.active ? "you clicked on the page, but not on Allow reading these sites" : "the page was not clicked";
26918
+ const waited = waitMs % 6e4 === 0 ? `${waitMs / 6e4} minute${waitMs === 6e4 ? "" : "s"}` : `${Math.round(waitMs / 1e3)} s`;
26919
+ for (; ; ) {
26920
+ if (options.signal?.aborted === true) {
26921
+ log("refused", { reason: "cancelled", ...last });
26922
+ throw refused("the request was cancelled before you allowed the sites", "cancelled");
26923
+ }
26924
+ if (Date.now() - started >= waitMs) {
26925
+ log("refused", { reason: "timeout", ...last, buttons: wired });
26926
+ throw refused(`you did not allow the sites in Chrome within ${waited}: ${lastSeen()}`, "timeout");
26927
+ }
26928
+ await new Promise((resolve4) => setTimeout(resolve4, pollMs));
26929
+ const seen = await answerOf(sessionId, world);
26930
+ if (seen === null) {
26931
+ log("refused", { reason: "closed", ...last });
26932
+ throw refused("you closed Octocrawl's page in Chrome before allowing the sites", "cancelled");
26933
+ }
26934
+ if (seen.answer !== last.answer || seen.active !== last.active)
26935
+ log("answer", { ...seen, afterMs: Date.now() - started });
26936
+ last = seen;
26937
+ if (seen.answer === "revoked") {
26938
+ log("refused", { reason: "revoked" });
26939
+ throw refused("you did not allow the sites: you clicked Revoke", "cancelled");
26940
+ }
26941
+ if (seen.answer === "allowed" && seen.active) {
26942
+ log("allowed", { afterMs: Date.now() - started });
26943
+ break;
26944
+ }
26945
+ }
26946
+ const revoked = new AbortController();
26947
+ let watching = true;
26948
+ void (async () => {
26949
+ while (watching && !revoked.signal.aborted) {
26950
+ await new Promise((resolve4) => setTimeout(resolve4, Math.max(pollMs, 500)));
26951
+ if (!watching)
26952
+ return;
26953
+ const seen = await answerOf(sessionId, world);
26954
+ if (seen === null || seen.answer === "revoked")
26955
+ revoked.abort(new Error("you revoked the sites in Chrome"));
26956
+ }
26957
+ })();
26958
+ return { hosts: [...scope.hosts], signal: revoked.signal, close: async () => {
26959
+ watching = false;
26960
+ await closeTab2();
26961
+ } };
26962
+ } catch (error) {
26963
+ await closeTab2();
26964
+ if (error instanceof HandoffNotThrough)
26965
+ throw error;
26966
+ throw refused(`Octocrawl could not ask you in Chrome: ${error instanceof Error ? error.message : String(error)}`, "chrome");
26967
+ }
26968
+ }
26969
+ async function closeTab(connection, tab) {
26970
+ for (const stop of tab.stops)
26971
+ stop();
26972
+ await connection.send("Target.closeTarget", { targetId: tab.targetId }).catch(() => void 0);
26973
+ }
25924
26974
  async function readPage(connection, browser, url, options) {
26975
+ const { read: read2, tab } = await readOpen(connection, browser, url, options);
26976
+ await closeTab(connection, tab);
26977
+ return read2;
26978
+ }
26979
+ async function readList(connection, browser, url, options) {
26980
+ const { read: read2, tab } = await readOpen(connection, browser, url, options);
26981
+ try {
26982
+ return await followPages(connection, tab, url, read2, options);
26983
+ } finally {
26984
+ await closeTab(connection, tab);
26985
+ }
26986
+ }
26987
+ var FOLLOW = (nextSelector, itemSelector) => `JSON.stringify({
26988
+ href: location.href,
26989
+ ready: document.readyState,
26990
+ status: (performance.getEntriesByType('navigation')[0] || {}).responseStatus || null,
26991
+ html: document.documentElement ? document.documentElement.outerHTML : '',
26992
+ secret: Array.from(document.querySelectorAll('input[type=password], input[autocomplete="one-time-code"]')).some((el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden'),
26993
+ key: ${itemSelector === void 0 ? `(document.body ? document.body.innerText : '')` : `Array.from(document.querySelectorAll(${JSON.stringify(itemSelector)})).map((el) => el.innerText).join('\\u0000')`},
26994
+ items: ${itemSelector === void 0 ? "null" : `document.querySelectorAll(${JSON.stringify(itemSelector)}).length`},
26995
+ next: (() => { const el = document.querySelector(${JSON.stringify(nextSelector)}); if (!el) return 'gone'; const style = getComputedStyle(el); if (el.getClientRects().length === 0 || style.visibility === 'hidden' || style.display === 'none') return 'hidden'; if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true' || el.closest('.disabled, [aria-disabled="true"]') !== null) return 'disabled'; return 'usable' })(),
26996
+ })`;
26997
+ var END_GRACE_MS = 5e3;
26998
+ async function followPages(connection, tab, url, first2, options) {
26999
+ const { follow } = options;
27000
+ const pollMs = options.pollMs ?? 500;
27001
+ const waitMs = options.waitMs ?? 6e5;
27002
+ const idleMs = follow.idleMs ?? 6e4;
27003
+ const max = follow.maxPages ?? Number.POSITIVE_INFINITY;
27004
+ const endGraceMs = follow.endGraceMs ?? END_GRACE_MS;
27005
+ const host = new URL(url).hostname;
27006
+ const started = Date.now();
27007
+ const script = FOLLOW(follow.nextSelector, follow.itemSelector);
27008
+ const isGone = (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/i.test(error.message);
27009
+ const look = async () => {
27010
+ const info = await connection.send("Target.getTargetInfo", { targetId: tab.targetId }, void 0, READ_TIMEOUT_MS);
27011
+ const answer = await connection.send("Runtime.evaluate", { expression: script, returnByValue: true }, tab.sessionId);
27012
+ if (typeof answer.result?.value !== "string")
27013
+ return null;
27014
+ const shown = JSON.parse(answer.result.value);
27015
+ if (typeof info.targetInfo?.url === "string")
27016
+ shown.href = info.targetInfo.url;
27017
+ return shown;
27018
+ };
27019
+ const pages = [{ url: first2.finalUrl, html: first2.html }];
27020
+ const keys = /* @__PURE__ */ new Set();
27021
+ let lastKey = null;
27022
+ let lastRead = null;
27023
+ let unusableSince = null;
27024
+ let lastNew = Date.now();
27025
+ let told = null;
27026
+ const done = (stoppedBy) => ({ first: first2, pages, stoppedBy });
27027
+ follow.onStart?.(url);
27028
+ try {
27029
+ const shown = await look();
27030
+ if (shown !== null) {
27031
+ keys.add(shown.key);
27032
+ lastKey = shown.key;
27033
+ lastRead = shown.key;
27034
+ pages[0].items = shown.href === first2.finalUrl ? shown.items ?? null : null;
27035
+ }
27036
+ } catch (error) {
27037
+ if (isGone(error))
27038
+ throw new HandoffNotThrough(`the tab for ${url} was closed, or Chrome quit, after ${pages.length} page(s) of the list were read`, null, "gone");
27039
+ }
27040
+ if (pages.length >= max)
27041
+ return done("max");
27042
+ while (Date.now() - started < waitMs) {
27043
+ await new Promise((resolve4) => setTimeout(resolve4, pollMs));
27044
+ if (isAborted(options.signal))
27045
+ throw new HandoffNotThrough(`the reading of ${url} was cancelled after ${pages.length} page(s) of the list`, null, "cancelled");
27046
+ if (Date.now() - lastNew > idleMs)
27047
+ return done("deadline");
27048
+ let shown;
27049
+ try {
27050
+ shown = await look();
27051
+ } catch (error) {
27052
+ if (isGone(error))
27053
+ throw new HandoffNotThrough(`the tab for ${url} was closed, or Chrome quit, after ${pages.length} page(s) of the list were read`, null, "gone");
27054
+ continue;
27055
+ }
27056
+ if (shown === null || shown.ready !== "complete")
27057
+ continue;
27058
+ if (shown.key !== lastKey) {
27059
+ lastKey = shown.key;
27060
+ unusableSince = null;
27061
+ continue;
27062
+ }
27063
+ const readable = sameSite(shown.href, host) && !onLoginPath(shown.href, url) && !shown.secret && (shown.status === null || shown.status >= 200 && shown.status < 300);
27064
+ if (!readable) {
27065
+ unusableSince = null;
27066
+ continue;
27067
+ }
27068
+ if (!keys.has(shown.key) && shown.key !== "") {
27069
+ const gate = checksOf({ href: shown.href, ready: shown.ready, status: shown.status, html: shown.html, secret: shown.secret, field: null, hidden: false }, null, shown.status, options).gate;
27070
+ if (gate !== null) {
27071
+ lastNew = Date.now();
27072
+ if (told !== shown.href) {
27073
+ told = shown.href;
27074
+ options.onWaiting?.(shown.href, gate.reason);
27075
+ }
27076
+ continue;
27077
+ }
27078
+ keys.add(shown.key);
27079
+ lastRead = shown.key;
27080
+ pages.push({ url: shown.href, html: shown.html, items: shown.items ?? null });
27081
+ lastNew = Date.now();
27082
+ follow.onPage?.(shown.href, pages.length);
27083
+ if (pages.length >= max)
27084
+ return done("max");
27085
+ }
27086
+ if (shown.key === lastRead && shown.next !== "usable") {
27087
+ unusableSince ??= Date.now();
27088
+ if (Date.now() - unusableSince >= endGraceMs)
27089
+ return done("end");
27090
+ } else
27091
+ unusableSince = null;
27092
+ }
27093
+ return done("deadline");
27094
+ }
27095
+ async function readOpen(connection, browser, url, options) {
25925
27096
  const waitMs = options.waitMs ?? 6e5;
25926
27097
  const pollMs = options.pollMs ?? 500;
25927
27098
  const host = new URL(url).hostname;
27099
+ const allowed = options.unattended === true ? new Set(options.allowedHosts ?? [new URL(url).host]) : null;
25928
27100
  const started = Date.now();
25929
27101
  let 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;
25931
- const ended = (error) => gone(error) ?? (error instanceof ChromeLoginError ? new HandoffNotThrough(`${url} was not read: ${error.message}`, sawGate) : error);
27102
+ 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, "gone") : null;
27103
+ const ended = (error) => gone(error) ?? (error instanceof ChromeLoginError ? new HandoffNotThrough(`${url} was not read: ${error.message}`, sawGate, "chrome") : error);
27104
+ if (isAborted(options.signal))
27105
+ throw new HandoffNotThrough(`the read of ${url} was cancelled before Octocrawl read it`, null, "cancelled");
25932
27106
  let targetId;
25933
27107
  try {
25934
27108
  targetId = (await connection.send("Target.createTarget", { url: "about:blank" })).targetId;
@@ -25936,10 +27110,29 @@ async function readPage(connection, browser, url, options) {
25936
27110
  throw ended(error);
25937
27111
  }
25938
27112
  const stops = [];
27113
+ let kept = false;
25939
27114
  try {
25940
27115
  const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
25941
- const heard = { document: null, act: null, ours: false, landings: /* @__PURE__ */ new Set(), documents: 0 };
27116
+ const heard = { document: null, act: null, ours: false, landings: /* @__PURE__ */ new Set(), documents: 0, committed: null, within: null };
25942
27117
  if (connection.on !== void 0) {
27118
+ stops.push(connection.on("Page.frameNavigated", sessionId, (params) => {
27119
+ const frame = params.frame;
27120
+ if (frame?.id !== targetId || frame.parentId !== void 0)
27121
+ return;
27122
+ heard.committed = String(frame.url ?? "");
27123
+ heard.within = null;
27124
+ }));
27125
+ stops.push(connection.on("Page.navigatedWithinDocument", sessionId, (params) => {
27126
+ if (params.frameId !== targetId)
27127
+ return;
27128
+ const within = { url: String(params.url ?? ""), byPage: null };
27129
+ heard.within = within;
27130
+ void (async () => {
27131
+ const made = await connection.send("Page.createIsolatedWorld", { frameId: targetId, worldName: WORLD }, sessionId);
27132
+ const active = await connection.send("Runtime.evaluate", { expression: "navigator.userActivation.hasBeenActive", contextId: made.executionContextId, returnByValue: true }, sessionId);
27133
+ within.byPage = active.result?.value === false;
27134
+ })().catch(() => void 0);
27135
+ }));
25943
27136
  stops.push(connection.on("Network.requestWillBeSent", sessionId, (params) => {
25944
27137
  if (params.type === "Document" && params.frameId === targetId && params.hasUserGesture === true)
25945
27138
  heard.act ??= "gesture_navigation";
@@ -25954,12 +27147,13 @@ async function readPage(connection, browser, url, options) {
25954
27147
  heard.landings.add(pageOf(heard.document.url));
25955
27148
  heard.ours = false;
25956
27149
  }
25957
- const seen = heard.document;
25958
- const check = classifyGate({ status: seen.status, header: (name) => seen.headers[name.toLowerCase()] ?? null, body: "" });
25959
- if (check !== null || CHECK_STATUSES.has(seen.status))
25960
- sawGate ??= check?.reason ?? `http_${seen.status}`;
27150
+ const seen2 = heard.document;
27151
+ const check = classifyGate({ status: seen2.status, header: (name) => seen2.headers[name.toLowerCase()] ?? null, body: "" });
27152
+ if (check !== null || CHECK_STATUSES.has(seen2.status))
27153
+ sawGate ??= check?.reason ?? `http_${seen2.status}`;
25961
27154
  }));
25962
27155
  await connection.send("Network.enable", {}, sessionId);
27156
+ await connection.send("Page.enable", {}, sessionId);
25963
27157
  }
25964
27158
  await connection.send("Target.activateTarget", { targetId }).catch(() => void 0);
25965
27159
  let navigation = null;
@@ -25979,11 +27173,12 @@ async function readPage(connection, browser, url, options) {
25979
27173
  let hiddenSince = null;
25980
27174
  let toldHidden = false;
25981
27175
  let clear = 0;
27176
+ let seen = { href: "", documents: 0 };
25982
27177
  let last = null;
25983
27178
  while (Date.now() - started < waitMs) {
25984
27179
  await new Promise((resolve4) => setTimeout(resolve4, pollMs));
25985
27180
  if (options.signal?.aborted === true)
25986
- throw new HandoffNotThrough(`the handoff of ${url} was cancelled before Octocrawl read it`, sawGate);
27181
+ throw new HandoffNotThrough(`the read of ${url} was cancelled before Octocrawl read it`, sawGate, "cancelled");
25987
27182
  if (navigation !== null && gone(navigation) !== null)
25988
27183
  throw gone(navigation);
25989
27184
  let state;
@@ -25995,6 +27190,7 @@ async function readPage(connection, browser, url, options) {
25995
27190
  continue;
25996
27191
  }
25997
27192
  state = JSON.parse(answer.result.value);
27193
+ seen = { href: state.href, documents: heard.documents };
25998
27194
  if (typeof info.targetInfo?.url === "string")
25999
27195
  state.href = info.targetInfo.url;
26000
27196
  if (heard.act === null) {
@@ -26024,7 +27220,8 @@ async function readPage(connection, browser, url, options) {
26024
27220
  hiddenSince = null;
26025
27221
  toldHidden = false;
26026
27222
  }
26027
- const response = heard.document !== null && sameDocument2(heard.document.url, state.href) ? heard.document : null;
27223
+ const inPlace = heard.committed !== null && heard.within !== null && heard.within.byPage === true && sameDocument2(heard.within.url, state.href) && (pageOf(heard.committed) === pageOf(url) || heard.landings.has(pageOf(heard.committed))) && rewrittenInPlace(heard.committed, state.href);
27224
+ const response = heard.document !== null && (sameDocument2(heard.document.url, state.href) || inPlace && sameDocument2(heard.document.url, heard.committed)) ? heard.document : null;
26028
27225
  const status = response?.status ?? state.status;
26029
27226
  last = { state, response };
26030
27227
  const { full, decisive, gate } = checksOf(state, response, status, options);
@@ -26036,13 +27233,14 @@ async function readPage(connection, browser, url, options) {
26036
27233
  }
26037
27234
  const typing = state.field !== null && field3 !== void 0 && state.field !== field3;
26038
27235
  field3 = state.field;
26039
- const asked = pageOf(state.href) === pageOf(url) || heard.landings.has(pageOf(state.href));
27236
+ const asked = pageOf(state.href) === pageOf(url) || heard.landings.has(pageOf(state.href)) || inPlace;
26040
27237
  const arrived = returns === 0 || connection.on === void 0 || heard.documents > documentsAtReturn;
26041
- const through = state.ready === "complete" && gate === null && (status === null || status >= 200 && status < 300) && sameSite(state.href, host) && !onLoginPath(state.href, url) && !state.secret && !typing && arrived;
27238
+ const inScope = allowed === null || allowed.has(hostAndPort(state.href));
27239
+ const through = inScope && state.ready === "complete" && gate === null && (status === null || status >= 200 && status < 300) && sameSite(state.href, host) && !onLoginPath(state.href, url) && !state.secret && !typing && arrived;
26042
27240
  clear = through ? clear + 1 : 0;
26043
27241
  if (clear < CLEAR_READS)
26044
27242
  continue;
26045
- if (heard.act === null) {
27243
+ if (heard.act === null && options.unattended !== true) {
26046
27244
  if (!confirming) {
26047
27245
  confirming = true;
26048
27246
  options.onConfirm?.(url);
@@ -26050,35 +27248,64 @@ async function readPage(connection, browser, url, options) {
26050
27248
  continue;
26051
27249
  }
26052
27250
  if (!asked) {
26053
- if (returns >= RETURNS)
26054
- throw new HandoffNotThrough(`${url} was not read: after you got through, the tab stayed on ${pageOf(state.href)}, not the page asked for`, sawGate);
27251
+ if (returns >= RETURNS) {
27252
+ const where2 = sawGate !== null && heard.act !== null ? `after you got through, the tab stayed on ${pageOf(state.href)}` : `each time Octocrawl took the tab back to it, the site led it on to ${pageOf(state.href)}`;
27253
+ throw new HandoffNotThrough(`${url} was not read: ${where2}, not the page asked for`, sawGate, "elsewhere");
27254
+ }
26055
27255
  returns++;
26056
27256
  documentsAtReturn = heard.documents;
26057
27257
  clear = 0;
26058
27258
  open(true);
26059
27259
  continue;
26060
27260
  }
26061
- return {
27261
+ let html2 = state.html;
27262
+ let whole;
27263
+ if (options.shownOnly === true) {
27264
+ try {
27265
+ const shown = await connection.send("Runtime.evaluate", { expression: SHOWN_HTML, returnByValue: true }, sessionId);
27266
+ if (shown.exceptionDetails !== void 0 || typeof shown.result?.value !== "string") {
27267
+ clear = 0;
27268
+ continue;
27269
+ }
27270
+ const now = JSON.parse(shown.result.value);
27271
+ if (now.href !== seen.href || heard.documents !== seen.documents) {
27272
+ clear = 0;
27273
+ continue;
27274
+ }
27275
+ html2 = now.shown;
27276
+ whole = state.html;
27277
+ } catch (error) {
27278
+ const left = gone(error);
27279
+ if (left !== null)
27280
+ throw left;
27281
+ world = null;
27282
+ clear = 0;
27283
+ continue;
27284
+ }
27285
+ }
27286
+ kept = true;
27287
+ return { read: {
26062
27288
  requestedUrl: url,
26063
27289
  finalUrl: state.href,
26064
27290
  status,
26065
27291
  contentType: response?.headers["content-type"] ?? null,
26066
- html: state.html,
27292
+ html: html2,
27293
+ ...whole === void 0 ? {} : { document: whole },
26067
27294
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
26068
27295
  wallMs: Date.now() - started,
26069
27296
  sawGate,
26070
27297
  act: heard.act,
26071
27298
  browser
26072
- };
27299
+ }, tab: { targetId, sessionId, stops } };
26073
27300
  }
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";
26075
- throw new HandoffNotThrough(`${url} was not through within ${Math.round(waitMs / 1e3)} s: ${where}`, sawGate);
27301
+ const holding = last === null ? null : stillGated(last, options);
27302
+ const where = last === null ? "it never loaded" : allowed !== null && !allowed.has(hostAndPort(last.state.href)) ? `it was on ${hostAndPort(last.state.href)}, which you did not allow (only ${[...allowed].join(", ")})` : !sameSite(last.state.href, host) ? `it was on ${safeHost2(last.state.href)}, not ${host}` : holding !== null ? `it still showed a check (${holding.reason}: ${holding.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";
27303
+ throw new HandoffNotThrough(`${url} was not through within ${Math.round(waitMs / 1e3)} s: ${where}`, holding?.reason ?? sawGate);
26076
27304
  } catch (error) {
26077
27305
  throw ended(error);
26078
27306
  } finally {
26079
- for (const stop of stops)
26080
- stop();
26081
- await connection.send("Target.closeTarget", { targetId }).catch(() => void 0);
27307
+ if (!kept)
27308
+ await closeTab(connection, { targetId, sessionId: "", stops });
26082
27309
  }
26083
27310
  }
26084
27311
  function checksOf(state, response, status, options) {
@@ -26119,6 +27346,37 @@ function pageOf(href) {
26119
27346
  function sameDocument2(a, b) {
26120
27347
  return a.split("#")[0] === b.split("#")[0];
26121
27348
  }
27349
+ function rewrittenInPlace(came, shown) {
27350
+ let a;
27351
+ let b;
27352
+ try {
27353
+ a = new URL(came);
27354
+ b = new URL(shown);
27355
+ } catch {
27356
+ return false;
27357
+ }
27358
+ if (a.origin !== b.origin || (a.pathname.replace(/\/+$/, "") || "/") !== (b.pathname.replace(/\/+$/, "") || "/"))
27359
+ return false;
27360
+ for (const name of new Set(a.searchParams.keys())) {
27361
+ if (!b.searchParams.has(name)) {
27362
+ if (a.searchParams.getAll(name).some((value) => /^\d+$/.test(value)))
27363
+ return false;
27364
+ continue;
27365
+ }
27366
+ const left = a.searchParams.getAll(name);
27367
+ const right = b.searchParams.getAll(name);
27368
+ if (left.length !== right.length || left.some((value, i) => value !== right[i]))
27369
+ return false;
27370
+ }
27371
+ return true;
27372
+ }
27373
+ function hostAndPort(href) {
27374
+ try {
27375
+ return new URL(href).host;
27376
+ } catch {
27377
+ return "another page";
27378
+ }
27379
+ }
26122
27380
  function safeHost2(href) {
26123
27381
  try {
26124
27382
  return new URL(href).hostname;
@@ -27054,7 +28312,7 @@ function modelSchema(root) {
27054
28312
  throw error;
27055
28313
  }
27056
28314
  }
27057
- async function callModel(result, format, request7, deterministic, execution, config, repair) {
28315
+ async function callModel(result, format, request8, deterministic, execution, config, repair) {
27058
28316
  execution.signal?.throwIfAborted();
27059
28317
  const fetchImpl = config.fetch ?? fetch;
27060
28318
  const response = await fetchImpl(`${config.baseUrl.replace(/\/$/, "")}/v1/chat/completions`, {
@@ -27083,7 +28341,7 @@ async function callModel(result, format, request7, deterministic, execution, con
27083
28341
  ],
27084
28342
  response_format: {
27085
28343
  type: "json_schema",
27086
- json_schema: { name: "w2l_extract", strict: request7.strict, schema: request7.schema }
28344
+ json_schema: { name: "w2l_extract", strict: request8.strict, schema: request8.schema }
27087
28345
  }
27088
28346
  })
27089
28347
  });
@@ -27211,7 +28469,7 @@ async function extractStructured(result, format, execution = {}, modelConfig = s
27211
28469
  const base = withoutPaths(data, broken);
27212
28470
  const baseEvidence = evidence.filter((item) => !broken.has(item.path));
27213
28471
  const deterministic = withoutPaths(mapped, broken);
27214
- const request7 = modelSchema(format.schema);
28472
+ const request8 = modelSchema(format.schema);
27215
28473
  let attempts = 0;
27216
28474
  let inputTokens = 0;
27217
28475
  let outputTokens = 0;
@@ -27223,13 +28481,13 @@ async function extractStructured(result, format, execution = {}, modelConfig = s
27223
28481
  inputTokens: tokensKnown ? inputTokens : null,
27224
28482
  outputTokens: tokensKnown ? outputTokens : null,
27225
28483
  externalCostUsd: null,
27226
- strict: request7.strict,
27227
- ...request7.reason === void 0 ? {} : { strictReason: request7.reason }
28484
+ strict: request8.strict,
28485
+ ...request8.reason === void 0 ? {} : { strictReason: request8.reason }
27228
28486
  });
27229
28487
  while (attempts < 2) {
27230
28488
  attempts++;
27231
28489
  try {
27232
- const model = await callModel(result, format, request7, deterministic, execution, modelConfig, repair);
28490
+ const model = await callModel(result, format, request8, deterministic, execution, modelConfig, repair);
27233
28491
  if (model.inputTokens === null || model.outputTokens === null)
27234
28492
  tokensKnown = false;
27235
28493
  else {
@@ -27361,7 +28619,7 @@ function scrapeEvidenceRecord(result, req, delivered) {
27361
28619
  return toEvidenceRecord(result, { mode: defaultApiMode(req.mode) }, { markdown: delivered.markdown, ...delivered.json === void 0 ? {} : { json: delivered.json } });
27362
28620
  }
27363
28621
  var NO_PAGE_METADATA = { title: null, description: null, language: null, keywords: null, robots: null, favicon: null, canonicalUrl: null };
27364
- var BROWSER_LANES2 = /* @__PURE__ */ new Set(["browser_local", "browser_local_authed", "browser_proxy"]);
28622
+ var BROWSER_LANES2 = /* @__PURE__ */ new Set(["browser_local", "browser_local_authed", "browser_proxy", "my_browser"]);
27365
28623
  function proxyUsedOf(result) {
27366
28624
  if (result.compliance?.access.egressOwner === "user")
27367
28625
  return "user";
@@ -27528,6 +28786,50 @@ function writeTaskEgress(file, id) {
27528
28786
  `, { mode: 384 });
27529
28787
  }
27530
28788
  var DEFAULT_WORKER_COUNT = 4;
28789
+ function listPagesRepeat(pages, itemSelector) {
28790
+ if (invalidSelector(itemSelector) !== null)
28791
+ return true;
28792
+ const merged = mergeListPages(pages.map(({ url, html: html2 }) => ({ url, html: html2 })), { type: "list", itemSelector });
28793
+ return merged === null || merged.spec === null || merged.list.pages !== pages.length || merged.list.truncated;
28794
+ }
28795
+ function continuedItemsRead(pages) {
28796
+ const at = (href) => {
28797
+ try {
28798
+ const parsed = new URL(href);
28799
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "") || "/"}${parsed.search}`;
28800
+ } catch {
28801
+ return href;
28802
+ }
28803
+ };
28804
+ const kept = pages.kept.map(at);
28805
+ const addressed = new Set(kept).size === kept.length && !kept.includes(at(pages.checkUrl)) && at(pages.checkUrl) !== at(pages.stepUrl) && pages.shown.every((page) => !kept.includes(at(page.url)));
28806
+ if (!addressed || typeof pages.keptItems !== "number")
28807
+ return null;
28808
+ let sum = pages.keptItems;
28809
+ for (const page of pages.shown) {
28810
+ if (typeof page.items !== "number")
28811
+ return null;
28812
+ sum += page.items;
28813
+ }
28814
+ return sum;
28815
+ }
28816
+ function settledBy(promise, context) {
28817
+ if (context.signal?.aborted || context.deadlineAt !== void 0 && context.deadlineAt <= Date.now())
28818
+ return Promise.resolve(null);
28819
+ if (context.signal === void 0 && context.deadlineAt === void 0)
28820
+ return promise;
28821
+ return new Promise((resolve4) => {
28822
+ const done = (value) => {
28823
+ clearTimeout(timer);
28824
+ context.signal?.removeEventListener("abort", aborted);
28825
+ resolve4(value);
28826
+ };
28827
+ const aborted = () => done(null);
28828
+ const timer = context.deadlineAt === void 0 ? void 0 : setTimeout(aborted, context.deadlineAt - Date.now());
28829
+ context.signal?.addEventListener("abort", aborted, { once: true });
28830
+ promise.then(done, () => done(null));
28831
+ });
28832
+ }
27531
28833
  function fetchedPastRobots(result) {
27532
28834
  return result.compliance?.robots.override !== void 0 || result.trace.some((event) => event.event === "robots_overridden");
27533
28835
  }
@@ -27569,9 +28871,13 @@ function createApiEngine(options = {}) {
27569
28871
  const compatTakes = (url, page) => compatHostListed(compatHosts, url) && compatOptionsRefusal(page) === null;
27570
28872
  const grantedBudget = (budget2) => {
27571
28873
  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) };
28874
+ const perPage = accessGrant?.budget.perRequestUsd ?? null;
28875
+ return {
28876
+ ...budget2,
28877
+ ...cap === null ? {} : { maxCostUsd: budget2.maxCostUsd === null ? cap : Math.min(budget2.maxCostUsd, cap) },
28878
+ // One page's own cap within the run's (ROADMAP PA item 4).
28879
+ ...perPage === null ? {} : { maxCostPerPageUsd: perPage }
28880
+ };
27575
28881
  };
27576
28882
  const basePolicy = options.networkPolicy ?? localNetworkPolicy();
27577
28883
  const networkPolicy = {
@@ -27581,7 +28887,13 @@ function createApiEngine(options = {}) {
27581
28887
  // The operator's file cap: the policy's own, else W2L_MAX_FILE_BYTES, else the default.
27582
28888
  maxFileBytes: basePolicy.maxFileBytes ?? maxFileBytesFromEnv(process.env)
27583
28889
  };
27584
- const egressPool = egressProxyList.length === 0 ? null : new EgressPool(egressProxyList, networkPolicy);
28890
+ const egressPool = egressProxyList.length === 0 ? null : new EgressPool(egressProxyList, networkPolicy, Date.now, options.egressEchoUrl ?? null);
28891
+ const withEgressExit = async (result, egress, context = {}) => {
28892
+ if (egress === void 0 || egressPool === null || result.trace.some((event) => event.event === "cache_hit"))
28893
+ return result;
28894
+ const exit = await settledBy(egressPool.exitOf(egress.id), context);
28895
+ return exit === null ? result : { ...result, trace: [...result.trace, { at: 0, lane: result.lane, event: "egress_exit", detail: { proxy: egress.id, ...exit } }] };
28896
+ };
27585
28897
  const fileStore = new FileStore(join10(taskRoot, "files"));
27586
28898
  const pageCache = PageCache.open(taskRoot);
27587
28899
  const cachePlanFor = (url, mode, page, formats, channels, robotsOverride, robotsSetAside2) => {
@@ -27701,6 +29013,11 @@ function createApiEngine(options = {}) {
27701
29013
  if (hosted && req.actions !== void 0)
27702
29014
  throw new RequestError("actions are not available in hosted mode: run Octocrawl locally to use them");
27703
29015
  };
29016
+ const checkAccessChoice = (req) => {
29017
+ if (req.access !== "enhanced" || accessGrant?.tier === "enhanced")
29018
+ return;
29019
+ throw new RequestError("access enhanced: this server has no approved budget for enhanced access (an access grant of tier enhanced, --access-grant or W2L_ACCESS_GRANT, ADR 0005); ask with access standard, or my-browser on your own machine", "unsupported_parameter", { parameters: ["access"] }, [REFUSAL_HINTS.stealth]);
29020
+ };
27704
29021
  const jobEvents = new JobEventHub();
27705
29022
  const jobWebhooks = new JobWebhooks(deliveryStore, { hosted, allowHttpLoopback: options.webhookPolicy?.allowHttpLoopback ?? !hosted });
27706
29023
  const logWebhookFailure = (taskId, stage, error) => {
@@ -27764,23 +29081,24 @@ function createApiEngine(options = {}) {
27764
29081
  const activeScrapes = /* @__PURE__ */ new Set();
27765
29082
  const crawlControllers = /* @__PURE__ */ new Map();
27766
29083
  const runningCrawls = /* @__PURE__ */ new Map();
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 });
29084
+ const createChannels = options.channelsFor ?? ((mode, egress, enhanced) => {
29085
+ 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 ?? [] }, vendorTariffs: accessGrant?.tariffs ?? {}, ...accessGrant === null ? {} : { vendorGrant: accessGrantRef(accessGrant) }, browserEngine: options.browserEngine ?? "playwright", compatTransport: compatHosts.length > 0, ...enhanced === true ? { enhanced } : {} });
27769
29086
  return options.httpOnly ? channels.filter((channel) => HTTP_CHANNELS.has(channel.id)) : channels;
27770
29087
  });
27771
29088
  const channelsByMode = /* @__PURE__ */ new Map();
27772
29089
  const historiesByMode = /* @__PURE__ */ new Map();
27773
- const channelsFor = (mode, egress) => {
27774
- const key = egress === void 0 ? mode : `${mode}|${egress.id}`;
29090
+ const channelsFor = (mode, egress, enhanced = false) => {
29091
+ const asEnhanced = enhanced && mode === "standard";
29092
+ const key = `${egress === void 0 ? mode : `${mode}|${egress.id}`}${asEnhanced ? "|enhanced" : ""}`;
27775
29093
  const existing = channelsByMode.get(key);
27776
29094
  if (existing !== void 0)
27777
29095
  return existing;
27778
- const channels = createChannels(mode, egress);
29096
+ const channels = asEnhanced ? createChannels(mode, egress, true) : createChannels(mode, egress);
27779
29097
  channelsByMode.set(key, channels);
27780
29098
  return channels;
27781
29099
  };
27782
29100
  const channelsForUrl = (mode, url, page = {}, formats = [], oneUrl = true, compat = true, egress) => {
27783
- const channels = channelsFor(mode, egress);
29101
+ const channels = channelsFor(mode, egress, page.access === "enhanced");
27784
29102
  const policy = options.channelPolicy?.(url) ?? "ladder";
27785
29103
  let selected = policy === "ladder" ? channels : channels.filter((channel) => policy === "http_only" ? HTTP_CHANNELS.has(channel.id) : channel.id === "browser_local");
27786
29104
  if (!compat || oneUrl && !compatHostListed(compatHosts, url))
@@ -27822,6 +29140,12 @@ function createApiEngine(options = {}) {
27822
29140
  filtered.push({ reason: "fastMode", dropped });
27823
29141
  selected = kept;
27824
29142
  }
29143
+ if (page.access === "standard") {
29144
+ const dropped = selected.filter((channel) => channel.vendorId !== void 0).map(name);
29145
+ if (dropped.length > 0)
29146
+ filtered.push({ reason: "access standard", dropped });
29147
+ selected = selected.filter((channel) => channel.vendorId === void 0);
29148
+ }
27825
29149
  const wire = ["headers", "mobile", "skipTlsVerification"].filter((option) => option === "headers" ? page.headers !== void 0 && Object.keys(page.headers).length > 0 : page[option] === true);
27826
29150
  if (wire.length > 0) {
27827
29151
  const dropped = selected.filter((channel) => channel.vendorId !== void 0).map(name);
@@ -27888,7 +29212,7 @@ function createApiEngine(options = {}) {
27888
29212
  const completed = await store.countCompletedSteps(taskId);
27889
29213
  const counts = await store.countSteps(taskId);
27890
29214
  const webhook = jobWebhooks.status(task);
27891
- const waitingForPerson = userChrome === null || !offersHandoff(task) ? void 0 : Object.entries(await store.countBlockReasons(taskId)).reduce((sum, [reason2, count]) => sum + (HANDOFF_REASONS[reason2] === void 0 ? 0 : count), 0);
29215
+ const waitingForPerson = userChrome === null || !offersHandoff(task) ? void 0 : listsPages2(task) ? (await stepsOf(store, taskId, "errors")).filter((step) => handoffNeededIn(task, step)).length : Object.entries(await store.countBlockReasons(taskId)).reduce((sum, [reason2, count]) => sum + (HANDOFF_REASONS[reason2] === void 0 ? 0 : count), 0);
27892
29216
  return {
27893
29217
  ...report,
27894
29218
  requested: task.batch.urls.length,
@@ -27898,9 +29222,10 @@ function createApiEngine(options = {}) {
27898
29222
  succeeded: (counts.success ?? 0) + (counts.partial ?? 0) + (counts.empty_verified ?? 0),
27899
29223
  failed: (counts.failed ?? 0) + (counts.blocked ?? 0) + (counts.cancelled ?? 0) + (counts.budget_exceeded ?? 0),
27900
29224
  // The cap in force: the batch's own, never above this service's worker count.
27901
- maxConcurrency: Math.min(task.batch.maxConcurrency ?? workerCount, workerCount),
29225
+ maxConcurrency: task.batch.lane === "my-browser" ? 1 : Math.min(task.batch.maxConcurrency ?? workerCount, workerCount),
27902
29226
  ...task.batch.invalidURLs === void 0 ? {} : { invalidURLs: task.batch.invalidURLs },
27903
29227
  ...waitingForPerson === void 0 ? {} : { waitingForPerson },
29228
+ ...approvalsWaiting.has(taskId) ? { waitingForApproval: true } : {},
27904
29229
  ...webhook === void 0 ? {} : { webhook }
27905
29230
  };
27906
29231
  } finally {
@@ -27982,6 +29307,74 @@ function createApiEngine(options = {}) {
27982
29307
  await store.close();
27983
29308
  }
27984
29309
  }
29310
+ function listCheckOf(result) {
29311
+ return (result.actions?.lists ?? []).find((run) => run.type === "paginate" && run.stoppedBy === "challenge" && run.challenge !== void 0) ?? null;
29312
+ }
29313
+ async function continueList(chrome, step, run, selection, fetchOpts, waitMs, hooks, signal) {
29314
+ const prior = step.result;
29315
+ const action = (selection.actions ?? []).find((item) => item.type === "paginate");
29316
+ if (action === void 0)
29317
+ return { reason: "the batch has no paginate step to go on with" };
29318
+ const kept = (prior.actions?.scrapes ?? []).filter((scrape) => scrape.step === run.index).map(({ url: url2, html: html2 }) => ({ url: url2, html: html2 }));
29319
+ const from = run.challenge.page;
29320
+ const url = run.challenge.url;
29321
+ try {
29322
+ const judged = { ...fetchOpts.includeTags === void 0 ? {} : { includeTags: fetchOpts.includeTags }, ...fetchOpts.excludeTags === void 0 ? {} : { excludeTags: fetchOpts.excludeTags }, ...fetchOpts.blockAds === void 0 ? {} : { blockAds: fetchOpts.blockAds } };
29323
+ const read2 = await chrome.readList(url, {
29324
+ ...waitMs === void 0 ? {} : { waitMs },
29325
+ ...hooks.onWaiting === void 0 ? {} : { onWaiting: hooks.onWaiting },
29326
+ ...hooks.onConfirm === void 0 ? {} : { onConfirm: hooks.onConfirm },
29327
+ ...hooks.onHidden === void 0 ? {} : { onHidden: hooks.onHidden },
29328
+ signal,
29329
+ ...judged,
29330
+ follow: {
29331
+ nextSelector: action.nextSelector,
29332
+ ...action.itemSelector === void 0 ? {} : { itemSelector: action.itemSelector },
29333
+ // A pager with no address of its own opens at the list's own page: the pages before the check are shown again on the way, and do not count against the limit.
29334
+ maxPages: Math.max(1, (action.maxPages ?? LIST_DEFAULTS.maxPages) - (new URL(url).pathname + new URL(url).search === new URL(step.url).pathname + new URL(step.url).search ? 0 : kept.length)),
29335
+ onStart: (pageUrl) => hooks.onContinue?.(pageUrl, kept.length + 1),
29336
+ onPage: (pageUrl, pages2) => hooks.onContinue?.(pageUrl, kept.length + pages2)
29337
+ }
29338
+ });
29339
+ if (read2.stoppedBy === "deadline" && read2.pages.length === 1)
29340
+ return { reason: `you got through the check at page ${from}, but did not page on in that tab in time: hand the batch over again to go on from there` };
29341
+ const last = read2.pages[read2.pages.length - 1];
29342
+ const { list: list2, ...withoutList } = fetchOpts;
29343
+ const base = pageFromUserBrowser({ ...read2.first, finalUrl: last.url, html: last.html }, prior, withoutList);
29344
+ const pages = [...kept, ...read2.pages];
29345
+ const merged = list2 === void 0 ? null : mergeListPages(pages, list2);
29346
+ const rounds = pages.length;
29347
+ const continued = { from, pages: read2.pages.length, by: "user_browser" };
29348
+ const itemsRead = action.itemSelector === void 0 || listPagesRepeat(pages, action.itemSelector) ? null : continuedItemsRead({ stepUrl: step.url, checkUrl: url, kept: kept.map((page) => page.url), keptItems: run.itemsRead, shown: read2.pages });
29349
+ const lists = (prior.actions?.lists ?? []).map((item) => item.index === run.index ? { ...item, stoppedBy: read2.stoppedBy, rounds, items: last.items ?? null, itemsRead, continued } : item);
29350
+ const actions = { ...prior.actions, scrapes: [...(prior.actions?.scrapes ?? []).filter((scrape) => scrape.step !== run.index), ...kept.map((page) => ({ ...page, step: run.index })), ...read2.pages.map(({ url: pageUrl, html: html2 }) => ({ url: pageUrl, html: html2, step: run.index, by: "user_browser" }))], lists };
29351
+ const valued = merged !== null && merged.valued;
29352
+ const short = read2.stoppedBy !== "end";
29353
+ const at = base.usage.wallMs;
29354
+ const result = {
29355
+ ...base,
29356
+ ...valued ? { status: "success", failureReason: null, blockReason: null } : {},
29357
+ actions,
29358
+ ...merged === null ? {} : { list: merged.list },
29359
+ warnings: [
29360
+ ...(base.warnings ?? []).filter((warning) => warning.code !== "list_not_exhausted"),
29361
+ ...short ? [{ code: "list_not_exhausted", message: `The list of step ${run.index} (paginate) stopped before its end (${read2.stoppedBy === "max" ? `its limit of ${rounds} pages` : "the wait for you to page on in your Chrome ended"}): more items may follow.` }] : []
29362
+ ],
29363
+ trace: [
29364
+ ...base.trace,
29365
+ { at, lane: base.lane, event: "list_continued", detail: { step: run.index, from, pages: read2.pages.length, stoppedBy: read2.stoppedBy, by: "user_browser", kept: kept.length, keptBy: prior.lane } },
29366
+ ...merged === null ? [] : [{ at, lane: base.lane, event: "list_extracted", detail: { records: merged.list.records.length, incomplete: merged.list.incomplete, pages: merged.list.pages, truncated: merged.list.truncated } }]
29367
+ ]
29368
+ };
29369
+ if (!CONTENTFUL_STATUS.has(result.status))
29370
+ return { reason: `the page Octocrawl read in Chrome was ${result.status} (${result.blockReason ?? result.failureReason ?? "no reason"}), not the list` };
29371
+ return { result };
29372
+ } catch (error) {
29373
+ if (!(error instanceof HandoffNotThrough))
29374
+ throw error;
29375
+ return { reason: error.message };
29376
+ }
29377
+ }
27985
29378
  async function readThrough(chrome, url, prior, fetchOpts, waitMs, hooks, signal) {
27986
29379
  try {
27987
29380
  const judged = { ...fetchOpts.includeTags === void 0 ? {} : { includeTags: fetchOpts.includeTags }, ...fetchOpts.excludeTags === void 0 ? {} : { excludeTags: fetchOpts.excludeTags }, ...fetchOpts.blockAds === void 0 ? {} : { blockAds: fetchOpts.blockAds } };
@@ -28005,6 +29398,146 @@ function createApiEngine(options = {}) {
28005
29398
  if (unread !== null)
28006
29399
  throw new RequestError(`handoff: the request asks for ${unread}, which a page read in your own Chrome cannot give`, "unsupported_parameter", { parameters: ["handoff"] });
28007
29400
  }
29401
+ function checkMyBrowser(req) {
29402
+ if (req.lane !== "my-browser")
29403
+ return;
29404
+ if ("urls" in req && req.maxConcurrency !== void 0 && req.maxConcurrency > 1)
29405
+ throw new RequestError("lane my-browser reads one page at a time, in your Chrome: maxConcurrency above 1 does not apply", "unsupported_parameter", { parameters: ["lane", "maxConcurrency"] });
29406
+ if (userChrome === null)
29407
+ throw new RequestError("lane my-browser: this server does not read pages in your Chrome; run Octocrawl on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI)", "unsupported_parameter", { parameters: ["lane"] });
29408
+ const unread = unreadByPerson(fetchOptions(req, req.formats));
29409
+ if (unread !== null)
29410
+ throw new RequestError(`lane my-browser: the request asks for ${unread}, which the my-browser lane does not give yet`, "unsupported_parameter", { parameters: ["lane"] });
29411
+ if (req.mode !== void 0 && req.mode !== "standard")
29412
+ throw new RequestError(`lane my-browser reads the page as you, in your Chrome: mode ${req.mode} does not apply`, "unsupported_parameter", { parameters: ["lane", "mode"] });
29413
+ if (req.lockdown === true)
29414
+ throw new RequestError("lane my-browser reads the page in your Chrome: lockdown answers from the cache alone, and a page read there is never cached", "unsupported_parameter", { parameters: ["lane", "lockdown"] });
29415
+ }
29416
+ async function myBrowserScrape(req, context, hooks) {
29417
+ if (handoffClosing.signal.aborted)
29418
+ throw new HandoffUnavailableError("Octocrawl is shutting down");
29419
+ const signal = AbortSignal.any([...context.signal === void 0 ? [] : [context.signal], ...hooks.signal === void 0 ? [] : [hooks.signal], shutdownController.signal, handoffClosing.signal]);
29420
+ const started = Date.now();
29421
+ let chrome;
29422
+ try {
29423
+ chrome = await openUserChrome(userChrome, signal);
29424
+ } catch (error) {
29425
+ throw new HandoffUnavailableError(error instanceof Error ? error.message : String(error));
29426
+ }
29427
+ try {
29428
+ const hosts = [new URL(req.url).host];
29429
+ hooks.onAllow?.(hosts);
29430
+ let allowed;
29431
+ try {
29432
+ allowed = await chrome.allow({ hosts, task: `scrape ${req.url}` }, { signal, log: approvalLog("scrape", null) });
29433
+ } catch (error) {
29434
+ throw new HandoffUnavailableError(error instanceof Error ? error.message : String(error));
29435
+ }
29436
+ try {
29437
+ return await readAllowed(chrome, allowed, req.url, fetchOptions(req, req.formats), req.handoff?.waitMs, hooks, signal, started);
29438
+ } finally {
29439
+ await allowed.close();
29440
+ }
29441
+ } finally {
29442
+ chrome.close();
29443
+ }
29444
+ }
29445
+ async function readAllowed(chrome, allowed, url, fetchOpts, waitMs, hooks, signal, started) {
29446
+ try {
29447
+ const read2 = await chrome.read(url, {
29448
+ unattended: true,
29449
+ allowedHosts: allowed.hosts,
29450
+ // What the person sees in their Chrome: not a panel the page keeps hidden (a sheet's shortcut list beside its grid).
29451
+ shownOnly: true,
29452
+ ...waitMs === void 0 ? {} : { waitMs },
29453
+ ...hooks.onWaiting === void 0 ? {} : { onWaiting: hooks.onWaiting },
29454
+ ...hooks.onHidden === void 0 ? {} : { onHidden: hooks.onHidden },
29455
+ signal: AbortSignal.any([signal, allowed.signal]),
29456
+ ...fetchOpts.includeTags === void 0 ? {} : { includeTags: fetchOpts.includeTags },
29457
+ ...fetchOpts.excludeTags === void 0 ? {} : { excludeTags: fetchOpts.excludeTags },
29458
+ ...fetchOpts.blockAds === void 0 ? {} : { blockAds: fetchOpts.blockAds }
29459
+ });
29460
+ return pageFromUserBrowser(read2, null, fetchOpts);
29461
+ } catch (error) {
29462
+ if (!(error instanceof HandoffNotThrough))
29463
+ throw error;
29464
+ const wallMs = Date.now() - started;
29465
+ if (allowed.signal.aborted)
29466
+ return unreadInUserBrowser(url, { status: "cancelled" }, `you revoked the sites in Chrome before ${url} was read`, wallMs);
29467
+ if (error.kind === "cancelled" || error.kind === "gone")
29468
+ return unreadInUserBrowser(url, { status: "cancelled" }, error.message, wallMs);
29469
+ if (error.kind === "chrome")
29470
+ return unreadInUserBrowser(url, { status: "failed", failureReason: "connection_error" }, error.message, wallMs);
29471
+ if (error.kind === "elsewhere")
29472
+ return unreadInUserBrowser(url, { status: "failed", failureReason: "redirect_limit" }, error.message, wallMs);
29473
+ const check = BLOCK_REASON.includes(error.check ?? "") ? error.check : null;
29474
+ return unreadInUserBrowser(url, check === null ? { status: "failed", failureReason: "timeout" } : { status: "blocked", blockReason: check }, error.message, wallMs);
29475
+ }
29476
+ }
29477
+ const approvalsWaiting = /* @__PURE__ */ new Set();
29478
+ const approvalLog = (kind, taskId) => (step, detail) => console.error(JSON.stringify({ component: "api", event: "my_browser_approval", kind, ...taskId === null ? {} : { taskId }, step, ...detail }));
29479
+ function myBrowserBatchReader(urls, taskId, fetchOpts) {
29480
+ const hosts = [...new Set(urls.map((url) => new URL(url).host))];
29481
+ let opened = null;
29482
+ const stop = new AbortController();
29483
+ const signal = AbortSignal.any([shutdownController.signal, handoffClosing.signal, stop.signal]);
29484
+ let reading = null;
29485
+ const open = () => opened ??= (async () => {
29486
+ if (userChrome === null)
29487
+ return { refused: "this server does not read pages in your Chrome: run Octocrawl on your own machine (octocrawl serve, the local MCP host, or the octocrawl CLI)", status: "connection_error" };
29488
+ let chrome;
29489
+ try {
29490
+ chrome = await openUserChrome(userChrome, signal);
29491
+ } catch (error) {
29492
+ return { refused: error instanceof Error ? error.message : String(error), status: "connection_error" };
29493
+ }
29494
+ approvalsWaiting.add(taskId);
29495
+ try {
29496
+ return { chrome, allowed: await chrome.allow({ hosts, task: `batch ${taskId}: ${urls.length} page${urls.length === 1 ? "" : "s"}` }, { signal, log: approvalLog("batch", taskId) }) };
29497
+ } catch (error) {
29498
+ chrome.close();
29499
+ return { refused: error instanceof Error ? error.message : String(error), status: "cancelled" };
29500
+ } finally {
29501
+ approvalsWaiting.delete(taskId);
29502
+ }
29503
+ })();
29504
+ const unread = (url, status, message2, started) => ({ result: unreadInUserBrowser(url, status === "cancelled" ? { status } : { status: "failed", failureReason: status }, message2, Date.now() - started), links: [] });
29505
+ return {
29506
+ async scrape(url, context) {
29507
+ const started = Date.now();
29508
+ const connection = await open();
29509
+ if ("refused" in connection)
29510
+ return unread(url, connection.status, connection.refused, started);
29511
+ if (connection.allowed.signal.aborted)
29512
+ return unread(url, "cancelled", `you revoked the sites in Chrome before ${url} was read`, started);
29513
+ if (!connection.allowed.hosts.includes(new URL(url).host))
29514
+ return unread(url, "cancelled", `${new URL(url).host} is not among the sites you allowed for this run of the batch`, started);
29515
+ if (signal.aborted || context?.signal?.aborted === true)
29516
+ return unread(url, "cancelled", `the batch stopped before ${url} was read`, started);
29517
+ const pending = readAllowed(connection.chrome, connection.allowed, url, fetchOpts, void 0, {}, AbortSignal.any([signal, ...context?.signal === void 0 ? [] : [context.signal]]), started);
29518
+ reading = pending;
29519
+ try {
29520
+ const read2 = await pending;
29521
+ return { result: read2, links: read2.links ?? [] };
29522
+ } finally {
29523
+ if (reading === pending)
29524
+ reading = null;
29525
+ }
29526
+ },
29527
+ async close() {
29528
+ stop.abort();
29529
+ if (opened === null)
29530
+ return;
29531
+ const connection = await opened;
29532
+ while (reading !== null)
29533
+ await reading.catch(() => void 0);
29534
+ if ("refused" in connection)
29535
+ return;
29536
+ await connection.allowed.close();
29537
+ connection.chrome.close();
29538
+ }
29539
+ };
29540
+ }
28008
29541
  async function handOffScrape(req, prior, context, hooks) {
28009
29542
  if (handoffClosing.signal.aborted)
28010
29543
  return { reason: "Octocrawl is shutting down" };
@@ -28054,7 +29587,7 @@ function createApiEngine(options = {}) {
28054
29587
  throw new CrawlStateError(`batch ${taskId} has a webhook: a page read in your own Chrome is read signed in as you, and is not sent to another address; its stopped items are not handed over`);
28055
29588
  handoffs.add(taskId);
28056
29589
  try {
28057
- const waiting = (await stepsOf(store, taskId, "errors")).filter(handoffNeeded);
29590
+ const waiting = (await stepsOf(store, taskId, "errors")).filter((step) => handoffNeededIn(task, step));
28058
29591
  const items = [];
28059
29592
  if (waiting.length > 0) {
28060
29593
  const selection = task.batch;
@@ -28074,7 +29607,8 @@ function createApiEngine(options = {}) {
28074
29607
  items.push({ id: step.id, url: step.url, through: false, status: step.status, reason: "the handoff was cancelled before this page" });
28075
29608
  continue;
28076
29609
  }
28077
- const read2 = await readThrough(chrome, step.url, step.result, fetchOpts, req.waitMs, hooks, signal);
29610
+ const listRun = step.result?.actions === void 0 ? null : listCheckOf(step.result);
29611
+ const read2 = listRun === null ? await readThrough(chrome, step.url, step.result, fetchOpts, req.waitMs, hooks, signal) : await continueList(chrome, step, listRun, selection, fetchOpts, req.waitMs, hooks, signal);
28078
29612
  if ("reason" in read2) {
28079
29613
  items.push({ id: step.id, url: step.url, through: false, status: step.status, reason: read2.reason });
28080
29614
  continue;
@@ -28094,6 +29628,8 @@ function createApiEngine(options = {}) {
28094
29628
  const { audit: _stoppedAudit, ...kept } = step;
28095
29629
  const replaced = { ...kept, status: stepStatusFromResult(stored.status), lane: stored.lane, contentHash: stored.evidence.rawBodySha256, cached: false, result: stored, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
28096
29630
  await store.putStep(replaced);
29631
+ if (listRun !== null)
29632
+ await store.clearPagesRead(taskId, step.canonicalUrl);
28097
29633
  items.push({ id: step.id, url: step.url, through: true, status: stored.status });
28098
29634
  const page = compactPage(replaced, task);
28099
29635
  if (webhookOf(task) !== void 0) {
@@ -28124,7 +29660,8 @@ function createApiEngine(options = {}) {
28124
29660
  const stored = task.batch ?? task.crawl;
28125
29661
  const selection = stored === void 0 || !hosted ? stored : { ...stored, skipTlsVerification: false };
28126
29662
  const egressFile = join10(task.taskDir, EGRESS_FILE);
28127
- const firstEgress = egressPool === null || mode === "authed" ? void 0 : bindTaskEgress(egressPool, egressFile);
29663
+ const myBrowser = task.batch?.lane === "my-browser" ? myBrowserBatchReader(task.batch.urls, task.id, fetchOptions(task.batch, task.batch.formats)) : null;
29664
+ const firstEgress = egressPool === null || mode === "authed" || myBrowser !== null ? void 0 : bindTaskEgress(egressPool, egressFile);
28128
29665
  let switches = 0;
28129
29666
  const lanes = [];
28130
29667
  const routeOf = (egress) => {
@@ -28138,7 +29675,7 @@ function createApiEngine(options = {}) {
28138
29675
  removeCookieSessions(task.taskDir);
28139
29676
  rmSync(egressFile, { force: true });
28140
29677
  };
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 });
29678
+ const runnerFor = (lane) => new LadderRunner(lane.channels, { mode, ...selection?.access === "enhanced" ? { enhanced: true } : {}, ...req.policyAllowlist.length ? { allowlistedDomains: req.policyAllowlist } : {} }, historyFor(mode), null, task.batch === void 0 ? null : sessionsFor(mode), { channelsFiltered: lane.filtered });
28142
29679
  const recordedOverrideFor = !robotsSetAside || task.batch?.robotsOverrides === void 0 ? null : robotsOverrideLookup(task.batch.robotsOverrides);
28143
29680
  const namedOverride = task.batch === void 0 ? ignoreRobotsOverride(task.crawl?.ignoreRobotsTxt) : namedUrlOverride;
28144
29681
  const robotsOverrideFor = recordedOverrideFor === null && namedOverride === void 0 ? null : (url) => recordedOverrideFor?.(url) ?? namedOverride;
@@ -28156,6 +29693,7 @@ function createApiEngine(options = {}) {
28156
29693
  lanes.push(current);
28157
29694
  const closeLadders = async () => {
28158
29695
  await Promise.all(lanes.map((lane) => lane.ladder.close()));
29696
+ await myBrowser?.close();
28159
29697
  };
28160
29698
  const pageAtom = selection === void 0 ? { scrape: (url, context) => {
28161
29699
  const lane = current;
@@ -28163,15 +29701,16 @@ function createApiEngine(options = {}) {
28163
29701
  }, close: closeLadders } : {
28164
29702
  async scrape(url, context) {
28165
29703
  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 } });
29704
+ const pageDeadline = myBrowser !== null ? context?.deadlineAt ?? Infinity : Math.min(context?.deadlineAt ?? Infinity, Date.now() + (selection.timeout ?? DEFAULT_SCRAPE_TIMEOUT_MS));
29705
+ const page = createExecutionScope({ ...context, ...pageDeadline === Infinity ? {} : { deadlineAt: pageDeadline }, ...lane.cookieSession === void 0 ? {} : { cookieSession: lane.cookieSession } });
28167
29706
  const formats = selection.formats ?? ["markdown"];
28168
29707
  const wants = (name) => hasFormat(formats, name);
28169
29708
  const custom = customJsonFormat(formats);
28170
29709
  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;
29710
+ const plan = myBrowser !== null ? null : lane.cookieSession === void 0 || cachePlan?.lockdown === true ? cachePlan : null;
28172
29711
  const answer = consultCache(plan, url, { mode, ...req.policyAllowlist.length ? { allowlistedDomains: req.policyAllowlist } : {} });
28173
29712
  const { outcome, json: json2 } = await (async () => {
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" };
29713
+ const outcome2 = answer.kind === "fetch" ? await (myBrowser !== null ? myBrowser.scrape(url, context) : 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" };
28175
29714
  const json3 = wants("json") ? await extractStructured(extractionInput(outcome2.result), custom, page, structuredModelConfigFromEnv()) : void 0;
28176
29715
  return { outcome: outcome2, json: json3 };
28177
29716
  })().finally(() => page.dispose());
@@ -28200,6 +29739,7 @@ function createApiEngine(options = {}) {
28200
29739
  let used = current;
28201
29740
  let outcome = await pageAtom.scrape(url, context);
28202
29741
  const events = [];
29742
+ const carried = [];
28203
29743
  while (egressInDoubt(outcome)) {
28204
29744
  let reason2;
28205
29745
  if (current === used) {
@@ -28222,10 +29762,12 @@ function createApiEngine(options = {}) {
28222
29762
  reason2 = "moved_with_task";
28223
29763
  }
28224
29764
  events.push({ at: 0, lane: outcome.result.lane, event: "egress_switched", detail: { from: used.egress.id, to: current.egress.id, reason: reason2, switches } });
29765
+ carried.push(...givenUpPaidCalls(outcome.result.trace));
28225
29766
  used = current;
28226
29767
  outcome = await pageAtom.scrape(url, context);
28227
29768
  }
28228
- return events.length === 0 ? outcome : { ...outcome, result: { ...outcome.result, trace: [...outcome.result.trace, ...events] } };
29769
+ const result = await withEgressExit(events.length === 0 ? outcome.result : { ...outcome.result, trace: [...carried, ...outcome.result.trace, ...events] }, used.egress, context);
29770
+ return result === outcome.result ? outcome : { ...outcome, result };
28229
29771
  },
28230
29772
  close: () => pageAtom.close()
28231
29773
  };
@@ -28248,8 +29790,9 @@ function createApiEngine(options = {}) {
28248
29790
  const orchestrator = new CrawlOrchestrator({
28249
29791
  store,
28250
29792
  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,
29793
+ // A page whose scrape threw called a provider only if the task's rungs have one (its mode and its access choice, as
29794
+ // its lane was built): otherwise its third-party cost is a known 0.
29795
+ scrapeErrorCostUsd: current.rungs.channels.some((channel) => channel.vendorId !== void 0) ? null : 0,
28253
29796
  workerCount,
28254
29797
  perHostConcurrency: Math.min(4, networkPolicy.perHostConcurrency),
28255
29798
  perHostMinDelayMs: networkPolicy.perHostMinDelayMs,
@@ -28290,18 +29833,18 @@ function createApiEngine(options = {}) {
28290
29833
  launchTask(pending, store, batchRunOptions(pending.batch, true));
28291
29834
  return;
28292
29835
  }
28293
- await finishJob(task, store);
28294
- inflight.delete(task.id);
28295
29836
  const after = await store.getTask(task.id);
28296
29837
  await endCookieSession(after !== null && isTerminalStatus(after.status));
29838
+ await finishJob(task, store);
29839
+ inflight.delete(task.id);
28297
29840
  await store.close();
28298
29841
  }).catch(async (error) => {
28299
29842
  inflight.delete(task.id);
28300
29843
  crawlControllers.delete(task.id);
28301
29844
  runningCrawls.delete(task.id);
28302
29845
  await markCrawlFailed(store, task.id);
28303
- await finishJob(task, store, error instanceof Error ? error.message : String(error));
28304
29846
  await endCookieSession(true);
29847
+ await finishJob(task, store, error instanceof Error ? error.message : String(error));
28305
29848
  await store.close();
28306
29849
  });
28307
29850
  runningCrawls.set(task.id, orchestrator);
@@ -28333,6 +29876,8 @@ function createApiEngine(options = {}) {
28333
29876
  }
28334
29877
  async function runScrape(req, context, record3, hooks = {}) {
28335
29878
  checkHandoff(req);
29879
+ checkAccessChoice(req);
29880
+ checkMyBrowser(req);
28336
29881
  checkFileCap(req);
28337
29882
  checkSelectors(req);
28338
29883
  checkAttributeSelectors(req.formats);
@@ -28345,17 +29890,46 @@ function createApiEngine(options = {}) {
28345
29890
  const deadlineAt = req.timeout === void 0 ? context.deadlineAt ?? Date.now() + DEFAULT_SCRAPE_TIMEOUT_MS : Math.min(context.deadlineAt ?? Infinity, Date.now() + req.timeout);
28346
29891
  const scope = createExecutionScope({ ...context, signal: context.signal ? AbortSignal.any([context.signal, shutdownController.signal]) : shutdownController.signal, deadlineAt });
28347
29892
  const mode = defaultApiMode(req.mode);
28348
- const rungs = channelsForUrl(mode, req.url, req, req.formats ?? [], true, true, mode === "authed" ? void 0 : egressPool?.pick());
29893
+ const scrapeEgress = mode === "authed" ? void 0 : egressPool?.pick();
29894
+ const rungs = channelsForUrl(mode, req.url, req, req.formats ?? [], true, true, scrapeEgress);
28349
29895
  const policy = {
28350
29896
  mode,
29897
+ // access enhanced permits the provider lane in mode standard too (the grant still decides whether one exists).
29898
+ ...req.access === "enhanced" ? { enhanced: true } : {},
28351
29899
  ...req.allowlistedDomains !== void 0 && req.allowlistedDomains.length > 0 ? { allowlistedDomains: req.allowlistedDomains } : {}
28352
29900
  };
28353
29901
  const runner = new LadderRunner(rungs.channels, policy, historyFor(mode), null, sessionsFor(mode), { channelsFiltered: rungs.filtered });
28354
29902
  const robotsOverride = req.robotsOverride ?? (record3 ? namedUrlOverride : void 0);
28355
29903
  const plan = record3 ? cachePlanFor(req.url, mode, req, req.formats, rungs.channels, req.robotsOverride, robotsOverride !== void 0) : null;
29904
+ const deliver = async (full, shapeScope, fetched) => {
29905
+ const shaped = await prepareScrapeResponse(full, req, shapeScope, null, overallStart, scrapeId).finally(() => {
29906
+ if (shapeScope !== scope)
29907
+ shapeScope.dispose();
29908
+ });
29909
+ const response = fetched ? shaped : withoutFetchUsage(shaped);
29910
+ if (!record3)
29911
+ return response;
29912
+ try {
29913
+ await writeScrapeRecord(scrapeRecordOf(scrapeId, requestedAt, req, full, response));
29914
+ return response;
29915
+ } catch (error) {
29916
+ console.error(JSON.stringify({ component: "api", event: "scrape_record_unwritten", scrapeId, error: error instanceof Error ? error.message : String(error) }));
29917
+ if (!("trace" in response))
29918
+ return response;
29919
+ return { ...response, trace: [...response.trace, { at: Math.round(performance.now() - overallStart), lane: response.lane, event: "scrape_record_unwritten", detail: { scrapeId } }] };
29920
+ }
29921
+ };
28356
29922
  const operation = (async () => {
29923
+ if (req.lane === "my-browser") {
29924
+ const read3 = await myBrowserScrape(req, context, hooks);
29925
+ const agentHints2 = agentHintsFor(req, { channelsTried: [read3.lane], result: read3 });
29926
+ const full2 = { ...read3, channelsTried: [read3.lane], ladderTrace: [], summary: summarize([read3.lane], [{ channel: read3.lane, result: read3 }]), ...agentHints2.length === 0 ? {} : { agentHints: agentHints2 } };
29927
+ return deliver(full2, createExecutionScope({ signal: AbortSignal.any([...context.signal === void 0 ? [] : [context.signal], shutdownController.signal]) }), true);
29928
+ }
29929
+ const scrapeCap = accessGrant?.budget.perRequestUsd ?? accessGrant?.budget.perRunUsd ?? null;
29930
+ const scrapeSpend = scrapeCap === null ? void 0 : createSpendLedger(scrapeCap);
28357
29931
  const answer = consultCache(plan, req.url, policy);
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)) };
29932
+ const run = answer.kind === "fetch" ? await runner.run(req.url, void 0, scrapeSpend === void 0 ? scope : { ...scope, spend: scrapeSpend }, { ...fetchOptions(req, req.formats), ...robotsOverride === void 0 ? {} : { robotsOverride } }).then(async (fetched) => ({ ...fetched, result: afterFetch(plan, answer, await withEgressExit(fetched.result, scrapeEgress, scope)) })) : { result: answer.result, ...untriedAudit(Math.round(performance.now() - overallStart)) };
28359
29933
  const handed = req.handoff !== void 0 && answer.kind === "fetch" && handoffResult(run.result) ? await handOffScrape(req, run.result, context, hooks) : null;
28360
29934
  const read2 = handed !== null && "result" in handed ? handed.result : null;
28361
29935
  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;
@@ -28364,29 +29938,14 @@ function createApiEngine(options = {}) {
28364
29938
  const full = {
28365
29939
  ...result,
28366
29940
  // 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 },
29941
+ usage: { ...result.usage, externalCostUsd: summary.externalCostUsd, ...run.result.usage.externalCostChargedUsd === void 0 ? {} : { externalCostChargedUsd: run.result.usage.externalCostChargedUsd } },
28368
29942
  channelsTried: run.channelsTried,
28369
29943
  ladderTrace: run.ladderTrace,
28370
29944
  summary,
28371
29945
  ...agentHints.length === 0 ? {} : { agentHints }
28372
29946
  };
28373
29947
  const shapeScope = handed !== null && "result" in handed ? createExecutionScope({ signal: AbortSignal.any([...context.signal === void 0 ? [] : [context.signal], shutdownController.signal]) }) : scope;
28374
- const shaped = await prepareScrapeResponse(full, req, shapeScope, null, overallStart, scrapeId).finally(() => {
28375
- if (shapeScope !== scope)
28376
- shapeScope.dispose();
28377
- });
28378
- const response = answer.kind === "fetch" ? shaped : withoutFetchUsage(shaped);
28379
- if (!record3)
28380
- return response;
28381
- try {
28382
- await writeScrapeRecord(scrapeRecordOf(scrapeId, requestedAt, req, full, response));
28383
- return response;
28384
- } catch (error) {
28385
- console.error(JSON.stringify({ component: "api", event: "scrape_record_unwritten", scrapeId, error: error instanceof Error ? error.message : String(error) }));
28386
- if (!("trace" in response))
28387
- return response;
28388
- return { ...response, trace: [...response.trace, { at: Math.round(performance.now() - overallStart), lane: response.lane, event: "scrape_record_unwritten", detail: { scrapeId } }] };
28389
- }
29948
+ return deliver(full, shapeScope, answer.kind === "fetch");
28390
29949
  })();
28391
29950
  activeScrapes.add(operation);
28392
29951
  try {
@@ -28514,6 +30073,7 @@ function createApiEngine(options = {}) {
28514
30073
  checkAttributeSelectors(req.formats);
28515
30074
  checkListSelectors(req.formats);
28516
30075
  checkHostedOptions(req);
30076
+ checkAccessChoice(req);
28517
30077
  const webhookConfig = jobWebhooks.check(req.webhook);
28518
30078
  if (defaultMaxPages !== null && req.maxPages != null && req.maxPages > defaultMaxPages) {
28519
30079
  throw new RequestError(`maxPages must be at most ${defaultMaxPages} on this server`);
@@ -28546,6 +30106,7 @@ function createApiEngine(options = {}) {
28546
30106
  maxTokens: null
28547
30107
  },
28548
30108
  crawl: {
30109
+ ...req.access === void 0 ? {} : { access: req.access },
28549
30110
  formats: req.formats ?? ["markdown"],
28550
30111
  includeLinks: req.includeLinks === true,
28551
30112
  includePaths: req.includePaths ?? [],
@@ -28607,6 +30168,8 @@ function createApiEngine(options = {}) {
28607
30168
  checkListSelectors(req.formats);
28608
30169
  checkRobotsOverride("robotsOverrides", req.robotsOverrides);
28609
30170
  checkHostedOptions(req);
30171
+ checkAccessChoice(req);
30172
+ checkMyBrowser(req);
28610
30173
  const webhookConfig = jobWebhooks.check(req.webhook);
28611
30174
  batchStartInProgress = true;
28612
30175
  try {
@@ -28634,7 +30197,9 @@ function createApiEngine(options = {}) {
28634
30197
  ...req.robotsOverrides === void 0 ? {} : { robotsOverrides: req.robotsOverrides },
28635
30198
  ...req.maxConcurrency === void 0 ? {} : { maxConcurrency: req.maxConcurrency },
28636
30199
  ...invalidURLs === void 0 ? {} : { invalidURLs },
28637
- ...webhookConfig === void 0 ? {} : { webhook: jobWebhooks.register(taskId, webhookConfig, req.webhookPayloadFormat) }
30200
+ ...webhookConfig === void 0 ? {} : { webhook: jobWebhooks.register(taskId, webhookConfig, req.webhookPayloadFormat) },
30201
+ ...req.lane === void 0 ? {} : { lane: req.lane },
30202
+ ...req.access === void 0 ? {} : { access: req.access }
28638
30203
  };
28639
30204
  const task = {
28640
30205
  id: taskId,
@@ -29126,11 +30691,11 @@ function attributionOf(req) {
29126
30691
  return Object.keys(attribution).length === 0 ? {} : { attribution };
29127
30692
  }
29128
30693
  function scrapeRecordOf(scrapeId, requestedAt, req, run, response) {
29129
- const { origin: _origin, integration: _integration, ...request7 } = req;
30694
+ const { origin: _origin, integration: _integration, ...request8 } = req;
29130
30695
  return {
29131
30696
  scrapeId,
29132
30697
  requestedAt,
29133
- request: { ...request7, ...req.headers === void 0 ? {} : { headers: Object.fromEntries(Object.keys(req.headers).map((name) => [name, name])) } },
30698
+ request: { ...request8, ...req.headers === void 0 ? {} : { headers: Object.fromEntries(Object.keys(req.headers).map((name) => [name, name])) } },
29134
30699
  ...attributionOf(req).attribution,
29135
30700
  status: response.status,
29136
30701
  failureReason: response.failureReason,
@@ -29167,7 +30732,7 @@ function crawlOptionsStored(task) {
29167
30732
  }
29168
30733
  var BATCH_SCOPE = { regexOnFullURL: false, ignoreQueryParameters: false, deduplicateSimilarURLs: false, crawlEntireDomain: true, allowSubdomains: false, allowExternalLinks: false };
29169
30734
  function batchRunOptions(batch, resume) {
29170
- return { maxDepth: 0, allowlistedDomains: [], useCached: false, resume, scope: BATCH_SCOPE, sitemap: "skip", maxConcurrency: batch.maxConcurrency ?? null, policyAllowlist: [] };
30735
+ return { maxDepth: 0, allowlistedDomains: [], useCached: false, resume, scope: BATCH_SCOPE, sitemap: "skip", maxConcurrency: batch.lane === "my-browser" ? 1 : batch.maxConcurrency ?? null, policyAllowlist: [] };
29171
30736
  }
29172
30737
  async function appendedWithoutStep(taskId, store) {
29173
30738
  const task = await store.getTask(taskId);
@@ -29217,11 +30782,23 @@ function crawlPolicyAllowlist(seedUrl, allowlistedDomains, scope) {
29217
30782
  return [];
29218
30783
  const host = new URL(seedUrl).hostname.toLowerCase();
29219
30784
  const apex = host.startsWith("www.") ? host.slice(4) : host;
29220
- const twin = host.startsWith("www.") ? apex : host.includes(".") && isIP4(host) === 0 ? `www.${host}` : null;
30785
+ const twin = host.startsWith("www.") ? apex : host.includes(".") && isIP5(host) === 0 ? `www.${host}` : null;
29221
30786
  return [.../* @__PURE__ */ new Set([host, ...twin === null ? [] : [twin], ...allowlistedDomains, ...scope.allowSubdomains ? [`*.${apex}`] : []])];
29222
30787
  }
29223
30788
  function handoffUnread(task) {
29224
- return task.batch === void 0 ? null : unreadByPerson(fetchOptions(task.batch, task.batch.formats));
30789
+ if (task.batch === void 0)
30790
+ return null;
30791
+ const options = fetchOptions(task.batch, task.batch.formats);
30792
+ return listsPages2(task) ? unreadByPerson({ ...options, actions: void 0 }) : unreadByPerson(options);
30793
+ }
30794
+ function listsPages2(task) {
30795
+ const actions = task.batch?.actions ?? [];
30796
+ return actions.length === 1 && actions[0].type === "paginate" && actions[0].itemSelector !== void 0;
30797
+ }
30798
+ function handoffNeededIn(task, step) {
30799
+ if (!handoffNeeded(step))
30800
+ return false;
30801
+ return !listsPages2(task) || (step.result?.actions?.lists ?? []).some((run) => run.type === "paginate" && run.stoppedBy === "challenge" && run.challenge !== void 0);
29225
30802
  }
29226
30803
  function offersHandoff(task) {
29227
30804
  return handoffUnread(task) === null && webhookOf(task) === void 0;
@@ -29292,7 +30869,7 @@ function compactPage(step, task) {
29292
30869
  }
29293
30870
  function toCrawlPage(step, includeLinks, task, handoffOffered = false) {
29294
30871
  const result = step.result;
29295
- const handoff = handoffOffered && handoffNeeded(step) ? handoffRequestOf(step) : null;
30872
+ const handoff = handoffOffered && handoffNeededIn(task, step) ? handoffRequestOf(step) : null;
29296
30873
  const mode = task.mode;
29297
30874
  const agentHints = result === null ? [] : agentHintsFor({ fastMode: (task.batch ?? task.crawl)?.fastMode }, { channelsTried: step.audit?.channelsTried ?? [result.lane], result, ...step.audit === void 0 ? {} : { summary: step.audit.summary, ladderTrace: step.audit.ladderTrace } });
29298
30875
  return {
@@ -30053,6 +31630,8 @@ function createApp(engine, options = {}) {
30053
31630
  return fail(c, err.code, err.message, err.details, err.agentHints);
30054
31631
  if (err instanceof CrawlStateError)
30055
31632
  return fail(c, "conflict", err.message);
31633
+ if (err instanceof HandoffUnavailableError)
31634
+ return fail(c, "conflict", err.message);
30056
31635
  if (err instanceof TaskNotFoundError)
30057
31636
  return fail(c, "not_found", "not found");
30058
31637
  if (err instanceof InvalidCursorError)
@@ -30082,6 +31661,22 @@ function readEgressProxies(env, grant, hosted) {
30082
31661
  throw new Error("W2L_EGRESS_PROXIES needs an access grant that names egress_sessions (ADR 0005; --access-grant or W2L_ACCESS_GRANT)");
30083
31662
  return proxies;
30084
31663
  }
31664
+ function readEgressEchoUrl(env, egresses) {
31665
+ const raw = env.W2L_EGRESS_ECHO_URL?.trim() ?? "";
31666
+ if (raw === "")
31667
+ return null;
31668
+ if (egresses.length === 0)
31669
+ throw new Error("W2L_EGRESS_ECHO_URL asks each egress proxy where it leaves from: it needs W2L_EGRESS_PROXIES");
31670
+ let url;
31671
+ try {
31672
+ url = new URL(raw);
31673
+ } catch {
31674
+ throw new Error("W2L_EGRESS_ECHO_URL must be an http(s) URL");
31675
+ }
31676
+ if (url.protocol !== "http:" && url.protocol !== "https:")
31677
+ throw new Error("W2L_EGRESS_ECHO_URL must be an http(s) URL");
31678
+ return url.href;
31679
+ }
30085
31680
  function readAccessGrant(argv, env, hosted) {
30086
31681
  const source = (readFlag(argv, "--access-grant") ?? env["W2L_ACCESS_GRANT"] ?? "").trim();
30087
31682
  if (source === "")
@@ -30100,7 +31695,7 @@ function readAccessGrant(argv, env, hosted) {
30100
31695
  }
30101
31696
  function accessGrantNotice(grant) {
30102
31697
  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}`}`;
31698
+ 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)}${grant.attestation === null ? "" : `; accepted by ${grant.attestation.principal}`}`;
30104
31699
  }
30105
31700
  function jobStreamsEnabled(env) {
30106
31701
  return (env["W2L_JOB_STREAMS"] ?? "").trim().toLowerCase() !== "off";
@@ -30117,8 +31712,9 @@ function parseListen(argv, env = process.env) {
30117
31712
  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
31713
  const compatHosts = compatHostsChoice(env, accessGrant, hosted);
30119
31714
  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`];
31715
+ const egressEchoUrl = readEgressEchoUrl(env, egressList);
31716
+ 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${egressEchoUrl === null ? "" : `; each is asked where it leaves from at ${new URL(egressEchoUrl).host}`}`];
31717
+ 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${(env.W2L_COMPAT_HOSTS ?? "").trim() === "" ? " (the hosts its acceptance showed it helps, research/access/benefit-hosts.v1.json; W2L_COMPAT_HOSTS names others, none turns it off)" : ""}; a request with custom headers or mobile keeps the http rung`];
30122
31718
  if (hosted) {
30123
31719
  if (tokens.length === 0) {
30124
31720
  throw new Error("hosted mode requires --token, W2L_API_TOKEN or W2L_API_TOKENS");
@@ -30142,7 +31738,8 @@ function parseListen(argv, env = process.env) {
30142
31738
  accessGrant,
30143
31739
  browserEngine,
30144
31740
  compatHosts,
30145
- egressProxies: egressList
31741
+ egressProxies: egressList,
31742
+ egressEchoUrl
30146
31743
  };
30147
31744
  }
30148
31745
  const networkPolicy = withOperatorContact(withEnvironmentProxy(tunedPolicy(localNetworkPolicy(), env), env), env);
@@ -30168,7 +31765,8 @@ function parseListen(argv, env = process.env) {
30168
31765
  accessGrant,
30169
31766
  browserEngine,
30170
31767
  compatHosts,
30171
- egressProxies: egressList
31768
+ egressProxies: egressList,
31769
+ egressEchoUrl
30172
31770
  };
30173
31771
  }
30174
31772
  function deliveryConfig(mode, env = process.env) {
@@ -30285,7 +31883,8 @@ async function runApiServer(argv, env = process.env, options = {}) {
30285
31883
  accessGrant: listen.accessGrant,
30286
31884
  browserEngine: listen.browserEngine,
30287
31885
  compatHosts: listen.compatHosts,
30288
- egressProxies: listen.egressProxies
31886
+ egressProxies: listen.egressProxies,
31887
+ egressEchoUrl: listen.egressEchoUrl
30289
31888
  });
30290
31889
  const app = createApp(engine, { loopbackOnly: servesLogins, tokens: listen.tokens, exposeInternalErrors: listen.mode === "local", jobStreams: listen.jobStreams, ...listen.rateLimit === void 0 ? {} : { rateLimit: listen.rateLimit } });
30291
31890
  const deliveryStore = DeliveryStore.open(join11(taskRoot, "section-b-control.sqlite"));
@@ -30382,6 +31981,8 @@ var KINDS = {
30382
31981
  maxConcurrency: "int",
30383
31982
  limit: "int",
30384
31983
  mode: "string",
31984
+ lane: "string",
31985
+ access: "string",
30385
31986
  sitemap: "string",
30386
31987
  idempotencyKey: "string",
30387
31988
  appendToId: "string",
@@ -30556,8 +32157,8 @@ function usage(command) {
30556
32157
  }
30557
32158
  const synopsis = {
30558
32159
  crawl: "usage: octocrawl crawl <url> [options] [--out <dir>] | octocrawl crawl --resume <taskId>",
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.",
32160
+ scrape: "usage: octocrawl scrape <url> [options] [--markdown] [--out <dir>] [--handoff] [--lane my-browser]\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.\n--lane my-browser: read the page in your own Chrome instead of fetching it: click Allow in Chrome, then\nAllow reading these sites in the page Octocrawl opens there (close it or click Revoke to stop).",
32161
+ batch: "usage: octocrawl batch <url>... [--urls-file <file>] [options] [--out <dir>] [--handoff] [--lane my-browser]\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. A batch with one paginate step (with itemSelector) is handed over too: the page its list\nstopped at opens, you get through the check and page on by clicking Next, and Octocrawl reads each page as it shows.\n--lane my-browser: read every page in your own Chrome, one at a time: click Allow in Chrome, then Allow reading\nthese sites once in the page Octocrawl opens there (close it or click Revoke to stop).",
30561
32162
  map: "usage: octocrawl map <url> [options]"
30562
32163
  };
30563
32164
  const lines = optionKeys(command).map((key) => {
@@ -30674,7 +32275,7 @@ ${LOGIN_USAGE}`);
30674
32275
  }
30675
32276
 
30676
32277
  // packages/cli/src/run.ts
30677
- var CLI_VERSION = "0.3.1";
32278
+ var CLI_VERSION = "0.3.2";
30678
32279
  async function runCli(argv, io) {
30679
32280
  const [name, ...rest] = argv;
30680
32281
  if (name === void 0 || name === "--help" || name === "help") {
@@ -30724,7 +32325,8 @@ ${usage(null)}`);
30724
32325
  accessGrant: listen.accessGrant,
30725
32326
  browserEngine: listen.browserEngine,
30726
32327
  compatHosts: listen.compatHosts,
30727
- egressProxies: listen.egressProxies
32328
+ egressProxies: listen.egressProxies,
32329
+ egressEchoUrl: listen.egressEchoUrl
30728
32330
  });
30729
32331
  try {
30730
32332
  return await runCommand(engine, command, line.urls, line.body, line.cli, io);
@@ -30790,6 +32392,7 @@ async function runCommand(engine, command, urls, body, cli, io) {
30790
32392
  if (urls.length === 0) throw new UsageError("octocrawl batch takes URLs as arguments or --urls-file");
30791
32393
  const accepted = await engine.startBatch(parseBatchStartRequest({ ...body, urls, origin: ORIGIN }));
30792
32394
  io.stderr(`octocrawl batch: task ${accepted.taskId}${accepted.invalidURLs?.length ? `, ${accepted.invalidURLs.length} invalid URLs skipped` : ""}`);
32395
+ if (body.lane === "my-browser" || body.access === "my-browser") io.stderr("octocrawl batch: in your Chrome, click Allow, then Allow reading these sites in the page Octocrawl opened (it lists every site of the batch); close that page or click Revoke to stop");
30793
32396
  return finish(engine, "batch", accepted.taskId, cli, io);
30794
32397
  }
30795
32398
  }
@@ -30822,6 +32425,8 @@ function handoffPrompts(command, io) {
30822
32425
  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)`),
30823
32426
  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
32427
  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)`),
32428
+ onContinue: (url, pages) => io.stderr(`octocrawl ${command}: through: page on in that tab by clicking Next, Octocrawl reads each page as it shows (${pages} read so far); it stops when Next is gone, or after 60 s without a new page`),
32429
+ onAllow: (hosts) => io.stderr(`octocrawl ${command}: in your Chrome, click Allow, then Allow reading these sites in the page Octocrawl opened (${hosts.join(", ")}); close that page or click Revoke to stop. While remote debugging is on, sites see navigator.webdriver true: turn it off at chrome://inspect/#remote-debugging when you are done`),
30825
32430
  ...io.signal === void 0 ? {} : { signal: io.signal }
30826
32431
  };
30827
32432
  }