fulltime-ics

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.

The feed

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.

Configuration

Three environment variables, all required; the val throws a named error if one is missing.

VariableWhat it is
FULLTIME_QUERYThe 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_NAMEThe 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_TOKENSecret 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).

How the pieces fit

FileRole
config.tsReads the environment and builds Full-Time URLs
fetchPage.tsFetches a Full-Time page past its Cloudflare WAF
parse.tsFixtures table → Fixture[], plus reading the page's own dropdowns
ics.tsFixture[] → iCalendar text
refresh.tsScrape → parse → render → cache in blob storage
scrape.tsInterval val, hourly on cron 17 * * * *
main.tsHTTP val: serves the cached feed and the setup page

The one thing to be careful about

fulltime.thefa.com blocks requests by their shape, not by IP. Measured behaviour:

  • HTTP/1.1 with a browser User-Agent200
  • HTTP/2, any headers → 403 "Attention Required!"
  • A curl or python-requests User-Agent403
  • /displayFixture.html (per-fixture detail) → 403 for 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.

What the calendar contains

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:, a Status: line when there is one, then the fixture's URL
  • Duration kick-off plus MATCH_DURATION_MINUTES (60)
  • Times Europe/London, with a VTIMEZONE so 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.

Caching

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-error blob 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), 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.

Working on it locally

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.

Limits

  • One page of fixtures (itemsPerPage=100); parse.ts logs 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.