diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 9b7c72f..fcc771b 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -25,6 +25,15 @@ var asuraSlugRe = regexp.MustCompile(`/comics/([^/?#]+)`) // through. Both the raw "&" and the HTML-escaped "&" forms occur. var demonicChapterRe = regexp.MustCompile(`chaptered\.php\?manga=\d+&(?:amp;)?chapter=([0-9.]+)`) +// comixSlugRe pulls the "-" segment out of a stored series_url. +// Only the id prefix is stable; the slug tail follows the title. +var comixSlugRe = regexp.MustCompile(`/title/([^/?#]+)`) + +// kaganeChapterRe matches the chapter numbers in a kagane API response. This +// branch is fed by the browser fetcher, so the body is JSON rather than HTML — +// there are no anchors to scan. +var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`) + // latestChapterFrom returns the highest chapter number body advertises for this // series. ok is false when the body yields nothing usable — an unknown site, an // empty body, a Cloudflare challenge page, and a site redesign all land here, @@ -61,6 +70,22 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) { re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`) case "demonic": re = demonicChapterRe + case "comix": + m := comixSlugRe.FindStringSubmatch(seriesURL) + if m == nil { + return latestChapter{}, false + } + // comix ships an SPA: the served HTML carries a JSON state blob instead + // of chapter anchors, and latestChapterUrl is the only place the newest + // chapter appears. Scoping to this series' id prefix keeps a + // "recommended" strip's entries from winning the maximum. + id := m[1] + if i := strings.Index(id, "-"); i != -1 { + id = id[:i] + } + re = regexp.MustCompile(`"latestChapterUrl":"/title/` + regexp.QuoteMeta(id) + `-[^"]*-chapter-([0-9.]+)"`) + case "kagane": + re = kaganeChapterRe default: return latestChapter{}, false } diff --git a/backend/internal/latest/sites_test.go b/backend/internal/latest/sites_test.go index de94005..a2bb82d 100644 --- a/backend/internal/latest/sites_test.go +++ b/backend/internal/latest/sites_test.go @@ -34,6 +34,24 @@ const challengeFixture = `Just a moment...</ti <script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></head> <body><div id="challenge-running">Checking your browser</div></body></html>` +// 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. +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/<uuid> 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}]} +` + 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" @@ -100,6 +118,34 @@ func TestLatestChapterFrom(t *testing.T) { 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, + }, } for _, tt := range tests {