本文最初发布于 scrapingbee.com 网站,经网站授权由 InfoQ 中文站翻译并分享。
这篇文章主要针对拥有一定 Javascript 开发经验的开发人员。但如果你很熟悉 Web 内容爬取,那么就算没有 Javascript 的相关经验,也能从本文中学到很多知识。
-
JS 语言开发背景
-
使用 DevTools 提取元素选择器(selector)的经验
-
与 ES6 Javascript 相关的经验(可选)
阅读这篇文章能够帮助读者:
-
了解 NodeJS 的功能
-
使用多个 HTTP 客户端来辅助 Web 抓取工作
-
利用多个经过实战检验的现代库来抓取 Web 内容
Javascript 是一种简单而现代化的语言,最初是为了向浏览器访问的网站添加动态行为而创建的。网站加载后,Javascript 通过浏览器的 JS 引擎运行,并转换为计算机可以理解的一堆代码。为了让 Javascript 与你的浏览器交互,后者提供了一个运行时环境(文档,窗口等)。
换句话说 Javascript 这种编程语言无法直接与计算机或其资源交互,抑或操纵它们。例如,在 Web 服务器中服务器必须能够与文件系统交互,才能读取文件或将记录存储在数据库中。
NodeJS 的理念是让 Javascript 不仅能运行在客户端,还能运行在服务端。为了做到这一点,资深开发人员 Ryan Dahl 采用了谷歌 Chrome 浏览器的 v8 JS 引擎,并将其嵌入了到名为 Node 的 C++ 程序中。因此 NodeJS 是一个运行时环境,它让使用 Javascript 编写的应用程序也能运行在服务器上。
大多数语言(例如 C 或 C++)使用多个线程来处理并发,相比之下 NodeJS 只使用单个主线程,并在事件循环(Event Loop)的帮助下用它以非阻塞方式执行任务。
const http = require('http');
const PORT = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});
server.listen(port, () => {
console.log(`Server running at PORT:${port}/`);
});
如果你已安装 NodeJS,运行 node <YourFileNameHere>.js(去掉<>号),然后打开浏览器并导航到 localhost:3000,就能看到“HelloWorld”的文本了。NodeJS 非常适合 I/O 密集型应用程序。
HTTP 客户端是将请求发送到服务器,然后从服务器接收响应的工具。本文要讨论的工具大都在后台使用 HTTP 客户端来查询你将尝试抓取的网站服务器。
const request = require('request')
request('https://www.reddit.com/r/programming.json', function (
error,
response,
body
) {
console.error('error:', error)
console.log('body:', body)
})
你可以在 Github 上找到 Request 库,运行 npm install request 就能安装完成。这里可以参考弃用通知及细节:
https://github.com/request/request/issues/3142
const axios = require('axios')
axios
.get('https://www.reddit.com/r/programming.json')
.then((response) => {
console.log(response)
})
.catch((error) => {
console.error(error)
});
async function getForum() {
try {
const response = await axios.get(
'https://www.reddit.com/r/programming.json'
)
console.log(response)
} catch (error) {
console.error(error)
}
}
你只需调用 getForum 即可!你可以在 Github 上找到 Axios 库,运行 npm install axios 即可安装。
https://github.com/axios/axios
类似 Axios,Superagent 是另一款强大的 HTTP 客户端,它支持 Promise 和 async/await 语法糖。它的 API 像 Axios 一样简单,但 Superagent 的依赖项更多,并且没那么流行。
const superagent = require("superagent")
const forumURL = "https://www.reddit.com/r/programming.json"
// callbacks
superagent
.get(forumURL)
.end((error, response) => {
console.log(response)
})
// promises
superagent
.get(forumURL)
.then((response) => {
console.log(response)
})
.catch((error) => {
console.error(error)
})
// promises with async/await
async function getForum() {
try {
const response = await superagent.get(forumURL)
console.log(response)
} catch (error) {
console.error(error)
}
你可以在 Github 上找到 Superagent 库,运行 npm install superagent 即可安装。
https://github.com/visionmedia/superagent
对于下文介绍的 Web 抓取工具,本文将使用 Axios 作为 HTTP 客户端。
在没有任何依赖项的情况下开始抓取 Web 内容,最简单的方法是:使用 HTTP 客户端查询网页时,在收到的 HTML 字符串上应用一组正则表达式——但这种方法绕的路太远了。正则表达式没那么灵活,并且很多专业人士和业余爱好者都很难写出正确的正则表达式。
const htmlString = '<label>Username: John Doe</label>'
const result = htmlString.match(/<label>(.+)<\/label>/)
console.log(result[1], result[1].split(": ")[1])
// Username: John Doe, John Doe
在 Javascript 中,match() 通常返回一个数组,该数组包含与正则表达式匹配的所有内容。第二个元素(在索引 1 中)将找到 textContent 或<label>标签的 innerHTML,这正是我们想要的。但是这个结果会包含一些我们不需要的文本(“Username: ”),必须将其删除。如你所见,对于一个非常简单的用例,这种方法用起来都很麻烦。所以我们应该使用 HTML 解析器之类的工具,后文具体讨论。
const cheerio = require('cheerio')
const $ = cheerio.load('<h2 class="title">Hello world</h2>')
$('h2.title').text('Hello there!')
$('h2').addClass('welcome')
$.html()
// <h2 class="title welcome">Hello there!</h2>
如你所见,Cheerio 用起来和 JQuery 很像。但是,它的工作机制和 Web 浏览器是不一样的,这意味着它不能:
-
渲染任何已解析或操纵的 DOM 元素
-
应用 CSS 或加载任何外部资源
-
执行 JavaScript
因此,如果你试图爬取的网站或 Web 应用程序有很多 Javascript 内容(例如“单页应用程序”),那么 Cheerio 并不是你的最佳选择,你可能还得依赖后文讨论的其他一些选项。
为了展示 Cheerio 的强大能力,我们将尝试在 Reddit 中爬取 r/programming 论坛,获取其中的帖子标题列表。
首先,运行以下命令来安装 Cheerio 和 axios:npm install cheerio axios。
const axios = require('axios');
const cheerio = require('cheerio');
const getPostTitles = async () => {
try {
const { data } = await axios.get(
'https://old.reddit.com/r/programming/'
);
const $ = cheerio.load(data);
const postTitles = [];
$('div > p.title > a').each((_idx, el) => {
const postTitle = $(el).text()
postTitles.push(postTitle)
});
return postTitles;
} catch (error) {
throw error;
}
};
getPostTitles()
.then((postTitles) => console.log(postTitles));
getPostTitles() 是一个异步函数,它将爬取旧版 reddit 的 r/programming 论坛。首先,使用 axios HTTP 客户端库的一个简单 HTTP GET 请求获取网站的 HTML,然后使用 cheerio.load() 函数将 html 数据输入到 Cheerio 中。
接下来使用浏览器的开发工具,你可以获得通常可以定位所有 postcard 的选择器。如果你用过 JQuery,肯定非常熟悉 $('div > p.title > a')。这将获取所有帖子,因为你只想获得每个帖子的标题,所以必须遍历每个帖子(使用 each() 函数来遍历)。
要从每个标题中提取文本,必须在 Cheerio 的帮助下获取 DOM 元素(el 表示当前元素)。然后在每个元素上调用 text() 以获取文本。
现在,你可以弹出一个终端并运行 node crawler.js,然后你将看到一个由大约 25 或 26 个帖子标题组成的长长的数组。尽管这是一个非常简单的用例,但它展示了 Cheerio 提供的 API 用起来是多么简单。
如果你的用例需要执行 Javascript 并加载外部资源,那么可以考虑以下几个选项。
JSDOM 是用在 NodeJS 中的,文档对象模型(DOM)的纯 Javascript 实现,如前所述,DOM 对 Node 不可用,而 JSDOM 就是最近似的替代品。它多少模拟了浏览器的机制。
const { JSDOM } = require('jsdom')
const { document } = new JSDOM(
'<h2 class="title">Hello world</h2>'
).window
const heading = document.querySelector('.title')
heading.textContent = 'Hello there!'
heading.classList.add('welcome')
heading.innerHTML
// <h2 class="title welcome">Hello there!</h2>
如你所见,JSDOM 创建了一个 DOM,然后你就可以像操纵浏览器 DOM 那样,用相同的方法和属性来操纵这个 DOM。为了演示如何使用 JSDOM 与网站交互,我们将获取 Redditr/programming 论坛的第一篇帖子,并对其点赞,然后我们将验证该帖子是否已被点赞。
npm install jsdom axios
const { JSDOM } = require("jsdom")
const axios = require('axios')
const upvoteFirstPost = async () => {
try {
const { data } = await axios.get("https://old.reddit.com/r/programming/");
const dom = new JSDOM(data, {
runScripts: "dangerously",
resources: "usable"
});
const { document } = dom.window;
const firstPost = document.querySelector("div > div.midcol > div.arrow");
firstPost.click();
const isUpvoted = firstPost.classList.contains("upmod");
const msg = isUpvoted
? "Post has been upvoted successfully!"
: "The post has not been upvoted!";
return msg;
} catch (error) {
throw error;
}
};
upvoteFirstPost().then(msg => console.log(msg));
upvoteFirstPost() 是一个异步函数,它将在 r/programming 中获取第一个帖子,然后对其点赞。为此,axios 发送 HTTP GET 请求以获取指定 URL 的 HTML。然后向 JSDOM 提供先前获取的 HTML 来创建新的 DOM。JSDOM 构造器将 HTML 作为第一个参数,将选项作为第二个参数,添加的 2 个选项会执行以下函数:
-
runScripts:设置为“dangerously”时,它允许执行事件处理程序和任何 Javascript 代码。如果你不清楚应用程序将运行的脚本是否可信,则最好将 runScripts 设置为“outside-only”,这会将所有 Javascript 规范提供的全局变量附加到 window 对象,从而防止任何脚本在内部执行。
-
resources:设置为“usable”时,它允许加载使用<script>标记声明的任何外部脚本(例如从 CDN 提取的 JQuery 库)。
创建 DOM 后,你将使用相同的 DOM 方法获取第一篇文章的点赞按钮,然后单击它。要验证单击操作是否有效,可以检查 classList 中是否有一个名为 upmod 的类。如果此类存在于 classList 中,则返回一条消息。
现在,你可以启动一个终端并运行 node crawler.js,然后你将看到一段文字,告诉你帖子是否已被点赞。尽管这个示例用例很简单,但是你可以在此基础上创建一些功能强大的事物,例如一个对特定用户的帖子投票的 bot。
如果你觉得 JSDOM 缺乏表现力,而且你的爬网工作很依赖这类操作,或者需要创建许多各不相同的 DOM,那么以下选项将是更好的选择。
顾名思义,Puppeteer 允许你以编程方式操控浏览器,就像木偶师操纵木偶一样。它默认为开发人员提供了一个高级 API 来控制 Chrome 的一个无头版本,并可以配置为无头运行。
Puppeteer 比之前提到的那些工具有用得多,因为它能让你的爬网操作就像真人与浏览器交互一样。这就开拓了很多前所未有的可能性:
-
你可以获取屏幕截图或生成页面 PDF。
-
你可以抓取单页应用程序并生成预渲染的内容。
-
自动执行许多不同的用户交互,例如键盘输入、表单提交、导航等。
它也能在爬网之外的其他许多任务中发挥重要作用,例如 UI 测试、辅助性能优化等。
npm install puppeteer
这将下载 Chromium 的一个打包版本,根据你的操作系统版本,这个版本大约需要 180MB 到 300MB。如果你不想用它,而是将 puppeteer 指向一个已经下载的 chromium 版本,则必须设置一些环境变量,但我不建议这样做。如果你确实不想为了这篇教程去下载 Chromium 和 puppeteer,那么还可以用一个 puppeteer playground:
https://try-puppeteer.appspot.com/
const puppeteer = require('puppeteer')
async function getVisual() {
try {
const URL = 'https://www.reddit.com/r/programming/'
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto(URL)
await page.screenshot({ path: 'screenshot.png' })
await page.pdf({ path: 'page.pdf' })
await browser.close()
} catch (error) {
console.error(error)
}
}
getVisual()
getVisual() 是一个异步函数,它将获取分配给 URL 变量的值的屏幕快照和 pdf。首先运行 puppeteer.launch() 创建浏览器实例,然后创建一个新页面。可以将该页面视为常规浏览器中的选项卡。接着使用 URL 作为参数调用 page.goto(),将先前创建的页面定向到指定的 URL。最终,浏览器实例与页面一起被销毁。完成此操作并完成页面加载后,将分别使用 page.screenshot() 和 page.pdf() 截取屏幕截图和 pdf。你也可以侦听 Javascript 加载事件并执行这些操作,在生产环境中强烈建议后面这种方法。
在终端上输入 node crawler.js 来运行代码,几秒钟后会创建 2 个文件,分别名为 screenshot.jpg 和 page.pdf。
Nightmare 也是像 Puppeteer 这样的高级浏览器自动化库,它用的是 Electron,但据说比以前的 PhantomJS 快大约一倍,并且更加现代化。
如果你出于种种原因不喜欢 Puppeteer,或者觉得 Chromium 包太大了,那么 Nightmare 就是你理想的选择。首先运行以下命令来安装 nightmare 库:npm install nightmare
const Nightmare = require('nightmare')
const nightmare = Nightmare()
nightmare
.goto('https://www.google.com/')
.type("input[title='Search']", 'ScrapingBee')
.click("input[value='Google Search']")
.wait('#rso > div:nth-child(1) > div > div > div.r > a')
.evaluate(
() =>
document.querySelector(
'#rso > div:nth-child(1) > div > div > div.r > a'
).href
)
.end()
.then((link) => {
console.log('Scraping Bee Web Link': link)
})
.catch((error) => {
console.error('Search failed:', error)
})
这里首先会创建一个 Nighmare 实例,然后调用 goto() 将该实例定向到谷歌搜索引擎;加载后,使用其选择器获取搜索框,然后更改搜索框的值(一个输入标签)到“ScrapingBee”。完成后,单击搜索按钮提交搜索表单。接着 Nightmare 会等到第一个链接加载完毕,一旦加载完成,将使用 DOM 方法获取包含该链接的 anchor 标记的 href 属性值。最后,完成所有操作后,这个链接将打印到控制台。要运行代码,请在终端中输入 node crawler.js。
文章好长啊!但现在你了解了 NodeJS 的不同用法,以及它丰富的库生态系统,这样就能用各种姿势随意爬网了。总结一下,你知道了:
-
NodeJS 是 Javascript 运行时,允许 Javascript 在服务端运行。感谢事件循环,它天生是非阻塞的。
-
HTTP 客户端(例如 Axios、Superagent 和 Request)用于将 HTTP 请求发送到服务器并接收响应。
-
Cheerio 从 JQuery 摄取精华,以在服务端运行来爬取 Web 内容,但不能执行 Javascript 代码。
-
JSDOM 根据标准 Javascript 规范从 HTML 字符串中创建 DOM,并允许你对其执行 DOM 操作。
-
Puppeteer 和 Nightmare 是高级浏览器自动化库,可让你以编程方式操纵 Web 应用程序,就像真实的人在与之交互一样。
Puppeteer 文档:
https://developers.google.com/web/tools/puppeteer
Scraping 博客:
https://www.scrapingbee.com/blog/
https://www.scrapingbee.com/blog/web-scraping-javascript/
文章评论