Hướng Dẫn Thực Hành

Xây dựng Trình tổng hợp danh sách việc làm với CaptchaAI

Một trình tổng hợp việc làm gom tin tuyển dụng từ nhiều job board về một chỗ, chuẩn hóa lại rồi cho phép tìm kiếm — nghe đơn giản, nhưng rào cản lớn nhất khi tự động thu thập là thử thách CAPTCHA chặn ngay ở trang kết quả. Bài này dựng một pipeline Python hoàn chỉnh giải quyết đúng vấn đề đó: scrape nhiều board, gặp reCAPTCHA v2 thì để CaptchaAI giải, sau đó lưu dữ liệu sạch vào SQLite để tra cứu.

Luồng xử lý tách thành bốn phần rõ ràng: một model dữ liệu, một lớp scraper biết tự giải CAPTCHA, các scraper riêng cho từng board, và một runner điều phối. Với một team HR-tech hay agency tuyển dụng ở TP.HCM muốn theo dõi thị trường việc làm công khai, kiến trúc này đủ gọn để chạy trên một máy và mở rộng dần khi thêm nguồn. Nếu bạn lưu dữ liệu có thể nhận dạng cá nhân, hãy ghi log tối thiểu và cân nhắc Nghị định 13/2023/NĐ-CP về bảo vệ dữ liệu cá nhân ngay từ khâu thiết kế lưu trữ.

Pipeline gồm bốn phần

  1. Model dữ liệuJobListingJobDatabase định nghĩa cấu trúc một tin và lớp lưu trữ SQLite.
  2. Lớp scraper tự giải CAPTCHA — tải trang và gọi CaptchaAI khi phát hiện reCAPTCHA v2.
  3. Scraper theo từng board — cấu hình URL và CSS selector cho mỗi nguồn.
  4. Runner điều phối — chạy toàn bộ và ghi kết quả vào database.

Kiến trúc tổng thể của trình tổng hợp

Dữ liệu đi một chiều: nhiều job board đổ vào một scraper có tích hợp bộ giải CAPTCHA, qua bước chuẩn hóa, rồi vào SQLite. Tách rời từng khối giúp bạn thêm board mới hoặc đổi nơi lưu trữ mà không phải viết lại phần còn lại của pipeline.

[Job Board A] ──┐
[Job Board B] ──┼──> Scraper + CAPTCHA Solver ──> Normalizer ──> SQLite DB
[Job Board C] ──┘

Mô hình dữ liệu cho tin tuyển dụng

JobListing là một dataclass mô tả một tin tuyển dụng: tiêu đề, công ty, địa điểm, khoảng lương, nguồn và thời điểm scrape. JobDatabase bọc SQLite lại — bảng jobs đặt ràng buộc UNIQUE trên cột url, nên hai lần scrape cùng một tin sẽ không tạo bản ghi trùng vì INSERT OR IGNORE lo phần đó. Chuẩn hóa ở đây là thống nhất lương về số, gắn nguồn cho mỗi tin và để search() lọc theo từ khóa lẫn địa điểm, nhờ vậy tin từ nhiều board vẫn so sánh được với nhau.

# models.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import sqlite3
import json

@dataclass
class JobListing:
    title: str
    company: str
    location: str
    url: str
    source: str
    salary_min: Optional[float] = None
    salary_max: Optional[float] = None
    posted_date: Optional[str] = None
    description: str = ""
    tags: list = field(default_factory=list)
    scraped_at: str = field(default_factory=lambda: datetime.now().isoformat())

class JobDatabase:
    def __init__(self, db_path="jobs.db"):
        self.conn = sqlite3.connect(db_path)
        self._create_table()

    def _create_table(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                company TEXT NOT NULL,
                location TEXT,
                url TEXT UNIQUE,
                source TEXT,
                salary_min REAL,
                salary_max REAL,
                posted_date TEXT,
                description TEXT,
                tags TEXT,
                scraped_at TEXT
            )
        """)
        self.conn.commit()

    def insert(self, job: JobListing):
        try:
            self.conn.execute(
                """INSERT OR IGNORE INTO jobs
                   (title, company, location, url, source,
                    salary_min, salary_max, posted_date,
                    description, tags, scraped_at)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (job.title, job.company, job.location, job.url,
                 job.source, job.salary_min, job.salary_max,
                 job.posted_date, job.description,
                 json.dumps(job.tags), job.scraped_at),
            )
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass  # Duplicate URL

    def search(self, keyword, location=None):
        query = "SELECT * FROM jobs WHERE title LIKE ?"
        params = [f"%{keyword}%"]
        if location:
            query += " AND location LIKE ?"
            params.append(f"%{location}%")
        query += " ORDER BY scraped_at DESC"
        cursor = self.conn.execute(query, params)
        return cursor.fetchall()

