跳至內容
文件

傳遞 webhook:在你自己的網站上接收文章

將 AutoPSEO 文章傳送到你網站的簽章 webhook:標頭、各事件與 payload、應回覆的內容、重試、等待中的指令,以及 IndexNow。

資源 → 內容 → 文章發布位置閱讀時間 9 分鐘

AutoPSEO 會將它發布、更新或下架的每篇文章,以簽章的 HTTPS POST 傳送到你自己的網站。你的網站負責儲存文章,並以自己的範本呈現,因此內容存放在你的系統中,即使日後離開 AutoPSEO 也能持續運作。本頁是給開發接收端的工程師的參考文件:簽章、每個事件及其 payload、AutoPSEO 會讀取的回應、重試、等待中的指令,以及 IndexNow。

設定 webhook

  1. 開啟資源的內容頁面,前往文章發布位置。
  2. 填寫你網站上的 webhook 位址。它必須是公開主機名稱上的 https:// URL:IP 位址與 localhost 會被拒絕。AutoPSEO 不會跟隨重新導向,因此請填入最終位址。
  3. 填寫你的網站位址,例如 https://example.com。每種內容類型的路徑都接在它之後,因此路徑為 /answers 的類型下的文章,會位於 https://example.com/answers/<slug>。
  4. 選擇儲存並傳送測試。AutoPSEO 會建立簽章密鑰(whsec_ 後接 48 個十六進位字元),並對你的位址發送 ping。會檢查簽章的接收端會讓第一次 ping 失敗,因為它還沒有密鑰。
  5. 以顯示或複製取得簽章密鑰,將它作為機密儲存在你的網站上,然後選擇傳送測試。
  6. 閱讀你的網站可處理的項目:每個指令會顯示已連線、未連線、已宣告,未測試或未測試。

新的資源一開始由 AutoPSEO 以自己的位址代管文章(請參閱代管),代管與 webhook 可以並行運作。等你的網站已經顯示這些文章後,請選擇停止在 AutoPSEO 上代管:每篇已上線的文章都會移到你網站的位址,並以 article.published 傳送到你的 webhook。這個按鈕需要有一個上次測試時已回應的 webhook,以及你網站的位址。代管關閉期間,無法移除 webhook。

新密鑰會立即取代簽章密鑰,舊密鑰隨即失效。產生新密鑰後,請馬上將它放到你的網站上。

只有上次 ping 以 2xx 回應的 webhook,AutoPSEO 才會為它將傳遞排入佇列。ping 失敗(在儲存時、在傳送測試時,或在網站掃描開始時)會將 webhook 標記為錯誤,而在錯誤期間發布的文章不會為它排入佇列。請讓 ping 持續能回應,並在修復中斷問題後選擇傳送測試。

標頭與簽章

每個請求都是帶有 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= 之後的部分。兩者不同時就拒絕該請求。

每次嘗試都會以各自的時間戳記重新簽章,因此你可以拒絕時間戳記與你的時鐘相差過大的請求(常見的窗口是 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 相同。

欄位意義
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 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"
  }
}
欄位意義
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 或沒有回應時,會保留這些位址,並在 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));
}

接下來:哪些內容會送到 webhook,由撰寫與審核和維護決定;完整流程請見內容引擎概覽。

讓你的 SEO 內容自動運行

連結 Search Console 與你的網站,AutoPSEO 會立即掃描並規劃首批主題。無需信用卡。