Imported from David-Lazaro-Fernandez/matine (
AGENTS.md). Install upstream withnpx skills add David-Lazaro-Fernandez/matine. Copyright stays with the author.
AGENTS.md
Guía para agentes de código (Claude Code, Cursor, Copilot) y para cualquier persona que edite este repo. Es la fuente de verdad de convenciones. Lo que no va aquí:
project.md— cómo funcionan las APIs de ambas cadenas, modelo de datos, estado y decisiones.ARCHITECTURE.md— qué proceso toca qué dato, qué lo programa, catálogo de servicios.DESIGN.md— paleta, tipografía, componentes y reglas visuales del dashboard.deploy/README.md— operación del servidor.make help— comandos.
Antes de proponer un cambio, lee la sección que le toca. Si el cambio contradice algo de aquí, dilo explícitamente en vez de hacerlo a medias.
1. Panorama
Inteligencia competitiva de cartelera para Cinemex (el cliente) frente a Cinépolis (el competidor). La captura es nacional (desde el 2026-09-11); el dashboard compara dentro de una plaza (CDMX por defecto, la del piloto) o a nivel nacional. Tres capas, sin mezclarse:
| Capa | Carpeta | Dependencias | Escribe |
|---|---|---|---|
| Captura | scraper/ |
solo stdlib, /usr/bin/python3 |
data/snapshots.db, data/raw/, data/logs/ |
| Negocio | analytics/ |
solo stdlib | nada (abre la base en mode=ro) |
| Presentación | dashboard/ (Next.js estático) + api/internal.py y api/pages/ |
dashboard/: Node/pnpm solo para el build; API: .venv (fastapi, uvicorn) |
nada |
| Acceso | auth/ |
.venv (hashlib.scrypt; boto3 solo para SES) |
data/app.db (organizaciones, cuentas, perfil, sesiones, enlaces, auditoría, logo), nada más |
| Agente | agent/ |
.venv (fastmcp 4.1.0) |
nada directo: conversaciones, auditoría y pulgares van por auth/conversations.py a data/app.db |
| Programación | jobs/ |
solo stdlib, /usr/bin/python3 |
data/logs/jobs.jsonl, data/locks/; genera deploy/systemd/ |
| Escucha | listening/ |
captura, muestra y medición: solo stdlib; classify y train: .venv-ml (torch, solo Mac) |
data/listening.db, data/listening/ |
| Avisos | integrations/ |
.venv (analytics/ en solo lectura; auth/ para config y estado) |
nada directo: el estado de envío va por auth/orgs.py a data/app.db |
| API pública | api/ |
.venv (fastapi, uvicorn) |
nada (lee snapshots.db con analytics.connect(), mode=ro) |
| Sitio público | marketing/ |
Node/Next.js, proyecto independiente (su propio package.json) |
nada (sitio estático, output: 'export') |
Reglas de arquitectura (no negociables sin discutirlo)
- El scraper no tiene dependencias externas. Corre con el Python del sistema en un servidor
pelado. Si necesitas algo que no está en la stdlib, la respuesta por defecto es implementarlo en
scraper/http.pyo replantear el cambio. Igual paraanalytics/: sinpandasni nada más. analytics/no importa FastAPI ni pandas y no sabe que existe un dashboard. Son funciones puras: recibenconny parámetros, devuelven listas de dicts. Están escritas así para envolverlas después en un API (FastAPI) sin reescribir nada.- La presentación no lleva SQL ni lógica de negocio.
dashboard/pinta lo que devuelve la API interna, y cada módulo deapi/pages/(uno por página) solo llama aanalytics/yauth/y devuelve sus filas tal cual. Si para una vista nueva hace falta un cálculo, va enanalytics/, no en el endpoint ni en el frontend. - Un solo escritor sobre
snapshots.db. Todo lo que escribe corre en serie desde el mismo trabajo o en minutos distintos (:07). No añadas un proceso escritor sin ubicarlo en ese calendario. - La comparación es de dos cadenas; la captura, de las que haya. Cinemex y Cinépolis son las comparables
(
US/THEM, shares head-to-head enanalytics/queries.py). La Cineteca Nacional (chain="cineteca", cine independiente de CDMX) se captura en el mismo esquema (scraper/cineteca.py, plano de Vista ensample.py) pero no entra a los shares (cine de autor, un precio, pocas funciones: engañaría). Lo que compara dos cadenas —analytics/plaza.py,analytics/queries.py,api/pages/cartelera.py— se queda en Cinemex↔Cinépolis:_window,plaza_whereyplaza_cinema_whererecibenchains=COMPARED(analytics/labels.py) por defecto, también con zona nacional, y lo que pide una cadena explícita (chain="cineteca") pasachains=(chain,). La Cineteca tiene su propia vista (dashboard/app/independientes/,analytics/independents.py). Un cine independiente nuevo sigue este patrón: captura aditiva,chains=explícito, presentación aparte. - El calendario tiene una sola fuente:
jobs/registry.py. Cada trabajo programado tiene una llave enjobs/keys.py(con su área, la capa dueña) y una entrada en el registro: pasos, horario, tope, reintentos y dónde corre. De ahí se generan las unidades de systemd (deploy/systemd/,make units), los agentes de launchd (make launchd-load), las capturas que esperascraper.healthy la tabla deARCHITECTURE.md;make checkfalla si lo generado no coincide. El registro nombra el código de cada paso por módulo y lo lanza como subproceso con su intérprete (python,venv,bash):jobs/no importa ninguna capa salvoscraper.config, y cualquiera puede importarjobs.keysyjobs.registrypara nombrar un trabajo. - Solo SQLite, un escritor por archivo (decisión 2026-09-25: sin Postgres ni RDS, por costo y por los 2 GB del
servidor; el código anterior vive en el tag
pre-sqlite-only).data/snapshots.dbla escribe solo la captura (scraper/);data/app.dbla escribe soloauth/;data/precalc.dbla escribe soloapi.precalc(decisión 2026-10-11);data/listening.dbla escribe sololistening/(decisión 2026-10-16), que guarda las respuestas por defecto de la Cartelera al final de los trabajossnapshotyseats. Esa es una caché que se puede borrar: sin ella, la API calcula en vivo. No agregues otro escritor a ninguna sin ubicarlo aquí. - El dashboard nunca escribe datos.
analytics.connect()abresnapshots.dbenmode=ro, a propósito. Lo único que escribe desde el dashboard esauth/, y solo enapp.db(cuentas y su perfil, sesiones, enlaces de acceso, auditoría, logo); las endpoints no lo llaman directo, pasan porapi/session.py, el único módulo que conoce la cookie de sesión.scraper/yanalytics/jamás importanauth/, yauth/no importa FastAPI. Las herramientas deagent/envuelven funciones deanalytics/(sin SQL) yagent/no importa FastAPI; nadie importaagent/salvoapi/. - Las reglas de "qué cuenta como cambio" y "cómo se cierra una función" viven en
scraper/diff.py(changed_fields,closing_kind) yscraper/normalize.py(TRACKED_FIELDS); scraper y dashboard las comparten y no se duplican. El crudo (data/raw/) es la fuente para reconstruir cualquier historia con esas mismas reglas. - El estado de un cine sale de
scraper/states.py(clave de INEGI,state_code, común a ambas cadenas), calculado por coordenadas conscripts/cinema_states.pyy versionado enscraper/cinema_states.csv. No lo derives de la API: la "ciudad"cdmxde Cinépolis y el "estado 8" de Cinemex cruzan la frontera con el Estado de México. - Qué películas son la misma en ambas cadenas lo decide
scraper/titles.py(title_key, reglas explícitas más la tabla versionadascraper/title_pairs.csv). Toda comparación de películas entre cadenas agrupa portitle_key(title_norm), registrada en SQLite poranalytics.connect(); no comparestitle_normentre cadenas. Un par dudoso se decide conscripts/title_pairs.py, nunca por parecido automático. - Qué distribuidora tiene un título lo decide
scraper/distributors.py:distributor_map.csvjunta las variantes ytitle_distributors.csvdecide cuando las cadenas no coinciden. Lo que compara distribuidoras entre cadenas pasa pordistributor_reach; un título sin decidir essin_conciliar, nunca se adivina. - La geografía tiene una sola fuente:
scraper/plazas.py. Qué ciudades de Cinépolis y qué áreas de Cinemex forman una plaza se define ahí (stdlib) y lo comparten el muestreo de planos (config.SEATS_PLAZAS) y el filtro de plaza deanalytics/(analytics/plaza.py). Los planos de asientos no se censan a nivel nacional: solo entran las plazas deAC_SEATS_PLAZAS(decisión 2026-09-11); precios y dulcería sí van sobre todos los cines capturados. La excepción es el aforo por sala, que se midió una vez para todo el país a mano (make capacity PLAZAS=all, 2026-09-12) porque casi no cambia;--plazassolo altera esa corrida, nunca un timer. Cada función llevadatetime_utc: "ya empezó" (from_now,closing_kind) se decide con esa hora, no con la de CDMX, porque México tiene siete zonas horarias. - El dashboard es un sitio estático.
next build(exportación estática) corre en GitHub Actions ydashboard/out/viaja dentro de la ramastable; el servidor no tiene Node. Caddy sirveout/y pasa/api/*a la API interna (absolut-cinema-dashboard.service, 127.0.0.1:8501). Todo componente de Fluent es de cliente y pide sus datos a/api. marketing/es un proyecto aparte con su propiopackage.json: ninguna capa de Python lo importa y él no importa nada del repo. La única conexión es HTTP: la demo/a-donde-ir/consulta la API pública (api/main.py, sobreanalytics/recommender.py) enNEXT_PUBLIC_API_URL, y nunca recibe el catálogo completo. Su identidad visual sí se hereda a mano deDESIGN.mdyanalytics/labels.py(vermarketing/design.md), pero eso es documentación, no código compartido.
2. Principios de producto
Estos mandan sobre cualquier preferencia técnica.
- Datos reales siempre. Lo que no existe no se muestra con cifras. Sin placeholders, sin datos de ejemplo, sin "aproximadamente". Si un panel depende de historia o de una integración que no está, se declara como pendiente en su sección y se dice qué desbloquea.
- Umbral antes de afirmación. Un hallazgo solo se muestra si cruza su umbral
(
analytics/findings.py). Si ninguna diferencia lo cruza, la pantalla lo dice y no inventa. - Shares, no absolutos. Toda medida comparable entre cadenas es % de la programación de cada cadena. Cinemex y Cinépolis tienen distinto número de cines y salas; un absoluto engaña. Denominador actual: funciones. Cuando haya aforo completo pasará a butacas ofertadas.
- Comparación justa del día en curso. Cinépolis borra cada función al empezar y Cinemex la
conserva unas horas. Cualquier consulta que incluya hoy usa
from_now=True(por defecto), y el corte se hace en UTC (datetime_utc), así una función de Tijuana no se da por empezada dos horas antes. - Se compara dentro del mismo alcance. Una plaza (
plaza="cdmx", la del piloto y la del dashboard por defecto) o todo el país (plaza=None); nunca una cadena en una plaza contra la otra en otra. - La semana de cine es jueves a miércoles. Es lo que ambas cadenas publican completo. Usa
analytics.cinema_week(), noisocalendar(). - Hablamos como Cinemex, en primera persona.
US/THEMenanalytics/labels.py.
3. Python
- Versiones: el scraper y
analytics/deben correr en/usr/bin/python3(3.9+); el dashboard usa el venv (3.12). - PEP 8 y el Zen de Python. Código elegante y legible antes que ingenioso.
- Evita la herencia (prefiere composición) y evita las clases salvo para excepciones. Este repo son funciones y módulos; que siga así.
- Nombra funciones y variables de forma que no haga falta un comentario para entenderlas.
- Archivos y carpetas en
snake_case. - Importa módulos, no funciones sueltas, cuando el módulo aporta contexto:
from scraper import config→config.DB_PATH. Para constantes y helpers de un mismo paquete, el import directo está bien (from .labels import SLOTS). - Todo lo privado de un módulo lleva
_(_window,_FORMAT_CASE). Si no lleva_, es API del módulo y alguien puede depender de ello. - Argumentos: posicionales solo los que enmarcan la función (
conn,d0,d1). Todo lo que matiza va con nombre y con default que sirva para el 80% de los casos (from_now=True,limit=60,chain="cinepolis"). - Comentarios: mayúscula inicial, gramática y puntuación correctas, en español. Un comentario
describe el comportamiento actual o el por qué, nunca cómo era antes ni el historial.
Los porqués valiosos son los de las APIs ajenas (por qué hay una pausa, por qué un lote es de 30). Frases cortas en
Español Técnico Simplificado y nunca al final de una línea de código; el detalle está en
.claude/rules/comments.md. - Docstrings: para quien usa la función, no para quien la va a editar. Si le quieres hablar al siguiente que edite, usa un comentario. Todo módulo lleva docstring de nivel superior con qué hace y qué supuestos tiene.
Vocabulario estándar
Los mismos nombres en todo el repo. Esto es sagrado; renombrar rompe la lectura cruzada de SQL,
analytics/, api/pages/ y dashboard/.
| Nombre | Significado |
|---|---|
chain |
"cinemex" | "cinepolis" | "cineteca" (Cineteca Nacional, CDMX) | "cineteca_gdl" (Cineteca FICG y Cineforo, Guadalajara) | "cineteca_mty" (Cineteca Nuevo León, Monterrey) | "wtc" (Cinemas WTC, CDMX) | "tonala" (Cine Tonalá, CDMX) | "cinemania" (Cinemanía, CDMX) | "papalote_mty" (Megapantalla IMAX de Papalote, Monterrey) | "epic" (Epic Cinemas, Monterrey) | "raly" (Cinemas Raly, Monterrey) | "cinery" (Cinery, Guadalajara) | "cabanas" (Cine Cabañas, Guadalajara); las cinetecas y los independientes se capturan aparte y no entran al head-to-head |
conn |
conexión SQLite de solo lectura, siempre primer argumento en analytics/ |
d0, d1 |
rango de fechas ISO inclusivo, en hora local de la plaza |
from_now |
recorta el día en curso para comparar justo |
show_id |
función; Cinemex id nacional, Cinépolis slug-del-cine:sessionId |
cinema_id |
Cinépolis por slug, Cinemex por id numérico |
screen |
sala; la llave de aforo es (chain, cinema_id, screen) |
minutes_to_start |
≥ 0 preventa (T−60), < 0 asistencia (post-inicio) |
pp |
puntos porcentuales (diferencias entre shares) |
plaza |
zona metropolitana comparable, clave de scraper/plazas.py (cdmx, gdl, mty); None = nacional |
city_id |
llave geográfica más fina de cada API: Cinépolis slug de ciudad (cdmx), Cinemex id de área ("15") |
state_id |
estado de la API de Cinemex ("8", "CDMX y Área Metropolitana"); NULL en Cinépolis |
state_code |
estado de INEGI del cine ("09" CDMX, "15" Estado de México), ambas cadenas; scraper/states.py |
title_key |
llave de título común a ambas cadenas (scraper/titles.py); las funciones de analytics/ la devuelven como title_norm |
datetime_utc |
la hora de la función en UTC (ISO con +00:00); datetime_local sigue siendo la que publica la cadena |
Consistencia de la API de analytics/
Cada función nueva se parece a las que ya existen: fn(conn, d0=None, d1=None, from_now=True, hours=None, plaza=None),
devuelve lista de dicts con claves en snake_case en inglés, y ordena de forma determinista
(ORDER BY explícito siempre; sin ORDER BY el mismo dato puede pintar distinto entre recargas).
Exporta en analytics/__init__.py y añádela a __all__. Antes de inventar una función nueva,
mira si extender una existente con un parámetro con nombre cubre el caso.
Toda consulta nueva se revisa con EXPLAIN QUERY PLAN contra la base real. snapshots.db pesa más de 2 GB y
event pasa del millón y medio de renglones. Un SCAN sobre una tabla grande que el filtro debería acotar es un índice
que falta: va en scraper/store.py (CREATE INDEX IF NOT EXISTS, aditivo; analytics/ abre en solo lectura), con un
comentario de qué consulta lo usa. Si el filtro siempre lleva la misma condición, el índice puede ser parcial
(idx_event_closed ... WHERE kind IN ('removed', 'expired')). Mide la consulta dos veces, en frío y en caliente; en frío
pesa la lectura del disco. Lo que no cambia entre peticiones no se recalcula en cada una: un total que no depende de la
página va en su propia llave de read() (api/pages/datos.py). Lo descubrió el explorador de Datos (2026-10-11): cada
página leía event completa y volvía a contar todo, 1.2 s en caliente y hasta 4.5 s en frío; con el índice parcial y el
total aparte, 0.3 s.
4. Textos, colores y etiquetas
- Ningún nombre interno llega al usuario, tampoco rutas de archivos ni módulos.
chain,kind, claves de franja y de cubeta se traducen enanalytics/labels.py. Si un texto aparece en pantalla, sale delabels.py(etiquetas) o defindings.py(frases), nunca escrito endashboard/ni enapi/.dashboard/los lee delib/labels.generated.ts(make labels). - Sin texto repetido ni de relleno. No agregues una etiqueta, nota, descripción o pie que repita lo que la pantalla ya muestra (el valor de un filtro, el título, un total que ya está en otro lado) o que solo describa la pantalla ("Las tablas del archivo histórico, para…"). Cada texto da un dato o una advertencia que no está en otra parte; ante la duda, no se escribe. La atribución que pide una licencia (© OpenStreetMap en los mapas) sí se queda.
- Ningún hex se escribe en la presentación (
dashboard/). Endashboard/la interfaz usatokens.*de Fluent. Los colores viven enanalytics/labels.py(RED,INK,GRAY,LINE,CHAIN_COLOR,RED_RAMP,DIVERGING…). Para un color nuevo: agrégalo alabels.pyy documéntalo enDESIGN.mdantes de usarlo. - Rojo = Cinemex, solo en los datos. Cinépolis = tinta (
#191A1E). Endashboard/la interfaz (botones, navegación, foco, enlaces) usa los tokens de Fluent con su marca azul (decisión 2026-10-10,DESIGN.md); el rojo no se usa para acciones, alertas ni valores negativos. La excepción es el tema oscuro: su marca es la rampa roja claraDARK_BRAND(decisión 2026-10-11). Cinemex siempre primero en las escalas; las series no se ciclan. - Formato para directivos: horas en 12 h, fechas en español, números tabulares. Usa los helpers
(
date_es,range_es,time_12,n,pp), nof"{x:.1f}"a mano.
5. Captura (scraper/)
- Todo lo configurable pasa por
scraper/config.pyy admite sobreescritura por variable de entorno (AC_DATA_DIR,CINEPOLIS_API_KEY,CINEMEX_BASE_URL,AC_EGRESS_PROXY…). Nada de URLs, claves, ids de área o rutas escritos dentro de un módulo. - Excepciones: usa las de
scraper/http.pyen vez de levantarValueError/RuntimeErrorgenéricos.AuthError(401, o 403 con JSON) significa casi siempre que la clave embebida rotó y hay que actualizarconfig.pyyproject.md;Blocked(403 con página HTML) que el WAF del borde rechaza la IP de salida y la clave está bien;RateLimited(429) que hay que bajar el ritmo;ApiErrorel resto. Un error nuevo con causa distinta merece su subclase, no un mensaje suelto. - Guarda el crudo. Cada snapshot deja su
raw/{chain}/{fecha}/*.json.gz. Es lo que permite recalcular sin volver a pedir. No añadas un flujo que descarte la respuesta original. - Los cambios de esquema van en
store.pyconCREATE TABLE IF NOT EXISTSy son aditivos: la base de producción tiene historia desde el 2026-09-07 y no se recrea. Una columna nueva se agrega, no se renombra. - Un fallo de una cadena no debe tumbar la corrida de la otra; el snapshot se registra con
ok=0yerror, yscraper.healthlo reporta. - Dentro de una cadena, la captura va por unidades (
scraper/units.py): Cinemex por estado de su API, Cinépolis por lotes de hasta 30 cines agrupados por estado de INEGI. Una unidad que falla no tumba a las demás y conserva el estado anterior de sus cines: ni eventos ni cierres (diff.carry_over,diff.failed_cinemas). Nunca escribas una unidad a medias: si una llamada de la unidad falla, se descarta la unidad entera. Los errores del sistema (AuthError,Blocked,RateLimited) detienen la cadena. Cada unidad queda ensnapshot_unit. current_showtimese escribe por diferencia (store.apply_current): altas, cambios y cierres, no la tabla entera.
Registro (logs)
Los timers y los agentes solo ven la consola y data/logs/. Registra lo que un humano o un agente
necesita para diagnosticar: fallos de API, huecos de captura, respuestas degradadas, fallbacks
inesperados. No sobre-registres: el ruido esconde los problemas reales. Un caso raro que se
puede resolver sin que importe se maneja en silencio.
scraper.health es el que decide si la captura está sana y sale con 1 si hay problemas para que
el timer lo note. Si añades un flujo de captura, añade su cobertura ahí y su entrada en jobs/registry.py.
6. Dashboard (dashboard/, api/internal.py)
- Una página por módulo en
dashboard/app/y un endpoint por página enapi/pages/<pagina>.py, con unrouterde FastAPI,TypedDictde respuesta,Literalpara las llaves yread(key, compute)(api/pages/__init__.py: abresnapshots.dben solo lectura, cierra la conexión y guarda el resultado 60 s).api/internal.pysolo los registra. Páginas: Cartelera (cinco pantallas, una pregunta cada una, encomponents/cartelera/cartelera-page.tsx: Hallazgos en/, Programación semanal, Preventa, Precios y butacas, Cines (enapp/cines/, sin periodo), Historial de funciones; zona, periodo y franja en la barra), Mapa, ¿A dónde ir? (recomendador; fuera del menú, se abre por su dirección), Dulcería, Independientes, Datos (explorador desnapshots.db,analytics/datasets.py) y, solo para admin, Usuarios y, solo para staff, Operaciones. Un módulo que responde una pregunta propia del cliente y no depende del periodo va en su página; lo demás, en la cartelera. Excepción: Independientes sí depende del periodo y aun así va aparte, para que la Cineteca nunca se mezcle con los shares Cinemex↔Cinépolis. - Los filtros viven en la URL (
components/filters.tsx:plaza,d0,d1,h0,h1) y viajan como parámetros de cada endpoint. Nacional es la ausencia deplaza. El menú los lleva entre las pantallas de Cartelera y Dulcería (components/shell.tsx,FILTER_KEYS). - Operaciones es la excepción documentada a "ningún nombre interno llega al usuario": su público es quien opera la
plataforma, así que muestra nombres de tablas, logs y módulos tal cual, y usa los colores de estado
OKyWARNdelabels.py. Su lógica vive enscraper/health.py; su endpoint (api/pages/operaciones.py) exige staff. Nada de esa página escribe ni ejecuta acciones. - Acceso. Sin sesión solo existen
/entrar,/olvidey/restablecer(lib/api.ts,PUBLIC_PATHS); un 401 de la API manda a entrar. Lo que ve cada cuenta lo decide la API, no el menú: el módulo contratado por su organización (session.require_moduleen cada router), el rol (require_admin,require_staff) y si descarga (can_download). - Una pregunta por pantalla, sin rótulos de capa (
DESIGN.md, "Pantallas de la Cartelera"). Una sección de evidencia va siempre en el mismo orden: pregunta, conclusión, leyenda, gráfico, controles, "Cómo leerla". Reutiliza los componentes decomponents/common.tsx(Seccion,Pregunta,Leerla,Apendice,Leyenda,Table, los esqueletos yDiferido), no repliques el marcado. - Una página larga pide sus datos por partes (
useApi,Diferido;DESIGN.md, "Carga"): lo de arriba al entrar y cada sección cuando se acerca a la pantalla, con su propio endpoint. - El contenedor de una gráfica no cambia de orden. Toda gráfica va dentro de
Grafica(VegaChartya lo usa) y lleva siempre estas filas, en este orden: primero la gráfica y la tabla que sale de su misma fuente (GraficaGrupojunta las dos); luego los controles que las modifican junto con el "?" (controles,ayuda); y al final los botones de descarga junto con los enlaces de acceso (botones,enlaces). Un selector, una nota o un enlace nuevo entra por esas ranuras, nunca arriba de la gráfica ni suelto debajo de los botones. Una tabla que no sale de la misma fuente no va ahí: va en Datos. Mientras carga, el esqueleto va dentro del contenedor para que los controles sigan visibles. - Paneles dependientes de historia: si un panel necesita más días de los que hay desde
FIRST_SNAPSHOT, se muestra como pendiente con lo que desbloquea, no con una serie de un punto. - El cálculo va en
analytics/. Si te encuentras escribiendo lógica de negocio en un componente o en un endpoint, es señal de que falta una función enanalytics/.
7. Shell y despliegue
- Toda variable entre comillas (
"$BACKUP_BUCKET","$(dirname "$0")"). Una ruta o un nombre sin comillar se rompe con un espacio. deploy/*.shson bash conset -euo pipefail: instalar o respaldar a medias es peor que fallar.- Los pasos de un trabajo del registro corren todos aunque uno falle (
jobs.run, igual quemake -k): cada paso escribe su log y una captura que falla no debe cancelar las siguientes del mismo tick. El trabajo sí sale con error. - Node se maneja con
pnpm, nunca connpm(pnpm install,pnpm run build,pnpm dlxen vez denpx). El lockfile versionado espnpm-lock.yaml; no se commitea unpackage-lock.json. - El registro es siempre el público,
https://registry.npmjs.org/, nunca Shadowbox. El servidor, Vercel y CI no ven el registro privado. Un lockfile que apunta a Shadowbox rompe la instalación fuera de esta máquina. - Nada de secretos en el repo. Van en
deploy/absolut-cinema.env(con.exampleversionado). - Un trabajo programado nuevo es una llave en
jobs/keys.pyy una entrada enjobs/registry.py, seguido demake units. No se escriben unidades de systemd ni plists a mano: cada una ejecutamake job KEY=llave, así cualquier cosa que corre en automático se reproduce a mano igual. Los únicos servicios escritos a mano son el dashboard (deploy/absolut-cinema-dashboard.service) y la API pública (deploy/absolut-cinema-api.service), porque son permanentes, no programados. - Los servicios de escritura diarios corren en
:07, las capturas de cartelera en:30y el pase de butacas en:50, para no chocar entre sí.
8. Verificar un cambio
make check es la compuerta: ruff (reglas en pyproject.toml), la comprobación de que scraper/, analytics/ y jobs/
compilan e importan con el Python del sistema, y pytest. Lo corre el hook pre-push (.githooks/, se activa una
vez por clon con make hooks) y el workflow de GitHub que mueve la rama stable; si falla en local no hay push, y si
falla en CI no hay despliegue. ruff no bloquea por largo de línea ni por los ; que agrupan pasos cortos (el repo los
usa a propósito); sí por imports sin usar, nombres sin definir, orden de imports y llaves repetidas en un dict.
La verificación principal es correr el flujo de verdad contra la base. Hay además pruebas unitarias en
tests/ (pytest, requirements-dev.txt, solo en el venv): lógica pura que no toca red (diff de snapshots,
parsers, cuentas sobre un app.db temporal, conjuntos del explorador) y los endpoints de la API interna con
TestClient (tests/test_internal_api.py: acceso y cada página, por rol, sobre la captura grabada). El frontend no tiene
pruebas unitarias; pnpm run build en dashboard/ revisa los tipos y corre en CI. El dashboard en celular se prueba en
Chromium con Playwright (tests/test_mobile.py, make test-ui): cada página a 320, 360, 390 y 820 px, con títulos,
nombres de cine y correo alargados, no debe salirse de la pantalla ni encimar texto, y la barra de módulos debe mostrar
lo de cada rol. Necesita el build, así que solo corre con AC_UI=1: en CI va después del build.
La captura se prueba de punta a punta contra respuestas reales grabadas de ambas APIs (tests/test_capture_replay.py,
tests/fixtures/capture/, scripts/capture_fixtures.py): Cinemex estado 18 y Cinépolis hermosillo pasan por
snapshot(), normalize y store, y el resultado debe ser idéntico al esperado grabado y cumplir las reglas de
capture_fixtures.problems() (columnas siempre llenas, fecha y UTC coherentes, cines con estado). Si cambias algo que
pide o transforma el dato (cinemex.py, cinepolis.py, normalize.py, store.py, units.py) y la prueba falla, no la
ajustes: lee la diferencia que imprime; si el cambio era a propósito, regenera el esperado con make fixtures EXPECTED=1
y di en el commit qué dato cambió. make fixtures vuelve a grabar de las APIs (cuando cambie su forma) y
make test-live aplica las mismas reglas a una captura en vivo; ninguna de las dos corre en CI (red, y el WAF de
Cinépolis bloquea IPs de nube).
Corre .venv/bin/python -m pytest -q tests/ si tocas scraper/diff.py,
un endpoint o añades lógica pura; añade una prueba cuando el caso quepa en memoria y, si es un endpoint, en test_internal_api.py. Antes de dar por bueno un cambio:
make help # los targets son lo que corren los timers
/usr/bin/python3 -m scraper.run # snapshot completo de ambas cadenas (~5 min)
/usr/bin/python3 -m scraper.health # sale 1 si hay huecos o fallos
make dashboard # API interna en :8500 en main; ver "Puertos de desarrollo"
make dashboard-dev # frontend del dashboard en :3000 en main; ver "Puertos de desarrollo"
make marketing-dev # landing en :4000 en main; ver "Puertos de desarrollo"
sqlite3 data/snapshots.db "SELECT * FROM snapshot ORDER BY id DESC LIMIT 4;"
Puertos de desarrollo: los servidores siempre corren en main, y en main siempre usan el frontend del dashboard en
localhost:3000 y la API interna en localhost:8500. Solo un worktree distinto corre en otros puertos: con N el
número del worktree activo (el primero es 1, el segundo es 2), el frontend va en 3000 + N y la API en 8500 + N. Con
main y dos worktrees activos: main en 3000 y 8500, el primer worktree en 3001 y 8501, el segundo en 3002 y 8502. El
frontend de un worktree pasa /api a la API de su mismo número (AC_API_PORT). Si un puerto está ocupado,
make dashboard-dev mata el proceso que lo ocupa antes de arrancar. AC_BASE_URL sin definir apunta a
http://localhost:3000, así que los enlaces de correo y de Slack van al dashboard local. En producción AC_BASE_URL va
en el .env del servidor y la API del servidor sigue en 127.0.0.1:8501 (deploy/). La landing (make marketing-dev)
sigue la misma regla desde 4000: main en 4000, el primer worktree en 4001. El número de cada carpeta sale de
scripts/wt slot y lo usa el Makefile.
- Ejecuta el módulo que tocaste, no solo el que te queda a mano. Un cambio en
normalize.pyostore.pyse verifica con unscraper.runreal; uno enanalytics/levantando el dashboard (make dashboardypnpm dev) y mirando el panel; uno ensample.pycon el flag correspondiente y--limitbajo. - Verifica con el scraper del sistema, no con el venv, para que un import accidental de una
dependencia externa falle aquí y no en el servidor:
/usr/bin/python3 -c "import analytics, scraper.run, scraper.sample". - Ningún flujo abre órdenes de checkout: desde el 2026-09-25 el plano de Cinemex se lee del
GET sessions/{id}público. Si alguna vez hiciera falta unPOSTdel checkout (buy/*), va a mano y con tope, nunca en un automatismo, y se pide antes. - Si añades una función a
analytics/, imprímela una vez con datos reales antes de conectarla al dashboard; es más rápido que depurar en el navegador. - Acceso:
data/app.dbse crea sola al primer uso;make user-create EMAIL=… NAME=… ROLE=adminimprime el enlace de invitación (conAC_MAIL_BACKEND=consoletambién queda endata/logs/mail.log). Los endpoints se prueban sin navegador con elTestClientde FastAPI; lo que solo un navegador prueba es la cookie (entrar, refrescar, salir).
9. Al terminar
- Revisa cada comentario que agregaste o tocaste. Preferimos código que se explica solo, con buenos nombres, a
comentarios que repiten lo que ya se lee en el código. Un comentario no explica de más y va en ASD-STE100 (inglés) o
en Español Técnico Simplificado. Nunca escribas un comentario a la derecha de una línea de código. Detalle en
.claude/rules/comments.md. - Pasa el linter sobre todo lo que agregaste o cambiaste:
make lint(ruff) para Python ypnpm run build(que revisa los tipos) paradashboard/ymarketing/. - Sigue los patrones de los archivos vecinos antes que cualquier preferencia general.
- Actualiza la documentación que el cambio invalide, en el archivo que le toca:
project.md(APIs, modelo de datos, decisiones),ARCHITECTURE.md(flujo, servicios, cadencias),DESIGN.md(color, tipografía, componentes),deploy/README.md(operación),README.md(entrada). - Un servicio nuevo entra en el catálogo de
ARCHITECTURE.mdy un target nuevo enmake help; si es programado, entra enjobs/registry.pyy la tabla deARCHITECTURE.mdse regenera conmake units. - Documenta con fecha lo que se verificó contra la API ajena (
verificado 2026-09-08); estas APIs no tienen contrato y lo que hoy responde puede cambiar. data/está fuera de git. No versiones la base, el crudo ni los logs.- La descripción de un PR sigue
.claude/rules/pull-requests.md: Summary (Problem, Solution y pocos puntos) y Test Plan, en ASD-STE100. - La rama principal es
main. No hagas commit ni push salvo que se te pida, excepto un cambio pequeño (abajo). La ramastablela mueve GitHub Actions (.github/workflows/tests.yml) cuando las pruebas pasan enmain, y es lo que el servidor despliega (make deploy, cada 15 min): no la muevas a mano. Si un commit rompe las pruebas,stablese queda atrás hasta que se arregle; por eso una prueba que falla en CI bloquea el despliegue de todo lo posterior. - La carpeta principal del repo siempre está en
main, limpia. Ahí solo se integran ramas. Una función que trabaja un solo agente va en su propia rama (git switch -c mi-funcion), sin worktree: cada worktree duplicanode_modulesy ocupa disco. Un worktree solo se crea cuando varios agentes escriben al mismo tiempo, o cuando la persona lo indica:git worktree add -b mi-funcion ../absolut-cinema-mi-funcion main, o desde la rama de la que dependa. Dos agentes nunca escriben en la misma carpeta. Así varios agentes trabajan en paralelo y cada rama entra amaincon su merge, sin pisar el trabajo de otro. Si no te dicen que hay más agentes, no hay worktree. El ciclo con worktrees esscripts/wt new,wt integrateywt done. La carpeta principal es un checkout normal (core.bareenfalse):wt integratefalla si no lo es o si tiene cambios, ywt listlo avisa. Nunca muevasmainconbranch -fnireset --hard.wt newenlazadata/y.venvde la carpeta principal e instalanode_modules: en el worktree correnmake dashboardymake dashboard-dev.make dashboardapagaprecalc.db(AC_PRECALC=0), así un cambio enanalytics/se ve de inmediato. Al terminar, se borran el worktree y la rama. - Un cambio pequeño se integra solo. Un cambio de pocas líneas y sin decisión abierta, como una constante, un texto
o un dato de configuración, no espera a que se pida el commit. Se hace en su rama (o en su worktree, si hay más agentes), se prueba con la compuerta que
le toca (
make checkopnpm run build), se hace merge amainy se borran el worktree y la rama. Si la prueba falla, no hay merge.
