欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Node.js 种子下载器

程序员文章站 2022-10-04 22:34:56
庆祝 2018 国庆,制作了一个 的种子下载器。爬取页面,根据页面的链接,破解另外一个网站,下载种子文件,同时使用 模块提高爬虫的并发量。项目比较简单,爬取页面没有使用任何爬虫框架。 的安装请看我的另外一篇文章, "Node.js 的多版本安装" 。 项目初始化 新建一个文件夹 ,在该文件夹下打开命 ......

庆祝 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 配置(楼主懒)。个人喜好添加数据库, 可以找到你需要的 node.js 包。

面向对象

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

总结

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

感谢阅读!