Các bài trước giúp crawler chạy nhanh: asyncio cho phép hàng chục kết nối cùng lúc, Playwright đọc được cả trang dựng bằng JavaScript. Nhưng nhanh chưa phải là tốt. Một crawler chỉ biết tăng tốc sẽ sớm bị chặn IP, làm chậm website của người khác, tải đi tải lại trang không đổi và chạm vào vùng chủ site đã nói rõ không muốn bị truy cập. Bài này gom lại những kỹ thuật biến một đoạn script thành một crawler lịch sự và hợp lệ: đọc đúng robots.txt, giãn cách request theo từng host, dùng cache điều kiện với ETag để nhận 304 thay vì tải lại, và đi theo sitemap thay vì dò link mù quáng. Mọi kết quả trong bài đều là output thật.

Bài nối tiếp Crawl trang render JavaScript bằng Playwright cho Python, Crawl bất đồng bộ với asyncio và httpx và Crawl data bằng Python: requests, BeautifulSoup và robots.txt. Các khối Semaphore, timeout, retry của bài asyncio được dùng lại cho crawler ở đây.

Môi trường và cách thử nghiệm

Code của crawler ở code/s7-06, chạy trên Apple M4, 16 GB RAM, Python 3.10.22 với httpx==0.22.0; phần robots dùng urllib.robotparser của thư viện chuẩn. Để kiểm soát hoàn toàn hành vi phía server, tôi viết một site thử nghiệm cục bộ (site_server.py) có robots.txt, sitemap index nén gzip, vài trang có ETag và Last-Modified, và nhật ký mọi request kèm thời điểm. Cuối bài có một thí nghiệm chạy thật trên books.toscrape.com, trang được tạo ra để luyện crawl. Các khối dùng chung nằm trong polite.py.

Bốn nguyên tắc của một crawler lịch sự

Nguyên tắcKỹ thuậtBảo vệ ai
Hỏi trước khi vàorobots.txt, điều khoản sử dụngChủ site và bạn (pháp lý)
Đừng dồn dậpGiãn cách theo host, Crawl-delay, Retry-AfterMáy chủ đích
Đừng tải lại cái đã cóCache điều kiện: ETag, Last-Modified, 304Cả hai phía: băng thông, thời gian
Đi đúng đườngSitemap, lastmodCả hai phía: ít request thừa

Lưu ý: robots.txt là quy ước kỹ thuật, không phải cơ chế bảo mật hay giấy phép pháp lý. Quyền lấy và dùng dữ liệu còn phụ thuộc điều khoản sử dụng, bản quyền và luật bảo vệ dữ liệu cá nhân. Tuân thủ robots.txt là điều kiện cần, chưa phải điều kiện đủ cho một crawler.

robots.txt: đọc đúng và xử lý các trường hợp lỗi

Thư viện chuẩn có sẵn urllib.robotparser.RobotFileParser. Theo tài liệu Python, đối tượng này có can_fetch(useragent, url) để hỏi một URL có được phép không, crawl_delay(useragent) và request_rate(useragent) (từ Python 3.6), và site_maps() (từ Python 3.8) để lấy các dòng Sitemap:. Thay vì để read() tự tải bằng urllib (không cho đặt User-Agent hay timeout), ta tải bằng httpx rồi đưa nội dung cho parse(). Cách này cũng cho ta quyết định rõ ràng khi robots.txt bị lỗi:

class Robots:
    async def load(self, client, base):
        try:
            r = await client.get(base + "/robots.txt", follow_redirects=True)   # httpx mặc định KHÔNG theo 3xx
            self.status = r.status_code
        except httpx.TransportError:
            self.status = 599
            self.rp.disallow_all = True           # không kết nối được: không crawl
            return self
        if r.status_code in (401, 403):
            self.rp.disallow_all = True           # bị từ chối: thận trọng
        elif r.status_code == 429 or r.status_code >= 500:
            self.rp.disallow_all = True           # tạm thời / bị giới hạn: chưa biết luật, không crawl
        elif 400 <= r.status_code < 500:
            self.rp.allow_all = True              # không có robots.txt: được phép
        elif r.status_code != 200:
            self.rp.disallow_all = True           # 3xx còn sót: thận trọng
        else:
            self.rp.parse(r.text.splitlines())
        return self

