超越Ctrl+S保存頁面所有資源


如何抓取頁面所有內容

基本需求

抓取頁面所有內容主要包括一下內容:

  1. 頁面內元素

頁面元素包含服務端直接返回的元素,動態構建的元素

  1. 頁面內所有資源

頁面所有資源包含本頁面所在域資源以及第三方域資源,同主域的資源也認為第三方域資源,這種資源一般是以絕對路徑的方式標識,同域下資源主要有三種表現方式 (以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:前綴。

當前實現方案

基本流程

  1. 服務端http get 頁面

  2. 根據服務端響應的html,遍歷需要加載的其它資源,比如javascript、image、css、font、media等資源

  3. 處理html、javascript、css 等文件,進行資源路徑替換,保證頁面本地化后能正常打開

不足之處

  1. http get 只能拿到原始內容,需要依賴后期再瀏覽器中加載之后的再渲染(比如依賴本地化的js再次請求數據進行頁面構建 或者 直接生成dom進行頁面構建)

  2. 請求后得到的資源文件依賴原本相對路徑,如果處理有較高的技術難度,比如使用AMD、CMD等模式加載的文件。由於當前方案抓取資源時對當前資源目錄層次全部鋪平了(縱向目錄已經不存在了,相對路徑也會變化),所以需要動態修改(拿應用了AMD加載模式的頁面舉例)require.config.js 文件的內容,否則會導致頁面js 無法正常加載,頁面無法正常渲染。

  3. 對非html頁面直接獲取的資源,獲取的難度較大,這種非html頁面直接獲取的資源包括,css 文件中引入的字體資源文件以及圖片資源文件,js資源文件中引入的資源文件,比如上述2 中描述的AMD、CMD模式實現的按需加載。

新的實現方案

puppeteer是操作chromnium的上層node api,當瀏覽器打開一個頁面是,可以簡單理解細分為如下過程:

  1. 通知瀏覽器發起請求
  2. 瀏覽器發起請求
  3. 瀏覽器獲取響應內容
  4. 瀏覽器把響應內容交給上層渲染引擎
  5. 渲染引擎處理

在整個過程中,puppeteer提供了一種機制讓我們有機會攔截到2和3這兩個階段,基於這點,我們可以做更多的事情,比如我們可以攔截頁面的所有請求,可以截獲所有的響應,而不用關注請求的去向,因為只要請求發出去了,就能受我們的控制,另外,由於是使用瀏覽器本身,所以跟直接http get 頁面最大的區別在於前者是渲染后的,后者是原始的,前者對SPA或者依靠腳本構建的應用比較友好。

使用puppeteer實現完全能處理原始方案的不足,新的實現思路如下:

  1. 攔截所有網絡請求,對資源請求以及構建dom相關請求進行處理

  2. 對同域名下資源進行相對路徑處理,在本地創建對應的相對路徑

  3. 對不同域名下資源(第三方資源)以第三方域名為名建立新的目錄,用來存儲第三方資源

  4. 資源處理,處理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,不過這有其它改進的方案,比如可以把同域名的路徑做的更靈活一點,可以讓接口消費者修改。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM