# slideshow-intel

Motor de research de cuentas TikTok *slideshow-first*, operado desde Claude Code (u otro
cliente MCP) o desde la CLI. Herramienta personal; réplica del núcleo de Scroll Show
(ver `docs/LOGICA_SCROLLSHOW.md`, `docs/SPEC_MVP.md`).

- Node 22 + TypeScript. **`patchright`** (fork drop-in de Playwright que cierra la fuga de CDP
  `Runtime.enable`) controlando **tu Chrome real** con un perfil dedicado
  (`%LOCALAPPDATA%\slideshow-intel\chrome-profile`), siempre con ventana. Sin stealth JS: en un
  navegador real la coherencia gana al spoofing (ver `docs/GUIA_INDETECTABILIDAD.md`).
- **Coherencia por país**: por defecto hereda el locale/timezone del SO del usuario (coherente con su
  IP/región, para cualquier país). Override con `SLI_LOCALE` / `SLI_TZ` solo si hace falta.
- Intercepta los XHR de TikTok (`/api/search/`, `/api/post/item_list/`, `/api/user/detail/`…),
  calcula métricas (views/follower, slideshow %, cadence, consistency 0-10…), guarda todo en
  SQLite (`node:sqlite`, sin deps nativas) y descarga slides + `metadata.json`.
- MCP server Streamable HTTP en `127.0.0.1:43121/mcp` con Bearer token
  (`%LOCALAPPDATA%\slideshow-intel\mcp-token.txt`).

## Instalación (usuario final) — Windows / macOS / Linux

Requiere **Node ≥ 22.13** y **Google Chrome** instalado. Cross-platform vía npm (sin instalador
por-OS, sin firma/notarización):

```bash
npm i -g @dropugc/discover        # o:  npx @dropugc/discover setup
sli setup                       # registra el MCP en Claude Code + imprime los próximos pasos
sli login                       # inicia sesión en TikTok una vez (Chrome dedicado)
sli link <token>                # (opcional) conecta a DropUGC → Discover para ver los resultados en la web
sli sync [runId] [--all]        # (opcional) re-envía un run pasado a DropUGC (los runs se sincronizan solos al terminar o detenerse)
```
Luego, en Claude Code: *"búscame cuentas ganadoras de slideshows/videos en mi nicho"*. El daemon
arranca solo. `SLI_CHROME=<ruta>` si Chrome no está en la ruta estándar.

## Uso rápido (desarrollo)

```bash
npm install
npm run sli -- doctor            # Chrome, data dir, sesión TikTok, librería
npm run sli -- login             # abre el Chrome dedicado en tiktok.com; inicia sesión ahí una vez
npm run sli -- discover "glow up tips" "jawline exercises" -t 5 -f slideshow   # o -f video / -f any
npm run sli -- analyze hyginmaxxing --days 15
npm run sli -- download -a hyginmaxxing -c 3 --sort views
npm run sli -- download -l https://www.tiktok.com/@user/photo/123456
npm run sli -- library search glow --sort views_per_follower
npm run sli -- runs
```

## Actualizaciones

No hay auto-update silencioso a propósito: un proceso que lanza tu agente no debe cambiar de
código sin que lo sepas. En su lugar:

- **Aviso**: una vez al día la CLI y el daemon consultan el registro npm (3 s máximo, silencioso
  sin red, cache en `update-check.json`). Si hay versión nueva, cada comando `sli` lo dice en una
  línea y `app_status` se lo dice a Claude para que te lo diga. DropUGC añade lo mismo cuando
  estás vinculado. `SLI_UPDATE_CHECK=0` lo desactiva.
- **`sli update`**: para el daemon, corre `npm i -g @dropugc/discover@latest` y te dice cómo
  reconectar (`/mcp` en Claude Code; las sesiones nuevas ya usan la versión nueva). `--check`
  solo informa.
