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,686.99−0.11%ETH2,351.21−0.20%
全部文章
黄金JavaScriptTypeScript教程

JavaScript 黄金价格 API 教程:Node.js、TypeScript 与 Next.js

用黄金价格 API 在 JavaScript 中获取金价:async/await 与 fetch、TypeScript 响应类型、带缓存的 Express 或 Next.js 路由,以及 API 密钥的安全用法。

作者:MetalAPI Team··阅读约 7 分钟

JavaScript 黄金价格 API:第一个请求

在 JavaScript 中调用黄金价格 API 只需一次 fetch 调用,但生产环境的集成还需要类型、错误处理、缓存以及一个安全存放 API 密钥的地方。本文使用 MetalAPI(一个提供贵金属、工业金属、能源、货币和加密货币数据的 JSON REST API)逐一实现这些功能:先是一个普通的 Node.js 脚本,然后是一个带类型的 TypeScript 客户端,最后是一个小型 Express 或 Next.js 路由,在不暴露密钥的情况下向前端提供金价。

Node.js 18 及更高版本内置 fetch,因此无需任何 HTTP 库。在控制台中创建一个密钥(密钥以 mk_live_ 开头),将其以 METALAPI_KEY=mk_live_... 的形式写入 .env 文件,然后用 node --env-file=.env gold.mjs 运行脚本。Node.js 20.6 及更高版本会读取该文件;在更早的版本中,请改为在 shell 中导出该变量。

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,即 1 金衡盎司黄金以基础货币计的价格。同一个对象中还包含 rates.XAU,约为 0.00025,它是倒数,即每美元可兑换的黄金盎司数。误把它显示出来是金价集成中的经典错误。将 base 改为 EUR,键就会变为 EURXAU;添加 unit=gram 即可获取每克价格。timestamp 是价格数据的时间(Unix 秒),而不是您发出请求的时间。

黄金价格 API 响应的 TypeScript 类型

响应结构小而稳定,因此很容易为其定义类型。成功的响应带有 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" }) 返回每日收盘价,Free 套餐每次请求最多 30 天,付费套餐最多 365 天。请根据 err.code 进行分支处理,切勿依赖消息文本。只重试 104(每分钟请求限制,等待 retryAfter 秒后重试)、500、503 和网络错误;这些失败的请求不计费。其他代码则需要修正问题:101 或 102 表示缺少密钥或密钥无效,105 表示每月配额已用完,106 表示您的套餐不包含该端点。

服务器端还是浏览器:API 密钥应该放在哪里

客户端 JavaScript 中的任何内容都是公开的。放在 React 打包文件、内联脚本或 NEXT_PUBLIC_、VITE_ 环境变量中的密钥,任何访问者都能在浏览器的开发者工具中读取,而且每次页面浏览都会消耗您的一次请求。向用户展示金价有三种方式:

  • 您自己的服务器路由(推荐)。浏览器调用您的端点,由您的服务器携带密钥调用 MetalAPI 并缓存结果。无论流量多大,用量都保持在每次缓存刷新一次请求。
  • 在浏览器中使用域名限制密钥。您可以在控制台中将密钥限制为仅供您的域名使用,Origin 或 Referer 不匹配的浏览器请求会被拒绝并返回错误 107,且不计费。这可以阻止其他网站在其访问者的浏览器中使用该密钥,但无法阻止有人把它复制到脚本中:不带 Origin 或 Referer 的请求总是被允许。请为此使用单独的密钥,切勿使用主密钥。详情请参阅身份验证指南。
  • 可嵌入小组件。如果只是单纯显示价格,小组件无需密钥,也无需编写代码,而且小组件的页面浏览不计入您的配额。

在 Express 中实现带缓存的金价 API 路由

服务器路由需要缓存。每个套餐都按固定间隔更新价格(Free 每天,Lite 每 30 分钟,Standard 每 10 分钟,Advanced 和 Pro 每 60 秒),因此更频繁地请求只会返回相同的数字,白白消耗配额。下面的路由会为每种货币和单位缓存 10 分钟,让并发请求共享同一次上游调用,并在无法连接 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); // 合并并发请求:每个键只发起一次上游调用
  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 教程中那样。缓存位于进程内存中;如果有多个实例或使用无服务器函数,请将其移至 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 中实现同样的路由

在 Next.js App Router 中,Route Handler 可以完成同样的工作,Next.js 的数据缓存也可以取代手写的缓存。将客户端保存为 lib/metalapi.ts,并将密钥以 METALAPI_KEY 的名称保存在 .env.local 中,不要加 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 } 会将上游响应最多保存 10 分钟,并在多个请求之间共享。Next.js 只会存储 HTTP 状态码为 200 的响应,因此 MetalAPI 的错误永远不会被缓存。如果您的应用启用了 Cache Components,请将 fetch 移到标记为 'use cache' 的辅助函数中,并改用 cacheLife 设置其缓存时长。

后续步骤

创建免费账户以获取密钥。Free 套餐每月包含 100 次请求,价格每日更新,无需信用卡,足以构建和测试上述所有内容。当您的路由上线时,请根据价格新鲜度和请求量选择套餐:一个 10 分钟的缓存条目每月约消耗 4,500 次请求,在 Standard 套餐的 10,000 次额度以内。定价页面对所有套餐做了比较,Python 黄金价格 API 教程则介绍了如何在脚本、pandas 和图表中使用同一个 API。

常见问题

可以直接从浏览器调用黄金价格 API 吗?

可以。API 会响应跨域请求,因此可以在网页中使用 fetch。请使用限制为您的域名的密钥,并记住访问者仍然可以读取它,而且每次页面浏览都会消耗一次请求。带缓存的服务器路由成本更低,也能让密钥保持私密。

为什么我的页面显示的是 0.00025,而不是金价?

您读取的是倒数汇率 rates.XAU。请读取 rates.USDXAU;如果基础货币会变化,请读取 rates[base + "XAU"]。以示例数值计算,1 ÷ 0.0002501319 得到 3997.89。

如何在 JavaScript 中获取以欧元计或每克的金价?

传入 base=EUR 和 unit=gram。此时价格为 rates.EURXAU,单位是欧元/克,响应中也会包含 unit: "gram"。unit 还接受 troy_oz(默认值)、kilogram、ounce、pound、metric_ton 和 short_ton。对于黄金,请避免使用 ounce,它表示常衡盎司。

应该多久刷新一次金价?

最多每个套餐更新间隔刷新一次:Free 每天一次,Lite 每小时一次(每 30 分钟一次每月约需 1,490 次请求,超出 1,000 次的配额),Standard 每 10 分钟一次。周末价格保持不变:现货黄金从纽约时间周五 17:00 到周日 17:00 停止交易。

可以不轮询就获取实时金价吗?

在 Pro 及以上套餐中,/stream 通过 Server-Sent Events 推送价格更新,因此您无需轮询 /latest。对于阈值逻辑(例如金价突破 $4,000),带 Webhook 的价格提醒(Advanced 及以上套餐)会在条件满足时调用您的服务器。

用您自己的数据试一试

创建免费账户,两分钟内即可发起第一次请求。每月 100 次请求,无需信用卡。

继续阅读