انتقل إلى المحتوى الرئيسي

مرجع واجهة برمجة التطبيقات للشركاء

دمج Society Speaks برمجياً. قوّ عمليات الدمج والالتقاطات والرؤى الافتتاحية باستخدام واجهات برمجة تطبيقات JSON مصممة لأسير العمل في غرفة الأخبار وسرعة المطورين.

إذا كنت جديداً هنا: يبدأ الناشرون مناقشة لكل موضوع تصويت — في أغلب الأحيان متوافقة مع عنوان URL واحد، لكن يمكنك بدلاً من ذلك استخدام external_id الخاص بك فقط للمحاور أو الأدوات بدون رابط دائم. البيانات تنتمي إليك — يمكن صياغتها من قبلك أو من نسخ توفرها أنت؛ لا شيء مستنتج من صفحتك الرئيسية ما لم تطلب تكاملك ذلك. التعاريف الرسمية وإضافة حقول API: مناقشة مقابل بيانات.

البدء السريع

curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"

ثم ضمّن باستخدام embed_url المعاد.

للمطورين

نقاط نهاية يمكن التنبؤ بها وأكواد خطأ واضحة وأمثلة جاهزة للنسخ واللصق.

لقادة التحرير

تختار كل سؤال، وكيف يرتبط بقصصك أو معرّفاتك، وما إذا كانت الأسطر المقدمة من القارئ تظهر في التضمين.

للحوكمة

حدود معدل التطلب وومتطلبات الإسناد وتصدر موثوق للحقيقة.

اختبار واجهة برمجة التطبيقات

  • بوابة الشركاء: أنشئ بوابة بوابة الشركاء:
  • ملعب تفاعلي: ملعب API المفتوح ملعب تفاعلي:
  • نقاط النهاية الكتابة (POST/PATCH /api/partner/...) هي من خادم إلى خادم فقط ويجب اختبارها من الواجهة الخلفية أو curl/Postman، وليس من متصفح Swagger.
  • من سطر الأوامر: استخدم أمثلة curl في كل قسم أدناه، واستبدل عنوان URL الأساسي والمعاملات حسب الحاجة.

خريطة سير عمل غرفة الأخبار السريعة: external_id لتعيين نظام إدارة المحتوى، واستدعاءات webhook لمزامنة النظام، وأدوار Partner Portal للتحكم في الوصول.

المصادقة والبيئات

استخدم مفاتيح الاختبار أثناء التدريب على التدريج؛ انتقل لمفاتيح مباشرة بمجرد تفعيل الفواتير. يتم إنشاء المفاتيح في Partner Portal.

مفتاح الاختبار: sspk_test_...
المفتاح المباشر: sspk_live_... (مُفعّل بعد الفواتير)

كل اسم مضيف تستضيف عليه iframe يجب أن يظهر ضمن Domains بعد التحقق من DNS — بشكل منفصل للتدريج (test) والإنتاج (live). عادة تسجل المواقع الإخبارية كلا من www. والنطاق القصير إذا كان القراء يستخدمون أحدهما.

القراء يتفاعلون دائماً مع Society Speaks عبر حركة ويب مشفرة عادية؛ يجب على فريق الاستضافة لديك الحفاظ على عنوان الويب العام لدينا (BASE_URL من جانبنا) متطابقاً مع ما يتوقعه المتصفحات.

من الخادم إلى الخادم فقط لعمليات الكتابة

إنشاء مناقشة وطرق إدارة الشركاء يجب استدعاؤها من خادمك وليس من JavaScript على جانب العميل (يتم رفض الطلبات التي تحتوي على رأس Origin برمز 403). تعمل نقاط النهاية المتعلقة بالقراءة فقط (البحث واللقطة وoEmbed) من المتصفحات بدون مفتاح API. للطلبات المصرح بها من تطبيق ويب، قم بالوكالة عبر خادمك الخلفي حتى تبقى مفاتيح API سرية.

HTTPS مطلوب

يجب تقديم جميع طلبات API عبر HTTPS وفرضها على طبقة الحافة/الوكيل لديك في الإنتاج. لا تسجل مفتاح API الخاص بك أو تضمنه في عناوين URL — أرسله دائماً في رأس X-API-Key.

مكتبات العميل

v0.3.0

عملاء مساعدين جاهزين للاستخدام لـ Python و Node.js. كل منهما عبارة عن ملف واحد مكتفٍ بذاته — بدون خطوة بناء، بدون تبعيات متعددة بخلاف requests (Python فقط). للاستخدام على جانب الخادم فقط — لا تكشف مفتاح API الخاص بك في كود المتصفح.

1. أضف إلى مشروعك

قم بتنزيل الملف وضعه جنباً إلى جنب مع كود التطبيق الخاص بك.

تنزيل societyspeaks_partner.py
# Place societyspeaks_partner.py anywhere in your project, then:
from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError

