【从入门到放不下系列】记一次用 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万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章