Documentație
Webhook de livrare: primirea articolelor pe propriul site
Webhook-ul semnat prin care AutoPSEO trimite articole către site-ul tău: antete, fiecare eveniment și payload, răspunsurile de dat, reîncercări, comenzi în așteptare și IndexNow.
AutoPSEO trimite fiecare articol pe care îl publică, îl actualizează sau îl retrage către propriul tău site, sub forma unui POST HTTPS semnat. Site-ul tău stochează articolul și îl afișează cu propriile șabloane, astfel că conținutul rămâne în sistemul tău și continuă să funcționeze chiar dacă renunți vreodată la AutoPSEO. Această pagină este referința pentru dezvoltatorul care construiește receptorul: semnarea, fiecare eveniment cu payload-ul său, răspunsurile pe care le citește AutoPSEO, reîncercările, comenzile în așteptare și IndexNow.
Configurarea webhook-ului
- Deschide pagina Conținut a proprietății și mergi la Unde se publică articolele.
- Completează Adresa webhook-ului pe site-ul tău. Trebuie să fie un URL
https://pe un nume de gazdă public: adresele IP șilocalhostsunt refuzate. AutoPSEO nu urmează redirecționări, așa că indică adresa finală. - Completează Adresa site-ului tău, de exemplu
https://example.com. Calea fiecărui tip de conținut se adaugă sub ea, deci un articol de tipul de la/answersse află lahttps://example.com/answers/<slug>. - Alege Salvează și trimite un test. AutoPSEO creează un secret de semnare (
whsec_urmat de 48 de caractere hexazecimale) și trimite un ping la adresa ta. Un receptor care verifică semnăturile va pica acest prim ping, pentru că nu are încă secretul. - Afișează Secretul de semnare cu Arată sau Copiază, păstrează-l ca secret pe site-ul tău, apoi alege Trimite un test.
- Citește Ce gestionează site-ul tău: fiecare comandă apare ca Conectată, Neconectată, Declarată, netestată sau Netestată.
Proprietățile noi pornesc cu articolele găzduite de AutoPSEO la propria adresă (vezi Găzduire), iar găzduirea și webhook-ul pot funcționa în paralel. Odată ce site-ul tău afișează articolele, alege Oprește găzduirea pe AutoPSEO: fiecare articol live se mută la adresa site-ului tău și este trimis către webhook ca article.published. Butonul cere un webhook care a răspuns la ultimul test și adresa site-ului tău. Cât timp găzduirea este oprită, webhook-ul nu poate fi eliminat.
Secret nou înlocuiește imediat secretul de semnare, iar cel vechi încetează să mai fie verificat pe loc. Pune noul secret pe site-ul tău imediat după ce l-ai generat.
AutoPSEO pune livrări în coadă doar pentru un webhook al cărui ultim ping a primit un răspuns 2xx. Un ping eșuat (la salvare, la Trimite un test sau la începutul unei scanări a site-ului) marchează webhook-ul ca fiind în eroare, iar articolele publicate cât timp este în eroare nu sunt puse în coadă pentru el. Menține ping funcțional și alege Trimite un test după ce ai remediat o pană.
Antete și semnătură
Fiecare cerere este un POST cu corp JSON și aceste antete:
| Antet | Valoare |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | Numele evenimentului, prezent și în corp ca event |
X-AutoPSEO-Delivery | Id-ul livrării, prezent și în corp ca id. Același la fiecare reîncercare a unei livrări |
X-AutoPSEO-Timestamp | Timpul Unix, în secunde, la care a fost semnată această încercare |
X-AutoPSEO-Signature | sha256= urmat de HMAC-SHA256 în hexazecimal al șirului <timestamp>.<raw body> |
Pentru a verifica o cerere:
- Ia antetul cu timestamp-ul, un punct și corpul cererii brut, exact așa cum a sosit, înainte de orice parsare JSON.
- Calculează HMAC-SHA256 peste acest șir. Cheia este întregul secret de semnare, așa cum îl afișează aplicația, cu tot prefixul
whsec_, ca octeți UTF-8. - Codifică rezultatul în hexazecimal și compară-l cu partea din
X-AutoPSEO-Signaturede dupăsha256=, în timp constant. Respinge cererea dacă diferă.
Fiecare încercare este semnată din nou, cu propriul timestamp, așa că poți refuza cererile al căror timestamp se îndepărtează mult de ceasul tău (cinci minute este o fereastră uzuală) fără a afecta reîncercările. HMAC în sine este definit în RFC 2104.
Fiecare corp conține id, event, occurredAt (ISO 8601, UTC) și site: { propertyId, domain }.
Evenimente
| Eveniment | Tip | Când îl trimite AutoPSEO |
|---|---|---|
ping | Verificare | Când salvezi webhook-ul, la Trimite un test și la începutul fiecărei scanări a site-ului |
test | Verificare | Imediat după un ping reușit, câte unul pentru fiecare comandă |
article.published | Livrare | Un articol devine live pentru prima dată; fiecare articol live, când oprești găzduirea |
article.updated | Livrare | O revizie nouă devine live (reîmprospătare, optimizare, revenire la o versiune anterioară, republicare după o retragere, adăugare de limbi) sau a fost adăugată o copertă |
article.unpublished | Livrare | Un articol este retras sau comasat într-o altă pagină |
path.add | Livrare | A fost aprobată o valoare nouă pentru un tip de conținut tipizat |
path.remove | Livrare | Cineva a cerut retragerea căii unei valori |
path.check | Apel | Înainte ca AutoPSEO să planifice o pagină la o adresă la care site-ul tău nu are nicio pagină sau să propună o valoare nouă |
site.export | Apel | În timpul unei scanări a site-ului, la sincronizarea zilnică a subiectelor și înainte ca AutoPSEO să modifice un articol live |
articles.adopted | Apel | După ce o scanare a importat articole exportate de site-ul tău |
site.handover | Apel | Când cineva predă scrierea tipurilor de conținut către AutoPSEO sau o preia înapoi |
Livrările trec printr-un outbox: sunt înregistrate în același lot de bază de date ca modificarea, sunt trimise imediat, reîncercate și pot aștepta site-ul tău. Apelurile sunt simple cereri cu răspuns: AutoPSEO citește răspunsul pe loc. Momentul în care se trimit comenzile de tip cale este explicat în Pagini tipizate și căi noi.
Payload-ul articolului
article.published și article.updated conțin articolul întreg, cu toate limbile:
{
"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
}
}
}
Intrarea în germană este prescurtată aici; fiecare traducere are aceeași structură ca copy.
| Câmp | Semnificație |
|---|---|
id | Id-ul articolului în AutoPSEO. Nu se schimbă niciodată, deci este o cheie sigură |
slug | Ultima parte a adresei. Pe paginile tipizate conține segmente unite prin /, de exemplu google/software-engineer |
path | Adresa relativă la site-ul tău: calea tipului plus slug-ul. Rămâne fixă după ce pagina există |
type | { key, name, path }. key este stabil; path este locul unde se află articolele tipului (gol pentru rădăcina site-ului) |
lang, langs | Limba principală și toate limbile acestei revizii |
rev | Numărul reviziei |
url | Adresa completă, așa cum o cunoaște AutoPSEO: cea a site-ului tău după oprirea găzduirii sau url-ul la care a răspuns ultima dată receptorul tău |
datePublished, dateModified | YYYY-MM-DD. Data publicării rămâne fixă de-a lungul reviziilor ulterioare |
contentHash | O amprentă a reviziei |
writer | Cine a scris revizia: platform (AutoPSEO), mcp (propriul tău AI), user, import (textul propriu al site-ului tău) sau aggregate (o pagină de listă) |
copy | Articolul în limba principală |
translations | Celelalte limbi, indexate după codul limbii |
category | Categoria articolului pe site-ul tău, dacă formatul tău păstrează categorii |
topic | { externalId, keyword } când articolul a fost scris pentru un subiect predat de site-ul tău, ca să poți închide acel subiect |
parts | Pe paginile tipizate, valoarea fiecărei părți: { "company": "google", "role": "software-engineer" } |
kind | "list" pe o pagină de listă. Conținutul ei este { title, h1, items: [{ from, text }] }, unde from este calea paginii din care provine fiecare element, iar parts indică valoarea pe care o grupează |
cover | O copertă făcută de AutoPSEO pentru articol. Descarc-o o singură dată și păstrează propria copie; adresa nu se schimbă niciodată, fiindcă numele fișierului este SHA-256 al imaginii |
basedOn | Pe article.updated: versiunea paginii tale de la care a pornit această revizie (vezi conflictele mai jos) |
redirectTo | Pe article.unpublished după o comasare: unde duce acum vechea adresă. Răspunde cu un 301 și nu publica nimic nou acolo |
article.unpublished conține aceleași câmpuri article, cu excepția copy, translations, category și cover.
În interiorul lui copy
| Câmp | Semnificație |
|---|---|
title, metaDescription, h1 | Titlul paginii, meta descrierea și titlul principal |
directAnswer | Răspunsul scurt afișat primul |
keyTakeaways | O listă de șiruri de caractere |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Opțional: întrebarea căutătorului, în cuvintele lui, când formatul tău o cere |
category | Opțional: una dintre categoriile site-ului tău |
lists | Opțional: liste de text simplu, indexate după cheie, pentru tipurile de conținut care le cer (întrebările-exemplu dintr-un ghid de interviu) |
În articolele scrise de AutoPSEO, bodyHtml folosește doar p, ul, li, strong, em, a și code, iar linkurile păstrează doar href. Textul preluat de pe propriul tău site își păstrează HTML-ul. Trece-l și printr-un sanitizer propriu dacă șabloanele tale îl cer.
Ce răspunde receptorul tău
Orice 2xx încheie o livrare. AutoPSEO citește și un obiect JSON opțional din corpul răspunsului; ține-l sub 64 KB, deoarece un răspuns mai mare contează ca eșec.
| Câmp | Efect |
|---|---|
url | Adresa https:// la care se află acum articolul. AutoPSEO o înregistrează ca URL-ul articolului |
version | Versiunea paginii tale, un șir opac de cel mult 128 de caractere. Revine ca basedOn la următoarea actualizare |
urls | Adresele https:// modificate de această livrare, pentru IndexNow (toate limbile sau adresele pe care ai încetat să le servești). [] înseamnă că nimic nu s-a schimbat |
cover | "wanted" când nu ai o copertă pentru articol, iar livrarea nu a adus niciuna. AutoPSEO face una și o trimite într-un article.updated |
Răspunsurile de eroare au semnificații diferite:
| Răspunsul tău | Cum îl citește AutoPSEO | Ce se întâmplă |
|---|---|---|
Un 4xx cu { "error": "unknown_event" } sau un 501 | Nu gestionezi încă această comandă | Livrarea așteaptă, fără reîncercări |
Un 409 cu { "error": "conflict" } | Actualizarea a fost scrisă pornind de la o versiune pe care pagina ta nu o mai are | Livrarea eșuează fără reîncercări, iar articolul este recitit de pe site-ul tău înainte de următoarea lui modificare |
Un 4xx, altul decât 408 sau 429, cu { "error": "<code>", "details": [] } la path.add sau path.remove | Un refuz cu motivul tău | Înregistrat; vezi Pagini tipizate și căi noi |
| Orice altceva: niciun răspuns în 10 secunde, o eroare de rețea, o redirecționare, 408, 429, 5xx sau alt 4xx | O defecțiune temporară | Reîncercat cu backoff |
Reîncercări și livrări înlocuite
O livrare este trimisă imediat ce modificarea este salvată, iar un program la fiecare cinci minute trimite tot ce este scadent, începând cu cele mai vechi. După o defecțiune temporară, AutoPSEO așteaptă 1 minut, apoi 5 minute, 15 minute, 1 oră, 3 ore, 6 ore și 12 ore între încercări. A opta încercare eșuată marchează livrarea ca eșuată, iar pagina Conținut o numără într-o insignă roșie de lângă webhook.
Când există o livrare mai nouă pentru același articol, una mai veche aflată încă în așteptare este marcată ca înlocuită și nu mai este trimisă niciodată. Fiecare livrare conține articolul întreg, așa că cea mai nouă este tot ce are nevoie site-ul tău. Comenzile de tip cale despre aceeași valoare funcționează la fel.
Receptorul tău poate vedea o livrare de două ori, de exemplu când a stocat articolul, dar a răspuns după timeout-ul de 10 secunde. Deduplică după X-AutoPSEO-Delivery sau fă fiecare scriere idempotentă, stocând după article.id. article.updated poate ajunge și la un articol pe care l-ai șters după un article.unpublished, fiindcă republicarea unui articol retras trimite o actualizare: tratează actualizările ca „creează sau înlocuiește”.
Comenzi pe care site-ul tău nu le gestionează încă
Un site poate implementa protocolul comandă cu comandă. AutoPSEO păstrează starea fiecărei comenzi și o află în trei moduri:
- Teste. După fiecare ping reușit, AutoPSEO trimite câte un
testpentru fiecare comandă. Corpul este{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, undetestconține un exemplu cu forma comenzii reale și referitor la<type path>/autopseo-connection-test, o adresă pe care niciun site nu o are. Verifică-l ca pe comanda reală, nu modifica nimic și răspunde cu un 2xx:{ "test": "<command>", "ok": true }pentru Conectată,{ "test": "<command>", "ok": false, "error": "unknown_event" }pentru Neconectată. Un răspuns cuok: falseși alt cod de eroare, cum ar fi retragerea unei adrese pe care nu o ai, contează tot ca „conectată”. Exemplul se află subtest, niciodată la nivelul superior, așa că un receptor care nu știe nimic despre teste nu găsește în el niciun articol. - Livrări și apeluri reale. Un 2xx marchează comanda ca fiind conectată;
unknown_evento marchează ca neconectată. - Răspunsul la ping. Comenzile pe care răspunsul tău le listează în
autopseo.eventsapar ca Declarată, netestată până când un test sau o livrare spune mai mult.
O livrare pentru o comandă neconectată așteaptă. Pagina Conținut listează ce așteaptă, de exemplu „Site-ul tău nu retrage încă articole”, cu un buton Făcut pe site pentru orice ai efectuat manual; AutoPSEO nu va mai trimite niciodată acel element. Când un test sau o livrare ulterioară găsește comanda conectată, tot ce aștepta după ea este trimis.
Răspunsul la ping
Răspunde la ping cu un 2xx. Un site care doar primește articole nu are nevoie de nimic în plus. Ca să îi spui AutoPSEO ce mai face site-ul tău, include un obiect 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"
}
}
| Câmp | Semnificație |
|---|---|
version | 1. Obiectul este ignorat dacă versiunea nu este cel puțin 1 |
events | Comenzile pe care le gestionezi, afișate ca declarate până sunt testate |
covers | true când paginile tale afișează o copertă la fiecare articol. Atunci autorul AutoPSEO face coperta în timp ce scrie, deci prima livrare o conține, iar propriului tău AI i se cere să furnizeze una. Vezi Coperte |
indexNow | { key, keyLocation? }: cheia IndexNow pe care o servești (8 până la 128 de litere, cifre și cratime) și, când fișierul cheie nu se află la /<key>.txt, locația lui https:// |
export | Ce predă site.export: articles, topics, profile |
adopt | Accepți articles.adopted, astfel că o scanare completă poate prelua articolele tale existente |
handover | Accepți site.handover |
types | Cheile tipurilor de conținut pe care le accepți (până la 50) |
writer | Cine scrie astăzi articolele tale noi: site sau autopseo |
IndexNow pentru adresele pe care le servești
Când răspunsul tău la ping declară indexNow, AutoPSEO face ping la IndexNow, la api.indexnow.org, după livrările pe care le accepți, iar site-ul tău nu mai trimite el însuși niciun ping.
- Trimite
urlsdin răspunsul tău. Fărăurls, trimiteurldin răspunsul tău, altfel URL-ul articolului.urls: []nu trimite nimic. - Se includ doar adresele de pe propria ta gazdă, așa cum cere protocolul IndexNow: gazda din
keyLocation, altfel gazda adresei site-ului tău. - Fiecare rulare de livrare trimite un singur ping pe site, cu până la 10.000 de adrese.
- 200 și 202 sunt acceptate. Un 429, un 5xx sau lipsa unui răspuns păstrează adresele și reîncearcă după o oră, dublând intervalul de fiecare dată până la o zi (mai mult când
Retry-Aftero cere), împreună cu ce au adăugat livrările ulterioare. Un 403 (cheie nevalidă) sau un 422 (adrese care nu sunt pe gazdă) este înregistrat, iar adresele sunt abandonate. - Pagina Conținut arată ultimul ping: când, câte adrese, statusul HTTP și următoarea încercare.
- Cât timp AutoPSEO găzduiește și el articolele, paginile găzduite primesc ping cu cheia proprie a AutoPSEO, iar a ta nu este folosită.
Instrumentul IndexNow din secțiunea Indexare este un trimițător manual separat; vezi IndexNow.
Conflicte: textul site-ului tău este originalul
version din răspunsul tău (sau version al unui articol din exportul tău) este ceea ce crede AutoPSEO că este pagina ta. Fiecare article.updated îl poartă ca basedOn. Dacă pagina s-a schimbat între timp pe site-ul tău, de exemplu după o corectură a unui editor, răspunde cu 409 și { "error": "conflict" } și nu stoca nimic. Livrarea eșuează fără reîncercări.
Înainte ca o modificare aflată în coadă să fie scrisă pe un articol live, AutoPSEO recitește acel articol de pe site-ul tău cu site.export și paths, dacă site-ul tău gestionează site.export. Dacă versiunea ta a evoluat, textul tău devine următoarea revizie a articolului (writer import), iar actualizarea care urmează poartă versiunea ta curentă ca basedOn.
path.add și path.remove
Aceste două livrări privesc o singură valoare a unui tip de conținut tipizat, de exemplu o companie în /interviews/{company}/{role}. Ele poartă un obiect path în loc de 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"] }
}
}
| Câmp | Semnificație |
|---|---|
path | Adresa proprie a valorii pe site-ul tău |
type | Tipul de conținut, ca { key, name, path } |
part | Partea din adresă pe care o ocupă valoarea |
value | { value, name }, plus, la path.add, aliases, url, sameAs, description și facts adunate de autorul AutoPSEO. Fiecare fapt are text, source, lastVerified și un tier care poate fi official sau reported |
takes | La path.add: valorile celorlalte părți după care caută oamenii împreună cu aceasta |
redirectTo | La path.remove: unde trebuie să ducă de acum înainte adresele valorii, cu un 301 |
Răspunde cu un 2xx când ai preluat comanda; la path.add poți adăuga url-ul la care se află acum valoarea, iar la path.remove urls pe care ai încetat să le servești, care merg la IndexNow. Ca să refuzi, răspunde cu un 4xx și { "error": "<code>", "details": ["<why>"] }. La path.add, invalid_value trimite valoarea înapoi la autor cu motivul tău (al treilea refuz este definitiv), iar orice alt cod o refuză definitiv. La path.remove, not_found contează ca realizat, iar orice alt cod, cum ar fi 409 in_use, lasă totul neschimbat. Marcarea unei comenzi de cale aflate în așteptare drept Făcut pe site contează ca un da din partea site-ului tău.
Apeluri: cereri al căror răspuns îl citește AutoPSEO
Apelurile sunt semnate ca livrările. Un apel așteaptă implicit până la 60 de secunde și 30 de secunde pentru recitiri și verificări de cale, și citește răspunsuri de până la 24 MB. Orice răspuns diferit de 2xx face apelul să eșueze.
site.export
Cererea conține export: { what, cursor, limit }, plus since (o oră ISO) pentru subiecte sau paths pentru o recitire. Răspunde cu { "items": [], "next": "<cursor>" }; AutoPSEO cere din nou, cu cursor setat la next-ul tău, până când acesta este gol sau null.
what: "articles"rulează la scanările complete ale site-ului, câte 10 pe pagină, când ai declaratexportcuarticlesșiadopt: true. Un răspuns 400 înseamnă „nesuportat”. Cupaths: ["/answers/x"]șilimit: 1recitește un singur articol; răspunde doar cu acel articol.what: "topics"rulează la fiecare scanare și la sincronizarea zilnică, câte 50 pe pagină; sincronizarea zilnică trimitesince, momentul ultimei preluări.what: "profile"rulează la fiecare scanare. Răspunde cu{ "profile": { types, languages, rules, categories, format } }, fiecare opțional:typesca[{ key, name, path }], iarrulescubrandTerms,synonyms,noise,onTopicșioffTopic.
Un articol exportat are nevoie de externalId, un slug (litere și cifre din orice scriere și cratime, cel mult 200 de caractere, segmente unite prin /), un path care se termină cu acel slug și copy cu cel puțin title, h1 și o secțiune. Câmpuri opționale: lang (implicit en), type (o cheie de tip de conținut), url, keyword, category, datePublished, dateModified, version, translations, owner (id-ul articolului în AutoPSEO, după adoptare) și maintenance: { kind: "refresh" or "optimize", reason }, care pune în coadă acea sarcină. Fiecare articol exportat devine un articol gestionat, cu textul tău ca primă revizie, și nu se trimite nimic înapoi. Exportul repetat este sigur, iar un articol retras de AutoPSEO nu mai revine niciodată printr-un export.
Un element de subiect este { keyword, status?, volume?, source?, externalId?, type?, reason? }, unde status este queued, planned sau rejected.
articles.adopted
După import, AutoPSEO îți cere să îi permiți să gestioneze articolele noi: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Răspunde cu { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Un articol pe care nu îl confirmi este eliminat din nou din AutoPSEO, cu excepția cazului în care AutoPSEO a scris deja o revizie a lui.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, trimis când cineva alege Lasă AutoPSEO să le scrie sau Predă scrierea înapoi site-ului pe pagina Site-ul tău a proprietății. Răspunde cu { "ok": true, "writer": "autopseo" }. După o predare către AutoPSEO, site-ul tău îi transmite subiectele pe care le validează, în loc să le scrie el.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Răspunde cu câte un rezultat pentru fiecare cale: { "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."] }] }. Un refuz este păstrat împreună cu motivul tău și nu mai este cerut din nou; o cale pe care o omiți este cerută din nou mai târziu.
Un receptor minimal
Un Cloudflare Worker care verifică semnătura cu Web Crypto API, gestionează cele trei comenzi pentru articole și le refuză pe celelalte cu unknown_event. Păstrează articolele într-un namespace KV legat ca ARTICLES, iar secretul de semnare într-un secret Worker numit AUTOPSEO_SECRET.
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);
},
};
În Node.js, verifică la fel, cu createHmac și timingSafeEqual, peste corpul brut primit de framework-ul tău:
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));
}
Mai departe: ce ajunge la webhook este stabilit de scriere și revizuire și de mentenanță; fluxul complet este în prezentarea generală a motorului de conținut.