Gần như dự án dữ liệu nào cũng có lúc cần số liệu mà nguồn không cung cấp API: bảng giá của đối thủ, danh mục sản phẩm công khai, bảng tỷ giá. Khi đó crawl data là cách lấy dữ liệu từ trang web về dạng có cấu trúc để phân tích. Bài này xây một chương trình crawl data nhỏ nhưng đúng quy trình bằng Python: dùng requests để tải trang, BeautifulSoup để bóc tách HTML, urllib.robotparser để đọc robots.txt, rồi thêm timeout, retry có backoff và giới hạn tốc độ. Toàn bộ code đã chạy thật trên Python 3.10 (môi trường dựng theo bài trước), requests 2.27.1, beautifulsoup4 4.10.0 và lxml 4.7.1, máy Apple M4 16 GB. Đích crawl là các trang cho phép thực hành công khai: books.toscrape.com và httpbin.org.

Crawl data hợp lệ: bắt đầu từ câu hỏi "có được phép không"

Trước khi viết dòng code crawl data đầu tiên, hãy trả lời bốn câu hỏi, vì chúng quyết định dự án của bạn hợp lệ hay không chứ không phải thư viện nào được chọn:

  1. Có API chính thức hay file dữ liệu mở không? Nếu có, dùng nó. API ổn định, nhẹ cho cả hai phía và có điều khoản rõ ràng.
  2. Điều khoản sử dụng của trang có cấm crawl data tự động không? Điều khoản là ràng buộc pháp lý, khác với robots.txt vốn chỉ là quy ước kỹ thuật.
  3. robots.txt cho phép đường dẫn bạn cần không? Tệp này nằm ở gốc tên miền và cho biết chủ trang mong muốn bot đi đâu, không đi đâu.
  4. Dữ liệu có chứa thông tin cá nhân không? Tên, số điện thoại, email của người dùng thật chịu ràng buộc của luật bảo vệ dữ liệu cá nhân; đừng thu thập nếu không có cơ sở pháp lý.

Cũng cần nói rõ: robots.txt không phải cơ chế bảo mật. Nó chỉ là lời đề nghị mà bot tử tế tự giác tuân theo; tài liệu của robotstxt.org mô tả đúng bản chất này. Dữ liệu cần bảo vệ phải được bảo vệ bằng xác thực, không phải bằng robots.txt. Vì thế bài này chỉ crawl data trên các site dựng sẵn để thực hành, và bạn nên áp dụng cùng chuẩn mực với site thật: nhận diện rõ bot qua User-Agent, đi chậm, dừng khi bị yêu cầu.

Cài đặt môi trường để crawl data

Theo bài Môi trường Python cho dự án dữ liệu và AI, mỗi dự án có một venv riêng và file ghim phiên bản. Với dự án crawl data, requirements.txt gồm các gói sau (đã pip freeze từ môi trường chạy thật):

beautifulsoup4==4.10.0
certifi==2021.10.8
charset-normalizer==2.0.10
html5lib==1.1
idna==3.3
lxml==4.7.1
requests==2.27.1
six==1.16.0
soupsieve==2.3.1
urllib3==1.26.8
webencodings==0.5.1

Hai lựa chọn thư viện đáng giải thích. requests là client HTTP đồng bộ quen thuộc, có Session để tái sử dụng kết nối. BeautifulSoup chỉ là lớp "bóc" HTML; bản thân nó cần một parser phía dưới. Tài liệu Beautiful Soup 4 so sánh ba parser: html.parser có sẵn trong thư viện chuẩn, tốc độ khá; lxml rất nhanh nhưng cần thư viện C; html5lib chậm nhưng parse giống trình duyệt nhất. Tài liệu cũng cảnh báo nên luôn chỉ định parser, vì với HTML sai cú pháp, mỗi parser sinh một cây khác nhau.

Đọc robots.txt trước khi crawl data