Chính sách này khớp với hướng dẫn của Google trong tài liệu robots.txt: phản hồi 4xx (trừ 429) được coi như không có tệp và không có hạn chế, còn lỗi 5xx khiến bot tạm ngừng crawl site rồi thử lại; ta xếp cả 429 vào nhóm tạm thời này. Google cũng đi theo chuyển hướng của robots.txt (tối đa năm bước), nên đoạn code bật follow_redirects=True: httpx 0.22 mặc định không theo 3xx, và nếu quên, trang chuyển hướng với thân rỗng sẽ bị parse() hiểu thành "không có luật nào" và crawler được phép đi khắp nơi. Riêng 401 và 403 Google coi là "không có tệp", còn ta chọn thận trọng hơn là không crawl vì tín hiệu "bị từ chối" thường là chủ ý. Kết quả chạy thật (demo1_robots.py) với robots.txt của site thử nghiệm:

robots.txt: HTTP 200
  can_fetch(/p/1) = True
  can_fetch(/private/admin) = False
  crawl_delay = 1
  sitemaps    = ['<host>/sitemap_index.xml']

Xử lý trạng thái của chính robots.txt:
  robots.txt trả 404 -> can_fetch(/p/1) = True, can_fetch(/private/admin) = True
  robots.txt trả 403 -> can_fetch(/p/1) = False, can_fetch(/private/admin) = False
  robots.txt trả 429 -> can_fetch(/p/1) = False, can_fetch(/private/admin) = False
  robots.txt trả 500 -> can_fetch(/p/1) = False, can_fetch(/private/admin) = False
  robots.txt trả 301 -> can_fetch(/p/1) = True, can_fetch(/private/admin) = False

Dòng cuối là trường hợp robots.txt trả 301 tới bản thật: nhờ theo chuyển hướng, crawler đọc được luật và vẫn chặn /private/admin. Ngược lại, khi robots.txt trả 404, mọi đường dẫn đều được phép, kể cả /private/admin.

Bẫy của urllib.robotparser cho crawler

Thư viện chuẩn đơn giản và có vài điểm khác với cách Google diễn giải. Ba thí nghiệm sau chỉ ra những điều dễ sai:

Thứ tự Allow/Disallow: urllib.robotparser dùng luật khớp ĐẦU TIÊN theo thứ tự trong file
  Disallow trước Allow: can_fetch(/gia-si/bang-gia) = False
  Allow trước Disallow: can_fetch(/gia-si/bang-gia) = True

Nhóm User-agent cụ thể thay thế nhóm '*':
  SudoBlogDemoCrawler  /san-pham    -> True
  SudoBlogDemoCrawler  /gia-si/x    -> False
  BotKhac              /san-pham    -> False
  BotKhac              /gia-si/x    -> False

Crawl-delay: chỉ số nguyên được nhận
  Crawl-delay: 1    -> crawl_delay() = 1
  Crawl-delay: 0.5  -> crawl_delay() = None
  Crawl-delay: 2    -> crawl_delay() = 2
  • Thứ tự luật có ý nghĩa. Bộ phân tích của Python (mã nguồn Lib/urllib/robotparser.py xác nhận) duyệt các dòng theo thứ tự và dùng luật đầu tiên khớp tiền tố. Ngược lại, Google áp dụng luật cụ thể nhất (đường dẫn dài nhất thắng). Với cùng một tệp, hai bên có thể cho kết quả khác nhau. Khi viết robots.txt, đặt Allow cụ thể trước Disallow chung để cả hai cách hiểu cùng ra một kết quả.
  • Nhóm User-agent cụ thể thay thế hẳn nhóm *. Crawler tự nhận tên SudoBlogDemoCrawler chỉ chịu luật của nhóm riêng của nó. Vì vậy hãy khai báo tên crawler ổn định để chủ site có thể viết luật riêng cho bạn.
  • Crawl-delay: 0.5 bị bỏ qua. Bộ phân tích chỉ nhận số nguyên, giá trị thập phân trả về None. Đồng thời, Google không hỗ trợ chỉ thị Crawl-delay, nhưng nhiều crawler khác thì có, nên với site của người khác ta vẫn tôn trọng nó.

Giãn cách request của crawler theo host

Ở bài asyncio ta giới hạn số request đồng thời. Chừng đó chưa đủ: 4 request đồng thời vẫn có thể bắn cùng một mili giây rồi nghỉ, tạo từng đợt đột biến. Rate limit đúng nghĩa là khoảng cách tối thiểu giữa hai lần bắt đầu request tới cùng host. Lớp Throttle bên dưới giữ một "lịch hẹn" cho mỗi host: mỗi lời gọi nhận một khe thời gian kế tiếp rồi ngủ đến đó:

class Throttle:
    def __init__(self, min_interval):
        self.min_interval = min_interval
        self._next = {}
        self._lock = asyncio.Lock()

    async def wait(self, host):
        async with self._lock:
            now = time.monotonic()
            start = max(now, self._next.get(host, 0.0))
            self._next[host] = start + self.min_interval
        if start > now:
            await asyncio.sleep(start - now)

