NASAの今日の一枚(APOD)
設定難易度

NASAが毎日公開している「Astronomy Picture of the Day(APOD)」を、iPhone・iPadのホーム画面に表示できるウィジェットです。

NASAのAPODには、銀河や星雲、惑星、宇宙探査など、毎日さまざまな一枚が公開されています。

せっかく毎日違う写真が公開されているなら、わざわざサイトを見に行くのではなく、ホーム画面に今日の一枚が自動で表示されたら面白いんじゃないかと思って作りました。

その日に選ばれた一枚を背景いっぱいに表示し、画像の上にタイトルと日付を表示。大サイズでは、その写真についての解説も一緒に確認できます。

写真だけでなく、宇宙探査や観測機器などが選ばれる日もあるので、「今日は何が出るかな?」と毎日ちょっと楽しめるウィジェットです。

ウィジェットの表示内容

NASAのAPODを取得します。

サイズごとの表示

  • 小・中:APODの画像・タイトル・日付を、サイズに合わせた配置で表示します。
  • 大:画像に加えて解説やクレジットなども確認できます。

設定手順

上から順に進めてください。コードの保存と設定のコピーに必要な操作欄は、それぞれの手順の中にあります。

  1. Scriptableを用意する

    App StoreでScriptableを開き、iPhoneにインストールします。すでに入っている場合は次へ進んでください。

  2. コードをコピーして保存する

    下のコード欄の「コピー」を押し、Scriptableへ移動します。「+」から新しいスクリプトを作り、コード全文を貼り付けて「NASAの今日の一枚(APOD)」などの名前で保存してください。

    保存できたら、このページに戻って次の「ホーム画面に追加して設定する」へ進みます。

    // Variables used by Scriptable.
    // These must be at the very top of the file. Do not edit.
    // icon-color: deep-blue; icon-glyph: rocket;
    // Version: 1.1.0
    
    // =====================================================
    // NASA 今日の一枚 (APOD) ウィジェット for Scriptable
    // - 画像を背景に、下部にタイトルを重ねて表示します
    // - 動画の日はサムネイルを表示します
    // - 取得結果をキャッシュし、通信失敗時は前回の画像を表示します
    // - タップすると APOD のページを開きます
    // =====================================================
    
    // NASA のAPIキー。DEMO_KEY は制限が厳しい(1時間30回・1日50回/IP)ので、
    // https://api.nasa.gov/ で無料キーを取得して差し替えるのがおすすめです。
    const API_KEY = "DEMO_KEY";
    
    // ウィジェットの更新間隔(時間)。APODは1日1回の更新なので短くする必要はありません
    const REFRESH_HOURS = 2;
    
    // ウィジェットのおおよその縦横比(端末により多少異なります)
    const ASPECT = { small: 1.0, medium: 2.14, large: 0.955 };
    const BASE_W = { small: 800, medium: 1000, large: 800 };
    
    // ---------- キャッシュ ----------
    const fm = FileManager.local();
    const CACHE_DIR = fm.documentsDirectory();
    const META_PATH = fm.joinPath(CACHE_DIR, "apod_meta.json");
    const IMG_PATH = fm.joinPath(CACHE_DIR, "apod_image.jpg");
    
    function readCache() {
      try {
        if (!fm.fileExists(META_PATH)) return null;
        const meta = JSON.parse(fm.readString(META_PATH));
        const image = meta.hasImage && fm.fileExists(IMG_PATH) ? Image.fromFile(IMG_PATH) : null;
        return { meta, image };
      } catch (e) {
        return null;
      }
    }
    
    function writeCache(meta, image) {
      try {
        fm.writeString(META_PATH, JSON.stringify({ ...meta, hasImage: !!image }));
        if (image) fm.writeImage(IMG_PATH, image);
      } catch (e) {
        console.warn("キャッシュの保存に失敗: " + e);
      }
    }
    
    // ---------- データ取得 ----------
    // NASA Science の現行データを優先。従来APIは予備として使用します。
    function plainText(value) {
      return String(value || "")
        .replace(/<br\s*\/?\s*>/gi, " ")
        .replace(/<[^>]*>/g, "")
        .replace(/&#(x[0-9a-f]+|[0-9]+);/gi, (_, n) => {
          const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : parseInt(n, 10);
          return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "";
        })
        .replace(/&quot;/g, '"').replace(/&apos;/g, "'")
        .replace(/&lt;/g, "<").replace(/&gt;/g, ">")
        .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
        .replace(/\s+/g, " ").trim();
    }
    
    async function requestJSON(url, name) {
      const req = new Request(url);
      req.timeoutInterval = 12;
      const body = await req.loadString();
      const status = req.response && req.response.statusCode;
      if (status && (status < 200 || status >= 300)) {
        throw new Error(name + ": HTTP " + status);
      }
      let data;
      try { data = JSON.parse(body); }
      catch (_) { throw new Error(name + ": JSON以外の応答"); }
      return data;
    }
    
    function normalizeScience(data) {
      if (!data || !/^\d{4}-\d{2}-\d{2}$/.test(data.date || "")) {
        throw new Error("NASA Science: 日付のない応答");
      }
      let imageUrl = null;
      if (data.media_type === "image") {
        // この取得先の url は画像ではなく記事ページです。
        imageUrl = data.hdurl;
        if (!imageUrl) throw new Error("NASA Science: 画像URLがありません");
        // NASAの画像配信サービスに縮小を依頼し、巨大な原寸画像を避けます。
        if (imageUrl.startsWith("https://assets.science.nasa.gov/dynamicimage/")) {
          imageUrl = imageUrl.split("?")[0] + "?w=1200&h=0&fit=clip";
        }
      } else {
        imageUrl = data.thumbnail_url || null;
      }
      return {
        date: data.date,
        title: plainText(data.title),
        explanation: plainText(data.explanation),
        copyright: plainText(data.copyright || data.credit),
        media_type: data.media_type,
        url: data.url,
        imageUrl,
        permalink: data.permalink || data.url,
        source: "NASA Science"
      };
    }
    
    async function fetchApod() {
      let scienceError;
      try {
        const response = await requestJSON(
          "https://science.nasa.gov/wp-json/wp/v2/apod-basic?per_page=1",
          "NASA Science"
        );
        const data = Array.isArray(response) ? response[0] : response;
        return normalizeScience(data);
      } catch (e) {
        scienceError = String(e);
        console.warn(scienceError);
      }
      try {
        const data = await requestJSON(
          "https://api.nasa.gov/planetary/apod?api_key=" + encodeURIComponent(API_KEY) + "&thumbs=true",
          "APOD API"
        );
        if (!data || !data.date) {
          throw new Error("APOD API: " + (data && (data.msg || (data.error && data.error.message)) || "不明な応答"));
        }
        return data;
      } catch (e) {
        throw new Error(scienceError + " / " + String(e));
      }
    }
    
    // 画像の日は url(標準サイズ)、動画の日はサムネイルを使う
    // ※ hdurl は巨大でウィジェットのメモリ制限に引っかかりやすいので使わない
    function pickImageUrl(data) {
      if (data.imageUrl) return data.imageUrl;
      if (data.media_type === "image") return data.url;
      return data.thumbnail_url || null;
    }
    
    async function loadApod() {
      const cached = readCache();
      try {
        const data = await fetchApod();
        // 同じ日付ならキャッシュ済みの画像を再利用(通信量とAPI回数の節約)
        if (cached && cached.meta.date === data.date && cached.meta.cachedImageUrl === pickImageUrl(data) && cached.image) {
          return { meta: data, image: cached.image, offline: false };
        }
        let image = null;
        const imgUrl = pickImageUrl(data);
        if (imgUrl) {
          const req = new Request(imgUrl);
          req.timeoutInterval = 30;
          image = await req.loadImage();
        }
        data.cachedImageUrl = imgUrl;
        writeCache(data, image);
        return { meta: data, image, offline: false };
      } catch (e) {
        console.warn("取得に失敗: " + e);
        if (cached) return { meta: cached.meta, image: cached.image, offline: true, error: String(e) };
        throw e;
      }
    }
    
    // ---------- 背景画像の合成 ----------
    // ウィジェットの縦横比に合わせて中央でトリミングし、下部に黒のグラデーションを重ねる
    function composeBackground(image, family) {
      const W = BASE_W[family];
      const H = Math.round(W / ASPECT[family]);
    
      const ctx = new DrawContext();
      ctx.size = new Size(W, H);
      ctx.opaque = true;
      ctx.respectScreenScale = false;
    
      ctx.setFillColor(new Color("#000000"));
      ctx.fillRect(new Rect(0, 0, W, H));
    
      const iw = image.size.width, ih = image.size.height;
      const s = Math.max(W / iw, H / ih);
      const dw = iw * s, dh = ih * s;
      ctx.drawImageInRect(image, new Rect((W - dw) / 2, (H - dh) / 2, dw, dh));
    
      // 下部グラデーション(文字を読みやすくする)
      const startY = H * 0.42;
      const steps = 48;
      for (let i = 0; i < steps; i++) {
        const y0 = Math.floor(startY + ((H - startY) * i) / steps);
        const y1 = i === steps - 1 ? H : Math.floor(startY + ((H - startY) * (i + 1)) / steps);
        const t = (i + 1) / steps;
        ctx.setFillColor(new Color("#000000", 0.88 * Math.pow(t, 1.6)));
        ctx.fillRect(new Rect(0, y0, W, y1 - y0));
      }
      return ctx.getImage();
    }
    
    // ---------- ウィジェット ----------
    function clean(s) {
      return String(s || "").replace(/\s+/g, " ").trim();
    }
    
    function buildWidget(apod, family) {
      const { meta, image, offline, error } = apod;
      const w = new ListWidget();
    
      if (image) {
        w.backgroundImage = composeBackground(image, family);
      } else {
        const g = new LinearGradient();
        g.colors = [new Color("#101a3d"), new Color("#03050f")];
        g.locations = [0, 1];
        w.backgroundGradient = g;
      }
    
      const white = new Color("#ffffff");
      const pad = family === "small" ? 10 : 14;
      w.setPadding(pad, pad, pad, pad);
      w.addSpacer(); // 文字を下に寄せる
    
      const label = w.addText("NASA APOD · " + meta.date + (offline ? " · キャッシュ" : ""));
      label.font = Font.systemFont(family === "small" ? 8 : 9);
      label.textColor = new Color("#ffffff", 0.7);
      label.lineLimit = 1;
      label.minimumScaleFactor = 0.7;
      w.addSpacer(2);
      if (offline && error) {
        const failure = w.addText(error);
        failure.font = Font.systemFont(8);
        failure.textColor = new Color("#ffcc99");
        failure.lineLimit = family === "small" ? 2 : 3;
        w.addSpacer(2);
      }
    
      const title = w.addText(clean(meta.title));
      title.textColor = white;
      if (family === "small") {
        title.font = Font.boldSystemFont(12);
        title.lineLimit = 3;
      } else {
        title.font = Font.boldSystemFont(family === "large" ? 18 : 15);
        title.lineLimit = 2;
      }
      title.minimumScaleFactor = 0.7;
    
      if (family === "large") {
        if (meta.explanation) {
          w.addSpacer(4);
          const ex = w.addText(clean(meta.explanation));
          ex.font = Font.systemFont(10);
          ex.textColor = new Color("#ffffff", 0.82);
          ex.lineLimit = 5;
        }
        if (meta.copyright) {
          w.addSpacer(4);
          const c = w.addText("© " + clean(meta.copyright));
          c.font = Font.systemFont(8);
          c.textColor = new Color("#ffffff", 0.55);
          c.lineLimit = 1;
        }
      }
    
      // タップで APOD のページを開く(例: 2026-09-20 → ap260920.html)
      const ymd = String(meta.date).slice(2).replace(/-/g, "");
      w.url = meta.permalink || "https://apod.nasa.gov/apod/ap" + ymd + ".html";
    
      w.refreshAfterDate = new Date(Date.now() + (offline ? 30 * 60 * 1000 : REFRESH_HOURS * 60 * 60 * 1000));
      return w;
    }
    
    function buildErrorWidget(err) {
      const w = new ListWidget();
      const g = new LinearGradient();
      g.colors = [new Color("#101a3d"), new Color("#03050f")];
      g.locations = [0, 1];
      w.backgroundGradient = g;
      const t = w.addText("APODを取得できませんでした");
      t.font = Font.boldSystemFont(13);
      t.textColor = new Color("#ffffff");
      w.addSpacer(4);
      const d = w.addText(String(err));
      d.font = Font.systemFont(9);
      d.textColor = new Color("#ffffff", 0.6);
      w.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000); // 失敗時は30分後に再試行
      return w;
    }
    
    // ---------- 実行 ----------
    let family = config.widgetFamily;
    if (!family) {
      // アプリ内で実行した場合はプレビューのサイズを選ぶ
      const a = new Alert();
      a.title = "プレビューのサイズ";
      ["小", "中", "大"].forEach(t => a.addAction(t));
      a.addCancelAction("キャンセル");
      const i = await a.presentSheet();
      family = ["small", "medium", "large"][i] || "medium";
    }
    if (!ASPECT[family]) family = "small"; // ロック画面用など未対応のサイズは小と同じ扱い
    
    let widget;
    try {
      widget = buildWidget(await loadApod(), family);
    } catch (e) {
      widget = buildErrorWidget(e);
    }
    
    if (config.runsInWidget) {
      Script.setWidget(widget);
    } else if (family === "small") {
      await widget.presentSmall();
    } else if (family === "large") {
      await widget.presentLarge();
    } else {
      await widget.presentMedium();
    }
    Script.complete();
  3. ホーム画面に追加して設定する

    iPhoneのホーム画面を長押しし、ウィジェット追加画面からScriptableを選びます。この記事の対応サイズの説明に合わせてサイズを選んで追加してください。

    追加したウィジェットを長押しして「ウィジェットを編集」を開き、「Script」に保存したスクリプトを選びます。

    「Parameter」は空欄で構いません。

    編集画面を閉じて表示を確認します。ホーム画面への反映には少し時間がかかる場合があります。

