Chia sẻ một vài kinh nghiệm có được khi tôi phát triển Web Automation App bằng Puppeteer.
Các trang web hiện nay phát triển kiểu SPA (Single Page Application) với giao diện được ‘render’ tại Client bằng JavaScript tùy theo dữ liệu trả về từ server qua REST API.
Điều này hoàn toàn khác phương thức truyền thống là Server ‘render’ dữ liệu thành HTML tĩnh rồi trả cho trình duyệt hiển thị.
Do giao diện website SPA chỉ được hiển thị đầy đủ sau một chuỗi xử lý tại trình duyệt, thậm chí còn đòi hỏi tương tác từ người dùng nên để xử lý các website này tự động chúng ta không thể tải và đọc file HTML tĩnh mà cần có cách điều khiển và xử lý HTML DOM tại trình duyệt trong thời gian thực.
Platform điều khiển trình duyệt tự động đầu tiên là Selenium, phát triển từ năm 2004 và vẫn đang được dùng rộng rãi trong những dự án lớn.
Mục lục
Headless Browser là gì?
Headless Browser là một trình duyệt web không có giao diện người dùng (GUI). Trình duyệt này thường khởi chạy bằng dòng lệnh (Command line – CLI) và mọi tương tác với Website sẽ được thực hiện thông qua …code.
Giống như chúng ta lập trình điều khiển cánh tay robot để duyệt web vậy.
Vì sao lại cần một trình duyệt không có GUI? Có một số lý do như sau:
- Hiệu năng tốt hơn do bớt được phần chiếm dụng nhiều tài nguyên nhất.
- Chạy được trên những server không hỗ trợ GUI.
- Dân code như tôi thích màn hình đen của CLI vì trông nguy hiểm (đùa thôi).
Ứng dụng phổ biến của Headless Browser là tự động hóa quá trình:
- Testing: Kiểm tra layout và chức năng hoạt động của website.
- Performance evaluation: Đánh giá hiệu năng của website.
- Scraping / Crawling: Thu thập dữ liệu từ các website.
Một số trình duyệt headless tiêu biểu:
- Chromium (Google Chrome, Microsoft Edge, Opera v.v…) với tham số dòng lệnh
--headless
. - Mozilla Firefox với tham số dòng lệnh
-headless
. - HtmlUnit
- Zoombie.js
- NW.js
- Nightmare (đã dừng phát triển)
- PhantomJS (đã dừng phát triển)
- v.v…
Puppeteer là phần mềm gì?
Puppeteer là một thư viện (high-level library) dành cho Node.js được phát triển bởi các kỹ sư Google từ 2018. Đương nhiên để sử dụng thì ta cần biết JavaScript rồi.
Puppeteer cung cấp các API điều khiển trình duyệt thông qua giao thức DevTools theo cả hai chế độ headless (mặc định) lẫn headful (có GUI) như trình duyệt thông thường.
Puppeteer khi cài đặt có kèm theo trình duyệt Chromium nhưng chỉ có phiên bản cho x86 và x64.
Tuy nhiên, Puppeteer Core cũng điều khiển được các trình duyệt hỗ trợ DevTools có sẵn trên máy như Google Chrome, Microsoft Edge hay thậm chí cả Mozilla Firefox.
Puppeteer vs. Selenium
Sẽ rất khập khiễng khi so sánh Puppeteer và Selenium. Việc lựa chọn hoàn toàn phụ thuộc nhu cầu và quy mô của công việc. Dưới đây là một số so sánh giữa 2 phần mềm:
Puppeteer | Selenium | |
Ra đời | 2018 | 2004 |
IDE | Không có | Selenium IDE |
Trình duyệt | Chrome/Chromium | Hầu hết các trình duyệt phổ biến như Chrome/Chromium, Firefox, Internet Explorer, Edge & Safari (v.v…) |
Ngôn ngữ lập trình | JavaScript (Node.js) | Java, Python, C#, Ruby, JavaScript, Kotlin (v.v…) và ngôn ngữ riêng là Selenese |
Giao tiếp với trình duyệt | Giao thức DevTools | Selenium WebDriver |
Cài đặt | Đơn giản, thông qua npm | Cài WebDriver và library tương ứng với ngôn ngữ lập trình bạn sử dụng |
Tốc độ | Nhanh | Chậm hơn do kiến trúc phức tạp |
Xử lý song song | Không hỗ trợ | Điều khiển nhiều máy tính một lúc thông qua Selenium Grid |
Chụp màn hình | Ảnh hoặc PDF | Chỉ hình ảnh |
Theo tôi thấy đa số các nhu cầu đều có thể giải quyết bằng Puppeteer. Trừ trường hợp bạn muốn test ứng dụng web trên tất cả những trình duyệt phổ biến và OS; Hoặc kiểm soát số lượng test case khổng lồ đòi hỏi phải chạy song song mới kịp tiến độ thì hãy dùng Selenium.
Bộ khung phát triển app Puppeteer
Khi bắt đầu phát triển, tôi thường khởi tạo Puppeteer với bộ khung cơ bản như dưới đây.
- Mở trình duyệt cực đại.
- Hiện cửa sổ DevTools để debug khi cần.
- Bỏ qua các lỗi HTTPS (chủ yếu do chứng chỉ chưa xác thực).
- Điều chỉnh lại kích thước viewport.
- Ngăn tình trạng app dừng khi không có hoạt động sau 30 giây.
- Chủ động hiển thị lỗi page và server nếu có.
'use strict'; const puppeteer = require('puppeteer'); (async () => { const browserOptions = { headless: false, // show the Chromium window devtools: true, // show the DevTools window ignoreHTTPSErrors: true, // skip all HTTPS errors if any (for testing purpose) // Chromium command-line switches args: [ '--start-maximized', // maximize the Chromium window ], }; const browser = await puppeteer.launch(browserOptions); try { // There is at least one tab open for the browser to remain open // so we are going to select it const [page] = await browser.pages() // Adjust the viewport size await page.setViewport({ width: 1280, height: 1080 }) // Avoid 30000ms timeout page.setDefaultNavigationTimeout(0) // Display Page errors page.on('pageerror', error => { console.error(`Page error: "${error.message}"`); }); // Display response error page.on('response', response => { if (!response.ok()) { console.error(`Response error ${response.status}: ${response.url()}`); } }); await page.goto("https://www.google.com/") /* ... Just stand still ... */ while (true) { await page.waitForNavigation(); } } catch (error) { console.error(error); } finally { await browser.close(); } })();
Viewport thay đổi được kích thước khi resize cửa sổ
Mặc định viewport của page có kích thước cố định 800×600 và có thể điều chỉnh được bằng Page.setViewport
. Dù ta thay đổi kích thước cửa sổ trình duyệt cỡ nào thì vùng hiển thị này cũng vẫn không thay đổi.
Để Viewport có thể thay đổi được kích thước giống như trình duyệt bình thường khi resize cửa sổ, ta thiết lập giá trị null
cho defaultViewport
.
const puppeteer = require('puppeteer'); (async () => { const browserOptions = { headless: false, // show the Chromium window defaultViewport: null, // responsive viewport }; const browser = await puppeteer.launch(browserOptions); /* ...Start your code here... */ })();
Chạy Puppeteer với cấu hình tối thiểu
Sau khi phát triển thành công, chúng ta hẳn muốn chạy Puppeteer với hiệu năng tốt nhất.
Ngoài việc bật chế độ chạy headless, ta sẽ truyền thêm những tham số dòng lệnh để tắt bớt những cài đặt (có thể) không cần thiết trong khi chạy.
Lưu ý: nếu app không hoạt động đúng, bạn hãy kiểm tra lại danh sách tham số trong minimalBrowserArgs dưới đây và loại bỏ những tham số gây vấn đề.
Những tham số này có thể thay đổi tùy theo phiên bản Chromium.
const puppeteer = require('puppeteer'); (async () => { // Disable 'unnecessary' settings const minimalBrowserArgs = [ '--autoplay-policy=user-gesture-required', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-breakpad', '--disable-client-side-phishing-detection', '--disable-component-update', '--disable-default-apps', '--disable-dev-shm-usage', '--disable-domain-reliability', '--disable-extensions', '--disable-features=AudioServiceOutOfProcess', '--disable-hang-monitor', '--disable-ipc-flooding-protection', '--disable-notifications', '--disable-offer-store-unmasked-wallet-cards', '--disable-popup-blocking', '--disable-print-preview', '--disable-prompt-on-repost', '--disable-renderer-backgrounding', '--disable-setuid-sandbox', '--disable-speech-api', '--disable-sync', '--hide-scrollbars', '--ignore-gpu-blacklist', '--metrics-recording-only', '--mute-audio', '--no-default-browser-check', '--no-first-run', '--no-pings', '--no-sandbox', '--no-zygote', '--password-store=basic', '--use-gl=swiftshader', '--use-mock-keychain', ]; const browserOptions = { headless: true, // don't show the Chromium window args: minimalBrowserArgs }; const browser = await puppeteer.launch(browserOptions); /* ... your code goes here ... */ })();
Tham khảo ý nghĩa các tham số dòng lệnh của trình duyệt Chromium tại đây.
Giả lập mở website bằng thiết bị di động
Có lúc chúng ta cần phải “lừa” website rằng nó đang được truy cập bằng một thiết bị di động nào đó, cụ thể ở đây là chuỗi User Agent và kích thước màn hình hiển thị. Trình duyệt Chromium có thể làm được việc này giống như khi bạn giả lập thiết bị bằng DevTools khi ấn F12.
Bạn có thể lấy danh sách tên thiết bị đang được Chromium hỗ trợ bằng đoạn mã Node.js nhỏ sau:
const puppeteer = require('puppeteer'); for (const [name, info] of Object.entries(puppeteer.devices)) { // print the device name and screen size console.log(name + ' -- ' + info.viewport.width + 'x' + info.viewport.height + '@' + info.viewport.deviceScaleFactor); }
Bạn sẽ thấy mỗi thiết bị luôn có 2 phiên bản chiều dọc và chiều ngang (suffix bằng “landscape”), ví dụ iPhone 6
và iPhone 6 landscape
.
Đoạn mã sau khởi tạo trình duyệt Chromium giả lập thiết bị iPhone 6 theo chiều dọc.
// Device name const DEVICE_NAME = "iPhone 6"; const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); // Tell Chromium to emulate the given device if (puppeteer.devices.hasOwnProperty(DEVICE_NAME)) { await page.emulate(puppeteer.devices[DEVICE_NAME]); } /* ... your code goes here ... */ })();
Hạn chế những resource không cần thiết
Việc này cũng góp phần cải thiện hiệu năng của app do trình duyệt không phải tải xuống và xử lý các tài nguyên như CSS, JavaScript, hình ảnh, video v.v… không cần thiết giống như cách làm của các Ad Blocker hoặc NoScript extension vậy.
Để cho phép chặn và xử lý từng request, ta dùng API Page.setRequestInterception(true)
và dùng Page.on("request", handler)
để xử lý request.
Đoạn mã dưới đây thực hiện chặn các request thỏa mãn:
- URL nằm trong
BLOCKED_URLS
(Regular Expression matches). - URL có resource type nằm trong danh sách
BLOCKED_TYPES
// DENY requests that match one of the following RegExp patterns const BLOCKED_URLS = [ '\.(png|gif)', 'eclick.vn', 'googletagmanager.com', 'google-analytics.com' ]; // DENY requests that attempt to load one of the following resource types const BLOCKED_TYPES = ['stylesheet', 'video']; const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); try { const page = await browser.newPage(); // !! IMPORTANT !! await page.setRequestInterception(true); page.on('request', request => { // Abort unnecessary requests if (BLOCKED_URLS.some(p => new RegExp(p, 'i').test(request.url())) || BLOCKED_TYPES.includes(request.resourceType())) { console.log('Blocked:', url) request.abort() // Abort the request } else { request.continue() } }); /* ...begin your flow here... */ } catch (error) { console.error(error); } finally { await browser.close(); } })();
Thay đổi thông tin trong request trước khi gửi lên server
Ta cũng có thể thay đổi nội dung của request trước khi nó được gửi lên server.
Tương tự như việc block các request như trên, ta cũng bật request interception và dùng Page.on("request", handler)
để chỉnh sửa request.
Nội dung của request sau khi thay đổi sẽ được truyền vào HTTPRequest.continue()
.
Ta dùng Page.on("response", handler)
để xử lý response.
const puppeteer = require('puppeteer'); // EXAMPLE: Modify the header and POST data of a request const timeZone = -(new Date().getTimezoneOffset()/60) const callbacks = { // The URL must come from example.com urlMatches: url => /example\.com/i.test(url), // Add a new "X-Timezone" header updateHeaders: headers => Object.assign({}, headers, {"X-Timezone": timeZone}), // Add a parameter "tz" to the POST data (query string) updatePostData: data => (q = new URLSearchParams(data), q.append("tz", timeZone), q).toString(), // Dump the JSON response data processResponse: async response => console.log(JSON.stringify(await response.json(), null, 2)) }; (async () => { const browser = await puppeteer.launch(); try { const page = await browser.newPage(); // !! IMPORTANT !! await page.setRequestInterception(true); page.on('request', async request => { const params = {}; if (callbacks.urlMatches(request.url()) { if (request.method() !== 'GET') { params.method = request.method() } if (callbacks.updateHeaders) { params.headers = callbacks .updateHeaders(await request.headers()) } if (callbacks.updatePostData) { params.postData = callbacks .updatePostData(await request.postData()) } } request.continue(params) }); page.on('response', async response => { const request = response.request() if (callbacks.urlMatches(request.url()) && callbacks.processResponse) { await callbacks.processResponse(response) } }); /* ...begin your flow here... */ } catch (error) { console.error(error); } finally { await browser.close(); } })();
Lưu và tải lại cookie
Một số website sử dụng cookie để lưu tình trạng hiện tại, điển hình là Facebook hay các nền tảng WordPress.
Bằng phương pháp dưới đây, ta sẽ chủ động lưu tất cả cookie (cả cookie thường lẫn HttpOnly) vào một file trước khi thoát và load nó trở lại khi khởi động app, trước khi tải website.
Để lấy được HttpOnly cookie thì ta bắt buộc phải dùng
page.cookies()
chứ không thể dùngdocument.cookie
trong khipage.evaluate()
.
Ưu điểm khi chỉ lưu cookie là tiết kiệm không gian lưu trữ (chắc chỉ vài chục KB), nếu so sánh với việc lưu toàn bộ cache của trình duyệt (cách sau).
Ví dụ sau lưu trữ và nạp cookie của Facebook từ file định dạng JSON để không cần đăng nhập trong lần chạy tiếp theo như sau.
Lưu ý bạn vẫn phải đăng nhập bằng tay trong lần đầu, vì thế ta cần tắt chế độ headless đi.
const COOKIE_FILE = './cookies.json'; // path to the cookie file const puppeteer = require('puppeteer'); const fs = require('fs'); (async () => { const browser = await puppeteer.launch({headless:false}); try { const page = await browser.newPage(); // BEGIN: Load cookies from file try { const cookiesString = await fs.promises.readFile(COOKIE_FILE); const cookies = JSON.parse(cookiesString); await page.setCookie(...cookies); console.log('The cookies have been loaded!') } catch { console.error('No cookie loaded!') } // Check whether a given CSS selector exists const hasSelector = (page, selector) => page.evaluate(s => !!document.querySelector(s), selector); /* Open Facebook and manually bypass login form and checkpoints */ await page.goto('https://m.facebook.com/'); while (!await hasSelector(page, '#m-top-of-feed')) { await page.waitForNavigation(); } /* ...Start scraping Facebook here... */ // END: Save cookies to file try { const cookies = await page.cookies() await fs.promises.writeFile(COOKIE_FILE, JSON.stringify(cookies)) console.log('The cookies have been saved!') } catch { console.error('Cookies was failed to save!') } } catch (error) { console.error(error); } finally { await browser.close(); } })();
Lưu lại user data và cache của trình duyệt
Puppeteer không có API đọc ghi dữ liệu trực tiếp vào các Storage và Database TRƯỚC KHI website được tải. Ta chỉ có thể dùng page.evaluate()
để ghi dữ liệu ngược lại mỗi khi trang được tải, tuy nhiên điều này sẽ phá vỡ hoạt động của Website.
Để sử dụng lại những thông tin trên trong lần chạy tiếp theo, ta buộc phải giữ lại toàn bộ User Data của trình duyệt (Mặc định Puppeteer chạy trong sandbox và không lưu dữ liệu này sau khi thoát).
Bạn chỉ cần chỉ định đường dẫn đến thư mục User Data vào trường userDataDir
khi cấu hình browser là xong.
const USER_DATA_DIR = './my_user_data'; const puppeteer = require('puppeteer'); (async () => { const browserOptions = { userDataDir: USER_DATA_DIR, /* other settings */ }; const browser = await puppeteer.launch(browserOptions); /* ... your code goes here ... */ })();
Vấn đề duy nhất của cách làm này là tốn dung lượng lưu trữ, có thể lên đến vài trăm MB, thậm chí vài GB.
Dùng puppeteer-core để điều khiển trình duyệt khác
Thay vì sử dụng package puppeteer
, tôi lại thích dùng puppeteer-core
do không cần phải tải thêm trình duyệt Chromium.
Điều này đặc biệt hợp lý trên các hệ thống chip ARM như Raspberry Pi, do Chromium tải bằng npm i puppeteer
chỉ hỗ trợ cho x86_64 mà thôi.
Sau khi cài đặt puppeteer-core
, ta cần truyền đường dẫn tuyệt đối đến một trình duyệt nào đó có hỗ trợ giao thức DevTools, đặc biệt là các trình duyệt họ Chromium như Google Chrome, Microsoft Edge hay Opera hoặc thậm chí là cả Mozilla Firefox vào tham số executablePath
.
Bộ khung mã nguồn sẽ như sau (lưu ý require
là puppeteer-core
).
const puppeteer = require('puppeteer-core'); (async () => { const browserOptions = { executablePath: '/usr/bin/chromium-browser', // path to the web browser /* ... */ }; const browser = await puppeteer.launch(browserOptions); /* ... your code goes here ... */ })();
Nếu dùng Mozilla Firefox thì cần thêm thông số product
vào nữa.
const puppeteer = require('puppeteer-core'); (async () => { const browserOptions = { product: 'firefox', executablePath: '/usr/bin/firefox', // path to the web browser /* ... */ }; const browser = await puppeteer.launch(browserOptions); /* ... your code goes here ... */ })();
Nếu bạn muốn dùng trình duyệt Google Chrome đang cài trên máy và còn muốn sử dụng cả các Profile sẵn có (bao gồm cả phiên đăng nhập và cache) thì ta chỉ định thêm đường dẫn của Profile như sau (đường dẫn khác nhau tùy theo OS):
const puppeteer = require('puppeteer-core'); const path = require("path"); const fs = require('fs'); const PROFILE_ID = "Profile 1"; // Default, Profile 1, Profile 2, etc. // Detect Google Chrome binary on Windows const CHROME_EXE_PATH = ["LOCALAPPDATA", "PROGRAMFILES", "ProgramFiles(x86)"] .filter(e => e in process.env) .map(e => path.resolve(process.env[e], "Google/Chrome/Application/chrome.exe")) .find(p => fs.existsSync(p)); const USER_DATA_DIR = path.resolve(process.env.LOCALAPPDATA, "Google/Chrome/User Data"); (async () => { // Google Chrome on Windows const browserOptions = { executablePath: CHROME_EXE_PATH, userDataDir: USER_DATA_DIR, args: [ "--profile-directory=" + PROFILE_ID ], /* ... */ }; const browser = await puppeteer.launch(browserOptions); /* ... your code goes here ... */ })();
Đánh lừa các website có khả năng phát hiện bot
Một số website như Google hay TikTok có cơ chế phát hiện chúng ta có đang sử dụng bot hay không và sẽ tìm cách ngăn chặn việc lấy dữ liệu từ họ bằng captcha.
Để vượt qua điều đó, có một số thủ thuật như:
- Dùng proxy hoặc VPN để thay đổi địa chỉ IP.
- Thay đổi chuỗi User Agent và một số HTTP header.
- Giảm tính quy luật trên các tương tác (ví dụ delay hoặc cuộn trang ngẫu nhiên).
Ngoài ra với Puppeteer ta có thể sử dụng plugin Puppeteer Stealth trong gói Puppeteer Extra. Để cài đặt ta dùng npm
như sau:
npm install puppeteer-extra puppeteer-extra-plugin-stealth
Tôi khuyến nghị sử dụng trình duyệt Google Chrome cài đặt trên máy tính thay vì trình duyệt built-in Chromium, viewport có kích thước tự nhiên.
Sau đó ta chỉnh sửa đôi chút đoạn mã khởi tạo Puppeteer, thay vì dùng puppeteer
thì ta dùng puppeteer-extra
:
const path = require('path'); const fs = require('fs'); const puppeteer = require('puppeteer-extra'); const StealthPlugin = require('puppeteer-extra-plugin-stealth'); puppeteer.use(StealthPlugin()); // Detect Google Chrome binary on Windows const CHROME_EXECUTABLE_PATH = ["LOCALAPPDATA", "PROGRAMFILES", "ProgramFiles(x86)"] .filter(e => e in process.env) .map(e => path.resolve(process.env[e], "Google/Chrome/Application/chrome.exe")) .find(p => fs.existsSync(p)); (async () => { const browser = await puppeteer.launch({ executablePath: CHROME_EXECUTABLE_PATH, headless: false, defaultViewport: null }); /* ... your code goes here ... */ })();
Tất nhiên Puppeteer Stealth không hoàn hảo. Ngoài kia vẫn có những website có khả năng phát hiện bot mạnh mẽ khiến ta phải đầu hàng tạm thời.