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 696,37−0,10 %ETH2 352,66−0,14 %
Все статьи
ЗолотоJavaScriptTypeScriptТуториал

API цен на золото на JavaScript: Node.js, TypeScript и Next.js

Цена золота в JavaScript через API: fetch с async/await, типы ответа в TypeScript, кэширующий маршрут на Express или Next.js и безопасная работа с ключом API.

Автор: MetalAPI Team··7 мин чтения

API цен на золото на JavaScript: первый запрос

Чтобы вызвать API цен на золото из JavaScript, достаточно одного вызова fetch, но интеграции для продакшена нужны ещё типы, обработка ошибок, кэш и безопасное место для ключа API. В этой статье мы построим всё это на MetalAPI — JSON REST API для драгоценных и промышленных металлов, энергоносителей, валют и криптовалют: сначала простой скрипт на Node.js, затем типизированный клиент на TypeScript и, наконец, небольшой маршрут на Express или Next.js, который отдаёт цену золота вашему фронтенду, не раскрывая ключ.

В Node.js 18 и новее fetch встроен, поэтому HTTP-библиотека не нужна. Создайте ключ в личном кабинете (ключи начинаются с mk_live_), запишите его в файл .env как METALAPI_KEY=mk_live_... и запустите скрипт командой node --env-file=.env gold.mjs. Node.js 20.6 и новее читают этот файл сами; в более старых версиях вместо этого экспортируйте переменную в оболочке.

gold.mjs
const params = new URLSearchParams({ base: "USD", currencies: "XAU" });
const res = await fetch(`https://metalapi.com/api/v1/latest?${params}`, {
  headers: { "X-API-Key": process.env.METALAPI_KEY },
  signal: AbortSignal.timeout(10_000),
});
const data = await res.json();
if (!data.success) throw new Error(`MetalAPI ${data.error.code}: ${data.error.info}`);

const price = data.rates[`${data.base}XAU`];
console.log(`Gold: ${price} ${data.base} per troy ounce`);
console.log(`Price time: ${new Date(data.timestamp * 1000).toISOString()}`);

С примерными значениями из документации rates.USDXAU равен 3997.89 — это цена одной тройской унции золота в базовой валюте. В том же объекте есть rates.XAU, около 0.00025, — обратная величина, количество унций золота за один доллар. Ошибочно показать именно это число — классическая ошибка в коде для цен на золото. Замените base на EUR, и ключ станет EURXAU; добавьте unit=gram, чтобы получить цену за грамм. timestamp — время ценовых данных в секундах Unix, а не время вашего запроса.

Типы TypeScript для ответа API цен на золото

Структуры ответов небольшие и стабильные, поэтому их легко типизировать. Успешные ответы содержат success: true. Неудачные — success: false и объект error с числовым code, type и сообщением info, так что размеченное объединение по полю success позволяет TypeScript различать эти два случая. Кроме того, клиент превращает неудачные ответы в типизированную ошибку, которая сохраняет значение Retry-After из ответов о превышении лимита запросов:

metalapi.ts
const API = "https://metalapi.com/api/v1";

export interface RateLimit {
  limit: number;
  remaining: number;
  used: number;
  reset: number; // секунды Unix
  reset_date: string;
  plan: string;
}

export interface LatestResponse {
  success: true;
  timestamp: number; // время ценовых данных, секунды Unix
  date: string;
  base: string;
  unit?: string; // присутствует, если unit отличается от troy_oz
  rates: Record<string, number>; // "USDXAU": цена, "XAU": обратная величина
  rate_limit: RateLimit;
}

export interface TimeframeResponse {
  success: true;
  start_date: string;
  end_date: string;
  base: string;
  rates: Record<string, Record<string, number>>; // ключи в формате YYYY-MM-DD
  rate_limit: RateLimit;
}

export interface ErrorResponse {
  success: false;
  error: { code: number; type: string; info: string; upgrade_url?: string };
}

export class MetalApiError extends Error {
  code: number;
  type: string;
  retryAfter?: number;

  constructor(code: number, type: string, info: string, retryAfter?: number) {
    super(`MetalAPI ${code} (${type}): ${info}`);
    this.name = "MetalApiError";
    this.code = code;
    this.type = type;
    this.retryAfter = retryAfter;
  }
}

export async function metalapi<T extends { success: true }>(
  path: string,
  params: Record<string, string> = {},
): Promise<T> {
  const res = await fetch(`${API}/${path}?${new URLSearchParams(params)}`, {
    headers: { "X-API-Key": process.env.METALAPI_KEY ?? "" },
    signal: AbortSignal.timeout(10_000),
  });
  const body = (await res.json().catch(() => null)) as T | ErrorResponse | null;
  if (body === null) throw new MetalApiError(res.status, "http_error", res.statusText);
  if (body.success === false) {
    const retryAfter = Number(res.headers.get("Retry-After")) || undefined;
    throw new MetalApiError(body.error.code, body.error.type, body.error.info, retryAfter);
  }
  return body;
}

