Tích Hợp

Crawlee + CaptchaAI: Tích hợp Scraping Framework hiện đại

Crawlee không tự giải CAPTCHA. Khung thu thập dữ liệu web (scraping) của Apify lo session, hàng đợi request và retry rất tốt, nhưng ngay khi requestHandler đụng phải reCAPTCHA v2 hay Cloudflare Turnstile, bạn phải tự gắn logic giải vào. Cách nhanh nhất: gọi CaptchaAI ngay trong requestHandler — gửi sitekey và pageurl tới in.php, polling res.php để lấy token, rồi đưa token vào form trước khi Crawlee tiếp tục crawl. Bài này đi qua ba tình huống thực tế — CheerioCrawler, PlaywrightCrawler và session pool — dùng được ngay cho scraping và QA production.

Nhiều đội outsourcing và product tại TP.HCM, Hà Nội áp đúng pattern này để theo dõi giá trên các sàn TMĐT hoặc chạy QA tự động cho hệ thống của chính mình — ví dụ cụ thể ở phần dưới.


Vì sao chọn Crawlee kết hợp CaptchaAI

Crawlee giải quyết tốt phần hạ tầng scraping; phần còn thiếu là CAPTCHA. CaptchaAI bù đắp đúng bốn chỗ đó:

  • Quản lý session tích hợp sẵn — fingerprint ổn định xuyên suốt sau khi CAPTCHA đã được giải.
  • Tự động thử lại (auto-retry) — request thất bại được gửi lại ngay sau khi có token.
  • Hỗ trợ proxy — ghép cùng proxy CaptchaAI khi cần đổi IP theo request.
  • Hàng đợi request — xếp việc giải CAPTCHA song song với crawl, không chặn queue.

Đây là lý do Crawlee + CaptchaAI là cặp phổ biến cho scraping production, thay vì tự viết lại retry và quản lý token từ đầu.


Bước 1: Gắn CaptchaAI vào CheerioCrawler cho trang tĩnh

Với trang không cần render JavaScript, CheerioCrawler là lựa chọn nhẹ nhất. Hàm solveCaptcha() bên dưới thực hiện đúng bốn bước chuẩn của CaptchaAI: gửi task, nhận task ID, polling res.php mỗi 5 giây (tối đa 24 lần, sau khi chờ 15 giây ban đầu để CAPTCHA có thời gian được giải), rồi trả token về cho requestHandler.

const { CheerioCrawler } = require('crawlee');
const https = require('https');

const API_KEY = process.env.CAPTCHAAI_API_KEY;

async function solveCaptcha(sitekey, pageurl) {
    // Submit task
    const submitData = new URLSearchParams({
        key: API_KEY,
        method: 'userrecaptcha',
        googlekey: sitekey,
        pageurl: pageurl,
        json: '1',
    });

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
        method: 'POST',
        body: submitData,
    });
    const submitResult = await submitResp.json();

    if (submitResult.status !== 1) {
        throw new Error(`Submit error: ${submitResult.request}`);
    }

    const taskId = submitResult.request;

    // Poll for result
    await new Promise(r => setTimeout(r, 15000));

    for (let i = 0; i < 24; i++) {
        const pollResp = await fetch(
            `https://ocr.captchaai.com/res.php?key=${API_KEY}&action=get&id=${taskId}&json=1`
        );
        const pollResult = await pollResp.json();

        if (pollResult.status === 1) return pollResult.request;
        if (pollResult.request !== 'CAPCHA_NOT_READY') {
            throw new Error(`Solve error: ${pollResult.request}`);
        }

        await new Promise(r => setTimeout(r, 5000));
    }

    throw new Error('Solve timeout');
}

// Crawlee spider with CAPTCHA handling
const crawler = new CheerioCrawler({
    maxConcurrency: 5,
    requestHandlerTimeoutSecs: 180,

    async requestHandler({ request, $, log }) {
        // Check if page has CAPTCHA
        const captchaDiv = $('[data-sitekey]');

        if (captchaDiv.length > 0) {
            const sitekey = captchaDiv.attr('data-sitekey');
            log.info(`CAPTCHA found on ${request.url}, solving...`);

            const token = await solveCaptcha(sitekey, request.url);
            log.info('CAPTCHA solved, submitting form');

            // Submit form with token
            const formData = new URLSearchParams({
                'g-recaptcha-response': token,
            });

            const resp = await fetch(request.url, {
                method: 'POST',
                body: formData,
            });
            const html = await resp.text();
            // Parse the result page...
        }

        // Extract data
        const title = $('title').text();
        const data = $('table tr').map((i, row) => ({
            col1: $(row).find('td:eq(0)').text().trim(),
            col2: $(row).find('td:eq(1)').text().trim(),
        })).get();

        log.info(`Scraped ${data.length} rows from ${request.url}`);
    },

    failedRequestHandler({ request, log }) {
        log.error(`Failed: ${request.url}`);
    },
});

