MetalAPI
XAU3,997.89−1.54‎%‎XAG53.936−1.51‎%‎XPT1,554.47−0.83‎%‎XPD1,047.03−0.44‎%‎XCU6.5080+2.29‎%‎WTI103.27+3.82‎%‎BRENT111.70−1.30‎%‎EUR1.120400−0.01‎%‎GBP1.337500−0.01‎%‎JPY0.0063761+0.14‎%‎CHF1.213445−0.25‎%‎TRY0.020125−0.12‎%‎BTC80,329.75−0.55‎%‎ETH2,329.36−1.13‎%‎
جميع المقالات
البث اللحظيJavaScriptدرس تطبيقي

بث أسعار المعادن اللحظية باستخدام Server-Sent Events

بُثّ أسعار الذهب والفضة اللحظية عبر Server-Sent Events: نقطة النهاية /stream وأحداثها، وEventSource مقابل Node.js، وإعادة الاتصال، والحصص، وشريط أسعار مباشر.

بقلم MetalAPI Team··وقت القراءة: 10 دقيقة

البث أم الاستطلاع؟

يطلب الاستطلاع الأسعار وفق مؤقت زمني. أما البث فيُبقي اتصال HTTP واحدًا مفتوحًا ويتيح للخادم دفع الأسعار الجديدة فور نشرها. يعتمد بث MetalAPI على Server-Sent Events (SSE): وهي استجابة HTTP عادية بنوع المحتوى text/event-stream تظل مفتوحة وتوصل أحداثًا مسمّاة. لا يحتاج إلى أي مكتبة WebSocket، ويمر عبر معظم الخوادم الوكيلة، وتدعمه المتصفحات أصلًا من خلال EventSource.

يتوقف جدوى البث على فترة التحديث في خطتك. يكلّف البث طلبًا واحدًا عند الاتصال وطلبًا واحدًا في كل دقيقة يظل فيها مفتوحًا، بغض النظر عن عدد مرات تغيّر الأسعار. أما الاستطلاع فيكلّف طلبًا عن كل استدعاء. لعميل واحد يراقب الأسعار على مدار الساعة في شهر من 31 يومًا:

الخطةفترة التحديثالاستطلاع بالفترة نفسهابث واحد مفتوح دائمًا
Proدقيقة واحدة44,640 طلبًانحو 45,400 طلب
Ultra، Business15 ثانية178,560 طلبًانحو 45,400 طلب
Enterprise5 ثوانٍ535,680 طلبًانحو 45,400 طلب

يشمل رقم البث طلبًا واحدًا عن كل إعادة اتصال تحدث كل ساعة، كما يُوضَّح أدناه. في خطة Pro تتقارب تكلفة البث وتكلفة الاستطلاع كل دقيقة؛ وميزة البث أن الأسعار الجديدة تصل فور نشرها دون حلقة استطلاع تحتاج إلى ضبط. أما في Ultra وBusiness وEnterprise، فالبث أرخص بكثير أيضًا من الاستطلاع بأقصى سرعة.

البث ليس الأداة المناسبة عندما يكفي أن يكون السعر صحيحًا مرة واحدة لكل مشاهدة صفحة، ولا للمهام الليلية، ولا للتحقق من العتبات. استخدم استدعاء /latest مخزّنًا مؤقتًا للحالتين الأوليين، وتنبيهات الأسعار مع خطافات الويب للحالة الثالثة.

نقطة النهاية /stream

http
GET https://metalapi.com/api/v1/stream?base=USD&currencies=XAU,XAG,XPT,XPD

يقبل البث المعاملات نفسها التي تقبلها /latest، وهي base وcurrencies (أو symbols) وunit (راجع توثيق نقطة النهاية latest)، ويستخدم المصادقة نفسها: api_key في سلسلة الاستعلام، أو ترويسة Authorization: Bearer، أو ترويسة X-API-Key. يجري التحقق من المعاملات قبل فتح البث، لذا يُرجع الرمز أو الوحدة غير المعروفة خطأ JSON عاديًا بدلًا من بث فارغ.

الخطةتحديثات الأسعار في البثالطلبات شهريًا
Proكل 60 ثانية100,000
Ultraكل 15 ثانية500,000
Businessكل 15 ثانية500,000
Enterpriseكل 5 ثوانٍ1,500,000

