Bạn viết xong một crawler bằng requests và BeautifulSoup, chạy thử và nhận về trang HTML gần như rỗng: không có sản phẩm, không có giá, chỉ có vài thẻ script. Nội dung thật được trình duyệt dựng sau đó bằng JavaScript, nên thứ ta tải về không phải thứ người dùng nhìn thấy. Đây là trường hợp Playwright sinh ra để giải quyết: điều khiển một trình duyệt thật (Chromium, Firefox hoặc WebKit) từ Python, chờ trang dựng xong rồi mới đọc dữ liệu. Bài này giải thích khi nào thực sự cần Playwright, cách chờ đúng, duyệt phân trang và cuộn vô hạn, chặn tài nguyên để giảm tải, và chạy nhiều trang song song bằng async API, với số liệu đo thật trên máy.

Bài nối tiếp Crawl bất đồng bộ với asyncio và httpx: giới hạn đồng thời đúng cách, Scrapy từ A tới Z và Crawl data bằng Python: requests, BeautifulSoup và robots.txt. Nguyên tắc giới hạn đồng thời và timeout ở bài asyncio được dùng lại ở đây.

Môi trường và trang thử nghiệm

Code nằm trong code/s7-05, chạy trên Apple M4, 16 GB RAM, Python 3.10 (dòng ổn định mới nhất thời điểm bài đăng; code không dùng tính năng riêng của 3.10, nên từ 3.8 trở lên đều chạy) với playwright==1.22.0, bản phát hành giữa tháng 5/2022, đi kèm Chromium 102.0.5005.40 (xem requirements.txt, đã kiểm chứng cài được trong venv sạch bằng pip install -r requirements.txt; bản này kéo theo greenlet==1.1.2).

Cài Playwright gồm hai bước: pip install -r requirements.txt (hoặc pip install playwright==1.22.0) rồi playwright install chromium để tải trình duyệt (khoảng 330 MB, nằm trong thư mục cache của người dùng, không nằm trong venv). Mọi ví dụ chỉ chạy trên các trang được tạo ra để luyện crawl: quotes.toscrape.com (có sẵn các biến thể /js/, /js-delayed/, /scroll) và books.toscrape.com. Cả hai không có robots.txt (đường dẫn trả 404), nhưng ta vẫn khai báo User-Agent và giãn cách giữa các trang.

Khi nào cần Playwright, khi nào không

Trình duyệt thật là công cụ nặng: tốn RAM, CPU và khó mở rộng. Trước khi dùng, hãy kiểm tra xem dữ liệu có sẵn ở nơi rẻ hơn hay không. Thí nghiệm đầu tiên đặt hai cách cạnh nhau trên cùng một URL quotes.toscrape.com/js/ (demo1_static_vs_js.py):

httpx: HTTP 200, 5806 byte, div.quote trong HTML thô = 0
httpx: chuỗi 'var data' có trong HTML (dữ liệu nằm trong thẻ script) = True
playwright: goto xong sau 2.33s, Chromium 102.0.5005.40
playwright: div.quote trong DOM sau khi chạy JS = 10
  câu đầu: “The world as we have created it is a process of our thinkin ...
  tác giả: Albert Einstein
  thẻ: ['change', 'deep-thoughts', 'thinking', 'world']
khởi động Chromium mất 0.24s

HTML thô có 0 phần tử div.quote, còn DOM sau khi trình duyệt chạy JavaScript có 10. Nhưng dòng thứ hai cho thấy một điều quan trọng: dữ liệu vẫn nằm trong HTML thô, trong biến var data của một thẻ script. Trường hợp này ta có thể lấy bằng regex và json.loads mà không cần trình duyệt. Thứ tự ưu tiên nên là:

  1. Tìm API hoặc JSON nhúng sẵn. Mở tab Network của DevTools, lọc XHR/Fetch, xem trang gọi endpoint nào. Gọi thẳng endpoint đó bằng httpx thường nhanh hơn trình duyệt hàng chục lần và ổn định hơn. Nhớ rằng API không công khai vẫn có thể có điều khoản cấm sử dụng.
  2. Phân tích dữ liệu nhúng trong thẻ script (JSON-LD, __NEXT_DATA__, biến toàn cục).
  3. Dùng Playwright khi dữ liệu chỉ có sau khi chạy JavaScript phức tạp, cần tương tác (nhấp, điền form, đăng nhập hợp lệ), hoặc cần chụp ảnh màn hình/PDF.

Mô hình Browser, Context, Page

