第 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 授權頁 → 同意。

順利的話會看到「✅ 連接成功」。

Threads 授權同意畫面,列出應用程式要求的兩項權限,底部是「以你的帳戶身分繼續」按鈕

4. 確認

回試算表,選 Threads → 測試連線。右下角應該出現:

✓ 已連接:@你的帳戶名

常見卡關

訊息 / 現象原因
redirect_uri 不符Meta 登記的網址與 /exec 不完全一致。逐字比對,注意結尾有沒有多餘斜線
授權頁顯示無法使用測試者邀請未接受。回第 2 章第 5 步
停在「這是回呼網址」你直接開了 /exec。要由選單發起
state 不符你用了舊的授權連結。重新由選單發起
換取權杖失敗App secret 錯,或未重新部署

這一章完成了什麼

  • ✅ 長期權杖(約 60 天)已存入 Script Properties
  • ✅ 你的 Threads user ID 已記下
  • ✅ 「測試連線」可以確認狀態

權杖只存在你自己的 Apps Script 專案裡,不會經過任何第三方。

下一章開始真正發帖。

步驟最後驗證於 2026年7月20日