mcp-scraper 0.69.0 → 0.72.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 (45) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +6 -2
  3. package/dist/{analytics-repository-62CAZAIP.js → analytics-repository-6YLRSWYO.js} +3 -2
  4. package/dist/bin/api-server.cjs +18592 -12680
  5. package/dist/bin/api-server.js +4 -4
  6. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs +3 -3
  9. package/dist/bin/mcp-scraper-install.js +2 -2
  10. package/dist/bin/mcp-stdio-server.cjs +10492 -9129
  11. package/dist/bin/mcp-stdio-server.js +9 -7
  12. package/dist/bin/paa-harvest.cjs +308 -41
  13. package/dist/bin/paa-harvest.js +4 -3
  14. package/dist/{chunk-M7D7WO75.js → chunk-2N4V4LVU.js} +3178 -2019
  15. package/dist/chunk-7RQULQF3.js +263 -0
  16. package/dist/{chunk-UL4ZKAWZ.js → chunk-A66DGFOU.js} +1 -1
  17. package/dist/{chunk-ES25GP6C.js → chunk-CXY5WV45.js} +24 -34
  18. package/dist/{chunk-Y46YNQMM.js → chunk-F5TK4KMT.js} +1 -1
  19. package/dist/{chunk-62LEXS5O.js → chunk-F6MUGMRN.js} +306 -44
  20. package/dist/{chunk-5RFQGIYC.js → chunk-FVL4GUTP.js} +2 -2
  21. package/dist/{chunk-OZIUFN6B.js → chunk-I45JV4EU.js} +2 -2
  22. package/dist/{chunk-2LXTOAKS.js → chunk-KIPIBPUB.js} +9 -1
  23. package/dist/chunk-MB72PA6S.js +2003 -0
  24. package/dist/chunk-OM7HVEJ3.js +26 -0
  25. package/dist/chunk-PJEEKOUM.js +404 -0
  26. package/dist/{chunk-RX2QAHML.js → chunk-RAFQEPJ4.js} +2 -2
  27. package/dist/{chunk-KO5CK5ZT.js → chunk-SR7GSLEA.js} +202 -441
  28. package/dist/chunk-T3MZISOF.js +240 -0
  29. package/dist/{chunk-ULYOCJUJ.js → chunk-TUTQHP4O.js} +1 -1
  30. package/dist/{chunk-65FTMB7G.js → chunk-WEFPBAAG.js} +180 -9
  31. package/dist/{chunk-LN6N3YLK.js → chunk-WFQ2E4WE.js} +1 -1
  32. package/dist/{db-ZGZAKYBW.js → db-566MOHHD.js} +7 -1
  33. package/dist/{extract-bundle-SXSJCPQI.js → extract-bundle-337ZDIIQ.js} +4 -4
  34. package/dist/gmail-service-E276SBJU.js +25 -0
  35. package/dist/index.cjs +322 -49
  36. package/dist/index.d.cts +15 -2
  37. package/dist/index.d.ts +15 -2
  38. package/dist/index.js +4 -3
  39. package/dist/{lead-list-enrichment-repository-CXLVIV3R.js → lead-list-enrichment-repository-WWEMJ7EN.js} +2 -2
  40. package/dist/{location-data-repository-G72M7DTN.js → location-data-repository-H4B2BWGY.js} +2 -2
  41. package/dist/{server-WCNYXILA.js → server-UGWC5YMD.js} +8376 -6616
  42. package/dist/{site-extract-repository-RLB3TFJ3.js → site-extract-repository-ACNAVNDN.js} +3 -3
  43. package/dist/{worker-KHSJG365.js → worker-JQORPCFQ.js} +9 -7
  44. package/package.json +1 -1
  45. package/dist/chunk-2L4C4DAZ.js +0 -103