Lớp scraper tự xử lý CAPTCHA

Đây là phần lõi. BaseScraper.fetch() tải trang như bình thường; nếu phát hiện dấu hiệu reCAPTCHA (data-sitekey hoặc g-recaptcha), nó gọi CaptchaAI lấy token rồi POST lại form. Quy trình giải chạy đúng bốn bước quen thuộc của CaptchaAI: trích sitekey từ HTML, gửi task tới in.php với method userrecaptcha kèm googlekeypageurl, polling res.php mỗi 5 giây cho đến khi có kết quả, rồi đưa token vào field g-recaptcha-response. Vòng lặp 24 lần với time.sleep(5) đặt trần chờ khoảng hai phút trước khi báo timeout — đủ rộng cho reCAPTCHA v2 mà không treo tiến trình vô hạn.

# scraper_base.py
import requests
import re
import time
import os

class BaseScraper:
    API_KEY = os.environ["CAPTCHAAI_API_KEY"]

    def __init__(self, source_name):
        self.source = source_name
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                          "AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36",
        })

    def fetch(self, url):
        resp = self.session.get(url, timeout=20)

        if self._has_captcha(resp.text):
            token = self._solve_captcha(url, resp.text)
            resp = self.session.post(url, data={
                "g-recaptcha-response": token,
            }, timeout=30)

        return resp.text

    def _has_captcha(self, html):
        return "data-sitekey" in html or "g-recaptcha" in html

    def _solve_captcha(self, url, html):
        match = re.search(r'data-sitekey="([^"]+)"', html)
        if not match:
            raise ValueError("No sitekey found")

        sitekey = match.group(1)

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.API_KEY,
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": url,
            "json": 1,
        }, timeout=30)
        task_id = resp.json()["request"]
        time.sleep(15)

        for _ in range(24):
            resp = 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":
                raise RuntimeError(data["request"])
            time.sleep(5)

        raise TimeoutError("CAPTCHA solve timeout")

Scraper cho từng job board

GenericJobScraper nhận vào một mẫu URL và một bộ CSS selector, nên cùng một class dùng được cho nhiều board khác nhau — thêm board mới chỉ là thêm cấu hình chứ không phải viết code mới. scrape_search() duyệt qua từng trang kết quả và dừng sớm khi một trang không còn thẻ tin nào. Riêng _extract_salary() dùng regex để tách khoảng lương từ text tự do; định dạng lương mỗi board mỗi khác nên đây thường là chỗ bạn cần tinh chỉnh đầu tiên.

# scrapers.py
from bs4 import BeautifulSoup
from scraper_base import BaseScraper
from models import JobListing
import re

class GenericJobScraper(BaseScraper):
    """Scrape a job board search results page."""

    def __init__(self, source_name, base_url, selectors):
        super().__init__(source_name)
        self.base_url = base_url
        self.selectors = selectors

    def scrape_search(self, keyword, location="", max_pages=3):
        jobs = []

        for page in range(1, max_pages + 1):
            url = self.base_url.format(
                keyword=keyword.replace(" ", "+"),
                location=location.replace(" ", "+"),
                page=page,
            )
            html = self.fetch(url)
            page_jobs = self._parse_listings(html)

            if not page_jobs:
                break
            jobs.extend(page_jobs)

        return jobs

    def _parse_listings(self, html):
        soup = BeautifulSoup(html, "html.parser")
        cards = soup.select(self.selectors["card"])
        jobs = []

        for card in cards:
            title_el = card.select_one(self.selectors["title"])
            company_el = card.select_one(self.selectors["company"])
            location_el = card.select_one(self.selectors.get("location", ".location"))
            link_el = card.select_one(self.selectors.get("link", "a"))

            if not title_el or not company_el:
                continue

            salary = self._extract_salary(card.get_text())

            jobs.append(JobListing(
                title=title_el.get_text(strip=True),
                company=company_el.get_text(strip=True),
                location=location_el.get_text(strip=True) if location_el else "",
                url=link_el["href"] if link_el else "",
                source=self.source,
                salary_min=salary[0],
                salary_max=salary[1],
            ))

        return jobs

    def _extract_salary(self, text):
        match = re.search(
            r'\$?([\d,]+)\s*[-–to]+\s*\$?([\d,]+)', text
        )
        if match:
            return (
                float(match.group(1).replace(",", "")),
                float(match.group(2).replace(",", "")),
            )
        return (None, None)

