feat: add recommendation feed scoring
This commit is contained in:
231
src/components/platform-entry/platformRecommendation.ts
Normal file
231
src/components/platform-entry/platformRecommendation.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user