From be9bb4cbf6816f92f01b255e0865cc53a16cabc4 Mon Sep 17 00:00:00 2001 From: ArdaxHz <70710586+ArdaxHz@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:56:25 +0300 Subject: [PATCH] Treat a missing MangaPlus expiry as unknown, not as expired in 1970 `chapterExpire` fell back to DEFAULT_TIMESTAMP (epoch second 1) when `endTimeStamp` was absent. proto3 drops zero values, so a chapter that never rotates out of the free window arrives with the field missing -- and stood in as 1, it reads as "expired in 1970". That was already wrong: the filter in `collect` skips anything whose expiry has passed, so such a chapter was silently never uploaded. It becomes dangerous alongside the platform's new paywalled pass, which hard-deletes a chapter the publisher still lists once its expiry is behind us: the chapters that are free forever are exactly the ones it would delete. A missing expiry is now null and stays null through `toChapterInput`, which the chapter contract already allows. The decision moves into `hasExpired` so the rule is one testable line rather than an inline comparison, and null reads as "no expiry stated" everywhere it is asked. Note this does not make expiry a paywall oracle -- `availability.ts` documents the far-future sentinel MangaPlus puts on subscriber-only chapters, which no expiry check can catch. It only stops the absent case from lying. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K2GWZ284dc6s3znLsavYrS --- src/mangaplus/manifest.json | 2 +- src/mangaplus/package.json | 2 +- src/mangaplus/src/expiry.test.ts | 27 +++++++++++++++++++++++++++ src/mangaplus/src/index.ts | 14 +++++++++++--- src/mangaplus/src/normalise.ts | 19 ++++++++++++++++++- 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/mangaplus/src/expiry.test.ts diff --git a/src/mangaplus/manifest.json b/src/mangaplus/manifest.json index bd27e5d..868a3ca 100644 --- a/src/mangaplus/manifest.json +++ b/src/mangaplus/manifest.json @@ -1,6 +1,6 @@ { "name": "mangaplus", - "version": "1.2.0", + "version": "1.2.1", "publoader_api": "^2.0.0", "runtime": "node", "entrypoint": "index.mjs", diff --git a/src/mangaplus/package.json b/src/mangaplus/package.json index 65036ed..6d51455 100644 --- a/src/mangaplus/package.json +++ b/src/mangaplus/package.json @@ -1,6 +1,6 @@ { "name": "publoader-ext-mangaplus", - "version": "1.2.0", + "version": "1.2.1", "private": true, "type": "module", "description": "publoader extension for MangaPlus by Shueisha (extension API v2)", diff --git a/src/mangaplus/src/expiry.test.ts b/src/mangaplus/src/expiry.test.ts new file mode 100644 index 0000000..ce95313 --- /dev/null +++ b/src/mangaplus/src/expiry.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { hasExpired } from "./normalise.ts"; + +const NOW = 1_757_332_800; // 2026-09-08T12:00:00Z, epoch seconds. + +test("a stated expiry in the past has closed", () => { + assert.equal(hasExpired(NOW - 86_400, NOW), true); +}); + +test("a stated expiry in the future has not", () => { + assert.equal(hasExpired(NOW + 86_400, NOW), false); +}); + +test("an absent expiry never expires", () => { + // The regression this guards: proto3 drops zero values, so a chapter that + // never rotates out arrives with endTimeStamp absent. It used to be stood in + // as DEFAULT_TIMESTAMP (epoch second 1), which read as "expired in 1970" -- + // skipping the chapter from every upload, and, once the platform started + // hard-deleting still-listed expired chapters as paywalled, marking the + // permanently-free chapters for deletion. + assert.equal(hasExpired(null, NOW), false); +}); + +test("an expiry exactly at now has not closed yet", () => { + assert.equal(hasExpired(NOW, NOW), false); +}); diff --git a/src/mangaplus/src/index.ts b/src/mangaplus/src/index.ts index 663bf59..f4e8180 100644 --- a/src/mangaplus/src/index.ts +++ b/src/mangaplus/src/index.ts @@ -38,6 +38,7 @@ import { type PbTitle, } from "./proto"; import { + hasExpired, normaliseChapters, numberWordsPattern, type OverrideOptions, @@ -187,7 +188,7 @@ function isoFromEpoch(seconds: number): string | null { function toChapterInput(chapter: RawChapter): ChapterInput { return { chapterTimestamp: isoFromEpoch(chapter.chapterTimestamp), - chapterExpire: isoFromEpoch(chapter.chapterExpire), + chapterExpire: chapter.chapterExpire === null ? null : isoFromEpoch(chapter.chapterExpire), chapterLanguage: chapter.chapterLanguage, chapterNumber: chapter.chapterNumber, chapterTitle: chapter.chapterTitle, @@ -310,7 +311,7 @@ class MangaPlus implements ExtensionRuntime { // MangaPlus rotates free chapters out; anything already expired, or // older than the update window, is not a new upload. if (postedChapterIds.has(chapter.chapterId)) continue; - if (chapter.chapterExpire < now) continue; + if (hasExpired(chapter.chapterExpire, now)) continue; if (chapter.chapterTimestamp < now - UPDATE_WINDOW_SECONDS) continue; updatedChapters.push(chapter); } @@ -880,7 +881,14 @@ class MangaPlus implements ExtensionRuntime { chapterId, chapterUrl: CHAPTER_URL(chapterId), chapterTimestamp: chapter.startTimeStamp ?? DEFAULT_TIMESTAMP, - chapterExpire: chapter.endTimeStamp ?? DEFAULT_TIMESTAMP, + // null, NOT DEFAULT_TIMESTAMP: a missing endTimeStamp means "no expiry + // known", and proto3 drops zero values, so a chapter that never expires + // arrives here with the field absent. Standing that in as epoch second + // 1 reads as "expired in 1970" — which skipped the chapter from every + // upload below, and now that the platform deletes still-listed expired + // chapters as paywalled, would delete exactly the chapters that are + // free forever. Unknown has to stay unknown. + chapterExpire: chapter.endTimeStamp ?? null, chapterTitle: chapter.subTitle ?? null, chapterNumber: chapter.name ?? null, chapterLanguage: manga.language, diff --git a/src/mangaplus/src/normalise.ts b/src/mangaplus/src/normalise.ts index 7e1a20a..fcc0fe9 100644 --- a/src/mangaplus/src/normalise.ts +++ b/src/mangaplus/src/normalise.ts @@ -38,7 +38,8 @@ export interface RawChapter { listSlot?: "first" | "mid" | "last"; chapterUrl: string; chapterTimestamp: number; - chapterExpire: number; + /** null when the publisher states no expiry, i.e. the chapter stays free. */ + chapterExpire: number | null; chapterTitle: string | null; chapterNumber: string | null; chapterLanguage: string; @@ -82,6 +83,22 @@ export interface OverrideOptions { verify_pages?: boolean; } +/** + * Has the publisher's free window closed? + * + * Only a stated expiry can close it. A null expiry means MANGA Plus named no + * end date — proto3 drops zero values, so a chapter that never rotates out + * arrives with the field absent — and "unknown" must not read as "expired". + * Getting this backwards skips the chapter from every upload, and, because the + * platform now hard-deletes still-listed expired chapters as paywalled, would + * delete the chapters that are free forever. + * + * Both arguments are epoch SECONDS, matching the wire format. + */ +export function hasExpired(expire: number | null, now: number): boolean { + return expire !== null && expire < now; +} + /** publoader's chapter-number validator (utils/utils.py). */ const CHAPTER_NUMBER_RE = /^(0|[1-9]\d*)((\.\d+){1,2})?[a-z]?$/i;