庆祝 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 元素,得到自己想要的数据。

解析分类页面

解析分类,重要的字段就是 theme,分别代表该分类的入口页面,以及该分类下总共有多少贴子,根据该字段可以判断网站数据是否更新了。具体就是 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  Dom = $(this)
      .find(\"h3\")
      .eq(0)
      .find(\"a\")
    // path. name 去掉链接中无用的字符
    let   = path. name( Dom.attr(\"href\") || \"\")
    let   =  Dom.text() || \"分类名为空\"
    let theme = ~~$(this)
      .find(\"td\")
      .eq(1)
      .find(\"span\")
      .text()
    let article = ~~$(this)
      .find(\"td\")
      .eq(2)
      .find(\"span\")
      .text()
    if (  &&  ) {
      let temp = {
         , // 链接
         , // 标题
        theme, // 主题 ,即总的列表数量
        article, // 文章
        endPage: ~~(theme / COMMON_CONFIG.pageSize)
      }
      categoryList[ ] = temp
    }
  })
}

下载并发量

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

 recursionExecutive() {
    let category s =  .keys(categoryList)
    if (this.categoryIndex >= category s.length) {
      return false
    }
    let currentCategory = category s[this.categoryIndex]
    this.jsonPath =
      COMMON_CONFIG.tableList + \"/\" + currentCategory.split(\"?\").pop() + \".json\"
    tableList = this.readJsonFile(this.jsonPath)
    let table s = []
    if (!tableList) {
      if (parseAllCategory) {
        this.categoryIndex++
        this.recursionExecutive()
      }
      return false
    }
    let category  = categoryList[currentCategory]. 
    let parentDir = COMMON_CONFIG.result + \"/\" + category 
    this.generateDirectory(parentDir)
    table s =  .keys(tableList)
    let totalLength = table s.length
    try {
      let requestUrls = table s.map(url => COMMON_CONFIG. Url + url)
      let step = COMMON_CONFIG.maxDetail s
      /**
       * 递归请求,防止返回的数据太多,爆栈
       * 这是因为 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 = table s[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()
    }
  }

解析列表页面

重要字段是 endPage 字段,分页代表总页数和页面详情链接。 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   = $(this)
      .find(\"h3\")
      .eq(0)
      .find(\"a\")
      .attr(\"href\")
    let tdDom = $(this).find(\"td\")
    let createTime = tdDom
      .eq(5)
      .find(\"a\")
      .text()
      .trim()
    if ( ) {
      let temp = {
        reply: ~~tdDom.eq(3).text(), // 回复
        popular: ~~tdDom.eq(4).text(), // 人气
        createTime, // 创建时间
        images: [], // 图片
        torrents: [] // 种子
      }
      if (tableList[ ]) {
        tableList[ ] =  .assign(tableList[ ], temp)
      } else {
        tableList[ ] = temp
      }
    }
  })
  this.updateCategoryList()
  this.updateTableList()
}

解析每个详情页面

详情页面重要的字段是 torrentsimages ,分别对应详情页的标题,种子的链接以及图片的链接。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)]
    //   字段非空,可以在下次不用爬取该页面,直接下载种子文件
  let   =
    $(\"#td_tpc h1\")
      .eq(0)
      .text() || \"已经爬取了\"

  if (this.isEmpty(tableList[seed])) {
    tableList[seed] = temp
  } else {
    tableList[seed].  =  
    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. 爬虫并发量解决方法

感谢阅读!

收藏 打印