Skip to content

Shuiyuan

ChangYo edited this page Oct 25, 2026 · 2 revisions

水源社区

项 值
平台 shuiyuan.sjtu.edu.cn
认证 校园 Web 会话 Cookie;Discourse User API Key
数据方向 读 + 写(书签、回复、私信、点赞;取消点赞不可用)
难度 中
最后验证 2026-09-21
证据等级 A(实测;站点为 Discourse 2026.5)

Warning

水源社区协议明确:未经社区书面许可,用户不得使用非官方认可的客户端、脚本等访问方式访问社区服务、下载内容,也不得授权/协助第三方获取平台数据。User API Key 是 Discourse 的授权机制,不等于站点许可。开发第三方客户端前,应先取得书面许可,否则账号可能被封禁。本页只记录技术事实,不构成合规建议。

水源是交大校园论坛,基于 Discourse。全站要求登录,没有匿名数据面。登录使用 jAccount SSO。第三方客户端有两种方式:站点 Cookie 或 Discourse User API Key。

要点

  • 可获取:主题与楼层、分类与标签、搜索、通知、私信、收藏、个人资料、站点结构、全站统计。
  • 前置:本人 jAccount 登录,或具有 read/write scope 的 User API Key。
  • 注意:站点没有 User API Key 管理界面,key 丢失后只能联系管理员;write scope 不含取消点赞;匿名请求、RSS 和爬虫均被拦截。

1. 站点与访问策略

事项 实测结论
框架 Discourse 2026.5.0-latest.1(HTML meta generator)
访问策略 全站 login_required:匿名请求 latest.json、categories.json、site.json、about.json 一律 403 {"errors":["您需要登录才能执行此操作。"],"error_type":"not_logged_in"}
爬虫 Googlebot UA 仍 403;latest.rss 302 跳登录
登录入口 / → 302 /auth/jaccount → 302 jaccount.sjtu.edu.cn/oauth2/authorize?...&scope=essential&state=...
会话 Cookie _forum_session + _t(remember-me,长效;具体时长未长期验证)
写操作 CSRF Cookie 路径需要 X-CSRF-Token(来自 GET /session/csrf);User API Key 路径不需要

水源的 SSO 使用水源自己注册的 jAccount 应用。你不是 OAuth 客户端,也不持有 client_secret,只让用户在受控窗口中完成一次浏览器登录。[实测 2026-09-13]


2. 认证

2.1 路线 A:校园 Web 会话 Cookie

适合:你已经在做桌面客户端,且能维护一个持久 Session。

打开 https://shuiyuan.sjtu.edu.cn/
  → 302 /auth/jaccount
  → 302 jaccount.sjtu.edu.cn/oauth2/authorize(水源的 client_id)
  → 用户人工登录(账号密码/扫码/短信 + 验证码)
  → 回跳 /auth/jaccount/callback
  → Discourse 种 _forum_session / _t
  → 后续带 Cookie 请求 *.json

要点:

  • GET 类端点只依赖 Cookie,不需要 CSRF。[实测]
  • 写操作(标记通知已读、收藏等)需要 X-CSRF-Token,从 GET /session/csrf 取;该端点匿名可用,token 与 _forum_session 配对。[实测]
  • 登录失效的预期表现是 403 not_logged_in 或 302 回登录页;具体形态待多天观察。
  • 不要脚本化验证码;不要保存用户密码。

2.2 路线 B:User API Key(推荐给第三方客户端)

这是 Discourse 官方给第三方客户端的授权机制:应用出示 RSA 公钥,用户在网页点授权,服务端把 key 加密后回传。

生成授权链接

GET https://shuiyuan.sjtu.edu.cn/user-api-key/new
  ?scopes=read,write
  &application_name=Dela
  &client_id=<随机 16 字节 hex>
  &nonce=<随机 16 字节 hex>
  &public_key=<SPKI PEM,URL 编码>

