AGENTS.md · git:20260922.e6a2574 · 2026-09-22 · sha256 870fab310202d24c

AGENTS.md git:20260922.e6a2574B

Immutable. This exact content is served forever at /api/v1/blob/870fab310202d24c.

# cambio-uruguay — AGENTS

Root map of a multi-package monorepo behind [cambio-uruguay.com](https://cambio-uruguay.com): a TypeScript Express API + casa-de-cambio/currency scrapers (this dir) plus a separate Nuxt frontend (`app/`), an MCP server (`mcp/`), and social bots (`bots/`). Operate only inside this worktree.

## Two build surfaces (own package.json, own deploy, DIFFERENT MongoDBs)
- **Root** = Express API + scrapers + sync jobs. `name:"api"`, TS4.9, CommonJS. Dev: `npm run dev` (`ts-node index.ts`). Build: `npm run build` (`tsc -p tsconfig.production.json` → `dist/`). Prod: pm2 via `ecosystem.config.js`. Mongo DB `cambio-uy` (`config.ts`), host A.
- **`app/`** = Nuxt 4 (`compatibilityVersion: 4`) / Vuetify 4.1.5 frontend. `name:"app"` v2.0.0, ESM, TS5.7. Own `package.json`, own build (`nuxt build`), own deploy (`app/scripts/deploy.sh`). Uses a DIFFERENT Mongo (`APP_MONGO_URI`, host B). **`classes/appdb.ts` is the only bridge from root → app DB.**
- **`mcp/`**, **`bots/`** = own `package.json` + `npm run build`; build separately before their pm2 apps start.
- **Rule: never mutate `app/` mid-build.** Root `tsconfig.json` excludes `app`, `dist`, `mcp`, `bots`, `scripts/oneoff`.

## pm2 jobs (`ecosystem.config.js` → script → cron UTC)
| app | script | cron | notes |
|---|---|---|---|
| currency-server | dist/index.js | — | **cluster ×2**, the API. NO scheduled work here (see below) |
| currency-sync | dist/sync.js | */5 * * * * | scrape all casas (`classes/sync_cambio.ts`). Dos guardas de plausibilidad, y son distintas a propósito: `rate_plausibility.ts` rechaza por fila lo que no puede ser un precio (**compra > venta**: una casa compra barato y vende caro) al momento de escribir, y `rate_audit.ts` corre al final del ciclo con TODAS las casas ya escritas, que es lo único que ve el caso espejo — una coma perdida del lado de la VENTA es coherente por fila y absurda al lado de las otras 40 casas. La banda es por percentiles del propio grupo (moneda+tipo) porque un factor fijo o borra el peso argentino (spreads reales de 10×) o deja pasar cualquier cosa en el dólar; fuera de p10/30–p90×30 se borra, fuera de p10/3–p90×3 sólo avisa. Telegram una vez por día por cotización. **Y una tercera, que mira otro eje**: `rate_staleness.ts` compara a cada casa contra SU PROPIO PASADO, porque las dos anteriores miran esta corrida y ninguna ve al origen que publica el mismo número desde hace dos meses — escribe fila fresca, con compra menor que venta y dentro de la banda, o sea verde en las tres señales de `/estado`. Medido: 17 de 43 pizarras del USD llevan ≥7 días quietas. NO BORRA NADA (una pizarra quieta puede ser un precio real); escribe `last_frozen_quotes.json`, lo sirve `GET /frozen-quotes` y `/estado` lo pinta como estado `frozen`. El daño no es pasivo: el mercado se mueve y la pizarra no, así que deriva al extremo de la distribución, y como el sitio ordena por "más barato" la sube al titular — por eso el veredicto trae `extreme` y sube a grave sin esperar los 30 días |
| currency-sheet | dist/sync_sheet.js | — | long-running Google-Sheet sync |
| currency-aduana | dist/sync_aduana.js | 30 9 * * 1 | Mondays; Reddit+Gemini customs corpus |
| currency-aduana-daily | dist/sync_aduana_daily.js | 40 9 * * * | self-gates to 2026-09-01..11-01 decree window |
| currency-banks-news | dist/sync_banks_news.js | 37 10 * * * | heaviest Gemini job (~36 calls) |
| currency-figures | dist/sync_figures.js | 52 9 * * * | UY figures (SMN/BPC/boleto) |
| currency-costs | dist/sync_costs.js | 43 9 * * * | cost-of-living figures |
| currency-loans | dist/sync_loans.js | 47 8 * * * | lender TEA refresh |
| currency-predictions | dist/sync_predictions.js | 23 9 * * * | writes APP DB `pricepredictions`; needs `APP_MONGO_URI` |
| currency-explain | dist/sync_explain.js | 7 10 * * * | writes APP DB `moveexplanations` |
| currency-bankos | dist/sync_bankos.js | 33 8 * * * | snapshots the whole Bankos discount map (reverse-engineered `com.anonymous.bankos`) into APP DB `bankossnapshots` (one upserted row) — the outage fallback for the app's `/api/bankos/discounts` (live-first) behind `/descuentos-con-tarjeta-uruguay`; needs `APP_MONGO_URI`; refuses to overwrite a good snapshot with a thin pull |
| currency-debt-relief | dist/sync_debt_relief.js | 13 10 1 * * | monthly; BCU usury caps |
| currency-bcu-rates | dist/sync_bcu_rates.js | 27 9 * * * | la grilla de tasas medias/topes del BCU (Ley 18.212) para `/adelanto-de-efectivo-tarjeta-de-credito`. **Diario a propósito**: el BCU republica TODOS LOS MESES (ventana trimestral móvil), la tabla rige desde el día 1 y el comunicado sale un día impredecible del mes anterior. Baja y parsea el **PDF oficial** (`unpdf`) — NO usa Gemini: la búsqueda groundeada devolvía la edición de 2022 archivada en la misma URL. Un candidato sólo se almacena si sus seis filas cumplen la aritmética de la ley (tope = media × 1,55, mora × 1,80) y ninguna media se movió más de 50 %; si falla, se conserva el snapshot anterior. Las filas en dólares se arrastran, no se parsean (ver `parse.ts`). bcu.gub.uy no manda las intermedias TLS: van embebidas en `classes/bcurates/certs.ts` |
| currency-temas-analysis | dist/sync_temas_analysis.js | 17 11 * * * | reads app DB, writes backend `temas_analysis_data`; self-gates 90d; needs `APP_MONGO_URI` |
| currency-site-analytics | dist/sync_site_analytics.js | 51 10 * * * | GA4 Data API → APP DB `siteanalyticssnapshots` for /estadisticas-del-sitio; needs `GA4_PROPERTY_ID` + SA (docs/analytics/GA4_DATA_API.md). The page's LIVE block is separate: on-demand `GET /site-analytics-realtime` on currency-server (Redis 45s), so the API process needs the same GA4 env. **Además escribe `siterevenuesnapshots`** (ingreso publicitario por familia de página, vía el enlace AdSense↔GA4 creado 2026-09-02): colección APARTE y ruta APARTE (`/api/site-revenue`, `requireAdmin`) porque el documento de al lado se publica entero en una página pública y un `.select()` mal escrito publicaría cuánto factura el sitio. `tests/site_analytics/revenue_privacy.test.ts` lo vigila. La lectura de ingresos va en su propio try: si el enlace se cae, el snapshot público se actualiza igual. **Y un cuarto reporte, sólo Uruguay** (`totalsUy`, mismas métricas y ventana que el total, `dimensionFilter` por `countryId = UY` — el primer filtro de dimensión del repo, `exactDimension()` en `ga4.ts`): el RPM del sitio divide la plata entre TODAS las vistas, incluidas las automatizadas que no cargan ni un anuncio (lectura del 21/9), y el mismo cociente sobre la audiencia real es la cifra que ese tráfico no mueve. Diagnóstico y aditivo: no decide `pending` ni `revenueWouldRegress`, y no se bloquea ningún país por suposición |
| currency-gsc | dist/sync_gsc.js | 20 11 * * * | la mitad que faltaba del bucle de tráfico: qué **busca** la gente antes de llegar. Search Analytics API → APP DB `searchconsoledays` (un doc compacto por día: **es el archivo**, Google borra todo lo que pasa de 16 meses) + `searchconsolesnapshots` (el tablero calculado, con la lista de oportunidades). Lo lee la página **privada** `/estadisticas-de-busqueda` (`requireAdmin`, noindex, fuera del sitemap): las consultas son el activo competitivo del sitio y no se publican. Los totales se piden SIN dimensiones porque sumar desgloses no reproduce la pantalla oficial (por consulta faltan un tercio de las impresiones; por página la posición da 9,16 donde Google dice 8,37). El pozo **cero-clic** (43 % de las impresiones, 44 clics) se identifica primero y se excluye de la curva y de toda oportunidad, y la **curva de CTR es la del propio sitio** (posición 3 = 0,9 %, no el 10 % de las tablas de la industria). `--backfill=N` llena el archivo hacia atrás. Ver `docs/analytics/SEARCH_CONSOLE_API.md`. **Y escribe la lista blanca de indexación de las fichas de alquiler** (`classes/gsc/indexAllowlist.ts` → APP DB `seoindexallowlists`, un documento `family: 'alquileres'`): una consulta más por `page` con filtro `contains /alquileres/`, ventana de 56 días, y de ahí las rutas con ≥5 impresiones. El app (`app/utils/rentalIndexHygiene.ts`) pasa a `noindex, follow` y saca del sitemap a la ficha con ≥56 días de `firstSeen` que no esté en la lista; el hub y las fichas jóvenes no se tocan. Medido el 22/9/2026: 9.647 fichas en el sitemap y ~40 con impresiones. **La ausencia nunca es un veredicto**: cero filas, error, respuesta al tope de filas o lista 70 % más chica que la guardada no escriben nada, y del lado del app un documento ausente, incompleto o de más de 14 días vale "sin cambio". Sin fila en `experiments.json`: se mide por cobertura en Search Console, no por clics. Ver `docs/app/RENTALS.md`, "Higiene del índice" |
| currency-search-demand | dist/sync_search_demand.js | 40 6 * * 0 | la cola de **qué escribir**, que es lo único que Search Console no puede ver: sólo lista consultas donde el sitio YA aparece. Cosecha el autocompletado uruguayo (`suggestqueries`, `gl=uy`, sin clave ni cuota) cruzando 8 prefijos de pregunta con las palabras de `classes/gaps/topics.ts`, **filtra por país** —el filtro que más descarta y el que no estaba en el diseño: `gl=uy` no filtra nada (la primera cosecha real volvió 381 sugerencias, 99 % "en tema" y casi todas de México, Venezuela o España), así que `isUruguayan()` exige una marca local; quedaron 83—, descarta lo que no es de las temáticas del sitio, mide la cobertura contra el índice RAG propio (`rankWithVector` con vector `null` = sólo la mitad léxica, cero embeddings gastados) y recién ahí gasta la etapa cara: mirar el SERP de los 25 mejores por `google_search_server` (:5112). El filtro que decide salió de escribir dos páginas a mano: **no** se entra donde hay caja de respuesta (el clic no existe) ni donde mandan las granjas de calculadoras (7 dominios dedicados en "cuánto me descuentan del sueldo", y acá las calculadoras no llevan anuncios); **sí** se entra donde el SERP es institucional e ilegible o hay foros/redes arriba. Un candidato que no llegó al presupuesto de SERP puntúa como "dudoso", no como cero: si no, la cola escondería justo lo que nadie miró. Escribe UN documento en APP DB `searchdemandqueues` y lo lee la página **privada** `/estadisticas-de-busqueda`. **NO PUBLICA NADA**: la cola es para que una persona la lea y decida. Semanal a propósito (el autocompletado se mueve en semanas) y domingo 06:40 UTC, después de que rag-index (04:20) dejó fresco el índice contra el que se mide la cobertura. Necesita `APP_MONGO_URI` |
| currency-revenue-plan | dist/sync_revenue_plan.js | 50 11 * * * | **el cruce que faltaba entre los dos tableros privados.** `currency-gsc` ordena el trabajo por CLICS potenciales y `currency-site-analytics` mide el RPM por FAMILIA de página — con el mismo `bucketOf`, a propósito, "para que las dos tablas se puedan cruzar fila a fila". Nadie las cruzaba, y entre familias hay un factor de TRESCIENTOS (GA4 3–15/9/2026; los montos sólo en `docs/seo/data/`, gitignored — este repo es público): una cola ordenada por clics manda a trabajar justo donde el clic no paga. Le pone precio a cada oportunidad según la familia de la URL que recibiría el clic (`attachPages` en `classes/gsc/opportunities.ts` le pega esa URL a las oportunidades por consulta, que no la traían) y reordena. El precio es el RPM MEDIDO de la familia si tiene muestra (≥500 vistas y ≥100 impresiones); si no, el RPM del sitio × el multiplicador de su tramo — un multiplicador y no un RPM absoluto porque la cifra absoluta envejece y la forma no, y el tramo se AUDITA solo publicando al lado el multiplicador medido. Lo que ordena es `clics × multiplicador`, que sigue existiendo cuando el ingreso medido es cero (el enlace AdSense↔GA4 es del 2026-09-02 y el ingreso diario era de centavos). **Y mide el libro de cambios** (`docs/seo/experiments.json`, declarado en el mismo commit que publica el cambio): cada cambio, 28 días después, como PORCIÓN de los clics del sitio y no en clics absolutos — entre marzo y agosto de 2026 las impresiones se multiplicaron por 6, así que un antes/después crudo declara ganador hasta a no tocar nada. La ventana cierra contra el ARCHIVO y no contra el calendario, el día del deploy no entra en ninguna punta, y un cambio que toca TODO el sitio no se puede declarar (sin control, el sujeto es el denominador). **Publica además `siteRpmUy`** (el RPM sólo sobre visitas uruguayas, de `totalsUy`), la tarjeta «RPM sólo Uruguay» del tablero: NUNCA ancla un multiplicador ni ordena la cola — va al lado del RPM del sitio, y cuando lo supera por ≥ `UY_RPM_DIVERGENCE` (1,5×) con muestra de familia (≥500 vistas, ≥100 impresiones) sale la alerta `views-without-impressions`, que avisa y no filtra. **NO PUBLICA NADA** y no sale a ninguna API: lee los snapshots que dejaron los otros dos. Necesita `APP_MONGO_URI`. Ver `docs/analytics/REVENUE_PLAN.md` |
| currency-store-profiles | scripts/run-store-profiles.sh → dist/sync_store_profiles.js | 17 7 * * 0 | `/tiendas-online-uruguay`: perfil semanal de las 76 tiendas curadas (`classes/stores/registry.ts`) con seis señales fechadas — sitio propio, antigüedad de dominio (crt.sh/Wayback), Trustpilot (:3029), Google Maps (:2221, sólo si el sitio del listado ES el dominio propio), menciones en r/uruguay y r/montevideo (Arctic Shift) y presencia en los catálogos propios (equipar/sillas) — nunca un veredicto de confianza ("confiable"/"estafa"). Cada tienda se guarda apenas se lee y sólo si alguna fuente externa contestó, así un backfill de Reddit de horas nunca pierde lo hecho; si las primeras 10 tiendas no obtuvieron ninguna respuesta nueva, las fuentes están caídas y corta sin escribir, saliendo con 1. Una fuente caída conserva el valor anterior CON SU FECHA VIEJA (`undefined`); sólo una respuesta explícita de "no hay nada" lo limpia (`null`). `STORE_SIGNAL_MAX_AGE_DAYS=60` (espejado en `app/utils/storeProfiles.ts`, paridad en `app/tests/unit/storeConstantsParity.test.ts`) hace que una señal vieja deje sola de contar para `indexable` — recalculado en cada lectura, nunca confiado tal cual está guardado. Reddit se lee incremental por ventanas con cursor por tienda (`STORES_REDDIT_MAX_CALLS`, 900 llamadas/corrida por defecto); el backfill de 24 meses de una tienda tarda semanas a este ritmo — ver `currency-store-reddit` abajo. Comparte con esa corrida el flock de `scripts/run-store-profiles.sh` (`STORES_LOCK_FILE`): esta corrida ESPERA (hasta `STORES_FULL_LOCK_WAIT_SECONDS`, 2h) en vez de competir o cancelarse. Necesita `APP_MONGO_URI`. Ver `docs/app/TIENDAS_ONLINE.md` |
| currency-store-reddit | scripts/run-store-profiles.sh --reddit-only | 41 3 * * * | nocturno, sólo Reddit para `/tiendas-online-uruguay`: el backfill de 24 meses de una tienda mide ~90 llamadas a Arctic Shift, así que a 900/semana (76 tiendas) el semanal solo tardaría ~8 semanas en completarlo; de noche el tope de reloj de pared (150 min) corta antes de agotar esas 900 llamadas — a ~540 por noche (medido, ver `docs/app/TIENDAS_ONLINE.md`) termina en ~13 noches, no ~8. Site/age/trustpilot/google y el catálogo propio NUNCA se consultan en este modo (ni siquiera se llaman — Google Places cobra por llamada y las demás no cambian de un día al otro), así que quedan exactamente con el valor de la semana. Sólo procesa tiendas cuyo backfill no terminó (`needsRedditBackfill`); una tienda sólo se guarda si Reddit avanzó de verdad esta noche (mención nueva o cursor movido), nunca por una llamada que sólo reconfirma lo ya leído. Tope de reloj de pared propio (`STORES_REDDIT_MAX_MINUTES`, 150 min por defecto), independiente del presupuesto de llamadas, para que una noche lenta de reintentos no llegue a pisar la corrida semanal del domingo. Toma el flock de arriba SIN esperar (`flock -n`): si la semanal está corriendo, se saltea y sale con 0. Ver `docs/app/TIENDAS_ONLINE.md` |
| currency-chair-tiers | dist/sync_chair_tiers.js | 31 12 * * 0 | weekly r/CharruaDevs chair tier list → APP DB `chairtiersnapshots` |
| currency-chairs-hourly | dist/sync_chairs.js --fast | 23 * * * * | hourly price-only refresh (ML + Shopify; no LLM, no Reddit, no Fenicio sweep) |
| currency-chairs | dist/sync_chairs.js | 41 11 * * * | daily desk-chair market: ML (:9656) + UY storefronts + FB Marketplace (:9657) → APP DB `chaircatalogproducts` |
| currency-phones-hourly | dist/sync_phones.js --fast | 37 * * * * | sólo precio: 8 búsquedas de MercadoLibre, 6 consultas por tienda, salta las 7 tiendas Fenicio de `PHONE_STORE_KEYS` (una se abusaría cada hora, no una vez al día); mezcla la foto de tiendas (`phonestoresnapshots`) que dejó la diaria para no perder sus bandas 23 h al día. Minuto 37: lejos de `currency-chairs-hourly` (:23), `currency-autos-hourly` (:29) y `currency-equipar-hourly` (:53), todos consumidores del mismo puente `:9656` |
| currency-phones | dist/sync_phones.js | 29 14 * * * | `/celulares-uruguay`: MercadoLibre (`MLU1055` + 22 búsquedas por familia) + 9 tiendas uruguayas (`classes/phones/spec.ts`, `PHONE_STORE_KEYS`) → una fila por modelo (marca+familia+almacenamiento, identidad en `classes/phones/identify.ts`, precisión sobre recall: 240/271 títulos identificados en la muestra medida) con banda de precio por condición, **nunca mezcladas**. Piso/techo absolutos (UYU 2.400–300.000 nuevo, 1.200–210.000 el resto) respaldan a los modelos con muy pocas ofertas contra un bulto/funda mal titulado o un error de moneda; una abstención bimodal (`ambiguousConditions`) se niega a arbitrar entre dos poblaciones de precio parejas fusionadas en una clave; un cribado iterativo de mediana-de-otros saca la peor oferta una por vez. 14:29 UTC deja una hora limpia después de `currency-chairs` (11:41) y `currency-equipar` (12:47), que pegan al mismo puente ML. Escribe APP DB `phonemodels`/`phonemeta`, además `phonestoresnapshots` (sólo la diaria) y `pricewatchoffers` vertical `celulares` (`productKeyFor` reidentifica por `phone:<key>`, porque casi ningún aviso de celular trae `catalogId` de ML). Una corrida flaca (menos del 40 % de lo guardado con banda nueva publicable) no pisa el catálogo anterior. Ver `docs/app/CELULARES.md` |
| currency-equipar / -hourly | dist/sync_equipar.js | 47 12 \* \* \* / 53 \* \* \* \* | `/equipar-casa-uruguay`: qué sale llenar una vivienda vacía. **Reusa el mismo cosechador que las sillas**, ahora extraído a `classes/retail/`: la palabra "silla" estaba compilada en UN punto por adaptador, así que la categoría se inyecta como `CategorySpec` y chairs pasó a ser su primer consumidor (82/82 de sus tests, sin cambios). Una tienda se barre **UNA vez** y cada producto se clasifica contra las 38 categorías: leer un sitemap de 40k URLs cuesta lo mismo para una que para cuarenta. ML y FB son al revés —se buscan por término— así que toman presupuesto, con los planes **intercalados** para que un corte le cueste a cada categoría su cola y no a una categoría todo; el orden es el del registro, que pone heladera/colchón/lavarropas arriba. **Dos regímenes por categoría**, porque no todas pueden afirmar lo mismo: `modelo` publica producto `marca\|modelo` con sus ofertas, `commodity` publica la **banda** p25/mediana/p75 — un juego de ollas se lista con doce títulos y sin modelo, y agruparlos por texto inventaría un producto que no existe. **FB nunca entra al régimen `modelo`** ("heladera funcionando" no identifica nada) y alimenta siempre la banda de usados, que **jamás se promedia con la de nuevo**: son dos mercados y el promedio no describe a ninguno. La guarda central es la **canasta emparejada**, que es la lección de PRECIOS.md: un total BAJA por faltarle ítems, así que si una categoría no tiene banda se publica el total **parcial** y qué falta, en la misma tarjeta. El ahorro de usado exige las dos patas (nuevo ≥8, usado ≥5) o dice que no hay datos. La variante no es opcional: un frigobar y una side-by-side son las dos "heladera", con 5× de varianza, y el tier cuelga de categoría+variante. `RETAIL_STORE_MAX_PDP=900` va **sólo en este job**: el default de 260 truncaría 38 categorías en orden de sitemap y sesgaría toda banda hacia lo que la tienda lista primero. **La moneda nunca se asume**: TYT declaraba `currency_code: "UYU"` en sus 515 productos pero 149 estaban en dólares en la propia vidriera (medido 16/9/2026), así que el adaptador lee la moneda que la tienda renderiza y descarta el producto si el monto no coincide con el precio publicado; un guardarraíl genérico (mediana de tienda+categoría vs. mediana de ML, ×20, `classes/retail/unitGuard.ts`) queda como respaldo para la próxima tienda que mienta. El tope de búsquedas por tienda (antes 24 de ~71, la mitad del tier S nunca llegaba) es un parámetro en código, 80 en la diaria y 24 en la horaria —no una env de pm2, que un app ya registrado nunca vuelve a leer—; lo que la horaria no busca lo completa una foto de los avisos de tienda que dejó la diaria (`equiparstoresnapshots`). **Además escribe `equiparlistings`** (una fila por aviso aceptado por la banda, sospechosos marcados y nunca servidos, poda a 30 días): el directorio con filtros de `/equipar-casa-uruguay/productos` y `/productos/<categoria>`, y la lista del lector en `/mi-lista` (localStorage, snapshot por aviso, `?ids=` para refrescar). Cada corrida (diaria y horaria) graba además un punto diario de precio por AVISO, no por producto, en APP DB `pricewatchoffers` (compartida con `currency-chairs`; ver `classes/pricewatch/` y `docs/app/PRICEWATCH.md`), materia prima para que un job futuro distinga un descuento real de un precio de lista inflado. Necesita `APP_MONGO_URI`. Ver `docs/app/EQUIPAR.md` |
| currency-movilidad / -hourly | dist/sync_movilidad.js | 33 15 \* \* \* / 7 \* \* \* \* | `/monopatines-electricos-uruguay` + `/bicicletas-electricas-uruguay`: cuánto sale un monopatín o una bicicleta eléctrica en Uruguay, con la normativa departamental al lado. **Segundo consumidor del registro inyectable de equipar** (`classes/movilidad/registry.ts`, dos categorías: `monopatin-electrico` régimen `modelo`, `bicicleta-electrica` régimen `commodity`), no un fork — `buildEquiparCatalog`/`categoryFor`/`specsFor`/`uncoveredCategories` toman un `registry` opcional que por defecto es `EQUIPAR_CATEGORIES`, así que reutiliza sin copiar los dos regímenes, las bandas, la separación nuevo/usado, la guarda de unidad y la mecánica de foto de tienda. `classes/equipar/basket.ts` sigue iterando SÓLO `EQUIPAR_CATEGORIES`, nunca un registro inyectado, así que ninguna de las dos categorías puede alcanzar la canasta de `/equipar-casa-uruguay`. **Nunca incluye un vehículo sin motor** (el `include` exige "eléctric[oa]"/"e-" en el propio título) y excluye motos eléctricas (las líneas "e-Yumbo" se venden como "Moto Eléctrica"), triciclos/cuatriciclos eléctricos (otro vehículo, con su propio decreto en San José) y kits de conversión — la exclusión de conversión es la palabra desnuda `conversion`/`convertir`, no una frase con conector, después de que "Kit Conversión Bicicleta Eléctrica 36v 350w" (sin "de") esquivara la frase original. Cinco tiendas en `MOVILIDAD_STORE_KEYS` (`delcar`, `superbikers`, `covercompany`, `voltbike`, `loopbikes`); las dos últimas sólo miden productos reales con `RetailStore.productTypeInTitle` activo (Shopify, opt-in): sus e-bikes/e-scooters reales van tituladas por marca/modelo puro ("SuperVolt") y el tipo de producto sólo vive en el `product_type` propio de Shopify, así que el adaptador lo antepone al título antes de clasificar. Presupuesto propio, mucho más chico que el de equipar (16/6 búsquedas de ML diaria/horaria, 6/2 de Facebook, 16/6 por tienda) y horarios elegidos para no chocar con los demás consumidores del mismo puente de MercadoLibre: sillas horaria `:23`, autos horaria `:29` y diaria `07:43`, celulares horaria `:37` y diaria `14:29`, alquileres horaria `:47`, equipar horaria `:53` y diaria `12:47`. **Un minuto horario está ocupado en TODAS las horas**: la diaria a las 15:47 se habría cruzado con la de alquileres una vez por día, así que va a `15:33` y la horaria al `:07`. `--dry-run` funciona incluso sin `APP_MONGO_URI`/`MONGO_URI` configurada. Una corrida flaca no pisa una buena (menos del 40 % de lo guardado con precio). APP DB `movilidaditems`/`movilidadmeta` (espejo en `app/`) y `movilidadstoresnapshots` (sólo backend, sólo la diaria la escribe). Graba además `pricewatchoffers` vertical `movilidad`. **Y desde el 22/9/2026 `movilidadlistings`**: una fila por aviso aceptado por la banda, que es el directorio con filtros (foto, tipo, condición, marca, vendedor, precio) EMBEBIDO en las dos páginas — no en una ruta nueva, porque el lector llama "el directorio" a estas dos páginas y partir `pages/<x>.vue` en `<x>/index.vue` agregaría dos canonicals que nadie pidió. La fila es la MISMA de `equiparlistings` (`buildEquiparListings` ya tomaba el registro inyectado) y la colección es aparte para que un monopatín no aparezca en el directorio de equipar la casa; la sirve `/api/movilidad/productos`, que comparte cuerpo con `/api/equipar/productos` (`app/server/utils/retailProductos.ts`) para que una faceta mal contada se arregle una sola vez. Toda URL con filtros va `noindex, follow`. Necesita `APP_MONGO_URI`. Ver `docs/app/MOVILIDAD.md` |
| currency-rentals | scripts/run-rentals.sh → dist/sync_rentals.js | 52 4 * * * | Directorio de `/alquileres-uruguay`: ML (:9656, respuesta cruda), InfoCasas (`__NEXT_DATA__`), FB (:9657) y Casasweb (HTML/formulario público, 19 departamentos) -> APP DB `rentallistings`/`rentalmetas`. **El barrio de Facebook sale del TÍTULO** (`classes/rentals/neighborhoods.ts`: diccionario de los 62 barrios INE + nombres anunciados por los otros portales, palabra entera, gana el más largo, genéricos como "Centro"/"Colón" sólo detrás de "en"/"zona"/"barrio", "y <calle>" es esquina, y un nombre de OTRO departamento que el de la tarjeta se rechaza en vez de mudarse; resuelve el 44 % de 3.067 títulos, medido 2026-09-22) porque la tarjeta trae sólo la ciudad y **el pin de la ficha de Marketplace NO es el inmueble**: grilla de ~1 km (saltos de 0,010986° exactos), 4 de 4 muestras de Montevideo a 3–6 km de la esquina que el vendedor escribe, `is_map_eligible: false` — no se publica. El País **importa desde el 2026-09-05, y no por un hallazgo técnico: lo autorizó el operador del portal**; sus [términos](https://inmuebles.elpais.com.uy/terms) siguen prohibiendo la extracción automatizada y su `robots.txt` sigue excluyendo `/api/`, así que el permiso fechado es lo único que sostiene esta fuente y `RENTALS_ELPAIS_ENABLED=0` la devuelve a consulta externa sin desplegar. No se lee su HTML (Cloudflare da 403 a nuestra UA y sólo traía 24 filas) sino los dos endpoints de su propio buscador: `POST /api/chat/init` abre una búsqueda guardada por departamento —**el ancla es el nombre del DEPARTAMENTO, no su capital: anclar Colonia en "Colonia del Sacramento" lo resuelve a ciudad y pasa de 24 avisos a 6**— y `GET /api/chat/<id>/results?page&limit=500` la pagina. **Abrir búsquedas lo limita Cloudflare, no una tasa**: pasan 3-4 `init` desde Node y el siguiente vuelve 403 `Just a moment...` con `ratelimit-remaining` en 7 y sin cabeceras de límite, mientras los `GET` de `results` siguen en 200. **Y lo primero que hay que cambiar es la UA**: desde el VPS, mismo segundo y misma ruta, `CambioUruguayBot/1.0` cobra 403 y una UA de navegador llega a la app (404 del chat inexistente) — con UA de navegador el `init` sale 201 por HTTP plano. Anunciarnos en la UA ahí no nos hace honestos, nos hace invisibles; la identificación se muda a la cabecera `x-cambio-uruguay-bot`, que viaja en todas las peticiones y se puede allowlistear (`RENTALS_EP_USER_AGENT` cambia la UA). Es la ÚNICA fuente del directorio con esta excepción. Si aun así hay desafío, se resuelve con **un navegador de verdad y sin falsificar nada** (`elpais_browser.ts`): puppeteer carga el portal y emite el mismo POST **desde dentro de la página**, con cabecera propia `x-cambio-uruguay-bot` para que el operador nos encuentre en sus registros — 6 seguidas en 201 mientras Node cobraba 403, y 19 de 19 departamentos con caché vacío. Primero HTTP plano (gratis cuando anda), a las 3 negativas el resto va al navegador en **una sola tanda, un solo Chrome**, con cierre en `finally` y presupuesto de 6 min porque un Chrome colgado acá ya causó una caída; `RENTALS_EP_BROWSER=0` lo apaga. Los identificadores **se guardan entre corridas** (`RENTALS_EP_CHATS_FILE`), así que en régimen no se abre ninguna ni se lanza navegador. Los departamentos van **ordenados por volumen** por si entran pocas aperturas: Montevideo/Maldonado/Canelones son el 92 %. Aporta lo que ninguna otra fuente da estructurado: la **garantía como dato** (`rentalGuarantees`, 40 % de los avisos de Montevideo contra el 4-10 % del campo de InfoCasas). Lo que NO se toma: el enriquecimiento por IA, los gastos convertidos, las fechas de importación como publicación, `featureIds` (el `GARAGE` sólo se corrobora en el texto 68 de 81 veces) y **ningún dato de contacto** — cada fila trae teléfono y correo de la inmobiliaria y sólo cruza su nombre público. Para unir avisos diferentes se exige siempre dirección exacta y el mismo identificador explícito de unidad, con atributos compatibles; barrio, precio, foto compartida y texto no prueban identidad. La vía por foto/título fue retirada tras confundir dos unidades de San Luis. Contradicciones de unidad/tipo/especificaciones vetan la unión. Cada oferta nueva conserva evidencia física privada `identity.version: 1`, excluida de las APIs; no se inventa para legacy. `mergeOffers` conserva avisos vigentes y separa incompatibles. La limpieza por dueño positivo corre en ambos modos después de guardar los destinos; ausencia sólo permite caducar ofertas tras full exitoso con `complete: true`. ML/FB/InfoCasas son parciales; Casasweb sólo declara completa una cosecha sin cortes ni fallas. InfoCasas divide el barrido por franjas simples de precio (sin URLs `-y-`): las páginas nacionales 600 y 850 devolvieron los mismos 21 IDs el 2026-09-07; valida el paginador y corta colas repetidas, sin interpretar ausencia como baja. Admite viviendas de $3.000–$7.999 UYU únicamente con evidencia propia validada por `eligibility.ts`, cuyo espejo app también filtra la sección económica. Poda histórica a 21 días separada. Una URL vieja sólo se hereda por el grupo que contiene el único aviso atribuible al título canónico anterior; ambigüedad o ausencia reservan la key. Ambos jobs comparten `flock`: rápida saltea si está ocupado; completa espera hasta una hora y falla si vence. El descriptor vive hasta terminar Node. Reparación del 2026-09-05 desde captura 07:19:18 UTC: 14.583 lecturas de cuatro fuentes, 25.050 IDs conservados sin pertenencias duplicadas; 9 pares en 7 grupos y luego 923 grupos legacy separados, con respaldos y auditoría. El cierre final de San Luis sigue pendiente de verificación. Puede quedar una vivienda realmente repetida en tarjetas separadas; no se certifica cobertura exhaustiva. Detalles fechados en `docs/app/RENTALS.md`. **Contacto, excepción acotada 2026-09-07:** el usuario autorizó publicar contactos comerciales visibles del aviso propio/perfil de agencia con URL y fecha propias. InfoCasas aporta identidad nativa de agencia y email visible; su botón de teléfono requiere login y sigue excluido. Casasweb aporta WhatsApp del bloque `#nombreInmo` de la ficha propia. El País ahora aporta teléfono únicamente si `tel:` visible, canonical y `data-listing-id` del bloque comercial coinciden con el aviso, nunca desde `contact.phone`/`sourceAgency.emails` crudos. No se infiere agencia por nombre ni dueño directo por ser particular; contradicciones vetan esa declaración. Sin mensajes, login ni copia de datos ocultos. Política y presupuestos en `docs/app/PROPERTY_ADVERTISERS.md`. |
| currency-rentals-detail | scripts/run-rentals-detail.sh → dist/sync_rentals_detail.js | 15 * * * * | la **ficha** de los avisos de Facebook de `/alquileres-uruguay`, calcada de `currency-autos-detail`: 60 por corrida a 6 s, puppeteer atado al Chrome de `facebook_profile_browser` (CDP `:9224`; lo compartido con autos vive en `classes/facebook/`), primero Montevideo sin barrio y sin coordenada, cada aviso UNA vez. La ficha trae lo que la tarjeta no: la DESCRIPCIÓN (medido 2026-09-22 sobre 100 fichas sin barrio: 89 la tienen, 31 nombran barrio, 17 una esquina; ubicables 36, Montevideo 30 de 63). Escribe la privada `rentalfacebookdetails` y completa en `rentallistings` sólo campos VACÍOS de propiedades de un único aviso de Facebook (descripción, barrio, departamento, coordenada). La coordenada sale de una esquina o dirección del propio texto geocodificada por el proxy de Google del sitio y **aceptada sólo** si vuelve una intersección ("&") con una palabra en común o un ROOFTOP con el número del texto: sin la regla Google devuelve el centroide de UNA calle, hasta 10 km. La cosecha (`harvestFacebookMarketplace`) recarga esas fichas al re-ver un aviso para no perder lo aprendido. No toma vendedor, contactos, mascotas ni el pin. Ver `docs/app/RENTALS.md` |
| currency-rentals-hourly | scripts/run-rentals.sh --fast | 47 * * * * | Novedades (`order=3` / `since=today`) y muestras de Marketplace/Casasweb; de El País, la primera página con `sort=newest` de Montevideo, Canelones y Maldonado, que son el 92 % de su catálogo. **Nunca poda propiedades ni expira ofertas por ausencia**. Sí limpia copias de avisos cuyo nuevo dueño fue guardado, conservando los que no vio; una separación no renueva fechas. |
| currency-property-services | dist/sync_property_services.js | 33 7 * * 0 | Servicios cercanos: extracto público Uruguay de OpenStreetMap/Geofabrik procesado offline → APP DB `propertyservices` y metadata del snapshot. Primera captura validada: 8.336 servicios (2026-09-07). El usuario consulta el índice local, nunca Overpass por vivienda/usuario. Sólo coordenadas propias aproximadas o exactas del aviso; centroides de barrios no dan distancias. Ver `docs/app/PROPERTY_NEARBY.md`. |
| currency-property-zones | dist/sync_property_zones.js | 53 6 * * * | Comparación de zonas: cohortes residenciales del índice + 62 polígonos INE2011 compatibles con denuncias MI + conteos OSM. APP DB `propertyzonesnapshots`, documentos precalculados y limitados. N≥8 por métrica, una observación por propiedad/anuncio, datos propios y últimos 10 días. CSV de delitos en streaming sólo si cambia su versión; sin microdatos, sin tasa por población ni puntaje de seguridad. Un fallo conserva la capa anterior y su fecha. No geocodifica ni consulta fuentes por visitante. Ver `docs/app/PROPERTY_ZONES.md`. **Además** arma las capas de luz, agua y reclamos de la IM (SUR, ZIP mensual en streaming, se relee sólo si cambia en CKAN), asigna `officialZone` a cada vivienda (coordenada propia → nombre oficial → alias medido ≥10 geolocalizadas y ≥85 %) y guarda el documento `impact` (servicios del barrio vs alquiler por m², ρ de Spearman con bootstrap sembrado). Ver `docs/app/PROPERTY_ZONE_SERVICES.md`. |
| currency-property-zones-hourly | dist/sync_property_zones.js --assign-only | 57 * * * * | `officialZone` de las viviendas nuevas de la hora, para el filtro «Datos del barrio» de `/alquileres-uruguay`, **y la capa de luz releída del libro** sobre el contexto guardado (`refreshPowerLevels`): el libro crece cada 10 min y con la diaria sola el contador "N de 3 días" y el paso a provisorio llegaban hasta 24 h tarde. Sólo toca `utilities.power` y los niveles; agua, reclamos, denuncias y geometría viajan como la diaria los dejó. Cede el lease ante la corrida diaria |
| currency-power-outages | dist/sync_power_outages.js | 3-59/10 * * * * | **el libro de cortes de luz que UTE no guarda**: la API del mapa UTEi (ECSE) sólo muestra el momento; cada 10 min se integra por trapecio en `poweroutagedays` (APP DB) por barrio (los 63 de UTE = los 62 INE + Puerto) y localidad. ECSE devuelve el JSON codificado dos veces si pedís `application/json`. Un hueco >20 min acredita 10, nunca inventa horas. Publica **provisorio** con ≥3 días y ≥85 % de cobertura (etiquetado "provisorio · N días medidos" en filtro, tarjetas y páginas de barrio; decisión del 2026-09-21 porque NADIE publica cortes por barrio: URSEA agrupa por distrito × densidad —Montevideo son 2 agrupamientos— y sólo en gráficos) y definitivo con ≥14 días |
| currency-water-interruptions | dist/sync_water_interruptions.js | 29 8 * * * | avisos de corte programado de OSE → `waterinterruptions`; `--backfill` baja el archivo entero (11.363 avisos). El barrio se deriva al leer: sólo nombres explícitos, nunca calles (70 % de los avisos de Montevideo nombran barrio) |
| currency-property-opportunities | scripts/run-property-opportunities.sh → dist/sync_property_opportunities.js | 21 6 * * * | `/oportunidades-inmobiliarias-uruguay`: comparación de precios pedidos, no tasación ni rentabilidad. Lee ofertas de alquiler con identidad propia y cosecha ventas públicas de InfoCasas; APP DB `propertysalelistings`/`propertysalemetas` privadas y `propertyopportunitysnapshots` público por operación. Exige 8 comparables, 4 anunciantes, misma zona/atributos y superficies compatibles; abstiene ante datos insuficientes o condiciones ambiguas. GC cero sólo vale con texto propio expreso. No modifica `rentallistings`. Requiere `APP_MONGO_URI`; 500 páginas/20 min; mantiene lecturas originales y rechaza cosechas fallidas/delgadas. Ver `docs/app/PROPERTY_OPPORTUNITIES.md`. |
| currency-property-opportunities-hourly | scripts/run-property-opportunities.sh --analyze-only | 17 * * * * | Recalcula con lecturas existentes sin consultar portales. Comparte flock con la lectura diaria; API retira sujetos/comparables vencidos aun si falla el job. Presupuestos y filtros seleccionan el análisis guardado, nunca recalculan la mediana. |
| currency-autos / -hourly | scripts/run-autos.sh → dist/sync_autos.js (`--fast` la horaria) | 43 7 \* \* \* / 29 \* \* \* \* | `/autos-usados-uruguay` + `/oportunidades-autos-usados-uruguay`. **Diez fuentes**: Mercado Libre (MLU1744, puente :9656, **secuencial con 1,5 s entre pedidos** — un 429 de ML deja al puente 10 min en su proxy residencial para todos los jobs; marca → modelo porque un offset ≥ 4000 vuelve a la página 0), **Facebook Marketplace** (CDP al Chrome con sesión de `facebook_profile_browser`, nunca lo cierra; en Uruguay FB no trae marca/modelo/año/km y dice UYU aunque sea en dólares, así que el auto sale del texto y la moneda se lee pegada al monto o se deduce contra autos iguales — deducida nunca cuenta para oportunidades; fichas con tope 120/día), Clasiautos y Julio (REST de WordPress), Shopping de Autos y Carper (WooCommerce Store API), Fidocar y Motorlider (Fenicio, mismo lector) y Car One (HTML, siempre con su filtro de usados por robots), y **Dueño Directo** (listado + ficha), el otro clasificado de particulares. **Motorlider vende la seña**: sus microdatos dicen USD 500 en las 77 fichas y el precio del auto sólo está en la ficha técnica, así que el lector Fenicio la prefiere y descarta todo auto bajo USD 1.000 / $U 40.000. Todo aviso que no es de ML se **identifica contra un diccionario con los ids de ML** (`classes/autos/catalog/`, 91 % de coincidencia con el modelo de ML) para caer en la misma cohorte; un mismo auto en varias fuentes queda una vez (ML > webs > FB). APP DB privada `carlistings`/`carfbcards`/`carharvestmetas`; pública `carcatalog`/`carcatalogmetas`/`carmarketsnapshots`/`caropportunitysnapshots`. Oportunidad = cohorte fija (modelo+año+**versión**+motor+caja, km dentro de max(20.000, 30 %)), medida sobre 2.279 avisos reales: sin versión y motor la regla "encontraba" 125 gangas que eran otra versión o un 4x2; con ellas, 11. Toda oportunidad pasa por su ficha (activa, mismo precio/km, sin choque/recupero/deuda/chapa extranjera). La horaria no relee las webs y nunca retira por ausencia. `AUTOS_<FUENTE>_ENABLED=0` apaga una fuente. **Además publica `/autos-chocados-y-con-deuda-uruguay`** (`carrisksnapshots`): lo que el aviso DECLARA —deuda, papeles, choque, recupero, mecánica, chapa extranjera, ex taxi— con la frase textual del vendedor y cuánto menos pide que los mismos autos que no declaran nada (papeles/deuda −21 %, chocado −35 %, medido contra la cohorte limpia). Las reglas: el que afirma es el vendedor y va la cita, no una conclusión nuestra; lo negado no cuenta ("sin deuda" es argumento de venta, y sin eso la mitad de las coincidencias son al revés); la cohorte de referencia es la LIMPIA; sin comparables se publica el aviso pero no un número; y la ausencia no es una afirmación. `financing` y `price_mismatch` quedan fuera de la taxonomía porque su descuento medido es ≈0: no son riesgo, son truco de aviso. **Un precio que no puede ser el de ese auto se retira antes del análisis** (`priceSanity.ts`), así que el veredicto vale para el catálogo, las oportunidades, el riesgo y el informe a la vez. **No es un piso fijo en dólares y no puede serlo**: debajo de US$ 1.000 la mayoría son precios REALES de autos que se venden para repuestos y lo dicen en su descripción, mientras que una Hilux 2015 "inmaculada" a $ 35.500 sobrevive a cualquier piso razonable — el monto no distingue, distingue el auto. Cascada de cohortes, la primera que existe manda: modelo+año (n≥5) < 15 %, marca+año (n≥8) < 8 % —hace falta porque ML parte "Hilux" de "Hilux Pick-up"—, año (n≥20) < 7 % y, sin ninguna cohorte, US$ 200. El umbral se AFLOJA cuanto más parecida es la cohorte. **No se corrige el precio, se retira el aviso**: dólares mal leídos, la seña publicada como precio y un dígito de menos son tres historias y ninguna se adivina. Medido: 17 de 19.036. **Y dos cosas que la banda de precios no puede ver**: un REPUESTO publicado en la categoría de autos tiene precio de repuesto, así que se va por lo que dice ser (`IS_A_PART`, sólo si la pieza abre el título — buscar la palabra en cualquier posición da 556 falsos positivos donde "techo" y "cuero" son equipamiento de un Porsche; "motor" quedó afuera porque "Motor Echo ... Libreta Títulos" es un auto); y **"U$U" se leía como PESOS** — en `u$u6990` ninguna alternativa dólar engancha en la posición 0, el motor avanza una letra y el `$u` de pesos matchea el medio de la palabra, que es exactamente lo que publicó el Spark a US$ 169. `u$u` va primero en toda alternancia y el `$u` de pesos exige que no venga una letra pegada adelante. El directorio filtra además por **carrocería** (9 familias) y por puertas, color, "bajó de precio", "sólo oportunidades", "sin deuda ni choque declarados" y "publicado en los últimos N días": la carrocería sale de la ficha propia, si no del modelo (≥ 8 fichas y ≥ 90 % de acuerdo, `basis: "model"`, y la tarjeta la imprime con "≈"), si no de la palabra del título —en ese orden, porque "Tracker Ltz **Rural** 5 Puertas" usa "rural" por "cinco puertas" y el auto es un SUV— y si nada alcanza el aviso no cumple un filtro de carrocería. 93,6 % de cobertura medida. **Y `/mercado-de-autos-usados-uruguay`** (`carreportsnapshots`): el informe del mercado —composición, oferta por marca y modelo, depreciación por modelo (recta sobre el log de la mediana por año), margen de negociación observado, automotora vs dueño y qué se compra con cada presupuesto—. **Mide oferta, no ventas**: en Uruguay las transferencias de usados no se publican por modelo. La rotación (cuánto tarda un aviso en irse) se calcula pero NO se publica hasta 14 días de serie y 150 retirados; la serie arrancó el 17/9/2026. **Y la base de teléfonos** (`carcontacts`, APP DB privada, un documento por aviso publicado, se reescribe con el catálogo y borra lo que sale de él): el número que el vendedor escribió en el texto PÚBLICO de su aviso o el comercial de la `/contacto` de la automotora —también para las cuentas de ML que `contacts/accounts.ts` reconoce como suyas por los autos que comparten con su web (≥10 gemelos), nunca por el nombre—, vencido a los 21 días de su propia lectura; el catálogo lleva sólo `hasContact` y el número se pide con un clic a `/api/cars/contact/<key>` (no-store, noindex, límite por IP, baja por hash en `carcontactoptouts`). **Nunca** lo que ML esconde tras "Ver teléfono" (reCAPTCHA + login) ni nada de Facebook (sesión): es el límite, no una tarea pendiente. Ver `docs/app/AUTOS_CONTACTOS.md`. Ver `docs/app/AUTOS.md` |
| currency-autos-detail | dist/sync_autos_detail.js | 11 \* \* \* \* | lee la **ficha propia** de los avisos de ML guardados, con presupuesto (400 por corrida) y por orden de utilidad: primero los baratos contra su marca+modelo+año —ahí hay un motivo que contar—, después los que bloquean su cohorte por falta de versión o caja. Una lectura sirve para las **tres** cosas que la tarjeta de búsqueda no trae: la VERSIÓN, que decide qué se compara con qué; la DESCRIPCIÓN, que es donde el vendedor dice que el auto tiene deuda o está chocado; y la **CARROCERÍA** (más puertas y color), que `detail.ts` ya leía del `ld+json` y que recién el 20/9/2026 salió a la superficie como filtro. Medido el 18/9/2026: había **122 fichas de 16.865 avisos de ML**; el 20/9/2026 ya eran **19.460 de 20.601**, o sea que esta cola se vacía sola en días y por eso la carrocería se publica desde la ficha y no partiendo el barrido de ML por `VEHICLE_BODY_TYPE` (costaría casi el doble de páginas del puente para el 83 % de los avisos, que están en modelos con más de una). Después de leer mira las fotos de los dudosos con Gemini (`AUTOS_VISION_MAX`, 30): **la IA no publica, filtra** —retira una oportunidad que sus propias fotos contradicen y corrobora lo que el vendedor ya declaró, nunca acusa a un aviso que no dice nada—. No publica nada por sí mismo |
| currency-autos-guide | dist/sync_autos_guide.js | 13 5 \* \* \* | la **Guía de Precios de Mercado Libre** (`/precios-autos/<marca>/<modelo>/<año>/`, precio por versión) sólo para los modelo-año que tiene el directorio, 40 min a 1,5 s por página → APP DB privada `carguideentries`; `currency-autos` la publica como referencia de cada ficha y tabla del modelo. **No es independiente**: es la mediana de los mismos avisos de ML (Hilux 2018 DX = US$ 32.990 en los dos lados); vale como catálogo de versiones y segunda opinión. La referencia uruguaya independiente (Autodata/URUTAX, base del aforo de SUCIVE y del BSE) es paga |
| currency-rag-index | dist/sync_rag_index.js | 20 4 * * * | crawls the public sitemap → chunks → Gemini embeddings → APP DB `ragchunks`; incremental by content hash |
| currency-reddit-bot | dist/sync_reddit_bot.js | 6 * * * * | **SOLO COMENTA** (nunca abre hilos: `post.ts` se niega sin `REDDIT_BOT_ALLOW_POSTS=1`). Por hora, ventana de **168 h**, hasta 5 comentarios por corrida durmiendo entre uno y otro, 25/día y 8 por sub. El enfriamiento por página está apagado a propósito y lo reemplaza "no repetir página dentro de una corrida" en `run.ts`. Calla 03–10 UTC. Los 7 subs de la lista son TODOS los subs uruguayos vivos (28 medidos), y sólo 4 son escribibles: `subrules.ts` frena r/AskUruguayan (ban) y r/CharruaDevs (reglas). La aclaración de bot vive en la BIO y `identity.ts` la exige antes de publicar. **Inerte hasta `REDDIT_BOT_ENABLED=1` AND `REDDIT_BOT_DRY_RUN=0`** |
| currency-reddit-bot-watch | dist/sync_reddit_bot_watch.js | 9 * * * * | reads back comment scores; trips a 48 h circuit breaker on 3 negatives/24 h |
| currency-reddit-stats | dist/sync_reddit_stats.js | 48 */3 * * * | agrega el ledger del bot en APP DB `redditbotstats` para /estadisticas-reddit. Los `days` se **mezclan** con lo guardado, no se recalculan: el ledger es memoria operativa y el histórico no puede depender de que nadie lo limpie. 39 min después del watcher, que es quien actualiza votos y estado |
| currency-content-gaps | dist/sync_content_gaps.js | 35 5 * * * | clusters unanswered questions → grounded DRAFT in `docs/reddit-gaps/` (never a page) |
| currency-videos | dist/sync_videos.js | 26 */6 * * * | reads the public YouTube **Atom feed** (no API key, no quota) of the curated channels in `classes/videos/channels.ts` -> APP DB `videossnapshots` for /videos-de-economia-uruguay + its 13 per-topic pages; needs `APP_MONGO_URI`; refuses to overwrite a good snapshot with an empty or half-dead run |
| currency-regional | dist/sync_regional.js | */10 * * * * | el tablero regional de `/cotizaciones-de-la-region` y `GET /regional`: **21 fuentes públicas** en AR/BR/PY/CL/BO (5 bancos centrales) unidas al tablero uruguayo propio. Argentina tiene **siete dólares simultáneos** y Brasil un fixing legal (PTAX) más un mostrador (turismo) que difiere ~6,5 %: por eso cada fila lleva `kind` y nada se promedia entre mercados. Escribe TRES cosas: el snapshot, un punto diario por mercado y **una fila por cada cambio de precio, sin umbral mínimo** (`regional_changes`, servido por `GET /regional/changes`) — la fila diaria se sobrescribe, así que lo que pasa adentro del día sólo existe en el ledger, y su resolución ES este intervalo. `validate.ts` filtra por forma, banda, consenso (20 % entre relevamientos, **3 % entre referencias mid-market**), coherencia contra las patas en dólares y contraste de la referencia internacional contra el propio país; **publica lo descartado** en `rejected`. El BCP no tiene API (403 tras Cloudflare) y se lee de sus dos páginas server-rendered. Ver `docs/api/REGIONAL.md` |
| currency-regional-history | dist/sync_regional.js --backfill | 9 5 * * * | mismo entrypoint: además baja las series que el publicador entrega entera (7 dólares argentinos desde 2011, **referencia BCRA desde 1996**, **PTAX desde el Plano Real** —antes es otra moneda—, dólar observado chileno **desde 1984**) y recorre los dos archivos que sólo contestan de a un día: el del BCP (`?fecha=dd/mm/yyyy`, desde 2014) y **nuestra propia colección diaria uruguaya desde 2022-12-28**, con presupuesto por corrida y salteando lo ya guardado. Inserta por (key, day). **La SGS del BCB rechaza ventanas de más de 10 años devolviendo vacío**, indistinguible de "no hay serie": `sgsWindows()` parte el rango. El BCP contesta un domingo con el promedio del viernes bajo el encabezado del domingo: sólo se guarda una fila con hora |
| currency-precios | dist/sync_precios.js | 12 3 * * * | los precios oficiales del SIPC (MEF/Defensa del Consumidor) y el histórico que el Estado **NO guarda**: `precios.gub.uy` responde 301 a `www.precios.uy`, y su API devuelve sólo el precio de hoy con su fecha, sin endpoint de serie. 215 × `compararArticulo` con bbox nacional; medido: **75.858 observaciones en 341 s**. **`compararCanasta` está prohibido y hay tripwire**: imputa —para el artículo 114 hay 28 observaciones reales y la matriz muestra el mismo `$509.32 (*)` en 722 de 722 locales, con 694 celdas sin fecha—, así que rankear supermercados con su total ordena promedios, no góndolas. De ahí la regla general: **se rechaza toda fila con `(*)` o sin `fecha`**. Dos trampas de protocolo: los POST dan **406** con `Accept: application/json` (va `text/plain`, como su propia SPA) y el origen usa **`x`=latitud, `y`=longitud**. Tres guardas por ejes distintos: banda por percentiles del **propio artículo** (el spread real va de 1,58× a 4,86×, un factor fijo no sirve) más marca `suspect` bajo p10/2 que **no borra pero no encabeza** (el $18,5 contra mediana $64 movió el titular a $25); frescura por la `fecha` del origen —acá viene regalada— donde `stale` **nunca gana un ranking de "más barato"**; y auditoría al cierre que ve lo que las otras no: la **góndola entera** desplazada es error de unidad. Las **ofertas se conservan**: son 1 de cada 10 filas y 7,8 % más baratas, descartarlas sesgaba el índice hacia arriba (recuperarlas subió los locales calificados de 211 a 356). La canasta está **pinneada y versionada** (33 artículos, elegidos por más observaciones y no por precio) y se ordena por **canasta emparejada, nunca por el total**: el total baja por FALTARLE artículos al local — correlación cobertura/total 0,842 y sólo 1 de 10 coincidencias entre los dos top-10. Exige 70 % de cobertura por local y 5 locales por ámbito; 13 de 19 departamentos califican y los otros 6 lo dicen. Ver `docs/app/PRECIOS.md` |
| currency-combustibles | dist/sync_combustibles.js | 11 7,13 * * * | tabla histórica de ANCAP (HTML, sin LLM) → `combustibles_history` + `GET /combustibles`; rechaza tablas con salto > 50 % o edición vieja; sirve `/precio-de-la-nafta-uruguay` |
| currency-charruadevs | dist/sync_charruadevs.js | 14 12 * * * | `/mercado-it-uruguay`: qué tan negativa es la visión del desarrollo de software en r/CharruaDevs. Arctic Shift (archivo que conserva lo borrado) + API de Reddit para votos y borrados → Gemini con esquema JSON (`askJSON`, único cliente `classes/gemini.ts`; modelo fijado `gemini-3.5-flash-lite` porque cambiarlo cambia la serie) → APP DB `charruadevstexts` (buscador: índice `$text` en español, **sin autores**) + `charruadevssnapshots` (`snapshot` que dibuja la página, `state` con las filas mensuales que no se pueden recalcular sin rebajar todo). Cada corrida baja los DOS últimos meses completos y sólo clasifica lo que no está; lo que Gemini no etiqueta no se guarda y se reintenta al día siguiente. Lo borrado hoy en Reddit queda `gone`: cuenta en los agregados anónimos pero no se muestra. No pisa el tablero con un snapshot 10 % más flaco. Sembrado una vez con `--seed <dir>`. Ver `docs/app/CHARRUADEVS.md` |
| currency-price-events | dist/sync_price_events.js | 13 15 * * * | `/ciberlunes-y-black-friday-uruguay`: ¿el descuento es real? Lee `pricewatchoffers` (nunca lo escribe — eso es `sync_equipar.ts`/`sync_chairs.ts`/`sync_phones.ts`/`sync_movilidad.ts`) y clasifica cada oferta vista HOY contra su PROPIO historial, nunca contra otra tienda: `baja-real` si el precio de hoy es ≤ 90 % de su propio mínimo de 60 días, `tachado-por-encima` si el precio de lista de hoy es ≥ 110 % de su propio máximo de venta de 60 días — comparado en **centavos enteros** (`priorMax * 1.1` en float puede perder un borde exacto por `1e-13`). Exige 21 días de antigüedad y 10 días previos DISTINTOS dentro de la ventana; menos que eso no es un descuento, es no tener con qué comparar. Publica en APP DB `priceeventsnapshots`: un puntero `current` más un archivo `day:YYYY-MM-DD` podado a 400 días. La vitrina `topDrops` va topada a 200 y máx 3 por vendedor con orden determinista (dropPct desc, ratio sin redondear, listingId); los totales del día (`dropsCount`/`inflatedCount`) NUNCA salen de `topDrops.length`, que se achata en 200. **Guarda de corrida flaca**: si ya había ≥ 20 elegibles publicados y la corrida nueva trae menos del 40 % de eso, no escribe y sale en 1 — salvo el primer run, que siempre escribe aunque traiga 0. Medido 2026-09-17: `pricewatchoffers` recién arrancó ese día, así que ninguna oferta califica hasta el 2026-10-08 (21 días después) y eso es lo esperado, no una falla. Necesita `APP_MONGO_URI`. Ver `docs/app/PRICE_EVENTS.md` |
| currency-price-events-hourly | dist/sync_price_events.js --event-only | 19 * * * * | Mismo entrypoint con `--event-only`: mira `classes/priceevents/calendar.ts::activeEvent()` ANTES de conectar a Mongo y sale en 0 sin abrir conexión si hoy no hay CyberLunes/Black Friday activo — 24 corridas por día, 365 días al año, casi todas sin nada que hacer. La edición de CyberLunes de noviembre 2026 todavía no tiene fecha publicada por la CEDU, así que activa una ventana adivinada (2026-11-01..08) para que este job ya esté recalculando cuando la fecha real se confirme. Nunca poda `day:` vencidos (la diaria ya lo hizo esa mañana; este puede correr hasta 24 veces en un día de evento). Si cae antes de que equipar/sillas escriban el punto del día (p.ej. 00:19 UTC, `analyzed === 0`), sale en 0 sin escribir en vez de marcar una corrida flaca falsa. Ver `docs/app/PRICE_EVENTS.md` |
| currency-market-series | dist/sync_market_series.js | 3 13 * * * | `/evolucion-precio-alquileres-uruguay`, `/evolucion-precio-viviendas-uruguay`, `/evolucion-precio-autos-usados-uruguay` y el bloque de cada modelo de auto: una serie diaria por cohorte (moneda × tipo × dormitorios × zona; modelo y modelo+año) con **dos medidas que no se mezclan**: el nivel (p25/mediana/p75 de lo que se pide, que se mueve también cuando cambia qué avisos hay) y la **misma oferta** (cada aviso contra su propio precio de hace 7/30/90 días, media geométrica, par fuera de [0,5; 2] descartado). Sólo LEE los catálogos públicos de la APP DB (`rentallistings` vía `projectZoneObservations`, `propertysalecatalog`, `carcatalog`), nunca una cosecha; monedas nunca convertidas; n ≥ 8 y pares ≥ 8. Escribe `marketseries`, `marketseriesmetas` y el historial PRIVADO `marketpricelogs` (sólo cambios de precio, poda a 120 días). Cada cohorte con 30+ unidades guarda además la **forma** del día: histograma de precios reales (nunca una campana ajustada: los precios son asimétricos), eje p1–p99, tramos log si p99 ≥ 4·p1, √n barras, tramos lineales múltiplos del redondeo de los precios (`hists`, últimos 100 días). Corrida flaca (< 60 %) no escribe y sale con 1. La serie arrancó el 2026-09-18 y no se reconstruye hacia atrás. Ver `docs/app/MARKET_SERIES.md` |
| currency-price-changes | dist/sync_price_changes.js | 9 16 * * * | `/cambios-de-precio-uruguay`: qué bajó y qué subió, **aviso por aviso**. El registro ya existía y es regular en TRES colecciones que nadie leía desde el sitio (`pricewatchoffers`, `carlistings.priceHistory`, `marketpricelogs`); este job no escribe historial, lo LEE y publica la foto del día en APP DB `pricechangesnapshots`. Autos sale de `carlistings` y no de `marketpricelogs`, aunque las dos lo tengan: la primera se actualiza cada hora y la segunda una vez por día, así que publicar las dos contaría el mismo cambio dos veces con distinta resolución. La moneda nunca se mezcla —un aviso que pasó de USD a UYU no bajó un 4.000 %, cambió de unidad— y nunca se afirma nada anterior a la primera lectura nuestra. Tope de 3 filas por vendedor para que una automotora que retoca cuarenta precios el mismo día no ocupe la tabla entera. La misma serie la adjunta cada ficha (autos, alquiler, venta) y cada oferta de celulares, sillas y el directorio de equipar, sin ruta propia: viaja en la respuesta que la página ya pide. Necesita `APP_MONGO_URI`. Ver `docs/app/PRICE_CHANGES.md` |
| currency-mcp | mcp/dist/index.js (cwd ./mcp) | — | HTTP :8788, `https://mcp.cambio-uruguay.com/mcp`. 29 tools en 5 toolsets por ruta (`/mcp`, `/mcp/alquileres`, `/mcp/autos`, `/mcp/productos`, `/mcp/sitio`, `/mcp/cambio`): los de alquileres/autos/productos/sitio leen la API del **sitio** (`SITE_BASE_URL`, rutas de `app/server/api`), así que un cambio de forma en esas rutas puede romper el MCP sin romper la página: `cd mcp && npm run smoke`. Tiene CORS para los orígenes del sitio porque `/asistente-ia` lo llama desde el navegador: un botón, el visitante entra a Puter (Google/Microsoft/Apple) y la IA se cobra a SU cuenta con cupo gratis mensual (Puter.js "user-pays"); alternativa, su propia clave de Gemini. El sitio no paga tokens. **`sitio`** (search_site/read_page/site_sections) responde de TODO el sitio vía `/api/site/search|page|sections` (app): navegación con el mismo índice y puntaje que el buscador del encabezado + pasajes BM25 del índice RAG nocturno `ragchunks` (léxico a propósito: un embedding por pregunta gastaría la cuota diaria de Gemini del sitio, la misma del índice y del bot de Reddit), cargado por proceso con TTL 6 h (~25 MB). Las cifras de los pasajes son del día del crawl y cada resultado trae la fecha; el buscador y `/buscar` ofrecen «Preguntarle a la IA». Los directorios (alquileres, alquiler ideal, oportunidades, autos, equipar, celulares, sillas, movilidad, súper) llevan `AssistantCta`: botón a `/asistente-ia?q=<pregunta armada con los chips de filtro>`, nunca con la dirección de referencia; la pregunta se envía sola al conectarse (`app/utils/assistantPrompt.ts`). Incluye la skill `mcp/skills/buscador-uruguay` (zip en `app/public/descargas/`, `npm run pack-skill`) y la guía pública `/buscar-con-ia`. **Deploy manual** (ver `mcp/DEPLOY.md`): no está en `deploy-backend.sh` |
| currency-bot-telegram / -discord | bots/dist/entries/{telegram,discord}.js | — | read `bots/.env` |
| currency-daily | bots/dist/entries/daily_report.js | 0 12 * * * | |
| currency-alerts | bots/dist/entries/alert_check.js | */15 11-21 * * * | intraday move alerts |
| currency-content-promo | bots/dist/entries/content_promo.js | 0 14 * * 1,3,5 | one evergreen guide to X; **inert until `CONTENT_PROMO_ENABLED=1`** in `bots/.env` |

Ambos trabajos de oportunidades también publican `/venta-viviendas-uruguay`: `propertysalecatalog` y `propertysalecatalogmetas`, desde una proyección explícita en `classes/propertysales/`. Una ficha corresponde a un anuncio (`infocasas-<id>`/`casasweb-<id>`), nunca a una unión inferida. La lectura diaria agrega Casasweb, pero sus tarjetas solas no prueban el precio total: para publicar se exige la ficha propia completa, porque saldos ANV/BHU y derechos parciales no aparecen en la tarjeta. Las exclusiones de ventas también rigen para los comparables de oportunidades. La última lectura no renueva la fecha de publicación; GC cero exige texto propio y ubicación oculta no se publica. Detalles: `docs/app/PROPERTY_SALES.md` y `PROPERTY_SALES_BACKEND.md`.

Root pm2 entrypoints live at repo root: `index.ts`, `sync.ts`, `sync_aduana*.ts`, `sync_banks_news.ts`, `sync_figures.ts`, `sync_costs.ts`, `sync_debt_relief.ts`, `sync_loans.ts`, `sync_predictions.ts`, `sync_explain.ts`, `sync_sheet.ts`, `sync_site_analytics.ts`, `sync_gsc.ts`, `sync_search_demand.ts`, `sync_revenue_plan.ts`, `sync_temas_analysis.ts`, `sync_rag_index.ts`, `sync_reddit_bot.ts`, `sync_reddit_bot_watch.ts`, `sync_content_gaps.ts`, `sync_videos.ts`, `sync_bcu_rates.ts`, `sync_rentals.ts`, `sync_rentals_detail.ts`, `sync_regional.ts`, `sync_precios.ts`, `sync_equipar.ts`, `sync_phones.ts`, `sync_movilidad.ts`, `sync_combustibles.ts`, `sync_autos_detail.ts`, `sync_charruadevs.ts`, `sync_store_profiles.ts`, `sync_price_events.ts`, `sync_market_series.ts`, `sync_power_outages.ts`, `sync_water_interruptions.ts`, `sync_price_changes.ts`. Shared: `config.ts`, `global.ts`, `sentry.ts`.

## Top-level dirs
| dir | role |
|---|---|
| `app/` | Nuxt frontend (separate package, own MongoDB) — see `app/AGENTS.md` |
| `classes/` | backend logic — see `classes/AGENTS.md` |
| `classes/cambios/` | 53 per-casa scraper modules + shared DolarAhora parser (**46** active keys in `origins.ts`, 6 comentadas) — see `classes/cambios/AGENTS.md`. **Cuidado al contar hacia afuera:** de esos 46 orígenes uno es `bcu`, que no es una casa de cambio y sólo aporta UI/UP/UR — son **45 casas**. El copy del sitio dice "más de 40" y por eso es correcto; cualquier cifra exacta que se publique afuera se cuenta desde `GET /parameters/origins` menos el BCU, no desde este archivo |
| `classes/retail/` | el lector de retail uruguayo **compartido**: 4 adaptadores de plataforma (fenicio/shopify/woocommerce/vtex) + ML + FB Marketplace, 16 tiendas en `stores.ts`, con la categoría inyectada como `CategorySpec`. Lo consumen `classes/chairs/` (vía `spec.ts`) y `classes/equipar/`; ninguno de los dos es dueño de las cañerías. Agregar una tienda son unas líneas en `stores.ts`, y un rediseño de storefront no rompe nada: cada adaptador lee un contrato publicado, no markup |
| `bots/` | Telegram/Discord/Twitter bots — see `bots/AGENTS.md` + `bots/README.md` |
| `mcp/` | open-source MCP server — see `mcp/AGENTS.md`, `mcp/README.md`, `mcp/DEPLOY.md` |
| `tests/` | root vitest backend suite — see `tests/AGENTS.md` |
| `docs/` | `analytics/ api/ app/ backlinks/ lighthouse/ medium-articles/ research/ seo/ superpowers/` (plans, SEO data, articles; `analytics/GA4_DATA_API.md` = GA4 read-path setup) |
| `scripts/` | `deploy-backend.sh` + `oneoff/` (dev one-offs, run via `npm run <name>`) |
| `swagger/` | OpenAPI config (`config.ts`, README) served by the API |
| `interfaces/` | `Cambio.ts` shared TS interface |
| `config/` | `config.ts` |
| `dist/` | root build output (gitignored) |

`classes/` key files: `database.ts` (Mongo connect), `gemini.ts` + `ai_service.ts` (LLM), `appdb.ts` (app-DB bridge), `reddit.ts`, `redis_cache.ts`, `notify.ts`, `cluster.ts` (`isPrimaryInstance()`), `Express/` (server setup), `models/` (mongoose), and per-feature dirs `aduana autos banks bcurates charruadevs costs debt equipar explain figures gaps gsc loans marketseries movilidad phones precios predictions priceevents pricehistory rag redditbot regional rentals revenueplan site-analytics stores temas-analysis utilities` (each `refresh.ts`/`store.ts`; `stores/` is `/tiendas-online-uruguay` — see `docs/app/TIENDAS_ONLINE.md`; `movilidad/` is `/monopatines-electricos-uruguay` + `/bicicletas-electricas-uruguay`, a second consumer of equipar's injectable category registry rather than a feature with its own classify/bands machinery — see `docs/app/MOVILIDAD.md`). La excepción es `mercadopago/`, que es SÓLO un parser puro y no tiene job: los topes de las promos viven en `api.mercadopago.com`, cuyo `robots.txt` es `Disallow: /`, así que los lee una persona con `npm run mp_promos` y se publican fechados en `app/utils/mercadoPagoPromos.ts`. No convertirlo en cron. `pricewatch/` es aparte: no es un feature con job propio, es un historial diario de precio por OFERTA (no por producto) que `sync_equipar.ts`, `sync_chairs.ts`, `sync_phones.ts` y `sync_movilidad.ts` escriben en APP DB `pricewatchoffers` después de guardar su catálogo. Ya tiene lector: `sync_price_events.ts`/`classes/priceevents/` lo compara contra su propio pasado para `/ciberlunes-y-black-friday-uruguay` — ver `docs/app/PRICEWATCH.md` y `docs/app/PRICE_EVENTS.md`.

## Build / run / test / lint
- Root: `npm run dev` (API), `npm run build`, `npm test` (`vitest run`, `tests/**/*.test.ts`). One-offs: `npm run prex`, `bcu_backfill`, `get_locations`, etc. (ts-node, in `scripts/oneoff/`, NOT compiled).
- `app/`: `npm run dev`, `npm run build`, `npm test`, `npm run lint`. **`npm run typecheck` is broken** (vue-tsc crashes — use `lint`). Dev restart wipes `.nuxt` → `npx nuxi prepare`.
- No committed `package-lock.json` at root (gitignored); only `app/package-lock.json` is committed. CI/deploy install with `npm install`, never `npm ci`. `app` installs use `--force`, NOT `--legacy-peer-deps` (drops pinia).

## Deploy (push to `main` → `.github/workflows/deploy.yml`)
- **App**: `changes` path-filter (`app/**`) → `test` job (app vitest) → `deploy` job SSHes and runs `app/scripts/deploy.sh` (flock + staging build + atomic swap + `pm2 reload`, zero-downtime). **A push that touches no file under `app/` does NOT redeploy the frontend** — the `nuxt build` on the server is ~5 min and 17 of the 20 commits before `43f5861` touched zero app files. Safe only because `app/` is self-contained (no workspaces, no `file:` deps, no alias out of `app/`): if you ever make the Nuxt build read something outside `app/`, widen the `app` filter in `deploy.yml` FIRST — the failure mode is a change that silently never reaches production. `workflow_dispatch` bypasses the filter and always deploys (escape hatch for forcing a rebuild). **Y hay una trampa con la cancelación por concurrencia:** si empujás dos veces seguidas, GitHub cancela el run del primer commit, y si el commit que sobrevive NO toca `app/`, el filtro saltea el frontend y los cambios de app del commit cancelado quedan sin desplegar — sin error y sin aviso. Pasó el 2026-09-03: `8f1b297` (recorte del payload de /sucursal) lo canceló `c818948` (sólo `classes/demand/**`), y producción siguió sirviendo el payload viejo hasta que otro commit volvió a tocar `app/`. Juntá los cambios de `app/` en un push, y medí la página en producción antes de darla por desplegada.
- **Backend**: `changes` path-filter (root `*.ts`, `classes/**`, `ecosystem.config.js`, `tests/**`…) → `backend-test` → `backend-deploy` SSHes and runs `scripts/deploy-backend.sh`. **Builds ON the server** (needs gitignored `sheet_key.json`), stages into `dist_staging`, atomic swap, rolling `pm2 reload currency-server`. Sequenced after app deploy so SSH sessions don't share the git tree.
- New non-server pm2 app must be added to `OTHER_APPS` in `deploy-backend.sh` or it never starts on the VPS. Since 2026-09-22 the script re-reads that list from the file it just pulled, so the app starts in the SAME deploy that adds it; before, bash kept running the pre-pull copy and the app only appeared on the next deploy (currency-rentals-detail had to be started by hand).

## Dos reglas al publicar (se aplican a TODO commit, no sólo a los de SEO)

- **Un cambio pensado para mover tráfico agrega su fila en `docs/seo/experiments.json` EN EL MISMO
  COMMIT.** `currency-revenue-plan` dictamina solo a los 28 días, midiendo la PORCIÓN de los clics
  del sitio y no los clics (entre marzo y agosto de 2026 las impresiones se multiplicaron por seis:
  un antes/después crudo declara ganador hasta a no tocar nada). Sin esa fila el cambio **no se mide
  nunca** — que es exactamente lo que venía pasando: el registro de crecimiento cierra nueve
  iteraciones seguidas con "evaluar con 28 días finales posteriores" y ninguna vuelve. Un cambio que
  toca TODAS las páginas (layout, navegación) NO se declara: sin control, el sujeto es también el
  denominador y el veredicto siempre da "sin cambio". `tests/revenueplan/experiments_routes.test.ts`
  verifica que cada ruta declarada exista — una ruta mal tipeada no rompe nada, publica "sin datos"
  para siempre y tiene el mismo síntoma que "todavía no hay historia". Ver `docs/analytics/REVENUE_PLAN.md`.
- **Este repositorio es PÚBLICO: cero cifras de ingreso en nada versionado.** Ni en código, ni en
  comentarios, ni en este archivo, ni en un mensaje de commit. La convención ya existía para los
  documentos (`docs/seo/adsense-growth-loop.md` es "público sin cifras"; los montos viven en
  `docs/seo/data/`, gitignored) y se rompió igual el 2026-09-20 porque se leía como si aplicara
  sólo a `docs/`. Publicar la FORMA — multiplicadores relativos al promedio del sitio, factores entre
  familias, órdenes de magnitud — dice lo mismo, no expone facturación y además envejece bien.

## Non-obvious gotchas
- **currency-server is pm2 cluster ×2 → NO recurring scheduler may live in the API process** (`setInterval`/cron would run once per instance). Guard with `classes/cluster.ts` `isPrimaryInstance()` or (preferred) a separate single-instance pm2 cron app. Tripwire: `tests/no_scheduler_in_api.test.ts`.
- Root vs app use different Mongo hosts/DBs; jobs writing app collections refuse to run without `APP_MONGO_URI`.
- **pm2 logs on the VPS used to die every hour (fixed 2026-09-12)**: `/root/cleanup_tmp_files.sh` (root crontab, `0 * * * *`) ran `pm2 flush`, emptying EVERY app's log — a 04:52 job's output was gone by 05:00 — while 8.8 GB of logs of apps that no longer existed piled up untouched. It now runs `/root/pm2_orphan_logs.py --delete` instead: only logs no live pm2 app writes to, untouched for 30 days (last run in `/root/pm2_orphan_logs.last`, old script in `/root/cleanup_tmp_files.sh.bak-20260912`). pm2-logrotate (10M, retain 5, daily) bounds live logs. If job logs start vanishing hourly again, look at that cron script first. Still persist what a failure needs in the DB (rentals: `rentalmetas` `sources[].note/lastOkAt/failingSince`, see `docs/app/RENTALS.md`).
- Secrets: `.env` (dotenv), `sheet_key.json`, `serviceAccount.json`, `prex_session.txt`, `proxy.txt` all gitignored/server-only. See `.env.sample` (PREX_* USD scrape, AI_* wormgpt).
- **El gate de secretos escanea DOS veces** (`.github/workflows/secret-scan.yml`): el árbol (`gitleaks dir .`) y **cada commit introducido por el push** (`gitleaks git . --log-opts=<before>..<sha>`). La regla `generic-api-key` marca cualquier línea donde un campo llamado como la palabra inglesa para "clave" recibe un valor con dígitos: los identificadores de catálogo (el modelo de un celular, la edición de un evento) tienen exactamente esa forma, así que **un renombrado posterior no alcanza**: el commit viejo sigue en el rango y el push falla entero (pasó el 17/9/2026 con celulares: árbol limpio, 32 hallazgos en la historia). Usá `id`/`slug` desde el primer commit, o aplastá la rama. Para reproducirlo antes de empujar: gitleaks 8.30.1, `gitleaks dir .` en un checkout limpio y `gitleaks git . --log-opts="origin/main..HEAD"` en el worktree.
- Ignore root scratch junk: `/*.html`, `/*.png`, `*.mp4`, `*.stackdump`, `.sdd-*`, `.superpowers/`, `docs/seo/data/` are gitignored debug artifacts.
- DB-derived casa scrapers (federal/argentino/romantico mirror BROU) false-fail without a live Mongo connection.
- The maintainer keeps a per-feature Claude auto-memory (`MEMORY.md`, outside the repo) with richer page-by-page history than this file.