Discovery anchors and top tags (BL-0051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60a25bacac
commit
41437c5321
2 files changed
+125
No files matched your search
@@ -0,0 +1,63 @@
|
||||
'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 record?.tags || []) {
|
||||
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 };
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { anchorsFor, topTagsFor, ANCHOR_WEIGHTS, MAX_ANCHORS } = require('../discovery/anchors');
|
||||
|
||||
const user = (settings = { discoveryEnabled: true }) => ({ name: 'ann', settings });
|
||||
const manga = (id, anilist, title = `Manga ${id}`) => ({ id, title, meta: anilist ? { 'x-anilist': { id: anilist } } : {} });
|
||||
|
||||
test('anchors are empty without opt-in', () => {
|
||||
const library = [manga('a', 1)];
|
||||
assert.deepEqual(anchorsFor({ user: user({}), db: {}, library, greatest: { entries: [] } }), []);
|
||||
assert.deepEqual(anchorsFor({ user: user({ discoveryEnabled: false }), db: {}, library, greatest: { entries: [] } }), []);
|
||||
});
|
||||
|
||||
test('weights, dedupe and cap', () => {
|
||||
const greatest = { entries: [{ id: 'g1', title: 'Great One', metadata: { id: 7 } }, { id: 'g2', title: 'No Meta' }] };
|
||||
const db = { greatestRatings: { ann: { g1: 'good', g2: 'flame' } } };
|
||||
const anchors = anchorsFor({ user: user(), db, library: [manga('m7', 7, 'Owned Seven')], greatest });
|
||||
assert.deepEqual(anchors, [{ anilistId: 7, weight: 3, source: 'good', title: 'Great One' }]);
|
||||
assert.equal(ANCHOR_WEIGHTS.flame, 5);
|
||||
const many = Array.from({ length: 20 }, (_, i) => manga(`m${i}`, i + 1));
|
||||
const capped = anchorsFor({ user: user(), db: {}, library: many, greatest: { entries: [] } });
|
||||
assert.equal(capped.length, MAX_ANCHORS);
|
||||
assert.equal(capped.length, 15);
|
||||
assert.deepEqual(capped.map(a => a.anilistId), Array.from({ length: 15 }, (_, i) => i + 1));
|
||||
assert.equal(capped[0].source, 'owned');
|
||||
});
|
||||
|
||||
test('reading anchors need the reading opt-in', () => {
|
||||
const library = [manga('m1', 1), manga('m2', 2)];
|
||||
const db = { progress: { ann: { m1: { c1: { done: true } }, m2: { c1: { done: false } } } } };
|
||||
const off = anchorsFor({ user: user(), db, library, greatest: { entries: [] } });
|
||||
assert.deepEqual(off.map(a => [a.anilistId, a.source]), [[1, 'owned'], [2, 'owned']]);
|
||||
const on = anchorsFor({ user: user({ discoveryEnabled: true, discoveryReadingActivity: true }), db, library, greatest: { entries: [] } });
|
||||
assert.deepEqual(on.map(a => [a.anilistId, a.source, a.weight, a.title]), [[1, 'read', 3, 'Manga m1'], [2, 'owned', 1, 'Manga m2']]);
|
||||
});
|
||||
|
||||
test('feedback keys parse anilist ids and ignore others', () => {
|
||||
const db = { discoveryFeedback: { ann: {
|
||||
'anilist:10': 'interested', 'anilist:11': 'maybe', 'anilist:12': 'dismissed', 'anilist:13': 'hidden',
|
||||
'greatest:x': 'interested', 'anilist:abc': 'interested' } } };
|
||||
const anchors = anchorsFor({ user: user(), db, library: [], greatest: { entries: [] } });
|
||||
assert.deepEqual(anchors, [
|
||||
{ anilistId: 10, weight: 4, source: 'interested', title: null },
|
||||
{ anilistId: 11, weight: 2, source: 'maybe', title: null }]);
|
||||
});
|
||||
|
||||
test('top tags weigh anchors, skip low rank and adult tags', () => {
|
||||
const records = {
|
||||
1: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Ecchi', rank: 90, kind: 'tag', isAdult: true },
|
||||
{ name: 'Faint', rank: 59, kind: 'tag', isAdult: false }] },
|
||||
2: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Zeta', rank: 60, kind: 'tag', isAdult: false },
|
||||
{ name: 'Alpha', rank: 80, kind: 'tag', isAdult: false }] }
|
||||
};
|
||||
const anchors = [{ anilistId: 1, weight: 5 }, { anilistId: 2, weight: 3 }, { anilistId: 3, weight: 5 }];
|
||||
const recordOf = id => records[id] || null;
|
||||
assert.deepEqual(topTagsFor(anchors, recordOf), [
|
||||
{ name: 'Action', kind: 'genre' }, { name: 'Alpha', kind: 'tag' }, { name: 'Zeta', kind: 'tag' }]);
|
||||
assert.deepEqual(topTagsFor(anchors, recordOf, { mature: true }), [
|
||||
{ name: 'Action', kind: 'genre' }, { name: 'Ecchi', kind: 'tag' }, { name: 'Alpha', kind: 'tag' }]);
|
||||
});
|
||||
Reference in new issue
Block a user