mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-25 10:14:31 +08:00
feat(route/bilibili): fold browser mode into video route (#21913)
* feat(route/bilibili): add video-browser route using Puppeteer for video list extraction Replace broken real-browser API dependency with getPuppeteerPage + browserless to render Bilibili space pages and extract video data via DOM parsing. Includes username extraction from page title and cover image detection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(route/bilibili): fix video title extraction picking stats over actual titles The candidate selection logic was preferring longer text (e.g. "8.3万23907:45") over real titles (e.g. "爆肝三天,我总结了 GPT Image 2 全网最实用的玩法") because stats strings passed the hasTitle check. Now prefer candidates that contain Chinese characters and don't start with digits, falling back to longest hasTitle candidate. Fixes 11/35 UIDs returning empty results. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(route/bilibili): add /user/video-browser route using Puppeteer + XHR interception Close #20406 Bilibili upgraded anti-crawler (WBI signing) on /x/space/wbi/arc/search in Nov 2025, causing existing /user/video/:uid to return 412. This new route bypasses it by intercepting the browser's own API response via Puppeteer, extracting structured JSON data (title, bvid, pic, created timestamp, author, play/comment counts) instead of fragile DOM scraping. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(route/bilibili): fold browser mode into video route --------- Co-authored-by: gqy20 <qingyu_ge@foxmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
gqy20
parent
220b6c42a1
commit
d0c6e6a87a
+164
-35
@@ -6,6 +6,7 @@ import { ViewType } from '@/types';
|
||||
import got from '@/utils/got';
|
||||
import { parseDuration } from '@/utils/helpers';
|
||||
import logger from '@/utils/logger';
|
||||
import { getPlaywrightPage, type Page } from '@/utils/playwright';
|
||||
|
||||
import cache from './cache';
|
||||
import utils, { getVideoUrl } from './utils';
|
||||
@@ -35,11 +36,59 @@ export const route: Route = {
|
||||
handler,
|
||||
};
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
const isJsonFeed = ctx.req.query('format') === 'json';
|
||||
interface VideoItem {
|
||||
aid: number;
|
||||
author?: string;
|
||||
bvid?: string;
|
||||
comment?: number;
|
||||
created: number;
|
||||
description: string;
|
||||
length: string;
|
||||
pic: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const uid = ctx.req.param('uid');
|
||||
const embed = !ctx.req.param('embed');
|
||||
interface VideoListData {
|
||||
list?: {
|
||||
vlist?: VideoItem[];
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoListResponse {
|
||||
code?: number;
|
||||
data?: VideoListData;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const videoListApiPath = '/x/space/wbi/arc/search';
|
||||
const allowedBrowserRequestTypes = new Set(['document', 'script', 'xhr', 'fetch', 'image', 'font', 'stylesheet', 'other']);
|
||||
|
||||
async function applyCookie(page: Page, cookie: string) {
|
||||
const cookies = cookie
|
||||
.split(';')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const equalIndex = item.indexOf('=');
|
||||
if (equalIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.slice(0, equalIndex).trim(),
|
||||
value: item.slice(equalIndex + 1).trim(),
|
||||
domain: '.bilibili.com',
|
||||
path: '/',
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== undefined);
|
||||
|
||||
if (cookies.length > 0) {
|
||||
await page.setCookie(...cookies);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVideoListFromApi(uid: string): Promise<VideoListData> {
|
||||
const cookie = await cache.getCookie();
|
||||
const wbiVerifyString = await cache.getWbiVerifyString();
|
||||
const dmImgList = utils.getDmImgList();
|
||||
@@ -50,7 +99,7 @@ async function handler(ctx: Context) {
|
||||
utils.addRenderData(utils.addDmVerifyInfoWithInter(`mid=${uid}&ps=30&tid=0&pn=1&keyword=&order=pubdate&platform=web&web_location=1550101&order_avoided=true`, dmImgList, dmImgInter), renderData),
|
||||
wbiVerifyString
|
||||
);
|
||||
const response = await got(`https://api.bilibili.com/x/space/wbi/arc/search?${params}`, {
|
||||
const response = await got(`https://api.bilibili.com${videoListApiPath}?${params}`, {
|
||||
headers: {
|
||||
Referer: `https://space.bilibili.com/${uid}`,
|
||||
origin: 'https://space.bilibili.com',
|
||||
@@ -63,9 +112,93 @@ async function handler(ctx: Context) {
|
||||
throw new Error(`Got error code ${data.code} while fetching: ${data.message}`);
|
||||
}
|
||||
|
||||
const usernameAndFace = await cache.getUsernameAndFaceFromUID(uid);
|
||||
const name = usernameAndFace[0] || data.data.list.vlist[0]?.author;
|
||||
const face = usernameAndFace[1];
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function fetchVideoListFromBrowser(uid: string): Promise<VideoListData> {
|
||||
const cookie = await cache.getCookie();
|
||||
if (!cookie) {
|
||||
throw new Error('Bilibili cookie is required to fetch video list in browser mode');
|
||||
}
|
||||
|
||||
const url = `https://space.bilibili.com/${uid}/video`;
|
||||
logger.info(`[bilibili/video] fetching via playwright: ${url}`);
|
||||
|
||||
let videoListResponsePromise: ReturnType<Page['waitForResponse']> | undefined;
|
||||
const { destroy } = await getPlaywrightPage(url, {
|
||||
onBeforeLoad: async (page) => {
|
||||
await applyCookie(page, cookie);
|
||||
|
||||
videoListResponsePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const request = response.request();
|
||||
return response.url().includes(videoListApiPath) && request.method() === 'GET' && (request.resourceType() === 'xhr' || request.resourceType() === 'fetch');
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
allowedBrowserRequestTypes.has(request.resourceType()) ? request.continue() : request.abort();
|
||||
});
|
||||
},
|
||||
gotoConfig: { waitUntil: 'domcontentloaded' },
|
||||
});
|
||||
|
||||
try {
|
||||
if (!videoListResponsePromise) {
|
||||
throw new Error('Failed to initialize Bilibili video list response watcher');
|
||||
}
|
||||
|
||||
const response = await videoListResponsePromise;
|
||||
const contentType = response.headers()['content-type'];
|
||||
if (!contentType?.includes('application/json')) {
|
||||
throw new Error(`Bilibili browser mode returned non-JSON response with status ${response.status()}; BILIBILI_COOKIE_* may be required`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as VideoListResponse;
|
||||
if (data.code) {
|
||||
logger.error(JSON.stringify(data.data));
|
||||
throw new Error(`Got error code ${data.code} while fetching in browser mode: ${data.message}`);
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
throw new Error('Bilibili browser response does not contain video list data');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} finally {
|
||||
await destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function getVideoList(uid: string): Promise<VideoListData> {
|
||||
try {
|
||||
return await fetchVideoListFromApi(uid);
|
||||
} catch (error) {
|
||||
logger.warn(`[bilibili/video] API request failed, falling back to browser mode: ${error}`);
|
||||
return fetchVideoListFromBrowser(uid);
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(ctx: Context) {
|
||||
const isJsonFeed = ctx.req.query('format') === 'json';
|
||||
|
||||
const uid = ctx.req.param('uid');
|
||||
const embed = !ctx.req.param('embed');
|
||||
const data = await getVideoList(uid);
|
||||
const videos = data.list?.vlist ?? [];
|
||||
|
||||
let name = videos[0]?.author || uid;
|
||||
let face: string | undefined;
|
||||
|
||||
try {
|
||||
const usernameAndFace = await cache.getUsernameAndFaceFromUID(uid);
|
||||
name = usernameAndFace[0] || name;
|
||||
face = usernameAndFace[1];
|
||||
} catch (error) {
|
||||
logger.warn(`[bilibili/video] failed to fetch user profile: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${name} 的 bilibili 空间`,
|
||||
@@ -74,32 +207,28 @@ async function handler(ctx: Context) {
|
||||
image: face ?? undefined,
|
||||
logo: face ?? undefined,
|
||||
icon: face ?? undefined,
|
||||
item:
|
||||
data.data &&
|
||||
data.data.list &&
|
||||
data.data.list.vlist &&
|
||||
(await Promise.all(
|
||||
data.data.list.vlist.map(async (item) => {
|
||||
const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
|
||||
return {
|
||||
title: item.title,
|
||||
description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
|
||||
pubDate: new Date(item.created * 1000).toUTCString(),
|
||||
link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
|
||||
author: name,
|
||||
comments: item.comment,
|
||||
attachments: item.bvid
|
||||
? [
|
||||
{
|
||||
url: getVideoUrl(item.bvid),
|
||||
mime_type: 'text/html',
|
||||
duration_in_seconds: parseDuration(item.length),
|
||||
},
|
||||
...subtitles,
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
)),
|
||||
item: await Promise.all(
|
||||
videos.map(async (item) => {
|
||||
const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
|
||||
return {
|
||||
title: item.title,
|
||||
description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
|
||||
pubDate: new Date(item.created * 1000).toUTCString(),
|
||||
link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
|
||||
author: name,
|
||||
comments: item.comment,
|
||||
attachments: item.bvid
|
||||
? [
|
||||
{
|
||||
url: getVideoUrl(item.bvid),
|
||||
mime_type: 'text/html',
|
||||
duration_in_seconds: parseDuration(item.length),
|
||||
},
|
||||
...subtitles,
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user