🎯 Trình duyệt đám mây tùy chỉnh, chống phát hiện được hỗ trợ bởi Chromium tự phát triển, thiết kế dành cho trình thu thập dữ liệu webtác nhân AI. 👉Dùng thử ngay
Quay lại blog

Crawlee cho Python: Xếp hàng, Xóa bản sao và Hiển thị một lần thu thập thực sự

Alex Johnson
Alex Johnson

Senior Web Scraping Engineer

04-Aug-2026

TL;DR:

  • Crawlee cho Python cung cấp cho bạn một hàng đợi yêu cầu, tự động loại bỏ URL trùng lặp, enqueue_links(), và một người viết tập dữ liệu, vì vậy một trình thu thập dữ liệu phân trang là một hàm xử lý duy nhất.
  • Lưu trữ của Crawlee được chia sẻ theo tiến trình, không phải theo trình thu thập dữ liệu. purge_on_startTrue và vẫn không tách biệt hai trình thu thập dữ liệu trong một tập lệnh.
  • Đo lường: hai trình thu thập dữ liệu giống hệt nhau với max_requests_per_crawl=2 trong một tập lệnh. Trình thu thập đầu tiên hoàn thành 2 yêu cầu và ghi lại 20 mục; trình thu thập thứ hai hoàn thành 3 yêu cầu và báo cáo 50 mục trên 5 trang.
  • BeautifulSoupCrawler không chạy JavaScript. Trên một trang được render từ phía khách hàng, nó hoàn thành yêu cầu và sản xuất 0 mục.
  • Lớp cơ sở HttpClient của Crawlee có bốn phương thức. Việc triển khai một phương thức gọi API thu thập dữ liệu Scrapeless Universal đã trả về 10 mục từ trang đó, với bộ xử lý định tuyến không thay đổi.
  • Kế hoạch miễn phí của Scrapeless bao gồm mọi yêu cầu trong hướng dẫn này.

Crawlee cho Python là phần của một trình thu thập dữ liệu mà bạn sẽ viết tự nó: hàng đợi giữ các URL, tập hợp ngăn bạn lấy một URL hai lần, bộ giới hạn đồng thời và người viết đưa kết quả vào đĩa. Bạn cung cấp một bộ xử lý nhận được một trang đã phân tích.

Vì Crawlee sở hữu hàng đợi và lưu trữ, các mặc định của nó quyết định kết quả của bạn trông như thế nào — và hai trong số đó tạo ra các con số sai một cách mà không có ngoại lệ nào sẽ cho bạn biết.

Hướng dẫn này xây dựng một trình thu thập dữ liệu hoạt động trên một trang web trực tiếp, đo lường những gì mặc định lưu trữ làm với một trình thu thập dữ liệu thứ hai, sau đó thay đổi phương tiện để cùng một bộ xử lý hoạt động trên một trang được render trong trình duyệt.

Những gì Crawlee mang lại cho bạn

Crawlee cung cấp một số lớp trình thu thập dữ liệu chia sẻ một giao diện. Lớp bạn chọn quyết định cách trang được phân tích:

  • BeautifulSoupCrawlerParselCrawler lấy dữ liệu qua HTTP và đưa cho bộ xử lý của bạn một cây đã phân tích.
  • HttpCrawler cung cấp cho bạn phản hồi thô mà không có phân tích.
  • PlaywrightCrawlerAdaptivePlaywrightCrawler điều khiển một trình duyệt thực.

Tất cả đều chấp nhận cùng một bộ định tuyến, cùng một thiết lập đồng thời, và cùng một lưu trữ. Thay đổi giữa chúng sẽ thay đổi đối tượng ngữ cảnh mà bộ xử lý của bạn nhận được, đó là lý do tại sao chuyển từ một trình thu thập dữ liệu HTTP sang một trình thu thập dữ liệu trình duyệt không phải là một thay đổi chỉ trong một dòng.

Cài đặt

bash Copy
pip install 'crawlee[beautifulsoup]'

Phần bổ sung quan trọng — gói cơ sở crawlee không kéo theo Beautiful Soup. Chạy xác minh đã sử dụng crawlee 1.9.0 với beautifulsoup4 4.15.0 trên Python 3.12.

Trình thu thập dữ liệu đầu tiên của bạn

Một trình thu thập dữ liệu là một lớp cùng với một bộ xử lý được trang trí. Bộ xử lý nhận được một ngữ cảnh chứa trang đã phân tích, yêu cầu, và các phương thức để đẩy dữ liệu và xếp hàng thêm URL.

