Node.js 种子下载器

小短腿 2018-10-12 01:46118 阅读

庆祝 2018 国庆,制作了一个 Node.js 的种子下载器。爬取页面,根据页面的链接,破解另外一个网站,下载种子文件,同时使用 async 模块提高爬虫的并发量。项目比较简单,爬取页面没有使用任何爬虫框架。项目源码

Node.js 的安装请看我的另外一篇文章,Node.js 的多版本安装

项目初始化

新建一个文件夹 FBIWarning,在该文件夹下打开命令行 CMD 或者 git bash。运行 npm init -y,该文件夹会生成一个 package.json 文件。

安装依赖包

安装依赖包 cnpm install --save cheerio iconv-lite request socks5-http-client。每个依赖包的功能如下:

  • cheerio // 解析 DOM
  • iconv-lite // 解决中文乱码的问题
  • request // http 请求
  • socks5-http-client // socks 代理
  • async // 提高下载并发量

请求代理

网站是国外网站,需要使用梯子,否则不能爬取。代理传送门socks5-http-client 配合 reqeust 使用,可以解决代理的问题。但是,该代理只支持 socks 代理, http(s) 代理暂不支持。

解决中文乱码的问题

目标网站的页面编码是 gbk ,而 request 依赖包的默认编码是 UTF-8,使用默认编码解码方式,会导致页面的中文变成乱码。所以得到返回数据前,去掉默认编码,就是设置编码为 encoding: null,然后使用 iconv-lite 使用 gbk 方式解码,这样就可以解决中文编码为乱码的问题,代码如下:

const request = require("request")
// 解析 dom
const cheerio = require("cheerio")
// 中文编码
const iconv = require("iconv-lite")
// 代理
const Agent = require("socks5-http-client/lib/Agent")
const COMMON_CONFIG = require("./config")
request.get(
  {
    url: requestUrl,
    agentClass: Agent,
    agentOptions: COMMON_CONFIG.socks,
    headers: {
      "User-Agent": COMMON_CONFIG.userAgent
    },
    // 去掉默认 utf-8 解码,否则解码会乱码
    encoding: null
  },
  function(err, response, body) {
    try {
      // 统一解决中文乱码的问题
      let content = iconv.decode(body, "gbk")
      let $ = cheerio.load(content)
      resolve($, err, response, body, content)
    } catch (error) {
      console.log(error)
      //如果连续发出多个请求,即使某个请求失败,也不影响后面的其他请求
      resolve(null)
    }
  }
)

解析页面

爬取页面后,之后就是解析页面中的 DOM 元素,得到自己想要的数据。

解析分类页面

解析分类,重要的字段就是 linktheme,分别代表该分类的入口页面,以及该分类下总共有多少贴子,根据该字段可以判断网站数据是否更新了。具体就是 cheerio 依赖包的使用,简单理解,该包就是 Node.js 端的 jQuery
$("#cate_3 tr") 就是获取 idcate_3 下面的所有 tr 标签,该网站比较古老,页面布局是 table 布局,解析 DOM 非常简单。
cheerio 详细说明请看官方说明

// $ 就是 request 请求后,解码后的数据
parseHtml($) {
  // 获取 DOM 的主题内容
  let categoryDom = $("#cate_3 tr")
  categoryDom.each(function() {
    let titleDom = $(this)
      .find("h3")
      .eq(0)
      .find("a")
    // path.basename 去掉链接中无用的字符
    let link = path.basename(titleDom.attr("href") || "")
    let title = titleDom.text() || "分类名为空"
    let theme = ~~$(this)
      .find("td")
      .eq(1)
      .find("span")
      .text()
    let article = ~~$(this)
      .find("td")
      .eq(2)
      .find("span")
      .text()
    if (link && title) {
      let temp = {
        link, // 链接
        title, // 标题
        theme, // 主题 ,即总的列表数量
        article, // 文章
        endPage: ~~(theme / COMMON_CONFIG.pageSize)
      }
      categoryList[link] = temp
    }
  })
}

下载并发量