// Run
(async () => {
    await crawler.run([
        'https://example.com/page1',
        'https://example.com/page2',
    ]);
})();

Điểm cần chú ý: solveCaptcha() ném lỗi khi submitResult.status khác 1 (thường do sai key hoặc sai sitekey), hoặc khi pollResult.request không phải chuỗi CAPCHA_NOT_READY — giữ nguyên chuỗi này khi so sánh, đây là giá trị API trả về thật. requestHandler chỉ tìm [data-sitekey] trước, có mới gọi CaptchaAI, tránh gọi API thừa trên các trang không hề có CAPTCHA.


Bước 2: Xử lý CAPTCHA render bằng JavaScript với PlaywrightCrawler

Nhiều trang chỉ chèn reCAPTCHA sau khi JavaScript chạy xong — CheerioCrawler không thấy vì nó không render DOM thật. PlaywrightCrawler xử lý việc này: đợi trang load xong (waitUntil: 'networkidle'), đọc data-sitekey trực tiếp từ DOM, bơm token vào đúng textarea g-recaptcha-response, rồi gọi lại callback nếu widget có khai báo data-callback.

const { PlaywrightCrawler } = require('crawlee');

const crawler = new PlaywrightCrawler({
    maxConcurrency: 3,
    requestHandlerTimeoutSecs: 180,
    launchContext: {
        launchOptions: {
            headless: true,
            args: [''],
        },
    },

    async requestHandler({ request, page, log }) {
        await page.goto(request.url, { waitUntil: 'networkidle' });

        // Check for reCAPTCHA
        const sitekey = await page.evaluate(() => {
            const el = document.querySelector('[data-sitekey]');
            return el ? el.getAttribute('data-sitekey') : null;
        });

        if (sitekey) {
            log.info(`CAPTCHA detected, solving for ${request.url}`);

            const token = await solveCaptcha(sitekey, request.url);

            // Inject token
            await page.evaluate((t) => {
                const ta = document.querySelector('[name="g-recaptcha-response"]');
                if (ta) {
                    ta.style.display = 'block';
                    ta.value = t;
                }
                // Trigger callback
                const widget = document.querySelector('.g-recaptcha');
                if (widget) {
                    const cb = widget.getAttribute('data-callback');
                    if (cb && typeof window[cb] === 'function') {
                        window[cb](t);
                    }
                }
            }, token);

            await page.click('button[type="submit"]');
            await page.waitForNavigation({ waitUntil: 'networkidle' });
        }

        // Extract data
        const title = await page.title();
        const content = await page.textContent('body');
        log.info(`Page: ${title}, length: ${content.length}`);
    },
});

So với CheerioCrawler, cái giá phải trả ở đây là thời gian khởi động browser thật — vì vậy ví dụ trên hạ maxConcurrency xuống 3 thay vì 5, tránh chiếm hết RAM khi nhiều instance Playwright chạy song song trên cùng một máy.


Bước 3: Giữ token CAPTCHA theo session với session pool

Khi một trang chặn theo phiên (session) thay vì theo từng request riêng lẻ, giải lại CAPTCHA mỗi lần là lãng phí thread. sessionPoolOptions cho Crawlee tái sử dụng cùng một session tối đa 50 lần trước khi đổi sang session mới — token CAPTCHA lưu trong session.userData sống theo đúng vòng đời session đó.

const { CheerioCrawler, Session } = require('crawlee');

const crawler = new CheerioCrawler({
    useSessionPool: true,
    sessionPoolOptions: {
        maxPoolSize: 10,
        sessionOptions: {
            maxUsageCount: 50,
        },
    },

    async requestHandler({ request, $, session, log }) {
        // If blocked, solve CAPTCHA and mark session as usable
        if ($('.captcha-container').length > 0) {
            const sitekey = $('[data-sitekey]').attr('data-sitekey');
            const token = await solveCaptcha(sitekey, request.url);

            // Store token in session for subsequent requests
            session.userData = session.userData || {};
            session.userData.captchaToken = token;
            session.userData.tokenTime = Date.now();

            log.info('CAPTCHA solved, session updated');
        }

        // Normal scraping
        const items = $('div.item').map((i, el) => ({
            name: $(el).find('.name').text().trim(),
            price: $(el).find('.price').text().trim(),
        })).get();

        log.info(`Found ${items.length} items`);
    },
});