python Copy
def build(*, storage_dir: str | None = None, http_client=None, max_requests: int = 3):
    crawler = BeautifulSoupCrawler(
        http_client=http_client,
        max_requests_per_crawl=max_requests,
        concurrency_settings=ConcurrencySettings(desired_concurrency=2, max_concurrency=2),
        configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
    )

    @crawler.router.default_handler
    async def handler(context: BeautifulSoupCrawlingContext) -> None:
        for quote in context.soup.select("div.quote"):
            await context.push_data({
                "text": quote.select_one("span.text").get_text(strip=True),
                "author": quote.select_one("small.author").get_text(strip=True),
                "url": context.request.url,
            })
        await context.enqueue_links(selector="li.next a")

    return crawler

context.soup là một đối tượng Beautiful Soup, vì vậy các bộ chọn hiện có sẽ không thay đổi. context.push_data() sẽ thêm vào tập dữ liệu. context.enqueue_links(selector=...) tìm các liên kết phù hợp với bộ chọn đó, xác định mỗi liên kết với trang hiện tại bằng cách sử dụng các quy tắc base-URL trong tiêu chuẩn URL WHATWG, và thêm kết quả vào hàng đợi — đã được loại bỏ trùng lặp, vì vậy một liên kết "tiếp theo" chỉ về trang đã được truy cập sẽ không tốn kém gì.

ConcurrencySettings từ chối một max_concurrency thấp hơn desired_concurrency của nó, vì vậy hãy đặt cả hai khi bạn giảm nó.

Chạy trên ba trang của một trang web danh ngôn trực tiếp:

text Copy
  static
    requests finished : 3
    dataset items     : 30
    distinct pages    : 3
    first quote       : “Thế giới như chúng ta đã tạo ra là một quá trình tư duy của chúng ta”
    first author      : Albert Einstein

Ba yêu cầu, mười danh ngôn mỗi yêu cầu, ba URL nguồn khác nhau. Bộ xử lý không bao giờ tạo ra một URL hoặc theo dõi một tập hợp đã truy cập.

Dữ liệu đi đâu

push_data() ghi vào một tập dữ liệu dưới ./storage, và crawler.get_data() đọc lại nó:

python Copy
async def report(label, crawler, start_url):
    await crawler.run([start_url])
    data = await crawler.get_data()
    print(f"  {label}")
    print(f"    requests finished : {crawler.statistics.state.requests_finished}")
    print(f"    dataset items     : {data.count}")
python Copy
print(f"    số trang khác biệt    : {len({i['url'] for i in data.items})}")
    return data

crawler.statistics.state.requests_finished là số lượng mà Crawlee thực sự hoàn thành, điều này đáng được in ra bên cạnh số lượng bộ dữ liệu. Khi hai số này không khớp với mong đợi của bạn, lý do thường là phần tiếp theo.

Lưu Trữ Sống Lâu Hơn Trình Thu Thập của Bạn

Configuration().purge_on_startTrue. Điều này có nghĩa là mỗi lần chạy đều bắt đầu từ một bộ dữ liệu trống và một hàng đợi trống. Thực tế không phải vậy — việc xóa diễn ra một lần, khi lưu trữ lần đầu tiên được mở trong quá trình, vì vậy một trình thu thập thứ hai được xây dựng trong cùng một kịch bản sẽ tham gia vào lưu trữ mà trình thu thập đầu tiên đã để lại.

Hai trình thu thập, được xây dựng bởi cùng một hàm, đều giới hạn ở hai yêu cầu, đều bắt đầu từ cùng một URL:

python Copy
await report("trình thu thập A", build(max_requests=2), "https://quotes.toscrape.com/")
await report("trình thu thập B", build(max_requests=2), "https://quotes.toscrape.com/")
text Copy
  trình thu thập A
    yêu cầu đã hoàn thành : 2
    mục bộ dữ liệu        : 20
    số trang khác biệt    : 2
  trình thu thập B
    yêu cầu đã hoàn thành : 3
    mục bộ dữ liệu        : 50
    số trang khác biệt    : 5

Trình thu thập B được cấu hình cho hai yêu cầu và đã hoàn thành ba. Bộ dữ liệu của nó báo cáo 50 mục trên 5 trang khác biệt, bao gồm tất cả những gì trình thu thập A đã viết. Không có lỗi xảy ra, và cả hai lần chạy đều được ghi nhận như là thành công.

