博客 / 詳情

返回

Puppeteer 的谷歌訂單爬蟲服務

基於 Puppeteer的谷歌訂單爬蟲服務

項目概述

本項目是一個基於 Puppeteer的谷歌訂單爬蟲服務,旨在通過自動化的方式登錄谷歌賬户並爬取訂單信息,並嵌入到某後台系統中,方便用户在後台系統中直接查詢gp訂單數據。項目中包含了多個服務模塊和控制器,用於處理不同的登錄和數據獲取邏輯。

項目結構

服務模塊

項目中主要的服務模塊 puppeteerService.js實現了這些功能:自動登錄、獲取驗證碼、驗證碼驗證、圖像驗證碼獲取及刷新、數據爬取等。

控制器

項目中包含多個控制器文件,如 loginController.js、verifyController.js、statusController.js等,這些控制器負責處理來自客户端的請求,並調用相應的服務模塊執行操作。

// statusContrtroller.js 

export const getStatus = async (req, res) => {
    const { account } = req.query;
    const puppeteerServiceTemp =
        account === A3_GOOGLE_ACCOUNT ?
            puppeteerServiceA3
            : puppeteerService;

    try {
        const statusObj = await puppeteerServiceTemp.getStatus();
        res.json({
            code: 200,
            data: statusObj,
            message: '',
            success: true
        });
    } catch (error) {
        console.error(error);
        logger.error(`獲取狀態失敗: ${error}`)
        res.status(200).json({
            code: 500,
            data: null,
            message: '獲取狀態失敗',
            success: false
        });
    }
};

狀態管理

項目中定義了一個 LoginStatus枚舉,用於表示不同的登錄狀態,如未登錄、驗證碼驗證中、已登錄等。每個服務模塊都有自己的狀態管理邏輯,確保在不同操作中能夠正確更新和檢查登錄狀態。

export const LoginStatus = Object.freeze({
    LOGGED_OUT: 'LOGGED_OUT', // 未登錄
    AWAITING_VERIFICATION: 'AWAITING_VERIFICATION', // 驗證碼等待填寫中
    VERIFYING_CODE: 'VERIFYING_CODE', // 驗證碼驗證中
    ONLINE: 'ONLINE', // 已登錄,
    NO_AUTH_ONLINE: 'NO_AUTH_ONLINE', // 已登錄但無查看訂單權限,
    AWAITING_IMG_CODE: 'AWAITING_IMG_CODE', // 圖形驗證碼等待填寫中
});

核心功能

變量定義

let browser;
let loginPage; // 用於存儲登錄頁面,以便於從接口接收到驗證碼時,程序識別該在哪個頁面進行輸入驗證碼操作
let imgPage; // 用於存儲圖形驗證碼頁面
let lastSendTime = 0; // 上次發送驗證碼的時間
let timeoutId = null; // 存儲定時器 ID
// 用於選擇頁面元素
const currentSelectors = {
 usernameInput: '#identifierId',
        usernameSubmitButton: '#identifierNext',
        passwordInput: '#password',
        passwordSubmitButton: '#passwordNext',
        verificationCodeInput: 'input[name="Pin"]',
        verificationCodeSubmitButton: '#idvPreregisteredPhoneNext',
        errorSelector: 'span[jsslot]',
        totpNext: '#totpNext'
};

自動化登錄

使用 Puppeteer模擬瀏覽器操作,自動化登錄谷歌賬户。支持輸入用户名、密碼和驗證碼等操作。

export const login = async () => {
    await checkLoginStatus();
    if (status.current !== LoginStatus.LOGGED_OUT) {
        return {
            data: null,
            success: false,
            code: 'NOT_IN_LOGGED_OUT',
            message: '當前不是未登錄狀態'
        };
    }
    const page = await browser.newPage();
    await page.goto(loginPageUrl, {timeout: 120 * 1000});

    await page.waitForSelector(currentSelectors.usernameInput);
    await page.type(currentSelectors.usernameInput, process.env.USER_NAME);
    await page.waitForSelector(currentSelectors.usernameSubmitButton);
    await page.click(currentSelectors.usernameSubmitButton);
    try {
        await page.waitForSelector(currentSelectors.passwordInput);
        await page.type(currentSelectors.passwordInput, process.env.USER_PASSWORD);
    }
    catch (e){
        // 其他處理,校驗是否出現了圖像驗證碼
    }
    await page.waitForSelector(currentSelectors.passwordSubmitButton);
    await page.click(currentSelectors.passwordSubmitButton);
    status.update(LoginStatus.AWAITING_VERIFICATION);
    loginPage = page;
    lastSendTime = new Date().valueOf();
    timerIdManage();
    return {
        data: null,
        success: true,
        code: 200,
        message: '已進入登錄流程'
    }
}

