前端爬虫实践

407 阅读11分钟

作者:Rui

什么是爬虫

爬虫,即网络爬虫,也叫做网络机器人,可以代替人们自动地在互联网中进行数据信息的采集与整理。简单来讲,爬虫就是一个探测机器,它的基本操作就是模拟人的行为去各个网站溜达,点点按钮,查查数据,或者把看到的信息背回来。

爬虫的合法性

爬虫作为一种计算机技术本身是中立的,因此爬虫本身在法律上并不被禁止。但是,利用爬虫技术获取数据这一行为是具有违法甚至是犯罪的风险的。

在使用爬虫技术时,应当遵守相关法律法规和道德准则。例如,应当遵守网站的 Robots 协议,不要爬取被禁止访问的内容;不要过度频繁地请求数据,以免对目标网站造成负担;不要爬取和使用他人的个人信息、商业数据和知识产权数据等。

Robots 协议,是一种用于告诉网络爬虫和其他网络机器人哪些部分的网站它们可以访问的标准。它依赖于自愿遵守。并非所有机器人都遵守该标准;电子邮件收集器、垃圾邮件机器人、恶意软件和扫描安全漏洞的机器人甚至可能从被告知不得进入的网站部分开始。

网站所有者可以在网站根目录中放置一个名为 robots.txt 的文本文件来给网络机器人提供指示。这个文本文件包含特定格式的指示。选择遵守这些指示的机器人会在从网站获取任何其他文件之前尝试获取此文件并阅读其中的指示。如果此文件不存在,网络机器人会认为网站所有者不希望对爬取整个站点施加任何限制。

爬虫使用现状

爬虫也分善恶。

像谷歌等搜索引擎的爬虫,每隔几天对全网的网页扫一遍,供大家查阅,各个被扫的网站大都很开心。这种就被定义为「善意爬虫」。

但是,像抢票软件这样的爬虫,对着 12306 每秒钟恨不得撸几万次。这种就被定义为「恶意爬虫」。

img

爬虫重灾区:出行、社交、电商。

就12306来说,每年过年之前,12306都要承受庞大的访问量。公开数据是这么说的:最高峰时 1 天内页面浏览量达 813.4 亿次,1 小时最高点击量 59.3 亿次,平均每秒 164.8 万次。这还是加上验证码防护之后的数据,可想而知被拦截在外面的爬虫还有多少。

社交的爬虫重灾区:微博。

img

上面的 API 接口都是微博的接口,它们分别用来获取某个人的微博列表、微博的状态、索引等等,使用这些接口,可以增加话题热度、关注、点赞或者留言,标准的僵尸粉上班流程。

还有那种大麦网抢票脚本也属于爬虫的一种。

......

前端爬虫的优缺点

优点:

  • 可以处理复杂的网页结构和交互逻辑,例如SPA(单页应用)、AJAX(异步JavaScript和XML)等。
  • 可以获取网页中的动态数据,例如价格、评论、排名等。
  • 可以模拟用户行为,例如点击、滚动、输入等。
  • 可以绕过一些反爬虫的机制,例如验证码、IP限制、Cookie验证等。

缺点:

  • 需要消耗更多的资源,例如内存、CPU、网络等。
  • 需要更多的技术知识,例如 JavaScript、DOM(文档对象模型)、CSS(层叠样式表)等。
  • 需要更多的时间,因为需要等待网页加载和渲染完成。

前端爬虫实践方案

JSON API 请求

如今绝大部分页面的数据都是从接口获取的,所以我们可以考虑直接通过接口请求获取到想要的数据,这种方式获取数据快且直接,但是会存在以下难点:

  1. 需要分析数据来自哪个接口及接口的数据结构
  2. 身份或验证码校验
  3. 请求频繁容易遭到IP封禁或频次限制(状态码:429 Too Many Requests)
  4. ......

开放接口

举例:获取 微博 某一关键词的搜索结果的前五页数据,将发送者名称、正文、评论数、转发数、点赞数和发布时间输出到 excel 文档:

const axios = require('axios')
const { exportToExcel, parseTime } = require('./utils')
const inquirer = require('inquirer')

