Node.js 抓取数据过程的进度保持

最近自己有个批量调用 API 抓取数据的需求,类似爬虫抓数据的感觉。听到爬虫二字,我们常常想到的是 Python, Beautiful Soup 之流,而对于简单地抓取数据这种需求来说,一个小米加步枪就能干掉的东西,拉个加农炮来,显得有些大材小用。实际上,只需要围绕着 抓取->格式转换处理->保存 这简单三步,然后用合适的工具或编程语言实现就好了。

驱动整个批量抓取过程的核心在于一个循环,把所有要访问的 URL 放在一个数组,循环遍历一下。对于我这样搞前端的来说,结合现代 JS 的 async/await 很容易就可以写出类似下方的代码(这里我用了 Axios 库处理 HTTP 请求)。

// Input
let read = fs.readFileSync('url-list.txt', 'utf-8');
let urlList = read.split('\n');

(async () => {
  for (let current = 0; current < urlList.length; current++) {
    const url = urlList[current];
    console.log(current, url);
    // get
    let { data } = await Axios.get(url);
    fs.writeFileSync(`result/${url}`, JSON.stringify(data));
  }
})();

简简单单一个循环,就可以解决这个问题,但问题来了,万一中途出错退出,再次启动,脚本得重头开始跑,这显然有点不够智能,有没有办法实现在程序中断过后再次启动时让程序恢复上次的进度?

想起 SICP 讲到的递归与迭代的思维。迭代,实际上是用固定数目的状态变量表示当前程序的状态的计算过程。迭代计算过程中,程序根据之前设定好的规则从一个状态转移到下一个状态,直到状态不再满足某个设定条件才结束。实现上来说,“迭代”二字指的是用来表示状态的变量的迭代更新。由此可见,我们的关注点应该聚焦在状态(state)上,for 循环本身也是服务于迭代计算过程的一种语法糖而已。

于是我们很容易可以看出,这个简单循环过程所迭代更新的状态变量只有 current,代表当前抓取的 URL 在数组的位置。这个变量存在于内存,而内存中的状态随着程序的中止而消失,所以关键在于如何把这个状态固定到磁盘或数据库等地方。这里能想到的思路是,在程序启动时把状态加载进来,在状态更新的同时把它固定下来。

在这里,我把这个状态变量序列化成 JSON,然后存储到文件,实现状态的固定。

// Input
let read = fs.readFileSync('url-list.txt', 'utf-8');
let urlList = read.split('\n');
let current = JSON.parse(fs.readFileSync('state', 'utf-8'));

(async () => {
  for (; current < urlList.length; current++) {
    const url = urlList[current];
    console.log(current, url);
    // get
    let { data } = await Axios.get(url);
    fs.writeFileSync(`result/${url}`, JSON.stringify(data));
    // save state
    fs.writeFileSync(`state`, JSON.stringify(current));
  }
})();

对于本文这个小需求来说,这样做已经够用,但扩展一下之后,还是有一些问题的,当状态变得复杂,需要更多的状态变量表示的时候,可能会导致持久化的语句遍布整个迭代过程中的每一个涉及到状态改变的地方,代码的可读性也降低了很多,让人不容易抓住重点。有没有什么办法把这些操作集中起来?想到了 Vue.js 的 MVVM 模型,它可以通过监视一个 Object 的变化而驱动视图的变化,或许我们可以实现类似的一些监听和触发机制,在变化的时候实现保存呢?

搜索发现,ES6 的 Proxy 可以满足这个需求,通过 Proxy 对象,把真正用来保存状态的对象包裹起来,只要定义一个 set 方法,在接到对象的改变的请求的时候,加入这个持久化操作就好了。另外,由于可能有多级的 Object 的存在,所以也对子对象递归加入 Proxy 的监控。

// save state
const Store = {
  fileName: 'state',
  _state: {},
  init: function () {
    if (fs.existsSync(this.fileName)) {
      let content = fs.readFileSync(this.fileName, 'utf-8');
      if (content) {
        this._state = JSON.parse(content);
      }
    }
    // state
    this.state = new Proxy(this._state, this.proxyHandler);
  },
  saveState: function () {
    // save
    fs.writeFileSync(this.fileName, JSON.stringify(this._state));
  },
  proxyHandler: {
    set: (target, key, value) => {
      // 递归 Proxy
      if (typeof value === "object") {
        value = new Proxy(value, this.proxyHandler);
      }
      target[key] = value;
      Store.saveState();
      return true;
    }
  }
};

Store.init();
const state = Store.state;

然后把循环里面的 current 换成 state.current,小爬虫就可以放飞自我,随意中止,再也不用担心跑的过程出问题而需要重来了~

当然,这里的 saveState 的实现可以很多样,不一定要写入文件,还可以改成 Redis, Sqlite 什么的。

已有 2 条评论
  1. await in loop 应该可以改改弄成并发?之前对比了一下会快很多。但是保存进度就不好弄了的样子😑 try catch一下弄个重试的操作?

    1. 可能要为每一个任务分别设置状态的标记,然后把各自的标记持久化吧,按顺序的好处是可以一个变量就能简单地代表整体的进度~
      有点数据库的感觉了

添加新评论