Skip to content

Commit 97cac27

Browse files
authored
fix(route/baidu): drop playwright (#23179)
1 parent c6cfb4f commit 97cac27

12 files changed

Lines changed: 352 additions & 734 deletions

File tree

lib/routes/baidu/tieba/common.ts

Lines changed: 39 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,7 @@
1-
import { createHash } from 'node:crypto';
2-
3-
import { Cookie } from 'tough-cookie';
4-
51
import { config } from '@/config';
6-
import ConfigNotFoundError from '@/errors/types/config-not-found';
72
import cache from '@/utils/cache';
8-
import got from '@/utils/got';
9-
import { getPlaywrightPage } from '@/utils/playwright';
10-
11-
/**
12-
* 解析百度 cookie 字符串为 Playwright 可用的 cookie 对象数组
13-
* 正确处理包含 '=' 的 cookie 值
14-
*/
15-
export function parseBaiduCookies(cookieStr: string): Array<{ name: string; value: string; domain: string; path: string }> {
16-
return cookieStr
17-
.split(';')
18-
.map((c) => Cookie.parse(c.trim()))
19-
.filter((c): c is Cookie => Boolean(c?.key))
20-
.map((c) => ({
21-
name: c.key,
22-
value: c.value,
23-
domain: '.tieba.baidu.com',
24-
path: '/',
25-
}));
26-
}
27-
28-
/**
29-
* 检查 HTML 内容是否包含百度安全验证页面
30-
*/
31-
export function checkSecurityVerification(html: string): void {
32-
if (html.includes('安全验证') || html.includes('百度安全验证')) {
33-
throw new Error('Baidu security verification required. The cookie may be expired or invalid. Please update your BAIDU_COOKIE.');
34-
}
35-
}
36-
37-
/**
38-
* 使用 Playwright 获取贴吧页面内容
39-
* 包含统一的 cookie 设置、安全验证检查和缓存逻辑
40-
* 带有重试机制处理瞬态错误
41-
*/
42-
export async function getTiebaPageContent(
43-
url: string,
44-
cacheKey: string,
45-
options: {
46-
waitForSelector?: string;
47-
timeout?: number;
48-
retries?: number;
49-
} = {}
50-
): Promise<string> {
51-
const cookie = config.baidu.cookie;
52-
53-
if (!cookie) {
54-
throw new ConfigNotFoundError('Baidu Tieba RSS is disabled due to the lack of <a href="https://docs.rsshub.app/deploy/config#baidu">BAIDU_COOKIE</a>');
55-
}
56-
57-
const cookies = parseBaiduCookies(cookie);
58-
const { waitForSelector = '.thread-card-wrapper, .virtual-list-item, .thread-content-box, .thread-card', timeout = 3000, retries = 3 } = options;
59-
60-
const data = await cache.tryGet(
61-
cacheKey,
62-
async () => {
63-
let lastError: Error | undefined;
64-
65-
/* eslint-disable no-await-in-loop -- Intentional sequential retry logic */
66-
for (let attempt = 0; attempt < retries; attempt++) {
67-
const { page, destroy } = await getPlaywrightPage(url, {
68-
onBeforeLoad: async (page) => {
69-
if (cookies.length > 0) {
70-
await page.context().addCookies(cookies);
71-
}
72-
},
73-
gotoConfig: { waitUntil: 'domcontentloaded' },
74-
});
75-
76-
try {
77-
// 等待页面稳定
78-
await new Promise((resolve) => setTimeout(resolve, 2000));
79-
80-
// 动态等待内容加载
81-
try {
82-
await page.waitForSelector(waitForSelector, { timeout });
83-
} catch {
84-
// 如果超时,继续执行
85-
}
86-
87-
const html = await page.content();
88-
checkSecurityVerification(html);
89-
return html;
90-
} catch (error) {
91-
lastError = error as Error;
92-
// 如果是最后一次尝试,抛出错误
93-
if (attempt === retries - 1) {
94-
throw lastError;
95-
}
96-
// 等待后重试
97-
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
98-
} finally {
99-
await destroy();
100-
}
101-
}
102-
/* eslint-enable no-await-in-loop */
103-
throw lastError || new Error('Failed to fetch page content');
104-
},
105-
config.cache.routeExpire,
106-
false
107-
);
108-
109-
return data;
110-
}
111-
112-
/**
113-
* 规范化 URL 为绝对地址
114-
*/
115-
export function normalizeUrl(href: string, base: string = 'https://tieba.baidu.com'): string {
116-
if (!href) {
117-
return '';
118-
}
119-
if (href.startsWith('http')) {
120-
return href;
121-
}
122-
const path = href.startsWith('/') ? href : `/${href}`;
123-
return `${base}${path}`;
124-
}
3+
import md5 from '@/utils/md5';
4+
import ofetch from '@/utils/ofetch';
1255

1266
/**
1277
* 通过 /c/f/frs/page API 获取贴吧帖子列表
@@ -145,62 +25,57 @@ type TiebaClientParams = {
14525
sign?: string;
14626
};
14727

148-
function computeSign(params: TiebaClientParams): string {
28+
const computeSign = (params: Partial<TiebaClientParams> & Record<string, string>): string => {
14929
// oxlint-disable-next-line unicorn-js/require-array-sort-compare
15030
const sortedKeys = Object.keys(params).toSorted();
15131
const raw = sortedKeys.map((key) => `${key}=${params[key]}`).join('') + TIEBA_CLIENT_SECRET;
152-
return createHash('md5').update(raw).digest('hex');
153-
}
154-
155-
export async function getTiebaForumData(params: { kw: string; cid?: string; isGood?: boolean; sortBy?: string }): Promise<any> {
156-
const cookie = config.baidu.cookie;
157-
if (!cookie) {
158-
throw new ConfigNotFoundError('Baidu Tieba RSS is disabled due to the lack of <a href="https://docs.rsshub.app/deploy/config#baidu">BAIDU_COOKIE</a>');
159-
}
160-
161-
const bduss = cookie.match(/BDUSS=([^;]+)/)?.[1] || '';
162-
if (!bduss) {
163-
throw new ConfigNotFoundError('BAIDU_COOKIE must contain BDUSS. Please check your cookie configuration.');
164-
}
32+
return md5(raw);
33+
};
16534

166-
const apiParams: TiebaClientParams = {
167-
_client_id: 'wappc_1234567890123_456',
35+
export const tiebaClientRequest = async (path: string, params: Partial<TiebaClientParams> & Record<string, string>) => {
36+
const bduss = config.baidu.cookie?.match(/BDUSS=([^;]+)/)?.[1];
37+
const apiParams: Partial<TiebaClientParams> & Record<string, string> = {
38+
_client_id: `wappc_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
16839
_client_type: '2',
16940
_client_version: '12.20.1.0',
17041
_phone_imei: '000000000000000',
17142
from: 'tieba',
172-
kw: params.kw,
173-
rn: '30',
174-
pn: '1',
175-
BDUSS: bduss,
43+
...(bduss && { BDUSS: bduss }),
44+
...params,
17645
};
177-
178-
if (params.isGood) {
179-
apiParams.is_good = '1';
180-
}
181-
if (params.cid && params.cid !== '0') {
182-
apiParams.cid = params.cid;
183-
}
184-
if (params.sortBy === 'replied') {
185-
apiParams.sort_type = '1';
186-
}
187-
18846
apiParams.sign = computeSign(apiParams);
18947

190-
const url = 'https://tieba.baidu.com/c/f/frs/page';
191-
const cacheKey = `tieba:api:forum:${params.kw}:${params.cid || '0'}:${params.sortBy || 'created'}`;
192-
48+
const cacheKey = `tieba:api:${path}:${new URLSearchParams(params).toString()}`;
19349
const data = await cache.tryGet(
19450
cacheKey,
195-
async () => {
196-
const { data: response } = await got.post(url, {
197-
form: apiParams,
198-
});
199-
return response;
200-
},
51+
() =>
52+
ofetch(`https://tieba.baidu.com${path}`, {
53+
method: 'POST',
54+
body: new URLSearchParams(apiParams),
55+
parseResponse: JSON.parse, // content-type application/x-javascript
56+
}),
20157
config.cache.routeExpire,
20258
false
20359
);
204-
60+
if (Number(data.error_code) !== 0) {
61+
throw new Error(`Tieba API error: ${data.error_msg || data.error_code}`);
62+
}
20563
return data;
206-
}
64+
};
65+
66+
export const renderContent = (items: any[] = []) =>
67+
items
68+
.map((item) => {
69+
switch (Number(item.type)) {
70+
case 1:
71+
case 18:
72+
return `<a href="${item.link}">${item.text}</a>`;
73+
case 2:
74+
return `<img src="https://tb3.bdstatic.com/emoji/${item.text}@2x.png" alt="${item.c}">`;
75+
case 3:
76+
return `<img src="${item.origin_src || item.src}">`;
77+
default:
78+
return item.text ?? '';
79+
}
80+
})
81+
.join('');
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Context } from 'hono';
2+
3+
import type { Route } from '@/types';
4+
5+
import { getForumFeed } from './forum';
6+
7+
export const route: Route = {
8+
path: '/tieba/forum/good/:kw/:cid?/:sortBy?',
9+
categories: ['bbs'],
10+
example: '/baidu/tieba/forum/good/女图',
11+
parameters: { kw: '吧名', cid: '精品分类,默认为 `0`(全部分类),如果不传 `cid` 则获取全部分类', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
12+
features: {
13+
requireConfig: [
14+
{
15+
name: 'BAIDU_COOKIE',
16+
optional: true,
17+
description: '百度 cookie 值,用于需要登录的贴吧页面',
18+
},
19+
],
20+
antiCrawler: true,
21+
},
22+
name: '精品帖子',
23+
maintainers: ['u3u', 'FlanChanXwO'],
24+
handler,
25+
};
26+
27+
function handler(ctx: Context) {
28+
const { kw, cid, sortBy } = ctx.req.param();
29+
return getForumFeed(kw, { cid, sortBy, isGood: true });
30+
}

lib/routes/baidu/tieba/forum.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Context } from 'hono';
2+
3+
import type { Route } from '@/types';
4+
import { parseDate } from '@/utils/parse-date';
5+
6+
import { renderContent, tiebaClientRequest } from './common';
7+
8+
export const route: Route = {
9+
path: '/tieba/forum/:kw/:sortBy?',
10+
categories: ['bbs'],
11+
example: '/baidu/tieba/forum/孙笑川',
12+
parameters: { kw: '吧名', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
13+
features: {
14+
requireConfig: [
15+
{
16+
name: 'BAIDU_COOKIE',
17+
optional: true,
18+
description: '百度 cookie 值,用于需要登录的贴吧页面',
19+
},
20+
],
21+
antiCrawler: true,
22+
},
23+
name: '帖子列表',
24+
maintainers: ['u3u', 'FlanChanXwO'],
25+
handler,
26+
};
27+
28+
function handler(ctx: Context) {
29+
const { kw, sortBy } = ctx.req.param();
30+
return getForumFeed(kw, { sortBy });
31+
}
32+
33+
export const getForumFeed = async (kw: string, { cid = '0', sortBy = 'created', isGood = false } = {}) => {
34+
const data = await tiebaClientRequest('/c/f/frs/page', {
35+
kw,
36+
rn: '30',
37+
pn: '1',
38+
...(isGood && { is_good: '1' }),
39+
...(cid !== '0' && { cid }),
40+
...(sortBy === 'replied' && { sort_type: '1' }),
41+
});
42+
43+
const authorMap = new Map<number, string>(data.user_list.map((user) => [Number(user.id), user.name_show || user.name]));
44+
45+
return {
46+
title: `${kw}吧`,
47+
link: `https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}`,
48+
item: data.thread_list.map((thread) => ({
49+
title: thread.title,
50+
link: `https://tieba.baidu.com/p/${thread.id}`,
51+
pubDate: parseDate(thread.create_time, 'X'),
52+
author: authorMap.get(Number(thread.author_id)),
53+
description: renderContent(thread.first_post_content || thread.abstract),
54+
})),
55+
};
56+
};

0 commit comments

Comments
 (0)