Theo tài liệu Playwright cho Python, thư viện cung cấp hai kiểu API: playwright.sync_api đơn giản, hợp với script, và playwright.async_api dùng async/await cho dự án xây trên asyncio. Cấu trúc đối tượng gồm ba lớp:

  • Browser: một tiến trình trình duyệt, tạo bằng p.chromium.launch(). Mặc định chạy headless (không cửa sổ). Khởi động tốn vài trăm mili giây, nên tạo một lần rồi dùng lại.
  • BrowserContext: một "hồ sơ" ẩn danh độc lập, có cookie, bộ nhớ cục bộ, User-Agent, ngôn ngữ riêng. Tạo context rất rẻ, là đơn vị cách ly thích hợp cho từng phiên crawl.
  • Page: một tab. Một context có nhiều page.

Hàm khởi đầu tối thiểu, tương ứng với phần đầu của demo1:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(user_agent="SudoBlogDemoCrawler/1.0 (+https://sudo.vn/blog; hoc-tap)")
    page = context.new_page()
    page.goto("https://quotes.toscrape.com/js/")
    print(page.locator("div.quote").count())   # 10
    browser.close()

Khối with bảo đảm tiến trình trình duyệt được dọn sạch khi thoát, kể cả khi có lỗi. Nếu không dùng with, hãy luôn try/finally với browser.close() để không để lại tiến trình Chromium mồ côi.

Chờ đúng cách với Playwright: auto-waiting và wait_for_selector

Lỗi số một của người mới dùng trình duyệt tự động là đọc dữ liệu quá sớm hoặc time.sleep(5) cho chắc. Cả hai đều sai: sleep quá ngắn thì thiếu dữ liệu, quá dài thì lãng phí. Trang /js-delayed/ cố ý trễ 10 giây trước khi dựng danh sách, rất hợp để quan sát (demo2_waiting.py):

ngay sau goto: div.quote = 0
chờ 3s: TimeoutError sau 3.0s -> Timeout 3000ms exceeded.
chờ thêm: có dữ liệu sau 6.7s, div.quote = 10
locator.inner_text tự chờ 11.8s: “The world as we have created it is a pr ...

goto trả về khi sự kiện load xảy ra, nhưng JavaScript của trang vẫn chưa dựng xong nên đếm được 0 phần tử. Có hai cách chờ đúng:

  • page.wait_for_selector("div.quote", timeout=...) chờ tới khi phần tử xuất hiện, hết hạn thì ném TimeoutError. Ở thí nghiệm, lần chờ 3 giây thất bại đúng 3,0 giây, lần chờ tiếp thành công sau 6,7 giây, tổng cộng gần 10 giây đúng với độ trễ của trang.
  • Locator tự chờ. Theo tài liệu về actionability, Playwright "auto-waits" mọi kiểm tra cần thiết trước khi thực hiện hành động, và nếu hết hạn thì hành động thất bại với TimeoutError. Ví dụ click() chờ phần tử hiển thị, ổn định (không còn di chuyển), nhận được sự kiện (không bị lớp phủ che) và được bật. Lời gọi inner_text() cũng tự chờ phần tử xuất hiện, nên lần chạy cuối mất 12,1 giây gồm khởi động và trễ 10 giây của trang.

Lưu ý rằng locator.count() không chờ: nó trả về số phần tử tại thời điểm gọi, nên ở ví dụ đầu mới ra 0. Nguyên tắc: trước khi đếm hoặc lặp, hãy chờ một dấu hiệu rõ ràng cho thấy dữ liệu đã sẵn sàng. Tránh wait_until="networkidle" làm giải pháp mặc định: nó chờ mạng yên lặng, mà nhiều trang có analytics hay websocket chạy liên tục khiến điều kiện không bao giờ đạt.

Thiết lập timeout mặc định của page bằng page.set_default_timeout(15000) (mili giây) để mọi thao tác cùng áp dụng. Giá trị mặc định của Playwright là 30 giây, quá dài cho crawler: một trang treo sẽ giữ cả slot trong 30 giây.

Duyệt phân trang bằng nút Next

Với phân trang kiểu nhấn nút, vòng lặp chuẩn là: chờ dữ liệu, đọc, kiểm tra còn nút "Next" không, nhấn và chờ điều hướng. Đây là demo3_paginate.py thu 10 trang của /js/, giãn cách 0,5 giây giữa các trang:

