Vòng lặp requests cộng BeautifulSoup như ở bài trước về crawl bằng requests và BeautifulSoup đủ cho vài chục trang, nhưng khi cần hàng nghìn trang thì bạn sẽ phải tự viết hàng đợi, lọc trùng, retry, giới hạn tốc độ và lưu dữ liệu. Scrapy là framework Python làm sẵn những việc đó trên nền Twisted (thư viện lập trình bất đồng bộ không chặn, xem tài liệu Deferred của Twisted). Bài này đi từ kiến trúc đến một dự án hoàn chỉnh: một spider lấy danh mục sách, ba item pipeline (chuẩn hoá giá, lọc trùng, ghi SQLite), một downloader middleware, một spider middleware, và cấu hình lịch sự với robots.txt, download delay, AutoThrottle. Mọi lệnh đã chạy thật trên Python 3.10, Scrapy 2.6.1 (phát hành 01/03/2022) trên máy 16 GB RAM, đích là books.toscrape.com, trang dựng sẵn cho việc thực hành.

Kiến trúc Scrapy: engine, scheduler, downloader và hai lớp middleware

Theo tài liệu kiến trúc Scrapy, hệ thống gồm các thành phần sau, và hiểu chúng là chìa khoá để biết nên đặt logic của mình ở đâu:

Thành phầnVai tròBạn viết gì ở đây
EngineĐiều phối luồng dữ liệu giữa mọi thành phầnKhông viết, chỉ cấu hình
SchedulerXếp hàng request, trả request kế tiếp cho engineHiếm khi tuỳ biến
DownloaderTải trang, trả responseThường chỉ chỉnh settings
SpiderPhân tích response, sinh item và request mớiSelector và logic đi theo liên kết
Item pipelineLàm sạch, kiểm tra, lọc trùng, lưu itemXử lý dữ liệu sau khi bóc tách
Downloader middlewareMóc nằm giữa engine và downloaderSửa request/response: header, proxy, retry, đo thời gian
Spider middlewareMóc nằm giữa engine và spiderSửa đầu vào/đầu ra của spider

Luồng dữ liệu có chín bước, tóm gọn: engine lấy request ban đầu từ spider, đưa vào scheduler, rồi lấy request kế tiếp để gửi qua downloader middleware tới downloader (process_request). Response quay về qua downloader middleware (process_response), rồi qua spider middleware (process_spider_input) tới spider. Spider trả về item và request mới qua process_spider_output; engine chuyển item cho pipeline và request mới cho scheduler. Vòng lặp kết thúc khi scheduler hết request. Điểm cần nhớ: spider chỉ sinh request, còn việc tải do downloader làm bất đồng bộ, nên một spider không bị chặn khi chờ một trang chậm.

Tạo dự án Scrapy và viết spider đầu tiên

Lệnh scrapy startproject sinh ra khung dự án với scrapy.cfg và một package chứa items.py, middlewares.py, pipelines.py, settings.py cùng thư mục spiders/ (đúng như hướng dẫn chính thức). Môi trường dựng theo bài Môi trường Python cho dự án dữ liệu và AI: venv riêng, mọi phiên bản được ghim trong requirements.txt. Dòng đầu của scrapy version cho biết bản đang dùng:

$ scrapy version -v | head -3
Scrapy       : 2.6.1
lxml         : 4.8.0.0
libxml2      : 2.9.13

Trước hết là Item, nơi khai báo các trường dữ liệu. Item giúp bắt lỗi chính tả tên trường sớm hơn so với dict thuần:

import scrapy


class BookItem(scrapy.Item):
    title = scrapy.Field()
    price = scrapy.Field()      # chuỗi thô từ HTML, ví dụ "£51.77"; pipeline chuyển thành số
    rating = scrapy.Field()
    in_stock = scrapy.Field()
    url = scrapy.Field()

