每天一张宇宙壁纸:Cloudflare Workers + NASA APOD 实战

每天打开电脑时,与其继续面对那张看腻的系统壁纸,不如让一团星云、一轮日食或者一张深空照片来接班。

这篇文章会做一个很小但完整的服务:Cloudflare Workers 负责对外提供 HTTP 接口,Cron Trigger(定时触发器)每天向 NASA 拉取一次 APOD,Workers KV(边缘键值存储)保存最近一次成功结果。访问 / 可以得到结构化 JSON,访问 /image 会跳转到当天的高清图片;如果当天内容是视频或没有可确认的图片地址,接口会明确返回降级信息,而不是把一个网页地址伪装成图片。

最终数据流如下:

Cron Trigger ──每天触发──> Worker ──HTTPS──> NASA APOD API
│
└──写入──> Workers KV

浏览器 / 壁纸脚本 ──GET / 或 /image──> Worker ──读取──> Workers KV

本文资料与代码按 2026-09-17 的官方状态核对。APOD 正处在一次重要迁移中,因此先把最容易踩坑的地方说清楚。

先说结论:APOD 正在迁移,但旧入口目前仍可用

APOD 是 Astronomy Picture of the Day,即“每日天文图片”。NASA Open APIs 目前仍列出这个入口:

GET https://api.nasa.gov/planetary/apod

不过,NASA 官方 nasa/apod-api 仓库 在 2026-09-10 更新说明:后端已经改为转接新的 WordPress Endpoint(WordPress 接口),仓库计划在 2026-12-01 归档并指向新端点;现阶段调用方不需要立刻修改请求地址。仓库文档已经展示 permalink、post_id、basic_html_url 等新返回字段。

与此同时,本文在 2026-09-17 实际核对 api.nasa.gov/planetary/apod 时,实时响应仍是大家熟悉的结构:包含 date、title、explanation、media_type、url、hdurl 和 service_version,其中 url 仍可能是图片地址。这说明迁移期间“官方仓库描述的新结构”和“线上某次实际响应”可能暂时不同。

因此,稳妥做法不是赌某一套结构永远不变,而是:

  1. 请求地址继续使用当前 NASA Open APIs 公布的 api.nasa.gov/planetary/apod;
  2. 图片优先使用官方定义为高清图片的 hdurl;
  3. 仅当 media_type 为 image,且旧式 url 明显是图片文件时,才把 url 当作图片后备地址;
  4. 将 permalink 或非图片型 url 视为 APOD 详情页;
  5. 遇到 video、iframe 或无法确认的结构时保留元数据并降级,不抓取 HTML 猜图片。

后面的代码已经实现这套防御式解析。将来 NASA 完成切换时,大概率只需调整解析函数,而不必推翻整个 Worker。

1. 成品会提供什么

部署完成后,Worker 有三个行为:

  • GET /:返回最新 APOD JSON,包括标题、日期、说明、署名、媒体类型、图片地址、详情页和抓取时间;
  • GET /image:图片可用时返回 307 跳转,可直接交给壁纸脚本;当天不是图片时返回 409 和详情页地址;
  • Cron Trigger:每天更新一次 KV。NASA 暂时不可用时,写入不会发生,KV 中上一份成功数据仍然保留。

它不会把 NASA 图片复制进 Cloudflare,也不会公开 NASA API Key。Worker 只保存元数据并把客户端重定向到原始图片地址,结构简单,也便于后续改成自己的前端页面。

2. 前置条件

你需要:

  1. 一个 Cloudflare 账号,并已启用 workers.dev 子域名;
  2. 本机安装 Node.js 与 npm;Cloudflare 当前按 Node.js 官方生命周期支持 Current、Active LTS 和 Maintenance LTS 版本,建议直接使用最新 LTS,详见 Wrangler 系统要求;
  3. 一个自己的 NASA API Key;
  4. 能使用终端编辑 TypeScript 文件。

本文使用 ES Modules(ES 模块)形式的 Worker,并选择 wrangler.jsonc。Cloudflare 当前明确推荐新项目使用 wrangler.jsonc,而不是旧教程里常见的 wrangler.toml;JSONC 是允许注释的 JSON。

3. 申请 NASA API Key

打开 NASA API Key 注册页,填写姓名和邮箱后获取密钥。测试文档时可以使用 DEMO_KEY,正式部署不要这样做。

NASA 的官方认证说明给出的默认限制是:普通 API Key 每小时 1,000 次,请求量在所有 api.nasa.gov API 之间合并计算;DEMO_KEY 则是每个 IP 每小时 30 次、每天 50 次。响应头中的 X-RateLimit-Limit 和 X-RateLimit-Remaining 可用于观察当前额度。