تتلقى الخطط الأدنى الخطأ 106 (HTTP 403) مع رسالة تفيد بأن البث اللحظي متاح في خطة Pro وما فوقها. وتنطبق على كل اتصال بضعة حدود:

  • 5 عمليات بث متزامنة كحد أقصى لكل مفتاح API. يُرفض الاتصال السادس بالخطأ 104 (HTTP 429).
  • يدوم الاتصال حتى 60 دقيقة. بعد ساعة يرسل الخادم حدث reconnect ويغلق البث، ثم يعيد العميل الاتصال.
  • يُعاد التحقق من المفتاح كل دقيقة. إذا أُلغي المفتاح أو استُنفدت الحصة الشهرية، يرسل الخادم حدث error ويغلق البث.
  • يُحتسب الاتصال ضمن حد الطلبات في الدقيقة مثل أي طلب آخر، لذا يبدأ العميل العالق في حلقة إعادة اتصال سريعة بتلقي أخطاء 104.

ما الذي يرسله الخادم

هذه بداية بث في صورته النصية الخام (الأسعار توضيحية):

text/event-stream
retry: 5000
event: prices
data: {"timestamp":1792834892,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002501319,"USDXAU":3997.89,"XAG":0.0185392857,"USDXAG":53.94}}

: ping 1792834907000

event: prices
data: {"timestamp":1792834952,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002500812764,"USDXAU":3998.7,"XAG":0.01852194851,"USDXAG":53.99}}
الحدثالبياناتما الذي تفعله
pricesلقطة كاملة: timestamp وdate وbase وrates، إضافةً إلى unit عندما لا تكون الوحدة أونصة تروياستبدل الأسعار التي تعرضها
reconnect{"reason":"max connection time reached"}لا شيء؛ أعد الاتصال عند إغلاق البث
errorcode وtype وinfo، بالرموز نفسها المستخدمة في REST APIتوقّف أو تمهّل قبل إعادة المحاولة، بحسب الرمز
: ping …سطر تعليق كل 15 ثانيةيتجاهله المحلّل؛ استخدمه دليلًا على أن الاتصال حي

ثلاث خصائص تحدد شكل شيفرة العميل. كل حدث prices هو لقطة كاملة للرموز التي طلبتها، وليس فرقًا جزئيًا أبدًا، لذا يعود العميل الذي يعيد الاتصال إلى أحدث حالة مع أول حدث. ويصل هذا الحدث الأول فورًا بعد الاتصال. وبعده لا يُرسل حدث إلا عند تغيّر الأسعار: يتحقق الخادم وفق فترة خطتك ويبقى صامتًا إذا لم يتحرك شيء، وهذا أمر طبيعي للمعادن خلال عطلة نهاية الأسبوع. الصمت مع نبضات الاتصال (heartbeats) لا بأس به؛ أما الصمت دونها فيعني أن الاتصال قد انقطع.

لا تحمل الأحداث id، لذا لا دور لـ Last-Event-ID. فلا يوجد ما يُعاد تشغيله، لأن كل لقطة تحل محل كل ما سبقها. وتتبع مفاتيح rates صيغة REST: USDXAU هو سعر أونصة تروي واحدة من الذهب بالدولار الأمريكي، وXAU هو مقلوبه.

في المتصفح باستخدام EventSource

يتولى EventSource الاتصال والتحليل وإعادة الاتصال نيابةً عنك. لكنه لا يستطيع إرسال ترويسات مخصصة، لذا يوضع المفتاح في سلسلة الاستعلام. استخدم مفتاحًا مقيّدًا بنطاقاتك: يرسل المتصفح ترويسة Origin مع الطلب، ويرفض المفتاح المقيّد عمليات البث المفتوحة من مواقع أخرى بالخطأ 107.

المتصفح
// Use a key restricted to your domains: it is visible to anyone who opens the page.
const url = "https://metalapi.com/api/v1/stream?api_key=YOUR_RESTRICTED_KEY&base=EUR&currencies=XAU,XAG&unit=gram";
const FATAL = [101, 102, 103, 105, 106, 107];
const es = new EventSource(url);

es.addEventListener("prices", (e) => {
  const { timestamp, base, rates } = JSON.parse(e.data);
  document.querySelector("#gold").textContent = rates[`${base}XAU`].toFixed(2);
  document.querySelector("#silver").textContent = rates[`${base}XAG`].toFixed(3);
  document.querySelector("#updated").textContent = new Date(timestamp * 1000).toLocaleTimeString();
});

