232 lines
6.2 KiB
TypeScript
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);
|
|
}
|