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:
1 parent
613db96355
commit
cd6a30a76b
18 files changed
+250
-65
No files matched your search
+19
-9
@@ -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 };
|
||||
Reference in new issue
Block a user