const headers = ['发送者', '正文', '评论数', '转发数', '点赞数', '发布时间']
const result = []
inquirer
  .prompt([
    {
      type: 'input',
      name: 'keyword',
      message: '请输入关键字:'
    }
  ])
  .then(({ keyword }) => {
    const pmList = []
    // 获取前5页数据
    for (let index = 1; index <= 5; index++) {
      const pm = axios.get('https://m.weibo.cn/api/container/getIndex', {
        params: {
          containerid: '100103type=1&q=' + keyword,
          page_type: 'searchall',
          page: index
        }
      })
      pmList.push(pm)
    }
    Promise.all(pmList).then(resList => {
      resList.forEach(({ data: res }) => {
        res.data.cards.forEach(card => {
          // card_type=9为显示的信息
          if (card.card_type === 9) {
            getCardDetails(card)
          } else if (card.card_type === 11) {
            for (const childCard of card.card_group) {
              getCardDetails(childCard)
            }
          }
        })
      })
      // 调用函数导出数据到对应文件中
      exportToExcel(headers, result, './excel/微博关键词搜索.xlsx');
    })
  })

// 获取发送者名称、正文、评论数、转发数、点赞数和发布时间
function getCardDetails (card) {
  if (card.card_type !== 9) {
    return
  }
  const { mblog } = card
  result.push([mblog.user.screen_name, mblog.text, mblog.comments_count, mblog.reposts_count, mblog.attitudes_count, parseTime(mblog.created_at)]) // 格式化时间格式
}

身份校验接口

有些接口并不像上述接口一样直接请求即可得到结果,它们一般会加入一些校验信息(如:Cookie、 Referer、 User-Agent、签名等)进行反爬处理。

若需要模拟请求,可直接把请求 curl 喂给 chatGPT,让它写好请求代码。

难点:有些参数是推测不到的(如:签名、加密参数),所以不能像开放接口那样可以连续请求数据。

cheerio

有些网站并不使用 JSON API 请求进行数据获取,而是在更新数据时通过返回 HTML 文件更新页面(即SSR),因此 JSON API 请求并不适用这种场景,但我们可以直接从页面获取信息。这时候可以使用 cheerio。

cheerio 是一个快速、灵活、优雅的用于解析和操作 HTML 和 XML 的库。它实现了 jQuery 的核心子集,并从 jQuery 库中删除了所有 DOM 不一致性和浏览器废物 。cheerio 使用非常简单、一致的 DOM 模型,使解析、操作和渲染变得非常高效。它几乎可以解析任何 HTML 或 XML 文档,并且可以在浏览器和服务器环境中工作。

举例:获取 曲棍球队 全部列表信息,将列表信息输出到 excel 文档:

const axios = require('axios')
const cheerio = require('cheerio')
// 封装好的导出函数
const { exportToExcel } = require('./utils')

// 文件表头
const headers = ['Team Name', 'Year', 'Wins', 'Losses', 'OT Losses', 'Win %', 'Goals For(GF)', 'Goals Against(GA)', '+/-']
const result = []
const pmList = []
// 获取全部页面的html文档
for (let page = 1; page <= 24; page++) {
  const pm = axios({
    url: `https://www.scrapethissite.com/pages/forms/?page_num=${page}`,
    methods: 'get'
  })
  pmList.push(pm)
}

Promise.all(pmList).then(resList => {
  resList.forEach(({ data }) => {
    const $ = cheerio.load(data)
    // 获取当前页的全部信息行
    $('.team').each(function () {
      // 解析每一行的所有信息
      const name = $(this).find('.name').text().trim()
      const year = $(this).find('.year').text().trim()
      const wins = $(this).find('.wins').text().trim()
      const losses = $(this).find('.losses').text().trim()
      const otLosses = $(this).find('.ot-losses').text().trim()
      const pct = $(this).find('.pct').text().trim()
      const gf = $(this).find('.gf').text().trim()
      const ga = $(this).find('.ga').text().trim()
      const diff = $(this).find('.diff').text().trim()
      result.push([name, year, wins, losses, otLosses, pct, gf, ga, diff])
    })
  })
  // 调用函数导出数据到对应文件中
  exportToExcel(headers, result, './excel/曲棍球队信息1.xlsx')
})

jsdom

