Skip to main content
Glama
troelskn

swehockey

by troelskn
README.md
# swehockey

Look up Swedish ice-hockey data from
[stats.swehockey.se](https://stats.swehockey.se) — schedules and results,
standings, team rosters, and season-total scoring & goalie stats — from your **AI
assistant** (via MCP) or the command line.

It's built for following youth series. Out of the box it covers **Skåne U16P,
U15P and U14P** (boys, seasons 2024-25 and 2025-26); you can
[add any other league](#add-your-own-leagues) yourself — another district, age
group, or the national/regional leagues.

## What you can ask your assistant

Once it's [connected](#connect-it-to-your-ai-assistant-mcp), you can ask things like:

- "How did the Skåne U16P teams do last weekend?"
- "Show the current U14P standings."
- "Who are Rögle's top scorers in U16P this season?"
- "Which U15P goalies have the best save percentage?"
- "List the teams in the U14P league."

It reads the official stats site and hands back clean, current data — no scraping
or copy-pasting on your side.

## Connect it to your AI assistant (MCP)

`bin/swehockey-mcp` is an [MCP](https://modelcontextprotocol.io) server — the
standard way to give an assistant new abilities. Point your assistant's MCP
configuration at it:

```json
{
  "mcpServers": {
    "swehockey": {
      "command": "bun",
      "args": ["/abs/path/to/swehockey/bin/swehockey-mcp"]
    }
  }
}
```

Replace the path with where you put this repo. You'll need [Bun](https://bun.sh)
installed (`curl -fsSL https://bun.sh/install | bash`), then run `bun install`
once inside the repo.

Your assistant then gets **read-only** tools to:

- see which leagues are set up, and the tournaments in each;
- browse districts and their cups;
- get schedules & results, standings, and team lists;
- get season-total scoring and goalie stats, grouped by club (and filterable to a
  single club).

It only reads — it can't change anything on the site or your machine. (Adding
leagues is done in the catalog file, [below](#add-your-own-leagues).)

New to MCP setup? See
[coworkerai.io/guide/mcp-setup](https://coworkerai.io/guide/mcp-setup).

## The leagues it knows about

The tool only answers about leagues listed in its catalog,
[`config/leagues.json`](config/leagues.json). It ships with **Skåne U16P / U15P /
U14P** for **2024-25** and **2025-26**. Anything else, you add.

### Add your own leagues

The catalog is a plain JSON file you can grow. The easiest way:

1. **Find the league** with the built-in `discover` helper (on the command line):
   ```bash
   ./bin/swehockey discover-leagues            # districts, grouped by region
   ./bin/swehockey discover-leagues 21103      # drill into one district (Skåne)
   ./bin/swehockey discover-leagues national   # the national leagues (SHL, U20 Nationell, …)
   ./bin/swehockey discover-leagues regional   # the region-wide leagues
   ```
2. **Add it** by appending `--write` to a league's id — it writes the entry for you:
   ```bash
   ./bin/swehockey discover-leagues 19001 --write
   ```

(Working with a coding assistant? You can also just ask it to "add the Stockholm
U16 league" and let it run those for you.) Full details in
[Growing the catalog](#growing-the-catalog).

Prefer to edit by hand? Each entry is one **league** (a region + age-group +
season) bundling the **tournaments** (the raw series on the site) it's made of:

```json
{
  "key": "skane-u16p-2025",
  "region": "Skåne",
  "ageGroup": "U16P",
  "season": "2025-26",
  "tournaments": [
    { "id": 18433, "name": "U16P Fortsättning" },
    { "id": 18434, "name": "U16P Grund A" }
  ]
}
```

- `key` is the short handle you'll use (e.g. "standings for `skane-u16p-2025`").
- each tournament `id` is swehockey's own number for that series — it's in the page
  URL, `…/Overview/18433`; `name` is just a label.

Edits are picked up **live** — the file is re-read whenever it changes, so you
don't have to restart the tool or the MCP server after adding a league. It's
re-validated each time, so a typo (missing field, duplicate id) shows up as a
clear error on the next request rather than misbehaving silently.

## Command line (optional)

Everything the assistant can do is available directly too. Add `--json` to any
command for machine-readable output.

```bash
./bin/swehockey leagues                       # the leagues that are set up
./bin/swehockey leagues skane-u14p-2025       # one league's tournaments
./bin/swehockey schedule skane-u14p-2025      # games + results, chronological
./bin/swehockey standings skane-u16p-2025     # standings, one table per tournament
./bin/swehockey playerstat skane-u16p-2025    # scoring, season totals by club
./bin/swehockey playerstat skane-u16p-2025 --team "Rögle BK"   # one club only
./bin/swehockey goaliestat skane-u16p-2025    # goalie stats by club
./bin/swehockey teams skane-u14p-2025         # the teams in a league
./bin/swehockey districts                     # ice-hockey districts, by region
./bin/swehockey cups 21103                    # cups in a district (id from `districts`)
./bin/swehockey schedule 18436                # a single tournament or cup, by id
```

Most commands take either a league `key` (from your catalog) or a raw numeric
tournament/cup `id` from the site. Run `./bin/swehockey help` for the full list.

> **Cups.** swehockey holds cups in its stats system (a "Cups" section per
> district). `districts` then `cups <district-id>` finds them — the "Cups" section
> plus any cup-named entry mis-filed above the divider. A cup id then works with
> the other read commands — `schedule` especially (cups are knockouts, so they
> usually have no standings or roster).

---

The rest is reference for maintaining and extending the tool.

## Concepts: tournaments and leagues

| | Meaning |
|---|---|
| **Tournament** | one raw series as the site presents it, keyed by its numeric `tournamentID` (e.g. `18433` "U16P Fortsättning") |
| **League** | a broad grouping — one region + age-group + season (e.g. `skane-u16p-2025`) — that bundles several tournaments |

A youth season is often split into several tournaments (an autumn/spring split,
re-seeded into strength groups), so one **league** holds ~4–7 **tournaments**. We
don't model that autumn/spring split — swehockey doesn't mark it consistently, so
we'd be guessing — tournaments are just listed in catalog order.

The catalog ([`config/leagues.json`](config/leagues.json)) is **data, not code**:
swehockey ids are season-specific and turn over every year, so you edit JSON to
add a season — no source changes. It's read on demand and cached against the
file's modified-time ([`src/catalog.ts`](src/catalog.ts)), so changes are picked
up without a restart and each load re-validates — a bad entry (missing field,
wrong type, duplicate id/key) fails loudly. `config/clubs.json` works the same way.

## Growing the catalog

Two developer commands find ids to add to the catalog (both can write straight to
it with `--write` — ids and names come verbatim from the source; neither is
exposed over MCP):

**`discover-season <league-key>`** — another *season* of a league you already
have. It follows that league's season-history links and reads the target
season's sibling dropdown:

```bash
./bin/swehockey discover-season skane-u16p-2025            # any season newer than 2025-26
./bin/swehockey discover-season skane-u16p-2025 2026-27    # a specific season
./bin/swehockey discover-season skane-u16p-2025 2026-27 --write
```

Until the season exists it reports "not published yet".

**`discover-leagues [<id>|national|regional]`** — *other leagues* you haven't
mapped. Scopes are **flat** (district / national / regional), not a tree:

```bash
./bin/swehockey discover-leagues               # 1. districts, grouped by region
./bin/swehockey discover-leagues 21103         # 2. a district's series (Skåne)
./bin/swehockey discover-leagues 21103 U15P    #    ...narrowed by name
./bin/swehockey discover-leagues 19001         # 3. a league's tournaments (a leaf)
./bin/swehockey discover-leagues 19001 --write #    ...scaffold that league
./bin/swehockey discover-leagues national      # 4. the Sweden-wide leagues
./bin/swehockey discover-leagues regional      # 5. the region-wide leagues
```

1. **No arg** → districts (entry points).
2. **A district id** → every series in that region's dropdown, flagged mapped vs
   unmapped. It's **unfiltered** on purpose: swehockey mixes leagues, championships
   (DM), training series and cups with no reliable separator (the Overview/Schedule
   link kind doesn't track it, and the "Cups" divider leaks cups above it), so it
   shows everything and you pick (a name filter helps). Read-only.
3. **A league id** → that league's tournaments, flagged mapped/unmapped.
4. **`national`** / **`regional`** → the Sweden-wide leagues (from the "National"
   menu's *Leagues* column — SHL, U20 Nationell, SDHL, …) and the region-wide
   leagues (the "Regional" menu's Men/Women sections, by region — HockeyTvåan, U18
   Regional, …). Both are listed directly in their nav menu and shown unfiltered
   (the sections also hold preseason/cup/district-team entries, with no reliable
   marker to separate them), so you pick. Drill into any league id like a leaf.

These three (district / national / regional) are **siblings, not a hierarchy** —
the catalog's `region` is just a label (`"Skåne"`, `"National"`, `"Region Syd"`).
The only real nesting is region → district, already reflected by grouping the
district list under regions.

**`--write`** works on a **league id** (a leaf — a listing has nothing single to
scaffold). It recovers what the leaf page doesn't carry:

- **District** leagues — region + name come from the parent district's dropdown
  (`--write` scans the district dropdowns to find which one lists this id); season
  from the page's season-history links.
- **National / regional** leagues — `region` is `"National"` / the region name
  (`"Region Syd"`); the age-group is taken from the league name (`U18 Nationell` →
  `U18`, else the name as-is, e.g. `HockeyTvåan`); season is the selected option of
  the page's season `<select>`. A league with no sub-divisions (SHL) is written as
  a single tournament. E.g. `discover-leagues 19937 --write` → `national-u18-2025`.
  Some regional pages expose no season picker, so `--write` reports *"couldn't
  determine the season"* rather than guess.

The catalog is validated before every write, so a scan can't corrupt it. Note the
season/league conflation: a scaffold is the current season — for a *new* season of
a newly-found league, run `discover-season` on it afterwards.

## Club grouping (`config/clubs.json`)

A second config you can edit. It's a whitelist of **multi-team clubs**, used to
group player/goalie stats by club: a club often fields several teams (e.g.
`Rögle BK Grön` / `Röd` / `Vit`), and without this they'd be three separate rows
for the same player.

```json
["Rögle BK", "IF Malmö Redhawks", "Helsingborg HC Ungdom"]
```

If a team name contains a listed string, that club becomes the grouping key;
otherwise the team stands on its own. swehockey exposes no authoritative club id,
so this is a deliberate whitelist rather than a name-suffix guess (which could
merge the wrong teams) — **add new multi-team clubs here as they appear.** It's
only consulted by `playerstat`/`goaliestat`; the rest of the tool ignores it.

## How player & goalie stats work

`playerstat`/`goaliestat` read the *complete* `PlayersByTeam` rosters and
aggregate (swehockey's own leader pages cap at the top 25, so they can't be
summed across a league — different point cutoffs each tournament would
undercount). They sum every counting stat across the league's tournaments and
**recompute** the rate columns (AVG = ΣTP/ΣGP, GAA = ΣGA·60/Σmin,
SVS% = ΣSVS/ΣSOG, W% = ΣW/(ΣW+ΣL)). On a single tournament it gives the full,
uncapped table.

Pass **`--team <name>`** (CLI) or the `team` argument (MCP) to keep only one
club's rows — case-insensitive partial match on the club name (`rögle`,
`Rögle BK`). Rank is assigned over the whole field first, so a filtered view
still shows each player's league-wide standing (the `RK`/`rank` column keeps
gaps). Each row carries an explicit `rank` in JSON.

Players are keyed by **name + club**, so a player's teams within one club
collapse into a single row (Rögle BK Grön/Röd/Vit → Rögle BK) — via the whitelist
in [`config/clubs.json`](#club-grouping-configclubsjson). swehockey's trailing
eligibility markers (`Name**`, a dispensation/affiliated flag it adds in some
tournaments but not others) are stripped before keying too, so a player isn't
split into marked/unmarked entries. Goalies are sorted by SVS%, so small samples
can top the list; the GPI/MIP columns show how much they actually played.

> **Team "id":** swehockey exposes no numeric team id on its league pages — a
> team's identifier is a short **code** (`MIF`, `RBK G`), read off the team
> scoring/goalkeeping page. It's a per-tournament display abbreviation, so it can
> vary between tournaments (`RBK Grön` vs `RBK G`) and is occasionally just the
> full name. Shown as `—` when it can't be matched.

## MCP tools

The server calls the same in-process query layer (`src/queries.ts`) the CLI uses
— no subprocess — and returns each query's JSON. Tools: `list_leagues`,
`get_league`, `list_tournaments`, `list_districts`, `list_cups`, `get_teams`,
`get_schedule`, `get_standings`, `get_player_stats`, `get_goalie_stats` (the last
two take an optional `team` filter). The `discover-*` commands are intentionally
not exposed — they're catalog maintenance, not queries.

## Project layout

| File | Purpose |
|---|---|
| `bin/swehockey`     | CLI entry point (`#!/usr/bin/env bun`) |
| `bin/swehockey-mcp` | MCP server entry point (read tools for an agent) |
| `src/cli.ts`        | Argument parsing + command dispatch + output formatting |
| `src/mcp.ts`        | MCP server (calls the query layer in-process) |
| `src/queries.ts`    | Shared fetch + dispatch + JSON shaping (CLI & MCP) |
| `src/catalog.ts`    | Leagues → tournaments catalog + resolvers |
| `src/client.ts`     | HTTP client (HTTPS, retries on flaky/partial fetches) |
| `src/html.ts`       | Generic `table.tblContent` parsing helpers |
| `src/standings.ts`  | Standings view → typed rows (rank, W/T/L, GF:GA, points) |
| `src/schedule.ts`   | Schedule view → typed games (date, teams, result, venue) |
| `src/playersByTeam.ts` | Complete rosters → aggregated player/goalie stats |
| `src/clubs.ts`      | Club whitelist + team→club mapping (`config/clubs.json`) |
| `src/discover.ts`   | Find not-yet-cataloged seasons / leagues (region scan) |
| `src/teams.ts`      | Teams in a tournament, joined with their swehockey code |