Compare commits

..

6 Commits

Author SHA1 Message Date
egutierrez 0e7b615a1e chore: auto-commit (2 archivos)
- CONVENTIONS.md
- tools/gen_osint_tools.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:18 +02:00
egutierrez cb7f6e92a0 chore: auto-commit (3 archivos)
- project.md
- reports/
- tools/import_google_contacts.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 21:56:57 +02:00
egutierrez ec9b70a72a feat(tools): import_contacts_vcf — backfill de import_key + enriquecimiento idempotente desde .vcf
Backfill de la clave de importacion (contact_import_key del registry) de los
contactos existentes + enriquecimiento aditivo desde un .vcf de Google
(telefonos/emails faltantes en contacts, direcciones en la persona enlazada).
Match por import_key con fallback por telefono. No destructivo: solo INSERT/UPDATE,
con assert de conteo intacto. Recupero los campos que el import original descarto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:48:03 +02:00
egutierrez d98127115b test(davx5): script reproducible de verificación DAVx5 ↔ Xandikos
12 checks: auth requerida (401 sin/con credencial mala), TLS Let's Encrypt + no-cleartext,
recepción de los 1065 contactos via PROPFIND+REPORT, integridad TEL/EMAIL de un contacto
conocido. Lee la credencial de pass en runtime (sin secretos hardcodeados). Validado en
emulador Pixel_API34 con DAVx5 4.5.14: recibió los 1065 contactos.
2026-06-13 01:24:36 +02:00
egutierrez db05c58893 docs(duckdb): inversión completada — DuckDB como fuente de verdad
Documenta la inversión implementada el 13/06/2026: ingest selectivo anti-pisado,
multi-valor en persons (634 fichas migradas), libretas (addressbooks), endpoints de
escritura estructurada, consumo desde osint_web tras el flag OSINT_DB_BACKEND (ON), las 5
funciones nuevas del registry, el runbook anti doble-verdad y el runtime systemd
(osint-db.service, Restart=always).
2026-06-13 00:57:14 +02:00
egutierrez fe280ec8ac feat(tools): sync bidireccional vault OSINT <-> Xandikos CardDAV
- sync_dav_to_osint.py (NUEVO): reverse sync Xandikos->vault. Trae contactos
  nuevos del movil (contexto: movil, dedup por tel/email) y ediciones de agenda
  (nombre/tel/email/aliases) PRESERVANDO la capa OSINT (relaciones/dni/contexto/
  fuente/tags). Estado persistente .sync_state.json (UID->etag/vault_mtime).
  Reconciliacion por etag; --dry-run (default) / --apply.
- sync_osint_to_dav.py: anade --check (audit read-only vault<->Xandikos: fichas
  sin vCard, vCards huerfanos, agendas divergentes) y optimiza build_existing_index
  con dav_get_collection (1 REPORT, ~9s->~0.5s) en vez del patron N+1.

