D
P
0

Web Development

1.343 Halaman Ter-scrape, 1.343 Isi Identik? Cascade Selector `article .entry-content` Mengambil Widget Sidebar

3 Agustus 2026·5 menit baca
1.343 Halaman Ter-scrape, 1.343 Isi Identik? Cascade Selector `article .entry-content` Mengambil Widget Sidebar

Ada satu jenis kegagalan yang jauh lebih licik daripada crash: proses yang melaporkan sukses seratus persen, dengan hasil yang seluruhnya salah.

Waktu itu saya memigrasikan konten sebuah situs klien dari WordPress ke Sanity. Yang dipindah adalah sebuah custom post type dengan 1.343 entri. Rencana awal saya lewat REST API, tapi CPT itu didaftarkan dengan 'show_in_rest' => false, dan mengubahnya di sisi WordPress bukan opsi yang tersedia. Jadi tinggal satu jalur: ambil HTML yang sudah dirender, lalu parse dengan jsdom.

Runner pertama selesai tanpa drama. 1.343 halaman terambil, 1.343 dilaporkan sukses, nol error. Ringkasan di terminal terlihat seperti pekerjaan yang beres.

Gejalanya: seribu tiga ratus halaman, satu isi

QA-nya sederhana: buka beberapa entri hasil impor di preview. Entri pertama tampil dengan body kosong plus satu link nyasar ke artikel yang sama sekali tidak berhubungan. Entri kedua: link nyasar yang sama. Entri ketiga: sama lagi.

Saya berhenti mengeklik dan menghitung saja. Hash isi tiap entri, lalu lihat berapa nilai uniknya:

import { createHash } from "node:crypto";
 
