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
元素,得到自己想要的数据。
解析分类页面
解析分类,重要的字段就是 link
和 theme
,分别代表该分类的入口页面,以及该分类下总共有多少贴子,根据该字段可以判断网站数据是否更新了。具体就是 cheerio
依赖包的使用,简单理解,该包就是 node.js
端的 jquery
。
如 $("#cate_3 tr")
就是获取 id
为 cate_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() } }
解析列表页面
重要字段是 endpage
和 link
字段,分页代表总页数和页面详情链接。 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() }
解析每个详情页面
详情页面重要的字段是 title
、 torrents
和 images
,分别对应详情页的标题,种子的链接以及图片的链接。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
总结
- 学习中文编码为乱码的解决方法
- 学习了
request
的代理以及文件下载功能 - 破解种子网站的种子下载功能
- js 面向对象开发
- 爬虫并发量解决方法
感谢阅读!