要点:

  • RSA 2048 位密钥对;私钥只留在本机内存/加密存储。
  • scopes:read 或 read,write。write 授权页会明确写「发布、编辑和删除内容」。
  • 不要带 auth_redirect。不带时,站点在页面内直接展示加密 payload,用户复制回应用。2026-09-14 在不带 auth_redirect 的情况下完成了完整链路;09-13 带 auth_redirect 的请求失败,但两者差异没有做 A/B,因此不能断言这是唯一原因。[实测 2026-09-14,未做 A/B]
  • application_name 不是白名单条件;Dela 和 OpenClaw Shuiyuan Skill 都能正常展示授权页。[实测]
  • 用户完成授权后,页面展示一段 base64 密文和「复制密钥」按钮。

解密

import { privateDecrypt, constants } from 'node:crypto';

const decrypted = privateDecrypt(
  { key: privatePem, padding: constants.RSA_PKCS1_PADDING },
  Buffer.from(payloadBase64, 'base64'),
).toString('utf8');

const parsed = JSON.parse(decrypted) as { key: string; nonce: string };
if (parsed.nonce !== expectedNonce) throw new Error('nonce 不匹配');
  • Discourse 规范默认用 pkcs1(RSA_PKCS1_PADDING),也支持传 padding=oaep;Dela 实测用的是 pkcs1。如果你的客户端/站点支持 OAEP,可以优先 OAEP。
  • 解密后核对 nonce,再保存 key。
  • 保存前调 GET /session/current.json 验证:
GET /session/current.json
User-Api-Key: <key>
User-Api-Client-Id: <client_id>

返回 current_user.username 就说明 key 有效。

撤销

POST /user-api-key/revoke
User-Api-Key: <key>
User-Api-Client-Id: <client_id>
  • 站点没有 User API Key 的查看/撤销界面(偏好设置 → 安全性只有「最近使用的设备」)。[实测 2026-09-15]
  • 客户端必须自己持久化 key,并提供撤销;否则 key 变成无法管理的休眠凭据。
  • 如果授权后 payload 没被捕获,这个 key 无法解密,只能找站点管理员清理。

2.3 两种方式如何选择

维度 Cookie 会话 User API Key
登录 用户人工 SSO 用户网页点授权
凭据 Cookie(_forum_session/_t) key + client_id
写操作 需要 CSRF 不需要 CSRF
生命周期 站点会话;时长未长期验证 长期;Discourse 默认 180 天未用撤销,站点可改
管理 清 Cookie 即可 站点无管理界面,客户端必须自己撤销
适合 已有浏览器 Session 的桌面客户端 独立客户端、需要长期授权

3. 数据清单与端点

2026-09-15 用 read key 普查了 17 个端点(其中 16 个只读 + 1 个写端点 PUT /notifications/mark-read);除 mark-read 在 read scope 下 403 外,只读端点均 200。[实测]

端点 内容 备注
GET /session/current.json 当前用户对象(实测 89 字段):username、name、unread_notifications、unread_private_messages、admin 等 验证 key/会话
GET /latest.json 30 个 topic/页 + topic_list.more_topics_url 最新主题
GET /latest.json?page=N 数字页码翻页 page=1 是首页;more_topics_url 从 page=0 起算
GET /new.json 新帖流,30/页 结构同 latest
GET /unread.json 未读流,30/页 结构同 latest
GET /categories.json 13 个顶级分类、帖子计数、趋势计数、read_restricted 不含完整子树
GET /c/<slug>/<id>.json 分类过滤的主题列表,30/页 如 /c/campus-life/23.json
GET /site.json 83 个分类全树、31 个 groups、notification_types/post_types、top_tags(101 个) 完整分类树在这里
GET /tag/<中文名>.json 按标签过滤,30/页 如 /tag/精品.json;slug 形态 404
GET /tags.json 280 个 tag,按常用度排序 无计数
GET /search.json?q=... 50 条/页:topics[] + posts[].blurb + grouped_search_result.hit_count 搜索分页用 page
GET /t/<id>.json 主题详情、post_stream.stream、每楼 cooked、details.participants、suggested_topics、thumbnails、timeline_lookup 60 字段/楼
GET /t/<id>/<post_number>.json 目标楼前后 20 楼 + 全量 stream 楼层深链
GET /t/<id>/posts.json?post_ids[]=... 指定楼层 长帖分段加载
GET /notifications.json?recent=true 15 条通知:notification_type、read、触发者、主题引用 类型实测 {2,5,6,12,14,19}
PUT /notifications/mark-read 标记通知已读 需要 write;read scope 下 403
GET /topics/private-messages/<me>.json 私信会话列表,archetype=private_message 条目含 allowed_user_count、participants
GET /u/<me>/bookmarks.json 本人收藏,含 excerpt、reminder_at、bookmarkable_url、tags、last_read_post_number 可直接渲染
GET /u/<me>/summary.json 阅读画像:days_visited、time_read、topics_entered、posts_read_count、top_categories、badges 个人统计
GET /u/<me>.json 完整个人资料(90 字段),含 email、trust_level、user_api_keys、user_auth_tokens、groups、user_option 敏感,谨慎使用
GET /about.json 全站统计、版本、管理团队 公开统计
GET /session/csrf CSRF token Cookie 写操作前置

