- 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>
66 lines
2.7 KiB
JavaScript
66 lines
2.7 KiB
JavaScript
'use strict';
|
|
|
|
// Anchors are the titles a user has shown taste for; their tags steer the wider AniList pool.
|
|
const ANCHOR_WEIGHTS = { flame: 5, good: 3, interested: 4, maybe: 2, read: 3, owned: 1 };
|
|
const MAX_ANCHORS = 15;
|
|
const MIN_TAG_RANK = 60;
|
|
const MAX_TOP_TAGS = 3;
|
|
|
|
function anchorsFor({ user, db, library, greatest }) {
|
|
if (!user?.settings?.discoveryEnabled) return [];
|
|
const byId = new Map();
|
|
const add = (anilistId, source, title) => {
|
|
if (!Number.isSafeInteger(anilistId) || anilistId <= 0) return;
|
|
const weight = ANCHOR_WEIGHTS[source];
|
|
const prior = byId.get(anilistId);
|
|
if (!prior || weight > prior.weight) byId.set(anilistId, { anilistId, weight, source, title: title ?? null });
|
|
};
|
|
|
|
const ratings = db?.greatestRatings?.[user.name] || {};
|
|
for (const entry of greatest?.entries || []) {
|
|
const vote = ratings[entry.id];
|
|
if (vote === 'flame' || vote === 'good') add(Number(entry.metadata?.id), vote, entry.title);
|
|
}
|
|
|
|
const feedback = db?.discoveryFeedback?.[user.name] || {};
|
|
for (const [key, value] of Object.entries(feedback)) {
|
|
const match = /^anilist:(\d+)$/.exec(key);
|
|
if (match && (value === 'interested' || value === 'maybe')) add(Number(match[1]), value, null);
|
|
}
|
|
|
|
const progress = db?.progress?.[user.name] || {};
|
|
const reading = Boolean(user.settings.discoveryReadingActivity);
|
|
for (const manga of library || []) {
|
|
const id = Number(manga.meta?.['x-anilist']?.id);
|
|
if (!id) continue;
|
|
const chapters = progress[manga.id];
|
|
const done = reading && chapters && Object.values(chapters).some(chapter => chapter?.done);
|
|
add(id, done ? 'read' : 'owned', manga.title);
|
|
}
|
|
|
|
return [...byId.values()]
|
|
.sort((a, b) => b.weight - a.weight || a.anilistId - b.anilistId)
|
|
.slice(0, MAX_ANCHORS);
|
|
}
|
|
|
|
function topTagsFor(anchors, recordOf, { mature = false } = {}) {
|
|
const scores = new Map();
|
|
for (const anchor of anchors) {
|
|
const record = recordOf(anchor.anilistId);
|
|
for (const tag of Array.isArray(record?.tags) ? record.tags : []) {
|
|
// A tag without a rank is no evidence of taste, so it must not pass the rank filter.
|
|
if (!tag || typeof tag !== 'object' || typeof tag.name !== 'string') continue;
|
|
if (!(tag.rank >= MIN_TAG_RANK) || (tag.isAdult && !mature)) continue;
|
|
const entry = scores.get(tag.name) || { name: tag.name, kind: tag.kind, score: 0 };
|
|
entry.score += anchor.weight;
|
|
scores.set(tag.name, entry);
|
|
}
|
|
}
|
|
return [...scores.values()]
|
|
.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
.slice(0, MAX_TOP_TAGS)
|
|
.map(({ name, kind }) => ({ name, kind }));
|
|
}
|
|
|
|
module.exports = { ANCHOR_WEIGHTS, MAX_ANCHORS, anchorsFor, topTagsFor };
|