D
P
0

Web Development

WAF Memblokir Scraper `curl` dengan 403 dan JS Challenge? Pindahkan Ekstraksi ke Browser yang Login Pakai Bookmarklet JSON-LD

2 Juli 2026·5 menit baca
WAF Memblokir Scraper `curl` dengan 403 dan JS Challenge? Pindahkan Ekstraksi ke Browser yang Login Pakai Bookmarklet JSON-LD

Seorang klien pindah rumah digital. Bertahun-tahun mereka berjualan lewat sebuah platform pihak ketiga, dan sekarang katalognya mau dibawa ke situs WordPress baru yang saya bangun. Tugasnya terdengar sederhana: tarik data produk mereka, yaitu judul, harga, gambar, dan deskripsi, dari platform lama ke situs baru. Satu hal yang perlu ditegaskan sejak awal: ini data milik klien sendiri, di platform tempat mereka punya akun resmi dan login sebagai pengguna sah. Ini migrasi konten sendiri, bukan memanen situs orang lain.

Platform-nya tidak menyediakan tombol export yang layak, jadi saya ambil jalur klasik: script PHP dengan cURL dari server saya, membaca halaman produk satu per satu. Request pertama langsung dijawab 403. Saya kira kurang header, jadi saya kirim user-agent browser sungguhan. Masih 403. Kadang yang balik bukan 403 melainkan halaman HTML berisi JavaScript challenge, halaman "sedang memeriksa browser Anda" yang harus dieksekusi dulu sebelum konten asli muncul. Beberapa percobaan kemudian, IP server saya di-flag dan semua request ditolak mentah-mentah. Rotasi user-agent tidak mengubah apa pun.

Kenapa ini jalan buntu

WAF modern tidak cuma melihat user-agent. Dia melihat TLS fingerprint (cURL dan browser bersalaman dengan cara berbeda di level TLS), kemampuan mengeksekusi JavaScript, pola perilaku, dan reputasi IP. Server saya adalah IP datacenter yang tidak bisa menjalankan JavaScript. Di mata WAF, itu definisi buku teks dari bot. Jalan untuk menang tetap ada: headless browser, residential proxy, spoofing fingerprint. Tapi itu perlombaan senjata yang rapuh, makin mahal tiap bulan, dan rasanya keliru untuk pekerjaan yang sepenuhnya sah. Saya berhenti dan bertanya ulang: siapa yang sudah pasti lolos semua pemeriksaan itu?

Jawabannya jelas begitu kelihatan: browser milik klien sendiri, yang sedang login ke akun mereka. Dia lolos TLS check karena memang browser. Dia lolos JS challenge karena memang mengeksekusi JavaScript. Dia tidak dicurigai karena dialah pengunjung sah yang justru sedang dilindungi WAF itu. Jadi daripada memaksa server saya menyamar jadi browser, pindahkan saja ekstraksinya ke dalam browser.

Perbaikannya: bookmarklet plus JSON-LD

Alatnya: bookmarklet, yaitu bookmark biasa yang URL-nya diawali javascript:. Editor klien membuka salah satu halaman produk mereka seperti biasa, klik bookmark, selesai. Tanpa install extension, tanpa tooling, tanpa saya perlu menyentuh akun mereka.

Bagian keduanya: jangan parsing HTML. Halaman produk di hampir semua platform komersial menyematkan structured data untuk SEO dalam tag script[type="application/ld+json"]. Lapisan itu stabil dan machine-readable. Platform punya insentif menjaganya tetap valid karena Google membacanya. Class CSS bisa berubah di setiap deploy; JSON-LD adalah kontrak.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Example Product",
  "image": ["https://cdn.example.com/p/123.jpg"],
  "description": "Product description here.",
  "offers": {
    "@type": "Offer",
    "price": "249000",
    "priceCurrency": "IDR"
  }
}

Bookmarklet-nya membaca semua tag JSON-LD di halaman, mencari node dengan @type: "Product", menormalkan field yang saya butuhkan, lalu mengirimnya ke endpoint REST di situs WordPress baru. Ini versi yang bisa dibaca; untuk dipakai, minify jadi satu baris dan beri prefix javascript: saat disimpan sebagai bookmark:

