feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,8 +59,13 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||
| [browser-profiles](browser-profiles.md) | 4 | Catalogo de perfiles del navegador Chromium para investigaciones multicuenta OSINT: por perfil guarda que correo/cuentas usar (secret_ref a pass, nunca el password), proposito, persona y nota del vault, y lanza el perfil listo via systemd-run. Fuente de verdad en el service osint_db (tablas browser_profiles + browser_profile_accounts) |
|
||||
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
|
||||
| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` |
|
||||
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||
| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza |
|
||||
| [eda](eda.md) | 8 | Exploratory Data Analysis por tabla con motor DuckDB push-down: perfil base SQL (SUMMARIZE), estadística numérica/categórica sobre muestra, tipo semántico por regex, score de calidad, render markdown con sparklines y el orquestador one-shot `profile_table` (promueve VARCHAR→numeric/datetime, emite TableProfile + report md/json). Fases siguientes: correlaciones, relaciones inter-tabla, modelos baratos, LLM, notebook |
|
||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Capability: browser-profiles
|
||||
|
||||
Catálogo operativo de los perfiles del navegador Chromium para investigaciones
|
||||
multicuenta OSINT. Por cada perfil de Chromium (un `--profile-directory` dentro
|
||||
de un user-data-dir) guarda **qué correo/cuentas usar, propósito, persona e
|
||||
identidad de la investigación** y la nota del vault que lo documenta, y permite
|
||||
**lanzar el perfil** listo para trabajar mostrando sus cuentas. La fuente de
|
||||
verdad vive en el service `osint_db` (FastAPI + DuckDB, `http://127.0.0.1:8771`),
|
||||
en las tablas `browser_profiles` + `browser_profile_accounts` (schema main,
|
||||
pobladas solo por API, como `network_scans`). Estas funciones son clientes HTTP
|
||||
finos a ese service.
|
||||
|
||||
**Regla de seguridad dura:** una cuenta guarda `secret_ref` — una **referencia**
|
||||
al secreto (ej. `pass show osint/p1/gmail`), NUNCA la contraseña en claro. Ni el
|
||||
service ni estas funciones almacenan o resuelven credenciales: `browser_profile_open`
|
||||
solo expone el `secret_ref` para que el operador (o otra herramienta) lo resuelva
|
||||
con `pass`/keepass.
|
||||
|
||||
Comparte el ecosistema del project `osint` (vault Obsidian + service `osint_db`)
|
||||
con los grupos `recon`, `osint-passive` y `dav`. El perfil real de Chromium vive
|
||||
en `~/.config/chromium-cdp` (user-data-dir con CDP 9222 inyectado por el wrapper
|
||||
`/usr/bin/chromium`); el catálogo NO toca el perfil en disco, solo su metadata.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `browser_profile_register_py_browser` | `browser_profile_register(profile_dir, label="", persona="", purpose="", note_path="", tags=None, notes="", user_data_dir="", status="active", accounts=None, base_url=...) -> dict` | Registra/actualiza un perfil y, opcionalmente, sus cuentas en una sola llamada (1 POST del perfil + 1 POST por cuenta). Idempotente (upsert por `profile_dir` y por `id` de cuenta). `accounts` es una lista de dicts `{service, identity, secret_ref?, role?, status?, notes?}`. |
|
||||
| `browser_profile_list_py_browser` | `browser_profile_list(status=None, base_url=...) -> dict` | Lista los perfiles del catálogo con su nº de cuentas (`n_accounts`). Filtro opcional por `status` (active/archived). Devuelve `{"status":"ok","profiles":[...]}`. |
|
||||
| `browser_profile_show_py_browser` | `browser_profile_show(profile_dir, base_url=...) -> dict` | Muestra un perfil con todas sus cuentas. Devuelve `{"status":"ok","profile":{...},"accounts":[...]}` o error si no existe. Las cuentas traen `secret_ref` (referencia), nunca el password. |
|
||||
| `browser_profile_open_py_browser` | `browser_profile_open(profile_dir, url=None, base_url=..., dry_run=False) -> dict` | Lanza Chromium en el perfil (`--profile-directory`) vía `systemd-run --user --scope` (evita exit-144) y devuelve sus cuentas/`secret_ref` para saber qué usar. `dry_run=True` devuelve el comando sin abrir nada. Compone `browser_profile_show` para leer la metadata. |
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.browser_profile_register import browser_profile_register
|
||||
from browser.browser_profile_list import browser_profile_list
|
||||
from browser.browser_profile_show import browser_profile_show
|
||||
from browser.browser_profile_open import browser_profile_open
|
||||
|
||||
# 1. Registrar un perfil con sus cuentas (secret_ref = referencia a pass, NO el password)
|
||||
browser_profile_register(
|
||||
"osint_01",
|
||||
label="osint_01",
|
||||
persona="sock-puppet Marta R.",
|
||||
purpose="infiltración foros nicho X",
|
||||
tags=["osint", "sockpuppet"],
|
||||
accounts=[
|
||||
{"service": "gmail", "identity": "marta.r.osint@gmail.com", "secret_ref": "pass show osint/osint_01/gmail", "role": "primary"},
|
||||
{"service": "twitter", "identity": "@marta_r_osint", "secret_ref": "pass show osint/osint_01/x", "role": "burner"},
|
||||
],
|
||||
)
|
||||
|
||||
# 2. Listar el catálogo
|
||||
browser_profile_list() # {"status":"ok","profiles":[{profile_dir, label, n_accounts, ...}]}
|
||||
|
||||
# 3. Ver un perfil con sus cuentas
|
||||
browser_profile_show("osint_01") # {"profile": {...}, "accounts": [{service, identity, secret_ref, role}]}
|
||||
|
||||
# 4. Abrir el perfil listo para trabajar (lanza Chromium + dice qué cuentas usar)
|
||||
browser_profile_open("osint_01", url="https://twitter.com")
|
||||
# -> systemd-run --user --scope -- chromium --profile-directory=osint_01 https://twitter.com
|
||||
# -> accounts: [(gmail, pass show osint/osint_01/gmail), (twitter, pass show osint/osint_01/x)]
|
||||
```
|
||||
|
||||
Vía `fn run` (un id conocido a la vez):
|
||||
|
||||
```bash
|
||||
./fn run browser_profile_list
|
||||
./fn run browser_profile_show osint_01
|
||||
./fn run browser_profile_open osint_01 https://twitter.com
|
||||
```
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No gestiona el perfil de Chromium en disco** (crear/clonar/extensiones/avatar):
|
||||
eso es `create_chrome_profile_bash_browser`, `list_chrome_profiles_go_browser`,
|
||||
`set_chrome_profile_appearance_bash_browser`. Este grupo solo guarda metadata
|
||||
operativa y lanza un perfil existente.
|
||||
- **No almacena ni resuelve contraseñas.** Solo referencias (`secret_ref`). El
|
||||
password se resuelve aparte con `pass`/keepass.
|
||||
- **No automatiza el login** ni rellena formularios: para eso usa el `browser_mcp`
|
||||
o el grupo `flow-replay` una vez el perfil está abierto.
|
||||
- **Requiere el service `osint_db` vivo** en `:8771`. Si está caído, las funciones
|
||||
devuelven `{"status":"error", ...}` sin lanzar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `profile_dir` es el nombre del directorio REAL del perfil de Chromium (lo
|
||||
que va en `--profile-directory`): `"Default"`, `"Profile 1"`, `"osint_01"`. NO
|
||||
es el nombre legible (ese es `label`). Verlos con
|
||||
`list_chrome_profiles_go_browser` o el `Local State` del user-data-dir.
|
||||
- `browser_profile_open` por defecto NO pasa `--user-data-dir` (el perfil vive en
|
||||
`~/.config/chromium-cdp`, que el wrapper `/usr/bin/chromium` ya inyecta). Si el
|
||||
perfil está en otro user-data-dir, regístralo con `user_data_dir=<ruta>` y la
|
||||
función lo pasará explícito.
|
||||
- Se lanza vía `systemd-run --user --scope` a propósito: lanzar Chromium directo
|
||||
desde un proceso hijo da exit-144 en este entorno.
|
||||
- `secret_ref` NUNCA es el password. Si te ves tentado a meter la contraseña ahí,
|
||||
para: guárdala en `pass`/keepass y referencia el comando.
|
||||
@@ -0,0 +1,57 @@
|
||||
# consent — CMP / IAB TCF / data brokers
|
||||
|
||||
Operar banners de consentimiento (Consent Management Platforms) y el ecosistema IAB TCF:
|
||||
detectar qué CMP usa un sitio, leer cuántos *vendors* (data brokers) declara, aceptar el
|
||||
banner cuando hace falta y cruzar los IDs de vendor contra la Global Vendor List de IAB para
|
||||
nominar a cada broker y describir qué datos personales recopila.
|
||||
|
||||
Nació de la investigación `projects/databrokers/` (data brokers de la prensa española).
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `extract_cmp_tcf_py_browser` | `extract_cmp_tcf(url, *, port=9222, accept_first=False, llm_fallback=False, ...) -> dict` | Navega a `url` por CDP, detecta el CMP (Didomi/OneTrust/Sourcepoint/Quantcast/otro_tcf), lee `window.__tcfapi` y devuelve nº de vendors, propósitos, muro "pago o consientes" y `vendor_ids`. Con `accept_first` acepta el banner antes de leer; con `llm_fallback` recurre a `find_consent_controls_llm` si el clic por selector falla. |
|
||||
| `find_consent_controls_llm_py_browser` | `find_consent_controls_llm(*, port=9222, max_candidates=40, model="claude-haiku-4-5-20251001") -> dict` | Recolecta los controles clicables del banner (los marca con `data-fnllm="N"`) y pregunta a un LLM (haiku) cuál es Aceptar / Rechazar / Ver socios. Devuelve los selectores. Resuelve CMP con clases dinámicas/texto no estándar sin selectores hardcodeados. |
|
||||
| `fetch_iab_gvl_py_cybersecurity` | `fetch_iab_gvl(out_path="", url="", lang="") -> dict` | Descarga y parsea la Global Vendor List de IAB (catálogo maestro de vendors: nombre, propósitos, `dataDeclaration`, retención, política). Endpoint v3 con fallback v2. |
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
Escanear un medio, contar sus brokers y nombrarlos cruzando con la GVL:
|
||||
|
||||
```python
|
||||
import sys; sys.path.insert(0, "python/functions")
|
||||
from browser.extract_cmp_tcf import extract_cmp_tcf
|
||||
from cybersecurity.fetch_iab_gvl import fetch_iab_gvl
|
||||
|
||||
# 1. Catálogo maestro de vendors (una vez).
|
||||
gvl = fetch_iab_gvl(out_path="/tmp/gvl.json") # {status, vendors:{id:{name,purposes,...}}, ...}
|
||||
|
||||
# 2. Escanear un sitio (Chrome con CDP en el puerto indicado; perfil limpio para que salga el banner).
|
||||
# accept_first acepta el banner; llm_fallback usa haiku si el botón no encaja con selectores fijos.
|
||||
scan = extract_cmp_tcf("https://www.lavanguardia.com", port=9335,
|
||||
accept_first=True, llm_fallback=True)
|
||||
# scan -> {status, cmp:'didomi', n_vendors:1092, vendor_ids:[...], paywall_consent:True, ...}
|
||||
|
||||
# 3. Nominar los brokers de ese medio.
|
||||
nombres = [gvl["vendors"].get(str(v), {}).get("name", f"(vendor {v})") for v in scan["vendor_ids"]]
|
||||
```
|
||||
|
||||
Orquestador completo sobre un censo de dominios: `projects/databrokers/scanner/scan_all.py`
|
||||
(itera → `extract_cmp_tcf` → persiste → cruza con la GVL → Excel).
|
||||
|
||||
## Prerrequisitos
|
||||
|
||||
- Un Chrome/Chromium con remote debugging (CDP) en el puerto usado. Lánzalo aislado del navegador
|
||||
diario (no 9222) con su propio `user_data_dir`. **Perfil limpio**: una vez aceptado el banner,
|
||||
la cookie de consent persiste en el perfil y los re-escaneos ya no muestran banner.
|
||||
- `ask_llm` (grupo `claude-direct`) requiere el token OAuth de Claude Max en `~/.claude/.credentials.json`.
|
||||
|
||||
## Fronteras (lo que el grupo NO cubre)
|
||||
|
||||
- No extrae la lista de vendors de CMP cuyo `getTCData` no rellena `vendor.consents`/`legitimateInterests`
|
||||
por la vía estándar, ni de banners alojados en iframe (Sourcepoint): el clic desde el documento
|
||||
principal no alcanza el iframe.
|
||||
- No interpreta el `tcString` (qué propósitos consintió el usuario en concreto); solo el universo de
|
||||
vendors declarado. Para decodificar el TCString haría falta una pieza aparte.
|
||||
- No es un bloqueador ni un gestor de consentimiento propio: solo observa y mide.
|
||||
@@ -0,0 +1,80 @@
|
||||
# eda — Exploratory Data Analysis por tabla
|
||||
|
||||
Grupo de capacidad para perfilar tablas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, outliers).
|
||||
|
||||
El orquestador one-shot es `profile_table_py_pipelines`: "hazme un EDA de esta tabla" → un `TableProfile` completo + report markdown + JSON sidecar en `reports/`.
|
||||
|
||||
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Pureza | Qué hace |
|
||||
|---|---|---|
|
||||
| `summarize_table_duckdb_py_datascience` | impure | Corazón: `SUMMARIZE` push-down → esqueleto del `TableProfile` con perfil base por columna (tipo inferido, nulls, distinct exacto ≤200k filas, flags). Reusa `duckdb_query_readonly`. |
|
||||
| `describe_numeric_py_datascience` | pure | Bloque `numeric` sobre una muestra: min/max/mean/median/mode/std/cv, percentiles p1-p99, IQR, skew, kurtosis, outliers, %zeros/%neg, tipo de distribución, histograma. |
|
||||
| `summarize_categorical_py_datascience` | pure | Bloque `categorical`: top-k frecuencias, mode, distinct, entropía de Shannon (bits), imbalance, longitudes. |
|
||||
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/integer/decimal/...) sin LLM. Primera pasada barata. |
|
||||
| `column_quality_score_py_datascience` | pure | Score de calidad 0-100 (completeness/validity/consistency) + issues legibles para un `ColumnProfile`. |
|
||||
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown autosuficiente (Overview, Columnas, Numéricas con sparkline ASCII, Categóricas, Calidad). |
|
||||
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75) de una lista de floats. |
|
||||
| `profile_table_py_pipelines` | pipeline | Orquestador end-to-end: compone todo lo anterior, promueve tipos VARCHAR→numeric/datetime por contenido, y emite `TableProfile` + report markdown + JSON. |
|
||||
|
||||
## Contrato de datos
|
||||
|
||||
Todas las funciones producen/consumen el mismo shape (dict JSON), lo que desacopla cálculo, render y (futuro) LLM:
|
||||
|
||||
```
|
||||
TableProfile = {
|
||||
table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||
duplicate_rows, duplicate_pct, constant_cols:[str], all_null_cols:[str],
|
||||
null_cell_pct, type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||
columns:[ColumnProfile], correlations, key_candidates:[str],
|
||||
quality_score, llm, models
|
||||
}
|
||||
|
||||
ColumnProfile = {
|
||||
name, physical_type, inferred_type, # numeric|categorical|datetime|boolean|text|id
|
||||
semantic_type, count, n_rows, null_count, null_pct, empty_count, empty_pct,
|
||||
distinct_count, unique_pct, # *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||
flags:[constant|possible_id|high_cardinality|mostly_null],
|
||||
quality_score,
|
||||
numeric: {min,max,mean,median,mode,std,variance,cv,p1,p5,p25,p50,p75,p95,p99,iqr,
|
||||
skew,kurtosis,n_outliers,outlier_pct,zero_pct,negative_pct,distribution_type,
|
||||
histogram:[{lo,hi,count}]} | None,
|
||||
categorical: {top:[{value,count,pct}],mode,mode_pct,n_distinct,entropy,imbalance,
|
||||
len_mean,len_min,len_max} | None,
|
||||
datetime: {min,max,range_days,granularity,n_gaps,future_pct,monotonic} | None
|
||||
}
|
||||
```
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
EDA de una tabla DuckDB en una línea (escribe `reports/eda_<table>_<ts>.md` + `.json`):
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from pipelines.profile_table import profile_table
|
||||
|
||||
r = profile_table(os.path.expanduser("~/.fn_freelance/freelance.duckdb"), "freelance_projects")
|
||||
print(r["status"], r["report_md_path"])
|
||||
prof = r["profile"]
|
||||
print(prof["type_breakdown"], "key_candidates:", prof["key_candidates"], "calidad:", prof["quality_score"])
|
||||
```
|
||||
|
||||
La promoción de tipo por contenido resuelve el caso típico de scrapers/CSV donde los números y fechas llegan como `VARCHAR`: `bids` ('10','20') se detecta `integer` y se perfila como numérica (mean/median/percentiles); `scraped_at` se detecta `datetime_iso`.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO carga la tabla entera a RAM**: solo metadata SQL + una muestra (`sample`, default 5000) por columna. Para distribución exacta de una columna enorme, sube `sample` o consulta SQL directa.
|
||||
- **Distinct exacto solo hasta 200k filas**; por encima usa aproximado (HyperLogLog) capado a nº de filas.
|
||||
- **Solo DuckDB** por ahora (CSV/Parquet/Excel entran gratis vía `read_csv_auto`/`read_parquet`/`read_xlsx` cargándolos antes a DuckDB). PostgreSQL y BigQuery requieren adaptador (pendiente).
|
||||
- **No es estadística inferencial ni modelado**: es perfilado descriptivo. Correlaciones, modelos baratos (PCA/KMeans/IsolationForest) y capa LLM son fases siguientes del grupo.
|
||||
|
||||
## Roadmap (fases siguientes)
|
||||
|
||||
- **Correlación / asociación**: Spearman, Cramér's V, Theil's U, correlation ratio η², Mutual Information, VIF → `correlations` del `TableProfile`.
|
||||
- **Relaciones inter-tabla**: FK inference por containment, cardinalidad de relación, join graph (mermaid), star-schema hints → `profile_database`.
|
||||
- **Modelos baratos** (flag `--models`, sklearn/scipy): PCA 2D, KMeans + silhouette, Isolation Forest, feature importance, tests de normalidad, tendencia temporal.
|
||||
- **Capa LLM** (flag `--llm`, grupo `claude-direct`): data dictionary, resumen ejecutivo (qué es 1 fila + granularidad), flag PII/RGPD, limpieza sugerida, análisis sugeridos.
|
||||
- **Entrega notebook**: analysis Jupyter auto-generado y ejecutado en el navegador colaborativo.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Email — Gestionar cuentas de correo por IMAP + SMTP (tecnología propia)
|
||||
|
||||
Tag: `email`. Grupo de funciones Python (solo stdlib: `imaplib`, `smtplib`, `email`) para
|
||||
**leer, hacer CRUD y enviar correo hablando los protocolos directamente** — sin browser CDP
|
||||
y sin el MCP Gmail de claude.ai. Es la base de un sistema multi-proveedor de gestión de
|
||||
cuentas: una conexión IMAP por buzón + SMTP para envío, con las credenciales resueltas desde
|
||||
`pass`/vault por la capa de aplicación.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="email"`.
|
||||
|
||||
## Cuándo usar este grupo (y cuándo NO)
|
||||
|
||||
| Caso | Vía |
|
||||
|---|---|
|
||||
| Leer/buscar/clasificar/mover/borrar/enviar correo de forma programática y fiable, multi-cuenta | **Este grupo** (IMAP+SMTP directo). |
|
||||
| Leer correo *interactivo* del usuario en su sesión (códigos de verificación al instante en su Gmail logueado) | Browser MCP sobre Gmail web (perfil 9222). Ver memoria `correos-por-browser-no-mcp-gmail`. |
|
||||
| — | El MCP Gmail de `claude.ai` queda descartado en ambos casos (indexa con latencia). |
|
||||
|
||||
IMAP directo **no** sustituye al browser para el flujo interactivo del usuario; lo complementa
|
||||
para automatización fiable con credenciales propias.
|
||||
|
||||
## Autenticación
|
||||
|
||||
Usuario + **app-password** (NO OAuth). Gmail exige 2FA activado y un App Password de 16 chars
|
||||
(`myaccount.google.com/apppasswords`). Otros proveedores con IMAP/SMTP clásico (Dovecot,
|
||||
dominio propio) aceptan user+pass directo. La credencial se guarda en `pass`
|
||||
(`email/<cuenta>-apppass`) y la resuelve la capa app, **nunca** se hardcodea ni se pasa a
|
||||
estas funciones desde el código del registry.
|
||||
|
||||
**Outlook/Hotmail/Office365 NO entran por aquí**: Microsoft desactivó basic auth para
|
||||
IMAP/SMTP; requieren OAuth2 (pista aparte, no cubierta por este grupo hoy).
|
||||
|
||||
## Servidores comunes
|
||||
|
||||
| Proveedor | IMAP | SMTP |
|
||||
|---|---|---|
|
||||
| Gmail | `imap.gmail.com:993` (SSL) | `smtp.gmail.com:465` (SSL) o `587` (STARTTLS) |
|
||||
| Dominio propio (Dovecot+Postfix) | `mail.<dominio>:993` | `mail.<dominio>:465`/`587` |
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
Núcleo IMAP — el primer argumento `conn` de toda operación es el objeto `imaplib.IMAP4_SSL`
|
||||
vivo que produce `imap_connect`. Todas operan por **UID** (estable), nunca por número de
|
||||
secuencia, y devuelven `dict {"status": "ok"|"error", ...}` sin lanzar.
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [imap_connect_py_infra](../../python/functions/infra/imap_connect.md) | `imap_connect(host, port=993, user, password, mailbox='INBOX', use_ssl=True, timeout_s=30) -> dict` | Abre IMAP4_SSL, login + select(mailbox), devuelve el `conn` vivo + `num_messages`. Impura. |
|
||||
| [imap_list_mailboxes_py_infra](../../python/functions/infra/imap_list_mailboxes.md) | `imap_list_mailboxes(conn) -> dict` | Lista carpetas decodificando modified-UTF-7 (Gmail: `[Gmail]/Sent Mail`, etc.). Impura. |
|
||||
| [imap_search_py_infra](../../python/functions/infra/imap_search.md) | `imap_search(conn, criteria='UNSEEN', mailbox='') -> dict` | Busca por criterio IMAP crudo (UNSEEN, FROM, SINCE…) y devuelve UIDs. Impura. |
|
||||
| [imap_fetch_message_py_infra](../../python/functions/infra/imap_fetch_message.md) | `imap_fetch_message(conn, uid, mark_seen=False) -> dict` | Baja y parsea un mensaje (from/to/cc/subject/date/body_text/body_html/attachments). `BODY.PEEK` no marca leído. Impura. |
|
||||
| [imap_mark_seen_py_infra](../../python/functions/infra/imap_mark_seen.md) | `imap_mark_seen(conn, uid, seen=True) -> dict` | Añade/quita la bandera `\Seen`. Impura. |
|
||||
| [imap_move_message_py_infra](../../python/functions/infra/imap_move_message.md) | `imap_move_message(conn, uid, dest_mailbox) -> dict` | Mueve por UID (UID MOVE RFC 6851, fallback COPY+EXPUNGE). Impura. |
|
||||
| [imap_delete_message_py_infra](../../python/functions/infra/imap_delete_message.md) | `imap_delete_message(conn, uid, expunge=True) -> dict` | Marca `\Deleted` y opcionalmente EXPUNGE. Impura. |
|
||||
| [imap_save_draft_py_infra](../../python/functions/infra/imap_save_draft.md) | `imap_save_draft(conn, raw_rfc822, mailbox='[Gmail]/Drafts', flags='\Draft') -> dict` | Guarda un borrador (bytes MIME) vía APPEND. Impura. |
|
||||
|
||||
Construir + enviar (SMTP):
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [email_build_html_py_infra](../../python/functions/infra/email_build_html.md) | `email_build_html(from_addr, to, subject, body_html) -> EmailMessagePy` | Construye un mensaje HTML inmutable. Pura. |
|
||||
| [smtp_send_py_infra](../../python/functions/infra/smtp_send.md) | `smtp_send(cfg, from_addr, to, subject, body_html='', body_text='', cc, bcc, attachments, headers) -> None` | Conecta SMTP, arma MIME y envía en un paso (TLS/STARTTLS/claro). Impura. |
|
||||
|
||||
## Ejemplo canónico end-to-end
|
||||
|
||||
Conectar a Gmail con app-password resuelto desde `pass`, listar no leídos, leer el primero,
|
||||
marcarlo leído, y enviar una respuesta. Las funciones se componen en un heredoc Python que
|
||||
**importa** del registry (no reescribe protocolo):
|
||||
|
||||
```python
|
||||
import sys, os, subprocess
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.imap_connect import imap_connect
|
||||
from infra.imap_search import imap_search
|
||||
from infra.imap_fetch_message import imap_fetch_message
|
||||
from infra.imap_mark_seen import imap_mark_seen
|
||||
from infra.smtp_send import smtp_send, SMTPConfigPy
|
||||
|
||||
EMAIL = "gutierenmanuel15@gmail.com"
|
||||
# Credencial desde pass (o usar pass_get_secret del registry). NUNCA hardcodear.
|
||||
PW = subprocess.run(["pass", "show", "email/gmail-enmanuel-apppass"],
|
||||
capture_output=True, text=True).stdout.splitlines()[0]
|
||||
|
||||
# 1. Conectar (IMAP) — el conn vivo viaja dentro del dict
|
||||
c = imap_connect(host="imap.gmail.com", port=993, user=EMAIL, password=PW, mailbox="INBOX")
|
||||
assert c["status"] == "ok", c
|
||||
conn = c["conn"]
|
||||
|
||||
# 2. Buscar no leídos y leer el primero (PEEK: no marca leído)
|
||||
s = imap_search(conn, criteria="UNSEEN")
|
||||
print("no leídos:", s["count"])
|
||||
if s["uids"]:
|
||||
uid = s["uids"][0]
|
||||
m = imap_fetch_message(conn, uid)["message"]
|
||||
print(m["from"], "—", m["subject"])
|
||||
imap_mark_seen(conn, uid) # marcar leído
|
||||
|
||||
# 3. Enviar (SMTP) — mismo app-password
|
||||
smtp_send(
|
||||
SMTPConfigPy(host="smtp.gmail.com", port=465, username=EMAIL, password=PW, tls_mode="tls"),
|
||||
from_addr=EMAIL, to=["dest@example.com"],
|
||||
subject="Probando IMAP+SMTP propios", body_text="Enviado sin browser, protocolo directo.",
|
||||
)
|
||||
conn.logout() # cerrar siempre
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No gestiona la cuenta multi-proveedor**: estas son primitivas de protocolo. El registro
|
||||
de N cuentas (host/port/auth_type por buzón) y la resolución de credenciales desde `pass`
|
||||
son responsabilidad de una **app** (p. ej. `apps/mail_manager`), no de este grupo.
|
||||
- **No hace OAuth**: solo user+app-password. Outlook/Office365 (basic auth muerto) quedan fuera
|
||||
hasta que exista una función `*_oauth_token` dedicada.
|
||||
- **No reemplaza al browser para el flujo interactivo del usuario** (ver tabla arriba).
|
||||
- **`imap_save_draft` no construye el MIME**: recibe bytes RFC822 ya serializados; el caller
|
||||
los arma con `email.message.EmailMessage().as_bytes()` (stdlib) o con `email_build_*` +
|
||||
serialización.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`conn` es un objeto vivo dentro del dict**: estas funciones se componen en heredocs/apps
|
||||
Python, NO por `fn run` (que no puede serializar el socket). Cerrar siempre con `conn.logout()`.
|
||||
- **UID, no número de secuencia**: los seq se renumeran al borrar; los UID son estables
|
||||
mientras no cambie `UIDVALIDITY` del buzón.
|
||||
- **Gmail `\Deleted` ≠ borrar**: marcar `\Deleted` solo quita la etiqueta de la carpeta actual.
|
||||
Para borrar de verdad hay que **mover a `[Gmail]/Trash`** con `imap_move_message`.
|
||||
- **Nombres de carpeta Gmail** llevan prefijo `[Gmail]/` (`[Gmail]/Sent Mail`, `[Gmail]/Drafts`,
|
||||
`[Gmail]/Trash`, `[Gmail]/Spam`).
|
||||
- **App-password requiere 2FA** activado en la cuenta Google; sin 2FA no se puede generar.
|
||||
- **Charsets**: `imap_fetch_message` decodifica RFC 2047 en cabeceras y respeta el charset de
|
||||
cada parte del cuerpo; aun así correos malformados pueden traer texto degradado.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `python/.venv` (solo stdlib, sin dependencias nuevas).
|
||||
- App-password de cada cuenta guardado en `pass` (`email/<cuenta>-apppass`).
|
||||
- 2FA activado en las cuentas Google.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Capability: seo
|
||||
|
||||
SEO orientado a datos sobre Google Search Console (GSC): autenticar contra la Search Console
|
||||
API con una cuenta de servicio, extraer Search Analytics (impresiones, clicks, CTR, posición
|
||||
por query y página) y aterrizarlo en DuckDB (verdad acumulada) + Postgres (espejo para
|
||||
Metabase). Es la cadena de ingesta del proyecto `seo_analytics`.
|
||||
|
||||
La tesis del grupo: el SEO deja de hacerse a ciegas y se convierte en un problema de datos
|
||||
con loop medible — el dashboard señala la oportunidad (striking distance, CTR bajo, content
|
||||
decay), se aplica el cambio y se mide el impacto en la siguiente ingesta.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `gsc_auth_py_infra` | `gsc_auth(credentials_path="", subject="") -> service` | Autentica contra la Search Console API v1 con una service account JSON (scope `webmasters.readonly`). Fallback a env `GSC_SA_JSON`. Devuelve el `service` de googleapiclient listo para consultar. |
|
||||
| `pull_gsc_search_analytics_py_datascience` | `pull_gsc_search_analytics(service, site_url, start_date, end_date, dimensions=None, row_limit=25000, max_total_rows=0, search_type="web") -> list[dict]` | Extrae Search Analytics paginando (startRow) hasta agotar. Aplana cada fila (keys → nombres de dimensión + clicks/impressions/ctr/position). `dimensions` por defecto `["query","page"]`. |
|
||||
| `ingest_gsc_search_analytics_py_pipelines` | `ingest_gsc_search_analytics(site_url="", duckdb_path="", pg_dsn="", start_date="", end_date="", lookback_days=5, credentials_path="") -> dict` | Pipeline: auth → pull (dims date,query,page) → upsert idempotente en DuckDB → espejo a Postgres (`mode=replace`). Resuelve defaults de env (`GSC_SITE_URL`, `SEO_DSN`, `GSC_SA_JSON`). Lo invoca el DAG `seo-gsc-daily`. |
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# Greenfield: ver projects/seo_analytics/docs/SETUP.md para crear la service account,
|
||||
# verificar la propiedad en Search Console y darle acceso a la SA.
|
||||
|
||||
# 1. Variables (el .env del proyecto las agrupa)
|
||||
export GSC_SITE_URL="sc-domain:ejemplo.com"
|
||||
export SEO_DSN="postgresql://captacion:PASS@localhost:5433/seo"
|
||||
export GSC_SA_JSON="$HOME/.config/seo/gsc-sa.json"
|
||||
|
||||
# 2. Ingesta diaria (auth + pull + DuckDB + espejo Postgres) — la corre el DAG seo-gsc-daily
|
||||
python/.venv/bin/python3 python/functions/pipelines/ingest_gsc_search_analytics.py
|
||||
|
||||
# 3. Dashboards en Metabase (una vez): añade la DB seo + 4 cards + dashboard
|
||||
SEO_PG_PASS=... METABASE_USER=... METABASE_PASS=... \
|
||||
python/.venv/bin/python3 projects/seo_analytics/setup_metabase.py
|
||||
```
|
||||
|
||||
Uso desde Python, componiendo las tres:
|
||||
|
||||
```python
|
||||
import sys; sys.path.insert(0, "python/functions")
|
||||
from infra import gsc_auth
|
||||
from datascience import pull_gsc_search_analytics
|
||||
|
||||
svc = gsc_auth() # lee GSC_SA_JSON
|
||||
rows = pull_gsc_search_analytics(svc, "sc-domain:ejemplo.com",
|
||||
"2026-05-01", "2026-05-28",
|
||||
dimensions=["date", "query", "page"])
|
||||
print(len(rows), rows[0])
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO hace keyword research ni rank tracking externo**. GSC dice por qué keywords ya apareces
|
||||
en Google; descubrir keywords nuevas o medir SERP de competidores es otro trabajo (scrapers).
|
||||
- **NO escribe los dashboards**. Las cards/dashboard de Metabase los construye el script del
|
||||
proyecto `setup_metabase.py` componiendo el grupo `metabase`. Este grupo solo ingiere datos.
|
||||
- **NO gestiona el scheduling**. Eso es `dag_engine` (DAG `seo-gsc-daily`, grupo `scheduler`).
|
||||
- **NO cubre Bing/otros buscadores**. Solo Google Search Console.
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- Los datos de GSC llegan con **~2-3 días de lag**. El pipeline pide hasta hoy menos 3 días.
|
||||
- Google **anonimiza queries de baja frecuencia** (privacy threshold): la suma por query no
|
||||
cuadra con el total del sitio. Es esperado, no un bug.
|
||||
- El formato de `site_url` importa: `sc-domain:ejemplo.com` (propiedad de dominio) vs URL
|
||||
completa con esquema (propiedad de prefijo).
|
||||
- La service account accede porque su email está **añadido como usuario en Search Console**
|
||||
(Settings > Users), no por domain-wide delegation. El JSON de la SA es un secreto.
|
||||
- **DuckDB es la verdad** (upsert idempotente, acumula histórico); **Postgres es un espejo**
|
||||
que se regenera por `replace` en cada sync. No acumular en Postgres directamente.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Sitio verificado en Search Console + service account con acceso (ver SETUP.md del proyecto).
|
||||
- Stack Postgres + Metabase del proyecto `captacion_clientes` (contenedores `captacion-postgres`
|
||||
:5433 y `captacion-metabase` :3030), con la DB `seo` creada.
|
||||
- Deps Python `google-api-python-client` + `google-auth` (ya en el venv del registry).
|
||||
Reference in New Issue
Block a user