-
v0.10.0
Stablereleased this
2026-07-31 08:54:18 -05:00 | 238 commits to main since this releaseFixed
-
Classic counters were about to publish in the wrong id namespace, and it
would have failed silently.crawlCountersis shared by both passes, and it
keyed entries by whatever id the tier list gave it. op.gg indexes classic
pages by BASE champion id — its classic tier list returnschampion_id: 24
for Jax and the page lives at/champions/jax, whilejade_jax307s — so the
file would have been written with base ids while champ select and the live
client both report60xxx.That is the failure mode this repo spends most of its comments avoiding: the
artifact validates, reads correctly, and diffs sensibly night to night, and
not one lookup ever matches. The runes pass already shifted its keys; counters
never got the same treatment because nothing exercised it.Three maps needed it, not one:
- the entry keys (
counters['60266']), - the opponent ids inside every matchup — op.gg names classic opponents by
their base display name ("Jax"), so the directory resolves them to base ids
while the consumer's board holds60xxx, and - the tier list, which is a second consumer-facing id-keyed map.
toQueueChampionId(newsrc/classic-ids.ts) is idempotent, so applying it
at more than one point in the assembly path cannot double-shift an id into a
range that exists for nobody. Six tests cover it, all four of which fail
against the previous code; the ranked namespace is asserted alongside each,
since the shift must happen for exactly one queue.No published file is affected — the classic pass is opt-in and has never run.
- the entry keys (
-
Classic counters recorded the rank band the crawl asked for, not the one
op.gg served. Same false-provenance problem the classic runes schema already
fixed: op.gg acceptstieron a classic page and discards it, so the data is
all ranks whatever was requested.CLASSIC_TIERmoved tocommon.schema.ts,
where both classic artifacts can reach it, and the counters artifact now
recordsallfor that queue.
Changed
-
The classic universe is 90 (champion, role) pairs, measured. A full sweep
on 16.15 replaced the 81 an earlier estimate assumed — TOP 23, MIDDLE 22,
JUNGLE 21, UTILITY 15, BOTTOM 9. The nightly op.gg budget is therefore ~732
requests rather than 714, which at 12.5 s is ~2.54 h and still inside the
three-hour window. The pacing is unchanged; the arithmetic behind it now
matches reality. -
The 0.9 coverage floors are kept for Classic Rift, on evidence rather than
by default. The concern was that a proportional floor is harsher on a
smaller universe. The same sweep returned 87/90 counters pages (96.7%) and
90/90 runes pages (100%), so both clear it with real headroom. The three
counters misses are stable rather than random — Fiddlesticks, Nunu and Gragas,
all jungle, all serving a page with no data array — which is why the margin
can be trusted. Split the floors only if a later measurement says so. -
Dropped the dead
runesArtifact: nullbranch fromOpggPassand the comment
claiming Classic Rift has no rune extractor. It has had one since 0.9.0.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.9.0
Stablereleased this
2026-07-31 07:07:46 -05:00 | 239 commits to main since this releaseChanged
-
The op.gg and Riot halves now run concurrently, so pacing op.gg no longer
costs the Riot crawl its window. They are unrelated hosts with unrelated
limits and separate rate limiters — serialising them only ever meant one sat
idle while the other waited out its own pacing. That was affordable when the
op.gg half finished in ~14 minutes. At 12.5 s a page it takes ~2.5 hours,
which would have left the Riot crawl ~30 minutes of a three-hour window, and
champion-damageandchampion-benchmarksare both built from those rows.Run together, each half gets the entire window instead of a slice. The crawl
window no longer needs widening — the recommendation in 0.8.0's notes is
withdrawn.Promise.allSettled, notPromise.all: a rejection in one half must not
cancel the other, which is the same isolation the per-artifact try/catch gives
inside each.The two op.gg passes (ranked, then classic) stay sequential with respect to
each other. They are the SAME host, and running them together would double the
request rate op.gg sees and undo the pacing.
Fixed
-
Concurrent halves could have raced on the Forgejo branch ref. Both publish
to the same branch, and the contents API's concurrency guard is the per-file
blob sha — it stops two writers clobbering one file and says nothing about two
commits racing to move the ref, which is what two different artifacts
publishing at the same instant would do.Commits now go through a one-at-a-time queue (
publish-queue.ts). Publishing
is seconds against crawls measured in hours, so serialising it costs nothing
measurable. A failed publish does not wedge the queue: later artifacts still
commit, and the caller still sees its own rejection. Validation happens
before queueing, so a schema failure surfaces as that artifact's error
rather than waiting behind another half's commit.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.8.0
Stablereleased this
2026-07-30 23:45:01 -05:00 | 240 commits to main since this releaseChanged
-
op.gg is now paced at one page per 12.5 s, taking the nightly crawl from
~14 minutes to ~2.5 hours. Derived rather than picked: the window makes 714
requests (5 tier lists + 271 counters + 271 runes for ranked, plus 5 + 81 + 81
for classic), and 9000 s / 714 ≈ 12.6 s.The old 1.5 s pacing walked the whole universe in a burst, and
champion-counterswas aborting at 55% coverage — the shape you get when
op.gg starts serving its 403 challenge page partway through. A 403 is
deliberately not retried, so once it starts, every remaining page fails
instantly and coverage collapses to however far the crawl got. This does not
prove that diagnosis, but bursting was the one thing under our control. -
COUNTERS_MAX_MINUTESandRUNES_MAX_MINUTESraised 45 → 70, and they are
coupled to the interval. At 12.5 s a 271-pair crawl needs ~57 minutes, so
the old 45-minute cap would have stopped it two thirds through and then
aborted it on coverage — turning a pacing change into a permanent outage.
Raise the caps with the interval or not at all. -
CLASSIC_ENABLEDnow defaults to OFF. Every other artifact is opt-out;
this one is opt-in. It adds ~167 requests to a window whose ranked half is
already failing for reasons nobody has established, and cumulative volume is
the last thing that should grow by default while that is open.
Notes
The crawl window probably needs widening, and this release does not do it.
CRAWL_START_HOUR=2/CRAWL_END_HOUR=5is three hours. An op.gg half taking
2.5 h of that leaves the Riot crawl ~30 minutes where it used to have ~2h45 —
andchampion-damageandchampion-benchmarksare both built from those rows,
so they would thin out night over night.02:00–07:00gives each half ~2.5 h.
That is a deployment decision, so it is flagged here rather than changed.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.7.0
Stablereleased this
2026-07-30 23:40:15 -05:00 | 241 commits to main since this releaseAdded
-
champion-runes-classic.jsonis published. v0.6.0 shipped the extractor
with nothing wired to it; the classic pass now crawls rune pages alongside
counters and publishes them.Its own schema, not the modern one with a flag. Classic Rift restores the
pre-2017 rune system, which is a different data model rather than a variant:
no keystone, no primary/secondary path, no shard rows, noselectedPerkIds—
the thingschampion-runes.schema.tsis built around. A page is a flat
multiset of four rune kinds held in quantities. Forcing it into the modern
shape would mean inventing empty paths or making half that schema optional,
which would weaken the validation currently catching drift on the ranked file.No perk dictionary either. The modern artifact hoists display strings because
inlining measured 1.9 MB against 212 KB across ~271 pairs of ~36-option
boards; classic is ~81 pairs carrying four runes each, so inlining costs a few
KB and removes a rehydration step from the consumer.
Fixed
- The classic artifacts no longer claim a rank band they do not have.
op.gg accepts thetierparameter for classic pages and discards it —
iron_plus,challengerand a deliberately invalid value all return
byte-identical rows. Recording the band the crawl asked for would be a false
provenance claim, and anything comparing classic to ranked figures would
silently compare a gold+ population against an everyone population. The files
now recordall.
Notes
Champion ids are re-keyed on the way out, and this is the subtle one. op.gg
indexes classic CHAMPION pages by BASE id — the tier list returns 24 for Jax and
the page lives at/champions/jax, whilejade_jax307-redirects — but champ
select and the live client both report60xxx, and the consumer looks entries up
by the id it is holding. So the crawl takes base ids in and publishes classic ids
out. A file keyed by base ids would validate, read correctly in review, and never
match a single lookup. Rune ids need no such treatment: op.gg already publishes
those in the775xxxclassic block, as it does items in77xxxx. Champions are
the exception.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.6.0
Stablereleased this
2026-07-30 21:35:39 -05:00 | 242 commits to main since this releaseAdded
-
Classic Rift is crawled as its own pass, publishing
champion-counters-classic.json. op.gg serves the mode under
type=classic, and the pass reuses everything except that parameter and the
output paths — same region, tier, coverage floors, pacing and time budgets —
so the classic file describes the same population as its ranked counterpart.It is a separate file rather than extra rows, and that is forced. op.gg
keys classic pages by BASE champion id:champion_id: 24is Jax in both
queues, measured on two different kits. Merged into one map they would collide
on the key and one would silently win.Classic is the cheaper half by a wide margin — 81 (champion, role) pairs
against ranked's ~272 — so it runs second, where a short budget costs the
ranked artifacts nothing. Its outcomes get their own publish streaks, because
its file goes stale independently of theirs. -
A classic rune extractor (
opgg/classic-runes-page.ts), not yet wired to
an artifact. Classic Rift restores the pre-2017 rune system, which is a
different data model rather than a variant: no paths, no keystone, no shards,
just a flat multiset of Marks, Seals, Glyphs and Quintessences held in
quantities. op.gg serves it from the same runes page underclassic_runes,
with775xxxids and a win COUNT rather than a rate, normalised to a percent
on the way out to match the other artifacts. Verified against live pages.
Fixed
buildTierListUrlomittedtype, which would have made the classic crawl
describe ranked champions. The tier lists are the index both op.gg crawls
run off, and op.gg defaults the parameter torankedwhen it is absent — so
the ranked pass was correct by luck. A classic pass would have discovered the
RANKED universe and then gone looking for those champions on classic pages,
producing a classic-labelled artifact full of the wrong roster. That is a
wrong file rather than an empty one, which is the failure this codebase is
built to avoid. Found before the classic pass ever ran.
Notes
Two things op.gg does not publish for this mode, established by sampling every
role: masteries (legacy_masteriesis an empty array on every page) and
synergies (play: 0, and the page itself says the sample is too small).
Neither is crawlable, and neither is a gap on this side.Classic data is also not segmented by rank: the
tierparameter is accepted
and discarded, withiron_plus,challengerand a deliberately invalid value
all returning byte-identical rows. The published file therefore describes all
ranks, and a later release should stop labelling itgold_plus.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-07-30 09:07:11 -05:00 | 243 commits to main since this releaseDeclining to publish is correct — the coverage floors exist so a thin crawl cannot replace a good file — and it is also completely silent. The night a failure becomes chronic logs exactly what the first night logged, and the window still reports success on the strength of the artifacts that did publish.
Patch 16.15 is the worked example:
countersaborted at 55% coverage two nights running whilechampion-counters.jsonkept serving 16.14 data, and the only trace was oneWARNamong 122 others.Added
Each of the four artifacts now carries a miss streak in a new
publish_statetable.outcome level missed, streak < STALE_AFTER_NIGHTS(3)WARNmissed, streak >= STALE_AFTER_NIGHTSERROR, naming the streak anddaysSinceSuccessmissed because something threw ERRORon the first nightpublished after a streak INFO, naming the missed nightspublished normally nothing — the commit line already said so - It counts nights, not runs. The counter advances at most once per local calendar date.
RUN_ON_STARTand a redeploy both re-enter the window, so without that guard, deploying a fix would trip the alarm it was fixing. - Faults skip the streak. A coverage abort is an expected non-result; a schema regression or a 403 from Forgejo is wrong immediately, and giving it three nights would bury the one signal worth acting on.
- The state is in Postgres, not beside the heartbeat, which lives in an ephemeral
/tmp— a redeploy would reset every alarm, and a redeploy is what happens right after shipping a fix for the thing being tracked. - Nothing is recorded when
PUBLISH_ENABLED=false. A smoke run has no publish outcome, and letting it write would leave the production streaks reading stale.
Fixed
- A damage publish failure no longer costs the benchmarks artifact. The two come from one crawl and were documented as publishing independently, but shared a single try/catch — so a schema regression in the first skipped the second, built from rows already paid for.
- The heartbeat is no longer touched after a Riot half that published nothing.
runRiotWindowreturnedvoidand the caller marked the window successful unconditionally, so a night that dedup'd to zero new rows still reported health to the container healthcheck.
Upgrading
No schema change to any published artifact; consumers need no update. The migration is additive and idempotent. One new optional env var,
STALE_AFTER_NIGHTS(default3).Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- It counts nights, not runs. The counter advances at most once per local calendar date.
-
released this
2026-07-29 09:17:41 -05:00 | 251 commits to main since this releaseChanged
The boot line now identifies the build and the config that decides whether a night produces anything.
versionandcommitare stamped into the image by CI via--build-arg, using the same values it tags the image with. This service sleeps 21 hours a day and Swarm pins:latestwithout ever re-resolving it, so a deploy that silently did not pull was indistinguishable from one that did until the next window opened — telling them apart meant resolving the running task's image digest against the registry by hand. Aversionofdevin a container means the build args never reacheddocker build.The line also carries what is otherwise invisible until too late:
publishEnabled(false crawls all night and commits nothing), both coverage floors (what withheld counters and runes on 2026-07-28),lookbackHours, the targetowner/repo@branch, andarchiveDir.DATABASE_URLis never logged — it carries the password inline. It is reduced tohost/database.Upgrade notes
No consumer change; logging and build metadata only.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
released this
2026-07-29 09:06:04 -05:00 | 253 commits to main since this releaseFixed
Champion display names stopped being unique, and it took both op.gg artifacts down.
Patch 16.15 added 60Jade_*entries to Data Dragon (173 champions -> 233), each carrying its base champion's display name.ChampionDirectorybuilt its name->id maps last-write-wins, so for the 18 names whose base alias sorts before theJade_block, the 60001+ id won. That id picks the op.gg slug, andjade_annieis not a page: 31 of 272 (champion, role) pairs failed, coverage fell to 88.6%, and the 90% floor correctly withheld both counters and runes on the night of 2026-07-28.Name lookups now keep the lowest id. The id->slug direction never needed a fix.
Added
A patch's final artifacts are archived instead of overwritten. The four canonical paths still hold whatever patch is live — lol-companion fetches one fixed URL forever, unchanged. On the first run of a new patch the outgoing file is copied to
ARCHIVE_DIR/<old patch>/<same basename>, e.g.data/archive/16.14/champion-runes.json.The trigger is the committed file's own
generatedFrom.patchdiffering from the one being published, so no schedule or stored state is involved and a failed copy retries the next night. The bytes come from the read the no-op check already performs. Existing archives are never rewritten, and an archiving failure is logged rather than costing the publish.Kept indefinitely: ~3 MB per patch across the four files.
Upgrade notes
No consumer change.
ARCHIVE_DIRdefaults todata/archive; the deployed stack sets it explicitly.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
v0.3.1
Stablereleased this
2026-07-28 09:03:03 -05:00 | 256 commits to main since this releaseChanged
- Log lines name their severity —
INFO,WARN,ERROR— instead of
pino's numeric scale. The Portainer dashboard renders whatever thelevel
field holds, so a nightly run showed a bare30where the severity should be,
readable only if you already know that debug is 20 and error is 50. The
LOG_LEVELthreshold is unaffected; it took names already.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Log lines name their severity —
-
v0.3.0
Stablereleased this
2026-07-28 08:56:18 -05:00 | 257 commits to main since this releaseChanged
-
The counters and runes artifacts are published readably instead of
minified. Both were committed as a single line, which kept them small but
made them unreadable in the Forgejo UI and gave git no line structure to delta
between nightly rewrites. They now use a width-bounded printer: structure on
its own lines, but any subtree whose minified form fits in 120 columns stays
inline, so a matchup or a rune board row remains on one line. Measured on
patch 16.14 — runes 398 KB → 539 KB (12,747 lines) against 1.47 MB at full
indentation; counters 911 KB → 1.08 MB (16,220 lines) against 2.01 MB. The
damage and benchmarks artifacts are unchanged, still fully indented.The JSON data is byte-for-byte equivalent, so this is not a schema change
and consumers need no update. The first run after deploying rewrites both
files once, then nightly diffs resume — and are now legible. -
The publish option that controlled this is now an
ArtifactFormat
(INDENTED_FORMAT/COMPACT_FORMAT) rather than a bareindentnumber.
Added
- Images are tagged with the package version (
:X.Y.Z) alongside:latest
and the commit SHA, so a running container can be matched to thevX.Y.Ztag
and release it was cut from.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-