From ed9d79bc40003a48329279135d3c26d3ac6ea6c9 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Thu, 29 Jan 2026 20:04:21 +0530 Subject: [PATCH] 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 --- skills/places-aggregator/SKILL.md | 191 ++++++ skills/places-aggregator/scripts/cli.test.ts | 493 +++++++++++++++ skills/places-aggregator/scripts/cli.ts | 614 +++++++++++++++++++ 3 files changed, 1298 insertions(+) create mode 100644 skills/places-aggregator/SKILL.md create mode 100644 skills/places-aggregator/scripts/cli.test.ts create mode 100644 skills/places-aggregator/scripts/cli.ts diff --git a/skills/places-aggregator/SKILL.md b/skills/places-aggregator/SKILL.md new file mode 100644 index 000000000..e5b8e0250 --- /dev/null +++ b/skills/places-aggregator/SKILL.md @@ -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 --lng --radius +``` +Example: `--lat 40.7128 --lng -74.006 --radius 1000` + +### Circle by Place ID +```bash +--place [--radius ] +``` +Example: `--place ChIJOwg_06VPwokRYv534QaPC8g --radius 500` + +### Region +```bash +--region +``` +Example: `--region ChIJYeZuBI9YwokRjMDs_IEyCwo` (Manhattan) + +### Custom Polygon +```bash +--polygon +``` +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 # Types to include +--exclude-types # Types to exclude +--primary-types # Primary types to include +--exclude-primary-types # Primary types to exclude +``` + +## Operating Status Filter + +```bash +--status +``` +Values: `operational` (or `open`), `closed` (or `permanently_closed`), `temporarily_closed` (or `temp_closed`) + +## Price Level Filter + +```bash +--price +``` +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 ` 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 diff --git a/skills/places-aggregator/scripts/cli.test.ts b/skills/places-aggregator/scripts/cli.test.ts new file mode 100644 index 000000000..31c1b74ea --- /dev/null +++ b/skills/places-aggregator/scripts/cli.test.ts @@ -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 { + const flags: Record = {}; + 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 = { + 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 = { + "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): 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): 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"]); + }); +}); diff --git a/skills/places-aggregator/scripts/cli.ts b/skills/places-aggregator/scripts/cli.ts new file mode 100644 index 000000000..82dcee5ff --- /dev/null +++ b/skills/places-aggregator/scripts/cli.ts @@ -0,0 +1,614 @@ +#!/usr/bin/env bun +/** + * Places Aggregator CLI - Area insights from Google Places + * Usage: bun skills/places-aggregator/scripts/cli.ts [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 { + const flags: Record = {}; + 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): 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): 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 = { + 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 = { + "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): 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 { + 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 = { + 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 = { + 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) { + 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) { + 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 ' to get full details"); +} + +async function both(flags: Record) { + 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 [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 Latitude (-90 to 90) + --lng Longitude (-180 to 180) + --radius Search radius (default: 1000) + + Circle by place: + --place Center circle on place ID + --radius Search radius (default: 1000) + + Region: + --region Geographic region boundary (city, state, etc.) + + Custom polygon: + --polygon 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 Place types to include + --exclude-types Place types to exclude + --primary-types Primary types to include + --exclude-primary-types Primary types to exclude + +Operating Status: + --status Filter by status (comma-separated) + Values: operational, closed, temporarily_closed + +Price Levels: + --price 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();