2. تثبيت الاعتماد الواحد

pip install requests

3. هيئ وأجرِ مكالمتك الأولى

from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError

client = SocietyspeaksPartnerClient(
    base_url="https://societyspeaks.io",
    api_key="sspk_live_your_key_here",  # never expose in browser code
)

try:
    # Look up an existing discussion by article URL
    result = client.lookup_by_article_url("https://yoursite.com/article")
    print(result["embed_url"])
except PartnerApiError as e:
    if e.status_code == 404:
        # Discussion doesn't exist yet — create one
        discussion = client.create_discussion(
            title="Should cities ban single-use plastics?",
            article_url="https://yoursite.com/article",
            excerpt="A new UN report finds that single-use plastic bans...",
            embed_statement_submissions_enabled=False,
        )
        print(discussion["embed_url"])
    else:
        raise
▶ عرض المصدر الكامل societyspeaks_partner.py
"""Lightweight Society Speaks Partner API client.

Intended as a reference wrapper for partners integrating from backend services.
"""

from __future__ import annotations

import hashlib
import hmac as _hmac
import time
import uuid
from typing import Any, Dict, List, Optional

import requests

SDK_VERSION = "0.3.0"


class PartnerApiError(Exception):
    def __init__(self, status_code: int, error: str, message: str, retry_after: Optional[int] = None):
        super().__init__(f"{status_code} {error}: {message}")
        self.status_code = status_code
        self.error = error
        self.message = message
        self.retry_after = retry_after


