申明:上班摸鱼不好,扒拉小说也不对,不管你们信不信,本项目只为技术练习
一、背景
年后刚开工,比较无聊,想看小说但是又觉得太光明正大,那能不能把小说放到编辑器里面看呢。
找了好多平台都没有我需要的小说下载,于是决定自己写一个爬虫,去扒拉
扒拉简单,但是很多网站出来的都不是存文本内容,所以决定自己写一段代码转换成自己想要的格式,输出成文件
二、目标站点
示例站点为:https://www.mht99.com
三、需求
- 能获取大部分免费网站的内容
- 能根据目标网站的文章规律自动加载下一章
- 能输出成自己想要的格式和文件类型
四、准备
1、创建一个 nodejs 项目
npm init
全部默认配置就好了,或者也可以详细填写
2、构建目录结构
- 入口文件 index.js
- 业务文件夹 src
- 接口文件夹 apis
- 文件存储文件夹 assets
3、依赖
功能简单,用不到依赖,不过这里安装了一个 request 来调取接口,也可以用原声的 http/https,但是考虑到各网站的区别,选择 request 比较方便。
五、业务逻辑
1、测试 api 是否能获取目标文章的内容——api.js
const request = require('request')
function getPage(uri) {
return new Promise((resolve, reject) => {
request(uri, (err, res, body) => {
if (err) {
console.error('api -------', 'error: ', err)
resolve(0)
} else if (res.statusCode === 200) {
console.log('api -------', 'body: ', body)
resolve(res)
} else {
resolve(0)
console.log('api -------', 'code: ', res.statusCode)
}
})
})
}
module.exports = {
getPage
}
运行下这段代码,发现能拿到目标网页。因为目标网站的文章内容不是来源于接口,只能获取整个页面。
2、内容处理——src/write
- 根据内容提取正文
- 将每一章放在一个文件或多章放在一个文件中
- 网络错误,链接错误,文章内容结束后断开请求
const fs = require('fs')
const apis = require('./api')
/**
* @param startId 第一章id
* @param fileName 文件名
* @param chapterSliceNumber 每隔多少章节分割一次文件
* @param endNumber 请求多少章节停止
* @param continuousErrorCloseNumber 连续错误关闭(次数)
*/
class WriteChapter {
#url = 'https://www.mht99.com/17023' // 网站
#pageIndex = 0 // 页码
#fileIndex = 1 // 文件下标
#finishChapterNumber = 0 // 已完成加载的数量
#data = [] // 数据存放
#continuousErrorNumber = 0 // 连续请求错误次数
reg = /<div id="content">[\s\S]*10000/
constructor(startId, fileName = '文章', chapterSliceNumber = 100, endNumber = 10000, continuousErrorCloseNumber = 5) {
if (this.#aguTypeValidate(startId, endNumber, chapterSliceNumber, continuousErrorCloseNumber, fileName)) {
this.chapterSliceNumber = chapterSliceNumber
this.startId = startId
this.fileName = fileName
this.endNumber = endNumber
this.continuousErrorCloseNumber = continuousErrorCloseNumber
} else {
console.error('错误:传入参数类型错误!\n 示例:new WriteChapter(13111, "西游记", 100, 1000, 5)')
}
}
start() {
this.#getPage().then(() => {
this.start()
})
}
#getPage() {
return new Promise((resolve, reject) => {
if (this.startId) {
let id = this.#pageIndex === 0 ? this.startId : `${this.startId}_${this.#pageIndex}`
apis.getPage(`${this.#url}/${id}.html`).then((res) => {
const body = res.body
if (body === 0) {
this.#pageIndex = 0
this.startId++
this.#continuousErrorNumber++
if (this.#continuousErrorNumber >= this.continuousErrorCloseNumber) {
reject()
} else {
resolve()
}
} else {
if (this.#finishChapterNumber >= this.endNumber) {
this.#pushData(body)
this.#write()
this.#data = []
reject()
return
}
if (this.#finishChapterNumber !== 0 && this.#finishChapterNumber % this.chapterSliceNumber === 0) {
this.#pushData(body)
this.#write()
this.#data = []
this.#pageIndex = 0
this.startId++
this.#finishChapterNumber++
this.#fileIndex++
resolve()
} else {
this.#pushData(body)
this.#finishChapterNumber++
this.#pageIndex++
resolve()
}
}
})
}
})
}
#pushData(res) {
let str = this.reg.exec(res) && this.reg.exec(res)[0] ? this.reg.exec(res)[0] : ''
if (this.#data && this.#data[0] && str === this.#data[this.#data.length - 1]) {
this.#data.push(str)
this.#finishChapterNumber++
this.#pageIndex = 0
this.startId++
this.#continuousErrorNumber++
} else {
this.#continuousErrorNumber = 0
this.#data.push(str)
}
}
#write() {
let fileFullName = `./assets/${this.fileName}_${this.#fileIndex}_${this.startId}.json`
let data = this.#data
let html = ''
data.forEach((item) => {
item = item
.replace('<div id="content">', '')
.replace('<p data-id="10000', '<br/>')
.replace(/[,。?!]/g, '<br/>')
html += item
})
let json = html.split('<br/>')
fs.writeFile(fileFullName, JSON.stringify(json), (err) => {
if (err) {
console.error('-------', 'err: ', err)
} else {
console.log('-------', 'success: ', fileFullName)
}
})
}
#aguTypeValidate(startId, endNumber, chapterSliceNumber, continuousErrorCloseNumber, fileName) {
let startIdV = !isNaN(Number(startId)),
chapterSliceNumberV = !chapterSliceNumber || !isNaN(Number(chapterSliceNumber)),
endNumberV = !endNumber || !isNaN(Number(endNumber)),
continuousErrorCloseNumberV = !continuousErrorCloseNumber || !isNaN(Number(continuousErrorCloseNumber)),
fileNameV = typeof fileName === 'string'
return startIdV && chapterSliceNumberV && endNumberV && continuousErrorCloseNumberV && fileNameV
}
}
module.exports = WriteChapter
这里将所有方法封装到了一个类中,如果是同一个站点,可以直接使用。输出内容为 json 文件,每一个标点符号分割成一行,可以修改#write 方法,将输出内容改为自己需要的文件类型及各式。当然,最好是多设置几个各式,可以根据参数选择。
3、入口文件
const WriteChapter = require('./write')
const startId = 35254397
const fileName = '技能'
const writeChapter = new WriteChapter(startId, fileName, 20, 1000)
writeChapter.start()
引入 src/write 中的累,传入参数,开始扒拉