Tiếp theo là spider. Lớp con của scrapy.Spider cần name duy nhất và start_urls; Scrapy gọi parse() cho mỗi response. Các selector CSS của Scrapy (thư viện parsel) cho phép viết ::attr(title) và ::text để lấy thuộc tính hoặc văn bản trực tiếp. response.follow tự ghép URL tương đối, thay cho urljoin thủ công ở bài trước. Có một chi tiết cố ý: tôi cho hai URL bắt đầu là index.html và catalogue/page-1.html, vốn là hai địa chỉ khác nhau nhưng cùng nội dung, để xem pipeline lọc trùng làm việc.

import scrapy

from shop.items import BookItem

RATING = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


class BooksSpider(scrapy.Spider):
    """Crawl danh mục books.toscrape.com (site dành cho thực hành crawl)."""
    name = "books"
    # index.html và catalogue/page-1.html cùng nội dung nhưng khác URL:
    # dupefilter của request không bắt được, pipeline lọc trùng sẽ bắt.
    start_urls = [
        "https://books.toscrape.com/index.html",
        "https://books.toscrape.com/catalogue/page-1.html",
    ]
    custom_settings = {"DEPTH_LIMIT": 1}     # từ mỗi trang bắt đầu, đi thêm tối đa 1 trang "next"

    def parse(self, response):
        for card in response.css("article.product_pod"):
            yield BookItem(
                title=card.css("h3 a::attr(title)").get(),
                price=card.css(".price_color::text").get(),
                rating=RATING.get(card.css("p.star-rating::attr(class)").get().split()[-1]),
                in_stock="In stock" in "".join(card.css(".availability::text").getall()),
                url=response.urljoin(card.css("h3 a::attr(href)").get()),
            )
        next_href = response.css("li.next a::attr(href)").get()
        if next_href:
            yield response.follow(next_href, callback=self.parse)

Có thể thử selector trước khi viết spider bằng scrapy shell. Lệnh dưới đây mở trang một, chạy một biểu thức print rồi thoát (--nolog tắt log để chỉ còn kết quả), cho thấy cấu trúc HTML đúng như giả định. Dòng None ở cuối không phải kết quả của biểu thức: đó là giá trị trả về của -c mà shell in ra sau khi chạy xong:

$ scrapy shell https://books.toscrape.com/catalogue/page-1.html --nolog -c "print(len(response.css('article.product_pod')), response.css('article.product_pod h3 a::attr(title)').get(), response.css('article.product_pod p.star-rating::attr(class)').get(), response.css('li.next a::attr(href)').get())"
20 A Light in the Attic star-rating Three page-2.html
None

Với -c, shell chạy đoạn mã đưa vào và in bốn giá trị: 20 thẻ sách, tiêu đề cuốn đầu, class sao ở dạng chuỗi star-rating Three (spider tách phần cuối), và liên kết "next" là page-2.html.

Item pipeline: làm sạch, lọc trùng và lưu dữ liệu

Theo tài liệu item pipeline, mỗi pipeline là một lớp có phương thức process_item(item, spider): trả item để chuyển tiếp, hoặc ném DropItem để bỏ. Các hook open_spider và close_spider phù hợp để mở và đóng kết nối, còn from_crawler cho phép đọc settings. Thứ tự chạy do số trong ITEM_PIPELINES quyết định: số nhỏ chạy trước, thông lệ nằm trong khoảng 0 đến 1000. Ba pipeline của dự án:

import sqlite3
from decimal import Decimal

from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem


class PricePipeline:
    """Chuẩn hoá giá "£51.77" thành số; bỏ item thiếu giá."""

    def process_item(self, item, spider):
        a = ItemAdapter(item)
        raw = a.get("price")
        if not raw:
            raise DropItem(f"thiếu giá: {a.get('url')}")
        a["price"] = float(Decimal(raw.replace("£", "").replace("Â", "").strip()))
        return item


