你是否曾想過將 Threads 上重要的討論串備份下來,特別是當原作者透過多則回覆來補充說明時?手動截圖或複製貼上既費時又零散。在這篇教學中,我們將延續先前安裝好的 Puppeteer 工具,打造一個自動化流程,讓你只要輸入一則 Threads 貼文網址,就能完整備份主貼文及作者的所有回覆
我們的目標非常明確:建立一個自動化工作,當你提供一個 Threads 貼文網址時,它能抓取頁面資料,並整理成一個結構化的 JSON 檔案。
新增一個流程「Create Workflow」

初始節點選擇「Trigger manually」

下個節點選擇「Edit Fields」,欄位設定為「url」,內容可以隨便填寫想抓的 Threads 網址

下個節點選擇「Puppeteer」的「Run Custom Script」

程式碼填寫如下
  // --- [ 0. 輔助函式定義 ] ---
  /**
   * 在巢狀的物件或陣列中遞迴尋找指定鍵的所有值
   * @param {object} obj - 要搜尋的物件或陣列
   * @param {string} key - 要尋找的鍵
   * @returns {unknown[]} - 找到的值的陣列,其元素的具體類型不確定
   */
  function nestedLookup(obj, key) {
    let results = [];
    if (typeof obj !== "object" || obj === null) {
      return results;
    }
    if (Array.isArray(obj)) {
      for (const item of obj) {
        results = results.concat(nestedLookup(item, key));
      }
    } else {
      for (const k in obj) {
        if (k === key) {
          results.push(obj[k]);
        }
        if (typeof obj[k] === "object") {
          results = results.concat(nestedLookup(obj[k], key));
        }
      }
    }
    return results;
  }
  /**
   * 解析 Threads 貼文的 JSON 資料集,提取重要欄位
   * @param {object} data - 單一貼文的資料物件
   * @returns {object} - 格式化後的貼文物件
   */
  function parseThread(data) {
      const post = data.post;
      if (!post) return null;
      const mediaType = post.media_type;
      let images = [];
      let videos = [];
      let imageCount = 0;
      let videoCount = 0;
      let videoThumbnail = null;
      // 處理單一影片 (mediaType === 2)
      if (mediaType === 2 && Array.isArray(post.video_versions) && post.video_versions.length > 0) {
          // 找出最高畫質的影片 (通常是 width 最大)
          const highestResVideo = post.video_versions.reduce(
              (max, current) => (current.width > max.width ? current : max),
              post.video_versions[0]
          );
          if (highestResVideo && highestResVideo.url) {
              videos.push(highestResVideo.url);
              videoCount = 1;
          }
          // 同時抓取影片的封面圖 (通常是第一張最高畫質)
          if (post.image_versions2?.candidates?.length > 0) {
              videoThumbnail = post.image_versions2.candidates[0].url;
          }
      // 處理單張圖片 (mediaType === 1)
      } else if (mediaType === 1 && Array.isArray(post.image_versions2?.candidates) && post.image_versions2.candidates.length > 0) {
          const highestResImage = post.image_versions2.candidates.reduce(
              (max, current) => (current.width > max.width ? current : max),
              post.image_versions2.candidates[0]
          );
          if (highestResImage && highestResImage.url) {
              images.push(highestResImage.url);
              imageCount = 1;
          }
      // 處理輪播 (mediaType === 8)
      } else if (mediaType === 8 && Array.isArray(post.carousel_media) && post.carousel_media.length > 0) {
          const carouselMedia = post.carousel_media;
          images = carouselMedia
              .filter(media => media.media_type === 1 && media.image_versions2?.candidates?.length > 0)
              .map(media => media.image_versions2.candidates[0].url) // 直接取最高畫質
              .filter(Boolean);
          videos = carouselMedia
              .filter(media => media.media_type === 2 && media.video_versions?.length > 0)
              .map(media => media.video_versions[0].url) // 直接取最高畫質
              .filter(Boolean);
          imageCount = images.length;
          videoCount = videos.length;
      }
      const result = {
          text: post.caption?.text || null,
          published_on: post.taken_at || null,
          id: post.id || null,
          pk: post.pk || null,
          code: post.code || null,
          username: post.user?.username || null,
          user_pic: post.user?.profile_pic_url || null,
          user_verified: post.user?.is_verified || false,
          user_pk: post.user?.pk || null,
          user_id: post.user?.id || null,
          has_audio: post.has_audio || false,
          reply_count: post.direct_reply_count || 0,
          like_count: post.like_count || 0,
          images: images,
          image_count: imageCount,
          videos: videos,
          video_count: videoCount,
          video_thumbnail: videoThumbnail // 新增影片封面圖欄位
      };
      if (result.username && result.code) {
          result.url = `https://www.threads.net/@${result.username}/post/${result.code}`;
      } else {
          result.url = null;
      }
      return result;
  }
  // --- [ 1. 主執行邏輯 ] ---
  const inputData = $input.item.json;
  const postUrl = inputData.url;
  // 輸入驗證
  if (!postUrl || !postUrl.startsWith("https://www.threads.")) {
      console.error("錯誤:未提供有效的 Threads 網址。應以 https://www.threads. 開頭。");
      throw new Error("無效的 Threads 網址,流程中止");
  }
  console.log(`[開始抓取] 目標網址: ${postUrl}`);
  try {
      const postCodeFromUrl = postUrl.split("/post/")[1]?.split("/")[0];
      if (!postCodeFromUrl) {
          throw new Error("無法從 URL 中解析出貼文代碼。");
      }
      console.log(`[目標鎖定] 貼文代碼: ${postCodeFromUrl}`);
      // --- [ 2. 導航並等待頁面載入 ] ---
      await $page.goto(postUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
      console.log("[頁面導航] 成功。");
      await $page.waitForSelector("[data-pressable-container=true]", { timeout: 20000 });
      console.log("[內容等待] 偵測到應用程式容器,開始提取資料");
      // --- [ 3. 提取並解析內嵌 JSON 資料 ] ---
      const hiddenDatasets = await $page.$$eval(
          'script[type="application/json"][data-sjs]',
          (scripts) => scripts.map((s) => s.textContent)
      );
      console.log(`[資料提取] 找到 ${hiddenDatasets.length} 個 JSON 資料區塊,開始過濾...`);
      for (const hiddenDataset of hiddenDatasets) {
          if (!hiddenDataset.includes(`"code":"${postCodeFromUrl}"`)) {
              continue;
          }
          console.log('[資料過濾] 找到包含目標貼文代碼的資料區塊!');
          const data = JSON.parse(hiddenDataset);
          // 直接從預期的路徑尋找
          let threadItems = [];
          try {
              // 嘗試從主要的資料路徑獲取 thread_items
              const mainDataPath = data.require[0][3][0].__bbox.result.data.data.edges[0].node;
              if (mainDataPath && mainDataPath.thread_items) {
                   threadItems = mainDataPath.thread_items;
                   console.log("[資料定位] 成功透過主要路徑找到 thread_items。");
              } else {
                   throw new Error("主要路徑中未找到 thread_items");
              }
          } catch (e) {
              // 如果主要路徑解析失敗,退回使用 nestedLookup 作為備用方案
              console.warn(`[資料定位] 主要路徑解析失敗 (${e.message}),退回使用 nestedLookup 備用方案。`);
              const lookupResult = nestedLookup(data, "thread_items");
              threadItems = lookupResult.flat();
          }
          if (!Array.isArray(threadItems) || threadItems.length === 0) {
              continue;
          }
          const allThreads = threadItems.map((t) => parseThread(t)).filter(Boolean);
          const mainThread = allThreads.find((t) => t.code === postCodeFromUrl);
          if (mainThread) {
              const authorUsername = mainThread.username;
              console.log(`[作者過濾] 主貼文作者為: ${authorUsername}。將只保留此作者的回覆。`);
              const replies = allThreads.filter((t) => {
                  return t.code !== postCodeFromUrl && t.username === authorUsername;
              });
              console.log(`[解析成功] 精準定位到主貼文 (${mainThread.code}),找到 ${replies.length} 則來自原作者的回覆。`);
              const result = {
                  thread: mainThread,
                  replies: replies,
              };
              // --- [ 4. 格式化並回傳結果 ] ---
              return [{
                  json: {
                      ...inputData,
                      ...result
                  }
              }];
          }
      }
      throw new Error("無法在頁面中找到目標貼文的資料。可能是貼文不存在,或頁面結構已變更");
  } catch (error) {
      console.error(`[抓取失敗] 在處理 ${postUrl} 時發生錯誤:`, error.message);
      throw new Error(`抓取失敗: ${error.message}`);
  }
為了讓主程式碼更簡潔,我們先定義兩個輔助工具函式。
nestedLookup(obj, key): 這個函式會在一個複雜的、巢狀的物件或陣列中,遞迴地找出所有符合指定 key 的值。由於 Threads 頁面原始資料結構複雜,這個函式能幫助我們輕鬆地撈出所需的資料區塊。
parseThread(data): 這個函式是我們的資料清洗器。它接收單一貼文的原始資料物件,從中提取我們感興趣的欄位(如:文字、作者、發布時間、圖片/影片連結等),並將它們整理成一個乾淨、格式化的物件。
這是爬蟲的主要執行流程,從接收輸入到回傳結果。
取得並驗證網址:
程式會先從輸入節點取得 url。
接著,它會驗證這個網址是否以 https://www.threads. 開頭,確保輸入的資料是有效的。如果網址無效,流程將會中止並拋出錯誤。
導航至目標頁面:
使用 await $page.goto() 指令,讓 Puppeteer 控制的瀏覽器前往我們提供的 Threads 網址。
await $page.waitForSelector() 會等待頁面特定元素載入完成,確保我們在頁面準備好之後才開始抓取資料。
提取 JSON 資料:
這是最關鍵的一步。現代的網頁常常將頁面初始資料儲存在 <script type="application/json"> 標籤中。相較於直接爬取畫面上的 HTML 元素,解析這些 JSON 資料更有效率且資料更完整。
$page.$$eval() 指令會找到所有符合條件的 script 標籤,並將其內容提取出來。
解析與過濾:
定位貼文: 腳本會從網址中解析出貼文的唯一代碼 (postCode),然後走訪所有抓取到的 JSON 資料區塊,直到找到包含該代碼的區塊。
提取資料: 找到目標後,使用前面定義的 nestedLookup 函式來撈出所有貼文項目 (thread_items)。
篩選作者回覆:
首先,腳本會找到我們的主貼文,並記錄下作者的 username。
接著,它會過濾所有的貼文,只保留那些 username 與主貼文作者相同,且不是主貼文本身的回覆。
這個過濾步驟,確保我們只備份到「原作者」的補充說明,排除了其他人的留言。
回傳結果:
最後,腳本將整理好的主貼文 (thread) 和作者回覆串 (replies) 組合成一個物件。
這個物件會被回傳到下一個節點,完成我們的備份任務
最後會輸出類似這樣的結構資料
{
  "url": "https://www.threads.net/...",
  "thread": {
    "text": "這是主貼文的內容...",
    "published_on": 1718712000,
    "username": "user",
    "images": ["url1.jpg", "url2.jpg"],
    "videos": []
  },
  "replies": [
    {
      "text": "這是作者的第 1 則回覆...",
      "username": "user"
    },
    {
      "text": "這是作者的第 2 則補充說明...",
      "username": "user"
    }
  ]
}
透過這個自動化流程,你現在有了一個強大的工具,可以輕鬆地將任何 Threads 貼文及其作者的完整回覆,保存為一份結構化的 JSON 資料,方便後續的查閱或應用