Di Balik Layar: Bagaimana Kami Menscrape 250 Ribu Data Caleg Pemilu 2024

Di Balik Layar: Bagaimana Kami Menscrape 250 Ribu Data Caleg Pemilu 2024

Zakiego

Zakiego

@zakiego

Latar Belakang

Project ini bermula dari tweet Mas Gilang (@mgilangjanuar) pada 15 Desember 2023. Namun, baru pada 23 Januari 2024 kemarin, project pengumpulan data ini selesai. Banyak kendala yang dialami, sebab itulah saya ingin menuliskan pelajaran apa saja yang didapat dari project ini.

Sebagai catatan penting, project ini hanya project gabut. Tidak memiliki tujuan apa pun, selain hanya karena suka melakukan scraping data. Tidak ada satu rupiah pun yang diperoleh dari pengumpulan data ini.

Metode

Metode yang digunakan adalah dengan fetch sederhana yang ada pada JavaScript.

 const response = await fetch("http://example.com/movies.json");
 const movies = await response.json();
 console.log(movies);

Fetch dilakukan dengan endpoint yang bisa diakses secara publik pada web KPU. Response yang berupa JSON dan HTML kemudian dikumpulkan dan dilakukan parsing untuk mendapatkan data yang bersih. Begitu saja.

Lebih detailnya, terdapat tiga tahapan yang dilakukan:

  1. getListDapil: mula-mula mengumpulkan daftar daerah pemilihan (dapil)
  2. getListCalon: kemudian melakukan fetch ke masing-masing dapil, untuk mendapatkan daftar calon yang ada pada daerah tersebut
  3. getProfilCalon: terakhir, barulah dengan nama yang ada, melakukan fetch dengan method post, menuju endpoint untuk mendapatkan profil calon

Masalah

Selama melakukan scraping, ternyata banyak masalah yang dihadapi. Mulai dari sisi scraper hingga masalah dari segi server.

Data Besar

const list = []
const resp = await fetch('https://example.com/api').then((res) => res.json())
list.push(resp)
await Bun.write('data.json', JSON.stringify(list));

Data yang didapat dikumpulkan dalam sebuah array. Selanjutnya array tersebut disimpan dalam file JSON.

Sebagai tambahan, saya menggunakan Bun (bukan NPM, PNPM, atau Yarn) karena lebih mudah digunakan untuk.

Seandainya datanya berukuran kecil, ini tidak menjadi masalah. Namun, secara total, ada 250 ribu lebih calon. Untuk sebuah file JSON, angka tersebut akan membuat file menjadi besar, sehingga akan lama untuk sekadar membaca dan meng-update data di dalamnya.

Dari situlah, kemudian saya beralih ke SQLite, dengan perantara ORM-nya yaitu Drizzle. Dibanding JSON, SQLite yang merupakan database sesungguhnya, membuat proses select, insert, dan update sangat mudah.

Penandaan

Sialnya, karena datanya banyak, maka tidak semua fetch untuk mendapatkan profil berhasil. Karena server memiliki keterbatasan dalam merespons request.

Terdapat 250 ribu calon dalam pemilu kali ini. Artinya, saya perlu melakukan 250 ribu kali fetch getProfilCalon untuk mendapatkan profil dari tiap calon. Angka yang sangat tidak sedikit.

Dalam keadaan seperti ini, tidak mungkin saya melakukannya hanya dengan sekali menjalankan script. Mengapa? Karena di tengah-tengah prosesnya, pasti akan ada fetch yang gagal, entah karena server yang kelebihan request, atau jaringan saya yang down.

Oleh sebab itu, saya perlu mengakali agar bisa mentoleransi kegagalan. Caranya, ketika script dijalankan ulang, saya hanya akan melakukan getProfilCalon untuk calon yang belum saya fetch.

Bagaimana menandai calon tersebut sudah di-fetch profilnya atau belum? Inilah mudahnya SQLite.

Saat menjalankan function getListCalon saya mendapatkan daftar nama calon. Di tiap nama tersebut kemudia saya tambahkan kolom is_fetched. Jika getProfilCalon sukses, maka akan bernilai true.

| nomor | nama          | is_fetched |
|-------|-------------- |-----------|
| 1     | John Doe      | true      |
| 2     | Jane Smith    | true      |
| 3     | Bob Johnson   | true      |
| 4     | Alice Brown   | true      |
| 5     | Charlie Davis | false     |
| 6     | Eve Wilson    | false     |

Alhasil, saya bisa melakukan query seperti di bawah ini:

const listNotFetched = SELECT * FROM list_anggota WHERE is_fetched = false

Dari listNotFetched, kemudian kita menjalankan getProfilCalon untuk mendapatkan detail profil dari calon.

Karena yang diquery hanya is_fetched = false, maka tidak perlu melakukan fetch dari awal, kita hanya akan melakukan fetch untuk profil yang belum didapat.

Pemisahan Command

Masih berkaitan toleransi kegagalan, tidak mungkin, ketika menjalankan script ulang, maka saya harus melakukan ulang semuanya. Sebagai konteks, terdapat 4 kategori data yang dikumpulkan:

  1. DPD
  2. DPR RI
  3. DPRD Provinsi
  4. DPRD Kabupaten Kota

Untuk mentoleransi kegagalan, dan tidak perlu menjalankan semuanya dari awal, maka command untuk menjalankan script saya pisah.

const category = process.argv[2];
const command = process.argv[3];

switch (category) {
  case 'dpr':
    switch (command) {
      case 'get-list-dapil':
        dpr.getListDapil()
        break;
      case 'get-list-calon':
        dpr.getListCalon()
        break;
      default:
        console.log('dpr command not found');
    }
    break;
  ...
  default:
    console.log('Unknown command');
}