Hai chi tiết: dùng time.monotonic() chứ không dùng đồng hồ hệ thống để không bị nhảy khi chỉnh giờ, và khoá chỉ bảo vệ phần đặt lịch chứ không bao phần ngủ, nếu không các worker sẽ xếp hàng tuần tự thay vì chồng các khoảng chờ. Thí nghiệm: 8 request, 4 worker, đo khoảng cách giữa các request ở phía server (demo2_throttle.py):

không giãn cách            tổng  0.01s  khoảng cách nhỏ nhất giữa 2 request =    0.1 ms  trung vị =    0.5 ms
giãn cách 0,25 s           tổng  1.75s  khoảng cách nhỏ nhất giữa 2 request =  246.2 ms  trung vị =  249.9 ms
giãn cách 1 s (Crawl-delay) tổng  7.00s  khoảng cách nhỏ nhất giữa 2 request =  998.5 ms  trung vị = 1000.5 ms

Khi không giãn cách, 8 request dồn trong 10 mili giây. Với khoảng cách 0,25 giây, server thấy đúng một request mỗi 250 ms, bất kể có bao nhiêu worker, và tổng thời gian bằng 7 × khoảng cách (1,75 và 7,00 giây). Crawler chậm mà không bị chặn thường nhanh hơn crawler nhanh rồi bị chặn.

Chọn khoảng cách: nếu robots.txt có Crawl-delay thì dùng giá trị đó; nếu không, với site không phải của mình, bắt đầu từ 1 giây. Nếu nhận 429 hoặc 503 kèm Retry-After thì chờ đúng thời gian yêu cầu (đã làm ở bài trước) và tăng khoảng cách chứ không chỉ retry.

Cache điều kiện cho crawler: ETag, Last-Modified và 304

Nhiều trang không đổi giữa hai lần crawl. Tải lại toàn bộ chúng là lãng phí cho cả hai bên. HTTP đã có cơ chế cho việc này. Theo tài liệu MDN về conditional requests, server gửi kèm tài nguyên một validator: ETag (chuỗi mờ định danh từng phiên bản) hoặc Last-Modified (thời điểm sửa cuối). Lần sau client gửi lại bằng If-None-Match hoặc If-Modified-Since; nếu tài nguyên không đổi, server trả 304 Not Modified không có body và client dùng bản đã lưu.

Hàm fetch_cached lưu body cùng hai validator trên đĩa và gửi các header điều kiện ở lần sau:

async def fetch_cached(client, cache, url):
    entry = cache.get(url)
    headers = {}
    if entry:
        if entry.get("etag"):
            headers["If-None-Match"] = entry["etag"]
        if entry.get("last_modified"):
            headers["If-Modified-Since"] = entry["last_modified"]
    r = await client.get(url, headers=headers)
    if r.status_code == 304 and entry:
        return entry["body"], "304", 0
    if r.status_code == 200:
        cache.put(url, {"body": r.text, "etag": r.headers.get("ETag"),
                        "last_modified": r.headers.get("Last-Modified")})
        return r.text, "moi", len(r.content)
    return None, "loi", 0

Chạy crawler trên site thử nghiệm với 6 trang, mỗi trang khoảng 3 KB (demo3_conditional.py); giữa lần 2 và lần 3 tôi sửa nội dung /p/2 và /p/5:

Header trang: {'etag': '"c2c37b6099bd"', 'last-modified': 'Fri, 15 Apr 2022 05:20:00 GMT'}
lần 1 (cache trống)                mới=6 304=0 lỗi=0 byte tải về=18210
lần 2 (không đổi)                  mới=0 304=6 lỗi=0 byte tải về=0
lần 3 (đã sửa /p/2 và /p/5)        mới=2 304=4 lỗi=0 byte tải về=6070
lần 4 (không đổi)                  mới=0 304=6 lỗi=0 byte tải về=0
Mã trạng thái máy chủ đã trả: {200: 9, 304: 16}

Lần crawl không đổi tải về 0 byte nội dung, và lần có thay đổi chỉ tải đúng hai trang bị sửa. Tổng cộng server trả 9 lần 200 và 16 lần 304. Vẫn có 6 request mỗi lần, nhưng các request này rất rẻ: chỉ header qua mạng, server không phải sinh lại nội dung. Hai điều cần biết khi dùng cơ chế này:

  • MDN lưu ý có hai kiểu so sánh: strong (giống nhau từng byte) và weak (nội dung tương đương, ví dụ chỉ khác ngày ở chân trang). Nhiều server tạo ETag yếu (tiền tố W/); ta không cần phân biệt, chỉ cần gửi lại đúng chuỗi đã nhận.
  • Không phải site nào cũng hỗ trợ. Trang động thường không có ETag, và có server nén dữ liệu làm đổi ETag. Khi đó crawler vẫn chạy đúng, chỉ không tiết kiệm được. Hãy tự tính băng thông tiết kiệm bằng cách đếm số 304, như trên.

