记录一次nodejs爬取《17吉他》所有吉他谱(只探讨技术)

突然就想扒一下吉他谱了,说做就做哈哈,中间也是没有想象中的顺利啊,出现了各种意想不到的坑,包括老生常谈的nodejs异步写法,还有可怕的内存溢出等问题。。我将一步步回顾各种重要的错误及我的解决方法,只贴关键部分代码,只探讨技术。(本篇文章不是入门文章,读者需要具有一定的ES6/7,nodejs能力以及爬虫相关知识)

使用的技术

由于未使用同步写法的nodejs框架,而且各种库也都是回调写法,需要稍作修改,所以对ES6/7的同步写法有一定的要求

nodejs request cheerio(类似jquery) ES6/7 mongoose iconv-lite uuid

观察页面结构获取相关dom元素

我们的最终目的就是获取所有的吉他谱,然后保存到我们的数据库中,我们使用cheerio来获取页面的dom元素,所以首先我们观察一下页面结构,怎么观察我就不说了,说一下我看到的规律

img标签

可以看到,吉他谱的图片会带一个alt标签在图片没有显示的时候显示提示信息,我们发现这个提示就是吉他谱的名字,这样我们就可以轻松的知道我们爬下来的图片是哪首歌的了,哈哈!

链接

网站的链接有很多,尤其这种论坛形式的,我们不能全都爬一遍,这样的话又费时间又爬取了很多无效的图片,所以我们需要找到这种吉他谱页面的路由规律:

// 正则
/^http:\/\/www.17jita.com\/tab\/img\/[0-9_]{4,6}.html/
// 对应路由
http://www.17jita.com/tab/img/8088.html
http://www.17jita.com/tab/img/8088_2.html

对于数学问题,代码比语言更清楚~

a 标签

由于我们扒的是整个网站的吉他谱,所以需要递归所有的a标签,为了防止递归无效a标签,我们就使用上面的正则匹配一下对应的路由是否是吉他谱路由

++到这里与前端相关的就基本结束了,剩下的就看nodejs了,我不会直接贴完成后的代码,我会尽量还原我犯的错以及解决方法++

首先我们安装这些东西

npm install --save mongoose request cheerio iconv-lite bagpipe

请求路由下载第一个页面

