网页转 PDF

2,503 阅读4分钟
原文链接: yiqiwuliao.com

为用户提供将连载(网页)导出为 PDF 功能,以便于用户更放心地记录,不用担心数据丢失。并且为 APP 端提供接口,当用户点击导出按钮时,提交导出任务到后台,成功导出后,通过私信告知用户。

实现思路

因为 Java 在转换 PDF 方面效果不够好,加上之前了解了下网页转换为 PDF,发现 phantomjs 是非常棒的一个工具。于是这次就直接选了该 js 进行转换。

主要的思路是这样的:

  • 安装 phantomjs
  • APP 端传入需要导出的 URL,请求导出接口
  • 服务器端接收到导出请求,根据 URL 的参数来判断导出的任务个数(因为有些连载会有几百个阶段,如果一次导出的话,会非常慢且生成的文件巨大),并且这是一个耗时操作,所以异步将任务添加到分布式队列中,同时返回成功状态码给前端,以免前端等待。
  • 消息队列的消费者收到任务,开始调用 shell 命令,执行 phantomjs
  • phantomjs 转换完毕后,将转成成的 pdf 上传到七牛(我们提供给用户的是一个地址,这样用户下载时就不消耗自己服务器带宽了)
  • 上传七牛成功后,消息队列手动 ACK,并将导出的结果存入数据库,同时发送私信告知用户,已经成功导出。

这就是主要的实现思路。

安装 phantomjs

  1. wget wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

  2. tar -jxvf phantomjs-2.1.1-linux-x86_64.tar.bz2

我们先来简单测试下该 js,看看效果如何。

toPdf.js

具体代码为:

'use strict';

var page = require('webpage').create()
  , system = require('system')
  , args = system.args
  , url = args.length > 1 ? args[1] : 'http://www.lianzai.me/toPdf/666666/10192/1.html'
  , filename = args.length > 2 ? args[2] : 'tmp';

page.paperSize = {
  format: 'A4',
  header: {
    height: "2cm",
    contents: phantom.callback(function(pageNum, numPages) {
            return "";
    })
  },
  footer: {
    height: "2.5cm",
    contents: phantom.callback(function(pageNum, numPages) {
        return "<h1 style='position: relative;top:35px;font-size:13px;color:#999;text-align:center;font-weight:400'><span>" + pageNum + " / " + numPages + "</span></h1>";
    })
  }
}

url = decodeURIComponent(url);
page.open(url, function (status) {
  console.log(status);
  if (status === 'success') {
    page.render('/testpdf/' + filename + '.pdf');
  }
  phantom.exit();
});

参数中如果没有传递 url 和 fileName,会有默认值。

我们把 phanmotjs 加入到 path 中,以便可以在终端直接使用 phantomjs

  1. vim /etc/profile

  2. export PATH=/usr/software/phantomjs-2.1.1-linux-x86_64/bin:$PATH

  3. source /etc/profile

测试效果

phantomjs toPdf.js http://www.lianzai.me/toPdf/123456/14456/1.html 非客观的我

转换成 PDF 的效果如下:

非客观的我.pdf

部分截图:

QQ20170704-191339@2x.png

可以看到效果是非常不错的。

定义 API

public static void export(Long uid, @Required String url, String uidSid) {

    paramError();

    // 判断是否为 vip
    Integer vipStatus = userInfoLogic.getVipStatus(uid);

    if (!UserInfo.VipStatus.VIP.getIndex().equals(vipStatus)) {
        renderJsonFail(ReturnCode.ONLY_VIP_ACCESS.getCode(), ReturnCode.ONLY_VIP_ACCESS.getMsg());
    }

    Long planId = extractPlanId(url);

    long stageCount = planStageLogic.countByUidAndPlanId(uid, planId, 0);

    if (stageCount > 0) {
        Date now = new Date();
        String taskId = Codec.UUID().split("-")[0];
        int pageCount = calculatePageCount(stageCount);
        Cache.set(taskId, pageCount, "10h");
        for (int i = 1; i <= pageCount; i++) {
            String url1 = url + "/" + i + ".html";
            AsyncOperation.createExportPdfMsg(taskId, uid, planId, url1, now, QueueTypeEnum.EXPORT_PDF);
        }
    }
    renderJsonSuccess();
}

这主要是自己的业务逻辑,主要看

AsyncOperation.createExportPdfMsg(taskId, uid, planId, url1, now, QueueTypeEnum.EXPORT_PDF),这里将任务细分为几个任务,然后添加到分布式消息队列中。详细代码就不贴了,都是一些基础的类。

Java 调用 Shell

在上一个步骤中,我们将任务添加到分布式消息队列中,然后现在需要进行消费。这个过程,主要是利用 Java 调用 Shell 命令,让 Java 执行我们的导出脚本。

Java 调用 Shell,关键代码就是

Process process = Runtime.getRuntime().exec(command);
process.waitFor();

该方法会返回一个 int 值,当返回结果为 0 的时候,则表示成功。

在这个问题上,我遇到过 127,255 返回码以及权限不足问题。

以下资料可作为参考

255错误码

127错误码

【权限不足】在 command 前面加上 chmod 777 即可解决。

上传到七牛

这个调用七牛 API 即可,上传完毕后,将七牛返回的地址存入数据库,同时向用户发送完成的私信通知。

然后再告诉消息队列,说该条消息已经被成功处理了,可以废弃掉了。

NOTE

  1. 千万要注意执行的脚本路径,不然会出各种意外的问题,例如权限不足,或者没有报任何错误,但是就是没有效果。

  2. 中文乱码问题

    linux 上可能没有安装可用的中文字体,导出来的pdf中文就为空白了。下载一个字体,进行解压,然后建立软链接

    1. wget http://dlc2.pconline.com.cn/filedown_367689_7048847/f9qOLERr/simsun.zip
    2. unzip simsun.zip
    3. ln -s /usr/share/fonts/truetype/simsun.ttf

以上大体上就完成了我们的需求,还有很多业务逻辑需要自己去考虑,关于这里使用的分布式消息队列,是 RabbitMQ。有兴趣的可以自己去了解下。