【從入門到放不下系列】記一次用 puppeteer 爬數據的歷程(下)—— 爬取搜狗微信搜索的公衆號文章

上回書說到,使用 puppeteer 庫做了截圖操作簡單展示了 puppeteer 的特性和基本使用方法。這回我們來說說用 puppeteer 做爬取頁面數據並寫入 Excel 的操作。

puppeteer 實戰:爬取搜狗微信搜索的公衆號文章並寫入 Excel

首先背景:搜狗搜索現在有個功能可以根據關鍵字來查詢微信公衆號文章,而我們要做的就是把指定關鍵字的文章給它爬下來,由於這個需求是從我司業務部門那邊來的,所以爲了方便運營同學查看,還需要把爬到的數據放到放到 Excel 中去。

使用完的感受是,這個東西只要讀懂 API,其實並不複雜。個人覺得複雜的的地方在於需要考慮各種 base case。
首先第一個一般的網站都做了防爬處理,我們需要苦旅通過合理的方式繞過。比如搜狗搜索這個就是,剛開始我計劃通過讀取到 a 標籤的 href 屬性,然後直接在瀏覽器中打開這個鏈接。但嘗試後發現,並能這樣操作,估計是搜狗做了什麼防爬操作,這樣做會報 IP 異常。遂放棄,使用 puppeteer 的 page.click 方法解決了這個問題,因爲實質上 puppeteer 是通過模擬真實用戶的點擊操作來實現的,所以並不會受防爬機制的影響。
另外一個就是因爲爬取的是微信文章,所以還要考慮微信文章本身的一些情況,比如說文章被髮布者刪除或遷移、文章被舉報,或者這篇文章時分享另外一篇文章等等情況…

總之,感覺用 puppeteer 寫的爬蟲腳本都是和業務強耦合的,所以個人覺得代碼本身並沒有什麼參考意義。但是我再編寫過程中遇到的各種奇葩問題還是可以看看,也許能爲解決你遇到的問題提供一點思路。

完整代碼如下:

const cheerio = require("cheerio");
const puppeteer = require("puppeteer");
const company = require("./company.json");
const keywords = require("./keywords.json");
const { exportExcel } = require("./export");

