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
- Search:
gmaps "<query>" [count] [--json], local business results, feed scrolled to load more.
- Directions:
gmaps --route "<p0>" "<p1>" … [--mode M] [--json], real time + distance for the given order (up to 25 places).
- 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.
- Parallelize independent calls, each opens its own background tab.
Commands
Search (default)
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)
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)
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:
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
{
"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
{
"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
{
"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.
1---2name: gmaps3description: 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.4---56# gmaps, Google Maps via CDP (search, directions, best-effort TSP)78Free, 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:910- **search** (default), local business results for a query.11- **`--route`**, real driving directions (total time + distance, current traffic) for an *ordered* list of places.12- **`--optimize`**, best-effort fastest *visiting order* (open-path TSP, fixed start = first place).1314Every call opens its own background tab and WebSocket session, safe for parallel use.1516## Core Principle1718Free, 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`.1920## When to Use / NOT2122**Use**, when the user asks to find local businesses, get directions/time between places (by any mode), or plan a multi-stop route order.2324**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).2526## Workflow27281. Search: `gmaps "<query>" [count] [--json]`, local business results, feed scrolled to load more.292. Directions: `gmaps --route "<p0>" "<p1>" … [--mode M] [--json]`, real time + distance for the given order (up to 25 places).303. 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.314. Parallelize independent calls, each opens its own background tab.3233## Commands3435### Search (default)3637```bash38gmaps "coffee shops in Austin TX" # up to 10 results, pretty39gmaps "sushi near Times Square" 5 # 5 results40gmaps "pharmacy open now Berlin" 20 # 20 (scrolls the feed)41gmaps --json "coffee shops in Austin TX" 5 # raw JSON42```4344| Arg | Meaning |45|------|---------|46| `--json` | Emit raw JSON instead of pretty-printed text |47| `<query>` | A Google Maps search, e.g. `coffee shops in Austin TX` |48| `[count]` | Number of results (default 10, capped at 30, the feed is scrolled to load more) |4950### Directions (`--route`)5152```bash53gmaps --route "Austin, TX" "Houston, TX" # real time + distance (driving)54gmaps --route --mode transit "London, UK" "Paris, France" # by travel mode55gmaps --route "Austin, TX" "Houston, TX" "Dallas, TX" # ordered multi-stop, total56gmaps --route --json --mode cycling "Austin, TX" "Houston, TX" # raw JSON57```5859Real 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.6061### Best-effort order (`--optimize`)6263```bash64gmaps --optimize "Austin, TX" "Houston, TX" "Dallas, TX" "San Antonio, TX"65gmaps --optimize --mode transit "Austin, TX" "Houston, TX" "Dallas, TX"66gmaps --optimize --json "Austin, TX" "Houston, TX" "Dallas, TX"67```6869Fastest *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.7071- 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².72- 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.73- The TSP is solved exactly (Held-Karp) for up to **12 places**.74- Total browser calls: **N + 1** (N place resolutions + 1 directions render).7576### Travel modes (`--mode`)7778| `--mode` | What it returns |79|------|---------|80| `driving` *(default)* | time + distance + via + tolls (the usual Maps route) |81| `transit` | time of the best bus/train/ferry/subway option (no distance/via) |82| `walking` | time + distance (durations render in hours, e.g. `62 hr`) |83| `cycling` | time + distance (may include a ferry/car-transport note) |84| `flights` | nonstop flight time only (best-effort, the flight card is outside the route panel) |85| `best` | whatever Maps' "Best" tab picks (driving for most routes; transit/flight where those are faster) |8687- **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.)88- 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.89- Applies to both `--route` and `--optimize`.9091### Parallel use9293Each call opens its own background tab, safe to run concurrently:9495```bash96gmaps "coffee Austin" 5 & gmaps "coffee Seattle" 5 & wait97gmaps --route "Austin, TX" "Houston, TX" & gmaps --route "Dallas, TX" "San Antonio, TX" & wait98```99100## Result shape101102### Search, pretty (default)103```104coffee shops in Austin TX · 5 results1051061. Jo's Coffee – South Congress ★4.4 (1972) $1–10 Coffee shop107 1300 S Congress Ave · Open · Closes 7 PM108 30.2510458, -97.7493717 · ChIJ1S_Bov20RIYRe7MiR8109 https://www.google.com/maps/place/Jo%27s+Coffee...110```111112### Search, `--json`113```json114{115 "query": "coffee shops in Austin TX",116 "count": 5,117 "results": [118 {119 "name": "Jo's Coffee – South Congress",120 "rating": 4.4,121 "review_count": 1972,122 "price": "$1–10",123 "category": "Coffee shop",124 "address": "1300 S Congress Ave",125 "hours": "Open · Closes 7 PM",126 "lat": 30.2510458,127 "lng": -97.7493717,128 "place_id": "ChIJ1S_Bov20RIYRe7MiR8",129 "url": "https://www.google.com/maps/place/Jo%27s+Coffee…"130 }131 ]132}133```134135Each 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.136137### Route, pretty138```139Route (driving): Austin, TX → Houston, TX140141 2 hr 31 min 165 miles via State Hwy 71 E and I-10 E142 Fastest route, the usual traffic · tolls143144 1. Austin, Texas, USA145 2. Houston, Texas, USA146147 https://www.google.com/maps/dir/Austin,+TX/Houston,+TX/148```149The `(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.150151### Route, `--json`152```json153{154 "mode": "route",155 "travel_mode": "driving",156 "places": ["Austin, TX", "Houston, TX"],157 "route": {158 "duration": "2 hr 31 min",159 "distance": "165 miles",160 "via": "via State Hwy 71 E and I-10 E",161 "label": "Fastest route, the usual traffic",162 "tolls": true,163 "waypoints": ["Austin, Texas, USA", "Houston, Texas, USA"],164 "url": "https://www.google.com/maps/dir/Austin,+TX/Houston,+TX/"165 }166}167```168169### Optimize, pretty170```171Optimized route · best-effort (straight-line TSP) · driving · fixed start: Austin, TX, USA172173 1. Austin, TX, USA (30.267153, -97.7430608) start174 2. San Antonio, TX, USA (29.4251905, -98.4945922) ← 118.4 km SW (218°)175 3. Houston, TX, USA (29.7600771, -95.3701108) ← 304.4 km E (82°)176 4. Dallas, TX, USA (32.7766642, -96.7969879) ← 361.8 km N (338°)177178 Straight-line total: 784.6 km179180Real driving for this order:181 7 hr 50 min 516 miles via I-35 S182 Fastest route now due to traffic conditions · tolls183 Austin, TX, USA → San Antonio, TX, USA → Houston, TX, USA → Dallas, TX, USA184185 https://www.google.com/maps/dir/Austin,+TX/San+Antonio,+TX/Houston,+TX/Dallas,+TX/186187Note: order is a best-effort estimate from straight-line (haversine) distances;188the driving time above is the real route for that order.189```190191### Optimize, `--json`192```json193{194 "mode": "optimize",195 "travel_mode": "driving",196 "places": [{ "query": "Austin, TX", "name": "Austin, TX, USA", "lat": 30.267153, "lng": -97.7430608, "url": "…" }, …],197 "order": ["Austin, TX, USA", "San Antonio, TX, USA", "Houston, TX, USA", "Dallas, TX, USA"],198 "order_indices": [0, 3, 1, 2],199 "legs": [{ "from": "Austin, TX, USA", "to": "San Antonio, TX, USA", "km": 118.4, "bearing": 218, "dir": "SW" }, …],200 "straight_line_total_km": 784.6,201 "route": { "duration": "7 hr 50 min", "distance": "516 miles", "via": "via I-35 S", "label": "…", "tolls": true, "waypoints": […], "url": "…" },202 "note": "Order is a best-effort estimate from straight-line (haversine) distances; the driving time/distance is the real route for that order."203}204```205206## How it works207208### Search209| Step | What happens |210|------|---------------|211| 1 | `session.connect()` (once), shared WebSocket to the browser |212| 2 | `Target.createTarget({ background: true })` + `attachToTarget`, isolated background tab + per-call `sessionId` |213| 3 | `Page.navigate` to `https://www.google.com/maps/search/<query>` (URI-encoded; spaces as `+`) |214| 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. |215| 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. |216| 6 | **One `Runtime.evaluate`** parses every result card and returns a JSON array. |217| 7 | Fire-and-forget `closeTab` in `finally`, cleanup without blocking the response. |218219### Directions (`--route` / `--optimize`)220| Step | What happens |221|------|---------------|222| 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. |223| 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. |224| 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. |225| 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. |226| 5 | Fire-and-forget `closeTab` in `finally`. |227228### Optimize extras229| Step | What happens |230|------|---------------|231| 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. |232| B | Build the **haversine** distance matrix and solve the **open-path TSP with fixed start = place[0]** (exact Held-Karp, ≤12 places). |233| C | Compute per-leg straight-line km + compass bearing. |234| 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. |235236Per 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).237238## Why a real browser for Maps239240- **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.241- **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).242- **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).243244## Red Flags245246### Search247- **`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.248- **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.249- **`?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 `+`.250- **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.251- **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`.252- **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.253- **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.254- **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.255256### Directions257- **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.258- **`distance` can be null** on a trivially short route (Maps may render no distance leaf).259- **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.260- **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`.261- **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.262263### Travel modes (`--mode`)264- **`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.265- **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.266- **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".267- **`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.268- **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.)269- **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.270271### Optimize272- **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.273- **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.274- **≤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.275276### General277- **No `jq` dependency**, parsing, TSP, and pretty-printing are done in-page / in JS.278- **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.279280## Verification281282Search 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.283284285## References286287N/A, no reference files; commands, result shapes, and traps are inline in this skill.