スプシ自動更新のコード

下のボタンで全文(307行)が一度にコピーされます。手動で範囲選択すると途中で切れてエラーになるので、必ずボタンを使ってください。

コピーしました(307行)。Apps Script側は ⌘A で全選択して消してから、貼り付けて保存してください。
  1. スプレッドシートの 拡張機能 → Apps Script を開く
  2. エディタ内を ⌘A(Windowsは Ctrl+A)で全選択して削除
  3. ⌘V で貼り付けて、💾で保存
  4. スプレッドシートに戻って再読み込み → メニュー「Threads分析」→「① 初期設定」

貼り付け後、エディタの最終行が } 1つで終わっていれば全文入っています。

/**
 * Threads分析 — スプレッドシート自動更新
 *
 * これは何か:
 *   このコードをGoogleスプレッドシートに入れると、スプシが毎日自動で
 *   Threadsから投稿と数字を取ってきて、シートを更新するようになります。
 *   パソコンの電源を切っていても動きます(Googleのサーバーで動くため)。
 *
 * 使い方(初回だけ・5分):
 *   1. スプレッドシートの「拡張機能 → Apps Script」を開く
 *   2. 最初から入っているコードを全部消して、このファイルの中身を貼り付ける
 *   3. 保存(💾マーク)して、スプレッドシートに戻って再読み込み
 *   4. メニューに「Threads分析」が増えているので、「① 初期設定」を押す
 *   5. 「設定」シートができるので、B1にアクセストークンを貼る
 *   6. メニュー「② 接続テスト」→ OKが出たら「④ 毎日の自動更新を開始」
 *
 * データの扱い:
 *   トークンも数字も、あなたのGoogleアカウントの中だけに保存されます。
 *   通信相手はThreadsの公式API(graph.threads.net)だけです。
 *   このスプシを他人に共有すると設定シートのトークンも見えるので、共有しないでください。
 */

const GRAPH = "https://graph.threads.net";
const POST_FIELDS = "id,text,permalink,timestamp,is_reply,replied_to,root_post,has_replies,media_type";

/* ---------- メニュー ---------- */
function onOpen() {
  SpreadsheetApp.getUi().createMenu("Threads分析")
    .addItem("① 初期設定(シートを作る)", "setupSheets")
    .addItem("② 接続テスト", "testConnection")
    .addItem("③ 今すぐ更新", "syncNow")
    .addSeparator()
    .addItem("④ 毎日の自動更新を開始", "startDaily")
    .addItem("⑤ 自動更新を停止", "stopDaily")
    .addToUi();
}

/* ---------- 初期設定 ---------- */
function setupSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const config = ensureSheet_(ss, "設定");
  if (!config.getRange("A1").getValue()) {
    config.getRange("A1").setValue("アクセストークン →");
    config.getRange("A2").setValue("最終更新");
    config.getRange("A3").setValue("状態");
    config.getRange("A5").setValue("使い方: B1にThreadsのアクセストークンを貼って、メニューの「② 接続テスト」を押してください。");
    config.getRange("A6").setValue("注意: このスプシを共有するとトークンも見えます。自分だけで使ってください。");
    config.setColumnWidth(1, 160);
    config.setColumnWidth(2, 480);
  }
  ensureSheet_(ss, "投稿一覧");
  ensureSheet_(ss, "日別表示");
  ensureSheet_(ss, "フォロワー推移");
  ensureSheet_(ss, "リンク別クリック");
  SpreadsheetApp.getUi().alert("シートを用意しました。\n「設定」シートのB1にアクセストークンを貼ってから、「② 接続テスト」を押してください。");
}

function ensureSheet_(ss, name) {
  return ss.getSheetByName(name) || ss.insertSheet(name);
}

/* ---------- API ---------- */
function token_() {
  const t = String(SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName("設定").getRange("B1").getValue() || "").trim();
  if (!t) throw new Error("設定シートのB1にアクセストークンを貼ってください。");
  return t;
}

function apiGet_(path, params, token) {
  const q = Object.entries(Object.assign({}, params, { access_token: token }))
    .map(([k, v]) => k + "=" + encodeURIComponent(v)).join("&");
  const res = UrlFetchApp.fetch(GRAPH + path + "?" + q, { muteHttpExceptions: true });
  const json = JSON.parse(res.getContentText());
  if (json.error) throw new Error(json.error.error_user_msg || json.error.message);
  return json;
}

/* ---------- 接続テスト ---------- */
function testConnection() {
  try {
    const me = apiGet_("/v1.0/me", { fields: "id,username" }, token_());
    setStatus_("OK: @" + me.username + " につながっています");
    SpreadsheetApp.getUi().alert("つながりました: @" + me.username
      + "\n「③ 今すぐ更新」で取得を試してから、「④ 毎日の自動更新を開始」を押してください。");
  } catch (e) {
    setStatus_("エラー: " + e.message);
    SpreadsheetApp.getUi().alert("つながりませんでした。\n" + e.message);
  }
}

