Skip to content

Commit 9603109

Browse files
cablateclaude
andauthored
feat: batch keyword scanning for local rank tracker (#68)
* chore: release v0.0.48 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add multi-keyword batch scanning to maps_local_rank_tracker Support up to 3 keywords in a single call via `keywords` array param. Single keyword via `keyword` remains backward compatible. Multi-keyword returns per-keyword metrics and grids. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ae495f3 commit 9603109

5 files changed

Lines changed: 149 additions & 88 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
8181
| `maps_explore_area` | Explore what's around a location — searches multiple place types and gets details in one call. |
8282
| `maps_plan_route` | Plan an optimized multi-stop route — uses Routes API waypoint optimization (up to 25 stops) for efficient ordering. |
8383
| `maps_compare_places` | Compare places side-by-side — searches, gets details, and optionally calculates distances. |
84-
| `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). |
84+
| `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). |
8585

8686
All tools are annotated with `readOnlyHint: true` and `destructiveHint: false` — MCP clients can auto-approve these without user confirmation.
8787

README.zh-TW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ npx @cablate/mcp-google-map --port 3000 --apikey "YOUR_API_KEY"
8181
| `maps_explore_area` | 一次呼叫探索某地周邊 — 搜尋多種地點類型並取得詳情 |
8282
| `maps_plan_route` | 規劃最佳化多站路線 — 地理編碼、最佳順序、回傳導航 |
8383
| `maps_compare_places` | 並排比較地點 — 搜尋、取得詳情,可選計算距離 |
84-
| `maps_local_rank_tracker` | 地理網格排名追蹤(類似 LocalFalcon)— 追蹤商家在不同位置的搜尋排名,回傳 ARP、ATRP、SoLV 指標 |
84+
| `maps_local_rank_tracker` | 地理網格排名追蹤(類似 LocalFalcon)— 支援最多 3 個關鍵字批量掃描,回傳 ARP、ATRP、SoLV 指標 |
8585

8686
所有工具標註 `readOnlyHint: true``destructiveHint: false` — MCP 客戶端可自動核准,無需使用者確認。
8787

skills/google-maps/references/tools-api.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,21 +398,30 @@ exec maps_compare_places '{"query": "ramen near Shibuya", "limit": 3}'
398398

399399
## maps_local_rank_tracker (composite)
400400

401-
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.
401+
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.
402402

403403
```bash
404+
# Single keyword
404405
exec maps_local_rank_tracker '{"keyword":"dentist","placeId":"ChIJ...","center":{"latitude":25.033,"longitude":121.564}}'
406+
407+
# Multi-keyword batch scan
408+
exec maps_local_rank_tracker '{"keywords":["dentist","dental clinic","teeth cleaning"],"placeId":"ChIJ...","center":{"latitude":25.033,"longitude":121.564}}'
405409
```
406410

407411
| Param | Type | Required | Description |
408412
|-------|------|----------|-------------|
409-
| keyword | string | yes | Search keyword to track (e.g., "dentist", "coffee shop") |
413+
| keyword | string | no* | Single search keyword (e.g., "dentist"). Use `keywords` for multi-keyword. |
414+
| keywords | string[] | no* | Array of 1-3 keywords for batch scanning. Overrides `keyword`. |
410415
| placeId | string | yes | Target business place_id |
411416
| center | object | yes | `{ latitude, longitude }` — grid center coordinate |
412417
| gridSize | number | no | Grid dimension (3–7, default: 3 → 3×3 = 9 points) |
413418
| gridSpacing | number | no | Distance between points in meters (100–10000, default: 1000) |
414419

415-
**Returns**: `target`, `grid_size`, `keyword`, `metrics` (ARP, ATRP, SoLV, found_in), `grid[]` (row, col, lat, lng, rank, top3)
420+
*Either `keyword` or `keywords` must be provided.
421+
422+
**Returns (single keyword)**: `target`, `grid_size`, `keyword`, `metrics` (ARP, ATRP, SoLV, found_in), `grid[]` (row, col, lat, lng, rank, top3)
423+
424+
**Returns (multi-keyword)**: `target`, `grid_size`, `keywords[]` — each with `keyword`, `metrics`, `grid[]`
416425

417426
**Metrics**:
418427
- **ARP** (Average Ranked Position) — average rank across points where the business was found

src/services/PlacesSearcher.ts

Lines changed: 112 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ export class PlacesSearcher {
655655
}
656656