class SocietyspeaksPartnerClient:
    def __init__(self, base_url: str, api_key: str, timeout: int = 15, max_retries: int = 2):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.timeout = timeout
        self.max_retries = max_retries

    @property
    def sdk_version(self) -> str:
        return SDK_VERSION

    @staticmethod
    def verify_webhook_signature(
        raw_body: bytes,
        signature_header: str,
        timestamp_header: str,
        secret: str,
        tolerance_seconds: int = 300,
    ) -> bool:
        """Verify an incoming webhook request signature.

        Always call this before processing any webhook payload.

        Args:
            raw_body: The raw request body **bytes** — read before parsing JSON.
            signature_header: Value of the ``X-SocietySpeaks-Signature`` header.
            timestamp_header: Value of the ``X-SocietySpeaks-Timestamp`` header.
            secret: The signing secret issued when the webhook endpoint was created.
            tolerance_seconds: Reject requests older than this many seconds to prevent
                replay attacks. Defaults to 300 (5 minutes).

        Raises:
            ValueError: If the timestamp is missing, malformed, or outside tolerance.

        Returns:
            ``True`` if the signature is valid, ``False`` otherwise.
        """
        try:
            ts = int(timestamp_header)
        except (TypeError, ValueError):
            raise ValueError("Invalid or missing X-SocietySpeaks-Timestamp header.")

        age = abs(int(time.time()) - ts)
        if age > tolerance_seconds:
            raise ValueError(
                f"Webhook timestamp is {age}s old (tolerance: {tolerance_seconds}s). "
                "Possible replay attack — reject this request."
            )

        if isinstance(raw_body, str):
            raw_body = raw_body.encode("utf-8")

        signed_payload = f"{ts}.".encode() + raw_body
        expected = "sha256=" + _hmac.new(
            secret.encode("utf-8"),
            signed_payload,
            hashlib.sha256,
        ).hexdigest()
        return _hmac.compare_digest(expected, signature_header)

    @staticmethod
    def _retry_after_seconds(response: requests.Response) -> Optional[int]:
        raw = response.headers.get("Retry-After")
        if not raw:
            return None
        try:
            return max(0, int(raw))
        except (TypeError, ValueError):
            return None

    def _request(
        self,
        method: str,
        path: str,
        *,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
        extra_headers: Optional[Dict[str, str]] = None,
    ):
        url = f"{self.base_url}{path}"
        headers = {
            "X-API-Key": self.api_key,
            "Content-Type": "application/json",
        }
        if extra_headers:
            headers.update(extra_headers)
        attempt = 0
        resp: Optional[requests.Response] = None
        while True:
            attempt += 1
            try:
                resp = requests.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=json_body,
                    params=params,
                    timeout=self.timeout,
                )
            except requests.RequestException as exc:
                if attempt > self.max_retries:
                    raise PartnerApiError(0, "network_error", str(exc)) from exc
                time.sleep(0.5 * attempt)
                continue
            if resp.status_code < 500 or attempt > self.max_retries:
                break
            time.sleep(0.5 * attempt)

        if resp is None:
            raise PartnerApiError(0, "network_error", "Request failed before receiving a response.")

        if 200 <= resp.status_code < 300:
            if resp.headers.get("Content-Type", "").startswith("application/json"):
                return resp.json()
            return resp.text

        body = {}
        try:
            body = resp.json() or {}
        except Exception:
            body = {}
        raise PartnerApiError(
            status_code=resp.status_code,
            error=body.get("error", "request_failed"),
            message=body.get("message", "Request failed."),
            retry_after=self._retry_after_seconds(resp),
        )

    def lookup_by_article_url(self, url: str):
        return self._request("GET", "/api/discussions/by-article-url", params={"url": url})

    def create_discussion(
        self,
        *,
        title: str,
        article_url: Optional[str] = None,
        external_id: Optional[str] = None,
        excerpt: Optional[str] = None,
        seed_statements: Optional[list] = None,
        source_name: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        embed_statement_submissions_enabled: Optional[bool] = None,
    ):
        if not article_url and not external_id:
            raise ValueError("Provide at least one identifier: article_url or external_id.")
        if not excerpt and not seed_statements:
            raise ValueError("Provide excerpt or seed_statements.")
        if embed_statement_submissions_enabled is not None and not isinstance(embed_statement_submissions_enabled, bool):
            raise ValueError("embed_statement_submissions_enabled must be a boolean when provided.")

        payload = {
            "title": title,
            "article_url": article_url,
            "external_id": external_id,
            "excerpt": excerpt,
            "seed_statements": seed_statements,
            "source_name": source_name,
            "embed_statement_submissions_enabled": embed_statement_submissions_enabled,
        }
        payload = {k: v for k, v in payload.items() if v is not None}

        return self._request(
            "POST",
            "/api/partner/discussions",
            json_body=payload,
            extra_headers={"Idempotency-Key": idempotency_key or f"idem_{uuid.uuid4().hex}"},
        )

    def get_discussion_by_external_id(self, external_id: str, env: Optional[str] = None):
        params = {"external_id": external_id}
        if env:
            params["env"] = env
        return self._request("GET", "/api/partner/discussions/by-external-id", params=params)

    def list_discussions(self, *, env: str = "all", page: int = 1, per_page: int = 30):
        return self._request(
            "GET",
            "/api/partner/discussions",
            params={"env": env, "page": page, "per_page": per_page},
        )

    def patch_discussion(
        self,
        discussion_id: int,
        *,
        is_closed: Optional[bool] = None,
        integrity_mode: Optional[bool] = None,
        embed_statement_submissions_enabled: Optional[bool] = None,
    ):
        payload: Dict[str, Any] = {}
        if is_closed is not None:
            if not isinstance(is_closed, bool):
                raise ValueError("is_closed must be a boolean when provided.")
            payload["is_closed"] = is_closed
        if integrity_mode is not None:
            if not isinstance(integrity_mode, bool):
                raise ValueError("integrity_mode must be a boolean when provided.")
            payload["integrity_mode"] = integrity_mode
        if embed_statement_submissions_enabled is not None:
            if not isinstance(embed_statement_submissions_enabled, bool):
                raise ValueError("embed_statement_submissions_enabled must be a boolean when provided.")
            payload["embed_statement_submissions_enabled"] = embed_statement_submissions_enabled
        if not payload:
            raise ValueError("Provide at least one field to patch.")
        return self._request("PATCH", f"/api/partner/discussions/{discussion_id}", json_body=payload)

    def list_webhooks(self):
        return self._request("GET", "/api/partner/webhooks")

    def create_webhook(self, *, url: str, event_types: List[str]):
        return self._request(
            "POST",
            "/api/partner/webhooks",
            json_body={"url": url, "event_types": event_types},
        )

    def update_webhook(
        self,
        endpoint_id: int,
        *,
        status: Optional[str] = None,
        event_types: Optional[List[str]] = None,
    ):
        payload: Dict[str, Any] = {}
        if status is not None:
            payload["status"] = status
        if event_types is not None:
            payload["event_types"] = event_types
        if not payload:
            raise ValueError("Provide status and/or event_types to update.")
        return self._request("PATCH", f"/api/partner/webhooks/{endpoint_id}", json_body=payload)

    def delete_webhook(self, endpoint_id: int):
        return self._request("DELETE", f"/api/partner/webhooks/{endpoint_id}")

    def rotate_webhook_secret(self, endpoint_id: int):
        return self._request("POST", f"/api/partner/webhooks/{endpoint_id}/rotate-secret")

    def export_usage(self, *, days: int = 30, env: str = "all", page: int = 1, per_page: int = 100):
        return self._request(
            "GET",
            "/api/partner/analytics/usage-export",
            params={"days": days, "env": env, "format": "json", "page": page, "per_page": per_page},
        )

عنوان URL الأساسي

https://societyspeaks.io/api

مناقشة مقابل بيانات

نستخدم هذه الكلمات بشكل متسق في Partner Portal وردود API ومواد الدعم — اطلع على هذا مرة واحدة والبقية من الوثائق ستبقى متوقعة.

