Author SHA1 Message Date
Renovate Bot 3fed9c7aed chore(deps): update dependency scikit-learn to v1.9.0 2026-07-06 12:00:42 +00:00
ChemaVXandClaude Fable 5 d31badeb5b refactor(ghost): _admin_get/_admin_headers compartidos en GhostPublisher
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
find_draft_by_title y fetch_published_menu duplicaban línea a línea el GET
admin de Ghost (token, URL, ClientSession, dict de headers, status check,
resp.json), y el dict Authorization/Accept-Version/Accept-Encoding estaba
copiado en 3 sitios — el incidente brotli ya demostró que el fix por copia
se deja sitios. Ahora los headers se construyen en un único punto
(_admin_headers, con SAFE_ACCEPT_ENCODING de config) y el GET en _admin_get;
publish_draft usa los mismos headers. aiohttp pasa a import de módulo en
generator.py (era 'import aiohttp as _aio' repetido dentro de dos métodos).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:00:31 +00:00
ChemaVXandClaude Fable 5 fbe8ae1885 fix(scraper): _unwrap_news_link normaliza //url y descarta apiclick sin URL usable
Un ?url= protocol-relative (//publisher.com/...) caía por el startswith('http')
y devolvía el link apiclick.aspx crudo, que pasaba blacklist y relevancia y
acababa raspado como página de redirect/consent de Bing. Ahora se antepone
https: a los protocol-relative y, si no se puede extraer URL válida de un
apiclick, se devuelve '' para que el caller lo salte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:58:40 +00:00
ChemaVXandClaude Fable 5 b58a3ce118 fix(ghost): find_draft_by_title compara títulos normalizados, no igualdad exacta
Si Ghost normaliza el título al guardar (trunca a 255 un H1 largo del LLM,
colapsa whitespace), la igualdad exacta no encontraba el draft recién creado
y la recuperación fallaba justo en el escenario que debe cubrir. _norm_title
aplica el mismo colapso+truncado a ambos lados de la comparación.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:56:50 +00:00
ChemaVXandClaude Fable 5 634f38e016 fix(scraper): SAFE_ACCEPT_ENCODING compartida — cubre la sesión SearXNG olvidada
La sesión aiohttp de _search_searxng (camino primario de búsqueda) construía
sus headers sin Accept-Encoding: heredaba el default de aiohttp, que volvería
a anunciar br si algún día se reinstala un backend brotli. El valor vive ahora
en src/config.py (SAFE_ACCEPT_ENCODING) en vez de copiado literal por sitio.

También corrige el comentario de HEADERS que afirmaba que brotlicffi seguía
instalado como fallback — 397546a lo quitó: un br no anunciado hoy falla sin
recuperación y la fuente se pierde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:56:07 +00:00
ChemaVXandClaude Fable 5 3d74857d85 fix(ghost): guard anti-duplicados dentro de publish_draft, solo tras POST aceptado
El guard anterior vivía en el fallback de autofill y casaba con CUALQUIER
draft del mismo título: un fallo pre-POST (Ollama caído) con un draft viejo
del mismo topic descartaba en silencio el contenido recién generado.

Ahora la recuperación vive dentro de publish_draft y solo se activa cuando
el POST fue aceptado (2xx) y falla la lectura de la respuesta — el escenario
exacto del incidente br del 2026-07-04 — con filtro since=attempt_start en
find_draft_by_title. Cubre a todos los callers: autofill, bare publish y
/publish (antes sin proteger: reintento del usuario = draft duplicado).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:54:49 +00:00
ChemaVXandClaude Fable 5 68ae6b436c docs: KNOWN-ISSUES.md — gotchas operacionales (brotli/aiohttp, WAL, DDGS, Google News) [skip ci]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:53:34 +00:00
ChemaVXandClaude Fable 5 7d07375dd1 feat(ghost): guard anti-duplicados en el fallback de publish
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Si el autofill falla DESPUÉS de que Ghost aceptara el POST (p.ej. error
leyendo la respuesta), el draft ya existe: el fallback ahora comprueba
por título exacto entre los drafts recientes (find_draft_by_title) y
reutiliza el existente en vez de duplicar. Solo aplica al camino de
fallback-tras-excepción — el modo OFF y la re-generación deliberada
siguen creando draft nuevo como siempre. Best-effort: si el check
falla, se publica bare como antes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:26:23 +00:00
ChemaVXandClaude Fable 5 76c927f0d5 fix(ghost): Accept-Encoding gzip explícito en publish_draft y menú SEO
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Blindaje contra el default de aiohttp: si algún día se reinstala un
backend brotli, las sesiones sin Accept-Encoding volverían a anunciar
br y el decode roto de aiohttp 3.14 rompería la lectura de respuestas
de Ghost (duplicando drafts). Con el header fijado no puede regresar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:19:50 +00:00
ChemaVXandClaude Fable 5 397546a37a fix(deps): quita brotlicffi — hacía que aiohttp anunciara br por defecto
Build & Deploy ResearchOwl / build-and-push (push) Successful in 48s
Regresión de esta mañana: con un backend brotli instalado, aiohttp mete
'br' en el Accept-Encoding por defecto de toda sesión que no lo fija
(publish_draft, seo/autofill). Ghost/Cloudflare responde br y el decode
incremental roto de aiohttp 3.14.1 revienta al leer la respuesta de un
POST ya aceptado: el fallback re-publicaba → drafts duplicados y aviso
SEO perdido. Sin backend brotli nada anuncia br (el scraper ya lo quitó
de sus HEADERS explícitos en 57f341f).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:17:56 +00:00
ChemaVXandClaude Fable 5 57f341fc22 fix(scraper): no anunciar br en Accept-Encoding — decode incremental roto en aiohttp 3.14
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Diagnóstico definitivo: los streams br de disclosure.org/todaywhy.com son
VÁLIDOS (los bytes crudos descomprimen offline con brotlicffi), pero el
descompresor incremental de aiohttp 3.14.1 falla según el troceo de
chunks — intermitente entre sitios e intentos, con Brotli y con
brotlicffi. Sin anunciar br los servidores mandan gzip (verificado:
los 4 sitios afectados, 200 + contenido íntegro) y el camino zlib es
sólido. brotlicffi se queda como red por si llega br no anunciado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:25:58 +00:00
ChemaVXandClaude Fable 5 6c807212a0 fix(scraper): brotlicffi en vez de Brotli — aiohttp 3.14 falla en streams br troceados
Build & Deploy ResearchOwl / build-and-push (push) Successful in 54s
Con Brotli puro, aiohttp 3.14.1 seguía sin decodificar algunos streams
br (disclosure.org, todaywhy.com: 'Can not decode content-encoding: br'
en 3/3 intentos, mientras httpx los decodifica bien). Con brotlicffi
(que aiohttp prefiere sobre Brotli) los mismos sitios decodifican al
primer intento — verificado en el pod con install efímero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:20:04 +00:00
ChemaVXandClaude Fable 5 becc43f770 fix(scraper): instala Brotli — se perdían fuentes br en silencio
Build & Deploy ResearchOwl / build-and-push (push) Successful in 55s
HEADERS anuncia 'Accept-Encoding: gzip, deflate, br' pero aiohttp no
tenía backend brotli: cualquier web que respondiera br fallaba con
'Can not decode content-encoding: brotli' tras 3 reintentos (vistos
washingtontimes, defensescoop, disclosure.org en sesión de prueba).
Verificado local: con Brotli==1.1.0 ambos decodifican (200, encoding=br).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:16:00 +00:00
ChemaVXandClaude Fable 5 a23810b9cc feat(scraper): seed de noticias Bing News RSS tras ENABLE_NEWS_SEED (off)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Nuevo _seed_news: registra el feed de Bing News como fuente rss y
_extract_rss (F3) siembra sus entries — sin lógica de parseo duplicada.
_unwrap_news_link extrae la URL real del publisher del ?url= de
apiclick.aspx.

Bing y no Google News como pedía el plan: verificado en vivo que los
links de news.google.com/rss/articles/ acaban en muro de consent (UE) y
el id AU_yqL solo se resuelve vía batchexecute interno — habría sembrado
URLs muertas. Los de Bing llevan la URL real en texto plano.

Flag off por defecto: se activará deliberadamente vía deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:59:00 +00:00
ChemaVXandClaude Fable 5 8dfd0116b2 fix(scraper): DDGS síncrono ya no bloquea el event loop
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Las llamadas DDGS() en _seed_search y _seed_youtube eran síncronas y se
ejecutaban directamente en código async: durante cada búsqueda DDG el bot
entero (comandos de Telegram incluidos) quedaba congelado. Extraídas a
helpers sync (_ddg_text_sync/_ddg_videos_sync) ejecutados vía
run_in_executor. Sin cambios en queries ni procesamiento de resultados.

Verificado con mock de DDG lento (3s): peor parón del loop 52ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:49:22 +00:00
ChemaVXandClaude Fable 5 c701628144 feat(scraper): F4 — siembra de Internet Archive (mediatype:texts)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
_seed_archive() consulta advancedsearch con el topic completo y siembra
las páginas /details/<id> (rows=12); los PDFs de cada item los descubre
la recursión y los extrae _extract_pdf. Verificado contra la API:
'Roswell incident declassified' devuelve informes del Proyecto Mogul y
documentos del CIA Reading Room.

Sin fallback de query a propósito: el AND estricto prima precisión —
relajar términos mete ruido (verificado: 'Manises' a secas devuelve
libros de loza valenciana). Topics sin material en Archive siembran 0
y el resto de seeds cubren. Aislado en try/except propio, además del
return_exceptions del gather.

Cierra el plan F1-F4 de mejora del descubrimiento de fuentes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:49:10 +00:00
ChemaVXandClaude Fable 5 0ce9933aaa fix(scraper): F3 — el cap del RSS aplica a entries añadidas, no escaneadas
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
entries[:20] antes del filtro de relevancia perdía las entries
relevantes que no caen entre las 20 más recientes del feed (verificado
con thedebrief.org: 0 relevantes en las primeras 20, 3 en las 100
totales). Ahora se escanean todas y el cap corta al añadir 20.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:46:09 +00:00
ChemaVXandClaude Fable 5 f210b34e5f feat(scraper): F3 — extractor RSS + detección de feeds con word-boundary
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>
2026-07-03 15:43:50 +00:00
ChemaVXandClaude Fable 5 9dae61fde8 feat(scraper): F2 — Wikipedia bilingüe siempre y búsqueda full-text
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Dos arreglos en _seed_wikipedia, motivados por la validación de F1
(found=0 en ambos idiomas para 'incidente ovni de Manises 1979' pese
a existir el artículo en es.wikipedia):

- opensearch es prefix-de-título: topics verbosos devuelven 0. Se pasa
  a action=query&list=search (full-text, srlimit=8), que para el mismo
  topic encuentra 'Manises UFO incident' (en) e 'Incidente OVNI de
  Manises' (es) + relacionados. Verificado contra la API en ambos.
- Se elimina el break tras en: ahora siembra en + es SIEMPRE, cada
  idioma aislado en su try/except.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:40:08 +00:00
ChemaVXandClaude Fable 5 200de018e4 feat(scraper): F1 — lista de motores SearXNG ampliada y configurable
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
_search_searxng hardcodeaba engines=duckduckgo,google,bing,brave, lo
que ignoraba cualquier motor extra de la instancia (y dejaba la
búsqueda coja cuando brave cae en rate limit y DDG en CAPTCHA, ambos
observados en vivo). Ahora la lista vive en settings.searxng_engines
(env SEARXNG_ENGINES, tunable por GitOps sin rebuild).

Default ampliado con motores verificados contra la instancia el
2026-07-03: +startpage +mojeek +presearch +google news +bing news
+internet archive scholar. Medido con queries reales: ~2x resultados
y ~2x dominios únicos por ~+2s/query. Excluidos qwant (denied),
yahoo (roto), wikidata (0 en texto) y wayback machine (duplicados).

Sin cambios en k8s-manifests: use_default_settings:true ya trae todos
estos motores configurados. El fallback a DDG queda intacto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:36:04 +00:00
ChemaVXandClaude Fable 5 228ea5c601 feat(news): 3 feeds ES nuevos tras retirar Espacio Misterio
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Verificados desde el pod (HTTP 200, entries y match de keywords):
- MysteryPlanet (mysteryplanet.com.ar) — diario, 3/10 match hoy
- Marcianitos Verdes (marcianitosverdes.haaan.com) — casi diario, 2/5
- El Ojo Crítico (elojocritico.info) — mensual, calidad ES

Descartados: Ufopolis (último post feb 2025), Mundo Esotérico (bozo).
Cold-start por fuente: el primer poll de cada uno siembra sin notificar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:25:51 +00:00
ChemaVXandClaude Fable 5 2cb0cf841c feat(deps): python-telegram-bot 21.5 -> 22.8
Build & Deploy ResearchOwl / build-and-push (push) Successful in 49s
Salto mayor revisado contra el changelog oficial:
- La 22.0 elimina lo deprecado en v20.x. De esa lista el bot solo usaba
  disable_web_page_preview (3 sitios), que ademas resulta seguir vivo
  como shim en 22.8 — se migra igualmente a link_preview_options, que
  es la API canonica desde Bot API 7.0.
- No usamos quote=, proxy_url, ni *_timeout en run_polling (el resto
  de eliminaciones). Python >=3.10 (imagen 3.12) y httpx >=0.27,<0.29
  (tenemos 0.28.1) OK.
- Verificado aislado: import de src.bot.bot sin DeprecationWarnings,
  8 tests en verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:17:21 +00:00
ChemaVXandClaude Fable 5 5876361ac7 chore(deps): pdfplumber 0.11.10 y python-dotenv 1.2.2
Build & Deploy ResearchOwl / build-and-push (push) Successful in 49s
Folda las dos ramas nuevas de Renovate (las 11 rancias ya las cerró
tras el bump b872d00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:12:36 +00:00
ChemaVXandClaude Fable 5 a91e119171 refactor(config): migrar a SettingsConfigDict y quitar Field(env=...)
Elimina las dos deprecaciones de Pydantic V2 (se rompían en V3):
- class Config -> model_config = SettingsConfigDict(env_file='.env')
- Field(env='X') en los 33 campos -> resolución automática por nombre
  de campo (case-insensitive en pydantic-settings); verificado que los
  33 nombres coinciden con su variable, así que el comportamiento es
  idéntico. pytest pasa de 34 warnings a 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:12:36 +00:00
ChemaVXandClaude Fable 5 19efc70539 feat(seo): fijar vocabulario de tags ES en el autofill
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Cierra el TODO de ALLOWED_TAGS: el allow-list ES son los 6 tags del
núcleo vivo de zonadeexclusion (Admin API, 2026-07-03), espejo de la
lista EN confirmada: uap, desclasificados, casos-militares,
casos-clasicos, investigacion, casos-espana. Quedan fuera la cola de
tags de 1 post que el modelo inventó antes de constreñir y el duplicado
investigacion-2 (colisión de mayúsculas en Ghost).

La regla anti-legacy 'never use "investigacion"' del prompt pasa a ser
solo-EN: en ES ese slug es el tag canónico del allow-list.

Tests de las funciones puras (_coerce, _system_prompt) por idioma.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:52:26 +00:00
ChemaVXandClaude Fable 5 b872d00efc chore(deps): bump 9 dependencias y eliminar fastapi/uvicorn sin uso
Build & Deploy ResearchOwl / build-and-push (push) Successful in 47s
Consolida el backlog de Renovate en un solo bump, ignorando las ramas
rancias que proponían downgrades (youtube-transcript-api, trafilatura,
sqlite-vec se quedan como están):

- aiohttp 3.10.0 -> 3.14.1
- httpx 0.27.0 -> 0.28.1 (PTB 21.5 requiere httpx~=0.27, que admite 0.28;
  el código solo usa AsyncClient(timeout=...), sin APIs eliminadas)
- beautifulsoup4 4.12.3 -> 4.15.0
- lxml 5.2.2 -> 5.4.0
- duckduckgo-search 6.2.6 -> 6.4.2
- aiosqlite 0.20.0 -> 0.22.1
- markdown 3.7 -> 3.10.2
- pydantic 2.8.0 -> 2.13.4 + pydantic-settings 2.4.0 -> 2.14.2
- fastapi y uvicorn eliminados: ningún módulo los importa

Verificado con install aislado (--target) + imports completos + pytest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:38:27 +00:00
ChemaVXandClaude Fable 5 dcfe674322 fix(news): rotar feeds de Reddit por run + loguear HTTP >=400
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Reddit sin OAuth ratelimita por IP con ventana larga: la 2ª petición
seguida a reddit.com devuelve 429 siempre (verificado: 30s de pausa no
bastan). Como r/UFOs va antes en FEEDS, r/UFOB recibía 429 en cada poll
desde F1 y nunca llegó a sembrar (0 filas en news_seen).

- Solo se pollea un feed de reddit por run, rotando (_reddit_turn por
  proceso). Con poll de 6h cada subreddit se refresca cada 12h.
- El 429 llegaba con bozo=False y 0 entries, invisible para el guard
  existente: ahora status >= 400 se loguea como warning y se salta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:18:15 +00:00
ChemaVXandClaude Fable 5 480d86e975 fix(news): quitar feed Espacio Misterio (XML malformado)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
El feed devuelve 'not well-formed (invalid token)' en cada poll desde la
activación; feedparser nunca extrae entries (bozo). Se elimina para no
generar un warning por tick. Quedan 6 feeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:09:38 +00:00
10 changed files with 559 additions and 127 deletions
+62
View File
@@ -0,0 +1,62 @@
# ResearchOwl — Known Issues & Operational Gotchas
## Brotli / aiohttp incompatibility
Do NOT install any brotli backend (`Brotli`, `brotlicffi`) while on aiohttp 3.14.x.
aiohttp's incremental brotli decompressor is broken and fails intermittently
depending on stream chunking ("Can not decode content-encoding: br" on VALID
streams — the same bytes decompress fine offline; httpx is unaffected).
Extra trap: merely *installing* a backend makes aiohttp advertise `br` in the
default `Accept-Encoding` of every session that doesn't set one explicitly.
On 2026-07-04 a scraper fix installed brotlicffi and inadvertently enabled br
project-wide: Ghost/Cloudflare responded in brotli, the decode blew up AFTER
Ghost had already accepted the POST, and the publish fallback re-published →
duplicate drafts + silently lost SEO notices.
Fix applied (commits `397546a` + `76c927f` + `7d07375`, guard relocated
2026-07-05):
- no brotli backend in requirements.txt (comment there explains why),
- explicit `Accept-Encoding: gzip, deflate` on all Ghost/autofill aiohttp
sessions and on the scraper HEADERS (`57f341f`),
- duplicate guard INSIDE `GhostPublisher.publish_draft` (covers every caller,
including `/publish`): if the POST is accepted (2xx) but reading the response
fails, it recovers the just-created draft via `find_draft_by_title(title,
since=attempt_start)` instead of raising. The guard only fires after an
accepted POST — a pre-POST failure (Ollama down, menu fetch, links) never
triggers it, so a stale same-title draft from a previous run can no longer
swallow freshly generated content.
Note: with no backend installed there is NO brotli fallback at all — a server
that responds `Content-Encoding: br` without it being advertised (misbehaving
CDN) fails undecodable and that source is lost. Accepted trade-off.
The header value lives in one place: `SAFE_ACCEPT_ENCODING` in `src/config.py`.
Every aiohttp session/request must use it explicitly — never rely on aiohttp's
default Accept-Encoding, which silently grows `br` if a backend appears.
Re-test with disclosure.org before ever re-enabling br (e.g. after an aiohttp
upgrade).
## SQLite WAL mode + read-only mounts
The database runs in WAL mode, so even read-only access needs the `-shm` file
writable, or must open with `sqlite3 "file:...?immutable=1"`. Backups (daily
CronJob `researchowl-db-backup`, 03:00 Europe/Madrid, PVC `researchowl-backups`,
7-day retention) inherit WAL mode: to inspect one from a read-only mount use
`?immutable=1`; to restore, copy it to a writable location first.
## DDG (duckduckgo_search) blocks the event loop
`DDGS()` is synchronous (blocking requests inside). Never call it directly from
async code — always wrap in `loop.run_in_executor()` (see `_ddg_text_sync` /
`_ddg_videos_sync` in `src/scraper/exhaustive.py`). Direct calls froze the
entire Telegram bot during searches until fixed on 2026-07-04 (`8dfd011`).
## Google News RSS is a dead end from this infrastructure
`news.google.com/rss` entry links point to `/rss/articles/CBMi…` redirects that
hit a consent wall from EU IPs, and the inner `AU_yqL` id is only resolvable via
Google's private batchexecute API. Do not retry. The news seed uses Bing News
RSS instead (`ENABLE_NEWS_SEED`, real publisher URL in the `?url=` param of
apiclick.aspx — unwrapped by `_unwrap_news_link`).
+18 -15
View File
@@ -1,38 +1,41 @@
# Core
fastapi==0.115.0
uvicorn==0.30.0
python-telegram-bot==21.5
httpx==0.27.0
aiohttp==3.10.0
python-telegram-bot==22.8
httpx==0.28.1
aiohttp==3.14.1
# NO instalar backend brotli (Brotli/brotlicffi): el decode incremental br de
# aiohttp 3.14 está roto (falla con streams válidos según el troceo), y con un
# backend presente aiohttp anuncia br POR DEFECTO en toda sesión sin
# Accept-Encoding explícito — el 2026-07-04 eso rompió publish_draft/autofill
# contra Ghost/Cloudflare (drafts duplicados). Sin backend, nada anuncia br.
# Scraping
beautifulsoup4==4.12.3
lxml==5.2.2
beautifulsoup4==4.15.0
lxml==5.4.0
trafilatura==1.12.2
youtube-transcript-api==0.6.3
pdfplumber==0.11.9
pdfplumber==0.11.10
feedparser==6.0.12
duckduckgo-search==6.2.6
duckduckgo-search==6.4.2
# Storage & Embeddings
sqlite-vec==0.1.9
aiosqlite==0.20.0
aiosqlite==0.22.1
# Processing
tiktoken==0.7.0
numpy==1.26.4
scikit-learn==1.5.1
scikit-learn==1.9.0
# Claude API (scoring)
anthropic>=0.40.0
# PDF export
markdown==3.7
markdown==3.10.2
reportlab==4.2.5
# Utilities
pydantic==2.8.0
pydantic-settings==2.4.0
pydantic==2.13.4
pydantic-settings==2.14.2
tenacity==9.0.0
structlog==24.4.0
python-dotenv==1.0.1
python-dotenv==1.2.2
+7 -4
View File
@@ -10,7 +10,7 @@ from typing import Optional
from zoneinfo import ZoneInfo
import structlog
from telegram import Update, Message
from telegram import Update, Message, LinkPreviewOptions
from telegram.ext import (
Application, CommandHandler, MessageHandler,
filters, ContextTypes
@@ -397,7 +397,7 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
generator.last_publish_notice,
parse_mode=ParseMode.MARKDOWN,
disable_web_page_preview=True,
link_preview_options=LinkPreviewOptions(is_disabled=True),
)
except Exception as e:
logger.warning("Failed to send SEO summary message", error=str(e))
@@ -526,7 +526,10 @@ async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
items = [item_from_row(r) for r in recent]
for chunk in format_digest(items):
await update.message.reply_text(chunk, disable_web_page_preview=False)
# Previews activos a propósito (el digest gana con la tarjeta del link).
await update.message.reply_text(
chunk, link_preview_options=LinkPreviewOptions(is_disabled=False)
)
finally:
await db_conn.close()
@@ -969,7 +972,7 @@ async def _scheduler_loop(app: Application):
for c in chunks:
await app.bot.send_message(
chat_id=news_chat_id, text=c,
disable_web_page_preview=False,
link_preview_options=LinkPreviewOptions(is_disabled=False),
)
# Marca todos los pendientes (incluidos los "…y N más")
await db.mark_news_notified([r["id"] for r in pending])
+52 -36
View File
@@ -1,52 +1,70 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import Optional
# Accept-Encoding para TODA petición aiohttp del proyecto. Nunca anunciar "br":
# el descompresor incremental de aiohttp 3.14 está roto, y con un backend brotli
# instalado aiohttp lo anunciaría POR DEFECTO en cualquier sesión sin
# Accept-Encoding explícito (incidente 2026-07-04, ver KNOWN-ISSUES.md).
# Toda sesión/petición nueva debe usar esta constante, no un literal.
SAFE_ACCEPT_ENCODING = "gzip, deflate"
class Settings(BaseSettings):
# Telegram
telegram_bot_token: str = Field(..., env="TELEGRAM_BOT_TOKEN")
telegram_allowed_users: str = Field("", env="TELEGRAM_ALLOWED_USERS") # comma-separated user IDs
telegram_bot_token: str = Field(...)
telegram_allowed_users: str = Field("") # comma-separated user IDs
# Ollama
ollama_url: str = Field("http://ollama.chemavx.xyz", env="OLLAMA_URL")
ollama_model: str = Field("qwen2.5:3b", env="OLLAMA_MODEL")
ollama_embed_model: str = Field("qwen2.5:3b", env="OLLAMA_EMBED_MODEL")
ollama_url: str = Field("http://ollama.chemavx.xyz")
ollama_model: str = Field("qwen2.5:3b")
ollama_embed_model: str = Field("qwen2.5:3b")
# Claude fallback (optional)
anthropic_api_key: Optional[str] = Field(None, env="ANTHROPIC_API_KEY")
claude_model: str = Field("claude-haiku-4-5", env="CLAUDE_MODEL")
anthropic_api_key: Optional[str] = Field(None)
claude_model: str = Field("claude-haiku-4-5")
# Database
db_path: str = Field("/data/researchowl.db", env="DB_PATH")
db_path: str = Field("/data/researchowl.db")
# Scraping
max_depth: int = Field(3, env="MAX_DEPTH") # recursion depth
max_sources: int = Field(150, env="MAX_SOURCES") # hard cap
max_pages_per_search: int = Field(5, env="MAX_PAGES_PER_SEARCH")
max_depth: int = Field(3) # recursion depth
max_sources: int = Field(150) # hard cap
max_pages_per_search: int = Field(5)
searxng_url: str = Field(
"http://searxng-svc.researchowl.svc.cluster.local:8080/search",
env="SEARXNG_URL"
)
request_timeout: int = Field(30, env="REQUEST_TIMEOUT")
request_delay: float = Field(1.0, env="REQUEST_DELAY") # seconds between requests
min_content_length: int = Field(200, env="MIN_CONTENT_LENGTH") # chars
# Motores que el scraper pide a SearXNG (todos vienen configurados por los
# defaults de la instancia, use_default_settings: true). Verificados en vivo
# 2026-07-03: la lista ampliada duplica resultados y dominios únicos (~2x)
# por ~+2s/query, y da redundancia cuando brave/DDG caen en rate limit o
# CAPTCHA. Excluidos: qwant (access denied), yahoo (roto), wikidata (0
# resultados en queries de texto), wayback machine (duplica contenido vivo).
searxng_engines: str = Field(
"duckduckgo,google,bing,brave,startpage,mojeek,presearch,"
"google news,bing news,internet archive scholar"
)
request_timeout: int = Field(30)
request_delay: float = Field(1.0) # seconds between requests
min_content_length: int = Field(200) # chars
# Fuentes opcionales — desactivadas por defecto: la IP del homelab está
# bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto.
enable_youtube: bool = Field(False, env="ENABLE_YOUTUBE")
enable_reddit: bool = Field(False, env="ENABLE_REDDIT")
enable_youtube: bool = Field(False)
enable_reddit: bool = Field(False)
# Bing News RSS como semilla de actualidad — off hasta activarlo a propósito
enable_news_seed: bool = Field(False)
# Processing
chunk_size: int = Field(800, env="CHUNK_SIZE") # tokens per chunk
chunk_overlap: int = Field(100, env="CHUNK_OVERLAP")
quality_threshold: float = Field(0.3, env="QUALITY_THRESHOLD") # 0-1, chunks below discarded
chunk_size: int = Field(800) # tokens per chunk
chunk_overlap: int = Field(100)
quality_threshold: float = Field(0.3) # 0-1, chunks below discarded
# Ghost CMS
ghost_url: Optional[str] = Field(None, env="GHOST_URL")
ghost_api_key: Optional[str] = Field(None, env="GHOST_API_KEY")
ghost_url_en: str = Field("", env="GHOST_URL_EN")
ghost_api_key_en: str = Field("", env="GHOST_API_KEY_EN")
ghost_url: Optional[str] = Field(None)
ghost_api_key: Optional[str] = Field(None)
ghost_url_en: str = Field("")
ghost_api_key_en: str = Field("")
# SEO autofill — "off" | "on" | "dryrun" (default off).
# off = today's exact behavior (bare draft, no second LLM call).
@@ -55,28 +73,27 @@ class Settings(BaseSettings):
# dryrun = runs the full pipeline + reports the proposed SEO to Telegram, but
# writes a BARE draft (no seo fields), for inspection before trusting
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
seo_autofill: str = Field("off", env="SEO_AUTOFILL")
seo_autofill: str = Field("off")
# News monitor (RSS de noticias UAP/OVNI) — inerte hasta NEWS_ENABLED=true.
NEWS_ENABLED: bool = Field(False, env="NEWS_ENABLED")
NEWS_POLL_INTERVAL_HOURS: int = Field(6, env="NEWS_POLL_INTERVAL_HOURS")
NEWS_ENABLED: bool = Field(False)
NEWS_POLL_INTERVAL_HOURS: int = Field(6)
# Chat al que el monitor empuja novedades; si None -> 1er TELEGRAM_ALLOWED_USERS
# (ver propiedad news_chat_id).
NEWS_CHAT_ID: Optional[int] = Field(None, env="NEWS_CHAT_ID")
NEWS_CHAT_ID: Optional[int] = Field(None)
NEWS_KEYWORDS: str = Field(
"ovni,ovnis,uap,ufo,desclasificado,desclasificacion,declassified,"
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,"
"aaro,pursue,fenomeno aereo,whistleblower",
env="NEWS_KEYWORDS",
)
NEWS_MAX_ITEMS: int = Field(15, env="NEWS_MAX_ITEMS")
NEWS_MAX_ITEMS: int = Field(15)
# Alerts
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
cost_alert_threshold: float = Field(0.15)
# App
log_level: str = Field("INFO", env="LOG_LEVEL")
timezone: str = Field("Europe/Madrid", env="TIMEZONE")
log_level: str = Field("INFO")
timezone: str = Field("Europe/Madrid")
@property
def allowed_user_ids(self) -> list[int]:
@@ -102,8 +119,7 @@ class Settings(BaseSettings):
def seo_autofill_dryrun(self) -> bool:
return (self.seo_autofill or "").strip().lower() == "dryrun"
class Config:
env_file = ".env"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
+110 -10
View File
@@ -9,9 +9,10 @@ import json
import re
import time
import aiohttp
import structlog
from src.config import settings
from src.config import settings, SAFE_ACCEPT_ENCODING
from src.llm import get_anthropic_client
from src.processor.processor import OllamaClient, ContentProcessor
from src.db.database import ResearchDB, OutputType
@@ -385,6 +386,24 @@ def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted
)
def _norm_title(t: str) -> str:
"""Normaliza un título para compararlo con lo que Ghost almacenó: colapsa
whitespace y trunca a 255 chars (límite de posts.title en Ghost — un H1
largo del LLM puede volver truncado/normalizado, y la igualdad exacta
fallaría justo en los casos que el guard debe cubrir)."""
return " ".join((t or "").split())[:255]
def _parse_ghost_ts(ts: str) -> float | None:
"""created_at de la Admin API (ISO 8601, p.ej. 2026-07-04T20:26:00.000+00:00
o sufijo Z) → epoch. None si no parsea."""
try:
from datetime import datetime
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
except Exception:
return None
class GhostPublisher:
def __init__(self, lang: str = "es"):
self.lang = lang
@@ -419,6 +438,64 @@ class GhostPublisher:
)
return f"{signing}.{sig}"
def _admin_headers(self) -> dict:
"""Headers canónicos de la Admin API (token JWT fresco por llamada).
Único sitio donde se construyen: cualquier cambio (Accept-Version,
encoding) se hace aquí y cubre todas las llamadas a Ghost."""
return {
"Authorization": f"Ghost {self._make_token()}",
"Accept-Version": "v5.0",
# Nunca heredar el default de aiohttp: con un backend brotli
# instalado anunciaría br y el decode roto de aiohttp 3.14 rompe
# la lectura de respuestas (incidente 2026-07-04, KNOWN-ISSUES.md).
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
}
async def _admin_get(self, query: str, timeout: float = 15) -> dict | None:
"""GET a {url}/ghost/api/admin/{query} con auth + headers canónicos.
None si non-200 (logueado); las excepciones de red/parseo suben tal
cual para que cada caller aplique su propia política best-effort."""
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=timeout)) as sess:
async with sess.get(f"{self.url}/ghost/api/admin/{query}",
headers=self._admin_headers()) as resp:
if resp.status != 200:
body = await resp.text()
logger.warning("Ghost admin GET non-200", query=query[:80],
status=resp.status, body=body[:200])
return None
return await resp.json()
async def find_draft_by_title(self, title: str,
since: float | None = None) -> dict | None:
"""Busca entre los drafts recientes uno con título exacto.
Mitad de recuperación del guard anti-duplicados de publish_draft: si el
POST fue aceptado por Ghost pero la lectura de la respuesta falló
(p.ej. decode roto), el draft existe aunque el caller viera una
excepción. Con `since` (epoch) solo casan drafts creados en este
intento — un draft viejo del mismo título de una run anterior NO cuenta
como "ya publicado". La comparación usa _norm_title (whitespace
colapsado + truncado a 255) para sobrevivir la normalización de Ghost.
Best-effort: None si no hay match o si falla."""
data = await self._admin_get(
"posts/?filter=status:draft&order=created_at%20desc&limit=15"
"&fields=id,title,slug,created_at"
)
if data is None:
return None
wanted = _norm_title(title)
for p in data.get("posts", []):
if _norm_title(p.get("title") or "") != wanted:
continue
if since is not None:
created = _parse_ghost_ts(p.get("created_at") or "")
# 60s de margen por posible desfase de reloj Ghost/bot.
if created is None or created < since - 60:
continue
return p
return None
async def publish_draft(self, title: str, markdown_content: str,
tags: list[str] | None = None,
seo: dict | None = None,
@@ -432,8 +509,6 @@ class GhostPublisher:
* seo — a generate_seo_fields dict; when given, its meta/OG/Twitter fields
are added to the post and its (allow-list) tags are used.
"""
import aiohttp as _aio
# Caller-supplied linked HTML wins; otherwise convert markdown as before.
html = body_html if body_html is not None else self._build_html(markdown_content)
@@ -454,7 +529,6 @@ class GhostPublisher:
"sections": [[10, 0]],
})
token = self._make_token()
# Language-aware default tag (fixes the hardcoded "investigacion" for EN).
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
post_obj = {
@@ -474,19 +548,40 @@ class GhostPublisher:
"twitter_description": seo["twitter_description"],
})
body = {"posts": [post_obj]}
async with _aio.ClientSession() as sess:
attempt_start = time.time()
async with aiohttp.ClientSession() as sess:
async with sess.post(
f"{self.url}/ghost/api/admin/posts/",
json=body,
headers={
"Authorization": f"Ghost {token}",
"Accept-Version": "v5.0",
},
headers=self._admin_headers(),
) as resp:
if resp.status not in (200, 201):
text = await resp.text()
raise ValueError(f"Ghost API {resp.status}: {text[:300]}")
return await resp.json()
# Guard anti-duplicados (incidente 2026-07-04): el 2xx confirma
# que Ghost YA creó el draft; si la lectura del body falla
# (p.ej. decode br roto), recuperamos el draft recién creado en
# vez de propagar — propagar hacía que el caller (o el usuario
# con /publish) reintentara y duplicara. El guard vive AQUÍ para
# cubrir a todos los callers, y solo se activa tras un POST
# aceptado: un fallo pre-POST jamás lo dispara.
try:
return await resp.json()
except Exception as read_err:
existing = None
try:
existing = await self.find_draft_by_title(
title, since=attempt_start)
except Exception as find_err:
logger.warning("publish_draft: draft-exists check failed",
error=str(find_err))
if existing:
logger.warning(
"publish_draft: POST aceptado pero lectura de la "
"respuesta falló; draft recuperado sin re-publicar",
post_id=existing["id"], error=str(read_err))
return {"posts": [existing]}
raise
# ─── Output generation ────────────────────────────────────────────────────────
@@ -554,6 +649,11 @@ class OutputGenerator:
except Exception as e:
logger.warning("SEO autofill failed; falling back to bare draft",
error=str(e))
# Sin guard anti-duplicados aquí: vive DENTRO de publish_draft
# (solo se activa tras un POST aceptado). Un fallo pre-POST
# (Ollama caído, menú, links) cae aquí y DEBE publicar el
# contenido nuevo — un guard por título casaba con drafts
# viejos del mismo topic y lo descartaba en silencio.
# fall through to the bare-draft publish below
# OFF mode, or autofill failed → today's exact bare-draft publish.
+35 -1
View File
@@ -21,7 +21,12 @@ UA = "ExclusionZone-NewsBot/1.0 (+https://theexclusionzone.com)"
FEEDS: list[dict] = [
{"name": "Gran Misterio", "url": "https://granmisterio.org/feed/", "lang": "es"},
{"name": "Espacio Misterio", "url": "https://www.espaciomisterio.com/feed/", "lang": "es"},
# Reemplazos ES tras retirar Espacio Misterio (XML roto). Verificados 2026-07-03:
# MysteryPlanet y Marcianitos publican a diario/casi a diario; El Ojo Crítico es
# mensual (poco volumen a propósito). Ufopolis/MundoEsoterico descartados (muerto/roto).
{"name": "MysteryPlanet", "url": "https://mysteryplanet.com.ar/site/feed/", "lang": "es"},
{"name": "Marcianitos Verdes", "url": "https://marcianitosverdes.haaan.com/feed/", "lang": "es"},
{"name": "El Ojo Crítico", "url": "https://elojocritico.info/feed/", "lang": "es"},
{"name": "Josep Guijarro (YT)", "url": "https://www.youtube.com/feeds/videos.xml?channel_id=UCIlGy26zi-BbX6zDYAMQ9Qg", "lang": "es"},
{"name": "r/UFOs", "url": "https://www.reddit.com/r/UFOs/new/.rss", "lang": "en"},
{"name": "r/UFOB", "url": "https://www.reddit.com/r/UFOB/new/.rss", "lang": "en"},
@@ -37,6 +42,16 @@ _SHORT_TOKEN_MAXLEN = 4
# Límite de Telegram 4096; troceamos por debajo con margen.
_TG_LIMIT = 4000
# Reddit sin OAuth ratelimita por IP con ventana larga (>30s verificado): la 2ª
# petición seguida a reddit.com devuelve 429 SIEMPRE, así que solo se pollea un
# feed de reddit por run, rotando. Contador por proceso: tras un reinicio se
# empieza por el primero, lo cual es inocuo.
_reddit_turn = 0
def _is_reddit(feed: dict) -> bool:
return "reddit.com" in feed["url"]
@dataclass
class NewsItem:
@@ -90,13 +105,32 @@ async def poll_feeds(db, settings) -> list[NewsItem]:
Best-effort: cada feed va en su propio try/except — uno caído nunca tumba el
run. feedparser.parse es bloqueante (I/O de red), así que se ejecuta en un
hilo para no congelar el event loop del bot durante el poll.
Reddit: solo se pollea UN feed de reddit.com por run, rotando entre ellos
(ver _reddit_turn) — dos peticiones seguidas garantizan un 429 en la 2ª.
"""
global _reddit_turn
keywords = [k.strip() for k in settings.NEWS_KEYWORDS.split(",") if k.strip()]
new_items: list[NewsItem] = []
reddit_feeds = [f for f in FEEDS if _is_reddit(f)]
skip: set[str] = set()
if len(reddit_feeds) > 1:
chosen = reddit_feeds[_reddit_turn % len(reddit_feeds)]
_reddit_turn += 1
skip = {f["name"] for f in reddit_feeds if f["name"] != chosen["name"]}
for feed in FEEDS:
if feed["name"] in skip:
continue
try:
parsed = await asyncio.to_thread(feedparser.parse, feed["url"], agent=UA)
status = getattr(parsed, "status", None)
# Un 429/403 llega con bozo=False y 0 entries: sin este guard se
# tragaba en silencio (así estuvo r/UFOB sin sembrar desde F1).
if status and status >= 400:
log.warning("feed %s HTTP %s; salto", feed["name"], status)
continue
if parsed.bozo and not parsed.entries:
log.warning("feed bozo sin entries: %s (%s)",
feed["name"], getattr(parsed, "bozo_exception", ""))
+187 -37
View File
@@ -7,7 +7,7 @@ import random
import re
import time
from typing import Optional
from urllib.parse import urljoin, urlparse, quote_plus
from urllib.parse import urljoin, urlparse, parse_qs, quote, quote_plus
import aiohttp
import feedparser
@@ -18,7 +18,7 @@ from duckduckgo_search import DDGS
from youtube_transcript_api import YouTubeTranscriptApi, NoTranscriptFound
from tenacity import retry, stop_after_attempt, wait_exponential
from src.config import settings
from src.config import settings, SAFE_ACCEPT_ENCODING
from src.db.database import ResearchDB
logger = structlog.get_logger()
@@ -27,7 +27,13 @@ HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,es;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
# Sin "br": el descompresor incremental de aiohttp 3.14 falla con streams
# brotli válidos según el troceo de chunks (disclosure.org, todaywhy.com;
# los mismos bytes descomprimen bien offline). Con gzip el camino zlib es
# sólido. OJO: ya NO hay ningún backend brotli instalado (397546a quitó
# brotlicffi) — si un servidor responde br sin que se haya anunciado,
# aiohttp falla sin recuperación posible y esa fuente se pierde.
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
"DNT": "1",
}
@@ -36,7 +42,7 @@ REDDIT_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Encoding": SAFE_ACCEPT_ENCODING, # sin "br", ver HEADERS
"Referer": "https://www.reddit.com/",
"X-Requested-With": "XMLHttpRequest",
}
@@ -53,6 +59,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 +92,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"
@@ -100,6 +110,25 @@ def is_blacklisted(url: str) -> bool:
return True
def _unwrap_news_link(link: str) -> str:
"""Bing News envuelve los links de entry en apiclick.aspx; la URL real del
publisher viene en texto plano en el parámetro ?url=. Cualquier otro link
pasa sin tocar. Si es un apiclick pero no trae URL usable, devuelve ""
el caller lo descarta por startswith("http") — en vez de dejar pasar el
redirect de bing.com, que se rasparía como página de consent/basura."""
try:
parsed = urlparse(link)
if (parsed.netloc.lower().endswith("bing.com")
and parsed.path == "/news/apiclick.aspx"):
real = parse_qs(parsed.query).get("url", [""])[0].strip()
if real.startswith("//"): # protocol-relative
real = "https:" + real
return real if real.startswith("http") else ""
except Exception:
return ""
return link
def normalize_url(url: str) -> str:
# Strip only the fragment. NO borrar el query string: rompía URLs de
# YouTube (watch?v=...) y artículos que enrutan por query (?id=, ?p=).
@@ -167,11 +196,14 @@ class ExhaustiveScraper:
tasks = [
self._seed_search(),
self._seed_wikipedia(),
self._seed_archive(),
]
if settings.enable_reddit:
tasks.append(self._seed_reddit())
if settings.enable_youtube:
tasks.append(self._seed_youtube())
if settings.enable_news_seed:
tasks.append(self._seed_news())
await asyncio.gather(*tasks, return_exceptions=True)
async def _generate_ddg_queries(self) -> list[str]:
@@ -229,13 +261,17 @@ class ExhaustiveScraper:
params = {
"q": query,
"format": "json",
"engines": "duckduckgo,google,bing,brave",
"engines": settings.searxng_engines,
"language": "all",
}
headers = {
"Accept": "application/json",
"X-Forwarded-For": "127.0.0.1",
"User-Agent": "ResearchOwl/1.0",
# Sin esto la sesión hereda el default de aiohttp, que anunciaría
# br si algún día se reinstala un backend brotli — y el decode roto
# de 3.14 tumbaría TODAS las búsquedas SearXNG (el camino primario).
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
}
try:
async with aiohttp.ClientSession() as session:
@@ -261,6 +297,21 @@ class ExhaustiveScraper:
logger.warning("SearXNG failed", query=query, error=str(e))
return []
# DDGS es síncrono (requests bloqueante por dentro): llamarlo directamente
# desde código async congela el event loop entero — bot de Telegram incluido —
# mientras dura la búsqueda. Estos helpers deben ejecutarse SIEMPRE vía
# run_in_executor.
@staticmethod
def _ddg_text_sync(query: str) -> list[dict]:
with DDGS() as ddgs:
return list(ddgs.text(query, max_results=settings.max_pages_per_search))
@staticmethod
def _ddg_videos_sync(query: str) -> list[dict]:
with DDGS() as ddgs:
return list(ddgs.videos(query, max_results=10))
async def _seed_search(self):
"""SearXNG primary + DDG fallback per query"""
queries = await self._generate_ddg_queries()
@@ -271,12 +322,10 @@ class ExhaustiveScraper:
if not results:
logger.info("SearXNG vacío, usando DDG", query=query)
try:
with DDGS() as ddgs:
ddg_results = list(ddgs.text(
query,
max_results=settings.max_pages_per_search
))
results = ddg_results
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
None, self._ddg_text_sync, query
)
logger.info("DDG fallback ok", query=query, results=len(results))
except Exception as e:
logger.warning("DDG fallback failed", query=query, error=str(e))
@@ -294,30 +343,92 @@ class ExhaustiveScraper:
await asyncio.sleep(random.uniform(1, 3))
async def _seed_wikipedia(self):
"""Search Wikipedia API for correct article URLs.
Tries English first, falls back to Spanish if no results found."""
"""Siembra Wikipedia en AMBOS idiomas siempre, con búsqueda full-text
(list=search). El opensearch anterior era prefix-de-título y devolvía 0
para topics verbosos ("incidente ovni de Manises 1979") aunque el
artículo exista; y el break tras en dejaba es solo como fallback.
Cada idioma va aislado: un fallo no afecta al otro."""
http = await self._get_http()
added = 0
for lang in ("en", "es"):
try:
api_url = (
f"https://{lang}.wikipedia.org/w/api.php?action=opensearch"
f"&search={quote_plus(self.topic)}&limit=10&format=json"
f"https://{lang}.wikipedia.org/w/api.php?action=query"
f"&list=search&srsearch={quote_plus(self.topic)}"
f"&srlimit=8&format=json"
)
async with http.get(api_url) as resp:
data = await resp.json()
urls = data[3] if len(data) > 3 else []
for url in urls:
if url:
await self.db.add_source(self.session_id, url, "wikipedia", depth=0)
added += 1
logger.info("Wikipedia seed", lang=lang, found=len(urls))
if added > 0:
break # English results found — no need to try Spanish
hits = data.get("query", {}).get("search", [])
added = 0
for h in hits:
title = h.get("title")
if not title:
continue
url = (f"https://{lang}.wikipedia.org/wiki/"
f"{quote(title.replace(' ', '_'))}")
await self.db.add_source(self.session_id, url, "wikipedia",
depth=0, title=title)
added += 1
logger.info("Wikipedia seed", lang=lang, found=added)
except Exception as e:
logger.warning("Wikipedia API seed failed", lang=lang, error=str(e))
async def _seed_archive(self):
"""Siembra Internet Archive (advancedsearch, mediatype:texts): documentos
FOIA, informes y libros históricos. Se siembra la página /details/<id>;
sus PDFs los descubre la recursión (la propia página los enlaza) y los
extrae _extract_pdf. Sin fallback de query: el AND estricto de archive.org
con el topic completo prima precisión — relajarlo mete ruido (verificado:
'Manises' a secas devuelve loza valenciana). Best-effort: nunca rompe el seed."""
try:
http = await self._get_http()
q = quote_plus(f"({self.topic}) AND mediatype:(texts)")
url = (
f"https://archive.org/advancedsearch.php?q={q}"
"&fl[]=identifier&fl[]=title&rows=12&page=1&output=json"
)
async with http.get(url) as resp:
if resp.status != 200:
logger.warning("Archive.org seed non-200", status=resp.status)
return
data = await resp.json(content_type=None)
docs = data.get("response", {}).get("docs", [])
added = 0
for d in docs:
ident = d.get("identifier")
if not ident:
continue
item_url = f"https://archive.org/details/{ident}"
title = d.get("title")
await self.db.add_source(
self.session_id, item_url, "web", depth=0,
title=title if isinstance(title, str) else None
)
added += 1
logger.info("Archive.org seed", found=len(docs), added=added)
except Exception as e:
logger.warning("Archive.org seed failed", error=str(e))
async def _seed_news(self):
"""Siembra Bing News RSS para cobertura de actualidad. Solo registra
el feed como fuente tipo rss: _extract_rss (dispatch del pipeline) lo
parsea y siembra sus entries — sin duplicar lógica de parseo aquí.
Bing y no Google News: los links de Google (news.google.com/rss/articles/
CBMi…) van a un muro de consent + redirect cifrado solo resoluble vía su
API interna batchexecute (verificado 2026-07-04); los de Bing llevan la
URL real del publisher en ?url= (ver _unwrap_news_link).
Best-effort: nunca rompe el seed."""
try:
url = f"https://www.bing.com/news/search?q={quote_plus(self.topic)}&format=rss"
await self.db.add_source(
self.session_id, url, "rss", depth=0,
title=f"Bing News: {self.topic}"
)
logger.info("News seed added", topic=self.topic)
except Exception as e:
logger.warning("News seed failed", error=str(e))
async def _seed_reddit(self):
"""Search Reddit — sequential to avoid rate limiting"""
try:
@@ -347,18 +458,18 @@ class ExhaustiveScraper:
async def _seed_youtube(self):
"""Search YouTube via DDG for video transcripts"""
try:
with DDGS() as ddgs:
results = list(ddgs.videos(
f"{self.topic} documentary explanation",
max_results=10
))
for r in results:
url = r.get("content", "")
if "youtube.com" in url or "youtu.be" in url:
await self.db.add_source(
self.session_id, url, "youtube", depth=0,
title=r.get("title")
)
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
None, self._ddg_videos_sync,
f"{self.topic} documentary explanation"
)
for r in results:
url = r.get("content", "")
if "youtube.com" in url or "youtu.be" in url:
await self.db.add_source(
self.session_id, url, "youtube", depth=0,
title=r.get("title")
)
except Exception as e:
logger.warning("YouTube seed failed", error=str(e))
@@ -472,6 +583,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
@@ -669,6 +783,42 @@ 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:
# Se escanean TODAS las entries y el cap aplica a las añadidas:
# capar antes de filtrar perdía las relevantes que no caen entre
# las 20 más recientes (verificado con thedebrief: 0/20 vs 3/100).
for e in entries:
if added >= 20: # cap, como los 30 links/página de web
break
link = normalize_url(_unwrap_news_link(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
+20 -24
View File
@@ -37,14 +37,19 @@ SITE_BY_LANG = {
# Tag allow-list — the model may ONLY pick from these; invented tags are dropped
# deterministically after the call (never trust the LLM to self-limit). EN is the
# live vocabulary Jose confirmed; ES is not yet constrained, so we don't enforce
# it (model picks freely there until an ES vocab is pinned down).
# live vocabulary Jose confirmed; ES is the live core taxonomy on zonadeexclusion
# (fetched 2026-07-03), mirroring EN. Deliberately excluded from ES: the one-post
# long-tail tags the model invented before constraining, and "investigacion-2"
# (a Ghost case-collision duplicate of "investigacion").
ALLOWED_TAGS = {
"en": [
"uap", "declassified", "military-cases", "classic-cases",
"investigation", "spain-cases", "latin-america",
],
# "es": [...] # TODO: pin the live ES tag vocabulary before constraining.
"es": [
"uap", "desclasificados", "casos-militares", "casos-clasicos",
"investigacion", "casos-espana",
],
}
# Language-aware default tag when nothing from the allow-list fits / the model
@@ -70,33 +75,20 @@ async def fetch_published_menu(lang: str) -> list[dict]:
try:
# Lazy import to avoid a heavy/circular import at module load.
from src.generator.generator import GhostPublisher
import aiohttp as _aio
pub = GhostPublisher(lang=lang)
if not pub.is_configured():
logger.warning("seo.menu: Ghost not configured for lang", lang=lang)
return []
token = pub._make_token()
url = (
f"{pub.url}/ghost/api/admin/posts/"
"?filter=status:published&fields=id,slug,title&limit=all"
# _admin_get lleva los headers canónicos (auth, Accept-Version y el
# Accept-Encoding sin br — ver KNOWN-ISSUES.md) y loguea los non-200.
data = await pub._admin_get(
"posts/?filter=status:published&fields=id,slug,title&limit=all",
timeout=30,
)
timeout = _aio.ClientTimeout(total=30)
async with _aio.ClientSession(timeout=timeout) as sess:
async with sess.get(
url,
headers={
"Authorization": f"Ghost {token}",
"Accept-Version": "v5.0",
},
) as resp:
if resp.status != 200:
body = await resp.text()
logger.warning("seo.menu: non-200 from Ghost",
status=resp.status, body=body[:200])
return []
data = await resp.json()
if data is None:
return []
menu = [
{"slug": p["slug"], "title": p.get("title", "")}
@@ -116,9 +108,13 @@ async def fetch_published_menu(lang: str) -> list[dict]:
def _system_prompt(lang: str) -> str:
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
# The anti-legacy "never use investigacion" rule is EN-only: on the ES site
# "investigacion" IS the canonical tag (and the allow-list already excludes
# the "investigacion-2" duplicate).
legacy_clause = ", never use \"investigacion\"" if lang == "en" else ""
tag_clause = (
f"TAGS: choose 2-4 tags ONLY from this exact list (never invent a tag, "
f"never translate it, never use \"investigacion\"): {allow}.\n"
f"never translate it{legacy_clause}): {allow}.\n"
if allow else
"TAGS: 2-4 lowercase-hyphenated topical tags appropriate to the article.\n"
)
+29
View File
@@ -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
@@ -26,3 +36,22 @@ def test_simple_chunk():
chunks = simple_chunk(text, chunk_size=100, overlap=20)
assert len(chunks) > 1
assert all(isinstance(c, str) for c in chunks)
def test_unwrap_news_link():
from src.scraper.exhaustive import _unwrap_news_link
wrapped = ("http://www.bing.com/news/apiclick.aspx?ref=FexRss&aid=&tid=x"
"&url=https://example.com/article&c=1&mkt=es-es")
assert _unwrap_news_link(wrapped) == "https://example.com/article"
# Links normales pasan intactos
assert _unwrap_news_link("https://thedebrief.org/foo") == "https://thedebrief.org/foo"
# url= protocol-relative se normaliza a https
assert _unwrap_news_link(
"http://www.bing.com/news/apiclick.aspx?url=//example.com/article"
) == "https://example.com/article"
# apiclick sin url= usable se descarta ("" → el caller lo salta), nunca se
# deja pasar el redirect de bing.com como fuente
assert _unwrap_news_link("http://www.bing.com/news/apiclick.aspx?ref=x") == ""
assert _unwrap_news_link(
"http://www.bing.com/news/apiclick.aspx?url=javascript:alert(1)"
) == ""
+39
View File
@@ -0,0 +1,39 @@
from src.seo.autofill import ALLOWED_TAGS, DEFAULT_TAG, _coerce, _system_prompt
BASE = {
"meta_title": "t",
"meta_description": "d",
"custom_excerpt": "e",
"image_query": "q",
"image_context": "c",
}
def test_coerce_es_drops_invented_tags():
obj = dict(BASE, tags=["uap", "humanoides", "Desclasificados", "investigacion-2"])
out = _coerce(obj, "es")
assert out["tags"] == ["uap", "desclasificados"]
def test_coerce_es_falls_back_to_default():
obj = dict(BASE, tags=["pentagono", "encuentros-cercanos"])
out = _coerce(obj, "es")
assert out["tags"] == [DEFAULT_TAG["es"]]
def test_coerce_en_still_constrained():
obj = dict(BASE, tags=["uap", "investigacion"])
out = _coerce(obj, "en")
assert out["tags"] == ["uap"]
def test_system_prompt_lists_allowed_tags_per_lang():
es = _system_prompt("es")
en = _system_prompt("en")
for tag in ALLOWED_TAGS["es"]:
assert tag in es
# La regla anti-legacy 'never use "investigacion"' es solo para EN: en ES
# "investigacion" es el tag canónico del allow-list.
assert 'never use "investigacion"' in en
assert 'never use "investigacion"' not in es
assert "ONLY from this exact list" in es