Add Greatest of All Time manga dashboard

This commit is contained in:
Codex committed 2026-10-09 10:10:34 +02:00
1 parent c8dd46d0fb
commit 5df94db9a3
16 files changed
+2717 -1

No files matched your search

+2
View File
@@ -9,6 +9,8 @@
!reading-analytics.js
!public/
!public/**
!greatest/
!greatest/**
!runtime/
!runtime/**
!sentinel/
+1
View File
@@ -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
+2
View File
@@ -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
+24
View File
@@ -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.
+2202
View File
@@ -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"
}
]
}
+44
View File
@@ -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 };
+6
View File
@@ -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; }
}
+1
View File
@@ -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'),
+42
View File
@@ -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); }
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Shijō Saikō no Manga · MangaCircus</title>
<link rel="stylesheet" href="app.css">
<link rel="stylesheet" href="greatest.css">
<link rel="icon" href="icon.svg">
<meta name="theme-color" content="#121214">
<script src="common.js" defer></script>
<script src="greatest.js" defer></script>
</head>
<body>
<main><p class="loading">Loading the collection…</p></main>
</body>
</html>
+146
View File
@@ -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();
})();
+36 -1
View File
@@ -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 };
+30
View File
@@ -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);
});
+26
View File
@@ -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();
+70
View File
@@ -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; });
+68
View File
@@ -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; });