Usa las funciones del registry: dav_get_collection, dav_delete_resource,
carddav_put_vcard, obsidian CRUD, pass_get_secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:30:27 +02:00
11 changed files with 4082 additions and 0 deletions
+5
View File
@@ -3,3 +3,8 @@ analysis/*/
vaults/* vaults/*
!vaults/.gitkeep !vaults/.gitkeep
!vaults/vault.yaml !vaults/vault.yaml
# Estado local del sync DAV (per-PC, no secretos pero efimero) y caches.
tools/.sync_state.json
tools/__pycache__/
**/__pycache__/
+110
View File
@@ -167,3 +167,113 @@ usa `tipo: organizacion` / `tipo: lugar` en vez de `tipo: persona`.
El migrador debe ser re-ejecutable: si una persona ya existe en osint con su slug, se El migrador debe ser re-ejecutable: si una persona ya existe en osint con su slug, se
actualiza (no se duplica). Los attachments ya movidos no se vuelven a mover. actualiza (no se duplica). Los attachments ya movidos no se vuelven a mover.
## 9. Scans de red (recon)
Todo escaneo de red de una investigación —WHOIS, RDAP, DNS, nmap, traceroute, ping— se
**archiva SIEMPRE en OSINT**. No existen scans sueltos: el resultado queda como nota navegable
en el vault y como fila consultable en la base de datos. Lo gestionan las funciones del grupo
de capacidad `recon` del registry (dominio `cybersecurity`); ver `docs/capabilities/recon.md`.
### 9.1 Nota del scan en el vault
Cada scan produce una nota Markdown bajo la carpeta del dominio escaneado:
```
dominios/<slug>/recon/<scan_type>-<YYYYMMDD-HHMM>.md
```
donde `<scan_type>` es uno de `whois | rdap | dns | nmap | traceroute | ping` y el timestamp
tiene granularidad de minuto. Frontmatter de la nota:
```yaml
tipo: scan-red
scan_tipo: whois # whois|rdap|dns|nmap|traceroute|ping
target: "ejemplo.com" # objetivo original (dominio, host o IP)
slug: ejemplo.com # slug del target (clave de la carpeta)
fecha: 2026-06-14T13:18:00 # ISO, momento del scan
herramienta: whois # CLI usada (whois, dig, nmap, ...)
tags: [scan-red, whois, recon]
```
Body: cabecera con target/tipo/herramienta/fecha, un `## Resumen` opcional con los campos
destacados del scan, y la salida cruda completa (`raw`) dentro de un bloque de código. La nota
es la **capa crítica**: si no se puede escribir, el guardado falla.
### 9.2 Tabla `network_scans` (DuckDB, service osint_db)
Además de la nota, cada scan se registra en la tabla `network_scans` (schema `main`) de la
base DuckDB que posee el service `osint_db` (single-writer), vía
`POST http://127.0.0.1:8771/api/scan`. Columnas:
| Columna | Qué |
|---|---|
| `id` | Identificador del scan |
| `target` | Objetivo original (dominio/host/IP) |
| `target_slug` | Slug del target (clave de agrupación) |
| `scan_type` | `whois \| rdap \| dns \| nmap \| traceroute \| ping` |
| `tool` | CLI usada (whois, dig, nmap, ...) |
| `scan_ts` | Timestamp ISO del scan |
| `note_path` | Ruta relativa de la nota en el vault |
| `summary` | JSON con los campos resumidos del scan |
| `created_at` | Timestamp de inserción |
Es la **capa best-effort**: si `osint_db` está caído o no expone el endpoint, el guardado
degrada a solo-nota (`registered=False` + aviso) sin fallar. El re-ingest del vault NO borra
`network_scans` —es una tabla de datos vivos, no derivada de las notas.
### 9.3 Cómo lanzar y guardar
El camino canónico es el pipeline one-shot del registry, que escanea y archiva en una sola
llamada:
```bash
cd /home/enmanuel/fn_registry
./fn run recon_osint <target> <scan_type> # p.ej. ./fn run recon_osint ejemplo.com whois
```
Para un nmap pesado (full-tcp, vuln, udp-top) lanzar en segundo plano por la duración:
```bash
nohup ./fn run recon_osint scanme.nmap.org nmap --profile full-tcp --timeout-s 7200 \
> /tmp/recon-fulltcp.log 2>&1 &
```
Alternativa atómica (controlas el scan y lo guardas aparte) desde Python, importando las
funciones del registry —no se reescriben:
```python
import sys; sys.path.insert(0, "python/functions")
from cybersecurity import dns_records
from cybersecurity.save_scan_to_osint import save_scan_to_osint
scan = dns_records("ejemplo.com")
if scan["status"] == "ok":
save_scan_to_osint("ejemplo.com", "dns", scan["raw"],
summary={"A": scan["records"].get("A")}, tool="dig")
```
### 9.4 Cómo consultar scans guardados
Desde una nota del vault, con un bloque `osintdb` (plugin osint-db) que consulta la tabla:
````markdown
```osintdb
SELECT scan_type, tool, scan_ts, note_path
FROM network_scans
WHERE target_slug = 'ejemplo.com'
ORDER BY scan_ts DESC
```
````
O contra el service directamente vía `/api/query` (mismo SQL). El slug del target se deriva
igual que en todo el vault:
```python
import re
slug = re.sub(r"[^a-z0-9._-]+", "-", target.lower())
```
> Nota: el `slug` de un dominio/host (p.ej. `ejemplo.com`, `192.168.1.10`) conserva puntos y
> guiones porque el set permitido es `[a-z0-9._-]`; difiere del slug de persona de la sección 2,
> que solo admite `[a-z0-9-]`.
+250
View File
@@ -0,0 +1,250 @@
# Stack DuckDB: la base de datos como fuente de verdad del project osint
Documento de arquitectura y operación del stack DuckDB + Obsidian del project `osint`.
Construido y verificado end-to-end el 12/06/2026. Sustituye (amplía) la decisión "KISS sin
BD intermedia" del issue 0172: la base DuckDB pasa a ser la **fuente de verdad estructurada**
del ecosistema OSINT, y el vault de Obsidian queda como capa de prosa + vista.
## Visión general
```
┌─────────────────────────────────────────┐
│ osint_db (FastAPI, 127.0.0.1:8771) │
│ dueño ÚNICO de data/osint.duckdb │
Xandikos ───────▶│ /api/ingest/dav │
(CardDAV/CalDAV) │ /api/ingest/vault ◀────── vault osint │
│ /api/query · /api/query/named │
│ /api/render/note ────────▶ notas .md │
└───────────────┬─────────────────────────┘
│ HTTP (requestUrl)
┌───────────────▼─────────────────────────┐
│ Plugin Obsidian "osint-db" │
│ code blocks ```osintdb en notas │
└─────────────────────────────────────────┘
```
Tres piezas:
| Pieza | Dónde | Qué hace |
|---|---|---|
| Service `osint_db` | `projects/osint/apps/osint_db/` | FastAPI local (solo 127.0.0.1, puerto 8771). Dueño único en escritura de `data/osint.duckdb`. Ingesta vault + DAV, sirve queries read-only y renderiza tablas dentro de notas. |
| Plugin `osint-db` | `projects/osint/apps/osint_obsidian_plugin/` | Plugin de Obsidian FINO (TypeScript, sin BD embebida). Ejecuta queries contra el service via HTTP y pinta tablas dentro de las notas. |
| Render headless | Funciones del registry | Proyecta resultados de query como tablas Markdown congeladas dentro de notas (bloques sentinel). Legible en móvil y en cualquier editor, sin plugin. |
## Modelo de datos: tres categorías de tablas
La regla central del diseño, en orden:
### 1. Tablas maestras con referencia a notas (schema `main`)
Fuente de verdad de las entidades estructuradas del vault. Cada fila lleva `note_path`
(path relativo de su nota dentro del vault `~/Obsidian/osint`).
| Tabla | Origen | Clave |
|---|---|---|
| `notes` | índice completo del vault (path, slug, tipo, title, mtime, frontmatter JSON) | `note_path` |
| `persons` | `personas/*.md` (nombre, aliases, sexo, fecha_nacimiento, dni, telefono, email, direccion, pais, contexto, fuente, dav_uid, tags) | `slug` |
| `organizations` | `organizaciones/*.md` | `slug` |
| `domains` | `dominios/*.md` | `slug` |
| `cases` | `casos/*.md` | `slug` |
| `places` | `lugares/*.md` | `slug` |
### 2. Tablas maestras DAV (schema `main`)
Eventos y contactos importados del servidor Xandikos (CardDAV/CalDAV). También fuente de
verdad. `contacts.note_path` enlaza el contacto con su ficha del vault cuando hay match
(por `dav_uid` extraído del campo `fuente` de la ficha, por teléfono o por email); puede
ser NULL (contacto sin ficha — visibles con la named query `contactos_sin_nota`).
| Tabla | Origen | Clave |
|---|---|---|
| `contacts` | colecciones CardDAV (uid, collection, etag, fn, tels, emails, raw, note_path) | `uid` |
| `events` | calendarios CalDAV (uid, calendar, dtstart/dtend, all_day, summary, location, rrule, raw) | `uid` |
### 3. Tablas derivadas (schema `derived`)
Datos computados/extra para consultar desde Obsidian. **REGLA DURA: ninguna tabla de
`derived` lleva columna que referencie notas** (`note_path` prohibido). Se reconstruyen
(DROP + CREATE) en cada ingest. Hay un test que verifica la regla contra
`information_schema`.
| Tabla | Qué |
|---|---|
| `derived.person_stats` | agregados de personas por contexto, país y tag |
| `derived.event_monthly` | conteo de eventos por calendario y mes |
| `derived.contact_link_quality` | contactos enlazados a ficha vs sin enlazar (solo números) |
## Ownership por campo (regla anti two-way-sync)
| Dato | Dueño | Dirección |
|---|---|---|
| Campos estructurados (entidades, contactos, eventos) | DuckDB | DB → nota (render) |
| Prosa libre de cada nota (cuerpo, notas de investigación) | Markdown | nunca se toca desde la DB |
| Frontmatter editado a mano en Obsidian | Markdown | se re-ingesta con `/api/ingest/vault` |
Los bloques generados en notas van entre sentinels y no se editan a mano; el resto de la
nota es del humano.
## Inversión completada (13/06/2026)
La dirección de la verdad quedó invertida: DuckDB es ahora la **fuente de verdad** de los
campos estructurados (personas, contactos, eventos), no un espejo. Cambios implementados:
- **Ingest selectivo (anti-pisado)**: `ingest_vault` ya NO hace DELETE+INSERT ciego sobre
`persons`. Si el `slug` ya existe en la DB, solo actualiza `note_path` + `extra_fm` (vía
`duckdb_upsert` con `update_cols` restringido); los campos OWNED por la DB no se pisan. Una
ficha nueva creada a mano en Obsidian se bootstrapea (INSERT completo). Verificado: un
centinela DB-owned sobrevive a un `POST /api/ingest/vault` sobre las 697 fichas.
- **Multi-valor**: `persons` ganó `telefonos`/`emails`/`direcciones`/`extra_fm` (JSON) —
migración `002_multivalue.sql`, backfill desde los singulares (que se mantienen por compat,
= primer elemento). `contacts.tels`/`emails` ya eran arrays. Las 634 fichas con dato se
materializaron a multi-valor (DB→nota, preservando la prosa).
- **Libretas/agendas de contactos**: tabla `addressbooks` (`003_addressbooks.sql`).
`ingest_dav` itera todas las libretas registradas. Crear libreta CardDAV nueva vía
`dav_make_addressbook` (extended MKCOL).
- **Endpoints de escritura estructurada** (ver tabla API): CRUD de person/contact/event,
addressbook/calendar, `/api/person/{slug}/render` (DB→nota) y `/api/push/dav`
(reconcilia DB→Xandikos). La escritura DB va bajo el write lock; el push DAV y el render
ocurren fuera de la transacción (no bloquean la DB con latencia de red).
- **`osint_web` consume `osint_db`** tras el feature flag `OSINT_DB_BACKEND` (en
`apps/osint_web/dev/feature_flags.json`, hoy **ON**): la vista Contactos lee/escribe contra
el service (DuckDB), con contactos multi-valor y libretas en la UI. Con el flag OFF vuelve
al camino histórico (vault `.md` + vCard Xandikos directo).
- **Funciones del registry nuevas** (grupo `duckdb`/`dav`): `duckdb_execute`, `duckdb_upsert`,
`dav_make_addressbook`, `dav_list_addressbooks`, `build_vcard`.
**Runbook (evitar doble-verdad):** con `OSINT_DB_BACKEND` ON, editar contactos/personas
SOLO por la app (osint_web → osint_db) o por la API de osint_db. No editar el mismo campo a
mano en el `.md` y por la app a la vez; el `.md` es la vista materializada desde la DB.
**Runtime:** `osint_db` corre como systemd user service `osint-db.service`
(`Restart=always`), no como proceso manual. Health: `curl 127.0.0.1:8771/api/health`.
## API del service (contrato)
Todas las respuestas son HTTP 200 con campo `status` en el body (`ok` | `error`); los
clientes parsean el body, no el código HTTP.
| Endpoint | Qué |
|---|---|
| `GET /api/health` | `{"status":"ok","db_path":"...","tables":N}` |
| `GET /api/tables` | catálogo: schema, nombre, kind (master/derived), row_count, columnas |
| `POST /api/query` | `{"sql":"SELECT ...","params":[],"max_rows":500}``{status, columns, rows, row_count, truncated}`. Solo lectura (conexión `read_only=True`). |
| `GET /api/queries` | catálogo de named queries (`server/named_queries.py`) |
| `POST /api/query/named` | `{"name":"personas_por_contexto","max_rows":500}` → misma shape que `/api/query` |
| `POST /api/ingest/vault` | escanea el vault completo, upserta maestras y reconstruye derivadas |
| `POST /api/ingest/dav` | baja colecciones Xandikos, upserta contacts/events, enlaza contactos con fichas |
| `POST /api/render/note` | `{"note_path":"tableros/x.md","block_id":"y","sql":...\|"query":...,"title":...}` → renderiza tabla Markdown dentro del bloque sentinel de la nota (la crea si no existe) |
| `POST/PUT/DELETE /api/person[/{slug}]` | CRUD de `persons` (multi-valor) + materializa la ficha (DB→nota) |
| `POST /api/person/{slug}/render` | DB→nota: escribe el frontmatter OWNED (listas) preservando `extra_fm` y la prosa del cuerpo |
| `POST/PUT/DELETE /api/contact[/{uid}]` | CRUD de `contacts` + push DB→Xandikos (`build_vcard` + `carddav_put_vcard` / `dav_delete_resource`) |
| `POST/PUT/DELETE /api/event[/{uid}]` | CRUD de `events` + push DB→Xandikos (`caldav_put_event` / `dav_delete_resource`) |
| `POST /api/addressbook` | crea una libreta CardDAV (`dav_make_addressbook`) + registra en `addressbooks` |
| `POST /api/calendar` | crea un calendario (`dav_make_calendar`) |
| `POST /api/push/dav` | reconcilia DB→Xandikos en bloque (contacts/events de la DB → servidor) |
Named queries incluidas: `personas_por_contexto`, `personas_recientes`, `eventos_proximos`,
`contactos_sin_nota`, `stats_personas`, `calidad_enlace_contactos`, `eventos_por_mes`.
## Plugin de Obsidian (`osint-db`)
Dentro de cualquier nota del vault, un code block con lenguaje `osintdb`:
````markdown
```osintdb
query: contactos_sin_nota
max_rows: 25
```
````
o SQL crudo:
````markdown
```osintdb
SELECT nombre, telefono, contexto, note_path
FROM persons
ORDER BY updated_at DESC
LIMIT 15
```
````
Comportamiento:
- Directivas soportadas al inicio del bloque: `query:` (named), `max_rows:`, `title:`.
Sin directivas, el bloque entero es SQL crudo contra `/api/query`.
- Las celdas de una columna `note_path` cuyo valor termina en `.md` se pintan como enlace
interno de Obsidian: click → abre la ficha. Así las tablas maestras enlazan a sus notas.
- Botón "Refrescar" por bloque. Errores del service se muestran en un callout; si el
service no responde, mensaje con hint de arranque.
- Settings del plugin: base URL (default `http://127.0.0.1:8771`) y `max_rows` default.
- Comando de paleta: "OSINT DB: insertar bloque de query".
- HTTP siempre via `requestUrl` de Obsidian (evita CORS). Desktop only.
## Render headless (sin plugin, legible en móvil)
Para tablas congeladas que viajan con la nota (sync móvil incluido), el service compone
tres funciones del registry:
1. `duckdb_query_readonly_py_infra` — ejecuta la query con conexión read-only.
2. `render_markdown_table_py_core` — filas → tabla Markdown GFM.
3. `upsert_sentinel_block_py_core` — inserta/reemplaza el bloque entre
`<!-- osintdb:begin id=X -->` y `<!-- osintdb:end id=X -->` de forma idempotente.
Ejemplo real: `tableros/db-personas.md` combina una tabla congelada (sentinel) y bloques
vivos del plugin en la misma nota.
```bash
curl -s -X POST http://127.0.0.1:8771/api/render/note \
-H 'Content-Type: application/json' \
-d '{"note_path":"tableros/db-personas.md","block_id":"personas","query":"personas_por_contexto","title":"Personas por contexto"}'
```
## Operación
```bash
# Arrancar el service (runtime manual, sin systemd por ahora)
cd /home/enmanuel/fn_registry/projects/osint/apps/osint_db
.venv/bin/python server/main.py # flags: --vault --db --port --host
# Re-ingestar (tras editar fichas a mano o tras cambios en Xandikos)
curl -s -X POST http://127.0.0.1:8771/api/ingest/vault
curl -s -X POST http://127.0.0.1:8771/api/ingest/dav
# Desplegar el plugin tras un cambio
cd /home/enmanuel/fn_registry/projects/osint/apps/osint_obsidian_plugin
pnpm build && ./deploy.sh # copia a .obsidian/plugins/osint-db/
```
Activación del plugin: ya está activado headless (id `osint-db` en
`.obsidian/community-plugins.json` del vault). Si Obsidian arranca en Restricted mode la
primera vez, un toggle manual único en Settings → Community plugins.
Credenciales DAV: `pass dav/xandikos-enmanuel` (via `pass_get_secret_py_infra`). La config
de colecciones espeja `tools/sync_dav_to_osint.py`.
## Gotchas
- **Single-writer DuckDB**: solo el service escribe la base. Cualquier otro proceso lee con
`read_only=True` (`duckdb_query_readonly`) o pasa por la API HTTP. Una lectura durante un
ingest puede devolver `status:error` momentáneo por el lock exclusivo; reintentar.
- **Versión del motor**: no abrir el `.duckdb` con CLIs/WASM de versión distinta a la del
venv (1.5.x). El formato de archivo puede divergir entre versiones mayores.
- **`read_only=True` exige que el archivo exista** — no crea bases nuevas.
- Los bloques sentinel no se editan a mano: el siguiente render los pisa. La prosa fuera de
los sentinels nunca se toca.
- Migraciones del schema: archivos numerados `migrations/NNN_*.sql` con tabla `_migrations`
(regla `db_migrations` del registry). Nunca borrar la base para "arreglar" el schema.
## Cifras del primer ingest real (12/06/2026)
- Vault: 1175 notes, 697 persons, 367 organizations, 9 places.
- DAV: 1065 contacts (704 enlazados a ficha, 361 sin nota), 98 events.
## Referencias
- Apps: `apps/osint_db/app.md` (service, e2e_checks) y `apps/osint_obsidian_plugin/app.md`
(build, deploy, uso).
- Grupos de capacidad del registry: `docs/capabilities/duckdb.md` y
`docs/capabilities/obsidian.md` (en el repo `fn_registry`).
- Convenciones del vault: `CONVENTIONS.md` (este project).
- Sync DAV ↔ vault preexistente: `tools/sync_dav_to_osint.py` / `tools/sync_osint_to_dav.py`.
+10
View File
@@ -25,6 +25,16 @@ El CRUD del vault se hace con el grupo de funciones del registry `obsidian`
alimenta las investigaciones, ver el grupo `web-proxy` y el tooling de browser del project alimenta las investigaciones, ver el grupo `web-proxy` y el tooling de browser del project
`web_scraping`. `web_scraping`.
### Stack DuckDB (fuente de verdad estructurada)
Desde el 12/06/2026 los datos estructurados del project (entidades del vault + contactos y
eventos de Xandikos) viven en una base DuckDB que es la fuente de verdad, con el vault como
capa de prosa + vista. Tres piezas: service `apps/osint_db` (FastAPI 127.0.0.1:8771, dueño
único de la base), plugin de Obsidian `apps/osint_obsidian_plugin` (bloques ```osintdb con
queries en vivo dentro de notas) y render headless de tablas Markdown congeladas via bloques
sentinel. Arquitectura, contrato API, modelo de tablas (maestras con `note_path`, maestras
DAV y derivadas sin referencias a notas) y operacion: ver `DUCKDB_STACK.md`.
### Relacion con web_scraping ### Relacion con web_scraping
`web_scraping` aporta la captura/automatizacion (perfiles Chromium, CDP, proxy, flow replay). `web_scraping` aporta la captura/automatizacion (perfiles Chromium, CDP, proxy, flow replay).
@@ -0,0 +1,9 @@
# Report — Migración persons multi-valor (20260613-0046)
- Fichas a migrar (con tel/email/direccion): 634
- Render DB→nota OK: 634
- Fallos: 0
- Duración: 7.1s
- Backup: projects/osint/apps/osint_db/data/backups/vault-md-20260613*.tgz
Cada ficha gana `telefonos: [...]`, `emails: [...]`, `direcciones: [...]` en el frontmatter (singulares mantenidos por compat); el cuerpo (prosa) se preserva.
+863
View File
@@ -0,0 +1,863 @@
#!/usr/bin/env python3
"""Genera las fichas de herramientas OSINT en el vault osint + el MOC indice.
Usa la funcion del registry create_obsidian_note_py_obsidian para escribir cada
ficha como .md plano con frontmatter YAML (sin abrir la GUI de Obsidian).
Fuente de verdad: la lista TOOLS de abajo. Idempotente con overwrite=True.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "python", "functions"))
# Permitir ejecucion desde la raiz del repo tambien:
sys.path.insert(0, os.path.join("/home/enmanuel/fn_registry", "python", "functions"))
from obsidian import create_obsidian_note # noqa: E402
VAULT = "/home/enmanuel/Obsidian/osint"
# Etiquetas de categoria legibles para el MOC.
CAT_LABELS = {
"buscador": "Buscadores y meta-buscadores",
"identidad": "Identidad — username, email, telefono, brechas",
"social": "Redes sociales y rostros",
"dominio": "Dominios e infraestructura (pasivo)",
"geolocalizacion": "Geolocalizacion (imagen, mapas, satelite, metadatos, IP)",
"imagen-forense": "Verificacion forense de imagen y video",
"archivo": "Archivo e historico web",
"empresa-es": "Empresas y registros (España)",
"framework": "Frameworks y portales de referencia",
}
CAT_ORDER = list(CAT_LABELS.keys())
# Cada tool: slug, nombre, url, cat, coste, reg (requiere cuenta), amb, antibot
# (bloquea curl / WAF, necesita navegador real), tags extra, para, como, gotchas.
TOOLS = [
# ---------- Buscadores ----------
dict(slug="google-dorking", nombre="Google Dorking", url="https://www.google.com/advanced_search",
cat="buscador", coste="free", reg=False, amb="global", antibot=False,
tags=["dorks", "buscador"],
para="Busqueda avanzada con operadores (site:, filetype:, intext:, inurl:) para encontrar documentos, perfiles y filtraciones indexadas.",
como="Combina operadores: `site:linkedin.com \"nombre\"`, `filetype:pdf \"empresa\"`, `intext:\"email@dominio\"`.",
gotchas=["Google limita resultados y muestra captcha si detecta scraping.",
"Los dorks no son ilegales pero acceder a lo que exponen puede serlo segun contexto."]),
dict(slug="bing", nombre="Bing Search", url="https://www.bing.com",
cat="buscador", coste="free", reg=False, amb="global", antibot=False,
tags=["buscador"],
para="Segundo indice: a veces conserva resultados que Google ya purgo y soporta operadores propios.",
como="Usa operadores `site:`, `ip:`, `feed:`. Util como contraste de Google.",
gotchas=["Indice menor que Google; complementario, no sustituto."]),
dict(slug="yandex-search", nombre="Yandex Search", url="https://yandex.com",
cat="buscador", coste="free", reg=False, amb="global", antibot=False,
tags=["buscador", "ruso"],
para="Buscador ruso con cobertura fuerte de contenido del este de Europa y Asia que Google indexa peor.",
como="Busqueda normal + su reverse image (ver ficha yandex-images) es la mejor del mercado.",
gotchas=["Interfaz/resultados sesgados a region; usa yandex.com (no .ru) para ingles."]),
dict(slug="duckduckgo", nombre="DuckDuckGo", url="https://duckduckgo.com",
cat="buscador", coste="free", reg=False, amb="global", antibot=False,
tags=["buscador", "privacidad"],
para="Buscador sin tracking; sus bangs (!g, !w) saltan a otros indices rapido.",
como="Usa `!` bangs para redirigir busquedas; util para consultas sin personalizacion.",
gotchas=["Indice propio limitado; mezcla resultados de Bing."]),
dict(slug="brave-search", nombre="Brave Search", url="https://search.brave.com",
cat="buscador", coste="free", reg=False, amb="global", antibot=False,
tags=["buscador", "privacidad"],
para="Indice independiente (no depende de Google/Bing); buen contraste para descubrir fuentes distintas.",
como="Busqueda directa; ofrece API de pago para automatizar.",
gotchas=["Indice mas joven; cobertura desigual por idioma."]),
dict(slug="intelligence-x", nombre="Intelligence X", url="https://intelx.io",
cat="buscador", coste="freemium", reg=True, amb="global", antibot=False,
tags=["leaks", "pastes", "darkweb"],
para="Motor que indexa filtraciones, pastes, documentos, darkweb y datos historicos por selector (email, dominio, BTC, IP).",
como="Busca un selector (email/dominio); la vista previa es gratis, el contenido completo requiere creditos.",
gotchas=["Manejar datos de brechas puede tener implicaciones legales segun jurisdiccion.",
"Cuenta gratuita muy limitada."]),
# ---------- Identidad ----------
dict(slug="whatsmyname", nombre="WhatsMyName", url="https://whatsmyname.app",
cat="identidad", coste="free", reg=False, amb="global", antibot=False,
tags=["username", "enumeracion"],
para="Enumera en que cientos de sitios existe un nombre de usuario concreto.",
como="Escribe el username; devuelve presencia por sitio. Mismo dataset que usan herramientas CLI.",
gotchas=["Falsos positivos: algunos sitios devuelven 200 para cualquier usuario.",
"No confirma que sea la MISMA persona, solo que el handle existe."]),
dict(slug="sherlock", nombre="Sherlock (CLI)", url="https://github.com/sherlock-project/sherlock",
cat="identidad", coste="free", reg=False, amb="global", antibot=False,
tags=["username", "cli"],
para="Herramienta CLI que busca un username en 400+ redes sociales.",
como="`sherlock <username>`; genera lista de URLs donde el handle existe.",
gotchas=["Falsos positivos frecuentes; verificar a mano.",
"Requiere instalacion local (pip/docker)."]),
dict(slug="maigret", nombre="Maigret (CLI)", url="https://github.com/soxoj/maigret",
cat="identidad", coste="free", reg=False, amb="global", antibot=False,
tags=["username", "cli"],
para="Sucesor mas potente de Sherlock: 2500+ sitios y extrae datos del perfil (no solo presencia).",
como="`maigret <username> --html`; genera informe con perfiles y metadatos extraidos.",
gotchas=["Mas lento por la cobertura; usar --top-sites para acotar.",
"Instalacion local requerida."]),
dict(slug="namechk", nombre="Namechk", url="https://namechk.com",
cat="identidad", coste="free", reg=False, amb="global", antibot=True,
tags=["username", "dominio"],
para="Comprueba disponibilidad de un username (y dominios) en muchas plataformas a la vez.",
como="Escribe el handle; marca ocupado/libre por servicio.",
gotchas=["Pensado para branding, no para OSINT: no enlaza al perfil.",
"Protegido por WAF; necesita navegador real (curl da 403)."]),
dict(slug="instantusername", nombre="Instant Username Search", url="https://instantusername.com",
cat="identidad", coste="free", reg=False, amb="global", antibot=True,
tags=["username"],
para="Chequeo de username en tiempo real sobre decenas de servicios.",
como="Escribe el handle; resultados incrementales segun teclea.",
gotchas=["Cobertura menor que Maigret.",
"WAF: necesita navegador real."]),
dict(slug="hunter-io", nombre="Hunter.io", url="https://hunter.io",
cat="identidad", coste="freemium", reg=True, amb="global", antibot=False,
tags=["email", "empresa"],
para="Encuentra y verifica direcciones de correo asociadas a un dominio corporativo, con el patron (nombre.apellido@).",
como="Introduce un dominio; devuelve correos conocidos + patron. Verificador de entregabilidad incluido.",
gotchas=["Plan gratis pocas busquedas/mes.",
"Solo correos corporativos publicos, no personales."]),
dict(slug="emailrep", nombre="EmailRep", url="https://emailrep.io",
cat="identidad", coste="freemium", reg=True, amb="global", antibot=False,
tags=["email", "reputacion"],
para="Perfil de reputacion de un email: antiguedad, perfiles sociales ligados, si aparece en brechas, si es desechable.",
como="Consulta el email via web o API; devuelve senales de riesgo y presencia.",
gotchas=["API key gratuita con cuota baja.",
"Datos agregados, no siempre actuales."]),
dict(slug="epieos", nombre="Epieos", url="https://epieos.com",
cat="identidad", coste="freemium", reg=False, amb="global", antibot=True,
tags=["email", "telefono", "google-account"],
para="A partir de un email o telefono revela cuenta de Google asociada (nombre, foto, reviews, calendario publico) y servicios vinculados.",
como="Introduce email o telefono; muestra Google account, Gravatar y servicios donde esta registrado.",
gotchas=["Protegido por DataDome (captcha); necesita navegador real.",
"Funciones avanzadas de pago."]),
dict(slug="holehe", nombre="holehe (CLI)", url="https://github.com/megadose/holehe",
cat="identidad", coste="free", reg=False, amb="global", antibot=False,
tags=["email", "cli"],
para="Comprueba en que 120+ servicios esta registrado un email, sin alertar al titular.",
como="`holehe email@dominio`; marca used/not-used por servicio via flujo de recuperacion.",
gotchas=["Algunos modulos quedan obsoletos cuando los sitios cambian su login.",
"Instalacion local requerida."]),
dict(slug="haveibeenpwned", nombre="Have I Been Pwned", url="https://haveibeenpwned.com",
cat="identidad", coste="freemium", reg=False, amb="global", antibot=False,
tags=["brechas", "email"],
para="Indica si un email aparece en brechas de datos publicas conocidas y en cuales.",
como="Introduce el email en la web; la API (para automatizar) es de pago.",
gotchas=["No muestra la contraseña, solo la brecha.",
"API key de pago; el chequeo web es gratis."]),
dict(slug="dehashed", nombre="DeHashed", url="https://dehashed.com",
cat="identidad", coste="pago", reg=True, amb="global", antibot=True,
tags=["brechas", "credenciales"],
para="Buscador de credenciales filtradas por email, usuario, nombre, telefono o IP, con el dato concreto de la brecha.",
como="Busca un selector; muestra registros (a veces con contraseña en claro/hash). Requiere suscripcion.",
gotchas=["Uso de credenciales filtradas: cuidado legal y etico.",
"De pago; WAF, navegador real."]),
dict(slug="leakcheck", nombre="LeakCheck", url="https://leakcheck.io",
cat="identidad", coste="freemium", reg=True, amb="global", antibot=False,
tags=["brechas", "credenciales"],
para="Alternativa a DeHashed: busca apariciones en brechas por email, usuario, telefono o dominio.",
como="Introduce el selector; vista parcial gratis, detalle con plan.",
gotchas=["Mismas precauciones legales que cualquier base de brechas."]),
dict(slug="phoneinfoga", nombre="PhoneInfoga (CLI)", url="https://github.com/sundowndev/phoneinfoga",
cat="identidad", coste="free", reg=False, amb="global", antibot=False,
tags=["telefono", "cli"],
para="Recon pasivo de numeros de telefono: operador, tipo de linea, pais y dorks automaticos para rastrear el numero.",
como="`phoneinfoga scan -n +34...`; genera footprint y enlaces de busqueda.",
gotchas=["No 'hackea' el numero; solo agrega datos publicos.",
"Instalacion local; algunos scanners requieren API keys."]),
dict(slug="truecaller", nombre="Truecaller", url="https://www.truecaller.com",
cat="identidad", coste="freemium", reg=True, amb="global", antibot=False,
tags=["telefono", "identificacion"],
para="Identifica el nombre asociado a un numero de telefono via su base colaborativa.",
como="Buscar el numero en web/app (requiere login). Util para name lookup.",
gotchas=["Requiere cuenta y a veces app movil.",
"Datos aportados por usuarios: pueden ser erroneos o estar desactualizados."]),
# ---------- Social ----------
dict(slug="social-searcher", nombre="Social Searcher", url="https://www.social-searcher.com",
cat="social", coste="freemium", reg=False, amb="global", antibot=False,
tags=["redes", "monitorizacion"],
para="Busca menciones publicas de una palabra/usuario en varias redes a la vez y analiza sentimiento.",
como="Introduce termino/handle; agrega posts publicos recientes.",
gotchas=["Solo contenido publico e indexado recientemente.",
"Cuota gratuita limitada por dia."]),
dict(slug="instaloader", nombre="Instaloader (CLI)", url="https://instaloader.github.io",
cat="social", coste="free", reg=False, amb="global", antibot=False,
tags=["instagram", "cli"],
para="Descarga posts, stories, bio y metadatos publicos de perfiles de Instagram de forma reproducible.",
como="`instaloader profile <usuario>`; baja media + JSON de metadatos.",
gotchas=["Instagram limita por rate y puede pedir login; usar con moderacion.",
"Loguearse con tu cuenta deja de ser pasivo y arriesga baneo."]),
dict(slug="x-advanced-search", nombre="X (Twitter) Advanced Search", url="https://x.com/search-advanced",
cat="social", coste="free", reg=True, amb="global", antibot=False,
tags=["twitter", "x"],
para="Filtra tweets por usuario, fecha, palabras exactas, ubicacion y tipo, sin scrapear.",
como="Usa operadores `from:`, `since:`, `until:`, `near:`, `geocode:`.",
gotchas=["X ahora exige login para ver resultados.",
"Busqueda geo (`geocode:`) cada vez mas limitada."]),
dict(slug="pimeyes", nombre="PimEyes", url="https://pimeyes.com",
cat="social", coste="pago", reg=True, amb="global", antibot=False,
tags=["rostro", "reverse-face"],
para="Buscador de caras: sube una foto y encuentra otras apariciones del mismo rostro en la web.",
como="Sube la imagen del rostro; muestra coincidencias. Ver donde aparecen requiere plan de pago.",
gotchas=["Implicaciones de privacidad serias; uso responsable.",
"Ver URLs de las coincidencias es de pago."]),
dict(slug="facecheck-id", nombre="FaceCheck.ID", url="https://facecheck.id",
cat="social", coste="freemium", reg=True, amb="global", antibot=False,
tags=["rostro", "reverse-face"],
para="Reverse face search orientado a redes y noticias; alternativa a PimEyes.",
como="Sube el rostro; devuelve coincidencias con score. Detalle completo con creditos.",
gotchas=["Resultados con ruido; verificar siempre.",
"Mismas cautelas de privacidad que PimEyes."]),
# ---------- Dominio ----------
dict(slug="shodan", nombre="Shodan", url="https://www.shodan.io",
cat="dominio", coste="freemium", reg=True, amb="global", antibot=False,
tags=["infra", "puertos", "iot"],
para="Motor de busqueda de dispositivos conectados: puertos, servicios, banners, camaras, ICS, ya indexados (no escaneas tu).",
como="Busca `hostname:`, `org:`, `port:`, `country:`. La consulta lee el indice de Shodan, es pasivo.",
gotchas=["Cuenta gratis con filtros/resultados limitados.",
"El dato puede estar cacheado/desactualizado."]),
dict(slug="censys", nombre="Censys Search", url="https://search.censys.io",
cat="dominio", coste="freemium", reg=True, amb="global", antibot=True,
tags=["infra", "certificados", "hosts"],
para="Inventario de hosts y certificados en internet; muy fuerte para mapear infraestructura y certificados de una org.",
como="Busca por host, certificado o dominio; pivota por fingerprint de cert.",
gotchas=["WAF: navegador real.",
"Free tier con cuota de consultas."]),
dict(slug="fofa", nombre="FOFA", url="https://fofa.info",
cat="dominio", coste="freemium", reg=True, amb="global", antibot=False,
tags=["infra", "ciberespacio"],
para="Buscador chino tipo Shodan/Censys; util para encontrar assets que los otros no indexan.",
como="Sintaxis `domain=`, `ip=`, `title=`. Resultados del indice (pasivo).",
gotchas=["Interfaz parcialmente en chino.",
"Free tier limitado."]),
dict(slug="crtsh", nombre="crt.sh", url="https://crt.sh",
cat="dominio", coste="free", reg=False, amb="global", antibot=True,
tags=["certificados", "subdominios"],
para="Consulta Certificate Transparency: revela subdominios de un dominio a partir de los certificados emitidos.",
como="`https://crt.sh/?q=%25.dominio.com` lista todos los subdominios certificados.",
gotchas=["Servidor a veces lento o caido (curl da timeout); reintentar.",
"Solo ve dominios con certificado emitido."]),
dict(slug="securitytrails", nombre="SecurityTrails", url="https://securitytrails.com",
cat="dominio", coste="freemium", reg=True, amb="global", antibot=True,
tags=["dns", "historico", "whois"],
para="DNS e historico WHOIS pasivo: registros DNS actuales e historicos, subdominios, cambios de propietario.",
como="Busca el dominio; ve historico A/MX/NS y subdominios. API para automatizar.",
gotchas=["Free tier muy limitado.",
"WAF: navegador real."]),
dict(slug="dnsdumpster", nombre="DNSDumpster", url="https://dnsdumpster.com",
cat="dominio", coste="free", reg=False, amb="global", antibot=False,
tags=["dns", "subdominios", "mapa"],
para="Mapa DNS gratuito de un dominio: subdominios, registros MX/NS y grafo de hosts.",
como="Introduce el dominio; genera tabla + grafo de la superficie DNS.",
gotchas=["Datos pasivos, no exhaustivos.",
"Limite de consultas por dia."]),
dict(slug="viewdns", nombre="ViewDNS.info", url="https://viewdns.info",
cat="dominio", coste="freemium", reg=False, amb="global", antibot=False,
tags=["dns", "whois", "reverse-ip"],
para="Caja de herramientas DNS/WHOIS: reverse IP (que dominios comparten IP), whois, propagacion, historico.",
como="Elige la herramienta (reverse IP lookup, whois history) e introduce dominio/IP.",
gotchas=["API de pago; web gratis con limites.",
"Reverse IP incompleto en hostings compartidos grandes."]),
dict(slug="urlscan-io", nombre="urlscan.io", url="https://urlscan.io",
cat="dominio", coste="freemium", reg=False, amb="global", antibot=False,
tags=["url", "scan", "screenshot"],
para="Analiza una URL en sandbox: capturas, recursos cargados, dominios contactados, tecnologias. La busqueda de scans previos es 100% pasiva.",
como="Busca el dominio en la pestaña Search para ver scans publicos previos sin lanzar uno nuevo.",
gotchas=["Lanzar un scan publico es visible para terceros; usa 'unlisted/private' si no quieres alertar.",
"Un scan activo toca el sitio objetivo (deja de ser pasivo)."]),
# ---------- Geolocalizacion ----------
dict(slug="yandex-images", nombre="Yandex Images (reverse)", url="https://yandex.com/images/",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["reverse-image", "rostro", "lugar"],
para="La mejor busqueda inversa de imagenes para lugares y rostros; clave para geolocalizar fotos.",
como="Sube la foto o pega su URL; Yandex sugiere imagenes y lugares visualmente similares.",
gotchas=["Sesgo a contenido del este; usar junto a Google Lens.",
"Puede pedir captcha tras varias subidas."]),
dict(slug="google-lens", nombre="Google Lens", url="https://lens.google.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["reverse-image", "objetos", "texto"],
para="Reconoce objetos, texto, productos y lugares en una imagen; bueno para identificar carteles, logos, edificios.",
como="Sube la imagen; recorta la zona de interes (cartel, fachada) para afinar.",
gotchas=["Mejor para objetos/texto que para coincidencia exacta de foto.",
"Combinar con Yandex para lugares."]),
dict(slug="bing-visual-search", nombre="Bing Visual Search", url="https://www.bing.com/visualsearch",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["reverse-image"],
para="Tercera busqueda inversa para contrastar Yandex y Google.",
como="Sube la imagen; revisa coincidencias y paginas que la contienen.",
gotchas=["Cobertura menor; usar como tercera opinion."]),
dict(slug="tineye", nombre="TinEye", url="https://tineye.com",
cat="geolocalizacion", coste="freemium", reg=False, amb="global", antibot=False,
tags=["reverse-image", "origen"],
para="Reverse image enfocado al ORIGEN y primera aparicion de una imagen (no a contenido similar).",
como="Sube la imagen; ordena por 'oldest' para encontrar la fuente original.",
gotchas=["No busca caras/objetos similares, solo la misma imagen y ediciones.",
"Indice mas pequeño que Yandex."]),
dict(slug="geospy", nombre="GeoSpy AI", url="https://geospy.ai",
cat="geolocalizacion", coste="freemium", reg=True, amb="global", antibot=False,
tags=["geolocalizacion", "ia", "lugar"],
para="Estima la ubicacion de una foto mediante IA, incluso sin metadatos, a partir de pistas visuales.",
como="Sube la imagen; devuelve una hipotesis de pais/ciudad con probabilidad.",
gotchas=["Estimacion probabilistica: verificar siempre con mapas/street view.",
"Acceso completo de pago."]),
dict(slug="google-earth", nombre="Google Earth Pro", url="https://earth.google.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["satelite", "historico", "3d"],
para="Imagenes satelitales con HISTORICO temporal y vista 3D; clave para datar cambios y medir.",
como="Usa la barra de tiempo para ver la misma zona en distintas fechas; mide distancias y alturas.",
gotchas=["Resolucion y fechas varian mucho por zona.",
"Version Pro de escritorio tiene mas herramientas que la web."]),
dict(slug="google-maps", nombre="Google Maps + Street View", url="https://maps.google.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["mapa", "street-view", "negocios"],
para="Mapa, vista de calle con historico, reseñas y fotos de usuarios para confirmar y datar una ubicacion.",
como="En Street View usa el reloj para ver capturas antiguas; revisa fotos de reseñas para interiores.",
gotchas=["Street View no cubre todo; combinar con Mapillary.",
"Las fotos de usuarios pueden estar mal ubicadas."]),
dict(slug="bing-maps", nombre="Bing Maps (Bird's Eye)", url="https://www.bing.com/maps",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["mapa", "oblicua", "satelite"],
para="Vista oblicua 'Bird's Eye' (45 grados) que muestra fachadas que el cenital no ve.",
como="Activa 'Bird's Eye'; rota la vista para ver los cuatro lados de un edificio.",
gotchas=["Cobertura oblicua limitada a ciertas ciudades."]),
dict(slug="yandex-maps", nombre="Yandex Maps", url="https://yandex.com/maps",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["mapa", "street-view"],
para="Street view (panoramas) y mapas con cobertura fuerte en Rusia, Turquia, Asia central y este de Europa.",
como="Activa panoramas; util donde Google Street View no llega.",
gotchas=["Cobertura floja en Europa occidental.",
"Interfaz parcialmente en ruso."]),
dict(slug="mapillary", nombre="Mapillary", url="https://www.mapillary.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=True,
tags=["street-level", "crowdsourced"],
para="Imagenes a nivel de calle aportadas por usuarios; cubre rutas y zonas que Street View ignora.",
como="Navega el mapa; las lineas verdes son tramos con fotos. Filtra por fecha.",
gotchas=["Calidad y cobertura desiguales (depende de aportaciones).",
"WAF en la home; navegador real."]),
dict(slug="kartaview", nombre="KartaView", url="https://kartaview.org",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["street-level", "crowdsourced", "open"],
para="Alternativa abierta a Mapillary con imagenes a nivel de calle crowdsourced.",
como="Explora el mapa para tramos fotografiados; descarga libre.",
gotchas=["Cobertura menor que Mapillary."]),
dict(slug="openstreetmap", nombre="OpenStreetMap", url="https://www.openstreetmap.org",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["mapa", "open", "datos"],
para="Mapa libre con datos crudos etiquetados (tipo de edificio, comercio, altura) consultables.",
como="Usa el editor/inspector para ver tags de un elemento; base para Overpass.",
gotchas=["Detalle depende de la comunidad local."]),
dict(slug="overpass-turbo", nombre="Overpass Turbo", url="https://overpass-turbo.eu",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["consulta", "osm", "features"],
para="Consulta los datos de OSM por tipo de objeto: 'todas las gasolineras / antenas / iglesias en esta zona'.",
como="Escribe una query Overpass QL y ejecutala sobre el area visible; ideal para acotar candidatos al geolocalizar.",
gotchas=["Curva de aprendizaje de la sintaxis QL.",
"Solo encuentra lo que esta etiquetado en OSM."]),
dict(slug="wikimapia", nombre="Wikimapia", url="https://wikimapia.org",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=True,
tags=["mapa", "crowdsourced", "etiquetas"],
para="Mapa colaborativo con descripciones de lugares y edificios que otros mapas no etiquetan.",
como="Navega y lee las descripciones de los poligonos dibujados por usuarios.",
gotchas=["Informacion sin verificar; tratar como pista.",
"WAF; navegador real."]),
dict(slug="sentinel-eo-browser", nombre="Sentinel Hub EO Browser", url="https://apps.sentinel-hub.com/eo-browser/",
cat="geolocalizacion", coste="free", reg=True, amb="global", antibot=False,
tags=["satelite", "sentinel", "multiespectral"],
para="Imagenes Sentinel/Landsat recientes y multiespectrales; ver cambios, incendios, agua, vegetacion por fecha.",
como="Selecciona zona y fecha; cambia entre bandas (true color, NDVI, etc.).",
gotchas=["Resolucion ~10m (no ve coches/personas).",
"Requiere cuenta gratuita para algunas capas."]),
dict(slug="zoom-earth", nombre="Zoom Earth", url="https://zoom.earth",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["satelite", "tiempo-real", "clima"],
para="Vista satelital casi en tiempo real con capas meteorologicas (nubes, tormentas, incendios).",
como="Navega el globo; util para contexto temporal/meteo de un evento.",
gotchas=["Resolucion baja; para contexto, no detalle."]),
dict(slug="nasa-worldview", nombre="NASA Worldview", url="https://worldview.earthdata.nasa.gov",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["satelite", "historico", "nasa"],
para="Imagenes satelitales diarias historicas de la NASA (MODIS/VIIRS) con barra temporal global.",
como="Elige fecha y capa; descarga la imagen del dia para una region.",
gotchas=["Resolucion baja (cientos de m); para fenomenos grandes."]),
dict(slug="suncalc", nombre="SunCalc", url="https://www.suncalc.org",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["chronolocation", "sol", "sombras"],
para="Calcula la posicion del sol y direccion de las sombras para una ubicacion y hora dadas (chronolocation).",
como="Fija el punto en el mapa y la fecha; ajusta la hora hasta que la sombra coincida con la de la foto.",
gotchas=["Necesitas estimar bien la fecha/estacion.",
"Funciona al reves: deducir la hora a partir de la sombra observada."]),
dict(slug="shadowmap", nombre="Shadowmap", url="https://shadowmap.org",
cat="geolocalizacion", coste="freemium", reg=False, amb="global", antibot=False,
tags=["chronolocation", "sombras", "3d"],
para="Simula sombras de edificios en 3D segun hora/fecha; util para chronolocation en ciudad.",
como="Coloca la vista en la zona y desliza la hora para comparar sombras de edificios.",
gotchas=["Modelo 3D solo en ciudades grandes.",
"Funciones avanzadas de pago."]),
dict(slug="exiftool", nombre="ExifTool (CLI)", url="https://exiftool.org",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["metadatos", "exif", "cli"],
para="Lee todos los metadatos de una imagen/archivo: GPS, fecha, camara, software. El estandar para EXIF.",
como="`exiftool foto.jpg`; busca GPSLatitude/Longitude y DateTimeOriginal.",
gotchas=["Las redes sociales borran EXIF al subir; suele haber GPS solo en originales.",
"Instalacion local (perl)."]),
dict(slug="metadata2go", nombre="Metadata2Go", url="https://www.metadata2go.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["metadatos", "exif", "online"],
para="Visor EXIF online sin instalar nada: sube el archivo y lee sus metadatos.",
como="Sube la imagen; muestra GPS, fecha y datos de camara.",
gotchas=["Subes el archivo a un tercero: no usar con material sensible.",
"Preferir ExifTool local para evidencia."]),
dict(slug="jimpl", nombre="Jimpl", url="https://jimpl.com",
cat="geolocalizacion", coste="free", reg=False, amb="global", antibot=False,
tags=["metadatos", "exif", "gps", "online"],
para="Visor EXIF online que situa la coordenada GPS de la foto directamente en un mapa.",
como="Sube la imagen; si tiene GPS, lo pinta en el mapa.",
gotchas=["Mismo aviso de privacidad: subes a un tercero."]),
dict(slug="ipinfo", nombre="IPinfo", url="https://ipinfo.io",
cat="geolocalizacion", coste="freemium", reg=True, amb="global", antibot=False,
tags=["ip-geo", "asn"],
para="Geolocalizacion de IP, ASN, organizacion y tipo de conexion (hosting/movil/vpn).",
como="`ipinfo.io/<ip>` en web o API; util para ubicar el origen aproximado de una IP.",
gotchas=["Geo de IP es aproximada (ciudad/region, no direccion).",
"VPN/proxy falsean la ubicacion."]),
dict(slug="ipgeolocation", nombre="ipgeolocation.io", url="https://ipgeolocation.io",
cat="geolocalizacion", coste="freemium", reg=True, amb="global", antibot=False,
tags=["ip-geo", "api"],
para="API de geolocalizacion de IP con zona horaria, moneda y datos de red.",
como="Consulta por IP via API; devuelve pais, region, ciudad aproximada.",
gotchas=["Precision limitada; varias fuentes discrepan.",
"Requiere API key."]),
dict(slug="maxmind", nombre="MaxMind GeoIP", url="https://www.maxmind.com",
cat="geolocalizacion", coste="freemium", reg=True, amb="global", antibot=False,
tags=["ip-geo", "base-datos"],
para="Base de datos GeoIP estandar de la industria (GeoLite2 gratis) para resolver IP a ubicacion offline.",
como="Descarga GeoLite2 (cuenta gratis) y consulta localmente; o usa su web demo.",
gotchas=["GeoLite2 menos precisa que la version de pago.",
"Requiere cuenta para descargar las DB."]),
# ---------- Imagen forense ----------
dict(slug="invid-weverify", nombre="InVID / WeVerify", url="https://www.invid-project.eu",
cat="imagen-forense", coste="free", reg=False, amb="global", antibot=False,
tags=["video", "verificacion", "frames", "extension"],
para="Extension del navegador para verificar videos/imagenes: extrae frames, hace reverse search, lee metadatos y detecta manipulaciones.",
como="Instala la extension (Fake News Debunker); pega la URL del video para sacar keyframes y buscarlos.",
gotchas=["Es una extension de navegador, no una web de consulta directa.",
"Algunas integraciones de redes se rompen cuando cambian sus APIs."]),
dict(slug="forensically", nombre="Forensically", url="https://29a.ch/photo-forensics/",
cat="imagen-forense", coste="free", reg=False, amb="global", antibot=False,
tags=["forense", "ela", "clonado"],
para="Suite forense de imagen en el navegador: ELA, deteccion de clonado, analisis de ruido, lupa de detalle.",
como="Sube la foto; usa Clone Detection y Error Level Analysis para detectar zonas editadas.",
gotchas=["ELA da pistas, no pruebas; interpretar con cuidado.",
"Funciona en local en el navegador (no sube la imagen)."]),
dict(slug="fotoforensics", nombre="FotoForensics", url="https://fotoforensics.com",
cat="imagen-forense", coste="free", reg=False, amb="global", antibot=False,
tags=["forense", "ela"],
para="Analisis ELA online clasico para detectar montajes en imagenes JPEG.",
como="Sube la imagen; las zonas con distinto nivel de error sugieren edicion.",
gotchas=["Subes la imagen a un tercero (queda publica un tiempo).",
"ELA tiene muchos falsos positivos en imagenes recomprimidas."]),
# ---------- Archivo ----------
dict(slug="wayback-machine", nombre="Wayback Machine", url="https://web.archive.org",
cat="archivo", coste="free", reg=False, amb="global", antibot=False,
tags=["archivo", "historico", "web"],
para="Archivo historico de paginas web: ver como era un sitio/perfil en una fecha pasada.",
como="Pega la URL; navega por las capturas en la linea de tiempo. Permite guardar una captura nueva.",
gotchas=["No todo esta archivado ni en todas las fechas.",
"robots.txt o peticiones de borrado pueden ocultar capturas."]),
dict(slug="archive-today", nombre="archive.today", url="https://archive.ph",
cat="archivo", coste="free", reg=False, amb="global", antibot=False,
tags=["archivo", "snapshot"],
para="Captura puntual de una pagina (incluye contenido dinamico) que queda congelada e inmune a borrados.",
como="Pega la URL para crear o buscar una captura; util para preservar evidencia antes de que se borre.",
gotchas=["Capturas puntuales, no rastreo continuo como Wayback.",
"Dominios espejo varios (.ph/.is/.today)."]),
dict(slug="cachedview", nombre="CachedView", url="https://cachedview.nl",
cat="archivo", coste="free", reg=False, amb="global", antibot=False,
tags=["cache", "google"],
para="Atajo para ver versiones cacheadas de una pagina (Google Cache, Wayback) en un sitio.",
como="Pega la URL; ofrece enlaces a las caches disponibles.",
gotchas=["Google Cache esta practicamente retirado; cada vez menos util.",
"Depende de que el tercero conserve la copia."]),
# ---------- Empresa ES ----------
dict(slug="catastro", nombre="Sede Catastro", url="https://www.sedecatastro.gob.es",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=False,
tags=["inmueble", "catastro", "geo"],
para="Catastro español: localiza inmuebles por direccion o referencia catastral, con superficie, uso, año y geometria sobre mapa.",
como="Busca por direccion; obtiene referencia catastral, plano y datos del inmueble (titular parcial sin certificado).",
gotchas=["El titular completo requiere certificado/identificacion y acreditar interes.",
"Cruzar referencia catastral con la direccion del objetivo."]),
dict(slug="borme", nombre="BORME (BOE)", url="https://www.boe.es/diario_borme/",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=False,
tags=["mercantil", "empresa", "boletin"],
para="Boletin Oficial del Registro Mercantil: constituciones, nombramientos, ceses y cambios de empresas españolas.",
como="Busca por nombre de persona o sociedad; revela en que empresas figura alguien como administrador.",
gotchas=["URL correcta es /diario_borme/ (la /borme/ da 404).",
"Datos historicos por fecha de publicacion."]),
dict(slug="libreborme", nombre="Libreborme", url="https://libreborme.net",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=True,
tags=["mercantil", "empresa", "buscador"],
para="Interfaz buscable sobre los datos del BORME: ficha de persona/empresa con sus cargos y relaciones.",
como="Busca el nombre; ve sus sociedades, cargos y co-administradores de un vistazo.",
gotchas=["Cobertura desde ~2009; datos antiguos pueden faltar.",
"WAF; navegador real."]),
dict(slug="einforma", nombre="eInforma", url="https://www.einforma.com",
cat="empresa-es", coste="freemium", reg=True, amb="espana", antibot=False,
tags=["mercantil", "empresa", "informe"],
para="Informes mercantiles de empresas españolas: cargos, cuentas, CIF, vinculaciones.",
como="Busca la empresa o el administrador; datos basicos gratis, informe completo de pago.",
gotchas=["Lo detallado es de pago.",
"Requiere cuenta."]),
dict(slug="axesor", nombre="Axesor", url="https://www.axesor.es",
cat="empresa-es", coste="freemium", reg=True, amb="espana", antibot=False,
tags=["mercantil", "empresa", "rating"],
para="Informacion mercantil y de riesgo de empresas españolas; alternativa a eInforma.",
como="Busca empresa/persona; ficha basica gratis, informe financiero de pago.",
gotchas=["Datos detallados de pago.",
"Requiere cuenta."]),
dict(slug="infoempresa", nombre="InfoEmpresa", url="https://www.infoempresa.com",
cat="empresa-es", coste="freemium", reg=False, amb="espana", antibot=False,
tags=["mercantil", "empresa"],
para="Buscador de empresas españolas con administradores, objeto social y datos de contacto.",
como="Busca por nombre de empresa o de persona para ver sus cargos.",
gotchas=["Parte de la ficha de pago.",
"Datos a veces desactualizados."]),
dict(slug="idealista", nombre="Idealista", url="https://www.idealista.com",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=True,
tags=["inmueble", "fotos", "geo"],
para="Portal inmobiliario: fotos y planos de viviendas que permiten cruzar una direccion con el interior y el entorno.",
como="Busca por zona/direccion; las fotos del anuncio revelan interior, vistas y referencias del lugar.",
gotchas=["Anuncios caducan; usar Wayback para los retirados.",
"WAF (DataDome); navegador real."]),
dict(slug="fotocasa", nombre="Fotocasa", url="https://www.fotocasa.es",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=False,
tags=["inmueble", "fotos", "geo"],
para="Segundo portal inmobiliario español; util para contrastar fotos/anuncios con Idealista.",
como="Busca por zona; cruza fotos y precios con el otro portal.",
gotchas=["Cobertura solapada con Idealista; usar ambos."]),
dict(slug="infobel", nombre="Infobel", url="https://www.infobel.com/es",
cat="empresa-es", coste="free", reg=False, amb="espana", antibot=True,
tags=["telefono", "directorio", "paginas-blancas"],
para="Paginas blancas/amarillas online: cruza nombre, telefono y direccion en España y otros paises.",
como="Busca por nombre o telefono para resolver el otro dato.",
gotchas=["Cobertura desigual; muchos numeros no listados (LOPD).",
"WAF; navegador real."]),
# ---------- Frameworks ----------
dict(slug="osint-framework", nombre="OSINT Framework", url="https://osintframework.com",
cat="framework", coste="free", reg=False, amb="global", antibot=False,
tags=["indice", "arbol", "recursos"],
para="Arbol navegable de cientos de recursos OSINT clasificados por tipo de dato (email, username, dominio, geo...).",
como="Navega el arbol por categoria para descubrir herramientas especificas para cada dato.",
gotchas=["Algunos enlaces estan muertos o desactualizados.",
"Es un indice, no ejecuta nada."]),
dict(slug="bellingcat-toolkit", nombre="Bellingcat's Online Toolkit", url="https://bellingcat.gitbook.io/toolkit",
cat="framework", coste="free", reg=False, amb="global", antibot=False,
tags=["indice", "investigacion", "geo"],
para="Coleccion curada por Bellingcat de herramientas para investigacion, con fuerte enfasis en geolocalizacion y verificacion.",
como="Busca por categoria (maps, satellite, social) la herramienta recomendada y probada por investigadores.",
gotchas=["Curada pero amplia; empezar por las marcadas como favoritas."]),
dict(slug="inteltechniques", nombre="IntelTechniques Tools", url="https://inteltechniques.com/tools/",
cat="framework", coste="free", reg=False, amb="global", antibot=False,
tags=["indice", "formularios", "bazzell"],
para="Conjunto de formularios de busqueda (Michael Bazzell) que automatizan consultas por email, username, telefono, etc.",
como="Abre la herramienta del dato que tengas; rellena y lanza busquedas en multiples servicios.",
gotchas=["Algunas herramientas se rompen cuando los servicios cambian sus URLs.",
"Parte del contenido premium esta en sus libros."]),
dict(slug="start-me-osint", nombre="Start.me — OSINT Collection", url="https://start.me/p/DPYPMz/the-ultimate-osint-collection",
cat="framework", coste="free", reg=False, amb="global", antibot=True,
tags=["indice", "enlaces", "dashboard"],
para="Dashboard comunitario con cientos de enlaces OSINT agrupados por tema; bueno para descubrir fuentes nuevas.",
como="Navega los paneles por categoria; marca los enlaces utiles.",
gotchas=["Calidad variable (es comunitario).",
"WAF; navegador real."]),
]
# ============================================================================
# Grupo recon: funciones del registry (NO sitios web) que se ejecutan con
# `fn run` y archivan su resultado por objetivo en el vault + DuckDB.
# docs/capabilities/recon.md + projects/osint/CONVENTIONS.md §9.
# ============================================================================
RECON_TOOLS = [
dict(slug="whois-lookup", nombre="whois_lookup", rid="whois_lookup_py_cybersecurity",
modo="pasivo", sudo=False, persiste=True,
comando="fn run recon_osint <target> whois",
para="Lookup WHOIS de un dominio o IP: registrar, pais del registrante, fechas (creacion/expiracion/actualizacion) y name servers, parseados best-effort sobre el raw.",
gotchas=["Pasivo (consulta el registro, no toca al objetivo).",
"El parseo depende del formato del registrar; el `raw` completo siempre se guarda."]),
dict(slug="rdap-lookup", nombre="rdap_lookup", rid="rdap_lookup_py_cybersecurity",
modo="pasivo", sudo=False, persiste=True,
comando="fn run recon_osint <target> rdap",
para="Lookup RDAP (sustituto JSON moderno de WHOIS) de dominio, IP o ASN (p.ej. AS15169); devuelve datos estructurados.",
gotchas=["Pasivo.",
"Mas fiable de parsear que WHOIS; preferir cuando el TLD lo soporta."]),
dict(slug="dns-records", nombre="dns_records", rid="dns_records_py_cybersecurity",
modo="pasivo", sudo=False, persiste=True,
comando="fn run recon_osint <target> dns",
para="Registros DNS via `dig +short` (A, AAAA, MX, NS, SOA, TXT, CNAME por defecto) en un dict por tipo.",
gotchas=["Pasivo (consulta a resolvers DNS, no al objetivo).",
"Resultados dependen del resolver/cache local."]),
dict(slug="ping-host", nombre="ping_host", rid="ping_host_py_cybersecurity",
modo="activo", sudo=False, persiste=True,
comando="fn run recon_osint <target> ping",
para="Sondeo ICMP: porcentaje de perdida y RTT (avg/min/max) hacia el host.",
gotchas=["ACTIVO: envia paquetes al objetivo.",
"Host filtrado por firewall = loss 100% pero status:ok (no es error)."]),
dict(slug="traceroute-host", nombre="traceroute_host", rid="traceroute_host_py_cybersecurity",
modo="activo", sudo=False, persiste=True,
comando="fn run recon_osint <target> traceroute",
para="Traza la ruta de red hasta el host: lista de hops con nombre/IP/RTT.",
gotchas=["ACTIVO: genera trafico hacia el objetivo y la ruta intermedia.",
"Hops sin respuesta (`* * *`) salen con hosts vacios."]),
dict(slug="nmap-scan", nombre="nmap_scan", rid="nmap_scan_py_cybersecurity",
modo="activo", sudo="perfiles os/udp-top/aggressive", persiste=True,
comando="fn run recon_osint <target> nmap # perfil quick por defecto",
para="Escaneo de puertos y servicios con nmap por perfiles (quick, top1000, service -sV -sC, vuln, udp-top, aggressive -A, discovery CIDR, os). Salida XML parseada a open_ports/hosts_up.",
gotchas=["ACTIVO E INTRUSIVO: solo contra objetivos PROPIOS o con autorizacion explicita. Escanear infra ajena puede ser ilegal.",
"Perfiles os/udp-top/aggressive requieren sudo.",
"Perfiles largos (vuln, aggressive) → lanzar en segundo plano (&/background).",
"discovery acepta CIDR: cuidado de no barrer rangos que no son tuyos."]),
dict(slug="save-scan-to-osint", nombre="save_scan_to_osint", rid="save_scan_to_osint_py_cybersecurity",
modo="sink", sudo=False, persiste=True,
comando="# se invoca dentro del pipeline; uso directo para archivar un raw externo",
para="Sink comun: archiva el resultado de CUALQUIER scan en el ecosistema OSINT. Dos capas: nota Markdown tipada en el vault (siempre) + POST a osint_db para registro DuckDB (best-effort).",
gotchas=["Si el service osint_db esta caido o el endpoint da 404, degrada a solo-nota (register_warning) sin fallar.",
"No lanza excepciones: devuelve dict de estado con note_path/registered/scan_id."]),
dict(slug="recon-osint", nombre="recon_osint (pipeline)", rid="recon_osint_py_pipelines",
modo="segun scan", sudo="si el scan lo pide", persiste=True,
comando="fn run recon_osint <target> <whois|rdap|dns|ping|traceroute|nmap>",
para="Pipeline one-shot: ejecuta un scan del tipo pedido y lo archiva en OSINT en una sola llamada. El camino canonico para recon + archivado por objetivo.",
gotchas=["Hereda el modo del scan elegido (whois/rdap/dns pasivos; ping/traceroute/nmap activos).",
"`save=False` ejecuta sin archivar (raro; por defecto archiva)."]),
]
RECON_PERSIST_LINES = [
"- **Nota** (siempre, fuente de verdad): `~/Obsidian/osint/dominios/<slug>/recon/<tipo>-<ts>.md` (`tipo: scan-red`, raw en bloque de codigo).",
"- **DuckDB** (best-effort): tabla `network_scans` en `osint_db` via `POST 127.0.0.1:8771/api/scan`.",
"- **Consultar** (code block `osintdb` del plugin): `SELECT * FROM network_scans WHERE target_slug='<slug>';`",
"- Service `osint_db` caido → degrada a solo-nota sin fallar.",
]
def build_recon_frontmatter(t):
return {
"tipo": "herramienta",
"nombre": t["nombre"],
"slug": t["slug"],
"registry_id": t["rid"],
"categoria": "recon",
"osint_modo": t["modo"],
"coste": "free",
"comando": t["comando"],
"requiere_sudo": t["sudo"],
"persiste_en_vault": bool(t["persiste"]),
"tags": ["herramienta", "osint", "recon", "registry", "red"],
}
def build_recon_body(t):
lines = []
lines.append("> Funcion del registry (no es un sitio web): se ejecuta con `fn run` y archiva el resultado por objetivo.")
lines.append("")
lines.append("## Para que")
lines.append("")
lines.append(t["para"])
lines.append("")
lines.append("## Como llamar")
lines.append("")
lines.append("```bash")
lines.append(t["comando"])
lines.append("```")
lines.append("")
lines.append(f"Registry ID: `{t['rid']}`. Inspeccionar: `mcp__registry__fn_show id=\"{t['rid']}\"`.")
lines.append("")
lines.append("## Persistencia (resultados por objetivo)")
lines.append("")
if t["modo"] == "sink":
lines.append("Es el sink que escribe la nota y registra en DuckDB:")
else:
lines.append("Al pasar por el pipeline `recon_osint` (o por `save_scan_to_osint`):")
lines.append("")
lines.extend(RECON_PERSIST_LINES)
lines.append("")
lines.append("## Gotchas")
lines.append("")
for g in t["gotchas"]:
lines.append(f"- {g}")
return "\n".join(lines)
def build_body(t):
lines = []
lines.append("## Para que")
lines.append("")
lines.append(t["para"])
lines.append("")
lines.append("## Como usar")
lines.append("")
lines.append(t["como"])
lines.append("")
lines.append("## Gotchas")
lines.append("")
for g in t["gotchas"]:
lines.append(f"- {g}")
return "\n".join(lines)
def build_frontmatter(t):
fm = {
"tipo": "herramienta",
"nombre": t["nombre"],
"slug": t["slug"],
"url": t["url"],
"categoria": t["cat"],
"osint_modo": "pasivo",
"coste": t["coste"],
"registro": bool(t["reg"]),
"ambito": t["amb"],
"anti_bot": bool(t["antibot"]),
"tags": ["herramienta", "osint", t["cat"]] + t["tags"],
}
return fm
def main():
created = []
# Validar slugs unicos (web + recon no colisionan).
slugs = [t["slug"] for t in TOOLS] + [t["slug"] for t in RECON_TOOLS]
assert len(slugs) == len(set(slugs)), "slugs duplicados: " + str(
[s for s in slugs if slugs.count(s) > 1]
)
for t in TOOLS:
rel = f"herramientas/{t['slug']}.md"
path = create_obsidian_note(
vault_dir=VAULT,
rel_path=rel,
body=build_body(t),
frontmatter=build_frontmatter(t),
overwrite=True,
)
created.append((t["cat"], t["slug"], path))
for t in RECON_TOOLS:
rel = f"herramientas/{t['slug']}.md"
path = create_obsidian_note(
vault_dir=VAULT,
rel_path=rel,
body=build_recon_body(t),
frontmatter=build_recon_frontmatter(t),
overwrite=True,
)
created.append(("recon", t["slug"], path))
# ----- MOC index -----
moc_lines = []
moc_lines.append("Indice de herramientas online para investigaciones OSINT pasivo.")
moc_lines.append("")
moc_lines.append(
"Cada herramienta tiene su ficha con `## Para que`, `## Como usar` y `## Gotchas`. "
"El campo `anti_bot: true` marca las que bloquean clientes simples y necesitan un navegador real "
"(perfil Chromium dedicado). Modo de uso por defecto: **pasivo** (no se interactua con los sistemas del objetivo)."
)
moc_lines.append("")
total = len(TOOLS) + len(RECON_TOOLS)
moc_lines.append(
f"**Total: {total} herramientas** ({len(TOOLS)} sitios/CLIs externos pasivos "
f"+ {len(RECON_TOOLS)} funciones del registry del grupo `recon`, estas ultimas ejecutables "
f"con `fn run` y con archivado por objetivo)."
)
moc_lines.append("")
for cat in CAT_ORDER:
items = [t for t in TOOLS if t["cat"] == cat]
if not items:
continue
moc_lines.append(f"## {CAT_LABELS[cat]}")
moc_lines.append("")
moc_lines.append("| Herramienta | Coste | Cuenta | Ambito | Para que |")
moc_lines.append("|---|---|---|---|---|")
for t in items:
cuenta = "si" if t["reg"] else "no"
ab = " ⚠️navegador" if t["antibot"] else ""
link = f"[[{t['slug']}\\|{t['nombre']}]]"
resumen = t["para"].strip()
moc_lines.append(
f"| {link}{ab} | {t['coste']} | {cuenta} | {t['amb']} | {resumen} |"
)
moc_lines.append("")
# ----- Seccion recon (funciones del registry, no sitios web) -----
moc_lines.append("## Recon de red (registry, ejecutable)")
moc_lines.append("")
moc_lines.append(
"Estas NO son sitios web: son funciones del registry (grupo `recon`, dominio `cybersecurity`) "
"que se ejecutan con `fn run` y que **archivan su resultado por objetivo** en el vault + DuckDB. "
"Doctrina: *todo escaneo se guarda siempre*. Pagina madre: `docs/capabilities/recon.md`."
)
moc_lines.append("")
moc_lines.append("| Herramienta | Registry ID | Modo | Sudo | Para que |")
moc_lines.append("|---|---|---|---|---|")
for t in RECON_TOOLS:
sudo = t["sudo"] if isinstance(t["sudo"], str) else ("si" if t["sudo"] else "no")
modo_tag = " ⚠️activo" if t["modo"] == "activo" else ""
link = f"[[{t['slug']}\\|{t['nombre']}]]"
moc_lines.append(
f"| {link}{modo_tag} | `{t['rid']}` | {t['modo']} | {sudo} | {t['para'].strip()} |"
)
moc_lines.append("")
moc_lines.append("**Patron canonico (recon + archivado por objetivo en 1 call):**")
moc_lines.append("")
moc_lines.append("```bash")
moc_lines.append("fn run recon_osint <target> <whois|rdap|dns|ping|traceroute|nmap>")
moc_lines.append("```")
moc_lines.append("")
moc_lines.append("Cada call deja:")
moc_lines.extend(RECON_PERSIST_LINES)
moc_lines.append("")
moc_lines.append("## Notas de uso")
moc_lines.append("")
moc_lines.append(
"- **Pasivo vs activo:** todo este indice es OSINT pasivo (fuentes publicas y de terceros). "
"Lanzar un scan que toque el sistema del objetivo (p.ej. un scan nuevo en urlscan, loguearse en una cuenta) "
"deja de ser pasivo."
)
moc_lines.append(
"- **Geolocalizacion de una foto (flujo tipico):** 1) `[[exiftool]]`/`[[jimpl]]` por si hay GPS en metadatos; "
"2) reverse image con `[[yandex-images]]` + `[[google-lens]]`; 3) confirmar en `[[google-maps]]`/`[[google-earth]]` + `[[mapillary]]`; "
"4) datar por sombras con `[[suncalc]]`/`[[shadowmap]]`; 5) acotar features con `[[overpass-turbo]]`."
)
moc_lines.append(
"- **España:** para inmuebles/direcciones cruzar `[[catastro]]` + `[[idealista]]`/`[[fotocasa]]`; "
"para empresas y cargos `[[borme]]`/`[[libreborme]]`."
)
moc_lines.append(
"- **Brechas y credenciales** (`[[dehashed]]`, `[[leakcheck]]`, `[[intelligence-x]]`): manejar con cautela legal/etica."
)
moc_lines.append(
"- **Recon de red:** `[[ping-host]]`, `[[traceroute-host]]` y sobre todo `[[nmap-scan]]` son ACTIVOS "
"(tocan al objetivo). Solo contra infra PROPIA o con autorizacion explicita. `[[whois-lookup]]`/`[[rdap-lookup]]`/`[[dns-records]]` "
"son pasivos. Todo lo que ejecutes con `[[recon-osint]]` queda archivado por objetivo en el vault."
)
moc_fm = {
"tipo": "index",
"tags": ["osint", "moc", "herramientas"],
}
moc_path = create_obsidian_note(
vault_dir=VAULT,
rel_path="herramientas/_indice.md",
body="\n".join(moc_lines),
frontmatter=moc_fm,
overwrite=True,
)
print(f"Fichas creadas: {len(created)}")
print(f"MOC: {moc_path}")
# Resumen por categoria
from collections import Counter
c = Counter(cat for cat, _, _ in created)
for cat in CAT_ORDER:
if c.get(cat):
print(f" {cat:18} {c[cat]}")
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Importación/enriquecimiento idempotente de contactos desde un .vcf.
Dos operaciones, ambas idempotentes y NO destructivas (solo INSERT/UPDATE
aditivo, nunca DELETE):
1. backfill — calcula la clave de importación determinística (import_key) de
los contactos ya presentes en la DB y la guarda. La clave la
genera la función del registry ``contact_import_key`` a partir
de la identidad estable del contacto (teléfonos normalizados >
emails > nombre normalizado).
2. enrich — lee un .vcf (p.ej. el export de Google) y, para cada tarjeta,
localiza el contacto existente por import_key (rápido) con
fallback por teléfono compartido, y le AÑADE lo que falte:
teléfonos y emails nuevos (en contacts) y direcciones (en la
persona enlazada por note_path, que es de donde el push de
agenda las propaga al móvil). Nunca pisa ni borra datos.
La DB osint.duckdb es single-writer (la posee el service osint_db). Este tool
abre una conexión de lectura para el plan y, solo con --apply, una conexión de
escritura breve mientras el service está inactivo. Hacer backup antes de --apply.
Uso:
python3 import_contacts_vcf.py backfill --dry-run
python3 import_contacts_vcf.py backfill --apply
python3 import_contacts_vcf.py enrich --vcf ~/Downloads/contacts.vcf --dry-run
python3 import_contacts_vcf.py enrich --vcf ~/Downloads/contacts.vcf --apply
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import duckdb
# --- Acceso a la función del registry contact_import_key ---------------------
_THIS = os.path.dirname(os.path.abspath(__file__))
_FN_DIR = os.path.normpath(os.path.join(_THIS, "..", "..", "..", "python", "functions"))
if not os.path.isdir(os.path.join(_FN_DIR, "core")):
_FN_DIR = os.path.expanduser("~/fn_registry/python/functions")
sys.path.insert(0, _FN_DIR)
from core.contact_import_key import contact_import_key # noqa: E402
DEFAULT_DB = os.path.join(_THIS, "..", "apps", "osint_db", "data", "osint.duckdb")
# --- Normalización y parseo --------------------------------------------------
def norm_phone(p: str) -> str:
"""Últimos 9 dígitos de un teléfono (mismo criterio que la DB/registry)."""
d = re.sub(r"\D", "", str(p or ""))
return d[-9:] if len(d) >= 9 else d
def _unfold(text: str) -> str:
return re.sub(r"\r?\n[ \t]", "", text)
def _adr_to_text(raw: str) -> str:
"""Dirección legible desde un valor ADR estructurado (7 componentes ';')."""
parts = [p.strip() for p in raw.split(";")]
nonempty = [p for p in parts if p]
if len(parts) >= 3 and parts[2]:
tail = [p for p in parts[3:] if p]
return ", ".join([parts[2]] + tail) if tail else parts[2]
return ", ".join(nonempty)
def parse_vcf(path: str) -> list:
"""Parsea un .vcf a una lista de dicts con los campos de interés por tarjeta.
Cada dict: {fn, tels (valores originales), emails, adrs (texto legible),
bdays}. Los teléfonos/emails se devuelven en su forma original (no
normalizada) para preservar el formato legible al añadirlos.
"""
text = _unfold(open(path, encoding="utf-8", errors="replace").read())
cards = []
for block in re.split(r"(?=BEGIN:VCARD)", text):
if "BEGIN:VCARD" not in block:
continue
fn = re.search(r"^FN:(.+)$", block, re.M)
tels = [t.strip() for t in re.findall(r"^TEL[^:]*:(.+)$", block, re.M) if t.strip()]
emails = [e.strip() for e in re.findall(r"^EMAIL[^:]*:(.+)$", block, re.M) if "@" in e]
adrs = [_adr_to_text(a) for a in re.findall(r"^ADR[^:]*:(.+)$", block, re.M)]
adrs = [a for a in adrs if a]
bdays = [b.strip() for b in re.findall(r"^BDAY[^:]*:(.+)$", block, re.M) if b.strip()]
cards.append(
{
"fn": fn.group(1).strip() if fn else "",
"tels": tels,
"emails": emails,
"adrs": adrs,
"bdays": bdays,
}
)
return cards
# --- Carga de la DB ----------------------------------------------------------
def load_contacts(con) -> list:
"""Filas de contacts como dicts con tels/emails decodificados."""
rows = con.execute(
"SELECT uid, fn, tels, emails, note_path, import_key FROM contacts"
).fetchall()
out = []
for uid, fn, tels, emails, note_path, import_key in rows:
out.append(
{
"uid": uid,
"fn": fn,
"tels": json.loads(tels or "[]"),
"emails": json.loads(emails or "[]"),
"note_path": note_path,
"import_key": import_key,
}
)
return out
def key_of(contact: dict) -> str:
return contact_import_key(contact.get("fn") or "", contact.get("tels") or [], contact.get("emails") or [])
# --- backfill ----------------------------------------------------------------
def cmd_backfill(db_path: str, apply: bool) -> int:
con = duckdb.connect(db_path, read_only=not apply)
try:
contacts = load_contacts(con)
updates = []
collisions = {}
for c in contacts:
k = key_of(c)
collisions.setdefault(k, []).append(c["uid"])
if c["import_key"] != k:
updates.append((k, c["uid"]))
dup = {k: v for k, v in collisions.items() if len(v) > 1}
print(f"contactos: {len(contacts)}")
print(f"import_key a (re)calcular: {len(updates)}")
print(f"claves con colisión (>1 contacto): {len(dup)}")
for k, uids in list(dup.items())[:5]:
print(f" {k}: {uids}")
if apply:
for k, uid in updates:
con.execute("UPDATE contacts SET import_key = ? WHERE uid = ?", [k, uid])
print(f"APLICADO: {len(updates)} import_key escritas")
else:
print("(dry-run: nada escrito; usa --apply)")
return 0
finally:
con.close()
# --- enrich ------------------------------------------------------------------
def build_plan(contacts: list, cards: list) -> list:
"""Plan de enriquecimiento: por contacto existente, qué tel/email/adr añadir.
Match por import_key (rápido) con fallback por teléfono normalizado
compartido. Solo genera entradas con algún cambio real.
"""
by_key = {}
by_phone = {}
for c in contacts:
k = c["import_key"] or key_of(c)
by_key.setdefault(k, c)
for t in c["tels"]:
by_phone.setdefault(norm_phone(t), c)
plan = []
for card in cards:
ck = contact_import_key(card["fn"], card["tels"], card["emails"])
hit = by_key.get(ck)
how = "import_key"
if hit is None:
for t in card["tels"]:
hit = by_phone.get(norm_phone(t))
if hit:
how = "phone"
break
if hit is None:
continue
db_tel_norm = {norm_phone(t) for t in hit["tels"]}
db_em = {e.strip().lower() for e in hit["emails"]}
add_tel = [t for t in card["tels"] if norm_phone(t) and norm_phone(t) not in db_tel_norm]
add_em = [e for e in card["emails"] if e.strip().lower() not in db_em]
# dedup interno preservando orden
add_tel = list(dict.fromkeys(add_tel))
add_em = list(dict.fromkeys(add_em))
if add_tel or add_em or card["adrs"]:
plan.append(
{
"uid": hit["uid"],
"fn": hit["fn"],
"note_path": hit["note_path"],
"match": how,
"add_tel": add_tel,
"add_email": add_em,
"add_adr": card["adrs"],
"new_tels": hit["tels"] + add_tel,
"new_emails": hit["emails"] + add_em,
}
)
return plan
def _person_add_adrs(con, note_path: str, adrs: list) -> int:
"""Añade direcciones a la persona enlazada (sin duplicar). Devuelve cuántas."""
row = con.execute(
"SELECT direcciones, direccion FROM persons WHERE note_path = ?", [note_path]
).fetchone()
if row is None:
return 0
current = json.loads(row[0] or "[]") if row[0] else []
if not current and row[1]:
current = [row[1]]
existing_norm = {re.sub(r"\s+", " ", x).strip().lower() for x in current}
added = [a for a in adrs if re.sub(r"\s+", " ", a).strip().lower() not in existing_norm]
if not added:
return 0
merged = current + added
con.execute(
"UPDATE persons SET direcciones = ?, direccion = ? WHERE note_path = ?",
[json.dumps(merged, ensure_ascii=False), merged[0], note_path],
)
return len(added)
def cmd_enrich(db_path: str, vcf: str, apply: bool) -> int:
cards = parse_vcf(vcf)
con = duckdb.connect(db_path, read_only=not apply)
try:
before = con.execute("SELECT count(*) FROM contacts").fetchone()[0]
contacts = load_contacts(con)
plan = build_plan(contacts, cards)
n_tel = sum(len(p["add_tel"]) for p in plan)
n_em = sum(len(p["add_email"]) for p in plan)
n_adr_targets = sum(1 for p in plan if p["add_adr"] and p["note_path"])
print(f".vcf tarjetas: {len(cards)} contactos DB: {before}")
print(f"contactos a enriquecer: {len(plan)} (+{n_tel} tel, +{n_em} email, "
f"direcciones a {n_adr_targets} personas enlazadas)")
for p in plan[:30]:
ch = []
if p["add_tel"]:
ch.append(f"+{len(p['add_tel'])}tel")
if p["add_email"]:
ch.append(f"+{len(p['add_email'])}email")
if p["add_adr"]:
ch.append("+adr" if p["note_path"] else "+adr(SIN persona)")
print(f" [{p['match']:10}] {(p['fn'] or '?')[:34]:34} {' '.join(ch)}")
if not apply:
print("(dry-run: nada escrito; usa --apply)")
return 0
adr_added = 0
for p in plan:
con.execute(
"UPDATE contacts SET tels = ?, emails = ? WHERE uid = ?",
[
json.dumps(p["new_tels"], ensure_ascii=False),
json.dumps(p["new_emails"], ensure_ascii=False),
p["uid"],
],
)
if p["add_adr"] and p["note_path"]:
adr_added += _person_add_adrs(con, p["note_path"], p["add_adr"])
after = con.execute("SELECT count(*) FROM contacts").fetchone()[0]
assert after == before, f"PÉRDIDA: contactos {before} -> {after}"
print(f"APLICADO: {len(plan)} contactos enriquecidos, {adr_added} direcciones "
f"añadidas a personas. Conteo intacto: {before} == {after}")
return 0
finally:
con.close()
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("backfill", help="calcula y guarda import_key de los contactos")
pb.add_argument("--db", default=DEFAULT_DB)
pb.add_argument("--apply", action="store_true")
pe = sub.add_parser("enrich", help="enriquece contactos desde un .vcf")
pe.add_argument("--vcf", required=True)
pe.add_argument("--db", default=DEFAULT_DB)
pe.add_argument("--apply", action="store_true")
args = ap.parse_args(argv)
db = os.path.abspath(os.path.expanduser(args.db))
if not os.path.exists(db):
print(f"ERROR: DB no existe: {db}", file=sys.stderr)
return 2
if args.cmd == "backfill":
return cmd_backfill(db, args.apply)
if args.cmd == "enrich":
vcf = os.path.expanduser(args.vcf)
if not os.path.exists(vcf):
print(f"ERROR: .vcf no existe: {vcf}", file=sys.stderr)
return 2
return cmd_enrich(db, vcf, args.apply)
return 1
if __name__ == "__main__":
sys.exit(main())
+870
View File
@@ -0,0 +1,870 @@
#!/usr/bin/env python3
"""Importa contactos de Google (vCard export) al vault OSINT como fichas de
persona y organizacion, clasificando con LLM y creando relaciones
persona <-> organizacion.
Flujo:
1. Parsear el .vcf con split_vcards (grupo `dav`). Extraer FN, TEL*, EMAIL*, ORG, TITLE.
2. Filtrar ruido/servicio (numeros de operadora, recordatorios, sin >=3 letras).
3. Clasificar con ask_llm (grupo `claude-direct`) por lotes de ~40, pidiendo JSON estricto.
4. Dedup contra personas/*.md existentes (match por slug exacto o subconjunto de tokens).
5. Generar fichas siguiendo projects/osint/CONVENTIONS.md (frontmatter canonico 3b).
Modos:
--dry-run (DEFAULT) no escribe nada; imprime resumen + muestra de 15.
--apply escribe de verdad usando funciones del grupo `obsidian`.
Tool de PROYECTO (vive en projects/osint/tools/). NO es funcion del registry,
NO se indexa. Idempotente: re-ejecutar no duplica (dedup por slug).
"""
import sys
import os
import re
import json
import argparse
import datetime
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from infra.split_vcards import split_vcards # noqa: E402
from core.ask_llm import ask_llm # noqa: E402
from obsidian import ( # noqa: E402
slugify_obsidian_name,
list_obsidian_notes,
read_obsidian_note,
create_obsidian_note,
update_obsidian_note,
)
OSINT = "/home/enmanuel/Obsidian/osint"
VCF_PATH = "/home/enmanuel/Downloads/contacts.vcf"
FUENTE = "Google Contacts export 2026-06-11"
LLM_MODEL = "claude-haiku-4-5-20251001"
BATCH_SIZE = 40
# Topónimos locales que el LLM tiende a confundir con organizaciones cuando
# vienen como sufijo del nombre del contacto (p.ej. "Adrian Quinto Almachar").
# Un lugar NUNCA se convierte en organizacion ni en relacion. (slugificados)
_PLACE_BLOCKLIST = {
"almachar", "barcelona", "madrid", "malaga", "velez-malaga", "velez",
"aliaguilla", "chamana", "axarquia", "torre-del-mar", "torrox", "nerja",
"comares", "benamargosa", "moclinejo", "iznate", "cutar",
}
# Frontmatter canonico de persona (CONVENTIONS.md seccion 3b), en orden.
PERSON_CANON = [
"tipo", "nombre", "slug", "aliases", "sexo", "fecha_nacimiento", "dni",
"telefono", "email", "direccion", "pais", "relaciones", "contexto",
"fuente", "tags",
]
# Frontmatter de organizacion (CONVENTIONS.md secciones 6 y 3b adaptado).
ORG_CANON = [
"tipo", "nombre", "slug", "aliases", "telefono", "email", "direccion",
"pais", "relaciones", "contexto", "fuente", "tags",
]
# --------------------------------------------------------------------------
# 1. Parseo de vCards
# --------------------------------------------------------------------------
def _unfold(vcard_text: str) -> str:
"""Deshace el folding de lineas de vCard (continuacion con espacio/tab)."""
return re.sub(r"\r?\n[ \t]", "", vcard_text)
def _vcard_values(vcard_text: str, prop: str) -> list:
"""Devuelve todos los valores de una propiedad (p.ej. TEL, EMAIL).
Acepta la forma `PROP;PARAMS:valor` y `PROP:valor`. Decodifica escapes
simples de vCard (\\, , \\;, \\n) en el valor.
"""
vals = []
for line in vcard_text.splitlines():
m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE)
if m:
v = m.group(1).strip()
v = v.replace("\\,", ",").replace("\\;", ";").replace("\\n", " ").replace("\\\\", "\\")
v = v.strip()
if v:
vals.append(v)
return vals
def parse_vcard(vcard_text: str) -> dict:
"""Extrae FN, todos los TEL, todos los EMAIL, ORG y TITLE de una vCard."""
txt = _unfold(vcard_text)
fn_vals = _vcard_values(txt, "FN")
org_vals = _vcard_values(txt, "ORG")
org = ""
if org_vals:
# ORG viene como `Empresa;Departamento`. Quitar componentes vacios.
org = " ".join(p.strip() for p in org_vals[0].split(";") if p.strip())
return {
"fn": fn_vals[0] if fn_vals else "",
"tels": _dedup_keep_order(_vcard_values(txt, "TEL")),
"emails": _dedup_keep_order(_vcard_values(txt, "EMAIL")),
"org": org,
"title": (_vcard_values(txt, "TITLE") or [""])[0],
}
def _dedup_keep_order(items: list) -> list:
seen, out = set(), []
for it in items:
key = it.strip().lower()
if key and key not in seen:
seen.add(key)
out.append(it.strip())
return out
# --------------------------------------------------------------------------
# 2. Filtro de ruido/servicio
# --------------------------------------------------------------------------
# Patrones de nombre que delatan numeros de servicio / recordatorios.
_SERVICE_NAME_RE = re.compile(
r"^\*" # empieza por *
r"|^\d{3,5}\b" # codigo corto al inicio (1200, 22122)
r"|att\.?\s*cliente"
r"|buz[oó]n|buzon"
r"|voicemail|voice\s*mail"
r"|gestiona|consulta\b|informaci[oó]n|recarga"
r"|servicio\s+al\s+cliente",
re.IGNORECASE,
)
def is_service(name: str) -> bool:
"""True si el contacto es ruido de operadora / recordatorio / sin nombre real."""
n = (name or "").strip()
if not n:
return True
if _SERVICE_NAME_RE.search(n):
return True
# menos de 3 letras = no es un nombre humano ni de negocio real
letters = re.sub(r"[^A-Za-zÀ-ÿñÑ]", "", n)
if len(letters) < 3:
return True
return False
# --------------------------------------------------------------------------
# 4. Dedup contra fichas existentes
# --------------------------------------------------------------------------
# Tokens demasiado comunes para fundamentar un match por subconjunto.
_STOP_TOKENS = {"de", "del", "la", "las", "el", "los", "y", "san", "da", "do"}
# Nombres de pila muy comunes: compartir SOLO estos no basta para deducir que
# dos contactos son la misma persona (hay decenas de "Antonio", "Maria", "Jose").
# Un match por subconjunto exige al menos un token distintivo fuera de esta lista
# (tipicamente un apellido).
_COMMON_GIVEN = {
"antonio", "jose", "juan", "maria", "manuel", "carlos", "francisco",
"javier", "david", "miguel", "angel", "luis", "pedro", "pablo", "rafael",
"fernando", "sergio", "alberto", "alejandro", "daniel", "jesus", "marcos",
"ana", "carmen", "cristina", "laura", "marta", "lucia", "elena", "sara",
"paula", "raquel", "gema", "lorena", "natalia", "silvia", "rosa", "isabel",
"dani", "javi", "manolo", "paco", "pepe", "alex", "nacho", "mari", "lola",
}
def _name_tokens(name: str) -> set:
slug = slugify_obsidian_name(name or "")
return {t for t in slug.split("-") if t and t not in _STOP_TOKENS}
def load_existing_persons() -> list:
"""Carga (slug, nombre, token_set) de cada ficha de persona del vault."""
out = []
for p in list_obsidian_notes(OSINT, subfolder="personas"):
base = os.path.splitext(os.path.basename(p))[0]
if base.startswith("_"):
continue
try:
fm = read_obsidian_note(p)["frontmatter"]
except Exception:
fm = {}
nombre = fm.get("nombre") or base.replace("-", " ")
out.append({
"slug": base,
"path": p,
"nombre": nombre,
"tokens": _name_tokens(nombre) or _name_tokens(base),
})
return out
def load_existing_orgs() -> dict:
"""Mapa slug -> path de las organizaciones existentes."""
out = {}
for p in list_obsidian_notes(OSINT, subfolder="organizaciones"):
base = os.path.splitext(os.path.basename(p))[0]
if base.startswith("_"):
continue
out[base] = p
return out
def _distinctive(tokens: set) -> bool:
"""True si el conjunto de tokens incluye al menos uno distintivo (apellido):
longitud >=4 y fuera de los nombres de pila ultra-comunes."""
return any(len(t) >= 4 and t not in _COMMON_GIVEN for t in tokens)
def match_existing_person(name: str, existing: list):
"""Busca una persona existente que case con `name`. Conservador a proposito.
Se considera la MISMA persona solo si:
- slug exacto, o
- los tokens del nombre de contacto son subconjunto de los de una ficha
existente (forma menos especifica del mismo nombre), compartiendo
>=2 tokens, ambos con >=2 tokens, y con al menos un token distintivo
(apellido) en el solape.
Esto cubre el caso del estandar ("Manuel Gutierrez" subset de "Manuel
Gutierrez Gamez") y RECHAZA fusiones erroneas por nombre de pila comun
("Antonio", "Maria") o por dos given names compartidos ("Maria Jose" vs
"Jose Maria ..."). Ante la duda, NO casa: se prefiere crear una ficha
nueva (un duplicado es recuperable; una fusion erronea corrompe una
investigacion existente).
"""
cand_slug = slugify_obsidian_name(name)
cand_tokens = _name_tokens(name)
if not cand_tokens:
return None
for ex in existing:
if ex["slug"] == cand_slug and cand_slug:
return ex
for ex in existing:
ex_tokens = ex["tokens"]
if len(cand_tokens) < 2 or len(ex_tokens) < 2:
continue
if not (cand_tokens <= ex_tokens):
continue
shared = cand_tokens & ex_tokens
if len(shared) >= 2 and _distinctive(shared):
return ex
return None
# --------------------------------------------------------------------------
# 3. Clasificacion LLM por lotes
# --------------------------------------------------------------------------
_LLM_SYSTEM = (
"Eres un clasificador de contactos telefonicos en espanol. Devuelves SOLO "
"un array JSON valido, sin texto alrededor, sin markdown."
)
_LLM_INSTRUCTIONS = """Clasifica cada contacto de la lista. Devuelve un array JSON con un objeto por contacto, en el MISMO orden, con estos campos:
{"i": <indice entero>, "tipo": "persona"|"organizacion"|"servicio", "persona_nombre": <string|null>, "org_nombre": <string|null>, "rol": <string|null>, "sexo": "hombre"|"mujer"|null}
Reglas:
- tipo="persona" si el contacto es un individuo (nombre de pila + apellidos).
- tipo="organizacion" si es un negocio, empresa, comercio o servicio (fruteria, autoescuela, seguros, banco, taller, tienda, restaurante, clinica...).
- tipo="servicio" si es un numero de operadora, recordatorio o automatismo (raro: ya filtramos la mayoria).
- Si el contacto MEZCLA persona y organizacion, rellena persona_nombre Y org_nombre Y rol.
Ej: "Emilio Villalba Gestor Orange" -> persona_nombre="Emilio Villalba", org_nombre="Orange", rol="gestor".
Ej: "Abdul Fruteria Velez" -> tipo="organizacion", org_nombre="Fruteria Velez", persona_nombre="Abdul", rol="dueno".
- persona_nombre: nombre LIMPIO de la persona (quita el rol y la empresa). null si no hay persona.
- org_nombre: nombre del negocio/empresa asociado. null si no hay.
- rol: gestor, comercial, dueno, empleado, contacto... null si no aplica.
- sexo: deduce del nombre de pila ("hombre"|"mujer"); null si ambiguo o no hay persona.
- Limpia emojis y typos al inferir, pero NO inventes datos.
Contactos:
"""
def _extract_json_array(text: str):
"""Extrae el primer array JSON `[...]` de una respuesta, tolerando texto alrededor."""
if not text:
return None
# intento directo
try:
v = json.loads(text.strip())
if isinstance(v, list):
return v
except Exception:
pass
# buscar el primer '[' y casar corchetes balanceados
start = text.find("[")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
chunk = text[start:i + 1]
try:
v = json.loads(chunk)
return v if isinstance(v, list) else None
except Exception:
return None
return None
def classify_batch(batch: list, llm_calls: list) -> list:
"""Clasifica un lote de contactos. batch = [(local_idx, contact_dict), ...].
Devuelve lista de dicts de clasificacion alineados por 'i' (local_idx).
Reintenta una vez si el parseo falla; si vuelve a fallar, marca todos como
persona por defecto y lo anota en llm_calls.
"""
lines = []
for idx, c in batch:
extra = []
if c["org"]:
extra.append(f"ORG={c['org']}")
if c["title"]:
extra.append(f"TITLE={c['title']}")
suffix = f" [{'; '.join(extra)}]" if extra else ""
lines.append(f"{idx}. {c['fn']}{suffix}")
prompt = _LLM_INSTRUCTIONS + "\n".join(lines)
for attempt in (1, 2):
try:
resp = ask_llm(prompt, model=LLM_MODEL, system=_LLM_SYSTEM,
max_tokens=4096, echo=False)
except Exception as e: # noqa: BLE001
llm_calls.append({"size": len(batch), "ok": False, "error": f"{type(e).__name__}: {e}", "attempt": attempt})
resp = ""
if not resp:
llm_calls.append({"size": len(batch), "ok": False, "error": "empty response (auth/token?)", "attempt": attempt})
if attempt == 2:
break
continue
arr = _extract_json_array(resp)
if arr is not None:
llm_calls.append({"size": len(batch), "ok": True, "attempt": attempt})
return arr
llm_calls.append({"size": len(batch), "ok": False, "error": "json parse failed", "attempt": attempt})
# fallback: todo persona
return [{"i": idx, "tipo": "persona", "persona_nombre": c["fn"],
"org_nombre": None, "rol": None, "sexo": None,
"_fallback": True} for idx, c in batch]
# --------------------------------------------------------------------------
# 5. Construccion de fichas (planificacion)
# --------------------------------------------------------------------------
def _ordered_frontmatter(values: dict, canon: list) -> dict:
"""Devuelve un dict ordenado segun `canon`, con extras al final."""
fm = {}
for k in canon:
fm[k] = values.get(k)
for k, v in values.items():
if k not in fm:
fm[k] = v
return fm
def _contact_block(tels: list, emails: list) -> str:
"""Seccion ## Contacto con los telefonos/emails extra (mas alla del primero)."""
lines = []
extra_tel = tels[1:]
extra_mail = emails[1:]
if extra_tel or extra_mail:
lines.append("## Contacto")
lines.append("")
for t in extra_tel:
lines.append(f"- telefono: {t}")
for e in extra_mail:
lines.append(f"- email: {e}")
lines.append("")
return "\n".join(lines)
def plan_person(name, sexo, tels, emails, org_slug, org_nombre, rol,
existing_persons, used_person_slugs):
"""Planifica crear o enriquecer una persona. Devuelve dict de plan."""
match = match_existing_person(name, existing_persons)
nombre = name.strip()
if match:
return {
"action": "enrich_person",
"slug": match["slug"],
"path": match["path"],
"nombre_existente": match["nombre"],
"alias_add": nombre,
"tel": tels[0] if tels else None,
"email": emails[0] if emails else None,
"tels": tels,
"emails": emails,
"org_slug": org_slug,
"org_nombre": org_nombre,
"rol": rol,
}
# crear nueva
slug = _resolve_slug(slugify_obsidian_name(nombre) or "contacto", used_person_slugs)
rel = []
if org_slug:
rel.append(f"[[{org_slug}]] — {rol or 'contacto'}")
fm = _ordered_frontmatter({
"tipo": "persona",
"nombre": nombre,
"slug": slug,
"aliases": [],
"sexo": sexo if sexo in ("hombre", "mujer") else None,
"fecha_nacimiento": None,
"dni": None,
"telefono": tels[0] if tels else None,
"email": emails[0] if emails else None,
"direccion": None,
"pais": None,
"relaciones": rel,
"contexto": "google-contacts",
"fuente": FUENTE,
"tags": ["persona", "osint", "contacto"],
}, PERSON_CANON)
body_parts = []
contact = _contact_block(tels, emails)
if contact:
body_parts.append(contact)
if org_slug:
body_parts.append("## Relacionado")
body_parts.append("")
body_parts.append(f"- [[organizaciones/{org_slug}|{org_nombre}]] — {rol or 'contacto'}")
body_parts.append("")
body_parts.append("## Notas")
body_parts.append("")
return {
"action": "create_person",
"slug": slug,
"nombre": nombre,
"frontmatter": fm,
"body": "\n".join(body_parts),
"tel": tels[0] if tels else None,
"email": emails[0] if emails else None,
"org_slug": org_slug,
"org_nombre": org_nombre,
"rol": rol,
}
def _fuzzy_existing_org(slug: str, existing_orgs: dict):
"""Devuelve el slug de una org existente que sea casi-duplicado de `slug`.
Casa cuando uno es prefijo del otro compartiendo >=5 chars de raiz comun
(p.ej. "fenixfood" ~ "fenixfood-sl", "biorganic" ~ "biorganicfood-sl",
"4geekss" ~ "4geeks"). None si no hay casi-duplicado.
"""
for ex in existing_orgs:
a, b = slug, ex
root = a if len(a) <= len(b) else b
longer = b if root is a else a
if len(root) >= 5 and longer.startswith(root):
return ex
# tolerar 1-2 chars de cola repetida ("4geekss" vs "4geeks")
common = os.path.commonprefix([a, b])
if len(common) >= 5 and abs(len(a) - len(b)) <= 2 and (
a[len(common):].strip("s-") == "" or b[len(common):].strip("s-") == ""
):
return ex
return None
def plan_org(org_nombre, tels, emails, existing_orgs, used_org_slugs,
person_slug=None, person_nombre=None, rol=None):
"""Planifica crear (o reutilizar) una organizacion. Devuelve (slug, plan|None).
plan=None si ya existe (en vault o ya planificada en este batch) o si el
nombre es un toponimo (no se crea org de lugar). slug=None si debe ignorarse.
"""
slug = slugify_obsidian_name(org_nombre)
if not slug:
return None, None
# Lugar -> no es organizacion: no crear, no enlazar.
if slug in _PLACE_BLOCKLIST:
return None, None
if slug in existing_orgs or slug in used_org_slugs:
# ya existe: solo enlazar (no crear). Devolvemos el slug, sin plan de creacion.
return slug, None
# Casi-duplicado de una org existente -> reutilizar la existente.
fuzzy = _fuzzy_existing_org(slug, existing_orgs)
if fuzzy:
return fuzzy, None
rel = []
if person_slug:
rel.append(f"[[{person_slug}]] — {rol or 'contacto'}")
fm = _ordered_frontmatter({
"tipo": "organizacion",
"nombre": org_nombre.strip(),
"slug": slug,
"aliases": [],
"telefono": tels[0] if tels else None,
"email": emails[0] if emails else None,
"direccion": None,
"pais": None,
"relaciones": rel,
"contexto": "google-contacts",
"fuente": FUENTE,
"tags": ["organizacion", "osint", "contacto"],
}, ORG_CANON)
body_parts = []
contact = _contact_block(tels, emails)
if contact:
body_parts.append(contact)
if person_slug:
body_parts.append("## Relacionado")
body_parts.append("")
body_parts.append(f"- [[{person_slug}|{person_nombre}]] — {rol or 'contacto'}")
body_parts.append("")
body_parts.append("## Notas")
body_parts.append("")
plan = {
"action": "create_org",
"slug": slug,
"nombre": org_nombre.strip(),
"frontmatter": fm,
"body": "\n".join(body_parts),
}
return slug, plan
def _resolve_slug(base: str, used: set) -> str:
"""Resuelve colisiones de slug con sufijo -2, -3..."""
if base not in used:
used.add(base)
return base
k = 2
while f"{base}-{k}" in used:
k += 1
s = f"{base}-{k}"
used.add(s)
return s
# --------------------------------------------------------------------------
# Orquestacion
# --------------------------------------------------------------------------
def build_plan(contacts, classifications, existing_persons, existing_orgs):
"""Construye la lista de acciones (crear/enriquecer) a partir de la clasificacion."""
by_idx = {}
for c in classifications:
if isinstance(c, dict) and "i" in c:
by_idx[c["i"]] = c
person_plans, org_plans, enrich_plans = [], [], []
relations = [] # (tipo_origen, slug_origen, slug_org, rol)
used_person_slugs = {p["slug"] for p in existing_persons}
used_org_slugs = set()
skipped_service = 0
# indice de personas existentes mutable (para que dedup vea las recien creadas)
persons_index = list(existing_persons)
for idx, contact in contacts:
cls = by_idx.get(idx)
if not cls:
cls = {"tipo": "persona", "persona_nombre": contact["fn"],
"org_nombre": None, "rol": None, "sexo": None}
tipo = (cls.get("tipo") or "persona").lower()
tels = contact["tels"]
emails = contact["emails"]
rol = cls.get("rol")
sexo = cls.get("sexo")
persona_nombre = cls.get("persona_nombre")
org_nombre = cls.get("org_nombre") or contact["org"] or None
if tipo == "servicio":
skipped_service += 1
continue
if tipo == "organizacion":
# crear la org (telefono al de la org); persona asociada si la hay
person_slug = None
person_disp = None
if persona_nombre and len(_name_tokens(persona_nombre)) >= 1:
pmatch = match_existing_person(persona_nombre, persons_index)
if pmatch:
person_slug = pmatch["slug"]
person_disp = pmatch["nombre"]
enrich_plans.append({
"action": "enrich_person", "slug": pmatch["slug"],
"path": pmatch["path"], "nombre_existente": pmatch["nombre"],
"alias_add": persona_nombre, "tel": None, "email": None,
"tels": [], "emails": [],
"org_slug": None, "org_nombre": None, "rol": None,
})
else:
pslug = _resolve_slug(slugify_obsidian_name(persona_nombre) or "contacto", used_person_slugs)
person_slug = pslug
person_disp = persona_nombre.strip()
pfm = _ordered_frontmatter({
"tipo": "persona", "nombre": persona_nombre.strip(), "slug": pslug,
"aliases": [], "sexo": sexo if sexo in ("hombre", "mujer") else None,
"fecha_nacimiento": None, "dni": None, "telefono": None, "email": None,
"direccion": None, "pais": None,
"relaciones": [], # se completa abajo con el org slug
"contexto": "google-contacts", "fuente": FUENTE,
"tags": ["persona", "osint", "contacto"],
}, PERSON_CANON)
person_plans.append({
"action": "create_person", "slug": pslug,
"nombre": persona_nombre.strip(), "frontmatter": pfm,
"body": "## Notas\n", "tel": None, "email": None,
"org_slug": None, "org_nombre": org_nombre, "rol": rol,
"_pending_org_rel": True,
})
persons_index.append({"slug": pslug, "path": None,
"nombre": persona_nombre.strip(),
"tokens": _name_tokens(persona_nombre)})
oslug, oplan = plan_org(org_nombre or contact["fn"], tels, emails,
existing_orgs, used_org_slugs,
person_slug=person_slug, person_nombre=person_disp, rol=rol)
if oslug:
used_org_slugs.add(oslug)
if oplan:
org_plans.append(oplan)
if person_slug:
relations.append(("persona->org", person_slug, oslug, rol))
# completar relacion en el person plan recien creado
for pp in person_plans:
if pp.get("_pending_org_rel") and pp["slug"] == person_slug:
pp["frontmatter"]["relaciones"] = [f"[[{oslug}]] — {rol or 'contacto'}"]
pp["org_slug"] = oslug
pp["body"] = (
"## Relacionado\n\n"
f"- [[organizaciones/{oslug}|{org_nombre}]] — {rol or 'contacto'}\n\n"
"## Notas\n"
)
pp.pop("_pending_org_rel", None)
continue
# tipo == persona
name = persona_nombre or contact["fn"]
org_slug = None
# si la persona trae una org asociada, planificar la org y enlazar
if org_nombre and len(_name_tokens(org_nombre)) >= 1:
oslug, oplan = plan_org(org_nombre, [], [], existing_orgs, used_org_slugs)
if oslug:
used_org_slugs.add(oslug)
org_slug = oslug
if oplan:
# la org no lleva tel/email del contacto (son de la persona)
org_plans.append(oplan)
pplan = plan_person(name, sexo, tels, emails, org_slug, org_nombre, rol,
persons_index, used_person_slugs)
if pplan["action"] == "create_person":
person_plans.append(pplan)
persons_index.append({"slug": pplan["slug"], "path": None,
"nombre": pplan["nombre"],
"tokens": _name_tokens(pplan["nombre"])})
if org_slug:
# backref persona en la org recien planificada
for op in org_plans:
if op["slug"] == org_slug and not op["frontmatter"].get("relaciones"):
op["frontmatter"]["relaciones"] = [f"[[{pplan['slug']}]] — {pplan['rol'] or 'contacto'}"]
else:
enrich_plans.append(pplan)
if org_slug:
relations.append(("persona->org", pplan["slug"], org_slug, rol))
return {
"person_creates": person_plans,
"org_creates": org_plans,
"enriches": enrich_plans,
"relations": relations,
"skipped_service": skipped_service,
}
# --------------------------------------------------------------------------
# Aplicar (solo --apply)
# --------------------------------------------------------------------------
def apply_plan(plan):
"""Escribe las fichas en disco usando funciones del grupo obsidian."""
created_p = created_o = enriched = 0
for pp in plan["person_creates"]:
create_obsidian_note(OSINT, f"personas/{pp['slug']}",
body=pp["body"], frontmatter=pp["frontmatter"],
overwrite=True)
created_p += 1
for op in plan["org_creates"]:
create_obsidian_note(OSINT, f"organizaciones/{op['slug']}",
body=op["body"], frontmatter=op["frontmatter"],
overwrite=True)
created_o += 1
for ep in plan["enriches"]:
path = ep["path"]
if not path or not os.path.exists(path):
continue
note = read_obsidian_note(path)
fm = dict(note["frontmatter"])
# anadir alias del contacto
aliases = fm.get("aliases") or []
if not isinstance(aliases, list):
aliases = [aliases]
if ep["alias_add"] and ep["alias_add"] not in aliases and ep["alias_add"] != fm.get("nombre"):
aliases.append(ep["alias_add"])
# rellenar telefono/email si faltan
if ep.get("tel") and not fm.get("telefono"):
fm["telefono"] = ep["tel"]
if ep.get("email") and not fm.get("email"):
fm["email"] = ep["email"]
update_obsidian_note(path, set_frontmatter={"aliases": aliases,
"telefono": fm.get("telefono"),
"email": fm.get("email")})
enriched += 1
return created_p, created_o, enriched
# --------------------------------------------------------------------------
# Reporte dry-run
# --------------------------------------------------------------------------
def report(plan, stats, llm_calls):
n_create_p = len(plan["person_creates"])
n_enrich = len(plan["enriches"])
n_create_o = len(plan["org_creates"])
n_rel = len(plan["relations"])
print("=" * 64)
print("DRY-RUN — import_google_contacts.py")
print("=" * 64)
print(f"vCards totales en el .vcf .................. {stats['total']}")
print(f"descartados servicio/ruido ................ {stats['filtered']}")
print(f"contactos clasificados con LLM ............ {stats['classified']}")
print(f" de ellos sin telefono ni email .......... {stats['no_contact']}")
print("-" * 64)
print(f"PERSONAS a crear .......................... {n_create_p}")
print(f"PERSONAS a enriquecer (ya existen) ........ {n_enrich}")
print(f"ORGANIZACIONES a crear .................... {n_create_o}")
print(f"RELACIONES persona<->organizacion ......... {n_rel}")
print(f"contactos marcados como servicio (LLM) .... {plan['skipped_service']}")
print(f"colisiones de slug resueltas (sufijo) ..... {stats['slug_collisions']}")
print("-" * 64)
print("Llamadas a ask_llm:")
ok = sum(1 for c in llm_calls if c["ok"])
fail = sum(1 for c in llm_calls if not c["ok"])
print(f" exitosas={ok} fallidas={fail} total_intentos={len(llm_calls)}")
for c in llm_calls:
if not c["ok"]:
print(f" FALLO lote size={c['size']} intento={c['attempt']}: {c.get('error')}")
print("=" * 64)
print("MUESTRA de 15 fichas (nombre -> tipo/accion -> tel/email -> relacion):")
print("-" * 64)
sample = []
for pp in plan["person_creates"]:
rel = f" -> org {pp['org_slug']} ({pp['rol'] or 'contacto'})" if pp.get("org_slug") else ""
sample.append(f"[crear persona] {pp['nombre']} | tel={pp['tel'] or '-'} email={pp['email'] or '-'}{rel}")
for op in plan["org_creates"]:
rels = op["frontmatter"].get("relaciones") or []
rel = f" -> {rels[0]}" if rels else ""
tel = op["frontmatter"].get("telefono")
eml = op["frontmatter"].get("email")
sample.append(f"[crear org] {op['nombre']} | tel={tel or '-'} email={eml or '-'}{rel}")
for ep in plan["enriches"]:
sample.append(f"[enriquecer] {ep['nombre_existente']} (+alias '{ep['alias_add']}', +tel={ep.get('tel') or '-'})")
for line in sample[:15]:
print(" " + line)
if len(sample) < 1:
print(" (sin fichas planificadas)")
print("=" * 64)
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Importa contactos Google al vault OSINT.")
ap.add_argument("--apply", action="store_true",
help="Escribe las fichas en disco. Por defecto: dry-run (no escribe).")
ap.add_argument("--vcf", default=VCF_PATH, help="Ruta al .vcf de contactos.")
ap.add_argument("--limit", type=int, default=0,
help="(debug) limita el numero de contactos clasificados.")
args = ap.parse_args()
if not os.path.exists(args.vcf):
print(f"ERROR: no existe el .vcf: {args.vcf}", file=sys.stderr)
return 1
with open(args.vcf, "r", encoding="utf-8", errors="replace") as f:
vcf_text = f.read()
cards = split_vcards(vcf_text)
total = len(cards)
contacts = []
filtered = 0
for raw in cards:
c = parse_vcard(raw)
if is_service(c["fn"]):
filtered += 1
continue
contacts.append(c)
if args.limit and args.limit > 0:
contacts = contacts[:args.limit]
# indexar contactos
indexed = list(enumerate(contacts))
# clasificar por lotes
llm_calls = []
classifications = []
for start in range(0, len(indexed), BATCH_SIZE):
batch = indexed[start:start + BATCH_SIZE]
classifications.extend(classify_batch(batch, llm_calls))
existing_persons = load_existing_persons()
existing_orgs = load_existing_orgs()
# contar colisiones: comparar slugs base antes de resolver
base_slugs = {}
for _, c in indexed:
s = slugify_obsidian_name(c["fn"])
if s:
base_slugs[s] = base_slugs.get(s, 0) + 1
slug_collisions = sum(v - 1 for v in base_slugs.values() if v > 1)
plan = build_plan(indexed, classifications, existing_persons, existing_orgs)
no_contact = sum(1 for _, c in indexed if not c["tels"] and not c["emails"])
stats = {
"total": total,
"filtered": filtered,
"classified": len(indexed),
"no_contact": no_contact,
"slug_collisions": slug_collisions,
}
report(plan, stats, llm_calls)
if args.apply:
cp, co, en = apply_plan(plan)
print(f"\nAPLICADO: personas creadas={cp} orgs creadas={co} enriquecidas={en}")
else:
print("\n(dry-run: no se escribio nada. Usa --apply para aplicar.)")
return 0
if __name__ == "__main__":
sys.exit(main())
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env python3
"""Sincroniza el servidor CardDAV (Xandikos) HACIA el vault OSINT (sentido
inverso de sync_osint_to_dav.py).
El movil (DAVx5) edita la libreta de contactos de Xandikos: anade contactos
nuevos, corrige telefonos/nombres. Este tool trae esos cambios de vuelta al
vault SIN pisar la capa OSINT (relaciones, dni, contexto, fuente, tags) que es
autoritativa en el lado del vault.
MODELO DE RECONCILIACION
========================
- El vault es la FUENTE DE VERDAD de los campos OSINT.
- Xandikos es la fuente de los cambios de AGENDA (nombre, telefono, email,
aliases) que llegan del movil.
Por cada vCard del servidor decidimos su accion comparando contra:
1. el estado persistente (.sync_state.json): UID -> {etag, vault_slug, ...}
2. el vault (match por telefono/email normalizado, o por slug del UID osint-)
Acciones:
- CREATE : UID nuevo (no en estado) y sin ficha que case por tel/email.
-> crea personas/<slug>.md con contexto: movil.
- UPDATE : el etag cambio respecto al estado (el movil lo edito).
-> actualiza SOLO los campos de agenda (nombre, telefono,
email, aliases), PRESERVANDO los campos OSINT.
- LINK : UID nuevo pero ya hay ficha que case por tel/email (no es un
contacto nuevo, es el mismo que ya esta en el vault).
-> registra el UID en el estado y, si el etag implica edicion,
aplica el UPDATE de agenda. No crea ficha duplicada.
- SKIP : etag igual al del estado -> sin cambios desde el ultimo sync.
last-write-wins por timestamp en campos de agenda: cuando el etag de Xandikos
cambia, el movil gano la ultima escritura de ese contacto -> se aplica al
vault. El push posterior (sync_osint_to_dav --apply, en el DAG) re-emite el
vault ya autoritativo en OSINT.
MODOS
=====
--dry-run (DEFAULT) reporta que crearia/actualizaria. NO escribe el vault ni
el estado.
--apply escribe el vault (create/update notes) y reescribe
.sync_state.json.
Tool de PROYECTO (vive en projects/osint/tools/). NO es funcion del registry,
NO se indexa.
"""
import sys
import os
import re
import json
import time
import argparse
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from infra.pass_get_secret import pass_get_secret # noqa: E402
from infra.dav_get_collection import dav_get_collection # noqa: E402
from obsidian import ( # noqa: E402
slugify_obsidian_name,
list_obsidian_notes,
read_obsidian_note,
create_obsidian_note,
update_obsidian_note,
)
# --------------------------------------------------------------------------
# Configuracion (espejo de sync_osint_to_dav.py)
# --------------------------------------------------------------------------
OSINT = "/home/enmanuel/Obsidian/osint"
DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
DAV_USER = "enmanuel"
DAV_COLLECTION = "/enmanuel/contacts/addressbook/"
PASS_SECRET = "dav/xandikos-enmanuel"
STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
".sync_state.json")
# Carpetas del vault que representan contactos (mismo set que el push).
SUBFOLDERS = (("personas", "persona"), ("organizaciones", "organizacion"))
# Frontmatter canonico de persona (CONVENTIONS.md 3b), en orden. Las fichas que
# creamos aqui lo respetan campo a campo.
PERSON_CANON = [
"tipo", "nombre", "slug", "aliases", "sexo", "fecha_nacimiento", "dni",
"telefono", "email", "direccion", "pais", "relaciones", "contexto",
"fuente", "tags",
]
# Campos de AGENDA que el movil puede editar y que actualizamos en un UPDATE.
# El resto del frontmatter (campos OSINT) NUNCA se toca en un UPDATE.
AGENDA_FIELDS = ("nombre", "telefono", "email", "aliases")
# --------------------------------------------------------------------------
# Parseo de vCard (entrada)
# --------------------------------------------------------------------------
def _unfold(vcard_text: str) -> str:
"""Deshace el folding de lineas de vCard (continuacion con espacio/tab)."""
return re.sub(r"\r?\n[ \t]", "", vcard_text)
def _vcard_values(vcard_text: str, prop: str) -> list:
"""Devuelve todos los valores de una propiedad (TEL, EMAIL, UID, ...).
Acepta `PROP;PARAMS:valor` y `PROP:valor`. Decodifica los escapes simples de
vCard (\\, , \\; , \\n) en el valor.
"""
vals = []
for line in vcard_text.splitlines():
m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE)
if m:
v = m.group(1).strip()
v = (v.replace("\\n", "\n").replace("\\,", ",")
.replace("\\;", ";").replace("\\\\", "\\"))
v = v.strip()
if v:
vals.append(v)
return vals
def parse_vcard(vcard_text: str) -> dict:
"""Extrae los campos de agenda + OSINT de un vCard.
Devuelve {uid, fn, tels[], emails[], nicknames[], sexo, dni, pais,
bday, categories[], note}. Solo lee; no escribe.
"""
txt = _unfold(vcard_text)
nick_raw = _vcard_values(txt, "NICKNAME")
nicknames = []
for nr in nick_raw:
nicknames.extend([a.strip() for a in nr.split(",") if a.strip()])
cat_raw = _vcard_values(txt, "CATEGORIES")
categories = []
for cr in cat_raw:
categories.extend([c.strip() for c in cr.split(",") if c.strip()])
return {
"uid": (_vcard_values(txt, "UID") or [""])[0],
"fn": (_vcard_values(txt, "FN") or [""])[0],
"tels": _dedup_keep_order(_vcard_values(txt, "TEL")),
"emails": _dedup_keep_order(_vcard_values(txt, "EMAIL")),
"nicknames": _dedup_keep_order(nicknames),
"sexo": (_vcard_values(txt, "X-OSINT-SEXO") or [None])[0],
"dni": (_vcard_values(txt, "X-OSINT-DNI") or [None])[0],
"pais": (_vcard_values(txt, "X-OSINT-PAIS") or [None])[0],
"bday": (_vcard_values(txt, "BDAY") or [None])[0],
"categories": categories,
"note": (_vcard_values(txt, "NOTE") or [""])[0],
}
def _dedup_keep_order(items: list) -> list:
seen, out = set(), []
for it in items:
key = str(it).strip().lower()
if key and key not in seen:
seen.add(key)
out.append(str(it).strip())
return out
def _norm_phone(p) -> str:
"""Normaliza un telefono a sus ultimos 9 digitos (numero nacional ES)."""
if not p:
return ""
d = re.sub(r"\D", "", str(p))
return d[-9:] if len(d) >= 9 else d
# --------------------------------------------------------------------------
# Indice del vault: telefono/email/slug -> ficha existente
# --------------------------------------------------------------------------
def _norm(v):
"""Normaliza 'null'/''/None del frontmatter a None real."""
if v is None:
return None
if isinstance(v, str) and v.strip().lower() in ("null", "none", ""):
return None
return v
def load_vault_index() -> dict:
"""Recorre las fichas del vault y construye los indices de match.
Devuelve {by_phone, by_email, by_slug, by_path, count} donde cada indice
mapea la clave -> dict de la ficha {slug, path, nombre, telefono, email,
aliases, mtime}. by_slug mapea slug -> ficha (para casar UID osint-<slug>).
"""
by_phone, by_email, by_slug, by_path = {}, {}, {}, {}
count = 0
for subfolder, _tipo in SUBFOLDERS:
folder = os.path.join(OSINT, subfolder)
if not os.path.isdir(folder):
continue
for p in list_obsidian_notes(OSINT, subfolder=subfolder):
# Solo fichas de nivel-1 (personas/<slug>.md), no las sub-notas.
if os.path.basename(os.path.dirname(p)) != subfolder:
continue
base = os.path.splitext(os.path.basename(p))[0]
if base.startswith("_"):
continue
try:
nd = read_obsidian_note(p)
except Exception: # noqa: BLE001
continue
fm = nd.get("frontmatter") or {}
slug = _norm(fm.get("slug")) or base
tel = _norm(fm.get("telefono"))
em = _norm(fm.get("email"))
aliases = fm.get("aliases") or []
if not isinstance(aliases, list):
aliases = [aliases]
ficha = {
"slug": slug,
"path": p,
"nombre": _norm(fm.get("nombre")) or base.replace("-", " "),
"telefono": tel,
"email": em,
"aliases": aliases,
"mtime": os.path.getmtime(p),
}
count += 1
by_slug[slug] = ficha
by_path[p] = ficha
if tel:
by_phone.setdefault(_norm_phone(tel), ficha)
if em:
by_email.setdefault(str(em).strip().lower(), ficha)
return {"by_phone": by_phone, "by_email": by_email,
"by_slug": by_slug, "by_path": by_path, "count": count}
def match_vault(parsed: dict, index: dict):
"""Devuelve la ficha del vault que casa con un vCard (o None).
Match por telefono primero (mas fiable), luego email, luego por el slug del
UID si es del estilo osint-<slug> (creado por el push). Devuelve (ficha,
source) con source in {phone, email, slug_uid} o (None, None).
"""
for tel in parsed["tels"]:
hit = index["by_phone"].get(_norm_phone(tel))
if hit:
return hit, "phone"
for em in parsed["emails"]:
hit = index["by_email"].get(str(em).strip().lower())
if hit:
return hit, "email"
uid = parsed.get("uid") or ""
if uid.startswith("osint-"):
hit = index["by_slug"].get(uid[len("osint-"):])
if hit:
return hit, "slug_uid"
return None, None
# --------------------------------------------------------------------------
# Estado persistente
# --------------------------------------------------------------------------
def load_state() -> dict:
"""Carga .sync_state.json. {} si no existe o esta corrupto."""
try:
with open(STATE_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_state(state: dict):
"""Reescribe .sync_state.json de forma atomica (tmp + rename)."""
tmp = STATE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True)
os.replace(tmp, STATE_PATH)
# --------------------------------------------------------------------------
# Construccion de fichas y de updates de agenda
# --------------------------------------------------------------------------
def _slug_for(parsed: dict) -> str:
"""Slug estable para una ficha nueva: del FN, o del UID si no hay FN."""
if parsed.get("fn"):
s = slugify_obsidian_name(parsed["fn"])
if s:
return s
return slugify_obsidian_name(parsed.get("uid") or "contacto-movil") or "contacto-movil"
def _unique_slug(slug: str, index: dict, planned_slugs: set) -> str:
"""Evita colision de slug: si ya existe, sufija -2, -3, ..."""
if slug not in index["by_slug"] and slug not in planned_slugs:
return slug
i = 2
while f"{slug}-{i}" in index["by_slug"] or f"{slug}-{i}" in planned_slugs:
i += 1
return f"{slug}-{i}"
def build_new_frontmatter(parsed: dict, slug: str) -> dict:
"""Frontmatter canonico (3b) para una ficha creada desde el movil.
contexto: movil (marca el origen). fuente: el UID de Xandikos. Los campos
OSINT que el vCard NO trae se dejan en null/[] como exige el esquema.
"""
bday = parsed.get("bday")
if bday and re.match(r"^\d{8}$", str(bday)):
bday = f"{bday[:4]}-{bday[4:6]}-{bday[6:8]}"
fm = {
"tipo": "persona",
"nombre": parsed.get("fn") or slug.replace("-", " "),
"slug": slug,
"aliases": parsed.get("nicknames") or [],
"sexo": parsed.get("sexo"),
"fecha_nacimiento": bday,
"dni": parsed.get("dni"),
"telefono": parsed["tels"][0] if parsed["tels"] else None,
"email": parsed["emails"][0] if parsed["emails"] else None,
"direccion": None,
"pais": parsed.get("pais"),
"relaciones": [],
"contexto": "movil",
"fuente": f"Xandikos UID {parsed.get('uid')}".strip(),
"tags": ["persona", "osint", "movil"],
}
# Reordenar segun el canon (las claves extra van al final).
ordered = {k: fm.get(k) for k in PERSON_CANON if k in fm}
for k, v in fm.items():
if k not in ordered:
ordered[k] = v
return ordered
def agenda_update_from_vcard(parsed: dict, ficha: dict) -> dict:
"""Calcula el diff de AGENDA (solo campos de agenda) entre vCard y ficha.
Devuelve {changes: {campo: (viejo, nuevo)}, set_frontmatter: {...}} con SOLO
los campos de agenda que difieren. NUNCA toca campos OSINT. Si no hay cambios
de agenda, changes queda vacio.
"""
changes = {}
new_fm = {}
new_nombre = parsed.get("fn")
if new_nombre and new_nombre != ficha.get("nombre"):
changes["nombre"] = (ficha.get("nombre"), new_nombre)
new_fm["nombre"] = new_nombre
new_tel = parsed["tels"][0] if parsed["tels"] else None
cur_tel = ficha.get("telefono")
if new_tel and _norm_phone(new_tel) != _norm_phone(cur_tel or ""):
changes["telefono"] = (cur_tel, new_tel)
new_fm["telefono"] = new_tel
new_email = parsed["emails"][0] if parsed["emails"] else None
cur_email = ficha.get("email")
if new_email and str(new_email).strip().lower() != str(cur_email or "").strip().lower():
changes["email"] = (cur_email, new_email)
new_fm["email"] = new_email
# aliases: union (el movil puede anadir un NICKNAME nuevo). No borramos los
# que ya hubiera en el vault.
new_nicks = parsed.get("nicknames") or []
cur_aliases = ficha.get("aliases") or []
merged = list(cur_aliases)
added = []
for nk in new_nicks:
if nk not in merged:
merged.append(nk)
added.append(nk)
if added:
changes["aliases"] = (cur_aliases, merged)
new_fm["aliases"] = merged
return {"changes": changes, "set_frontmatter": new_fm}
# --------------------------------------------------------------------------
# Plan: clasificar cada vCard en CREATE / UPDATE / LINK / SKIP
# --------------------------------------------------------------------------
def plan_pull(resources: list, index: dict, state: dict) -> dict:
"""Clasifica cada vCard del servidor. Devuelve {actions, counts}.
Cada accion es un dict con {kind, uid, etag, parsed, ...}. kind in
{create, update, link, skip, skip_empty}.
"""
actions = []
counts = {"total": len(resources), "create": 0, "update": 0,
"link": 0, "skip": 0, "skip_empty": 0}
planned_slugs = set()
for r in resources:
parsed = parse_vcard(r["data"])
uid = parsed.get("uid") or os.path.splitext(
os.path.basename(r["href"]))[0]
etag = r.get("etag")
# vCard sin nada util (ni nombre ni tel ni email) -> ignorar.
if not parsed.get("fn") and not parsed["tels"] and not parsed["emails"]:
counts["skip_empty"] += 1
actions.append({"kind": "skip_empty", "uid": uid, "etag": etag,
"href": r["href"]})
continue
st = state.get(uid)
ficha, match_src = match_vault(parsed, index)
if st is None:
# UID nuevo en el estado.
if ficha is None:
# No casa con nada del vault -> contacto nuevo del movil.
slug = _unique_slug(_slug_for(parsed), index, planned_slugs)
planned_slugs.add(slug)
counts["create"] += 1
actions.append({
"kind": "create", "uid": uid, "etag": etag,
"href": r["href"], "parsed": parsed, "slug": slug,
"frontmatter": build_new_frontmatter(parsed, slug),
})
else:
# Ya hay ficha que casa -> el push ya lo subio o es el mismo
# contacto. Es el LINK BASELINE: registramos el mapeo
# UID -> ficha en el estado SIN tocar el vault. NO aplicamos
# agenda aqui: un match por telefono/email es ambiguo (varios
# contactos del movil comparten numero, o el vault ya dedupeo)
# y el vault es autoritativo en su nombre curado. Los cambios de
# agenda solo fluyen cuando, ya con el UID en el estado, su etag
# CAMBIA en un sync posterior (rama UPDATE de abajo). diff vacio.
counts["link"] += 1
actions.append({
"kind": "link", "uid": uid, "etag": etag,
"href": r["href"], "parsed": parsed,
"ficha": ficha, "match_src": match_src,
"diff": {"changes": {}, "set_frontmatter": {}},
})
else:
# UID ya conocido. Comparar etag.
if st.get("etag") == etag and etag is not None:
counts["skip"] += 1
actions.append({"kind": "skip", "uid": uid, "etag": etag,
"href": r["href"]})
continue
# etag cambio (o no teniamos etag) -> el movil edito. Update agenda.
if ficha is None:
# Conociamos el UID pero la ficha ya no casa por tel/email
# (¿cambio el numero?). Intentar por slug guardado en estado.
saved_slug = st.get("vault_slug")
if saved_slug and saved_slug in index["by_slug"]:
ficha = index["by_slug"][saved_slug]
if ficha is None:
# No encontramos la ficha -> tratar como create (raro).
slug = _unique_slug(_slug_for(parsed), index, planned_slugs)
planned_slugs.add(slug)
counts["create"] += 1
actions.append({
"kind": "create", "uid": uid, "etag": etag,
"href": r["href"], "parsed": parsed, "slug": slug,
"frontmatter": build_new_frontmatter(parsed, slug),
})
else:
diff = agenda_update_from_vcard(parsed, ficha)
counts["update"] += 1
actions.append({
"kind": "update", "uid": uid, "etag": etag,
"href": r["href"], "parsed": parsed,
"ficha": ficha, "diff": diff,
})
return {"actions": actions, "counts": counts}
# --------------------------------------------------------------------------
# Aplicar (solo --apply)
# --------------------------------------------------------------------------
def apply_pull(plan: dict, state: dict, index: dict) -> dict:
"""Ejecuta el plan: crea/actualiza fichas y reescribe el estado."""
res = {"created": 0, "updated": 0, "linked": 0, "errors": []}
now = int(time.time())
for a in plan["actions"]:
kind = a["kind"]
uid = a["uid"]
try:
if kind == "create":
rel = f"personas/{a['slug']}.md"
path = create_obsidian_note(
OSINT, rel, body="\n## Notas\n",
frontmatter=a["frontmatter"], overwrite=False)
res["created"] += 1
state[uid] = {"etag": a["etag"], "vault_slug": a["slug"],
"vault_path": path,
"vault_mtime": os.path.getmtime(path),
"last_sync": now}
elif kind in ("update", "link"):
ficha = a["ficha"]
diff = a["diff"]
if diff["set_frontmatter"]:
update_obsidian_note(
ficha["path"], set_frontmatter=diff["set_frontmatter"])
if kind == "update":
res["updated"] += 1
else:
res["linked"] += 1
else:
if kind == "link":
res["linked"] += 1
mtime = (os.path.getmtime(ficha["path"])
if os.path.exists(ficha["path"]) else now)
state[uid] = {"etag": a["etag"], "vault_slug": ficha["slug"],
"vault_path": ficha["path"],
"vault_mtime": mtime, "last_sync": now}
elif kind in ("skip", "skip_empty"):
# Mantener/actualizar el etag conocido sin tocar el vault.
if kind == "skip":
prev = state.get(uid, {})
prev["etag"] = a["etag"]
prev["last_sync"] = now
state[uid] = prev
except Exception as e: # noqa: BLE001
res["errors"].append({"uid": uid, "kind": kind, "error": str(e)})
return res
# --------------------------------------------------------------------------
# Reporte
# --------------------------------------------------------------------------
def report(plan: dict, index: dict, sample_n: int = 8):
c = plan["counts"]
print("=" * 72)
print("DRY-RUN — sync_dav_to_osint.py (Xandikos CardDAV -> vault OSINT)")
print("=" * 72)
print(f"servidor ........................ {DAV_BASE}")
print(f"coleccion ....................... {DAV_COLLECTION}")
print(f"fichas en el vault .............. {index['count']}")
print("-" * 72)
print(f"vCards en Xandikos .............. {c['total']}")
print(f" CREATE (contacto nuevo movil) {c['create']}")
print(f" UPDATE (movil edito agenda) .. {c['update']}")
print(f" LINK (ya en vault, enlazar) {c['link']}")
print(f" SKIP (sin cambios) ......... {c['skip']}")
print(f" SKIP (vCard vacio) ......... {c['skip_empty']}")
print("=" * 72)
# Muestra de CREATE.
creates = [a for a in plan["actions"] if a["kind"] == "create"]
if creates:
print(f"NUEVAS fichas a crear (muestra {min(sample_n, len(creates))} "
f"de {len(creates)}):")
print("-" * 72)
for a in creates[:sample_n]:
fm = a["frontmatter"]
print(f" + personas/{a['slug']}.md | UID {a['uid']}")
print(f" nombre={fm.get('nombre')!r} tel={fm.get('telefono')!r} "
f"email={fm.get('email')!r} contexto={fm.get('contexto')!r}")
print("=" * 72)
# Muestra de UPDATE con changes reales.
updates = [a for a in plan["actions"]
if a["kind"] in ("update", "link") and a["diff"]["changes"]]
if updates:
print(f"UPDATES de agenda (movil edito) (muestra "
f"{min(sample_n, len(updates))} de {len(updates)}):")
print("-" * 72)
for a in updates[:sample_n]:
f = a["ficha"]
print(f" ~ {f['path'].split('/osint/')[-1]} | UID {a['uid']} "
f"[{a['kind']}]")
for campo, (old, new) in a["diff"]["changes"].items():
print(f" {campo}: {old!r} -> {new!r} (OSINT preservado)")
print("=" * 72)
elif c["update"] or c["link"]:
print("(UPDATE/LINK detectados pero sin cambios de agenda netos)")
print("=" * 72)
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Sincroniza Xandikos CardDAV -> vault OSINT (reverse). "
"Trae contactos nuevos y ediciones de agenda del movil "
"preservando la capa OSINT del vault.")
ap.add_argument("--apply", action="store_true",
help="Escribe el vault y el estado. Por defecto: dry-run.")
ap.add_argument("--dry-run", action="store_true",
help="No escribe nada (es el comportamiento por defecto; "
"el flag existe para ser explicito).")
ap.add_argument("--sample", type=int, default=8,
help="Numero de fichas a mostrar por categoria en el dry-run.")
args = ap.parse_args()
secret = pass_get_secret(PASS_SECRET)
if secret.get("status") != "ok":
print(f"ERROR: no se pudo leer el secreto '{PASS_SECRET}' de pass: "
f"{secret.get('error')}", file=sys.stderr)
return 1
pwd = secret["value"] # sensible: NUNCA logear
print("Descargando coleccion de Xandikos (1 REPORT)...")
coll = dav_get_collection(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION, "vcard")
if coll.get("status") != "ok":
print(f"ERROR: no se pudo leer la coleccion CardDAV: "
f"{coll.get('error')} (http {coll.get('http_status')})",
file=sys.stderr)
return 1
resources = coll.get("resources", [])
print(f" {len(resources)} vCards descargados.")
print("Indexando el vault OSINT...")
index = load_vault_index()
print(f" {index['count']} fichas indexadas.")
state = load_state()
print(f"Estado previo: {len(state)} UIDs conocidos "
f"({'existe' if os.path.exists(STATE_PATH) else 'nuevo'} .sync_state.json)")
plan = plan_pull(resources, index, state)
report(plan, index, sample_n=args.sample)
if args.apply:
print("\nAPLICANDO (escribiendo el vault + estado)...")
res = apply_pull(plan, state, index)
save_state(state)
print(f"APLICADO: created={res['created']} updated={res['updated']} "
f"linked={res['linked']} errors={len(res['errors'])}")
for e in res["errors"][:10]:
print(f" ERROR uid={e['uid']} [{e['kind']}]: {e['error']}")
print(f"Estado reescrito: {len(state)} UIDs en {STATE_PATH}")
else:
print("\n(dry-run: NO se toco el vault ni el estado. "
"Usa --apply para escribir.)")
return 0
if __name__ == "__main__":
sys.exit(main())
+843
View File
@@ -0,0 +1,843 @@
#!/usr/bin/env python3
"""Sincroniza las fichas del vault OSINT hacia el servidor CardDAV (Xandikos),
enriqueciendo cada vCard con la capa de informacion extra de Obsidian: alias,
relaciones, contexto, direccion, lugares, DNI, fecha de nacimiento, etc.
Los contactos ya estan en Xandikos (vinieron de Google con tel/email). Este
sync AÑADE la capa OSINT a esos vCards (o crea los que falten). Es idempotente
por UID: re-ejecutar no duplica.
Tool de PROYECTO (vive en projects/osint/tools/). NO es funcion del registry,
NO se indexa.
=============================================================================
COMO PERSONALIZAR QUE CAMPOS OSINT VIAJAN AL VCARD -> edita FIELD_MAP
=============================================================================
FIELD_MAP es la unica fuente de verdad del mapeo frontmatter -> vCard. Cada
entrada es una tupla:
(campo_frontmatter, sensible, generador)
- campo_frontmatter : str. Clave del frontmatter de la ficha (p.ej. "dni").
Hay claves SINTETICAS calculadas en build_record()
("lugares", "nombre_completo") que tambien se mapean
aqui: no salen tal cual del YAML pero se exponen al
generador como si fueran un campo mas.
- sensible : bool. True marca el campo como dato sensible que viaja
al movil (DNI, direccion, lugares). El dry-run los lista
EXPLICITAMENTE para que confirmes antes de --apply.
- generador : callable(valor) -> list[str]. Recibe el valor del campo
y devuelve 0..N lineas vCard ya formateadas (sin CRLF).
Devuelve [] para omitir el campo (valor vacio/null).
PARA ACTIVAR / DESACTIVAR UN CAMPO:
- Desactivar: comenta (o borra) su linea en FIELD_MAP. Esa propiedad dejara
de generarse y de viajar al servidor.
- Activar uno nuevo: añade una tupla. El generador recibe el valor crudo del
frontmatter; usa las propiedades estandar vCard 3.0 (FN, N, TEL, EMAIL,
ADR, BDAY, NICKNAME, NOTE, CATEGORIES) o una extension X- propia
(X-OSINT-*) para datos que no tienen propiedad estandar.
REGLAS DE FORMATO QUE EL GENERADOR DEBE RESPETAR:
- Una propiedad NOTE por vCard como maximo es lo limpio: por eso TODOS los
textos largos (relaciones, contexto, notas-body, lugares) se acumulan en
NOTE_PARTS (ver helper note()) y se emiten al final como UNA sola linea
NOTE con saltos escapados (\\n). No emitas varias lineas NOTE sueltas.
- Escapa SIEMPRE los valores con vcard_escape() (\\ , ; , : de mas, saltos de
linea). Los generadores de abajo ya lo hacen.
- El orden de FIELD_MAP es el orden en que aparecen las propiedades en el
vCard resultante (salvo NOTE, que siempre va al final).
EJEMPLO — añadir el pais como propiedad X-:
("pais", False, lambda v: [f"X-OSINT-PAIS:{vcard_escape(v)}"] if v else []),
EJEMPLO — desactivar el envio del DNI al movil:
# ("dni", True, lambda v: [f"X-OSINT-DNI:{vcard_escape(v)}"] if v else []),
=============================================================================
"""
import sys
import os
import re
import argparse
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from infra.pass_get_secret import pass_get_secret # noqa: E402
from infra.dav_list_resources import dav_list_resources # noqa: E402
from infra.dav_get_resource import dav_get_resource # noqa: E402
from infra.dav_get_collection import dav_get_collection # noqa: E402
from infra.carddav_put_vcard import carddav_put_vcard # noqa: E402
from obsidian import ( # noqa: E402
list_obsidian_notes,
read_obsidian_note,
)
# --------------------------------------------------------------------------
# Configuracion
# --------------------------------------------------------------------------
OSINT = "/home/enmanuel/Obsidian/osint"
DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
DAV_USER = "enmanuel"
DAV_COLLECTION = "/enmanuel/contacts/addressbook/"
PASS_SECRET = "dav/xandikos-enmanuel"
# Carpetas del vault a sincronizar y el tipo que representan.
SUBFOLDERS = (("personas", "persona"), ("organizaciones", "organizacion"))
# Prefijo del UID para fichas que NO casan con un contacto existente de Google.
OSINT_UID_PREFIX = "osint-"
# --------------------------------------------------------------------------
# Helpers de formato vCard
# --------------------------------------------------------------------------
def vcard_escape(value) -> str:
"""Escapa un valor para una propiedad vCard 3.0 (RFC 2426).
Escapa backslash, coma, punto y coma y normaliza los saltos de linea a la
secuencia escapada \\n (un NOTE multilinea valido va en UNA sola linea
logica con \\n literales).
"""
if value is None:
return ""
s = str(value)
s = s.replace("\\", "\\\\")
s = s.replace("\n", "\\n").replace("\r", "")
s = s.replace(",", "\\,").replace(";", "\\;")
return s
def _date_to_bday(value) -> str:
"""Convierte una fecha ISO (YYYY-MM-DD) o un date a BDAY vCard (YYYYMMDD).
Devuelve "" si no parsea. vCard 3.0 acepta tanto YYYY-MM-DD como YYYYMMDD;
usamos el formato basico sin guiones por compatibilidad amplia.
"""
if not value:
return ""
s = str(value).strip()
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s)
if m:
return f"{m.group(1)}{m.group(2)}{m.group(3)}"
m = re.match(r"^(\d{4})(\d{2})(\d{2})$", s)
if m:
return s
return ""
def _clean_relacion(rel: str) -> str:
"""Normaliza una entrada de `relaciones` para mostrarla legible en NOTE.
Las relaciones del vault son del estilo '[[isagri]] — contacto' o
'[[personas/x|Nombre]] — hermano'. Quitamos los corchetes wikilink y la
barra de alias, dejando texto plano legible en el movil.
"""
if not rel:
return ""
s = str(rel)
# [[target|alias]] -> alias ; [[target]] -> target
def _wl(m):
inner = m.group(1)
return inner.split("|", 1)[1] if "|" in inner else inner.split("/")[-1]
s = re.sub(r"\[\[([^\]]+)\]\]", _wl, s)
return s.strip()
# --------------------------------------------------------------------------
# NOTE acumulado: las propiedades de texto largo se juntan en UNA sola NOTE.
# Cada build_vcard() arranca con esta lista vacia (ver build_vcard()).
# --------------------------------------------------------------------------
_NOTE_PARTS: list = []
def note(text: str) -> list:
"""Acumula un fragmento en la NOTE global y devuelve [] (no emite linea).
Los generadores de FIELD_MAP que producen texto descriptivo (relaciones,
contexto, notas del body, lugares) llaman a note() en vez de emitir su
propia linea NOTE: asi el vCard final lleva una unica propiedad NOTE bien
formada con todos los fragmentos separados por \\n.
"""
if text and str(text).strip():
_NOTE_PARTS.append(str(text).strip())
return []
# --------------------------------------------------------------------------
# FIELD_MAP — EDITA AQUI para activar/desactivar campos (ver cabecera).
# Cada tupla: (campo_frontmatter, sensible, generador(valor) -> list[str])
# --------------------------------------------------------------------------
FIELD_MAP = [
# --- Identidad basica ---
# FN es obligatorio en vCard 3.0; el nombre completo de la ficha.
("nombre_completo", False, lambda v: [f"FN:{vcard_escape(v)}"] if v else []),
# N estructurado (apellidos;nombre;;;) — derivado del nombre completo.
("n_struct", False, lambda v: [f"N:{v}"] if v else []),
# Alias / otros nombres por los que aparece -> NICKNAME (CSV escapado).
("aliases", False, lambda v: [
"NICKNAME:" + ",".join(vcard_escape(a) for a in v)
] if v else []),
# --- Contacto (ya suelen estar en el vCard de Google; los reafirmamos) ---
("telefono", False, lambda v: [f"TEL;TYPE=CELL:{vcard_escape(v)}"] if v else []),
("email", False, lambda v: [f"EMAIL;TYPE=INTERNET:{vcard_escape(v)}"] if v else []),
# --- Localizacion ---
# ADR estructurado vCard: ;;<calle>;<ciudad>;;<cp>;<pais>. Metemos la
# direccion completa en el campo street (componente 3) por simplicidad.
("direccion", True, lambda v: [f"ADR;TYPE=HOME:;;{vcard_escape(v)};;;;"] if v else []),
("pais", False, lambda v: [f"X-OSINT-PAIS:{vcard_escape(v)}"] if v else []),
# --- Datos OSINT sin propiedad estandar -> extensiones X- ---
("dni", True, lambda v: [f"X-OSINT-DNI:{vcard_escape(v)}"] if v else []),
("fecha_nacimiento", False, lambda v: (
[f"BDAY:{_date_to_bday(v)}"] if _date_to_bday(v) else []
)),
("sexo", False, lambda v: [f"X-OSINT-SEXO:{vcard_escape(v)}"] if v else []),
("contexto", False, lambda v: note(f"Contexto: {v}") if v else []),
("fuente", False, lambda v: note(f"Fuente: {v}") if v else []),
# --- Texto descriptivo -> se acumula en la NOTE unica ---
("relaciones", False, lambda v: note(
"Relaciones: " + "; ".join(_clean_relacion(r) for r in v if _clean_relacion(r))
) if v else []),
# Lugares: sintetico (extraido de la seccion ## Lugares del body). Sensible.
("lugares", True, lambda v: note(
"Lugares: " + "; ".join(v)
) if v else []),
# Notas libres del body (seccion ## Notas), si las hay.
("notas_body", False, lambda v: note(v) if v else []),
# --- Categoria para agruparlos en la libreta del movil ---
("tags", False, lambda v: [
"CATEGORIES:" + ",".join(vcard_escape(t) for t in v)
] if v else []),
]
# Conjunto de campos marcados sensibles (para el reporte de privacidad).
SENSITIVE_FIELDS = sorted({campo for campo, sensible, _ in FIELD_MAP if sensible})
# --------------------------------------------------------------------------
# Extraccion de datos sinteticos del body de la ficha
# --------------------------------------------------------------------------
def _section(body: str, header: str) -> str:
"""Devuelve el texto crudo de una seccion `## <header>` del body (hasta la
siguiente cabecera `## ` o el final). "" si la seccion no existe o vacia.
"""
if not body:
return ""
pat = re.compile(rf"^##\s+{re.escape(header)}\s*$(.*?)(?=^##\s|\Z)",
re.MULTILINE | re.DOTALL)
m = pat.search(body)
return m.group(1).strip() if m else ""
def _extract_lugares(body: str) -> list:
"""Extrae los lugares de la seccion `## Lugares` como texto plano legible.
Las lineas son del estilo:
- [[lugares/calle-x|Calle X 1 Almachar Malaga]]
Devolvemos el alias legible (lo de despues del |) o el target slug.
"""
sec = _section(body, "Lugares")
out = []
for line in sec.splitlines():
line = line.strip().lstrip("-").strip()
if not line:
continue
m = re.search(r"\[\[([^\]]+)\]\]", line)
if m:
inner = m.group(1)
disp = inner.split("|", 1)[1] if "|" in inner else inner.split("/")[-1]
out.append(disp.strip())
elif line:
out.append(line)
return out
def _extract_notas(body: str) -> str:
"""Extrae el texto libre de la seccion `## Notas` (si tiene contenido)."""
sec = _section(body, "Notas")
# Limpiar wikilinks/embeds del texto de notas para que sea legible plano.
sec = re.sub(r"!?\[\[([^\]]+)\]\]",
lambda m: (m.group(1).split("|", 1)[1] if "|" in m.group(1)
else m.group(1).split("/")[-1]),
sec)
return sec.strip()
def _n_struct_from_nombre(nombre: str) -> str:
"""Construye el campo N (apellidos;nombre;;;) heuristico desde el nombre.
vCard N = Family;Given;Additional;Prefix;Suffix. Heuristica simple para
nombres espanoles: primer token = given, resto = family. No es perfecto
(apellidos compuestos) pero es suficiente para ordenar en el movil; el FN
sigue siendo el nombre completo canonico.
"""
if not nombre:
return ""
parts = [p for p in str(nombre).split() if p]
if not parts:
return ""
given = vcard_escape(parts[0])
family = vcard_escape(" ".join(parts[1:])) if len(parts) > 1 else ""
return f"{family};{given};;;"
# --------------------------------------------------------------------------
# Construccion del "record" enriquecido por ficha + del vCard
# --------------------------------------------------------------------------
def build_record(note_data: dict, tipo: str) -> dict:
"""Aplana una ficha (frontmatter + body) en un dict de campos para FIELD_MAP.
Incluye los campos del frontmatter mas los sinteticos:
- nombre_completo : el nombre canonico (FN).
- n_struct : el campo N estructurado.
- lugares : lista extraida de ## Lugares.
- notas_body : texto libre de ## Notas.
Normaliza los 'null' string/None a None y deja las listas vacias como [].
"""
fm = dict(note_data.get("frontmatter") or {})
body = note_data.get("body") or ""
def _norm(v):
if v is None:
return None
if isinstance(v, str) and v.strip().lower() in ("null", "none", ""):
return None
return v
nombre = _norm(fm.get("nombre")) or os.path.splitext(
os.path.basename(note_data["path"]))[0].replace("-", " ")
rec = {k: _norm(v) for k, v in fm.items()}
# Asegurar listas para los campos lista (evita None en los generadores).
for list_field in ("aliases", "relaciones", "tags"):
val = rec.get(list_field)
if val is None:
rec[list_field] = []
elif not isinstance(val, list):
rec[list_field] = [val]
rec["nombre_completo"] = nombre
rec["n_struct"] = _n_struct_from_nombre(nombre)
rec["lugares"] = _extract_lugares(body)
rec["notas_body"] = _extract_notas(body)
rec["_tipo"] = tipo
rec["_slug"] = _norm(fm.get("slug")) or os.path.splitext(
os.path.basename(note_data["path"]))[0]
rec["_path"] = note_data["path"]
return rec
def build_vcard(rec: dict, uid: str) -> dict:
"""Genera el texto vCard 3.0 de un record aplicando FIELD_MAP.
Devuelve {text, fields_emitted} donde fields_emitted es el conjunto de
campos de FIELD_MAP que produjeron alguna linea (incluye los sensibles que
viajaron). NOTE se emite UNA sola vez al final con todos los fragmentos.
"""
global _NOTE_PARTS
_NOTE_PARTS = [] # reset del acumulador de NOTE para este vCard
lines = ["BEGIN:VCARD", "VERSION:3.0"]
fields_emitted = set()
for campo, _sensible, gen in FIELD_MAP:
value = rec.get(campo)
try:
produced = gen(value)
except Exception: # noqa: BLE001 — un generador no debe tumbar el sync
produced = []
if produced:
lines.extend(produced)
fields_emitted.add(campo)
# Emitir la NOTE acumulada (relaciones + contexto + fuente + lugares + notas).
if _NOTE_PARTS:
joined = "\\n".join(vcard_escape(p) for p in _NOTE_PARTS)
lines.append(f"NOTE:{joined}")
fields_emitted.add("NOTE")
lines.append(f"UID:{uid}")
lines.append("END:VCARD")
text = "\r\n".join(lines) + "\r\n"
return {"text": text, "fields_emitted": fields_emitted}
# --------------------------------------------------------------------------
# Indice de dedup: telefono/email -> UID de un vCard ya existente en Xandikos
# --------------------------------------------------------------------------
def _norm_phone(p) -> str:
"""Normaliza un telefono a sus ultimos 9 digitos (numero nacional ES).
Quita espacios, prefijos +34/0034 y separadores. Dos formatos distintos del
mismo numero ('+34 680 43 88 89' y '680438889') colapsan a la misma clave.
"""
if not p:
return ""
d = re.sub(r"\D", "", str(p))
return d[-9:] if len(d) >= 9 else d
def _vcard_prop_values(text: str, prop: str) -> list:
"""Extrae los valores de una propiedad (TEL, EMAIL, UID...) de un vCard."""
out = []
for line in text.splitlines():
m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE)
if m:
v = m.group(1).strip()
if v:
out.append(v)
return out
def build_existing_index(base, user, pwd, collection, *, verbose=True) -> dict:
"""Descarga los vCards existentes de Xandikos y construye los indices de dedup.
Devuelve {phone: {key->uid}, email: {key->uid}, total: int, fetched: int,
errors: int}. Las claves son telefono normalizado (9 digitos) y email en
minusculas. Cada clave apunta al UID del PRIMER vCard que la declara.
Usa dav_get_collection (1 sola peticion REPORT con el contenido inline) en
vez del patron N+1 (PROPFIND + un GET por recurso): para ~1064 contactos baja
de ~9s a ~1s, lo que mantiene el step PUSH del DAG dentro de su timeout.
"""
coll = dav_get_collection(base, user, pwd, collection, "vcard")
if coll.get("status") != "ok":
return {"phone": {}, "email": {}, "total": 0, "fetched": 0,
"errors": 1, "error": coll.get("error")}
resources = coll.get("resources", [])
phone_idx, email_idx = {}, {}
fetched = errors = 0
for r in resources:
text = r.get("data") or ""
if not text:
errors += 1
continue
fetched += 1
uid = (_vcard_prop_values(text, "UID") or [""])[0]
if not uid:
# UID derivado del nombre del recurso si el vCard no lo declara.
uid = os.path.splitext(os.path.basename(r["href"]))[0]
for tel in _vcard_prop_values(text, "TEL"):
k = _norm_phone(tel)
if k:
phone_idx.setdefault(k, uid)
for em in _vcard_prop_values(text, "EMAIL"):
k = em.strip().lower()
if k:
email_idx.setdefault(k, uid)
if verbose:
print(f" indice Xandikos: {fetched}/{len(resources)} vCards leidos, "
f"{len(phone_idx)} telefonos, {len(email_idx)} emails, "
f"{errors} errores de lectura")
return {"phone": phone_idx, "email": email_idx,
"total": len(resources), "fetched": fetched, "errors": errors}
def resolve_uid(rec: dict, index: dict) -> dict:
"""Resuelve el UID a usar para un record: reusa el existente o crea osint-<slug>.
Devuelve {uid, source} donde source es 'phone', 'email' o 'new'. Match por
telefono primero (mas fiable que email para estos contactos), luego email.
"""
tel = rec.get("telefono")
em = rec.get("email")
if tel:
hit = index["phone"].get(_norm_phone(tel))
if hit:
return {"uid": hit, "source": "phone"}
if em:
hit = index["email"].get(str(em).strip().lower())
if hit:
return {"uid": hit, "source": "email"}
return {"uid": OSINT_UID_PREFIX + rec["_slug"], "source": "new"}
# --------------------------------------------------------------------------
# Orquestacion: planificar todas las fichas
# --------------------------------------------------------------------------
def load_fichas() -> list:
"""Carga todas las fichas de las carpetas configuradas como (note_data, tipo)."""
out = []
for subfolder, tipo in SUBFOLDERS:
for p in list_obsidian_notes(OSINT, subfolder=subfolder):
# Solo fichas de nivel-1 (personas/<slug>.md). Excluye las sub-notas de
# documentos (personas/<slug>/dni.md, fotos.md, ...) que no son contactos.
if os.path.basename(os.path.dirname(p)) != subfolder:
continue
base = os.path.splitext(os.path.basename(p))[0]
if base.startswith("_"):
continue
try:
nd = read_obsidian_note(p)
except Exception: # noqa: BLE001
continue
out.append((nd, tipo))
return out
def plan_sync(index: dict) -> dict:
"""Construye el plan completo: un vCard enriquecido por ficha + su UID.
Devuelve {records: [...], counts: {...}} donde cada record del plan lleva
{rec, uid, uid_source, vcard, fields_emitted, sensitive_emitted}.
"""
fichas = load_fichas()
plan = []
counts = {
"fichas_total": len(fichas),
"persona": 0, "organizacion": 0,
"uid_reused_phone": 0, "uid_reused_email": 0, "uid_new": 0,
}
sensitive_carrying = {f: 0 for f in SENSITIVE_FIELDS}
for note_data, tipo in fichas:
rec = build_record(note_data, tipo)
counts[tipo] = counts.get(tipo, 0) + 1
resolved = resolve_uid(rec, index)
uid = resolved["uid"]
built = build_vcard(rec, uid)
emitted = built["fields_emitted"]
sens_here = sorted(f for f in SENSITIVE_FIELDS if f in emitted)
for f in sens_here:
sensitive_carrying[f] += 1
counts_key = {
"phone": "uid_reused_phone",
"email": "uid_reused_email",
"new": "uid_new",
}[resolved["source"]]
counts[counts_key] += 1
plan.append({
"rec": rec,
"uid": uid,
"uid_source": resolved["source"],
"vcard": built["text"],
"fields_emitted": emitted,
"sensitive_emitted": sens_here,
})
return {"records": plan, "counts": counts,
"sensitive_carrying": sensitive_carrying}
# --------------------------------------------------------------------------
# Aplicar (solo --apply)
# --------------------------------------------------------------------------
def apply_sync(plan, base, user, pwd, collection) -> dict:
"""Sube cada vCard del plan a Xandikos via carddav_put_vcard. Idempotente."""
ok = fail = 0
errors = []
for item in plan["records"]:
res = carddav_put_vcard(base, user, pwd, collection,
item["uid"], item["vcard"])
if res.get("status") == "ok":
ok += 1
else:
fail += 1
errors.append({"uid": item["uid"], "error": res.get("error"),
"http_status": res.get("http_status")})
return {"ok": ok, "fail": fail, "errors": errors}
# --------------------------------------------------------------------------
# Reporte dry-run
# --------------------------------------------------------------------------
def report(plan, index, sample_n=5):
c = plan["counts"]
print("=" * 70)
print("DRY-RUN — sync_osint_to_dav.py (vault OSINT -> Xandikos CardDAV)")
print("=" * 70)
print(f"servidor ........................ {DAV_BASE}")
print(f"coleccion ....................... {DAV_COLLECTION}")
print("-" * 70)
print(f"vCards ya en Xandikos ........... {index['total']} "
f"(leidos {index['fetched']}, errores {index['errors']})")
print(f"fichas OSINT a sincronizar ...... {c['fichas_total']}")
print(f" personas ...................... {c.get('persona', 0)}")
print(f" organizaciones ................ {c.get('organizacion', 0)}")
print("-" * 70)
print("Resolucion de UID (dedup contra Xandikos):")
print(f" reusa UID existente (telefono) {c['uid_reused_phone']}")
print(f" reusa UID existente (email) ... {c['uid_reused_email']}")
print(f" UID nuevo (osint-<slug>) ...... {c['uid_new']}")
reused = c['uid_reused_phone'] + c['uid_reused_email']
print(f" => {reused} fichas ENRIQUECEN un contacto existente, "
f"{c['uid_new']} CREAN uno nuevo")
print("=" * 70)
# ---- Privacidad: que campos sensibles viajarian al movil ----
print("PRIVACIDAD — campos SENSIBLES que viajarian al movil con --apply:")
print("-" * 70)
any_sensitive = False
for f in SENSITIVE_FIELDS:
n = plan["sensitive_carrying"].get(f, 0)
if n:
any_sensitive = True
print(f" [SENSIBLE] {f:<12} -> presente en {n} vCard(s)")
if not any_sensitive:
print(" (ningun campo sensible viajaria con el FIELD_MAP actual)")
else:
print("")
print(" Estos datos se escribirian en la libreta de contactos del movil")
print(" (que se sincroniza/respalda fuera de tu control). Revisa el")
print(" FIELD_MAP y comenta los campos que NO quieras enviar antes de")
print(" ejecutar --apply.")
print("=" * 70)
# ---- Campos OSINT no sensibles que tambien viajan ----
emitted_counter = {}
for item in plan["records"]:
for f in item["fields_emitted"]:
emitted_counter[f] = emitted_counter.get(f, 0) + 1
print("Cobertura de campos (cuantos vCards llevan cada propiedad):")
print("-" * 70)
for f in sorted(emitted_counter, key=lambda k: -emitted_counter[k]):
mark = " [SENSIBLE]" if f in SENSITIVE_FIELDS else ""
print(f" {f:<16} {emitted_counter[f]:>4}{mark}")
print("=" * 70)
# ---- Muestra de N vCards completos ----
print(f"MUESTRA de {sample_n} vCards completos (asi se veria el mapeo):")
print("=" * 70)
# Elegir muestras variadas: prioriza las que llevan mas campos OSINT.
enriched = sorted(plan["records"],
key=lambda it: -len(it["fields_emitted"]))
shown = 0
for item in enriched:
rec = item["rec"]
src = {"phone": "reusa UID (tel)", "email": "reusa UID (email)",
"new": "UID nuevo"}[item["uid_source"]]
print(f"--- {rec['nombre_completo']} [{rec['_tipo']}] | {src}: {item['uid']}")
print(item["vcard"].replace("\r\n", "\n").rstrip())
print("")
shown += 1
if shown >= sample_n:
break
print("=" * 70)
# --------------------------------------------------------------------------
# Audit de consistencia (--check) — read-only, vault <-> Xandikos
# --------------------------------------------------------------------------
def _norm_email(e) -> str:
return str(e).strip().lower() if e else ""
def audit_consistency(base, user, pwd, collection) -> dict:
"""Compara vault OSINT <-> Xandikos y devuelve el reporte de drift.
Read-only: descarga la coleccion en 1 REPORT (dav_get_collection) y la cruza
con las fichas del vault. Reporta:
- vault_sin_vcard : fichas del vault sin contraparte en Xandikos
(por telefono/email normalizado, ni por UID osint-).
- vcard_huerfano : vCards del servidor sin ficha en el vault (anadidos
en el movil que aun no se han traido / contactos no-OSINT).
- difiere : contactos que casan pero donde tel/email/alias difieren
entre vault y vCard.
Devuelve {vault_total, vcard_total, vault_sin_vcard:[...], vcard_huerfano:[...],
difiere:[...]}.
"""
coll = dav_get_collection(base, user, pwd, collection, "vcard")
if coll.get("status") != "ok":
return {"error": coll.get("error"), "http_status": coll.get("http_status")}
resources = coll.get("resources", [])
# Indice del servidor: telefono/email -> {uid, fn, tels, emails, nicks, href}
srv_by_phone, srv_by_email, srv_by_uid = {}, {}, {}
srv_records = []
for r in resources:
txt = re.sub(r"\r?\n[ \t]", "", r["data"]) # unfold
uid = (_vcard_prop_values(txt, "UID") or [""])[0] or \
os.path.splitext(os.path.basename(r["href"]))[0]
fn = (_vcard_prop_values(txt, "FN") or [""])[0]
tels = _vcard_prop_values(txt, "TEL")
emails = _vcard_prop_values(txt, "EMAIL")
nicks = []
for nk in _vcard_prop_values(txt, "NICKNAME"):
nicks.extend([a.strip() for a in nk.split(",") if a.strip()])
rec = {"uid": uid, "fn": fn, "tels": tels, "emails": emails,
"nicks": nicks, "href": r["href"], "matched": False}
srv_records.append(rec)
srv_by_uid[uid] = rec
for t in tels:
srv_by_phone.setdefault(_norm_phone(t), rec)
for e in emails:
srv_by_email.setdefault(_norm_email(e), rec)
# Recorrer el vault y cruzar.
vault_total = 0
vault_sin_vcard, difiere = [], []
for note_data, _tipo in load_fichas():
vault_total += 1
fm = note_data.get("frontmatter") or {}
slug = fm.get("slug") or os.path.splitext(
os.path.basename(note_data["path"]))[0]
nombre = fm.get("nombre") or slug.replace("-", " ")
tel = fm.get("telefono")
em = fm.get("email")
aliases = fm.get("aliases") or []
if not isinstance(aliases, list):
aliases = [aliases]
if isinstance(tel, str) and tel.strip().lower() in ("null", "none", ""):
tel = None
if isinstance(em, str) and em.strip().lower() in ("null", "none", ""):
em = None
rec = None
if tel and _norm_phone(tel) in srv_by_phone:
rec = srv_by_phone[_norm_phone(tel)]
elif em and _norm_email(em) in srv_by_email:
rec = srv_by_email[_norm_email(em)]
elif ("osint-" + slug) in srv_by_uid:
rec = srv_by_uid["osint-" + slug]
if rec is None:
vault_sin_vcard.append({"slug": slug, "nombre": nombre,
"telefono": tel, "email": em})
continue
rec["matched"] = True
# Comparar campos de agenda.
diffs = []
if tel and _norm_phone(tel) not in {_norm_phone(t) for t in rec["tels"]}:
diffs.append(("telefono", tel, ", ".join(rec["tels"]) or "-"))
if em and _norm_email(em) not in {_norm_email(e) for e in rec["emails"]}:
diffs.append(("email", em, ", ".join(rec["emails"]) or "-"))
srv_nick_set = {n.lower() for n in rec["nicks"]}
missing_alias = [a for a in aliases if a and a.lower() not in srv_nick_set]
if missing_alias and rec["nicks"]:
diffs.append(("aliases", "; ".join(aliases),
"; ".join(rec["nicks"]) or "-"))
if diffs:
difiere.append({"slug": slug, "nombre": nombre, "uid": rec["uid"],
"diffs": diffs})
vcard_huerfano = [
{"uid": r["uid"], "fn": r["fn"],
"telefono": ", ".join(r["tels"]) or None,
"email": ", ".join(r["emails"]) or None}
for r in srv_records if not r["matched"]
]
return {
"vault_total": vault_total,
"vcard_total": len(srv_records),
"vault_sin_vcard": vault_sin_vcard,
"vcard_huerfano": vcard_huerfano,
"difiere": difiere,
}
def report_audit(a: dict, sample_n: int = 15):
if a.get("error"):
print(f"ERROR audit: {a['error']} (http {a.get('http_status')})",
file=sys.stderr)
return
print("=" * 72)
print("AUDIT de consistencia — vault OSINT <-> Xandikos CardDAV (read-only)")
print("=" * 72)
print(f"fichas en el vault .............. {a['vault_total']}")
print(f"vCards en Xandikos .............. {a['vcard_total']}")
print("-" * 72)
print(f"fichas del vault SIN vCard ...... {len(a['vault_sin_vcard'])}")
print(f"vCards SIN ficha (huerfanos) .... {len(a['vcard_huerfano'])}")
print(f"contactos con AGENDA divergente . {len(a['difiere'])}")
print("=" * 72)
if a["vault_sin_vcard"]:
print(f"FICHAS DEL VAULT SIN vCard (muestra "
f"{min(sample_n, len(a['vault_sin_vcard']))}):")
print("-" * 72)
for f in a["vault_sin_vcard"][:sample_n]:
print(f" - {f['slug']:<36} tel={f['telefono']!r} email={f['email']!r}")
print("=" * 72)
if a["vcard_huerfano"]:
print(f"vCards HUERFANOS (en el movil, sin ficha) (muestra "
f"{min(sample_n, len(a['vcard_huerfano']))}):")
print("-" * 72)
for r in a["vcard_huerfano"][:sample_n]:
print(f" - {r['fn'][:34]:<34} | UID {r['uid']} tel={r['telefono']!r}")
print("=" * 72)
if a["difiere"]:
print(f"AGENDA DIVERGENTE vault vs movil (muestra "
f"{min(sample_n, len(a['difiere']))}):")
print("-" * 72)
for d in a["difiere"][:sample_n]:
print(f" ~ {d['slug']} (UID {d['uid']})")
for campo, vault_v, srv_v in d["diffs"]:
print(f" {campo}: vault={vault_v!r} movil={srv_v!r}")
print("=" * 72)
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Sincroniza fichas OSINT (Obsidian) -> Xandikos CardDAV, "
"enriqueciendo los vCards con la capa OSINT.")
ap.add_argument("--apply", action="store_true",
help="Sube los vCards al servidor. Por defecto: dry-run "
"(no toca el servidor).")
ap.add_argument("--check", action="store_true",
help="Audit de consistencia READ-ONLY: compara vault <-> "
"Xandikos y reporta fichas sin vCard, vCards huerfanos "
"y agendas divergentes. No sube nada.")
ap.add_argument("--sample", type=int, default=5,
help="Numero de vCards completos a mostrar en el dry-run "
"(o de filas por categoria en --check).")
args = ap.parse_args()
secret = pass_get_secret(PASS_SECRET)
if secret.get("status") != "ok":
print(f"ERROR: no se pudo leer el secreto '{PASS_SECRET}' de pass: "
f"{secret.get('error')}", file=sys.stderr)
return 1
pwd = secret["value"] # sensible: NUNCA logear
if args.check:
a = audit_consistency(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION)
report_audit(a, sample_n=max(args.sample, 15))
return 1 if a.get("error") else 0
print("Construyendo indice de contactos ya existentes en Xandikos...")
index = build_existing_index(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION)
if index.get("errors") and index["fetched"] == 0:
print(f"ERROR: no se pudo listar/leer la coleccion CardDAV: "
f"{index.get('error')}", file=sys.stderr)
return 1
plan = plan_sync(index)
report(plan, index, sample_n=args.sample)
if args.apply:
print("\nAPLICANDO (PUT a Xandikos)...")
res = apply_sync(plan, DAV_BASE, DAV_USER, pwd, DAV_COLLECTION)
print(f"APLICADO: ok={res['ok']} fail={res['fail']}")
for e in res["errors"][:10]:
print(f" FALLO uid={e['uid']}: {e['error']} (http {e['http_status']})")
else:
print("\n(dry-run: NO se toco el servidor. Usa --apply para subir los vCards.)")
return 0
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
#
# test_davx5_sync.sh — Protocol-level test of the Xandikos CardDAV server that
# DAVx5 (Android) syncs against. Exercises exactly what DAVx5 does under the
# hood: PROPFIND discovery, addressbook-query REPORT, plus auth and TLS hardening.
#
# This is the reproducible baseline that does NOT need the Android emulator: it
# validates the server contract DAVx5 depends on. The emulator-side verification
# (1065 raw_contacts in the device contacts provider) is documented in the task
# report but is inherently interactive (UI-driven account setup).
#
# Credentials are read from `pass` at runtime — NEVER hardcoded.
# pass entry: dav/xandikos-enmanuel (first line = password)
#
# Usage:
# ./test_davx5_sync.sh # run all checks
# ./test_davx5_sync.sh -v # verbose (show curl details)
#
# Exit code 0 = all checks passed; non-zero = at least one failure.
set -u
# ---- Config -----------------------------------------------------------------
BASE="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
PRINCIPAL_PATH="/enmanuel/"
ADDRESSBOOK_PATH="/enmanuel/contacts/addressbook/"
USER="enmanuel"
PASS_ENTRY="dav/xandikos-enmanuel"
# Expected number of contacts (vCards) currently served. Allow a tolerance band
# so the test survives small day-to-day changes without going green on a
# catastrophic loss (e.g. empty collection).
EXPECTED_VCARDS=1065
TOLERANCE=50 # accept EXPECTED +/- TOLERANCE
# A known contact that must round-trip with its TEL and EMAIL intact.
KNOWN_NAME="Nieves"
KNOWN_TEL="676 95 90 40"
KNOWN_EMAIL="nieves@gomezdeseguraabogados.com"
VERBOSE=0
[ "${1:-}" = "-v" ] && VERBOSE=1
# ---- Helpers ----------------------------------------------------------------
PASS_COUNT=0
FAIL_COUNT=0
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; RESET=$'\e[0m'
ok() { echo "${GREEN}PASS${RESET} $1"; PASS_COUNT=$((PASS_COUNT+1)); }
fail() { echo "${RED}FAIL${RESET} $1"; FAIL_COUNT=$((FAIL_COUNT+1)); }
info() { [ "$VERBOSE" = 1 ] && echo " $1"; return 0; }
# Read the DAV password from pass into a variable. Never echo it.
DAV_PASS="$(pass "$PASS_ENTRY" 2>/dev/null | head -1)"
if [ -z "$DAV_PASS" ]; then
echo "${RED}ABORT${RESET}: could not read password from 'pass $PASS_ENTRY'"
exit 2
fi
AB_URL="${BASE}${ADDRESSBOOK_PATH}"
HTTP_URL="http://${BASE#https://}${ADDRESSBOOK_PATH}"
PROPFIND_BODY='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getetag/></d:prop></d:propfind>'
REPORT_BODY='<?xml version="1.0"?><c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop></c:addressbook-query>'
echo "=== DAVx5 / Xandikos CardDAV protocol test ==="
echo "Server: $BASE"
echo "Collection: $ADDRESSBOOK_PATH"
echo
# ---- (a) Auth required -------------------------------------------------------
echo "--- (a) Authentication ---"
code=$(curl -s -o /dev/null -w '%{http_code}' -X PROPFIND -H "Depth: 0" "$AB_URL")
[ "$code" = "401" ] && ok "no auth -> 401 (got $code)" || fail "no auth -> expected 401, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${USER}:definitely-wrong-password" -X PROPFIND -H "Depth: 0" "$AB_URL")
[ "$code" = "401" ] && ok "bad password -> 401 (got $code)" || fail "bad password -> expected 401, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -u "ghost:${DAV_PASS}" -X PROPFIND -H "Depth: 0" "$AB_URL")
# Xandikos returns 401 for an unknown user too.
{ [ "$code" = "401" ] || [ "$code" = "403" ] || [ "$code" = "404" ]; } \
&& ok "unknown user -> $code (rejected)" || fail "unknown user -> expected 401/403/404, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${USER}:${DAV_PASS}" -X PROPFIND -H "Depth: 0" "$AB_URL")
[ "$code" = "207" ] && ok "valid auth -> 207 Multi-Status (got $code)" || fail "valid auth -> expected 207, got $code"
echo
# ---- (b) TLS -----------------------------------------------------------------
echo "--- (b) TLS / transport hardening ---"
# Valid cert: curl WITHOUT -k must succeed.
if curl -sf -o /dev/null -u "${USER}:${DAV_PASS}" -X PROPFIND -H "Depth: 0" "$AB_URL"; then
ok "valid TLS chain (no -k needed)"
else
fail "TLS verification failed without -k"
fi
# Cert issuer should be a real CA (Let's Encrypt here), not self-signed.
issuer=$(echo | openssl s_client -connect "${BASE#https://}:443" -servername "${BASE#https://}" 2>/dev/null \
| openssl x509 -noout -issuer 2>/dev/null)
info "issuer: $issuer"
echo "$issuer" | grep -qi "Let's Encrypt" \
&& ok "cert issued by Let's Encrypt" || fail "unexpected cert issuer: $issuer"
# Plain HTTP must NOT serve data in cleartext: expect a redirect to HTTPS (3xx)
# or refusal — never a 200 with contact data.
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PROPFIND -H "Depth: 0" "$HTTP_URL" 2>/dev/null)
case "$code" in
301|302|307|308) ok "plain HTTP -> $code redirect to HTTPS (no cleartext)";;
000) ok "plain HTTP refused/unreachable (no cleartext)";;
200) fail "plain HTTP served 200 in CLEARTEXT — server leaks data over http!";;
*) ok "plain HTTP -> $code (not 200, no cleartext data)";;
esac
echo
# ---- (c) Reception: N contacts via REPORT -----------------------------------
echo "--- (c) Contact reception (DAVx5's sync mechanism) ---"
# PROPFIND Depth:1 -> count .vcf hrefs
pf=$(curl -s -u "${USER}:${DAV_PASS}" -X PROPFIND -H "Depth: 1" \
-H "Content-Type: application/xml" --data "$PROPFIND_BODY" "$AB_URL")
hrefs=$(printf '%s' "$pf" | grep -oE '<[a-zA-Z0-9]+:href>[^<]+\.vcf</[a-zA-Z0-9]+:href>' | wc -l | tr -d ' ')
info "PROPFIND .vcf hrefs: $hrefs"
if [ "$hrefs" -ge $((EXPECTED_VCARDS - TOLERANCE)) ] && [ "$hrefs" -le $((EXPECTED_VCARDS + TOLERANCE)) ]; then
ok "PROPFIND Depth:1 lists $hrefs vCards (expected ~$EXPECTED_VCARDS)"
else
fail "PROPFIND Depth:1 listed $hrefs vCards (expected ~$EXPECTED_VCARDS +/-$TOLERANCE)"
fi
# REPORT addressbook-query -> count BEGIN:VCARD (actual data download)
rep=$(curl -s -u "${USER}:${DAV_PASS}" -X REPORT -H "Depth: 1" \
-H "Content-Type: application/xml" --data "$REPORT_BODY" "$AB_URL")
vcards=$(printf '%s' "$rep" | grep -c 'BEGIN:VCARD')
info "REPORT BEGIN:VCARD count: $vcards"
if [ "$vcards" -ge $((EXPECTED_VCARDS - TOLERANCE)) ] && [ "$vcards" -le $((EXPECTED_VCARDS + TOLERANCE)) ]; then
ok "REPORT addressbook-query returns $vcards vCards (expected ~$EXPECTED_VCARDS)"
else
fail "REPORT returned $vcards vCards (expected ~$EXPECTED_VCARDS +/-$TOLERANCE)"
fi
echo
# ---- (d) Known contact integrity --------------------------------------------
echo "--- (d) Known-contact integrity (TEL + EMAIL) ---"
# Extract the single vCard block that matches KNOWN_NAME and check fields.
block=$(printf '%s' "$rep" | awk -v name="$KNOWN_NAME" '
/BEGIN:VCARD/ {buf=""}
{buf=buf"\n"$0}
/END:VCARD/ { if (buf ~ ("FN:.*"name)) {print buf; exit} }')
if [ -z "$block" ]; then
fail "known contact matching '$KNOWN_NAME' not found in REPORT"
else
ok "known contact '$KNOWN_NAME' present in addressbook"
printf '%s' "$block" | grep -qF "$KNOWN_TEL" \
&& ok " -> TEL '$KNOWN_TEL' intact" || fail " -> TEL '$KNOWN_TEL' MISSING"
printf '%s' "$block" | grep -qiF "$KNOWN_EMAIL" \
&& ok " -> EMAIL '$KNOWN_EMAIL' intact" || fail " -> EMAIL '$KNOWN_EMAIL' MISSING"
fi
echo
# ---- Summary ----------------------------------------------------------------
echo "=== Summary: ${GREEN}${PASS_COUNT} passed${RESET}, ${RED}${FAIL_COUNT} failed${RESET} ==="
[ "$FAIL_COUNT" -eq 0 ] && exit 0 || exit 1