Files
MangaCircus/discovery/scoring.js
T

157 lines
8.2 KiB
JavaScript

'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 : [];
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 listOf(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)) {
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 listOf(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 });
}
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 listOf(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 };