Tugas pertama sebuah feed reader adalah menemukan feed-nya, dan di banyak situs berita besar feed itu memang tidak ada. Jalan pintas yang biasa dipakai orang adalah scraping halaman depan, yang rapuh dan tidak sopan. Ada urutan langkah yang lebih baik, dan sebagian besar sudah diumumkan oleh situs itu sendiri: di tag <link>, di robots.txt, dan di sitemap. Aplikasi desktop osFoundry melakukan ini dengan Rust (news_feed.rs, fungsi news_discover_feeds, news_discover_sections dan news_fetch_section). Di bawah ini prosedur yang sama dalam Python dengan standard library saja, dijalankan terhadap delapan situs berita sungguhan, lengkap dengan jumlah request yang dihabiskan tiap situs dan di mana hasil akhirnya.
Aturan dasar sebelum request pertama
Skrip discovery mengakses situs yang belum pernah ia lihat, jadi sopan santunnya harus tertanam di satu fungsi yang membuat request, bukan diserahkan ke pemanggil.
UA = "Mozilla/5.0 (compatible; osFoundryNews/1.0; +https://osfoundry.io)" PAUSE = 1.0 # seconds between requests to the same site MAX_REQUESTS = 25 # per site, everything included class Site: def __init__(self, host): self.root = f"https://{host}/" self.log, self.robots, self._last = [], None, 0.0 def get(self, url, step): if len(self.log) >= MAX_REQUESTS: raise Budget(url) if self.robots and not url.endswith("/robots.txt") and not self.robots.can_fetch(UA, url): self.log.append((step, url, "robots-disallow", 0, 0)) return None, None wait = self._last + PAUSE - time.monotonic() if wait > 0: time.sleep(wait) t0 = time.monotonic() req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Encoding": "gzip"}) status, body = None, None try: with urllib.request.urlopen(req, timeout=15) as r: raw = r.read(8 * 1024 * 1024) if r.headers.get("Content-Encoding") == "gzip" or url.endswith(".gz"): raw = gzip.decompress(raw) status, body = r.status, raw.decode("utf-8", "replace") except urllib.error.HTTPError as e: status = e.code except Exception as e: status = type(e).__name__ self._last = time.monotonic() self.log.append((step, url, status, len(body or ""), round((self._last - t0) * 1000))) return status, body Satu request dalam satu waktu, jaraknya minimal satu detik, batas keras per situs, User-Agent yang menyebutkan siapa yang bertanya (sama dengan yang dikirim aplikasi), dan robots.txt dicek lewat urllib.robotparser sebelum setiap URL lain. Log inilah sumber semua angka di bawah.
robots.txt diambil paling awal, sebelum halaman depan. Di situs yang ternyata punya feed, ini memakan satu request tambahan, tapi dengan begitu aturannya berlaku untuk semua request sesudahnya, dan file itu juga memuat daftar sitemap yang memang dibutuhkan di langkah berikutnya. Ada dua kondisi berhenti lagi di atasnya: kalau robots.txt melarang root situs untuk User-Agent ini, atau halaman depan menjawab 401, 403 atau 429, skrip berhenti untuk situs itu.
Langkah 1: feed yang diumumkan situs
Situs yang punya RSS hampir selalu menyebutkannya di head halaman. Satu request untuk halaman depan, satu kali parsing HTML:
class Links(HTMLParser): def __init__(self): super().__init__() self.alternates, self.anchors = [], [] def handle_starttag(self, tag, attrs): a = dict(attrs) if tag == "link" and "alternate" in (a.get("rel") or "").lower().split(): t = (a.get("type") or "").lower() if ("rss" in t or "atom" in t) and a.get("href"): self.alternates.append(a["href"]) elif tag == "a" and a.get("href"): self.anchors.append(a["href"]) Di The Verge, TechCrunch dan ITmedia, urusannya selesai di sini: masing-masing dua request (robots.txt dan halaman depan), beres dalam kurang dari 4 detik termasuk jeda. Feed ITmedia ada di host lain (rss.itmedia.co.jp), dan itu salah satu alasan untuk lebih percaya pada link yang diumumkan daripada menebak path.
Langkah 2: sembilan alamat yang umum
Kalau tidak ada yang diumumkan, aplikasi mencoba sembilan path feed yang umum dan berhenti di path pertama yang bisa di-parse sebagai RSS atau Atom: /feed, /feed/, /rss, /rss.xml, /feed.xml, /atom.xml, /index.xml, /feeds/posts/default, /rss/index.xml. Path ini menangkap default CMS (WordPress, Blogger, Hugo, Ghost) di situs yang tema-nya lupa memasang tag <link>.
Di tiga koran nasional Jepang dalam pengujian ini, Nikkei, Asahi dan Yomiuri, ke-27 percobaan gagal: 26 menjawab 404 dan satu (asahi.com/rss) menjawab 403. Itu 9 dari 13 atau 14 request yang dihabiskan tiap situs. Kalau Anda hanya peduli pada penerbit besar, membaca sitemap sebelum percobaan ini akan memangkas tiap run menjadi 4 atau 5 request. Untuk tool umum yang juga harus menangani blog kecil, percobaan ini tetap layak dipertahankan.
Langkah 3: apa yang ditunjuk robots.txt
robots.txt mencantumkan sitemap lewat baris Sitemap:. Sebuah sitemap bisa berupa <sitemapindex> yang menunjuk ke sitemap lain, atau <urlset> berisi halaman. Skrip membaca hingga empat sitemap yang tercantum, masuk satu level ke dalam index (hingga lima anak), dan berhenti di sitemap pertama yang bisa dipakai.
Menentukan sitemap mana yang bisa dipakai adalah bagian yang perlu hati-hati. Tes yang paling jelas adalah namespace Google News, xmlns:news=, yang oleh format ini dikhususkan untuk artikel yang baru terbit. Nikkei menunjukkan kenapa tes itu tidak cukup. sections_sitemap.xml miliknya mendeklarasikan namespace tersebut, padahal 184 dari 187 URL-nya adalah halaman rubrik seperti /business/startups/. Jadi skrip menilai sitemap dari URL di dalamnya:
def usable(info): """Judge a sitemap by the URLs in it, not by the namespace it declares.""" if info.get("section_share", 0) >= 0.7: return "sections" if info.get("article_share", 0) >= 0.7 and info["kind"] == "news_sitemap": return "news sitemap" if info.get("article_share", 0) >= 0.7 and info.get("dated"): return "article sitemap" return None section_share dan article_share adalah proporsi path <loc> yang lolos dua tes bentuk di bawah. Ambang 70 % adalah angka yang dipakai aplikasi untuk sitemap rubrik.
Berikut hasil akhir tiap situs:
| Situs | Request | Detik | Hasil akhir |
|---|---|---|---|
| www.theverge.com | 2 | 3.7 | RSS dari <link rel="alternate"> |
| techcrunch.com | 2 | 3.4 | RSS dari <link rel="alternate"> |
| www.itmedia.co.jp | 2 | 3.6 | RSS dari <link rel="alternate"> (di rss.itmedia.co.jp) |
| www.yomiuri.co.jp | 13 | 27.2 | News sitemap, 340 artikel, semuanya dari 24 jam terakhir |
| www.asahi.com | 13 | 47.3 | News sitemap (national), 61 artikel, 36 dari 24 jam terakhir |
| www.nikkei.com | 14 | 30.8 | Sitemap rubrik, 184 rubrik; halaman Startups menghasilkan 20 artikel |
| apnews.com | 2 | 2.9 | Berhenti: halaman depan menjawab 403 |
| www.reuters.com | 1 | 1.5 | Berhenti: robots.txt melarang User-Agent ini |
Diukur dari sebuah Mac pada November 2026, satu situs demi satu situs. Sebagian besar detiknya berasal dari jeda satu detik; respons tunggal paling lambat adalah 6.9 s, yaitu 403 dari asahi.com/rss.
Yomiuri dan Asahi tidak butuh apa-apa lagi: news sitemap pada dasarnya adalah feed tanpa ringkasan, berisi URL, judul dan waktu terbit untuk tiap artikel. Index milik Asahi memecahnya per desk redaksi (sitemap_national.xml, sitemap_business.xml dan seterusnya), jadi mengikutinya berarti memilih desk, mirip dengan memilih rubrik.
Langkah 4: halaman rubrik, kalau hanya itu yang ada
Sitemap Nikkei lebih mirip direktori situs daripada daftar artikel. Tes bentuk untuk rubrik adalah kebalikan dari tes artikel, ditambah batas kedalaman dan blocklist pendek:
def looks_like_article(path): segs = [s for s in path.split("/") if s] if not segs: return False last = re.sub(r"(\.html?)+$", "", segs[-1]) if len(last) < 6: return False if len(segs) < 2: return last.count("-") >= 3 if any(re.fullmatch(r"\d{4}", a) and re.fullmatch(r"\d{2}", b) for a, b in zip(segs, segs[1:])): return True if len(last) >= 8 and re.search(r"\d", last): return True return last.count("-") >= 3 def looks_like_section(path): if looks_like_article(path): return False segs = [s for s in path.split("/") if s] if not segs or len(segs) > 3: return False if "." in segs[-1] and not segs[-1].endswith(".html"): return False return not any(s.lower() in BLOCKED for s in segs) # search, login, about, privacy, ... Sebuah artikel adalah path yang mengandung pasangan tanggal (/2026/11/06/), segmen terakhir mirip ID sepanjang 8 karakter atau lebih yang mengandung angka, atau slug dengan minimal tiga tanda hubung. Aturan ID tidak diterapkan di kedalaman 1, karena di sana aturan itu akan ikut menelan halaman tag seperti /coronavirus-2026. Tiga tanda hubung, bukan dua, karena /news/tech-and-science/ adalah rubrik di banyak situs.
Mengikuti sebuah rubrik lalu cukup satu request per pengecekan: ambil halaman rubrik, simpan link ke host yang sama yang berbentuk artikel, dan buang yang nyasar.
shaped = [u for u in unique if looks_like_article(urlparse(u).path)] first = lambda u: next((s for s in urlparse(u).path.split("/") if s), None) counts = {} for u in shaped: counts[first(u)] = counts.get(first(u), 0) + 1 if counts: seg, n = max(counts.items(), key=lambda kv: kv[1]) if n * 10 >= len(shaped) * 6: shaped = [u for u in shaped if first(u) == seg] Halaman Startups Nikkei punya 271 link berbeda ke host yang sama. Tes bentuk dan aturan segmen dominan (kalau 60 % hasil yang cocok diawali segmen pertama yang sama, di sini /article/, sisanya dibuang) menyisakan 20, semuanya berbentuk /article/DGXZQOUC026TI0S6A101C2000000/. 251 yang dibuang adalah menu, halaman tag dan tema, /login, /search, halaman data pasar dan sejenisnya.
Aplikasi sampai di titik yang sama. Pemilih rubriknya untuk www.nikkei.com menampilkan "Showing 60 of 185 sections": 184 dari sitemap ditambah halaman depan, yang baru ditambahkan setelah dicek bahwa halaman depan itu sendiri memuat link artikel.
Satu hal lagi yang dibocorkan sitemap rubrik Nikkei: setiap rubrik punya <lastmod>, dan 157 dari 187 berubah dalam 24 jam sebelumnya. Follower yang memantau banyak rubrik bisa membaca satu file itu dulu dan melewati halaman rubrik yang tidak berubah.
Kalau jawabannya tidak
AP menjawab 403 untuk halaman depan, jadi skrip berhenti setelah dua request. Tanpa aturan itu, skrip akan lanjut membuat 13 request lagi yang juga mengembalikan 403, seperti yang terjadi pada versi skrip ini sebelumnya. robots.txt milik Reuters mencantumkan crawler tertentu yang diizinkan dan melarang semua yang lain, jadi skrip berhenti setelah satu request dan tidak pernah mengambil halaman depan.
Keduanya bukan teka-teki yang perlu diakali. Situs yang menjawab 403 ke klien yang sudah mengidentifikasi diri, atau berkata tidak di robots.txt, sudah menjawab pertanyaannya. Perilaku yang berguna bagi tool feed adalah menyampaikannya dengan jelas lalu berhenti, sehingga situs hanya menanggung satu atau dua request, bukan crawl penuh.
Aplikasi desktop osFoundry menjalankan discovery ini secara lokal saat Anda menambahkan sumber di layar News; halaman news firehose menjelaskan apa lagi yang bisa dilakukan layar tersebut.



