المدونة/Developer

بناء حاسبة أسعار مجوهرات الذهب باستخدام JavaScript

بناء حاسبة أسعار مجوهرات الذهب من أسعار القيراط الحية لكل غرام، بحساب عشري دقيق، وتخزين مؤقت، وهوامش ربح، وحدود تقييم واضحة.

Developer
اللغة:
العربية

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

ما الذي تقدّره هذه الحاسبة، وما الذي لا تقدّره

الناتج هو قيمة المادة: قيمة محتوى الذهب في القطعة عند السعر الفوري الحالي، قبل إضافة أي شيء آخر. إنها ليست تثميناً (appraisal)، وليست قيمة إعادة بيع مضمونة، وليست ما سيدفعه المشتري فعلياً.

مستبعد تماماً:

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

كل رقم تنتجه هذه الحاسبة يجب أن يرافقه سطر يخبر المستخدم بذلك. ابنِ إخلاء المسؤولية داخل الواجهة، لا في التوثيق فقط.

الخطوة 1: جلب أسعار القيراطات الثمانية لكل غرام

تعيد نقطة نهاية /v1/carat في الـ API أسعار الغرام الواحد لثمانية أنواع قيراط شائعة في طلب واحد، دون الحاجة إلى مفتاح API:

GET https://api.goldprice.dev/v1/carat?currency=USD

استجابة حقيقية تبدو هكذا:

{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}

يهم الكود أدناه تفصيلان. كل حقل سعر هو سلسلة نصية (string)، وليس رقماً، لتبقى الأرقام العشرية الدقيقة سليمة عبر رحلة JSON. وحقل currency يقبل أياً من 31 عملة يدعمها API، فتغطي نقطة النهاية نفسها USD أو EUR أو INR أو IDR بتغيير معامل استعلام واحد فقط.

الخطوة 2: التخزين المؤقت من جهة الخادم، 60 ثانية

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

import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);

هذا node:http خالص، دون حاجة إلى إطار عمل لنقطة نهاية واحدة. معالجة الفشل مهمة بقدر أهمية المسار السعيد: استجابة مصدرية غير 200 أو جسم مشوّه تُطلق خطأً، وتعود getCarat إلى آخر قيمة مخزنة مؤقتاً إن وُجدت وكانت لا تزال ضمن نافذة TTL. فقط عندما لا توجد ذاكرة مؤقتة على الإطلاق يصل الخطأ إلى العميل، كاستجابة 502.

لا تستدعِ api.goldprice.dev مباشرةً من JavaScript في المتصفح. الـ API عمداً بلا واجهة CORS للمتصفح، بحسب التصميم، لذا سيفشل أي fetch من كود جهة العميل. مرّر كل طلب عبر نقطة نهاية خادمك الخاص، وهو ما يبقي أيضاً الذاكرة المؤقتة مشتركة بين كل الزوار بدلاً من واحدة لكل تبويب متصفح.

الخطوة 3: قيمة المادة: الوزن مضروباً في سعر الغرام

الصيغة الأساسية عملية ضرب واحدة: weight_grams × per_gram_karat_price. المشكلة أن كلا الطرفين يجب أن يبقيا في حساب عشري دقيق، لأن ضرب أرقام النقطة العائمة (floating-point) لقيم مالية ينحرف عن الدقة:

console.log(0.1 + 0.2); // 0.30000000000000004

يمنحك الـ API بالفعل سلاسل عشرية دقيقة. الحل هو إبقاؤها أعداداً صحيحة بالوحدات الصغرى (السنتات) بدلاً من تحويلها إلى Number ثم إعادتها:

function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}

لا حاجة إلى مكتبة أعداد كبيرة هنا، فقط BigInt، الذي تشحنه كل بيئة تشغيل JS حالية بشكل أصلي. يصل الوزن كرقم عشري عادي (ميزان الصائغ يقرأ حتى 0.01g أو 0.001g)، لذا يُحوَّل إلى مليغرامات صحيحة قبل الضرب، وتحدث القسمة للعودة لاحقاً، بعد الضرب، كي تبقى القيم الوسيطة دقيقة. لاحظ أن قسمة الأعداد الصحيحة تُقرِّب بالبتر (truncate) لا بالتقريب؛ اختر قاعدة تقريب صريحة للوحدة الصغرى لعملتك قبل نشر هذا في الإنتاج، على غرار الطريقة التي يقرّب بها الـ API نفسه كل سعر قيراط إلى خطوة العملة العشرية الخاصة بها.

الخطوة 4: إضافة الهامش ومصنعية التشغيل بشكل منفصل

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

  • هامش التاجر: زيادة المحل أو خصم جانب الشراء، عادةً نسبة مئوية
  • مصنعية التشغيل: تكلفة تحويل الذهب الخام إلى قطعة جاهزة، عادةً رسم ثابت أو نسبة مئوية من قيمة المادة، ومستقلة تماماً عن السعر الفوري
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}

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

الخطوة 5: عرض الطابع الزمني، والعملة، والحدود

جانب المتصفح يستدعي خادمك، لا الـ API المصدري أبداً:

async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});

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

الخطوة 6: التعامل مع فشل الجلب والبيانات المشوّهة

للفشل ثلاثة أشكال عند حدود الخادم، والكود أعلاه يعالجها جميعاً بالفعل:

  • فشل الطلب المصدري نفسه (خطأ شبكة، مهلة زمنية): يُلتقط في fetchCarat، ويعود إلى الذاكرة المؤقتة
  • الاستجابة بحالة غير 200: تُفحص صراحةً، وتُعامَل معاملة فشل الشبكة نفسها
  • جسم الاستجابة لا يبدو صحيحاً (حقول مفقودة، أنواع خاطئة): فحص typeof data.price_gram_24k !== "string" يلتقط جسماً مشوّهاً قبل تخزينه مؤقتاً أو تقديمه

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

الترخيص

نقطة النهاية المجهولة المستخدمة في هذه المقالة مناسبة للتطوير والاختبار. الاستخدام التجاري لبيانات الـ API الخام (في منتج تفرض رسوماً عليه، أو يدرّ إيرادات) يتطلب ترخيص خطة Pro أو أعلى. إذا كان كل ما تحتاجه سعراً مباشراً معروضاً على صفحة، لا بيانات خام للحساب عليها، فإن الأداة الرسمية القابلة للتضمين مجانية للاستخدام التجاري في العرض ولا تحتاج مفتاح API.

قراءات ذات صلة

مقالات ذات صلة

Developer

Backtest local-currency gold without FX look-ahead bias

Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.

← اقرأ
Developer

Gold price WebSocket on Reddit: the streaming questions, answered

The real-time gold streaming questions that come up on Reddit, answered: whether a gold WebSocket exists, how the goldprice.dev XAU and XAG tick stream works end to end, and which other APIs actually offer one (most metals APIs just poll).

← اقرأ
Developer

Gold price API on Stack Overflow: developer troubleshooting, answered

The gold-price-API errors developers hit on Stack Overflow, fixed: 401 auth, 400 invalid_symbol, 429 rate limits, CORS from the browser, reading the response, and pulling historical data.

← اقرأ

goldprice.dev

أسعار الذهب اللحظية، بيانات OHLC التاريخية، وتجميع متعدد المصادر — متاحة عبر REST و SSE.