天天看点

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