URL bắt đầu mà trình thu thập B được cung cấp đã được truy cập trước đó, vì vậy quá trình loại bỏ đã loại bỏ nó, trong khi trang mà trình thu thập A đã xếp hàng và chưa bao giờ truy cập vẫn đang chờ đợi. Giới hạn và bộ dữ liệu mà một trình thu thập báo cáo đều là thuộc tính của lưu trữ chung, chứ không phải của chính trình thu thập đó.

Cung cấp cho mỗi trình thu thập thư mục lưu trữ riêng khi chúng chia sẻ một quá trình. Đó là lý do chính mà builder ở trên nhận tham số chính xác cho lý do này:

python Copy
        configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,

Chạy lại ba giai đoạn cùng một lần với storage_dir được thiết lập cho mỗi trình thu thập và các giá trị trở thành những gì bạn đã cấu hình. Một trình thu thập cho mỗi quá trình là câu trả lời khác, và là câu đơn giản hơn cho sản xuất.

Khi Trang Hiển Thị Trong Trình Duyệt

https://quotes.toscrape.com/js/ xây dựng DOM của nó từ một mảng JavaScript. BeautifulSoupCrawler truy xuất nó mà không phàn nàn:

text Copy
  javascript
    yêu cầu đã hoàn thành : 1
    mục bộ dữ liệu        : 0
    số trang khác biệt    : 0

Một yêu cầu đã hoàn thành, không có mục nào. Mã nguồn mà Crawlee nhận được không chứa bất kỳ phần tử div.quote nào, và một trình thu thập HTTP không có gì để tạo ra chúng.

Câu trả lời đã được tài liệu hóa là PlaywrightCrawler, nghĩa là phụ thuộc vào trình duyệt, một đối tượng ngữ cảnh khác, và phải viết lại phương thức phân tích của trình xử lý. Sự thay đổi hẹp hơn là giữ lại BeautifulSoupCrawler và chỉ thay thế phương thức vận chuyển của nó mà Crawlee hỗ trợ thông qua tham số http_client.

HttpClient có bốn phương thức, và chỉ hai trong số đó cần làm việc thực sự. Một đối tượng phản hồi thỏa mãn kiểu cấu trúc HttpResponse của Crawlee — một giao thức theo nghĩa của đặc tả giao thức typing của Python — bao bọc HTML đã được tạo ra. Nó phải tiết lộ mã trạng thái và tiêu đề bởi vì Crawlee xử lý chúng theo cách mà đặc tả ngữ nghĩa HTTP định nghĩa:

python Copy
class RenderedResponse:
    """Thích ứng một chuỗi HTML đã được render theo giao thức HttpResponse của Crawlee."""

    def __init__(self, body: bytes, status_code: int = 200) -> None:
        self._body = body
        self._status_code = status_code

    @property
    def http_version(self) -> str:
        return "HTTP/1.1"

    @property
    def status_code(self) -> int:
        return self._status_code

    @property
    def headers(self) -> HttpHeaders:
        return HttpHeaders({"content-type": "text/html; charset=utf-8"})

    async def read(self) -> bytes:
        return self._body

    async def read_stream(self) -> AsyncIterator[bytes]:
        raise RuntimeError("streaming không được hỗ trợ bởi khách hàng này")
        yield b""

Khách hàng tự gọi Scrapeless Universal Scraping API. Điểm cuối đó tạo trang và trả lại HTML dưới dạng chuỗi. Cuộc gọi chặn thông qua asyncio.to_thread để nó không làm ngưng trệ vòng lặp sự kiện mà tài liệu công việc asyncio mô tả:

python Copy
class ScrapelessHttpClient(HttpClient):
    """Định tuyến mọi yêu cầu Crawlee thông qua Universal Scraping API."""

    def __init__(self, *, proxy_country: str = "US") -> None:
        super().__init__()
        self._proxy_country = proxy_country
        self._token = os.environ["SCRAPELESS_API_KEY"]

    def _render(self, url: str) -> bytes:
python Copy
payload = {
            "actor": "unlocker.webunlocker",
            "input": {"url": url, "proxy_country": self._proxy_country, "js_render": True},
        }
        request = urllib.request.Request(
            UNLOCKER,
            data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json", "x-api-token": self._token},
        )
        with urllib.request.urlopen(request, timeout=180) as response:
            return json.loads(response.read().decode())["data"].encode("utf-8")

    async def crawl(self, request, *, session=None, proxy_info=None, statistics=None,
                    timeout: timedelta | None = None) -> HttpCrawlingResult:
        body = await asyncio.to_thread(self._render, request.url)
        return HttpCrawlingResult(http_response=RenderedResponse(body))

    async def send_request(self, url, *, method="GET", headers=None, payload=None,
                           session=None, proxy_info=None, timeout=None) -> HttpResponse:
        body = await asyncio.to_thread(self._render, url)
        return RenderedResponse(body)

    def stream(self, url, **kwargs):
        raise NotImplementedError("khách hàng này không hỗ trợ streaming")

    async def cleanup(self) -> None:
        return None

