اربط موقعك الخاص

انشر علىموقعك الخاص.

إذا لم يكن موقعك على WordPress أو Webflow أو منصة أخرى مدعومة، فابنِ مستقبِلًا صغيرًا. يرسل إليه AutoSeoPro.ai طلبات موقّعة لنشر المقالات وتحديثها وحذفها، ثم يردّ موقعك بإيصال.

ما هو

المستقبِل هو عنوان ويب في موقعك يقبل طلبات POST من AutoSeoPro.ai. يُوقَّع كل طلب بسرّ لا يعرفه إلا أنت وAutoSeoPro.ai، فيستطيع موقعك التحقق من أصالته. لكل مقال أربعة أحداث ممكنة: ping (اختبار) وpublish وupdate وdelete.

لا يعدّ AutoSeoPro.ai المقال منشورًا إلا إذا كان ردّك إيصالًا كاملًا يطابق الطلب. مجرد 200 ليس دليلًا.

قد يحمل كائن المقال حقولًا إضافية غير الموثقة هنا، مثل التصنيفات والوسوم. تجاهل ما لا تستخدمه.

الإعداد في خمس خطوات

  1. ابنِ المستقبِل. ابدأ من إحدى الحزم أدناه (Node.js أو PHP أو Python) واستبدل دوال المعالجة باستدعاءات إلى نظام إدارة المحتوى أو قاعدة البيانات لديك.
  2. أنشئ سرًّا. ولّد 32 حرفًا عشوائيًا على الأقل، مثلًا بالأمر openssl rand -hex 32، واحفظه في بيئة المستقبِل، وليس في شيفرة تنشرها.
  3. أضفه في AutoSeoPro.ai. افتح الاتصالات واختر منصة أخرى (REST API) ثم أدخل عنوان المستقبِل والعنوان العام لموقعك (أصل https بالضبط، مثل https://www.example.com) والسرّ.
  4. شغّل اختبار الاتصال. يرسل AutoSeoPro.ai ping موقّعًا ولا يقبل إلا ردًّا يكرر مفتاح عدم التكرار مع accepted: true.
  5. انشر. انشر مقالًا واحدًا يدويًا للتحقق من النتيجة في موقعك، ثم فعّل الطيار الآلي.

ما يحمله كل طلب

كل طلب هو POST بجسم JSON وهذه الترويسات.

الترويسةالمعنى
Content-Typeدائمًا application/json.
AuthorizationBearer متبوعًا بسرّك. السرّ نفسه يوقّع الطلب.
X-Rankora-Protocolدائمًا 2026-08-27. ارفض أي قيمة أخرى.
X-Rankora-Eventping أو publish أو update أو delete. يجب أن تساوي حقل event في الجسم.
X-Rankora-Idempotency-Keyفريد لكل عملية وبحد أقصى 128 حرفًا. استخدمه للتعرف على الطلبات المعادة.
X-Rankora-Timestampوقت Unix بالثواني لحظة توقيع الطلب.
X-Rankora-Signaturev1= متبوعًا بـ HMAC-SHA256 للطلب بالنظام الست عشري بأحرف صغيرة.

كيف يُحسب التوقيع

تحقق من التوقيع قبل تحليل الجسم. إذا فشل أي فحص فأجب بـ 401 ولا تفعل شيئًا آخر.

  1. اقرأ جسم الطلب الخام كبايتات. لا تحلله ثم تعيد ترميزه، لأن ذلك يغيّر البايتات.
  2. ابنِ السلسلة timestamp.idempotencyKey.rawBody: ترويسة X-Rankora-Timestamp وترويسة X-Rankora-Idempotency-Key والجسم الخام، مفصولة بنقاط.
  3. احسب HMAC-SHA256 لتلك السلسلة بسرّك واكتبه بالنظام الست عشري بأحرف صغيرة.
  4. قارن v1= متبوعًا بذلك الناتج مع ترويسة X-Rankora-Signature في زمن ثابت، مثلًا باستخدام crypto.timingSafeEqual أو hash_equals أو hmac.compare_digest.
  5. ارفض أي طابع زمني يبعد أكثر من خمس دقائق عن ساعتك، في أي اتجاه، وارفض رمز Authorization: Bearer المفقود أو الخاطئ.
التوقيع في Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the Buffer you received, untouched.
const signed = `${timestamp}.${idempotencyKey}.`;
const expected = "v1=" + createHmac("sha256", secret)
  .update(signed)
  .update(rawBody)
  .digest("hex");

const ok = expected.length === signature.length &&
  timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;

عدم التكرار

يعيد AutoSeoPro.ai المحاولة بعد انتهاء المهلة، لذا قد يصل الحدث نفسه أكثر من مرة. يجب أن يغيّر مستقبِلك موقعك مرة واحدة فقط.

  • يحمل كل حدث مفتاح عدم تكرار. احفظ المفتاح مع الرد الدقيق الذي أرسلته له.
  • عندما يعود مفتاح، أعد الرد المحفوظ ولا تلمس نظام إدارة المحتوى مرة أخرى.
  • طابق بالمفتاح وحده. للمحاولة المعادة طابع زمني وoccurredAt جديدان، فيختلف جسمها عن المحاولة الأولى.
  • احفظ الرد فقط بعد نجاح التغيير. إذا فشلت معالجتك فأجب برمز خطأ ولا تحفظ شيئًا، كي تنجح المحاولة المعادة.
  • قد يصل طلبان بالمفتاح نفسه في اللحظة نفسها. استخدم قفلًا لكل مفتاح حتى ينفّذ معالجتك طلب واحد فقط.
  • يستخدم التحديث مفتاحًا مشتقًا من محتوى المقال: المحتوى المتغير مفتاح جديد، والمحتوى المطابق إعادة إرسال.

أشكال الردود

أجب بـ 200 وجسم JSON. تعرض الأمثلة جسم طلب كل حدث باختصار والرد الدقيق الذي يتوقعه AutoSeoPro.ai.

  • قيمة version هي 2026-08-27 وidempotencyKey هو مفتاح الطلب.
  • تكون state هي published عندما تكون رؤية المقال publish، وdraft عندما تكون draft. لا تنشر مسودة أبدًا.
  • postId وreceiptId معرّفان ثابتان بحد أقصى 256 حرفًا (حروف وأرقام و . _ : / -). يعيد التحديث قيمة postId التي تلقاها.
  • حذف منشور محذوف أصلًا يُعدّ نجاحًا أيضًا: أجب بـ deleted: true.
  • لرفض حدث، أجب بـ 4xx أو 5xx مع جسم application/problem+json. لا تجب بـ 2xx عند الفشل أبدًا.

Ping ping

جسم الطلب
{
  "version": "2026-08-27",
  "event": "ping",
  "platform": "rest_api",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "occurredAt": "2026-10-13T09:30:00.000Z"
}
الرد · 200
{
  "version": "2026-08-27",
  "event": "ping",
  "idempotencyKey": "ping:6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "accepted": true
}

النشر publish

جسم الطلب
{
  "version": "2026-08-27",
  "event": "publish",
  "platform": "rest_api",
  "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "article": {
    "title": "How to repot a monstera",
    "html": "<p>Repot in spring, one size up…</p>",
    "slug": "how-to-repot-a-monstera",
    "metaTitle": "How to repot a monstera",
    "metaDescription": "A step-by-step guide to repotting a monstera without shocking the roots.",
    "focusKeyword": "repot monstera",
    "featuredImageUrl": "https://assets.example.net/monstera.jpg",
    "authorName": "Ana Silva",
    "publishMode": "publish",
    "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
    "visibility": "publish"
  }
}
الرد · 200
{
  "version": "2026-08-27",
  "event": "publish",
  "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
  "receiptId": "rcpt_9f3a1c7e2b4d6a80",
  "postId": "post_41",
  "state": "published",
  "url": "https://www.example.com/blog/how-to-repot-a-monstera"
}

