/** * Convert Google Takeout Maps data to GeoJSON files, so that I can finally * escape from google maps. * * Setup: download a Google Takeout export including "Maps (your places)" and * "Saved", place the export folder next to this script and use it as --input * * deno run ./takeout_to_geojson.ts --input ./Takeout * * NOTE: For the first usage, you will probably need to accept Google T&C manually * in the browser before url redirects work. */ import { Command } from 'jsr:@cliffy/command' import { colors } from 'jsr:@cliffy/ansi/colors' import { parse } from 'jsr:@std/csv' import { ensureDir } from 'jsr:@std/fs' import { launch } from 'jsr:@astral/astral' // ── Browser ─────────────────────────────────────────────────────────────────── let _browser: Awaited> | null = null let _profileDir = './browser-profile' async function getBrowser() { if (!_browser) _browser = await launch({ headless: false, args: [`--user-data-dir=${_profileDir}`], }) return _browser } async function closeBrowser() { await _browser?.close() _browser = null } // ── Geocoding ───────────────────────────────────────────────────────────────── const matchLatLon = /@([-+]?\d{1,2}(?:\.\d+)?),([-+]?\d{1,3}(?:\.\d+)?)/ function coordsFromUrl(url: string) { const m = url.match(matchLatLon) if (!m) return null return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) } } // Opens the Google Maps URL in a real browser so JS executes and the page // redirects to a URL containing @lat,lon, then reverse-geocodes with Nominatim. async function geocodeGoogleMapsUrl(url: string) { const browser = await getBrowser() const page = await browser.newPage() try { await page.goto(url) let finalUrl = url for (let i = 0; i < 15; i++) { finalUrl = await page.evaluate(() => window.location.href) if (matchLatLon.test(finalUrl)) break await new Promise(r => setTimeout(r, 1000)) } const coords = coordsFromUrl(finalUrl) if (!coords) return null const nominatim = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lon}&format=json` ).then(r => r.json()).catch(() => null) return { ...nominatim, ...coords } } finally { await page.close() } } // ── Helpers ─────────────────────────────────────────────────────────────────── function urlName(url?: string) { if (!url) return null const q = url.match(/[?&]q=([^&]+)/)?.[1] return q ? decodeURIComponent(q.replace(/\+/g, ' ')) : null } function coordName(coords?: [number, number]) { if (!coords) return null const [lon, lat] = coords return `${lat}, ${lon}` } // ── CLI ─────────────────────────────────────────────────────────────────────── await new Command() .name('takeout-to-geojson') .description('Convert Google Takeout Maps data to GeoJSON files.') .option('-i, --input ', 'Path to the takeout directory.', { default: './takeout' }) .option('-o, --output ', 'Path to write results.', { default: './results' }) .action(async ({ input, output }) => { _profileDir = `${output}/browser-profile` await ensureDir(`${output}/lists`) const decoder = new TextDecoder() // Index Saved Places by name for fast lookup const savedPlaces = JSON.parse(decoder.decode( await Deno.readFile(`${input}/Maps (your places)/Saved Places.json`) )) const placesByName: Record = {} for (const place of savedPlaces.features) { const name = place.properties?.location?.name ?? urlName(place.properties?.google_maps_url) ?? coordName(place.geometry?.coordinates) place.properties.location ??= {} place.properties.location.name = name placesByName[name] = place } // Load any previously recorded unfound entries so reruns skip them const unfoundPath = `${output}/unfound.json` let unfound: { list: string; title: string; url: string }[] = [] try { unfound = JSON.parse(await Deno.readTextFile(unfoundPath)) } catch { /* first run */ } const unfoundKeys = new Set(unfound.map(e => `${e.list}::${e.title}`)) async function recordUnfound(entry: { list: string; title: string; url: string }) { unfound.push(entry) unfoundKeys.add(`${entry.list}::${entry.title}`) await Deno.writeTextFile(unfoundPath, JSON.stringify(unfound, null, 2)) } // Rate-limit geocoding to stay within Nominatim's 1 req/sec policy const GEOCODE_DELAY_MS = 1500 let lastGeocodeTime = 0 async function geocodeStraggler(title: string, url: string) { const wait = GEOCODE_DELAY_MS - (Date.now() - lastGeocodeTime) if (wait > 0) await new Promise(r => setTimeout(r, wait)) const geo = await geocodeGoogleMapsUrl(url).catch(() => null) lastGeocodeTime = Date.now() if (!geo?.lat || !geo?.lon) return null return { type: 'Feature', geometry: { type: 'Point', coordinates: [geo.lon, geo.lat] }, properties: { ...geo, location: { name: title, address: geo.display_name ?? '' }, date: new Date().toISOString(), google_maps_url: url, }, } } // Process each saved list CSV for await (const dirEntry of Deno.readDir(`${input}/Saved`)) { const listName = dirEntry.name.split('.')[0] const outputPath = `${output}/lists/${listName}.json` let existing = { name: listName, type: 'FeatureCollection', features: [] as any[] } try { existing = JSON.parse(await Deno.readTextFile(outputPath)) } catch { /* first run */ } const completedTitles = new Set(existing.features.map((f: any) => f.properties.Name)) const features = existing.features const rawCsv = decoder.decode(await Deno.readFile(`${input}/Saved/${dirEntry.name}`)) const headerIndex = rawCsv.split('\n').findIndex(l => l.startsWith('Title,')) const csvToParse = headerIndex > 0 ? rawCsv.split('\n').slice(headerIndex).join('\n') : rawCsv const rows = parse(csvToParse, { skipFirstRow: true }) as Record[] const pending = rows.filter(r => r.Title && !completedTitles.has(r.Title) && !unfoundKeys.has(`${listName}::${r.Title}`) ) console.log(colors.bold(`\n${listName} `) + colors.dim(`(${pending.length} remaining of ${rows.length})`)) for (const { Title, Note, URL } of rows) { if (!Title) continue if (completedTitles.has(Title) || unfoundKeys.has(`${listName}::${Title}`)) continue let place = placesByName[Title] if (!place && URL) { console.log(colors.dim(` geocoding: ${Title}`)) place = await geocodeStraggler(Title, URL) } if (!place) { await recordUnfound({ list: listName, title: Title, url: URL }) console.log(colors.red(` ✗ ${Title}`)) continue } place.properties.timestamp = place.properties.date place.properties.Name = place.properties.location?.name ?? Title place.properties.description = Note delete place.properties.date features.push(place) await Deno.writeTextFile(outputPath, JSON.stringify( { name: listName, type: 'FeatureCollection', features }, null, 2 )) console.log(colors.green(` ✓ ${Title}`)) } } await closeBrowser() // Copy supplementary GeoJSON files as-is console.log('\nCopying supplementary files...') const supplementary: [string, string][] = [ [`${input}/Maps (your places)/Saved Places.json`, `${output}/saved_places.json`], [`${input}/Maps (your places)/Reviews.json`, `${output}/reviews.json`], ] for (const [src, dest] of supplementary) { try { await Deno.copyFile(src, dest) console.log(colors.green(` ✓ ${dest}`)) } catch { console.log(colors.dim(` - skipped (not found): ${src}`)) } } console.log(colors.bold.green(`\nDone! Results written to ${output}/`)) }) .parse(Deno.args)