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

+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; });