113 行
3.5 KiB
TypeScript
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(),
|
|
};
|
|
}
|