文档
投递 webhook:在你自己的网站上接收文章
将 AutoPSEO 文章发送到你网站的签名 webhook:请求头、各事件及其负载、应返回的响应、重试、等待中的命令和 IndexNow。
AutoPSEO 会把它发布、更新或下线的每一篇文章,以带签名的 HTTPS POST 请求发送到你自己的网站。你的网站存储文章,并用自己的模板渲染,因此内容保存在你的系统里,即使你日后离开也能继续正常使用。本页是写给搭建接收端的开发者的参考:签名、每个事件及其负载、AutoPSEO 会读取的响应、重试、等待中的命令和 IndexNow。
设置 webhook
- 打开该资源的 Content 页面,找到 Where articles are published。
- 填写 Webhook address on your site。它必须是公网主机名上的
https://URL:IP 地址和localhost会被拒绝。AutoPSEO 不会跟随重定向,所以请填写最终地址。 - 填写 Your site's address,例如
https://example.com。每种内容类型的路径都挂在它下面,因此路径为/answers的类型下的文章,地址是https://example.com/answers/<slug>。 - 选择 Save and send a test。AutoPSEO 会生成一个签名密钥(
whsec_后接 48 个十六进制字符),并向你的地址发送 ping。会校验签名的接收端在第一次 ping 时必然失败,因为它还没有这个密钥。 - 用 Show 或 Copy 显示 Signing secret,把它作为机密存放在你的网站上,然后选择 Send a test。
- 查看 What your site handles:每个命令会显示为 Connected、Not connected、Declared, not tested 或 Not tested。
新资源默认由 AutoPSEO 在它自己的地址上托管文章(参见托管),托管与 webhook 可以并行运行。等你的网站已经能显示这些文章后,选择 Stop hosting on AutoPSEO:每篇已上线的文章都会迁移到你网站的地址,并以 article.published 发送到你的 webhook。该按钮要求 webhook 最近一次测试已得到应答,并且已填写你网站的地址。托管关闭期间,无法移除 webhook。
New secret 会立即替换签名密钥,旧密钥随即失效。生成新密钥后,请马上把它配置到你的网站上。
只有最近一次 ping 得到 2xx 应答的 webhook,AutoPSEO 才会为其排队投递。ping 失败(保存时、Send a test 时,或站点扫描开始时)会把 webhook 标记为出错,而它出错期间发布的文章不会为它排队。请保持 ping 能正常应答,并在故障修复后选择 Send a test。
请求头与签名
每个请求都是带 JSON 正文的 POST,并包含以下请求头:
| 请求头 | 值 |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | 事件名称,同时也作为 event 出现在正文中 |
X-AutoPSEO-Delivery | 投递 id,同时也作为 id 出现在正文中。同一次投递的每次重试都相同 |
X-AutoPSEO-Timestamp | 本次尝试签名时的 Unix 时间(秒) |
X-AutoPSEO-Signature | sha256= 后接 <timestamp>.<raw body> 的十六进制 HMAC-SHA256 |
验证请求的步骤:
- 取时间戳请求头的值,加一个英文句点,再接上原样收到的原始请求正文(不要先做任何 JSON 解析)。
- 对该字符串计算 HMAC-SHA256。密钥是应用中显示的完整签名密钥(包含
whsec_前缀),按 UTF-8 字节处理。 - 将结果转成十六进制,并以恒定时间与
X-AutoPSEO-Signature中sha256=之后的部分比较。两者不一致就拒绝该请求。
每次尝试都会用各自的时间戳重新签名,因此你可以拒绝时间戳与本机时钟相差过大的请求(常见窗口是五分钟),而不会影响重试。HMAC 本身的定义见 RFC 2104。
每个正文都包含 id、event、occurredAt(ISO 8601,UTC)和 site: { propertyId, domain }。
事件
| 事件 | 类型 | AutoPSEO 何时发送 |
|---|---|---|
ping | 检查 | 保存 webhook 时、Send a test 时,以及每次站点扫描开始时 |
test | 检查 | ping 成功后立即发送,每个命令一个 |
article.published | 投递 | 文章首次上线;停止托管时,每篇已上线的文章 |
article.updated | 投递 | 新修订上线(刷新、优化、回滚、下线后重新发布、新增语言)或新增了封面 |
article.unpublished | 投递 | 文章被下线,或被合并到另一个页面 |
path.add | 投递 | 某个类型化内容类型的新值已获批准 |
path.remove | 投递 | 有人请求下线某个值的路径 |
path.check | 调用 | AutoPSEO 在你的网站上尚无页面的地址规划页面之前,或提议新值之前 |
site.export | 调用 | 站点扫描期间、每日主题同步期间,以及 AutoPSEO 修改已上线文章之前 |
articles.adopted | 调用 | 扫描导入了你的网站导出的文章之后 |
site.handover | 调用 | 有人把内容类型的写作交给 AutoPSEO,或交还给网站时 |
投递通过发件箱(outbox)进行:它们与变更记录在同一个数据库批次中,立即发送、可重试,也可以等待你的网站就绪。调用则是普通的请求与响应:AutoPSEO 当场读取应答。路径命令的发送时机见类型化页面与新路径。
文章负载
article.published 和 article.updated 携带整篇文章,包含所有语言:
{
"id": "0f6d2c1e-7a51-4c1b-9d0e-5b8f3a2e9c41",
"event": "article.published",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"article": {
"id": "a4c09e2b-6f1d-4e57-8b3a-1d2e3f4a5b6c",
"slug": "plan-a-content-calendar",
"path": "/answers/plan-a-content-calendar",
"type": { "key": "answers", "name": "Answers", "path": "/answers" },
"lang": "en",
"langs": ["en", "de"],
"rev": 1,
"url": "https://example.com/answers/plan-a-content-calendar",
"datePublished": "2026-10-12",
"dateModified": "2026-10-12",
"contentHash": "9f2c4e1a7b3d5f6081a2c3d4",
"writer": "platform",
"category": "Planning",
"copy": {
"title": "How to plan a content calendar",
"metaDescription": "A content calendar in five steps, with a template.",
"h1": "How to plan a content calendar",
"directAnswer": "List your topics, give each a date and an owner, then review the plan weekly.",
"keyTakeaways": ["Plan a quarter at a time.", "Give every piece one owner."],
"sections": [{ "heading": "Start from your topics", "bodyHtml": "<p>Begin with the searches you want to win.</p>" }],
"faq": [{ "q": "How far ahead should I plan?", "a": "One quarter is enough for most teams." }],
"sources": [{ "title": "Creating helpful content", "url": "https://developers.google.com/search/docs/fundamentals/creating-helpful-content" }]
},
"translations": { "de": { "title": "Einen Redaktionsplan erstellen", "h1": "Einen Redaktionsplan erstellen" } },
"cover": {
"url": "https://app.autopseo.com/media/covers/prop_123/<sha256>.jpg",
"sha256": "<sha256>",
"contentType": "image/jpeg",
"width": 1536,
"height": 1024
}
}
}
这里的德语条目做了精简;每个翻译的结构都与 copy 相同。
| 字段 | 含义 |
|---|---|
id | AutoPSEO 为文章分配的 id。它永不改变,可以放心用作键 |
slug | 地址的最后一部分。在类型化页面上,它由用 / 连接的多个段组成,例如 google/software-engineer |
path | 相对于你网站的地址:类型的路径加上 slug。页面一旦创建便固定不变 |
type | { key, name, path }。key 是稳定的;path 是该类型文章所在的位置(站点根目录时为空) |
lang、langs | 主语言,以及本次修订包含的所有语言 |
rev | 修订号 |
url | AutoPSEO 所知的完整地址:关闭托管后是你网站的地址,或是你的接收端最近一次应答的 url |
datePublished、dateModified | YYYY-MM-DD。发布日期在后续修订中保持不变 |
contentHash | 本次修订的指纹 |
writer | 本次修订的撰写者:platform(AutoPSEO)、mcp(你自己的 AI)、user、import(你网站自己的文本)或 aggregate(列表页) |
copy | 主语言的文章内容 |
translations | 其他语言,以语言代码为键 |
category | 你网站为它设定的分类(当你的格式保留分类时) |
topic | 当文章是为你的网站提交的某个主题而写时,为 { externalId, keyword },方便你关闭该主题 |
parts | 在类型化页面上,每个部分的取值:{ "company": "google", "role": "software-engineer" } |
kind | 列表页上为 "list"。其内容为 { title, h1, items: [{ from, text }] },其中 from 是各条目来源页面的路径,parts 指明它汇总的值 |
cover | AutoPSEO 为文章生成的封面。请获取一次并保存你自己的副本;地址永不改变,因为文件名就是图片的 SHA-256 |
basedOn | 在 article.updated 上:本次修订所基于的你页面的版本(见下文冲突部分) |
redirectTo | 在合并后的 article.unpublished 上:旧地址现在指向的位置。请用 301 应答,且不要在那里放置任何新内容 |
article.unpublished 携带相同的 article 字段,但不含 copy、translations、category 和 cover。
copy 内部结构
| 字段 | 含义 |
|---|---|
title、metaDescription、h1 | 页面标题、meta 描述和主标题 |
directAnswer | 首先展示的简短回答 |
keyTakeaways | 字符串列表 |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | 可选:搜索者用自己的话提出的问题(当你的格式要求时) |
category | 可选:你网站的分类之一 |
lists | 可选:按键组织的纯文本列表,供要求它们的内容类型使用(例如面试指南的示例问题) |
在 AutoPSEO 撰写的文章中,bodyHtml 只使用 p、ul、li、strong、em、a 和 code,链接只保留 href。从你自己网站导入的文本会保留你的 HTML。如果你的模板有此要求,也请再用你自己的清理器处理一遍。
接收端应返回什么
任何 2xx 都表示投递完成。AutoPSEO 还会从应答正文中读取一个可选的 JSON 对象;请控制在 64 KB 以内,更大的应答会被视为失败。
| 字段 | 作用 |
|---|---|
url | 文章现在所在的 https:// 地址。AutoPSEO 会将其记录为文章的 URL |
version | 你页面的版本,一个不透明字符串,最多 128 个字符。它会在下一次更新时作为 basedOn 带回来 |
urls | 本次投递改动过的 https:// 地址,用于 IndexNow(每种语言的地址,或你已停止提供服务的地址)。[] 表示没有任何改动 |
cover | 当你没有该文章的封面、且本次投递也没有携带封面时,返回 "wanted"。AutoPSEO 会生成一张,并通过 article.updated 发送 |
不同的错误应答含义各不相同:
| 你的应答 | AutoPSEO 的理解 | 结果 |
|---|---|---|
带 { "error": "unknown_event" } 的 4xx,或 501 | 你尚未处理该命令 | 投递进入等待,不重试 |
带 { "error": "conflict" } 的 409 | 更新所基于的版本,你的页面已不再拥有 | 投递失败且不重试,文章会在下次变更前从你的网站重新读取 |
对 path.add 或 path.remove 返回 408 和 429 之外的 4xx,并带 { "error": "<code>", "details": [] } | 附带你所给理由的拒绝 | 予以记录;参见类型化页面与新路径 |
| 其他情况:10 秒内无应答、网络错误、重定向、408、429、5xx 或其他 4xx | 临时故障 | 按退避策略重试 |
重试与被取代的投递
变更一保存,投递就会立即发送,同时每五分钟有一次定时任务,按从旧到新的顺序发送所有到期的投递。临时故障之后,AutoPSEO 会在两次尝试之间依次等待 1 分钟、5 分钟、15 分钟、1 小时、3 小时、6 小时和 12 小时。第八次尝试失败后,该投递会被标记为失败,Content 页面会在 webhook 旁用红色徽章计入它。
当同一篇文章存在更新的投递时,仍处于待发送状态的旧投递会被标记为已被取代,永远不会发送。每次投递都携带整篇文章,所以你的网站只需要最新的那一次。关于同一个值的路径命令也是同样的处理方式。
你的接收端可能会收到同一次投递两次,例如它已经存储了文章,但在 10 秒超时之后才应答。请根据 X-AutoPSEO-Delivery 去重,或者按 article.id 存储,让每次写入都具有幂等性。article.updated 也可能指向一篇你在收到 article.unpublished 后已删除的文章,因为重新发布一篇已下线的文章会发送更新:请把更新当作“创建或替换”来处理。
你的网站尚未处理的命令
网站可以一次只实现协议中的一个命令。AutoPSEO 会记录每个命令的状态,并通过三种方式获知:
- 测试。 每次 ping 成功后,AutoPSEO 会为每个命令发送一个
test。正文为{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } },其中test包含一个结构与真实命令一致的示例,所涉及的地址是<type path>/autopseo-connection-test,任何网站都不会有这个地址。请像处理真实命令那样检查它,但不要改动任何内容,并以 2xx 应答:{ "test": "<command>", "ok": true }表示 Connected,{ "test": "<command>", "ok": false, "error": "unknown_event" }表示 Not connected。如果应答为ok: false并带有其他错误码(例如对一个你并没有的地址执行下线),仍算作已连接。示例位于test之下,绝不会出现在顶层,因此对测试一无所知的接收端在其中找不到任何文章。 - 真实的投递和调用。 2xx 会将该命令标记为已连接;
unknown_event会将其标记为未连接。 - ping 应答。 你的应答在
autopseo.events中列出的命令会显示为 Declared, not tested,直到测试或某次投递给出更多信息。
针对未连接命令的投递会进入等待。Content 页面会列出正在等待的内容,例如“Your site does not take articles down yet”,对于你手动完成的操作,还有 Done on the site 按钮;之后 AutoPSEO 就不会再发送那一项。当之后的测试或投递发现该命令已连接时,所有在等它的内容都会被发送。
ping 应答
用 2xx 应答 ping。只接收文章的网站不需要其他内容。如果想告诉 AutoPSEO 你的网站还能做什么,请包含一个 autopseo 对象:
{
"ok": true,
"autopseo": {
"version": 1,
"events": ["article.published", "article.updated", "article.unpublished"],
"covers": true,
"indexNow": { "key": "3f1c9a7e5b2d4f60", "keyLocation": "https://example.com/3f1c9a7e5b2d4f60.txt" },
"export": ["articles", "topics", "profile"],
"adopt": true,
"handover": true,
"types": ["answers"],
"writer": "site"
}
}
| 字段 | 含义 |
|---|---|
version | 1。如果没有大于等于 1 的版本号,该对象会被忽略 |
events | 你处理的命令,在经过测试之前显示为已声明 |
covers | 当你的页面会为每篇文章显示封面时为 true。这样 AutoPSEO 的写作器会在撰写时一并生成封面,第一次投递就会携带它,并且会要求你自己的 AI 提供封面。参见封面 |
indexNow | { key, keyLocation? }:你提供的 IndexNow 密钥(8 到 128 个字母、数字和连字符),以及密钥文件不在 /<key>.txt 时它的 https:// 位置 |
export | site.export 提供的内容:articles、topics、profile |
adopt | 你接受 articles.adopted,这样完整扫描就可以接管你现有的文章 |
handover | 你接受 site.handover |
types | 你接受的内容类型键(最多 50 个) |
writer | 目前由谁撰写你的新文章:site 或 autopseo |
为你提供服务的地址使用 IndexNow
当你的 ping 应答声明了 indexNow 时,AutoPSEO 会在你接受的投递之后,向 api.indexnow.org 发送 IndexNow ping,而你的网站自己不必发送任何 ping。
- 它会发送你的应答中列出的
urls。没有urls时发送应答中的url,再没有则发送文章的 URL。urls: []表示不发送任何内容。 - 只有位于你自己主机上的地址才会提交,这是 IndexNow 协议的要求:取
keyLocation的主机,否则取你网站地址的主机。 - 每次投递运行对每个站点发送一次 ping,最多包含 10,000 个地址。
- 200 和 202 视为成功。429、5xx 或无应答时,地址会被保留,并在一小时后重试,之后每次翻倍,最长到一天(当
Retry-After要求更久时则按其要求),同时带上之后投递新增的地址。403(密钥无效)或 422(地址不在该主机上)会被记录并丢弃。 - Content 页面会显示最近一次 ping:时间、地址数量、HTTP 状态和下次尝试时间。
- 当 AutoPSEO 同时也托管这些文章时,托管页面使用 AutoPSEO 自己的密钥进行 ping,你的密钥不会被使用。
Indexing 下的 IndexNow 工具是一个独立的手动提交器;参见 IndexNow。
冲突:以你网站上的文本为准
你的应答中的 version(或你导出中某篇文章的 version)表示 AutoPSEO 认为你的页面当前所处的版本。每个 article.updated 都会把它作为 basedOn 携带。如果页面在你的网站上已有改动(例如编辑做了更正),请应答 409 并附上 { "error": "conflict" },且不要存储任何内容。该投递会失败且不重试。
在写入对已上线文章的排队变更之前,如果你的网站处理 site.export,AutoPSEO 会通过 site.export 和 paths 从你的网站重新读取该文章。如果你的版本已经更新,你的文本就会成为文章的下一个修订(撰写者为 import),随后的更新会把你当前的版本作为 basedOn 携带。
path.add 和 path.remove
这两种投递针对类型化内容类型的某一个值,例如 /interviews/{company}/{role} 中的一家公司。它们携带的是 path 对象,而不是 article:
{
"id": "7c1e9d2a-3b4f-4e8a-9c0d-1e2f3a4b5c6d",
"event": "path.add",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"path": {
"path": "/interviews/jane-street",
"type": { "key": "interviews", "name": "Interviews", "path": "/interviews" },
"part": "company",
"value": {
"value": "jane-street",
"name": "Jane Street",
"aliases": [],
"url": "https://<its own site>/",
"sameAs": ["https://<its careers page>/"],
"description": "<what it is, in a few words>",
"facts": [
{ "text": "<one sentence, as the source states it>", "source": "https://<the page that says it>", "lastVerified": "2026-10-01", "tier": "official" }
]
},
"takes": { "role": ["software-engineer"] }
}
}
| 字段 | 含义 |
|---|---|
path | 该值在你网站上的专属地址 |
type | 内容类型,形式为 { key, name, path } |
part | 该值所填充的地址部分 |
value | { value, name };在 path.add 上还包括 AutoPSEO 的写作器收集的 aliases、url、sameAs、description 和 facts。每条事实包含 text、source、lastVerified,以及取值为 official 或 reported 的 tier |
takes | 在 path.add 上:人们会与它一起搜索的其他部分的取值 |
redirectTo | 在 path.remove 上:该值的各个地址今后应以 301 指向的位置 |
接受命令时应答 2xx;对 path.add,你可以附上该值现在所在的 url;对 path.remove,可以附上你已停止提供服务的 urls,它们会被送往 IndexNow。若要拒绝,请应答 4xx 并附上 { "error": "<code>", "details": ["<why>"] }。对 path.add,invalid_value 会带着你的理由把该值退回给写作器(第三次拒绝即为终局),其他任何错误码都会永久拒绝它。对 path.remove,not_found 视为已完成,其他任何错误码(例如 409 in_use)都会让一切保持原状。把等待中的路径命令标记为 Done on the site,就等同于你的网站表示同意。
调用:AutoPSEO 会读取其应答的请求
调用与投递一样需要签名。调用默认最多等待 60 秒,重新读取和路径检查则等待 30 秒,可读取最大 24 MB 的应答。除 2xx 之外的任何应答都会使调用失败。
site.export
请求携带 export: { what, cursor, limit };对主题还有 since(ISO 时间),重新读取时还有 paths。请应答 { "items": [], "next": "<cursor>" };AutoPSEO 会把 cursor 设为你的 next 再次请求,直到它为空或为 null。
what: "articles"在完整站点扫描中运行,每页 10 篇,前提是你声明了包含articles的export并且adopt: true。应答 400 表示“不支持”。带paths: ["/answers/x"]和limit: 1时,它会重新读取单篇文章;请只返回该文章。what: "topics"在每次扫描和每日同步中运行,每页 50 条;每日同步会发送since,即上次拉取的时间。what: "profile"在每次扫描中运行。请应答{ "profile": { types, languages, rules, categories, format } },各项均为可选:types形如[{ key, name, path }],rules包含brandTerms、synonyms、noise、onTopic和offTopic。
导出的文章需要 externalId、slug(任意文字的字母和数字以及连字符,最多 200 个字符,各段用 / 连接)、以该 slug 结尾的 path,以及至少包含 title、h1 和一个章节的 copy。可选字段:lang(默认为 en)、type(内容类型键)、url、keyword、category、datePublished、dateModified、version、translations、owner(被接管后的 AutoPSEO 文章 id),以及 maintenance: { kind: "refresh" or "optimize", reason },它会把相应任务加入队列。每篇导出的文章都会成为受管理的文章,你的文本作为其第一个修订,并且不会有任何内容回传。重复导出是安全的,AutoPSEO 已下线的文章也绝不会通过导出重新出现。
主题条目的形式为 { keyword, status?, volume?, source?, externalId?, type?, reason? },其中 status 为 queued、planned 或 rejected。
articles.adopted
导入之后,AutoPSEO 会请你让它管理这些新文章:{ "articles": [{ "externalId", "articleId", "slug", "path" }] }。请应答 { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }。你没有确认的文章会再次从 AutoPSEO 中移除,除非 AutoPSEO 已经为它写过修订。
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } },当有人在该资源的 Your site 页面上选择 Let AutoPSEO write them 或 Hand writing back to the site 时发送。请应答 { "ok": true, "writer": "autopseo" }。交给 AutoPSEO 之后,你的网站会把它审核过的主题交给 AutoPSEO,而不是自己去写。
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }。请为每条路径返回一个结果:{ "results": [{ "path": "/interviews/google/data-engineer", "ok": true }, { "path": "/interviews/jane-street", "ok": false, "error": "not_allowed", "details": ["We only list companies with a careers page."] }] }。拒绝会连同你的理由一起保留,不会再次询问;你漏掉的路径稍后会被再次询问。
一个最简接收端
一个 Cloudflare Worker:使用 Web Crypto API 验证签名,处理三个文章命令,并对其余命令以 unknown_event 拒绝。它把文章保存在绑定为 ARTICLES 的 KV 命名空间中,签名密钥保存在名为 AUTOPSEO_SECRET 的 Worker 机密中。
const HANDLED = ["article.published", "article.updated", "article.unpublished"];
const MAX_SKEW_SECONDS = 300;
const SITE = "https://example.com";
const json = (data, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
function hexToBytes(hex) {
if (!/^[0-9a-f]{64}$/.test(hex)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
async function verified(request, body, secret) {
const timestamp = request.headers.get("X-AutoPSEO-Timestamp") || "";
const signature = (request.headers.get("X-AutoPSEO-Signature") || "").replace(/^sha256=/, "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const bytes = hexToBytes(signature);
if (!bytes) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
// verify() compares in constant time.
return crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${timestamp}.${body}`));
}
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const body = await request.text();
if (!(await verified(request, body, env.AUTOPSEO_SECRET))) return json({ error: "bad_signature" }, 401);
let msg;
try {
msg = JSON.parse(body);
} catch {
return json({ error: "bad_json" }, 400);
}
if (msg.event === "ping") return json({ ok: true, autopseo: { version: 1, events: HANDLED } });
if (msg.event === "test") {
const command = msg.test && msg.test.event;
// Check the sample as you would the real command, and store nothing.
return HANDLED.includes(command)
? json({ test: command, ok: true })
: json({ test: command, ok: false, error: "unknown_event" });
}
if (msg.event === "article.published" || msg.event === "article.updated") {
const a = msg.article;
const stored = JSON.parse((await env.ARTICLES.get(a.id)) || "null");
if (a.basedOn && stored && stored.version && stored.version !== a.basedOn) {
return json({ error: "conflict", details: ["The page changed on the site."] }, 409);
}
// Any change to the page, here or in your own editor, gets a new version.
const version = crypto.randomUUID();
await env.ARTICLES.put(a.id, JSON.stringify({ ...a, version }));
return json({ url: SITE + a.path, version });
}
if (msg.event === "article.unpublished") {
const a = msg.article;
if (a.redirectTo) await env.ARTICLES.put(a.id, JSON.stringify({ path: a.path, redirectTo: a.redirectTo }));
else await env.ARTICLES.delete(a.id);
return json({ ok: true });
}
return json({ error: "unknown_event" }, 400);
},
};
在 Node.js 上,用 createHmac 和 timingSafeEqual 以同样的方式验证,针对框架收到的原始正文:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAutopseo(rawBody, headers, secret) {
const timestamp = String(headers["x-autopseo-timestamp"] || "");
const given = String(headers["x-autopseo-signature"] || "");
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}