Skip to content

Commit 0ab0ad9

Browse files
committed
feat(route): enhance Bilibili articles
Refs #22799
1 parent 1d8bac7 commit 0ab0ad9

6 files changed

Lines changed: 725 additions & 49 deletions

File tree

lib/routes/bilibili/article.ts

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
1-
import { load } from 'cheerio';
2-
1+
import { config } from '@/config';
2+
import ConfigNotFoundError from '@/errors/types/config-not-found';
33
import type { Route } from '@/types';
4+
import { buildBilibiliArticleFeedItem, parseBilibiliArticlePage } from '@/utils/bilibili-article';
5+
import { parseBilibiliOpusArticle } from '@/utils/bilibili-opus';
46
import cacheGeneral from '@/utils/cache';
57
import got from '@/utils/got';
68
import { parseDate } from '@/utils/parse-date';
79

810
import cache from './cache';
911

1012
export const route: Route = {
11-
path: '/user/article/:uid',
13+
path: '/user/article/:uid/:loginUid?',
1214
categories: ['social-media'],
1315
example: '/bilibili/user/article/334958638',
14-
parameters: { uid: '用户 id, 可在 UP 主主页中找到' },
16+
parameters: {
17+
uid: 'UP 主用户 id,可在其主页中找到',
18+
loginUid: '可选,用于选择 BILIBILI_COOKIE_{loginUid} 对应的登录用户',
19+
},
1520
features: {
16-
requireConfig: false,
21+
requireConfig: [
22+
{
23+
name: 'BILIBILI_COOKIE_*',
24+
optional: true,
25+
description: '配置 BILIBILI_COOKIE_{loginUid} 后,可在路由末尾传入 loginUid,使用该登录用户已有的访问权限读取其已解锁的图文正文。',
26+
},
27+
],
1728
requirePuppeteer: false,
1829
antiCrawler: false,
1930
supportBT: false,
@@ -23,65 +34,107 @@ export const route: Route = {
2334
radar: [
2435
{
2536
source: ['space.bilibili.com/:uid'],
37+
target: '/user/article/:uid',
2638
},
2739
],
2840
name: 'UP 主图文',
2941
maintainers: ['lengthmin', 'Qixingchen', 'hyoban'],
3042
handler,
43+
description: '逐篇抓取图文正文。配置 BILIBILI\\_COOKIE\\_{loginUid} 后,可通过 loginUid 参数选择登录用户,并使用该账号已有的访问权限读取其已解锁的充电专属内容。未配置时仅返回公开内容或预览。',
3144
};
3245

3346
async function handler(ctx) {
3447
const uid = ctx.req.param('uid');
35-
const name = await cache.getUsernameFromUID(uid);
48+
const loginUid = ctx.req.param('loginUid');
49+
const cookie = loginUid ? config.bilibili.cookies[loginUid] : cache.getConfiguredCookie();
50+
51+
if (loginUid && !cookie) {
52+
throw new ConfigNotFoundError(`Missing BILIBILI_COOKIE_${loginUid}`);
53+
}
54+
55+
const referer = `https://space.bilibili.com/${uid}/article`;
56+
const headers = {
57+
Referer: referer,
58+
'User-Agent': config.ua,
59+
...(cookie && { Cookie: cookie }),
60+
};
3661
const response = await got({
3762
method: 'get',
3863
url: `https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/feed/space?host_mid=${uid}`,
39-
headers: {
40-
Referer: `https://space.bilibili.com/${uid}/article`,
41-
},
64+
headers,
4265
});
66+
67+
if (response.data.code !== 0 || !Array.isArray(response.data.data?.items)) {
68+
throw new Error(`Failed to fetch Bilibili articles: ${response.data.message || `API code ${response.data.code}`}`);
69+
}
70+
4371
const data = response.data.data;
44-
const title = `${name} 的 bilibili 图文`;
4572
const link = `https://space.bilibili.com/${uid}/article`;
46-
const description = `${name} 的 bilibili 图文`;
47-
const cookie = await cache.getCookie();
73+
const cacheContext = loginUid || 'default';
4874

49-
const item = await Promise.all(
75+
const resolvedItems = await Promise.all(
5076
data.items.map(async (item) => {
51-
const link = 'https:' + item.jump_url;
52-
const data = await cacheGeneral.tryGet(
53-
link,
54-
async () =>
55-
(
56-
await got({
57-
method: 'get',
58-
url: link,
59-
headers: {
60-
Referer: `https://space.bilibili.com/${uid}/article`,
61-
Cookie: cookie,
62-
},
63-
})
64-
).data
65-
);
66-
67-
const $ = load(data as string);
68-
const description = $('.opus-module-content').html();
69-
const pubDate = $('.opus-module-author__pub__text').text().replace('编辑于 ', '');
70-
71-
const single = {
72-
title: item.content,
77+
const link = item.jump_url.startsWith('//') ? `https:${item.jump_url}` : item.jump_url;
78+
let article = {};
79+
80+
try {
81+
article = await cacheGeneral.tryGet(`bilibili:article:v4:${item.opus_id}:${cacheContext}`, async () => {
82+
const detail = await got({
83+
method: 'get',
84+
url: `https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/detail?id=${encodeURIComponent(item.opus_id)}`,
85+
headers,
86+
});
87+
const opusArticle = parseBilibiliOpusArticle(detail.data);
88+
if (opusArticle.description) {
89+
return opusArticle;
90+
}
91+
92+
const page = await got({
93+
method: 'get',
94+
url: link,
95+
headers,
96+
});
97+
const pageArticle = parseBilibiliArticlePage(page.data as string);
98+
if (!pageArticle.description) {
99+
throw new Error(`Bilibili article body is unavailable: ${link}`);
100+
}
101+
return pageArticle;
102+
});
103+
} catch {
104+
// An authenticated title-only result must not be cached or
105+
// published. Anonymous routes retain their preview fallback.
106+
}
107+
108+
const feedItem = buildBilibiliArticleFeedItem({
109+
article,
110+
fallbackTitle: item.content,
73111
link,
74-
description: description || item.content,
75-
// 2019年11月11日 08:50
76-
pubDate: pubDate ? parseDate(pubDate, 'YYYY年MM月DD日 HH:mm') : undefined,
112+
authenticated: Boolean(cookie),
113+
});
114+
115+
if (!feedItem) {
116+
return;
117+
}
118+
119+
return {
120+
...feedItem,
121+
pubDate: feedItem.pubDate ? parseDate(feedItem.pubDate, typeof feedItem.pubDate === 'number' ? 'X' : 'YYYY年MM月DD日 HH:mm') : undefined,
77122
};
78-
return single;
79123
})
80124
);
125+
const item = resolvedItems.filter((item) => item !== undefined);
126+
127+
if (cookie && item.length === 0) {
128+
throw new Error('No authenticated Bilibili article bodies were returned. Verify that the configured BILIBILI_COOKIE_* account can access these articles.');
129+
}
130+
131+
const name = item.find((article) => article.author)?.author || uid;
132+
const title = `${name} 的 bilibili 图文`;
133+
81134
return {
82135
title,
83136
link,
84-
description,
137+
description: title,
85138
item,
86139
};
87140
}

lib/routes/bilibili/dynamic.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { config } from '@/config';
44
import CaptchaError from '@/errors/types/captcha';
55
import type { Route } from '@/types';
66
import { ViewType } from '@/types';
7+
import { extractBilibiliOpusImages, extractBilibiliOpusImagesFromHtml, normalizeBilibiliImageUrl, renderBilibiliImages } from '@/utils/bilibili-opus';
78
import cache from '@/utils/cache';
89
import got from '@/utils/got';
910
import { parseDuration } from '@/utils/helpers';
11+
import logger from '@/utils/logger';
1012
import { parseDate } from '@/utils/parse-date';
1113
import { fallback, queryToBoolean } from '@/utils/readable-social';
1214

13-
import type { BilibiliWebDynamicResponse, Item2, Modules } from './api-interface';
15+
import type { BilibiliWebDynamicResponse, Item2, Modules, Orig } from './api-interface';
1416
import cacheIn from './cache';
1517
import utils, { getLiveUrl, getVideoUrl } from './utils';
1618

@@ -46,7 +48,7 @@ export const route: Route = {
4648
},
4749
],
4850
requirePuppeteer: false,
49-
antiCrawler: false,
51+
antiCrawler: true,
5052
supportBT: false,
5153
supportPodcast: false,
5254
supportScihub: false,
@@ -178,10 +180,55 @@ const getImgs = (data?: Modules) => {
178180
url: major[type]?.cover,
179181
});
180182
}
181-
return imgUrls
182-
.filter(Boolean)
183-
.map((img) => `<img src="${img.url}" ${img.width ? `width="${img.width}"` : ''} ${img.height ? `height="${img.height}"` : ''}>`)
184-
.join('');
183+
return renderBilibiliImages(imgUrls);
184+
};
185+
186+
const getItemImages = async (item: Item2 | Orig | undefined, cookie: string) => {
187+
const images = getImgs(item?.modules);
188+
const majorType = item?.modules.module_dynamic?.major?.type;
189+
const isOpus = majorType === 'MAJOR_TYPE_OPUS' || item?.type === 'DYNAMIC_TYPE_DRAW' || item?.type === 'DYNAMIC_TYPE_ARTICLE';
190+
const hasInlineOpusImages = Boolean(item?.modules.module_dynamic?.major?.opus?.pics?.length);
191+
192+
if (!item?.id_str || !isOpus || hasInlineOpusImages) {
193+
return images;
194+
}
195+
196+
const pageUrl = `https://www.bilibili.com/opus/${item.id_str}`;
197+
const headers = {
198+
Referer: pageUrl,
199+
'User-Agent': config.ua,
200+
...(cookie && { Cookie: cookie }),
201+
};
202+
203+
try {
204+
const detail = await cache.tryGet(`bilibili:dynamic:opus:v2:${item.id_str}`, async () => {
205+
try {
206+
const response = await got({
207+
method: 'get',
208+
url: `https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/detail?id=${encodeURIComponent(item.id_str)}`,
209+
headers,
210+
});
211+
if (response.data?.code === 0) {
212+
const images = extractBilibiliOpusImages(response.data);
213+
if (images.length) {
214+
return { images };
215+
}
216+
}
217+
} catch {
218+
// Fall back to the public Opus page below.
219+
}
220+
221+
const page = await got({ method: 'get', url: pageUrl, headers });
222+
return {
223+
images: extractBilibiliOpusImagesFromHtml(page.data as string),
224+
};
225+
});
226+
227+
return renderBilibiliImages(detail.images) || images;
228+
} catch (error) {
229+
logger.warn(`[bilibili/dynamic] Failed to fetch Opus images for ${item.id_str}: ${error instanceof Error ? error.message : String(error)}`);
230+
return images;
231+
}
185232
};
186233

