文档索引

博客发文机器人接入说明

面向站外开发者。读完本文即可实现一个发文机器人,无需阅读本站源码。

本文是发文接口的唯一对外口径。字段、长度上限、限频数值、错误码均照实抄录, 若与源码不符以源码为准(但也请提 issue —— 那就是本文过期了)。

0. 一句话说清

没有专用的「机器人接口」。 机器人就是一个普通的登录账号, 走和对人完全一样的 HTTP 接口。你需要的只有四件事:

  1. 一个 core+ 账号(§2)—— 注册出来是 user,需要提权一次
  2. 拉一次栏目清单(§6)—— 拿到 category_id
  3. 一个 POST 发文章(§7)
  4. 想改就 PUT(§8);只想改「对不对外」用 PATCH(§8.1)

无需申请 token、无需白名单、无需站长开任何开关。

⚠️ 发文接口的请求体是「键集封闭」的:多传一个它不认识的键就返回 400, 不会静默忽略。这条规则是本文 §6.2 那个坑逼出来的,请先读那一段再动手。

⚠️ 文章默认「仅站内可见」(internal)。 想让站外的人(未登录访客)也读得到, 得显式传 visibility(§7.1)—— 这个接口不是「发出去就等于公开」。 别把「发出去了」当成「公开了」:默认档下访客打开链接看到的是登录页。


1. 能力边界(先读这段)

范围 能读 能写 说明
发新文章 —— ✅ POST /api/blogs,需 core+ 且未被禁言
改 / 删自己的文章 —— ✅ 仅作者本人,别人改不了(§8)
决定文章对不对外 —— ✅ 发文 / 改文时传 visibility(三档,见 §7.1);默认 internal(仅站内)
读任意文章(含正文) ✅ ❌ 需 core+ 登录(§9)
读栏目清单 ✅ ❌ GET /api/categories,需 core+ 登录(§6)
改 / 删别人的文章 ✅ ❌ 那是管理员的路,机器人没有这条路
管理栏目(增删改) ❌ ❌ 只有站长能建栏目,机器人只能选

机器人的权限边界是三条:core+ 角色、登录状态、是否被禁言。

⚠️ 机器人享受不到任何豁免:它同样会被禁言(被禁言期间发文返回 403),也同样受每日 20 篇的限额约束(§10)。这是刻意设计—— 出问题时站长能像处理普通用户一样处理它。

⚠️ 而且机器人发的文章会产生真实后果:创建即入库、立即可见(没有审核队列, 也没有草稿态),还可能给全站管理员发通知(§11)。这是一个会对真人产生后果的接口。

⚠️ 「可见」是两个层次,别混为一谈: · 站内可见 —— core+ 用户都能读到。这是发文的下限,任何文章都是这样。 · 对外可读 —— 未登录访客也能打开(link / public),顺带决定进不进 sitemap、允不允许搜索引擎索引(§7.1 的表)。 新文章默认 internal:站内可见、对外打不开。


2. 准备账号

发文要求 core+ 角色(core / admin / owner 都行)。注册出来的账号是 user,需要站长提权一次 —— 注册、登录、提权的完整步骤与错误码见 comment-bot.md §2,三份机器人文档说的是同一件事,不重复抄。

📌 文档之间只在这一处互相引用:账号准备是三个机器人共用的前置步骤, 抄三份迟早会漂。其余部分本文都是自包含的。


3. 鉴权

除注册 / 登录外,每个请求都要带上会话 cookie:

POST /api/blogs
Cookie: raricy_session=<JWT>
Content-Type: application/json

raricy_session 是登录接口 Set-Cookie 回给你的那个值,照着存、照着带即可。

⚠️ 写请求(POST / PUT / DELETE)会校验来源,跨站会被拦成 403 跨源请求被拒绝 (CSRF)。用任何 HTTP 客户端都没问题, 但别在浏览器页面里用 fetch 跨站调它。

会话可能被失效(改密、被强制下线、被禁言 / 重置密码)—— 此时所有接口一律回 401 请先登录。机器人应当把 401 当成「重新登录」的信号,而不是重试。

