Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 客戶端可自動核准,無需使用者確認。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 12 additions & 3 deletions skills/google-maps/references/tools-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 112 additions & 80 deletions src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@ export class PlacesSearcher {
}

async localRankTracker(params: {
keyword: string;
keywords: string[];
placeId: string;
center: { latitude: number; longitude: number };
gridSize?: number;
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
};
}
}
26 changes: 23 additions & 3 deletions src/tools/maps/localRankTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -34,9 +45,18 @@ export type LocalRankTrackerParams = z.infer<z.ZodObject<typeof SCHEMA>>;

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 {
Expand Down
Loading