Lo primero que tiene que hacer un lector de feeds es encontrar el feed, y en muchos sitios de noticias grandes no hay ninguno que encontrar. El plan B al que recurre casi todo el mundo es scrapear la portada, algo frágil y poco educado. Hay un orden mejor para ir probando cosas, y casi todo lo anuncia el propio sitio: en una etiqueta <link>, en robots.txt y en los sitemaps. La app de escritorio de osFoundry lo hace en Rust (news_feed.rs, funciones news_discover_feeds, news_discover_sections y news_fetch_section). A continuación está el mismo procedimiento en Python con la biblioteca estándar, ejecutado contra ocho sitios de noticias reales, con las peticiones que costó cada uno y dónde terminó cada uno.
Reglas básicas antes de la primera petición
Un script de descubrimiento visita sitios que nunca ha visto, así que la buena educación tiene que estar dentro de la única función que hace peticiones, no quedar en manos de quien la llama.
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
Una petición cada vez, con al menos un segundo entre ellas, un tope fijo por sitio, un User-Agent que dice quién pregunta (el mismo que envía la app) y robots.txt consultado mediante urllib.robotparser antes de cualquier otra URL. Del log salen las cifras que aparecen más abajo.
robots.txt se descarga primero, antes que la portada. En los sitios que acaban teniendo feed eso cuesta una petición, pero hace que las reglas se apliquen a todo lo que viene después, y además trae la lista de sitemaps que los pasos posteriores necesitan de todos modos. Por encima hay dos condiciones de parada más: si robots.txt prohíbe la raíz del sitio para este User-Agent, o si la portada responde 401, 403 o 429, el script deja ese sitio.
Paso 1: el feed que anuncia el sitio
Los sitios con RSS casi siempre lo indican en el head de la página. Una petición para la portada y un parseo de 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"])
En The Verge, TechCrunch e ITmedia, aquí se acabó: dos peticiones cada uno (robots.txt y la portada), en menos de 4 segundos contando la pausa. El feed de ITmedia vive en otro host (rss.itmedia.co.jp), que es una de las razones para fiarse del enlace anunciado en lugar de adivinar rutas.
Paso 2: las nueve direcciones de siempre
Cuando no se anuncia nada, la app prueba nueve rutas de feed habituales y se detiene en la primera que se puede parsear como RSS o Atom: /feed, /feed/, /rss, /rss.xml, /feed.xml, /atom.xml, /index.xml, /feeds/posts/default, /rss/index.xml. Con esto se pillan las rutas por defecto de los CMS (WordPress, Blogger, Hugo, Ghost) en sitios cuyo tema se olvidó de la etiqueta <link>.
En los tres periódicos nacionales japoneses de la prueba, Nikkei, Asahi y Yomiuri, fallaron los 27 intentos: 26 respondieron 404 y uno (asahi.com/rss) respondió 403. Eso supuso 9 de las 13 o 14 peticiones que costó cada sitio. Si solo te interesan los grandes medios, leer los sitemaps antes de estos intentos dejaría cada una de esas ejecuciones en 4 o 5 peticiones. Para una herramienta general que también tiene que lidiar con blogs pequeños, los intentos se ganan su sitio.
Paso 3: a qué apunta robots.txt
robots.txt enumera los sitemaps con líneas Sitemap:. Un sitemap es o bien un <sitemapindex> que apunta a más sitemaps, o bien un <urlset> de páginas. El script lee hasta cuatro de los sitemaps listados, baja un nivel dentro de un índice (hasta cinco hijos) y se detiene en el primero que le sirve.
Lo delicado es decidir qué hace que un sitemap sirva. La prueba obvia es el namespace de Google News, xmlns:news=, que el formato reserva para artículos publicados recientemente. Nikkei demuestra por qué esa prueba no basta. Su sections_sitemap.xml declara el namespace, y 184 de sus 187 URL son páginas de sección como /business/startups/. Así que el script juzga un sitemap por las URL que contiene:
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 y article_share son las fracciones de rutas <loc> que superan las dos pruebas de forma que se ven más abajo. El umbral del 70 % es el que usa la app para los sitemaps de secciones.
Así terminó cada sitio:
| Sitio | Peticiones | Segundos | Dónde terminó |
|---|---|---|---|
| www.theverge.com | 2 | 3,7 | RSS desde <link rel="alternate">
|
| techcrunch.com | 2 | 3,4 | RSS desde <link rel="alternate">
|
| www.itmedia.co.jp | 2 | 3,6 | RSS desde <link rel="alternate"> (en rss.itmedia.co.jp) |
| www.yomiuri.co.jp | 13 | 27,2 | Sitemap de noticias, 340 artículos, todos de las últimas 24 horas |
| www.asahi.com | 13 | 47,3 | Sitemap de noticias (nacional), 61 artículos, 36 de las últimas 24 horas |
| www.nikkei.com | 14 | 30,8 | Sitemap de secciones, 184 secciones; la página de Startups dio 20 artículos |
| apnews.com | 2 | 2,9 | Parado: la portada respondió 403 |
| www.reuters.com | 1 | 1,5 | Parado: robots.txt prohíbe este User-Agent |
Medido desde un Mac en noviembre de 2026, un sitio detrás de otro. Buena parte de los segundos es la pausa de un segundo; la respuesta individual más lenta fue de 6,9 s, el 403 de asahi.com/rss.
Yomiuri y Asahi no necesitan nada más: un sitemap de noticias es un feed sin resúmenes, con una URL, un título y una fecha de publicación por artículo. El índice de Asahi lo divide por área de la redacción (sitemap_national.xml, sitemap_business.xml, etc.), así que seguirlo consiste en elegir áreas, algo muy parecido a elegir secciones.
Paso 4: páginas de sección, cuando no hay otra cosa
El sitemap de Nikkei es un directorio del sitio más que una lista de artículos. La prueba de forma para una sección es la inversa de la de artículo, más un límite de profundidad y una lista de bloqueo corta:
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, ...
Un artículo es una ruta con un par de fecha (/2026/11/06/), un último segmento con pinta de ID de 8 caracteres o más que contiene un dígito, o un slug con al menos tres guiones. La regla del ID no se aplica en profundidad 1, porque ahí se tragaría páginas de etiqueta como /coronavirus-2026. Tres guiones y no dos, porque /news/tech-and-science/ es una sección en un montón de sitios.
Seguir una sección es entonces una petición por comprobación: descargar la página de sección, quedarse con los enlaces del mismo host que tengan forma de artículo y descartar los que se salen del patrón.
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]
La página de Startups de Nikkei tenía 271 enlaces distintos del mismo host. La prueba de forma y la regla del segmento dominante (si el 60 % de las coincidencias empieza por el mismo primer segmento, aquí /article/, el resto fuera) dejaron 20, todos del tipo /article/DGXZQOUC026TI0S6A101C2000000/. Los 251 descartados eran menús, páginas de etiquetas y temas, /login, /search, páginas de datos de mercado y similares.
La app llega al mismo sitio. Su selector de secciones para www.nikkei.com dice "Showing 60 of 185 sections": las 184 del sitemap más la portada, que solo añade después de comprobar que la propia portada tiene enlaces a artículos.
El sitemap de secciones de Nikkei revela una cosa más: cada sección tiene un <lastmod>, y 157 de las 187 habían cambiado en las 24 horas anteriores. Un lector que siga muchas secciones puede leer primero ese único archivo y saltarse las páginas de sección que no se han movido.
Cuando la respuesta es no
AP respondió 403 a la portada, así que el script paró tras dos peticiones. Sin esa regla habría hecho 13 peticiones más que también devolvían 403, que es lo que hacía una versión anterior de este script. El robots.txt de Reuters enumera los crawlers concretos que permite y prohíbe a todos los demás, así que el script paró tras una petición y ni siquiera descargó la portada.
Ninguno de los dos es un acertijo que haya que esquivar. Un sitio que responde 403 a un cliente identificado, o que dice que no en robots.txt, ya ha contestado. Lo útil en una herramienta de feeds es decirlo claramente y parar, lo que le cuesta al sitio una o dos peticiones en lugar de un rastreo.
La app de escritorio de osFoundry hace este descubrimiento en local cuando añades una fuente en su pantalla News; la página del news firehose describe todo lo demás que hace esa pantalla.

Top comments (0)