request ({url: baseUrl,'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',gzip:true}, (err, res, body) => {console.log(body)// ...})

我后来发现,他们居然没有限制UA,所以User-Agent不写是没关系的,然后gzip最好写一下,因为网站用了gzip压缩,不过不写好像也可以。然后。。

第一个坑 (gbk编码)

当你打印body的时候你会发现,中文全是乱码,这年头居然还用gbk我也是醉了,nodejs原生不支持gbk,只能用第三方包解码了,代码如下:

const iconv = require('iconv-lite');
request ({url: baseUrl,encoding: null,'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',gzip:true}, (err, res, body) => {body = iconv.decode(body, 'gb2312');console.log(body)// ...})

第二个坑(同步写法的request)

现在是2018年了,js在同步写法上以及多了很多创新了,咱们也得赶赶潮流不是,我决定用async来改写这段代码,结果,人家request不支持。。。

const result = await request ({url: baseUrl,encoding: null,'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',gzip:true})
// 这样得不到body的

这样就只能在外面套一个promise了。

new Promise((resolve, reject) => {request ({url: baseUrl,encoding: null,'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50',gzip:true}, async (err, res, body) => {body = iconv.decode(body, 'gb2312');console.log(body)// ...})})

第三个坑(重复链接和图片,同名的不同图片)

拿到页面了,我们就从里面抽我们需要的dom出来,a链接和img链接及alt,下面是填坑之后的代码

const $ = cheerio.load(body);
const images = {};
// 获取图片链接
$('img').each(function () {// 获取图片链接然后下载let src = $(this).attr('src');if (src) {const end = src.substr(-4, 4).toLowerCase();const start = src.substr(0, 4).toLowerCase();if (imgFormat.includes(end)) {if (start !== 'http') {src = new URL(src, 'http://www.17jita.com/')}src = src.href || src;let name = $(this).attr('alt');if (name) {name = name.replace(/\//g, '-');if (downloadImgs[name] === void 0) {downloadImgs[name] = [src];images[name + idv4() + end] = src} else if (!downloadImgs[name].includes(src)) {downloadImgs[name].push(src);images[name + idv4() + end] = src}}}}
});
// 拿到a链接
let link = [];
$('a').each(function () {let href = $(this).attr('href');if (href) {const start = href.substr(0, 4).toLowerCase();if (start !== 'http') {// 把链接拼成绝对路径href = new URL(href, 'http://www.17jita.com/');}href = href.href || href;if (href.substr(0, 10) !== 'javascript') {if (/^http:\/\/www.17jita.com\/tab\/img\/[0-9_]{4,6}.html/.test(href)) {link.push(href);}}}

我简单介绍一下为什么这么写:

a链接

我首先使用nodejs把链接拼成绝对路径,然后在判断这个路径是否是一个吉他谱路径的格式,如果是的话,我就将这个链接写到link数组里

图片

首先,我先拿到页面的所有图片和alt中的图片名称。这里会存在一个问题,如果我不判断,直接下载图片的话,会有很多冗余的重复logo之类的,所以我需要判断图片是否已经下载过了。
其次,因为一个曲子的吉他谱有好几张,而alt是相同的,没法区分,直接存会覆盖的,所以我使用uuid生成随机hash,写过SPA的朋友应该对这个文件名加hash的写法比较熟悉,就不多说了。
第三,既然我在文件名后加了hash,那怎么区分已经下载的文件啊?这里我就用了一个全局变量downloadImgs来保存已经下载的图片,key是alt的值,value是一个数组,因为吉他谱是一个alt对应很多图片的。

现在我们来简单的回顾一下我们得到了哪些东西吧~
1. 该页面所有的链接 - link
2. 该页面所有没有下载过的图片 - images
3. 所有曾经下载过的图片和该页面即将下载的图片 - downloadImgs

拿到了这些东西之后我们就可以开始下载了,我们先不管递归其他页面,先把当前页面的图片下载下来~

console.log('正在下载');
await imgDownload(images);
console.log('下载完成');// imgDownload模块
module.exports = async (images) => {const download = async (url, key) => {try {const result = await new Promise((resolve, reject) => {request({url, headers: {'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50'}}, (err, res, body) => {if (err) {console.log(err.message);reject(new Error('发生错误'));} else {const data = new Buffer(body).toString('base64');resolve(res)}}).on('error', err => {console.log(err.message)}).pipe(fs.createWriteStream(path.join(__dirname, `../static/${key}`), err => {console.log(err.message)})).on('error', err => {console.log(err.message)})});const data = new Buffer(result.body).toString('base64');await new Promise((resolve, reject) => {GuitarModel.create({name: key, Base64: data}, err => {if (err) {reject(err)} else {resolve()}});})} catch (err) {console.log(err.message);}};const urlList = Object.keys(images);for (let key of urlList) {await download(images[key], key)}
};

这里也没什么好说的了,我一共保存两份,一份编码成Base64保存到mongodb,一份直接存到static目录下。

第四个坑(指数增长的异步请求)

现在我们已经完成了单个页面数据的爬取了,又有了该页面的所有链接了,按道理接下来递归就可以了。但是在这里有很多个坑。
首先我们需要将__17JITA包装一下,否则没法同步递归自己,我们需要返回promise,将业务逻辑写在promise中,这样await才能知道何时结束。

return new Promise((resolve, reject) => {// 逻辑
})

接下来就是坑了
1. 我们看如下代码:

link.forEach(async (href) => {if (!reachedLink.includes(href)) {try {await __17JITA(href);} catch (err) {console.log(err.message)}}
});

乍一看没问题,但是他是有问题的,因为虽然回调函数是async同步写法,但是forEach可不管你,一股脑全给你执行一遍,我们的预期是link数组中一个链接的回调执行完再执行下一个回调,但是事实上他会同时遍历完整个link数组,同步的过程只是在回调函数里面,没有任何意义
这带来的后果是可怕的,因为链接个数是指数级增长的,这么多个异步请求发出去,汇编写的也受不了啊

  1. 改进:
for (let href of link) {if (!reachedLink.includes(href)) {try {await __17JITA(href);} catch (err) {console.log(err)}}
}

这样确实可以解决很多异步请求同时发出的问题,但是,随之而来的问题就是:
这很不nodejs
我们分析一下,如果每个页面有10个链接的话,首页获取完图片后,进入第一个链接,然后获取完第一个连接的10图片和10个链接,然后再进入该页面的第一个链接,依次类推。
我们会发现,nodejs天生的异步完全没有用上,所以我们需要同时进行多个异步请求,又不能太多导致崩溃。有什么办法?

任务队列

使用任务队列,我们将这些请求推入队列中,每次只取一定数量的请求出来执行,不用自己实现,这里已经有大神造的轮子了bagpipe具体用法在github有中文文档,代码如下:

const Bagpipe = require('bagpipe');
const bagpipe = new Bagpipe(10, {timeout: 10000});
bagpipe.on('full', function (length) {console.warn('排队中,目前队列长度为:' + length);
});
for (let href of link) {if (!reachedLink.includes(href)) {bagpipe.push(__17JITA, href, function () {// 异步回调执行});}
}

这样就能保持同时执行10个函数,其他的递归都在任务队列里

第五个坑(内存溢出)


都知道递归是相当耗内存的操作,刷oj的时候递归不小心就内存超限。本以为生产环境的nodejs可以抗住但是我还是忽视了网站的容量,nodejs在任务队列两万多的时候报错了。。因为异步请求的速度完全赶不上js的执行速度。因为异步执行回调的原因,使用了任务队列后同一时刻有10个回调在执行,而这些回调又会生成新的回调,虽然同一时间只能执行10个递归函数,但是递归的速度依然很快。导致栈内函数越来越多,网站页面又多,最后内存溢出了。
可以想象如果不是使用了任务队列,任由他执行的话,指数级增长的函数调用栈可能会爆炸,我试了一下,最后只有长按电源键重启了哈哈��


这个问题如何解决呢?

递归优化

尾递归可以优化递归的逻辑,但是这个没法做尾递归,而且数据量太大了,我最终没有采用

减少递归数

我们可以及时return掉使用过的函数,但是还是杯水车薪啊,一个函数产生10个递归,就算我及时释放这个函数的内存也没办法啊~

使用循环

虽然递归很好用,但是内存溢出的问题没有解决办法啊,只能循环了,代码如下:

// 全局变量
const allLinks = ['http://www.17jita.com/tab/'];// __17JITA
for (let href of link) {if (!reachedLink.includes(href)) {allLinks.push(href);}
}// 新建一个循环的函数,执行
const doPa = async () => {let i = 0;while (true) {try {await __17JITA(allLinks[i]);} catch (err) {console.log(err)}i += 1;if(i > allLinks) {break}}
};

我们把每次执行函数得到的链接保存,在一个个执行,这样就完美解决了内存泄漏的问题了,但是还是没有用到nodejs的异步特性,改进如下:

const doPa = async () => {let i = 0;while (true) {const num = allLinks.length - i < 5 ? allLinks.length - i : 5;let arr = [];for (let j = i; j < i + num; j++) {arr.push(__17JITA(allLinks[j]))}try {await Promise.all(arr);} catch (err) {console.log(err)}console.log(i, num);*/i += num;if(i > allLinks) {break}}
};

我们设置了同时执行5个异步__17JITA,这样就可以利用nodejs的异步特性加快爬取速度了。

到这里坑就基本填完了,最后做一下优化,连接超时后自动退出

// __17JITA
// 在开始添加
let time = setTimeout(() => {reject('超时')}, 25000);for (let href of link) {if (!reachedLink.includes(href)) {allLinks.push(href);clearTimeout(time)}
}

为了避免给《17吉他网》带来不必要的麻烦,源代码就不放出来了,希望大家只是学习技术,不要用作商业用途。

记录一次nodejs爬取《17吉他》所有吉他谱相关推荐

  1. 记录一次nodejs爬取《17吉他》所有吉他谱(只探讨技术)

    不洗碗工作室 -- xinzai 突然就想扒一下吉他谱了,说做就做哈哈,中间也是没有想象中的顺利啊,出现了各种意想不到的坑,包括老生常谈的nodejs异步写法,还有可怕的内存溢出等问题..我将一步步回 ...

  2. 记录一下使用nodejs爬取双色球历史开奖数据并写入文件过程,仅自己做着玩玩

    [双色球给了几个红球和几个蓝球使用js生成所有可能的结果] 发送请求这里使用的 superagent 解析 html 这里使用的 cheerio 拿的数据是:https://datachart.500 ...

  3. 使用nodejs爬取和讯网高管增减持数据

    为了抓取和讯网高管增减持的数据,首先得分析一下数据的来源: 网址: http://stockdata.stock.hexun.com/ggzjc/history.shtml 使用chrome开发者工具 ...

  4. node.js request get 请求怎么拿到返回的数据_使用JS和NodeJS爬取Web内容

    作者 | Shenesh Perera译者 | 王强策划 | 李俊辰这些年来 Javascript 进步飞快,又引入了称为 NodeJS 的运行时,所以已经成为了最流行和使用最广泛的语言之一.不管你要 ...

  5. 把爬取信息导出到mysql,关于爬虫学习的一些小小记录(四)——爬取数据存入数据库...

    关于爬虫学习的一些小小记录(四)--爬取数据存入数据库 创建数据库 pymysql 模块 具体操作 预知后事如何 前面我们已经讲了怎么访问网页,并且从网页源码中提取数据.既然数据有了,怎样管理就是下一 ...

  6. 【学习记录】基于python爬取Flickr图片及元数据

    为复现师姐论文成果,爬取Flickr网站数据,只需爬取图片元数据,无需爬取图片: (一已成功,二失败了,这里记录给自己看.) 一.用Python的icrawler包 icrawler是一个轻型框架,自 ...

  7. nodejs 爬取动态网页

    文章目录 前言 准备工作 创建工程 今日头条爬虫代码 后记 前言 昨天实现了草榴的爬取 nodejs 做一个简单的爬虫爬草榴,今天对代码做了一部分修改,增加了可以指定开始页和结束页,并且给所有文件都单 ...

  8. 捡了鼠标开网吧系列——nodejs爬取电影链接

    前言 最近在看新出的电视剧"斗破苍穹",可是一集电视剧的广告开头90s中间45s片尾15s,实在令人发指.之前有了解过,网上有很多免vip解析接口,于是本渣决定动手做一个可以去除广 ...

  9. 1024_scsdn_徽章获取日_日常工作记录_百度图片爬取小程序

    运行下述代码,将会自动打开百度图片搜索并开始无限下载所搜索到的图片,你不停,它不停,会保存至当前文件夹. 前提是使用python,然后安装了webdriver驱动器 from selenium imp ...

最新文章

  1. char*,const char*,string的相互转换 C++
  2. Javascript闭包,比较好懂的一篇文章
  3. (二)Amazon Lightsail 部署LAMP应用程序之部署单片LAMP应用程序
  4. leetcode 485,487,1004. Max Consecutive Ones I ,II, III(最大连续1的个数问题合集)
  5. 玩转C#控件-常用控件属性
  6. li或dd 浮动后增加图片时高度多出3-5px的问题
  7. css控制的代码,通过CSS控制把网页上的代码美化
  8. Visual Studio 2008下AJAX的设置
  9. 【往届成功检索】ICAITA2020国际学术会议来袭,诚邀参与!
  10. 【2012百度之星资格赛】F:百科蝌蚪团
  11. springboot日志可视化_Springboot面试问题集锦
  12. 有关科学计算方面的python解决
  13. Powershell管理系列(一)Active Direcrtory管理:用户管理
  14. Android View框架总结(二)View焦点
  15. 同步与互斥的基本原理
  16. 解决windows 您没有权限访问\\192.168.1.X(局域网) 请与网络管理员联系请求访问权限
  17. TraceView工具如何使用
  18. 爱立信软件测试实习生面试
  19. 小米摄像头上传云服务器,杜老师说群晖:小米云摄像头无法对接群晖存储解决方法...
  20. 2021年焊工(初级)报名考试及焊工(初级)新版试题

热门文章

  1. Python-模拟鼠标键盘动作
  2. 【堡垒机测评】关于纽盾堡垒机、jumpserver堡垒机、行云管家堡垒机的使用对比
  3. GUC-9 ReadWriteLock : 读写锁
  4. Redis 单数据多源超高并发下的解决方案 1
  5. 嵌入式开发用到的一些工具
  6. 学习PrintWriter类
  7. 程伟健:粤港澳大湾区西进 , 城市民宿集群将为金湾发展实力布局
  8. STM32F429 以太网MAC滤波应用说明
  9. Day12 学习分享 - 面向对象
  10. 火箭还是飞机?——DevOps 的两种模式