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
package/dist/index.cjs CHANGED
@@ -2598,6 +2598,93 @@ async function resolveGoogleGotoUrls(rawUrls, options = {}) {
2598
2598
  await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker()));
2599
2599
  return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
2600
2600
  }
2601
+ function resolvedSite(link, fallback) {
2602
+ try {
2603
+ return new URL(link.resolvedUrl ?? link.url).hostname.replace(/^www\./, "");
2604
+ } catch {
2605
+ return fallback;
2606
+ }
2607
+ }
2608
+ async function resolveHarvestResultGoogleGotoLinks(result, options = {}) {
2609
+ const treeNodes = [];
2610
+ const visit = (nodes) => {
2611
+ for (const node of nodes) {
2612
+ treeNodes.push(node);
2613
+ visit(node.children ?? []);
2614
+ }
2615
+ };
2616
+ visit(result.tree ?? []);
2617
+ const candidates = [
2618
+ ...result.flat.flatMap((row) => row.sources ?? []),
2619
+ ...treeNodes.flatMap((node) => node.sources),
2620
+ ...result.organicResults ?? [],
2621
+ ...(result.aiOverview?.citations ?? []).map(({ text: _text, href, ...link }) => ({ ...link, url: href })),
2622
+ ...(result.aiMode?.citations ?? []).map(({ text: _text, href, ...link }) => ({ ...link, url: href }))
2623
+ ];
2624
+ const unique = new Map(candidates.filter((link) => Boolean(link?.rawUrl)).map((link) => [link.rawUrl, link]));
2625
+ const gotoUrls = [...unique.values()].filter((link) => link.linkType === "google_goto_redirect" && link.resolutionStatus !== "resolved").map((link) => link.rawUrl);
2626
+ if (gotoUrls.length > 0) {
2627
+ const batch = await resolveGoogleGotoUrls(gotoUrls, options);
2628
+ for (const resolution2 of batch.resolutions) {
2629
+ const link = unique.get(resolution2.rawUrl);
2630
+ if (!link) continue;
2631
+ unique.set(resolution2.rawUrl, resolution2.resolutionStatus === "resolved" && resolution2.resolvedUrl ? { ...link, url: resolution2.resolvedUrl, resolvedUrl: resolution2.resolvedUrl, resolutionStatus: "resolved" } : { ...link, resolutionStatus: resolution2.resolutionStatus });
2632
+ }
2633
+ }
2634
+ const replace = (link) => ({
2635
+ ...link,
2636
+ ...unique.get(link.rawUrl) ?? link
2637
+ });
2638
+ const replaceSources = (sources) => sources.map((source) => {
2639
+ const link = replace(source);
2640
+ return { ...link, site: resolvedSite(link, source.site) };
2641
+ });
2642
+ for (const row of result.flat) {
2643
+ row.sources = replaceSources(row.sources ?? []);
2644
+ const primary = row.sources[0];
2645
+ row.source_title = primary?.title ?? row.source_title;
2646
+ row.source_site = primary?.site ?? row.source_site;
2647
+ row.source_cite = primary?.url ?? row.source_cite;
2648
+ }
2649
+ for (const node of treeNodes) {
2650
+ node.sources = replaceSources(node.sources ?? []);
2651
+ const primary = node.sources[0];
2652
+ node.sourceTitle = primary?.title ?? node.sourceTitle;
2653
+ node.sourceSite = primary?.site ?? node.sourceSite;
2654
+ node.sourceCite = primary?.url ?? node.sourceCite;
2655
+ }
2656
+ for (let index = 0; index < (result.organicResults?.length ?? 0); index++) {
2657
+ const link = replace(result.organicResults[index]);
2658
+ result.organicResults[index] = { ...link, domain: resolvedSite(link, result.organicResults[index].domain) ?? "" };
2659
+ }
2660
+ const replaceCitation = (citation) => {
2661
+ const link = replace({
2662
+ url: citation.href,
2663
+ rawUrl: citation.rawUrl,
2664
+ resolvedUrl: citation.resolvedUrl,
2665
+ linkType: citation.linkType,
2666
+ resolutionStatus: citation.resolutionStatus
2667
+ });
2668
+ const { url, ...metadata } = link;
2669
+ return { ...citation, ...metadata, href: url };
2670
+ };
2671
+ if (result.aiOverview) result.aiOverview.citations = (result.aiOverview.citations ?? []).map(replaceCitation);
2672
+ if (result.aiMode) result.aiMode.citations = (result.aiMode.citations ?? []).map(replaceCitation);
2673
+ const values = [...unique.values()];
2674
+ result.diagnostics.links = {
2675
+ linkCandidatesObserved: unique.size,
2676
+ plainLinksObserved: values.filter((link) => link.linkType === "plain").length,
2677
+ googleUrlRedirectsObserved: values.filter((link) => link.linkType === "google_url_redirect").length,
2678
+ googleGotoRedirectsObserved: values.filter((link) => link.linkType === "google_goto_redirect").length,
2679
+ redirectsResolved: values.filter((link) => link.linkType !== "plain" && link.resolutionStatus === "resolved").length,
2680
+ redirectsUnresolved: values.filter((link) => link.resolutionStatus === "unresolved").length,
2681
+ redirectsRejected: values.filter((link) => link.resolutionStatus === "rejected").length,
2682
+ resolverNonGoogleRequestsObserved: 0,
2683
+ paaSourcesPreserved: result.flat.reduce((total, row) => total + row.sources.length, 0),
2684
+ aiOverviewLabelValidated: result.aiOverview?.detected ?? false
2685
+ };
2686
+ return result;
2687
+ }
2601
2688
  async function resolveGoogleOutboundLinks(page, values, options = {}) {
2602
2689
  const byRawUrl = /* @__PURE__ */ new Map();
2603
2690
  for (const value of values) byRawUrl.set(value.rawUrl, value);
@@ -2718,15 +2805,24 @@ async function resolveGoogleOutboundLinks(page, values, options = {}) {
2718
2805
  }
2719
2806
 
2720
2807
  // src/extractor/PAAExtractor.ts
2808
+ 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]*$`;
2809
+ var PAA_CLICK_EARLIEST_CONFIRMATION_MS = 700;
2810
+ var PAA_CLICK_MID_CONFIRMATION_MS = 1e3;
2811
+ var PAA_CLICK_ATTEMPT_BUDGET_MS = 1400;
2812
+ var PAA_CLICK_DISPATCH_TIMEOUT_MS = 900;
2813
+ var PAA_FRONTIER_BATCH_SIZE = 3;
2814
+ var MAX_RETAINED_INTERACTION_OBSERVATIONS = 250;
2721
2815
  var PAAExtractor = class {
2722
- constructor(driver, reporter, onPaaProgress) {
2816
+ constructor(driver, reporter, onPaaProgress, onProviderSession) {
2723
2817
  this.driver = driver;
2724
2818
  this.reporter = reporter;
2725
2819
  this.onPaaProgress = onPaaProgress;
2820
+ this.onProviderSession = onProviderSession;
2726
2821
  }
2727
2822
  driver;
2728
2823
  reporter;
2729
2824
  onPaaProgress;
2825
+ onProviderSession;
2730
2826
  completeness = {
2731
2827
  paaWithoutAnswer: 0,
2732
2828
  paaWithoutSource: 0,
@@ -2741,6 +2837,7 @@ var PAAExtractor = class {
2741
2837
  clickedQuestions = /* @__PURE__ */ new Set();
2742
2838
  paaLifecycle = this.emptyPaaLifecycle(0);
2743
2839
  checkpointMaterial = null;
2840
+ interactionObservations = [];
2744
2841
  emptyPaaLifecycle(requestedQuestions) {
2745
2842
  return {
2746
2843
  requestedQuestions,
@@ -2753,7 +2850,12 @@ var PAAExtractor = class {
2753
2850
  knownQuestions: 0,
2754
2851
  processedQuestions: 0,
2755
2852
  failedInteractions: 0,
2756
- noGrowthConfirmations: 0
2853
+ noGrowthConfirmations: 0,
2854
+ visibleControls: 0,
2855
+ eligibleControls: 0,
2856
+ dispatchedInteractions: 0,
2857
+ confirmedInteractions: 0,
2858
+ unconfirmedInteractions: 0
2757
2859
  };
2758
2860
  }
2759
2861
  emptyLinkDiagnostics() {
@@ -2862,7 +2964,7 @@ var PAAExtractor = class {
2862
2964
  }
2863
2965
  async extractVisibleItems(page, onlyQuestion) {
2864
2966
  const sels = PAASelectors;
2865
- const raw = await page.evaluate(({ selectors, onlyQuestion: onlyQuestion2 }) => {
2967
+ const raw = await page.evaluate(({ selectors, onlyQuestion: onlyQuestion2, answerPlaceholderPattern }) => {
2866
2968
  function cleanText(el) {
2867
2969
  if (!el) return "";
2868
2970
  const parts = [];
@@ -2889,12 +2991,13 @@ var PAAExtractor = class {
2889
2991
  const aiAnswerBody = answerContainer?.querySelector(selectors.aiAnswerBody);
2890
2992
  const answerElement = aiAnswerBody ?? answerContainer;
2891
2993
  const answerText = cleanText(answerElement);
2994
+ const answerIsPlaceholder = new RegExp(answerPlaceholderPattern, "i").test(answerText);
2892
2995
  const citationCandidates = [
2893
2996
  ...Array.from((answerElement ?? pair).querySelectorAll("a[href]")),
2894
2997
  ...Array.from(pair.querySelectorAll(selectors.aiCitation)),
2895
2998
  ...Array.from(pair.querySelectorAll(selectors.aiSourceCard))
2896
2999
  ];
2897
- const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80)) && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href));
3000
+ const hasLoadedAnswer = !answerIsPlaceholder && (expanded && answerText.length >= 10 || answerText.length > 30 && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href)));
2898
3001
  let sourceTitle = hasLoadedAnswer ? pair.querySelector(selectors.sourceTitle)?.innerText?.trim() || void 0 : void 0;
2899
3002
  let sourceSite = hasLoadedAnswer ? pair.querySelector(selectors.sourceSite)?.innerText?.trim() || void 0 : void 0;
2900
3003
  const sources = [];
@@ -2918,7 +3021,7 @@ var PAAExtractor = class {
2918
3021
  sources
2919
3022
  };
2920
3023
  });
2921
- }, { selectors: sels, onlyQuestion });
3024
+ }, { selectors: sels, onlyQuestion, answerPlaceholderPattern: PAA_ANSWER_PLACEHOLDER_PATTERN });
2922
3025
  const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
2923
3026
  return raw.flatMap((item) => {
2924
3027
  const sources = [];
@@ -3014,7 +3117,11 @@ var PAAExtractor = class {
3014
3117
  phase,
3015
3118
  records: this.collectedRows.map((row) => ({ ...row })),
3016
3119
  lifecycle: { ...this.paaLifecycle },
3017
- ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {}
3120
+ ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {},
3121
+ interactions: this.interactionObservations.map((observation) => ({
3122
+ ...observation,
3123
+ confirmationSignals: [...observation.confirmationSignals]
3124
+ }))
3018
3125
  };
3019
3126
  try {
3020
3127
  await this.onPaaProgress(snapshot);
@@ -3095,6 +3202,7 @@ var PAAExtractor = class {
3095
3202
  const seenQs = /* @__PURE__ */ new Set();
3096
3203
  const orderedQs = [];
3097
3204
  this.clickedQuestions = /* @__PURE__ */ new Set();
3205
+ this.interactionObservations = [];
3098
3206
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
3099
3207
  const clickAttempts = /* @__PURE__ */ new Map();
3100
3208
  const processedQuestions = /* @__PURE__ */ new Set();
@@ -3102,6 +3210,7 @@ var PAAExtractor = class {
3102
3210
  const capturedItems = /* @__PURE__ */ new Map();
3103
3211
  const MAX_CLICK_ATTEMPTS = 2;
3104
3212
  const EXHAUSTION_CONFIRMATIONS = 3;
3213
+ let batchNumber = 0;
3105
3214
  const itemArgs = {
3106
3215
  sel: PAASelectors.item,
3107
3216
  dataQ: PAASelectors.itemDataQ,
@@ -3109,46 +3218,171 @@ var PAAExtractor = class {
3109
3218
  questionEl: PAASelectors.itemQuestionEl,
3110
3219
  answerSel: PAASelectors.answerContainer,
3111
3220
  expandedClass: PAASelectors.expandedClass,
3112
- clickTarget: PAASelectors.clickTarget
3221
+ clickTarget: PAASelectors.clickTarget,
3222
+ answerPlaceholderPattern: PAA_ANSWER_PLACEHOLDER_PATTERN,
3223
+ questionsOnly: options.questionsOnly,
3224
+ sourceSel: "a[href]"
3113
3225
  };
3114
3226
  const readItemStates = () => page.evaluate(
3115
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel }) => {
3116
- return Array.from(document.querySelectorAll(sel)).map((el) => ({
3117
- q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
3118
- done: el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true" || Boolean((el.querySelector(answerSel)?.textContent ?? "").trim())
3119
- })).filter((s) => s.q);
3227
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel, answerPlaceholderPattern, questionsOnly }) => {
3228
+ const cleanVisibleMaterial = (element) => {
3229
+ if (!element) return "";
3230
+ const parts = [];
3231
+ for (const node of element.childNodes) {
3232
+ if (node.nodeType === Node.TEXT_NODE) {
3233
+ const text2 = node.textContent?.trim();
3234
+ if (text2) parts.push(text2);
3235
+ continue;
3236
+ }
3237
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
3238
+ const child = node;
3239
+ if (child.tagName === "STYLE" || child.tagName === "SCRIPT" || child.getAttribute("aria-hidden") === "true") continue;
3240
+ const text = cleanVisibleMaterial(child);
3241
+ if (text) parts.push(text);
3242
+ }
3243
+ return parts.join(" ").replace(/\s+/g, " ").trim();
3244
+ };
3245
+ return Array.from(document.querySelectorAll(sel)).map((el, index) => {
3246
+ 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();
3247
+ const expanded = el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true";
3248
+ const answerReady = answerText.length >= 10 && !new RegExp(answerPlaceholderPattern, "i").test(answerText);
3249
+ return {
3250
+ q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
3251
+ done: expanded && (questionsOnly || answerReady),
3252
+ expanded,
3253
+ index
3254
+ };
3255
+ }).filter((s) => s.q);
3120
3256
  },
3121
3257
  itemArgs
3122
3258
  );
3123
- const expandOneItemSerially = async (q, beforeCount) => {
3259
+ const expandOneItemSerially = async (q, beforeCount, itemIndex, wasExpanded, controlAttempt, batchNumber2) => {
3124
3260
  const clickStartedMs = Date.now();
3125
- const items = page.locator(itemArgs.sel);
3126
- const itemIndex = await items.evaluateAll(
3127
- (elements, { dataQ, dataInitQ, target }) => elements.findIndex((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ)) === target),
3128
- { dataQ: itemArgs.dataQ, dataInitQ: itemArgs.dataInitQ, target: q }
3129
- ).catch(() => -1);
3130
- const clickTarget = itemIndex >= 0 ? items.nth(itemIndex).locator(itemArgs.clickTarget) : null;
3261
+ const baseObservation = {
3262
+ id: `${batchNumber2}:${questionIdFor(q)}:${controlAttempt}`,
3263
+ questionId: questionIdFor(q),
3264
+ question: q,
3265
+ batchNumber: batchNumber2,
3266
+ controlAttempt,
3267
+ observedAt: new Date(clickStartedMs).toISOString(),
3268
+ beforeQuestionCount: beforeCount,
3269
+ expandedBefore: wasExpanded
3270
+ };
3271
+ const clickTarget = itemIndex >= 0 ? page.locator(itemArgs.sel).nth(itemIndex).locator(itemArgs.clickTarget) : null;
3272
+ let dispatchErrorCode = null;
3131
3273
  const clicked = clickTarget ? await (async () => {
3132
- await clickTarget.scrollIntoViewIfNeeded({ timeout: 5e3 });
3133
- const box = await clickTarget.boundingBox({ timeout: 5e3 });
3134
- if (!box) return false;
3135
- await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
3274
+ if (wasExpanded) {
3275
+ await clickTarget.click({ timeout: PAA_CLICK_DISPATCH_TIMEOUT_MS, force: true });
3276
+ await page.waitForTimeout(120);
3277
+ }
3278
+ await clickTarget.click({ timeout: PAA_CLICK_DISPATCH_TIMEOUT_MS, force: true });
3136
3279
  return true;
3137
- })().catch(() => false) : false;
3138
- if (!clicked) return { dispatched: false, confirmed: false };
3139
- const remainingHumanClickDelayMs = 700 - (Date.now() - clickStartedMs);
3140
- if (remainingHumanClickDelayMs > 0) await page.waitForTimeout(remainingHumanClickDelayMs);
3141
- const confirmed = await page.waitForFunction(
3142
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget: clickTarget2, answerSel, target, min }) => {
3143
- const items2 = Array.from(document.querySelectorAll(sel));
3144
- const item = items2.find((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ) || element.querySelector(questionEl)?.innerText?.trim()) === target);
3145
- if (!item) return items2.length > min;
3146
- return item.classList.contains(expandedClass) || item.querySelector(clickTarget2)?.getAttribute("aria-expanded") === "true" || Boolean((item.querySelector(answerSel)?.textContent ?? "").trim()) || items2.length > min;
3147
- },
3148
- { ...itemArgs, target: q, min: beforeCount },
3149
- { timeout: 2500, polling: 100 }
3150
- ).catch(() => null);
3151
- return { dispatched: true, confirmed: Boolean(confirmed) };
3280
+ })().catch((error) => {
3281
+ dispatchErrorCode = error instanceof Error && (error.name === "TimeoutError" || /timeout/i.test(error.message)) ? "click_ack_timeout" : "click_failed";
3282
+ return false;
3283
+ }) : false;
3284
+ const dispatchDurationMs = Date.now() - clickStartedMs;
3285
+ if (!clicked && dispatchErrorCode !== "click_ack_timeout") {
3286
+ const observation2 = {
3287
+ ...baseObservation,
3288
+ dispatched: false,
3289
+ outcome: "dispatch_failed",
3290
+ confirmationSignals: [],
3291
+ confirmationCheckpointMs: null,
3292
+ confirmedAtMs: null,
3293
+ dispatchDurationMs,
3294
+ totalDurationMs: dispatchDurationMs,
3295
+ afterQuestionCount: beforeCount,
3296
+ newQuestionCount: 0,
3297
+ expandedAfter: wasExpanded,
3298
+ answerObserved: false,
3299
+ sourceCount: 0,
3300
+ errorCode: itemIndex < 0 ? "control_missing" : dispatchErrorCode ?? "click_failed"
3301
+ };
3302
+ return { dispatched: false, confirmed: false, observation: observation2 };
3303
+ }
3304
+ let latest = {
3305
+ exists: true,
3306
+ expanded: wasExpanded,
3307
+ answerReady: false,
3308
+ sourceCount: 0,
3309
+ questionCount: beforeCount
3310
+ };
3311
+ let signals = [];
3312
+ let confirmationCheckpointMs = null;
3313
+ let confirmedAtMs = null;
3314
+ let confirmed = false;
3315
+ for (const checkpointMs of [PAA_CLICK_EARLIEST_CONFIRMATION_MS, PAA_CLICK_MID_CONFIRMATION_MS, PAA_CLICK_ATTEMPT_BUDGET_MS]) {
3316
+ const remainingMs = clickStartedMs + checkpointMs - Date.now();
3317
+ if (remainingMs > 0) await page.waitForTimeout(remainingMs);
3318
+ latest = await page.evaluate(
3319
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget: clickTarget2, answerSel, answerPlaceholderPattern, sourceSel, target }) => {
3320
+ const cleanVisibleMaterial = (element) => {
3321
+ if (!element) return "";
3322
+ const parts = [];
3323
+ for (const node of element.childNodes) {
3324
+ if (node.nodeType === Node.TEXT_NODE) {
3325
+ const text2 = node.textContent?.trim();
3326
+ if (text2) parts.push(text2);
3327
+ continue;
3328
+ }
3329
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
3330
+ const child = node;
3331
+ if (child.tagName === "STYLE" || child.tagName === "SCRIPT" || child.getAttribute("aria-hidden") === "true") continue;
3332
+ const text = cleanVisibleMaterial(child);
3333
+ if (text) parts.push(text);
3334
+ }
3335
+ return parts.join(" ").replace(/\s+/g, " ").trim();
3336
+ };
3337
+ const items = Array.from(document.querySelectorAll(sel));
3338
+ const item = items.find((element) => (element.getAttribute(dataQ) || element.getAttribute(dataInitQ) || element.querySelector(questionEl)?.innerText?.trim()) === target);
3339
+ if (!item) return { exists: false, expanded: false, answerReady: false, sourceCount: 0, questionCount: items.length };
3340
+ const expanded = item.classList.contains(expandedClass) || item.querySelector(clickTarget2)?.getAttribute("aria-expanded") === "true";
3341
+ const answerElement = item.querySelector(answerSel);
3342
+ const answerText = cleanVisibleMaterial(answerElement);
3343
+ 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();
3344
+ const answerReady = confirmationText.length >= 10 && !new RegExp(answerPlaceholderPattern, "i").test(confirmationText);
3345
+ return {
3346
+ exists: true,
3347
+ expanded,
3348
+ answerReady,
3349
+ sourceCount: answerReady ? answerElement?.querySelectorAll(sourceSel).length ?? 0 : 0,
3350
+ questionCount: items.length
3351
+ };
3352
+ },
3353
+ { ...itemArgs, target: q }
3354
+ ).catch(() => latest);
3355
+ signals = [
3356
+ ...latest.expanded ? ["expanded"] : [],
3357
+ ...latest.answerReady ? ["answer"] : [],
3358
+ ...latest.sourceCount > 0 ? ["source"] : [],
3359
+ ...latest.questionCount > beforeCount ? ["question_growth"] : []
3360
+ ];
3361
+ if (latest.answerReady || latest.sourceCount > 0 || latest.questionCount > beforeCount) {
3362
+ confirmed = true;
3363
+ confirmationCheckpointMs = checkpointMs;
3364
+ confirmedAtMs = Date.now() - clickStartedMs;
3365
+ break;
3366
+ }
3367
+ }
3368
+ const totalDurationMs = Date.now() - clickStartedMs;
3369
+ const observation = {
3370
+ ...baseObservation,
3371
+ dispatched: clicked,
3372
+ outcome: confirmed ? "confirmed" : clicked ? "unconfirmed" : "dispatch_failed",
3373
+ confirmationSignals: signals,
3374
+ confirmationCheckpointMs,
3375
+ confirmedAtMs,
3376
+ dispatchDurationMs,
3377
+ totalDurationMs,
3378
+ afterQuestionCount: latest.questionCount,
3379
+ newQuestionCount: Math.max(0, latest.questionCount - beforeCount),
3380
+ expandedAfter: latest.expanded,
3381
+ answerObserved: latest.answerReady,
3382
+ sourceCount: latest.sourceCount,
3383
+ errorCode: !clicked ? dispatchErrorCode : confirmed ? null : latest.exists ? "confirmation_timeout" : "control_missing"
3384
+ };
3385
+ return { dispatched: clicked, confirmed, observation };
3152
3386
  };
3153
3387
  let round = 0;
3154
3388
  expansion: while (true) {
@@ -3179,7 +3413,8 @@ var PAAExtractor = class {
3179
3413
  targetStatus: orderedQs.length >= options.maxQuestions ? "met" : "not_met",
3180
3414
  knownQuestions: orderedQs.length,
3181
3415
  processedQuestions: processedQuestions.size,
3182
- failedInteractions: failedInteractions.size
3416
+ failedInteractions: failedInteractions.size,
3417
+ visibleControls: states.length
3183
3418
  };
3184
3419
  if (discovered) await this.emitProgress("discovered");
3185
3420
  if (options.questionsOnly && seenQs.size >= options.maxQuestions) {
@@ -3191,10 +3426,44 @@ var PAAExtractor = class {
3191
3426
  };
3192
3427
  break;
3193
3428
  }
3429
+ if (!options.questionsOnly && seenQs.size >= options.maxQuestions) {
3430
+ let materialChanged = false;
3431
+ const keptQuestions = new Set(orderedQs.slice(0, options.maxQuestions));
3432
+ for (const item of await this.extractVisibleItems(page)) {
3433
+ if (!keptQuestions.has(item.question)) continue;
3434
+ const existing = capturedItems.get(item.question);
3435
+ const merged = {
3436
+ ...item,
3437
+ answer: item.answer ?? existing?.answer,
3438
+ sourceTitle: item.sourceTitle ?? existing?.sourceTitle,
3439
+ sourceSite: item.sourceSite ?? existing?.sourceSite,
3440
+ sourceCite: item.sourceCite ?? existing?.sourceCite,
3441
+ sources: this.mergePaaSources(existing?.sources, item.sources)
3442
+ };
3443
+ capturedItems.set(item.question, merged);
3444
+ materialChanged = this.upsertCollectedRow(this.toFlatRow(merged, 1, null, options.query)) || materialChanged;
3445
+ }
3446
+ if (materialChanged) await this.emitProgress("answer_captured");
3447
+ if ([...keptQuestions].every((question) => {
3448
+ const item = capturedItems.get(question);
3449
+ return Boolean(item?.answer?.trim() && item.sources.length > 0);
3450
+ })) {
3451
+ failedInteractions.clear();
3452
+ this.paaLifecycle = {
3453
+ ...this.paaLifecycle,
3454
+ discoveryStatus: "target_reached",
3455
+ targetStatus: "met",
3456
+ failedInteractions: 0,
3457
+ completionReason: "The requested unique-question count and its answer/source material were observed."
3458
+ };
3459
+ break;
3460
+ }
3461
+ }
3194
3462
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
3195
3463
  const clickable = states.filter((state) => kept.has(state.q) && !state.done && (clickAttempts.get(state.q) ?? 0) < MAX_CLICK_ATTEMPTS && !abandonedNeverReclick.has(state.q));
3196
- const target = clickable[0];
3197
- if (!target) {
3464
+ const targets = clickable.slice(-PAA_FRONTIER_BATCH_SIZE);
3465
+ this.paaLifecycle = { ...this.paaLifecycle, eligibleControls: clickable.length };
3466
+ if (targets.length === 0) {
3198
3467
  if (seenQs.size >= options.maxQuestions) {
3199
3468
  this.paaLifecycle = {
3200
3469
  ...this.paaLifecycle,
@@ -3242,37 +3511,64 @@ var PAAExtractor = class {
3242
3511
  };
3243
3512
  break;
3244
3513
  }
3245
- this.reporter.onDepth(++round);
3246
3514
  await this.throwIfCaptcha(page, "Google PAA expansion");
3247
- clickAttempts.set(target.q, (clickAttempts.get(target.q) ?? 0) + 1);
3248
- const expansionResult = await expandOneItemSerially(target.q, states.length);
3249
- if (expansionResult.dispatched) this.clickedQuestions.add(target.q);
3250
- if (!options.questionsOnly) {
3251
- let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
3252
- if (!clickedItem?.answer) {
3253
- await page.waitForTimeout(1300);
3254
- clickedItem = (await this.extractVisibleItems(page, target.q))[0];
3515
+ batchNumber += 1;
3516
+ for (const target of targets) {
3517
+ this.reporter.onDepth(++round);
3518
+ if ((clickAttempts.get(target.q) ?? 0) > 0) {
3519
+ this.paaLifecycle = {
3520
+ ...this.paaLifecycle,
3521
+ automaticRetries: this.paaLifecycle.automaticRetries + 1
3522
+ };
3255
3523
  }
3256
- if (clickedItem) {
3257
- capturedItems.set(target.q, clickedItem);
3524
+ clickAttempts.set(target.q, (clickAttempts.get(target.q) ?? 0) + 1);
3525
+ }
3526
+ const expansionResults = await Promise.all(targets.map(async (target) => ({
3527
+ target,
3528
+ result: await expandOneItemSerially(
3529
+ target.q,
3530
+ states.length,
3531
+ target.index,
3532
+ target.expanded,
3533
+ clickAttempts.get(target.q) ?? 1,
3534
+ batchNumber
3535
+ )
3536
+ })));
3537
+ for (const { target, result } of expansionResults) {
3538
+ if (result.dispatched || result.confirmed) this.clickedQuestions.add(target.q);
3539
+ this.interactionObservations.push(result.observation);
3540
+ }
3541
+ if (this.interactionObservations.length > MAX_RETAINED_INTERACTION_OBSERVATIONS) {
3542
+ this.interactionObservations.splice(0, this.interactionObservations.length - MAX_RETAINED_INTERACTION_OBSERVATIONS);
3543
+ }
3544
+ if (!options.questionsOnly) {
3545
+ const targetQuestions = new Set(targets.map((target) => target.q));
3546
+ for (const clickedItem of await this.extractVisibleItems(page)) {
3547
+ if (!targetQuestions.has(clickedItem.question)) continue;
3548
+ capturedItems.set(clickedItem.question, clickedItem);
3258
3549
  if (this.upsertCollectedRow(this.toFlatRow(clickedItem, 1, null, options.query))) {
3259
3550
  await this.emitProgress("answer_captured");
3260
3551
  }
3261
3552
  }
3262
3553
  }
3263
- const captured = capturedItems.get(target.q);
3264
- if (expansionResult.confirmed || Boolean(captured?.answer?.trim())) {
3265
- processedQuestions.add(target.q);
3266
- failedInteractions.delete(target.q);
3267
- } else if ((clickAttempts.get(target.q) ?? 0) >= MAX_CLICK_ATTEMPTS) {
3268
- failedInteractions.add(target.q);
3269
- abandonedNeverReclick.add(target.q);
3554
+ for (const { target, result } of expansionResults) {
3555
+ const captured = capturedItems.get(target.q);
3556
+ if (result.confirmed || Boolean(captured?.answer?.trim())) {
3557
+ processedQuestions.add(target.q);
3558
+ failedInteractions.delete(target.q);
3559
+ } else if ((clickAttempts.get(target.q) ?? 0) >= MAX_CLICK_ATTEMPTS) {
3560
+ failedInteractions.add(target.q);
3561
+ abandonedNeverReclick.add(target.q);
3562
+ }
3270
3563
  }
3271
3564
  this.paaLifecycle = {
3272
3565
  ...this.paaLifecycle,
3273
3566
  processedQuestions: processedQuestions.size,
3274
3567
  failedInteractions: failedInteractions.size,
3275
- noGrowthConfirmations: 0
3568
+ noGrowthConfirmations: 0,
3569
+ dispatchedInteractions: this.interactionObservations.filter((value) => value.dispatched).length,
3570
+ confirmedInteractions: this.interactionObservations.filter((value) => value.outcome === "confirmed").length,
3571
+ unconfirmedInteractions: this.interactionObservations.filter((value) => value.outcome !== "confirmed").length
3276
3572
  };
3277
3573
  await this.emitProgress("click_observed");
3278
3574
  }
@@ -3866,6 +4162,18 @@ var PAAExtractor = class {
3866
4162
  try {
3867
4163
  this.throwIfAborted(signal);
3868
4164
  await this.driver.launch(config);
4165
+ const providerSession = this.driver.getProviderSessionMetadata?.();
4166
+ if (providerSession?.providerSessionId && this.onProviderSession) {
4167
+ try {
4168
+ await this.onProviderSession(providerSession);
4169
+ } catch (err) {
4170
+ console.warn(JSON.stringify({
4171
+ event: "paa_provider_session_sink_failed",
4172
+ provider: providerSession.provider,
4173
+ message: err instanceof Error ? err.message : String(err)
4174
+ }));
4175
+ }
4176
+ }
3869
4177
  this.throwIfAborted(signal);
3870
4178
  const locationResolution = options.location ? resolveLocation(options.location) : null;
3871
4179
  const canonicalLocation = options.location ? locationResolution?.canonical ?? options.location : null;
@@ -4970,6 +5278,17 @@ function stripInternalDebug(result, keepDebug) {
4970
5278
  delete diagnostics.debug;
4971
5279
  return { ...result, diagnostics };
4972
5280
  }
5281
+ async function resolveSurvivingGotoLinks(result) {
5282
+ try {
5283
+ return await resolveHarvestResultGoogleGotoLinks(result, { totalBudgetMs: 1e4 });
5284
+ } catch (error) {
5285
+ console.warn(JSON.stringify({
5286
+ event: "harvest_goto_resolution_failed",
5287
+ message: errorMessage(error)
5288
+ }));
5289
+ return result;
5290
+ }
5291
+ }
4973
5292
  async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4974
5293
  if (!kernelApiKey || !proxyId) return;
4975
5294
  try {
@@ -4986,10 +5305,10 @@ async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4986
5305
  }));
4987
5306
  }
4988
5307
  }
4989
- async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress) {
5308
+ async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress, onProviderSession) {
4990
5309
  const driver = createSerpDriver(options, { forceManaged: forceManagedSerp });
4991
5310
  const reporter = new ProgressReporter();
4992
- const extractor = new PAAExtractor(driver, reporter, onPaaProgress);
5311
+ const extractor = new PAAExtractor(driver, reporter, onPaaProgress, onProviderSession);
4993
5312
  if (signal?.aborted) {
4994
5313
  return {
4995
5314
  result: null,
@@ -5134,12 +5453,20 @@ async function harvest(rawOptions) {
5134
5453
  max_questions: attemptOptions.maxQuestions
5135
5454
  }));
5136
5455
  const baseCtx = currentCostContext();
5456
+ const onProviderSession = (metadata) => emitAttemptEvent(onAttemptEvent, {
5457
+ type: "provider_session_started",
5458
+ attemptNumber,
5459
+ maxAttempts,
5460
+ provider: metadata.provider,
5461
+ providerSessionId: metadata.providerSessionId,
5462
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
5463
+ });
5137
5464
  const allowHeadlessTest = baseCtx?.harvestBrowserMode === "headless-test" && process.env[HEADLESS_HARVEST_TEST_ENV] === "true";
5138
5465
  const attempt = baseCtx ? await runWithCostContext({
5139
5466
  ...baseCtx,
5140
5467
  forceHeadless: allowHeadlessTest,
5141
5468
  forceHeadful: !allowHeadlessTest
5142
- }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress);
5469
+ }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress, onProviderSession)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress, onProviderSession);
5143
5470
  if (attempt.error) {
5144
5471
  const err = attempt.error;
5145
5472
  const outcome2 = classifyAttemptError(err);
@@ -5184,7 +5511,8 @@ async function harvest(rawOptions) {
5184
5511
  lastError = err;
5185
5512
  if (willRetry2) continue;
5186
5513
  if (attempt.result) {
5187
- return withResultQuality(stripInternalDebug(attempt.result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
5514
+ const resolvedResult2 = await resolveSurvivingGotoLinks(attempt.result);
5515
+ return withResultQuality(stripInternalDebug(resolvedResult2, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
5188
5516
  }
5189
5517
  break;
5190
5518
  }
@@ -5219,7 +5547,8 @@ async function harvest(rawOptions) {
5219
5547
  if (willRetry2) continue;
5220
5548
  break;
5221
5549
  }
5222
- const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
5550
+ const resolvedResult = await resolveSurvivingGotoLinks(result);
5551
+ const finalResult = withResultQuality(stripInternalDebug(resolvedResult, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
5223
5552
  const outcome = classifyAttemptResult(finalResult);
5224
5553
  const willRetry = outcome === "degraded_result" && i < maxAttempts - 1;
5225
5554
  const resultError = outcome === "degraded_result" ? degradedResultMessage(finalResult) : null;