4. 字段与语义

topic

  • id 稳定唯一;来源链接可用 /t/<id> 构造。
  • tags 元素是对象 {id, name, slug},不是字符串;映射取 .name。[实测]
  • posters[] 的 description 形如 Original Poster, Most Recent Poster;楼主取包含 Original Poster 的那位,缺标注时取第一位。
  • topic_list.users[] 是 posters[].user_id 的查表来源;用户名/头像在这里。
  • last_posted_at 可用于增量对比;API 没有稳定的 change cursor。

post

  • cooked 是服务端消毒后的 HTML;仍需在你的渲染层做白名单清洗。
  • 点赞数不在顶层字段,在 actions_summary[] 里 id=2 的条目:{count, acted, can_act};acted 是当前用户是否已赞,count=0 时条目可能缺省。[实测 2026-09-21]
  • reply_to_post_number 表示楼层级回复。
  • yours 表示是否本人。

分类与标签

  • categories.json 只有 13 个顶级分类;?include_subcategories=true 不生效。完整 83 个分类树在 site.json。[实测]
  • 分类过滤端点 /c/<slug>/<id>.json;slug 从 site.json/categories.json 取。
  • 标签过滤端点 /tag/<中文名>.json;侧栏的 11-tag slug 形态 404。[实测]
  • latest.json?tag=X 和 c/<slug>/<id>.json?tag=X 不会过滤;分类和标签只能互斥使用。[实测]
  • 分页跟随 more_topics_url;tag 列表的 more URL 形如 /tag/精品?match_all_tags=true&page=1&tags[]=精品,路径加 .json 才是下一页。

搜索

  • search.json?q= 返回 topics[]、posts[](含 blurb 摘要)、grouped_search_result.hit_count。
  • 分页用 page,不是 more_topics_url;grouped_search_result.more_full_page_results 表示还有下一页。
  • 搜索摘要质量足够直接展示;正文仍要进主题页读。

通知

  • notification_type 是 Discourse 标准枚举;实测出现 {2,5,6,12,14,19}。
  • recent=true 只返回有限一页;不在列表里不代表已读。不要根据「不在 recent 列表」把本地通知标成已读。
  • PUT /notifications/mark-read 存在;read scope 下 403,属权限不足而非不存在。

私信 / 收藏 / 个人资料

  • 私信列表:/topics/private-messages/<username>.json,archetype=private_message。
  • 收藏:/u/<username>/bookmarks.json;创建 POST /bookmarks,body {bookmarkable_id, bookmarkable_type:"Topic"};删除 DELETE /bookmarks/<id>。
  • 个人资料 /u/<me>.json 含 email、user_api_keys 等敏感字段;只取需要的,不要把整包发给模型。

5. 分页、限流、错误

