Nodejs puppeteer + events 實現簡易物流爬蟲


前幾天在掘金看到一篇講利用 puppeteer 進行頁面測試的文章,瞬間對這個無頭瀏覽器來了興趣。一通了解以後,愛不釋手。
Puppeteer(中文翻譯」操縱木偶的人」) 是 Google Chrome 團隊官方的無界面(Headless)Chrome 工具,它是一個Node庫,提供了一個高級的 API 來控制DevTools協議上的無頭版 Chrome 。也能夠配置爲使用完整(非無頭)的 Chrome。Chrome素來在瀏覽器界穩執牛耳,所以,Chrome Headless 必將成爲 web 應用自動化測試的行業標杆。使用Puppeteer,至關於同時具備 Linux 和 Chrome 雙端的操做能力,應用場景可謂很是之多。如:
  • 生成頁面的截圖和PDF。
  • 抓取SPA並生成預先呈現的內容(即「SSR」)。
  • 從網站抓取你須要的內容。
  • 自動錶單提交,UI測試,鍵盤輸入等
  • 建立一個最新的自動化測試環境。使用最新的JavaScript和瀏覽器功能,直接在最新版本的Chrome中運行測試。
  • 捕獲您的網站的時間線跟蹤,以幫助診斷性能問題。
而我也作了一個爬取物流狀態的小 demo, 地址: github.com/yinchengnuo…
puppeteer 雖然很強大,安裝使用卻很簡單。可是由於要 down 一個瀏覽器到 package 裏。因此安裝 puppeteer推薦使用 cnpm,完整的安裝指令以下:
npm i bufferutil utf-8-validate cnpm && npx cnpm puppeteer複製代碼
安裝完成後的使用也很簡單,官方給的文檔很詳細,基本上有什麼需求,看着文檔就能搗鼓出來。上面的 小demo 就是一個簡單的根據物流單號獲取物流狀態的小工具:
const puppeteer = require('puppeteer');

(async () => {
    // 生成瀏覽器實例
    const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'] })
    // 生成一個頁面實例
    const page = await browser.newPage()
    // 讓此頁面去訪問 快遞100 官網
    await page.goto('https://www.kuaidi100.com/', { timeout: 0, waitUntil: 'networkidle2' }) // 557006432812950
    // 找到 快遞100 官網的輸入框
    const input = await page.$('#postid')
    // 把物流單號輸入到輸入框裏
    await input.type(process.argv.slice(2)[0] || '557006432812950')
    // 找到 快遞100 官網的輸入框後面的搜索按鈕
    const query = await page.$('#query')
    // 攔截網路響應
    page.on('response', async res => {
        if (res._url.includes('/query')) { // 判斷指定 url
            console.log(JSON.parse(await res.text())) // 獲取到數據
            await page.close()
            await browser.close()
        }
    })
    // 點擊搜索按鈕
    await query.click()
})();複製代碼
使用起來只須要:
node index.js '物流單號'複製代碼
去掉註釋只有不到 20 行代碼,太強大了。
而邏輯更是簡直不要太簡單,就是和人的操做同樣:
打開瀏覽器 => 打開Tab => 輸入URL回車 => 找到輸入框並輸入 => 點擊搜索 => 獲取數據複製代碼
這應該就是 puppeteer 最簡單的使用了。
可是僅僅這個是沒有辦法實現需求的,好比我如今須要一個接口,帶着物流單號 Get 一下就能獲得物流動態數據。僅僅這樣的話,單單是速度就會讓人抓狂:
await puppeteer.launch()
await browser.newPage()
await page.goto()複製代碼
這三步走下來就至少須要 2 s。固然這個不一樣的設備都有所不一樣。可是顯然咱們不能將這三步放在接口邏輯裏,而是要提早打開 puppeteer,等接收到請求直接執行:
const input = await page.$('#postid')
    await input.type('xxxxxxxxxx')
    const query = await page.$('#query')
    await query.click()複製代碼
就能夠了。
可是這樣會有問題,問題就是當存在併發請求時候。全部的請求操做的都是同一個頁面,同一個 input ,同一個 button。這種狀況下是沒有辦法保證,當 click() 觸發時,input 裏的value 是否是當前接口請求時帶來的參數。
這種狀況下就要考慮加鎖了,還須要一個執行隊列在在併發量大時來放置等待中的物流單號,同時咱們也須要多個 tab 來加強處理能力,以及一個分發函數,將不一樣的請求分發至不一樣 tab。
大概的流程是這樣的:
  • 服務器啓動,啓動 puppteer 並打開必定數量的頁面(這裏是3個),並跳轉至指定頁面等待。在此同時,將 3 個頁面實例並一些其餘狀態保存至 pageList:
