Turn a mplus.run LFG export into a sortable applicant table with median and average Mythic+ parse on every row — so you can judge a whole queue at a glance instead of clicking into each character.
┌──────────────┬─────────┬────────┬───────┬────────┬──────────┬──────────────┬───────────┐
│ Character │ Class │ Role │ iLvl │ M+ Scr │ Dungeons │ Median Parse │ Avg Parse │
├──────────────┼─────────┼────────┼───────┼────────┼──────────┼──────────────┼───────────┤
│ Furywar │ Warrior │ DPS │ 630 │ 3586 │ 8 │ 87.5 │ 84.5 │
│ Holyheals │ Paladin │ Healer │ 625 │ 3000 │ 3 │ 68.0 │ 62.0 │
│ Brewmonk │ Monk │ Tank │ 628 │ 3200 │ 4 │ 22.5 │ 22.0 │
└──────────────┴─────────┴────────┴───────┴────────┴──────────┴──────────────┴───────────┘
Click any row for a per-dungeon breakdown, click any header to sort, and parse cells are coloured on the usual Warcraft Logs percentile scale.
- Node 23.6 or newer — the code runs as TypeScript directly through Node's built-in type stripping, so there is no build step and no runtime dependencies. Node 24 LTS is a good choice.
git clone https://github.com/beaurancourt/gray-matter.git
cd gray-matter
cp .env.example .env
$EDITOR .env # paste your Warcraft Logs credentials (see below)
npm start # http://localhost:3000There is no npm install step: nothing is needed at runtime. Only install dev dependencies if you
want to run the tests or typecheck:
npm install # optional: typescript + @types/node
npm test
npm run typecheck- Install the mplus.run addon from CurseForge (also in the CurseForge app).
- Open the group finder and let applicants queue up.
- Click the mplus.run icon next to the Refresh button in the applicant list.
- Press Ctrl+C to copy the link, then paste it into gray-matter and hit Load roster.
The addon also has a right-click menu to copy a single player's profile URL, but those links carry no parse data — use the bulk export for the table.
Parses come straight from the Warcraft Logs API v2, so you need a free API client. It takes about a minute.
- Sign in at https://www.warcraftlogs.com, then open https://www.warcraftlogs.com/api/clients.
- Click Create Client.
- Name it anything, e.g.
gray-matter. - Redirect URL — this app never uses it. The client-credentials flow this app uses never
redirects anywhere, so any placeholder is fine:
http://localhost:3000/callback. (If the form insists on HTTPS,https://localhost:3000/callbackworks too.) - Make sure you are creating a client credentials client. You do not need the user-authorization / PKCE flow — that is for reading private logs, which this app doesn't do.
- Create it, then copy the Client ID and Client Secret immediately. Warcraft Logs shows the secret exactly once; if you lose it you must delete the client and create a new one.
- Paste both into
.env:
WCL_CLIENT_ID=01a0a067-0000-0000-0000-000000000000
WCL_CLIENT_SECRET=your-secret-here.env is gitignored, so your credentials never end up in git.
npm run doctorAuthenticating with Warcraft Logs...
Fetched 66 zones.
Auto-detected Mythic+ zone:
55 Mythic+ Season 2 [Midnight]
Rate limit: 14.02/3600 points used, resets in 58 min.
If that prints a zone, you're done. Useful variants:
npm run doctor -- --zones # list every zone WCL knows about
npm run doctor -- "<mplus.run link>" # decode a roster and look up its first character
npm run doctor -- --raw "<link>" # dump the raw zoneRankings JSON--raw is the quickest way to see what Warcraft Logs actually returns if you want to change how the
columns are computed.
Everything is optional; the defaults are sensible.
| Variable | Default | Meaning |
|---|---|---|
PORT |
3000 |
HTTP port. |
WCL_CLIENT_ID |
— | Required. Warcraft Logs API client ID. |
WCL_CLIENT_SECRET |
— | Required. Warcraft Logs API client secret. |
WCL_MPLUS_ZONE_ID |
auto | Pin a Warcraft Logs zone. Blank auto-detects the current season. |
WCL_METRIC |
auto |
auto, points_and_damage, points_and_healing, dps, hps. |
CACHE_TTL_MINUTES |
720 |
How long a character's parses stay cached. |
WCL_CONCURRENCY |
4 |
How many characters to look up in parallel. |
auto asks for healing rankings for characters who can only heal, and damage rankings for
everyone else, so a healer's row never shows a meaningless damage parse.
The addon base64-encodes the applicant list into the link:
https://mplus.run/lfg?d=<url-safe base64 of the JSON>
Two things worth knowing about it:
- The payload contains no parse data — only identity, score and item level. Everything shown in the parse columns is fetched afterwards.
- mplus.run's own data routes sit behind a Cloudflare challenge that returns
403to non-browser clients, so this app never calls mplus.run. It decodes the link locally and goes to the same upstream source mplus.run uses.
One GraphQL query per character:
query ZoneRankings($name: String!, $serverSlug: String!, $serverRegion: String!, $zoneID: Int!, $metric: CharacterPageRankingMetricType) {
characterData {
character(name: $name, serverSlug: $serverSlug, serverRegion: $serverRegion) {
zoneRankings(zoneID: $zoneID, metric: $metric)
}
}
}The points_and_damage / points_and_healing metrics attach a throughputRankings block, keyed by
encounter id, alongside the ordinary score rankings:
{
"zone": 55, "metric": "points_and_damage",
"rankings": [ /* score rankings: used here only for names, kills and specs */ ],
"throughputRankings": {
"12993": {
"best_level": 14, // their highest timed key for this dungeon
"best_historical_percentile": 80, // <- the per-dungeon "key%" parse
"median_historical_percentile": 80,
"best_historical_low_parses": false
}
}
}For each dungeon, one number goes into the series: the character's best key% parse at their highest timed keystone level for that dungeon. Then:
| Column | Definition |
|---|---|
| Median Parse | median of those per-dungeon numbers |
| Avg Parse | mean of those per-dungeon numbers |
That per-dungeon number is best_historical_percentile, and best_level is the key it came from —
surfaced as the Key column in the expanded row.
Verified end to end against live data: for a real Arcane Mage the eight values came out
80, 66, 97, 95, 93, 64, 97, 55, reproducing Warcraft Logs' own per-dungeon figures exactly and
giving median 86.5 / average 80.875. test/parses.test.ts pins that fixture, so a
regression in field selection fails the suite.
Note this deliberately does not use WCL's medianPerformanceAverage / bestPerformanceAverage.
Those aggregate the per-dungeon median percentiles, which answers a different question ("how do
their typical runs look"). For the same character the two disagree sharply: 86.5 by this app's
definition versus 51.7 for medianPerformanceAverage.
Two caveats worth knowing:
- It is not always 8 numbers. A dungeon the character has never logged has no
throughputRankingsentry at all and is excluded. The Dungeons column shows how many actually contributed — a median from 4 dungeons is shakier than one from 8. - WCL only knows about logged runs. If a key wasn't logged, or was logged privately, it won't
appear, so
best_levelcan come out lower than the highest key shown on Raider.io.
The expanded row shows the key level, the Best parse that feeds the columns, and the raw Median kill percentile from the same block.
Warcraft Logs bills by points per hour (3600 by default), not by request, and each character
lookup spends a couple of points. Results are cached in memory and on disk
(.cache/parses.json) for CACHE_TTL_MINUTES, keyed by character + zone + metric, so re-opening the
same roster costs nothing. Tick Ignore cache to force a refresh. A full 40-applicant roster costs
roughly 80 points.
It is a single Node process with no dependencies, so it runs anywhere Node 23.6+ does. A systemd
unit is included at deploy/gray-matter.service — edit User and
WorkingDirectory for your box, then:
sudo cp deploy/gray-matter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now gray-matter
journalctl -u gray-matter -fThe server keeps the Warcraft Logs secret server-side, so do not expose it directly to the internet without putting an auth layer (or a private network such as a VPN or Tailscale) in front of it — anyone who can reach it can spend your Warcraft Logs points.
src/
mplus.ts decode/encode the addon export payload
wcl.ts OAuth client-credentials flow, GraphQL, Mythic+ zone resolution
parses.ts zoneRankings JSON -> median/average parse summary
server.ts static host + /api/roster + streaming /api/parses
cache.ts TTL cache with lazy disk persistence
metadata.ts class/role lookup tables
config.ts env handling
doctor.ts setup diagnostics CLI
public/ the table UI (no framework, no build step)
test/ node:test suites
npm run dev # watch mode
npm test # 27 tests
npm run typecheckTests cover payload round-tripping, malformed links, the parse summariser against a real fixture, metric selection, and the exact per-dungeon values for a known character.
- mplus.run's
robots.txtcarries Cloudflare content signals (ai-train=no) and disallows a number of AI crawlers. This project does not crawl it: it decodes a link you already have and queries the Warcraft Logs API, which is the same upstream source mplus.run itself uses. - Character names and realms come out of your own in-game LFG queue. The only outbound requests are to Warcraft Logs.
MIT — see LICENSE.
{ "r": "eu", // region: us | eu | kr | tw "p": [ // players { "re": "tarren-mill", // realm slug "n": "Veryalive", // character name "c": 1, // class id "ra": 1, // race id "s": 3586.41, // Mythic+ score "i": 630, // item level "ro": 4, // role bitfield: 1 tank, 2 healer, 4 dps "f": "a", // faction: a | h "g": 1 // optional group id } ] }