Deteksi perubahan situs web adalah cara untuk mengetahui bahwa penyedia layanan telah mengubah klausul syarat dan ketentuan, menghentikan dukungan suatu endpoint dalam dokumentasinya, atau diam-diam mengedit kebijakan privasi sebelum ada yang mengumumkannya. Pada akhir panduan ini, Anda akan memiliki skrip Python yang mengambil snapshot daftar halaman sebagai Markdown melalui Geonode Scraper API, menyaring bagian yang tidak relevan, menyimpan setiap versi di SQLite, menghasilkan perbandingan (diff) yang mudah dibaca, dan mengirimkan notifikasi ke Slack hanya ketika konten benar-benar berubah. Skrip ini dijalankan melalui cron atau GitHub Actions dan tetap berfungsi meskipun proses pengambilan data mengalami gangguan.
Skrip ini sengaja tidak dirancang sebagai pemantau harga. Harga adalah angka yang berada di slot yang diketahui; sedangkan dokumen adalah teks di mana lokasi dan bentuk perubahannya tidak diketahui sebelumnya. Pilihan desain di bawah ini didasarkan pada hal tersebut.
Apa yang Anda pantau dan mengapa menggunakan Markdown
Halaman-halaman yang layak dipantau memiliki ciri-ciri yang sama: bersifat publik, berisi banyak teks, jarang diperbarui, dan memiliki dampak signifikan saat diperbarui. Halaman referensi API, panduan otentikasi, tabel batasan laju, syarat dan ketentuan, perjanjian pemrosesan data, kebijakan penggunaan yang dapat diterima, catatan perubahan, serta riwayat insiden di halaman status semuanya termasuk dalam kategori ini.
Membandingkan kode HTML mentah dari halaman-halaman ini sangat merepotkan. Hash yang disertakan dalam URL aset, token CSRF, nama kelas uji A/B, dan analitik yang disisipkan berubah setiap kali halaman dimuat. Mengonversinya ke Markdown terlebih dahulu akan membuang hampir semua elemen tersebut dan hanya menyisakan kalimat-kalimatnya. Endpoint /v1/extract mengembalikan Markdown secara langsung, sehingga skrip ini tidak pernah menggunakan parser HTML.
Instal satu dependensi dengan pip install requests. Selebihnya menggunakan pustaka standar.
Mengambil cuplikan layar
Fetcher ini meminta format Markdown, menonaktifkan rendering JavaScript secara default, dan mengaktifkannya per halaman untuk situs dokumentasi yang melakukan rendering di sisi klien. Blok wait_config hanya diperlukan ketika sebuah halaman mengisi isinya setelah dimuat; blok ini secara otomatis mengaktifkan rendering jika ada.
import os
import requests
API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}
def snapshot(session: requests.Session, url: str, render_js: bool = False,
wait_for: str | None = None) -> tuple[int, str]:
"""Return (origin_http_status, markdown). Raises on API-level errors."""
body = {"url": url, "formats": ["markdown"], "render_js": render_js,
"processing_mode": "sync", "timeout": 60000}
if wait_for:
body["wait_config"] = {"wait_until": "domcontentloaded", "wait_for": wait_for,
"wait_timeout": 2000}
body.pop("render_js") # wait_config implies rendering; sending false is rejected
r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
if r.status_code == 429:
err = r.json().get("error", r.json())
raise RuntimeError(f"rate limited ({err.get('code')}): {err.get('message')}")
r.raise_for_status()
payload = r.json()
return payload["metadata"]["http_status"], (payload.get("data") or {}).get("markdown") or ""
Status asal sama pentingnya dengan isi halaman. Halaman pemeliharaan 503 dapat dikonversi menjadi Markdown yang sepenuhnya valid; tanpa memeriksa http_status, Anda akan menyimpannya sebagai versi baru dan menampilkan peringatan terkait hal tersebut.
Menghilangkan gangguan
Dua mekanisme, diterapkan secara berurutan. Tahap regex menghapus baris-baris yang diketahui sering berubah: tanggal, spanduk "terakhir diperbarui", pemberitahuan cookie, catatan hak cipta di bagian bawah halaman, serta tautan "sunting halaman ini". Kemudian, daftar putih (allowlist) bagian hanya mempertahankan judul-judul yang Anda inginkan, sehingga menghilangkan elemen navigasi, sidebar, dan blok pemasaran yang dipertahankan oleh konverter Markdown karena dianggap sebagai teks asli.
import re
NOISE = [
re.compile(r"(?im)^.*\b(last (updated|modified|reviewed)|effective date|updated on)\b.*$"),
re.compile(r"(?im)^.*\b(cookie|cookies)\b.*\b(accept|consent|preferences)\b.*$"),
re.compile(r"(?im)^.*(©|copyright|all rights reserved).*$"),
re.compile(r"(?im)^.*\b(edit this page|was this page helpful|on this page)\b.*$"),
re.compile(r"\b(?:\d{1,2}\s)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}\b", re.I),
re.compile(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?\S*)?\b"),
re.compile(r"!\[[^\]]*\]\([^)]*\)"), # images
]
def strip_noise(md: str, keep_sections: list[str] | None = None) -> str:
for rx in NOISE:
md = rx.sub("", md)
if keep_sections:
kept, keep = [], False
for line in md.splitlines():
if line.startswith("#"):
title = line.lstrip("#").strip().lower()
keep = any(s.lower() in title for s in keep_sections)
if keep:
kept.append(line)
md = "\n".join(kept)
return normalize_ws(md)
def normalize_ws(text: str) -> str:
lines = (re.sub(r"[ \t]+", " ", ln).strip() for ln in text.splitlines())
return "\n".join(ln for ln in lines if ln)
keep_sections adalah daftar substring yang dicocokkan dengan judul, sehingga ["rate limit", "authentication"] mempertahankan blok H2/H3 tersebut beserta semua konten di bawahnya hingga judul berikutnya. Biarkan tetap None untuk halaman kebijakan di mana setiap klausul penting.
Ekspresi reguler untuk tanggal sengaja dibuat agresif. Baris "Berlaku mulai 1 Maret 2026" pada halaman kebijakan adalah jenis perubahan yang ingin Anda lihat, jadi jika hal itu penting bagi Anda, hapus entri NOISE pertama dan biarkan perbandingan (diff) menampilkannya.
Hashing versus diffing
Hashing menjawab pertanyaan "apakah ada yang berubah" dalam waktu konstan. Diffing menjawab pertanyaan "apa yang berubah" dalam bentuk yang dapat dibaca manusia dalam pesan Slack. Sebagian besar alat pendeteksi perubahan situs web hanya menggunakan hash; gunakan keduanya: hash untuk penyaringan, dan diff untuk pelaporan.
import difflib
import hashlib
def digest(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def unified(old: str, new: str, url: str, context: int = 2, max_lines: int = 120) -> str:
diff = difflib.unified_diff(old.splitlines(), new.splitlines(),
fromfile=f"{url} (previous)", tofile=f"{url} (current)",
lineterm="", n=context)
lines = list(diff)
if len(lines) > max_lines:
lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
return "\n".join(lines)
difflib.unified_diff terdapat dalam pustaka standar dan didokumentasikan di docs.python.org. Argumen n menetapkan baris konteks; dua baris sudah cukup untuk teks naratif. Batasi keluaran agar penulisan ulang satu halaman penuh tidak menghasilkan peringatan sebanyak 5.000 baris.
Menyimpan snapshot
SQLite menyimpan riwayat lengkap, bukan hanya versi terakhir. Hal ini memungkinkan untuk membandingkan dengan dua snapshot terakhir (bagian berikutnya) dan menjawab pertanyaan "kapan klausa ini pertama kali muncul" berbulan-bulan kemudian.
import sqlite3
import time
SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL,
taken_at REAL NOT NULL,
http_status INTEGER,
hash TEXT,
content TEXT
);
CREATE INDEX IF NOT EXISTS idx_url_time ON snapshots(url, taken_at DESC);
"""
def open_db(path: str = "changes.sqlite") -> sqlite3.Connection:
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
def last_snapshots(db: sqlite3.Connection, url: str, n: int = 2) -> list[tuple[str, str]]:
"""Most recent first: [(hash, content), ...] for successful (200) fetches only."""
return db.execute("SELECT hash, content FROM snapshots WHERE url=? AND http_status=200 "
"ORDER BY taken_at DESC LIMIT ?", (url, n)).fetchall()
def save(db: sqlite3.Connection, url: str, status: int, h: str | None, content: str | None) -> None:
db.execute("INSERT INTO snapshots (url, taken_at, http_status, hash, content) VALUES (?,?,?,?,?)",
(url, time.time(), status, h, content))
db.commit()
Halaman kebijakan berukuran kecil, sehingga menyimpan seluruh konten per eksekusi tidak memakan banyak sumber daya. Jika Anda memantau ratusan dokumen panjang setiap jam, simpan konten hanya ketika hash-nya berubah dan simpan baris hash-nya saja jika tidak ada perubahan.
Menangani permintaan yang tidak stabil
Permintaan yang tidak stabil merupakan sumber utama hasil positif palsu dalam deteksi perubahan situs web, sehingga ada tiga langkah pencegahan. Ulangi permintaan dengan menerapkan strategi backoff sebelum menyerah. Anggap semua kode status selain 200 sebagai "tidak ada data", simpan kode status tersebut dalam catatan, dan jangan bandingkan. Dan bandingkan hash baru dengan dua snapshot terakhir yang valid, bukan hanya yang terakhir: jika halaman berganti A, B, A, maka B kemungkinan besar merupakan varian sementara (halaman kesalahan yang masih mengembalikan kode status 200, cache yang menyajikan versi lama) dan tidak layak mendapat peringatan.
def fetch_with_retry(session: requests.Session, url: str, attempts: int = 3, **kw) -> tuple[int, str]:
delay = 5
for i in range(attempts):
try:
status, md = snapshot(session, url, **kw)
if status < 500 and md.strip():
return status, md
except (requests.RequestException, RuntimeError) as exc:
if i == attempts - 1:
raise
time.sleep(delay)
delay *= 3
return status, md
def check(db: sqlite3.Connection, session: requests.Session, url: str,
keep_sections: list[str] | None = None, **kw) -> str | None:
"""Returns a unified diff if a real change was detected, else None."""
status, raw = fetch_with_retry(session, url, **kw)
if status != 200:
save(db, url, status, None, None)
return None
clean = strip_noise(raw, keep_sections)
h = digest(clean)
previous = last_snapshots(db, url, n=2)
save(db, url, status, h, clean)
if not previous:
return None # first run, nothing to compare
if any(h == old_hash for old_hash, _ in previous):
return None # unchanged, or a revert of a blip
return unified(previous[0][1], clean, url)
Aturan snapshot kedua memiliki satu konsekuensi: perubahan yang sebenarnya yang dibatalkan dalam satu siklus tidak dilaporkan. Untuk dokumentasi dan pemantauan kebijakan, hal ini hampir selalu merupakan pertukaran yang tepat.
Pemberitahuan
Webhook masuk Slack menerima isi pesan (body) JSON yang berisi bidang text; masukkan perbedaan (diff) ke dalam blok kode agar tetap menggunakan spasi tetap. Pengiriman melalui email menggunakan smtplib memerlukan beberapa baris tambahan dan berguna jika Slack tidak tersedia.
import json
import smtplib
from email.message import EmailMessage
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
def notify_slack(url: str, diff: str) -> None:
if not SLACK_WEBHOOK:
return
fence = "`" * 3 # literal ``` would close the Markdown block
text = f"*Change detected:* {url}\n{fence}\n{diff[:3500]}\n{fence}"
requests.post(SLACK_WEBHOOK, data=json.dumps({"text": text}),
headers={"Content-Type": "application/json"}, timeout=15).raise_for_status()
def notify_email(url: str, diff: str, to: str) -> None:
msg = EmailMessage()
msg["Subject"] = f"Change detected: {url}"
msg["From"] = os.environ["SMTP_FROM"]
msg["To"] = to
msg.set_content(diff)
with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587"))) as s:
s.starttls()
s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
s.send_message(msg)
Slack memotong pesan yang panjang, sehingga terdapat batasan 3.500 karakter untuk diff. Untuk diff yang panjang, posting tampilan layar pertama dan lampirkan teks lengkapnya di tempat lain, atau arahkan pesan ke ID snapshot yang tersimpan.
Daftar Pantauan dan Runner
Konfigurasi disimpan dalam sebuah daftar sehingga Anda dapat menyimpannya di repositori dan meninjau perubahannya layaknya kode. Setiap entri berisi URL, informasi apakah perlu dilakukan rendering, selektor opsional yang harus ditunggu, serta daftar putih bagian opsional.
WATCHLIST = [
{"url": "https://example.com/legal/terms"},
{"url": "https://example.com/legal/privacy"},
{"url": "https://example.com/docs/api/authentication",
"keep_sections": ["authentication", "rate limit"]},
{"url": "https://example.com/changelog", "render_js": True, "wait_for": "main article"},
]
def main() -> None:
db = open_db()
session = requests.Session()
for item in WATCHLIST:
url = item["url"]
try:
diff = check(db, session, url, keep_sections=item.get("keep_sections"),
render_js=item.get("render_js", False), wait_for=item.get("wait_for"))
except Exception as exc:
print(f"[skip] {url}: {exc}")
continue
if diff:
print(f"[changed] {url}")
notify_slack(url, diff)
else:
print(f"[ok] {url}")
if __name__ == "__main__":
main()
Pada eksekusi pertama, database diisi dengan data awal dan tidak ada laporan yang dihasilkan. Mulai dari eksekusi kedua, setiap perubahan nyata akan menghasilkan satu pesan Slack yang berisi perbandingan (diff).
Penjadwalan deteksi perubahan situs web
Dua opsi yang tidak memerlukan infrastruktur. Baris cron di mesin mana pun yang memiliki Python dan variabel lingkungan:
# daily at 06:10, log to a file
10 6 * * * cd /opt/change-monitor && /usr/bin/python3 monitor.py >> monitor.log 2>&1
Atau alur kerja GitHub Actions, yang praktis karena berkas SQLite dapat dikomit kembali ke repositori sebagai penyimpanan status, sedangkan rahasia disimpan di pengaturan repositori:
name: change-monitor
on:
schedule:
- cron: "10 6 * * *"
workflow_dispatch:
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install requests
- run: python monitor.py
env:
GEONODE_API_KEY: ${{ secrets.GEONODE_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- run: |
git config user.name "change-monitor"
git config user.email "bot@users.noreply.github.com"
git add changes.sqlite && git commit -m "snapshot $(date -u +%F)" || true
git push
Jadwal per jam sudah cukup untuk changelog; sedangkan untuk halaman hukum, jadwal harian sudah lebih dari cukup bagi kebanyakan tim. Tabel di bawah ini memberikan titik awal yang wajar.
| Jenis halaman | Frekuensi yang disarankan | render_js | keep_sections |
|---|
| Ketentuan, privasi, AUP | harian | tidak | tidak ada (seluruh halaman) |
| Referensi API | setiap 6 jam | tergantung pada mesin dokumentasi | ya, endpoint yang Anda gunakan |
| Catatan perubahan / catatan rilis | per jam | sering | tidak ada |
| Riwayat insiden halaman status | per jam | biasanya | tidak ada |
Catatan mengenai syarat dan ketentuan serta robot
Membaca dokumentasi publik atau halaman kebijakan sekali sehari merupakan penggunaan web yang wajar, dan hal ini sama dengan yang dilakukan oleh browser. Namun, periksa syarat dan ketentuan situs serta robots.txt sebelum menambahkannya ke daftar pantauan, jaga frekuensi pemantauan agar proporsional, dan jangan pernah memantau halaman yang memerlukan login yang tidak Anda miliki aksesnya. Jika sebuah situs menerbitkan umpan RSS atau API changelog, gunakanlah itu: hal ini lebih hemat bagi kedua belah pihak dan tidak akan terganggu saat tata letak halaman berubah.
Kesalahan umum
Membandingkan kode HTML mentah
Setiap proses build pada situs dokumentasi modern akan menulis ulang hash aset dan nama kelas. Perbedaan (diff) menjadi tidak terbaca dan setiap proses deploy memicu peringatan. Konversikan ke Markdown terlebih dahulu, seperti yang dilakukan oleh snapshot().
Memicu peringatan pada respons non-200 pertama
Satu respons 503 atau halaman tantangan bot yang mengembalikan kode status 200 dengan pesan "Silakan verifikasi bahwa Anda adalah manusia" tidak boleh disimpan sebagai versi atau memicu peringatan. Periksa http_status, pastikan kontennya tidak kosong, dan bandingkan dengan dua snapshot terakhir yang valid.
Penghapusan yang Terlalu Sedikit atau Terlalu Banyak
Jika perbandingan (diff) Anda hanya menampilkan tanggal, tambahkan ke NOISE. Jika perubahan klausa terlewatkan karena keep_sections menghapus judul di bawah mana klausa tersebut berada, perluas daftar izinkan. Jalankan pemantau selama seminggu dan sesuaikan kedua daftar tersebut berdasarkan perbandingan yang dihasilkannya sebelum mempercayainya.
Membiarkan daftar pantauan menyimpang dari kenyataan
URL yang dialihkan ke jalur baru, atau halaman dokumentasi yang dipindahkan ke bawah awalan berversi, secara diam-diam berubah menjadi 404 atau halaman yang berbeda. Catat status non-200 secara jelas dan tinjau baris "skip" dalam output sesekali.
Langkah selanjutnya
Semua yang disebutkan di atas menggunakan pustaka standar Python ditambah satu panggilan HTTP per halaman, yang merupakan semua yang dibutuhkan untuk mendeteksi perubahan dokumen di situs web. Bagian yang sulit untuk dihosting sendiri adalah proses pengambilan datanya sendiri: konversi Markdown yang bersih yang menangani situs dokumen yang dirender dengan JavaScript, mengembalikan http_status asli dari sumbernya, dan tidak diblokir saat Anda memeriksa empat puluh halaman vendor setiap pagi. Geonode dan Scraper API menyediakan panggilan tersebut, dengan render_js dan wait_config untuk halaman yang dirender oleh klien, serta kuota gratis sebesar 1.500 permintaan per bulan yang cukup untuk mencakup daftar pantauan harian. Lihat halaman Scraper API untuk paket layanan dan kasus penggunaan web scraping untuk pola-pola terkait.
Pertanyaan yang Sering Diajukan
Apa itu deteksi perubahan situs web dan kapan hal ini berguna?
Deteksi perubahan situs web adalah praktik mengambil halaman sesuai jadwal, membandingkannya dengan snapshot sebelumnya, dan memberikan peringatan jika terdapat perbedaan pada konten yang signifikan. Fitur ini paling berguna untuk halaman yang Anda andalkan tetapi tidak Anda kendalikan: dokumentasi API, syarat dan ketentuan layanan, kebijakan privasi, ketentuan harga, dan catatan perubahan vendor, di mana pengeditan diam-diam dapat merusak integrasi atau asumsi kepatuhan.
Mengapa membandingkan Markdown daripada HTML mentah?
HTML mengandung tata letak, skrip, parameter pelacakan, dan hash build yang dapat berubah tanpa mengubah teksnya. Mengonversinya ke Markdown terlebih dahulu akan menyederhanakan halaman menjadi isinya saja, sehingga perbandingan (diff) akan menampilkan kalimat yang diedit, bukan ratusan baris perubahan atribut. Geonode Scraper API mengembalikan Markdown secara langsung, sehingga alur kerja tetap singkat.
Bagaimana cara menghindari peringatan palsu dari tanggal, spanduk cookie, dan navigasi?
Singkirkan gangguan yang diketahui dengan sekumpulan kecil ekspresi reguler (tanggal, baris 'terakhir diperbarui', pemberitahuan cookie, footer), lalu pertahankan hanya bagian yang Anda perlukan menggunakan daftar putih judul. Normalisasikan spasi sebelum membuat hash. Terakhir, bandingkan dengan dua snapshot terakhir sehingga halaman kesalahan sementara tidak memicu peringatan dengan sendirinya.
Apakah saya harus meng-hash halaman atau membandingkan perbedaannya?
Keduanya. Hash adalah pemeriksaan kesamaan yang cepat untuk menentukan apakah perlu melakukan tindakan apa pun. Perbandingan terpadu (unified diff) dari difflib adalah yang dibaca pengguna dalam pemberitahuan. Lakukan hashing terlebih dahulu, lakukan perbandingan hanya jika hasil hashing berbeda, dan kirimkan hasil perbandingan bersama pemberitahuan agar pembaca dapat menilai perubahan tanpa perlu membuka halaman.
Seberapa sering deteksi perubahan situs web harus dijalankan?
Sesuaikan frekuensinya dengan tingkat risiko dan tingkat perubahan halaman. Halaman hukum dan kebijakan jarang berubah lebih dari beberapa kali dalam setahun, jadi pemeriksaan harian sudah cukup. Dokumen API yang aktif dan catatan perubahan (changelog) memerlukan pemeriksaan setiap jam. Jalankan proses ini melalui cron atau jadwal GitHub Actions, dan pastikan intervalnya cukup lama agar pengambilan data yang tidak stabil dapat dicoba ulang sebelum dilaporkan.
Apa saja pertimbangan terkait kepatuhan dan batasan?
Memantau dokumentasi publik dan halaman kebijakan dengan frekuensi yang wajar merupakan penggunaan web yang normal, tetapi bacalah syarat dan ketentuan serta robots.txt situs tersebut dan patuhi aturan tersebut; jangan memantau halaman yang dilindungi otentikasi yang tidak Anda miliki hak aksesnya. Di sisi API, paket gratis Scraper API mencakup 1.5K permintaan per bulan; pemeriksaan harian terhadap 40 halaman masih berada dalam batas tersebut.