iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0

你是否曾想過將 Threads 上重要的討論串備份下來,特別是當原作者透過多則回覆來補充說明時?手動截圖或複製貼上既費時又零散。在這篇教學中,我們將延續先前安裝好的 Puppeteer 工具,打造一個自動化流程,讓你只要輸入一則 Threads 貼文網址,就能完整備份主貼文及作者的所有回覆

核心目標

我們的目標非常明確:建立一個自動化工作,當你提供一個 Threads 貼文網址時,它能抓取頁面資料,並整理成一個結構化的 JSON 檔案。

workflow

  • 新增一個流程「Create Workflow」

    image 0.png

  • 初始節點選擇「Trigger manually」

    image 1.png

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

    image 2.png

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

    • 記得 Options 的區塊要填上設定

    image 3.png

  • 程式碼填寫如下

    // --- [ 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 result = {
        text: data.post?.caption?.text || null,
        published_on: data.post?.taken_at || null,
        id: data.post?.id || null,
        pk: data.post?.pk || null,
        code: data.post?.code || null,
        username: data.post?.user?.username || null,
        user_pic: data.post?.user?.profile_pic_url || null,
        user_verified: data.post?.user?.is_verified || false,
        user_pk: data.post?.user?.pk || null,
        user_id: data.post?.user?.id || null,
        has_audio: data.post?.has_audio || false,
        reply_count: data.post?.direct_reply_count || 0,
        like_count: data.post?.like_count || 0,
        images: (data.post?.carousel_media || [])
          .map((media) => media.image_versions2?.candidates?.[1]?.url)
          .filter(Boolean),
        image_count: data.post?.carousel_media_count || 0,
        videos: (data.post?.video_versions || [])
          .map((video) => video.url)
          .filter(Boolean),
      };
    
      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);
        const threadItems = nestedLookup(data, "thread_items");
    
        if (!Array.isArray(threadItems) || threadItems.length === 0) {
          continue;
        }
    
        const allThreads = threadItems.flat().map((t) => parseThread(t));
        const mainThread = allThreads.find((t) => t.code === postCodeFromUrl);
    
        if (mainThread) {
          const authorUsername = mainThread.username;
          console.log(
            `[作者過濾] 主貼文作者為: ${authorUsername}。將只保留此作者的回覆。`
          );
    
          const replies = allThreads.filter((t) => {
            // 回覆必須滿足兩個條件:
            // 1. 它不是主貼文本身。
            // 2. 它的作者與主貼文的作者相同。
            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}`);
    }
    

程式碼說明

第 1 部分:輔助函式

為了讓主程式碼更簡潔,我們先定義兩個輔助工具函式。

  • nestedLookup(obj, key): 這個函式會在一個複雜的、巢狀的物件或陣列中,遞迴地找出所有符合指定 key 的值。由於 Threads 頁面原始資料結構複雜,這個函式能幫助我們輕鬆地撈出所需的資料區塊。

  • parseThread(data): 這個函式是我們的資料清洗器。它接收單一貼文的原始資料物件,從中提取我們感興趣的欄位(如:文字、作者、發布時間、圖片/影片連結等),並將它們整理成一個乾淨、格式化的物件。

第 2 部分:主執行邏輯

這是爬蟲的主要執行流程,從接收輸入到回傳結果。

  1. 取得並驗證網址:

    • 程式會先從輸入節點取得 url

    • 接著,它會驗證這個網址是否以 https://www.threads. 開頭,確保輸入的資料是有效的。如果網址無效,流程將會中止並拋出錯誤。

  2. 導航至目標頁面:

    • 使用 await $page.goto() 指令,讓 Puppeteer 控制的瀏覽器前往我們提供的 Threads 網址。

    • await $page.waitForSelector() 會等待頁面特定元素載入完成,確保我們在頁面準備好之後才開始抓取資料。

  3. 提取 JSON 資料:

    • 這是最關鍵的一步。現代的網頁常常將頁面初始資料儲存在 <script type="application/json"> 標籤中。相較於直接爬取畫面上的 HTML 元素,解析這些 JSON 資料更有效率且資料更完整。

    • $page.$$eval() 指令會找到所有符合條件的 script 標籤,並將其內容提取出來。

  4. 解析與過濾:

    • 定位貼文: 腳本會從網址中解析出貼文的唯一代碼 (postCode),然後走訪所有抓取到的 JSON 資料區塊,直到找到包含該代碼的區塊。

    • 提取資料: 找到目標後,使用前面定義的 nestedLookup 函式來撈出所有貼文項目 (thread_items)。

    • 篩選作者回覆:

      • 首先,腳本會找到我們的主貼文,並記錄下作者的 username

      • 接著,它會過濾所有的貼文,只保留那些 username 與主貼文作者相同,且不是主貼文本身的回覆。

      • 這個過濾步驟,確保我們只備份到「原作者」的補充說明,排除了其他人的留言。

  5. 回傳結果:

    • 最後,腳本將整理好的主貼文 (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 資料,方便後續的查閱或應用


上一篇
[Day23]_Puppeteer
系列文
告別重複瑣事: n8n workflow 自動化工作實踐24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言