⚠️ 被降权(core → user)不在其列:会话仍然有效,只是发文接口改回 403 只有核心用户才能发布文章。别当会话失效去重登 —— 重登也一样。


4. 响应信封与错误码

站内接口(/api/...)共用同一信封:

{ "code": 200, "message": "ok", ...其他数据字段 }

出错时 code 与 HTTP 状态码一致,且只有 code 和 message:

{ "code": 400, "message": "未知字段 \"category\",……" }
状态码 含义 常见 message
400 参数错误 标题不能为空 / 未知字段 "tags",本接口只接受:… / 选择的栏目不存在
401 未登录 / 会话失效 请先登录
403 角色不足 / 被禁言 / 该栏目仅管理员 / 跨源 只有核心用户才能发布文章 / 该栏目仅允许管理员发布文章
404 文章不存在(或已被软删) 文章不存在
429 触发限频 今日发布数量已达上限(20篇)

⚠️ 发文接口的档位文案是 只有核心用户才能发布文章 —— 与读接口的 需要核心用户权限 不是同一句。两处都照实收着,别只匹配其中一条。

⚠️ 站内接口大多带 Cache-Control: no-store(apiOk / apiErr 统一加), 但 §9 那两条读口(GET /api/blogs、GET /api/blogs/:id)是裸 Response.json, 不带这个头 —— 别把它们当可缓存的公共数据。


5. 时间戳格式

文章的时间戳是 ISO 8601 字符串带 Z 后缀:

"2026-09-11T20:31:05.000Z"

但那个 Z 是假的。 本站全库时间戳的语义是「UTC+8 墙上时间,贴 Z 标签」—— 上面这个例子表示的是北京时间 20:31:05,而不是 UTC 20:31:05。 正确与错误的解析写法见 comment-bot.md §5,全站的坑是同一个。


6. 取栏目(⚠️ 发文前必须先做这一步)

6.1 栏目清单

GET /api/categories
Cookie: raricy_session=<JWT>
{
  "code": 200,
  "message": "ok",
  "categories": [
    {
      "id": 3,
      "name": "技术",
      "slug": "tech",
      "icon": "💻",
      "parent_id": null,
      "path": "技术",
      "admin_only_posting": false
    },
    {
      "id": 7,
      "name": "前端",
      "slug": "frontend",
      "icon": "",
      "parent_id": 3,
      "path": "技术 > 前端",
      "admin_only_posting": false
    }
  ]
}
字段 说明
id 发文时要填进 category_id 的那个数
name 栏目名(展示用)
slug URL 用的短名,发文时不要用它(见 §6.2)
icon emoji 图标,可能是空串
parent_id 父栏目 id;顶层栏目为 null
path 完整路径,父 > 子;顶层就是栏目名本身
admin_only_posting true = 这个栏目只有管理员能发,机器人发过去必被 403

清单是摊平的(两层树摊成一张表),顺序即站点展示顺序。

✅ 停用(isActive=false)的栏目不会出现在清单里 —— 所以你从这份清单里 挑出来的 id,一定能过服务端那道「栏目存在且启用」的校验。

✅ admin_only_posting 给的是生效值:父栏目勾了「仅管理员可发」, 子栏目也会是 true。照着它挑,就不会发出去才收一个 403。

6.2 category_id 与 category 是两个东西(最容易踩)

这是本站最容易踩的一处字段名不对称:

场景 字段名 值
发文(POST /api/blogs) category_id 栏目数字 id
筛列表(GET /api/blogs) category 栏目slug 字符串

把 category(或 categoryId、category_name 之类)传进发文接口,不会生效 —— 服务端不认识这个键。在 2026-09 之前它会被静默忽略:请求照样回 200 上传成功,文章却落进「未分类」,响应里看不出任何异常。

现在不会了:发文接口对未知字段直接 400,并且对栏目近义键会指路:

{
  "code": 400,
  "message": "未知字段 \"category\":栏目要传 category_id(栏目数字 ID,清单见 GET /api/categories)"
}