- **Versión mínima (kill switch)**: el dist-tag `min` del paquete en npm. Una instalación por
  debajo de `min` no ejecuta `discover`/`analyze`/`download`/`login` ni ninguna tool MCP hasta
  actualizarse; solo muestra el aviso. Se fija sin publicar nada:
  `npm dist-tag add @dropugc/discover@0.5.1 min`.
- **`sli stop`** ahora para el daemon de verdad (endpoint `/api/shutdown`, con token, loopback).

## Arquitectura: daemon único

Un solo proceso (el **daemon**) es dueño del Chrome. Claude Code y la CLI son **clientes**: le
mandan el trabajo por HTTP en vez de abrir su propio navegador, así que **nunca chocan por el perfil**.
- El daemon sirve `/mcp` (para Claude) y `/api/*` (para la CLI) en `127.0.0.1:43121`.
- Los comandos que tocan el navegador (`discover`, `analyze`, `download`, `login`, `doctor`)
  **levantan el daemon solos** si no está corriendo, y hacen streaming de los logs.
- Los que no tocan el navegador (`library`, `runs`) leen la SQLite directo (seguro con WAL).
- Instancia única: si ya hay un daemon, un segundo `mcp serve` no arranca.
- **Claude Code no habla con el daemon directamente**: lanza `sli mcp stdio`, un proxy de stdio
  que arranca el daemon si no está y reenvía cada mensaje JSON-RPC a `/mcp`. Así el registro en
  Claude es un comando (lo que todo cliente MCP local sabe lanzar), no una URL que después de un
  reinicio no responde. Si el daemon muere a mitad de sesión, el proxy lo levanta una vez y
  reintenta; si no puede, devuelve un error JSON-RPC en vez de colgarse. El token Bearer nunca
  sale del data dir: lo lee el proxy.

## Desde Claude Code

```bash
npm run sli -- setup              # una vez: registra `sli mcp stdio` en Claude Code (scope user)
# no hay nada que dejar corriendo. Para ver el daemon en primer plano (logs), opcional:
npm run sli -- mcp serve
# clientes que solo aceptan URL (raro): `sli mcp install --http`, y entonces sí mantén `mcp serve` vivo
```

Luego, en cualquier sesión de Claude Code: *"tengo una app de X, búscame 5 cuentas de slideshows
con ≥100k views en 30 días y dime qué formato usan"*. Tools: `app_status`, `start_discovery`,
`analyze_account`, `start_download`, `job_status`, `wait_job`, `job_results`, `resume_job`, `stop_job`,
`list_runs`, `sync_run`, `save_insight`, `search_accounts`, `get_account`, `judge_candidates`.

### Discovery con objetivo (0.2.0): relevancia + checkpoints de revisión

El score de discovery era 100 % rendimiento (views/follower, save rate, momentum…) sin noción de lo que
buscas: un creator de lifestyle con un post patrocinado que TikTok asoció al keyword "ganaba". Desde
0.2.0 el motor es **objective-aware** en cada punto donde gasta presupuesto:

- `start_discovery(objective, include_terms, exclude_terms, visual_cues, min_on_topic_pct=20, review=true)`.
  Claude escribe una **rúbrica** una vez (frases multi-palabra: `"ai photo"`, `"photo edit"`, `"epik"` —
  nunca un genérico suelto como `"ai"`); el daemon la aplica determinísticamente.
- **Gate del candidato** (antes de gastar ~24 s midiendo): con el caption/hashtags/bio del hit de búsqueda,
  los autores claramente off-topic **no se miden** (`skipped_off_topic` en el resultado).
- **Relevancia de la cuenta medida**: `on_topic_pct` (% de posts recientes que matchean) y `on_topic_views_pct`
  → veredicto automático `on_topic | adjacent | off_topic`. `adjacent` = pocos posts on-topic pero cargan
  las views (creator con posts patrocinados que sí ganan en el nicho); `off_topic` → rechazada.
