Poirot 是自动将字体文件(woff\woff2\ttf)映射为结果字典,主要用于中文字体反爬虫的破解,包括 css 字体映射和图片文字反爬虫.

实现的基础思路可见于字体反爬虫解决方案-自动化通过字体文件生成映射字典.

Mori 同属于爬虫工作的小工具.名字取自大侦探波洛(Hercule Poirot). 与 Mori 的脚本服务不同,这次从实际使用的角度考虑,采用了 web 服务的形式.

本篇记录了 Poirot 的开发中学习到的技术和踩到的坑。

主要包括:

  1. flask-websocket 的使用
  2. 进度条的实现

进度条的实现

制作前端进度条的目的无非是以下几种.

  1. 美观的\功能全面的 UI
  2. 防止给用户造成无相应的错觉

第二点在进行数据分析的项目时比较常见,动辄数分钟的后端处理,对于不明就里的用户来说很容易去乱点一些东西,以致不必要的麻烦.

使用目的的不同也就导致了实现方案的不同.我开发主要使用过 3 种实现方案.

  1. 假进度条
  2. 前端轮询
  3. WebScoket

接下来我将详细解释三者的具体实现.

假进度条

顾名思义,这就是一个脱离了后端的,无法真正反映处理进度的”进度条”,简言之,空壳子. 使用这类进度条的原因有很多:技术力不足,时间不够,服务器性能等.

前端实现一个进度条非常简单,以我最常用的 Bootstrap 为例.

<div class="progress">
  <div
    class="progress-bar w-75"
    role="progressbar"
    aria-valuenow="75"
    aria-valuemin="0"
    aria-valuemax="100"
  ></div>
</div>

通过改变这个 tag 的宽度属性就可以实现进度条效果.具体的就是在 js 中使用 for 循环来控制(代码如下),如此就能完成一个 10 秒的进度条.

var bar = $(".progress-bar");
for (var i = 0; i < 10; i++) {
  bar.style["width"] = i * 10 + "%";
  sleep(1000);
}

但是这里有一个 根本的原则(先决条件) ,那就是后端处理的时间必须可估算,传统的数据处理时间与回传数据的数据量是可以满足函数关系的(在保证后端稳定运行,负载可靠的情况下).

微小的偏差是允许的,但是如果出现前后端进度极大偏差,那就失了进度条使用的初衷.

这里的一个强化方案就是, 预估时间永远比处理时间要长 ,当后端处理完成返回数据时,直接将进度条拉满,这样就实现了前后端同步的假象.

前端轮询

在前端请求数据分析接口 A 的同时,开启一个子线程用来统计分析的进度. 同时开启一个轮询接口 B 专门用来返回这个分析进度. 前端每隔一段时间向接口 B 查询进度,并反馈到进度条.

前端轮询的一个可参考案例就是 Miguel GrinBerg 的 flask-celery-example , 这里轮询了 celery 的 task,让用户能够知道后台程序的处理进度。

前端实现

引入了 nanobar 用作进度条的展示, 以及 toastr 用作关键消息闪现提示。

async function getData(url = "") {
  const response = await fetch(url, {
    method: "GET",
    mode: "cors",
    cache: "no-cache",
    credentials: "same-origin",
    headers: {
      "Content-Type": "application/json",
    },
    redirect: "follow",
  });
  return response.json();
}