es.addEventListener("error", (e) => {
  if (e instanceof MessageEvent && e.data) {
    // Sent by MetalAPI before it closes the stream: {"code":105,"type":"quota_exceeded",...}
    const err = JSON.parse(e.data);
    console.warn("MetalAPI stream error", err.code, err.info);
    if (FATAL.includes(err.code)) es.close(); // reconnecting would fail again
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // Otherwise the connection dropped and the browser reconnects by itself.
});

يتلقى مستمع error نوعين مختلفين من الأحداث. تصل مشكلة الاتصال على شكل Event عادي، ويعيد EventSource الاتصال تلقائيًا بعد 5 ثوانٍ وفق سطر retry الذي يرسله الخادم. أما الخطأ الذي يرسله MetalAPI فيصل على شكل MessageEvent تحتوي data فيه على خطأ JSON، ويغلق الخادم البث مباشرةً بعده. بالنسبة إلى الرموز التي لا تحلّها إعادة المحاولة، استدعِ close()؛ وإلا فسيستمر المتصفح في إعادة الاتصال وفي التعرض للرفض.

إذا كانت الاستجابة الأولى خطأ HTTP منذ البداية، مثل 106 لأن الخطة لا تشمل البث، فإن EventSource لا يكشف جسم الاستجابة ولا يعيد المحاولة: تصبح قيمة readyState هي CLOSED. وتعرض نقطة نهاية الاستخدام المجانية خطة المفتاح عندما تحتاج إلى معرفة السبب.

يناسب البث المباشر من المتصفح لوحات المعلومات الداخلية ذات العدد القليل من المستخدمين. فكل علامة تبويب مفتوحة هي بث مستقل: تكلّف طلبًا في الدقيقة وتُحتسب ضمن حد 5 عمليات بث متزامنة لكل مفتاح. أما للصفحات العامة، فشغّل بثًا واحدًا على خادمك ووزّعه على المتصفحات، كما يفعل شريط الأسعار أدناه.

في Node.js باستخدام fetch

على الخادم يمكن وضع المفتاح في ترويسة، وتتحكم أنت في إعادة الاتصال بدقة. يتضمن Node.js 18 والإصدارات الأحدث fetch مع أجسام استجابة قابلة للبث، لذا يتسع عميل كامل لوحدة واحدة دون أي اعتماديات:

metal-stream.ts
// metal-stream.ts — Node.js 18+, no dependencies
export interface Prices {
  timestamp: number;
  date: string;
  base: string;
  unit?: string;
  rates: Record<string, number>;
}

const STREAM_URL = "https://metalapi.com/api/v1/stream?base=USD&currencies=XAU,XAG,XPT,XPD";
const FATAL = new Set([101, 102, 103, 105, 106, 107]); // retrying will not help
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

class FatalStreamError extends Error {}

export async function streamPrices(onPrices: (p: Prices) => void): Promise<never> {
  let retryMs = 5_000; // the server sends `retry: 5000`
  let failures = 0;
  for (;;) {
    const ctrl = new AbortController();
    let watchdog: ReturnType<typeof setTimeout> | undefined;
    const kick = () => {
      clearTimeout(watchdog);
      watchdog = setTimeout(() => ctrl.abort(), 45_000); // 3 missed heartbeats
    };
    try {
      const res = await fetch(STREAM_URL, {
        headers: { Authorization: `Bearer ${process.env.METALAPI_KEY}`, Accept: "text/event-stream" },
        signal: ctrl.signal,
      });
      if (!res.ok || !res.body) {
        const body = await res.json().catch(() => null);
        const code = body?.error?.code;
        throw FATAL.has(code) ? new FatalStreamError(`${code}: ${body.error.info}`) : new Error(`HTTP ${res.status}`);
      }
      kick();
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        kick();
        buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
        let end: number;
        while ((end = buffer.indexOf("\n\n")) !== -1) {
          const block = buffer.slice(0, end);
          buffer = buffer.slice(end + 2);
          let event = "message";
          const data: string[] = [];
          for (const line of block.split("\n")) {
            if (!line || line.startsWith(":")) continue; // heartbeat comment
            const i = line.indexOf(":");
            const field = i === -1 ? line : line.slice(0, i);
            const val = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
            if (field === "event") event = val;
            else if (field === "data") data.push(val);
            else if (field === "retry" && /^\d+$/.test(val)) retryMs = Number(val);
          }
          if (event === "prices") {
            failures = 0;
            onPrices(JSON.parse(data.join("\n")));
          } else if (event === "error") {
            const err = JSON.parse(data.join("\n"));
            if (FATAL.has(err.code)) throw new FatalStreamError(`${err.code}: ${err.info}`);
          }
          // `reconnect`: the server closes the connection next; the loop reconnects.
        }
      }
    } catch (err) {
      if (err instanceof FatalStreamError) throw err;
      failures++;
      console.warn("stream interrupted:", (err as Error).message);
    } finally {
      clearTimeout(watchdog);
    }
    // 5 s after a normal close, then back off up to a minute while it keeps failing.
    const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
    await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
  }
}