نقاش
المظلة لتجربة تصويت واحدة موجهة للقارئ: عنوان العنوان، وكيفية ربطه بموقعك (article_url و/أو external_id)، بيئة الشريك (اختبار/مباشر)، عناوين URL للتضمين/الإجماع، والمشاركة المدمجة أسفله. ينشئ إنشاء نقاش عبر API هذه الحاوية أولاً.
التصريحات
الطلبات الفردية داخل تلك المظلة — كل منها يجمع عدد أصواتها الخاصة بالموافقة / عدم الموافقة / غير متأكد. املأها أثناء الإنشاء باستخدام seed_statements أو excerpt يوقد بذور الذكاء الاصطناعي؛ أضفها/عدّلها لاحقاً في البوابة؛ اسمح اختيارياً للقراء المجهولين بالمساهمة بالمزيد عندما تكون حقول تقديم التضمين مفعّلة لهذا النقاش.

سير العمل الشائع

النمط المعتاد هو «نقاش واحد لكل عنوان URL منشور»، لكن هذا اختيار وليس شرطاً. يمكنك إنشاء نقاشات لصفحات المقالات، وإرفاق معرّف CMS بدون مقالة عامة، أو الدمج بينهما حتى تبقى التقارير متسقة عبر مكدسك.

بغض النظر عن المعرّفات، تذكر الفصل: الإرجاع/الإنشاء يعيد discussion_id؛ البيانات تُنمذج بشكل منفصل وتُعرض عبر التضمين، أدوات بيان البوابة، وحمولات لقطات المحتوى.

تضمين برمجي (موصى به)

  1. 1) ابحث عن النقاش حسب عنوان URL المقالة
  2. 2) إذا لم يكن موجوداً، أنشئ قشرة النقاش (البيانات الأولية اختيارياً في نفس الاستدعاء)
  3. 3) احفظ discussion_id واستخدم embed_url؛ راجع البيانات داخل البوابة إذا احتجت إلى تعديلات
# 1) Lookup
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article" \
  -H "X-API-Key: sspk_test_..."

# 2) If 404, create
curl -X POST "https://societyspeaks.io/api/partner/discussions" \
  -H "X-API-Key: sspk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"article_url":"https://example.com/article","title":"Example title","excerpt":"..."}'

تضمين يدوي (بداية سريعة)

استخدم مولد التضمين لإنشاء iframes لمرة واحدة. يعتمد على نفس واجهات برمجة التطبيقات ويُرجع نفس discussion_id وembed_url.

قائمة التحقق من الإطلاق البسيطة

يمكن للمحررين والمنتجين تصفح القسم أدناه؛ شارك ملاحظات الهندسة مع قسم تكنولوجيا المعلومات.

لفريق التحرير والمنتج

  • ملخص المفردات: نقاش واحد = موضوع تصويت واحد بالإضافة إلى غلاف التضمين الخاص به؛ عدة بيانات تجلس تحتها — الأسطر التي يجيب عليها القراء بموافقة / عدم موافقة / غير متأكد. شرح أطول →
  • أنت تقرر ما يُطلق على كل موضوع تصويت وما يراه القراء أولاً — ربط المقالة اختياري؛ المدونات المباشرة والمحاور والمشاريع الخاصة يمكنها استخدام معرّف مستقر بدلاً من عنوان URL للقصة.
  • يجب الموافقة على كل عنوان ويب قد تحمّل عليه الجزء في بوابة الشريك الخاصة بك — مواقع البروفة والمواقع المباشرة منفصلة.
  • إذا كان القراء يكتبون كلاً من www. واسم النطاق المجرد، سجّل موافقتين.
  • اطلب من الهندسة إضافة علامة التتبع القصيرة (?ref=…) حتى تبقى التقارير قابلة للنسب. ما تعنيه العلامات →
  • اختياري: اطلب ارتفاعاً تلقائياً على الصندوق حتى لا تُقطع المقالات الطويلة (سكريبت صغير واحد من جانبك). كيف يعمل →
  • فضّل «شيء ما تغيّر» الأصوات التلقائية لأنظمتك على الفحص اليدوي. نظرة عامة على Webhooks →

لفريق الهندسة والاستضافة

  • ضع مفاتيح API للاختبار فقط مع نطاقات البروفة؛ مفاتيح مباشرة فقط مع نطاقات الإنتاج المُتحقق منها.
  • تضمينات البروفة (test) في الإنتاج تحتاج دائماً إلى صفحة الوالد قابلة للحل من المتصفح — حتى بدون PARTNER_EMBED_REQUIRE_PARENT_ORIGIN.
  • بيئة التصليب الاختيارية: PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true (الإنتاج فقط، تضمينات الشريك المباشرة) تحظر HTML iframe عندما لا نستطيع حل صفحة الوالد من Origin أو Referer — أقسى، وقد تؤثر على المتصفحات غير الشائعة أو إعدادات الخصوصية.
  • تقديمات القارئ المضمّنة من التضمين تحترم EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR وEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP في الإعدادات (بالإضافة إلى حدود شخصية).

