Repository navigation
Shuiyuan
| 项 | 值 |
|---|---|
| 平台 | 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/writescope 的 User API Key。 - 注意:站点没有 User API Key 管理界面,key 丢失后只能联系管理员;
writescope 不含取消点赞;匿名请求、RSS 和爬虫均被拦截。
| 事项 | 实测结论 |
|---|---|
| 框架 | 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]
适合:你已经在做桌面客户端,且能维护一个持久 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 回登录页;具体形态待多天观察。 - 不要脚本化验证码;不要保存用户密码。
这是 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 无法解密,只能找站点管理员清理。
| 维度 | Cookie 会话 | User API Key |
|---|---|---|
| 登录 | 用户人工 SSO | 用户网页点授权 |
| 凭据 | Cookie(_forum_session/_t) |
key + client_id |
| 写操作 | 需要 CSRF | 不需要 CSRF |
| 生命周期 | 站点会话;时长未长期验证 | 长期;Discourse 默认 180 天未用撤销,站点可改 |
| 管理 | 清 Cookie 即可 | 站点无管理界面,客户端必须自己撤销 |
| 适合 | 已有浏览器 Session 的桌面客户端 | 独立客户端、需要长期授权 |
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 写操作前置 |
-
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。
-
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-tagslug 形态 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等敏感字段;只取需要的,不要把整包发给模型。
- 主题列表: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 判断。
已验证(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 视为「已赞」,不要报错。
- 取消点赞只能引导用户去网页端。
- 不要在公共帖子/私信里泄露他人信息。
-
匿名什么都拿不到:
login_required,Googlebot/RSS 也被拦。不要设计「先匿名抓公开数据」的降级。 - User API Key 不等于站点许可:水源协议要求书面许可。先确认许可,再开发。
- 站点没有 key 管理界面:payload 丢了就是休眠 key;客户端必须自己撤销。
-
403多义:没登录、key 失效、scope 不够、重复点赞、不能取消点赞,全是 403。看 body,不要一律重新授权。 -
write不含取消点赞:点赞前想清楚;取消只能网页端。 -
标签用中文名:
/tag/精品.json可以,slug 形态 404。 -
分类+标签不能组合过滤:
?tag=被忽略;要么分类,要么标签。 -
categories.json只包含顶级分类:完整分类树在site.json;include_subcategories=true不生效。 -
tags是对象:按字符串处理会得到[object Object]。 -
点赞数在
actions_summary:不在顶层;count=0时条目可能缺省。 -
more_topics_url要规范化:补.json、限制同源、处理page=0。 -
_t生命周期未长期验证:不要假设永久;每次使用前探活。 - 媒体也受登录保护:帖子图片匿名请求会破图;用带会话/API key 的代理加载,并校验 MIME。
-
AI 摘要标志不代表接口可用:
summarizable/has_cached_summary只说明站点启用了相关能力,摘要端点尚未验证。 - 不要高频轮询:阈值未知;分钟级 + 退避。
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 只留在本机;授权完成后用它解密 payloadimport { 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"- 不要在没有书面许可的情况下发布第三方水源客户端。
- 不要批量抓取全站、深分页扫楼、把内容重新分发。
- 不要自动发帖/自动私信/自动点赞;写操作要用户确认。
- 不要把私信、邮箱、
user_api_keys等敏感字段发给模型或写进日志。 - 不要做「绕过登录」「伪造 UA」「破解验证码」。
- 不要把
User API Key写进前端、URL、Git 仓库。
- 站点版本、
login_required、匿名 403、SSO 跳转、Cookie、User API Key 流程、17 个端点:[实测 2026-09-13/14/15,内部实测记录未入库;实现见 Dela src/main/shuiyuan/*] -
writescope、书签增删、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 警告。
本 Wiki 记录的是开发实践,不是学校官方承诺。每个结论都标注证据与日期;校园平台会调整字段、权限和登录方式,动手前请按页面里的「最后验证」复核。发现过期内容请提 Issue 或直接改。