Tutorials

Bangun Agregator Daftar Pekerjaan dengan CaptchaAI

Bagian tersulit dari membangun agregator lowongan kerja bukanlah scraping-nya, melainkan tantangan CAPTCHA yang muncul begitu Anda membuka halaman hasil pencarian di banyak job board. Tutorial ini menyelesaikan persoalan itu: Anda akan menyusun scraper Python yang menarik lowongan dari beberapa job board sekaligus, menyerahkan reCAPTCHA v2 ke CaptchaAI untuk diselesaikan otomatis, menormalisasi hasilnya, lalu menyimpannya ke satu database SQLite yang bisa dicari.


Gambaran arsitektur

Alur datanya searah dan sederhana: setiap job board menjadi sumber, semuanya mengalir ke satu scraper yang sekaligus menangani CAPTCHA, lalu hasilnya dinormalisasi dan disimpan. Diagram berikut merangkum seluruh pipeline:

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

Keempat komponen ini bisa Anda bangun satu per satu. Kita mulai dari bentuk datanya.


Model data untuk setiap lowongan

Sebelum menyentuh scraping, tetapkan dulu bentuk data yang ingin Anda simpan. JobListing mendefinisikan satu lowongan — judul, perusahaan, lokasi, rentang gaji, hingga waktu scraping — sementara JobDatabase membungkus SQLite dan menjamin setiap URL hanya tersimpan sekali lewat constraint UNIQUE. Dengan begitu, lowongan yang sama yang muncul di dua job board tidak akan menjadi entri duplikat.

# 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()

Scraper dengan penanganan CAPTCHA otomatis

Kelas BaseScraper inilah inti integrasinya. Ketika sebuah halaman mengembalikan reCAPTCHA v2, alurnya mengikuti pola empat langkah yang sama di seluruh API CaptchaAI: kirim task ke in.php, simpan task ID yang dikembalikan, lakukan polling ke res.php sampai token siap, lalu kirim ulang permintaan dengan token itu di field g-recaptcha-response. Endpoint in.php/res.php sengaja kompatibel dengan pola 2Captcha, jadi jika Anda pernah memakai layanan sejenis, kodenya langsung terasa familier.

# 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")

Perhatikan bahwa self.session dipertahankan antar-permintaan. Ini penting: banyak job board hanya menantang CAPTCHA sekali per sesi, sehingga memakai session yang sama mencegah CAPTCHA berulang di setiap halaman — sekaligus menghemat thread yang terpakai.


Mengurai hasil pencarian job board

GenericJobScraper bisa dipakai untuk hampir semua job board karena selektor CSS-nya dijadikan parameter, bukan di-hardcode. Anda cukup memberikan templat URL pencarian dan sekumpulan selektor untuk kartu lowongan, judul, perusahaan, lokasi, dan tautan. Metode _extract_salary menebak rentang gaji dari teks kartu — inilah bagian yang paling sering perlu Anda sesuaikan per job board, karena format penulisan gaji sangat bervariasi antar situs.

# 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)

Menjalankan agregator

Terakhir, main.py merangkai semuanya: daftar job board di BOARDS, sekumpulan kata kunci pencarian, dan loop yang menyimpan setiap lowongan ke database. Menambah job board baru cukup dengan menambahkan satu entri ke BOARDS — tidak perlu menulis kelas baru.

# 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()

Jeda time.sleep(5) antar-job board itu disengaja. Membanjiri satu situs dengan permintaan beruntun adalah cara respons kompetitif memicu pemblokiran; beri jarak yang wajar antar-permintaan.


Menskalakan dan memperkirakan biaya

Di ekosistem automation Indonesia — banyak pekerjaan scraping freelance lewat Upwork atau Fastwork, tim data startup, hingga agregator lowongan lokal — biaya adalah pertimbangan nyata. CaptchaAI menagih per thread, bukan per solve: paket BASIC ($15/bulan, 5 thread) sudah memadai untuk agregator satu hingga dua job board yang berjalan terjadwal, dan Anda bisa naik ke ADVANCE ($90/bulan, 50 thread) saat menambah sumber tanpa khawatir biaya per-CAPTCHA membengkak. Setiap thread menangani satu CAPTCHA dalam satu waktu, dengan solve tak terbatas selama bulan berjalan.

Jika scraper dijalankan sebagai job terjadwal, deploy ke region terdekat seperti AWS ap-southeast-3 (Jakarta) menekan latency saat memanggil situs target maupun API. Satu catatan kepatuhan: sesuai UU Pelindungan Data Pribadi (UU 27/2022), kumpulkan hanya data lowongan yang memang dipublikasikan terbuka dan hindari menyimpan data pribadi pelamar.


Mengatasi masalah umum

Masalah Penyebab Solusi
Listing duplikat Lowongan sama muncul di beberapa halaman Dedup berbasis URL lewat constraint UNIQUE
Ekstraksi gaji gagal Format gaji non-standar Sesuaikan regex _extract_salary per job board
CAPTCHA muncul di setiap halaman Session tidak dipertahankan Gunakan kembali self.session di seluruh permintaan
Listing kosong setelah solve Form CAPTCHA butuh JS Beralih ke Selenium + CaptchaAI

Pertanyaan umum

Berapa biaya CaptchaAI untuk scraping lowongan dalam jumlah besar?

CaptchaAI menagih per thread, bukan per CAPTCHA yang diselesaikan. Untuk agregator terjadwal, BASIC ($15/bulan, 5 thread) biasanya cukup; naik ke ADVANCE ($90/bulan, 50 thread) hanya jika Anda menjalankan banyak scraper secara paralel. Volume solve tidak dibatasi per thread.

Apakah CaptchaAI bisa menyelesaikan CAPTCHA selain reCAPTCHA v2?

Bisa. Selain reCAPTCHA v2/v3, CaptchaAI menyelesaikan Cloudflare Turnstile dan Cloudflare Challenge, GeeTest v3, serta CAPTCHA gambar/OCR, grid, dan BLS; CaptchaFox (beta), Friendly Captcha (beta), dan Lemin (beta) juga tersedia. hCaptcha dan FunCaptcha belum didukung.

Kapan saya perlu beralih dari requests ke Selenium?

Saat CAPTCHA atau daftar lowongan hanya dirender oleh JavaScript, sehingga HTML awal dari requests kosong. Dalam kasus itu, jalankan halaman di Selenium, ambil sitekey dari DOM yang sudah dirender, lalu tetap serahkan penyelesaiannya ke CaptchaAI.

Apakah scraping data lowongan aman dari sisi hukum di Indonesia?

Selama Anda hanya mengumpulkan lowongan yang dipublikasikan terbuka dan tidak menyimpan data pribadi pelamar, umumnya wajar. UU Pelindungan Data Pribadi (UU 27/2022) menuntut kehati-hatian terhadap data pribadi — ini catatan umum, bukan nasihat hukum.


Panduan terkait


Kumpulkan data lowongan dari banyak sumber dalam satu database — mulai dengan CaptchaAI.

Komentar dinonaktifkan untuk artikel ini.