所以:

  • 看到 未知字段 "…" → 你键名写错了,改键名,别重试
  • 栏目字段只认 category_id,值是 §6.1 里的 id(数字)
  • category_id 也可以传数字字符串("3"),服务端按整数解析
  • 不传 / 传空串 / 传 null / 传 0 = 未分类,这是合法的,不是错误

7. 发文章

POST /api/blogs
Cookie: raricy_session=<JWT>
Content-Type: application/json

{
  "title": "标题",
  "description": "一句话摘要",
  "content": "# 正文\n\nMarkdown 源文",
  "category_id": 7
}

7.1 请求字段

只有这 5 个键。多一个都会被 400 顶回来。

字段 类型 必填 说明
title string 是 标题,去空白后不能为空
description string 是 摘要 / 简介,去空白后不能为空
content string 是 正文,Markdown 源文,不 trim(原样入库)
category_id number | string 否 栏目数字 ID(§6);不传 = 未分类
visibility string 否 对外可见性三档(见下);不传 = internal(仅站内)
长度上限 值 计数口径
title 30 字 trim() 之后
description 100 字 trim() 之后
content 250000 字 原样,不 trim

⚠️ 恰好等于上限放行,超一个字符就 400。

⚠️ title / description 传成非字符串(数字、对象)会被按空串处理 → 报 标题不能为空,而不是「类型错误」。这是有意为之:字段名对不上就当场报, 类型对不上按缺失算。

visibility 的三档(取值就是这三个字符串,别传别的):

档 谁能读 进 sitemap 允许搜索引擎索引
internal(默认) 只有站内 core+ ❌ ❌
link 拿到链接的任何人(含未登录访客) ❌ ❌
public 拿到链接的任何人 ✅ ✅

⚠️ 不传 = internal(创建时)。「文章发出去了」只代表站内能看, 对外打不开 —— 访客拿到的是登录页。想对外就显式传。

⚠️ 写错了不会静默:不在上面三档里的值("pulbic" 这种拼错、以及 true、 1 这类非字符串)一律 400 可见性取值不合法,可选:internal / link / public。 这是刻意的 —— 静默按默认档处理会让你以为发出去了,其实没人看得到。

⚠️ 但有一个例外:""(空串)与 null 会被当作「没传」→ 落成默认档 internal。JSON 里写出空串多半就是写错了,所以别用空串表示「不指定」 —— 要么省掉这个键,要么显式给三档之一。(新增的 PATCH 不吃这一套,见 §8.1。)

⚠️ link 与 public 只差「可不可被索引」,可读性是一样的。 不想被搜索引擎收录、只想给拿到链接的人看,就选 link。

⚠️ 改回 internal 收不回已经被抓走的副本。 搜索引擎与第三方存档(以及社交 平台的卡片缓存)可能已经存下了一份 —— 这条是不可逆的,§11 再说一次。

设为 public 之后,这篇文章会出现在这些地方(link 不会):

出口 说明
/explore 站内的对外公开列表页,免登录可看;只列 public 档(与 sitemap 同一集合)
sitemap 允许搜索引擎收录(link 不进)
分享卡片 GET /api/og/blog/:id 一张 PNG,免登录;link / public 都回 200,internal / 已软删 / 不存在三者同形 404

📌 这三条是给站外的人看的入口,机器人自己不需要调它们 —— 但要知道传了 public 就等于把这篇推到了这些地方。反过来,想给「拿到链接的人」看又不被 搜索引擎收走,选 link。

7.2 成功响应

{
  "code": 200,
  "message": "上传成功",
  "blog_id": "9f1c8e2a-…",
  "redirect": "/blog/9f1c8e2a-…"
}

✅ blog_id 是 UUID,不是自增数字。发完请把它存下来 —— 改文章(§8)要靠它。

✅ 文章创建即可见(站内 core+ 立刻读得到),没有审核队列,也没有草稿态。 对外是否可读由 visibility 决定(不传 = 仅站内,§7.1)。