function setStatus_(msg) {
  const s = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("設定");
  if (s) {
    s.getRange("B2").setValue(new Date());
    s.getRange("B3").setValue(msg);
  }
}

/* ---------- トークンの自動延長(週1回)。60日切れを防ぐ ---------- */
function refreshTokenIfNeeded_() {
  const props = PropertiesService.getScriptProperties();
  const last = Number(props.getProperty("lastRefresh") || 0);
  if (Date.now() - last < 7 * 86400000) return;
  try {
    const r = apiGet_("/refresh_access_token", { grant_type: "th_refresh_token" }, token_());
    if (r.access_token) {
      SpreadsheetApp.getActiveSpreadsheet().getSheetByName("設定")
        .getRange("B1").setValue(r.access_token);
      props.setProperty("lastRefresh", String(Date.now()));
    }
  } catch (e) {
    // 発行から24時間未満のトークンは延長できない。次回にまた試す。
  }
}

/* ---------- 本体: 取得してシートを更新 ---------- */
function syncNow() {
  const token = token_();
  refreshTokenIfNeeded_();
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // 自分のユーザーID
  const me = apiGet_("/v1.0/me", { fields: "id,username" }, token);

  // 投稿一覧(ツリーの2投稿目以降は /me/replies から合流)
  const top = fetchList_("/v1.0/me/threads", token);
  const ownIds = {};
  top.forEach(function(p){ ownIds[String(p.id)] = true; });
  let children = [];
  try {
    children = fetchList_("/v1.0/me/replies", token).filter(function(r){
      const root = String((r.root_post && r.root_post.id) || "");
      return root && ownIds[root] && !ownIds[String(r.id)];
    });
  } catch (e) {}
  const raw = top.concat(children);

  // 投稿ごとの数字(1件ずつ・間を空けて。一度に投げるとAPIに弾かれる)
  const posts = raw.map(function(p, i){
    let m = {};
    try {
      const r = apiGet_("/v1.0/" + p.id + "/insights",
        { metric: "views,likes,replies,reposts,quotes" }, token);
      (r.data || []).forEach(function(item){
        const v = item.values && item.values[0] && item.values[0].value;
        const t = item.total_value && item.total_value.value;
        m[item.name] = Number(v != null ? v : (t != null ? t : 0)) || 0;
      });
    } catch (e) {}
    if (i % 5 === 4) Utilities.sleep(300);
    return {
      id: String(p.id), text: p.text || "", permalink: p.permalink || "",
      postedAt: p.timestamp || "", isReply: !!p.is_reply,
      rootId: String((p.root_post && p.root_post.id) || p.id),
      views: m.views || 0, likes: m.likes || 0, replies: m.replies || 0,
      reposts: m.reposts || 0,
    };
  });

  // ツリーにまとめて遷移率を出す(分析ツールと同じ計算)
  const groups = {};
  posts.forEach(function(p){ (groups[p.rootId] = groups[p.rootId] || []).push(p); });
  const trees = Object.keys(groups).map(function(rootId){
    const items = groups[rootId].sort(function(a, b){
      return new Date(a.postedAt || 0) - new Date(b.postedAt || 0); });
    const root = items.filter(function(p){ return !p.isReply; })[0] || items[0];
    const second = items.filter(function(p){ return p.id !== root.id; })[0] || null;
    const sum = items.reduce(function(s, p){
      s.likes += p.likes; s.replies += p.replies; s.reposts += p.reposts; return s;
    }, { likes: 0, replies: 0, reposts: 0 });
    return { root: root, items: items, sum: sum,
      carry: (second && root.views > 0) ? second.views / root.views : null,
      urls: extractUrls_(items.map(function(p){ return p.text; }).join("\n")) };
  }).sort(function(a, b){ return new Date(b.root.postedAt || 0) - new Date(a.root.postedAt || 0); });

  // 投稿一覧シートを書き換え
  const sheet = ensureSheet_(ss, "投稿一覧");
  sheet.clearContents();
  const rows = [["投稿日時", "フック(1行目)", "本文(全文)", "ツリー全文", "型",
    "表示", "遷移率(%)", "いいね", "返信", "リポスト", "リンク有", "ツリー段数", "投稿URL"]];
  trees.forEach(function(t){
    rows.push([
      t.root.postedAt ? new Date(t.root.postedAt) : "",
      flatText_((t.root.text || "").split("\n")[0]),
      flatText_(t.root.text),
      t.items.length > 1
        ? t.items.map(function(p, i){ return (i + 1) + ". " + flatText_(p.text); }).join(" | ")
        : "",
      hookType_(t.root.text),
      t.root.views,
      t.carry != null ? Number((t.carry * 100).toFixed(1)) : "",
      t.sum.likes, t.sum.replies, t.sum.reposts,
      t.urls.length ? "有" : "", t.items.length, t.root.permalink,
    ]);
  });
  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.setFrozenRows(1);
  // 全文の列は長いので幅を決めて折り返さない。セルを選べば全部読める。
  sheet.setColumnWidth(2, 260);
  sheet.setColumnWidth(3, 420);
  sheet.setColumnWidth(4, 420);
  sheet.getRange(1, 2, sheet.getLastRow(), 3)
    .setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP);

  // 日別表示(60日分を日付で上書き。開いていない日の分も入る)
  try {
    const until = Math.floor(Date.now() / 1000);
    const r = apiGet_("/v1.0/" + me.id + "/threads_insights",
      { metric: "views", since: until - 60 * 86400, until: until }, token);
    const vals = (r.data && r.data[0] && r.data[0].values) || [];
    upsertByKey_(ensureSheet_(ss, "日別表示"), ["日付", "表示回数"],
      vals.map(function(v){ return [String(v.end_time).slice(0, 10), Number(v.value) || 0]; }));
  } catch (e) {}

  // フォロワー(毎日1点ずつ貯まる。ここがスプシ自動更新のいちばんの価値)
  try {
    const r = apiGet_("/v1.0/" + me.id + "/threads_insights", { metric: "followers_count" }, token);
    let f = 0;
    (r.data || []).forEach(function(item){
      f = Number((item.total_value && item.total_value.value) || 0) || f;
    });
    const today = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd");
    upsertByKey_(ensureSheet_(ss, "フォロワー推移"), ["日付", "フォロワー数"], [[today, f]]);
  } catch (e) {}

  // リンク別クリック(累計)
  try {
    const r = apiGet_("/v1.0/" + me.id + "/threads_insights", { metric: "clicks" }, token);
    const vals = (r.data && r.data[0] && r.data[0].link_total_values) || [];
    const today = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd");
    const s = ensureSheet_(ss, "リンク別クリック");
    upsertByKey_(s, ["リンクURL", "クリック(累計)", "最終確認日"],
      vals.map(function(v){ return [v.link_url, Number(v.value) || 0, today]; }));
  } catch (e) {}

  setStatus_("OK: " + trees.length + "本の投稿を更新しました");
}

