如何使用 Node 构建网络爬虫
作者:乔丹·伊拉博尔✏️
介绍
网络爬虫(通常简称为爬虫或有时被称为蜘蛛机器人)是一种系统性地浏览互联网的机器人,其主要目的是对网页进行索引。搜索引擎可以利用这些网络机器人来提高用户搜索结果的质量。除了索引万维网之外,爬虫还可以用于收集数据(称为网络抓取)。
网络爬虫过程对 CPU 的消耗相当大,具体取决于网站的结构和提取数据的复杂程度。为了优化和加速这一过程,我们将使用Node 工作线程(线程),它们非常适合处理 CPU 密集型操作。
本文将介绍如何构建一个网络爬虫,用于抓取网站并将数据存储到数据库中。该爬虫机器人将使用 Node 工作节点来执行这两项操作。
先决条件
安装
启动终端并创建一个新目录用于本教程:
$ mkdir worker-tutorial
$ cd worker-tutorial
运行以下命令初始化目录:
$ yarn init -y
我们需要以下软件包来构建爬虫:
- Axios——一个基于 Promise 的 HTTP 客户端,适用于浏览器和 Node.js。
- Cheerio是一个轻量级的 jQuery 实现,它允许我们访问服务器端的 DOM。
- Firebase 数据库——一个云端托管的 NoSQL 数据库。如果您不熟悉如何设置 Firebase 数据库,请查看文档并按照步骤 1-3 开始操作。
让我们使用以下命令安装上面列出的软件包:
$ yarn add axios cheerio firebase-admin
各位员工好
在开始使用 worker 构建爬虫之前,我们先来了解一些基础知识。你可以hello.js在项目根目录创建一个测试文件来运行以下代码片段。
注册工人
可以通过从模块导入 worker 类来初始化(注册)一个 worker,worker_threads如下所示:
// hello.js
const { Worker } = require('worker_threads');
new Worker("./worker.js");
你好世界
使用 Workers进行打印输出Hello World非常简单,只需运行以下代码片段即可:
// 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'
}
这段代码片段从模块中引入工作类和isMainThread对象worker_threads:
isMainThread帮助我们了解当前是在主线程还是工作线程中运行。new Worker(__filename)使用__filename变量注册一个新的工作进程,在本例中,该变量为hello.js
与员工沟通
当一个新的工作线程(worker)被创建时,会有一个消息端口允许线程间通信。以下代码片段展示了如何在工作线程之间传递消息:
// 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!");
});
}
在上面的代码片段中,我们在初始化工作线程后,使用 `send` 函数向父线程发送消息parentPort.postMessage()。然后,我们使用 `listen` 函数监听来自父线程的消息parentPort.once()。我们还使用 `send` 函数向工作线程发送消息worker.postMessage(),并使用 `listen` 函数监听来自工作线程的消息worker.once()。
运行代码后会产生以下输出:
Main Thread: Hi!
Worker thread: Hello!
构建爬行器
让我们构建一个基本的网络爬虫,它使用 Node 工作节点来爬取网页并写入数据库。爬虫将按以下顺序完成任务:
- 从网站获取(请求)HTML
- 从响应中提取 HTML
- 遍历 DOM 并提取包含汇率的表格
- 格式化表格元素(
tbody、、tr和td),并提取汇率值 - 将汇率值存储在对象中,并使用以下方式将其发送到工作线程:
worker.postMessage() - 在工作线程中接受来自父线程的消息
parentPort.on() - 将消息存储到 Firestore(Firebase 数据库)中
让我们在项目目录中创建两个新文件:
main.js——关于主线dbWorker.js– 对于工作线程
本教程的源代码已上传至GitHub,欢迎克隆、fork 或提交 issue。
主线程(main.js)
在主线程中,我们将抓取IBAN 网站的数据,获取常用货币兑美元的当前汇率。我们将导入axios这些数据,并使用简单的请求从该网站获取 HTML 代码GET。
我们还将使用cheerio遍历 DOM 并从表格元素中提取数据。为了确定要提取的确切元素,我们将在浏览器中打开IBAN 网站并加载开发者工具:
从上图中,我们可以看到table带有类名的元素table table-bordered table-hover downloads。这将是一个很好的起点,我们可以将其传递给cheerio根元素选择器:
// 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;
}
使用 Node 运行上述代码将得到以下输出:
接下来,我们将更新该main.js文件,以便正确格式化输出并将其发送到我们的工作线程。
更新主帖
为了正确格式化输出,我们需要删除空格和制表符,因为我们将最终输出存储在文件中JSON。让我们main.js相应地更新文件:
// 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线程进行存储。
工作线程(dbWorker.js)
在这个工作线程中,我们将初始化 Firebase 并监听来自主线程的爬取数据。当数据到达时,我们将把它存储到数据库中,并向主线程发送消息以确认数据存储成功。
下面这段代码片段负责执行上述操作:
// dbWorker.js
const { parentPort } = require('worker_threads');
const admin = require("firebase-admin");
//firebase credentials
let firebaseConfig = {
apiKey: "XXXXXXXXXXXX-XXX-XXX",
authDomain: "XXXXXXXXXXXX-XXX-XXX",
databaseURL: "XXXXXXXXXXXX-XXX-XXX",
projectId: "XXXXXXXXXXXX-XXX-XXX",
storageBucket: "XXXXXXXXXXXX-XXX-XXX",
messagingSenderId: "XXXXXXXXXXXX-XXX-XXX",
appId: "XXXXXXXXXXXX-XXX-XXX"
};
// Initialize Firebase
admin.initializeApp(firebaseConfig);
let db = admin.firestore();
// get current data in DD-MM-YYYY format
let date = new Date();
let currDate = `${date.getDate()}-${date.getMonth()}-${date.getFullYear()}`;
// recieve crawled data from main thread
parentPort.once("message", (message) => {
console.log("Recieved data from mainWorker...");
// store data gotten from main thread in database
db.collection("Rates").doc(currDate).set({
rates: JSON.stringify(message)
}).then(() => {
// send data back to main thread if operation was successful
parentPort.postMessage("Data saved successfully");
})
.catch((err) => console.log(err))
});
注意:要在 Firebase 上设置数据库,请访问Firebase 文档并按照步骤 1-3 开始操作。
使用 Node运行main.js(包括dbWorker.js)将产生以下输出:
您现在可以查看您的 Firebase 数据库,并会看到以下已抓取的数据:
最后说明
虽然网络爬虫很有趣,但如果您利用数据侵犯版权,则可能触犯法律。通常建议您事先阅读目标网站的条款和条件,了解其数据爬取政策。您可以在本页面的“爬取政策”部分了解更多信息。
使用工作线程并不能保证应用程序运行速度更快,但如果使用得当,它可以营造出速度更快的假象,因为它通过减轻主线程上 CPU 密集型任务的负担来释放主线程。
结论
在本教程中,我们学习了如何构建一个网络爬虫,用于抓取货币汇率并将其保存到数据库中。我们还学习了如何使用工作线程来执行这些操作。
以下每个代码片段的源代码都可以在GitHub上找到。欢迎克隆、fork 或提交 issue。
延伸阅读
想了解更多关于工作线程的信息?您可以查看以下链接:
仅限 200 版本:监控生产环境中失败和缓慢的网络请求
部署基于 Node 的 Web 应用或网站并不难,难的是确保 Node 实例持续为应用提供资源。如果您希望确保对后端或第三方服务的请求都能成功,不妨试试 LogRocket。
LogRocket 会对您的应用进行检测,记录页面加载时间、首字节到达时间、慢网络请求等基准性能指标,并记录 Redux、NgRx 和 Vuex 的操作/状态。立即免费开始监控。
文章《如何使用Node构建网络爬虫》最初发表于LogRocket博客。
文章来源:https://dev.to/bnevilleoneill/how-to-build-a-web-crawler-with-node-3hn2