Gửi nó tới cùng một crawler và chạy cùng một trang:

text Copy
  javascript+api
    yêu cầu hoàn tất : 1
    mục dữ liệu      : 10
    trang khác nhau   : 1
    trích dẫn đầu tiên : “Thế giới như chúng ta đã tạo ra là một quá trình tư duy của chúng ta

Mười mục từ trang tạo ra không có. Bộ xử lý định tuyến, bộ chọn, cuộc gọi tập dữ liệu, và enqueue_links đều không thay đổi — hàng đợi và loại bỏ trùng lặp của Crawlee vẫn hoạt động, vì chỉ có đối tượng thu thập byte được thay thế. Giữ chìa khóa trong môi trường là SCRAPELESS_API_KEY; hướng dẫn hướng dẫn bắt đầu với Universal Scraping API liệt kê các tham số yêu cầu khác. Nếu bạn cần định tuyến proxy cho khách hàng HTTP mặc định, hướng dẫn Crawlee proxy sẽ phủ nhận cấu hình đó.

Bắt đầu mất một phút — tạo một tài khoản Scrapeless miễn phí và gói miễn phí bao gồm mọi thứ ở đây.

Chạy Nó

bash Copy
export SCRAPELESS_API_KEY="your-api-key"
python3 crawlee_demo.py

Đầu ra hoàn chỉnh từ lần kiểm tra xác minh:

text Copy
crawlee 1.9.0 | beautifulsoup4 4.15.0
xóa dữ liệu khi bắt đầu mặc định: True
--- trang tĩnh, khách hàng HTTP mặc định, lưu trữ tách biệt ---
  tĩnh
    yêu cầu hoàn tất : 3
    mục dữ liệu      : 30
    trang khác nhau   : 3
    trích dẫn đầu tiên : “Thế giới như chúng ta đã tạo ra là một quá trình tư duy của chúng ta
    tác giả đầu tiên   : Albert Einstein
--- trang javascript, khách hàng HTTP mặc định, lưu trữ tách biệt ---
  javascript
    yêu cầu hoàn tất : 1
    mục dữ liệu      : 0
    trang khác nhau   : 0
--- trang javascript, ScrapelessHttpClient, lưu trữ tách biệt ---
  javascript+api
    yêu cầu hoàn tất : 1
    mục dữ liệu      : 10
    trang khác nhau   : 1
    trích dẫn đầu tiên : “Thế giới như chúng ta đã tạo ra là một quá trình tư duy của chúng ta
--- hai crawler, một quy trình, lưu trữ mặc định ---
  crawler A
    yêu cầu hoàn tất : 2
    mục dữ liệu      : 20
    trang khác nhau   : 2
  crawler B
    yêu cầu hoàn tất : 3
    mục dữ liệu      : 50
    trang khác nhau   : 5

Xử lý sự cố

Tập dữ liệu có nhiều mục hơn số mục được sản xuất trong lần chạy này. Lưu trữ được chia sẻ theo quá trình. Đặt Configuration(storage_dir=...) cho mỗi crawler, hoặc xóa ./storage giữa các lần chạy, hoặc chạy một crawler mỗi quy trình.

desired_concurrency không thể lớn hơn max_concurrency. ConcurrencySettings xác thực cặp này khi khởi tạo. Giảm max_concurrency một mình sẽ nâng lên; đặt desired_concurrency để khớp.

ModuleNotFoundError: Không tìm thấy mô-đun 'bs4'. Gói cơ bản không có bộ phân tích. Cài đặt crawlee[beautifulsoup] hoặc crawlee[parsel].

ImportError trên HttpHeaders. Nó được xuất ra từ gói crawlee cấp cao nhất, không từ một mô-đun con.

Không có mục nào và một yêu cầu đã hoàn tất. Trang được hiển thị phía khách hàng. In await context.http_response.read() và tìm kiếm một giá trị bạn có thể thấy trên trang; nếu giá trị vắng mặt, không có bộ chọn nào sẽ tìm thấy nó.

