Four public event sites + one internal proxy. Each public site is a Hono app on a Cloudflare Worker, with hourly-scraped data bundled into the worker, a D1 database for push subscriptions, and a small set of shared primitives in packages/core (security headers, robots/llms.txt, api-catalog, markdown rendering, WebMCP wiring, calendar popover, Turnstile lazy-load).
Aggregated programme for Frankfurt's Museumsufer district (museums + exhibitions + events). Apex ins.museum 301-redirects to frankfurt.ins.museum; sitemap declares museumsufer.app as the canonical brand host.
GET /— day view with date strip;?date=YYYY-MM-DD&range=2-14&lang=de|en|frGET /museum/:slug— single museum: hours, exhibitions, events, JSON-LDMuseumschemaGET /impressum(+/imprint→ 301, preserves?lang) — imprintGET /api/docs— Scalar OpenAPI reference UIGET /partial/content— HTMX fragment for date/range swaps (returnsX-Date-Label)
GET /api/day,/api/events,/api/exhibitions,/api/museums(?date,?lang)GET /feed.ics(+ alias/calendar.ics),GET /api/event/:id.icsGET /feed.xml(+ alias/rss.xml) — RSS 2.0, 7-day windowPOST /api/transit— RMV transit times + walking distance per museum ({lat,lng}body, 20 km radius)POST /api/like— anonymous likes (visitor hash from IP + day)POST /api/contact— Turnstile-verified, routed tofeedback@ins.museumGET /api/push/key | subscribe | unsubscribe | me
/robots.txt(CF-managed Content Signals + worker sitemap pointer)/sitemap.xml,/llms.txt,/.well-known/llms.txt,/.well-known/api-catalog/api/docs/openapi.json(OpenAPI 3.1)Linkheaders on HTML:api-catalog,service-desc,service-doc,describedby- Markdown content negotiation on day view + museum pages (
Accept: text/markdown) - WebMCP tools:
get_events,get_exhibitions,get_museums,get_day_overview,search_museumsufer
- Date strip + range nav, HTMX content swaps with URL push
- Theme toggle (light/dark, localStorage, FOUC-prevention inline script)
- Per-event/exhibition like buttons (AJAX)
- Share menus building Google / Outlook / Yahoo calendar deep-links (UTM-tagged)
- Push digest signup dialog with per-museum filters and 3 schedules
- "Problem melden" contact dialog (Turnstile-gated) — feedback / error reports / suggestions →
feedback@ins.museum - Client-side search (NFD + diacritic-strip)
- "Ask your AI" deep-links — pre-filled day-context prompt routed to Gemini AI Mode, ChatGPT, Claude, Perplexity, Grok (shared via
packages/core/src/llm-services.ts) - FAQ section (6 collapsible Q&A) with
FAQPageJSON-LD emitted viapackages/core/src/faq.ts
- Crons
0 5,6 * * *,0 15,16 * * *,0 7,8 * * SUN(Berlin-aware morning 07:00, afternoon 17:00, weekly Sunday 09:00); only the matching local hour dispatches the push digest - Scraping runs in
.github/workflows/scrape.yml, regeneratessrc/scrape-data.ts, push triggers CF redeploy
de(default),en,fr;Accept-Languagedetection,?lang=override,Content-Languageper response, hreflang<link rel=alternate>
- ~20+ museum-specific scrapers under
src/scrapers/covering Frankfurt's Museumsufer institutions (Städel, Schirn, MMK, Senckenberg, Jüdisches Museum, Historisches Museum, DAM, MAK, Liebieghaus, Weltkulturen, Bibelhaus, Dommuseum, Caricatura, FFF, FKV, Giersch, Ledermuseum, FDH, Archäologisches, Experiminta, Bürgerstiftung, MFK, DFF/Kino…) plus per-museum exhibition parsers
- Cloudflare D1 binding
DB(push subscriptions, likes) - Turnstile (site key
0x4AAAAAADNxRIr9duIRP8ag),TURNSTILE_SECRETvia wrangler secret - VAPID env vars
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT - Cloudflare Email Routing →
feedback@ins.museum /img/*image proxy with museum-domain allowlist
Aggregated theatre programme for Frankfurt. Apex ins.theater 301-redirects to the subdomain.
GET /— day programme with 60-day date stripGET /tag/:date— dated programmeGET /theater/:slug— single theatre with 60-day scheduleGET /partial/programme— HTMX fragmentGET /impressum(+/imprint→ 301)GET /api/docs— Scalar UI
GET /api/day,/api/theaters,/api/theater/:slugGET /api/performances?from&to&theater(≤ 60-day range),GET /api/performance/:idGET /feed.ics(14-day),GET /performance/:id/feed.icsGET /feed.xml//rss.xmlPOST /api/contact,GET|POST /api/push/{key,subscribe,unsubscribe,me}
/robots.txt,/sitemap.xml,/llms.txt(+.well-known),/.well-known/api-catalog,/api/docs/openapi.json/manifest.json(PWA)Linkheaders + markdown content negotiation on day and theatre viewsX-Robots-Tag: noindexon/api/*(excluding/api/docs)- WebMCP tools:
get_performances,get_performances_range,get_theaters,get_theater,search_performances
- 60-day date strip with performance counts; HTMX
hx-push-urlswaps - Transit popover (RMV app, RMV web, Google Maps, Apple Maps deep-links)
- Calendar popover for per-performance ICS
- Status badges: "Ausverkauft", "Entfällt", "Restkarten"
- Push digest dialog (3 schedules + per-theatre filter)
- Contact dialog (Turnstile-gated)
- "Ask your AI" deep-links — pre-filled date-context prompt routed to Gemini AI Mode, ChatGPT, Claude, Perplexity, Grok (shared via
packages/core/src/llm-services.ts) - FAQ section (6 collapsible Q&A) with
FAQPageJSON-LD emitted viapackages/core/src/faq.ts - Service worker at
/sw.js(cache v5)
- Same cron triad as museums; Berlin-local dispatch for morning / afternoon / Sunday digests
- Scraping in GitHub Action, hourly during the active Berlin window
- German only (
de-DE)
~23 venue-specific scrapers in src/scrapers/ for: Schauspiel Frankfurt, Oper Frankfurt, The English Theatre, Die Komödie, Mousonturm, Neues Theater Höchst, Volksbühne, Stalburg, Tigerpalast, Die Schmiere, Dresden Frankfurt Dance Company, Papageno, Dramatische Bühne, Theater Willy Praml, Kellertheater, Gallus Theater, Theaterhaus, Internationales Theater, Galli Theater, Theater Alte Brücke, Die Käs, Theater Lempenfieber, Landungsbrücken.
- D1 (
frankfurt-theaters-db), Turnstile, VAPID push, Cloudflare Email →feedback@ins.theater
Concert programme aggregator (classical, jazz, sacred, world, experimental, chamber). Apex konzert.haus 301-redirects to frankfurt.konzert.haus. The URL path is city-scoped (<city>.konzert.haus) and currently serves Frankfurt + surrounding region.
GET /— day programme with date strip + genre filterGET /tag/:date— dated programme (hreflang alternates de/en/fr)GET /spielort/:slug— single venue (60-day schedule,MusicVenueJSON-LD)GET /genre/:slug— genre listing (60-day window)GET /impressum(+/imprint→ 301)GET /api/docs— Scalar UI
GET /api/events?date|from|to[,genre,venue,city](range capped at 90 days)GET /api/events/:id,GET /api/venues,GET /api/venues/:slug- Calendars:
/feed.ics,/spielort/:slug/feed.ics,/genre/:slug/feed.ics,/event/:id/feed.ics /feed.rss(+/feed.xml→ redirect)GET /og/:id/image.svg— dynamic OG imagePOST /api/contact,GET|POST /api/push/{key,subscribe,unsubscribe,me}
/robots.txt,/sitemap.xml,/llms.txt(+.well-known),/.well-known/api-catalog,/api/docs/openapi.json,/manifest.jsonLinkheaders; markdown content negotiation on day view + venue page- WebMCP tools:
get_events,get_venues,list_venue_slugs,list_genres,search_programme
- Calendar popover (add to device calendar) per event
- Genre pills (6 genres) + 60-day date strip; HTMX
partial/programmeswaps - Push digest dialog with genre filter; contact dialog with Turnstile
- Theme toggle, language switcher (de/en/fr)
- Service worker at
/sw.js - Past-event filtering (hides events ≥ 30 min in the past on today's view; surfaces the hidden count)
- Same cron triad as the other apps; Berlin-aware morning / afternoon / Sunday digests
de(default),en,fr; full UI translation incl. genre labels
~19 venue scrapers in src/scrapers/, configured in concert-config.ts:
- Frankfurt classical: alte-oper, oper-frankfurt, dr-hochs-konservatorium, hfmdk, ensemble-modern, hr-sinfonieorchester, holzhausenschlösschen
- Jazz: hr-bigband, jazz-frankfurt, jazz-palmengarten
- World / experimental: brotfabrik, romanfabrik
- Sacred: andreas-koehs, kirchenmusik-dreikoenig, st-katharinen
- Regional: kronberg-academy, rheingau-musikfestival, bad-homburger-schlosskonzerte, bad-soden
- D1 (
konzert-haus-db), Turnstile, VAPID push, Cloudflare Email →feedback@konzert.haus - UTM tagging on outbound ticket URLs via
buildUtm("frankfurt.konzert.haus")
Aggregated cultural events for Landau in der Pfalz and the Südliche Weinstraße. Serves apex landau.today + www.landau.today (CF custom domains).
GET /— today's events, optional?date=YYYY-MM-DDGET /c/:cat— category-filtered view (16 slugs)GET /partial/content— HTMX fragment for category/date swapsGET /event/:id— event detail (schema.org, calendar deep-links, VRN transit)GET /event/:id.ics— single-event iCalGET /impressum(+/imprint→ 301)GET /api/docs— Scalar UI
GET /api/day?date&category—{date, count, events[]}GET /api/events— flexible (dateorfrom/to), ≤ 90-day rangeGET /api/events/:id,GET /api/categoriesGET /feed.xml(RSS, 7-day),GET /feed.ics(14-day)POST /api/contact— Turnstile-verified →feedback@landau.todayGET|POST /api/push/{key,subscribe,unsubscribe,me}
/robots.txt,/sitemap.xml,/llms.txt(+.well-known),/.well-known/api-catalog,/api/docs/openapi.jsonLinkheaders; markdown content negotiation on/and/c/:cat- WebMCP tools:
get_events,list_categories,search_events
- Client-side search (
.js-search), diacritic-fold + token match; ⌘/Ctrl+K shortcut - Theme toggle (localStorage, FOUC inline)
- "In der Nähe" — geolocation haversine sort with distance badges; re-stamps order after htmx swap
- Visited-event tracking via
localStorage('landau-today-visited') - Per-row share with
?highlight=<id>; native share API + clipboard fallback + toast ?highlight=arrival animation (pulse + scrollIntoView)- Service worker (offline cache)
- FAQ section (translated
de/fr) withFAQPageJSON-LD viapackages/core/src/faq.ts - Push digest dialog (3 schedules + 16 category filters, iOS PWA hint, capability detection)
- Contact dialog (Turnstile lazy-load on open)
- Same cron triad; Berlin-aware morning/afternoon/Sunday digest delivery
- Scraping in
.github/workflows/scrape.yml
de(default),fr; query?lang=+Content-Languageper response
kulturnetz— kulturnetz-landau.de (schema.org microdata, 15 category pages)landau-de— Advantic CMS; HTML listing joined with ICS feed by title; KatID → unified categorieshambacher-schloss— WP + Modern Events Calendar RSS namespacerptu— RPTU newsroom RSS, filtered for "Landau"suew— TYPO3 sfcontenthub, 50+ villages, pagination cappfalz-de— Drupal; sitemap pre-filter + per-event verification; recurring events fanned out 30-day horizon; concurrency-limited with overall budget cap
konzert, theater, tanz, kino, kabarett, literatur, vortrag, ausstellung, feste, junge-kultur, kurse, nachtleben, gedenken, exkursion, sport, sonstiges
- D1 (
landau-today-db), Turnstile, VAPID push, Cloudflare Email Routing /img/*image proxy with explicit upstream allowlist (kulturnetz, landau.de, suedlicheweinstrasse.de, pfalz.de, hambacher-schloss.de), 7-day edge TTL, stripsSet-Cookie
Tiny Node service (Docker, node:22-alpine) that fetches HTML on behalf of the scrapers when an upstream blocks datacenter IPs or has a broken TLS chain.
GET /?url=<target>— proxies the URL, returns body with original status + content-type. Sends a Chrome User-Agent, follows redirects, disables TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0).- Bearer auth via
Authorization: Bearer <AUTH_TOKEN>whenAUTH_TOKENenv var is set (otherwise open). - 400 if
urlmissing, 401 on bad/missing token. - No scheduled jobs; request-driven only.
- Scrapers route through it by setting
proxy: trueon the upstream config and readingFETCH_PROXY_URL+FETCH_PROXY_TOKENfrom the worker env.
Used by every public app. Keeps the four sites byte-for-byte consistent on the agent-facing surface.
buildRobotsTxt— emits Sitemap + LLMs pointer; bot rules and Content-Signal directives are owned by Cloudflare's "Managed robots.txt" feature, which prepends them at the edge per-zone.buildApiCatalog—/.well-known/api-cataloglinkset (api-catalog + service-desc + service-doc + status).buildManifest— PWA manifest builder.buildWebMcpScript/WebMcpToolDef— inline WebMCP registration (usesnavigator.modelContext.provideContextwithregisterToolforward-compat).renderDayMarkdown/renderVenueMarkdown/wantsMarkdown— markdown views for content-negotiated agent responses.securityHeaders— standard security headers middleware (CSP, X-CTO, Referrer-Policy, Permissions-Policy).handleContactRequest— shared Turnstile-verified contact form → Cloudflare Email Routing.CalendarPopover+POPOVER_POSITIONING_SCRIPT— add-to-calendar UI.HTMX_LIFECYCLE_SCRIPT,TURNSTILE_LAZY_LOAD_SCRIPT,THEME_FOUC_SCRIPT— small inline JS snippets re-used across apps.buildHreflangAlternates,langSwitchItems,buildLangParam— i18n routing helpers.buildUtm— UTM tagger for outbound ticket / detail URLs.LLM_SERVICES(llm-services.ts) — "Ask your AI" target list (Gemini AI Mode, ChatGPT, Claude, Perplexity, Grok) with brand colours, SVG glyphs, andbuildUrl(prompt)deep-links.buildFaqPageSchema(faq.ts) — JSON-LDFAQPagebuilder used by the museums / theaters / landau FAQ sections.- Date helpers:
todayIso,dateOffset,berlinHourMinute,formatLocalisedDateLong, German month/weekday tables,compareNullsLast,escapeHtml.