diff --git a/README.md b/README.md index 4d447c0..81f0faa 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup | `maps_explore_area` | Explore what's around a location — searches multiple place types and gets details in one call. | | `maps_plan_route` | Plan an optimized multi-stop route — uses Routes API waypoint optimization (up to 25 stops) for efficient ordering. | | `maps_compare_places` | Compare places side-by-side — searches, gets details, and optionally calculates distances. | -| `maps_local_rank_tracker` | Track a business's local search ranking across a geographic grid — like LocalFalcon. Returns rank at each point, top-3 competitors, and metrics (ARP, ATRP, SoLV). | +| `maps_local_rank_tracker` | Track a business's local search ranking across a geographic grid — like LocalFalcon. Supports up to 3 keywords for batch scanning. Returns rank at each point, top-3 competitors, and metrics (ARP, ATRP, SoLV). | All tools are annotated with `readOnlyHint: true` and `destructiveHint: false` — MCP clients can auto-approve these without user confirmation. diff --git a/README.zh-TW.md b/README.zh-TW.md index 7301f54..1de08d1 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -81,7 +81,7 @@ npx @cablate/mcp-google-map --port 3000 --apikey "YOUR_API_KEY" | `maps_explore_area` | 一次呼叫探索某地周邊 — 搜尋多種地點類型並取得詳情 | | `maps_plan_route` | 規劃最佳化多站路線 — 地理編碼、最佳順序、回傳導航 | | `maps_compare_places` | 並排比較地點 — 搜尋、取得詳情,可選計算距離 | -| `maps_local_rank_tracker` | 地理網格排名追蹤(類似 LocalFalcon)— 追蹤商家在不同位置的搜尋排名,回傳 ARP、ATRP、SoLV 指標 | +| `maps_local_rank_tracker` | 地理網格排名追蹤(類似 LocalFalcon)— 支援最多 3 個關鍵字批量掃描,回傳 ARP、ATRP、SoLV 指標 | 所有工具標註 `readOnlyHint: true` 和 `destructiveHint: false` — MCP 客戶端可自動核准,無需使用者確認。 diff --git a/package.json b/package.json index 0470ecf..2c78c72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cablate/mcp-google-map", - "version": "0.0.47", + "version": "0.0.48", "mcpName": "io.github.cablate/google-map", "description": "18 Google Maps tools for AI agents — geocode, search, directions, weather, air quality, local rank tracking, map images via MCP server or standalone CLI", "type": "module", diff --git a/skills/google-maps/references/tools-api.md b/skills/google-maps/references/tools-api.md index 6333587..f7c9cbf 100644 --- a/skills/google-maps/references/tools-api.md +++ b/skills/google-maps/references/tools-api.md @@ -398,21 +398,30 @@ exec maps_compare_places '{"query": "ramen near Shibuya", "limit": 3}' ## maps_local_rank_tracker (composite) -Track a business's local search ranking across a geographic grid (like LocalFalcon). Searches the same keyword from multiple coordinates to see how rank varies by location. +Track a business's local search ranking across a geographic grid (like LocalFalcon). Searches the same keyword(s) from multiple coordinates to see how rank varies by location. Supports up to 3 keywords for batch scanning. ```bash +# Single keyword exec maps_local_rank_tracker '{"keyword":"dentist","placeId":"ChIJ...","center":{"latitude":25.033,"longitude":121.564}}' + +# Multi-keyword batch scan +exec maps_local_rank_tracker '{"keywords":["dentist","dental clinic","teeth cleaning"],"placeId":"ChIJ...","center":{"latitude":25.033,"longitude":121.564}}' ``` | Param | Type | Required | Description | |-------|------|----------|-------------| -| keyword | string | yes | Search keyword to track (e.g., "dentist", "coffee shop") | +| keyword | string | no* | Single search keyword (e.g., "dentist"). Use `keywords` for multi-keyword. | +| keywords | string[] | no* | Array of 1-3 keywords for batch scanning. Overrides `keyword`. | | placeId | string | yes | Target business place_id | | center | object | yes | `{ latitude, longitude }` — grid center coordinate | | gridSize | number | no | Grid dimension (3–7, default: 3 → 3×3 = 9 points) | | gridSpacing | number | no | Distance between points in meters (100–10000, default: 1000) | -**Returns**: `target`, `grid_size`, `keyword`, `metrics` (ARP, ATRP, SoLV, found_in), `grid[]` (row, col, lat, lng, rank, top3) +*Either `keyword` or `keywords` must be provided. + +**Returns (single keyword)**: `target`, `grid_size`, `keyword`, `metrics` (ARP, ATRP, SoLV, found_in), `grid[]` (row, col, lat, lng, rank, top3) + +**Returns (multi-keyword)**: `target`, `grid_size`, `keywords[]` — each with `keyword`, `metrics`, `grid[]` **Metrics**: - **ARP** (Average Ranked Position) — average rank across points where the business was found diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index 743e4a0..e91b4de 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -655,7 +655,7 @@ export class PlacesSearcher { } async localRankTracker(params: { - keyword: string; + keywords: string[]; placeId: string; center: { latitude: number; longitude: number }; gridSize?: number; @@ -685,97 +685,52 @@ export class PlacesSearcher { } } - // Search from each grid point (concurrency-limited) - const concurrency = 5; - const gridResults: Array<{ - row: number; - col: number; - lat: number; - lng: number; - rank: number | null; - top3: string[]; - }> = []; - - const searchOne = async (point: (typeof gridPoints)[0]) => { - try { - const places = await this.newPlacesService.searchText({ - textQuery: params.keyword, - locationBias: { lat: point.lat, lng: point.lng, radius: spacingMeters / 2 }, - maxResultCount: 20, - }); - - const rank = places.findIndex((p: any) => p.place_id === params.placeId); - const top3 = places.slice(0, 3).map((p: any) => p.name || ""); - - return { - row: point.row, - col: point.col, - lat: Math.round(point.lat * 1e6) / 1e6, - lng: Math.round(point.lng * 1e6) / 1e6, - rank: rank >= 0 ? rank + 1 : null, - top3, - }; - } catch { - return { - row: point.row, - col: point.col, - lat: Math.round(point.lat * 1e6) / 1e6, - lng: Math.round(point.lng * 1e6) / 1e6, - rank: null, - top3: [] as string[], - }; + // Get target business name + let targetName = ""; + try { + const details = await this.getPlaceDetails(params.placeId); + if (details.success && details.data) { + targetName = details.data.name; } - }; - - // Execute with concurrency limit - for (let i = 0; i < gridPoints.length; i += concurrency) { - const batch = gridPoints.slice(i, i + concurrency); - const results = await Promise.all(batch.map(searchOne)); - gridResults.push(...results); + } catch { + // ignore } - // Calculate metrics - const rankedPoints = gridResults.filter((r) => r.rank !== null); - const totalPoints = gridResults.length; - const inTop3 = rankedPoints.filter((r) => r.rank! <= 3).length; - - const arp = - rankedPoints.length > 0 - ? Math.round((rankedPoints.reduce((sum, r) => sum + r.rank!, 0) / rankedPoints.length) * 10) / 10 - : null; - - const atrp = Math.round((gridResults.reduce((sum, r) => sum + (r.rank ?? 21), 0) / totalPoints) * 10) / 10; - - const solv = Math.round((inTop3 / totalPoints) * 1000) / 10; + // Scan each keyword across the grid + const keywordResults = []; + for (const keyword of params.keywords) { + const gridResults = await this.scanKeywordGrid(keyword, params.placeId, gridPoints, spacingMeters); + keywordResults.push({ keyword, ...gridResults }); + } - // Get target business name from first found result or place details - let targetName = ""; - const foundPoint = gridResults.find((r) => r.rank !== null); - if (foundPoint) { - try { - const details = await this.getPlaceDetails(params.placeId); - if (details.success && details.data) { - targetName = details.data.name; - } - } catch { - // ignore - } + // Single keyword: flat response (backward compatible) + if (keywordResults.length === 1) { + const kr = keywordResults[0]; + return { + success: true, + data: { + target: { name: targetName, place_id: params.placeId }, + grid_size: `${gridSize}x${gridSize}`, + grid_spacing_m: spacingMeters, + keyword: kr.keyword, + metrics: kr.metrics, + grid: kr.grid, + }, + }; } + // Multi-keyword: array of results return { success: true, data: { target: { name: targetName, place_id: params.placeId }, grid_size: `${gridSize}x${gridSize}`, grid_spacing_m: spacingMeters, - keyword: params.keyword, - metrics: { - arp, - atrp, - solv, - found_in: `${rankedPoints.length}/${totalPoints}`, - }, - grid: gridResults, + keywords: keywordResults.map((kr) => ({ + keyword: kr.keyword, + metrics: kr.metrics, + grid: kr.grid, + })), }, }; } catch (error) { @@ -785,4 +740,81 @@ export class PlacesSearcher { }; } } + + private async scanKeywordGrid( + keyword: string, + placeId: string, + gridPoints: Array<{ row: number; col: number; lat: number; lng: number }>, + spacingMeters: number + ) { + const concurrency = 5; + const gridResults: Array<{ + row: number; + col: number; + lat: number; + lng: number; + rank: number | null; + top3: string[]; + }> = []; + + const searchOne = async (point: (typeof gridPoints)[0]) => { + try { + const places = await this.newPlacesService.searchText({ + textQuery: keyword, + locationBias: { lat: point.lat, lng: point.lng, radius: spacingMeters / 2 }, + maxResultCount: 20, + }); + + const rank = places.findIndex((p: any) => p.place_id === placeId); + const top3 = places.slice(0, 3).map((p: any) => p.name || ""); + + return { + row: point.row, + col: point.col, + lat: Math.round(point.lat * 1e6) / 1e6, + lng: Math.round(point.lng * 1e6) / 1e6, + rank: rank >= 0 ? rank + 1 : null, + top3, + }; + } catch { + return { + row: point.row, + col: point.col, + lat: Math.round(point.lat * 1e6) / 1e6, + lng: Math.round(point.lng * 1e6) / 1e6, + rank: null, + top3: [] as string[], + }; + } + }; + + for (let i = 0; i < gridPoints.length; i += concurrency) { + const batch = gridPoints.slice(i, i + concurrency); + const results = await Promise.all(batch.map(searchOne)); + gridResults.push(...results); + } + + const rankedPoints = gridResults.filter((r) => r.rank !== null); + const totalPoints = gridResults.length; + const inTop3 = rankedPoints.filter((r) => r.rank! <= 3).length; + + const arp = + rankedPoints.length > 0 + ? Math.round((rankedPoints.reduce((sum, r) => sum + r.rank!, 0) / rankedPoints.length) * 10) / 10 + : null; + + const atrp = Math.round((gridResults.reduce((sum, r) => sum + (r.rank ?? 21), 0) / totalPoints) * 10) / 10; + + const solv = Math.round((inTop3 / totalPoints) * 1000) / 10; + + return { + metrics: { + arp, + atrp, + solv, + found_in: `${rankedPoints.length}/${totalPoints}`, + }, + grid: gridResults, + }; + } } diff --git a/src/tools/maps/localRankTracker.ts b/src/tools/maps/localRankTracker.ts index e96c91e..41c0781 100644 --- a/src/tools/maps/localRankTracker.ts +++ b/src/tools/maps/localRankTracker.ts @@ -4,10 +4,21 @@ import { getCurrentApiKey } from "../../utils/requestContext.js"; const NAME = "maps_local_rank_tracker"; const DESCRIPTION = - "Track a business's local search ranking across a geographic grid (like LocalFalcon). Searches the same keyword from multiple coordinates around a center point to see how rank varies by location. Returns rank at each grid point, top-3 competitors per point, and summary metrics (ARP, ATRP, SoLV). Useful for local SEO analysis."; + "Track a business's local search ranking across a geographic grid (like LocalFalcon). Searches the same keyword(s) from multiple coordinates around a center point to see how rank varies by location. Supports up to 3 keywords for batch scanning. Returns rank at each grid point, top-3 competitors per point, and summary metrics (ARP, ATRP, SoLV). Useful for local SEO analysis."; const SCHEMA = { - keyword: z.string().describe("Search keyword to track ranking for (e.g., 'dentist', 'coffee shop', 'pizza')"), + keyword: z + .string() + .optional() + .describe("Single search keyword (e.g., 'dentist'). Use 'keywords' for multi-keyword scanning."), + keywords: z + .array(z.string()) + .min(1) + .max(3) + .optional() + .describe( + "Array of 1-3 keywords to scan (e.g., ['dentist', 'dental clinic', 'teeth cleaning']). Overrides 'keyword'." + ), placeId: z.string().describe("Google Maps place_id of the target business to track"), center: z .object({ @@ -34,9 +45,18 @@ export type LocalRankTrackerParams = z.infer>; async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> { try { + // Resolve keywords: keywords array takes priority, fallback to keyword string + const resolvedKeywords: string[] = params.keywords || (params.keyword ? [params.keyword] : []); + if (resolvedKeywords.length === 0) { + return { + content: [{ type: "text", text: "Either 'keyword' or 'keywords' must be provided." }], + isError: true, + }; + } + const apiKey = getCurrentApiKey(); const placesSearcher = new PlacesSearcher(apiKey); - const result = await placesSearcher.localRankTracker(params); + const result = await placesSearcher.localRankTracker({ ...params, keywords: resolvedKeywords }); if (!result.success) { return {