diff --git a/lib/routes/bilibili/video.ts b/lib/routes/bilibili/video.ts index e98af3aa3d..70f7859379 100644 --- a/lib/routes/bilibili/video.ts +++ b/lib/routes/bilibili/video.ts @@ -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 { 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 { + 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 | 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 { + 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, + }; + }) + ), }; }