動物感染症モニター
設定難易度

JIHS(国立健康危機管理研究機構)が公開している「感染症発生動向調査」のデータをもとに、動物で報告されている感染症の発生状況を確認できるScriptableウィジェットです。

鳥インフルエンザや動物の結核など、獣医師から届け出られた動物感染症について、今週の新規届出や年間累積、主な発生地域をホーム画面から確認できます。

普段のニュースではなかなか目にする機会のない、日本国内の動物感染症の動きを知るためのウィジェットです。

ウィジェットの表示内容

感染症の届出情報から、動物に関連する感染症を取り上げて表示します。

サイズごとの表示

  • 小:主な感染症の週間報告数や年累計などを表示します。
  • 中:報告件数や感染症の種類、都道府県の情報も確認できます。
  • 大:動物・感染症・地域別の情報を詳しく表示します。

設定手順

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

  1. Scriptableを用意する

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

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

    下のコード欄の「コピー」を押します。Scriptableへ移動し、「+」から新しいスクリプトを作ってコード全文を貼り付け、動物感染症モニターなどの名前で保存してください。

    保存したらこのページに戻り、次の手順へ進みます。

    // Variables used by Scriptable.
    // These must be at the very top of the file. Do not edit.
    // icon-color: deep-green; icon-glyph: paw;
    // Version: 1.0.0
    
    // =====================================================
    // 動物感染症モニター - Scriptable Widget
    //
    // 小:
    //   今週の届出
    //   年間累積
    //   最多疾患
    //
    // 中:
    //   今週の届出
    //   年間累積
    //   報告疾患数
    //   累積上位疾患+主な都道府県
    //
    // 大:
    //   サマリー
    //   今週の新規届出
    //   年間累積ランキング
    //   都道府県分布
    //
    // データ:
    // 国立健康危機管理研究機構(JIHS)
    // 感染症発生動向調査週報 IDWR 速報
    //
    // パラメーター不要
    // =====================================================
    
    
    // =====================================================
    // 設定
    // =====================================================
    
    const CONFIG = {
    
      JIHS_BASE:
        "https://id-info.jihs.go.jp/surveillance/idwr/provisional",
    
      SEARCH_WEEKS: 8,
    
      CACHE_HOURS: 6,
    
      REFRESH_MINUTES: 180
    
    };
    
    
    // =====================================================
    // FileManager
    // =====================================================
    
    const fm =
      FileManager.local();
    
    const cacheDir =
      fm.joinPath(
        fm.documentsDirectory(),
        "animal-infection-cache"
      );
    
    if (!fm.fileExists(cacheDir)) {
      fm.createDirectory(cacheDir);
    }
    
    
    // =====================================================
    // 色
    // =====================================================
    
    const COLORS = {
    
      bgTop:
        new Color("#111827"),
    
      bgBottom:
        new Color("#05070B"),
    
      text:
        Color.white(),
    
      sub:
        new Color("#9CA3AF"),
    
      green:
        new Color("#53D769"),
    
      orange:
        new Color("#FF9F43"),
    
      yellow:
        new Color("#FFD166"),
    
      red:
        new Color("#FF5A5F"),
    
      line:
        new Color("#FFFFFF", 0.10),
    
      box:
        new Color("#FFFFFF", 0.055)
    
    };
    
    
    // =====================================================
    // 都道府県
    // =====================================================
    
    const PREFECTURES = [
    
      "北海道",
    
      "青森県",
      "岩手県",
      "宮城県",
      "秋田県",
      "山形県",
      "福島県",
    
      "茨城県",
      "栃木県",
      "群馬県",
      "埼玉県",
      "千葉県",
      "東京都",
      "神奈川県",
    
      "新潟県",
      "富山県",
      "石川県",
      "福井県",
      "山梨県",
      "長野県",
    
      "岐阜県",
      "静岡県",
      "愛知県",
      "三重県",
    
      "滋賀県",
      "京都府",
      "大阪府",
      "兵庫県",
      "奈良県",
      "和歌山県",
    
      "鳥取県",
      "島根県",
      "岡山県",
      "広島県",
      "山口県",
    
      "徳島県",
      "香川県",
      "愛媛県",
      "高知県",
    
      "福岡県",
      "佐賀県",
      "長崎県",
      "熊本県",
      "大分県",
      "宮崎県",
      "鹿児島県",
    
      "沖縄県"
    
    ];
    
    
    // =====================================================
    // メイン
    // =====================================================
    
    let widget;
    
    try {
    
      // -------------------------------------------------
      // 最新週
      // -------------------------------------------------
    
      const latest =
        await findLatestWeek();
    
    
      // -------------------------------------------------
      // CSV解析
      // -------------------------------------------------
    
      const data =
        parseAnimalCSV(
          latest.csv
        );
    
    
      if (!data.length) {
    
        throw new Error(
          "動物疾患データを解析できませんでした"
        );
    
      }
    
    
      // -------------------------------------------------
      // 年間累積ランキング
      // -------------------------------------------------
    
      const ranking =
        [...data]
          .filter(
            item =>
              item.total > 0
          )
          .sort(
            (a, b) =>
              b.total -
              a.total
          );
    
    
      // -------------------------------------------------
      // 今週の届出
      // -------------------------------------------------
    
      const weekly =
        [...data]
          .filter(
            item =>
              item.weekly > 0
          )
          .sort(
            (a, b) =>
              b.weekly -
              a.weekly
          );
    
    
      // -------------------------------------------------
      // Widget
      // -------------------------------------------------
    
      widget =
        createWidget(
          ranking,
          weekly,
          latest
        );
    
    
      // -------------------------------------------------
      // 更新予定
      // -------------------------------------------------
    
      widget.refreshAfterDate =
        new Date(
          Date.now() +
          CONFIG.REFRESH_MINUTES *
          60 *
          1000
        );
    
    }
    catch (e) {
    
      console.log(e);
    
      widget =
        createErrorWidget(
          e.message
        );
    
    }
    
    
    // =====================================================
    // Widget表示
    // =====================================================
    
    if (config.runsInWidget) {
    
      Script.setWidget(widget);
    
    }
    else {
    
      const family =
        config.widgetFamily ||
        "medium";
    
    
      if (family === "small") {
    
        await widget.presentSmall();
    
      }
      else if (family === "large") {
    
        await widget.presentLarge();
    
      }
      else {
    
        await widget.presentMedium();
    
      }
    
    }
    
    Script.complete();
    
    
    // =====================================================
    // 最新週を探す
    // =====================================================
    
    async function findLatestWeek() {
    
      const today =
        new Date();
    
    
      const info =
        getISOWeek(today);
    
    
      let year =
        info.year;
    
      let week =
        info.week;
    
    
      for (
        let i = 0;
        i < CONFIG.SEARCH_WEEKS;
        i++
      ) {
    
        console.log(
          `動物疾患CSV確認:${year} 第${week}週`
        );
    
    
        const csv =
          await getAnimalCSV(
            year,
            week
          );
    
    
        if (
          csv &&
          isAnimalCSV(csv)
        ) {
    
          console.log(
            `取得成功:${year} 第${week}週`
          );
    
    
          return {
    
            year,
            week,
            csv
    
          };
    
        }
    
    
        const prev =
          getPreviousWeek(
            year,
            week
          );
    
    
        year =
          prev.year;
    
        week =
          prev.week;
    
      }
    
    
      throw new Error(
        "最新の動物疾患データが見つかりません"
      );
    
    }
    
    
    // =====================================================
    // 動物疾患CSV判定
    // =====================================================
    
    function isAnimalCSV(text) {
    
      if (!text) {
        return false;
      }
    
    
      return (
    
        text.includes("サル") ||
        text.includes("鳥類") ||
        text.includes("犬") ||
        text.includes("ラクダ") ||
        text.includes("鳥インフルエンザ")
    
      );
    
    }
    
    
    // =====================================================
    // 動物疾患CSV取得
    // =====================================================
    
    async function getAnimalCSV(
      year,
      week
    ) {
    
      const ww =
        String(week)
          .padStart(2, "0");
    
    
      const cachePath =
        fm.joinPath(
          cacheDir,
          `animal-${year}-${ww}.csv`
        );
    
    
      // -------------------------------------------------
      // キャッシュ
      // -------------------------------------------------
    
      if (fm.fileExists(cachePath)) {
    
        const mod =
          fm.modificationDate(
            cachePath
          );
    
    
        if (mod) {
    
          const age =
            (
              Date.now() -
              mod.getTime()
            ) /
            3600000;
    
    
          if (
            age <
            CONFIG.CACHE_HOURS
          ) {
    
            const cached =
              fm.readString(
                cachePath
              );
    
    
            if (
              isAnimalCSV(
                cached
              )
            ) {
    
              return cached;
    
            }
    
          }
    
        }
    
      }
    
    
      // -------------------------------------------------
      // JIHS
      // -------------------------------------------------
    
      const urls = [
    
        `${CONFIG.JIHS_BASE}/${year}/${ww}/${year}-${ww}-animal.csv`,
    
        `${CONFIG.JIHS_BASE}/${year}/${ww}/${year}-${ww}-animal.csv?download=1`
    
      ];
    
    
      for (const url of urls) {
    
        try {
    
          console.log(url);
    
    
          const req =
            new Request(url);
    
    
          req.timeoutInterval =
            15;
    
    
          // -----------------------------------------------
          // Dataとして取得
          // -----------------------------------------------
    
          const data =
            await req.load();
    
    
          if (!data) {
            continue;
          }
    
    
          const text =
            decodeCSV(
              data
            );
    
    
          if (
            text &&
            isAnimalCSV(text)
          ) {
    
            fm.writeString(
              cachePath,
              text
            );
    
    
            return text;
    
          }
    
        }
        catch (e) {
    
          console.log(
            `取得失敗:${url}`
          );
    
    
          console.log(e);
    
        }
    
      }
    
    
      // -------------------------------------------------
      // 通信失敗時は古いキャッシュ
      // -------------------------------------------------
    
      if (
        fm.fileExists(
          cachePath
        )
      ) {
    
        const cached =
          fm.readString(
            cachePath
          );
    
    
        if (
          isAnimalCSV(
            cached
          )
        ) {
    
          return cached;
    
        }
    
      }
    
    
      return null;
    
    }
    
    
    // =====================================================
    // CSV文字コード
    // =====================================================
    
    function decodeCSV(data) {
    
      try {
    
        const text =
          data.toRawString();
    
    
        if (
          text &&
          isAnimalCSV(text)
        ) {
    
          return text;
    
        }
    
      }
      catch (_) {}
    
    
      try {
    
        return data.toString();
    
      }
      catch (_) {}
    
    
      return "";
    
    }
    
    
    // =====================================================
    // 動物疾患CSV解析
    // =====================================================
    
    function parseAnimalCSV(csv) {
    
      const rows =
        parseCSV(csv);
    
    
      if (
        rows.length <
        3
      ) {
    
        return [];
    
      }
    
    
      // =================================================
      // 疾患名が書かれている行
      // =================================================
    
      let diseaseRowIndex =
        -1;
    
    
      for (
        let r = 0;
        r <
        Math.min(
          rows.length,
          12
        );
        r++
      ) {
    
        const joined =
          rows[r]
            .map(normalize)
            .join("|");
    
    
        if (
          joined.includes("エボラ") ||
          joined.includes("ペスト") ||
          joined.includes("鳥インフルエンザ") ||
          joined.includes("結核")
        ) {
    
          diseaseRowIndex =
            r;
    
          break;
    
        }
    
      }
    
    
      if (
        diseaseRowIndex <
        0
      ) {
    
        return [];
    
      }
    
    
      // =================================================
      // 見出し
      // =================================================
    
      const diseaseRow =
        rows[
          diseaseRowIndex
        ] || [];
    
    
      const animalRow =
        rows[
          diseaseRowIndex + 1
        ] || [];
    
    
      const typeRow =
        rows[
          diseaseRowIndex + 2
        ] || [];
    
    
      // =================================================
      // 全国行
      // =================================================
    
      let nationalRow =
        rows.find(
          row => {
    
            const first =
              normalize(
                row[0]
              );
    
    
            return (
              first === "総数" ||
              first === "全国" ||
              first === "計"
            );
    
          }
        );
    
    
      // -------------------------------------------------
      // 全国行名が想定外の場合
      // -------------------------------------------------
    
      if (!nationalRow) {
    
        nationalRow =
          rows.find(
            row => {
    
              const first =
                normalize(
                  row[0]
                );
    
    
              if (
                PREFECTURES.includes(
                  first
                )
              ) {
    
                return false;
    
              }
    
    
              let numbers =
                0;
    
    
              for (
                let i = 1;
                i < row.length;
                i++
              ) {
    
                if (
                  isNumeric(
                    row[i]
                  )
                ) {
    
                  numbers++;
    
                }
    
              }
    
    
              return (
                numbers >= 2
              );
    
            }
          );
    
      }
    
    
      if (!nationalRow) {
    
        return [];
    
      }
    
    
      // =================================================
      // 各疾患
      // =================================================
    
      const result = [];
    
    
      let currentDisease =
        "";
    
      let currentAnimal =
        "";
    
    
      const maxColumns =
        Math.max(
    
          diseaseRow.length,
          animalRow.length,
          typeRow.length,
          nationalRow.length
    
        );
    
    
      for (
        let col = 1;
        col < maxColumns;
        col++
      ) {
    
        // -------------------------------------------------
        // 疾患名
        // -------------------------------------------------
    
        const diseaseValue =
          normalize(
            diseaseRow[col]
          );
    
    
        if (diseaseValue) {
    
          currentDisease =
            diseaseValue;
    
        }
    
    
        // -------------------------------------------------
        // 対象動物
        // -------------------------------------------------
    
        const animalValue =
          normalize(
            animalRow[col]
          );
    
    
        if (
          animalValue &&
          !isTypeLabel(
            animalValue
          )
        ) {
    
          currentAnimal =
            animalValue;
    
        }
    
    
        // -------------------------------------------------
        // 報告 / 累積
        // -------------------------------------------------
    
        let type =
          normalize(
            typeRow[col]
          );
    
    
        if (
          !isTypeLabel(type)
        ) {
    
          const alternative =
            normalize(
              animalRow[col]
            );
    
    
          if (
            isTypeLabel(
              alternative
            )
          ) {
    
            type =
              alternative;
    
          }
    
        }
    
    
        if (
          !currentDisease ||
          !isTypeLabel(type)
        ) {
    
          continue;
    
        }
    
    
        // -------------------------------------------------
        // 全国値
        // -------------------------------------------------
    
        const value =
          parseNumber(
            nationalRow[col]
          );
    
    
        // -------------------------------------------------
        // 疾患+対象動物
        // -------------------------------------------------
    
        let item =
          result.find(
            item =>
              item.disease ===
                currentDisease &&
              item.animal ===
                currentAnimal
          );
    
    
        if (!item) {
    
          item = {
    
            disease:
              currentDisease,
    
            animal:
              currentAnimal,
    
            weekly:
              0,
    
            total:
              0,
    
            weeklyPrefs:
              [],
    
            totalPrefs:
              []
    
          };
    
    
          result.push(
            item
          );
    
        }
    
    
        // -------------------------------------------------
        // 都道府県別
        // -------------------------------------------------
    
        const prefs = [];
    
    
        for (
          const row of rows
        ) {
    
          const pref =
            normalize(
              row[0]
            );
    
    
          if (
            !PREFECTURES.includes(
              pref
            )
          ) {
    
            continue;
    
          }
    
    
          const n =
            parseNumber(
              row[col]
            );
    
    
          if (
            n > 0
          ) {
    
            prefs.push({
    
              name:
                pref,
    
              value:
                n
    
            });
    
          }
    
        }
    
    
        prefs.sort(
          (a, b) =>
            b.value -
            a.value
        );
    
    
        // -------------------------------------------------
        // 保存
        // -------------------------------------------------
    
        if (
          type === "報告" ||
          type === "報告数"
        ) {
    
          item.weekly =
            value;
    
          item.weeklyPrefs =
            prefs;
    
        }
        else if (
          type === "累積" ||
          type === "累積報告数"
        ) {
    
          item.total =
            value;
    
          item.totalPrefs =
            prefs;
    
        }
    
      }
    
    
      return result.filter(
        item =>
          item.weekly > 0 ||
          item.total > 0
      );
    
    }
    
    
    // =====================================================
    // 報告 / 累積 判定
    // =====================================================
    
    function isTypeLabel(value) {
    
      return (
    
        value === "報告" ||
        value === "報告数" ||
        value === "累積" ||
        value === "累積報告数"
    
      );
    
    }
    
    
    // =====================================================
    // 集計
    // =====================================================
    
    function getSummary(
      ranking,
      weekly
    ) {
    
      const yearlyTotal =
        ranking.reduce(
          (sum, item) =>
            sum +
            item.total,
          0
        );
    
    
      const weeklyTotal =
        weekly.reduce(
          (sum, item) =>
            sum +
            item.weekly,
          0
        );
    
    
      return {
    
        yearlyTotal,
    
        weeklyTotal,
    
        diseaseCount:
          ranking.length
    
      };
    
    }
    
    
    // =====================================================
    // Widget
    // =====================================================
    
    function createWidget(
      ranking,
      weekly,
      latest
    ) {
    
      const w =
        new ListWidget();
    
    
      // -------------------------------------------------
      // 背景
      // -------------------------------------------------
    
      const gradient =
        new LinearGradient();
    
    
      gradient.colors = [
    
        COLORS.bgTop,
        COLORS.bgBottom
    
      ];
    
    
      gradient.locations = [
    
        0,
        1
    
      ];
    
    
      w.backgroundGradient =
        gradient;
    
    
      // -------------------------------------------------
      // タップ
      // -------------------------------------------------
    
      w.url =
        `${CONFIG.JIHS_BASE}/${latest.year}/${String(latest.week).padStart(2, "0")}/`;
    
    
      // -------------------------------------------------
      // サイズ
      // -------------------------------------------------
    
      const family =
        config.widgetFamily ||
        "medium";
    
    
      if (
        family === "small"
      ) {
    
        return createSmall(
          w,
          ranking,
          weekly,
          latest
        );
    
      }
    
    
      if (
        family === "large"
      ) {
    
        return createLarge(
          w,
          ranking,
          weekly,
          latest
        );
    
      }
    
    
      return createMedium(
        w,
        ranking,
        weekly,
        latest
      );
    
    }
    
    
    // =====================================================
    // 小サイズ
    // =====================================================
    
    function createSmall(
      w,
      ranking,
      weekly,
      latest
    ) {
    
      w.setPadding(
        14,
        14,
        12,
        14
      );
    
    
      const summary =
        getSummary(
          ranking,
          weekly
        );
    
    
      // -------------------------------------------------
      // Header
      // -------------------------------------------------
    
      const header =
        w.addStack();
    
    
      header.centerAlignContent();
    
    
      const icon =
        header.addText(
          "🐾"
        );
    
    
      icon.font =
        Font.systemFont(11);
    
    
      header.addSpacer(5);
    
    
      const title =
        header.addText(
          "動物感染症"
        );
    
    
      title.font =
        Font.boldSystemFont(12);
    
    
      title.textColor =
        COLORS.text;
    
    
      w.addSpacer(8);
    
    
      // -------------------------------------------------
      // 今週
      // -------------------------------------------------
    
      const weekLabel =
        w.addText(
          "今週の届出"
        );
    
    
      weekLabel.font =
        Font.mediumSystemFont(9);
    
    
      weekLabel.textColor =
        COLORS.sub;
    
    
      w.addSpacer(1);
    
    
      const weekValue =
        w.addText(
          `${summary.weeklyTotal}件`
        );
    
    
      weekValue.font =
        Font.boldSystemFont(25);
    
    
      weekValue.textColor =
        summary.weeklyTotal > 0
          ? COLORS.orange
          : COLORS.green;
    
    
      w.addSpacer(6);
    
    
      // -------------------------------------------------
      // 年間累積
      // -------------------------------------------------
    
      const totalRow =
        w.addStack();
    
    
      totalRow.centerAlignContent();
    
    
      const totalLabel =
        totalRow.addText(
          "年間累積"
        );
    
    
      totalLabel.font =
        Font.systemFont(9);
    
    
      totalLabel.textColor =
        COLORS.sub;
    
    
      totalRow.addSpacer();
    
    
      const totalValue =
        totalRow.addText(
          `${summary.yearlyTotal}件`
        );
    
    
      totalValue.font =
        Font.boldSystemFont(11);
    
    
      totalValue.textColor =
        COLORS.text;
    
    
      // -------------------------------------------------
      // 最多疾患
      // -------------------------------------------------
    
      if (
        ranking.length
      ) {
    
        const top =
          ranking[0];
    
    
        w.addSpacer(6);
    
    
        const topLabel =
          w.addText(
            "最多"
          );
    
    
        topLabel.font =
          Font.systemFont(8);
    
    
        topLabel.textColor =
          COLORS.sub;
    
    
        w.addSpacer(1);
    
    
        const disease =
          w.addText(
            displayDisease(
              top.disease
            )
          );
    
    
        disease.font =
          Font.semiboldSystemFont(10);
    
    
        disease.textColor =
          COLORS.text;
    
    
        disease.lineLimit =
          1;
    
    
        w.addSpacer(1);
    
    
        const topInfo =
          w.addText(
            `${top.total}件`
          );
    
    
        topInfo.font =
          Font.boldSystemFont(9);
    
    
        topInfo.textColor =
          COLORS.green;
    
      }
    
    
      w.addSpacer();
    
    
      // -------------------------------------------------
      // Footer
      // -------------------------------------------------
    
      const footer =
        w.addText(
          `JIHS|第${latest.week}週`
        );
    
    
      footer.font =
        Font.systemFont(8);
    
    
      footer.textColor =
        COLORS.sub;
    
    
      return w;
    
    }
    
    
    // =====================================================
    // 中サイズ
    // =====================================================
    
    function createMedium(
      w,
      ranking,
      weekly,
      latest
    ) {
    
      w.setPadding(
        14,
        15,
        12,
        15
      );
    
    
      const summary =
        getSummary(
          ranking,
          weekly
        );
    
    
      // -------------------------------------------------
      // Header
      // -------------------------------------------------
    
      addHeader(
        w,
        latest
      );
    
    
      w.addSpacer(7);
    
    
      // -------------------------------------------------
      // サマリー
      // -------------------------------------------------
    
      const summaryRow =
        w.addStack();
    
    
      summaryRow.centerAlignContent();
    
    
      addSummaryItem(
        summaryRow,
        "今週",
        `${summary.weeklyTotal}件`,
        summary.weeklyTotal > 0
          ? COLORS.orange
          : COLORS.green
      );
    
    
      summaryRow.addSpacer(18);
    
    
      addSummaryItem(
        summaryRow,
        "年間累積",
        `${summary.yearlyTotal}件`,
        COLORS.text
      );
    
    
      summaryRow.addSpacer(18);
    
    
      addSummaryItem(
        summaryRow,
        "報告疾患",
        `${summary.diseaseCount}種`,
        COLORS.text
      );
    
    
      w.addSpacer(7);
    
    
      addLine(w);
    
    
      w.addSpacer(6);
    
    
      // -------------------------------------------------
      // 疾患
      // -------------------------------------------------
    
      const items =
        ranking.slice(
          0,
          3
        );
    
    
      items.forEach(
        (item, index) => {
    
          addMediumDisease(
            w,
            item
          );
    
    
          if (
            index <
            items.length - 1
          ) {
    
            w.addSpacer(5);
    
          }
    
        }
      );
    
    
      w.addSpacer();
    
    
      addFooter(
        w,
        latest
      );
    
    
      return w;
    
    }
    
    
    // =====================================================
    // 中サイズ:疾患
    // =====================================================
    
    function addMediumDisease(
      w,
      item
    ) {
    
      const row =
        w.addStack();
    
    
      row.centerAlignContent();
    
    
      // -------------------------------------------------
      // アイコン
      // -------------------------------------------------
    
      const icon =
        row.addText(
          animalIcon(
            item.animal
          )
        );
    
    
      icon.font =
        Font.systemFont(11);
    
    
      row.addSpacer(6);
    
    
      // -------------------------------------------------
      // 左
      // -------------------------------------------------
    
      const left =
        row.addStack();
    
    
      left.layoutVertically();
    
    
      const disease =
        left.addText(
          displayDisease(
            item.disease
          )
        );
    
    
      disease.font =
        Font.semiboldSystemFont(10);
    
    
      disease.textColor =
        COLORS.text;
    
    
      disease.lineLimit =
        1;
    
    
      // -------------------------------------------------
      // 主な地域
      // -------------------------------------------------
    
      if (
        item.totalPrefs &&
        item.totalPrefs.length
      ) {
    
        left.addSpacer(1);
    
    
        const prefText =
          item.totalPrefs
            .slice(
              0,
              3
            )
            .map(
              p =>
                `${shortPref(p.name)} ${p.value}`
            )
            .join("・");
    
    
        const pref =
          left.addText(
            prefText
          );
    
    
        pref.font =
          Font.systemFont(8);
    
    
        pref.textColor =
          COLORS.sub;
    
    
        pref.lineLimit =
          1;
    
      }
    
    
      row.addSpacer();
    
    
      // -------------------------------------------------
      // 右
      // -------------------------------------------------
    
      const right =
        row.addStack();
    
    
      right.layoutVertically();
    
    
      const value =
        right.addText(
          `累積 ${item.total}件`
        );
    
    
      value.font =
        Font.boldSystemFont(10);
    
    
      value.textColor =
        COLORS.green;
    
    
      value.rightAlignText();
    
    }
    
    
    // =====================================================
    // 大サイズ
    // =====================================================
    
    function createLarge(
      w,
      ranking,
      weekly,
      latest
    ) {
    
      w.setPadding(
        18,
        18,
        15,
        18
      );
    
    
      const summary =
        getSummary(
          ranking,
          weekly
        );
    
    
      // -------------------------------------------------
      // Header
      // -------------------------------------------------
    
      addHeader(
        w,
        latest,
        true
      );
    
    
      w.addSpacer(11);
    
    
      // =================================================
      // サマリー
      // =================================================
    
      const summaryBox =
        w.addStack();
    
    
      summaryBox.backgroundColor =
        COLORS.box;
    
    
      summaryBox.cornerRadius =
        10;
    
    
      summaryBox.setPadding(
        9,
        10,
        9,
        10
      );
    
    
      addSummaryItem(
        summaryBox,
        "今週",
        `${summary.weeklyTotal}件`,
        summary.weeklyTotal > 0
          ? COLORS.orange
          : COLORS.green
      );
    
    
      summaryBox.addSpacer();
    
    
      addSummaryItem(
        summaryBox,
        "年間累積",
        `${summary.yearlyTotal}件`,
        COLORS.text
      );
    
    
      summaryBox.addSpacer();
    
    
      addSummaryItem(
        summaryBox,
        "報告疾患",
        `${summary.diseaseCount}種`,
        COLORS.text
      );
    
    
      w.addSpacer(12);
    
    
      // =================================================
      // 今週
      // =================================================
    
      if (
        summary.weeklyTotal > 0
      ) {
    
        const weekTitle =
          w.addText(
            "今週の新規届出"
          );
    
    
        weekTitle.font =
          Font.boldSystemFont(11);
    
    
        weekTitle.textColor =
          COLORS.text;
    
    
        w.addSpacer(5);
    
    
        weekly
          .slice(
            0,
            2
          )
          .forEach(
            (item, index) => {
    
              const row =
                w.addStack();
    
    
              row.centerAlignContent();
    
    
              const disease =
                row.addText(
                  `${animalIcon(item.animal)} ${displayDisease(item.disease)}`
                );
    
    
              disease.font =
                Font.systemFont(9);
    
    
              disease.textColor =
                COLORS.text;
    
    
              disease.lineLimit =
                1;
    
    
              row.addSpacer();
    
    
              const number =
                row.addText(
                  `${item.weekly}件`
                );
    
    
              number.font =
                Font.boldSystemFont(10);
    
    
              number.textColor =
                COLORS.orange;
    
    
              if (
                index <
                Math.min(
                  weekly.length,
                  2
                ) - 1
              ) {
    
                w.addSpacer(3);
    
              }
    
            }
          );
    
    
        w.addSpacer(8);
    
      }
      else {
    
        const noReport =
          w.addStack();
    
    
        noReport.centerAlignContent();
    
    
        const dot =
          noReport.addText(
            "●"
          );
    
    
        dot.font =
          Font.boldSystemFont(8);
    
    
        dot.textColor =
          COLORS.green;
    
    
        noReport.addSpacer(6);
    
    
        const text =
          noReport.addText(
            "この週に新たな届出はありません"
          );
    
    
        text.font =
          Font.systemFont(9);
    
    
        text.textColor =
          COLORS.sub;
    
    
        w.addSpacer(10);
    
      }
    
    
      // =================================================
      // 年間累積
      // =================================================
    
      const section =
        w.addText(
          "年間累積"
        );
    
    
      section.font =
        Font.boldSystemFont(12);
    
    
      section.textColor =
        COLORS.text;
    
    
      w.addSpacer(8);
    
    
      const items =
        ranking.slice(
          0,
          4
        );
    
    
      items.forEach(
        (item, index) => {
    
          addLargeDisease(
            w,
            item,
            index + 1
          );
    
    
          if (
            index <
            items.length - 1
          ) {
    
            w.addSpacer(10);
    
          }
    
        }
      );
    
    
      w.addSpacer();
    
    
      addFooter(
        w,
        latest
      );
    
    
      return w;
    
    }
    
    
    // =====================================================
    // 大サイズ:疾患
    // =====================================================
    
    function addLargeDisease(
      w,
      item,
      rank
    ) {
    
      // -------------------------------------------------
      // 上段
      // -------------------------------------------------
    
      const top =
        w.addStack();
    
    
      top.centerAlignContent();
    
    
      const rankText =
        top.addText(
          `${rank}`
        );
    
    
      rankText.font =
        Font.boldSystemFont(9);
    
    
      rankText.textColor =
        COLORS.sub;
    
    
      top.addSpacer(7);
    
    
      const icon =
        top.addText(
          animalIcon(
            item.animal
          )
        );
    
    
      icon.font =
        Font.systemFont(11);
    
    
      top.addSpacer(5);
    
    
      const disease =
        top.addText(
          displayDisease(
            item.disease
          )
        );
    
    
      disease.font =
        Font.semiboldSystemFont(11);
    
    
      disease.textColor =
        COLORS.text;
    
    
      disease.lineLimit =
        1;
    
    
      top.addSpacer();
    
    
      const total =
        top.addText(
          `${item.total}件`
        );
    
    
      total.font =
        Font.boldSystemFont(13);
    
    
      total.textColor =
        COLORS.green;
    
    
      // -------------------------------------------------
      // 対象動物
      // -------------------------------------------------
    
      if (
        item.animal
      ) {
    
        w.addSpacer(2);
    
    
        const animal =
          w.addText(
            `対象:${displayAnimal(item.animal)}`
          );
    
    
        animal.font =
          Font.systemFont(8);
    
    
        animal.textColor =
          COLORS.sub;
    
      }
    
    
      // -------------------------------------------------
      // 地域分布
      // -------------------------------------------------
    
      if (
        item.totalPrefs &&
        item.totalPrefs.length
      ) {
    
        w.addSpacer(4);
    
    
        addDistributionBar(
          w,
          item
        );
    
    
        w.addSpacer(3);
    
    
        const prefs =
          item.totalPrefs
            .slice(
              0,
              4
            )
            .map(
              p =>
                `${shortPref(p.name)} ${p.value}`
            )
            .join("・");
    
    
        const pref =
          w.addText(
            prefs
          );
    
    
        pref.font =
          Font.systemFont(8);
    
    
        pref.textColor =
          COLORS.sub;
    
    
        pref.lineLimit =
          1;
    
      }
    
    }
    
    
    // =====================================================
    // 地域集中度バー
    //
    // 最多都道府県が全国累積に占める割合
    // =====================================================
    
    function addDistributionBar(
      w,
      item
    ) {
    
      const container =
        w.addStack();
    
    
      container.centerAlignContent();
    
    
      // -------------------------------------------------
      // バー背景
      // -------------------------------------------------
    
      const bar =
        container.addStack();
    
    
      bar.size =
        new Size(
          245,
          5
        );
    
    
      bar.cornerRadius =
        2.5;
    
    
      bar.backgroundColor =
        new Color(
          "#FFFFFF",
          0.08
        );
    
    
      // -------------------------------------------------
      // TOP都道府県
      // -------------------------------------------------
    
      const topValue =
        item.totalPrefs.length
          ? item.totalPrefs[0].value
          : 0;
    
    
      const ratio =
        item.total > 0
          ? Math.min(
              1,
              topValue /
              item.total
            )
          : 0;
    
    
      const maxWidth =
        245;
    
    
      const fillWidth =
        Math.max(
          3,
          Math.round(
            maxWidth *
            ratio
          )
        );
    
    
      const fill =
        bar.addStack();
    
    
      fill.size =
        new Size(
          fillWidth,
          5
        );
    
    
      fill.cornerRadius =
        2.5;
    
    
      fill.backgroundColor =
        COLORS.green;
    
    
      bar.addSpacer();
    
    }
    
    
    // =====================================================
    // サマリー項目
    // =====================================================
    
    function addSummaryItem(
      stack,
      label,
      value,
      color
    ) {
    
      const item =
        stack.addStack();
    
    
      item.layoutVertically();
    
    
      const labelText =
        item.addText(
          label
        );
    
    
      labelText.font =
        Font.systemFont(8);
    
    
      labelText.textColor =
        COLORS.sub;
    
    
      item.addSpacer(2);
    
    
      const valueText =
        item.addText(
          value
        );
    
    
      valueText.font =
        Font.boldSystemFont(13);
    
    
      valueText.textColor =
        color;
    
    }
    
    
    // =====================================================
    // Header
    // =====================================================
    
    function addHeader(
      w,
      latest,
      large = false
    ) {
    
      const row =
        w.addStack();
    
    
      row.centerAlignContent();
    
    
      const icon =
        row.addText(
          "🐾"
        );
    
    
      icon.font =
        Font.systemFont(
          large
            ? 15
            : 13
        );
    
    
      row.addSpacer(6);
    
    
      const title =
        row.addText(
          "動物感染症モニター"
        );
    
    
      title.font =
        Font.boldSystemFont(
          large
            ? 17
            : 15
        );
    
    
      title.textColor =
        COLORS.text;
    
    
      row.addSpacer();
    
    
      const week =
        row.addText(
          `第${latest.week}週`
        );
    
    
      week.font =
        Font.mediumSystemFont(9);
    
    
      week.textColor =
        COLORS.sub;
    
    }
    
    
    // =====================================================
    // Line
    // =====================================================
    
    function addLine(w) {
    
      const line =
        w.addStack();
    
    
      line.size =
        new Size(
          0,
          1
        );
    
    
      line.backgroundColor =
        COLORS.line;
    
    }
    
    
    // =====================================================
    // Footer
    // =====================================================
    
    function addFooter(
      w,
      latest
    ) {
    
      const footer =
        w.addStack();
    
    
      footer.centerAlignContent();
    
    
      const source =
        footer.addText(
          "JIHS 感染症発生動向調査"
        );
    
    
      source.font =
        Font.systemFont(8);
    
    
      source.textColor =
        COLORS.sub;
    
    
      footer.addSpacer();
    
    
      const week =
        footer.addText(
          `${latest.year}年第${latest.week}週`
        );
    
    
      week.font =
        Font.systemFont(8);
    
    
      week.textColor =
        COLORS.sub;
    
    }
    
    
    // =====================================================
    // エラーWidget
    // =====================================================
    
    function createErrorWidget(
      message
    ) {
    
      const w =
        new ListWidget();
    
    
      w.backgroundColor =
        COLORS.bgTop;
    
    
      w.setPadding(
        16,
        16,
        16,
        16
      );
    
    
      const icon =
        w.addText(
          "⚠︎"
        );
    
    
      icon.font =
        Font.boldSystemFont(24);
    
    
      icon.textColor =
        COLORS.orange;
    
    
      w.addSpacer(10);
    
    
      const title =
        w.addText(
          "動物感染症"
        );
    
    
      title.font =
        Font.boldSystemFont(16);
    
    
      title.textColor =
        COLORS.text;
    
    
      w.addSpacer(6);
    
    
      const text =
        w.addText(
          message
        );
    
    
      text.font =
        Font.systemFont(11);
    
    
      text.textColor =
        COLORS.sub;
    
    
      text.lineLimit =
        5;
    
    
      return w;
    
    }
    
    
    // =====================================================
    // CSV Parser
    // =====================================================
    
    function parseCSV(text) {
    
      const rows = [];
    
      let row = [];
    
      let field = "";
    
      let quote = false;
    
    
      for (
        let i = 0;
        i < text.length;
        i++
      ) {
    
        const c =
          text[i];
    
    
        if (quote) {
    
          if (
            c === '"'
          ) {
    
            if (
              text[i + 1] === '"'
            ) {
    
              field += '"';
    
              i++;
    
            }
            else {
    
              quote = false;
    
            }
    
          }
          else {
    
            field += c;
    
          }
    
        }
        else {
    
          if (
            c === '"'
          ) {
    
            quote = true;
    
          }
          else if (
            c === ","
          ) {
    
            row.push(
              field
            );
    
    
            field = "";
    
          }
          else if (
            c === "\n"
          ) {
    
            row.push(
              field
            );
    
    
            rows.push(
              row
            );
    
    
            row = [];
    
            field = "";
    
          }
          else if (
            c !== "\r"
          ) {
    
            field += c;
    
          }
    
        }
    
      }
    
    
      if (
        field.length ||
        row.length
      ) {
    
        row.push(
          field
        );
    
    
        rows.push(
          row
        );
    
      }
    
    
      return rows;
    
    }
    
    
    // =====================================================
    // ISO週番号
    // =====================================================
    
    function getISOWeek(date) {
    
      const d =
        new Date(
          Date.UTC(
            date.getFullYear(),
            date.getMonth(),
            date.getDate()
          )
        );
    
    
      const day =
        d.getUTCDay() ||
        7;
    
    
      d.setUTCDate(
        d.getUTCDate() +
        4 -
        day
      );
    
    
      const yearStart =
        new Date(
          Date.UTC(
            d.getUTCFullYear(),
            0,
            1
          )
        );
    
    
      const week =
        Math.ceil(
          (
            (
              d -
              yearStart
            ) /
            86400000 +
            1
          ) /
          7
        );
    
    
      return {
    
        year:
          d.getUTCFullYear(),
    
        week
    
      };
    
    }
    
    
    // =====================================================
    // 前週
    // =====================================================
    
    function getPreviousWeek(
      year,
      week
    ) {
    
      if (
        week > 1
      ) {
    
        return {
    
          year,
    
          week:
            week - 1
    
        };
    
      }
    
    
      const d =
        new Date(
          year - 1,
          11,
          28
        );
    
    
      const info =
        getISOWeek(
          d
        );
    
    
      return {
    
        year:
          year - 1,
    
        week:
          info.week
    
      };
    
    }
    
    
    // =====================================================
    // 数値変換
    // =====================================================
    
    function parseNumber(value) {
    
      if (
        value === undefined ||
        value === null ||
        value === ""
      ) {
    
        return 0;
    
      }
    
    
      const n =
        parseFloat(
          String(value)
            .replace(
              /,/g,
              ""
            )
            .trim()
        );
    
    
      return isNaN(n)
        ? 0
        : n;
    
    }
    
    
    // =====================================================
    // 数値判定
    // =====================================================
    
    function isNumeric(value) {
    
      if (
        value === undefined ||
        value === null ||
        value === ""
      ) {
    
        return false;
    
      }
    
    
      return !isNaN(
        Number(
          String(value)
            .replace(
              /,/g,
              ""
            )
            .trim()
        )
      );
    
    }
    
    
    // =====================================================
    // 文字列正規化
    // =====================================================
    
    function normalize(value) {
    
      if (
        value === undefined ||
        value === null
      ) {
    
        return "";
    
      }
    
    
      return String(value)
        .replace(
          /^\uFEFF/,
          ""
        )
        .replace(
          /\u3000/g,
          " "
        )
        .replace(
          /\r/g,
          ""
        )
        .trim();
    
    }
    
    
    // =====================================================
    // 疾患表示名
    // =====================================================
    
    function displayDisease(name) {
    
      if (!name) {
    
        return "不明";
    
      }
    
    
      return name
        .replace(
          /重症急性呼吸器症候群/g,
          "SARS"
        )
        .replace(
          /中東呼吸器症候群/g,
          "MERS"
        );
    
    }
    
    
    // =====================================================
    // 動物表示名
    // =====================================================
    
    function displayAnimal(name) {
    
      if (!name) {
    
        return "対象動物";
    
      }
    
    
      return name;
    
    }
    
    
    // =====================================================
    // 都道府県短縮
    // =====================================================
    
    function shortPref(name) {
    
      if (
        name === "北海道"
      ) {
    
        return "北海道";
    
      }
    
    
      return String(name)
        .replace(
          /[都府県]$/,
          ""
        );
    
    }
    
    
    // =====================================================
    // 動物アイコン
    // =====================================================
    
    function animalIcon(name) {
    
      if (!name) {
    
        return "🐾";
    
      }
    
    
      if (
        name.includes("犬")
      ) {
    
        return "🐕";
    
      }
    
    
      if (
        name.includes("サル") ||
        name.includes("猿")
      ) {
    
        return "🐒";
    
      }
    
    
      if (
        name.includes("鳥")
      ) {
    
        return "🐦";
    
      }
    
    
      if (
        name.includes("ラクダ")
      ) {
    
        return "🐪";
    
      }
    
    
      if (
        name.includes("タヌキ") ||
        name.includes("ハクビシン")
      ) {
    
        return "🦝";
    
      }
    
    
      return "🐾";
    
    }
  3. ホーム画面に追加して設定する

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

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

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

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

動作確認・使用結果

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

表示・使用時のポイント

感染症の届出情報から、動物に関連する感染症を取り上げて表示します。このウィジェットではParameterを設定する必要はありません。

更新履歴

1.0.0

  • 初回リリース

よくあるエラーと対処

確認すること
表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
報告数が0になる
対象期間に報告がない場合は0になります。データ取得失敗とは別です。
今週の値がまだ出ない
週報の公開後に更新されます。表示対象の週を確認してください。

注意事項・制限

  • 表示値は届出・集計された情報です。動物全体の感染数を測るものではありません。
  • 特定のペットや家庭の感染リスクを判定する機能はありません。
  • ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。

データ提供・参考資料

SHARE

この記事をシェア