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
+11
-9
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user