Node-Tumblr-Downloader 是一个 Tumblr 图片、视频批量下载命令行工具,本次使用 JavaScript 进行重构,本文记述此次重构中的部分技术细节。

由此访问本项目位于 GitHub 的 镜像仓库

本工具项目第一版使用 Python 完成于 2017 年 8 月份。2018 年国庆节前夕将该项目使用 JavaScript 进行完全重构。

本文所述工具版本为 0.4.2,这一版本发布于 2018 年 9 月 30 日。

设计目标

工具的基本设计目标是,通过命令行操作对 Tumblr 博客服务上指定用户或指定关键字的图片和视频资源进行下载操作。在此基础上需要考虑的问题包含:命令接收处理、 Tumblr 数据请求和分析、网络代理和本地存储读写。

结构设计

早期使用 Python 编写的工具基本实现以上功能,但由于认识不足,实际的功能设计和代码组织上出现了较大纰漏,项目发布后不久即放弃了维护。再版之初考虑到了一些实际问题,进行了梳理和整合。

  ./node_modules     依赖项目录
  package.json       项目文件
  app.js             命令行入口文件
  request.js         请求对象组件
  main.js            主功能文件
  event.js           事件注册组件
  download.js        下载功能组件
  config.js          配置文件

命令行入口文件是用户实际接触的第一个文件,这里设定命令行输入内容的处理方法。请求对象文件设定工作的网络设置。主功能文件是用于处理实际发起的请求,并在获取到内容后进行处理的。事件注册组件设计了一个用于设定、托管和触发自定义事件的对象,提供全局的事件处理能力。下载功能组件是专门处理下载事物的文件。配置文件是对常用功能进行预定义的文件,在此存储的数据可以在实际使用时被覆盖。

技术细节

命令行处理部分,使用命令行工具 yargs 进行处理,在这一步获取用户输入的用户名、媒体类型和数量的信息,还有一部分网络代理相关的设置也由此获得。检查其中媒体类型是否符合预定范围后,调起网络请求对象的构造函数,将之前获取的数据全部传入。

  /* request.js */

  const createOptions = (args)=>{
    const { host = config.proxy.host, 
      port = config.proxy.port, 
      timeout = config.proxy.timeout, 
      noProxy = config.proxy.noProxy } = args;
    const options = {timeout}
    if(!noProxy) {
      options.proxy = `http://${host}:${port}`
    }
    return options;
  }

  const setRequest = (options)=>{
    global.request = Request.defaults(createOptions(options));
    return new Main(options);
  };

网络请求对象构造函数收到数据后,根据用户输入和配置文件当中的信息进行整合,生成一个用于 request 构造函数的配置参数对象,这个对象当中包含代理信息(如代理服务器地址、代理服务端口等)和网络请求的超时时间数据。配置参数中,代理信息需要对用户输入的数据中 noProxy 参数进行检查,如果为 true,则配置参数对象中的 proxy 属性不会被写入。将完成后的配置对象传入 request 构造函数,返回一个请求对象,将这个请求对象寄存在 global 这个环境对象中,便于跨文件后可以被调用。其后,实例化一个主功能类,开始执行数据处理工作。

  /* main.js */

  class Main {
    constructor(options){
      /* 补足必要参数 */
      this.options = {
        username: options.username,
        type: options.type || 'photo',
        page: options.page || 0,
        thread: options.thread || config.download.thread,
        output: options.output || config.download.path
      }
      // ...
      /* 注册自定义事件寄存对象 */
      const e = {};
      this.eventlistener = installEventListener(e);
      this.eventInit(); // 调用自定义事件初始化函数
      /* 实例化首次下载文件列表和补充下载文件列表 */
      this.downloadList = new List();
      // ...
      this.final = this.options.thread;
      this.init();
    }
  // ...
  }

主功能类在初始化阶段使用传入的用户数据和配置文件当中的信息进行整合,补充所需的必要信息后,开始正式工作。首先,调 event.js 注册一个自定义事件寄存对象,并设计一个用于自定义事件初始化的函数;其次,调 download.js 初始化下载文件列表类,作为首次下载文件列表对象和补充下载文件列表对象;第三,设计一系列必要的数据处理函数,如获取页码和获取文件列表的函数等。

  /* main.js */

  init() {
    if(this.options.page) {
      this.getFileList(this.options.page);
    }else {
      this.getPageNumber();
    }
  }

初始化,检查用户是否规定下载文件的页码数,如果存在则直接调 getFileList 函数进行处理,如不存在则调 getPageNumber 函数进行处理。这两个函数在执行完毕后中触发自定义事件,经过判断后决定是进行递归执行还是进行下一步数据处理。getPageNumber 函数执行完毕后会触发事件调起 getFileList 函数,每次查找下载文件列表后都会向首次下载文件列表对象进行一个 push 操作,在以上的两个函数全部执行完毕后,准备进行下载流程。

  /* main.js */  

  createDownloadTasks(num) {
    for(let i = num; i > 0; i--) {
      this.callDownloadTask();
    }
  }

  callDownloadTask() {
    /* ... */
    const item = this.downloadList.pop() // ....
    // 构建下载任务参数
    const options = { filename:this.getFileName(item, this.options.type), /* ... */}
    // 调用下载任务函数
    downloadFile(options);
  }

文件的下载操作,使用 callDownloadTask 函数进行控制,每执行一次表示启动一个下载流程。这个函数在执行时会从当前下载文件列表中读取一个作为下载地址,使用 getFileName 函数从它的链接路径中拆分出目标文件名,传递给下载(downloadFile)函数。

为同时进行多个文件下载操作,使用 createDownloadTasks 函数控制在一次下载任务中 callDownloadTask 函数的执行次数。

  /* download.js */

  // ...
  const hash = [];
  const md5 = (text)=> crypto.createHash('md5').update(text).digest('hex');

  const downloadFile = async (options)=>{
    const {url, filename, path, e} = options;

    const getBinary = (url)=> request.get(url ,{encoding: 'binary'});
    const checkMD5 = (list, item)=> list.includes(md5(item));
    // 同名文件检查
    if(!fs.existsSync(filepath)) {
      await getBinary(url).then((res)=>{
        // 相同数据文件检查
        if (!checkMD5(hash, res)) {
          // 想本地写入数据  
          fs.writeFileSync(filepath,res,'binary');
          hash.push(md5(res));
          e.trigger('download-file-success');
        }
      }).catch((err)=>{
        // 触发事件发送下载失败文件的信息
        e.trigger('download-file-fail', url);
      });
    }
  }

为实现对下载任务的监控,避免出现下载任务已启动但是下载失败的情形出现,在下载任务各流程均通过自定义事件与主程序进行通信,以便记录下载异常文件的信息,进行补充下载流程。在每一次下载过程(而不是下载任务)中,会记录每一个已下载文件的数据 MD5 特征,用于在后续文件下载过程中进行检查;与此同时,下载文件之前会检查这个名称的文件是否已存在,如存在则直接跳过本次下载任务。这样的设计可以避免同一文件名文件以及不同文件名但数据相同的文件被重复下载。