page.goto(START)
while True:
    page.wait_for_selector("div.quote")
    quotes = page.locator("div.quote")
    for i in range(quotes.count()):      # Locator.all() chưa có ở bản 1.22
        q = quotes.nth(i)
        rows.append({
            "text": q.locator("span.text").inner_text(),
            "author": q.locator("small.author").inner_text(),
            "tags": q.locator("a.tag").all_inner_texts(),
        })
    nxt = page.locator("li.next a")
    if nxt.count() == 0:
        break
    time.sleep(0.5)                      # giãn cách lịch sự
    with page.expect_navigation():
        nxt.click()
trang 1: https://quotes.toscrape.com/js/ -> 10 câu
trang 2: https://quotes.toscrape.com/js/page/2/ -> 20 câu
trang 3: https://quotes.toscrape.com/js/page/3/ -> 30 câu
...
trang 10: https://quotes.toscrape.com/js/page/10/ -> 100 câu
xong: 10 trang, 100 câu, 50 tác giả, 12.4s

Hai chi tiết nên chú ý. Thứ nhất, with page.expect_navigation(): bao quanh lệnh nhấn để không bỏ lỡ điều hướng nếu nó diễn ra quá nhanh; đó là mẫu "đặt người nghe trước khi bấm" của Playwright. Thứ hai, phiên bản 1.22 chưa có Locator.all() (tôi gặp AttributeError khi chạy lần đầu), nên ta lặp bằng count() và nth(). Vì thế luôn ghi rõ phiên bản thư viện.

Về tốc độ: 12,4 giây cho 10 trang, tức khoảng 1,2 giây mỗi trang gồm cả giãn cách, so với cách gọi API trực tiếp ở phần sau. Đó là cái giá của trình duyệt.

Cuộn vô hạn: nghe mạng thay vì đọc DOM

Trang /scroll chỉ nạp thêm 10 câu mỗi khi người dùng cuộn tới đáy. Có hai cách thu thập. Cách thứ nhất là điều khiển trình duyệt cuộn và đếm phần tử. Cách thứ hai, hiệu quả hơn, là lắng nghe các response mạng bằng page.on("response", ...), như tài liệu Network mô tả. Đoạn mã dưới vừa cuộn vừa ghi lại các lời gọi tới /api/quotes (demo4_scroll_network.py):

def on_response(resp):
    if "/api/quotes" in resp.url:
        data = resp.json()
        captured.append((resp.url, resp.status, len(data["quotes"]), data["has_next"]))
page.on("response", on_response)

page.goto("https://quotes.toscrape.com/scroll")
page.wait_for_selector("div.quote")
for _ in range(15):
    before = page.locator("div.quote").count()
    page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
    try:
        page.wait_for_function("n => document.querySelectorAll('div.quote').length > n",
                               arg=before, timeout=2500)
    except PWTimeout:      # hết hạn mà không có thêm quote: đã tới cuối
        break

Kết quả chạy thật:

DOM cuối cùng: div.quote = 100
bắt được 15 response XHR; có dữ liệu: 10, rỗng: 5
  XHR 200 /api/quotes?page=9 -> 10 câu, has_next=True
  XHR 200 /api/quotes?page=10 -> 10 câu, has_next=False
  XHR 200 /api/quotes?page=11 -> 0 câu, has_next=False
httpx gọi thẳng API: 10 trang, 100 câu, 3.73s

Có ba bài học trong những dòng này. Một, DOM cuối có đủ 100 câu và các response bắt được cho ta dữ liệu JSON sạch, không cần parse HTML. Hai, ngoài 10 response có dữ liệu còn có một số response rỗng (5 lượt chạy này, 8 lượt chạy trước đó, nên số lượng không cố định). Tôi không đọc mã script của trang nên chỉ nêu quan sát: sau trang 10 vẫn còn yêu cầu page=11 trả về 0 câu với has_next=False; nhiều khả năng sự kiện cuộn bắn thêm khi response trước chưa kịp cập nhật trạng thái. Crawler đọc DOM bỏ qua chuyện này, nhưng nếu ta tự lặp theo số lần cuộn thì phải coi response rỗng là tín hiệu dừng.

Ba, điều cần nhất: khi đã biết endpoint, gọi thẳng bằng httpx cho cùng 100 câu trong 3,73 giây mà không cần Chromium. Ở đây thời gian chủ yếu là 10 lần gọi tuần tự; kết hợp với bài về asyncio còn nhanh hơn nữa. Playwright là bước khám phá, httpx là bước khai thác.

