From 5df94db9a3e4241fc0c3e0005e5e8bae552f765b Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 9 Oct 2026 10:10:14 +0200 Subject: [PATCH] Add Greatest of All Time manga dashboard --- .dockerignore | 2 + Dockerfile | 1 + README.md | 2 + docs/greatest.md | 24 + greatest/catalog.json | 2202 ++++++++++++++++++++++++++++++ greatest/index.js | 44 + public/app.css | 6 + public/common.js | 1 + public/greatest.css | 42 + public/greatest.html | 17 + public/greatest.js | 146 ++ server.js | 37 +- test/greatest.test.js | 30 + test/services.test.js | 26 + tools/enrich-greatest-catalog.js | 70 + tools/test-greatest-ui.js | 68 + 16 files changed, 2717 insertions(+), 1 deletion(-) create mode 100644 docs/greatest.md create mode 100644 greatest/catalog.json create mode 100644 greatest/index.js create mode 100644 public/greatest.css create mode 100644 public/greatest.html create mode 100644 public/greatest.js create mode 100644 test/greatest.test.js create mode 100644 tools/enrich-greatest-catalog.js create mode 100644 tools/test-greatest-ui.js diff --git a/.dockerignore b/.dockerignore index e0d6755..d852286 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,8 @@ !reading-analytics.js !public/ !public/** +!greatest/ +!greatest/** !runtime/ !runtime/** !sentinel/ diff --git a/Dockerfile b/Dockerfile index dbf4045..250016b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ ARG VCS_REF=development LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF COPY package.json server.js library.js reading-direction.js chapter-identity.js reading-analytics.js catalog.js ./ COPY public ./public +COPY greatest ./greatest COPY tools ./tools COPY scraper ./scraper COPY sentinel ./sentinel diff --git a/README.md b/README.md index 0e46dd0..df7d0ac 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A no-frills, self-hosted manga / comic / webtoon reader. - Reads the manga straight from a folder tree. The same folders stay readable in any normal picture viewer. - Accounts with per-user reading progress, view settings, an opt-in Active Reading Clock, a private Reading Story and "new chapter" notifications. - Reader with three view modes (**Flow**, **Page**, **Book**), a page zoom that leaves the controls alone, and tap zones for mobile. +- A separate **Greatest of All Time** manga-history dashboard, with five-year periods, per-user wish ratings, community ranking, and local-library badges. Its curated titles do not enter the download queue. ## Run it @@ -29,6 +30,7 @@ The current Windows installation uses Docker Compose; its old `MangaCircus Super Scheduled Task is disabled. Do not run a native duplicate writer. See [docs/local-deployment.md](docs/local-deployment.md) for its observed layout, health checks, restart procedure, and recovery point. +See [Greatest of All Time](docs/greatest.md) for catalog ownership, voting, and metadata refresh. ### Docker diff --git a/docs/greatest.md b/docs/greatest.md new file mode 100644 index 0000000..7317832 --- /dev/null +++ b/docs/greatest.md @@ -0,0 +1,24 @@ +# Shijō Saikō no Manga — Greatest of All Time + +`greatest.html` is a second, single-page dashboard. The Japanese title is **史上最高の漫画**, romanized **Shijō Saikō no Manga** (“greatest manga of all time”). It displays the [research reading map](manga-reading-map-2026-10-09.md) as ranked five-year cohorts from before 1970 through 2025–2026. Each period keeps its own separator and tile grid. + +## Separation and data ownership + +- `greatest/catalog.json` is a versioned, read-only editorial seed in the application image. Entries have stable IDs, debut year, cohort, curator rank, rationale, official source lead, and optional AniList cover/chapter/status metadata. It is **not** part of `library/`, scraper queue, or `data/catalog.json` collections. +- `greatest/index.js` validates the seed and performs only exact title/alias matching against the existing library index. A match adds the local cover, chapter count, status and **In library** badge; it never moves or downloads a title. +- `data/db.json` holds `greatestRatings[userName][entryId]`. Each account can select `flame`, `good`, `neutral`, or `skip`, or click its current choice to clear it. Existing chapter-version votes are separate. Deleting a user removes their ratings. Include `data/db.json` in backups as before. +- The only write route is authenticated `POST /api/greatest/rating` with `{id,rating}` (`null` clears). Authenticated `GET /api/greatest` returns the catalog, per-user choice, valid-user community counts, score, and local-library match. Unknown IDs/ratings are rejected. There is no admin-only gate: every account may vote. + +Within each cohort, **Rank by my vote** sorts flame → good → neutral → skull → unrated; **Rank by everyone** uses `3×flames + thumbs-up − 2×skulls`, with curator rank as the tie-breaker. These are preference scores, not independent critical ratings. The quick chips filter by *your* ratings (or in-library status), even when community sorting is active. The popup shows the full rationale, metadata, official source link, community counts, and a library link where available. + +## Covers and metadata + +The one-time seed is curated from the research document. `node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results blank rather than using a sequel or remake cover. The server makes **no AniList calls at runtime**. Browser covers are lazy-loaded from AniList's CDN; a title-letter card remains if a cover is missing or fails. Local library covers take precedence. + +Public catalog chapter counts are often absent; the UI says “Chapters unknown” rather than inventing one. For owned titles, the local library count wins. Status comes from AniList's releasing/finished flag or local library metadata, and may be unknown. Official source links may be publisher editions, chapter services, or an award/info lead; they do not imply all chapters are available in every country. No unverified hoster coverage is claimed. + +## Deployment and QA + +The Docker image now copies `greatest/` as app code; no new Compose service, volume, port, secret, or migration is needed. Rebuild/recreate **reader only** to pick up catalog/UI changes. The scraper and sentinel do not need restarting. Existing `data/db.json` accepts the new `greatestRatings` object without migration. + +Run `npm run test:reader` for catalog/API tests and `node tools/test-greatest-ui.js` for disposable headless-browser QA. The UI fixture blocks remote covers and never contacts the scraper. Manual catalog edits should preserve stable IDs once anyone has voted; changing a title's ID strands its votes. diff --git a/greatest/catalog.json b/greatest/catalog.json new file mode 100644 index 0000000..5f98418 --- /dev/null +++ b/greatest/catalog.json @@ -0,0 +1,2202 @@ +{ + "schema": 1, + "updatedAt": "2026-10-09", + "title": "Shijō Saikō no Manga", + "periods": [ + "Before 1970", + "1970–1974", + "1975–1979", + "1980–1984", + "1985–1989", + "1990–1994", + "1995–1999", + "2000–2004", + "2005–2009", + "2010–2014", + "2015–2019", + "2020–2024", + "2025–2026" + ], + "entries": [ + { + "id": "astro-boy-1952", + "title": "Astro Boy", + "year": 1952, + "period": "Before 1970", + "periodOrder": 0, + "rank": 1, + "note": "Humane robot science fiction that made long-form adventure manga feel modern; a foundation for the *Pluto* branch later below.", + "source": { + "kind": "edition", + "url": "https://www.darkhorse.com/Books/25-825/Astro-Boy-Omnibus-Volume-1-TPB" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30728-deU0SZKHwSp5.png", + "chapters": 233, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30728, + "url": "https://anilist.co/manga/30728" + } + }, + { + "id": "phoenix-1954", + "title": "Phoenix", + "year": 1954, + "period": "Before 1970", + "periodOrder": 0, + "rank": 2, + "note": "Tezuka's time-spanning meditation on life, death, and recurrence; the most ambitious early match to your philosophical sci-fi taste.", + "source": { + "kind": "info", + "url": "https://tezukaosamu.net/en/manga/656.html" + }, + "cover": null, + "chapters": null, + "status": "unknown" + }, + { + "id": "ashita-no-joe-1968", + "title": "Ashita no Joe", + "year": 1968, + "period": "Before 1970", + "periodOrder": 0, + "rank": 3, + "note": "Boxing as class struggle, self-destruction, and national cultural memory rather than a neat victory ladder.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/ashita-no-joe-fighting-for-tomorrow/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31303-l21HJn9AXl7w.jpg", + "chapters": 171, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31303, + "url": "https://anilist.co/manga/31303" + } + }, + { + "id": "princess-knight-1953", + "title": "Princess Knight", + "year": 1953, + "period": "Before 1970", + "periodOrder": 0, + "rank": 4, + "note": "A crucial early story-driven shōjo fantasy; historically important even if its gender treatment feels of its time.", + "source": { + "kind": "info", + "url": "https://tezukaosamu.net/en/manga/559.html" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b31676-ReKRBqwbdml1.jpg", + "chapters": 27, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31676, + "url": "https://anilist.co/manga/31676" + } + }, + { + "id": "golgo-13-1968", + "title": "Golgo 13", + "year": 1968, + "period": "Before 1970", + "periodOrder": 0, + "rank": 5, + "note": "The hard-boiled assassin serial as a decades-long political/time-capsule form.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/golgo-13" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx31298-Cik2Ff9Ip3MD.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 31298, + "url": "https://anilist.co/manga/31298" + } + }, + { + "id": "dororo-1967", + "title": "Dororo", + "year": 1967, + "period": "Before 1970", + "periodOrder": 0, + "rank": 6, + "note": "Body-horror quest through a demon-infested feudal Japan; an unusually direct ancestor of your dark-fantasy favorites.", + "source": { + "kind": "info", + "url": "https://tezukaosamu.net/en/manga/310.html" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31658-xZFgMcIzhbB4.png", + "chapters": 19, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31658, + "url": "https://anilist.co/manga/31658" + } + }, + { + "id": "the-legend-of-kamui-kamui-den-1964", + "title": "The Legend of Kamui / Kamui Den", + "year": 1964, + "period": "Before 1970", + "periodOrder": 0, + "rank": 7, + "note": "Dense ninja history with caste, rebellion, and social realism; rewarding but a demanding classic.", + "source": { + "kind": "edition", + "url": "https://drawnandquarterly.com/books/the-legend-of-kamui/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx47839-3sf0J3dOnN2O.png", + "chapters": 74, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 47839, + "url": "https://anilist.co/manga/47839" + } + }, + { + "id": "kitaro-gegege-no-kitaro-1960", + "title": "Kitarō / GeGeGe no Kitarō", + "year": 1960, + "period": "Before 1970", + "periodOrder": 0, + "rank": 8, + "note": "Yōkai folklore recast as popular manga; a useful bridge to later Japanese horror. English editions are selections from different incarnations.", + "source": { + "kind": "edition", + "url": "https://drawnandquarterly.com/books/kitaros-yokai-battles/" + }, + "cover": null, + "chapters": null, + "status": "unknown" + }, + { + "id": "lone-wolf-and-cub-1970", + "title": "Lone Wolf and Cub", + "year": 1970, + "period": "1970–1974", + "periodOrder": 1, + "rank": 1, + "note": "An austere father–son samurai epic whose visual storytelling still feels cinematic.", + "source": { + "kind": "edition", + "url": "https://www.darkhorse.com/Books/40-092/Lone-Wolf-and-Cub-Vol-1-The-Assassins-Road-TPB" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30904-KRN1WE3ZU3vl.jpg", + "chapters": 142, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30904, + "url": "https://anilist.co/manga/30904" + } + }, + { + "id": "the-rose-of-versailles-1972", + "title": "The Rose of Versailles", + "year": 1972, + "period": "1970–1974", + "periodOrder": 1, + "rank": 2, + "note": "French Revolution melodrama that radically expanded what historical shōjo could do.", + "source": { + "kind": "edition", + "url": "https://store.udonentertainment.com/collections/the-rose-of-versailles" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30660-hBFkOt2Ab2Ti.jpg", + "chapters": 85, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30660, + "url": "https://anilist.co/manga/30660" + } + }, + { + "id": "devilman-1972", + "title": "Devilman", + "year": 1972, + "period": "1970–1974", + "periodOrder": 1, + "rank": 3, + "note": "Apocalyptic horror, moral collapse, and a major ancestor of modern dark fantasy.", + "source": { + "kind": "edition", + "url": "https://sevenseasentertainment.com/series/devilman-the-classic-collection/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31474-R4x7qkjUfBw6.jpg", + "chapters": 53, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31474, + "url": "https://anilist.co/manga/31474" + } + }, + { + "id": "buddha-1972", + "title": "Buddha", + "year": 1972, + "period": "1970–1974", + "periodOrder": 1, + "rank": 4, + "note": "Tezuka's compassionate, irreverent philosophical biography, with much more grit than the premise suggests.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/buddha/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31111-ldPYMNd3PvHa.png", + "chapters": 69, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31111, + "url": "https://anilist.co/manga/31111" + } + }, + { + "id": "black-jack-1973", + "title": "Black Jack", + "year": 1973, + "period": "1970–1974", + "periodOrder": 1, + "rank": 5, + "note": "Brilliant episodic medical ethics built around an outlaw surgeon; ideal if you like tight moral dilemmas.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/black-jack/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx31600-CCA5TUKJBQJh.jpg", + "chapters": 243, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31600, + "url": "https://anilist.co/manga/31600" + } + }, + { + "id": "barefoot-gen-1973", + "title": "Barefoot Gen", + "year": 1973, + "period": "1970–1974", + "periodOrder": 1, + "rank": 6, + "note": "Essential, harrowing Hiroshima survivor testimony in comics form.", + "source": { + "kind": "edition", + "url": "https://lastgasp.com/collections/barefoot-gen" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx33281-2NXB8uHRxpI3.png", + "chapters": 54, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 33281, + "url": "https://anilist.co/manga/33281" + } + }, + { + "id": "the-poe-clan-1972", + "title": "The Poe Clan", + "year": 1972, + "period": "1970–1974", + "periodOrder": 1, + "rank": 7, + "note": "Moto Hagio's melancholy vampire mosaic; a beautiful source for the gothic and psychological side of manga.", + "source": { + "kind": "edition", + "url": "https://www.fantagraphics.com/products/the-complete-poe-clan-vols-1-2" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx34305-ul28wvP0lhjv.jpg", + "chapters": 26, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 34305, + "url": "https://anilist.co/manga/34305" + } + }, + { + "id": "the-heart-of-thomas-1974", + "title": "The Heart of Thomas", + "year": 1974, + "period": "1970–1974", + "periodOrder": 1, + "rank": 8, + "note": "A school tragedy that helped establish psychologically intricate boys'-love/shōjo storytelling.", + "source": { + "kind": "edition", + "url": "https://www.fantagraphics.com/products/heart-of-thomas" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx39152-QcXrecYJwsEQ.jpg", + "chapters": 40, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 39152, + "url": "https://anilist.co/manga/39152" + } + }, + { + "id": "the-glass-mask-1975", + "title": "The Glass Mask", + "year": 1975, + "period": "1975–1979", + "periodOrder": 2, + "rank": 1, + "note": "Legendary acting rivalry and unusually intense long-form character growth;", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31469-xYQ8kWQynM7n.png", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 31469, + "url": "https://anilist.co/manga/31469" + } + }, + { + "id": "galaxy-express-999-1977", + "title": "Galaxy Express 999", + "year": 1977, + "period": "1975–1979", + "periodOrder": 2, + "rank": 2, + "note": "Melancholy train-through-space fables about bodies, technology, and mortality;", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx39705-uxZAOISKjKgB.jpg", + "chapters": 91, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 39705, + "url": "https://anilist.co/manga/39705" + } + }, + { + "id": "urusei-yatsura-1978", + "title": "Urusei Yatsura", + "year": 1978, + "period": "1975–1979", + "periodOrder": 2, + "rank": 3, + "note": "Rumiko Takahashi's anarchic rom-com machine; hugely influential on ensemble comedy.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/urusei-yatsura" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30304-jAQt0fJkMgUd.jpg", + "chapters": 366, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30304, + "url": "https://anilist.co/manga/30304" + } + }, + { + "id": "captain-harlock-1977", + "title": "Captain Harlock", + "year": 1977, + "period": "1975–1979", + "periodOrder": 2, + "rank": 4, + "note": "Romantic space-pirate resistance and striking Leiji Matsumoto atmosphere.", + "source": { + "kind": "edition", + "url": "https://sevenseasentertainment.com/series/captain-harlock-the-classic-collection/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx35115-fZzfIJXnDYds.jpg", + "chapters": 55, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 35115, + "url": "https://anilist.co/manga/35115" + } + }, + { + "id": "candy-candy-1975", + "title": "Candy Candy", + "year": 1975, + "period": "1975–1979", + "periodOrder": 2, + "rank": 5, + "note": "A shōjo melodrama landmark with cross-generational popularity;", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx31388-JDIwg74gvgt3.jpg", + "chapters": 44, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31388, + "url": "https://anilist.co/manga/31388" + } + }, + { + "id": "the-poem-of-wind-and-trees-1976", + "title": "The Poem of Wind and Trees", + "year": 1976, + "period": "1975–1979", + "periodOrder": 2, + "rank": 6, + "note": "A historically important, emotionally severe early boys'-love work;", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31365-ucezaL74sIgK.jpg", + "chapters": 139, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31365, + "url": "https://anilist.co/manga/31365" + } + }, + { + "id": "kochikame-1976", + "title": "Kochikame", + "year": 1976, + "period": "1975–1979", + "periodOrder": 2, + "rank": 7, + "note": "Everyday police-station comedy whose extraordinary longevity makes it a time capsule; full", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx30733-QmyPwBhjbgyX.jpg", + "chapters": 1968, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30733, + "url": "https://anilist.co/manga/30733" + } + }, + { + "id": "akira-1982", + "title": "Akira", + "year": 1982, + "period": "1980–1984", + "periodOrder": 3, + "rank": 1, + "note": "The definitive urban-collapse/cyberpunk landmark; a particularly strong fit for your *BLAME!* and *Biomega* shelf.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/akira/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30664-JJq9tosQvlpW.jpg", + "chapters": 120, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30664, + "url": "https://anilist.co/manga/30664" + } + }, + { + "id": "nausicaa-of-the-valley-of-the-wind-1982", + "title": "Nausicaä of the Valley of the Wind", + "year": 1982, + "period": "1980–1984", + "periodOrder": 3, + "rank": 2, + "note": "Miyazaki's manga is a far deeper ecological-war epic than its film adaptation.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/manga-books/manga/nausicaa-of-the-valley-of-the-wind/all" + }, + "cover": null, + "chapters": null, + "status": "unknown" + }, + { + "id": "dragon-ball-1984", + "title": "Dragon Ball", + "year": 1984, + "period": "1980–1984", + "periodOrder": 3, + "rank": 3, + "note": "The grammar of modern battle shōnen, executed with an unmatched sense of motion and play.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100011" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30042-4SetGiEbGc9x.jpg", + "chapters": 520, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30042, + "url": "https://anilist.co/manga/30042" + } + }, + { + "id": "fist-of-the-north-star-1983", + "title": "Fist of the North Star", + "year": 1983, + "period": "1980–1984", + "periodOrder": 3, + "rank": 4, + "note": "Post-apocalyptic martial-arts excess that shaped action manga's iconography.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/fist-of-the-north-star" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31149-TOwqxOlY1Zs0.jpg", + "chapters": 245, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31149, + "url": "https://anilist.co/manga/31149" + } + }, + { + "id": "maison-ikkoku-1980", + "title": "Maison Ikkoku", + "year": 1980, + "period": "1980–1984", + "periodOrder": 3, + "rank": 5, + "note": "A surprisingly adult, patient romance/comedy about grief and choosing a future.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/maison-ikkoku" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30688-vhFGM9jUcGrU.png", + "chapters": 162, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30688, + "url": "https://anilist.co/manga/30688" + } + }, + { + "id": "touch-1981", + "title": "Touch", + "year": 1981, + "period": "1980–1984", + "periodOrder": 3, + "rank": 6, + "note": "Mitsuru Adachi's baseball romance: understatement, timing, and a devastating turn.", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31013-Suolsv3rhJnA.jpg", + "chapters": 257, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31013, + "url": "https://anilist.co/manga/31013" + } + }, + { + "id": "captain-tsubasa-1981", + "title": "Captain Tsubasa", + "year": 1981, + "period": "1980–1984", + "periodOrder": 3, + "rank": 7, + "note": "Football dreams drawn at full kinetic intensity; foundational sports manga.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/200014" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31789-qiNlHJDMJncz.png", + "chapters": 356, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31789, + "url": "https://anilist.co/manga/31789" + } + }, + { + "id": "dr-slump-1980", + "title": "Dr. Slump", + "year": 1980, + "period": "1980–1984", + "periodOrder": 3, + "rank": 8, + "note": "Toriyama's wild gag-comic laboratory before *Dragon Ball*.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/dr-slump" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30796-PFF4n7Y2spz9.png", + "chapters": 236, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30796, + "url": "https://anilist.co/manga/30796" + } + }, + { + "id": "berserk-1989", + "title": "Berserk", + "year": 1989, + "period": "1985–1989", + "periodOrder": 4, + "rank": 1, + "note": "Monumental dark fantasy, trauma, and page design; already the clearest taste anchor in your library.", + "source": { + "kind": "edition", + "url": "https://www.darkhorse.com/books/12-705/berserk-volume-1-tpb/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30002-Cul4OeN7bYtn.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30002, + "url": "https://anilist.co/manga/30002" + } + }, + { + "id": "jojo-s-bizarre-adventure-1987", + "title": "JoJo's Bizarre Adventure", + "year": 1987, + "period": "1985–1989", + "periodOrder": 4, + "rank": 2, + "note": "Reinvents its own rules, cast, and visual language across parts; start with Part 1 rather than cataloging *Steel Ball Run* as the debut.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/jojo-s-bizarre-adventure" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31630-Fg8JERVKuWB5.jpg", + "chapters": 69, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31630, + "url": "https://anilist.co/manga/31630" + } + }, + { + "id": "parasyte-1989", + "title": "Parasyte", + "year": 1989, + "period": "1985–1989", + "periodOrder": 4, + "rank": 3, + "note": "Body horror sharpened into an argument about ecology and personhood.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/parasyte/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30401-siY6wmwnHFi6.png", + "chapters": 64, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30401, + "url": "https://anilist.co/manga/30401" + } + }, + { + "id": "the-ghost-in-the-shell-1989", + "title": "The Ghost in the Shell", + "year": 1989, + "period": "1985–1989", + "periodOrder": 4, + "rank": 4, + "note": "Dense cybernetic politics and identity questions; a natural next stop from *BLAME!*.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/the-ghost-in-the-shell/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31023-iozeApEMjmPP.jpg", + "chapters": 11, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31023, + "url": "https://anilist.co/manga/31023" + } + }, + { + "id": "banana-fish-1985", + "title": "Banana Fish", + "year": 1985, + "period": "1985–1989", + "periodOrder": 4, + "rank": 5, + "note": "Crime thriller and devastating relationship drama that crosses demographic boundaries.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/banana-fish" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30756-rlxjQWBAizHX.jpg", + "chapters": 110, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30756, + "url": "https://anilist.co/manga/30756" + } + }, + { + "id": "ranma-1-2-1987", + "title": "Ranma ½", + "year": 1987, + "period": "1985–1989", + "periodOrder": 4, + "rank": 6, + "note": "Genre-defining gender-bending martial-arts farce with astonishing comic timing.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/ranma" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30023-NWoD28ZKC73c.jpg", + "chapters": 407, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30023, + "url": "https://anilist.co/manga/30023" + } + }, + { + "id": "city-hunter-1985", + "title": "City Hunter", + "year": 1985, + "period": "1985–1989", + "periodOrder": 4, + "rank": 7, + "note": "Stylish urban action-comedy with a distinctly late-1980s pulse.", + "source": null, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30792-62RxEnU7tlDg.png", + "chapters": 336, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30792, + "url": "https://anilist.co/manga/30792" + } + }, + { + "id": "saint-seiya-1985", + "title": "Saint Seiya", + "year": 1985, + "period": "1985–1989", + "periodOrder": 4, + "rank": 8, + "note": "Mythic armor battles whose melodrama and design traveled worldwide.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/knights-of-the-zodiac-saint-seiya" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31045-YUcWBMk7RpeK.png", + "chapters": 249, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 31045, + "url": "https://anilist.co/manga/31045" + } + }, + { + "id": "slam-dunk-1990", + "title": "Slam Dunk", + "year": 1990, + "period": "1990–1994", + "periodOrder": 5, + "rank": 1, + "note": "The sports-manga benchmark: exhilarating games built on genuinely earned character growth.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/slam-dunk" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30051-5KJyPlO7z5F4.png", + "chapters": 276, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30051, + "url": "https://anilist.co/manga/30051" + } + }, + { + "id": "monster-1994", + "title": "Monster", + "year": 1994, + "period": "1990–1994", + "periodOrder": 5, + "rank": 2, + "note": "Urasawa's humane, sprawling suspense novel in manga form; top priority for your mystery shelf.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/monster" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30001-Knby7l1jevE7.jpg", + "chapters": 162, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30001, + "url": "https://anilist.co/manga/30001" + } + }, + { + "id": "sailor-moon-1991", + "title": "Sailor Moon", + "year": 1991, + "period": "1990–1994", + "periodOrder": 5, + "rank": 3, + "note": "Magical-girl team adventure with lasting iconography and romantic urgency.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/pretty-guardian-sailor-moon/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30092-tjzz7ymlFfQu.png", + "chapters": 61, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30092, + "url": "https://anilist.co/manga/30092" + } + }, + { + "id": "yu-yu-hakusho-1990", + "title": "Yu Yu Hakusho", + "year": 1990, + "period": "1990–1994", + "periodOrder": 5, + "rank": 4, + "note": "Supernatural tournament storytelling with unusually lively characters.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/yuyu-hakusho" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30053-wCR6xyGzeUYo.png", + "chapters": 176, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30053, + "url": "https://anilist.co/manga/30053" + } + }, + { + "id": "blade-of-the-immortal-1993", + "title": "Blade of the Immortal", + "year": 1993, + "period": "1990–1994", + "periodOrder": 5, + "rank": 5, + "note": "Violent samurai revenge with extraordinary ink work and moral rot.", + "source": { + "kind": "publisher catalog", + "url": "https://images.darkhorse.com/libraries/downloads/LIBCON25_ESSENTIALS.pdf" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b30658-4KzCEiCDN7Yq.jpg", + "chapters": 219, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30658, + "url": "https://anilist.co/manga/30658" + } + }, + { + "id": "detective-conan-case-closed-1994", + "title": "Detective Conan / Case Closed", + "year": 1994, + "period": "1990–1994", + "periodOrder": 5, + "rank": 6, + "note": "An immense fair-play mystery machine; best sampled in arcs rather than treated as a completion project.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/case-closed" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31061-SJQb7931MG7m.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 31061, + "url": "https://anilist.co/manga/31061" + } + }, + { + "id": "basara-1990", + "title": "Basara", + "year": 1990, + "period": "1990–1994", + "periodOrder": 5, + "rank": 7, + "note": "Post-apocalyptic shōjo revolution, romance, and political identity; a great overlooked bridge between genres.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/basara" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30974-Ck0pncZ7LEnW.png", + "chapters": 107, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30974, + "url": "https://anilist.co/manga/30974" + } + }, + { + "id": "rurouni-kenshin-1994", + "title": "Rurouni Kenshin", + "year": 1994, + "period": "1990–1994", + "periodOrder": 5, + "rank": 8, + "note": "Influential Meiji-era action with a violence-versus-repentance core.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100023" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30022-jxTlViun1o10.jpg", + "chapters": 259, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30022, + "url": "https://anilist.co/manga/30022" + } + }, + { + "id": "one-piece-1997", + "title": "One Piece", + "year": 1997, + "period": "1995–1999", + "periodOrder": 6, + "rank": 1, + "note": "Unmatched adventure-world construction and emotional long-game; already in your library.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100020" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30013-BeslEMqiPhlk.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30013, + "url": "https://anilist.co/manga/30013" + } + }, + { + "id": "vagabond-1998", + "title": "Vagabond", + "year": 1998, + "period": "1995–1999", + "periodOrder": 6, + "rank": 2, + "note": "Philosophical swordsmanship and breathtaking draftsmanship; strong *Berserk* crossover.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/vagabond" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30656-9mW113O7rDnA.png", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30656, + "url": "https://anilist.co/manga/30656" + } + }, + { + "id": "hunter-hunter-1998", + "title": "Hunter × Hunter", + "year": 1998, + "period": "1995–1999", + "periodOrder": 6, + "rank": 3, + "note": "Battle manga as evolving strategy game and moral experiment.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100015" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30026-uCvXMudMzmwI.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30026, + "url": "https://anilist.co/manga/30026" + } + }, + { + "id": "20th-century-boys-1999", + "title": "20th Century Boys", + "year": 1999, + "period": "1995–1999", + "periodOrder": 6, + "rank": 4, + "note": "Conspiracy mystery across generations; a near-obvious pick if *Monster* lands.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/naoki-urasawa-s-20th-century-boys" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30003-E84fwIh22LAQ.jpg", + "chapters": 249, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30003, + "url": "https://anilist.co/manga/30003" + } + }, + { + "id": "blame-1997", + "title": "BLAME!", + "year": 1997, + "period": "1995–1999", + "periodOrder": 6, + "rank": 5, + "note": "Architectural terror and almost wordless cyberpunk; present on your shelf.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/blame/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30149-YetcNrfacNVj.jpg", + "chapters": 66, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30149, + "url": "https://anilist.co/manga/30149" + } + }, + { + "id": "uzumaki-1998", + "title": "Uzumaki", + "year": 1998, + "period": "1995–1999", + "periodOrder": 6, + "rank": 6, + "note": "Junji Ito's spiraling small-town cosmic horror, formally as inventive as it is grotesque.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/uzumaki" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30436-Opk9ZubMPuDU.jpg", + "chapters": 20, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30436, + "url": "https://anilist.co/manga/30436" + } + }, + { + "id": "fruits-basket-1998", + "title": "Fruits Basket", + "year": 1998, + "period": "1995–1999", + "periodOrder": 6, + "rank": 7, + "note": "A shōjo classic that turns supernatural family setup into a long trauma-and-healing story.", + "source": { + "kind": "edition", + "url": "https://yenpress.com/series/fruits-basket-collector-s-edition" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30102-MJuQ0e0k2CgU.png", + "chapters": 137, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30102, + "url": "https://anilist.co/manga/30102" + } + }, + { + "id": "naruto-1999", + "title": "Naruto", + "year": 1999, + "period": "1995–1999", + "periodOrder": 6, + "rank": 8, + "note": "Found-family ninja epic whose strongest arcs show why it became a worldwide entry point.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100018" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx30011-9yUF1dXWgDOx.jpg", + "chapters": 700, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30011, + "url": "https://anilist.co/manga/30011" + } + }, + { + "id": "gto-great-teacher-onizuka-1997", + "title": "GTO: Great Teacher Onizuka", + "year": 1997, + "period": "1995–1999", + "periodOrder": 6, + "rank": 9, + "note": "Juvenile comedy undercut by a sincere defense of troubled students.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/gto-great-teacher-onizuka/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30336-KWTPMwsNTeSa.png", + "chapters": 208, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30336, + "url": "https://anilist.co/manga/30336" + } + }, + { + "id": "fullmetal-alchemist-2001", + "title": "Fullmetal Alchemist", + "year": 2001, + "period": "2000–2004", + "periodOrder": 7, + "rank": 1, + "note": "Probably the most satisfying closed-loop fantasy adventure of its era: alchemy, war, grief, and consequence.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/fullmetal-alchemist" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30025-mpPVpCKFTowt.png", + "chapters": 116, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30025, + "url": "https://anilist.co/manga/30025" + } + }, + { + "id": "death-note-2003", + "title": "Death Note", + "year": 2003, + "period": "2000–2004", + "periodOrder": 7, + "rank": 2, + "note": "A near-perfect high-concept cat-and-mouse hook, especially in its early stretch.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100008" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30021-FE6kmrfpuKyb.jpg", + "chapters": 108, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30021, + "url": "https://anilist.co/manga/30021" + } + }, + { + "id": "nana-2000", + "title": "Nana", + "year": 2000, + "period": "2000–2004", + "periodOrder": 7, + "rank": 3, + "note": "Adult friendship, music, romance, and compromise with a rare emotional bite.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/nana" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30028-VJqBC1ar6AxE.png", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30028, + "url": "https://anilist.co/manga/30028" + } + }, + { + "id": "pluto-2003", + "title": "Pluto", + "year": 2003, + "period": "2000–2004", + "periodOrder": 7, + "rank": 4, + "note": "*Astro Boy* rebuilt as a war-trauma murder mystery by the creator of *Monster*.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/pluto-urasawa-x-tezuka" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30745-kueBpwriQkvZ.jpg", + "chapters": 65, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30745, + "url": "https://anilist.co/manga/30745" + } + }, + { + "id": "yotsuba-2003", + "title": "Yotsuba&!", + "year": 2003, + "period": "2000–2004", + "periodOrder": 7, + "rank": 5, + "note": "Observation and joy made into a near-flawless quiet comedy; a palate cleanser worth the detour.", + "source": { + "kind": "edition", + "url": "https://yenpress.com/series/yotsuba" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30104-sUVzNlTWZ5cu.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 30104, + "url": "https://anilist.co/manga/30104" + } + }, + { + "id": "bleach-2001", + "title": "Bleach", + "year": 2001, + "period": "2000–2004", + "periodOrder": 7, + "rank": 6, + "note": "Aesthetic, sword-fighting, and character-design powerhouse already on your shelf.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100004" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30012-1epmVfTSv2rr.png", + "chapters": 706, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30012, + "url": "https://anilist.co/manga/30012" + } + }, + { + "id": "gantz-2000", + "title": "Gantz", + "year": 2000, + "period": "2000–2004", + "periodOrder": 7, + "rank": 7, + "note": "Messy, excessive survival SF with page-turning momentum; one of your more direct taste matches.", + "source": { + "kind": "edition", + "url": "https://www.darkhorse.com/Books/30-524/Gantz-Omnibus-Volume-1-TPB" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30564-RbyFbLzbnKea.jpg", + "chapters": 383, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30564, + "url": "https://anilist.co/manga/30564" + } + }, + { + "id": "nodame-cantabile-2001", + "title": "Nodame Cantabile", + "year": 2001, + "period": "2000–2004", + "periodOrder": 7, + "rank": 8, + "note": "Music-school romantic comedy that actually cares about craft and artistic ambition.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/nodame-cantabile/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30419-lT7p72ItXPNK.jpg", + "chapters": 152, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30419, + "url": "https://anilist.co/manga/30419" + } + }, + { + "id": "vinland-saga-2005", + "title": "Vinland Saga", + "year": 2005, + "period": "2005–2009", + "periodOrder": 8, + "rank": 1, + "note": "Viking violence transformed into a remarkable argument about what strength is for.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/vinland-saga/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx30642-0mjRDkf4THpo.jpg", + "chapters": 224, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 30642, + "url": "https://anilist.co/manga/30642" + } + }, + { + "id": "kingdom-2006", + "title": "Kingdom", + "year": 2006, + "period": "2005–2009", + "periodOrder": 8, + "rank": 2, + "note": "Huge-scale Chinese-war strategy and addictive career progression; likely strong for your *Solo Leveling*/*One Piece* side.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/kingdom" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx46765-KPXir4sRqJBW.png", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 46765, + "url": "https://anilist.co/manga/46765" + } + }, + { + "id": "goodnight-punpun-2007", + "title": "Goodnight Punpun", + "year": 2007, + "period": "2005–2009", + "periodOrder": 8, + "rank": 3, + "note": "Formal daring and psychologically brutal coming-of-age; brilliant, but heed the heavy-content warning.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/goodnight-punpun" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx34632-5xMDkx3pXsEh.png", + "chapters": 147, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 34632, + "url": "https://anilist.co/manga/34632" + } + }, + { + "id": "chihayafuru-2007", + "title": "Chihayafuru", + "year": 2007, + "period": "2005–2009", + "periodOrder": 8, + "rank": 4, + "note": "Makes competitive karuta feel as tense as any battle manga, without losing its relationships.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/chihayafuru/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx43245-Ied8R99LH3u6.jpg", + "chapters": 248, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 43245, + "url": "https://anilist.co/manga/43245" + } + }, + { + "id": "a-bride-s-story-2008", + "title": "A Bride's Story", + "year": 2008, + "period": "2005–2009", + "periodOrder": 8, + "rank": 5, + "note": "Astonishingly detailed Central Asian historical slice of life and household drama.", + "source": { + "kind": "edition", + "url": "https://yenpress.com/series/a-bride-s-story" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx41514-IHeXFwqmWivk.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 41514, + "url": "https://anilist.co/manga/41514" + } + }, + { + "id": "attack-on-titan-2009", + "title": "Attack on Titan", + "year": 2009, + "period": "2005–2009", + "periodOrder": 8, + "rank": 6, + "note": "Escalating siege mystery that repeatedly changes the reader's understanding of its world.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/attack-on-titan/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx53390-1RsuABC34P9D.jpg", + "chapters": 141, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 53390, + "url": "https://anilist.co/manga/53390" + } + }, + { + "id": "march-comes-in-like-a-lion-2007", + "title": "March Comes in Like a Lion", + "year": 2007, + "period": "2005–2009", + "periodOrder": 8, + "rank": 7, + "note": "Shogi, loneliness, and hard-won human warmth; a quieter prestige pick.", + "source": { + "kind": "edition", + "url": "https://www.denpa.pub/book" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx31224-dxugU08jahRT.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 31224, + "url": "https://anilist.co/manga/31224" + } + }, + { + "id": "bakuman-2008", + "title": "Bakuman", + "year": 2008, + "period": "2005–2009", + "periodOrder": 8, + "rank": 8, + "note": "An entertaining and revealing, if idealized, story about making a hit manga.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100002" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx39711-tjPWXT1AW321.jpg", + "chapters": 176, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 39711, + "url": "https://anilist.co/manga/39711" + } + }, + { + "id": "golden-kamuy-2014", + "title": "Golden Kamuy", + "year": 2014, + "period": "2010–2014", + "periodOrder": 9, + "rank": 1, + "note": "Ainu history, treasure hunt, survival cuisine, grotesque comedy: gloriously hard to categorize.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/golden-kamuy" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86559-Y4xDmoz4ud43.jpg", + "chapters": 314, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 86559, + "url": "https://anilist.co/manga/86559" + } + }, + { + "id": "land-of-the-lustrous-2012", + "title": "Land of the Lustrous", + "year": 2012, + "period": "2010–2014", + "periodOrder": 9, + "rank": 2, + "note": "Immortal gemstone bodies and slowly devastating identity change; exquisite speculative manga.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/land-of-the-lustrous/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx74489-5HtDCFfut8Be.jpg", + "chapters": 120, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 74489, + "url": "https://anilist.co/manga/74489" + } + }, + { + "id": "delicious-in-dungeon-2014", + "title": "Delicious in Dungeon", + "year": 2014, + "period": "2010–2014", + "periodOrder": 9, + "rank": 3, + "note": "Dungeon ecology and cooking become a serious fantasy-world argument.", + "source": { + "kind": "edition", + "url": "https://yenpress.com/series/delicious-in-dungeon" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86082-MXizJxzbijdd.jpg", + "chapters": 111, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 86082, + "url": "https://anilist.co/manga/86082" + } + }, + { + "id": "one-punch-man-2012", + "title": "One-Punch Man", + "year": 2012, + "period": "2010–2014", + "periodOrder": 9, + "rank": 4, + "note": "Superhero parody with implausibly spectacular fight art; present locally.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/one-punch-man" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx74347-sZpmNJ5xLwRK.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 74347, + "url": "https://anilist.co/manga/74347" + } + }, + { + "id": "haikyu-2012", + "title": "Haikyu!!", + "year": 2012, + "period": "2010–2014", + "periodOrder": 9, + "rank": 5, + "note": "Modern sports manga's great ensemble and a model of readable action.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100014" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx65243-mR4MnJFmfaOF.png", + "chapters": 404, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 65243, + "url": "https://anilist.co/manga/65243" + } + }, + { + "id": "a-silent-voice-2013", + "title": "A Silent Voice", + "year": 2013, + "period": "2010–2014", + "periodOrder": 9, + "rank": 6, + "note": "Accountability and disability treated with an unusually painful, compassionate eye.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/a-silent-voice/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx85135-11OOnyaqV71k.png", + "chapters": 64, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 85135, + "url": "https://anilist.co/manga/85135" + } + }, + { + "id": "ajin-demi-human-2012", + "title": "Ajin: Demi-Human", + "year": 2012, + "period": "2010–2014", + "periodOrder": 9, + "rank": 7, + "note": "Tactical immortal-on-immortal thriller; a particularly strong fit for your action/SF preferences.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/ajin-demi-human/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/b79865-rwKmISrhJJyP.jpg", + "chapters": 99, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 79865, + "url": "https://anilist.co/manga/79865" + } + }, + { + "id": "blue-giant-2013", + "title": "Blue Giant", + "year": 2013, + "period": "2010–2014", + "periodOrder": 9, + "rank": 8, + "note": "A jazz practice-and-ambition manga that somehow makes performance visibly loud.", + "source": { + "kind": "edition", + "url": "https://sevenseasentertainment.com/series/blue-giant-omnibus/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86890-hYxIiVM6rBBG.jpg", + "chapters": 80, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 86890, + "url": "https://anilist.co/manga/86890" + } + }, + { + "id": "witch-hat-atelier-2016", + "title": "Witch Hat Atelier", + "year": 2016, + "period": "2015–2019", + "periodOrder": 10, + "rank": 1, + "note": "Beautifully designed magic with politics, disability, and apprenticeship underneath; 2025 Harvey Best Manga winner.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/witch-hat-atelier/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx98263-kIeaTXrAbGkj.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 98263, + "url": "https://anilist.co/manga/98263" + } + }, + { + "id": "chainsaw-man-2018", + "title": "Chainsaw Man", + "year": 2018, + "period": "2015–2019", + "periodOrder": 10, + "rank": 2, + "note": "Chaotic horror-action that keeps finding a more human emotional center.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100037" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx105778-euxXZEIfDY2u.png", + "chapters": 232, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 105778, + "url": "https://anilist.co/manga/105778" + } + }, + { + "id": "blue-period-2017", + "title": "Blue Period", + "year": 2017, + "period": "2015–2019", + "periodOrder": 10, + "rank": 3, + "note": "Art-making as vocation, insecurity, and strenuous learning; a Kodansha Manga Award winner.", + "source": { + "kind": "award history", + "url": "https://www.kodansha.co.jp/awards/comics/histories" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx107237-tyQXghsKmE0t.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 107237, + "url": "https://anilist.co/manga/107237" + } + }, + { + "id": "beastars-2016", + "title": "Beastars", + "year": 2016, + "period": "2015–2019", + "periodOrder": 10, + "rank": 4, + "note": "Predator/prey noir and social allegory with an odd, tender character core; award-recognized.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/beastars" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx98587-useiMvZ0nfya.png", + "chapters": 196, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 98587, + "url": "https://anilist.co/manga/98587" + } + }, + { + "id": "the-apothecary-diaries-2017", + "title": "The Apothecary Diaries", + "year": 2017, + "period": "2015–2019", + "periodOrder": 10, + "rank": 5, + "note": "Court intrigue solved by a poison-obsessed medical detective.", + "source": { + "kind": "read", + "url": "https://global.manga-up.com/manga/164" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/nx99022-Hh2WdyNgR8HM.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 99022, + "url": "https://anilist.co/manga/99022" + } + }, + { + "id": "heavenly-delusion-2018", + "title": "Heavenly Delusion", + "year": 2018, + "period": "2015–2019", + "periodOrder": 10, + "rank": 6, + "note": "Two intertwined post-apocalyptic mysteries; a highly specific match to your library.", + "source": { + "kind": "read", + "url": "https://kmanga.kodansha.com/title/10511/episode/345745" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101942-yjslDpT6XcpK.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 101942, + "url": "https://anilist.co/manga/101942" + } + }, + { + "id": "demon-slayer-kimetsu-no-yaiba-2016", + "title": "Demon Slayer: Kimetsu no Yaiba", + "year": 2016, + "period": "2015–2019", + "periodOrder": 10, + "rank": 7, + "note": "Cleanly motivated tragedy and unusually readable, concentrated action.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/demon-slayer-kimetsu-no-yaiba" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx87216-c9bSNVD10UuD.png", + "chapters": 207, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 87216, + "url": "https://anilist.co/manga/87216" + } + }, + { + "id": "jujutsu-kaisen-2018", + "title": "Jujutsu Kaisen", + "year": 2018, + "period": "2015–2019", + "periodOrder": 10, + "rank": 8, + "note": "Dark occult combat with inventive constraints and breakneck tactical reversals.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100034" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx101517-H3TdM3g5ZUe9.jpg", + "chapters": 272, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 101517, + "url": "https://anilist.co/manga/101517" + } + }, + { + "id": "spy-family-2019", + "title": "Spy × Family", + "year": 2019, + "period": "2015–2019", + "periodOrder": 10, + "rank": 9, + "note": "Found-family spy comedy that uses its gimmick for real tenderness.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100056" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx108556-NHjkz0BNJhLx.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 108556, + "url": "https://anilist.co/manga/108556" + } + }, + { + "id": "kaguya-sama-love-is-war-2015", + "title": "Kaguya-sama: Love Is War", + "year": 2015, + "period": "2015–2019", + "periodOrder": 10, + "rank": 10, + "note": "Romantic comedy as elaborate psychological warfare, then sincere character study.", + "source": { + "kind": "edition", + "url": "https://www.viz.com/kaguya-sama-love-is-war" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx86635-EdaLQmsn86Fy.png", + "chapters": 281, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 86635, + "url": "https://anilist.co/manga/86635" + } + }, + { + "id": "frieren-beyond-journey-s-end-2020", + "title": "Frieren: Beyond Journey's End", + "year": 2020, + "period": "2020–2024", + "periodOrder": 11, + "rank": 1, + "note": "Post-quest fantasy about the scale of memory and grief; 2021 Manga Taisho winner and a strong fit for your fantasy shelf.", + "source": { + "kind": "read", + "url": "https://www.viz.com/vizmanga/chapters/frieren-the-journeys-end" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118586-CXKgWikBFQgS.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 118586, + "url": "https://anilist.co/manga/118586" + } + }, + { + "id": "dandadan-2021", + "title": "Dandadan", + "year": 2021, + "period": "2020–2024", + "periodOrder": 11, + "rank": 2, + "note": "UFOs, ghosts, young romance, and some of the most kinetic pages in weekly manga.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100171" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx132029-prGF4gePdSKv.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 132029, + "url": "https://anilist.co/manga/132029" + } + }, + { + "id": "the-summer-hikaru-died-2021", + "title": "The Summer Hikaru Died", + "year": 2021, + "period": "2020–2024", + "periodOrder": 11, + "rank": 3, + "note": "Queer grief and rural body horror with extraordinary unease.", + "source": { + "kind": "edition", + "url": "https://yenpress.com/series/the-summer-hikaru-died" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx138603-j0gcshf7PVkN.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 138603, + "url": "https://anilist.co/manga/138603" + } + }, + { + "id": "medalist-2020", + "title": "Medalist", + "year": 2020, + "period": "2020–2024", + "periodOrder": 11, + "rank": 4, + "note": "Figure-skating mentorship with rigorous athletic detail and big emotional payoffs.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/medalist/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx118371-3xk9VUbWnnRR.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 118371, + "url": "https://anilist.co/manga/118371" + } + }, + { + "id": "orb-on-the-movements-of-the-earth-2020", + "title": "Orb: On the Movements of the Earth", + "year": 2020, + "period": "2020–2024", + "periodOrder": 11, + "rank": 5, + "note": "A dangerous idea passed between people across generations; philosophical historical thriller.", + "source": { + "kind": "edition", + "url": "https://sevenseasentertainment.com/series/orb-on-the-movements-of-the-earth-omnibus/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx124139-OOL1bW0HecJT.jpg", + "chapters": 62, + "status": "completed", + "metadata": { + "provider": "AniList", + "id": 124139, + "url": "https://anilist.co/manga/124139" + } + }, + { + "id": "the-darwin-incident-2020", + "title": "The Darwin Incident", + "year": 2020, + "period": "2020–2024", + "periodOrder": 11, + "rank": 6, + "note": "A human–chimpanzee teenager turns a political-thriller premise into questions of personhood; 2026 Kodansha Manga Award winner.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/the-darwin-incident/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx120638-cWwWsAGL3t9d.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 120638, + "url": "https://anilist.co/manga/120638" + } + }, + { + "id": "kagurabachi-2023", + "title": "Kagurabachi", + "year": 2023, + "period": "2020–2024", + "periodOrder": 11, + "rank": 7, + "note": "Sword-revenge action that rapidly grew beyond its meme beginnings into a genuinely stylish hit.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100274" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx169355-5pqzh1Wb4NOQ.png", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 169355, + "url": "https://anilist.co/manga/169355" + } + }, + { + "id": "gachiakuta-2022", + "title": "Gachiakuta", + "year": 2022, + "period": "2020–2024", + "periodOrder": 11, + "rank": 8, + "note": "Graffiti-inflected junkyard fantasy and kinetic social anger; 2026 Kodansha Manga Award winner.", + "source": { + "kind": "edition", + "url": "https://kodansha.us/series/gachiakuta/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx144946-cscic3n2SwdY.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 144946, + "url": "https://anilist.co/manga/144946" + } + }, + { + "id": "sakamoto-days-2020", + "title": "Sakamoto Days", + "year": 2020, + "period": "2020–2024", + "periodOrder": 11, + "rank": 9, + "note": "Retired-hitman comedy with action choreography that repeatedly tops its own gimmick.", + "source": { + "kind": "read", + "url": "https://mangaplus.shueisha.co.jp/titles/100127" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx125828-p78Z8SflkfmO.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 125828, + "url": "https://anilist.co/manga/125828" + } + }, + { + "id": "ichi-the-witch-2024", + "title": "Ichi the Witch", + "year": 2024, + "period": "2020–2024", + "periodOrder": 11, + "rank": 10, + "note": "A newer spell-hunting fantasy with a charismatic oddball lead; promising but less historically settled than the first six here.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/ichi-the-witch" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx180752-13CVO310XiEs.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 180752, + "url": "https://anilist.co/manga/180752" + } + }, + { + "id": "home-at-the-horizon-2025", + "title": "Home at the Horizon", + "year": 2025, + "period": "2025–2026", + "periodOrder": 12, + "rank": 1, + "note": "Seaside bathhouse family drama; #1 in the 2026 Next Manga Awards web vote.", + "source": { + "kind": "official award and reading lead", + "url": "https://mangaplus.shueisha.co.jp/web_pages/2441/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx188733-YMOdYQ8llVnm.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 188733, + "url": "https://anilist.co/manga/188733" + } + }, + { + "id": "someone-hertz-2025", + "title": "Someone Hertz", + "year": 2025, + "period": "2025–2026", + "periodOrder": 12, + "rank": 2, + "note": "Comedy/radio romance; #1 in the 2026 print-category reader vote and officially serialized in English.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/someone-hertz" + }, + "cover": null, + "chapters": null, + "status": "unknown" + }, + { + "id": "futari-bus-2025", + "title": "Futari Bus", + "year": 2025, + "period": "2025–2026", + "periodOrder": 12, + "rank": 3, + "note": "A bus-ride, almost-romance comedy; #2 in the same reader vote.;", + "source": { + "kind": "award/info", + "url": "https://tsugimanga.jp/winners_announcement/comics" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx201855-7dEgpKSZhWsH.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 201855, + "url": "https://anilist.co/manga/201855" + } + }, + { + "id": "war-of-the-adults-2025", + "title": "War of the Adults", + "year": 2025, + "period": "2025–2026", + "periodOrder": 12, + "rank": 4, + "note": "Kappy/Masaaki Tsuzuki's social-conflict thriller; #4 in the 2026 web vote and a better match to your darker shelf.", + "source": { + "kind": "official award and reading lead", + "url": "https://mangaplus.shueisha.co.jp/web_pages/2441/" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx189691-TYkHsBOxbQW8.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 189691, + "url": "https://anilist.co/manga/189691" + } + }, + { + "id": "the-marshal-king-2025", + "title": "The Marshal King", + "year": 2025, + "period": "2025–2026", + "periodOrder": 12, + "rank": 5, + "note": "Boichi's Western-inflected action; one of MANGA Plus's most-viewed 2025 debut chapters, but too young for a legacy claim.", + "source": { + "kind": "read", + "url": "https://www.viz.com/shonenjump/chapters/the-marshal-king" + }, + "cover": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx186517-AP09vY1BVb1E.jpg", + "chapters": null, + "status": "ongoing", + "metadata": { + "provider": "AniList", + "id": 186517, + "url": "https://anilist.co/manga/186517" + } + }, + { + "id": "hitoner-2026", + "title": "Hitoner", + "year": 2026, + "period": "2025–2026", + "periodOrder": 12, + "rank": 6, + "note": "Human/other-species observation story; #2 in the 2026 web vote. Include as an adventurous wildcard, not a proven classic.", + "source": { + "kind": "official award and reading lead", + "url": "https://mangaplus.shueisha.co.jp/web_pages/2441/" + }, + "cover": null, + "chapters": null, + "status": "unknown" + } + ] +} diff --git a/greatest/index.js b/greatest/index.js new file mode 100644 index 0000000..3e15f8c --- /dev/null +++ b/greatest/index.js @@ -0,0 +1,44 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const RATINGS = Object.freeze(['flame', 'good', 'neutral', 'skip']); +const WEIGHT = Object.freeze({ flame: 3, good: 1, neutral: 0, skip: -2 }); +const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, ''); + +function loadCatalog(file = path.join(__dirname, 'catalog.json')) { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + if (parsed.schema !== 1 || !Array.isArray(parsed.entries)) throw new Error('Invalid Greatest of All Time catalog'); + const ids = new Set(); + for (const entry of parsed.entries) { + if (!/^[a-z0-9-]+$/.test(entry.id) || ids.has(entry.id) || !entry.title || !entry.period || !Number.isInteger(entry.rank)) + throw new Error(`Invalid or duplicate Greatest of All Time entry: ${entry.id}`); + if (entry.cover && !/^https:\/\//.test(entry.cover)) throw new Error(`Invalid cover URL for ${entry.id}`); + if (entry.source?.url && !/^https:\/\//.test(entry.source.url)) throw new Error(`Invalid source URL for ${entry.id}`); + ids.add(entry.id); + } + return parsed; +} + +function findOwned(entry, mangas) { + const names = new Set([entry.title, ...(entry.aliases || [])].map(normalize)); + return mangas.find(m => { + const alt = m.meta?.altTitles; + return [m.title, ...(Array.isArray(alt) ? alt : alt ? [alt] : [])].some(name => names.has(normalize(name))); + }) || null; +} + +function countsFor(entryId, votes, users) { + const counts = { flame: 0, good: 0, neutral: 0, skip: 0 }; + for (const name of Object.keys(users)) { + const rating = votes[name]?.[entryId]; + if (RATINGS.includes(rating)) counts[rating]++; + } + return counts; +} + +function scoreFor(counts) { + return RATINGS.reduce((sum, rating) => sum + counts[rating] * WEIGHT[rating], 0); +} + +module.exports = { RATINGS, WEIGHT, normalize, loadCatalog, findOwned, countsFor, scoreFor }; diff --git a/public/app.css b/public/app.css index 6dcf3bc..742631b 100644 --- a/public/app.css +++ b/public/app.css @@ -34,6 +34,7 @@ label { display: block; } padding: 8px 16px; padding-top: max(8px, env(safe-area-inset-top)); background: rgba(18, 18, 20, .94); border-bottom: 1px solid var(--line); } .brand { font-weight: 800; text-decoration: none; font-size: 17px; letter-spacing: .2px; } +.nav-greatest { border-color: #5b3c46; color: #f0b9a0; } .bell-wrap { position: relative; } .bell .count { background: var(--accent); color: #fff; border-radius: 9px; font-size: 12px; font-weight: 700; padding: 0 6px; } .panel { @@ -100,6 +101,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; } .toolbar select { flex: 1; } .grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; } .nav { padding-left: 12px; padding-right: 12px; } + .nav { gap: 5px; } + .nav .brand { font-size: 14px; } + .nav .btn { padding-left: 7px; padding-right: 7px; font-size: 12px; } + .nav-greatest { font-size: 0 !important; } + .nav-greatest::after { content: 'GOAT'; font-size: 12px; } .reading-summary { grid-template-columns: 1fr; gap: 5px; } .reading-summary-links { justify-content: flex-start; } } diff --git a/public/common.js b/public/common.js index 121e935..ef928f2 100644 --- a/public/common.js +++ b/public/common.js @@ -96,6 +96,7 @@ function topNav(me) { document.addEventListener('click', () => { panel.hidden = true; }); const nav = h('header', { class: 'nav' }, h('a', { class: 'brand', href: './' }, 'MangaCircus'), + h('a', { class: 'btn nav-greatest', href: 'greatest.html', title: 'Shijō Saikō no Manga · Greatest of All Time' }, 'Greatest'), h('div', { class: 'grow' }), h('div', { class: 'bell-wrap' }, bell, panel), h('a', { class: 'btn', href: 'settings.html' }, 'Settings'), diff --git a/public/greatest.css b/public/greatest.css new file mode 100644 index 0000000..3fc7808 --- /dev/null +++ b/public/greatest.css @@ -0,0 +1,42 @@ +.greatest-intro { padding: 18px 0 14px; max-width: 750px; } +.greatest-intro h1 { font-size: clamp(28px, 5vw, 42px); letter-spacing: -.03em; } +.greatest-intro p { margin: 7px 0; } +.greatest-eyebrow { color: #e9a47d; text-transform: uppercase; letter-spacing: .15em; font-size: 12px; font-weight: 800; } +.greatest-toolbar { display: flex; align-items: center; gap: 10px; margin: 12px 0 10px; } +.greatest-toolbar > input { flex: 1; min-width: 0; } +.greatest-sort, .greatest-filters { display: flex; flex-wrap: wrap; gap: 7px; } +.greatest-filters { margin: 10px 0 12px; } +.greatest-periods { margin-top: 12px; } +.greatest-period { padding: 20px 0 28px; border-top: 2px solid #48404a; } +.greatest-period-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.greatest-period-heading h2 { margin: 0; font-size: 21px; } +.greatest-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 22px 16px; } +.greatest-tile { min-width: 0; } +.greatest-open { position: relative; display: block; width: 100%; padding: 0; border: 0; text-align: left; color: inherit; background: none; cursor: pointer; font: inherit; } +.greatest-open:hover .greatest-cover img { transform: scale(1.035); } +.greatest-open:focus-visible, .greatest-vote:focus-visible { outline: 2px solid #fff; outline-offset: 3px; } +.greatest-cover { position: relative; display: block; width: 100%; aspect-ratio: 2 / 3; overflow: hidden; border-radius: 9px; background: linear-gradient(150deg, #49343c, #282c38 60%, #18181d); box-shadow: 0 4px 17px #0007; } +.greatest-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform .2s; } +.greatest-cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; color: #dbc8bd; font: 700 80px/1 Georgia, serif; opacity: .6; } +.greatest-owned { position: absolute; top: 7px; right: 7px; padding: 2px 7px; border-radius: 9px; background: #183d34; color: #a7f0d1; font-size: 11px; font-weight: 700; box-shadow: 0 1px 8px #0008; } +.greatest-name { display: block; margin-top: 8px; font-weight: 700; line-height: 1.25; } +.greatest-meta { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; } +.greatest-votes { display: flex; gap: 8px; margin-top: 12px; } +.greatest-votes.compact { gap: 3px; margin-top: 6px; } +.greatest-vote { display: inline-grid; place-items: center; flex: 1; min-width: 0; min-height: 36px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--fg); cursor: pointer; font: inherit; } +.greatest-vote:hover { background: var(--card2); } +.greatest-vote.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 25%, var(--card)); } +.greatest-vote:disabled { opacity: .5; cursor: wait; } +.greatest-dialog { width: min(780px, calc(100vw - 24px)); max-height: min(850px, calc(100vh - 30px)); overflow: auto; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--fg); box-shadow: 0 18px 70px #000a; } +.greatest-dialog::backdrop { background: #000b; } +.greatest-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; } +.greatest-dialog-body { display: grid; grid-template-columns: minmax(130px, 230px) minmax(0, 1fr); gap: 22px; } +.greatest-dialog-body h2 { margin: 0 0 8px; font-size: 25px; } +.greatest-dialog-body p { margin: 9px 0; } +.greatest-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 17px 0; } +@media (max-width: 680px) { + .greatest-toolbar { align-items: stretch; flex-direction: column; } + .greatest-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 12px; } + .greatest-dialog-body { grid-template-columns: 1fr; } + .greatest-dialog-body > .greatest-cover { width: min(160px, 42vw); } +} diff --git a/public/greatest.html b/public/greatest.html new file mode 100644 index 0000000..5162595 --- /dev/null +++ b/public/greatest.html @@ -0,0 +1,17 @@ + + + + + +Shijō Saikō no Manga · MangaCircus + + + + + + + + +

Loading the collection…

+ + diff --git a/public/greatest.js b/public/greatest.js new file mode 100644 index 0000000..240f5cd --- /dev/null +++ b/public/greatest.js @@ -0,0 +1,146 @@ +'use strict'; +/* Curated catalog UI. Rating is the only write; the library and scraper are untouched. */ +(async function () { + let me, data; + try { [me, data] = await Promise.all([api('api/me'), api('api/greatest')]); } catch (error) { return showError(error); } + topNav(me); + const main = $('main'); main.textContent = ''; + let sortMode = store.get('greatest-sort') === 'all' ? 'all' : 'mine'; + let filterMode = 'all'; + const query = h('input', { type: 'search', placeholder: 'Search the collection…', 'aria-label': 'Search Greatest of All Time' }); + const count = h('span', { class: 'muted small', 'aria-live': 'polite' }); + const mine = h('button', { type: 'button', class: 'btn small' }, 'Rank by my vote'); + const everyone = h('button', { type: 'button', class: 'btn small' }, 'Rank by everyone'); + const filterBar = h('div', { class: 'greatest-filters', 'aria-label': 'Filter by your rating' }); + const filters = [ + ['all', 'All'], ['rated', 'Rated'], ['flame', '🔥 Flame'], ['good', '👍 Looks good'], + ['neutral', '● Neutral'], ['skip', '💀 Not for me'], ['unrated', 'Unrated'], ['owned', '📚 In library'], + ]; + const filterButtons = new Map(); + for (const [value, label] of filters) { + const button = h('button', { type: 'button', class: 'filter-chip' }, label); + button.onclick = () => { filterMode = value; render(); }; + filterButtons.set(value, button); filterBar.append(button); + } + const results = h('div', { class: 'greatest-periods' }); + const dialog = h('dialog', { class: 'greatest-dialog', 'aria-label': 'Manga details' }); + document.body.append(dialog); + mine.onclick = () => { sortMode = 'mine'; store.set('greatest-sort', sortMode); render(); }; + everyone.onclick = () => { sortMode = 'all'; store.set('greatest-sort', sortMode); render(); }; + query.oninput = render; + main.append( + h('div', { class: 'greatest-intro' }, + h('p', { class: 'greatest-eyebrow' }, 'Greatest of All Time'), + h('h1', {}, data.title || 'Shijō Saikō no Manga'), + h('p', { class: 'muted' }, '史上最高の漫画 · A curated journey through manga history. Your votes are private to your account; the community ranking combines everyone’s votes.'), + h('p', { class: 'muted small' }, '🔥 Definitely want · 👍 Looks good · ● Neutral · 💀 Not for me · 📚 Already in your library')), + h('div', { class: 'greatest-toolbar' }, query, h('div', { class: 'greatest-sort' }, mine, everyone)), + filterBar, count, results); + + function ratingButtons(item, compact = false) { + const bar = h('div', { class: 'greatest-votes' + (compact ? ' compact' : ''), 'aria-label': `Rate ${item.title}` }); + for (const [value, symbol, label] of [ + ['flame', '🔥', 'Definitely want'], ['good', '👍', 'Looks good'], + ['neutral', '●', 'Neutral'], ['skip', '💀', 'Not for me'], + ]) { + const selected = item.myRating === value; + const button = h('button', { type: 'button', class: 'greatest-vote' + (selected ? ' selected' : ''), + 'aria-label': `${label}: ${item.title}`, 'aria-pressed': String(selected), title: label }, symbol); + button.onclick = async event => { + event.stopPropagation(); button.disabled = true; + try { + const next = selected ? null : value; + const saved = await api('api/greatest/rating', { id: item.id, rating: next }); + Object.assign(item, saved); + render(); + if (dialog.open) openDetails(item); + } catch (error) { alert(error.message || String(error)); button.disabled = false; } + }; + bar.append(button); + } + return bar; + } + + function cover(item) { + const box = h('span', { class: 'greatest-cover' }, h('span', { class: 'greatest-cover-fallback', 'aria-hidden': 'true' }, item.title.slice(0, 1))); + if (item.cover && safeHref(item.cover)) { + const image = h('img', { src: item.cover, alt: '', loading: 'lazy' }); + image.onerror = () => image.remove(); + box.append(image); + } + return box; + } + + function tile(item) { + const open = h('button', { type: 'button', class: 'greatest-open', 'aria-label': `Details for ${item.title}` }, + cover(item), + item.library ? h('span', { class: 'greatest-owned', title: 'Already in your library' }, '📚 In library') : null, + h('span', { class: 'greatest-name' }, item.title), + h('span', { class: 'greatest-meta' }, `${item.year} · ${item.chapters == null ? 'Chapters unknown' : `${item.chapters} chapters`}`), + h('span', { class: 'greatest-meta' }, statusLabel(item.status))); + open.onclick = () => openDetails(item); + return h('article', { class: 'greatest-tile' }, open, ratingButtons(item, true)); + } + + function statusLabel(status) { + return status === 'ongoing' ? 'Ongoing' : status === 'completed' ? 'Completed' : 'Status unknown'; + } + + function openDetails(item) { + dialog.textContent = ''; + const close = h('button', { type: 'button', class: 'btn small', onclick: () => dialog.close() }, 'Close'); + const links = h('div', { class: 'greatest-links' }); + if (item.library) links.append(h('a', { class: 'btn primary', href: mangaUrl(item.library.id) }, 'Open in library')); + if (safeHref(item.source?.url)) links.append(h('a', { class: 'btn', href: item.source.url, target: '_blank', rel: 'noopener noreferrer' }, + `Official ${item.source.kind || 'source'} ↗`)); + if (safeHref(item.metadata?.url)) links.append(h('a', { class: 'btn', href: item.metadata.url, target: '_blank', rel: 'noopener noreferrer' }, 'AniList metadata ↗')); + const votes = item.votes || {}; + dialog.append( + h('div', { class: 'greatest-dialog-head' }, h('span', { class: 'muted small' }, `${item.period} · curator #${item.rank}`), close), + h('div', { class: 'greatest-dialog-body' }, cover(item), h('div', {}, + h('h2', {}, item.title), + h('p', { class: 'muted' }, `${item.year} · ${item.chapters == null ? 'Chapter count unknown' : `${item.chapters} chapters`} · ${statusLabel(item.status)}`), + h('p', {}, String(item.note || '').replace(/\*/g, '')), + h('p', { class: 'muted small' }, `Community votes: 🔥 ${votes.flame || 0} · 👍 ${votes.good || 0} · ● ${votes.neutral || 0} · 💀 ${votes.skip || 0}`), + ratingButtons(item), links, + h('p', { class: 'muted small' }, 'Chapter counts and status come from public catalog metadata, or from your local library when present. Availability may vary by region.')))); + if (!dialog.open) dialog.showModal(); + } + + function matches(item, q) { + if (q && !`${item.title} ${item.note || ''}`.toLowerCase().includes(q)) return false; + if (filterMode === 'rated') return !!item.myRating; + if (filterMode === 'unrated') return !item.myRating; + if (filterMode === 'owned') return !!item.library; + if (['flame', 'good', 'neutral', 'skip'].includes(filterMode)) return item.myRating === filterMode; + return true; + } + + function render() { + mine.classList.toggle('on', sortMode === 'mine'); everyone.classList.toggle('on', sortMode === 'all'); + mine.setAttribute('aria-pressed', String(sortMode === 'mine')); + everyone.setAttribute('aria-pressed', String(sortMode === 'all')); + for (const [value, button] of filterButtons) { + button.classList.toggle('on', filterMode === value); + button.setAttribute('aria-pressed', String(filterMode === value)); + } + results.textContent = ''; + const q = query.value.trim().toLowerCase(); + let visible = 0; + for (const period of data.periods) { + const items = data.entries.filter(item => item.period === period && matches(item, q)); + if (!items.length) continue; + items.sort((a, b) => sortMode === 'all' + ? b.score - a.score || a.rank - b.rank + : (ratingScore(b.myRating) - ratingScore(a.myRating)) || a.rank - b.rank); + visible += items.length; + results.append(h('section', { class: 'greatest-period' }, + h('div', { class: 'greatest-period-heading' }, h('h2', {}, period), h('span', { class: 'muted small' }, `${items.length} manga`)), + h('div', { class: 'greatest-grid' }, items.map(tile)))); + } + count.textContent = `${visible} of ${data.entries.length} manga · ${sortMode === 'all' ? 'community score' : 'your vote'} within each period`; + if (!visible) results.append(h('p', { class: 'empty muted' }, 'No manga match these filters.')); + } + function ratingScore(value) { return { flame: 3, good: 1, neutral: 0, skip: -2 }[value] ?? -3; } + render(); +})(); diff --git a/server.js b/server.js index e40c5fd..f14070b 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,8 @@ const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto'); const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library'); const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./catalog'); +const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned, + countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest'); const { secret } = require('./runtime/config'); const { applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary, @@ -40,8 +42,10 @@ const MIME = { // ---------- storage: one JSON file, written atomically ---------- fs.mkdirSync(DATA, { recursive: true }); const catalog = new Catalog(DATA); +const greatestCatalog = loadGreatestCatalog(); +const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry])); const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json'); -const db = { users: {}, sessions: {}, progress: {}, reading: {}, follows: {}, notes: {}, votes: {}, known: {} }; +const db = { users: {}, sessions: {}, progress: {}, reading: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, known: {} }; // A corrupt db.json stops the server instead of silently starting empty and overwriting it. if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8'))); function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); } @@ -364,6 +368,36 @@ const routes = { }; }, + 'GET /api/greatest': ({ u }) => ({ + title: greatestCatalog.title, updatedAt: greatestCatalog.updatedAt, periods: greatestCatalog.periods, + ratings: GREATEST_RATINGS, + entries: greatestCatalog.entries.map(entry => { + const owned = findGreatestOwned(entry, lib.mangas); + const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users); + return { + ...entry, + cover: owned?.cover ? url(owned.cover) : entry.cover, + chapters: owned ? owned.chapters.length : entry.chapters, + status: owned?.meta?.status || entry.status, + library: owned ? { id: owned.id, chapters: owned.chapters.length } : null, + myRating: db.greatestRatings?.[u.name]?.[entry.id] || null, + votes: counts, score: greatestScore(counts), + }; + }), + }), + 'POST /api/greatest/rating': ({ u, b }) => { + const id = String(b.id || ''); + if (!greatestById.has(id)) bad(404, 'Catalog entry not found'); + const rating = b.rating === null || b.rating === '' ? null : String(b.rating || ''); + if (rating !== null && !GREATEST_RATINGS.includes(rating)) bad(400, 'Unknown rating'); + const all = (db.greatestRatings = db.greatestRatings || {}); + const mine = (all[u.name] = all[u.name] || {}); + if (rating === null) delete mine[id]; else mine[id] = rating; + save(); + const votes = greatestCounts(id, all, db.users); + return { id, myRating: rating, votes, score: greatestScore(votes) }; + }, + 'GET /api/manga': ({ u, q }) => { const m = mangaOf(q.get('m')), prog = progressOf(u, m), showStory = settingsOf(u).readingStory; const records = mangaReadingOf(u, m); @@ -545,6 +579,7 @@ const routes = { const name = String(b.name || ''); if (name === u.name) bad(400, "You can't delete your own account."); delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; + if (db.greatestRatings) delete db.greatestRatings[name]; for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k]; save(); return { ok: true }; diff --git a/test/greatest.test.js b/test/greatest.test.js new file mode 100644 index 0000000..d9faa4a --- /dev/null +++ b/test/greatest.test.js @@ -0,0 +1,30 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { loadCatalog, findOwned, countsFor, scoreFor } = require('../greatest'); + +test('curated manga catalog has unique identities and ordered five-year periods', () => { + const catalog = loadCatalog(); + assert.equal(catalog.periods.length, 13); + assert.equal(catalog.entries.length, 106); + for (const [index, period] of catalog.periods.entries()) { + const entries = catalog.entries.filter(entry => entry.period === period); + assert.ok(entries.length >= 5 && entries.length <= 10); + assert.deepEqual(entries.map(entry => entry.rank), entries.map((_, rank) => rank + 1)); + assert.ok(entries.every(entry => entry.periodOrder === index)); + } + assert.equal(catalog.entries.find(entry => entry.title === 'Phoenix').year, 1954); + assert.equal(catalog.entries.find(entry => entry.title === 'JoJo\'s Bizarre Adventure').year, 1987); +}); + +test('library matching and vote aggregation stay independent of scraper data', () => { + const catalog = loadCatalog(); + const berserk = catalog.entries.find(entry => entry.title === 'Berserk'); + const manga = { id: 'local-Berserk', title: 'Berserk', meta: {} }; + assert.equal(findOwned(berserk, [manga]), manga); + assert.equal(findOwned(berserk, [{ id: 'different', title: 'Berserker', meta: {} }]), null); + const votes = { ada: { [berserk.id]: 'flame' }, bob: { [berserk.id]: 'good' }, gone: { [berserk.id]: 'skip' } }; + const counts = countsFor(berserk.id, votes, { ada: {}, bob: {} }); + assert.deepEqual(counts, { flame: 1, good: 1, neutral: 0, skip: 0 }); + assert.equal(scoreFor(counts), 4); +}); diff --git a/test/services.test.js b/test/services.test.js index 3181146..23d40e6 100644 --- a/test/services.test.js +++ b/test/services.test.js @@ -47,6 +47,32 @@ test('three services integrate collections, auth, custom covers and private heal let inventory; for (let n = 0; n < 50; n++) { inventory = await (await request('/api/library')).json(); if (inventory.mangas.length === 2) break; await delay(100); } assert.equal(inventory.mangas.length, 2); + assert.equal((await fetch(base + '/api/greatest')).status, 401); + const greatest = await (await request('/api/greatest')).json(); + assert.equal(greatest.entries.length, 106); + const firstPick = greatest.entries[0]; + assert.equal(firstPick.myRating, null); + assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: 'flame' })).status, 200); + assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: 'invalid' })).status, 400); + assert.equal((await request('/api/greatest/rating', { id: 'missing', rating: 'good' })).status, 404); + const afterRating = (await (await request('/api/greatest')).json()).entries[0]; + assert.equal(afterRating.myRating, 'flame'); + assert.equal(afterRating.votes.flame, 1); + assert.equal(afterRating.score, 3); + assert.equal((await request('/api/admin/user', { name: 'another', password: 'local-test-password' })).status, 200); + const anotherLogin = await fetch(base + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'another', password: 'local-test-password' }) }); + assert.equal(anotherLogin.status, 200); + const anotherCookie = anotherLogin.headers.get('set-cookie').split(';')[0]; + const anotherRequest = (endpoint, body) => fetch(base + endpoint, { headers: { cookie: anotherCookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) }); + assert.equal((await (await anotherRequest('/api/greatest')).json()).entries[0].myRating, null); + assert.equal((await anotherRequest('/api/greatest/rating', { id: firstPick.id, rating: 'good' })).status, 200); + const communityPick = (await (await request('/api/greatest')).json()).entries[0]; + assert.equal(communityPick.myRating, 'flame'); + assert.deepEqual(communityPick.votes, { flame: 1, good: 1, neutral: 0, skip: 0 }); + assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: null })).status, 200); + assert.equal((await (await request('/api/greatest')).json()).entries[0].votes.flame, 0); + assert.equal((await request('/api/admin/user/delete', { name: 'another' })).status, 200); + assert.equal((await (await request('/api/greatest')).json()).entries[0].votes.good, 0); const ids = inventory.mangas.map(m => m.id); const catalog = { revision: 0, collections: [{ id: 'demo', title: 'Demo collection', members: ids.map((mangaId, index) => ({ mangaId, readingOrder: index + 1, role: index ? 'sequel' : 'main' })), coverMode: 'mosaic' }], relations: [{ from: ids[0], to: ids[1], type: 'sequel', state: 'confirmed' }] }; let response = await request('/api/admin/catalog', catalog); assert.equal(response.status, 200); const saved = await response.json(); diff --git a/tools/enrich-greatest-catalog.js b/tools/enrich-greatest-catalog.js new file mode 100644 index 0000000..b24655e --- /dev/null +++ b/tools/enrich-greatest-catalog.js @@ -0,0 +1,70 @@ +'use strict'; +// Optional, manually run metadata pass. The reader never contacts AniList at runtime. +// This intentionally only enriches an exact first-serialization-year match; a sequel, +// remake, or similarly named work is worse than a missing cover. +const fs = require('fs'); +const path = require('path'); +const { normalize } = require('../greatest'); + +const file = path.join(__dirname, '..', 'greatest', 'catalog.json'); +const catalog = JSON.parse(fs.readFileSync(file, 'utf8')); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const query = `query ($search: String) { + Page(perPage: 10) { + media(search: $search, type: MANGA, format: MANGA, sort: POPULARITY_DESC) { + id title { english romaji native } startDate { year } chapters status siteUrl + coverImage { large } + } + } +}`; +const aliases = { + 'kitaro-gegege-no-kitaro-1960': 'Hakaba Kitarou', + 'the-legend-of-kamui-kamui-den-1964': 'Kamui Den', + 'the-poem-of-wind-and-trees-1976': 'Kaze to Ki no Uta', + 'the-heart-of-thomas-1974': 'Thomas no Shinzou', + 'gto-great-teacher-onizuka-1997': 'Great Teacher Onizuka', + 'someone-hertz-2025': 'Samawan Hertz', + 'home-at-the-horizon-2025': 'Okaeri Suiheisen', + 'war-of-the-adults-2025': 'Otona Taisen', + 'frieren-beyond-journey-s-end-2020': 'Sousou no Frieren', +}; + +async function search(title) { + for (let attempt = 0; attempt < 3; attempt++) { + const response = await fetch('https://graphql.anilist.co', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables: { search: title } }), signal: AbortSignal.timeout(12000), + }); + if (response.status === 429 || response.status >= 500) { await wait(10000 * (attempt + 1)); continue; } + const body = await response.json(); + return body.data?.Page?.media || []; + } + return []; +} + +(async () => { + let matched = 0; + for (const [index, entry] of catalog.entries.entries()) { + const title = aliases[entry.id] || entry.title.split(' / ')[0].split(': ')[0]; + try { + const results = await search(title); + const target = normalize(title); + const match = results.find(item => item.startDate?.year === entry.year && + [item.title?.english, item.title?.romaji, item.title?.native].some(name => normalize(name) === target)) || + results.find(item => item.startDate?.year === entry.year && + [item.title?.english, item.title?.romaji].some(name => normalize(name).includes(target) || target.includes(normalize(name)))); + if (match) { + entry.cover = /^https:\/\//.test(match.coverImage?.large || '') ? match.coverImage.large : null; + entry.chapters = Number.isInteger(match.chapters) && match.chapters > 0 ? match.chapters : null; + entry.status = match.status === 'RELEASING' ? 'ongoing' : match.status === 'FINISHED' ? 'completed' : 'unknown'; + entry.metadata = { provider: 'AniList', id: match.id, url: match.siteUrl }; + matched++; + } else process.stderr.write(`No exact year/title match: ${entry.title} (${entry.year})\n`); + } catch (error) { process.stderr.write(`Metadata unavailable: ${entry.title}: ${error.message}\n`); } + if ((index + 1) % 10 === 0) process.stderr.write(`Checked ${index + 1}/${catalog.entries.length}; matched ${matched}\n`); + await wait(1500); + } + fs.writeFileSync(file + '.tmp', JSON.stringify(catalog, null, 2) + '\n'); + fs.renameSync(file + '.tmp', file); + process.stderr.write(`Saved ${matched}/${catalog.entries.length} exact matches\n`); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tools/test-greatest-ui.js b/tools/test-greatest-ui.js new file mode 100644 index 0000000..59a6498 --- /dev/null +++ b/tools/test-greatest-ui.js @@ -0,0 +1,68 @@ +'use strict'; +// Disposable local reader + browser; no production accounts or scraper traffic. +const assert = require('node:assert/strict'); +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), net = require('node:net'); +const { spawn } = require('node:child_process'); +const { chromium } = require('playwright'); +const root = path.resolve(__dirname, '..'); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function freePort() { + const server = net.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-ui-')); + const library = path.join(dir, 'library'), data = path.join(dir, 'data'); + fs.mkdirSync(library); fs.mkdirSync(data); + const port = await freePort(), base = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, ['server.js'], { cwd: root, windowsHide: true, + env: { ...process.env, PORT: String(port), LIBRARY: library, DATA: data, SCAN_INTERVAL: '0', + FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:1', COOKIE_SECURE: '0' }, stdio: 'ignore' }); + let browser; + try { + for (let i = 0; i < 80; i++) { + if (await fetch(base + '/health').then(r => r.ok).catch(() => false)) break; + await delay(100); + } + assert.equal((await fetch(base + '/health')).status, 200); + const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'curator', password: 'local-test-password' }) }); + assert.equal(signup.status, 200); + const cookie = signup.headers.get('set-cookie').split(';')[0]; + browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const [name, value] = cookie.split('='); + await context.addCookies([{ name, value, url: base }]); + await context.route(/^https:\/\//, route => route.abort()); // Never load remote cover art in fixture. + const page = await context.newPage(), errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(base + '/greatest.html'); + await page.getByRole('heading', { name: 'Shijō Saikō no Manga' }).waitFor(); + assert.equal(await page.locator('.greatest-period').count(), 13); + assert.equal(await page.locator('.greatest-tile').count(), 106); + const astro = page.locator('.greatest-tile').filter({ hasText: 'Astro Boy' }); + await astro.getByRole('button', { name: 'Definitely want: Astro Boy' }).click(); + await page.getByRole('button', { name: '🔥 Flame' }).click(); + assert.equal(await page.locator('.greatest-tile').count(), 1); + await page.getByRole('button', { name: 'All', exact: true }).click(); + await page.getByRole('button', { name: 'Rank by everyone' }).click(); + await page.getByRole('button', { name: 'Details for Astro Boy' }).click(); + await page.getByRole('dialog').getByText('Community votes: 🔥 1').waitFor(); + await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click(); + await page.setViewportSize({ width: 390, height: 844 }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Mobile horizontal overflow'); + await page.getByRole('button', { name: '📚 In library' }).click(); + assert.equal(await page.locator('.greatest-tile').count(), 0); + assert.deepEqual(errors, []); + await context.close(); + console.log('Greatest of All Time browser QA passed: periods, ratings, filters, modal, mobile layout.'); + } finally { + if (browser) await browser.close(); + await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }); + fs.rmSync(dir, { recursive: true, force: true }); + } +})().catch(error => { console.error(error); process.exitCode = 1; });