package latest import ( "strings" "testing" ) // Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af // fetched 2026-07-26. The first anchor is the "First Chapter" shortcut: it is a // real chapter link with no "Chapter N" text, and it must not be mistaken for // the latest just because it parses. const asuraSeriesFixture = ` First Chapter Chapter 179 Chapter 181 Chapter 180 ` // A chapter link belonging to a different series, of the kind a "you might also // like" strip would introduce. Slug scoping must exclude it. const asuraCrossSeriesFixture = asuraSeriesFixture + ` Chapter 999 ` // Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer fetched // 2026-07-26. Note the raw "&", the doubled space after Chapter 0.5 Chapter 294 Chapter 296 Chapter 295 ` // What Cloudflare serves instead of the page when an IP's bot score flips. const challengeFixture = `Just a moment...
Checking your browser
` // Trimmed from the server-rendered HTML of // https://comix.to/title/n8we-dungeons-and-crayons fetched 2026-08-03. comix is // an SPA: the page ships a JSON state blob rather than a list of chapter // anchors, and latestChapterUrl is where the newest chapter actually lives. // // Still the right fixture after comix moved behind the challenge (#98): the // browser read is an in-tab fetch of the Series URL, so the body a poll parses // is this same server-rendered HTML, not a rendered DOM. Confirmed against a // live cleared tab 2026-08-16 (TestSmokeComix): the in-tab fetch returned // 24793 bytes of server-rendered HTML that these same parses read a chapter // and a cover out of. const comixSeriesFixture = ` {"firstChapterUrl":"/title/n8we-dungeons-and-crayons/5038739-chapter-1","latestChapterUrl":"/title/n8we-dungeons-and-crayons/11139891-chapter-80"}, {""manga","recommended","n8we",1]":{"items":[{"latestChapterUrl":"/title/qqwrm-full-time-awakening/99999999-chapter-999"}]} ` // The kagane branch is fed by the browser fetcher, so the body is API JSON, not // HTML. Trimmed from GET /api/v2/series/ on 2026-08-03. const kaganeAPIFixture = ` {"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption", "series_books":[{"book_id":"a","title":"Episode 1","chapter_no":"1","sort_no":1}, {"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41}, {"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]} ` // Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05. // The page carries a newest-first "latest chapters" widget above an // oldest-first paginated list, so the newest anchor is deliberately NOT last — // only a maximum finds it. The final anchor belongs to another series and must // be excluded by slug scoping. const novelfullSeriesFixture = ` Chapter 9999 ` // Trimmed from // https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/ // fetched 2026-08-11, whole document (~307 KB decoded; the wire body is ~32 KB // zstd-compressed). This // novel publishes its chapters under two Chapter Slugs: 1–99 at // …-not-chapter-/ and 100–423 at …-not-them-all-chapter-/, and the page // lists them newest-first, so the …-not-them-all anchors precede the …-not // anchors. Every anchor through the comment-thread marker is verbatim page // text (the site renders this novel's chapter titles as "[ ... words ]"). The // comment block after the marker is the real wpdiscuz comment #wpd-comm-358_0 // from https://lightnovelworld.net/novel/the-sword-illuminates-the-great-wilderness/ // — the pinned page serves zero comments — with its share/link/vote/reply // boilerplate trimmed. The comment's body carried no link, so the bare to https://lightnovelworld.net/overgeared-chapter-2059/ inside // wpd-comment-text is the one composed element; that URL is a real chapter of // a real different novel (overgeared; fetched, HTTP 200). const lnwSeriesFixture = `
  • Vol. 1 Ch. 404
    [ ... words ]
    April 12, 2026
  • Vol. 1 Ch. 423
    [ ... words ]
    April 7, 2026
  • Vol. 1 Ch. 300
    [ ... words ]
    April 4, 2026
  • Vol. 1 Ch. 200
    [ ... words ]
    March 29, 2026
  • Vol. 1 Ch. 100
    [ ... words ]
    March 22, 2026
  • Vol. 1 Ch. 99
    Chapter 99
    November 5, 2025
  • Vol. 1 Ch. 50
    Chapter 50
    October 29, 2025
  • Vol. 1 Ch. 1
    Chapter 01
    October 11, 2025
  • hasbi asy
    Member
    hasbi asy
    1 month ago
    ` // Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af // (redirected to ...-00dcbf97) on 2026-08-10. const asuraCoverFixture = `` // Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer on 2026-08-10. // The source publishes the raw space in this URL. const demonicCoverFixture = `` // Trimmed from https://comix.to/title/n8we-dungeons-and-crayons on 2026-08-10. // The state includes a recommended poster before the target detail object and // nested IDs inside that object; no og:image is present. const comixCoverFixture = `` // Trimmed from GET https://kagane.to/api/v2/series/019fe11a-8670-7cf3-8343-0b02057d3787 on 2026-08-10. const kaganeCoverFixture = `{"series_covers":[{"cover_id":"019fe11a-84d1-714b-9cf4-2827f277f3c0","language":"en","volume_number":"1","chapter_number":null,"note":null,"image_id":"019fe11a-84c3-7fc3-a84b-88787374b617"}]}` // Trimmed from https://novelfull.com/reverend-insanity.html on 2026-08-10. const novelfullCoverFixture = `` // Trimmed from // https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/ // on 2026-08-11. const lnwCoverFixture = `` func TestCoverFrom(t *testing.T) { const comixURL = "https://comix.to/title/n8we-dungeons-and-crayons" tests := []struct { name string site string seriesURL string body string wantOK bool wantCover string }{ { name: "asura uses published metadata URL", site: "asura", body: asuraCoverFixture, wantOK: true, wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp", }, { name: "demonic escapes raw spaces", site: "demonic", body: demonicCoverFixture, wantOK: true, wantCover: "https://readermc.org/images/thumbnails/Catastrophic%20Necromancer.webp", }, { name: "comix takes target medium poster", site: "comix", seriesURL: comixURL, body: comixCoverFixture, wantOK: true, wantCover: "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg", }, { name: "kagane reads API cover image ID", site: "kagane", body: kaganeCoverFixture, wantOK: true, wantCover: "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed", }, { name: "novelfull reads image metadata", site: "novelfull", body: novelfullCoverFixture, wantOK: true, wantCover: "https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp", }, { name: "lightnovelworld reads og image", site: "lightnovelworld", body: lnwCoverFixture, wantOK: true, wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg", }, { name: "later metadata cover survives empty match", site: "asura", body: `` + asuraCoverFixture, wantOK: true, wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp", }, { name: "page without cover is empty", site: "asura", body: ``, }, { name: "unknown site is empty", site: "unknown", body: asuraCoverFixture, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := coverFrom(tt.site, tt.seriesURL, tt.body) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (got %q)", ok, tt.wantOK, got) } if got != tt.wantCover { t.Errorf("cover = %q, want %q", got, tt.wantCover) } }) } } func TestCoverFromChallenge(t *testing.T) { tests := []struct { site string seriesURL string }{ {"asura", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"}, {"demonic", "https://demonicscans.org/manga/Catastrophic-Necromancer"}, {"comix", "https://comix.to/title/n8we-dungeons-and-crayons"}, {"kagane", "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"}, {"novelfull", "https://novelfull.com/reverend-insanity.html"}, {"lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/"}, } for _, tt := range tests { t.Run(tt.site, func(t *testing.T) { if got, ok := coverFrom(tt.site, tt.seriesURL, challengeFixture); ok || got != "" { t.Fatalf("cover = %q, ok = %v, want empty", got, ok) } }) } } func TestLatestChapterFrom(t *testing.T) { const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer" tests := []struct { name string site string seriesURL string body string wantOK bool wantNum float64 wantLabel string }{ { name: "asura takes the max, not the last listed", site: "asura", seriesURL: asuraURL, body: asuraSeriesFixture, wantOK: true, wantNum: 181, wantLabel: "Chapter 181", }, { name: "asura ignores another series' chapter links", site: "asura", seriesURL: asuraURL, body: asuraCrossSeriesFixture, wantOK: true, wantNum: 181, wantLabel: "Chapter 181", }, { name: "asura scoping survives a build-hash rotation", site: "asura", seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-059befe1", body: asuraCrossSeriesFixture, wantOK: true, wantNum: 181, wantLabel: "Chapter 181", }, { name: "asura with an unparseable series url", site: "asura", seriesURL: "https://asurascans.com/", body: asuraSeriesFixture, wantOK: false, }, { name: "demonic takes the max across raw and escaped ampersands", site: "demonic", seriesURL: demonicURL, body: demonicSeriesFixture, wantOK: true, wantNum: 296, wantLabel: "Chapter 296", }, { name: "demonic keeps decimal chapters parseable", site: "demonic", seriesURL: demonicURL, body: `Chapter 0.5`, wantOK: true, wantNum: 0.5, wantLabel: "Chapter 0.5", }, { name: "empty body", site: "asura", seriesURL: asuraURL, body: "", wantOK: false, }, { name: "cloudflare challenge page", site: "asura", seriesURL: asuraURL, body: challengeFixture, wantOK: false, }, { name: "demonic markup handed to the asura rule", site: "asura", seriesURL: asuraURL, body: demonicSeriesFixture, wantOK: false, }, { name: "unknown site", site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture, wantOK: false, }, { name: "comix reads latestChapterUrl, scoped to this series", site: "comix", seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons", body: comixSeriesFixture, wantOK: true, wantNum: 80, wantLabel: "Chapter 80", }, { name: "comix yields nothing on a challenge page", site: "comix", seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons", body: challengeFixture, wantOK: false, }, { name: "kagane takes the max chapter_no from API json", site: "kagane", seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", body: kaganeAPIFixture, wantOK: true, wantNum: 41, wantLabel: "Chapter 41", }, { name: "kagane yields nothing on a challenge page", site: "kagane", seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", body: challengeFixture, wantOK: false, }, { name: "novelfull takes the max and ignores another series", site: "novelfull", seriesURL: "https://novelfull.com/reverend-insanity.html", body: novelfullSeriesFixture, wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334", }, { name: "novelfull yields nothing on a challenge page", site: "novelfull", seriesURL: "https://novelfull.com/reverend-insanity.html", body: challengeFixture, wantOK: false, }, { name: "novelfull with an unparseable series url", site: "novelfull", seriesURL: "https://novelfull.com/genre/Fantasy", body: novelfullSeriesFixture, wantOK: false, }, // Stored before the slug split, so the address carries the ...-not // Chapter Slug; the 100-423 block under the other slug must still win. // The body is the chapter-list portion of lnwSeriesFixture with the // comment block omitted; the marker is kept, because a body without it // is skipped, not scanned. { name: "lightnovelworld max spans both chapter slugs", site: "lightnovelworld", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not/", body: strings.SplitN(lnwSeriesFixture, lnwCommentMarker, 2)[0] + lnwCommentMarker, wantOK: true, wantNum: 423, wantLabel: "Chapter 423", }, { name: "lightnovelworld comment anchor cannot set the latest chapter", site: "lightnovelworld", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/", body: lnwSeriesFixture, wantOK: true, wantNum: 423, wantLabel: "Chapter 423", }, // Marker removed from the fixture, comment block still present: a // redesign must degrade into a skip, never into the comment's number. { name: "lightnovelworld body without the comment marker is skipped", site: "lightnovelworld", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/", body: strings.ReplaceAll(lnwSeriesFixture, lnwCommentMarker, ""), wantOK: false, }, { name: "lightnovelworld yields nothing on a challenge page", site: "lightnovelworld", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/", body: challengeFixture, wantOK: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := latestChapterFrom(tt.site, tt.seriesURL, tt.body) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (got %+v)", ok, tt.wantOK, got) } if !tt.wantOK { return } if got.Num != tt.wantNum { t.Errorf("Num = %v, want %v", got.Num, tt.wantNum) } if got.Label != tt.wantLabel { t.Errorf("Label = %q, want %q", got.Label, tt.wantLabel) } }) } }