Sebagian besar isi web crawler itu rejection handling ​

August 3, 2026

robots.txt, Cloudflare challenge, circuit breaker, dan antrean yang tak pernah membuang job: 90% pekerjaan crawling yang bukan soal mengambil halaman.

Masalah sulit sebuah crawler bukan mengambil halaman. Masalahnya adalah segala hal yang dilakukan server untuk menghindari diambil.

Saya belajar itu pelan-pelan, sambil menatap log yang penuh 403 dan soft-200: respons yang mengembalikan 200 OK tapi isinya Cloudflare challenge page, bukan konten. Mengambil satu halaman itu satu baris kode. Sisanya, 90% pekerjaan, adalah rejection handling: belasan cara berbeda sebuah server bilang tidak.

Tulisan ini soal 90% itu: membaca robots.txt, mengatur jeda permintaan per host, naik melewati bot defense hanya kalau terpaksa, mengenali challenge page yang berbohong soal keberhasilan, dan tahu kapan berhenti mencoba ulang. Yang terakhir itu paling lama saya benahi. Tiap mekanisme di bawah adalah sesuatu yang pernah saya salah minimal sekali.

Sopan santun dulu, karena kasar bikin diblokir ​

Awalnya saya memperlakukan ini sebagai masalah adu licik: situs yang memblokir saya pasti mendeteksi suatu trik yang perlu saya sembunyikan lebih baik. Itu terbalik. Kebanyakan blokir tidak pintar. Ia rate limiter yang bereaksi atas kenyataan bahwa saya mengirim seratus permintaan dalam sedetik. Obatnya bukan siluman, tapi tidak kasar sejak awal.

Tiga hal mengerjakan sebagian besarnya.

robots.txt, dengan cache yang memisahkan kegagalan definitif dari yang sementara. Saya mem-parse-nya dengan temoto/robotstxt dan meng-cache hasilnya 24 jam. Kehati-hatiannya di penanganan kegagalan. 404 berarti "tidak ada aturan", sebuah jawaban definitif, jadi saya cache boleh-semua. 5xx atau timeout berarti "saya tidak sempat bertanya", jadi saya tidak meng-cache apa pun dan mencoba lagi nanti. Salah di salah satu arah sama-sama merugikan: cache 5xx sementara sebagai blokir, dan Anda mengunci diri dari situs yang cuma down sepuluh detik; anggap disallow asli sebagai sementara, dan Anda terus menyerbu path yang diminta untuk dihindari.

Delay clock per host. Tiap host dapat max(default saya 1000ms, Crawl-delay milik situs), dibatasi 60 detik supaya situs yang memasang Crawl-delay: 3600 tak bisa membekukan worker. Satu-satunya bagian yang tak sepele adalah mengatur jeda tanpa memegang lock selama tidur:

go
// Satu "waktu boleh berikutnya" per host. Klaim slot Anda di dalam lock,
// lalu lepas dan tidur. Goroutine yang menuju host yang sama tetap dapat
// jeda yang benar, dan tak ada yang macet di sleep.
func (p *politeness) pace(host string, delay time.Duration) time.Duration {
    p.mu.Lock()
    hp := p.hosts[host]
    now := time.Now()
    if hp.next.Before(now) {
        hp.next = now
    }
    wait := hp.next.Sub(now)
    hp.next = hp.next.Add(delay) // klaim slot berikutnya sebelum lepas lock
    p.mu.Unlock()
    return wait // pemanggil tidur selama `wait` di luar lock
}

Mempartisi frontier berdasarkan domain. Domain tiap URL di-hash dengan FNV-1a dan diarahkan ke partisi tetap, jadi seluruh example.com selalu jatuh ke worker yang sama. Itu membuat pacing per-host jadi urusan lokal di memori. Tak ada worker yang perlu berkoordinasi dengan yang lain untuk tahu kapan sebuah host terakhir disentuh. Alternatifnya, shared token bucket di Redis, menambah satu round-trip jaringan dan satu dependensi untuk sesuatu yang sebenarnya cuma timestamp di sebuah map. (Map-nya dibatasi LRU di 50.000 host.)

Tak ada yang canggih di sini, tapi inilah beda antara crawler yang jalan berbulan-bulan dan yang rentang IP-nya diblokir dalam satu sore.

Escalation ladder ​

Sebagian situs butuh lebih dari sopan santun. Mereka duduk di belakang Cloudflare atau bot defense sejenis. Nalurinya langsung meraih alat terberat: headless browser penuh yang me-render semuanya. Tapi itu lambat dan berat sumber daya, dan challenge system dituning untuk menyadari otomasi, jadi memulai dengan browser bahkan tidak lebih andal. Ia cuma lebih mahal.

Jadi fetcher-nya naik bertingkat per tier, yang termurah dulu.

Tier 1: HTTP polos. Permintaan net/http, timeout 20 detik, body dibatasi 16 MiB. Tiap hop redirect divalidasi ulang lewat penjaga SSRF yang memblokir skema non-HTTP dan, setelah resolusi DNS, menolak menyambung ke loopback, alamat privat, link-local, dan alamat metadata cloud 169.254.169.254. Mayoritas permintaan tak pernah keluar dari tier ini.

