# Gmaps

> Use when the user asks to find local businesses, get directions or travel time between places, or plan a multi-stop visiting order. Keyless Google Maps via CDP: structured business results, live directions in any travel mode, and a best-effort fastest route order. Requires browser-harness-js on PATH and a Chromium browser with remote debugging.

- Skill: `ryan-brosas/gmaps` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ryan-brosas/gmaps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ryan-brosas/gmaps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ryan-brosas (https://skillmd.com/u/ryan-brosas)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ryan-brosas/gmaps

---


# gmaps, Google Maps via CDP (search, directions, best-effort TSP)

Free, keyless access to Google Maps through CDP, the same data the metered **Google Places** and **Directions** APIs sell, sourced directly from the rendered page. No API key, no quota, no `jq`. Three modes:

- **search** (default), local business results for a query.
- **`--route`**, real driving directions (total time + distance, current traffic) for an *ordered* list of places.
- **`--optimize`**, best-effort fastest *visiting order* (open-path TSP, fixed start = first place).

Every call opens its own background tab and WebSocket session, safe for parallel use.

## Core Principle

Free, keyless access to Google Maps through CDP, the same data the metered Google Places and Directions APIs sell, sourced directly from the rendered page in your own browser. No API key, no quota, no `jq`.

## When to Use / NOT

**Use**, when the user asks to find local businesses, get directions/time between places (by any mode), or plan a multi-stop route order.

**NOT**, bulk harvest (this is per-query use: one query, one route of ≤25 places, or one TSP of ≤12 places). when per-leg times for an ordered multi-stop route are needed (Maps shows only the total, open `url` for turn-by-turn). when predictive future-departure times are needed (the page gives current traffic, not a traffic model).

## Workflow

1. Search: `gmaps "<query>" [count] [--json]`, local business results, feed scrolled to load more.
2. Directions: `gmaps --route "<p0>" "<p1>" … [--mode M] [--json]`, real time + distance for the given order (up to 25 places).
3. Ordering: `gmaps --optimize "<p0>" … [--mode M] [--json]`, best-effort fastest visiting order (open-path TSP, fixed start, ≤12 places), then one real directions call for that order.
4. Parallelize independent calls, each opens its own background tab.

## Commands

### Search (default)

```bash
gmaps "coffee shops in Austin TX"            # up to 10 results, pretty
gmaps "sushi near Times Square" 5            # 5 results
gmaps "pharmacy open now Berlin" 20          # 20 (scrolls the feed)
gmaps --json "coffee shops in Austin TX" 5   # raw JSON
```

| Arg | Meaning |
|------|---------|
| `--json` | Emit raw JSON instead of pretty-printed text |
| `<query>` | A Google Maps search, e.g. `coffee shops in Austin TX` |
| `[count]` | Number of results (default 10, capped at 30, the feed is scrolled to load more) |

### Directions (`--route`)

```bash
gmaps --route "Austin, TX" "Houston, TX"                       # real time + distance (driving)
gmaps --route --mode transit "London, UK" "Paris, France"     # by travel mode
gmaps --route "Austin, TX" "Houston, TX" "Dallas, TX"           # ordered multi-stop, total
gmaps --route --json --mode cycling "Austin, TX" "Houston, TX" # raw JSON
```

Real directions for the **given order** in a travel `--mode` (default `driving`). Reads the total time + distance (current traffic) and the resolved waypoint names. The first place is the origin. Up to 25 places. Per-leg times are not in the collapsed route summary (Maps shows only the total), open `url` for the full turn-by-turn.

### Best-effort order (`--optimize`)

```bash
gmaps --optimize "Austin, TX" "Houston, TX" "Dallas, TX" "San Antonio, TX"
gmaps --optimize --mode transit "Austin, TX" "Houston, TX" "Dallas, TX"
gmaps --optimize --json "Austin, TX" "Houston, TX" "Dallas, TX"
```

Fastest *visiting order* as an **open-path TSP with a fixed start** (the first place). The first place is where you start. the rest are ordered to minimize total straight-line distance, then **one real directions call** is made for that order (in the chosen `--mode`, default `driving`), so the reported time/distance is real even though the *order* is a straight-line estimate.

- Each place is resolved to exact lat/lng in parallel (background tabs poll the resolved place URL for its `!3d!4d` coords), **N lookups**, not N².
- Edges are **virtualized** as straight-line (haversine) distance; each leg also reports its compass bearing. The travel `--mode` affects only the final real directions call, not the TSP ordering.
- The TSP is solved exactly (Held-Karp) for up to **12 places**.
- Total browser calls: **N + 1** (N place resolutions + 1 directions render).

### Travel modes (`--mode`)

| `--mode` | What it returns |
|------|---------|
| `driving` *(default)* | time + distance + via + tolls (the usual Maps route) |
| `transit` | time of the best bus/train/ferry/subway option (no distance/via) |
| `walking` | time + distance (durations render in hours, e.g. `62 hr`) |
| `cycling` | time + distance (may include a ferry/car-transport note) |
| `flights` | nonstop flight time only (best-effort, the flight card is outside the route panel) |
| `best` | whatever Maps' "Best" tab picks (driving for most routes; transit/flight where those are faster) |

- **No `motorcycle` or `ferry` mode.** Maps' top-level tabs are the six above; motorcycling routes via `driving`, and ferries appear as segments within `driving`/`transit` routes. (A dedicated two-wheeler tab does not appear in Maps for Vietnam or the US, only some regions/countries.)
- The page loads in `best`; for any other mode `gmaps` clicks that travel-mode tab. An unavailable mode (e.g. `cycling` for an island route) reports a clean error.
- Applies to both `--route` and `--optimize`.

### Parallel use

Each call opens its own background tab, safe to run concurrently:

```bash
gmaps "coffee Austin" 5 &  gmaps "coffee Seattle" 5 &  wait
gmaps --route "Austin, TX" "Houston, TX" &  gmaps --route "Dallas, TX" "San Antonio, TX" &  wait
```

## Result shape

### Search, pretty (default)
```
coffee shops in Austin TX  ·  5 results

1. Jo's Coffee – South Congress   ★4.4 (1972)   $1–10   Coffee shop
   1300 S Congress Ave   ·   Open · Closes 7 PM
   30.2510458, -97.7493717   ·   ChIJ1S_Bov20RIYRe7MiR8
   https://www.google.com/maps/place/Jo%27s+Coffee...
```

### Search, `--json`
```json
{
  "query": "coffee shops in Austin TX",
  "count": 5,
  "results": [
    {
      "name": "Jo's Coffee – South Congress",
      "rating": 4.4,
      "review_count": 1972,
      "price": "$1–10",
      "category": "Coffee shop",
      "address": "1300 S Congress Ave",
      "hours": "Open · Closes 7 PM",
      "lat": 30.2510458,
      "lng": -97.7493717,
      "place_id": "ChIJ1S_Bov20RIYRe7MiR8",
      "url": "https://www.google.com/maps/place/Jo%27s+Coffee…"
    }
  ]
}
```

Each result carries: `name, rating, review_count, price, category, address, hours, lat, lng, place_id, url`. Missing fields are `null` (or `-` in pretty mode), e.g. a place with no price tier or no street address.

### Route, pretty
```
Route (driving): Austin, TX  →  Houston, TX

  2 hr 31 min   165 miles   via State Hwy 71 E and I-10 E
  Fastest route, the usual traffic  ·  tolls

  1. Austin, Texas, USA
  2. Houston, Texas, USA

  https://www.google.com/maps/dir/Austin,+TX/Houston,+TX/
```
The `(driving)` is the `--mode` (default). `distance` (and `via`) can be null on a trivially short route, and for `transit`/`flights` (no distance). `tolls` appears only when the route has tolls.

### Route, `--json`
```json
{
  "mode": "route",
  "travel_mode": "driving",
  "places": ["Austin, TX", "Houston, TX"],
  "route": {
    "duration": "2 hr 31 min",
    "distance": "165 miles",
    "via": "via State Hwy 71 E and I-10 E",
    "label": "Fastest route, the usual traffic",
    "tolls": true,
    "waypoints": ["Austin, Texas, USA", "Houston, Texas, USA"],
    "url": "https://www.google.com/maps/dir/Austin,+TX/Houston,+TX/"
  }
}
```

### Optimize, pretty
```
Optimized route  ·  best-effort (straight-line TSP)  ·  driving  ·  fixed start: Austin, TX, USA

  1. Austin, TX, USA  (30.267153, -97.7430608)   start
  2. San Antonio, TX, USA  (29.4251905, -98.4945922)   ← 118.4 km SW (218°)
  3. Houston, TX, USA  (29.7600771, -95.3701108)   ← 304.4 km E (82°)
  4. Dallas, TX, USA  (32.7766642, -96.7969879)   ← 361.8 km N (338°)

  Straight-line total: 784.6 km

Real driving for this order:
  7 hr 50 min   516 miles   via I-35 S
  Fastest route now due to traffic conditions  ·  tolls
  Austin, TX, USA  →  San Antonio, TX, USA  →  Houston, TX, USA  →  Dallas, TX, USA

  https://www.google.com/maps/dir/Austin,+TX/San+Antonio,+TX/Houston,+TX/Dallas,+TX/

Note: order is a best-effort estimate from straight-line (haversine) distances;
the driving time above is the real route for that order.
```

### Optimize, `--json`
```json
{
  "mode": "optimize",
  "travel_mode": "driving",
  "places": [{ "query": "Austin, TX", "name": "Austin, TX, USA", "lat": 30.267153, "lng": -97.7430608, "url": "…" }, …],
  "order": ["Austin, TX, USA", "San Antonio, TX, USA", "Houston, TX, USA", "Dallas, TX, USA"],
  "order_indices": [0, 3, 1, 2],
  "legs": [{ "from": "Austin, TX, USA", "to": "San Antonio, TX, USA", "km": 118.4, "bearing": 218, "dir": "SW" }, …],
  "straight_line_total_km": 784.6,
  "route": { "duration": "7 hr 50 min", "distance": "516 miles", "via": "via I-35 S", "label": "…", "tolls": true, "waypoints": […], "url": "…" },
  "note": "Order is a best-effort estimate from straight-line (haversine) distances; the driving time/distance is the real route for that order."
}
```

## How it works

### Search
| Step | What happens |
|------|---------------|
| 1 | `session.connect()` (once), shared WebSocket to the browser |
| 2 | `Target.createTarget({ background: true })` + `attachToTarget`, isolated background tab + per-call `sessionId` |
| 3 | `Page.navigate` to `https://www.google.com/maps/search/<query>` (URI-encoded; spaces as `+`) |
| 4 | **Poll for `a[href*='/maps/place/']` count > 0**, the real readiness signal. Chrome never fires `networkIdle` for Maps (continuous XHR polling keeps the 500ms quiet window from ever opening), so waiting on lifecycle events times out. Polling the feed is both faster and reliable. |
| 5 | **Scroll the feed** to load up to `count` results. The feed renders ~7 cards up front; more stream in as the feed is scrolled to its bottom. Stops at `count`, after two consecutive no-growth scrolls (end of results), or after a 25-scroll cap. |
| 6 | **One `Runtime.evaluate`** parses every result card and returns a JSON array. |
| 7 | Fire-and-forget `closeTab` in `finally`, cleanup without blocking the response. |

### Directions (`--route` / `--optimize`)
| Step | What happens |
|------|---------------|
| 1 | `Page.navigate` to `https://www.google.com/maps/dir/<p0>/<p1>/…/` (path form with `+` for spaces). The `?query=` form does not render. The page loads in Maps' "Best" mode. |
| 2 | **Poll the Directions panel for a route duration.** Like the feed, `networkIdle` never fires. Route-list durations end in `min`/`hr`; the travel-mode tabs use a compact form (`2h 30m`, `16 hr`) with no `min` token, so polling `/\d+\s*min\b/` in the `[aria-label="Directions"]` panel waits for the real route and ignores the mode tabs. A distance leaf (`miles`/`km`) is also accepted so an exact-hour route (`2 hr`, no min) still triggers. The map scale bar `50 km` is outside the panel. |
| 3 | **Select the travel `--mode`** (unless `best`). The mode tabs are the `button[role=radio]` that contain an `[aria-label]` icon, in a stable order `[Best, Driving, Transit, Walking, Cycling, Flights]` (the map-type Default/Satellite radios have no icon aria-label, so they're excluded). Tab aria-labels are *localized* ("Driving" → "Lái xe"), so the tab is selected by **index**, not label; the tabs are waited-for (they can lag the initial paint), and an unavailable tab is detected (DOM `disabled` property, with a fallback: no route after the click ⇒ "not available"). After a real click the panel **clears (duration → null) then repopulates** with the new mode's duration, so the re-render is waited out by polling null → non-null, no race with the previous mode's duration. |
| 4 | **One `Runtime.evaluate`** extracts the primary route: the first full-format duration leaf **not inside a travel-mode `BUTTON[role=radio]`** (those hold the compact per-mode best-times); the distance leaf in its nearest ancestor; and the unique `via …` / `Fastest route …` / `This route has tolls.` leaves (transit alternates like RedCoach/FlixBus have none of these, so they don't pollute). Resolved waypoint names come from `input[aria-label]` (`Starting point …` / `Destination …`). For `flights` the duration is read body-wide (the flight card is outside the Directions panel) and distance/via/tolls are null. |
| 5 | Fire-and-forget `closeTab` in `finally`. |

### Optimize extras
| Step | What happens |
|------|---------------|
| A | **Resolve each place** in parallel background tabs: a place-name search does *not* render a feed, it resolves to a single place page whose URL updates (in ~3-7 s) to `/maps/place/<name>/@<view>/data=…!8m2!3d<lat>!4d<lng>…`. Each tab polls `location.href` for the `!3d!4d` token (the place's exact coords, the `@lat,lng` is only the viewport). The place name is read from the `/maps/place/<name>/` path segment. |
| B | Build the **haversine** distance matrix and solve the **open-path TSP with fixed start = place[0]** (exact Held-Karp, ≤12 places). |
| C | Compute per-leg straight-line km + compass bearing. |
| D | **One real `--route` call** (in the chosen `--mode`, default driving) for the chosen order → the reported time/distance is real; only the *order* is a straight-line estimate. |

Per call: search ~2-3 s (feed render + scrolls); route ~2-3 s; optimize ~5-9 s (N parallel place resolutions + 1 directions render). Background tabs keep it unobtrusive (unlike `ytdl`, Maps needs no playback/poToken).

## Why a real browser for Maps

- **The Google Places and Directions APIs are metered and gated**, a key, a billing account, and per-call cost. The Maps web page renders the same data for free; driving your own browser reads it with no key and no quota. The one thing the paid Directions API gives that the free page cannot is `optimizeWaypoints` (TSP for ≤9 stops), `--optimize` replaces that with a best-effort straight-line TSP.
- **A real browser renders the feed and the route panel.** Maps is a heavy SPA: results stream into `div[role=feed]` as it scrolls, and the directions panel renders route durations with class names that are obfuscated and rotate. A single `Runtime.evaluate` extracts every card / the primary route in one CDP call, no HTML fetching, no proxy, no Cloudflare wall (the page is rendered as you, in your own browser).
- **Per-query use, not bulk harvest.** This is the agent-call shape: one query (or one route of ≤25 / one TSP of ≤12 places), returned as structured JSON or text. It is *not* a bulk scraper (that needs proxy rotation + rate management, which a real-browser-per-call approach doesn't scale to).

## Red Flags

### Search
- **`networkIdle` never fires for Maps.** Continuous XHR polling holds the network busy, so the lifecycle-event wait gsearch/findata use would time out every time. `gmaps` polls `a[href*='/maps/place/']` count instead, the actual readiness signal.
- **No class-name selectors.** Google's class names are obfuscated (`.Nv2PK`, `.bfdHYd`, `.MW4etd`, …) and rotate across releases. The parser relies only on **stable signals**: the result link's `aria-label` (name), the card's leaf-text DOM order (rating/reviews/price/category/address/hours), and the `href` (`!3d<lat>!4d<lng>` for coords, `!19sChIJ…` / `!1s0x..:0x..` for the place id). If Maps changes the card text order, the address/hours heuristics are the first things to revisit.
- **`?query=` URL form does not render the feed** (returns a redirect shell with no results), only the path form `/maps/search/<query>` does. Spaces are sent as `+`.
- **Address is a heuristic.** It's the first leaf starting with a street number (`^\d{1,5}[-A-Za-z]*\s`). A place with no street address (an area/region result, or a named building) yields `address: null` rather than a guess.
- **Hours is a heuristic.** It's the `Open` / `Closed` / `Open 24 hours` / `Temporarily closed` leaf, plus the following `· Closes/Opens …` leaf when present. `Open 24 hours` rendered as two leaves (`Open` + `24 hours`) would be read as just `Open`.
- **Phone, website, and full weekly hours are NOT in the feed card**, they live on the place-detail page and need a per-place navigation to read. Not implemented (would be a `--details` mode); for now use `url` to open a place.
- **Sponsored results** may appear (Maps sometimes shows one ad, which is also a `/maps/place/` link). They are not filtered out, check the data if it matters.
- **Consent / cookie wall** (rare in an already-warmed browser) returns zero results. If results come back empty, open the search URL in the browser once to clear it.

### Directions
- **Per-leg times are not in the collapsed route summary.** Maps shows only the total for an ordered multi-stop route. Per-leg durations live behind the "Details" expansion (not implemented); use `url` for turn-by-turn.
- **`distance` can be null** on a trivially short route (Maps may render no distance leaf).
- **The primary route is "first full-format duration not inside a travel-mode radio."** The mode tabs (`BUTTON[role=radio]`) carry compact best-times (`2h 30m`) that must be excluded; the route list uses the full form (`2 hr 31 min`). Transit alternates (RedCoach/FlixBus) appear in the same panel but have no `via`/`Fastest route`/distance leaves, so they don't contaminate the primary extraction.
- **Times are current-traffic ("now").** The page gives *now*, not a predictive future departure (the paid API's `departure_time`/`traffic_model` is the one thing the page doesn't give). The label varies: "Fastest route, the usual traffic" vs "Fastest route now due to traffic conditions", both are matched by `^Fastest route`.
- **Place-name searches don't render a feed**, that's why `--optimize` resolves places by polling the place-page URL for `!3d!4d`, not by parsing feed cards. A background tab takes ~3-7 s to update the URL; `resolvePlace` polls up to 20 s.

### Travel modes (`--mode`)
- **`best` is Maps' default and may not be driving.** For routes where transit/flight is faster (e.g. London→Paris, where Best = the Eurostar), `best` returns the transit time. Pass `--mode driving` to force driving. The skill's *default* is `driving` (not `best`) for this reason.
- **Mode tabs are selected by INDEX, not aria-label**, the labels are localized ("Driving" → "Lái xe" in vi). The order `[Best, Driving, Transit, Walking, Cycling, Flights]` has been stable across locales, but if Maps reorders or drops a tab the index mapping breaks.
- **Unavailable modes are detected two ways**, the tab's DOM `disabled` property (which can lag its render), with a fallback: if clicking produces no route (panel clears but never repopulates), the mode is reported as "not available for this route".
- **`flights` is best-effort.** The flight card renders *outside* the Directions panel, so the nonstop flight time is read body-wide; there is no distance/via/tolls. Connecting flights and booking details are not parsed.
- **No `motorcycle` or `ferry` mode.** Maps exposes only the six tabs above; motorcycling routes via `driving`, ferries appear as segments within `driving`/`transit` routes. (A two-wheeler tab does not appear in Maps for Vietnam or the US.)
- **Walking/cycling durations render in hours** in the route list (e.g. `62 hr`), not the "days" shown on the mode tab, both are matched.

### Optimize
- **The order is a straight-line estimate, not the true driving-optimal order.** Straight-line (haversine) distance is the "virtualized" edge cost; road-network detours can reorder the true optimum. The reported *driving time/distance* is real (one directions call for the chosen order), only the *order* is best-effort.
- **Fixed start, open path.** The first place is the start and is fixed; the path visits every place once and ends anywhere (no return). Not a round trip.
- **≤12 places, exact.** Held-Karp is exact but exponential; above 12 the command errors rather than guess. N place resolutions run in parallel (N background tabs) + 1 directions call = N+1 browser calls.

### General
- **No `jq` dependency**, parsing, TSP, and pretty-printing are done in-page / in JS.
- **Inputs reach the heredoc by placeholder substitution** (node `JSON.stringify` + function-replacements), immune to `&` / `$` / `\` in place names (e.g. `AT&T Stadium`, `Café du Monde`), the same mechanism the search query uses.

## Verification

Search results carry `name, rating, review_count, price, category, address, hours, lat, lng, place_id, url` (missing fields are `null`). Route JSON carries `duration`, `distance`, `via`, `tolls`, `waypoints`, and `url`. Optimize reports `straight_line_total_km` plus the real route for the chosen order, with the best-effort note attached. If results come back empty, open the search URL in the browser once to clear a consent/cookie wall.


## References

N/A, no reference files; commands, result shapes, and traps are inline in this skill.

