Swanton Baseball
Three fantasy baseball leagues on one site, with history back to 2012. Standings, rosters, keepers, and AI-written league recaps, fed by sixteen cron jobs that pull from ESPN and Yahoo on separate schedules.
Synthesizing a scoring format the platform doesn't have
Two of the leagues are head-to-head, which ESPN understands. One is all-play: every week, every team is scored against every other team, and you bank a 9-0 or a 4-5 instead of one matchup result. ESPN has no concept of this at all.
So it gets manufactured:
for (const opponent of teams) {
if (opponent.espnTeamId === team.espnTeamId) continue
if (team.score > opponent.score) wins++
else if (team.score < opponent.score) losses++
else ties++
}
const rank = teams.filter((t) => t.score > team.score).length + 1
W/L/T always sums to nine, which is the invariant worth asserting on — a bug anywhere in the ingest shows up as a week that doesn't add up rather than as standings that are quietly wrong.
The interesting part is that both formats share one Score table. opponentId is nullable
and populated only for head-to-head; all-play rows leave it null and carry the synthesized
record instead. One schema, two scoring models, no discriminator column and no second table
to keep in sync.
Head-to-head results deliberately record UNDECIDED pairings too, so a matchup renders on
the schedule before it's been played. The alternative is a page that's empty until Sunday
night.
The pipeline is the real system
Sixteen cron.schedule() jobs, all pinned to America/New_York because fantasy deadlines are
wall-clock local and DST will otherwise move them twice a year. Scores sync every fifteen
minutes. Player status runs three times daily. Keepers sync Wednesday at noon. The newsroom
fires at 8am Tuesday through Sunday.
They run in their own container rather than as host crontab entries — which turned out to matter, because a later server migration wiped the host crontab and every in-container schedule survived untouched.
29 Prisma models, 27 migrations, 252 TypeScript files, ~313 tests.
The part that stays fragile
ESPN authentication is browser-cookie scraping — espn_s2 and SWID lifted from a logged-in
session. There is no API key to rotate and no documented contract to rely on. It expires
roughly annually, always mid-season, and the only fix is to open a browser and go get the
cookies again. Worth naming rather than hiding: some integrations don't have a clean version,
and pretending otherwise just means the failure surprises you.