Skip to content

Commit 5fac6c1

Browse files
committed
feat: migrate scrapers to RSS feeds and add test suite coverage
1 parent a99e099 commit 5fac6c1

3 files changed

Lines changed: 49 additions & 98 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ node_modules/
2121
tmp/
2222
*.log
2323
test_scrapers.py
24+
test_run_job.py
25+
verify_rss.py
2426

2527
# OS
2628
.DS_Store

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
### Componentes
2626

27-
1. **GitHub Actions** (`.github/workflows/bot.yml`): El motor del bot. Se ejecuta automáticamente cada 3 horas o manualmente vía webhook. Realiza el scraping, resume con IA y envía a Telegram.
27+
1. **GitHub Actions** (`.github/workflows/bot.yml`): El motor del bot. Se ejecuta automáticamente cada 3 horas o manualmente vía webhook. Realiza el scraping (RSS), resume con IA y envía a Telegram.
2828
2. **Cloudflare Workers** (`api/webhook.js`): El receptor. Recibe mensajes de Telegram y dispara el Action de GitHub cuando se usa `/noticias`.
2929
3. **Repositorio externo** (`DevCop95/cYHBernews`): El historial. Las noticias se guardan en `noticias.json` para deduplicación y persistencia.
3030

run_job.py

Lines changed: 46 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@ def is_recent(date_str):
6868

6969
try:
7070
clean = date_str.split('T')[0].strip()
71-
for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%Y/%m/%d'):
71+
# Intentar formatos comunes + formato RSS (RFC 822)
72+
for fmt in ('%Y-%m-%d', '%d/%m/%Y', '%Y/%m/%d', '%a, %d %b %Y %H:%M:%S %z'):
7273
try:
73-
dt = datetime.strptime(clean, fmt)
74+
dt = datetime.strptime(clean if fmt != '%a, %d %b %Y %H:%M:%S %z' else date_str, fmt)
75+
# Normalizar a offset-naive para la comparación si es necesario
76+
if dt.tzinfo:
77+
dt = dt.replace(tzinfo=None)
7478
return datetime.now() - dt < timedelta(days=2)
7579
except:
7680
continue
@@ -247,6 +251,39 @@ def send_to_telegram(message):
247251
except Exception as e:
248252
logger.error(f"Error Telegram: {e}")
249253

254+
# ── RSS Scraper ───────────────────────────────────────────────────────────────
255+
256+
def scrape_rss_feed(url, source_name):
257+
try:
258+
r = requests.get(url, headers=HEADERS, timeout=15)
259+
r.raise_for_status()
260+
soup = BeautifulSoup(r.text, 'xml')
261+
262+
items = []
263+
for entry in soup.find_all('item', limit=10):
264+
title = entry.title.text.strip() if entry.title else ""
265+
link = entry.link.text.strip() if entry.link else ""
266+
pub_date = entry.pubDate.text.strip() if entry.pubDate else ""
267+
268+
if not title or not link:
269+
continue
270+
271+
if pub_date and not is_recent(pub_date):
272+
continue
273+
274+
items.append({
275+
'title': title,
276+
'link': link,
277+
'source': source_name
278+
})
279+
# Solo retornamos la primera noticia válida para mantener consistencia con el flujo actual
280+
if items:
281+
return items
282+
283+
except Exception as e:
284+
logger.error(f"RSS Error ({source_name}): {e}")
285+
return []
286+
250287
# ── Scrapers ──────────────────────────────────────────────────────────────────
251288

252289
BLOCKED_URLS = {
@@ -261,107 +298,19 @@ def send_to_telegram(message):
261298
}
262299

263300
def scrape_cybersecurity_news():
264-
try:
265-
r = requests.get(
266-
"https://cybersecuritynews.es/category/actualidad/inteligencia-artificial/",
267-
headers=HEADERS, timeout=15
268-
)
269-
soup = BeautifulSoup(r.text, 'html.parser')
270-
271-
# Palabras clave para saltar noticias "sticky" o promocionales viejas
272-
bad_keywords = ["Insurance Day", "Puertas Abiertas", "CyberCoffee", "CyberWebinar"]
273-
274-
for article in soup.find_all('article', limit=15):
275-
title_tag = article.find(['h1', 'h2', 'h3'])
276-
link_tag = title_tag.find('a') if title_tag else article.find('a', href=True)
277-
if not link_tag:
278-
continue
279-
href, title = link_tag['href'], link_tag.text.strip()
280-
title = title.replace("AntAnterior", "").replace("Siguiente", "").strip()
281-
282-
# Filtros
283-
if href in BLOCKED_URLS:
284-
continue
285-
if len(title) <= 25:
286-
continue
287-
if any(k.lower() in title.lower() for k in bad_keywords):
288-
continue
289-
290-
return [{'title': title, 'link': href, 'source': 'CyberSecurity News'}]
291-
except Exception as e:
292-
logger.error(f"CSN error: {e}")
293-
return []
301+
# RSS de CSN (generalmente incluye IA si es el feed principal o de categoría)
302+
return scrape_rss_feed("https://cybersecuritynews.es/feed/", "CyberSecurity News")
294303