動作確認・使用結果

確認した端末 iPhone 17 ProMAX
iOS iOS27
Scriptable 1.7.19
確認したサイズ 小・中・大
バージョン 1.1.0
動作確認日 2026-10-13

表示・使用時のポイント

NASAのAPODを取得します。写真ではなく動画が選ばれた日はサムネイルなどを使い、取得に失敗した場合は保存済みの内容を表示できることがあります。

更新履歴

1.1.0

  • キャッシュのみ表示される問題に対応し、NASA Scienceの公式取得先を優先するよう変更
  • 新しい取得先の画像と記事リンクに対応
  • 画像を縮小して取得し、メモリ負荷を軽減
  • 取得失敗時にエラー内容を表示し、30分後に再取得するよう変更

1.0.0

  • 初回リリース

よくあるエラーと対処

確認すること
表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
画像がいつもと違う・写真が出ない
APODが動画の日もあります。元のAPODページで内容を確認してください。
APIの取得エラーになる
通信を確認し、共有のDEMO_KEYの利用上限に達した場合は時間を置いてください。
古い写真が出る
保存済みデータを使っている場合があります。表示日付と通信状態を確認してください。

注意事項・制限

  • NASA側の公開時刻により、日本の日付とAPODの日付がずれる場合があります。
  • 画像の権利や利用条件は、各日の掲載クレジットを確認してください。
  • ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。

データ提供・参考資料

NASA Astronomy Picture of the Day:画像と解説。
NASA Open APIs:APOD API。

SHARE

この記事をシェア