البحث حسب عنوان URL المقالة

ابحث عن نقاش من خلال عنوان URL لمقالتك. يرجع البحث بيانات وصفية للنقاش (discussion_id, embed_url, إلخ)؛ البيانات التي يصوّت عليها القراء تصل مع تضمين ذلك النقاش وحمولات لقطات المحتوى.

GET /api/discussions/by-article-url

معاملات الاستعلام

url مطلوب عنوان URL المقالة (مرمز URL)
ref اختياري مرجع الشريك للتحليلات

مثال الطلب

curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"

استجابة ناجحة (200)

{
  "discussion_id": 123,
  "slug": "should-we-reform-housing-policy",
  "title": "Should we reform housing policy?",
  "embed_url": "https://societyspeaks.io/discussions/123/embed",
  "consensus_url": "https://societyspeaks.io/discussions/123/should-we.../consensus",
  "snapshot_url": "https://societyspeaks.io/api/discussions/123/snapshot",
  "source": "rss",
  "env": "live"
}

استجابات الخطأ

400 missing_url - المعامل URL مطلوب
400 invalid_url - لم يتمكن من تحليل عنوان URL
403 partner_disabled - تم إلغاء وصول التضمين وواجهة برمجة التطبيقات لهذا مرجع الشريك
404 no_discussion - لا يوجد نقاش لهذا عنوان URL
429 rate_limited - عدد كبير جداً من الطلبات؛ أعد المحاولة لاحقاً

الحصول على لقطة النقاش

احصل على عدد المشاركين والبيانات الوصفية. لا يتضمن محتوى التحليل.

GET /api/discussions/{discussion_id}/snapshot

معاملات المسار

discussion_id مطلوب معرّف النقاش

مثال الطلب

curl "https://societyspeaks.io/api/discussions/123/snapshot"

استجابة ناجحة (200)

{
  "discussion_id": 123,
  "discussion_title": "Should we reform housing policy?",
  "participant_count": 847,
  "statement_count": 12,
  "has_analysis": true,
  "opinion_groups": 3,
  "analyzed_at": "2026-02-05T10:30:00Z",
  "consensus_url": "https://societyspeaks.io/discussions/123/.../consensus",
  "teaser_text": "Housing reform debate reveals surprising common ground"
}

عندما لا يكون التحليل جاهزاً

{
  "discussion_id": 123,
  "discussion_title": "Should we reform housing policy?",
  "participant_count": 23,
  "statement_count": 5,
  "has_analysis": false,
  "consensus_url": "https://societyspeaks.io/discussions/123/.../consensus"
}

استجابات الخطأ

403 partner_disabled - تم إلغاء وصول التضمين وواجهة برمجة التطبيقات لهذا مرجع الشريك
403 forbidden - مفتاح API اختبار صحيح مطلوب للنقاشات الاختبارية
404 discussion_not_found - النقاش غير موجود
429 rate_limited - عدد كبير جداً من الطلبات؛ أعد المحاولة لاحقاً

معاملات Embed URL

تخصيص مظهر التضمين باستخدام معاملات URL.

التضمينات متاحة لنقاشات Society Speaks الأصلية.