Để crawl data đúng quy tắc, thư viện chuẩn có sẵn urllib.robotparser với lớp RobotFileParser: can_fetch(useragent, url) trả lời một URL có được phép hay không, còn crawl_delay() và request_rate() (từ Python 3.6) và site_maps() (từ 3.8) đọc các chỉ thị mở rộng. Hàm read() của lớp này tự tải file nhưng không cho đặt timeout và User-Agent, nên tôi tải bằng requests rồi đưa vào parse():

"""Kiểm tra robots.txt bằng urllib.robotparser trước khi crawl."""
import urllib.robotparser

import requests

UA = "SudoBlogBot/1.0 (+https://sudo.vn; bai-viet-huong-dan)"


def load_robots(base: str) -> urllib.robotparser.RobotFileParser:
    """Tải robots.txt bằng requests (có timeout, có User-Agent) rồi đưa cho parser."""
    rp = urllib.robotparser.RobotFileParser()
    r = requests.get(base + "/robots.txt", headers={"User-Agent": UA}, timeout=10)
    print(f"{base}/robots.txt -> HTTP {r.status_code}")
    if r.status_code == 200:
        rp.parse(r.text.splitlines())
    elif r.status_code in (401, 403):
        rp.disallow_all = True      # 401/403: bị cấm truy cập robots.txt thì coi như cấm hết (cách RobotFileParser.read() xử lý)
    elif 400 <= r.status_code < 500:
        rp.allow_all = True         # các lỗi 4xx khác (vd. 404): site không có quy tắc, được phép (cách RobotFileParser.read() xử lý)
    else:
        rp.disallow_all = True      # 5xx hoặc mã lạ: chưa xác định được quy tắc, thận trọng tạm dừng thay vì crawl
    return rp


if __name__ == "__main__":
    for base, paths in [
        ("https://httpbin.org", ["/get", "/deny", "/deny/anything"]),
        ("https://books.toscrape.com", ["/catalogue/page-1.html"]),
    ]:
        rp = load_robots(base)
        for p in paths:
            print(f"  can_fetch({p!r}) = {rp.can_fetch(UA, base + p)}")
        print("  crawl_delay =", rp.crawl_delay(UA))

Điểm tinh tế nằm ở xử lý mã trạng thái, giống cách RobotFileParser.read() làm: 401 hoặc 403 nghĩa là bạn bị cấm đọc cả robots.txt nên coi như cấm hết; các lỗi 4xx khác như 404 nghĩa là site không có quy tắc nên được phép. Phần code trong bài đi thêm một bước thận trọng so với thư viện chuẩn: với 5xx (server đang lỗi) hay mã lạ, ta chưa biết site muốn gì nên tạm dừng thay vì crawl. Chạy thử với hai site:

$ python robots_demo.py
https://httpbin.org/robots.txt -> HTTP 200
  can_fetch('/get') = True
  can_fetch('/deny') = False
  can_fetch('/deny/anything') = False
  crawl_delay = None
https://books.toscrape.com/robots.txt -> HTTP 404
  can_fetch('/catalogue/page-1.html') = True
  crawl_delay = None

Trang httpbin.org khai Disallow: /deny nên /deny và mọi đường dẫn con bị chặn, còn /get thì được. Trang books.toscrape.com trả 404 cho robots.txt, tức không giới hạn. Không site nào khai Crawl-delay, nhưng điều đó không có nghĩa bạn được tải dồn dập: hãy tự đặt giới hạn hợp lý (xem phần giới hạn tốc độ bên dưới).

Tải trang bằng requests để crawl data: session, timeout và retry

Ba thiết lập sau biến một đoạn requests.get() thử nghiệm thành code crawl data chạy được lâu dài. Thứ nhất, luôn đặt timeout. Tài liệu Advanced Usage của requests nói thẳng: mặc định requests không timeout, nên chương trình có thể treo hàng phút. Timeout là tuple (connect, read): connect là thời gian chờ thiết lập kết nối, read là thời gian chờ giữa các byte server gửi về. Thứ hai, dùng Session để tái sử dụng kết nối TCP (connection pooling) và đặt sẵn header cho mọi request. Thứ ba, retry có backoff cho lỗi tạm thời, vì mặc định requests không tự retry.