295304
def scrape_welivesecurity():
296-
try:
297-
r = requests.get("https://www.welivesecurity.com/la-es/", headers=HEADERS, timeout=15)
298-
soup = BeautifulSoup(r.text, 'html.parser')
299-
for article in soup.find_all('div', class_='article-list-card', limit=5):
300-
# Nuevo selector de fecha para WLS
301-
info_tag = article.find('div', class_='article-title-info')
302-
date_text = ""
303-
if info_tag:
304-
spans = info_tag.find_all('span')
305-
if spans:
306-
date_text = spans[-1].text.strip()
307-
308-
if not date_text:
309-
time_tag = article.find('time')
310-
date_text = time_tag.text.strip() if time_tag else ""
311-
312-
if date_text and not is_recent(date_text):
313-
continue
314-
315-
link_tag = article.find('a', href=True)
316-
title_tag = article.find('p', class_='title') or article.find(['h2', 'h3'])
317-
title = title_tag.text.strip() if title_tag else ""
318-
if title and link_tag:
319-
href = link_tag['href']
320-
return [{'title': title,
321-
'link': href if href.startswith('http') else f"https://www.welivesecurity.com{href}",
322-
'source': 'WeLiveSecurity'}]
323-
except Exception as e:
324-
logger.error(f"WLS error: {e}")
325-
return []
305+
return scrape_rss_feed("https://www.welivesecurity.com/la-es/feed/", "WeLiveSecurity")
326306

327307
def scrape_xataka():
328-
try:
329-
r = requests.get("https://www.xataka.com/categoria/robotica-e-ia", headers=HEADERS, timeout=15)
330-
soup = BeautifulSoup(r.text, 'html.parser')
331-
for article in soup.find_all('article', class_='abstract-article', limit=5):
332-
time_tag = article.find('time')
333-
date_val = time_tag['datetime'] if time_tag and time_tag.has_attr('datetime') else None
334-
if date_val and not is_recent(date_val):
335-
continue
336-
337-
title_tag = article.find('h2')
338-
link_tag = title_tag.find('a') if title_tag else None
339-
if title_tag and link_tag:
340-
return [{'title': title_tag.text.strip(), 'link': link_tag['href'], 'source': 'Xataka'}]
341-
except Exception as e:
342-
logger.error(f"Xataka error: {e}")
343-
return []
308+
# Feed verificado de Xataka
309+
return scrape_rss_feed("https://www.xataka.com/index.xml", "Xataka")
344310

345311
def scrape_wired_espanol():
346-
try:
347-
r = requests.get("https://es.wired.com/tag/inteligencia-artificial", headers=HEADERS, timeout=15)
348-
soup = BeautifulSoup(r.text, 'html.parser')
349-
for article in soup.find_all('div', class_=lambda x: x and 'SummaryItemContent' in x, limit=5):
350-
time_tag = article.find('time')
351-
date_val = time_tag.text.strip() if time_tag else None
352-
if date_val and not is_recent(date_val):
353-
continue
354-
link_tag = article.find('a')
355-
if link_tag:
356-
title = link_tag.text.strip()
357-
if len(title) > 20:
358-
href = link_tag['href']
359-
return [{'title': title,
360-
'link': href if href.startswith('http') else f"https://es.wired.com{href}",
361-
'source': 'WIRED en Español'}]
362-
except Exception as e:
363-
logger.error(f"WIRED error: {e}")
364-
return []
312+
# Feed de Wired España (estándar, a veces requiere el trailing slash)
313+
return scrape_rss_feed("https://es.wired.com/feed/rss", "WIRED en Español")
365314

366315
# ── Main ──────────────────────────────────────────────────────────────────────
367316

0 commit comments

Comments
 (0)