Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9453f8
feat(route/baidu): add support for BAIDU_COOKIE in various baidu tieb…
FlanChanXwO Apr 8, 2026
7b880dd
refactor(forum): improve code readability and structure in forum.tsx
FlanChanXwO Apr 8, 2026
e083661
feat(post): enhance time parsing and reply link generation in post.tsx
FlanChanXwO Apr 8, 2026
e2d9a67
feat(route/baidu): refactor page content retrieval and enhance cookie…
FlanChanXwO Apr 8, 2026
4b80d98
refactor(route/baidu): refactor cookie parsing logic and improve code…
FlanChanXwO Apr 8, 2026
7d7efc7
feat(route/baidu/tieba): add BAIDU_COOKIE support and extract shared …
FlanChanXwO Apr 8, 2026
47415db
style(baidu/tieba): fix code formatting and whitespace issues
FlanChanXwO Apr 8, 2026
8b3a080
Merge remote-tracking branch 'origin/master'
FlanChanXwO Apr 8, 2026
fc7ae10
feat(route/baidu): add retry mechanism for transient errors in page c…
FlanChanXwO Apr 8, 2026
e486eb3
Merge branch 'DIYgod:master' into master
FlanChanXwO Apr 8, 2026
bf19f34
fix: improve page content retrieval
FlanChanXwO Apr 22, 2026
cf78d1e
style: auto format
github-actions[bot] Apr 22, 2026
43322b7
fix: improve URL encoding and enhance content retrieval logic
FlanChanXwO Apr 22, 2026
4bef130
feat(route/baidu/tieba): migrate forum route to use client API
FlanChanXwO May 12, 2026
a23d65e
Merge upstream into pr/FlanChanXwO/21663
TonyRL Jun 8, 2026
c40018a
revert: unnecessary changes mentioned in https://github.com/DIYgod/RS…
TonyRL Jun 8, 2026
af29a3b
Merge branch 'DIYgod' into pr/FlanChanXwO/21663
TonyRL Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type ConfigEnvKeys =
| 'FOLLOW_PRICE'
| 'FOLLOW_USER_LIMIT'
// Route-specific (dynamic cookies with prefixes)
| 'BAIDU_COOKIE'
| `BILIBILI_COOKIE_${string}`
| 'BILIBILI_DM_IMG_LIST'
| 'BILIBILI_DM_IMG_INTER'
Expand Down Expand Up @@ -358,6 +359,9 @@ export type Config = {
};

