octocrawl 0.3.2 → 0.4.0

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 (2) hide show
  1. package/dist/cli.js +213 -69
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3829,7 +3829,7 @@ function classifyGate(res) {
3829
3829
  return { reason: "captcha", signals: px };
3830
3830
  const wall = res.status >= 200 && res.status < 300 ? shortWallPage(res.body) : null;
3831
3831
  if (contentful)
3832
- return wall === null ? null : { reason: "bot_detected_generic", signals: wall };
3832
+ return wall;
3833
3833
  const captcha = matched(lower, CAPTCHA_MARKERS);
3834
3834
  if (captcha.length > 0) {
3835
3835
  return { reason: "captcha", signals: captcha };
@@ -3845,8 +3845,6 @@ function classifyGate(res) {
3845
3845
  signals.push("heading_sign_in");
3846
3846
  return { reason: "login_wall", signals };
3847
3847
  }
3848
- if (wall !== null)
3849
- return { reason: "bot_detected_generic", signals: wall };
3850
3848
  const geo = matched(lower, GEO_MARKERS);
3851
3849
  if (geo.length > 0) {
3852
3850
  return { reason: "geo_restricted", signals: geo };
@@ -3873,18 +3871,29 @@ function classifyGate(res) {
3873
3871
  };
3874
3872
  }
3875
3873
  }
3876
- return null;
3874
+ return wall;
3877
3875
  }
3878
3876
  function shortWallPage(body) {
3879
- if (!/\b(?:reference|incident|unusual|suspicious)\b/i.test(body))
3877
+ if (!WALL_TRIGGER.test(body))
3880
3878
  return null;
3881
3879
  const page = pageText(body);
3882
3880
  if (page === null)
3883
3881
  return null;
3882
+ const bot = (signals) => ({ reason: "bot_detected_generic", signals });
3884
3883
  if (AKAMAI_REFERENCE.test(page.text))
3885
- return ["akamai_reference", "short_page"];
3884
+ return bot(["akamai_reference", "short_page"]);
3886
3885
  if (page.headings.some((h) => UNUSUAL_ACTIVITY.test(h)) && AUTOMATED_TRAFFIC.test(page.text))
3887
- return ["heading_unusual_activity", "text_automated_traffic", "short_page"];
3886
+ return bot(["heading_unusual_activity", "text_automated_traffic", "short_page"]);
3887
+ const shown = page.text.toLowerCase();
3888
+ const named = page.headings.join("\n").toLowerCase();
3889
+ const strong = matched(named, BOT_STRONG_MARKERS);
3890
+ if (strong.length > 0) {
3891
+ const person = shown.includes("activate and hold the button") ? ["text_activate_and_hold"] : [];
3892
+ return { reason: person.length > 0 ? "captcha" : "bot_detected_generic", signals: [...strong, ...person, "short_page"] };
3893
+ }
3894
+ const weak = matched(shown, WALL_WEAK_MARKERS);
3895
+ if (weak.length >= 2 && matched(named, WALL_WEAK_MARKERS).length > 0)
3896
+ return bot([...weak, "short_page"]);
3888
3897
  return null;
3889
3898
  }
