mcp-scraper 0.2.6 → 0.2.8

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 (44) hide show
  1. package/README.md +13 -6
  2. package/dist/bin/api-server.cjs +582 -171
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +2 -2
  5. package/dist/bin/browser-agent-stdio-server.cjs +442 -9
  6. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  7. package/dist/bin/browser-agent-stdio-server.js +2 -2
  8. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +519 -31
  9. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
  11. package/dist/bin/mcp-scraper-install.cjs +2 -1
  12. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  13. package/dist/bin/mcp-scraper-install.js +2 -1
  14. package/dist/bin/mcp-scraper-install.js.map +1 -1
  15. package/dist/bin/mcp-stdio-server.cjs +68 -13
  16. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  17. package/dist/bin/mcp-stdio-server.js +2 -2
  18. package/dist/bin/paa-harvest.cjs +42 -6
  19. package/dist/bin/paa-harvest.cjs.map +1 -1
  20. package/dist/bin/paa-harvest.js +1 -1
  21. package/dist/chunk-5HMOPP76.js +793 -0
  22. package/dist/chunk-5HMOPP76.js.map +1 -0
  23. package/dist/{chunk-7WB2W6FO.js → chunk-6NEXSNSA.js} +69 -14
  24. package/dist/chunk-6NEXSNSA.js.map +1 -0
  25. package/dist/{chunk-MY3S7EX7.js → chunk-CQTAKXBN.js} +43 -7
  26. package/dist/chunk-CQTAKXBN.js.map +1 -0
  27. package/dist/chunk-I26QN7WQ.js +7 -0
  28. package/dist/chunk-I26QN7WQ.js.map +1 -0
  29. package/dist/index.cjs +42 -6
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/{server-MKCU3M7Y.js → server-BTTDFPSQ.js} +456 -142
  33. package/dist/server-BTTDFPSQ.js.map +1 -0
  34. package/dist/{worker-NAKGTIF5.js → worker-5O44YBF4.js} +2 -2
  35. package/docs/mcp-tool-quality-spec.md +3 -1
  36. package/package.json +1 -1
  37. package/dist/chunk-7WB2W6FO.js.map +0 -1
  38. package/dist/chunk-MY3S7EX7.js.map +0 -1
  39. package/dist/chunk-T6SIPPOQ.js +0 -7
  40. package/dist/chunk-T6SIPPOQ.js.map +0 -1
  41. package/dist/chunk-XL4V7VKD.js +0 -360
  42. package/dist/chunk-XL4V7VKD.js.map +0 -1
  43. package/dist/server-MKCU3M7Y.js.map +0 -1
  44. /package/dist/{worker-NAKGTIF5.js.map → worker-5O44YBF4.js.map} +0 -0
@@ -8682,7 +8682,7 @@ function buildYouTubeChannelVideosUrl(channelInput) {
8682
8682
  }
8683
8683
  return `https://www.youtube.com/${handle}/videos`;
8684
8684
  }