- Solo los **on_topic** siembran sonidos/hashtags/sugeridas y el PageRank de la librería (adiós al
  compounding de basura). Los keywords se buscan intercalados (1 cada 2 mediciones) hasta agotarlos.
- **Checkpoint de revisión** (`review=true`, cada 4 PASS + al final): `wait_job` devuelve `state: "review"`
  con una tarjeta por candidata (bio, top captions, hashtags, ≤3 **covers como imágenes**). Claude responde
  `judge_candidates(job_id, batch_id, verdicts)`; `off_topic` **des-pasa** la cuenta, la saca de los seeds,
  retira los arms que sembró y **libera el slot** (el target vuelve a N-1 y el presupuesto de medición se
  refunda con el coste medio de un ganador) → el run sigue buscando. Sin respuesta en `review_timeout_s`
  (180 s) sigue con los veredictos automáticos. La CLI (`sli discover --objective --include --exclude`) no
  tiene reviewer: solo veredictos automáticos.
- Los frames de video NO se analizan en discovery (caro, detectable, rara vez cambia el veredicto que ya dan
  cover + caption + bio); eso sigue en `start_download` para los finalistas.

**0.3 — Claude en cada decisión (protocolo de decisión)**: el job ya no se limita a un checkpoint final. Cede en
cada punto donde se gasta presupuesto y `wait_job` devuelve `state: "decision"` con un brief:
`triage` (tras cada búsqueda: autores con caption/hashtags/bio + cover para los dudosos → `triage_candidates`:
probe|skip + `new_queries`/`retire_queries`), `verdict` (tras **sondas baratas** de ~8 s: bio, captions recientes
marcadas `[on-topic]`, 2 covers reducidas, métricas aproximadas → `judge_probes`: measure|reject + relevancia) y
`review` (raro: PASS con relevancia solo automática → `judge_candidates`). Solo lo aprobado recibe la medición
profunda (~23 s). Las decisiones van en cola (una pendiente a la vez) y **no bloquean las manos**: el daemon sigue
sondeando lo ya aprobado mientras Claude decide; sin respuesta en `review_timeout_s` deciden las heurísticas.
`peek_covers(usernames)` sirve covers bajo demanda cuando el texto no basta. Los skips/verdicts de Claude se
recuerdan por colección. Ver `docs/ALGORITMO_IDEAL.md`.

**0.3 fase 2 — LEARN / EXPAND / BUDGET con Claude (fuentes gateadas al nacer, recompensa diferida)**:
- **Recompensa de cada fuente** (query / hashtag / sonido): el pull ya no se premia por "autores nuevos" sino por
  lo que esos autores **llegan a ganar** después: probe aprobado (+0.1), verdict `measure` (+0.3), PASS (+0.6; se
  devuelve si se des-pasa). El bandit UCB reparte las búsquedas entre las fuentes vivas con esa recompensa; cada
  candidata recuerda qué arm la sacó. `job_results` muestra por fuente `surfaced → probed → measured → passed`.
- **`learn`** (tras cada 2 PASS, o antes si la frontera se queda corta): una tarjeta por ganador (métricas, captions,
  2 covers, **sus sonidos y hashtags con cuántas cuentas del run los comparten y su lift — evidencia, no veredicto**,
  y las cuentas sugeridas del perfil) → `approve_expansion`: `why_it_wins` por ganador (queda en resultados y
  dashboard), `follow_sounds` / `follow_hashtags` (se vuelven arms del bandit) y `follow_suggested` (true | lista →
  pasan a la frontera y se sondean). **Nada se sigue sin aprobación**; ganador omitido o timeout → heurística de
  lift (la de 0.2.0, solo on_topic). Además `add_include` / `add_exclude` **evolucionan la rúbrica** en caliente
  (versión +1; los gates siguientes usan la nueva) y `new_queries` / `retire_queries`.