/* ---------- 自動実行の登録 ---------- */
function startDaily() {
  stopDaily();
  ScriptApp.newTrigger("syncNow").timeBased().everyDays(1).atHour(6).create();
  SpreadsheetApp.getUi().alert("毎朝6時ごろの自動更新を開始しました。\nパソコンを閉じていても動きます。止めたい時は「⑤ 自動更新を停止」を押してください。");
}

function stopDaily() {
  ScriptApp.getProjectTriggers().forEach(function(t){
    if (t.getHandlerFunction() === "syncNow") ScriptApp.deleteTrigger(t);
  });
}

/* ---------- 部品 ---------- */
function fetchList_(path, token) {
  const out = [];
  let after = null;
  for (let page = 0; page < 3; page++) {
    const params = { fields: POST_FIELDS, limit: 100 };
    if (after) params.after = after;
    const res = apiGet_(path, params, token);
    (res.data || []).forEach(function(p){ out.push(p); });
    after = res.paging && res.paging.cursors && res.paging.cursors.after;
    if (!after || !(res.data || []).length) break;
  }
  return out;
}

function extractUrls_(text) {
  return (text || "").match(/https?:\/\/[^\s "'<>()()「」【】]+/g) || [];
}

// セル内の改行はスプシで行が崩れるので、1行に均してから入れる。
function flatText_(s) {
  return String(s || "").replace(/\s*\n+\s*/g, " ").trim();
}

function hookType_(text) {
  const line = (text || "").split("\n")[0].slice(0, 60);
  if (/実は|本当は|誰も|知らない|バレ|裏側|知ってました/.test(line)) return "意外性型";
  if (/危険|注意|NG|ダメ|やめて|やめた方|禁止|逆効果|間違い|失敗|しないで|ないと/.test(line)) return "警告型";
  if (/[??]\s*$|ますか|ですか|でしょうか/.test(line)) return "疑問型";
  if (/人へ|人は必見|人集合|方へ|あなた|さん、|全員/.test(line)) return "呼びかけ型";
  if (/[0-90-9]+(つ|個|選|割|%|%|倍|日|分|円|位|歳|代)/.test(line)) return "数字型";
  if (/です。|ます。|です$|ます$/.test(line)) return "断定型";
  return "その他";
}

// 1列目をキーに上書き・追記する(日付やURLの重複行を作らない)
function upsertByKey_(sheet, header, rows) {
  if (!sheet.getLastRow()) {
    sheet.getRange(1, 1, 1, header.length).setValues([header]);
    sheet.setFrozenRows(1);
  }
  const last = sheet.getLastRow();
  const existing = {};
  if (last > 1) {
    sheet.getRange(2, 1, last - 1, 1).getValues().forEach(function(r, i){
      existing[String(r[0])] = i + 2;
    });
  }
  rows.forEach(function(row){
    const at = existing[String(row[0])];
    if (at) sheet.getRange(at, 1, 1, row.length).setValues([row]);
    else sheet.appendRow(row);
  });
}