最近需要做一个下载vue页面为pdf格式的功能,网上方案有很多,也尝试了其他方案,最终采用的方案 流程就是点击下载报告按钮,则请求后端一个接口,例如接口地址为example.cn/file/downlo…, 则返回一个pdf的文件地址 即可实现下载。背后原理就是利用爬虫抓取页面然后转为pdf,存储在服务器静态资源目录,当然过程是怎样呢?接下来记录一下:

1.[前端]准备好一个vue页面

此页面就是最终需要被转为pdf后下载的页面

2.[前端]页面点击按钮,发起请求,传入参数

<el-button type="primary" @click="downloadReport">报告下载</el-button>

<script> import { reportDownload } from '@/api/request/analysis'// 下载的接口

const url = host + `/report_download/${params.id}` // url 则是需要下载的vue页面路径
const downloadReport = () => {
    reportDownload({
        id: params.id,// 传入id和url
        url: url
    }).then((res:any) => {
        if (res) {
            const url = host + res
            window.open(url, '_blank') // 直接通过window.open下载 
        }
    }).catch((err) => {
        return err
    })
} </script>

3.[后端] 准备接口(report_download) 并返回pdf路径

3.1 利用谷歌的爬虫 puppeteer (这儿使用node版)

编写node爬虫脚本(就不具体介绍puppeteer了),java后端或者其他后端,在收到前端的请求后(/download),在执行node脚本。 具体可查看github github.com/puppeteer/p…

一个官网最简单的 puppeteer 转pdf脚本:

// hn.js
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://news.ycombinator.com', {
    waitUntil: 'networkidle2',
  });
  await page.pdf({ path: 'hn.pdf', format: 'a4' });

  await browser.close();
})();
命令行执行
node hn.js

则可生成一个hn.pdf

当然项目中考虑的需求要复杂一些:比如 1.页面需要请求多个接口,而接口请求是异步的,需要耗时,那需要判断接口加载完成,也就是数据渲染了 才转为PDF,所以肯能需要在 puppeteer 脚本中监听前端页面的控制台console,用来标识请求数据完成,但是一般情况下打包后的页面又删除调了所有console。需要做个一处理

const print = console;
print.log('report generate done');
// report generate done 这是前端vue页面和 puppeteer 脚本统一的标识,用来判断数据请求为完成状态 再利用爬虫功能 转为pdf

2.后端返回的pdf文件 数据格式有问题,前端用window.open 则不能直接下载 而是在浏览器预览,需要后端或者nginx设置header Content-Type application/octet-stream;

Nginx配置

server {
    listen 8888;
    server_name localhost;
    #charset koi8-r;
    #access_log logs/host.access.log main;

    location / {
        root F:/1/pdf;
        if ($request_filename ~* ^.*?\.(html|doc|pdf|zip|docx)$) {
            add_header Content-Disposition attachment;
            add_header Content-Type application/octet-stream;
        }
    }
}

3.我的index.html页面限制了html的宽高 需要在预览页面重置body html app 的宽高

这次用到的代码

const puppeteer = require('puppeteer');

let errorHandler = function(when, exit=true) {
    return function(error) {
        console.log(`error happened when ${when}: ${error}`);
        if (exit) {
            process.exit(1);
        }
    };
};

class Reporter {
    constructor(timeout){
        this._promise = new Promise(((resolve, reject) => {
            this._resolve = resolve;
            this._reject = reject;
        }));

        this._timer = setTimeout(() => {
            this._reject('timeout');
        }, timeout);
    }

    async render(url, pdf_path, header, footer) {
        let browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox'], ignoreHTTPSErrors: true, headless: true}).catch(errorHandler('launch browser'));
        let page = await browser.newPage().catch(errorHandler('create page'));
        page.on('console', message => { // 页面必须在控制台输出console 才代表页面加载完成 才能开始转pdf
            console.log(message.text());
            if (message.text() === 'report generate done') {
                this._resolve();
            }
        });

        await page.goto(url, {timeout: 120 * 1000, waitUntil: ["domcontentloaded", "networkidle2"]}).catch(errorHandler('goto url'));

        await this._promise.catch(errorHandler('wait report'));
        clearTimeout(this._timer);
        await page.waitFor(3000);//新版已弃用

        await page.pdf({ // 生成pdf
            path: pdf_path,
            printBackground: true,
            preferCSSPageSize: true,
            displayHeaderFooter: true,
            format: 'A4',
            margin: {
                top: '2cm',
                bottom: '2cm'
            },
            headerTemplate: `<div style="width:100%;text-align:center;font-size:10px">` + header + `</div>`,
            footerTemplate: `<div style="width:100%;text-align:center;font-size:10px">` + footer + `</div>`}
        ).catch(errorHandler('generate pdf'));

        await browser.close().catch(errorHandler('close browser'));
    }
}

(async () => {
    let reporter = new Reporter(30 * 60 * 1000);  // 10 分钟 超时
    await reporter.render(process.argv[2], process.argv[3], process.argv[4], process.argv[5]);
})();