استخدمه من أي سكربت أو خدمة:

watch.ts
import { streamPrices } from "./metal-stream";

streamPrices((p) => {
  console.log(new Date(p.timestamp * 1000).toISOString(), "gold", p.rates.USDXAU, "silver", p.rates.USDXAG);
}).catch((err) => {
  console.error("Stream stopped:", err.message); // a FATAL code: fix the key, plan or quota
  process.exit(1);
});

يتبع المحلّل صيغة SSE: تُفصل الأحداث بسطر فارغ، ويسمّي event: الحدث، وتُضم أسطر data: بعضها إلى بعض بفواصل أسطر، والأسطر التي تبدأ بنقطتين رأسيتين تعليقات. وتصلح أيضًا أي مكتبة عميل SSE تتيح ضبط ترويسات الطلب، ما دامت تطبّق قواعد إعادة الاتصال الواردة في القسم التالي.

إعادة الاتصال ومعالجة الأعطال

تتبع الوحدة أعلاه مجموعة قصيرة من القواعد التي ينبغي أن يطبّقها أي عميل بث:

الحالةالإشارةالاستجابة
التدوير كل ساعةحدث reconnect، ثم ينتهي البثأعد الاتصال بعد مهلة retry التي يحددها الخادم (5 ثوانٍ)
انقطاع الشبكةخطأ في القراءة، أو انتهاء البث دون reconnectأعد الاتصال مع تراجع أسّي وتأخير عشوائي (jitter)، بفاصل 60 ثانية كحد أقصى
اتصال صامتلا بيانات ولا نبضات لمدة 45 ثانيةألغِ الاتصال وأعد الاتصال؛ فقد فاتت ثلاث نبضات
رفض مؤقت104، 500، 503تمهّل ثم أعد المحاولة
رفض دائم101، 102، 103، 105، 106، 107توقّف ونبّه شخصًا مسؤولًا

المؤقت الرقابي (watchdog) أهم مما يبدو. فالاتصال الذي ينقطع دون إعادة تعيين TCP، خلف بوابة NAT أو موازن أحمال مثلًا، قد يبدو مفتوحًا لدقائق قبل أن يلاحظ نظام التشغيل ذلك. ومع نبضة كل 15 ثانية، يستطيع العميل اكتشاف الانقطاع خلال 45 ثانية.

احذر حلقات إعادة الاتصال: فكل اتصال ناجح يكلّف طلبًا، وكل محاولة تُحتسب ضمن حد الطلبات في الدقيقة. ويحدّ التراجع الأسّي من الضرر عندما يستمر شيء ما في الفشل.

يستحق نفاد الحصة (105) تنبيهًا خاصًا به. يُبلغ البث عنه كحدث error عند التحقق التالي الذي يجري كل دقيقة، ولا تفيد إعادة الاتصال إلى أن تُعاد تعيين الحصة في اليوم الأول من الشهر (UTC) أو تُرقّى الخطة.

بناء شريط أسعار مباشر

لا ينبغي لشريط أسعار عام أن يفتح بث MetalAPI لكل زائر. بدلًا من ذلك، يحتفظ خادم ترحيل (relay) صغير ببث واحد على الخادم ويدفع كل لقطة إلى كل متصفح عبر نقطة نهاية SSE خاصة به. وبذلك يعتمد استهلاكك على عدد خوادم الترحيل لا على عدد الزوار، ولا يغادر المفتاح الخادم أبدًا:

relay.ts
// relay.ts — one MetalAPI stream on the server, any number of browsers.
import { readFileSync } from "node:fs";
import http from "node:http";
import { streamPrices } from "./metal-stream";

const page = readFileSync(new URL("./ticker.html", import.meta.url));
const clients = new Set<http.ServerResponse>();
let latest: string | null = null;

const broadcast = (chunk: string) => {
  for (const res of clients) res.write(chunk);
};

