Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
10 changes: 10 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -604,6 +606,10 @@ export type Config = {
sorrycc: {
cookie?: string;
};
southplus: {
cookie?: string;
ua?: string;
};
spotify: {
clientId?: string;
clientSecret?: string;
Expand Down Expand Up @@ -1106,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,
Expand Down
202 changes: 202 additions & 0 deletions lib/routes/south-plus/forum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
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 ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';

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;
Comment thread
NicholasYZ marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#22186 (comment) has not been resolved.

Does the site only work when using trueUA instead of RSSHub's default random generated UA?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the site only work when using trueUA instead of RSSHub's default random generated UA?

No. The site works with any realistic browser UA. config.trueUA (RSSHub/1.0) is not required and in fact should NOT be used as a fallback, because it prevents the request-rewriter from generating a random browser UA.

Fix applied:

// Before:
const ua = config.southplus.ua || config.trueUA;
headers['User-Agent'] = ua;

// After:
const ua = config.southplus.ua;
if (ua) {
    headers['User-Agent'] = ua;
}

When SOUTHPLUS_UA is not set, the User-Agent header is omitted, letting the request-rewriter generate a random browser UA automatically.


const forumUrl = `${BASE_URL}/thread.php?fid-${fid}.html`;

const headers: Record<string, string> = {
'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 html = await ofetch(forumUrl, { headers });
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 a[id^="a_ajax_"]')
.toArray()
.map((item) => {
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 = $row.find('a.bl[href*="action-show-uid"]').text().trim();

// Thread post date in column 2 (div.f10.gray2)
const postDateText = $row.find('div.f10.gray2').first().text().trim();

// Last post date in column 4 (a.f10)
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 = $row.find('a.s8').first().text().trim();
Comment on lines +62 to +74

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure these selectors can match the content?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Here's the HTML structure and extraction results for each board.

Selector-to-HTML mapping

Field Selector HTML example
Title $el.text().trim() (a[id^="a_ajax_"]) <a id="a_ajax_2882258"><b>Title text</b></a>
Category $row.find('a.s8').first().text().trim() <a class="s8">[分类标签]</a>
Author $row.find('a.bl[href*="action-show-uid"]').text().trim() <a class="bl" href="u.php?action-show-uid-428736">Author</a>
pubDate $row.find('td.tal.y-style a.f10').last().text().trim() <a class="f10">2026-06-08 23:27</a>

Test results across 4 boards (2026-06-08)

Board fid Items Categories extracted Posts without category
Animation 4 15 [RAW], [3D动画], [MMD] omitted
Comics 5 15 [合集], [汉化区补档], [日文] omitted
ACG Discussion 8 10 [其它], [动漫], [cos] omitted
ASMR / Voice 128 15 [asmr录播], [同人音声], [音声汉化] omitted

Raw HTML example (forum/128, post with category)

<tr align="center" class="tr3 t_one">
  <!-- Column 0: status icon -->
  <td></td>

  <!-- Column 1: category + title -->
  <td>
    <a class="s8">[同人音声]</a>                    ← $row.find('a.s8')
    <h3>
      <a id="a_ajax_3373" href="read.php?tid-3373.html">
        <b>[自购]title text</b>
      </a>
    </h3>
  </td>

  <!-- Column 2: author + post date -->
  <td>
    <a class="bl" href="...action-show-uid...">poster name</a>  ← $row.find('a.bl[href*="action-show-uid"]')
    <div class="f10 gray2">2026-06-07 10:55</div>            ← $row.find('div.f10.gray2')
  </td>

  <!-- Column 3: replies/views -->
  <td></td>

  <!-- Column 4: last post date -->
  <td class="tal y-style">
    <a class="f10">2026-06-07 10:55</a>               ← $row.find('td.tal.y-style a.f10').last()
  </td>
</tr>
# Title author pubDate category
1 [自购]title text poster name Sun, 07 Jun 2026 [同人音声]

Raw HTML example (forum/4, sticky post, no category)

<td style="text-align:left;line-height:23px;" id="td_3373">
  <img src="...headtopic_3.gif" title="置顶帖标志"/>
  [08-20]
  <h3>
    <a href="read.php?tid-3373.html" id="a_ajax_3373">
      <b><font color=#FF00FF>新人报道帖子(回帖已修复)</font></b>
    </a>
  </h3>
</td>

→ No <a class="s8">category returns empty → omitted via category: category ? [category] : undefined


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 detailHtml = await ofetch(item.link, { headers });
const $detail = load(detailHtml);

// Get the main post content
// PHPWind: <div class="f14" id="read_tpc"> 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
}
Comment thread
NicholasYZ marked this conversation as resolved.

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,其余可选。
:::

::: tip UA 说明
South Plus 服务器会校验 Cookie 与浏览器 User-Agent 的绑定关系。Cookie 仅在登录时使用的浏览器版本下有效,不同版本或不同平台的 UA 均会被拒绝。

如需更换 Cookie,请同时更新 \`SOUTHPLUS_UA\` 为对应浏览器的 UA 字符串。

如果 Cookie 是通过代理获取的,需设置 RSSHub 全局环境变量 \`PROXY_URI\`(如 \`http://host:port\`),否则服务器会拒绝认证。
:::
:::`,
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 时使用的浏览器版本完全一致。可从浏览器 F12 → Network → 请求头中复制。未设置时使用 RSSHub 内置 UA。',
},
],
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,
};
22 changes: 22 additions & 0 deletions lib/routes/south-plus/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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 和 User-Agent 步骤:**

1. 在浏览器中登录 [south-plus.net](https://south-plus.net) 或 [snow-plus.net](https://snow-plus.net)
2. 确认右上角显示用户名和「退出」链接(而非「登录」)
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',
};