Đoạn code dưới đây thử hai cơ chế đầu bằng httpbin.org, chỗ dành sẵn cho việc kiểm thử HTTP, và so sánh ba parser trên cùng một HTML hỏng:

"""Timeout, retry có backoff và khác biệt giữa các parser HTML (httpbin.org + HTML cục bộ)."""
import logging
import time

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(format="  [urllib3] %(message)s", level=logging.WARNING)
logging.getLogger("urllib3.util.retry").setLevel(logging.DEBUG)

print("1) Timeout: /delay/3 với timeout đọc 1 giây")
t0 = time.perf_counter()
try:
    requests.get("https://httpbin.org/delay/3", timeout=(3.05, 1))
except requests.exceptions.ReadTimeout as e:
    print(f"  ReadTimeout sau {time.perf_counter()-t0:.1f}s")

print("2) Retry: /status/503 với total=2, backoff_factor=0.5")
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=Retry(total=2, backoff_factor=0.5, status_forcelist=[503])))
t0 = time.perf_counter()
try:
    s.get("https://httpbin.org/status/503", timeout=10)
except requests.exceptions.RetryError:
    print(f"  RetryError sau {time.perf_counter()-t0:.1f}s (đã thử 3 lần: 1 lần đầu + 2 lần retry)")

print("3) Cùng một HTML hỏng, ba parser cho ba cây khác nhau")
broken = "<ul><li>Sách A<li>Sách B</ul><p>Đoạn 1<p>Đoạn 2"
for parser in ("html.parser", "lxml", "html5lib"):
    try:
        print(f"  {parser:<11}:", BeautifulSoup(broken, parser))
    except Exception as e:
        print(f"  {parser:<11}: không dùng được ({type(e).__name__})")
$ python resilience_demo.py
1) Timeout: /delay/3 với timeout đọc 1 giây
  ReadTimeout sau 1.6s
2) Retry: /status/503 với total=2, backoff_factor=0.5
  [urllib3] Incremented Retry for (url='/status/503'): Retry(total=1, connect=None, read=None, redirect=None, status=None)
  [urllib3] Incremented Retry for (url='/status/503'): Retry(total=0, connect=None, read=None, redirect=None, status=None)
  RetryError sau 2.5s (đã thử 3 lần: 1 lần đầu + 2 lần retry)
3) Cùng một HTML hỏng, ba parser cho ba cây khác nhau
  html.parser: <ul><li>Sách A<li>Sách B</li></li></ul><p>Đoạn 1<p>Đoạn 2</p></p>
  lxml       : <html><body><ul><li>Sách A</li><li>Sách B</li></ul><p>Đoạn 1</p><p>Đoạn 2</p></body></html>
  html5lib   : <html><head></head><body><ul><li>Sách A</li><li>Sách B</li></ul><p>Đoạn 1</p><p>Đoạn 2</p></body></html>

Có vài điều rút ra. Với /delay/3 và timeout đọc 1 giây, ReadTimeout nổ sau khoảng 1,6 giây: tức là 1 giây chờ cộng thời gian bắt tay TLS và kết nối, đúng như tài liệu ghi chú rằng timeout không phải thời gian đồng hồ tường. Với retry, lần thử đầu tiên và lần retry đầu không chờ, lần retry thứ hai chờ backoff_factor × 2^(n-1) = 1 giây theo công thức trong mã nguồn urllib3 1.26.8, cộng thời gian mạng ra 2,5 giây. Khi hết lượt, requests ném RetryError.

Khi crawl data qua nhiều request, lớp Retry còn tôn trọng header Retry-After của server với các mã 413, 429 và 503 (urllib3 1.26 chỉ áp dụng cho ba mã này), nên khi site yêu cầu chờ 30 giây, bạn sẽ chờ đúng 30 giây thay vì tự đoán. Ví dụ /status/503 ở trên của httpbin không gửi header này nên chưa minh hoạ được điều đó.