- **`budget`** (cada 4 búsquedas, y **al agotarse el presupuesto con PASS < target** — antes el run moría en
  silencio): tabla de rendimiento por fuente + presupuesto restante + eventos recientes → `steer_budget`:
  `continue` | `expand` (+`extra_searches`, default 4; las sondas y mediciones crecen en proporción; con
  `new_queries`) | `narrow` (`retire_queries`) | `stop` (termina y puntúa). Máximo 5 decisiones de budget por run;
  las notas quedan en `steering` del resultado.
- Fuentes: las queries nuevas de Claude y los arms que aprueba en `learn` nacen "sin pull" (el bandit los busca pronto,
  una vez); el gemelo `photo` de cada keyword nace con prior bajo (compite por UCB) salvo en runs `format: slideshow`,
  donde sigue siendo primario. `retire_queries` retira ambas pestañas. Tras `stop` no se abren más decisiones: las
  sondas pendientes se cierran como "stopped before verdict" y no se expande nada.
- **Memoria por colección con desenlace**: además del veredicto (on/adjacent/off) se recuerda si la cuenta **pasó** en
  algún run de la colección (ganador conocido → se mide sin volver a preguntar) o fue **rechazada** y por qué (se muestra
  en el triage como "rejected last time (razón) — probe only if it may have grown"; sin cliente no se vuelve a sondear).
  Triage admite `ignore` (on-topic pero no vale la pena ahora: no se recuerda nada, a diferencia de `skip` = off-topic).
  El brief de triage muestra como máximo 24 autores (primero los dudosos por prior; una página de sonido lista ~100
  usuarios al azar): el resto se resuelve sin preguntar (los seguros ya están en la frontera; los dudosos se descartan).
  `retire_queries` también acepta `sound|<id>`, `ht|<tag>` o `#tag` para retirar una fuente de expansión.
- CLI / sin cliente: no hay learn ni budget; expansión por lift como siempre.

**Afinos de tiempo (0.3.0)**: una visita tiene un suelo anti-detección (nav + 2.5–4 s + 2–3.5 s por scroll) que no se
toca; lo que se recorta es el TRABAJO: (1) toda medición **para de hacer scroll en cuanto la ventana de métricas está
cubierta** (≥3 posts más antiguos que su inicio) — la sonda salta su scroll y la profunda baja de ~23 s a 5–10 s o a 0 s
(si la primera página de la sonda ya cubría la ventana no se re-visita: se reutilizan sus posts, sonidos y sugeridas);
(2) ganadores conocidos de la colección usan la medición de la librería hasta 7 días (24 h para el resto) → 0 s;
(3) duraciones en los logs (`probe — … (7.1 s, covers the window)`, `revisited — 2 scroll(s) (11.2 s)`). Medido con
`scripts/mcp-autopilot.mjs` (reviewer de latencia 0): target 5 en nicho nuevo = 7.7 min, 14/18 profundas gratis; el
coste dominante pasa a ser la sonda (~7 s × N) — reducir N es el trabajo del triage de Claude y de la memoria.
`postsPerWeek` se calcula sobre el tramo realmente visto (≤90 d), no sobre 90 días fijos.

**0.3 fase 3 — cerebro del nicho + calibración + desatendido**: todo lo que Claude decide se vuelve memoria de la
**colección** (`collection` de `start_discovery` / `--collection`), persistida en SQLite (`collection_brain`,
`collection_sources`, `collection_calibration`, `run_accounts.why_it_wins`):
- **Cerebro**: objetivo + rúbrica tal como Claude la dejó (v0 al crearla, +1 por cada evolución; la numeración continúa
  entre runs), fuentes (queries, sonidos, hashtags) con rendimiento ACUMULADO `surfaced→probed→measured→passed`, quién
  las creó (user/claude/heuristic/brain) y cuáles retiró Claude, ganadores conocidos con su `why_it_wins`, cuentas
  juzgadas, lista de "dudosos". Tool **`collection_brain(collection?)`** (sin argumento lista las colecciones) y CLI
  `sli brain [collection]`.