Sitemap: crawler đi đúng đường thay vì dò link

Crawler dò theo liên kết sẽ gặp cả những trang không ai cần, trong khi bỏ sót trang không có link tới. Sitemap là danh sách URL do chính chủ site cung cấp. Theo giao thức sitemaps.org, mỗi tệp chứa tối đa 50.000 URL và không quá 50 MB khi chưa nén; có thể nén gzip để tiết kiệm băng thông; nhiều tệp được gom bằng một sitemap index (<sitemapindex>); thẻ <lastmod> cho biết lần sửa cuối của trang; và dòng Sitemap: trong robots.txt chỉ tới vị trí tệp. Hàm đọc sitemap đệ quy xử lý cả ba trường hợp:

async def read_sitemap(client, url, depth=0):
    if depth > 3:
        return []
    r = await client.get(url)
    if r.status_code != 200:
        return []
    data = r.content
    if url.endswith(".gz") or data[:2] == b"\x1f\x8b":
        data = gzip.decompress(data)
    root = ET.fromstring(data)
    if root.tag == SM_NS + "sitemapindex":
        out = []
        for sm in root.findall(SM_NS + "sitemap"):
            out += await read_sitemap(client, sm.find(SM_NS + "loc").text.strip(), depth + 1)
        return out
    return [(u.find(SM_NS + "loc").text.strip(),
             (u.find(SM_NS + "lastmod").text.strip() if u.find(SM_NS + "lastmod") is not None else None))
            for u in root.findall(SM_NS + "url")]

Giới hạn độ sâu bảo vệ crawler khỏi sitemap index lồng nhau vô hạn. Lưu ý xml.etree không an toàn trước XML độc hại (như "billion laughs"); với sitemap không tin cậy hãy dùng defusedxml hoặc giới hạn kích thước tải về trước khi parse.

Ráp thành crawler lịch sự hoàn chỉnh

Quy trình của crawler gồm: đọc robots.txt, lấy sitemap từ đó, loại các URL bị cấm, giãn cách theo Crawl-delay, và tải bằng cache điều kiện. Chạy ba lượt trên site thử nghiệm, với sitemap có sẵn cả một URL /private/admin bị robots.txt cấm (demo4_sitemap_crawl.py):

Crawl-delay từ robots.txt = 1 -> dùng 1 s
Lần 1
  sitemap: 7 URL (lastmod mẫu: 2022-04-15)
  bỏ qua vì robots.txt: ['/private/admin']
  tải mới=6 304=0 byte=18210
  mất 5.0s
Lần 2 (không đổi)
  sitemap: 7 URL (lastmod mẫu: 2022-04-15)
  bỏ qua vì robots.txt: ['/private/admin']
  tải mới=0 304=6 byte=0
  mất 6.0s
Lần 3 (sau khi sửa /p/3)
  sitemap: 7 URL (lastmod mẫu: 2022-04-15)
  bỏ qua vì robots.txt: ['/private/admin']
  tải mới=1 304=5 byte=3035
Request tới /private/ phía server: 0

Dòng cuối là kiểm chứng quan trọng nhất: nhật ký phía server cho thấy 0 request tới khu vực bị cấm, dù sitemap liệt kê nó. Sitemap và robots.txt là hai nguồn độc lập, và robots.txt luôn thắng. Còn thời gian: lần 1 mất 5,0 giây cho 6 trang (5 khoảng cách 1 giây), lần 2 mất 6,0 giây vì bộ giãn cách vẫn nhớ khe thời gian của lượt trước. Đây là điều ta muốn khi crawler chạy liên tục, nhưng đừng bất ngờ khi thấy lần sau chậm hơn một nhịp.

Crawler cũng có thể dùng lastmod để bỏ qua hẳn trang chưa đổi kể từ lần crawl trước, tiết kiệm cả request 304. Nhưng lastmod do chủ site khai báo và có thể sai, nên với trang quan trọng vẫn nên kiểm bằng ETag.

Thử trên một site thật

Cuối cùng, chạy cùng crawler trên books.toscrape.com với 5 trang danh mục, tuần tự, nghỉ 0,3 giây giữa các trang (demo5_real_site.py):