分页

  • 主题列表:30/页;跟随 topic_list.more_topics_url,不要自己拼 page。
  • more_topics_url 可能是相对路径、带 query、带 page=0;用 URL 解析,限制同源,路径补 .json。
  • 搜索:50/页,page 参数;more_full_page_results 判断是否继续。
  • 长主题:先读 post_stream.stream(楼层 id 列表),再用 post_ids[] 分段拉。
function nextTopicsPath(moreUrl: string | null): string | null {
  if (!moreUrl) return null;
  const url = new URL(moreUrl, 'https://shuiyuan.sjtu.edu.cn');
  if (url.origin !== 'https://shuiyuan.sjtu.edu.cn') throw new Error('分页地址无效');
  return `${url.pathname.replace(/\.json$/, '')}.json${url.search}`;
}

限流

  • 响应未暴露 X-RateLimit-*;Discourse 只在触发限流时返回 Retry-After,阈值未知。[实测]
  • 轮询保持分钟级以上间隔,做指数退避;不要高频深分页。
  • 写操作后不要立刻重复提交;点赞重复会 403。

错误

状态 含义 处理
403 not_logged_in 未登录/会话失效 重新登录
403 其他 可能是 key 失效、缺 write scope、或重复操作 先看 body errors;不要一律重新授权
404 主题/楼层/端点不存在 检查 id;tag slug 形态会 404
429 限流 读 Retry-After,退避
5xx 站点临时错误 重试一次,保留缓存

403 是水源最容易误判的状态:它同时覆盖「没登录」「key 无效」「scope 不够」「重复点赞」「不能取消点赞」。必须结合请求和 body 判断。


6. 写操作

已验证(write scope,2026-09-15 / 09-21):

操作 请求 结果
创建书签 POST /bookmarks,{bookmarkable_id, bookmarkable_type:"Topic"} 200 [实测 2026-09-15]
删除书签 DELETE /bookmarks/<id> 200 [实测 2026-09-15]
点赞楼层 POST /post_actions.json,{id:<post_id>, post_action_type_id:2} 200 [实测 2026-09-21]
重复点赞 同上 403「你已经执行过此类操作」[实测 2026-09-21]
取消点赞 DELETE /post_actions/<post_id>.json?post_action_type_id=2 403;write scope 不含 post_actions#destroy [实测 2026-09-21]

代码已实现、但未实测(端点形状来自 src/main/shuiyuan/api.ts,不要当契约):

操作 请求 状态
回复主题 POST /posts.json,{topic_id, raw, reply_to_post_number?} 未实测;代码按 201 处理
发私信 POST /posts.json,{raw, title, archetype:"private_message", target_usernames:[...]} 未实测
标记通知已读 PUT /notifications/mark-read 未实测;read scope 下 403 已观察

未验证/不可用:

  • POST /uploads.json(Composer 附件,multipart:type=composer + files[])未实测;站点若禁用或调整大小限制,以 413/403/422 暴露。
  • discourse-reactions 插件未安装;表情回应没有第三方 REST 端点。[实测 2026-09-21]
  • 发帖、编辑、删除、私信已读等写端点未逐一验证;不要因为「write scope」就假设全部可用。

写操作原则:

  • 用户确认后再发;不要自动回复/自动私信。
  • 点赞要幂等:重复点赞 403 视为「已赞」,不要报错。
  • 取消点赞只能引导用户去网页端。
  • 不要在公共帖子/私信里泄露他人信息。

