• 超越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,不过这有其它改进的方案,比如可以把同域名的路径做的更灵活一点,可以让接口消费者修改。

  • 相关阅读:
    【MSSQL】备份
    cenos7创建删除软连接ln s
    mybatis plus 条件构造器queryWrapper学习
    使用socket实现即时通讯
    vue动态合并行和列
    解决:/deep/ 不能正常使用 Expected selector
    java字符串日期格式相互转换
    Git使用git通过ssh连接linux:ssh 用户名@IP地址
    vue+element跨行跨列合并
    Centos7配置node环境
  • 原文地址:https://www.cnblogs.com/Johnzhang/p/9061998.html
Copyright © 2020-2023  润新知