Turns one team's fixture list on FA Full-Time into a calendar you can
subscribe to on a phone or laptop. A cron job scrapes the fixtures page every hour, renders an
.ics feed, and caches it; an HTTP endpoint serves that cache.
Everything identifying — which club, which team, which URL — lives in environment variables, so this source can stay public.
https://<subdomain>.val.run/<FEED_TOKEN>/calendar.ics the calendar itself
https://<subdomain>.val.run/<FEED_TOKEN> a page with a webcal:// subscribe link
FEED_TOKEN is a secret first path segment: the feed needs no login (so Apple Calendar can refresh
it unattended) but can't be found by guessing the hostname. Anything without the token — the bare
hostname included — gets a bare 404.
Three environment variables, all required; the val throws a named error if one is missing.
| Variable | What it is |
|---|---|
FULLTIME_QUERY | The query string from a Full-Time fixtures URL. This is what pins the feed to one team — copy it from the address bar after filtering to the team on the site, keeping selectedSeason, selectedTeam and selectedRelatedFixtureOption. |
TEAM_NAME | The team exactly as Full-Time spells it. Used as the calendar's name, and to re-find the team's id after a season rolls over. |
FEED_TOKEN | Secret path segment, e.g. openssl rand -hex 16. Changing it changes the feed URL, so every device has to re-subscribe. |
To point this at a different team, change FULLTIME_QUERY and TEAM_NAME, then delete the
fixture-ids blob (or just run scrape.ts, which re-derives the ids when the old ones return
nothing).
| File | Role |
|---|---|
config.ts | Reads the environment and builds Full-Time URLs |
fetchPage.ts | Fetches a Full-Time page past its Cloudflare WAF |
parse.ts | Fixtures table → Fixture[], plus reading the page's own dropdowns |
ics.ts | Fixture[] → iCalendar text |
refresh.ts | Scrape → parse → render → cache in blob storage |
scrape.ts | Interval val, hourly on cron 17 * * * * |
main.ts | HTTP val: serves the cached feed and the setup page |
fulltime.thefa.com blocks requests by their shape, not by IP. Measured behaviour:
User-Agent → 200403 "Attention Required!"curl or python-requests User-Agent → 403/displayFixture.html (per-fixture detail) → 403 for every client that isn't a real browserDeno's fetch negotiates HTTP/2 on Val Town and so gets blocked. fetchPage.ts therefore speaks
HTTP/1.1 by hand over Deno.connectTls with ALPN pinned to http/1.1, and keeps two fetch-based
fallbacks behind it. Replacing that with a plain fetch() silently breaks the hourly scrape —
check get_logs for which rung succeeded (fetched via raw-tls-http1.1) before suspecting the
parser.
One event per upcoming fixture — a match drops out of the feed once its date has passed.
Home Team v Away TeamType:, Competition:, a Status: line when there is one, then the fixture's URLMATCH_DURATION_MINUTES (60)Europe/London, with a VTIMEZONE so BST and GMT both resolve correctlySTATUS:CANCELLED, which Apple Calendar strikes throughEvent UIDs come from Full-Time's own fixture ids, so rescheduling a match moves the existing event
rather than creating a second one.
refresh.ts writes { ics, contentHash, fetchedAt, … } to the val's blob storage under
calendar. Two details worth knowing:
last-error blob
and shows on the setup page, while subscribers keep the previous feed.DTSTAMP), so
ETags stay stable and devices get 304s instead of re-downloading an identical calendar.main.ts also refreshes on demand if the cache is more than 90 minutes old, so a missed cron run
heals itself on the next request, with one in-flight refresh at a time.
deno check --allow-import *.ts # scrape.ts needs Val Town's Interval global, so it fails here
parse.ts and ics.ts are pure functions over a saved copy of the fixtures page, which makes them
easy to test without touching the network. On the platform, use run_file on scrape.ts and read
get_logs rather than waiting for the cron.
itemsPerPage=100); parse.ts logs a warning if it ever hits that cap./displayFixture.html.