Imported from pchinso/webcams-tap (
AGENTS.md). Install upstream withnpx skills add pchinso/webcams-tap. Copyright stays with the author.
AGENTS.md
Notas para un asistente de IA (Claude Code u otro) que retome este proyecto sin el historial de la conversación en la que se construyó. Ver README.md para instalación y uso; esto es contexto de "por qué está hecho así" y decisiones a respetar.
Qué es esto
Dos cosas independientes en el mismo repo:
- App de escritorio (
main.py+cameras.py+streams.py+mapthumb.py+hls.min.js): visor de 88 webcams públicas del Cantábrico (Asturias + costa gallega de Estaca de Bares), con navegación por teclado/ratón, buffer para cambio instantáneo, detección de cámaras muertas, meteo en vivo, modo loop y grabación. - Rutina de evaluación del eclipse (
captura_eclipse.py+evaluar_eclipse.py): campaña de captura diaria (iniciada 17-07-2026, independiente de la app) para acumular datos de nubosidad real por ubicación de cara al eclipse solar total del 12-08-2026 (totalidad sobre Asturias ~20:26 CEST). Objetivo final: decidir desde qué webcam habría mejor probabilidad de verlo, y con el tiempo mejorar la predicción combinando el histórico con el forecast de Open-Meteo cerca de la fecha (idea pendiente, no implementada).
Decisiones que no hay que deshacer sin motivo
- No usar el reproductor embebido de rtsp.me/Angelcam directamente
(iframe). Se probó primero y el problema es real: ese reproductor exige
un clic del usuario para arrancar y, al ser de otro origen, ni la app ni
el usuario pueden dárselo de forma fiable (la capa de control de la app
intercepta los clics). La solución fue extraer/resolver la URL
.m3u8real (streams.py: resolve_stream_url) y reproducirla conhls.jssobre un<video muted autoplay>propio. - rtsp.me ha rediseñado dos veces cómo se resuelve su
.m3u8, y probablemente lo vuelva a hacer. Historial, para no repetir la investigación desde cero cada vez:- 21-07-2026: pasó de servir el
.m3u8en claro en el HTML a una SPA vacía. Rompió ~81/88 cámaras de golpe (detectado por el informe ejecutivo del/loopde supervisión). Arreglado ese mismo día con un flujo víaGET {embed_url}session/(JSON constream.hlsTemplateystream.keyJs). - 27-07-2026 → 02-08-2026 (sin detectar durante 5 días): rtsp.me
empezó a restringir cámaras individuales a un
allowedDomainsconcreto (webcamsdeasturias.com/hispacams.com); esas cámaras dabandomain_forbiddenen/session/aunque el Referer fuera correcto. 41/88 cámaras se quedaron "sin datos" de forma constante (mismo conjunto exacto cada día) sin que nadie lo arreglara — quedó apuntado en el informe ejecutivo pero la sesión dedicada recomendada no se abrió hasta 5 días después. Lección: cuando el informe ejecutivo marca una incidencia como "prioridad alta, necesita sesión dedicada", no dejarla varios ciclos de/loopsin atender. - Arreglo actual (02-08-2026):
streams.py: _resolve_rtspme()usa un endpoint distinto y más nuevo,GET https://rtsp.me/api/embed/<id>/token/?source=<RTSPME_SOURCE>(con Referer=embed_url) — el parámetrosource(URL-encoded,RTSPME_SOURCE = "https://webcamsdeasturias.com/") es imprescindible: sin él, las cámaras concam.domainrestringido dandomain_forbiddenaunque el Referer sea válido. Se descubrió inspeccionando con CDP/headless Chromium qué peticiones hace un navegador real al cargar la página que sí embebe la cámara — el Referer HTTP por sí solo nunca iba a bastar porque, dentro del propio iframe de rtsp.me, el Referer de sus peticiones internas siempre esrtsp.me(elsourcees un dato que su JS construye aparte, probablemente a partir dedocument.referrer, y lo manda como query param). La respuesta de/token/da{status, token, url}; el siguiente paso esGET {url}/token/{token}(Referer=https://rtsp.me/), que devuelve{hls: <URL .m3u8 real>}. Funciona tanto para las cámaras que nunca se rompieron como para las 41 restringidas (probado con muestra de ambos tipos, 100% éxito) — sustituye por completo al flujo de/session/del 21-07, no coexisten. Los segmentos siguen en.m4s(fMP4/CMAF), sin problema para OpenCV/ffmpeg. Si vuelve a romperse: repetir el mismo método (CDP/headless Chromium cargando la página real que embebe una cámara afectada, mirar qué pide su JS) en vez de asumir que el flujo actual es la única forma válida — ya ha cambiado dos veces.
- 21-07-2026: pasó de servir el
- Los streams de rtsp.me "duermen" sin espectadores y sirven una
playlist con segmentos placeholder (con el formato antiguo, nombre
terminado en
-40x.ts, vercaptura_eclipse.py: PLACEHOLDER_RE— no se ha confirmado si el formato.m4snuevo tiene un equivalente; en las pruebas tras el 21-07 los streams respondían con contenido real ya en el primer intento, sin necesitar el sondeo) en vez de vídeo real. La rutina de captura los despierta imitando el comportamiento del reproductor (wake_stream(): resuelve la URL → sondeo de la playlist tocando segmentos hasta que cambian a reales, con reintento si la primera respuesta da timeout — el transcoder bajo demanda a veces tarda en arrancar). Sin esto, la captura por lotes solo conseguía ~25/80 cámaras en vez de ~71-80/88. La app normal no necesita el sondeo porque hls.js reproduciendo activamente ya actúa como espectador y despierta el stream solo. - No borrar cámaras caídas de
cameras.py. El usuario pidió explícitamente no eliminar fuentes que fallan puntualmente porque pueden volver a funcionar. Tanto la app (marca "mala" temporal, reintenta a los 10 min) como la rutina de captura (deja sin frames ese día, sin romper el resto) están pensadas para convivir con cámaras intermitentes. - No tocar el formato de
capturas/AAAA-MM-DD/<slug-cámara>/HHMMSS.jpgni los slugs de cámara (captura_eclipse.py: slug(), indexado por posición enCAMERAS).evaluar_eclipse.pydepende de ese formato para construir la serie histórica y el ranking acumulado; cambiarlo a mitad de campaña rompe la comparación entre días. capturas/,animaciones/,reportes/,grabaciones/van en.gitignore: son datos generados (potencialmente cientos de MB/día), no código. No los añadas al repo salvo que el usuario lo pida explícitamente.- La carpeta
memoria/es una copia puntual (volcado manual) de la memoria de contexto que un asistente Claude Code mantiene entre sesiones fuera del repo. No se sincroniza sola — si se actualiza la memoria real, hay que volver a copiarla aquí a mano si se quiere reflejar el cambio. - En Linux,
pywebviewdebe forzarse a Qt (PYWEBVIEW_GUI=qt), nunca dejarlo en GTK (backend por defecto ahí). Diagnosticado a fondo: con GTK/WebKit2GTK,hls.jsparsea bien el manifest y la playlist pero nunca llega a pedir fragmentos (eventohlsError/internalExceptionno fatal al montar el buffer) — la cámara se queda en negro para siempre aunque el stream funcione (mismo stream, mismohls.js, en Chromium se ve bien al servir el HTML por HTTP). Qt usa QtWebEngine (motor Chromium, como WebView2 en Windows) y sí decodifica.lanzar.shya fuerza esto via env var; necesitapython-pyqt6+python-pyqt6-webengine(paquetes de sistema,sudo pacman -S ..., no instalables por pip) yqtpy(sí por pip, ya en el.venv). No "arreglar" esto tocando GTK/WebKit2GTK — es un límite real de su soporte de Media Source Extensions, no una configuración que falte.
Peculiaridades del entorno de desarrollo (no del equipo de producción)
Esto se descubrió en la máquina donde se escribió el código, que no es la que ejecuta la app. Si aparecen los mismos síntomas en el equipo real, aplican los mismos workarounds; si no aparecen, ignóralos:
- Variables de entorno
SSL_CERT_FILE/REQUESTS_CA_BUNDLEapuntando a un certificado corporativo inexistente rompíanrequests/OpenCV con streams por HTTPS. Workaround:os.environ.pop(...)al principio decaptura_eclipse.py, yverify=Falsecomo fallback enstreams.py: _http_getcuando falla la verificación TLS. Si el equipo real no tiene esas variables, este código es inocuo (nunca se dispara). - Un aviso de log no fatal (
AccessibilityObject.Bounds... recursion) al arrancarpywebviewen el entorno de desarrollo (sandbox sin sesión gráfica interactiva real). No impidió que la ventana funcionara en las pruebas hechas vía navegador con los mismos streams. Si aparece en el equipo real y molesta, investigarlo entonces — no es necesariamente el mismo problema.
Cómo se verificó cada pieza (sin navegador headless propio)
Todo el desarrollo se hizo en una máquina sin sesión gráfica interactiva
real, así que la verificación de UI se hizo renderizando el HTML generado
por main.py (build_html(test_urls=...)) en el navegador de la
herramienta de desarrollo, sirviéndolo por python -m http.server y
comprobando con JavaScript inyectado (readyState, luminancia, contenido
del DOM) en vez de capturas de pantalla de la ventana nativa. Si tocas
main.py, la forma más rápida de volver a comprobar cambios de
interfaz/JS sin instalar nada nuevo es ese mismo patrón: generar el HTML,
servirlo por HTTP, abrirlo en un navegador normal.
Ejecución diaria de la rutina del eclipse (equipo Linux)
En el equipo Linux que ejecuta la campaña diaria (distinto del usado para desarrollar), la ejecución real no debe depender de una sesión interactiva de Claude Code. Decisión (ver detalle completo en establece_rutina_linux.md):
- El planificador del sistema operativo (
cronosystemd timer) es quien lanzarun_daily.shcada día a las 19:30 hora local (la ventana de captura es 19:30–21:00;captura_eclipse.pyya espera y corta solo,run_daily.shno necesitatimeoutexterno). - Claude Code con
/looppuede usarse solo como supervisión adicional (revisar logs, detectar fallos), nunca como el mecanismo principal de disparo diario: si se cierra la sesión, se reinicia el equipo o/loopno se recupera, no hay garantía de que la captura se lance. - Este equipo concreto (Arch/Omarchy) no tiene
croninstalado (cronieno está presente). Se usa un systemd user timer en su lugar (~/.config/systemd/user/webcams-eclipse.{service,timer}), equivalente en este caso a la receta decrondel documento de referencia. Si se reinstala el equipo o se cambia de máquina, reevaluar si instalarcronieo mantener systemd según lo que tenga esa máquina. run_daily.shusa.venv/(creado conpython3 -m venv .venv) porque Python del sistema en Arch es "externally managed" (PEP 668) y bloqueapip installglobal; no asumir que las dependencias del eclipse (opencv-python,Pillow,requests) están instaladas a nivel sistema.- Antes de activar el timer se validó con una prueba rápida
(
captura_eclipse.py --ahora 1, 70/80 cámaras, formato correcto encapturas/) que el entorno funciona (mismo criterio que el documento de referencia paracron: no programar sin probar antes). - Estado a 17-07-2026: timer activo
(
systemctl --user enable --now webcams-eclipse.timer) conloginctl enable-linger chinsopara que se dispare aunque no haya sesión gráfica iniciada. Comprobar consystemctl --user list-timers webcams-eclipse.timery revisarlogs/daily.logtras la primera ejecución real.
Fuentes de datos (contexto de por qué son de fiar)
- Cámaras: red Webcams de Asturias / Hispacams
(
webcamsdeasturias.com,hispacams.com), rastreadas por categoría (playas, puertos, comunidades limítrofes) para cubrir toda la costa; una cámara de Viveiro viene de Hispacams pero usa Angelcam como proveedor de vídeo en vez de rtsp.me. Las 8 cámaras de Fisterra→Ortigueira (índices 80-87, añadidas 19-07-2026) vienen de Hispacams (Fisterra, rtsp.me) y de G24/CRTVG (g24.gal, radiotelevisión pública de Galicia) para el resto — streams.m3u8propios encrtvg.es, permanentes y sin necesidad de "despertar" (siempre en directo). Se probaron muchas otras fuentes para el tramo Costa da Morte (Camariñas, Malpica, Laxe, Cedeira, Cariño) sin éxito:camaramar.comcataloga esos puntos pero la mayoría no tiene cámara activa asociada ("webcam":[]en su Livewire), o solo ofrece un bucle grabado (VODgrabaciones/...), no directo real. Si se quiere ampliar esa zona más adelante, revisar de nuevocamaramar.com(puede que activen cámaras que hoy están vacías) o buscar webcams municipales propias de esos concellos. - Meteo: Open-Meteo (
api.open-meteo.com), API pública sin clave, consultada por lotes (todas las cámaras en una sola petición HTTP).