Lưu ý quan trọng: token reCAPTCHA thường chỉ có hiệu lực khoảng 2 phút kể từ lúc CaptchaAI trả về. session.userData.tokenTime ghi lại thời điểm nhận token để bạn tự kiểm tra TTL trước khi tái dùng — cache token có TTL, không cache vô thời hạn, để tránh gửi token đã hết hạn vào form.


Ví dụ thực tế: theo dõi giá trên Shopee, Lazada, Tiki bằng Crawlee

Một use case phổ biến ở các đội product và outsourcing tại TP.HCM, Hà Nội là theo dõi giá trên chính danh mục sản phẩm của mình hoặc dữ liệu công khai trên các sàn TMĐT như Shopee, Lazada, Tiki để phục vụ nghiên cứu thị trường. Pattern giống hệt ba bước trên: CheerioCrawler cho trang danh mục còn tĩnh, chuyển sang PlaywrightCrawler khi sàn render giá bằng JavaScript và có CAPTCHA chặn request bất thường. Vì Crawlee chạy tốt trên Apify, nhiều đội deploy actor kèm CaptchaAI qua HTTP API thay vì tự host worker riêng — phần chi phí thread bên dưới giải thích khi nào nên làm vậy. Nếu dữ liệu chạm tới thông tin cá nhân (tên người bán, review có định danh), Nghị định 13/2023/NĐ-CP về bảo vệ dữ liệu cá nhân là lý do nên bật audit log và giảm thiểu dữ liệu lưu trữ ngay từ khâu thiết kế pipeline.


Chi phí chạy CaptchaAI trong Crawlee ở quy mô lớn

CaptchaAI tính phí theo số thread chạy song song, không tính theo từng CAPTCHA — mỗi thread giải không giới hạn trong tháng. maxConcurrency: 5 ở Bước 1 vừa khớp gói BASIC ($15/tháng, 5 thread): mỗi request đang chờ CAPTCHA chiếm một thread tới khi có token. Chạy PlaywrightCrawler với concurrency cao hơn, hoặc nhiều actor song song trên Apify, cần đếm số thread đang mở CAPTCHA cùng lúc — không phải tổng request mỗi ngày — để chọn đúng gói trên trang pricing của CaptchaAI.


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

CheerioCrawler hay PlaywrightCrawler phù hợp hơn khi trang có CAPTCHA?

Ưu tiên CheerioCrawler nếu sitekey đã nằm sẵn trong HTML tĩnh — nhanh và nhẹ tài nguyên hơn nhiều. Chỉ chuyển sang PlaywrightCrawler khi CAPTCHA hoặc chính sitekey được JavaScript chèn vào sau khi trang load, hoặc khi cần click nút submit thật để trigger callback của widget.

Giải CAPTCHA trong Crawlee tốn bao nhiêu mỗi tháng?

Phụ thuộc số thread cần chạy song song, không phụ thuộc số CAPTCHA giải mỗi ngày vì mỗi thread giải không giới hạn. Gói BASIC ($15/tháng, 5 thread) đủ cho crawler nhỏ chạy maxConcurrency: 5 như ví dụ ở Bước 1; scale lên bằng cách nâng gói theo đúng bảng giá CaptchaAI, không phải trả thêm theo từng CAPTCHA giải được.

Token CAPTCHA có tái sử dụng được giữa các session Crawlee không?

Có, trong thời gian ngắn — lưu token vào session.userData như ở Bước 3 và tự kiểm tra tokenTime trước khi dùng lại, vì token reCAPTCHA hết hiệu lực sau khoảng 2 phút. Không nên cache token lâu hơn TTL thực tế của loại CAPTCHA đang giải.

Deploy Crawlee actor kèm CaptchaAI trên Apify có được không?

Được. Actor Crawlee chạy bình thường trên Apify, còn CaptchaAI được gọi qua HTTP API y hệt cách gọi từ máy local — chỉ cần đặt API key làm biến môi trường của actor thay vì hard-code trong code.


Hướng dẫn liên quan


Lấy API key CaptchaAI và gắn vào requestHandler của Crawlee ngay hôm nay — đăng ký tại đây.

Os comentários estão desativados para este artigo.