class DuplicatesPipeline:
    """Lọc trùng theo URL sách, giữ trạng thái trong một set cho mỗi lần chạy."""

    def __init__(self):
        self.seen = set()

    def process_item(self, item, spider):
        url = ItemAdapter(item)["url"]
        if url in self.seen:
            raise DropItem(f"trùng: {url}")
        self.seen.add(url)
        return item


class SQLitePipeline:
    """Ghi vào SQLite; đường dẫn lấy từ settings qua from_crawler."""

    def __init__(self, path):
        self.path = path

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.get("SQLITE_PATH", "books.db"))

    def open_spider(self, spider):
        self.conn = sqlite3.connect(self.path)
        self.conn.execute("DROP TABLE IF EXISTS books")
        self.conn.execute("CREATE TABLE books (url TEXT PRIMARY KEY, title TEXT, price REAL, rating INTEGER, in_stock INTEGER)")

    def close_spider(self, spider):
        self.conn.commit()
        self.conn.close()

    def process_item(self, item, spider):
        a = ItemAdapter(item)
        self.conn.execute("INSERT INTO books VALUES (?,?,?,?,?)",
                          (a["url"], a["title"], a["price"], a["rating"], int(a["in_stock"])))
        return item

Chú ý ba điểm. PricePipeline chạy đầu (100) để các bước sau luôn thấy giá là số. DuplicatesPipeline (200) giữ tập URL đã thấy: pipeline này tồn tại một lần cho mỗi lần chạy, nên trạng thái trong bộ nhớ chỉ lọc trùng trong cùng một lần crawl; muốn lọc trùng giữa các lần chạy phải dựa vào cơ sở dữ liệu. SQLitePipeline (300) dùng from_crawler để lấy đường dẫn từ settings và đóng kết nối trong close_spider. Khoá chính theo url còn đóng vai lớp bảo vệ cuối: nếu lọc trùng lọt, SQLite sẽ báo lỗi thay vì lưu hai dòng.

Ba giới hạn của đoạn mã trên quyết định khi nào nó đủ dùng. Thứ nhất, giá: Decimal giúp phân tích chuỗi chính xác, nhưng ép sang float để lưu cột REAL làm mất lợi thế đó. Với số tiền thật (đơn hàng, thanh toán) nên lưu số nguyên theo đơn vị nhỏ nhất (đồng, xu) hoặc lưu chuỗi Decimal; ở đây giá sách chỉ để minh hoạ. Lệnh replace("Â", "") là biện pháp phòng thủ: nếu một trang bị giải mã sai bảng mã, ký hiệu £ có thể thành "£"; trong lần chạy này trang trả về UTF-8 đúng nên nó không có tác dụng.

Thứ hai, sqlite3 là thư viện đồng bộ, mỗi lần INSERT chặn reactor của Twisted trong thời gian ngắn. Với vài chục nghìn dòng thì chấp nhận được, nhưng quy mô lớn nên ghi theo lô hoặc dùng twisted.enterprise.adbapi để chạy truy vấn trong thread pool.

Thứ ba, commit() chỉ gọi ở close_spider: nếu tiến trình bị kill giữa chừng, dữ liệu trong giao dịch đang mở sẽ mất; muốn an toàn hơn, commit sau mỗi N item.

Middleware: downloader và spider

Hai loại middleware khác nhau ở chỗ đứng. Downloader middleware thấy request trước khi tải và response sau khi tải. Giá trị trả về của process_request quyết định luồng: None để đi tiếp, một Response để bỏ qua việc tải, một Request để xếp lại lịch, hoặc ném IgnoreRequest. Số trong DOWNLOADER_MIDDLEWARES cũng là thứ tự: process_request chạy theo số tăng dần, process_response theo số giảm dần. Muốn tắt middleware có sẵn, gán None. Scrapy đã có sẵn nhiều middleware trong DOWNLOADER_MIDDLEWARES_BASE: robots.txt (100), user-agent (500), retry (550), redirect (600), cookies (700)... Bạn thấy chúng trong output ở phần chạy thật bên dưới.