驗證碼處理

通過定時器管理,確保驗證碼在規定時間內被處理。

const timerIdManage = () =>{
    // 清除之前的定時器
    if (timeoutId) {
        clearTimeout(timeoutId);
    }
    // 設置一個定時器,十分鐘後檢查一下:距離上次發送驗證碼的時間是否"超過10分鐘且status狀態未改變",如果是,則清空loginPage 且重置status
    timeoutId = setTimeout(async () => {
        if (status.current !== LoginStatus.ONLINE && new Date().valueOf() - lastSendTime > 10 * 60 * 1000) {
            logger.info('驗證碼超過十分鐘未填寫,重置登錄流程');
            if(loginPage){
                await loginPage.close();
                loginPage = null;
            }
            if(imgPage){
                await imgPage.close();
                imgPage = null;
            }
            status.update(LoginStatus.LOGGED_OUT);
        }
    }, 10 * 60 * 1000);
}

驗證碼校驗

用户請求驗證碼校驗接口,程序執行verifyController.js

import * as puppeteerService from '../services/puppeteerService.js';
import * as puppeteerServiceA3 from '../services/puppeteerServiceA3.js';

const A3_GOOGLE_ACCOUNT = 'infocenter@a3games.com';

export const verify = async (req, res) => {
    const { code, account } = req.body;
    console.log('req', req);
    const puppeteerServiceTemp =
        account === A3_GOOGLE_ACCOUNT ?
            puppeteerServiceA3
            : puppeteerService;
    console.log('接受到驗證碼參數', code);

    if (!code) {
        return res.status(400).json({ data: null, success: false, code: 400, message: '缺少驗證碼參數' });
    }
    try {
        const data = await puppeteerServiceTemp.verifyCode(code);
        res.status(200).json({ ...data});
    } catch (error) {
        console.error(error);
        res.status(200).json({ data: null, success: false, code: 500, message: '' });
    }
};
export const verifyCode = async (verificationCode) => {
    if (!loginPage) {
        logger.info('登陸頁面不存在');
        status.update(LoginStatus.LOGGED_OUT);
        return {
            data: null,
            code: 'NO_LOGIN_PAGE',
            success: false,
            message: '登錄頁面不存在,請重新登錄'
        };
    }
    // 如果當前狀態不是驗證碼驗證
    if (status.current !== LoginStatus.AWAITING_VERIFICATION) {
        logger.info('當前狀態不是驗證碼驗證狀態,無法進行驗證碼校驗');
        return {
            data: null,
            code: 'NOT_IN_STEP',
            success: false,
            message: '當前狀態不是驗證碼驗證,無法進行驗證碼校驗'
        };
    }
    status.update(LoginStatus.VERIFYING_CODE);
    // 移除上次報錯元素,方便下次輸入判斷
    await loginPage.$eval(currentSelectors.errorSelector, el => el.remove()).catch(() => null);
    try {
        await loginPage.waitForSelector(currentSelectors.verificationCodeInput);
        await loginPage.$eval(currentSelectors.verificationCodeInput, el => el.value = '');
        await loginPage.type(currentSelectors.verificationCodeInput, verificationCode);

        await loginPage.waitForSelector(currentSelectors.totpNext);
        await loginPage.click(currentSelectors.totpNext);

        const result = await Promise.race([
            // 設置4s等待時間,如果4s後仍然沒有檢查到驗證碼錯誤提示,則認為驗證成功
            new Promise(resolve => setTimeout(() => resolve('timeout'), 4 * 1000)).then(async()=>{
                const errorMessage = await loginPage.$eval(currentSelectors.errorSelector, el => el.innerText).catch(() => null);
                if(!errorMessage){
                    return 'success';
                }
                else {
                    return errorMessage;
                }
            }),
            loginPage.waitForSelector(currentSelectors.errorSelector, {timeout: 120 * 1000}).then(async () => {
                const errorMessage = await loginPage.$eval(currentSelectors.errorSelector, el => el.innerText).catch(() => null);
                return errorMessage;
            })
        ]);
        if (result === 'success') {
            await new Promise(resolve => setTimeout(resolve, 3000));
            await loginPage.goto(checkLoginUrl, {
                timeout: 120 * 1000,
                waitUntil: 'domcontentloaded',
            });
            const curPageUrl = loginPage.url();
            // 判斷是否跳轉到了已登錄頁面
            const isLoggedIn = curPageUrl.includes('https://play.google.com/console/developers');
            if (isLoggedIn) {
                if(timeoutId){
                    clearTimeout(timeoutId);
                }
                status.update(LoginStatus.ONLINE);
                loginPage.close();
                loginPage = null;
                return {
                    data: null,
                    success: true,
                    code: 'VERIFY_SUCCESS',
                    message: '驗證成功'
                };
            } else {
                if(timeoutId){
                    clearTimeout(timeoutId);
                }
                // 驗證碼登錄成功了,但是沒有權限訪問訂單
                status.update(LoginStatus.NO_AUTH_ONLINE);
                return {
                    success: true,
                    data: null,
                    code: 'NO_ORDER_AUTH',
                    message: '沒有訪問權限'
                };
            }
        }
        else {
            // 驗證碼錯誤重置為等待驗證碼狀態,提示重試
            status.update(LoginStatus.AWAITING_VERIFICATION);
            return {
                code: 'CODE_ERROR',
                data: null,
                success: false,
                message: result || '驗證碼不正確'
            };
        }
    } catch (e){
        status.update(LoginStatus.AWAITING_VERIFICATION);
        logger.error(`驗證碼校驗失敗: ${e}`, loginPage.url(), await loginPage.content());
    }
}