3890
3899
  function pageText(body) {
@@ -4002,7 +4011,7 @@ function nextLane(reason2, from) {
4002
4011
  return from === "browser_proxy" ? null : "browser_proxy";
4003
4012
  }
4004
4013
  }
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;
4014
+ 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, WALL_TRIGGER, WALL_WEAK_MARKERS, AUTOMATED_TRAFFIC, HIDDEN_ELEMENTS, HEADING_ELEMENTS;
4006
4015
  var init_gate = __esm({
4007
4016
  "packages/http-core/dist/gate.js"() {
4008
4017
  "use strict";
@@ -4096,6 +4105,8 @@ var init_gate = __esm({
4096
4105
  WALL_PAGE_MAX_TEXT = 1500;
4097
4106
  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
4107
  UNUSUAL_ACTIVITY = /\b(?:unusual|suspicious) (?:activity|traffic)\b/i;
4108
+ WALL_TRIGGER = /\b(?:reference|incident|unusual|suspicious|blocked|interruption|automated|robot|human|humans|bot|checking|wait|moment|security|ddos|denied)\b/i;
4109
+ WALL_WEAK_MARKERS = BOT_WEAK_MARKERS.filter(([, signal]) => signal !== "weak_enable_js_and_cookies" && signal !== "weak_enable_javascript");
4099
4110
  AUTOMATED_TRAFFIC = /\bautomated (?:traffic|access|requests|queries|browsing)\b/i;
4100
4111
  HIDDEN_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "svg"]);
4101
4112
  HEADING_ELEMENTS = /* @__PURE__ */ new Set(["h1", "h2", "h3", "title"]);
@@ -8738,6 +8749,26 @@ function rawSignals(doc) {
8738
8749
  markers.add("aria_busy");
8739
8750
  return { scriptChars, markers: Array.from(markers), hydrationJson };
8740
8751
  }
8752
+ function hasLoadingText(region) {
8753
+ const stack = [region];
8754
+ while (stack.length > 0) {
8755
+ const node = stack.pop();
8756
+ if (node.nodeType === 3) {
8757
+ const text2 = (node.textContent ?? "").trim();
8758
+ if (text2.length === 0 || text2.length > LOADING_TEXT_MAX_CHARS || !(LOADING_MESSAGE.test(text2) || LOADING_TRAILING.test(text2)))
8759
+ continue;
8760
+ if (node.parentElement?.closest("button, a") == null)
8761
+ return true;
8762
+ } else if (node.nodeType === 1) {
8763
+ const el = node;
8764
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || SCREEN_READER_ONLY.test(el.getAttribute("class") ?? ""))
8765
+ continue;
8766
+ for (const child of Array.from(el.childNodes))
8767
+ stack.push(child);
8768
+ }
8769
+ }
8770
+ return false;
8771
+ }
8741
8772
  function countEmptyTables(doc) {
8742
8773
  let empty = 0;
8743
8774
  for (const table of qsa(doc, "table")) {
@@ -8778,6 +8809,8 @@ function detectRenderSignals(raw, cleaned, context = {}) {
8778
8809
  reason2 = "hydration_shell";
8779
8810
  else if (has2("aria_busy") && raw.scriptChars >= 1e3)
8780
8811
  reason2 = "aria_busy";
8812
+ else if (context.loadingShown === true && textChars <= LOADING_PAGE_MAX_TEXT && raw.scriptChars >= 1e3)
8813
+ reason2 = "loading_text";
8781
8814
  const listRecords = reason2 === null && context.listing === true && raw.hydrationJson.length > 0 ? partialRecordList(recordLists(raw), visible.toLowerCase()) : null;
8782
8815
  if (listRecords !== null)
8783
8816
  reason2 = "hydration_list_partial";
@@ -8791,14 +8824,14 @@ function detectRenderSignals(raw, cleaned, context = {}) {
8791
8824
  reason: reason2
8792
8825
  };
8793
8826
  }
8794
- var APP_ROOT_SELECTOR, JS_FALLBACK_CLASS, HYDRATION_SCRIPT, APP_ROOT_MAX_TEXT, HYDRATION_MIN_CHARS, NOTICE_MAX_TEXT, HYDRATION_TEXT_MIN_CHARS, HYDRATION_TEXT_MIN_SPACES, HYDRATION_MAX_JSON_CHARS, HYDRATION_MAX_PASSAGES, LIST_MIN_RECORDS, LIST_NAME_MIN_CHARS, LIST_MIN_SHOWN, LIST_MAX_NAMES, LIST_MAX_LISTS;
8827
+ var APP_ROOT_SELECTOR, JS_FALLBACK_CLASS, HYDRATION_SCRIPT, APP_ROOT_MAX_TEXT, HYDRATION_MIN_CHARS, NOTICE_MAX_TEXT, HYDRATION_TEXT_MIN_CHARS, HYDRATION_TEXT_MIN_SPACES, HYDRATION_MAX_JSON_CHARS, HYDRATION_MAX_PASSAGES, LIST_MIN_RECORDS, LIST_NAME_MIN_CHARS, LIST_MIN_SHOWN, LIST_MAX_NAMES, LIST_MAX_LISTS, LOADING_MESSAGE, LOADING_TRAILING, LOADING_TEXT_MAX_CHARS, LOADING_PAGE_MAX_TEXT, SCREEN_READER_ONLY;
8795
8828
  var init_render = __esm({
8796
8829
  "packages/extract-tf/dist/render.js"() {
8797
8830
  "use strict";
8798
8831
  init_dom();
8799
8832
  APP_ROOT_SELECTOR = "#root, #app, #__next, #__nuxt, #___gatsby, [data-reactroot], [ng-app], [ng-version], [data-server-rendered]";
8800
8833
  JS_FALLBACK_CLASS = /(^|[\s_-])(hide-if-js|hide-if-js-enabled|js-disabled|js-only|requires-js|needs-js)([\s_-]|$)/i;
8801
- HYDRATION_SCRIPT = /window\.__[A-Za-z0-9_]+__\s*=|window\._[A-Za-z0-9_]*(?:STATE|CONFIG|DATA)\s*=|__NEXT_DATA__|__NUXT__/;
8834
+ HYDRATION_SCRIPT = /window\.__[A-Za-z0-9_]+__\s*=|window\._[A-Za-z0-9_]*(?:STATE|CONFIG|DATA)\s*=|window\.__(?:data|state)\s*=|__NEXT_DATA__|__NUXT__/;
8802
8835
  APP_ROOT_MAX_TEXT = 200;
8803
8836
  HYDRATION_MIN_CHARS = 2e3;
8804
8837
  NOTICE_MAX_TEXT = 1500;
@@ -8811,6 +8844,11 @@ var init_render = __esm({
8811
8844
  LIST_MIN_SHOWN = 3;
8812
8845
  LIST_MAX_NAMES = 500;
8813
8846
  LIST_MAX_LISTS = 50;
8847
+ LOADING_MESSAGE = /^(?:(?:loading|fetching|please wait|one moment|searching|retrieving)(?:\s+[\w-]+){0,3}\s*(?:\.{2,3}|\u2026)|please wait)$/i;
8848
+ LOADING_TRAILING = /\b(?:loading|fetching)\s*(?:\.{2,3}|\u2026)$/i;
8849
+ LOADING_TEXT_MAX_CHARS = 48;
8850
+ LOADING_PAGE_MAX_TEXT = 4e3;
8851
+ SCREEN_READER_ONLY = /(?:^|[\s_-])(?:sr-only|visually-?hidden|screen-?reader-(?:only|text))(?:$|[\s_-])|sronly|screenreaderonly/i;
8814
8852
  }
8815
8853
  });
8816
8854
 
@@ -8895,10 +8933,12 @@ function layoutParent(el) {
8895
8933
  function selectMain(doc, blocks) {
8896
8934
  if (blocks.length === 0)
8897
8935
  return null;
8898
- for (const sel of ["article", "main"]) {
8936
+ for (const sel of ["article", "main", '[role="main"]']) {
8899
8937
  const el = doc.querySelector(sel);
8900
8938
  if (!el)
8901
8939
  continue;
8940
+ if (sel === '[role="main"]' && qsa(doc, "h1").some((h1) => !el.contains(h1)))
8941
+ continue;
8902
8942
  const inside = blocks.filter((b) => el.contains(b.el));
8903
8943
  if (inside.length >= blocks.length * 0.5)
8904
8944
  return el;
@@ -9655,6 +9695,22 @@ function hasBuyBoxUnderH2(doc, prices) {
9655
9695
  }
9656
9696
  return true;
9657
9697
  }
9698
+ function ownPriceAfterTitle(doc, names) {
9699
+ const h1 = qsa(doc, "h1")[0];
9700
+ if (h1 === void 0)
9701
+ return false;
9702
+ const prices = new Set(visiblePrices(doc));
9703
+ const all = qsa(doc, "*");
9704
+ const first2 = all.slice(all.indexOf(h1) + 1).find((el) => prices.has(el));
9705
+ if (first2 === void 0)
9706
+ return false;
9707
+ for (let up = first2.parentElement; up !== null && !up.contains(h1); up = up.parentElement) {
9708
+ const text2 = textOf(up).replace(/\s+/g, " ");
9709
+ if (names.some((name) => text2.includes(name)))
9710
+ return false;
9711
+ }
9712
+ return true;
9713
+ }
9658
9714
  function countAll(doc) {
9659
9715
  const textLength = (el) => (el?.textContent ?? "").replace(/\s+/g, " ").trim().length;
9660
9716
  const textChars = textLength(doc.body);
@@ -9695,6 +9751,39 @@ function collectPageTypes(root, out) {
9695
9751
  collectPageTypes(record3["@graph"], out);
9696
9752
  }
9697
9753
  }
9754
+ function ownListedProducts(root) {
9755
+ let most = [];
9756
+ for (const node of Array.isArray(root) ? root : [root]) {
9757
+ if (typeof node !== "object" || node === null)
9758
+ continue;
9759
+ const record3 = node;
9760
+ if (Array.isArray(record3["@graph"])) {
9761
+ const listed = ownListedProducts(record3["@graph"]);
9762
+ if (listed.length > most.length)
9763
+ most = listed;
9764
+ }
9765
+ if (!typeNames(record3).includes("itemlist"))
9766
+ continue;
9767
+ if (typeof record3["name"] === "string" && isRecommendationHeading(record3["name"]))
9768
+ continue;
9769
+ const products = [];
9770
+ for (const element of Array.isArray(record3["itemListElement"]) ? record3["itemListElement"] : []) {
9771
+ if (typeof element !== "object" || element === null)
9772
+ continue;
9773
+ const item = element["item"];
9774
+ const product = typeNames(element).includes("product") ? element : typeof item === "object" && item !== null && typeNames(item).includes("product") ? item : null;
9775
+ if (product !== null)
9776
+ products.push(typeof product["name"] === "string" ? product["name"].replace(/\s+/g, " ").trim() : "");
9777
+ }
9778
+ if (products.length > most.length)
9779
+ most = products;
9780
+ }
9781
+ return most;
9782
+ }
9783
+ function typeNames(node) {
9784
+ const t = node["@type"];
9785
+ return (typeof t === "string" ? [t] : Array.isArray(t) ? t.filter((type) => typeof type === "string") : []).map(normalizeTypeName);
9786
+ }
9698
9787
  function collectJsonLdTypes(node, out, listed, inList = false) {
9699
9788
  if (Array.isArray(node)) {
9700
9789
  for (const item of node)
@@ -9711,7 +9800,7 @@ function collectJsonLdTypes(node, out, listed, inList = false) {
9711
9800
  else
9712
9801
  out.push(type);
9713
9802
  }
9714
- const list2 = inList || types.includes("itemlist");
9803
+ const list2 = inList || types.includes("itemlist") || COLLECTION_PAGE_TYPES.some((type) => types.includes(type));
9715
9804
  for (const value of Object.values(node)) {
9716
9805
  if (typeof value === "object" && value !== null)
9717
9806
  collectJsonLdTypes(value, out, listed, list2);
@@ -9721,6 +9810,7 @@ function collectPageSignals(doc) {
9721
9810
  const jsonLdTypes = [];
9722
9811
  const listed = { products: 0 };
9723
9812
  const pageTypes = [];
9813
+ let ownListed = [];
9724
9814
  for (const el of qsa(doc, 'script[type="application/ld+json"]')) {
9725
9815
  const text2 = (el.textContent ?? "").trim();
9726
9816
  if (text2.length === 0)
@@ -9729,6 +9819,9 @@ function collectPageSignals(doc) {
9729
9819
  const parsed = JSON.parse(text2);
9730
9820
  collectJsonLdTypes(parsed, jsonLdTypes, listed);
9731
9821
  collectPageTypes(parsed, pageTypes);
9822
+ const own = ownListedProducts(parsed);
9823
+ if (own.length > ownListed.length)
9824
+ ownListed = own;
9732
9825
  } catch {
9733
9826
  }
9734
9827
  }
@@ -9747,7 +9840,9 @@ function collectPageSignals(doc) {
9747
9840
  postArticles: qsa(doc, "article.post").length,
9748
9841
  productCards: productCards(doc),
9749
9842
  listedProducts: listed.products,
9750
- pageTypes
9843
+ pageTypes,
9844
+ ownListedProducts: ownListed.length,
9845
+ ownListedNames: ownListed.filter((name) => name.length > 0)
9751
9846
  };
9752
9847
  }
9753
9848
  function productCards(doc) {
@@ -9900,7 +9995,7 @@ function hasCardGrid(doc) {
9900
9995
  function routeByCounts(c, s, doc) {
9901
9996
  let grid;
9902
9997
  const cardGrid = () => grid ??= hasCardGrid(doc);
9903
- const listedOnly = s.listedProducts >= PRODUCT_CARDS && COLLECTION_PAGE_TYPES.some((type) => hasToken2(s.pageTypes, type)) && (s.productCards || !hasToken2(s.itemTypeTokens, "product"));
9998
+ const listedOnly = s.listedProducts >= PRODUCT_CARDS && (COLLECTION_PAGE_TYPES.some((type) => hasToken2(s.pageTypes, type)) || s.ownListedProducts >= PRODUCT_CARDS && !ownPriceAfterTitle(doc, s.ownListedNames)) && (s.productCards || !hasToken2(s.itemTypeTokens, "product"));
9904
9999
  if ((s.productCards || listedOnly) && !c.buyBox && !hasToken2(s.jsonLdTypes, "product")) {
9905
10000
  return { type: "collection", strategy: "article" };
9906
10001
  }
@@ -11198,6 +11293,26 @@ function showsDeclaredProduct(main, product) {
11198
11293
  const price = product.price.value.replace(/[^\d.,]/g, "");
11199
11294
  return text2.includes(product.name.value.replace(/\s+/g, " ").trim()) && price !== "" && text2.includes(price);
11200
11295
  }
11296
+ function hiddenRegion(region) {
11297
+ for (let el = region; el !== null; el = el.parentElement) {
11298
+ const hidden = el.getAttribute("hidden");
11299
+ if (hidden !== null && hidden.toLowerCase() !== "until-found" && !reactStreamedSegment(el))
11300
+ return true;
11301
+ }
11302
+ return false;
11303
+ }
11304
+ function reactStreamedSegment(el) {
11305
+ if (REACT_STREAMED_SEGMENT.test(el.id))
11306
+ return true;
11307
+ if (el.tagName.toLowerCase() !== "table" || el.id !== "")
11308
+ return false;
11309
+ const part = el.firstElementChild;
11310
+ if (part === null)
11311
+ return false;
11312
+ if (REACT_STREAMED_SEGMENT.test(part.id))
11313
+ return true;
11314
+ return part.tagName.toLowerCase() === "tbody" && part.id === "" && REACT_STREAMED_SEGMENT.test(part.firstElementChild?.id ?? "");
11315
+ }
11201
11316
  function headingsOnly(region) {
11202
11317
  const isHeading = (el) => /^h[1-6]$/.test(el.tagName.toLowerCase());
11203
11318
  let headingChars = 0;
@@ -11231,7 +11346,7 @@ function headingsOnly(region) {
11231
11346
  }
11232
11347
  return true;
11233
11348
  }
11234
- var DEFAULT_CLASSIFY, BUY_BOX_CONFIDENCE, DESCRIPTION_MIN_CHARS, ExtractTf, extractTf, HEADINGS_ONLY_MAX_CHARS, MIN_PROSE_CHARS;
11349
+ var DEFAULT_CLASSIFY, BUY_BOX_CONFIDENCE, DESCRIPTION_MIN_CHARS, ExtractTf, extractTf, REACT_STREAMED_SEGMENT, HEADINGS_ONLY_MAX_CHARS, MIN_PROSE_CHARS;
11235
11350
  var init_extract = __esm({
11236
11351
  "packages/extract-tf/dist/extract.js"() {
11237
11352
  "use strict";
@@ -11350,11 +11465,11 @@ var init_extract = __esm({
11350
11465
  baseUrl,
11351
11466
  metadata,
11352
11467
  confidence: selected ? 1 : confidenceOf(blocks.filter((b) => main?.contains(b.el)).length, main, blocks.length, mainLength, decision.type, productShown, favorPrecision, favorRecall, product),
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),
11468
+ // Escalate when a strategy produced nothing at all, a region the page
11469
+ // hides (hiddenRegion), or the article cascade a region that only names
11470
+ // the page (headingsOnly; a region the last resort found is a list's).
11471
+ // Routing to a non-article strategy is not by itself an escalation reason.
11472
+ escalate: main === null || hiddenRegion(main) || strategy === "article" && headingsOnly(main),
11358
11473
  ...lastResort ? { lastResort: true } : {},
11359
11474
  pageType: decision.type,
11360
11475
  strategy,
@@ -11368,7 +11483,8 @@ var init_extract = __esm({
11368
11483
  productShown,
11369
11484
  hydrationShown: dataShown,
11370
11485
  contentShown: showsDeclaredProduct(main, product),
11371
- listing: decision.type === "listing" || decision.type === "collection"
11486
+ listing: decision.type === "listing" || decision.type === "collection",
11487
+ loadingShown: main !== null && hasLoadingText(main)
11372
11488
  }),
11373
11489
  labelledValues: main ? collectLabelledValues(main) : [],
11374
11490
  timings: { parseMs, extractMs: Math.max(0, performance.now() - extractionStart) }
@@ -11378,6 +11494,7 @@ var init_extract = __esm({
11378
11494
  }
11379
11495
  };
11380
11496
  extractTf = new ExtractTf();
11497
+ REACT_STREAMED_SEGMENT = /S:[0-9a-f]+$/i;
11381
11498
  HEADINGS_ONLY_MAX_CHARS = 100;
11382
11499
  MIN_PROSE_CHARS = 3;
11383
11500
  }
@@ -11388,7 +11505,7 @@ var EXTRACTOR_VERSION;
11388
11505
  var init_version = __esm({
11389
11506
  "packages/extract-tf/dist/version.js"() {
11390
11507
  "use strict";
11391
- EXTRACTOR_VERSION = "extract-tf/16";
11508
+ EXTRACTOR_VERSION = "extract-tf/19";
11392
11509
  }
11393
11510
  });
11394
11511
 
@@ -12983,6 +13100,7 @@ var init_identity = __esm({
12983
13100
  var provider_exports = {};
12984
13101
  __export(provider_exports, {
12985
13102
  ProviderSubject: () => ProviderSubject,
13103
+ providerFailure: () => providerFailure,
12986
13104
  robotsFetcherVia: () => robotsFetcherVia
12987
13105
  });
12988
13106
  function robotsFetcherVia(dispatcherFor) {
@@ -13013,6 +13131,40 @@ function isPlainText2(contentType) {
13013
13131
  return true;
13014
13132
  return contentType.toLowerCase().trimStart().startsWith("text/plain");
13015
13133
  }
13134
+ function providerFailure(url, wallMs, trace, err, aborted) {
13135
+ return {
13136
+ requestedUrl: url,
13137
+ status: "failed",
13138
+ failureReason: aborted ? "timeout" : err instanceof Error && err.message.includes("net::ERR_NAME_NOT_RESOLVED") ? "dns_error" : "provider_error",
13139
+ blockReason: null,
13140
+ budgetExceeded: null,
13141
+ lane: "provider",
13142
+ escalations: [],
13143
+ markdown: null,
13144
+ truncated: false,
13145
+ truncatedAt: null,
13146
+ compliance: null,
13147
+ evidence: {
13148
+ finalUrl: url,
13149
+ httpStatus: null,
13150
+ redirectChain: [],
13151
+ contentType: null,
13152
+ rawBodySha256: null,
13153
+ artifacts: []
13154
+ },
13155
+ usage: {
13156
+ wallMs,
13157
+ bytesWire: 0,
13158
+ bytesDecompressed: 0,
13159
+ requestCount: 1,
13160
+ attemptCount: 1,
13161
+ contentTokens: null,
13162
+ browserMs: 0,
13163
+ externalCostUsd: null
13164
+ },
13165
+ trace
13166
+ };
13167
+ }
13016
13168
  var defaultRobotsFetcher, ProviderSubject;
13017
13169
  var init_provider2 = __esm({
13018
13170
  "packages/bench/dist/subjects/provider.js"() {
@@ -13147,41 +13299,7 @@ var init_provider2 = __esm({
13147
13299
  event: "provider_failed",
13148
13300
  detail: { error: err instanceof Error ? err.message.slice(0, 200) : String(err) }
13149
13301
  });
13150
- return {
13151
- requestedUrl: url,
13152
- status: "failed",
13153
- // The provider broke, not the target. Reporting this as http_error
13154
- // would blame the publisher for our vendor's outage. A target name
13155
- // the vendor's browser could not resolve is a DNS fact, not a fault.
13156
- failureReason: execution.signal?.aborted ? "timeout" : err instanceof Error && err.message.includes("net::ERR_NAME_NOT_RESOLVED") ? "dns_error" : "provider_error",
13157
- blockReason: null,
13158
- budgetExceeded: null,
13159
- lane: "provider",
13160
- escalations: [],
13161
- markdown: null,
13162
- truncated: false,
13163
- truncatedAt: null,
13164
- compliance: null,
13165
- evidence: {
13166
- finalUrl: url,
13167
- httpStatus: null,
13168
- redirectChain: [],
13169
- contentType: null,
13170
- rawBodySha256: null,
13171
- artifacts: []
13172
- },
13173
- usage: {
13174
- wallMs: wallMs2,
13175
- bytesWire: 0,
13176
- bytesDecompressed: 0,
13177
- requestCount: 1,
13178
- attemptCount: 1,
13179
- contentTokens: null,
13180
- browserMs: 0,
13181
- externalCostUsd: null
13182
- },
13183
- trace
13184
- };
13302
+ return providerFailure(url, wallMs2, trace, err, execution.signal?.aborted === true);
13185
13303
  }
13186
13304
  const wallMs = Date.now() - start;
13187
13305
  const observedUa = res.sentUserAgent ?? null;
@@ -25202,6 +25320,7 @@ function buildChannels(mode, opts = {}) {
25202
25320
  }
25203
25321
  return await pending;
25204
25322
  };
25323
+ const requestDeadlines = /* @__PURE__ */ new WeakMap();
25205
25324
  const raw = {
25206
25325
  id: "provider",
25207
25326
  vendorId,
@@ -25254,13 +25373,34 @@ function buildChannels(mode, opts = {}) {
25254
25373
  };
25255
25374
  return skip;
25256
25375
  }
25257
- if (sessionApplies && session.resume !== void 0 && session.resume !== null) {
25258
- pendingResume = session.resume;
25259
- persistenceAttempted = true;
25260
- } else {
25261
- await preparePersistence(execution);
25376
+ const start = Date.now();
25377
+ const requestDeadlineAt = execution !== void 0 && requestDeadlines.has(execution) ? requestDeadlines.get(execution) : execution?.deadlineAt;
25378
+ const requestEnded = () => execution?.signal?.aborted === true || requestDeadlineAt !== void 0 && Date.now() >= requestDeadlineAt;
25379
+ const sessionEnded = () => execution?.deadlineAt !== void 0 && Date.now() >= execution.deadlineAt;
25380
+ const failed = async (err) => {
25381
+ const { providerFailure: providerFailure2 } = await Promise.resolve().then(() => (init_provider2(), provider_exports));
25382
+ const wallMs = Date.now() - start;
25383
+ const message2 = ops.secrets.reduce((m, secret) => scrubSecret(m, secret), err instanceof Error ? err.message : String(err)).slice(0, 200);
25384
+ return providerFailure2(url, wallMs, [
25385
+ { at: 0, lane: "provider", event: "provider_selected", detail: { provider: vendorId } },
25386
+ { at: wallMs, lane: "provider", event: "provider_failed", detail: { error: message2 } }
25387
+ ], err, sessionEnded());
25388
+ };
25389
+ let vendor;
25390
+ try {
25391
+ if (sessionApplies && session.resume !== void 0 && session.resume !== null) {
25392
+ pendingResume = session.resume;
25393
+ persistenceAttempted = true;
25394
+ } else {
25395
+ await preparePersistence(execution);
25396
+ }
25397
+ vendor = tariff !== null ? await connectOwn(execution) : await ensureConnected(execution);
25398
+ } catch (err) {
25399
+ if (requestEnded())
25400
+ throw err;
25401
+ return await failed(err);
25262
25402
  }
25263
- const { declaration, transport } = tariff !== null ? await connectOwn(execution) : await ensureConnected(execution);
25403
+ const { declaration, transport } = vendor;
25264
25404
  try {
25265
25405
  if (pendingResume !== null) {
25266
25406
  transport.useResumedSession(pendingResume);
@@ -25268,6 +25408,10 @@ function buildChannels(mode, opts = {}) {
25268
25408
  const { ProviderSubject: ProviderSubject2 } = await Promise.resolve().then(() => (init_provider2(), provider_exports));
25269
25409
  const subject = new ProviderSubject2(declaration, transport, mode, null, providerRobots);
25270
25410
  return await subject.fetch(url, execution?.deadlineAt, execution?.signal, execution?.onRetryAfter, options);
25411
+ } catch (err) {
25412
+ if (requestEnded() || !sessionEnded())
25413
+ throw err;
25414
+ return await failed(err);
25271
25415
  } finally {
25272
25416
  if (tariff !== null)
25273
25417
  await transport.close().catch(() => {
@@ -25293,7 +25437,9 @@ function buildChannels(mode, opts = {}) {
25293
25437
  fetch: async (url, session, execution, options) => {
25294
25438
  if (tariff === null || tariff.maxSessionMs === null)
25295
25439
  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);
25440
+ const held = { ...execution, deadlineAt: Math.min(execution?.deadlineAt ?? Number.POSITIVE_INFINITY, Date.now() + tariff.maxSessionMs) };
25441
+ requestDeadlines.set(held, execution?.deadlineAt);
25442
+ return raw.fetch(url, session, held, options);
25297
25443
  }
25298
25444
  };
25299
25445
  };
@@ -25708,7 +25854,7 @@ import { mkdir as mkdir7, writeFile as writeFile6 } from "fs/promises";
25708
25854
  init_dist();
25709
25855
 
25710
25856
  // packages/sdk/dist/version.js
25711
- var SDK_VERSION = "0.3.2";
25857
+ var SDK_VERSION = "0.4.0";
25712
25858
 
25713
25859
  // packages/sdk/dist/watcher.js
25714
25860
  init_dist();
@@ -31638,10 +31784,8 @@ function createApp(engine, options = {}) {
31638
31784
  return fail(c, "invalid_request", "cursor is not one this API issued");
31639
31785
  if (err instanceof SyntaxError)
31640
31786
  return fail(c, "invalid_json", "body must be JSON");
31641
- if (options.exposeInternalErrors === true)
31642
- return fail(c, "internal_error", err.message);
31643
31787
  console.error(JSON.stringify({ component: "api", method: c.req.method, path: c.req.path, error: err.stack ?? String(err) }));
31644
- return fail(c, "internal_error", "internal error");
31788
+ return fail(c, "internal_error", options.exposeInternalErrors === true ? err.message : "internal error");
31645
31789
  });
31646
31790
  return app;
31647
31791
  }
@@ -32275,7 +32419,7 @@ ${LOGIN_USAGE}`);
32275
32419
  }
32276
32420
 
32277
32421
  // packages/cli/src/run.ts
32278
- var CLI_VERSION = "0.3.2";
32422
+ var CLI_VERSION = "0.4.0";
32279
32423
  async function runCli(argv, io) {
32280
32424
  const [name, ...rest] = argv;
32281
32425
  if (name === void 0 || name === "--help" || name === "help") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octocrawl",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Octocrawl on the command line: scrape, crawl, batch and map with an Evidence Record on every page, or serve the local API.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "keywords": [