前段時間一個女性朋友給我發來一個連接,裏面內容截圖node
說有人賣她這個軟件 1000 塊,我心想這用爬蟲我也能爬出來啊。git
因而信誓旦旦對她說:你別買,我給你寫,寫完這個 Spider,我就是你的 Spider Man 了。雖然她面無表情地哦了我一下。github
通過「千辛萬苦」我找到了 puppeteer
這個庫,能夠在 node.js 下爬取一些 ajax 請求後渲染的頁面數據。既然在掘金混,那就先看看掘金的大佬們,而後順便點個關注。web
源碼地址ajax
肯定好需求,那能夠開始寫代碼了。chrome
puppeteer 是一個 chrome 官方出品的 node.js 庫,他提供了在無 UI 狀況下使用 chrome 的功能typescript
能夠截圖,導出 pdf,進行 UI 自動化測試,爬蟲等等。固然咱們這裏主要是對頁面爬取功能進行一次試探,下面介紹簡單使用方法。shell
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = 1
設置不下載 chrome,自行下載會更快,前往下載地址。npm
npm init -y
npm i puppeteer
touch app.js
複製代碼
// app.js
const puppeteer = require('puppeteer');
(async () => {
// 開啓瀏覽器
const browser = await puppeteer.launch({
headless: false, // 設置爲 true 啓用無 UI 模式
slowMo: 20, // 下降執行速度,方便看到執行動做
executablePath: './chrome/Chromium.app/Contents/MacOS/Chromium' // chromium 路徑
});
const page = await browser.newPage(); // 打開新的標籤頁
await page.goto('https://baidu.com'); // 前往百度頁面
await page.screenshot({path: 'baidu.png'}); // 截圖
await browser.close(); // 關閉瀏覽器
})();
複製代碼
這樣就打開了百度並截了個圖。json
更多 API 的介紹能夠看官網。
App.js
部分代碼:
try {
// 讀取本地 cookies
const cookiesString = await fs.readFileSync(Config.cookiesPath, 'utf-8');
const cookies: SetCookie[] = JSON.parse(cookiesString);
// 判斷是否過時
if (cookies[0].expires && Util.isNotOverdue(cookies[0].expires)) {
[page, browser] = await Puppeteer.init(); // 初始化瀏覽器
await page.setCookie(...cookies); // 頁面設置 cookie
await Util.goTo(page); // 前往掘金首頁
} else {
// 首次登錄
[page, browser] = await Login.firstLogin();
}
} catch (e) {
// 首次登錄
[page, browser] = await Login.firstLogin();
}
複製代碼
首次登陸咱們須要本身輸入帳號密碼,經過 node.js 從命令行讀取。
InputConsole.ts
:
import * as readline from 'readline';
export default class InputConsole {
// 定義逐行讀取的實例
public static readonly interface = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
public static async inputUser(): Promise<string[]> {
InputConsole.interface.setPrompt('帳號: '); // 提示輸入帳號
InputConsole.interface.prompt(); // 提供可輸入新位置
const username = await InputConsole.getLineContent(); // 獲取用戶輸入帳號
InputConsole.interface.setPrompt('密碼: '); // 提示輸入密碼
InputConsole.interface.prompt(); // 提供可輸入新位置
const password = await InputConsole.getLineContent(); // 獲取用戶輸入密碼
InputConsole.interface.close(); // 關閉實例
return [username.trim(), password.trim()]; // 返回帳號密碼
}
// 監聽 'line' 事件獲取輸入內容
private static getLineContent(): Promise<string> {
return new Promise((resolve): void => {
InputConsole.interface.on('line', (line): void => {
resolve(line);
});
});
}
}
複製代碼
Login.ts
:登陸邏輯
因爲某些 cookie 是經過 document.cookie 獲取不到的,因此咱們要經過登陸後的響應頭獲取
public static async firstLogin(): Promise<[Page, Browser]> {
return new Promise(async resolve => {
let result: string[] = [];
// 監聽 readline interface 關閉
InputConsole.interface.on('close', async (): Promise<void> => {
// 初始化 puppeteer 相關設置,獲取 page 和 browser 對象
const [page, browser] = await Puppeteer.init();
// 前往掘金頁面
await Util.goTo(page);
// 登陸初始化,若是登錄過成功,返回 page 和 browser
// 不然關閉瀏覽器
const success = await Login.init(page, result);
if (success) {
resolve([page, browser]);
} else {
await browser.close();
}
});
result = await InputConsole.inputUser();
});
};
// init 函數
private static async init(page: Page, [username, password]: string[]): Promise<Boolean> {
// 點擊登陸按鈕
await page.$eval(
'span.login',
(login: any): void => login.click(),
);
// 輸入從命令行讀取的帳號密碼
await Promise.all([
await page.type('input[name="loginPhoneOrEmail"]', username),
await page.type('input[name="loginPassword"]', password),
]);
// 點擊肯定按鈕
await page.$eval('div.panel > button.btn', (btn: any): void => btn.click());
return new Promise((resolve) => {
// 監聽頁面內的 response
page.on('response', async (res): Promise<void> => {
const url = res.url();
// 判斷是否是登陸接口
if (url === 'https://juejin.im/auth/type/phoneNumber'
|| url === 'https://juejin.im/auth/type/email') {
// 若是接口調用成功,從 headers 裏獲取 cookie 存入本地,返回成功
// 返回失敗
if (res.status() === 200 && res.ok()) {
const cookiesArray: Record<string, any>[] = [];
const headers = res.headers();
for (let key in headers) {
if (key === 'set-cookie') {
// 分組 cookies
const cookiesList = headers[key].split('\n');
// 遍歷分組 cookies
cookiesList.forEach(cookiesString => {
// 以 ; 分割成數組
const splitHeaders = cookiesString.split(';');
// 設置 domain 爲掘金
const cookies: Record<string, any> = { domain: 'juejin.im' };
// 遍歷每一項
splitHeaders.forEach((headers, index) => {
// 以 = 分割,注意不要分割掉 auth 最後的 =,因此正則匹配後面不爲空的 = 來分割
const [key, value] = headers.split(/=(?=\S)/);
// 若是 value 存在,則設置對應的屬性,不然是 httpOnly 這些設置爲 true
if (value) {
if (index === 0) {
cookies.name = key.trim();
cookies.value = value.trim();
} else {
if (key.trim().toLowerCase() === 'expires') {
cookies[key.trim()] = new Date(value).getTime();
} else {
cookies[key.trim()] = value.trim();
}
}
} else {
const resultKey = key.trim().toLowerCase() === 'httponly' ? 'httpOnly' : key.trim();
cookies[resultKey] = true;
}
});
// 存入數組
cookiesArray.push(cookies);
});
}
}
// 寫入本地文件保存
fs.writeFileSync(Config.cookiesPath, JSON.stringify(cookiesArray), 'utf-8');
console.clear();
console.log('登錄成功');
resolve(true);
} else {
console.log('帳號/密碼錯誤');
resolve(false);
}
}
});
});
}
複製代碼
經過 window.scrollBy 來實現滾動,主要的部分就要判斷當前頁面內容高度和滾動高度,而後來調用方法進行滾動。因爲是數據異步加載後再渲染 UI,因此咱們要等待 UI 渲染完成後纔去獲取高度。
Author.ts
:
// 獲取 body 高度
public static async getBodyHeight(page: Page): Promise<number> {
// 等待數據加載完成再獲取
await page.waitForResponse((response): boolean => {
if (response.url() === 'https://web-api.juejin.im/query') {
const data = JSON.parse(response.request().postData() as string);
return response.status() === 200 && data.variables && data.variables.channel;
}
return false;
});
// 返回 body 高
return await page.$eval('body', (body: any): number => body.clientHeight);
};
// 滾動屏幕
public static async scroll(page: Page): Promise<void> {
// 取出 body 高度
let height = await Author.getBodyHeight(page);
// 20條數據的 li 元素高度
const scrollHeight = 96 * 20;
// 每次滾動的高度
let offsetHeight = scrollHeight;
// 取數計數器
let count = 1;
// 循環,滾動到底部前或者計數器未滿
while (offsetHeight < height && count < 10) {
// 計數 + 1
count += 1;
// 執行滾動
await page.evaluate(scrollTop => {
window.scrollBy({
top: scrollTop,
left: 0,
// behavior: 'smooth',
});
}, offsetHeight);
// 從新計算 body 高度
height = await Author.getBodyHeight(page);
// 計算下一次須要滾動的距離
offsetHeight = scrollHeight * count;
}
}
複製代碼
Author.ts
:
// focus:true 爲關注,false 取關
public static async focus(page: Page, focus: boolean) {
// 獲取所有關注/已關注按鈕
const buttons = await page.$$('a.link button.follow-btn') as ElementHandle[];
// 存放按鈕點擊的數組
const buttonPr: Promise<void>[] = [];
// 遍歷所有按鈕將執行方法存入數組
buttons.forEach((button): void => {
buttonPr.push(
page.evaluate((btn, focus): void => {
if (btn.innerText.trim() === '關注' && focus) {
btn.click();
} else if (btn.innerText.trim() === '已關注' && !focus) {
btn.click();
}
}, button, focus),
);
});
// 同時異步執行
await Promise.all(buttonPr);
}
複製代碼
這裏分爲兩步,首先獲取全部優秀做者可點擊項,而後依次點擊每一項開啓新頁面整理數據而後再關閉,同時打開太多個會加載不出來頁面。
Author.ts
// 信息收集,獲取全部的點擊項
private static async collectMsg(page: Page): Promise<(() => Promise<void>)[]> { // 獲取全部優秀做者的點擊項 const liList = await page.$$('ul.user-list li.item a.link') as ElementHandle[]; const buttonPr: (() => Promise<void>)[] = []; // 存到 promise 數組,以後一個個取出來拿數據 liList.forEach((item): void => { buttonPr.push(async () => { await page.evaluate((li): void => { li.click(); }, item); }); }); return buttonPr; } // 做者信息整理 public static async getUserMsg(page: Page, browser: Browser): Promise<boolean> { // 存放全部優秀做者信息的數組 const users: User[] = []; // 獲取優秀做者可點擊項 const buttonPr = await Author.collectMsg(page); const isLoaded: Promise<boolean> = new Promise((resolve) => { // 監聽新開的頁面 browser.on('targetcreated', async (target) => { const newPage = await target.page(); await Promise.all([ newPage.setJavaScriptEnabled(true), newPage.setViewport(Config.viewport), ]); // 等待 UI 渲染 await newPage.waitForSelector('div.user-info-block.block.shadow > div.lazy.avatar.loaded'); // 從頁面拿所須要的做者數據 const [ name, level, job, company, motto, like, read, value, ] = await Promise.all([ newPage.$eval( 'h1.username', (h1: any) => h1.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'h1.username > a.rank > img', (img: any) => img.getAttribute('alt')) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.position > span.content > span:first-of-type', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.position > span.content > span:nth-of-type(3)', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.intro > span.content', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.block-body > div.stat-item:nth-last-of-type(3) span.count', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.block-body > div.stat-item:nth-last-of-type(2) span.count', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), newPage.$eval( 'div.block-body > div.stat-item:last-of-type span.count', (span: any) => span.innerText.trim()) .catch(() => new Promise(resolve => resolve(''))), ]); // 構建做者對象 const user = new User(name, level, job, company, motto, like, read, value); // 存入數組 users.push(user); if (buttonPr.length > 0) { // 若是做者數組還有對象,取第一個點擊,獲取完數據再關閉頁面 fn = buttonPr.shift() as (() => Promise<void>); await fn(); await newPage.close(); } else { // 若是當前也全部做者信息獲取完畢,寫入本地 json 文件,並通知上層該步驟結束 fs.writeFileSync(Config.usersPath, JSON.stringify(users), 'utf-8'); resolve(true); } }); }); // 取出第一個做者的 item,執行點擊 let fn = buttonPr.shift() as (() => Promise<void>); await fn(); return isLoaded; } 複製代碼
通過這一波練習以後,仍是掌握了一些經常使用 API 的,這個過程當中也再次發現 TS 的優點,TS 大法好!若是以爲有用麻煩你點個 star 吧 😝。