解析列表页面与分类页面类似,不过列表页面有很多页面,需要不断的请求新的页面。为了提高下载的并发量,使用 async 模块,使用了其官方提供的例子,提高了下载的并发量。内部的递归调用,是为了防止内存爆栈。

 recursionExecutive() {
    let categoryLinks = Object.keys(categoryList)
    if (this.categoryIndex >= categoryLinks.length) {
      return false
    }
    let currentCategory = categoryLinks[this.categoryIndex]
    this.jsonPath =
      COMMON_CONFIG.tableList + "/" + currentCategory.split("?").pop() + ".json"
    tableList = this.readJsonFile(this.jsonPath)
    let tableLinks = []
    if (!tableList) {
      if (parseAllCategory) {
        this.categoryIndex++
        this.recursionExecutive()
      }
      return false
    }
    let categoryTitle = categoryList[currentCategory].title
    let parentDir = COMMON_CONFIG.result + "/" + categoryTitle
    this.generateDirectory(parentDir)
    tableLinks = Object.keys(tableList)
    let totalLength = tableLinks.length
    try {
      let requestUrls = tableLinks.map(url => COMMON_CONFIG.baseUrl + url)
      let step = COMMON_CONFIG.maxDetailLinks
      /**
       * 递归请求,防止返回的数据太多,爆栈
       * 这是因为 async 返回的结果,都会放在 results 数组中
       * 而且每个返回的结果,是一个 html 文件,当返回的文件非常多时,内存占满了
       * 使用递归可以解决这个问题
       * 列表页面的递归请求是一样的
       */
      // 重新赋值 this 否则递归函数找不到 this
      let _this = this
      function innerRecursion(arrayIndex) {
        log("内部递归调用")
        let urls = requestUrls.slice(arrayIndex * step, (arrayIndex + 1) * step)
        async.mapLimit(
          urls,
          COMMON_CONFIG.connectTasks,
          async url => {
            return await _this.requestPage(url)
          },
          (err, results) => {
            if (!err) {
              for (let i = 0; i < results.length; i++) {
                let seed = tableLinks[i + arrayIndex * step]
                let result = results[i]
                let directory = parentDir + "/" + seed.replace(/\//gi, "_")
                if (result) {
                  _this.parseHtml(result, seed, directory)
                }
              }
            }
            if (urls.length < step) {
              _this.categoryIndex++
              _this.recursionExecutive()
            } else {
              innerRecursion(arrayIndex + 1)
            }
          }
        )
      }
      innerRecursion(0)
    } catch (error) {
      this.requestFailure()
    }
  }

解析列表页面

重要字段是 endPagelink 字段,分页代表总页数和页面详情链接。 DOM 解析如下:

parseHtml($, currentCategory) {
  let tableDom = $("#ajaxtable tr")
  tableDom.each(function() {
    // 获取列表页分页的总页数
    let endPage = ~~$("#main .pages")
      .eq(0)
      .text()
      .trim()
      .match(/\((.+?)\)/g)[0]
      .slice(1, -1)
      .split("/")
      .pop()
    categoryList[currentCategory].endPage = endPage
    // 详情页面链接
    let link = $(this)
      .find("h3")
      .eq(0)
      .find("a")
      .attr("href")
    let tdDom = $(this).find("td")
    let createTime = tdDom
      .eq(5)
      .find("a")
      .text()
      .trim()
    if (link) {
      let temp = {
        reply: ~~tdDom.eq(3).text(), // 回复
        popular: ~~tdDom.eq(4).text(), // 人气
        createTime, // 创建时间
        images: [], // 图片
        torrents: [] // 种子
      }
      if (tableList[link]) {
        tableList[link] = Object.assign(tableList[link], temp)
      } else {
        tableList[link] = temp
      }
    }
  })
  this.updateCategoryList()
  this.updateTableList()
}

解析每个详情页面

详情页面重要的字段是 titletorrentsimages ,分别对应详情页的标题,种子的链接以及图片的链接。DOM 解析如下:

parseHtml($, seed, directory) {
  let torrents = []
  /**
   * 获取页面上的每一个图片链接和地址链接
   * 不放过任何一个图片和种子,是不是很贴心!!
   */
  $("body a").each(function() {
    let href = $(this).attr("href")
    if (href && COMMON_CONFIG.seedSite.some(item => href.includes(item))) {
      torrents.push(href)
    }
  })
  torrents = [...new Set(torrents)]
  let images = []
  $("body img").each(function() {
    let src = $(this).attr("src")
    if (src) {
      images.push(src)
    }
  })
  images = [...new Set(images)]
    // title 字段非空,可以在下次不用爬取该页面,直接下载种子文件
  let title =
    $("#td_tpc h1")
      .eq(0)
      .text() || "已经爬取了"

  if (this.isEmpty(tableList[seed])) {
    tableList[seed] = temp
  } else {
    tableList[seed].title = title
    tableList[seed].torrents = [...tableList[seed].torrents, torrents]
    tableList[seed].images = [...tableList[seed].images, images]
  }
  this.updateTableList()
  this.downloadResult(directory, torrents, images)
}

图片的下载非常简单,代码如下:

/**
 * 下载文件
 * @param {String} url 请求链接
 * @param {String} filePath 文件路径
 */
  downloadFile(url, filePath) {
    if (!url || !filePath) {
      return false
    }
    request
      .get({
        url,
        agentClass: Agent,
        agentOptions: COMMON_CONFIG.socks,
        headers: {
          headers: {
            "User-Agent": COMMON_CONFIG.userAgent
          }
        }
      })
      .pipe(fs.createWriteStream(filePath))
  }

但是种子得到的只是一个链接,需要破解该网站的种子下载。查看网站的种子下载方式,就是一个 post 请求,后台就会返会种子文件。刚开始的时候,不熟悉服务端的表单提交方式,导致文件一直得不到,后来详细查看了 request 的官文文档,发现是自己写错了。结合上面的图片下载,种子的下载方式自然就有了,代码如下:

/**
   * 下载种子链接
   * @param {String} childDir // 子目录
   * @param {String} downloadUrl  // 下载种子地址
   */
  downloadTorrent(childDir, downloadUrl) {
    // 解析出链接的 code 值
    let code = querystring.parse(downloadUrl.split("?").pop()).ref
    if (!code || !childDir) {
      return false
    }
    // 发出 post 请求,然后接受文件即可
    request
      .post({
        url: COMMON_CONFIG.torrent,
        agentClass: Agent,
        agentOptions: COMMON_CONFIG.socks,
        headers: {
          "User-Agent": COMMON_CONFIG.userAgent
        },
        formData: {
          code
        }
      })
      .pipe(fs.createWriteStream(childDir + "/" + code + ".torrent"))
  }

数据库

本应用没有使用数据库,而是使用 json 文件存储结果,是为了省去 sql 配置(楼主懒)。个人喜好添加数据库,https://github.com/sindresorhus/awesome-nodejs 可以找到你需要的 Node.js 包。

面向对象

刚开始是使用面向过程的方式写的,后来发现代码太重复了,所以采用 OOP 改写了整个代码。详细了解请看阮一峰 ES6 class

总结

  1. 学习中文编码为乱码的解决方法
  2. 学习了 request 的代理以及文件下载功能
  3. 破解种子网站的种子下载功能
  4. js 面向对象开发
  5. 爬虫并发量解决方法

感谢阅读!

回复数量: 0
暂无评论~~
  请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!