Hai middleware tự viết dưới đây: một đo thời gian cho từng response (downloader), một đếm số item và request mà spider sinh ra (spider). Cả hai đều chỉ quan sát, không đổi dữ liệu:

import time

from scrapy import Request


class TimingDownloaderMiddleware:
    """Downloader middleware: đo thời gian từ lúc request đi qua tới lúc response về."""

    def process_request(self, request, spider):
        request.meta["t0"] = time.perf_counter()
        return None                      # None: tiếp tục chuỗi middleware và tải bình thường

    def process_response(self, request, response, spider):
        dt = time.perf_counter() - request.meta["t0"]
        spider.logger.info("TIMING %s %s %.2fs", response.status, response.url, dt)
        spider.crawler.stats.inc_value("custom/response_time_total_ms", int(dt * 1000))
        return response                  # phải trả Response (hoặc Request) để chuỗi tiếp tục


class CountSpiderMiddleware:
    """Spider middleware: đếm số item và request mà spider sinh ra cho từng response."""

    def process_spider_output(self, response, result, spider):
        n_items = n_reqs = 0
        for obj in result:
            if isinstance(obj, Request):
                n_reqs += 1
            else:
                n_items += 1
            yield obj
        spider.logger.info("SPIDER-MW %s -> %d item, %d request mới", response.url, n_items, n_reqs)

Cần lưu ý rằng process_response luôn phải trả về Response hoặc Request; quên return là lỗi rất hay gặp và làm spider "im lặng" không nhận được gì.

Cấu hình lịch sự: robots.txt, delay và AutoThrottle

Các giá trị mặc định của Scrapy thiên về tốc độ. Kiểm tra bằng scrapy settings --get ngoài dự án cho: DOWNLOAD_DELAY=0, CONCURRENT_REQUESTS=16, CONCURRENT_REQUESTS_PER_DOMAIN=8, DOWNLOAD_TIMEOUT=180, RETRY_TIMES=2, và ROBOTSTXT_OBEY=False. Riêng ROBOTSTXT_OBEY, template của startproject bật nó thành True trong settings.py, nhưng spider chạy bằng scrapy runspider ngoài dự án sẽ dùng mặc định False, nên hãy luôn đặt tường minh. Với một site nhỏ, 8 request đồng thời không delay là quá nặng. AutoThrottle giải quyết bằng cách tự điều chỉnh: độ trễ mục tiêu bằng độ trễ phản hồi chia cho mức đồng thời mục tiêu, delay mới là trung bình của delay cũ và mục tiêu, không bao giờ thấp hơn DOWNLOAD_DELAY hay cao hơn AUTOTHROTTLE_MAX_DELAY.

BOT_NAME = "shop"
SPIDER_MODULES = ["shop.spiders"]
NEWSPIDER_MODULE = "shop.spiders"

USER_AGENT = "SudoBlogBot/1.0 (+https://sudo.vn; bai-viet-huong-dan)"
ROBOTSTXT_OBEY = True                   # tôn trọng robots.txt
DOWNLOAD_DELAY = 1.0                    # tối thiểu 1 giây giữa hai request tới cùng site
CONCURRENT_REQUESTS_PER_DOMAIN = 2
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 10.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
RETRY_TIMES = 2
DOWNLOAD_TIMEOUT = 15
TELNETCONSOLE_ENABLED = False

ITEM_PIPELINES = {
    "shop.pipelines.PricePipeline": 100,
    "shop.pipelines.DuplicatesPipeline": 200,
    "shop.pipelines.SQLitePipeline": 300,
}
DOWNLOADER_MIDDLEWARES = {
    "shop.middlewares.TimingDownloaderMiddleware": 543,
}
SPIDER_MIDDLEWARES = {
    "shop.middlewares.CountSpiderMiddleware": 543,
}
SQLITE_PATH = "books.db"
FEED_EXPORT_ENCODING = "utf-8"
LOG_LEVEL = "INFO"
LOG_FORMATTER = "shop.logformat.ShortLogFormatter"