⚠️ 响应里不回声 visibility。想确认它落成了哪一档,改完 GET /api/blogs/:id 看 blog.visibility(§9)。

7.3 会被拒绝的情形

情形 返回
未登录 / Cookie 失效 401 请先登录
账号不是 core+ 403 只有核心用户才能发布文章
账号被禁言 403 您已被禁言,无法执行此操作。剩余约…。原因:…
跨站调用 403 跨源请求被拒绝 (CSRF)
标题 / 摘要 / 正文为空 400 标题不能为空 / 描述不能为空 / 内容不能为空
超过长度上限 400 标题不能超过30个字符 / 描述不能超过100个字符 / 内容不能超过250000个字符
传了不认识的键 400 未知字段 "…",本接口只接受:title / description / content / category_id / visibility
category_id 不是整数 400 栏目ID格式错误
category_id 不存在或已停用 400 选择的栏目不存在
visibility 不在三档里 400 可见性取值不合法,可选:internal / link / public
该栏目仅管理员可发(含父栏目勾选) 403 该栏目仅允许管理员发布文章
触发每日限额 429 今日发布数量已达上限(20篇)

校验顺序是固定的,一次只报第一条:未知字段 → 可见性 → 必填 → 长度 → 栏目格式 / 存在性。 所以「空标题 + visibility 拼错」报的是可见性那条;改完才会看到「标题不能为空」。


8. 改文章 / 删文章

PUT /api/blogs/:id
Cookie: raricy_session=<JWT>
Content-Type: application/json

{ "title": "新标题", "description": "新摘要", "content": "新正文", "category_id": null, "visibility": "link" }
  • 请求字段与 §7.1 完全一致(同样只认那 5 个键,同样那几档长度上限)
  • 是整体覆盖,不是局部更新 —— 想把文章改成未分类,就显式传 category_id: null
  • 成功:{ "code": 200, "message": "更新成功", "blog_id": "…", "redirect": "/blog/…" }

⚠️ visibility 是这条「整体覆盖」规则唯一的例外:不传 = 不改动这一档。 也就是说,一个只会传旧那 4 个键的调用方改一次标题,不会把文章打回 internal(否则就是一次静默下架:对外消失、退出 sitemap)。 想改档位就显式传(传 "internal" 就是真的要改回仅站内)。

📌 这条豁免只对 PUT 有效。POST(创建)没有「原值」可保持,不传就是 默认档 internal(§7.1)—— 两条路径的缺省语义相反,别一起记。

情形 返回
不是作者本人 403 无权编辑该文章
文章不存在 / 已被软删 404 文章不存在
其余 与 §7.3 同一批文案

8.1 只改可见性:PATCH /api/blogs/:id

PATCH /api/blogs/:id
Cookie: raricy_session=<JWT>
Content-Type: application/json

{ "visibility": "link" }

只认 visibility 这一个键 —— 多传任何一个键都 400。想改档位就用它,别用 PUT: PUT 是整体覆盖,为了改一列得把标题、摘要、正文全部回传,既有丢更新的竞态 (另一个标签页刚改过标题就会被这次覆盖掉),又把正文白往返一遍。

  • 权限与 PUT 逐条相同:core+ + 作者本人(管理员同样改不了别人的文章)+ 禁言检查
  • changed 会告诉你到底动没动:
{ "code": 200, "message": "可见性已从「仅站内可见」改为「对外公开」",
  "visibility": "public", "changed": true }

目标档与当前相同时 message 是 可见性没有变化、changed: false(不是固定的成功文案)。

  • ⚠️ 取值必须恰好是三档之一,空串也 400 —— 它刻意不套用 POST / PUT 那套 「空串 = 没传 = internal」的归一化(§7.1):那条规则是给「旧客户端压根没提这件事」用的, 而在这条窄接口上显式传空串几乎一定是写错了,静默当成「改成站内」会让人在文章 从公网消失之后才发现。