(async () => {
  const scripts = document.querySelectorAll('script[type="application/ld+json"]');
  let product = null;
 
  for (const tag of scripts) {
    try {
      const data = JSON.parse(tag.textContent);
      const nodes = Array.isArray(data) ? data : data['@graph'] || [data];
      product = nodes.find((n) => n['@type'] === 'Product');
      if (product) break;
    } catch (err) {
      /* skip invalid JSON */
    }
  }
 
  if (!product) {
    alert('No Product JSON-LD found on this page.');
    return;
  }
 
  const offer = Array.isArray(product.offers) ? product.offers[0] : product.offers || {};
  const payload = {
    key: 'SHARED_MIGRATION_KEY',
    name: product.name || '',
    price: String(offer.price || ''),
    currency: offer.priceCurrency || '',
    images: [].concat(product.image || []),
    description: product.description || '',
    sourceUrl: location.href,
  };
 
  const res = await fetch('https://new-site.com/wp-json/migrate/v1/product', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
 
  alert(res.ok ? 'Imported: ' + payload.name : 'Import failed: HTTP ' + res.status);
})();

Di sisi WordPress, endpoint penerima didaftarkan lewat REST API dan membuat draft produk. Kuncinya saya taruh di body request, bukan di header custom, supaya preflight CORS tidak rewel; rute REST WordPress sudah mengirim header CORS secara default:

add_action('rest_api_init', function () {
    register_rest_route('migrate/v1', '/product', [
        'methods'             => 'POST',
        'permission_callback' => function (WP_REST_Request $request) {
            $params = $request->get_json_params();
            $key    = (string) ($params['key'] ?? '');
            return hash_equals((string) get_option('migration_shared_key'), $key);
        },
        'callback'            => function (WP_REST_Request $request) {
            $data = $request->get_json_params();
 
            $post_id = wp_insert_post([
                'post_type'    => 'product',
                'post_status'  => 'draft',
                'post_title'   => sanitize_text_field($data['name'] ?? ''),
                'post_content' => wp_kses_post($data['description'] ?? ''),
            ], true);
 
            if (is_wp_error($post_id)) {
                return new WP_Error('insert_failed', 'Could not create draft', ['status' => 500]);
            }
 
            update_post_meta($post_id, '_price', sanitize_text_field($data['price'] ?? ''));
            update_post_meta($post_id, '_currency', sanitize_text_field($data['currency'] ?? ''));
            update_post_meta($post_id, '_source_images', array_map('esc_url_raw', (array) ($data['images'] ?? [])));
            update_post_meta($post_id, '_source_url', esc_url_raw($data['sourceUrl'] ?? ''));
 
            return ['id' => $post_id, 'status' => 'draft'];
        },
    ]);
});

Endpoint-nya sengaja hanya membuat draft, bukan langsung publish. Editor mengklik bookmark di tiap halaman produk, draft bermunculan di admin WordPress, lalu mereka review semuanya sebelum tayang. Begitu migrasi selesai, endpoint dan kuncinya saya cabut.

Pelajaran

Ironi cerita ini: saya menghabiskan setengah hari mencoba membuat server terlihat seperti browser, padahal browser sungguhan sudah duduk manis di kantor klien. Checklist yang saya bawa pulang:

  • Kalau WAF memblokir akses server-side ke data yang memang milikmu, browser yang sudah login itulah API-nya. Berhenti melawan; pindah posisi.
  • Pilih JSON-LD atau structured data lain ketimbang scraping DOM. Selector itu rapuh; kontrak SEO itu stabil.
  • Bookmarklet adalah alat zero-install yang bisa dijalankan editor non-teknis. Sempurna untuk tugas migrasi sekali jalan.
  • Endpoint penerima tetap harus diautentikasi, hanya boleh membuat draft, dan dimatikan begitu selesai.

Dan yang paling penting: lakukan ini hanya untuk data yang memang milikmu atau milik klienmu, di platform tempat kalian pengguna resmi. Teknik yang sama di situs orang lain bukan lagi migrasi; itu masalah.