JavaScript 电影 API 教程
编写一个 Node.js 脚本,接收一组电影 ID 并为每部打印卡片:片名、年份、导演、主要演员和所有已知评分。它使用内置的 fetch,所以没有 npm 包。演职员和评分通过 append_to_response 在同一个请求中返回。
你将编写 cards.mjs,一个 Node.js 脚本。给它电影 ID,它会为每部电影打印一张卡片,包含片名、年份、导演、主要演员和所有已知评分。它使用内置的 fetch,需要 Node 20 或更新版本,大约需要 15 分钟。
步骤 1:获取 API 密钥
套餐为预付方式,只在速度上不同:每分钟 20、40 或 120 个请求,每个套餐都有全部数据。在定价页选择一个,在账户后台创建密钥,然后把密钥放入环境变量:
脚本读取 process.env.AMODB_KEY,所以密钥不会出现在源代码或版本控制中。
export AMODB_KEY="YOUR_API_KEY"mkdir movie-cards && cd movie-cards
node --version步骤 3:发送第一个请求
调用 GET /,在 Authorization 请求头中带上密钥并设置超时,然后在解析响应体之前先检查状态。
const res = await fetch('https://api.amodb.dev/3/movie/550?language=en-US', {
headers: { Authorization: `Bearer ${process.env.AMODB_KEY}` },
signal: AbortSignal.timeout(10_000),
});
console.log(res.status, (await res.json()).title);步骤 4:加入演职员和评分
在同一个调用上追加 credits,ratings,就会在各自的键下返回演员列表、幕后人员列表和五个评分来源。这样每部电影只需一次请求而不是三次,整个调用只占你每分钟 20、40 或 120 个请求中的一个。导演是 job 为 Director 的幕后人员。字段说明见演职员和评分。
在 ratings 中,每个来源是 { value, votes } 或 null。下面的卡片格式化函数会跳过 null,并把其余的连接起来:
const scores = Object.entries(movie.ratings ?? {})
.filter(([, entry]) => entry !== null)
.map(([source, entry]) => `${source} ${entry.value}`)
.join(', ');步骤 5:处理错误和速率限制
每一种失败都对应一个决策:
| 状态 | 决策 |
|---|---|
| 401 | 停止整个运行。错误或已停用的密钥会让每个 ID 都失败。 |
| 404 | 报告该 ID,然后继续处理下一个。 |
| 429 | 等待 Retry-After 秒(上限 30)后重试,最多重试两次。 |
| 504 | 按同样的计划重试,然后报告。 |
| 超时或无网络 | 等待一秒乘以尝试次数,第三次失败后报告。 |
X-RateLimit-Remaining 响应头显示当前 60 秒窗口中还剩多少次调用。如果想在出现 429 之前放慢速度,可以记录它。窗口规则见速率限制,完整列表见错误。
步骤 6:加入一个小缓存
详情在边缘缓存 6 小时,所以在这段时间内再次请求会返回相同的数据。在你这一侧做缓存,可以完全跳过调用。脚本保存一个由 { expires, data } 条目构成的 Map,启动时从 .amodb-cache.json 加载,结束时写回。对同样的 ID 运行第二次,不会发出任何请求。
步骤 7:完整程序
把它保存为 cards.mjs,然后运行 node cards.mjs 550 603 27205。
运行两次。第一次对每个 ID 发送一次请求,如果返回 429 则多一次重试,并写入 .amodb-cache.json。第二次打印同样的卡片,不发送任何请求。删除该文件可强制刷新。不存在的 ID 会打印 404: The resource you requested could not be found. 并把退出码设为 1,这在脚本运行于计划任务中时很有用。
循环特意写成顺序执行。一次处理所有 ID 会在一次突发中耗尽每分钟的额度,并让大多数请求走进 429 流程。如果需要更快,可以每五个 ID 一组处理,并把重试逻辑保留在 request() 中。
import { readFile, writeFile } from 'node:fs/promises';
const BASE = 'https://api.amodb.dev/3';
const KEY = process.env.AMODB_KEY;
const CACHE_FILE = '.amodb-cache.json';
const DETAILS_TTL_MS = 6 * 60 * 60 * 1000;
if (!KEY) {
console.error('Set AMODB_KEY first.');
process.exit(1);
}
class ApiError extends Error {
constructor(status, message) {
super(`${status}: ${message}`);
this.status = status;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function request(path, params = {}) {
const url = new URL(BASE + path);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
for (let attempt = 1; attempt <= 3; attempt++) {
let res;
try {
res = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
if (attempt === 3) throw new Error(`network error: ${err.message}`);
await sleep(1000 * attempt);
continue;
}
if (res.status === 401) {
throw new ApiError(401, 'the key is invalid or suspended. Check AMODB_KEY.');
}
if ((res.status === 429 || res.status === 504) && attempt < 3) {
const seconds = Math.min(Number(res.headers.get('Retry-After')) || 1, 30);
await sleep(seconds * 1000);
continue;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(res.status, body.status_message ?? res.statusText);
}
return res.json();
}
}
async function loadCache() {
try {
return new Map(Object.entries(JSON.parse(await readFile(CACHE_FILE, 'utf8'))));
} catch {
return new Map();
}
}
async function cachedGet(cache, path, params, ttlMs) {
const key = `${path}?${new URLSearchParams(params)}`;
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.data;
const data = await request(path, params);
cache.set(key, { expires: Date.now() + ttlMs, data });
return data;
}
function formatCard(movie) {
const year = (movie.release_date ?? '').slice(0, 4) || 'n/a';
const director = movie.credits.crew.find((p) => p.job === 'Director')?.name ?? 'unknown';
const cast = movie.credits.cast.slice(0, 3).map((p) => p.name).join(', ');
const scores = Object.entries(movie.ratings ?? {})
.filter(([, entry]) => entry !== null)
.map(([source, entry]) => `${source} ${entry.value}`)
.join(', ');
return [
`${movie.title} (${year}), ${movie.runtime ?? '?'} min, directed by ${director}`,
` Cast: ${cast || 'none listed'}`,
` Ratings: ${scores || 'none known'}`,
].join('\n');
}
const ids = process.argv.slice(2);
if (ids.length === 0) {
console.error('Usage: node cards.mjs 550 603 27205');
process.exit(1);
}
const cache = await loadCache();
let failed = false;
for (const id of ids) {
try {
const params = { language: 'en-US', append_to_response: 'credits,ratings' };
console.log(formatCard(await cachedGet(cache, `/movie/${id}`, params, DETAILS_TTL_MS)));
} catch (err) {
console.error(`id ${id}: ${err.message}`);
failed = true;
if (err.status === 401) break;
}
}
await writeFile(CACHE_FILE, JSON.stringify(Object.fromEntries(cache)));
process.exitCode = failed ? 1 : 0;