我们的设计通常每天只由 Cron 请求一次 NASA,KV 首次为空时才会由 HTTP 请求补拉一次,因此不会让每一位访客都消耗 NASA 额度。

4. 初始化 Worker 项目

使用 Cloudflare 官方的 C3(create-cloudflare 命令行脚手架)创建项目:

npm create cloudflare@latest -- nasa-apod-wallpaper
cd nasa-apod-wallpaper

交互选项选择:

  1. Hello World example;
  2. Worker only;
  3. TypeScript;
  4. 是否初始化 Git 按个人需要选择;
  5. 暂时不要部署。

完成后主要会用到 src/index.ts、wrangler.jsonc 和 package.json。

5. 配置 wrangler.jsonc

将 wrangler.jsonc 改为:

{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "nasa-apod-wallpaper",
"main": "src/index.ts",
"compatibility_date": "2026-09-17",
"workers_dev": true,
"kv_namespaces": [
{
"binding": "APOD_CACHE"
}
],
"secrets": {
"required": ["NASA_API_KEY"]
},
"triggers": {
"crons": ["5 13 * * *"]
},
"observability": {
"enabled": true
}
}

这里没有漏写 KV 的 id。Wrangler 当前支持自动配置资源:在绑定中只声明 APOD_CACHE,首次通过 Wrangler 部署时会自动创建 KV namespace(KV 命名空间),并把真实资源 ID 回写到配置文件。这个能力目前标记为 Beta,所以请把部署后回写的 wrangler.jsonc 一并保存。

如果你的账号或 Wrangler 版本没有启用自动配置,按 Workers KV 官方入门执行 npx wrangler kv namespace create APOD_CACHE,再把命令实际返回的 ID 加到这个绑定中,不要照抄别人的 ID。

Cron 的 UTC 时区

Cloudflare Cron Triggers 的表达式固定使用 UTC:

5 13 * * *

表示每天 13:05 UTC 运行,对应中国标准时间(UTC+8)的 21:05。它不是“服务器本地时间”,也不会自动按你的浏览器时区换算。如果想在中国标准时间早上 08:30 更新,应写成 30 0 * * *。

APOD 的实际上线时间由 NASA 决定,本文不假设它一定在某个整点发布。这里选择晚间更新只是一个实用默认值;如果你观察到更新偏晚,可以调整 Cron,但不要高频轮询。

6. 配置 Secrets

Secret(密钥绑定)是 Cloudflare 为 API Key 等敏感值提供的加密配置。不要把 NASA Key 写进 wrangler.jsonc 的 vars,也不要提交到 Git。

在项目根目录创建 .dev.vars,仅写入 NASA_API_KEY 这一项,并把等号右侧填写为你从 NASA 邮件复制的真实密钥。它使用 dotenv 格式。确认 .gitignore 包含:

.dev.vars*
.env*

.dev.vars 只供本地开发使用。Cloudflare 的Secrets 官方文档也明确要求不要提交 .dev.vars 或 .env。

第一次部署时,我们会用 --secrets-file .dev.vars 同时上传代码和 Secret。以后需要轮换密钥,可以运行:

npx wrangler secret put NASA_API_KEY

命令会交互式读取真实密钥,不会把值留在 shell 历史中。当前 Wrangler 执行 secret put 会创建并立即部署一个新 Worker 版本,操作前要知道它不是纯粹的本地配置命令。

7. 完整 Worker TypeScript 代码

用下面内容替换 src/index.ts:

interface Env {
NASA_API_KEY: string;
APOD_CACHE: KVNamespace;
}

interface ApodSnapshot {
source: "NASA APOD";
source_date: string;
title: string;
explanation: string;
media_type: string;
image_url: string | null;
page_url: string;
credit: string | null;
fetched_at: string;
}

const NASA_APOD_ENDPOINT = "https://api.nasa.gov/planetary/apod";
const KV_KEY = "apod:latest";

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function readString(
value: unknown,
options: { allowEmpty?: boolean } = {},
): string | null {
if (typeof value !== "string") return null;
const result = value.trim();
if (!options.allowEmpty && result.length === 0) return null;
return result;
}