Phần cuối của output cho thấy lý do phải chỉ định parser. Khi crawl data, với HTML <li>Sách A<li>Sách B thiếu thẻ đóng, html.parser lồng Sách B vào bên trong Sách A, trong khi lxml và html5lib tách thành hai mục ngang hàng như trình duyệt. Nếu code crawl data của bạn giả định cấu trúc phẳng, đổi parser có thể làm kết quả thay đổi mà không có lỗi nào được báo.

Bóc tách HTML khi crawl data bằng BeautifulSoup và CSS selector

Trang danh mục của books.toscrape.com bọc mỗi cuốn sách trong thẻ <article class="product_pod">; bên trong có tiêu đề (thuộc tính title của thẻ a), giá (.price_color), xếp hạng sao (thẻ p có class star-rating Three) và tình trạng kho. Với BeautifulSoup, select() và select_one() nhận CSS selector, thường gọn và dễ đọc hơn chuỗi find_all() lồng nhau. Thuộc tính đọc như khoá từ điển: tag["href"] báo KeyError nếu thiếu, còn tag.get("href") trả None; thuộc tính nhiều giá trị như class được trả về dạng danh sách. Tôi dùng dataclass để có cấu trúc rõ ràng và Decimal cho giá để tránh sai số số thực:

"""Crawl có trách nhiệm books.toscrape.com (trang dành riêng cho thực hành crawl).

Tuân thủ robots.txt, giới hạn tốc độ, timeout, retry có backoff, dừng sau N trang.
"""
import csv
import sys
import time
from dataclasses import dataclass, asdict
from decimal import Decimal
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from robots_demo import UA, load_robots

BASE = "https://books.toscrape.com"
START = BASE + "/catalogue/page-1.html"
RATING = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


@dataclass
class Book:
    title: str
    price: Decimal
    rating: int
    in_stock: bool
    url: str


def make_session() -> requests.Session:
    s = requests.Session()
    s.headers.update({"User-Agent": UA, "Accept-Language": "en"})
    retry = Retry(total=3, backoff_factor=1.0, status_forcelist=[429, 500, 502, 503, 504],
                  respect_retry_after_header=True)
    s.mount("https://", HTTPAdapter(max_retries=retry))
    return s


def parse_price(text: str) -> Decimal:
    return Decimal(text.replace("£", "").replace("Â", "").strip())


def parse_page(html: bytes, page_url: str) -> tuple[list[Book], str | None]:
    soup = BeautifulSoup(html, "lxml")          # truyền bytes để bs4 tự nhận mã hoá
    books = []
    for card in soup.select("article.product_pod"):
        a = card.select_one("h3 a")
        rating_cls = card.select_one("p.star-rating")["class"]       # ['star-rating', 'Three']
        books.append(Book(
            title=a["title"],
            price=parse_price(card.select_one(".price_color").get_text()),
            rating=RATING[rating_cls[1]],
            in_stock="In stock" in card.select_one(".availability").get_text(),
            url=urljoin(page_url, a["href"]),
        ))
    nxt = soup.select_one("li.next a")
    return books, urljoin(page_url, nxt["href"]) if nxt else None


def crawl(max_pages: int = 3, delay: float = 1.0) -> list[Book]:
    rp = load_robots(BASE)
    session, url, out = make_session(), START, []
    for n in range(1, max_pages + 1):
        if not url:
            break
        if not rp.can_fetch(UA, url):
            print("robots.txt cấm:", url)
            break
        t0 = time.perf_counter()
        r = session.get(url, timeout=(3.05, 15))
        r.raise_for_status()
        books, url_next = parse_page(r.content, url)
        print(f"trang {n}: HTTP {r.status_code}, {len(r.content)/1024:.1f} KiB, "
              f"{len(books)} sách, {time.perf_counter()-t0:.2f}s")
        out += books
        url = url_next
        time.sleep(delay)                        # giới hạn tốc độ: tối đa ~1 request/giây
    return out


def save_csv(books: list[Book], path: str) -> None:
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(asdict(books[0])))
        w.writeheader()
        w.writerows(asdict(b) for b in books)


