mcp-scraper 0.79.1 → 0.79.3

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 (38) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +1 -1
  3. package/dist/{analytics-repository-VEUDEPWI.js → analytics-repository-YOY5KMOD.js} +3 -3
  4. package/dist/bin/api-server.js +4 -4
  5. package/dist/bin/mcp-scraper-cli.js +1 -1
  6. package/dist/bin/mcp-scraper-core.js +7 -7
  7. package/dist/bin/mcp-scraper-install.js +2 -2
  8. package/dist/bin/mcp-stdio-server.js +7 -7
  9. package/dist/bin/paa-harvest.js +3 -3
  10. package/dist/{chunk-LB2GNACG.js → chunk-3423R23T.js} +3 -3
  11. package/dist/{chunk-NGLZ6UJR.js → chunk-5UAFBKJR.js} +1 -1
  12. package/dist/{chunk-B6L73AO4.js → chunk-6HAV7LCE.js} +3 -1
  13. package/dist/{chunk-NH3EUCZL.js → chunk-7UZAVM4J.js} +138 -11
  14. package/dist/{chunk-Q5L2OCOQ.js → chunk-C5Z4OFKW.js} +1 -1
  15. package/dist/{chunk-KAGHJQFO.js → chunk-EQGTEHLZ.js} +1 -1
  16. package/dist/{chunk-BTQ23KCT.js → chunk-F5GQJWZU.js} +1 -1
  17. package/dist/{chunk-DUWZQSSJ.js → chunk-HEUMDX4I.js} +1 -1
  18. package/dist/{chunk-OIGM6VKQ.js → chunk-IHYG52ZW.js} +1 -1
  19. package/dist/{chunk-OUBH6XMY.js → chunk-K3Z5AQYE.js} +1 -1
  20. package/dist/{chunk-UFJJGXVO.js → chunk-KNGTQD6U.js} +396 -67
  21. package/dist/{chunk-R4YRH5PG.js → chunk-MVB25X55.js} +1 -1
  22. package/dist/{chunk-47ZCUBM5.js → chunk-NVUKO5NN.js} +12 -1
  23. package/dist/{chunk-QVW5S6BD.js → chunk-TCYZ7HMU.js} +3 -3
  24. package/dist/{chunk-SL7YVA4S.js → chunk-YBR32LOD.js} +1 -1
  25. package/dist/{chunk-OU4FV2RP.js → chunk-YXNDOQXN.js} +49 -0
  26. package/dist/{db-TA567JLA.js → db-Z34LPZNR.js} +5 -1
  27. package/dist/{extract-bundle-I2GNC25L.js → extract-bundle-S3ZGNFML.js} +3 -3
  28. package/dist/{gmail-service-GGQVJUC3.js → gmail-service-YFS2OV4S.js} +4 -4
  29. package/dist/index.cjs +394 -65
  30. package/dist/index.d.cts +35 -0
  31. package/dist/index.d.ts +35 -0
  32. package/dist/index.js +3 -3
  33. package/dist/{lead-list-enrichment-repository-5LEWW4X7.js → lead-list-enrichment-repository-36RPVV6N.js} +2 -2
  34. package/dist/{location-data-repository-AHKDXTEQ.js → location-data-repository-WPRG62GE.js} +2 -2
  35. package/dist/{server-MNSYIX42.js → server-MDWFOUTS.js} +73 -28
  36. package/dist/{site-extract-repository-KF736IR6.js → site-extract-repository-WAJMIDPJ.js} +2 -2
  37. package/dist/{worker-VU6JCCLG.js → worker-3LUFQWWJ.js} +5 -5
  38. package/package.json +1 -1
@@ -8,7 +8,7 @@ import {
8
8
  currentCostContext,
9
9
  recordKernelSession,
10
10
  runWithCostContext
11
- } from "./chunk-NGLZ6UJR.js";
11
+ } from "./chunk-5UAFBKJR.js";
12
12
  import {
13
13
  DEFAULT_PROXY_MODE,
14
14
  HarvestOptionsSchema,
@@ -24,7 +24,7 @@ import {
24
24
  RECAPTCHA_INSTRUCTIONS,
25
25
  RequestAbortedError,
26
26
  sanitizeVendorName
27
- } from "./chunk-OU4FV2RP.js";
27
+ } from "./chunk-YXNDOQXN.js";
28
28
 
29
29
  // src/driver/BrowserDriver.ts
30
30
  import { chromium } from "playwright-extra";
@@ -2054,6 +2054,93 @@ async function resolveGoogleGotoUrls(rawUrls, options = {}) {
2054
2054
  await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker()));
2055
2055
  return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
2056
2056
  }