187234
const getUrl = (item?: Item2, useAvid = false) => {
@@ -347,7 +394,7 @@ async function handler(ctx) {
347394
description = description.replaceAll(
348395
emoji.text,
349396
() =>
350-
`<img alt="${emoji.text}" src="${emoji.icon_url}" style="margin: -1px 1px 0px; display: inline-block; width: 20px; height: 20px; vertical-align: text-bottom;" title="" referrerpolicy="no-referrer">`
397+
`<img alt="${emoji.text}" src="${normalizeBilibiliImageUrl(emoji.icon_url)}" style="margin: -1px 1px 0px; display: inline-block; width: 20px; height: 20px; vertical-align: text-bottom;" title="">`
351398
);
352399
}
353400
// 处理转发带图评论的情况
@@ -357,7 +404,7 @@ async function handler(ctx) {
357404
pics
358405
.map(
359406
(pic) =>
360-
`<img alt="${text}" src="${pic.src}" style="margin: 0px 0px 0px; display: inline-block; width: ${pic.width}px; height: ${pic.height}px; vertical-align: text-bottom;" title="" referrerpolicy="no-referrer">`
407+
`<img alt="${text}" src="${normalizeBilibiliImageUrl(pic.src)}" style="margin: 0px 0px 0px; display: inline-block; width: ${pic.width}px; height: ${pic.height}px; vertical-align: text-bottom;" title="">`
361408
)
362409
.join('<br>')
363410
);
@@ -417,10 +464,12 @@ async function handler(ctx) {
417464
originDescription += `<br>${originDes}`;
418465
}
419466

467+
const [itemImages, originImages] = await Promise.all([getItemImages(item, cookie), getItemImages(item.orig, cookie)]);
468+
420469
// 换行处理
421470
description = description.replaceAll('\r\n', '<br>').replaceAll('\n', '<br>');
422471
originDescription = originDescription.replaceAll('\r\n', '<br>').replaceAll('\n', '<br>');
423-
const descriptions = [title, description, getIframe(data, embed), getImgs(data), urlText, originDescription, getIframe(origin, embed), getImgs(origin), originUrlText]
472+
const descriptions = [title, description, getIframe(data, embed), itemImages, urlText, originDescription, getIframe(origin, embed), originImages, originUrlText]
424473
.map((e) => e?.trim())
425474
.filter(Boolean)
426475
.join('<br>');

lib/utils/bilibili-article.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { buildBilibiliArticleFeedItem, parseBilibiliArticlePage } from './bilibili-article';
4+
5+
describe('buildBilibiliArticleFeedItem', () => {
6+
it('does not publish an authenticated title-only fallback', () => {
7+
const item = buildBilibiliArticleFeedItem({
8+
article: {
9+
title: 'Subscriber article',
10+
},
11+
fallbackTitle: 'Subscriber article',
12+
link: 'https://www.bilibili.com/opus/123',
13+
authenticated: true,
14+
});
15+
16+
expect(item).toBeUndefined();
17+
});
18+
19+
it('marks an authenticated full-text upgrade with a distinct stable guid', () => {
20+
const item = buildBilibiliArticleFeedItem({
21+
article: {
22+
title: 'Subscriber article',
23+
description: '<p>Full subscriber body</p>',
24+
},
25+
fallbackTitle: 'Subscriber article',
26+
link: 'https://www.bilibili.com/opus/123',
27+
authenticated: true,
28+
});
29+
30+
expect(item).toMatchObject({
31+
guid: 'https://www.bilibili.com/opus/123#rsshub-authenticated-fulltext-v1',
32+
description: '<p>Full subscriber body</p>',
33+
});
34+
});
35+
36+
it('retains the title preview for anonymous feeds', () => {
37+
const item = buildBilibiliArticleFeedItem({
38+
article: {},
39+
fallbackTitle: 'Public preview',
40+
link: 'https://www.bilibili.com/opus/123',
41+
authenticated: false,
42+
});
43+
44+
expect(item).toMatchObject({
45+
guid: 'https://www.bilibili.com/opus/123',
46+
description: 'Public preview',
47+
});
48+
});
49+
});
50+
51+
describe('parseBilibiliArticlePage', () => {
52+
it('extracts the complete opus body and metadata', () => {
53+
const article = parseBilibiliArticlePage(`
54+
<html>
55+
<head><title>Fallback title - 哔哩哔哩</title></head>
56+
<body>
57+
<div class="opus-module-title__text">Full article title</div>
58+
<div class="opus-module-author__name">Capital_12</div>
59+
<div class="opus-module-author__pub__text">编辑于 2026年07月17日 16:38</div>
60+
<div class="opus-module-content"><h2>Heading</h2><p>Subscriber-visible body</p></div>
61+
<div class="opus-module-paywall">Paywall prompt</div>
62+
</body>
63+
</html>
64+
`);
65+
66+
expect(article).toEqual({
67+
title: 'Full article title',
68+
description: '<h2>Heading</h2><p>Subscriber-visible body</p>',
69+
author: 'Capital_12',
70+
pubDate: '2026年07月17日 16:38',
71+
});
72+
});
73+
74+
it('falls back to the document title when the opus title is unavailable', () => {
75+
const article = parseBilibiliArticlePage('<html><head><title>Fallback title - 哔哩哔哩</title></head><body></body></html>');
76+
77+
expect(article.title).toBe('Fallback title');
78+
expect(article.description).toBeUndefined();
79+
});
80+
81+
it('ignores Bilibili verification pages', () => {
82+
const article = parseBilibiliArticlePage('<html><head><title>验证码_哔哩哔哩</title></head><body></body></html>');
83+
84+
expect(article).toEqual({});
85+
});
86+
});

0 commit comments

Comments
 (0)