这些年来Javascript进步飞快,又引入了称为NodeJS的运行时,所以已经成为了最流行和使用最广泛的语言之一。不管你要写的是Web应用还是移动应用,都能在Javascript生态中找到合适的工具。本文要介绍的是如何在NodeJS的活跃生态系统帮助下高效地抓取Web内容,以满足大多数相关需求。
本文最初发布于scrapingbee.com网站,经网站授权由InfoQ中文站翻译并分享。
这篇文章主要针对拥有一定Javascript开发经验的开发人员。但如果你很熟悉Web内容爬取,那么就算没有Javascript的相关经验,也能从本文中学到很多知识。
阅读这篇文章能够帮助读者:
Javascript是一种简单而现代化的语言,最初是为了向浏览器访问的网站添加动态行为而创建的。网站加载后,Javascript通过浏览器的JS引擎运行,并转换为计算机可以理解的一堆代码。为了让Javascript与你的浏览器交互,后者提供了一个运行时环境(文档,窗口等)。
换句话说Javascript这种编程语言无法直接与计算机或其资源交互,抑或操纵它们。例如,在Web服务器中服务器必须能够与文件系统交互,才能读取文件或将记录存储在数据库中。
NodeJS的理念是让Javascript不仅能运行在客户端,还能运行在服务端。为了做到这一点,资深开发人员Ryan Dahl采用了谷歌Chrome浏览器的v8 JS引擎,并将其嵌入了到名为Node的C++程序中。因此NodeJS是一个运行时环境,它让使用Javascript编写的应用程序也能运行在服务器上。
大多数语言(例如C或C++)使用多个线程来处理并发,相比之下NodeJS只使用单个主线程,并在事件循环(Event Loop)的帮助下用它以非阻塞方式执行任务。
我们很容易就能建立一个简单的Web服务器,如下所示:
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客户端来查询你将尝试抓取的网站服务器。
Request是Javascript生态系统中使用最广泛的HTTP客户端之一,不过现在Request库的作者已正式声明,不推荐大家继续使用它了。这并不是说它就不能用了,还有很多库仍在使用它,并且它真的很好用。使用Request发出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库(https://github.com/request/request),运行npm install request就能安装完成。这里可以参考弃用通知及细节(https://github.com/request/request/issues/3142)。如果你因为这个库过时了而觉得不放心,后面还有更多推荐!
Axios是基于promise的HTTP客户端,可在浏览器和NodeJS中运行。如果你使用Typescript,则axios可以覆盖内置类型。通过Axios发起HTTP请求是很简单的,它默认内置Promise支持,不像Request还得用回调:
const axios = require('axios')
axios
.get('https://www.reddit.com/r/programming.json')
.then((response) => {
console.log(response)
})
.catch((error) => {
console.error(error)
});
如果你喜欢Promises API的async/await语法糖,那么也可以用它们,但由于顶级的await仍处于第3阶段(https://github.com/tc39/proposal-top-level-await), 我们只能用Async Function来代替:
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库(https://github.com/axios/axios),运行npm install axios即可安装。
类似Axios,Superagent是另一款强大的HTTP客户端,它支持Promise和async/await语法糖。它的API像Axios一样简单,但Superagent的依赖项更多,并且没那么流行。
在Superagent中,使用promise、async/await或callbacks发出HTTP请求的方式如下:
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库(https://github.com/visionmedia/superagent),运行npm install superagent即可安装。
对于下文介绍的Web抓取工具,本文将使用Axios作为HTTP客户端。
在没有任何依赖项的情况下开始抓取Web内容,最简单的方法是:使用HTTP客户端查询网页时,在收到的HTML字符串上应用一组正则表达式——但这种方法绕的路太远了。正则表达式没那么灵活,并且很多专业人士和业余爱好者都很难写出正确的正则表达式。
对于复杂的Web抓取任务来说,正则表达式很快就会遇到瓶颈了。不管怎样我们先来试一下。假设有一个带用户名的标签,我们需要其中的用户名,那么使用正则表达式时的方法差不多是这样:
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解析器之类的工具,后文具体讨论。
Cheerio是一个高效轻便的库,它允许你在服务端使用JQuery的丰富而强大的API。如果你以前使用过JQuery,那么很容易就能上手Cheerio。它把DOM所有不一致性和浏览器相关的特性都移除掉了,并公开了一个高效的API来解析和操作DOM。
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浏览器是不一样的,这意味着它不能:
因此,如果你试图爬取的网站或Web应用程序有很多Javascript内容(例如“单页应用程序”),那么Cheerio并不是你的最佳选择,你可能还得依赖后文讨论的其他一些选项。
为了展示Cheerio的强大能力,我们将尝试在Reddit中爬取r/programming论坛,获取其中的帖子标题列表。
首先,运行以下命令来安装Cheerio和axios:npm install cheerio axios。
然后创建一个名为crawler.js的新文件,并复制/粘贴以下代码:
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就是最近似的替代品。它多少模拟了浏览器的机制。
创建了一个DOM后,我们就可以通过编程方式与要爬取的Web应用程序或网站交互,像点击按钮这样的操作也能做了。如果你熟悉DOM的操作方法,那么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论坛的第一篇帖子,并对其点赞,然后我们将验证该帖子是否已被点赞。
首先运行以下命令来安装jsdom和axios:npm install jsdom axios
然后创建一个名为rawler.js的文件,并复制/粘贴以下代码:
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个选项会执行以下函数:
领取专属 10元无门槛券
私享最新 技术干货