下のボタンで全文(307行)が一度にコピーされます。手動で範囲選択すると途中で切れてエラーになるので、必ずボタンを使ってください。
拡張機能 → Apps Script を開く/**
* 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);
});
}