Web scraper dengan Next.js (frontend) + Python Flask (backend). Fokus
utamanya ngambil struktur Google Form publik — pertanyaan, tipe, opsi, dan
entry.xxxx ID-nya — plus scraper generik buat halaman web biasa.
┌─────────────────┐ /api/* ┌──────────────────┐ HTTP ┌────────────┐
│ Next.js :3000 │ ──rewrite─> │ Flask :5000 │ ────────> │ Target │
│ React UI │ <────────── │ fetch + parse │ <──────── │ page │
└─────────────────┘ JSON └──────────────────┘ └────────────┘
Frontend nggak pernah manggil Flask langsung dari browser. Semua lewat rewrite
di next.config.mjs, jadi requestnya same-origin — nggak ada CORS preflight,
dan alamat backend nggak ikut ke-bundle ke client.
Form publik ngirim seluruh definisinya ke browser lewat variabel JavaScript
FB_PUBLIC_LOAD_DATA_ — rendernya emang di sisi client. Parser di sini baca
variabel itu, bukan HTML hasil render, jadi yang kebaca struktur aslinya.
| Data | Keterangan |
|---|---|
| Judul & deskripsi form | |
entry_id |
Format entry.123456789, dipakai buat prefill link |
| Tipe field | 14 tipe: short answer, paragraph, radio, dropdown, checkbox, linear scale, grid, date, time, file upload, dll |
| Wajib / tidak | |
| Opsi pilihan | Termasuk nandain opsi "Other" |
| Grid | Dipecah jadi satu entry_id per baris |
| Linear scale | Beserta label ujung min/max |
Export ke CSV atau JSON langsung dari UI.
Metadata (title, description, OpenGraph), heading, link, gambar, tabel, dan JSON-LD. Bisa juga pakai CSS selector sendiri:
{
"url": "https://example.com",
"options": {
"rules": {
"harga": { "selector": ".price" },
"produk": { "selector": "h2.title", "all": true },
"gambar": { "selector": "img.main", "attr": "src" }
}
}
}Jawaban/response Google Form nggak bisa diambil. Bukan karena belum dibikin — datanya emang nggak pernah dikirim ke halaman publik. Response cuma bisa diakses pemilik form lewat Google Sheets atau Forms API pakai OAuth. Tool apa pun yang ngaku bisa nyedot response dari form orang lain itu bohong, atau minta kredensial kamu.
Yang diambil di sini murni struktur form yang emang publik — sama persis kayak yang dikirim ke browser siapa pun yang buka link formnya.
| Versi | Cek | |
|---|---|---|
| Node.js | 18+ (dites di 22.16) | node --version |
| Python | 3.9+ (dites di 3.13.2) | py --version |
Kenapa
py, bukanpython? Di Windows,pythonsering nunjuk ke shim Microsoft Store atau venv tool lain yang nggak bawa pip. Launcherpyselalu ngarah ke interpreter asli. Cek punyamu denganpy --list.
cd C:\laragon\www\scraper-studio
.\scripts\setup.ps1.\scripts\dev.ps1setup.ps1 bikin venv dan install dua-duanya. dev.ps1 nyalain kedua server di
jendela terpisah. Habis itu buka http://localhost:3000.
Kalau mau ngerti tiap langkahnya.
Backend:
cd backend
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item .env.example .env
.\.venv\Scripts\python.exe app.pyJalan di http://127.0.0.1:5000.
Frontend (terminal terpisah):
cd frontend
npm install
npm run devJalan di http://localhost:3000.
curl http://127.0.0.1:5000/api/healthHarusnya balik {"ok":true,"service":"scraper-studio",...}.
Test backend:
cd backend
.\.venv\Scripts\python.exe -m pytest tests\ -qscraper-studio/
├── backend/ # Flask
│ ├── app.py # route + error handler
│ ├── config.py # config dari environment
│ ├── openapi.py # spec OpenAPI 3.0 -> /api/docs
│ ├── requirements.txt
│ ├── .env.example
│ ├── core/
│ │ ├── fetcher.py # HTTP + proteksi SSRF
│ │ ├── robots.py # cek robots.txt (cache 1 jam)
│ │ ├── ratelimit.py # rate limit per IP
│ │ └── errors.py # error -> status code HTTP
│ ├── scrapers/
│ │ ├── __init__.py # registry + auto-detect
│ │ ├── google_form.py # parser FB_PUBLIC_LOAD_DATA_
│ │ └── generic.py # BeautifulSoup + CSS rules
│ └── tests/ # 33 test
├── frontend/ # Next.js App Router + TypeScript + Tailwind v4
│ ├── next.config.mjs # rewrite /api/* -> Flask
│ ├── postcss.config.mjs # plugin Tailwind
│ ├── app/
│ │ ├── page.tsx
│ │ ├── layout.tsx
│ │ └── globals.css # token neobrutalism, light/dark ikut OS
│ ├── components/
│ │ ├── ui.tsx # Card, Button, Badge, Stat, Code
│ │ ├── ScrapeForm.tsx
│ │ ├── ResultPanel.tsx
│ │ └── FieldTable.tsx
│ └── lib/
│ ├── api.ts # client + export CSV
│ └── types.ts
└── scripts/
├── setup.ps1
└── dev.ps1
Frontend-nya pakai Tailwind v4 dengan gaya neobrutalism: border tebal 3px, shadow keras tanpa blur, warna flat yang nyala, dan tombol yang "keteken" — bergeser masuk ke bayangannya sendiri pas diklik.
Token-nya ada di @theme dalam app/globals.css. Tailwind v4 nerbitin tiap
entry @theme jadi CSS variable di :root, dan utility-nya nunjuk ke var() —
jadi dark mode cukup nulis ulang variabelnya di dalam
@media (prefers-color-scheme: dark), nggak perlu nyebar class dark: ke
seluruh markup. Di mode gelap, border sama shadow-nya balik dari hitam ke
putih-tulang; warna aksennya dibiarin karena udah cukup kontras.
Cuma dua pola yang diangkat jadi class (.brut dan .brut-press di
@layer components) karena kepakai di mana-mana. Sisanya utility biasa, dan
variasi warna dibungkus jadi komponen React di components/ui.tsx supaya
tone-nya kejaga sama TypeScript, bukan sama @apply.
Mau ganti font display? Satu baris --font-display di @theme.
Swagger UI ada di http://localhost:3000/api/docs — lewat rewrite yang sama
kayak endpoint lain, jadi nggak perlu tau alamat Flask-nya. Kalau backend-nya
lagi jalan sendirian, bisa juga langsung di http://127.0.0.1:5000/api/docs.
Spec mentahnya di /api/openapi.json (OpenAPI 3.0.3) — tinggal di-import ke
Postman/Insomnia, atau dipakai buat generate client:
curl http://localhost:3000/api/openapi.json -o openapi.jsonSpec-nya ditulis sebagai dict di backend/openapi.py, bukan YAML di docstring,
biar seluruh kontraknya kebaca dalam satu tempat. Enum tipe field sama daftar
error code-nya di-derive dari kodenya langsung (FIELD_TYPES dan subclass
ScrapeError), jadi nambah tipe baru otomatis kebawa ke dokumentasi. Yang
nggak bisa auto — misal nambah route baru — dijaga sama tests/test_openapi.py:
kalau ada endpoint yang belum didokumentasiin, testnya merah.
{
"url": "https://docs.google.com/forms/d/e/FORM_ID/viewform",
"scraper": "google_form",
"options": {}
}scraper opsional — kalau dikosongin, backend nebak sendiri dari URL-nya
(docs.google.com/forms jadi google_form, selainnya generic).
Respons sukses:
{
"ok": true,
"scraper": "google_form",
"fetch": { "status_code": 200, "final_url": "...", "bytes": 48210 },
"data": {
"title": "Form Pendaftaran",
"field_count": 12,
"fields": [
{
"title": "Nama lengkap",
"type": "short_answer",
"entry_id": "entry.123456789",
"required": true,
"options": []
}
]
}
}Respons gagal — bentuknya konsisten, jadi client cukup ngecek ok:
{
"ok": false,
"error": {
"code": "blocked_url",
"message": "Refusing to fetch a private or loopback address.",
"detail": "192.168.1.1 resolves to 192.168.1.1"
}
}| Code | HTTP | Artinya |
|---|---|---|
invalid_url |
400 | Bukan http/https, atau host nggak ke-resolve |
blocked_url |
403 | Nunjuk ke alamat privat/loopback |
robots_disallowed |
403 | Ditolak robots.txt host tujuan |
parse_failed |
422 | Halaman keambil tapi strukturnya nggak dikenali |
rate_limited |
429 | Kena limit |
fetch_failed |
502 | Timeout, kegedean, atau error dari server tujuan |
Status service, dan daftar scraper yang tersedia.
Swagger UI dan spec-nya. Aset Swagger UI di-bundle di dalam package-nya, jadi halamannya tetap render tanpa internet.
Semua lewat backend/.env (contoh lengkap di .env.example):
| Variabel | Default | Fungsi |
|---|---|---|
FLASK_PORT |
5000 |
Port backend |
FETCH_TIMEOUT |
20 |
Timeout per request (detik) |
MAX_FETCH_BYTES |
5000000 |
Batas ukuran respons |
RESPECT_ROBOTS |
true |
Patuhi robots.txt |
ALLOW_PRIVATE_HOSTS |
false |
Matiin proteksi SSRF — dev doang |
RATE_LIMIT / RATE_WINDOW |
30 / 60 |
Limit per IP |
Backend nerima URL dari client terus nge-fetch dari sisi server. Itu pola klasik
SSRF: tanpa penjagaan, orang bisa nyuruh server kamu nembak
169.254.169.254 (endpoint metadata cloud) atau ngintip service internal di
localhost:3306.
Makanya tiap URL di-resolve dulu ke IP-nya sebelum koneksi dibuka, dan semua
alamat privat, loopback, link-local, sama reserved langsung ditolak. Ini yang
dites di tests/test_fetcher.py.
ALLOW_PRIVATE_HOSTS=true mematikan penjagaan itu. Cuma buat nembak server test
lokal — jangan pernah dinyalain di instance yang bisa diakses publik.
Tool ini defaultnya patuh robots.txt dan ada rate limit. Jangan dimatiin kecuali kamu emang punya situsnya.
Buat Google Forms, docs.google.com/robots.txt nulis Allow: /forms secara
eksplisit — jadi ngambil struktur form publik itu diizinkan, dan ada
Crawl-delay: 1 yang sebaiknya dihormati.
Yang perlu dijaga sendiri:
- Ambil struktur publik, bukan data pribadi orang.
- Jangan pakai
entry_idbuat spam submit ke form yang bukan punyamu. Prefill link itu fitur resmi Google buat bantu orang isi form, bukan buat nge-flood. - Cek ToS situs targetnya. robots.txt bukan izin hukum.
- Kalau nyimpen hasil scraping yang ada data orang, itu masuk ranah aturan perlindungan data.
Could not reach the backend. di UI.
Flask-nya belum nyala. Cek curl http://127.0.0.1:5000/api/health.
No module named pip waktu bikin venv.
python kamu nunjuk ke venv tool lain. Pakai py -m venv .venv, jangan
python -m venv .venv. Lihat py --list buat daftar interpreter asli.
This page has no FB_PUBLIC_LOAD_DATA_ block.
Formnya nggak publik (minta login), atau yang kamu tempel link /edit bukan
/viewform. Tes dulu: buka link itu di jendela penyamaran — kalau kamu diminta
login, scraper juga bakal ketolak.
Struktur form kebaca aneh padahal sebelumnya normal.
Format FB_PUBLIC_LOAD_DATA_ itu internal Google dan nggak ada jaminan stabil.
Kalau Google ngubah layoutnya, indeks di scrapers/google_form.py perlu
disesuaikan. Parsernya udah defensif (_at() nggak bakal IndexError), jadi
gejalanya bakal field kosong, bukan crash.
Turbopack ngeluh soal workspace root.
Udah dikunci di next.config.mjs lewat turbopack.root. Kalau muncul lagi,
berarti foldernya dipindah.
MIT
