写了一个 iOS 小组件:NASA 每日天文一图
[Scriptable] NASA 每日天文图 (APOD) iOS 小组件
一个在 iPhone 桌面上看 NASA 的每日天文一图 (APOD)。
核心亮点
零门槛 (Zero Config):不需要申请 NASA API Key。直接抓取 NASA 官网数据,省去注册麻烦,也不用担心 Key 额度超限。
自动取色 (Dynamic Color):脚本会自动提取当日图片的主色调,生成磨砂质感的渐变背景蒙版。
哪怕图片是黑白的,组件也不会单调。
超强抗网络波动:
智能选图:自动识别官网主图,排除 Logo 和图标。
多重兜底:直连 NASA 失败时,自动切换到 weserv 图片代理,大幅提升国内加载成功率。
缓存优先:断网或请求失败时,自动展示上一次缓存的图片,绝不开天窗(不黑屏)。
UI 细节:
底部半透明信息卡片,模拟 iOS 原生组件质感。
顶部自动根据主色调生成 “APOD” 胶囊标签。
遇到 “视频日”(当天是视频没图片),会自动显示提示,并保持美观的背景。
预览图
使用方法
App Store 下载 Scriptable。
打开 App,点击右上角 + 号,新建脚本。
将下方代码完整复制粘贴进去,命名为 NASA APOD。
回到桌面,添加 Scriptable 小组件,尺寸选 中号 (
在小组件设置里,Script 选择刚才保存的脚本即可。
脚本代码
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-blue; icon-glyph: star;
/*
* APOD 零 Key 美化稳定版(不显示在线状态)
* - 数据源:https://apod.nasa.gov/apod/astropix.html
* - 主图优先:href="image/..." -> img src="image/..."
* - 图片兜底:直连失败 -> weserv 代理
* - 防黑缓存:缓存过小图会自动丢弃
*/
const LOCALE = "zh-CN";
const HTML_TIMEOUT_SEC = 25;
const IMAGE_TIMEOUT_SEC = 45;
const PREVIEW_SIZE = "medium "; // small | medium | large
// 如果你之前已经黑了很多次,强烈建议把它改成 true 跑一次,再改回 false
const RESET_CACHE_ONCE = false;
const fm = FileManager.local();
const cacheDir = fm.joinPath(fm.documentsDirectory(), "apod_nokey_widget_cache");
const cacheJsonPath = fm.joinPath(cacheDir, "apod.json");
const cacheImgPath = fm.joinPath(cacheDir, "apod.jpg");
function ensureCacheDir() {
if (!fm.fileExists(cacheDir)) fm.createDirectory(cacheDir);
}
function resetCacheIfNeeded() {
if (!RESET_CACHE_ONCE) return;
try { if (fm.fileExists(cacheJsonPath)) fm.remove(cacheJsonPath); } catch {}
try { if (fm.fileExists(cacheImgPath)) fm.remove(cacheImgPath); } catch {}
}
async function requestText(url, timeoutSec) {
const req = new Request(url);
req.timeoutInterval = timeoutSec;
const text = await req.loadString();
return { text, statusCode: req.response?.statusCode };
}
async function requestImage(url, timeoutSec) {
const req = new Request(url);
req.timeoutInterval = timeoutSec;
const img = await req.loadImage();
return img;
}
function readCache() {
try {
if (!fm.fileExists(cacheJsonPath)) return null;
const wrapper = JSON.parse(fm.readString(cacheJsonPath));
const img = fm.fileExists(cacheImgPath) ? fm.readImage(cacheImgPath) : null;
return { data: wrapper.data, img };
} catch {
return null;
}
}
function writeCache(data, img) {
ensureCacheDir();
fm.writeString(cacheJsonPath, JSON.stringify({ savedAt: Date.now(), data }));
if (img) fm.writeImage(cacheImgPath, img);
}
function stripHtml(s) {
return String(s || "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
}
function absApodUrl(pathOrUrl) {
if (!pathOrUrl) return null;
const base = "https://apod.nasa.gov/apod/";
const raw = String(pathOrUrl).trim();
if (/^https?:\/\//i.test(raw)) {
// 强制 apod 走 https
return raw.replace(/^http:\/\/apod\.nasa\.gov\//i, "https://apod.nasa.gov/");
}
return (base + raw.replace(/^\//, "")).replace(/^http:\/\/apod\.nasa\.gov\//i, "https://apod.nasa.gov/");
}
function pickMainImageUrl(html) {
// 1) 最稳:主图通常包在 <a href="image/...jpg"><img ...></a>
const href = html.match(/<a[^>]+href="(image\/[^"]+\.(?:jpg|jpeg|png|webp)(?:\?[^"]*)?)"/i);
if (href?.[1]) return absApodUrl(href[1]);
// 2) 次稳:直接 img src="image/..."
const src = html.match(/<img[^>]+src="(image\/[^"]+\.(?:jpg|jpeg|png|webp)(?:\?[^"]*)?)"/i);
if (src?.[1]) return absApodUrl(src[1]);
// 3) 兜底:找任意 image/xxx.jpg
const any = html.match(/(image\/[^\s"'<>]+\.(?:jpg|jpeg|png|webp))/i);
if (any?.[1]) return absApodUrl(any[1]);
return null;
}
function parseApodHtml(html) {
const bolds = [...html.matchAll(/<b>([\s\S]*?)<\/b>/gi)].map((m) => stripHtml(m[1]));
const title =
bolds.find(
(t) =>
t &&
!/Astronomy Picture of the Day /i.test(t) &&
!/APOD/i.test(t) &&
t.length >= 3 &&
t.length <= 90
) || "NASA APOD";
const dateMatch = html.match(/(\d{4}\s+[A-Za-z]+\s+\d{1,2})/);
const dateText = dateMatch ? dateMatch[1] : "";
let credit = "";
const creditMatch = html.match(/Image Credit[^<]*<\/b>\s*([\s\S]*?)<\/center>/i);
if (creditMatch?.[1]) credit = stripHtml(creditMatch[1]).slice(0, 120);
const isVideo = /<iframe\b/i.test(html) || /youtube\.com|youtu\.be|vimeo\.com/i.test(html);
const imageUrl = pickMainImageUrl(html);
return { title, dateText, credit, imageUrl, isVideo };
}
function parseToLocalDateString(maybeEnglishDate) {
if (!maybeEnglishDate) return "";
try {
const d = new Date(maybeEnglishDate);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(LOCALE, { year: "numeric", month: "long", day : "numeric" });
}
} catch {}
return String(maybeEnglishDate);
}
function weservProxy(url) {
const stripped = String(url).replace(/^https?:\/\//, "");
return `https://images.weserv.nl/?url=${encodeURIComponent(stripped)}`;
}
function isImageUsable(img) {
if (!img) return false;
// Scriptable 的 Image 通常有 size
const w = img.size?.width || 0;
const h = img.size?.height || 0;
// 主图不可能这么小;避免 logo/透明小图导致“黑”
return w >= 300 && h >= 300;
}
async function downloadMainImage(imageUrl) {
const direct = imageUrl;
const proxy = weservProxy(imageUrl);
try {
const img = await requestImage(direct, IMAGE_TIMEOUT_SEC);
if (isImageUsable(img)) return img;
} catch {}
const img2 = await requestImage(proxy, IMAGE_TIMEOUT_SEC);
if (!isImageUsable(img2)) throw new Error("下载到的图片过小(可能不是主图)");
return img2;
}
function makeOverlayGradient() {
// 不要太黑:让背景图清晰可见,同时保证底部文字清楚
const g = new LinearGradient();
g.locations = [0, 0.55, 1];
g.colors = [
new Color("#000000", 0.16),
new Color("#000000", 0.06),
new Color("#000000", 0.46),
];
return g;
}
function makeFallbackGradient() {
const g = new LinearGradient();
g.locations = [0, 1];
g.colors = [new Color("#0b1220"), new Color("#111827")];
return g;
}
function addBadge(container) {
const pill = container.addStack();
pill.setPadding(6, 10, 6, 10);
pill.cornerRadius = 999;
pill.backgroundColor = new Color("#0b1020", 0.30);
const t = pill.addText("APOD");
t.textColor = new Color("#ffffff", 0.92);
t.font = Font.semiboldSystemFont(10);
}
function addInfoCard(container, data, family) {
const card = container.addStack();
card.layoutVertically();
card.setPadding(12, 12, 12, 12);
card.cornerRadius = 16;
card.backgroundColor = new Color("#0b1020", 0.36);
const titleFont = family === "small" ? 15 : 17;
const dateFont = family === "small" ? 11 : 12;
const title = card.addText(data?.title || "NASA APOD");
title.textColor = Color.white();
title.font = Font.boldSystemFont(titleFont);
title.lineLimit = 2;
card.addSpacer(6);
const dateLine = parseToLocalDateString(data?.dateText || "");
if (dateLine) {
const d = card.addText(dateLine);
d.textColor = new Color("#e5e7eb", 0.92);
d.font = Font.systemFont(dateFont);
d.lineLimit = 1;
}
if (data?.credit) {
const c = card.addText(`© ${data.credit}`);
c.textColor = new Color("#cbd5e1", 0.85);
c.font = Font.systemFont(10);
c.lineLimit = 1;
}
if (!data?.imageUrl && data?.isVideo) {
card.addSpacer(6);
const v = card.addText("今日为视频,官网无可用图片预览");
v.textColor = new Color("#cbd5e1", 0.9);
v.font = Font.systemFont(10);
v.lineLimit = 2;
}
}
async function createWidget() {
resetCacheIfNeeded();
const cache = readCache();
let data = cache?.data || null;
let bgImg = isImageUsable(cache?.img) ? cache.img : null;
// 在线抓取
try {
const url = "https://apod.nasa.gov/apod/astropix.html";
const { text, statusCode } = await requestText(url, HTML_TIMEOUT_SEC);
if (statusCode && statusCode >= 400) throw new Error(`HTTP ${statusCode}`);
const parsed = parseApodHtml(text);
data = parsed;
if (parsed.imageUrl) {
const img = await downloadMainImage(parsed.imageUrl);
bgImg = img;
writeCache(parsed, img);
} else {
// 只缓存文字,不覆盖旧图
ensureCacheDir();
fm.writeString(cacheJsonPath, JSON.stringify({ savedAt: Date.now(), data: parsed }));
}
} catch (e) {
// 在线失败:无缓存才显示错误页
if (!data && !bgImg) {
const w = new ListWidget();
w.setPadding(16, 16, 16, 16);
w.backgroundGradient = makeFallbackGradient();
const t = w.addText("APOD 暂不可用");
t.textColor = Color.white();
t.font = Font.boldSystemFont(16);
w.addSpacer(8);
const m = w.addText(String(e.message).slice(0, 180));
m.textColor = new Color("#ffcccc");
m.font = Font.systemFont(11);
m.lineLimit = 4;
w.url = "https://apod.nasa.gov/apod/astropix.html";
return w;
}
}
const w = new ListWidget();
w.setPadding(0, 0, 0, 0);
w.refreshAfterDate = new Date(Date.now() + 60 * 60 * 1000);
w.url = "https://apod.nasa.gov/apod/astropix.html";
if (bgImg) {
w.backgroundImage = bgImg;
w.backgroundGradient = makeOverlayGradient();
} else {
w.backgroundGradient = makeFallbackGradient();
}
const family = config.widgetFamily || "medium ";
const content = w.addStack();
content.layoutVertically();
content.setPadding(14, 14, 14, 14);
addBadge(content);
content.addSpacer();
addInfoCard(content, data, family);
return w;
}
// 运行
const widget = await createWidget();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
if (PREVIEW_SIZE === "small") widget.presentSmall();
else if (PREVIEW_SIZE === "large") widget.presentLarge();
else widget.presentMedium();
}
Script.complete();