解析页面数据,除了 cheerio 外,jsdom 也能做到。

jsdom 是一个用于 Node.js 的纯 JavaScript 实现的许多 Web 标准,尤其是 WHATWG DOM 和 HTML 标准的库。它的目标是模拟足够多的 Web 浏览器的子集,以便对测试和抓取现实世界的 Web 应用程序有用。

使用 jsdom,你可以将 HTML 文本传递给 JSDOM 构造函数,它将返回一个 JSDOM 对象,该对象具有许多有用的属性,尤其是 window。

const jsdom = require('jsdom')
const { JSDOM } = jsdom

// 设置了runScripts: "dangerously"选项,表示允许执行脚本
const dom = new JSDOM(`<!DOCTYPE html><script>window.foo = 'bar';</script>`, { runScripts: "dangerously" })
console.log(dom.window.foo) // "bar"

所以上一举例的后半部分代码可以修改为:

const { JSDOM } = require('jsdom')

...

Promise.all(pmList).then(resList => {
  resList.forEach(({ data }) => {
    const { window } = new JSDOM(data)
    // 获取当前页的全部信息行
    const teams = window.document.querySelectorAll('.team')
    teams.forEach(team => {
      // 解析每一行的所有信息
      const name = team.querySelector('.name').innerHTML.trim()
      const year = team.querySelector('.year').innerHTML.trim()
      const wins = team.querySelector('.wins').innerHTML.trim()
      const losses = team.querySelector('.losses').innerHTML.trim()
      const otLosses = team.querySelector('.ot-losses').innerHTML.trim()
      const pct = team.querySelector('.pct').innerHTML.trim()
      const gf = team.querySelector('.gf').innerHTML.trim()
      const ga = team.querySelector('.ga').innerHTML.trim()
      const diff = team.querySelector('.diff').innerHTML.trim()
      result.push([name, year, wins, losses, otLosses, pct, gf, ga, diff])
    })
  })
  // 调用函数导出数据到对应文件中
  exportToExcel(headers, result, './excel/曲棍球队信息2.xlsx')
})

在上述举例中 jsdom 和 cheerio 看上去用法差不多,但是 jsdom 的功能远比 cheerio 丰富,jsdom 还能模拟用户交互(如:点击按钮或填写表单)、请求数据JSDOM.fromURL())、执行 HTML 文档中的 JavaScript 脚本等。对于挂载在 window 对象上的属性,可通过 JSDOM 对象中的 window 属性访问。

使用 jsdom 来模拟用户交互示例:

const jsdom = require('jsdom');
const { JSDOM } = jsdom;

const dom = new JSDOM('<!DOCTYPE html><button id="myButton">Click me!</button>');
const button = dom.window.document.querySelector("#myButton");
button.addEventListener("click", () => {
  console.log("Button clicked!");
});

// 模拟用户点击按钮
button.click(); // 输出 "Button clicked!"

问题:cheerio 和 jsdom 在什么情况下不适用呢?

答:SPA 应用。SPA 应用的页面内容一般都是动态加载的,不随 HTML 文档返回。

  • cheerio 是能解析 HTML 文档,但是不支持执行 JavaScript 脚本。
  • jsdom 可以执行内联和外部脚本,但它并不支持所有的 JavaScript 特性和浏览器 API,同时 SPA 应用程序通常依赖于浏览器中的一些特定功能,如 AJAX 请求和浏览器历史记录管理,所以它无法正确执行一些复杂的 SPA 应用程序中的 JavaScript 代码。

Puppeteer

Puppeteer 是一个 Node.js 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chrome/Chromium。使用 Puppeteer,您可以执行大多数在浏览器中手动执行的操作,例如生成页面的屏幕截图和 PDF,爬取单页应用并生成预渲染内容(即服务器端渲染),自动提交表单、进行 UI 测试、键盘输入等。

Puppeteer 爬虫的一般步骤:

  1. 启动一个浏览器实例
  2. 创建新页面并跳转到目标网站
  3. 等待页面加载,waitForxxx() 方法
  4. 通过 $()$$() 方法选择目标 dom
  5. 获取目标 dom 的属性或文本
  6. 关闭浏览器实例

举例:获取 豆瓣电影 排行榜中的电影名称、评分和链接,并将结果保存到一个 excel 文件。

