mcp-scraper 0.26.3 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/dist/bin/api-server.cjs +309 -270
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +2 -2
  5. package/dist/bin/mcp-scraper-cli.cjs +3 -2
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +3 -3
  8. package/dist/bin/mcp-scraper-install.cjs +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs +22 -9
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +3 -3
  14. package/dist/bin/paa-harvest.cjs +2 -1
  15. package/dist/bin/paa-harvest.cjs.map +1 -1
  16. package/dist/bin/paa-harvest.js +2 -2
  17. package/dist/{chunk-FUWZWKGO.js → chunk-KAMKTSJQ.js} +2 -2
  18. package/dist/chunk-O6KFDDRM.js +7 -0
  19. package/dist/chunk-O6KFDDRM.js.map +1 -0
  20. package/dist/{chunk-73YM3DEB.js → chunk-RPR5LDLS.js} +22 -10
  21. package/dist/chunk-RPR5LDLS.js.map +1 -0
  22. package/dist/{chunk-E5J4HJBO.js → chunk-YH5DRFWQ.js} +4 -2
  23. package/dist/{chunk-XGIPATLV.js → chunk-ZQSHKWV4.js} +3 -2
  24. package/dist/chunk-ZQSHKWV4.js.map +1 -0
  25. package/dist/index.cjs +2 -1
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/{server-GN34MGAE.js → server-YXVSZ3T7.js} +287 -261
  29. package/dist/{server-GN34MGAE.js.map → server-YXVSZ3T7.js.map} +1 -1
  30. package/dist/{worker-E3KHQH2F.js → worker-RYP5EOV5.js} +3 -3
  31. package/docs/mcp-tool-manifest.generated.json +37 -8
  32. package/package.json +1 -1
  33. package/dist/chunk-27VXTOD2.js +0 -7
  34. package/dist/chunk-27VXTOD2.js.map +0 -1
  35. package/dist/chunk-73YM3DEB.js.map +0 -1
  36. package/dist/chunk-XGIPATLV.js.map +0 -1
  37. /package/dist/{chunk-FUWZWKGO.js.map → chunk-KAMKTSJQ.js.map} +0 -0
  38. /package/dist/{chunk-E5J4HJBO.js.map → chunk-YH5DRFWQ.js.map} +0 -0
  39. /package/dist/{worker-E3KHQH2F.js.map → worker-RYP5EOV5.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  harvest
3
- } from "./chunk-E5J4HJBO.js";
3
+ } from "./chunk-YH5DRFWQ.js";
4
4
  import {
5
5
  recordVendorUsage
6
6
  } from "./chunk-MTSBI7ZH.js";
7
7
  import "./chunk-K443GQY5.js";
8
- import "./chunk-XGIPATLV.js";
8
+ import "./chunk-ZQSHKWV4.js";
9
9
  import "./chunk-M2S27J6Z.js";
10
10
  import "./chunk-ICT7DDHL.js";
11
11
 
@@ -12,9 +12,11 @@ import {
12
12
  MapsSelectors,
13
13
  buildYouTubeChannelVideosUrl,
14
14
  deleteKernelProxyId,
15
+ encodeUule,
15
16
  harvest,
17
+ normalizeLocation,
16
18
  resolveKernelProxyId
17
- } from "./chunk-E5J4HJBO.js";
19
+ } from "./chunk-YH5DRFWQ.js";
18
20
  import {
19
21
  HttpMcpToolExecutor,
20
22
  assessTranscriptSignal,
@@ -34,7 +36,7 @@ import {
34
36
  sanitizeAttempts,
35
37
  sanitizeHarvestResult,
36
38
  transcribeMediaUrl
37
- } from "./chunk-73YM3DEB.js";
39
+ } from "./chunk-RPR5LDLS.js";
38
40
  import {
39
41
  auditImages,
40
42
  buildLinkReport,
@@ -63,7 +65,7 @@ import {
63
65
  workflowOutputBaseDir,
64
66
  workflowStepCount,
65
67
  workflowSupportsSteps
66
- } from "./chunk-FUWZWKGO.js";
68
+ } from "./chunk-KAMKTSJQ.js";
67
69
  import "./chunk-K443GQY5.js";