const URL = require('url')
const events = require('events');
const puppeteer = require('puppeteer');
const company = require('./util/exoresscom')

const pageNum = 3 // 無頭瀏覽器 tab 數量
let nowPageNum = 0 // 可用 page 個數
const pageList = [] // page 實例列表
const requestList = [] // 待處理單號
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'] })
for(let i = 0; i < pageNum; i ++) { // 初始化指定數量的 page 配置
    const page = await browser.newPage() // 生成 page 實例
    page.goto('https://www.kuaidi100.com/', { timeout: 0, waitUntil: 'domcontentloaded' }).then(() => {
        nowPageNum ++
    }) // 頁面加載完成後標記可用頁面個數
    page.on('response', async res => { // 監聽網頁網絡請求響應數據
        if (res._url.includes('/query?')) { // 監聽指定 url
            const result = JSON.parse(await res.text()) // 取到數據
            result.com ? result.comInfo = company.find(e => e.number == result.com) : '' // 查詢到結果後綁定快遞公司名稱
            !result.nu ? result.nu = URL.parse(res.url(), true).query.postid : '' // 查詢無無結果時將物流單號賦值給 nu 便於從隊列刪除
            event.emit('REQUEST_OK', result) // 將數據發送到全局
        }
    })
    pageList.push({ 
        page, // page 實例
        requesting: false, // 狀態是否在請求中
        async request(order_num) { // 請求方法
            this.requesting = true // 將狀態標記爲請求中
            const input = await this.page.$('#postid') // 獲取輸入框
            await input.type(order_num) // 填入物流單號
            const query = await this.page.$('#query') // 獲取按鈕
            await query.click() // 觸發按鈕點擊
            this.requesting = false // 將狀態標記爲空閒
        }
    })
}複製代碼
  • 當接收到請求,獲取到物流單號,執行分發函數
router.get("/express", async (ctx) => { // 物流單號查詢
    if (ctx.request.query.num) { // 檢查物流單號
        if (nowPageNum) { // 爬蟲頁面是否開啓
            distribute(ctx.request.query.num) // 分發請求
            try {
                ctx.body = await new Promise(resolve => event.on('REQUEST_OK', data => data.nu == ctx.request.query.num && resolve(data))) // 等待請求成功響應請求 
            } catch (error) {
                ctx.body = { msg: '爬取失敗' }
            }
        } else {
            ctx.body = { msg: '爬蟲啓動中' }
        }
    } else {
        ctx.body = { msg: '訂單號不合法' }
    }
})複製代碼
  • 分發函數拿到物流單號,判斷當前是否有空閒 tab 。有就執行爬取,沒有就把物流單號 push 進一個等待隊列
const distribute = order_num => { // 根據訂單號分發請求
    if (!requestList.includes(order_num)) {
        const free = pageList.find(e => !e.requesting) // 獲取空閒的 page
        free ? free.request(order_num) : requestList.push(order_num) // 有空閒 page 就執行爬取,不然推入 requestList 等待
    }
}複製代碼
  • 最後,又回到了第一步,在註冊 page 的時候就給 page 添加了攔截相應事件。當攔截到數據後,經過 event 發射到全局:
page.on('response', async res => { // 監聽網頁網絡請求響應數據
    if (res._url.includes('/query?')) { // 監聽指定 url
        const result = JSON.parse(await res.text()) // 取到數據
        result.com ? result.comInfo = company.find(e => e.number == result.com) : '' // 查詢到結果後綁定快遞公司名稱
        !result.nu ? result.nu = URL.parse(res.url(), true).query.postid : '' // 查詢無無結果時將物流單號賦值給 nu 便於從隊列刪除
        event.emit('REQUEST_OK', result) // 將數據發送到全局
    }
})複製代碼
此時,會有兩處可以接收到響應完成的數據,分別是全局的:
event.on('REQUEST_OK', () => requestList.length && distribute(requestList.splice(0, 1)[0])) // 當有訂單爬取成功且 requestList 有等待訂單,從新分發)複製代碼
和路由內的:
ctx.body = await new Promise(resolve => event.on('REQUEST_OK', data => data.nu == ctx.request.query.num && resolve(data))) // 等待請求成功響應請求複製代碼
至此,全部的邏輯就造成了一個閉環。
一個簡單的爬蟲就作好了。
固然,這只是最理想狀況下的邏輯處理流程,實際中的項目要考慮的狀況要比這多太多了。因此這僅僅是我學習 nodejs 過程當中對 events 和 爬蟲 的一次實踐。完整的接口代碼在這裏:
接口源碼地址 。若有更好看法,還請不吝指出,萬分感謝。
相關文章
相關標籤/搜索