https://societyspeaks.io/discussions/{discussion_id}/embed
المعامل الوصف مثال
theme المظهر المسبق: default, dark, editorial, minimal, bold, muted theme=editorial
primary اللون الأساسي (hex بدون #) primary=1e40af
bg لون الخلفية (hex بدون #) bg=f9fafb
font عائلة الخط من قائمة مسموحة font=Georgia
ref مرجع الشريك للتحليلات ref=observer

الخطوط المسموحة: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro

الدمج: تتبع الجلسة والخصوصية

يستخدم الدمج ملف تعريف ارتباط من الطرف الأول لإزالة تكرار الأصوات المجهولة عبر تحميلات الصفحات. عندما يتم تحميل الدمج في iframe عبر النطاق (الحالة العادية لمواقع الشركاء)، قد تحجب المتصفحات ذات إعدادات الخصوصية الصارمة ملف التعريف هذا باعتباره ملف تعريف ارتباط من طرف ثالث.

النقاشات المُنشأة من خلال API الشريك الخاص بك محدودة النطاق بالشريك: تتحقق صفحة التضمين من أصل الموقع الأب (باستخدام Origin أو Referer عند الحاجة) مقابل نطاقاتك المُتحقق منها للبيئة المطابقة للاختبار/المباشر. النقاشات العامة الأصلية لا تخضع لتلك بوابة الإطار.

لا يتطلب أي إجراء. يتراجع الدمج تلقائياً إلى معرّف جلسة قائم على localStorage (embed_fingerprint) عندما تكون ملفات تعريف الارتباط غير متاحة. يعمل إزالة تكرار الأصوات في كلا الوضعين.

كيف يعمل:

  • ينشئ الدمج معرف مشارك عشوائي ويخزنه في localStorage تحت أصل الدمج.
  • يتم إرسال هذا المعرف مع كل صوت باسم embed_fingerprint لإزالة التكرار من جانب الخادم.
  • إذا قام المستخدم بمسح بيانات الموقع أو استخدم التصفح الخاص، تبدأ جلسة جديدة (يمكنه التصويت مرة أخرى).
  • Safari و Firefox و Brave مدعومة بالكامل عبر هذا البديل.

أحداث PostMessage

يتواصل الدمج مع الإطار الأصلي عبر postMessage.

societyspeaks:embed:loaded

يُرسل عند انتهاء الدمج من التحميل.

{
  "type": "societyspeaks:embed:loaded",
  "discussionId": 123,
  "statementCount": 5
}

societyspeaks:embed:resize

يُرسل عند تغيير ارتفاع محتوى الدمج. استخدم لتغيير حجم iframe.

{
  "type": "societyspeaks:embed:resize",
  "discussionId": 123,
  "height": 450
}

مثال: تغيير حجم iframe تلقائياً

window.addEventListener('message', (event) => {
  if (event.data.type === 'societyspeaks:embed:resize') {
    const iframe = document.querySelector('iframe[src*="societyspeaks"]');
    if (iframe) iframe.style.height = event.data.height + 'px';
  }
});

مزود oEmbed

تفعيل الدمج التلقائي لمناقشات Society Speaks في المنصات التي تدعم oEmbed.

GET /api/oembed

معاملات الاستعلام

url مطلوب عنوان URL لمناقشة Society Speaks
maxwidth اختياري أقصى عرض للدمج
maxheight اختياري أقصى ارتفاع للدمج
format اختياري صيغة الاستجابة (يدعم "json" فقط)

مثال الطلب

curl "https://societyspeaks.io/api/oembed?url=https://societyspeaks.io/discussions/123/my-discussion"

استجابة ناجحة (200)

{
  "type": "rich",
  "version": "1.0",
  "title": "Should we reform housing policy?",
  "provider_name": "Society Speaks",
  "provider_url": "https://societyspeaks.io",
  "html": "<iframe src=\"https://societyspeaks.io/discussions/123/embed\" ...></iframe>",
  "width": 600,
  "height": 400,
  "cache_age": 3600
}

الاكتشاف التلقائي: تتضمن صفحات المناقشة علامة <link rel="alternate" type="application/json+oembed"> للاكتشاف التلقائي لـ oEmbed من قبل المنصات المتوافقة.

إنشاء مناقشة (مصرح)

أنشئ نقاشاً لقصة محددة، أو لأي سياق تحريري يمكنك تسميته بمعرّف مستقر. وفّر article_url عندما يكون لديك رابط عام؛ استخدم external_id عندما لا يكون لديك (أو استخدم كليهما لتتبع الحقيقة). يتطلب مفتاح API.

ينشئ هذا الـ endpoint دائماً النقاش أولاً. جمّع البيانات الأولية في نفس الطلب عبر seed_statements أو وفّر excerpt حتى نتمكن من إنشاء بذور؛ وإلا أضف البيانات لاحقاً من بوابة الشريك. اقرأ كيف ينطبق النقاش مقابل البيانات على الحقول →

POST /api/partner/discussions

الرؤوس

X-API-Key مطلوب مفتاح API الشريك الخاص بك
Content-Type مطلوب application/json

نص الطلب

article_url اختياري عنوان URL المقالة الأساسي (معيّن إذا تم توفيره)
external_id اختياري معرف مستقر معرّف من قبل الشريك (مطلوب إذا تم حذف article_url)
title مطلوب عنوان المناقشة (حد أقصى 200 حرف)
excerpt مشروط مقتطف المقالة للبيانات المولدة بواسطة الذكاء الاصطناعي
seed_statements مشروط مصفوفة من {content, position} بيانات
source_name اختياري اسم منشورك للإسناد
embed_statement_submissions_enabled اختياري السماح بتقديم القارئ المضمن في الدمج لهذه المناقشة (الافتراضي false)

ملاحظات: وفر معرفاً واحداً على الأقل (article_url أو external_id) وإما excerpt أو seed_statements.

التوصية: احتفظ بـ embed_statement_submissions_enabled بقيمة false لمعظم صفحات المقالات، وفعّله فقط للمناقشات حيث يريد فريقك التقاط بيانات مضمنة من الدمج.

مثال الطلب

curl -X POST "https://societyspeaks.io/api/partner/discussions" \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_$(openssl rand -hex 16)" \
  -d '{
    "article_url": "https://example.com/article/urban-cars",
    "title": "Should cities ban cars from downtown areas?",
    "excerpt": "A new study shows that car-free zones improve air quality...",
    "source_name": "Example News"
  }'

استجابة النجاح (201)

{
  "discussion_id": 456,
  "slug": "should-cities-ban-cars-from-downtown-areas",
  "title": "Should cities ban cars from downtown areas?",
  "partner_article_url": "https://example.com/article/urban-cars",
  "external_id": "observer-cms-12345",
  "embed_statement_submissions_enabled": false,
  "embed_url": "https://societyspeaks.io/discussions/456/embed",
  "consensus_url": "https://societyspeaks.io/discussions/456/.../consensus",
  "snapshot_url": "https://societyspeaks.io/api/discussions/456/snapshot",
  "source": "partner",
  "env": "live",
  "statement_count": 5
}

استجابات الخطأ

401 invalid_api_key - مفتاح API مفقود أو غير صحيح
400 missing_identifier أو missing_content - يلزم (article_url أو external_id) وexcerpt/seed_statements
409 discussion_exists - النقاش موجود بالفعل لهذا الرابط

تحديث سياسة النقاش (موثق)

استخدم هذا لإغلاق/إعادة فتح النقاشات، وتبديل وضع السلامة، والتحكم في تقديمات embed المضمنة.

curl -X PATCH "https://societyspeaks.io/api/partner/discussions/456" \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "is_closed": false,
    "integrity_mode": true,
    "embed_statement_submissions_enabled": true
  }'