2057
+ function resolvedSite(link, fallback) {
2058
+ try {
2059
+ return new URL(link.resolvedUrl ?? link.url).hostname.replace(/^www\./, "");
2060
+ } catch {
2061
+ return fallback;
2062
+ }
2063
+ }
2064
+ async function resolveHarvestResultGoogleGotoLinks(result, options = {}) {
2065
+ const treeNodes = [];
2066
+ const visit = (nodes) => {
2067
+ for (const node of nodes) {
2068
+ treeNodes.push(node);
2069
+ visit(node.children ?? []);
2070
+ }
2071
+ };
2072
+ visit(result.tree ?? []);
2073
+ const candidates = [
2074
+ ...result.flat.flatMap((row) => row.sources ?? []),
2075
+ ...treeNodes.flatMap((node) => node.sources),
2076
+ ...result.organicResults ?? [],
2077
+ ...(result.aiOverview?.citations ?? []).map(({ text: _text, href, ...link }) => ({ ...link, url: href })),
2078
+ ...(result.aiMode?.citations ?? []).map(({ text: _text, href, ...link }) => ({ ...link, url: href }))
2079
+ ];
2080
+ const unique = new Map(candidates.filter((link) => Boolean(link?.rawUrl)).map((link) => [link.rawUrl, link]));
2081
+ const gotoUrls = [...unique.values()].filter((link) => link.linkType === "google_goto_redirect" && link.resolutionStatus !== "resolved").map((link) => link.rawUrl);
2082
+ if (gotoUrls.length > 0) {
2083
+ const batch = await resolveGoogleGotoUrls(gotoUrls, options);
2084
+ for (const resolution2 of batch.resolutions) {
2085
+ const link = unique.get(resolution2.rawUrl);
2086
+ if (!link) continue;
2087
+ unique.set(resolution2.rawUrl, resolution2.resolutionStatus === "resolved" && resolution2.resolvedUrl ? { ...link, url: resolution2.resolvedUrl, resolvedUrl: resolution2.resolvedUrl, resolutionStatus: "resolved" } : { ...link, resolutionStatus: resolution2.resolutionStatus });
2088
+ }
2089
+ }
2090
+ const replace = (link) => ({
2091
+ ...link,
2092
+ ...unique.get(link.rawUrl) ?? link
2093
+ });
2094
+ const replaceSources = (sources) => sources.map((source) => {
2095
+ const link = replace(source);
2096
+ return { ...link, site: resolvedSite(link, source.site) };
2097
+ });
2098
+ for (const row of result.flat) {
2099
+ row.sources = replaceSources(row.sources ?? []);
2100
+ const primary = row.sources[0];
2101
+ row.source_title = primary?.title ?? row.source_title;
2102
+ row.source_site = primary?.site ?? row.source_site;
2103
+ row.source_cite = primary?.url ?? row.source_cite;
2104
+ }
2105
+ for (const node of treeNodes) {
2106
+ node.sources = replaceSources(node.sources ?? []);
2107
+ const primary = node.sources[0];
2108
+ node.sourceTitle = primary?.title ?? node.sourceTitle;
2109
+ node.sourceSite = primary?.site ?? node.sourceSite;
2110
+ node.sourceCite = primary?.url ?? node.sourceCite;
2111
+ }
2112
+ for (let index = 0; index < (result.organicResults?.length ?? 0); index++) {
2113
+ const link = replace(result.organicResults[index]);
2114
+ result.organicResults[index] = { ...link, domain: resolvedSite(link, result.organicResults[index].domain) ?? "" };
2115
+ }
2116
+ const replaceCitation = (citation) => {
2117
+ const link = replace({
2118
+ url: citation.href,
2119
+ rawUrl: citation.rawUrl,
2120
+ resolvedUrl: citation.resolvedUrl,
2121
+ linkType: citation.linkType,
2122
+ resolutionStatus: citation.resolutionStatus
2123
+ });
2124
+ const { url, ...metadata } = link;
2125
+ return { ...citation, ...metadata, href: url };
2126
+ };
2127
+ if (result.aiOverview) result.aiOverview.citations = (result.aiOverview.citations ?? []).map(replaceCitation);
2128
+ if (result.aiMode) result.aiMode.citations = (result.aiMode.citations ?? []).map(replaceCitation);
2129
+ const values = [...unique.values()];
2130
+ result.diagnostics.links = {
2131
+ linkCandidatesObserved: unique.size,
2132
+ plainLinksObserved: values.filter((link) => link.linkType === "plain").length,
2133
+ googleUrlRedirectsObserved: values.filter((link) => link.linkType === "google_url_redirect").length,
2134
+ googleGotoRedirectsObserved: values.filter((link) => link.linkType === "google_goto_redirect").length,
2135
+ redirectsResolved: values.filter((link) => link.linkType !== "plain" && link.resolutionStatus === "resolved").length,
2136
+ redirectsUnresolved: values.filter((link) => link.resolutionStatus === "unresolved").length,
2137
+ redirectsRejected: values.filter((link) => link.resolutionStatus === "rejected").length,
2138
+ resolverNonGoogleRequestsObserved: 0,
2139
+ paaSourcesPreserved: result.flat.reduce((total, row) => total + row.sources.length, 0),
2140
+ aiOverviewLabelValidated: result.aiOverview?.detected ?? false
2141
+ };
2142
+ return result;
2143
+ }
2057
2144
  async function resolveGoogleOutboundLinks(page, values, options = {}) {
2058
2145
  const byRawUrl = /* @__PURE__ */ new Map();
2059
2146
  for (const value of values) byRawUrl.set(value.rawUrl, value);
@@ -2174,15 +2261,24 @@ async function resolveGoogleOutboundLinks(page, values, options = {}) {
2174
2261
  }
2175
2262
 
2176
2263
  // src/extractor/PAAExtractor.ts
2264
+ var PAA_ANSWER_PLACEHOLDER_PATTERN = String.raw`^(?:an error has occurred(?:[.!…]\s*)?(?:please try again later[.!…]?)?|loading|please wait|try again|something went wrong)[.!…\s]*$`;
2265
+ var PAA_CLICK_EARLIEST_CONFIRMATION_MS = 700;
2266
+ var PAA_CLICK_MID_CONFIRMATION_MS = 1e3;
2267
+ var PAA_CLICK_ATTEMPT_BUDGET_MS = 1400;
2268
+ var PAA_CLICK_DISPATCH_TIMEOUT_MS = 900;
2269
+ var PAA_FRONTIER_BATCH_SIZE = 3;
2270
+ var MAX_RETAINED_INTERACTION_OBSERVATIONS = 250;
2177
2271
  var PAAExtractor = class {
2178
- constructor(driver, reporter, onPaaProgress) {
2272
+ constructor(driver, reporter, onPaaProgress, onProviderSession) {
2179
2273
  this.driver = driver;
2180
2274
  this.reporter = reporter;
2181
2275
  this.onPaaProgress = onPaaProgress;
2276
+ this.onProviderSession = onProviderSession;
2182
2277
  }
2183
2278
  driver;
2184
2279
  reporter;
2185
2280
  onPaaProgress;
2281
+ onProviderSession;
2186
2282
  completeness = {
2187
2283
  paaWithoutAnswer: 0,
2188
2284
  paaWithoutSource: 0,
@@ -2197,6 +2293,7 @@ var PAAExtractor = class {
2197
2293
  clickedQuestions = /* @__PURE__ */ new Set();
2198
2294
  paaLifecycle = this.emptyPaaLifecycle(0);
2199
2295
  checkpointMaterial = null;
2296
+ interactionObservations = [];
2200
2297
  emptyPaaLifecycle(requestedQuestions) {
2201
2298
  return {
2202
2299
  requestedQuestions,
@@ -2209,7 +2306,12 @@ var PAAExtractor = class {
2209
2306
  knownQuestions: 0,
2210
2307
  processedQuestions: 0,
2211
2308
  failedInteractions: 0,
2212
- noGrowthConfirmations: 0
2309
+ noGrowthConfirmations: 0,
2310
+ visibleControls: 0,
2311
+ eligibleControls: 0,
2312
+ dispatchedInteractions: 0,
2313
+ confirmedInteractions: 0,
2314
+ unconfirmedInteractions: 0
2213
2315
  };
2214
2316
  }
2215
2317
  emptyLinkDiagnostics() {
@@ -2318,7 +2420,7 @@ var PAAExtractor = class {
2318
2420
  }
2319
2421
  async extractVisibleItems(page, onlyQuestion) {
2320
2422
  const sels = PAASelectors;
2321
- const raw = await page.evaluate(({ selectors, onlyQuestion: onlyQuestion2 }) => {
2423
+ const raw = await page.evaluate(({ selectors, onlyQuestion: onlyQuestion2, answerPlaceholderPattern }) => {
2322
2424
  function cleanText(el) {
2323
2425
  if (!el) return "";
2324
2426
  const parts = [];
@@ -2345,12 +2447,13 @@ var PAAExtractor = class {
2345
2447
  const aiAnswerBody = answerContainer?.querySelector(selectors.aiAnswerBody);
2346
2448
  const answerElement = aiAnswerBody ?? answerContainer;
2347
2449
  const answerText = cleanText(answerElement);
2450
+ const answerIsPlaceholder = new RegExp(answerPlaceholderPattern, "i").test(answerText);
2348
2451
  const citationCandidates = [
2349
2452
  ...Array.from((answerElement ?? pair).querySelectorAll("a[href]")),
2350
2453
  ...Array.from(pair.querySelectorAll(selectors.aiCitation)),
2351
2454
  ...Array.from(pair.querySelectorAll(selectors.aiSourceCard))
2352
2455
  ];
2353
- const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80)) && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href));
2456
+ const hasLoadedAnswer = !answerIsPlaceholder && (expanded && answerText.length >= 10 || answerText.length > 30 && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href)));
2354
2457
  let sourceTitle = hasLoadedAnswer ? pair.querySelector(selectors.sourceTitle)?.innerText?.trim() || void 0 : void 0;
2355
2458
  let sourceSite = hasLoadedAnswer ? pair.querySelector(selectors.sourceSite)?.innerText?.trim() || void 0 : void 0;
2356
2459
  const sources = [];
@@ -2374,7 +2477,7 @@ var PAAExtractor = class {
2374
2477
  sources
2375
2478
  };
2376
2479
  });
2377
- }, { selectors: sels, onlyQuestion });
2480
+ }, { selectors: sels, onlyQuestion, answerPlaceholderPattern: PAA_ANSWER_PLACEHOLDER_PATTERN });
2378
2481
  const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
2379
2482
  return raw.flatMap((item) => {
2380
2483
  const sources = [];
@@ -2470,7 +2573,11 @@ var PAAExtractor = class {
2470
2573
  phase,
2471
2574
  records: this.collectedRows.map((row) => ({ ...row })),
2472
2575
  lifecycle: { ...this.paaLifecycle },
2473
- ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {}
2576
+ ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {},
2577
+ interactions: this.interactionObservations.map((observation) => ({
2578
+ ...observation,
2579
+ confirmationSignals: [...observation.confirmationSignals]
2580
+ }))
2474
2581
  };
2475
2582
  try {
2476
2583
  await this.onPaaProgress(snapshot);
@@ -2551,6 +2658,7 @@ var PAAExtractor = class {
2551
2658
  const seenQs = /* @__PURE__ */ new Set();
2552
2659
  const orderedQs = [];
2553
2660
  this.clickedQuestions = /* @__PURE__ */ new Set();
2661
+ this.interactionObservations = [];
2554
2662
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
2555
2663
  const clickAttempts = /* @__PURE__ */ new Map();
2556
2664
  const processedQuestions = /* @__PURE__ */ new Set();
@@ -2558,6 +2666,7 @@ var PAAExtractor = class {
2558
2666
  const capturedItems = /* @__PURE__ */ new Map();
2559
2667
  const MAX_CLICK_ATTEMPTS = 2;
2560
2668
  const EXHAUSTION_CONFIRMATIONS = 3;
2669
+ let batchNumber = 0;
2561
2670
  const itemArgs = {
2562
2671
  sel: PAASelectors.item,
2563
2672
  dataQ: PAASelectors.itemDataQ,
@@ -2565,46 +2674,171 @@ var PAAExtractor = class {
2565
2674
  questionEl: PAASelectors.itemQuestionEl,
2566
2675
  answerSel: PAASelectors.answerContainer,
2567
2676
  expandedClass: PAASelectors.expandedClass,
2568
- clickTarget: PAASelectors.clickTarget
2677
+ clickTarget: PAASelectors.clickTarget,
2678
+ answerPlaceholderPattern: PAA_ANSWER_PLACEHOLDER_PATTERN,
2679
+ questionsOnly: options.questionsOnly,
2680
+ sourceSel: "a[href]"
2569
2681
  };
2570
2682
  const readItemStates = () => page.evaluate(
2571
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel }) => {
2572
- return Array.from(document.querySelectorAll(sel)).map((el) => ({
2573
- q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
2574
- done: el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true" || Boolean((el.querySelector(answerSel)?.textContent ?? "").trim())
2575
- })).filter((s) => s.q);
2683
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel, answerPlaceholderPattern, questionsOnly }) => {
2684
+ const cleanVisibleMaterial = (element) => {
2685
+ if (!element) return "";
2686
+ const parts = [];
2687
+ for (const node of element.childNodes) {
2688
+ if (node.nodeType === Node.TEXT_NODE) {
2689
+ const text2 = node.textContent?.trim();
2690
+ if (text2) parts.push(text2);
2691
+ continue;
2692
+ }
2693
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
2694
+ const child = node;
2695
+ if (child.tagName === "STYLE" || child.tagName === "SCRIPT" || child.getAttribute("aria-hidden") === "true") continue;
2696
+ const text = cleanVisibleMaterial(child);
2697
+ if (text) parts.push(text);
2698
+ }
2699
+ return parts.join(" ").replace(/\s+/g, " ").trim();
2700
+ };
2701
+ return Array.from(document.querySelectorAll(sel)).map((el, index) => {
2702
+ const answerText = cleanVisibleMaterial(el.querySelector(answerSel)).replace(/An AI Overview is not available for this search/gi, " ").replace(/Can't generate an AI overview right now\.?\s*Try again later\.?/gi, " ").replace(/\bAI Overview\b/gi, " ").replace(/\s+/g, " ").trim();
2703
+ const expanded = el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true";
2704
+ const answerReady = answerText.length >= 10 && !new RegExp(answerPlaceholderPattern, "i").test(answerText);
2705
+ return {
2706
+ q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
2707
+ done: expanded && (questionsOnly || answerReady),
2708
+ expanded,
2709
+ index
2710
+ };
2711
+ }).filter((s) => s.q);
2576
2712
  },
2577
2713
  itemArgs
2578
2714
  );
2579
- const expandOneItemSerially = async (q, beforeCount) => {
2715
+ const expandOneItemSerially = async (q, beforeCount, itemIndex, wasExpanded, controlAttempt, batchNumber2) => {
2580
2716
  const clickStartedMs = Date.now();
2581
- const items = page.locator(itemArgs.sel);
2582
- const itemIndex = await items.evaluateAll(
2583
- (elements, { dataQ, dataInitQ, target }) => elements.findIndex((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ)) === target),
2584
- { dataQ: itemArgs.dataQ, dataInitQ: itemArgs.dataInitQ, target: q }
2585
- ).catch(() => -1);
2586
- const clickTarget = itemIndex >= 0 ? items.nth(itemIndex).locator(itemArgs.clickTarget) : null;
2717
+ const baseObservation = {
2718
+ id: `${batchNumber2}:${questionIdFor(q)}:${controlAttempt}`,
2719
+ questionId: questionIdFor(q),
2720
+ question: q,
2721
+ batchNumber: batchNumber2,
2722
+ controlAttempt,
2723
+ observedAt: new Date(clickStartedMs).toISOString(),
2724
+ beforeQuestionCount: beforeCount,
2725
+ expandedBefore: wasExpanded
2726
+ };
2727
+ const clickTarget = itemIndex >= 0 ? page.locator(itemArgs.sel).nth(itemIndex).locator(itemArgs.clickTarget) : null;
2728
+ let dispatchErrorCode = null;
2587
2729
  const clicked = clickTarget ? await (async () => {
2588
- await clickTarget.scrollIntoViewIfNeeded({ timeout: 5e3 });
2589
- const box = await clickTarget.boundingBox({ timeout: 5e3 });
2590
- if (!box) return false;
2591
- await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
2730
+ if (wasExpanded) {
2731
+ await clickTarget.click({ timeout: PAA_CLICK_DISPATCH_TIMEOUT_MS, force: true });
2732
+ await page.waitForTimeout(120);
2733
+ }
2734
+ await clickTarget.click({ timeout: PAA_CLICK_DISPATCH_TIMEOUT_MS, force: true });
2592
2735
  return true;
2593
- })().catch(() => false) : false;
2594
- if (!clicked) return { dispatched: false, confirmed: false };
2595
- const remainingHumanClickDelayMs = 700 - (Date.now() - clickStartedMs);
2596
- if (remainingHumanClickDelayMs > 0) await page.waitForTimeout(remainingHumanClickDelayMs);
2597
- const confirmed = await page.waitForFunction(
2598
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget: clickTarget2, answerSel, target, min }) => {
2599
- const items2 = Array.from(document.querySelectorAll(sel));
2600
- const item = items2.find((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ) || element.querySelector(questionEl)?.innerText?.trim()) === target);
2601
- if (!item) return items2.length > min;
2602
- return item.classList.contains(expandedClass) || item.querySelector(clickTarget2)?.getAttribute("aria-expanded") === "true" || Boolean((item.querySelector(answerSel)?.textContent ?? "").trim()) || items2.length > min;
2603
- },
2604
- { ...itemArgs, target: q, min: beforeCount },
2605
- { timeout: 2500, polling: 100 }
2606
- ).catch(() => null);
2607
- return { dispatched: true, confirmed: Boolean(confirmed) };
2736
+ })().catch((error) => {
2737
+ dispatchErrorCode = error instanceof Error && (error.name === "TimeoutError" || /timeout/i.test(error.message)) ? "click_ack_timeout" : "click_failed";
2738
+ return false;
2739
+ }) : false;
2740
+ const dispatchDurationMs = Date.now() - clickStartedMs;
2741
+ if (!clicked && dispatchErrorCode !== "click_ack_timeout") {
2742
+ const observation2 = {
2743
+ ...baseObservation,
2744
+ dispatched: false,
2745
+ outcome: "dispatch_failed",
2746
+ confirmationSignals: [],
2747
+ confirmationCheckpointMs: null,
2748
+ confirmedAtMs: null,
2749
+ dispatchDurationMs,
2750
+ totalDurationMs: dispatchDurationMs,
2751
+ afterQuestionCount: beforeCount,
2752
+ newQuestionCount: 0,
2753
+ expandedAfter: wasExpanded,
2754
+ answerObserved: false,
2755
+ sourceCount: 0,
2756
+ errorCode: itemIndex < 0 ? "control_missing" : dispatchErrorCode ?? "click_failed"
2757
+ };
2758
+ return { dispatched: false, confirmed: false, observation: observation2 };
2759
+ }
2760
+ let latest = {
2761
+ exists: true,
2762
+ expanded: wasExpanded,
2763
+ answerReady: false,
2764
+ sourceCount: 0,
2765
+ questionCount: beforeCount
2766
+ };
2767
+ let signals = [];
2768
+ let confirmationCheckpointMs = null;
2769
+ let confirmedAtMs = null;
2770
+ let confirmed = false;
2771
+ for (const checkpointMs of [PAA_CLICK_EARLIEST_CONFIRMATION_MS, PAA_CLICK_MID_CONFIRMATION_MS, PAA_CLICK_ATTEMPT_BUDGET_MS]) {
2772
+ const remainingMs = clickStartedMs + checkpointMs - Date.now();
2773
+ if (remainingMs > 0) await page.waitForTimeout(remainingMs);
2774
+ latest = await page.evaluate(
2775
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget: clickTarget2, answerSel, answerPlaceholderPattern, sourceSel, target }) => {
2776
+ const cleanVisibleMaterial = (element) => {
2777
+ if (!element) return "";
2778
+ const parts = [];
2779
+ for (const node of element.childNodes) {
2780
+ if (node.nodeType === Node.TEXT_NODE) {
2781
+ const text2 = node.textContent?.trim();
2782
+ if (text2) parts.push(text2);
2783
+ continue;
2784
+ }
2785
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
2786
+ const child = node;
2787
+ if (child.tagName === "STYLE" || child.tagName === "SCRIPT" || child.getAttribute("aria-hidden") === "true") continue;
2788
+ const text = cleanVisibleMaterial(child);
2789
+ if (text) parts.push(text);
2790
+ }
2791
+ return parts.join(" ").replace(/\s+/g, " ").trim();
2792
+ };
2793
+ const items = Array.from(document.querySelectorAll(sel));
2794
+ const item = items.find((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ) || element.querySelector(questionEl)?.innerText?.trim()) === target);
2795
+ if (!item) return { exists: false, expanded: false, answerReady: false, sourceCount: 0, questionCount: items.length };
2796
+ const expanded = item.classList.contains(expandedClass) || item.querySelector(clickTarget2)?.getAttribute("aria-expanded") === "true";
2797
+ const answerElement = item.querySelector(answerSel);
2798
+ const answerText = cleanVisibleMaterial(answerElement);
2799
+ const confirmationText = answerText.replace(/An AI Overview is not available for this search/gi, " ").replace(/Can't generate an AI overview right now\.?\s*Try again later\.?/gi, " ").replace(/\bAI Overview\b/gi, " ").replace(/\s+/g, " ").trim();
2800
+ const answerReady = confirmationText.length >= 10 && !new RegExp(answerPlaceholderPattern, "i").test(confirmationText);
2801
+ return {
2802
+ exists: true,
2803
+ expanded,
2804
+ answerReady,
2805
+ sourceCount: answerReady ? answerElement?.querySelectorAll(sourceSel).length ?? 0 : 0,
2806
+ questionCount: items.length
2807
+ };
2808
+ },
2809
+ { ...itemArgs, target: q }
2810
+ ).catch(() => latest);
2811
+ signals = [
2812
+ ...latest.expanded ? ["expanded"] : [],
2813
+ ...latest.answerReady ? ["answer"] : [],
2814
+ ...latest.sourceCount > 0 ? ["source"] : [],
2815
+ ...latest.questionCount > beforeCount ? ["question_growth"] : []
2816
+ ];
2817
+ if (latest.answerReady || latest.sourceCount > 0 || latest.questionCount > beforeCount) {
2818
+ confirmed = true;
2819
+ confirmationCheckpointMs = checkpointMs;
2820
+ confirmedAtMs = Date.now() - clickStartedMs;
2821
+ break;
2822
+ }
2823
+ }
2824
+ const totalDurationMs = Date.now() - clickStartedMs;
2825
+ const observation = {
2826
+ ...baseObservation,
2827
+ dispatched: clicked,
2828
+ outcome: confirmed ? "confirmed" : clicked ? "unconfirmed" : "dispatch_failed",
2829
+ confirmationSignals: signals,
2830
+ confirmationCheckpointMs,
2831
+ confirmedAtMs,
2832
+ dispatchDurationMs,
2833
+ totalDurationMs,
2834
+ afterQuestionCount: latest.questionCount,
2835
+ newQuestionCount: Math.max(0, latest.questionCount - beforeCount),
2836
+ expandedAfter: latest.expanded,
2837
+ answerObserved: latest.answerReady,
2838
+ sourceCount: latest.sourceCount,
2839
+ errorCode: !clicked ? dispatchErrorCode : confirmed ? null : latest.exists ? "confirmation_timeout" : "control_missing"
2840
+ };
2841
+ return { dispatched: clicked, confirmed, observation };
2608
2842
  };
