feat(skills): add places-aggregator skill for Google Area Insights API
Add new skill for querying Google Places Aggregator API (areainsights.googleapis.com). Features: - count: Count places matching filters (INSIGHT_COUNT) - list: List place IDs (INSIGHT_PLACES, max 100) - both: Get count and place IDs in one request Location filters: - Circle by coordinates (--lat/--lng/--radius) - Circle by place ID (--place) - Region by place ID (--region) - Custom polygon (--polygon with GeoJSON or JSON file) Filters: - Type filters (--types, --primary-types, --exclude-types) - Operating status (--status: operational/closed/temporarily_closed) - Price levels (--price: 0-4 or named levels) - Rating (--min-rating, --max-rating: 1.0-5.0) Uses same GOOGLE_PLACES_API_KEY as goplaces skill. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5f4715acfc
commit
ed9d79bc40
191
skills/places-aggregator/SKILL.md
Normal file
191
skills/places-aggregator/SKILL.md
Normal file
@ -0,0 +1,191 @@
|
||||
---
|
||||
name: places-aggregator
|
||||
description: Query Google Places Aggregator API for area insights - count places, list place IDs by location (circle/region/polygon), type, rating, price, and operating status.
|
||||
homepage: https://developers.google.com/maps/documentation/places-aggregate
|
||||
metadata: {"moltbot":{"emoji":"📊","requires":{"bins":["bun"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
|
||||
---
|
||||
|
||||
# Places Aggregator CLI
|
||||
|
||||
Query area insights from Google Places Aggregator API. Count places or get place IDs matching location, type, rating, price, and status filters.
|
||||
|
||||
## Setup
|
||||
|
||||
Set environment variable:
|
||||
```bash
|
||||
export GOOGLE_PLACES_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
Or configure in `~/.moltbot/moltbot.json`:
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"places-aggregator": {
|
||||
"env": {
|
||||
"GOOGLE_PLACES_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### count - Count places (INSIGHT_COUNT)
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts count --lat 40.7128 --lng -74.006 --radius 1000 --types restaurant
|
||||
bun {baseDir}/scripts/cli.ts count --place "ChIJOwg_06VPwokRYv534QaPC8g" --types cafe,bar
|
||||
bun {baseDir}/scripts/cli.ts count --region "ChIJYeZuBI9YwokRjMDs_IEyCwo" --types restaurant
|
||||
bun {baseDir}/scripts/cli.ts count --polygon ./area.json --types coffee_shop
|
||||
```
|
||||
|
||||
### list - List place IDs (INSIGHT_PLACES)
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts list --lat 40.7128 --lng -74.006 --radius 500 --types coffee_shop
|
||||
bun {baseDir}/scripts/cli.ts list --lat 40.758 --lng -73.9855 --radius 300 --types restaurant --min-rating 4.5
|
||||
```
|
||||
|
||||
Note: Place IDs only returned when count <= 100.
|
||||
|
||||
### both - Get count and place IDs in one request
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts both --lat 40.75 --lng -73.98 --radius 200 --types coffee_shop
|
||||
```
|
||||
|
||||
## Location Filters (exactly one required)
|
||||
|
||||
### Circle by Coordinates
|
||||
```bash
|
||||
--lat <number> --lng <number> --radius <meters>
|
||||
```
|
||||
Example: `--lat 40.7128 --lng -74.006 --radius 1000`
|
||||
|
||||
### Circle by Place ID
|
||||
```bash
|
||||
--place <place_id> [--radius <meters>]
|
||||
```
|
||||
Example: `--place ChIJOwg_06VPwokRYv534QaPC8g --radius 500`
|
||||
|
||||
### Region
|
||||
```bash
|
||||
--region <place_id>
|
||||
```
|
||||
Example: `--region ChIJYeZuBI9YwokRjMDs_IEyCwo` (Manhattan)
|
||||
|
||||
### Custom Polygon
|
||||
```bash
|
||||
--polygon <file_or_json>
|
||||
```
|
||||
Accepts JSON file path or inline JSON. Formats supported:
|
||||
- GeoJSON: `[[lng,lat], [lng,lat], ...]`
|
||||
- Object array: `[{lat: N, lng: N}, ...]`
|
||||
- API format: `{coordinates: [{latitude: N, longitude: N}, ...]}`
|
||||
|
||||
Polygon must be counterclockwise with first and last point identical (closed).
|
||||
|
||||
Example file (`area.json`):
|
||||
```json
|
||||
[
|
||||
[-74.01, 40.71],
|
||||
[-74.00, 40.71],
|
||||
[-74.00, 40.72],
|
||||
[-74.01, 40.72],
|
||||
[-74.01, 40.71]
|
||||
]
|
||||
```
|
||||
|
||||
Example inline:
|
||||
```bash
|
||||
--polygon '[[-74.01,40.71],[-74.00,40.71],[-74.00,40.72],[-74.01,40.72],[-74.01,40.71]]'
|
||||
```
|
||||
|
||||
## Type Filters
|
||||
|
||||
At least one of `--types` or `--primary-types` required.
|
||||
|
||||
```bash
|
||||
--types <type1,type2,...> # Types to include
|
||||
--exclude-types <type1,type2> # Types to exclude
|
||||
--primary-types <type1,type2> # Primary types to include
|
||||
--exclude-primary-types <t1,t2> # Primary types to exclude
|
||||
```
|
||||
|
||||
## Operating Status Filter
|
||||
|
||||
```bash
|
||||
--status <status1,status2>
|
||||
```
|
||||
Values: `operational` (or `open`), `closed` (or `permanently_closed`), `temporarily_closed` (or `temp_closed`)
|
||||
|
||||
## Price Level Filter
|
||||
|
||||
```bash
|
||||
--price <level1,level2,...>
|
||||
```
|
||||
Values: `0`-`4` or `free`/`inexpensive`/`moderate`/`expensive`/`very_expensive`
|
||||
|
||||
| Level | Name | Symbol |
|
||||
|-------|------|--------|
|
||||
| 0 | Free | - |
|
||||
| 1 | Inexpensive | $ |
|
||||
| 2 | Moderate | $$ |
|
||||
| 3 | Expensive | $$$ |
|
||||
| 4 | Very Expensive | $$$$ |
|
||||
|
||||
## Rating Filter
|
||||
|
||||
```bash
|
||||
--min-rating <1.0-5.0> # Minimum rating
|
||||
--max-rating <1.0-5.0> # Maximum rating
|
||||
```
|
||||
|
||||
## Output Options
|
||||
|
||||
```bash
|
||||
--json # Raw JSON response
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Count coffee shops in 500m radius
|
||||
bun {baseDir}/scripts/cli.ts count --lat 40.7484 --lng -73.9857 --radius 500 --types coffee_shop
|
||||
|
||||
# List highly-rated restaurants
|
||||
bun {baseDir}/scripts/cli.ts list --lat 40.758 --lng -73.9855 --radius 300 --types restaurant --min-rating 4.5
|
||||
|
||||
# Budget-friendly cafes
|
||||
bun {baseDir}/scripts/cli.ts count --lat 51.5074 --lng -0.1278 --radius 1000 --types cafe --price 0,1,2
|
||||
|
||||
# Only operational restaurants
|
||||
bun {baseDir}/scripts/cli.ts count --lat 40.7 --lng -74.0 --radius 1000 --types restaurant --status operational
|
||||
|
||||
# Bars in Manhattan region
|
||||
bun {baseDir}/scripts/cli.ts count --region ChIJYeZuBI9YwokRjMDs_IEyCwo --types bar
|
||||
|
||||
# Custom polygon area
|
||||
bun {baseDir}/scripts/cli.ts count --polygon ./downtown.json --types restaurant,cafe
|
||||
|
||||
# Get both count and IDs
|
||||
bun {baseDir}/scripts/cli.ts both --lat 40.75 --lng -73.98 --radius 200 --types coffee_shop --json
|
||||
```
|
||||
|
||||
## Common Place Types
|
||||
|
||||
```
|
||||
restaurant, cafe, bar, coffee_shop, bakery, grocery_store, supermarket,
|
||||
gym, park, museum, hotel, hospital, pharmacy, bank, atm, gas_station,
|
||||
parking, school, university, library, movie_theater, shopping_mall
|
||||
```
|
||||
|
||||
Full list: https://developers.google.com/maps/documentation/places/web-service/place-types
|
||||
|
||||
## Notes
|
||||
|
||||
- Place IDs only returned when count <= 100
|
||||
- Use `goplaces details <place_id>` to get full place details
|
||||
- Polygon coordinates must form a closed shape (first = last point)
|
||||
- Polygon vertices must be in counterclockwise order
|
||||
- Rating values: 1.0 to 5.0
|
||||
493
skills/places-aggregator/scripts/cli.test.ts
Normal file
493
skills/places-aggregator/scripts/cli.test.ts
Normal file
@ -0,0 +1,493 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
// Test pure utility functions extracted from cli.ts
|
||||
|
||||
// Types
|
||||
interface LatLng {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface Circle {
|
||||
radius: number;
|
||||
latLng?: LatLng;
|
||||
place?: string;
|
||||
}
|
||||
|
||||
interface Region {
|
||||
place: string;
|
||||
}
|
||||
|
||||
interface Polygon {
|
||||
coordinates: LatLng[];
|
||||
}
|
||||
|
||||
interface CustomArea {
|
||||
polygon: Polygon;
|
||||
}
|
||||
|
||||
interface LocationFilter {
|
||||
circle?: Circle;
|
||||
region?: Region;
|
||||
customArea?: CustomArea;
|
||||
}
|
||||
|
||||
interface TypeFilter {
|
||||
includedTypes?: string[];
|
||||
excludedTypes?: string[];
|
||||
includedPrimaryTypes?: string[];
|
||||
excludedPrimaryTypes?: string[];
|
||||
}
|
||||
|
||||
type OperatingStatus =
|
||||
| "OPERATING_STATUS_OPERATIONAL"
|
||||
| "OPERATING_STATUS_PERMANENTLY_CLOSED"
|
||||
| "OPERATING_STATUS_TEMPORARILY_CLOSED";
|
||||
|
||||
type PriceLevel =
|
||||
| "PRICE_LEVEL_FREE"
|
||||
| "PRICE_LEVEL_INEXPENSIVE"
|
||||
| "PRICE_LEVEL_MODERATE"
|
||||
| "PRICE_LEVEL_EXPENSIVE"
|
||||
| "PRICE_LEVEL_VERY_EXPENSIVE";
|
||||
|
||||
// Utility functions from cli.ts
|
||||
function parseArgs(args: string[]): Record<string, string | boolean> {
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
const key = args[i].slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseOperatingStatus(input: string): OperatingStatus[] {
|
||||
const statusMap: Record<string, OperatingStatus> = {
|
||||
operational: "OPERATING_STATUS_OPERATIONAL",
|
||||
open: "OPERATING_STATUS_OPERATIONAL",
|
||||
closed: "OPERATING_STATUS_PERMANENTLY_CLOSED",
|
||||
permanently_closed: "OPERATING_STATUS_PERMANENTLY_CLOSED",
|
||||
temporarily_closed: "OPERATING_STATUS_TEMPORARILY_CLOSED",
|
||||
temp_closed: "OPERATING_STATUS_TEMPORARILY_CLOSED",
|
||||
};
|
||||
|
||||
return input.split(",").map((s) => {
|
||||
const key = s.trim().toLowerCase();
|
||||
const mapped = statusMap[key];
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid status: ${s}. Valid: operational, closed, temporarily_closed`);
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
function parsePriceLevels(input: string): PriceLevel[] {
|
||||
const priceMap: Record<string, PriceLevel> = {
|
||||
"0": "PRICE_LEVEL_FREE",
|
||||
free: "PRICE_LEVEL_FREE",
|
||||
"1": "PRICE_LEVEL_INEXPENSIVE",
|
||||
inexpensive: "PRICE_LEVEL_INEXPENSIVE",
|
||||
cheap: "PRICE_LEVEL_INEXPENSIVE",
|
||||
"2": "PRICE_LEVEL_MODERATE",
|
||||
moderate: "PRICE_LEVEL_MODERATE",
|
||||
"3": "PRICE_LEVEL_EXPENSIVE",
|
||||
expensive: "PRICE_LEVEL_EXPENSIVE",
|
||||
"4": "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
very_expensive: "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
};
|
||||
|
||||
return input.split(",").map((p) => {
|
||||
const key = p.trim().toLowerCase();
|
||||
const mapped = priceMap[key];
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid price level: ${p}. Valid: 0-4 or free/inexpensive/moderate/expensive/very_expensive`);
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
function buildLocationFilter(flags: Record<string, string | boolean>): LocationFilter {
|
||||
// Region
|
||||
if (flags.region) {
|
||||
return { region: { place: String(flags.region) } };
|
||||
}
|
||||
|
||||
// Circle centered on place
|
||||
if (flags.place) {
|
||||
const radius = flags.radius ? parseInt(String(flags.radius), 10) : 1000;
|
||||
const place = String(flags.place);
|
||||
const formattedPlace = place.startsWith("places/") ? place : `places/${place}`;
|
||||
return {
|
||||
circle: {
|
||||
place: formattedPlace,
|
||||
radius,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Circle with lat/lng
|
||||
if (flags.lat && flags.lng) {
|
||||
const radius = flags.radius ? parseInt(String(flags.radius), 10) : 1000;
|
||||
return {
|
||||
circle: {
|
||||
latLng: {
|
||||
latitude: parseFloat(String(flags.lat)),
|
||||
longitude: parseFloat(String(flags.lng)),
|
||||
},
|
||||
radius,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Location required: --lat/--lng/--radius (circle), --place (circle), --region, or --polygon (custom area)"
|
||||
);
|
||||
}
|
||||
|
||||
function buildTypeFilter(flags: Record<string, string | boolean>): TypeFilter {
|
||||
const filter: TypeFilter = {};
|
||||
|
||||
if (flags.types) {
|
||||
filter.includedTypes = String(flags.types).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["exclude-types"]) {
|
||||
filter.excludedTypes = String(flags["exclude-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["primary-types"]) {
|
||||
filter.includedPrimaryTypes = String(flags["primary-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["exclude-primary-types"]) {
|
||||
filter.excludedPrimaryTypes = String(flags["exclude-primary-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
|
||||
if (!filter.includedTypes && !filter.includedPrimaryTypes) {
|
||||
throw new Error("At least one of --types or --primary-types required");
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
function parsePolygonData(data: any): Polygon {
|
||||
if (Array.isArray(data)) {
|
||||
const coordinates: LatLng[] = data.map((coord: any) => {
|
||||
if (Array.isArray(coord)) {
|
||||
return { latitude: coord[1], longitude: coord[0] };
|
||||
} else {
|
||||
return {
|
||||
latitude: coord.latitude ?? coord.lat,
|
||||
longitude: coord.longitude ?? coord.lng,
|
||||
};
|
||||
}
|
||||
});
|
||||
return { coordinates };
|
||||
} else if (data.coordinates) {
|
||||
if (Array.isArray(data.coordinates[0]) && typeof data.coordinates[0][0] === "number") {
|
||||
const coordinates: LatLng[] = data.coordinates.map((coord: number[]) => ({
|
||||
latitude: coord[1],
|
||||
longitude: coord[0],
|
||||
}));
|
||||
return { coordinates };
|
||||
}
|
||||
return data as Polygon;
|
||||
}
|
||||
throw new Error("Invalid polygon format");
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
describe("parseArgs", () => {
|
||||
test("parses simple flags", () => {
|
||||
const args = ["--lat", "40.7", "--lng", "-74.0"];
|
||||
expect(parseArgs(args)).toEqual({
|
||||
lat: "40.7",
|
||||
lng: "-74.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses boolean flags", () => {
|
||||
const args = ["--json", "--lat", "40.7"];
|
||||
expect(parseArgs(args)).toEqual({
|
||||
json: true,
|
||||
lat: "40.7",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses comma-separated values", () => {
|
||||
const args = ["--types", "restaurant,cafe,bar"];
|
||||
expect(parseArgs(args)).toEqual({
|
||||
types: "restaurant,cafe,bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses hyphenated flags", () => {
|
||||
const args = ["--min-rating", "4.5", "--max-rating", "5.0"];
|
||||
expect(parseArgs(args)).toEqual({
|
||||
"min-rating": "4.5",
|
||||
"max-rating": "5.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty args", () => {
|
||||
expect(parseArgs([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOperatingStatus", () => {
|
||||
test("parses operational status", () => {
|
||||
expect(parseOperatingStatus("operational")).toEqual(["OPERATING_STATUS_OPERATIONAL"]);
|
||||
});
|
||||
|
||||
test("parses open as operational", () => {
|
||||
expect(parseOperatingStatus("open")).toEqual(["OPERATING_STATUS_OPERATIONAL"]);
|
||||
});
|
||||
|
||||
test("parses closed status", () => {
|
||||
expect(parseOperatingStatus("closed")).toEqual(["OPERATING_STATUS_PERMANENTLY_CLOSED"]);
|
||||
});
|
||||
|
||||
test("parses temporarily_closed status", () => {
|
||||
expect(parseOperatingStatus("temporarily_closed")).toEqual(["OPERATING_STATUS_TEMPORARILY_CLOSED"]);
|
||||
});
|
||||
|
||||
test("parses temp_closed alias", () => {
|
||||
expect(parseOperatingStatus("temp_closed")).toEqual(["OPERATING_STATUS_TEMPORARILY_CLOSED"]);
|
||||
});
|
||||
|
||||
test("parses multiple statuses", () => {
|
||||
expect(parseOperatingStatus("operational,closed")).toEqual([
|
||||
"OPERATING_STATUS_OPERATIONAL",
|
||||
"OPERATING_STATUS_PERMANENTLY_CLOSED",
|
||||
]);
|
||||
});
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(parseOperatingStatus("OPERATIONAL")).toEqual(["OPERATING_STATUS_OPERATIONAL"]);
|
||||
});
|
||||
|
||||
test("throws on invalid status", () => {
|
||||
expect(() => parseOperatingStatus("invalid")).toThrow("Invalid status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePriceLevels", () => {
|
||||
test("parses numeric levels", () => {
|
||||
expect(parsePriceLevels("0")).toEqual(["PRICE_LEVEL_FREE"]);
|
||||
expect(parsePriceLevels("1")).toEqual(["PRICE_LEVEL_INEXPENSIVE"]);
|
||||
expect(parsePriceLevels("2")).toEqual(["PRICE_LEVEL_MODERATE"]);
|
||||
expect(parsePriceLevels("3")).toEqual(["PRICE_LEVEL_EXPENSIVE"]);
|
||||
expect(parsePriceLevels("4")).toEqual(["PRICE_LEVEL_VERY_EXPENSIVE"]);
|
||||
});
|
||||
|
||||
test("parses named levels", () => {
|
||||
expect(parsePriceLevels("free")).toEqual(["PRICE_LEVEL_FREE"]);
|
||||
expect(parsePriceLevels("inexpensive")).toEqual(["PRICE_LEVEL_INEXPENSIVE"]);
|
||||
expect(parsePriceLevels("moderate")).toEqual(["PRICE_LEVEL_MODERATE"]);
|
||||
expect(parsePriceLevels("expensive")).toEqual(["PRICE_LEVEL_EXPENSIVE"]);
|
||||
expect(parsePriceLevels("very_expensive")).toEqual(["PRICE_LEVEL_VERY_EXPENSIVE"]);
|
||||
});
|
||||
|
||||
test("parses cheap as inexpensive", () => {
|
||||
expect(parsePriceLevels("cheap")).toEqual(["PRICE_LEVEL_INEXPENSIVE"]);
|
||||
});
|
||||
|
||||
test("parses multiple levels", () => {
|
||||
expect(parsePriceLevels("0,1,2")).toEqual([
|
||||
"PRICE_LEVEL_FREE",
|
||||
"PRICE_LEVEL_INEXPENSIVE",
|
||||
"PRICE_LEVEL_MODERATE",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles mixed formats", () => {
|
||||
expect(parsePriceLevels("free,1,moderate")).toEqual([
|
||||
"PRICE_LEVEL_FREE",
|
||||
"PRICE_LEVEL_INEXPENSIVE",
|
||||
"PRICE_LEVEL_MODERATE",
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws on invalid level", () => {
|
||||
expect(() => parsePriceLevels("5")).toThrow("Invalid price level");
|
||||
expect(() => parsePriceLevels("invalid")).toThrow("Invalid price level");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLocationFilter", () => {
|
||||
test("builds circle from lat/lng", () => {
|
||||
const flags = { lat: "40.7128", lng: "-74.006", radius: "1000" };
|
||||
const filter = buildLocationFilter(flags);
|
||||
expect(filter.circle).toBeDefined();
|
||||
expect(filter.circle?.latLng?.latitude).toBe(40.7128);
|
||||
expect(filter.circle?.latLng?.longitude).toBe(-74.006);
|
||||
expect(filter.circle?.radius).toBe(1000);
|
||||
});
|
||||
|
||||
test("uses default radius of 1000m", () => {
|
||||
const flags = { lat: "40.7128", lng: "-74.006" };
|
||||
const filter = buildLocationFilter(flags);
|
||||
expect(filter.circle?.radius).toBe(1000);
|
||||
});
|
||||
|
||||
test("builds circle from place ID", () => {
|
||||
const flags = { place: "ChIJOwg_06VPwokR", radius: "500" };
|
||||
const filter = buildLocationFilter(flags);
|
||||
expect(filter.circle?.place).toBe("places/ChIJOwg_06VPwokR");
|
||||
expect(filter.circle?.radius).toBe(500);
|
||||
});
|
||||
|
||||
test("preserves places/ prefix if already present", () => {
|
||||
const flags = { place: "places/ChIJOwg_06VPwokR" };
|
||||
const filter = buildLocationFilter(flags);
|
||||
expect(filter.circle?.place).toBe("places/ChIJOwg_06VPwokR");
|
||||
});
|
||||
|
||||
test("builds region filter", () => {
|
||||
const flags = { region: "ChIJYeZuBI9YwokR" };
|
||||
const filter = buildLocationFilter(flags);
|
||||
expect(filter.region?.place).toBe("ChIJYeZuBI9YwokR");
|
||||
});
|
||||
|
||||
test("throws when no location provided", () => {
|
||||
expect(() => buildLocationFilter({})).toThrow("Location required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTypeFilter", () => {
|
||||
test("builds filter with included types", () => {
|
||||
const flags = { types: "restaurant,cafe" };
|
||||
const filter = buildTypeFilter(flags);
|
||||
expect(filter.includedTypes).toEqual(["restaurant", "cafe"]);
|
||||
});
|
||||
|
||||
test("builds filter with primary types", () => {
|
||||
const flags = { "primary-types": "restaurant" };
|
||||
const filter = buildTypeFilter(flags);
|
||||
expect(filter.includedPrimaryTypes).toEqual(["restaurant"]);
|
||||
});
|
||||
|
||||
test("builds filter with excluded types", () => {
|
||||
const flags = { types: "restaurant", "exclude-types": "fast_food" };
|
||||
const filter = buildTypeFilter(flags);
|
||||
expect(filter.includedTypes).toEqual(["restaurant"]);
|
||||
expect(filter.excludedTypes).toEqual(["fast_food"]);
|
||||
});
|
||||
|
||||
test("trims whitespace from types", () => {
|
||||
const flags = { types: "restaurant, cafe , bar" };
|
||||
const filter = buildTypeFilter(flags);
|
||||
expect(filter.includedTypes).toEqual(["restaurant", "cafe", "bar"]);
|
||||
});
|
||||
|
||||
test("throws when no types provided", () => {
|
||||
expect(() => buildTypeFilter({})).toThrow("At least one of --types or --primary-types required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolygonData", () => {
|
||||
test("parses GeoJSON format [[lng, lat], ...]", () => {
|
||||
const data = [
|
||||
[-74.01, 40.71],
|
||||
[-74.00, 40.71],
|
||||
[-74.00, 40.72],
|
||||
[-74.01, 40.71],
|
||||
];
|
||||
const polygon = parsePolygonData(data);
|
||||
expect(polygon.coordinates).toHaveLength(4);
|
||||
expect(polygon.coordinates[0]).toEqual({ latitude: 40.71, longitude: -74.01 });
|
||||
});
|
||||
|
||||
test("parses object array [{lat, lng}, ...]", () => {
|
||||
const data = [
|
||||
{ lat: 40.71, lng: -74.01 },
|
||||
{ lat: 40.71, lng: -74.00 },
|
||||
{ lat: 40.72, lng: -74.00 },
|
||||
{ lat: 40.71, lng: -74.01 },
|
||||
];
|
||||
const polygon = parsePolygonData(data);
|
||||
expect(polygon.coordinates).toHaveLength(4);
|
||||
expect(polygon.coordinates[0]).toEqual({ latitude: 40.71, longitude: -74.01 });
|
||||
});
|
||||
|
||||
test("parses API format [{latitude, longitude}, ...]", () => {
|
||||
const data = [
|
||||
{ latitude: 40.71, longitude: -74.01 },
|
||||
{ latitude: 40.71, longitude: -74.00 },
|
||||
];
|
||||
const polygon = parsePolygonData(data);
|
||||
expect(polygon.coordinates).toHaveLength(2);
|
||||
expect(polygon.coordinates[0]).toEqual({ latitude: 40.71, longitude: -74.01 });
|
||||
});
|
||||
|
||||
test("parses nested coordinates format", () => {
|
||||
const data = {
|
||||
coordinates: [
|
||||
[-74.01, 40.71],
|
||||
[-74.00, 40.71],
|
||||
],
|
||||
};
|
||||
const polygon = parsePolygonData(data);
|
||||
expect(polygon.coordinates).toHaveLength(2);
|
||||
expect(polygon.coordinates[0]).toEqual({ latitude: 40.71, longitude: -74.01 });
|
||||
});
|
||||
|
||||
test("passes through already correct format", () => {
|
||||
const data = {
|
||||
coordinates: [
|
||||
{ latitude: 40.71, longitude: -74.01 },
|
||||
{ latitude: 40.71, longitude: -74.00 },
|
||||
],
|
||||
};
|
||||
const polygon = parsePolygonData(data);
|
||||
expect(polygon.coordinates).toEqual(data.coordinates);
|
||||
});
|
||||
|
||||
test("throws on invalid format", () => {
|
||||
expect(() => parsePolygonData({ invalid: true })).toThrow("Invalid polygon format");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI env validation", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
test("requires GOOGLE_PLACES_API_KEY", () => {
|
||||
delete process.env.GOOGLE_PLACES_API_KEY;
|
||||
expect(process.env.GOOGLE_PLACES_API_KEY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: full filter building", () => {
|
||||
test("builds complete filter from flags", () => {
|
||||
const flags = {
|
||||
lat: "40.7128",
|
||||
lng: "-74.006",
|
||||
radius: "500",
|
||||
types: "restaurant,cafe",
|
||||
"exclude-types": "fast_food",
|
||||
"min-rating": "4.0",
|
||||
};
|
||||
|
||||
const locationFilter = buildLocationFilter(flags);
|
||||
const typeFilter = buildTypeFilter(flags);
|
||||
|
||||
expect(locationFilter.circle?.latLng?.latitude).toBe(40.7128);
|
||||
expect(locationFilter.circle?.radius).toBe(500);
|
||||
expect(typeFilter.includedTypes).toEqual(["restaurant", "cafe"]);
|
||||
expect(typeFilter.excludedTypes).toEqual(["fast_food"]);
|
||||
});
|
||||
});
|
||||
614
skills/places-aggregator/scripts/cli.ts
Normal file
614
skills/places-aggregator/scripts/cli.ts
Normal file
@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Places Aggregator CLI - Area insights from Google Places
|
||||
* Usage: bun skills/places-aggregator/scripts/cli.ts <command> [options]
|
||||
*
|
||||
* Full API coverage for https://areainsights.googleapis.com/v1:computeInsights
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
|
||||
const API_KEY = process.env.GOOGLE_PLACES_API_KEY;
|
||||
const API_URL = "https://areainsights.googleapis.com/v1:computeInsights";
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("Error: GOOGLE_PLACES_API_KEY environment variable required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Types matching the API exactly
|
||||
interface LatLng {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface Circle {
|
||||
radius: number;
|
||||
latLng?: LatLng;
|
||||
place?: string;
|
||||
}
|
||||
|
||||
interface Region {
|
||||
place: string;
|
||||
}
|
||||
|
||||
interface Polygon {
|
||||
coordinates: LatLng[];
|
||||
}
|
||||
|
||||
interface CustomArea {
|
||||
polygon: Polygon;
|
||||
}
|
||||
|
||||
interface LocationFilter {
|
||||
circle?: Circle;
|
||||
region?: Region;
|
||||
customArea?: CustomArea;
|
||||
}
|
||||
|
||||
interface TypeFilter {
|
||||
includedTypes?: string[];
|
||||
excludedTypes?: string[];
|
||||
includedPrimaryTypes?: string[];
|
||||
excludedPrimaryTypes?: string[];
|
||||
}
|
||||
|
||||
interface RatingFilter {
|
||||
minRating?: number;
|
||||
maxRating?: number;
|
||||
}
|
||||
|
||||
type OperatingStatus =
|
||||
| "OPERATING_STATUS_OPERATIONAL"
|
||||
| "OPERATING_STATUS_PERMANENTLY_CLOSED"
|
||||
| "OPERATING_STATUS_TEMPORARILY_CLOSED";
|
||||
|
||||
type PriceLevel =
|
||||
| "PRICE_LEVEL_FREE"
|
||||
| "PRICE_LEVEL_INEXPENSIVE"
|
||||
| "PRICE_LEVEL_MODERATE"
|
||||
| "PRICE_LEVEL_EXPENSIVE"
|
||||
| "PRICE_LEVEL_VERY_EXPENSIVE";
|
||||
|
||||
interface Filter {
|
||||
locationFilter: LocationFilter;
|
||||
typeFilter: TypeFilter;
|
||||
operatingStatus?: OperatingStatus[];
|
||||
priceLevels?: PriceLevel[];
|
||||
ratingFilter?: RatingFilter;
|
||||
}
|
||||
|
||||
type Insight = "INSIGHT_COUNT" | "INSIGHT_PLACES";
|
||||
|
||||
interface InsightsRequest {
|
||||
insights: Insight[];
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
interface PlaceInsight {
|
||||
place: string;
|
||||
}
|
||||
|
||||
interface InsightsResponse {
|
||||
placeInsights?: PlaceInsight[];
|
||||
count?: string;
|
||||
}
|
||||
|
||||
// Parse command line
|
||||
function parseArgs(args: string[]): Record<string, string | boolean> {
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
const key = args[i].slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
// Parse polygon from file or inline JSON
|
||||
function parsePolygon(input: string): Polygon {
|
||||
let data: any;
|
||||
|
||||
// Check if it's a file path
|
||||
if (existsSync(input)) {
|
||||
const content = readFileSync(input, "utf-8");
|
||||
data = JSON.parse(content);
|
||||
} else {
|
||||
// Try parsing as inline JSON
|
||||
data = JSON.parse(input);
|
||||
}
|
||||
|
||||
// Handle different formats
|
||||
if (Array.isArray(data)) {
|
||||
// Array of [lng, lat] pairs (GeoJSON style) or {lat, lng} objects
|
||||
const coordinates: LatLng[] = data.map((coord: any) => {
|
||||
if (Array.isArray(coord)) {
|
||||
// [lng, lat] format
|
||||
return { latitude: coord[1], longitude: coord[0] };
|
||||
} else {
|
||||
// {lat, lng} or {latitude, longitude} format
|
||||
return {
|
||||
latitude: coord.latitude ?? coord.lat,
|
||||
longitude: coord.longitude ?? coord.lng,
|
||||
};
|
||||
}
|
||||
});
|
||||
return { coordinates };
|
||||
} else if (data.coordinates) {
|
||||
// Already in correct format or nested
|
||||
if (Array.isArray(data.coordinates[0]) && typeof data.coordinates[0][0] === "number") {
|
||||
// GeoJSON polygon coordinates [[lng,lat], ...]
|
||||
const coordinates: LatLng[] = data.coordinates.map((coord: number[]) => ({
|
||||
latitude: coord[1],
|
||||
longitude: coord[0],
|
||||
}));
|
||||
return { coordinates };
|
||||
}
|
||||
return data as Polygon;
|
||||
}
|
||||
|
||||
throw new Error("Invalid polygon format. Expected array of coordinates or {coordinates: [...]}");
|
||||
}
|
||||
|
||||
// Build location filter (one of: circle, region, customArea)
|
||||
function buildLocationFilter(flags: Record<string, string | boolean>): LocationFilter {
|
||||
// CustomArea with polygon
|
||||
if (flags.polygon) {
|
||||
const polygon = parsePolygon(String(flags.polygon));
|
||||
return { customArea: { polygon } };
|
||||
}
|
||||
|
||||
// Region
|
||||
if (flags.region) {
|
||||
return { region: { place: String(flags.region) } };
|
||||
}
|
||||
|
||||
// Circle centered on place
|
||||
if (flags.place) {
|
||||
const radius = flags.radius ? parseInt(String(flags.radius), 10) : 1000;
|
||||
const place = String(flags.place);
|
||||
// API expects "places/PLACE_ID" format
|
||||
const formattedPlace = place.startsWith("places/") ? place : `places/${place}`;
|
||||
return {
|
||||
circle: {
|
||||
place: formattedPlace,
|
||||
radius,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Circle with lat/lng
|
||||
if (flags.lat && flags.lng) {
|
||||
const radius = flags.radius ? parseInt(String(flags.radius), 10) : 1000;
|
||||
return {
|
||||
circle: {
|
||||
latLng: {
|
||||
latitude: parseFloat(String(flags.lat)),
|
||||
longitude: parseFloat(String(flags.lng)),
|
||||
},
|
||||
radius,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Location required: --lat/--lng/--radius (circle), --place (circle), --region, or --polygon (custom area)"
|
||||
);
|
||||
}
|
||||
|
||||
// Build type filter
|
||||
function buildTypeFilter(flags: Record<string, string | boolean>): TypeFilter {
|
||||
const filter: TypeFilter = {};
|
||||
|
||||
if (flags.types) {
|
||||
filter.includedTypes = String(flags.types).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["exclude-types"]) {
|
||||
filter.excludedTypes = String(flags["exclude-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["primary-types"]) {
|
||||
filter.includedPrimaryTypes = String(flags["primary-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
if (flags["exclude-primary-types"]) {
|
||||
filter.excludedPrimaryTypes = String(flags["exclude-primary-types"]).split(",").map((t) => t.trim());
|
||||
}
|
||||
|
||||
if (!filter.includedTypes && !filter.includedPrimaryTypes) {
|
||||
throw new Error("At least one of --types or --primary-types required");
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Parse operating status
|
||||
function parseOperatingStatus(input: string): OperatingStatus[] {
|
||||
const statusMap: Record<string, OperatingStatus> = {
|
||||
operational: "OPERATING_STATUS_OPERATIONAL",
|
||||
open: "OPERATING_STATUS_OPERATIONAL",
|
||||
closed: "OPERATING_STATUS_PERMANENTLY_CLOSED",
|
||||
permanently_closed: "OPERATING_STATUS_PERMANENTLY_CLOSED",
|
||||
temporarily_closed: "OPERATING_STATUS_TEMPORARILY_CLOSED",
|
||||
temp_closed: "OPERATING_STATUS_TEMPORARILY_CLOSED",
|
||||
};
|
||||
|
||||
return input.split(",").map((s) => {
|
||||
const key = s.trim().toLowerCase();
|
||||
const mapped = statusMap[key];
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid status: ${s}. Valid: operational, closed, temporarily_closed`);
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
// Parse price levels
|
||||
function parsePriceLevels(input: string): PriceLevel[] {
|
||||
const priceMap: Record<string, PriceLevel> = {
|
||||
"0": "PRICE_LEVEL_FREE",
|
||||
free: "PRICE_LEVEL_FREE",
|
||||
"1": "PRICE_LEVEL_INEXPENSIVE",
|
||||
inexpensive: "PRICE_LEVEL_INEXPENSIVE",
|
||||
cheap: "PRICE_LEVEL_INEXPENSIVE",
|
||||
"2": "PRICE_LEVEL_MODERATE",
|
||||
moderate: "PRICE_LEVEL_MODERATE",
|
||||
"3": "PRICE_LEVEL_EXPENSIVE",
|
||||
expensive: "PRICE_LEVEL_EXPENSIVE",
|
||||
"4": "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
very_expensive: "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||
};
|
||||
|
||||
return input.split(",").map((p) => {
|
||||
const key = p.trim().toLowerCase();
|
||||
const mapped = priceMap[key];
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid price level: ${p}. Valid: 0-4 or free/inexpensive/moderate/expensive/very_expensive`);
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
// Build full filter
|
||||
function buildFilter(flags: Record<string, string | boolean>): Filter {
|
||||
const filter: Filter = {
|
||||
locationFilter: buildLocationFilter(flags),
|
||||
typeFilter: buildTypeFilter(flags),
|
||||
};
|
||||
|
||||
// Operating status
|
||||
if (flags.status) {
|
||||
filter.operatingStatus = parseOperatingStatus(String(flags.status));
|
||||
}
|
||||
|
||||
// Price levels
|
||||
if (flags.price) {
|
||||
filter.priceLevels = parsePriceLevels(String(flags.price));
|
||||
}
|
||||
|
||||
// Rating filter
|
||||
if (flags["min-rating"] || flags["max-rating"]) {
|
||||
filter.ratingFilter = {};
|
||||
if (flags["min-rating"]) {
|
||||
const min = parseFloat(String(flags["min-rating"]));
|
||||
if (min < 1 || min > 5) throw new Error("min-rating must be between 1.0 and 5.0");
|
||||
filter.ratingFilter.minRating = min;
|
||||
}
|
||||
if (flags["max-rating"]) {
|
||||
const max = parseFloat(String(flags["max-rating"]));
|
||||
if (max < 1 || max > 5) throw new Error("max-rating must be between 1.0 and 5.0");
|
||||
filter.ratingFilter.maxRating = max;
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Make API request
|
||||
async function computeInsights(request: InsightsRequest): Promise<InsightsResponse> {
|
||||
const response = await fetch(`${API_URL}?key=${API_KEY}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API error ${response.status}: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Format location for display
|
||||
function formatLocation(loc: LocationFilter): string {
|
||||
if (loc.circle?.latLng) {
|
||||
const { latitude, longitude } = loc.circle.latLng;
|
||||
return `Circle: ${latitude.toFixed(4)}, ${longitude.toFixed(4)} (${loc.circle.radius}m)`;
|
||||
}
|
||||
if (loc.circle?.place) {
|
||||
return `Circle: ${loc.circle.place} (${loc.circle.radius}m)`;
|
||||
}
|
||||
if (loc.region) {
|
||||
return `Region: ${loc.region.place}`;
|
||||
}
|
||||
if (loc.customArea?.polygon) {
|
||||
const coords = loc.customArea.polygon.coordinates;
|
||||
return `Polygon: ${coords.length} vertices`;
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Format price level for display
|
||||
function formatPriceLevel(level: PriceLevel): string {
|
||||
const names: Record<PriceLevel, string> = {
|
||||
PRICE_LEVEL_FREE: "Free",
|
||||
PRICE_LEVEL_INEXPENSIVE: "Inexpensive ($)",
|
||||
PRICE_LEVEL_MODERATE: "Moderate ($$)",
|
||||
PRICE_LEVEL_EXPENSIVE: "Expensive ($$$)",
|
||||
PRICE_LEVEL_VERY_EXPENSIVE: "Very Expensive ($$$$)",
|
||||
};
|
||||
return names[level] || level;
|
||||
}
|
||||
|
||||
// Format operating status for display
|
||||
function formatStatus(status: OperatingStatus): string {
|
||||
const names: Record<OperatingStatus, string> = {
|
||||
OPERATING_STATUS_OPERATIONAL: "Operational",
|
||||
OPERATING_STATUS_PERMANENTLY_CLOSED: "Permanently Closed",
|
||||
OPERATING_STATUS_TEMPORARILY_CLOSED: "Temporarily Closed",
|
||||
};
|
||||
return names[status] || status;
|
||||
}
|
||||
|
||||
// Print filter summary
|
||||
function printFilterSummary(filter: Filter) {
|
||||
console.log("\nFilter:");
|
||||
console.log(` Location: ${formatLocation(filter.locationFilter)}`);
|
||||
|
||||
const types =
|
||||
filter.typeFilter.includedTypes?.join(", ") ||
|
||||
filter.typeFilter.includedPrimaryTypes?.join(", ");
|
||||
console.log(` Types: ${types}`);
|
||||
|
||||
if (filter.typeFilter.excludedTypes?.length) {
|
||||
console.log(` Excluded: ${filter.typeFilter.excludedTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
if (filter.operatingStatus?.length) {
|
||||
console.log(` Status: ${filter.operatingStatus.map(formatStatus).join(", ")}`);
|
||||
}
|
||||
|
||||
if (filter.priceLevels?.length) {
|
||||
console.log(` Price: ${filter.priceLevels.map(formatPriceLevel).join(", ")}`);
|
||||
}
|
||||
|
||||
if (filter.ratingFilter) {
|
||||
const { minRating, maxRating } = filter.ratingFilter;
|
||||
if (minRating && maxRating) {
|
||||
console.log(` Rating: ${minRating} - ${maxRating}`);
|
||||
} else if (minRating) {
|
||||
console.log(` Rating: >= ${minRating}`);
|
||||
} else if (maxRating) {
|
||||
console.log(` Rating: <= ${maxRating}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands
|
||||
|
||||
async function count(flags: Record<string, string | boolean>) {
|
||||
const request: InsightsRequest = {
|
||||
insights: ["INSIGHT_COUNT"],
|
||||
filter: buildFilter(flags),
|
||||
};
|
||||
|
||||
const result = await computeInsights(request);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const count = result.count || "0";
|
||||
console.log(`Places matching criteria: ${count}`);
|
||||
printFilterSummary(request.filter);
|
||||
}
|
||||
|
||||
async function list(flags: Record<string, string | boolean>) {
|
||||
const request: InsightsRequest = {
|
||||
insights: ["INSIGHT_PLACES"],
|
||||
filter: buildFilter(flags),
|
||||
};
|
||||
|
||||
const result = await computeInsights(request);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const places = result.placeInsights || [];
|
||||
const count = result.count || places.length.toString();
|
||||
|
||||
console.log(`Found ${count} places`);
|
||||
|
||||
if (places.length === 0) {
|
||||
console.log("\nNo place IDs returned (count may exceed 100)");
|
||||
printFilterSummary(request.filter);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nPlace IDs:");
|
||||
for (const p of places) {
|
||||
// Extract place ID from "places/PLACE_ID" format
|
||||
const placeId = p.place.replace("places/", "");
|
||||
console.log(` ${placeId}`);
|
||||
}
|
||||
|
||||
printFilterSummary(request.filter);
|
||||
console.log("\nTip: Use 'goplaces details <place_id>' to get full details");
|
||||
}
|
||||
|
||||
async function both(flags: Record<string, string | boolean>) {
|
||||
const request: InsightsRequest = {
|
||||
insights: ["INSIGHT_COUNT", "INSIGHT_PLACES"],
|
||||
filter: buildFilter(flags),
|
||||
};
|
||||
|
||||
const result = await computeInsights(request);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const places = result.placeInsights || [];
|
||||
const count = result.count || "0";
|
||||
|
||||
console.log(`Total count: ${count}`);
|
||||
console.log(`Place IDs returned: ${places.length}`);
|
||||
|
||||
if (places.length > 0) {
|
||||
console.log("\nPlace IDs:");
|
||||
for (const p of places) {
|
||||
const placeId = p.place.replace("places/", "");
|
||||
console.log(` ${placeId}`);
|
||||
}
|
||||
} else if (parseInt(count, 10) > 100) {
|
||||
console.log("\n(Place IDs only returned when count <= 100)");
|
||||
}
|
||||
|
||||
printFilterSummary(request.filter);
|
||||
}
|
||||
|
||||
// Help
|
||||
function help() {
|
||||
console.log(`
|
||||
Places Aggregator CLI - Full API Coverage
|
||||
|
||||
Usage: places-aggregator <command> [options]
|
||||
|
||||
Commands:
|
||||
count Count places matching filters (INSIGHT_COUNT)
|
||||
list List place IDs matching filters (INSIGHT_PLACES, max 100)
|
||||
both Get both count and place IDs in one request
|
||||
|
||||
Location Filters (exactly one required):
|
||||
|
||||
Circle by coordinates:
|
||||
--lat <number> Latitude (-90 to 90)
|
||||
--lng <number> Longitude (-180 to 180)
|
||||
--radius <meters> Search radius (default: 1000)
|
||||
|
||||
Circle by place:
|
||||
--place <place_id> Center circle on place ID
|
||||
--radius <meters> Search radius (default: 1000)
|
||||
|
||||
Region:
|
||||
--region <place_id> Geographic region boundary (city, state, etc.)
|
||||
|
||||
Custom polygon:
|
||||
--polygon <file_or_json> Polygon coordinates (JSON file or inline)
|
||||
Formats: [[lng,lat],...] or [{lat,lng},...]
|
||||
Must be counterclockwise, first=last point
|
||||
|
||||
Type Filters (at least one of --types or --primary-types required):
|
||||
--types <t1,t2,...> Place types to include
|
||||
--exclude-types <t1,t2> Place types to exclude
|
||||
--primary-types <t1,t2> Primary types to include
|
||||
--exclude-primary-types <t> Primary types to exclude
|
||||
|
||||
Operating Status:
|
||||
--status <s1,s2> Filter by status (comma-separated)
|
||||
Values: operational, closed, temporarily_closed
|
||||
|
||||
Price Levels:
|
||||
--price <p1,p2,...> Filter by price (comma-separated)
|
||||
Values: 0-4 or free/inexpensive/moderate/expensive/very_expensive
|
||||
|
||||
Rating:
|
||||
--min-rating <1.0-5.0> Minimum rating
|
||||
--max-rating <1.0-5.0> Maximum rating
|
||||
|
||||
Output:
|
||||
--json Output raw JSON response
|
||||
|
||||
Examples:
|
||||
|
||||
# Count coffee shops in 500m radius
|
||||
places-aggregator count --lat 40.7484 --lng -73.9857 --radius 500 --types coffee_shop
|
||||
|
||||
# List highly-rated restaurants (IDs only when <= 100)
|
||||
places-aggregator list --lat 40.758 --lng -73.9855 --radius 300 --types restaurant --min-rating 4.5
|
||||
|
||||
# Budget cafes only
|
||||
places-aggregator count --lat 51.5074 --lng -0.1278 --radius 1000 --types cafe --price 0,1,2
|
||||
|
||||
# Filter by status
|
||||
places-aggregator count --lat 40.7 --lng -74.0 --radius 1000 --types restaurant --status operational
|
||||
|
||||
# Use a region (Manhattan)
|
||||
places-aggregator count --region ChIJYeZuBI9YwokRjMDs_IEyCwo --types bar
|
||||
|
||||
# Use a polygon (from file)
|
||||
places-aggregator count --polygon ./my-area.json --types restaurant
|
||||
|
||||
# Use polygon inline (GeoJSON format: [lng, lat])
|
||||
places-aggregator count --polygon '[[-74.01,40.71],[-74.00,40.71],[-74.00,40.72],[-74.01,40.72],[-74.01,40.71]]' --types cafe
|
||||
|
||||
# Get both count and place IDs
|
||||
places-aggregator both --lat 40.75 --lng -73.98 --radius 200 --types coffee_shop
|
||||
|
||||
Common Place Types:
|
||||
restaurant, cafe, bar, coffee_shop, bakery, grocery_store, supermarket,
|
||||
gym, park, museum, hotel, hospital, pharmacy, bank, atm, gas_station,
|
||||
parking, school, university, library, movie_theater, shopping_mall
|
||||
|
||||
Full list: https://developers.google.com/maps/documentation/places/web-service/place-types
|
||||
|
||||
Environment:
|
||||
GOOGLE_PLACES_API_KEY Google Places API key (required)
|
||||
`);
|
||||
}
|
||||
|
||||
// Main
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
const flags = parseArgs(args.slice(1));
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "count":
|
||||
await count(flags);
|
||||
break;
|
||||
case "list":
|
||||
await list(flags);
|
||||
break;
|
||||
case "both":
|
||||
await both(flags);
|
||||
break;
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
case undefined:
|
||||
help();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
help();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue
Block a user