الخيار الموصى به الافتراضي لمعظم صفحات المقالات: "embed_statement_submissions_enabled": false. فعّل true للنقاشات المختارة عالية التفاعل (مثل المدونات المباشرة أو مركز الحملات).

رد نداء Webhook (موثق)

قم بتكوين رد نداء دورة الحياة الموقعة لتجنب الاستقصاء. استخدم استدعاءات الخادم إلى الخادم مع مفتاح صادر من البوابة.

GET /api/partner/webhooks - إدراج نقاط نهاية webhook

POST /api/partner/webhooks - إنشاء نقطة نهاية واستلام سر التوقيع مرة واحدة

PATCH /api/partner/webhooks/{endpoint_id} - إيقاف/استئناف وتحديث اشتراكات الأحداث

POST /api/partner/webhooks/{endpoint_id}/rotate-secret - تدوير سر التوقيع

DELETE /api/partner/webhooks/{endpoint_id} - حذف نقطة النهاية

رؤوس الطلب

يتضمن كل تسليم webhook رؤوس الهيدر التالية:

الرأس الوصف
X-SocietySpeaks-Eventنوع الحدث، مثل discussion.created
X-SocietySpeaks-Event-Idمعرّف UUID فريد للحدث — استخدمه لإزالة التكرار
X-SocietySpeaks-Timestampثوان Unix (UTC) عند إرسال الحدث
X-SocietySpeaks-Signatureتوقيع HMAC-SHA256 — تحقق من هذا دائماً

تحقق دائماً من توقيعات webhook

بدون التحقق، يمكن لأي طرف يعرف عنوان نقطة النهاية الخاصة بك أن يرسل أحداثاً مزيفة. اقرأ الجسم الخام قبل تحليل JSON، ثم تحقق من توقيع HMAC-SHA256 باستخدام سر التوقيع الخاص بك. ارفض الطلبات التي تحمل طوابع زمنية أقدم من 5 دقائق لمنع هجمات إعادة التشغيل.

تنسيق التوقيع

الحمولة الموقعة هي {timestamp}.{raw_body} (ثوان Unix، نقطة حرفية، ثم جسم الطلب الخام UTF-8). رأس الهيدر X-SocietySpeaks-Signature هو sha256=<hex_digest>.

مثال التحقق

# Flask example — adapt raw-body reading to your framework
from flask import Flask, request, abort
from societyspeaks_partner import SocietyspeaksPartnerClient
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["SS_WEBHOOK_SECRET"]  # store in env, never hardcode