657657
async localRankTracker(params: {
658-
keyword: string;
658+
keywords: string[];
659659
placeId: string;
660660
center: { latitude: number; longitude: number };
661661
gridSize?: number;
@@ -685,97 +685,52 @@ export class PlacesSearcher {
685685
}
686686
}
687687

688-
// Search from each grid point (concurrency-limited)
689-
const concurrency = 5;
690-
const gridResults: Array<{
691-
row: number;
692-
col: number;
693-
lat: number;
694-
lng: number;
695-
rank: number | null;
696-
top3: string[];
697-
}> = [];
698-
699-
const searchOne = async (point: (typeof gridPoints)[0]) => {
700-
try {
701-
const places = await this.newPlacesService.searchText({
702-
textQuery: params.keyword,
703-
locationBias: { lat: point.lat, lng: point.lng, radius: spacingMeters / 2 },
704-
maxResultCount: 20,
705-
});
706-
707-
const rank = places.findIndex((p: any) => p.place_id === params.placeId);
708-
const top3 = places.slice(0, 3).map((p: any) => p.name || "");
709-
710-
return {
711-
row: point.row,
712-
col: point.col,
713-
lat: Math.round(point.lat * 1e6) / 1e6,
714-
lng: Math.round(point.lng * 1e6) / 1e6,
715-
rank: rank >= 0 ? rank + 1 : null,
716-
top3,
717-
};
718-
} catch {
719-
return {
720-
row: point.row,
721-
col: point.col,
722-
lat: Math.round(point.lat * 1e6) / 1e6,
723-
lng: Math.round(point.lng * 1e6) / 1e6,
724-
rank: null,
725-
top3: [] as string[],
726-
};
688+
// Get target business name
689+
let targetName = "";
690+
try {
691+
const details = await this.getPlaceDetails(params.placeId);
692+
if (details.success && details.data) {
693+
targetName = details.data.name;
727694
}
728-
};
729-
730-
// Execute with concurrency limit
731-
for (let i = 0; i < gridPoints.length; i += concurrency) {
732-
const batch = gridPoints.slice(i, i + concurrency);
733-
const results = await Promise.all(batch.map(searchOne));
734-
gridResults.push(...results);
695+
} catch {
696+
// ignore
735697
}
736698

737-
// Calculate metrics
738-
const rankedPoints = gridResults.filter((r) => r.rank !== null);
739-
const totalPoints = gridResults.length;
740-
const inTop3 = rankedPoints.filter((r) => r.rank! <= 3).length;
741-
742-
const arp =
743-
rankedPoints.length > 0
744-
? Math.round((rankedPoints.reduce((sum, r) => sum + r.rank!, 0) / rankedPoints.length) * 10) / 10
745-
: null;
746-
747-
const atrp = Math.round((gridResults.reduce((sum, r) => sum + (r.rank ?? 21), 0) / totalPoints) * 10) / 10;
748-
749-
const solv = Math.round((inTop3 / totalPoints) * 1000) / 10;
699+
// Scan each keyword across the grid
700+
const keywordResults = [];
701+
for (const keyword of params.keywords) {
702+
const gridResults = await this.scanKeywordGrid(keyword, params.placeId, gridPoints, spacingMeters);
703+
keywordResults.push({ keyword, ...gridResults });
704+
}
750705

751-
// Get target business name from first found result or place details
752-
let targetName = "";
753-
const foundPoint = gridResults.find((r) => r.rank !== null);
754-
if (foundPoint) {
755-
try {
756-
const details = await this.getPlaceDetails(params.placeId);
757-
if (details.success && details.data) {
758-
targetName = details.data.name;
759-
}
760-
} catch {
761-
// ignore
762-
}
706+
// Single keyword: flat response (backward compatible)
707+
if (keywordResults.length === 1) {
708+
const kr = keywordResults[0];
709+
return {
710+
success: true,
711+
data: {
712+
target: { name: targetName, place_id: params.placeId },
713+
grid_size: `${gridSize}x${gridSize}`,
714+
grid_spacing_m: spacingMeters,
715+
keyword: kr.keyword,
716+
metrics: kr.metrics,
717+
grid: kr.grid,
718+
},
719+
};
763720
}
764721

722+
// Multi-keyword: array of results
765723
return {
766724
success: true,
767725
data: {
768726
target: { name: targetName, place_id: params.placeId },
769727
grid_size: `${gridSize}x${gridSize}`,
770728
grid_spacing_m: spacingMeters,
771-
keyword: params.keyword,
772-
metrics: {
773-
arp,
774-
atrp,
775-
solv,
776-
found_in: `${rankedPoints.length}/${totalPoints}`,
777-
},
778-
grid: gridResults,
729+
keywords: keywordResults.map((kr) => ({
730+
keyword: kr.keyword,
731+
metrics: kr.metrics,
732+
grid: kr.grid,
733+
})),
779734
},
780735
};
781736
} catch (error) {
@@ -785,4 +740,81 @@ export class PlacesSearcher {
785740
};
786741
}
787742
}
743+
744+
private async scanKeywordGrid(
745+
keyword: string,
746+
placeId: string,
747+
gridPoints: Array<{ row: number; col: number; lat: number; lng: number }>,
748+
spacingMeters: number
749+
) {
750+
const concurrency = 5;
751+
const gridResults: Array<{
752+
row: number;
753+
col: number;
754+
lat: number;
755+
lng: number;
756+
rank: number | null;
757+
top3: string[];
758+
}> = [];
759+
760+
const searchOne = async (point: (typeof gridPoints)[0]) => {
761+
try {
762+
const places = await this.newPlacesService.searchText({
763+
textQuery: keyword,
764+
locationBias: { lat: point.lat, lng: point.lng, radius: spacingMeters / 2 },
765+
maxResultCount: 20,
766+
});
767+
768+
const rank = places.findIndex((p: any) => p.place_id === placeId);
769+
const top3 = places.slice(0, 3).map((p: any) => p.name || "");
770+
771+
return {
772+
row: point.row,
773+
col: point.col,
774+
lat: Math.round(point.lat * 1e6) / 1e6,
775+
lng: Math.round(point.lng * 1e6) / 1e6,
776+
rank: rank >= 0 ? rank + 1 : null,
777+
top3,
778+
};
779+
} catch {
780+
return {
781+
row: point.row,
782+
col: point.col,
783+
lat: Math.round(point.lat * 1e6) / 1e6,
784+
lng: Math.round(point.lng * 1e6) / 1e6,
785+
rank: null,
786+
top3: [] as string[],
787+
};
788+
}
789+
};
790+
791+
for (let i = 0; i < gridPoints.length; i += concurrency) {
792+
const batch = gridPoints.slice(i, i + concurrency);
793+
const results = await Promise.all(batch.map(searchOne));
794+
gridResults.push(...results);
795+
}
796+
797+
const rankedPoints = gridResults.filter((r) => r.rank !== null);
798+
const totalPoints = gridResults.length;
799+
const inTop3 = rankedPoints.filter((r) => r.rank! <= 3).length;
800+
801+
const arp =
802+
rankedPoints.length > 0
803+
? Math.round((rankedPoints.reduce((sum, r) => sum + r.rank!, 0) / rankedPoints.length) * 10) / 10
804+
: null;
805+
806+
const atrp = Math.round((gridResults.reduce((sum, r) => sum + (r.rank ?? 21), 0) / totalPoints) * 10) / 10;
807+
808+
const solv = Math.round((inTop3 / totalPoints) * 1000) / 10;
809+
810+
return {
811+
metrics: {
812+
arp,
813+
atrp,
814+
solv,
815+
found_in: `${rankedPoints.length}/${totalPoints}`,
816+
},
817+
grid: gridResults,
818+
};
819+
}
788820
}

src/tools/maps/localRankTracker.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@ import { getCurrentApiKey } from "../../utils/requestContext.js";
44

55
const NAME = "maps_local_rank_tracker";
66
const DESCRIPTION =
7-
"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.";
7+
"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.";
88

99
const SCHEMA = {
10-
keyword: z.string().describe("Search keyword to track ranking for (e.g., 'dentist', 'coffee shop', 'pizza')"),
10+
keyword: z
11+
.string()
12+
.optional()
13+
.describe("Single search keyword (e.g., 'dentist'). Use 'keywords' for multi-keyword scanning."),
14+
keywords: z
15+
.array(z.string())
16+
.min(1)
17+
.max(3)
18+
.optional()
19+
.describe(
20+
"Array of 1-3 keywords to scan (e.g., ['dentist', 'dental clinic', 'teeth cleaning']). Overrides 'keyword'."
21+
),
1122
placeId: z.string().describe("Google Maps place_id of the target business to track"),
1223
center: z
1324
.object({
@@ -34,9 +45,18 @@ export type LocalRankTrackerParams = z.infer<z.ZodObject<typeof SCHEMA>>;
3445

3546
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
3647
try {
48+
// Resolve keywords: keywords array takes priority, fallback to keyword string
49+
const resolvedKeywords: string[] = params.keywords || (params.keyword ? [params.keyword] : []);
50+
if (resolvedKeywords.length === 0) {
51+
return {
52+
content: [{ type: "text", text: "Either 'keyword' or 'keywords' must be provided." }],
53+
isError: true,
54+
};
55+
}
56+
3757
const apiKey = getCurrentApiKey();
3858
const placesSearcher = new PlacesSearcher(apiKey);
39-
const result = await placesSearcher.localRankTracker(params);
59+
const result = await placesSearcher.localRankTracker({ ...params, keywords: resolvedKeywords });
4060

4161
if (!result.success) {
4262
return {

0 commit comments

Comments
 (0)