streamPrices((p) => {
  latest = JSON.stringify({ timestamp: p.timestamp, base: p.base, rates: p.rates });
  broadcast(`event: prices\ndata: ${latest}\n\n`);
}).catch((err) => {
  console.error("upstream stream stopped:", err.message);
  process.exit(1);
});

setInterval(() => broadcast(": ping\n\n"), 15_000);

http.createServer((req, res) => {
  if (req.url === "/") return void res.writeHead(200, { "Content-Type": "text/html" }).end(page);
  if (req.url !== "/prices") return void res.writeHead(404).end();
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-store",
    "X-Accel-Buffering": "no",
  });
  res.write("retry: 3000\n\n");
  if (latest) res.write(`event: prices\ndata: ${latest}\n\n`); // new viewers get prices at once
  clients.add(res);
  req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));

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

ticker.html
<!doctype html>
<meta charset="utf-8">
<title>Live metal prices</title>
<style>
  .ticker { display: flex; flex-wrap: wrap; gap: 1.5rem; font: 15px/1.4 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  .up { color: #15803d; }
  .down { color: #dc2626; }
</style>

<div id="ticker" class="ticker">Loading prices…</div>

<script>
  const NAMES = { XAU: "Gold", XAG: "Silver", XPT: "Platinum", XPD: "Palladium" };
  const previous = {};
  const ticker = document.getElementById("ticker");

  new EventSource("/prices").addEventListener("prices", (e) => {
    const { base, rates, timestamp } = JSON.parse(e.data);
    const money = new Intl.NumberFormat(undefined, { style: "currency", currency: base });
    ticker.replaceChildren(
      ...Object.entries(NAMES).map(([code, name]) => {
        const price = rates[base + code];
        const el = document.createElement("span");
        if (previous[code] !== undefined && price !== previous[code]) {
          el.className = price > previous[code] ? "up" : "down";
        }
        previous[code] = price;
        el.textContent = `${name} ${money.format(price)}`;
        return el;
      }),
    );
    ticker.title = `Prices as of ${new Date(timestamp * 1000).toLocaleString()}`;
  });
</script>

شغّله باستخدام METALAPI_KEY=mk_live_... npx tsx relay.ts وافتح http://localhost:3000. يقارن الشريط كل سعر بالحدث السابق. ولعرض التغيّر اليومي بدلًا من ذلك، اجلب سعر افتتاح اليوم مرة واحدة من نقطة النهاية OHLC وقارن كل سعر جديد به.

ملاحظات لبيئة الإنتاج

  • أوقف التخزين المؤقت في الخادم الوكيل. يرسل MetalAPI الترويستين X-Accel-Buffering: no وCache-Control: no-transform. إذا كان nginx أو خادم وكيل آخر يقع أمام خادم الترحيل لديك، فعطّل تخزين الاستجابة مؤقتًا لمسار SSE (proxy_buffering off; في nginx)، وإلا ستصل الأحداث على دفعات.
  • لا تضغط بث الأحداث. قد تحتجز البرمجيات الوسيطة للضغط الأجزاء الصغيرة إلى أن يمتلئ مخزنها المؤقت. استثنِ منها استجابات text/event-stream.
  • شغّل خادم الترحيل كعملية طويلة الأمد. عادةً ما تخضع الدوال عديمة الخادم (serverless) لحدود زمنية للتنفيذ، ما يجعلها خيارًا غير مناسب لإبقاء بث مفتوح على مدار الساعة.
  • اعرض وقت البيانات. اعرض timestamp الخاص بالحدث، لا الوقت الذي تلقاه فيه المتصفح، حتى يميّز المشاهدون بين سوق هادئة في عطلة نهاية الأسبوع وتغذية متوقفة.
  • راعِ قارئات الشاشة. لا تضع شريط أسعار سريع التحديث في منطقة aria-live؛ فالإعلان عن كل تحديث مزعج.
  • خطّط لاستهلاك الحصة. يستهلك بث واحد مفتوح دائمًا نحو 45,400 طلب شهريًا، لذا تتسع خطة Pro لبثّين إضافةً إلى استدعاءات API الاعتيادية. تحقّق من استهلاكك مجانًا عبر نقطة نهاية الاستخدام.

يسرد توثيق API معاملات البث، وتوضح صفحة الأسعار الخطط التي تشمله.

جرّبه مع بياناتك الخاصة

أنشئ حسابًا مجانيًا وأرسل طلبك الأول في أقل من دقيقتين. 100 طلب شهريًا، دون بطاقة ائتمان.

تابع القراءة