68
70
  import {
69
71
  DEFAULT_MAPS_PROXY_MODE,
@@ -74,8 +76,8 @@ import {
74
76
  RawMapsHoursRowSchema,
75
77
  RawMapsOverviewSchema,
76
78
  RawMapsReviewStatsSchema
77
- } from "./chunk-XGIPATLV.js";
78
- import "./chunk-27VXTOD2.js";
79
+ } from "./chunk-ZQSHKWV4.js";
80
+ import "./chunk-O6KFDDRM.js";
79
81
  import {
80
82
  completeExtractJob,
81
83
  countSuccessfulPages,
@@ -13077,6 +13079,262 @@ videoApp.post("/status", createApiKeyAuth(), async (c) => {
13077
13079
  // src/api/maps-routes.ts
13078
13080
  import { Hono as Hono11 } from "hono";
13079
13081
 
13082
+ // src/extractor/MapsSearchExtractor.ts
13083
+ var LOCAL_RESULTS_PAGE_LIMIT = 8;
13084
+ var LOCAL_RESULTS_WAIT_MS = 1e3;
13085
+ function buildGoogleLocalResultsUrl(input) {
13086
+ const query = [input.query, input.location].filter(Boolean).join(" ");
13087
+ const params = new URLSearchParams({ q: query, udm: "1", gl: input.gl, hl: input.hl, pws: "0" });
13088
+ if (input.location) params.set("uule", encodeUule(normalizeLocation(input.location)));
13089
+ return `https://www.google.com/search?${params.toString()}`;
13090
+ }
13091
+ var MapsSearchExtractor = class {
13092
+ constructor(driver) {
13093
+ this.driver = driver;
13094
+ }
13095
+ driver;
13096
+ async extract(options) {
13097
+ const startMs = Date.now();
13098
+ const searchQuery = [options.query, options.location].filter(Boolean).join(" ");
13099
+ const searchUrl = buildGoogleLocalResultsUrl(options);
13100
+ const config = {
13101
+ headless: options.headless,
13102
+ kernelApiKey: options.kernelApiKey,
13103
+ kernelProxyId: options.kernelProxyId,
13104
+ kernelProxyResolution: options.kernelProxyResolution,
13105
+ proxyMode: options.proxyMode,
13106
+ viewport: { width: 1280, height: 900 },
13107
+ locale: `${options.hl}-${options.gl.toUpperCase()}`,
13108
+ debug: options.debug
13109
+ };
13110
+ try {
13111
+ await this.driver.launch(config);
13112
+ const page = this.driver.getPage();
13113
+ await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13114
+ await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
13115
+ if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
13116
+ const results = await this.collectResults(page, options);
13117
+ return {
13118
+ query: options.query,
13119
+ location: options.location ?? null,
13120
+ searchQuery,
13121
+ searchUrl,
13122
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
13123
+ requestedMaxResults: options.maxResults,
13124
+ resultCount: results.length,
13125
+ results,
13126
+ durationMs: Date.now() - startMs
13127
+ };
13128
+ } finally {
13129
+ await this.driver.close();
13130
+ }
13131
+ }
13132
+ async detectBlock(page) {
13133
+ return page.evaluate(() => {
13134
+ const text2 = document.body.innerText.slice(0, 2e3);
13135
+ return /unusual traffic|captcha|recaptcha|about this page/i.test(text2) || /\/sorry\//.test(location.href);
13136
+ });
13137
+ }
13138
+ async collectResults(page, options) {
13139
+ const seen = /* @__PURE__ */ new Set();
13140
+ const results = [];
13141
+ let pageCount = 0;
13142
+ while (results.length < options.maxResults && pageCount < LOCAL_RESULTS_PAGE_LIMIT) {
13143
+ if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
13144
+ const cards = await this.extractVisibleCards(page, options.location);
13145
+ for (const card of cards) {
13146
+ if (results.length >= options.maxResults || seen.has(card.cardKey)) continue;
13147
+ seen.add(card.cardKey);
13148
+ const details = await this.openCardAndExtractDialog(page, card, options.includeServices);
13149
+ results.push({
13150
+ position: results.length + 1,
13151
+ name: card.name,
13152
+ placeUrl: details?.placeUrl ?? card.placeUrl,
13153
+ cid: details?.cid ?? card.cid,
13154
+ cidDecimal: details?.cidDecimal ?? card.cidDecimal,
13155
+ rating: details?.rating ?? card.rating,
13156
+ reviewCount: details?.reviewCount ?? card.reviewCount,
13157
+ category: details?.category ?? card.category,
13158
+ address: details?.address ?? card.address,
13159
+ phone: details?.phone ?? card.phone,
13160
+ hoursStatus: details?.hoursStatus ?? card.hoursStatus,
13161
+ websiteUrl: details?.websiteUrl ?? card.websiteUrl,
13162
+ directionsUrl: card.directionsUrl,
13163
+ metadata: card.metadata,
13164
+ services: details?.services ?? [],
13165
+ areasServed: details?.areasServed ?? [],
13166
+ profileDetailsStatus: details ? "collected" : "unavailable"
13167
+ });
13168
+ }
13169
+ if (results.length >= options.maxResults) break;
13170
+ const advanced = await this.advanceToMoreResults(page);
13171
+ if (!advanced) break;
13172
+ pageCount += 1;
13173
+ }
13174
+ return results;
13175
+ }
13176
+ async extractVisibleCards(page, location2) {
13177
+ const fallbackLocation = location2 ?? "";
13178
+ return page.evaluate((locationHint) => {
13179
+ const normalize = (value) => {
13180
+ const text2 = value?.replace(/\s+/g, " ").trim() ?? "";
13181
+ return text2 || null;
13182
+ };
13183
+ const phonePattern = /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/;
13184
+ const addressPattern = /\b[A-Z]{2}\s+\d{5}(?:-\d{4})?\b|\b(?:St|Street|Ave|Avenue|Rd|Road|Blvd|Boulevard|Dr|Drive|Ln|Lane|Way|Pkwy|Parkway)\b/i;
13185
+ const cards = Array.from(document.querySelectorAll("button")).filter((button) => {
13186
+ const name = normalize(button.querySelector("h3")?.textContent);
13187
+ return Boolean(name) && !button.closest('[role="dialog"], dialog') && !/\bsponsored\b/i.test(button.innerText);
13188
+ });
13189
+ const seen = /* @__PURE__ */ new Set();
13190
+ const out = [];
13191
+ for (const [cardIndex, card] of cards.entries()) {
13192
+ const name = normalize(card.querySelector("h3")?.textContent);
13193
+ if (!name) continue;
13194
+ const text2 = normalize(card.innerText) ?? name;
13195
+ const key = `${name.toLowerCase()}|${text2.toLowerCase()}`;
13196
+ if (seen.has(key)) continue;
13197
+ seen.add(key);
13198
+ const lines = text2.split(/\n+/).map((line) => normalize(line)).filter((line) => Boolean(line));
13199
+ const aria = normalize(card.getAttribute("aria-label"));
13200
+ const rating = (aria ?? text2).match(/(?:rated\s+)?(\d(?:\.\d)?)\s*(?:out of 5)?/i)?.[1] ?? null;
13201
+ const reviewCount = (aria ?? text2).match(/(?:\(|·\s*)([\d,]+)\s+(?:user\s+)?reviews?\b/i)?.[1]?.replace(/,/g, "") ?? null;
13202
+ const phone = lines.find((line) => phonePattern.test(line)) ?? null;
13203
+ const address = lines.find((line) => addressPattern.test(line)) ?? null;
13204
+ const hoursStatus = lines.find((line) => /\b(?:open|closed|opens|closes)\b/i.test(line)) ?? null;
13205
+ const category = lines.find(
13206
+ (line) => line !== name && line !== phone && line !== address && line !== hoursStatus && !/^(?:rated|\d[\d.,]*\s*(?:out of 5|reviews?)?)\b/i.test(line) && !/sponsored|website|directions|call|save|share|more/i.test(line)
13207
+ ) ?? null;
13208
+ const mapsSearchUrl = `https://www.google.com/maps/search/${encodeURIComponent([name, locationHint].filter(Boolean).join(" "))}`;
13209
+ out.push({
13210
+ cardIndex,
13211
+ cardKey: key,
13212
+ name,
13213
+ placeUrl: mapsSearchUrl,
13214
+ cid: null,
13215
+ cidDecimal: null,
13216
+ rating,
13217
+ reviewCount,
13218
+ category,
13219
+ address,
13220
+ phone,
13221
+ hoursStatus,
13222
+ websiteUrl: null,
13223
+ directionsUrl: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent([name, address].filter(Boolean).join(", "))}`,
13224
+ metadata: lines.slice(0, 20)
13225
+ });
13226
+ }
13227
+ return out;
13228
+ }, fallbackLocation);
13229
+ }
13230
+ async openCardAndExtractDialog(page, card, includeServices) {
13231
+ const clicked = await page.evaluate((input) => {
13232
+ const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
13233
+ const cards = Array.from(document.querySelectorAll("button")).filter(
13234
+ (button) => Boolean(button.querySelector("h3")) && !button.closest('[role="dialog"], dialog') && !/\bsponsored\b/i.test(button.innerText)
13235
+ );
13236
+ const exact = cards.find((button) => `${normalize(button.querySelector("h3")?.textContent)}|${normalize(button.innerText)}` === input.cardKey);
13237
+ const target = exact ?? cards[input.cardIndex];
13238
+ if (!target) return false;
13239
+ target.click();
13240
+ return true;
13241
+ }, { cardIndex: card.cardIndex, cardKey: card.cardKey });
13242
+ if (!clicked) return null;
13243
+ const dialog = page.locator('dialog, [role="dialog"]').filter({ has: page.locator('h1, h2, [role="heading"]') }).last();
13244
+ await dialog.waitFor({ state: "visible", timeout: 6e3 }).catch(() => {
13245
+ });
13246
+ if (await dialog.count() === 0 || !await dialog.isVisible().catch(() => false)) return null;
13247
+ const details = await dialog.evaluate((root, includeServicesValue) => {
13248
+ const text2 = (value) => value?.replace(/\s+/g, " ").trim() ?? "";
13249
+ const phonePattern = /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/;
13250
+ const buttons = Array.from(root.querySelectorAll("button"));
13251
+ const buttonTexts = buttons.map((button) => text2(button.innerText)).filter(Boolean);
13252
+ const servicesText = includeServicesValue ? buttonTexts.find((value) => /^services\s*:/i.test(value)) : null;
13253
+ const services = servicesText ? [...new Set(servicesText.replace(/^services\s*:\s*/i, "").split(/\s*[,·]\s*/).map((value) => value.trim()).filter(Boolean))] : [];
13254
+ const areaText = includeServicesValue ? buttonTexts.find((value) => /^serves\b/i.test(value)) : null;
13255
+ const areasServed = areaText ? [areaText.replace(/^serves\s*/i, "").trim()].filter(Boolean) : [];
13256
+ const externalLinks = Array.from(root.querySelectorAll("a[href]"));
13257
+ const website = externalLinks.find((link) => /^website$/i.test(text2(link.innerText)) || /^website$/i.test(link.getAttribute("aria-label") ?? ""));
13258
+ const mapLink = externalLinks.find((link) => /google\.[^/]+\/maps|\/maps\//i.test(link.href));
13259
+ const addressLink = externalLinks.find((link) => /\b[A-Z]{2}\s+\d{5}(?:-\d{4})?\b|\b(?:St|Street|Ave|Avenue|Rd|Road|Blvd|Boulevard|Dr|Drive|Ln|Lane|Way|Pkwy|Parkway)\b/i.test(text2(link.innerText)));
13260
+ const allText = text2(root.innerText);
13261
+ const rating = allText.match(/\b(?:rated\s+)?(\d(?:\.\d)?)\s+out of 5\b/i)?.[1] ?? allText.match(/\b(\d(?:\.\d)?)\s+rated\b/i)?.[1] ?? null;
13262
+ const reviewCount = allText.match(/([\d,]+)\s+(?:user\s+)?reviews?\b/i)?.[1]?.replace(/,/g, "") ?? null;
13263
+ const phoneButton = buttonTexts.find((value) => /^call\b/i.test(value) || phonePattern.test(value)) ?? "";
13264
+ const phone = phoneButton.match(phonePattern)?.[0] ?? null;
13265
+ const hoursStatus = buttonTexts.find((value) => /\b(?:open|closed|opens|closes)\b/i.test(value)) ?? null;
13266
+ const placeUrl = mapLink?.href ?? addressLink?.href ?? location.href;
13267
+ const fid = placeUrl.match(/!1s(0x[0-9a-f]+):(0x[0-9a-f]+)/i);
13268
+ const cid = fid ? `${fid[1]}:${fid[2]}` : null;
13269
+ let cidDecimal = null;
13270
+ if (fid) {
13271
+ try {
13272
+ cidDecimal = BigInt(fid[2]).toString();
13273
+ } catch {
13274
+ }
13275
+ }
13276
+ if (!cidDecimal) {
13277
+ const cidFromQuery = new URL(placeUrl).searchParams.get("cid");
13278
+ cidDecimal = cidFromQuery && /^\d+$/.test(cidFromQuery) ? cidFromQuery : null;
13279
+ }
13280
+ return {
13281
+ placeUrl,
13282
+ cid,
13283
+ cidDecimal,
13284
+ rating,
13285
+ reviewCount,
13286
+ category: null,
13287
+ address: text2(addressLink?.innerText) || null,
13288
+ phone,
13289
+ websiteUrl: website?.href ?? null,
13290
+ hoursStatus,
13291
+ services,
13292
+ areasServed
13293
+ };
13294
+ }, includeServices);
13295
+ await this.closeDialog(page);
13296
+ return details;
13297
+ }
13298
+ async closeDialog(page) {
13299
+ const closed = await page.evaluate(() => {
13300
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]'));
13301
+ const dialog = dialogs.find((candidate) => candidate.offsetParent !== null);
13302
+ const close = dialog && Array.from(dialog.querySelectorAll("button")).find(
13303
+ (button) => /^close$/i.test(button.getAttribute("aria-label") ?? "") || /^close$/i.test(button.innerText.trim())
13304
+ );
13305
+ close?.click();
13306
+ return Boolean(close);
13307
+ });
13308
+ if (closed) await page.waitForTimeout(250);
13309
+ }
13310
+ async advanceToMoreResults(page) {
13311
+ const before = await page.evaluate(() => document.body.innerText.slice(-2e3));
13312
+ const advanced = await page.evaluate(() => {
13313
+ const visible = (element) => element.offsetParent !== null;
13314
+ const mobile = Array.from(document.querySelectorAll("button, a")).find(
13315
+ (element) => visible(element) && /^(more search results|more results)$/i.test(element.innerText.trim())
13316
+ );
13317
+ if (mobile) {
13318
+ mobile.click();
13319
+ return true;
13320
+ }
13321
+ const next = Array.from(document.querySelectorAll("a[href]")).find(
13322
+ (link) => visible(link) && (/^next$/i.test(link.innerText.trim()) || /[?&]start=\d+/.test(link.href))
13323
+ );
13324
+ if (next) {
13325
+ next.click();
13326
+ return true;
13327
+ }
13328
+ return false;
13329
+ });
13330
+ if (!advanced) return false;
13331
+ await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
13332
+ await page.waitForFunction((previous) => document.body.innerText.slice(-2e3) !== previous, before, { timeout: 5e3 }).catch(() => {
13333
+ });
13334
+ return true;
13335
+ }
13336
+ };
13337
+
13080
13338
  // src/extractor/MapsNavigator.ts
13081
13339
  var MapsNavigator = class {
13082
13340
  constructor(page) {
@@ -13135,32 +13393,26 @@ var MapsNavigator = class {
13135
13393
  await this.page.waitForSelector('[role="main"] h2', { timeout: 5e3 }).catch(() => {
13136
13394
  });
13137
13395
  }
13138
- async navigateToSerpBusinessPanel(businessName, location2) {
13139
- const query = `${businessName} ${location2}`;
13140
- const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&udm=1`;
13396
+ async navigateToSerpBusinessPanel(businessName, location2, gl, hl) {
13397
+ const searchUrl = buildGoogleLocalResultsUrl({ query: businessName, location: location2, gl, hl });
13141
13398
  await this.page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 45e3 }).catch(() => {
13142
13399
  });
13143
- await this.page.waitForTimeout(2e3);
13400
+ await this.page.waitForTimeout(1e3);
13144
13401
  const clicked = await this.page.evaluate((name) => {
13145
- const trimmedName = name.trim();
13146
- const words = trimmedName.split(/\s+/);
13147
- const prefix = words.slice(0, Math.min(2, words.length)).join(" ");
13148
- const headings = Array.from(document.querySelectorAll('h3, div[role="heading"]'));
13149
- const heading = headings.find((el2) => el2.innerText?.trim() === trimmedName) ?? headings.find((el2) => el2.innerText?.trim().startsWith(prefix));
13150
- const clickable = heading?.closest('a, div[role="link"], div[jsaction]');
13151
- if (clickable) {
13152
- clickable.click();
13153
- return true;
13154
- }
13155
- if (heading) {
13156
- heading.click();
13402
+ const normalized = name.replace(/\s+/g, " ").trim().toLowerCase();
13403
+ const card = Array.from(document.querySelectorAll("button")).find(
13404
+ (button) => button.querySelector("h3")?.textContent?.replace(/\s+/g, " ").trim().toLowerCase() === normalized
13405
+ );
13406
+ if (card) {
13407
+ card.click();
13157
13408
  return true;
13158
13409
  }
13159
13410
  return false;
13160
13411
  }, businessName);
13161
13412
  if (!clicked) return false;
13162
- await this.page.waitForTimeout(2e3);
13163
- return true;
13413
+ await this.page.waitForSelector('dialog, [role="dialog"]', { timeout: 5e3 }).catch(() => {
13414
+ });
13415
+ return this.page.locator('dialog, [role="dialog"]').count().then((count) => count > 0);
13164
13416
  }
13165
13417
  buildReviewsUrl(placeUrl) {
13166
13418
  const fid = placeUrl.match(/!1s(0x[0-9a-f]+:0x[0-9a-f]+)/i);
@@ -13336,7 +13588,7 @@ var MapsExtractor = class {
13336
13588
  let servicesStatus = "not_requested";
13337
13589
  if (options.includeServices) {
13338
13590
  try {
13339
- const opened = await navigator.navigateToSerpBusinessPanel(options.businessName, options.location);
13591
+ const opened = await navigator.navigateToSerpBusinessPanel(options.businessName, options.location, options.gl, options.hl);
13340
13592
  if (opened) {
13341
13593
  const result = await this.extractServicesAndAreasServed(page);
13342
13594
  services = result.services;
@@ -13557,242 +13809,15 @@ var MapsExtractor = class {
13557
13809
  return result.data;
13558
13810
  }
13559
13811
  async extractServicesAndAreasServed(page) {
13560
- await page.waitForTimeout(1500);
13561
- let services = [];
13562
- if (await this.clickRowStartingWithRetry(page, "Services:")) {
13563
- await page.waitForSelector('[role="dialog"]', { timeout: 5e3 }).catch(() => {
13564
- });
13565
- await page.waitForTimeout(800);
13566
- services = await this.extractOpenDialogItems(page, "Services");
13567
- await this.closeOpenDialog(page, "Services");
13568
- await page.waitForTimeout(500);
13569
- }
13570
- let areasServed = [];
13571
- if (await this.clickRowStartingWithRetry(page, "Serves ")) {
13572
- await page.waitForSelector('[role="dialog"]', { timeout: 5e3 }).catch(() => {
13573
- });
13574
- await page.waitForTimeout(800);
13575
- areasServed = await this.extractOpenDialogItems(page, "Areas Served");
13576
- await this.closeOpenDialog(page, "Areas Served");
13577
- }
13578
- return { services, areasServed };
13579
- }
13580
- async clickRowStartingWithRetry(page, prefix) {
13581
- if (await this.clickRowStartingWith(page, prefix)) return true;
13582
- await page.waitForTimeout(2e3);
13583
- return this.clickRowStartingWith(page, prefix);
13584
- }
13585
- async clickRowStartingWith(page, prefix) {
13586
- return page.evaluate((p) => {
13587
- const el2 = Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(
13588
- (e) => (e.innerText ?? "").trim().startsWith(p)
13589
- );
13590
- if (!el2) return false;
13591
- el2.click();
13592
- return true;
13593
- }, prefix);
13594
- }
13595
- async extractOpenDialogItems(page, expectedHeading) {
13596
- return page.evaluate((expected) => {
13597
- const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'));
13598
- const withHeading = dialogs.find((d) => {
13599
- const h = d.querySelector('h1, h2, [role="heading"]');
13600
- const headingText = h?.innerText?.trim() ?? "";
13601
- return headingText.startsWith(expected);
13602
- });
13603
- if (!withHeading) return [];
13604
- const headingEl = withHeading.querySelector('h1, h2, [role="heading"]');
13605
- const headingLines = new Set(
13606
- (headingEl?.innerText ?? "").split("\n").map((s) => s.trim()).filter(Boolean)
13607
- );
13608
- const seen = /* @__PURE__ */ new Set();
13609
- const items = [];
13610
- withHeading.querySelectorAll("*").forEach((el2) => {
13611
- if (el2.children.length > 0) return;
13612
- const text2 = el2.innerText?.trim() ?? el2.textContent?.trim() ?? "";
13613
- if (!text2 || headingLines.has(text2)) return;
13614
- if (/^(back|close|done|thanks)$/i.test(text2)) return;
13615
- if (/^serves\s.*following/i.test(text2)) return;
13616
- if (seen.has(text2)) return;
13617
- seen.add(text2);
13618
- items.push(text2);
13619
- });
13620
- return items;
13621
- }, expectedHeading);
13622
- }
13623
- async closeOpenDialog(page, expectedHeading) {
13624
- await page.evaluate((expected) => {
13625
- const dialog = Array.from(document.querySelectorAll('[role="dialog"]')).find((d) => {
13626
- const h = d.querySelector('h1, h2, [role="heading"]');
13627
- const headingText = h?.innerText?.trim() ?? "";
13628
- return headingText.startsWith(expected);
13629
- });
13630
- const closeBtn = dialog?.querySelector('[aria-label*="Close" i], button[aria-label*="close" i]');
13631
- closeBtn?.click();
13632
- }, expectedHeading);
13633
- }
13634
- };
13635
-
13636
- // src/extractor/MapsSearchExtractor.ts
13637
- var MAPS_SEARCH_SCROLL_BUDGET_MS = 6e4;
13638
- var MAPS_SEARCH_SCROLL_STEP_MS = 1200;
13639
- var MAPS_SEARCH_MAX_NO_GROWTH_ROUNDS = 4;
13640
- var MapsSearchExtractor = class {
13641
- constructor(driver) {
13642
- this.driver = driver;
13643
- }
13644
- driver;
13645
- async extract(options) {
13646
- const startMs = Date.now();
13647
- const searchQuery = [options.query, options.location].filter(Boolean).join(" ");
13648
- const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(searchQuery)}?hl=${encodeURIComponent(options.hl)}`;
13649
- const config = {
13650
- headless: options.headless,
13651
- kernelApiKey: options.kernelApiKey,
13652
- kernelProxyId: options.kernelProxyId,
13653
- kernelProxyResolution: options.kernelProxyResolution,
13654
- proxyMode: options.proxyMode,
13655
- viewport: { width: 1280, height: 900 },
13656
- locale: `${options.hl}-${options.gl.toUpperCase()}`,
13657
- debug: options.debug
13658
- };
13659
- try {
13660
- await this.driver.launch(config);
13661
- const page = this.driver.getPage();
13662
- await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13663
- await page.waitForTimeout(3e3);
13664
- const blocked = await this.detectBlock(page);
13665
- if (blocked) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
13666
- const results = await this.collectResults(page, options.maxResults);
13667
- return {
13668
- query: options.query,
13669
- location: options.location ?? null,
13670
- searchQuery,
13671
- searchUrl,
13672
- extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
13673
- requestedMaxResults: options.maxResults,
13674
- resultCount: results.length,
13675
- results,
13676
- durationMs: Date.now() - startMs
13677
- };
13678
- } finally {
13679
- await this.driver.close();
13680
- }
13681
- }
13682
- async detectBlock(page) {
13683
13812
  return page.evaluate(() => {
13684
- const text2 = document.body.innerText.slice(0, 2e3);
13685
- return /unusual traffic|captcha|recaptcha|about this page/i.test(text2) || /\/sorry\//.test(location.href);
13686
- });
13687
- }
13688
- async collectResults(page, maxResults) {
13689
- const seen = /* @__PURE__ */ new Map();
13690
- const started = Date.now();
13691
- let noGrowthRounds = 0;
13692
- while (Date.now() - started < MAPS_SEARCH_SCROLL_BUDGET_MS) {
13693
- const before = seen.size;
13694
- const batch = await this.extractVisibleResults(page);
13695
- for (const result of batch) {
13696
- const key = this.resultKey(result);
13697
- if (!seen.has(key)) seen.set(key, { ...result, position: seen.size + 1 });
13698
- if (seen.size >= maxResults) break;
13699
- }
13700
- if (seen.size >= maxResults) break;
13701
- if (seen.size === before) noGrowthRounds += 1;
13702
- else noGrowthRounds = 0;
13703
- if (noGrowthRounds >= MAPS_SEARCH_MAX_NO_GROWTH_ROUNDS) break;
13704
- await page.evaluate(() => {
13705
- const feed = document.querySelector('[role="feed"]');
13706
- if (feed) {
13707
- feed.scrollTop = feed.scrollHeight;
13708
- } else {
13709
- window.scrollTo(0, document.body.scrollHeight);
13710
- }
13711
- });
13712
- await page.waitForTimeout(MAPS_SEARCH_SCROLL_STEP_MS);
13713
- }
13714
- return [...seen.values()].slice(0, maxResults);
13715
- }
13716
- resultKey(result) {
13717
- return result.cidDecimal ?? result.placeUrl.replace(/[?&].*$/, "") ?? result.name;
13718
- }
13719
- async extractVisibleResults(page) {
13720
- return page.evaluate(() => {
13721
- function normalizeText(value) {
13722
- const text2 = value?.replace(/\s+/g, " ").trim() ?? "";
13723
- return text2 || null;
13724
- }
13725
- function cidFromUrl(url) {
13726
- const fid = url.match(/!1s(0x[0-9a-f]+):(0x[0-9a-f]+)/i);
13727
- if (!fid) return { cid: null, cidDecimal: null };
13728
- let cidDecimal = null;
13729
- try {
13730
- cidDecimal = BigInt(fid[2]).toString();
13731
- } catch {
13732
- }
13733
- return { cid: `${fid[1]}:${fid[2]}`, cidDecimal };
13734
- }
13735
- function textParts(card) {
13736
- if (!card) return [];
13737
- const parts = [];
13738
- card.querySelectorAll("div, span").forEach((el2) => {
13739
- const text2 = Array.from(el2.childNodes).filter((node) => node.nodeType === 3).map((node) => node.textContent?.trim() ?? "").filter((text3) => text3.length > 1 && text3.length < 140).join(" ");
13740
- if (text2 && !parts.includes(text2)) parts.push(text2);
13741
- });
13742
- return parts;
13743
- }
13744
- function firstMatching(parts, pattern) {
13745
- const value = parts.find((part) => pattern.test(part));
13746
- return value ?? null;
13747
- }
13748
- function normalizedSet(values) {
13749
- return new Set(values.filter(Boolean).map((value) => value.toLowerCase()));
13750
- }
13751
- const out = [];
13752
- const seen = /* @__PURE__ */ new Set();
13753
- const anchors = Array.from(document.querySelectorAll('a[href*="/maps/place/"]'));
13754
- for (const anchor of anchors) {
13755
- const placeUrl = anchor.href;
13756
- const stableUrl = placeUrl.replace(/[?&].*$/, "");
13757
- if (seen.has(stableUrl)) continue;
13758
- seen.add(stableUrl);
13759
- const card = anchor.closest('.Nv2PK, [role="article"], .bfdHYd') ?? anchor.parentElement;
13760
- const parts = textParts(card);
13761
- const aria = normalizeText(anchor.getAttribute("aria-label"));
13762
- const heading = normalizeText(card?.querySelector('.qBF1Pd, .fontHeadlineSmall, [role="heading"]')?.textContent);
13763
- const name = aria ?? heading ?? parts[0] ?? stableUrl;
13764
- const links = Array.from(card?.querySelectorAll("a[href]") ?? []);
13765
- const websiteUrl = links.find((link) => link.href.startsWith("http") && !link.href.includes("google."))?.href ?? null;
13766
- const rating = firstMatching(parts, /^\d(?:\.\d)?$/);
13767
- const reviewCountRaw = firstMatching(parts, /^\(?[\d,]+\)?(?:\s+reviews?)?$/i);
13768
- const phone = firstMatching(parts, /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/);
13769
- const hoursStatus = parts.find((part) => /^(open|closed|closes|opens)\b|^·\s*(opens|closes)\b/i.test(part)) ?? null;
13770
- const address = parts.find((part) => /\b[A-Z]{2}\s+\d{5}\b|\b(?:St|Street|Ave|Avenue|Rd|Road|Blvd|Drive|Dr)\b/i.test(part)) ?? null;
13771
- const directionsUrl = links.find((link) => /google\.[^/]+\/maps\/dir|\/dir\//i.test(link.href))?.href ?? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent([name, address].filter(Boolean).join(", ") || name)}`;
13772
- const excluded = normalizedSet([name, rating, reviewCountRaw, phone, hoursStatus, address, "Website", "Directions"]);
13773
- const category = parts.find((part) => {
13774
- const normalized = part.toLowerCase();
13775
- return !excluded.has(normalized) && !/^\d(?:\.\d)?$|^\(?[\d,]+\)?(?:\s+reviews?)?$/i.test(part) && !/(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/.test(part) && !/\b[A-Z]{2}\s+\d{5}\b|\b(?:St|Street|Ave|Avenue|Rd|Road|Blvd|Drive|Dr)\b/i.test(part) && !/^(open|closed|closes|opens)\b|^·\s*(opens|closes)\b|directions|website|book online|sponsored|visit site|financing/i.test(part);
13776
- }) ?? null;
13777
- const { cid, cidDecimal } = cidFromUrl(placeUrl);
13778
- out.push({
13779
- position: out.length + 1,
13780
- name,
13781
- placeUrl,
13782
- cid,
13783
- cidDecimal,
13784
- rating,
13785
- reviewCount: reviewCountRaw ? reviewCountRaw.replace(/[()]/g, "") : null,
13786
- category,
13787
- address,
13788
- phone,
13789
- hoursStatus,
13790
- websiteUrl,
13791
- directionsUrl,
13792
- metadata: parts.slice(0, 20)
13793
- });
13794
- }
13795
- return out;
13813
+ const visible = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).find((dialog) => dialog.offsetParent !== null);
13814
+ if (!visible) return { services: [], areasServed: [] };
13815
+ const values = Array.from(visible.querySelectorAll("button")).map((button) => button.innerText.replace(/\s+/g, " ").trim()).filter(Boolean);
13816
+ const servicesText = values.find((value) => /^services\s*:/i.test(value));
13817
+ const services = servicesText ? [...new Set(servicesText.replace(/^services\s*:\s*/i, "").split(/\s*[,·]\s*/).map((value) => value.trim()).filter(Boolean))] : [];
13818
+ const served = values.find((value) => /^serves\b/i.test(value));
13819
+ const areasServed = served ? [served.replace(/^serves\s*/i, "").trim()].filter(Boolean) : [];
13820
+ return { services, areasServed };
13796
13821
  });
13797
13822
  }
13798
13823
  };
@@ -14709,6 +14734,7 @@ async function searchCity(options, market) {
14709
14734
  gl: options.gl,
14710
14735
  hl: options.hl,
14711
14736
  maxResults: options.maxResultsPerCity,
14737
+ includeServices: false,
14712
14738
  headless: options.headless,
14713
14739
  kernelApiKey: options.kernelApiKey,
14714
14740
  proxyMode: options.proxyMode,
@@ -26978,7 +27004,7 @@ app.get("/cron/tick", async (c) => {
26978
27004
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
26979
27005
  return c.json({ error: "Unauthorized" }, 401);
26980
27006
  }
26981
- const { drainQueue } = await import("./worker-E3KHQH2F.js");
27007
+ const { drainQueue } = await import("./worker-RYP5EOV5.js");
26982
27008
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
26983
27009
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
26984
27010
  const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup] = await Promise.all([
@@ -27180,4 +27206,4 @@ app.get("/blog/:slug/", (c) => {
27180
27206
  export {
27181
27207
  app
27182
27208
  };
27183
- //# sourceMappingURL=server-GN34MGAE.js.map
27209
+ //# sourceMappingURL=server-YXVSZ3T7.js.map