API цен на золото на JavaScript: Node.js, TypeScript и Next.js
Цена золота в JavaScript через API: fetch с async/await, типы ответа в TypeScript, кэширующий маршрут на Express или Next.js и безопасная работа с ключом API.
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 и новее читают этот файл сами; в более старых версиях вместо этого экспортируйте переменную в оболочке.
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 из ответов о превышении лимита запросов:
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 недоступен:
// 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:
<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_, чтобы он существовал только на сервере:
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 запросов в месяц, без банковской карты.
Читайте также
Что означает XAU/USD: коды XAU, XAG, XPT и XPD
Что означает XAU/USD: почему у золота есть валютный код ISO 4217, что обозначают XAG, XPT и XPD и как читать направление котировки в ценах и данных API.
Бесплатный API цен на золото: что дают 100 запросов в месяц
Бесплатный API цен на золото без прикрас: что даёт тариф Free MetalAPI, как растянуть 100 запросов в месяц с помощью кеша и объединения запросов и когда менять тариф.
API цен на золото на Python: запросы, кэш и графики
API цен на золото на Python: актуальные цены через requests, обработка ошибок и повторов, кэширование ответов и график временного ряда в pandas и matplotlib.