function readHttpsUrl(value: unknown): string | null {
const candidate = readString(value);
if (!candidate) return null;

try {
const url = new URL(candidate);
return url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}

function looksLikeImage(url: string): boolean {
return /\.(?:avif|gif|jpe?g|png|webp|tiff?)(?:$|[?#])/i.test(url);
}

function legacyPageForDate(sourceDate: string): string {
const [year, month, day] = sourceDate.split("-");
return `https://apod.nasa.gov/apod/ap${year.slice(2)}${month}${day}.html`;
}

function normalizeApod(payload: unknown): ApodSnapshot {
if (!isRecord(payload)) {
throw new Error("NASA APOD returned a non-object payload");
}

const sourceDate = readString(payload.date);
const title = readString(payload.title);
if (!sourceDate || !/^\d{4}-\d{2}-\d{2}$/.test(sourceDate) || !title) {
throw new Error("NASA APOD response is missing date or title");
}

const mediaType = readString(payload.media_type) ?? "unknown";
const hdUrl = readHttpsUrl(payload.hdurl);
const rawUrl = readHttpsUrl(payload.url);
const permalink = readHttpsUrl(payload.permalink);

// 新旧返回结构都把 hdurl 当作图片优先来源。
// 旧结构中的 url 只有在明确像图片文件时才作为后备,
// 防止把迁移后的 APOD 文章页面误当成图片。
const imageUrl =
mediaType === "image"
? hdUrl ?? (rawUrl && looksLikeImage(rawUrl) ? rawUrl : null)
: null;

const pageUrl =
permalink ??
(rawUrl && rawUrl !== imageUrl ? rawUrl : null) ??
legacyPageForDate(sourceDate);

return {
source: "NASA APOD",
source_date: sourceDate,
title,
explanation: readString(payload.explanation, { allowEmpty: true }) ?? "",
media_type: mediaType,
image_url: imageUrl,
page_url: pageUrl,
credit: readString(payload.credit) ?? readString(payload.copyright),
fetched_at: new Date().toISOString(),
};
}

async function fetchAndStoreApod(env: Env): Promise<ApodSnapshot> {
const apiUrl = new URL(NASA_APOD_ENDPOINT);
apiUrl.searchParams.set("api_key", env.NASA_API_KEY);

const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
"User-Agent": "nasa-apod-wallpaper-worker/1.0",
},
});

if (!response.ok) {
throw new Error(`NASA APOD request failed with HTTP ${response.status}`);
}

const payload: unknown = await response.json();
const snapshot = normalizeApod(payload);

// 不设置 expiration:上游失败时保留上一份成功数据。
await env.APOD_CACHE.put(KV_KEY, JSON.stringify(snapshot));
return snapshot;
}

async function getLatestApod(env: Env): Promise<ApodSnapshot> {
const cached = await env.APOD_CACHE.get<ApodSnapshot>(KV_KEY, "json");
return cached ?? fetchAndStoreApod(env);
}

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=300, s-maxage=3600",
"Content-Type": "application/json; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
});
}

export default {
async fetch(request, env): Promise<Response> {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "86400",
},
});
}

if (request.method !== "GET") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET, OPTIONS" },
});
}

const url = new URL(request.url);

try {
if (url.pathname === "/" || url.pathname === "/apod.json") {
return jsonResponse(await getLatestApod(env));
}

if (url.pathname === "/image") {
const apod = await getLatestApod(env);
if (apod.image_url) {
return Response.redirect(apod.image_url, 307);
}

return jsonResponse(
{
error: "Today's APOD is not a directly usable image",
media_type: apod.media_type,
page_url: apod.page_url,
},
409,
);
}

return jsonResponse({ error: "Not Found" }, 404);
} catch (error) {
console.error("APOD request failed", error);
return new Response(
JSON.stringify({ error: "Unable to load APOD right now" }),
{
status: 502,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
},
},
);
}
},

async scheduled(_controller, env): Promise<void> {
const snapshot = await fetchAndStoreApod(env);
console.log("APOD cache refreshed", {
source_date: snapshot.source_date,
media_type: snapshot.media_type,
has_image: snapshot.image_url !== null,
});
},
} satisfies ExportedHandler<Env>;

这段代码同时导出了 fetch() 和 scheduled():前者处理 HTTP,后者由 Cron 调用。两条路径复用同一个 fetchAndStoreApod(),避免“手动访问能用、定时任务却走另一套逻辑”。

还有三个值得保留的细节:

  • NASA API Key 只用于 Worker 到 NASA 的请求,不出现在响应和日志中;
  • NASA 返回非 2xx 或结构缺少关键字段时先抛错,不用坏数据覆盖 KV;
  • explanation 和 credit 在迁移后的结构中可能含 HTML。本文只把它们作为 JSON 字符串返回;如果做网页,请先清洗,或只用 textContent 渲染,不能直接塞进 innerHTML。

8. 为什么使用 KV,而不是只用 Cache API

