Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
+115
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
// Reader-owned catalog. Membership never changes filesystem or reading IDs.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const TYPES = ['prequel', 'sequel', 'side-story', 'spin-off', 'adaptation', 'alternate-version', 'alternate-timeline', 'compilation', 'remake', 'shared-universe', 'other'];
|
||||
const ROLES = ['main', ...TYPES];
|
||||
const STATES = ['suggested', 'confirmed', 'rejected', 'conflicted'];
|
||||
class CatalogError extends Error { constructor(code, message) { super(message); this.code = code; } }
|
||||
const fail = message => { throw new CatalogError(400, message); };
|
||||
const text = (value, max = 200) => String(value || '').trim().slice(0, max);
|
||||
function normalize(input, mangaIds, prior = {}) {
|
||||
if (!input || !Array.isArray(input.collections) || !Array.isArray(input.relations)) fail('Expected collections and relations arrays');
|
||||
if (input.collections.length > 2000 || input.relations.length > 10000) fail('Catalog is too large');
|
||||
const seen = new Set(), membership = new Set();
|
||||
const collections = input.collections.map(c => {
|
||||
const id = text(c.id || crypto.randomUUID(), 80);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id) || seen.has(id)) fail('Collection IDs must be unique letters, numbers, dash or underscore');
|
||||
seen.add(id);
|
||||
if (!text(c.title)) fail('Collection title is required');
|
||||
if (!Array.isArray(c.members) || !c.members.length) fail('A collection needs members');
|
||||
const local = new Set();
|
||||
const members = c.members.map((member, index) => {
|
||||
const mangaId = text(member.mangaId, 500);
|
||||
// Keep absent members on subsequent edits: an temporarily unavailable mount must not erase them.
|
||||
const wasMember = (prior.collections || []).some(old => old.id === id && old.members.some(m => m.mangaId === mangaId));
|
||||
if (!mangaIds.has(mangaId) && !wasMember) fail(`Unknown manga: ${mangaId}`);
|
||||
if (local.has(mangaId) || membership.has(mangaId)) fail(`Manga already belongs to a collection: ${mangaId}`);
|
||||
local.add(mangaId); membership.add(mangaId);
|
||||
const role = text(member.role || 'main');
|
||||
if (!ROLES.includes(role)) fail('Unknown member role');
|
||||
const readingOrder = Number(member.readingOrder ?? index + 1);
|
||||
if (!Number.isFinite(readingOrder) || readingOrder < 0) fail('Reading order must be a nonnegative number');
|
||||
return { mangaId, role, readingOrder };
|
||||
}).sort((a, b) => a.readingOrder - b.readingOrder);
|
||||
const coverMode = c.coverMode || 'selected';
|
||||
if (!['selected', 'mosaic', 'custom'].includes(coverMode)) fail('Unknown cover mode');
|
||||
const coverMangaId = text(c.coverMangaId || members[0].mangaId, 500);
|
||||
if (!local.has(coverMangaId)) fail('Selected cover must belong to this collection');
|
||||
const customCover = text(c.customCover, 100);
|
||||
if (customCover && !/^[a-f0-9]{64}\.(jpg|png|webp|gif|avif|bmp)$/.test(customCover)) fail('Invalid custom cover');
|
||||
return { id, title: text(c.title), description: text(c.description, 5000), members, coverMode, coverMangaId,
|
||||
...(customCover ? { customCover } : {}), grouped: c.grouped !== false };
|
||||
});
|
||||
const relationKeys = new Set();
|
||||
const relations = input.relations.map(r => {
|
||||
const from = text(r.from, 500), to = text(r.to, 500), type = text(r.type), state = r.state || 'suggested';
|
||||
if (from === to || !from || !to || !TYPES.includes(type) || !STATES.includes(state)) fail('Invalid relation');
|
||||
for (const id of [from, to]) if (!mangaIds.has(id) && !(prior.relations || []).some(old => old.from === id || old.to === id)) fail(`Unknown manga: ${id}`);
|
||||
const key = JSON.stringify([from, to, type]);
|
||||
if (relationKeys.has(key)) fail('Duplicate relationship');
|
||||
relationKeys.add(key);
|
||||
const evidence = (Array.isArray(r.evidence) ? r.evidence : []).slice(0, 12).map(e => {
|
||||
const url = text(e.url, 1000);
|
||||
if (url && !/^https?:\/\/[^\s]+$/i.test(url)) fail('Evidence must be an HTTP URL');
|
||||
return { provider: text(e.provider), url, note: text(e.note, 1500) };
|
||||
});
|
||||
return { from, to, type, state, evidence, explanation: text(r.explanation, 3000), method: text(r.method || 'manual'),
|
||||
confidence: Math.max(0, Math.min(1, Number(r.confidence) || 0)) };
|
||||
});
|
||||
return { version: 1, collections, relations };
|
||||
}
|
||||
|
||||
class Catalog {
|
||||
constructor(dataRoot) {
|
||||
this.file = path.join(dataRoot, 'catalog.json');
|
||||
this.state = { version: 1, revision: 0, collections: [], relations: [], audit: [] };
|
||||
if (fs.existsSync(this.file)) {
|
||||
const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
if (saved.version !== 1 || !Array.isArray(saved.collections) || !Array.isArray(saved.relations)) throw new Error('Unsupported catalog file');
|
||||
this.state = saved;
|
||||
}
|
||||
}
|
||||
update(input, mangaIds, actor) {
|
||||
if (input.revision !== this.state.revision) throw new CatalogError(409, 'Catalog changed; reload before saving');
|
||||
const value = normalize(input, mangaIds, this.state);
|
||||
const next = { ...value, revision: this.state.revision + 1,
|
||||
audit: [...(this.state.audit || []), { at: new Date().toISOString(), actor, revision: this.state.revision + 1 }].slice(-200) };
|
||||
fs.writeFileSync(this.file + '.tmp', JSON.stringify(next, null, 2) + '\n');
|
||||
fs.renameSync(this.file + '.tmp', this.file);
|
||||
this.state = next;
|
||||
return next;
|
||||
}
|
||||
suggest(mangas) {
|
||||
const byAniList = new Map(mangas.filter(m => m.meta?.['x-anilist']?.id).map(m => [String(m.meta['x-anilist'].id), m]));
|
||||
const relations = [...this.state.relations];
|
||||
const mapping = { PREQUEL: 'prequel', SEQUEL: 'sequel', SIDE_STORY: 'side-story', SPIN_OFF: 'spin-off', ADAPTATION: 'adaptation', ALTERNATIVE: 'alternate-version', COMPILATION: 'compilation', OTHER: 'other', SOURCE: 'adaptation', PARENT: 'other' };
|
||||
for (const m of mangas) for (const r of m.meta?.['x-anilist']?.relations || []) {
|
||||
const target = byAniList.get(String(r.id)), type = mapping[r.type] || 'other';
|
||||
if (!target || target.id === m.id || relations.some(e => e.from === m.id && e.to === target.id && e.type === type)) continue;
|
||||
relations.push({ from: m.id, to: target.id, type, state: 'suggested', method: 'anilist', confidence: 1,
|
||||
explanation: `AniList identifies the target as ${r.type}. Review before grouping.`,
|
||||
evidence: [{ provider: 'AniList', url: m.meta['x-anilist'].url || `https://anilist.co/manga/${m.meta['x-anilist'].id}`, note: r.type }] });
|
||||
}
|
||||
return relations;
|
||||
}
|
||||
}
|
||||
|
||||
function collectionViews(catalog, mangas) {
|
||||
const byId = new Map(mangas.map(m => [m.id, m]));
|
||||
return catalog.collections.map(c => {
|
||||
const members = c.members.filter(member => byId.has(member.mangaId)).map(member => ({ ...byId.get(member.mangaId), role: member.role, readingOrder: member.readingOrder }));
|
||||
const chosen = byId.get(c.coverMangaId) || members[0];
|
||||
const sum = key => members.reduce((n, m) => n + (+m[key] || 0), 0);
|
||||
const dates = members.map(m => m.firstReleaseAt).filter(Boolean);
|
||||
return { ...c, members, cover: c.coverMode === 'custom' && c.customCover ? `api/collection-cover?id=${encodeURIComponent(c.id)}&v=${c.customCover}` : chosen?.cover,
|
||||
covers: members.map(m => m.cover).filter(Boolean).slice(0, 4), chapters: sum('chapters'), read: sum('read'), unread: sum('unread'),
|
||||
tags: [...new Set(members.flatMap(m => m.tags || []))], authors: [...new Set(members.flatMap(m => m.authors || []))],
|
||||
altTitles: members.flatMap(m => [m.title, ...(m.altTitles || [])]),
|
||||
status: members.some(m => m.status === 'ongoing') ? 'ongoing' : members.every(m => m.status === 'completed') ? 'completed' : 'mixed',
|
||||
updated: Math.max(0, ...members.map(m => +m.updated || 0)), lastRead: Math.max(0, ...members.map(m => +m.lastRead || 0)),
|
||||
firstReleaseAt: dates.length ? Math.min(...dates) : 0, firstRelease: dates.length ? String(new Date(Math.min(...dates)).getUTCFullYear()) : '',
|
||||
reading: { activeMs: members.reduce((n, m) => n + (m.reading?.activeMs || 0), 0), updatedAt: Math.max(0, ...members.map(m => m.reading?.updatedAt || 0)) } };
|
||||
}).filter(c => c.members.length);
|
||||
}
|
||||
module.exports = { Catalog, CatalogError, collectionViews, normalize, TYPES, ROLES };
|
||||
Reference in new issue
Block a user