From 16b9168907651f7055815bb9b64937b31d538e74 Mon Sep 17 00:00:00 2001 From: NicholasYZ Date: Thu, 4 Jun 2026 21:38:51 +0800 Subject: [PATCH 1/6] feat(route): add South Plus forum route - /south-plus/forum/:fid? for PHPWind-based forum - Cookie-based auth via SOUTHPLUS_COOKIE env var - Configurable UA via SOUTHPLUS_UA (cookie-version binding) - Full-text fetching with cache.tryGet - p-map concurrency control (3 at a time) - Category extraction from thread listing - Fid reference table and cookie guide in description --- lib/config.ts | 10 ++ lib/routes/south-plus/forum.ts | 201 +++++++++++++++++++++++++++++ lib/routes/south-plus/namespace.ts | 21 +++ 3 files changed, 232 insertions(+) create mode 100644 lib/routes/south-plus/forum.ts create mode 100644 lib/routes/south-plus/namespace.ts diff --git a/lib/config.ts b/lib/config.ts index a460f33723b0..73a9ee88357e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -191,6 +191,8 @@ type ConfigEnvKeys = | 'SIS001_BASE_URL' | 'SKEB_BEARER_TOKEN' | 'SORRYCC_COOKIES' + | 'SOUTHPLUS_COOKIE' + | 'SOUTHPLUS_UA' | 'SPOTIFY_CLIENT_ID' | 'SPOTIFY_CLIENT_SECRET' | 'SPOTIFY_REFRESHTOKEN' @@ -401,6 +403,10 @@ export type Config = { douban: { cookie?: string; }; + southplus: { + cookie?: string; + ua?: string; + }; ehentai: { ipb_member_id?: string; ipb_pass_hash?: string; @@ -903,6 +909,10 @@ const calculateValue = () => { douban: { cookie: envs.DOUBAN_COOKIE, }, + southplus: { + cookie: envs.SOUTHPLUS_COOKIE, + ua: envs.SOUTHPLUS_UA, + }, ehentai: { ipb_member_id: envs.EH_IPB_MEMBER_ID, ipb_pass_hash: envs.EH_IPB_PASS_HASH, diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts new file mode 100644 index 000000000000..d956bf944341 --- /dev/null +++ b/lib/routes/south-plus/forum.ts @@ -0,0 +1,201 @@ +import { load } from 'cheerio'; +import pMap from 'p-map'; + +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import type { Route } from '@/types'; +import cache from '@/utils/cache'; +import { parseDate } from '@/utils/parse-date'; + +const BASE_URL = 'https://south-plus.net'; + +const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0'; + +async function handler(ctx) { + const fid = ctx.req.param('fid') ?? '8'; + const cookie = config.southplus.cookie; + const ua = config.southplus.ua || DEFAULT_UA; + + const forumUrl = `${BASE_URL}/thread.php?fid-${fid}.html`; + + const headers: Record = { + 'User-Agent': ua, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9', + Referer: 'https://south-plus.net/index.php', + }; + if (cookie) { + headers.Cookie = cookie; + } + + const response = await fetch(forumUrl, { headers }); + const html = await response.text(); + const $ = load(html); + + // Check if access is denied + const pageTitle = $('head > title').text(); + if (pageTitle.includes('没有权限') || pageTitle.includes('没有登录') || pageTitle.includes('认证版块')) { + if (!cookie) { + throw new ConfigNotFoundError('此版块需要登录才能访问。请配置 SOUTHPLUS_COOKIE 环境变量。'); + } + throw new ConfigNotFoundError('Cookie 已过期或无效,无法访问此版块。请更新 SOUTHPLUS_COOKIE 环境变量。'); + } + + // Parse thread list + // Thread rows have class="tr3 t_one" + // Structure: 5 td columns + // [0] status icon + // [1] category + title (h3 > a#a_ajax_XXXX) + page links + // [2] author (a.bl) + post date (div.f10.gray2) + // [3] replies/views + // [4] last post date (a.f10) + last poster (span.gray2) + const threadList = $('tr.tr3.t_one') + .toArray() + .filter((item) => { + const $item = $(item); + const threadLink = $item.find('a[id^="a_ajax_"]').attr('href'); + return Boolean(threadLink); + }) + .map((item) => { + const $item = $(item); + const titleEl = $item.find('a[id^="a_ajax_"]'); + const threadLink = titleEl.attr('href'); + const title = titleEl.text().trim(); + const link = threadLink ? new URL(threadLink, BASE_URL).href : ''; + + // Author in column 2 + const author = $item.find('a.bl[href*="action-show-uid"]').text().trim(); + + // Thread post date in column 2 (div.f10.gray2) + const postDateText = $item.find('div.f10.gray2').first().text().trim(); + + // Last post date in column 4 (a.f10) + const lastPostDateText = $item.find('td.tal.y-style a.f10').last().text().trim(); + + // Use last post date as pubDate for RSS sorting + const pubDate = parseDate(lastPostDateText) || parseDate(postDateText); + + // Thread category tag (e.g. [自购], [公告]) in column 1 + const category = $item.find('a.s8').first().text().trim(); + + return { + title, + link, + author, + pubDate, + category: category ? [category] : undefined, + }; + }); + + // Optionally fetch full content for each thread (with cache) + const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50; + const items = await pMap( + threadList.slice(0, limit), + (item) => + cache.tryGet(item.link, async () => { + try { + const detailResponse = await fetch(item.link, { headers }); + const detailHtml = await detailResponse.text(); + const $detail = load(detailHtml); + + // Get the main post content + // PHPWind:
for the first post + const contentEl = $detail('#read_tpc'); + if (contentEl.length > 0) { + item.description = contentEl.html() ?? ''; + } + + // Get the original post date from tiptop area + const dateEl = $detail('.tiptop .fl.gray'); + if (dateEl.length > 0) { + const dateText = dateEl.first().text().trim(); + const dateMatch = dateText.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/); + if (dateMatch) { + item.pubDate = parseDate(dateMatch[1]); + } + } + + // Get the author from the detail page + const authorEl = $detail('.r_two a[href*="action-show-uid"] strong'); + if (authorEl.length > 0) { + item.author = authorEl.first().text().trim(); + } + } catch { + // If detail page fails, keep the list page data + } + + return item; + }), + { concurrency: 3 } + ); + + return { + title: $('head > title').text().trim(), + link: forumUrl, + description: $('meta[name="description"]').attr('content'), + language: 'zh-CN', + item: items, + }; +} + +export const route: Route = { + path: '/forum/:fid?', + categories: ['bbs'], + example: '/south-plus/forum/8', + parameters: { + fid: '论坛版块 ID,默认为 8(ACG交流)。可在 thread.php?fid-XXX.html 中找到。常用 fid 见下方说明', + }, + description: `::: tip 常用版块 ID + +| fid | 版块名称 | 需要登录 | +| --- | -------- | :------: | +| 48 | 询问求物 | 是 | +| 8 | ACG 交流 | 否 | +| 12 | 轻小说 | 是 | +| 9 | 茶馆 | 是 | +| 201 | COSPLAY | 是 | +| 6 | 游戏资源 | 是 | +| 5 | 实用漫画 | 是 | +| 4 | 实用动画 | 是 | +| 128 | 同人音声 | 是 | +| 208 | AI 交流 | 是 | + +::: + +::: tip Cookie 示例 + +\`\`\` +eb9e6_winduser=XXXX...XXXX%3D%3D; eb9e6_cknum=YYYY...YYYY%3D; eb9e6_ck_info=%2F%09; cf_clearance=ZZZZ...ZZZZ; eb9e6_lastpos=other; eb9e6_ol_offset=123456; eb9e6_readlog=%2C...; eb9e6_threadlog=%2C...; eb9e6_lastvisit=...; peacemaker=1 +\`\`\` + +\`eb9e6_winduser\` 和 \`eb9e6_cknum\` 是必需的认证 cookie,其余可选。 +:::`, + features: { + requireConfig: [ + { + name: 'SOUTHPLUS_COOKIE', + optional: true, + description: '登录 Cookie,格式为分号+空格分隔的 key=value 对。核心字段:eb9e6_winduser(认证令牌)、eb9e6_cknum(会话校验)。从浏览器登录后导出完整 cookie 字符串即可。', + }, + { + name: 'SOUTHPLUS_UA', + optional: true, + description: '浏览器 User-Agent,需与获取 Cookie 时使用的浏览器版本一致。默认为 Firefox 151。', + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['south-plus.net/thread.php', 'snow-plus.net/thread.php'], + target: '/forum/:fid', + }, + ], + name: '论坛帖子', + maintainers: ['NicholasYZ'], + handler, +}; diff --git a/lib/routes/south-plus/namespace.ts b/lib/routes/south-plus/namespace.ts new file mode 100644 index 000000000000..f438437ed804 --- /dev/null +++ b/lib/routes/south-plus/namespace.ts @@ -0,0 +1,21 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'South Plus', + url: 'south-plus.net', + description: `::: tip +South Plus (南 +) 是一个基于 PHPWind 架构的 ACG 综合交流论坛。 + +部分板块需要登录才能访问,请配置 \`SOUTHPLUS_COOKIE\` 环境变量。 + +**获取 Cookie 步骤:** + +1. 在浏览器中登录 [snow-plus.net](https://snow-plus.net) 或 [south-plus.net](https://south-plus.net) +2. 确认右上角显示用户名和「退出」链接(而非「登录」) +3. 按 F12 → Application/Storage → Cookies → 选择站点域名 +4. 将所有 cookie 拼接为单行:\`key1=value1; key2=value2; ...\` +5. 核心 cookie 为 \`eb9e6_winduser\`(认证令牌)和 \`eb9e6_cknum\`(会话校验) + +:::`, + lang: 'zh-CN', +}; From f82ac3309df3894e3b1fbac1e84f48e3fd372f36 Mon Sep 17 00:00:00 2001 From: NicholasYZ Date: Thu, 4 Jun 2026 23:22:34 +0800 Subject: [PATCH 2/6] feat(south-plus): support PROXY_URI for cookie-IP binding, use config.trueUA as default - Replace hardcoded Firefox 151 UA with config.trueUA to comply with guideline 36 - Use built-in proxy.dispatcher (PROXY_URI) for proxy support instead of custom env var - Update documentation: F12 Network approach for cookie/UA extraction, PROXY_URI tip - Fix markdown structure: close Cookie tip block before UA tip --- lib/routes/south-plus/forum.ts | 20 ++++++++++++++------ lib/routes/south-plus/namespace.ts | 11 ++++++----- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts index d956bf944341..7cf1bc5e738d 100644 --- a/lib/routes/south-plus/forum.ts +++ b/lib/routes/south-plus/forum.ts @@ -6,15 +6,14 @@ import ConfigNotFoundError from '@/errors/types/config-not-found'; import type { Route } from '@/types'; import cache from '@/utils/cache'; import { parseDate } from '@/utils/parse-date'; +import proxy from '@/utils/proxy'; const BASE_URL = 'https://south-plus.net'; -const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0'; - async function handler(ctx) { const fid = ctx.req.param('fid') ?? '8'; const cookie = config.southplus.cookie; - const ua = config.southplus.ua || DEFAULT_UA; + const ua = config.southplus.ua || config.trueUA; const forumUrl = `${BASE_URL}/thread.php?fid-${fid}.html`; @@ -28,7 +27,7 @@ async function handler(ctx) { headers.Cookie = cookie; } - const response = await fetch(forumUrl, { headers }); + const response = await fetch(forumUrl, { headers, dispatcher: proxy.dispatcher }); const html = await response.text(); const $ = load(html); @@ -94,7 +93,7 @@ async function handler(ctx) { (item) => cache.tryGet(item.link, async () => { try { - const detailResponse = await fetch(item.link, { headers }); + const detailResponse = await fetch(item.link, { headers, dispatcher: proxy.dispatcher }); const detailHtml = await detailResponse.text(); const $detail = load(detailHtml); @@ -169,6 +168,15 @@ eb9e6_winduser=XXXX...XXXX%3D%3D; eb9e6_cknum=YYYY...YYYY%3D; eb9e6_ck_info=%2F% \`\`\` \`eb9e6_winduser\` 和 \`eb9e6_cknum\` 是必需的认证 cookie,其余可选。 +::: + +::: tip UA 说明 +South Plus 服务器会校验 Cookie 与浏览器 User-Agent 的绑定关系。Cookie 仅在登录时使用的浏览器版本下有效,不同版本或不同平台的 UA 均会被拒绝。 + +如需更换 Cookie,请同时更新 \`SOUTHPLUS_UA\` 为对应浏览器的 UA 字符串。 + +如果 Cookie 是通过代理获取的,需设置 RSSHub 全局环境变量 \`PROXY_URI\`(如 \`http://host:port\`),否则服务器会拒绝认证。 +::: :::`, features: { requireConfig: [ @@ -180,7 +188,7 @@ eb9e6_winduser=XXXX...XXXX%3D%3D; eb9e6_cknum=YYYY...YYYY%3D; eb9e6_ck_info=%2F% { name: 'SOUTHPLUS_UA', optional: true, - description: '浏览器 User-Agent,需与获取 Cookie 时使用的浏览器版本一致。默认为 Firefox 151。', + description: '浏览器 User-Agent,需与获取 Cookie 时使用的浏览器版本完全一致。可从浏览器 F12 → Network → 请求头中复制。未设置时使用 RSSHub 内置 UA。', }, ], requirePuppeteer: false, diff --git a/lib/routes/south-plus/namespace.ts b/lib/routes/south-plus/namespace.ts index f438437ed804..216151cf9162 100644 --- a/lib/routes/south-plus/namespace.ts +++ b/lib/routes/south-plus/namespace.ts @@ -8,13 +8,14 @@ South Plus (南 +) 是一个基于 PHPWind 架构的 ACG 综合交流论坛。 部分板块需要登录才能访问,请配置 \`SOUTHPLUS_COOKIE\` 环境变量。 -**获取 Cookie 步骤:** +**获取 Cookie 和 User-Agent 步骤:** -1. 在浏览器中登录 [snow-plus.net](https://snow-plus.net) 或 [south-plus.net](https://south-plus.net) +1. 在浏览器中登录 [south-plus.net](https://south-plus.net) 或 [snow-plus.net](https://snow-plus.net) 2. 确认右上角显示用户名和「退出」链接(而非「登录」) -3. 按 F12 → Application/Storage → Cookies → 选择站点域名 -4. 将所有 cookie 拼接为单行:\`key1=value1; key2=value2; ...\` -5. 核心 cookie 为 \`eb9e6_winduser\`(认证令牌)和 \`eb9e6_cknum\`(会话校验) +3. 按 F12 → **Network**(网络)→ 刷新页面 → 点击任意请求 → **Request Headers**(请求头) +4. 复制 \`Cookie\` 字段的完整值(单行,分号 + 空格分隔),设置为 \`SOUTHPLUS_COOKIE\` +5. 复制 \`User-Agent\` 字段的值,设置为 \`SOUTHPLUS_UA\`(Cookie 与 UA 版本绑定,必须匹配) +6. 如果 Cookie 是通过代理获取的,需设置 RSSHub 全局环境变量 \`PROXY_URI\`(如 \`http://host:port\`) :::`, lang: 'zh-CN', From c046e1092db52f7c5833a0076b6d31bfb12f333c Mon Sep 17 00:00:00 2001 From: NicholasYZ Date: Fri, 5 Jun 2026 00:00:34 +0800 Subject: [PATCH 3/6] fix(south-plus): remove manual dispatcher to avoid assertion failure in CI RSSHub globally overrides globalThis.fetch with proxy auto-injection via request-rewriter. Passing dispatcher: null (when PROXY_URI is unset) triggers undici internal assert(dispatcher) failure in GitHub Actions. --- lib/routes/south-plus/forum.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts index 7cf1bc5e738d..aece858b5d9e 100644 --- a/lib/routes/south-plus/forum.ts +++ b/lib/routes/south-plus/forum.ts @@ -6,7 +6,6 @@ import ConfigNotFoundError from '@/errors/types/config-not-found'; import type { Route } from '@/types'; import cache from '@/utils/cache'; import { parseDate } from '@/utils/parse-date'; -import proxy from '@/utils/proxy'; const BASE_URL = 'https://south-plus.net'; @@ -27,7 +26,7 @@ async function handler(ctx) { headers.Cookie = cookie; } - const response = await fetch(forumUrl, { headers, dispatcher: proxy.dispatcher }); + const response = await fetch(forumUrl, { headers }); const html = await response.text(); const $ = load(html); @@ -93,7 +92,7 @@ async function handler(ctx) { (item) => cache.tryGet(item.link, async () => { try { - const detailResponse = await fetch(item.link, { headers, dispatcher: proxy.dispatcher }); + const detailResponse = await fetch(item.link, { headers }); const detailHtml = await detailResponse.text(); const $detail = load(detailHtml); From 26c836c768e900cc5b4974f215062ebef71cf660 Mon Sep 17 00:00:00 2001 From: NicholasYZ <46787434+NicholasYZ@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:08:26 +0800 Subject: [PATCH 4/6] Update lib/routes/south-plus/forum.ts Co-authored-by: Tony --- lib/routes/south-plus/forum.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts index aece858b5d9e..ba370225dcee 100644 --- a/lib/routes/south-plus/forum.ts +++ b/lib/routes/south-plus/forum.ts @@ -47,13 +47,8 @@ async function handler(ctx) { // [2] author (a.bl) + post date (div.f10.gray2) // [3] replies/views // [4] last post date (a.f10) + last poster (span.gray2) - const threadList = $('tr.tr3.t_one') + const threadList = $('tr.tr3.t_one a[id^="a_ajax_"]') .toArray() - .filter((item) => { - const $item = $(item); - const threadLink = $item.find('a[id^="a_ajax_"]').attr('href'); - return Boolean(threadLink); - }) .map((item) => { const $item = $(item); const titleEl = $item.find('a[id^="a_ajax_"]'); From 03b511216ddaa99b7814e280ee307ef8f802223d Mon Sep 17 00:00:00 2001 From: NicholasYZ Date: Sun, 7 Jun 2026 16:43:38 +0800 Subject: [PATCH 5/6] fix(south-plus): address PR review comments - Use ofetch instead of native fetch - Simplify thread list selector per review suggestion - Only update author/pubDate from detail page when content is found - Move config block to correct alphabetical position --- lib/config.ts | 16 +++++------ lib/routes/south-plus/forum.ts | 49 +++++++++++++++++----------------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 73a9ee88357e..e49c2310b199 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -403,10 +403,6 @@ export type Config = { douban: { cookie?: string; }; - southplus: { - cookie?: string; - ua?: string; - }; ehentai: { ipb_member_id?: string; ipb_pass_hash?: string; @@ -610,6 +606,10 @@ export type Config = { sorrycc: { cookie?: string; }; + southplus: { + cookie?: string; + ua?: string; + }; spotify: { clientId?: string; clientSecret?: string; @@ -909,10 +909,6 @@ const calculateValue = () => { douban: { cookie: envs.DOUBAN_COOKIE, }, - southplus: { - cookie: envs.SOUTHPLUS_COOKIE, - ua: envs.SOUTHPLUS_UA, - }, ehentai: { ipb_member_id: envs.EH_IPB_MEMBER_ID, ipb_pass_hash: envs.EH_IPB_PASS_HASH, @@ -1116,6 +1112,10 @@ const calculateValue = () => { sorrycc: { cookie: envs.SORRYCC_COOKIES, }, + southplus: { + cookie: envs.SOUTHPLUS_COOKIE, + ua: envs.SOUTHPLUS_UA, + }, spotify: { clientId: envs.SPOTIFY_CLIENT_ID, clientSecret: envs.SPOTIFY_CLIENT_SECRET, diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts index ba370225dcee..0e44989e6f56 100644 --- a/lib/routes/south-plus/forum.ts +++ b/lib/routes/south-plus/forum.ts @@ -5,6 +5,7 @@ import { config } from '@/config'; import ConfigNotFoundError from '@/errors/types/config-not-found'; import type { Route } from '@/types'; import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; const BASE_URL = 'https://south-plus.net'; @@ -26,8 +27,7 @@ async function handler(ctx) { headers.Cookie = cookie; } - const response = await fetch(forumUrl, { headers }); - const html = await response.text(); + const html = await ofetch(forumUrl, { headers }); const $ = load(html); // Check if access is denied @@ -50,26 +50,26 @@ async function handler(ctx) { const threadList = $('tr.tr3.t_one a[id^="a_ajax_"]') .toArray() .map((item) => { - const $item = $(item); - const titleEl = $item.find('a[id^="a_ajax_"]'); - const threadLink = titleEl.attr('href'); - const title = titleEl.text().trim(); + const $el = $(item); + const $row = $el.closest('tr'); + const threadLink = $el.attr('href'); + const title = $el.text().trim(); const link = threadLink ? new URL(threadLink, BASE_URL).href : ''; // Author in column 2 - const author = $item.find('a.bl[href*="action-show-uid"]').text().trim(); + const author = $row.find('a.bl[href*="action-show-uid"]').text().trim(); // Thread post date in column 2 (div.f10.gray2) - const postDateText = $item.find('div.f10.gray2').first().text().trim(); + const postDateText = $row.find('div.f10.gray2').first().text().trim(); // Last post date in column 4 (a.f10) - const lastPostDateText = $item.find('td.tal.y-style a.f10').last().text().trim(); + const lastPostDateText = $row.find('td.tal.y-style a.f10').last().text().trim(); // Use last post date as pubDate for RSS sorting const pubDate = parseDate(lastPostDateText) || parseDate(postDateText); // Thread category tag (e.g. [自购], [公告]) in column 1 - const category = $item.find('a.s8').first().text().trim(); + const category = $row.find('a.s8').first().text().trim(); return { title, @@ -87,8 +87,7 @@ async function handler(ctx) { (item) => cache.tryGet(item.link, async () => { try { - const detailResponse = await fetch(item.link, { headers }); - const detailHtml = await detailResponse.text(); + const detailHtml = await ofetch(item.link, { headers }); const $detail = load(detailHtml); // Get the main post content @@ -96,22 +95,22 @@ async function handler(ctx) { const contentEl = $detail('#read_tpc'); if (contentEl.length > 0) { item.description = contentEl.html() ?? ''; - } - // Get the original post date from tiptop area - const dateEl = $detail('.tiptop .fl.gray'); - if (dateEl.length > 0) { - const dateText = dateEl.first().text().trim(); - const dateMatch = dateText.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/); - if (dateMatch) { - item.pubDate = parseDate(dateMatch[1]); + // Get the original post date from tiptop area + const dateEl = $detail('.tiptop .fl.gray'); + if (dateEl.length > 0) { + const dateText = dateEl.first().text().trim(); + const dateMatch = dateText.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/); + if (dateMatch) { + item.pubDate = parseDate(dateMatch[1]); + } } - } - // Get the author from the detail page - const authorEl = $detail('.r_two a[href*="action-show-uid"] strong'); - if (authorEl.length > 0) { - item.author = authorEl.first().text().trim(); + // Get the author from the detail page + const authorEl = $detail('.r_two a[href*="action-show-uid"] strong'); + if (authorEl.length > 0) { + item.author = authorEl.first().text().trim(); + } } } catch { // If detail page fails, keep the list page data From e0e2629bd304b62467a5e97cc7d25b257dce9820 Mon Sep 17 00:00:00 2001 From: NicholasYZ Date: Fri, 12 Jun 2026 19:26:56 +0800 Subject: [PATCH 6/6] fix(south-plus): do not fall back to config.trueUA for User-Agent When SOUTHPLUS_UA is not configured, omit the User-Agent header entirely to let the request-rewriter generate a random browser UA. config.trueUA (RSSHub/1.0) is a crawler UA and may cause 403 errors with strict WAFs. Closes review comment #7 in PR #22186. --- lib/routes/south-plus/forum.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/routes/south-plus/forum.ts b/lib/routes/south-plus/forum.ts index 0e44989e6f56..8bf924a48008 100644 --- a/lib/routes/south-plus/forum.ts +++ b/lib/routes/south-plus/forum.ts @@ -13,16 +13,18 @@ const BASE_URL = 'https://south-plus.net'; async function handler(ctx) { const fid = ctx.req.param('fid') ?? '8'; const cookie = config.southplus.cookie; - const ua = config.southplus.ua || config.trueUA; + const ua = config.southplus.ua; const forumUrl = `${BASE_URL}/thread.php?fid-${fid}.html`; const headers: Record = { - 'User-Agent': ua, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9', Referer: 'https://south-plus.net/index.php', }; + if (ua) { + headers['User-Agent'] = ua; + } if (cookie) { headers.Cookie = cookie; } @@ -166,7 +168,7 @@ eb9e6_winduser=XXXX...XXXX%3D%3D; eb9e6_cknum=YYYY...YYYY%3D; eb9e6_ck_info=%2F% ::: tip UA 说明 South Plus 服务器会校验 Cookie 与浏览器 User-Agent 的绑定关系。Cookie 仅在登录时使用的浏览器版本下有效,不同版本或不同平台的 UA 均会被拒绝。 -如需更换 Cookie,请同时更新 \`SOUTHPLUS_UA\` 为对应浏览器的 UA 字符串。 +如需更换 Cookie,请同时设置 \`SOUTHPLUS_UA\` 为对应浏览器的 UA 字符串。未设置时 RSSHub 会自动生成随机的浏览器 UA。 如果 Cookie 是通过代理获取的,需设置 RSSHub 全局环境变量 \`PROXY_URI\`(如 \`http://host:port\`),否则服务器会拒绝认证。 ::: @@ -181,7 +183,7 @@ South Plus 服务器会校验 Cookie 与浏览器 User-Agent 的绑定关系。C { name: 'SOUTHPLUS_UA', optional: true, - description: '浏览器 User-Agent,需与获取 Cookie 时使用的浏览器版本完全一致。可从浏览器 F12 → Network → 请求头中复制。未设置时使用 RSSHub 内置 UA。', + description: '浏览器 User-Agent,需与获取 Cookie 时使用的浏览器版本完全一致。可从浏览器 F12 → Network → 请求头中复制。未设置时由 RSSHub 自动生成随机浏览器 UA。', }, ], requirePuppeteer: false,