Tier 2, secara prinsip: TLS fingerprint impersonation. Tier tengah yang standar adalah membuat TLS handshake Anda (JA3/JA4) cocok dengan Chrome asli dan meng-cache cookie cf_clearance supaya host yang sudah lolos berhenti menantang. Saya terus terang bahwa tier ini sudah dirancang tapi belum dibangun. Hari ini fetcher-nya melompat dari tier 1 ke tier 3. Saya menyebutnya karena menghilangkannya akan salah menggambarkan cara kerja ladder ini, dan karena siapa pun yang mengklaim Cloudflare bypass yang "sudah selesai" sedang menjual sesuatu.

Tier 3: browser sungguhan, di luar proses. Ketika permintaan balik dalam keadaan ditantang, URL-nya dikirim ke browser sidecar eksternal (layanan ala FlareSolverr) lewat HTTP, bukan headless Chrome di dalam proses. Menaruhnya di layanan terpisah berarti browser yang crash atau bocor memori tak ikut menjatuhkan crawler. Panggilannya dibatasi semaphore 2 dan timeout 30 detik.

Aturan yang penting: eskalasi terjadi pada tepat dua sinyal. HTTP 403, atau respons yang mengembalikan 200 tapi sebenarnya challenge page (bagian berikutnya). 5xx biasa tak memicu eskalasi. Server yang down itu masalah retry-dan-backoff, dan menghabiskan browser slot yang langka untuknya adalah pemborosan.

Mendeteksi respons yang berbohong ​

Failure mode yang paling memakan waktu saya adalah soft-200: server mengembalikan 200 OK, tapi body-nya interstitial Cloudflare, bukan halaman. Percaya pada kode status, dan Anda akan menyimpan sejuta challenge page sambil mengira crawl-nya jalan.

Deteksinya dua bagian:

go
var challengeMarkers = []string{
    "just a moment", "challenge-platform", "cf_chl",
    "/cdn-cgi/challenge", "attention required",
    "enable javascript and cookies", "cf-browser-verification", "turnstile",
}

func needsEscalation(body []byte) bool {
    lower := bytes.ToLower(body)
    for _, m := range challengeMarkers {
        if bytes.Contains(lower, []byte(m)) {
            return true
        }
    }
    // Density check: halaman yang cukup besar tapi byte-nya nyaris semua
    // script/markup dan nyaris tanpa teks tampak. JS shell yang belum
    // ter-render.
    return len(body) > 2048 && visibleTextLen(body) < 200
}

Bagian pertama daftar string yang selalu muncul di challenge page. Bagian kedua density check kasar: buang <script> dan <style>, buang sisa tag, hitung yang tersisa. Kalau HTML mentahnya di atas 2 KB tapi kurang dari 200 byte teks tampak yang selamat, hampir pasti itu JavaScript shell yang belum ter-render. Serahkan ke tier browser, yang bisa menunggu halamannya terisi.

Density check ini sengaja tidak presisi. Ia sesekali salah pada halaman yang memang tipis, tapi ia murah dan benar di sebagian besar kasus, yang merupakan trade yang saya mau ketika alternatifnya me-render tiap halaman di browser cuma untuk memastikan.

Tahu kapan berhenti mencoba ulang ​

Ini yang paling lama, dan bukan benar-benar masalah teknis. Kadang tier browser pun tak bisa melewati challenge, bukan karena salah konfigurasi, tapi karena pertahanan situs itu memang tak bisa ditembus hari ini. Naluri saya menyuruh mencoba ulang. Naluri itu salah, dan saya punya insiden untuk membuktikannya.

Suatu malam sebuah browser sidecar benar-benar tak bisa menyelesaikan Cloudflare challenge modern. Ia tidak crash; ia hidup, bekerja, dan gagal, membakar timeout penuh di tiap URL: 48 dari 49 percobaan timeout di atas 60 detik masing-masing. Panggilan itu menumpuk melawan batas in-flight antrean dan seluruh crawl consumer macet. Pukul 02:45, 722 job masuk dead-letter. Satu host yang tak tertembus menghentikan segalanya, karena tiap worker terjebak menunggunya.

Perbaikannya circuit breaker, tapi bagian menariknya adalah kondisi pemutusnya. Versi naifnya menghitung kegagalan per target dan putus pada situs yang keras. Itu salah: situs keras yang tetap mengembalikan halaman asli bukan sesuatu yang mau Anda blacklist. Jadi breaker-nya membedakan backend health dari target difficulty:

go
// Putus karena sidecar-nya sakit, bukan karena target-nya keras.
// Backend mati: timeout, connection refused, challenge tak terpecahkan (Status == 0).
// Backend sehat yang me-render halaman yang memang berat (Status != 0)
// TIDAK dihitung melawan breaker.
func browserBackendFailure(err error, status int) bool {
    return err != nil || status == 0
}