if __name__ == "__main__":
    pages = int(sys.argv[1]) if len(sys.argv) > 1 else 3
    books = crawl(pages)
    save_csv(books, "books.csv")
    print(f"\nTổng: {len(books)} sách, giá trung bình £{sum(b.price for b in books)/len(books):.2f}")
    rate_5 = [b for b in books if b.rating == 5]
    print(f"Sách 5 sao: {len(rate_5)}")
    for b in sorted(books, key=lambda b: b.price)[:3]:
        print(f"  rẻ nhất: £{b.price} | {b.rating} sao | {b.title}")

Hàm parse_page tách riêng khỏi việc tải trang là chủ ý: bạn có thể lưu HTML về đĩa và kiểm thử logic bóc tách mà không cần mạng. Khi crawl data theo liên kết, hãy chú ý urljoin: link trong HTML thường là đường dẫn tương đối như page-2.html, cần ghép với URL của trang hiện tại để ra URL tuyệt đối.

Lỗi mã hoá: vì sao "£" thành "£"

Một lỗi kinh điển khi crawl data nằm ở hàm parse_price ở trên: nó phải xoá cả ký tự "Â". Nguyên nhân: server trả Content-Type: text/html mà không khai báo charset. Theo tài liệu requests, khi đó thư viện áp dụng mặc định của RFC 2616 là ISO-8859-1 cho response.text, trong khi nội dung thực tế là UTF-8. Đoạn mã sau tái hiện lỗi:

"""Lỗi mã hoá thường gặp: server không khai báo charset nên requests đoán ISO-8859-1."""
import requests
from bs4 import BeautifulSoup

URL = "https://books.toscrape.com/catalogue/page-1.html"
r = requests.get(URL, timeout=10)
print("Content-Type    :", r.headers["Content-Type"])
print("r.encoding      :", r.encoding)
print("apparent_encoding:", r.apparent_encoding)

sai = BeautifulSoup(r.text, "lxml").select_one(".price_color").text
dung = BeautifulSoup(r.content, "lxml").select_one(".price_color").text
print("dùng r.text     :", sai)
print("dùng r.content  :", dung)
$ python encoding_demo.py
Content-Type    : text/html
r.encoding      : ISO-8859-1
apparent_encoding: utf-8
dùng r.text     : £51.77
dùng r.content  : £51.77

Cách sửa tốt nhất là đưa r.content (bytes) cho BeautifulSoup để nó tự dò mã hoá từ thẻ <meta charset> trong HTML, như hàm parse_page làm. Cách khác là đặt r.encoding = r.apparent_encoding trước khi đọc r.text. Đừng "chữa" bằng cách xoá ký tự lạ như mẹo ở parse_price: đó chỉ là lớp đệm phòng thủ, không phải giải pháp.

Chạy chương trình crawl data: phân trang, giới hạn tốc độ, ghi CSV

Hàm crawl thực hiện crawl data qua nhiều trang: nó theo dõi liên kết "next" cho đến khi hết trang hoặc đạt giới hạn max_pages, kiểm tra robots.txt trước mỗi request, và time.sleep(delay) giữa hai request để giữ nhịp tối đa khoảng một request mỗi giây. Đây là mức lịch sự tối thiểu: một chương trình crawl data tuần tự nhưng không nghỉ có thể gửi hàng chục request mỗi giây, đủ làm chậm một site nhỏ. Kết quả chạy 3 trang:

$ python crawl_books.py 3
https://books.toscrape.com/robots.txt -> HTTP 404
trang 1: HTTP 200, 49.3 KiB, 20 sách, 1.53s
trang 2: HTTP 200, 49.7 KiB, 20 sách, 0.72s
trang 3: HTTP 200, 50.2 KiB, 20 sách, 0.91s