狀態檢查

通過檢查頁面 URL和特定元素的內容,判斷當前的登錄狀態,並在登錄過期時自動更新狀態為未登錄。

const url = await page.url();
 if (url.includes('accounts.google.com')) {
 updateStatus(LoginStatus.LOGGED_OUT);
 } else {
 updateStatus(LoginStatus.ONLINE);
 }
}

數據爬取

在成功登錄後,項目能夠自動化爬取谷歌訂單信息,並將結果返回給客户端。

await page.goto('https://myaccount.google.com/purchases');
 const data = await page.evaluate(() => {
 return Array.from(document.querySelectorAll('.order-item')).map(item => item.innerText);
 });
 return data;
}

獲取、刷新和驗證圖像驗證碼

獲取圖像驗證碼

const page = await browser.newPage();
    await page.goto(loginPageUrl, {timeout: 120 * 1000});
    await page.waitForSelector(currentSelectors.usernameInput);
    await page.type(currentSelectors.usernameInput, process.env.USER_NAME);
    await page.waitForSelector(currentSelectors.usernameSubmitButton);
    await page.click(currentSelectors.usernameSubmitButton);
    try {
        await page.waitForSelector(currentSelectors.passwordInput);
        await page.type(currentSelectors.passwordInput, process.env.USER_PASSWORD);
    }
    catch (e){
        const playCaptchaButton = await page.waitForSelector('#playCaptchaButton');
        if (playCaptchaButton) {
            logger.info('出現了圖形驗證碼');
            //獲取 id為 captchaimg 的圖片的src屬性
            const captchaImgSrc = await page.$eval('#captchaimg', (el) => el.src);
            logger.info('captchaSrc', captchaImgSrc);
            logger.info('此時url', await page.url());
            imgPage = page;
            // 更新狀態為等待圖形驗證碼提交
            status.update(LoginStatus.AWAITING_IMG_CODE);
            // 初始化定時器,十分鐘內未校驗圖形驗證碼則重置狀態
            timerIdManage();
            return {
                data: {
                    captchaImgSrc,
                },
                success: true,
                code: 'NEED_IMG_CODE',
                message: '需要校驗圖形驗證碼'
            }
        }
        // logger.info('未出現圖形驗證碼');
    }

刷新圖像驗證碼

async function refreshImgCode() {
 await page.click('#refresh-captcha');
 await page.waitForTimeout(1000);
 const newImgSrc = await page.$eval('#captcha-img', img => img.src);
 return newImgSrc;
}

驗證圖像驗證碼

