← Back to Writing

Automating my Football Schedule

I automated my 5-a-side football schedule into Google Calendar using Google Apps Script — scraping an ASP.NET site, handling ViewState encoding, and sharing it with teammates for free.

Automating my Football Schedule

I play 5-a-side football twice a week at Goals Australia in Carrara. Two different teams, two different nights, one shared problem: the schedule was posted online, and keeping track of it required checking the website manually every week. Occasionally I missed a kickoff time or turned up at the wrong pitch.

The straightforward response would have been to check the site more consistently. Instead I spent an evening automating it. The time investment was disproportionate to the inconvenience it solved, but the process was instructive in ways that made it worthwhile.

The Goal (Pun Intended)

The objective was clear: game fixtures should appear in my Google Calendar automatically, without manual intervention. The system needed to handle both teams, extract the correct time and pitch, prevent duplicate entries, and run on a schedule without any ongoing cost. Free, fully automated, and simple enough to share with teammates.

My first instinct was to find an iCal or API feed from the Goals Australia website. The schedule is hosted on a platform called EZLeagues by EZFacility, and they do publish calendar sync documentation. That turned out to be a dead end. The calendar sync feature is restricted to facility administrators, not players.

The alternative was Google Apps Script, a JavaScript runtime that runs on Google's servers at no cost, with access to Gmail, Google Calendar, Google Sheets, and external HTTP requests. The free quota is generous enough for a lightweight daily script. The plan was to write a function that fetches the schedule page for each team, identifies the next upcoming fixture, and creates the corresponding calendar event.

function syncGoalsSchedule() {
  var calendar = getOrCreateCalendar("⚽ Goals 5-a-side");
  syncTeam(calendar, "MONDAY_TEAM_ID", "My Monday Team", CalendarApp.EventColor.BLUE);
  syncTeam(calendar, "THURSDAY_TEAM_ID", "My Thursday Team", CalendarApp.EventColor.GREEN);
}

Each team is assigned a distinct colour in the calendar, a small detail that becomes useful when scanning a week at a glance.

Problem One: The Website is Built With ASP.NET ViewState

The EZLeagues pages use an old ASP.NET pattern called ViewState, which encodes a chunk of page state into a large compressed blob of text embedded in the HTML. When I first fetched the page and attempted to parse it, the parser was looking for clean HTML table rows and finding nothing useful. The log output was consistent:

Found 0 upcoming game(s) for [Team Name]

The breakthrough was finding a /print.aspx endpoint on the EZLeagues platform. Print pages are designed to function without JavaScript, which means they render actual HTML rather than relying on the browser to reconstruct the view. Fetching from that endpoint made the schedule data immediately readable in standard table rows. Web scraping rarely works as expected on the first attempt. The solution here required understanding what the print endpoint was doing differently, and adjusting the approach to match the actual rendered output rather than making assumptions about the page structure.

The next problem was that the regex was still matching segments of the ViewState blob rather than the intended table cells. Most of the debugging in this project came down to logging what the page actually returned and comparing it to what the code was expecting. Once the real HTML structure was visible, writing accurate selectors was straightforward. The fix was to strip the ViewState field first, then target table rows using the data-th attributes on each cell:

html = html.replace(/value="\/wE[^"]{50,}"/g, 'value="[removed]"');

var rowRe = /<tr[^>]*class="(?:RowStyle|AlternateRowStyle)"[^>]*>([\s\S]*?)<\/tr>/gi;

With that in place, extracting the date, time, venue, home team, away team, and completion status became reliable.

Problem Two: Times Were Missing

After the first working version, the debug output showed all four scheduled games with correct dates and opponents, but every kickoff time was showing as 18:00, the default fallback. The time is stored in the Time/Status cell, which displays either Complete for finished games or the actual kickoff time for upcoming ones, in the format 8:05 PM. The parser was reading the cell correctly, but the regex was not matching because of how the cell label was being escaped in the regular expression.

var statusStr = cell("Time\\/Status");

var timeM = statusStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (timeM) {
  var h = parseInt(timeM[1]);
  var m = parseInt(timeM[2]);
  if (timeM[3].toUpperCase() === "PM" && h !== 12) h += 12;
  d.setHours(h, m, 0, 0);
} else {
  d.setHours(18, 0, 0, 0); // sensible default until schedule is posted
}

The 6pm default proved genuinely useful in practice. Goals Australia sometimes publishes the fixture list before kickoff times are confirmed. The script creates the event with a placeholder time and corrects it automatically on the next daily run once the real time appears on the site. A default should reflect the actual use case, not just guard against parsing failure. Six pm is a reasonable estimate for an unconfirmed game time, and designing it that way made the tool meaningfully more useful.

Running the script daily also means it encounters the same upcoming fixtures multiple times before they occur. Without a duplicate check, events would accumulate on the same calendar day. The solution was to check for an existing matching event before creating anything new:

var dayStart = new Date(game.date); dayStart.setHours(0,0,0,0);
var dayEnd   = new Date(game.date); dayEnd.setHours(23,59,59,999);

var clash = calendar.getEvents(dayStart, dayEnd).some(function(e) {
  return e.getTitle().indexOf(teamName.substring(0,8)) !== -1;
});

if (clash) return;

It checks the full day window, finds any existing events that contain the team name, and skips creation if one already exists.

The trigger was initially set to run weekly on Sunday evenings, on the assumption that the schedule would always be published by then. That assumption did not hold. Goals Australia sometimes posts mid-week, which meant fixtures could be missed until the following Sunday. Switching to a daily trigger at 3pm resolved this. Each run takes approximately two seconds and uses two URL fetch calls, which barely registers against Google's free quota limits.

Sharing It With the Team

Once the script was working reliably, the natural next step was making it available to teammates. The Apps Script deploy flow is designed for publishing web apps and APIs, which was more infrastructure than this required. Instead, I prepared three self-contained versions of the code: one for Monday, one for Thursday, and one covering both. Anyone could paste their version into their own Apps Script project and run it independently. Each version runs on the individual's own Google account, writes to their own calendar, and requires no shared infrastructure. I also wrote a plain-language setup guide that did not assume any prior knowledge of coding.

Both teams now sync automatically. Games appear in the calendar with the correct time, the correct pitch, and a location link. When a time has not yet been confirmed, the event appears at 6pm and updates itself the following day. The script runs on a daily trigger, costs nothing, and handles both teams without manual input. The main outcome is that understanding how the platform rendered its data, and where to look for a cleaner version of it, turned out to be more important than the scripting itself.

A logical next step would be a weekly digest that aggregates calendar events, upcoming fixtures, and local weather into a single scheduled summary. That is a separate project.