2609
2843
  let round = 0;
2610
2844
  expansion: while (true) {
@@ -2635,7 +2869,8 @@ var PAAExtractor = class {
2635
2869
  targetStatus: orderedQs.length >= options.maxQuestions ? "met" : "not_met",
2636
2870
  knownQuestions: orderedQs.length,
2637
2871
  processedQuestions: processedQuestions.size,
2638
- failedInteractions: failedInteractions.size
2872
+ failedInteractions: failedInteractions.size,
2873
+ visibleControls: states.length
2639
2874
  };
2640
2875
  if (discovered) await this.emitProgress("discovered");
2641
2876
  if (options.questionsOnly && seenQs.size >= options.maxQuestions) {
@@ -2647,10 +2882,44 @@ var PAAExtractor = class {
2647
2882
  };
2648
2883
  break;
2649
2884
  }
2885
+ if (!options.questionsOnly && seenQs.size >= options.maxQuestions) {
2886
+ let materialChanged = false;
2887
+ const keptQuestions = new Set(orderedQs.slice(0, options.maxQuestions));
2888
+ for (const item of await this.extractVisibleItems(page)) {
2889
+ if (!keptQuestions.has(item.question)) continue;
2890
+ const existing = capturedItems.get(item.question);
2891
+ const merged = {
2892
+ ...item,
2893
+ answer: item.answer ?? existing?.answer,
2894
+ sourceTitle: item.sourceTitle ?? existing?.sourceTitle,
2895
+ sourceSite: item.sourceSite ?? existing?.sourceSite,
2896
+ sourceCite: item.sourceCite ?? existing?.sourceCite,
2897
+ sources: this.mergePaaSources(existing?.sources, item.sources)
2898
+ };
2899
+ capturedItems.set(item.question, merged);
2900
+ materialChanged = this.upsertCollectedRow(this.toFlatRow(merged, 1, null, options.query)) || materialChanged;
2901
+ }
2902
+ if (materialChanged) await this.emitProgress("answer_captured");
2903
+ if ([...keptQuestions].every((question) => {
2904
+ const item = capturedItems.get(question);
2905
+ return Boolean(item?.answer?.trim() && item.sources.length > 0);
2906
+ })) {
2907
+ failedInteractions.clear();
2908
+ this.paaLifecycle = {
2909
+ ...this.paaLifecycle,
2910
+ discoveryStatus: "target_reached",
2911
+ targetStatus: "met",
2912
+ failedInteractions: 0,
2913
+ completionReason: "The requested unique-question count and its answer/source material were observed."
2914
+ };
2915
+ break;
2916
+ }
2917
+ }
2650
2918
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2651
2919
  const clickable = states.filter((state) => kept.has(state.q) && !state.done && (clickAttempts.get(state.q) ?? 0) < MAX_CLICK_ATTEMPTS && !abandonedNeverReclick.has(state.q));
2652
- const target = clickable[0];
2653
- if (!target) {
2920
+ const targets = clickable.slice(-PAA_FRONTIER_BATCH_SIZE);
2921
+ this.paaLifecycle = { ...this.paaLifecycle, eligibleControls: clickable.length };
2922
+ if (targets.length === 0) {
2654
2923
  if (seenQs.size >= options.maxQuestions) {
2655
2924
  this.paaLifecycle = {
2656
2925
  ...this.paaLifecycle,
@@ -2698,37 +2967,64 @@ var PAAExtractor = class {
2698
2967
  };
2699
2968
  break;
2700
2969
  }
2701
- this.reporter.onDepth(++round);
2702
2970
  await this.throwIfCaptcha(page, "Google PAA expansion");
2703
- clickAttempts.set(target.q, (clickAttempts.get(target.q) ?? 0) + 1);
2704
- const expansionResult = await expandOneItemSerially(target.q, states.length);
2705
- if (expansionResult.dispatched) this.clickedQuestions.add(target.q);
2706
- if (!options.questionsOnly) {
2707
- let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2708
- if (!clickedItem?.answer) {
2709
- await page.waitForTimeout(1300);
2710
- clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2971
+ batchNumber += 1;
2972
+ for (const target of targets) {
2973
+ this.reporter.onDepth(++round);
2974
+ if ((clickAttempts.get(target.q) ?? 0) > 0) {
2975
+ this.paaLifecycle = {
2976
+ ...this.paaLifecycle,
2977
+ automaticRetries: this.paaLifecycle.automaticRetries + 1
2978
+ };
2711
2979
  }
2712
- if (clickedItem) {
2713
- capturedItems.set(target.q, clickedItem);
2980
+ clickAttempts.set(target.q, (clickAttempts.get(target.q) ?? 0) + 1);
2981
+ }
2982
+ const expansionResults = await Promise.all(targets.map(async (target) => ({
2983
+ target,
2984
+ result: await expandOneItemSerially(
2985
+ target.q,
2986
+ states.length,
2987
+ target.index,
2988
+ target.expanded,
2989
+ clickAttempts.get(target.q) ?? 1,
2990
+ batchNumber
2991
+ )
2992
+ })));
2993
+ for (const { target, result } of expansionResults) {
2994
+ if (result.dispatched || result.confirmed) this.clickedQuestions.add(target.q);
2995
+ this.interactionObservations.push(result.observation);
2996
+ }
2997
+ if (this.interactionObservations.length > MAX_RETAINED_INTERACTION_OBSERVATIONS) {
2998
+ this.interactionObservations.splice(0, this.interactionObservations.length - MAX_RETAINED_INTERACTION_OBSERVATIONS);
2999
+ }
3000
+ if (!options.questionsOnly) {
3001
+ const targetQuestions = new Set(targets.map((target) => target.q));
3002
+ for (const clickedItem of await this.extractVisibleItems(page)) {
3003
+ if (!targetQuestions.has(clickedItem.question)) continue;
3004
+ capturedItems.set(clickedItem.question, clickedItem);
2714
3005
  if (this.upsertCollectedRow(this.toFlatRow(clickedItem, 1, null, options.query))) {
2715
3006
  await this.emitProgress("answer_captured");
2716
3007
  }
2717
3008
  }
2718
3009
  }
2719
- const captured = capturedItems.get(target.q);
2720
- if (expansionResult.confirmed || Boolean(captured?.answer?.trim())) {
2721
- processedQuestions.add(target.q);
2722
- failedInteractions.delete(target.q);
2723
- } else if ((clickAttempts.get(target.q) ?? 0) >= MAX_CLICK_ATTEMPTS) {
2724
- failedInteractions.add(target.q);
2725
- abandonedNeverReclick.add(target.q);
3010
+ for (const { target, result } of expansionResults) {
3011
+ const captured = capturedItems.get(target.q);
3012
+ if (result.confirmed || Boolean(captured?.answer?.trim())) {
3013
+ processedQuestions.add(target.q);
3014
+ failedInteractions.delete(target.q);
3015
+ } else if ((clickAttempts.get(target.q) ?? 0) >= MAX_CLICK_ATTEMPTS) {
3016
+ failedInteractions.add(target.q);
3017
+ abandonedNeverReclick.add(target.q);
3018
+ }
2726
3019
  }
2727
3020
  this.paaLifecycle = {
2728
3021
  ...this.paaLifecycle,
2729
3022
  processedQuestions: processedQuestions.size,
2730
3023
  failedInteractions: failedInteractions.size,
2731
- noGrowthConfirmations: 0
3024
+ noGrowthConfirmations: 0,
3025
+ dispatchedInteractions: this.interactionObservations.filter((value) => value.dispatched).length,
3026
+ confirmedInteractions: this.interactionObservations.filter((value) => value.outcome === "confirmed").length,
3027
+ unconfirmedInteractions: this.interactionObservations.filter((value) => value.outcome !== "confirmed").length
2732
3028
  };
2733
3029
  await this.emitProgress("click_observed");
2734
3030
  }
@@ -3322,6 +3618,18 @@ var PAAExtractor = class {
3322
3618
  try {
3323
3619
  this.throwIfAborted(signal);
3324
3620
  await this.driver.launch(config);
3621
+ const providerSession = this.driver.getProviderSessionMetadata?.();
3622
+ if (providerSession?.providerSessionId && this.onProviderSession) {
3623
+ try {
3624
+ await this.onProviderSession(providerSession);
3625
+ } catch (err) {
3626
+ console.warn(JSON.stringify({
3627
+ event: "paa_provider_session_sink_failed",
3628
+ provider: providerSession.provider,
3629
+ message: err instanceof Error ? err.message : String(err)
3630
+ }));
3631
+ }
3632
+ }
3325
3633
  this.throwIfAborted(signal);
3326
3634
  const locationResolution = options.location ? resolveLocation(options.location) : null;
3327
3635
  const canonicalLocation = options.location ? locationResolution?.canonical ?? options.location : null;
@@ -4430,6 +4738,17 @@ function stripInternalDebug(result, keepDebug) {
4430
4738
  delete diagnostics.debug;
4431
4739
  return { ...result, diagnostics };
4432
4740
  }
4741
+ async function resolveSurvivingGotoLinks(result) {
4742
+ try {
4743
+ return await resolveHarvestResultGoogleGotoLinks(result, { totalBudgetMs: 1e4 });
4744
+ } catch (error) {
4745
+ console.warn(JSON.stringify({
4746
+ event: "harvest_goto_resolution_failed",
4747
+ message: errorMessage(error)
4748
+ }));
4749
+ return result;
4750
+ }
4751
+ }
4433
4752
  async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4434
4753
  if (!kernelApiKey || !proxyId) return;
4435
4754
  try {
@@ -4446,10 +4765,10 @@ async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4446
4765
  }));
4447
4766
  }
4448
4767
  }
4449
- async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress) {
4768
+ async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress, onProviderSession) {
4450
4769
  const driver = createSerpDriver(options, { forceManaged: forceManagedSerp });
4451
4770
  const reporter = new ProgressReporter();
4452
- const extractor = new PAAExtractor(driver, reporter, onPaaProgress);
4771
+ const extractor = new PAAExtractor(driver, reporter, onPaaProgress, onProviderSession);
4453
4772
  if (signal?.aborted) {
4454
4773
  return {
4455
4774
  result: null,
@@ -4594,12 +4913,20 @@ async function harvest(rawOptions) {
4594
4913
  max_questions: attemptOptions.maxQuestions
4595
4914
  }));
4596
4915
  const baseCtx = currentCostContext();
4916
+ const onProviderSession = (metadata) => emitAttemptEvent(onAttemptEvent, {
4917
+ type: "provider_session_started",
4918
+ attemptNumber,
4919
+ maxAttempts,
4920
+ provider: metadata.provider,
4921
+ providerSessionId: metadata.providerSessionId,
4922
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
4923
+ });
4597
4924
  const allowHeadlessTest = baseCtx?.harvestBrowserMode === "headless-test" && process.env[HEADLESS_HARVEST_TEST_ENV] === "true";
4598
4925
  const attempt = baseCtx ? await runWithCostContext({
4599
4926
  ...baseCtx,
4600
4927
  forceHeadless: allowHeadlessTest,
4601
4928
  forceHeadful: !allowHeadlessTest
4602
- }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress);
4929
+ }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress, onProviderSession)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress, onProviderSession);
4603
4930
  if (attempt.error) {
4604
4931
  const err = attempt.error;
4605
4932
  const outcome2 = classifyAttemptError(err);
@@ -4644,7 +4971,8 @@ async function harvest(rawOptions) {
4644
4971
  lastError = err;
4645
4972
  if (willRetry2) continue;
4646
4973
  if (attempt.result) {
4647
- return withResultQuality(stripInternalDebug(attempt.result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4974
+ const resolvedResult2 = await resolveSurvivingGotoLinks(attempt.result);
4975
+ return withResultQuality(stripInternalDebug(resolvedResult2, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4648
4976
  }
4649
4977
  break;
4650
4978
  }
@@ -4679,7 +5007,8 @@ async function harvest(rawOptions) {
4679
5007
  if (willRetry2) continue;
4680
5008
  break;
4681
5009
  }
4682
- const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
5010
+ const resolvedResult = await resolveSurvivingGotoLinks(result);
5011
+ const finalResult = withResultQuality(stripInternalDebug(resolvedResult, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4683
5012
  const outcome = classifyAttemptResult(finalResult);
4684
5013
  const willRetry = outcome === "degraded_result" && i < maxAttempts - 1;
4685
5014
  const resultError = outcome === "degraded_result" ? degradedResultMessage(finalResult) : null;
@@ -1,6 +1,6 @@
1
1
  // release-message.json
2
2
  var release_message_default = {
3
- message: "PAA harvests now distinguish a proven end of the question frontier from an interrupted browser, automatically resume the same durable job, and preserve captured questions, answers, AI Overview citations, and ranked SERP links."
3
+ message: "PAA harvests now distinguish click acknowledgement from confirmed Google DOM effects, preserve every observed interaction at 0.7, 1.0, and 1.4-second checkpoints, reject hidden or scripted placeholder text as answers, correlate attempts to Bright Data sessions immediately, and recover interrupted work without claiming the question frontier ended."
4
4
  };
5
5
 
6
6
  // src/install-terminal.ts