Chạy toàn bộ pipeline tổng hợp việc làm

main.py ráp mọi thứ lại: khai báo danh sách BOARDS, chạy scraper cho từng từ khóa, ghi kết quả vào database rồi thử một truy vấn tìm kiếm. time.sleep(5) giữa các board là phép lịch sự tối thiểu để không dội request quá dày lên một nguồn. Muốn thêm board, bạn chỉ cần bổ sung một mục vào BOARDS với base_url và bộ selector tương ứng.

# main.py
import time
from models import JobDatabase
from scrapers import GenericJobScraper

BOARDS = [
    {
        "name": "Board A",
        "base_url": "https://board-a.example.com/search?q={keyword}&l={location}&p={page}",
        "selectors": {
            "card": ".job-card",
            "title": ".job-title",
            "company": ".company-name",
            "location": ".job-location",
            "link": "a.job-link",
        },
    },
]

def main():
    db = JobDatabase()
    keywords = ["python developer", "data engineer"]

    for board in BOARDS:
        scraper = GenericJobScraper(board["name"], board["base_url"], board["selectors"])

        for keyword in keywords:
            print(f"Scraping {board['name']} for '{keyword}'...")
            jobs = scraper.scrape_search(keyword, location="Remote")

            for job in jobs:
                db.insert(job)
                print(f"  {job.title} at {job.company}")

            time.sleep(5)

    # Search example
    results = db.search("python", "Remote")
    print(f"\nFound {len(results)} matching jobs")

if __name__ == "__main__":
    main()

Xử lý sự cố thường gặp

Vấn đề Nguyên nhân Cách xử lý
Tin tuyển dụng bị trùng Cùng một job xuất hiện trên nhiều trang Khử trùng theo URL nhờ ràng buộc UNIQUE
Không tách được mức lương Định dạng lương không theo chuẩn Chỉnh lại regex _extract_salary cho từng board
Gặp CAPTCHA ở mọi trang Session không được giữ lại giữa các request Tái sử dụng self.session cho toàn bộ crawler
Không có tin nào sau khi giải Form CAPTCHA cần JavaScript render Chuyển sang Selenium + CaptchaAI

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

Pipeline này giải loại CAPTCHA nào?

reCAPTCHA v2. Lớp BaseScraper phát hiện data-sitekey, gửi task tới in.php với method userrecaptcha, polling res.php để lấy token rồi đặt vào field g-recaptcha-response. Nếu job board của bạn dùng loại khác, CaptchaAI cũng hỗ trợ reCAPTCHA v3, Cloudflare Turnstile và GeeTest v3.

Chi phí giải CAPTCHA cho một trình tổng hợp việc làm là bao nhiêu?

CaptchaAI tính tiền theo thread (luồng giải đồng thời), không theo mỗi lần giải. Gói BASIC ($15/tháng, 5 thread) đủ cho một crawler chạy vài board, và mỗi thread giải không giới hạn số CAPTCHA trong tháng. Khi cần scrape song song nhiều board hơn, tăng lên STANDARD ($30/tháng, 15 thread) hoặc ADVANCE ($90/tháng, 50 thread).

Khi nào nên chuyển từ requests sang Selenium?

Khi form CAPTCHA cần JavaScript render hoặc token phải nằm trong một phiên trình duyệt thật. Nếu giải xong mà trang vẫn trả về rỗng, đó là dấu hiệu cần một trình duyệt headless (Selenium hoặc Playwright) kết hợp CaptchaAI thay cho requests.

Lưu và tìm kiếm dữ liệu việc làm thế nào?

JobDatabase dùng SQLite với ràng buộc UNIQUE trên URL để tự khử trùng. Method search() lọc theo từ khóa tiêu đề và địa điểm, sắp xếp theo thời điểm scrape mới nhất — đủ cho một dashboard nội bộ mà không cần thêm hạ tầng.

Hướng dẫn liên quan

Tổng hợp dữ liệu tin tuyển dụng từ nhiều nguồn — bắt đầu với CaptchaAI.

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