庆祝 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
// 解析 DOMiconv-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 配置(楼主懒)。个人喜好添加数据库,https://github.com/sindresorhus/awesome-nodejs 可以找到你需要的 Node.js
包。
面向对象
刚开始是使用面向过程的方式写的,后来发现代码太重复了,所以采用 OOP
改写了整个代码。详细了解请看阮一峰 ES6 class
总结
- 学习中文编码为乱码的解决方法
- 学习了
request
的代理以及文件下载功能 - 破解种子网站的种子下载功能
- js 面向对象开发
- 爬虫并发量解决方法
感谢阅读!