ライブのチャンピオンシップ順位表を作る
RapidAPI キーをブラウザに出さない小さなキャッシュプロキシを使って、MotoGP、Moto2、Moto3 のライダーズ選手権をウェブサイトに表示します。
10 分で読めます
順位表は、ニュースサイト、ファンブログ、クラブのページで最も一般的な MotoGP ウィジェットです。このガイドでは、2 つのパートに分けて作ります。
- プロキシ — キーを保持してレスポンスをキャッシュする小さなサーバーエンドポイント。
- ウィジェット — プロキシから表を描画する、素の HTML と JavaScript。
1. プロキシ
ブラウザには RapidAPI キーを絶対に見せてはいけないので、ページは自前のエンドポイントと通信します。この例では Node.js 18+ と Express を使いますが、同じ 30 行ほどのコードを Netlify、Vercel、Cloudflare の関数としても使えます。
// server.js — npm install express
import express from "express";
const HOST = "motogp-api-championship-data-statistics.p.rapidapi.com";
const KEY = process.env.RAPIDAPI_KEY;
if (!KEY) throw new Error("RAPIDAPI_KEY is not set");
const CATEGORIES = new Set(["MOTOGP", "MOTO2", "MOTO3"]);
const TTL_MS = 60 * 60 * 1000; // 1 hour
const cache = new Map();
async function cached(path) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < TTL_MS) return hit.body;
const res = await fetch(`https://${HOST}${path}`, {
headers: { "X-RapidAPI-Key": KEY, "X-RapidAPI-Host": HOST },
});
if (!res.ok) throw new Error(`Upstream ${res.status}`);
const body = await res.json();
cache.set(path, { at: Date.now(), body });
return body;
}
const app = express();
app.get("/api/standings", async (req, res) => {
const category = String(req.query.category ?? "MOTOGP").toUpperCase();
if (!CATEGORIES.has(category)) return res.status(400).json({ error: "Unknown category" });
try {
const { data: season } = await cached("/seasons/latest");
const { data } = await cached(`/standings/seasons/${season.year}/riders?category=${category}`);
res.set("Cache-Control", "public, max-age=300").json({ season: season.year, category, rows: data });
} catch (err) {
res.status(502).json({ error: "Standings are unavailable right now" });
}
});
app.use(express.static("public"));
app.listen(3000, () => console.log("http://localhost:3000"));
重要な点が 2 つあります。
- 入力を検証する。 既知の 3 つのカテゴリだけを転送するので、他人があなたのプロキシを使って任意のエンドポイントにクォータを消費させることはできません。
- キャッシュする。 カテゴリごとに 1 時間に 1 回の上流呼び出しで、すべての訪問者に対応できます。
2. ウィジェット
<!-- public/index.html -->
<div class="standings">
<div role="tablist">
<button data-category="MOTOGP" aria-selected="true">MotoGP</button>
<button data-category="MOTO2">Moto2</button>
<button data-category="MOTO3">Moto3</button>
</div>
<table>
<caption></caption>
<thead>
<tr><th>Pos</th><th>Rider</th><th>Team</th><th>Wins</th><th>Pts</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<script type="module">
const tbody = document.querySelector(".standings tbody");
const caption = document.querySelector(".standings caption");
function cell(text) {
const td = document.createElement("td");
td.textContent = text; // textContent, never innerHTML, for data from an API
return td;
}
async function load(category) {
caption.textContent = "Loading…";
const res = await fetch(`/api/standings?category=${category}`);
if (!res.ok) {
caption.textContent = "Standings are unavailable right now.";
tbody.replaceChildren();
return;
}
const { season, rows } = await res.json();
caption.textContent = `${season} ${category} riders' championship`;
tbody.replaceChildren(
...rows.map((r) => {
const tr = document.createElement("tr");
tr.append(
cell(r.position),
cell(`#${r.rider_number} ${r.rider_name}`),
cell(r.team_name),
cell(r.wins),
cell(r.total_points),
);
return tr;
}),
);
}
document.querySelectorAll("[data-category]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-category]").forEach((b) => b.setAttribute("aria-selected", b === button));
load(button.dataset.category);
});
});
load("MOTOGP");
</script>
RAPIDAPI_KEY=... node server.js を実行し、http://localhost:3000 を開いてください。
バリエーション
- 首位との差。
rows[0].total_points - r.total_pointsを表示する列を追加します。 - チーム選手権。 2 つ目のルートを
/standings/seasons/{year}/teamsに向けます。各行にはteam_nameとtotal_pointsが含まれます。 - マニュファクチャラー。
/standings/seasons/{year}/manufacturersはmanufacturer_nameを持つ行を返します。 - 静的サイト。 ビルド時に順位を取得し(Astro、Next.js、Hugo)、レースウィークエンドごとに再ビルドします。プロキシは不要です。