情形 返回
不是作者本人 403 无权编辑该文章
文章不存在 / 已被软删 404 文章不存在
多传了别的键 400 本接口只接受 visibility,多传了:…
取值不在三档(含空串) 400 可见性取值不合法,可选:internal / link / public
DELETE /api/blogs/:id
  • 同样仅作者本人(非作者 403 无权删除该文章)
  • 成功:{ "code": 200, "message": "文章已删除", "blog": { "id": "…", "ignore": true } }
  • ⚠️ 这是软删除:站点永不物理删除任何内容。删掉的文章对读者等同不存在 (一律 404),但不要把它当成「可以反复清理重建」的机制 —— 复用同一个标题反复删建,在读者与审计侧都看得到痕迹。

9. 读文章

GET /api/blogs/:id          # 单篇(含 Markdown 正文)
GET /api/blogs?page=1       # 列表

两条都需 core+ 登录,与站内页面同档 —— 本站没有「只读豁免」, 机器人模型统一是「一个 core+ 账号 + 会话 cookie」。

单篇返回的关键字段:

{
  "code": 200,
  "blog": {
    "id": "9f1c8e2a-…",
    "title": "标题",
    "description": "摘要",
    "author": "alice",              // 作者用户名,可能为 null
    "author_id": "u_xxx",
    "created_at": "2026-09-11T20:31:05.000Z",   // ⚠️ 假的 Z,见 §5
    "likes_count": 0,
    "comments_count": 0,
    "fish_count": 0,
    "category": "前端",              // 栏目名,未分类为 null
    "category_path": "技术 > 前端",   // 完整路径
    "content": "# 正文…",            // Markdown 源文
    "visibility": "internal"          // 对外可见性三档(§7.1)
  }
}

列表(GET /api/blogs)的查询参数:

参数 说明
page 页码,从 1 起
per_page 每页条数,clamp 到 1..50;不传走服务默认
category 按栏目筛,⚠️ 传 slug(§6.2 的不对称就在这)
featured 1 = 只看精选,0 = 只看非精选,不传 = 不筛
search 关键词
search_fields 搜哪些字段,逗号分隔:title / description / author / content;未知字段名直接 400;不传 = 标题 / 摘要 / 作者名
sort updated = 按更新时间;其余一律按发布时间

📌 带 search_fields=content(搜正文)会计入另一条限频(§10): 正文搜索是全表扫描,是重活。


10. 限频

规则 额度 适用
发文 20 篇 / 本站当日 POST /api/blogs(按作者计,写入与校验之间判)
改文章 无限频 PUT /api/blogs/:id
读文章 / 读栏目 无限频 §6、§9(搜正文那档除外)
搜正文(search_fields=content) 30 次 / 分钟 GET /api/blogs,与 /blog 页面共用同一个预算
登录 15 分钟内失败 300 次 / IP、100 次 / 用户名 仅统计失败

超限一律 429。

⚠️ 「当日」按本站时区(UTC+8)的零点切分,不是 UTC 零点,也不是你的本地时区。

⚠️ 发文没有分钟级限额,只有每天 20 篇 —— 意味着一台失控的机器人 可以在 1 分钟内把 20 篇全发完,而不是被平滑地限速。请务必在机器人侧 自己做节流,并在启动前先确认这次真的要发(§11)。

限频计数存在服务端(进程内计数桶,定期落盘快照),重启不会清零 —— 别指望靠重启洗掉自己的用量。但它也不是持久化账本,不要拿它做用量统计。


11. 礼仪与约定

  • 文章是公开内容。 发出去就被全站 core+ 用户看到,且会进博客列表与搜索。 机器人发文前请自问一句:这篇东西值得占一个位置吗?
  • 对外公开是不可逆的。 visibility: "public" 之后,搜索引擎与第三方存档 可能已经抓走副本 —— 改回 internal 收不回那些副本,社交平台的卡片缓存也要 数周才过期。所以「要不要对外」发布前就定下来,别先公开看看效果再收回。
  • 别把接口当存储用。 一天 20 篇的额度不是用来做数据同步的 —— 要存结构化数据请用别的机制,博客区不是你的数据库。
  • 选栏目要选对。 发到不相关的栏目比不选栏目更糟:读者按栏目订阅, 错发会被当成垃圾信息。
  • 发到「仅管理员可发」的栏目只会拿到 403 —— 用 §6.1 的 admin_only_posting 提前筛掉。
  • 通知是真的。 某些栏目开了「发文提醒管理员」:你在那些栏目发文, 全站管理员与站长都会收到一条通知(说的是「用户 X 在栏目 Y 发布了新文章:标题」)。 别用高频发文去刷管理员的通知列表。(发文者本人若也是管理员,他收不到自己那条。)

