mcp-scraper 0.76.0 → 0.78.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 (40) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/dist/{analytics-repository-AUTJK4HK.js → analytics-repository-D5LEGQG3.js} +3 -3
  4. package/dist/bin/api-server.js +4 -4
  5. package/dist/bin/mcp-scraper-cli.js +3 -3
  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 +4 -4
  10. package/dist/{chunk-BOGRXBH2.js → chunk-2P2ZVRPK.js} +189 -57
  11. package/dist/{chunk-E6Y3VCOJ.js → chunk-3PUMNEOC.js} +3 -3
  12. package/dist/{chunk-KIPIBPUB.js → chunk-47ZCUBM5.js} +1 -1
  13. package/dist/{chunk-IBQNOZ7E.js → chunk-74CCAY6T.js} +1 -1
  14. package/dist/{chunk-7RQULQF3.js → chunk-B6L73AO4.js} +1 -1
  15. package/dist/{chunk-WFQ2E4WE.js → chunk-BTQ23KCT.js} +1 -1
  16. package/dist/{chunk-SDX6TRJ6.js → chunk-FODYEDZK.js} +3 -3
  17. package/dist/{chunk-PJEEKOUM.js → chunk-FUMG43EN.js} +1 -1
  18. package/dist/{chunk-XSYEDC25.js → chunk-IYYRSVRQ.js} +1 -1
  19. package/dist/{chunk-A66DGFOU.js → chunk-KAGHJQFO.js} +1 -1
  20. package/dist/{chunk-CXY5WV45.js → chunk-NGLZ6UJR.js} +1 -1
  21. package/dist/{chunk-WEFPBAAG.js → chunk-OU4FV2RP.js} +140 -4
  22. package/dist/{chunk-F5TK4KMT.js → chunk-OUBH6XMY.js} +1 -1
  23. package/dist/{chunk-77LQV73B.js → chunk-OWF2JJKN.js} +13 -1
  24. package/dist/{chunk-F6MUGMRN.js → chunk-UFJJGXVO.js} +702 -165
  25. package/dist/{chunk-KBTUPRTF.js → chunk-VEQPA65D.js} +1 -1
  26. package/dist/{chunk-D2O7V2CT.js → chunk-VMWSXKUQ.js} +1 -1
  27. package/dist/{chunk-JNFOBQVH.js → chunk-ZUCU2A6V.js} +1 -1
  28. package/dist/{db-566MOHHD.js → db-TA567JLA.js} +17 -1
  29. package/dist/{extract-bundle-OO74L67T.js → extract-bundle-P2DTUVY3.js} +3 -3
  30. package/dist/{gmail-service-TIU4V5JA.js → gmail-service-HN57R2GS.js} +4 -4
  31. package/dist/index.cjs +714 -165
  32. package/dist/index.d.cts +83 -1
  33. package/dist/index.d.ts +83 -1
  34. package/dist/index.js +4 -4
  35. package/dist/{lead-list-enrichment-repository-WWEMJ7EN.js → lead-list-enrichment-repository-5LEWW4X7.js} +2 -2
  36. package/dist/{location-data-repository-H4B2BWGY.js → location-data-repository-AHKDXTEQ.js} +2 -2
  37. package/dist/{server-IAGYCPN6.js → server-6B2OG3NN.js} +1434 -983
  38. package/dist/{site-extract-repository-B47JB62U.js → site-extract-repository-XKSVXSSY.js} +2 -2
  39. package/dist/{worker-DR6R7J46.js → worker-2BYACLNF.js} +6 -6
  40. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -128,11 +128,23 @@ var MapsSearchOptionsSchema = import_zod.z.object({
128
128
  headless: import_zod.z.boolean().default(true)
129
129
  });
130
130
  var RawPAAItemSchema = import_zod.z.object({
131
+ questionId: import_zod.z.string().min(1),
132
+ googleLinkId: import_zod.z.string().nullable(),
133
+ googleEvidenceId: import_zod.z.string().nullable(),
131
134
  question: import_zod.z.string().min(1),
132
135
  answer: import_zod.z.string().optional(),
133
136
  sourceTitle: import_zod.z.string().optional(),
134
137
  sourceSite: import_zod.z.string().optional(),
135
- sourceCite: import_zod.z.string().optional()
138
+ sourceCite: import_zod.z.string().optional(),
139
+ sources: import_zod.z.array(import_zod.z.object({
140
+ title: import_zod.z.string().nullable(),
141
+ site: import_zod.z.string().nullable(),
142
+ url: import_zod.z.string().min(1),
143
+ rawUrl: import_zod.z.string().min(1),
144
+ resolvedUrl: import_zod.z.string().nullable(),
145
+ linkType: import_zod.z.enum(["plain", "google_url_redirect", "google_goto_redirect"]),
146
+ resolutionStatus: import_zod.z.enum(["not_needed", "resolved", "unresolved", "rejected"])
147
+ })).default([])
136
148
  });
