0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

muse+grok+Scriptableで自分専用ニュースまとめを作ってみた

0
Last updated at Posted at 2026-11-01

はじめに

皆さん、twitter(旧X)で時間溶かしてますか?私は1日に36時間くらいtwitterとにらめっこしています。

最新情報、特にLLMまわりは流れが速すぎて、タイムラインを眺めているだけでは追いつけませんよね。かといって自分で定期的に検索しに行くのもめんどくさい。

そこで、AIエージェント「muse」に、毎日決まった時刻に最新トピックを集めてもらい、ウィジェットにまとめてiphoneのホーム画面に表示させる仕組みを作ってみました。

当記事では、その運用のやり方と考え方を紹介します。
迷える子羊の皆さんを照らす、一筋の光になれれば幸いです。

スクリーンショット 2026-11-01 20.49.47.png
↑ちなみに完成形はこんな感じ。

全体の流れ

[定期実行 1日4回]
   │
   ├─ 1. 候補集め
   │     ・muse経由でGrokに「直近数時間でXで話題の技術トピック」を聞く
   │     ・他のSNS検索・ニュース検索でも補完
   │
   ├─ 2. 裏取り(真偽確認)
   │     ・museから各ニュースを公式発表・元の投稿・技術メディアで照合
   │     ・確認できないものは載せない or「噂・未確認」と明示
   │
   ├─ 3. 重複チェック
   │     ・既にウィジェットで配信済みのニュースを除く(続報だけ例外)
   │
   ├─ 4. 配信
   │     ・museのチャットに見出し+要点+出典リンクで投稿
   │     ・同時にJSONとしてGoogle Driveに書き出し
   │
   └─ 5. 表示
         ・Google DriveのJSONをScriptableから取得し、
           iPhoneホーム画面のウィジェットに反映

実装方法 作業手順

museへの命令

私は以下のような命令をmuseに丸投げして、情報を拾ってきてもらってます(専用のサイドチャットを作ると管理が楽です)。
有線カテゴリ、配信の回数などは好みに応じてカスタマイズしてください。

毎日4回、Twitter・その他SNSやニュースサイト、arXiv、GitHub、Hugging Face等から最新の技術トピックを集めて、
私のGoogle DriveにJSON形式で保存してください。毎回同じファイル名・同じファイルに上書きしてください。

毎回の作業ごとに、以下の点に留意してください。
①そのニュースと同じ内容を既に配信していないかどうか(重複の管理)
②その情報に信憑性があるかどうか、一次情報や出所を確認してください。
 ⇨信憑性がない噂レベルの情報には、その旨を明記してください。

以下のカテゴリのニュースを優先的に集めてください。
集めるニュースの数に上限は設けませんので、重要な情報は漏らさずキャッチするようにしてください。
逆に目立ったニュースがない場合、正直に無いと言ってください。

①ローカルLLM(新しいモデル、話題のモデル)
②クラウドLLM(新モデルのリリースなど)
③ローカルで動く画像生成AI
④Apple製品(Mac・iPhoneなど)
⑤新しい脆弱性などのセキュリティ情報
⑥その他(GPU・PCハードウェア、ガジェット、開発ツールなど)

JSONは以下の形式で出力してください。
{
  "version": 1,
  "title": "ニュースフィード名",
  "date": "YYYY-MM-DD",
  "edition": "配信回",
  "updatedAt": "YYYY-MM-DDThh:mm:ss+09:00",
  "items": [
    {
      "category": "カテゴリ",
      "title": "記事タイトル",
      "summary": "要約(1〜2行)",
      "url": "リンクURL",
      "edition": "配信回"
    }
  ]
}

私は、できれば多くの情報を仕入れたいので、取得するトピックの件数に上限は付けませんでした。

逆に、大きな動きがない時間帯は無理に埋めず、「特になし」と返すようにしています。
水増しされるくらいなら、何も返されない方が信頼できますからねー。

JSONをウィジェットに反映

Google Driveに吐き出されたJSONをScriptableから読み取って、ウィジェットに表示させる仕組みを作ります。

まだScriptable入れてないよーって方は、以下のリンクから。

DLできたらアプリを開いて、右上の+マークから新規スクリプトを追加⇨以下のJavascriptをコピペしてください。スクリプトはmuseに作ってもらいました。
Google Driveのリンクとかは各自で埋めてね。

const DATA_URL = "YOUR_GOOGLE_DRIVE_LINK";
const SCRIPT_NAME_FALLBACK = "テック最新情報";

const PRIMARY_TEXT = Color.dynamic(new Color("#1a1a1a"), new Color("#f5f5f5"));
const SECONDARY_TEXT = Color.dynamic(new Color("#666666"), new Color("#a8a8a8"));
const ACCENT = new Color("#6c5ce7");

const CATEGORY_COLORS = {
  "ローカルLLM": "#6c5ce7",
  "クラウドLLM": "#0984e3",
  "画像生成AI": "#e84393",
  "Apple": "#636e72",
  "脆弱性": "#d63031",
  "その他": "#00a087"
};

function colorFor(category) {
  const hex = CATEGORY_COLORS[category] || "#636e72";
  return new Color(hex);
}

