Интеграции · Webhook
Получаване на статии от Ninorai чрез Webhook
Ninorai пише и публикува SEO статии. За сайтове без CMS всяка готова статия се изпраща като подписана HTTP заявка към адрес, който вие задавате. Тази страница описва точно какво получавате и как да го проверите.
Как работи
- Вие публикувате HTTPS endpoint на сайта си, например
https://www.example.bg/api/ninorai, и измисляте таен ключ (произволен низ, поне 16 знака). - Собственикът на акаунта въвежда адреса и ключа в Ninorai. Ninorai изпраща тестово събитие
ping, за да провери връзката. - Когато статия е готова, Ninorai изпраща
POSTкъм адреса ви с ZIP архив в тялото: HTML на статията, всички изображения иmanifest.jsonс метаданните. - Вашият код проверява подписа, разархивира и записва статията там, където сайтът ви чете съдържание. Отговаряте с
2xxи по желание с адреса на публикуваната страница.
Заявката
POST {вашият адрес} с тяло суров ZIP архив и Content-Type: application/zip. Заглавки:
| Заглавка | Стойност |
|---|---|
X-NinorAI-Event | article.publish за нова статия, article.update за промяна на вече изпратена, ping за тест на връзката. |
X-NinorAI-Delivery | UUID, уникален за всеки опит за доставка. Използвайте го за идемпотентност: една и съща доставка може да пристигне повторно при мрежова грешка. |
X-NinorAI-Article-Id | UUID на статията в Ninorai. Един и същ при publish и всички следващи update за нея. Липсва при ping. |
X-NinorAI-Timestamp | Unix време в секунди, в момента на изпращане. Част от подписа. |
X-NinorAI-Signature | sha256=<hex>, HMAC-SHA256 с вашия таен ключ (виж по-долу). |
Събитието ping идва с малко JSON тяло {"event":"ping"} и Content-Type: application/json, със същата схема на подписване. Отговорете с 2xx, без да правите нищо друго.
2xx веднага и довършете асинхронно.Подписът
Подписът се изчислява върху низа {timestamp}.{body}, където timestamp е стойността на X-NinorAI-Timestamp, точката е буквална, а body са суровите байтове на тялото. Ключът е вашият таен ключ, алгоритъмът HMAC-SHA256, резултатът е hex с малки букви, с префикс sha256=.
expected = "sha256=" + hex( HMAC_SHA256( key = secret, message = timestamp + "." + rawBody ) )
Проверявайте:
- Подписът съвпада, сравнен с константно време (
crypto.timingSafeEqualв Node,hash_equalsв PHP). X-NinorAI-Timestampе в рамките на 5 минути от текущото време, за да не може стара заявка да бъде преизпратена.- Четете тялото като сурови байтове преди каквото и да е парсване. Всяка промяна на байтовете разваля подписа.
При невалиден подпис отговорете с 401. Ninorai тогава спира опитите и уведомява собственика, че ключът е грешен.
Архивът
manifest.json метаданни (по-долу)
article.html тялото на статията, чист HTML без <html>/<body>
images/{slug}.jpg водещо изображение
images/{slug}-1.png изображения в текста, по ред на появяване
images/{slug}-2.jpg
images/{slug}-schema-1.jpg изображения, споменати само в JSON-LD
Всеки <img src> в article.html вече сочи към относителен път images/.... Качете изображенията където ви е удобно и пренапишете пътищата. Не разчитайте на конкретни имена на файлове: четете ги от manifest.json и от HTML-а.
manifest.json
| Поле | Тип | Значение |
|---|---|---|
event | string | article.publish или article.update |
article_id | uuid | Идентификатор на статията в Ninorai |
cms_post_id | string | null | При update: стойността post_id, която сте върнали при първоначалното публикуване |
title | string | Заглавие (H1) |
slug | string | Предложен URL slug, латиница |
meta_title | string | За <title> |
meta_description | string | За <meta name="description"> |
status | string | publish, draft или future (планирана за по-късно) |
scheduled_at | ISO 8601 | null | Кога да стане видима, когато status е future |
categories | string[] | Категории |
tags | string[] | Етикети |
featured_image | string | null | Относителен път в архива към водещото изображение |
schema_json | string | null | JSON-LD като низ, за <script type="application/ld+json"> |
language | string | Език на статията, например bg |
Общият размер на изображенията в един архив е ограничен до 25 MB.
Отговорът
Всеки 2xx означава прието. По желание върнете JSON:
{ "post_id": "вашият вътрешен идентификатор", "url": "https://www.example.bg/blog/kak-da-..." }
urlсе показва на собственика в Ninorai като адрес на публикуваната статия и се използва за вътрешни връзки между статиите. Без него Ninorai приема{адрес на сайта}/{slug}.post_idсе връща при следващarticle.updateкатоcms_post_id. Без него се използва UUID-то на доставката.401или403: Ninorai спира и уведомява собственика за невалиден ключ.- Друга грешка или липса на отговор: Ninorai опитва още два пъти, после отбелязва публикуването като неуспешно.
Пример за Next.js (App Router) във Vercel
Route handler на app/api/ninorai/route.ts. Разархивирането е с jszip; подписът се проверява с вградения crypto. Тайният ключ е в променлива на средата NINORAI_WEBHOOK_SECRET.
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
import JSZip from "jszip";
export const runtime = "nodejs";
const SECRET = process.env.NINORAI_WEBHOOK_SECRET!;
function verify(raw: Buffer, timestamp: string | null, signature: string | null): boolean {
if (!timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected =
"sha256=" +
createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), raw]))
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const raw = Buffer.from(await req.arrayBuffer());
const ok = verify(raw, req.headers.get("x-ninorai-timestamp"), req.headers.get("x-ninorai-signature"));
if (!ok) return NextResponse.json({ error: "bad signature" }, { status: 401 });
const event = req.headers.get("x-ninorai-event");
if (event === "ping") return NextResponse.json({ ok: true });
const zip = await JSZip.loadAsync(raw);
const manifest = JSON.parse(await zip.file("manifest.json")!.async("string"));
let html = await zip.file("article.html")!.async("string");
// Запишете изображенията и пренапишете пътищата в HTML-а.
for (const name of Object.keys(zip.files).filter((n) => n.startsWith("images/"))) {
const bytes = await zip.file(name)!.async("nodebuffer");
const publicUrl = await saveImage(name, bytes); // вашата функция
html = html.split(name).join(publicUrl);
}
const url = await saveArticle({ // вашата функция
id: manifest.article_id, // ключ за update
slug: manifest.slug,
title: manifest.title,
metaTitle: manifest.meta_title,
metaDescription: manifest.meta_description,
html,
featuredImage: manifest.featured_image,
schemaJson: manifest.schema_json,
status: manifest.status,
scheduledAt: manifest.scheduled_at,
categories: manifest.categories,
tags: manifest.tags,
language: manifest.language,
});
return NextResponse.json({ post_id: manifest.article_id, url });
}
За article.update намерете статията по article_id (или по cms_post_id, ако сте върнали друг post_id) и я презапишете. Обработвайте всяка доставка идемпотентно: при повторна заявка със същия X-NinorAI-Delivery просто върнете 2xx.
Къде да отиде статията
Ninorai не налага как съхранявате съдържанието. Два работещи модела за ръчно писан Next.js сайт във Vercel:
- Съдържание в хранилище: handler-ът записва HTML, изображения и метаданни през Vercel Blob, KV, Postgres или друга база, а страницата
/blog/[slug]ги чете динамично (или с ISR иrevalidatePath). Статията е онлайн секунди след доставката. - Съдържание в Git: handler-ът прави commit на файловете в репото през GitHub API (например в
content/blog/{slug}/), Vercel прави нов build. Отговорете с2xxпреди commit-а, защото build отнема минути.
И в двата случая страницата на статията трябва да изведе meta_title, meta_description, JSON-LD от schema_json, водещото изображение и да има канонични, стабилни адреси, защото следващите статии ще линкват към тях.
Настройка в Ninorai
- Генерирайте таен ключ, например
openssl rand -hex 32, и го запишете катоNINORAI_WEBHOOK_SECRETв средата на сайта. - Публикувайте endpoint-а на публичен HTTPS адрес. Вътрешни адреси, localhost и частни IP мрежи се отхвърлят.
- В Ninorai: Сайтове → вашият сайт → Свързване, тип Webhook, попълнете адреса и тайния ключ и запазете. Ninorai изпраща
pingи показва дали връзката е успешна. - Ако искате да преглеждате всяка статия преди да бъде изпратена, изберете режим на публикуване „с одобрение“ в настройките на сайта. Иначе статиите тръгват автоматично по календара.
Контролен списък преди пускане
- Тялото се чете като сурови байтове, преди парсване.
- Подписът се проверява върху
timestamp + "." + bodyс константно време; старите timestamp-и се отхвърлят. pingвръща2xxбез странични ефекти.- Повторна доставка със същия
X-NinorAI-Deliveryне създава дубликат. article.updateпрезаписва същата статия, не създава нова.- Пътищата към изображенията в HTML-а са пренаписани към публични адреси.
- Отговорът идва под 30 секунди и съдържа
urlна статията.