Static-first travel guides built from Google Maps saved lists.
Favorite Places turns personal saved lists into a small shareable site: guide pages, searchable place cards, map views, top picks, tags, notes, and lightweight theming. It is designed as a template app with user-owned content kept in a separate site/ pack.
- An Astro static site for browsing guides and places
- A Python +
uvdata pipeline for importing Google Maps and Wanderlog lists - A
site/overlay for your config, theme, source lists, raw data, overrides, templates, and assets - Optional Google Places enrichment for categories, status, Maps links, ratings, and photos
- A tiny committed
site.example/pack that works as a demo
Install dependencies:
bun install
uv syncOptional local pre-commit hook setup:
uv tool install prek
prek install
prek run --all-filesThis installs a prek-managed pre-commit hook that runs Biome checks on staged frontend files, ruff on staged Python files under scripts/ and tests/python/, and mypy on the typed pipeline helper modules under scripts/.
Keep editor- or agent-specific launch configs local. Files under .claude/ are not part of the repo contract and should remain untracked.
Preview the committed example site pack:
FAVORITE_PLACES_SITE_DIR=site.example bun run build:data
FAVORITE_PLACES_SITE_DIR=site.example bun run devThen open the local URL printed by Astro.
The example pack uses two public Google Maps example lists, one public Wanderlog guide example, committed raw snapshots, manual overrides, tags, top picks, and template aside blocks. It does not need API keys to render.
Copy the example pack into the default site/ location:
cp -R site.example siteEdit:
site/config.tsfor site name, nav, copy, labels, map provider, and display optionssite/theme.cssfor colors, fonts, spacing, and custom stylingsite/list_sources.jsonfor your Google Maps or Wanderlog list sourcessite/overrides/for handwritten notes, tags, top picks, and rankingsite/content/templates/for optional trusted HTML insertion pointssite/public/for logos, favicons, and site-owned assets
After site/ exists, it is the default. You only need FAVORITE_PLACES_SITE_DIR when previewing site.example, using a private sibling site pack, or using a later app/ + site/ layout.
Each source in site/list_sources.json needs a stable slug.
urlsources infertype: "google_list_url"for supported Google Maps links, includinghttps://maps.app.goo.gl/...shortlinks andhttps://www.google.com/maps/...share links.urlsources also infertype: "wanderlog_view_url"for supported Wanderlog guide URLs likehttps://wanderlog.com/view/....pathsources infertype: "google_export_csv"and requiretitle.typecan still be included explicitly, but it must match the configuredurlorpath.titleis optional for Google Maps URL sources and acts as a fallback list title.titleis optional for Wanderlog URL sources and acts as a fallback guide title.- Google My Maps URLs such as
https://www.google.com/maps/d/...are not supported yet.
[
{
"slug": "tokyo-japan",
"url": "https://maps.app.goo.gl/your-public-list"
},
{
"slug": "hong-kong-wanderlog-example",
"type": "wanderlog_view_url",
"url": "https://wanderlog.com/view/your-guide/hong-kong-recommendations",
"title": "Hong Kong Recommendations (Wanderlog example)"
},
{
"slug": "taipei-taiwan",
"path": "data/imports/taipei-taiwan.csv",
"title": "Taipei, Taiwan"
}
]Supported sources:
- Public Google Maps saved-list URLs, including
https://maps.app.goo.gl/...shortlinks - Public Wanderlog guide URLs, including
https://wanderlog.com/view/... - Google Takeout saved-list CSV exports
Google My Maps URLs are not supported yet.
For Google Maps URL sources, raw snapshots preserve the list owner object, including name, photo_url, photo_path, avatar_mode, and profile_id, plus any collaborators the scraper can recover. Individual places can also carry an added_by author with name and profile_id. When the scraped owner is the effective published author, source refresh downloads a square local author image into site/public/author-photos/ and stores its photo_path. Guide list overrides can optionally set, replace, or suppress the generated guide author; use photo_path to point at a site-owned image under site/public/, or set avatar_mode to photo, initials, or icon.
Guide list overrides can also set "place_photo_mode": "remote_url" to force a guide to skip local site/public/place-photos/ assets and render cards from direct photo_url values instead. The default is "local_cache".
Place-level added_by metadata is preserved in generated place data. Cards show it by default only when it differs from the guide author, so collaborator additions are visible without repeating the guide owner on every card. Override a place with "added_by": {"name": "Name", "avatar_mode": "initials"} to set it manually, or "added_by": null to suppress it for that place. Set placeCard.showAttribution: false in site/config.ts to hide all individual place attributions, or placeCard.showGuideAuthorAttribution: true to also show places added by the guide author.
Refresh configured sources and rebuild generated site data:
bun run sync:sourcesRebuild generated site data from existing raw snapshots:
bun run build:dataRun the site:
bun run devVerify before shipping:
bun run check
bun run buildThe site works without enrichment, and normal builds never call Google. When you run an enrichment command, the pipeline first tries to scrape each place's Google Maps page from the saved-list URL. If that page scrape is blocked, limited, unmatched, or too sparse to trust, the pipeline can optionally fall back to the Google Places API.
Add a server/build-time Places key only if you want that API fallback:
GOOGLE_PLACES_API_KEY=...Then run one of the enrichment commands:
bun run fill:gaps
bun run enrich:data
bun run refresh:enrichmentGenerate LLM semantic descriptions from existing cached enrichment without rescraping or using the Places API:
bun run refresh:semantic-enrichment
bun run refresh:semantic-enrichment:force
bun run refresh:semantic-descriptions
bun run refresh:semantic-descriptions:forceCompare LLM model choices for semantic enrichment and scraper DOM repair with the eval harness documented in docs/llm-evals.md. The package aliases are bun run eval:llm, bun run eval:llm:semantic, and bun run eval:llm:dom-repair.
The behavior is configurable in a few places:
GOOGLE_PLACES_ENRICHMENT_STRATEGYcontrols enrichment source selection. Usescrapefor scraper-only,apifor API-only, orscrape_then_apifor scraper first with API fallback. The default isscrape_then_api.GOOGLE_PLACES_API_KEYenables API-based enrichment. It is required forapimode and for the fallback leg ofscrape_then_api.site/enrichment.jsoncontrols site-owned scraper policy.google_maps_places.llm_repairdefaults todom, whilecollect_reviewsandcollect_aboutdefault tofalseso enrichment stays compact unless a site opts into those heavier panels.GOOGLE_MAPS_PLACES_LLM_REPAIR,GOOGLE_MAPS_PLACES_COLLECT_REVIEWS, andGOOGLE_MAPS_PLACES_COLLECT_ABOUToverride the site enrichment config for automation or one-off refreshes.GMAPS_SCRAPER_PROXYroutes Google Maps list and place-page scraping through a proxy.FAVORITE_PLACES_GMAPS_SCRAPER_STATE_DIRoptionally overrides where scraper browser profiles and HTTP cookie jars are stored. Point multiple worktrees at the same absolute path when you want to reuse scraper session state across them.- The command controls refresh scope:
fill:gapsfills missing enrichment and photos,enrich:datafills missing or stale cache entries,refresh:enrichmentrefreshes every entry,refresh:semantic-enrichmentupdates cached semantic neighborhoods/tags from already cached evidence, andrefresh:semantic-descriptionsonly updates cached semantic descriptions from already cached enrichment evidence.
Example site/enrichment.json:
{
"google_maps_places": {
"llm_repair": "dom",
"collect_reviews": false,
"collect_about": false,
"semantic_llm": false,
"semantic_descriptions": false,
"semantic_description_force_refresh": false,
"price_display": {
"currency_mode": "guide_local",
"source_order": ["price_range", "admission_price", "room_price"],
"max_numeric_by_source": {
"admission_price": {
"JPY": 5000,
"TWD": 1000
}
}
},
"neighborhood_mappings": [
{
"city": "Taipei",
"country": "Taiwan",
"from": "Wanhua District",
"to": "Wanhua",
"when_address_contains": "Wanhua District"
}
]
}
}When semantic_llm is enabled and LLM credentials are configured, the pipeline uses compact cache-only evidence from price range, review topics, review snippets, and About labels to infer neighborhood, type tags, and vibe tags. semantic_descriptions separately enables generated card descriptions. Descriptions are reused while the semantic description signature remains stable; the signature tracks major quality changes such as name/address/category changes, review topics appearing, About sections changing, price range, and coarse rating/review-count buckets. Set semantic_description_force_refresh when you intentionally want to regenerate descriptions even if the signature is unchanged. If the LLM is unavailable or errors, deterministic category, locality, and vibe rules still produce the guide data.
Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY to log uncached LLM calls to Langfuse. The optional LANGFUSE_BASE_URL selects a non-default Langfuse region or self-hosted instance. Langfuse logging covers scraper place repair and semantic enrichment/description generations; cache hits are not logged as model calls. Langfuse export uses short, async-friendly defaults (LANGFUSE_TIMEOUT=2, LANGFUSE_FLUSH_AT=8, and LANGFUSE_FLUSH_INTERVAL=1.0) so observability does not dominate long refresh jobs; override those SDK settings if needed. Scraper repair logs redact URLs and omit full request/response payloads by default; set GMAPS_SCRAPER_LANGFUSE_FULL_CAPTURE=true only when you explicitly want full scraper repair payload capture.
price_display controls the card-facing price label while keeping raw scraper fields in the enrichment cache. source_order chooses which scraper field to display first: price_range, admission_price, or room_price. Numeric price_range values are displayed conservatively for food/drink/shopping-style categories; attraction tickets and lodging quotes should come through the separate admission_price or room_price fields. currency_mode supports raw, guide_local, or target; target also requires target_currency, such as USD. Symbol-only values like $$ keep the same tier and swap the symbol, while numeric prices use cached daily USD exchange rates from api.fxratesapi.com with jsDelivr currency-api fallback. If rates are unavailable, the raw price is used. max_numeric_by_source can hide implausibly large converted values by source field and display currency, which is useful when Google surfaces reseller bundles instead of a simple admission ticket.
neighborhood_mappings is an ordered site-level cleanup layer for local naming conventions. Each rule can scope by city and country, match a current from neighborhood, optionally require when_address_contains or when_candidate, and then emit to. Per-place neighborhood overrides still win over these mappings.
Manual overrides always win over machine-enriched fields.
# Browser Google Maps display key.
GOOGLE_MAPS_JS_API_KEY=...
# Server/build-time key for API-based enrichment.
GOOGLE_PLACES_API_KEY=...
# Enrichment source strategy: scrape, api, or scrape_then_api.
GOOGLE_PLACES_ENRICHMENT_STRATEGY=scrape_then_api
# Scraper LLM repair policy: off, dom, or dom_then_translation.
GOOGLE_MAPS_PLACES_LLM_REPAIR=dom
# Optional LLM semantic tags/neighborhoods from enriched cache evidence.
GOOGLE_MAPS_PLACES_SEMANTIC_LLM=false
# Optional LLM-generated card descriptions from enriched cache evidence.
GOOGLE_MAPS_PLACES_SEMANTIC_DESCRIPTIONS=false
# Force LLM-generated card description regeneration.
GOOGLE_MAPS_PLACES_SEMANTIC_DESCRIPTION_FORCE_REFRESH=false
# Optional LLM observability for uncached generations.
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
LANGFUSE_TIMEOUT=2
LANGFUSE_FLUSH_AT=8
LANGFUSE_FLUSH_INTERVAL=1.0
GMAPS_SCRAPER_LANGFUSE_FULL_CAPTURE=false
# Optional proxy for Google Maps list and place-page scraping.
GMAPS_SCRAPER_PROXY=...
# Optional shared scraper state root for browser profiles and curl cookies.
FAVORITE_PLACES_GMAPS_SCRAPER_STATE_DIR=/absolute/path/to/.context/gmaps-scraper
# Force Leaflet/OpenStreetMap rendering.
PUBLIC_MAP_PROVIDER=leaflet
# Hide place photos in the UI.
PUBLIC_PLACE_PHOTOS=off
# Point at a non-default site pack.
FAVORITE_PLACES_SITE_DIR=../siteTo share scraper state across Git worktrees and the main checkout, a practical choice is:
FAVORITE_PLACES_GMAPS_SCRAPER_STATE_DIR="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/.context/gmaps-scraper"A fresh persistent profile does not automatically fix limited-view responses on its own. It gives the scraper a durable browser profile and a durable curl cookie jar, but you may still need to run a headed scrape once against that same path to establish consent or trust state.
Use a restricted browser key for GOOGLE_MAPS_JS_API_KEY. Do not expose GOOGLE_PLACES_API_KEY to the browser.
This is a static Astro site, so it can deploy to Cloudflare Pages, GitHub Pages, or similar static hosts.
For Cloudflare Pages, use the custom build path documented in Architecture. Do not rely on Cloudflare's automatic Python dependency detection for this repo.
- Architecture: site-pack contract, Cloudflare Pages, refresh automation, data model, and enrichment internals
- Design System: current visual system notes