Kết luận

Giá trị của Crawlee là cơ chế xung quanh bộ xử lý của bạn: hàng đợi, loại bỏ trùng lặp, đồng thời có giới hạn, và một tập dữ liệu. Cơ chế đó cũng là thứ cần theo dõi, vì nó giữ trạng thái tồn tại qua đối tượng crawler. Hai thước đo trong hướng dẫn này đều xuất phát từ thực tế đó — một crawler thứ hai trong một quá trình báo cáo 50 mục khi nó đã thu thập ít hơn nhiều, và một trang được hiển thị bởi khách hàng trả về một số không sạch.
Cả hai đều có thể được chẩn đoán trong một dòng. In requests_finished cùng với số lượng tập dữ liệu trong mỗi lần chạy; khi chúng không khớp với cấu hình của bạn, hãy nhìn vào bộ nhớ trước các bộ chọn. Và khi số lượng bằng không vì markup đến trống, cách sửa chữa nhỏ nhất là thay đổi phương thức vận chuyển và để lại trình xử lý nguyên vẹn.

Sẵn sàng để thử chưa? Bắt đầu với gói miễn phí của Scrapeless và xem bảng giá hiện tại cho các khối lượng lớn hơn.

Câu hỏi thường gặp

H: Lớp crawler Crawlee nào tôi nên bắt đầu với?

Bắt đầu với BeautifulSoupCrawler nếu dữ liệu nằm trong HTML được phục vụ, vì nó tốn một yêu cầu HTTP cho mỗi trang và cung cấp cho bạn một cây phân tích quen thuộc. Chuyển sang ParselCrawler nếu bạn thích XPath, HttpCrawler nếu bạn muốn các byte thô, và chỉ sử dụng crawler Playwright khi trang thật sự cần một trình duyệt.

H: Crawlee khác gì so với việc tự viết vòng lặp?

Crawlee cung cấp hàng đợi yêu cầu, loại bỏ URL trùng lặp, đồng thời giới hạn độ đồng thời và bảo tồn tập dữ liệu. Trong lần chạy ở trên, enqueue_links(selector="li.next a") đã đi qua ba trang mà không cần trình xử lý tạo dựng một URL nào hoặc theo dõi các trang mà nó đã thấy.

H: Tại sao tập dữ liệu của tôi lại chứa kết quả từ một lần chạy trước đó?

Bởi vì bộ nhớ của Crawlee được chia sẻ theo quá trình và purge_on_start chỉ được kích hoạt một lần khi bộ nhớ lần đầu tiên được mở, không phải theo từng crawler. Hai crawler trong một kịch bản chia sẻ một tập dữ liệu và một hàng đợi yêu cầu. Cung cấp cho mỗi cái một Configuration(storage_dir=...), hoặc chạy một crawler trên mỗi quá trình.

H: Tôi có phải chuyển sang PlaywrightCrawler cho các trang JavaScript không?

Không. PlaywrightCrawler là một lựa chọn, nhưng nó thay đổi lớp crawler và ngữ cảnh mà trình xử lý của bạn nhận được. Việc triển khai giao diện HttpClient của Crawlee chỉ thay đổi cách mà byte được lấy, đó là lý do tại sao trình xử lý trong hướng dẫn này đã chuyển từ 0 mục sang 10 mà không cần chỉnh sửa.

H: Crawlee ghi đầu ra ở đâu?

Mặc định trong ./storage, với các tập dữ liệu trong storage/datasets/. crawler.get_data() đọc lại tập dữ liệu trong cùng một quá trình, và Configuration(storage_dir=...) di chuyển toàn bộ cây đến một nơi khác.

Tại Scrapless, chúng tôi chỉ truy cập dữ liệu có sẵn công khai trong khi tuân thủ nghiêm ngặt các luật, quy định và chính sách bảo mật trang web hiện hành. Nội dung trong blog này chỉ nhằm mục đích trình diễn và không liên quan đến bất kỳ hoạt động bất hợp pháp hoặc vi phạm nào. Chúng tôi không đảm bảo và từ chối mọi trách nhiệm đối với việc sử dụng thông tin từ blog này hoặc các liên kết của bên thứ ba. Trước khi tham gia vào bất kỳ hoạt động cạo nào, hãy tham khảo ý kiến ​​cố vấn pháp lý của bạn và xem xét các điều khoản dịch vụ của trang web mục tiêu hoặc có được các quyền cần thiết.

Bài viết phổ biến nhất

Danh mục