本插件为安知鱼(Anheyu)内容管理平台接入 W3C Webmention 协议:接收并验证其它站点对本站文章的引用,发文时向正文中的外部链接发送通知。
维护者:白夜
插件类型:
eventhook(订阅文章事件)+ 自带 HTTP 服务插件版本:
1.5.3(端口9801)
1. 概述
Webmention 是 W3C 的双向引用协议:A 站文章引用了 B 站,A 站向 B 站的接收端点 POST 一对 source/target,B 站抓取 A 站页面确认链接存在,再把这条引用展示在自己文章底部。
插件实现两个方向:
入站:
POST /api/webmention接收端点,验证来源页面确实链接了 target,套审核规则后落盘。出站:文章发布或更新时,提取正文外链,发现对方端点并发送通知。
管理界面与文章底部展示由插件自带的脚本注入,默认端口 9801,当前版本 1.5.2。
2. 功能特性
分类 | 能力 |
|---|---|
入站接收 |
|
来源验证 | 抓取 source 页面, |
语义类型 | 解析 microformats: |
出站发送 | 订阅 |
端点发现 | W3C 三级回退 + |
审核 | 域名黑名单、敏感词、白名单快速通道、自动通过开关 |
管理接口 | mention 列表与审核、出站日志、统计、导入导出、手动发送 |
公开展示 |
|
安全 | SSRF 拨号层拦截、管理员回源鉴权、按 IP 限流、存储条数上限 |
3. 工作原理
3.1 进程模型
插件是 Anheyu 主程序以 go-plugin 拉起的独立子进程,与主程序拥有同等系统权限。main() 先启动自带 HTTP 服务(goroutine),再 sdk.Serve 注册 eventhook 能力。运行期日志写 stderr,stdout 留给握手。
情况 | 行为 |
|---|---|
端口绑定失败 | 1s 起按指数退避重试,最长 30s 一次,进程不退出 |
存储初始化失败 | 数据类接口返回 500, |
3.2 鉴权链路
管理接口要求管理员身份,插件本身不持有 JWT 密钥:
PLAINTEXT
浏览器 ──(Authorization: Bearer <JWT>)──► 插件 /api/*
│
插件把该 Bearer 转发到后端 admin_check_path
│
HTTP 200 且响应体 code==200 → 管理员,放行
HTTP 401 → 提示重新登录;其余 → 403
免鉴权路径:/api/health、/api/login、/api/endpoint、/api/webmention、/api/public/*,以及两个注入脚本的加载器与指纹实现体。"通过"结果按 admin_cache_ttl(默认 30 秒)缓存。
3.3 出站发送
订阅 article.published 与 article.updated。send_enabled 默认 false,关闭时不创建队列,事件回调直接返回。
正文外链提取规则:
规则 | 处理 |
|---|---|
协议 | 仅 |
本站链接 | 排除属于 |
非页面链接 | 跳过 |
fragment | 去掉后再作为 target |
数量 | 保序去重后按 |
事件回调只做解析与入队,投递由后台队列完成:2 个 worker,队列长度 256,满则丢弃并记日志,不阻塞事件回调。发送前查重,同一文章对同一 target 已发送过则记为 skipped。
3.4 端点发现
按 W3C §3.1.2 顺序回退,前一级未命中才试下一级。第 4 级失败(404、不可达、空响应)静默返回未找到。
优先级 | 位置 | 说明 |
|---|---|---|
1 | HTTP |
|
2 | HTML | 读取响应体前 64KB |
3 | HTML | 同上 |
4 |
| 非规范兜底,请求页面最终地址所在站点根 |
4. 环境要求
Anheyu(社区版或 PRO)开启插件系统;App 版本 ≥
1.0.0。site_url必填,用于拼接文章绝对 URL 与判定 target 归属。接收端点需对公网可达,由外部 Nginx 或 CDN 反代到插件端口。
插件与后端在 Docker 内网可达,用于回源管理员校验。
文章页需声明接收端点,其它站点才能发现并向本站发送。
5. 安装
后台进入「插件管理」。
上传安装包
webmention-hub.zip(内含plugin.json与各平台二进制)。安装成功后状态变为「运行中」。升级同 ID 插件会保留既有配置与禁用状态。
安装包内
plugin.json必须位于压缩包根目录。
BASH
cd plugins/webmention-hub
./build.sh # 交叉编译 5 平台并产出 webmention-hub.zip
6. 快速开始
填
site_url,例如https://blog.example.com。这是唯一必填项。反代接收端点到
9801,保留前缀(proxy_pass结尾不带斜杠):
NGINX
location /webmention/ { proxy_pass http://127.0.0.1:9801; }
在文章页声明接收端点:
HTML
<link rel="webmention" href="https://blog.example.com/webmention/api/webmention" />
需要向别人发送通知时开启
send_enabled。建一个自定义页面挂载管理控制台,见 §9.2。
7. 配置参数参考
以下参数在 plugin.json 的 config_schema 中声明,由后台保存后以环境变量注入插件进程。
参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| number |
| 插件 HTTP 服务端口。仅内网可达,禁止公网暴露。 |
| string | 空 | 监听地址。空=所有网卡;同机部署可填 |
| string |
| 用于管理员校验的后端基地址。 |
| string |
| "仅管理员可访问"的后端接口路径,返回 200 且业务码 200 视为放行。 |
| string | 空 | 站点根地址,用于拼接文章绝对 URL、判定 target 归属。必填。 |
| string | 空 | 接收端点的公网路径前缀,如 |
| string | 空 | mention 与发送日志落盘目录。留空则用安装目录之外的 |
| boolean |
| 是否启用出站发送。关闭时不创建发送队列。 |
| number |
| 单篇最多向多少个外链发送。 |
| boolean |
| 验证通过的入站 mention 是否直接放行展示。 |
| string | 空 | 来源域名黑名单,逗号分隔。 |
| string | 空 | 来源域名白名单,逗号分隔。非空时仅白名单可自动通过。 |
| string | 空 | 敏感词黑名单,逗号分隔,大小写不敏感。 |
| number |
| 接收端点与公开查询每来源 IP 每分钟请求上限。 |
| number |
| 管理员"通过"结果缓存秒数。 |
| boolean |
| 是否开启免鉴权的公开查询接口。关闭时返回 404。 |
| boolean |
| 是否由插件在根路径托管独立控制台页。关闭时两个注入脚本仍可取。 |
8. 使用指南
8.1 接收端点
POST /api/webmention,Content-Type: application/x-www-form-urlencoded,字段 source 与 target。成功返回 HTTP 202:
BASH
curl -i -X POST https://blog.example.com/webmention/api/webmention \
-d 'source=https://other.example/posts/hello' \
-d 'target=https://blog.example.com/posts/hi'
# 202
{ "code": 200, "message": "accepted", "data": { "id": "…", "status": "approved" } }
target 必须属于本站 host:等于 site_url 的 host,或与之互为子域。未配置 site_url 时不做这层过滤。
来源验证抓取 source 页面(超时 12 秒,读取上限 1MB),把页面内所有 <a href> 解析成绝对地址后与 target 等价比对:
规范化项 | 处理 |
|---|---|
scheme / host | 统一小写 |
端口 |
|
路径尾斜杠 | 裁掉多余尾斜杠,根路径 |
fragment | 忽略空片段 |
query | 保留 |
规范比对未命中时,target 的等价写法(原样、去尾斜杠、https/http 互换)以明文出现且前方 60 字符内含 href,也算命中。
8.2 审核规则
按顺序判定,先命中先返回:
顺序 | 规则 | 结果 |
|---|---|---|
1 | 来源域名命中 |
|
2 | 文本命中 |
|
3 |
|
|
4 |
|
|
5 | 白名单为空且 |
|
6 | 其余 |
|
域名匹配为"相等或子域":example.com 命中 example.com 与 a.example.com,不命中 notexample.com。
状态 | 含义 | 前台可见 |
|---|---|---|
| 待审核 | 否 |
| 已通过 | 是 |
| 已拒绝 | 否 |
| 命中拦截规则 | 否 |
同一对 (source, target) 重复投递不新增记录,只刷新内容与更新时间;已有的人工 rejected / spam 不会被覆盖。
8.3 出站发送
只需开启 send_enabled。发布或更新文章后自动提取正文外链并投递。只订阅 published 与 updated,因为 article.created 多对应草稿,此时文章页还没有对外地址。
状态 | 含义 |
|---|---|
| 对方返回 2xx/3xx,已受理 |
| 目标页面未声明 webmention 端点 |
| 对方返回 4xx/5xx,或网络错误 |
| 同一文章对同一 target 已发送过 |
结果记录在 GET /api/sent,不会自动重试。
8.4 手动发送
为指定文章重新触发一次出站发送,不依赖事件。POST /api/send/manual,需要 send_enabled 已开启:
JSON
{
"article_url": "https://blog.example.com/posts/hi",
"content_html": "<p>正文里含有 <a href=\"https://other.example/x\">外链</a></p>"
}
content_html 可留空,留空时按 article_url 抓取页面后提取外链。响应的 queued 为入队条数。
9. 前端接入
9.1 文章底部展示
widget.js 运行时注入,免鉴权查询 GET /api/public/mentions?url=<文章绝对URL>,只返回 approved 的 mention。public_enabled=false 时接口返回 404,脚本静默退出。
HTML
<script src="https://blog.example.com/webmention/api/widget.js"></script>
挂载点按顺序查找,第一个命中的容器生效:
TEXT
#webmention-widget > #post-content > .post-content > .article-content
> #article-content > article .prose > article .rich-content > article > main
可用 window.__WM_WIDGET__ 覆盖,写在引入脚本之前:
HTML
<script>
window.__WM_WIDGET__ = {
apiBase: "https://blog.example.com/webmention/api",
target: "https://blog.example.com/posts/hi",
mount: "#webmention-widget"
};
</script>
字段 | 默认取值 |
|---|---|
| 加载器上的 |
|
|
| 见上方选择器列表 |
没有任何 mention 时不渲染任何元素。
9.2 管理控制台
一、自定义页面注入。 后台「页面管理」新建页面,在「自定义 JS」里粘贴:
JS
(function () {
if (window.__WM_CONSOLE_INJECTED__) return;
window.__WM_CONSOLE_INJECTED__ = true;
var host = document.querySelector("article") || document.body;
host.style.minHeight = "0";
var hd = host.previousElementSibling;
if (hd && hd.tagName === "HEADER") hd.style.display = "none";
var s = document.createElement("script");
s.id = "wm-console-entry";
s.src = "https://blog.example.com/webmention/api/console.js";
host.appendChild(s);
})();
「自定义 JS」字段按 new Function 执行 JS 源码,直接粘 <script> 标签会报 Unexpected token '<'。脚本带 id="wm-console-entry" 时,控制台挂进该脚本的父容器(正文 article)。
二、独立页。 serve_ui=true 后访问插件服务根路径,如 https://blog.example.com/webmention/。
登录态:控制台没有内置登录框,读取 localStorage 的 auth-storage 取 accessToken,因此需与站点同源。部署在独立域名时指定站点根 window.ANHEYU_SITE_ORIGIN = "https://blog.example.com"。未登录或令牌失效(401/403)时跳转 /login?redirect=<当前地址>。
配色跟随站点主题:读 <html> 的 dark 类或 data-theme 属性,切换实时生效;站点未声明时退回系统 prefers-color-scheme。文章底部挂件(widget.js)同样跟随。
脚本分两层:
路径 | 内容 | Cache-Control |
|---|---|---|
| 加载器,几十字节 |
|
| 实现体 |
|
<hash> 为最终字节的 SHA-256 前 10 位。页面里只粘固定加载器地址,升级不需要清缓存。
10. HTTP API 参考
直连插件时前缀为
/api/...;反代保留子路径前缀时插件会自行裁掉/api/之前的部分。
统一响应封装,业务成功以 code=200 判定:{ "code": 200, "message": "ok", "data": { } }
方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
GET |
| 否 | 健康检查,含 |
POST |
| 否 | 登录代理,透传后端 |
GET |
| 否 | 返回本站接收端点的绝对 URL |
GET |
| 否 | 纯文本回显接收端点 URL |
POST |
| 否 | 入站接收端点 |
GET |
| 否 | 公开查询, |
GET |
| 是 | mention 列表,可带 |
POST |
| 是 | 修改审核状态 |
DELETE |
| 是 | 删除 mention |
GET |
| 是 | 出站发送日志,可带 |
GET |
| 是 | 聚合统计 |
GET |
| 是 | 导出 mention 为 JSON |
POST |
| 是 | 导入 mention 数组 |
POST |
| 是 | 手动触发出站发送 |
列表类接口带 ?page=&page_size= 时返回 {list, total, page, page_size},否则返回全量数组。page_size 默认 20,上限 100。
审核状态修改,status 取 approved / rejected / pending / spam:
JSON
{ "status": "approved", "reason": "" }
统计接口的 data:
JSON
{
"incoming_total": 0, "incoming_approved": 0, "incoming_pending": 0, "incoming_rejected": 0,
"incoming_by_type": { "mention": 0, "like": 0 },
"outgoing_total": 0, "outgoing_dispatched": 0, "outgoing_no_endpoint": 0, "outgoing_failed": 0,
"top_source_hosts": [], "top_targets": []
}
11. 安全说明
SSRF 防护:发现、验证、发送都走带拨号层校验的客户端,拒绝回环、私网、链路本地、组播与
169.254.0.0/16;域名解析出多个 IP 时任一落入内部段即拒绝;重定向逐跳复验,限 5 跳。限流:接收端点与公开查询各按来源 IP 每分钟限流(
rate_limit_per_min,默认30);登录代理固定每来源 IP 每分钟10次。来源 IP 判定顺序:
X-Real-IP→X-Forwarded-For首项 →RemoteAddr。端口:
9801只应内网可达,外部统一经反向代理。同机部署可将listen_host设为127.0.0.1。存储上限:入站 mention
5000条,出站日志2000条;超限优先淘汰最旧的未通过记录。公开展示脱敏:公开接口不返回审核原因等内部字段;作者链接、来源、头像渲染前只放行
http/https。凭据:插件不持有 JWT 密钥,不落盘任何凭据。数据文件以
0600权限原子写入。
12. 故障排查
现象 | 可能原因 | 处理 |
|---|---|---|
收不到别的站点发来的通知 | 文章页没有声明端点,或端点未对公网可达 | 检查页面 |
端点返回 400「来源页面未引用目标」 | 对方链接写错、页面需登录、或内容由 JS 渲染 |
|
端点返回 400「target 不属于本站」 | target 的 host 与 | 核对 |
对方没收到我的通知 |
| 开启开关;在 |
管理面板打不开或一片空白 | 反代前缀与脚本地址不一致,或实现体被鉴权拦成 403 | 核对片段里的 |
面板提示「需要管理员权限」 | 非管理员登录态,或控制台与站点不同源 | 用管理员账号登录;异构源时设 |
导入 JSON 报解析失败 | 时间字段不是 RFC3339 | 时间写成 |
mention 一直待审核 | 白名单非空且来源不在名单内,或 | 加入 |
13. 常见问题
Q:不开 serve_ui 能用吗? A:能。把自定义页面注入的片段挂上即可,两个注入脚本始终可取。
Q:为什么发送记录里出现 skipped? A:同一文章对同一 target 之前已成功发送过,插件不重复通知对方。
Q:能只接收不发送吗? A:可以。保持 send_enabled=false,入站接收与展示照常工作。
Q:endpoint_path 什么时候必须填? A:反向代理剥掉了 /webmention 前缀时。保留前缀可以留空,插件会从请求路径感知。
Q:/.well-known/webmention 一定要配吗? A:不必。它只是兜底,部分发送方会探测;不配也不影响 Link 头与 HTML 声明。
Q:审核状态能批量改吗? A:接口按单条处理,控制台列表逐条操作。
14. 限制与已知事项
microformats 提取基于正则,来源页面结构差异可能导致作者名、头像、摘要为空,不影响落盘。
事件负载字段名以后端为准,
ArticlePayload做了多写法兼容;拼不出绝对 URL 时跳过本次入队。出站发送不自动重试,失败记录在日志里,需要时用「手动发送」重来。
存储是单文件 JSON,每次改动整份原子重写,适合
5000/2000条这个量级。已通过审核的 mention 不会重新抓取来源页面。