function update_progress(nanobar, status_div, now_state = "") {
  // nanobar 是nanobar.js 提供的一个进度条插件
  // status_div 是展示进度条的div块

  let url = api_for_sandbox_progress.replace("none", task_id); // 将这里的url替换成实际要请求的url
  getData(url).then((data) => {
    percent = parseInt((data["info"]["current"] * 100) / data["info"]["total"]);
    nanobar.go(percent);
    $(status_div.childNodes[1]).text(percent + "%");
    $(status_div.childNodes[2]).text(data["state"]);

    if ("result" in data) {
      $(status_div.childNodes[3]).text("Result: " + data["result"]);
    } else {
      $(status_div.childNodes[3]).text("Result: " + data["state"]);
    }

    if (data["state"] === "SUCCESS") {
      toastr["success"]("COMPLETE"); // toastr是一个消息闪现插件

      load_neo_page();
    } else if (data["state"] === "FAILURE") {
      toastr["error"]("FAILURE");
    } else {
      if (data["state"] !== now_state) {
        toastr["success"](data["state"]);
      }
      now_state = data["state"];
      $(status_div.childNodes[3]).text("Result: " + data["state"]);

      setTimeout(function () {
        update_progress(nanobar, status_div, now_state);
      }, 2000);
    }
  });
}

后端实现

轮询机制下的后端代码就是将状态存储在一个队列中,当轮询请求到达时将这个队列里面的消息交付给这个请求。

WebSocket

WebSocket is a computer communications protocol, providing full-duplex communication channels over a single TCP connection.
–wikipedia

在进度条上,使用 websocket 的根本目的是通过服务器端返回实时数据给前端。与前端轮询的”等间隔的时间内提高不定长的进度条长度”不同,WebScoket 拥有更加灵活的使用方式,这样做的好处就是能够让进度条的涨势更加平滑,与假进度条的平滑不同,这是在实时反映后端处理进度的基础上的。

具体的实现可见于下一段flask-websocket 的使用

flask-websocket 的使用

文档,文档中有详细的 websocket 使用案例。这里摘出我所使用的部分以为样例。

后端实现 👇

socketio = SocketIO(app, async_mode=None)
thread = None
thread_lock = Lock()


def background_thread():
    """开始记录实时的处理进度,每隔2s返回一次"""
    while True:
        socketio.sleep(2)
        ret = []
        while not SocketQueue.res_queue.empty():
            ProgressBar.now_length += 1
            ret.append(SocketQueue.res_queue.get())
        socketio.emit('my_response',
                      {'data': ret, 'width': str(ProgressBar.calculate()) + '%'},
                      namespace='/test')


@socketio.on('connect', namespace='/test')
def test_connect():
    """建立websocket连接"""
    global thread
    with thread_lock:
        if thread is None:
            thread = socketio.start_background_task(background_thread)

@socketio.on('disconnect_request', namespace='/test')
def disconnect_request():
    """关闭websocket连接"""
    @copy_current_request_context
    def can_disconnect():
        disconnect()

    session['receive_count'] = session.get('receive_count', 0) + 1

    emit('my_response',
         {'data': 'Disconnected!', 'count': session['receive_count']},
         callback=can_disconnect)

前端实现 👇

const socket = io(namespace);

socket.on("connect", function () {
  //   socket.emit("my_event", { data: "I'm connected!" });
  toastr.success("connect success", "", { timeOut: 500 });
});

socket.on("my_response", function (msgs, cb) {
  if (msgs["data"].length > 0) {
    let progress = $("#crack-progress")[0];
    progress.style["width"] = msgs["width"];
  }

  msgs["data"].forEach(function (msg) {
    toastr.success(msg, "", { timeOut: 1000 });
  });
  if (cb) cb();
});

socket.emit("disconnect_request");
socket.close();

在经历了几次优化之后,实际在代码中的应用与样例代码有所出入

前端的坑

前端的 socket 请求需要后端的接口对应,不能出现前端有 socket 请求而后端没有对应的接口的情况,反之则没有问题.

这个坑主要是在使用 gunicorn 进行部署时出现,报错信息为 Invalid session **** (further occurrences of this error will be logged with level INFO) .

后记

至此,进度条采用了从三种实现方式中选择了 socket .

实际上,选择 socket 最核心的原因就是,以前没有实际使用过这个技术,正好借着这次的机会实践一下而已。

至于性能方面,socket、前端长轮询、SSE 中哪个实现方法的效率最高、消耗最低,则不在这边文章的考虑范围之内了。