7. 常见问题

  1. 匿名什么都拿不到:login_required,Googlebot/RSS 也被拦。不要设计「先匿名抓公开数据」的降级。
  2. User API Key 不等于站点许可:水源协议要求书面许可。先确认许可,再开发。
  3. 站点没有 key 管理界面:payload 丢了就是休眠 key;客户端必须自己撤销。
  4. 403 多义:没登录、key 失效、scope 不够、重复点赞、不能取消点赞,全是 403。看 body,不要一律重新授权。
  5. write 不含取消点赞:点赞前想清楚;取消只能网页端。
  6. 标签用中文名:/tag/精品.json 可以,slug 形态 404。
  7. 分类+标签不能组合过滤:?tag= 被忽略;要么分类,要么标签。
  8. categories.json 只包含顶级分类:完整分类树在 site.json;include_subcategories=true 不生效。
  9. tags 是对象:按字符串处理会得到 [object Object]。
  10. 点赞数在 actions_summary:不在顶层;count=0 时条目可能缺省。
  11. more_topics_url 要规范化:补 .json、限制同源、处理 page=0。
  12. _t 生命周期未长期验证:不要假设永久;每次使用前探活。
  13. 媒体也受登录保护:帖子图片匿名请求会破图;用带会话/API key 的代理加载,并校验 MIME。
  14. AI 摘要标志不代表接口可用:summarizable/has_cached_summary 只说明站点启用了相关能力,摘要端点尚未验证。
  15. 不要高频轮询:阈值未知;分钟级 + 退避。

8. 最小示例

生成授权链接(Node)

import { generateKeyPairSync, randomBytes } from 'node:crypto';

const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const clientId = randomBytes(16).toString('hex');
const nonce = randomBytes(16).toString('hex');

const url = new URL('https://shuiyuan.sjtu.edu.cn/user-api-key/new');
url.search = new URLSearchParams({
  scopes: 'read,write',
  application_name: 'MyCampusTool',
  client_id: clientId,
  nonce,
  public_key: publicKey.export({ type: 'spki', format: 'pem' }).toString().trim(),
}).toString();

console.log(url.href);
// 私钥 privateKey 只留在本机;授权完成后用它解密 payload

解密并验证

import { privateDecrypt, constants } from 'node:crypto';

const payload = '<用户粘贴的 base64>';
const json = privateDecrypt(
  { key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), padding: constants.RSA_PKCS1_PADDING },
  Buffer.from(payload.replace(/\s+/g, ''), 'base64'),
).toString('utf8');
const { key, nonce: returned } = JSON.parse(json) as { key: string; nonce: string };
if (returned !== nonce) throw new Error('nonce 不匹配');

const me = await fetch('https://shuiyuan.sjtu.edu.cn/session/current.json', {
  headers: { 'User-Api-Key': key, 'User-Api-Client-Id': clientId },
});
console.log(await me.json());

读最新主题

curl 'https://shuiyuan.sjtu.edu.cn/latest.json' \
  -H "User-Api-Key: $SHUIYUAN_KEY" \
  -H "User-Api-Client-Id: $SHUIYUAN_CLIENT_ID"

读主题正文

curl 'https://shuiyuan.sjtu.edu.cn/t/518172.json' \
  -H "User-Api-Key: $SHUIYUAN_KEY" \
  -H "User-Api-Client-Id: $SHUIYUAN_CLIENT_ID"

9. 不要做

  • 不要在没有书面许可的情况下发布第三方水源客户端。
  • 不要批量抓取全站、深分页扫楼、把内容重新分发。
  • 不要自动发帖/自动私信/自动点赞;写操作要用户确认。
  • 不要把私信、邮箱、user_api_keys 等敏感字段发给模型或写进日志。
  • 不要做「绕过登录」「伪造 UA」「破解验证码」。
  • 不要把 User API Key 写进前端、URL、Git 仓库。

10. 证据与来源

  • 站点版本、login_required、匿名 403、SSO 跳转、Cookie、User API Key 流程、17 个端点:[实测 2026-09-13/14/15,内部实测记录未入库;实现见 Dela src/main/shuiyuan/*]
  • write scope、书签增删、CSRF 差异、key 无管理界面:[实测 2026-09-15]
  • 点赞/取消点赞/discourse-reactions:[实测 2026-09-21]
  • 水源社区协议:[官方 2026-10-25] https://shuiyuan.sjtu.edu.cn/tos
  • Discourse User API Key 规范:[上游 2026-10-25] https://meta.discourse.org/t/user-api-keys-specification/48536

变更记录

  • 2026-10-25:首版。合并代码实现与两份实测记录;补 User API Key 生命周期、写操作边界、ToS 警告。

Clone this wiki locally