'use strict'; // Pure scoring helpers for the AniList seed parts and the feed slot mix (BL-0051). const { topTagsFor } = require('./anchors'); const MAX_FEED = 20; const SLOTS = { perKind: 8, perPeriod: 4, fresh: 4, outside: 4 }; const CREATOR_MIN_WEIGHT = 3; const TAG_TOP_MIN_SCORE = 75; const FRESH_YEARS = 2; // Part name -> group (seed kind) for the four AniList seed parts. const SEED_PART_GROUP = { communityRec: 'recs', sameCreator: 'creator', tagTop: 'tagTop', fresh: 'fresh' }; const SEED_GROUPS = new Set(Object.values(SEED_PART_GROUP)); // Tie-break when two parts are equal: seed parts first, then Phase A order. const PART_ORDER = ['communityRec', 'sameCreator', 'tagTop', 'fresh', 'interest', 'editorial', 'sharedTaste', 'relation', 'era', 'reading', 'sharedLibrary']; 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) { const safe = Math.max(0, Number(rating) || 0); return Math.min(5, 1 + Math.log2(1 + safe) / 2) * weight / 5; } const sameCreatorValue = weight => 4 * weight / 5; // What the harvest cache says about this user's anchors: one lookup map per seed kind. function seedIndexFor({ anchors, harvestCache, mature = false }) { const index = { recs: new Map(), creators: new Map(), tagTop: new Map(), fresh: new Map(), topTags: [] }; if (!harvestCache) return index; const recordOf = id => harvestCache.get('title', id)?.data || null; const nameOf = anchor => anchor.title || recordOf(anchor.anilistId)?.title || `AniList ${anchor.anilistId}`; for (const anchor of anchors) { const name = nameOf(anchor); const entry = harvestCache.get('recs', anchor.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 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, fetchedAt: harvestCache.get('creator', author.staffId)?.fetchedAt || null }); } } index.topTags = topTagsFor(anchors, recordOf, { mature }); index.topTags.forEach((tag, place) => { const entry = harvestCache.get('tagTop', `${tag.kind}:${tag.name}`); 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 objectsOf(fresh?.data)) { index.fresh.set(`anilist:${Number(item.anilistId)}`, { record: item.record || null, fetchedAt: fresh.fetchedAt }); } return index; } // Seed parts for one candidate with the reason and evidence each one brings. function seedNotes(candidate, seeds, thisYear) { const notes = []; const urlFor = record => record?.siteUrl && /^https:\/\/[^\s]+$/i.test(String(record.siteUrl)) ? String(record.siteUrl) : candidate.metadataUrl || null; let bestRec = null; for (const hit of seeds.recs.get(candidate.id) || []) { const value = communityRecValue(hit.rating, hit.anchor.weight); if (!bestRec || value > bestRec.value) bestRec = { ...hit, value }; } if (bestRec) notes.push({ part: 'communityRec', value: round2(bestRec.value), text: `AniList readers who liked ${bestRec.name} recommend this (${bestRec.rating}).`, evidence: { id: `rec:${bestRec.anchor.anilistId}:${candidate.id}`, kind: 'recommendation', label: `AniList recommendation from readers of ${bestRec.name}`, note: `Rating ${bestRec.rating}`, url: urlFor(bestRec.record), fetchedAt: bestRec.fetchedAt } }); let bestCreator = null; 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 }; } if (bestCreator) { const author = bestCreator.candidateAuthor.name || bestCreator.author.name; const verb = bestCreator.anchor.source === 'read' ? 'read' : 'liked'; notes.push({ part: 'sameCreator', value: round2(sameCreatorValue(bestCreator.anchor.weight)), text: `Also by ${author} — you ${verb} ${bestCreator.name}.`, evidence: { id: `creator:${bestCreator.author.staffId}:${candidate.id}`, kind: 'creator', label: `Also by ${author}`, note: `Shared creator with ${bestCreator.name}`, url: urlFor(null), fetchedAt: bestCreator.fetchedAt || candidate.fetchedAt || null } }); } const top = seeds.tagTop.get(candidate.id); const averageScore = Number(candidate.averageScore ?? top?.record?.averageScore); if (top && averageScore >= TAG_TOP_MIN_SCORE) notes.push({ part: 'tagTop', value: top.value, text: `Top-rated in ${top.tag.name}, one of your most-liked tags.`, evidence: { id: `tagtop:${top.tag.kind}:${top.tag.name}:${candidate.id}`, kind: 'tag-top', label: `Top-rated in ${top.tag.name} on AniList`, note: `Average score ${averageScore}`, url: urlFor(top.record), fetchedAt: top.fetchedAt } }); const fresh = seeds.fresh.get(candidate.id); if (fresh) { const status = fresh.record?.status ?? candidate.status; const year = Number(fresh.record?.year ?? candidate.year); const isNew = status === 'ongoing' && year >= thisYear - FRESH_YEARS; notes.push({ part: 'fresh', value: isNew ? 2 : 1, // The exact template claims a start year and "ongoing", which only the value-2 case proves. text: isNew ? `New: started ${year}, ongoing and trending.` : 'Trending on AniList right now.', evidence: { id: `fresh:${candidate.id}`, kind: 'fresh', label: 'Trending new manga on AniList', note: '', url: urlFor(fresh.record), fetchedAt: fresh.fetchedAt } }); } return notes; } function largestPart(scoreParts) { let best = null; for (const [part, value] of Object.entries(scoreParts)) { if (!best || value > best.value || (value === best.value && partRank(part) < partRank(best.part))) best = { part, value }; } return best; } function groupFor(scoreParts, period) { const top = largestPart(scoreParts); return top && top.value > 0 && SEED_PART_GROUP[top.part] ? SEED_PART_GROUP[top.part] : period || 'Related works'; } const byScore = (a, b) => b.score - a.score || (a.candidateId < b.candidateId ? -1 : a.candidateId > b.candidateId ? 1 : 0); // Reserve slots for fresh and outside-the-top-tags rows, then fill greedily; every step respects the group caps. function mixSlots(rows, { topTags = [], max = MAX_FEED } = {}) { const sorted = [...rows].sort(byScore); const picked = new Set(), counts = new Map(); const capOf = group => SEED_GROUPS.has(group) ? SLOTS.perKind : SLOTS.perPeriod; const take = row => { if (picked.has(row) || picked.size >= max || (counts.get(row.group) || 0) >= capOf(row.group)) return false; picked.add(row); counts.set(row.group, (counts.get(row.group) || 0) + 1); return true; }; const reserve = (limit, wanted) => { let taken = 0; for (const row of sorted) if (taken < limit && wanted(row) && take(row)) taken++; }; const top = new Set(topTags.map(tag => String(tag.name).toLowerCase())); reserve(SLOTS.fresh, row => row.scoreParts?.fresh > 0); reserve(SLOTS.outside, row => !listOf(row.tags).some(tag => top.has(String(tag).toLowerCase()))); for (const row of sorted) take(row); return [...picked].sort(byScore).map((row, index) => ({ ...row, rank: index + 1 })); } module.exports = { MAX_FEED, SLOTS, PART_ORDER, round2, communityRecValue, sameCreatorValue, seedIndexFor, seedNotes, largestPart, groupFor, partRank, mixSlots };