12. 排错速查

现象 多半是
400 未知字段 "category" §6.2:栏目字段叫 category_id,值是数字 ID,不是 slug
400 未知字段 "tags" 键集封闭:只认 title / description / content / category_id / visibility
400 可见性取值不合法… visibility 不在 internal / link / public 三档里 —— 多半是拼错了(§7.1)
文章发出去,但访客打开是登录页 默认档就是仅站内(internal)。要对外得显式传 visibility(§7.1)
改了标题之后,文章突然对外打不开 调用方没传 visibility。现在的语义是「不传 = 不改档」,所以这不该再发生 —— 若仍出现,请提 issue(§8)
想让文章不被搜索引擎收录 选 link 而不是 public:两者可读性相同,只有 public 进 sitemap、允许索引(§7.1)
400 选择的栏目不存在 栏目 ID 抄错,或该栏目已被停用 —— 重新拉一次 §6.1 的清单
400 标题不能为空 标题是空串、或传成了非字符串。拼错的字段名(titel)不会走到这条 —— 键集封闭会先报 未知字段 "titel"
403 只有核心用户才能发布文章 账号还是 user,需要站长提权一次(§2)
403 该栏目仅允许管理员发布文章 换个栏目,或看 §6.1 的 admin_only_posting
403 跨源请求被拒绝 (CSRF) 你在浏览器里跨站调了它(§3)
429 今日发布数量已达上限(20篇) 等本站时区(UTC+8)的下一个零点
响应 200,但文章是「未分类」 更新前的老接口会这样;现在同情形会报 400 未知字段。若仍出现,请提 issue
时间整体差 8 小时 §5 的假 Z

13. 最小可用流程

// 1. 登录(步骤见 comment-bot.md §2),拿到 raricy_session
// 2. 拉栏目清单,挑一个机器人发得出去的
const cats = await (await fetch('https://raricy.com/api/categories', { headers: { cookie } })).json();
const target = cats.categories.find((c) => c.slug === 'tech' && !c.admin_only_posting);
if (!target) throw new Error('没有可发的栏目');

// 3. 发文 —— 注意是 category_id(数字),以及 visibility(决定对不对外)
const res = await fetch('https://raricy.com/api/blogs', {
  method: 'POST',
  headers: { cookie, 'content-type': 'application/json' },
  body: JSON.stringify({
    title: '标题',
    description: '摘要',
    content: '# 正文\n\n用 Markdown 写。',
    category_id: target.id,
    // 不传 = internal(仅站内可见);'link' = 拿到链接的人可读但不许索引;
    // 'public' = 对全互联网公开且可被搜索到(不可逆,见 §7.1)
    visibility: 'internal',
  }),
});
const body = await res.json();
console.log(body.code, body.message, body.blog_id);

📌 文档集里的机器人文档(chat-bot.md、comment-bot.md、vote-bot.md、 checkin-bot.md、like-feed-bot.md、image-bot.md、clipboard-bot.md、 account-bot.md、favorite-bot.md、fish-bot.md、fish-bank-example.md) 加上本文,覆盖讨论 / 评论 / 投票 / 签到 / 点赞投喂 / 图床 / 剪贴板 / 账号通知 / 收藏夹 / 鱼干 / 发文十一个方向;账号准备、鉴权、时间戳三件事各份说的是同一套。

本页内容来自仓库 docs/bot/blog-bot.md·在 GitHub 上查看