diff --git a/lib/routes/xhamster/index.ts b/lib/routes/xhamster/index.ts
new file mode 100644
index 000000000000..80ca0ce3a2ac
--- /dev/null
+++ b/lib/routes/xhamster/index.ts
@@ -0,0 +1,169 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:creators',
+ categories: ['multimedia'],
+ example: '/xhamster/faustina-pierre',
+ parameters: {
+ creators: 'Creator slug from the URL (e.g. `faustina-pierre`)',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['xhamster.com/creators/:creators', 'xhamster.com/creators/:creators/newest'],
+ target: '/:creators',
+ },
+ ],
+ name: '最近更新',
+ maintainers: [],
+ handler,
+ url: 'xhamster.com/faustina-pierre/newest',
+};
+
+interface VideoThumb {
+ id: number;
+ title: string;
+ pageURL: string;
+ thumbURL: string;
+ imageURL?: string;
+ trailerURL?: string;
+ trailerFallbackUrl?: string;
+ created?: number;
+ duration?: number;
+ views?: number;
+ isUHD?: boolean;
+}
+
+interface Initials {
+ infoComponent?: {
+ pornstarTop?: {
+ name?: string;
+ };
+ };
+ trendingVideoSectionComponent?: {
+ videoListProps?: {
+ videoThumbProps?: VideoThumb[];
+ };
+ };
+}
+
+function extractInitials(scriptContent: string): Initials {
+ const withoutPrefix = scriptContent.replace(/^\s*window\.initials\s*=\s*/, '').trim();
+ const jsonStr = withoutPrefix.endsWith(';') ? withoutPrefix.slice(0, -1) : withoutPrefix;
+ return JSON.parse(jsonStr);
+}
+
+function formatDuration(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = seconds % 60;
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
+}
+
+function renderDescription(video: VideoThumb & { author?: string }): string {
+ const thumb = video.imageURL ?? video.thumbURL;
+ const duration = video.duration ? formatDuration(video.duration) : '';
+ const views = video.views === undefined ? '' : video.views.toLocaleString();
+ const quality = video.isUHD ? '4K' : '';
+
+ return `
+
+
+
+
+ ${quality} + ${duration ? `Duration: ${duration}` : ''} + ${views ? `|Views: ${views}` : ''} + ${video.author ? `|Author: ${video.author}` : ''} +
+ `.trim(); +} + +const GOT_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://xhamster.com/', +}; + +async function handler(ctx) { + const { creators } = ctx.req.param(); + const pageUrl = `https://xhamster.com/creators/${encodeURIComponent(creators)}/newest`; + + const response = await got(pageUrl, { headers: GOT_HEADERS }); + + const $ = load(response.data); + const initialsRaw = $('#initials-script').html(); + if (!initialsRaw) { + throw new Error('Could not locate initials script on page'); + } + + let initials: Initials; + try { + initials = extractInitials(initialsRaw); + } catch { + throw new Error('Failed to parse page data'); + } + + const creatorName = initials.infoComponent?.pornstarTop?.name ?? creators; + const videos = initials.trendingVideoSectionComponent?.videoListProps?.videoThumbProps ?? []; + + const items = await Promise.all( + videos.map((video) => + cache.tryGet(`xhamster:video:${video.id}`, async () => { + // 尝试获取单个视频页面以提取更完整的信息 + let author = creatorName; + try { + const { data } = await got(video.pageURL, { headers: GOT_HEADERS }); + const $page = load(data); + // 从视频页面提取作者名,xHamster 视频页的上传者通常在此选择器 + const uploaderText = $page('.video-author-wrap a').first().text().trim(); + if (uploaderText) { + author = uploaderText; + } + } catch { + // 页面抓取失败时降级使用列表页数据 + } + + const enriched = { ...video, author }; + + return { + title: `${video.title}${video.isUHD ? ' [4K]' : ''}`, + link: video.pageURL, + pubDate: video.created ? parseDate(video.created * 1000) : undefined, + author, + description: renderDescription(enriched), + media: { + content: { + url: video.trailerURL ?? video.pageURL, + type: 'video/mp4', + ...(video.duration && { duration: video.duration }), + }, + thumbnail: { + url: video.imageURL ?? video.thumbURL, + }, + }, + }; + }) + ) + ); + + return { + title: `${creatorName} – Newest | xHamster`, + link: pageUrl, + description: `Latest videos from ${creatorName} on xHamster`, + item: items, + }; +} diff --git a/lib/routes/xhamster/namespace.ts b/lib/routes/xhamster/namespace.ts new file mode 100644 index 000000000000..48e5450af3b5 --- /dev/null +++ b/lib/routes/xhamster/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'xHamster', + url: 'xhamster.com', + lang: 'en', +};