8685
- var import_playwright_extra, import_puppeteer_extra_plugin_stealth, import_playwright3, import_sdk4, DESKTOP_USER_AGENT, MOBILE_USER_AGENT, DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS, KERNEL_BROWSER_CLOSE_TIMEOUT_MS, KERNEL_SESSION_DELETE_TIMEOUT_MS, BrowserDriver;
8685
+ var import_playwright_extra, import_puppeteer_extra_plugin_stealth, import_playwright3, import_sdk4, DESKTOP_USER_AGENT, MOBILE_USER_AGENT, DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS, KERNEL_BROWSER_CLOSE_TIMEOUT_MS, KERNEL_SESSION_DELETE_TIMEOUT_MS, DEFAULT_SERP_CHALLENGE_SOLVE_WAIT_MS, SERP_CHALLENGE_SOLVE_POLL_MS, BrowserDriver;
8686
8686
  var init_BrowserDriver = __esm({
8687
8687
  "src/driver/BrowserDriver.ts"() {
8688
8688
  "use strict";
@@ -8698,6 +8698,8 @@ var init_BrowserDriver = __esm({
8698
8698
  DEFAULT_KERNEL_BROWSER_TIMEOUT_SECONDS = 180;
8699
8699
  KERNEL_BROWSER_CLOSE_TIMEOUT_MS = 3e3;
8700
8700
  KERNEL_SESSION_DELETE_TIMEOUT_MS = 5e3;
8701
+ DEFAULT_SERP_CHALLENGE_SOLVE_WAIT_MS = 2500;
8702
+ SERP_CHALLENGE_SOLVE_POLL_MS = 500;
8701
8703
  BrowserDriver = class {
8702
8704
  browser = null;
8703
8705
  context = null;
@@ -8947,20 +8949,20 @@ var init_BrowserDriver = __esm({
8947
8949
  const diag = await this.captureDiagnostics(url);
8948
8950
  throw new ExtractionError(`page.goto failed: ${err.message} | ${diag}`);
8949
8951
  }
8950
- const captchaCount = await this.page.locator(PAASelectors.captchaMarker).count();
8951
- if (captchaCount > 0) {
8952
+ if (await this.isSerpChallengeVisible()) {
8952
8953
  await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: true });
8953
- throw new CaptchaError(this.captchaMessage());
8954
+ const solved = await this.waitForSerpChallengeToClear(navDebug, url);
8955
+ if (!solved) throw new CaptchaError(this.captchaMessage());
8954
8956
  }
8955
8957
  const fastFound = await this.page.waitForSelector(PAASelectors.item, { timeout: 4e3 }).catch(() => null);
8956
8958
  if (fastFound) {
8957
8959
  await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: true, captchaDetected: false });
8958
8960
  return { hasPaa: true };
8959
8961
  }
8960
- const captchaAfter = await this.page.locator(PAASelectors.captchaMarker).count();
8961
- if (captchaAfter > 0) {
8962
+ if (await this.isSerpChallengeVisible()) {
8962
8963
  await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: true });
8963
- throw new CaptchaError(this.captchaMessage());
8964
+ const solved = await this.waitForSerpChallengeToClear(navDebug, url);
8965
+ if (!solved) throw new CaptchaError(this.captchaMessage());
8964
8966
  }
8965
8967
  for (let i = 1; i <= 6; i++) {
8966
8968
  await this.page.evaluate((f) => {
@@ -8976,6 +8978,35 @@ var init_BrowserDriver = __esm({
8976
8978
  await this.updateSerpNavigationDebug(navDebug, url, { hasPaa: false, captchaDetected: false });
8977
8979
  return { hasPaa: false };
8978
8980
  }
8981
+ async isSerpChallengeVisible() {
8982
+ if (!this.page) return false;
8983
+ const captchaCount = await this.page.locator(PAASelectors.captchaMarker).count().catch(() => 0);
8984
+ if (captchaCount > 0) return true;
8985
+ const finalUrl = this.page.url();
8986
+ if (/google\.[^/]+\/sorry\//i.test(finalUrl)) return true;
8987
+ const bodySnippet = await this.page.evaluate(() => {
8988
+ const text = (document.body?.innerText ?? "").replace(/\s+/g, " ").trim();
8989
+ return text.slice(0, 1e3);
8990
+ }).catch(() => "");
8991
+ return /recaptcha|unusual traffic|are you a robot|about this page|detected unusual traffic/i.test(bodySnippet);
8992
+ }
8993
+ async waitForSerpChallengeToClear(navDebug, requestedUrl) {
8994
+ if (!this.kernelClient || !this.page) return false;
8995
+ const waitMs = positiveIntFromEnv("SERP_CHALLENGE_SOLVE_WAIT_MS", DEFAULT_SERP_CHALLENGE_SOLVE_WAIT_MS);
8996
+ const deadline = Date.now() + waitMs;
8997
+ while (Date.now() < deadline) {
8998
+ await this.page.waitForTimeout(Math.min(SERP_CHALLENGE_SOLVE_POLL_MS, Math.max(250, deadline - Date.now())));
8999
+ if (!await this.isSerpChallengeVisible()) {
9000
+ await this.page.waitForTimeout(1e3);
9001
+ if (!await this.isSerpChallengeVisible()) {
9002
+ await this.updateSerpNavigationDebug(navDebug, requestedUrl, { hasPaa: null, captchaDetected: false });
9003
+ return true;
9004
+ }
9005
+ }
9006
+ }
9007
+ await this.updateSerpNavigationDebug(navDebug, requestedUrl, { hasPaa: false, captchaDetected: true });
9008
+ return false;
9009
+ }
8979
9010
  async updateSerpNavigationDebug(navDebug, requestedUrl, state) {
8980
9011
  if (!navDebug || !this.page) return;
8981
9012
  try {
@@ -9023,7 +9054,12 @@ var init_BrowserDriver = __esm({
9023
9054
  async navigateTo(url) {
9024
9055
  try {
9025
9056
  await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
9057
+ if (/google\.[^/]+\/search\?/i.test(url) && await this.isSerpChallengeVisible()) {
9058
+ const solved = await this.waitForSerpChallengeToClear(null, url);
9059
+ if (!solved) throw new CaptchaError(this.captchaMessage());
9060
+ }
9026
9061
  } catch (err) {
9062
+ if (err instanceof CaptchaError) throw err;
9027
9063
  const diag = await this.captureDiagnostics(url);
9028
9064
  throw new ExtractionError(`page.goto failed: ${err.message} | ${diag}`);
9029
9065
  }
@@ -12584,24 +12620,215 @@ var init_MapsSearchExtractor = __esm({
12584
12620
  }
12585
12621
  });
12586
12622
 
12587
- // src/api/maps-routes.ts
12588
- function mapsErrorResponse(c, msg, errorCode) {
12589
- const blocked = msg.includes("CAPTCHA") || msg.includes("blocked");
12590
- return c.json({
12591
- error: sanitizeVendorName(msg),
12592
- error_code: blocked ? "captcha_or_blocked" : errorCode,
12593
- retryable: blocked
12594
- }, blocked ? 503 : 500);
12623
+ // src/maps-search-rotation.ts
12624
+ function mapsSearchMaxAttemptsForProxyMode(proxyMode) {
12625
+ return proxyMode === "location" ? MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS : MAPS_SEARCH_MAX_ATTEMPTS;
12626
+ }
12627
+ function mapsSearchErrorMessage(err) {
12628
+ return sanitizeVendorName(err instanceof Error ? err.message : String(err));
12629
+ }
12630
+ function looksLikeProxyTunnelFailure(message) {
12631
+ 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);
12632
+ }
12633
+ function looksLikeProxyUnavailable(message) {
12634
+ return /proxy unavailable|proxy_unavailable|connection_test_failed|did not return a proxy id|configured fallback/i.test(message);
12635
+ }
12636
+ function looksLikeBrowserClosed(message) {
12637
+ return /Target page, context or browser has been closed|browser has been closed|context has been closed|page has been closed|browser not found|CDP session closed|Connection closed|WebSocket.*closed|Protocol error.*closed|Browser closed|disconnected/i.test(message);
12638
+ }
12639
+ function mapsSearchFailureOutcome(err) {
12640
+ if (err instanceof CaptchaError) return "captcha";
12641
+ const message = mapsSearchErrorMessage(err);
12642
+ if (/timeout|timed out|Timeout \d+ms exceeded|deadline/i.test(message)) return "timeout";
12643
+ if (looksLikeProxyTunnelFailure(message)) return "proxy_tunnel_failed";
12644
+ if (looksLikeProxyUnavailable(message)) return "proxy_unavailable";
12645
+ if (looksLikeBrowserClosed(message)) return "browser_closed";
12646
+ return "unknown_error";
12647
+ }
12648
+ function retryableMapsSearchError(err, proxyMode) {
12649
+ if (err instanceof CaptchaError) return true;
12650
+ const message = mapsSearchErrorMessage(err);
12651
+ if (/timeout|timed out|Timeout \d+ms exceeded|deadline/i.test(message)) return true;
12652
+ if (looksLikeBrowserClosed(message)) return true;
12653
+ return proxyMode === "location" && (looksLikeProxyTunnelFailure(message) || looksLikeProxyUnavailable(message));
12595
12654
  }
12596
- async function cleanupDisposableProxy(kernelApiKey, proxyId) {
12655
+ function suffix(value) {
12656
+ return value ? value.slice(-8) : null;
12657
+ }
12658
+ function attemptFromDebug(input) {
12659
+ const network = input.debug.networkLocation;
12660
+ const browserSessionId = input.debug.kernel?.sessionId ?? null;
12661
+ const target = input.resolution?.target ?? null;
12662
+ return {
12663
+ attemptNumber: input.attemptIndex + 1,
12664
+ maxAttempts: input.maxAttempts,
12665
+ status: input.status,
12666
+ outcome: input.outcome,
12667
+ willRetry: input.willRetry,
12668
+ durationMs: input.durationMs,
12669
+ resultCount: input.resultCount,
12670
+ error: input.error,
12671
+ proxyMode: input.proxyMode,
12672
+ proxyResolutionSource: input.resolution?.source ?? null,
12673
+ proxyIdSuffix: input.resolution?.proxyIdSuffix ?? null,
12674
+ proxyTargetLevel: target?.level ?? null,
12675
+ proxyTargetLocation: target?.canonicalLocation ?? null,
12676
+ proxyTargetZip: target?.zip ?? null,
12677
+ browserSessionIdSuffix: suffix(browserSessionId),
12678
+ observedIp: network?.ip ?? null,
12679
+ observedCity: network?.city ?? null,
12680
+ observedRegion: network?.region ?? null
12681
+ };
12682
+ }
12683
+ async function cleanupDisposableProxy(kernelApiKey, proxyId, eventName) {
12597
12684
  if (!kernelApiKey || !proxyId) return;
12598
- await deleteKernelProxyId(kernelApiKey, proxyId).catch((err) => {
12685
+ try {
12686
+ await deleteKernelProxyId(kernelApiKey, proxyId);
12687
+ } catch (err) {
12599
12688
  console.warn(JSON.stringify({
12600
- event: "maps_search_proxy_delete_failed",
12601
- proxy_id_suffix: proxyId.slice(-6),
12602
- message: err instanceof Error ? err.message : String(err)
12689
+ event: eventName,
12690
+ proxy_id_suffix: suffix(proxyId),
12691
+ message: mapsSearchErrorMessage(err)
12603
12692
  }));
12604
- });
12693
+ }
12694
+ }
12695
+ async function runMapsSearchWithRotation(options) {
12696
+ const attempts = [];
12697
+ const maxAttempts = mapsSearchMaxAttemptsForProxyMode(options.proxyMode);
12698
+ const started = Date.now();
12699
+ let lastError = null;
12700
+ for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) {
12701
+ const attemptStarted = Date.now();
12702
+ const driver = new BrowserDriver();
12703
+ const extractor = new MapsSearchExtractor(driver);
12704
+ const proxyZip = options.proxyZipForAttempt?.(attemptIndex) ?? options.proxyZip;
12705
+ let resolution2 = null;
12706
+ let disposableProxyId;
12707
+ try {
12708
+ resolution2 = await resolveKernelProxyId({
12709
+ kernelApiKey: options.kernelApiKey,
12710
+ proxyMode: options.proxyMode,
12711
+ configuredKernelProxyId: options.kernelProxyId ?? options.configuredKernelProxyId,
12712
+ location: options.location,
12713
+ proxyZip,
12714
+ gl: options.gl,
12715
+ attemptIndex,
12716
+ fresh: options.proxyMode === "location"
12717
+ });
12718
+ disposableProxyId = resolution2.disposableProxyId;
12719
+ const result = await extractor.extract({
12720
+ ...options,
12721
+ proxyZip,
12722
+ kernelProxyId: options.proxyMode === "none" ? void 0 : resolution2.kernelProxyId,
12723
+ kernelProxyResolution: resolution2.resolution
12724
+ });
12725
+ const attempt = attemptFromDebug({
12726
+ attemptIndex,
12727
+ maxAttempts,
12728
+ status: "ok",
12729
+ outcome: "success",
12730
+ willRetry: false,
12731
+ durationMs: Date.now() - attemptStarted,
12732
+ resultCount: result.resultCount,
12733
+ error: null,
12734
+ proxyMode: options.proxyMode,
12735
+ resolution: resolution2.resolution,
12736
+ debug: driver.getDebugSnapshot()
12737
+ });
12738
+ attempts.push(attempt);
12739
+ return {
12740
+ ...result,
12741
+ attempts: attempts.slice(),
12742
+ durationMs: Date.now() - started
12743
+ };
12744
+ } catch (err) {
12745
+ lastError = err;
12746
+ const retryable = retryableMapsSearchError(err, options.proxyMode);
12747
+ const willRetry = attemptIndex < maxAttempts - 1 && retryable;
12748
+ const attempt = attemptFromDebug({
12749
+ attemptIndex,
12750
+ maxAttempts,
12751
+ status: "failed",
12752
+ outcome: mapsSearchFailureOutcome(err),
12753
+ willRetry,
12754
+ durationMs: Date.now() - attemptStarted,
12755
+ resultCount: 0,
12756
+ error: mapsSearchErrorMessage(err),
12757
+ proxyMode: options.proxyMode,
12758
+ resolution: resolution2?.resolution ?? null,
12759
+ debug: driver.getDebugSnapshot()
12760
+ });
12761
+ attempts.push(attempt);
12762
+ console.warn(JSON.stringify({
12763
+ event: options.failureEventName ?? "maps_search_attempt_failed",
12764
+ ...options.logContext,
12765
+ attempt_number: attempt.attemptNumber,
12766
+ max_attempts: attempt.maxAttempts,
12767
+ outcome: attempt.outcome,
12768
+ will_retry: attempt.willRetry,
12769
+ proxy_resolution_source: attempt.proxyResolutionSource,
12770
+ proxy_id_suffix: attempt.proxyIdSuffix,
12771
+ browser_session_id_suffix: attempt.browserSessionIdSuffix,
12772
+ observed_city: attempt.observedCity,
12773
+ observed_region: attempt.observedRegion,
12774
+ message: attempt.error
12775
+ }));
12776
+ if (!willRetry) {
12777
+ throw new MapsSearchRotationError(
12778
+ mapsSearchErrorMessage(err),
12779
+ attempts,
12780
+ retryable,
12781
+ err instanceof CaptchaError ? "captcha_or_blocked" : "maps_search_failed"
12782
+ );
12783
+ }
12784
+ } finally {
12785
+ await cleanupDisposableProxy(options.kernelApiKey, disposableProxyId, "maps_search_proxy_delete_failed");
12786
+ await driver.close();
12787
+ }
12788
+ }
12789
+ throw new MapsSearchRotationError(
12790
+ lastError ? mapsSearchErrorMessage(lastError) : "Maps search failed",
12791
+ attempts,
12792
+ true
12793
+ );
12794
+ }
12795
+ var MAPS_SEARCH_MAX_ATTEMPTS, MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS, MapsSearchRotationError;
12796
+ var init_maps_search_rotation = __esm({
12797
+ "src/maps-search-rotation.ts"() {
12798
+ "use strict";
12799
+ init_BrowserDriver();
12800
+ init_MapsSearchExtractor();
12801
+ init_errors();
12802
+ init_kernel_proxy_resolver();
12803
+ MAPS_SEARCH_MAX_ATTEMPTS = 3;
12804
+ MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS = 5;
12805
+ MapsSearchRotationError = class extends Error {
12806
+ constructor(message, attempts, retryable, errorCode = "maps_search_failed") {
12807
+ super(message);
12808
+ this.attempts = attempts;
12809
+ this.retryable = retryable;
12810
+ this.errorCode = errorCode;
12811
+ }
12812
+ attempts;
12813
+ retryable;
12814
+ errorCode;
12815
+ name = "MapsSearchRotationError";
12816
+ };
12817
+ }
12818
+ });
12819
+
12820
+ // src/api/maps-routes.ts
12821
+ function mapsErrorResponse(c, err, errorCode) {
12822
+ const msg = err instanceof Error ? err.message : String(err);
12823
+ const rotationError = err instanceof MapsSearchRotationError ? err : null;
12824
+ const blocked = msg.includes("CAPTCHA") || msg.includes("blocked");
12825
+ const retryable = rotationError?.retryable ?? blocked;
12826
+ return c.json({
12827
+ error: sanitizeVendorName(msg),
12828
+ error_code: rotationError?.errorCode ?? (blocked ? "captcha_or_blocked" : errorCode),
12829
+ retryable,
12830
+ attempts: rotationError?.attempts ?? void 0
12831
+ }, retryable ? 503 : 500);
12605
12832
  }
12606
12833
  var import_hono5, mapsApp;
12607
12834
  var init_maps_routes = __esm({
@@ -12611,13 +12838,12 @@ var init_maps_routes = __esm({
12611
12838
  init_db();
12612
12839
  init_rates();
12613
12840
  init_MapsExtractor();
12614
- init_MapsSearchExtractor();
12615
12841
  init_BrowserDriver();
12616
12842
  init_schemas3();
12617
12843
  init_api_auth();
12618
12844
  init_errors();
12619
12845
  init_browser_service_env();
12620
- init_kernel_proxy_resolver();
12846
+ init_maps_search_rotation();
12621
12847
  mapsApp = new import_hono5.Hono();
12622
12848
  mapsApp.post("/search", createApiKeyAuth(), async (c) => {
12623
12849
  const user = c.get("user");
@@ -12636,24 +12862,10 @@ var init_maps_routes = __esm({
12636
12862
  [parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
12637
12863
  );
12638
12864
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
12639
- const driver = new BrowserDriver();
12640
- const extractor = new MapsSearchExtractor(driver);
12641
- let disposableProxyId;
12642
12865
  try {
12643
- const resolution2 = await resolveKernelProxyId({
12644
- kernelApiKey: parsed.data.kernelApiKey,
12645
- proxyMode: parsed.data.proxyMode,
12646
- configuredKernelProxyId: browserServiceProxyId(),
12647
- location: parsed.data.location,
12648
- proxyZip: parsed.data.proxyZip,
12649
- gl: parsed.data.gl,
12650
- fresh: parsed.data.proxyMode === "location"
12651
- });
12652
- disposableProxyId = resolution2.disposableProxyId;
12653
- const result = await extractor.extract({
12866
+ const result = await runMapsSearchWithRotation({
12654
12867
  ...parsed.data,
12655
- kernelProxyId: parsed.data.proxyMode === "none" ? void 0 : resolution2.kernelProxyId,
12656
- kernelProxyResolution: resolution2.resolution
12868
+ configuredKernelProxyId: browserServiceProxyId()
12657
12869
  });
12658
12870
  await logRequestEvent({
12659
12871
  userId: user.id,
@@ -12674,12 +12886,10 @@ var init_maps_routes = __esm({
12674
12886
  status: "failed",
12675
12887
  query: [parsed.data.query, parsed.data.location].filter(Boolean).join(" "),
12676
12888
  location: parsed.data.location,
12677
- error: msg
12889
+ error: msg,
12890
+ result: err instanceof MapsSearchRotationError ? { attempts: err.attempts } : void 0
12678
12891
  });
12679
- return mapsErrorResponse(c, msg, "maps_search_failed");
12680
- } finally {
12681
- await cleanupDisposableProxy(parsed.data.kernelApiKey, disposableProxyId);
12682
- await driver.close();
12892
+ return mapsErrorResponse(c, err, "maps_search_failed");
12683
12893
  }
12684
12894
  });
12685
12895
  mapsApp.post("/place", createApiKeyAuth(), async (c) => {
@@ -12886,12 +13096,17 @@ function errorAttemptsSection(body) {
12886
13096
  const browser = debug.browser ?? {};
12887
13097
  const kernel = browser.browserRuntime ?? browser.kernel ?? {};
12888
13098
  const proxyResolution = kernel.proxyResolution ?? {};
12889
- const network = browser.networkLocation ?? {};
13099
+ const network = browser.networkLocation ?? {
13100
+ ip: attempt.observedIp ?? attempt.observed_ip,
13101
+ city: attempt.observedCity ?? attempt.observed_city,
13102
+ region: attempt.observedRegion ?? attempt.observed_region
13103
+ };
12890
13104
  const nav = browser.serpNavigation ?? {};
12891
13105
  const geo = [network.ip, network.city, network.region].filter(Boolean).join(" / ") || "geo unknown";
12892
- const sessionId = attempt.browser_session_id ?? attempt.kernel_session_id ?? kernel.sessionId ?? "unknown";
13106
+ const sessionId = attempt.browser_session_id ?? attempt.browserSessionIdSuffix ?? attempt.kernel_session_id ?? kernel.sessionId ?? "unknown";
12893
13107
  const cleanupSucceeded2 = attempt.session_cleanup_succeeded ?? attempt.kernel_delete_succeeded;
12894
- return `- Attempt ${attempt.attempt_number ?? "?"}: ${attempt.outcome ?? attempt.status ?? "unknown"} \xB7 session ${sessionId} \xB7 proxy ${debug.request?.proxyMode ?? kernel.proxyMode ?? "unknown"}${proxyResolution.source ? `/${proxyResolution.source}` : ""} \xB7 ${geo} \xB7 CAPTCHA ${nav.captchaDetected === true ? "yes" : nav.captchaDetected === false ? "no" : "unknown"} \xB7 cleanup ${cleanupSucceeded2 === true ? "yes" : cleanupSucceeded2 === false ? "no" : "unknown"}`;
13108
+ const proxySource = proxyResolution.source ?? attempt.proxyResolutionSource;
13109
+ return `- Attempt ${attempt.attempt_number ?? attempt.attemptNumber ?? "?"}: ${attempt.outcome ?? attempt.status ?? "unknown"} \xB7 session ${sessionId} \xB7 proxy ${debug.request?.proxyMode ?? kernel.proxyMode ?? attempt.proxyMode ?? "unknown"}${proxySource ? `/${proxySource}` : ""} \xB7 ${geo} \xB7 CAPTCHA ${nav.captchaDetected === true ? "yes" : nav.captchaDetected === false ? "no" : "unknown"} \xB7 cleanup ${cleanupSucceeded2 === true ? "yes" : cleanupSucceeded2 === false ? "no" : "unknown"}`;
12895
13110
  });
12896
13111
  return `
12897
13112
 
@@ -13352,6 +13567,29 @@ ${rows}`,
13352
13567
  }
13353
13568
  };
13354
13569
  }
13570
+ function normalizeMapsAttempts(value) {
13571
+ const attempts = Array.isArray(value) ? value : [];
13572
+ return attempts.map((attempt, index) => ({
13573
+ attemptNumber: attempt.attemptNumber ?? attempt.attempt_number ?? index + 1,
13574
+ maxAttempts: attempt.maxAttempts ?? attempt.max_attempts ?? attempts.length,
13575
+ status: attempt.status === "ok" ? "ok" : "failed",
13576
+ outcome: attempt.outcome ?? attempt.status ?? "unknown",
13577
+ willRetry: attempt.willRetry ?? attempt.will_retry ?? false,
13578
+ durationMs: attempt.durationMs ?? attempt.duration_ms ?? 0,
13579
+ resultCount: attempt.resultCount ?? attempt.result_count ?? 0,
13580
+ error: attempt.error ? sanitizeVendorText(attempt.error) : null,
13581
+ proxyMode: attempt.proxyMode ?? attempt.proxy_mode ?? "location",
13582
+ proxyResolutionSource: attempt.proxyResolutionSource ?? attempt.proxy_resolution_source ?? null,
13583
+ proxyIdSuffix: attempt.proxyIdSuffix ?? attempt.proxy_id_suffix ?? null,
13584
+ proxyTargetLevel: attempt.proxyTargetLevel ?? attempt.proxy_target_level ?? null,
13585
+ proxyTargetLocation: attempt.proxyTargetLocation ?? attempt.proxy_target_location ?? null,
13586
+ proxyTargetZip: attempt.proxyTargetZip ?? attempt.proxy_target_zip ?? null,
13587
+ browserSessionIdSuffix: attempt.browserSessionIdSuffix ?? attempt.browser_session_id ?? null,
13588
+ observedIp: attempt.observedIp ?? attempt.observed_ip ?? null,
13589
+ observedCity: attempt.observedCity ?? attempt.observed_city ?? null,
13590
+ observedRegion: attempt.observedRegion ?? attempt.observed_region ?? null
13591
+ }));
13592
+ }
13355
13593
  function formatCreditsInfo(raw, input) {
13356
13594
  const parsed = parseData(raw);
13357
13595
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -13424,6 +13662,8 @@ function formatMapsSearch(raw, input) {
13424
13662
  const searchQuery = d.searchQuery ?? [input.query, input.location].filter(Boolean).join(" ");
13425
13663
  const requestedMax = d.requestedMaxResults ?? input.maxResults ?? 10;
13426
13664
  const durationMs = d.durationMs;
13665
+ const attempts = normalizeMapsAttempts(d.attempts);
13666
+ const lastAttempt = attempts.at(-1);
13427
13667
  const rows = results.map((r) => {
13428
13668
  const rating = [r.rating, r.reviewCount ? `(${r.reviewCount})` : null].filter(Boolean).join(" ");
13429
13669
  return `| ${r.position} | ${cell(r.name)} | ${cell(r.category)} | ${cell(rating)} | ${cell(r.address)} | ${r.cidDecimal ? `\`${r.cidDecimal}\`` : "\u2014"} | ${r.websiteUrl ? `[site](${r.websiteUrl})` : "\u2014"} | [maps](${r.placeUrl}) |`;
@@ -13438,6 +13678,7 @@ ${meta}`;
13438
13678
  const full = [
13439
13679
  `# Google Maps Search: "${searchQuery}"`,
13440
13680
  `**Returned:** ${results.length} profile candidate${results.length === 1 ? "" : "s"} \xB7 **Requested max:** ${requestedMax} \xB7 **Limit:** 50`,
13681
+ attempts.length ? `**Attempts:** ${attempts.length}/${lastAttempt?.maxAttempts ?? attempts.length} \xB7 **Proxy:** ${lastAttempt?.proxyMode ?? "unknown"}${lastAttempt?.proxyResolutionSource ? `/${lastAttempt.proxyResolutionSource}` : ""} \xB7 **Observed:** ${[lastAttempt?.observedCity, lastAttempt?.observedRegion].filter(Boolean).join(", ") || "unknown"}` : null,
13441
13682
  `
13442
13683
  ## Results
13443
13684
  | # | Name | Category | Rating | Address | CID | Website | Maps |
@@ -13461,6 +13702,7 @@ ${rows}`,
13461
13702
  requestedMaxResults: requestedMax,
13462
13703
  resultCount: results.length,
13463
13704
  results: normalizedResults,
13705
+ attempts,
13464
13706
  durationMs: durationMs ?? 0
13465
13707
  }
13466
13708
  };
@@ -13471,6 +13713,7 @@ function formatDirectoryWorkflow(raw, input) {
13471
13713
  const d = parsed.data;
13472
13714
  const cities = (d.cities ?? []).map((city) => ({
13473
13715
  ...city,
13716
+ attempts: normalizeMapsAttempts(city.attempts),
13474
13717
  results: city.results.map((result) => ({
13475
13718
  ...result,
13476
13719
  phone: result.phone ?? null,
@@ -13934,36 +14177,9 @@ var init_location_db = __esm({
13934
14177
  });
13935
14178
 
13936
14179
  // src/directory/directory-workflow.ts
13937
- async function cleanupDisposableProxy2(kernelApiKey, proxyId) {
13938
- if (!kernelApiKey || !proxyId) return;
13939
- try {
13940
- await deleteKernelProxyId(kernelApiKey, proxyId);
13941
- } catch (err) {
13942
- console.warn(JSON.stringify({
13943
- event: "directory_workflow_proxy_delete_failed",
13944
- proxy_id_suffix: proxyId.slice(-6),
13945
- message: err instanceof Error ? err.message : String(err)
13946
- }));
13947
- }
13948
- }
13949
- function maxAttemptsForProxyMode(proxyMode) {
13950
- return proxyMode === "location" ? DIRECTORY_LOCATION_PROXY_MAX_ATTEMPTS : DIRECTORY_MAX_ATTEMPTS;
13951
- }
13952
14180
  function errorMessage(err) {
13953
14181
  return err instanceof Error ? err.message : String(err);
13954
14182
  }
13955
- function looksLikeProxyTunnelFailure(message) {
13956
- 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);
13957
- }
13958
- function looksLikeProxyUnavailable(message) {
13959
- return /proxy unavailable|proxy_unavailable|connection_test_failed|did not return a proxy id|configured fallback/i.test(message);
13960
- }
13961
- function retryableCitySearchError(err, proxyMode) {
13962
- if (err instanceof CaptchaError) return true;
13963
- const message = errorMessage(err);
13964
- if (/timeout|timed out|Timeout \d+ms exceeded|deadline/i.test(message)) return true;
13965
- return proxyMode === "location" && (looksLikeProxyTunnelFailure(message) || looksLikeProxyUnavailable(message));
13966
- }
13967
14183
  function proxyZipForAttempt(options, market, attemptIndex) {
13968
14184
  if (options.proxyZip) return options.proxyZip;
13969
14185
  if (!market.zips.length) return void 0;
@@ -13982,25 +14198,10 @@ async function mapLimit(items, limit, fn) {
13982
14198
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
13983
14199
  return out;
13984
14200
  }
13985
- async function searchCityAttempt(options, market, attemptIndex) {
13986
- const driver = new BrowserDriver();
13987
- const extractor = new MapsSearchExtractor(driver);
13988
- const start = Date.now();
13989
- let disposableProxyId;
14201
+ async function searchCity(options, market) {
14202
+ const started = Date.now();
13990
14203
  try {
13991
- const proxyZip = proxyZipForAttempt(options, market, attemptIndex);
13992
- const resolution2 = await resolveKernelProxyId({
13993
- kernelApiKey: options.kernelApiKey,
13994
- proxyMode: options.proxyMode,
13995
- configuredKernelProxyId: browserServiceProxyId(),
13996
- location: market.location,
13997
- proxyZip,
13998
- gl: options.gl,
13999
- attemptIndex,
14000
- fresh: options.proxyMode === "location"
14001
- });
14002
- disposableProxyId = resolution2.disposableProxyId;
14003
- const result = await extractor.extract({
14204
+ const result = await runMapsSearchWithRotation({
14004
14205
  query: options.query,
14005
14206
  location: market.location,
14006
14207
  gl: options.gl,
@@ -14008,11 +14209,16 @@ async function searchCityAttempt(options, market, attemptIndex) {
14008
14209
  maxResults: options.maxResultsPerCity,
14009
14210
  headless: options.headless,
14010
14211
  kernelApiKey: options.kernelApiKey,
14011
- kernelProxyId: options.proxyMode === "none" ? void 0 : resolution2.kernelProxyId,
14012
- kernelProxyResolution: resolution2.resolution,
14013
14212
  proxyMode: options.proxyMode,
14014
- proxyZip,
14015
- debug: options.debug
14213
+ proxyZip: options.proxyZip,
14214
+ debug: options.debug,
14215
+ configuredKernelProxyId: browserServiceProxyId(),
14216
+ proxyZipForAttempt: (attemptIndex) => proxyZipForAttempt(options, market, attemptIndex),
14217
+ failureEventName: "directory_workflow_city_attempt_failed",
14218
+ logContext: {
14219
+ city: market.city,
14220
+ state: market.state
14221
+ }
14016
14222
  });
14017
14223
  return {
14018
14224
  city: market.city,
@@ -14028,50 +14234,29 @@ async function searchCityAttempt(options, market, attemptIndex) {
14028
14234
  error: null,
14029
14235
  resultCount: result.resultCount,
14030
14236
  durationMs: result.durationMs,
14237
+ attempts: result.attempts ?? [],
14031
14238
  results: result.results
14032
14239
  };
14033
- } finally {
14034
- await cleanupDisposableProxy2(options.kernelApiKey, disposableProxyId);
14035
- }
14036
- }
14037
- async function searchCity(options, market) {
14038
- const started = Date.now();
14039
- const maxAttempts = maxAttemptsForProxyMode(options.proxyMode);
14040
- let lastError = null;
14041
- for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) {
14042
- try {
14043
- return await searchCityAttempt(options, market, attemptIndex);
14044
- } catch (err) {
14045
- lastError = err;
14046
- const willRetry = attemptIndex < maxAttempts - 1 && retryableCitySearchError(err, options.proxyMode);
14047
- console.warn(JSON.stringify({
14048
- event: "directory_workflow_city_attempt_failed",
14049
- city: market.city,
14050
- state: market.state,
14051
- attempt_number: attemptIndex + 1,
14052
- max_attempts: maxAttempts,
14053
- will_retry: willRetry,
14054
- message: errorMessage(err)
14055
- }));
14056
- if (!willRetry) break;
14057
- }
14240
+ } catch (err) {
14241
+ const attempts = err instanceof MapsSearchRotationError ? err.attempts : [];
14242
+ return {
14243
+ city: market.city,
14244
+ state: market.state,
14245
+ location: market.location,
14246
+ cityKey: market.cityKey,
14247
+ censusName: market.censusName,
14248
+ population: market.population,
14249
+ populationYear: market.populationYear,
14250
+ zips: market.zips,
14251
+ counties: market.counties,
14252
+ status: "failed",
14253
+ error: errorMessage(err),
14254
+ resultCount: 0,
14255
+ durationMs: Date.now() - started,
14256
+ attempts,
14257
+ results: []
14258
+ };
14058
14259
  }
14059
- return {
14060
- city: market.city,
14061
- state: market.state,
14062
- location: market.location,
14063
- cityKey: market.cityKey,
14064
- censusName: market.censusName,
14065
- population: market.population,
14066
- populationYear: market.populationYear,
14067
- zips: market.zips,
14068
- counties: market.counties,
14069
- status: "failed",
14070
- error: lastError ? errorMessage(lastError) : "City Maps search failed",
14071
- resultCount: 0,
14072
- durationMs: Date.now() - started,
14073
- results: []
14074
- };
14075
14260
  }
14076
14261
  function csvRowsFor(result) {
14077
14262
  const rows = [];
@@ -14206,23 +14391,18 @@ async function runDirectoryWorkflowFromPlan(options, plan) {
14206
14391
  const csvPath = options.saveCsv ? await saveDirectoryCsv(base) : null;
14207
14392
  return { ...base, csvPath };
14208
14393
  }
14209
- var import_promises5, import_node_path6, import_zod18, DIRECTORY_MAX_ATTEMPTS, DIRECTORY_LOCATION_PROXY_MAX_ATTEMPTS, DirectoryWorkflowOptionsSchema;
14394
+ var import_promises5, import_node_path6, import_zod18, DirectoryWorkflowOptionsSchema;
14210
14395
  var init_directory_workflow = __esm({
14211
14396
  "src/directory/directory-workflow.ts"() {
14212
14397
  "use strict";
14213
14398
  import_promises5 = require("fs/promises");
14214
14399
  import_node_path6 = require("path");
14215
14400
  import_zod18 = require("zod");
14216
- init_MapsSearchExtractor();
14217
- init_BrowserDriver();
14218
14401
  init_mcp_response_formatter();
14219
14402
  init_browser_service_env();
14220
- init_kernel_proxy_resolver();
14221
- init_errors();
14403
+ init_maps_search_rotation();
14222
14404
  init_csv();
14223
14405
  init_location_db();
14224
- DIRECTORY_MAX_ATTEMPTS = 3;
14225
- DIRECTORY_LOCATION_PROXY_MAX_ATTEMPTS = 5;
14226
14406
  DirectoryWorkflowOptionsSchema = import_zod18.z.object({
14227
14407
  query: import_zod18.z.string().min(1),
14228
14408
  state: import_zod18.z.string().min(2).default("TN"),
@@ -16034,7 +16214,7 @@ function classifyAttemptResult(result) {
16034
16214
  function errorMessage2(err) {
16035
16215
  return err instanceof Error ? err.message : String(err);
16036
16216
  }
16037
- function maxAttemptsForProxyMode2(proxyMode) {
16217
+ function maxAttemptsForProxyMode(proxyMode) {
16038
16218
  return proxyMode === "location" ? LOCATION_PROXY_MAX_ATTEMPTS : MAX_ATTEMPTS2;
16039
16219
  }
16040
16220
  function looksLikeProxyTunnelFailure2(message) {
@@ -16061,7 +16241,7 @@ function stripInternalDebug(result, keepDebug) {
16061
16241
  delete diagnostics.debug;
16062
16242
  return { ...result, diagnostics };
16063
16243
  }
16064
- async function cleanupDisposableProxy3(kernelApiKey, proxyId) {
16244
+ async function cleanupDisposableProxy2(kernelApiKey, proxyId) {
16065
16245
  if (!kernelApiKey || !proxyId) return;
16066
16246
  try {
16067
16247
  await deleteKernelProxyId(kernelApiKey, proxyId);
@@ -16146,7 +16326,7 @@ async function harvest(rawOptions) {
16146
16326
  };
16147
16327
  const requestedDebug = typeof raw.debug === "boolean" ? raw.debug : false;
16148
16328
  const needsLocationEvidence = proxyMode === "location" && Boolean(proxyOpts.location);
16149
- const maxAttempts = maxAttemptsForProxyMode2(proxyMode);
16329
+ const maxAttempts = maxAttemptsForProxyMode(proxyMode);
16150
16330
  const serializer = new OutputSerializer();
16151
16331
  let lastError = null;
16152
16332
  for (let i = 0; i < maxAttempts; i++) {
@@ -16223,7 +16403,7 @@ async function harvest(rawOptions) {
16223
16403
  debug: attempt.debug,
16224
16404
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
16225
16405
  });
16226
- await cleanupDisposableProxy3(kernelApiKey, resolution2.disposableProxyId);
16406
+ await cleanupDisposableProxy2(kernelApiKey, resolution2.disposableProxyId);
16227
16407
  lastError = err;
16228
16408
  if (willRetry) continue;
16229
16409
  break;
@@ -16254,7 +16434,7 @@ async function harvest(rawOptions) {
16254
16434
  debug: attempt.debug,
16255
16435
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
16256
16436
  });
16257
- await cleanupDisposableProxy3(kernelApiKey, resolution2.disposableProxyId);
16437
+ await cleanupDisposableProxy2(kernelApiKey, resolution2.disposableProxyId);
16258
16438
  lastError = err;
16259
16439
  if (willRetry) continue;
16260
16440
  break;
@@ -16274,7 +16454,7 @@ async function harvest(rawOptions) {
16274
16454
  debug: attempt.debug,
16275
16455
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
16276
16456
  });
16277
- await cleanupDisposableProxy3(kernelApiKey, resolution2.disposableProxyId);
16457
+ await cleanupDisposableProxy2(kernelApiKey, resolution2.disposableProxyId);
16278
16458
  if (attemptOptions.format === "json" || attemptOptions.format === "both") {
16279
16459
  await serializer.writeJSON(finalResult, attemptOptions.outputDir);
16280
16460
  }
@@ -17082,12 +17262,12 @@ var PACKAGE_VERSION;
17082
17262
  var init_version = __esm({
17083
17263
  "src/version.ts"() {
17084
17264
  "use strict";
17085
- PACKAGE_VERSION = "0.2.6";
17265
+ PACKAGE_VERSION = "0.2.8";
17086
17266
  }
17087
17267
  });
17088
17268
 
17089
17269
  // src/mcp/mcp-tool-schemas.ts
17090
- var import_zod20, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, CreditsInfoInputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
17270
+ var import_zod20, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, CreditsInfoInputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
17091
17271
  var init_mcp_tool_schemas = __esm({
17092
17272
  "src/mcp/mcp-tool-schemas.ts"() {
17093
17273
  "use strict";
@@ -17099,7 +17279,7 @@ var init_mcp_tool_schemas = __esm({
17099
17279
  gl: import_zod20.z.string().length(2).default("us").describe("Google country code inferred from location or user language. Examples: United States us, United Kingdom gb, Japan jp, Canada ca, Australia au."),
17100
17280
  hl: import_zod20.z.string().default("en").describe("Google interface/content language inferred from the user request. Use en unless the user asks for another language or locale."),
17101
17281
  device: import_zod20.z.enum(["desktop", "mobile"]).default("desktop").describe("SERP device context. Use desktop by default; use mobile only when the user asks for mobile rankings."),
17102
- proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state SERPs; it creates a fresh residential proxy ID per attempt and retries CAPTCHA, proxy tunnel failure, and wrong-location evidence before returning. Use configured only for the static configured proxy. Use none only for direct-network debugging."),
17282
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state SERPs; it creates a fresh residential proxy ID per attempt. If Google shows a CAPTCHA/challenge, browser-service sessions briefly wait for the automatic solver first, then retry with a fresh proxy/session if the challenge does not clear. Also retries proxy tunnel failure and wrong-location evidence before returning. Use configured only for the static configured proxy. Use none only for direct-network debugging."),
17103
17283
  proxyZip: import_zod20.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override for residential location proxy targeting. Use when the user gives a specific ZIP or when city-center targeting needs to be forced. With proxyMode location this ZIP is used for each fresh proxy attempt."),
17104
17284
  debug: import_zod20.z.boolean().default(false).describe("Include sanitized browser/session/location diagnostics in the response. Use true when debugging localization, CAPTCHA, or proxy behavior.")
17105
17285
  };
@@ -17158,7 +17338,7 @@ var init_mcp_tool_schemas = __esm({
17158
17338
  gl: import_zod20.z.string().length(2).default("us").describe("Google country code inferred from location."),
17159
17339
  hl: import_zod20.z.string().length(2).default("en").describe("Language inferred from user request."),
17160
17340
  maxResults: import_zod20.z.number().int().min(1).max(50).default(10).describe("Number of Google Maps business/profile candidates to return. Default 10. Maximum 50. Use 10 unless the user asks for more."),
17161
- proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state Maps searches; it creates a fresh residential proxy ID when the browser service is available. Use configured for the server proxy ID, and none only for local direct-network debugging."),
17341
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state Maps searches; retryable failures create a new residential proxy ID and new browser session for up to 5 attempts. Use configured for the server proxy ID, and none only for local direct-network debugging."),
17162
17342
  proxyZip: import_zod20.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override for residential location proxy targeting. Use when the user gives a specific ZIP or city-center ZIP."),
17163
17343
  debug: import_zod20.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics when debugging Maps localization, CAPTCHA, or proxy behavior.")
17164
17344
  };
@@ -17173,7 +17353,7 @@ var init_mcp_tool_schemas = __esm({
17173
17353
  includeZipGroups: import_zod20.z.boolean().default(true).describe("Attach ZIP groups from a configured US ZIPS CSV when available. Set MCP_SCRAPER_USZIPS_CSV_PATH on the API server or pass usZipsCsvPath in local/test mode."),
17174
17354
  usZipsCsvPath: import_zod20.z.string().optional().describe("Local/test-only path to a US ZIPS CSV with state_abbr, zipcode, county, city columns, such as Lead Magician tools/analytics/data/uszips.csv. Deployed APIs should use MCP_SCRAPER_USZIPS_CSV_PATH instead."),
17175
17355
  saveCsv: import_zod20.z.boolean().default(true).describe("Save a directory-ready CSV to the MCP Scraper output directory and return its path. CSV rows include source_location, result_position, business_name, review_stars, category, address, phone, hours_status, website_url, directions_url, place_url, CID fields, population, and ZIP groups."),
17176
- proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode for every city Maps search. Use location by default for US city/state batches; it creates fresh residential proxy IDs when the browser service is available. Use configured for the server proxy ID, and none only for local direct-network debugging."),
17356
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode for every city Maps search. Use location by default for US city/state batches; retryable failures create a new residential proxy ID and new browser session for up to 5 attempts per city. Use configured for the server proxy ID, and none only for local direct-network debugging."),
17177
17357
  proxyZip: import_zod20.z.string().regex(/^\d{5}$/).optional().describe("Optional ZIP override for proxy targeting. Normally omit it so each city can use its Lead Magician ZIP group or city/state location."),
17178
17358
  debug: import_zod20.z.boolean().default(false).describe("Include sanitized browser/proxy diagnostics in each Maps browser session when supported.")
17179
17359
  };
@@ -17196,6 +17376,26 @@ var init_mcp_tool_schemas = __esm({
17196
17376
  notes: import_zod20.z.string().max(4e3).optional().describe("Extra product, client, stack, or hosting requirements to include in the implementation prompt.")
17197
17377
  };
17198
17378
  NullableString = import_zod20.z.string().nullable();
17379
+ MapsSearchAttemptOutput = import_zod20.z.object({
17380
+ attemptNumber: import_zod20.z.number().int().min(1),
17381
+ maxAttempts: import_zod20.z.number().int().min(1),
17382
+ status: import_zod20.z.enum(["ok", "failed"]),
17383
+ outcome: import_zod20.z.string(),
17384
+ willRetry: import_zod20.z.boolean(),
17385
+ durationMs: import_zod20.z.number().int().min(0),
17386
+ resultCount: import_zod20.z.number().int().min(0),
17387
+ error: NullableString,
17388
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]),
17389
+ proxyResolutionSource: import_zod20.z.enum(["disabled", "location_reused", "location_created", "configured_fallback", "unavailable"]).nullable(),
17390
+ proxyIdSuffix: NullableString,
17391
+ proxyTargetLevel: import_zod20.z.enum(["zip", "city", "state"]).nullable(),
17392
+ proxyTargetLocation: NullableString,
17393
+ proxyTargetZip: NullableString,
17394
+ browserSessionIdSuffix: NullableString,
17395
+ observedIp: NullableString,
17396
+ observedCity: NullableString,
17397
+ observedRegion: NullableString
17398
+ });
17199
17399
  MapsSearchOutputSchema = {
17200
17400
  query: import_zod20.z.string(),
17201
17401
  location: import_zod20.z.string().nullable(),
@@ -17220,6 +17420,7 @@ var init_mcp_tool_schemas = __esm({
17220
17420
  directionsUrl: NullableString,
17221
17421
  metadata: import_zod20.z.array(import_zod20.z.string())
17222
17422
  })),
17423
+ attempts: import_zod20.z.array(MapsSearchAttemptOutput),
17223
17424
  durationMs: import_zod20.z.number().int().min(0)
17224
17425
  };
17225
17426
  DirectoryMapsBusinessOutput = import_zod20.z.object({
@@ -17266,6 +17467,7 @@ var init_mcp_tool_schemas = __esm({
17266
17467
  error: NullableString,
17267
17468
  resultCount: import_zod20.z.number().int().min(0),
17268
17469
  durationMs: import_zod20.z.number().int().min(0),
17470
+ attempts: import_zod20.z.array(MapsSearchAttemptOutput),
17269
17471
  results: import_zod20.z.array(DirectoryMapsBusinessOutput)
17270
17472
  })),
17271
17473
  durationMs: import_zod20.z.number().int().min(0)
@@ -17482,7 +17684,7 @@ var init_mcp_tool_schemas = __esm({
17482
17684
  gl: import_zod20.z.string().length(2).default("us").describe("Google country code inferred from location or user language."),
17483
17685
  hl: import_zod20.z.string().default("en").describe("Google interface/content language inferred from user request."),
17484
17686
  device: import_zod20.z.enum(["desktop", "mobile"]).default("desktop").describe("SERP device context. Use desktop by default; use mobile only when the user asks for mobile rankings."),
17485
- proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state SERPs; it creates a fresh residential proxy ID per attempt and retries CAPTCHA, proxy tunnel failure, and wrong-location evidence before returning. Use configured only for the static configured proxy. Use none only for direct-network debugging."),
17687
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy targeting mode. Use location by default for US city/state SERPs; it creates a fresh residential proxy ID per attempt. If Google shows a CAPTCHA/challenge, browser-service sessions briefly wait for the automatic solver first, then retry with a fresh proxy/session if the challenge does not clear. Also retries proxy tunnel failure and wrong-location evidence before returning. Use configured only for the static configured proxy. Use none only for direct-network debugging."),
17486
17688
  proxyZip: import_zod20.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override for residential location proxy targeting. Use when the user gives a specific ZIP or when city-center targeting needs to be forced. With proxyMode location this ZIP is used for each fresh proxy attempt."),
17487
17689
  debug: import_zod20.z.boolean().default(false).describe("Include sanitized browser/session/location diagnostics in the response. Use true when debugging localization, CAPTCHA, or proxy behavior."),
17488
17690
  pages: import_zod20.z.number().int().min(1).max(2).default(1).describe("Number of result pages to fetch (1\u20132)")
@@ -17493,7 +17695,7 @@ var init_mcp_tool_schemas = __esm({
17493
17695
  gl: import_zod20.z.string().length(2).default("us").describe("Google country code inferred from the requested market, e.g. us, gb, ca, au."),
17494
17696
  hl: import_zod20.z.string().default("en").describe("Google interface/content language inferred from the user request."),
17495
17697
  device: import_zod20.z.enum(["desktop", "mobile"]).default("desktop").describe("SERP device context. Use mobile only when the user asks for mobile rankings or mobile SERP evidence."),
17496
- proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy behavior for capture. Use location for localized US residential evidence; it creates a fresh proxy ID per attempt and retries CAPTCHA, proxy tunnel failure, and wrong-location evidence before returning. Use configured only for the static residential proxy, and none only for direct-network debugging."),
17698
+ proxyMode: import_zod20.z.enum(["location", "configured", "none"]).default("location").describe("Proxy behavior for capture. Use location for localized US residential evidence; it creates a fresh proxy ID per attempt. If Google shows a CAPTCHA/challenge, browser-service sessions briefly wait for the automatic solver first, then retry with a fresh proxy/session if the challenge does not clear. Also retries proxy tunnel failure and wrong-location evidence before returning. Use configured only for the static residential proxy, and none only for direct-network debugging."),
17497
17699
  proxyZip: import_zod20.z.string().regex(/^\d{5}$/).optional().describe("Optional US ZIP override for residential location proxy targeting when a precise city-center or ZIP proxy is needed. With proxyMode location this ZIP is used for each fresh proxy attempt."),
17498
17700
  pages: import_zod20.z.number().int().min(1).max(2).default(1).describe("Number of Google result pages to capture. Use 1 normally and 2 only when the user needs deeper ranking evidence."),
17499
17701
  debug: import_zod20.z.boolean().default(false).describe("Include sanitized browser, proxy, and location diagnostics. Use true when debugging localization, CAPTCHA, proxy selection, or capture reliability."),
@@ -17913,14 +18115,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
17913
18115
  if (savesReports) registerSavedReportResources(server);
17914
18116
  server.registerTool("harvest_paa", {
17915
18117
  title: "Google PAA + SERP Harvest",
17916
- description: withReportNote('Best default tool for Google search research. Extracts People Also Ask questions plus answers/source URLs, organic SERP, local pack when present, entity IDs (CID/GCID/KG MID), and AI Overview. Infer the user language: split topic from location (e.g. "best hvac company in Denver CO" => query "best hvac company", location "Denver, CO", gl "us", hl "en"). For US local SERPs, leave proxyMode as location so the service uses fresh residential proxy IDs across retries and rejects wrong-location evidence instead of returning a bad market. Use maxQuestions 30 normally, 100-200 for "full", "deep", "all", or comprehensive research. Deep harvests above 100 questions can run for several minutes with no interim progress \u2014 warn the user before starting one and keep maxQuestions at or below 100 unless they explicitly want a deep harvest. Credits are charged by extracted question; unused request hold is refunded.'),
18118
+ description: withReportNote('Best default tool for Google search research. Extracts People Also Ask questions plus answers/source URLs, organic SERP, local pack when present, entity IDs (CID/GCID/KG MID), and AI Overview. Infer the user language: split topic from location (e.g. "best hvac company in Denver CO" => query "best hvac company", location "Denver, CO", gl "us", hl "en"). For US local SERPs, leave proxyMode as location so the service uses fresh residential proxy IDs across retries, briefly waits for browser-service automatic CAPTCHA/challenge solving when Google blocks a page, and rotates to a new proxy/session if the challenge does not clear or the market evidence is wrong. Use maxQuestions 30 normally, 100-200 for "full", "deep", "all", or comprehensive research. Deep harvests above 100 questions can run for several minutes with no interim progress \u2014 warn the user before starting one and keep maxQuestions at or below 100 unless they explicitly want a deep harvest. Credits are charged by extracted question; unused request hold is refunded.'),
17917
18119
  inputSchema: HarvestPaaInputSchema,
17918
18120
  outputSchema: HarvestPaaOutputSchema,
17919
18121
  annotations: liveWebToolAnnotations("Google PAA + SERP Harvest")
17920
18122
  }, async (input) => formatHarvestPaa(await executor.harvestPaa(input), input));
17921
18123
  server.registerTool("search_serp", {
17922
18124
  title: "Google SERP Lookup",
17923
- description: withReportNote("Fast Google SERP lookup without PAA expansion. Use when the user asks for rankings, organic results, local pack, quick SERP, or positions. Split topic from location and infer gl/hl from the user request. For US city/state rankings, keep proxyMode as location and pass proxyZip when a city-center ZIP is known; location mode uses fresh residential proxy IDs and retries CAPTCHA, proxy tunnel failures, and wrong-location evidence before returning."),
18125
+ description: withReportNote("Fast Google SERP lookup without PAA expansion. Use when the user asks for rankings, organic results, local pack, quick SERP, or positions. Split topic from location and infer gl/hl from the user request. For US city/state rankings, keep proxyMode as location and pass proxyZip when a city-center ZIP is known; location mode uses fresh residential proxy IDs, briefly waits for browser-service automatic CAPTCHA/challenge solving when Google blocks a page, and rotates to a new proxy/session if the challenge does not clear, the proxy tunnel fails, or location evidence is wrong."),
17924
18126
  inputSchema: SearchSerpInputSchema,
17925
18127
  outputSchema: SearchSerpOutputSchema,
17926
18128
  annotations: liveWebToolAnnotations("Google SERP Lookup")
@@ -17988,14 +18190,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
17988
18190
  }, async (input) => formatMapsPlaceIntel(await executor.mapsPlaceIntel(input), input));
17989
18191
  server.registerTool("maps_search", {
17990
18192
  title: "Google Maps Business Search",
17991
- description: withReportNote('Search Google Maps for multiple businesses/profiles by category, niche, keyword, or local market. Use this when the user asks for several Google Business Profiles, GMBs, GBPs, leads, prospects, competitors, or "more than the 3-pack." For US city/state Maps searches, keep proxyMode as location so the browser service can create a fresh residential proxy ID for that market; pass proxyZip only when a specific ZIP or city-center ZIP is known. Returns up to 50 candidates with names, place URLs, CIDs when available, ratings, review counts, and profile metadata. Default maxResults is 10; maximum is 50. Use maps_place_intel afterward only when a selected business needs full details and reviews.'),
18193
+ description: withReportNote('Search Google Maps for multiple businesses/profiles by category, niche, keyword, or local market. Use this when the user asks for several Google Business Profiles, GMBs, GBPs, leads, prospects, competitors, or "more than the 3-pack." For US city/state Maps searches, keep proxyMode as location so the browser service creates a fresh residential proxy ID and browser session for the market; retryable failures rotate to a new proxy and new browser session for up to 5 attempts. Pass proxyZip only when a specific ZIP or city-center ZIP is known. Returns up to 50 candidates with names, place URLs, CIDs when available, ratings, review counts, profile metadata, and sanitized attempt telemetry. Default maxResults is 10; maximum is 50. Use maps_place_intel afterward only when a selected business needs full details and reviews.'),
17992
18194
  inputSchema: MapsSearchInputSchema,
17993
18195
  outputSchema: MapsSearchOutputSchema,
17994
18196
  annotations: liveWebToolAnnotations("Google Maps Business Search")
17995
18197
  }, async (input) => formatMapsSearch(await executor.mapsSearch(input), input));
17996
18198
  server.registerTool("directory_workflow", {
17997
18199
  title: "Directory Workflow: Markets + Maps",
17998
- description: withReportNote('Build directory/prospecting datasets by selecting US city markets from the free Census Population Estimates city/place dataset, optionally joining configured US ZIPS/Lead Magician ZIP groups, then running Google Maps business searches for each city in parallel. Use this when the user wants "all cities over 100k population in a state", "build a directory CSV", "find markets then get Maps data", or similar location-database + Maps workflows. Set minPopulation, state, query, maxResultsPerCity, and concurrency. Use concurrency up to 5 for parallel city sessions. Keep proxyMode as location so each city can use a fresh residential proxy ID when the browser service is available; retryable city failures use fresh proxies across attempts. Saved CSV rows include source_location, result_position, business_name, review_stars, category, address, phone, hours_status, website_url, directions_url, place_url, cid, cid_decimal, city population, and ZIP groups. This workflow captures star ratings from Maps list cards, not profile review counts; use maps_place_intel only when a selected profile needs deeper review details. For local Lead Magician ZIP enrichment, set MCP_SCRAPER_USZIPS_CSV_PATH on the API server or pass usZipsCsvPath only in local/test mode.'),
18200
+ description: withReportNote('Build directory/prospecting datasets by selecting US city markets from the free Census Population Estimates city/place dataset, optionally joining configured US ZIPS/Lead Magician ZIP groups, then running Google Maps business searches for each city in parallel. Use this when the user wants "all cities over 100k population in a state", "build a directory CSV", "find markets then get Maps data", or similar location-database + Maps workflows. Set minPopulation, state, query, maxResultsPerCity, and concurrency. Use concurrency up to 5 for parallel city sessions. Keep proxyMode as location so each city search creates fresh residential proxy IDs and browser sessions; retryable city failures rotate to a new proxy and new browser session for up to 5 attempts. Saved CSV rows include source_location, result_position, business_name, review_stars, category, address, phone, hours_status, website_url, directions_url, place_url, cid, cid_decimal, city population, and ZIP groups. Structured city results include sanitized attempt telemetry. This workflow captures star ratings from Maps list cards, not profile review counts; use maps_place_intel only when a selected profile needs deeper review details. For local Lead Magician ZIP enrichment, set MCP_SCRAPER_USZIPS_CSV_PATH on the API server or pass usZipsCsvPath only in local/test mode.'),
17999
18201
  inputSchema: DirectoryWorkflowInputSchema,
18000
18202
  outputSchema: DirectoryWorkflowOutputSchema,
18001
18203
  annotations: liveWebToolAnnotations("Directory Workflow: Markets + Maps")
@@ -18183,7 +18385,7 @@ async function requireMcpCallerKey(c) {
18183
18385
  function registerSerpIntelligenceCaptureTools(server, executor) {
18184
18386
  server.registerTool("capture_serp_snapshot", {
18185
18387
  title: "SERP Intelligence Snapshot",
18186
- description: "Capture a structured SERP Intelligence Google snapshot through POST /serp-intelligence/capture, the same product capture path used by Phoenix. Split query from location, infer gl/hl, use proxyMode location for localized US residential evidence; location mode creates fresh proxy IDs across retries and rejects wrong-location evidence before returning. Use configured only for the static residential proxy, and none only for direct-network debugging. Set debug true when investigating location evidence, proxy behavior, CAPTCHA, or capture reliability.",
18388
+ description: "Capture a structured SERP Intelligence Google snapshot through POST /serp-intelligence/capture, the same product capture path used by Phoenix. Split query from location, infer gl/hl, use proxyMode location for localized US residential evidence; location mode creates fresh proxy IDs across retries, briefly waits for browser-service automatic CAPTCHA/challenge solving when Google blocks a page, and rotates to a new proxy/session if the challenge does not clear or location evidence is wrong. Use configured only for the static residential proxy, and none only for direct-network debugging. Set debug true when investigating location evidence, proxy behavior, CAPTCHA, or capture reliability.",
18187
18389
  inputSchema: CaptureSerpSnapshotInputSchema,
18188
18390
  annotations: liveWebToolAnnotations("SERP Intelligence Snapshot")
18189
18391
  }, async (input) => executor.captureSerpSnapshot(input));
@@ -18351,18 +18553,26 @@ async function recordAction(input) {
18351
18553
  async function recordReplayStart(input) {
18352
18554
  const db = getDb();
18353
18555
  await db.execute({
18354
- sql: `INSERT INTO browser_agent_replays (replay_id, session_id, view_url, label)
18355
- VALUES (?, ?, ?, ?)`,
18356
- args: [input.replayId, input.sessionId, input.viewUrl, input.label]
18556
+ sql: `INSERT INTO browser_agent_replays (replay_id, session_id, view_url, label, started_at)
18557
+ VALUES (?, ?, ?, ?, ?)`,
18558
+ args: [input.replayId, input.sessionId, input.viewUrl, input.label, (/* @__PURE__ */ new Date()).toISOString()]
18357
18559
  });
18358
18560
  }
18359
18561
  async function recordReplayStop(replayId, viewUrl) {
18360
18562
  const db = getDb();
18361
18563
  await db.execute({
18362
- sql: `UPDATE browser_agent_replays SET stopped_at = datetime('now'), view_url = COALESCE(?, view_url) WHERE replay_id = ?`,
18363
- args: [viewUrl, replayId]
18564
+ sql: `UPDATE browser_agent_replays SET stopped_at = ?, view_url = COALESCE(?, view_url) WHERE replay_id = ?`,
18565
+ args: [(/* @__PURE__ */ new Date()).toISOString(), viewUrl, replayId]
18364
18566
  });
18365
18567
  }
18568
+ async function getActiveReplayRow(sessionId) {
18569
+ const db = getDb();
18570
+ const res = await db.execute({
18571
+ sql: `SELECT * FROM browser_agent_replays WHERE session_id = ? AND stopped_at IS NULL ORDER BY started_at DESC LIMIT 1`,
18572
+ args: [sessionId]
18573
+ });
18574
+ return res.rows[0] ?? null;
18575
+ }
18366
18576
  async function listReplayRows(sessionId) {
18367
18577
  const db = getDb();
18368
18578
  const res = await db.execute({
@@ -18482,7 +18692,13 @@ async function readPage(cdpWsUrl) {
18482
18692
  role: el2.getAttribute("role") || el2.tagName.toLowerCase(),
18483
18693
  name,
18484
18694
  x: Math.round(r.left + r.width / 2),
18485
- y: Math.round(r.top + r.height / 2)
18695
+ y: Math.round(r.top + r.height / 2),
18696
+ left: Math.round(r.left),
18697
+ top: Math.round(r.top),
18698
+ width: Math.round(r.width),
18699
+ height: Math.round(r.height),
18700
+ right: Math.round(r.right),
18701
+ bottom: Math.round(r.bottom)
18486
18702
  });
18487
18703
  }
18488
18704
  const text = (document.body?.innerText || "").replace(/\n{3,}/g, "\n\n").trim().slice(0, 6e3);
@@ -18492,13 +18708,168 @@ async function readPage(cdpWsUrl) {
18492
18708
  url,
18493
18709
  title,
18494
18710
  text: data.text,
18495
- elements: data.els.map((e, i) => ({ ref: i + 1, role: e.role, name: e.name, x: e.x, y: e.y }))
18711
+ elements: data.els.map((e, i) => ({
18712
+ ref: i + 1,
18713
+ role: e.role,
18714
+ name: e.name,
18715
+ x: e.x,
18716
+ y: e.y,
18717
+ left: e.left,
18718
+ top: e.top,
18719
+ width: e.width,
18720
+ height: e.height,
18721
+ right: e.right,
18722
+ bottom: e.bottom
18723
+ }))
18496
18724
  };
18497
18725
  } finally {
18498
18726
  await browser.close().catch(() => {
18499
18727
  });
18500
18728
  }
18501
18729
  }
18730
+ async function locatePageTargets(cdpWsUrl, targets) {
18731
+ const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
18732
+ try {
18733
+ const context = browser.contexts()[0] ?? await browser.newContext();
18734
+ const page = context.pages()[0] ?? await context.newPage();
18735
+ const url = page.url();
18736
+ const title = await page.title().catch(() => "");
18737
+ await page.evaluate(() => {
18738
+ ;
18739
+ globalThis.__name = (target) => target;
18740
+ }).catch(() => {
18741
+ });
18742
+ const data = await page.evaluate((rawTargets) => {
18743
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
18744
+ const isVisibleRect = (r) => r.width >= 1 && r.height >= 1 && r.bottom >= 0 && r.right >= 0 && r.top <= window.innerHeight && r.left <= window.innerWidth;
18745
+ const roleOf = (el2) => el2.getAttribute("role") || el2.tagName.toLowerCase();
18746
+ const nameOf = (el2) => {
18747
+ const e = el2;
18748
+ return normalize(
18749
+ e.getAttribute("aria-label") || e.placeholder || e.innerText || e.getAttribute("value") || e.getAttribute("title") || ""
18750
+ ).slice(0, 160);
18751
+ };
18752
+ const textOf = (el2) => normalize(el2.innerText || el2.textContent || "").slice(0, 500);
18753
+ const unionRects = (rects) => {
18754
+ const visible = rects.filter(isVisibleRect);
18755
+ if (!visible.length) return null;
18756
+ const left = Math.min(...visible.map((r) => r.left));
18757
+ const top = Math.min(...visible.map((r) => r.top));
18758
+ const right = Math.max(...visible.map((r) => r.right));
18759
+ const bottom = Math.max(...visible.map((r) => r.bottom));
18760
+ return {
18761
+ left,
18762
+ top,
18763
+ right,
18764
+ bottom,
18765
+ width: right - left,
18766
+ height: bottom - top,
18767
+ x: left,
18768
+ y: top,
18769
+ toJSON: () => ({})
18770
+ };
18771
+ };
18772
+ const elementPayload = (el2, rect) => ({
18773
+ role: roleOf(el2),
18774
+ tag: el2.tagName.toLowerCase(),
18775
+ name: nameOf(el2),
18776
+ text: textOf(el2),
18777
+ x: Math.round(rect.left + rect.width / 2),
18778
+ y: Math.round(rect.top + rect.height / 2),
18779
+ left: Math.round(rect.left),
18780
+ top: Math.round(rect.top),
18781
+ width: Math.round(rect.width),
18782
+ height: Math.round(rect.height),
18783
+ right: Math.round(rect.right),
18784
+ bottom: Math.round(rect.bottom)
18785
+ });
18786
+ const selectorMatches = (selector) => {
18787
+ const nodes = Array.from(document.querySelectorAll(selector));
18788
+ return nodes.map((el2) => ({ el: el2, rect: el2.getBoundingClientRect() })).filter((item) => isVisibleRect(item.rect));
18789
+ };
18790
+ const textMatches = (needle, match) => {
18791
+ const wanted = normalize(needle);
18792
+ const wantedLower = wanted.toLowerCase();
18793
+ if (!wantedLower) return [];
18794
+ const matches = [];
18795
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
18796
+ let node = walker.nextNode();
18797
+ while (node) {
18798
+ const raw = node.textContent || "";
18799
+ const normalizedRaw = normalize(raw);
18800
+ const rawLower = raw.toLowerCase();
18801
+ const normalizedLower = normalizedRaw.toLowerCase();
18802
+ const ok = match === "exact" ? normalizedLower === wantedLower : normalizedLower.includes(wantedLower);
18803
+ if (ok) {
18804
+ const start = match === "exact" ? 0 : rawLower.indexOf(wantedLower);
18805
+ const parent = node.parentElement;
18806
+ if (parent && start >= 0) {
18807
+ try {
18808
+ const range = document.createRange();
18809
+ range.setStart(node, Math.min(start, raw.length));
18810
+ range.setEnd(node, Math.min(raw.length, start + needle.length));
18811
+ const rect = unionRects(Array.from(range.getClientRects()));
18812
+ range.detach();
18813
+ if (rect) matches.push({ el: parent, rect });
18814
+ } catch {
18815
+ const rect = parent.getBoundingClientRect();
18816
+ if (isVisibleRect(rect)) matches.push({ el: parent, rect });
18817
+ }
18818
+ }
18819
+ }
18820
+ node = walker.nextNode();
18821
+ }
18822
+ if (matches.length) return matches;
18823
+ const fallback = Array.from(document.querySelectorAll("body *")).map((el2) => ({ el: el2, rect: el2.getBoundingClientRect(), text: textOf(el2) })).filter((item) => isVisibleRect(item.rect)).filter((item) => {
18824
+ const value = item.text.toLowerCase();
18825
+ return match === "exact" ? value === wantedLower : value.includes(wantedLower);
18826
+ }).sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
18827
+ return fallback.map(({ el: el2, rect }) => ({ el: el2, rect }));
18828
+ };
18829
+ const viewport = {
18830
+ width: window.innerWidth,
18831
+ height: window.innerHeight,
18832
+ devicePixelRatio: window.devicePixelRatio || 1
18833
+ };
18834
+ const results = rawTargets.map((target) => {
18835
+ const index = Math.max(0, Math.floor(target.index ?? 0));
18836
+ const match = target.match === "exact" ? "exact" : "contains";
18837
+ try {
18838
+ const selector = target.selector?.trim();
18839
+ const text = target.text?.trim();
18840
+ const candidates = selector ? selectorMatches(selector) : text ? textMatches(text, match) : [];
18841
+ const selected = candidates[index];
18842
+ return {
18843
+ name: target.name ?? null,
18844
+ selector: selector || null,
18845
+ text: text || null,
18846
+ match,
18847
+ index,
18848
+ found: Boolean(selected),
18849
+ element: selected ? elementPayload(selected.el, selected.rect) : null,
18850
+ ...selected ? {} : { error: "target not found in current viewport" }
18851
+ };
18852
+ } catch (err) {
18853
+ return {
18854
+ name: target.name ?? null,
18855
+ selector: target.selector ?? null,
18856
+ text: target.text ?? null,
18857
+ match,
18858
+ index,
18859
+ found: false,
18860
+ element: null,
18861
+ error: err instanceof Error ? err.message : String(err)
18862
+ };
18863
+ }
18864
+ });
18865
+ return { viewport, targets: results };
18866
+ }, targets);
18867
+ return { url, title, viewport: data.viewport, targets: data.targets };
18868
+ } finally {
18869
+ await browser.close().catch(() => {
18870
+ });
18871
+ }
18872
+ }
18502
18873
  async function replayStart(runtimeSessionId) {
18503
18874
  const k = client();
18504
18875
  const res = await k.browsers.replays.start(runtimeSessionId);
@@ -18561,6 +18932,22 @@ function failure(err) {
18561
18932
  const msg = err instanceof Error ? err.message : String(err);
18562
18933
  return { error: sanitizeVendorName(msg) };
18563
18934
  }
18935
+ function parseAgentDate(value) {
18936
+ if (!value) return null;
18937
+ const parsed = Date.parse(value);
18938
+ if (Number.isFinite(parsed)) return parsed;
18939
+ const legacyParsed = Date.parse(`${value.replace(" ", "T")}Z`);
18940
+ return Number.isFinite(legacyParsed) ? legacyParsed : null;
18941
+ }
18942
+ function replayClock(row) {
18943
+ if (!row) return null;
18944
+ const startedAtMs = parseAgentDate(row.started_at);
18945
+ return {
18946
+ replay_id: row.replay_id,
18947
+ started_at: row.started_at,
18948
+ replay_elapsed_seconds: startedAtMs == null ? null : Math.max(0, (Date.now() - startedAtMs) / 1e3)
18949
+ };
18950
+ }
18564
18951
  function replayDownloadUrl(sessionId, replayId) {
18565
18952
  return `/agent/sessions/${encodeURIComponent(sessionId)}/replays/${encodeURIComponent(replayId)}/download`;
18566
18953
  }
@@ -18671,13 +19058,15 @@ function buildBrowserAgentRoutes() {
18671
19058
  }
18672
19059
  await charge(row.id, user.id, t0);
18673
19060
  await recordAction({ sessionId: row.id, type: "screenshot", params: null, ok: true });
19061
+ const activeReplay = await getActiveReplayRow(row.id);
18674
19062
  return c.json({
18675
19063
  image_base64: shot.base64,
18676
19064
  mime_type: shot.mimeType,
18677
19065
  url: page?.url ?? null,
18678
19066
  title: page?.title ?? null,
18679
19067
  elements: page?.elements ?? [],
18680
- text: page?.text ?? null
19068
+ text: page?.text ?? null,
19069
+ replay: replayClock(activeReplay)
18681
19070
  });
18682
19071
  } catch (err) {
18683
19072
  await charge(row.id, user.id, t0);
@@ -18694,7 +19083,8 @@ function buildBrowserAgentRoutes() {
18694
19083
  const page = await readPage(row.cdp_ws_url);
18695
19084
  await charge(row.id, user.id, t0);
18696
19085
  await recordAction({ sessionId: row.id, type: "read", params: null, ok: true });
18697
- return c.json(page);
19086
+ const activeReplay = await getActiveReplayRow(row.id);
19087
+ return c.json({ ...page, replay: replayClock(activeReplay) });
18698
19088
  } catch (err) {
18699
19089
  await charge(row.id, user.id, t0);
18700
19090
  return c.json(failure(err), 502);
@@ -18723,6 +19113,27 @@ function buildBrowserAgentRoutes() {
18723
19113
  return c.json(failure(err), 502);
18724
19114
  }
18725
19115
  });
19116
+ app2.post("/sessions/:id/locate", async (c) => {
19117
+ const user = c.get("user");
19118
+ const row = await loadOpenSession(c.req.param("id"), user.id);
19119
+ if (!row) return c.json({ error: "not found" }, 404);
19120
+ const body = await c.req.json().catch(() => ({}));
19121
+ const rawTargets = Array.isArray(body.targets) ? body.targets : [];
19122
+ const targets = rawTargets.filter((value) => value && typeof value === "object").slice(0, 20).map((value) => value);
19123
+ if (!targets.length) return c.json({ error: "targets is required" }, 400);
19124
+ const t0 = Date.now();
19125
+ try {
19126
+ const result = await locatePageTargets(row.cdp_ws_url, targets);
19127
+ await charge(row.id, user.id, t0);
19128
+ await recordAction({ sessionId: row.id, type: "locate", params: { targets: targets.length }, ok: true });
19129
+ const activeReplay = await getActiveReplayRow(row.id);
19130
+ return c.json({ ...result, replay: replayClock(activeReplay) });
19131
+ } catch (err) {
19132
+ await charge(row.id, user.id, t0);
19133
+ await recordAction({ sessionId: row.id, type: "locate", params: { targets: targets.length }, ok: false, error: String(err) });
19134
+ return c.json(failure(err), 502);
19135
+ }
19136
+ });
18726
19137
  app2.post("/sessions/:id/type", async (c) => {
18727
19138
  const user = c.get("user");
18728
19139
  const row = await loadOpenSession(c.req.param("id"), user.id);