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