mcp-scraper 0.26.2 → 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 (41) hide show
  1. package/README.md +6 -4
  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 +61 -27
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +64 -5
  14. package/dist/bin/mcp-stdio-server.js.map +1 -1
  15. package/dist/bin/paa-harvest.cjs +2 -1
  16. package/dist/bin/paa-harvest.cjs.map +1 -1
  17. package/dist/bin/paa-harvest.js +2 -2
  18. package/dist/{chunk-FUWZWKGO.js → chunk-KAMKTSJQ.js} +2 -2
  19. package/dist/chunk-O6KFDDRM.js +7 -0
  20. package/dist/chunk-O6KFDDRM.js.map +1 -0
  21. package/dist/{chunk-QO2TRJ3L.js → chunk-RPR5LDLS.js} +24 -48
  22. package/dist/chunk-RPR5LDLS.js.map +1 -0
  23. package/dist/{chunk-E5J4HJBO.js → chunk-YH5DRFWQ.js} +4 -2
  24. package/dist/{chunk-XGIPATLV.js → chunk-ZQSHKWV4.js} +3 -2
  25. package/dist/chunk-ZQSHKWV4.js.map +1 -0
  26. package/dist/index.cjs +2 -1
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/{server-K6ODZOZA.js → server-YXVSZ3T7.js} +323 -262
  30. package/dist/server-YXVSZ3T7.js.map +1 -0
  31. package/dist/{worker-E3KHQH2F.js → worker-RYP5EOV5.js} +3 -3
  32. package/docs/mcp-tool-manifest.generated.json +37 -8
  33. package/package.json +1 -1
  34. package/dist/chunk-QO2TRJ3L.js.map +0 -1
  35. package/dist/chunk-V37MZON3.js +0 -7
  36. package/dist/chunk-V37MZON3.js.map +0 -1
  37. package/dist/chunk-XGIPATLV.js.map +0 -1
  38. package/dist/server-K6ODZOZA.js.map +0 -1
  39. /package/dist/{chunk-FUWZWKGO.js.map → chunk-KAMKTSJQ.js.map} +0 -0
  40. /package/dist/{chunk-E5J4HJBO.js.map → chunk-YH5DRFWQ.js.map} +0 -0
  41. /package/dist/{worker-E3KHQH2F.js.map → worker-RYP5EOV5.js.map} +0 -0
