ninoraıHelp Към приложението

Интеграции · Webhook

Получаване на статии от Ninorai чрез Webhook

Ninorai пише и публикува SEO статии. За сайтове без CMS всяка готова статия се изпраща като подписана HTTP заявка към адрес, който вие задавате. Тази страница описва точно какво получавате и как да го проверите.

Как работи

  1. Вие публикувате HTTPS endpoint на сайта си, например https://www.example.bg/api/ninorai, и измисляте таен ключ (произволен низ, поне 16 знака).
  2. Собственикът на акаунта въвежда адреса и ключа в Ninorai. Ninorai изпраща тестово събитие ping, за да провери връзката.
  3. Когато статия е готова, Ninorai изпраща POST към адреса ви с ZIP архив в тялото: HTML на статията, всички изображения и manifest.json с метаданните.
  4. Вашият код проверява подписа, разархивира и записва статията там, където сайтът ви чете съдържание. Отговаряте с 2xx и по желание с адреса на публикуваната страница.

Заявката

POST {вашият адрес} с тяло суров ZIP архив и Content-Type: application/zip. Заглавки:

ЗаглавкаСтойност
X-NinorAI-Eventarticle.publish за нова статия, article.update за промяна на вече изпратена, ping за тест на връзката.
X-NinorAI-DeliveryUUID, уникален за всеки опит за доставка. Използвайте го за идемпотентност: една и съща доставка може да пристигне повторно при мрежова грешка.
X-NinorAI-Article-IdUUID на статията в Ninorai. Един и същ при publish и всички следващи update за нея. Липсва при ping.
X-NinorAI-TimestampUnix време в секунди, в момента на изпращане. Част от подписа.
X-NinorAI-Signaturesha256=<hex>, HMAC-SHA256 с вашия таен ключ (виж по-долу).

Събитието ping идва с малко JSON тяло {"event":"ping"} и Content-Type: application/json, със същата схема на подписване. Отговорете с 2xx, без да правите нищо друго.

Времето за отговор е 30 секунди. Ако обработката е по-бавна (например commit в Git и ново build), приемете архива, върнете 2xx веднага и довършете асинхронно.

Подписът

Подписът се изчислява върху низа {timestamp}.{body}, където timestamp е стойността на X-NinorAI-Timestamp, точката е буквална, а body са суровите байтове на тялото. Ключът е вашият таен ключ, алгоритъмът HMAC-SHA256, резултатът е hex с малки букви, с префикс sha256=.

expected = "sha256=" + hex( HMAC_SHA256( key = secret, message = timestamp + "." + rawBody ) )

Проверявайте:

При невалиден подпис отговорете с 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

ПолеТипЗначение
eventstringarticle.publish или article.update
article_iduuidИдентификатор на статията в Ninorai
cms_post_idstring | nullПри update: стойността post_id, която сте върнали при първоначалното публикуване
titlestringЗаглавие (H1)
slugstringПредложен URL slug, латиница
meta_titlestringЗа <title>
meta_descriptionstringЗа <meta name="description">
statusstringpublish, draft или future (планирана за по-късно)
scheduled_atISO 8601 | nullКога да стане видима, когато status е future
categoriesstring[]Категории
tagsstring[]Етикети
featured_imagestring | nullОтносителен път в архива към водещото изображение
schema_jsonstring | nullJSON-LD като низ, за <script type="application/ld+json">
languagestringЕзик на статията, например bg

Общият размер на изображенията в един архив е ограничен до 25 MB.

Отговорът

Всеки 2xx означава прието. По желание върнете JSON:

{ "post_id": "вашият вътрешен идентификатор", "url": "https://www.example.bg/blog/kak-da-..." }

Пример за 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:

И в двата случая страницата на статията трябва да изведе meta_title, meta_description, JSON-LD от schema_json, водещото изображение и да има канонични, стабилни адреси, защото следващите статии ще линкват към тях.

Настройка в Ninorai

  1. Генерирайте таен ключ, например openssl rand -hex 32, и го запишете като NINORAI_WEBHOOK_SECRET в средата на сайта.
  2. Публикувайте endpoint-а на публичен HTTPS адрес. Вътрешни адреси, localhost и частни IP мрежи се отхвърлят.
  3. В Ninorai: Сайтове → вашият сайт → Свързване, тип Webhook, попълнете адреса и тайния ключ и запазете. Ninorai изпраща ping и показва дали връзката е успешна.
  4. Ако искате да преглеждате всяка статия преди да бъде изпратена, изберете режим на публикуване „с одобрение“ в настройките на сайта. Иначе статиите тръгват автоматично по календара.

Контролен списък преди пускане