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.