// 戻り値: { state: "ok" | "empty" | "error", data: object | null }
// ok    = ニュースが取れた
// empty = 取れたが目立ったニュースが0件だった
// error = 取得できなかった
async function loadData() {
  try {
    const req = new Request(DATA_URL);
    req.timeoutInterval = 20;
    const text = await req.loadString();
    const data = JSON.parse(text);
    if (data && Array.isArray(data.items)) {
      return { state: data.items.length > 0 ? "ok" : "empty", data: data };
    }
    return { state: "error", data: null };
  } catch (e) {
    return { state: "error", data: null };
  }
}

function formatTime(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "";
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  return hh + ":" + mm;
}

function esc(s) {
  return String(s || "")
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function domainOf(url) {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch (e) {
    return "";
  }
}

function runURL() {
  let scriptName = SCRIPT_NAME_FALLBACK;
  try {
    const n = Script.name();
    if (n) scriptName = n;
  } catch (e) {}
  return "scriptable:///run?scriptName=" + encodeURIComponent(scriptName);
}

// ---------- 一覧ページ(HTML) ----------
function pageShell(data, innerHTML) {
  const updated = data ? formatTime(data.updatedAt) : "";
  const css = ""
    + ":root{color-scheme:light dark}"
    + "*{box-sizing:border-box}"
    + "body{margin:0;padding:20px 16px 32px;font-family:-apple-system,BlinkMacSystemFont,'Hiragino Sans',sans-serif;"
    + "background:linear-gradient(180deg,#f6f7fc 0%,#eceef7 100%);color:#1a1a1a}"
    + "@media (prefers-color-scheme: dark){body{background:linear-gradient(180deg,#171924 0%,#222636 100%);color:#f5f5f5}}"
    + "header{margin:4px 2px 16px}"
    + "h1{font-size:22px;margin:0 0 6px}"
    + ".sub{font-size:13px;opacity:.65}"
    + ".accent{height:3px;border-radius:2px;background:linear-gradient(90deg,#6c5ce7,#0984e3,#00a087);margin-top:12px}"
    + ".card{display:block;text-decoration:none;color:inherit;background:rgba(255,255,255,.85);"
    + "border-radius:16px;padding:14px 16px;margin:0 0 12px;box-shadow:0 2px 10px rgba(30,35,70,.08)}"
    + "@media (prefers-color-scheme: dark){.card{background:rgba(255,255,255,.07);box-shadow:none}}"
    + ".chip{display:inline-block;color:#fff;font-size:11px;font-weight:700;"
    + "padding:3px 9px;border-radius:999px;margin-bottom:8px}"
    + ".title{display:block;font-size:16px;font-weight:700;line-height:1.45}"
    + ".summary{display:block;font-size:13px;line-height:1.55;opacity:.75;margin-top:6px}"
    + ".meta{display:block;font-size:12px;opacity:.5;margin-top:8px}"
    + ".notice{background:rgba(255,255,255,.85);border-radius:16px;padding:22px 16px;"
    + "text-align:center;font-size:15px;font-weight:600;line-height:1.6}"
    + "@media (prefers-color-scheme: dark){.notice{background:rgba(255,255,255,.07)}}"
    + ".notice .small{display:block;font-size:12px;font-weight:400;opacity:.6;margin-top:6px}"
    + "footer{font-size:12px;opacity:.5;text-align:center;margin-top:20px}";

  const sub = data
    ? esc(data.date || "") + " " + esc(data.edition || "")
      + (updated ? " ・ " + updated + "更新" : "")
      + " ・ 全" + data.items.length + "件"
    : "";

  return "<!DOCTYPE html><html lang='ja'><head><meta charset='utf-8'>"
    + "<meta name='viewport' content='width=device-width, initial-scale=1'>"
    + "<title>テック最新情報</title><style>" + css + "</style></head><body>"
    + "<header><h1>📰 テック最新情報</h1>"
    + (sub ? "<div class='sub'>" + sub + "</div>" : "")
    + "<div class='accent'></div></header>"
    + innerHTML
    + "</body></html>";
}

function buildListHTML(result) {
  if (result.state === "error") {
    return pageShell(null,
      "<div class='notice'>ニュースを取得できませんでした"
      + "<span class='small'>通信環境を確認して、時間をおいて再度お試しください</span></div>");
  }
  if (result.state === "empty") {
    return pageShell(result.data,
      "<div class='notice'>目立ったニュースはありません"
      + "<span class='small'>次回の配信をお待ちください</span></div>"
      + "<footer>配信は毎日 8:20頃・12:20頃・17:20頃・21:20頃です</footer>");
  }
  const data = result.data;
  const cards = data.items.map(function (item) {
    const hex = CATEGORY_COLORS[item.category] || "#636e72";
    const domain = domainOf(item.url);
    const meta = [item.edition || "", domain].filter(Boolean).join(" ・ ");
    return ""
      + '<a class="card" href="' + esc(item.url) + '">'
      + '<span class="chip" style="background:' + hex + '">' + esc(item.category || "その他") + "</span>"
      + '<span class="title">' + esc(item.title) + "</span>"
      + (item.summary ? '<span class="summary">' + esc(item.summary) + "</span>" : "")
      + (meta ? '<span class="meta">' + esc(meta) + "</span>" : "")
      + "</a>";
  }).join("\n");
  return pageShell(data, cards + "<footer>カードをタップすると出典の記事・ポストが開きます</footer>");
}

async function presentListPage() {
  const result = await loadData();
  const wv = new WebView();
  await wv.loadHTML(buildListHTML(result));
  await wv.present();
}

// ---------- ウィジェット ----------
function buildWidgetBase() {
  const widget = new ListWidget();
  widget.setPadding(12, 14, 12, 14);

  const gradient = new LinearGradient();
  gradient.colors = [
    Color.dynamic(new Color("#ffffff"), new Color("#1d2030")),
    Color.dynamic(new Color("#eef1fa"), new Color("#262a3e"))
  ];
  gradient.locations = [0, 1];
  widget.backgroundGradient = gradient;
  return widget;
}

function addHeader(widget, subText) {
  const header = widget.addStack();
  header.centerAlignContent();
  const hTitle = header.addText("📰 テック最新情報");
  hTitle.font = Font.boldSystemFont(14);
  hTitle.textColor = PRIMARY_TEXT;
  header.addSpacer();
  if (subText) {
    const hSub = header.addText(subText);
    hSub.font = Font.systemFont(9);
    hSub.textColor = SECONDARY_TEXT;
  }
  widget.addSpacer(5);

  const line = widget.addStack();
  line.size = new Size(0, 2);
  line.backgroundColor = ACCENT;
  line.cornerRadius = 1;
  widget.addSpacer(9);
}

function addNotice(widget, mainText, subText) {
  const main = widget.addText(mainText);
  main.font = Font.semiboldSystemFont(12);
  main.textColor = PRIMARY_TEXT;
  main.lineLimit = 2;
  if (subText) {
    widget.addSpacer(4);
    const sub = widget.addText(subText);
    sub.font = Font.systemFont(10);
    sub.textColor = SECONDARY_TEXT;
    sub.lineLimit = 2;
  }
}

function addItem(widget, item) {
  const row = widget.addStack();
  row.topAlignContent();

  const chip = row.addStack();
  chip.backgroundColor = colorFor(item.category);
  chip.cornerRadius = 6;
  chip.setPadding(2, 6, 2, 6);
  const chipText = chip.addText(item.category || "その他");
  chipText.font = Font.boldSystemFont(8);
  chipText.textColor = Color.white();
  chipText.lineLimit = 1;

  row.addSpacer(7);

  const title = row.addText(item.title || "");
  title.font = Font.semiboldSystemFont(12);
  title.textColor = PRIMARY_TEXT;
  title.lineLimit = 2;
}

async function createWidget() {
  const result = await loadData();
  const widget = buildWidgetBase();

  if (result.state === "error") {
    addHeader(widget, "");
    addNotice(widget, "ニュースを取得できませんでした", "時間をおいて再度お試しください");
  } else if (result.state === "empty") {
    addHeader(widget, (result.data.edition || "") + " " + formatTime(result.data.updatedAt));
    addNotice(widget, "目立ったニュースはありません", "次回の配信をお待ちください");
  } else {
    const data = result.data;
    addHeader(widget, (data.edition || "") + " " + formatTime(data.updatedAt));

    let maxItems = 4;
    if (config.widgetFamily === "small") maxItems = 2;
    if (config.widgetFamily === "large") maxItems = 8;

    const items = data.items.slice(0, maxItems);
    for (let i = 0; i < items.length; i++) {
      addItem(widget, items[i]);
      if (i < items.length - 1) widget.addSpacer(8);
    }
  }

  // タップでこのスクリプトを開き、一覧ページを表示する
  widget.url = runURL();
  widget.refreshAfterDate = new Date(Date.now() + 1000 * 60 * 30);
  return widget;
}

// ---------- 実行 ----------
if (config.runsInWidget) {
  const widget = await createWidget();
  Script.setWidget(widget);
} else {
  await presentListPage();
}
Script.complete();

最後に、iphoneのホーム画面を長押しで編集モードに入ったあと、Scriptableのウィジェットを追加してください。
追加したウィジェットを長押し⇨ウィジェットを編集⇨Scriptに先ほど作ったスクリプトを指定すれば、完了‼️お疲れ様でした‼️

出力結果

スクリーンショット 2026-11-01 20.49.47.png スクリーンショット 2026-11-01 21.30.10.png
⬅️ホーム画面
ウィジェットをタッチすると記事の一覧ページに飛んで、それぞれ一次情報を確認できる➡️

重要な点‼️‼️

  • 誇張しない。未確認は未確認と書くようにしっかり指示する
  • 速報性はGrok、正確さはmuseで一次情報を探し裏取り、と役割を分ける
  • 分野と優先順位を先に決めておくと、件数が増えてもブレない

これで無駄にtwitterを徘徊しなくても、携帯を開くだけで自動でローカルLLMの情報が集まりますね。
良かったね。それじゃ、いい情報漬けライフを‼️

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?