Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)

- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 09:43:31 +01:00
1 parent 613db96355
commit cd6a30a76b
18 files changed
+250 -65

No files matched your search

+19 -9
View File
@@ -12,6 +12,9 @@ const MATURE_TAG_RANK = 60;
const VALID_FORMATS = new Set(['MANGA', 'ONE_SHOT']);
const safeUrl = value => /^https:\/\/[^\s]+$/i.test(String(value || '')) ? String(value) : null;
const unique = values => [...new Set(values.filter(Boolean))];
// manga.json and cached metadata are hand-editable files: a field that is not a list counts as empty.
const listOf = value => Array.isArray(value) ? value : [];
const stringsOf = value => unique(listOf(value).filter(item => typeof item === 'string'));
function cachedMetadata(dir, id) {
if (!Number.isSafeInteger(Number(id)) || Number(id) < 1) return null;
@@ -29,7 +32,7 @@ function candidateFromGreatest(entry, cacheDir) {
type: cached?.['x-anilist']?.format || 'MANGA', status: cached?.status || entry.status || 'unknown',
cover: safeUrl(cached?.['x-anilist']?.coverUrl) || safeUrl(entry.cover),
metadataUrl: safeUrl(cached?.['x-anilist']?.url) || safeUrl(entry.metadata?.url) || null,
tags: unique(cached?.tags || []), authors: unique(cached?.authors || []),
tags: stringsOf(cached?.tags), authors: stringsOf(cached?.authors),
period: entry.period, editorialRank: entry.rank, greatestId: entry.id,
evidence: [{ id: `greatest:${entry.id}`, kind: 'curation', label: 'Greatest of All Time selection',
note: String(entry.note || '').slice(0, 600), url: safeUrl(entry.source?.url) || safeUrl(entry.metadata?.url) }],
@@ -37,7 +40,8 @@ function candidateFromGreatest(entry, cacheDir) {
};
}
function addRelations(map, anchor, cacheDir) {
for (const relation of anchor.relations || []) {
for (const relation of listOf(anchor.relations)) {
if (!relation || typeof relation !== 'object') continue;
if (!VALID_FORMATS.has(relation.format) || !Number.isSafeInteger(Number(relation.id))) continue;
const id = `anilist:${Number(relation.id)}`;
if (map.has(id)) continue;
@@ -47,11 +51,11 @@ function addRelations(map, anchor, cacheDir) {
year: cached?.year || null, type: relation.format, status: cached?.status || 'unknown',
cover: safeUrl(cached?.['x-anilist']?.coverUrl),
metadataUrl: safeUrl(cached?.['x-anilist']?.url) || safeUrl(relation.url) || `https://anilist.co/manga/${relation.id}`,
tags: unique(cached?.tags || []), authors: unique(cached?.authors || []), period: null,
tags: stringsOf(cached?.tags), authors: stringsOf(cached?.authors), period: null,
editorialRank: null, greatestId: null,
evidence: [{ id: `relation:${anchor.id}:${id}`, kind: 'relation', label: `${relation.type || 'RELATED'} to ${anchor.title}`,
note: '', url: safeUrl(relation.url) || `https://anilist.co/manga/${relation.id}` }],
relatedTo: { id: anchor.id, title: anchor.title, type: relation.type || 'RELATED' },
relatedTo: { id: anchor.id, title: anchor.title, type: String(relation.type || 'RELATED') },
relations: cached?.['x-anilist']?.relations || [],
});
}
@@ -139,12 +143,12 @@ function feedForUser({ user, db, candidates, greatest, library, queuedTitles = [
for (const tag of candidate?.tags || []) likedTags.set(tag.toLowerCase(), anchor);
}
const ownedTags = new Map();
for (const manga of library) for (const tag of manga.meta?.tags || []) if (typeof tag === 'string') ownedTags.set(tag.toLowerCase(), manga.title);
for (const manga of library) for (const tag of stringsOf(manga.meta?.tags)) ownedTags.set(tag.toLowerCase(), manga.title);
const readTags = new Map();
if (settings.discoveryReadingActivity) for (const manga of library) {
const progress = db.progress?.[user.name]?.[manga.id] || {};
if (!Object.values(progress).some(chapter => chapter?.done)) continue;
for (const tag of manga.meta?.tags || []) if (typeof tag === 'string') readTags.set(tag.toLowerCase(), manga.title);
for (const tag of stringsOf(manga.meta?.tags)) readTags.set(tag.toLowerCase(), manga.title);
}
const seeds = seedIndexFor({ anchors: anchorsFor({ user, db, library, greatest }), harvestCache, mature: !!settings.discoveryMature });
const rows = [];
@@ -217,8 +221,14 @@ function candidatePool({ greatest, library, cacheDir, harvestCache }) {
}
return { all, candidates };
}
const runResult = (all, candidates, feeds) => ({ candidates: Object.fromEntries(candidates.map(c => [c.id, c])), feeds,
examined: all.length, clipped: Math.max(0, all.length - candidates.length) });
// Only candidates a feed shows are published; the rest of the pool (up to 2000, with AniList
// records) would bloat discovery.json for nothing. examined/clipped still describe the whole pool.
function runResult(all, candidates, feeds) {
const shown = new Set();
for (const feed of Object.values(feeds)) for (const item of feed?.items || []) shown.add(item.candidateId);
return { candidates: Object.fromEntries(candidates.filter(c => shown.has(c.id)).map(c => [c.id, c])), feeds,
examined: all.length, clipped: Math.max(0, all.length - candidates.length) };
}
function generate({ db, library, greatest, cacheDir, harvestCache = null, queuedTitles = [], now }) {
const { all, candidates } = candidatePool({ greatest, library, cacheDir, harvestCache });
const feeds = {};
@@ -254,5 +264,5 @@ function mergeUserFeed(prior, result, userName) {
return { candidates, feeds };
}
module.exports = { generate, generateAsync, buildCandidates, feedForUser, mixSlots, mergeUserFeed, cachedMetadata,
module.exports = { generate, generateAsync, buildCandidates, candidatePool, feedForUser, mixSlots, mergeUserFeed, cachedMetadata,
isMature, MAX_CANDIDATES, MAX_FEED, SLOTS };