不洗碗工做室 -- xinzaijavascript
忽然就想扒一下吉他譜了,說作就作哈哈,中間也是沒有想象中的順利啊,出現了各類意想不到的坑,包括老生常談的nodejs異步寫法,還有可怕的內存溢出等問題。。我將一步步回顧各類重要的錯誤及個人解決方法,只貼關鍵部分代碼,只探討技術。(本篇文章不是入門文章,讀者須要具備必定的ES6/7,nodejs能力以及爬蟲相關知識)html
使用的技術前端
因爲未使用同步寫法的nodejs框架,並且各類庫也都是回調寫法,須要稍做修改,因此對ES6/7的同步寫法有必定的要求java
nodejs
request
cheerio(相似jquery)
ES6/7
mongoose
iconv-lite
uuid
node
咱們的最終目的就是獲取全部的吉他譜,而後保存到咱們的數據庫中,咱們使用cheerio
來獲取頁面的dom元素,因此首先咱們觀察一下頁面結構,怎麼觀察我就不說了,說一下我看到的規律jquery
img標籤git
能夠看到,吉他譜的圖片會帶一個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壓縮,不過不寫好像也能夠。而後。。
當你打印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)
// ...
})
複製代碼
如今是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對應不少圖片的。
如今咱們來簡單的回顧一下咱們獲得了哪些東西吧~
link
images
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) => {
// 邏輯
})
複製代碼
接下來就是坑了
link.forEach(async (href) => {
if (!reachedLink.includes(href)) {
try {
await __17JITA(href);
} catch (err) {
console.log(err.message)
}
}
});
複製代碼
乍一看沒問題,可是他是有問題的,由於雖然回調函數是async
同步寫法,可是forEach
可無論你,一股腦全給你執行一遍,咱們的預期是link
數組中一個連接的回調執行完再執行下一個回調,可是事實上他會同時遍歷完整個link
數組,同步的過程只是在回調函數裏面,沒有任何意義
這帶來的後果是可怕的,由於連接個數是指數級增加的,這麼多個異步請求發出去,彙編寫的也受不了啊
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個函數,其餘的遞歸都在任務隊列裏
這個問題如何解決呢?
遞歸優化
尾遞歸能夠優化遞歸的邏輯,可是這個無法作尾遞歸,並且數據量太大了,我最終沒有采用
減小遞歸數
咱們能夠及時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吉他網》帶來沒必要要的麻煩,源代碼就不放出來了,但願你們只是學習技術,不要用做商業用途。