記錄一次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吉他網》帶來沒必要要的麻煩,源代碼就不放出來了,但願你們只是學習技術,不要用做商業用途。

相關文章
相關標籤/搜索