文件
tennis-training-hub/server/tutorialMetrics.ts
2026-04-07 11:00:03 +08:00

113 行
3.5 KiB
TypeScript

import { ENV } from "./_core/env";
export type TutorialMetrics = {
viewCount?: number;
commentCount?: number;
thumbnailUrl?: string;
fetchedAt: Date;
};
export type TutorialMetricSource = {
sourcePlatform?: string | null;
platformVideoId?: string | null;
metricsFetchedAt?: Date | string | null;
viewCount?: number | null;
commentCount?: number | null;
};
const METRIC_CACHE_MS = 12 * 60 * 60 * 1000;
function parseCount(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
export function shouldRefreshTutorialMetrics(source: TutorialMetricSource) {
if (!source.sourcePlatform || !source.platformVideoId) return false;
if (source.sourcePlatform !== "bilibili" && source.sourcePlatform !== "youtube") return false;
const fetchedAt =
source.metricsFetchedAt instanceof Date
? source.metricsFetchedAt
: source.metricsFetchedAt
? new Date(source.metricsFetchedAt)
: null;
if (!fetchedAt || Number.isNaN(fetchedAt.getTime())) return true;
if (source.viewCount == null && source.commentCount == null) return true;
return (Date.now() - fetchedAt.getTime()) > METRIC_CACHE_MS;
}
export async function fetchTutorialMetrics(sourcePlatform: string, platformVideoId: string): Promise<TutorialMetrics | null> {
if (sourcePlatform === "bilibili") {
return fetchBilibiliMetrics(platformVideoId);
}
if (sourcePlatform === "youtube") {
return fetchYouTubeMetrics(platformVideoId);
}
return null;
}
async function fetchBilibiliMetrics(bvid: string): Promise<TutorialMetrics | null> {
const response = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, {
headers: {
"accept": "application/json",
"user-agent": "tennis-training-hub/1.0",
},
});
if (!response.ok) {
throw new Error(`Bilibili metrics request failed with ${response.status}`);
}
const payload = await response.json();
const stat = payload?.data?.stat;
if (!payload?.data || !stat) return null;
return {
viewCount: parseCount(stat.view),
commentCount: parseCount(stat.reply),
thumbnailUrl: typeof payload.data.pic === "string" ? payload.data.pic.replace(/^http:\/\//, "https://") : undefined,
fetchedAt: new Date(),
};
}
async function fetchYouTubeMetrics(videoId: string): Promise<TutorialMetrics | null> {
if (!ENV.youtubeApiKey) return null;
const url = new URL("https://www.googleapis.com/youtube/v3/videos");
url.searchParams.set("part", "statistics,snippet");
url.searchParams.set("id", videoId);
url.searchParams.set("key", ENV.youtubeApiKey);
const response = await fetch(url.toString(), {
headers: {
"accept": "application/json",
"user-agent": "tennis-training-hub/1.0",
},
});
if (!response.ok) {
throw new Error(`YouTube metrics request failed with ${response.status}`);
}
const payload = await response.json();
const item = Array.isArray(payload?.items) ? payload.items[0] : null;
if (!item) return null;
return {
viewCount: parseCount(item.statistics?.viewCount),
commentCount: parseCount(item.statistics?.commentCount),
thumbnailUrl:
item.snippet?.thumbnails?.maxres?.url ||
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url,
fetchedAt: new Date(),
};
}