基於 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的強大功能,項目實現了自動化登錄、驗證碼處理、狀態檢查和數據爬取,具備良好的擴展性和穩定性,適合在生產環境中使用。