Geonode logo
Geonode Team

Geonode Team

Diperbarui: 16 Oktober 2026

Diterbitkan: 16 Oktober 2026

Pencarian Web Bertahap: Memperbarui Halaman yang Berubah Tanpa Harus Melakukan Pencarian Ulang Seluruh Situs

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.

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.

SinyalBiayaKeandalanFungsi dalam modul ini
Sitemap lastmodsatu kali pengambilan sitemaprendah hingga sedangmengatur ulang next_check menjadi 0
ETag / Last-Modifiedsatu permintaan, tanpa renderingtinggi jika didukung304 berarti tidak berubah
Hash kontensatu kali pengambilan penuhtinggiperbandingan dengan data acuan
Interval adaptiftidak adan/amenentukan kapan harus memeriksa ulang
404 / 410satu permintaantinggimenandai gone
Tidak ada dalam peta situs selama N kali pengambilantidak adarendahmemaksa 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.