@app.route("/webhooks/societyspeaks", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()  # read raw bytes BEFORE any JSON parsing

    try:
        valid = SocietyspeaksPartnerClient.verify_webhook_signature(
            raw_body=raw_body,
            signature_header=request.headers.get("X-SocietySpeaks-Signature", ""),
            timestamp_header=request.headers.get("X-SocietySpeaks-Timestamp", ""),
            secret=WEBHOOK_SECRET,
            tolerance_seconds=300,  # reject events older than 5 minutes
        )
    except ValueError as exc:
        # Timestamp missing, malformed, or outside the tolerance window
        abort(400, str(exc))

    if not valid:
        abort(401, "Invalid webhook signature.")

    event      = request.get_json()
    event_type = request.headers.get("X-SocietySpeaks-Event")
    event_id   = request.headers.get("X-SocietySpeaks-Event-Id")

    # Deduplicate using event_id (store processed IDs in your DB or cache)
    if already_processed(event_id):
        return "", 200

    if event_type == "discussion.created":
        handle_discussion_created(event["data"])

    return "", 200

التحقق اليدوي (بدون استخدام SDK)

إذا كنت لا تستخدم SDK الخاص بنا، اتبع هذه الخطوات بالضبط:

  1. اقرأ جسم الطلب الخام كبايتات — قبل أي تحليل JSON.
  2. حلّل X-SocietySpeaks-Timestamp كرقم صحيح. إذا كان غائباً أو أقدم من 300 ثانية، ارفضه برد HTTP 400.
  3. بناء الحمولة الموقعة: {timestamp} + . + بايتات الجسم الخام.
  4. احسب HMAC-SHA256(key=signing_secret, msg=signed_payload) وقم بتشفيره بصيغة سادسة عشرية.
  5. أضف البادئة sha256= لتشكيل سلسلة التوقيع المتوقعة.
  6. قارن مع X-SocietySpeaks-Signature باستخدام مقارنة ثابتة الوقتhmac.compare_digest في Python، crypto.timingSafeEqual في Node. فحص التساوي البسيط يسرّب معلومات التوقيت.

إدارة السر

يتم إرجاع سر التوقيع مرة واحدة عند إنشاء نقطة نهاية — قم بتخزينه بأمان في متغير بيئة أو مدير أسرار، ليس أبداً في شيفرة المصدر. لا يتم عرضه مرة أخرى في البوابة. استخدم POST /api/partner/webhooks/{endpoint_id}/rotate-secret لإصدار سر جديد؛ قبل بموجز السر القديم والجديد بشكل مختصر خلال فترة التبديل قبل إيقاف تشغيل القديم.

تصدير التحليلات (موثق)

صدّر أحداث الاستخدام بصيغة JSON أو CSV لأنابيب BI.

curl "https://societyspeaks.io/api/partner/analytics/usage-export?days=30&env=all&format=json&page=1&per_page=100" \
  -H "X-API-Key: your_api_key"

استخدم format=csv لاستيعاب الملفات المباشر، أو format=json المترقق لمستهلكي API.

التكلفة وحماية الإساءة

من يدفع مقابل ماذا: البحث واللقطة والصورة المرئية وصفحة embed لا تستخدم LLM الخاص بنا. يتم تحديد معدل كل واحد منها لكل IP (وref اختياري) لمنع الإساءة. إنشاء النقاش هو الطريقة الوحيدة التي يمكن لها أن تؤدي إلى عبارات البذور التي تم إنشاؤها بواسطة AI (OpenAI/Anthropic)؛ يستخدم مفاتح API الخاصة بنا، وليس مفاتيحك.

الشركاء يستخدمون المفاتيح التي نصدرها. فقط الطلبات التي تحتوي على X-API-Key صحيح (من قائمة السماح بنا) يمكن أن تنشئ نقاشات. لا نستخدم مفاتيح LLM التي تم توفيرها من الشركاء لتدفق الإنشاء. إذا تم إساءة استخدام مفتاح، فإننا نلغيه.

إنشاء نقاش مقتصر على 30 طلب في الساعة لكل مفتاح API. بالاقتران مع التحكم في المفتاح، يحد هذا من تكاليف نموذج اللغة وقاعدة البيانات من نقطة الإنشاء.

حدود المعدل

نقطة النهاية الحد
البحث حسب رابط المقالة 60 طلب / دقيقة / IP
الحصول على لقطة 120 طلب / دقيقة / IP
تقديم التصويت (الافتراضي) 30 طلب / دقيقة / IP
تقديم التصويت (وضع النزاهة) 10 طلبات / دقيقة / نقاش
إنشاء بيان (مصرح) 10 في الساعة
إنشاء بيان (مجهول الهوية) 5 في الساعة
بيان العلم (من التضمين) 10 طلبات / دقيقة / IP
بيان مضمّن من التضمين (نقاشات محدودة النطاق بالشريك) افتراضي 25 / قارئ / ساعة + سقف IP؛ انظر env
إنشاء نقاش 30 طلب / ساعة / مفتاح

تتضمن الاستجابات 429 Too Many Requests مع رأس Retry-After يشير إلى عدد الثواني حتى يُسمح بالطلب التالي.

وضع النزاهة: بعض النقاشات لديها حدود معدل تصويت أكثر صرامة ممكّنة لحماية النزاهة. يتعامل التضمين مع هذا بشفافية — يرى المستخدمون رسالة "يرجى التحرك ببطء".

بيانات مضمّنة للتضمين: يستخدم Flask-Limiter EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR وEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (انظر config.py). الحدود الشاملة لكل مستخدم تنطبق أيضاً قبل تخزين البيانات.

تنسيق الخطأ

جميع الأخطاء ترجع JSON بتنسيق متسق.

{
  "error": "error_code",
  "message": "Human-readable description of the error"
}

أسئلة؟ اتصل بنا أو قم بزيارة مركز الشركاء.