Tổng: 60 sách, giá trung bình £35.00
Sách 5 sao: 14
  rẻ nhất: £12.84 | 1 sao | In Her Wake
  rẻ nhất: £13.99 | 2 sao | Starving Hearts (Triangular Trade Trilogy, #1)
  rẻ nhất: £14.27 | 4 sao | Untitled Collection: Sabbath Poems 2014

Lần crawl data này cho 60 sách trên ba trang, mỗi trang khoảng 50 KiB. Thời gian mỗi trang dao động từ 0,72 đến 1,53 giây là thời gian mạng thực tế (trang đầu chậm hơn vì còn thiết lập kết nối TLS), không phải đo benchmark. File books.csv có dòng tiêu đề và giá đã chuẩn hoá thành số:

title,price,rating,in_stock,url
A Light in the Attic,51.77,3,True,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,1,True,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html

Sau khi crawl data xong, từ CSV này bạn có thể nạp vào pandas để phân tích. Lưu ý Decimal được ghi ra CSV thành chuỗi số thường, không kèm ký hiệu tiền tệ, nên bước nạp lại cần ép kiểu.

Khi nào crawl data bằng requests và BeautifulSoup là không đủ

Để crawl data, cặp công cụ này phù hợp với trang HTML tĩnh, nhưng có ba giới hạn cần biết:

  • Nội dung dựng bằng JavaScript: requests chỉ tải HTML thô. Nếu dữ liệu cần crawl data được nạp qua XHR sau khi trang mở, hãy mở tab Network của trình duyệt để tìm endpoint JSON và gọi trực tiếp (thường nhẹ và ổn định hơn), hoặc dùng trình duyệt tự động như Selenium khi không còn cách nào khác.
  • Quy mô lớn: crawl data bằng vòng lặp tuần tự đơn giản nhưng không có hàng đợi, không lưu trạng thái để chạy tiếp, không lọc trùng. Khi cần hàng nghìn trang, hãy dùng framework như Scrapy, chủ đề của bài kế tiếp.
  • Chống bot: nhiều site có rào cản kỹ thuật. Bài này không hướng dẫn vượt rào khi crawl data: nếu một site chặn bạn, hãy xin quyền truy cập hoặc dùng API chính thức, thay vì xoay IP hay giả mạo danh tính.

Lỗi thường gặp khi crawl data

  • Không đặt timeout khi crawl data: một request treo làm kẹt cả pipeline. Luôn truyền timeout=(connect, read).
  • Dùng r.text khi server không khai báo charset: gây lỗi "£" như trên. Ưu tiên r.content cho BeautifulSoup.
  • Giả định selector không đổi: site đổi giao diện là việc crawl data gãy. Kiểm tra select_one() có trả None không và ghi log khi cấu trúc lệch kỳ vọng thay vì để crash giữa chừng.
  • Quên raise_for_status(): trang lỗi 500 hay 429 vẫn có thể bị parse thành "0 sách" và lưu vào CSV như dữ liệu thật.
  • Dùng User-Agent mặc định python-requests/2.27.1: nhiều site chặn UA này, và quan trọng hơn, chủ site không có cách liên hệ bạn. Dùng UA có tên bot và địa chỉ liên hệ.
  • Bỏ qua robots.txt và điều khoản: rủi ro pháp lý và uy tín, kể cả khi dữ liệu truy cập được công khai.

Checklist trước khi crawl data

  • Trước khi crawl data, đã kiểm tra API chính thức, điều khoản sử dụng và robots.txt của site.
  • User-Agent nhận diện rõ bot kèm thông tin liên hệ.
  • Có timeout, retry giới hạn số lần, backoff và time.sleep giữa các request.
  • Dữ liệu không chứa thông tin cá nhân, hoặc đã có cơ sở pháp lý.
  • Có giới hạn số trang khi thử nghiệm, và logic bóc tách tách riêng để kiểm thử offline (đúng tinh thần nguyên lý SOLID: mỗi hàm một trách nhiệm).

Toàn bộ mã nguồn nằm ở thư mục code/s7-02/ (robots_demo.py, encoding_demo.py, resilience_demo.py, crawl_books.py), chạy bằng python crawl_books.py 3 trong venv. Khi cần crawl data hàng nghìn trang, vòng lặp thủ công này sẽ bộc lộ giới hạn và đó là lúc framework chuyên dụng phát huy tác dụng.

Bài tiếp theo

Đọc tiếp: Scrapy từ A tới Z: spider, item pipeline và middleware.