如何抓取頁面所有內容
基本需求
抓取頁面所有內容主要包括一下內容:
- 頁面內元素
頁面元素包含服務端直接返回的元素,動態構建的元素
- 頁面內所有資源
頁面所有資源包含本頁面所在域資源以及第三方域資源,同主域的資源也認為第三方域資源,這種資源一般是以絕對路徑的方式標識,同域下資源主要有三種表現方式 (以https://www.baidu.com舉例)
a). 相對路徑
<image src="./image/logo.png" />
b). 絕對路徑
<image src="https://www.baidu.com/image/logo.png" />
c). 絕對路徑2
<image src="//www.baidu.com/image/logo.png" />
這種表示方式會自動根據瀏覽器打開該頁面的協議請求時加入協議(protocol),本地保存后,基於file協議打開同樣會加入file:前綴。
當前實現方案
基本流程
-
服務端http get 頁面
-
根據服務端響應的html,遍歷需要加載的其它資源,比如javascript、image、css、font、media等資源
-
處理html、javascript、css 等文件,進行資源路徑替換,保證頁面本地化后能正常打開
不足之處
-
http get 只能拿到原始內容,需要依賴后期再瀏覽器中加載之后的再渲染(比如依賴本地化的js再次請求數據進行頁面構建 或者 直接生成dom進行頁面構建)
-
請求后得到的資源文件依賴原本相對路徑,如果處理有較高的技術難度,比如使用AMD、CMD等模式加載的文件。由於當前方案抓取資源時對當前資源目錄層次全部鋪平了(縱向目錄已經不存在了,相對路徑也會變化),所以需要動態修改(拿應用了AMD加載模式的頁面舉例)require.config.js 文件的內容,否則會導致頁面js 無法正常加載,頁面無法正常渲染。
-
對非html頁面直接獲取的資源,獲取的難度較大,這種非html頁面直接獲取的資源包括,css 文件中引入的字體資源文件以及圖片資源文件,js資源文件中引入的資源文件,比如上述2 中描述的AMD、CMD模式實現的按需加載。
新的實現方案
puppeteer是操作chromnium的上層node api,當瀏覽器打開一個頁面是,可以簡單理解細分為如下過程:
- 通知瀏覽器發起請求
- 瀏覽器發起請求
- 瀏覽器獲取響應內容
- 瀏覽器把響應內容交給上層渲染引擎
- 渲染引擎處理
在整個過程中,puppeteer提供了一種機制讓我們有機會攔截到2和3這兩個階段,基於這點,我們可以做更多的事情,比如我們可以攔截頁面的所有請求,可以截獲所有的響應,而不用關注請求的去向,因為只要請求發出去了,就能受我們的控制,另外,由於是使用瀏覽器本身,所以跟直接http get 頁面最大的區別在於前者是渲染后的,后者是原始的,前者對SPA或者依靠腳本構建的應用比較友好。
使用puppeteer實現完全能處理原始方案的不足,新的實現思路如下:
-
攔截所有網絡請求,對資源請求以及構建dom相關請求進行處理
-
對同域名下資源進行相對路徑處理,在本地創建對應的相對路徑
-
對不同域名下資源(第三方資源)以第三方域名為名建立新的目錄,用來存儲第三方資源
-
資源處理,處理html資源,css資源以及javascript文件中絕對路徑為相對路徑(這里絕對路徑是指直接引入的cdn等模式路徑,相對路徑是指對cdn域名本地化目錄后的路徑)
核心代碼說明
基於上述新的方案,實現的核心代碼如下,代碼中加入了詳細的注釋,不再做過多解釋,有疑問歡迎留言討論
const puppeteer = require('puppeteer');
const URL = require('url');
const md5 = require('md5');
const fs = require('fs');
const util = require('util');
const path = require('path');
const shell = require('shelljs');
//資源保存目錄
const BASEDIR = './asserts/';
const start = async () => {
//初始化刪除清理資源目錄,僅測試階段,因為當前目錄為時間戳生成
shell.exec('rm -rf asserts/');
//因為所有網絡請求都會攔截,處理請求和頁面資源以及dom構建無關可忽略
//下面的域名是比較常見的前端采集域名 (有很多沒有列出來的)
const blackList = [
'collect.ptengine.cn',
'collect.ptengine.jp',
'js.ptengine.cn',
'js.ptengine.jp',
'hm.baidu.com',
'api.growingio.com',
'www.google-analytics.com',
'script.hotjar.com',
'vars.hotjar.com'
];
//用來緩存第三方資源(包括css、javascript),在請求沒有結束之前,無法獲取完整的第三方資源列,無法保證css、javascript中內容替換完整,所以先緩存,請求結束后再統一替換
const resourceBufferMap = new Map();
//第三方資源服務(域名)列表
const thirdPartyList = {};
try {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//啟用請求攔截
await page.setRequestInterception(true);
//以博客園為例子進行頁面抓取
let url = "https://www.cnblogs.com"
let docUrl = URL.parse(url);
//獲取請求地址的域名,用來確定資源是否來自第三方
let originUrl = (docUrl.protocol + "//" + docUrl.hostname)
//@fixme 每次抓取生成的內容目錄名稱
let md5_prefix = md5(Date.now());
page.on('request', async (req) => {
const whitelist = ['image', 'script', 'stylesheet', 'document', 'font'];
//如果請求的是第三方域名,只考慮和頁面構建相關的資源
if (req.url().indexOf(originUrl) == -1 && !whitelist.includes(req.resourceType())) {
return req.abort();
}
//采集黑名單中的內容不處理
if (blackList.indexOf(URL.parse(req.url()).host) != -1) {
return req.abort();
}
req.continue();
});
page.on('response', async res => {
let request = res.request(),
resourceUrl = request.url(),
urlObj = URL.parse(resourceUrl),
filePath = urlObj.pathname, //文件路徑
dirPath = path.dirname(filePath), //目錄路徑
requestMethod = request.method().toUpperCase(), //請求方法
isSameOrigin = resourceUrl.includes(originUrl); //是否是同域名請求
//只考慮get請求資源,其它http verb 對文件資源請求較少
if (requestMethod === 'GET') {
//如果是同一個域名下的資源,則直接構建目錄,下載文件
//創建路徑的方式依據請求本身path結構,保證和原資源網站目錄結構完整統一,這樣即使有CMD、AMD規范的代碼再次執行,require相對路徑也不會出現問題。
let dirPathCreatedIfNotExists,
filePathCreatedIfNotExists;
let hostname = urlObj.hostname;
if (isSameOrigin) {
//構建同域名path
//同域名的資源 有時會以//www.xxx.com/images/logo.png 這種方式使用,所以,對這種資源需要特殊處理
thirdPartyList[`//${hostname}`] = '';
dirPathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, dirPath);
filePathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, filePath);
} else {
//第三方資源構建正則表達式,替換http、https、// 三種模式路徑為本地目錄路徑
thirdPartyList[`(https?:)?//${hostname}`] = `/${hostname}`;
dirPathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, hostname, dirPath);
filePathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, hostname, filePath);
}
//獲取擴展名 如果獲取不到 則認為不是資源文件
if (path.extname(filePathCreatedIfNotExists)) {
//路徑不存在,直接創建多級目錄
if (!fs.existsSync(dirPathCreatedIfNotExists)) {
shell.exec(`mkdir -p ${dirPathCreatedIfNotExists}`);
console.log('create dir');
}
if (res.ok()) {
if ((isSameOrigin && dirPath != '/') || !isSameOrigin) {
let needReplace = ['stylesheet', 'script'];
//@fixme toString 可能會有編碼問題
let fileContent = (await res.buffer()).toString();
//第三方域名還獲取,先緩存再處理
if (needReplace.includes(request.resourceType())) {
//js css 文件中可能包含需要替換的內容,需要處理
//所以暫時緩存不寫入文件
resourceBufferMap.set(filePathCreatedIfNotExists, fileContent);
} else {
fs.writeFileSync(filePathCreatedIfNotExists, await res.buffer());
}
}
}
}
}
});
await page.goto(url, {
waitUntil: 'networkidle0'
});
let content = await page.content();
//對css javascript文件 進行替換處理
resourceBufferMap.forEach((value, key) => {
value = applyReplace(value, thirdPartyList);
fs.writeFileSync(key, value);
})
// html 內容處理
content = applyReplace(content, thirdPartyList);
fs.writeFileSync(`./asserts/${md5_prefix}/index.html`, content);
await page.close();
await browser.close();
} catch (error) {
console.log(error);
}
}
function applyReplace(origin, regList) {
for (let prop in regList) {
//進行正則全局替換
let reg = new RegExp(prop, 'g')
origin = origin.replace(reg, regList[prop]);
}
return origin;
}
start();
總結
上述方案能解決幾乎所有原始方案無法解決的問題,但是也並非十全十美,首選,相比原始方案,增加了渲染的步驟,所以性能有所下降;其次如果用戶網站比較特殊,比如https://www.xxx.com/admin 這個路徑下資源,比如某css文件中有如下寫法:'background:url('./xxx.bg.png')' ,這時路徑會找不到,因為在資源路徑替換階段,會替換為hostname,即查找資源是會去根目錄去找,導致路徑not found,不過這有其它改進的方案,比如可以把同域名的路徑做的更靈活一點,可以讓接口消費者修改。
