From f210b34e5fd33ae73ae1544c9b9684cf6a253542 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Fri, 3 Jul 2026 15:43:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(scraper):=20F3=20=E2=80=94=20extractor=20R?= =?UTF-8?q?SS=20+=20detecci=C3=B3n=20de=20feeds=20con=20word-boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_source_type ya clasificaba 'rss' pero no había rama en _process_source: los feeds caían a _extract_web y trafilatura no saca nada del XML (se descartaban por cortos). Ahora _extract_rss parsea el feed (feedparser en hilo, como el monitor de noticias) y siembra sus entries como fuentes nuevas: filtro de relevancia por título/URL, blacklist, dedupe, cap de 20 y respeto de max_depth. El feed en sí se marca skipped — es puro descubrimiento, no tiene contenido que trocear. De paso, la detección pasa de substring puro (clasificaba /feedback y /atomic como rss) a RSS_RE con límites de palabra. Tests de ambos. Co-Authored-By: Claude Fable 5 --- src/scraper/exhaustive.py | 40 ++++++++++++++++++++++++++++++++++++++- tests/test_scraper.py | 10 ++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/scraper/exhaustive.py b/src/scraper/exhaustive.py index 48dd803..ba3dcbe 100644 --- a/src/scraper/exhaustive.py +++ b/src/scraper/exhaustive.py @@ -53,6 +53,10 @@ YOUTUBE_RE = re.compile(r"(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11 PDF_RE = re.compile(r"\.pdf(\?|$)", re.IGNORECASE) REDDIT_RE = re.compile(r"reddit\.com/(r/\w+/comments/\w+)") WIKIPEDIA_RE = re.compile(r"wikipedia\.org/wiki/(.+)") +# Segmentos de path típicos de feeds, con límite de palabra: el substring puro +# clasificaba /feedback o /atomic como rss. +RSS_RE = re.compile(r"/(rss|feeds?|atom)(/|\.xml|\.rss|$)|\.rss$|format=rss", + re.IGNORECASE) async def fetch_with_retry(fetch_fn, source_name: str, max_retries: int = 3): @@ -82,7 +86,7 @@ def detect_source_type(url: str) -> str: return "wikipedia" if "arxiv.org" in url: return "arxiv" - if any(d in url for d in ["rss", "feed", "atom"]): + if RSS_RE.search(url): return "rss" return "web" @@ -479,6 +483,9 @@ class ExhaustiveScraper: lambda: self._extract_reddit(url), url ) await asyncio.sleep(settings.request_delay) + elif source_type == "rss": + # El feed no tiene contenido propio: es puro descubrimiento. + return await self._extract_rss(source_id, url, source["depth"]) elif source_type == "pdf": content, title = await fetch_with_retry( lambda: self._extract_pdf(url), url @@ -676,6 +683,37 @@ class ExhaustiveScraper: logger.warning("Reddit extraction failed", url=url, error=str(e)) return None, None + async def _extract_rss(self, source_id: int, url: str, depth: int) -> int: + """RSS/Atom: siembra las entries del feed como fuentes nuevas (filtradas + por relevancia de título/URL) y marca el feed como skipped — no tiene + contenido propio que trocear. feedparser.parse es bloqueante: va en hilo, + igual que en el monitor de noticias. Devuelve nº de fuentes añadidas.""" + parsed = await asyncio.to_thread(feedparser.parse, url, + agent=HEADERS["User-Agent"]) + entries = parsed.entries or [] + added = 0 + if depth < settings.max_depth: + for e in entries[:20]: # cap, como los 30 links/página de web + link = normalize_url(getattr(e, "link", "") or "") + title = getattr(e, "title", "") or "" + if (not link.startswith("http") or is_blacklisted(link) + or not self._url_is_relevant(link, title)): + continue + if await self.db.source_exists(self.session_id, link): + continue + await self.db.add_source( + self.session_id, link, detect_source_type(link), + depth=depth + 1, title=title or None + ) + added += 1 + await self.db.update_source( + source_id, status="skipped", + error=f"rss feed: {added} entries sembradas" + ) + logger.info("RSS feed seeded", url=url[:60], + entries=len(entries), added=added) + return added + async def _extract_pdf(self, url: str) -> tuple[Optional[str], Optional[str]]: """Download and extract PDF text""" import pdfplumber diff --git a/tests/test_scraper.py b/tests/test_scraper.py index 8324198..edbaa1a 100644 --- a/tests/test_scraper.py +++ b/tests/test_scraper.py @@ -11,6 +11,16 @@ def test_detect_source_type(): assert detect_source_type("https://example.com/article") == "web" +def test_detect_source_type_rss(): + assert detect_source_type("https://thedebrief.org/feed/") == "rss" + assert detect_source_type("https://example.com/rss") == "rss" + assert detect_source_type("https://example.com/feeds/all.atom.xml") == "rss" + assert detect_source_type("https://www.liberationtimes.com/?format=rss") == "rss" + # Falsos positivos del substring viejo: no son feeds. + assert detect_source_type("https://example.com/feedback") == "web" + assert detect_source_type("https://example.com/atomic-theory") == "web" + + def test_is_blacklisted(): assert is_blacklisted("https://facebook.com/something") == True assert is_blacklisted("https://en.wikipedia.org/wiki/Test") == False