// Route-specific Configurations
baidu: {
cookie?: string;
};
bilibili: {
cookies: Record<string, string | undefined>;
dmImgList?: string;
Expand Down Expand Up @@ -860,6 +864,9 @@ const calculateValue = () => {
},

// Route-specific Configurations
baidu: {
cookie: envs.BAIDU_COOKIE,
},
bilibili: {
cookies: bilibili_cookies,
dmImgList: envs.BILIBILI_DM_IMG_LIST,
Expand Down
190 changes: 190 additions & 0 deletions lib/routes/baidu/tieba/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { createHash } from 'node:crypto';

import { Cookie } from 'tough-cookie';

import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { getPlaywrightPage } from '@/utils/playwright';

/**
* 解析百度 cookie 字符串为 Playwright 可用的 cookie 对象数组
* 正确处理包含 '=' 的 cookie 值
*/
export function parseBaiduCookies(cookieStr: string): Array<{ name: string; value: string; domain: string; path: string }> {
return cookieStr
.split(';')
.map((c) => Cookie.parse(c.trim()))
.filter((c): c is Cookie => Boolean(c?.key))
.map((c) => ({
name: c.key,
value: c.value,
domain: '.tieba.baidu.com',
path: '/',
}));
}

/**
* 检查 HTML 内容是否包含百度安全验证页面
*/
export function checkSecurityVerification(html: string): void {
if (html.includes('安全验证') || html.includes('百度安全验证')) {
throw new Error('Baidu security verification required. The cookie may be expired or invalid. Please update your BAIDU_COOKIE.');
}
}

/**
* 使用 Playwright 获取贴吧页面内容
* 包含统一的 cookie 设置、安全验证检查和缓存逻辑
* 带有重试机制处理瞬态错误
*/
export async function getTiebaPageContent(
url: string,
cacheKey: string,
options: {
waitForSelector?: string;
timeout?: number;
retries?: number;
} = {}
): Promise<string> {
const cookie = config.baidu.cookie;

if (!cookie) {
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>');
}

const cookies = parseBaiduCookies(cookie);
const { waitForSelector = '.thread-card-wrapper, .virtual-list-item, .thread-content-box, .thread-card', timeout = 3000, retries = 3 } = options;

const data = await cache.tryGet(
cacheKey,
async () => {
let lastError: Error | undefined;

/* eslint-disable no-await-in-loop -- Intentional sequential retry logic */
for (let attempt = 0; attempt < retries; attempt++) {
const { page, destroy } = await getPlaywrightPage(url, {

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.
onBeforeLoad: async (page) => {
if (cookies.length > 0) {
await page.context().addCookies(cookies);
}
},
gotoConfig: { waitUntil: 'domcontentloaded' },
});

try {
// 等待页面稳定
await new Promise((resolve) => setTimeout(resolve, 2000));

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.

// 动态等待内容加载
try {
await page.waitForSelector(waitForSelector, { timeout });

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.
} catch {
// 如果超时,继续执行
}

const html = await page.content();

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.
checkSecurityVerification(html);
return html;
} catch (error) {
lastError = error as Error;
// 如果是最后一次尝试,抛出错误
if (attempt === retries - 1) {
throw lastError;
}
// 等待后重试
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.
} finally {
await destroy();

Check failure

Code scanning / oxlint

eslint(no-await-in-loop) Error

Unexpected await inside a loop.
Collect all promises into an array and use Promise.all() to run them in parallel, rather than awaiting each one sequentially inside the loop.
}
}
/* eslint-enable no-await-in-loop */
throw lastError || new Error('Failed to fetch page content');
},
config.cache.routeExpire,
false
);

return data as string;
}

/**
* 规范化 URL 为绝对地址
*/
export function normalizeUrl(href: string, base: string = 'https://tieba.baidu.com'): string {
if (!href) {
return '';
}
if (href.startsWith('http')) {
return href;
}
const path = href.startsWith('/') ? href : `/${href}`;
return `${base}${path}`;
}

/**
* 通过 /c/f/frs/page API 获取贴吧帖子列表
* 使用贴吧客户端签名认证,无需 Puppeteer
*/
const TIEBA_CLIENT_SECRET = 'tiebaclient!!!';

function computeSign(params: Record<string, string>): string {
// oxlint-disable-next-line unicorn-js/require-array-sort-compare
const sortedKeys = Object.keys(params).toSorted();
const raw = sortedKeys.map((key) => `${key}=${params[key]}`).join('') + TIEBA_CLIENT_SECRET;
return createHash('md5').update(raw).digest('hex');
Comment thread
TonyRL marked this conversation as resolved.
Dismissed
}

export async function getTiebaForumData(params: { kw: string; cid?: string; isGood?: boolean; sortBy?: string }): Promise<any> {
const cookie = config.baidu.cookie;
if (!cookie) {
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>');
}

const bduss = cookie.match(/BDUSS=([^;]+)/)?.[1] || '';
if (!bduss) {
throw new ConfigNotFoundError('BAIDU_COOKIE must contain BDUSS. Please check your cookie configuration.');
}

const apiParams: Record<string, string> = {
_client_id: 'wappc_1234567890123_456',
_client_type: '2',
_client_version: '12.20.1.0',
_phone_imei: '000000000000000',
from: 'tieba',
kw: params.kw,
rn: '30',
pn: '1',
BDUSS: bduss,
};

if (params.isGood) {
apiParams.is_good = '1';
}
if (params.cid && params.cid !== '0') {
apiParams.cid = params.cid;
}
if (params.sortBy === 'replied') {
apiParams.sort_type = '1';
}

apiParams.sign = computeSign(apiParams);

const url = 'https://tieba.baidu.com/c/f/frs/page';
const cacheKey = `tieba:api:forum:${params.kw}:${params.cid || '0'}:${params.sortBy || 'created'}`;

const data = await cache.tryGet(
cacheKey,
async () => {
const { data: response } = await got.post(url, {
form: apiParams,
});
return response;
},
config.cache.routeExpire,
false
);

return data;
}
130 changes: 77 additions & 53 deletions lib/routes/baidu/tieba/forum.tsx
Original file line number Diff line number Diff line change
@@ -1,85 +1,109 @@
import { load } from 'cheerio';
import { raw } from 'hono/html';
import { renderToString } from 'hono/jsx/dom/server';

import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';

import { getTiebaForumData } from './common';

export const route: Route = {
path: ['/tieba/forum/good/:kw/:cid?/:sortBy?', '/tieba/forum/:kw/:sortBy?'],
categories: ['bbs'],
example: '/baidu/tieba/forum/good/女图',
parameters: { kw: '吧名', cid: '精品分类,默认为 `0`(全部分类),如果不传 `cid` 则获取全部分类', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
features: {
requireConfig: false,
requireConfig: [
{
name: 'BAIDU_COOKIE',
optional: false,
description: '百度 cookie 值,用于需要登录的贴吧页面',
},
],
requirePuppeteer: false,
antiCrawler: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
name: '精品帖子',
maintainers: ['u3u'],
maintainers: ['u3u', 'FlanChanXwO'],
handler,
};

function extractContent(items: any[]): { text: string; images: string[] } {
let text = '';
const images: string[] = [];
if (!Array.isArray(items)) {
return { text, images };
}
for (const item of items) {
if (Number(item.type) === 0 && item.text) {
text += item.text;
} else if (Number(item.type) === 3) {
const src = item.origin_src || item.original_src || item.big_cdn_src || item.cdn_src || item.src;
if (src) {
images.push(src);
}
}
}
return { text, images };
}

async function handler(ctx) {
// sortBy: created, replied
const { kw, cid = '0', sortBy = 'created' } = ctx.req.param();
const isGood = ctx.req.path.includes('good');

// PC端:https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}
// 移动端接口:https://tieba.baidu.com/mo/q/m?kw=${encodeURIComponent(kw)}&lp=5024&forum_recommend=1&lm=0&cid=0&has_url_param=1&pn=0&is_ajax=1
const params = { kw: encodeURIComponent(kw) };
ctx.req.path.includes('good') && (params.tab = 'good');
cid && (params.cid = cid);
const { data } = await got('https://tieba.baidu.com/f', {
headers: {
Referer: 'https://tieba.baidu.com/',
},
searchParams: params,
});
const data = await getTiebaForumData({ kw, cid, isGood, sortBy });

if (data?.error_code && data.error_code !== '0' && data.error_code !== 0) {
throw new Error(`Tieba API error: ${data.error_msg || data.error_code}`);
}

const threadListHTML = load(data)('code[id="pagelet_html_frs-list/pagelet/thread_list"]')
.contents()
.filter((e) => e.nodeType === '8');
Comment thread
FlanChanXwO marked this conversation as resolved.
const threadList = data?.thread_list || [];

const $ = load(threadListHTML.prevObject[0].data);
const list = $('#thread_list > .j_thread_list[data-field]')
.toArray()
.map((element) => {
const item = $(element);
const { id, author_name } = item.data('field');
const time = sortBy === 'created' ? item.find('.is_show_create_time').text().trim() : item.find('.threadlist_reply_date').text().trim();
const title = item.find('a.j_th_tit').text().trim();
const details = item.find('.threadlist_abs').text().trim();
const medias = item
.find('.threadlist_media img')
.toArray()
.map((element) => {
const item = $(element);
return `<img src="${item.attr('bpic')}">`;
})
.join('');
if (threadList.length === 0) {
throw new Error('No threads found. The cookie may be expired or invalid. Please check your BAIDU_COOKIE.');
}

return {
title,
description: renderToString(
<>
<p>{details}</p>
<p>{raw(medias)}</p>
<p>作者:{author_name}</p>
</>
),
pubDate: timezone(parseDate(time, ['HH:mm', 'M-D', 'YYYY-MM'], true), 8),
link: `https://tieba.baidu.com/p/${id}`,
};
});
// Build author map from user_list
const userList: any[] = data?.user_list || [];
const authorMap = new Map<number, string>();
for (const user of userList) {
if (user.id) {
authorMap.set(Number(user.id), user.name_show || user.name || '');
}
}

const list = threadList.map((thread) => {
// Prefer first_post_content (richer), fall back to abstract
const { text: content, images } = extractContent(thread.first_post_content || thread.abstract || []);

const timestamp = Number(thread.create_time || 0);
const pubDate = timestamp > 0 ? timezone(new Date(timestamp * 1000), 8) : undefined;

const authorName = authorMap.get(Number(thread.author_id)) || '';

return {
title: thread.title,
link: `https://tieba.baidu.com/p/${thread.id || thread.tid}`,
...(pubDate && { pubDate }),
author: authorName,
description: renderToString(
<>
{content ? <p>{content}</p> : null}
{images.length > 0 ? (
<div>
{images.map((img) => (
<img src={img} alt="" style={{ maxWidth: '100%', margin: '5px 0' }} />
))}
</div>
) : null}
</>
),
};
});

return {
title: `${kw}吧`,
description: load(data)('meta[name="description"]').attr('content'),
link: `https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}`,
item: list,
};
Expand Down
Loading