Stable Asura series IDs: strip rotating build hash (#6)

Asura series slugs carry a site-wide build hash (-059befe1) that rotates on every redeploy, silently orphaning all asura bookmarks (old-hash URLs 302 to new-hash ones, so detect() yields keys that never match stored rows).

Backend:
- migrateAsuraKeys in OpenStore: one-off idempotent migration rewriting hashed asura keys to the stable hashless ID, merging collisions to newest updated_at; losers deleted before winner rewrite (PK-collision safe) — regression tests included
- poller latestChapterFrom: build hash made optional in chapter-scoping regex so latest_chapter survives redeploys

Userscript:
- stripBuildHash(/-[0-9a-f]{8}$/) applied to seriesId in both asura detect branches; URLs keep full slug (stale hashes 302)
- load-time migration rewrites cached + retry-queued asura keys to the stripped form so a queued PUT cannot resurrect an orphaned row

Docs: AGENTS.md + CLAUDE.md URL-shape notes; design spec at docs/superpowers/specs/2026-07-28-asura-stable-series-id-design.md

Verified: go test -count=1 ./... green (new rotation + collision tests), node --check green, regex verified against live asurascans.com slugs.

Deploy order: backend first (migration runs at OpenStore). Orphan rows created by not-yet-updated userscripts self-heal on next backend restart.

Reviewed-on: #6
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
2026-07-28 07:21:10 +07:00
committed by sulthan
parent 6dce9fc481
commit 206c447f36
7 changed files with 432 additions and 6 deletions
+46 -2
View File
@@ -66,6 +66,18 @@
return out;
}
// Asura appends a site-wide build hash to every slug
// ("...-059befe1"); it rotates on each redeploy, so it must not be part
// of the series identity — old keys can never match pages after a
// redeploy. Only the ID strips it; seriesUrl keeps the full slug because
// navigation needs the current hash (stale ones 302). Must stay in sync
// with asuraBuildHash in backend/store.go.
// ponytail: a real title ending in exactly 8 hex chars would lose its
// last segment; none known.
function stripBuildHash(slug) {
return slug.replace(/-[0-9a-f]{8}$/, "");
}
const asura = {
site: "asura",
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
@@ -83,7 +95,7 @@
return {
type: "chapter",
site: this.site,
seriesId: m[1],
seriesId: stripBuildHash(m[1]),
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1],
@@ -98,7 +110,7 @@
return {
type: "series",
site: this.site,
seriesId: m[1],
seriesId: stripBuildHash(m[1]),
title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1],
@@ -384,6 +396,38 @@
const queue = loadQueue();
// Keys cached (or parked in the retry queue) by versions before the
// Asura build-hash strip still carry the hash. Align them with the
// server-side migration so a queued PUT cannot resurrect an orphaned
// row and the cache matches what the server now returns. Duplicate
// cache entries collapsing onto one key are left as-is — the next
// refresh() replaces the whole list from the already-merged server.
(function migrateLocalKeys() {
let dirty = false;
for (let i = 0; i < state.list.length; i++) {
const b = state.list[i];
if (b.site !== "asura" || !b.series_id) continue;
const id = stripBuildHash(b.series_id);
if (id === b.series_id) continue;
state.list[i] = Object.assign({}, b, { series_id: id, key: "asura:" + id });
dirty = true;
}
if (dirty) {
reindex();
saveCache(state.list);
}
dirty = false;
for (const e of queue) {
if (!e.key.startsWith("asura:")) continue;
const k = "asura:" + stripBuildHash(e.key.slice("asura:".length));
if (k !== e.key) {
e.key = k;
dirty = true;
}
}
if (dirty) saveQueue(queue);
})();
function queueCount() {
return queue.length;
}