mcp-scraper 0.40.1 → 0.40.2

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 (87) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/api-server.cjs +60220 -0
  3. package/dist/bin/api-server.cjs.map +1 -0
  4. package/dist/bin/api-server.d.cts +1 -0
  5. package/dist/bin/api-server.d.ts +1 -0
  6. package/dist/bin/api-server.js +38 -0
  7. package/dist/bin/api-server.js.map +1 -0
  8. package/dist/bin/mcp-scraper-cli.cjs +2671 -0
  9. package/dist/bin/mcp-scraper-cli.cjs.map +1 -0
  10. package/dist/bin/mcp-scraper-cli.d.cts +1 -0
  11. package/dist/bin/mcp-scraper-cli.d.ts +1 -0
  12. package/dist/bin/mcp-scraper-cli.js +742 -0
  13. package/dist/bin/mcp-scraper-cli.js.map +1 -0
  14. package/dist/bin/mcp-scraper-install.cjs +129 -0
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -0
  16. package/dist/bin/mcp-scraper-install.d.cts +1 -0
  17. package/dist/bin/mcp-scraper-install.d.ts +1 -0
  18. package/dist/bin/mcp-scraper-install.js +27 -0
  19. package/dist/bin/mcp-scraper-install.js.map +1 -0
  20. package/dist/bin/mcp-stdio-server.cjs +12532 -0
  21. package/dist/bin/mcp-stdio-server.cjs.map +1 -0
  22. package/dist/bin/mcp-stdio-server.d.cts +1 -0
  23. package/dist/bin/mcp-stdio-server.d.ts +1 -0
  24. package/dist/bin/mcp-stdio-server.js +135 -0
  25. package/dist/bin/mcp-stdio-server.js.map +1 -0
  26. package/dist/bin/paa-harvest.cjs +3808 -0
  27. package/dist/bin/paa-harvest.cjs.map +1 -0
  28. package/dist/bin/paa-harvest.d.cts +1 -0
  29. package/dist/bin/paa-harvest.d.ts +1 -0
  30. package/dist/bin/paa-harvest.js +44 -0
  31. package/dist/bin/paa-harvest.js.map +1 -0
  32. package/dist/chunk-345BQXZH.js +712 -0
  33. package/dist/chunk-345BQXZH.js.map +1 -0
  34. package/dist/chunk-3PIWJS6Y.js +276 -0
  35. package/dist/chunk-3PIWJS6Y.js.map +1 -0
  36. package/dist/chunk-44HZLHDV.js +52 -0
  37. package/dist/chunk-44HZLHDV.js.map +1 -0
  38. package/dist/chunk-7XBBFBYY.js +3410 -0
  39. package/dist/chunk-7XBBFBYY.js.map +1 -0
  40. package/dist/chunk-CB5C3BPB.js +135 -0
  41. package/dist/chunk-CB5C3BPB.js.map +1 -0
  42. package/dist/chunk-FQI5PFE7.js +1866 -0
  43. package/dist/chunk-FQI5PFE7.js.map +1 -0
  44. package/dist/chunk-G3P3ZDB4.js +69 -0
  45. package/dist/chunk-G3P3ZDB4.js.map +1 -0
  46. package/dist/chunk-GUVKHCKE.js +3050 -0
  47. package/dist/chunk-GUVKHCKE.js.map +1 -0
  48. package/dist/chunk-K443GQY5.js +24 -0
  49. package/dist/chunk-K443GQY5.js.map +1 -0
  50. package/dist/chunk-LP6E462I.js +11555 -0
  51. package/dist/chunk-LP6E462I.js.map +1 -0
  52. package/dist/chunk-NVXNEOUQ.js +158 -0
  53. package/dist/chunk-NVXNEOUQ.js.map +1 -0
  54. package/dist/chunk-QZXKQB7Y.js +414 -0
  55. package/dist/chunk-QZXKQB7Y.js.map +1 -0
  56. package/dist/chunk-SBLGBZZB.js +647 -0
  57. package/dist/chunk-SBLGBZZB.js.map +1 -0
  58. package/dist/chunk-X2LKCX6H.js +499 -0
  59. package/dist/chunk-X2LKCX6H.js.map +1 -0
  60. package/dist/chunk-XELSA2MS.js +108 -0
  61. package/dist/chunk-XELSA2MS.js.map +1 -0
  62. package/dist/chunk-XORPNO3Z.js +684 -0
  63. package/dist/chunk-XORPNO3Z.js.map +1 -0
  64. package/dist/chunk-ZUJLSICT.js +7 -0
  65. package/dist/chunk-ZUJLSICT.js.map +1 -0
  66. package/dist/db-W3CP562I.js +239 -0
  67. package/dist/db-W3CP562I.js.map +1 -0
  68. package/dist/editorial-reading-room/assets/app.js +335 -0
  69. package/dist/editorial-reading-room/assets/index.html +131 -0
  70. package/dist/editorial-reading-room/assets/styles.css +1052 -0
  71. package/dist/extract-bundle-K4PG3RZJ.js +568 -0
  72. package/dist/extract-bundle-K4PG3RZJ.js.map +1 -0
  73. package/dist/index.cjs +4160 -0
  74. package/dist/index.cjs.map +1 -0
  75. package/dist/index.d.cts +413 -0
  76. package/dist/index.d.ts +413 -0
  77. package/dist/index.js +338 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/location-data-repository-RLQX6SNM.js +35 -0
  80. package/dist/location-data-repository-RLQX6SNM.js.map +1 -0
  81. package/dist/server-KM3CFWCF.js +34674 -0
  82. package/dist/server-KM3CFWCF.js.map +1 -0
  83. package/dist/site-extract-repository-2SMMFKKL.js +62 -0
  84. package/dist/site-extract-repository-2SMMFKKL.js.map +1 -0
  85. package/dist/worker-FXAGFYOE.js +142 -0
  86. package/dist/worker-FXAGFYOE.js.map +1 -0
  87. package/package.json +4 -2
