跳至内容
文档

投递 webhook:在你自己的网站上接收文章

将 AutoPSEO 文章发送到你网站的签名 webhook:请求头、各事件及其负载、应返回的响应、重试、等待中的命令和 IndexNow。

资源 → 内容 → 文章发布位置阅读需 9 分钟

AutoPSEO 会把它发布、更新或下线的每一篇文章,以带签名的 HTTPS POST 请求发送到你自己的网站。你的网站存储文章,并用自己的模板渲染,因此内容保存在你的系统里,即使你日后离开也能继续正常使用。本页是写给搭建接收端的开发者的参考:签名、每个事件及其负载、AutoPSEO 会读取的响应、重试、等待中的命令和 IndexNow。

设置 webhook

  1. 打开该资源的 Content 页面,找到 Where articles are published。
  2. 填写 Webhook address on your site。它必须是公网主机名上的 https:// URL:IP 地址和 localhost 会被拒绝。AutoPSEO 不会跟随重定向,所以请填写最终地址。
  3. 填写 Your site's address,例如 https://example.com。每种内容类型的路径都挂在它下面,因此路径为 /answers 的类型下的文章,地址是 https://example.com/answers/<slug>。
  4. 选择 Save and send a test。AutoPSEO 会生成一个签名密钥(whsec_ 后接 48 个十六进制字符),并向你的地址发送 ping。会校验签名的接收端在第一次 ping 时必然失败,因为它还没有这个密钥。
  5. 用 Show 或 Copy 显示 Signing secret,把它作为机密存放在你的网站上,然后选择 Send a test。
  6. 查看 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-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-Event事件名称,同时也作为 event 出现在正文中
X-AutoPSEO-Delivery投递 id,同时也作为 id 出现在正文中。同一次投递的每次重试都相同
X-AutoPSEO-Timestamp本次尝试签名时的 Unix 时间(秒)
X-AutoPSEO-Signaturesha256= 后接 <timestamp>.<raw body> 的十六进制 HMAC-SHA256

验证请求的步骤:

  1. 取时间戳请求头的值,加一个英文句点,再接上原样收到的原始请求正文(不要先做任何 JSON 解析)。
  2. 对该字符串计算 HMAC-SHA256。密钥是应用中显示的完整签名密钥(包含 whsec_ 前缀),按 UTF-8 字节处理。
  3. 将结果转成十六进制,并以恒定时间与 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 相同。

字段含义
idAutoPSEO 为文章分配的 id。它永不改变,可以放心用作键
slug地址的最后一部分。在类型化页面上,它由用 / 连接的多个段组成,例如 google/software-engineer
path相对于你网站的地址:类型的路径加上 slug。页面一旦创建便固定不变
type{ key, name, path }。key 是稳定的;path 是该类型文章所在的位置(站点根目录时为空)
lang、langs主语言,以及本次修订包含的所有语言
rev修订号
urlAutoPSEO 所知的完整地址:关闭托管后是你网站的地址,或是你的接收端最近一次应答的 url
datePublished、dateModifiedYYYY-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 指明它汇总的值
coverAutoPSEO 为文章生成的封面。请获取一次并保存你自己的副本;地址永不改变,因为文件名就是图片的 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"
  }
}
字段含义
version1。如果没有大于等于 1 的版本号,该对象会被忽略
events你处理的命令,在经过测试之前显示为已声明
covers当你的页面会为每篇文章显示封面时为 true。这样 AutoPSEO 的写作器会在撰写时一并生成封面,第一次投递就会携带它,并且会要求你自己的 AI 提供封面。参见封面
indexNow{ key, keyLocation? }:你提供的 IndexNow 密钥(8 到 128 个字母、数字和连字符),以及密钥文件不在 /<key>.txt 时它的 https:// 位置
exportsite.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));
}

下一步:哪些内容会到达 webhook,由写作与审核和维护决定;完整流程见内容引擎概览。

让你的 SEO 内容自动运行

连接 Search Console 和你的网站,AutoPSEO 会立即扫描并规划首批主题。无需信用卡。