使用NodeJS请求抓取带有进程Cookie认证的站点 译文
作者 | Lokesh Joshi
译者 | 张哲刚
审校丨Noe
简介
当前,NodeJS拥有大量的库,基本上可以解决所有的常规需求。网络抓取是一项门槛较低的技术,衍生了大量自由职业者以及开发团队。自然而然,NodeJS的库生态系统几乎包含了网络解析所需的一切。
本文论述中,首先假定已经运行在应用程序皆为NodeJS解析而工作的核心设备之上。此外,我们将研究一个示例,从销售品牌服装和饰品的网站https://outlet.scotch-soda.com 站点中的几百个页面里收集数据。这些代码示例类似于一些真实的抓取应用程序,其中一个就是在Yelp抓取中使用。
当然,由于本文研究所限,示例中删除了一些生产组件,例如数据库、容器化、代理连接以及进程管理工具(例如pm2)。另外,在诸如linting这类显而易见的事务上也不会停止。
但是,我们会保证项目的基本结构完善,将使用最流行的库(Axios,Cheerio,Lodash),使用Puppeter提取授权密钥,使用NodeJS流抓取数据并将其写入文件。
术语规定
本文将使用以下术语:NodeJS应用程序——服务器应用程序;网站 outlet.scotch-soda.com ——Web资源,网站服务器为Web服务器。大体来说,首先是在Chrome或Firefox中探究网站网络资源及其页面,然后运行一个服务器应用程序,向Web服务器发送HTTP请求,最后收到带有相应数据的响应。
获取授权Cookie
outlet.scotch-soda.com的内容仅对授权用户开放。本示例中,授权将通过由服务器应用程序控制的Chromium浏览器实施,cookie也是从其中接收。这些Cookie将包含在每个向web服务器发出的HTTP请求上的HTTP标头中,从而允许应用程序访问这些授权内容。当抓取具有数万乃至数十万页面的大量资源时,接收到的Cookie需要更新一些次数。
该应用程序将具有以下结构:
cookieManager.js:带有Cookie管理器类的文件,用以负责获取cookie;
cookie-storage.js: cookie 变量文件;
index.js:安排Cookie管理器调用点;
.env:环境变量文件。
/project_root
|__ /src
| ? |__ /helpers
| ? ? ?|__ **cookie-manager.js**
| ? ? ?|__ **cookie-storage.js**
|**__ .env**
|__ **index.js**
主目录和文件结构
将以下代码添加到应用程序中:
// index.js
// including environment variables in .env
require('dotenv').config();
const cookieManager = require('./src/helpers/cookie-manager');
const { setLocalCookie } = require('./src/helpers/cookie-storage');
// IIFE - application entry point
(async () => {
// CookieManager call point
// login/password values are stored in the .env file
const cookie = await cookieManager.fetchCookie(
process.env.LOGIN,
process.env.PASSWORD,
);
if (cookie) {
// if the cookie was received, assign it as the value of a storage variable
setLocalCookie(cookie);
} else {
console.log('Warning! Could not fetch the Cookie after 3 attempts. Aborting the process...');
// close the application with an error if it is impossible to receive the cookie
process.exit(1);
}
})();
在cookie-manager.js中:
// cookie-manager.js
// 'user-agents' generates 'User-Agent' values for HTTP headers
// 'puppeteer-extra' - wrapper for 'puppeteer' library
const _ = require('lodash');
const UserAgent = require('user-agents');
const puppeteerXtra = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
// hide from webserver that it is bot
puppeteerXtra.use(StealthPlugin());
class CookieManager {
// this.browser & this.page - Chromium window and page instances
constructor() {
this.browser = null;
this.page = null;
this.cookie = null;
}
// getter
getCookie() {
return this.cookie;
}
// setter
setCookie(cookie) {
this.cookie = cookie;
}
async fetchCookie(username, password) {
// give 3 attempts to authorize and receive cookies
const attemptCount = 3;
try {
// instantiate Chromium window and blank page
this.browser = await puppeteerXtra.launch({
args: ['--window-size=1920,1080'],
headless: process.env.NODE_ENV === 'PROD',
});
// Chromium instantiates blank page and sets 'User-Agent' header
this.page = await this.browser.newPage();
await this.page.setUserAgent((new UserAgent()).toString());
for (let i = 0; i < attemptCount; i += 1) {
// Chromium asks the web server for an authorization page
//and waiting for DOM
await this.page.goto(process.env.LOGIN_PAGE, { waitUntil: ['domcontentloaded'] });
// Chromium waits and presses the country selection confirmation button
// and falling asleep for 1 second: page.waitForTimeout(1000)
await this.page.waitForSelector('#changeRegionAndLanguageBtn', { timeout: 5000 });
await this.page.click('#changeRegionAndLanguageBtn');
await this.page.waitForTimeout(1000);
// Chromium waits for a block to enter a username and password
await this.page.waitForSelector('div.login-box-content', { timeout: 5000 });
await this.page.waitForTimeout(1000);
// Chromium enters username/password and clicks on the 'Log in' button
await this.page.type('input.email-input', username);
await this.page.waitForTimeout(1000);
await this.page.type('input.password-input', password);
await this.page.waitForTimeout(1000);
await this.page.click('button[value="Log in"]');
await this.page.waitForTimeout(3000);
// Chromium waits for target content to load on 'div.main' selector
await this.page.waitForSelector('div.main', { timeout: 5000 });
// get the cookies and glue them into a string of the form <key>=<value> [; <key>=<value>]
this.setCookie(
_.join(
_.map(
await this.page.cookies(),
({ name, value }) => _.join([name, value], '='),
),
'; ',
),
);
// when the cookie has been received, break the loop
if (this.cookie) break;
}
// return cookie to call point (in index.js)
return this.getCookie();
} catch (err) {
throw new Error(err);
} finally {
// close page and browser instances
this.page && await this.page.close();
this.browser && await this.browser.close();
}
}
}
// export singleton
module.exports = new CookieManager();
某些变量的值是链接到.env文件的。
// .env
NODE_ENV=DEV
LOGIN_PAGE=https://outlet.scotch-soda.com/de/en/login
LOGIN=tyrell.wellick@ecorp.com
PASSWORD=i*m_on@kde
例如,配置无头消息属性,发送到方法 puppeteerXtra.launch解析为布尔值,它取决于状态可变的process.env.node_env 。在开发过程中,变量被设置为DEV,无头变量被设置为false,因此Puppeteer能够明白它此刻应该在监视器上呈现执行Chromium 。
方法page.cookies返回一个对象数组,每个对象定义一个 cookie 并包含两个属性:名称和值 。使用一系列 Lodash 函数,循环提取每个 cookie 的键值对,并生成类似于下面的字符串:
文件 cookie-storage.js:
// cookie-storage.js
// cookie storage variable
let localProcessedCookie = null;
// getter
const getLocalCookie = () => localProcessedCookie;
// setter
const setLocalCookie = (cookie) => {
localProcessedCookie = cookie;
// lock the getLocalCookie function;
// its scope with the localProcessedCookie value will be saved
// after the setLocalCookie function completes
return getLocalCookie;
};
module.exports = {
setLocalCookie,
getLocalCookie,
};
对于闭包的定义,明确的思路是:在该变量作用域内的函数结束后,保持对某个变量值的访问。通常,当函数完成执行返回操作时,它会离开调用堆栈,垃圾回收机制会从作用域内删除内存中的所有变量。
上面的示例中,本地cookie设置器完成设置后,应该回收的本地已处理cookie变量的值将保留在计算机的内存中。这就意味着只要应用程序在运行,它就可以在代码中的任何地方获取这个值。
这样,当调用setLocalCookie时,将从中返回getLocalCookie函数。一旦这个LocalCookie函数作用域面临回收时,NodeJS能够看到它具有getLocalCookie闭包函数。此时,垃圾回收机制将返回的获取器作用域内的所有变量都保留在内存中。由于可变的本地处理Cookie在getLocalCookie的作用域内,因此它将继续存在,保持与Cookie的绑定。
URL生成器
应用程序需要一个url的主列表才能开始爬取。在生产过程中,爬取通常从Web资源的主页开始,经过一定数量次数的迭代,最终建立一个指向登录页面的链接集合。通常,一个Web资源有成千上万个这样的链接。
在此示例中,爬取程序只会传输8个爬取链接作为输入,链接指向包含着主要产品分类目录的页面,它们分别是:
?? https://outlet.scotch-soda.com/women/clothing??
?? https://outlet.scotch-soda.com/women/footwear??
?? https://outlet.scotch-soda.com/women/accessories/all-womens-accessories??
?? https://outlet.scotch-soda.com/men/clothing??
?? https://outlet.scotch-soda.com/men/footwear??
?? https://outlet.scotch-soda.com/men/accessories/all-mens-accessories??
?? https://outlet.scotch-soda.com/kids/girls/clothing/all-girls-clothing??
?? https://outlet.scotch-soda.com/kids/boys/clothing/all-boys-clothing??
使用这么长的链接字符,会影响代码美观性,为了避免这种情形,让我们用下列文件创建一个短小精悍的URL构建器:
categories.js: 包含路由参数的文件;
target-builder.js: 构建url集合的文件.
/project_root
|__ /src
| ? |__ /constants
| | ?|__ **categories.js**
| ? |__ /helpers
| ? ? ?|__ cookie-manager.js
| ? ? ?|__ cookie-storage.js
| ? ? ?|__ **target-builder.js**
|**__ .env**
|__ index.js
添加以下代码:
// .env
MAIN_PAGE=https://outlet.scotch-soda.com
// index.js
// import builder function
const getTargetUrls = require('./src/helpers/target-builder');
(async () => {
// here the proccess of getting cookie
// gets an array of url links and determines it's length L
const targetUrls = getTargetUrls();
const { length: L } = targetUrls;
})();
// categories.js
module.exports = [
'women/clothing',
'women/footwear',
'women/accessories/all-womens-accessories',
'men/clothing',
'men/footwear',
'men/accessories/all-mens-accessories',
'kids/girls/clothing/all-girls-clothing',
'kids/boys/clothing/all-boys-clothing',
];
// target-builder.js
const path = require('path');
const categories = require('../constants/categories');
// static fragment of route parameters
const staticPath = 'global/en';
// create URL object from main page address
const url = new URL(process.env.MAIN_PAGE);
// add the full string of route parameters to the URL object
// and return full url string
const addPath = (dynamicPath) => {
url.pathname = path.join(staticPath, dynamicPath);
return url.href;
};
// collect URL link from each element of the array with categories
module.exports = () => categories.map((category) => addPath(category));
这三个代码片段构建了本段开头给出的8个链接,演示了内置的URL以及路径库的使用。可能有人会觉得,这不是大炮打蚊子嘛!使用插值明明更简单啊!
有明确规范的NodeJS方法用于处理路由以及URL请求参数,主要是基于以下两个原因:
1、插值在轻量级应用下还好;
2、为了养成良好的习惯,应当每天使用。
爬网和抓取
向服务器应用程序的逻辑中心添加两个文件:
·crawler.js:包含用于向 Web 服务器发送请求和接收网页标记的爬网程序类;
·parser.js:包含解析器类,其中包含用于抓取标记和获取目标数据的方法。
/project_root
|__ /src
| ? |__ /constants
| | ?|__ categories.js
| ? |__ /helpers
| ? | ?|__ cookie-manager.js
| ? | ?|__ cookie-storage.js
| ? | ?|__ target-builder.js
****| ? |__ **crawler.js**
| ? |__ **parser.js**
|**__** .env
|__ **index.js**
首先,添加一个循环index.js,它将依次传递URL链接到爬取程序并接收解析后的数据:
// index.js
const crawler = new Crawler();
(async () => {
// getting Cookie proccess
// and url-links array...
const { length: L } = targetUrls;
// run a loop through the length of the array of url links
for (let i = 0; i < L; i += 1) {
// call the run method of the crawler for each link
// and return parsed data
const result = await crawler.run(targetUrls[i]);
// do smth with parsed data...
}
})();
爬取代码:
// crawler.js
require('dotenv').config();
const cheerio = require('cheerio');
const axios = require('axios').default;
const UserAgent = require('user-agents');
const Parser = require('./parser');
// getLocalCookie - closure function, returns localProcessedCookie
const { getLocalCookie } = require('./helpers/cookie-storage');
module.exports = class Crawler {
constructor() {
// create a class variable and bind it to the newly created Axios object
// with the necessary headers
this.axios = axios.create({
headers: {
cookie: getLocalCookie(),
'user-agent': (new UserAgent()).toString(),
},
});
}
async run(url) {
console.log('IScraper: working on %s', url);
try {
// do HTTP request to the web server
const { data } = await this.axios.get(url);
// create a cheerio object with nodes from html markup
const $ = cheerio.load(data);
// if the cheerio object contains nodes, run Parser
// and return to index.js the result of parsing
if ($.length) {
const p = new Parser($);
return p.parse();
}
console.log('IScraper: could not fetch or handle the page content from %s', url);
return null;
} catch (e) {
console.log('IScraper: could not fetch the page content from %s', url);
return null;
}
}
};
解析器的任务是在接收到 cheerio 对象时选择数据,然后为每个 URL 链接构建以下结构:
[
{
"Title":"Graphic relaxed-fit T-shirt | Women",
"CurrentPrice":25.96,
"Currency":"