【爬蟲】用 puppeteer 來關注掘金優秀做者

前言

前段時間一個女性朋友給我發來一個連接,裏面內容截圖node

說有人賣她這個軟件 1000 塊,我心想這用爬蟲我也能爬出來啊。git

因而信誓旦旦對她說:你別買,我給你寫,寫完這個 Spider,我就是你的 Spider Man 了。雖然她面無表情地哦了我一下。github

通過「千辛萬苦」我找到了 puppeteer 這個庫,能夠在 node.js 下爬取一些 ajax 請求後渲染的頁面數據。既然在掘金混,那就先看看掘金的大佬們,而後順便點個關注。web

1. 效果演示

源碼地址ajax

1.1 登陸

1.2 關注優秀做者

1.4 提取優秀做者信息

2. 需求

  1. 實現登陸,保存 cookies 到本地;
  2. 查找優秀做者信息並整理到本地,且能夠關注和取關;
  3. 用 typescript 面向對象寫;

肯定好需求,那能夠開始寫代碼了。chrome

3. 準備工做

4. puppeteer 簡單介紹

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 的介紹能夠看官網。

5. 實現

5.1 登陸

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();
}
複製代碼

5.2 首次登陸

首次登陸咱們須要本身輸入帳號密碼,經過 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);
        }
      }
    });
  });
}
複製代碼

5.3 滾動屏幕

經過 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;
  }
}
複製代碼

5.4 關注/取關優秀做者

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);
}
複製代碼

5.5 收集優秀做者信息

這裏分爲兩步,首先獲取全部優秀做者可點擊項,而後依次點擊每一項開啓新頁面整理數據而後再關閉,同時打開太多個會加載不出來頁面。

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 吧 😝。

相關文章
相關標籤/搜索