export async function goldPrice(base = "USD", unit = "troy_oz") {
  const data = await metalapi<LatestResponse>("latest", { base, currencies: "XAU", unit });
  return { price: data.rates[`${data.base}XAU`], base: data.base, unit, asOf: new Date(data.timestamp * 1000) };
}

await goldPrice("EUR", "gram") возвращает цену грамма в евро вместе с меткой времени, а metalapi<TimeframeResponse>("timeframe", { start_date, end_date, currencies: "XAU" }) — дневные цены закрытия: до 30 дней на запрос на Free и до 365 на платных тарифах. Ветвите логику по err.code, но никогда — по сообщению. Повторяйте только 104 (поминутный лимит, через retryAfter секунд), 500, 503 и сетевые ошибки; такие неудачные запросы не тарифицируются. Остальные коды требуют исправления: 101 или 102 означают отсутствующий или недействительный ключ, 105 — исчерпанную месячную квоту, а 106 — эндпоинт, который не входит в ваш тариф.

Сервер или браузер: где должен храниться ключ API

Всё, что находится в клиентском JavaScript, публично. Ключ в бандле React, во встроенном скрипте или в переменной окружения с префиксом NEXT_PUBLIC_ или VITE_ может прочитать любой посетитель в инструментах разработчика браузера, а каждый просмотр страницы расходует один из ваших запросов. Показать пользователям цену золота можно тремя способами:

  • Собственный серверный маршрут (рекомендуется). Браузер обращается к вашему эндпоинту, а ваш сервер вызывает MetalAPI с ключом и кэширует результат. Расход остаётся на уровне одного запроса на каждое обновление кэша, каким бы ни был трафик.
  • Ключ с ограничением по доменам в браузере. В личном кабинете можно ограничить ключ вашими доменами: запросы из браузера, у которых Origin или Referer не совпадает, отклоняются с ошибкой 107 и не тарифицируются. Это не позволит другим сайтам использовать ключ в браузерах своих посетителей, но не остановит того, кто скопирует его в скрипт: запросы без Origin и Referer всегда разрешены. Используйте для этого отдельный ключ, а не основной. Подробности — в руководстве по аутентификации.
  • Встраиваемый виджет. Для простого показа цены виджету не нужны ни ключ, ни код, а просмотры страниц с виджетом не расходуют вашу квоту.

Маршрут Express с кэшированием цены золота

Серверному маршруту нужен кэш. Каждый тариф обновляет цены с фиксированным интервалом (раз в день на Free, каждые 30 минут на Lite, 10 минут на Standard и 60 секунд на Advanced и Pro), поэтому более частые запросы возвращают те же числа и лишь расходуют квоту. Этот маршрут кэширует каждую валюту и единицу измерения на 10 минут, позволяет одновременным запросам использовать один общий вызов к API и продолжает отдавать последнюю удачную цену, если MetalAPI недоступен:

server.ts
// npm install express, затем: METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { goldPrice } from "./metalapi";

const TTL_MS = 10 * 60_000; // тариф Standard: цены обновляются каждые 10 минут
const CURRENCIES = new Set(["USD", "EUR", "GBP"]);
const UNITS = new Set(["troy_oz", "gram"]);

type Quote = Awaited<ReturnType<typeof goldPrice>>;
const cache = new Map<string, { quote: Quote; expires: number }>();
const inflight = new Map<string, Promise<Quote>>();

function cachedGold(base: string, unit: string): Promise<Quote> {
  const key = `${base}:${unit}`;
  const hit = cache.get(key);
  if (hit && Date.now() < hit.expires) return Promise.resolve(hit.quote);

  let pending = inflight.get(key); // single flight: один вызов к API на ключ
  if (!pending) {
    pending = goldPrice(base, unit)
      .then((quote) => {
        cache.set(key, { quote, expires: Date.now() + TTL_MS });
        return quote;
      })
      .catch((err) => {
        if (!hit) throw err;
        cache.set(key, { quote: hit.quote, expires: Date.now() + 60_000 }); // устаревшая цена, повтор через 1 мин
        return hit.quote;
      })
      .finally(() => inflight.delete(key));
    inflight.set(key, pending);
  }
  return pending;
}

const app = express();

app.get("/api/gold", async (req, res) => {
  const base = String(req.query.currency ?? "USD").toUpperCase();
  const unit = String(req.query.unit ?? "troy_oz");
  if (!CURRENCIES.has(base) || !UNITS.has(unit)) {
    res.status(400).json({ error: "Unsupported currency or unit" });
    return;
  }
  try {
    res.set("Cache-Control", "public, max-age=60").json(await cachedGold(base, unit));
  } catch (err) {
    console.error(err);
    res.status(503).json({ error: "Gold price temporarily unavailable" });
  }
});

app.listen(3000, () => console.log("http://localhost:3000/api/gold?currency=EUR&unit=gram"));

Белый список важен не только для безопасности, но и для квоты. Каждая комбинация валюты и единицы — отдельная запись в кэше, которая стоит одного запроса на каждое обновление, поэтому шесть комбинаций, поддерживаемых в актуальном состоянии круглосуточно, расходуют около 26 800 запросов в месяц. Оставьте в списке только то, что показывают ваши страницы, или запрашивайте несколько валют одним запросом и пересчитывайте локально, как в туториале по PHP и WordPress. Кэш хранится в памяти процесса; если у вас несколько экземпляров или serverless-функции, перенесите его в Redis или в кэш вашей платформы.

