The Traveling Gym Reviewer
Mapping Every BJJ Gym Near Me by Drive Time
I scraped Google Maps for the drive time from my home to every BJJ gym in my metro, bucketed the results, and learned more than I expected about how to plan a tour.
I started BJJ later in life and I’m trying to figure out where to train. The public-source list of gyms in my metro turned out to be over a hundred schools long, and I started running into the same problem most adults hit: distance on a map doesn’t tell you what a weekday-morning commute actually feels like.
So I built a small system to scrape drive time — the number that matters — from my home to every gym in the metro, and bucket the results to see what the practical training pool actually looks like.
This post is a writeup of what came back, and how the scraping works under the hood. The code at the bottom is short enough to copy.
The result — bucket distribution
I’ll keep my exact location and the gym names out of this post. What the distribution does tell you is interesting on its own:
| Drive time | Gyms | % |
|---|---|---|
| < 15 min | 2 | 2% |
| 15-25 min | 20 | 17% |
| 25-40 min | 41 | 34% |
| 40-60 min | 46 | 38% |
| 60+ min | 11 | 9% |
| Total | 120 | 100% |
A few things to read out of that:
- A 25-minute radius gives me only 22 gyms out of 120 in the metro. That’s the genuinely-commutable pool. Anything more than 25 minutes is a weekend trip, or a sport-after-work commitment I have to defend against family time.
- Roughly three-quarters of the gyms in this metro are 25–60 minutes away. That’s the “drive-time tax” of living in any moderately spread city. If I lived more centrally, the distribution would skew left; if I lived in a smaller exurb, it’d skew right.
- The long-tail of 11 gyms over 60 minutes is the “I will visit this once on a Saturday morning and write it up” set. They’ll never be a weekly thing for me, and that’s useful information up front.
The point isn’t the count. The point is that a flat list of 120 schools implied a much bigger training opportunity than a 25-minute filter does. The right gym for a desk-worker with a family in this metro lives inside a 22-gym list — not a 120-gym list.
Why drive time, not distance
The first version of this used haversine distance — straight-line kilometers between zip centroid and gym. It was wrong by 20–50% in practice, in both directions:
- A gym 8 miles away on a freeway was a 15-minute trip.
- A gym 4 miles away through downtown stoplights was a 22-minute trip.
The haversine version told me to prioritize the wrong schools. Real drive time — what Google Maps would route — is what flows out of an actual weekday commute. So I went and got it.
How I scraped Google Maps without an API key
I’m a traveling consultant, not a dot-com — every API key is one more
thing to argue about with a CFO somewhere. So this is API-free: it
drives my own Google Chrome via AppleScript Apple Events to load
the Maps directions URL for each (home zip → gym) pair, then reads
the route panel with a synchronous JavaScript snippet.
The pattern is the same one I use for every “Chrome does the work” piece in this project: open a real tab, let the page render in my logged-in session (cookies + uBlock + residential IP — Google trusts it), scrape the DOM, move on. No headless browser, no proxy, no API.
The shape of one query
import { execFileSync } from "node:child_process";
function osascript(s: string): string {
return execFileSync("osascript", ["-e", s], { encoding: "utf8" }).trim();
}
// Synchronous JS that reads the Maps route panel. Sync matters:
// `execute javascript` returns `missing value` for any Promise, so no
// `async`/`await` inside the page-side function.
const EXTRACT = `(function(){
var card =
document.querySelector('div[data-trip-index="0"]') ||
document.querySelector('.Fk3sm') ||
document.querySelector('.UgZKXd');
if (!card) return JSON.stringify({ ready: false });
var aria = card.getAttribute('aria-label') || '';
var text = (card.textContent || '').replace(/\\s+/g, ' ').trim();
return JSON.stringify({ ready: true, aria, text });
})()`;
function getDriveTime(originZip: string, destLat: number, destLon: number) {
const url =
`https://www.google.com/maps/dir/?api=1` +
`&origin=${encodeURIComponent(originZip)}` +
`&destination=${destLat},${destLon}` +
`&travelmode=driving`;
const script = `
tell application "Google Chrome"
set t to active tab of front window
set URL of t to "${url}"
delay 3
return (execute t javascript "${EXTRACT.replace(/"/g, '\\"')}")
end tell
`;
return JSON.parse(osascript(script));
}
There’s a small dance to make this robust — polling for the panel
instead of a fixed delay, captcha detection, retries with longer
waits — but the core is the four lines above. The full version is
about 120 lines.
Parsing the duration
Google Maps describes the route in aria-label and the visible card
text. Both contain phrases like "27 min" or "1 hr 12 min" and a
distance like "11.4 mi". The parser is forgiving:
export function parseDurationMinutes(raw: string): number | null {
const s = raw.toLowerCase();
// "1 hr 12 min" → 72
const hm = s.match(/(\d+)\s*(?:hr|h)\s*(\d+)?\s*(?:min|m)?/);
if (hm) return +hm[1] * 60 + (hm[2] ? +hm[2] : 0);
// "45 min" → 45
const m = s.match(/(\d+)\s*(?:min|m)\b/);
return m ? +m[1] : null;
}
Bucketing
Once every gym has a drive_time_min, the bucket function is one
small switch. The breakpoints are opinionated for the metro I’m in
(if you live somewhere denser, slide them left; somewhere sprawlier,
slide them right):
type Bucket = "very-close" | "close" | "medium" | "far" | "very-far";
function bucketFor(min: number): Bucket {
if (min < 15) return "very-close"; // weekday morning, no planning
if (min < 25) return "close"; // weekday morning, manageable
if (min < 40) return "medium"; // weekday if the day is light
if (min < 60) return "far"; // weekend trip
return "very-far"; // saturday-only
}
Politeness, captchas, and the rate-limit dance
Google will challenge a session that hits Maps too fast. The mitigation that works for me:
- One tab at a time. Multiple concurrent tabs raise the fingerprint. A single tab in a real browser looks like a real human.
- 6–10 second random pauses between gyms. ~120 queries = ~16 minutes. That’s fine for a once-a-month refresh.
- Captcha detection that aborts on the first challenge and tells me exactly which gym to re-start from. I open Chrome, complete the challenge by hand, re-run. Across the full 120-query sweep I got zero captchas.
Long-term, if I ever needed real-time drive times I’d swap this for OpenRouteService’s free tier — same output schema, no scraping. But for a once-a-month batch from my own machine, the AppleScript path is the simplest possible thing.
Aggregation = privacy
I don’t want my exact zip on the public site, and I don’t want to publicly rank individual gyms by their distance from my house — that’s a weirdly personal signal about a small business.
The system handles both at once. Drive times get stored per
(home_zip × gym) pair internally, but the export step aggregates them
by a publicLabel (e.g. "my metro"). Right now I only have one
home zip, so the labeled value equals the raw value. The moment I add a
second zip to the config — say, a friend’s place across town — every
public surface starts showing the minimum drive time across the two
zips. Individual home zips become statistically indistinguishable.
function aggregateForPublic(rows: DriveTimeRow[]): PublicDriveTime[] {
// Group by publicLabel; emit the min drive_min per group.
// With ≥ 2 zips per label, the original zip identity is masked.
const byLabel = new Map<string, DriveTimeRow[]>();
for (const r of rows) {
if (r.drive_time_min == null) continue;
(byLabel.get(r.publicLabel) ?? byLabel.set(r.publicLabel, []).get(r.publicLabel)!)
.push(r);
}
return [...byLabel].map(([label, list]) => {
const best = list.sort((a, b) => a.drive_time_min! - b.drive_time_min!)[0];
return { publicLabel: label, drive_min: best.drive_time_min!, distance_km: best.distance_km! };
});
}
The same idea works for gym names: a stretched-far list with hundreds of entries can become an anonymous bucket distribution like the one at the top of this post. The data is still useful to me; the surface is still useful to a reader; no individual gym is named or ranked by drive time in public.
What I’m actually going to do with this
The point of building this isn’t a leaderboard. It’s a calendar.
The unit of training I’m focused on is the private lesson. My wife dances at night, which means evenings are mine with the kids. So my real training window is mornings and early afternoons — weekdays mostly, weekend mornings when those work. Privates fit that because they’re one-on-one, they happen on the coach’s calendar, and they don’t require me to be at a 6 PM group class while I’m doing dinner and bedtime. The drive-time data tells me which gyms are realistically inside a morning window before the day fills up, which can only happen on a weekend, and which are a half-day trip.
I want to record each private, end to end. With permission, always. The point isn’t a content factory — it’s that one hour with a thoughtful coach is worth re-watching 20 times, and the only way to do that honestly is to ask up front and record only when the gym is comfortable with it. Some will say yes. Some will say no. Both are useful answers.
Then, if they’ll have me, I want to spar. Not because I have something to prove — I’m a few months in, and the only thing I can honestly demonstrate is that I’m an enthusiastic mostly-blank slate who’s athletic enough to give a coach or a student a halfway-decent roll. What I actually want is for the gym’s people to demonstrate their skill. That’s the show. I’m the training dummy. Different gyms roll differently — different grips, different paces, different philosophies about how hard the average sparring round goes — and the only honest way to capture that is to be the partner across the mat.
So the plan, in order:
- Use the 25-minute drive-time bucket to identify the privates I can schedule on a weekday morning or early afternoon without rearranging the rest of life.
- Reach out to the head coach, explain what I’m doing, ask if they’ll take a one-hour private with me.
- Ask about recording. Honor whatever they say.
- If the room is open to it, stay for an open mat and roll with whoever’s available — coaches, blue belts, purple belts, anyone. The variation in their approach is the point.
- Write it up honestly.
- Re-run this whole drive-time scrape monthly. Routes shift, gyms move, and new schools open. The list isn’t static.
That’s the loop. The drive-time buckets just tell me which gyms are actually inside the surface of a normal week and which are weekend expeditions. Everything else is showing up, asking permission, and being a decent partner.
If you do something similar, I’d love to compare distributions across metros. Send a screenshot of your bucket counts; no names needed.
The bigger pattern
This is the same pattern I keep using on this project: a real Chrome tab, a synchronous JavaScript extractor, a SQLite table, a stupidly simple aggregation step. No APIs, no monthly bills, no rate limits I don’t control. The drive-time scraper is barely 200 lines of code and it answered a question that genuinely changes how I’m going to plan a year of training.
It also reframed the problem. I started with “where do I want to train BJJ?” and ended with “where can I actually train BJJ given my life?” — those turn out to be different questions, and the answer to the second one is a meaningfully shorter list.
That list is what the tour is built on.
Frequently asked
+Why drive time instead of straight-line distance?
Distance lies. A gym 8 miles away on highway is faster than one 4 miles away through stoplights. Drive time accounts for highway access, road type, and how Google Maps thinks about your actual route — which is what you'll experience after work or with the kids in the car.
+How did you get drive times without using an API?
AppleScript drives the real Google Chrome session via Apple Events. Each gym opens Maps' directions URL, a small synchronous JavaScript snippet reads the route panel, and the duration + distance get parsed and stored. No API key, no third-party dependency, no rate-limit invoice.
+Why bucket the drive times instead of just sorting?
A sorted list answers 'which is closest' but not 'how dense is my training pool'. Bucketing surfaces the distribution: how many gyms are realistically commutable on a regular weekday, how many are weekend-only trips, how many are functionally inaccessible. That shapes a year's plan, not just one drive.
+Can other metros use this approach?
Yes — the only thing that changes is the home zip and the gym list. The scraper is metro-agnostic; the bucket boundaries (15 / 25 / 40 / 60 min) are tuned for a moderately sprawling Sun Belt metro and should be adjusted for denser cities (NYC, SF) or larger ones (Houston, DFW).