上回書說到,使用 puppeteer 庫做了截圖操作簡單展示了 puppeteer 的特性和基本使用方法。這回我們來說說用 puppeteer 做爬取頁面數據並寫入 Excel 的操作。
puppeteer 實戰:爬取搜狗微信搜索的公衆號文章並寫入 Excel
首先背景:搜狗搜索現在有個功能可以根據關鍵字來查詢微信公衆號文章,而我們要做的就是把指定關鍵字的文章給它爬下來,由於這個需求是從我司業務部門那邊來的,所以爲了方便運營同學查看,還需要把爬到的數據放到放到 Excel 中去。
使用完的感受是,這個東西只要讀懂 API,其實並不複雜。個人覺得複雜的的地方在於需要考慮各種 base case。
首先第一個一般的網站都做了防爬處理,我們需要苦旅通過合理的方式繞過。比如搜狗搜索這個就是,剛開始我計劃通過讀取到 a 標籤的 href 屬性,然後直接在瀏覽器中打開這個鏈接。但嘗試後發現,並能這樣操作,估計是搜狗做了什麼防爬操作,這樣做會報 IP 異常。遂放棄,使用 puppeteer 的 page.click 方法解決了這個問題,因爲實質上 puppeteer 是通過模擬真實用戶的點擊操作來實現的,所以並不會受防爬機制的影響。
另外一個就是因爲爬取的是微信文章,所以還要考慮微信文章本身的一些情況,比如說文章被髮布者刪除或遷移、文章被舉報,或者這篇文章時分享另外一篇文章等等情況…
總之,感覺用 puppeteer 寫的爬蟲腳本都是和業務強耦合的,所以個人覺得代碼本身並沒有什麼參考意義。但是我再編寫過程中遇到的各種奇葩問題還是可以看看,也許能爲解決你遇到的問題提供一點思路。
完整代碼如下:
const cheerio = require("cheerio");
const puppeteer = require("puppeteer");
const company = require("./company.json");
const keywords = require("./keywords.json");
const { exportExcel } = require("./export");
// 獲取不通關鍵字搜索結果頁面 url
function getUrl(queryWords, currPage = 1) {
return `https://weixin.sogou.com/weixin?type=2&s_from=input&query=${queryWords}&page=${currPage}`;
}
(async function() {
const width = 1024;
const height = 1600;
// 創建一個瀏覽器實例
const browser = await puppeteer.launch({
// 關閉 Chrome 無頭(無界面)模式
headless: false,
// 顧名思義,設置默認視窗寬高
defaultViewport: { width: width, height: height }
});
async function sougouSpiders(keywords) {
// 新建一個頁面(瀏覽器 Tab 頁)
const page = await browser.newPage();
// 設置頁面等待超時時間,可以稍微長一點,避免因網絡原因導致拋錯
page.setDefaultNavigationTimeout(90000);
// 設置新開 Tab 頁的寬高
await page.setViewport({ width: width, height: height });
let currSpiderPage = 1;
// 在新開的 Tab 頁中打開某一個關鍵字的 url
await page.goto(getUrl(keywords, currSpiderPage), {
withUntil: "networkidle2"
});
// 拿到頁面內容
let firstPageHtml = await page.content();
// 獲取 cheerio 實例
let $ = await cheerio.load(firstPageHtml);
const is404 = $(".main-left")
.eq(0)
.text()
.includes("沒有找到相關的微信公衆號文章。");
// 考慮可能會有關鍵字查詢無果的情況
if (is404) {
console.log("當前關鍵字無檢索結果!");
// 如果關鍵字查詢無果,則關閉這個頁面
await page.close();
} else {
// 根據分頁組件的值讀取到當前關鍵字的檢索結果共有多少頁
const totalPage =
Array.from($(".p-fy a")).length === 0
? 1
: Array.from($(".p-fy a")).length;
// 10 頁之後的數據實際無使用價值,所以判斷如果檢索結果超過 10 頁則只取 10 頁
const reallyTotalPage = totalPage >= 10 ? 10 : totalPage;
// 在控制檯打印一下信息,便於排錯
console.log(
`當前關鍵字:${keywords} 成功獲取當前關鍵字總頁數:${reallyTotalPage}`
);
const result = [];
// 上面的操作實際上只是爲了知道頁面的檢索結果相關信息,真實的爬取操作是在下面這個函數中
async function getData(currSpiderPage) {
console.log("正在爬取頁:", currSpiderPage);
await page.goto(getUrl(keywords, currSpiderPage), {
withUntil: "networkidle2"
});
let firstPageHtml = await page.content();
let $ = cheerio.load(firstPageHtml);
const itemLen = $(".news-box .news-list").find("li").length;
for (let j = 0; j < itemLen; j++) {
const currText = $(".news-box .news-list")
.find("li")
.eq(j)
.find(".txt-box")
.eq(0);
const currLinkBtnId = currText
.find("h3 a")
.eq(0)
.attr("id");
console.log(
`正在爬取關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
1} 條,當前頁共 ${itemLen} 條,共 ${reallyTotalPage} 頁,時間:${new Date()}`
);
// 有些 item 沒有圖片導致 img-box 被隱藏了,所以需要先把這個節點顯示出來,防止點擊失效
await page.addStyleTag({
content:
".img-box{ border:10px solid red; display:block!important }"
});
// 獲取到 link 節點
const linkBtn = await page.$(`#sogou_vr_11002601_img_${j}`);
// 此處的判斷是因爲在實際的爬取過程中發現有些 item 無法點擊
if (linkBtn) {
await page.click(`#sogou_vr_11002601_img_${j}`);
} else {
await page.click(`#sogou_vr_11002601_title_${j}`);
}
try {
let currUrl = null;
// 監聽當前頁面 url 的變化,如果出現了 mp.weixin.qq.com 則說明文章被正常打開
const newTarget = await browser.waitForTarget(t => {
currUrl = t.url();
return t.url().includes("mp.weixin.qq.com");
});
// 拿到新打開的文章詳情頁實例
const newPage = await newTarget.page();
try {
const title = await newPage.waitForSelector("#activity-name");
// 如果能夠讀取到文章標題
if (title) {
// 讀取頁面內容父容器節點
await newPage.waitForSelector("#js_content");
const newPageContent = await newPage.content();
let jq = await cheerio.load(newPageContent);
// 先去遍歷一下所有的圖片,如果有圖片,就在圖片後面追擊一下該圖片的 url
await jq("#js_content img").each((idx, curr) => {
jq(curr).after(
`<div>
【注意了這裏有張圖片,爬取到的 url 是:${$(curr).attr(
"src"
)}】
</div>`
);
});
// 完成上面的操作後,讀取當前文章的詳細內容
const text = await jq("#js_content").text();
// 讀取完成,將當前文章的所有信息寫入數組中去
result.push({
title: $(".news-box .news-list li .txt-box h3 a")
.eq(j)
.text(),
url: currUrl,
account: currText
.find(".account")
.eq(0)
.text(),
publishTime: currText
.find(".s2")
.eq(0)
.text(),
content: text
});
// 當前文章爬取完成,關閉頁面
await newPage.close();
}
} catch (e) {
// 如果上面的操作拋錯,則說明當前文章有問題(實際爬取過程中發現,有些文章鏈接能夠打開,但頁面內容提示已被髮布者刪除或遷移,或者是被舉報了。)
console.log("這篇文章掛了");
// 如果這篇文章出錯,則仍然放到 result 中計個數
result.push({
title: $("#" + currLinkBtnId).text(),
url: `關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
1} 條`,
account: currText
.find(".account")
.eq(0)
.text(),
publishTime: currText
.find(".s2")
.eq(0)
.text(),
content: "這篇文章已被髮布者刪除或遷移,或者是被舉報了"
});
// 完成上述操作,關閉頁面
await newPage.close();
}
} catch (err) {
// 如果是超時錯誤,則關掉當前當前頁面,繼續下一個
if (err.toString && err.toString().includes("TimeoutError")) {
const pagesLen = (await browser.pages()).length;
(await browser.pages())[pagesLen - 1].close();
}
console.log("=====!!!!!!出!錯!!了!!!========", err);
// 如果這篇文章鏈接沒有打開,則放到 result 中計個數
result.push({
title: $("#" + currLinkBtnId).text(),
url: `關鍵字【${keywords}】:第 ${currSpiderPage} 頁的第 ${j +
1} 條`,
account: currText
.find(".account")
.eq(0)
.text(),
publishTime: currText
.find(".s2")
.eq(0)
.text(),
content: "這篇文章pa出問題了,手動搜索一下吧~"
});
}
}
}
for (let i = 0; i <= totalPage; i++) {
if (currSpiderPage <= totalPage) {
await getData(currSpiderPage);
currSpiderPage++;
}
}
console.log(`關鍵字:【${keywords}】,爬取完成正在寫入Excel!`);
// 寫入 Excel
await exportExcel(keywords, result);
// 關閉頁面
await page.close();
}
}
const companyList = company.data;
const keywordList = keywords.data;
// 循環操作
for (let i = 0; i < companyList.length; i++) {
for (let j = 0; j < keywordList.length; j++) {
await sougouSpiders(
`${companyList[i].companyName} ${keywordList[j].join(" ")}`
);
}
}
console.log("爬完了");
})();
exportExcel導出Excel:
const fs = require("fs");
const xlsx = require("node-xlsx");
module.exports = {
async exportExcel(fileName, data) {
let dataArr = [];
let title = ["文章標題", "文章URL", "作者名(公衆號)", "發佈日期", "內容"];
dataArr.push(title);
data.forEach(curr => {
dataArr.push([
curr.title,
curr.url,
curr.account,
curr.publishTime,
curr.content
]);
});
const options = {
"!cols": [
{ wch: 70 },
{ wch: 100 },
{ wch: 30 },
{ wch: 30 },
{ wch: 200 }
]
};
// 寫xlsx
var buffer = await xlsx.build(
[
{
name: "sheet1",
data: dataArr
}
],
options
);
await fs.writeFile(`./dist/data1【${fileName}】.xlsx`, buffer, function(
err
) {
if (err) throw err;
console.log("寫入成功!");
});
}
};