robots.txt: HTTP 404 -> can_fetch(/catalogue/page-2.html) = True
lần 1: mới=5 304=0 byte tải về=257073
lần 2: mới=0 304=5 byte tải về=0

Site này không có robots.txt (404, tức không hạn chế) nhưng máy chủ có gửi ETag và Last-Modified, nên lần chạy thứ hai nhận cả 5 phản hồi 304 và không tải về byte nội dung nào, so với khoảng 251 KB của lần đầu. Với site hàng nghìn trang, mức tiết kiệm nhân lên rất nhiều. Dù trang luyện tập cho phép, crawler vẫn khai báo User-Agent rõ ràng (SudoBlogDemoCrawler/1.0 kèm địa chỉ liên hệ) để quản trị viên biết ai đang truy cập.

Khía cạnh pháp lý và đạo đức

  • Điều khoản sử dụng. Nhiều site cấm thu thập tự động trong điều khoản dù robots.txt để trống. Đọc trước, và xin phép hoặc dùng API chính thức nếu có.
  • Bản quyền và mục đích sử dụng. Lấy được dữ liệu không đồng nghĩa với được tái phân phối hay dùng thương mại.
  • Dữ liệu cá nhân. Tên, số điện thoại, email hay hồ sơ người dùng chịu quy định bảo vệ dữ liệu cá nhân. Tránh thu thập nếu không có căn cứ hợp pháp; nếu cần phân tích, ẩn danh hoá ngay khi lưu.
  • Không né cơ chế chặn. Xoay IP hay giả danh trình duyệt để vượt rate limit là hành vi đối kháng, không thuộc phạm vi một crawler lịch sự. Nếu bị chặn, hãy liên hệ chủ site.
  • Ghi log. Lưu URL, thời điểm, mã trạng thái để giải trình khi có khiếu nại về crawler của mình.

Khi nào các kỹ thuật này là quá mức

  • Crawler cho site của chính bạn hoặc API nội bộ có thoả thuận: vẫn nên giãn cách vừa phải, nhưng không cần đọc robots.txt hay sitemap; thay vào đó dùng API xuất dữ liệu nếu có.
  • Crawler chạy một lần với vài chục trang: cache điều kiện không đáng công; chỉ cần giãn cách và User-Agent.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
Crawler vẫn vào được thư mục bị cấmChỉ lấy URL từ sitemap mà không lọc qua can_fetchMọi URL, từ bất kỳ nguồn nào, đều đi qua bộ kiểm tra robots.txt
Crawler bị chặn dù đã đặt giới hạn đồng thờiGiới hạn đồng thời không phải rate limit; request dồn thành đợtThêm khoảng cách tối thiểu theo host
Crawl-delay không có tác dụngGiá trị thập phân bị bộ phân tích bỏ quaDùng số nguyên; tự đặt mặc định khi trả về None
Luật Allow không được áp dụngDisallow đứng trước trong tệpĐặt luật cụ thể trước luật chung; kiểm tra bằng can_fetch
Không bao giờ nhận được 304Không gửi lại đúng ETag; server không hỗ trợ validatorLưu nguyên chuỗi ETag, in header ra kiểm tra
Crawler dừng hẳn khi robots.txt lỗi 5xxChính sách thận trọng ở trênThử lại sau một thời gian; đừng coi 5xx là "được phép"

Checklist cho crawler lịch sự và hợp lệ

  • Crawler đọc điều khoản và robots.txt của từng host trước khi chạy; xử lý riêng 4xx, 401/403, 5xx, lỗi mạng.
  • User-Agent của crawler ổn định, có địa chỉ liên hệ; đặt tên riêng để chủ site viết luật cho nó.
  • Khoảng cách tối thiểu theo host (tôn trọng Crawl-delay và Retry-After), ngoài giới hạn đồng thời.
  • Cache điều kiện bằng ETag và Last-Modified; đếm số 304 để biết hiệu quả.
  • Dùng sitemap làm nguồn URL, nhưng mọi URL đều qua kiểm tra robots.txt.
  • Không thu thập dữ liệu cá nhân nếu không có căn cứ; không né cơ chế chặn.

Bài tiếp theo

Đến đây ta đã đi hết nhóm bài thu thập dữ liệu web bằng Python: từ requests và BeautifulSoup, qua Scrapy, asyncio, Playwright, tới những nguyên tắc để crawler hoạt động lâu dài mà không gây hại. Dữ liệu thu về thường bẩn, nên bài kế tiếp là Làm sạch dữ liệu crawl bằng pandas: chuẩn hoá, khử trùng lặp, kiểu dữ liệu.