mcp-scraper 0.86.5 → 0.87.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.
@@ -50,7 +50,7 @@ import {
50
50
  } from "./chunk-P7FWOMU7.js";
51
51
  import {
52
52
  PACKAGE_VERSION
53
- } from "./chunk-BA6BPDSP.js";
53
+ } from "./chunk-Y3AIFO7C.js";
54
54
  import {
55
55
  PUBLIC_ERROR_CODES,
56
56
  buildPublicErrorEnvelope,
@@ -1126,6 +1126,28 @@ function errorAttemptsSection(body) {
1126
1126
  Attempts:
1127
1127
  ${lines.join("\n")}`;
1128
1128
  }
1129
+ function publicHarvestPagination(value) {
1130
+ const row = structuredRecord(value);
1131
+ const statuses = ["not_requested", "not_attempted", "captured", "unavailable", "failed"];
1132
+ const codes = ["missing_next", "invalid_next", "empty_page", "captcha", "timeout", "navigation_error", "unsupported_driver"];
1133
+ if (row.requestedPages !== 1 && row.requestedPages !== 2 || row.capturedPages !== 1 && row.capturedPages !== 2 || !statuses.includes(row.page2Status) || !Number.isInteger(row.page1OrganicCount) || Number(row.page1OrganicCount) < 0 || !Number.isInteger(row.page2OrganicCount) || Number(row.page2OrganicCount) < 0) return null;
1134
+ const captured = row.page2Status === "captured";
1135
+ if (captured !== (row.capturedPages === 2) || captured && (row.requestedPages !== 2 || Number(row.page2OrganicCount) === 0) || !captured && row.page2OrganicCount !== 0 || row.requestedPages === 1 && row.page2Status !== "not_requested" || row.requestedPages === 2 && row.page2Status === "not_requested" || row.page2Status === "not_attempted" && row.failureCode !== void 0) return null;
1136
+ return {
1137
+ requestedPages: row.requestedPages,
1138
+ capturedPages: row.capturedPages,
1139
+ page2Status: row.page2Status,
1140
+ page1OrganicCount: Number(row.page1OrganicCount),
1141
+ page2OrganicCount: Number(row.page2OrganicCount),
1142
+ ...codes.includes(row.failureCode) ? { failureCode: row.failureCode } : {}
1143
+ };
1144
+ }
1145
+ function harvestPaginationText(pagination) {
1146
+ if (!pagination) return "";
1147
+ const outcome = pagination.page2Status === "captured" ? "Page 2 organic results captured; PAA harvested on the original first page." : pagination.requestedPages === 2 ? `Page 2 ${pagination.page2Status.replaceAll("_", " ")}${pagination.failureCode ? ` (${pagination.failureCode})` : ""}; first-page evidence retained.` : "PAA harvested on the first page.";
1148
+ return `
1149
+ **Pagination:** ${pagination.capturedPages} of ${pagination.requestedPages} requested organic pages captured. ${outcome}`;
1150
+ }
1129
1151
  function publicPaaLifecycle(value) {
1130
1152
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1131
1153
  const row = value;
@@ -1285,6 +1307,7 @@ function formatHarvestPaa(raw, input) {
1285
1307
  const aiOvw = d.aiOverview;
1286
1308
  const diagnostics = d.diagnostics;
1287
1309
  const durationMs = d.stats?.durationMs;
1310
+ const pagination = publicHarvestPagination(diagnostics?.pagination);
1288
1311
  const paaRows = flat.map(
1289
1312
  (r, i) => `| ${i + 1} | ${cell(r.question)} | ${cell(truncate(r.answer, 120))} | ${cell(r.source_title || r.source_site || "")} |`
1290
1313
  ).join("\n");
@@ -1317,7 +1340,7 @@ ${serpRows}` : "";
1317
1340
  - Dig into a result: use \`extract_url\` on any organic URL`;
1318
1341
  const full = `# PAA Report: "${input.query}"${input.location ? ` \xB7 ${input.location}` : ""}
1319
1342
 
1320
- ${paaTable}${serpTable}${entityIdsSection(entityIds)}${aiSection}${statsLine}${debugSection(diagnostics?.debug)}${tips}`;
1343
+ ${paaTable}${harvestPaginationText(pagination)}${serpTable}${entityIdsSection(entityIds)}${aiSection}${statsLine}${debugSection(diagnostics?.debug)}${tips}`;
1321
1344
  return {
1322
1345
  ...oneBlock(full),
1323
1346
  structuredContent: {
@@ -1330,6 +1353,7 @@ ${paaTable}${serpTable}${entityIdsSection(entityIds)}${aiSection}${statsLine}${d
1330
1353
  degradationReasons: diagnostics?.degradationReasons ?? [],
1331
1354
  retryRecommended: diagnostics?.retryRecommended ?? null,
1332
1355
  paaLifecycle: publicPaaLifecycle(diagnostics?.paaLifecycle),
1356
+ pagination,
1333
1357
  questions: flat.map((r) => publicPaaQuestion(r)),
1334
1358
  organicResults: organic.map((r) => publicOrganicResult(r)),
1335
1359
  aiOverview: publicAiOverview(aiOvw),
@@ -1390,6 +1414,7 @@ function durablePaaProgress(result, options) {
1390
1414
  );
1391
1415
  }
1392
1416
  return {
1417
+ pagination: publicHarvestPagination(diagnostics.pagination ?? structuredRecord(rawProgress.material).pagination),
1393
1418
  requestedQuestions: finiteNonNegative(completeness.requestedQuestions ?? options.maxQuestions),
1394
1419
  capturedQuestions: captured,
1395
1420
  answeredQuestions: answered,
@@ -1410,6 +1435,7 @@ function durablePaaResult(result) {
1410
1435
  resultQuality: nullableBoundedString(diagnostics.resultQuality ?? result.resultQuality),
1411
1436
  retryRecommended: typeof diagnostics.retryRecommended === "boolean" ? diagnostics.retryRecommended : typeof result.retryRecommended === "boolean" ? result.retryRecommended : null,
1412
1437
  paaLifecycle: publicPaaLifecycle(diagnostics.paaLifecycle ?? structuredRecord(result.progress).lifecycle),
1438
+ pagination: publicHarvestPagination(diagnostics.pagination ?? structuredRecord(structuredRecord(result.progress).material).pagination),
1413
1439
  questionCount: rows.length,
1414
1440
  questions: rows.map(publicPaaQuestion),
1415
1441
  organicResults: Array.isArray(result.organicResults) ? result.organicResults.filter((row) => row && typeof row === "object").map((row) => publicOrganicResult(row)) : [],
@@ -1521,7 +1547,7 @@ function formatHarvestPaaStatus(raw) {
1521
1547
  return {
1522
1548
  content: [{
1523
1549
  type: "text",
1524
- text: recovering ? `PAA job ${jobId}: recovering automatically after an interrupted worker. ${progress.capturedQuestions ?? 0} questions are preserved; keep polling this same job.` : `PAA job ${jobId}: ${state}. ${progress.capturedQuestions ?? 0} questions are preserved.`
1550
+ text: (recovering ? `PAA job ${jobId}: recovering automatically after an interrupted worker. ${progress.capturedQuestions ?? 0} questions are preserved; keep polling this same job.` : `PAA job ${jobId}: ${state}. ${progress.capturedQuestions ?? 0} questions are preserved.`) + harvestPaginationText(publicHarvestPagination(structuredRecord(result.diagnostics).pagination ?? structuredRecord(structuredRecord(result.progress).material).pagination))
1525
1551
  }],
1526
1552
  structuredContent
1527
1553
  };
@@ -8725,6 +8751,7 @@ var WebsiteUrlOrDomainSchema = z7.string().trim().min(1).transform((raw, ctx) =>
8725
8751
  }
8726
8752
  });
8727
8753
  var HarvestPaaInputSchema = {
8754
+ pages: z7.number().int().min(1).max(2).default(1).describe("Organic result pages to capture. Default 1, maximum 2. Page 2 is captured when available before harvesting PAA on the original first page; it does not add a second PAA graph. Pagination output reports the pages actually captured."),
8728
8755
  query: z7.string().min(1).describe('The search topic, exactly as it should be searched, e.g. "best hvac company in Denver". Include the place here when you want it in the search terms \u2014 the server sends your query to Google unchanged and never adds or removes a location.'),
8729
8756
  location: z7.string().optional().describe('Where Google should think the searcher is, e.g. "Denver, CO". Sets the Google UULE parameter only \u2014 it never changes your query text and never selects a proxy. To put the place in the search terms too, write it into query.'),
8730
8757
  maxQuestions: z7.number().int().min(1).max(200).default(30).describe("PAA questions to extract. Default 30, maximum 200. Use 10 for quick probes, 100-200 for deep research. Billed per extracted question; unused hold refunded."),
@@ -10019,6 +10046,14 @@ var PaaInteractionOutput = z7.object({
10019
10046
  sourceCount: z7.number().int().min(0),
10020
10047
  errorCode: z7.enum(["click_failed", "click_ack_timeout", "control_missing", "confirmation_timeout"]).nullable()
10021
10048
  });
10049
+ var HarvestPaginationOutput = z7.object({
10050
+ requestedPages: z7.union([z7.literal(1), z7.literal(2)]),
10051
+ capturedPages: z7.union([z7.literal(1), z7.literal(2)]),
10052
+ page2Status: z7.enum(["not_requested", "not_attempted", "captured", "unavailable", "failed"]),
10053
+ page1OrganicCount: z7.number().int().min(0),
10054
+ page2OrganicCount: z7.number().int().min(0),
10055
+ failureCode: z7.enum(["missing_next", "invalid_next", "empty_page", "captcha", "timeout", "navigation_error", "unsupported_driver"]).optional()
10056
+ }).nullable();
10022
10057
  var HarvestPaaOutputSchema = {
10023
10058
  query: z7.string(),
10024
10059
  location: NullableString,
@@ -10029,6 +10064,7 @@ var HarvestPaaOutputSchema = {
10029
10064
  degradationReasons: z7.array(z7.string()),
10030
10065
  retryRecommended: z7.boolean().nullable(),
10031
10066
  paaLifecycle: PaaLifecycleOutput,
10067
+ pagination: HarvestPaginationOutput,
10032
10068
  questions: z7.array(PaaQuestionOutput),
10033
10069
  organicResults: z7.array(OrganicResultOutput),
10034
10070
  aiOverview: AiOverviewOutput,
@@ -10036,6 +10072,7 @@ var HarvestPaaOutputSchema = {
10036
10072
  durationMs: z7.number().min(0).nullable()
10037
10073
  };
10038
10074
  var HarvestPaaDurableProgressOutput = z7.object({
10075
+ pagination: HarvestPaginationOutput,
10039
10076
  requestedQuestions: z7.number().int().min(0).nullable(),
10040
10077
  capturedQuestions: z7.number().int().min(0),
10041
10078
  answeredQuestions: z7.number().int().min(0),
@@ -10077,6 +10114,7 @@ var HarvestPaaDurableBillingOutput = z7.object({
10077
10114
  refundMc: z7.number().int().min(0).nullable()
10078
10115
  });
10079
10116
  var HarvestPaaDurableResultOutput = z7.object({
10117
+ pagination: HarvestPaginationOutput,
10080
10118
  completionStatus: NullableString,
10081
10119
  resultQuality: NullableString,
10082
10120
  retryRecommended: z7.boolean().nullable(),
@@ -17286,7 +17324,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
17286
17324
  registerPersonalAssistantMcpSurface(server, executor);
17287
17325
  server.registerTool("harvest_paa", {
17288
17326
  title: "Google PAA + SERP Harvest",
17289
- description: `Expand one Google People Also Ask SERP into questions, answers, every preserved source, AI Overview evidence, ranking URLs, and entity IDs. maxQuestions is a target count, not traversal depth. Results distinguish target_reached, proven frontier_exhausted, interruption, and recovery_exhausted; a failed click or browser timeout is never reported as exhaustion. This compatibility tool waits; use harvest_paa_start plus harvest_paa_status for long runs. Optional SERP modules require their include flags. Use gl and location for regional context. Costs ${PAA_BASE_CREDITS} Credits per harvest plus ${PAA_QUESTION_CREDITS} Credits per question actually returned; unused hold is refunded. After a timeout or unknown response, reuse the same idempotencyKey. Call credits_info for current pricing and balance.`,
17327
+ description: `Expand one Google People Also Ask SERP into questions, answers, every preserved source, AI Overview evidence, ranking URLs, and entity IDs. Set pages to 2 to add the second organic-results page when available; PAA is still expanded once on the preserved first page, and pagination reports what was captured. maxQuestions is a target count, not traversal depth. Results distinguish target_reached, proven frontier_exhausted, interruption, and recovery_exhausted; a failed click or browser timeout is never reported as exhaustion. This compatibility tool waits; use harvest_paa_start plus harvest_paa_status for long runs. Optional SERP modules require their include flags. Use gl and location for regional context. Costs ${PAA_BASE_CREDITS} Credits per harvest plus ${PAA_QUESTION_CREDITS} Credits per question actually returned; unused hold is refunded. After a timeout or unknown response, reuse the same idempotencyKey. Call credits_info for current pricing and balance.`,
17290
17328
  inputSchema: harvestPaaInputSchema,
17291
17329
  outputSchema: recordOutputSchema("harvest_paa", HarvestPaaOutputSchema),
17292
17330
  annotations: liveWebToolAnnotations("Google PAA + SERP Harvest")
@@ -17299,7 +17337,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
17299
17337
  });
17300
17338
  server.registerTool("harvest_paa_start", {
17301
17339
  title: "Start Durable Google PAA Harvest",
17302
- description: `Start a durable Google People Also Ask harvest and return its job receipt. maxQuestions is the requested target count. The job automatically resumes an interrupted serverless worker under the same jobId, idempotency key, and billing hold while preserving checkpoints. Keep one idempotencyKey after a timeout, unknown response, or in-progress reply; replaying it recovers the existing job without a duplicate charge. Poll jobId with harvest_paa_status. Costs ${PAA_BASE_CREDITS} Credits plus ${PAA_QUESTION_CREDITS} per retained question; unused hold is refunded.`,
17340
+ description: `Start a durable Google People Also Ask harvest and return its job receipt. Set pages to 2 to add the second organic-results page when available; PAA is still expanded once on the preserved first page, and pagination survives checkpoint recovery. maxQuestions is the requested target count. The job automatically resumes an interrupted serverless worker under the same jobId, idempotency key, and billing hold while preserving checkpoints. Keep one idempotencyKey after a timeout, unknown response, or in-progress reply; replaying it recovers the existing job without a duplicate charge. Poll jobId with harvest_paa_status. Costs ${PAA_BASE_CREDITS} Credits plus ${PAA_QUESTION_CREDITS} per retained question; unused hold is refunded.`,
17303
17341
  inputSchema: harvestPaaStartInputSchema,
17304
17342
  outputSchema: recordOutputSchema("harvest_paa_start", HarvestPaaStartOutputSchema),
17305
17343
  annotations: { ...liveWebToolAnnotations("Start Durable Google PAA Harvest"), readOnlyHint: false, idempotentHint: true }
@@ -17309,7 +17347,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
17309
17347
  });
17310
17348
  server.registerTool("harvest_paa_status", {
17311
17349
  title: "Check Durable Google PAA Harvest",
17312
- description: "Poll an owner-scoped harvest_paa_start job. Returns the job/operation/task correlation ID, saved progress, automatic-recovery count, target and discovery status, material completeness, recent per-control interaction outcomes with 0.7/1.0/1.4-second confirmation telemetry, provider-session-correlated attempts, terminal rows, and billing. frontier_exhausted means every observed eligible PAA control was processed plus three healthy no-growth confirmations; interruption never means exhaustion. Polling never starts or bills another run.",
17350
+ description: "Poll an owner-scoped harvest_paa_start job. Returns the job/operation/task correlation ID, saved progress, automatic-recovery count, target and discovery status, material completeness, bounded organic-page pagination, recent per-control interaction outcomes with 0.7/1.0/1.4-second confirmation telemetry, provider-session-correlated attempts, terminal rows, and billing. Page-two outcomes describe organic results only; the PAA graph always comes from the preserved first page. frontier_exhausted means every observed eligible PAA control was processed plus three healthy no-growth confirmations; interruption never means exhaustion. Polling never starts or bills another run.",
17313
17351
  inputSchema: HarvestPaaStatusInputSchema,
17314
17352
  outputSchema: recordOutputSchema("harvest_paa_status", HarvestPaaStatusOutputSchema),
17315
17353
  annotations: { ...liveWebToolAnnotations("Check Durable Google PAA Harvest"), idempotentHint: true, openWorldHint: false }
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var PACKAGE_VERSION = "0.86.5";
2
+ var PACKAGE_VERSION = "0.87.0";
3
3
 
4
4
  export {
5
5
  PACKAGE_VERSION
package/dist/index.cjs CHANGED
@@ -395,6 +395,58 @@ var LocationMismatchError = class extends Error {
395
395
  }
396
396
  };
397
397
 
398
+ // src/driver/IBrowserDriver.ts
399
+ async function withManagedTemporaryPage(context, setup, operation, signal) {
400
+ let page;
401
+ let ended = false;
402
+ let timer;
403
+ let rejectStop = () => {
404
+ };
405
+ const aborted = () => signal?.reason instanceof DOMException && signal.reason.name === "TimeoutError" ? signal.reason : new RequestAbortedError();
406
+ const onAbort = () => rejectStop(aborted());
407
+ const stop = new Promise((_, reject) => {
408
+ rejectStop = reject;
409
+ });
410
+ const close = async (target) => {
411
+ let closeTimer;
412
+ try {
413
+ await Promise.race([
414
+ target.close().catch(() => {
415
+ }),
416
+ new Promise((resolve) => {
417
+ closeTimer = setTimeout(resolve, 2e3);
418
+ })
419
+ ]);
420
+ } finally {
421
+ if (closeTimer) clearTimeout(closeTimer);
422
+ }
423
+ };
424
+ if (signal?.aborted) throw aborted();
425
+ signal?.addEventListener("abort", onAbort, { once: true });
426
+ timer = setTimeout(() => rejectStop(new DOMException("Optional pagination timed out", "TimeoutError")), 3e4);
427
+ try {
428
+ return await Promise.race([
429
+ (async () => {
430
+ const created = await context.newPage();
431
+ if (ended) {
432
+ await close(created);
433
+ throw new RequestAbortedError();
434
+ }
435
+ page = created;
436
+ await setup(created);
437
+ if (ended) throw new RequestAbortedError();
438
+ return operation(created);
439
+ })(),
440
+ stop
441
+ ]);
442
+ } finally {
443
+ ended = true;
444
+ if (timer) clearTimeout(timer);
445
+ signal?.removeEventListener("abort", onAbort);
446
+ if (page) await close(page);
447
+ }
448
+ }
449
+
398
450
  // src/api/cost-telemetry.ts
399
451
  var import_node_crypto2 = require("crypto");
400
452
 
@@ -1085,6 +1137,11 @@ var BrowserDriver = class {
1085
1137
  };
1086
1138
  });
1087
1139
  }
1140
+ async withTemporaryPage(operation, signal) {
1141
+ if (!this.context) throw new Error("Browser context is not available");
1142
+ return withManagedTemporaryPage(this.context, async () => {
1143
+ }, operation, signal);
1144
+ }
1088
1145
  async navigateToSERP(query, uule, gl, hl, options) {
1089
1146
  const params = new URLSearchParams({ q: query, gl, hl, pws: "0" });
1090
1147
  if (options?.tbs) params.set("tbs", options.tbs);
@@ -1489,7 +1546,10 @@ var BrightDataSerpDriver = class {
1489
1546
  const customCdp = this.cdpSession;
1490
1547
  const sessionResponse = await customCdp.send("Browser.getSessionId");
1491
1548
  this.providerSessionId = validateProviderSessionId(sessionResponse?.sessionId);
1492
- await this.page.route("**/*", async (route) => {
1549
+ await this.setupPage(this.page);
1550
+ }
1551
+ async setupPage(page) {
1552
+ await page.route("**/*", async (route) => {
1493
1553
  const request = route.request();
1494
1554
  const resourceType = request.resourceType();
1495
1555
  let requestUrl;
@@ -1514,6 +1574,10 @@ var BrightDataSerpDriver = class {
1514
1574
  await route.continue();
1515
1575
  });
1516
1576
  }
1577
+ async withTemporaryPage(operation, signal) {
1578
+ if (!this.context || this.closing) throw new Error("Browser context is not available");
1579
+ return withManagedTemporaryPage(this.context, (page) => this.setupPage(page), operation, signal);
1580
+ }
1517
1581
  async navigateToSERP(query, uule, gl, hl, options) {
1518
1582
  if (!this.page) throw new Error("Browser page is not available");
1519
1583
  const params = new URLSearchParams({ q: query, gl, hl, pws: "0" });
@@ -2489,6 +2553,11 @@ function parsedGoogleGoto(value) {
2489
2553
  if (!url || url.protocol !== "https:" || !isGoogleHost(url.hostname) || url.pathname !== "/goto") return null;
2490
2554
  return url.searchParams.get("url") ? url : null;
2491
2555
  }
2556
+ function hasResolvedGotoDestination(value) {
2557
+ if (value.linkType !== "google_goto_redirect" || value.resolutionStatus !== "resolved" || !parsedGoogleGoto(value.rawUrl) || !value.resolvedUrl) return false;
2558
+ const destination = safeHttpUrl(value.resolvedUrl);
2559
+ return Boolean(destination && !isGoogleHost(destination.hostname) && destination.href === value.resolvedUrl && value.url === destination.href);
2560
+ }
2492
2561
  function questionIdFor(question) {
2493
2562
  const normalized = question.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
2494
2563
  return `paa_${(0, import_node_crypto3.createHash)("sha256").update(normalized).digest("hex").slice(0, 16)}`;
@@ -2705,8 +2774,12 @@ async function resolveHarvestResultGoogleGotoLinks(result, options = {}) {
2705
2774
  }
2706
2775
  async function resolveGoogleOutboundLinks(page, values, options = {}) {
2707
2776
  const byRawUrl = /* @__PURE__ */ new Map();
2708
- for (const value of values) byRawUrl.set(value.rawUrl, value);
2709
- const inputs = [...byRawUrl.values()].filter((value) => value.linkType === "google_goto_redirect");
2777
+ for (const value of values) {
2778
+ const existing = byRawUrl.get(value.rawUrl);
2779
+ if (existing && hasResolvedGotoDestination(existing)) continue;
2780
+ byRawUrl.set(value.rawUrl, value.linkType === "google_goto_redirect" && !hasResolvedGotoDestination(value) ? { ...value, url: value.rawUrl, resolvedUrl: null, resolutionStatus: "unresolved" } : value);
2781
+ }
2782
+ const inputs = [...byRawUrl.values()].filter((value) => value.linkType === "google_goto_redirect" && !hasResolvedGotoDestination(value));
2710
2783
  if (inputs.length === 0) return byRawUrl;
2711
2784
  const deadlineMs = Date.now() + (options.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS);
2712
2785
  const direct = await resolveGoogleGotoUrls(inputs.map((value) => value.rawUrl), options);
@@ -2922,7 +2995,7 @@ var PAAExtractor = class {
2922
2995
  ];
2923
2996
  const unique = new Map(candidates.map((link) => [link.rawUrl, link]));
2924
2997
  let resolverNonGoogleRequestsObserved = 0;
2925
- const resolved = await resolveGoogleOutboundLinks(page, unique.values(), {
2998
+ const resolved = await resolveGoogleOutboundLinks(page, candidates, {
2926
2999
  onNetworkAudit: (audit) => {
2927
3000
  resolverNonGoogleRequestsObserved = audit.nonGoogleRequestsObserved;
2928
3001
  }
@@ -3173,6 +3246,7 @@ var PAAExtractor = class {
3173
3246
  diagnostics: {
3174
3247
  completionStatus: "paa_found",
3175
3248
  problem: null,
3249
+ ...material?.pagination ? { pagination: { ...material.pagination } } : {},
3176
3250
  resultQuality: "partial",
3177
3251
  degradedResult: false,
3178
3252
  retryRecommended: true,
@@ -4137,6 +4211,55 @@ var PAAExtractor = class {
4137
4211
  ...locationEvidence ? { locationEvidence } : {}
4138
4212
  };
4139
4213
  }
4214
+ async captureSecondOrganicPage(page, options, signal) {
4215
+ this.throwIfAborted(signal);
4216
+ if (!this.driver.withTemporaryPage) return { organic: [], status: "failed", failureCode: "unsupported_driver" };
4217
+ let guardedFailure;
4218
+ try {
4219
+ const initial = new URL(page.url());
4220
+ const valid = (raw) => {
4221
+ try {
4222
+ const url = new URL(raw, initial);
4223
+ return initial.origin === "https://www.google.com" && url.origin === initial.origin && !url.username && !url.password && url.pathname === "/search" && url.searchParams.getAll("q").length === 1 && url.searchParams.get("q") === options.query && url.searchParams.getAll("start").length === 1 && url.searchParams.get("start") === "10" && (!url.searchParams.has("num") || url.searchParams.get("num") === "10") && ["gl", "hl"].every((key) => !url.searchParams.has(key) || url.searchParams.getAll(key).length === 1 && url.searchParams.get(key) === options[key]) && ["uule", "tbs", "udm", "tbm", "pws"].every((key) => url.searchParams.getAll(key).length <= 1 && url.searchParams.get(key) === initial.searchParams.get(key));
4224
+ } catch {
4225
+ return false;
4226
+ }
4227
+ };
4228
+ const href = await page.evaluate(() => {
4229
+ const next = document.querySelector('a#pnnext, a[rel="next"]');
4230
+ return next?.getAttribute("href") ?? null;
4231
+ });
4232
+ if (!href) return { organic: [], status: "unavailable", failureCode: "missing_next" };
4233
+ if (!valid(href)) return { organic: [], status: "unavailable", failureCode: "invalid_next" };
4234
+ const organic = await this.driver.withTemporaryPage(async (second) => {
4235
+ await second.route("**/*", async (route) => {
4236
+ const request = route.request();
4237
+ if (request.isNavigationRequest() && request.frame() === second.mainFrame() && !valid(request.url())) {
4238
+ guardedFailure = /^https:\/\/www\.google\.com\/sorry(?:\/|\?)/.test(request.url()) ? "captcha" : "invalid_next";
4239
+ await route.abort("blockedbyclient");
4240
+ } else await route.fallback();
4241
+ });
4242
+ await second.goto(new URL(href, initial).href, { waitUntil: "domcontentloaded", timeout: 2e4 });
4243
+ this.throwIfAborted(signal);
4244
+ await this.throwIfCaptcha(second, "Google SERP page 2");
4245
+ if (!valid(second.url())) {
4246
+ guardedFailure = "invalid_next";
4247
+ throw new Error("Invalid pagination landing");
4248
+ }
4249
+ return this.extractOrganicResults(second);
4250
+ }, signal);
4251
+ this.throwIfAborted(signal);
4252
+ return organic.length > 0 ? { organic: organic.map((row) => ({ ...row, position: row.position + 10 })), status: "captured" } : { organic: [], status: "unavailable", failureCode: "empty_page" };
4253
+ } catch (err) {
4254
+ this.throwIfAborted(signal);
4255
+ if (err instanceof RequestAbortedError) throw err;
4256
+ return {
4257
+ organic: [],
4258
+ status: "failed",
4259
+ failureCode: guardedFailure ?? (err instanceof CaptchaError ? "captcha" : err instanceof Error && (err.name === "TimeoutError" || /timed? ?out/i.test(err.message)) ? "timeout" : "navigation_error")
4260
+ };
4261
+ }
4262
+ }
4140
4263
  async extract(options, signal) {
4141
4264
  const startMs = Date.now();
4142
4265
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
@@ -4340,23 +4463,49 @@ var PAAExtractor = class {
4340
4463
  const initialLocationEvidence = options.debug ? inferSerpLocationEvidence(canonicalLocation, organicResults, localPack) : void 0;
4341
4464
  this.reporter.onVideos(videos);
4342
4465
  this.reporter.onForums(forums);
4466
+ const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
4467
+ let pagination = {
4468
+ requestedPages: (options.pages ?? 1) >= 2 ? 2 : 1,
4469
+ capturedPages: 1,
4470
+ page2Status: (options.pages ?? 1) >= 2 ? "not_attempted" : "not_requested",
4471
+ page1OrganicCount: organicResults.length,
4472
+ page2OrganicCount: 0
4473
+ };
4474
+ let allOrganic = organicResults;
4475
+ this.checkpointMaterial = {
4476
+ surface: aiSurfaces.surface,
4477
+ aiOverview: aiSurfaces.aiOverview,
4478
+ aiMode: aiSurfaces.aiMode,
4479
+ whatPeopleSaying,
4480
+ videos,
4481
+ forums,
4482
+ organicResults: allOrganic,
4483
+ localPack,
4484
+ entityIds,
4485
+ pagination
4486
+ };
4487
+ await this.emitProgress("serp_captured");
4488
+ if (pagination.requestedPages === 2) {
4489
+ const second = await this.captureSecondOrganicPage(page, executionOptions, signal);
4490
+ allOrganic = [...organicResults, ...second.organic];
4491
+ pagination = {
4492
+ ...pagination,
4493
+ capturedPages: second.status === "captured" ? 2 : 1,
4494
+ page2Status: second.status,
4495
+ page2OrganicCount: second.organic.length,
4496
+ ...second.failureCode ? { failureCode: second.failureCode } : {}
4497
+ };
4498
+ this.checkpointMaterial = { ...this.checkpointMaterial, organicResults: allOrganic, pagination };
4499
+ await this.emitProgress("serp_captured");
4500
+ await this.resolveMaterialLinks(page, [], allOrganic, aiSurfaces.aiOverview, aiSurfaces.aiMode);
4501
+ this.checkpointMaterial = { ...this.checkpointMaterial, organicResults: allOrganic };
4502
+ await this.emitProgress("serp_captured");
4503
+ }
4504
+ const locationEvidence = options.debug ? inferSerpLocationEvidence(canonicalLocation, allOrganic, localPack) : initialLocationEvidence;
4343
4505
  if (!hasPaa) {
4344
- let noPaaOrganic = organicResults;
4345
- let locationEvidence2 = initialLocationEvidence;
4346
- if ((options.pages ?? 1) >= 2) {
4347
- const p2params = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", start: "10" });
4348
- if (recencyToTbs(options.recency)) p2params.set("tbs", recencyToTbs(options.recency));
4349
- if (uule) p2params.set("uule", uule);
4350
- await this.driver.navigateTo("https://www.google.com/search?" + p2params.toString());
4351
- await this.throwIfCaptcha(page, "Google SERP page 2");
4352
- const p2organic = await this.extractOrganicResults(page);
4353
- noPaaOrganic = [...organicResults, ...p2organic.map((r) => ({ ...r, position: r.position + 10 }))];
4354
- if (options.debug) {
4355
- locationEvidence2 = inferSerpLocationEvidence(canonicalLocation, noPaaOrganic, localPack);
4356
- }
4357
- }
4358
- const aiSurfaces2 = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
4359
- await this.resolveMaterialLinks(page, [], noPaaOrganic, aiSurfaces2.aiOverview, aiSurfaces2.aiMode);
4506
+ await this.resolveMaterialLinks(page, [], allOrganic, aiSurfaces.aiOverview, aiSurfaces.aiMode);
4507
+ this.checkpointMaterial = { ...this.checkpointMaterial, organicResults: allOrganic };
4508
+ await this.emitProgress("serp_captured");
4360
4509
  const stats2 = {
4361
4510
  seed: executionOptions.query,
4362
4511
  totalQuestions: 0,
@@ -4373,39 +4522,27 @@ var PAAExtractor = class {
4373
4522
  completionStatus: "no_paa",
4374
4523
  noPaaObserved: true,
4375
4524
  problem: null,
4525
+ pagination,
4376
4526
  paaLifecycle: { ...this.paaLifecycle },
4377
4527
  completeness: { ...this.completeness },
4378
4528
  links: { ...this.linkDiagnostics },
4379
- ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2, locationResolution) } : {}
4529
+ ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence, locationResolution) } : {}
4380
4530
  },
4381
4531
  totalQuestions: 0,
4382
- surface: aiSurfaces2.surface,
4383
- aiOverview: aiSurfaces2.aiOverview,
4384
- aiMode: aiSurfaces2.aiMode,
4532
+ surface: aiSurfaces.surface,
4533
+ aiOverview: aiSurfaces.aiOverview,
4534
+ aiMode: aiSurfaces.aiMode,
4385
4535
  whatPeopleSaying,
4386
4536
  tree: [],
4387
4537
  flat: [],
4388
4538
  videos,
4389
4539
  forums,
4390
- organicResults: noPaaOrganic,
4540
+ organicResults: allOrganic,
4391
4541
  localPack,
4392
4542
  entityIds,
4393
4543
  stats: stats2
4394
4544
  };
4395
4545
  }
4396
- const aiSurfaces = includeAiOverview ? await this.extractAISurfaces(page, options) : emptyAiSurfaces;
4397
- this.checkpointMaterial = {
4398
- surface: aiSurfaces.surface,
4399
- aiOverview: aiSurfaces.aiOverview,
4400
- aiMode: aiSurfaces.aiMode,
4401
- whatPeopleSaying,
4402
- videos,
4403
- forums,
4404
- organicResults,
4405
- localPack,
4406
- entityIds
4407
- };
4408
- await this.emitProgress("serp_captured");
4409
4546
  const flat = await this.runBFS(page, executionOptions, signal);
4410
4547
  this.throwIfAborted(signal);
4411
4548
  const shortVidsParams = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", udm: ShortVideoSelectors.udm });
@@ -4426,20 +4563,6 @@ var PAAExtractor = class {
4426
4563
  }
4427
4564
  }
4428
4565
  this.reporter.onVideos(shortVideos);
4429
- let allOrganic = organicResults;
4430
- let locationEvidence = initialLocationEvidence;
4431
- if ((options.pages ?? 1) >= 2) {
4432
- const p2params = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", start: "10" });
4433
- if (recencyToTbs(options.recency)) p2params.set("tbs", recencyToTbs(options.recency));
4434
- if (uule) p2params.set("uule", uule);
4435
- await this.driver.navigateTo("https://www.google.com/search?" + p2params.toString());
4436
- await this.throwIfCaptcha(page, "Google SERP page 2");
4437
- const p2organic = await this.extractOrganicResults(page);
4438
- allOrganic = [...organicResults, ...p2organic.map((r) => ({ ...r, position: r.position + 10 }))];
4439
- if (options.debug) {
4440
- locationEvidence = inferSerpLocationEvidence(canonicalLocation, allOrganic, localPack);
4441
- }
4442
- }
4443
4566
  await this.resolveMaterialLinks(page, flat, allOrganic, aiSurfaces.aiOverview, aiSurfaces.aiMode);
4444
4567
  this.checkpointMaterial = {
4445
4568
  ...this.checkpointMaterial,
@@ -4449,6 +4572,7 @@ var PAAExtractor = class {
4449
4572
  videos: [...videos, ...shortVideos],
4450
4573
  organicResults: allOrganic
4451
4574
  };
4575
+ await this.emitProgress("expansion_finished");
4452
4576
  const allVideos = [...videos, ...shortVideos];
4453
4577
  const tree = this.buildTree(flat, executionOptions.query);
4454
4578
  const stats = {
@@ -4466,6 +4590,7 @@ var PAAExtractor = class {
4466
4590
  diagnostics: {
4467
4591
  completionStatus: "paa_found",
4468
4592
  problem: null,
4593
+ pagination,
4469
4594
  paaLifecycle: { ...this.paaLifecycle },
4470
4595
  completeness: { ...this.completeness },
4471
4596
  links: { ...this.linkDiagnostics },
package/dist/index.d.cts CHANGED
@@ -177,9 +177,19 @@ interface PaaInteractionObservation {
177
177
  errorCode: 'click_failed' | 'click_ack_timeout' | 'control_missing' | 'confirmation_timeout' | null;
178
178
  }
179
179
  type PaaProgressPhase = 'serp_captured' | 'discovered' | 'click_observed' | 'answer_captured' | 'fill_incomplete' | 'expansion_finished' | 'attempt_ending';
180
+ /** Observed organic pagination outcome; page two never expands a separate PAA graph. */
181
+ interface HarvestPaginationDiagnostics {
182
+ requestedPages: 1 | 2;
183
+ capturedPages: 1 | 2;
184
+ page2Status: 'not_requested' | 'not_attempted' | 'captured' | 'unavailable' | 'failed';
185
+ page1OrganicCount: number;
186
+ page2OrganicCount: number;
187
+ failureCode?: 'missing_next' | 'invalid_next' | 'empty_page' | 'captcha' | 'timeout' | 'navigation_error' | 'unsupported_driver';
188
+ }
180
189
  /** Material already observed before PAA expansion completes. It is checkpointed
181
190
  * so a worker interruption cannot discard the SERP that led to the PAA graph. */
182
191
  interface PaaProgressMaterial {
192
+ pagination?: HarvestPaginationDiagnostics;
183
193
  surface: GoogleSurface;
184
194
  aiOverview: AIOverviewResult;
185
195
  aiMode: AIModeResult;
@@ -201,6 +211,7 @@ interface PaaProgressSnapshot {
201
211
  interactions?: PaaInteractionObservation[];
202
212
  }
203
213
  interface HarvestDiagnostics {
214
+ pagination?: HarvestPaginationDiagnostics;
204
215
  completionStatus: HarvestCompletionStatus;
205
216
  /** Set only when the extractor directly observed a readable SERP without a PAA block. */
206
217
  noPaaObserved?: boolean;
package/dist/index.d.ts CHANGED
@@ -177,9 +177,19 @@ interface PaaInteractionObservation {
177
177
  errorCode: 'click_failed' | 'click_ack_timeout' | 'control_missing' | 'confirmation_timeout' | null;
178
178
  }
179
179
  type PaaProgressPhase = 'serp_captured' | 'discovered' | 'click_observed' | 'answer_captured' | 'fill_incomplete' | 'expansion_finished' | 'attempt_ending';
180
+ /** Observed organic pagination outcome; page two never expands a separate PAA graph. */
181
+ interface HarvestPaginationDiagnostics {
182
+ requestedPages: 1 | 2;
183
+ capturedPages: 1 | 2;
184
+ page2Status: 'not_requested' | 'not_attempted' | 'captured' | 'unavailable' | 'failed';
185
+ page1OrganicCount: number;
186
+ page2OrganicCount: number;
187
+ failureCode?: 'missing_next' | 'invalid_next' | 'empty_page' | 'captcha' | 'timeout' | 'navigation_error' | 'unsupported_driver';
188
+ }
180
189
  /** Material already observed before PAA expansion completes. It is checkpointed
181
190
  * so a worker interruption cannot discard the SERP that led to the PAA graph. */
182
191
  interface PaaProgressMaterial {
192
+ pagination?: HarvestPaginationDiagnostics;
183
193
  surface: GoogleSurface;
184
194
  aiOverview: AIOverviewResult;
185
195
  aiMode: AIModeResult;
@@ -201,6 +211,7 @@ interface PaaProgressSnapshot {
201
211
  interactions?: PaaInteractionObservation[];
202
212
  }
203
213
  interface HarvestDiagnostics {
214
+ pagination?: HarvestPaginationDiagnostics;
204
215
  completionStatus: HarvestCompletionStatus;
205
216
  /** Set only when the extractor directly observed a readable SERP without a PAA block. */
206
217
  noPaaObserved?: boolean;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  harvest
3
- } from "./chunk-NH4QAH3X.js";
3
+ } from "./chunk-R2HRN2FW.js";
4
4
  import "./chunk-OM7HVEJ3.js";
5
5
  import {
6
6
  recordVendorUsage