137
149
  var RawMapsOverviewSchema = import_zod.z.object({
138
150
  name: import_zod.z.string().nullable(),
@@ -373,6 +385,9 @@ var RequestAbortedError = class extends Error {
373
385
  super(message);
374
386
  }
375
387
  };
388
+ var PaaInteractionInterruptedError = class extends Error {
389
+ name = "PaaInteractionInterruptedError";
390
+ };
376
391
  var LocationMismatchError = class extends Error {
377
392
  name = "LocationMismatchError";
378
393
  constructor(message = "Google returned results for a different location than requested") {
@@ -2254,16 +2269,15 @@ async function extractAISurfacesFromDocument(config) {
2254
2269
  if (textOf(heading) === "AI Overview") return true;
2255
2270
  const header = el.querySelector(selectors.aio.header);
2256
2271
  if (textOf(header).split(/\n|\s{2,}/).some((part) => part.trim() === "AI Overview")) return true;
2257
- return textOf(el).includes("AI Overview");
2272
+ return false;
2258
2273
  }
2259
2274
  function findAIORoot() {
2260
2275
  const primaryRoots = Array.from(document.querySelectorAll(selectors.aio.root));
2261
2276
  const labeledPrimary = primaryRoots.find(hasAIOverviewLabel);
2262
2277
  if (labeledPrimary) return labeledPrimary;
2263
- if (primaryRoots.length > 0) return primaryRoots[0];
2264
2278
  if (selectors.aio.legacyRoot) {
2265
2279
  const legacy = document.querySelector(selectors.aio.legacyRoot);
2266
- if (legacy) return legacy;
2280
+ if (legacy && hasAIOverviewLabel(legacy)) return legacy;
2267
2281
  }
2268
2282
  const headings = document.querySelectorAll(`${selectors.aio.heading}, h1, h2, h3, [role="heading"]`);
2269
2283
  for (const h of headings) {
@@ -2342,26 +2356,12 @@ async function extractAISurfacesFromDocument(config) {
2342
2356
  if (!raw || /not available|try again|can't generate/i.test(raw)) return null;
2343
2357
  return raw;
2344
2358
  }
2345
- function normalizeHref(rawHref) {
2359
+ function absoluteHref(rawHref) {
2346
2360
  if (!rawHref || rawHref.startsWith("javascript:")) return null;
2347
- let href = rawHref;
2348
2361
  try {
2349
2362
  const absolute = new URL(rawHref, window.location.href);
2350
- const q = absolute.searchParams.get("q") ?? absolute.searchParams.get("url");
2351
- if (/(\.|^)google\./i.test(absolute.hostname) && q?.startsWith("http")) {
2352
- href = q;
2353
- } else {
2354
- href = absolute.href;
2355
- }
2356
- } catch {
2357
- return null;
2358
- }
2359
- if (!/^https?:\/\//i.test(href)) return null;
2360
- try {
2361
- const url = new URL(href);
2362
- const isGoogleInternal = /(\.|^)google\./i.test(url.hostname);
2363
- if (isGoogleInternal) return null;
2364
- return url.href;
2363
+ if (!["http:", "https:"].includes(absolute.protocol) || absolute.username || absolute.password) return null;
2364
+ return { href: absolute.href, rawHref: absolute.href };
2365
2365
  } catch {
2366
2366
  return null;
2367
2367
  }
@@ -2371,17 +2371,18 @@ async function extractAISurfacesFromDocument(config) {
2371
2371
  const seen = /* @__PURE__ */ new Set();
2372
2372
  const citations = [];
2373
2373
  for (const a of Array.from(root.querySelectorAll("a[href]"))) {
2374
- const href = normalizeHref(a.getAttribute("href") ?? "");
2375
- if (!href || seen.has(href)) continue;
2376
- seen.add(href);
2374
+ const link = absoluteHref(a.getAttribute("href") ?? "");
2375
+ if (!link || seen.has(link.rawHref)) continue;
2376
+ seen.add(link.rawHref);
2377
2377
  let fallbackHost = "";
2378
2378
  try {
2379
- fallbackHost = new URL(href).hostname.replace(/^www\./, "");
2379
+ fallbackHost = new URL(link.href).hostname.replace(/^www\./, "");
2380
2380
  } catch {
2381
2381
  }
2382
2382
  citations.push({
2383
- text: textOf(a) || fallbackHost || href,
2384
- href
2383
+ text: textOf(a) || fallbackHost || link.href,
2384
+ href: link.href,
2385
+ rawHref: link.rawHref
2385
2386
  });
2386
2387
  }
2387
2388
  return citations;
@@ -2436,6 +2437,286 @@ async function extractAISurfacesFromDocument(config) {
2436
2437
  };
2437
2438
  }
2438
2439
 
2440
+ // src/extractor/google-outbound-links.ts
2441
+ var import_node_crypto3 = require("crypto");
2442
+ var MAX_LINK_LENGTH = 8e3;
2443
+ var DEFAULT_TIMEOUT_MS = 3e3;
2444
+ var DEFAULT_TOTAL_BUDGET_MS = 1e4;
2445
+ var DEFAULT_HTTP_CONCURRENCY = 8;
2446
+ function isGoogleHost(hostname) {
2447
+ return /(^|\.)google\.(?:com|[a-z]{2,3}(?:\.[a-z]{2})?)$/i.test(hostname);
2448
+ }
2449
+ function safeHttpUrl(value) {
2450
+ if (!value || value.length > MAX_LINK_LENGTH) return null;
2451
+ try {
2452
+ const url = new URL(value);
2453
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
2454
+ if (url.username || url.password) return null;
2455
+ return url;
2456
+ } catch {
2457
+ return null;
2458
+ }
2459
+ }
2460
+ function absoluteHttpUrl(value, baseUrl) {
2461
+ if (!value || value.length > MAX_LINK_LENGTH || /^javascript:/i.test(value)) return null;
2462
+ try {
2463
+ const absolute = new URL(value, baseUrl);
2464
+ return safeHttpUrl(absolute.href);
2465
+ } catch {
2466
+ return null;
2467
+ }
2468
+ }
2469
+ function parsedGoogleGoto(value) {
2470
+ const url = safeHttpUrl(value);
2471
+ if (!url || url.protocol !== "https:" || !isGoogleHost(url.hostname) || url.pathname !== "/goto") return null;
2472
+ return url.searchParams.get("url") ? url : null;
2473
+ }
2474
+ function questionIdFor(question) {
2475
+ const normalized = question.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
2476
+ return `paa_${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex").slice(0, 16)}`;
2477
+ }
2478
+ function classifyGoogleOutboundLink(rawHref, baseUrl) {
2479
+ const absolute = absoluteHttpUrl(rawHref, baseUrl);
2480
+ if (!absolute) return null;
2481
+ const rawUrl = absolute.href;
2482
+ if (!isGoogleHost(absolute.hostname)) {
2483
+ return {
2484
+ url: rawUrl,
2485
+ rawUrl,
2486
+ resolvedUrl: rawUrl,
2487
+ linkType: "plain",
2488
+ resolutionStatus: "not_needed"
2489
+ };
2490
+ }
2491
+ if (absolute.pathname === "/url") {
2492
+ const target = safeHttpUrl(absolute.searchParams.get("q") ?? absolute.searchParams.get("url") ?? "");
2493
+ if (!target || isGoogleHost(target.hostname)) return null;
2494
+ return {
2495
+ url: target.href,
2496
+ rawUrl,
2497
+ resolvedUrl: target.href,
2498
+ linkType: "google_url_redirect",
2499
+ resolutionStatus: "resolved"
2500
+ };
2501
+ }
2502
+ if (absolute.pathname === "/goto" && absolute.searchParams.get("url")) {
2503
+ return {
2504
+ url: rawUrl,
2505
+ rawUrl,
2506
+ resolvedUrl: null,
2507
+ linkType: "google_goto_redirect",
2508
+ resolutionStatus: "unresolved"
2509
+ };
2510
+ }
2511
+ return null;
2512
+ }
2513
+ function tokenFor(value) {
2514
+ return parsedGoogleGoto(value)?.searchParams.get("url") ?? null;
2515
+ }
2516
+ function externalRedirectTarget(source, location) {
2517
+ try {
2518
+ const sourceUrl = new URL(source);
2519
+ const target = safeHttpUrl(new URL(location, sourceUrl).href);
2520
+ if (!target || isGoogleHost(target.hostname)) return null;
2521
+ if (!target.hash && sourceUrl.hash) target.hash = sourceUrl.hash;
2522
+ return target.href;
2523
+ } catch {
2524
+ return null;
2525
+ }
2526
+ }
2527
+ async function resolveGoogleGotoUrls(rawUrls, options = {}) {
2528
+ const inputs = [...new Set([...rawUrls].flatMap((value) => parsedGoogleGoto(value) ? [value] : []))];
2529
+ const records = new Map(inputs.map((rawUrl) => [rawUrl, {
2530
+ rawUrl,
2531
+ resolvedUrl: null,
2532
+ resolutionStatus: "unresolved"
2533
+ }]));
2534
+ const fallbackFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null;
2535
+ const fetchImpl = options.fetchImpl ?? fallbackFetch;
2536
+ if (inputs.length === 0 || !fetchImpl) {
2537
+ return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
2538
+ }
2539
+ const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
2540
+ const deadlineMs = Date.now() + Math.max(1, options.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS);
2541
+ const concurrency = Math.max(1, Math.min(16, Math.floor(options.concurrency ?? DEFAULT_HTTP_CONCURRENCY)));
2542
+ let cursor = 0;
2543
+ const worker = async () => {
2544
+ while (cursor < inputs.length) {
2545
+ const rawUrl = inputs[cursor++];
2546
+ const source = parsedGoogleGoto(rawUrl);
2547
+ const expectedToken = source?.searchParams.get("url");
2548
+ if (!source || !expectedToken) {
2549
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2550
+ continue;
2551
+ }
2552
+ source.hash = "";
2553
+ let currentUrl = source.href;
2554
+ for (let hop = 0; hop < 2; hop++) {
2555
+ const remainingMs = deadlineMs - Date.now();
2556
+ if (remainingMs <= 0) return;
2557
+ const controller = new AbortController();
2558
+ const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, remainingMs));
2559
+ let response;
2560
+ try {
2561
+ response = await fetchImpl(currentUrl, {
2562
+ method: "GET",
2563
+ redirect: "manual",
2564
+ headers: {
2565
+ accept: "*/*",
2566
+ "user-agent": "Mozilla/5.0"
2567
+ },
2568
+ signal: controller.signal
2569
+ });
2570
+ } catch {
2571
+ break;
2572
+ } finally {
2573
+ clearTimeout(timer);
2574
+ }
2575
+ if (response.status < 300 || response.status >= 400) break;
2576
+ const location = response.headers.get("location");
2577
+ if (!location) break;
2578
+ const destination = externalRedirectTarget(rawUrl, location);
2579
+ if (destination) {
2580
+ records.set(rawUrl, { rawUrl, resolvedUrl: destination, resolutionStatus: "resolved" });
2581
+ break;
2582
+ }
2583
+ let next;
2584
+ try {
2585
+ next = new URL(location, currentUrl);
2586
+ } catch {
2587
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2588
+ break;
2589
+ }
2590
+ if (!isGoogleHost(next.hostname) || tokenFor(next.href) !== expectedToken) {
2591
+ records.set(rawUrl, { rawUrl, resolvedUrl: null, resolutionStatus: "rejected" });
2592
+ break;
2593
+ }
2594
+ currentUrl = next.href;
2595
+ }
2596
+ }
2597
+ };
2598
+ await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker()));
2599
+ return { resolutions: [...records.values()], nonGoogleRequestsObserved: 0 };
2600
+ }
2601
+ async function resolveGoogleOutboundLinks(page, values, options = {}) {
2602
+ const byRawUrl = /* @__PURE__ */ new Map();
2603
+ for (const value of values) byRawUrl.set(value.rawUrl, value);
2604
+ const inputs = [...byRawUrl.values()].filter((value) => value.linkType === "google_goto_redirect");
2605
+ if (inputs.length === 0) return byRawUrl;
2606
+ const deadlineMs = Date.now() + (options.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS);
2607
+ const direct = await resolveGoogleGotoUrls(inputs.map((value) => value.rawUrl), options);
2608
+ for (const record of direct.resolutions) {
2609
+ const input = byRawUrl.get(record.rawUrl);
2610
+ if (!input) continue;
2611
+ byRawUrl.set(record.rawUrl, record.resolutionStatus === "resolved" && record.resolvedUrl ? { ...input, url: record.resolvedUrl, resolvedUrl: record.resolvedUrl, resolutionStatus: "resolved" } : { ...input, resolutionStatus: record.resolutionStatus });
2612
+ }
2613
+ const unresolvedInputs = inputs.filter((input) => byRawUrl.get(input.rawUrl)?.resolutionStatus === "unresolved");
2614
+ if (unresolvedInputs.length === 0 || options.browserFallback === false || Date.now() >= deadlineMs) {
2615
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved: 0 });
2616
+ return byRawUrl;
2617
+ }
2618
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2619
+ const resolverPage = await page.context().newPage().catch(() => null);
2620
+ if (!resolverPage) {
2621
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved: 0 });
2622
+ return byRawUrl;
2623
+ }
2624
+ let active = null;
2625
+ let nonGoogleRequestsObserved = 0;
2626
+ try {
2627
+ await resolverPage.route("**/*", async (route) => {
2628
+ const requestUrl = route.request().url();
2629
+ try {
2630
+ if (!isGoogleHost(new URL(requestUrl).hostname)) nonGoogleRequestsObserved += 1;
2631
+ } catch {
2632
+ }
2633
+ const requestToken = tokenFor(requestUrl);
2634
+ if (!active || requestToken !== active.token) {
2635
+ await route.abort("blockedbyclient").catch(() => {
2636
+ });
2637
+ return;
2638
+ }
2639
+ const target = active;
2640
+ try {
2641
+ const response = await route.fetch({ maxRedirects: 0, timeout: target.timeoutMs });
2642
+ target.location = response.headers().location ?? null;
2643
+ } finally {
2644
+ await route.abort("blockedbyclient").catch(() => {
2645
+ });
2646
+ target.finish();
2647
+ }
2648
+ });
2649
+ for (const input of unresolvedInputs) {
2650
+ const remainingMs = deadlineMs - Date.now();
2651
+ if (remainingMs <= 0) break;
2652
+ const source = parsedGoogleGoto(input.rawUrl);
2653
+ const expectedToken = source?.searchParams.get("url");
2654
+ if (!source || !expectedToken) {
2655
+ byRawUrl.set(input.rawUrl, { ...input, resolutionStatus: "rejected" });
2656
+ continue;
2657
+ }
2658
+ const attemptTimeoutMs = Math.min(timeoutMs, remainingMs);
2659
+ source.hash = "";
2660
+ let currentUrl = source.href;
2661
+ let rejected = false;
2662
+ for (let hop = 0; hop < 2; hop++) {
2663
+ let finish;
2664
+ const intercepted = new Promise((resolve) => {
2665
+ finish = resolve;
2666
+ });
2667
+ const frameId = `mcp-goto-${Math.random().toString(36).slice(2)}`;
2668
+ active = { token: expectedToken, timeoutMs: attemptTimeoutMs, location: null, finish };
2669
+ await resolverPage.evaluate(({ url, id }) => {
2670
+ const frame = document.createElement("iframe");
2671
+ frame.id = id;
2672
+ frame.hidden = true;
2673
+ frame.src = url;
2674
+ document.body.append(frame);
2675
+ }, { url: currentUrl, id: frameId }).catch(() => null);
2676
+ await Promise.race([
2677
+ intercepted,
2678
+ new Promise((resolve) => setTimeout(resolve, attemptTimeoutMs))
2679
+ ]);
2680
+ const location = active.location;
2681
+ active = null;
2682
+ await resolverPage.evaluate((id) => document.getElementById(id)?.remove(), frameId).catch(() => null);
2683
+ if (!location) break;
2684
+ const destination = externalRedirectTarget(input.rawUrl, location);
2685
+ if (destination) {
2686
+ byRawUrl.set(input.rawUrl, {
2687
+ ...input,
2688
+ url: destination,
2689
+ resolvedUrl: destination,
2690
+ resolutionStatus: "resolved"
2691
+ });
2692
+ break;
2693
+ }
2694
+ let next;
2695
+ try {
2696
+ next = new URL(location, currentUrl);
2697
+ } catch {
2698
+ rejected = true;
2699
+ break;
2700
+ }
2701
+ if (!isGoogleHost(next.hostname) || tokenFor(next.href) !== expectedToken) {
2702
+ rejected = true;
2703
+ break;
2704
+ }
2705
+ currentUrl = next.href;
2706
+ }
2707
+ if (rejected && byRawUrl.get(input.rawUrl)?.resolutionStatus !== "resolved") {
2708
+ byRawUrl.set(input.rawUrl, { ...input, resolutionStatus: "rejected" });
2709
+ }
2710
+ }
2711
+ } finally {
2712
+ active = null;
2713
+ await resolverPage.close().catch(() => {
2714
+ });
2715
+ options.onNetworkAudit?.({ nonGoogleRequestsObserved });
2716
+ }
2717
+ return byRawUrl;
2718
+ }
2719
+
2439
2720
  // src/extractor/PAAExtractor.ts
2440
2721
  var PAAExtractor = class {
2441
2722
  constructor(driver, reporter, onPaaProgress) {
@@ -2453,10 +2734,118 @@ var PAAExtractor = class {
2453
2734
  aioShareCaptured: null
2454
2735
  };
2455
2736
  aioShareUrlEarly = null;
2737
+ linkDiagnostics = this.emptyLinkDiagnostics();
2456
2738
  collectedRows = [];
2457
2739
  partialContext = null;
2458
2740
  progressSequence = 0;
2459
2741
  clickedQuestions = /* @__PURE__ */ new Set();
2742
+ paaLifecycle = this.emptyPaaLifecycle(0);
2743
+ checkpointMaterial = null;
2744
+ emptyPaaLifecycle(requestedQuestions) {
2745
+ return {
2746
+ requestedQuestions,
2747
+ questionsFound: 0,
2748
+ targetStatus: "not_met",
2749
+ discoveryStatus: "in_progress",
2750
+ materialStatus: "partial",
2751
+ completionReason: "PAA expansion is still running.",
2752
+ automaticRetries: 0,
2753
+ knownQuestions: 0,
2754
+ processedQuestions: 0,
2755
+ failedInteractions: 0,
2756
+ noGrowthConfirmations: 0
2757
+ };
2758
+ }
2759
+ emptyLinkDiagnostics() {
2760
+ return {
2761
+ linkCandidatesObserved: 0,
2762
+ plainLinksObserved: 0,
2763
+ googleUrlRedirectsObserved: 0,
2764
+ googleGotoRedirectsObserved: 0,
2765
+ redirectsResolved: 0,
2766
+ redirectsUnresolved: 0,
2767
+ redirectsRejected: 0,
2768
+ resolverNonGoogleRequestsObserved: 0,
2769
+ paaSourcesPreserved: 0,
2770
+ aiOverviewLabelValidated: false
2771
+ };
2772
+ }
2773
+ mergePaaSources(existing = [], incoming = []) {
2774
+ const byRawUrl = /* @__PURE__ */ new Map();
2775
+ for (const source of [...existing, ...incoming]) {
2776
+ const current = byRawUrl.get(source.rawUrl);
2777
+ if (!current) {
2778
+ byRawUrl.set(source.rawUrl, source);
2779
+ continue;
2780
+ }
2781
+ const selected = source.resolutionStatus === "resolved" && current.resolutionStatus !== "resolved" ? source : current;
2782
+ byRawUrl.set(source.rawUrl, { ...selected, title: selected.title ?? current.title ?? source.title, site: selected.site ?? current.site ?? source.site });
2783
+ }
2784
+ return [...byRawUrl.values()];
2785
+ }
2786
+ normalizeAICitations(pageUrl, citations) {
2787
+ const seen = /* @__PURE__ */ new Set();
2788
+ return citations.flatMap((citation) => {
2789
+ const link = classifyGoogleOutboundLink(citation.rawHref ?? citation.href, pageUrl);
2790
+ if (!link || seen.has(link.rawUrl)) return [];
2791
+ seen.add(link.rawUrl);
2792
+ const { url, ...metadata } = link;
2793
+ return [{ ...metadata, text: citation.text, href: url }];
2794
+ });
2795
+ }
2796
+ async resolveMaterialLinks(page, flat, organicResults, aiOverview, aiMode) {
2797
+ const candidates = [
2798
+ ...flat.flatMap((row) => row.sources ?? []),
2799
+ ...organicResults,
2800
+ ...aiOverview.citations.map(({ text: _text, href, ...link }) => ({ ...link, url: href })),
2801
+ ...aiMode.citations.map(({ text: _text, href, ...link }) => ({ ...link, url: href }))
2802
+ ];
2803
+ const unique = new Map(candidates.map((link) => [link.rawUrl, link]));
2804
+ let resolverNonGoogleRequestsObserved = 0;
2805
+ const resolved = await resolveGoogleOutboundLinks(page, unique.values(), {
2806
+ onNetworkAudit: (audit) => {
2807
+ resolverNonGoogleRequestsObserved = audit.nonGoogleRequestsObserved;
2808
+ }
2809
+ });
2810
+ const replace = (link) => ({ ...link, ...resolved.get(link.rawUrl) ?? link });
2811
+ for (const row of flat) {
2812
+ row.sources = (row.sources ?? []).map(replace);
2813
+ const primary = row.sources[0];
2814
+ row.source_title = primary?.title ?? row.source_title;
2815
+ row.source_site = primary?.site ?? row.source_site;
2816
+ row.source_cite = primary?.url ?? row.source_cite;
2817
+ }
2818
+ for (let index = 0; index < organicResults.length; index++) {
2819
+ const next = replace(organicResults[index]);
2820
+ if (next.resolvedUrl) {
2821
+ try {
2822
+ next.domain = new URL(next.resolvedUrl).hostname.replace(/^www\./, "");
2823
+ } catch {
2824
+ }
2825
+ }
2826
+ organicResults[index] = next;
2827
+ }
2828
+ const replaceCitation = (citation) => {
2829
+ const link = replace({ url: citation.href, rawUrl: citation.rawUrl, resolvedUrl: citation.resolvedUrl, linkType: citation.linkType, resolutionStatus: citation.resolutionStatus });
2830
+ const { url, ...metadata } = link;
2831
+ return { ...metadata, text: citation.text, href: url };
2832
+ };
2833
+ aiOverview.citations = aiOverview.citations.map(replaceCitation);
2834
+ aiMode.citations = aiMode.citations.map(replaceCitation);
2835
+ const values = [...resolved.values()];
2836
+ this.linkDiagnostics = {
2837
+ linkCandidatesObserved: unique.size,
2838
+ plainLinksObserved: values.filter((link) => link.linkType === "plain").length,
2839
+ googleUrlRedirectsObserved: values.filter((link) => link.linkType === "google_url_redirect").length,
2840
+ googleGotoRedirectsObserved: values.filter((link) => link.linkType === "google_goto_redirect").length,
2841
+ redirectsResolved: values.filter((link) => link.linkType !== "plain" && link.resolutionStatus === "resolved").length,
2842
+ redirectsUnresolved: values.filter((link) => link.resolutionStatus === "unresolved").length,
2843
+ redirectsRejected: values.filter((link) => link.resolutionStatus === "rejected").length,
2844
+ resolverNonGoogleRequestsObserved,
2845
+ paaSourcesPreserved: flat.reduce((total, row) => total + (row.sources ?? []).length, 0),
2846
+ aiOverviewLabelValidated: aiOverview.detected
2847
+ };
2848
+ }
2460
2849
  normalizeQuestion(q) {
2461
2850
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
2462
2851
  }
@@ -2493,34 +2882,6 @@ var PAAExtractor = class {
2493
2882
  function cleanSourceLabel(value) {
2494
2883
  return (value ?? "").replace(/\s*\(\+\d+\)\s*-\s*View related links.*$/i, "").replace(/\s*-\s*Opens in new tab.*$/i, "").trim();
2495
2884
  }
2496
- function isGoogleHost(host) {
2497
- const normalized = host.toLowerCase().replace(/\.$/, "");
2498
- return normalized === "google.com" || normalized.endsWith(".google.com") || /^google\.[a-z.]+$/.test(normalized) || /\.google\.[a-z.]+$/.test(normalized) || normalized === "googleusercontent.com" || normalized.endsWith(".googleusercontent.com");
2499
- }
2500
- function safeCitationTarget(rawHref) {
2501
- let parsed;
2502
- try {
2503
- parsed = new URL(rawHref);
2504
- } catch {
2505
- return null;
2506
- }
2507
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
2508
- if (parsed.username || parsed.password) return null;
2509
- if (isGoogleHost(parsed.hostname)) {
2510
- if (parsed.pathname !== "/url") return null;
2511
- const redirected = parsed.searchParams.get("q") ?? parsed.searchParams.get("url");
2512
- if (!redirected) return null;
2513
- try {
2514
- parsed = new URL(redirected);
2515
- } catch {
2516
- return null;
2517
- }
2518
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
2519
- if (parsed.username || parsed.password) return null;
2520
- }
2521
- if (isGoogleHost(parsed.hostname)) return null;
2522
- return { href: parsed.href, host: parsed.hostname };
2523
- }
2524
2885
  return Array.from(document.querySelectorAll(selectors.item)).filter((pair) => !onlyQuestion2 || (pair.getAttribute(selectors.itemDataQ) || pair.getAttribute(selectors.itemDataInitQ) || pair.querySelector(selectors.itemQuestionEl)?.innerText?.trim() || "") === onlyQuestion2).map((pair) => {
2525
2886
  const clickTarget = pair.querySelector(selectors.clickTarget);
2526
2887
  const expanded = pair.classList.contains(selectors.expandedClass) || clickTarget?.getAttribute("aria-expanded") === "true";
@@ -2528,50 +2889,62 @@ var PAAExtractor = class {
2528
2889
  const aiAnswerBody = answerContainer?.querySelector(selectors.aiAnswerBody);
2529
2890
  const answerElement = aiAnswerBody ?? answerContainer;
2530
2891
  const answerText = cleanText(answerElement);
2531
- const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80));
2892
+ const citationCandidates = [
2893
+ ...Array.from((answerElement ?? pair).querySelectorAll("a[href]")),
2894
+ ...Array.from(pair.querySelectorAll(selectors.aiCitation)),
2895
+ ...Array.from(pair.querySelectorAll(selectors.aiSourceCard))
2896
+ ];
2897
+ const hasLoadedAnswer = expanded || answerText.length > 30 && !/an error has occurred/i.test(answerText.slice(0, 80)) && citationCandidates.some((anchor) => /^https?:\/\//.test(anchor.href));
2532
2898
  let sourceTitle = hasLoadedAnswer ? pair.querySelector(selectors.sourceTitle)?.innerText?.trim() || void 0 : void 0;
2533
2899
  let sourceSite = hasLoadedAnswer ? pair.querySelector(selectors.sourceSite)?.innerText?.trim() || void 0 : void 0;
2534
- let sourceCite = hasLoadedAnswer ? pair.querySelector(selectors.sourceCite)?.innerText?.trim() || void 0 : void 0;
2535
- if (hasLoadedAnswer && !sourceCite) {
2900
+ const sources = [];
2901
+ if (hasLoadedAnswer) {
2536
2902
  const seenHrefs = /* @__PURE__ */ new Set();
2537
- const anchors = [];
2538
- const anchorRoot = answerElement ?? pair;
2539
- const prioritized = [
2540
- ...Array.from(anchorRoot.querySelectorAll(selectors.aiCitation)),
2541
- ...Array.from(anchorRoot.querySelectorAll(selectors.aiSourceCard)),
2542
- ...Array.from(anchorRoot.querySelectorAll("a[href]"))
2543
- ];
2544
- for (const a of prioritized) {
2545
- const target = safeCitationTarget(a.href);
2546
- if (!target || seenHrefs.has(target.href)) continue;
2547
- seenHrefs.add(target.href);
2548
- anchors.push({
2549
- text: (a.textContent || "").trim(),
2550
- label: cleanSourceLabel(a.getAttribute("aria-label")),
2551
- href: target.href,
2552
- host: target.host
2553
- });
2554
- }
2555
- const primary = anchors.find((x) => x.label.length > 2 || x.text.length > 2) ?? anchors[0];
2556
- if (primary) {
2557
- sourceTitle = sourceTitle || primary.label || primary.text || void 0;
2558
- sourceSite = sourceSite || primary.host.replace(/^www\./, "");
2559
- sourceCite = primary.href;
2903
+ for (const a of citationCandidates) {
2904
+ if (!/^https?:\/\//.test(a.href) || seenHrefs.has(a.href)) continue;
2905
+ seenHrefs.add(a.href);
2906
+ sources.push({ title: cleanSourceLabel(a.getAttribute("aria-label")) || (a.textContent || "").trim(), rawHref: a.href });
2560
2907
  }
2908
+ const primary = sources.find((x) => x.title.length > 2) ?? sources[0];
2909
+ if (primary) sourceTitle = sourceTitle || primary.title || void 0;
2561
2910
  }
2562
2911
  return {
2912
+ googleLinkId: pair.getAttribute("data-lk") || pair.querySelector("[data-lk]")?.getAttribute("data-lk") || null,
2913
+ googleEvidenceId: pair.getAttribute("data-ved") || pair.querySelector("[data-ved]")?.getAttribute("data-ved") || null,
2563
2914
  question: pair.getAttribute(selectors.itemDataQ) || pair.getAttribute(selectors.itemDataInitQ) || pair.querySelector(selectors.itemQuestionEl)?.innerText?.trim() || "",
2564
2915
  answer: hasLoadedAnswer ? answerText || void 0 : void 0,
2565
2916
  sourceTitle,
2566
2917
  sourceSite,
2567
- sourceCite
2918
+ sources
2568
2919
  };
2569
2920
  });
2570
2921
  }, { selectors: sels, onlyQuestion });
2922
+ const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
2571
2923
  return raw.flatMap((item) => {
2924
+ const sources = [];
2925
+ const seen = /* @__PURE__ */ new Set();
2926
+ for (const candidate of item.sources) {
2927
+ const link = classifyGoogleOutboundLink(candidate.rawHref, baseUrl);
2928
+ if (!link || seen.has(link.rawUrl)) continue;
2929
+ seen.add(link.rawUrl);
2930
+ let site = item.sourceSite ?? null;
2931
+ try {
2932
+ site ||= new URL(link.resolvedUrl ?? link.rawUrl).hostname.replace(/^www\./, "");
2933
+ } catch {
2934
+ }
2935
+ sources.push({ ...link, title: candidate.title || item.sourceTitle || null, site });
2936
+ }
2937
+ const primary = sources[0];
2572
2938
  const cleaned = {
2573
- ...item,
2574
- answer: cleanPAAAnswerText(item.answer, item.question, item.sourceTitle)
2939
+ questionId: questionIdFor(item.question),
2940
+ googleLinkId: item.googleLinkId,
2941
+ googleEvidenceId: item.googleEvidenceId,
2942
+ question: item.question,
2943
+ answer: cleanPAAAnswerText(item.answer, item.question, item.sourceTitle),
2944
+ sourceTitle: primary?.title ?? item.sourceTitle,
2945
+ sourceSite: primary?.site ?? item.sourceSite,
2946
+ sourceCite: primary?.url,
2947
+ sources
2575
2948
  };
2576
2949
  const result = RawPAAItemSchema.safeParse(cleaned);
2577
2950
  if (!result.success) {
@@ -2596,11 +2969,15 @@ var PAAExtractor = class {
2596
2969
  toFlatRow(item, depth, parentQuestion, seed) {
2597
2970
  return {
2598
2971
  seed_query: seed,
2972
+ question_id: item.questionId ?? questionIdFor(item.question),
2973
+ google_link_id: item.googleLinkId ?? "",
2974
+ google_evidence_id: item.googleEvidenceId ?? "",
2599
2975
  question: item.question,
2600
2976
  answer: item.answer ?? "",
2601
2977
  source_title: item.sourceTitle ?? "",
2602
2978
  source_site: item.sourceSite ?? "",
2603
2979
  source_cite: item.sourceCite ?? "",
2980
+ sources: item.sources ?? [],
2604
2981
  depth,
2605
2982
  parent_question: parentQuestion ?? "",
2606
2983
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -2621,20 +2998,23 @@ var PAAExtractor = class {
2621
2998
  source_title: row.source_title || existing.source_title,
2622
2999
  source_site: row.source_site || existing.source_site,
2623
3000
  source_cite: row.source_cite || existing.source_cite,
3001
+ sources: this.mergePaaSources(existing.sources, row.sources),
2624
3002
  extracted_at: row.extracted_at || existing.extracted_at
2625
3003
  };
2626
- const changed = merged.answer !== existing.answer || merged.source_title !== existing.source_title || merged.source_site !== existing.source_site || merged.source_cite !== existing.source_cite;
3004
+ const changed = merged.answer !== existing.answer || merged.source_title !== existing.source_title || merged.source_site !== existing.source_site || merged.source_cite !== existing.source_cite || JSON.stringify(merged.sources) !== JSON.stringify(existing.sources);
2627
3005
  if (changed) this.collectedRows[index] = merged;
2628
3006
  return changed;
2629
3007
  }
2630
3008
  async emitProgress(phase) {
2631
- if (!this.onPaaProgress || this.collectedRows.length === 0) return;
3009
+ if (!this.onPaaProgress || this.collectedRows.length === 0 && !this.checkpointMaterial) return;
2632
3010
  const snapshot = {
2633
3011
  sequence: ++this.progressSequence,
2634
3012
  observedAt: (/* @__PURE__ */ new Date()).toISOString(),
2635
3013
  clickedQuestions: [...this.clickedQuestions],
2636
3014
  phase,
2637
- records: this.collectedRows.map((row) => ({ ...row }))
3015
+ records: this.collectedRows.map((row) => ({ ...row })),
3016
+ lifecycle: { ...this.paaLifecycle },
3017
+ ...this.checkpointMaterial ? { material: structuredClone(this.checkpointMaterial) } : {}
2638
3018
  };
2639
3019
  try {
2640
3020
  await this.onPaaProgress(snapshot);
@@ -2649,10 +3029,11 @@ var PAAExtractor = class {
2649
3029
  }
2650
3030
  getPartialResult() {
2651
3031
  const ctx = this.partialContext;
2652
- if (!ctx || this.collectedRows.length === 0) return null;
3032
+ if (!ctx || this.collectedRows.length === 0 && !this.checkpointMaterial) return null;
2653
3033
  const flat = this.collectedRows.map((row) => ({ ...row }));
3034
+ const material = this.checkpointMaterial;
2654
3035
  const paaWithoutAnswer = ctx.questionsOnly ? 0 : flat.filter((row) => !row.answer?.trim()).length;
2655
- const paaWithoutSource = ctx.questionsOnly ? 0 : flat.filter((row) => !row.source_cite?.trim()).length;
3036
+ const paaWithoutSource = ctx.questionsOnly ? 0 : flat.filter((row) => (row.sources ?? []).length === 0).length;
2656
3037
  const stats = {
2657
3038
  seed: ctx.seed,
2658
3039
  totalQuestions: flat.length,
@@ -2670,6 +3051,14 @@ var PAAExtractor = class {
2670
3051
  resultQuality: "partial",
2671
3052
  degradedResult: false,
2672
3053
  retryRecommended: true,
3054
+ paaLifecycle: {
3055
+ ...this.paaLifecycle,
3056
+ questionsFound: flat.length,
3057
+ targetStatus: flat.length >= (ctx.maxQuestions ?? 0) ? "met" : "not_met",
3058
+ discoveryStatus: "interrupted",
3059
+ materialStatus: paaWithoutAnswer > 0 || paaWithoutSource > 0 ? "partial" : "complete",
3060
+ completionReason: "The browser attempt ended before frontier exhaustion could be proven."
3061
+ },
2673
3062
  completeness: {
2674
3063
  ...this.completeness,
2675
3064
  paaWithoutAnswer,
@@ -2686,17 +3075,17 @@ var PAAExtractor = class {
2686
3075
  }]
2687
3076
  },
2688
3077
  totalQuestions: flat.length,
2689
- surface: "web",
2690
- aiOverview: { detected: false, text: null, citations: [] },
2691
- aiMode: { detected: false, text: null, citations: [] },
2692
- whatPeopleSaying: [],
3078
+ surface: material?.surface ?? "web",
3079
+ aiOverview: material?.aiOverview ?? { detected: false, text: null, citations: [] },
3080
+ aiMode: material?.aiMode ?? { detected: false, text: null, citations: [] },
3081
+ whatPeopleSaying: material?.whatPeopleSaying ?? [],
2693
3082
  tree: this.buildTree(flat, ctx.seed),
2694
3083
  flat,
2695
- videos: [],
2696
- forums: [],
2697
- organicResults: [],
2698
- localPack: [],
2699
- entityIds: { entities: [], kgIds: [], cids: [], gcids: [] },
3084
+ videos: material?.videos ?? [],
3085
+ forums: material?.forums ?? [],
3086
+ organicResults: material?.organicResults ?? [],
3087
+ localPack: material?.localPack ?? [],
3088
+ entityIds: material?.entityIds ?? { entities: [], kgIds: [], cids: [], gcids: [] },
2700
3089
  stats
2701
3090
  };
2702
3091
  }
@@ -2705,12 +3094,14 @@ var PAAExtractor = class {
2705
3094
  const seenKeys = /* @__PURE__ */ new Set();
2706
3095
  const seenQs = /* @__PURE__ */ new Set();
2707
3096
  const orderedQs = [];
2708
- this.collectedRows = [];
2709
- this.progressSequence = 0;
2710
3097
  this.clickedQuestions = /* @__PURE__ */ new Set();
2711
3098
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
2712
- const clickedOnceEver = /* @__PURE__ */ new Set();
3099
+ const clickAttempts = /* @__PURE__ */ new Map();
3100
+ const processedQuestions = /* @__PURE__ */ new Set();
3101
+ const failedInteractions = /* @__PURE__ */ new Set();
2713
3102
  const capturedItems = /* @__PURE__ */ new Map();
3103
+ const MAX_CLICK_ATTEMPTS = 2;
3104
+ const EXHAUSTION_CONFIRMATIONS = 3;
2714
3105
  const itemArgs = {
2715
3106
  sel: PAASelectors.item,
2716
3107
  dataQ: PAASelectors.itemDataQ,
@@ -2721,15 +3112,15 @@ var PAAExtractor = class {
2721
3112
  clickTarget: PAASelectors.clickTarget
2722
3113
  };
2723
3114
  const readItemStates = () => page.evaluate(
2724
- ({ sel, dataQ, dataInitQ, questionEl, expandedClass }) => {
3115
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, clickTarget, answerSel }) => {
2725
3116
  return Array.from(document.querySelectorAll(sel)).map((el) => ({
2726
3117
  q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
2727
- done: el.classList.contains(expandedClass)
3118
+ done: el.classList.contains(expandedClass) || el.querySelector(clickTarget)?.getAttribute("aria-expanded") === "true" || Boolean((el.querySelector(answerSel)?.textContent ?? "").trim())
2728
3119
  })).filter((s) => s.q);
2729
3120
  },
2730
3121
  itemArgs
2731
3122
  );
2732
- const expandOneItemSerially = async (q) => {
3123
+ const expandOneItemSerially = async (q, beforeCount) => {
2733
3124
  const clickStartedMs = Date.now();
2734
3125
  const items = page.locator(itemArgs.sel);
2735
3126
  const itemIndex = await items.evaluateAll(
@@ -2744,49 +3135,118 @@ var PAAExtractor = class {
2744
3135
  await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
2745
3136
  return true;
2746
3137
  })().catch(() => false) : false;
2747
- if (!clicked) return "failed";
3138
+ if (!clicked) return { dispatched: false, confirmed: false };
2748
3139
  const remainingHumanClickDelayMs = 700 - (Date.now() - clickStartedMs);
2749
3140
  if (remainingHumanClickDelayMs > 0) await page.waitForTimeout(remainingHumanClickDelayMs);
2750
- return "ok";
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) };
2751
3152
  };
2752
3153
  let round = 0;
2753
- let growthWaits = 0;
2754
- while (true) {
2755
- if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
3154
+ expansion: while (true) {
3155
+ if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) {
3156
+ this.paaLifecycle = {
3157
+ ...this.paaLifecycle,
3158
+ discoveryStatus: "interrupted",
3159
+ completionReason: "The execution deadline arrived before frontier exhaustion could be proven."
3160
+ };
3161
+ throw new PaaInteractionInterruptedError(this.paaLifecycle.completionReason);
3162
+ }
2756
3163
  this.throwIfAborted(signal);
2757
3164
  const states = await readItemStates();
2758
3165
  let discovered = false;
2759
- for (const s of states) {
2760
- if (!seenQs.has(s.q)) {
2761
- seenQs.add(s.q);
2762
- orderedQs.push(s.q);
3166
+ for (const state of states) {
3167
+ if (!seenQs.has(state.q)) {
3168
+ seenQs.add(state.q);
3169
+ orderedQs.push(state.q);
2763
3170
  if (orderedQs.length <= options.maxQuestions) {
2764
- discovered = this.upsertCollectedRow(this.toFlatRow({ question: s.q }, 1, null, options.query)) || discovered;
3171
+ discovered = this.upsertCollectedRow(this.toFlatRow({ questionId: questionIdFor(state.q), googleLinkId: null, googleEvidenceId: null, question: state.q, sources: [] }, 1, null, options.query)) || discovered;
2765
3172
  }
2766
3173
  }
3174
+ if (state.done) processedQuestions.add(state.q);
2767
3175
  }
3176
+ this.paaLifecycle = {
3177
+ ...this.paaLifecycle,
3178
+ questionsFound: Math.min(orderedQs.length, options.maxQuestions),
3179
+ targetStatus: orderedQs.length >= options.maxQuestions ? "met" : "not_met",
3180
+ knownQuestions: orderedQs.length,
3181
+ processedQuestions: processedQuestions.size,
3182
+ failedInteractions: failedInteractions.size
3183
+ };
2768
3184
  if (discovered) await this.emitProgress("discovered");
2769
- if (options.questionsOnly && seenQs.size >= options.maxQuestions) break;
3185
+ if (options.questionsOnly && seenQs.size >= options.maxQuestions) {
3186
+ this.paaLifecycle = {
3187
+ ...this.paaLifecycle,
3188
+ discoveryStatus: "target_reached",
3189
+ targetStatus: "met",
3190
+ completionReason: "The requested unique-question count was observed."
3191
+ };
3192
+ break;
3193
+ }
2770
3194
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2771
- const clickable = states.filter((s) => kept.has(s.q) && !clickedOnceEver.has(s.q) && !abandonedNeverReclick.has(s.q));
2772
- const target = clickable.find((s) => !s.done) ?? (seenQs.size < options.maxQuestions ? clickable[0] : void 0);
3195
+ 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];
2773
3197
  if (!target) {
2774
- if (seenQs.size >= options.maxQuestions) break;
2775
- if (++growthWaits > 1) break;
2776
- const grew = await page.waitForFunction(
2777
- ({ sel, min }) => document.querySelectorAll(sel).length > min,
2778
- { sel: PAASelectors.item, min: states.length },
2779
- { timeout: 2e3, polling: 100 }
2780
- ).catch(() => null);
2781
- if (!grew) continue;
2782
- continue;
3198
+ if (seenQs.size >= options.maxQuestions) {
3199
+ this.paaLifecycle = {
3200
+ ...this.paaLifecycle,
3201
+ discoveryStatus: "target_reached",
3202
+ targetStatus: "met",
3203
+ completionReason: "The requested unique-question count was observed."
3204
+ };
3205
+ break;
3206
+ }
3207
+ const visible = new Set(states.map((state) => state.q));
3208
+ const unresolved2 = orderedQs.slice(0, options.maxQuestions).filter(
3209
+ (question) => !processedQuestions.has(question) && (failedInteractions.has(question) || !visible.has(question))
3210
+ );
3211
+ if (unresolved2.length > 0) {
3212
+ this.paaLifecycle = {
3213
+ ...this.paaLifecycle,
3214
+ discoveryStatus: "interrupted",
3215
+ failedInteractions: (/* @__PURE__ */ new Set([...failedInteractions, ...unresolved2])).size,
3216
+ completionReason: "At least one known PAA control failed or disappeared before it could be confirmed."
3217
+ };
3218
+ await this.emitProgress("expansion_finished");
3219
+ throw new PaaInteractionInterruptedError(this.paaLifecycle.completionReason);
3220
+ }
3221
+ for (let confirmation = 1; confirmation <= EXHAUSTION_CONFIRMATIONS; confirmation++) {
3222
+ this.throwIfAborted(signal);
3223
+ await this.throwIfCaptcha(page, "Google PAA exhaustion confirmation");
3224
+ await page.mouse.wheel(0, 320);
3225
+ const grew = await page.waitForFunction(
3226
+ ({ sel, min }) => document.querySelectorAll(sel).length > min,
3227
+ { sel: PAASelectors.item, min: states.length },
3228
+ { timeout: 1500, polling: 100 }
3229
+ ).catch(() => null);
3230
+ this.paaLifecycle = { ...this.paaLifecycle, noGrowthConfirmations: confirmation };
3231
+ if (grew) continue expansion;
3232
+ const confirmationStates = await readItemStates();
3233
+ if (confirmationStates.some((state) => !seenQs.has(state.q))) continue expansion;
3234
+ }
3235
+ this.paaLifecycle = {
3236
+ ...this.paaLifecycle,
3237
+ discoveryStatus: "frontier_exhausted",
3238
+ targetStatus: "not_met",
3239
+ failedInteractions: 0,
3240
+ noGrowthConfirmations: EXHAUSTION_CONFIRMATIONS,
3241
+ completionReason: "Every known eligible PAA control was processed and three healthy no-growth confirmations found no additional questions."
3242
+ };
3243
+ break;
2783
3244
  }
2784
- growthWaits = 0;
2785
3245
  this.reporter.onDepth(++round);
2786
3246
  await this.throwIfCaptcha(page, "Google PAA expansion");
2787
- clickedOnceEver.add(target.q);
2788
- this.clickedQuestions.add(target.q);
2789
- const expansionStatus = await expandOneItemSerially(target.q);
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);
2790
3250
  if (!options.questionsOnly) {
2791
3251
  let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2792
3252
  if (!clickedItem?.answer) {
@@ -2800,27 +3260,21 @@ var PAAExtractor = class {
2800
3260
  }
2801
3261
  }
2802
3262
  }
2803
- if (expansionStatus === "failed" && !capturedItems.has(target.q)) {
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);
2804
3269
  abandonedNeverReclick.add(target.q);
2805
3270
  }
2806
- }
2807
- if (options.questionsOnly && seenQs.size < options.maxQuestions) {
2808
- for (let recoveryRound = 0; recoveryRound < options.questionGrowthRecoveryRounds && seenQs.size < options.maxQuestions; recoveryRound++) {
2809
- if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2810
- const reExpanded = await this.reExpandCollapsedItems(page).catch(() => false);
2811
- if (!reExpanded) break;
2812
- await page.waitForTimeout(900);
2813
- for (const state of await readItemStates()) {
2814
- if (!seenQs.has(state.q)) {
2815
- seenQs.add(state.q);
2816
- orderedQs.push(state.q);
2817
- if (orderedQs.length <= options.maxQuestions) {
2818
- this.upsertCollectedRow(this.toFlatRow({ question: state.q }, 1, null, options.query));
2819
- }
2820
- }
2821
- }
2822
- await this.emitProgress("discovered");
2823
- }
3271
+ this.paaLifecycle = {
3272
+ ...this.paaLifecycle,
3273
+ processedQuestions: processedQuestions.size,
3274
+ failedInteractions: failedInteractions.size,
3275
+ noGrowthConfirmations: 0
3276
+ };
3277
+ await this.emitProgress("click_observed");
2824
3278
  }
2825
3279
  const itemMap = options.questionsOnly ? /* @__PURE__ */ new Map() : new Map(capturedItems);
2826
3280
  if (!options.questionsOnly) {
@@ -2837,24 +3291,34 @@ var PAAExtractor = class {
2837
3291
  const row = this.toFlatRow(item, 1, null, options.query);
2838
3292
  this.upsertCollectedRow(row);
2839
3293
  results.push(row);
2840
- this.reporter.onQuestion({ question: item.question, answer: item.answer ?? null, sourceTitle: item.sourceTitle ?? null, sourceSite: item.sourceSite ?? null, sourceCite: item.sourceCite ?? null, depth: 1, parentQuestion: null, children: [] });
3294
+ this.reporter.onQuestion({ questionId: item.questionId, googleLinkId: item.googleLinkId, googleEvidenceId: item.googleEvidenceId, question: item.question, answer: item.answer ?? null, sourceTitle: item.sourceTitle ?? null, sourceSite: item.sourceSite ?? null, sourceCite: item.sourceCite ?? null, sources: item.sources, depth: 1, parentQuestion: null, children: [] });
2841
3295
  } else {
2842
- const row = this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query);
3296
+ const row = this.toFlatRow({ questionId: questionIdFor(q), googleLinkId: null, googleEvidenceId: null, question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0, sources: [] }, 1, null, options.query);
2843
3297
  this.upsertCollectedRow(row);
2844
3298
  results.push(row);
2845
3299
  }
2846
3300
  }
2847
3301
  this.completeness.paaWithoutAnswer = options.questionsOnly ? 0 : results.filter((r) => !r.answer).length;
2848
- this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_cite).length;
3302
+ this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => (r.sources ?? []).length === 0).length;
2849
3303
  this.completeness.paaRequested = options.maxQuestions;
2850
3304
  this.completeness.paaReturned = results.length;
2851
3305
  this.completeness.paaUnique = seenKeys.size;
3306
+ const materialPartial = !options.questionsOnly && (this.completeness.paaWithoutAnswer > 0 || this.completeness.paaWithoutSource > 0);
3307
+ this.paaLifecycle = {
3308
+ ...this.paaLifecycle,
3309
+ questionsFound: results.length,
3310
+ targetStatus: results.length >= options.maxQuestions ? "met" : "not_met",
3311
+ materialStatus: materialPartial ? "partial" : "complete",
3312
+ processedQuestions: processedQuestions.size,
3313
+ failedInteractions: failedInteractions.size
3314
+ };
2852
3315
  await this.emitProgress("fill_incomplete");
3316
+ await this.emitProgress("expansion_finished");
2853
3317
  return results;
2854
3318
  }
2855
3319
  async fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick) {
2856
3320
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2857
- const isIncomplete = (item) => !item?.answer || !(item.sourceTitle || item.sourceSite || item.sourceCite);
3321
+ const isIncomplete = (item) => !item?.answer || (item.sources ?? []).length === 0;
2858
3322
  const missingBefore = new Set(
2859
3323
  [...kept].filter((q) => isIncomplete(itemMap.get(q)))
2860
3324
  );
@@ -2872,7 +3336,8 @@ var PAAExtractor = class {
2872
3336
  answer: item.answer ?? existing?.answer,
2873
3337
  sourceTitle: item.sourceTitle ?? existing?.sourceTitle,
2874
3338
  sourceSite: item.sourceSite ?? existing?.sourceSite,
2875
- sourceCite: item.sourceCite ?? existing?.sourceCite
3339
+ sourceCite: item.sourceCite ?? existing?.sourceCite,
3340
+ sources: this.mergePaaSources(existing?.sources, item.sources)
2876
3341
  });
2877
3342
  const upgraded = itemMap.get(q);
2878
3343
  if (upgraded && this.upsertCollectedRow(this.toFlatRow(upgraded, 1, null, options.query))) {
@@ -3059,7 +3524,7 @@ var PAAExtractor = class {
3059
3524
  }
3060
3525
  async extractOrganicResults(page) {
3061
3526
  const sels = OrganicSelectors;
3062
- return page.evaluate((s) => {
3527
+ const raw = await page.evaluate((s) => {
3063
3528
  const out = [];
3064
3529
  let pos = 0;
3065
3530
  document.querySelectorAll(s.result).forEach((card) => {
@@ -3085,6 +3550,17 @@ var PAAExtractor = class {
3085
3550
  });
3086
3551
  return out;
3087
3552
  }, sels);
3553
+ const baseUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
3554
+ return raw.flatMap((item) => {
3555
+ const link = classifyGoogleOutboundLink(item.url, baseUrl);
3556
+ if (!link) return [];
3557
+ let domain = item.domain;
3558
+ try {
3559
+ domain = new URL(link.resolvedUrl ?? link.rawUrl).hostname.replace(/^www\./, "");
3560
+ } catch {
3561
+ }
3562
+ return [{ ...item, ...link, domain }];
3563
+ });
3088
3564
  }
3089
3565
  async extractLocalPack(page) {
3090
3566
  const sels = LocalPackSelectors;
@@ -3236,15 +3712,21 @@ var PAAExtractor = class {
3236
3712
  aim: AIModeSelectors,
3237
3713
  expandWaitMs: 1500
3238
3714
  });
3239
- if (!surfaces.aiOverview.detected) {
3715
+ const pageUrl = typeof page.url === "function" ? page.url() : "https://www.google.com/";
3716
+ const normalized = {
3717
+ surface: surfaces.surface,
3718
+ aiOverview: { ...surfaces.aiOverview, citations: this.normalizeAICitations(pageUrl, surfaces.aiOverview.citations) },
3719
+ aiMode: { ...surfaces.aiMode, citations: this.normalizeAICitations(pageUrl, surfaces.aiMode.citations) }
3720
+ };
3721
+ if (!normalized.aiOverview.detected) {
3240
3722
  this.completeness.aioShareCaptured = null;
3241
- return surfaces;
3723
+ return normalized;
3242
3724
  }
3243
3725
  const shareUrl = this.aioShareUrlEarly ?? await this.captureAIOverviewShareUrl(page, options).catch(() => null);
3244
3726
  this.completeness.aioShareCaptured = shareUrl !== null;
3245
3727
  return {
3246
- ...surfaces,
3247
- aiOverview: { ...surfaces.aiOverview, shareUrl }
3728
+ ...normalized,
3729
+ aiOverview: { ...normalized.aiOverview, shareUrl }
3248
3730
  };
3249
3731
  }
3250
3732
  async captureAIOverviewShareUrl(page, options) {
@@ -3289,11 +3771,15 @@ var PAAExtractor = class {
3289
3771
  const nodeMap = /* @__PURE__ */ new Map();
3290
3772
  for (const row of flat) {
3291
3773
  const node = {
3774
+ questionId: row.question_id,
3775
+ googleLinkId: row.google_link_id || null,
3776
+ googleEvidenceId: row.google_evidence_id || null,
3292
3777
  question: row.question,
3293
3778
  answer: row.answer || null,
3294
3779
  sourceTitle: row.source_title || null,
3295
3780
  sourceSite: row.source_site || null,
3296
3781
  sourceCite: row.source_cite || null,
3782
+ sources: row.sources ?? [],
3297
3783
  depth: row.depth,
3298
3784
  parentQuestion: row.parent_question || null,
3299
3785
  children: []
@@ -3340,10 +3826,13 @@ var PAAExtractor = class {
3340
3826
  async extract(options, signal) {
3341
3827
  const startMs = Date.now();
3342
3828
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
3829
+ this.linkDiagnostics = this.emptyLinkDiagnostics();
3343
3830
  this.aioShareUrlEarly = null;
3344
3831
  this.collectedRows = [];
3345
3832
  this.progressSequence = 0;
3833
+ this.checkpointMaterial = null;
3346
3834
  this.clickedQuestions = /* @__PURE__ */ new Set();
3835
+ this.paaLifecycle = this.emptyPaaLifecycle(options.maxQuestions);
3347
3836
  this.partialContext = {
3348
3837
  seed: options.query,
3349
3838
  location: options.location ?? null,
@@ -3406,8 +3895,17 @@ var PAAExtractor = class {
3406
3895
  this.throwIfAborted(signal);
3407
3896
  const page = this.driver.getPage();
3408
3897
  await this.throwIfCaptcha(page, "Google SERP");
3898
+ if (!hasPaa) {
3899
+ this.paaLifecycle = {
3900
+ ...this.paaLifecycle,
3901
+ discoveryStatus: "no_paa_observed",
3902
+ materialStatus: "complete",
3903
+ completionReason: "A readable Google SERP was observed without a People Also Ask block."
3904
+ };
3905
+ }
3409
3906
  if (options.questionsOnly) {
3410
3907
  const flat2 = hasPaa ? await this.runBFS(page, executionOptions, signal) : [];
3908
+ await this.resolveMaterialLinks(page, flat2, [], emptyAiSurfaces.aiOverview, emptyAiSurfaces.aiMode);
3411
3909
  const stats2 = {
3412
3910
  seed: executionOptions.query,
3413
3911
  totalQuestions: flat2.length,
@@ -3424,7 +3922,9 @@ var PAAExtractor = class {
3424
3922
  completionStatus: hasPaa ? "paa_found" : "no_paa",
3425
3923
  ...!hasPaa ? { noPaaObserved: true } : {},
3426
3924
  problem: null,
3925
+ paaLifecycle: { ...this.paaLifecycle },
3427
3926
  completeness: { ...this.completeness },
3927
+ links: { ...this.linkDiagnostics },
3428
3928
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, void 0, locationResolution) } : {}
3429
3929
  },
3430
3930
  totalQuestions: flat2.length,
@@ -3467,6 +3967,7 @@ var PAAExtractor = class {
3467
3967
  locationEvidence2 = inferSerpLocationEvidence(canonicalLocation, allOrganic2, localPack2);
3468
3968
  }
3469
3969
  }
3970
+ await this.resolveMaterialLinks(page, [], allOrganic2, aiSurfaces2.aiOverview, aiSurfaces2.aiMode);
3470
3971
  const stats2 = {
3471
3972
  seed: executionOptions.query,
3472
3973
  totalQuestions: 0,
@@ -3483,6 +3984,7 @@ var PAAExtractor = class {
3483
3984
  completionStatus: "serp_only",
3484
3985
  problem: null,
3485
3986
  completeness: { ...this.completeness },
3987
+ links: { ...this.linkDiagnostics },
3486
3988
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
3487
3989
  },
3488
3990
  totalQuestions: 0,
@@ -3528,6 +4030,7 @@ var PAAExtractor = class {
3528
4030
  }
3529
4031
  }
3530
4032
  const aiSurfaces2 = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
4033
+ await this.resolveMaterialLinks(page, [], noPaaOrganic, aiSurfaces2.aiOverview, aiSurfaces2.aiMode);
3531
4034
  const stats2 = {
3532
4035
  seed: executionOptions.query,
3533
4036
  totalQuestions: 0,
@@ -3544,7 +4047,9 @@ var PAAExtractor = class {
3544
4047
  completionStatus: "no_paa",
3545
4048
  noPaaObserved: true,
3546
4049
  problem: null,
4050
+ paaLifecycle: { ...this.paaLifecycle },
3547
4051
  completeness: { ...this.completeness },
4052
+ links: { ...this.linkDiagnostics },
3548
4053
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
3549
4054
  },
3550
4055
  totalQuestions: 0,
@@ -3562,9 +4067,21 @@ var PAAExtractor = class {
3562
4067
  stats: stats2
3563
4068
  };
3564
4069
  }
4070
+ const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
4071
+ this.checkpointMaterial = {
4072
+ surface: aiSurfaces.surface,
4073
+ aiOverview: aiSurfaces.aiOverview,
4074
+ aiMode: aiSurfaces.aiMode,
4075
+ whatPeopleSaying,
4076
+ videos,
4077
+ forums,
4078
+ organicResults,
4079
+ localPack,
4080
+ entityIds
4081
+ };
4082
+ await this.emitProgress("serp_captured");
3565
4083
  const flat = await this.runBFS(page, executionOptions, signal);
3566
4084
  this.throwIfAborted(signal);
3567
- const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
3568
4085
  const shortVidsParams = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", udm: ShortVideoSelectors.udm });
3569
4086
  if (uule) shortVidsParams.set("uule", uule);
3570
4087
  let shortVideos = [];
@@ -3597,6 +4114,15 @@ var PAAExtractor = class {
3597
4114
  locationEvidence = inferSerpLocationEvidence(canonicalLocation, allOrganic, localPack);
3598
4115
  }
3599
4116
  }
4117
+ await this.resolveMaterialLinks(page, flat, allOrganic, aiSurfaces.aiOverview, aiSurfaces.aiMode);
4118
+ this.checkpointMaterial = {
4119
+ ...this.checkpointMaterial,
4120
+ surface: aiSurfaces.surface,
4121
+ aiOverview: aiSurfaces.aiOverview,
4122
+ aiMode: aiSurfaces.aiMode,
4123
+ videos: [...videos, ...shortVideos],
4124
+ organicResults: allOrganic
4125
+ };
3600
4126
  const allVideos = [...videos, ...shortVideos];
3601
4127
  const tree = this.buildTree(flat, executionOptions.query);
3602
4128
  const stats = {
@@ -3614,7 +4140,9 @@ var PAAExtractor = class {
3614
4140
  diagnostics: {
3615
4141
  completionStatus: "paa_found",
3616
4142
  problem: null,
4143
+ paaLifecycle: { ...this.paaLifecycle },
3617
4144
  completeness: { ...this.completeness },
4145
+ links: { ...this.linkDiagnostics },
3618
4146
  ...diagnosticWarnings.length > 0 ? { warnings: diagnosticWarnings } : {},
3619
4147
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence, locationResolution) } : {}
3620
4148
  },
@@ -3634,6 +4162,13 @@ var PAAExtractor = class {
3634
4162
  };
3635
4163
  } catch (err) {
3636
4164
  errorCount++;
4165
+ if (this.paaLifecycle.discoveryStatus === "in_progress") {
4166
+ this.paaLifecycle = {
4167
+ ...this.paaLifecycle,
4168
+ discoveryStatus: "interrupted",
4169
+ completionReason: "The browser attempt ended before frontier exhaustion could be proven."
4170
+ };
4171
+ }
3637
4172
  await this.emitProgress("attempt_ending").catch(() => {
3638
4173
  });
3639
4174
  this.reporter.onError(err instanceof Error ? err : new Error(String(err)));
@@ -3682,7 +4217,10 @@ var OutputSerializer = class {
3682
4217
  await import_node_fs.promises.mkdir(outputDir, { recursive: true });
3683
4218
  const seedRaw = rows[0]?.seed_query ?? "paa";
3684
4219
  const slug = seedRaw.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
3685
- const csv = import_papaparse.default.unparse(rows, { header: true });
4220
+ const csv = import_papaparse.default.unparse(rows.map(({ sources, ...row }) => ({
4221
+ ...row,
4222
+ sources_json: JSON.stringify(sources)
4223
+ })), { header: true });
3686
4224
  const filename = `${slug}-${Date.now()}.csv`;
3687
4225
  const fullPath = import_node_path.default.join(outputDir, filename);
3688
4226
  await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
@@ -3778,7 +4316,7 @@ var ProgressReporter = class {
3778
4316
  var import_sdk3 = __toESM(require("@onkernel/sdk"), 1);
3779
4317
 
3780
4318
  // src/backup-proxy.ts
3781
- var import_node_crypto3 = require("crypto");
4319
+ var import_node_crypto4 = require("crypto");
3782
4320
  var import_sdk2 = __toESM(require("@onkernel/sdk"), 1);
3783
4321
  var BACKUP_PROXY_HOST = "pr.oxylabs.io";
3784
4322
  var BACKUP_PROXY_PORT = 7777;
@@ -3800,7 +4338,7 @@ function backupProxyAvailable() {
3800
4338
  }
3801
4339
  function randomStickySessionId() {
3802
4340
  let digits = "";
3803
- for (let i = 0; i < 10; i++) digits += String((0, import_node_crypto3.randomInt)(0, 10));
4341
+ for (let i = 0; i < 10; i++) digits += String((0, import_node_crypto4.randomInt)(0, 10));
3804
4342
  return digits;
3805
4343
  }
3806
4344
  function buildBackupUsername(opts = {}) {
@@ -4336,6 +4874,7 @@ function classifyAttemptError(err) {
4336
4874
  if (err instanceof CaptchaError) return "captcha";
4337
4875
  if (err instanceof LocationMismatchError) return "location_mismatch";
4338
4876
  if (err instanceof RequestAbortedError) return "request_aborted";
4877
+ if (err instanceof PaaInteractionInterruptedError) return "browser_session_interrupted";
4339
4878
  if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) return "timeout";
4340
4879
  const message = err instanceof Error ? err.message : String(err);
4341
4880
  if (/browser (?:has been )?closed|context (?:has been )?closed|target page, context or browser has been closed|session closed|page has been closed/i.test(message)) {
@@ -4360,7 +4899,9 @@ function withResultQuality(result, requestedPaaCount, questionsOnly) {
4360
4899
  const paaWithoutSource = questionsOnly ? 0 : result.flat.filter((row) => !row.source_cite?.trim()).length;
4361
4900
  const noPaa = result.diagnostics.completionStatus === "no_paa" && (result.diagnostics.noPaaObserved === true || hasUsableContent);
4362
4901
  const hasPaa = result.diagnostics.completionStatus === "paa_found";
4363
- const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption));
4902
+ const discoveryStatus = result.diagnostics.paaLifecycle?.discoveryStatus;
4903
+ const discoveryIncomplete = discoveryStatus === "interrupted" || discoveryStatus === "recovery_exhausted";
4904
+ const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount && discoveryStatus !== "frontier_exhausted" || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption) || discoveryIncomplete);
4364
4905
  const resultQuality = noPaa ? "complete" : hasPaa && uniquePaaCount > 0 ? paaIncomplete || hasWarnings ? "partial" : "complete" : hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
4365
4906
  const degradedResult = resultQuality === "degraded";
4366
4907
  result.diagnostics = {
@@ -4377,7 +4918,15 @@ function withResultQuality(result, requestedPaaCount, questionsOnly) {
4377
4918
  paaRequested: requestedPaaCount,
4378
4919
  paaReturned: result.flat.length,
4379
4920
  paaUnique: uniquePaaCount
4380
- }
4921
+ },
4922
+ ...result.diagnostics.paaLifecycle ? {
4923
+ paaLifecycle: {
4924
+ ...result.diagnostics.paaLifecycle,
4925
+ questionsFound: uniquePaaCount,
4926
+ targetStatus: uniquePaaCount >= requestedPaaCount ? "met" : "not_met",
4927
+ materialStatus: paaWithoutAnswer > 0 || paaWithoutSource > 0 ? "partial" : "complete"
4928
+ }
4929
+ } : {}
4381
4930
  };
4382
4931
  return result;
4383
4932
  }