前面的預備章節中我們大致了解了如何在服務器上的 Node.js 應用出現問題時,從常規的錯誤日志、系統/進程指標以及兜底的核心轉儲這些角度來排查問題。這樣就引出了下一個問題:我們知道進程的 CPU/Memory 高,或者拿到了進程 Crash 后的核心轉儲,要如何去進行分析定位到具體的 JavaScript 代碼段。
其實 Chrome 自帶的 Devtools,對于 JavaScript 代碼的上述 CPU/Memory 問題有著很好的原生解析展示,本節會給大家做一些實用功能和指標的介紹(基于 Chrome v72,不同的版本間使用方式存在差異)。
本書首發在 Github,倉庫地址:https://github.com/aliyun-node/Node.js-Troubleshooting-Guide,云棲社區會同步更新。
I. 導出 JS 代碼運行狀態
當我們通過第一節中提到的系統/進程指標排查發現當前的 Node.js 應用的 CPU 特別高時,首先我們需要去通過一些方式將當前 Node.js 應用一段時間內的 JavaScript 代碼運行狀況 Dump 出來,這樣子才能分析知道 CPU 高的原因。幸運的是,V8 引擎內部實現了一個 CPU Profiler 能夠幫助我們完成一段時間內 JS 代碼運行狀態的導出,目前也有不少成熟的模塊或者工具來幫我們完成這樣的操作。
v8-profiler 是一個老牌的 Node.js 應用性能分析工具,它可以很方便地幫助開發者導出 JS 代碼地運行狀態,我們可以在項目目錄執行如下命令安裝此模塊:
npm install v8-profiler --save
接著可以在代碼中按照如下方式獲取到 5s 內地 JS 代碼運行狀態:
'use strict'; const v8Profiler=require('v8-profiler'); const title='test'; v8Profiler.startProfiling(title, true); setTimeout(()=> { const profiler=v8Profiler.stopProfiling(title); profiler.delete(); console.log(profiler); }, 5000);
那么我們可以看到,v8-profiler 模塊幫我導出的代碼運行狀態實際上是一個很大的 JSON 對象,我們可以將這個 JSON 對象序列化為字符串后存儲到文件:test.cpuprofile 。注意這里的文件名后綴必須為 .cpuprofile ,否則 Chrome devtools 是不識別的。
注意:v8-profiler 目前也處于年久失修的狀態了,在 Node.js 8 和 Node.js 10 上已經無法正確編譯安裝了,如果你在 8 或者 10 的項目中想進行使用,可以試試看 v8-profiler-next。
II. 分析 CPU Profile 文件
借助于 v8-profiler 拿到我們的 Node.js 應用一段時間內的 JS 代碼運行狀態后,我們可以將其導入 Chrome devtools 中進行分析展示。
在 Chrome 72 中,分析我們 Dump 出來的 CPU Profile 的方法已經和之前有所不同了,默認工具欄中也不會展示 CPU Profile 的分析頁面,我們需要通過點擊工具欄右側的 更多 按鈕,然后選擇 More tools -> JavaScript Profiler 來進入到 CPU 的分析頁面,如下圖所示:
選中 JavaScript Profiler 后,在出現的頁面上點擊 Load 按鈕,然后將剛才保存得到的 test.cpuprofile 文件加載進來,就可以看到 Chrome devtools 的解析結果了:
這里默認的視圖是 Heavy 視圖,在這個視圖下,Devtools 會按照對你的應用的影響程度從高到低,將這些函數列舉出來,點擊展開能夠看到這些列舉出來的函數的全路徑,方便你去代碼中對應的位置進行排查。這里解釋兩個比較重要的指標,以便讓大家能更有針對性地進行排查:
像在上述地截圖例子中,ejs 模塊在線上都應該開啟了緩存,所以 ejs 模塊的 compile 方法不應該出現在列表中,這顯然是一個非常可疑的性能損耗點,需要我們去展開找到原因。
除了 Heavy 視圖,Devtools 實際上還給我們提供了火焰圖來進行更多維度的展示,點擊左上角可以切換:
火焰圖按照我們的 CPU 采樣時間軸進行展示,那么在這里我們更容易看到我們的 Node.js 應用在采樣期間 JS 代碼的執行行為,新增的兩個指標這邊也給大家解釋一下其含義:
綜上,借助于 Chrome devtools 和能夠導出當前 Node.js 應用 Javascript 代碼運行狀態的模塊,我們已經可以比較完備地對應用服務異常時,排查定位到相應的 Node.js 進程 CPU 很高的情況進行排查和定位分析了。在生產實踐中,這部分的 JS 代碼的性能的分析往往也會用到新項目上線前的性能壓測中,有興趣的同學可以更深入地研究下。
I. 判斷是否內存泄漏
在筆者的經歷中,內存泄漏問題是 Node.js 在線上運行時出現的問題種類中的重災區。尤其是三方庫自身的 Bug 或者開發者使用不當引起的內存泄漏,會讓很多的 Node.js 開發者感到束手無策。本節首先向讀者介紹下,什么情況下我們的應用算是有很大的可能在發生內存泄漏呢?
實際上判斷我們的線上 Node.js 應用是否有內存泄漏也非常簡單:借助于大家各自公司的一些系統和進程監控工具,如果我們發現 Node.js 應用的總內存占用曲線 處于長時間的只增不降 ,并且堆內存按照趨勢突破了 堆限制的 70% 了,那么基本上應用 很大可能 產生了泄漏。
當然事無絕對,如果確實應用的訪問量(QPS)也在一直增長中,那么內存曲線只增不減也屬于正常情況,如果確實因為 QPS 的不斷增長導致堆內存超過堆限制的 70% 甚至 90%,此時我們需要考慮的擴容服務器來緩解內存問題。
II. 導出 JS 堆內存快照
如果確認了 Node.js 應用出現了內存泄漏的問題,那么和上面 CPU 的問題一樣,我們需要通過一些辦法導出 JS 內存快照(堆快照)來進行分析。V8 引擎同樣在內部提供了接口可以直接將分配在 V8 堆上的 JS 對象導出來供開發者進行分析,這里我們采用 heapdump 這個模塊,首先依舊是執行如下命令進行安裝:
npm install heapdump --save
接著可以在代碼中按照如下方法使用此模塊:
'use sytrict'; const heapdump=require('heapdump'); heapdump.writeSnapshot('./test' + '.heapsnapshot');
這樣我們就在當前目錄下得到了一個堆快照文件:test.heapsnapshot ,用文本編輯工具打開這個文件,可以看到其依舊是一個很大的 JSON 結構,同樣這里的堆快照文件后綴必須為 .heapsnapshot ,否則 Chrome devtools 是不識別的。
III. 分析堆快照
在 Chrome devtools 的工具欄中選擇 Memory 即可進入到分析頁面,如下圖所示:
然后點擊頁面上的 Load 按鈕,選擇我們剛才生成 test.heapsnapshot 文件,就可以看到分析結果,如下圖所示:
默認的視圖其實是一個 Summary 視圖,這里的 Constructor 和我們編寫 JS 代碼時的構造函數并無不同,都是指代此構造函數創建的對象,新版本的 Chrome devtools 中還在構造函數后面增加 * number 的信息,它代表這個構造函數創建的實例的個數。
實際上在堆快照的分析視圖中,有兩個非常重要的概念需要大家去理解,否則很可能拿到堆快照看著分析結果也無所適從,它們是 Shallow Size 和 Retained Size ,要更好地去理解這兩個概念,我們需要先了解 支配樹。首先我們看如下簡化后的堆快照描述的內存關系圖:
這里的 1 為根節點,即 GC 根,那么對于對象 5 來說,如果我們想要讓對象 5 回收(即從 GC 根不可達),僅僅去掉對象 4 或者對象 3 對于對象 5 的引用是不夠的,因為顯然從根節點 1 可以分別從對象 3 或者對象 4 遍歷到對象 5。因此我們只有去掉對象 2 才能將對象 5 回收,所以在上面這個圖中,對象 5 的直接支配者是對象 2。照著這個思路,我們可以通過一定的算法將上述簡化后的堆內存關系圖轉化為支配樹:
對象 1 到對象 8 間的支配關系描述如下:
好了,到這里我們可以開始解釋什么是 Shallow Size 和 Retained Size 了,實際上對象的 Shallow Size 就是對象自身被創建時,在 V8 堆上分配的大小,結合上面的例子,即對象 1 到 8 自身的大小。對象的 Retained Size 則是把此對象從堆上拿掉,則 Full GC 后 V8 堆能夠釋放出的空間大小。同樣結合上面的例子,支配樹的葉子節點對象 3、對象 7 和對象 8 因為沒有任何直接支配對象,因此其 Retained Size 等于其 Shallow Size。
將剩下的非葉子節點可以一一展開,為了篇幅描述方便,SZ_5表示對象 5 的 Shallow Size,RZ_5 表示對象 5 的 Retained Size,那么可以得到如下結果:
這里可以發現,GC 根的 Retained Size 等于堆上所有從此根出發可達對象的 Shallow Size 之和,這和我們的理解預期是相符合的,畢竟將 GC 根從堆上拿掉的話,原本就應當將從此根出發的所有對象都清理掉。
理解了這一點,回到我們最開始看到的默認總覽視圖中,正常來說,可能的泄漏對象往往其 Retained Size 特別大,我們可以在窗口中依據 Retained Size 進行排序來對那些占據了堆空間絕大部分的對象進行排查:
假如確認了可疑對象,Chrome devtools 中也會給你自動展開方便你去定位到代碼段,下面以 NativeModule 這個構造器生成的對象 vm 為例:
這里上半部分是順序的引用關系,比如 NativeModule 實例 @45655 的 exports 屬性指向了對象 @45589,filename 屬性則指向一個字符串 "vm.js";下半部分則是反向的引用關系:NativeModule 實例 @13021 的 _cache 屬性指向了 Object 實例 @41103,而 Object 實例 @41103 的 vm 屬性指向了 NativeModule 實例 @45655。
如果對這部分展示圖表比較暈的可以仔細看下上面的例子,因為找到可疑的泄漏對象,結合上圖能看到此對象下的屬性和值及其父引用關系鏈,絕大部分情況下我們就可以定位到生成可疑對象的 JS 代碼段了。
實際上除了默認的 Summary 視圖,Chrome devtools 還提供了 Containment 和 Statistics 視圖,這里再看下 Containment 視圖,選擇堆快照解析頁面的左上角可以進行切換,如下圖所示:
這個視圖實際上是堆快照解析出來的內存關系圖的直接展示,因此相比 Summary 視圖,從這個視圖中直接查找可疑的泄漏對象相對比較困難。
Chrome devtools 實際上是非常強大的一個工具,本節也只是僅僅介紹了對 CPU Profile 和堆快照解析能力的介紹和常用視圖的使用指南,如果你仔細閱讀了本節內容,面對服務器上定位到的 Node.js 應用 CPU 飆高或者內存泄漏這樣的問題,想必就可以做到心中有數不慌亂了。
作者:奕鈞
webpack 出現之前,前端開發人員會使用 grunt 和 gulp 等工具來處理資源,并將它們從 /src 文件夾移動到 /dist 或 /build 目錄中。JavaScript 模塊也遵循同樣方式,但是,像 webpack 這樣的工具,將動態打包所有依賴(創建所謂的 依賴圖(dependency graph))。這是極好的創舉,因為現在每個模塊都可以明確表述它自身的依賴,可以避免打包未使用的模塊。
webpack 最出色的功能之一就是,除了引入 JavaScript,還可以通過 loader 或內置的 Asset Modules 引入任何其他類型的文件。也就是說,以上列出的那些 JavaScript 的優點(例如顯式依賴),同樣可以用來構建 web 站點或 web 應用程序中的所有非 JavaScript 內容。讓我們從 CSS 開始起步,或許你可能已經熟悉了下面這些設置。
首先我們創建一個目錄webpack-css,用npm初始化 ,然后在本地安裝 webpack:
mkdir webpack-css
cd webpack-css
npm init -y
npm install webpack webpack-cli --save-dev
目錄結構、文件和內容如下:
webpack-css
|- package.json
|- /dist
|- index.html
|-webpack.config.js
其中dist/index.html文件內容為:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>webpack 資源管理</title>
<script src="bundle.js"></script>
</head>
<body>
<div class="css_demo">css 資源管理</div>
</body>
</html>
webpack.config.js的文件內容如下:
const path=require('path');
module.exports={
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};
package.js的文件內容如下:
{
"name": "webpack-css",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"css-loader": "^5.2.0",
"style-loader": "^2.0.0",
"webpack": "^5.28.0"
}
}
為了在 JavaScript 模塊中 import 一個 CSS 文件,你需要安裝 style-loader 和 css-loader,并在 module 配置 中添加這些 loader:
npm install --save-dev style-loader css-loader
修改webpack.config.js添加加載CSS:
const path=require('path')
module.exports={
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname,'dist')
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader']
}
]
}
}
模塊 loader 可以鏈式調用。鏈中的每個 loader 都將對資源進行轉換。鏈會按逆序執行。第一個 loader 將其結果(被轉換后的資源)傳遞給下一個 loader,依此類推。最后,webpack 期望鏈中的最后的 loader 返回 JavaScript。
應保證 loader 的先后順序:'style-loader' 在前,而 'css-loader' 在后。如果不遵守此約定,webpack 可能會拋出錯誤。
webpack 根據正則表達式,來確定應該查找哪些文件,并將其提供給指定的 loader。在這個示例中,所有以 .css 結尾的文件,都將被提供給 style-loader 和 css-loader。
這使你可以在依賴于此樣式的 js 文件中使用 import './style.css'。現在,在此模塊執行過程中,含有 CSS 字符串的 <style> 標簽,將被插入到 html 文件的 <head> 中。
我們嘗試一下,通過在項目中添加一個新的 style.css 文件,并將其 import 到我們的 index.js 中。
在src的目錄下創建 style.css 文件,src/style.css文件內容如下:
.css_demo {
color: red;
}
在src/index.js的文件中引入style.css:
import './style.css'
執行打包命令
npm run build
> webpack-css@1.0.0 build D:\project\mockjs\webpack-css
> webpack
asset bundle.js 3.58 KiB [emitted] [minimized] (name: main)
runtime modules 663 bytes 3 modules
orphan modules 326 bytes [orphan] 1 module
cacheable modules 8.9 KiB
modules by path ./src/ 679 bytes
./src/index.js + 1 modules 346 bytes [built] [code generated]
./node_modules/css-loader/dist/cjs.js!./src/style.css 333 bytes [built] [code generated]
modules by path ./node_modules/ 8.23 KiB
./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js 6.67 KiB [built] [code generated]
./node_modules/css-loader/dist/runtime/api.js 1.57 KiB [built] [code generated]
WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value.
Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/
webpack 5.28.0 compiled with 1 warning in 1573 ms
再次在瀏覽器中打開 dist/index.html,你應該看到 css 資源管理 現在的樣式是紅色。要查看 webpack 做了什么,請檢查頁面(不要查看頁面源代碼,它不會顯示結果,因為 <style> 標簽是由 JavaScript 動態創建的),并查看頁面的 head 標簽。它應該包含 style 塊元素,也就是我們在 index.js 中 import 的 css 文件中的樣式。
效果
注意,在多數情況下,你也可以進行 壓縮 CSS,以便在生產環境中節省加載時間。最重要的是,現有的 loader 可以支持任何你可以想到的 CSS 風格 - postcss, sass 和 less 等。
文講解怎樣用 Node.js 高效地從 Web 爬取數據。
前提條件
本文主要針對具有一定 JavaScript 經驗的程序員。如果你對 Web 抓取有深刻的了解,但對 JavaScript 并不熟悉,那么本文仍然能夠對你有所幫助。
你將學到
通過本文你將學到:
了解 Node.js
Javascript 是一種簡單的現代編程語言,最初是為了向瀏覽器中的網頁添加動態效果。當加載網站后,Javascript 代碼由瀏覽器的 Javascript 引擎運行。為了使 Javascript 與你的瀏覽器進行交互,瀏覽器還提供了運行時環境(document、window等)。
這意味著 Javascript 不能直接與計算機資源交互或對其進行操作。例如在 Web 服務器中,服務器必須能夠與文件系統進行交互,這樣才能讀寫文件。
Node.js 使 Javascript 不僅能夠運行在客戶端,而且還可以運行在服務器端。為了做到這一點,其創始人 Ryan Dahl 選擇了Google Chrome 瀏覽器的 v8 Javascript Engine,并將其嵌入到用 C++ 開發的 Node 程序中。所以 Node.js 是一個運行時環境,它允許 Javascript 代碼也能在服務器上運行。
與其他語言(例如 C 或 C++)通過多個線程來處理并發性相反,Node.js 利用單個主線程并并在事件循環的幫助下以非阻塞方式執行任務。
要創建一個簡單的 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}/`);
});
如果你已安裝了 Node.js,可以試著運行上面的代碼。Node.js 非常適合 I/O 密集型程序。
HTTP 客戶端:訪問 Web
HTTP 客戶端是能夠將請求發送到服務器,然后接收服務器響應的工具。下面提到的所有工具底的層都是用 HTTP 客戶端來訪問你要抓取的網站。
Request
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/issues/3142 找到棄用通知及其含義。
Axios
Axios 是基于 promise 的 HTTP 客戶端,可在瀏覽器和 Node.js 中運行。如果你用 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 仍處于 stage 3 ,所以我們只好先用異步函數來代替:
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!可以在 https://github.com/axios/axios 上找到Axios庫。
Superagent
與 Axios 一樣,Superagent 是另一個強大的 HTTP 客戶端,它支持 Promise 和 async/await 語法糖。它具有像 Axios 這樣相當簡單的 API,但是 Superagent 由于存在更多的依賴關系并且不那么流行。
用 promise、async/await 或回調向 Superagent 發出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)
}
}
可以在 https://github.com/visionmedia/superagent 找到 Superagent。
正則表達式:艱難的路
在沒有任何依賴性的情況下,最簡單的進行網絡抓取的方法是,使用 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中)將找到我們想要的 <label> 標記的 textContent 或 innerHTML。但是結果中包含一些不需要的文本( “Username: “),必須將其刪除。
如你所見,對于一個非常簡單的用例,步驟和要做的工作都很多。這就是為什么應該依賴 HTML 解析器的原因,我們將在后面討論。
Cheerio:用于遍歷 DOM 的核心 JQuery
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 應用是嚴重依賴 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 中。
然后在瀏覽器的 Dev Tools 幫助下,可以獲得可以定位所有列表項的選擇器。如果你使用過 JQuery,則必須非常熟悉 $('div> p.title> a')。這將得到所有帖子,因為你只希望單獨獲取每個帖子的標題,所以必須遍歷每個帖子,這些操作是在 each() 函數的幫助下完成的。
要從每個標題中提取文本,必須在 Cheerio 的幫助下獲取 DOM元素( el 指代當前元素)。然后在每個元素上調用 text() 能夠為你提供文本。
現在,打開終端并運行 node crawler.js,然后你將看到大約存有標題的數組,它會很長。盡管這是一個非常簡單的用例,但它展示了 Cheerio 提供的 API 的簡單性質。
如果你的用例需要執行 Javascript 并加載外部源,那么以下幾個選項將很有幫助。
JSDOM:Node 的 DOM
JSDOM 是在 Node.js 中使用的文檔對象模型的純 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 與網站進行交互,我們將獲得 Reddit r/programming 論壇的第一篇帖子并對其進行投票,然后驗證該帖子是否已被投票。
首先運行以下命令來安裝 jsdom 和 axios:npm install jsdom axios
然后創建名為 crawler.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。然后通過先前獲取的 HTML 來創建新的 DOM。JSDOM 構造函數把HTML 作為第一個參數,把 option 作為第二個參數,已添加的 2 個 option 項執行以下功能:
創建 DOM 后,用相同的 DOM 方法得到第一篇文章的 upvote 按鈕,然后單擊。要驗證是否確實單擊了它,可以檢查 classList 中是否有一個名為 upmod 的類。如果存在于 classList 中,則返回一條消息。
打開終端并運行 node crawler.js,然后會看到一個整潔的字符串,該字符串將表明帖子是否被贊過。盡管這個例子很簡單,但你可以在這個基礎上構建功能強大的東西,例如,一個圍繞特定用戶的帖子進行投票的機器人。
如果你不喜歡缺乏表達能力的 JSDOM ,并且實踐中要依賴于許多此類操作,或者需要重新創建許多不同的 DOM,那么下面將是更好的選擇。
Puppeteer:無頭瀏覽器
顧名思義,Puppeteer 允許你以編程方式操縱瀏覽器,就像操縱木偶一樣。它通過為開發人員提供高級 API 來默認控制無頭版本的 Chrome。
Puppeteer 比上述工具更有用,因為它可以使你像真正的人在與瀏覽器進行交互一樣對網絡進行爬取。這就具備了一些以前沒有的可能性:
它還可以在 Web 爬取之外的其他任務中發揮重要作用,例如 UI 測試、輔助性能優化等。
通常你會想要截取網站的屏幕截圖,也許是為了了解競爭對手的產品目錄,可以用 puppeteer 來做到。首先運行以下命令安裝 puppeteer,:npm install puppeteer
這將下載 Chromium 的 bundle 版本,根據操作系統的不同,該版本大約 180 MB 至 300 MB。如果你要禁用此功能。
讓我們嘗試在 Reddit 中獲取 r/programming 論壇的屏幕截圖和 PDF,創建一個名為 crawler.js的新文件,然后復制粘貼以下代碼:
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 變量中 url 對應的屏幕截圖和 pdf。首先,通過 puppeteer.launch() 創建瀏覽器實例,然后創建一個新頁面。可以將該頁面視為常規瀏覽器中的選項卡。然后通過以 URL 為參數調用 page.goto() ,將先前創建的頁面定向到指定的 URL。最終,瀏覽器實例與頁面一起被銷毀。
完成操作并完成頁面加載后,將分別使用 page.screenshot() 和 page.pdf() 獲取屏幕截圖和 pdf。你也可以偵聽 javascript load 事件,然后執行這些操作,在生產環境級別下強烈建議這樣做。
在終端上運行 node crawler.js ,幾秒鐘后,你會注意到已經創建了兩個文件,分別名為 screenshot.jpg 和 page.pdf。
Nightmare:Puppeteer 的替代者
Nightmare 是類似 Puppeteer 的高級瀏覽器自動化庫,該庫使用 Electron,但據說速度是其前身 PhantomJS 的兩倍。
如果你在某種程度上不喜歡 Puppeteer 或對 Chromium 捆綁包的大小感到沮喪,那么 nightmare 是一個理想的選擇。首先,運行以下命令安裝 nightmare 庫:npm install nightmare
然后,一旦下載了 nightmare,我們將用它通過 Google 搜索引擎找到 ScrapingBee 的網站。創建一個名為crawler.js的文件,然后將以下代碼復制粘貼到其中:
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() 將該實例定向到 Google 搜索引擎,加載后,使用其選擇器獲取搜索框,然后使用搜索框的值(輸入標簽)更改為“ScrapingBee”。完成后,通過單擊 “Google搜索” 按鈕提交搜索表單。然后告訴 Nightmare 等到第一個鏈接加載完畢,一旦完成,它將使用 DOM 方法來獲取包含該鏈接的定位標記的 href 屬性的值。
最后,完成所有操作后,鏈接將打印到控制臺。
總結
*請認真填寫需求信息,我們會在24小時內與您取得聯系。