第 4 章
接通 Threads:授權與長期權杖
走一次 OAuth 授權,把回傳的驗證碼換成短期權杖,再換成可用約 60 天的長期權杖。
現在把試算表和你的 Threads 帳戶接起來。流程是標準的 OAuth:
你按「連接 Threads」
↓ 跳去 Threads 授權頁
你按「同意」
↓ Threads 帶著 code 轉回你的 /exec 網址
doGet 收到 code
↓ 換取短期權杖(約 1 小時)
↓ 再換取長期權杖(約 60 天)
存入 Script Properties
要換兩次,是因為 Threads 只在第一步發出短期權杖;排程用的必須是長期權杖。
1. 加入 OAuth 程式碼
在 Code.gs 貼上以下全部內容,接在第 3 章那段之後(把原本那個空的 doGet 刪掉,用這裡的版本):
/* ========================= 選單 ========================= */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("Threads")
.addItem("連接 Threads", "connectThreads")
.addItem("測試連線", "testConnection")
.addToUi();
}
/* ========================= 設定 ========================= */
function props_() {
return PropertiesService.getScriptProperties();
}
function config_() {
var p = props_();
var appId = p.getProperty("APP_ID");
var appSecret = p.getProperty("APP_SECRET");
if (!appId || !appSecret) {
throw new Error("尚未設定 APP_ID / APP_SECRET,請先執行 saveConfig()。");
}
return { appId: appId, appSecret: appSecret, props: p };
}
/* ====================== OAuth 授權 ====================== */
/**
* 顯示授權連結。因為 Apps Script 的對話框不能直接跳轉,
* 所以我們顯示一條連結,讓你自己按。
*/
function connectThreads() {
var cfg = config_();
var redirectUri = ScriptApp.getService().getUrl();
// state 用來確認轉回來的請求真的來自我們發起的那一次。
var state = Utilities.getUuid();
cfg.props.setProperty("OAUTH_STATE", state);
var scope = "threads_basic,threads_content_publish,threads_manage_insights";
var url =
"https://threads.net/oauth/authorize" +
"?client_id=" + encodeURIComponent(cfg.appId) +
"&redirect_uri=" + encodeURIComponent(redirectUri) +
"&scope=" + encodeURIComponent(scope) +
"&response_type=code" +
"&state=" + encodeURIComponent(state);
var html = HtmlService.createHtmlOutput(
'<p style="font:14px/1.6 sans-serif">按下面的連結完成授權:</p>' +
'<p><a href="' + url + '" target="_blank" rel="noopener" ' +
'style="font:600 15px sans-serif">前往 Threads 授權 →</a></p>' +
'<p style="font:13px/1.6 sans-serif;color:#666">授權後會看到「連接成功」,' +
'然後就可以關掉分頁,回來按「測試連線」。</p>'
).setWidth(420).setHeight(220);
SpreadsheetApp.getUi().showModalDialog(html, "連接 Threads");
}
/** Threads 授權後會轉回這裡,帶著 code。 */
function doGet(e) {
var out = function (msg) {
return HtmlService.createHtmlOutput(
'<p style="font:15px/1.7 sans-serif;padding:24px">' + msg + "</p>"
);
};
try {
var params = (e && e.parameter) || {};
if (params.error) {
return out("❌ 授權被拒絕:" + params.error_description || params.error);
}
if (!params.code) {
return out("這是 Threads 發帖器的回呼網址。請從試算表選單開始連接。");
}
var cfg = config_();
var expected = cfg.props.getProperty("OAUTH_STATE");
if (!expected || params.state !== expected) {
return out("❌ state 不符,為安全起見已中止。請重新由選單發起授權。");
}
cfg.props.deleteProperty("OAUTH_STATE");
var token = exchangeCode_(cfg, params.code);
var longLived = exchangeLongLived_(cfg, token.access_token);
cfg.props.setProperty("ACCESS_TOKEN", longLived.access_token);
cfg.props.setProperty("TOKEN_AT", String(Date.now()));
cfg.props.setProperty("USER_ID", String(token.user_id));
return out("✅ 連接成功!可以關掉這個分頁,回試算表按「測試連線」。");
} catch (err) {
return out("❌ 出錯:" + err.message);
}
}
/** code → 短期權杖(約 1 小時)。 */
function exchangeCode_(cfg, code) {
var res = UrlFetchApp.fetch("https://graph.threads.net/oauth/access_token", {
method: "post",
muteHttpExceptions: true,
payload: {
client_id: cfg.appId,
client_secret: cfg.appSecret,
grant_type: "authorization_code",
redirect_uri: ScriptApp.getService().getUrl(),
code: code,
},
});
var data = JSON.parse(res.getContentText() || "{}");
if (!data.access_token) {
throw new Error("換取短期權杖失敗:" + res.getContentText().slice(0, 200));
}
return data;
}
/** 短期權杖 → 長期權杖(約 60 天)。 */
function exchangeLongLived_(cfg, shortToken) {
var url =
"https://graph.threads.net/access_token" +
"?grant_type=th_exchange_token" +
"&client_secret=" + encodeURIComponent(cfg.appSecret) +
"&access_token=" + encodeURIComponent(shortToken);
var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
var data = JSON.parse(res.getContentText() || "{}");
if (!data.access_token) {
throw new Error("換取長期權杖失敗:" + res.getContentText().slice(0, 200));
}
return data;
}
/* ====================== 測試連線 ====================== */
function testConnection() {
var p = props_();
var token = p.getProperty("ACCESS_TOKEN");
if (!token) {
SpreadsheetApp.getActiveSpreadsheet().toast("尚未連接,請先按「連接 Threads」。");
return;
}
var res = UrlFetchApp.fetch(
API + "/me?fields=id,username&access_token=" + encodeURIComponent(token),
{ muteHttpExceptions: true }
);
var data = JSON.parse(res.getContentText() || "{}");
var msg = data.username
? "✓ 已連接:@" + data.username
: "✗ " + res.getContentText().slice(0, 160);
SpreadsheetApp.getActiveSpreadsheet().toast(msg, "Threads", 8);
}
2. 重新部署
改了程式碼之後,必須重新部署,否則 /exec 仍然執行舊版本:
部署 → 管理部署作業 → ✏️ → 版本:新版本 → 部署
再強調一次:用編輯現有部署,不要新增。新增會產生新網址,Meta 那邊就對不上。
3. 授權
回到試算表,重新整理頁面(選單要重新載入才會出現),然後選 Threads → 連接 Threads。
按對話框裡的連結 → Threads 授權頁 → 同意。
順利的話會看到「✅ 連接成功」。

4. 確認
回試算表,選 Threads → 測試連線。右下角應該出現:
✓ 已連接:@你的帳戶名
常見卡關
| 訊息 / 現象 | 原因 |
|---|---|
redirect_uri 不符 | Meta 登記的網址與 /exec 不完全一致。逐字比對,注意結尾有沒有多餘斜線 |
| 授權頁顯示無法使用 | 測試者邀請未接受。回第 2 章第 5 步 |
| 停在「這是回呼網址」 | 你直接開了 /exec。要由選單發起 |
state 不符 | 你用了舊的授權連結。重新由選單發起 |
| 換取權杖失敗 | App secret 錯,或未重新部署 |
這一章完成了什麼
- ✅ 長期權杖(約 60 天)已存入 Script Properties
- ✅ 你的 Threads user ID 已記下
- ✅ 「測試連線」可以確認狀態
權杖只存在你自己的 Apps Script 專案裡,不會經過任何第三方。
下一章開始真正發帖。
步驟最後驗證於 2026年7月20日