Dengan code di atas pada index.ts, command akan berbeda sesuai keperluan.

Misalkan, saya perlu mendapatkan list dapil tingkat DPRD Provinsi, maka commandnya adalah ‘bun run index.ts dprd-provinsi get-list-dapil`

Begitu pula jika ingin mendapatkan list calon DPR RI, ‘bun run index.ts dpr-ri get-list-calon’

Batch Fetching

Ingat lagi, ada 250 ribu kali fetch yang perlu dilakukan. Ada beberapa opsi untuk melakukan ini:

  1. Default. Di tiap fetch, kita perlu menunggu (await) satu fetch selesai, barulah kita bisa melakukan fetch selanjutnya. Dengan kata lain, hanya bisa melakukan satu fetch dalam satu waktu, maka ini akan sangat lama. Dengan asumsi satu fetch 2 detik, dikali 250 ribu, maka perlu 500 ribu detik. Dan 500 ribu detik setara dengan 5,79 hari. Akan memakan waktu sangat lama. Ini masih asumsi waktu untuk fetch, sedangkan setelah pengumpulan data, perlu ada proses pengolahan data yang tidak kalah lamanya.
  2. Paralel. Artinya fetch akan dilakukan bersamaan. Bayangkan, 250 ribu fetch ditembakkan ke satu server secara bersamaan, apa jadinya? Yak betul, runtuh alias down.
  3. Pertengahan antara fetch satu-satu dan fetch semua berbarengan, yaitu fetch berkelompok, atau batching. Artinya kita tidak menembakkan 250 ribu fetch dalam satu waktu, namun di bagi ke beberapa kelompok. Misal satu kelompok terdiri dari 100 fetch. Maka 100 fetch akan ditembakkan, kita menunggu kelompok ini selesai, barulah kita melanjutkan ke kelompok lain dan menembakkan 100 fetch. Dengan cara ini, kita akan lebih berperi-keserver-an.

Metode ke-3 inilah yang digunakan. Beruntungnya, Mas @gadingnstn pernah membuat library bernama Concurrent Manager, yang fungsinya batching concurrent promise. Alhasil saya tidak perlu menulis script untuk mengurus batching ini dari awal.

import ConcurrentManager from 'concurrent-manager';

const concurrent = new ConcurrentManager({
  concurrent: 10, 
  withMillis: true 
});

for (const calon of list) {
  concurrent.queue(() => {
    return doSomethingPromiseRequest();
  });
}

concurrent.run()

Anti-Bot

Setelah masalah dari sisi kita sudah teratasi, kemudian ada lagi masalah dari segi server. Saat kita melakukan misal 50 fetch dalam satu waktu, server akan otomatis memblokir IP dalam kurun waktu terentu.

Untuk mengatasi ini, ada dua hal yang saya lakukan:

1. Penjedaan Antar Fetch

Mulanya sebanyak 1000 fetch ditembakkan dalam satu waktu. Ternyata responsenya menjadi 403, yang terjadi adalah IP diblokir oleh server. Kemudian fetch diturunkan ke angka 50 dan antar fetch diberi jeda beberapa detik. Ternyata solusi ini ampuh untuk menghindari pemblokiran oleh server.

export const sleep = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

export const sleepRandom = async (min: number, max: number) => {
  const delay = Math.floor(Math.random() * max) + min;
  await sleep(delay);
};

export const customFetch = async (url: string, options?: RequestInit) => {
  await sleepRandom(1_500, 3_000);
  const res = await fetch(url, { ...options });
  return res;
};

2. Transit Via Cloudflare Worker

Meski sudah bisa mengatasi pemblokiran, dengan mengurangi jumlah concurrent menjadi 50 dan ada penjedaan antar fetch, muncul masalah baru: waktu menjadi lebih lama.

Berbagai cara dilakukan, namun gagal. Hingga akhirnya sadar, karena ini rate limit berdasarkan IP, artinya saya harus melakukan fetch melalui IP berbeda.

Bagaimana caranya? Ada banyak cara. Namun cara paling mudah (dan gratis) adalah dengan menggunakan Cloudflare Worker. Saya membuat API untuk melakukan fetch ke server KPU.

Ilustrasi begini:

  • Before: User -> Server KPU
  • After: User -> Cloudflare Worker -> Server KPU

Sederhananya, Cloudflare Worker hanya melakukan fetch ke alamat yang sudah saya tentukan, kemudian memberikan response persis seperti yang dia dapat.

var src_default = {
  async fetch(request, env, ctx) {
    console.log("Incoming Request:", request);
    const targetURL = "https://infopemilu.kpu.go.id/Pemilu/Dct_dprprov/profile";
    const response = await fetch(targetURL, request);
    console.log("Response from Target:", response);
    return response;
  }
};

export {
  src_default as default
};

Yang awalnya saya melakukan fetch ke https://infopemilu.kpu.go.id/Pemilu/Dct_dprprov/profile, kemudian saya hanya perlu melakuka fetch ke transit-example.workers.dev

Karena request dilakukan oleh server Cloudflare, artinya request menggunakan IP dari Cloudflare, maka IP saya tidak akan terblokir.

Untuk mempercepat proses scrape data, saya membuat 3 Cloudflare Worker. Skema akhir proses scrape menjadi seperti ini:

  1. User -> Server KPU
  2. User -> Cloudflare Worker 1 -> Server KPU 
  3. User -> Cloudflare Worker 2 -> Server KPU
  4. User -> Cloudflare Worker 3 -> Server KPU

Penutup

Ada seribu cara untuk melakukan pembatasan, dan ada seribu satu cara untuk melompatinya.

Terakhir, pilihlah pemimpin yang mau mendengarkan kalau dikritik, bukan mencu….. 😥