Nhận diện bot qua USER_AGENT có địa chỉ liên hệ, đặt DOWNLOAD_DELAY một giây, giới hạn hai request đồng thời mỗi tên miền và bật AutoThrottle là bộ cấu hình tối thiểu nên có cho một dự án crawl có trách nhiệm; quy ước robots.txt được chuẩn hoá trong RFC 9309. Để thấy RobotsTxtMiddleware thực sự chặn, tôi chạy một spider hai request tới httpbin.org, nơi robots.txt khai Disallow: /deny:

"""Spider tối thiểu để thấy RobotsTxtMiddleware chặn request: chạy bằng `scrapy runspider`."""
import scrapy


class RobotsSpider(scrapy.Spider):
    name = "robots_demo"
    custom_settings = {
        "ROBOTSTXT_OBEY": True,
        "USER_AGENT": "SudoBlogBot/1.0 (+https://sudo.vn; bai-viet-huong-dan)",
        "LOG_LEVEL": "INFO",
        "TELNETCONSOLE_ENABLED": False,
    }

    def start_requests(self):
        for path in ("/get", "/deny"):
            yield scrapy.Request("https://httpbin.org" + path, callback=self.ok, errback=self.err)

    def ok(self, response):
        self.logger.info("OK  %s -> HTTP %s", response.url, response.status)

    def err(self, failure):
        self.logger.info("ERR %s -> %s", failure.request.url, failure.type.__name__)
[robots_demo] INFO: ERR https://httpbin.org/deny -> IgnoreRequest
[robots_demo] INFO: OK  https://httpbin.org/get -> HTTP 200
 'robotstxt/forbidden': 1,
 'robotstxt/request_count': 1,
 'robotstxt/response_count': 1,
 'robotstxt/response_status_count/200': 1,

