https://hampstead-heath.blankm.workers.dev
A twenty-four-stop loop from Hampstead Underground station, over the top of the Heath to Kenwood and back down past the swimming ponds. 27 tracks, plus the full transcript.
Every track opens with where you should be standing and closes by telling you where to walk next. Nothing auto-advances: between stops you are walking, not listening.
public/ is the published site and the only thing the Worker can see. Everything
outside it is source, and is therefore unpublishable by construction rather than
by a list of exclusions.
public/index.html– the page: map, transcript, a photograph and a play button on every stoppublic/stops/– one page per stop, 24 of them, each with its own recording and transcriptpublic/audio/– the 27 trackspublic/images/– one photograph per track, pluscredits.jsonpublic/hampstead-heath-walk.gpx,.kml,.csv– the stops and their order, in the three shapes something else will open: a walking app, Google Earth, a spreadsheetpublic/hampstead-heath-full-walk.m4a– all of it as one continuous file, for offlinepublic/cover.jpg– the artwork, also embedded in every trackpublic/og.jpg– the sharing card, and the favicon set beside itpublic/feed.xml– the same tracks as a podcast feedpublic/guide.md– the whole transcript as plain Markdownpublic/llms.txt,public/robots.txt,public/sitemap.xml– for the readers that are not peoplemap.json– the shape of the Heath, its water and its roads, from OpenStreetMap. Read at build time and inlined as SVG, so it is never servedvoice.json– which voice actually made the audio that is herebuild.py– the narration, and everything that is generated from itfetch_images.py– re-fetches the photographs from Wikimedia Commonsfetch_map.py– re-fetches the map geometry from OpenStreetMap
Everything under public/ except the photographs, the audio and the map geometry
is generated by build.py from one list of stops. None of it is edited by hand.
Narration is synthesised speech. Opening hours were checked in August 2026 and are the first thing that will change.
The macOS system voices are licensed for personal, non-commercial use only (macOS SLA §2.F), and the same clause forbids publishing or public sharing of their output. That rules them out for anything on a website, commercial or not.
The build uses Amazon Polly's generative engine instead. Commercial use is covered by the AWS customer agreement, and the free tier covers 100,000 generative characters a month for the first year – a little over twice the size of this script, so a rebuild is usually free, but two in a month is not. After that it is $30 per million characters, which is about $1.38 a rebuild.
aws configure # once; region eu-west-2
python3 build.py --voices # the en-GB generative voices
export POLLY_VOICE=Amy # or Brian; those are the two en-GB ones
python3 build.py --sample # one track, to hear it before committing
python3 build.py # all 27 tracks, then rebuilds the pageThe region matters. Polly's generative engine runs in nine regions only, and
eu-north-1 is not among them; PL_REGION defaults to eu-west-2, which is
both close and, for this walk, the right city. The pace is an SSML prosody
tag rather than a parameter, because the generative engine has no speed
control of its own.
Three other engines are available. TTS_ENGINE=google is Chirp 3: HD, needing
GOOGLE_API_KEY or GOOGLE_ACCESS_TOKEN, an open Cloud billing account and
the Text-to-Speech API enabled. TTS_ENGINE=elevenlabs (with
ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID) costs about 36,000 credits per
rebuild and sounds better; every paid ElevenLabs plan grants commercial
rights, and they survive cancelling the plan. TTS_ENGINE=say is the Mac
voice, for drafting only. Do not publish it.
--sample names its output after the engine and voice, so you can render the
same track on two of them and compare.
The continuous full-walk file is spliced from the finished tracks rather than
synthesised again, which halves the credits and guarantees it matches. Voice,
engine, region and pace are the PL_* constants at the top of build.py.
voice.json records which voice actually made the audio in the repo, and the
colophon reads from it, so the page cannot claim a voice it did not use.
Needs macOS (afconvert), mutagen and boto3; the cover also needs
pillow.
python3 build.py # audio, then the page
python3 build.py --page # page, stop pages, feed, sitemap, robots, llms.txt
python3 build.py --cover # redraw cover.jpg and og.jpg
python3 build.py --icons # redraw the favicon set and the web manifestThe text lives in one place, STOPS in build.py, so the transcript on the
page cannot drift out of step with the recording. Change the voice settings at
the top and the whole set rebuilds in one pass.
Stop numbers are assigned from walking order in build.py, not written into
each entry, so inserting one renumbers the guide by itself. Three things do
need doing by hand: the spoken cross-references in neighbouring walk texts,
a coordinate in STOPS_LL in fetch_map.py, and a picture in PICKS in
fetch_images.py.
Be aware that inserting a stop changes the index prefix on every audio and
image file after it, so the whole set has to be re-rendered and re-fetched
before the page will build. Do that on a branch: a half-renumbered main
deploys a page whose photographs and audio all 404.
A stop can carry an event: something with a date on it happening at that
place. The Pergola has one, the Open House Festival's guided tour of it.
It is never spoken. A date cannot go into a recording that will not be re-cut
when it passes, so the event text is printed on the page, written into
guide.md and emitted as schema.org Event nodes, and the audio ignores it
entirely. It is also the one block on the page that uses figures rather than
words, for the same reason.
build.py drops each date the morning after it has gone, and the whole block
once the last one has, so a rebuild in October publishes no trace of a
September tour. Nothing expires without a rebuild, though: the page is static,
so a stale notice sits there until the next --page run.
event=dict(name="Pergola Walking Tour", host="Open House Festival",
url="https://programme.openhouse.org.uk/listings/2106",
dates=["2026-09-12", "2026-09-19"], start="11:00", end="12:30",
meet="the Whitestone Pond flagpole, which is stop 8 of this walk",
body=["...", "..."])Every track has one photograph, chosen to show the thing you are standing in
front of rather than the prettiest view of it. All are Creative Commons or
public domain, from Wikimedia Commons. Each is credited under the picture with
its photographer, licence and source, and images/credits.json carries the
same information in full. To change one, edit PICKS in fetch_images.py and
re-run it, then python3 build.py --page.
If you reuse these photographs elsewhere, the CC BY-SA terms come with them.
Drawn as inline SVG from OpenStreetMap geometry: no tiles, no libraries, no requests to anybody. That matters because the middle of the Heath has no signal, and a slippy map is the first thing to fail there. Drag or pinch to move it, tap a number to play that stop, and "Where am I" uses the browser's own geolocation – nothing leaves the phone.
The dotted line is the order of the stops, not the path you walk.
Stop coordinates live in STOPS_LL in fetch_map.py. Hand-typed coordinates
were out by up to 300 metres, so check any you change against the drawn roads
and water rather than against memory. Map data © OpenStreetMap contributors,
ODbL.
The same twenty-four stops leave the site in three notations, written by
build.py from STOPS and map.json at the same moment as the page:
hampstead-heath-walk.gpx– waypoints and an<rte>, each waypoint linking back to that stop's page. For Garmin, OsmAnd, Gaia, Komoot, Organic Maps.hampstead-heath-walk.kml– the same stops with a style per kind, in the four colours the drawn map uses, plusExtendedDataso Google My Maps builds a table rather than one unsearchable balloon of prose.hampstead-heath-walk.csv– one row per stop: coordinate, category, track, duration, admission, and the URLs of its page and its audio.latitudeandlongitudeare named as such because that is what My Maps looks for.
ROUTE_FORMATS at the top of build.py is the list all three come from, and
it is also what the page, the JSON-LD DataDownload nodes and llms.txt
enumerate: a format cannot be written and then not offered, or offered and not
written. To add a fourth, add a row there and a generator beside gpx().
None of the three is the path you walk. They join the stops in straight lines, same as the dotted line on the map, because the walking directions are prose in the narration and were never a polyline. Turn-by-turn would mean tracing the Heath's own path network, which is a different and much larger job.
Deployed from main to Cloudflare Workers with npx wrangler deploy. Pushing to
GitHub does not deploy anything.
wrangler.jsonc is generated by build.py, so change the deploy config in
the generator at the bottom of build_page, not in the file – editing the file
alone lasts until the next --page run. It points at ./public, which is why
the repo is laid out this way: an earlier config of "directory": "." published
.git, the build scripts and the Cloudflare account cache along with the site.
It also sets html_handling: auto-trailing-slash, which is what serves
public/stops/kenwood-house/index.html at /stops/kenwood-house/ – the URL the
sitemap, the feed and every internal link use.
A page nobody can find is a private page. build.py --page writes everything a
crawler, a feed reader or a language model needs, from the same STOPS list, so
none of it can describe a walk that is not there.
- A page per stop.
public/stops/<slug>/– 24 of them, each with the recording, the transcript, the photograph, the coordinates and prev/next. One page can only rank for one thing; twenty-four can rank for twenty-four.stops/is the index, and it is one hop from the home page so the pages are not orphans. - Structured data. JSON-LD on every page:
TouristTripwith all 24 stops as an orderedItemListwith coordinates,TouristAttractionper stop,AudioObjectwith the transcript,ImageObjectcarrying each photographer's credit and licence,FAQPage,BreadcrumbList, andsameAslinks to Wikipedia and Wikidata so a machine knows which Hampstead Heath this is. - The head. Doctype,
lang, canonical, Open Graph and Twitter cards againstog.jpg,max-image-preview:large, a favicon set and a manifest. Without the card, a pasted link is a bare grey URL. robots.txt. Everything allowed, explicitly, including the AI crawlers, because several of them read an unanswered question as a no. AlsoContent-Signal: search=yes, ai-input=yes, ai-train=yesand the sitemap.feed.xml. The 27 tracks as a podcast. Apple, Spotify and Overcast are a search engine each.guide.mdandllms.txt. The whole thing as plain text.llms.txtis a convention no crawler has yet promised to read;guide.mdis the file that actually gets quoted.
ORIGIN at the top of build.py is the canonical origin – scheme and host – and
every absolute URL is built from it, so moving to a real domain is one line. It
sits next to SITE, which is the directory those URLs are served from; the two
are deliberately not the same name.
Four things are not code and have to be done by hand:
- Verify the site in Google Search Console and Bing Webmaster Tools and submit the sitemap. Bing matters more than its market share suggests, because ChatGPT's search grounding leans on Bing's index.
- Check that Cloudflare's AI Crawl Control is not blocking
OAI-SearchBotandPerplexityBotat the edge, which it does regardless ofrobots.txt. - Set
OWNER_EMAILinbuild.pybefore submitting the feed: Apple Podcasts will not accept it without an owner address. - A custom domain.
workers.devis on the Public Suffix List and carries no accumulated authority, so it is the ceiling on anything else here.
Two licences, because there are two kinds of thing in here.
- The code –
build.py,fetch_images.py,fetch_map.py– is MIT. SeeLICENSE. - The narration, the transcript and the walk itself are CC BY 4.0. See
LICENSE-CONTENT. - The photographs are not covered by either. Each belongs to its
photographer under its own Creative Commons terms, recorded per file in
public/images/credits.jsonand credited under every picture. If you reuse them, the terms come with them. Map data © OpenStreetMap contributors, ODbL.