const puppeteer = require('puppeteer')
// 封装好的导出函数
const { exportToExcel } = require('./utils')

// 定义目标网址
const url = 'https://movie.douban.com/chart'
const headers = ['电影名称', '评分', '电影链接']

// 定义一个异步函数,用于爬取数据
async function crawl() {
  // 启动一个浏览器实例,关闭无头模式
  const browser = await puppeteer.launch({headless: false})
  // 创建一个新的页面
  const page = await browser.newPage()
  // 设置页面的视口大小
  await page.setViewport({width: 1280, height: 1000})
  // 访问目标网址
  await page.goto(url)
  // 等待页面加载完成
  await page.waitForSelector('.item')
  // 获取页面中的电影列表元素
  const items = await page.$$('.item')
  // 创建一个空数组,用于存储电影数据
  const movies = []
  // 遍历每个电影元素
  for (let item of items) {
    // 获取电影名称元素
    const title = await item.$('.nbg')
    // 获取电影评分元素
    const rating = await item.$('.rating_nums')
    // 获取电影名称文本
    const titleText = await page.evaluate(el => el.title, title)
    // 获取电影评分文本
    const ratingText = await page.evaluate(el => el ? el.textContent : '', rating)
    // 获取电影链接属性
    const linkAttr = await title.getProperty('href')
    // 获取电影链接文本
    const linkText = await linkAttr.jsonValue()
    // 将电影数据添加到数组中
    movies.push([titleText, ratingText, linkText])
  }
  // 关闭浏览器实例
  await browser.close()
  // 调用函数导出数据到对应文件中
  exportToExcel(headers, movies, './excel/豆瓣电影.xlsx')
}

// 调用爬取函数
crawl()

Puppeteer 的功能非常强大,在浏览器能做到的它基本都能做到,更多功能介绍可以查阅【官网文档】

Selenium WebDriver

Selenium 是一套用于自动化 Web 浏览器的工具 。它支持市场上所有主流浏览器的自动化,包括 Firefox,Chrome,Internet Explorer 和 Opera 等。WebDriver 是 Selenium 的一个组件,它提供了一组 API 来控制 Web 浏览器的行为 。

Selenium WebDriver 爬虫步骤和 Puppeteer 相似,但前者可以设置一些个性化参数(如:指定访问的浏览器)。

举例:获取 豆瓣电影 top250 排行榜的电影列表中每部电影的标题和评分。

// 导入 Selenium WebDriver 模块中的 Builder 和 By 类
const { Builder, By } = require('selenium-webdriver')

async function crawl() {
  // 创建一个新的 WebDriver 实例,指定使用 chrome 浏览器
  const driver = await new Builder().forBrowser('chrome').build()
  let current = 0
  try {
    // 电影标题列表
    const movieTitles = []
    // 电影评分列表
    const movieRatings = []
    while (current < 250) {
      // 打开豆瓣电影排行榜页面
      await driver.get(`https://movie.douban.com/top250?start=${current}`)
      // 获取电影列表元素
      const movieList = await driver.findElement(By.className('grid_view'))
      // 获取电影标题元素列表
      const titles = await movieList.findElements(By.css('.hd span.title:nth-of-type(1)'))
      // 获取电影评分元素列表
      const ratings = await movieList.findElements(By.className('rating_num'))
      // 遍历电影标题和评分元素列表,获取文本并放到结果数组
      for (let i = 0; i < titles.length; i++) {
        const title = await titles[i].getText()
        const rating = await ratings[i].getText()
        movieTitles.push(title)
        movieRatings.push(rating)
      }
      current += 25
    }
    for (let index = 0; index < movieTitles.length; index++) {
      // 控制台输出结果
      console.log(movieTitles[index] + ':' + movieRatings[index])
    }
  } finally {
    // 关闭浏览器
    await driver.quit()
  }
}

crawl()