التحديث update

جسم الطلب
{
  "version": "2026-08-27",
  "event": "update",
  "platform": "rest_api",
  "idempotencyKey": "update:3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "postId": "post_41",
  "article": {
    "title": "How to repot a monstera",
    "html": "<p>Repot in spring, one size up…</p>",
    "slug": "how-to-repot-a-monstera",
    "metaTitle": "How to repot a monstera",
    "metaDescription": "A step-by-step guide to repotting a monstera without shocking the roots.",
    "focusKeyword": "repot monstera",
    "featuredImageUrl": "https://assets.example.net/monstera.jpg",
    "authorName": "Ana Silva",
    "publishMode": "publish",
    "idempotencyKey": "article-018f0d64-6e90-7d11-a123-0123456789ab",
    "visibility": "publish"
  }
}
الرد · 200
{
  "version": "2026-08-27",
  "event": "update",
  "idempotencyKey": "update:3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e3b9d5c1e",
  "receiptId": "rcpt_c41e7a9025bd3f16",
  "postId": "post_41",
  "state": "published",
  "url": "https://www.example.com/blog/how-to-repot-a-monstera"
}

الحذف delete

جسم الطلب
{
  "version": "2026-08-27",
  "event": "delete",
  "platform": "rest_api",
  "idempotencyKey": "delete:8d2e5f10-7c3a-4b9e-a1d6-5e0f3c7b9a24",
  "occurredAt": "2026-10-13T09:30:00.000Z",
  "postId": "post_41"
}
الرد · 200
{
  "version": "2026-08-27",
  "event": "delete",
  "idempotencyKey": "delete:8d2e5f10-7c3a-4b9e-a1d6-5e0f3c7b9a24",
  "postId": "post_41",
  "deleted": true
}
401 · application/problem+json
{
  "type": "about:blank",
  "title": "Bad signature",
  "status": 401,
  "detail": "The signature does not match the body."
}

يجب أن يكون عنوان الرد على موقعك

يجب أن يكون url في ردّ publish أو update بروتوكول https وعلى العنوان العام للموقع الذي أدخلته في AutoSeoPro.ai تمامًا: المخطط والمضيف والمنفذ نفسها. يُرفض الرد الذي يحمل أي عنوان آخر باعتباره إيصالًا ناقصًا، فلا يستطيع خطأ أو مستقبِل مخترَق توجيه AutoSeoPro.ai إلى موقع آخر.

حزم المستقبِل

كل حزمة مستقبِل صغير كامل: فحص الرمز والتوقيع ونافذة الدقائق الخمس ومخزن عدم التكرار مع قفل ومعالج تجريبي يحفظ المقالات كملفات. انسخ الملفات واستبدل المعالجات باستدعاءات نظام إدارة المحتوى لديك.

Node.js (Express)

يتطلب Node 18 أو أحدث وnpm install express. ملف التحقق بلا أي تبعيات.

الملف: receiver-kits/node/autoseopro-receiver.mjs
/**
 * AutoSeoPro.ai signed publishing receiver: framework-free core (Node 18+, no dependencies).
 *
 * Protocol 2026-08-27. Copy this file next to server.mjs (the Express wiring).
 * It does five things, in this order, for every request:
 *
 *   1. checks the Bearer token (the same secret you pasted into AutoSeoPro.ai)
 *   2. checks the protocol version and the 5-minute timestamp window
 *   3. recomputes the HMAC-SHA256 signature over the RAW body bytes and compares it in constant time
 *   4. returns the stored reply when it has already seen this idempotency key (a replay never repeats the CMS change)
 *   5. otherwise runs your handler once, validates its answer, stores the reply and returns it
 *
 * Only step 5 touches your CMS. Everything else is the same for every site.
 */
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";

export const PROTOCOL = "2026-08-27";
export const WINDOW_SECONDS = 300;
export const MIN_SECRET_LENGTH = 32;

const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const EXTERNAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
const TIMESTAMP = /^[0-9]{10,13}$/;
const SIGNATURE = /^v1=[a-f0-9]{64}$/;
const EVENTS = new Set(["ping", "publish", "update", "delete"]);

/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
export function safeEqual(a, b) {
  const left = createHash("sha256").update(String(a)).digest();
  const right = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(left, right);
}

/** `v1=` + hex HMAC-SHA256 of `timestamp.idempotencyKey.rawBody`. The timestamp is the header text exactly as sent. */
export function sign(secret, timestamp, idempotencyKey, rawBody) {
  const mac = createHmac("sha256", secret);
  mac.update(`${timestamp}.${idempotencyKey}.`);
  mac.update(rawBody); // the exact bytes received, never JSON.stringify(JSON.parse(body))
  return `v1=${mac.digest("hex")}`;
}

function problem(status, title, detail) {
  return { status, contentType: "application/problem+json", body: { type: "about:blank", title, status, detail } };
}

