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
@@ -430,6 +430,7 @@ var NANGO_USD_PER_CONNECTION_MONTH = envRate("NANGO_USD_PER_CONNECTION_MONTH", 1
430
430
  var NANGO_USD_PER_FUNCTION_RUN = envRate("NANGO_USD_PER_FUNCTION_RUN", 1e-4);
431
431
  var NANGO_USD_PER_PROXY_REQUEST = envRate("NANGO_USD_PER_PROXY_REQUEST", 1e-4);
432
432
  var NANGO_USD_PER_COMPUTE_SEC = envRate("NANGO_USD_PER_COMPUTE_SEC", 2e-4);
433
+ var BRIGHTDATA_BROWSER_USD_PER_GB = envRate("BRIGHTDATA_BROWSER_USD_PER_GB", 0);
433
434
  function kernelCostUsd(ms, headful) {
434
435
  const sec = Math.max(0, ms) / 1e3;
435
436
  return sec * (headful ? KERNEL_HEADFUL_USD_PER_SEC : KERNEL_HEADLESS_USD_PER_SEC);
@@ -452,7 +453,8 @@ async function runCostTelemetryMigration() {
452
453
  SELECT
453
454
  (SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('kernel_session_log', 'vendor_usage_log', 'cost_probe_runs')) = 3
454
455
  AND (SELECT COUNT(*) FROM pragma_table_info('kernel_session_log') WHERE name IN ('proxy_source', 'proxy_type', 'method')) = 3
455
- AND (SELECT COUNT(*) FROM pragma_table_info('vendor_usage_log') WHERE name = 'method') = 1
456
+ AND (SELECT COUNT(*) FROM pragma_table_info('vendor_usage_log') WHERE name IN ('method', 'source_key')) = 2
457
+ AND (SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'vendor_usage_log_vendor_source_key') = 1
456
458
  AND (SELECT COUNT(*) FROM pragma_table_info('cost_probe_runs') WHERE name IN ('units', 'unit_type', 'mode')) = 3
457
459
  AS ready
458
460
  `);
@@ -508,6 +510,7 @@ async function runCostTelemetryMigration() {
508
510
  unit_type TEXT,
509
511
  est_cost_usd REAL NOT NULL DEFAULT 0,
510
512
  error TEXT,
513
+ source_key TEXT,
511
514
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
512
515
  )
513
516
  `);
@@ -518,6 +521,11 @@ async function runCostTelemetryMigration() {
518
521
  await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN method TEXT`);
519
522
  } catch {
520
523
  }
524
+ try {
525
+ await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN source_key TEXT`);
526
+ } catch {
527
+ }
528
+ 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`);
521
529
  await db.execute(`
522
530
  CREATE TABLE IF NOT EXISTS cost_probe_runs (
523
531
  id TEXT PRIMARY KEY,
@@ -1122,6 +1130,13 @@ var BrowserDriver = class {
1122
1130
  getKernelSessionId() {
1123
1131
  return this.kernelSessionId;
1124
1132
  }
1133
+ getProviderSessionMetadata() {
1134
+ return {
1135
+ provider: this.kernelSessionId ? "kernel" : "local",
1136
+ providerSessionId: this.kernelSessionId,
1137
+ disconnectObservation: null
1138
+ };
1139
+ }
1125
1140
  getDebugSnapshot() {
1126
1141
  return this.debugSnapshot;
1127
1142
  }
@@ -1136,6 +1151,7 @@ var BrowserDriver = class {
1136
1151
  const proxySource = this.kernelProxySource;
1137
1152
  const proxyType = this.kernelProxyType;
1138
1153
  const headlessSent = this.kernelHeadlessSent;
1154
+ const provider = this.getProviderSessionMetadata();
1139
1155
  this.browser = null;
1140
1156
  this.context = null;
1141
1157
  this.page = null;
@@ -1170,7 +1186,11 @@ var BrowserDriver = class {
1170
1186
  kernelDeleteSucceeded: deleteResult.status === "fulfilled",
1171
1187
  kernelDeleteError: deleteResult.status === "rejected" ? deleteResult.reason instanceof Error ? deleteResult.reason.message : String(deleteResult.reason) : null,
1172
1188
  browserCloseSucceeded: closeResult.status === "fulfilled",
1173
- browserCloseError: closeResult.status === "rejected" ? closeResult.reason instanceof Error ? closeResult.reason.message : String(closeResult.reason) : null
1189
+ browserCloseError: closeResult.status === "rejected" ? closeResult.reason instanceof Error ? closeResult.reason.message : String(closeResult.reason) : null,
1190
+ provider: provider.provider,
1191
+ providerSessionId: provider.providerSessionId,
1192
+ providerDisconnectObserved: false,
1193
+ providerDisconnectMessage: null
1174
1194
  };
1175
1195
  if (deleteResult.status === "rejected") {
1176
1196
  console.warn(JSON.stringify({
@@ -1202,7 +1222,11 @@ var BrowserDriver = class {
1202
1222
  kernelDeleteSucceeded: null,
1203
1223
  kernelDeleteError: null,
1204
1224
  browserCloseSucceeded: true,
1205
- browserCloseError: null
1225
+ browserCloseError: null,
1226
+ provider: provider.provider,
1227
+ providerSessionId: provider.providerSessionId,
1228
+ providerDisconnectObserved: false,
1229
+ providerDisconnectMessage: null
1206
1230
  };
1207
1231
  } else if (this.context) {
1208
1232
  const ctx = this.context;
@@ -1215,7 +1239,11 @@ var BrowserDriver = class {
1215
1239
  kernelDeleteSucceeded: null,
1216
1240
  kernelDeleteError: null,
1217
1241
  browserCloseSucceeded: true,
1218
- browserCloseError: null
1242
+ browserCloseError: null,
1243
+ provider: "local",
1244
+ providerSessionId: null,
1245
+ providerDisconnectObserved: false,
1246
+ providerDisconnectMessage: null
1219
1247
  };
1220
1248
  }
1221
1249
  return {
@@ -1224,13 +1252,64 @@ var BrowserDriver = class {
1224
1252
  kernelDeleteSucceeded: null,
1225
1253
  kernelDeleteError: null,
1226
1254
  browserCloseSucceeded: null,
1227
- browserCloseError: null
1255
+ browserCloseError: null,
1256
+ provider: "local",
1257
+ providerSessionId: null,
1258
+ providerDisconnectObserved: false,
1259
+ providerDisconnectMessage: null
1228
1260
  };
1229
1261
  }
1230
1262
  };
1231
1263
 
1232
1264
  // src/driver/BrightDataSerpDriver.ts
1233
1265
  var import_playwright2 = require("playwright");
1266
+
1267
+ // src/driver/browser-provider-telemetry.ts
1268
+ var MAX_PROVIDER_SESSION_ID_LENGTH = 512;
1269
+ var MAX_PROVIDER_MESSAGE_LENGTH = 320;
1270
+ function boundedText(value, maxLength) {
1271
+ if (typeof value !== "string") return null;
1272
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
1273
+ if (!normalized) return null;
1274
+ return normalized.slice(0, maxLength);
1275
+ }
1276
+ function boundedProviderMessage(value) {
1277
+ const message = boundedText(value, MAX_PROVIDER_MESSAGE_LENGTH);
1278
+ if (!message) return null;
1279
+ 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);
1280
+ }
1281
+ function sanitizeProviderLocalMessage(value) {
1282
+ return boundedProviderMessage(value);
1283
+ }
1284
+ function validateProviderSessionId(value) {
1285
+ 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)) {
1286
+ throw new Error("Bright Data did not return a valid browser session ID");
1287
+ }
1288
+ return value;
1289
+ }
1290
+ function makeProviderInterruptionObservation(source, message, observedAt = (/* @__PURE__ */ new Date()).toISOString()) {
1291
+ const safeMessage = boundedProviderMessage(message);
1292
+ return {
1293
+ source,
1294
+ message: safeMessage ?? source.replaceAll("_", " "),
1295
+ observedAt
1296
+ };
1297
+ }
1298
+ function projectProviderSessionPublic(metadata) {
1299
+ return {
1300
+ provider: metadata.provider,
1301
+ providerDisconnectObserved: metadata.disconnectObservation !== null,
1302
+ providerDisconnectMessage: metadata.disconnectObservation?.message ?? null
1303
+ };
1304
+ }
1305
+ function projectProviderSessionPrivate(metadata) {
1306
+ return {
1307
+ ...projectProviderSessionPublic(metadata),
1308
+ providerSessionId: metadata.providerSessionId
1309
+ };
1310
+ }
1311
+
1312
+ // src/driver/BrightDataSerpDriver.ts
1234
1313
  var BLOCKED_TYPES = /* @__PURE__ */ new Set(["image", "media", "font", "stylesheet", "ping"]);
1235
1314
  var BLOCKED_HOST_PARTS = [
1236
1315
  "doubleclick.net",
@@ -1280,8 +1359,19 @@ var BrightDataSerpDriver = class {
1280
1359
  browser = null;
1281
1360
  context = null;
1282
1361
  page = null;
1362
+ cdpSession = null;
1363
+ providerSessionId = null;
1364
+ disconnectObservation = null;
1365
+ closing = false;
1366
+ observeDisconnect(source, message) {
1367
+ if (this.closing || this.disconnectObservation) return;
1368
+ this.disconnectObservation = makeProviderInterruptionObservation(source, message);
1369
+ }
1283
1370
  async launch(_config) {
1284
1371
  if (this.page) return;
1372
+ this.closing = false;
1373
+ this.providerSessionId = null;
1374
+ this.disconnectObservation = null;
1285
1375
  const endpoint = brightDataBrowserEndpoint();
1286
1376
  if (!endpoint) throw new Error("SERP browser service is not configured");
1287
1377
  this.browser = await import_playwright2.chromium.connectOverCDP(endpoint);
@@ -1297,6 +1387,14 @@ var BrightDataSerpDriver = class {
1297
1387
  }
1298
1388
  });
1299
1389
  this.page = this.context.pages()[0] ?? await this.context.newPage();
1390
+ this.browser.on("disconnected", () => this.observeDisconnect("browser_disconnected"));
1391
+ this.context.on("close", () => this.observeDisconnect("context_closed"));
1392
+ this.page.on("close", () => this.observeDisconnect("page_closed"));
1393
+ this.page.on("crash", () => this.observeDisconnect("page_crashed"));
1394
+ this.cdpSession = await this.context.newCDPSession(this.page);
1395
+ const customCdp = this.cdpSession;
1396
+ const sessionResponse = await customCdp.send("Browser.getSessionId");
1397
+ this.providerSessionId = validateProviderSessionId(sessionResponse?.sessionId);
1300
1398
  await this.page.route("**/*", async (route) => {
1301
1399
  const request = route.request();
1302
1400
  const resourceType = request.resourceType();
@@ -1352,23 +1450,38 @@ var BrightDataSerpDriver = class {
1352
1450
  getKernelSessionId() {
1353
1451
  return null;
1354
1452
  }
1453
+ getProviderSessionMetadata() {
1454
+ return {
1455
+ provider: "bright_data_browser_api",
1456
+ providerSessionId: this.providerSessionId,
1457
+ disconnectObservation: this.disconnectObservation
1458
+ };
1459
+ }
1355
1460
  getDebugSnapshot() {
1356
1461
  return { kernel: null, context: null, networkLocation: null, serpNavigation: null };
1357
1462
  }
1358
1463
  async close() {
1464
+ this.closing = true;
1359
1465
  let error = null;
1360
1466
  try {
1361
1467
  await this.browser?.close();
1362
1468
  } catch (caught) {
1363
- error = caught instanceof Error ? caught.message : String(caught);
1469
+ error = sanitizeProviderLocalMessage(caught instanceof Error ? caught.message : String(caught)) ?? "Bright Data browser close failed";
1470
+ this.disconnectObservation ??= makeProviderInterruptionObservation("browser_close_error", error);
1364
1471
  }
1472
+ const provider = projectProviderSessionPrivate(this.getProviderSessionMetadata());
1473
+ this.browser = null;
1474
+ this.context = null;
1475
+ this.page = null;
1476
+ this.cdpSession = null;
1365
1477
  return {
1366
1478
  kernelSessionId: null,
1367
1479
  kernelDeleteStarted: false,
1368
1480
  kernelDeleteSucceeded: null,
1369
1481
  kernelDeleteError: null,
1370
1482
  browserCloseSucceeded: error === null,
1371
- browserCloseError: error
1483
+ browserCloseError: error,
1484
+ ...provider
1372
1485
  };
1373
1486
  }
1374
1487
  };
@@ -2264,12 +2377,14 @@ async function extractAISurfacesFromDocument(config) {
2264
2377
 
2265
2378
  // src/extractor/PAAExtractor.ts
2266
2379
  var PAAExtractor = class {
2267
- constructor(driver, reporter) {
2380
+ constructor(driver, reporter, onPaaProgress) {
2268
2381
  this.driver = driver;
2269
2382
  this.reporter = reporter;
2383
+ this.onPaaProgress = onPaaProgress;
2270
2384
  }
2271
2385
  driver;
2272
2386
  reporter;
2387
+ onPaaProgress;
2273
2388
  completeness = {
2274
2389
  paaWithoutAnswer: 0,
2275
2390
  paaWithoutSource: 0,
@@ -2279,6 +2394,8 @@ var PAAExtractor = class {
2279
2394
  aioShareUrlEarly = null;
2280
2395
  collectedRows = [];
2281
2396
  partialContext = null;
2397
+ progressSequence = 0;
2398
+ clickedQuestions = /* @__PURE__ */ new Set();
2282
2399
  normalizeQuestion(q) {
2283
2400
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
2284
2401
  }
@@ -2315,6 +2432,34 @@ var PAAExtractor = class {
2315
2432
  function cleanSourceLabel(value) {
2316
2433
  return (value ?? "").replace(/\s*\(\+\d+\)\s*-\s*View related links.*$/i, "").replace(/\s*-\s*Opens in new tab.*$/i, "").trim();
2317
2434
  }
2435
+ function isGoogleHost(host) {
2436
+ const normalized = host.toLowerCase().replace(/\.$/, "");
2437
+ 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");
2438
+ }
2439
+ function safeCitationTarget(rawHref) {
2440
+ let parsed;
2441
+ try {
2442
+ parsed = new URL(rawHref);
2443
+ } catch {
2444
+ return null;
2445
+ }
2446
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
2447
+ if (parsed.username || parsed.password) return null;
2448
+ if (isGoogleHost(parsed.hostname)) {
2449
+ if (parsed.pathname !== "/url") return null;
2450
+ const redirected = parsed.searchParams.get("q") ?? parsed.searchParams.get("url");
2451
+ if (!redirected) return null;
2452
+ try {
2453
+ parsed = new URL(redirected);
2454
+ } catch {
2455
+ return null;
2456
+ }
2457
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
2458
+ if (parsed.username || parsed.password) return null;
2459
+ }
2460
+ if (isGoogleHost(parsed.hostname)) return null;
2461
+ return { href: parsed.href, host: parsed.hostname };
2462
+ }
2318
2463
  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) => {
2319
2464
  const clickTarget = pair.querySelector(selectors.clickTarget);
2320
2465
  const expanded = pair.classList.contains(selectors.expandedClass) || clickTarget?.getAttribute("aria-expanded") === "true";
@@ -2336,21 +2481,14 @@ var PAAExtractor = class {
2336
2481
  ...Array.from(anchorRoot.querySelectorAll("a[href]"))
2337
2482
  ];
2338
2483
  for (const a of prioritized) {
2339
- const href = a.href;
2340
- if (!/^https?:\/\//.test(href) || seenHrefs.has(href)) continue;
2341
- let host = "";
2342
- try {
2343
- host = new URL(href).hostname;
2344
- } catch {
2345
- continue;
2346
- }
2347
- if (/(^|\.)google(usercontent)?\.[a-z.]+$/.test(host)) continue;
2348
- seenHrefs.add(href);
2484
+ const target = safeCitationTarget(a.href);
2485
+ if (!target || seenHrefs.has(target.href)) continue;
2486
+ seenHrefs.add(target.href);
2349
2487
  anchors.push({
2350
2488
  text: (a.textContent || "").trim(),
2351
2489
  label: cleanSourceLabel(a.getAttribute("aria-label")),
2352
- href,
2353
- host
2490
+ href: target.href,
2491
+ host: target.host
2354
2492
  });
2355
2493
  }
2356
2494
  const primary = anchors.find((x) => x.label.length > 2 || x.text.length > 2) ?? anchors[0];
@@ -2407,10 +2545,53 @@ var PAAExtractor = class {
2407
2545
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
2408
2546
  };
2409
2547
  }
2548
+ upsertCollectedRow(row) {
2549
+ const key = this.normalizeQuestion(row.question);
2550
+ const index = this.collectedRows.findIndex((existing2) => this.normalizeQuestion(existing2.question) === key);
2551
+ if (index < 0) {
2552
+ this.collectedRows.push({ ...row });
2553
+ return true;
2554
+ }
2555
+ const existing = this.collectedRows[index];
2556
+ const merged = {
2557
+ ...existing,
2558
+ ...row,
2559
+ answer: row.answer || existing.answer,
2560
+ source_title: row.source_title || existing.source_title,
2561
+ source_site: row.source_site || existing.source_site,
2562
+ source_cite: row.source_cite || existing.source_cite,
2563
+ extracted_at: row.extracted_at || existing.extracted_at
2564
+ };
2565
+ const changed = merged.answer !== existing.answer || merged.source_title !== existing.source_title || merged.source_site !== existing.source_site || merged.source_cite !== existing.source_cite;
2566
+ if (changed) this.collectedRows[index] = merged;
2567
+ return changed;
2568
+ }
2569
+ async emitProgress(phase) {
2570
+ if (!this.onPaaProgress || this.collectedRows.length === 0) return;
2571
+ const snapshot = {
2572
+ sequence: ++this.progressSequence,
2573
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
2574
+ clickedQuestions: [...this.clickedQuestions],
2575
+ phase,
2576
+ records: this.collectedRows.map((row) => ({ ...row }))
2577
+ };
2578
+ try {
2579
+ await this.onPaaProgress(snapshot);
2580
+ } catch (err) {
2581
+ console.warn(JSON.stringify({
2582
+ event: "paa_progress_sink_failed",
2583
+ sequence: snapshot.sequence,
2584
+ phase: snapshot.phase,
2585
+ message: err instanceof Error ? err.message : String(err)
2586
+ }));
2587
+ }
2588
+ }
2410
2589
  getPartialResult() {
2411
2590
  const ctx = this.partialContext;
2412
2591
  if (!ctx || this.collectedRows.length === 0) return null;
2413
- const flat = [...this.collectedRows];
2592
+ const flat = this.collectedRows.map((row) => ({ ...row }));
2593
+ const paaWithoutAnswer = ctx.questionsOnly ? 0 : flat.filter((row) => !row.answer?.trim()).length;
2594
+ const paaWithoutSource = ctx.questionsOnly ? 0 : flat.filter((row) => !row.source_cite?.trim()).length;
2414
2595
  const stats = {
2415
2596
  seed: ctx.seed,
2416
2597
  totalQuestions: flat.length,
@@ -2425,7 +2606,17 @@ var PAAExtractor = class {
2425
2606
  diagnostics: {
2426
2607
  completionStatus: "paa_found",
2427
2608
  problem: null,
2428
- completeness: { ...this.completeness },
2609
+ resultQuality: "partial",
2610
+ degradedResult: false,
2611
+ retryRecommended: true,
2612
+ completeness: {
2613
+ ...this.completeness,
2614
+ paaWithoutAnswer,
2615
+ paaWithoutSource,
2616
+ paaRequested: ctx.maxQuestions,
2617
+ paaReturned: flat.length,
2618
+ paaUnique: flat.length
2619
+ },
2429
2620
  warnings: [{
2430
2621
  code: "paa_partial",
2431
2622
  surface: "paa",
@@ -2434,6 +2625,9 @@ var PAAExtractor = class {
2434
2625
  }]
2435
2626
  },
2436
2627
  totalQuestions: flat.length,
2628
+ surface: "web",
2629
+ aiOverview: { detected: false, text: null, citations: [] },
2630
+ aiMode: { detected: false, text: null, citations: [] },
2437
2631
  whatPeopleSaying: [],
2438
2632
  tree: this.buildTree(flat, ctx.seed),
2439
2633
  flat,
@@ -2441,7 +2635,7 @@ var PAAExtractor = class {
2441
2635
  forums: [],
2442
2636
  organicResults: [],
2443
2637
  localPack: [],
2444
- entityIds: {},
2638
+ entityIds: { entities: [], kgIds: [], cids: [], gcids: [] },
2445
2639
  stats
2446
2640
  };
2447
2641
  }
@@ -2451,7 +2645,8 @@ var PAAExtractor = class {
2451
2645
  const seenQs = /* @__PURE__ */ new Set();
2452
2646
  const orderedQs = [];
2453
2647
  this.collectedRows = [];
2454
- const results = this.collectedRows;
2648
+ this.progressSequence = 0;
2649
+ this.clickedQuestions = /* @__PURE__ */ new Set();
2455
2650
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
2456
2651
  const clickedOnceEver = /* @__PURE__ */ new Set();
2457
2652
  const capturedItems = /* @__PURE__ */ new Map();
@@ -2499,12 +2694,17 @@ var PAAExtractor = class {
2499
2694
  if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2500
2695
  this.throwIfAborted(signal);
2501
2696
  const states = await readItemStates();
2697
+ let discovered = false;
2502
2698
  for (const s of states) {
2503
2699
  if (!seenQs.has(s.q)) {
2504
2700
  seenQs.add(s.q);
2505
2701
  orderedQs.push(s.q);
2702
+ if (orderedQs.length <= options.maxQuestions) {
2703
+ discovered = this.upsertCollectedRow(this.toFlatRow({ question: s.q }, 1, null, options.query)) || discovered;
2704
+ }
2506
2705
  }
2507
2706
  }
2707
+ if (discovered) await this.emitProgress("discovered");
2508
2708
  if (options.questionsOnly && seenQs.size >= options.maxQuestions) break;
2509
2709
  const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2510
2710
  const clickable = states.filter((s) => kept.has(s.q) && !clickedOnceEver.has(s.q) && !abandonedNeverReclick.has(s.q));
@@ -2524,6 +2724,7 @@ var PAAExtractor = class {
2524
2724
  this.reporter.onDepth(++round);
2525
2725
  await this.throwIfCaptcha(page, "Google PAA expansion");
2526
2726
  clickedOnceEver.add(target.q);
2727
+ this.clickedQuestions.add(target.q);
2527
2728
  const expansionStatus = await expandOneItemSerially(target.q);
2528
2729
  if (!options.questionsOnly) {
2529
2730
  let clickedItem = (await this.extractVisibleItems(page, target.q))[0];
@@ -2531,7 +2732,12 @@ var PAAExtractor = class {
2531
2732
  await page.waitForTimeout(1300);
2532
2733
  clickedItem = (await this.extractVisibleItems(page, target.q))[0];
2533
2734
  }
2534
- if (clickedItem?.answer) capturedItems.set(target.q, clickedItem);
2735
+ if (clickedItem) {
2736
+ capturedItems.set(target.q, clickedItem);
2737
+ if (this.upsertCollectedRow(this.toFlatRow(clickedItem, 1, null, options.query))) {
2738
+ await this.emitProgress("answer_captured");
2739
+ }
2740
+ }
2535
2741
  }
2536
2742
  if (expansionStatus === "failed" && !capturedItems.has(target.q)) {
2537
2743
  abandonedNeverReclick.add(target.q);
@@ -2547,14 +2753,19 @@ var PAAExtractor = class {
2547
2753
  if (!seenQs.has(state.q)) {
2548
2754
  seenQs.add(state.q);
2549
2755
  orderedQs.push(state.q);
2756
+ if (orderedQs.length <= options.maxQuestions) {
2757
+ this.upsertCollectedRow(this.toFlatRow({ question: state.q }, 1, null, options.query));
2758
+ }
2550
2759
  }
2551
2760
  }
2761
+ await this.emitProgress("discovered");
2552
2762
  }
2553
2763
  }
2554
2764
  const itemMap = options.questionsOnly ? /* @__PURE__ */ new Map() : new Map(capturedItems);
2555
2765
  if (!options.questionsOnly) {
2556
2766
  await this.fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick);
2557
2767
  }
2768
+ const results = [];
2558
2769
  for (const q of orderedQs) {
2559
2770
  if (results.length >= options.maxQuestions) break;
2560
2771
  const key = this.normalizeQuestion(q);
@@ -2562,14 +2773,22 @@ var PAAExtractor = class {
2562
2773
  seenKeys.add(key);
2563
2774
  const item = itemMap.get(q);
2564
2775
  if (item) {
2565
- results.push(this.toFlatRow(item, 1, null, options.query));
2776
+ const row = this.toFlatRow(item, 1, null, options.query);
2777
+ this.upsertCollectedRow(row);
2778
+ results.push(row);
2566
2779
  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: [] });
2567
2780
  } else {
2568
- results.push(this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query));
2781
+ const row = this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query);
2782
+ this.upsertCollectedRow(row);
2783
+ results.push(row);
2569
2784
  }
2570
2785
  }
2571
2786
  this.completeness.paaWithoutAnswer = options.questionsOnly ? 0 : results.filter((r) => !r.answer).length;
2572
- this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_title && !r.source_site && !r.source_cite).length;
2787
+ this.completeness.paaWithoutSource = options.questionsOnly ? 0 : results.filter((r) => !r.source_cite).length;
2788
+ this.completeness.paaRequested = options.maxQuestions;
2789
+ this.completeness.paaReturned = results.length;
2790
+ this.completeness.paaUnique = seenKeys.size;
2791
+ await this.emitProgress("fill_incomplete");
2573
2792
  return results;
2574
2793
  }
2575
2794
  async fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick) {
@@ -2594,6 +2813,10 @@ var PAAExtractor = class {
2594
2813
  sourceSite: item.sourceSite ?? existing?.sourceSite,
2595
2814
  sourceCite: item.sourceCite ?? existing?.sourceCite
2596
2815
  });
2816
+ const upgraded = itemMap.get(q);
2817
+ if (upgraded && this.upsertCollectedRow(this.toFlatRow(upgraded, 1, null, options.query))) {
2818
+ await this.emitProgress("fill_incomplete");
2819
+ }
2597
2820
  }
2598
2821
  this.completeness.paaAnswersRecovered = [...missingAnswersBefore].filter((q) => itemMap.get(q)?.answer).length;
2599
2822
  }
@@ -3058,7 +3281,15 @@ var PAAExtractor = class {
3058
3281
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
3059
3282
  this.aioShareUrlEarly = null;
3060
3283
  this.collectedRows = [];
3061
- this.partialContext = { seed: options.query, location: options.location ?? null, startMs };
3284
+ this.progressSequence = 0;
3285
+ this.clickedQuestions = /* @__PURE__ */ new Set();
3286
+ this.partialContext = {
3287
+ seed: options.query,
3288
+ location: options.location ?? null,
3289
+ startMs,
3290
+ maxQuestions: options.maxQuestions,
3291
+ questionsOnly: options.questionsOnly
3292
+ };
3062
3293
  const isMobile = options.device === "mobile";
3063
3294
  const config = {
3064
3295
  headless: options.headless,
@@ -3130,6 +3361,7 @@ var PAAExtractor = class {
3130
3361
  extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
3131
3362
  diagnostics: {
3132
3363
  completionStatus: hasPaa ? "paa_found" : "no_paa",
3364
+ ...!hasPaa ? { noPaaObserved: true } : {},
3133
3365
  problem: null,
3134
3366
  completeness: { ...this.completeness },
3135
3367
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, void 0, locationResolution) } : {}
@@ -3249,6 +3481,7 @@ var PAAExtractor = class {
3249
3481
  extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
3250
3482
  diagnostics: {
3251
3483
  completionStatus: "no_paa",
3484
+ noPaaObserved: true,
3252
3485
  problem: null,
3253
3486
  completeness: { ...this.completeness },
3254
3487
  ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
@@ -3340,6 +3573,8 @@ var PAAExtractor = class {
3340
3573
  };
3341
3574
  } catch (err) {
3342
3575
  errorCount++;
3576
+ await this.emitProgress("attempt_ending").catch(() => {
3577
+ });
3343
3578
  this.reporter.onError(err instanceof Error ? err : new Error(String(err)));
3344
3579
  throw err;
3345
3580
  }
@@ -4019,6 +4254,11 @@ function getAttemptLogSink(rawOptions) {
4019
4254
  const sink = rawOptions.onAttemptEvent;
4020
4255
  return typeof sink === "function" ? sink : void 0;
4021
4256
  }
4257
+ function getPaaProgressSink(rawOptions) {
4258
+ if (!rawOptions || typeof rawOptions !== "object") return void 0;
4259
+ const sink = rawOptions.onPaaProgress;
4260
+ return typeof sink === "function" ? sink : void 0;
4261
+ }
4022
4262
  async function emitAttemptEvent(sink, event) {
4023
4263
  if (!sink) return;
4024
4264
  try {
@@ -4051,17 +4291,32 @@ function classifyAttemptResult(result) {
4051
4291
  function resultHasUsableContent(result) {
4052
4292
  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;
4053
4293
  }
4054
- function withResultQuality(result) {
4294
+ function withResultQuality(result, requestedPaaCount, questionsOnly) {
4055
4295
  const hasUsableContent = resultHasUsableContent(result);
4056
4296
  const hasWarnings = (result.diagnostics.warnings?.length ?? 0) > 0;
4057
- const resultQuality = hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
4297
+ const uniquePaaCount = new Set(result.flat.map((row) => row.question.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim())).size;
4298
+ const paaWithoutAnswer = questionsOnly ? 0 : result.flat.filter((row) => !row.answer?.trim()).length;
4299
+ const paaWithoutSource = questionsOnly ? 0 : result.flat.filter((row) => !row.source_cite?.trim()).length;
4300
+ const noPaa = result.diagnostics.completionStatus === "no_paa" && (result.diagnostics.noPaaObserved === true || hasUsableContent);
4301
+ const hasPaa = result.diagnostics.completionStatus === "paa_found";
4302
+ const paaIncomplete = hasPaa && (uniquePaaCount < requestedPaaCount || paaWithoutAnswer > 0 || paaWithoutSource > 0 || Boolean(result.diagnostics.interruption));
4303
+ const resultQuality = noPaa ? "complete" : hasPaa && uniquePaaCount > 0 ? paaIncomplete || hasWarnings ? "partial" : "complete" : hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
4058
4304
  const degradedResult = resultQuality === "degraded";
4059
4305
  result.diagnostics = {
4060
4306
  ...result.diagnostics,
4061
4307
  resultQuality,
4062
4308
  degradedResult,
4063
4309
  degradationReasons: degradedResult ? ["empty_primary_serp"] : [],
4064
- retryRecommended: degradedResult
4310
+ retryRecommended: degradedResult || resultQuality === "partial" && (paaIncomplete || result.diagnostics.warnings?.some((warning) => warning.retryable) === true),
4311
+ completeness: {
4312
+ paaWithoutAnswer,
4313
+ paaWithoutSource,
4314
+ paaAnswersRecovered: result.diagnostics.completeness?.paaAnswersRecovered ?? 0,
4315
+ aioShareCaptured: result.diagnostics.completeness?.aioShareCaptured ?? null,
4316
+ paaRequested: requestedPaaCount,
4317
+ paaReturned: result.flat.length,
4318
+ paaUnique: uniquePaaCount
4319
+ }
4065
4320
  };
4066
4321
  return result;
4067
4322
  }
@@ -4121,10 +4376,10 @@ async function cleanupDisposableProxy(kernelApiKey, proxyId) {
4121
4376
  }));
4122
4377
  }
4123
4378
  }
4124
- async function extractOnce(options, signal, forceManagedSerp = false) {
4379
+ async function extractOnce(options, signal, forceManagedSerp = false, onPaaProgress) {
4125
4380
  const driver = createSerpDriver(options, { forceManaged: forceManagedSerp });
4126
4381
  const reporter = new ProgressReporter();
4127
- const extractor = new PAAExtractor(driver, reporter);
4382
+ const extractor = new PAAExtractor(driver, reporter, onPaaProgress);
4128
4383
  if (signal?.aborted) {
4129
4384
  return {
4130
4385
  result: null,
@@ -4176,17 +4431,25 @@ async function extractOnce(options, signal, forceManagedSerp = false) {
4176
4431
  }
4177
4432
  if (error) {
4178
4433
  const outcome = classifyAttemptError(error);
4179
- const budgetExhausted = outcome === "timeout" || outcome === "request_aborted";
4434
+ const interruptionCode = outcome === "timeout" || outcome === "request_aborted" || outcome === "browser_session_interrupted" ? outcome : null;
4180
4435
  let salvaged = null;
4181
- if (budgetExhausted) {
4436
+ if (interruptionCode) {
4182
4437
  try {
4183
4438
  salvaged = typeof extractor.getPartialResult === "function" ? extractor.getPartialResult() : null;
4184
4439
  } catch {
4185
4440
  salvaged = null;
4186
4441
  }
4187
4442
  }
4188
- if (salvaged) {
4189
- return { result: salvaged, error: null, cleanup, debug, salvagedFrom: error };
4443
+ if (salvaged && interruptionCode) {
4444
+ salvaged.diagnostics = {
4445
+ ...salvaged.diagnostics,
4446
+ interruption: {
4447
+ code: interruptionCode,
4448
+ reasonSource: "client_runtime",
4449
+ message: errorMessage(error)
4450
+ }
4451
+ };
4452
+ return { result: salvaged, error, cleanup, debug, salvagedFrom: error };
4190
4453
  }
4191
4454
  return { result: null, error, cleanup, debug };
4192
4455
  }
@@ -4196,6 +4459,7 @@ async function harvest(rawOptions) {
4196
4459
  const raw = typeof rawOptions === "object" && rawOptions !== null ? rawOptions : {};
4197
4460
  const signal = getAbortSignal(rawOptions);
4198
4461
  const onAttemptEvent = getAttemptLogSink(rawOptions);
4462
+ const onPaaProgress = getPaaProgressSink(rawOptions);
4199
4463
  const forceManagedSerp = raw.forceManagedSerp === true;
4200
4464
  const requestedProxyMode = raw.proxyMode;
4201
4465
  const proxyMode = requestedProxyMode === "location" || requestedProxyMode === "none" || requestedProxyMode === "configured" ? requestedProxyMode : DEFAULT_PROXY_MODE;
@@ -4265,7 +4529,7 @@ async function harvest(rawOptions) {
4265
4529
  ...baseCtx,
4266
4530
  forceHeadless: allowHeadlessTest,
4267
4531
  forceHeadful: !allowHeadlessTest
4268
- }, () => extractOnce(attemptOptions, signal, forceManagedSerp)) : await extractOnce(attemptOptions, signal, forceManagedSerp);
4532
+ }, () => extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress)) : await extractOnce(attemptOptions, signal, forceManagedSerp, onPaaProgress);
4269
4533
  if (attempt.error) {
4270
4534
  const err = attempt.error;
4271
4535
  const outcome2 = classifyAttemptError(err);
@@ -4298,7 +4562,7 @@ async function harvest(rawOptions) {
4298
4562
  maxAttempts,
4299
4563
  outcome: outcome2,
4300
4564
  kernelSessionId: attempt.cleanup.kernelSessionId,
4301
- questionCount: 0,
4565
+ questionCount: attempt.result?.totalQuestions ?? 0,
4302
4566
  durationMs: Date.now() - startedAtMs,
4303
4567
  error: errorMessage(err),
4304
4568
  willRetry: willRetry2,
@@ -4309,6 +4573,9 @@ async function harvest(rawOptions) {
4309
4573
  await cleanupDisposableProxy(kernelApiKey, resolution2.disposableProxyId);
4310
4574
  lastError = err;
4311
4575
  if (willRetry2) continue;
4576
+ if (attempt.result) {
4577
+ return withResultQuality(stripInternalDebug(attempt.result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4578
+ }
4312
4579
  break;
4313
4580
  }
4314
4581
  const result = attempt.result;
@@ -4342,7 +4609,7 @@ async function harvest(rawOptions) {
4342
4609
  if (willRetry2) continue;
4343
4610
  break;
4344
4611
  }
4345
- const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug));
4612
+ const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug), attemptOptions.maxQuestions, attemptOptions.questionsOnly);
4346
4613
  const outcome = classifyAttemptResult(finalResult);
4347
4614
  const willRetry = outcome === "degraded_result" && i < maxAttempts - 1;
4348
4615
  const resultError = outcome === "degraded_result" ? degradedResultMessage(finalResult) : null;