nodewebcrawers

前言

网络爬虫通常简称为 crawler,有时也被称为 spider-bot,是一种按某种固定规则爬取互联网信息的机器人,通常用于网络索引。这些互联网机器人用于提高用户搜索结果的质量。除此之外,还可以用来收集数据(称为web抓取)。

根据站点的结构和被提取的数据的复杂性,web抓取的过程可能会在CPU上执行很多任务。为了优化和加速这个过程,我们将使用对cpu密集型操作使用的 Node 中的 worker

在本文中, 我们将学习如何构建一个网络爬虫, 爬取网站数据并将数据持久化。

本文的前置知识:

  • 1、了解Node.js
  • 2、Yarn 或 npm
  • 3、Node系统版本最好是大于等于10.5.0

初始化工程

1
2
$ mkdir webcrawers
$ cd webcrawers

运行以下命令初始化项目:

1
yarn init -y

我们需要使用到下面的依赖包:

1
$ yarn add axios cheerio

注册一个workers

我们先简单了解一下 worker 的用法:Node提供了 worker_threads 模块创建worker。

1
2
3
4
5
6
7
8
// hello.js

const { Worker, isMainThread } = require('worker_threads');
if(isMainThread){
new Worker(__filename);
} else{
console.log("Worker says: Hello World"); // prints 'Worker says: Hello World'
}

worker_threads 模块提供 WorkerisMainThread :

isMainThread: false 表示当前为 worker 线程,true 表示为主线程

new Worker(__filename) 通过指定的 __filename 来注册worker,在我们的例子中 __filename 就是 hello.js

workers通信

下面代码演示了线程之间是如何通信的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// hello.js

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
const worker = new Worker(__filename);
worker.once('message', (message) => {
console.log(message); // prints 'Worker thread: Hello!'
});
worker.postMessage('Main Thread: Hi!');
} else {
parentPort.once('message', (message) => {
console.log(message) // prints 'Main Thread: Hi!'
parentPort.postMessage("Worker thread: Hello!");
});
}

在上面的代码中, 我们初始化一个 worker 线程,使用 parentPort.postMessage() 发一个消息到主线程。然后使用 parentPort.once() 监听主线程传递的消息; 使用 worker.postMessage() 发消息到一个 worker 线程和利用 worker.once()worker 线程中监听消息。

程序最终的输出结果:

1
2
Main Thread: Hi!
Worker thread: Hello!

开发爬虫

创建一个基本的网络爬虫, 使用Node Worker爬取数据并写入本地文件:

1、利用 Fetch 爬取网站的 HTML 页面代码。
2、接受返回的 response.
3、遍历DOM, 从 tbody, tr, 和 td中提取汇率数据。
4、汇率值存储在一个对象并使用 worker.postMessage() 将其发送到 worker 线程.
5、接受来自主线程的消息在 worker 线程使用 parentPort.on()
6、将数据存储在本地文件。

在项目中创建两个文件:

  • 1、main.js – for the main thread
  • 2、dbWorker.js – for the worker thread

主线程(main.js)

使用 axios 发起一个简单的 GET 请求来获取 HTML 代码,使用 cheerio 来遍历 DOM 和并从中提取数据。

devtools

如上图所看到的,table 含有以下class类名: table table-bordered table-hover downloads,我们可以像jQuery 的一样操作 dom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// main.js

const axios = require('axios');
const cheerio = require('cheerio');
const url = "https://www.iban.com/exchange-rates";

fetchData(url).then( (res) => {
const html = res.data;
const $ = cheerio.load(html);
const statsTable = $('.table.table-bordered.table-hover.downloads > tbody > tr');
statsTable.each(function() {
let title = $(this).find('td').text();
console.log(title);
});
})

async function fetchData(url){
console.log("Crawling data...")
// make http call to url
let response = await axios(url).catch((err) => console.log(err));

if(response.status !== 200){
console.log("Error occurred while fetching data");
return;
}
return response;
}

上面代码的运行结果如下:

runcode

后面我们将继续完善 main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// main.js
[...]
let workDir = __dirname+"/dbWorker.js";

const mainFunc = async () => {
const url = "https://www.iban.com/exchange-rates";
// fetch html data from iban website
let res = await fetchData(url);
if(!res.data){
console.log("Invalid data Obj");
return;
}
const html = res.data;
let dataObj = new Object();
// mount html page to the root element
const $ = cheerio.load(html);

let dataObj = new Object();
const statsTable = $('.table.table-bordered.table-hover.downloads > tbody > tr');
//loop through all table rows and get table data
statsTable.each(function() {
let title = $(this).find('td').text(); // get the text in all the td elements
let newStr = title.split("\t"); // convert text (string) into an array
newStr.shift(); // strip off empty array element at index 0
formatStr(newStr, dataObj); // format array string and store in an object
});

return dataObj;
}

mainFunc().then((res) => {
// start worker
const worker = new Worker(workDir);
console.log("Sending crawled data to dbWorker...");
// send formatted data to worker thread
worker.postMessage(res);
// listen to message from worker thread
worker.on("message", (message) => {
console.log(message)
});
});

[...]

function formatStr(arr, dataObj){
// regex to match all the words before the first digit
let regExp = /[^A-Z]*(^\D+)/
let newArr = arr[0].split(regExp); // split array element 0 using the regExp rule
dataObj[newArr[1]] = newArr[2]; // store object
}

我们对数据进行了格式化, mainFunc() 执行后将格式化后的数据回传给 worker 线程。

worker线程(dbWorker.js)

worker 线程中, 我们将初始化重火力点和侦听从主线程爬数据。当数据到达时,我们会将其存储在数据库中并将一条消息发送回主线程确认数据存储是成功的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// dbWorker.js

const { parentPort } = require('worker_threads');
const fs = require('fs');
const path = require('path')

// get current data in DD-MM-YYYY format
let date = new Date();
let currDate = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;

// recieve crawled data from main thread
parentPort.once("message", (content) => {
console.log("Recieved data from mainWorker...");
fs.writeFile(path.join(__dirname, '../db/' + currDate + '.json'), JSON.stringify(content), 'utf8', function(err){
//如果err=null,表示文件使用成功,否则,表示希尔文件失败
if(err) {
console.log('写文件出错了,错误是:' + err);
} else {
// send data back to main thread if operation was successful
parentPort.postMessage("Data saved successfully");
}
})
});

尽管爬取数据的过程可能很有趣, 一旦你使用的数据侵犯版权,它就是是违法的。 所以需要事先了解它的数据隐私策略。

结论

本文中, 我们学习如何创建一个爬虫应用,爬取货币汇率并将它们存到本地文件。另外我们还学习了如何使用worker线程来帮助我们提升爬取速度。

完整的代码都在这里:GitHub