Files
Genarrative/src/components/platform-entry/platformRecommendation.ts

232 lines
6.2 KiB
TypeScript

import {
buildPlatformPublicGalleryCardKey,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
const MS_PER_DAY = 86_400_000;
const FEATURED_BONUS = 14;
const MAX_FRESHNESS_SCORE = 12;
export type PlatformRecommendationOptions = {
nowMs?: number;
limit?: number;
};
type RecommendationCandidate = {
entry: PlatformPublicGalleryCard;
key: string;
sourceType: string;
firstSeenIndex: number;
isFeatured: boolean;
timestampMs: number;
score: number;
};
type PlatformRecommendationMetricKey =
| 'playCount'
| 'remixCount'
| 'likeCount'
| 'recentPlayCount7d';
function parseRecommendationTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
}
function getRecommendationMetric(
entry: PlatformPublicGalleryCard,
key: PlatformRecommendationMetricKey,
) {
const value = (
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
)[key];
return Math.max(0, Math.round(Number(value ?? 0) || 0));
}
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry) {
if (
entry.sourceType === 'edutainment' &&
'templateId' in entry &&
entry.templateId
) {
return `edutainment:${entry.templateId}`;
}
return entry.sourceType;
}
return 'rpg';
}
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
return 'themeTags' in entry && Array.isArray(entry.themeTags)
? entry.themeTags
: [];
}
function scoreRecommendationCandidate(
candidate: Omit<RecommendationCandidate, 'score'>,
nowMs: number,
) {
const entry = candidate.entry;
const ageDays =
candidate.timestampMs > 0
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
: Number.POSITIVE_INFINITY;
const freshnessScore = Number.isFinite(ageDays)
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
: 0;
const coverScore = entry.coverImageSrc ? 1.5 : 0;
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
return (
(candidate.isFeatured ? FEATURED_BONUS : 0) +
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
freshnessScore +
coverScore +
tagScore +
summaryScore
);
}
function compareRecommendationCandidates(
left: RecommendationCandidate,
right: RecommendationCandidate,
) {
const scoreDiff = right.score - left.score;
if (scoreDiff !== 0) {
return scoreDiff;
}
const timeDiff = right.timestampMs - left.timestampMs;
if (timeDiff !== 0) {
return timeDiff;
}
if (left.firstSeenIndex !== right.firstSeenIndex) {
return left.firstSeenIndex - right.firstSeenIndex;
}
return left.key.localeCompare(right.key, 'zh-CN');
}
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
const remaining = [...candidates];
const result: RecommendationCandidate[] = [];
while (remaining.length > 0) {
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
let nextIndex = 0;
if (lastSourceType) {
const alternativeIndex = remaining.findIndex(
(candidate) => candidate.sourceType !== lastSourceType,
);
if (alternativeIndex > 0) {
nextIndex = alternativeIndex;
}
}
const [nextCandidate] = remaining.splice(nextIndex, 1);
if (nextCandidate) {
result.push(nextCandidate);
}
}
return result;
}
export function buildPlatformRecommendedEntries(
params: {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
},
options: PlatformRecommendationOptions = {},
) {
const candidateMap = new Map<
string,
Omit<RecommendationCandidate, 'score'>
>();
let firstSeenIndex = 0;
const collectEntries = (
entries: PlatformPublicGalleryCard[],
source: 'featured' | 'latest',
) => {
entries.forEach((entry) => {
const key = buildPlatformPublicGalleryCardKey(entry);
const timestampMs = getRecommendationTimestamp(entry);
const existing = candidateMap.get(key);
if (existing) {
existing.isFeatured = existing.isFeatured || source === 'featured';
if (timestampMs >= existing.timestampMs) {
existing.entry = entry;
existing.timestampMs = timestampMs;
}
return;
}
candidateMap.set(key, {
entry,
key,
sourceType: getRecommendationSourceType(entry),
firstSeenIndex,
isFeatured: source === 'featured',
timestampMs,
});
firstSeenIndex += 1;
});
};
collectEntries(params.featuredEntries, 'featured');
collectEntries(params.latestEntries, 'latest');
const nowMs = options.nowMs ?? Date.now();
const rankedCandidates = Array.from(candidateMap.values())
.map((candidate) => ({
...candidate,
score: scoreRecommendationCandidate(candidate, nowMs),
}))
.sort(compareRecommendationCandidates);
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
const limit =
typeof options.limit === 'number' && options.limit > 0
? Math.floor(options.limit)
: diversifiedCandidates.length;
return diversifiedCandidates
.slice(0, limit)
.map((candidate) => candidate.entry);
}