@@ -17330,7 +17330,7 @@ var init_schemas3 = __esm({
17330
17330
  "use strict";
17331
17331
  import_zod17 = require("zod");
17332
17332
  DEFAULT_PROXY_MODE = "none";
17333
- DEFAULT_MAPS_PROXY_MODE = "location";
17333
+ DEFAULT_MAPS_PROXY_MODE = "none";
17334
17334
  HarvestOptionsSchema = import_zod17.z.object({
17335
17335
  query: import_zod17.z.string().min(1),
17336
17336
  location: import_zod17.z.string().optional(),
@@ -17372,6 +17372,7 @@ var init_schemas3 = __esm({
17372
17372
  gl: import_zod17.z.string().length(2).default("us"),
17373
17373
  hl: import_zod17.z.string().length(2).default("en"),
17374
17374
  maxResults: import_zod17.z.number().int().min(1).max(50).default(10),
17375
+ includeServices: import_zod17.z.boolean().default(false),
17375
17376
  proxyMode: import_zod17.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE),
17376
17377
  proxyZip: import_zod17.z.string().regex(/^\d{5}$/).optional(),
17377
17378
  debug: import_zod17.z.boolean().default(false),
@@ -19227,12 +19228,277 @@ var init_video_routes = __esm({
19227
19228
  }
19228
19229
  });
19229
19230
 
19231
+ // src/extractor/MapsSearchExtractor.ts
19232
+ function buildGoogleLocalResultsUrl(input) {
19233
+ const query = [input.query, input.location].filter(Boolean).join(" ");
19234
+ const params = new URLSearchParams({ q: query, udm: "1", gl: input.gl, hl: input.hl, pws: "0" });
19235
+ if (input.location) params.set("uule", encodeUule(normalizeLocation(input.location)));
19236
+ return `https://www.google.com/search?${params.toString()}`;
19237
+ }
19238
+ var LOCAL_RESULTS_PAGE_LIMIT, LOCAL_RESULTS_WAIT_MS, MapsSearchExtractor;
19239
+ var init_MapsSearchExtractor = __esm({
19240
+ "src/extractor/MapsSearchExtractor.ts"() {
19241
+ "use strict";
19242
+ init_errors();
19243
+ init_uule();
19244
+ LOCAL_RESULTS_PAGE_LIMIT = 8;
19245
+ LOCAL_RESULTS_WAIT_MS = 1e3;
19246
+ MapsSearchExtractor = class {
19247
+ constructor(driver) {
19248
+ this.driver = driver;
19249
+ }
19250
+ driver;
19251
+ async extract(options) {
19252
+ const startMs = Date.now();
19253
+ const searchQuery = [options.query, options.location].filter(Boolean).join(" ");
19254
+ const searchUrl = buildGoogleLocalResultsUrl(options);
19255
+ const config = {
19256
+ headless: options.headless,
19257
+ kernelApiKey: options.kernelApiKey,
19258
+ kernelProxyId: options.kernelProxyId,
19259
+ kernelProxyResolution: options.kernelProxyResolution,
19260
+ proxyMode: options.proxyMode,
19261
+ viewport: { width: 1280, height: 900 },
19262
+ locale: `${options.hl}-${options.gl.toUpperCase()}`,
19263
+ debug: options.debug
19264
+ };
19265
+ try {
19266
+ await this.driver.launch(config);
19267
+ const page = this.driver.getPage();
19268
+ await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
19269
+ await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19270
+ if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19271
+ const results = await this.collectResults(page, options);
19272
+ return {
19273
+ query: options.query,
19274
+ location: options.location ?? null,
19275
+ searchQuery,
19276
+ searchUrl,
19277
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
19278
+ requestedMaxResults: options.maxResults,
19279
+ resultCount: results.length,
19280
+ results,
19281
+ durationMs: Date.now() - startMs
19282
+ };
19283
+ } finally {
19284
+ await this.driver.close();
19285
+ }
19286
+ }
19287
+ async detectBlock(page) {
19288
+ return page.evaluate(() => {
19289
+ const text2 = document.body.innerText.slice(0, 2e3);
19290
+ return /unusual traffic|captcha|recaptcha|about this page/i.test(text2) || /\/sorry\//.test(location.href);
19291
+ });
19292
+ }
19293
+ async collectResults(page, options) {
19294
+ const seen = /* @__PURE__ */ new Set();
19295
+ const results = [];
19296
+ let pageCount = 0;
19297
+ while (results.length < options.maxResults && pageCount < LOCAL_RESULTS_PAGE_LIMIT) {
19298
+ if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19299
+ const cards = await this.extractVisibleCards(page, options.location);
19300
+ for (const card of cards) {
19301
+ if (results.length >= options.maxResults || seen.has(card.cardKey)) continue;
19302
+ seen.add(card.cardKey);
19303
+ const details = await this.openCardAndExtractDialog(page, card, options.includeServices);
19304
+ results.push({
19305
+ position: results.length + 1,
19306
+ name: card.name,
19307
+ placeUrl: details?.placeUrl ?? card.placeUrl,
19308
+ cid: details?.cid ?? card.cid,
19309
+ cidDecimal: details?.cidDecimal ?? card.cidDecimal,
19310
+ rating: details?.rating ?? card.rating,
19311
+ reviewCount: details?.reviewCount ?? card.reviewCount,
19312
+ category: details?.category ?? card.category,
19313
+ address: details?.address ?? card.address,
19314
+ phone: details?.phone ?? card.phone,
19315
+ hoursStatus: details?.hoursStatus ?? card.hoursStatus,
19316
+ websiteUrl: details?.websiteUrl ?? card.websiteUrl,
19317
+ directionsUrl: card.directionsUrl,
19318
+ metadata: card.metadata,
19319
+ services: details?.services ?? [],
19320
+ areasServed: details?.areasServed ?? [],
19321
+ profileDetailsStatus: details ? "collected" : "unavailable"
19322
+ });
19323
+ }
19324
+ if (results.length >= options.maxResults) break;
19325
+ const advanced = await this.advanceToMoreResults(page);
19326
+ if (!advanced) break;
19327
+ pageCount += 1;
19328
+ }
19329
+ return results;
19330
+ }
19331
+ async extractVisibleCards(page, location2) {
19332
+ const fallbackLocation = location2 ?? "";
19333
+ return page.evaluate((locationHint) => {
19334
+ const normalize4 = (value) => {
19335
+ const text2 = value?.replace(/\s+/g, " ").trim() ?? "";
19336
+ return text2 || null;
19337
+ };
19338
+ const phonePattern = /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/;
19339
+ 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;
19340
+ const cards = Array.from(document.querySelectorAll("button")).filter((button) => {
19341
+ const name = normalize4(button.querySelector("h3")?.textContent);
19342
+ return Boolean(name) && !button.closest('[role="dialog"], dialog') && !/\bsponsored\b/i.test(button.innerText);
19343
+ });
19344
+ const seen = /* @__PURE__ */ new Set();
19345
+ const out = [];
19346
+ for (const [cardIndex, card] of cards.entries()) {
19347
+ const name = normalize4(card.querySelector("h3")?.textContent);
19348
+ if (!name) continue;
19349
+ const text2 = normalize4(card.innerText) ?? name;
19350
+ const key = `${name.toLowerCase()}|${text2.toLowerCase()}`;
19351
+ if (seen.has(key)) continue;
19352
+ seen.add(key);
19353
+ const lines = text2.split(/\n+/).map((line) => normalize4(line)).filter((line) => Boolean(line));
19354
+ const aria = normalize4(card.getAttribute("aria-label"));
19355
+ const rating = (aria ?? text2).match(/(?:rated\s+)?(\d(?:\.\d)?)\s*(?:out of 5)?/i)?.[1] ?? null;
19356
+ const reviewCount = (aria ?? text2).match(/(?:\(|·\s*)([\d,]+)\s+(?:user\s+)?reviews?\b/i)?.[1]?.replace(/,/g, "") ?? null;
19357
+ const phone = lines.find((line) => phonePattern.test(line)) ?? null;
19358
+ const address = lines.find((line) => addressPattern.test(line)) ?? null;
19359
+ const hoursStatus = lines.find((line) => /\b(?:open|closed|opens|closes)\b/i.test(line)) ?? null;
19360
+ const category = lines.find(
19361
+ (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)
19362
+ ) ?? null;
19363
+ const mapsSearchUrl = `https://www.google.com/maps/search/${encodeURIComponent([name, locationHint].filter(Boolean).join(" "))}`;
19364
+ out.push({
19365
+ cardIndex,
19366
+ cardKey: key,
19367
+ name,
19368
+ placeUrl: mapsSearchUrl,
19369
+ cid: null,
19370
+ cidDecimal: null,
19371
+ rating,
19372
+ reviewCount,
19373
+ category,
19374
+ address,
19375
+ phone,
19376
+ hoursStatus,
19377
+ websiteUrl: null,
19378
+ directionsUrl: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent([name, address].filter(Boolean).join(", "))}`,
19379
+ metadata: lines.slice(0, 20)
19380
+ });
19381
+ }
19382
+ return out;
19383
+ }, fallbackLocation);
19384
+ }
19385
+ async openCardAndExtractDialog(page, card, includeServices) {
19386
+ const clicked = await page.evaluate((input) => {
19387
+ const normalize4 = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
19388
+ const cards = Array.from(document.querySelectorAll("button")).filter(
19389
+ (button) => Boolean(button.querySelector("h3")) && !button.closest('[role="dialog"], dialog') && !/\bsponsored\b/i.test(button.innerText)
19390
+ );
19391
+ const exact = cards.find((button) => `${normalize4(button.querySelector("h3")?.textContent)}|${normalize4(button.innerText)}` === input.cardKey);
19392
+ const target = exact ?? cards[input.cardIndex];
19393
+ if (!target) return false;
19394
+ target.click();
19395
+ return true;
19396
+ }, { cardIndex: card.cardIndex, cardKey: card.cardKey });
19397
+ if (!clicked) return null;
19398
+ const dialog = page.locator('dialog, [role="dialog"]').filter({ has: page.locator('h1, h2, [role="heading"]') }).last();
19399
+ await dialog.waitFor({ state: "visible", timeout: 6e3 }).catch(() => {
19400
+ });
19401
+ if (await dialog.count() === 0 || !await dialog.isVisible().catch(() => false)) return null;
19402
+ const details = await dialog.evaluate((root, includeServicesValue) => {
19403
+ const text2 = (value) => value?.replace(/\s+/g, " ").trim() ?? "";
19404
+ const phonePattern = /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/;
19405
+ const buttons = Array.from(root.querySelectorAll("button"));
19406
+ const buttonTexts = buttons.map((button) => text2(button.innerText)).filter(Boolean);
19407
+ const servicesText = includeServicesValue ? buttonTexts.find((value) => /^services\s*:/i.test(value)) : null;
19408
+ const services = servicesText ? [...new Set(servicesText.replace(/^services\s*:\s*/i, "").split(/\s*[,·]\s*/).map((value) => value.trim()).filter(Boolean))] : [];
19409
+ const areaText = includeServicesValue ? buttonTexts.find((value) => /^serves\b/i.test(value)) : null;
19410
+ const areasServed = areaText ? [areaText.replace(/^serves\s*/i, "").trim()].filter(Boolean) : [];
19411
+ const externalLinks = Array.from(root.querySelectorAll("a[href]"));
19412
+ const website = externalLinks.find((link) => /^website$/i.test(text2(link.innerText)) || /^website$/i.test(link.getAttribute("aria-label") ?? ""));
19413
+ const mapLink = externalLinks.find((link) => /google\.[^/]+\/maps|\/maps\//i.test(link.href));
19414
+ 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)));
19415
+ const allText = text2(root.innerText);
19416
+ 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;
19417
+ const reviewCount = allText.match(/([\d,]+)\s+(?:user\s+)?reviews?\b/i)?.[1]?.replace(/,/g, "") ?? null;
19418
+ const phoneButton = buttonTexts.find((value) => /^call\b/i.test(value) || phonePattern.test(value)) ?? "";
19419
+ const phone = phoneButton.match(phonePattern)?.[0] ?? null;
19420
+ const hoursStatus = buttonTexts.find((value) => /\b(?:open|closed|opens|closes)\b/i.test(value)) ?? null;
19421
+ const placeUrl = mapLink?.href ?? addressLink?.href ?? location.href;
19422
+ const fid = placeUrl.match(/!1s(0x[0-9a-f]+):(0x[0-9a-f]+)/i);
19423
+ const cid = fid ? `${fid[1]}:${fid[2]}` : null;
19424
+ let cidDecimal = null;
19425
+ if (fid) {
19426
+ try {
19427
+ cidDecimal = BigInt(fid[2]).toString();
19428
+ } catch {
19429
+ }
19430
+ }
19431
+ if (!cidDecimal) {
19432
+ const cidFromQuery = new URL(placeUrl).searchParams.get("cid");
19433
+ cidDecimal = cidFromQuery && /^\d+$/.test(cidFromQuery) ? cidFromQuery : null;
19434
+ }
19435
+ return {
19436
+ placeUrl,
19437
+ cid,
19438
+ cidDecimal,
19439
+ rating,
19440
+ reviewCount,
19441
+ category: null,
19442
+ address: text2(addressLink?.innerText) || null,
19443
+ phone,
19444
+ websiteUrl: website?.href ?? null,
19445
+ hoursStatus,
19446
+ services,
19447
+ areasServed
19448
+ };
19449
+ }, includeServices);
19450
+ await this.closeDialog(page);
19451
+ return details;
19452
+ }
19453
+ async closeDialog(page) {
19454
+ const closed = await page.evaluate(() => {
19455
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]'));
19456
+ const dialog = dialogs.find((candidate) => candidate.offsetParent !== null);
19457
+ const close = dialog && Array.from(dialog.querySelectorAll("button")).find(
19458
+ (button) => /^close$/i.test(button.getAttribute("aria-label") ?? "") || /^close$/i.test(button.innerText.trim())
19459
+ );
19460
+ close?.click();
19461
+ return Boolean(close);
19462
+ });
19463
+ if (closed) await page.waitForTimeout(250);
19464
+ }
19465
+ async advanceToMoreResults(page) {
19466
+ const before = await page.evaluate(() => document.body.innerText.slice(-2e3));
19467
+ const advanced = await page.evaluate(() => {
19468
+ const visible = (element) => element.offsetParent !== null;
19469
+ const mobile = Array.from(document.querySelectorAll("button, a")).find(
19470
+ (element) => visible(element) && /^(more search results|more results)$/i.test(element.innerText.trim())
19471
+ );
19472
+ if (mobile) {
19473
+ mobile.click();
19474
+ return true;
19475
+ }
19476
+ const next = Array.from(document.querySelectorAll("a[href]")).find(
19477
+ (link) => visible(link) && (/^next$/i.test(link.innerText.trim()) || /[?&]start=\d+/.test(link.href))
19478
+ );
19479
+ if (next) {
19480
+ next.click();
19481
+ return true;
19482
+ }
19483
+ return false;
19484
+ });
19485
+ if (!advanced) return false;
19486
+ await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19487
+ await page.waitForFunction((previous) => document.body.innerText.slice(-2e3) !== previous, before, { timeout: 5e3 }).catch(() => {
19488
+ });
19489
+ return true;
19490
+ }
19491
+ };
19492
+ }
19493
+ });
19494
+
19230
19495
  // src/extractor/MapsNavigator.ts
19231
19496
  var MapsNavigator;
19232
19497
  var init_MapsNavigator = __esm({
19233
19498
  "src/extractor/MapsNavigator.ts"() {
19234
19499
  "use strict";
19235
19500
  init_selectors();
19501
+ init_MapsSearchExtractor();
19236
19502
  MapsNavigator = class {
19237
19503
  constructor(page) {
19238
19504
  this.page = page;
@@ -19290,32 +19556,26 @@ var init_MapsNavigator = __esm({
19290
19556
  await this.page.waitForSelector('[role="main"] h2', { timeout: 5e3 }).catch(() => {
19291
19557
  });
19292
19558
  }
19293
- async navigateToSerpBusinessPanel(businessName, location2) {
19294
- const query = `${businessName} ${location2}`;
19295
- const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&udm=1`;
19559
+ async navigateToSerpBusinessPanel(businessName, location2, gl, hl) {
19560
+ const searchUrl = buildGoogleLocalResultsUrl({ query: businessName, location: location2, gl, hl });
19296
19561
  await this.page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 45e3 }).catch(() => {
19297
19562
  });
19298
- await this.page.waitForTimeout(2e3);
19563
+ await this.page.waitForTimeout(1e3);
19299
19564
  const clicked = await this.page.evaluate((name) => {
19300
- const trimmedName = name.trim();
19301
- const words = trimmedName.split(/\s+/);
19302
- const prefix = words.slice(0, Math.min(2, words.length)).join(" ");
19303
- const headings = Array.from(document.querySelectorAll('h3, div[role="heading"]'));
19304
- const heading = headings.find((el2) => el2.innerText?.trim() === trimmedName) ?? headings.find((el2) => el2.innerText?.trim().startsWith(prefix));
19305
- const clickable = heading?.closest('a, div[role="link"], div[jsaction]');
19306
- if (clickable) {
19307
- clickable.click();
19308
- return true;
19309
- }
19310
- if (heading) {
19311
- heading.click();
19565
+ const normalized = name.replace(/\s+/g, " ").trim().toLowerCase();
19566
+ const card = Array.from(document.querySelectorAll("button")).find(
19567
+ (button) => button.querySelector("h3")?.textContent?.replace(/\s+/g, " ").trim().toLowerCase() === normalized
19568
+ );
19569
+ if (card) {
19570
+ card.click();
19312
19571
  return true;
19313
19572
  }
19314
19573
  return false;
19315
19574
  }, businessName);
19316
19575
  if (!clicked) return false;
19317
- await this.page.waitForTimeout(2e3);
19318
- return true;
19576
+ await this.page.waitForSelector('dialog, [role="dialog"]', { timeout: 5e3 }).catch(() => {
19577
+ });
19578
+ return this.page.locator('dialog, [role="dialog"]').count().then((count) => count > 0);
19319
19579
  }
19320
19580
  buildReviewsUrl(placeUrl) {
19321
19581
  const fid = placeUrl.match(/!1s(0x[0-9a-f]+:0x[0-9a-f]+)/i);
@@ -19508,7 +19768,7 @@ var init_MapsExtractor = __esm({
19508
19768
  let servicesStatus = "not_requested";
19509
19769
  if (options.includeServices) {
19510
19770
  try {
19511
- const opened = await navigator.navigateToSerpBusinessPanel(options.businessName, options.location);
19771
+ const opened = await navigator.navigateToSerpBusinessPanel(options.businessName, options.location, options.gl, options.hl);
19512
19772
  if (opened) {
19513
19773
  const result = await this.extractServicesAndAreasServed(page);
19514
19774
  services = result.services;
@@ -19729,249 +19989,15 @@ var init_MapsExtractor = __esm({
19729
19989
  return result.data;
19730
19990
  }
19731
19991
  async extractServicesAndAreasServed(page) {
19732
- await page.waitForTimeout(1500);
19733
- let services = [];
19734
- if (await this.clickRowStartingWithRetry(page, "Services:")) {
19735
- await page.waitForSelector('[role="dialog"]', { timeout: 5e3 }).catch(() => {
19736
- });
19737
- await page.waitForTimeout(800);
19738
- services = await this.extractOpenDialogItems(page, "Services");
19739
- await this.closeOpenDialog(page, "Services");
19740
- await page.waitForTimeout(500);
19741
- }
19742
- let areasServed = [];
19743
- if (await this.clickRowStartingWithRetry(page, "Serves ")) {
19744
- await page.waitForSelector('[role="dialog"]', { timeout: 5e3 }).catch(() => {
19745
- });
19746
- await page.waitForTimeout(800);
19747
- areasServed = await this.extractOpenDialogItems(page, "Areas Served");
19748
- await this.closeOpenDialog(page, "Areas Served");
19749
- }
19750
- return { services, areasServed };
19751
- }
19752
- async clickRowStartingWithRetry(page, prefix) {
19753
- if (await this.clickRowStartingWith(page, prefix)) return true;
19754
- await page.waitForTimeout(2e3);
19755
- return this.clickRowStartingWith(page, prefix);
19756
- }
19757
- async clickRowStartingWith(page, prefix) {
19758
- return page.evaluate((p) => {
19759
- const el2 = Array.from(document.querySelectorAll('button, a, div[role="button"]')).find(
19760
- (e) => (e.innerText ?? "").trim().startsWith(p)
19761
- );
19762
- if (!el2) return false;
19763
- el2.click();
19764
- return true;
19765
- }, prefix);
19766
- }
19767
- async extractOpenDialogItems(page, expectedHeading) {
19768
- return page.evaluate((expected) => {
19769
- const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'));
19770
- const withHeading = dialogs.find((d) => {
19771
- const h = d.querySelector('h1, h2, [role="heading"]');
19772
- const headingText = h?.innerText?.trim() ?? "";
19773
- return headingText.startsWith(expected);
19774
- });
19775
- if (!withHeading) return [];
19776
- const headingEl = withHeading.querySelector('h1, h2, [role="heading"]');
19777
- const headingLines = new Set(
19778
- (headingEl?.innerText ?? "").split("\n").map((s) => s.trim()).filter(Boolean)
19779
- );
19780
- const seen = /* @__PURE__ */ new Set();
19781
- const items = [];
19782
- withHeading.querySelectorAll("*").forEach((el2) => {
19783
- if (el2.children.length > 0) return;
19784
- const text2 = el2.innerText?.trim() ?? el2.textContent?.trim() ?? "";
19785
- if (!text2 || headingLines.has(text2)) return;
19786
- if (/^(back|close|done|thanks)$/i.test(text2)) return;
19787
- if (/^serves\s.*following/i.test(text2)) return;
19788
- if (seen.has(text2)) return;
19789
- seen.add(text2);
19790
- items.push(text2);
19791
- });
19792
- return items;
19793
- }, expectedHeading);
19794
- }
19795
- async closeOpenDialog(page, expectedHeading) {
19796
- await page.evaluate((expected) => {
19797
- const dialog = Array.from(document.querySelectorAll('[role="dialog"]')).find((d) => {
19798
- const h = d.querySelector('h1, h2, [role="heading"]');
19799
- const headingText = h?.innerText?.trim() ?? "";
19800
- return headingText.startsWith(expected);
19801
- });
19802
- const closeBtn = dialog?.querySelector('[aria-label*="Close" i], button[aria-label*="close" i]');
19803
- closeBtn?.click();
19804
- }, expectedHeading);
19805
- }
19806
- };
19807
- }
19808
- });
19809
-
19810
- // src/extractor/MapsSearchExtractor.ts
19811
- var MAPS_SEARCH_SCROLL_BUDGET_MS, MAPS_SEARCH_SCROLL_STEP_MS, MAPS_SEARCH_MAX_NO_GROWTH_ROUNDS, MapsSearchExtractor;
19812
- var init_MapsSearchExtractor = __esm({
19813
- "src/extractor/MapsSearchExtractor.ts"() {
19814
- "use strict";
19815
- init_errors();
19816
- MAPS_SEARCH_SCROLL_BUDGET_MS = 6e4;
19817
- MAPS_SEARCH_SCROLL_STEP_MS = 1200;
19818
- MAPS_SEARCH_MAX_NO_GROWTH_ROUNDS = 4;
19819
- MapsSearchExtractor = class {
19820
- constructor(driver) {
19821
- this.driver = driver;
19822
- }
19823
- driver;
19824
- async extract(options) {
19825
- const startMs = Date.now();
19826
- const searchQuery = [options.query, options.location].filter(Boolean).join(" ");
19827
- const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(searchQuery)}?hl=${encodeURIComponent(options.hl)}`;
19828
- const config = {
19829
- headless: options.headless,
19830
- kernelApiKey: options.kernelApiKey,
19831
- kernelProxyId: options.kernelProxyId,
19832
- kernelProxyResolution: options.kernelProxyResolution,
19833
- proxyMode: options.proxyMode,
19834
- viewport: { width: 1280, height: 900 },
19835
- locale: `${options.hl}-${options.gl.toUpperCase()}`,
19836
- debug: options.debug
19837
- };
19838
- try {
19839
- await this.driver.launch(config);
19840
- const page = this.driver.getPage();
19841
- await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
19842
- await page.waitForTimeout(3e3);
19843
- const blocked = await this.detectBlock(page);
19844
- if (blocked) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19845
- const results = await this.collectResults(page, options.maxResults);
19846
- return {
19847
- query: options.query,
19848
- location: options.location ?? null,
19849
- searchQuery,
19850
- searchUrl,
19851
- extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
19852
- requestedMaxResults: options.maxResults,
19853
- resultCount: results.length,
19854
- results,
19855
- durationMs: Date.now() - startMs
19856
- };
19857
- } finally {
19858
- await this.driver.close();
19859
- }
19860
- }
19861
- async detectBlock(page) {
19862
19992
  return page.evaluate(() => {
19863
- const text2 = document.body.innerText.slice(0, 2e3);
19864
- return /unusual traffic|captcha|recaptcha|about this page/i.test(text2) || /\/sorry\//.test(location.href);
19865
- });
19866
- }
19867
- async collectResults(page, maxResults) {
19868
- const seen = /* @__PURE__ */ new Map();
19869
- const started = Date.now();
19870
- let noGrowthRounds = 0;
19871
- while (Date.now() - started < MAPS_SEARCH_SCROLL_BUDGET_MS) {
19872
- const before = seen.size;
19873
- const batch = await this.extractVisibleResults(page);
19874
- for (const result of batch) {
19875
- const key = this.resultKey(result);
19876
- if (!seen.has(key)) seen.set(key, { ...result, position: seen.size + 1 });
19877
- if (seen.size >= maxResults) break;
19878
- }
19879
- if (seen.size >= maxResults) break;
19880
- if (seen.size === before) noGrowthRounds += 1;
19881
- else noGrowthRounds = 0;
19882
- if (noGrowthRounds >= MAPS_SEARCH_MAX_NO_GROWTH_ROUNDS) break;
19883
- await page.evaluate(() => {
19884
- const feed = document.querySelector('[role="feed"]');
19885
- if (feed) {
19886
- feed.scrollTop = feed.scrollHeight;
19887
- } else {
19888
- window.scrollTo(0, document.body.scrollHeight);
19889
- }
19890
- });
19891
- await page.waitForTimeout(MAPS_SEARCH_SCROLL_STEP_MS);
19892
- }
19893
- return [...seen.values()].slice(0, maxResults);
19894
- }
19895
- resultKey(result) {
19896
- return result.cidDecimal ?? result.placeUrl.replace(/[?&].*$/, "") ?? result.name;
19897
- }
19898
- async extractVisibleResults(page) {
19899
- return page.evaluate(() => {
19900
- function normalizeText(value) {
19901
- const text2 = value?.replace(/\s+/g, " ").trim() ?? "";
19902
- return text2 || null;
19903
- }
19904
- function cidFromUrl(url) {
19905
- const fid = url.match(/!1s(0x[0-9a-f]+):(0x[0-9a-f]+)/i);
19906
- if (!fid) return { cid: null, cidDecimal: null };
19907
- let cidDecimal = null;
19908
- try {
19909
- cidDecimal = BigInt(fid[2]).toString();
19910
- } catch {
19911
- }
19912
- return { cid: `${fid[1]}:${fid[2]}`, cidDecimal };
19913
- }
19914
- function textParts(card) {
19915
- if (!card) return [];
19916
- const parts = [];
19917
- card.querySelectorAll("div, span").forEach((el2) => {
19918
- 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(" ");
19919
- if (text2 && !parts.includes(text2)) parts.push(text2);
19920
- });
19921
- return parts;
19922
- }
19923
- function firstMatching(parts, pattern) {
19924
- const value = parts.find((part) => pattern.test(part));
19925
- return value ?? null;
19926
- }
19927
- function normalizedSet(values) {
19928
- return new Set(values.filter(Boolean).map((value) => value.toLowerCase()));
19929
- }
19930
- const out = [];
19931
- const seen = /* @__PURE__ */ new Set();
19932
- const anchors = Array.from(document.querySelectorAll('a[href*="/maps/place/"]'));
19933
- for (const anchor of anchors) {
19934
- const placeUrl = anchor.href;
19935
- const stableUrl = placeUrl.replace(/[?&].*$/, "");
19936
- if (seen.has(stableUrl)) continue;
19937
- seen.add(stableUrl);
19938
- const card = anchor.closest('.Nv2PK, [role="article"], .bfdHYd') ?? anchor.parentElement;
19939
- const parts = textParts(card);
19940
- const aria = normalizeText(anchor.getAttribute("aria-label"));
19941
- const heading = normalizeText(card?.querySelector('.qBF1Pd, .fontHeadlineSmall, [role="heading"]')?.textContent);
19942
- const name = aria ?? heading ?? parts[0] ?? stableUrl;
19943
- const links = Array.from(card?.querySelectorAll("a[href]") ?? []);
19944
- const websiteUrl = links.find((link) => link.href.startsWith("http") && !link.href.includes("google."))?.href ?? null;
19945
- const rating = firstMatching(parts, /^\d(?:\.\d)?$/);
19946
- const reviewCountRaw = firstMatching(parts, /^\(?[\d,]+\)?(?:\s+reviews?)?$/i);
19947
- const phone = firstMatching(parts, /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}/);
19948
- const hoursStatus = parts.find((part) => /^(open|closed|closes|opens)\b|^·\s*(opens|closes)\b/i.test(part)) ?? null;
19949
- 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;
19950
- 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)}`;
19951
- const excluded = normalizedSet([name, rating, reviewCountRaw, phone, hoursStatus, address, "Website", "Directions"]);
19952
- const category = parts.find((part) => {
19953
- const normalized = part.toLowerCase();
19954
- 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);
19955
- }) ?? null;
19956
- const { cid, cidDecimal } = cidFromUrl(placeUrl);
19957
- out.push({
19958
- position: out.length + 1,
19959
- name,
19960
- placeUrl,
19961
- cid,
19962
- cidDecimal,
19963
- rating,
19964
- reviewCount: reviewCountRaw ? reviewCountRaw.replace(/[()]/g, "") : null,
19965
- category,
19966
- address,
19967
- phone,
19968
- hoursStatus,
19969
- websiteUrl,
19970
- directionsUrl,
19971
- metadata: parts.slice(0, 20)
19972
- });
19973
- }
19974
- return out;
19993
+ const visible = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).find((dialog) => dialog.offsetParent !== null);
19994
+ if (!visible) return { services: [], areasServed: [] };
19995
+ const values = Array.from(visible.querySelectorAll("button")).map((button) => button.innerText.replace(/\s+/g, " ").trim()).filter(Boolean);
19996
+ const servicesText = values.find((value) => /^services\s*:/i.test(value));
19997
+ const services = servicesText ? [...new Set(servicesText.replace(/^services\s*:\s*/i, "").split(/\s*[,·]\s*/).map((value) => value.trim()).filter(Boolean))] : [];
19998
+ const served = values.find((value) => /^serves\b/i.test(value));
19999
+ const areasServed = served ? [served.replace(/^serves\s*/i, "").trim()].filter(Boolean) : [];
20000
+ return { services, areasServed };
19975
20001
  });
19976
20002
  }
19977
20003
  };
@@ -22721,7 +22747,10 @@ function formatMapsSearch(raw, input) {
22721
22747
  const normalizedResults = results.map((result) => ({
22722
22748
  ...result,
22723
22749
  phone: result.phone ?? null,
22724
- hoursStatus: result.hoursStatus ?? null
22750
+ hoursStatus: result.hoursStatus ?? null,
22751
+ services: result.services ?? [],
22752
+ areasServed: result.areasServed ?? [],
22753
+ profileDetailsStatus: result.profileDetailsStatus ?? "unavailable"
22725
22754
  }));
22726
22755
  const searchQuery = d.searchQuery ?? [input.query, input.location].filter(Boolean).join(" ");
22727
22756
  const requestedMax = d.requestedMaxResults ?? input.maxResults ?? 10;
@@ -22749,9 +22778,14 @@ ${meta}`;
22749
22778
  |---|------|----------|--------|---------|-----|---------|------|
22750
22779
  ${rows}`,
22751
22780
  metadataSection,
22781
+ results.some((result) => result.services?.length || result.areasServed?.length) ? `
22782
+ ## Services and Areas Served
22783
+ ${results.filter((result) => result.services?.length || result.areasServed?.length).map((result) => `### ${result.position}. ${result.name}
22784
+ ${result.services?.length ? `- **Services:** ${result.services.join(", ")}` : ""}${result.areasServed?.length ? `
22785
+ - **Serves:** ${result.areasServed.join("; ")}` : ""}`).join("\n\n")}` : null,
22752
22786
  `
22753
22787
  ---
22754
- \u{1F4A1} **Next step:** use \`maps_place_intel\` with a selected business name and location to hydrate full hours, phone, review topics, and optional review cards.`,
22788
+ \u{1F4A1} **Next step:** use \`maps_place_intel\` with a selected business name and location for full hours, review topics, and optional review cards. Set \`includeServices: true\` on this search to enrich the ranked list in one local-results session.`,
22755
22789
  durationMs != null ? `
22756
22790
  *Extracted in ${(durationMs / 1e3).toFixed(1)}s*` : null
22757
22791
  ].filter(Boolean).join("\n");
@@ -23802,6 +23836,7 @@ async function searchCity(options, market) {
23802
23836
  gl: options.gl,
23803
23837
  hl: options.hl,
23804
23838
  maxResults: options.maxResultsPerCity,
23839
+ includeServices: false,
23805
23840
  headless: options.headless,
23806
23841
  kernelApiKey: options.kernelApiKey,
23807
23842
  proxyMode: options.proxyMode,
@@ -29688,7 +29723,7 @@ var PACKAGE_VERSION;
29688
29723
  var init_version = __esm({
29689
29724
  "src/version.ts"() {
29690
29725
  "use strict";
29691
- PACKAGE_VERSION = "0.26.2";
29726
+ PACKAGE_VERSION = "0.27.0";
29692
29727
  }
29693
29728
  });
29694
29729
 
@@ -30279,8 +30314,9 @@ var init_mcp_tool_schemas = __esm({
30279
30314
  gl: import_zod35.z.string().length(2).default("us").describe("Google country code inferred from location."),
30280
30315
  hl: import_zod35.z.string().length(2).default("en").describe("Language inferred from user request."),
30281
30316
  maxResults: import_zod35.z.number().int().min(1).max(50).default(10).describe("Number of candidates to return. Default 10, maximum 50."),
30282
- proxyMode: import_zod35.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE).describe("Defaults to location (city/state residential proxy targeting). configured forces the service proxy without city/ZIP targeting; none is local debugging only."),
30283
- proxyZip: import_zod35.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override for residential proxy targeting."),
30317
+ includeServices: import_zod35.z.boolean().default(false).describe("Open each returned Google Business Profile and include configured services and areas served when available. Does not collect review cards."),
30318
+ proxyMode: import_zod35.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE).describe("Leave unset for the default direct browser route. Google localization comes from the city in the query plus UULE, gl, and hl. location is an explicit residential-proxy override."),
30319
+ proxyZip: import_zod35.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override when proxyMode is location."),
30284
30320
  debug: import_zod35.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics.")
30285
30321
  };
30286
30322
  DirectoryWorkflowInputSchema = {
@@ -30294,7 +30330,7 @@ var init_mcp_tool_schemas = __esm({
30294
30330
  includeZipGroups: import_zod35.z.boolean().default(true).describe("Attach ZIP groups from a configured US ZIPS CSV when available (MCP_SCRAPER_USZIPS_CSV_PATH or usZipsCsvPath)."),
30295
30331
  usZipsCsvPath: import_zod35.z.string().optional().describe("Local/test-only path to a US ZIPS CSV (state_abbr, zipcode, county, city columns). Deployed APIs should use MCP_SCRAPER_USZIPS_CSV_PATH instead. For ZIP enrichment, set MCP_SCRAPER_USZIPS_CSV_PATH on the server, or pass this in local/test mode."),
30296
30332
  saveCsv: import_zod35.z.boolean().default(true).describe("Save a directory-ready CSV of results to the MCP Scraper output directory and return its path."),
30297
- proxyMode: import_zod35.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE).describe("Proxy targeting per city search. Defaults to location (city/ZIP-group residential targeting); configured forces the service proxy; none is local debugging only."),
30333
+ proxyMode: import_zod35.z.enum(["location", "configured", "none"]).default(DEFAULT_MAPS_PROXY_MODE).describe("Proxy behavior per city search. Leave unset for the direct localized Google route; location is an explicit residential-proxy override."),
30298
30334
  proxyZip: import_zod35.z.string().regex(/^\d{5}$/).optional().describe("Optional ZIP override for proxy targeting; normally omitted."),
30299
30335
  debug: import_zod35.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics.")
30300
30336
  };
@@ -30365,7 +30401,10 @@ var init_mcp_tool_schemas = __esm({
30365
30401
  hoursStatus: NullableString,
30366
30402
  websiteUrl: NullableString,
30367
30403
  directionsUrl: NullableString,
30368
- metadata: import_zod35.z.array(import_zod35.z.string())
30404
+ metadata: import_zod35.z.array(import_zod35.z.string()),
30405
+ services: import_zod35.z.array(import_zod35.z.string()).default([]),
30406
+ areasServed: import_zod35.z.array(import_zod35.z.string()).default([]),
30407
+ profileDetailsStatus: import_zod35.z.enum(["collected", "none_exist", "unavailable", "not_requested"]).default("unavailable")
30369
30408
  })),
30370
30409
  attempts: import_zod35.z.array(MapsSearchAttemptOutput),
30371
30410
  durationMs: import_zod35.z.number().int().min(0)
@@ -32111,7 +32150,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
32111
32150
  }, async (input) => formatMapsPlaceIntel(await executor.mapsPlaceIntel(input), input));
32112
32151
  server.registerTool("maps_search", {
32113
32152
  title: "Google Maps Business Search",
32114
- description: "Search Google Maps for multiple businesses by category, niche, or local market \u2014 leads, prospects, competitors, or beyond the 3-pack. Leave proxyMode unset. Returns up to 50 candidates (default 10) with names, place URLs, CIDs, ratings.",
32153
+ description: "Search Google local results for multiple businesses by category, niche, or local market \u2014 leads, prospects, competitors, or beyond the 3-pack. It paginates the rendered local-results list, opens each exact business card, then closes the profile dialog before continuing. Set includeServices for services and areas served; review cards are never collected by this tool.",
32115
32154
  inputSchema: MapsSearchInputSchema,
32116
32155
  outputSchema: recordOutputSchema("maps_search", MapsSearchOutputSchema),
32117
32156
  annotations: liveWebToolAnnotations("Google Maps Business Search")