Selenium 和 Puppeteer 都是用于浏览器自动化的工具,但它们之间有一些主要区别:

  • Selenium 支持多种浏览器,而 Puppeteer 只专注于 Chrome、Chromium。Puppeteer 是 Chrome 的远程控制库,而 Selenium 是完整的浏览器应用测试解决方案。
  • Puppeteer 的核心特点是它的速度和对 Chrome DevTools 协议的完全支持,而 Selenium 有着更广泛的浏览器支持。
  • Selenium 支持多语言,如 Python、Java、Ruby、JavaScript等;而 Puppeteer 是一个 Node.js 库,只能用 JavaScript 开发,不过它也有一个非官方的 Python 移植版本 pyppeteer。

以上方案还可以组合使用,在某些爬虫场景会更加得心应手。

Puppeteer + JSON API 请求

如果在使用 JSON API 请求时存在模拟不了请求的情况(如:包含未知参数、加密参数),但是又想直接通过接口获取数据,则可以使用 Puppeteer + JSON API 请求的方式。

Puppeteer API 中提供了 HTTPResponse 类, HTTPResponse 类提供了一些方法和属性来获取有关响应的信息,例如响应的 URL(url())、状态码(status())、响应头(headers())和响应体(json()text())等。 因此可以通过监听 response 事件得到所有请求的响应数据,再根据 URL 筛选出目标接口的响应数据即可。

示例代码如下:

const puppeteer = require('puppeteer')

async function crawl () {
  // 启动一个浏览器实例
  const browser = await puppeteer.launch()
  // 创建一个新的页面
  const page = await browser.newPage()
  // 监听响应事件
  page.on('response', res => {
    // 判断请求接口是否为目标接口
    if (res.url().includes('/target-url')) {
      res.json().then(body => {
        console.log(body)
      })
    }
  })

  // 跳转目标网站
  // waitUntil 选项指定了在什么时候认为页面加载完成。'networkidle2'表示当网络连接数量降到 2 个以下时,页面被认为已经加载完成
  await page.goto(`https://example.com/xxx`, { waitUntil: 'networkidle2' })
  ...
  await browser.close()
}

crawl()

方案对比

方案特点适用场景
JSON API 请求获取数据快速,但需要分析接口结构、存在身份校验问题接口结构清晰
cheerio语法简单,直接从 HTML 文本里获取信息服务端渲染页面
jsdom支持部分 JavaScript 执行,返回 window 对象服务端渲染页面、需要执行 JavaScript 脚本
Puppeteer强大,但安装和运行比较耗时和可能会遇到一些兼容性或者稳定性的问题基本上都适用
Selenium WebDriver支持多种浏览器和语言、兼容性好和易于使用,但速度较慢和需要安装浏览器驱动基本上都适用
Puppeteer + JSON API 请求解决了接口校验问题,获取数据快捷接口参数无法破解,但又想直接获取响应数据

总结

除了以上的方案,还能使用 Puppeteer + cheerio、Puppeteer + jsdom 的方案,它们能解决服务端渲染的接口校验问题,但是这两种方案使用场景较少。

除此,Github 社区还贡献了其他的爬虫框架,如 x-crawl,它是基于 Puppeteer 进一步封装的爬虫框架,加入了一些间隔爬取、失败重试、代理轮换等新特性。

从上面的爬虫方案可以简单总结出爬虫的一般步骤如下:

  1. 确定目标:确定要抓取的网站和信息类型。
  2. 发送请求:使用 HTTP 库(如:requestaxios 库)向目标网站发送请求,获取网页内容。
  3. 解析内容:使用 HTML 解析库(如:cheeriojsdom 库)解析网页内容,提取目标信息;或从响应体 JSON 对象中提取目标信息。
  4. 存储数据:将提取的数据存储到本地文件或数据库中,以便进一步分析和使用。
  5. 遍历链接:如果想要抓取多个页面,可以在解析内容时提取页面上的链接,并遍历这些链接以获取更多页面的内容。

不同的爬虫可能会根据目标网站和信息类型的不同而有所不同。

在爬虫过程中经常遇到接口校验、请求频次限制、IP 封禁等问题。除了接口方面的反爬操作,很多厂商在页面上也做了很多反爬操作,可阅读博客《盘点盘点十几种常见的反爬策略!!》

最后推荐一些可用于爬虫练习的网站:ScrapethissiteToscrapeWeb Scraper Test Sites

特别强调:爬虫需要遵守网站的使用协议和法律规定,否则可能会造成侵权或者违法。

参考内容