Cloudflare Cache API(缓存接口)看起来也能 put() 和 match(),但它的缓存内容不会在各数据中心之间复制。Cron 在某个数据中心写入 caches.default,并不能保证另一地区的访客命中同一份缓存。因此“定时任务预热一次,全球都能读到”是错误假设。

Workers KV 更适合这个场景:一天写一两次、读取很多次。它有中心存储并在各地缓存。不过,KV 是 eventual consistency(最终一致性),官方说明其他地区可能需要 60 秒或更久才能看到新值,负查询也会被缓存。详见 KV 工作原理。壁纸晚一分钟更新通常可以接受;若业务要求严格的读写一致性,应改用 Durable Objects,而不是假装 KV 是强一致数据库。

HTTP 响应里的 Cache-Control 则是另一层:它允许浏览器或按你规则配置的 CDN 缓存 JSON,减少重复读取。它不等于 KV,也不能保证 workers.dev 上的 Worker 永远不会执行。若以后给自定义域名配置 Cache Rule,还要确保缓存键不包含任何 Secret,并接受最多一小时的响应陈旧窗口。

9. 本地测试

先生成与当前配置、兼容日期匹配的类型:

npx wrangler types

再启动本地开发服务器:

npx wrangler dev

Wrangler 会加载 .dev.vars,并在本地模拟 KV。Cloudflare 的本地开发说明指出,本地绑定默认与生产资源隔离,因此第一次访问会发现本地 KV 为空,随后请求 NASA 并写入本地数据,这是正常现象。

另开一个终端依次检查:

curl --fail --silent --show-error localhost:8787/
curl --silent --show-error --dump-header - --output /dev/null localhost:8787/image
curl --fail --silent --show-error \
"localhost:8787/cdn-cgi/local/scheduled?format=json"

预期行为不是某个固定标题,而是:

  1. / 返回合法 JSON,且 source_date、title、media_type、fetched_at 存在;
  2. 图片日访问 /image 得到 307 和 Location;非图片日得到 409 JSON;
  3. 本地 scheduled 测试返回的 outcome 为 ok,终端出现刷新日志。

不要把某一天的标题或图片 URL 写进自动化断言,因为 APOD 本来就会变化。若只想检查 TypeScript,可使用项目生成的类型检查脚本;若脚手架没有提供,再运行:

npx tsc --noEmit

10. 部署与验收

第一次部署时,让 Wrangler 从 .dev.vars 同时上传 Secret:

npx wrangler deploy --secrets-file .dev.vars

Wrangler 会登录 Cloudflare、部署 Worker、自动配置 APOD_CACHE,并输出真实的 HTTPS 访问地址。自动配置成功后,检查 wrangler.jsonc,其中应已写入 KV 的真实 id。以后的普通代码部署运行:

npx wrangler deploy

已经配置的 Secret 会保留,不需要每次重新输入。

把 Wrangler 输出的真实地址保存到当前 shell 的 WORKER_URL 变量后验收:

curl --fail --silent --show-error "$WORKER_URL/"
curl --silent --show-error --dump-header - --output /dev/null \
"$WORKER_URL/image"
npx wrangler kv key get --binding=APOD_CACHE \
"apod:latest" --remote --text

检查 JSON 中的 source_date 与 fetched_at,不要只看 HTTP 200。/image 在图片日应跳转到 HTTPS 图片地址;非图片日的 409 是设计行为,不是故障。

Cron 配置变更在 Cloudflare 全球网络传播可能需要数分钟,官方文档给出的上限是 15 分钟。可在 Cloudflare Dashboard 的 Worker 页面进入 Settings → Trigger Events → View events 查看最近的 Cron 调用,也可以运行:

npx wrangler tail

等待下一次计划触发时观察 APOD cache refreshed 日志。不要为了“验收”把生产 Cron 改成每分钟运行后忘记改回来。

11. 常见错误与排查

NASA 返回 401、403 或 Worker 返回 502

先确认 .dev.vars 和生产 Secret 的变量名都精确写成 NASA_API_KEY。如果刚轮换密钥,重新执行 npx wrangler secret put NASA_API_KEY。不要把 Key 放在公开 URL、前端 JavaScript 或 Git 仓库里。

NASA 返回 429

这是限流。停止高频刷新,检查 X-RateLimit-Remaining,并确认没有在生产使用共享的 DEMO_KEY。本文的 / 会优先读 KV;如果你自行删除 KV 读取逻辑,就会让每位访客都直连 NASA。

/image 返回 409

先看响应里的 media_type 和 page_url。APOD 不保证每天都是静态图片,也可能是视频或 iframe;迁移中的新结构也可能只提供文章页。正确做法是打开详情页,而不是把页面 HTML 当图片下载。