Output trên đã lọc, chỉ giữ các dòng liên quan (hai dòng log của spider và bốn dòng thống kê robotstxt/*); log đầy đủ còn nhiều dòng khởi động khác. Request tới /deny không bao giờ được gửi đi: nó bị huỷ với IgnoreRequest và thống kê ghi robotstxt/forbidden: 1. Đây là lý do nên có errback, vì request bị chặn sẽ không vào callback.

Chạy spider và đọc kết quả

Lệnh scrapy crawl books -O ../books.json chạy spider và ghi feed JSON. Cờ -O ghi đè file, còn -o nối thêm (và JSON nối thêm sẽ thành file hỏng, nên dùng JSON Lines khi cần nối). Các phần dưới đây trích từ log thật, đã rút gọn: tôi bỏ tiền tố thời gian ở đầu mỗi dòng và dòng Versions; đầu tiên là danh sách middleware và pipeline đang bật. Lưu ý hai middleware tự viết nằm đúng chỗ theo số thứ tự 543:

[scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
 'shop.middlewares.TimingDownloaderMiddleware',
 'scrapy.downloadermiddlewares.retry.RetryMiddleware',
 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware',
 'scrapy.downloadermiddlewares.stats.DownloaderStats']
[scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
 'shop.middlewares.CountSpiderMiddleware',
 'scrapy.spidermiddlewares.referer.RefererMiddleware',
 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
 'scrapy.spidermiddlewares.depth.DepthMiddleware']
[scrapy.middleware] INFO: Enabled item pipelines:
['shop.pipelines.PricePipeline',
 'shop.pipelines.DuplicatesPipeline',
 'shop.pipelines.SQLitePipeline']

Phần diễn biến tải trang như sau (bản gốc có 20 dòng "DROP trùng", tôi chỉ giữ hai dòng đầu và thay phần còn lại bằng một dòng "..."):

[scrapy.core.engine] INFO: Spider opened
[scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
[books] INFO: TIMING 404 https://books.toscrape.com/robots.txt 0.89s
[books] INFO: TIMING 200 https://books.toscrape.com/index.html 1.09s
[books] INFO: SPIDER-MW https://books.toscrape.com/index.html -> 20 item, 1 request mới
[books] INFO: TIMING 200 https://books.toscrape.com/catalogue/page-1.html 1.87s
[scrapy.core.scraper] WARNING: DROP trùng: https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
[scrapy.core.scraper] WARNING: DROP trùng: https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
... (còn 18 dòng DROP trùng tương tự)
[books] INFO: SPIDER-MW https://books.toscrape.com/catalogue/page-1.html -> 20 item, 1 request mới
[books] INFO: TIMING 200 https://books.toscrape.com/catalogue/page-2.html 1.70s
[books] INFO: SPIDER-MW https://books.toscrape.com/catalogue/page-2.html -> 20 item, 0 request mới
[scrapy.core.engine] INFO: Closing spider (finished)
[scrapy.extensions.feedexport] INFO: Stored json feed (40 items) in: ../books.json

Một vài quan sát. Request đầu tiên tới robots.txt được trả 404, tức là site không có quy tắc. Các số trong dòng TIMING (0,9 đến 1,9 giây) lớn hơn thời gian mạng thuần vì process_request chạy trước khi request vào hàng đợi của downloader, nơi áp dụng download delay và AutoThrottle: con số này gồm cả thời gian chờ. Dòng SPIDER-MW của trang hai báo "0 request mới" dù trang có nút next: do DEPTH_LIMIT = 1, DepthMiddleware (số 900) lọc request vượt độ sâu trước khi tới middleware của ta (543), vì đầu ra của spider đi qua spider middleware theo thứ tự số giảm dần. Cuối cùng là phần thống kê (finish_time và start_time là giờ UTC):

[scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'custom/response_time_total_ms': 5540,
 'downloader/request_bytes': 1062,
 'downloader/request_count': 4,
 'downloader/request_method_count/GET': 4,
 'downloader/response_bytes': 153751,
 'downloader/response_count': 4,
 'downloader/response_status_count/200': 3,
 'downloader/response_status_count/404': 1,
 'dupefilter/filtered': 1,
 'elapsed_time_seconds': 3.92713,
 'feedexport/success_count/FileFeedStorage': 1,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2026, 11, 9, 14, 48, 8, 993400),
 'item_dropped_count': 20,
 'item_dropped_reasons_count/DropItem': 20,
 'item_scraped_count': 40,
 'log_count/INFO': 16,
 'log_count/WARNING': 20,
 'memusage/max': 70172672,
 'memusage/startup': 70172672,
 'request_depth_max': 1,
 'response_received_count': 4,
 'robotstxt/request_count': 1,
 'robotstxt/response_count': 1,
 'robotstxt/response_status_count/404': 1,
 'scheduler/dequeued': 3,
 'scheduler/dequeued/memory': 3,
 'scheduler/enqueued': 3,
 'scheduler/enqueued/memory': 3,
 'start_time': datetime.datetime(2026, 11, 9, 14, 48, 5, 66270)}

Từ thống kê có thể đối chiếu từng con số: 4 request (1 robots.txt, 2 URL đầu, 1 trang hai), trong đó dupefilter/filtered: 1 là request tới page-2.html bị scheduler loại vì đã được lên lịch từ trang kia. Ba trang, mỗi trang 20 sách, cho 60 item; pipeline bỏ 20 item của page-1.html vì trùng với index.html, còn item_scraped_count là 40. Tổng thời gian khoảng 4 giây cho 4 request, chủ yếu là delay có chủ ý chứ không phải do mạng chậm. Kiểm tra dữ liệu trong SQLite:

$ python verify_db.py
số dòng      : 40
giá trung bình: 34.96
5 sao        : 10
  đắt nhất   : ('Our Band Could Be Your Life: Scenes from the American Indie Underground, 1981-1991', 57.25)
  đắt nhất   : ('Birdsong: A Story in Pictures', 54.64)
  đắt nhất   : ('Sapiens: A Brief History of Humankind', 54.23)

Có đúng 40 dòng khớp với item_scraped_count. Điểm đáng chú ý: có hai tầng lọc trùng khác nhau. Dupefilter của scheduler lọc request trùng theo vân tay của request (thường là phương thức, URL và nội dung), còn DuplicatesPipeline lọc item trùng theo khoá nghiệp vụ. Hai URL khác nhau cho cùng một sách sẽ lọt qua tầng đầu và chỉ bị tầng sau chặn.

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

  • Vài trang tĩnh: khung dự án, settings và pipeline là quá tay; một script requests cộng BeautifulSoup như trong bài trước gọn hơn nhiều.
  • Trang dựng bằng JavaScript: Scrapy chỉ nhận HTML thô như requests. Hãy tìm endpoint JSON mà trang gọi ngầm, hoặc kết hợp trình duyệt tự động như Playwright (chủ đề của một bài sau).
  • Cần ngữ cảnh tương tác: đăng nhập nhiều bước, CAPTCHA, thao tác chuột là những việc không phù hợp với mô hình request/response.
  • Nhóm không quen Twisted: debug lỗi bất đồng bộ đòi hỏi quen với Deferred; chi phí học này đáng kể với đội nhỏ chỉ crawl thỉnh thoảng.

Lỗi thường gặp với Scrapy

  • Request bị bỏ im lặng: trang bị chặn bởi robots.txt hoặc OffsiteMiddleware (khác allowed_domains) chỉ để lại dòng thống kê; hãy đọc phần stats cuối log.
  • Quên yield khi theo liên kết: response.follow(...) chỉ tạo request, không gọi yield thì không có gì xảy ra.
  • Pipeline không chạy: chưa khai báo trong ITEM_PIPELINES, hoặc ném lỗi trong open_spider. Xem dòng "Enabled item pipelines" trong log đầu tiên.
  • Trạng thái trong pipeline tăng mãi: set lọc trùng giữ mọi khoá trong RAM; với hàng triệu item cần dùng DB hoặc Bloom filter.
  • Để mặc định nhanh: không đặt delay và giới hạn đồng thời có thể khiến IP bị chặn và gây hại cho site đích. Cấu hình ở trên là mức khởi điểm hợp lý, hãy chỉnh theo điều khoản và khả năng chịu tải của từng site.
  • Dùng -o với JSON: nối thêm vào file cũ làm hỏng định dạng. Dùng -O hoặc đuôi .jl.

Checklist triển khai Scrapy

  • User-Agent có thông tin liên hệ, ROBOTSTXT_OBEY = True, delay và giới hạn đồng thời hợp lý, AutoThrottle bật.
  • Item khai báo rõ các trường; pipeline đầu làm sạch, pipeline sau lọc trùng và lưu.
  • Có errback cho request quan trọng và theo dõi thống kê cuối mỗi lần chạy.
  • Logic bóc tách kiểm thử được trên HTML đã lưu, không phụ thuộc mạng.
  • Chỉ crawl dữ liệu được phép, không thu thập thông tin cá nhân.

Toàn bộ mã nằm ở thư mục code/s7-03/ (dự án shop/, robots_spider.py, verify_db.py, run_all.sh). Có Scrapy trong tay, bài kế tiếp trở lại với cùng bài toán nhưng ở quy mô lớn hơn: chạy nhiều request đồng thời đúng cách bằng asyncio.

Bài tiếp theo

Đọc tiếp: Crawl bất đồng bộ với asyncio và httpx: giới hạn đồng thời đúng cách.