function header(headers, name) {
  const wanted = name.toLowerCase();
  for (const [key, value] of Object.entries(headers ?? {})) {
    if (key.toLowerCase() === wanted) return Array.isArray(value) ? value[0] : value;
  }
  return undefined;
}

/**
 * Checks everything that proves the request is genuinely from AutoSeoPro.ai and is fresh.
 * Returns `{ ok: true, event, idempotencyKey }` or `{ ok: false, response }`.
 */
export function verifyRequest({ headers, rawBody }, { secret, now = () => Date.now() }) {
  if (typeof secret !== "string" || secret.length < MIN_SECRET_LENGTH) {
    return { ok: false, response: problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.") };
  }
  const authorization = header(headers, "authorization") ?? "";
  if (!authorization.startsWith("Bearer ") || !safeEqual(authorization.slice(7), secret)) {
    return { ok: false, response: problem(401, "Unauthorized", "Missing or wrong bearer token.") };
  }
  if (header(headers, "x-rankora-protocol") !== PROTOCOL) {
    return { ok: false, response: problem(400, "Unsupported protocol version", `Only ${PROTOCOL} is supported.`) };
  }
  const event = header(headers, "x-rankora-event") ?? "";
  const idempotencyKey = header(headers, "x-rankora-idempotency-key") ?? "";
  const timestamp = header(headers, "x-rankora-timestamp") ?? "";
  const signature = header(headers, "x-rankora-signature") ?? "";
  if (!EVENTS.has(event) || !IDEMPOTENCY_KEY.test(idempotencyKey) || !TIMESTAMP.test(timestamp) || !SIGNATURE.test(signature)) {
    return { ok: false, response: problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.") };
  }
  // Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
  const seconds = timestamp.length >= 13 ? Math.floor(Number(timestamp) / 1000) : Number(timestamp);
  if (Math.abs(Math.floor(now() / 1000) - seconds) > WINDOW_SECONDS) {
    return { ok: false, response: problem(401, "Stale timestamp", "The request is outside the 5-minute window.") };
  }
  if (!safeEqual(signature, sign(secret, timestamp, idempotencyKey, rawBody))) {
    return { ok: false, response: problem(401, "Bad signature", "The signature does not match the body.") };
  }
  return { ok: true, event, idempotencyKey };
}

/** One JSON file per idempotency key, written atomically, plus an in-process lock so two simultaneous
 *  deliveries of the same key never both reach your handler. Behind several processes use a database
 *  table with a UNIQUE(idempotency_key) constraint instead. */
export class FileStore {
  constructor(directory) {
    this.directory = directory;
    this.chains = new Map();
    mkdirSync(directory, { recursive: true });
  }
  path(key) {
    return join(this.directory, `${createHash("sha256").update(key).digest("hex")}.json`);
  }
  get(key) {
    try {
      return JSON.parse(readFileSync(this.path(key), "utf8"));
    } catch {
      return undefined;
    }
  }
  put(key, reply) {
    const target = this.path(key);
    const temporary = `${target}.${randomBytes(6).toString("hex")}.tmp`;
    writeFileSync(temporary, JSON.stringify(reply));
    renameSync(temporary, target);
  }
  async withLock(key, work) {
    const previous = this.chains.get(key) ?? Promise.resolve();
    const run = previous.then(work, work);
    const tail = run.catch(() => undefined);
    this.chains.set(key, tail);
    try {
      return await run;
    } finally {
      if (this.chains.get(key) === tail) this.chains.delete(key);
    }
  }
}

/** True when `url` is https on the origin (scheme + host + port) of your public site, e.g. "https://www.example.com". */
function onPublicOrigin(url, publicSiteOrigin) {
  try {
    const candidate = new URL(url);
    return candidate.protocol === "https:" && candidate.origin === new URL(publicSiteOrigin).origin;
  } catch {
    return false;
  }
}

function parseBody(rawBody, event, idempotencyKey) {
  let payload;
  try {
    payload = JSON.parse(Buffer.from(rawBody).toString("utf8"));
  } catch {
    return { error: problem(400, "Invalid JSON", "The body is not valid JSON.") };
  }
  if (!payload || payload.version !== PROTOCOL || payload.event !== event || payload.idempotencyKey !== idempotencyKey) {
    return { error: problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.") };
  }
  return { payload };
}

/**
 * Handle one delivery.
 *
 * handlers = {
 *   publish({ article, idempotencyKey })           -> { postId, receiptId, url }
 *   update({ postId, article, idempotencyKey })    -> { postId, receiptId, url }   (postId must be the one received)
 *   delete({ postId, idempotencyKey })             -> void                         (deleting a missing post is still a success)
 * }
 * A handler throws an Error with `.status` (404, 409, 422...) to refuse; any other throw is a 500.
 * `ping` needs no handler. The article may carry fields beyond the ones documented: ignore what you do not use.
 */
export async function handleEvent({ headers, rawBody }, { secret, publicSiteOrigin, store, handlers, now }) {
  const verified = verifyRequest({ headers, rawBody }, { secret, now });
  if (!verified.ok) return verified.response;
  const { event, idempotencyKey } = verified;

  return store.withLock(idempotencyKey, async () => {
    // Replays are answered from the store and never reach the CMS again. The body of a retry can differ
    // (occurredAt changes), so the key alone decides.
    const stored = store.get(idempotencyKey);
    if (stored) return { status: 200, contentType: "application/json", body: stored };

    const parsed = parseBody(rawBody, event, idempotencyKey);
    if (parsed.error) return parsed.error;
    const { payload } = parsed;

    let receipt;
    try {
      if (event === "ping") {
        receipt = { version: PROTOCOL, event, idempotencyKey, accepted: true };
      } else if (event === "delete") {
        if (!EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for delete.");
        await handlers.delete({ postId: payload.postId, idempotencyKey });
        receipt = { version: PROTOCOL, event, idempotencyKey, postId: payload.postId, deleted: true };
      } else {
        const article = payload.article;
        if (!article || typeof article.title !== "string" || typeof article.html !== "string" || !article.title || !article.html) {
          return problem(422, "Invalid article", "article.title and article.html are required.");
        }
        if (event === "update" && !EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for update.");
        const result = event === "publish"
          ? await handlers.publish({ article, idempotencyKey })
          : await handlers.update({ postId: payload.postId, article, idempotencyKey });
        if (!EXTERNAL_ID.test(String(result?.postId)) || !EXTERNAL_ID.test(String(result?.receiptId)) || (event === "update" && result.postId !== payload.postId)) {
          return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.");
        }
        // The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
        if (!onPublicOrigin(result.url, publicSiteOrigin)) {
          return problem(500, "URL outside the public site", `url must be https on ${publicSiteOrigin}.`);
        }
        receipt = {
          version: PROTOCOL,
          event,
          idempotencyKey,
          receiptId: result.receiptId,
          postId: result.postId,
          // The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
          state: (article.visibility ?? article.publishMode) === "publish" ? "published" : "draft",
          url: result.url,
        };
      }
    } catch (error) {
      const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 600 ? error.status : 500;
      return problem(status, status === 500 ? "Receiver error" : "Refused", status === 500 ? "The receiver failed; the event was not recorded." : String(error.message));
    }
    store.put(idempotencyKey, receipt); // only successes are stored, so a failed event can be retried
    return { status: 200, contentType: "application/json", body: receipt };
  });
}
الملف: receiver-kits/node/server.mjs
/**
 * AutoSeoPro.ai publishing receiver: Express wiring + a demo handler that saves posts as JSON files.
 *
 *   npm install express
 *   AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)" PUBLIC_SITE_ORIGIN=https://www.example.com node server.mjs
 *
 * Put it behind HTTPS (your reverse proxy or host) and paste its public URL, e.g.
 * https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
 *
 * Replace the three functions in `handlers` with calls into your CMS or database.
 */
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import express from "express";
import { FileStore, handleEvent } from "./autoseopro-receiver.mjs";

const secret = process.env.AUTOSEOPRO_RECEIVER_SECRET ?? "";
const publicSiteOrigin = process.env.PUBLIC_SITE_ORIGIN ?? "";
const dataDirectory = process.env.RECEIVER_DATA_DIR ?? "./receiver-data";
if (secret.length < 32 || !publicSiteOrigin) {
  console.error("Set AUTOSEOPRO_RECEIVER_SECRET (32+ characters) and PUBLIC_SITE_ORIGIN (https://your-site).");
  process.exit(1);
}

const postsDirectory = join(dataDirectory, "posts");
mkdirSync(postsDirectory, { recursive: true });
const store = new FileStore(join(dataDirectory, "idempotency"));

const slugify = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
const postFile = (postId) => join(postsDirectory, `${createHash("sha256").update(postId).digest("hex")}.json`);
const urlFor = (post) => `${publicSiteOrigin.replace(/\/$/, "")}/blog/${post.slug}`;
const receiptIdFor = (idempotencyKey) => `rcpt_${createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 16)}`;

/**
 * Images: `article.featuredImageUrl` and any <img src> inside `article.html` are absolute https URLs.
 * Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
 * refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
 * Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
 */
const handlers = {
  publish({ article, idempotencyKey }) {
    const postId = `post_${randomUUID()}`;
    const post = { postId, slug: slugify(article.slug || article.title) || postId, article, state: article.visibility };
    writeFileSync(postFile(postId), JSON.stringify(post));
    return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
  },
  update({ postId, article, idempotencyKey }) {
    let post;
    try {
      post = JSON.parse(readFileSync(postFile(postId), "utf8"));
    } catch {
      throw Object.assign(new Error("Unknown postId."), { status: 404 });
    }
    post = { ...post, article, state: article.visibility, slug: slugify(article.slug || article.title) || post.slug };
    writeFileSync(postFile(postId), JSON.stringify(post));
    return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
  },
  delete({ postId }) {
    // Deleting something that is already gone is still a success: the goal state is "not there".
    writeFileSync(postFile(postId), JSON.stringify({ postId, deleted: true }));
  },
};

const app = express();
// The signature covers the exact bytes, so keep the body raw. Do NOT use express.json() on this route.
app.post("/autoseopro/events", express.raw({ type: "*/*", limit: "5mb" }), async (req, res) => {
  const reply = await handleEvent(
    { headers: req.headers, rawBody: Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) },
    { secret, publicSiteOrigin, store, handlers },
  );
  res.status(reply.status).type(reply.contentType).send(JSON.stringify(reply.body));
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Receiver listening on :${port}/autoseopro/events`));

PHP 8 بلا إطار عمل

بلا إطار عمل ولا حزم Composer. يعمل على استضافة PHP عادية.

الملف: receiver-kits/php/autoseopro_receiver.php
<?php
/**
 * AutoSeoPro.ai signed publishing receiver: the verification and idempotency core (PHP 8+, no framework,
 * no Composer packages). Protocol 2026-08-27.
 *
 * For every request it: checks the Bearer token, the protocol version and the 5-minute timestamp window,
 * recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time, answers a
 * replayed idempotency key from the store without touching your CMS, and otherwise runs your handler once
 * and stores the reply. Only the handlers in index.php talk to your CMS.
 */
declare(strict_types=1);

const AUTOSEOPRO_PROTOCOL = '2026-08-27';
const AUTOSEOPRO_WINDOW_SECONDS = 300;
const AUTOSEOPRO_MIN_SECRET_LENGTH = 32;

/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
function autoseopro_safe_equal(string $a, string $b): bool
{
    return hash_equals(hash('sha256', $a, true), hash('sha256', $b, true));
}

/** `v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text exactly as sent. */
function autoseopro_sign(string $secret, string $timestamp, string $idempotencyKey, string $rawBody): string
{
    return 'v1=' . hash_hmac('sha256', $timestamp . '.' . $idempotencyKey . '.' . $rawBody, $secret);
}

/** A problem+json answer: [status, content type, body]. */
function autoseopro_problem(int $status, string $title, string $detail): array
{
    return [$status, 'application/problem+json', ['type' => 'about:blank', 'title' => $title, 'status' => $status, 'detail' => $detail]];
}

/** Case-insensitive header lookup in an array of name => value. */
function autoseopro_header(array $headers, string $name): string
{
    foreach ($headers as $key => $value) {
        if (strcasecmp((string) $key, $name) === 0) {
            return is_array($value) ? (string) ($value[0] ?? '') : (string) $value;
        }
    }
    return '';
}

/** Request headers from $_SERVER. Some Apache/FastCGI setups hide Authorization; see the .htaccess note in index.php. */
function autoseopro_request_headers(): array
{
    $headers = [];
    foreach ($_SERVER as $key => $value) {
        if (strncmp($key, 'HTTP_', 5) === 0) {
            $headers[str_replace('_', '-', substr($key, 5))] = $value;
        }
    }
    if (!isset($headers['AUTHORIZATION']) && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
        $headers['AUTHORIZATION'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    }
    return $headers;
}

/**
 * Proves the request is genuinely from AutoSeoPro.ai and is fresh.
 * Returns ['ok' => true, 'event' => ..., 'idempotencyKey' => ...] or ['ok' => false, 'response' => [status, type, body]].
 */
function autoseopro_verify(array $headers, string $rawBody, string $secret, ?int $now = null): array
{
    if (strlen($secret) < AUTOSEOPRO_MIN_SECRET_LENGTH) {
        return ['ok' => false, 'response' => autoseopro_problem(500, 'Receiver is not configured', 'The shared secret must be at least 32 characters.')];
    }
    $authorization = autoseopro_header($headers, 'authorization');
    if (strncmp($authorization, 'Bearer ', 7) !== 0 || !autoseopro_safe_equal(substr($authorization, 7), $secret)) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Unauthorized', 'Missing or wrong bearer token.')];
    }
    if (autoseopro_header($headers, 'x-rankora-protocol') !== AUTOSEOPRO_PROTOCOL) {
        return ['ok' => false, 'response' => autoseopro_problem(400, 'Unsupported protocol version', 'Only ' . AUTOSEOPRO_PROTOCOL . ' is supported.')];
    }
    $event = autoseopro_header($headers, 'x-rankora-event');
    $key = autoseopro_header($headers, 'x-rankora-idempotency-key');
    $timestamp = autoseopro_header($headers, 'x-rankora-timestamp');
    $signature = autoseopro_header($headers, 'x-rankora-signature');
    if (!in_array($event, ['ping', 'publish', 'update', 'delete'], true)
        || preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/', $key) !== 1
        || preg_match('/^[0-9]{10,13}$/', $timestamp) !== 1
        || preg_match('/^v1=[a-f0-9]{64}$/', $signature) !== 1) {
        return ['ok' => false, 'response' => autoseopro_problem(400, 'Malformed headers', 'An X-Rankora-* header is missing or invalid.')];
    }
    // Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
    $seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp;
    if (abs(($now ?? time()) - $seconds) > AUTOSEOPRO_WINDOW_SECONDS) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Stale timestamp', 'The request is outside the 5-minute window.')];
    }
    if (!autoseopro_safe_equal($signature, autoseopro_sign($secret, $timestamp, $key, $rawBody))) {
        return ['ok' => false, 'response' => autoseopro_problem(401, 'Bad signature', 'The signature does not match the body.')];
    }
    return ['ok' => true, 'event' => $event, 'idempotencyKey' => $key];
}

/**
 * One JSON file per idempotency key plus an exclusive file lock, so two simultaneous deliveries of the same
 * key never both reach your handler. With several servers use a database table with a UNIQUE key instead.
 */
final class AutoSeoProFileStore
{
    public function __construct(private string $directory)
    {
        if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
            throw new RuntimeException('Cannot create ' . $directory);
        }
    }

    private function path(string $key, string $extension): string
    {
        return $this->directory . '/' . hash('sha256', $key) . '.' . $extension;
    }

    public function get(string $key): ?array
    {
        $file = $this->path($key, 'json');
        if (!is_file($file)) {
            return null;
        }
        $decoded = json_decode((string) file_get_contents($file), true);
        return is_array($decoded) ? $decoded : null;
    }

    public function put(string $key, array $reply): void
    {
        $file = $this->path($key, 'json');
        $temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
        file_put_contents($temporary, json_encode($reply, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        rename($temporary, $file);
    }

    public function withLock(string $key, callable $work): mixed
    {
        $lock = fopen($this->path($key, 'lock'), 'c');
        if ($lock === false || !flock($lock, LOCK_EX)) {
            throw new RuntimeException('Cannot lock the idempotency store.');
        }
        try {
            return $work();
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }
    }
}

/** True when $url is https on the origin (scheme + host + port) of your public site. */
function autoseopro_on_public_origin(mixed $url, string $publicSiteOrigin): bool
{
    if (!is_string($url)) {
        return false;
    }
    $candidate = parse_url($url);
    $configured = parse_url($publicSiteOrigin);
    if (!is_array($candidate) || !is_array($configured) || !isset($candidate['host'], $configured['host'])) {
        return false;
    }
    $origin = static fn (array $parts): string => strtolower(($parts['scheme'] ?? '') . '://' . $parts['host'] . ':' . ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)));
    return ($candidate['scheme'] ?? '') === 'https' && !isset($candidate['user']) && $origin($candidate) === $origin($configured);
}

function autoseopro_valid_external_id(mixed $value): bool
{
    return is_string($value) && preg_match('/^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,255}$/', $value) === 1;
}

/**
 * Handle one delivery. Returns [status, content type, body array].
 *
 * $config   = ['secret' => ..., 'public_site_origin' => 'https://www.example.com', 'now' => optional unix seconds]
 * $handlers = [
 *   'publish' => fn(array $article, string $idempotencyKey): array => ['postId' => ..., 'receiptId' => ..., 'url' => ...],
 *   'update'  => fn(string $postId, array $article, string $idempotencyKey): array => same shape, same postId,
 *   'delete'  => fn(string $postId, string $idempotencyKey): void   (deleting a missing post is still a success),
 * ]
 * A handler refuses by throwing AutoSeoProRefusal(status, message); any other exception is a 500.
 * ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
 */
function autoseopro_handle(array $headers, string $rawBody, array $config, AutoSeoProFileStore $store, array $handlers): array
{
    $verified = autoseopro_verify($headers, $rawBody, (string) $config['secret'], $config['now'] ?? null);
    if (!$verified['ok']) {
        return $verified['response'];
    }
    $event = $verified['event'];
    $key = $verified['idempotencyKey'];

    return $store->withLock($key, static function () use ($event, $key, $rawBody, $config, $store, $handlers): array {
        // Replays are answered from the store and never reach the CMS again. The body of a retry can differ
        // (occurredAt changes), so the key alone decides.
        $stored = $store->get($key);
        if ($stored !== null) {
            return [200, 'application/json', $stored];
        }
        $payload = json_decode($rawBody, true);
        if (!is_array($payload)) {
            return autoseopro_problem(400, 'Invalid JSON', 'The body is not valid JSON.');
        }
        if (($payload['version'] ?? null) !== AUTOSEOPRO_PROTOCOL || ($payload['event'] ?? null) !== $event || ($payload['idempotencyKey'] ?? null) !== $key) {
            return autoseopro_problem(400, 'Header and body disagree', 'version, event and idempotencyKey must match the headers.');
        }

        try {
            if ($event === 'ping') {
                $receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'ping', 'idempotencyKey' => $key, 'accepted' => true];
            } elseif ($event === 'delete') {
                if (!autoseopro_valid_external_id($payload['postId'] ?? null)) {
                    return autoseopro_problem(422, 'Invalid postId', 'postId is required for delete.');
                }
                ($handlers['delete'])($payload['postId'], $key);
                $receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'delete', 'idempotencyKey' => $key, 'postId' => $payload['postId'], 'deleted' => true];
            } else {
                $article = $payload['article'] ?? null;
                if (!is_array($article) || !is_string($article['title'] ?? null) || $article['title'] === '' || !is_string($article['html'] ?? null) || $article['html'] === '') {
                    return autoseopro_problem(422, 'Invalid article', 'article.title and article.html are required.');
                }
                if ($event === 'update' && !autoseopro_valid_external_id($payload['postId'] ?? null)) {
                    return autoseopro_problem(422, 'Invalid postId', 'postId is required for update.');
                }
                $result = $event === 'publish'
                    ? ($handlers['publish'])($article, $key)
                    : ($handlers['update'])($payload['postId'], $article, $key);
                if (!autoseopro_valid_external_id($result['postId'] ?? null) || !autoseopro_valid_external_id($result['receiptId'] ?? null)
                    || ($event === 'update' && $result['postId'] !== $payload['postId'])) {
                    return autoseopro_problem(500, 'Handler returned no durable ids', 'postId and receiptId are required and must be stable.');
                }
                // The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
                if (!autoseopro_on_public_origin($result['url'] ?? null, (string) $config['public_site_origin'])) {
                    return autoseopro_problem(500, 'URL outside the public site', 'url must be https on ' . $config['public_site_origin'] . '.');
                }
                $requested = $article['visibility'] ?? ($article['publishMode'] ?? 'draft');
                $receipt = [
                    'version' => AUTOSEOPRO_PROTOCOL,
                    'event' => $event,
                    'idempotencyKey' => $key,
                    'receiptId' => $result['receiptId'],
                    'postId' => $result['postId'],
                    // The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
                    'state' => $requested === 'publish' ? 'published' : 'draft',
                    'url' => $result['url'],
                ];
            }
        } catch (AutoSeoProRefusal $refusal) {
            return autoseopro_problem($refusal->status, 'Refused', $refusal->getMessage());
        } catch (Throwable $error) {
            return autoseopro_problem(500, 'Receiver error', 'The receiver failed; the event was not recorded.');
        }
        $store->put($key, $receipt); // only successes are stored, so a failed event can be retried
        return [200, 'application/json', $receipt];
    });
}

/** Throw from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid content). */
final class AutoSeoProRefusal extends RuntimeException
{
    public function __construct(public int $status, string $message)
    {
        parent::__construct($message);
    }
}
الملف: receiver-kits/php/index.php
<?php
/**
 * AutoSeoPro.ai publishing receiver: the entry point + a demo handler that saves posts as JSON files.
 *
 * Put this folder on any PHP 8 host over HTTPS and paste the URL that reaches this file, e.g.
 * https://www.example.com/autoseopro/events, into AutoSeoPro.ai (route /autoseopro/events to index.php with your
 * web server or a tiny rewrite). Set two environment variables on the host:
 *
 *   AUTOSEOPRO_RECEIVER_SECRET   a random 32+ character secret, the same value you paste into AutoSeoPro.ai
 *                             (generate one with: openssl rand -hex 32)
 *   PUBLIC_SITE_ORIGIN        https://www.example.com
 *
 * Apache hides the Authorization header from PHP unless you add this to .htaccess:
 *   RewriteEngine On
 *   RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
 *
 * Replace the three functions in $handlers with calls into your CMS or database.
 */
declare(strict_types=1);

require __DIR__ . '/autoseopro_receiver.php';

$secret = (string) getenv('AUTOSEOPRO_RECEIVER_SECRET');
$publicSiteOrigin = (string) getenv('PUBLIC_SITE_ORIGIN');
$dataDirectory = getenv('RECEIVER_DATA_DIR') ?: __DIR__ . '/receiver-data'; // keep this OUTSIDE the web root in production

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit;
}

$store = new AutoSeoProFileStore($dataDirectory . '/idempotency');
if (!is_dir($dataDirectory . '/posts')) {
    mkdir($dataDirectory . '/posts', 0700, true);
}
$postFile = static fn (string $postId): string => $dataDirectory . '/posts/' . hash('sha256', $postId) . '.json';
$slugify = static fn (string $value): string => trim(substr(preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? '', 0, 80), '-');
$urlFor = static fn (string $slug): string => rtrim($publicSiteOrigin, '/') . '/blog/' . $slug;
$receiptIdFor = static fn (string $key): string => 'rcpt_' . substr(hash('sha256', $key), 0, 16);

/*
 * Images: article.featuredImageUrl and any <img src> inside article.html are absolute https URLs.
 * Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
 * refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
 * Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
 */
$handlers = [
    'publish' => static function (array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
        $postId = 'post_' . bin2hex(random_bytes(16));
        $slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
        file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
        return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
    },
    'update' => static function (string $postId, array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
        if (!is_file($postFile($postId))) {
            throw new AutoSeoProRefusal(404, 'Unknown postId.');
        }
        $slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
        file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
        return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
    },
    'delete' => static function (string $postId, string $key) use ($postFile): void {
        // Deleting something that is already gone is still a success: the goal state is "not there".
        if (is_file($postFile($postId))) {
            unlink($postFile($postId));
        }
    },
];

[$status, $contentType, $body] = autoseopro_handle(
    autoseopro_request_headers(),
    (string) file_get_contents('php://input'), // the RAW bytes: never re-encode the JSON before verifying
    ['secret' => $secret, 'public_site_origin' => $publicSiteOrigin],
    $store,
    $handlers,
);
http_response_code($status);
header('Content-Type: ' . $contentType);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

Python (Flask)

يتطلب Python 3.8 أو أحدث مع Flask. يستخدم ملف التحقق المكتبة القياسية فقط.

الملف: receiver-kits/python/autoseopro_receiver.py
"""AutoSeoPro.ai signed publishing receiver: framework-free core (Python 3.8+, standard library only).

Protocol 2026-08-27. For every request it checks the Bearer token, the protocol version and the 5-minute
timestamp window, recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time,
answers a replayed idempotency key from the store without touching your CMS, and otherwise runs your handler
once and stores the reply. Only the handlers in app.py talk to your CMS.
"""
import hashlib
import hmac
import json
import os
import re
import threading
import time
from contextlib import contextmanager
from urllib.parse import urlsplit

try:  # POSIX file locks so several worker processes share one store; Windows falls back to the thread lock.
    import fcntl
except ImportError:  # pragma: no cover
    fcntl = None

PROTOCOL = "2026-08-27"
WINDOW_SECONDS = 300
MIN_SECRET_LENGTH = 32

_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_EXTERNAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$")
_TIMESTAMP = re.compile(r"^[0-9]{10,13}$")
_SIGNATURE = re.compile(r"^v1=[a-f0-9]{64}$")
_EVENTS = ("ping", "publish", "update", "delete")


class Refusal(Exception):
    """Raise from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid)."""

    def __init__(self, status, message):
        super().__init__(message)
        self.status = status


def safe_equal(a, b):
    """Compare two strings without leaking where they differ (hash first, so the lengths never matter)."""
    return hmac.compare_digest(hashlib.sha256(a.encode()).digest(), hashlib.sha256(b.encode()).digest())


def sign(secret, timestamp, idempotency_key, raw_body):
    """`v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text as sent."""
    message = timestamp.encode() + b"." + idempotency_key.encode() + b"." + raw_body
    return "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()


def problem(status, title, detail):
    return status, "application/problem+json", {"type": "about:blank", "title": title, "status": status, "detail": detail}


def _header(headers, name):
    wanted = name.lower()
    for key, value in headers.items():
        if key.lower() == wanted:
            return value
    return ""


def verify_request(headers, raw_body, secret, now=None):
    """Prove the request is genuinely from AutoSeoPro.ai and is fresh.

    `raw_body` must be the exact bytes received (Flask: request.get_data()), never re-serialised JSON.
    Returns (event, idempotency_key, None) when valid, or (None, None, (status, content_type, body)) when not.
    """
    if len(secret) < MIN_SECRET_LENGTH:
        return None, None, problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.")
    authorization = _header(headers, "authorization")
    if not authorization.startswith("Bearer ") or not safe_equal(authorization[7:], secret):
        return None, None, problem(401, "Unauthorized", "Missing or wrong bearer token.")
    if _header(headers, "x-rankora-protocol") != PROTOCOL:
        return None, None, problem(400, "Unsupported protocol version", "Only " + PROTOCOL + " is supported.")
    event = _header(headers, "x-rankora-event")
    key = _header(headers, "x-rankora-idempotency-key")
    timestamp = _header(headers, "x-rankora-timestamp")
    signature = _header(headers, "x-rankora-signature")
    if (event not in _EVENTS or not _IDEMPOTENCY_KEY.match(key) or not _TIMESTAMP.match(timestamp)
            or not _SIGNATURE.match(signature)):
        return None, None, problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.")
    # Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
    seconds = int(timestamp) // 1000 if len(timestamp) >= 13 else int(timestamp)
    if abs(int(now if now is not None else time.time()) - seconds) > WINDOW_SECONDS:
        return None, None, problem(401, "Stale timestamp", "The request is outside the 5-minute window.")
    if not safe_equal(signature, sign(secret, timestamp, key, raw_body)):
        return None, None, problem(401, "Bad signature", "The signature does not match the body.")
    return event, key, None


class FileStore:
    """One JSON file per idempotency key plus an exclusive lock, so two simultaneous deliveries of the same
    key never both reach your handler. With several servers use a database table with a UNIQUE key instead."""

    def __init__(self, directory):
        self.directory = directory
        self._thread_lock = threading.Lock()
        os.makedirs(directory, mode=0o700, exist_ok=True)

    def _path(self, key, extension):
        return os.path.join(self.directory, hashlib.sha256(key.encode()).hexdigest() + "." + extension)

    def get(self, key):
        try:
            with open(self._path(key, "json"), encoding="utf-8") as handle:
                return json.load(handle)
        except (OSError, ValueError):
            return None

    def put(self, key, reply):
        target = self._path(key, "json")
        temporary = "%s.%s.tmp" % (target, os.urandom(6).hex())
        with open(temporary, "w", encoding="utf-8") as handle:
            json.dump(reply, handle)
        os.replace(temporary, target)

    @contextmanager
    def lock(self, key):
        # flock is per open file, so it excludes other threads and other worker processes for the same key
        # without blocking different keys. Without fcntl (Windows) one process-wide lock is used instead.
        handle = open(self._path(key, "lock"), "a")
        try:
            if fcntl is not None:
                fcntl.flock(handle, fcntl.LOCK_EX)
                yield
            else:  # pragma: no cover
                with self._thread_lock:
                    yield
        finally:
            handle.close()  # closing releases the flock


def _on_public_origin(url, public_site_origin):
    """True when `url` is https on the origin (scheme + host + port) of your public site."""
    try:
        candidate, configured = urlsplit(url), urlsplit(public_site_origin)
        return (candidate.scheme == "https" and not candidate.username and candidate.hostname is not None
                and configured.hostname is not None
                and (candidate.hostname, candidate.port or 443) == (configured.hostname, configured.port or 443))
    except (ValueError, TypeError, AttributeError):
        return False


def handle_event(headers, raw_body, config, store, handlers):
    """Handle one delivery. Returns (status, content_type, body dict).

    config   = {"secret": ..., "public_site_origin": "https://www.example.com", "now": optional unix seconds}
    handlers = {
      "publish": lambda article, idempotency_key: {"postId": ..., "receiptId": ..., "url": ...},
      "update":  lambda post_id, article, idempotency_key: same shape, same postId,
      "delete":  lambda post_id, idempotency_key: None   (deleting a missing post is still a success),
    }
    A handler refuses by raising Refusal(status, message); any other exception becomes a 500.
    ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
    """
    event, key, failure = verify_request(headers, raw_body, config["secret"], config.get("now"))
    if failure:
        return failure

    with store.lock(key):
        # Replays are answered from the store and never reach the CMS again. The body of a retry can differ
        # (occurredAt changes), so the key alone decides.
        stored = store.get(key)
        if stored is not None:
            return 200, "application/json", stored
        try:
            payload = json.loads(raw_body.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            return problem(400, "Invalid JSON", "The body is not valid JSON.")
        if (not isinstance(payload, dict) or payload.get("version") != PROTOCOL
                or payload.get("event") != event or payload.get("idempotencyKey") != key):
            return problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.")

        try:
            if event == "ping":
                receipt = {"version": PROTOCOL, "event": "ping", "idempotencyKey": key, "accepted": True}
            elif event == "delete":
                post_id = payload.get("postId")
                if not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id):
                    return problem(422, "Invalid postId", "postId is required for delete.")
                handlers["delete"](post_id, key)
                receipt = {"version": PROTOCOL, "event": "delete", "idempotencyKey": key, "postId": post_id, "deleted": True}
            else:
                article = payload.get("article")
                if (not isinstance(article, dict) or not isinstance(article.get("title"), str) or not article["title"]
                        or not isinstance(article.get("html"), str) or not article["html"]):
                    return problem(422, "Invalid article", "article.title and article.html are required.")
                post_id = payload.get("postId")
                if event == "update" and (not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id)):
                    return problem(422, "Invalid postId", "postId is required for update.")
                result = (handlers["publish"](article, key) if event == "publish"
                          else handlers["update"](post_id, article, key))
                valid_ids = all(isinstance(result.get(name), str) and _EXTERNAL_ID.match(result[name]) for name in ("postId", "receiptId"))
                if not valid_ids or (event == "update" and result["postId"] != post_id):
                    return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.")
                # The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
                if not _on_public_origin(result.get("url"), config["public_site_origin"]):
                    return problem(500, "URL outside the public site", "url must be https on " + config["public_site_origin"] + ".")
                requested = article.get("visibility") or article.get("publishMode") or "draft"
                receipt = {
                    "version": PROTOCOL,
                    "event": event,
                    "idempotencyKey": key,
                    "receiptId": result["receiptId"],
                    "postId": result["postId"],
                    # The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
                    "state": "published" if requested == "publish" else "draft",
                    "url": result["url"],
                }
        except Refusal as refusal:
            return problem(refusal.status, "Refused", str(refusal))
        except Exception:  # noqa: BLE001 - a receiver must answer, never crash the worker
            return problem(500, "Receiver error", "The receiver failed; the event was not recorded.")
        store.put(key, receipt)  # only successes are stored, so a failed event can be retried
        return 200, "application/json", receipt
