feat(scraper): F3 — extractor RSS + detección de feeds con word-boundary
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9dae61fde8
commit
f210b34e5f
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user