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:
DIYgod
2026-05-05 18:07:51 +08:00
committed by GitHub
co-authored by Claude Opus 4.7 gqy20
parent 220b6c42a1
commit d0c6e6a87a
+164 -35
View File
@@ -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,
};
})
),
};
}