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

+11 -9
View File
@@ -17,6 +17,8 @@ const PART_ORDER = ['communityRec', 'sameCreator', 'tagTop', 'fresh',
const round2 = value => Math.round(value * 100) / 100;
const listOf = value => Array.isArray(value) ? value : [];
// Cached lists come from disk; anything that is not an object is skipped, never dereferenced.
const objectsOf = value => listOf(value).filter(item => item && typeof item === 'object');
const partRank = part => { const index = PART_ORDER.indexOf(part); return index < 0 ? PART_ORDER.length : index; };
function communityRecValue(rating, weight) {
@@ -34,13 +36,13 @@ function seedIndexFor({ anchors, harvestCache, mature = false }) {
for (const anchor of anchors) {
const name = nameOf(anchor);
const entry = harvestCache.get('recs', anchor.anilistId);
for (const item of listOf(entry?.data)) {
const id = `anilist:${Number(item?.anilistId)}`;
for (const item of objectsOf(entry?.data)) {
const id = `anilist:${Number(item.anilistId)}`;
if (!index.recs.has(id)) index.recs.set(id, []);
index.recs.get(id).push({ anchor, name, rating: Number(item.rating) || 0, record: item.record || null, fetchedAt: entry.fetchedAt });
}
if (anchor.weight < CREATOR_MIN_WEIGHT) continue;
for (const author of listOf(recordOf(anchor.anilistId)?.authors)) {
for (const author of objectsOf(recordOf(anchor.anilistId)?.authors)) {
const prior = index.creators.get(author.staffId);
if (prior && prior.anchor.weight >= anchor.weight) continue;
index.creators.set(author.staffId, { anchor, name, author,
@@ -50,15 +52,15 @@ function seedIndexFor({ anchors, harvestCache, mature = false }) {
index.topTags = topTagsFor(anchors, recordOf, { mature });
index.topTags.forEach((tag, place) => {
const entry = harvestCache.get('tagTop', `${tag.kind}:${tag.name}`);
for (const item of listOf(entry?.data)) {
const id = `anilist:${Number(item?.anilistId)}`;
for (const item of objectsOf(entry?.data)) {
const id = `anilist:${Number(item.anilistId)}`;
// Tags come best-first, so the first hit is the strongest.
if (!index.tagTop.has(id)) index.tagTop.set(id, { tag, value: 3 - place, record: item.record || null, fetchedAt: entry.fetchedAt });
}
});
const fresh = harvestCache.get('fresh', 'global');
for (const item of listOf(fresh?.data)) {
index.fresh.set(`anilist:${Number(item?.anilistId)}`, { record: item.record || null, fetchedAt: fresh.fetchedAt });
for (const item of objectsOf(fresh?.data)) {
index.fresh.set(`anilist:${Number(item.anilistId)}`, { record: item.record || null, fetchedAt: fresh.fetchedAt });
}
return index;
}
@@ -80,8 +82,8 @@ function seedNotes(candidate, seeds, thisYear) {
url: urlFor(bestRec.record), fetchedAt: bestRec.fetchedAt } });
let bestCreator = null;
for (const author of listOf(candidate.authorRecords)) {
const hit = seeds.creators.get(author?.staffId);
for (const author of objectsOf(candidate.authorRecords)) {
const hit = seeds.creators.get(author.staffId);
// An anchor is never its own "also by" suggestion.
if (!hit || `anilist:${hit.anchor.anilistId}` === candidate.id) continue;
if (!bestCreator || hit.anchor.weight > bestCreator.anchor.weight) bestCreator = { ...hit, candidateAuthor: author };