package/dist/index.cjs ADDED
@@ -0,0 +1,4160 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ VideoGenerator: () => VideoGenerator,
34
+ buildClipPrompts: () => buildClipPrompts,
35
+ harvest: () => harvest
36
+ });
37
+ module.exports = __toCommonJS(src_exports);
38
+
39
+ // src/schemas.ts
40
+ var import_zod = require("zod");
41
+ var DEFAULT_PROXY_MODE = "none";
42
+ var DEFAULT_MAPS_PROXY_MODE = "none";
43
+ var HarvestOptionsSchema = import_zod.z.object({
44
+ query: import_zod.z.string().min(1),
45
+ location: import_zod.z.string().optional(),
46
+ gl: import_zod.z.string().length(2).default("us"),
47
+ hl: import_zod.z.string().length(2).default("en"),
48
+ device: import_zod.z.enum(["desktop", "mobile"]).default("desktop"),
49
+ proxyMode: import_zod.z.enum(["location", "configured", "none"]).default(DEFAULT_PROXY_MODE),
50
+ proxyZip: import_zod.z.string().regex(/^\d{5}$/).optional(),
51
+ keepDefaultProxy: import_zod.z.boolean().optional(),
52
+ requireUsEgress: import_zod.z.boolean().optional(),
53
+ maxAttempts: import_zod.z.number().int().min(1).max(12).optional(),
54
+ debug: import_zod.z.boolean().default(false),
55
+ depth: import_zod.z.number().int().min(1).max(30).default(3),
56
+ maxQuestions: import_zod.z.number().int().min(1).max(1e3).default(100),
57
+ headless: import_zod.z.boolean().default(false),
58
+ profileDir: import_zod.z.string().optional(),
59
+ proxy: import_zod.z.string().url().optional(),
60
+ kernelApiKey: import_zod.z.string().optional(),
61
+ kernelProxyId: import_zod.z.string().optional(),
62
+ kernelProxyResolution: import_zod.z.unknown().optional(),
63
+ outputDir: import_zod.z.string().default("./paa-output"),
64
+ format: import_zod.z.enum(["json", "csv", "both"]).default("both"),
65
+ serpOnly: import_zod.z.boolean().default(false),
66
+ pages: import_zod.z.number().int().min(1).max(2).default(1),
67
+ recency: import_zod.z.enum(["day", "week", "month", "year"]).optional(),
68
+ softDeadlineMs: import_zod.z.number().optional()
69
+ });
70
+ function recencyToTbs(recency) {
71
+ switch (recency) {
72
+ case "day":
73
+ return "qdr:d";
74
+ case "week":
75
+ return "qdr:w";
76
+ case "month":
77
+ return "qdr:m";
78
+ case "year":
79
+ return "qdr:y";
80
+ default:
81
+ return void 0;
82
+ }
83
+ }
84
+ var MapsPlaceOptionsSchema = import_zod.z.object({
85
+ businessName: import_zod.z.string().min(1),
86
+ location: import_zod.z.string().min(1),
87
+ gl: import_zod.z.string().length(2).default("us"),
88
+ hl: import_zod.z.string().length(2).default("en"),
89
+ includeReviews: import_zod.z.boolean().default(false),
90
+ maxReviews: import_zod.z.number().int().min(1).max(500).default(50),
91
+ includeServices: import_zod.z.boolean().default(false),
92
+ kernelApiKey: import_zod.z.string().optional(),
93
+ kernelProxyId: import_zod.z.string().optional(),
94
+ headless: import_zod.z.boolean().default(true)
95
+ });
96
+ var MapsSearchOptionsSchema = import_zod.z.object({
97
+ query: import_zod.z.string().min(1),
98
+ location: import_zod.z.string().optional(),
99
+ gl: import_zod.z.string().length(2).default("us"),
100
+ hl: import_zod.z.string().length(2).default("en"),
101
+ maxResults: import_zod.z.number().int().min(1).max(50).default(10),
102
+ includeServices: import_zod.z.boolean().default(false),
103
+ proxyMode: import_zod.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE),
104
+ proxyZip: import_zod.z.string().regex(/^\d{5}$/).optional(),
105
+ serpRedirect: import_zod.z.boolean().optional(),
106
+ forceDirectEgress: import_zod.z.boolean().optional(),
107
+ debug: import_zod.z.boolean().default(false),
108
+ kernelApiKey: import_zod.z.string().optional(),
109
+ kernelProxyId: import_zod.z.string().optional(),
110
+ kernelProxyResolution: import_zod.z.unknown().optional(),
111
+ headless: import_zod.z.boolean().default(true)
112
+ });
113
+ var RawPAAItemSchema = import_zod.z.object({
114
+ question: import_zod.z.string().min(1),
115
+ answer: import_zod.z.string().optional(),
116
+ sourceTitle: import_zod.z.string().optional(),
117
+ sourceSite: import_zod.z.string().optional(),
118
+ sourceCite: import_zod.z.string().optional()
119
+ });
120
+ var RawMapsOverviewSchema = import_zod.z.object({
121
+ name: import_zod.z.string().nullable(),
122
+ rating: import_zod.z.string().nullable(),
123
+ reviewCount: import_zod.z.string().nullable(),
124
+ category: import_zod.z.string().nullable(),
125
+ address: import_zod.z.string().nullable(),
126
+ hoursSummary: import_zod.z.string().nullable(),
127
+ phone: import_zod.z.string().nullable(),
128
+ phoneDisplay: import_zod.z.string().nullable(),
129
+ website: import_zod.z.string().nullable(),
130
+ plusCode: import_zod.z.string().nullable(),
131
+ bookingUrl: import_zod.z.string().nullable()
132
+ });
133
+ var RawMapsHoursRowSchema = import_zod.z.object({
134
+ day: import_zod.z.string(),
135
+ hours: import_zod.z.string()
136
+ });
137
+ var RawMapsReviewStatsSchema = import_zod.z.object({
138
+ reviewHistogram: import_zod.z.array(import_zod.z.object({
139
+ stars: import_zod.z.number(),
140
+ count: import_zod.z.string()
141
+ })),
142
+ reviewTopics: import_zod.z.array(import_zod.z.object({
143
+ label: import_zod.z.string(),
144
+ count: import_zod.z.string()
145
+ }))
146
+ });
147
+ var RawMapsReviewCardSchema = import_zod.z.object({
148
+ reviewId: import_zod.z.string(),
149
+ author: import_zod.z.string().nullable(),
150
+ stars: import_zod.z.string().nullable(),
151
+ date: import_zod.z.string().nullable(),
152
+ text: import_zod.z.string().nullable(),
153
+ ownerResponse: import_zod.z.string().nullable()
154
+ });
155
+ var RawMapsAboutAttributeSchema = import_zod.z.object({
156
+ section: import_zod.z.string(),
157
+ attribute: import_zod.z.string()
158
+ });
159
+
160
+ // src/lib/browser-service-env.ts
161
+ function browserServiceApiKey() {
162
+ const value = (process.env.BROWSER_SERVICE_API_KEY ?? process.env.KERNEL_API_KEY)?.trim();
163
+ return value || void 0;
164
+ }
165
+ function browserServiceProxyId() {
166
+ return void 0;
167
+ }
168
+
169
+ // src/driver/BrowserDriver.ts
170
+ var import_playwright_extra = require("playwright-extra");
171
+ var import_puppeteer_extra_plugin_stealth = __toESM(require("puppeteer-extra-plugin-stealth"), 1);
172
+ var import_playwright = require("playwright");
173
+ var import_sdk = __toESM(require("@onkernel/sdk"), 1);
174
+
175
+ // src/selectors.ts
176
+ var PAASelectors = {
177
+ container: ".eJH8qe.adDDi",
178
+ dataInitq: "[data-initq]",
179
+ item: ".related-question-pair",
180
+ itemDataQ: "data-q",
181
+ itemDataInitQ: "data-initq",
182
+ itemQuestionEl: ".JlqpRe",
183
+ answerContainer: ".bCOlv, .hgKElc, .wDYxhc, .LGOjhe, .fo7IQd, .fmW3u",
184
+ sourceTitle: "h3",
185
+ sourceSite: ".VuuXrf",
186
+ sourceCite: "cite",
187
+ clickTarget: ".JlqpRe",
188
+ expandedClass: "aoRk1c",
189
+ captchaMarker: '#captcha-form, #recaptcha, form[action*="/sorry/"], .g-recaptcha, [data-sitekey]'
190
+ };
191
+ var VideoSelectors = {
192
+ container: 'div[jscontroller="HWk0Gf"]',
193
+ sectionHeading: '.mgAbYb[role="heading"]',
194
+ item: "a.rIRoqf"
195
+ };
196
+ var ShortVideoSelectors = {
197
+ udm: "39",
198
+ item: "a.rIRoqf",
199
+ durationPattern: /^\d+:\d+$/,
200
+ platforms: ["YouTube", "TikTok", "Instagram", "Facebook", "X"]
201
+ };
202
+ var ForumSelectors = {
203
+ section: ".ULSxyf",
204
+ item: "a.KYg7td.INpicf",
205
+ title: ".hyYc0c",
206
+ source: ".K4ETW"
207
+ };
208
+ var WhatPeopleSayingSelectors = {
209
+ sectionTag: "g-section-with-header",
210
+ sectionHeadingText: "What people are saying",
211
+ card: '.dRzkFf[role="listitem"]',
212
+ cardLink: 'a.WlydOe[jsname="YKoRaf"]',
213
+ titleH1: "h1.WQWxe",
214
+ titleDiv: ".eAaXgc",
215
+ popularCommentLabel: ".qgdis",
216
+ source: ".sTl1Td",
217
+ platformBadge: ".appd0, .KrMNbf",
218
+ ytChannel: ".sjVJQd",
219
+ ytDate: ".PLq9Je",
220
+ authorNote: ".nDgy9d"
221
+ };
222
+ var AIOverviewSelectors = {
223
+ root: "[data-lhcontainer][data-streaming-container][eid]",
224
+ legacyRoot: '[data-hveid="CBMQAA"]',
225
+ wrapper: ".Fgyi2e",
226
+ controller: '[jscontroller="AkrxPe"]',
227
+ contentSubtree: '[data-subtree="mfc"]',
228
+ header: ".heWuVc",
229
+ heading: ".Fzsovc.cwYVJe.RJPOee",
230
+ showMoreButton: '[aria-label="Show more AI Overview"]',
231
+ sourcesPanel: ".OZ9ddf.WAUd4",
232
+ disclaimer: ".DuQANe.MSJHRb"
233
+ };
234
+ var AIModeSelectors = {
235
+ root: '[data-hveid="CAUQAA"]',
236
+ wrapper: ".Fgyi2e",
237
+ citations: '.Fgyi2e [data-hveid] a[jsname="pxBnId"]'
238
+ };
239
+ var OrganicSelectors = {
240
+ result: ".wHYlTd.tF2Cxc",
241
+ title: "h3.LC20lb",
242
+ siteName: ".VuuXrf",
243
+ cite: "cite.tjvcx",
244
+ snippet: ".VwiC3b",
245
+ redditCite: "cite.qLRx3b",
246
+ ratingWrap: ".Y0A0hc",
247
+ ratingValue: ".yi40Hd",
248
+ reviewCount: ".RDApEe"
249
+ };
250
+ var LocalPackSelectors = {
251
+ headingText: "Businesses",
252
+ card: ".w7Dbne",
253
+ name: ".OSrXXb",
254
+ ratingValue: ".yi40Hd",
255
+ reviewCount: ".RDApEe"
256
+ };
257
+
258
+ // src/errors.ts
259
+ var RECAPTCHA_INSTRUCTIONS = "Google returned a CAPTCHA. Run with --headless=false to re-warm the browser profile, then retry.";
260
+ function sanitizeVendorName(message) {
261
+ return message.replace(/kernel\.sh\s+sessions?/gi, "sessions").replace(/kernel\.sh\s+session/gi, "this session").replace(/kernel\.sh/gi, "the service").replace(/kernel\s+sessions?/gi, "sessions").replace(/kernel\s+session/gi, "this session").replace(/\bkernel\b/gi, "the service").replace(/ +/g, " ").trim();
262
+ }
263
+ var CaptchaError = class extends Error {
264
+ constructor(instructions) {
265
+ super(`CAPTCHA detected. ${instructions}`);
266
+ this.instructions = instructions;
267
+ }
268
+ instructions;
269
+ name = "CaptchaError";
270
+ };
271
+ var ExtractionError = class extends Error {
272
+ constructor(message, cause) {
273
+ super(message);
274
+ this.cause = cause;
275
+ }
276
+ cause;
277
+ name = "ExtractionError";
278
+ };
279
+ var RequestAbortedError = class extends Error {
280
+ name = "RequestAbortedError";
281
+ constructor(message = "Request aborted before harvest completed") {
282
+ super(message);
283
+ }
284
+ };
285
+ var LocationMismatchError = class extends Error {
286
+ name = "LocationMismatchError";
287
+ constructor(message = "Google returned results for a different location than requested") {
288
+ super(message);
289
+ }
290
+ };
291
+
292
+ // src/api/cost-telemetry.ts
293
+ var import_node_crypto2 = require("crypto");
294
+
295
+ // src/api/db.ts
296
+ var import_http = require("@libsql/client/http");
297
+ var import_node_crypto = require("crypto");
298
+ var import_zod2 = require("zod");
299
+ var DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
300
+ var DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
301
+ var _db = null;
302
+ function getDb() {
303
+ if (!_db) _db = (0, import_http.createClient)({ url: DB_URL, authToken: DB_TOKEN });
304
+ return _db;
305
+ }
306
+ var REQUEST_EVENT_RESULT_MAX_BYTES = 32 * 1024;
307
+ var SiteAuditJobRowSchema = import_zod2.z.object({
308
+ id: import_zod2.z.string(),
309
+ user_id: import_zod2.z.number(),
310
+ status: import_zod2.z.string().default("pending"),
311
+ client_domain: import_zod2.z.string(),
312
+ session_path: import_zod2.z.string(),
313
+ request: import_zod2.z.string(),
314
+ result: import_zod2.z.string().nullable(),
315
+ error: import_zod2.z.string().nullable(),
316
+ created_at: import_zod2.z.string(),
317
+ updated_at: import_zod2.z.string()
318
+ });
319
+ var SiteAuditPhaseLogRowSchema = import_zod2.z.object({
320
+ id: import_zod2.z.number(),
321
+ job_id: import_zod2.z.string(),
322
+ phase: import_zod2.z.string(),
323
+ completed_at: import_zod2.z.string(),
324
+ output_summary: import_zod2.z.string()
325
+ });
326
+
327
+ // src/api/cost-context.ts
328
+ var import_node_async_hooks = require("async_hooks");
329
+ var storage = new import_node_async_hooks.AsyncLocalStorage();
330
+ function runWithCostContext(ctx, fn) {
331
+ return storage.run(ctx, fn);
332
+ }
333
+ function currentCostContext() {
334
+ return storage.getStore();
335
+ }
336
+
337
+ // src/api/cost-rates.ts
338
+ var KERNEL_HEADLESS_USD_PER_SEC = 166667e-10;
339
+ var KERNEL_HEADFUL_USD_PER_SEC = 1333336e-10;
340
+ var HEADLESS_OPS = /* @__PURE__ */ new Set(["serp", "fb_search", "fb_ad", "instagram"]);
341
+ var FAL_WIZPER_USD_PER_COMPUTE_SEC = 111e-5;
342
+ var FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR = 4;
343
+ var DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS = 4e-4;
344
+ function envRate(name, fallback) {
345
+ const raw = process.env[name]?.trim();
346
+ if (!raw) return fallback;
347
+ const parsed = Number(raw);
348
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
349
+ }
350
+ var NANGO_USD_PER_CONNECTION_MONTH = envRate("NANGO_USD_PER_CONNECTION_MONTH", 1);
351
+ var NANGO_USD_PER_FUNCTION_RUN = envRate("NANGO_USD_PER_FUNCTION_RUN", 1e-4);
352
+ var NANGO_USD_PER_PROXY_REQUEST = envRate("NANGO_USD_PER_PROXY_REQUEST", 1e-4);
353
+ var NANGO_USD_PER_COMPUTE_SEC = envRate("NANGO_USD_PER_COMPUTE_SEC", 2e-4);
354
+ function kernelCostUsd(ms, headful) {
355
+ const sec = Math.max(0, ms) / 1e3;
356
+ return sec * (headful ? KERNEL_HEADFUL_USD_PER_SEC : KERNEL_HEADLESS_USD_PER_SEC);
357
+ }
358
+ function vendorCostUsd(vendor, units) {
359
+ if (vendor === "fal_wizper") {
360
+ const estimatedComputeSec = Math.max(0, units) / FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR;
361
+ return estimatedComputeSec * FAL_WIZPER_USD_PER_COMPUTE_SEC;
362
+ }
363
+ if (vendor === "deepinfra_qwen") return Math.max(0, units) / 1e3 * DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS;
364
+ if (vendor === "openrouter") return Math.max(0, units);
365
+ if (vendor === "mcp_memory_video") return Math.max(0, units);
366
+ if (vendor === "mcp_memory_ai") return Math.max(0, units);
367
+ if (vendor === "nango_connection") return Math.max(0, units) * NANGO_USD_PER_CONNECTION_MONTH;
368
+ if (vendor === "nango_function_run") return Math.max(0, units) * NANGO_USD_PER_FUNCTION_RUN;
369
+ if (vendor === "nango_proxy_request") return Math.max(0, units) * NANGO_USD_PER_PROXY_REQUEST;
370
+ if (vendor === "nango_compute") return Math.max(0, units) * NANGO_USD_PER_COMPUTE_SEC;
371
+ return 0;
372
+ }
373
+
374
+ // src/api/cost-telemetry.ts
375
+ var _ready = false;
376
+ var _migrationPromise = null;
377
+ async function migrateCostTelemetry() {
378
+ if (_ready) return;
379
+ if (_migrationPromise) return _migrationPromise;
380
+ _migrationPromise = runCostTelemetryMigration().finally(() => {
381
+ _migrationPromise = null;
382
+ });
383
+ return _migrationPromise;
384
+ }
385
+ async function runCostTelemetryMigration() {
386
+ const db = getDb();
387
+ const existing = await db.execute(`
388
+ SELECT
389
+ (SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('kernel_session_log', 'vendor_usage_log', 'cost_probe_runs')) = 3
390
+ AND (SELECT COUNT(*) FROM pragma_table_info('kernel_session_log') WHERE name IN ('proxy_source', 'proxy_type', 'method')) = 3
391
+ AND (SELECT COUNT(*) FROM pragma_table_info('vendor_usage_log') WHERE name = 'method') = 1
392
+ AND (SELECT COUNT(*) FROM pragma_table_info('cost_probe_runs') WHERE name IN ('units', 'unit_type', 'mode')) = 3
393
+ AS ready
394
+ `);
395
+ if (Number(existing.rows[0]?.ready ?? 0) === 1) {
396
+ _ready = true;
397
+ return;
398
+ }
399
+ await db.execute(`
400
+ CREATE TABLE IF NOT EXISTS kernel_session_log (
401
+ id TEXT PRIMARY KEY,
402
+ kernel_session_id TEXT,
403
+ op TEXT,
404
+ source TEXT NOT NULL,
405
+ probe_run_id TEXT,
406
+ user_id INTEGER,
407
+ stealth INTEGER,
408
+ headless_sent INTEGER,
409
+ proxy_used INTEGER,
410
+ fallback INTEGER NOT NULL DEFAULT 0,
411
+ opened_at TEXT,
412
+ closed_at TEXT,
413
+ duration_ms INTEGER NOT NULL DEFAULT 0,
414
+ est_cost_usd_headless REAL NOT NULL DEFAULT 0,
415
+ est_cost_usd_headful REAL NOT NULL DEFAULT 0,
416
+ error TEXT,
417
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
418
+ )
419
+ `);
420
+ await db.execute(`CREATE INDEX IF NOT EXISTS kernel_session_log_op ON kernel_session_log(op)`);
421
+ await db.execute(`CREATE INDEX IF NOT EXISTS kernel_session_log_probe ON kernel_session_log(probe_run_id)`);
422
+ await db.execute(`CREATE INDEX IF NOT EXISTS kernel_session_log_created ON kernel_session_log(created_at)`);
423
+ try {
424
+ await db.execute(`ALTER TABLE kernel_session_log ADD COLUMN proxy_source TEXT`);
425
+ } catch {
426
+ }
427
+ try {
428
+ await db.execute(`ALTER TABLE kernel_session_log ADD COLUMN proxy_type TEXT`);
429
+ } catch {
430
+ }
431
+ try {
432
+ await db.execute(`ALTER TABLE kernel_session_log ADD COLUMN method TEXT`);
433
+ } catch {
434
+ }
435
+ await db.execute(`
436
+ CREATE TABLE IF NOT EXISTS vendor_usage_log (
437
+ id TEXT PRIMARY KEY,
438
+ op TEXT,
439
+ probe_run_id TEXT,
440
+ user_id INTEGER,
441
+ vendor TEXT NOT NULL,
442
+ model TEXT,
443
+ units REAL NOT NULL DEFAULT 0,
444
+ unit_type TEXT,
445
+ est_cost_usd REAL NOT NULL DEFAULT 0,
446
+ error TEXT,
447
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
448
+ )
449
+ `);
450
+ await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_op ON vendor_usage_log(op)`);
451
+ await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_probe ON vendor_usage_log(probe_run_id)`);
452
+ await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_created ON vendor_usage_log(created_at)`);
453
+ try {
454
+ await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN method TEXT`);
455
+ } catch {
456
+ }
457
+ await db.execute(`
458
+ CREATE TABLE IF NOT EXISTS cost_probe_runs (
459
+ id TEXT PRIMARY KEY,
460
+ tool TEXT NOT NULL,
461
+ target TEXT,
462
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
463
+ wall_ms INTEGER NOT NULL DEFAULT 0,
464
+ success INTEGER NOT NULL DEFAULT 0,
465
+ error TEXT,
466
+ http_only INTEGER NOT NULL DEFAULT 0,
467
+ kernel_used INTEGER NOT NULL DEFAULT 0,
468
+ kernel_sessions INTEGER NOT NULL DEFAULT 0,
469
+ kernel_seconds_total REAL NOT NULL DEFAULT 0,
470
+ kernel_tier_observed TEXT,
471
+ est_kernel_cost_usd_headless REAL NOT NULL DEFAULT 0,
472
+ est_kernel_cost_usd_headful REAL NOT NULL DEFAULT 0,
473
+ vendor_cost_usd REAL NOT NULL DEFAULT 0,
474
+ charged_credits REAL NOT NULL DEFAULT 0,
475
+ margin_usd_headless_starter REAL NOT NULL DEFAULT 0,
476
+ margin_usd_headful_starter REAL NOT NULL DEFAULT 0,
477
+ notes TEXT
478
+ )
479
+ `);
480
+ await db.execute(`CREATE INDEX IF NOT EXISTS cost_probe_runs_tool ON cost_probe_runs(tool)`);
481
+ try {
482
+ await db.execute(`ALTER TABLE cost_probe_runs ADD COLUMN units REAL`);
483
+ } catch {
484
+ }
485
+ try {
486
+ await db.execute(`ALTER TABLE cost_probe_runs ADD COLUMN unit_type TEXT`);
487
+ } catch {
488
+ }
489
+ try {
490
+ await db.execute(`ALTER TABLE cost_probe_runs ADD COLUMN mode TEXT`);
491
+ } catch {
492
+ }
493
+ _ready = true;
494
+ }
495
+ async function recordKernelSession(r) {
496
+ try {
497
+ await migrateCostTelemetry();
498
+ const ctx = currentCostContext();
499
+ const durationMs = Math.max(0, r.closedAtMs - r.openedAtMs);
500
+ const db = getDb();
501
+ await db.execute({
502
+ sql: `INSERT INTO kernel_session_log
503
+ (id, kernel_session_id, op, source, probe_run_id, user_id, stealth, headless_sent, proxy_used, proxy_source, proxy_type, fallback, opened_at, closed_at, duration_ms, est_cost_usd_headless, est_cost_usd_headful, error, method)
504
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
505
+ args: [
506
+ (0, import_node_crypto2.randomUUID)(),
507
+ r.kernelSessionId ?? null,
508
+ ctx?.op ?? null,
509
+ r.source,
510
+ ctx?.probeRunId ?? null,
511
+ ctx?.userId ?? null,
512
+ boolToInt(r.stealth),
513
+ boolToInt(r.headlessSent),
514
+ boolToInt(r.proxyUsed),
515
+ r.proxySource ?? null,
516
+ r.proxyType ?? null,
517
+ r.fallback ? 1 : 0,
518
+ new Date(r.openedAtMs).toISOString(),
519
+ new Date(r.closedAtMs).toISOString(),
520
+ durationMs,
521
+ kernelCostUsd(durationMs, false),
522
+ kernelCostUsd(durationMs, true),
523
+ r.error ?? null,
524
+ ctx?.subOp ?? null
525
+ ]
526
+ });
527
+ } catch (err) {
528
+ console.warn("[cost-telemetry] recordKernelSession failed:", err instanceof Error ? err.message : String(err));
529
+ }
530
+ }
531
+ async function recordVendorUsage(r) {
532
+ try {
533
+ await migrateCostTelemetry();
534
+ const ctx = currentCostContext();
535
+ const db = getDb();
536
+ await db.execute({
537
+ sql: `INSERT INTO vendor_usage_log
538
+ (id, op, probe_run_id, user_id, vendor, model, units, unit_type, est_cost_usd, error, method)
539
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
540
+ args: [
541
+ (0, import_node_crypto2.randomUUID)(),
542
+ ctx?.op ?? null,
543
+ ctx?.probeRunId ?? null,
544
+ ctx?.userId ?? null,
545
+ r.vendor,
546
+ r.model ?? null,
547
+ r.units,
548
+ r.unitType,
549
+ vendorCostUsd(r.vendor, r.units),
550
+ r.error ?? null,
551
+ ctx?.subOp ?? null
552
+ ]
553
+ });
554
+ } catch (err) {
555
+ console.warn("[cost-telemetry] recordVendorUsage failed:", err instanceof Error ? err.message : String(err));
556
+ }
557
+ }
558
+ function boolToInt(v) {
559
+ if (v === null || v === void 0) return null;
560
+ return v ? 1 : 0;
561
+ }
562
+
563
+ // src/driver/BrowserDriver.ts
564
+ import_playwright_extra.chromium.use((0, import_puppeteer_extra_plugin_stealth.default)());
565
+ var DESKTOP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
566
+ var MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
567
+ var DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS = 180;
568
+ var KERNEL_BROWSER_CLOSE_TIMEOUT_MS = 3e3;
569
+ var KERNEL_SESSION_DELETE_TIMEOUT_MS = 5e3;
570
+ function positiveIntFromEnv(name, fallback) {
571
+ const raw = process.env[name];
572
+ if (!raw) return fallback;
573
+ const parsed = Number(raw);
574
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
575
+ }
576
+ function proxyIdSuffix(proxyId) {
577
+ return proxyId ? proxyId.slice(-6) : null;
578
+ }
579
+ function serpArrivalUrl(googleUrl, baseOverride) {
580
+ const base = (baseOverride ?? process.env.SERP_REDIRECT_BASE)?.trim().replace(/\/+$/, "");
581
+ if (!base) return googleUrl;
582
+ return `${base}/g/${Buffer.from(googleUrl, "utf8").toString("base64url")}`;
583
+ }
584
+ function isUsCountry(country) {
585
+ if (!country) return false;
586
+ return /united states|u\.?s\.?a?\.?|^us$/i.test(country.trim());
587
+ }
588
+ function errorText(err) {
589
+ return err instanceof Error ? err.message : String(err);
590
+ }
591
+ function isKernelProfileConflict(err) {
592
+ return /\b409\b|conflict|already exists/i.test(errorText(err));
593
+ }
594
+ async function ensureKernelProfile(kernelClient, name) {
595
+ try {
596
+ await kernelClient.profiles.create({ name });
597
+ } catch (err) {
598
+ if (!isKernelProfileConflict(err)) throw err;
599
+ }
600
+ }
601
+ function rankCheckContextOptions(config) {
602
+ return {
603
+ viewport: config.viewport,
604
+ locale: config.locale,
605
+ userAgent: config.userAgent ?? (config.isMobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT),
606
+ ...config.deviceScaleFactor ? { deviceScaleFactor: config.deviceScaleFactor } : {},
607
+ ...config.isMobile !== void 0 ? { isMobile: config.isMobile } : {},
608
+ ...config.hasTouch !== void 0 ? { hasTouch: config.hasTouch } : {}
609
+ };
610
+ }
611
+ async function withTimeout(promise, timeoutMs, label) {
612
+ let timeout;
613
+ try {
614
+ return await Promise.race([
615
+ promise,
616
+ new Promise((_, reject) => {
617
+ timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
618
+ })
619
+ ]);
620
+ } finally {
621
+ if (timeout) clearTimeout(timeout);
622
+ }
623
+ }
624
+ function buildYouTubeChannelVideosUrl(channelInput) {
625
+ const raw = channelInput.trim();
626
+ if (!raw) throw new Error("channelHandle is required");
627
+ const urlLike = /^https?:\/\//i.test(raw) || /^(www\.|m\.)?youtube\.com\//i.test(raw);
628
+ if (urlLike) {
629
+ const parsed = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
630
+ const host = parsed.hostname.replace(/^www\./, "").replace(/^m\./, "").toLowerCase();
631
+ if (host !== "youtube.com") throw new Error("channel URL must be on youtube.com");
632
+ const segments = parsed.pathname.split("/").filter(Boolean);
633
+ const first = segments[0] ?? "";
634
+ const second = segments[1] ?? "";
635
+ if (first.startsWith("@")) return `https://www.youtube.com/${first}/videos`;
636
+ if (first === "channel" && second) return `https://www.youtube.com/channel/${second}/videos`;
637
+ if ((first === "c" || first === "user") && second) return `https://www.youtube.com/${first}/${second}/videos`;
638
+ throw new Error("channel URL must be a YouTube handle, /channel/UC..., /c/..., or /user/... URL");
639
+ }
640
+ const stripped = raw.replace(/^\/+/, "").replace(/\/+$/, "");
641
+ const withoutVideos = stripped.replace(/\/videos$/i, "");
642
+ if (/^UC[\w-]{20,}$/.test(withoutVideos)) {
643
+ return `https://www.youtube.com/channel/${withoutVideos}/videos`;
644
+ }
645
+ const handle = withoutVideos.startsWith("@") ? withoutVideos : `@${withoutVideos}`;
646
+ if (!/^@[\w.-]+$/.test(handle)) {
647
+ throw new Error("channelHandle must be an @handle, UC channel ID, or YouTube channel URL");
648
+ }
649
+ return `https://www.youtube.com/${handle}/videos`;
650
+ }
651
+ var BrowserDriver = class {
652
+ browser = null;
653
+ context = null;
654
+ page = null;
655
+ kernelClient = null;
656
+ kernelSessionId = null;
657
+ kernelOpenedAtMs = null;
658
+ kernelStealth = null;
659
+ kernelProxyUsed = null;
660
+ kernelProxySource = null;
661
+ kernelProxyType = null;
662
+ kernelHeadlessSent = null;
663
+ debugEnabled = false;
664
+ debugSnapshot = {
665
+ kernel: null,
666
+ context: null,
667
+ networkLocation: null,
668
+ serpNavigation: null
669
+ };
670
+ async launch(config) {
671
+ this.debugEnabled = config.debug === true;
672
+ const proxyMode = config.proxyMode ?? (config.kernelProxyId ? "configured" : "none");
673
+ const device = config.isMobile ? "mobile" : "desktop";
674
+ this.debugSnapshot = {
675
+ kernel: null,
676
+ context: {
677
+ viewport: config.viewport,
678
+ locale: config.locale,
679
+ device,
680
+ userAgent: config.userAgent ?? (config.isMobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT),
681
+ deviceScaleFactor: config.deviceScaleFactor ?? null,
682
+ isMobile: config.isMobile === true,
683
+ hasTouch: config.hasTouch === true
684
+ },
685
+ networkLocation: null,
686
+ serpNavigation: null
687
+ };
688
+ if (config.kernelApiKey) {
689
+ this.kernelClient = new import_sdk.default({ apiKey: config.kernelApiKey });
690
+ const timeoutSeconds = positiveIntFromEnv("KERNEL_BROWSER_TIMEOUT_SECONDS", DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS);
691
+ if (config.kernelProfileName && config.kernelProfileSaveChanges === true) {
692
+ await ensureKernelProfile(this.kernelClient, config.kernelProfileName);
693
+ }
694
+ const costCtx = currentCostContext();
695
+ let useHeadless;
696
+ if (config.headlessMode === "headful" || costCtx?.forceHeadful === true) useHeadless = false;
697
+ else if (config.headlessMode === "headless") useHeadless = true;
698
+ else useHeadless = costCtx?.forceHeadless === true || process.env.KERNEL_HEADLESS_DISABLED !== "true" && !!costCtx?.op && HEADLESS_OPS.has(costCtx.op);
699
+ this.kernelHeadlessSent = useHeadless ? true : null;
700
+ if (costCtx?.headlessSentOut) costCtx.headlessSentOut.value = this.kernelHeadlessSent;
701
+ const kernelBrowser = await this.kernelClient.browsers.create({
702
+ stealth: true,
703
+ timeout_seconds: timeoutSeconds,
704
+ ...useHeadless ? { headless: true } : {},
705
+ ...config.kernelProxyId ? { proxy_id: config.kernelProxyId } : {},
706
+ ...config.kernelProfileName ? {
707
+ profile: {
708
+ name: config.kernelProfileName,
709
+ ...typeof config.kernelProfileSaveChanges === "boolean" ? { save_changes: config.kernelProfileSaveChanges } : {}
710
+ }
711
+ } : {}
712
+ });
713
+ this.kernelSessionId = kernelBrowser.session_id;
714
+ this.kernelOpenedAtMs = Date.now();
715
+ this.kernelStealth = typeof kernelBrowser.stealth === "boolean" ? kernelBrowser.stealth : null;
716
+ this.kernelProxyUsed = typeof kernelBrowser.proxy_id === "string" ? Boolean(kernelBrowser.proxy_id) : Boolean(config.kernelProxyId);
717
+ this.kernelProxySource = config.kernelProxyResolution?.source ?? null;
718
+ this.kernelProxyType = config.kernelProxyResolution?.proxyType ?? null;
719
+ let defaultProxyDisabled = null;
720
+ let defaultProxyDisableError = null;
721
+ if (!config.kernelProxyId && !config.keepDefaultProxy) {
722
+ try {
723
+ await withTimeout(
724
+ this.kernelClient.browsers.update(this.kernelSessionId, { disable_default_proxy: true }),
725
+ 5e3,
726
+ `Kernel session ${this.kernelSessionId} disable default proxy`
727
+ );
728
+ defaultProxyDisabled = true;
729
+ } catch (err) {
730
+ defaultProxyDisabled = false;
731
+ defaultProxyDisableError = errorText(err);
732
+ }
733
+ }
734
+ const kernelDebug = {
735
+ sessionId: this.kernelSessionId,
736
+ proxyMode,
737
+ requestedProxyIdPresent: Boolean(config.kernelProxyId),
738
+ requestedProxyIdSuffix: proxyIdSuffix(config.kernelProxyId),
739
+ createdProxyIdPresent: typeof kernelBrowser.proxy_id === "string" ? Boolean(kernelBrowser.proxy_id) : null,
740
+ createdProxyIdSuffix: proxyIdSuffix(kernelBrowser.proxy_id),
741
+ retrievedProxyIdPresent: null,
742
+ retrievedProxyIdSuffix: null,
743
+ retrievedProxyIdMatchesRequested: null,
744
+ defaultProxyDisabled,
745
+ defaultProxyDisableError,
746
+ proxyResolution: config.kernelProxyResolution ?? null,
747
+ timeoutSeconds,
748
+ stealth: typeof kernelBrowser.stealth === "boolean" ? kernelBrowser.stealth : null,
749
+ profilePresent: null,
750
+ poolPresent: null,
751
+ retrieveError: null
752
+ };
753
+ this.debugSnapshot.kernel = kernelDebug;
754
+ console.info(JSON.stringify({
755
+ event: "kernel_browser_created",
756
+ kernel_session_id: this.kernelSessionId,
757
+ timeout_seconds: timeoutSeconds,
758
+ proxy_mode: proxyMode,
759
+ proxy_id_present: Boolean(config.kernelProxyId),
760
+ profile_name_present: Boolean(config.kernelProfileName),
761
+ proxy_resolution_source: config.kernelProxyResolution?.source
762
+ }));
763
+ if (this.debugEnabled) {
764
+ await this.populateKernelRetrieveDebug(kernelDebug, config.kernelProxyId);
765
+ }
766
+ this.browser = await import_playwright.chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
767
+ this.context = await this.browser.newContext(rankCheckContextOptions(config));
768
+ await this.installEsbuildHelperShims(this.context);
769
+ this.page = await this.context.newPage();
770
+ await this.page.setViewportSize(config.viewport);
771
+ if (this.debugEnabled || config.requireUsEgress) {
772
+ this.debugSnapshot.networkLocation = await this.captureBrowserNetworkLocation();
773
+ }
774
+ if (config.requireUsEgress) {
775
+ const country = this.debugSnapshot.networkLocation?.country ?? null;
776
+ if (!isUsCountry(country)) {
777
+ throw new CaptchaError(`Non-US egress (${country ?? "unknown"}) \u2014 retrying with a fresh session`);
778
+ }
779
+ }
780
+ return;
781
+ }
782
+ const launchOpts = {
783
+ headless: config.headless,
784
+ proxy: config.proxy ? { server: config.proxy } : void 0,
785
+ ...config.browserExecutablePath ? { executablePath: config.browserExecutablePath } : {},
786
+ ...config.ignoreDefaultArgs ? { ignoreDefaultArgs: config.ignoreDefaultArgs } : {}
787
+ };
788
+ const ctxOpts = rankCheckContextOptions(config);
789
+ if (config.profileDir) {
790
+ this.context = await import_playwright_extra.chromium.launchPersistentContext(config.profileDir, {
791
+ ...launchOpts,
792
+ ...ctxOpts
793
+ });
794
+ await this.installEsbuildHelperShims(this.context);
795
+ this.page = await this.context.newPage();
796
+ } else {
797
+ this.browser = await import_playwright_extra.chromium.launch(launchOpts);
798
+ this.context = await this.browser.newContext(ctxOpts);
799
+ await this.installEsbuildHelperShims(this.context);
800
+ this.page = await this.context.newPage();
801
+ }
802
+ if (this.debugEnabled) {
803
+ this.debugSnapshot.networkLocation = await this.captureBrowserNetworkLocation();
804
+ }
805
+ }
806
+ async populateKernelRetrieveDebug(kernelDebug, requestedProxyId) {
807
+ if (!this.kernelClient || !this.kernelSessionId) return;
808
+ try {
809
+ const retrieved = await withTimeout(
810
+ this.kernelClient.browsers.retrieve(this.kernelSessionId),
811
+ 5e3,
812
+ `Kernel session ${this.kernelSessionId} retrieve`
813
+ );
814
+ kernelDebug.retrievedProxyIdPresent = typeof retrieved.proxy_id === "string" ? Boolean(retrieved.proxy_id) : false;
815
+ kernelDebug.retrievedProxyIdSuffix = proxyIdSuffix(retrieved.proxy_id);
816
+ kernelDebug.retrievedProxyIdMatchesRequested = requestedProxyId ? retrieved.proxy_id === requestedProxyId : !retrieved.proxy_id;
817
+ kernelDebug.timeoutSeconds = typeof retrieved.timeout_seconds === "number" ? retrieved.timeout_seconds : kernelDebug.timeoutSeconds;
818
+ kernelDebug.stealth = typeof retrieved.stealth === "boolean" ? retrieved.stealth : kernelDebug.stealth;
819
+ kernelDebug.profilePresent = Boolean(retrieved.profile);
820
+ kernelDebug.poolPresent = Boolean(retrieved.pool);
821
+ } catch (err) {
822
+ kernelDebug.retrieveError = errorText(err);
823
+ }
824
+ }
825
+ async captureBrowserNetworkLocation() {
826
+ const fallback = (message, source = "ipapi.co") => ({
827
+ source,
828
+ ip: null,
829
+ city: null,
830
+ region: null,
831
+ country: null,
832
+ org: null,
833
+ timezone: null,
834
+ error: message
835
+ });
836
+ if (!this.context) return fallback("browser context is not available");
837
+ let debugPage = null;
838
+ try {
839
+ debugPage = await this.context.newPage();
840
+ const ipwho = await this.loadJsonInDebugPage(debugPage, "https://ipwho.is/");
841
+ if (ipwho) {
842
+ const connection = typeof ipwho.connection === "object" && ipwho.connection !== null ? ipwho.connection : {};
843
+ return {
844
+ source: "ipwho.is",
845
+ ip: typeof ipwho.ip === "string" ? ipwho.ip : null,
846
+ city: typeof ipwho.city === "string" ? ipwho.city : null,
847
+ region: typeof ipwho.region === "string" ? ipwho.region : null,
848
+ country: typeof ipwho.country === "string" ? ipwho.country : null,
849
+ org: typeof connection.org === "string" ? connection.org : null,
850
+ timezone: typeof ipwho.timezone === "object" && ipwho.timezone !== null && typeof ipwho.timezone.id === "string" ? ipwho.timezone.id : null,
851
+ error: null
852
+ };
853
+ }
854
+ const ipify = await this.loadJsonInDebugPage(debugPage, "https://api64.ipify.org?format=json");
855
+ if (ipify) {
856
+ return {
857
+ source: "api64.ipify.org",
858
+ ip: typeof ipify.ip === "string" ? ipify.ip : null,
859
+ city: null,
860
+ region: null,
861
+ country: null,
862
+ org: null,
863
+ timezone: null,
864
+ error: null
865
+ };
866
+ }
867
+ await withTimeout(
868
+ debugPage.goto("https://ipapi.co/json/", { waitUntil: "domcontentloaded", timeout: 7e3 }),
869
+ 8e3,
870
+ "browser network location navigation"
871
+ );
872
+ const body = await debugPage.locator("body").innerText({ timeout: 2e3 });
873
+ const data = JSON.parse(body);
874
+ return {
875
+ source: "ipapi.co",
876
+ ip: typeof data.ip === "string" ? data.ip : null,
877
+ city: typeof data.city === "string" ? data.city : null,
878
+ region: typeof data.region === "string" ? data.region : null,
879
+ country: typeof data.country_name === "string" ? data.country_name : typeof data.country === "string" ? data.country : null,
880
+ org: typeof data.org === "string" ? data.org : null,
881
+ timezone: typeof data.timezone === "string" ? data.timezone : null,
882
+ error: null
883
+ };
884
+ } catch (err) {
885
+ return fallback(errorText(err));
886
+ } finally {
887
+ await debugPage?.close().catch(() => {
888
+ });
889
+ }
890
+ }
891
+ async loadJsonInDebugPage(debugPage, url) {
892
+ try {
893
+ await withTimeout(
894
+ debugPage.goto(url, { waitUntil: "domcontentloaded", timeout: 7e3 }),
895
+ 8e3,
896
+ `browser network location navigation ${url}`
897
+ );
898
+ const body = await debugPage.locator("body").innerText({ timeout: 2e3 });
899
+ return JSON.parse(body);
900
+ } catch {
901
+ return null;
902
+ }
903
+ }
904
+ async installEsbuildHelperShims(context) {
905
+ await context.addInitScript(() => {
906
+ const g = globalThis;
907
+ if (typeof g.__name !== "function") g.__name = (fn) => fn;
908
+ if (typeof g.__publicField !== "function") g.__publicField = (obj, key, value) => {
909
+ obj[key] = value;
910
+ return value;
911
+ };
912
+ });
913
+ }
914
+ async navigateToSERP(query, uule, gl, hl, options) {
915
+ const params = new URLSearchParams({ q: query, gl, hl, pws: "0" });
916
+ if (options?.num) params.set("num", String(options.num));
917
+ if (options?.tbs) params.set("tbs", options.tbs);
918
+ if (uule) params.set("uule", uule);
919
+ const url = "https://www.google.com/search?" + params.toString();
920
+ const navUrl = serpArrivalUrl(url);
921
+ const navDebug = options?.debug ? {
922
+ requestedUrl: url,
923
+ finalUrl: null,
924
+ title: null,
925
+ bodySnippet: null,
926
+ hasPaa: null,
927
+ captchaDetected: null,
928
+ googleSorryUrl: null,
929
+ redirected: null
930
+ } : null;
931
+ if (navDebug) this.debugSnapshot.serpNavigation = navDebug;
932
+ try {
933
+ await this.page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 45e3 });
934
+ } catch (err) {
935
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: null, captchaDetected: null });
936
+ const diag = await this.captureDiagnostics(url);
937
+ throw new ExtractionError(`page.goto failed: ${err.message} | ${diag}`);
938
+ }
939
+ if (await this.isSerpChallengeVisible()) {
940
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: true });
941
+ throw new CaptchaError(this.captchaMessage());
942
+ }
943
+ const fastFound = await this.page.waitForSelector(PAASelectors.item, { timeout: 4e3 }).catch(() => null);
944
+ if (fastFound) {
945
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: true, captchaDetected: false });
946
+ return { hasPaa: true };
947
+ }
948
+ if (await this.isSerpChallengeVisible()) {
949
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: true });
950
+ throw new CaptchaError(this.captchaMessage());
951
+ }
952
+ for (let i = 1; i <= 6; i++) {
953
+ await this.page.evaluate((f) => {
954
+ window.scrollTo(0, document.body.scrollHeight * f);
955
+ }, i / 6);
956
+ await this.page.waitForTimeout(600);
957
+ const count = await this.page.locator(PAASelectors.item).count();
958
+ if (count > 0) {
959
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: true, captchaDetected: false });
960
+ return { hasPaa: true };
961
+ }
962
+ }
963
+ await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: false });
964
+ return { hasPaa: false };
965
+ }
966
+ async isSerpChallengeVisible() {
967
+ if (!this.page) return false;
968
+ const captchaCount = await this.page.locator(PAASelectors.captchaMarker).count().catch(() => 0);
969
+ if (captchaCount > 0) return true;
970
+ const finalUrl = this.page.url();
971
+ if (/google\.[^/]+\/sorry\//i.test(finalUrl)) return true;
972
+ const bodySnippet = await this.page.evaluate(() => {
973
+ const text = (document.body?.innerText ?? "").replace(/\s+/g, " ").trim();
974
+ return text.slice(0, 1e3);
975
+ }).catch(() => "");
976
+ return /recaptcha|unusual traffic|are you a robot|about this page|detected unusual traffic/i.test(bodySnippet);
977
+ }
978
+ async updateSerpNavigationDebug(navDebug, requestedUrl, state) {
979
+ if (!navDebug || !this.page) return;
980
+ try {
981
+ const finalUrl = this.page.url();
982
+ const title = await this.page.title().catch(() => "");
983
+ const bodySnippet = await this.page.evaluate(() => {
984
+ const text = (document.body?.innerText ?? "").replace(/\s+/g, " ").trim();
985
+ return text.slice(0, 500);
986
+ }).catch(() => "");
987
+ const textCaptcha = /recaptcha|unusual traffic|are you a robot/i.test(bodySnippet);
988
+ navDebug.finalUrl = finalUrl;
989
+ navDebug.title = title;
990
+ navDebug.bodySnippet = bodySnippet;
991
+ navDebug.hasPaa = state.hasPaa;
992
+ navDebug.captchaDetected = state.captchaDetected ?? textCaptcha;
993
+ navDebug.googleSorryUrl = /google\.[^/]+\/sorry\//i.test(finalUrl);
994
+ navDebug.redirected = finalUrl !== requestedUrl;
995
+ } catch (err) {
996
+ navDebug.bodySnippet = `debug capture failed: ${errorText(err)}`;
997
+ }
998
+ }
999
+ async captureDiagnostics(intendedUrl) {
1000
+ try {
1001
+ const finalUrl = this.page.url();
1002
+ const title = await this.page.title().catch(() => "");
1003
+ const bodySnippet = await this.page.evaluate(() => {
1004
+ const t = (document.body?.innerText ?? "").replace(/\s+/g, " ").trim();
1005
+ return t.slice(0, 400);
1006
+ }).catch(() => "");
1007
+ const consent = /consent\.google\./.test(finalUrl) || /before you continue/i.test(bodySnippet);
1008
+ const recaptcha = /recaptcha|unusual traffic|are you a robot/i.test(bodySnippet);
1009
+ const flags = [
1010
+ consent ? "CONSENT_WALL" : "",
1011
+ recaptcha ? "BOT_CHALLENGE" : "",
1012
+ finalUrl !== intendedUrl ? "REDIRECTED" : ""
1013
+ ].filter(Boolean).join(",");
1014
+ return `intended=${intendedUrl} | final=${finalUrl} | title="${title}" | flags=[${flags}] | body="${bodySnippet}"`;
1015
+ } catch (e) {
1016
+ return `diagnostics-failed: ${e.message}`;
1017
+ }
1018
+ }
1019
+ captchaMessage() {
1020
+ return this.kernelClient ? "Google returned a CAPTCHA on this session \u2014 retrying with a fresh session." : RECAPTCHA_INSTRUCTIONS;
1021
+ }
1022
+ async navigateTo(url) {
1023
+ const arrivalBase = process.env.ADS_ARRIVAL_REDIRECT_BASE?.trim();
1024
+ const navUrl = arrivalBase ? serpArrivalUrl(url, arrivalBase) : url;
1025
+ try {
1026
+ await this.page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 45e3 });
1027
+ if (/google\.[^/]+\/search\?/i.test(url) && await this.isSerpChallengeVisible()) {
1028
+ throw new CaptchaError(this.captchaMessage());
1029
+ }
1030
+ } catch (err) {
1031
+ if (err instanceof CaptchaError) throw err;
1032
+ const diag = await this.captureDiagnostics(url);
1033
+ throw new ExtractionError(`page.goto failed: ${err.message} | ${diag}`);
1034
+ }
1035
+ }
1036
+ async navigateToChannel(channelHandle) {
1037
+ const url = buildYouTubeChannelVideosUrl(channelHandle);
1038
+ try {
1039
+ await this.page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1040
+ } catch (err) {
1041
+ const diag = await this.captureDiagnostics(url);
1042
+ throw new ExtractionError(`navigateToChannel failed: ${err.message} | ${diag}`);
1043
+ }
1044
+ }
1045
+ async evaluate(fn, arg) {
1046
+ return this.page.evaluate(fn, arg);
1047
+ }
1048
+ getPage() {
1049
+ return this.page;
1050
+ }
1051
+ getKernelSessionId() {
1052
+ return this.kernelSessionId;
1053
+ }
1054
+ getDebugSnapshot() {
1055
+ return this.debugSnapshot;
1056
+ }
1057
+ async close() {
1058
+ if (this.browser) {
1059
+ const b = this.browser;
1060
+ const sessionId = this.kernelSessionId;
1061
+ const client = this.kernelClient;
1062
+ const openedAtMs = this.kernelOpenedAtMs;
1063
+ const stealth = this.kernelStealth;
1064
+ const proxyUsed = this.kernelProxyUsed;
1065
+ const proxySource = this.kernelProxySource;
1066
+ const proxyType = this.kernelProxyType;
1067
+ const headlessSent = this.kernelHeadlessSent;
1068
+ this.browser = null;
1069
+ this.context = null;
1070
+ this.page = null;
1071
+ this.kernelSessionId = null;
1072
+ this.kernelClient = null;
1073
+ this.kernelOpenedAtMs = null;
1074
+ this.kernelHeadlessSent = null;
1075
+ this.kernelProxySource = null;
1076
+ this.kernelProxyType = null;
1077
+ if (client && sessionId && openedAtMs != null) {
1078
+ void recordKernelSession({ kernelSessionId: sessionId, source: "browser_driver", stealth, headlessSent, proxyUsed, proxySource, proxyType, openedAtMs, closedAtMs: Date.now() });
1079
+ }
1080
+ if (client && sessionId) {
1081
+ console.info(JSON.stringify({
1082
+ event: "kernel_browser_delete_started",
1083
+ kernel_session_id: sessionId
1084
+ }));
1085
+ const deleteSession = withTimeout(
1086
+ client.browsers.deleteByID(sessionId),
1087
+ KERNEL_SESSION_DELETE_TIMEOUT_MS,
1088
+ `Kernel session ${sessionId} delete`
1089
+ );
1090
+ const closeBrowser = withTimeout(
1091
+ b.close(),
1092
+ KERNEL_BROWSER_CLOSE_TIMEOUT_MS,
1093
+ `Kernel browser ${sessionId} close`
1094
+ );
1095
+ const [deleteResult, closeResult] = await Promise.allSettled([deleteSession, closeBrowser]);
1096
+ const result = {
1097
+ kernelSessionId: sessionId,
1098
+ kernelDeleteStarted: true,
1099
+ kernelDeleteSucceeded: deleteResult.status === "fulfilled",
1100
+ kernelDeleteError: deleteResult.status === "rejected" ? deleteResult.reason instanceof Error ? deleteResult.reason.message : String(deleteResult.reason) : null,
1101
+ browserCloseSucceeded: closeResult.status === "fulfilled",
1102
+ browserCloseError: closeResult.status === "rejected" ? closeResult.reason instanceof Error ? closeResult.reason.message : String(closeResult.reason) : null
1103
+ };
1104
+ if (deleteResult.status === "rejected") {
1105
+ console.warn(JSON.stringify({
1106
+ event: "kernel_browser_delete_failed",
1107
+ kernel_session_id: sessionId,
1108
+ message: result.kernelDeleteError
1109
+ }));
1110
+ console.warn(`Kernel session cleanup failed for ${sessionId}:`, deleteResult.reason);
1111
+ } else {
1112
+ console.info(JSON.stringify({
1113
+ event: "kernel_browser_delete_succeeded",
1114
+ kernel_session_id: sessionId
1115
+ }));
1116
+ }
1117
+ if (closeResult.status === "rejected") {
1118
+ console.warn(JSON.stringify({
1119
+ event: "kernel_browser_close_failed",
1120
+ kernel_session_id: sessionId,
1121
+ message: result.browserCloseError
1122
+ }));
1123
+ console.warn(`Kernel browser close failed for ${sessionId}:`, closeResult.reason);
1124
+ }
1125
+ return result;
1126
+ }
1127
+ await b.close();
1128
+ return {
1129
+ kernelSessionId: null,
1130
+ kernelDeleteStarted: false,
1131
+ kernelDeleteSucceeded: null,
1132
+ kernelDeleteError: null,
1133
+ browserCloseSucceeded: true,
1134
+ browserCloseError: null
1135
+ };
1136
+ } else if (this.context) {
1137
+ const ctx = this.context;
1138
+ this.context = null;
1139
+ this.page = null;
1140
+ await ctx.close();
1141
+ return {
1142
+ kernelSessionId: null,
1143
+ kernelDeleteStarted: false,
1144
+ kernelDeleteSucceeded: null,
1145
+ kernelDeleteError: null,
1146
+ browserCloseSucceeded: true,
1147
+ browserCloseError: null
1148
+ };
1149
+ }
1150
+ return {
1151
+ kernelSessionId: null,
1152
+ kernelDeleteStarted: false,
1153
+ kernelDeleteSucceeded: null,
1154
+ kernelDeleteError: null,
1155
+ browserCloseSucceeded: null,
1156
+ browserCloseError: null
1157
+ };
1158
+ }
1159
+ };
1160
+
1161
+ // src/locations.ts
1162
+ var LOCATIONS = {
1163
+ "austin": "Austin,Texas,United States",
1164
+ "new york": "New York,New York,United States",
1165
+ "new york city": "New York,New York,United States",
1166
+ "nyc": "New York,New York,United States",
1167
+ "los angeles": "Los Angeles,California,United States",
1168
+ "la": "Los Angeles,California,United States",
1169
+ "chicago": "Chicago,Illinois,United States",
1170
+ "houston": "Houston,Texas,United States",
1171
+ "phoenix": "Phoenix,Arizona,United States",
1172
+ "philadelphia": "Philadelphia,Pennsylvania,United States",
1173
+ "philly": "Philadelphia,Pennsylvania,United States",
1174
+ "san antonio": "San Antonio,Texas,United States",
1175
+ "dallas": "Dallas,Texas,United States",
1176
+ "miami": "Miami,Florida,United States",
1177
+ "seattle": "Seattle,Washington,United States",
1178
+ "denver": "Denver,Colorado,United States",
1179
+ "loveland": "Loveland,Colorado,United States",
1180
+ "loveland co": "Loveland,Colorado,United States",
1181
+ "fort collins": "Fort Collins,Colorado,United States",
1182
+ "boulder": "Boulder,Colorado,United States",
1183
+ "colorado springs": "Colorado Springs,Colorado,United States",
1184
+ "boston": "Boston,Massachusetts,United States",
1185
+ "atlanta": "Atlanta,Georgia,United States",
1186
+ "san francisco": "San Francisco,California,United States",
1187
+ "sf": "San Francisco,California,United States",
1188
+ "portland": "Portland,Oregon,United States",
1189
+ "las vegas": "Las Vegas,Nevada,United States",
1190
+ "minneapolis": "Minneapolis,Minnesota,United States",
1191
+ "detroit": "Detroit,Michigan,United States",
1192
+ "nashville": "Nashville,Tennessee,United States",
1193
+ "charlotte": "Charlotte,North Carolina,United States",
1194
+ "orlando": "Orlando,Florida,United States",
1195
+ "san diego": "San Diego,California,United States",
1196
+ "baltimore": "Baltimore,Maryland,United States",
1197
+ "sacramento": "Sacramento,California,United States",
1198
+ "columbus": "Columbus,Ohio,United States",
1199
+ "indianapolis": "Indianapolis,Indiana,United States",
1200
+ "san jose": "San Jose,California,United States",
1201
+ "fort worth": "Fort Worth,Texas,United States",
1202
+ "jacksonville": "Jacksonville,Florida,United States",
1203
+ "memphis": "Memphis,Tennessee,United States",
1204
+ "louisville": "Louisville,Kentucky,United States",
1205
+ "raleigh": "Raleigh,North Carolina,United States",
1206
+ "richmond": "Richmond,Virginia,United States",
1207
+ "salt lake city": "Salt Lake City,Utah,United States",
1208
+ "toronto": "Toronto,Ontario,Canada",
1209
+ "vancouver": "Vancouver,British Columbia,Canada",
1210
+ "montreal": "Montreal,Quebec,Canada",
1211
+ "calgary": "Calgary,Alberta,Canada",
1212
+ "ottawa": "Ottawa,Ontario,Canada",
1213
+ "london": "London,England,United Kingdom",
1214
+ "manchester": "Manchester,England,United Kingdom",
1215
+ "birmingham": "Birmingham,England,United Kingdom",
1216
+ "edinburgh": "Edinburgh,Scotland,United Kingdom",
1217
+ "glasgow": "Glasgow,Scotland,United Kingdom",
1218
+ "leeds": "Leeds,England,United Kingdom",
1219
+ "sydney": "Sydney,New South Wales,Australia",
1220
+ "melbourne": "Melbourne,Victoria,Australia",
1221
+ "brisbane": "Brisbane,Queensland,Australia",
1222
+ "perth": "Perth,Western Australia,Australia",
1223
+ "adelaide": "Adelaide,South Australia,Australia",
1224
+ "dublin": "Dublin,Leinster,Ireland"
1225
+ };
1226
+
1227
+ // src/uule.ts
1228
+ function encodeVarint(value) {
1229
+ const bytes = [];
1230
+ let remaining = value;
1231
+ do {
1232
+ let byte = remaining & 127;
1233
+ remaining >>>= 7;
1234
+ if (remaining > 0) byte |= 128;
1235
+ bytes.push(byte);
1236
+ } while (remaining > 0);
1237
+ return bytes;
1238
+ }
1239
+ function encodeUule(name) {
1240
+ const locationBytes = Buffer.from(name, "utf8");
1241
+ const payload = Buffer.concat([
1242
+ Buffer.from([8, 2, 16, 32, 34]),
1243
+ Buffer.from(encodeVarint(locationBytes.length)),
1244
+ locationBytes
1245
+ ]);
1246
+ return `w+${payload.toString("base64")}`;
1247
+ }
1248
+ function normalizeLocation(input) {
1249
+ const raw = input.toLowerCase().trim();
1250
+ if (LOCATIONS[raw]) return LOCATIONS[raw];
1251
+ const beforeComma = raw.split(",")[0].trim();
1252
+ if (beforeComma !== raw && LOCATIONS[beforeComma]) return LOCATIONS[beforeComma];
1253
+ const withoutState = raw.replace(/\s+[a-z]{2}$/, "").trim();
1254
+ if (withoutState !== raw && LOCATIONS[withoutState]) return LOCATIONS[withoutState];
1255
+ return input;
1256
+ }
1257
+
1258
+ // src/serp-location-query.ts
1259
+ function normalizedTerms(value) {
1260
+ return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
1261
+ }
1262
+ function includesTermSequence(query, phrase) {
1263
+ const queryTerms = normalizedTerms(query);
1264
+ const phraseTerms = normalizedTerms(phrase);
1265
+ if (phraseTerms.length === 0 || phraseTerms.length > queryTerms.length) return false;
1266
+ return queryTerms.some((_, start) => phraseTerms.every((term, index) => queryTerms[start + index] === term));
1267
+ }
1268
+ function cityFromCanonicalLocation(location, fallback) {
1269
+ return location.split(",", 1)[0]?.trim() || fallback.split(",", 1)[0]?.trim() || fallback.trim();
1270
+ }
1271
+ function buildLocalizedSerpQuery(query, location, canonicalLocation) {
1272
+ const normalizedQuery = query.trim();
1273
+ const requestedLocation = location?.trim();
1274
+ if (!requestedLocation || !canonicalLocation) return normalizedQuery;
1275
+ const city = cityFromCanonicalLocation(canonicalLocation, requestedLocation);
1276
+ return includesTermSequence(normalizedQuery, city) ? normalizedQuery : `${normalizedQuery} ${requestedLocation}`;
1277
+ }
1278
+
1279
+ // src/serp-location-debug.ts
1280
+ var STATE_TO_CODE = {
1281
+ alabama: "AL",
1282
+ alaska: "AK",
1283
+ arizona: "AZ",
1284
+ arkansas: "AR",
1285
+ california: "CA",
1286
+ colorado: "CO",
1287
+ connecticut: "CT",
1288
+ delaware: "DE",
1289
+ florida: "FL",
1290
+ georgia: "GA",
1291
+ hawaii: "HI",
1292
+ idaho: "ID",
1293
+ illinois: "IL",
1294
+ indiana: "IN",
1295
+ iowa: "IA",
1296
+ kansas: "KS",
1297
+ kentucky: "KY",
1298
+ louisiana: "LA",
1299
+ maine: "ME",
1300
+ maryland: "MD",
1301
+ massachusetts: "MA",
1302
+ michigan: "MI",
1303
+ minnesota: "MN",
1304
+ mississippi: "MS",
1305
+ missouri: "MO",
1306
+ montana: "MT",
1307
+ nebraska: "NE",
1308
+ nevada: "NV",
1309
+ "new hampshire": "NH",
1310
+ "new jersey": "NJ",
1311
+ "new mexico": "NM",
1312
+ "new york": "NY",
1313
+ "north carolina": "NC",
1314
+ "north dakota": "ND",
1315
+ ohio: "OH",
1316
+ oklahoma: "OK",
1317
+ oregon: "OR",
1318
+ pennsylvania: "PA",
1319
+ "rhode island": "RI",
1320
+ "south carolina": "SC",
1321
+ "south dakota": "SD",
1322
+ tennessee: "TN",
1323
+ texas: "TX",
1324
+ utah: "UT",
1325
+ vermont: "VT",
1326
+ virginia: "VA",
1327
+ washington: "WA",
1328
+ "west virginia": "WV",
1329
+ wisconsin: "WI",
1330
+ wyoming: "WY",
1331
+ "district of columbia": "DC"
1332
+ };
1333
+ var STATE_PATTERN = [
1334
+ ...Object.keys(STATE_TO_CODE).map((s) => s.replace(/\s+/g, "\\s+")),
1335
+ ...Object.values(STATE_TO_CODE)
1336
+ ].join("|");
1337
+ var CITY_STATE_RE = new RegExp(`\\b([A-Z][A-Za-z]+(?:[\\s.-][A-Z][A-Za-z]+){0,4}),?\\s+(${STATE_PATTERN})\\b`, "gi");
1338
+ function normalizeRegionCode(input) {
1339
+ if (!input) return null;
1340
+ const trimmed = input.trim();
1341
+ if (/^[A-Z]{2}$/i.test(trimmed)) return trimmed.toUpperCase();
1342
+ return STATE_TO_CODE[trimmed.toLowerCase()] ?? null;
1343
+ }
1344
+ function normalizeCity(input) {
1345
+ const cleaned = input.replace(/\s+/g, " ").trim().replace(/^.*\b(?:in|near|around|serving)\s+/i, "");
1346
+ return cleaned.toLowerCase().replace(/\b[a-z]/g, (char) => char.toUpperCase());
1347
+ }
1348
+ function parseExpected(canonicalLocation) {
1349
+ if (!canonicalLocation) return null;
1350
+ const [city = "", region = ""] = canonicalLocation.split(",").map((part) => part.trim());
1351
+ return {
1352
+ city: normalizeCity(city),
1353
+ regionCode: normalizeRegionCode(region),
1354
+ canonicalLocation
1355
+ };
1356
+ }
1357
+ function addCandidate(candidates, city, region, example) {
1358
+ const normalizedCity = normalizeCity(city);
1359
+ const regionCode = normalizeRegionCode(region);
1360
+ if (!normalizedCity || !regionCode) return;
1361
+ const key = `${normalizedCity.toLowerCase()}|${regionCode}`;
1362
+ const existing = candidates.get(key);
1363
+ if (existing) {
1364
+ existing.count++;
1365
+ if (existing.examples.length < 3 && !existing.examples.includes(example)) existing.examples.push(example);
1366
+ return;
1367
+ }
1368
+ candidates.set(key, { city: normalizedCity, regionCode, count: 1, examples: [example] });
1369
+ }
1370
+ function decodeSerpText(text) {
1371
+ try {
1372
+ return decodeURIComponent(text);
1373
+ } catch {
1374
+ }
1375
+ try {
1376
+ return decodeURIComponent(text.replace(/%(?![0-9a-fA-F]{2})/g, "%25"));
1377
+ } catch {
1378
+ return text;
1379
+ }
1380
+ }
1381
+ function scanText(candidates, text) {
1382
+ const normalized = decodeSerpText(text).replace(/[+/|_-]+/g, " ");
1383
+ for (const match of normalized.matchAll(CITY_STATE_RE)) {
1384
+ addCandidate(candidates, match[1] ?? "", match[2] ?? "", normalized.slice(0, 180));
1385
+ }
1386
+ }
1387
+ function inferSerpLocationEvidence(canonicalLocation, organicResults, localPack) {
1388
+ const expected = parseExpected(canonicalLocation);
1389
+ const candidates = /* @__PURE__ */ new Map();
1390
+ for (const result of organicResults) {
1391
+ scanText(candidates, [result.title, result.snippet ?? "", result.cite ?? "", result.url].join(" "));
1392
+ }
1393
+ for (const business of localPack) {
1394
+ scanText(candidates, [business.name, ...business.metadata, business.websiteUrl ?? "", business.directionsUrl ?? ""].join(" "));
1395
+ }
1396
+ const rankedCandidates = Array.from(candidates.values()).sort((a, b) => b.count - a.count || a.city.localeCompare(b.city)).slice(0, 8);
1397
+ if (!expected) {
1398
+ return { status: "not_requested", expected: null, candidates: rankedCandidates };
1399
+ }
1400
+ if (rankedCandidates.length === 0) {
1401
+ return { status: "unknown", expected, candidates: [] };
1402
+ }
1403
+ const matched = rankedCandidates.some(
1404
+ (candidate) => candidate.city.toLowerCase() === expected.city.toLowerCase() && (expected.regionCode == null || candidate.regionCode === expected.regionCode)
1405
+ );
1406
+ return {
1407
+ status: matched ? "matched" : "mismatch",
1408
+ expected,
1409
+ candidates: rankedCandidates
1410
+ };
1411
+ }
1412
+
1413
+ // src/lib/paa-answer-cleanup.ts
1414
+ var MAX_ANSWER_LENGTH = 1200;
1415
+ var BOILERPLATE_PATTERNS = [
1416
+ /An AI Overview is not available for this search/gi,
1417
+ /Can't generate an AI overview right now\.?\s*Try again later\.?/gi,
1418
+ /\bAI Overview\b/gi,
1419
+ /\bView all\b/gi
1420
+ ];
1421
+ var CUT_MARKERS = [
1422
+ /\bRelated Links\b/i,
1423
+ /\bAsk anything in\s*AI Mode\b/i,
1424
+ /\bAI can make mistakes\b/i,
1425
+ /\bThis is for informational purposes only\b/i,
1426
+ /\bShow more\b/i,
1427
+ /\b\d+\s+sites\b/i,
1428
+ /\b\d{1,2}\s*[msh]\s*[A-Z][A-Za-z]/,
1429
+ /\b(?:YouTube|Reddit|Facebook|Instagram|TikTok)·/
1430
+ ];
1431
+ function normalizeWhitespace(text) {
1432
+ return text.replace(/\u00a0/g, " ").replace(/([.!?])([A-Z])/g, "$1 $2").replace(/([:;])([A-Z])/g, "$1 $2").replace(/([a-z])([A-Z][a-z])/g, "$1 $2").replace(/(\d)([A-Z][a-z])/g, "$1 $2").replace(/([a-z])(\d)/g, "$1 $2").replace(/\s+/g, " ").trim();
1433
+ }
1434
+ function cutAtFirstMarker(text) {
1435
+ let cutAt = -1;
1436
+ for (const marker of CUT_MARKERS) {
1437
+ const match = marker.exec(text);
1438
+ marker.lastIndex = 0;
1439
+ if (match && (cutAt === -1 || match.index < cutAt)) cutAt = match.index;
1440
+ }
1441
+ return cutAt === -1 ? text : text.slice(0, cutAt);
1442
+ }
1443
+ function cutAtSourceTitle(text, sourceTitle) {
1444
+ const title = sourceTitle?.trim();
1445
+ if (!title || title.length < 8) return text;
1446
+ const idx = text.toLowerCase().indexOf(title.toLowerCase());
1447
+ return idx > 40 ? text.slice(0, idx) : text;
1448
+ }
1449
+ function findAttributionCut(beforeUrl) {
1450
+ const dateMatch = beforeUrl.match(/[•·]\s*(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+\d{4}/i);
1451
+ if (dateMatch?.index && dateMatch.index > 40) return dateMatch.index;
1452
+ const start = Math.max(0, beforeUrl.length - 260);
1453
+ const tail = beforeUrl.slice(start);
1454
+ const sentenceBreaks = [...tail.matchAll(/[.!?]\s*(?=[A-Z][A-Za-z0-9"'$])/g)];
1455
+ for (const match of sentenceBreaks) {
1456
+ const remainder = tail.slice(match.index + 1).trim();
1457
+ const lead = remainder.slice(0, 160);
1458
+ const looksLikeTitle = /^(?:Best|Top|What|How|Why|When|Where|Which|Can|Should|Is|Are|Do|Does)\b/i.test(remainder);
1459
+ if (remainder.length > 20 && looksLikeTitle && /(?:\s[-|]\s|Heating|Cooling|Company|Services|Blog|Guide|Review)/i.test(lead)) {
1460
+ return start + match.index + 1;
1461
+ }
1462
+ }
1463
+ const last = sentenceBreaks.at(-1);
1464
+ if (last?.index !== void 0) return start + last.index + 1;
1465
+ return beforeUrl.length;
1466
+ }
1467
+ function cutAtUrlAttribution(text) {
1468
+ const urlMatch = text.match(/https?:\/\/\S+/i);
1469
+ if (!urlMatch?.index) return text;
1470
+ const beforeUrl = text.slice(0, urlMatch.index);
1471
+ return beforeUrl.slice(0, findAttributionCut(beforeUrl));
1472
+ }
1473
+ function trimToSentenceLimit(text) {
1474
+ if (text.length <= MAX_ANSWER_LENGTH) return text;
1475
+ const slice = text.slice(0, MAX_ANSWER_LENGTH);
1476
+ const lastSentence = Math.max(slice.lastIndexOf("."), slice.lastIndexOf("!"), slice.lastIndexOf("?"));
1477
+ return (lastSentence > 240 ? slice.slice(0, lastSentence + 1) : slice).trim();
1478
+ }
1479
+ function cleanPAAAnswerText(answer, question, sourceTitle) {
1480
+ if (!answer) return void 0;
1481
+ let text = normalizeWhitespace(answer);
1482
+ const normalizedQuestion = question ? normalizeWhitespace(question) : "";
1483
+ if (normalizedQuestion && text.toLowerCase().startsWith(normalizedQuestion.toLowerCase())) {
1484
+ text = text.slice(normalizedQuestion.length).trim();
1485
+ }
1486
+ if (/^An error has occurred\.?\s*Please try again later\.?/i.test(text)) {
1487
+ return void 0;
1488
+ }
1489
+ for (const pattern of BOILERPLATE_PATTERNS) {
1490
+ text = text.replace(pattern, " ");
1491
+ }
1492
+ text = text.replace(/\b[A-Z][A-Za-z&'\u2019 -]{2,60}\+\d+\b/g, " ").replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\+\d+\b/gi, " ");
1493
+ text = normalizeWhitespace(text);
1494
+ text = cutAtFirstMarker(text);
1495
+ text = cutAtSourceTitle(text, sourceTitle);
1496
+ text = cutAtUrlAttribution(text);
1497
+ text = normalizeWhitespace(text);
1498
+ text = text.replace(/\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+\d{4}$/i, "").trim();
1499
+ text = trimToSentenceLimit(text);
1500
+ if (!text) return void 0;
1501
+ if (/^An error has occurred\.?\s*Please try again later\.?$/i.test(text)) return void 0;
1502
+ return text;
1503
+ }
1504
+
1505
+ // src/extractor/ai-surfaces.ts
1506
+ async function extractAISurfacesFromDocument(config) {
1507
+ const selectors = config ?? {
1508
+ aio: {
1509
+ root: "[data-lhcontainer][data-streaming-container][eid]",
1510
+ legacyRoot: '[data-hveid="CBMQAA"]',
1511
+ wrapper: ".Fgyi2e",
1512
+ controller: '[jscontroller="AkrxPe"]',
1513
+ contentSubtree: '[data-subtree="mfc"]',
1514
+ heading: ".Fzsovc.cwYVJe.RJPOee",
1515
+ header: ".heWuVc",
1516
+ showMoreButton: '[aria-label="Show more AI Overview"]',
1517
+ sourcesPanel: ".OZ9ddf.WAUd4",
1518
+ disclaimer: ".DuQANe.MSJHRb"
1519
+ },
1520
+ aim: {
1521
+ root: '[data-hveid="CAUQAA"]',
1522
+ wrapper: ".Fgyi2e"
1523
+ },
1524
+ expandWaitMs: 1500
1525
+ };
1526
+ const sn = window.google?.sn ?? "unknown";
1527
+ const surface = sn === "aim" ? "aim" : sn === "web" ? "web" : "unknown";
1528
+ function textOf(el) {
1529
+ if (!el) return "";
1530
+ return (el.innerText ?? el.textContent ?? "").trim();
1531
+ }
1532
+ function hasSubstantiveContent(text) {
1533
+ if (!text) return false;
1534
+ const normalized = text.replace(/\s+/g, " ").trim();
1535
+ return normalized.length >= 16 && normalized.split(" ").filter(Boolean).length >= 2;
1536
+ }
1537
+ function hasAIOverviewLabel(el) {
1538
+ const heading = el.querySelector(selectors.aio.heading);
1539
+ if (textOf(heading) === "AI Overview") return true;
1540
+ const header = el.querySelector(selectors.aio.header);
1541
+ if (textOf(header).split(/\n|\s{2,}/).some((part) => part.trim() === "AI Overview")) return true;
1542
+ return textOf(el).includes("AI Overview");
1543
+ }
1544
+ function findAIORoot() {
1545
+ const primaryRoots = Array.from(document.querySelectorAll(selectors.aio.root));
1546
+ const labeledPrimary = primaryRoots.find(hasAIOverviewLabel);
1547
+ if (labeledPrimary) return labeledPrimary;
1548
+ if (primaryRoots.length > 0) return primaryRoots[0];
1549
+ if (selectors.aio.legacyRoot) {
1550
+ const legacy = document.querySelector(selectors.aio.legacyRoot);
1551
+ if (legacy) return legacy;
1552
+ }
1553
+ const headings = document.querySelectorAll(`${selectors.aio.heading}, h1, h2, h3, [role="heading"]`);
1554
+ for (const h of headings) {
1555
+ if (textOf(h) !== "AI Overview") continue;
1556
+ let el = h.parentElement;
1557
+ for (let i = 0; i < 8 && el; i++) {
1558
+ if (el.matches(selectors.aio.root) || el.querySelector(selectors.aio.controller) || el.querySelector(selectors.aio.contentSubtree)) {
1559
+ return el;
1560
+ }
1561
+ el = el.parentElement;
1562
+ }
1563
+ return h.parentElement;
1564
+ }
1565
+ return null;
1566
+ }
1567
+ function cleanText(target) {
1568
+ if (!target) return null;
1569
+ const clone = target.cloneNode(true);
1570
+ clone.querySelectorAll([
1571
+ "script",
1572
+ "style",
1573
+ "noscript",
1574
+ "img",
1575
+ "picture",
1576
+ "video",
1577
+ selectors.aio.header,
1578
+ selectors.aio.showMoreButton,
1579
+ selectors.aio.sourcesPanel,
1580
+ selectors.aio.disclaimer,
1581
+ '[data-subtree="dfa"]',
1582
+ "[data-src-id]",
1583
+ '[role="dialog"]',
1584
+ ".HWMcu",
1585
+ ".bTFeG",
1586
+ ".CyMdWb",
1587
+ ".MFrAxb",
1588
+ ".F0OfWd.hfWAgb",
1589
+ ".x2qcTc.fZavHb",
1590
+ ".SvjEff",
1591
+ ".sR2MY",
1592
+ ".lKuDef",
1593
+ ".GSPQcc",
1594
+ "a[href]",
1595
+ "button",
1596
+ '[role="button"]'
1597
+ ].join(",")).forEach((el) => el.remove());
1598
+ const holder = document.createElement("div");
1599
+ holder.style.position = "fixed";
1600
+ holder.style.left = "-10000px";
1601
+ holder.style.top = "0";
1602
+ holder.style.width = `${Math.max(320, Math.round(target.getBoundingClientRect?.().width || 960))}px`;
1603
+ holder.style.opacity = "0";
1604
+ holder.style.pointerEvents = "none";
1605
+ holder.append(clone);
1606
+ document.body.append(holder);
1607
+ const rendered = clone.innerText || clone.textContent || "";
1608
+ holder.remove();
1609
+ const lines = rendered.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim().split("\n").map((line) => line.replace(/\u00a0/g, " ").trim()).filter(Boolean);
1610
+ const filteredLines = [];
1611
+ for (let i = 0; i < lines.length; i++) {
1612
+ const line = lines[i];
1613
+ const next = lines[i + 1] ?? "";
1614
+ if (line === "AI Overview") continue;
1615
+ if (line === "Show more") continue;
1616
+ if (/^AI can make mistakes/i.test(line)) continue;
1617
+ if (/^Thank you\b/i.test(line)) continue;
1618
+ if (/^Your feedback helps Google improve/i.test(line)) continue;
1619
+ if (/^\+?\d+$/.test(line)) continue;
1620
+ if (/^\+\d+$/.test(next) && line.length <= 80) {
1621
+ i++;
1622
+ continue;
1623
+ }
1624
+ filteredLines.push(line);
1625
+ }
1626
+ const raw = filteredLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
1627
+ if (!raw || /not available|try again|can't generate/i.test(raw)) return null;
1628
+ return raw;
1629
+ }
1630
+ function normalizeHref(rawHref) {
1631
+ if (!rawHref || rawHref.startsWith("javascript:")) return null;
1632
+ let href = rawHref;
1633
+ try {
1634
+ const absolute = new URL(rawHref, window.location.href);
1635
+ const q = absolute.searchParams.get("q") ?? absolute.searchParams.get("url");
1636
+ if (/(\.|^)google\./i.test(absolute.hostname) && q?.startsWith("http")) {
1637
+ href = q;
1638
+ } else {
1639
+ href = absolute.href;
1640
+ }
1641
+ } catch {
1642
+ return null;
1643
+ }
1644
+ if (!/^https?:\/\//i.test(href)) return null;
1645
+ try {
1646
+ const url = new URL(href);
1647
+ const isGoogleInternal = /(\.|^)google\./i.test(url.hostname);
1648
+ if (isGoogleInternal) return null;
1649
+ return url.href;
1650
+ } catch {
1651
+ return null;
1652
+ }
1653
+ }
1654
+ function extractCitations(root) {
1655
+ if (!root) return [];
1656
+ const seen = /* @__PURE__ */ new Set();
1657
+ const citations = [];
1658
+ for (const a of Array.from(root.querySelectorAll("a[href]"))) {
1659
+ const href = normalizeHref(a.getAttribute("href") ?? "");
1660
+ if (!href || seen.has(href)) continue;
1661
+ seen.add(href);
1662
+ let fallbackHost = "";
1663
+ try {
1664
+ fallbackHost = new URL(href).hostname.replace(/^www\./, "");
1665
+ } catch {
1666
+ }
1667
+ citations.push({
1668
+ text: textOf(a) || fallbackHost || href,
1669
+ href
1670
+ });
1671
+ }
1672
+ return citations;
1673
+ }
1674
+ async function maybeExpand(root) {
1675
+ const button = root.querySelector(selectors.aio.showMoreButton);
1676
+ if (!button || button.getAttribute("aria-expanded") !== "false") return false;
1677
+ button.click();
1678
+ const waitMs = selectors.expandWaitMs ?? 1500;
1679
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
1680
+ return true;
1681
+ }
1682
+ const aioRoot = findAIORoot();
1683
+ let aioText = null;
1684
+ let aioCitations = [];
1685
+ let aioExpanded = false;
1686
+ let aioFullyExpanded = false;
1687
+ let aioSections = [];
1688
+ if (aioRoot) {
1689
+ aioExpanded = await maybeExpand(aioRoot);
1690
+ const controller = aioRoot.querySelector(selectors.aio.controller);
1691
+ const contentSubtree = aioRoot.querySelector(selectors.aio.contentSubtree);
1692
+ const showMore = aioRoot.querySelector(selectors.aio.showMoreButton);
1693
+ aioFullyExpanded = controller?.getAttribute("data-trnct") === "false" || showMore?.getAttribute("aria-expanded") === "true" || !showMore;
1694
+ aioText = cleanText(contentSubtree ?? controller ?? aioRoot);
1695
+ aioSections = (aioText ?? "").split("\n").map((line) => line.trim()).filter((line) => /^\d+\.\s+.+/.test(line));
1696
+ aioCitations = extractCitations(aioRoot);
1697
+ }
1698
+ const aimRoot = document.querySelector(selectors.aim.root);
1699
+ const aimRootDetected = surface === "aim" && !!aimRoot;
1700
+ const aimContainer = aimRoot?.closest(selectors.aim.wrapper) ?? aimRoot;
1701
+ const rawAimText = aimRootDetected ? cleanText(aimContainer) : null;
1702
+ const aimDetected = aimRootDetected && hasSubstantiveContent(rawAimText);
1703
+ const aimText = aimDetected ? rawAimText : null;
1704
+ const aimCitations = aimDetected ? extractCitations(aimContainer) : [];
1705
+ const aiOverviewDetected = !!aioRoot && hasSubstantiveContent(aioText);
1706
+ return {
1707
+ surface,
1708
+ aiOverview: {
1709
+ detected: aiOverviewDetected,
1710
+ text: aiOverviewDetected ? aioText : null,
1711
+ citations: aiOverviewDetected ? aioCitations : [],
1712
+ expanded: aiOverviewDetected ? aioExpanded : false,
1713
+ fullyExpanded: aiOverviewDetected ? aioFullyExpanded : false,
1714
+ sections: aiOverviewDetected ? aioSections : []
1715
+ },
1716
+ aiMode: {
1717
+ detected: aimDetected && aimText !== null,
1718
+ text: aimText,
1719
+ citations: aimCitations
1720
+ }
1721
+ };
1722
+ }
1723
+
1724
+ // src/extractor/PAAExtractor.ts
1725
+ var DESKTOP_USER_AGENT2 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1726
+ var MOBILE_USER_AGENT2 = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
1727
+ var PAAExtractor = class {
1728
+ constructor(driver, reporter) {
1729
+ this.driver = driver;
1730
+ this.reporter = reporter;
1731
+ }
1732
+ driver;
1733
+ reporter;
1734
+ completeness = {
1735
+ paaWithoutAnswer: 0,
1736
+ paaWithoutSource: 0,
1737
+ paaAnswersRecovered: 0,
1738
+ aioShareCaptured: null
1739
+ };
1740
+ aioShareUrlEarly = null;
1741
+ collectedRows = [];
1742
+ partialContext = null;
1743
+ normalizeQuestion(q) {
1744
+ return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
1745
+ }
1746
+ throwIfAborted(signal) {
1747
+ if (!signal?.aborted) return;
1748
+ if (signal.reason instanceof DOMException && signal.reason.name === "TimeoutError") throw signal.reason;
1749
+ throw new RequestAbortedError();
1750
+ }
1751
+ async throwIfCaptcha(page, context) {
1752
+ const captchaCount = await page.locator(PAASelectors.captchaMarker).count().catch(() => 0);
1753
+ if (captchaCount > 0) {
1754
+ throw new CaptchaError(`${context} returned a CAPTCHA \u2014 retrying with a fresh session.`);
1755
+ }
1756
+ }
1757
+ async extractVisibleItems(page) {
1758
+ const sels = PAASelectors;
1759
+ const raw = await page.evaluate((selectors) => {
1760
+ function cleanText(el) {
1761
+ if (!el) return "";
1762
+ const parts = [];
1763
+ for (const n of el.childNodes) {
1764
+ if (n.nodeType === Node.TEXT_NODE) {
1765
+ const text = n.textContent?.trim();
1766
+ if (text) parts.push(text);
1767
+ } else if (n.tagName === "STYLE" || n.tagName === "SCRIPT") {
1768
+ continue;
1769
+ } else {
1770
+ const text = cleanText(n);
1771
+ if (text) parts.push(text);
1772
+ }
1773
+ }
1774
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1775
+ }
1776
+ return Array.from(document.querySelectorAll(selectors.item)).map((pair) => {
1777
+ let sourceTitle = pair.querySelector(selectors.sourceTitle)?.innerText?.trim() || void 0;
1778
+ let sourceSite = pair.querySelector(selectors.sourceSite)?.innerText?.trim() || void 0;
1779
+ let sourceCite = pair.querySelector(selectors.sourceCite)?.innerText?.trim() || void 0;
1780
+ if (!sourceCite) {
1781
+ const seenHrefs = /* @__PURE__ */ new Set();
1782
+ const anchors = [];
1783
+ for (const a of Array.from(pair.querySelectorAll("a[href]"))) {
1784
+ const href = a.href;
1785
+ if (!/^https?:\/\//.test(href) || seenHrefs.has(href)) continue;
1786
+ let host = "";
1787
+ try {
1788
+ host = new URL(href).hostname;
1789
+ } catch {
1790
+ continue;
1791
+ }
1792
+ if (/(^|\.)google(usercontent)?\.[a-z.]+$/.test(host)) continue;
1793
+ seenHrefs.add(href);
1794
+ anchors.push({ text: (a.textContent || "").trim(), href, host });
1795
+ }
1796
+ const primary = anchors.find((x) => x.text.length > 2) ?? anchors[0];
1797
+ if (primary) {
1798
+ sourceTitle = sourceTitle || primary.text || void 0;
1799
+ sourceSite = sourceSite || primary.host.replace(/^www\./, "");
1800
+ sourceCite = primary.href;
1801
+ }
1802
+ }
1803
+ return {
1804
+ question: pair.getAttribute(selectors.itemDataQ) || pair.getAttribute(selectors.itemDataInitQ) || pair.querySelector(selectors.itemQuestionEl)?.innerText?.trim() || "",
1805
+ answer: cleanText(pair.querySelector(selectors.answerContainer)) || void 0,
1806
+ sourceTitle,
1807
+ sourceSite,
1808
+ sourceCite
1809
+ };
1810
+ });
1811
+ }, sels);
1812
+ return raw.flatMap((item) => {
1813
+ const cleaned = {
1814
+ ...item,
1815
+ answer: cleanPAAAnswerText(item.answer, item.question, item.sourceTitle)
1816
+ };
1817
+ const result = RawPAAItemSchema.safeParse(cleaned);
1818
+ if (!result.success) {
1819
+ console.warn("[PAAExtractor] item parse failed:", item.question, result.error.issues[0]?.message);
1820
+ return [];
1821
+ }
1822
+ return [result.data];
1823
+ });
1824
+ }
1825
+ async clickItem(page, questionText) {
1826
+ try {
1827
+ const pairLocator = page.locator(
1828
+ `${PAASelectors.item}[data-q="${questionText}"], ${PAASelectors.item}[data-initq="${questionText}"]`
1829
+ ).first();
1830
+ await pairLocator.click();
1831
+ } catch {
1832
+ }
1833
+ }
1834
+ toFlatRow(item, depth, parentQuestion, seed) {
1835
+ return {
1836
+ seed_query: seed,
1837
+ question: item.question,
1838
+ answer: item.answer ?? "",
1839
+ source_title: item.sourceTitle ?? "",
1840
+ source_site: item.sourceSite ?? "",
1841
+ source_cite: item.sourceCite ?? "",
1842
+ depth,
1843
+ parent_question: parentQuestion ?? "",
1844
+ extracted_at: (/* @__PURE__ */ new Date()).toISOString()
1845
+ };
1846
+ }
1847
+ getPartialResult() {
1848
+ const ctx = this.partialContext;
1849
+ if (!ctx || this.collectedRows.length === 0) return null;
1850
+ const flat = [...this.collectedRows];
1851
+ const stats = {
1852
+ seed: ctx.seed,
1853
+ totalQuestions: flat.length,
1854
+ maxDepthReached: flat.reduce((m, r) => Math.max(m, r.depth), 0),
1855
+ durationMs: Date.now() - ctx.startMs,
1856
+ errorCount: 0
1857
+ };
1858
+ return {
1859
+ seed: ctx.seed,
1860
+ location: ctx.location,
1861
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
1862
+ diagnostics: {
1863
+ completionStatus: "paa_found",
1864
+ problem: null,
1865
+ completeness: { ...this.completeness },
1866
+ warnings: [{
1867
+ code: "paa_partial",
1868
+ surface: "paa",
1869
+ message: `Harvest stopped before finishing; returning the ${flat.length} question(s) collected up to that point.`,
1870
+ retryable: true
1871
+ }]
1872
+ },
1873
+ totalQuestions: flat.length,
1874
+ whatPeopleSaying: [],
1875
+ tree: this.buildTree(flat, ctx.seed),
1876
+ flat,
1877
+ videos: [],
1878
+ forums: [],
1879
+ organicResults: [],
1880
+ localPack: [],
1881
+ entityIds: {},
1882
+ stats
1883
+ };
1884
+ }
1885
+ async runBFS(page, options, signal) {
1886
+ const seenKeys = /* @__PURE__ */ new Set();
1887
+ const seenQs = /* @__PURE__ */ new Set();
1888
+ const orderedQs = [];
1889
+ this.collectedRows = [];
1890
+ const results = this.collectedRows;
1891
+ const abandonedNeverReclick = /* @__PURE__ */ new Set();
1892
+ const clickedOnceEver = /* @__PURE__ */ new Set();
1893
+ const itemArgs = {
1894
+ sel: PAASelectors.item,
1895
+ dataQ: PAASelectors.itemDataQ,
1896
+ dataInitQ: PAASelectors.itemDataInitQ,
1897
+ questionEl: PAASelectors.itemQuestionEl,
1898
+ answerSel: PAASelectors.answerContainer,
1899
+ expandedClass: PAASelectors.expandedClass,
1900
+ clickTarget: PAASelectors.clickTarget
1901
+ };
1902
+ const readItemStates = () => page.evaluate(
1903
+ ({ sel, dataQ, dataInitQ, questionEl, expandedClass, answerSel }) => {
1904
+ const answerText = (root) => {
1905
+ if (!root) return "";
1906
+ let out = "";
1907
+ const walk = (n) => {
1908
+ for (const c of Array.from(n.childNodes)) {
1909
+ if (c.nodeType === Node.TEXT_NODE) out += c.textContent || "";
1910
+ else if (c.nodeType === Node.ELEMENT_NODE && c.tagName !== "STYLE" && c.tagName !== "SCRIPT") walk(c);
1911
+ }
1912
+ };
1913
+ walk(root);
1914
+ return out.replace(/\s+/g, " ").trim();
1915
+ };
1916
+ return Array.from(document.querySelectorAll(sel)).map((el) => ({
1917
+ q: el.getAttribute(dataQ) || el.getAttribute(dataInitQ) || el.querySelector(questionEl)?.innerText?.trim() || "",
1918
+ done: el.classList.contains(expandedClass) || answerText(el.querySelector(answerSel)).length > 30
1919
+ })).filter((s) => s.q);
1920
+ },
1921
+ itemArgs
1922
+ );
1923
+ const expandOneItemSerially = async (q) => {
1924
+ const clicked = await page.evaluate(
1925
+ ({ sel, dataQ, dataInitQ, clickTarget, q: target }) => {
1926
+ const item = Array.from(document.querySelectorAll(sel)).find((el) => (el.getAttribute(dataQ) || el.getAttribute(dataInitQ)) === target);
1927
+ const btn = item?.querySelector(clickTarget);
1928
+ if (!btn) return false;
1929
+ btn.scrollIntoView({ block: "center" });
1930
+ btn.click();
1931
+ return true;
1932
+ },
1933
+ { ...itemArgs, q }
1934
+ ).catch(() => false);
1935
+ if (!clicked) return "failed";
1936
+ try {
1937
+ const outcome = await page.waitForFunction(
1938
+ ({ sel, dataQ, dataInitQ, answerSel, q: target }) => {
1939
+ const answerText = (root) => {
1940
+ if (!root) return "";
1941
+ let out = "";
1942
+ const walk = (n) => {
1943
+ for (const c of Array.from(n.childNodes)) {
1944
+ if (c.nodeType === Node.TEXT_NODE) out += c.textContent || "";
1945
+ else if (c.nodeType === Node.ELEMENT_NODE && c.tagName !== "STYLE" && c.tagName !== "SCRIPT") walk(c);
1946
+ }
1947
+ };
1948
+ walk(root);
1949
+ return out.replace(/\s+/g, " ").trim();
1950
+ };
1951
+ const item = Array.from(document.querySelectorAll(sel)).find((el) => (el.getAttribute(dataQ) || el.getAttribute(dataInitQ)) === target);
1952
+ if (!item) return "gone";
1953
+ const text = answerText(item.querySelector(answerSel));
1954
+ if (/an error has occurred/i.test(text.slice(0, 80))) return "error";
1955
+ if (text.length > 30) return "ok";
1956
+ return false;
1957
+ },
1958
+ { ...itemArgs, q },
1959
+ { timeout: 6500, polling: 200 }
1960
+ );
1961
+ return await outcome.jsonValue() === "ok" ? "ok" : "failed";
1962
+ } catch {
1963
+ return "failed";
1964
+ }
1965
+ };
1966
+ let round = 0;
1967
+ let growthWaits = 0;
1968
+ while (true) {
1969
+ if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
1970
+ this.throwIfAborted(signal);
1971
+ const states = await readItemStates();
1972
+ for (const s of states) {
1973
+ if (!seenQs.has(s.q)) {
1974
+ seenQs.add(s.q);
1975
+ orderedQs.push(s.q);
1976
+ }
1977
+ }
1978
+ const kept = new Set(orderedQs.slice(0, options.maxQuestions));
1979
+ const clickable = states.filter((s) => kept.has(s.q) && !clickedOnceEver.has(s.q) && !abandonedNeverReclick.has(s.q));
1980
+ const target = clickable.find((s) => !s.done) ?? (seenQs.size < options.maxQuestions ? clickable[0] : void 0);
1981
+ if (!target) {
1982
+ if (seenQs.size >= options.maxQuestions) break;
1983
+ if (++growthWaits > 1) break;
1984
+ const grew = await page.waitForFunction(
1985
+ ({ sel, min }) => document.querySelectorAll(sel).length > min,
1986
+ { sel: PAASelectors.item, min: states.length },
1987
+ { timeout: 3e3 }
1988
+ ).catch(() => null);
1989
+ if (!grew) break;
1990
+ continue;
1991
+ }
1992
+ growthWaits = 0;
1993
+ this.reporter.onDepth(++round);
1994
+ await this.throwIfCaptcha(page, "Google PAA expansion");
1995
+ clickedOnceEver.add(target.q);
1996
+ if (await expandOneItemSerially(target.q) === "failed") abandonedNeverReclick.add(target.q);
1997
+ }
1998
+ const itemMap = new Map((await this.extractVisibleItems(page)).map((i) => [i.question, i]));
1999
+ await this.fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick);
2000
+ for (const q of orderedQs) {
2001
+ if (results.length >= options.maxQuestions) break;
2002
+ const key = this.normalizeQuestion(q);
2003
+ if (seenKeys.has(key)) continue;
2004
+ seenKeys.add(key);
2005
+ const item = itemMap.get(q);
2006
+ if (item) {
2007
+ results.push(this.toFlatRow(item, 1, null, options.query));
2008
+ 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: [] });
2009
+ } else {
2010
+ results.push(this.toFlatRow({ question: q, answer: void 0, sourceTitle: void 0, sourceSite: void 0, sourceCite: void 0 }, 1, null, options.query));
2011
+ }
2012
+ }
2013
+ this.completeness.paaWithoutAnswer = results.filter((r) => !r.answer).length;
2014
+ this.completeness.paaWithoutSource = results.filter((r) => !r.source_title && !r.source_site && !r.source_cite).length;
2015
+ return results;
2016
+ }
2017
+ async fillIncompleteItems(page, orderedQs, itemMap, options, abandonedNeverReclick) {
2018
+ const kept = new Set(orderedQs.slice(0, options.maxQuestions));
2019
+ const missingBefore = new Set(
2020
+ [...kept].filter((q) => !itemMap.get(q)?.answer && !abandonedNeverReclick?.has(q))
2021
+ );
2022
+ if (missingBefore.size === 0) return;
2023
+ for (let round = 0; round < 2; round++) {
2024
+ if (options.softDeadlineMs && Date.now() >= options.softDeadlineMs) break;
2025
+ const stillMissing = [...missingBefore].filter((q) => !itemMap.get(q)?.answer);
2026
+ if (stillMissing.length === 0) break;
2027
+ const reExpanded = await this.reExpandCollapsedItems(page).catch(() => false);
2028
+ await page.waitForTimeout(reExpanded ? 900 : 500);
2029
+ let improved = false;
2030
+ for (const item of await this.extractVisibleItems(page)) {
2031
+ if (!kept.has(item.question)) continue;
2032
+ const existing = itemMap.get(item.question);
2033
+ if (!item.answer || existing?.answer) continue;
2034
+ itemMap.set(item.question, {
2035
+ ...item,
2036
+ sourceTitle: item.sourceTitle ?? existing?.sourceTitle,
2037
+ sourceSite: item.sourceSite ?? existing?.sourceSite,
2038
+ sourceCite: item.sourceCite ?? existing?.sourceCite
2039
+ });
2040
+ improved = true;
2041
+ }
2042
+ if (!improved) break;
2043
+ }
2044
+ this.completeness.paaAnswersRecovered = [...missingBefore].filter((q) => itemMap.get(q)?.answer).length;
2045
+ }
2046
+ async reExpandCollapsedItems(page) {
2047
+ const unexpandedSel = `${PAASelectors.item}:not(.${PAASelectors.expandedClass}) ${PAASelectors.clickTarget}`;
2048
+ const count = await page.locator(unexpandedSel).count().catch(() => 0);
2049
+ if (count === 0) return false;
2050
+ for (let i = 0; i < count; i++) {
2051
+ try {
2052
+ const btn = page.locator(unexpandedSel).first();
2053
+ await btn.scrollIntoViewIfNeeded();
2054
+ await btn.click({ force: true });
2055
+ await page.waitForTimeout(300);
2056
+ } catch {
2057
+ }
2058
+ }
2059
+ return true;
2060
+ }
2061
+ async extractVideos(page) {
2062
+ const vsels = VideoSelectors;
2063
+ return page.evaluate((sels) => {
2064
+ const results = [];
2065
+ const containers = Array.from(document.querySelectorAll(sels.container));
2066
+ for (const container of containers) {
2067
+ const headingEl = container.querySelector(sels.sectionHeading);
2068
+ const headingText = headingEl?.textContent?.trim() ?? "";
2069
+ const type = headingText.toLowerCase().includes("short") ? "short_video" : "video";
2070
+ const items = Array.from(container.querySelectorAll(sels.item));
2071
+ for (const a of items) {
2072
+ const href = a.href;
2073
+ if (!href || !href.includes("youtube") && !href.includes("youtu.be")) continue;
2074
+ const raw = a.textContent?.trim() ?? "";
2075
+ const ytIdx = raw.indexOf("YouTube");
2076
+ if (ytIdx === -1) continue;
2077
+ const title = raw.slice(0, ytIdx).trim();
2078
+ const remainder = raw.slice(ytIdx + 7).replace(/^[·\s·]+/, "");
2079
+ const channelMatch = remainder.match(/^([^·\n]+)/);
2080
+ const channel = channelMatch ? channelMatch[1].trim() : "";
2081
+ if (title) results.push({ type, title, channel, platform: "YouTube", duration: "", url: href });
2082
+ }
2083
+ }
2084
+ return results;
2085
+ }, vsels);
2086
+ }
2087
+ async extractForums(page) {
2088
+ const fsels = ForumSelectors;
2089
+ return page.evaluate((sels) => {
2090
+ const results = [];
2091
+ const sections = Array.from(document.querySelectorAll(sels.section));
2092
+ const forumSection = sections.find((s) => s.textContent?.includes("Discussions"));
2093
+ if (!forumSection) return results;
2094
+ const items = Array.from(forumSection.querySelectorAll(sels.item));
2095
+ for (const a of items) {
2096
+ const href = a.href;
2097
+ if (!href) continue;
2098
+ const titleEl = a.querySelector(sels.title);
2099
+ const sourceEl = a.querySelector(sels.source);
2100
+ const title = titleEl?.textContent?.trim() ?? "";
2101
+ const source = sourceEl?.textContent?.trim() ?? "";
2102
+ if (title) results.push({ title, source, url: href });
2103
+ }
2104
+ return results;
2105
+ }, fsels);
2106
+ }
2107
+ async extractShortVideos(page, shortUrl) {
2108
+ try {
2109
+ await page.goto(shortUrl, { waitUntil: "domcontentloaded" });
2110
+ await page.waitForTimeout(1500);
2111
+ } catch {
2112
+ return [];
2113
+ }
2114
+ await this.throwIfCaptcha(page, "Google short video search");
2115
+ const svSels = {
2116
+ item: ShortVideoSelectors.item,
2117
+ platforms: [...ShortVideoSelectors.platforms]
2118
+ };
2119
+ const raw = await page.evaluate((sels) => {
2120
+ const seen = /* @__PURE__ */ new Set();
2121
+ const results = [];
2122
+ const items = Array.from(document.querySelectorAll(sels.item));
2123
+ const videoHosts = ["youtube.com", "youtu.be", "tiktok.com", "instagram.com", "facebook.com", "fb.watch"];
2124
+ const byHref = /* @__PURE__ */ new Map();
2125
+ for (const a of items) {
2126
+ const href = a.href;
2127
+ if (!href) continue;
2128
+ if (!videoHosts.some((h) => href.includes(h))) continue;
2129
+ const text = a.textContent?.trim() ?? "";
2130
+ if (!byHref.has(href)) byHref.set(href, []);
2131
+ byHref.get(href).push(text);
2132
+ }
2133
+ for (const [href, texts] of byHref.entries()) {
2134
+ if (seen.has(href)) continue;
2135
+ seen.add(href);
2136
+ const duration = texts.find((t) => /^\d+:\d+$/.test(t)) ?? "";
2137
+ const titleText = texts.find((t) => !/^\d+:\d+$/.test(t) && t.length > 5) ?? "";
2138
+ if (!titleText) continue;
2139
+ let title = titleText;
2140
+ let platform = "";
2141
+ let channel = "";
2142
+ for (const p of sels.platforms) {
2143
+ let lastIdx = -1;
2144
+ let search = 0;
2145
+ while (true) {
2146
+ const found = titleText.indexOf(p, search);
2147
+ if (found === -1) break;
2148
+ lastIdx = found;
2149
+ search = found + 1;
2150
+ }
2151
+ if (lastIdx === -1) continue;
2152
+ const after = titleText.slice(lastIdx + p.length);
2153
+ const isSourceTag = /^[\s·]/.test(after) || after.trim() === "";
2154
+ if (!isSourceTag) continue;
2155
+ title = titleText.slice(0, lastIdx).trim();
2156
+ platform = p;
2157
+ const stripped = after.replace(/^[\s·]+/, "");
2158
+ const dotIdx = stripped.indexOf("\xB7");
2159
+ channel = (dotIdx === -1 ? stripped : stripped.slice(0, dotIdx)).trim();
2160
+ break;
2161
+ }
2162
+ if (title) results.push({ title, channel, platform, duration, url: href });
2163
+ }
2164
+ return results;
2165
+ }, svSels);
2166
+ return raw.map((r) => ({ type: "short_video", ...r }));
2167
+ }
2168
+ async extractWhatPeopleSaying(page) {
2169
+ const sels = WhatPeopleSayingSelectors;
2170
+ return page.evaluate((s) => {
2171
+ const section = Array.from(document.querySelectorAll(s.sectionTag)).find((el) => el.textContent?.includes(s.sectionHeadingText)) ?? document.querySelector(".yG4QQe.TBC9ub.NbhJ1c");
2172
+ if (!section) return [];
2173
+ return Array.from(section.querySelectorAll(s.card)).map((card) => {
2174
+ const link = card.querySelector(s.cardLink);
2175
+ const url = link?.href ?? "";
2176
+ const titleH1 = card.querySelector(s.titleH1)?.textContent?.trim();
2177
+ const titleDiv = card.querySelector(s.titleDiv)?.textContent?.trim();
2178
+ const title = titleH1 ?? titleDiv ?? "";
2179
+ const sourceText = card.querySelector(s.source)?.textContent?.trim() ?? "";
2180
+ const platformEl = card.querySelector(s.platformBadge);
2181
+ const platformText = platformEl?.textContent?.trim() ?? "";
2182
+ const ytChannel = card.querySelector(s.ytChannel)?.textContent?.trim() ?? "";
2183
+ const ytDate = card.querySelector(s.ytDate)?.textContent?.trim() ?? "";
2184
+ const authorNote = card.querySelector(s.authorNote)?.textContent?.trim() ?? null;
2185
+ const commentLabelEl = card.querySelector(s.popularCommentLabel);
2186
+ let popularComment = null;
2187
+ if (commentLabelEl) {
2188
+ let next = commentLabelEl.nextSibling;
2189
+ while (next) {
2190
+ const t = next.textContent?.trim();
2191
+ if (t) {
2192
+ popularComment = t;
2193
+ break;
2194
+ }
2195
+ next = next.nextSibling;
2196
+ }
2197
+ }
2198
+ const allSpans = Array.from(card.querySelectorAll("span"));
2199
+ const duration = allSpans.find((s2) => /^\d+:\d+$/.test(s2.textContent?.trim() ?? ""))?.textContent?.trim() ?? null;
2200
+ const engagementParts = allSpans.map((s2) => s2.textContent?.trim() ?? "").filter(
2201
+ (t) => /\d/.test(t) && (t.includes("comment") || t.includes("reaction") || t.includes("view") || t.includes("like") || t.includes("share"))
2202
+ );
2203
+ const engagement = engagementParts[0] ?? "";
2204
+ const dateCandidates = allSpans.map((s2) => s2.textContent?.trim() ?? "").filter((t) => /\d+ (day|week|month|year|hour)s? ago|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec/.test(t));
2205
+ const date = ytDate || (dateCandidates[0] ?? "");
2206
+ const platform = platformText || (ytChannel ? "YouTube" : "");
2207
+ const source = ytChannel || sourceText;
2208
+ let type = "unknown";
2209
+ const pl = platform.toLowerCase();
2210
+ const src = source.toLowerCase();
2211
+ const srcRaw = sourceText.toLowerCase();
2212
+ if (pl.includes("reddit") || src.startsWith("r/")) type = "reddit";
2213
+ else if (pl.includes("facebook") || srcRaw.includes("facebook")) type = "facebook";
2214
+ else if (pl.includes("instagram") || srcRaw.includes("instagram")) type = "instagram";
2215
+ else if (pl.includes("tiktok") || srcRaw.includes("tiktok")) type = "tiktok";
2216
+ else if (pl.includes("youtube") || !!ytChannel) type = "youtube";
2217
+ else type = "news";
2218
+ return { type, title, url, source, platform, popularComment, engagement, date, duration, authorNote };
2219
+ });
2220
+ }, sels);
2221
+ }
2222
+ async extractOrganicResults(page) {
2223
+ const sels = OrganicSelectors;
2224
+ return page.evaluate((s) => {
2225
+ const out = [];
2226
+ let pos = 0;
2227
+ document.querySelectorAll(s.result).forEach((card) => {
2228
+ const titleEl = card.querySelector(s.title);
2229
+ if (!titleEl) return;
2230
+ const title = titleEl.textContent?.trim() ?? "";
2231
+ const linkEl = titleEl.closest("a");
2232
+ const url = linkEl?.href ?? "";
2233
+ if (!title || !url) return;
2234
+ pos++;
2235
+ const cite = card.querySelector(s.cite)?.textContent?.trim() ?? null;
2236
+ const snippet = card.querySelector(s.snippet)?.textContent?.trim() ?? null;
2237
+ const isRedditStyle = !!card.querySelector(s.redditCite);
2238
+ const ratingEl = card.querySelector(s.ratingWrap);
2239
+ const inlineRating = ratingEl ? { value: ratingEl.querySelector(s.ratingValue)?.textContent?.trim() ?? "", count: ratingEl.querySelector(s.reviewCount)?.textContent?.trim() ?? "" } : null;
2240
+ let domain = "";
2241
+ try {
2242
+ domain = new URL(url).hostname.replace(/^www\./, "");
2243
+ } catch {
2244
+ domain = card.querySelector(s.siteName)?.textContent?.trim() ?? "";
2245
+ }
2246
+ out.push({ position: pos, title, url, domain, cite, snippet, isRedditStyle, inlineRating });
2247
+ });
2248
+ return out;
2249
+ }, sels);
2250
+ }
2251
+ async extractLocalPack(page) {
2252
+ const sels = LocalPackSelectors;
2253
+ return page.evaluate((s) => {
2254
+ const out = [];
2255
+ let container = null;
2256
+ document.querySelectorAll('[role="heading"]').forEach((h) => {
2257
+ if (!container && h.textContent?.includes(s.headingText)) container = h.closest("[data-hveid]");
2258
+ });
2259
+ if (!container) return out;
2260
+ container.querySelectorAll(s.card).forEach((card, i) => {
2261
+ const name = card.querySelector(s.name)?.textContent?.trim() ?? "";
2262
+ if (!name) return;
2263
+ const rating = card.querySelector(s.ratingValue)?.textContent?.trim() ?? null;
2264
+ const reviewRaw = card.querySelector(s.reviewCount)?.textContent?.trim() ?? null;
2265
+ const reviewCount = reviewRaw ? reviewRaw.replace(/[()]/g, "").trim() : null;
2266
+ let cid = card.querySelector("a[data-cid]")?.getAttribute("data-cid") ?? null;
2267
+ if (!cid) {
2268
+ for (const link of Array.from(card.querySelectorAll("a[href]"))) {
2269
+ const m1 = link.href.match(/[?&]cid=(\d+)/);
2270
+ if (m1) {
2271
+ cid = m1[1];
2272
+ break;
2273
+ }
2274
+ const m2 = link.href.match(/!1s0x[0-9a-f]+:0x([0-9a-f]+)/i);
2275
+ if (m2) {
2276
+ try {
2277
+ cid = BigInt("0x" + m2[1]).toString();
2278
+ } catch {
2279
+ }
2280
+ if (cid) break;
2281
+ }
2282
+ }
2283
+ }
2284
+ const metadata = [];
2285
+ card.querySelectorAll("div, span").forEach((el) => {
2286
+ const text = Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent?.trim() ?? "").filter((t) => t.length > 1 && t.length < 120).join(" ");
2287
+ if (text && !metadata.includes(text)) metadata.push(text);
2288
+ });
2289
+ const links = Array.from(card.querySelectorAll("a[href]"));
2290
+ const directionsUrl = links.find((a) => a.href.includes("google.com/maps"))?.href ?? null;
2291
+ const websiteUrl = links.find((a) => !a.href.includes("google.com") && a.href.startsWith("http"))?.href ?? null;
2292
+ out.push({ position: i + 1, name, cid, rating, reviewCount, metadata, websiteUrl, directionsUrl });
2293
+ });
2294
+ return out;
2295
+ }, sels);
2296
+ }
2297
+ async extractEntityIds(page) {
2298
+ return page.evaluate(() => {
2299
+ const kgIds = /* @__PURE__ */ new Set();
2300
+ const cids = /* @__PURE__ */ new Set();
2301
+ const gcids = /* @__PURE__ */ new Set();
2302
+ const recordMap = /* @__PURE__ */ new Map();
2303
+ function nameFromWrapper(el) {
2304
+ const sel = [".OSrXXb", ".dbg0pd", ".tzt0oe", '[role="heading"]', "h3"];
2305
+ for (const s of sel) {
2306
+ const found = el.querySelector(s);
2307
+ if (found?.textContent?.trim()) return found.textContent.trim();
2308
+ }
2309
+ return "";
2310
+ }
2311
+ document.querySelectorAll('[id^="pv-/g/"]').forEach((wrapper) => {
2312
+ const raw = wrapper.getAttribute("id");
2313
+ if (!raw) return;
2314
+ const kgId = raw.replace("pv-", "");
2315
+ kgIds.add(kgId);
2316
+ const name = nameFromWrapper(wrapper);
2317
+ const cidEl = wrapper.querySelector("a[data-cid]");
2318
+ const cid = cidEl?.getAttribute("data-cid") ?? null;
2319
+ if (cid) cids.add(cid);
2320
+ if (name) recordMap.set(kgId, { name, kgId, cid, gcid: null });
2321
+ });
2322
+ document.querySelectorAll("[data-mid]").forEach((el) => {
2323
+ const mid = el.getAttribute("data-mid");
2324
+ if (!mid?.startsWith("/g/")) return;
2325
+ kgIds.add(mid);
2326
+ if (!recordMap.has(mid)) {
2327
+ const name = nameFromWrapper(el);
2328
+ if (name) recordMap.set(mid, { name, kgId: mid, cid: null, gcid: null });
2329
+ }
2330
+ });
2331
+ document.querySelectorAll(".w7Dbne").forEach((card) => {
2332
+ const cidEl = card.querySelector("a[data-cid]");
2333
+ const cid = cidEl?.getAttribute("data-cid") ?? null;
2334
+ if (!cid) return;
2335
+ cids.add(cid);
2336
+ const name = card.querySelector(".OSrXXb")?.textContent?.trim() ?? "";
2337
+ if (!name) return;
2338
+ const kgIdEl = card.querySelector('[id^="pv-/g/"]');
2339
+ const kgId = kgIdEl ? kgIdEl.getAttribute("id").replace("pv-", "") : null;
2340
+ const key = kgId ?? `cid:${cid}`;
2341
+ if (recordMap.has(key)) {
2342
+ const existing = recordMap.get(key);
2343
+ if (!existing.cid) recordMap.set(key, { ...existing, cid });
2344
+ } else {
2345
+ recordMap.set(key, { name, kgId, cid, gcid: null });
2346
+ }
2347
+ });
2348
+ document.querySelectorAll("a[data-cid]").forEach((el) => {
2349
+ const cid = el.getAttribute("data-cid");
2350
+ if (!cid) return;
2351
+ cids.add(cid);
2352
+ const alreadyNamed = [...recordMap.values()].some((r) => r.cid === cid);
2353
+ if (!alreadyNamed) {
2354
+ let node = el.parentElement;
2355
+ let name = "";
2356
+ for (let i = 0; i < 8 && node; i++) {
2357
+ const h = node.querySelector('.OSrXXb, .dbg0pd, [role="heading"], h3');
2358
+ if (h?.textContent?.trim()) {
2359
+ name = h.textContent.trim();
2360
+ break;
2361
+ }
2362
+ node = node.parentElement;
2363
+ }
2364
+ if (name) recordMap.set(`cid:${cid}`, { name, kgId: null, cid, gcid: null });
2365
+ }
2366
+ });
2367
+ const scriptContent = Array.from(document.querySelectorAll("script:not([src])")).map((s) => s.textContent ?? "").filter((t) => t.length > 1e4).join("\n");
2368
+ for (const m of scriptContent.matchAll(/\/g\/[a-zA-Z0-9_-]{5,20}/g)) kgIds.add(m[0]);
2369
+ for (const m of scriptContent.matchAll(/gcid:[a-zA-Z0-9_]+/g)) gcids.add(m[0]);
2370
+ for (const m of scriptContent.matchAll(/0x[0-9a-f]+:0x([0-9a-f]+)/gi)) {
2371
+ try {
2372
+ cids.add(BigInt("0x" + m[1]).toString());
2373
+ } catch {
2374
+ }
2375
+ }
2376
+ return { entities: [...recordMap.values()], kgIds: [...kgIds], cids: [...cids], gcids: [...gcids] };
2377
+ });
2378
+ }
2379
+ mergeLocalPackIntoEntities(entityIds, localPack) {
2380
+ const cidSet = new Set(entityIds.cids);
2381
+ const records = entityIds.entities.map((r) => ({ ...r }));
2382
+ for (const biz of localPack) {
2383
+ if (!biz.cid) continue;
2384
+ cidSet.add(biz.cid);
2385
+ const nameNorm = biz.name.toLowerCase().trim();
2386
+ const byName = records.find((r) => r.name.toLowerCase().trim() === nameNorm);
2387
+ if (byName) {
2388
+ if (!byName.cid) byName.cid = biz.cid;
2389
+ } else if (!records.find((r) => r.cid === biz.cid)) {
2390
+ records.push({ name: biz.name, kgId: null, cid: biz.cid, gcid: null });
2391
+ }
2392
+ }
2393
+ return { ...entityIds, entities: records, cids: [...cidSet] };
2394
+ }
2395
+ async extractAISurfaces(page, options) {
2396
+ const surfaces = await page.evaluate(extractAISurfacesFromDocument, {
2397
+ aio: AIOverviewSelectors,
2398
+ aim: AIModeSelectors,
2399
+ expandWaitMs: 1500
2400
+ });
2401
+ if (!surfaces.aiOverview.detected) {
2402
+ this.completeness.aioShareCaptured = null;
2403
+ return surfaces;
2404
+ }
2405
+ const shareUrl = this.aioShareUrlEarly ?? await this.captureAIOverviewShareUrl(page, options).catch(() => null);
2406
+ this.completeness.aioShareCaptured = shareUrl !== null;
2407
+ return {
2408
+ ...surfaces,
2409
+ aiOverview: { ...surfaces.aiOverview, shareUrl }
2410
+ };
2411
+ }
2412
+ async captureAIOverviewShareUrl(page, options) {
2413
+ if (options?.softDeadlineMs && Date.now() >= options.softDeadlineMs) return null;
2414
+ const clicked = await page.evaluate(() => {
2415
+ const heads = Array.from(document.querySelectorAll('h1,h2,h3,[role="heading"],div')).filter((h) => (h.textContent || "").trim() === "AI Overview");
2416
+ for (const head of heads) {
2417
+ let sec = head;
2418
+ for (let up = 0; up < 8 && sec; up++) {
2419
+ const btn = sec.querySelector('[aria-label="Share"]');
2420
+ if (btn) {
2421
+ btn.scrollIntoView({ block: "center" });
2422
+ btn.click();
2423
+ return true;
2424
+ }
2425
+ sec = sec.parentElement;
2426
+ }
2427
+ }
2428
+ return false;
2429
+ }).catch(() => false);
2430
+ if (!clicked) return null;
2431
+ let url = null;
2432
+ try {
2433
+ const found = await page.waitForFunction(() => {
2434
+ const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
2435
+ while (w.nextNode()) {
2436
+ const m = (w.currentNode.textContent || "").match(/https:\/\/share\.google\/aimode\/\S+/);
2437
+ if (m) return m[0];
2438
+ }
2439
+ return false;
2440
+ }, void 0, { timeout: 4500, polling: 250 });
2441
+ url = await found.jsonValue();
2442
+ } catch {
2443
+ url = null;
2444
+ }
2445
+ await page.keyboard.press("Escape").catch(() => {
2446
+ });
2447
+ return url;
2448
+ }
2449
+ buildTree(flat, _seed) {
2450
+ const roots = [];
2451
+ const nodeMap = /* @__PURE__ */ new Map();
2452
+ for (const row of flat) {
2453
+ const node = {
2454
+ question: row.question,
2455
+ answer: row.answer || null,
2456
+ sourceTitle: row.source_title || null,
2457
+ sourceSite: row.source_site || null,
2458
+ sourceCite: row.source_cite || null,
2459
+ depth: row.depth,
2460
+ parentQuestion: row.parent_question || null,
2461
+ children: []
2462
+ };
2463
+ nodeMap.set(row.question, node);
2464
+ }
2465
+ for (const node of nodeMap.values()) {
2466
+ if (node.parentQuestion && nodeMap.has(node.parentQuestion)) {
2467
+ nodeMap.get(node.parentQuestion).children.push(node);
2468
+ } else {
2469
+ roots.push(node);
2470
+ }
2471
+ }
2472
+ return roots;
2473
+ }
2474
+ getBrowserDebugSnapshot() {
2475
+ return this.driver.getDebugSnapshot();
2476
+ }
2477
+ buildHarvestDebugSnapshot(options, canonicalLocation, uule, locationEvidence) {
2478
+ if (!options.debug) return void 0;
2479
+ return {
2480
+ enabled: true,
2481
+ request: {
2482
+ query: options.query,
2483
+ locationInput: options.location ?? null,
2484
+ canonicalLocation,
2485
+ uule,
2486
+ gl: options.gl,
2487
+ hl: options.hl,
2488
+ device: options.device,
2489
+ proxyMode: options.proxyMode,
2490
+ proxyZip: options.proxyZip ?? null,
2491
+ serpOnly: options.serpOnly,
2492
+ pages: options.pages ?? 1
2493
+ },
2494
+ browser: this.getBrowserDebugSnapshot(),
2495
+ ...locationEvidence ? { locationEvidence } : {}
2496
+ };
2497
+ }
2498
+ async extract(options, signal) {
2499
+ const startMs = Date.now();
2500
+ this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
2501
+ this.aioShareUrlEarly = null;
2502
+ this.collectedRows = [];
2503
+ this.partialContext = { seed: options.query, location: options.location ?? null, startMs };
2504
+ const isMobile = options.device === "mobile";
2505
+ const config = {
2506
+ headless: options.headless,
2507
+ profileDir: options.profileDir,
2508
+ proxy: options.proxy,
2509
+ kernelApiKey: options.kernelApiKey,
2510
+ kernelProxyId: options.kernelProxyId,
2511
+ kernelProxyResolution: options.kernelProxyResolution,
2512
+ proxyMode: options.proxyMode,
2513
+ keepDefaultProxy: options.keepDefaultProxy,
2514
+ requireUsEgress: options.requireUsEgress,
2515
+ viewport: isMobile ? { width: 390, height: 844 } : { width: 1280, height: 800 },
2516
+ locale: `${options.hl}-${options.gl.toUpperCase()}`,
2517
+ userAgent: isMobile ? MOBILE_USER_AGENT2 : DESKTOP_USER_AGENT2,
2518
+ deviceScaleFactor: isMobile ? 3 : 1,
2519
+ isMobile,
2520
+ hasTouch: isMobile,
2521
+ debug: options.debug
2522
+ };
2523
+ let errorCount = 0;
2524
+ const diagnosticWarnings = [];
2525
+ try {
2526
+ this.throwIfAborted(signal);
2527
+ await this.driver.launch(config);
2528
+ this.throwIfAborted(signal);
2529
+ const canonicalLocation = options.location ? normalizeLocation(options.location) : null;
2530
+ const uule = canonicalLocation ? encodeUule(canonicalLocation) : null;
2531
+ const executedQuery = buildLocalizedSerpQuery(options.query, options.location, canonicalLocation);
2532
+ const executionOptions = executedQuery === options.query ? options : { ...options, query: executedQuery };
2533
+ const { hasPaa } = await this.driver.navigateToSERP(
2534
+ executionOptions.query,
2535
+ uule,
2536
+ options.gl,
2537
+ options.hl,
2538
+ {
2539
+ ...options.serpOnly ? { num: 100 } : {},
2540
+ ...recencyToTbs(options.recency) ? { tbs: recencyToTbs(options.recency) } : {},
2541
+ debug: options.debug
2542
+ }
2543
+ );
2544
+ this.throwIfAborted(signal);
2545
+ const page = this.driver.getPage();
2546
+ await this.throwIfCaptcha(page, "Google SERP");
2547
+ this.aioShareUrlEarly = await this.captureAIOverviewShareUrl(page, options).catch(() => null);
2548
+ if (options.serpOnly) {
2549
+ const [organicResults2, localPack2, rawEntityIds2] = await Promise.all([
2550
+ this.extractOrganicResults(page),
2551
+ this.extractLocalPack(page),
2552
+ this.extractEntityIds(page)
2553
+ ]);
2554
+ const entityIds2 = this.mergeLocalPackIntoEntities(rawEntityIds2, localPack2);
2555
+ const aiSurfaces2 = await this.extractAISurfaces(page, options);
2556
+ let locationEvidence2 = options.debug ? inferSerpLocationEvidence(canonicalLocation, organicResults2, localPack2) : void 0;
2557
+ let allOrganic2 = organicResults2;
2558
+ if ((options.pages ?? 1) >= 2) {
2559
+ const p2params = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", start: "10" });
2560
+ if (recencyToTbs(options.recency)) p2params.set("tbs", recencyToTbs(options.recency));
2561
+ if (uule) p2params.set("uule", uule);
2562
+ await this.driver.navigateTo("https://www.google.com/search?" + p2params.toString());
2563
+ await this.throwIfCaptcha(page, "Google SERP page 2");
2564
+ const p2organic = await this.extractOrganicResults(page);
2565
+ allOrganic2 = [...organicResults2, ...p2organic.map((r) => ({ ...r, position: r.position + 10 }))];
2566
+ if (options.debug) {
2567
+ locationEvidence2 = inferSerpLocationEvidence(canonicalLocation, allOrganic2, localPack2);
2568
+ }
2569
+ }
2570
+ const stats2 = {
2571
+ seed: executionOptions.query,
2572
+ totalQuestions: 0,
2573
+ maxDepthReached: 0,
2574
+ durationMs: Date.now() - startMs,
2575
+ errorCount
2576
+ };
2577
+ this.reporter.onComplete(stats2);
2578
+ return {
2579
+ seed: executionOptions.query,
2580
+ location: options.location ?? null,
2581
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
2582
+ diagnostics: {
2583
+ completionStatus: "serp_only",
2584
+ problem: null,
2585
+ completeness: { ...this.completeness },
2586
+ ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2) } : {}
2587
+ },
2588
+ totalQuestions: 0,
2589
+ surface: aiSurfaces2.surface,
2590
+ aiOverview: aiSurfaces2.aiOverview,
2591
+ aiMode: aiSurfaces2.aiMode,
2592
+ whatPeopleSaying: [],
2593
+ tree: [],
2594
+ flat: [],
2595
+ videos: [],
2596
+ forums: [],
2597
+ organicResults: allOrganic2,
2598
+ localPack: localPack2,
2599
+ entityIds: entityIds2,
2600
+ stats: stats2
2601
+ };
2602
+ }
2603
+ const [videos, forums, whatPeopleSaying, rawEntityIds, organicResults, localPack] = await Promise.all([
2604
+ this.extractVideos(page),
2605
+ this.extractForums(page),
2606
+ this.extractWhatPeopleSaying(page),
2607
+ this.extractEntityIds(page),
2608
+ this.extractOrganicResults(page),
2609
+ this.extractLocalPack(page)
2610
+ ]);
2611
+ const entityIds = this.mergeLocalPackIntoEntities(rawEntityIds, localPack);
2612
+ const initialLocationEvidence = options.debug ? inferSerpLocationEvidence(canonicalLocation, organicResults, localPack) : void 0;
2613
+ this.reporter.onVideos(videos);
2614
+ this.reporter.onForums(forums);
2615
+ if (!hasPaa) {
2616
+ let noPaaOrganic = organicResults;
2617
+ let locationEvidence2 = initialLocationEvidence;
2618
+ if ((options.pages ?? 1) >= 2) {
2619
+ const p2params = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", start: "10" });
2620
+ if (recencyToTbs(options.recency)) p2params.set("tbs", recencyToTbs(options.recency));
2621
+ if (uule) p2params.set("uule", uule);
2622
+ await this.driver.navigateTo("https://www.google.com/search?" + p2params.toString());
2623
+ await this.throwIfCaptcha(page, "Google SERP page 2");
2624
+ const p2organic = await this.extractOrganicResults(page);
2625
+ noPaaOrganic = [...organicResults, ...p2organic.map((r) => ({ ...r, position: r.position + 10 }))];
2626
+ if (options.debug) {
2627
+ locationEvidence2 = inferSerpLocationEvidence(canonicalLocation, noPaaOrganic, localPack);
2628
+ }
2629
+ }
2630
+ const aiSurfaces2 = await this.extractAISurfaces(page, options);
2631
+ const stats2 = {
2632
+ seed: executionOptions.query,
2633
+ totalQuestions: 0,
2634
+ maxDepthReached: 0,
2635
+ durationMs: Date.now() - startMs,
2636
+ errorCount
2637
+ };
2638
+ this.reporter.onComplete(stats2);
2639
+ return {
2640
+ seed: executionOptions.query,
2641
+ location: options.location ?? null,
2642
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
2643
+ diagnostics: {
2644
+ completionStatus: "no_paa",
2645
+ problem: null,
2646
+ completeness: { ...this.completeness },
2647
+ ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence2) } : {}
2648
+ },
2649
+ totalQuestions: 0,
2650
+ surface: aiSurfaces2.surface,
2651
+ aiOverview: aiSurfaces2.aiOverview,
2652
+ aiMode: aiSurfaces2.aiMode,
2653
+ whatPeopleSaying,
2654
+ tree: [],
2655
+ flat: [],
2656
+ videos,
2657
+ forums,
2658
+ organicResults: noPaaOrganic,
2659
+ localPack,
2660
+ entityIds,
2661
+ stats: stats2
2662
+ };
2663
+ }
2664
+ const flat = await this.runBFS(page, executionOptions, signal);
2665
+ this.throwIfAborted(signal);
2666
+ const aiSurfaces = await this.extractAISurfaces(page, options);
2667
+ const shortVidsParams = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", udm: ShortVideoSelectors.udm });
2668
+ if (uule) shortVidsParams.set("uule", uule);
2669
+ let shortVideos = [];
2670
+ try {
2671
+ shortVideos = await this.extractShortVideos(page, "https://www.google.com/search?" + shortVidsParams.toString());
2672
+ } catch (err) {
2673
+ if (!(err instanceof CaptchaError)) throw err;
2674
+ errorCount++;
2675
+ diagnosticWarnings.push({
2676
+ code: "short_videos_captcha_skipped",
2677
+ surface: "short_videos",
2678
+ message: err.message,
2679
+ retryable: true
2680
+ });
2681
+ }
2682
+ this.reporter.onVideos(shortVideos);
2683
+ let allOrganic = organicResults;
2684
+ let locationEvidence = initialLocationEvidence;
2685
+ if ((options.pages ?? 1) >= 2) {
2686
+ const p2params = new URLSearchParams({ q: executionOptions.query, gl: options.gl, hl: options.hl, pws: "0", start: "10" });
2687
+ if (recencyToTbs(options.recency)) p2params.set("tbs", recencyToTbs(options.recency));
2688
+ if (uule) p2params.set("uule", uule);
2689
+ await this.driver.navigateTo("https://www.google.com/search?" + p2params.toString());
2690
+ await this.throwIfCaptcha(page, "Google SERP page 2");
2691
+ const p2organic = await this.extractOrganicResults(page);
2692
+ allOrganic = [...organicResults, ...p2organic.map((r) => ({ ...r, position: r.position + 10 }))];
2693
+ if (options.debug) {
2694
+ locationEvidence = inferSerpLocationEvidence(canonicalLocation, allOrganic, localPack);
2695
+ }
2696
+ }
2697
+ const allVideos = [...videos, ...shortVideos];
2698
+ const tree = this.buildTree(flat, executionOptions.query);
2699
+ const stats = {
2700
+ seed: executionOptions.query,
2701
+ totalQuestions: flat.length,
2702
+ maxDepthReached: flat.reduce((m, r) => Math.max(m, r.depth), 0),
2703
+ durationMs: Date.now() - startMs,
2704
+ errorCount
2705
+ };
2706
+ this.reporter.onComplete(stats);
2707
+ return {
2708
+ seed: executionOptions.query,
2709
+ location: options.location ?? null,
2710
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
2711
+ diagnostics: {
2712
+ completionStatus: "paa_found",
2713
+ problem: null,
2714
+ completeness: { ...this.completeness },
2715
+ ...diagnosticWarnings.length > 0 ? { warnings: diagnosticWarnings } : {},
2716
+ ...options.debug ? { debug: this.buildHarvestDebugSnapshot(executionOptions, canonicalLocation, uule, locationEvidence) } : {}
2717
+ },
2718
+ totalQuestions: flat.length,
2719
+ surface: aiSurfaces.surface,
2720
+ aiOverview: aiSurfaces.aiOverview,
2721
+ aiMode: aiSurfaces.aiMode,
2722
+ whatPeopleSaying,
2723
+ tree,
2724
+ flat,
2725
+ videos: allVideos,
2726
+ forums,
2727
+ organicResults: allOrganic,
2728
+ localPack,
2729
+ entityIds,
2730
+ stats
2731
+ };
2732
+ } catch (err) {
2733
+ errorCount++;
2734
+ this.reporter.onError(err instanceof Error ? err : new Error(String(err)));
2735
+ throw err;
2736
+ }
2737
+ }
2738
+ };
2739
+
2740
+ // src/output/OutputSerializer.ts
2741
+ var import_node_fs = require("fs");
2742
+ var import_node_path = __toESM(require("path"), 1);
2743
+ var import_papaparse = __toESM(require("papaparse"), 1);
2744
+
2745
+ // src/output/memory-library-sink.ts
2746
+ async function postToMemoryLibrary(args) {
2747
+ const url = process.env.MCP_MEMORY_INGEST_URL;
2748
+ const key = process.env.MCP_MEMORY_KEY;
2749
+ if (!url || !key) return;
2750
+ try {
2751
+ const res = await fetch(url, {
2752
+ method: "POST",
2753
+ headers: { "content-type": "application/json" },
2754
+ body: JSON.stringify({ data: { apiKey: key, title: args.title, content: args.content, source: args.source } })
2755
+ });
2756
+ const body = await res.json().catch(() => null);
2757
+ if (!res.ok || body?.result?.error) {
2758
+ console.warn("[memory-library-sink] ingest not accepted:", res.status, body?.result?.message ?? "");
2759
+ }
2760
+ } catch (err) {
2761
+ console.warn("[memory-library-sink] ingest failed:", err?.message);
2762
+ }
2763
+ }
2764
+
2765
+ // src/output/OutputSerializer.ts
2766
+ var OutputSerializer = class {
2767
+ async writeJSON(result, outputDir) {
2768
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2769
+ const slug = result.seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2770
+ const filename = `${slug}-${Date.now()}.json`;
2771
+ const fullPath = import_node_path.default.join(outputDir, filename);
2772
+ await import_node_fs.promises.writeFile(fullPath, JSON.stringify(result, null, 2), "utf8");
2773
+ await postToMemoryLibrary({ title: `${result.seed} scrape`, content: JSON.stringify(result), source: `mcp-scraper:${fullPath}` });
2774
+ return fullPath;
2775
+ }
2776
+ async writeCSV(rows, outputDir) {
2777
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2778
+ const seedRaw = rows[0]?.seed_query ?? "paa";
2779
+ const slug = seedRaw.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2780
+ const csv = import_papaparse.default.unparse(rows, { header: true });
2781
+ const filename = `${slug}-${Date.now()}.csv`;
2782
+ const fullPath = import_node_path.default.join(outputDir, filename);
2783
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2784
+ return fullPath;
2785
+ }
2786
+ async writeVideoCSV(videos, seed, outputDir) {
2787
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2788
+ const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2789
+ const csv = import_papaparse.default.unparse(videos, { header: true });
2790
+ const filename = `${slug}-videos-${Date.now()}.csv`;
2791
+ const fullPath = import_node_path.default.join(outputDir, filename);
2792
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2793
+ return fullPath;
2794
+ }
2795
+ async writeForumCSV(forums, seed, outputDir) {
2796
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2797
+ const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2798
+ const csv = import_papaparse.default.unparse(forums, { header: true });
2799
+ const filename = `${slug}-forums-${Date.now()}.csv`;
2800
+ const fullPath = import_node_path.default.join(outputDir, filename);
2801
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2802
+ return fullPath;
2803
+ }
2804
+ async writeAIOverviewCSV(citations, text, seed, outputDir) {
2805
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2806
+ const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2807
+ const rows = citations.map((c, i) => ({
2808
+ seed_query: seed,
2809
+ response_text: i === 0 ? text ?? "" : "",
2810
+ citation_text: c.text,
2811
+ citation_href: c.href
2812
+ }));
2813
+ const csv = import_papaparse.default.unparse(rows, { header: true });
2814
+ const filename = `${slug}-ai-overview-${Date.now()}.csv`;
2815
+ const fullPath = import_node_path.default.join(outputDir, filename);
2816
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2817
+ return fullPath;
2818
+ }
2819
+ async writeAIModeCSV(citations, text, seed, outputDir) {
2820
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2821
+ const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2822
+ const rows = citations.map((c, i) => ({
2823
+ seed_query: seed,
2824
+ response_text: i === 0 ? text ?? "" : "",
2825
+ citation_text: c.text,
2826
+ citation_href: c.href
2827
+ }));
2828
+ const csv = import_papaparse.default.unparse(rows, { header: true });
2829
+ const filename = `${slug}-ai-mode-${Date.now()}.csv`;
2830
+ const fullPath = import_node_path.default.join(outputDir, filename);
2831
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2832
+ return fullPath;
2833
+ }
2834
+ async writeWhatPeopleSayingCSV(cards, seed, outputDir) {
2835
+ await import_node_fs.promises.mkdir(outputDir, { recursive: true });
2836
+ const slug = seed.toLowerCase().replace(/\W+/g, "-").slice(0, 40);
2837
+ const rows = cards.map((c) => ({ seed_query: seed, ...c }));
2838
+ const csv = import_papaparse.default.unparse(rows, { header: true });
2839
+ const filename = `${slug}-what-people-saying-${Date.now()}.csv`;
2840
+ const fullPath = import_node_path.default.join(outputDir, filename);
2841
+ await import_node_fs.promises.writeFile(fullPath, csv, "utf8");
2842
+ return fullPath;
2843
+ }
2844
+ };
2845
+
2846
+ // src/output/ProgressReporter.ts
2847
+ var ProgressReporter = class {
2848
+ onQuestion(node) {
2849
+ process.stdout.write(JSON.stringify({ event: "question", depth: node.depth, question: node.question }) + "\n");
2850
+ }
2851
+ onDepth(depth) {
2852
+ process.stdout.write(JSON.stringify({ event: "depth", depth }) + "\n");
2853
+ }
2854
+ onVideos(videos) {
2855
+ for (const v of videos) {
2856
+ process.stdout.write(JSON.stringify({ event: "video", type: v.type, platform: v.platform, duration: v.duration, title: v.title, channel: v.channel, url: v.url }) + "\n");
2857
+ }
2858
+ }
2859
+ onForums(forums) {
2860
+ for (const f of forums) {
2861
+ process.stdout.write(JSON.stringify({ event: "forum", title: f.title, source: f.source, url: f.url }) + "\n");
2862
+ }
2863
+ }
2864
+ onComplete(stats) {
2865
+ process.stdout.write(JSON.stringify({ event: "complete", ...stats }) + "\n");
2866
+ }
2867
+ onError(err) {
2868
+ process.stderr.write(JSON.stringify({ event: "error", type: err.constructor.name, message: err.message }) + "\n");
2869
+ }
2870
+ };
2871
+
2872
+ // src/kernel-proxy-resolver.ts
2873
+ var import_sdk3 = __toESM(require("@onkernel/sdk"), 1);
2874
+
2875
+ // src/backup-proxy.ts
2876
+ var import_node_crypto3 = require("crypto");
2877
+ var import_sdk2 = __toESM(require("@onkernel/sdk"), 1);
2878
+ var BACKUP_PROXY_HOST = "pr.oxylabs.io";
2879
+ var BACKUP_PROXY_PORT = 7777;
2880
+ var BACKUP_STICKY_SESSION_MINUTES = 10;
2881
+ var BACKUP_LAST_RESORT_ATTEMPTS = 2;
2882
+ function backupProxyUsername() {
2883
+ const value = process.env.OXYLABS_USERNAME?.trim();
2884
+ return value || void 0;
2885
+ }
2886
+ function backupProxyPassword() {
2887
+ const value = process.env.OXYLABS_PASSWORD?.trim();
2888
+ return value || void 0;
2889
+ }
2890
+ function backupProxyCountry() {
2891
+ const value = process.env.OXYLABS_COUNTRY?.trim();
2892
+ return (value || "US").toUpperCase();
2893
+ }
2894
+ function backupProxyAvailable() {
2895
+ return Boolean(backupProxyUsername() && backupProxyPassword());
2896
+ }
2897
+ function randomStickySessionId() {
2898
+ let digits = "";
2899
+ for (let i = 0; i < 10; i++) digits += String((0, import_node_crypto3.randomInt)(0, 10));
2900
+ return digits;
2901
+ }
2902
+ function buildBackupUsername(opts = {}) {
2903
+ const user = backupProxyUsername();
2904
+ if (!user) return void 0;
2905
+ const base = user.startsWith("customer-") ? user : `customer-${user}`;
2906
+ const country = (opts.country?.trim() || backupProxyCountry()).toUpperCase();
2907
+ const sticky = opts.sticky ? `-sessid-${randomStickySessionId()}-sesstime-${BACKUP_STICKY_SESSION_MINUTES}` : "";
2908
+ return `${base}-cc-${country}${sticky}`;
2909
+ }
2910
+ function scrubBackupProxyText(message) {
2911
+ return message.replace(/pr\.oxylabs\.io(:\d+)?/gi, "backup proxy").replace(/oxylabs/gi, "backup proxy");
2912
+ }
2913
+ function backupProxyName(attemptIndex) {
2914
+ const stamp = `${Date.now()}-${attemptIndex ?? 0}-${Math.random().toString(36).slice(2, 8)}`;
2915
+ return `mcp-backup-custom-${stamp}`;
2916
+ }
2917
+ async function createBackupProxyId(kernelApiKey, opts = {}) {
2918
+ const username = buildBackupUsername({ sticky: opts.sticky });
2919
+ const password = backupProxyPassword();
2920
+ if (!username || !password) throw new Error("backup proxy pool is not configured");
2921
+ const kernel = new import_sdk2.default({ apiKey: kernelApiKey });
2922
+ try {
2923
+ const created = await kernel.proxies.create({
2924
+ type: "custom",
2925
+ name: backupProxyName(opts.attemptIndex),
2926
+ protocol: "http",
2927
+ config: {
2928
+ host: BACKUP_PROXY_HOST,
2929
+ port: BACKUP_PROXY_PORT,
2930
+ username,
2931
+ password
2932
+ }
2933
+ });
2934
+ if (!created.id) throw new Error("backup proxy creation did not return a proxy id");
2935
+ return created.id;
2936
+ } catch (err) {
2937
+ const message = err instanceof Error ? err.message : String(err);
2938
+ throw new Error(scrubBackupProxyText(message));
2939
+ }
2940
+ }
2941
+
2942
+ // src/kernel-proxy-resolver.ts
2943
+ var US_STATE_CODES = {
2944
+ alabama: "AL",
2945
+ alaska: "AK",
2946
+ arizona: "AZ",
2947
+ arkansas: "AR",
2948
+ california: "CA",
2949
+ colorado: "CO",
2950
+ connecticut: "CT",
2951
+ delaware: "DE",
2952
+ florida: "FL",
2953
+ georgia: "GA",
2954
+ hawaii: "HI",
2955
+ idaho: "ID",
2956
+ illinois: "IL",
2957
+ indiana: "IN",
2958
+ iowa: "IA",
2959
+ kansas: "KS",
2960
+ kentucky: "KY",
2961
+ louisiana: "LA",
2962
+ maine: "ME",
2963
+ maryland: "MD",
2964
+ massachusetts: "MA",
2965
+ michigan: "MI",
2966
+ minnesota: "MN",
2967
+ mississippi: "MS",
2968
+ missouri: "MO",
2969
+ montana: "MT",
2970
+ nebraska: "NE",
2971
+ nevada: "NV",
2972
+ "new hampshire": "NH",
2973
+ "new jersey": "NJ",
2974
+ "new mexico": "NM",
2975
+ "new york": "NY",
2976
+ "north carolina": "NC",
2977
+ "north dakota": "ND",
2978
+ ohio: "OH",
2979
+ oklahoma: "OK",
2980
+ oregon: "OR",
2981
+ pennsylvania: "PA",
2982
+ "rhode island": "RI",
2983
+ "south carolina": "SC",
2984
+ "south dakota": "SD",
2985
+ tennessee: "TN",
2986
+ texas: "TX",
2987
+ utah: "UT",
2988
+ vermont: "VT",
2989
+ virginia: "VA",
2990
+ washington: "WA",
2991
+ "west virginia": "WV",
2992
+ wisconsin: "WI",
2993
+ wyoming: "WY"
2994
+ };
2995
+ var US_CITY_CENTER_ZIPS = {
2996
+ "atlanta|GA": "30303",
2997
+ "austin|TX": "78701",
2998
+ "baltimore|MD": "21201",
2999
+ "boston|MA": "02108",
3000
+ "boulder|CO": "80302",
3001
+ "charlotte|NC": "28202",
3002
+ "chicago|IL": "60601",
3003
+ "colorado_springs|CO": "80903",
3004
+ "columbus|OH": "43215",
3005
+ "dallas|TX": "75201",
3006
+ "denver|CO": "80202",
3007
+ "detroit|MI": "48226",
3008
+ "fort_collins|CO": "80524",
3009
+ "fort_worth|TX": "76102",
3010
+ "houston|TX": "77002",
3011
+ "indianapolis|IN": "46204",
3012
+ "jacksonville|FL": "32202",
3013
+ "las_vegas|NV": "89101",
3014
+ "los_angeles|CA": "90012",
3015
+ "louisville|KY": "40202",
3016
+ "loveland|CO": "80537",
3017
+ "memphis|TN": "38103",
3018
+ "miami|FL": "33131",
3019
+ "minneapolis|MN": "55401",
3020
+ "nashville|TN": "37203",
3021
+ "new_york|NY": "10001",
3022
+ "orlando|FL": "32801",
3023
+ "philadelphia|PA": "19103",
3024
+ "phoenix|AZ": "85004",
3025
+ "portland|OR": "97205",
3026
+ "raleigh|NC": "27601",
3027
+ "richmond|VA": "23219",
3028
+ "sacramento|CA": "95814",
3029
+ "salt_lake_city|UT": "84101",
3030
+ "san_antonio|TX": "78205",
3031
+ "san_diego|CA": "92101",
3032
+ "san_francisco|CA": "94103",
3033
+ "san_jose|CA": "95113",
3034
+ "seattle|WA": "98101"
3035
+ };
3036
+ function proxyIdSuffix2(proxyId) {
3037
+ return proxyId ? proxyId.slice(-6) : null;
3038
+ }
3039
+ function resolution(source, proxyMode, proxyId, target, error, disposable = false) {
3040
+ const proxyType = source === "disabled" ? "none" : source === "configured_fallback" ? "configured" : source === "isp_created" ? "isp" : source === "backup_custom" ? "custom" : source === "location_created" || source === "location_reused" ? "residential" : "unknown";
3041
+ return {
3042
+ kernelProxyId: proxyId,
3043
+ ...disposable && proxyId ? { disposableProxyId: proxyId } : {},
3044
+ resolution: {
3045
+ source,
3046
+ proxyType,
3047
+ proxyMode,
3048
+ proxyIdPresent: Boolean(proxyId),
3049
+ proxyIdSuffix: proxyIdSuffix2(proxyId),
3050
+ target,
3051
+ error,
3052
+ disposable
3053
+ }
3054
+ };
3055
+ }
3056
+ function normalizeStateName(value) {
3057
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
3058
+ }
3059
+ function normalizeCountryName(value) {
3060
+ return value.trim().toLowerCase().replace(/\./g, "").replace(/\s+/g, " ");
3061
+ }
3062
+ function isUnitedStates(country) {
3063
+ if (!country) return true;
3064
+ const normalized = normalizeCountryName(country);
3065
+ return normalized === "united states" || normalized === "united states of america" || normalized === "usa" || normalized === "us";
3066
+ }
3067
+ function stateCodeFor(region) {
3068
+ const trimmed = region.trim();
3069
+ if (/^[A-Za-z]{2}$/.test(trimmed)) return trimmed.toUpperCase();
3070
+ return US_STATE_CODES[normalizeStateName(trimmed)] ?? null;
3071
+ }
3072
+ function kernelCityIdentifierCandidates(city) {
3073
+ const ascii = city.normalize("NFKD").replace(/[^\x00-\x7F]/g, "").toLowerCase();
3074
+ const words = ascii.split(/[^a-z0-9]+/).filter(Boolean);
3075
+ const underscored = words.join("_");
3076
+ const compact = words.join("");
3077
+ return Array.from(new Set([underscored, compact].filter(Boolean)));
3078
+ }
3079
+ function proxyName(country, state, city) {
3080
+ return city ? `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}-${city}` : `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}`;
3081
+ }
3082
+ function freshProxyName(baseName, attemptIndex) {
3083
+ const stamp = `${Date.now()}-${attemptIndex ?? 0}-${Math.random().toString(36).slice(2, 8)}`;
3084
+ return `${baseName}-fresh-${stamp}`;
3085
+ }
3086
+ function zipProxyName(zip) {
3087
+ return `mcp-serp-residential-us-zip-${zip}`;
3088
+ }
3089
+ function parseKernelLocationProxyTarget(location, gl) {
3090
+ if (!location || gl.toLowerCase() !== "us") return null;
3091
+ const canonicalLocation = normalizeLocation(location);
3092
+ let parts = canonicalLocation.split(",").map((part) => part.trim()).filter(Boolean);
3093
+ if (parts.length > 1 && isUnitedStates(parts[parts.length - 1])) {
3094
+ parts = parts.slice(0, -1);
3095
+ }
3096
+ if (parts.length === 1) {
3097
+ const stateOnly = stateCodeFor(parts[0]);
3098
+ if (!stateOnly) return null;
3099
+ return {
3100
+ canonicalLocation,
3101
+ level: "state",
3102
+ country: "US",
3103
+ state: stateOnly,
3104
+ city: "",
3105
+ cityCandidates: [],
3106
+ proxyName: proxyName("US", stateOnly),
3107
+ config: {
3108
+ country: "US",
3109
+ state: stateOnly
3110
+ }
3111
+ };
3112
+ }
3113
+ const [city = "", region = ""] = parts;
3114
+ if (!city || !region) return null;
3115
+ const state = stateCodeFor(region);
3116
+ if (!state) return null;
3117
+ const cityCandidates = kernelCityIdentifierCandidates(city);
3118
+ const primaryCity = cityCandidates[0];
3119
+ if (!primaryCity) return null;
3120
+ return {
3121
+ canonicalLocation,
3122
+ level: "city",
3123
+ country: "US",
3124
+ state,
3125
+ city: primaryCity,
3126
+ cityCandidates,
3127
+ proxyName: proxyName("US", state, primaryCity),
3128
+ config: {
3129
+ country: "US",
3130
+ state,
3131
+ city: primaryCity
3132
+ }
3133
+ };
3134
+ }
3135
+ function cityZipKey(target) {
3136
+ return `${target.city}|${target.state}`;
3137
+ }
3138
+ function knownZipFor(target, explicitZip) {
3139
+ if (explicitZip && /^\d{5}$/.test(explicitZip)) return explicitZip;
3140
+ return US_CITY_CENTER_ZIPS[cityZipKey(target)] ?? null;
3141
+ }
3142
+ function zipTarget(target, zip) {
3143
+ return {
3144
+ ...target,
3145
+ level: "zip",
3146
+ zip,
3147
+ proxyName: zipProxyName(zip),
3148
+ config: {
3149
+ country: target.country,
3150
+ state: target.state,
3151
+ zip
3152
+ }
3153
+ };
3154
+ }
3155
+ function withProxyName(target, name) {
3156
+ return {
3157
+ ...target,
3158
+ proxyName: name
3159
+ };
3160
+ }
3161
+ function configMatches(config, target, city) {
3162
+ if (target.level === "zip") {
3163
+ return config?.country?.toUpperCase() === target.country && config?.zip === target.zip;
3164
+ }
3165
+ return config?.country?.toUpperCase() === target.country && config?.state?.toUpperCase() === target.state && (city ? config?.city === city : !config?.city);
3166
+ }
3167
+ function findExistingTargetProxy(proxies, target) {
3168
+ return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === target.proxyName || configMatches(proxy.config, target, target.level === "city" ? target.city : void 0))) ?? null;
3169
+ }
3170
+ function findExistingProxy(proxies, target) {
3171
+ for (const city of target.cityCandidates) {
3172
+ const name = proxyName(target.country, target.state, city);
3173
+ const found = proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target, city)));
3174
+ if (found) return found;
3175
+ }
3176
+ return null;
3177
+ }
3178
+ function stateTarget(target) {
3179
+ return {
3180
+ ...target,
3181
+ level: "state",
3182
+ proxyName: proxyName(target.country, target.state),
3183
+ config: {
3184
+ country: target.country,
3185
+ state: target.state
3186
+ }
3187
+ };
3188
+ }
3189
+ function findExistingStateProxy(proxies, target) {
3190
+ const name = proxyName(target.country, target.state);
3191
+ return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target))) ?? null;
3192
+ }
3193
+ function escalatedTargetLevel(target, attemptIndex) {
3194
+ return stateTarget(target);
3195
+ }
3196
+ function errorText2(err) {
3197
+ return err instanceof Error ? err.message : String(err);
3198
+ }
3199
+ function freshTargetCandidates(target, explicitZip, attemptIndex) {
3200
+ const out = [];
3201
+ const zip = knownZipFor(target, explicitZip);
3202
+ if (zip) {
3203
+ const targetZip = zipTarget(target, zip);
3204
+ out.push(withProxyName(targetZip, freshProxyName(targetZip.proxyName, attemptIndex)));
3205
+ }
3206
+ for (const city of target.cityCandidates) {
3207
+ const cityTarget = {
3208
+ ...target,
3209
+ level: "city",
3210
+ city,
3211
+ proxyName: proxyName(target.country, target.state, city),
3212
+ config: {
3213
+ country: target.country,
3214
+ state: target.state,
3215
+ city
3216
+ }
3217
+ };
3218
+ out.push(withProxyName(cityTarget, freshProxyName(cityTarget.proxyName, attemptIndex)));
3219
+ }
3220
+ const fallbackTarget = stateTarget(target);
3221
+ out.push(withProxyName(fallbackTarget, freshProxyName(fallbackTarget.proxyName, attemptIndex)));
3222
+ return out;
3223
+ }
3224
+ async function settleCreateExhaustion(options, target, createErrors) {
3225
+ if (options.backupOnExhaustion && createErrors.length > 0 && options.kernelApiKey && backupProxyAvailable()) {
3226
+ try {
3227
+ const backupId = await createBackupProxyId(options.kernelApiKey, { attemptIndex: options.attemptIndex });
3228
+ return resolution("backup_custom", options.proxyMode, backupId, target, null, true);
3229
+ } catch (err) {
3230
+ createErrors.push(`backup: ${scrubBackupProxyText(errorText2(err))}`);
3231
+ }
3232
+ }
3233
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, createErrors.join(" | "));
3234
+ }
3235
+ async function resolveBackupKernelProxyId(options) {
3236
+ if (!options.kernelApiKey || !backupProxyAvailable()) return null;
3237
+ try {
3238
+ const backupId = await createBackupProxyId(options.kernelApiKey, { attemptIndex: options.attemptIndex });
3239
+ return resolution("backup_custom", options.proxyMode, backupId, null, null, true);
3240
+ } catch {
3241
+ return null;
3242
+ }
3243
+ }
3244
+ async function createFreshLocationProxy(kernel, options, target) {
3245
+ const createErrors = [];
3246
+ for (const candidate of freshTargetCandidates(target, options.proxyZip, options.attemptIndex)) {
3247
+ try {
3248
+ const created = await kernel.proxies.create({
3249
+ type: "residential",
3250
+ name: candidate.proxyName,
3251
+ config: candidate.level === "zip" ? { country: candidate.country, zip: candidate.zip } : candidate.config
3252
+ });
3253
+ if (created.id) {
3254
+ return resolution("location_created", options.proxyMode, created.id, candidate, null, true);
3255
+ }
3256
+ createErrors.push(`${candidate.proxyName}: Kernel did not return a proxy id`);
3257
+ } catch (err) {
3258
+ createErrors.push(`${candidate.proxyName}: ${errorText2(err)}`);
3259
+ }
3260
+ }
3261
+ if (process.env.KERNEL_PROXY_TYPE !== "residential") {
3262
+ const ispTarget = stateTarget(target);
3263
+ try {
3264
+ const created = await kernel.proxies.create({
3265
+ type: "isp",
3266
+ name: freshProxyName(`mcp-serp-isp-${ispTarget.country.toLowerCase()}-${(ispTarget.state ?? "").toLowerCase()}`, options.attemptIndex),
3267
+ config: { country: ispTarget.country, ...ispTarget.state ? { state: ispTarget.state } : {} }
3268
+ });
3269
+ if (created.id) {
3270
+ return resolution("isp_created", options.proxyMode, created.id, ispTarget, null, true);
3271
+ }
3272
+ createErrors.push("isp: Kernel did not return a proxy id");
3273
+ } catch (err) {
3274
+ createErrors.push(`isp: ${errorText2(err)}`);
3275
+ }
3276
+ }
3277
+ return settleCreateExhaustion(options, target, createErrors);
3278
+ }
3279
+ async function deleteKernelProxyId(kernelApiKey, proxyId) {
3280
+ if (!kernelApiKey || !proxyId) return;
3281
+ const kernel = new import_sdk3.default({ apiKey: kernelApiKey });
3282
+ await kernel.proxies.delete(proxyId);
3283
+ }
3284
+ async function resolveKernelProxyId(options) {
3285
+ if (options.proxyMode === "none") {
3286
+ return resolution("disabled", options.proxyMode, void 0, null, null);
3287
+ }
3288
+ if (options.proxyMode === "configured") {
3289
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, null, null);
3290
+ }
3291
+ const target = parseKernelLocationProxyTarget(options.location, options.gl);
3292
+ if (!target || !options.kernelApiKey) {
3293
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, target ? null : "location could not be normalized to a US city/state proxy target");
3294
+ }
3295
+ const kernel = new import_sdk3.default({ apiKey: options.kernelApiKey });
3296
+ try {
3297
+ const attemptIndex = options.attemptIndex ?? 0;
3298
+ if (options.fresh) {
3299
+ return await createFreshLocationProxy(kernel, options, target);
3300
+ }
3301
+ if (attemptIndex >= 1) {
3302
+ const escalatedTarget = escalatedTargetLevel(target, attemptIndex);
3303
+ const createErrors2 = [];
3304
+ try {
3305
+ const created = await kernel.proxies.create({
3306
+ type: "residential",
3307
+ name: escalatedTarget.proxyName,
3308
+ config: escalatedTarget.config
3309
+ });
3310
+ if (created.id) {
3311
+ return resolution("location_created", options.proxyMode, created.id, escalatedTarget, null);
3312
+ }
3313
+ createErrors2.push(`${escalatedTarget.state}: Kernel did not return a proxy id`);
3314
+ } catch (err) {
3315
+ createErrors2.push(`${escalatedTarget.state}: ${errorText2(err)}`);
3316
+ }
3317
+ return settleCreateExhaustion(options, escalatedTarget, createErrors2);
3318
+ }
3319
+ const proxies = await kernel.proxies.list();
3320
+ const zip = knownZipFor(target, options.proxyZip);
3321
+ const createErrors = [];
3322
+ if (zip) {
3323
+ const targetZip = zipTarget(target, zip);
3324
+ const existingZip = findExistingTargetProxy(proxies, targetZip);
3325
+ if (existingZip?.id) {
3326
+ return resolution("location_reused", options.proxyMode, existingZip.id, targetZip, null);
3327
+ }
3328
+ try {
3329
+ const created = await kernel.proxies.create({
3330
+ type: "residential",
3331
+ name: targetZip.proxyName,
3332
+ config: {
3333
+ country: targetZip.country,
3334
+ zip
3335
+ }
3336
+ });
3337
+ if (created.id) {
3338
+ return resolution("location_created", options.proxyMode, created.id, targetZip, null);
3339
+ }
3340
+ createErrors.push(`${zip}: Kernel did not return a proxy id`);
3341
+ } catch (err) {
3342
+ createErrors.push(`${zip}: ${errorText2(err)}`);
3343
+ }
3344
+ }
3345
+ const existing = findExistingProxy(proxies, target);
3346
+ if (existing?.id) {
3347
+ return resolution("location_reused", options.proxyMode, existing.id, target, createErrors.join(" | ") || null);
3348
+ }
3349
+ for (const city of target.cityCandidates) {
3350
+ try {
3351
+ const created = await kernel.proxies.create({
3352
+ type: "residential",
3353
+ name: proxyName(target.country, target.state, city),
3354
+ config: {
3355
+ country: target.country,
3356
+ state: target.state,
3357
+ city
3358
+ }
3359
+ });
3360
+ if (created.id) {
3361
+ return resolution("location_created", options.proxyMode, created.id, {
3362
+ ...target,
3363
+ level: "city",
3364
+ city,
3365
+ proxyName: proxyName(target.country, target.state, city),
3366
+ config: {
3367
+ country: target.country,
3368
+ state: target.state,
3369
+ city
3370
+ }
3371
+ }, null);
3372
+ }
3373
+ createErrors.push(`${city}: Kernel did not return a proxy id`);
3374
+ } catch (err) {
3375
+ createErrors.push(`${city}: ${errorText2(err)}`);
3376
+ }
3377
+ }
3378
+ const fallbackTarget = stateTarget(target);
3379
+ const existingState = findExistingStateProxy(proxies, fallbackTarget);
3380
+ if (existingState?.id) {
3381
+ return resolution("location_reused", options.proxyMode, existingState.id, fallbackTarget, createErrors.join(" | "));
3382
+ }
3383
+ try {
3384
+ const created = await kernel.proxies.create({
3385
+ type: "residential",
3386
+ name: fallbackTarget.proxyName,
3387
+ config: fallbackTarget.config
3388
+ });
3389
+ if (created.id) {
3390
+ return resolution("location_created", options.proxyMode, created.id, fallbackTarget, createErrors.join(" | "));
3391
+ }
3392
+ createErrors.push(`${fallbackTarget.state}: Kernel did not return a proxy id`);
3393
+ } catch (err) {
3394
+ createErrors.push(`${fallbackTarget.state}: ${errorText2(err)}`);
3395
+ }
3396
+ return settleCreateExhaustion(options, target, createErrors);
3397
+ } catch (err) {
3398
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, errorText2(err));
3399
+ }
3400
+ }
3401
+
3402
+ // src/harvest.ts
3403
+ var MAX_ATTEMPTS = 3;
3404
+ var LOCATION_PROXY_MAX_ATTEMPTS = 5;
3405
+ function abortReason(signal) {
3406
+ if (signal.reason instanceof DOMException && signal.reason.name === "TimeoutError") return signal.reason;
3407
+ return new RequestAbortedError();
3408
+ }
3409
+ function getAbortSignal(rawOptions) {
3410
+ if (!rawOptions || typeof rawOptions !== "object") return void 0;
3411
+ const signal = rawOptions.signal;
3412
+ if (signal instanceof AbortSignal) return signal;
3413
+ return void 0;
3414
+ }
3415
+ function getAttemptLogSink(rawOptions) {
3416
+ if (!rawOptions || typeof rawOptions !== "object") return void 0;
3417
+ const sink = rawOptions.onAttemptEvent;
3418
+ return typeof sink === "function" ? sink : void 0;
3419
+ }
3420
+ async function emitAttemptEvent(sink, event) {
3421
+ if (!sink) return;
3422
+ try {
3423
+ await sink(event);
3424
+ } catch (err) {
3425
+ console.warn(JSON.stringify({
3426
+ event: "harvest_attempt_log_failed",
3427
+ attempt_number: event.attemptNumber,
3428
+ message: err instanceof Error ? err.message : String(err)
3429
+ }));
3430
+ }
3431
+ }
3432
+ function classifyAttemptError(err) {
3433
+ if (err instanceof CaptchaError) return "captcha";
3434
+ if (err instanceof LocationMismatchError) return "location_mismatch";
3435
+ if (err instanceof RequestAbortedError) return "request_aborted";
3436
+ if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) return "timeout";
3437
+ const message = err instanceof Error ? err.message : String(err);
3438
+ if (looksLikeProxyTunnelFailure(message)) return "proxy_tunnel_failed";
3439
+ if (looksLikeProxyUnavailable(message)) return "proxy_unavailable";
3440
+ return /timeout|timed out|Timeout \d+ms exceeded|deadline/i.test(message) ? "timeout" : "error";
3441
+ }
3442
+ function classifyAttemptResult(result) {
3443
+ if (result.diagnostics.degradedResult) return "degraded_result";
3444
+ return result.diagnostics?.completionStatus ?? (result.totalQuestions > 0 ? "paa_found" : "no_paa");
3445
+ }
3446
+ function resultHasUsableContent(result) {
3447
+ 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;
3448
+ }
3449
+ function withResultQuality(result) {
3450
+ const hasUsableContent = resultHasUsableContent(result);
3451
+ const hasWarnings = (result.diagnostics.warnings?.length ?? 0) > 0;
3452
+ const resultQuality = hasUsableContent ? hasWarnings ? "partial" : "complete" : "degraded";
3453
+ const degradedResult = resultQuality === "degraded";
3454
+ result.diagnostics = {
3455
+ ...result.diagnostics,
3456
+ resultQuality,
3457
+ degradedResult,
3458
+ degradationReasons: degradedResult ? ["empty_primary_serp"] : [],
3459
+ retryRecommended: degradedResult
3460
+ };
3461
+ return result;
3462
+ }
3463
+ function degradedResultMessage(result) {
3464
+ const reasons = result.diagnostics.degradationReasons ?? [];
3465
+ if (reasons.includes("empty_primary_serp")) {
3466
+ return "No usable primary SERP surface was captured; the result may be incomplete.";
3467
+ }
3468
+ return "The harvest result was marked degraded.";
3469
+ }
3470
+ function errorMessage(err) {
3471
+ return err instanceof Error ? err.message : String(err);
3472
+ }
3473
+ function maxAttemptsForProxyMode(proxyMode) {
3474
+ return proxyMode === "location" ? LOCATION_PROXY_MAX_ATTEMPTS : MAX_ATTEMPTS;
3475
+ }
3476
+ function looksLikeProxyTunnelFailure(message) {
3477
+ return /ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED|ERR_SOCKS_CONNECTION_FAILED|tunnel connection failed|proxy connection failed|transport error: proxy/i.test(message);
3478
+ }
3479
+ function looksLikeProxyUnavailable(message) {
3480
+ return /proxy unavailable|proxy_unavailable|connection_test_failed|did not return a proxy id|configured fallback/i.test(message);
3481
+ }
3482
+ function retryableLocationProxyError(outcome) {
3483
+ return outcome === "captcha" || outcome === "proxy_tunnel_failed" || outcome === "proxy_unavailable";
3484
+ }
3485
+ function locationMismatchMessage(result) {
3486
+ const evidence = result.diagnostics.debug?.locationEvidence;
3487
+ const expected = evidence?.expected?.canonicalLocation ?? result.location ?? "requested location";
3488
+ const candidates = evidence?.candidates.slice(0, 3).map((candidate) => `${candidate.city}, ${candidate.regionCode}`).join("; ");
3489
+ return candidates ? `Google returned results for ${candidates}, not ${expected}` : `Google returned results for a different location than ${expected}`;
3490
+ }
3491
+ function shouldRetryLocationMismatch(result, proxyMode) {
3492
+ return proxyMode === "location" && result.diagnostics.debug?.locationEvidence?.status === "mismatch";
3493
+ }
3494
+ function stripInternalDebug(result, keepDebug) {
3495
+ if (keepDebug || !result.diagnostics.debug) return result;
3496
+ const diagnostics = { ...result.diagnostics };
3497
+ delete diagnostics.debug;
3498
+ return { ...result, diagnostics };
3499
+ }
3500
+ async function cleanupDisposableProxy(kernelApiKey, proxyId) {
3501
+ if (!kernelApiKey || !proxyId) return;
3502
+ try {
3503
+ await deleteKernelProxyId(kernelApiKey, proxyId);
3504
+ console.info(JSON.stringify({
3505
+ event: "kernel_proxy_deleted",
3506
+ proxy_id_suffix: proxyId.slice(-6)
3507
+ }));
3508
+ } catch (err) {
3509
+ console.warn(JSON.stringify({
3510
+ event: "kernel_proxy_delete_failed",
3511
+ proxy_id_suffix: proxyId.slice(-6),
3512
+ message: errorMessage(err)
3513
+ }));
3514
+ }
3515
+ }
3516
+ async function extractOnce(options, signal) {
3517
+ const driver = new BrowserDriver();
3518
+ const reporter = new ProgressReporter();
3519
+ const extractor = new PAAExtractor(driver, reporter);
3520
+ if (signal?.aborted) {
3521
+ return {
3522
+ result: null,
3523
+ error: abortReason(signal),
3524
+ cleanup: await driver.close(),
3525
+ debug: null
3526
+ };
3527
+ }
3528
+ let onAbort;
3529
+ const abortPromise = signal ? new Promise((_, reject) => {
3530
+ onAbort = () => reject(abortReason(signal));
3531
+ signal.addEventListener("abort", onAbort, { once: true });
3532
+ }) : null;
3533
+ let result = null;
3534
+ let error = null;
3535
+ let cleanup;
3536
+ let debug = null;
3537
+ try {
3538
+ const extraction = extractor.extract(options, signal);
3539
+ if (abortPromise) extraction.catch(() => {
3540
+ });
3541
+ result = await (abortPromise ? Promise.race([extraction, abortPromise]) : extraction);
3542
+ } catch (err) {
3543
+ error = err;
3544
+ } finally {
3545
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
3546
+ debug = result?.diagnostics.debug ?? (options.debug ? {
3547
+ enabled: true,
3548
+ request: {
3549
+ query: options.query,
3550
+ locationInput: options.location ?? null,
3551
+ canonicalLocation: null,
3552
+ uule: null,
3553
+ gl: options.gl,
3554
+ hl: options.hl,
3555
+ device: options.device,
3556
+ proxyMode: options.proxyMode,
3557
+ proxyZip: options.proxyZip ?? null,
3558
+ serpOnly: options.serpOnly,
3559
+ pages: options.pages ?? 1
3560
+ },
3561
+ browser: driver.getDebugSnapshot()
3562
+ } : null);
3563
+ cleanup = await driver.close();
3564
+ }
3565
+ if (error) {
3566
+ const outcome = classifyAttemptError(error);
3567
+ const budgetExhausted = outcome === "timeout" || outcome === "request_aborted";
3568
+ let salvaged = null;
3569
+ if (budgetExhausted) {
3570
+ try {
3571
+ salvaged = typeof extractor.getPartialResult === "function" ? extractor.getPartialResult() : null;
3572
+ } catch {
3573
+ salvaged = null;
3574
+ }
3575
+ }
3576
+ if (salvaged) {
3577
+ return { result: salvaged, error: null, cleanup, debug, salvagedFrom: error };
3578
+ }
3579
+ return { result: null, error, cleanup, debug };
3580
+ }
3581
+ return { result, error: null, cleanup, debug };
3582
+ }
3583
+ async function harvest(rawOptions) {
3584
+ const raw = typeof rawOptions === "object" && rawOptions !== null ? rawOptions : {};
3585
+ const signal = getAbortSignal(rawOptions);
3586
+ const onAttemptEvent = getAttemptLogSink(rawOptions);
3587
+ const requestedProxyMode = raw.proxyMode;
3588
+ const proxyMode = requestedProxyMode === "location" || requestedProxyMode === "none" || requestedProxyMode === "configured" ? requestedProxyMode : DEFAULT_PROXY_MODE;
3589
+ const kernelApiKey = typeof raw.kernelApiKey === "string" ? raw.kernelApiKey.trim() : browserServiceApiKey();
3590
+ const configuredKernelProxyId = typeof raw.kernelProxyId === "string" ? raw.kernelProxyId.trim() : browserServiceProxyId();
3591
+ const proxyOpts = {
3592
+ kernelApiKey,
3593
+ proxyMode,
3594
+ configuredKernelProxyId,
3595
+ location: typeof raw.location === "string" ? raw.location : void 0,
3596
+ proxyZip: typeof raw.proxyZip === "string" ? raw.proxyZip : void 0,
3597
+ gl: typeof raw.gl === "string" ? raw.gl : "us"
3598
+ };
3599
+ const requestedDebug = typeof raw.debug === "boolean" ? raw.debug : false;
3600
+ const needsLocationEvidence = proxyMode === "location" && Boolean(proxyOpts.location);
3601
+ const requestedMaxAttempts = raw.maxAttempts;
3602
+ const baseMaxAttempts = typeof requestedMaxAttempts === "number" && Number.isInteger(requestedMaxAttempts) && requestedMaxAttempts >= 1 ? requestedMaxAttempts : maxAttemptsForProxyMode(proxyMode) + 1;
3603
+ const maxAttempts = baseMaxAttempts + (backupProxyAvailable() ? BACKUP_LAST_RESORT_ATTEMPTS : 0);
3604
+ const softDeadlineMs = typeof raw.softDeadlineMs === "number" ? raw.softDeadlineMs : null;
3605
+ const serializer = new OutputSerializer();
3606
+ let lastError = null;
3607
+ let effectiveProxyMode = proxyMode;
3608
+ let proxyFailures = 0;
3609
+ for (let i = 0; i < maxAttempts; i++) {
3610
+ const attemptNumber = i + 1;
3611
+ const startedAtMs = Date.now();
3612
+ try {
3613
+ if (signal?.aborted) throw abortReason(signal);
3614
+ const isBackupAttempt = i >= baseMaxAttempts;
3615
+ if (isBackupAttempt && softDeadlineMs !== null && Date.now() >= softDeadlineMs) break;
3616
+ const resolution2 = isBackupAttempt ? await resolveBackupKernelProxyId({ kernelApiKey, proxyMode: effectiveProxyMode, attemptIndex: i }) : await resolveKernelProxyId({
3617
+ ...proxyOpts,
3618
+ proxyMode: effectiveProxyMode,
3619
+ attemptIndex: i,
3620
+ fresh: effectiveProxyMode === "location",
3621
+ backupOnExhaustion: true
3622
+ });
3623
+ if (!resolution2) break;
3624
+ const mergedAttempt = {
3625
+ ...raw,
3626
+ kernelApiKey,
3627
+ kernelProxyId: resolution2.kernelProxyId,
3628
+ kernelProxyResolution: resolution2.resolution,
3629
+ proxyMode: effectiveProxyMode,
3630
+ debug: requestedDebug || needsLocationEvidence
3631
+ };
3632
+ if (effectiveProxyMode === "none" && !isBackupAttempt) mergedAttempt.kernelProxyId = void 0;
3633
+ const attemptOptions = HarvestOptionsSchema.parse(mergedAttempt);
3634
+ await emitAttemptEvent(onAttemptEvent, {
3635
+ type: "started",
3636
+ attemptNumber,
3637
+ maxAttempts,
3638
+ query: attemptOptions.query,
3639
+ location: attemptOptions.location ?? null,
3640
+ maxQuestions: attemptOptions.maxQuestions,
3641
+ startedAt: new Date(startedAtMs).toISOString()
3642
+ });
3643
+ console.info(JSON.stringify({
3644
+ event: "harvest_attempt_started",
3645
+ attempt_number: attemptNumber,
3646
+ max_attempts: maxAttempts,
3647
+ query: attemptOptions.query,
3648
+ location: attemptOptions.location ?? null,
3649
+ max_questions: attemptOptions.maxQuestions
3650
+ }));
3651
+ const baseCtx = currentCostContext();
3652
+ const attempt = baseCtx ? await runWithCostContext({ ...baseCtx, forceHeadful: true }, () => extractOnce(attemptOptions, signal)) : await extractOnce(attemptOptions, signal);
3653
+ if (attempt.error) {
3654
+ const err = attempt.error;
3655
+ const outcome2 = classifyAttemptError(err);
3656
+ if ((outcome2 === "captcha" || outcome2 === "proxy_tunnel_failed") && effectiveProxyMode === "location") {
3657
+ proxyFailures++;
3658
+ if (proxyFailures >= 2) effectiveProxyMode = "none";
3659
+ }
3660
+ const willRetry2 = i < maxAttempts - 1 && (outcome2 === "captcha" || proxyMode === "location" && retryableLocationProxyError(outcome2));
3661
+ if (outcome2 === "captcha") {
3662
+ console.warn(JSON.stringify({
3663
+ event: "harvest_attempt_captcha",
3664
+ attempt_number: attemptNumber,
3665
+ max_attempts: maxAttempts,
3666
+ message: errorMessage(err),
3667
+ will_retry: willRetry2
3668
+ }));
3669
+ } else if (willRetry2) {
3670
+ console.warn(JSON.stringify({
3671
+ event: "harvest_attempt_proxy_retry",
3672
+ attempt_number: attemptNumber,
3673
+ max_attempts: maxAttempts,
3674
+ outcome: outcome2,
3675
+ message: errorMessage(err),
3676
+ will_retry: willRetry2
3677
+ }));
3678
+ }
3679
+ await emitAttemptEvent(onAttemptEvent, {
3680
+ type: "finished",
3681
+ attemptNumber,
3682
+ maxAttempts,
3683
+ outcome: outcome2,
3684
+ kernelSessionId: attempt.cleanup.kernelSessionId,
3685
+ questionCount: 0,
3686
+ durationMs: Date.now() - startedAtMs,
3687
+ error: errorMessage(err),
3688
+ willRetry: willRetry2,
3689
+ cleanup: attempt.cleanup,
3690
+ debug: attempt.debug,
3691
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3692
+ });
3693
+ await cleanupDisposableProxy(kernelApiKey, resolution2.disposableProxyId);
3694
+ lastError = err;
3695
+ if (willRetry2) continue;
3696
+ break;
3697
+ }
3698
+ const result = attempt.result;
3699
+ if (!result) throw new Error("Harvest attempt completed without a result");
3700
+ if (shouldRetryLocationMismatch(result, proxyMode)) {
3701
+ const err = new LocationMismatchError(locationMismatchMessage(result));
3702
+ const willRetry2 = i < maxAttempts - 1;
3703
+ console.warn(JSON.stringify({
3704
+ event: "harvest_attempt_location_mismatch",
3705
+ attempt_number: attemptNumber,
3706
+ max_attempts: maxAttempts,
3707
+ message: err.message,
3708
+ will_retry: willRetry2
3709
+ }));
3710
+ await emitAttemptEvent(onAttemptEvent, {
3711
+ type: "finished",
3712
+ attemptNumber,
3713
+ maxAttempts,
3714
+ outcome: "location_mismatch",
3715
+ kernelSessionId: attempt.cleanup.kernelSessionId,
3716
+ questionCount: result.totalQuestions,
3717
+ durationMs: Date.now() - startedAtMs,
3718
+ error: err.message,
3719
+ willRetry: willRetry2,
3720
+ cleanup: attempt.cleanup,
3721
+ debug: attempt.debug,
3722
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3723
+ });
3724
+ await cleanupDisposableProxy(kernelApiKey, resolution2.disposableProxyId);
3725
+ lastError = err;
3726
+ if (willRetry2) continue;
3727
+ break;
3728
+ }
3729
+ const finalResult = withResultQuality(stripInternalDebug(result, requestedDebug));
3730
+ const outcome = classifyAttemptResult(finalResult);
3731
+ const willRetry = outcome === "degraded_result" && i < maxAttempts - 1;
3732
+ const resultError = outcome === "degraded_result" ? degradedResultMessage(finalResult) : null;
3733
+ if (willRetry) {
3734
+ console.warn(JSON.stringify({
3735
+ event: "harvest_attempt_degraded_result",
3736
+ attempt_number: attemptNumber,
3737
+ max_attempts: maxAttempts,
3738
+ degradation_reasons: finalResult.diagnostics.degradationReasons,
3739
+ will_retry: true
3740
+ }));
3741
+ }
3742
+ await emitAttemptEvent(onAttemptEvent, {
3743
+ type: "finished",
3744
+ attemptNumber,
3745
+ maxAttempts,
3746
+ outcome,
3747
+ kernelSessionId: attempt.cleanup.kernelSessionId,
3748
+ questionCount: finalResult.totalQuestions,
3749
+ durationMs: Date.now() - startedAtMs,
3750
+ error: resultError,
3751
+ willRetry,
3752
+ cleanup: attempt.cleanup,
3753
+ debug: attempt.debug,
3754
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3755
+ });
3756
+ await cleanupDisposableProxy(kernelApiKey, resolution2.disposableProxyId);
3757
+ if (willRetry) continue;
3758
+ if (attemptOptions.format === "json" || attemptOptions.format === "both") {
3759
+ await serializer.writeJSON(finalResult, attemptOptions.outputDir);
3760
+ }
3761
+ if (attemptOptions.format === "csv" || attemptOptions.format === "both") {
3762
+ await Promise.all([
3763
+ serializer.writeCSV(finalResult.flat, attemptOptions.outputDir),
3764
+ finalResult.videos.length > 0 ? serializer.writeVideoCSV(finalResult.videos, finalResult.seed, attemptOptions.outputDir) : Promise.resolve(""),
3765
+ finalResult.forums.length > 0 ? serializer.writeForumCSV(finalResult.forums, finalResult.seed, attemptOptions.outputDir) : Promise.resolve(""),
3766
+ finalResult.aiOverview.detected ? serializer.writeAIOverviewCSV(finalResult.aiOverview.citations, finalResult.aiOverview.text, finalResult.seed, attemptOptions.outputDir) : Promise.resolve(""),
3767
+ finalResult.aiMode.detected ? serializer.writeAIModeCSV(finalResult.aiMode.citations, finalResult.aiMode.text, finalResult.seed, attemptOptions.outputDir) : Promise.resolve(""),
3768
+ finalResult.whatPeopleSaying.length > 0 ? serializer.writeWhatPeopleSayingCSV(finalResult.whatPeopleSaying, finalResult.seed, attemptOptions.outputDir) : Promise.resolve("")
3769
+ ]);
3770
+ }
3771
+ return finalResult;
3772
+ } catch (err) {
3773
+ const outcome = classifyAttemptError(err);
3774
+ if ((outcome === "captcha" || outcome === "proxy_tunnel_failed") && effectiveProxyMode === "location") {
3775
+ proxyFailures++;
3776
+ if (proxyFailures >= 2) effectiveProxyMode = "none";
3777
+ }
3778
+ const willRetry = i < maxAttempts - 1 && (outcome === "captcha" || proxyMode === "location" && retryableLocationProxyError(outcome));
3779
+ if (outcome === "captcha") {
3780
+ console.warn(JSON.stringify({
3781
+ event: "harvest_attempt_captcha",
3782
+ attempt_number: attemptNumber,
3783
+ max_attempts: maxAttempts,
3784
+ message: errorMessage(err),
3785
+ will_retry: willRetry
3786
+ }));
3787
+ } else if (willRetry) {
3788
+ console.warn(JSON.stringify({
3789
+ event: "harvest_attempt_proxy_retry",
3790
+ attempt_number: attemptNumber,
3791
+ max_attempts: maxAttempts,
3792
+ outcome,
3793
+ message: errorMessage(err),
3794
+ will_retry: willRetry
3795
+ }));
3796
+ }
3797
+ await emitAttemptEvent(onAttemptEvent, {
3798
+ type: "finished",
3799
+ attemptNumber,
3800
+ maxAttempts,
3801
+ outcome,
3802
+ kernelSessionId: null,
3803
+ questionCount: 0,
3804
+ durationMs: Date.now() - startedAtMs,
3805
+ error: errorMessage(err),
3806
+ willRetry,
3807
+ cleanup: {
3808
+ kernelSessionId: null,
3809
+ kernelDeleteStarted: false,
3810
+ kernelDeleteSucceeded: null,
3811
+ kernelDeleteError: null,
3812
+ browserCloseSucceeded: null,
3813
+ browserCloseError: null
3814
+ },
3815
+ debug: null,
3816
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3817
+ });
3818
+ lastError = err;
3819
+ if (willRetry) continue;
3820
+ if (outcome === "captcha") break;
3821
+ throw err;
3822
+ }
3823
+ }
3824
+ if (lastError && !(lastError instanceof CaptchaError)) throw lastError;
3825
+ console.warn(JSON.stringify({
3826
+ event: "harvest_captcha_exhausted",
3827
+ max_attempts: maxAttempts,
3828
+ session_kind: kernelApiKey ? "kernel" : "local"
3829
+ }));
3830
+ throw new CaptchaError(sanitizeVendorName(`CAPTCHA on all ${maxAttempts} fresh sessions. Try again in a few minutes.`));
3831
+ }
3832
+
3833
+ // src/video/VideoGenerator.ts
3834
+ var import_node_child_process2 = require("child_process");
3835
+ var import_node_fs4 = require("fs");
3836
+ var import_node_os2 = require("os");
3837
+ var import_node_path4 = require("path");
3838
+ var import_client3 = require("@fal-ai/client");
3839
+
3840
+ // src/video/promptBuilder.ts
3841
+ var DEEPINFRA_URL = "https://api.deepinfra.com/v1/openai/chat/completions";
3842
+ var OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
3843
+ var QWEN_MODEL = "Qwen/Qwen3.6-35B-A3B";
3844
+ var SYSTEM_PROMPT = `You are a video prompt engineer for Seedance 2.0 text-to-video AI.
3845
+
3846
+ IMPORTANT TECHNICAL CONTEXT: Clip 2 will be generated using image-to-video, meaning it will start from the exact last frame of Clip 1. The two clips will be visually seamless \u2014 same location, same characters, continuous motion. You must write prompts that make this feel like one uninterrupted 16-second video.
3847
+
3848
+ Your job: turn a PAA question and its answer into a complete short-form video with visuals, narration, and a background audio mood.
3849
+
3850
+ Produce four things:
3851
+
3852
+ 1. clip1 (~8s): Show a real person experiencing the situation the question describes. End on a specific frozen moment \u2014 a held expression, a paused action \u2014 that clip 2 continues from.
3853
+
3854
+ 2. clip2 (~8s): Continue from that exact frozen frame. Deliver the informational payoff from the answer. Show specific facts playing out visually. Describe motion continuing from clip 1's ending frame, not a new scene.
3855
+
3856
+ 3. voiceover: A spoken narration that fits in 10 seconds of TTS audio. HARD LIMIT: 22 words maximum. Count every word before finalizing \u2014 if it exceeds 22 words, cut it. Starts with the problem, ends with the answer. No filler, no "in conclusion". Informational and direct.
3857
+
3858
+ 4. audioMood: 6-10 words describing INSTRUMENTAL background music only \u2014 no vocals, no voice, no lyrics. Describe instruments and mood (e.g. "warm acoustic guitar, uplifting, professional home service, no vocals"). Will be passed to an AI audio model.
3859
+
3860
+ Rules for visuals:
3861
+ - No text, captions, graphics, or overlays
3862
+ - Photorealistic, natural lighting, specific details
3863
+ - Describe exactly what the camera sees
3864
+
3865
+ Respond with JSON only:
3866
+ {"clip1": "...", "clip2": "...", "voiceover": "...", "audioMood": "..."}`;
3867
+ async function callLLM(apiKey, baseUrl, question, answer) {
3868
+ const res = await fetch(baseUrl, {
3869
+ method: "POST",
3870
+ headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
3871
+ body: JSON.stringify({
3872
+ model: QWEN_MODEL,
3873
+ temperature: 0.7,
3874
+ messages: [
3875
+ { role: "system", content: SYSTEM_PROMPT },
3876
+ { role: "user", content: `Question: ${question}
3877
+
3878
+ Answer: ${answer.slice(0, 500)}` }
3879
+ ]
3880
+ })
3881
+ });
3882
+ if (!res.ok) throw new Error(`LLM call failed (${res.status}): ${await res.text()}`);
3883
+ const data = await res.json();
3884
+ if (baseUrl === OPENROUTER_URL) {
3885
+ void recordVendorUsage({ vendor: "openrouter", model: QWEN_MODEL, units: data.usage?.cost ?? 0, unitType: "usd" });
3886
+ } else {
3887
+ void recordVendorUsage({ vendor: "deepinfra_qwen", model: QWEN_MODEL, units: data.usage?.completion_tokens ?? 0, unitType: "tokens" });
3888
+ }
3889
+ const raw = data.choices[0]?.message?.content?.trim() ?? "";
3890
+ const match = raw.match(/\{[\s\S]*\}/);
3891
+ if (!match) throw new Error(`No JSON in QWEN response: ${raw.slice(0, 200)}`);
3892
+ const parsed = JSON.parse(match[0]);
3893
+ if (!parsed.clip1 || !parsed.clip2 || !parsed.voiceover || !parsed.audioMood) {
3894
+ throw new Error(`QWEN response missing fields: ${raw.slice(0, 200)}`);
3895
+ }
3896
+ return parsed;
3897
+ }
3898
+ async function buildClipPrompts(question, answer) {
3899
+ const deepinfraKey = process.env["DEEPINFRA_API_KEY"];
3900
+ const openrouterKey = process.env["OPENROUTER_API_KEY"];
3901
+ if (deepinfraKey) {
3902
+ try {
3903
+ return await callLLM(deepinfraKey, DEEPINFRA_URL, question, answer);
3904
+ } catch (err) {
3905
+ console.warn("[promptBuilder] DeepInfra failed, trying OpenRouter:", err.message);
3906
+ }
3907
+ }
3908
+ if (openrouterKey) {
3909
+ return await callLLM(openrouterKey, OPENROUTER_URL, question, answer);
3910
+ }
3911
+ throw new Error("No LLM key \u2014 set DEEPINFRA_API_KEY or OPENROUTER_API_KEY");
3912
+ }
3913
+ function extractEpisodePrompts(brief) {
3914
+ if (!brief.clip1 || !brief.clip2 || !brief.voiceover || !brief.audioMood) {
3915
+ throw new Error("Episode brief is missing prompt fields \u2014 run blog-to-video skill to regenerate");
3916
+ }
3917
+ return { clip1: brief.clip1, clip2: brief.clip2, voiceover: brief.voiceover, audioMood: brief.audioMood };
3918
+ }
3919
+
3920
+ // src/video/AudioGenerator.ts
3921
+ var import_node_fs2 = require("fs");
3922
+ var import_node_path2 = require("path");
3923
+ var import_node_os = require("os");
3924
+ var import_client = require("@fal-ai/client");
3925
+ var MMAUDIO_MODEL = "fal-ai/mmaudio-v2";
3926
+ var ELEVENLABS_MODEL = "fal-ai/elevenlabs/tts";
3927
+ var GEMINI_TTS_MODEL = "fal-ai/google/gemini-2.5-flash-preview-tts";
3928
+ async function downloadAudio(url) {
3929
+ const res = await fetch(url);
3930
+ if (!res.ok) throw new Error(`Failed to download TTS audio (${res.status})`);
3931
+ return Buffer.from(await res.arrayBuffer());
3932
+ }
3933
+ async function generateVoiceover(text) {
3934
+ console.log("[AudioGenerator] Generating voiceover...");
3935
+ const outDir = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `tts-${Date.now()}`);
3936
+ (0, import_node_fs2.mkdirSync)(outDir, { recursive: true });
3937
+ const outPath = (0, import_node_path2.join)(outDir, "voiceover.mp3");
3938
+ try {
3939
+ const voiceId = process.env["ELEVENLABS_VOICE_ID"] ?? "pNInz6obpgDQGcFmaJgB";
3940
+ const result2 = await import_client.fal.run(ELEVENLABS_MODEL, {
3941
+ input: { text, voice_id: voiceId, model_id: "eleven_v3" }
3942
+ });
3943
+ (0, import_node_fs2.writeFileSync)(outPath, await downloadAudio(result2.audio.url));
3944
+ console.log("[AudioGenerator] TTS: ElevenLabs via fal");
3945
+ return outPath;
3946
+ } catch (err) {
3947
+ console.warn("[AudioGenerator] ElevenLabs via fal failed, trying Gemini:", err.message);
3948
+ }
3949
+ const voice = process.env["GEMINI_TTS_VOICE"] ?? "Kore";
3950
+ const result = await import_client.fal.run(GEMINI_TTS_MODEL, { input: { text, voice } });
3951
+ (0, import_node_fs2.writeFileSync)(outPath, await downloadAudio(result.audio.url));
3952
+ console.log("[AudioGenerator] TTS: Gemini via fal");
3953
+ return outPath;
3954
+ }
3955
+ async function addBackgroundAudio(videoUrl, mood, durationSeconds) {
3956
+ console.log("[AudioGenerator] Adding background audio via MMAudio V2...");
3957
+ const result = await import_client.fal.run(MMAUDIO_MODEL, {
3958
+ input: {
3959
+ video_url: videoUrl,
3960
+ prompt: mood,
3961
+ negative_prompt: "speech, voice, talking, dialogue, narration, vocals, singing, human voice, conversation, words, lyrics, announcer, commentary",
3962
+ duration: durationSeconds,
3963
+ cfg_strength: 4.5
3964
+ }
3965
+ });
3966
+ return result.video.url;
3967
+ }
3968
+
3969
+ // src/video/VideoMixer.ts
3970
+ var import_node_child_process = require("child_process");
3971
+ var import_node_fs3 = require("fs");
3972
+ var import_node_path3 = require("path");
3973
+ var import_client2 = require("@fal-ai/client");
3974
+ async function download(url, destPath) {
3975
+ const res = await fetch(url);
3976
+ if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`);
3977
+ (0, import_node_fs3.writeFileSync)(destPath, Buffer.from(await res.arrayBuffer()));
3978
+ }
3979
+ async function concatenateClips(clip1Url, clip2Url, outDir) {
3980
+ (0, import_node_fs3.mkdirSync)(outDir, { recursive: true });
3981
+ const ts = Date.now();
3982
+ const p1 = (0, import_node_path3.join)(outDir, `clip1-${ts}.mp4`);
3983
+ const p2 = (0, import_node_path3.join)(outDir, `clip2-${ts}.mp4`);
3984
+ const out = (0, import_node_path3.join)(outDir, `combined-${ts}.mp4`);
3985
+ console.log("[VideoMixer] Downloading clips...");
3986
+ await Promise.all([download(clip1Url, p1), download(clip2Url, p2)]);
3987
+ console.log("[VideoMixer] Concatenating...");
3988
+ (0, import_node_child_process.execSync)(
3989
+ `ffmpeg -i "${p1}" -i "${p2}" -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" -map "[v]" -y "${out}" -loglevel error`
3990
+ );
3991
+ return out;
3992
+ }
3993
+ async function uploadToFal(localPath) {
3994
+ const { readFileSync: readFileSync2 } = await import("fs");
3995
+ const blob = new Blob([readFileSync2(localPath)], { type: "video/mp4" });
3996
+ const url = await import_client2.fal.storage.upload(blob);
3997
+ console.log("[VideoMixer] Uploaded to fal:", url);
3998
+ return url;
3999
+ }
4000
+ async function overlayVoiceover(videoPath, voiceoverUrl, outDir) {
4001
+ const ts = Date.now();
4002
+ const wav = (0, import_node_path3.join)(outDir, `voiceover-${ts}.wav`);
4003
+ const out = (0, import_node_path3.join)(outDir, `final-${ts}.mp4`);
4004
+ console.log("[VideoMixer] Downloading voiceover...");
4005
+ await download(voiceoverUrl, wav);
4006
+ console.log("[VideoMixer] Mixing voiceover over background audio...");
4007
+ (0, import_node_child_process.execSync)(
4008
+ `ffmpeg -i "${videoPath}" -i "${wav}" -filter_complex "[0:a]volume=0.2[bg];[1:a]volume=1.0[vo];[bg][vo]amix=inputs=2:duration=first[a]" -map 0:v -map "[a]" -c:v copy -y "${out}" -loglevel error`
4009
+ );
4010
+ return out;
4011
+ }
4012
+
4013
+ // src/video/VideoGenerator.ts
4014
+ var T2V = "bytedance/seedance-2.0/text-to-video";
4015
+ var I2V = "bytedance/seedance-2.0/image-to-video";
4016
+ function buildInput(prompt, opts, seed, imageUrl) {
4017
+ return {
4018
+ prompt,
4019
+ resolution: opts.resolution ?? "720p",
4020
+ duration: opts.clipDurationSeconds ?? 8,
4021
+ aspect_ratio: opts.aspectRatio ?? "16:9",
4022
+ generate_audio: false,
4023
+ ...seed !== void 0 ? { seed } : {},
4024
+ ...imageUrl !== void 0 ? { image_url: imageUrl } : {}
4025
+ };
4026
+ }
4027
+ async function generate(model, input) {
4028
+ const { request_id } = await import_client3.fal.queue.submit(model, { input });
4029
+ console.log(`[fal] submitted ${model} \u2192 ${request_id}`);
4030
+ while (true) {
4031
+ await new Promise((r) => setTimeout(r, 5e3));
4032
+ const s = await import_client3.fal.queue.status(model, { requestId: request_id, logs: false });
4033
+ console.log(`[fal] ${request_id} \u2192 ${s.status}`);
4034
+ if (s.status === "FAILED") throw new Error(`Request ${request_id} failed`);
4035
+ if (s.status !== "COMPLETED") continue;
4036
+ const result = await import_client3.fal.queue.result(model, { requestId: request_id });
4037
+ return result.data;
4038
+ }
4039
+ }
4040
+ async function extractLastFrame(videoUrl, outDir) {
4041
+ const ts = Date.now();
4042
+ const mp4Path = (0, import_node_path4.join)(outDir, `clip1-raw-${ts}.mp4`);
4043
+ const jpgPath = (0, import_node_path4.join)(outDir, `last-frame-${ts}.jpg`);
4044
+ const res = await fetch(videoUrl);
4045
+ if (!res.ok) throw new Error(`Failed to download clip 1 (${res.status})`);
4046
+ (0, import_node_fs4.writeFileSync)(mp4Path, Buffer.from(await res.arrayBuffer()));
4047
+ try {
4048
+ (0, import_node_child_process2.execSync)(`ffmpeg -sseof -0.1 -i "${mp4Path}" -vframes 1 -y "${jpgPath}" -loglevel error`);
4049
+ } finally {
4050
+ try {
4051
+ (0, import_node_fs4.unlinkSync)(mp4Path);
4052
+ } catch {
4053
+ }
4054
+ }
4055
+ return jpgPath;
4056
+ }
4057
+ var VideoGenerator = class {
4058
+ constructor(apiKey) {
4059
+ const key = apiKey ?? process.env["FAL_KEY"];
4060
+ if (!key) throw new Error("FAL_KEY is required");
4061
+ import_client3.fal.config({ credentials: key });
4062
+ }
4063
+ async generateClipPair(question, answer, opts = {}) {
4064
+ const outDir = opts.outputDir ?? (0, import_node_path4.join)((0, import_node_os2.tmpdir)(), `paa-video-${Date.now()}`);
4065
+ (0, import_node_fs4.mkdirSync)(outDir, { recursive: true });
4066
+ console.log("\n[1/7] Generating prompts via QWEN 3.6...");
4067
+ const prompts = await buildClipPrompts(question, answer);
4068
+ console.log(" Voiceover:", prompts.voiceover);
4069
+ console.log(" Audio mood:", prompts.audioMood);
4070
+ console.log("\n[2/7] Generating clip 1 (text-to-video)...");
4071
+ const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed));
4072
+ console.log("\n[3/7] Extracting last frame \u2192 clip 2 start...");
4073
+ const jpgPath = await extractLastFrame(result1.video.url, outDir);
4074
+ const imageBlob = new Blob([(0, import_node_fs4.readFileSync)(jpgPath)], { type: "image/jpeg" });
4075
+ const frameUrl = await import_client3.fal.storage.upload(imageBlob);
4076
+ try {
4077
+ (0, import_node_fs4.unlinkSync)(jpgPath);
4078
+ } catch {
4079
+ }
4080
+ console.log("\n[4/7] Generating clip 2 (image-to-video from last frame)...");
4081
+ const seed2 = opts.seed !== void 0 ? opts.seed + 1 : void 0;
4082
+ const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl));
4083
+ console.log("\n[5/7] Concatenating clips + generating voiceover (parallel)...");
4084
+ const [combinedPath, voiceoverUrl] = await Promise.all([
4085
+ concatenateClips(result1.video.url, result2.video.url, outDir),
4086
+ generateVoiceover(prompts.voiceover)
4087
+ ]);
4088
+ console.log("\n[6/7] Adding background audio via MMAudio V2...");
4089
+ const falVideoUrl = await uploadToFal(combinedPath);
4090
+ const totalDuration = (opts.clipDurationSeconds ?? 8) * 2;
4091
+ const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration);
4092
+ console.log("\n[7/7] Overlaying voiceover on final video...");
4093
+ const videoWithAudioPath = (0, import_node_path4.join)(outDir, `with-bg-audio-${Date.now()}.mp4`);
4094
+ const bgRes = await fetch(videoWithAudioUrl);
4095
+ (0, import_node_fs4.writeFileSync)(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()));
4096
+ const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir);
4097
+ return {
4098
+ clip1Url: result1.video.url,
4099
+ clip2Url: result2.video.url,
4100
+ finalVideoPath,
4101
+ seed: result1.seed,
4102
+ promptClip1: prompts.clip1,
4103
+ promptClip2: prompts.clip2,
4104
+ voiceover: prompts.voiceover,
4105
+ audioMood: prompts.audioMood
4106
+ };
4107
+ }
4108
+ async generateEpisode(brief, opts = {}) {
4109
+ const outDir = opts.outputDir ?? (0, import_node_path4.join)((0, import_node_os2.tmpdir)(), `episode-${brief.episodeNumber}-${Date.now()}`);
4110
+ (0, import_node_fs4.mkdirSync)(outDir, { recursive: true });
4111
+ const prompts = extractEpisodePrompts(brief);
4112
+ console.log(`
4113
+ [Episode ${brief.episodeNumber}/${brief.episodeCount}] ${brief.sectionTitle}`);
4114
+ console.log(" Voiceover:", prompts.voiceover);
4115
+ console.log("\n[2/7] Generating clip 1 (text-to-video)...");
4116
+ const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed));
4117
+ console.log("\n[3/7] Extracting last frame \u2192 clip 2 start...");
4118
+ const jpgPath = await extractLastFrame(result1.video.url, outDir);
4119
+ const imageBlob = new Blob([(0, import_node_fs4.readFileSync)(jpgPath)], { type: "image/jpeg" });
4120
+ const frameUrl = await import_client3.fal.storage.upload(imageBlob);
4121
+ try {
4122
+ (0, import_node_fs4.unlinkSync)(jpgPath);
4123
+ } catch {
4124
+ }
4125
+ console.log("\n[4/7] Generating clip 2 (image-to-video from last frame)...");
4126
+ const seed2 = opts.seed !== void 0 ? opts.seed + 1 : void 0;
4127
+ const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl));
4128
+ console.log("\n[5/7] Concatenating clips + generating voiceover (parallel)...");
4129
+ const [combinedPath, voiceoverUrl] = await Promise.all([
4130
+ concatenateClips(result1.video.url, result2.video.url, outDir),
4131
+ generateVoiceover(prompts.voiceover)
4132
+ ]);
4133
+ console.log("\n[6/7] Adding background audio via MMAudio V2...");
4134
+ const falVideoUrl = await uploadToFal(combinedPath);
4135
+ const totalDuration = (opts.clipDurationSeconds ?? 8) * 2;
4136
+ const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration);
4137
+ console.log("\n[7/7] Overlaying voiceover on final video...");
4138
+ const videoWithAudioPath = (0, import_node_path4.join)(outDir, `with-bg-audio-${Date.now()}.mp4`);
4139
+ const bgRes = await fetch(videoWithAudioUrl);
4140
+ (0, import_node_fs4.writeFileSync)(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()));
4141
+ const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir);
4142
+ return {
4143
+ clip1Url: result1.video.url,
4144
+ clip2Url: result2.video.url,
4145
+ finalVideoPath,
4146
+ seed: result1.seed,
4147
+ promptClip1: prompts.clip1,
4148
+ promptClip2: prompts.clip2,
4149
+ voiceover: prompts.voiceover,
4150
+ audioMood: prompts.audioMood
4151
+ };
4152
+ }
4153
+ };
4154
+ // Annotate the CommonJS export names for ESM import in node:
4155
+ 0 && (module.exports = {
4156
+ VideoGenerator,
4157
+ buildClipPrompts,
4158
+ harvest
4159
+ });
4160
+ //# sourceMappingURL=index.cjs.map