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:
- HTTP/1.1 with a browser
User-Agent→200 - HTTP/2, any headers →
403"Attention Required!" - A
curlorpython-requestsUser-Agent→403 /displayFixture.html(per-fixture detail) →403for every client that isn't a real browser
Deno'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.
- Title
Home Team v Away Team - Location the venue as Full-Time lists it (a name only; addresses live on the blocked detail pages, so each event links to its fixture instead)
- Notes
Type:,Competition:, aStatus:line when there is one, then the fixture's URL - Duration kick-off plus
MATCH_DURATION_MINUTES(60) - Times
Europe/London, with aVTIMEZONEso BST and GMT both resolve correctly - A fixture with no confirmed kick-off becomes an all-day event; one marked postponed, cancelled or
abandoned gets
STATUS:CANCELLED, which Apple Calendar strikes through
Event 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:
- A failed scrape never overwrites a good calendar — the error goes to a separate
last-errorblob and shows on the setup page, while subscribers keep the previous feed. - The feed is only re-rendered when its content actually changes (the hash ignores
DTSTAMP), soETags stay stable and devices get304s 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.
- One page of fixtures (
itemsPerPage=100);parse.tslogs a warning if it ever hits that cap. - No venue addresses, referees or match reports: those only exist on
/displayFixture.html. - Anyone with the feed URL can read the fixture list, so treat it as private.