Фронтенд затем обращается к вашему маршруту, а не к MetalAPI:

index.html
<p>Gold: <strong id="gold">…</strong> <small id="gold-time"></small></p>
<script type="module">
  const res = await fetch("/api/gold?currency=EUR&unit=gram");
  if (res.ok) {
    const q = await res.json();
    const money = new Intl.NumberFormat(undefined, { style: "currency", currency: q.base });
    document.getElementById("gold").textContent = `${money.format(q.price)} per gram`;
    document.getElementById("gold-time").textContent = `as of ${new Date(q.asOf).toLocaleString()}`;
  }
</script>

Тот же маршрут в Next.js

В App Router Next.js ту же работу выполняет Route Handler, а вместо написанного вручную кэша можно использовать кэш данных Next.js. Сохраните клиент как lib/metalapi.ts и храните ключ в .env.local как METALAPI_KEY без префикса NEXT_PUBLIC_, чтобы он существовал только на сервере:

app/api/gold/route.ts
import type { ErrorResponse, LatestResponse } from "@/lib/metalapi";

export async function GET() {
  try {
    const res = await fetch("https://metalapi.com/api/v1/latest?currencies=XAU,XAG", {
      headers: { "X-API-Key": process.env.METALAPI_KEY ?? "" },
      next: { revalidate: 600 }, // тариф Standard: повторно используем ответ 10 минут
    });
    const data = (await res.json()) as LatestResponse | ErrorResponse;
    if (data.success) {
      return Response.json({ gold: data.rates.USDXAU, silver: data.rates.USDXAG, timestamp: data.timestamp });
    }
    console.error("MetalAPI error", data.error.code, data.error.info);
  } catch (err) {
    console.error(err);
  }
  return Response.json({ error: "Prices temporarily unavailable" }, { status: 503 });
}

next: { revalidate: 600 } сохраняет ответ API на срок до 10 минут и использует его для всех запросов. Next.js сохраняет только ответы со статусом HTTP 200, поэтому ошибка MetalAPI никогда не попадёт в кэш. Если в вашем приложении включены Cache Components, перенесите запрос во вспомогательную функцию с пометкой 'use cache' и задайте срок её жизни через cacheLife.

Что дальше

Создайте бесплатный аккаунт, чтобы получить ключ. Тариф Free включает 100 запросов в месяц с ежедневным обновлением и не требует банковской карты — этого достаточно, чтобы разработать и протестировать всё описанное выше. Когда маршрут заработает в продакшене, выберите тариф по свежести данных и объёму: запись в кэше с обновлением каждые 10 минут расходует около 4 500 запросов в месяц, что укладывается в 10 000 запросов тарифа Standard. Все тарифы сравниваются на странице тарифов, а статья «API цен на золото на Python» описывает тот же API для скриптов, pandas и графиков.

Частые вопросы

Можно ли вызывать API цен на золото прямо из браузера?

Да. API отвечает на кросс-доменные запросы, поэтому fetch работает с веб-страницы. Используйте ключ, ограниченный вашими доменами, и помните, что посетители всё равно могут его прочитать, а каждый просмотр страницы стоит одного запроса. Серверный маршрут с кэшем обходится дешевле и сохраняет ключ в тайне.

Почему на моей странице 0,00025 вместо цены золота?

Вы читаете rates.XAU — обратный курс. Читайте rates.USDXAU или rates[base + "XAU"], если базовая валюта меняется. С примерными значениями 1 ÷ 0,0002501319 даёт 3 997,89.

Как получить цену золота в евро или за грамм на JavaScript?

Передайте base=EUR и unit=gram. Тогда цена — это rates.EURXAU в евро за грамм, а ответ содержит unit: "gram". unit также принимает troy_oz (по умолчанию), kilogram, ounce, pound, metric_ton и short_ton. Для золота не используйте ounce — это торговая унция (avoirdupois).

Как часто обновлять цену золота?

Не чаще одного раза за интервал обновления вашего тарифа: раз в день на Free, раз в час на Lite (опрос каждые 30 минут потребовал бы около 1 490 запросов в месяц при квоте 1 000), каждые 10 минут на Standard. На выходных цены не меняются: спотовое золото не торгуется с 17:00 пятницы до 17:00 воскресенья по нью-йоркскому времени.

Можно ли получать цены на золото в реальном времени без опроса?

На тарифе Pro и выше /stream присылает обновления цен через Server-Sent Events, поэтому опрашивать /latest не нужно. Для пороговой логики, например когда золото пересекает отметку 4 000 $, ценовые оповещения через вебхуки (тариф Advanced и выше) вызывают ваш сервер при выполнении условия.

Попробуйте на своих данных

Создайте бесплатный аккаунт и отправьте первый запрос меньше чем за две минуты. 100 запросов в месяц, без банковской карты.

Читайте также