Một chi tiết kỹ thuật đáng nhớ: trang này chỉ nạp thêm khi vị trí cuộn chạm chính xác đáy tài liệu (độ lệch không quá 1 pixel). Ban đầu tôi dùng document.body.scrollHeight và mouse.wheel, kết quả chỉ nạp được trang đầu. Đổi sang document.documentElement.scrollHeight mới chạm đúng đáy. Đây là loại chi tiết chỉ lộ ra khi chạy thật.

Chặn tài nguyên trong Playwright để giảm tải

Khi chỉ cần chữ, việc tải ảnh, CSS và font là lãng phí băng thông và thời gian. Playwright cho phép chặn bằng page.route: tài liệu mô tả handler nhận một đối tượng route, có thể abort(), continue_() hoặc fulfill(). Ta chặn theo loại tài nguyên (resource_type) và đo trên trang danh mục books.toscrape.com (20 sách kèm ảnh bìa), mỗi cấu hình chạy hai lượt (demo5_block_resources.py):

BLOCK = {"image", "stylesheet", "font", "media"}
page.route("**/*", lambda r: (r.abort() if r.request.resource_type in BLOCK else r.continue_()))
chặn tài nguyên=False thời gian= 2.51s  request=31  bị huỷ= 1  tải về=  223.8 KB  sách đọc được=20
chặn tài nguyên=True  thời gian= 1.54s  request=33  bị huỷ=27  tải về=   66.0 KB  sách đọc được=20
chặn tài nguyên=False thời gian= 4.32s  request=31  bị huỷ= 1  tải về=  296.5 KB  sách đọc được=20
chặn tài nguyên=True  thời gian= 1.87s  request=30  bị huỷ=24  tải về=   57.5 KB  sách đọc được=20

Dung lượng tải giảm rõ rệt (223,8 xuống 66,0 KB ở lượt đầu, 296,5 xuống 57,5 KB ở lượt hai, tức giảm khoảng 70 đến 80%), thời gian giảm từ 39% đến 57%.

Cột "tải về" thay đổi giữa hai lượt vì cache trình duyệt và trạng thái mạng, nên nên đọc chúng như thứ tự độ lớn, không phải hằng số. Mức giảm chưa lớn vì trang thử nghiệm nhỏ; với các trang thương mại điện tử nặng hàng MB ảnh và script quảng cáo, lợi ích thường lớn hơn nhiều. Đổi lại, có hai rủi ro: một số trang kiểm tra việc ảnh hoặc CSS có tải được không để nhận diện bot, và một số layout cần CSS để phần tử có kích thước, khiến wait_for_selector mặc định (chờ hiển thị) thất bại. Hãy chặn từng loại một và kiểm tra lại kết quả.

Lưu ý cách đo: lần đầu tôi cộng len(resp.body()) và nhận số liệu vô lý (chặn mà lại tải nhiều hơn, vì body() không phản ánh lượng byte thực qua mạng). Cách đúng là cộng request.sizes()["responseBodySize"] khi sự kiện requestfinished xảy ra, tức lượng byte thực sự nhận từ mạng.

Chạy nhiều trang Playwright song song bằng async API

Một Browser có thể mở nhiều Page cùng lúc, và vì Playwright là I/O-bound ở phía Python (phần nặng nằm trong tiến trình trình duyệt), asyncio phát huy tốt. Ta dùng chính mẫu Semaphore của bài trước, nhưng lần này giới hạn số tab mở đồng thời (demo6_async_pages.py):

async def scrape(context, n, sem):
    async with sem:
        page = await context.new_page()
        try:
            await page.goto(f"https://quotes.toscrape.com/js/page/{n}/")
            await page.wait_for_selector("div.quote")
            return await page.locator("div.quote span.text").count()
        finally:
            await page.close()

async with async_playwright() as p:
    browser = await p.chromium.launch()
    context = await browser.new_context()
    sem = asyncio.Semaphore(limit)
    counts = await asyncio.gather(*(scrape(context, n, sem) for n in range(1, 11)))
song song tối đa  1 page: 100 câu,  8.07s
song song tối đa  3 page: 100 câu,  3.93s
song song tối đa  5 page: 100 câu,  4.00s

