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

不洗碗工做室 -- xinzaijavascript

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

使用的技术前端

因为未使用同步写法的nodejs框架,并且各类库也都是回调写法,须要稍做修改,因此对ES6/7的同步写法有必定的要求java

nodejs request cheerio(相似jquery) ES6/7 mongoose iconv-lite uuidnode

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

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

img标签git

image

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

连接mongodb

网站的连接有不少,尤为这种论坛形式的,咱们不能全都爬一遍,这样的话又费时间又爬取了不少无效的图片,因此咱们须要找到这种吉他谱页面的路由规律:数据库

// 正则
/^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个函数,其余的递归都在任务队列里

第五个坑(内存溢出)

image
都知道递归是至关耗内存的操做,刷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吉他网》带来没必要要的麻烦,源代码就不放出来了,但愿你们只是学习技术,不要用做商业用途。

相关文章
相关标签/搜索