الملف: receiver-kits/python/app.py
"""AutoSeoPro.ai publishing receiver: Flask wiring + a demo handler that saves posts as JSON files.

    pip install flask
    export AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)"
    export PUBLIC_SITE_ORIGIN=https://www.example.com
    flask --app app run        (use gunicorn or uWSGI behind HTTPS in production)

Paste the public URL, e.g. https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
Replace the three functions in HANDLERS with calls into your CMS or database.
"""
import hashlib
import json
import os
import re
import secrets

from flask import Flask, Response, request

from autoseopro_receiver import FileStore, Refusal, handle_event

SECRET = os.environ.get("AUTOSEOPRO_RECEIVER_SECRET", "")
PUBLIC_SITE_ORIGIN = os.environ.get("PUBLIC_SITE_ORIGIN", "")
DATA_DIR = os.environ.get("RECEIVER_DATA_DIR", "./receiver-data")  # keep this OUTSIDE the web root in production

os.makedirs(os.path.join(DATA_DIR, "posts"), mode=0o700, exist_ok=True)
STORE = FileStore(os.path.join(DATA_DIR, "idempotency"))


def _post_file(post_id):
    return os.path.join(DATA_DIR, "posts", hashlib.sha256(post_id.encode()).hexdigest() + ".json")


def _slug(article, fallback):
    text = re.sub(r"[^a-z0-9]+", "-", (article.get("slug") or article["title"]).lower()).strip("-")[:80]
    return text or fallback