Từ 1 lên 3 tab, thời gian giảm gần một nửa (8,07 xuống 3,93 giây), nhưng từ 3 lên 5 chỉ giảm thêm chút ít (4,00 giây). Mỗi tab Chromium tiêu tốn CPU và RAM đáng kể, nên lợi ích giảm dần rất nhanh. Khác với httpx có thể mở hàng trăm kết nối, với trình duyệt con số hợp lý thường chỉ là 2 đến 8 tab mỗi tiến trình. Muốn mở rộng hơn, hãy chạy nhiều tiến trình hoặc nhiều máy, mỗi nơi một Browser. Điều bắt buộc ở mọi cấu hình Playwright: đóng page trong finally. Quên page.close() làm RAM tăng dần theo số trang đã thu.

Crawl có trách nhiệm với trình duyệt tự động

Trình duyệt thật làm nhiều hơn một lời gọi HTTP: mỗi trang kéo theo hàng chục request phụ (script, ảnh, quảng cáo, theo dõi). Vì vậy trách nhiệm của người crawl lớn hơn, không nhỏ hơn:

  • Kiểm tra điều khoản và robots.txt trước khi chạy. Nhiều trang cấm tự động hoá trong điều khoản sử dụng, dù kỹ thuật làm được. Ví dụ trong bài chỉ dùng trang luyện tập.
  • Khai báo danh tính thật qua User-Agent có thông tin liên hệ. Playwright không nhằm để giả dạng người dùng, và bài này không hướng dẫn né cơ chế chống bot: nếu một trang chặn tự động hoá, hãy xin phép hoặc dùng API chính thức.
  • Giãn cách và giới hạn đồng thời như bài trước, ở mức thấp hơn vì mỗi trang nặng hơn.
  • Không đăng nhập thay người khác hoặc thu dữ liệu cá nhân. Nếu cần đăng nhập, chỉ dùng tài khoản của chính mình và theo phạm vi được phép; không lưu mật khẩu trong code, đọc từ biến môi trường.
  • Chặn tài nguyên không cần thiết (quảng cáo, ảnh) là cách giảm tải cho cả hai phía.

Khi nào không nên dùng Playwright

  • Dữ liệu có sẵn trong HTML hoặc JSON nhúng: như ví dụ đầu bài, httpx cộng parse JSON là đủ, nhanh hơn rất nhiều.
  • Có API công khai, ổn định: dùng API, đừng điều khiển giao diện.
  • Khối lượng lớn (hàng trăm nghìn trang): chi phí hạ tầng của trình duyệt sẽ rất đắt; tìm endpoint dữ liệu hoặc xem xét thoả thuận dữ liệu với bên cung cấp.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
Đếm phần tử ra 0 dù trang có dữ liệucount() không chờ, trang chưa dựng xongwait_for_selector trước, hoặc dùng locator có auto-waiting
TimeoutError rải rácTimeout mặc định 30 giây quá dài hoặc mạng chậm, trang đổi cấu trúcĐặt set_default_timeout, kiểm tra selector, retry có giới hạn
RAM tăng liên tụcKhông đóng page hoặc browser; quá nhiều tabtry/finally với close(), giới hạn số tab bằng Semaphore
AttributeError với một API mớiPhiên bản Playwright cũ hơn tài liệu bạn đang đọcGhim phiên bản trong requirements.txt, đọc tài liệu đúng bản
Cuộn vô hạn không nạp thêmTrang kiểm tra vị trí cuộn chính xác hoặc dùng sự kiện khácCuộn tới documentElement.scrollHeight; ưu tiên gọi thẳng API
Lỗi playwright install hoặc không tìm thấy trình duyệtChưa tải trình duyệt hoặc khác phiên bản thư việnChạy lại playwright install chromium sau khi nâng thư viện

Checklist cho crawler Playwright

  • Đã thử tìm API hoặc JSON nhúng trước khi dùng Playwright.
  • Một Browser, mỗi phiên một Context, mỗi tác vụ một Page, đóng trong finally.
  • Chờ bằng điều kiện (selector, hàm), không dùng sleep cố định; đặt timeout mặc định hợp lý cho Playwright.
  • Đăng ký người nghe (expect_navigation, expect_response) trước khi nhấn.
  • Giới hạn số tab bằng Semaphore, giãn cách giữa các trang.
  • Chặn ảnh, font, quảng cáo nếu chỉ cần chữ, rồi kiểm tra lại kết quả.
  • Ghim phiên bản thư viện và trình duyệt; khai báo User-Agent rõ ràng; tuân thủ điều khoản.

Bài tiếp theo

Crawl nhanh và chạy được JavaScript chưa đủ, một crawler tốt còn phải lịch sự và hợp lệ. Bài kế tiếp: Crawler lịch sự và hợp lệ: rate limit, cache, ETag và sitemap.