本地能读,线上 KV 却是空的

wrangler dev 默认使用本地模拟 KV,生产 KV 是另一份数据。查看线上数据时给 Wrangler 命令加 --remote。反过来,本地开发不要随意启用远程绑定,否则测试写入会改动生产数据。

Cron 没有按北京时间执行

Cron 永远按 UTC。把中国标准时间减去 8 小时再写表达式,并等待最长 15 分钟的配置传播时间。Dashboard 手动创建和 wrangler.jsonc 同时管理也容易互相覆盖;使用 Wrangler 的项目应以配置文件为唯一来源。

更新后短时间仍读到旧数据

先看 fetched_at。KV 在全球是最终一致的,新值传播可能超过 60 秒;浏览器还可能在 max-age=300 内复用响应。这类短暂陈旧是当前缓存策略的预期代价。

NASA 改了字段,日志出现缺少 date 或 title

先查看 NASA 官方 APOD API 仓库的最新迁移说明和实际响应,再只修改 normalizeApod()。不要直接删除校验让未知结构进入 KV,也不要依赖非官方代理来“修复”字段。

12. 成本与免费额度边界

截至 2026-09-17,Cloudflare 官方公布的 Workers 限制中,Free 计划包括每天 100,000 次请求、每次 HTTP/Cron 调用 10 ms CPU 时间,以及每个账号 5 个 Cron Triggers。等待 NASA 网络响应的时间不计入 CPU 时间,但 JSON 解析和代码执行会计入。

Workers KV 免费额度包括每天 100,000 次读取、1,000 次写入以及账号总计 1 GB 存储;同一个 key 的写入上限为每秒一次。本文默认每天约一次 NASA 请求、一次 KV 写入,每个未被客户端缓存的 HTTP 请求约一次 KV 读取,个人使用通常很轻。

不过,“通常在免费额度内”不是无限免费承诺:

  • Worker 的公开 URL 被大量访问时,请求和 KV 读取都会增长;
  • Cache 命中也可能计入 Workers 请求数,具体以 Workers Pricing和账号仪表盘为准;
  • Cloudflare 与 NASA 都可能调整限制;
  • 自定义域名、日志、其他 Worker 和 KV 项目会共享账号级额度。

当前 Workers Paid 计划的账号最低费用为每月 5 美元,超出包含量后再按请求和 CPU 用量计费;这个个人项目没有必要为了“更稳”而预先升级,先根据真实流量观察即可。

上线后至少偶尔查看 Workers Analytics、KV 用量和 NASA 限流响应头。对个人壁纸服务来说,控制访问范围比盲目增加缓存层更有效。

13. 图片版权、署名与“能看不等于能随便用”

APOD 是精选栏目,不是“全部图片都归 NASA 所有”的版权池。APOD 官方版权说明明确指出:每张图片都会标注所有者或机构;部分图片受版权保护,公开或商业使用时需要联系权利人,NASA 和 APOD 都不能替第三方授权。

NASA 自有内容通常可用于教育或信息用途,但仍应注明 NASA 为来源,不能暗示 NASA 为产品背书;含 NASA 标识、可识别人物或第三方素材时还有额外限制,详见 NASA Images and Media Usage Guidelines。

实际使用时建议:

  1. 壁纸客户端同时保留 credit、page_url 和 source_date;
  2. 在公开网页展示图片时,把署名放在图片附近并链接回 APOD 详情页;
  3. credit 或 copyright 缺失不代表自动进入公有领域;
  4. 商业展示、再分发或印刷前,逐张确认 APOD 页面上的权利人信息;
  5. 不要移除图片中已有的水印和署名。

这也是代码没有代理、裁切或永久复制图片的原因。技术上能下载,不等于法律上拥有再次传播的权利。

14. 还能怎样扩展

基础版本跑稳后,可以继续做三件事:

  • 增加一个静态前端,以 textContent 安全展示标题与说明,并在图片旁显示署名;
  • 以日期作为额外 KV key 保存有限历史,但要设置保留策略并计算写入、读取与存储额度;
  • 给自己的壁纸脚本增加“非图片日不更换”和“下载失败保留旧壁纸”逻辑。

不建议马上做的事情是高频轮询、批量镜像全部历史图片,或者解析 APOD 页面 HTML 猜测资源地址。这个小服务的价值恰恰在于边界清楚:NASA 提供内容,Worker 负责定时获取与稳定分发,KV 保存最近一次成功状态,异常时宁可保留昨夜星空,也不制造一张坏壁纸。

参考资料