export const verifyImgCode = async (imgCode) =>{

    if(!imgPage){
        return {
            data: null,
            success: false,
            code: 500,
            message: '未找到圖形驗證碼頁面'
        }
    }
    if(status.current !== LoginStatus.AWAITING_IMG_CODE){
        return {
            data: null,
            success: false,
            code: 500,
            message: '當前不是等待圖形驗證碼狀態'
        }
    }
    await imgPage.waitForSelector('input[type="text"]');
    await imgPage.type('input[type="text"]', imgCode);

    await imgPage.waitForSelector(currentSelectors.usernameSubmitButton);
    await imgPage.click(currentSelectors.usernameSubmitButton);
    logger.info('驗證圖形驗證碼-點擊了用户了提交按鈕');
    const result = await Promise.race([
        // 設置4s等待時間,如果4s後仍然沒有檢查到密碼輸入框,則認為圖形驗證碼驗證失敗;否則成功
        new Promise(resolve => setTimeout(() => resolve('timeout'), 4 * 1000)).then(async()=>{
            const messageInput = await imgPage.waitForSelector(currentSelectors.passwordInput).catch(() => null);
            if(!messageInput){
                return 'failed';
            }
            return 'success';
        }),
        imgPage.waitForSelector(currentSelectors.passwordInput, {timeout: 120 * 1000}).then(async () => {
            return 'success';
        })
    ]);
    if (result === 'success') {

        await imgPage.waitForSelector(currentSelectors.passwordInput);
        await imgPage.type(currentSelectors.passwordInput, process.env.USER_PASSWORD_NIBIRUTECH);
        logger.info('圖形驗證碼驗證成功,已輸入用户密碼', process.env.USER_PASSWORD_NIBIRUTECH);
        await imgPage.waitForSelector(currentSelectors.passwordSubmitButton);
        await imgPage.click(currentSelectors.passwordSubmitButton);
        // await page.waitForNavigation({ timeout: 120 * 1000, waitUntil: 'domcontentloaded' }); // stable版本的chrome不需要,註釋

        status.update(LoginStatus.AWAITING_VERIFICATION);
        loginPage = imgPage;
        imgPage = null;
        lastSendTime = new Date().valueOf();
        logger.info('圖形驗證碼通過,已進入登錄流程');
        timerIdManage();
        return {
            data: null,
            success: true,
            code: 200,
            message: '已進入登錄流程'
        }
    }
    else {
        // 驗證碼錯誤重置為等待驗證碼狀態,提示重試
        status.update(LoginStatus.AWAITING_IMG_CODE);
        logger.info('圖形驗證碼錯誤', await imgPage.content());
        return {
            code: 'CODE_ERROR',
            data: null,
            success: false,
            message: `填寫圖形驗證碼:${imgCode}不正確`
        };
    }

}
}

錯誤處理

項目中實現了詳細的錯誤處理邏輯,在登錄失敗、驗證碼錯誤、數據獲取失敗等情況下,能夠捕獲異常並返回相應的錯誤信息。同時,項目中使用了日誌記錄,便於調試和監控。

// 指定要刪除的文件夾路徑
const folderToDelete = join(__dirname, '../../tmp/gpc_order_spider_usr_dir3');

const fetchData = async (page) => {
    // 爬取數據操作...
    } catch (error) {
        // 發生錯誤的頁面
        const str = await page.content();
        if(str.includes('Signed out')){
            // 若頁面上有字符串Signed out,則代表登錄過期
            status.update(LoginStatus.LOGGED_OUT);
            clearLogin();
            logger.info('登陸信息已清除');
            return {
                data: null,
                message: '登錄過期',
                code: 'NOT_LOGGED_IN',
                success: false,
            };
        }
        return {
            data: null,
            message: '數據查詢發生錯誤',
            code: 'DATA_SEARCH_ERROR',
            success: false,
        };
    }
};

// 刪除 tmp 文件夾的函數
export const clearLogin = async () => {
    try {
        // 遞歸刪除文件夾其內容
        await rm(folderToDelete, {recursive: true, force: true});
        status.update(LoginStatus.LOGGED_OUT);
        browser.close();
        initializeBrowser();
        logger.info(`文件夾 ${folderToDelete} 已成功刪除`);
    } catch (error) {
        logger.error(`刪除文件夾時發生錯誤: ${error}`);
    }
};

總結

該項目通過自動化的方式簡化了谷歌訂單信息的獲取流程,適用於需要頻繁查詢訂單信息的場景。通過模塊化的設計,項目能夠靈活地擴展和維護,支持多賬户的管理和操作。結合 Puppeteer的強大功能,項目實現了自動化登錄、驗證碼處理、狀態檢查和數據爬取,具備良好的擴展性和穩定性,適合在生產環境中使用。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.