def _url(slug):
    return PUBLIC_SITE_ORIGIN.rstrip("/") + "/blog/" + slug


def _receipt_id(key):
    return "rcpt_" + hashlib.sha256(key.encode()).hexdigest()[:16]


def _save(post_id, article):
    slug = _slug(article, post_id)
    with open(_post_file(post_id), "w", encoding="utf-8") as handle:
        json.dump({"postId": post_id, "slug": slug, "article": article}, handle)
    return slug


# Images: article["featuredImageUrl"] and any <img src> inside article["html"] are absolute https URLs.
# Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
# refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
# Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
def publish(article, key):
    post_id = "post_" + secrets.token_hex(16)
    return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}


def update(post_id, article, key):
    if not os.path.isfile(_post_file(post_id)):
        raise Refusal(404, "Unknown postId.")
    return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}


def delete(post_id, key):
    # Deleting something that is already gone is still a success: the goal state is "not there".
    try:
        os.remove(_post_file(post_id))
    except FileNotFoundError:
        pass


HANDLERS = {"publish": publish, "update": update, "delete": delete}

app = Flask(__name__)


@app.post("/autoseopro/events")
def autoseopro_events():
    # get_data() returns the exact bytes received. Never use request.get_json() before verifying the signature.
    status, content_type, body = handle_event(
        dict(request.headers),
        request.get_data(),
        {"secret": SECRET, "public_site_origin": PUBLIC_SITE_ORIGIN},
        STORE,
        HANDLERS,
    )
    return Response(json.dumps(body), status=status, content_type=content_type)

الصور

featuredImageUrl ووسوم img داخل html روابط https مطلقة. يمكنك تركها روابط أو نسخها إلى مخزنك الخاص. عند النسخ نزّل عناوين https فقط، وارفض العناوين الخاصة، وحدد مهلة وحدًّا للحجم، واحتفظ بالرابط الأصلي إذا فشل التنزيل.

قبل الإنتاج

يناسب مخزن الملفات خادمًا واحدًا. مع عدة خوادم احفظ مفاتيح عدم التكرار في جدول قاعدة بيانات بمفتاح فريد، وقدّم المستقبِل عبر HTTPS فقط.