// 獲取不通關鍵字搜索結果頁面 url
function getUrl(queryWords, currPage = 1) {
  return `https://weixin.sogou.com/weixin?type=2&s_from=input&query=${queryWords}&page=${currPage}`;
}
(async function() {
  const width = 1024;
  const height = 1600;
  // 創建一個瀏覽器實例
  const browser = await puppeteer.launch({
    // 關閉 Chrome 無頭(無界面)模式
    headless: false,
    // 顧名思義,設置默認視窗寬高
    defaultViewport: { width: width, height: height }
  });
  async function sougouSpiders(keywords) {
    // 新建一個頁面(瀏覽器 Tab 頁)
    const page = await browser.newPage();
    // 設置頁面等待超時時間,可以稍微長一點,避免因網絡原因導致拋錯
    page.setDefaultNavigationTimeout(90000);
    // 設置新開 Tab 頁的寬高
    await page.setViewport({ width: width, height: height });
    let currSpiderPage = 1;
    // 在新開的 Tab 頁中打開某一個關鍵字的 url
    await page.goto(getUrl(keywords, currSpiderPage), {
      withUntil: "networkidle2"
    });
    // 拿到頁面內容
    let firstPageHtml = await page.content();
    // 獲取 cheerio 實例
    let $ = await cheerio.load(firstPageHtml);
    const is404 = $(".main-left")
      .eq(0)
      .text()
      .includes("沒有找到相關的微信公衆號文章。");
    // 考慮可能會有關鍵字查詢無果的情況
    if (is404) {
      console.log("當前關鍵字無檢索結果!");
      // 如果關鍵字查詢無果,則關閉這個頁面
      await page.close();
    } else {
      // 根據分頁組件的值讀取到當前關鍵字的檢索結果共有多少頁
      const totalPage =
        Array.from($(".p-fy a")).length === 0
          ? 1
          : Array.from($(".p-fy a")).length;
      // 10 頁之後的數據實際無使用價值,所以判斷如果檢索結果超過 10 頁則只取 10 頁
      const reallyTotalPage = totalPage >= 10 ? 10 : totalPage;
      // 在控制檯打印一下信息,便於排錯
      console.log(
        `當前關鍵字:${keywords}  成功獲取當前關鍵字總頁數:${reallyTotalPage}`
      );

      const result = [];
      // 上面的操作實際上只是爲了知道頁面的檢索結果相關信息,真實的爬取操作是在下面這個函數中
      async function getData(currSpiderPage) {
        console.log("正在爬取頁:", currSpiderPage);
        await page.goto(getUrl(keywords, currSpiderPage), {
          withUntil: "networkidle2"
        });

        let firstPageHtml = await page.content();
        let $ = cheerio.load(firstPageHtml);

        const itemLen = $(".news-box .news-list").find("li").length;
        for (let j = 0; j < itemLen; j++) {
          const currText = $(".news-box .news-list")
            .find("li")
            .eq(j)
            .find(".txt-box")
            .eq(0);
          const currLinkBtnId = currText
            .find("h3 a")
            .eq(0)
            .attr("id");
          console.log(
            `正在爬取關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
              1} 條,當前頁共 ${itemLen} 條,共 ${reallyTotalPage} 頁,時間:${new Date()}`
          );
          // 有些 item 沒有圖片導致 img-box 被隱藏了,所以需要先把這個節點顯示出來,防止點擊失效
          await page.addStyleTag({
            content:
              ".img-box{ border:10px solid red; display:block!important }"
          });
          // 獲取到 link 節點
          const linkBtn = await page.$(`#sogou_vr_11002601_img_${j}`);
          // 此處的判斷是因爲在實際的爬取過程中發現有些 item 無法點擊
          if (linkBtn) {
            await page.click(`#sogou_vr_11002601_img_${j}`);
          } else {
            await page.click(`#sogou_vr_11002601_title_${j}`);
          }

          try {
            let currUrl = null;
            // 監聽當前頁面 url 的變化,如果出現了 mp.weixin.qq.com 則說明文章被正常打開
            const newTarget = await browser.waitForTarget(t => {
              currUrl = t.url();
              return t.url().includes("mp.weixin.qq.com");
            });
            // 拿到新打開的文章詳情頁實例
            const newPage = await newTarget.page();
            try {
              const title = await newPage.waitForSelector("#activity-name");
              // 如果能夠讀取到文章標題
              if (title) {
                // 讀取頁面內容父容器節點
                await newPage.waitForSelector("#js_content");
                const newPageContent = await newPage.content();
                let jq = await cheerio.load(newPageContent);
                // 先去遍歷一下所有的圖片,如果有圖片,就在圖片後面追擊一下該圖片的 url
                await jq("#js_content img").each((idx, curr) => {
                  jq(curr).after(
                    `<div>
                     【注意了這裏有張圖片,爬取到的 url 是:${$(curr).attr(
                       "src"
                     )}】
                    </div>`
                  );
                });
                // 完成上面的操作後,讀取當前文章的詳細內容
                const text = await jq("#js_content").text();
                // 讀取完成,將當前文章的所有信息寫入數組中去
                result.push({
                  title: $(".news-box .news-list li .txt-box h3 a")
                    .eq(j)
                    .text(),
                  url: currUrl,
                  account: currText
                    .find(".account")
                    .eq(0)
                    .text(),
                  publishTime: currText
                    .find(".s2")
                    .eq(0)
                    .text(),
                  content: text
                });
                // 當前文章爬取完成,關閉頁面
                await newPage.close();
              }
            } catch (e) {
              // 如果上面的操作拋錯,則說明當前文章有問題(實際爬取過程中發現,有些文章鏈接能夠打開,但頁面內容提示已被髮布者刪除或遷移,或者是被舉報了。)
              console.log("這篇文章掛了");
              // 如果這篇文章出錯,則仍然放到 result 中計個數
              result.push({
                title: $("#" + currLinkBtnId).text(),
                url: `關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
                  1} 條`,
                account: currText
                  .find(".account")
                  .eq(0)
                  .text(),
                publishTime: currText
                  .find(".s2")
                  .eq(0)
                  .text(),
                content: "這篇文章已被髮布者刪除或遷移,或者是被舉報了"
              });
              // 完成上述操作,關閉頁面
              await newPage.close();
            }
          } catch (err) {
            // 如果是超時錯誤,則關掉當前當前頁面,繼續下一個
            if (err.toString && err.toString().includes("TimeoutError")) {
              const pagesLen = (await browser.pages()).length;
              (await browser.pages())[pagesLen - 1].close();
            }
            console.log("=====!!!!!!出!錯!!了!!!========", err);
            // 如果這篇文章鏈接沒有打開,則放到 result 中計個數
            result.push({
              title: $("#" + currLinkBtnId).text(),
              url: `關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
                1} 條`,
              account: currText
                .find(".account")
                .eq(0)
                .text(),
              publishTime: currText
                .find(".s2")
                .eq(0)
                .text(),
              content: "這篇文章pa出問題了,手動搜索一下吧~"
            });
          }
        }
      }
      for (let i = 0; i <= totalPage; i++) {
        if (currSpiderPage <= totalPage) {
          await getData(currSpiderPage);
          currSpiderPage++;
        }
      }
      console.log(`關鍵字:【${keywords}】,爬取完成正在寫入Excel!`);
      // 寫入 Excel
      await exportExcel(keywords, result);
      // 關閉頁面
      await page.close();
    }
  }

  const companyList = company.data;
  const keywordList = keywords.data;
  // 循環操作
  for (let i = 0; i < companyList.length; i++) {
    for (let j = 0; j < keywordList.length; j++) {
      await sougouSpiders(
        `${companyList[i].companyName} ${keywordList[j].join(" ")}`
      );
    }
  }

  console.log("爬完了");
})();

exportExcel導出Excel:

const fs = require("fs");
const xlsx = require("node-xlsx");

module.exports = {
  async exportExcel(fileName, data) {
    let dataArr = [];
    let title = ["文章標題", "文章URL", "作者名(公衆號)", "發佈日期", "內容"];
    dataArr.push(title);
    data.forEach(curr => {
      dataArr.push([
        curr.title,
        curr.url,
        curr.account,
        curr.publishTime,
        curr.content
      ]);
    });
    const options = {
      "!cols": [
        { wch: 70 },
        { wch: 100 },
        { wch: 30 },
        { wch: 30 },
        { wch: 200 }
      ]
    };

    // 寫xlsx
    var buffer = await xlsx.build(
      [
        {
          name: "sheet1",
          data: dataArr
        }
      ],
      options
    );
    await fs.writeFile(`./dist/data1【${fileName}】.xlsx`, buffer, function(
      err
    ) {
      if (err) throw err;
      console.log("寫入成功!");
    });
  }
};

發佈了9 篇原創文章 · 獲贊 27 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章