記錄一次nodejs爬取《17吉他》所有吉他譜(只探討技術)

不洗碗工作室發表於2019-03-03

不洗碗工作室 -- xinzai

突然就想扒一下吉他譜了,說做就做哈哈,中間也是沒有想象中的順利啊,出現了各種意想不到的坑,包括老生常談的nodejs非同步寫法,還有可怕的記憶體溢位等問題。。我將一步步回顧各種重要的錯誤及我的解決方法,只貼關鍵部分程式碼,只探討技術。(本篇文章不是入門文章,讀者需要具有一定的ES6/7,nodejs能力以及爬蟲相關知識)

使用的技術

由於未使用同步寫法的nodejs框架,而且各種庫也都是回撥寫法,需要稍作修改,所以對ES6/7的同步寫法有一定的要求

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

觀察頁面結構獲取相關dom元素

我們的最終目的就是獲取所有的吉他譜,然後儲存到我們的資料庫中,我們使用cheerio來獲取頁面的dom元素,所以首先我們觀察一下頁面結構,怎麼觀察我就不說了,說一下我看到的規律

img標籤

image

可以看到,吉他譜的圖片會帶一個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個函式,其他的遞迴都在任務佇列裡

第五個坑(記憶體溢位)

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吉他網》帶來不必要的麻煩,原始碼就不放出來了,希望大家只是學習技術,不要用作商業用途。

相關文章