Spider chạy bốn mươi phút, log toàn 200 OK, nhưng item xuất ra rỗng vì response nào cũng là trang xác minh người dùng. Chỗ sửa đúng không nằm trong hàm parse mà ở tầng downloader middleware: chặn response, bắt sitekey, gọi CaptchaAI lấy token rồi phát lại request — spider không cần biết chuyện đó vừa xảy ra.
Bài này dựng một CaptchaAIMiddleware cho reCAPTCHA v2 và Cloudflare Turnstile, kèm settings, spider mẫu và một spider middleware phụ đưa token xuống callback. Luồng gọn trong bốn bước: bắt sitekey, gửi task tới in.php, polling (chủ động hỏi kết quả định kỳ) res.php, tạo lại request với token.
Vì sao nên giải CAPTCHA ở tầng middleware thay vì trong spider
Trong dự án nhiều spider, nhét đoạn gọi API giải CAPTCHA vào từng hàm parse là cách nhanh nhất để có bốn bản sao cùng một logic thử lại, không bản nào giống bản nào. Đẩy xuống downloader middleware, bạn được ba thứ:
- Spider quay lại đúng vai trò parse dữ liệu; phần giải CAPTCHA nằm ngoài tầm nhìn của nó.
- Logic thử lại, timeout và thống kê nằm ở một chỗ, áp dụng cho mọi domain.
- Khi trang mục tiêu đổi từ reCAPTCHA v2 sang Turnstile, bạn sửa một dictionary, không sửa từng spider.
Downloader middleware bắt sitekey và gọi CaptchaAI
Middleware bên dưới hook vào process_response, so response với hai pattern sitekey, gọi CaptchaAI rồi phát lại request kèm token, và dùng signal spider_closed để in thống kê.
# middlewares.py
import re
import time
import logging
import requests as http_requests
from scrapy import signals
from scrapy.http import HtmlResponse
logger = logging.getLogger(__name__)
class CaptchaAIMiddleware:
"""Scrapy downloader middleware for automatic CAPTCHA solving."""
CAPTCHA_PATTERNS = [
(r'data-sitekey="([^"]+)"', "recaptcha"),
(r"cf-turnstile.*?data-sitekey=\"([^\"]+)\"", "turnstile"),
]
def __init__(self, api_key, max_retries=2):
self.api_key = api_key
self.max_retries = max_retries
self.stats = {"detected": 0, "solved": 0, "failed": 0}
@classmethod
def from_crawler(cls, crawler):
api_key = crawler.settings.get("CAPTCHAAI_API_KEY")
if not api_key:
raise ValueError("CAPTCHAAI_API_KEY setting is required")
middleware = cls(
api_key=api_key,
max_retries=crawler.settings.getint("CAPTCHAAI_MAX_RETRIES", 2),
)
crawler.signals.connect(middleware.spider_closed, signal=signals.spider_closed)
return middleware
def process_response(self, request, response, spider):
"""Check response for CAPTCHA and solve if found."""
if not isinstance(response, HtmlResponse):
return response
body = response.text
for pattern, captcha_type in self.CAPTCHA_PATTERNS:
match = re.search(pattern, body)
if match:
sitekey = match.group(1)
self.stats["detected"] += 1
logger.info(
f"CAPTCHA ({captcha_type}) on {response.url}, solving..."
)
retries = request.meta.get("captcha_retries", 0)
if retries >= self.max_retries:
self.stats["failed"] += 1
logger.error(f"Max CAPTCHA retries on {response.url}")
return response
token = self._solve(captcha_type, sitekey, response.url)
if token:
self.stats["solved"] += 1
# Re-request with token
new_request = request.copy()
new_request.meta["captcha_retries"] = retries + 1
new_request.meta["captcha_token"] = token
new_request.method = "POST"
new_request.body = f"g-recaptcha-response={token}"
new_request.headers[b"Content-Type"] = b"application/x-www-form-urlencoded"
new_request.dont_filter = True
return new_request
else:
self.stats["failed"] += 1
return response
def _solve(self, captcha_type, sitekey, pageurl):
"""Solve CAPTCHA via CaptchaAI."""
method_map = {
"recaptcha": {"method": "userrecaptcha", "googlekey": sitekey},
"turnstile": {"method": "turnstile", "sitekey": sitekey},
}
params = method_map.get(captcha_type)
if not params:
return None
try:
resp = http_requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"pageurl": pageurl,
"json": 1,
**params,
}, timeout=30)
result = resp.json()
if result.get("status") != 1:
logger.error(f"Submit error: {result.get('request')}")
return None
task_id = result["request"]
time.sleep(10)
for _ in range(24):
resp = http_requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key, "action": "get",
"id": task_id, "json": 1,
}, timeout=15)
data = resp.json()
if data.get("status") == 1:
return data["request"]
if data["request"] != "CAPCHA_NOT_READY":
return None
time.sleep(5)
except Exception as e:
logger.error(f"Solve exception: {e}")
return None
def spider_closed(self, spider):
"""Log CAPTCHA statistics on spider close."""
logger.info(
f"CAPTCHA Stats — Detected: {self.stats['detected']}, "
f"Solved: {self.stats['solved']}, "
f"Failed: {self.stats['failed']}"
)
Ba chi tiết đáng đọc kỹ. from_crawler bắt buộc phải thấy CAPTCHAAI_API_KEY — thiếu key thì spider dừng ngay thay vì chạy nửa đêm rồi mới hỏng. captcha_retries nằm trong request.meta nên số lần thử đếm theo từng request. Và Request phát lại phải có dont_filter = True, nếu không Scrapy coi URL đã thăm rồi bỏ qua.
Hàm _solve đi đúng luồng chuẩn của CaptchaAI: POST tới in.php với method tương ứng (userrecaptcha hoặc turnstile), nhận id task, chờ 10 giây rồi hỏi res.php mỗi 5 giây. Hai mươi bốn vòng là dư: theo số liệu công bố, Turnstile thường xong dưới 10 giây và reCAPTCHA v2 dưới 60 giây, với tỷ lệ giải thành công cao trên các loại được hỗ trợ.
Cấu hình settings.py cho spider hay gặp CAPTCHA
Middleware chỉ chạy khi được khai báo trong DOWNLOADER_MIDDLEWARES, và timeout mặc định của Scrapy quá ngắn cho một vòng giải CAPTCHA.
# settings.py
# CaptchaAI configuration
CAPTCHAAI_API_KEY = "YOUR_API_KEY" # Better: use env variable
CAPTCHAAI_MAX_RETRIES = 2
# Enable the middleware
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.CaptchaAIMiddleware": 600,
}
# Increase timeouts for CAPTCHA solving
DOWNLOAD_TIMEOUT = 180
# Rate limiting
DOWNLOAD_DELAY = 3
RANDOMIZE_DOWNLOAD_DELAY = True
CONCURRENT_REQUESTS = 4
CONCURRENT_REQUESTS_PER_DOMAIN = 2
DOWNLOAD_TIMEOUT = 180 là mức an toàn vì một request có thể phải chờ hết vòng polling. Nếu bạn còn middleware tự viết khác, chạy spider với -L DEBUG và đọc dòng log Enabled downloader middlewares để xác nhận thứ tự thực tế của priority 600.
Chọn số thread khớp với CONCURRENT_REQUESTS
CaptchaAI tính tiền theo thread (luồng giải đồng thời), không theo từng lượt giải: mỗi gói cho một số thread cố định và số lượt giải không giới hạn. Với CONCURRENT_REQUESTS = 4, nhiều nhất bốn request chạm CAPTCHA cùng lúc, nên BASIC ($15/tháng, 5 thread) đủ cho một spider đơn lẻ. Chạy song song nhiều job thì cộng CONCURRENT_REQUESTS rồi chọn theo tổng — quanh mức 30–40 request đồng thời, ADVANCE ($90/tháng, 50 thread) là vừa tay. Giá và hóa đơn đều bằng USD, đừng quy đổi sang VND.
Spider chỉ còn lo việc parse dữ liệu
Khi middleware đã gánh phần CAPTCHA, spider gọn lại như bình thường: chọn selector, sinh item, đi tiếp.
# spiders/product_spider.py
import scrapy
class ProductSpider(scrapy.Spider):
name = "products"
start_urls = ["https://example.com/products"]
def parse(self, response):
"""Parse product listing page."""
# The middleware handles CAPTCHAs automatically
# This method only deals with parsing
for product in response.css("div.product-card"):
yield {
"name": product.css(".name::text").get("").strip(),
"price": product.css(".price::text").get("").strip(),
"url": response.urljoin(product.css("a::attr(href)").get("")),
}
# Follow pagination
next_page = response.css("a.next-page::attr(href)").get()
if next_page:
yield scrapy.Request(response.urljoin(next_page))
def parse_product(self, response):
"""Parse individual product page."""
# Access CAPTCHA token if middleware solved one
token = response.meta.get("captcha_token")
if token:
self.logger.info(f"Page accessed after CAPTCHA solve: {response.url}")
yield {
"title": response.css("h1::text").get("").strip(),
"description": response.css(".description::text").get("").strip(),
"price": response.css(".price::text").get("").strip(),
}
parse_product vẫn đọc được captcha_token từ response.meta — chỗ hữu ích để ghi log những URL nào phải giải CAPTCHA, từ đó biết chi phí rơi vào domain nào.
Đưa token xuống các callback sinh ra sau đó
Downloader middleware giải CAPTCHA cho đúng một request. Nếu callback sinh tiếp request con dùng lại cùng phiên, thêm một spider middleware nhỏ để token đi theo.
class CaptchaTokenSpiderMiddleware:
"""Pass CAPTCHA tokens to spider callbacks."""
def process_spider_input(self, response, spider):
"""Add CAPTCHA token to response meta if available."""
token = response.meta.get("captcha_token")
if token:
spider.logger.debug(f"CAPTCHA token available for {response.url}")
return None
def process_spider_output(self, response, result, spider):
"""Forward token to new requests from this response."""
token = response.meta.get("captcha_token")
for item_or_request in result:
if isinstance(item_or_request, scrapy.Request) and token:
item_or_request.meta.setdefault("parent_captcha_token", token)
yield item_or_request
process_spider_output gắn parent_captcha_token vào mọi Request sinh ra từ response đã giải. Nhớ đăng ký lớp này trong SPIDER_MIDDLEWARES, không phải DOWNLOADER_MIDDLEWARES — đây là lỗi hay gặp nhất khi ghép hai lớp.
Ví dụ vận hành: pipeline theo dõi giá chạy ban đêm
Một đội data ba người ở TP.HCM chạy Scrapy mỗi đêm để đối chiếu giá niêm yết công khai của chính danh mục sản phẩm công ty đang bán trên Shopee, Lazada và Tiki. Một tỷ lệ nhỏ response trả về trang xác minh, nên sáng hôm sau bảng dữ liệu thủng lỗ chỗ và ai đó phải chạy lại bằng tay.
Sau khi bật CaptchaAIMiddleware, dòng CAPTCHA Stats — Detected / Solved / Failed cuối mỗi lần chạy trở thành chỉ số vận hành: Detected tăng đều theo tuần nghĩa là trang mục tiêu siết kiểm tra, Failed bật lên đột ngột thường là sitekey đã đổi. Nếu dữ liệu có chạm tới thông tin cá nhân, Nghị định 13/2023/NĐ-CP là lý do thực tế để log tối thiểu: thời điểm, URL, loại CAPTCHA và trạng thái giải.
Xử lý sự cố thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
ValueError khi spider khởi động |
Thiếu CAPTCHAAI_API_KEY |
Nạp key từ biến môi trường |
Max CAPTCHA retries lặp lại |
Form cần field khác ngoài token | Đối chiếu request thật trong DevTools |
ERROR_WRONG_GOOGLEKEY |
Regex bắt nhầm data-sitekey |
Thu hẹp pattern về khối chứa CAPTCHA |
| Thông lượng tụt khi CAPTCHA dày | Request đồng thời vượt số thread | Giảm CONCURRENT_REQUESTS hoặc nâng gói |
Câu hỏi thường gặp
Downloader middleware và spider middleware khác nhau ở đâu?
Downloader middleware nằm giữa engine và mạng nên thấy response thô và có quyền phát lại request — đúng chỗ để giải CAPTCHA. Spider middleware nằm giữa engine và spider, chỉ hợp để chuyển token xuống callback.
Spider chạy 4 request đồng thời thì cần gói nào?
BASIC ($15/tháng, 5 thread) là đủ, vì thread chỉ bị chiếm trong lúc CaptchaAI đang giải. Chạy nhiều spider song song thì cộng CONCURRENT_REQUESTS của mọi job rồi chọn theo tổng.
Chờ token có làm nghẽn Scrapy không?
Có. Các lệnh gọi HTTP trong _solve là đồng bộ nên chúng chặn luồng khi polling. Với crawl lớn, giữ CONCURRENT_REQUESTS vừa phải hoặc tách phần giải sang worker bất đồng bộ.
CaptchaAI giải được những loại CAPTCHA nào?
reCAPTCHA v2 và v3 (kể cả Enterprise), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, CAPTCHA ảnh/OCR, grid-image, BLS; CaptchaFox, Friendly Captcha, Lemin ở giai đoạn beta. CaptchaAI không hỗ trợ hCaptcha và FunCaptcha, còn GeeTest v4 mới ở trạng thái sắp ra mắt.
Vì sao request thử lại vẫn nhận về trang CAPTCHA?
Token đúng nhưng gửi sai chỗ là nguyên nhân phổ biến nhất: form có thể cần thêm trường ẩn, đúng header Referer, hoặc token phải đi trong request AJAX. Ghi lại một request thành công làm bằng tay rồi khớp từng field.
Hướng dẫn liên quan
Bắt đầu với Scrapy và CaptchaAI — lấy API key của bạn.