- **FRAME**: un run de una colección conocida arranca desde su cerebro: la rúbrica que pasa Claude se **une** a la del
  cerebro (o se usa la del cerebro si no pasa ninguna), el objetivo por defecto es el del cerebro, las queries/arms
  productivas vuelven como arms con su rendimiento histórico como prior (compiten, no saltan la cola), las retiradas
  no vuelven solas (si el user las pasa explícitamente como keyword, se reactivan y se avisa), los ganadores conocidos
  se miden sin preguntar. El prompt `/discover-niche` llama a `collection_brain` antes de `start_discovery`.
- **Calibración**: el daemon cuenta, por colección, cuándo cada regla automática coincide con Claude — `gate` (rúbrica
  en triage vs probe/skip), `auto_verdict` (relevancia automática vs la de Claude en verdict/review) y `auto_probe`
  (measure/reject automático vs Claude). Se muestra en los briefs ("el gate acierta 78 % aquí, n=40") y en el cerebro.
- **Desatendido** (CLI / `review=false`): usa el cerebro (rúbrica, queries productivas, memoria de cuentas). Una regla
  actúa sola solo si está calibrada ≥80 % con n≥10 (sin votos aún, actúa — no hay nada mejor); si está calibrada por
  debajo, lo que ella descartaría queda como **"undecided"** (no se sonda / se rechaza marcado, sin memoria) y sale en
  `result.pending` y en el cerebro para que el próximo run con Claude lo resuelva.

- `wait_job(job_id, timeout_s≤120)` hace long-poll: Claude no necesita dormir ni sondear desde una shell.
- `start_discovery(..., collection: "Pushup apps")` agrupa el run en una **colección** (nicho/proyecto)
  del dashboard de DropUGC. Los runs de una colección comparten cuentas, top posts e insights.
- `save_insight(collection, title, body_md)`: Claude guarda su análisis ("por qué gana el formato") como
  nota markdown en la colección — la investigación sobrevive al chat.

Sync a DropUGC: cada run de discovery se envía solo al terminar **o al detenerse** (`stop_job` /
`sli stop`): el run detenido se puntúa igual con lo medido, se guarda en la librería (`runs.result`)
y se sincroniza. El payload lleva, por cuenta ganadora, sus **top 5 posts** (views, caption, sonido,
link) y un **thumbnail** chico del cover subido a DropUGC (best-effort; el mp4/frames nunca salen de tu
máquina). Si el push falla (sin internet, DropUGC caído) queda en el **outbox** (`runs.synced_at IS NULL`)
y el daemon lo reintenta al arrancar y cada 10 min (`SLI_SYNC_RETRY_MIN`); `job_results` muestra el
estado del sync. `sync_run` / `sli sync [runId] [--all]` re-envían runs pasados (idempotente).
Runs que quedaron `running` por un daemon muerto se marcan `error` al arrancar.
Nota: discovery mide a propósito más allá del target (buffer ×1.5 para la selección diversa) —
llegar al target no es motivo para detenerlo.

## Captcha / verificación

Si TikTok muestra una verificación en la ventana de Chrome, el job pasa a `paused`; resuélvela a
mano y se reanuda solo (o `resume_job`).

## Variables

`SLI_HOME` (data dir), `SLI_CHROME` (ruta a chrome.exe), `SLI_LOCALE` (default `en-US`),
`SLI_MCP_PORT` (default 43121).

## Estructura

```
src/browser    launch de Chrome, sesión, captcha, pacing
src/tiktok     parsers de JSON, captura XHR, perfil, búsqueda
src/metrics    fórmulas
src/library    SQLite
src/jobs       job manager (running/paused/done, partial en disco)
src/core       discovery / analyze / download / engine
src/mcp        servidor MCP
src/cli        comandos
tests/         unit tests (npm test)
```
