标签 NASA APOD 下的文章

[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 小组件,尺寸选 中号 (Medium) 或 大号 (Large)。
在小组件设置里,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();

📌 转载信息
原作者:
user321
转载时间:
2026/1/15 18:24:48

NASA 的天文每日一图(APOD)

好处:

  • 适合庄重的场合,避免二次元壁纸这样容易社死的情况。
  • NASA 的图像可以免费商业使用哦,即使是盈利项目也可以使用。
  • 分辨率足够,方便裁剪,随便一裁就是漂亮的壁纸。
  • 每天都有(有时是 youtube 视频,不能使用),可以用一些自动化程序每日更换。
  • 因为是天文图片,所以很容易找到整体色调都是深色的,特别适合作为终端的背景。
  • 有格调,有故事性。

我服务器很多,有部分是带桌面的,我会用 rdp 远程过去。但是有的时候我会脑子一抽,忘记连上的是哪台了。于是我今天给它们按帐号分别换了 APOD 的壁纸,这样就一目了然。

这是我帮忙维护的公用计算机,供同事和访客临时打印点儿东西什么。我在这个上面部署了一个 powershell 脚本(GitHub 上找的,请自行搜索吧),每天更换壁纸。

顺便,也给 Tabby 换了个背景。这样一个深色背景也不会特别干扰文字显示。


📌 转载信息
原作者:
peiyangium
转载时间:
2026/1/3 12:01:48