Pencarian web bertahap adalah praktik mengambil ulang hanya halaman-halaman yang berubah, bukan seluruh situs. Pada akhir panduan ini, Anda akan memiliki modul Python yang menemukan URL dari peta situs atau titik akhir Geonode/v1/map, menormalkannya, menyimpan status per-URL di SQLite, menggunakan ETag dan hash konten untuk menentukan apa yang berubah, menyesuaikan interval kunjungan ulang, mendeteksi penghapusan, dan mengambil kumpulan yang berubah melalui /v1/extract atau /v1/batch. Geonode mengambil halaman-halaman tersebut; skrip Anda yang mengelola status dan proses perbandingannya.
Mengapa Pencarian Ulang Penuh Memboroskan Anggaran
Sebuah situs dengan 50.000 halaman yang mengalami perubahan sebesar 2% per minggu membutuhkan sekitar 1.000 permintaan per minggu agar tetap terkini. Pencarian ulang penuh mingguan menghabiskan 50.000 permintaan. Itu berarti perbedaan 50 kali lipat dalam hal permintaan API, bandwidth, dan waktu yang dihabiskan oleh pipeline Anda untuk menunggu I/O.
Pemborosan ini semakin parah pada halaman yang dirender dengan JavaScript, di mana setiap permintaan lebih lambat dan lebih mahal, serta pada situs yang menerapkan pembatasan laju (rate-limit) secara ketat. Pencarian web inkremental membalikkan masalah ini: Anda menyimpan sedikit informasi status per URL dan membiarkan informasi tersebut menentukan ke mana permintaan berikutnya harus diarahkan.
Modul di bawah ini terdiri dari sekitar 120 baris kode. Setiap bagian menambahkan satu komponen. Instal dependensinya dengan pip install requests (httpx berfungsi sama persis jika Anda lebih menyukainya).
Mencari URL
Dua sumber, berdasarkan urutan prioritas. Peta situs (sitemap) memberikan URL beserta lastmod
opsional, yang merupakan sinyal perubahan paling sederhana yang ada. Jika sebuah situs tidak memiliki peta situs yang dapat digunakan, POST /v1/map
menemukan URL dengan menggabungkan penguraian peta situs dengan ekstraksi tautan HTML, dan mengembalikan setiap URL beserta source
-nya.
import os
import xml.etree.ElementTree as ET
import requests
API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}
SM_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
def urls_from_sitemap(sitemap_url: str, session: requests.Session) -> list[tuple[str, str | None]]:
"""Return (loc, lastmod) pairs. Follows sitemap index files one level."""
root = ET.fromstring(session.get(sitemap_url, timeout=30).content)
out: list[tuple[str, str | None]] = []
for sm in root.findall("sm:sitemap", SM_NS): # sitemap index
out.extend(urls_from_sitemap(sm.findtext("sm:loc", namespaces=SM_NS), session))
for u in root.findall("sm:url", SM_NS):
out.append((u.findtext("sm:loc", namespaces=SM_NS),
u.findtext("sm:lastmod", namespaces=SM_NS)))
return out
def urls_from_map(site_url: str, session: requests.Session) -> list[tuple[str, None]]:
body = {"url": site_url, "include_subdomains": False, "ignore_query_parameters": True}
r = session.post(f"{API}/v1/map", json=body, headers=HEADERS, timeout=120)
r.raise_for_status()
return [(link["url"], None) for link in r.json()["links"]]
lastmod
dari peta situs hanyalah petunjuk, bukan jaminan. Beberapa CMS menandai setiap URL dengan waktu penerbitan; yang lain tidak pernah memperbaruinya. Anggaplah hal ini sebagai alasan untuk memeriksa lebih cepat, bukan sebagai alasan untuk melewatkan pemeriksaan sama sekali.
Normalisasi URL
Tanpa normalisasi, https://Example.com/docs/?utm_source=x#top dan https://example.com/docs akan menjadi dua baris dan dua kali pengambilan data. Lakukan normalisasi sebelum ada yang menyentuh penyimpanan status.
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"gclid", "fbclid", "ref", "mc_cid", "mc_eid"}
def normalize(url: str) -> str:
parts = urlsplit(url.strip())
scheme = parts.scheme.lower()
host = parts.netloc.lower()
if (scheme, host.rsplit(":", 1)[-1]) in {("http", "80"), ("https", "443")}:
host = host.rsplit(":", 1)[0]
query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if k.lower() not in TRACKING]
query.sort()
path = parts.path or "/"
if len(path) > 1 and path.endswith("/"):
path = path[:-1] # pick one convention and stick to it
return urlunsplit((scheme, host, path, urlencode(query), "")) # fragment dropped
Aturan tanda garis miring akhir (trailing-slash) merupakan pilihan kebijakan. Beberapa kerangka kerja (framework) menyajikan konten yang berbeda di /docs dan /docs/. Jika situs tujuan termasuk di antaranya, hapus baris tersebut dan biarkan kedua URL tersebut tetap sebagai baris terpisah.
Penyimpanan sistem
Satu tabel SQLite menyimpan semua informasi yang dibutuhkan crawler untuk mengambil keputusan. Kolom-kolomnya sesuai dengan sinyal-sinyal yang dibahas di bagian selanjutnya dari panduan ini.
import sqlite3
import time
SCHEMA = """
CREATE TABLE IF NOT EXISTS pages (
normalized_url TEXT PRIMARY KEY,
url TEXT NOT NULL,
last_seen REAL,
last_fetched REAL,
next_check REAL DEFAULT 0,
interval_s INTEGER DEFAULT 86400,
content_hash TEXT,
etag TEXT,
last_modified TEXT,
sitemap_lastmod TEXT,
missing_runs INTEGER DEFAULT 0,
gone INTEGER DEFAULT 0
);
"""
def open_db(path: str = "crawl_state.sqlite") -> sqlite3.Connection:
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
def upsert_discovered(db: sqlite3.Connection, found: list[tuple[str, str | None]]) -> None:
now = time.time()
for raw, lastmod in found:
n = normalize(raw)
row = db.execute("SELECT sitemap_lastmod FROM pages WHERE normalized_url=?", (n,)).fetchone()
if row is None:
db.execute("INSERT INTO pages (normalized_url,url,last_seen,sitemap_lastmod) VALUES (?,?,?,?)",
(n, raw, now, lastmod))
else:
bump = lastmod is not None and lastmod != row[0]
db.execute("UPDATE pages SET last_seen=?, missing_runs=0, sitemap_lastmod=?, "
"next_check=CASE WHEN ? THEN 0 ELSE next_check END WHERE normalized_url=?",
(now, lastmod, bump, n))
db.commit()
last_seen
mencatat proses penemuan terakhir yang mencantumkan URL tersebut, yang berbeda dari last_fetched
. Perubahan pada sitemap_lastmod
akan mengatur ulang next_check
menjadi nol sehingga halaman tersebut akan diambil dalam antrian perubahan berikutnya.
Permintaan bersyarat: ETag dan If-Modified-Since
Apabila suatu situs mengirimkan ETag atau Last-Modified, permintaan bersyarat memungkinkan server asal merespons dengan kode status 304 tanpa isi. Anda mengirimkan header-header tersebut melalui bidang headers pada /v1/extract dan membaca status dari server asal melalui metadata.http_status. Permintaan ini tetap dihitung dalam kuota paket Anda, tetapi lebih cepat daripada proses rendering penuh.
Tidak semua situs menghormati validator, dan CDN terkadang menghapusnya. Kode di bawah ini mengirimkannya jika tersedia dan beralih ke proses hashing jika responsnya adalah 200.
Penghitungan hash konten setelah menghapus blok yang mudah berubah
Hasil hash mentah dari Markdown akan berubah setiap kali dijalankan pada halaman yang menampilkan waktu saat ini, token CSRF, atau blok "artikel terkait". Hapus elemen-elemen tersebut sebelum melakukan penghitungan hash. Simpan aturan penghapusan tersebut di satu tempat agar Anda dapat menyesuaikannya per situs.
import hashlib
import re
VOLATILE = [
re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?\S*"), # ISO timestamps
re.compile(r"(?i)(csrf|nonce|session)[_-]?token[^\s]*"),
re.compile(r"(?i)^#+\s*related (posts|articles).*?(?=^#|\Z)", re.M | re.S),
re.compile(r"[ \t]+"),
]
def content_fingerprint(markdown: str) -> str:
text = markdown
for rx in VOLATILE:
text = rx.sub(" ", text)
text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def fetch_one(session: requests.Session, row: sqlite3.Row | tuple) -> dict:
"""Sync extract with conditional headers. Returns a dict the caller can store."""
normalized_url, url, etag, last_modified = row
cond = {}
if etag:
cond["If-None-Match"] = etag
if last_modified:
cond["If-Modified-Since"] = last_modified
body = {"url": url, "formats": ["markdown"], "render_js": False,
"processing_mode": "sync", "headers": cond or None, "timeout": 60000}
r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
if r.status_code == 429:
raise RuntimeError(f"rate limited: {r.json().get('error', r.json())}")
r.raise_for_status()
payload = r.json()
status = payload["metadata"].get("http_status")
md = (payload.get("data") or {}).get("markdown") or ""
return {"http_status": status, "markdown": md,
"hash": content_fingerprint(md) if status == 200 else None}
Apakah header respons ETag dan Last-Modified dari sumber ditampilkan dalam metadata bergantung pada versi API yang Anda gunakan; periksa respons untuk akun Anda sebelum mengandalkannya. Jika header tersebut tidak ditampilkan, hash konten tetap memberikan jawaban yang andal mengenai apakah konten telah berubah atau tidak; Anda hanya kehilangan jalan pintas 304.
Antrian perubahan dan kunjungan ulang adaptif
Antrian ini merupakan inti dari perayapan web bertahap, dan ini merupakan kueri, bukan struktur data: setiap baris yang nilai next_check-nya sudah berlalu dan tidak ditandai sebagai gone. Setelah setiap proses pengambilan data, interval tersebut bergeser. Jika ditemukan perubahan: bagi dua nilainya, lalu bulatkan ke bawah menjadi satu jam. Tidak ada perubahan: kalikan dengan 1,5, dan bulatkan ke atas hingga 30 hari. Dalam beberapa minggu, jadwal akan menyesuaikan dengan frekuensi perubahan yang teramati pada setiap halaman.
MIN_S, MAX_S = 3600, 30 * 86400
def change_queue(db: sqlite3.Connection, limit: int = 500) -> list[tuple]:
return db.execute(
"SELECT normalized_url, url, etag, last_modified FROM pages "
"WHERE gone=0 AND next_check<=? ORDER BY next_check LIMIT ?",
(time.time(), limit)).fetchall()
def record_result(db: sqlite3.Connection, normalized_url: str, res: dict) -> str:
now = time.time()
old_hash, interval = db.execute(
"SELECT content_hash, interval_s FROM pages WHERE normalized_url=?",
(normalized_url,)).fetchone()
if res["http_status"] in (404, 410):
db.execute("UPDATE pages SET gone=1, last_fetched=? WHERE normalized_url=?", (now, normalized_url))
db.commit()
return "gone"
changed = res["http_status"] == 200 and res["hash"] != old_hash
interval = max(MIN_S, interval // 2) if changed else min(MAX_S, int(interval * 1.5))
db.execute("UPDATE pages SET last_fetched=?, next_check=?, interval_s=?, "
"content_hash=COALESCE(?, content_hash) WHERE normalized_url=?",
(now, now + interval, interval, res["hash"], normalized_url))
db.commit()
return "changed" if changed else "unchanged"
Kode status 304 diterima dengan http_status == 304 dan tanpa hash, sehingga masuk ke cabang yang tidak berubah dan intervalnya bertambah. Setiap kode status 5xx atau kesalahan jaringan harus membiarkan baris tersebut tetap utuh agar dicoba kembali pada eksekusi berikutnya; bungkus fetch_one dalam blok try/except di runner Anda untuk itu.
Mendeteksi URL yang Dihapus
Dua sinyal, digunakan secara bersamaan. Kode status 404 atau 410 secara langsung mengubah gone menjadi aktif di record_result. URL yang menghilang dari peta situs merupakan bukti yang lebih lemah, karena peta situs seringkali terlambat diperbarui dan terkadang mengabaikan beberapa bagian. Hitung jumlah proses pencarian berturut-turut di mana URL tersebut tidak ditemukan, dan lakukan pengambilan konfirmasi hanya setelah N kali proses tersebut.
def mark_missing(db: sqlite3.Connection, run_started: float, n_runs: int = 3) -> None:
"""Call after upsert_discovered. Rows not seen this run get missing_runs+1."""
db.execute("UPDATE pages SET missing_runs=missing_runs+1 WHERE last_seen<? AND gone=0", (run_started,))
db.execute("UPDATE pages SET next_check=0 WHERE missing_runs>=? AND gone=0", (n_runs,))
db.commit()
Baris yang ditandai gone tetap berada di tabel. Hal ini mencegah tautan masuk yang sudah usang memperkenalkan kembali URL tersebut sebagai yang baru. Jika halaman tersebut kembali muncul, proses pencarian berikutnya akan mencantumkannya kembali; atur ulang gone menjadi 0 di upsert_discovered jika Anda menginginkan perilaku tersebut.
Mengambil kumpulan URL yang berubah secara massal
Untuk beberapa ratus URL, loop sinkronisasi di atas sudah cukup, dan pada sistem perayapan web inkremental yang sudah matang, kumpulan URL yang berubah biasanya memang sekecil itu. Untuk ribuan URL, kirimkan antrian sebagai satu tugas /v1/batch dan lakukan polling terhadapnya. Badan permintaan batch menerima bidang-bidang yang sama seperti formats, render_js, proxy, headers, dan wait_config seperti pada ekstraksi tunggal, yang diterapkan ke setiap URL; oleh karena itu, header bersyarat tidak dapat bervariasi per URL dalam satu batch. Gunakan batch untuk jalur content-hash biasa dan pertahankan jalur sinkronisasi untuk URL yang memiliki ETag.
def batch_fetch(session: requests.Session, urls: list[str], poll_s: int = 10) -> dict[str, dict]:
body = {"urls": urls, "formats": ["markdown"], "render_js": False, "ignore_invalid_urls": True}
job = session.post(f"{API}/v1/batch", json=body, headers=HEADERS, timeout=60).json()
while True:
st = session.get(f"{API}/v1/batch/{job['job_id']}", headers=HEADERS, timeout=60).json()
if st["status"] in ("completed", "failed", "cancelled"):
break
time.sleep(poll_s)
out = {}
for res in st.get("results", []):
if res["status"] != "completed":
continue
md = (res.get("data") or {}).get("markdown") or ""
out[res["url"]] = {"http_status": res["metadata"].get("http_status"),
"markdown": md, "hash": content_fingerprint(md)}
return out
Alternatifnya adalah menggunakan processing_mode: "async" pada panggilan /v1/extract individu, yang mengembalikan kode status 202 beserta job_id yang dapat Anda pantau melalui GET /v1/extract/{job_id}. Metode ini mempertahankan header per URL tetapi memerlukan satu permintaan per URL. Dalam kedua kasus, kirimkan hasilnya melalui record_result persis seperti yang dilakukan pada jalur sinkron.
Menyusun proses eksekusi
Satu kali eksekusi terdiri dari: menemukan, melakukan upsert, menandai yang hilang, mengosongkan antrian, dan mencatat. Jalankan proses ini melalui cron atau penjadwal dengan frekuensi sesuai kebutuhan target keakuratan data Anda; interval adaptif akan menjaga jumlah pengambilan data tetap rendah terlepas dari seberapa sering proses eksekusi dijalankan.
def run(sitemap_url: str | None, site_url: str) -> None:
db = open_db()
session = requests.Session()
started = time.time()
found = urls_from_sitemap(sitemap_url, session) if sitemap_url else urls_from_map(site_url, session)
upsert_discovered(db, found)
mark_missing(db, started)
queue = change_queue(db)
with_etag = [r for r in queue if r[2] or r[3]]
plain = [r for r in queue if not (r[2] or r[3])]
for row in with_etag:
try:
record_result(db, row[0], fetch_one(session, row))
except Exception as exc: # leave row for next run
print("retry later:", row[1], exc)
if plain:
results = batch_fetch(session, [r[1] for r in plain])
for row in plain:
if row[1] in results:
record_result(db, row[0], results[row[1]])
Inilah modul perayapan web inkremental secara keseluruhan. Tabel di bawah ini merangkum fungsi masing-masing sinyal, sehingga Anda dapat menghilangkan bagian yang tidak diperlukan.
| Sinyal | Biaya | Keandalan | Fungsi dalam modul ini |
|---|---|---|---|
Sitemap lastmod | satu kali pengambilan sitemap | rendah hingga sedang | mengatur ulang next_check menjadi 0 |
| ETag / Last-Modified | satu permintaan, tanpa rendering | tinggi jika didukung | 304 berarti tidak berubah |
| Hash konten | satu kali pengambilan penuh | tinggi | perbandingan dengan data acuan |
| Interval adaptif | tidak ada | n/a | menentukan kapan harus memeriksa ulang |
| 404 / 410 | satu permintaan | tinggi | menandai gone |
| Tidak ada dalam peta situs selama N kali pengambilan | tidak ada | rendah | memaksa pengambilan konfirmasi |
Kesalahan umum
Melakukan hashing sebelum menghapus konten yang berubah-ubah
Halaman yang menampilkan "Dibuat pada 14:02:11" berubah setiap kali diambil, sehingga crawler Anda akan mengurangi intervalnya hingga mencapai batas minimum dan mengambil ulang setiap jam. Periksa perbedaan (diff) antara dua pengambilan berturut-turut untuk halaman mana pun yang melaporkan perubahan pada setiap eksekusi, lalu tambahkan pola tersebut ke VOLATILE.
Menormalkan setelah penyisipan pertama
Jika pada eksekusi pertama URL mentah disimpan dan normalisasi ditambahkan kemudian, setiap baris berpotensi menjadi duplikat. Lakukan normalisasi sejak awal dan jadikan normalized_url sebagai kunci utama, seperti yang ditentukan dalam skema.
Hanya Mengandalkan Ketidakhadiran Sitemap
Sitemap sering terlambat setelah deployment, terbagi di antara berkas indeks, dan terkadang menghilangkan bagian-bagian utuh setelah perubahan CMS. Menandai halaman sebagai hilang hanya karena menghilang dari satu pengambilan sitemap akan menghapus konten aktif dari indeks Anda. Pastikan ada konfirmasi 404 atau 410.
Menjalankan batch dengan header bersyarat per-URL
/v1/batch menerapkan satu objek headers ke setiap URL dalam tugas. Mengirimkan If-None-Match yang dimaksudkan untuk satu halaman ke seribu halaman lainnya akan menghasilkan hasil yang tidak berarti. Arahkan baris yang memiliki ETag melalui jalur sinkronisasi dan sisanya melalui batch, seperti yang dilakukan oleh run().
Langkah selanjutnya
Modul di atas menyimpan status, membandingkan konten, dan menjadwalkan kunjungan ulang, serta membutuhkan lapisan pengambilan data yang mengembalikan Markdown yang rapi dengan kode status http_status yang akurat untuk setiap URL, termasuk kode status 304 dan 404 yang menjadi dasarnya. Geonode Scraper API menyediakan lapisan tersebut: /v1/extract untuk halaman tunggal dengan header per permintaan, /v1/batch untuk kumpulan perubahan massal, dan /v1/map jika tidak ada peta situs. Tingkat gratis mencakup 1.500 permintaan per bulan, cukup untuk menguji loop inkremental pada situs nyata sebelum Anda menentukan paket yang sesuai. Lihat halaman Scraper API untuk informasi mengenai paket, dan harga untuk tingkatan permintaan.
Pertanyaan yang Sering Diajukan
Apa itu perayapan web inkremental?
Perayapan web inkremental berarti hanya mengunjungi kembali halaman-halaman yang kemungkinan besar telah berubah sejak proses perayapan terakhir, alih-alih mengambil ulang seluruh situs setiap kali. Crawler menyimpan riwayat status dengan sidik jari (fingerprint) per URL, mengidentifikasi kandidat halaman dengan efisien (sitemap lastmod, ETag, Last-Modified), dan mengalokasikan anggaran pengunduhan hanya pada subset halaman yang benar-benar membutuhkannya.
Bagaimana cara mengetahui halaman mana yang berubah tanpa mengunduhnya?
Anda biasanya tidak dapat mengetahui dengan pasti tanpa melakukan permintaan, tetapi Anda dapat memprioritaskan kandidat. Last-Modified pada sitemap adalah sinyal yang paling efisien. Permintaan bersyarat dengan If-None-Match atau If-Modified-Since akan mengembalikan kode status 304 pada situs yang mendukung validator. Untuk yang lainnya, ambil dan bandingkan hash konten yang dihitung setelah menghilangkan blok yang mudah berubah seperti cap waktu dan token sesi.
Apakah Geonode Scraper API menangani penyimpanan status dan deteksi perubahan untuk saya?
Tidak. API Scraper API mengambil halaman dan mengembalikan Markdown atau HTML beserta metadata seperti http_status. Skrip Anda yang mengelola penyimpanan status, normalisasi URL, perhitungan hash, dan penjadwalan. Pemisahan ini disengaja: API tetap stateless dan murah untuk dipanggil, sementara logika perayapan web inkremental berada dalam kode yang Anda kendalikan dan dapat diuji.
Bagaimana cara menjadwalkan kunjungan ulang untuk penjelajahan web inkremental?
Mulailah dengan interval default, lalu sesuaikan per URL. Jika pengambilan data menemukan perubahan, kurangi interval menjadi setengah hingga batas bawah; jika tidak menemukan perubahan, kalikan interval hingga batas atas. Halaman yang sering diperbarui diperiksa lebih sering, sedangkan halaman statis mendekati batas atas. Simpan nilai next_check dalam tabel status dan lakukan kueri untuk baris yang nilai next_check-nya telah terlampaui.
Bagaimana cara mendeteksi halaman yang telah dihapus?
Gunakan dua sinyal secara bersamaan. Kode status 404 atau 410 saat pengambilan data merupakan sinyal yang kuat. Tidak adanya halaman dalam peta situs selama N kali pengecekan berturut-turut merupakan sinyal yang lebih lemah, karena peta situs seringkali tidak lengkap. Tandai baris tersebut sebagai "hilang" hanya jika pengambilan data langsung mengonfirmasinya, dan pertahankan baris tersebut agar Anda tidak menemukan kembali dan mengambil ulang URL dari tautan yang sudah kadaluwarsa.
Apa saja batasan dan aturan kepatuhan yang harus saya patuhi?
Patuhi robots.txt dan ketentuan layanan situs, jaga agar tingkat permintaan tetap wajar, dan jangan pernah melewati otentikasi. Di sisi Scraper API, paket gratis mencakup 1,5 ribu permintaan per bulan dan paket berbayar tersedia mulai dari 2 hingga 100 thread, jadi sesuaikan kumpulan yang berubah dengan paket Anda. Permintaan bersyarat tetap dihitung sebagai permintaan.
