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)
|
PDF_RE = re.compile(r"\.pdf(\?|$)", re.IGNORECASE)
|
||||||
REDDIT_RE = re.compile(r"reddit\.com/(r/\w+/comments/\w+)")
|
REDDIT_RE = re.compile(r"reddit\.com/(r/\w+/comments/\w+)")
|
||||||
WIKIPEDIA_RE = re.compile(r"wikipedia\.org/wiki/(.+)")
|
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):
|
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"
|
return "wikipedia"
|
||||||
if "arxiv.org" in url:
|
if "arxiv.org" in url:
|
||||||
return "arxiv"
|
return "arxiv"
|
||||||
if any(d in url for d in ["rss", "feed", "atom"]):
|
if RSS_RE.search(url):
|
||||||
return "rss"
|
return "rss"
|
||||||
return "web"
|
return "web"
|
||||||
|
|
||||||
@@ -479,6 +483,9 @@ class ExhaustiveScraper:
|
|||||||
lambda: self._extract_reddit(url), url
|
lambda: self._extract_reddit(url), url
|
||||||
)
|
)
|
||||||
await asyncio.sleep(settings.request_delay)
|
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":
|
elif source_type == "pdf":
|
||||||
content, title = await fetch_with_retry(
|
content, title = await fetch_with_retry(
|
||||||
lambda: self._extract_pdf(url), url
|
lambda: self._extract_pdf(url), url
|
||||||
@@ -676,6 +683,37 @@ class ExhaustiveScraper:
|
|||||||
logger.warning("Reddit extraction failed", url=url, error=str(e))
|
logger.warning("Reddit extraction failed", url=url, error=str(e))
|
||||||
return None, None
|
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]]:
|
async def _extract_pdf(self, url: str) -> tuple[Optional[str], Optional[str]]:
|
||||||
"""Download and extract PDF text"""
|
"""Download and extract PDF text"""
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ def test_detect_source_type():
|
|||||||
assert detect_source_type("https://example.com/article") == "web"
|
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():
|
def test_is_blacklisted():
|
||||||
assert is_blacklisted("https://facebook.com/something") == True
|
assert is_blacklisted("https://facebook.com/something") == True
|
||||||
assert is_blacklisted("https://en.wikipedia.org/wiki/Test") == False
|
assert is_blacklisted("https://en.wikipedia.org/wiki/Test") == False
|
||||||
|
|||||||
Reference in New Issue
Block a user