package/dist/index.cjs CHANGED
@@ -443,6 +443,7 @@ var NANGO_USD_PER_CONNECTION_MONTH = envRate("NANGO_USD_PER_CONNECTION_MONTH", 1
443
443
  var NANGO_USD_PER_FUNCTION_RUN = envRate("NANGO_USD_PER_FUNCTION_RUN", 1e-4);
444
444
  var NANGO_USD_PER_PROXY_REQUEST = envRate("NANGO_USD_PER_PROXY_REQUEST", 1e-4);
445
445
  var NANGO_USD_PER_COMPUTE_SEC = envRate("NANGO_USD_PER_COMPUTE_SEC", 2e-4);
446
+ var BRIGHTDATA_BROWSER_USD_PER_GB = envRate("BRIGHTDATA_BROWSER_USD_PER_GB", 0);
446
447
  function kernelCostUsd(ms, headful) {
447
448
  const sec = Math.max(0, ms) / 1e3;
448
449
  return sec * (headful ? KERNEL_HEADFUL_USD_PER_SEC : KERNEL_HEADLESS_USD_PER_SEC);
@@ -460,6 +461,9 @@ function vendorCostUsd(vendor, units) {
460
461
  if (vendor === "nango_function_run") return Math.max(0, units) * NANGO_USD_PER_FUNCTION_RUN;
461
462
  if (vendor === "nango_proxy_request") return Math.max(0, units) * NANGO_USD_PER_PROXY_REQUEST;
462
463
  if (vendor === "nango_compute") return Math.max(0, units) * NANGO_USD_PER_COMPUTE_SEC;
464
+ if (vendor === "brightdata_browser_api") {
465
+ return Math.max(0, units) / 1e9 * BRIGHTDATA_BROWSER_USD_PER_GB;
466
+ }
463
467
  return 0;
464
468
  }
465
469
 
@@ -480,7 +484,8 @@ async function runCostTelemetryMigration() {
480
484
  SELECT
481
485
  (SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('kernel_session_log', 'vendor_usage_log', 'cost_probe_runs')) = 3
482
486
  AND (SELECT COUNT(*) FROM pragma_table_info('kernel_session_log') WHERE name IN ('proxy_source', 'proxy_type', 'method')) = 3
483
- AND (SELECT COUNT(*) FROM pragma_table_info('vendor_usage_log') WHERE name = 'method') = 1
487
+ AND (SELECT COUNT(*) FROM pragma_table_info('vendor_usage_log') WHERE name IN ('method', 'source_key')) = 2
488
+ AND (SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'vendor_usage_log_vendor_source_key') = 1
484
489
  AND (SELECT COUNT(*) FROM pragma_table_info('cost_probe_runs') WHERE name IN ('units', 'unit_type', 'mode')) = 3
485
490
  AS ready
486
491
  `);
@@ -536,6 +541,7 @@ async function runCostTelemetryMigration() {
536
541
  unit_type TEXT,
537
542
  est_cost_usd REAL NOT NULL DEFAULT 0,
538
543
  error TEXT,
544
+ source_key TEXT,
539
545
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
540
546
  )
541
547
  `);
@@ -546,6 +552,11 @@ async function runCostTelemetryMigration() {
546
552
  await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN method TEXT`);
547
553
  } catch {
548
554
  }
555
+ try {
556
+ await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN source_key TEXT`);
557
+ } catch {
558
+ }
559
+ await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS vendor_usage_log_vendor_source_key ON vendor_usage_log(vendor, source_key) WHERE source_key IS NOT NULL`);
549
560
  await db.execute(`
550
561
  CREATE TABLE IF NOT EXISTS cost_probe_runs (
551
562
  id TEXT PRIMARY KEY,
@@ -625,26 +636,29 @@ async function recordVendorUsage(r) {
625
636
  await migrateCostTelemetry();
626
637
  const ctx = currentCostContext();
627
638
  const db = getDb();
628
- await db.execute({
629
- sql: `INSERT INTO vendor_usage_log
630
- (id, op, probe_run_id, user_id, vendor, model, units, unit_type, est_cost_usd, error, method)
631
- VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
639
+ const result = await db.execute({
640
+ sql: `INSERT OR IGNORE INTO vendor_usage_log
641
+ (id, op, probe_run_id, user_id, vendor, model, units, unit_type, est_cost_usd, error, method, source_key)
642
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
632
643
  args: [
633
644
  (0, import_node_crypto2.randomUUID)(),
634
- ctx?.op ?? null,
635
- ctx?.probeRunId ?? null,
636
- ctx?.userId ?? null,
645
+ r.op ?? ctx?.op ?? null,
646
+ r.probeRunId ?? ctx?.probeRunId ?? null,
647
+ r.userId ?? ctx?.userId ?? null,
637
648
  r.vendor,
638
649
  r.model ?? null,
639
650
  r.units,
640
651
  r.unitType,
641
652
  vendorCostUsd(r.vendor, r.units),
642
653
  r.error ?? null,
643
- ctx?.subOp ?? null
654
+ r.method ?? ctx?.subOp ?? null,
655
+ r.sourceKey ?? null
644
656
  ]
645
657
  });
658
+ return result.rowsAffected === 1;
646
659
  } catch (err) {
647
660
  console.warn("[cost-telemetry] recordVendorUsage failed:", err instanceof Error ? err.message : String(err));
661
+ return false;
648
662
  }
649
663
  }
650
664
  function boolToInt(v) {
@@ -1177,6 +1191,13 @@ var BrowserDriver = class {
1177
1191
  getKernelSessionId() {
1178
1192
  return this.kernelSessionId;
1179
1193
  }
1194
+ getProviderSessionMetadata() {
1195
+ return {
1196
+ provider: this.kernelSessionId ? "kernel" : "local",
1197
+ providerSessionId: this.kernelSessionId,
1198
+ disconnectObservation: null
1199
+ };
1200
+ }
1180
1201
  getDebugSnapshot() {
1181
1202
  return this.debugSnapshot;
1182
1203
  }
@@ -1191,6 +1212,7 @@ var BrowserDriver = class {
1191
1212
  const proxySource = this.kernelProxySource;
1192
1213
  const proxyType = this.kernelProxyType;
1193
1214
  const headlessSent = this.kernelHeadlessSent;
1215
+ const provider = this.getProviderSessionMetadata();
1194
1216
  this.browser = null;
1195
1217
  this.context = null;
1196
1218
  this.page = null;
@@ -1225,7 +1247,11 @@ var BrowserDriver = class {
1225
1247
  kernelDeleteSucceeded: deleteResult.status === "fulfilled",
1226
1248
  kernelDeleteError: deleteResult.status === "rejected" ? deleteResult.reason instanceof Error ? deleteResult.reason.message : String(deleteResult.reason) : null,
1227
1249
  browserCloseSucceeded: closeResult.status === "fulfilled",
1228
- browserCloseError: closeResult.status === "rejected" ? closeResult.reason instanceof Error ? closeResult.reason.message : String(closeResult.reason) : null
1250
+ browserCloseError: closeResult.status === "rejected" ? closeResult.reason instanceof Error ? closeResult.reason.message : String(closeResult.reason) : null,
1251
+ provider: provider.provider,
1252
+ providerSessionId: provider.providerSessionId,
1253
+ providerDisconnectObserved: false,
1254
+ providerDisconnectMessage: null
1229
1255
  };
1230
1256
  if (deleteResult.status === "rejected") {
1231
1257
  console.warn(JSON.stringify({
@@ -1257,7 +1283,11 @@ var BrowserDriver = class {
1257
1283
  kernelDeleteSucceeded: null,
1258
1284
  kernelDeleteError: null,
1259
1285
  browserCloseSucceeded: true,
1260
- browserCloseError: null
1286
+ browserCloseError: null,
1287
+ provider: provider.provider,
1288
+ providerSessionId: provider.providerSessionId,
1289
+ providerDisconnectObserved: false,
1290
+ providerDisconnectMessage: null
1261
1291
  };
1262
1292
  } else if (this.context) {
1263
1293
  const ctx = this.context;
@@ -1270,7 +1300,11 @@ var BrowserDriver = class {
1270
1300
  kernelDeleteSucceeded: null,
1271
1301
  kernelDeleteError: null,
1272
1302
  browserCloseSucceeded: true,
1273
- browserCloseError: null
1303
+ browserCloseError: null,
1304
+ provider: "local",
1305
+ providerSessionId: null,
1306
+ providerDisconnectObserved: false,
1307
+ providerDisconnectMessage: null
1274
1308
  };
1275
1309
  }
1276
1310
  return {
@@ -1279,13 +1313,64 @@ var BrowserDriver = class {
1279
1313
  kernelDeleteSucceeded: null,
1280
1314
  kernelDeleteError: null,
1281
1315
  browserCloseSucceeded: null,
1282
- browserCloseError: null
1316
+ browserCloseError: null,
1317
+ provider: "local",
1318
+ providerSessionId: null,
1319
+ providerDisconnectObserved: false,
1320
+ providerDisconnectMessage: null
1283
1321
  };
1284
1322
  }
1285
1323
  };
1286
1324
 
1287
1325
  // src/driver/BrightDataSerpDriver.ts
1288
1326
  var import_playwright2 = require("playwright");
1327
+
1328
+ // src/driver/browser-provider-telemetry.ts
1329
+ var MAX_PROVIDER_SESSION_ID_LENGTH = 512;
1330
+ var MAX_PROVIDER_MESSAGE_LENGTH = 320;
1331
+ function boundedText(value, maxLength) {
1332
+ if (typeof value !== "string") return null;
1333
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
1334
+ if (!normalized) return null;
1335
+ return normalized.slice(0, maxLength);
1336
+ }
1337
+ function boundedProviderMessage(value) {
1338
+ const message = boundedText(value, MAX_PROVIDER_MESSAGE_LENGTH);
1339
+ if (!message) return null;
1340
+ return message.replace(/\b(?:wss?|https?):\/\/\S+/gi, "[redacted endpoint]").replace(/\b(?:authorization|proxy-authorization):\s*\S+/gi, "$1: [redacted]").replace(/\bBearer\s+\S+/gi, "Bearer [redacted]").slice(0, MAX_PROVIDER_MESSAGE_LENGTH);
1341
+ }
1342
+ function sanitizeProviderLocalMessage(value) {
1343
+ return boundedProviderMessage(value);
1344
+ }
1345
+ function validateProviderSessionId(value) {
1346
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_PROVIDER_SESSION_ID_LENGTH || value.trim() !== value || /[\s\u0000-\u001f\u007f]/.test(value) || /:\/\//.test(value)) {
1347
+ throw new Error("Bright Data did not return a valid browser session ID");
1348
+ }
1349
+ return value;
1350
+ }
1351
+ function makeProviderInterruptionObservation(source, message, observedAt = (/* @__PURE__ */ new Date()).toISOString()) {
1352
+ const safeMessage = boundedProviderMessage(message);
1353
+ return {
1354
+ source,
1355
+ message: safeMessage ?? source.replaceAll("_", " "),
1356
+ observedAt
1357
+ };
1358
+ }
1359
+ function projectProviderSessionPublic(metadata) {
1360
+ return {
1361
+ provider: metadata.provider,
1362
+ providerDisconnectObserved: metadata.disconnectObservation !== null,
1363
+ providerDisconnectMessage: metadata.disconnectObservation?.message ?? null
1364
+ };
1365
+ }
1366
+ function projectProviderSessionPrivate(metadata) {
1367
+ return {
1368
+ ...projectProviderSessionPublic(metadata),
1369
+ providerSessionId: metadata.providerSessionId
1370
+ };
1371
+ }
1372
+
1373
+ // src/driver/BrightDataSerpDriver.ts
1289
1374
  var BLOCKED_TYPES = /* @__PURE__ */ new Set(["image", "media", "font", "stylesheet", "ping"]);
1290
1375
  var BLOCKED_HOST_PARTS = [
1291
1376
  "doubleclick.net",
@@ -1335,8 +1420,19 @@ var BrightDataSerpDriver = class {
1335
1420
  browser = null;
1336
1421
  context = null;
1337
1422
  page = null;
1423
+ cdpSession = null;
1424
+ providerSessionId = null;
1425
+ disconnectObservation = null;
1426
+ closing = false;
1427
+ observeDisconnect(source, message) {
1428
+ if (this.closing || this.disconnectObservation) return;
1429
+ this.disconnectObservation = makeProviderInterruptionObservation(source, message);
1430
+ }
1338
1431
  async launch(_config) {
1339
1432
  if (this.page) return;
1433
+ this.closing = false;
1434
+ this.providerSessionId = null;
1435
+ this.disconnectObservation = null;
1340
1436
  const endpoint = brightDataBrowserEndpoint();
1341
1437
  if (!endpoint) throw new Error("SERP browser service is not configured");
1342
1438
  this.browser = await import_playwright2.chromium.connectOverCDP(endpoint);
@@ -1352,6 +1448,14 @@ var BrightDataSerpDriver = class {
1352
1448
  }
1353
1449
  });
1354
1450
  this.page = this.context.pages()[0] ?? await this.context.newPage();
1451
+ this.browser.on("disconnected", () => this.observeDisconnect("browser_disconnected"));
1452
+ this.context.on("close", () => this.observeDisconnect("context_closed"));
1453
+ this.page.on("close", () => this.observeDisconnect("page_closed"));
1454
+ this.page.on("crash", () => this.observeDisconnect("page_crashed"));
1455
+ this.cdpSession = await this.context.newCDPSession(this.page);
1456
+ const customCdp = this.cdpSession;
1457
+ const sessionResponse = await customCdp.send("Browser.getSessionId");
1458
+ this.providerSessionId = validateProviderSessionId(sessionResponse?.sessionId);
1355
1459
  await this.page.route("**/*", async (route) => {
1356
1460
  const request = route.request();
1357
1461
  const resourceType = request.resourceType();
@@ -1407,23 +1511,38 @@ var BrightDataSerpDriver = class {
1407
1511
  getKernelSessionId() {
1408
1512
  return null;
1409
1513
  }
1514
+ getProviderSessionMetadata() {
1515
+ return {
1516
+ provider: "bright_data_browser_api",
1517
+ providerSessionId: this.providerSessionId,
1518
+ disconnectObservation: this.disconnectObservation
1519
+ };
1520
+ }
1410
1521
  getDebugSnapshot() {
1411
1522
  return { kernel: null, context: null, networkLocation: null, serpNavigation: null };
1412
1523
  }
1413
1524
  async close() {
1525
+ this.closing = true;
1414
1526
  let error = null;
1415
1527
  try {
1416
1528
  await this.browser?.close();
1417
1529
  } catch (caught) {
1418
- error = caught instanceof Error ? caught.message : String(caught);
1530
+ error = sanitizeProviderLocalMessage(caught instanceof Error ? caught.message : String(caught)) ?? "Bright Data browser close failed";
1531
+ this.disconnectObservation ??= makeProviderInterruptionObservation("browser_close_error", error);
1419
1532
  }
1533
+ const provider = projectProviderSessionPrivate(this.getProviderSessionMetadata());
1534
+ this.browser = null;
1535
+ this.context = null;
1536
+ this.page = null;
1537
+ this.cdpSession = null;
1420
1538
  return {
1421
1539
  kernelSessionId: null,
1422
1540
  kernelDeleteStarted: false,
1423
1541
  kernelDeleteSucceeded: null,
1424
1542
  kernelDeleteError: null,
1425
1543
  browserCloseSucceeded: error === null,
1426
- browserCloseError: error
1544
+ browserCloseError: error,
1545
+ ...provider
1427
1546
  };
1428
1547
  }
1429
1548
  };
@@ -2319,12 +2438,14 @@ async function extractAISurfacesFromDocument(config) {
2319
2438
 
2320
2439
  // src/extractor/PAAExtractor.ts
2321
2440
  var PAAExtractor = class {
2322
- constructor(driver, reporter) {
2441
+ constructor(driver, reporter, onPaaProgress) {
2323
2442
  this.driver = driver;
2324
2443
  this.reporter = reporter;
2444
+ this.onPaaProgress = onPaaProgress;
2325
2445
  }
2326
2446
  driver;
2327
2447
  reporter;
2448
+ onPaaProgress;
2328
2449
  completeness = {
2329
2450
  paaWithoutAnswer: 0,
2330
2451
  paaWithoutSource: 0,
@@ -2334,6 +2455,8 @@ var PAAExtractor = class {
2334
2455
  aioShareUrlEarly = null;
2335
2456
  collectedRows = [];
2336
2457
  partialContext = null;
2458
+ progressSequence = 0;
2459
+ clickedQuestions = /* @__PURE__ */ new Set();
2337
2460
  normalizeQuestion(q) {
2338
2461
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
2339
2462
  }
@@ -2370,6 +2493,34 @@ var PAAExtractor = class {
2370
2493
  function cleanSourceLabel(value) {
2371
2494
  return (value ?? "").replace(/\s*\(\+\d+\)\s*-\s*View related links.*$/i, "").replace(/\s*-\s*Opens in new tab.*$/i, "").trim();
2372
2495
  }
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
+ }
2373
2524
  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) => {
2374
2525
  const clickTarget = pair.querySelector(selectors.clickTarget);
2375
2526
  const expanded = pair.classList.contains(selectors.expandedClass) || clickTarget?.getAttribute("aria-expanded") === "true";
@@ -2391,21 +2542,14 @@ var PAAExtractor = class {
2391
2542
  ...Array.from(anchorRoot.querySelectorAll("a[href]"))
2392
2543
  ];
2393
2544
  for (const a of prioritized) {
2394
- const href = a.href;
2395
- if (!/^https?:\/\//.test(href) || seenHrefs.has(href)) continue;
2396
- let host = "";
2397
- try {
2398
- host = new URL(href).hostname;
2399
- } catch {
2400
- continue;
2401
- }
2402
- if (/(^|\.)google(usercontent)?\.[a-z.]+$/.test(host)) continue;
2403
- seenHrefs.add(href);
2545
+ const target = safeCitationTarget(a.href);
2546
+ if (!target || seenHrefs.has(target.href)) continue;
2547
+ seenHrefs.add(target.href);
2404
2548
  anchors.push({
2405
2549
  text: (a.textContent || "").trim(),
2406
2550
  label: cleanSourceLabel(a.getAttribute("aria-label")),
2407
- href,
2408
- host
2551
+ href: target.href,
2552
+ host: target.host
2409
2553
  });
2410
2554
  }
2411
2555
  const primary = anchors.find((x) => x.label.length > 2 || x.text.length > 2) ?? anchors[0];
@@ -2462,10 +2606,53 @@ var PAAExtractor = class {
2462
2606
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
2463
2607
  };
2464
2608
  }
2609
+ upsertCollectedRow(row) {
2610
+ const key = this.normalizeQuestion(row.question);
2611
+ const index = this.collectedRows.findIndex((existing2) => this.normalizeQuestion(existing2.question) === key);
2612
+ if (index < 0) {
2613
+ this.collectedRows.push({ ...row });
2614
+ return true;
2615
+ }
2616
+ const existing = this.collectedRows[index];
2617
+ const merged = {
2618
+ ...existing,
2619
+ ...row,
2620
+ answer: row.answer || existing.answer,
2621
+ source_title: row.source_title || existing.source_title,
2622
+ source_site: row.source_site || existing.source_site,
2623
+ source_cite: row.source_cite || existing.source_cite,
2624
+ extracted_at: row.extracted_at || existing.extracted_at
2625
+ };
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;
2627
+ if (changed) this.collectedRows[index] = merged;
2628
+ return changed;
2629
+ }
2630
+ async emitProgress(phase) {
2631
+ if (!this.onPaaProgress || this.collectedRows.length === 0) return;
2632
+ const snapshot = {
2633
+ sequence: ++this.progressSequence,
2634
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
2635
+ clickedQuestions: [...this.clickedQuestions],
2636
+ phase,
2637
+ records: this.collectedRows.map((row) => ({ ...row }))
2638
+ };
2639
+ try {
2640
+ await this.onPaaProgress(snapshot);
2641
+ } catch (err) {
2642
+ console.warn(JSON.stringify({
2643
+ event: "paa_progress_sink_failed",
2644
+ sequence: snapshot.sequence,
2645
+ phase: snapshot.phase,
2646
+ message: err instanceof Error ? err.message : String(err)
2647
+ }));
2648
+ }
2649
+ }
2465
2650
  getPartialResult() {
2466
2651
  const ctx = this.partialContext;
2467
2652
  if (!ctx || this.collectedRows.length === 0) return null;
2468
- const flat = [...this.collectedRows];
2653
+ const flat = this.collectedRows.map((row) => ({ ...row }));
2654
+ 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;
2469
2656
  const stats = {
2470
2657
  seed: ctx.seed,
2471
2658
  totalQuestions: flat.length,
@@ -2480,7 +2667,17 @@ var PAAExtractor = class {
2480
2667
  diagnostics: {
2481
2668
  completionStatus: "paa_found",
2482
2669
  problem: null,
2483
- completeness: { ...this.completeness },
2670
+ resultQuality: "partial",
2671
+ degradedResult: false,
2672
+ retryRecommended: true,
2673
+ completeness: {
2674
+ ...this.completeness,
2675
+ paaWithoutAnswer,
2676
+ paaWithoutSource,
2677
+ paaRequested: ctx.maxQuestions,
2678
+ paaReturned: flat.length,
2679
+ paaUnique: flat.length
2680
+ },
2484
2681
  warnings: [{
2485
2682
  code: "paa_partial",
2486
2683
  surface: "paa",
@@ -2489,6 +2686,9 @@ var PAAExtractor = class {
2489
2686
  }]
2490
2687
  },
2491
2688
  totalQuestions: flat.length,
2689
+ surface: "web",
2690
+ aiOverview: { detected: false, text: null, citations: [] },
2691
+ aiMode: { detected: false, text: null, citations: [] },
2492
2692
  whatPeopleSaying: [],
2493
2693
  tree: this.buildTree(flat, ctx.seed),
2494
2694
  flat,
@@ -2496,7 +2696,7 @@ var PAAExtractor = class {
2496
2696
  forums: [],
2497
2697
  organicResults: [],
2498
2698
  localPack: [],
2499
- entityIds: {},
2699
+ entityIds: { entities: [], kgIds: [], cids: [], gcids: [] },
2500
2700
  stats
2501
2701
  };
2502
2702
  }
@@ -2506,7 +2706,8 @@ var PAAExtractor = class {
2506
2706
  const seenQs = /* @__PURE__ */ new Set();
2507
2707
  const orderedQs = [];
2508
2708
  this.collectedRows = [];
2509
- const results = this.collectedRows;
2709
+ this.progressSequence = 0;
2710
+ this.clickedQuestions = /* @__PURE__ */ new Set();
2510
2711
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
2511
2712
  const clickedOnceEver = /* @__PURE__ */ new Set();
2512
2713
  const capturedItems = /* @__PURE__ */ new Map();
@@ -2554,12 +2755,17 @@ var PAAExtractor = class {
2554
2755
  if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2555
2756
  this.throwIfAborted(signal);
2556
2757
  const states = await readItemStates();
2758
+ let discovered = false;
2557
2759
  for (const s of states) {
2558
2760
  if (!seenQs.has(s.q)) {
2559
2761
  seenQs.add(s.q);
2560
2762
  orderedQs.push(s.q);
2763
+ if (orderedQs.length <= options.maxQuestions) {
2764
+ discovered = this.upsertCollectedRow(this.toFlatRow({ question: s.q }, 1, null, options.query)) || discovered;
2765
+ }
2561
2766
  }
2562
2767
  }
2768
+ if (discovered) await this.emitProgress("discovered");
2563
2769
  if (options.questionsOnly && seenQs.size >= options.maxQuestions) break;
2564
2770
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2565
2771
  const clickable = states.filter((s) => kept.has(s.q) && !clickedOnceEver.has(s.q) && !abandonedNeverReclick.has(s.q));
@@ -2579,6 +2785,7 @@ var PAAExtractor = class {
2579
2785
  this.reporter.onDepth(++round);
2580
2786
  await this.throwIfCaptcha(page, "Google PAA expansion");
2581
2787
  clickedOnceEver.add(target.q);
2788
+ this.clickedQuestions.add(target.q);
2582
2789
  const expansionStatus = await expandOneItemSerially(target.q);
2583
2790
  if (!options.questionsOnly) {
2584
2791
  let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
@@ -2586,7 +2793,12 @@ var PAAExtractor = class {
2586
2793
  await page.waitForTimeout(1300);
2587
2794
  clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2588
2795
  }
2589
- if (clickedItem?.answer) capturedItems.set(target.q, clickedItem);
2796
+ if (clickedItem) {
2797
+ capturedItems.set(target.q, clickedItem);
2798
+ if (this.upsertCollectedRow(this.toFlatRow(clickedItem, 1, null, options.query))) {
2799
+ await this.emitProgress("answer_captured");
2800
+ }
2801
+ }
2590
2802
  }
2591
2803
  if (expansionStatus === "failed" && !capturedItems.has(target.q)) {
2592
2804
  abandonedNeverReclick.add(target.q);
@@ -2602,14 +2814,19 @@ var PAAExtractor = class {
2602
2814
  if (!seenQs.has(state.q)) {
2603
2815
  seenQs.add(state.q);
2604
2816
  orderedQs.push(state.q);
2817
+ if (orderedQs.length <= options.maxQuestions) {
2818
+ this.upsertCollectedRow(this.toFlatRow({ question: state.q }, 1, null, options.query));
2819
+ }
2605
2820
  }
2606
2821
  }
2822
+ await this.emitProgress("discovered");
2607
2823
  }
2608
2824
  }
2609
2825
  const itemMap = options.questionsOnly ? /* @__PURE__ */ new Map() : new Map(capturedItems);
2610
2826
  if (!options.questionsOnly) {
2611
2827
  await this.fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick);
2612
2828
  }
2829
+ const results = [];
2613
2830
  for (const q of orderedQs) {
2614
2831
  if (results.length >= options.maxQuestions) break;
2615
2832
  const key = this.normalizeQuestion(q);
@@ -2617,14 +2834,22 @@ var PAAExtractor = class {
2617
2834
  seenKeys.add(key);
2618
2835
  const item = itemMap.get(q);
2619
2836
  if (item) {
2620
- results.push(this.toFlatRow(item, 1, null, options.query));
2837
+ const row = this.toFlatRow(item, 1, null, options.query);
2838
+ this.upsertCollectedRow(row);
2839
+ results.push(row);
2621
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: [] });
2622
2841
  } else {
2623
- results.push(this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query));
2842
+ const row = this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query);
2843
+ this.upsertCollectedRow(row);
2844
+ results.push(row);
2624
2845
  }
2625
2846
  }
2626
2847
  this.completeness.paaWithoutAnswer = options.questionsOnly ? 0 : results.filter((r) => !r.answer).length;
2627
- this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_title && !r.source_site && !r.source_cite).length;
2848
+ this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_cite).length;
2849
+ this.completeness.paaRequested = options.maxQuestions;
2850
+ this.completeness.paaReturned = results.length;
2851
+ this.completeness.paaUnique = seenKeys.size;
2852
+ await this.emitProgress("fill_incomplete");
2628
2853
  return results;
2629
2854
  }
2630
2855
  async fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick) {
@@ -2649,6 +2874,10 @@ var PAAExtractor = class {
2649
2874
  sourceSite: item.sourceSite ?? existing?.sourceSite,
2650
2875
  sourceCite: item.sourceCite ?? existing?.sourceCite
2651
2876
  });
2877
+ const upgraded = itemMap.get(q);
2878
+ if (upgraded && this.upsertCollectedRow(this.toFlatRow(upgraded, 1, null, options.query))) {
2879
+ await this.emitProgress("fill_incomplete");
2880
+ }
2652
2881
  }
2653
2882
  this.completeness.paaAnswersRecovered = [...missingAnswersBefore].filter((q) => itemMap.get(q)?.answer).length;
2654
2883
  }
@@ -3113,7 +3342,15 @@ var PAAExtractor = class {
3113
3342
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
3114
3343
  this.aioShareUrlEarly = null;
3115
3344
  this.collectedRows = [];
3116
- this.partialContext = { seed: options.query, location: options.location ?? null, startMs };
3345
+ this.progressSequence = 0;
3346
+ this.clickedQuestions = /* @__PURE__ */ new Set();
3347
+ this.partialContext = {
3348
+ seed: options.query,
3349
+ location: options.location ?? null,
3350
+ startMs,
3351
+ maxQuestions: options.maxQuestions,
3352
+ questionsOnly: options.questionsOnly
3353
+ };
3117
3354
  const isMobile = options.device === "mobile";
3118
3355
  const config = {
3119
3356
  headless: options.headless,
@@ -3185,6 +3422,7 @@ var PAAExtractor = class {
3185
3422
  extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
3186
3423
  diagnostics: {
3187
3424
  completionStatus: hasPaa ? "paa_found" : "no_paa",
3425
+ ...!hasPaa ? { noPaaObserved: true } : {},
3188
3426
  problem: null,
3189
3427
  completeness: { ...this.completeness },
3190
3428
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, void 0, locationResolution) } : {}
@@ -3304,6 +3542,7 @@ var PAAExtractor = class {
3304
3542
  extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
3305
3543
  diagnostics: {
3306
3544
  completionStatus: "no_paa",
3545
+ noPaaObserved: true,
3307
3546
  problem: null,
3308
3547
  completeness: { ...this.completeness },
3309
3548
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
@@ -3395,6 +3634,8 @@ var PAAExtractor = class {
3395
3634
  };
3396
3635
  } catch (err) {
3397
3636
  errorCount++;
3637
+ await this.emitProgress("attempt_ending").catch(() => {
3638
+ });
3398
3639
  this.reporter.onError(err instanceof Error ? err : new Error(String(err)));
3399
3640
  throw err;
3400
3641
  }
@@ -4074,6 +4315,11 @@ function getAttemptLogSink(rawOptions) {
4074
4315
  const sink = rawOptions.onAttemptEvent;
4075
4316
  return typeof sink === "function" ? sink : void 0;
4076
4317
  }
4318
+ function getPaaProgressSink(rawOptions) {
4319
+ if (!rawOptions || typeof rawOptions !== "object") return void 0;
4320
+ const sink = rawOptions.onPaaProgress;
4321
+ return typeof sink === "function" ? sink : void 0;
4322
+ }
4077
4323
  async function emitAttemptEvent(sink, event) {
4078
4324
  if (!sink) return;
4079
4325
  try {
@@ -4106,17 +4352,32 @@ function classifyAttemptResult(result) {
4106
4352
  function resultHasUsableContent(result) {
4107
4353
  return result.flat.length > 0 || result.organicResults.length > 0 || result.localPack.length > 0 || result.videos.length > 0 || result.forums.length > 0 || result.whatPeopleSaying.length > 0 || result.aiOverview.detected || result.aiMode.detected;
4108
4354
  }
4109
- function withResultQuality(result) {
4355
+ function withResultQuality(result, requestedPaaCount, questionsOnly) {
4110
4356
  const hasUsableContent = resultHasUsableContent(result);
4111
4357
  const hasWarnings = (result.diagnostics.warnings?.length ?? 0) > 0;
4112
- const resultQuality = hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
4358
+ const uniquePaaCount = new Set(result.flat.map((row) => row.question.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim())).size;
4359
+ const paaWithoutAnswer = questionsOnly ? 0 : result.flat.filter((row) => !row.answer?.trim()).length;
4360
+ const paaWithoutSource = questionsOnly ? 0 : result.flat.filter((row) => !row.source_cite?.trim()).length;
4361
+ const noPaa = result.diagnostics.completionStatus === "no_paa" && (result.diagnostics.noPaaObserved === true || hasUsableContent);
4362
+ const hasPaa = result.diagnostics.completionStatus === "paa_found";
4363
+ const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption));
4364
+ const resultQuality = noPaa ? "complete" : hasPaa && uniquePaaCount > 0 ? paaIncomplete || hasWarnings ? "partial" : "complete" : hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
4113
4365
  const degradedResult = resultQuality === "degraded";
4114
4366
  result.diagnostics = {
4115
4367
  ...result.diagnostics,
4116
4368
  resultQuality,
4117
4369
  degradedResult,
4118
4370
  degradationReasons: degradedResult ? ["empty_primary_serp"] : [],
4119
- retryRecommended: degradedResult
4371
+ retryRecommended: degradedResult || resultQuality === "partial" && (paaIncomplete || result.diagnostics.warnings?.some((warning) => warning.retryable) === true),
4372
+ completeness: {
4373
+ paaWithoutAnswer,
4374
+ paaWithoutSource,
4375
+ paaAnswersRecovered: result.diagnostics.completeness?.paaAnswersRecovered ?? 0,
4376
+ aioShareCaptured: result.diagnostics.completeness?.aioShareCaptured ?? null,
4377
+ paaRequested: requestedPaaCount,
4378
+ paaReturned: result.flat.length,
4379
+ paaUnique: uniquePaaCount
4380
+ }
4120
4381
  };
4121
4382
  return result;
4122
4383
  }
@@ -4176,10 +4437,10 @@ async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4176
4437
  }));
4177
4438
  }
4178
4439
  }
4179
- async function extractOnce(options, signal, forceManagedSerp = false) {
4440
+ async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress) {
4180
4441
  const driver = createSerpDriver(options, { forceManaged: forceManagedSerp });
4181
4442
  const reporter = new ProgressReporter();
4182
- const extractor = new PAAExtractor(driver, reporter);
4443
+ const extractor = new PAAExtractor(driver, reporter, onPaaProgress);
4183
4444
  if (signal?.aborted) {
4184
4445
  return {
4185
4446
  result: null,
@@ -4231,17 +4492,25 @@ async function extractOnce(options, signal, forceManagedSerp = false) {
4231
4492
  }
4232
4493
  if (error) {
4233
4494
  const outcome = classifyAttemptError(error);
4234
- const budgetExhausted = outcome === "timeout" || outcome === "request_aborted";
4495
+ const interruptionCode = outcome === "timeout" || outcome === "request_aborted" || outcome === "browser_session_interrupted" ? outcome : null;
4235
4496
  let salvaged = null;
4236
- if (budgetExhausted) {
4497
+ if (interruptionCode) {
4237
4498
  try {
4238
4499
  salvaged = typeof extractor.getPartialResult === "function" ? extractor.getPartialResult() : null;
4239
4500
  } catch {
4240
4501
  salvaged = null;
4241
4502
  }
4242
4503
  }
4243
- if (salvaged) {
4244
- return { result: salvaged, error: null, cleanup, debug, salvagedFrom: error };
4504
+ if (salvaged && interruptionCode) {
4505
+ salvaged.diagnostics = {
4506
+ ...salvaged.diagnostics,
4507
+ interruption: {
4508
+ code: interruptionCode,
4509
+ reasonSource: "client_runtime",
4510
+ message: errorMessage(error)
4511
+ }
4512
+ };
4513
+ return { result: salvaged, error, cleanup, debug, salvagedFrom: error };
4245
4514
  }
4246
4515
  return { result: null, error, cleanup, debug };
4247
4516
  }
@@ -4251,6 +4520,7 @@ async function harvest(rawOptions) {
4251
4520
  const raw = typeof rawOptions === "object" && rawOptions !== null ? rawOptions : {};
4252
4521
  const signal = getAbortSignal(rawOptions);
4253
4522
  const onAttemptEvent = getAttemptLogSink(rawOptions);
4523
+ const onPaaProgress = getPaaProgressSink(rawOptions);
4254
4524
  const forceManagedSerp = raw.forceManagedSerp === true;
4255
4525
  const requestedProxyMode = raw.proxyMode;
4256
4526
  const proxyMode = requestedProxyMode === "location" || requestedProxyMode === "none" || requestedProxyMode === "configured" ? requestedProxyMode : DEFAULT_PROXY_MODE;
@@ -4320,7 +4590,7 @@ async function harvest(rawOptions) {
4320
4590
  ...baseCtx,
4321
4591
  forceHeadless: allowHeadlessTest,
4322
4592
  forceHeadful: !allowHeadlessTest
4323
- }, () => extractOnce(attemptOptions, signal, forceManagedSerp)) : await extractOnce(attemptOptions, signal, forceManagedSerp);
4593
+ }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress);
4324
4594
  if (attempt.error) {
4325
4595
  const err = attempt.error;
4326
4596
  const outcome2 = classifyAttemptError(err);
@@ -4353,7 +4623,7 @@ async function harvest(rawOptions) {
4353
4623
  maxAttempts,
4354
4624
  outcome: outcome2,
4355
4625
  kernelSessionId: attempt.cleanup.kernelSessionId,
4356
- questionCount: 0,
4626
+ questionCount: attempt.result?.totalQuestions ?? 0,
4357
4627
  durationMs: Date.now() - startedAtMs,
4358
4628
  error: errorMessage(err),
4359
4629
  willRetry: willRetry2,
@@ -4364,6 +4634,9 @@ async function harvest(rawOptions) {
4364
4634
  await cleanupDisposableProxy(kernelApiKey, resolution2.disposableProxyId);
4365
4635
  lastError = err;
4366
4636
  if (willRetry2) continue;
4637
+ if (attempt.result) {
4638
+ return withResultQuality(stripInternalDebug(attempt.result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4639
+ }
4367
4640
  break;
4368
4641
  }
4369
4642
  const result = attempt.result;
@@ -4397,7 +4670,7 @@ async function harvest(rawOptions) {
4397
4670
  if (willRetry2) continue;
4398
4671
  break;
4399
4672
  }
4400
- const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug));
4673
+ const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4401
4674
  const outcome = classifyAttemptResult(finalResult);
4402
4675
  const willRetry = outcome === "degraded_result" && i < maxAttempts - 1;
4403
4676
  const resultError = outcome === "degraded_result" ? degradedResultMessage(finalResult) : null;