文件
傳遞 webhook:在你自己的網站上接收文章
將 AutoPSEO 文章傳送到你網站的簽章 webhook:標頭、各事件與 payload、應回覆的內容、重試、等待中的指令,以及 IndexNow。
AutoPSEO 會將它發布、更新或下架的每篇文章,以簽章的 HTTPS POST 傳送到你自己的網站。你的網站負責儲存文章,並以自己的範本呈現,因此內容存放在你的系統中,即使日後離開 AutoPSEO 也能持續運作。本頁是給開發接收端的工程師的參考文件:簽章、每個事件及其 payload、AutoPSEO 會讀取的回應、重試、等待中的指令,以及 IndexNow。
設定 webhook
- 開啟資源的內容頁面,前往文章發布位置。
- 填寫你網站上的 webhook 位址。它必須是公開主機名稱上的
https://URL:IP 位址與localhost會被拒絕。AutoPSEO 不會跟隨重新導向,因此請填入最終位址。 - 填寫你的網站位址,例如
https://example.com。每種內容類型的路徑都接在它之後,因此路徑為/answers的類型下的文章,會位於https://example.com/answers/<slug>。 - 選擇儲存並傳送測試。AutoPSEO 會建立簽章密鑰(
whsec_後接 48 個十六進位字元),並對你的位址發送 ping。會檢查簽章的接收端會讓第一次 ping 失敗,因為它還沒有密鑰。 - 以顯示或複製取得簽章密鑰,將它作為機密儲存在你的網站上,然後選擇傳送測試。
- 閱讀你的網站可處理的項目:每個指令會顯示已連線、未連線、已宣告,未測試或未測試。
新的資源一開始由 AutoPSEO 以自己的位址代管文章(請參閱代管),代管與 webhook 可以並行運作。等你的網站已經顯示這些文章後,請選擇停止在 AutoPSEO 上代管:每篇已上線的文章都會移到你網站的位址,並以 article.published 傳送到你的 webhook。這個按鈕需要有一個上次測試時已回應的 webhook,以及你網站的位址。代管關閉期間,無法移除 webhook。
新密鑰會立即取代簽章密鑰,舊密鑰隨即失效。產生新密鑰後,請馬上將它放到你的網站上。
只有上次 ping 以 2xx 回應的 webhook,AutoPSEO 才會為它將傳遞排入佇列。ping 失敗(在儲存時、在傳送測試時,或在網站掃描開始時)會將 webhook 標記為錯誤,而在錯誤期間發布的文章不會為它排入佇列。請讓 ping 持續能回應,並在修復中斷問題後選擇傳送測試。
標頭與簽章
每個請求都是帶有 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=之後的部分。兩者不同時就拒絕該請求。
每次嘗試都會以各自的時間戳記重新簽章,因此你可以拒絕時間戳記與你的時鐘相差過大的請求(常見的窗口是 5 分鐘),而不會影響重試。HMAC 本身的定義見 RFC 2104。
每個內文都帶有 id、event、occurredAt(ISO 8601,UTC)以及 site: { propertyId, domain }。
事件
| 事件 | 類別 | AutoPSEO 何時傳送 |
|---|---|---|
ping | 檢查 | 儲存 webhook 時、傳送測試時,以及每次網站掃描開始時 |
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 當場讀取回應。路徑指令何時傳送,請見具型別頁面與新路徑的說明。
文章 payload
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 description 與標題文字 |
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 以外、帶有 { "error": "<code>", "details": [] } 的 4xx | 附有你的理由的拒絕 | 予以記錄;請參閱具型別頁面與新路徑 |
| 其他情況:10 秒內沒有回應、網路錯誤、重新導向、408、429、5xx 或其他 4xx | 暫時性失敗 | 以退避方式重試 |
重試與被取代的傳遞
變更一儲存,傳遞就會立即送出,另有每 5 分鐘一次的排程,依時間由舊到新送出所有到期的項目。發生暫時性失敗後,AutoPSEO 在各次嘗試之間依序等待 1 分鐘、5 分鐘、15 分鐘、1 小時、3 小時、6 小時與 12 小時。第 8 次嘗試失敗後,該筆傳遞會被標記為失敗,內容頁面會在 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 }表示已連線,回覆{ "test": "<command>", "ok": false, "error": "unknown_event" }表示未連線。ok: false搭配其他錯誤代碼的回應(例如要下架一個你沒有的位址)仍算作已連線。範例位於test之下,絕不在最上層,因此完全不知道測試的接收端不會在其中找到任何文章。 - 真實的傳遞與呼叫。2xx 會將該指令標記為已連線;
unknown_event則標記為未連線。 - ping 的回應。你的回應在
autopseo.events中列出的指令,會顯示為已宣告,未測試,直到測試或傳遞提供更多資訊為止。
未連線指令的傳遞會等待。內容頁面會列出等待中的項目,例如「你的網站尚未支援文章下架」,並為你手動完成的任何項目提供已在網站上完成按鈕;之後 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 或沒有回應時,會保留這些位址,並在 1 小時後再試,之後每次加倍,最長到一天(若
Retry-After要求更久則依其要求),並帶上後續傳遞所新增的位址。403(金鑰無效)或 422(位址不在該主機上)會被記錄並捨棄。 - 內容頁面會顯示最近一次 ping:時間、位址數量、HTTP 狀態,以及下次嘗試時間。
- 當 AutoPSEO 同時代管文章時,代管的頁面會以 AutoPSEO 自己的金鑰進行 ping,不會使用你的金鑰。
索引下的 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。每個 fact 都有 text、source、lastVerified,以及 official 或 reported 的 tier |
takes | 在 path.add 上:人們會與它一併搜尋的其他部分的值 |
redirectTo | 在 path.remove 上:該值的位址今後應以 301 導向的位置 |
接受指令時回應 2xx;在 path.add 上,你可以附上該值目前所在的 url,而在 path.remove 上則可附上你已停止提供的 urls,它們會送往 IndexNow。若要拒絕,請回應帶有 { "error": "<code>", "details": ["<why>"] } 的 4xx。在 path.add 上,invalid_value 會連同你的理由將該值退回給撰寫器(第 3 次拒絕即為最終結果),其他任何代碼則會永久拒絕它。在 path.remove 上,not_found 視為已完成,而其他任何代碼(例如 409 in_use)會讓一切維持原樣。將等待中的路徑指令標記為已在網站上完成,等同於你的網站答應了。
呼叫: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與adopt: true宣告了export。回應 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" } },當有人在資源的你的網站頁面上選擇讓 AutoPSEO 撰寫或將撰寫交還網站時傳送。回應 { "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));
}