const buckets = new Map();
for (const item of results) {
  const key = createHash("sha1").update(item.html).digest("hex");
  buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
 
console.log(buckets.size, "isi unik dari", results.length, "halaman");
// 1 isi unik dari 1343 halaman

Satu hash unik untuk 1.343 halaman. Setiap entri berisi blok HTML yang sama persis, panjangnya 1.405 karakter, sampai byte terakhir. Scraper-nya tidak pernah gagal. Dia cuma konsisten mengambil elemen yang salah.

Dua jalan buntu sebelum saya berhenti menebak

Dugaan pertama saya: ada layer cache di sisi WordPress yang menyajikan halaman yang sama berulang-ulang. Gampang dites. Saya curl beberapa URL satu per satu dan membaca HTML mentahnya. Kontennya ada di sana, benar, berbeda per halaman. Jadi masalahnya bukan di pengambilan.

Dugaan kedua: concurrency. Mungkin response tertukar antar worker. Saya turunkan concurrency ke 1 dan jalankan ulang sebagian. Hasilnya identik lagi. Ini poin yang saya pegang: kalau sebuah bug selamat dari eksekusi serial penuh, itu bukan race condition. Itu deterministik, dan yang deterministik ada di kode saya sendiri.

Baru setelah itu saya melakukan hal paling membosankan yang seharusnya saya lakukan sejak awal: simpan HTML mentah satu halaman ke disk, buka di editor, dan cari tahu elemen mana yang sebenarnya dipetik selector saya.

Akar masalahnya: cascade yang tidak pernah gagal

Ekstraktornya pakai pola yang kelihatan aman, daftar selector berurut dari paling spesifik ke paling longgar:

const CONTENT_SELECTORS = [
  "article .entry-content",
  "article .post-content",
  "article",
];
 
function pickContent(doc) {
  for (const selector of CONTENT_SELECTORS) {
    const el = doc.querySelector(selector);
    if (el) return el.innerHTML;
  }
  return null;
}

Idenya masuk akal: kalau selector pertama tidak ketemu, mundur ke yang lebih longgar. Masalahnya, tema WordPress yang dipakai situs itu menaruh konten utama di sebuah div .entry-content top-level, tanpa pembungkus <article> sama sekali. Dan satu-satunya tag <article> yang ada di halaman itu justru milik widget sidebar "popular posts".

Jadi cascade-nya jalan persis seperti yang saya tulis. article .entry-content tidak ketemu. article .post-content tidak ketemu. article ketemu, dan yang ketemu adalah kartu widget pertama di sidebar. Sidebar itu strukturnya identik di seluruh situs, makanya seluruh hasilnya identik. Link nyasar yang saya lihat waktu QA itu isi widget-nya, bukan isi entri.

Fallback longgar tidak menyelamatkan saya. Justru fallback itulah yang menyembunyikan kegagalannya. Kalau cascade-nya berhenti di selector pertama dan melempar error, saya akan tahu dalam sepuluh detik pertama, bukan setelah 1.343 dokumen masuk ke CMS.

Perbaikannya

Buang prefix article sepenuhnya, targetkan container konten secara langsung, lalu bersihkan elemen chrome tema yang ikut terangkut:

const CONTENT_SELECTORS = [
  ".entry-content",
  ".content-inner",
  ".jeg_post_content",
  "main .single-content",
];
 
const STRIP_SELECTORS = [
  ".jeg_share_button",
  ".jeg_share_float_container",
  ".jeg_sharelist",
  ".jeg_post_meta",
  ".jeg_meta_container",
  ".back-link",
];
 
function extractContent(doc) {
  let el = null;
  for (const selector of CONTENT_SELECTORS) {
    el = doc.querySelector(selector);
    if (el) break;
  }
 
  if (!el) throw new Error("tidak ada container konten yang cocok");
 
  for (const selector of STRIP_SELECTORS) {
    el.querySelectorAll(selector).forEach((node) => node.remove());
  }
 
  const html = el.innerHTML.trim();
  if (html.length < 200) throw new Error("body terlalu pendek, curigai salah elemen");
  return html;
}

Tapi jujur saja, dua penjaga itu tidak akan menangkap bug yang barusan saya alami. Widget sidebar tadi panjangnya 1.405 karakter, lolos ambang minimum dengan santai. Yang benar-benar menangkapnya cuma satu: cek keunikan di level batch, bukan di level halaman.

const hashes = new Set(results.map((r) => createHash("sha1").update(r.html).digest("hex")));
if (hashes.size < results.length * 0.9) {
  throw new Error(`cuma ${hashes.size} body unik dari ${results.length} halaman, ekstraktornya salah elemen`);
}

Jadi ada tiga lapis sekarang. Pertama, tidak ada lagi fallback yang boleh asal ketemu: kalau tidak ada container yang cocok, dia melempar, dan halaman itu masuk daftar gagal alih-alih diam-diam terisi sampah. Kedua, ambang panjang minimum untuk halaman yang nyaris kosong. Ketiga, dan ini yang paling penting, assert keunikan setelah semua halaman selesai. Halaman yang salah bisa terlihat sempurna satu per satu; yang membongkarnya adalah melihat semuanya sekaligus.

Re-scrape: 1.343 dari 1.343 bersih. Satu timeout muncul di tengah jalan dan pulih setelah CONCURRENCY saya turunkan ke 2. Kali ini QA di output yang sudah dirender menunjukkan isi yang benar, bukan sidebar.

Yang saya bawa pulang

  • Selector cascade bisa sukses di setiap halaman dan tetap salah di setiap halaman. Ketemu bukan sinonim benar.
  • Fallback yang terlalu longgar itu utang. article sendirian akan cocok dengan apa pun yang kebetulan memakai tag itu, termasuk widget yang bukan konten.
  • Kalau hasil scraping terlihat seragam, curigai ekstraktornya sebelum curiga sumbernya. Hash isi tiap halaman dan hitung nilai uniknya. Satu hash untuk ribuan halaman itu bug, bukan kebetulan.
  • Bug yang selamat dari eksekusi serial bukan masalah concurrency. Berhenti mengutak-atik throttle dan buka HTML mentahnya.
  • QA harus dilakukan pada output yang sudah dirender, bukan pada laporan sukses runner. Log hijau cuma bilang script-nya tidak crash, bukan bahwa datanya benar.