Lima backend failure beruntun membuka breaker selama dua menit; setelah itu ia meloloskan tepat satu probe untuk menguji pemulihan. Backend sehat yang sekadar me-render halaman berat tak dihitung melawannya. Saya juga memangkas timeout browser dari 90 detik jadi 30. Panggilan macet yang gagal cepat mengembalikan slot-nya ke URL yang mungkin benar-benar terselesaikan.

Antrean tak pernah membuang job ​

Di bawah semua ini ada work queue NATS JetStream: satu durable stream per tahap pipeline, dengan retensi WorkQueuePolicy supaya pesan lenyap begitu di-ack. Job yang gagal di-retry dengan jadwal sisi-klien 2s, 10s, 30s, 60s, 120s, sampai 8 pengiriman, lalu masuk keadaan dead-letter.

Satu detail di sini adalah jebakan yang layak ditandai. JetStream punya field BackOff per-consumer yang kelihatannya persis yang Anda mau untuk jarak retry. Saya membiarkannya kosong dengan sengaja, karena mengisinya membuat BackOff[0] menimpa AckWait: backoff pertama 2 detik diam-diam menyusutkan ack window Anda jadi 2 detik, dan handler mana pun yang lebih lama dikirim ulang saat masih bekerja. Job yang sama jalan beberapa kali dan sulit sekali dilihat kenapa. Backoff-nya hidup di kode saya sendiri, dan AckWait tetap 30 detik penuh.

Karena NATS mengirim at-least-once padahal saya mau tiap efek mendarat sekali, tiap worker menulis output-nya dan job tahap berikutnya ke Postgres dalam satu transaksi (pola transactional outbox), dan sebuah relay meng-poll tabel itu tiap 500ms dan mem-publish dengan message-ID deterministik supaya balapan tak bisa menghasilkan duplikat. Job yang kehabisan retry tak dibuang. Ia ditandai failed dan dibiarkan di tempat, dan ada perintah operator untuk me-redrive sekumpulan begitu apa pun yang memblokirnya berubah.

Menemukan halaman baru tanpa menggeledah search engine ​

Untuk menemukan URL baru, langkah paling jelas adalah menanyai search engine besar. Itu juga cara tercepat diblokir. Search engine paling galak soal lalu lintas otomatis, dan IP datacenter adalah hal pertama yang mereka usir. Jadi alih-alih menggeledah hasil, saya menanyai API resmi yang memang dibangun untuk ditanyai: satu web-search API berbayar plus beberapa API ensiklopedis dan akademis tanpa kunci, di-fan-out paralel, masing-masing mengembalikan batch-nya, disatukan dan di-dedup berdasarkan URL ternormalisasi. Kalau satu down, yang lain tetap jalan. Lead yang ditemukan begini dapat relevance prior lebih rendah (0.5) ketimbang curated seed (0.9).

Di sinilah juga bug terburuk proyek ini bersarang, dan perbaikannya pelajaran kecil soal urutan. Sumber link baru terkaya saya adalah halaman kategori ensiklopedis, halaman yang bukan konten, cuma daftar link. Bug-nya: saya menjalankan relevance filter pada sebuah halaman sebelum memanen link-nya. Halaman kategori dalam bahasa daerah, yang judul dan isinya tak cocok dengan relevance vocabulary saya yang satu bahasa, akan gagal filter itu dan dibuang berikut semua link di dalamnya. Saya diam-diam membunuh multilingual discovery di sumbernya.

Perbaikannya adalah memutuskan sebuah halaman itu apa sebelum memutuskan apakah ia relevan:

go
// Putuskan halaman ini APA sebelum memutuskan apakah ia relevan.
// Halaman lead/listing selalu dipanen link-nya, dalam bahasa apa pun.
// Hanya kandidat konten sungguhan yang lewat relevance gate.
func routePage(isDoc, isLead, isListing, relevant bool) disposition {
    switch {
    case isDoc:      return dispExtract      // dokumen asli (termasuk PDF)
    case isLead:     return dispHarvestOnly  // mis. hub ensiklopedia
    case isListing:  return dispHarvestOnly  // halaman indeks/paginasi
    case relevant:   return dispExtract
    default:         return dispReject
    }
}

Supaya hub berbahasa daerah bisa di-parse, saya juga harus mengajari harvester kata lokal untuk "Kategori" di belasan bahasa: tumbung di Banjar, kawan di Aceh, dalala di Gorontalo, plus kognat yang jelas untuk tetangga. Sekelompok halaman yang tadinya tak kelihatan karena saya tak bisa baca labelnya jadi terjangkau.

Kenapa failure handling itu intinya ​

Crawler tak punya garis finis. Ia jalan terus-menerus, tanpa keadaan di mana ia "selesai". Yang layak dioptimalkan bukan seberapa sering ia berhasil, tapi bagaimana ia berperilaku ketika gagal. Sebagian besar yang saya jelaskan adalah failure handling: cache yang fail-safe, breaker yang menyerah pada satu host tanpa menyerah pada sisanya, antrean yang menunda kerja alih-alih membuangnya. Tak ada yang cerdas. Inilah bagian yang menentukan apakah sistem jalan tanpa diawasi berbulan-bulan atau tumbang begitu satu situs melawan.