feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,21 @@ from .extract_mac_addresses import extract_mac_addresses
|
||||
from .extract_phone_numbers import extract_phone_numbers
|
||||
from .extract_iocs import extract_iocs
|
||||
|
||||
# OSINT passive atomic functions (grupo osint-passive).
|
||||
from .extract_exif_metadata import extract_exif_metadata
|
||||
from .extract_pdf_metadata import extract_pdf_metadata
|
||||
from .guess_email_formats import guess_email_formats
|
||||
from .enumerate_username_sites import enumerate_username_sites
|
||||
from .build_search_dorks import build_search_dorks
|
||||
from .whois_lookup import whois_lookup
|
||||
from .dns_records import dns_records
|
||||
from .enum_subdomains_crtsh import enum_subdomains_crtsh
|
||||
|
||||
# OSINT passive enrichment orchestrators (grupo osint-enrich).
|
||||
from .scan_ficha_attachments_metadata import scan_ficha_attachments_metadata
|
||||
from .enrich_person_passive import enrich_person_passive
|
||||
from .enrich_org_passive import enrich_org_passive
|
||||
|
||||
__all__ = [
|
||||
"hash_sha256",
|
||||
"hash_md5",
|
||||
@@ -44,4 +59,15 @@ __all__ = [
|
||||
"extract_mac_addresses",
|
||||
"extract_phone_numbers",
|
||||
"extract_iocs",
|
||||
"extract_exif_metadata",
|
||||
"extract_pdf_metadata",
|
||||
"guess_email_formats",
|
||||
"enumerate_username_sites",
|
||||
"build_search_dorks",
|
||||
"whois_lookup",
|
||||
"dns_records",
|
||||
"enum_subdomains_crtsh",
|
||||
"scan_ficha_attachments_metadata",
|
||||
"enrich_person_passive",
|
||||
"enrich_org_passive",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: build_search_dorks
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_search_dorks(target: str, tipo: str = 'persona', extra_domains: list | None = None) -> list"
|
||||
description: "Genera consultas (dorks) de motor de busqueda para investigar un target segun su tipo (persona|email|dominio|usuario): frase exacta, site:linkedin, filetype:pdf, intext con cv/curriculum, site:<dominio> filetype:xlsx, dorks de leaks/pastebin para email, redes sociales para usuario, etc. extra_domains acota via site:. OSINT pasivo puro, sin red."
|
||||
tags: [osint-passive, dork, search, recon, google-dork, cybersecurity, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: target
|
||||
desc: "Cadena objetivo: nombre de persona, email, dominio o username segun el tipo."
|
||||
- name: tipo
|
||||
desc: "Uno de 'persona', 'email', 'dominio', 'usuario'. Cualquier otro valor devuelve solo la frase exacta. Default 'persona'."
|
||||
- name: extra_domains
|
||||
desc: "Lista opcional de dominios para añadir dorks 'site:<dominio> \"<target>\"' independientemente del tipo."
|
||||
output: "Lista de strings de dork listos para pegar en un buscador, en orden de generacion."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_persona_genera_dorks_esperados"
|
||||
- "test_email_genera_dorks_esperados"
|
||||
- "test_dominio_genera_site_dorks"
|
||||
- "test_usuario_genera_redes_sociales"
|
||||
- "test_extra_domains_acota_con_site"
|
||||
- "test_tipo_desconocido_solo_frase_exacta"
|
||||
test_file_path: "python/functions/cybersecurity/build_search_dorks_test.py"
|
||||
file_path: "python/functions/cybersecurity/build_search_dorks.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
build_search_dorks("Juan Perez", tipo="persona", extra_domains=["acme.com"])
|
||||
# ['"Juan Perez"',
|
||||
# '"Juan Perez" filetype:pdf',
|
||||
# 'site:linkedin.com/in "Juan Perez"',
|
||||
# 'site:twitter.com "Juan Perez"',
|
||||
# 'intext:"Juan Perez" (curriculum OR cv OR resume)',
|
||||
# '"Juan Perez" (email OR correo OR contacto)',
|
||||
# '"Juan Perez" filetype:doc OR "Juan Perez" filetype:docx',
|
||||
# 'site:acme.com "Juan Perez"']
|
||||
|
||||
build_search_dorks("empresa.com", tipo="dominio")
|
||||
# ['"empresa.com"', 'site:empresa.com', 'site:empresa.com filetype:pdf',
|
||||
# 'site:empresa.com filetype:xlsx', 'site:empresa.com (login OR admin OR dashboard)', ...]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando ya tengas un target identificado (persona, email, dominio o alias) y quieras una bateria de consultas de buscador listas para pegar manualmente y mapear documentos, perfiles y posibles filtraciones. Encaja despues de `guess_email_formats` (dorks de email) o `enumerate_username_sites` (dorks de usuario/persona) en una investigacion autorizada.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura: solo genera strings de consulta, NO ejecuta busquedas ni toca la red. Los dorks se pegan a mano en el buscador.
|
||||
- La sintaxis usa operadores de Google (site:, filetype:, intext:, inurl:); otros buscadores soportan un subconjunto distinto y algunos dorks no funcionaran igual.
|
||||
- Para tipos no reconocidos devuelve unicamente la frase exacta entre comillas (mas los `extra_domains` si se pasan), no falla.
|
||||
- Uso solo para investigacion OSINT autorizada; los dorks de leaks/breaches/pastebin pueden devolver datos sensibles cuyo tratamiento esta sujeto a ley.
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Genera consultas (dorks) de motor de busqueda para investigar un target.
|
||||
|
||||
Funcion pura de OSINT pasivo: a partir de un target y su tipo (persona,
|
||||
email, dominio o usuario) produce una lista de cadenas de busqueda listas
|
||||
para pegar en un buscador. No toca la red.
|
||||
"""
|
||||
|
||||
|
||||
def build_search_dorks(
|
||||
target: str,
|
||||
tipo: str = "persona",
|
||||
extra_domains: list | None = None,
|
||||
) -> list:
|
||||
"""Genera dorks de motor de busqueda adaptados al tipo de target.
|
||||
|
||||
Segun el tipo seleccionado produce las consultas mas utiles para
|
||||
investigar:
|
||||
|
||||
- persona: nombre exacto, en linkedin, con cv/curriculum, en ficheros.
|
||||
- email: el email entre comillas, en pastebin/breaches, en filetypes.
|
||||
- dominio: site:dominio, ficheros expuestos, subdominios, paneles.
|
||||
- usuario: el alias en redes sociales y foros.
|
||||
|
||||
`extra_domains` añade dorks `site:<dominio> "<target>"` para acotar la
|
||||
busqueda a dominios concretos, independientemente del tipo.
|
||||
|
||||
Args:
|
||||
target: cadena objetivo (nombre, email, dominio o username).
|
||||
tipo: uno de "persona", "email", "dominio", "usuario". Cualquier
|
||||
otro valor cae al conjunto generico (solo la frase exacta).
|
||||
extra_domains: lista opcional de dominios para acotar via site:.
|
||||
|
||||
Returns:
|
||||
lista de strings de dork en orden de generacion (sin deduplicar
|
||||
salvo el dork de frase exacta, que es la base comun).
|
||||
"""
|
||||
q = f'"{target}"' # frase exacta, base de casi todo dork
|
||||
dorks = [q]
|
||||
t = tipo.strip().lower()
|
||||
|
||||
if t == "persona":
|
||||
dorks += [
|
||||
f"{q} filetype:pdf",
|
||||
f'site:linkedin.com/in {q}',
|
||||
f'site:twitter.com {q}',
|
||||
f'intext:{q} (curriculum OR cv OR resume)',
|
||||
f'{q} (email OR correo OR contacto)',
|
||||
f'{q} filetype:doc OR {q} filetype:docx',
|
||||
]
|
||||
elif t == "email":
|
||||
dorks += [
|
||||
f'{q} site:pastebin.com',
|
||||
f'{q} (leak OR breach OR dump OR password)',
|
||||
f'{q} filetype:txt',
|
||||
f'{q} filetype:csv',
|
||||
f'{q} site:github.com',
|
||||
]
|
||||
elif t == "dominio":
|
||||
dorks += [
|
||||
f'site:{target}',
|
||||
f'site:{target} filetype:pdf',
|
||||
f'site:{target} filetype:xlsx',
|
||||
f'site:{target} (login OR admin OR dashboard)',
|
||||
f'site:{target} intext:(password OR contraseña)',
|
||||
f'site:*.{target}',
|
||||
f'-www site:{target}',
|
||||
]
|
||||
elif t == "usuario":
|
||||
dorks += [
|
||||
f'{q} site:github.com',
|
||||
f'{q} site:reddit.com',
|
||||
f'{q} (profile OR perfil OR user OR usuario)',
|
||||
f'inurl:{target}',
|
||||
]
|
||||
# Cualquier otro tipo: solo la frase exacta (ya añadida).
|
||||
|
||||
if extra_domains:
|
||||
for dom in extra_domains:
|
||||
dorks.append(f'site:{dom} {q}')
|
||||
|
||||
return dorks
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests para build_search_dorks."""
|
||||
|
||||
from build_search_dorks import build_search_dorks
|
||||
|
||||
|
||||
def test_persona_genera_dorks_esperados():
|
||||
"""El tipo persona produce frase exacta, linkedin, cv y filetypes."""
|
||||
out = build_search_dorks("Juan Perez", tipo="persona")
|
||||
assert '"Juan Perez"' in out
|
||||
assert '"Juan Perez" filetype:pdf' in out
|
||||
assert any("linkedin.com" in d for d in out)
|
||||
assert any("curriculum OR cv OR resume" in d for d in out)
|
||||
|
||||
|
||||
def test_email_genera_dorks_esperados():
|
||||
"""El tipo email busca en pastebin, breaches y filetypes."""
|
||||
out = build_search_dorks("bob@corp.com", tipo="email")
|
||||
assert '"bob@corp.com"' in out
|
||||
assert any("pastebin.com" in d for d in out)
|
||||
assert any("leak OR breach OR dump OR password" in d for d in out)
|
||||
assert '"bob@corp.com" filetype:csv' in out
|
||||
|
||||
|
||||
def test_dominio_genera_site_dorks():
|
||||
"""El tipo dominio produce site: y ficheros expuestos."""
|
||||
out = build_search_dorks("empresa.com", tipo="dominio")
|
||||
assert "site:empresa.com" in out
|
||||
assert "site:empresa.com filetype:xlsx" in out
|
||||
assert any("login OR admin OR dashboard" in d for d in out)
|
||||
assert "site:*.empresa.com" in out
|
||||
|
||||
|
||||
def test_usuario_genera_redes_sociales():
|
||||
"""El tipo usuario busca en github, reddit y por inurl."""
|
||||
out = build_search_dorks("jdoe", tipo="usuario")
|
||||
assert '"jdoe"' in out
|
||||
assert any("github.com" in d for d in out)
|
||||
assert any("reddit.com" in d for d in out)
|
||||
assert "inurl:jdoe" in out
|
||||
|
||||
|
||||
def test_extra_domains_acota_con_site():
|
||||
"""extra_domains añade dorks site:<dominio> con la frase exacta."""
|
||||
out = build_search_dorks(
|
||||
"Ana Ruiz", tipo="persona", extra_domains=["uni.edu", "gov.es"]
|
||||
)
|
||||
assert 'site:uni.edu "Ana Ruiz"' in out
|
||||
assert 'site:gov.es "Ana Ruiz"' in out
|
||||
|
||||
|
||||
def test_tipo_desconocido_solo_frase_exacta():
|
||||
"""Un tipo no reconocido devuelve solo la frase exacta (mas extras)."""
|
||||
out = build_search_dorks("algo", tipo="otracosa")
|
||||
assert out[0] == '"algo"'
|
||||
# Sin extras y tipo desconocido, solo la frase base.
|
||||
assert out == ['"algo"']
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: dns_records
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def dns_records(dominio: str, types: list | None = None) -> dict"
|
||||
description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short <tipo> <dominio>` por subprocess para cada tipo (default A, AAAA, MX, TXT, NS, CNAME). Parsea la salida (una linea por valor) y devuelve un dict {tipo: [valores]}. Pasivo: solo consulta DNS publico."
|
||||
tags: [osint-passive, dns, recon, cybersecurity, dig]
|
||||
params:
|
||||
- name: dominio
|
||||
desc: "Dominio a resolver, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||
- name: types
|
||||
desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 6 defaults: A, AAAA, MX, TXT, NS, CNAME."
|
||||
output: "dict {tipo: [valores]} con una clave por tipo consultado; cada valor es la lista de lineas devueltas por `dig +short` para ese tipo (lista vacia si no hay registro o el dominio no existe)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_parsea_salida_de_dig", "test_dominio_inexistente_listas_vacias", "test_usa_tipos_default", "test_timeout_devuelve_lista_vacia", "test_dominio_vacio_lanza_error"]
|
||||
test_file_path: "python/functions/cybersecurity/dns_records_test.py"
|
||||
file_path: "python/functions/cybersecurity/dns_records.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import dns_records
|
||||
|
||||
# Resolver todos los tipos por defecto
|
||||
records = dns_records("organic-machine.com")
|
||||
print(records["A"]) # ['135.125.201.30']
|
||||
print(records["MX"]) # ['10 mail.organic-machine.com.', ...]
|
||||
|
||||
# Solo los tipos que interesan
|
||||
solo_a_mx = dns_records("organic-machine.com", types=["A", "MX"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala al iniciar el reconocimiento pasivo de un dominio para mapear su
|
||||
infraestructura DNS publica (IPs, servidores de correo, nameservers, TXT con
|
||||
SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar
|
||||
subdominios o consultar RDAP.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si falta, lanza `RuntimeError`.
|
||||
- Cada consulta tiene timeout de 10s; si una expira, esa clave queda como lista vacia y el resto continua.
|
||||
- La salida es la de `dig +short` cruda: los MX incluyen prioridad ("10 mail..."), los nombres pueden venir con punto final (FQDN), y los TXT entre comillas. No se normaliza para mantener fidelidad.
|
||||
- Un dominio inexistente o sin un registro concreto devuelve lista vacia (no error): distingue "sin datos" mirando las listas vacias.
|
||||
- Resuelve contra el resolver configurado en el sistema; resultados pueden variar segun el DNS recursivo usado.
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Recoleccion OSINT pasiva de registros DNS via el binario `dig`.
|
||||
|
||||
Funcion IMPURA: ejecuta `dig +short <tipo> <dominio>` como subprocess para
|
||||
cada tipo de registro y parsea la salida (una linea por valor). Es OSINT
|
||||
pasivo: consulta DNS publico, no envia trafico intrusivo al objetivo.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
DEFAULT_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
|
||||
|
||||
|
||||
def dns_records(dominio: str, types: list | None = None) -> dict:
|
||||
"""Resuelve registros DNS de un dominio ejecutando `dig +short`.
|
||||
|
||||
Para cada tipo en ``types`` ejecuta ``dig +short <tipo> <dominio>`` y
|
||||
parsea la salida: cada linea no vacia es un valor del registro. Un
|
||||
dominio inexistente (o un registro ausente) produce una lista vacia.
|
||||
|
||||
Args:
|
||||
dominio: Dominio a resolver (ej. ``"organic-machine.com"``).
|
||||
types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``). Si es
|
||||
None usa los defaults: A, AAAA, MX, TXT, NS, CNAME.
|
||||
|
||||
Returns:
|
||||
Dict ``{tipo: [valores]}`` con una clave por tipo consultado. Cada
|
||||
valor es la lista de lineas devueltas por dig para ese tipo.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si el binario `dig` no esta instalado o el dominio
|
||||
esta vacio.
|
||||
"""
|
||||
if not dominio or not dominio.strip():
|
||||
raise RuntimeError("dns_records: dominio vacio")
|
||||
|
||||
query_types = types if types is not None else list(DEFAULT_TYPES)
|
||||
result: dict = {}
|
||||
|
||||
for record_type in query_types:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["dig", "+short", record_type, dominio],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError(
|
||||
"dns_records: binario `dig` no encontrado en PATH"
|
||||
) from e
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout en una consulta concreta: dejamos lista vacia y seguimos.
|
||||
result[record_type] = []
|
||||
continue
|
||||
|
||||
values = [
|
||||
line.strip()
|
||||
for line in proc.stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
result[record_type] = values
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests para dns_records."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from dns_records import dns_records
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, stdout: str):
|
||||
self.stdout = stdout
|
||||
self.returncode = 0
|
||||
|
||||
|
||||
def test_parsea_salida_de_dig(monkeypatch):
|
||||
"""Cada linea no vacia de dig se convierte en un valor del registro."""
|
||||
fixtures = {
|
||||
"A": "135.125.201.30\n",
|
||||
"MX": "10 mail.organic-machine.com.\n20 mail2.organic-machine.com.\n",
|
||||
"TXT": '"v=spf1 -all"\n',
|
||||
}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
record_type = cmd[2]
|
||||
return _FakeProc(fixtures.get(record_type, ""))
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = dns_records("organic-machine.com", types=["A", "MX", "TXT"])
|
||||
|
||||
assert result["A"] == ["135.125.201.30"]
|
||||
assert result["MX"] == [
|
||||
"10 mail.organic-machine.com.",
|
||||
"20 mail2.organic-machine.com.",
|
||||
]
|
||||
assert result["TXT"] == ['"v=spf1 -all"']
|
||||
|
||||
|
||||
def test_dominio_inexistente_listas_vacias(monkeypatch):
|
||||
"""Salida vacia de dig (dominio inexistente) produce listas vacias."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return _FakeProc("")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = dns_records("nope-no-existe-xyz.invalid", types=["A", "AAAA"])
|
||||
|
||||
assert result == {"A": [], "AAAA": []}
|
||||
|
||||
|
||||
def test_usa_tipos_default(monkeypatch):
|
||||
"""Sin types consulta los 6 tipos por defecto."""
|
||||
consultados = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
consultados.append(cmd[2])
|
||||
return _FakeProc("")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = dns_records("organic-machine.com")
|
||||
|
||||
assert set(result.keys()) == {"A", "AAAA", "MX", "TXT", "NS", "CNAME"}
|
||||
assert consultados == ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
|
||||
|
||||
|
||||
def test_timeout_devuelve_lista_vacia(monkeypatch):
|
||||
"""Un timeout en una consulta deja lista vacia y no aborta el resto."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 10.0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = dns_records("organic-machine.com", types=["A"])
|
||||
|
||||
assert result == {"A": []}
|
||||
|
||||
|
||||
def test_dominio_vacio_lanza_error():
|
||||
"""Dominio vacio lanza RuntimeError."""
|
||||
try:
|
||||
dns_records("")
|
||||
assert False, "deberia haber lanzado RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: enrich_org_passive
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def enrich_org_passive(dominio: str) -> dict"
|
||||
description: "Orquestador OSINT pasivo: perfil de una organizacion por su dominio usando solo fuentes publicas. Compone whois_lookup (registro WHOIS), dns_records (registros DNS) y enum_subdomains_crtsh (subdominios via Certificate Transparency / crt.sh). Devuelve whois + dns + subdomains."
|
||||
tags: [osint-enrich, osint-passive, cybersecurity, org, whois, dns, subdomains]
|
||||
uses_functions: [whois_lookup_py_cybersecurity, dns_records_py_cybersecurity, enum_subdomains_crtsh_py_cybersecurity]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: dominio
|
||||
desc: "dominio de la organizacion, p.ej. organic-machine.com. No puede estar vacio (ValueError). Se hace strip de espacios"
|
||||
output: "dict con whois (salida de whois_lookup(dominio)), dns (salida de dns_records(dominio)) y subdomains (salida de enum_subdomains_crtsh(dominio))"
|
||||
tested: true
|
||||
tests: ["test_golden_compone_whois_dns_subdomains", "test_dominio_vacio_lanza", "test_dominio_con_espacios_se_normaliza"]
|
||||
test_file_path: "python/functions/cybersecurity/enrich_org_passive_test.py"
|
||||
file_path: "python/functions/cybersecurity/enrich_org_passive.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import enrich_org_passive
|
||||
|
||||
res = enrich_org_passive("organic-machine.com")
|
||||
|
||||
print(res["whois"]["registrar"]) # registrar segun WHOIS
|
||||
print(res["dns"].get("A")) # registros A del dominio
|
||||
print(res["subdomains"][:5]) # primeros subdominios vistos en crt.sh
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando arrancas la ficha OSINT de una organizacion y quieres su perfil pasivo (quien registro el dominio, su DNS y su superficie de subdominios) sin enviar trafico ofensivo a la infraestructura.
|
||||
- Como reconocimiento previo, completamente pasivo, antes de cualquier evaluacion activa (que requeriria otra autorizacion explicita).
|
||||
- Para mapear de un golpe la huella publica de un dominio: WHOIS + DNS + Certificate Transparency en una sola llamada.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Uso solo para investigacion autorizada.** El reconocimiento de infraestructura ajena debe contar con permiso del propietario o ampararse en una base legitima.
|
||||
- Funcion IMPURA: hace consultas de red (WHOIS, DNS y HTTP a crt.sh). Es pasiva respecto al objetivo (no le envia trafico ofensivo), pero **deja huella** en los servicios consultados (logs de crt.sh, resolvers DNS, servidores WHOIS).
|
||||
- La salida depende de la disponibilidad y rate-limiting de las fuentes: WHOIS puede venir truncado/redactado (GDPR), crt.sh puede limitar o tardar, y algunos registros DNS pueden no existir. Maneja claves ausentes en el dict resultante.
|
||||
- crt.sh solo ve subdominios que hayan emitido certificados TLS publicos: la lista NO es exhaustiva (no incluye subdominios sin cert o con certs privados).
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Orquestador OSINT pasivo: perfil de una organizacion por su dominio.
|
||||
|
||||
Compone funciones atomicas del registro (`whois_lookup`, `dns_records`,
|
||||
`enum_subdomains_crtsh`) para construir un perfil pasivo de una organizacion a
|
||||
partir de su dominio, usando solo fuentes publicas (WHOIS, DNS, Certificate
|
||||
Transparency via crt.sh) sin contactar directamente con la infraestructura del
|
||||
objetivo mas alla de las consultas DNS estandar.
|
||||
|
||||
Funcion IMPURA: hace consultas de red (WHOIS, DNS, HTTP a crt.sh).
|
||||
"""
|
||||
|
||||
from cybersecurity import dns_records, enum_subdomains_crtsh, whois_lookup
|
||||
|
||||
|
||||
def enrich_org_passive(dominio: str) -> dict:
|
||||
"""Construye un perfil OSINT pasivo de una organizacion por su dominio.
|
||||
|
||||
Agrega tres fuentes publicas: el registro WHOIS del dominio, sus registros
|
||||
DNS y los subdominios observados en Certificate Transparency (crt.sh).
|
||||
|
||||
Args:
|
||||
dominio: dominio de la organizacion, p.ej. `organic-machine.com`.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
- whois: salida de whois_lookup(dominio).
|
||||
- dns: salida de dns_records(dominio).
|
||||
- subdomains: salida de enum_subdomains_crtsh(dominio).
|
||||
|
||||
Raises:
|
||||
ValueError: si el dominio esta vacio.
|
||||
"""
|
||||
if not (dominio or "").strip():
|
||||
raise ValueError("dominio no puede estar vacio")
|
||||
|
||||
dominio = dominio.strip()
|
||||
|
||||
# Resiliente a fallo parcial: si una fuente externa falla (p.ej. crt.sh lento o
|
||||
# rate-limitado), se registra el error y se devuelven las demas igualmente.
|
||||
result = {"whois": {}, "dns": {}, "subdomains": [], "errors": {}}
|
||||
for key, fn in (("whois", whois_lookup), ("dns", dns_records),
|
||||
("subdomains", enum_subdomains_crtsh)):
|
||||
try:
|
||||
result[key] = fn(dominio)
|
||||
except Exception as exc: # noqa: BLE001 — captura amplia a proposito por fuente
|
||||
result["errors"][key] = f"{type(exc).__name__}: {exc}"
|
||||
return result
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests para enrich_org_passive.
|
||||
|
||||
Las funciones compuestas (whois_lookup, dns_records, enum_subdomains_crtsh) se
|
||||
monkeypatchean para no tocar la red. Solo se verifica la orquestacion: que
|
||||
ensambla las tres fuentes bajo las claves correctas y normaliza el dominio.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from cybersecurity import enrich_org_passive
|
||||
|
||||
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
|
||||
# parchear los globals del modulo lo tomamos via importlib.
|
||||
mod = importlib.import_module("cybersecurity.enrich_org_passive")
|
||||
|
||||
|
||||
def test_golden_compone_whois_dns_subdomains(monkeypatch):
|
||||
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"domain": d, "registrar": "OVH"})
|
||||
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"], "MX": ["mx.x"]})
|
||||
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [f"www.{d}", f"api.{d}"])
|
||||
|
||||
res = enrich_org_passive("organic-machine.com")
|
||||
|
||||
assert res["whois"] == {"domain": "organic-machine.com", "registrar": "OVH"}
|
||||
assert res["dns"] == {"A": ["1.2.3.4"], "MX": ["mx.x"]}
|
||||
assert res["subdomains"] == ["www.organic-machine.com", "api.organic-machine.com"]
|
||||
assert set(res.keys()) == {"whois", "dns", "subdomains", "errors"}
|
||||
assert res["errors"] == {} # sin fallos cuando todas las fuentes responden
|
||||
|
||||
|
||||
def test_fuente_que_falla_se_captura_en_errors(monkeypatch):
|
||||
"""Si una fuente externa peta (p.ej. crt.sh), se registra en errors y el resto se devuelve."""
|
||||
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"registrar": "OVH"})
|
||||
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"]})
|
||||
|
||||
def boom(d):
|
||||
raise RuntimeError("crt.sh 404")
|
||||
monkeypatch.setattr(mod, "enum_subdomains_crtsh", boom)
|
||||
|
||||
res = enrich_org_passive("organic-machine.com")
|
||||
assert res["whois"] == {"registrar": "OVH"}
|
||||
assert res["dns"] == {"A": ["1.2.3.4"]}
|
||||
assert res["subdomains"] == []
|
||||
assert "subdomains" in res["errors"] and "crt.sh 404" in res["errors"]["subdomains"]
|
||||
|
||||
|
||||
def test_dominio_vacio_lanza(monkeypatch):
|
||||
monkeypatch.setattr(mod, "whois_lookup", lambda d: {})
|
||||
monkeypatch.setattr(mod, "dns_records", lambda d: {})
|
||||
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [])
|
||||
with pytest.raises(ValueError):
|
||||
enrich_org_passive(" ")
|
||||
|
||||
|
||||
def test_dominio_con_espacios_se_normaliza(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def whois(d):
|
||||
seen["whois"] = d
|
||||
return {}
|
||||
|
||||
def dns(d):
|
||||
seen["dns"] = d
|
||||
return {}
|
||||
|
||||
def subs(d):
|
||||
seen["subs"] = d
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(mod, "whois_lookup", whois)
|
||||
monkeypatch.setattr(mod, "dns_records", dns)
|
||||
monkeypatch.setattr(mod, "enum_subdomains_crtsh", subs)
|
||||
|
||||
enrich_org_passive(" organic-machine.com ")
|
||||
|
||||
# Las tres funciones reciben el dominio ya sin espacios.
|
||||
assert seen == {
|
||||
"whois": "organic-machine.com",
|
||||
"dns": "organic-machine.com",
|
||||
"subs": "organic-machine.com",
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: enrich_person_passive
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def enrich_person_passive(nombre: str, apellidos: str, dominios: list | None = None, usernames: list | None = None) -> dict"
|
||||
description: "Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona SIN tocar al objetivo. Compone guess_email_formats (emails candidatos por cada dominio dado, o gmail/outlook por defecto), enumerate_username_sites (comprobacion de usernames en servicios publicos) y build_search_dorks (dorks tipo persona, que NO se ejecutan, solo se generan)."
|
||||
tags: [osint-enrich, osint-passive, cybersecurity, person, email, username, dorks]
|
||||
uses_functions: [guess_email_formats_py_cybersecurity, enumerate_username_sites_py_cybersecurity, build_search_dorks_py_cybersecurity]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "nombre de pila de la persona"
|
||||
- name: apellidos
|
||||
desc: "apellido(s) de la persona. nombre y apellidos no pueden estar ambos vacios (ValueError)"
|
||||
- name: dominios
|
||||
desc: "lista de dominios de correo donde generar formatos de email candidatos. None o vacia => usa los dominios comunes gmail.com/outlook.com"
|
||||
- name: usernames
|
||||
desc: "lista de usernames a comprobar en sitios publicos via enumerate_username_sites. None o vacia => no se comprueba ningun username"
|
||||
output: "dict con email_candidates (lista de emails candidatos NO verificados, deduplicada y ordenada), username_hits (lista de {username, hits} con el resultado de enumerate_username_sites por username) y dorks (lista de dorks de busqueda tipo persona generados pero NO ejecutados)"
|
||||
tested: true
|
||||
tests: ["test_golden_compone_emails_usernames_y_dorks", "test_sin_dominios_usa_comunes", "test_sin_usernames_no_comprueba", "test_nombre_y_apellidos_vacios_lanza", "test_emails_deduplicados"]
|
||||
test_file_path: "python/functions/cybersecurity/enrich_person_passive_test.py"
|
||||
file_path: "python/functions/cybersecurity/enrich_person_passive.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import enrich_person_passive
|
||||
|
||||
res = enrich_person_passive(
|
||||
"Juan", "Perez",
|
||||
dominios=["organic-machine.com"],
|
||||
usernames=["jperez", "juanp"],
|
||||
)
|
||||
|
||||
print(res["email_candidates"]) # ['juan.perez@organic-machine.com', 'jperez@organic-machine.com', ...]
|
||||
for u in res["username_hits"]:
|
||||
print(u["username"], len(u["hits"])) # sitios publicos donde aparece cada username
|
||||
print(res["dorks"]) # dorks listos para pegar en un buscador (no se ejecutan aqui)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando arrancas la ficha OSINT de una persona y quieres una primera tanda de candidatos (emails probables, presencia de usernames, dorks) sin enviar nada al objetivo ni a su correo.
|
||||
- Antes de verificar manualmente: genera el espacio de candidatos para que despues confirmes cuales son reales.
|
||||
- Como paso pasivo previo a cualquier accion activa (que requeriria otra autorizacion y otras funciones).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Uso solo para investigacion autorizada.** Generar candidatos sobre una persona sin base legitima puede vulnerar privacidad/leyes de proteccion de datos.
|
||||
- Los `email_candidates` son **candidatos NO verificados**: son permutaciones plausibles del nombre, NO emails confirmados. No asumas que existen ni los uses para envio.
|
||||
- Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos por red, lo que **deja una huella minima** (requests a esos sitios). `build_search_dorks` y `guess_email_formats` son locales.
|
||||
- Los dorks se **generan pero NO se ejecutan** aqui: ejecutarlos en un buscador es un paso aparte y deja su propia huella.
|
||||
- Si no aportas `dominios`, se usan gmail.com/outlook.com como heuristica; ajusta la lista a los dominios reales del entorno de la persona para candidatos utiles.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona.
|
||||
|
||||
Compone funciones atomicas del registro (`guess_email_formats`,
|
||||
`enumerate_username_sites`, `build_search_dorks`) para producir candidatos OSINT
|
||||
de una persona SIN contactar ni atacar al objetivo. Los dorks se generan pero
|
||||
NO se ejecutan.
|
||||
|
||||
Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos (red).
|
||||
"""
|
||||
|
||||
from cybersecurity import (
|
||||
build_search_dorks,
|
||||
enumerate_username_sites,
|
||||
guess_email_formats,
|
||||
)
|
||||
|
||||
# Dominios de correo comunes usados cuando el caller no aporta dominios propios.
|
||||
_COMMON_EMAIL_DOMAINS = ("gmail.com", "outlook.com")
|
||||
|
||||
|
||||
def enrich_person_passive(
|
||||
nombre: str,
|
||||
apellidos: str,
|
||||
dominios: list | None = None,
|
||||
usernames: list | None = None,
|
||||
) -> dict:
|
||||
"""Genera candidatos OSINT pasivos para una persona.
|
||||
|
||||
Para cada dominio aportado (o los dominios comunes gmail/outlook si no se da
|
||||
ninguno) genera los formatos de email candidatos. Para cada username
|
||||
aportado comprueba en que sitios publicos existe. Ademas genera dorks de
|
||||
busqueda de tipo persona (que NO se ejecutan, solo se devuelven).
|
||||
|
||||
Args:
|
||||
nombre: nombre de pila de la persona.
|
||||
apellidos: apellido(s) de la persona.
|
||||
dominios: lista de dominios de correo donde buscar formatos de email.
|
||||
Si es None o vacia, se usan los dominios comunes gmail/outlook.
|
||||
usernames: lista de usernames a comprobar en sitios publicos. Si es None
|
||||
o vacia, no se realiza ninguna comprobacion de username.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
- email_candidates: lista de emails candidatos (no verificados).
|
||||
- username_hits: lista de resultados de enumerate_username_sites por
|
||||
cada username comprobado.
|
||||
- dorks: lista de dorks de busqueda generados (no ejecutados).
|
||||
|
||||
Raises:
|
||||
ValueError: si nombre y apellidos estan ambos vacios.
|
||||
"""
|
||||
if not (nombre or "").strip() and not (apellidos or "").strip():
|
||||
raise ValueError("nombre y apellidos no pueden estar ambos vacios")
|
||||
|
||||
target_domains = [d for d in (dominios or []) if d] or list(_COMMON_EMAIL_DOMAINS)
|
||||
|
||||
email_candidates: list = []
|
||||
for dominio in target_domains:
|
||||
candidates = guess_email_formats(nombre, apellidos, dominio)
|
||||
if candidates:
|
||||
email_candidates.extend(candidates)
|
||||
# Deduplicar preservando orden.
|
||||
email_candidates = list(dict.fromkeys(email_candidates))
|
||||
|
||||
username_hits: list = []
|
||||
for username in usernames or []:
|
||||
if not username:
|
||||
continue
|
||||
hits = enumerate_username_sites(username)
|
||||
username_hits.append({"username": username, "hits": hits})
|
||||
|
||||
target = f"{nombre} {apellidos}".strip()
|
||||
dorks = build_search_dorks(target, "persona")
|
||||
|
||||
return {
|
||||
"email_candidates": email_candidates,
|
||||
"username_hits": username_hits,
|
||||
"dorks": dorks,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests para enrich_person_passive.
|
||||
|
||||
Las funciones compuestas (guess_email_formats, enumerate_username_sites,
|
||||
build_search_dorks) se monkeypatchean. Solo se verifica la orquestacion: que
|
||||
itera por dominios/usernames, deduplica emails, usa dominios comunes por
|
||||
defecto y genera (sin ejecutar) los dorks.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from cybersecurity import enrich_person_passive
|
||||
|
||||
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
|
||||
# parchear los globals del modulo lo tomamos via importlib.
|
||||
mod = importlib.import_module("cybersecurity.enrich_person_passive")
|
||||
|
||||
|
||||
def _patch(monkeypatch, *, emails_fn=None, usernames_fn=None, dorks_fn=None):
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"guess_email_formats",
|
||||
emails_fn or (lambda n, a, d: [f"{n.lower()}.{a.lower()}@{d}", f"{n[0].lower()}{a.lower()}@{d}"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"enumerate_username_sites",
|
||||
usernames_fn or (lambda u: [{"site": "github", "url": f"https://github.com/{u}", "exists": True}]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"build_search_dorks",
|
||||
dorks_fn or (lambda target, tipo: [f'"{target}" {tipo}', f'intext:"{target}"']),
|
||||
)
|
||||
|
||||
|
||||
def test_golden_compone_emails_usernames_y_dorks(monkeypatch):
|
||||
_patch(monkeypatch)
|
||||
|
||||
res = enrich_person_passive(
|
||||
"Juan", "Perez",
|
||||
dominios=["organic-machine.com"],
|
||||
usernames=["jperez", "juanp"],
|
||||
)
|
||||
|
||||
assert res["email_candidates"] == [
|
||||
"juan.perez@organic-machine.com",
|
||||
"jperez@organic-machine.com",
|
||||
]
|
||||
assert [u["username"] for u in res["username_hits"]] == ["jperez", "juanp"]
|
||||
assert res["username_hits"][0]["hits"][0]["site"] == "github"
|
||||
assert res["dorks"] == ['"Juan Perez" persona', 'intext:"Juan Perez"']
|
||||
|
||||
|
||||
def test_sin_dominios_usa_comunes(monkeypatch):
|
||||
seen_domains = []
|
||||
|
||||
def emails(n, a, d):
|
||||
seen_domains.append(d)
|
||||
return [f"{n}@{d}"]
|
||||
|
||||
_patch(monkeypatch, emails_fn=emails)
|
||||
|
||||
res = enrich_person_passive("Ana", "Lopez")
|
||||
|
||||
assert seen_domains == ["gmail.com", "outlook.com"]
|
||||
assert res["email_candidates"] == ["Ana@gmail.com", "Ana@outlook.com"]
|
||||
|
||||
|
||||
def test_sin_usernames_no_comprueba(monkeypatch):
|
||||
called = []
|
||||
_patch(monkeypatch, usernames_fn=lambda u: called.append(u) or [])
|
||||
|
||||
res = enrich_person_passive("Ana", "Lopez", dominios=["x.com"])
|
||||
|
||||
assert res["username_hits"] == []
|
||||
assert called == [] # enumerate_username_sites no se invoca
|
||||
|
||||
|
||||
def test_nombre_y_apellidos_vacios_lanza(monkeypatch):
|
||||
_patch(monkeypatch)
|
||||
with pytest.raises(ValueError):
|
||||
enrich_person_passive("", " ")
|
||||
|
||||
|
||||
def test_emails_deduplicados(monkeypatch):
|
||||
# Dos dominios distintos que devuelven el mismo email -> debe deduplicar.
|
||||
_patch(monkeypatch, emails_fn=lambda n, a, d: ["dup@same.com", "dup@same.com"])
|
||||
|
||||
res = enrich_person_passive("Juan", "Perez", dominios=["a.com", "b.com"])
|
||||
|
||||
assert res["email_candidates"] == ["dup@same.com"]
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: enum_subdomains_crtsh
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list"
|
||||
description: "Enumeracion OSINT pasiva de subdominios desde Certificate Transparency (crt.sh). Consulta https://crt.sh/?q=%25.<dominio>&output=json con http_get_json, extrae name_value de cada certificado, separa por saltos de linea, deduplica, filtra wildcards (*.) y devuelve la lista ordenada de subdominios unicos. Pasivo: no toca al objetivo, consulta logs CT publicos."
|
||||
tags: [osint-passive, subdomains, crtsh, certificate-transparency, recon, cybersecurity]
|
||||
params:
|
||||
- name: dominio
|
||||
desc: "Dominio base a enumerar, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||
- name: timeout_s
|
||||
desc: "Segundos maximo de espera de la peticion HTTP a crt.sh (default 20.0)."
|
||||
output: "Lista ordenada de subdominios unicos (en minusculas, sin wildcards) que aparecen en certificados emitidos para el dominio. Lista vacia si crt.sh no devuelve resultados."
|
||||
uses_functions: ["http_get_json_py_infra"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_dedup_y_orden", "test_filtra_wildcards", "test_respuesta_vacia", "test_respuesta_no_lista_lanza_error", "test_dominio_vacio_lanza_error"]
|
||||
test_file_path: "python/functions/cybersecurity/enum_subdomains_crtsh_test.py"
|
||||
file_path: "python/functions/cybersecurity/enum_subdomains_crtsh.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import enum_subdomains_crtsh
|
||||
|
||||
subs = enum_subdomains_crtsh("organic-machine.com")
|
||||
for s in subs:
|
||||
print(s)
|
||||
# api.organic-machine.com
|
||||
# mail.organic-machine.com
|
||||
# organic-machine.com
|
||||
# www.organic-machine.com
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para descubrir la superficie de subdominios de un objetivo sin enviarle
|
||||
trafico: los logs de Certificate Transparency listan todos los nombres para
|
||||
los que se han emitido certificados TLS. Complementa a `dns_records`
|
||||
(resolucion) y `whois_lookup` (registro) en el reconocimiento pasivo inicial.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es OSINT **pasivo**: consulta los logs CT publicos via crt.sh, NO toca al
|
||||
dominio objetivo ni resuelve los subdominios encontrados (algunos pueden
|
||||
estar muertos o no resolver).
|
||||
- crt.sh suele ir lento o rate-limitear bajo carga; para dominios grandes la
|
||||
respuesta puede tardar varios segundos o agotar el `timeout_s`. Subir el
|
||||
timeout o reintentar si falla.
|
||||
- Solo encuentra subdominios que han tenido certificado TLS emitido y logueado
|
||||
en CT; subdominios internos sin certificado publico no apareceran.
|
||||
- Los wildcards (`*.dominio`) se filtran porque no son hosts concretos.
|
||||
- crt.sh devuelve un array JSON (no un objeto); por eso si la respuesta no es
|
||||
una lista se lanza `RuntimeError`.
|
||||
- Puede incluir subdominios de niveles arbitrarios y dominios relacionados que
|
||||
compartieron certificado SAN; revisa la salida antes de usarla como verdad.
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Enumeracion OSINT pasiva de subdominios via Certificate Transparency (crt.sh).
|
||||
|
||||
Funcion IMPURA: consulta los logs publicos de Certificate Transparency a
|
||||
traves de crt.sh y extrae los subdominios que han aparecido en certificados
|
||||
emitidos para el dominio. Es OSINT pasivo: no toca al dominio objetivo, solo
|
||||
consulta registros CT publicos.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
|
||||
)
|
||||
|
||||
from infra.http_get_json import http_get_json # noqa: E402
|
||||
|
||||
|
||||
def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list:
|
||||
"""Enumera subdominios de un dominio desde Certificate Transparency.
|
||||
|
||||
Consulta ``https://crt.sh/?q=%25.<dominio>&output=json`` (``%25`` = ``%``,
|
||||
el wildcard de crt.sh) usando ``http_get_json`` del registry. crt.sh
|
||||
devuelve un array JSON de certificados; de cada uno se toma el campo
|
||||
``name_value`` (que puede contener varios nombres separados por saltos de
|
||||
linea, uno por SAN). Se separan, deduplican, se filtran los wildcards
|
||||
(``*.``) y se devuelve la lista ordenada de subdominios unicos.
|
||||
|
||||
Args:
|
||||
dominio: Dominio base a enumerar (ej. ``"organic-machine.com"``).
|
||||
timeout_s: Segundos maximo de espera de la peticion HTTP (default 20).
|
||||
|
||||
Returns:
|
||||
Lista ordenada de subdominios unicos (sin wildcards). Lista vacia si
|
||||
crt.sh no devuelve resultados.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si el dominio esta vacio o la peticion HTTP falla.
|
||||
"""
|
||||
if not dominio or not dominio.strip():
|
||||
raise RuntimeError("enum_subdomains_crtsh: dominio vacio")
|
||||
|
||||
dom = dominio.strip().lower()
|
||||
# %25 es el wildcard '%' de crt.sh: busca cualquier nombre que termine en .<dominio>
|
||||
url = f"https://crt.sh/?q=%25.{dom}&output=json"
|
||||
|
||||
data = http_get_json(url, timeout=timeout_s)
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
f"enum_subdomains_crtsh: respuesta crt.sh inesperada "
|
||||
f"(tipo {type(data).__name__})"
|
||||
)
|
||||
|
||||
found: set = set()
|
||||
for cert in data:
|
||||
if not isinstance(cert, dict):
|
||||
continue
|
||||
name_value = cert.get("name_value", "")
|
||||
for raw_name in name_value.split("\n"):
|
||||
name = raw_name.strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
if name.startswith("*."):
|
||||
continue
|
||||
found.add(name)
|
||||
|
||||
return sorted(found)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests para enum_subdomains_crtsh."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import enum_subdomains_crtsh as esc
|
||||
from enum_subdomains_crtsh import enum_subdomains_crtsh
|
||||
|
||||
|
||||
def _crtsh_sample() -> list:
|
||||
# crt.sh devuelve un array; name_value puede traer varios SAN separados
|
||||
# por '\n', con duplicados y wildcards.
|
||||
return [
|
||||
{"name_value": "www.organic-machine.com\norganic-machine.com"},
|
||||
{"name_value": "api.organic-machine.com"},
|
||||
{"name_value": "www.organic-machine.com"}, # duplicado
|
||||
{"name_value": "*.organic-machine.com"}, # wildcard, se filtra
|
||||
{"name_value": "MAIL.Organic-Machine.com"}, # case distinto
|
||||
]
|
||||
|
||||
|
||||
def test_dedup_y_orden(monkeypatch):
|
||||
"""Subdominios deduplicados, en minusculas y ordenados."""
|
||||
monkeypatch.setattr(
|
||||
esc, "http_get_json", lambda url, timeout=20.0: _crtsh_sample()
|
||||
)
|
||||
|
||||
result = enum_subdomains_crtsh("organic-machine.com")
|
||||
|
||||
assert result == [
|
||||
"api.organic-machine.com",
|
||||
"mail.organic-machine.com",
|
||||
"organic-machine.com",
|
||||
"www.organic-machine.com",
|
||||
]
|
||||
|
||||
|
||||
def test_filtra_wildcards(monkeypatch):
|
||||
"""Las entradas '*.dominio' se descartan."""
|
||||
monkeypatch.setattr(
|
||||
esc,
|
||||
"http_get_json",
|
||||
lambda url, timeout=20.0: [{"name_value": "*.organic-machine.com"}],
|
||||
)
|
||||
|
||||
result = enum_subdomains_crtsh("organic-machine.com")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_respuesta_vacia(monkeypatch):
|
||||
"""crt.sh sin resultados devuelve lista vacia."""
|
||||
monkeypatch.setattr(esc, "http_get_json", lambda url, timeout=20.0: [])
|
||||
|
||||
result = enum_subdomains_crtsh("organic-machine.com")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_respuesta_no_lista_lanza_error(monkeypatch):
|
||||
"""Una respuesta que no es lista lanza RuntimeError."""
|
||||
monkeypatch.setattr(
|
||||
esc, "http_get_json", lambda url, timeout=20.0: {"unexpected": "obj"}
|
||||
)
|
||||
|
||||
try:
|
||||
enum_subdomains_crtsh("organic-machine.com")
|
||||
assert False, "deberia haber lanzado RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dominio_vacio_lanza_error():
|
||||
"""Dominio vacio lanza RuntimeError."""
|
||||
try:
|
||||
enum_subdomains_crtsh("")
|
||||
assert False, "deberia haber lanzado RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: enumerate_username_sites
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def enumerate_username_sites(username: str, timeout_s: float = 8.0, sites: list | None = None) -> list"
|
||||
description: "Comprueba si un username existe en ~12 sitios publicos (github, twitter/x, instagram, tiktok, reddit, gitlab, keybase, medium, telegram, youtube, pinterest, about.me) consultando la URL de perfil estilo sherlock ligero. Detecta por codigo HTTP (200 existe, 404 no existe). Cada sitio se consulta aislado en try/except con User-Agent de navegador y allow_redirects: un timeout no aborta el resto. OSINT pasivo."
|
||||
tags: [osint-passive, username, enumeration, recon, identity, sherlock, cybersecurity, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [requests]
|
||||
params:
|
||||
- name: username
|
||||
desc: "Nombre de usuario a buscar, sin arroba ni URL (ej. 'jdoe')."
|
||||
- name: timeout_s
|
||||
desc: "Timeout en segundos por peticion HTTP. Default 8.0."
|
||||
- name: sites
|
||||
desc: "Lista opcional de dicts {'site','url'} donde 'url' lleva el placeholder '{u}'. Si es None usa DEFAULT_SITES (~12 sitios)."
|
||||
output: "Lista de dicts {'site','url','exists','status'} en el orden de los sitios. 'exists' es True/False/None y 'status' el codigo HTTP (int) o None si la peticion fallo."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_status_200_es_existe"
|
||||
- "test_status_404_es_no_existe"
|
||||
- "test_status_otro_es_indeterminado"
|
||||
- "test_excepcion_por_sitio_no_aborta_el_resto"
|
||||
- "test_estructura_y_url_formateada"
|
||||
- "test_lista_por_defecto_tiene_doce_sitios"
|
||||
test_file_path: "python/functions/cybersecurity/enumerate_username_sites_test.py"
|
||||
file_path: "python/functions/cybersecurity/enumerate_username_sites.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
enumerate_username_sites("torvalds", timeout_s=8.0)
|
||||
# [{"site": "github", "url": "https://github.com/torvalds", "exists": True, "status": 200},
|
||||
# {"site": "twitter", "url": "https://x.com/torvalds", "exists": None, "status": 403},
|
||||
# {"site": "instagram","url": "https://www.instagram.com/torvalds/","exists": False, "status": 404},
|
||||
# ...]
|
||||
|
||||
# Lista propia de sitios:
|
||||
enumerate_username_sites("jdoe", sites=[{"site": "github", "url": "https://github.com/{u}"}])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando tengas un username (o alias) y quieras un barrido rapido de presencia en redes/plataformas publicas para mapear la huella digital de un objetivo en una investigacion autorizada. Util tras `guess_email_formats` para pivotar de identidad a perfiles, o como entrada para construir dorks con `build_search_dorks`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Deja huella**: aunque es recoleccion "pasiva" desde el punto de vista del objetivo, lanza una peticion HTTP real a cada sitio. Esas peticiones quedan en logs del sitio y pueden asociarse a tu IP. Usa proxy/VPN si la investigacion lo requiere.
|
||||
- **Falsos positivos/negativos por anti-bot**: muchos sitios (instagram, tiktok, x) devuelven 200 con paginas de login/captcha o bloquean por User-Agent, dando exists=True erroneo o status indeterminado. El 200/404 no es garantia; verifica manualmente los hits relevantes.
|
||||
- **Respeta rate limits**: lanzar muchas comprobaciones seguidas puede activar bloqueos o baneos temporales por IP. Espacia las consultas en barridos grandes.
|
||||
- **Estados intermedios**: cualquier codigo distinto de 200/404 (301, 403, 429, 5xx) deja `exists=None`; un fallo de red por sitio deja `status=None` y `exists=None` sin abortar el resto.
|
||||
- **Solo para investigacion autorizada.** No uses esta funcion para acoso, doxing ni vigilancia sin base legal.
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Comprueba la existencia de un username en sitios publicos (sherlock ligero).
|
||||
|
||||
OSINT pasivo: para un username dado, consulta la URL de perfil de una lista
|
||||
de sitios conocidos y deduce si la cuenta existe por el codigo de estado
|
||||
HTTP (200 = existe, 404 = no existe). Cada sitio se consulta de forma
|
||||
aislada: un fallo (timeout, error de red) no aborta el resto.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
# Cada entrada describe un sitio: como construir la URL de perfil a partir
|
||||
# del username. La deteccion es por codigo de estado (200 existe / 404 no).
|
||||
DEFAULT_SITES = [
|
||||
{"site": "github", "url": "https://github.com/{u}"},
|
||||
{"site": "twitter", "url": "https://x.com/{u}"},
|
||||
{"site": "instagram", "url": "https://www.instagram.com/{u}/"},
|
||||
{"site": "tiktok", "url": "https://www.tiktok.com/@{u}"},
|
||||
{"site": "reddit", "url": "https://www.reddit.com/user/{u}"},
|
||||
{"site": "gitlab", "url": "https://gitlab.com/{u}"},
|
||||
{"site": "keybase", "url": "https://keybase.io/{u}"},
|
||||
{"site": "medium", "url": "https://medium.com/@{u}"},
|
||||
{"site": "telegram", "url": "https://t.me/{u}"},
|
||||
{"site": "youtube", "url": "https://www.youtube.com/@{u}"},
|
||||
{"site": "pinterest", "url": "https://www.pinterest.com/{u}/"},
|
||||
{"site": "about_me", "url": "https://about.me/{u}"},
|
||||
]
|
||||
|
||||
_BROWSER_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def enumerate_username_sites(
|
||||
username: str,
|
||||
timeout_s: float = 8.0,
|
||||
sites: list | None = None,
|
||||
) -> list:
|
||||
"""Comprueba si un username existe en una lista de sitios publicos.
|
||||
|
||||
Para cada sitio construye la URL de perfil con el username y hace un
|
||||
GET con User-Agent de navegador siguiendo redirecciones. Interpreta el
|
||||
codigo de estado final: 200 -> existe, 404 -> no existe, cualquier otro
|
||||
-> indeterminado (exists=None). Los errores de red por sitio (timeout,
|
||||
conexion) se capturan y se reportan con status=None y exists=None sin
|
||||
interrumpir la enumeracion del resto.
|
||||
|
||||
Args:
|
||||
username: nombre de usuario a buscar (sin arroba ni URL).
|
||||
timeout_s: timeout en segundos por peticion. Default 8.0.
|
||||
sites: lista opcional de dicts {"site", "url"} donde "url" contiene
|
||||
el placeholder "{u}". Si es None se usa DEFAULT_SITES.
|
||||
|
||||
Returns:
|
||||
lista de dicts {"site", "url", "exists", "status"} en el mismo orden
|
||||
que la lista de sitios. "exists" es True/False/None y "status" es el
|
||||
codigo HTTP (int) o None si la peticion fallo.
|
||||
"""
|
||||
targets = sites if sites is not None else DEFAULT_SITES
|
||||
headers = {"User-Agent": _BROWSER_UA}
|
||||
results = []
|
||||
|
||||
for entry in targets:
|
||||
site = entry.get("site", "")
|
||||
url = entry["url"].format(u=username)
|
||||
status = None
|
||||
exists = None
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout_s,
|
||||
allow_redirects=True,
|
||||
)
|
||||
status = resp.status_code
|
||||
if status == 200:
|
||||
exists = True
|
||||
elif status == 404:
|
||||
exists = False
|
||||
else:
|
||||
exists = None
|
||||
except requests.RequestException:
|
||||
# Timeout, error de conexion, demasiados redirects, etc.
|
||||
status = None
|
||||
exists = None
|
||||
|
||||
results.append(
|
||||
{"site": site, "url": url, "exists": exists, "status": status}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests para enumerate_username_sites (red mockeada con monkeypatch)."""
|
||||
|
||||
import requests
|
||||
|
||||
import enumerate_username_sites as mod
|
||||
from enumerate_username_sites import enumerate_username_sites
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""Respuesta HTTP minima con solo status_code."""
|
||||
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _make_get(status_by_url):
|
||||
"""Construye un fake de requests.get que devuelve status por URL."""
|
||||
|
||||
def _fake_get(url, **kwargs):
|
||||
return _FakeResp(status_by_url[url])
|
||||
|
||||
return _fake_get
|
||||
|
||||
|
||||
SITES = [
|
||||
{"site": "alpha", "url": "https://alpha.test/{u}"},
|
||||
{"site": "beta", "url": "https://beta.test/{u}"},
|
||||
{"site": "gamma", "url": "https://gamma.test/{u}"},
|
||||
]
|
||||
|
||||
|
||||
def test_status_200_es_existe(monkeypatch):
|
||||
"""Un 200 marca exists=True."""
|
||||
status_map = {
|
||||
"https://alpha.test/bob": 200,
|
||||
"https://beta.test/bob": 200,
|
||||
"https://gamma.test/bob": 200,
|
||||
}
|
||||
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||
out = enumerate_username_sites("bob", sites=SITES)
|
||||
assert all(r["exists"] is True for r in out)
|
||||
assert all(r["status"] == 200 for r in out)
|
||||
|
||||
|
||||
def test_status_404_es_no_existe(monkeypatch):
|
||||
"""Un 404 marca exists=False."""
|
||||
status_map = {
|
||||
"https://alpha.test/ghost": 404,
|
||||
"https://beta.test/ghost": 404,
|
||||
"https://gamma.test/ghost": 404,
|
||||
}
|
||||
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||
out = enumerate_username_sites("ghost", sites=SITES)
|
||||
assert all(r["exists"] is False for r in out)
|
||||
assert all(r["status"] == 404 for r in out)
|
||||
|
||||
|
||||
def test_status_otro_es_indeterminado(monkeypatch):
|
||||
"""Un codigo distinto de 200/404 deja exists=None pero conserva status."""
|
||||
status_map = {
|
||||
"https://alpha.test/x": 301,
|
||||
"https://beta.test/x": 403,
|
||||
"https://gamma.test/x": 500,
|
||||
}
|
||||
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||
out = enumerate_username_sites("x", sites=SITES)
|
||||
assert [r["exists"] for r in out] == [None, None, None]
|
||||
assert [r["status"] for r in out] == [301, 403, 500]
|
||||
|
||||
|
||||
def test_excepcion_por_sitio_no_aborta_el_resto(monkeypatch):
|
||||
"""Un fallo de red en un sitio no interrumpe la enumeracion."""
|
||||
|
||||
def _fake_get(url, **kwargs):
|
||||
if "beta" in url:
|
||||
raise requests.Timeout("simulado")
|
||||
return _FakeResp(200)
|
||||
|
||||
monkeypatch.setattr(requests, "get", _fake_get)
|
||||
out = enumerate_username_sites("u", sites=SITES)
|
||||
assert len(out) == 3
|
||||
by_site = {r["site"]: r for r in out}
|
||||
assert by_site["alpha"]["exists"] is True
|
||||
assert by_site["alpha"]["status"] == 200
|
||||
# El sitio que fallo queda con status/exists None.
|
||||
assert by_site["beta"]["exists"] is None
|
||||
assert by_site["beta"]["status"] is None
|
||||
assert by_site["gamma"]["exists"] is True
|
||||
|
||||
|
||||
def test_estructura_y_url_formateada(monkeypatch):
|
||||
"""Cada resultado lleva las claves esperadas y la URL con el username."""
|
||||
status_map = {
|
||||
"https://alpha.test/neo": 200,
|
||||
"https://beta.test/neo": 404,
|
||||
"https://gamma.test/neo": 200,
|
||||
}
|
||||
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||
out = enumerate_username_sites("neo", sites=SITES)
|
||||
for r in out:
|
||||
assert set(r.keys()) == {"site", "url", "exists", "status"}
|
||||
assert "neo" in r["url"]
|
||||
|
||||
|
||||
def test_lista_por_defecto_tiene_doce_sitios():
|
||||
"""La lista DEFAULT_SITES cubre ~12 sitios publicos."""
|
||||
assert len(mod.DEFAULT_SITES) == 12
|
||||
sites = {s["site"] for s in mod.DEFAULT_SITES}
|
||||
assert {"github", "instagram", "reddit", "telegram"} <= sites
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: extract_exif_metadata
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def extract_exif_metadata(image_path: str) -> dict"
|
||||
description: "Lee los metadatos EXIF de una imagen con Pillow y los devuelve normalizados (fecha, camara, software, GPS en grados decimales) mas el volcado completo de tags en `raw`. OSINT pasiva sobre documentos propios: revela cuando, con que dispositivo y donde se tomo una foto."
|
||||
tags: [osint-passive, exif, metadata, image, gps, forensics, pillow, extract, cybersecurity, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [PIL]
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "ruta al archivo de imagen en disco (JPEG, PNG, TIFF, ...)"
|
||||
output: "dict con datetime, camera_make, camera_model, software, gps_lat, gps_lon (grados decimales o None) y raw (dict con todos los tags EXIF legibles por nombre). Si la imagen no tiene EXIF, los campos van a None y raw={}."
|
||||
tested: true
|
||||
tests:
|
||||
- "imagen sin EXIF (PNG) devuelve campos None y raw vacio"
|
||||
- "imagen con EXIF devuelve camara, software y fecha"
|
||||
- "GPSInfo DMS se convierte a grados decimales con signo por hemisferio"
|
||||
test_file_path: "python/functions/cybersecurity/extract_exif_metadata_test.py"
|
||||
file_path: "python/functions/cybersecurity/extract_exif_metadata.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity.extract_exif_metadata import extract_exif_metadata
|
||||
|
||||
meta = extract_exif_metadata(
|
||||
"/home/enmanuel/Obsidian/osint/attachments/personas/objetivo_01.jpg"
|
||||
)
|
||||
print(meta["datetime"]) # '2024:08:12 19:43:07' (cuando se tomo)
|
||||
print(meta["camera_model"]) # 'iPhone 13 Pro' (con que dispositivo)
|
||||
print(meta["gps_lat"], meta["gps_lon"]) # 40.4168 -3.7038 (donde) o None, None
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recolectes inteligencia pasiva sobre una imagen propia o de un objetivo
|
||||
y necesites saber cuando, con que dispositivo y desde donde se capturo, antes de
|
||||
publicarla o compartirla. Usala tambien para auditar tus propios documentos y
|
||||
detectar fugas de metadatos (geolocalizacion, modelo de telefono, software de
|
||||
edicion) antes de subirlos a un sitio publico.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: abre el archivo del disco. Lanza si la ruta no existe o no es
|
||||
una imagen valida (Pillow `UnidentifiedImageError` / `FileNotFoundError`).
|
||||
- JPEG y HEIC de moviles suelen traer GPS embebido; PNG normalmente no lleva
|
||||
EXIF y devolvera todos los campos en None con `raw={}`.
|
||||
- El GPS revela la ubicacion fisica donde se tomo la foto — dato sensible. No
|
||||
lo loguees ni lo compartas sin consentimiento.
|
||||
- `DateTimeOriginal` vive en el sub-IFD EXIF, no en el IFD raiz; algunas
|
||||
imagenes solo tienen `DateTime` (fecha de modificacion del archivo), que se
|
||||
usa como fallback.
|
||||
- Las coordenadas se convierten de DMS (grados/minutos/segundos) a grados
|
||||
decimales y se les aplica el signo segun `GPSLatitudeRef`/`GPSLongitudeRef`
|
||||
(S y W => negativo).
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Extrae metadatos EXIF de una imagen (OSINT pasiva sobre documentos propios)."""
|
||||
|
||||
from PIL import ExifTags, Image
|
||||
|
||||
# Mapa inverso nombre -> id no hace falta: usamos ExifTags.TAGS (id -> nombre).
|
||||
_GPS_TAGS = ExifTags.GPSTAGS # id -> nombre para el sub-IFD de GPS.
|
||||
|
||||
|
||||
def _to_degrees(value) -> float | None:
|
||||
"""Convierte una coordenada GPS en formato DMS (grados, minutos, segundos) a grados decimales.
|
||||
|
||||
Pillow devuelve cada componente como un IFDRational o una tupla (num, den).
|
||||
"""
|
||||
try:
|
||||
d, m, s = value
|
||||
return float(d) + float(m) / 60.0 + float(s) / 3600.0
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_gps(gps_info: dict) -> tuple[float | None, float | None]:
|
||||
"""Devuelve (lat, lon) en grados decimales desde el sub-IFD GPSInfo, o (None, None)."""
|
||||
if not gps_info:
|
||||
return None, None
|
||||
|
||||
named = {_GPS_TAGS.get(k, k): v for k, v in gps_info.items()}
|
||||
|
||||
lat = _to_degrees(named.get("GPSLatitude")) if "GPSLatitude" in named else None
|
||||
lon = _to_degrees(named.get("GPSLongitude")) if "GPSLongitude" in named else None
|
||||
|
||||
if lat is not None and str(named.get("GPSLatitudeRef", "N")).upper() == "S":
|
||||
lat = -lat
|
||||
if lon is not None and str(named.get("GPSLongitudeRef", "E")).upper() == "W":
|
||||
lon = -lon
|
||||
|
||||
return lat, lon
|
||||
|
||||
|
||||
def extract_exif_metadata(image_path: str) -> dict:
|
||||
"""Lee los metadatos EXIF de una imagen y los devuelve normalizados.
|
||||
|
||||
Abre la imagen con Pillow y extrae los tags EXIF. Normaliza los campos
|
||||
mas relevantes para OSINT (fecha, camara, software, GPS) y adjunta el
|
||||
diccionario completo de tags legibles por nombre en `raw`.
|
||||
|
||||
Args:
|
||||
image_path: ruta al archivo de imagen (JPEG, PNG, TIFF, ...).
|
||||
|
||||
Returns:
|
||||
dict con las claves: datetime, camera_make, camera_model, software,
|
||||
gps_lat, gps_lon (grados decimales o None) y raw (dict tag->valor).
|
||||
Si la imagen no tiene EXIF, los campos van a None y raw queda {}.
|
||||
"""
|
||||
result = {
|
||||
"datetime": None,
|
||||
"camera_make": None,
|
||||
"camera_model": None,
|
||||
"software": None,
|
||||
"gps_lat": None,
|
||||
"gps_lon": None,
|
||||
"raw": {},
|
||||
}
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif = img.getexif()
|
||||
|
||||
if not exif:
|
||||
return result
|
||||
|
||||
# Tags de nivel raiz por nombre.
|
||||
raw = {ExifTags.TAGS.get(tag_id, tag_id): value for tag_id, value in exif.items()}
|
||||
|
||||
# Sub-IFD EXIF (DateTimeOriginal vive aqui, no en el IFD raiz).
|
||||
try:
|
||||
exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)
|
||||
except (AttributeError, KeyError, ValueError):
|
||||
exif_ifd = {}
|
||||
for tag_id, value in exif_ifd.items():
|
||||
raw[ExifTags.TAGS.get(tag_id, tag_id)] = value
|
||||
|
||||
# GPS IFD.
|
||||
try:
|
||||
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
|
||||
except (AttributeError, KeyError, ValueError):
|
||||
gps_ifd = {}
|
||||
if gps_ifd:
|
||||
raw["GPSInfo"] = {_GPS_TAGS.get(k, k): v for k, v in gps_ifd.items()}
|
||||
|
||||
result["raw"] = raw
|
||||
result["datetime"] = raw.get("DateTimeOriginal") or raw.get("DateTime")
|
||||
result["camera_make"] = raw.get("Make")
|
||||
result["camera_model"] = raw.get("Model")
|
||||
result["software"] = raw.get("Software")
|
||||
|
||||
lat, lon = _extract_gps(gps_ifd)
|
||||
result["gps_lat"] = lat
|
||||
result["gps_lon"] = lon
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests para extract_exif_metadata."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import Base, GPS, IFD
|
||||
from PIL.TiffImagePlugin import IFDRational
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from cybersecurity.extract_exif_metadata import extract_exif_metadata
|
||||
|
||||
|
||||
def _make_png_without_exif(path: str) -> None:
|
||||
"""Crea un PNG pequeño sin EXIF."""
|
||||
Image.new("RGB", (4, 4), (10, 20, 30)).save(path, format="PNG")
|
||||
|
||||
|
||||
def _make_jpeg_with_exif(path: str) -> None:
|
||||
"""Crea un JPEG pequeño con EXIF de camara/software/fecha."""
|
||||
img = Image.new("RGB", (4, 4), (200, 100, 50))
|
||||
exif = img.getexif()
|
||||
exif[Base.Make.value] = "TestCam"
|
||||
exif[Base.Model.value] = "Model X"
|
||||
exif[Base.Software.value] = "PyTestRig 1.0"
|
||||
exif[Base.DateTime.value] = "2024:08:12 19:43:07"
|
||||
# DateTimeOriginal vive en el sub-IFD EXIF.
|
||||
exif_ifd = exif.get_ifd(IFD.Exif)
|
||||
exif_ifd[Base.DateTimeOriginal.value] = "2024:08:12 19:43:07"
|
||||
img.save(path, format="JPEG", exif=exif)
|
||||
|
||||
|
||||
def _make_jpeg_with_gps(path: str) -> None:
|
||||
"""Crea un JPEG con GPSInfo en DMS, hemisferio sur y oeste."""
|
||||
img = Image.new("RGB", (4, 4), (0, 128, 64))
|
||||
exif = img.getexif()
|
||||
gps_ifd = exif.get_ifd(IFD.GPSInfo)
|
||||
# 40 deg, 25 min, 0.48 seg => 40.41680 grados.
|
||||
gps_ifd[GPS.GPSLatitude.value] = (
|
||||
IFDRational(40, 1),
|
||||
IFDRational(25, 1),
|
||||
IFDRational(48, 100),
|
||||
)
|
||||
gps_ifd[GPS.GPSLatitudeRef.value] = "S" # hemisferio sur => negativo
|
||||
# 3 deg, 42 min, 13.68 seg => 3.7038 grados.
|
||||
gps_ifd[GPS.GPSLongitude.value] = (
|
||||
IFDRational(3, 1),
|
||||
IFDRational(42, 1),
|
||||
IFDRational(1368, 100),
|
||||
)
|
||||
gps_ifd[GPS.GPSLongitudeRef.value] = "W" # oeste => negativo
|
||||
img.save(path, format="JPEG", exif=exif)
|
||||
|
||||
|
||||
def test_imagen_sin_exif_png_devuelve_none(tmp_path):
|
||||
"""imagen sin EXIF (PNG) devuelve campos None y raw vacio."""
|
||||
p = str(tmp_path / "plain.png")
|
||||
_make_png_without_exif(p)
|
||||
|
||||
meta = extract_exif_metadata(p)
|
||||
|
||||
assert meta["datetime"] is None
|
||||
assert meta["camera_make"] is None
|
||||
assert meta["camera_model"] is None
|
||||
assert meta["software"] is None
|
||||
assert meta["gps_lat"] is None
|
||||
assert meta["gps_lon"] is None
|
||||
assert meta["raw"] == {}
|
||||
|
||||
|
||||
def test_imagen_con_exif_devuelve_camara_software_fecha(tmp_path):
|
||||
"""imagen con EXIF devuelve camara, software y fecha."""
|
||||
p = str(tmp_path / "withexif.jpg")
|
||||
_make_jpeg_with_exif(p)
|
||||
|
||||
meta = extract_exif_metadata(p)
|
||||
|
||||
assert meta["camera_make"] == "TestCam"
|
||||
assert meta["camera_model"] == "Model X"
|
||||
assert meta["software"] == "PyTestRig 1.0"
|
||||
assert meta["datetime"] == "2024:08:12 19:43:07"
|
||||
assert meta["raw"] # no vacio
|
||||
|
||||
|
||||
def test_gps_dms_a_grados_decimales_con_signo(tmp_path):
|
||||
"""GPSInfo DMS se convierte a grados decimales con signo por hemisferio."""
|
||||
p = str(tmp_path / "withgps.jpg")
|
||||
_make_jpeg_with_gps(p)
|
||||
|
||||
meta = extract_exif_metadata(p)
|
||||
|
||||
assert meta["gps_lat"] is not None
|
||||
assert meta["gps_lon"] is not None
|
||||
# Sur y Oeste => ambos negativos.
|
||||
assert meta["gps_lat"] < 0
|
||||
assert meta["gps_lon"] < 0
|
||||
assert abs(meta["gps_lat"] - (-40.41680)) < 1e-4
|
||||
assert abs(meta["gps_lon"] - (-3.7038)) < 1e-4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
test_imagen_sin_exif_png_devuelve_none(Path(d))
|
||||
test_imagen_con_exif_devuelve_camara_software_fecha(Path(d))
|
||||
test_gps_dms_a_grados_decimales_con_signo(Path(d))
|
||||
print("Todos los tests pasaron.")
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: extract_pdf_metadata
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def extract_pdf_metadata(pdf_path: str) -> dict"
|
||||
description: "Lee los metadatos del Document Info de un PDF con pypdf (titulo, autor, creador, productor, fechas, numero de paginas) mas el volcado completo en `raw`. OSINT pasiva sobre documentos propios: revela quien y con que software genero el documento. Tolerante a PDFs cifrados (no falla, rellena `error`)."
|
||||
tags: [osint-passive, pdf, metadata, document, forensics, pypdf, extract, cybersecurity, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [pypdf]
|
||||
params:
|
||||
- name: pdf_path
|
||||
desc: "ruta al archivo PDF en disco"
|
||||
output: "dict con title, author, creator, producer, creation_date, mod_date (ISO 8601 si parseables, sino valor crudo), num_pages, raw (todo el doc info) y error (None si todo fue bien, mensaje en caso contrario)."
|
||||
tested: true
|
||||
tests:
|
||||
- "PDF con metadatos devuelve titulo, autor y num_pages"
|
||||
- "PDF sin doc info devuelve campos None sin petar"
|
||||
- "fechas parseables se devuelven en ISO 8601"
|
||||
test_file_path: "python/functions/cybersecurity/extract_pdf_metadata_test.py"
|
||||
file_path: "python/functions/cybersecurity/extract_pdf_metadata.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
|
||||
|
||||
meta = extract_pdf_metadata(
|
||||
"/home/enmanuel/Obsidian/osint/attachments/personas/cv_objetivo.pdf"
|
||||
)
|
||||
print(meta["author"]) # 'Enmanuel G.' (quien lo creo)
|
||||
print(meta["producer"]) # 'Microsoft Word 2021' (con que software)
|
||||
print(meta["creation_date"]) # '2024-03-11T10:22:00+01:00'
|
||||
print(meta["num_pages"]) # 3
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recolectes inteligencia pasiva sobre un PDF propio o de un objetivo y
|
||||
necesites saber quien lo creo, con que herramienta y cuando. Usala tambien para
|
||||
auditar tus propios documentos antes de publicarlos: el campo `author` y
|
||||
`producer` suelen filtrar el nombre real del usuario, la version del software y
|
||||
la organizacion, datos que no quieres exponer.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: abre el archivo del disco. Captura la excepcion en lugar de
|
||||
propagarla — si el PDF esta corrupto, cifrado o no es un PDF, devuelve el dict
|
||||
con lo que pudo leer y un mensaje en `error` (no lanza).
|
||||
- PDFs cifrados: intenta abrir con password vacio (caso comun de "restriccion
|
||||
de copia"). Si requiere password real, `error` empieza por `encrypted:` y los
|
||||
campos pueden quedar None.
|
||||
- Muchas fechas de PDF vienen en formato `D:YYYYMMDDHHmmSS+ZZ`; se convierten a
|
||||
ISO 8601 cuando pypdf las parsea, sino se devuelven crudas.
|
||||
- `raw` serializa los valores a string para evitar tipos no JSON-friendly
|
||||
(IndirectObject, ByteStringObject). Los campos normalizados conservan el
|
||||
contenido textual.
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Extrae metadatos de un PDF con pypdf (OSINT pasiva sobre documentos propios)."""
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
def _iso_or_raw(reader: PdfReader, getter_name: str, raw_value):
|
||||
"""Devuelve una fecha en ISO 8601 si pypdf la sabe parsear, sino el valor crudo.
|
||||
|
||||
pypdf expone `creation_date`/`modification_date` (datetime) ademas del
|
||||
string crudo `/CreationDate`/`/ModDate` en formato `D:YYYYMMDDHHmmSS`.
|
||||
"""
|
||||
try:
|
||||
dt = getattr(reader.metadata, getter_name, None)
|
||||
except Exception:
|
||||
dt = None
|
||||
if dt is not None:
|
||||
try:
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return str(raw_value) if raw_value is not None else None
|
||||
|
||||
|
||||
def extract_pdf_metadata(pdf_path: str) -> dict:
|
||||
"""Lee los metadatos del Document Info de un PDF.
|
||||
|
||||
Abre el PDF con pypdf y extrae los campos estandar del diccionario de
|
||||
informacion (titulo, autor, creador, productor, fechas) mas el numero de
|
||||
paginas. Las fechas se devuelven en ISO 8601 cuando son parseables, en su
|
||||
valor crudo en caso contrario. No falla si el PDF esta cifrado: captura la
|
||||
excepcion, devuelve lo que pueda y rellena el campo `error`.
|
||||
|
||||
Args:
|
||||
pdf_path: ruta al archivo PDF en disco.
|
||||
|
||||
Returns:
|
||||
dict con las claves: title, author, creator, producer, creation_date,
|
||||
mod_date, num_pages, raw (dict con todo el doc info) y error (None si
|
||||
todo fue bien, mensaje de error en caso contrario).
|
||||
"""
|
||||
result = {
|
||||
"title": None,
|
||||
"author": None,
|
||||
"creator": None,
|
||||
"producer": None,
|
||||
"creation_date": None,
|
||||
"mod_date": None,
|
||||
"num_pages": None,
|
||||
"raw": {},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
reader = PdfReader(pdf_path)
|
||||
|
||||
# PDF cifrado: intentar abrir con password vacio (caso comun).
|
||||
if getattr(reader, "is_encrypted", False):
|
||||
try:
|
||||
reader.decrypt("")
|
||||
except Exception as exc:
|
||||
result["error"] = f"encrypted: {exc}"
|
||||
|
||||
try:
|
||||
result["num_pages"] = len(reader.pages)
|
||||
except Exception as exc:
|
||||
result["error"] = result["error"] or f"pages: {exc}"
|
||||
|
||||
meta = reader.metadata
|
||||
if meta is not None:
|
||||
result["title"] = str(meta.title) if meta.title is not None else None
|
||||
result["author"] = str(meta.author) if meta.author is not None else None
|
||||
result["creator"] = str(meta.creator) if meta.creator is not None else None
|
||||
result["producer"] = (
|
||||
str(meta.producer) if meta.producer is not None else None
|
||||
)
|
||||
result["creation_date"] = _iso_or_raw(
|
||||
reader, "creation_date", meta.get("/CreationDate")
|
||||
)
|
||||
result["mod_date"] = _iso_or_raw(
|
||||
reader, "modification_date", meta.get("/ModDate")
|
||||
)
|
||||
result["raw"] = {str(k): str(v) for k, v in meta.items()}
|
||||
except Exception as exc:
|
||||
result["error"] = result["error"] or str(exc)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests para extract_pdf_metadata."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
|
||||
|
||||
|
||||
def _make_pdf_with_metadata(path: str) -> None:
|
||||
"""Crea un PDF de 2 paginas con doc info (titulo, autor, fechas)."""
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=200, height=200)
|
||||
writer.add_blank_page(width=200, height=200)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "Documento OSINT",
|
||||
"/Author": "Enmanuel G.",
|
||||
"/Creator": "PyTestRig",
|
||||
"/Producer": "pypdf",
|
||||
"/CreationDate": "D:20240311102200+01'00'",
|
||||
"/ModDate": "D:20240312113000+01'00'",
|
||||
}
|
||||
)
|
||||
with open(path, "wb") as fh:
|
||||
writer.write(fh)
|
||||
|
||||
|
||||
def _make_pdf_without_metadata(path: str) -> None:
|
||||
"""Crea un PDF de 1 pagina sin doc info."""
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
with open(path, "wb") as fh:
|
||||
writer.write(fh)
|
||||
|
||||
|
||||
def test_pdf_con_metadatos_devuelve_titulo_autor_paginas(tmp_path):
|
||||
"""PDF con metadatos devuelve titulo, autor y num_pages."""
|
||||
p = str(tmp_path / "withmeta.pdf")
|
||||
_make_pdf_with_metadata(p)
|
||||
|
||||
meta = extract_pdf_metadata(p)
|
||||
|
||||
assert meta["error"] is None
|
||||
assert meta["title"] == "Documento OSINT"
|
||||
assert meta["author"] == "Enmanuel G."
|
||||
assert meta["creator"] == "PyTestRig"
|
||||
assert meta["producer"] == "pypdf"
|
||||
assert meta["num_pages"] == 2
|
||||
assert meta["raw"] # no vacio
|
||||
|
||||
|
||||
def test_pdf_sin_doc_info_devuelve_none_sin_petar(tmp_path):
|
||||
"""PDF sin doc info devuelve campos None sin petar."""
|
||||
p = str(tmp_path / "nometa.pdf")
|
||||
_make_pdf_without_metadata(p)
|
||||
|
||||
meta = extract_pdf_metadata(p)
|
||||
|
||||
assert meta["error"] is None
|
||||
assert meta["num_pages"] == 1
|
||||
assert meta["title"] is None
|
||||
assert meta["author"] is None
|
||||
|
||||
|
||||
def test_fechas_parseables_en_iso_8601(tmp_path):
|
||||
"""fechas parseables se devuelven en ISO 8601."""
|
||||
p = str(tmp_path / "dates.pdf")
|
||||
_make_pdf_with_metadata(p)
|
||||
|
||||
meta = extract_pdf_metadata(p)
|
||||
|
||||
# pypdf parsea D:YYYYMMDDHHmmSS a datetime; isoformat() lleva 'T'.
|
||||
assert meta["creation_date"] is not None
|
||||
assert "2024-03-11" in meta["creation_date"]
|
||||
assert "T" in meta["creation_date"]
|
||||
assert meta["mod_date"] is not None
|
||||
assert "2024-03-12" in meta["mod_date"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
test_pdf_con_metadatos_devuelve_titulo_autor_paginas(Path(d))
|
||||
test_pdf_sin_doc_info_devuelve_none_sin_petar(Path(d))
|
||||
test_fechas_parseables_en_iso_8601(Path(d))
|
||||
print("Todos los tests pasaron.")
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: guess_email_formats
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list"
|
||||
description: "Genera candidatos de email comunes (nombre.apellido, n.apellido, apellido.nombre, inicial+apellido, variantes con dos apellidos, etc.) a partir de nombre, apellidos y dominio. Normaliza acentos y ñ a ASCII en minusculas y deduplica preservando el orden. OSINT pasivo puro, sin red."
|
||||
tags: [osint-passive, email, enumeration, recon, identity, cybersecurity, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [unicodedata]
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "Nombre de pila. Si tiene varios tokens se usa el primero como nombre principal."
|
||||
- name: apellidos
|
||||
desc: "Uno o dos apellidos separados por espacio. Con dos apellidos se generan variantes que los unen."
|
||||
- name: dominio
|
||||
desc: "Dominio de correo sin arroba (ej. 'empresa.com'). Si viene con '@' delante se limpia."
|
||||
output: "Lista de strings '<local>@<dominio>' con los candidatos de email en orden de generacion, sin duplicados."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_nombre_simple_un_apellido"
|
||||
- "test_acentos_y_enie_normalizados"
|
||||
- "test_dos_apellidos_genera_variantes_unidas"
|
||||
- "test_dedup_preserva_orden"
|
||||
- "test_dominio_con_arroba_se_limpia"
|
||||
test_file_path: "python/functions/cybersecurity/guess_email_formats_test.py"
|
||||
file_path: "python/functions/cybersecurity/guess_email_formats.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
guess_email_formats("José", "García López", "empresa.com")
|
||||
# ['jose@empresa.com',
|
||||
# 'jose.garcia@empresa.com',
|
||||
# 'josegarcia@empresa.com',
|
||||
# 'j.garcia@empresa.com',
|
||||
# 'jgarcia@empresa.com',
|
||||
# 'jose_garcia@empresa.com',
|
||||
# 'garcia.jose@empresa.com',
|
||||
# 'joseg@empresa.com',
|
||||
# 'jose.garcialopez@empresa.com',
|
||||
# 'josegarcialopez@empresa.com',
|
||||
# 'jose.lopez@empresa.com',
|
||||
# 'jgarcialopez@empresa.com']
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala al arrancar una investigacion de identidad cuando conoces nombre + apellidos + dominio de una organizacion y quieres una lista de direcciones probables para verificar despues (MX, catch-all, validacion SMTP, breach lookup). Es el primer paso antes de cualquier comprobacion activa.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion pura: NO valida que el email exista ni hace ninguna comprobacion de red. Solo genera candidatos sintacticos.
|
||||
- La lista de patrones es heuristica (los formatos mas comunes), no exhaustiva: organizaciones con esquemas propios (ej. id numerico) no quedaran cubiertas.
|
||||
- La normalizacion translitera acentos y ñ a ASCII y elimina cualquier caracter no alfanumerico del local part; nombres con guiones o apostrofes pierden esos separadores.
|
||||
- Solo usa el primer token del nombre como nombre principal; nombres compuestos (ej. "Maria Jose") no generan variantes con el segundo nombre.
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Genera candidatos de email a partir de nombre, apellidos y dominio.
|
||||
|
||||
Funcion pura de OSINT pasivo: produce los patrones de direccion de correo
|
||||
mas habituales en organizaciones, sin tocar la red. Util como punto de
|
||||
partida para verificacion posterior (MX, catch-all, validacion SMTP, etc.).
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _ascii_lower(text: str) -> str:
|
||||
"""Normaliza un texto a ASCII en minusculas.
|
||||
|
||||
Translitera acentos y caracteres latinos (a, e, n, ...) a su forma
|
||||
ASCII, pasa a minusculas y elimina cualquier caracter que no sea
|
||||
alfanumerico. No introduce separadores (a diferencia de un slug).
|
||||
|
||||
Args:
|
||||
text: texto de entrada (puede contener acentos, ñ, mayusculas).
|
||||
|
||||
Returns:
|
||||
cadena ASCII en minusculas formada solo por [a-z0-9].
|
||||
"""
|
||||
# NFKD descompone los caracteres acentuados en base + diacritico.
|
||||
decomposed = unicodedata.normalize("NFKD", text)
|
||||
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||
lowered = stripped.lower()
|
||||
return "".join(c for c in lowered if c.isalnum())
|
||||
|
||||
|
||||
def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list:
|
||||
"""Genera candidatos de email comunes a partir de identidad y dominio.
|
||||
|
||||
Combina el nombre y los apellidos en los patrones de direccion mas
|
||||
frecuentes (nombre.apellido, inicial+apellido, apellido.nombre, etc.),
|
||||
normalizando acentos/ñ a ASCII y minusculas. Si hay dos apellidos
|
||||
tambien genera variantes que los unen. Deduplica preservando el orden
|
||||
de aparicion.
|
||||
|
||||
Args:
|
||||
nombre: nombre de pila (puede incluir varios tokens separados por
|
||||
espacio; se usa el primero como nombre principal).
|
||||
apellidos: uno o dos apellidos separados por espacio.
|
||||
dominio: dominio de correo sin arroba (ej. "empresa.com").
|
||||
|
||||
Returns:
|
||||
lista de strings "<local>@<dominio>" con los candidatos, en el
|
||||
orden en que se generan los patrones y sin duplicados.
|
||||
"""
|
||||
n = _ascii_lower(nombre.split()[0]) if nombre.split() else ""
|
||||
apellido_tokens = [_ascii_lower(a) for a in apellidos.split() if _ascii_lower(a)]
|
||||
a1 = apellido_tokens[0] if apellido_tokens else ""
|
||||
a2 = apellido_tokens[1] if len(apellido_tokens) > 1 else ""
|
||||
dom = dominio.strip().lstrip("@").lower()
|
||||
|
||||
ni = n[0] if n else ""
|
||||
a1i = a1[0] if a1 else ""
|
||||
apellido_full = "".join(apellido_tokens) # apellido1apellido2 unidos
|
||||
|
||||
locals_raw = [
|
||||
n, # nombre
|
||||
f"{n}.{a1}" if n and a1 else "", # nombre.apellido
|
||||
f"{n}{a1}" if n and a1 else "", # nombreapellido
|
||||
f"{ni}.{a1}" if ni and a1 else "", # n.apellido
|
||||
f"{ni}{a1}" if ni and a1 else "", # napellido
|
||||
f"{n}_{a1}" if n and a1 else "", # nombre_apellido
|
||||
f"{a1}.{n}" if a1 and n else "", # apellido.nombre
|
||||
f"{n}{a1i}" if n and a1i else "", # nombre+inicial_apellido
|
||||
f"{n}.{apellido_full}" if n and a2 else "", # nombre.apellido1apellido2
|
||||
f"{n}{apellido_full}" if n and a2 else "", # nombreapellido1apellido2
|
||||
f"{n}.{a2}" if n and a2 else "", # nombre.apellido2
|
||||
f"{ni}{apellido_full}" if ni and a2 else "", # n+apellidos unidos
|
||||
]
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for local in locals_raw:
|
||||
if not local:
|
||||
continue
|
||||
email = f"{local}@{dom}"
|
||||
if email not in seen:
|
||||
seen.add(email)
|
||||
out.append(email)
|
||||
return out
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests para guess_email_formats."""
|
||||
|
||||
from guess_email_formats import guess_email_formats
|
||||
|
||||
|
||||
def test_nombre_simple_un_apellido():
|
||||
"""Nombre simple con un apellido produce los patrones base."""
|
||||
result = guess_email_formats("Juan", "Perez", "empresa.com")
|
||||
assert "juan.perez@empresa.com" in result
|
||||
assert "juanperez@empresa.com" in result
|
||||
assert "j.perez@empresa.com" in result
|
||||
assert "jperez@empresa.com" in result
|
||||
assert "juan_perez@empresa.com" in result
|
||||
assert "perez.juan@empresa.com" in result
|
||||
assert "juan@empresa.com" in result
|
||||
# Sin segundo apellido no hay variantes unidas.
|
||||
assert all("@empresa.com" in e for e in result)
|
||||
|
||||
|
||||
def test_acentos_y_enie_normalizados():
|
||||
"""Acentos y ñ se transliteran a ASCII en minusculas."""
|
||||
result = guess_email_formats("José", "Muñoz", "acme.org")
|
||||
assert "jose.munoz@acme.org" in result
|
||||
assert "j.munoz@acme.org" in result
|
||||
# No debe aparecer ningun caracter no ASCII.
|
||||
for email in result:
|
||||
assert email == email.encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
|
||||
def test_dos_apellidos_genera_variantes_unidas():
|
||||
"""Dos apellidos producen variantes que los unen."""
|
||||
result = guess_email_formats("Maria", "Garcia Lopez", "uni.edu")
|
||||
assert "maria.garcialopez@uni.edu" in result
|
||||
assert "mariagarcialopez@uni.edu" in result
|
||||
assert "maria.lopez@uni.edu" in result
|
||||
assert "mgarcialopez@uni.edu" in result
|
||||
# El patron de primer apellido sigue presente.
|
||||
assert "maria.garcia@uni.edu" in result
|
||||
|
||||
|
||||
def test_dedup_preserva_orden():
|
||||
"""Los candidatos no se repiten y mantienen el orden de generacion."""
|
||||
result = guess_email_formats("Ana", "Ruiz", "x.com")
|
||||
assert len(result) == len(set(result))
|
||||
# nombre.apellido aparece antes que apellido.nombre.
|
||||
assert result.index("ana.ruiz@x.com") < result.index("ruiz.ana@x.com")
|
||||
|
||||
|
||||
def test_dominio_con_arroba_se_limpia():
|
||||
"""Un dominio prefijado con @ se normaliza sin duplicar la arroba."""
|
||||
result = guess_email_formats("Luis", "Soto", "@corp.io")
|
||||
assert "luis.soto@corp.io" in result
|
||||
assert all(e.count("@") == 1 for e in result)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: scan_ficha_attachments_metadata
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def scan_ficha_attachments_metadata(attachments_dir: str) -> dict"
|
||||
description: "Orquestador OSINT pasivo: recorre un directorio de attachments de una ficha (imagenes y PDFs), extrae sus metadatos componiendo extract_exif_metadata (imagenes .jpg/.jpeg/.png/.heic) y extract_pdf_metadata (.pdf), y agrega los puntos GPS y las fechas encontradas. Devuelve files + gps_points + dates + summary."
|
||||
tags: [osint-enrich, osint-passive, cybersecurity, metadata, exif, pdf]
|
||||
uses_functions: [extract_exif_metadata_py_cybersecurity, extract_pdf_metadata_py_cybersecurity]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os]
|
||||
params:
|
||||
- name: attachments_dir
|
||||
desc: "ruta a un directorio de attachments, p.ej. /home/enmanuel/Obsidian/osint/attachments/personas/<slug>/. Se recorre recursivamente. Si no es un directorio existente lanza NotADirectoryError"
|
||||
output: "dict con files (lista de {path, type, metadata} por archivo procesado: type es 'image' o 'pdf'), gps_points (lista de {file, lat, lon} agregando las coordenadas EXIF), dates (lista de fechas str de EXIF y PDF) y summary ({n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}). Los archivos cuya extraccion falla quedan con metadata={'error': ...} y suman a summary.errors"
|
||||
tested: true
|
||||
tests: ["test_golden_agrega_gps_y_fechas_de_imagen_y_pdf", "test_directorio_inexistente_lanza", "test_ignora_extensiones_no_soportadas", "test_error_en_archivo_se_captura_en_summary"]
|
||||
test_file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata_test.py"
|
||||
file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import scan_ficha_attachments_metadata
|
||||
|
||||
# Escanea los attachments de una persona en el vault OSINT.
|
||||
res = scan_ficha_attachments_metadata(
|
||||
"/home/enmanuel/Obsidian/osint/attachments/personas/juan-perez/"
|
||||
)
|
||||
|
||||
print(res["summary"]) # {'n_files': 7, 'n_images': 5, 'n_pdfs': 2, 'n_gps_points': 2, 'n_dates': 4, 'errors': 0}
|
||||
for p in res["gps_points"]:
|
||||
print(p["file"], p["lat"], p["lon"]) # coordenadas extraidas de las fotos
|
||||
print(res["dates"]) # fechas EXIF + fechas de creacion/modificacion de los PDFs
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando montas la ficha OSINT de una persona u organizacion y quieres saber de un vistazo que huella de metadatos dejan los documentos/fotos que ya tienes guardados (donde y cuando se hicieron).
|
||||
- Antes de publicar/compartir attachments propios: para auditar que GPS y fechas se filtrarian.
|
||||
- Como paso de enriquecimiento dentro de un pipeline de investigacion, justo despues de descargar los attachments al directorio de la ficha.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Uso solo para investigacion autorizada.** Extraer metadatos de archivos ajenos sin permiso puede ser ilegal segun jurisdiccion. Limitate a tus propios documentos o a material que estes autorizado a analizar.
|
||||
- Funcion IMPURA: hace I/O sobre el sistema de archivos (recorrido recursivo con `os.walk`). Si `attachments_dir` no existe lanza `NotADirectoryError`.
|
||||
- La extraccion por archivo se aisla con try/except: un archivo corrupto no aborta el escaneo, queda con `metadata={"error": ...}` y suma a `summary.errors`.
|
||||
- Solo procesa imagenes .jpg/.jpeg/.png/.heic y PDFs .pdf; el resto se ignora silenciosamente. El soporte real de HEIC/EXIF depende de lo que `extract_exif_metadata` (Pillow) pueda abrir en el sistema.
|
||||
- Las fechas se devuelven tal cual las reportan EXIF/PDF (formatos heterogeneos, sin normalizar a ISO).
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Orquestador OSINT pasivo: escanea metadatos de los attachments de una ficha.
|
||||
|
||||
Recorre un directorio de attachments (imagenes y PDFs) y extrae sus metadatos
|
||||
componiendo las funciones atomicas del registro (`extract_exif_metadata`,
|
||||
`extract_pdf_metadata`). Agrega los puntos GPS y las fechas encontradas para
|
||||
dar una vista rapida de la huella de metadatos de una persona/organizacion.
|
||||
|
||||
Funcion IMPURA: hace I/O sobre el sistema de archivos.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from cybersecurity import extract_exif_metadata, extract_pdf_metadata
|
||||
|
||||
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".heic")
|
||||
_PDF_EXTS = (".pdf",)
|
||||
|
||||
|
||||
def _iter_files(attachments_dir: str):
|
||||
"""Recorre recursivamente el directorio devolviendo rutas de archivo ordenadas."""
|
||||
for root, _dirs, files in os.walk(attachments_dir):
|
||||
for name in sorted(files):
|
||||
yield os.path.join(root, name)
|
||||
|
||||
|
||||
def scan_ficha_attachments_metadata(attachments_dir: str) -> dict:
|
||||
"""Escanea un directorio de attachments y agrega los metadatos extraidos.
|
||||
|
||||
Aplica `extract_exif_metadata` a las imagenes (.jpg/.jpeg/.png/.heic) y
|
||||
`extract_pdf_metadata` a los PDFs (.pdf). Agrega los puntos GPS de las
|
||||
imagenes y todas las fechas detectadas (EXIF + PDF).
|
||||
|
||||
Args:
|
||||
attachments_dir: ruta a un directorio de attachments, p.ej.
|
||||
`/home/enmanuel/Obsidian/osint/attachments/personas/<slug>/`.
|
||||
Se recorre recursivamente.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
- files: lista de {path, type, metadata} por archivo procesado.
|
||||
- gps_points: lista de {file, lat, lon} con las coordenadas EXIF.
|
||||
- dates: lista de fechas (str) encontradas en EXIF y PDF.
|
||||
- summary: {n_files, n_images, n_pdfs, n_gps_points, n_dates,
|
||||
errors} con conteos agregados.
|
||||
|
||||
Raises:
|
||||
NotADirectoryError: si `attachments_dir` no es un directorio existente.
|
||||
"""
|
||||
if not os.path.isdir(attachments_dir):
|
||||
raise NotADirectoryError(f"no es un directorio: {attachments_dir}")
|
||||
|
||||
files: list[dict] = []
|
||||
gps_points: list[dict] = []
|
||||
dates: list[str] = []
|
||||
n_images = 0
|
||||
n_pdfs = 0
|
||||
errors = 0
|
||||
|
||||
for path in _iter_files(attachments_dir):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext in _IMAGE_EXTS:
|
||||
ftype = "image"
|
||||
elif ext in _PDF_EXTS:
|
||||
ftype = "pdf"
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
if ftype == "image":
|
||||
metadata = extract_exif_metadata(path)
|
||||
n_images += 1
|
||||
lat = metadata.get("gps_lat")
|
||||
lon = metadata.get("gps_lon")
|
||||
if lat is not None and lon is not None:
|
||||
gps_points.append({"file": path, "lat": lat, "lon": lon})
|
||||
dt = metadata.get("datetime")
|
||||
if dt:
|
||||
dates.append(str(dt))
|
||||
else:
|
||||
metadata = extract_pdf_metadata(path)
|
||||
n_pdfs += 1
|
||||
for key in ("creation_date", "modification_date", "creationDate", "modDate"):
|
||||
val = metadata.get(key)
|
||||
if val:
|
||||
dates.append(str(val))
|
||||
except Exception as exc: # noqa: BLE001 - I/O sobre archivos heterogeneos
|
||||
errors += 1
|
||||
metadata = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
files.append({"path": path, "type": ftype, "metadata": metadata})
|
||||
|
||||
summary = {
|
||||
"n_files": len(files),
|
||||
"n_images": n_images,
|
||||
"n_pdfs": n_pdfs,
|
||||
"n_gps_points": len(gps_points),
|
||||
"n_dates": len(dates),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
return {
|
||||
"files": files,
|
||||
"gps_points": gps_points,
|
||||
"dates": dates,
|
||||
"summary": summary,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests para scan_ficha_attachments_metadata.
|
||||
|
||||
Las funciones compuestas (extract_exif_metadata / extract_pdf_metadata) se
|
||||
monkeypatchean para no depender ni de archivos reales ni de Pillow. Solo se
|
||||
verifica la orquestacion y la agregacion (GPS, fechas, summary, manejo de
|
||||
errores y filtrado de extensiones).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from cybersecurity import scan_ficha_attachments_metadata
|
||||
|
||||
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo, asi
|
||||
# que `cybersecurity.scan_ficha_attachments_metadata` resuelve a la funcion.
|
||||
# Para parchear los globals del modulo orquestador lo tomamos via importlib.
|
||||
mod = importlib.import_module("cybersecurity.scan_ficha_attachments_metadata")
|
||||
|
||||
|
||||
def _make_tree(tmp_path, names):
|
||||
"""Crea archivos vacios en tmp_path; devuelve el directorio."""
|
||||
d = tmp_path / "ficha"
|
||||
d.mkdir()
|
||||
for name in names:
|
||||
(d / name).write_bytes(b"")
|
||||
return str(d)
|
||||
|
||||
|
||||
def test_golden_agrega_gps_y_fechas_de_imagen_y_pdf(tmp_path, monkeypatch):
|
||||
attachments = _make_tree(tmp_path, ["foto1.jpg", "foto2.png", "doc.pdf"])
|
||||
|
||||
exif_by_name = {
|
||||
"foto1.jpg": {"datetime": "2024:01:02 10:11:12", "gps_lat": 40.4, "gps_lon": -3.7},
|
||||
"foto2.png": {"datetime": None, "gps_lat": None, "gps_lon": None},
|
||||
}
|
||||
|
||||
def fake_exif(path):
|
||||
return exif_by_name[os.path.basename(path)]
|
||||
|
||||
def fake_pdf(path):
|
||||
return {"creation_date": "D:20230101000000", "modification_date": "D:20230202000000"}
|
||||
|
||||
monkeypatch.setattr(mod, "extract_exif_metadata", fake_exif)
|
||||
monkeypatch.setattr(mod, "extract_pdf_metadata", fake_pdf)
|
||||
|
||||
res = scan_ficha_attachments_metadata(attachments)
|
||||
|
||||
# 3 archivos procesados (2 imagenes + 1 pdf).
|
||||
assert res["summary"] == {
|
||||
"n_files": 3,
|
||||
"n_images": 2,
|
||||
"n_pdfs": 1,
|
||||
"n_gps_points": 1,
|
||||
"n_dates": 3, # 1 de la imagen + 2 del pdf
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
# GPS solo de foto1.
|
||||
assert res["gps_points"] == [
|
||||
{"file": os.path.join(attachments, "foto1.jpg"), "lat": 40.4, "lon": -3.7}
|
||||
]
|
||||
|
||||
# Fechas agregadas de EXIF + PDF.
|
||||
assert "2024:01:02 10:11:12" in res["dates"]
|
||||
assert "D:20230101000000" in res["dates"]
|
||||
assert "D:20230202000000" in res["dates"]
|
||||
|
||||
# files lleva path/type/metadata por cada uno.
|
||||
types = {os.path.basename(f["path"]): f["type"] for f in res["files"]}
|
||||
assert types == {"foto1.jpg": "image", "foto2.png": "image", "doc.pdf": "pdf"}
|
||||
|
||||
|
||||
def test_directorio_inexistente_lanza(tmp_path):
|
||||
with pytest.raises(NotADirectoryError):
|
||||
scan_ficha_attachments_metadata(str(tmp_path / "no-existe"))
|
||||
|
||||
|
||||
def test_ignora_extensiones_no_soportadas(tmp_path, monkeypatch):
|
||||
attachments = _make_tree(tmp_path, ["nota.txt", "video.mp4", "foto.jpg"])
|
||||
|
||||
monkeypatch.setattr(mod, "extract_exif_metadata", lambda p: {"gps_lat": None, "gps_lon": None, "datetime": None})
|
||||
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {})
|
||||
|
||||
res = scan_ficha_attachments_metadata(attachments)
|
||||
|
||||
# Solo la imagen cuenta; txt y mp4 se ignoran.
|
||||
assert res["summary"]["n_files"] == 1
|
||||
assert res["summary"]["n_images"] == 1
|
||||
assert res["summary"]["n_pdfs"] == 0
|
||||
assert [os.path.basename(f["path"]) for f in res["files"]] == ["foto.jpg"]
|
||||
|
||||
|
||||
def test_error_en_archivo_se_captura_en_summary(tmp_path, monkeypatch):
|
||||
attachments = _make_tree(tmp_path, ["roto.jpg", "ok.pdf"])
|
||||
|
||||
def boom(path):
|
||||
raise OSError("imagen corrupta")
|
||||
|
||||
monkeypatch.setattr(mod, "extract_exif_metadata", boom)
|
||||
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {"creation_date": "D:20200101"})
|
||||
|
||||
res = scan_ficha_attachments_metadata(attachments)
|
||||
|
||||
assert res["summary"]["errors"] == 1
|
||||
assert res["summary"]["n_files"] == 2 # ambos archivos quedan registrados
|
||||
roto = next(f for f in res["files"] if os.path.basename(f["path"]) == "roto.jpg")
|
||||
assert "error" in roto["metadata"]
|
||||
assert "imagen corrupta" in roto["metadata"]["error"]
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: whois_lookup
|
||||
kind: function
|
||||
lang: py
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict"
|
||||
description: "Recoleccion OSINT pasiva de datos de registro de dominio via RDAP (reemplazo moderno de WHOIS sobre HTTP/JSON). Consulta https://rdap.org/domain/<dominio> con http_get_json y normaliza registrar, fechas de creacion/expiracion/ultimo cambio, nameservers, estados y entidades. Devuelve {found: False} si el dominio no existe (404)."
|
||||
tags: [osint-passive, whois, rdap, recon, cybersecurity]
|
||||
params:
|
||||
- name: dominio
|
||||
desc: "Dominio a consultar, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||
- name: timeout_s
|
||||
desc: "Segundos maximo de espera de la peticion HTTP a rdap.org (default 15.0)."
|
||||
output: "dict normalizado con found (bool), registrar, creation_date, expiration_date, last_changed, nameservers (lista), status (lista), entities (lista de {handle, roles}) y raw (RDAP completo). Si el dominio no existe (HTTP 404) devuelve {found: False}."
|
||||
uses_functions: ["http_get_json_py_infra"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_normaliza_respuesta_rdap", "test_dominio_no_encontrado_404", "test_otro_error_http_se_propaga", "test_sin_registrar_ni_fechas", "test_dominio_vacio_lanza_error"]
|
||||
test_file_path: "python/functions/cybersecurity/whois_lookup_test.py"
|
||||
file_path: "python/functions/cybersecurity/whois_lookup.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity import whois_lookup
|
||||
|
||||
info = whois_lookup("organic-machine.com")
|
||||
if info["found"]:
|
||||
print(info["registrar"]) # 'Example Registrar Inc.'
|
||||
print(info["creation_date"]) # '2020-01-15T10:00:00Z'
|
||||
print(info["expiration_date"]) # '2027-01-15T10:00:00Z'
|
||||
print(info["nameservers"]) # ['ns1.example.net', 'ns2.example.net']
|
||||
print(info["status"]) # ['client transfer prohibited']
|
||||
else:
|
||||
print("dominio no registrado")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para obtener metadatos de registro de un dominio sin depender del CLI
|
||||
`whois` (no instalado): edad del dominio, fecha de expiracion (dominios a
|
||||
punto de caducar), registrar y nameservers autoritativos. Util en perfilado
|
||||
pasivo, deteccion de dominios recien creados (typosquatting/phishing) y
|
||||
validacion de propiedad.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- RDAP no esta uniformemente desplegado en todos los TLD: algunos devuelven
|
||||
campos vacios o ni siquiera responden. Por eso los campos opcionales pueden
|
||||
quedar `None` y `nameservers`/`status`/`entities` listas vacias.
|
||||
- rdap.org actua como bootstrap y redirige al servidor RDAP autoritativo del
|
||||
TLD; depende de su disponibilidad.
|
||||
- El registrante (`entities` con rol distinto de `registrar`) suele estar
|
||||
redactado por privacy/GDPR: casi siempre solo veras `handle` y `roles`, sin
|
||||
datos personales.
|
||||
- Un dominio no registrado devuelve `{"found": False}` (HTTP 404); cualquier
|
||||
otro error HTTP (rate limit 429, 5xx) se propaga como `RuntimeError`.
|
||||
- Las fechas se devuelven tal cual las da RDAP (ISO 8601 UTC), sin parsear a
|
||||
objetos `datetime`.
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Recoleccion OSINT pasiva de datos de registro de dominio via RDAP.
|
||||
|
||||
Funcion IMPURA: consulta el servicio RDAP publico (reemplazo moderno de
|
||||
WHOIS, sobre HTTP/JSON) y normaliza la respuesta. Es OSINT pasivo: no toca
|
||||
al dominio objetivo, solo el directorio RDAP publico.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
|
||||
)
|
||||
|
||||
from infra.http_get_json import http_get_json # noqa: E402
|
||||
|
||||
|
||||
def _events_by_action(raw: dict) -> dict:
|
||||
"""Indexa la lista RDAP ``events`` por ``eventAction`` -> ``eventDate``."""
|
||||
out: dict = {}
|
||||
for event in raw.get("events", []) or []:
|
||||
action = event.get("eventAction")
|
||||
date = event.get("eventDate")
|
||||
if action and date:
|
||||
out[action] = date
|
||||
return out
|
||||
|
||||
|
||||
def _extract_registrar(raw: dict) -> str | None:
|
||||
"""Busca la entidad con rol ``registrar`` y devuelve su nombre vCard."""
|
||||
for entity in raw.get("entities", []) or []:
|
||||
roles = entity.get("roles", []) or []
|
||||
if "registrar" not in roles:
|
||||
continue
|
||||
vcard = entity.get("vcardArray")
|
||||
if isinstance(vcard, list) and len(vcard) == 2:
|
||||
for field in vcard[1]:
|
||||
if isinstance(field, list) and field and field[0] == "fn":
|
||||
return field[3]
|
||||
return entity.get("handle")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_nameservers(raw: dict) -> list:
|
||||
"""Extrae los ldhName de los nameservers RDAP, ordenados."""
|
||||
servers = []
|
||||
for ns in raw.get("nameservers", []) or []:
|
||||
name = ns.get("ldhName")
|
||||
if name:
|
||||
servers.append(name.lower())
|
||||
return sorted(set(servers))
|
||||
|
||||
|
||||
def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict:
|
||||
"""Consulta RDAP de un dominio y normaliza la informacion de registro.
|
||||
|
||||
Usa ``http_get_json`` del registry contra ``https://rdap.org/domain/<dominio>``
|
||||
(rdap.org redirige al servidor RDAP autoritativo del TLD). Normaliza
|
||||
registrar, fechas (creacion / expiracion / ultimo cambio), nameservers,
|
||||
estados y entidades, e incluye la respuesta cruda en ``raw``.
|
||||
|
||||
Args:
|
||||
dominio: Dominio a consultar (ej. ``"organic-machine.com"``).
|
||||
timeout_s: Segundos maximo de espera de la peticion HTTP (default 15).
|
||||
|
||||
Returns:
|
||||
Dict normalizado con claves: ``found`` (bool), ``registrar``,
|
||||
``creation_date``, ``expiration_date``, ``last_changed``,
|
||||
``nameservers`` (lista), ``status`` (lista), ``entities`` (lista de
|
||||
roles/handles) y ``raw`` (respuesta RDAP completa). Si el dominio no
|
||||
existe (HTTP 404) devuelve ``{"found": False}``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si el dominio esta vacio o la peticion falla por una
|
||||
razon distinta de 404.
|
||||
"""
|
||||
if not dominio or not dominio.strip():
|
||||
raise RuntimeError("whois_lookup: dominio vacio")
|
||||
|
||||
url = f"https://rdap.org/domain/{dominio.strip()}"
|
||||
|
||||
try:
|
||||
raw = http_get_json(url, timeout=timeout_s)
|
||||
except RuntimeError as e:
|
||||
# http_get_json envuelve los HTTPError como "HTTP <code>".
|
||||
if "HTTP 404" in str(e):
|
||||
return {"found": False}
|
||||
raise
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise RuntimeError(
|
||||
f"whois_lookup: respuesta RDAP inesperada (tipo {type(raw).__name__})"
|
||||
)
|
||||
|
||||
events = _events_by_action(raw)
|
||||
entities = [
|
||||
{
|
||||
"handle": ent.get("handle"),
|
||||
"roles": ent.get("roles", []) or [],
|
||||
}
|
||||
for ent in raw.get("entities", []) or []
|
||||
]
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"registrar": _extract_registrar(raw),
|
||||
"creation_date": events.get("registration"),
|
||||
"expiration_date": events.get("expiration"),
|
||||
"last_changed": events.get("last changed"),
|
||||
"nameservers": _extract_nameservers(raw),
|
||||
"status": raw.get("status", []) or [],
|
||||
"entities": entities,
|
||||
"raw": raw,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests para whois_lookup."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import whois_lookup as wl
|
||||
from whois_lookup import whois_lookup
|
||||
|
||||
|
||||
def _rdap_sample() -> dict:
|
||||
return {
|
||||
"ldhName": "organic-machine.com",
|
||||
"status": ["client transfer prohibited"],
|
||||
"events": [
|
||||
{"eventAction": "registration", "eventDate": "2020-01-15T10:00:00Z"},
|
||||
{"eventAction": "expiration", "eventDate": "2027-01-15T10:00:00Z"},
|
||||
{"eventAction": "last changed", "eventDate": "2026-01-10T08:30:00Z"},
|
||||
],
|
||||
"nameservers": [
|
||||
{"ldhName": "ns1.example.net"},
|
||||
{"ldhName": "NS2.EXAMPLE.NET"},
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"handle": "REG-123",
|
||||
"roles": ["registrar"],
|
||||
"vcardArray": [
|
||||
"vcard",
|
||||
[
|
||||
["version", {}, "text", "4.0"],
|
||||
["fn", {}, "text", "Example Registrar Inc."],
|
||||
],
|
||||
],
|
||||
},
|
||||
{"handle": "REGISTRANT-9", "roles": ["registrant"]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_normaliza_respuesta_rdap(monkeypatch):
|
||||
"""Extrae registrar, fechas, nameservers, status y entities."""
|
||||
monkeypatch.setattr(wl, "http_get_json", lambda url, timeout=15.0: _rdap_sample())
|
||||
|
||||
result = whois_lookup("organic-machine.com")
|
||||
|
||||
assert result["found"] is True
|
||||
assert result["registrar"] == "Example Registrar Inc."
|
||||
assert result["creation_date"] == "2020-01-15T10:00:00Z"
|
||||
assert result["expiration_date"] == "2027-01-15T10:00:00Z"
|
||||
assert result["last_changed"] == "2026-01-10T08:30:00Z"
|
||||
assert result["nameservers"] == ["ns1.example.net", "ns2.example.net"]
|
||||
assert result["status"] == ["client transfer prohibited"]
|
||||
assert {"handle": "REGISTRANT-9", "roles": ["registrant"]} in result["entities"]
|
||||
assert result["raw"]["ldhName"] == "organic-machine.com"
|
||||
|
||||
|
||||
def test_dominio_no_encontrado_404(monkeypatch):
|
||||
"""Un HTTP 404 de http_get_json devuelve {'found': False}."""
|
||||
|
||||
def fake(url, timeout=15.0):
|
||||
raise RuntimeError("http_get_json: HTTP 404 at 'rdap.org' — not found")
|
||||
|
||||
monkeypatch.setattr(wl, "http_get_json", fake)
|
||||
|
||||
result = whois_lookup("nope-no-existe-xyz.invalid")
|
||||
|
||||
assert result == {"found": False}
|
||||
|
||||
|
||||
def test_otro_error_http_se_propaga(monkeypatch):
|
||||
"""Un error HTTP distinto de 404 se propaga como RuntimeError."""
|
||||
|
||||
def fake(url, timeout=15.0):
|
||||
raise RuntimeError("http_get_json: HTTP 500 at 'rdap.org' — boom")
|
||||
|
||||
monkeypatch.setattr(wl, "http_get_json", fake)
|
||||
|
||||
try:
|
||||
whois_lookup("organic-machine.com")
|
||||
assert False, "deberia haberse propagado el error 500"
|
||||
except RuntimeError as e:
|
||||
assert "HTTP 500" in str(e)
|
||||
|
||||
|
||||
def test_sin_registrar_ni_fechas(monkeypatch):
|
||||
"""RDAP minimo: campos opcionales quedan None / listas vacias."""
|
||||
monkeypatch.setattr(
|
||||
wl, "http_get_json", lambda url, timeout=15.0: {"ldhName": "x.com"}
|
||||
)
|
||||
|
||||
result = whois_lookup("x.com")
|
||||
|
||||
assert result["found"] is True
|
||||
assert result["registrar"] is None
|
||||
assert result["creation_date"] is None
|
||||
assert result["nameservers"] == []
|
||||
assert result["status"] == []
|
||||
assert result["entities"] == []
|
||||
|
||||
|
||||
def test_dominio_vacio_lanza_error():
|
||||
"""Dominio vacio lanza RuntimeError."""
|
||||
try:
|
||||
whois_lookup("")
|
||||
assert False, "deberia haber lanzado RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1,10 +1,28 @@
|
||||
from .setup_logger import setup_logger, get_logger
|
||||
from .generate_app_icon import generate_app_icon
|
||||
from .generate_initials_avatar import generate_initials_avatar
|
||||
from .http_replay_sequence import http_replay_sequence
|
||||
from .hoppscotch_login import hoppscotch_login
|
||||
from .hoppscotch_create_request import hoppscotch_create_request
|
||||
from .hoppscotch_update_request import hoppscotch_update_request
|
||||
from .hoppscotch_delete_request import hoppscotch_delete_request
|
||||
from .hoppscotch_list_requests import hoppscotch_list_requests
|
||||
from .pass_get_secret import pass_get_secret
|
||||
from .hoppscotch_set_environment import hoppscotch_set_environment
|
||||
from .hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
__all__ = [
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
"generate_app_icon",
|
||||
"generate_initials_avatar",
|
||||
"http_replay_sequence",
|
||||
"hoppscotch_login",
|
||||
"hoppscotch_create_request",
|
||||
"hoppscotch_update_request",
|
||||
"hoppscotch_delete_request",
|
||||
"hoppscotch_list_requests",
|
||||
"pass_get_secret",
|
||||
"hoppscotch_set_environment",
|
||||
"hoppscotch_run_request",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: build_hoppscotch_collection
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_hoppscotch_collection(calls: list[dict], *, name: str = \"Collection\", request_names: list[str] | None = None) -> dict"
|
||||
description: "Helper interno de serializacion del grupo hoppscotch: convierte call specs (method/url/headers/body/body_type) en el formato HoppRESTRequest/coleccion Hoppscotch (request v:2). Lo usan hoppscotch_create_request y hoppscotch_update_request para construir el campo request de las mutations GraphQL del self-host. NO uses el dict resultante para escribir un .json e importarlo a mano: el flujo canonico es operar el self-host por la API (ver docs/capabilities/hoppscotch.md). Pura: solo stdlib, sin red."
|
||||
tags: [hoppscotch, flow-replay, http, infra, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: calls
|
||||
desc: "lista de call specs (tipicamente la salida de har_extract_calls). Cada elemento es un dict con claves opcionales: method (str), url (str), headers (dict name->value), cookies (dict name->value), body (str|None), body_type (json|form|raw|None). Otras claves como status o sets_cookies se ignoran."
|
||||
- name: name
|
||||
desc: "nombre de la coleccion Hoppscotch resultante. Default 'Collection'."
|
||||
- name: request_names
|
||||
desc: "nombres explicitos por request, alineados por indice con calls. Si se pasa y existe el indice, sobreescribe el nombre derivado. None = derivar todos como '<METHOD> <path>'."
|
||||
output: "dict de coleccion Hoppscotch JSON-serializable: {\"v\": 1, \"name\", \"folders\": [], \"requests\": [...]}. Cada request lleva v:'2', endpoint, name, method (upper), headers (lista key/value/active con header Cookie inyectado si habia cookies), body (contentType+body segun body_type), auth none, params/requestVariables vacios."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_get_simple"
|
||||
- "test_edge_post_json_con_headers_y_cookies"
|
||||
- "test_request_names_sobreescribe_nombre_derivado"
|
||||
- "test_form_body_genera_contenttype_urlencoded"
|
||||
- "test_raw_body_genera_contenttype_text_plain"
|
||||
- "test_body_type_desconocido_da_body_null"
|
||||
- "test_lista_vacia"
|
||||
- "test_call_spec_sin_url_ni_method"
|
||||
- "test_sin_cookies_no_anade_header_cookie"
|
||||
test_file_path: "python/functions/infra/build_hoppscotch_collection_test.py"
|
||||
file_path: "python/functions/infra/build_hoppscotch_collection.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import json
|
||||
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
# Call specs tal cual salen de har_extract_calls.
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/api/search?q=foo",
|
||||
"headers": {"Accept": "application/json"},
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/login",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||
"body": '{"user":"neo","pass":"<<password>>"}',
|
||||
"body_type": "json",
|
||||
},
|
||||
]
|
||||
|
||||
collection = build_hoppscotch_collection(calls, name="Example flow")
|
||||
# {
|
||||
# "v": 1,
|
||||
# "name": "Example flow",
|
||||
# "folders": [],
|
||||
# "requests": [
|
||||
# {
|
||||
# "v": "2",
|
||||
# "endpoint": "https://api.example.com/api/search?q=foo",
|
||||
# "name": "GET /api/search",
|
||||
# "params": [],
|
||||
# "headers": [{"key": "Accept", "value": "application/json", "active": True}],
|
||||
# "method": "GET",
|
||||
# "auth": {"authType": "none", "authActive": True},
|
||||
# "preRequestScript": "",
|
||||
# "testScript": "",
|
||||
# "body": {"contentType": None, "body": None},
|
||||
# "requestVariables": [],
|
||||
# },
|
||||
# {
|
||||
# "v": "2",
|
||||
# "endpoint": "https://api.example.com/login",
|
||||
# "name": "POST /login",
|
||||
# "params": [],
|
||||
# "headers": [
|
||||
# {"key": "Content-Type", "value": "application/json", "active": True},
|
||||
# {"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||
# ],
|
||||
# "method": "POST",
|
||||
# "auth": {"authType": "none", "authActive": True},
|
||||
# "preRequestScript": "",
|
||||
# "testScript": "",
|
||||
# "body": {"contentType": "application/json", "body": '{"user":"neo","pass":"<<password>>"}'},
|
||||
# "requestVariables": [],
|
||||
# },
|
||||
# ],
|
||||
# }
|
||||
|
||||
# Listo para escribir a disco e importar en la app Desktop / hopp CLI.
|
||||
with open("flow.collection.json", "w") as f:
|
||||
json.dump(collection, f, indent=2)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras abrir en la GUI de Hoppscotch unas peticiones que ya grabaste y destilaste con el patron grabar->destilar->reproducir (HAR -> har_filter_flows -> har_extract_calls). Pasas las call specs por esta funcion, guardas el dict resultante como `.json` y lo importas en la app Desktop o con el CLI `hopp`. Es la salida amigable para humanos del flujo de replay: cuando prefieras inspeccionar/tocar las peticiones a mano en el GUI antes de promoverlas a una funcion-accion del registry con http_replay_sequence. La funcion inversa, parse_hoppscotch_collection, reimporta una coleccion editada en el GUI de vuelta a call specs.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Genera el formato canonico estable v1/v2.** La coleccion sale como `v:1` y cada request como `v:"2"` — la forma de los fixtures oficiales de Hoppscotch, garantizada importable. Hoppscotch la migra automaticamente a su ultima version interna al importar; no intentes emitir la version "ultima" a mano.
|
||||
- **Las cookies se inyectan como header Cookie.** Si un call spec trae `cookies` no vacio, se anade un unico header `Cookie` al final con formato `k1=v1; k2=v2`. Hoppscotch no tiene un slot de cookies separado en el request, asi que viajan en headers; al reimportar con parse_hoppscotch_collection se vuelven a separar.
|
||||
- **Los secretos NO se sustituyen.** La funcion copia headers, cookies y body tal cual. Tokens de sesion, `Authorization` y contrasenas viajan en claro en el dict resultante. Si quieres parametrizar, es el caller quien debe marcar los valores con `<<var>>` (referencia a variable de environment de Hoppscotch) antes de llamar a esta funcion. NO commitear el `.json` resultante sin redactar.
|
||||
- **Claves extra del call spec se ignoran.** `status`, `sets_cookies` y cualquier otra clave que no sea method/url/headers/cookies/body/body_type no aparecen en la coleccion (son metadata de la captura, no del request a reproducir).
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Convierte call specs del registry en una coleccion Hoppscotch importable.
|
||||
|
||||
Mitad "exportar al GUI" del puente entre el motor de replay del registry y
|
||||
Hoppscotch. La funcion inversa es parse_hoppscotch_collection.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _request_name(call: dict, fallback_index: int) -> str:
|
||||
"""Deriva un nombre legible para la request a partir del metodo y el path.
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) method y url.
|
||||
fallback_index: indice de la call dentro de la lista (no usado en el
|
||||
nombre derivado, reservado para desambiguar si hiciera falta).
|
||||
|
||||
Returns:
|
||||
nombre del estilo "GET /api/search".
|
||||
"""
|
||||
method = str(call.get("method") or "GET").upper()
|
||||
url = str(call.get("url") or "")
|
||||
path = urlparse(url).path or "/"
|
||||
return f"{method} {path}"
|
||||
|
||||
|
||||
def _build_headers(call: dict) -> list[dict]:
|
||||
"""Construye la lista de headers Hoppscotch desde el dict del call spec.
|
||||
|
||||
Convierte el dict headers (preservando orden de insercion) a la lista
|
||||
[{"key", "value", "active": True}, ...] y, si el call spec trae cookies no
|
||||
vacias, anade un header extra "Cookie" al final con formato "k1=v1; k2=v2".
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) headers y cookies.
|
||||
|
||||
Returns:
|
||||
lista de headers Hoppscotch.
|
||||
"""
|
||||
headers: list[dict] = []
|
||||
raw_headers = call.get("headers") or {}
|
||||
for key, value in raw_headers.items():
|
||||
headers.append({"key": key, "value": value, "active": True})
|
||||
|
||||
cookies = call.get("cookies") or {}
|
||||
if cookies:
|
||||
cookie_value = "; ".join(f"{name}={val}" for name, val in cookies.items())
|
||||
headers.append({"key": "Cookie", "value": cookie_value, "active": True})
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def _build_body(call: dict) -> dict:
|
||||
"""Construye el objeto body Hoppscotch segun body_type del call spec.
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) body y body_type.
|
||||
|
||||
Returns:
|
||||
dict con contentType y body. Si no hay body o el body_type es
|
||||
desconocido/None, ambos campos son None.
|
||||
"""
|
||||
body = call.get("body")
|
||||
body_type = call.get("body_type")
|
||||
|
||||
content_types = {
|
||||
"json": "application/json",
|
||||
"form": "application/x-www-form-urlencoded",
|
||||
"raw": "text/plain",
|
||||
}
|
||||
|
||||
if body is None or body_type not in content_types:
|
||||
return {"contentType": None, "body": None}
|
||||
|
||||
return {"contentType": content_types[body_type], "body": body}
|
||||
|
||||
|
||||
def build_hoppscotch_collection(
|
||||
calls: list[dict],
|
||||
*,
|
||||
name: str = "Collection",
|
||||
request_names: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Convierte una lista de call specs en una coleccion Hoppscotch importable.
|
||||
|
||||
Genera el formato canonico estable (coleccion v:1, request v:"2") que
|
||||
Hoppscotch migra a la ultima version al importar. Pura: sin I/O ni red,
|
||||
solo stdlib, determinista.
|
||||
|
||||
Args:
|
||||
calls: lista de call specs (salida de har_extract_calls). Cada elemento
|
||||
es un dict con claves opcionales: method, url, headers, cookies,
|
||||
body, body_type. Otras claves (status, sets_cookies, ...) se ignoran.
|
||||
name: nombre de la coleccion Hoppscotch.
|
||||
request_names: nombres explicitos por request, alineados por indice. Si
|
||||
se pasa y existe el indice, sobreescribe el nombre derivado. None =
|
||||
derivar todos los nombres como "<METHOD> <path>".
|
||||
|
||||
Returns:
|
||||
dict con la coleccion Hoppscotch: {"v": 1, "name", "folders": [],
|
||||
"requests": [...]}. JSON-serializable.
|
||||
"""
|
||||
requests: list[dict] = []
|
||||
|
||||
for index, call in enumerate(calls):
|
||||
if request_names is not None and index < len(request_names):
|
||||
req_name = request_names[index]
|
||||
else:
|
||||
req_name = _request_name(call, index)
|
||||
|
||||
endpoint = str(call.get("url") or "")
|
||||
method = str(call.get("method") or "GET").upper()
|
||||
|
||||
requests.append(
|
||||
{
|
||||
"v": "2",
|
||||
"endpoint": endpoint,
|
||||
"name": req_name,
|
||||
"params": [],
|
||||
"headers": _build_headers(call),
|
||||
"method": method,
|
||||
"auth": {"authType": "none", "authActive": True},
|
||||
"preRequestScript": "",
|
||||
"testScript": "",
|
||||
"body": _build_body(call),
|
||||
"requestVariables": [],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"v": 1,
|
||||
"name": name,
|
||||
"folders": [],
|
||||
"requests": requests,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests para build_hoppscotch_collection."""
|
||||
|
||||
import json
|
||||
|
||||
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
|
||||
def test_golden_get_simple():
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/api/search?q=foo",
|
||||
"headers": {"Accept": "application/json"},
|
||||
}
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(calls, name="MyCol")
|
||||
|
||||
assert result["v"] == 1
|
||||
assert result["name"] == "MyCol"
|
||||
assert result["folders"] == []
|
||||
assert len(result["requests"]) == 1
|
||||
|
||||
req = result["requests"][0]
|
||||
assert req["v"] == "2"
|
||||
assert req["endpoint"] == "https://api.example.com/api/search?q=foo"
|
||||
assert req["name"] == "GET /api/search"
|
||||
assert req["method"] == "GET"
|
||||
assert req["params"] == []
|
||||
assert req["headers"] == [
|
||||
{"key": "Accept", "value": "application/json", "active": True}
|
||||
]
|
||||
assert req["auth"] == {"authType": "none", "authActive": True}
|
||||
assert req["preRequestScript"] == ""
|
||||
assert req["testScript"] == ""
|
||||
assert req["requestVariables"] == []
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
# JSON-serializable
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_edge_post_json_con_headers_y_cookies():
|
||||
calls = [
|
||||
{
|
||||
"method": "post",
|
||||
"url": "https://api.example.com/login",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Csrf": "tok",
|
||||
},
|
||||
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||
"body": '{"user":"neo"}',
|
||||
"body_type": "json",
|
||||
}
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(calls)
|
||||
|
||||
req = result["requests"][0]
|
||||
assert req["method"] == "POST"
|
||||
assert req["name"] == "POST /login"
|
||||
|
||||
# Header Cookie generado al final con formato "; " join
|
||||
assert req["headers"] == [
|
||||
{"key": "Content-Type", "value": "application/json", "active": True},
|
||||
{"key": "X-Csrf", "value": "tok", "active": True},
|
||||
{"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||
]
|
||||
|
||||
assert req["body"] == {
|
||||
"contentType": "application/json",
|
||||
"body": '{"user":"neo"}',
|
||||
}
|
||||
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_request_names_sobreescribe_nombre_derivado():
|
||||
calls = [
|
||||
{"method": "GET", "url": "https://api.example.com/a"},
|
||||
{"method": "GET", "url": "https://api.example.com/b"},
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(
|
||||
calls, request_names=["Custom A"]
|
||||
)
|
||||
|
||||
# Indice 0 usa el nombre explicito; indice 1 cae al derivado.
|
||||
assert result["requests"][0]["name"] == "Custom A"
|
||||
assert result["requests"][1]["name"] == "GET /b"
|
||||
|
||||
|
||||
def test_form_body_genera_contenttype_urlencoded():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/form",
|
||||
"body": "a=1&b=2",
|
||||
"body_type": "form",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {
|
||||
"contentType": "application/x-www-form-urlencoded",
|
||||
"body": "a=1&b=2",
|
||||
}
|
||||
|
||||
|
||||
def test_raw_body_genera_contenttype_text_plain():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/raw",
|
||||
"body": "hello",
|
||||
"body_type": "raw",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {"contentType": "text/plain", "body": "hello"}
|
||||
|
||||
|
||||
def test_body_type_desconocido_da_body_null():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/x",
|
||||
"body": "ignored",
|
||||
"body_type": "binary",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
|
||||
def test_lista_vacia():
|
||||
result = build_hoppscotch_collection([], name="Empty")
|
||||
assert result == {
|
||||
"v": 1,
|
||||
"name": "Empty",
|
||||
"folders": [],
|
||||
"requests": [],
|
||||
}
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_call_spec_sin_url_ni_method():
|
||||
calls = [{}]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["endpoint"] == ""
|
||||
assert req["method"] == "GET"
|
||||
assert req["name"] == "GET /"
|
||||
assert req["headers"] == []
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
|
||||
def test_sin_cookies_no_anade_header_cookie():
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/x",
|
||||
"headers": {"Accept": "*/*"},
|
||||
"cookies": {},
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert all(h["key"] != "Cookie" for h in req["headers"])
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: generate_initials_avatar
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def generate_initials_avatar(text: str, out_path: str, bg_hex: str = \"\", size: int = 256, fg_hex: str = \"#FFFFFF\") -> str"
|
||||
description: "Genera un avatar circular de iniciales (foto de perfil) como PNG: circulo de color con 1-2 iniciales blancas centradas. Color de fondo derivado de forma determinista del texto si no se especifica."
|
||||
tags: [avatar, icon, rofi, pillow, profile, initials]
|
||||
params:
|
||||
- name: text
|
||||
desc: "Nombre del que derivar las iniciales (ej. 'John Doe', 'osint_01'). Se trocea por espacios, guiones y guiones bajos."
|
||||
- name: out_path
|
||||
desc: "Ruta de salida del PNG. Se crea el directorio padre si no existe. Rutas relativas se resuelven contra el cwd."
|
||||
- name: bg_hex
|
||||
desc: "Color de fondo del circulo en formato '#RRGGBB'. Si va vacio ('') se deriva de forma determinista de text via md5 sobre una paleta de 12 colores."
|
||||
- name: size
|
||||
desc: "Lado del PNG cuadrado en pixels. Default 256. El circulo deja ~4% de margen; fuera queda transparente."
|
||||
- name: fg_hex
|
||||
desc: "Color del texto de las iniciales en '#RRGGBB'. Default blanco '#FFFFFF'."
|
||||
output: "La misma out_path recibida. Efecto: escribe un PNG RGBA cuadrado con el avatar circular en disco."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [hashlib, os, pathlib, PIL]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/generate_initials_avatar.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.generate_initials_avatar import generate_initials_avatar
|
||||
|
||||
# Color de fondo determinista derivado del nombre.
|
||||
generate_initials_avatar("Aurgi", "/tmp/aurgi.png") # -> circulo con "A"
|
||||
generate_initials_avatar("John Doe", "/tmp/john.png") # -> circulo con "JD"
|
||||
|
||||
# Color de fondo explicito + tamano custom.
|
||||
generate_initials_avatar("Personal", "/tmp/personal.png", bg_hex="#7c3aed", size=128)
|
||||
```
|
||||
|
||||
Desde el dispatcher (genera con defaults, fondo derivado del texto):
|
||||
|
||||
```bash
|
||||
./fn run generate_initials_avatar_py_infra "Aurgi" /tmp/aurgi.png
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un icono reconocible de un perfil (navegador, usuario, cuenta)
|
||||
y no tengas una foto real: genera un avatar de iniciales determinista por nombre.
|
||||
Util para entradas de rofi, launchers, listas de perfiles o cualquier UI que
|
||||
muestre un identificador visual estable. Mismo `text` -> mismo color siempre.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe un PNG a disco. Crea el directorio padre si falta y lanza
|
||||
`OSError` con mensaje claro si la escritura falla.
|
||||
- **Fondo transparente**: solo el circulo (con ~4% de margen) lleva color; las
|
||||
esquinas del PNG quedan con alpha 0. Si lo pegas sobre un fondo claro, el
|
||||
circulo se ve recortado correctamente, pero un visor que ignore el alpha
|
||||
mostrara las esquinas negras.
|
||||
- **Dependencia Pillow**: requiere `PIL` (Pillow) instalado en el venv del
|
||||
registry (`python/.venv`). No usa cairosvg.
|
||||
- **Fuente DejaVu hardcodeada**: usa `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`.
|
||||
Si no existe (otro SO/distro), cae a `ImageFont.load_default()`, que es mas
|
||||
pequena y pixelada — las iniciales se veran peor pero no falla.
|
||||
- **Antialiasing 4x**: renderiza a `size*4` y reduce con LANCZOS. Para `size`
|
||||
muy grande (>1024) el coste de memoria/tiempo crece cuadraticamente.
|
||||
|
||||
## Notas
|
||||
|
||||
Reglas de iniciales: trocea por espacios, `-` y `_`; toma la primera letra
|
||||
alfabetica de los dos primeros tokens que empiecen por letra (max 2, en
|
||||
mayusculas). Si solo un token tiene letra inicial -> 1 inicial. Si ninguno
|
||||
empieza por letra -> primer caracter alfanumerico del texto. Ejemplos:
|
||||
"Aurgi" -> "A", "Work" -> "W", "osint_01" -> "O", "John Doe" -> "JD",
|
||||
"Personal" -> "P".
|
||||
|
||||
Paleta determinista (12 colores tipo Tailwind 500): sky, emerald, violet,
|
||||
amber, rose, indigo, teal, orange, fuchsia, lime, cyan, red. El indice se
|
||||
elige con `int(md5(text), 16) % 12`, estable entre procesos.
|
||||
|
||||
Las funciones auxiliares `derive_initials(text)` y `derive_bg_color(text)` son
|
||||
publicas y reutilizables por separado si solo necesitas la logica de iniciales
|
||||
o de color sin generar el PNG.
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Genera un avatar circular de iniciales tipo foto de perfil.
|
||||
|
||||
Dibuja un circulo relleno de color con 1-2 iniciales blancas centradas sobre
|
||||
fondo transparente y lo exporta como PNG cuadrado. El color de fondo se puede
|
||||
fijar explicitamente o derivar de forma DETERMINISTA del texto (mismo texto ->
|
||||
mismo color siempre), lo que produce avatares reconocibles y distintos por
|
||||
nombre sin necesidad de una imagen real.
|
||||
|
||||
Funciones publicas reutilizables:
|
||||
derive_initials — extrae 1-2 iniciales en mayusculas de un nombre
|
||||
derive_bg_color — mapea un texto a un color de paleta de forma estable
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Fuente bold preinstalada en este Linux. Si no existe, se cae al default de PIL.
|
||||
DEFAULT_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
# Paleta agradable tipo Tailwind 500-600 (12 colores). El indice se elige de
|
||||
# forma determinista a partir del hash del texto -> mismo texto, mismo color.
|
||||
PALETTE = [
|
||||
"#0ea5e9", # sky-500
|
||||
"#10b981", # emerald-500
|
||||
"#8b5cf6", # violet-500
|
||||
"#f59e0b", # amber-500
|
||||
"#f43f5e", # rose-500
|
||||
"#6366f1", # indigo-500
|
||||
"#14b8a6", # teal-500
|
||||
"#f97316", # orange-500
|
||||
"#d946ef", # fuchsia-500
|
||||
"#84cc16", # lime-500
|
||||
"#06b6d4", # cyan-500
|
||||
"#ef4444", # red-500
|
||||
]
|
||||
|
||||
# Factor de supersampling para antialiasing: se renderiza a NxN veces el tamano
|
||||
# final y se reduce con LANCZOS para obtener bordes suaves.
|
||||
_SUPERSAMPLE = 4
|
||||
|
||||
# Margen del circulo respecto al canvas (~4% por lado).
|
||||
_MARGIN_RATIO = 0.04
|
||||
|
||||
# Tamano de fuente como fraccion del lado del canvas.
|
||||
_FONT_RATIO = 0.46
|
||||
|
||||
|
||||
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||
"""Convierte un color "#RRGGBB" (o "RRGGBB") a una tupla RGB."""
|
||||
h = h.lstrip("#")
|
||||
if len(h) != 6:
|
||||
raise ValueError(f"color hex invalido, se espera #RRGGBB: {h!r}")
|
||||
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def derive_initials(text: str) -> str:
|
||||
"""Extrae 1-2 iniciales en mayusculas a partir de un nombre.
|
||||
|
||||
Trocea el texto por espacios, guiones y guiones bajos. Toma la primera
|
||||
letra alfabetica de los dos primeros tokens que empiecen por letra. Si solo
|
||||
un token tiene letra inicial, devuelve 1 inicial. Si ninguno empieza por
|
||||
letra, usa el primer caracter alfanumerico del texto completo.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "John Doe", "osint_01").
|
||||
|
||||
Returns:
|
||||
1 o 2 caracteres en mayusculas. Cadena vacia si no hay nada alfanumerico.
|
||||
"""
|
||||
# Normaliza separadores a espacios.
|
||||
normalized = text.replace("-", " ").replace("_", " ")
|
||||
tokens = [t for t in normalized.split() if t]
|
||||
|
||||
initials = []
|
||||
for token in tokens:
|
||||
# Primera letra alfabetica del token (el token debe empezar por letra).
|
||||
if token[0].isalpha():
|
||||
initials.append(token[0].upper())
|
||||
if len(initials) == 2:
|
||||
break
|
||||
|
||||
if initials:
|
||||
return "".join(initials)
|
||||
|
||||
# Fallback: primer caracter alfanumerico del texto entero.
|
||||
for ch in text:
|
||||
if ch.isalnum():
|
||||
return ch.upper()
|
||||
return ""
|
||||
|
||||
|
||||
def derive_bg_color(text: str) -> str:
|
||||
"""Mapea un texto a un color de la paleta de forma estable y determinista.
|
||||
|
||||
Usa md5 del texto para indexar la paleta, de modo que el mismo texto
|
||||
produce siempre el mismo color entre ejecuciones y procesos.
|
||||
|
||||
Args:
|
||||
text: Texto del que derivar el color.
|
||||
|
||||
Returns:
|
||||
Color en formato "#RRGGBB" de la paleta interna.
|
||||
"""
|
||||
digest = hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
idx = int(digest, 16) % len(PALETTE)
|
||||
return PALETTE[idx]
|
||||
|
||||
|
||||
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""Carga la fuente DejaVu Bold al tamano dado, con fallback al default."""
|
||||
try:
|
||||
return ImageFont.truetype(DEFAULT_FONT_PATH, font_size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def generate_initials_avatar(
|
||||
text: str,
|
||||
out_path: str,
|
||||
bg_hex: str = "",
|
||||
size: int = 256,
|
||||
fg_hex: str = "#FFFFFF",
|
||||
) -> str:
|
||||
"""Genera un avatar circular de iniciales y lo guarda como PNG.
|
||||
|
||||
Dibuja un circulo relleno con `bg_hex` (o un color derivado de `text` si va
|
||||
vacio) y centra 1-2 iniciales en `fg_hex` sobre el. El fondo fuera del
|
||||
circulo queda transparente. Renderiza a 4x y reduce con LANCZOS para bordes
|
||||
suaves.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "Aurgi", "John Doe").
|
||||
out_path: Ruta de salida del PNG. El directorio padre se crea si falta.
|
||||
bg_hex: Color de fondo del circulo en "#RRGGBB". Si va vacio (""), se
|
||||
deriva de forma determinista de `text`.
|
||||
size: Lado del PNG cuadrado en pixels (default 256).
|
||||
fg_hex: Color del texto en "#RRGGBB" (default blanco "#FFFFFF").
|
||||
|
||||
Returns:
|
||||
La misma `out_path` recibida.
|
||||
|
||||
Raises:
|
||||
ValueError: Si algun color no tiene formato "#RRGGBB" o `size` <= 0.
|
||||
OSError: Si falla la escritura del archivo a disco.
|
||||
"""
|
||||
if size <= 0:
|
||||
raise ValueError(f"size debe ser positivo, recibido: {size!r}")
|
||||
|
||||
background = bg_hex if bg_hex else derive_bg_color(text)
|
||||
bg_rgb = _hex_to_rgb(background)
|
||||
fg_rgb = _hex_to_rgb(fg_hex)
|
||||
|
||||
initials = derive_initials(text)
|
||||
|
||||
# Render a 4x para antialiasing, luego se reduce con LANCZOS.
|
||||
big = size * _SUPERSAMPLE
|
||||
canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
margin = int(big * _MARGIN_RATIO)
|
||||
circle_box = [margin, margin, big - margin - 1, big - margin - 1]
|
||||
draw.ellipse(circle_box, fill=bg_rgb + (255,))
|
||||
|
||||
if initials:
|
||||
font_size = max(1, int(big * _FONT_RATIO))
|
||||
font = _load_font(font_size)
|
||||
|
||||
# Bounding box real del glyph (no solo ascent) para centrado optico.
|
||||
bbox = draw.textbbox((0, 0), initials, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
# El origen del texto se desplaza por el offset del bbox para que el
|
||||
# glyph quede centrado tanto horizontal como verticalmente.
|
||||
text_x = (big - text_w) / 2 - bbox[0]
|
||||
text_y = (big - text_h) / 2 - bbox[1]
|
||||
draw.text((text_x, text_y), initials, font=font, fill=fg_rgb + (255,))
|
||||
|
||||
final = canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
out = Path(out_path)
|
||||
if not out.is_absolute():
|
||||
out = Path.cwd() / out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
final.save(out, format="PNG")
|
||||
except OSError as exc:
|
||||
raise OSError(f"no se pudo escribir el avatar en {out}: {exc}") from exc
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: hoppscotch_create_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_create_request(collection_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, team_id: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Crea una request REST dentro de una team collection de Hoppscotch self-hosted via la mutation GraphQL createRequestInCollection. Construye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo envia como json string en el campo request del input. La mutation esta protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Algunas versiones del backend exigen team_id dentro del input."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: collection_id
|
||||
desc: "ID de la team collection donde insertar la request."
|
||||
- name: method
|
||||
desc: "metodo HTTP de la request (GET, POST, PUT, ...). Se normaliza a mayusculas por build_hoppscotch_collection."
|
||||
- name: url
|
||||
desc: "endpoint completo de la request (con query string si aplica)."
|
||||
- name: title
|
||||
desc: "nombre visible de la request en la GUI de Hoppscotch. None = derivar de method + path (p.ej. 'GET /ping')."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
|
||||
- name: body
|
||||
desc: "cuerpo de la request como texto YA serializado (no se re-serializa). None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None. Determina el contentType del HoppRESTRequest."
|
||||
- name: team_id
|
||||
desc: "ID de la team duena de la collection. Requerido por las versiones del backend cuyo CreateTeamRequestInput exige teamID (el self-host de referencia lo exige). Si el backend no lo pide, None."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito: {status: 'ok', id: str, title: str} con el ID de la request creada. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_crea_request_y_devuelve_id"
|
||||
- "test_body_json_se_serializa_en_el_request"
|
||||
- "test_team_id_se_incluye_en_data"
|
||||
- "test_team_id_omitido_no_aparece_en_data"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_create_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_create_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Crear una request POST con body JSON en una team collection.
|
||||
result = hoppscotch_create_request(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
method="POST",
|
||||
url="https://api.example.com/login",
|
||||
title="Login",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body='{"user":"neo","pass":"<<secret>>"}',
|
||||
body_type="json",
|
||||
team_id="cmq8kn0v500030xls1nvminjy", # requerido por este backend
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "id": "...", "title": "Login"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el agente quiera preparar una request REST en una team Hoppscotch
|
||||
self-hosted via API para que el humano la vea aparecer en vivo en la GUI (las
|
||||
subscriptions de Hoppscotch propagan la creacion en tiempo real). Util en el
|
||||
patron grabar->destilar->reproducir: tras destilar un flujo a call specs, se
|
||||
materializan como requests dentro de una collection que el humano inspecciona.
|
||||
Primero obten el `access_token` con `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** No se pasa el
|
||||
dict directo: el campo `request` del input es un string. Esta funcion serializa
|
||||
con `json.dumps` el item que produce `build_hoppscotch_collection`.
|
||||
- **`team_id` puede ser obligatorio.** El self-host de referencia exige `teamID`
|
||||
dentro de `CreateTeamRequestInput`. Si lo omites contra ese backend, GraphQL
|
||||
responde "Field teamID of required type ID! was not provided". Pasa `team_id`.
|
||||
- **Secreto — nunca logear el token en crudo.** No imprimas `access_token` en
|
||||
claro; trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. CRUD validado contra el self-host vivo el 10/06/2026.
|
||||
Se anadio `team_id` opcional porque el backend de referencia exige `teamID` en el
|
||||
input.
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Crea una request REST dentro de una team collection de Hoppscotch.
|
||||
|
||||
Construye el HoppRESTRequest canonico (reusando build_hoppscotch_collection del
|
||||
registry) y lo inserta en una team collection via la mutation GraphQL
|
||||
createRequestInCollection del backend self-hosted. La mutation esta protegida
|
||||
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
_MUTATION = (
|
||||
"mutation($c:ID!,$d:CreateTeamRequestInput!){"
|
||||
" createRequestInCollection(collectionID:$c, data:$d){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_create_request(
|
||||
collection_id: str,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
team_id: str | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Crea una request en una team collection de Hoppscotch.
|
||||
|
||||
Args:
|
||||
collection_id: ID de la team collection donde insertar la request.
|
||||
method: metodo HTTP de la request (GET, POST, ...).
|
||||
url: endpoint de la request.
|
||||
title: nombre visible de la request en la GUI. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras de la request.
|
||||
body: cuerpo de la request como texto ya serializado.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
team_id: ID de la team duena de la collection. Requerido por las
|
||||
versiones del backend cuyo CreateTeamRequestInput exige `teamID`
|
||||
(el self-host de referencia lo exige). Si el backend no lo pide,
|
||||
dejar None.
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization.
|
||||
backend_url: base del backend Hoppscotch (sin barra final).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
|
||||
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
|
||||
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
|
||||
"""
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
|
||||
data: dict = {
|
||||
"title": req_item["name"],
|
||||
"request": json.dumps(req_item),
|
||||
}
|
||||
if team_id is not None:
|
||||
data["teamID"] = team_id
|
||||
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {
|
||||
"c": collection_id,
|
||||
"d": data,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
created = (data.get("data") or {}).get("createRequestInCollection")
|
||||
if not created or not created.get("id"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "createRequestInCollection returned no id",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": created["id"],
|
||||
"title": created.get("title"),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests para hoppscotch_create_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
|
||||
el POST GraphQL lleva la mutation createRequestInCollection, el access_token en
|
||||
la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_create_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_create_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_crea_request_y_devuelve_id(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"createRequestInCollection": {
|
||||
"id": "req-99",
|
||||
"title": "Ping",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_create_request(
|
||||
"col-1",
|
||||
"GET",
|
||||
"https://api.example.com/ping",
|
||||
title="Ping",
|
||||
headers={"Accept": "application/json"},
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "req-99"
|
||||
assert result["title"] == "Ping"
|
||||
|
||||
# El POST fue al endpoint GraphQL con la cookie access_token.
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["kwargs"]["json"]
|
||||
assert "createRequestInCollection" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["c"] == "col-1"
|
||||
assert variables["d"]["title"] == "Ping"
|
||||
|
||||
# `request` es un json string de un HoppRESTRequest v:"2".
|
||||
req = json.loads(variables["d"]["request"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "GET"
|
||||
assert req["endpoint"] == "https://api.example.com/ping"
|
||||
assert req["name"] == "Ping"
|
||||
|
||||
|
||||
def test_body_json_se_serializa_en_el_request(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-2",
|
||||
"POST",
|
||||
"https://api.example.com/login",
|
||||
body='{"user":"neo"}',
|
||||
body_type="json",
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
req = json.loads(captured["kwargs"]["json"]["variables"]["d"]["request"])
|
||||
assert req["method"] == "POST"
|
||||
assert req["body"] == {
|
||||
"contentType": "application/json",
|
||||
"body": '{"user":"neo"}',
|
||||
}
|
||||
|
||||
|
||||
def test_team_id_se_incluye_en_data(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-3",
|
||||
"GET",
|
||||
"https://api.example.com/x",
|
||||
team_id="team-abc",
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
data = captured["kwargs"]["json"]["variables"]["d"]
|
||||
assert data["teamID"] == "team-abc"
|
||||
|
||||
|
||||
def test_team_id_omitido_no_aparece_en_data(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-4", "GET", "https://api.example.com/x", access_token="A"
|
||||
)
|
||||
|
||||
data = captured["kwargs"]["json"]["variables"]["d"]
|
||||
assert "teamID" not in data
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_req/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_create_request(
|
||||
"bad", "GET", "https://x", access_token="A"
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: hoppscotch_delete_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_delete_request(request_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Borra una request REST de una team collection de Hoppscotch self-hosted via la mutation GraphQL deleteRequest. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Confirma que la mutation devolvio true antes de reportar exito."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [requests]
|
||||
params:
|
||||
- name: request_id
|
||||
desc: "ID de la request a borrar."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito (data.deleteRequest == true): {status: 'ok', deleted: str}. En error (GraphQL errors, deleteRequest != true, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_delete_true"
|
||||
- "test_error_delete_false"
|
||||
test_file_path: "python/functions/infra/hoppscotch_delete_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_delete_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_delete_request import hoppscotch_delete_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
result = hoppscotch_delete_request(
|
||||
request_id="cmq8lue8l000x0xlsd62bncpi",
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "deleted": "cmq8lue8l000x0xlsd62bncpi"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras eliminar una request que el agente creo (o que ya no hace falta) de
|
||||
una team collection Hoppscotch self-hosted, y que el humano vea la baja en vivo en
|
||||
la GUI por subscriptions. Util para limpiar requests temporales tras un flujo de
|
||||
prueba. Necesitas el `request_id` (de `hoppscotch_list_requests`) y un
|
||||
`access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **Operacion destructiva.** Borra la request de verdad; no es reversible. Confirma
|
||||
el `request_id` (p.ej. con `hoppscotch_list_requests`) antes de borrar.
|
||||
- **Solo `data.deleteRequest == true` es exito.** Cualquier otro valor (false, null)
|
||||
o un bloque `errors` se reporta como `status: 'error'`.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(delete confirmo que la request desaparece de la lista posterior).
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Borra una request REST de una team collection de Hoppscotch.
|
||||
|
||||
Invoca la mutation GraphQL deleteRequest del backend self-hosted. Protegida por
|
||||
GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
_MUTATION = "mutation($r:ID!){ deleteRequest(requestID:$r) }"
|
||||
|
||||
|
||||
def hoppscotch_delete_request(
|
||||
request_id: str,
|
||||
*,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Borra una request de Hoppscotch por su ID.
|
||||
|
||||
Args:
|
||||
request_id: ID de la request a borrar.
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization.
|
||||
backend_url: base del backend Hoppscotch (sin barra final).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "deleted": str}``. En error (GraphQL
|
||||
errors, deleteRequest != true, HTTP no 200, transporte):
|
||||
``{"status": "error", "error": str, "data": ...}``.
|
||||
"""
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {"r": request_id},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
deleted = (data.get("data") or {}).get("deleteRequest")
|
||||
if deleted is not True:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "deleteRequest did not return true",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {"status": "ok", "deleted": request_id}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests para hoppscotch_delete_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post. Verifican el camino ok
|
||||
(deleteRequest=true) y el de error (deleteRequest=false).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_delete_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_delete_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_delete_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, {"data": {"deleteRequest": True}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_delete_request("req-1", access_token="ACCESS-JWT")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["deleted"] == "req-1"
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
assert "deleteRequest" in captured["kwargs"]["json"]["query"]
|
||||
assert captured["kwargs"]["json"]["variables"] == {"r": "req-1"}
|
||||
|
||||
|
||||
def test_error_delete_false(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(200, {"data": {"deleteRequest": False}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_delete_request("req-2", access_token="A")
|
||||
assert result["status"] == "error"
|
||||
assert "did not return true" in result["error"]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Test e2e real del grupo hoppscotch contra un self-host VIVO.
|
||||
|
||||
NO corre en la suite normal (skip). Para ejecutarlo a mano contra la instancia
|
||||
viva, quita temporalmente el skip y asegura:
|
||||
- backend Hoppscotch en http://localhost:3170
|
||||
- mailpit en http://localhost:8025
|
||||
- una team collection real cuyo ID pongas en COLLECTION_ID abajo.
|
||||
|
||||
El flujo: login(admin@example.com) -> create_request -> list -> delete.
|
||||
|
||||
Nota: el backend de referencia exige `teamID` dentro de CreateTeamRequestInput,
|
||||
asi que la create pasa `team_id=TEAM_ID`. Rellena COLLECTION_ID y TEAM_ID con
|
||||
una team collection real (consultables via myTeams / rootCollectionsOfTeam).
|
||||
Este flujo se valido con exito contra la instancia viva el 10/06/2026.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
from infra.hoppscotch_list_requests import hoppscotch_list_requests
|
||||
from infra.hoppscotch_delete_request import hoppscotch_delete_request
|
||||
from infra.hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
# Rellenar con IDs reales del self-host antes de correr.
|
||||
TEAM_ID = "REPLACE_WITH_REAL_TEAM_ID"
|
||||
COLLECTION_ID = "REPLACE_WITH_REAL_COLLECTION_ID"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real contra self-host vivo, correr a mano")
|
||||
def test_e2e_crud_request_real():
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login
|
||||
|
||||
token = login["access_token"]
|
||||
|
||||
created = hoppscotch_create_request(
|
||||
COLLECTION_ID,
|
||||
"GET",
|
||||
"https://api.example.com/e2e-ping",
|
||||
title="e2e ping",
|
||||
team_id=TEAM_ID,
|
||||
access_token=token,
|
||||
)
|
||||
assert created["status"] == "ok", created
|
||||
req_id = created["id"]
|
||||
|
||||
listed = hoppscotch_list_requests(COLLECTION_ID, access_token=token)
|
||||
assert listed["status"] == "ok", listed
|
||||
assert any(r["id"] == req_id for r in listed["requests"])
|
||||
|
||||
deleted = hoppscotch_delete_request(req_id, access_token=token)
|
||||
assert deleted["status"] == "ok", deleted
|
||||
assert deleted["deleted"] == req_id
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real self-host vivo")
|
||||
def test_e2e_run_request_aparece_en_user_history():
|
||||
"""Ejecuta una request real y verifica que entra en el UserHistory.
|
||||
|
||||
login -> run_request GET <<baseURL>>/api/status -> status_code 200 +
|
||||
recorded True. La entry debe verse en la pestana History de la GUI
|
||||
(subscription userHistoryCreated). Validado contra la instancia viva.
|
||||
"""
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login
|
||||
token = login["access_token"]
|
||||
|
||||
result = hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status (e2e)",
|
||||
variables={"baseURL": "https://registry.organic-machine.com"},
|
||||
access_token=token,
|
||||
)
|
||||
assert result["status"] == "ok", result
|
||||
assert result["status_code"] == 200, result
|
||||
assert result["recorded"] is True, result
|
||||
assert result["history_id"], result
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: hoppscotch_list_requests
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_list_requests(collection_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\", take: int = 50) -> dict"
|
||||
description: "Lista las requests de una team collection de Hoppscotch self-hosted via la query GraphQL requestsInCollection. Devuelve cada request como {id, title}. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [requests]
|
||||
params:
|
||||
- name: collection_id
|
||||
desc: "ID de la team collection cuyas requests se quieren listar."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
- name: take
|
||||
desc: "numero maximo de requests a devolver (argumento take de la query). Default 50."
|
||||
output: "dict. En exito: {status: 'ok', requests: [{id: str, title: str}, ...]}. En error (GraphQL errors, requestsInCollection null, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_lista_dos_requests"
|
||||
- "test_take_se_pasa_a_la_query"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_list_requests_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_list_requests.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_list_requests import hoppscotch_list_requests
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
result = hoppscotch_list_requests(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
access_token=token,
|
||||
)
|
||||
print(result)
|
||||
# {"status": "ok", "requests": [{"id": "...", "title": "Login"}, ...]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las requests de una team collection Hoppscotch
|
||||
self-hosted: para verificar que un `hoppscotch_create_request` aparecio, para
|
||||
obtener el `request_id` que pasar a `hoppscotch_update_request` /
|
||||
`hoppscotch_delete_request`, o para auditar el contenido de una collection antes
|
||||
de modificarla. Necesitas un `access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La query esta
|
||||
protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **Devuelve solo {id, title}, no el HoppRESTRequest completo.** La query pide
|
||||
unicamente id y title; no incluye method/url/headers/body. Para el cuerpo
|
||||
completo de una request, consulta su detalle aparte.
|
||||
- **`take` limita el resultado.** Solo se devuelven hasta `take` requests (default
|
||||
50). Sube `take` si la collection tiene mas.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(list reflejo la creacion y la baja de una request real).
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Lista las requests de una team collection de Hoppscotch.
|
||||
|
||||
Invoca la query GraphQL requestsInCollection del backend self-hosted. Protegida
|
||||
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
_QUERY = (
|
||||
"query($c:ID!,$t:Int){"
|
||||
" requestsInCollection(collectionID:$c, take:$t){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_list_requests(
|
||||
collection_id: str,
|
||||
*,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
take: int = 50,
|
||||
) -> dict:
|
||||
"""Lista las requests de una team collection de Hoppscotch.
|
||||
|
||||
Args:
|
||||
collection_id: ID de la team collection a listar.
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization.
|
||||
backend_url: base del backend Hoppscotch (sin barra final).
|
||||
take: numero maximo de requests a devolver (default 50).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "requests": [{"id": str,
|
||||
"title": str}, ...]}``. En error (GraphQL errors, HTTP no 200,
|
||||
transporte): ``{"status": "error", "error": str, "data": ...}``.
|
||||
"""
|
||||
payload = {
|
||||
"query": _QUERY,
|
||||
"variables": {"c": collection_id, "t": take},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
rows = (data.get("data") or {}).get("requestsInCollection")
|
||||
if rows is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "requestsInCollection returned null",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"requests": [
|
||||
{"id": r.get("id"), "title": r.get("title")} for r in rows
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests para hoppscotch_list_requests.
|
||||
|
||||
Deterministas: monkeypatchean requests.post. Verifican el camino ok (devuelve la
|
||||
lista normalizada) y el de error (GraphQL errors).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_list_requests # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_list_requests"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_lista_dos_requests(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"requestsInCollection": [
|
||||
{"id": "r1", "title": "Ping"},
|
||||
{"id": "r2", "title": "Login"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("col-1", access_token="ACCESS-JWT")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["requests"] == [
|
||||
{"id": "r1", "title": "Ping"},
|
||||
{"id": "r2", "title": "Login"},
|
||||
]
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
assert "requestsInCollection" in captured["kwargs"]["json"]["query"]
|
||||
assert captured["kwargs"]["json"]["variables"] == {"c": "col-1", "t": 50}
|
||||
|
||||
|
||||
def test_take_se_pasa_a_la_query(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, {"data": {"requestsInCollection": []}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("col", access_token="A", take=10)
|
||||
assert result["status"] == "ok"
|
||||
assert result["requests"] == []
|
||||
assert captured["kwargs"]["json"]["variables"]["t"] == 10
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_coll/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("bad", access_token="A")
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: hoppscotch_login
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_login(email: str, *, backend_url: str = \"http://localhost:3170\", mailpit_url: str = \"http://localhost:8025\", timeout_s: float = 15.0) -> dict"
|
||||
description: "Login headless contra un Hoppscotch self-hosted via magic link, leyendo el correo de verificacion desde una instancia Mailpit de pruebas. Reproduce el flujo sin navegador: POST /v1/auth/signin (deviceIdentifier) -> lee el correo 'Sign in' del email en Mailpit -> extrae el token (?token=...) del cuerpo -> POST /v1/auth/verify (Set-Cookie access_token + refresh_token). Devuelve los JWT de sesion que las mutations GraphQL protegidas esperan en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, auth]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [re, requests]
|
||||
params:
|
||||
- name: email
|
||||
desc: "correo del usuario que inicia sesion. Debe poder recibir el correo de verificacion en la instancia Mailpit indicada (en el self-host de pruebas, admin@example.com)."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. Los endpoints REST de auth cuelgan de {backend_url}/v1/auth/signin y /v1/auth/verify. Default http://localhost:3170."
|
||||
- name: mailpit_url
|
||||
desc: "base de la API de Mailpit donde aterriza el correo de verificacion, sin barra final. Default http://localhost:8025."
|
||||
- name: timeout_s
|
||||
desc: "timeout por request HTTP en segundos. Default 15.0."
|
||||
output: "dict. En exito: {status: 'ok', access_token: str, refresh_token: str, email: str}. En error (signin != 201, no llega correo 'Sign in', token no encontrado en el correo, verify != 200, o fallo de transporte): {status: 'error', error: str}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_login_devuelve_tokens"
|
||||
- "test_verify_recibe_token_extraido_y_device_identifier"
|
||||
- "test_error_signin_no_201"
|
||||
- "test_error_correo_no_encontrado"
|
||||
- "test_error_token_no_en_correo"
|
||||
test_file_path: "python/functions/infra/hoppscotch_login_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_login.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
|
||||
# 1) Obtener un JWT de sesion via magic link (headless, lee el correo de Mailpit).
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login["error"]
|
||||
token = login["access_token"]
|
||||
|
||||
# 2) Usar el token para crear una request en una team collection.
|
||||
# El self-host de referencia exige team_id dentro del input.
|
||||
created = hoppscotch_create_request(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
method="GET",
|
||||
url="https://api.example.com/ping",
|
||||
title="Ping",
|
||||
team_id="cmq8kn0v500030xls1nvminjy",
|
||||
access_token=token,
|
||||
)
|
||||
print(created) # {"status": "ok", "id": "...", "title": "Ping"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un JWT de sesion de un Hoppscotch self-hosted para operar su API
|
||||
GraphQL protegida (crear/editar/borrar requests, gestionar collections) sin abrir
|
||||
el navegador. Es el primer paso de cualquier flujo CRUD del grupo `hoppscotch`:
|
||||
llama esto, captura `access_token`, y paselo a `hoppscotch_create_request` /
|
||||
`hoppscotch_update_request` / `hoppscotch_delete_request` / `hoppscotch_list_requests`.
|
||||
Requiere que el backend mande el correo de verificacion a una instancia Mailpit
|
||||
accesible (entorno de pruebas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** Las mutations
|
||||
GraphQL leen el JWT de la cookie `access_token`. Cada funcion del grupo lo manda
|
||||
con `cookies={"access_token": ...}`.
|
||||
- **El token expira (~24h).** Cuando una llamada GraphQL devuelva un error de auth,
|
||||
re-loguea con `hoppscotch_login` para obtener un access_token fresco.
|
||||
- **Depende de Mailpit.** El flujo lee el correo de verificacion de una instancia
|
||||
Mailpit de pruebas. No funciona contra un backend que mande el correo a un buzon
|
||||
real al que esta funcion no pueda consultar por API.
|
||||
- **Secreto — nunca logear el token en crudo.** `access_token`/`refresh_token` son
|
||||
credenciales de sesion. No los imprimas ni los persistas en claro; trataelos como
|
||||
un secreto (vault/pass) si los guardas entre ejecuciones.
|
||||
- **Coincidencia del correo por subject + destinatario.** Se elige el mensaje mas
|
||||
reciente cuyo destinatario sea `email` y cuyo subject contenga "Sign in". Si hay
|
||||
varios magic links pendientes para el mismo email, se usa el ultimo de la lista.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Flujo magic link headless validado contra el self-host
|
||||
vivo (login + CRUD completo) el 10/06/2026.
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Login headless contra un Hoppscotch self-hosted via magic link.
|
||||
|
||||
Reproduce el flujo de magic link de Hoppscotch sin navegador, leyendo el
|
||||
correo de verificacion desde una instancia Mailpit de pruebas:
|
||||
|
||||
1. POST /v1/auth/signin -> deviceIdentifier
|
||||
2. GET mailpit messages -> ultimo correo "Sign in" para ese email
|
||||
3. GET mailpit message/{id} -> extrae el token (?token=...) del cuerpo
|
||||
4. POST /v1/auth/verify -> Set-Cookie access_token + refresh_token
|
||||
|
||||
Devuelve los JWT de sesion (access_token / refresh_token). El access_token es
|
||||
el que las mutations GraphQL protegidas por GqlAuthGuard esperan en la cookie
|
||||
`access_token` (no en el header Authorization).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
# El correo de Hoppscotch incluye un enlace con ?token=<jwt>. El token es un
|
||||
# JWT (3 segmentos base64url separados por puntos), asi que aceptamos letras,
|
||||
# digitos, guion, guion bajo y punto.
|
||||
_TOKEN_RE = re.compile(r"token=([A-Za-z0-9_\-.]+)")
|
||||
|
||||
|
||||
def hoppscotch_login(
|
||||
email: str,
|
||||
*,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
mailpit_url: str = "http://localhost:8025",
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Obtiene un JWT de sesion de Hoppscotch via magic link (headless).
|
||||
|
||||
Args:
|
||||
email: correo del usuario que inicia sesion. Debe poder recibir el
|
||||
correo de verificacion en la instancia Mailpit indicada.
|
||||
backend_url: base del backend Hoppscotch (sin barra final). El endpoint
|
||||
REST de auth cuelga de ``{backend_url}/v1/auth/...``.
|
||||
mailpit_url: base de la API de Mailpit donde aterriza el correo de
|
||||
verificacion (sin barra final).
|
||||
timeout_s: timeout por request HTTP en segundos.
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
``{"status": "ok", "access_token": str, "refresh_token": str,
|
||||
"email": str}``. En error (signin no 201, no llega correo, token no
|
||||
encontrado, verify no 200, o fallo de transporte):
|
||||
``{"status": "error", "error": str}``.
|
||||
"""
|
||||
session = requests.Session()
|
||||
|
||||
try:
|
||||
# 1) Signin: pide el magic link. Respuesta 201 con deviceIdentifier.
|
||||
signin = session.post(
|
||||
f"{backend_url}/v1/auth/signin",
|
||||
json={"email": email},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if signin.status_code != 201:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"signin returned {signin.status_code} "
|
||||
f"(expected 201): {signin.text[:200]}"
|
||||
),
|
||||
}
|
||||
try:
|
||||
device_identifier = signin.json().get("deviceIdentifier")
|
||||
except ValueError:
|
||||
device_identifier = None
|
||||
if not device_identifier:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "signin response missing deviceIdentifier",
|
||||
}
|
||||
|
||||
# 2) Localiza el correo de verificacion mas reciente para este email.
|
||||
messages = session.get(
|
||||
f"{mailpit_url}/api/v1/messages",
|
||||
params={"limit": 5},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if messages.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"mailpit messages returned {messages.status_code} "
|
||||
"(expected 200)"
|
||||
),
|
||||
}
|
||||
try:
|
||||
inbox = messages.json().get("messages") or []
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "mailpit messages response is not valid JSON",
|
||||
}
|
||||
|
||||
message_id = None
|
||||
for msg in inbox:
|
||||
recipients = msg.get("To") or []
|
||||
to_match = any(
|
||||
(addr.get("Address") or "").lower() == email.lower()
|
||||
for addr in recipients
|
||||
)
|
||||
subject = msg.get("Subject") or ""
|
||||
if to_match and "Sign in" in subject:
|
||||
message_id = msg.get("ID")
|
||||
break
|
||||
if not message_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"no 'Sign in' email found for {email} in mailpit",
|
||||
}
|
||||
|
||||
# 3) Descarga el cuerpo del correo y extrae el token.
|
||||
message = session.get(
|
||||
f"{mailpit_url}/api/v1/message/{message_id}",
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if message.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"mailpit message returned {message.status_code} "
|
||||
"(expected 200)"
|
||||
),
|
||||
}
|
||||
try:
|
||||
body = message.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "mailpit message response is not valid JSON",
|
||||
}
|
||||
haystack = f"{body.get('Text') or ''}\n{body.get('HTML') or ''}"
|
||||
token_match = _TOKEN_RE.search(haystack)
|
||||
if not token_match:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "magic-link token not found in verification email",
|
||||
}
|
||||
token = token_match.group(1)
|
||||
|
||||
# 4) Verify: canjea el token + deviceIdentifier por las cookies de
|
||||
# sesion (access_token / refresh_token).
|
||||
verify = session.post(
|
||||
f"{backend_url}/v1/auth/verify",
|
||||
json={"token": token, "deviceIdentifier": device_identifier},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if verify.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"verify returned {verify.status_code} "
|
||||
f"(expected 200): {verify.text[:200]}"
|
||||
),
|
||||
}
|
||||
|
||||
access_token = session.cookies.get("access_token")
|
||||
refresh_token = session.cookies.get("refresh_token")
|
||||
if not access_token:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "verify succeeded but no access_token cookie was set",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"email": email,
|
||||
}
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tests para hoppscotch_login.
|
||||
|
||||
Deterministas: monkeypatchean requests.Session para no tocar la red. Simulan el
|
||||
flujo magic link completo (signin -> mailpit list -> mailpit message -> verify)
|
||||
y verifican que se devuelven los JWT, asi como los caminos de error.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_login # noqa: F401 (registra el submodulo en sys.modules)
|
||||
|
||||
# El __init__ del paquete rebinds el nombre `hoppscotch_login` a la funcion,
|
||||
# que sombrea el submodulo. Recuperamos el submodulo real desde sys.modules
|
||||
# para monkeypatchear su simbolo `requests`.
|
||||
mod = sys.modules["infra.hoppscotch_login"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Respuesta HTTP mockeada minima: status_code, json(), text."""
|
||||
|
||||
def __init__(self, status_code=200, json_data=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
class _FakeCookies:
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
|
||||
def get(self, name):
|
||||
return self._store.get(name)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Session mockeada: despacha por (method, path) a respuestas predefinidas."""
|
||||
|
||||
def __init__(self, routes, cookie_store):
|
||||
self._routes = routes
|
||||
self.cookies = _FakeCookies(cookie_store)
|
||||
self.calls = []
|
||||
|
||||
def _dispatch(self, method, url, **kwargs):
|
||||
self.calls.append((method, url, kwargs))
|
||||
for (m, fragment), resp in self._routes.items():
|
||||
if m == method and fragment in url:
|
||||
return resp
|
||||
raise AssertionError(f"unexpected {method} {url}")
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
return self._dispatch("POST", url, **kwargs)
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
return self._dispatch("GET", url, **kwargs)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _install_session(monkeypatch, routes, cookie_store):
|
||||
session = _FakeSession(routes, cookie_store)
|
||||
monkeypatch.setattr(mod.requests, "Session", lambda: session)
|
||||
return session
|
||||
|
||||
|
||||
def test_golden_login_devuelve_tokens(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "dev-123"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "msg-1",
|
||||
"Subject": "Sign in to Hoppscotch",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/msg-1"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"Text": "Click here",
|
||||
"HTML": (
|
||||
"<a href='http://localhost:3170/?token="
|
||||
"eyJhbGciOi.JhbGci_Q-zz'>Sign in</a>"
|
||||
),
|
||||
},
|
||||
),
|
||||
("POST", "/v1/auth/verify"): _FakeResponse(200, {"ok": True}),
|
||||
}
|
||||
_install_session(
|
||||
monkeypatch,
|
||||
routes,
|
||||
{"access_token": "ACCESS-JWT", "refresh_token": "REFRESH-JWT"},
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["access_token"] == "ACCESS-JWT"
|
||||
assert result["refresh_token"] == "REFRESH-JWT"
|
||||
assert result["email"] == "admin@example.com"
|
||||
|
||||
|
||||
def test_verify_recibe_token_extraido_y_device_identifier(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "dev-xyz"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "m9",
|
||||
"Subject": "Sign in",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/m9"): _FakeResponse(
|
||||
200,
|
||||
{"Text": "verify at ?token=abc.DEF-123_456", "HTML": ""},
|
||||
),
|
||||
("POST", "/v1/auth/verify"): _FakeResponse(200, {}),
|
||||
}
|
||||
session = _install_session(
|
||||
monkeypatch, routes, {"access_token": "A", "refresh_token": "R"}
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
# El POST a verify llevo el token extraido del correo + el deviceIdentifier.
|
||||
verify_call = next(
|
||||
c for c in session.calls if c[0] == "POST" and "verify" in c[1]
|
||||
)
|
||||
sent = verify_call[2]["json"]
|
||||
assert sent["token"] == "abc.DEF-123_456"
|
||||
assert sent["deviceIdentifier"] == "dev-xyz"
|
||||
|
||||
|
||||
def test_error_signin_no_201(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
500, None, text="boom"
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "signin returned 500" in result["error"]
|
||||
|
||||
|
||||
def test_error_correo_no_encontrado(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "d"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "x",
|
||||
"Subject": "Newsletter",
|
||||
"To": [{"Address": "other@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "no 'Sign in' email" in result["error"]
|
||||
|
||||
|
||||
def test_error_token_no_en_correo(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "d"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "m",
|
||||
"Subject": "Sign in",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/m"): _FakeResponse(
|
||||
200, {"Text": "no token here", "HTML": "<p>nada</p>"}
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "token not found" in result["error"]
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: hoppscotch_run_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_run_request(method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, variables: dict | None = None, access_token: str, backend_url: str = \"http://localhost:3170\", record_history: bool = True, timeout_s: float = 30.0, verify_tls: bool = True) -> dict"
|
||||
description: "Ejecuta una peticion HTTP real (resolviendo placeholders <<var>>/{{var}} con un dict de variables) y la registra en el UserHistory de un Hoppscotch self-hosted via la mutation GraphQL createUserHistory, para que el humano la vea aparecer en vivo en la pestana History de su GUI (subscription userHistoryCreated). La request se ejecuta con las variables resueltas, pero en el History se guarda SIN resolver (con los literales <<var>>) igual que en el editor. resMetadata minimo: statusCode + duration. El access_token va como cookie, no como header Authorization."
|
||||
tags: [hoppscotch, flow-replay, http]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, re, requests]
|
||||
params:
|
||||
- name: method
|
||||
desc: "metodo HTTP de la peticion (GET, POST, ...)."
|
||||
- name: url
|
||||
desc: "endpoint de la peticion. Puede contener placeholders <<var>> o {{var}} que se resuelven con `variables` antes de ejecutar."
|
||||
- name: title
|
||||
desc: "nombre visible de la request en el History. None = derivar de method + path via build_hoppscotch_collection."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras. Sus values tambien admiten placeholders <<var>>/{{var}}."
|
||||
- name: body
|
||||
desc: "cuerpo de la peticion como texto ya serializado. Admite placeholders. None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo para el HoppRESTRequest del History: 'json' | 'form' | 'raw' | None."
|
||||
- name: variables
|
||||
desc: "dict name->value para resolver los placeholders al EJECUTAR. Una variable que falte deja el literal intacto. None = no se resuelve nada."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization. Necesario para registrar en el History."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch self-host sin barra final. La mutation cuelga de {backend_url}/graphql. Default http://localhost:3170."
|
||||
- name: record_history
|
||||
desc: "si True y hay access_token, registra la request ejecutada en el UserHistory via createUserHistory. Default True."
|
||||
- name: timeout_s
|
||||
desc: "timeout en segundos de la peticion HTTP ejecutada (y del POST de History). Default 30.0."
|
||||
- name: verify_tls
|
||||
desc: "verificacion del certificado TLS de la peticion ejecutada. Default True."
|
||||
output: "dict. En exito de la ejecucion HTTP: {status: 'ok', status_code: int, duration_ms: int, response_body: str (truncado a 5000 chars), response_headers: dict, recorded: bool, history_id: str|None}. Si la ejecucion fue ok pero el registro de History fallo, status sigue 'ok', recorded False y se anade history_error. Si la ejecucion HTTP falla (RequestException): {status: 'error', error: str, recorded: False}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_ejecuta_resolviendo_variables_angle"
|
||||
- "test_ejecuta_resolviendo_variables_brace"
|
||||
- "test_record_history_registra_request_sin_resolver"
|
||||
- "test_record_history_false_no_llama_create_user_history"
|
||||
- "test_request_exception_status_error"
|
||||
- "test_variable_faltante_conserva_literal"
|
||||
test_file_path: "python/functions/infra/hoppscotch_run_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_run_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
# 1) Obtener un JWT de sesion (headless, lee el correo de Mailpit).
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login["error"]
|
||||
token = login["access_token"]
|
||||
|
||||
# 2) Ejecutar una request con una variable y dejar rastro en el History de la GUI.
|
||||
result = hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status",
|
||||
variables={"baseURL": "https://registry.organic-machine.com"},
|
||||
access_token=token,
|
||||
)
|
||||
print(result["status_code"], result["recorded"], result["history_id"])
|
||||
# 200 True hist-...
|
||||
# -> aparece en vivo en la pestana History del Hoppscotch self-host.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el agente ejecuta una consulta HTTP y quiere que el humano la vea en el
|
||||
History de su GUI Hoppscotch self-hosted, en vivo. La entry aparece via la
|
||||
subscription `userHistoryCreated` sin que el humano refresque. Util para hacer
|
||||
auditable/observable lo que el agente prueba: cada `hoppscotch_run_request` deja
|
||||
en la pestana History la request (con sus variables sin resolver) y su statusCode
|
||||
+ duracion. Encadena con `hoppscotch_login` para obtener el `access_token`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
`createUserHistory` lee el JWT de la cookie `access_token`. Se manda con
|
||||
`cookies={"access_token": ...}`. Si expira (~24h), re-loguea con
|
||||
`hoppscotch_login`.
|
||||
- **reqData lleva la request SIN resolver.** Lo que se guarda en el History es el
|
||||
HoppRESTRequest con los placeholders `<<var>>`/`{{var}}` literales, igual que en
|
||||
el editor de la GUI, para que el humano vea la plantilla con sus variables y no
|
||||
los valores expandidos. La peticion SI se ejecuta con las variables resueltas.
|
||||
- **Soporta `<<>>` y `{{}}`.** Hoppscotch usa `<<var>>`; muchas plantillas traen
|
||||
`{{var}}`. Ambas sintaxis se resuelven al ejecutar. Una variable que falte en
|
||||
`variables` deja el literal intacto (no rompe).
|
||||
- **resMetadata minimo: statusCode + duration.** Se envia
|
||||
`{"statusCode": ..., "duration": ...}`. Si una version del backend exigiera mas
|
||||
campos, el registro fallaria con `history_error` (la ejecucion HTTP sigue siendo
|
||||
ok). Ajustar el shape si el self-host lo pide.
|
||||
- **El body de respuesta se trunca a 5000 chars** en `response_body` del output,
|
||||
para no devolver payloads enormes. Los `response_headers` van completos.
|
||||
- **duration_ms viene de `resp.elapsed`,** no de `time.time()`: es la latencia que
|
||||
midio `requests` para la peticion ejecutada.
|
||||
- **Degradacion suave del History:** si la ejecucion HTTP fue ok pero el POST de la
|
||||
mutation falla (transporte, no-JSON, errores GraphQL, sin id), `status` sigue
|
||||
"ok", `recorded` es False y se anade `history_error` con el detalle.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Ejecucion + registro en UserHistory del self-host;
|
||||
resolucion de placeholders `<<>>`/`{{}}`.
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Ejecuta una peticion HTTP y la registra en el History de Hoppscotch self-host.
|
||||
|
||||
Doble proposito: (1) lanza la request real con `requests` resolviendo placeholders
|
||||
de variables y (2) opcionalmente la persiste en el UserHistory del backend
|
||||
Hoppscotch self-hosted via la mutation GraphQL createUserHistory, de modo que el
|
||||
humano la vea aparecer en vivo en la pestana History de su GUI (la GUI escucha la
|
||||
subscription `userHistoryCreated`).
|
||||
|
||||
La request se ejecuta con las variables resueltas, pero lo que se guarda en el
|
||||
History es la request SIN resolver (con `<<var>>`/`{{var}}` literales), igual que
|
||||
en la GUI: asi el humano ve la plantilla con sus variables, no los valores
|
||||
expandidos. La mutation esta protegida por GqlAuthGuard: el JWT de sesion viaja en
|
||||
la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
# Hoppscotch usa la sintaxis <<var>>; muchas plantillas tambien traen {{var}}.
|
||||
# Aceptamos ambas: grupo 1 = delimitador de apertura, grupo 2 = nombre de la
|
||||
# variable, grupo 3 = delimitador de cierre.
|
||||
_VAR_RE = re.compile(r"(<<|\{\{)\s*([A-Za-z0-9_]+)\s*(>>|\}\})")
|
||||
|
||||
# Limite del cuerpo de respuesta en el output, para no devolver payloads enormes.
|
||||
_BODY_TRUNCATE = 5000
|
||||
|
||||
_HISTORY_MUTATION = (
|
||||
"mutation($d:String!,$m:String!,$t:ReqType!){"
|
||||
" createUserHistory(reqData:$d, resMetadata:$m, reqType:$t){ id } }"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_placeholders(text: str, variables: dict) -> str:
|
||||
"""Sustituye <<var>>/{{var}} por su valor en `variables`.
|
||||
|
||||
Si la variable no esta en `variables`, se conserva el literal tal cual
|
||||
(incluidos los delimitadores). Determinista y sin I/O.
|
||||
|
||||
Args:
|
||||
text: cadena con (opcionales) placeholders.
|
||||
variables: dict name->value con los valores de sustitucion.
|
||||
|
||||
Returns:
|
||||
la cadena con los placeholders conocidos resueltos.
|
||||
"""
|
||||
|
||||
def repl(match: re.Match) -> str:
|
||||
name = match.group(2)
|
||||
if name in variables:
|
||||
return str(variables[name])
|
||||
return match.group(0)
|
||||
|
||||
return _VAR_RE.sub(repl, text)
|
||||
|
||||
|
||||
def hoppscotch_run_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
variables: dict | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
record_history: bool = True,
|
||||
timeout_s: float = 30.0,
|
||||
verify_tls: bool = True,
|
||||
) -> dict:
|
||||
"""Ejecuta una request HTTP y la registra en el History de Hoppscotch.
|
||||
|
||||
Resuelve los placeholders `<<var>>`/`{{var}}` de la url, los headers y el
|
||||
body usando `variables`, lanza la peticion real con `requests`, y (si
|
||||
`record_history`) guarda en el UserHistory del backend self-host la request
|
||||
SIN resolver (para que en la GUI History se vea con las variables, igual que
|
||||
en el editor).
|
||||
|
||||
Args:
|
||||
method: metodo HTTP (GET, POST, ...).
|
||||
url: endpoint, puede contener placeholders `<<var>>`/`{{var}}`.
|
||||
title: nombre visible de la request en el History. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras. Sus values admiten placeholders.
|
||||
body: cuerpo de la request como texto ya serializado. Admite placeholders.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
variables: dict name->value para resolver los placeholders al EJECUTAR.
|
||||
None = no se resuelve nada (los literales viajan tal cual).
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization. Necesario para grabar
|
||||
en el History.
|
||||
backend_url: base del backend Hoppscotch self-host (sin barra final).
|
||||
record_history: si True y hay access_token, registra la request en el
|
||||
UserHistory via createUserHistory.
|
||||
timeout_s: timeout de la peticion HTTP en segundos.
|
||||
verify_tls: verificacion del certificado TLS de la request ejecutada.
|
||||
|
||||
Returns:
|
||||
Dict. En exito de la ejecucion HTTP:
|
||||
``{"status": "ok", "status_code": int, "duration_ms": int,
|
||||
"response_body": str (truncado a 5000 chars), "response_headers": dict,
|
||||
"recorded": bool, "history_id": str|None}``. Si la ejecucion fue ok pero
|
||||
el registro de History fallo, `status` sigue "ok", `recorded` False y se
|
||||
anade `history_error`. Si la ejecucion HTTP falla (RequestException):
|
||||
``{"status": "error", "error": str, "recorded": False}``.
|
||||
"""
|
||||
variables = variables or {}
|
||||
headers = headers or {}
|
||||
|
||||
# 1) Resolver placeholders para EJECUTAR (copia; los originales se conservan
|
||||
# para registrarlos sin resolver en el History).
|
||||
resolved_url = _resolve_placeholders(url, variables)
|
||||
resolved_headers = {
|
||||
key: _resolve_placeholders(str(value), variables)
|
||||
for key, value in headers.items()
|
||||
}
|
||||
resolved_body = (
|
||||
_resolve_placeholders(body, variables) if body is not None else None
|
||||
)
|
||||
|
||||
# 2) Ejecutar la peticion real.
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
resolved_url,
|
||||
headers=resolved_headers,
|
||||
data=resolved_body if resolved_body is not None else None,
|
||||
timeout=timeout_s,
|
||||
verify=verify_tls,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"transport error: {exc}",
|
||||
"recorded": False,
|
||||
}
|
||||
|
||||
duration_ms = int(resp.elapsed.total_seconds() * 1000)
|
||||
status_code = resp.status_code
|
||||
response_body = resp.text[:_BODY_TRUNCATE]
|
||||
response_headers = dict(resp.headers)
|
||||
|
||||
result = {
|
||||
"status": "ok",
|
||||
"status_code": status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"response_body": response_body,
|
||||
"response_headers": response_headers,
|
||||
"recorded": False,
|
||||
"history_id": None,
|
||||
}
|
||||
|
||||
# 3) Registrar en el UserHistory (request SIN resolver, como en la GUI).
|
||||
if not record_history or not access_token:
|
||||
return result
|
||||
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
req_data = json.dumps(req_item)
|
||||
res_metadata = json.dumps(
|
||||
{"statusCode": status_code, "duration": duration_ms}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"query": _HISTORY_MUTATION,
|
||||
"variables": {"d": req_data, "m": res_metadata, "t": "REST"},
|
||||
}
|
||||
|
||||
try:
|
||||
hist_resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
result["history_error"] = f"transport error: {exc}"
|
||||
return result
|
||||
|
||||
try:
|
||||
hist_data = hist_resp.json()
|
||||
except ValueError:
|
||||
result["history_error"] = (
|
||||
f"non-JSON history response (HTTP {hist_resp.status_code})"
|
||||
)
|
||||
return result
|
||||
|
||||
if hist_data.get("errors"):
|
||||
result["history_error"] = f"graphql errors: {hist_data['errors']}"
|
||||
return result
|
||||
|
||||
created = (hist_data.get("data") or {}).get("createUserHistory")
|
||||
if not created or not created.get("id"):
|
||||
result["history_error"] = "createUserHistory returned no id"
|
||||
return result
|
||||
|
||||
result["recorded"] = True
|
||||
result["history_id"] = created["id"]
|
||||
return result
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests para hoppscotch_run_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.request (ejecucion HTTP) y requests.post
|
||||
(mutation createUserHistory). Verifican la resolucion de placedores `<<>>`/`{{}}`
|
||||
para EJECUTAR, que el History recibe la request SIN resolver, y los caminos de
|
||||
record_history=False y de error de transporte.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
import infra.hoppscotch_run_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_run_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Respuesta minima para requests.request (ejecucion)."""
|
||||
|
||||
def __init__(self, status_code=200, text="OK", headers=None, elapsed_ms=12):
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.headers = headers or {"Content-Type": "text/plain"}
|
||||
self.elapsed = timedelta(milliseconds=elapsed_ms)
|
||||
|
||||
|
||||
class _FakeGraphQLResponse:
|
||||
"""Respuesta minima para requests.post (createUserHistory)."""
|
||||
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def _patch_history_ok(monkeypatch, captured):
|
||||
def fake_post(url, **kwargs):
|
||||
captured["history_url"] = url
|
||||
captured["history_kwargs"] = kwargs
|
||||
return _FakeGraphQLResponse(
|
||||
200, {"data": {"createUserHistory": {"id": "hist-42"}}}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
|
||||
def test_ejecuta_resolviendo_variables_angle(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["method"] = method
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, text="pong")
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/x",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# La request ejecutada lleva la url resuelta.
|
||||
assert captured["url"] == "https://h/x"
|
||||
assert result["status"] == "ok"
|
||||
assert result["status_code"] == 200
|
||||
assert result["response_body"] == "pong"
|
||||
assert result["duration_ms"] == 12
|
||||
|
||||
|
||||
def test_ejecuta_resolviendo_variables_brace(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["url"] = url
|
||||
return _FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"{{baseURL}}/x",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# {{var}} resuelve igual que <<var>>.
|
||||
assert captured["url"] == "https://h/x"
|
||||
|
||||
|
||||
def test_record_history_registra_request_sin_resolver(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
return _FakeResponse(200, text="body", headers={"X-Test": "1"})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="ACCESS-JWT",
|
||||
record_history=True,
|
||||
)
|
||||
|
||||
assert result["recorded"] is True
|
||||
assert result["history_id"] == "hist-42"
|
||||
|
||||
# El POST de History fue al endpoint GraphQL con la cookie access_token.
|
||||
assert captured["history_url"].endswith("/graphql")
|
||||
assert captured["history_kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["history_kwargs"]["json"]
|
||||
assert "createUserHistory" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["t"] == "REST"
|
||||
|
||||
# reqData es el json string de un HoppRESTRequest v:"2" con la url SIN resolver.
|
||||
req = json.loads(variables["d"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "GET"
|
||||
assert req["endpoint"] == "<<baseURL>>/api/status"
|
||||
assert req["name"] == "Status"
|
||||
|
||||
# resMetadata minimo: statusCode + duration.
|
||||
res_meta = json.loads(variables["m"])
|
||||
assert res_meta == {"statusCode": 200, "duration": 12}
|
||||
|
||||
|
||||
def test_record_history_false_no_llama_create_user_history(monkeypatch):
|
||||
calls = {"post": 0}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
return _FakeResponse(200)
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls["post"] += 1
|
||||
return _FakeGraphQLResponse(200, {"data": {"createUserHistory": {"id": "x"}}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"https://h/x",
|
||||
access_token="A",
|
||||
record_history=False,
|
||||
)
|
||||
|
||||
assert result["recorded"] is False
|
||||
assert result["history_id"] is None
|
||||
assert calls["post"] == 0
|
||||
|
||||
|
||||
def test_request_exception_status_error(monkeypatch):
|
||||
def fake_request(method, url, **kwargs):
|
||||
raise mod.requests.RequestException("boom")
|
||||
|
||||
# Si llegara a postear seria un fallo del test: no debe.
|
||||
def fake_post(url, **kwargs):
|
||||
raise AssertionError("no debe registrar history si la ejecucion fallo")
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET", "https://h/x", access_token="A"
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["recorded"] is False
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
def test_variable_faltante_conserva_literal(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["url"] = url
|
||||
return _FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/x",
|
||||
variables={"otra": "y"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# baseURL no esta en variables -> el literal se conserva.
|
||||
assert captured["url"] == "<<baseURL>>/x"
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: hoppscotch_set_environment
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_set_environment(team_id: str, name: str, variables: list[dict], *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch self-hosted via GraphQL, resolviendo secretos desde pass. Lista los environments de la team y, si ya existe uno con ese name, llama updateTeamEnvironment; si no, createTeamEnvironment. Cualquier variable cuyo value empiece por 'pass:' se resuelve con pass_get_secret y se fuerza secret=True. Los valores secretos nunca se logean ni aparecen en el output: resolved_secrets lista solo los keys. Las mutations estan protegidas por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, secret, infra]
|
||||
uses_functions: [pass_get_secret_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: team_id
|
||||
desc: "ID de la team duena del environment."
|
||||
- name: name
|
||||
desc: "nombre del environment. La idempotencia es por este nombre dentro de la team: si ya existe uno con este name se actualiza, si no se crea."
|
||||
- name: variables
|
||||
desc: "lista de dicts {key: str, value: str, secret: bool}. Si un value empieza por 'pass:' el resto se resuelve como ruta de pass con pass_get_secret y el secreto resuelto se usa como value real, forzando secret=True. Campos secret ausentes se tratan como False."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito: {status: 'ok', id: str, name: str, action: 'created'|'updated', resolved_secrets: list[str]} donde resolved_secrets son SOLO los keys resueltos desde pass (nunca valores). En error: {status: 'error', error: str} (resolucion pass fallida con el key afectado, GraphQL errors, HTTP no JSON, o fallo de transporte). Si una variable pass: no se resuelve, NO se crea/actualiza el environment."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_crea_cuando_no_existe"
|
||||
- "test_actualiza_cuando_existe"
|
||||
- "test_resuelve_secreto_desde_pass"
|
||||
- "test_error_pass_no_llama_mutation"
|
||||
test_file_path: "python/functions/infra/hoppscotch_set_environment_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_set_environment.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_set_environment import hoppscotch_set_environment
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Una variable normal + una resuelta desde pass (se marca secret=True sola).
|
||||
result = hoppscotch_set_environment(
|
||||
team_id="cmq8kn0v500030xls1nvminjy",
|
||||
name="registry",
|
||||
variables=[
|
||||
{"key": "base_url", "value": "https://api.example.com", "secret": False},
|
||||
{"key": "api_key", "value": "pass:apis/licenseplatedata"},
|
||||
],
|
||||
access_token=token,
|
||||
)
|
||||
print(result)
|
||||
# {"status": "ok", "id": "...", "name": "registry",
|
||||
# "action": "updated", "resolved_secrets": ["api_key"]}
|
||||
# El valor crudo de api_key NUNCA aparece en el output.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras definir o actualizar las variables de un workspace (team
|
||||
environment) Hoppscotch self-hosted desde el registry, con los secretos
|
||||
resueltos desde `pass` en vez de hardcodearlos. Util en el patron grabar->
|
||||
destilar->reproducir: tras destilar un flujo, dejas sus tokens/credenciales como
|
||||
variables `pass:` de un environment que el humano ve en la GUI, sin que el
|
||||
secreto pase por el codigo. Idempotente por nombre: vuelve a llamarla para
|
||||
actualizar sin duplicar. Primero obten el `access_token` con `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Idempotente por nombre.** Busca un environment con ese `name` en la team: si
|
||||
existe lo actualiza, si no lo crea. Dos teams pueden tener environments con el
|
||||
mismo nombre sin colisionar (la busqueda es por team).
|
||||
- **`pass:` resuelve de pass y fuerza `secret=True`.** Si el `value` empieza por
|
||||
`pass:`, el resto es la ruta de pass; el secreto resuelto reemplaza al value y
|
||||
la variable queda marcada como secreta aunque pasaras `secret=False`.
|
||||
- **Nunca logea secretos.** Ni en stdout ni en el output: `resolved_secrets`
|
||||
contiene solo los KEYS resueltos desde pass, jamas los valores. El valor crudo
|
||||
no aparece en el dict de retorno.
|
||||
- **Falla en pass = no se toca el environment.** Si una variable `pass:` no se
|
||||
puede resolver, la funcion aborta con `{"status": "error"}` y el key afectado
|
||||
ANTES de cualquier mutation: no deja el environment a medias.
|
||||
- **El access_token va como cookie, no como header Authorization.** Las mutations
|
||||
estan protegidas por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El secreto viaja en claro al backend self-host local por GraphQL.** Hoppscotch
|
||||
recibe el valor resuelto en el campo `variables`. Es aceptable porque el backend
|
||||
de referencia es local; no apuntes esta funcion a un Hoppscotch remoto sin TLS.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Listado + create + update validados contra el self-host
|
||||
vivo el 11/06/2026 (createTeamEnvironment / updateTeamEnvironment / listado via
|
||||
team{ teamEnvironments }). Resolucion `pass:` via pass_get_secret_py_infra.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch.
|
||||
|
||||
Define las variables de un workspace Hoppscotch self-hosted via GraphQL,
|
||||
resolviendo secretos desde `pass`: cualquier variable cuyo `value` empiece por
|
||||
``pass:`` se resuelve con pass_get_secret y se marca como `secret=True`.
|
||||
|
||||
Idempotencia por nombre: lista los environments de la team y, si ya existe uno
|
||||
con el `name` dado, lo actualiza; si no, lo crea. Las mutations estan protegidas
|
||||
por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie
|
||||
`access_token`.
|
||||
|
||||
Los valores secretos NUNCA se logean ni aparecen en el output: `resolved_secrets`
|
||||
lista solo los KEYS resueltos desde pass, jamas sus valores.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
|
||||
_LIST_QUERY = "query($t:ID!){ team(teamID:$t){ teamEnvironments{ id name } } }"
|
||||
_CREATE_MUTATION = (
|
||||
"mutation($n:String!,$t:ID!,$v:String!){"
|
||||
" createTeamEnvironment(name:$n,teamID:$t,variables:$v){ id name } }"
|
||||
)
|
||||
_UPDATE_MUTATION = (
|
||||
"mutation($id:ID!,$n:String!,$v:String!){"
|
||||
" updateTeamEnvironment(id:$id,name:$n,variables:$v){ id name } }"
|
||||
)
|
||||
|
||||
_PASS_PREFIX = "pass:"
|
||||
|
||||
|
||||
def hoppscotch_set_environment(
|
||||
team_id: str,
|
||||
name: str,
|
||||
variables: list[dict],
|
||||
*,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Crea o actualiza un Team Environment de Hoppscotch (idempotente por nombre).
|
||||
|
||||
Args:
|
||||
team_id: ID de la team duena del environment.
|
||||
name: nombre del environment. La idempotencia es por este nombre dentro
|
||||
de la team: si ya existe uno con este name se actualiza, si no se crea.
|
||||
variables: lista de dicts ``{"key": str, "value": str, "secret": bool}``.
|
||||
Si un `value` empieza por ``pass:`` el resto se resuelve como ruta de
|
||||
pass con pass_get_secret y el secreto resuelto se usa como value real,
|
||||
forzando `secret=True` en esa variable. Campos `secret` ausentes se
|
||||
tratan como False.
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization.
|
||||
backend_url: base del backend Hoppscotch sin barra final. El endpoint
|
||||
GraphQL es ``{backend_url}/graphql``.
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "id": str, "name": str,
|
||||
"action": "created"|"updated", "resolved_secrets": list[str]}`` donde
|
||||
`resolved_secrets` son SOLO los keys resueltos desde pass (nunca valores).
|
||||
En error: ``{"status": "error", "error": str}`` (resolucion pass fallida,
|
||||
GraphQL errors, HTTP no 200, o fallo de transporte). Si una variable
|
||||
`pass:` no se puede resolver, NO se crea/actualiza el environment.
|
||||
"""
|
||||
resolved: list[dict] = []
|
||||
resolved_secrets: list[str] = []
|
||||
|
||||
for var in variables:
|
||||
key = var.get("key")
|
||||
value = var.get("value", "")
|
||||
secret = bool(var.get("secret", False))
|
||||
|
||||
if isinstance(value, str) and value.startswith(_PASS_PREFIX):
|
||||
pass_path = value[len(_PASS_PREFIX):]
|
||||
secret_res = pass_get_secret(pass_path)
|
||||
if secret_res.get("status") != "ok":
|
||||
# NO crear el env a medias: aborta con el key afectado.
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"pass resolution failed for key {key!r} "
|
||||
f"(path {pass_path!r}): {secret_res.get('error')}"
|
||||
),
|
||||
}
|
||||
value = secret_res["value"]
|
||||
secret = True
|
||||
resolved_secrets.append(key)
|
||||
|
||||
resolved.append({"key": key, "value": value, "secret": secret})
|
||||
|
||||
variables_json = json.dumps(resolved)
|
||||
|
||||
# 1) Localiza un environment existente con este nombre (idempotencia).
|
||||
try:
|
||||
list_resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json={"query": _LIST_QUERY, "variables": {"t": team_id}},
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
list_data = _parse_json(list_resp)
|
||||
if list_data is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON list response (HTTP {list_resp.status_code})",
|
||||
}
|
||||
if list_data.get("errors"):
|
||||
return {"status": "error", "error": "graphql errors", "data": list_data}
|
||||
|
||||
team = (list_data.get("data") or {}).get("team") or {}
|
||||
existing_id = None
|
||||
for env in team.get("teamEnvironments") or []:
|
||||
if env.get("name") == name:
|
||||
existing_id = env.get("id")
|
||||
break
|
||||
|
||||
# 2) Update si existe, create si no.
|
||||
if existing_id:
|
||||
query = _UPDATE_MUTATION
|
||||
gql_vars = {"id": existing_id, "n": name, "v": variables_json}
|
||||
result_field = "updateTeamEnvironment"
|
||||
action = "updated"
|
||||
else:
|
||||
query = _CREATE_MUTATION
|
||||
gql_vars = {"n": name, "t": team_id, "v": variables_json}
|
||||
result_field = "createTeamEnvironment"
|
||||
action = "created"
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json={"query": query, "variables": gql_vars},
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
data = _parse_json(resp)
|
||||
if data is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
if data.get("errors"):
|
||||
return {"status": "error", "error": "graphql errors", "data": data}
|
||||
|
||||
env = (data.get("data") or {}).get(result_field)
|
||||
if not env or not env.get("id"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"{result_field} returned no id",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": env["id"],
|
||||
"name": env.get("name", name),
|
||||
"action": action,
|
||||
"resolved_secrets": resolved_secrets,
|
||||
}
|
||||
|
||||
|
||||
def _parse_json(resp):
|
||||
"""Devuelve el JSON de la respuesta o None si no es JSON valido."""
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests para hoppscotch_set_environment.
|
||||
|
||||
Deterministas: monkeypatchean requests.post (capa de red) y pass_get_secret.
|
||||
Verifican crear vs actualizar (idempotencia por nombre), resolucion de secretos
|
||||
`pass:` (fuerza secret=True, key en resolved_secrets, valor crudo fuera del
|
||||
output), y abortar sin llamar la mutation si pass falla.
|
||||
|
||||
Hay un test e2e real marcado skip por defecto (self-host vivo).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import infra.hoppscotch_set_environment # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_set_environment"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def _make_post(call_log, list_envs, mutation_result):
|
||||
"""Construye un fake requests.post que distingue listado de mutation.
|
||||
|
||||
El listado lleva 'teamEnvironments' en la query; cualquier otra es mutation.
|
||||
Cada llamada se registra en call_log para inspeccion.
|
||||
"""
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
payload = kwargs["json"]
|
||||
query = payload["query"]
|
||||
call_log.append({"url": url, "kwargs": kwargs, "query": query})
|
||||
if "teamEnvironments" in query:
|
||||
return _FakeResponse(
|
||||
200, {"data": {"team": {"teamEnvironments": list_envs}}}
|
||||
)
|
||||
# mutation (create o update)
|
||||
field = (
|
||||
"updateTeamEnvironment"
|
||||
if "updateTeamEnvironment" in query
|
||||
else "createTeamEnvironment"
|
||||
)
|
||||
return _FakeResponse(200, {"data": {field: mutation_result}})
|
||||
|
||||
return fake_post
|
||||
|
||||
|
||||
def test_crea_cuando_no_existe(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(calls, list_envs=[], mutation_result={"id": "env-1", "name": "test_env"}),
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar", "secret": False}],
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "env-1"
|
||||
assert result["action"] == "created"
|
||||
assert result["resolved_secrets"] == []
|
||||
|
||||
# Segunda llamada = mutation createTeamEnvironment con las variables.
|
||||
mutation = calls[1]
|
||||
assert "createTeamEnvironment" in mutation["query"]
|
||||
assert mutation["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
gql_vars = mutation["kwargs"]["json"]["variables"]
|
||||
assert gql_vars["n"] == "test_env"
|
||||
assert gql_vars["t"] == "team-1"
|
||||
sent_vars = json.loads(gql_vars["v"])
|
||||
assert sent_vars == [{"key": "foo", "value": "bar", "secret": False}]
|
||||
|
||||
|
||||
def test_actualiza_cuando_existe(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(
|
||||
calls,
|
||||
list_envs=[{"id": "env-existing", "name": "test_env"}],
|
||||
mutation_result={"id": "env-existing", "name": "test_env"},
|
||||
),
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar"}],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "env-existing"
|
||||
assert result["action"] == "updated"
|
||||
|
||||
mutation = calls[1]
|
||||
assert "updateTeamEnvironment" in mutation["query"]
|
||||
gql_vars = mutation["kwargs"]["json"]["variables"]
|
||||
assert gql_vars["id"] == "env-existing"
|
||||
|
||||
|
||||
def test_resuelve_secreto_desde_pass(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(calls, list_envs=[], mutation_result={"id": "env-2", "name": "e"}),
|
||||
)
|
||||
|
||||
def fake_pass(path, **kwargs):
|
||||
assert path == "apis/lpd"
|
||||
return {"status": "ok", "value": "TOP-SECRET-VALUE"}
|
||||
|
||||
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"e",
|
||||
[
|
||||
{"key": "plain", "value": "visible", "secret": False},
|
||||
{"key": "apikey", "value": "pass:apis/lpd", "secret": False},
|
||||
],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
# El key resuelto aparece, pero NUNCA el valor crudo.
|
||||
assert result["resolved_secrets"] == ["apikey"]
|
||||
assert "TOP-SECRET-VALUE" not in json.dumps(result)
|
||||
|
||||
# La variable resuelta viaja con el valor real y secret=True forzado.
|
||||
mutation = calls[1]
|
||||
sent_vars = json.loads(mutation["kwargs"]["json"]["variables"]["v"])
|
||||
by_key = {v["key"]: v for v in sent_vars}
|
||||
assert by_key["apikey"]["value"] == "TOP-SECRET-VALUE"
|
||||
assert by_key["apikey"]["secret"] is True
|
||||
assert by_key["plain"]["value"] == "visible"
|
||||
assert by_key["plain"]["secret"] is False
|
||||
|
||||
|
||||
def test_error_pass_no_llama_mutation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls.append(kwargs["json"]["query"])
|
||||
return _FakeResponse(200, {"data": {"team": {"teamEnvironments": []}}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
def fake_pass(path, **kwargs):
|
||||
return {"status": "error", "error": "pass not installed"}
|
||||
|
||||
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"e",
|
||||
[{"key": "apikey", "value": "pass:apis/lpd"}],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "apikey" in result["error"]
|
||||
# No se hizo ninguna llamada de red (ni listado ni mutation): aborta antes.
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real contra self-host vivo")
|
||||
def test_e2e_create_then_update_live():
|
||||
"""End-to-end real contra el Hoppscotch self-host vivo.
|
||||
|
||||
login -> set_environment("test_env") -> created -> set_environment de nuevo
|
||||
-> updated. Limpia el env al final con deleteTeamEnvironment.
|
||||
"""
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
import requests
|
||||
|
||||
team_id = "cmq8kn0v500030xls1nvminjy"
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
first = mod.hoppscotch_set_environment(
|
||||
team_id,
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar", "secret": False}],
|
||||
access_token=token,
|
||||
)
|
||||
assert first["status"] == "ok"
|
||||
assert first["action"] == "created"
|
||||
env_id = first["id"]
|
||||
|
||||
second = mod.hoppscotch_set_environment(
|
||||
team_id,
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "baz", "secret": False}],
|
||||
access_token=token,
|
||||
)
|
||||
assert second["status"] == "ok"
|
||||
assert second["action"] == "updated"
|
||||
assert second["id"] == env_id
|
||||
|
||||
# Cleanup: borra el env de prueba.
|
||||
del_q = "mutation($id:ID!){ deleteTeamEnvironment(id:$id) }"
|
||||
requests.post(
|
||||
"http://localhost:3170/graphql",
|
||||
json={"query": del_q, "variables": {"id": env_id}},
|
||||
cookies={"access_token": token},
|
||||
timeout=15.0,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: hoppscotch_update_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_update_request(request_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Actualiza una request REST existente en Hoppscotch self-hosted via la mutation GraphQL updateRequest. Reconstruye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo aplica sobre la request identificada por request_id. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: request_id
|
||||
desc: "ID de la request existente a actualizar."
|
||||
- name: method
|
||||
desc: "metodo HTTP de la request (GET, POST, ...). Se normaliza a mayusculas."
|
||||
- name: url
|
||||
desc: "endpoint completo de la request (con query string si aplica)."
|
||||
- name: title
|
||||
desc: "nuevo nombre visible de la request. None = derivar de method + path."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
|
||||
- name: body
|
||||
desc: "cuerpo de la request como texto YA serializado. None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito: {status: 'ok', id: str, title: str}. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_actualiza_request_y_devuelve_id"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_update_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_update_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_update_request import hoppscotch_update_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Actualizar una request existente: cambiar metodo, url, titulo y body.
|
||||
result = hoppscotch_update_request(
|
||||
request_id="cmq8lue8l000x0xlsd62bncpi",
|
||||
method="POST",
|
||||
url="https://api.example.com/login",
|
||||
title="Login (actualizado)",
|
||||
body='{"user":"neo"}',
|
||||
body_type="json",
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "id": "...", "title": "Login (actualizado)"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una request ya existe en una team collection y quieres reescribir su
|
||||
contenido (metodo, url, cabeceras, body o titulo) desde el agente, para que el
|
||||
humano vea el cambio reflejado en vivo en la GUI por subscriptions. Necesitas el
|
||||
`request_id` (de `hoppscotch_list_requests` o de un `hoppscotch_create_request`
|
||||
previo) y un `access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** El campo
|
||||
`request` del input es un string; esta funcion lo serializa con `json.dumps`.
|
||||
- **Reescribe la request entera, no hace patch.** El HoppRESTRequest enviado
|
||||
reemplaza el contenido: pasa todos los campos que quieras conservar, no solo los
|
||||
que cambian.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(create -> update -> list confirmo el titulo actualizado).
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Actualiza una request REST existente en Hoppscotch.
|
||||
|
||||
Reconstruye el HoppRESTRequest canonico (reusando build_hoppscotch_collection
|
||||
del registry) y lo aplica sobre una request existente via la mutation GraphQL
|
||||
updateRequest del backend self-hosted. Protegida por GqlAuthGuard: el JWT de
|
||||
sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
_MUTATION = (
|
||||
"mutation($r:ID!,$d:UpdateTeamRequestInput!){"
|
||||
" updateRequest(requestID:$r, data:$d){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_update_request(
|
||||
request_id: str,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Actualiza una request existente en Hoppscotch.
|
||||
|
||||
Args:
|
||||
request_id: ID de la request a actualizar.
|
||||
method: metodo HTTP de la request (GET, POST, ...).
|
||||
url: endpoint de la request.
|
||||
title: nombre visible de la request en la GUI. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras de la request.
|
||||
body: cuerpo de la request como texto ya serializado.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization.
|
||||
backend_url: base del backend Hoppscotch (sin barra final).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
|
||||
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
|
||||
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
|
||||
"""
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {
|
||||
"r": request_id,
|
||||
"d": {
|
||||
"title": req_item["name"],
|
||||
"request": json.dumps(req_item),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
updated = (data.get("data") or {}).get("updateRequest")
|
||||
if not updated or not updated.get("id"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "updateRequest returned no id",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": updated["id"],
|
||||
"title": updated.get("title"),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests para hoppscotch_update_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
|
||||
el POST GraphQL lleva la mutation updateRequest con requestID, el access_token
|
||||
en la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_update_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_update_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_actualiza_request_y_devuelve_id(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"updateRequest": {"id": "req-7", "title": "New title"}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_update_request(
|
||||
"req-7",
|
||||
"POST",
|
||||
"https://api.example.com/x",
|
||||
title="New title",
|
||||
body="a=1&b=2",
|
||||
body_type="form",
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "req-7"
|
||||
assert result["title"] == "New title"
|
||||
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["kwargs"]["json"]
|
||||
assert "updateRequest" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["r"] == "req-7"
|
||||
assert variables["d"]["title"] == "New title"
|
||||
|
||||
req = json.loads(variables["d"]["request"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "POST"
|
||||
assert req["body"] == {
|
||||
"contentType": "application/x-www-form-urlencoded",
|
||||
"body": "a=1&b=2",
|
||||
}
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_req/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_update_request(
|
||||
"missing", "GET", "https://x", access_token="A"
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: pass_get_secret
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict"
|
||||
description: "Lee un secreto del gestor de contrasenas pass (passwordstore.org) ejecutando `pass show <path>` como subproceso (lista de args, nunca shell=True). Devuelve la linea solicitada (1-indexed): line=1 es la contrasena por convencion de pass, line=N es metadata multilinea (usuario, URL, notas). El valor es sensible y la funcion NUNCA lo logea. Maneja errores sin lanzar: pass no instalado, entry inexistente, linea fuera de rango. Solo usa stdlib (subprocess)."
|
||||
tags: [pass, secret, credential, infra, flow-replay]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [subprocess]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta del secreto dentro del store (p.ej. 'gitea/dataforge-git-token'). Es el argumento que recibiria `pass show <path>`."
|
||||
- name: line
|
||||
desc: "numero de linea a devolver, 1-indexed. line=1 (default) = primera linea = contrasena por convencion de pass. line=N = linea N para metadata multilinea."
|
||||
- name: timeout_s
|
||||
desc: "timeout del subproceso `pass show` en segundos. Default 10.0."
|
||||
output: "dict. En exito: {status: 'ok', value: str} con la linea pedida sin el salto de linea final. En error (sin lanzar): {status: 'error', error: str} para pass no instalado ('pass not installed'), entry inexistente o fallo de pass (stderr stripeado), o linea fuera de rango ('line N out of range')."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_line_1_devuelve_la_password"
|
||||
- "test_line_2_devuelve_metadata"
|
||||
- "test_returncode_distinto_de_cero_es_error"
|
||||
- "test_pass_no_instalado_es_error"
|
||||
- "test_linea_fuera_de_rango_es_error"
|
||||
- "test_timeout_es_error"
|
||||
test_file_path: "python/functions/infra/pass_get_secret_test.py"
|
||||
file_path: "python/functions/infra/pass_get_secret.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
|
||||
# Primera linea = la contrasena/token (convencion de pass).
|
||||
res = pass_get_secret("gitea/dataforge-git-token")
|
||||
print(res) # {"status": "ok", "value": "ghp_..."} -- NO logear el value en prod
|
||||
|
||||
# Linea 2 = metadata (p.ej. el usuario), si el entry es multilinea.
|
||||
user = pass_get_secret("apis/licenseplatedata", line=2)
|
||||
print(user) # {"status": "ok", "value": "user: neo"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites resolver un secreto de `pass` para inyectarlo en una config,
|
||||
un header HTTP, una variable de entorno o un body de request sin hardcodearlo en
|
||||
el codigo. Es el lector de secretos del registry en Python: el caller pide la
|
||||
ruta del store y recibe el valor en `value`, listo para enchufar donde haga
|
||||
falta. line=1 para la password; line=N para metadata (usuario, URL, notas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere `pass` instalado y el GPG agent desbloqueado.** Si `pass` no esta en
|
||||
el PATH devuelve `{"status": "error", "error": "pass not installed"}`. Si el
|
||||
agente GPG esta bloqueado, `pass show` puede colgarse hasta el `timeout_s`.
|
||||
- **El valor es un secreto: no lo logees.** La funcion nunca lo imprime ni lo
|
||||
registra. Trata el campo `value` como sensible aguas arriba (no `print` en
|
||||
produccion, no persistir en claro).
|
||||
- **line=1 es la contrasena.** Por convencion de pass la primera linea es el
|
||||
secreto principal; las lineas siguientes son metadata 1-indexed.
|
||||
- **No usa shell.** Ejecuta `["pass", "show", path]` como lista de args, nunca
|
||||
`shell=True`, asi que `path` no puede inyectar comandos.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Lector de secretos `pass` para Python, base de la
|
||||
resolucion `pass:` en hoppscotch_set_environment.
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Lee un secreto del gestor de contrasenas `pass` (passwordstore.org).
|
||||
|
||||
Ejecuta `pass show <path>` como subproceso (lista de args, nunca shell=True) y
|
||||
devuelve la linea solicitada del secreto. Por convencion de pass, la primera
|
||||
linea es la contrasena y las lineas siguientes son metadata (usuario, URL,
|
||||
notas, etc.).
|
||||
|
||||
El valor devuelto es sensible: esta funcion NUNCA lo logea. El caller es
|
||||
responsable de tratarlo como secreto (no imprimirlo, no persistirlo en claro).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict:
|
||||
"""Lee una linea de un secreto del password store (pass).
|
||||
|
||||
Args:
|
||||
path: ruta del secreto dentro del store (p.ej. "gitea/dataforge-git-token").
|
||||
Es el argumento que recibiria `pass show <path>`.
|
||||
line: numero de linea a devolver, 1-indexed. line=1 (default) es la
|
||||
primera linea = la contrasena por convencion de pass. line=N
|
||||
devuelve la linea N para metadata multilinea.
|
||||
timeout_s: timeout del subproceso en segundos.
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "value": str}`` con la linea pedida
|
||||
sin el salto de linea final. En error (sin lanzar):
|
||||
``{"status": "error", "error": str}`` para: pass no instalado, entry
|
||||
inexistente / fallo de pass (returncode != 0), o linea fuera de rango.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pass", "show", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return {"status": "error", "error": "pass not installed"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "error": f"pass timed out after {timeout_s}s"}
|
||||
|
||||
if proc.returncode != 0:
|
||||
return {"status": "error", "error": (proc.stderr or "").strip()}
|
||||
|
||||
# `pass show` termina con un salto de linea; splitlines lo absorbe.
|
||||
lines = proc.stdout.splitlines()
|
||||
if line < 1 or line > len(lines):
|
||||
return {"status": "error", "error": f"line {line} out of range"}
|
||||
|
||||
return {"status": "ok", "value": lines[line - 1]}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests para pass_get_secret.
|
||||
|
||||
Deterministas: monkeypatchean subprocess.run para no ejecutar `pass` real.
|
||||
Verifican seleccion de linea (1-indexed), errores de pass (returncode != 0),
|
||||
pass no instalado (FileNotFoundError) y linea fuera de rango.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import infra.pass_get_secret # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.pass_get_secret"]
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def test_line_1_devuelve_la_password(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
assert args == ["pass", "show", "apis/lpd"]
|
||||
assert kwargs.get("shell") is None # nunca shell=True
|
||||
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result == {"status": "ok", "value": "s3cr3t-pass"}
|
||||
|
||||
|
||||
def test_line_2_devuelve_metadata(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd", line=2)
|
||||
assert result == {"status": "ok", "value": "user: neo"}
|
||||
|
||||
|
||||
def test_returncode_distinto_de_cero_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(1, stdout="", stderr="Error: apis/nope is not in the password store.\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/nope")
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "Error: apis/nope is not in the password store."
|
||||
|
||||
|
||||
def test_pass_no_instalado_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
raise FileNotFoundError("no such file: pass")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result == {"status": "error", "error": "pass not installed"}
|
||||
|
||||
|
||||
def test_linea_fuera_de_rango_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(0, stdout="solo-una-linea\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd", line=5)
|
||||
assert result == {"status": "error", "error": "line 5 out of range"}
|
||||
|
||||
|
||||
def test_timeout_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=args, timeout=10.0)
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result["status"] == "error"
|
||||
assert "timed out" in result["error"]
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: sync_chromium_profiles_to_rofi
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "sync_chromium_profiles_to_rofi(local_state_path: str = '', apps_dir: str = '', icons_dir: str = '', chromium_cmd: str = 'chromium') -> dict"
|
||||
description: "Sincroniza los perfiles de Chromium con el lanzador (rofi / menus .desktop). Lee profile.info_cache del Local State y por cada perfil de usuario valido genera un avatar PNG con las iniciales (o reutiliza la foto Gaia) y un archivo .desktop que lanza Chromium en ese perfil con su icono. Limpia los .desktop obsoletos generados por esta funcion (marcador X-RofiChromiumProfile) y refresca la base de datos de aplicaciones con update-desktop-database (best-effort). Excluye System Profile y perfiles con nombre vacio. Idempotente."
|
||||
tags: ["rofi", "chromium", "desktop", "launcher", "xdg", "profiles", "icons"]
|
||||
params:
|
||||
- name: local_state_path
|
||||
desc: "Ruta al archivo 'Local State' de Chromium (JSON con profile.info_cache). Si viene vacio usa ~/.config/chromium-cdp/Local State. El user-data-dir se deriva como su dirname."
|
||||
- name: apps_dir
|
||||
desc: "Directorio XDG de salida de los .desktop. Si viene vacio usa ~/.local/share/applications. rofi escanea este arbol."
|
||||
- name: icons_dir
|
||||
desc: "Directorio de salida de los iconos PNG por perfil. Si viene vacio usa ~/.local/share/icons/chromium-profiles."
|
||||
- name: chromium_cmd
|
||||
desc: "Comando base para el Exec del .desktop. Default 'chromium'. NO se pasa --user-data-dir: el wrapper del sistema (/etc/chromium.d/cdp) lo inyecta. El --profile-directory es relativo a ese user-data-dir."
|
||||
output: "dict con user_data_dir (str absoluto), created (lista de .desktop escritos), removed (lista de .desktop obsoletos borrados), profiles (lista de {dir, name, desktop, icon}), warnings (perfiles saltados por datos raros) y update_desktop_database ({attempted, ok, detail})."
|
||||
uses_functions:
|
||||
- generate_initials_avatar_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- Pillow
|
||||
file_path: "python/functions/infra/sync_chromium_profiles_to_rofi.py"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Ejecucion directa con defaults (lee ~/.config/chromium-cdp/Local State,
|
||||
# escribe .desktop en ~/.local/share/applications y PNGs en
|
||||
# ~/.local/share/icons/chromium-profiles)
|
||||
cd $HOME/fn_registry
|
||||
./fn run sync_chromium_profiles_to_rofi_py_infra
|
||||
```
|
||||
|
||||
```python
|
||||
# Variante Python importando la funcion (PYTHONPATH=python/functions via fn run,
|
||||
# o sys.path.insert al ejecutar suelto).
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.sync_chromium_profiles_to_rofi import sync_chromium_profiles_to_rofi
|
||||
|
||||
# Apuntando a un Local State y directorios de salida de prueba en /tmp
|
||||
res = sync_chromium_profiles_to_rofi(
|
||||
local_state_path="/tmp/chromium-test/Local State",
|
||||
apps_dir="/tmp/chromium-test/apps",
|
||||
icons_dir="/tmp/chromium-test/icons",
|
||||
)
|
||||
print(res["created"], res["removed"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras crear, renombrar o eliminar un perfil de Chromium, o a diario via dag_engine,
|
||||
para que los perfiles aparezcan en rofi (y en cualquier menu XDG) con su propio
|
||||
icono y un Exec que abre Chromium directamente en ese perfil. Util tambien al
|
||||
provisionar una maquina nueva donde se han clonado varios perfiles.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `Exec` del `.desktop` depende del wrapper del sistema que inyecta
|
||||
`--user-data-dir=$HOME/.config/chromium-cdp` (via `/etc/chromium.d/cdp`). Por eso
|
||||
NO se pasa `--user-data-dir` aqui; solo `chromium --profile-directory="<dir>"`. En
|
||||
una maquina sin ese wrapper el `.desktop` abriria el perfil del user-data-dir por
|
||||
defecto de Chromium, no el del directorio `chromium-cdp`.
|
||||
- El marcador `X-RofiChromiumProfile=true` gobierna la limpieza: la funcion solo
|
||||
borra `.desktop` que ella misma genero. NUNCA toca `.desktop` ajenos aunque
|
||||
coincidan con el patron `chromium-*.desktop`.
|
||||
- `update-desktop-database` es best-effort: si el binario no esta en el PATH, la
|
||||
funcion NO falla; lo reporta en `update_desktop_database.detail`. rofi suele
|
||||
funcionar igualmente sin el refresh, pero algunos menus cachean.
|
||||
- Excluye siempre `"System Profile"` (no es un perfil de usuario) y cualquier
|
||||
entrada con `name` vacio.
|
||||
- Si un perfil tiene `gaia_picture_file_name` y el archivo existe en el dir del
|
||||
perfil, se usa esa imagen tal cual como icono (sin recorte circular). Si no, se
|
||||
genera el avatar de iniciales con `generate_initials_avatar`.
|
||||
- El `slug` se deriva de la CLAVE del `info_cache` (el nombre del subdirectorio,
|
||||
ej. `"Profile 1"` -> `profile-1`), no del nombre legible. Asi dos perfiles con el
|
||||
mismo nombre visible pero distinto directorio no colisionan.
|
||||
@@ -0,0 +1,264 @@
|
||||
"""sync_chromium_profiles_to_rofi — sincroniza perfiles de Chromium con el lanzador (rofi / menus .desktop).
|
||||
|
||||
Lee los perfiles de Chromium desde el `Local State` de un user-data-dir y, por
|
||||
cada perfil de usuario valido, genera:
|
||||
|
||||
1. un avatar PNG con las iniciales del perfil (icono distinto por perfil), o
|
||||
reutiliza la foto Gaia del perfil si esta disponible,
|
||||
2. un archivo `.desktop` que lanza Chromium en ese perfil con ese icono.
|
||||
|
||||
Ademas limpia los `.desktop` de perfiles que ya no existen (identificados por el
|
||||
marcador `X-RofiChromiumProfile=true`) y refresca la base de datos de aplicaciones
|
||||
con `update-desktop-database` (best-effort).
|
||||
|
||||
El `Exec` NO pasa `--user-data-dir`: el wrapper del sistema (`/etc/chromium.d/cdp`)
|
||||
inyecta `--user-data-dir=$HOME/.config/chromium-cdp` en cada invocacion de chromium.
|
||||
Por eso basta `chromium --profile-directory="<dir>"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Resolver el paquete `infra` del registry tanto al ejecutar via `fn run`
|
||||
# (PYTHONPATH=python/functions) como al ejecutar el archivo directo como script.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.generate_initials_avatar import generate_initials_avatar # noqa: E402
|
||||
|
||||
# Marcador que identifica los .desktop generados por ESTA funcion. Gobierna la
|
||||
# limpieza de obsoletos sin tocar .desktop ajenos.
|
||||
MARKER = "X-RofiChromiumProfile=true"
|
||||
|
||||
|
||||
def _slugify(key: str) -> str:
|
||||
"""Convierte la clave del dir del perfil en un slug seguro para nombres de archivo.
|
||||
|
||||
"Profile 1" -> "profile-1", "Default" -> "default", "osint_01" -> "osint-01".
|
||||
"""
|
||||
s = key.strip().lower()
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
s = re.sub(r"[^a-z0-9-]", "", s)
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
return s
|
||||
|
||||
|
||||
def _desktop_body(name: str, dir_key: str, icon_path: str, chromium_cmd: str) -> str:
|
||||
"""Construye el cuerpo del archivo .desktop para un perfil.
|
||||
|
||||
El em dash en `Name` es intencional. `dir_key` se encierra entre comillas
|
||||
dobles en el Exec porque puede contener espacios ("Profile 1").
|
||||
"""
|
||||
return (
|
||||
"[Desktop Entry]\n"
|
||||
"Version=1.0\n"
|
||||
"Type=Application\n"
|
||||
f"Name=Chromium — {name}\n"
|
||||
"GenericName=Web Browser\n"
|
||||
f"Comment=Chromium en el perfil {name}\n"
|
||||
f'Exec={chromium_cmd} --profile-directory="{dir_key}" %U\n'
|
||||
f"Icon={icon_path}\n"
|
||||
"Terminal=false\n"
|
||||
"Categories=Network;WebBrowser;\n"
|
||||
"StartupWMClass=chromium\n"
|
||||
f"{MARKER}\n"
|
||||
)
|
||||
|
||||
|
||||
def sync_chromium_profiles_to_rofi(
|
||||
local_state_path: str = "",
|
||||
apps_dir: str = "",
|
||||
icons_dir: str = "",
|
||||
chromium_cmd: str = "chromium",
|
||||
) -> dict:
|
||||
"""Sincroniza los perfiles de Chromium con entradas .desktop + iconos para rofi.
|
||||
|
||||
Lee `profile.info_cache` del `Local State` y, por cada perfil de usuario valido
|
||||
(excluye "System Profile" y perfiles con nombre vacio), genera un avatar PNG y
|
||||
un archivo `.desktop` que lanza Chromium en ese perfil. Limpia los `.desktop`
|
||||
obsoletos generados previamente por esta funcion y refresca la base de datos de
|
||||
aplicaciones. Es idempotente: ejecutarla N veces deja el mismo estado.
|
||||
|
||||
Args:
|
||||
local_state_path: Ruta al archivo `Local State` de Chromium. Si viene vacio
|
||||
usa `~/.config/chromium-cdp/Local State`.
|
||||
apps_dir: Directorio de salida de los `.desktop`. Si viene vacio usa
|
||||
`~/.local/share/applications`.
|
||||
icons_dir: Directorio de salida de los iconos PNG. Si viene vacio usa
|
||||
`~/.local/share/icons/chromium-profiles`.
|
||||
chromium_cmd: Comando base para lanzar Chromium en el Exec del `.desktop`.
|
||||
Default "chromium" (el wrapper del sistema inyecta `--user-data-dir`).
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
user_data_dir: ruta absoluta del user-data-dir derivado del Local State.
|
||||
created: lista de paths de los .desktop escritos/actualizados este run.
|
||||
removed: lista de paths de los .desktop obsoletos borrados.
|
||||
profiles: lista de {dir, name, desktop, icon} por perfil procesado.
|
||||
warnings: lista de mensajes de perfiles saltados por datos raros.
|
||||
update_desktop_database: dict {attempted, ok, detail} con el resultado
|
||||
best-effort de `update-desktop-database`.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el `Local State` no existe.
|
||||
ValueError: si el JSON no tiene `profile.info_cache`.
|
||||
"""
|
||||
if not local_state_path:
|
||||
local_state_path = os.path.expanduser("~/.config/chromium-cdp/Local State")
|
||||
if not apps_dir:
|
||||
apps_dir = os.path.expanduser("~/.local/share/applications")
|
||||
if not icons_dir:
|
||||
icons_dir = os.path.expanduser("~/.local/share/icons/chromium-profiles")
|
||||
|
||||
ls_path = Path(local_state_path)
|
||||
if not ls_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Local State no encontrado en {ls_path}. "
|
||||
"Verifica la ruta del user-data-dir de Chromium."
|
||||
)
|
||||
|
||||
user_data_dir = ls_path.parent
|
||||
|
||||
try:
|
||||
state = json.loads(ls_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError(f"No se pudo parsear el JSON de {ls_path}: {exc}") from exc
|
||||
|
||||
info_cache = state.get("profile", {}).get("info_cache")
|
||||
if not isinstance(info_cache, dict):
|
||||
raise ValueError(
|
||||
f"{ls_path} no contiene profile.info_cache (dict). "
|
||||
"El archivo no parece un Local State de Chromium valido."
|
||||
)
|
||||
|
||||
apps_path = Path(apps_dir)
|
||||
icons_path = Path(icons_dir)
|
||||
icons_path.mkdir(parents=True, exist_ok=True)
|
||||
apps_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
created: list[str] = []
|
||||
profiles: list[dict] = []
|
||||
warnings: list[str] = []
|
||||
# slugs vigentes -> sirve para la limpieza de obsoletos
|
||||
valid_slugs: set[str] = set()
|
||||
|
||||
for dir_key, meta in info_cache.items():
|
||||
try:
|
||||
if dir_key == "System Profile":
|
||||
continue
|
||||
if not isinstance(meta, dict):
|
||||
warnings.append(f"perfil {dir_key!r}: metadata no es dict, saltado")
|
||||
continue
|
||||
|
||||
name = (meta.get("name") or "").strip()
|
||||
if not name:
|
||||
# name vacio -> no es un perfil de usuario util
|
||||
continue
|
||||
|
||||
slug = _slugify(dir_key)
|
||||
if not slug:
|
||||
warnings.append(f"perfil {dir_key!r}: slug vacio tras sanitizar, saltado")
|
||||
continue
|
||||
valid_slugs.add(slug)
|
||||
|
||||
icon_path = icons_path / f"{slug}.png"
|
||||
|
||||
# Preferir la foto Gaia del perfil si existe; si no, avatar de iniciales.
|
||||
gaia = (meta.get("gaia_picture_file_name") or "").strip()
|
||||
gaia_src = user_data_dir / dir_key / gaia if gaia else None
|
||||
if gaia_src is not None and gaia_src.is_file():
|
||||
try:
|
||||
shutil.copyfile(str(gaia_src), str(icon_path))
|
||||
except Exception as exc:
|
||||
warnings.append(
|
||||
f"perfil {dir_key!r}: no se pudo copiar foto Gaia ({exc}), "
|
||||
"usando avatar de iniciales"
|
||||
)
|
||||
generate_initials_avatar(name, str(icon_path))
|
||||
else:
|
||||
generate_initials_avatar(name, str(icon_path))
|
||||
|
||||
desktop_path = apps_path / f"chromium-{slug}.desktop"
|
||||
desktop_path.write_text(
|
||||
_desktop_body(name, dir_key, str(icon_path), chromium_cmd),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
created.append(str(desktop_path))
|
||||
profiles.append(
|
||||
{
|
||||
"dir": dir_key,
|
||||
"name": name,
|
||||
"desktop": str(desktop_path),
|
||||
"icon": str(icon_path),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"perfil {dir_key!r}: error inesperado, saltado ({exc})")
|
||||
continue
|
||||
|
||||
# --- Limpieza de obsoletos: .desktop generados por esta funcion cuyo slug ya
|
||||
# no corresponde a ningun perfil vigente. ---
|
||||
removed: list[str] = []
|
||||
for desktop_file in apps_path.glob("chromium-*.desktop"):
|
||||
try:
|
||||
content = desktop_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if MARKER not in content:
|
||||
# No es nuestro -> no tocar.
|
||||
continue
|
||||
slug = desktop_file.stem[len("chromium-"):]
|
||||
if slug in valid_slugs:
|
||||
continue
|
||||
# Obsoleto: borrar .desktop + su icono asociado.
|
||||
try:
|
||||
desktop_file.unlink()
|
||||
removed.append(str(desktop_file))
|
||||
except Exception as exc:
|
||||
warnings.append(f"no se pudo borrar obsoleto {desktop_file}: {exc}")
|
||||
continue
|
||||
old_icon = icons_path / f"{slug}.png"
|
||||
if old_icon.exists():
|
||||
try:
|
||||
old_icon.unlink()
|
||||
except Exception as exc:
|
||||
warnings.append(f"no se pudo borrar icono obsoleto {old_icon}: {exc}")
|
||||
|
||||
# --- Refrescar base de datos de aplicaciones (best-effort). ---
|
||||
udb = {"attempted": False, "ok": False, "detail": ""}
|
||||
udb_bin = shutil.which("update-desktop-database")
|
||||
if udb_bin:
|
||||
udb["attempted"] = True
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[udb_bin, str(apps_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
udb["ok"] = proc.returncode == 0
|
||||
udb["detail"] = (proc.stderr or proc.stdout or "").strip()
|
||||
except Exception as exc:
|
||||
udb["detail"] = f"error ejecutando update-desktop-database: {exc}"
|
||||
else:
|
||||
udb["detail"] = "update-desktop-database no encontrado en PATH"
|
||||
|
||||
return {
|
||||
"user_data_dir": str(user_data_dir),
|
||||
"created": created,
|
||||
"removed": removed,
|
||||
"profiles": profiles,
|
||||
"warnings": warnings,
|
||||
"update_desktop_database": udb,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = sync_chromium_profiles_to_rofi()
|
||||
print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
|
||||
@@ -0,0 +1,37 @@
|
||||
from .parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
from .extract_obsidian_wikilinks import extract_obsidian_wikilinks
|
||||
from .format_obsidian_note import format_obsidian_note
|
||||
|
||||
# CRUD impuro de notas en disco (grupo obsidian)
|
||||
from .read_obsidian_note import read_obsidian_note
|
||||
from .create_obsidian_note import create_obsidian_note
|
||||
from .update_obsidian_note import update_obsidian_note
|
||||
from .delete_obsidian_note import delete_obsidian_note
|
||||
|
||||
# Listado/busqueda de notas y CRUD de vaults (grupo obsidian)
|
||||
from .list_obsidian_notes import list_obsidian_notes
|
||||
from .search_obsidian_notes import search_obsidian_notes
|
||||
from .list_obsidian_vaults import list_obsidian_vaults
|
||||
from .create_obsidian_vault import create_obsidian_vault
|
||||
|
||||
# Utilidades de migracion / extraccion de subgrafos (grupo obsidian)
|
||||
from .slugify_obsidian_name import slugify_obsidian_name
|
||||
from .extract_obsidian_embeds import extract_obsidian_embeds
|
||||
from .resolve_obsidian_embed import resolve_obsidian_embed
|
||||
|
||||
__all__ = [
|
||||
"parse_obsidian_frontmatter",
|
||||
"extract_obsidian_wikilinks",
|
||||
"format_obsidian_note",
|
||||
"read_obsidian_note",
|
||||
"create_obsidian_note",
|
||||
"update_obsidian_note",
|
||||
"delete_obsidian_note",
|
||||
"list_obsidian_notes",
|
||||
"search_obsidian_notes",
|
||||
"list_obsidian_vaults",
|
||||
"create_obsidian_vault",
|
||||
"slugify_obsidian_name",
|
||||
"extract_obsidian_embeds",
|
||||
"resolve_obsidian_embed",
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: create_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def create_obsidian_note(vault_dir: str, rel_path: str, body: str = '', frontmatter: dict = None, overwrite: bool = False) -> str"
|
||||
description: "Crea una nota Markdown nueva en un vault de Obsidian. Anade extension .md si falta, crea directorios padre, serializa frontmatter YAML + body con la funcion pura format_obsidian_note. Falla si la nota existe salvo overwrite=True. No depende de la app GUI de Obsidian: solo escribe un archivo .md plano en disco."
|
||||
tags: [obsidian, markdown, frontmatter, create, write, notes]
|
||||
uses_functions: ["format_obsidian_note_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "directorio raiz del vault de Obsidian donde se crea la nota"
|
||||
- name: rel_path
|
||||
desc: "ruta relativa de la nota dentro del vault; se le anade .md si no lo trae"
|
||||
- name: body
|
||||
desc: "cuerpo Markdown de la nota sin frontmatter (default cadena vacia)"
|
||||
- name: frontmatter
|
||||
desc: "dict con el frontmatter YAML a escribir; None se trata como {}"
|
||||
- name: overwrite
|
||||
desc: "si False (default) y la nota existe lanza FileExistsError; True sobreescribe"
|
||||
output: "ruta absoluta (str) del archivo .md escrito"
|
||||
tested: true
|
||||
tests:
|
||||
- "crea nota con frontmatter y body"
|
||||
- "anade extension md si falta"
|
||||
- "crea directorios padre"
|
||||
- "existente sin overwrite lanza fileexistserror"
|
||||
- "overwrite true sobreescribe"
|
||||
- "destino directorio lanza isadirectoryerror"
|
||||
test_file_path: "python/functions/obsidian/create_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/create_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import create_obsidian_note
|
||||
|
||||
path = create_obsidian_note(
|
||||
vault_dir="/home/me/vault",
|
||||
rel_path="Inbox/Idea rapida", # se convierte en Inbox/Idea rapida.md
|
||||
body="Primer apunte. Ver [[Proyecto X]].",
|
||||
frontmatter={"title": "Idea rapida", "tags": ["inbox", "wip"]},
|
||||
)
|
||||
print(path) # /home/me/vault/Inbox/Idea rapida.md
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras crear una nota nueva en un vault de Obsidian desde codigo o un agente: capturar una idea en el Inbox, generar notas a partir de datos, o materializar plantillas. Crea automaticamente los directorios padre, asi que sirve para sembrar estructuras de carpetas nuevas.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Escribe en disco** (I/O impuro): crea el archivo y los directorios padre que falten (`os.makedirs(..., exist_ok=True)`).
|
||||
- **No respeta locks de la app GUI**: si Obsidian esta abierto, el archivo nuevo aparecera en el vault, pero crear una nota cuyo nombre choque con una abierta y sin guardar puede provocar conflictos de version en el editor.
|
||||
- Por defecto **no sobreescribe**: lanza `FileExistsError` si la nota ya existe. Pasa `overwrite=True` para reemplazar.
|
||||
- Lanza `IsADirectoryError` si el destino resuelto es un directorio existente.
|
||||
- El nombre de archivo se respeta tal cual (incluidos espacios); Obsidian admite espacios en nombres de nota.
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Crea una nota nueva de Obsidian (.md) en un vault, en disco.
|
||||
|
||||
Compone la funcion pura format_obsidian_note del grupo obsidian para serializar
|
||||
frontmatter + body. Funcion impura: escribe un archivo nuevo en disco y crea
|
||||
directorios padre.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import format_obsidian_note
|
||||
|
||||
|
||||
def create_obsidian_note(
|
||||
vault_dir: str,
|
||||
rel_path: str,
|
||||
body: str = "",
|
||||
frontmatter: dict = None,
|
||||
overwrite: bool = False,
|
||||
) -> str:
|
||||
"""Crea una nota Markdown nueva dentro de un vault de Obsidian.
|
||||
|
||||
Args:
|
||||
vault_dir: directorio raiz del vault donde se crea la nota.
|
||||
rel_path: ruta relativa de la nota dentro del vault. Si no termina en
|
||||
".md" se le anade la extension automaticamente.
|
||||
body: cuerpo Markdown de la nota (sin frontmatter). Por defecto vacio.
|
||||
frontmatter: dict con el frontmatter YAML a escribir. None -> {}.
|
||||
overwrite: si False (default) y la nota ya existe, lanza FileExistsError.
|
||||
Si True, sobreescribe el archivo existente.
|
||||
|
||||
Returns:
|
||||
La ruta absoluta del archivo .md escrito.
|
||||
|
||||
Raises:
|
||||
FileExistsError: si la nota existe y overwrite=False.
|
||||
IsADirectoryError: si la ruta destino es un directorio existente.
|
||||
OSError: si la escritura falla por otro motivo de I/O.
|
||||
"""
|
||||
if not rel_path.endswith(".md"):
|
||||
rel_path = rel_path + ".md"
|
||||
|
||||
abs_path = os.path.abspath(os.path.join(vault_dir, rel_path))
|
||||
|
||||
if os.path.isdir(abs_path):
|
||||
raise IsADirectoryError(f"destination is a directory: {abs_path}")
|
||||
if os.path.exists(abs_path) and not overwrite:
|
||||
raise FileExistsError(
|
||||
f"obsidian note already exists (use overwrite=True): {abs_path}"
|
||||
)
|
||||
|
||||
parent = os.path.dirname(abs_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
content = format_obsidian_note(frontmatter or {}, body or "")
|
||||
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return abs_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
vault = tempfile.mkdtemp()
|
||||
p = create_obsidian_note(
|
||||
vault, "subdir/Nota Nueva", body="Cuerpo.", frontmatter={"title": "X"}
|
||||
)
|
||||
assert os.path.isfile(p), p
|
||||
assert p.endswith("Nota Nueva.md"), p
|
||||
try:
|
||||
create_obsidian_note(vault, "subdir/Nota Nueva", body="otra")
|
||||
raise AssertionError("debio lanzar FileExistsError")
|
||||
except FileExistsError:
|
||||
pass
|
||||
p2 = create_obsidian_note(vault, "subdir/Nota Nueva", body="z", overwrite=True)
|
||||
assert p2 == p, (p2, p)
|
||||
os.remove(p)
|
||||
os.rmdir(os.path.dirname(p))
|
||||
os.rmdir(vault)
|
||||
print("create_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests para create_obsidian_note."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from create_obsidian_note import create_obsidian_note
|
||||
from read_obsidian_note import read_obsidian_note
|
||||
|
||||
|
||||
def test_crea_nota_con_frontmatter_y_body(tmp_path):
|
||||
# Golden path: crea nota con frontmatter + body y crea el subdir padre.
|
||||
vault = str(tmp_path)
|
||||
path = create_obsidian_note(
|
||||
vault,
|
||||
"Proyectos/Idea.md",
|
||||
body="Primer apunte. [[Proyecto X]].",
|
||||
frontmatter={"title": "Idea", "tags": ["inbox"]},
|
||||
)
|
||||
assert os.path.isfile(path)
|
||||
assert path.endswith("Proyectos/Idea.md")
|
||||
|
||||
note = read_obsidian_note(path)
|
||||
assert note["frontmatter"]["title"] == "Idea"
|
||||
assert note["tags"] == ["inbox"]
|
||||
assert "Primer apunte" in note["body"]
|
||||
assert note["wikilinks"] == ["Proyecto X"]
|
||||
|
||||
|
||||
def test_anade_extension_md_si_falta(tmp_path):
|
||||
# Edge: rel_path sin extension -> se le anade .md.
|
||||
path = create_obsidian_note(str(tmp_path), "Inbox/Nota rapida", body="x")
|
||||
assert path.endswith("Nota rapida.md")
|
||||
assert os.path.isfile(path)
|
||||
|
||||
|
||||
def test_crea_directorios_padre(tmp_path):
|
||||
# Edge: crea toda la jerarquia de carpetas que no existe.
|
||||
path = create_obsidian_note(str(tmp_path), "a/b/c/Honda.md", body="y")
|
||||
assert os.path.isfile(path)
|
||||
assert os.path.isdir(os.path.join(str(tmp_path), "a", "b", "c"))
|
||||
|
||||
|
||||
def test_existente_sin_overwrite_lanza_fileexistserror(tmp_path):
|
||||
# Error path: la nota ya existe y overwrite=False (default).
|
||||
create_obsidian_note(str(tmp_path), "Dup.md", body="original")
|
||||
with pytest.raises(FileExistsError):
|
||||
create_obsidian_note(str(tmp_path), "Dup.md", body="nuevo")
|
||||
|
||||
|
||||
def test_overwrite_true_sobreescribe(tmp_path):
|
||||
# Edge: overwrite=True reemplaza el contenido y devuelve la misma ruta.
|
||||
p1 = create_obsidian_note(str(tmp_path), "Dup.md", body="original")
|
||||
p2 = create_obsidian_note(
|
||||
str(tmp_path), "Dup.md", body="reemplazado", overwrite=True
|
||||
)
|
||||
assert p1 == p2
|
||||
note = read_obsidian_note(p2)
|
||||
assert note["body"].strip() == "reemplazado"
|
||||
|
||||
|
||||
def test_destino_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: el destino resuelto ya es un directorio.
|
||||
target = tmp_path / "EsCarpeta.md"
|
||||
target.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
create_obsidian_note(str(tmp_path), "EsCarpeta.md", body="x")
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: create_obsidian_vault
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "create_obsidian_vault(parent_dir: str, name: str) -> str"
|
||||
description: "Crea un vault de Obsidian nuevo: parent_dir/name/ + parent_dir/name/.obsidian/app.json con {} (config minima valida). Lanza error si el vault ya existe (ya tiene .obsidian/). Devuelve el path absoluto del vault."
|
||||
tags: [obsidian, vault, create, crud, filesystem]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["json", "os"]
|
||||
params:
|
||||
- name: parent_dir
|
||||
desc: "directorio bajo el cual se crea la carpeta del vault; se crea si no existe"
|
||||
- name: name
|
||||
desc: "nombre de la carpeta del nuevo vault (un solo segmento de path, sin separadores)"
|
||||
output: "path absoluto del directorio del vault creado"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/create_obsidian_vault.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import create_obsidian_vault
|
||||
|
||||
path = create_obsidian_vault("/home/enmanuel/Obsidian", "Proyectos2026")
|
||||
print(path) # /home/enmanuel/Obsidian/Proyectos2026
|
||||
# Crea ademas /home/enmanuel/Obsidian/Proyectos2026/.obsidian/app.json -> {}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites crear un vault de Obsidian listo para abrir desde cero (scaffolding de un workspace nuevo, automatizar la creacion de vaults por proyecto) sin pasar por la GUI de Obsidian.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe en el filesystem. Crea `parent_dir/name/.obsidian/` y un `app.json` con `{}` (config minima que Obsidian reconoce como vault valido).
|
||||
- **No sobrescribe**: si el destino ya parece un vault (ya tiene `.obsidian/`) lanza `FileExistsError`; nunca pisa un vault existente.
|
||||
- **Nombre validado**: lanza `ValueError` si `name` es vacio o contiene un separador de path (`/`), para evitar crear estructuras anidadas accidentales.
|
||||
- Lo que hace vault a una carpeta es la presencia de `.obsidian/`; este es el mismo criterio que usa `list_obsidian_vaults` para descubrir vaults, asi que un vault recien creado aparece de inmediato en ese listado.
|
||||
- `parent_dir` se crea si no existe (`makedirs`), de modo que se puede crear un vault en una ruta nueva en una sola llamada.
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Create a new, valid Obsidian vault on disk."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def create_obsidian_vault(parent_dir: str, name: str) -> str:
|
||||
"""Create a new Obsidian vault under ``parent_dir`` and return its path.
|
||||
|
||||
Creates ``parent_dir/name/`` together with a minimal but valid Obsidian
|
||||
configuration: ``parent_dir/name/.obsidian/app.json`` containing ``{}``.
|
||||
The presence of an ``.obsidian/`` directory is what Obsidian uses to
|
||||
recognise a folder as a vault, so the result opens cleanly in Obsidian.
|
||||
|
||||
Impure: it writes to the filesystem. If the target already looks like a
|
||||
vault (it already has an ``.obsidian/`` directory) a ``FileExistsError`` is
|
||||
raised so an existing vault is never silently overwritten. A
|
||||
``ValueError`` is raised for an empty ``name`` or one containing a path
|
||||
separator.
|
||||
|
||||
Args:
|
||||
parent_dir: Directory under which the new vault folder is created. It is
|
||||
created if it does not exist yet.
|
||||
name: Name of the new vault folder (a single path segment, no
|
||||
separators).
|
||||
|
||||
Returns:
|
||||
The absolute path of the created vault directory.
|
||||
"""
|
||||
if not name or os.sep in name or (os.altsep and os.altsep in name):
|
||||
raise ValueError(f"invalid vault name: {name!r}")
|
||||
|
||||
vault_path = os.path.abspath(os.path.join(parent_dir, name))
|
||||
obsidian_dir = os.path.join(vault_path, ".obsidian")
|
||||
|
||||
if os.path.isdir(obsidian_dir):
|
||||
raise FileExistsError(f"vault already exists: {vault_path}")
|
||||
|
||||
os.makedirs(obsidian_dir, exist_ok=True)
|
||||
app_json = os.path.join(obsidian_dir, "app.json")
|
||||
with open(app_json, "w", encoding="utf-8") as handle:
|
||||
json.dump({}, handle)
|
||||
|
||||
return vault_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = create_obsidian_vault(tmp, "MyVault")
|
||||
assert os.path.isdir(path), path
|
||||
assert os.path.isfile(os.path.join(path, ".obsidian", "app.json"))
|
||||
with open(os.path.join(path, ".obsidian", "app.json")) as f:
|
||||
assert json.load(f) == {}
|
||||
|
||||
# Re-creating the same vault must fail.
|
||||
try:
|
||||
create_obsidian_vault(tmp, "MyVault")
|
||||
raise AssertionError("expected FileExistsError")
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# Invalid name must fail.
|
||||
try:
|
||||
create_obsidian_vault(tmp, "bad/name")
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: delete_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def delete_obsidian_note(path: str) -> bool"
|
||||
description: "Borra un archivo de nota Markdown de Obsidian del disco. Por seguridad solo borra un archivo concreto, nunca un directorio. No depende de la app GUI de Obsidian: opera directamente sobre el archivo .md plano."
|
||||
tags: [obsidian, markdown, delete, write, notes]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta al archivo .md de la nota a borrar"
|
||||
output: "True (bool) si el archivo fue borrado correctamente"
|
||||
tested: true
|
||||
tests:
|
||||
- "borra archivo existente y devuelve true"
|
||||
- "archivo inexistente lanza filenotfounderror"
|
||||
- "directorio lanza isadirectoryerror"
|
||||
- "no borra otros archivos"
|
||||
test_file_path: "python/functions/obsidian/delete_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/delete_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import delete_obsidian_note
|
||||
|
||||
ok = delete_obsidian_note("/home/me/vault/Inbox/Idea descartada.md")
|
||||
print(ok) # True
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras eliminar una nota concreta de un vault de Obsidian desde codigo o un agente: limpiar el Inbox, borrar notas generadas temporalmente, o eliminar una nota tras consolidar su contenido en otra. Para varias notas, llama una vez por archivo (no acepta directorios).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Borra del disco** (I/O impuro) y de forma **irreversible**: no manda a papelera, hace `os.remove`. El archivo no se puede recuperar salvo backup del vault.
|
||||
- **Solo archivos, nunca directorios**: lanza `IsADirectoryError` si el path apunta a una carpeta, para evitar borrados masivos accidentales.
|
||||
- **No respeta locks de la app GUI**: si Obsidian tiene la nota abierta, borrarla en disco dejara la pestana huerfana en el editor; al guardar desde Obsidian podria recrearse el archivo.
|
||||
- Lanza `FileNotFoundError` si el archivo no existe.
|
||||
- No borra archivos asociados (adjuntos, attachments referenciados): solo el `.md` indicado.
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Borra una nota de Obsidian (.md) del disco.
|
||||
|
||||
Funcion impura: elimina un archivo del sistema de archivos. Por seguridad solo
|
||||
borra archivos, nunca directorios.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def delete_obsidian_note(path: str) -> bool:
|
||||
"""Borra un archivo de nota Markdown de Obsidian.
|
||||
|
||||
Args:
|
||||
path: ruta al archivo .md a borrar.
|
||||
|
||||
Returns:
|
||||
True si el archivo fue borrado correctamente.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el archivo no existe.
|
||||
IsADirectoryError: si la ruta apunta a un directorio (nunca se borra un
|
||||
directorio, solo un archivo concreto).
|
||||
OSError: si el borrado falla por otro motivo de I/O.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"obsidian note not found: {path}")
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(
|
||||
f"refusing to delete a directory, only single files: {path}"
|
||||
)
|
||||
|
||||
os.remove(path)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
fd, tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
assert delete_obsidian_note(tmp) is True
|
||||
assert not os.path.exists(tmp)
|
||||
try:
|
||||
delete_obsidian_note(tmp)
|
||||
raise AssertionError("debio lanzar FileNotFoundError")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print("delete_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests para delete_obsidian_note."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from delete_obsidian_note import delete_obsidian_note
|
||||
|
||||
|
||||
def test_borra_archivo_existente_y_devuelve_true(tmp_path):
|
||||
# Golden path: borra un .md existente y confirma que desaparece.
|
||||
note = tmp_path / "Borrame.md"
|
||||
note.write_text("contenido", encoding="utf-8")
|
||||
|
||||
result = delete_obsidian_note(str(note))
|
||||
assert result is True
|
||||
assert not note.exists()
|
||||
|
||||
|
||||
def test_archivo_inexistente_lanza_filenotfounderror(tmp_path):
|
||||
# Error path: borrar algo que no existe.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_obsidian_note(str(tmp_path / "fantasma.md"))
|
||||
|
||||
|
||||
def test_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: se niega a borrar un directorio.
|
||||
sub = tmp_path / "carpeta"
|
||||
sub.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
delete_obsidian_note(str(sub))
|
||||
# El directorio sigue intacto tras el error.
|
||||
assert sub.is_dir()
|
||||
|
||||
|
||||
def test_no_borra_otros_archivos(tmp_path):
|
||||
# Edge: borrar una nota no afecta a las demas del vault.
|
||||
a = tmp_path / "A.md"
|
||||
b = tmp_path / "B.md"
|
||||
a.write_text("a", encoding="utf-8")
|
||||
b.write_text("b", encoding="utf-8")
|
||||
|
||||
assert delete_obsidian_note(str(a)) is True
|
||||
assert not a.exists()
|
||||
assert b.exists()
|
||||
assert os.path.isfile(str(b))
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: extract_obsidian_embeds
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def extract_obsidian_embeds(body: str) -> list"
|
||||
description: "Extrae SOLO los embeds ![[...]] (attachments incrustados: imagenes, pdf, otras notas) del cuerpo de una nota de Obsidian, ignorando los wikilinks normales [[...]]. Para cada embed devuelve el target tal cual (nombre de archivo), quitando alias (|...) y anclas (#...). Deduplica preservando orden de aparicion. Pura, sin I/O. Util para detectar que attachments arrastra una nota al migrar un subgrafo."
|
||||
tags: [obsidian, embed, attachment, image, markdown, extract, migrate]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["re"]
|
||||
params:
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de una nota de Obsidian (idealmente sin frontmatter). Puede mezclar wikilinks [[...]] y embeds ![[...]] con alias (|) o ancla (#)."
|
||||
output: "Lista de strings con los nombres de los attachments embebidos (el target de cada ![[...]]), unicos y en orden de aparicion. Solo embeds: los wikilinks normales [[...]] se ignoran. Lista vacia si no hay embeds."
|
||||
tested: true
|
||||
tests:
|
||||
- "solo embeds ignora wikilinks"
|
||||
- "varios embeds orden y dedup"
|
||||
- "quita alias y ancla"
|
||||
- "nombre con espacios y parentesis"
|
||||
- "sin embeds solo wikilinks"
|
||||
- "body vacio"
|
||||
test_file_path: "python/functions/obsidian/extract_obsidian_embeds_test.py"
|
||||
file_path: "python/functions/obsidian/extract_obsidian_embeds.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
body = (
|
||||
"Texto con [[Nota normal]] y un embed ![[imagen.jpg]]. "
|
||||
"Luego ![[doc.pdf]] y otra vez ![[imagen.jpg]]."
|
||||
)
|
||||
extract_obsidian_embeds(body)
|
||||
# ["imagen.jpg", "doc.pdf"]
|
||||
|
||||
extract_obsidian_embeds("![[dni enmanuel (2).jpg]]")
|
||||
# ["dni enmanuel (2).jpg"]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando migres o extraigas un subgrafo de notas y necesites saber QUE
|
||||
attachments hay que copiar junto a cada nota. A diferencia de
|
||||
`extract_obsidian_wikilinks` (que devuelve todos los enlaces, incluidos los
|
||||
embeds), esta funcion aisla solo los `![[...]]`, que son los archivos fisicos
|
||||
incrustados. Combinala con `resolve_obsidian_embed` para localizar el path real
|
||||
de cada attachment dentro del vault.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Devuelve el nombre del attachment tal cual aparece en el embed (p.ej.
|
||||
`dni enmanuel (2).jpg`), NO un path. Obsidian resuelve embeds por nombre de
|
||||
archivo unico; para obtener la ruta real usa `resolve_obsidian_embed`.
|
||||
- Quita deliberadamente el alias (`|300`, util para dimensionar imagenes) y el
|
||||
ancla (`#Seccion`, util para embeber un trozo de otra nota). Si necesitas esos
|
||||
modificadores, parsea el body tu mismo.
|
||||
- Solo reconoce embeds con doble corchete `![[...]]`. Los embeds Markdown
|
||||
estandar de imagen `` NO se detectan.
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Extrae los embeds ![[...]] (attachments incrustados) de una nota de Obsidian.
|
||||
|
||||
Funcion pura: solo procesa texto. A diferencia de extract_obsidian_wikilinks,
|
||||
ignora los wikilinks normales [[...]] y devuelve unicamente los embeds ![[...]].
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Matchea SOLO embeds: el '!' inicial es obligatorio (?<! evitaria capturar
|
||||
# un '[[' precedido de '!', pero aqui exigimos el '!' como parte del match).
|
||||
# Captura el contenido entre los dobles corchetes del embed.
|
||||
_EMBED_RE = re.compile(r"!\[\[([^\[\]]+?)\]\]")
|
||||
|
||||
|
||||
def extract_obsidian_embeds(body: str) -> list:
|
||||
"""Extrae SOLO los embeds ``![[...]]`` del cuerpo de una nota de Obsidian.
|
||||
|
||||
Un embed `![[archivo.jpg]]` incrusta un attachment (imagen, pdf, otra nota)
|
||||
dentro de la nota. Esta funcion devuelve el target de cada embed, mientras
|
||||
que los wikilinks normales `[[...]]` (que NO empiezan por `!`) se ignoran.
|
||||
|
||||
Para cada embed se normaliza el target:
|
||||
|
||||
![[imagen.jpg]] -> "imagen.jpg"
|
||||
![[imagen.jpg|300]] -> "imagen.jpg" (se quita el alias |...)
|
||||
![[nota#Seccion]] -> "nota" (se quita el ancla #...)
|
||||
![[dni enmanuel (2).jpg]] -> "dni enmanuel (2).jpg"
|
||||
|
||||
Los targets se deduplican preservando el orden de primera aparicion. Pura y
|
||||
deterministica: no hace I/O ni muta nada.
|
||||
|
||||
Args:
|
||||
body: Cuerpo Markdown de una nota de Obsidian (idealmente sin
|
||||
frontmatter).
|
||||
|
||||
Returns:
|
||||
Lista de strings con los nombres de los attachments embebidos, unicos y
|
||||
en orden de aparicion. Lista vacia si no hay embeds.
|
||||
"""
|
||||
if not body:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
targets = []
|
||||
for match in _EMBED_RE.finditer(body):
|
||||
inner = match.group(1)
|
||||
# Quitar alias tras el primer '|'.
|
||||
target = inner.split("|", 1)[0]
|
||||
# Quitar ancla/heading tras el primer '#'.
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.strip()
|
||||
if not target:
|
||||
continue
|
||||
if target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
body = (
|
||||
"Texto con [[Nota normal]] y un embed ![[imagen.jpg]]. "
|
||||
"Otra cosa [[Otra Nota|alias]] y ![[doc.pdf]] y ![[imagen.jpg]]."
|
||||
)
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg", "doc.pdf"], body
|
||||
assert extract_obsidian_embeds("") == []
|
||||
assert extract_obsidian_embeds("solo [[wikilink]] aqui") == []
|
||||
assert extract_obsidian_embeds("![[dni enmanuel (2).jpg]]") == ["dni enmanuel (2).jpg"]
|
||||
print("extract_obsidian_embeds smoke OK")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests para extract_obsidian_embeds."""
|
||||
|
||||
from extract_obsidian_embeds import extract_obsidian_embeds
|
||||
|
||||
|
||||
def test_solo_embeds_ignora_wikilinks():
|
||||
# Golden path: mezcla de [[Nota normal]] y ![[imagen.jpg]] -> solo el embed.
|
||||
body = "Texto con [[Nota normal]] y un embed ![[imagen.jpg]]."
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg"]
|
||||
|
||||
|
||||
def test_varios_embeds_orden_y_dedup():
|
||||
# Edge: varios embeds preservan orden y deduplican.
|
||||
body = "![[a.jpg]] luego ![[b.pdf]] luego ![[a.jpg]] y ![[c.png]]"
|
||||
assert extract_obsidian_embeds(body) == ["a.jpg", "b.pdf", "c.png"]
|
||||
|
||||
|
||||
def test_quita_alias_y_ancla():
|
||||
# Edge: alias (|) y heading/ancla (#) se descartan del target.
|
||||
body = "![[imagen.jpg|300]] y ![[nota#Seccion]]"
|
||||
assert extract_obsidian_embeds(body) == ["imagen.jpg", "nota"]
|
||||
|
||||
|
||||
def test_nombre_con_espacios_y_parentesis():
|
||||
# Edge: nombre de archivo real con espacios y parentesis se preserva.
|
||||
body = "Mira ![[dni enmanuel (2).jpg]] adjunto."
|
||||
assert extract_obsidian_embeds(body) == ["dni enmanuel (2).jpg"]
|
||||
|
||||
|
||||
def test_sin_embeds_solo_wikilinks():
|
||||
assert extract_obsidian_embeds("solo [[wikilink]] y [[otro|alias]]") == []
|
||||
|
||||
|
||||
def test_body_vacio():
|
||||
assert extract_obsidian_embeds("") == []
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: extract_obsidian_wikilinks
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def extract_obsidian_wikilinks(body: str) -> list"
|
||||
description: "Extrae los targets de los wikilinks [[...]] del cuerpo de una nota de Obsidian. Normaliza alias ([[nota|alias]] -> nota), heading ([[nota#h]] -> nota) y block id ([[nota#^id]] -> nota). Incluye tambien los embeds ![[...]] como links (Obsidian los trata como tales). Deduplica preservando orden de aparicion. Pura, sin I/O."
|
||||
tags: [obsidian, wikilink, links, markdown, extract, note, graph]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["re"]
|
||||
params:
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de una nota de Obsidian (idealmente sin frontmatter). Puede contener wikilinks [[...]] y embeds ![[...]] con alias (|), heading (#) o block id (#^)."
|
||||
output: "Lista de strings con los nombres de nota target unicos, en orden de primera aparicion. Cada target esta normalizado (sin alias, sin heading/block anchor, sin espacios al borde). Los embeds de imagen/nota se incluyen igual que los links normales. Lista vacia si no hay wikilinks."
|
||||
tested: true
|
||||
tests:
|
||||
- "links basicos y normalizacion"
|
||||
- "incluye embeds"
|
||||
- "dedup preserva orden"
|
||||
- "alias y heading combinados"
|
||||
- "whitespace se strippa"
|
||||
- "sin links"
|
||||
- "body vacio"
|
||||
test_file_path: "python/functions/obsidian/extract_obsidian_wikilinks_test.py"
|
||||
file_path: "python/functions/obsidian/extract_obsidian_wikilinks.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]. Embed: ![[diagram.png]]. Repeat [[Note A]]."
|
||||
)
|
||||
extract_obsidian_wikilinks(body)
|
||||
# ["Note A", "Note B", "Note C", "diagram.png"]
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para construir el grafo de enlaces de un vault (backlinks/forward-links),
|
||||
detectar notas huerfanas o referenciadas, o validar enlaces rotos antes de un
|
||||
refactor. Aplicala al `body` que devuelve `parse_obsidian_frontmatter` para
|
||||
ignorar wikilinks que pudieran aparecer dentro de valores YAML del frontmatter.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Los embeds `![[...]]` se incluyen como links (decision intencional: Obsidian
|
||||
los cuenta en el grafo). Si necesitas separar links de embeds, filtra por la
|
||||
extension del target o por el `!` aparte — esta funcion no distingue.
|
||||
- Solo normaliza al nombre de nota: pierde deliberadamente el alias, el heading
|
||||
y el block id. Si necesitas el anchor completo, parsea el body tu mismo.
|
||||
- No resuelve rutas relativas ni desambigua notas con el mismo nombre en
|
||||
carpetas distintas: devuelve el texto del link tal cual (sin la carpeta si el
|
||||
link no la incluye).
|
||||
- No procesa Markdown links estandar `[texto](url)` — solo wikilinks `[[...]]`.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Extract wikilink targets from the body of an Obsidian note."""
|
||||
|
||||
import re
|
||||
|
||||
# Matches both plain wikilinks [[...]] and embeds ![[...]].
|
||||
# The captured group is everything between the double brackets.
|
||||
_WIKILINK_RE = re.compile(r"!?\[\[([^\[\]]+?)\]\]")
|
||||
|
||||
|
||||
def extract_obsidian_wikilinks(body: str) -> list:
|
||||
"""Extract the note targets from the wikilinks in a note body.
|
||||
|
||||
Recognizes both plain links `[[...]]` and embeds `![[...]]` (Obsidian
|
||||
treats embeds as links too). Each target is normalized to the bare note
|
||||
name:
|
||||
|
||||
[[note|alias]] -> "note"
|
||||
[[note#heading]] -> "note"
|
||||
[[note#^blockid]] -> "note"
|
||||
[[note]] -> "note"
|
||||
![[image.png]] -> "image.png"
|
||||
|
||||
The alias (after `|`), the heading/block anchor (after `#`) and surrounding
|
||||
whitespace are stripped. Targets are deduplicated while preserving the
|
||||
order of first appearance. Pure and deterministic: no I/O, no mutation.
|
||||
|
||||
Args:
|
||||
body: The Markdown body of an Obsidian note (without frontmatter).
|
||||
|
||||
Returns:
|
||||
A list of unique target note names (strings), in order of appearance.
|
||||
"""
|
||||
if not body:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
targets = []
|
||||
for match in _WIKILINK_RE.finditer(body):
|
||||
inner = match.group(1)
|
||||
# Drop the alias part after the first pipe.
|
||||
target = inner.split("|", 1)[0]
|
||||
# Drop the heading / block anchor after the first hash.
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.strip()
|
||||
if not target:
|
||||
continue
|
||||
if target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]. Embed: ![[diagram.png]]. Repeat [[Note A]]."
|
||||
)
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "Note B", "Note C", "diagram.png"], links
|
||||
|
||||
assert extract_obsidian_wikilinks("") == []
|
||||
assert extract_obsidian_wikilinks("no links here") == []
|
||||
assert extract_obsidian_wikilinks("[[ spaced |alias]]") == ["spaced"]
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests para extract_obsidian_wikilinks."""
|
||||
|
||||
from extract_obsidian_wikilinks import extract_obsidian_wikilinks
|
||||
|
||||
|
||||
def test_links_basicos_y_normalizacion():
|
||||
body = (
|
||||
"See [[Note A]] and [[Note B|the second]] plus [[Note A#Section]] "
|
||||
"and [[Note C#^block123]]."
|
||||
)
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "Note B", "Note C"]
|
||||
|
||||
|
||||
def test_incluye_embeds():
|
||||
body = "Text [[Note A]] and embed ![[diagram.png]] and ![[Note D]]."
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note A", "diagram.png", "Note D"]
|
||||
|
||||
|
||||
def test_dedup_preserva_orden():
|
||||
body = "[[Z]] [[A]] [[Z]] [[A|alias]] [[B]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Z", "A", "B"]
|
||||
|
||||
|
||||
def test_alias_y_heading_combinados():
|
||||
body = "[[Note E#Heading|Custom Alias]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["Note E"]
|
||||
|
||||
|
||||
def test_whitespace_se_strippa():
|
||||
body = "[[ spaced note |alias]] and [[ tight ]]"
|
||||
links = extract_obsidian_wikilinks(body)
|
||||
assert links == ["spaced note", "tight"]
|
||||
|
||||
|
||||
def test_sin_links():
|
||||
assert extract_obsidian_wikilinks("no links here") == []
|
||||
|
||||
|
||||
def test_body_vacio():
|
||||
assert extract_obsidian_wikilinks("") == []
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: format_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def format_obsidian_note(frontmatter: dict, body: str) -> str"
|
||||
description: "Serializa una nota completa de Obsidian a partir de un dict de frontmatter y un body. Si frontmatter no esta vacio, emite '---\\n<yaml>---\\n\\n<body>' usando yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True). Si frontmatter es vacio o None, devuelve solo el body. Es la inversa de parse_obsidian_frontmatter para un round-trip razonable. Pura, sin I/O."
|
||||
tags: [obsidian, frontmatter, yaml, markdown, format, serialize, note]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["yaml"]
|
||||
params:
|
||||
- name: frontmatter
|
||||
desc: "Dict con los metadatos YAML de la nota. Vacio o None significa que no se emite bloque frontmatter (solo el body). El orden de las claves se preserva (sort_keys=False)."
|
||||
- name: body
|
||||
desc: "Cuerpo Markdown de la nota. None se trata como cadena vacia."
|
||||
output: "String con el texto completo de la nota. Con frontmatter: '---\\n<yaml>---\\n\\n<body>' (unicode literal, orden de claves preservado). Sin frontmatter: solo el body."
|
||||
tested: true
|
||||
tests:
|
||||
- "con frontmatter"
|
||||
- "frontmatter vacio devuelve body"
|
||||
- "frontmatter none devuelve body"
|
||||
- "preserva orden de claves"
|
||||
- "unicode literal"
|
||||
- "round trip con parse"
|
||||
test_file_path: "python/functions/obsidian/format_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/format_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
# "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello."
|
||||
|
||||
format_obsidian_note({}, "just a body")
|
||||
# "just a body"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como ultimo paso del round-trip parse -> modificar -> format: tras leer
|
||||
una nota con `parse_obsidian_frontmatter`, mutar el dict de frontmatter (anadir
|
||||
un tag, cambiar status, actualizar una fecha) y volver a serializar la nota
|
||||
lista para escribir a disco. Tambien para generar notas nuevas desde cero con
|
||||
metadatos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Inserta una linea en blanco entre el bloque frontmatter y el body (`---\n\n`).
|
||||
En el round-trip con `parse_obsidian_frontmatter`, el body recuperado lleva
|
||||
un salto de linea inicial extra; comparar con `.strip()` o asumir ese
|
||||
separador. La inversa es "razonable", no byte-a-byte.
|
||||
- `yaml.safe_dump` reformatea el YAML a su estilo canonico: las listas salen en
|
||||
estilo bloque sin indentar (`tags:\n- a`), las claves no se reordenan
|
||||
(`sort_keys=False`) y el unicode queda literal (`allow_unicode=True`). El
|
||||
texto exacto del frontmatter original puede no preservarse aunque el mapping
|
||||
si.
|
||||
- Un dict vacio o `None` produce solo el body (sin `---`), coherente con que
|
||||
`parse_obsidian_frontmatter` trate un frontmatter vacio como ausente.
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Serialize an Obsidian note from a frontmatter mapping and a body."""
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def format_obsidian_note(frontmatter: dict, body: str) -> str:
|
||||
"""Serialize a complete Obsidian note from frontmatter and body.
|
||||
|
||||
When `frontmatter` is a non-empty mapping, the result is:
|
||||
|
||||
---\n<yaml>---\n\n<body>
|
||||
|
||||
where `<yaml>` is produced by
|
||||
`yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)` (which
|
||||
already ends in a newline). When `frontmatter` is empty or None, only the
|
||||
body is returned. This is the inverse of `parse_obsidian_frontmatter` for a
|
||||
reasonable round-trip (key order preserved, unicode kept literal).
|
||||
|
||||
Pure and deterministic: no I/O, no mutation of the inputs.
|
||||
|
||||
Args:
|
||||
frontmatter: Mapping of YAML metadata for the note. Empty or None means
|
||||
no frontmatter block is emitted.
|
||||
body: The Markdown body of the note.
|
||||
|
||||
Returns:
|
||||
The full note text as a string.
|
||||
"""
|
||||
safe_body = body if body is not None else ""
|
||||
|
||||
if not frontmatter:
|
||||
return safe_body
|
||||
|
||||
yaml_block = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)
|
||||
# yaml.safe_dump already terminates with a trailing newline.
|
||||
return f"---\n{yaml_block}---\n\n{safe_body}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
assert out == "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello.", repr(out)
|
||||
|
||||
# Empty frontmatter -> body only.
|
||||
assert format_obsidian_note({}, "just a body") == "just a body"
|
||||
assert format_obsidian_note(None, "just a body") == "just a body"
|
||||
|
||||
# Round-trip with parse_obsidian_frontmatter: the frontmatter mapping is
|
||||
# recovered exactly; the body is recovered modulo the blank separator line
|
||||
# that the format inserts between the frontmatter block and the body.
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
fm = {"title": "Round Trip", "status": "open"}
|
||||
body = "Body with a [[link]]."
|
||||
note = format_obsidian_note(fm, body)
|
||||
parsed = parse_obsidian_frontmatter(note)
|
||||
assert parsed["frontmatter"] == fm, parsed
|
||||
assert parsed["body"].strip() == body, repr(parsed["body"])
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests para format_obsidian_note."""
|
||||
|
||||
from format_obsidian_note import format_obsidian_note
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def test_con_frontmatter():
|
||||
out = format_obsidian_note({"title": "My Note", "tags": ["a", "b"]}, "Hello.")
|
||||
assert out == "---\ntitle: My Note\ntags:\n- a\n- b\n---\n\nHello."
|
||||
|
||||
|
||||
def test_frontmatter_vacio_devuelve_body():
|
||||
assert format_obsidian_note({}, "just a body") == "just a body"
|
||||
|
||||
|
||||
def test_frontmatter_none_devuelve_body():
|
||||
assert format_obsidian_note(None, "just a body") == "just a body"
|
||||
|
||||
|
||||
def test_preserva_orden_de_claves():
|
||||
fm = {"zeta": 1, "alpha": 2, "mid": 3}
|
||||
out = format_obsidian_note(fm, "body")
|
||||
# sort_keys=False -> insertion order preserved.
|
||||
assert out.index("zeta") < out.index("alpha") < out.index("mid")
|
||||
|
||||
|
||||
def test_unicode_literal():
|
||||
out = format_obsidian_note({"title": "Año Nuevo"}, "Cuerpo con ñ.")
|
||||
assert "Año Nuevo" in out
|
||||
assert "Cuerpo con ñ." in out
|
||||
|
||||
|
||||
def test_round_trip_con_parse():
|
||||
fm = {"title": "Round Trip", "status": "open", "count": 3}
|
||||
body = "Body with a [[link]] and #tag."
|
||||
note = format_obsidian_note(fm, body)
|
||||
parsed = parse_obsidian_frontmatter(note)
|
||||
assert parsed["frontmatter"] == fm
|
||||
# The format inserts a blank separator line before the body.
|
||||
assert parsed["body"].strip() == body
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: list_obsidian_notes
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_obsidian_notes(vault_dir: str, subfolder: str = \"\", tag: str = \"\") -> list"
|
||||
description: "Recorre recursivamente un vault de Obsidian (o un subfolder) y devuelve los paths absolutos de todas las notas .md, ordenados. Excluye siempre .obsidian/ y .trash/. Si se da tag, filtra a las notas cuyo frontmatter tags lo contenga (usa parse_obsidian_frontmatter)."
|
||||
tags: [obsidian, notes, list, vault, frontmatter, filesystem]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "path (absoluto o relativo) a la raiz del vault de Obsidian"
|
||||
- name: subfolder
|
||||
desc: "subcarpeta relativa dentro del vault a la que restringir el recorrido; vacio recorre todo el vault"
|
||||
- name: tag
|
||||
desc: "tag opcional; si no es vacio, solo devuelve notas cuyo frontmatter tags lo contenga (lista o escalar)"
|
||||
output: "lista ordenada de paths absolutos a las notas .md que cumplen el filtro"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/list_obsidian_notes.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import list_obsidian_notes
|
||||
|
||||
# Todas las notas de un vault real
|
||||
notas = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas")
|
||||
print(len(notas), notas[:3])
|
||||
|
||||
# Solo las notas etiquetadas con #inversion
|
||||
inversion = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", tag="inversion")
|
||||
|
||||
# Restringir a una subcarpeta del vault
|
||||
plantillas = list_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", subfolder="Plantillas")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las notas de un vault de Obsidian (para indexar, exportar, contar o alimentar otra funcion) o filtrar por tag sin abrir Obsidian. Es el punto de entrada antes de leer/buscar contenido nota a nota.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `FileNotFoundError` si la ruta no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Exclusion obligatoria**: `.obsidian/` y `.trash/` se podan del `os.walk` y nunca aparecen en el resultado; los `.md` internos de la config de Obsidian no se listan.
|
||||
- **Coste en vaults grandes**: recorre todo el arbol. En vaults pesados como `NotasDeObsidian` (~554M) el `os.walk` completo es costoso; usa `subfolder` para acotar cuando puedas.
|
||||
- **Filtro por tag**: cuando `tag` esta dado, ABRE cada nota para parsear su frontmatter — multiplica el coste de I/O. El campo `tags` se acepta como lista (`[a, b]`) o escalar (`a`); notas sin frontmatter o sin el tag se excluyen.
|
||||
- Las notas con encoding invalido se leen con `errors="replace"`; el frontmatter ilegible se trata como ausente (la nota se excluye del filtro por tag).
|
||||
@@ -0,0 +1,111 @@
|
||||
"""List the Markdown notes inside an Obsidian vault, optionally filtered by tag."""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import parse_obsidian_frontmatter
|
||||
|
||||
# Directories that are part of Obsidian's machinery, never user notes.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def list_obsidian_notes(vault_dir: str, subfolder: str = "", tag: str = "") -> list:
|
||||
"""Return the absolute paths of every Markdown note under an Obsidian vault.
|
||||
|
||||
Walks ``vault_dir`` (or ``vault_dir/subfolder`` when ``subfolder`` is given)
|
||||
recursively and collects every ``.md`` file. The ``.obsidian/`` and
|
||||
``.trash/`` directories are always pruned from the walk so their internal
|
||||
files never appear in the result.
|
||||
|
||||
When ``tag`` is provided, only notes whose frontmatter ``tags`` field
|
||||
contains that tag are kept. The frontmatter is read with
|
||||
``parse_obsidian_frontmatter`` (the registry's pure parser). The ``tags``
|
||||
field may be a list (``[a, b]``) or a single scalar (``a``); both forms are
|
||||
handled. Notes without frontmatter or without the tag are excluded.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``FileNotFoundError`` if the root
|
||||
directory does not exist and ``NotADirectoryError`` if it is not a directory.
|
||||
|
||||
Args:
|
||||
vault_dir: Absolute or relative path to the vault root.
|
||||
subfolder: Optional relative subfolder inside the vault to restrict the
|
||||
walk to. Empty string walks the whole vault.
|
||||
tag: Optional tag. When non-empty, only notes whose frontmatter ``tags``
|
||||
contains it are returned.
|
||||
|
||||
Returns:
|
||||
A sorted list of absolute paths to the matching ``.md`` notes.
|
||||
"""
|
||||
root = os.path.join(vault_dir, subfolder) if subfolder else vault_dir
|
||||
root = os.path.abspath(root)
|
||||
|
||||
if not os.path.exists(root):
|
||||
raise FileNotFoundError(f"vault path does not exist: {root}")
|
||||
if not os.path.isdir(root):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {root}")
|
||||
|
||||
notes: list[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# Prune Obsidian machinery in-place so os.walk never descends into them.
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
if not filename.lower().endswith(".md"):
|
||||
continue
|
||||
full = os.path.abspath(os.path.join(dirpath, filename))
|
||||
if not tag:
|
||||
notes.append(full)
|
||||
continue
|
||||
if _note_has_tag(full, tag):
|
||||
notes.append(full)
|
||||
|
||||
return sorted(notes)
|
||||
|
||||
|
||||
def _note_has_tag(note_path: str, tag: str) -> bool:
|
||||
"""Return True if the note's frontmatter ``tags`` contains ``tag``."""
|
||||
try:
|
||||
with open(note_path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
content = handle.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
frontmatter = parse_obsidian_frontmatter(content).get("frontmatter", {})
|
||||
tags = frontmatter.get("tags")
|
||||
if tags is None:
|
||||
return False
|
||||
if isinstance(tags, str):
|
||||
return tags == tag
|
||||
if isinstance(tags, (list, tuple, set)):
|
||||
return tag in tags
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "sub"))
|
||||
# A note in .obsidian must never be listed.
|
||||
with open(os.path.join(tmp, ".obsidian", "ignored.md"), "w") as f:
|
||||
f.write("should be excluded")
|
||||
with open(os.path.join(tmp, "a.md"), "w") as f:
|
||||
f.write("---\ntags:\n - work\n - todo\n---\nbody A")
|
||||
with open(os.path.join(tmp, "sub", "b.md"), "w") as f:
|
||||
f.write("---\ntags: personal\n---\nbody B")
|
||||
with open(os.path.join(tmp, "c.md"), "w") as f:
|
||||
f.write("no frontmatter")
|
||||
|
||||
all_notes = list_obsidian_notes(tmp)
|
||||
assert len(all_notes) == 3, all_notes
|
||||
assert all(".obsidian" not in p for p in all_notes), all_notes
|
||||
|
||||
work = list_obsidian_notes(tmp, tag="work")
|
||||
assert len(work) == 1 and work[0].endswith("a.md"), work
|
||||
|
||||
personal = list_obsidian_notes(tmp, tag="personal")
|
||||
assert len(personal) == 1 and personal[0].endswith("b.md"), personal
|
||||
|
||||
only_sub = list_obsidian_notes(tmp, subfolder="sub")
|
||||
assert len(only_sub) == 1 and only_sub[0].endswith("b.md"), only_sub
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: list_obsidian_vaults
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_obsidian_vaults(base_dir: str) -> list"
|
||||
description: "Devuelve los vaults de Obsidian que cuelgan directamente (1 nivel) de base_dir: subdirectorios que contienen un .obsidian/. Devuelve lista de {name, path} ordenada por name. Util para enumerar /home/enmanuel/Obsidian/."
|
||||
tags: [obsidian, vault, list, discover, filesystem]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: base_dir
|
||||
desc: "directorio que contiene vaults de Obsidian como subcarpetas (p.ej. /home/enmanuel/Obsidian)"
|
||||
output: "lista de dicts {name, path} (uno por vault), ordenada por name; path es absoluto"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/list_obsidian_vaults.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import list_obsidian_vaults
|
||||
|
||||
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
|
||||
for v in vaults:
|
||||
print(v["name"], "->", v["path"])
|
||||
# NotasDeObsidian, AurgiObsidian, DataScientist, Finanzas, LLM_agentes
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites descubrir que vaults de Obsidian existen bajo una carpeta raiz (por ejemplo para construir un selector, iterar sobre todos los vaults, o validar que un nombre de vault existe) sin abrir Obsidian.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `FileNotFoundError` si `base_dir` no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Solo 1 nivel**: inspecciona unicamente los hijos inmediatos de `base_dir`; no es recursivo, asi que un vault anidado dentro de otro vault no se detecta. Esto es intencional para evitar recorrer arboles pesados.
|
||||
- **Criterio de vault**: una carpeta cuenta como vault solo si contiene un subdirectorio `.obsidian/` (la carpeta de config que crea Obsidian). Carpetas con solo notas `.md` pero sin `.obsidian/` no se consideran vaults.
|
||||
- No abre las notas ni recorre su contenido, asi que es barato incluso cuando algun vault es enorme (`NotasDeObsidian` ~554M): solo mira la presencia de `.obsidian/`.
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Discover the Obsidian vaults that live directly under a base directory."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def list_obsidian_vaults(base_dir: str) -> list:
|
||||
"""Return the Obsidian vaults found one level below ``base_dir``.
|
||||
|
||||
A directory is considered an Obsidian vault when it contains an
|
||||
``.obsidian/`` subdirectory (the folder Obsidian creates to hold its
|
||||
per-vault configuration). Only the immediate children of ``base_dir`` are
|
||||
inspected; the search is not recursive, so nested vaults inside a vault are
|
||||
not reported.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``FileNotFoundError`` if
|
||||
``base_dir`` does not exist and ``NotADirectoryError`` if it is not a
|
||||
directory.
|
||||
|
||||
Args:
|
||||
base_dir: Directory that holds Obsidian vaults as subfolders, e.g.
|
||||
``/home/enmanuel/Obsidian``.
|
||||
|
||||
Returns:
|
||||
A list of dicts ``{"name": str, "path": str}`` (one per vault), sorted
|
||||
by name. ``path`` is the absolute path of the vault directory.
|
||||
"""
|
||||
base = os.path.abspath(base_dir)
|
||||
if not os.path.exists(base):
|
||||
raise FileNotFoundError(f"base directory does not exist: {base}")
|
||||
if not os.path.isdir(base):
|
||||
raise NotADirectoryError(f"base path is not a directory: {base}")
|
||||
|
||||
vaults: list[dict] = []
|
||||
for entry in os.scandir(base):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if os.path.isdir(os.path.join(entry.path, ".obsidian")):
|
||||
vaults.append({"name": entry.name, "path": os.path.abspath(entry.path)})
|
||||
|
||||
vaults.sort(key=lambda v: v["name"])
|
||||
return vaults
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Two real vaults, one plain dir, one file.
|
||||
os.makedirs(os.path.join(tmp, "VaultA", ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "VaultB", ".obsidian"))
|
||||
os.makedirs(os.path.join(tmp, "NotAVault"))
|
||||
with open(os.path.join(tmp, "loose.md"), "w") as f:
|
||||
f.write("not a dir")
|
||||
|
||||
vaults = list_obsidian_vaults(tmp)
|
||||
names = [v["name"] for v in vaults]
|
||||
assert names == ["VaultA", "VaultB"], names
|
||||
assert all(os.path.isabs(v["path"]) for v in vaults), vaults
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: parse_obsidian_frontmatter
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def parse_obsidian_frontmatter(content: str) -> dict"
|
||||
description: "Separa una nota de Obsidian (Markdown plano) en su frontmatter YAML y su cuerpo. Parsea el bloque YAML delimitado por --- al inicio del archivo con yaml.safe_load. Si no hay frontmatter valido al inicio, devuelve frontmatter vacio y el contenido completo como body. Soporta finales de linea \\n y \\r\\n. Pura, sin I/O."
|
||||
tags: [obsidian, frontmatter, yaml, markdown, parse, note]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["yaml"]
|
||||
params:
|
||||
- name: content
|
||||
desc: "Texto completo de una nota de Obsidian/Markdown. El frontmatter, si existe, debe ser un bloque YAML delimitado por lineas '---' que empieza en la primera linea del archivo."
|
||||
output: "Dict con dos claves: 'frontmatter' (dict con el mapping YAML parseado, o {} si no hay frontmatter valido) y 'body' (str con el cuerpo de la nota tras el bloque frontmatter, o el contenido completo cuando no hay frontmatter valido)."
|
||||
tested: true
|
||||
tests:
|
||||
- "frontmatter basico"
|
||||
- "crlf line endings"
|
||||
- "sin frontmatter devuelve content completo"
|
||||
- "frontmatter sin cierre es body"
|
||||
- "frontmatter vacio"
|
||||
- "yaml invalido es body"
|
||||
- "content vacio"
|
||||
test_file_path: "python/functions/obsidian/parse_obsidian_frontmatter_test.py"
|
||||
file_path: "python/functions/obsidian/parse_obsidian_frontmatter.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
# {
|
||||
# "frontmatter": {"title": "My Note", "tags": ["a", "b"]},
|
||||
# "body": "\nHello [[other]].",
|
||||
# }
|
||||
|
||||
plain = "just a body, no frontmatter"
|
||||
parse_obsidian_frontmatter(plain)
|
||||
# {"frontmatter": {}, "body": "just a body, no frontmatter"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala al leer una nota de Obsidian desde disco cuando necesites acceder a sus
|
||||
metadatos YAML (tags, aliases, status, fechas) por separado del texto, o antes
|
||||
de modificar el frontmatter y volver a serializar con `format_obsidian_note`.
|
||||
Es el primer paso del round-trip parse -> modificar -> format.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Un bloque frontmatter vacio (`---\n---`) parsea a `None` en YAML, que no es
|
||||
un dict, por lo que se trata como "sin frontmatter" y el contenido completo
|
||||
vuelve como body. Esto es intencional para mantener la inversa con
|
||||
`format_obsidian_note` (que omite frontmatter vacio).
|
||||
- El `---` de apertura debe estar en la primera linea exacta del contenido. Un
|
||||
`---` precedido de lineas en blanco o texto NO se considera frontmatter.
|
||||
- YAML invalido se trata como "sin frontmatter": devuelve el contenido como
|
||||
body en lugar de lanzar excepcion (funcion pura, sin error_type).
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Parse the YAML frontmatter of an Obsidian note treated as plain Markdown."""
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def parse_obsidian_frontmatter(content: str) -> dict:
|
||||
"""Split an Obsidian note into its YAML frontmatter and body.
|
||||
|
||||
The frontmatter is the YAML block delimited by `---` lines at the very
|
||||
start of the file. It is parsed with `yaml.safe_load`. If there is no
|
||||
valid frontmatter block at the start of the content (no leading `---`,
|
||||
no closing `---`, or the YAML does not parse into a mapping), the whole
|
||||
content is returned as the body and the frontmatter is an empty dict.
|
||||
|
||||
Supports both `\\n` and `\\r\\n` line endings. Pure and deterministic:
|
||||
no I/O, no mutation of the input.
|
||||
|
||||
Args:
|
||||
content: Full text of an Obsidian/Markdown note.
|
||||
|
||||
Returns:
|
||||
A dict with two keys:
|
||||
- "frontmatter": the parsed YAML mapping (dict), or {} if absent.
|
||||
- "body": the note body after the frontmatter block, or the full
|
||||
content when there is no valid frontmatter.
|
||||
"""
|
||||
if not content:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
# Normalize line endings for splitting without mutating the original body.
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
|
||||
# Frontmatter must start on the very first line with an exact `---`.
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
# Find the closing `---` delimiter.
|
||||
closing_index = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
closing_index = i
|
||||
break
|
||||
|
||||
if closing_index is None:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
yaml_block = "\n".join(lines[1:closing_index])
|
||||
body = "\n".join(lines[closing_index + 1:])
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_block)
|
||||
except yaml.YAMLError:
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return {"frontmatter": {}, "body": content}
|
||||
|
||||
return {"frontmatter": parsed, "body": body}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
assert result["frontmatter"] == {"title": "My Note", "tags": ["a", "b"]}
|
||||
assert result["body"] == "\nHello [[other]]."
|
||||
|
||||
# CRLF line endings.
|
||||
crlf = "---\r\ntitle: X\r\n---\r\nbody line"
|
||||
assert parse_obsidian_frontmatter(crlf)["frontmatter"] == {"title": "X"}
|
||||
|
||||
# No frontmatter -> body is the full content.
|
||||
plain = "just a body, no frontmatter"
|
||||
assert parse_obsidian_frontmatter(plain) == {"frontmatter": {}, "body": plain}
|
||||
|
||||
# Unterminated frontmatter -> treated as plain body.
|
||||
broken = "---\ntitle: X\nno closing delimiter"
|
||||
assert parse_obsidian_frontmatter(broken) == {"frontmatter": {}, "body": broken}
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests para parse_obsidian_frontmatter."""
|
||||
|
||||
from parse_obsidian_frontmatter import parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_basico():
|
||||
note = "---\ntitle: My Note\ntags:\n - a\n - b\n---\n\nHello [[other]]."
|
||||
result = parse_obsidian_frontmatter(note)
|
||||
assert result["frontmatter"] == {"title": "My Note", "tags": ["a", "b"]}
|
||||
assert result["body"] == "\nHello [[other]]."
|
||||
|
||||
|
||||
def test_crlf_line_endings():
|
||||
crlf = "---\r\ntitle: X\r\nstatus: done\r\n---\r\nbody line\r\nsecond line"
|
||||
result = parse_obsidian_frontmatter(crlf)
|
||||
assert result["frontmatter"] == {"title": "X", "status": "done"}
|
||||
assert "body line" in result["body"]
|
||||
|
||||
|
||||
def test_sin_frontmatter_devuelve_content_completo():
|
||||
plain = "just a body, no frontmatter\nwith two lines"
|
||||
result = parse_obsidian_frontmatter(plain)
|
||||
assert result == {"frontmatter": {}, "body": plain}
|
||||
|
||||
|
||||
def test_frontmatter_sin_cierre_es_body():
|
||||
broken = "---\ntitle: X\nno closing delimiter\nmore text"
|
||||
result = parse_obsidian_frontmatter(broken)
|
||||
assert result == {"frontmatter": {}, "body": broken}
|
||||
|
||||
|
||||
def test_frontmatter_vacio():
|
||||
empty = "---\n---\nbody after empty frontmatter"
|
||||
result = parse_obsidian_frontmatter(empty)
|
||||
# An empty YAML block parses to None (not a dict) -> treated as no frontmatter.
|
||||
assert result == {"frontmatter": {}, "body": empty}
|
||||
|
||||
|
||||
def test_yaml_invalido_es_body():
|
||||
bad = "---\nthis: is: invalid: yaml: ::\n---\nbody"
|
||||
result = parse_obsidian_frontmatter(bad)
|
||||
assert result == {"frontmatter": {}, "body": bad}
|
||||
|
||||
|
||||
def test_content_vacio():
|
||||
result = parse_obsidian_frontmatter("")
|
||||
assert result == {"frontmatter": {}, "body": ""}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: read_obsidian_note
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def read_obsidian_note(path: str) -> dict"
|
||||
description: "Lee una nota Markdown de Obsidian desde disco y la descompone en frontmatter YAML, body, wikilinks [[...]] y tags normalizados. Compone las funciones puras parse_obsidian_frontmatter y extract_obsidian_wikilinks. No depende de la app GUI de Obsidian: solo lee el archivo .md plano."
|
||||
tags: [obsidian, markdown, frontmatter, wikilinks, read, notes]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian", "extract_obsidian_wikilinks_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta al archivo .md de la nota a leer"
|
||||
output: "dict con path (str), frontmatter (dict), body (str), wikilinks (list de destinos [[...]]) y tags (list normalizada desde frontmatter['tags'], acepta CSV o lista)"
|
||||
tested: true
|
||||
tests:
|
||||
- "lee nota con frontmatter y wikilinks"
|
||||
- "normaliza tags csv a lista"
|
||||
- "nota sin frontmatter"
|
||||
- "archivo inexistente lanza filenotfounderror"
|
||||
- "directorio lanza isadirectoryerror"
|
||||
test_file_path: "python/functions/obsidian/read_obsidian_note_test.py"
|
||||
file_path: "python/functions/obsidian/read_obsidian_note.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import read_obsidian_note
|
||||
|
||||
note = read_obsidian_note("/home/me/vault/Proyectos/Idea.md")
|
||||
print(note["frontmatter"]) # {'title': 'Idea', 'tags': ['proyecto', 'wip']}
|
||||
print(note["tags"]) # ['proyecto', 'wip']
|
||||
print(note["wikilinks"]) # ['Nota Relacionada', 'Otra Idea']
|
||||
print(note["body"][:80]) # primeras lineas del cuerpo Markdown
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites cargar el contenido de una nota de Obsidian de forma estructurada: leer su frontmatter, su cuerpo, los wikilinks que apunta o sus tags. Es el primer paso natural antes de actualizar una nota (`update_obsidian_note`) o de construir un grafo de enlaces a partir de los `wikilinks`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Lee de disco** (I/O impuro): el resultado refleja el estado del archivo en ese instante.
|
||||
- **No respeta locks de la app GUI**: si Obsidian esta abierto y tiene la nota con cambios sin guardar, leeras la version persistida en disco, no la del editor en memoria.
|
||||
- Lanza `FileNotFoundError` si el path no existe e `IsADirectoryError` si apunta a un directorio.
|
||||
- `tags` se normaliza siempre a lista: acepta tanto `tags: proyecto, wip` (CSV) como `tags: [proyecto, wip]` (lista YAML). Otros tipos se convierten a string en un unico tag.
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Lee una nota de Obsidian (.md) desde disco y la descompone en sus partes.
|
||||
|
||||
Compone funciones puras del grupo obsidian: parse_obsidian_frontmatter y
|
||||
extract_obsidian_wikilinks. Funcion impura: hace I/O de lectura de archivo.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import extract_obsidian_wikilinks, parse_obsidian_frontmatter
|
||||
|
||||
|
||||
def _normalize_tags(raw) -> list:
|
||||
"""Normaliza el campo tags del frontmatter a una lista de strings.
|
||||
|
||||
Acepta None, un string CSV ("a, b, c") o una lista. Devuelve siempre
|
||||
una lista de strings sin espacios sobrantes y sin entradas vacias.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, str):
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(t).strip() for t in raw if str(t).strip()]
|
||||
# Cualquier otro tipo (int, etc.) -> representacion como unico tag.
|
||||
return [str(raw).strip()] if str(raw).strip() else []
|
||||
|
||||
|
||||
def read_obsidian_note(path: str) -> dict:
|
||||
"""Lee una nota Markdown de Obsidian y devuelve sus partes estructuradas.
|
||||
|
||||
Args:
|
||||
path: ruta al archivo .md a leer.
|
||||
|
||||
Returns:
|
||||
dict con las claves:
|
||||
- path: la ruta leida (tal cual fue pasada).
|
||||
- frontmatter: dict con el frontmatter YAML parseado.
|
||||
- body: str con el cuerpo Markdown sin el frontmatter.
|
||||
- wikilinks: list con los destinos de los wikilinks [[...]] del body.
|
||||
- tags: list normalizada a partir de frontmatter["tags"].
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el archivo no existe.
|
||||
IsADirectoryError: si la ruta apunta a un directorio.
|
||||
OSError: si la lectura falla por otro motivo de I/O.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"obsidian note not found: {path}")
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(f"expected a file, got a directory: {path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
parsed = parse_obsidian_frontmatter(content)
|
||||
frontmatter = parsed.get("frontmatter", {}) or {}
|
||||
body = parsed.get("body", "") or ""
|
||||
|
||||
wikilinks = extract_obsidian_wikilinks(body)
|
||||
tags = _normalize_tags(frontmatter.get("tags"))
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body,
|
||||
"wikilinks": wikilinks,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
sample = "---\ntitle: Demo\ntags: [a, b]\n---\n\nHola [[Otra Nota]] y [[Tercera]].\n"
|
||||
fd, tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(sample)
|
||||
note = read_obsidian_note(tmp)
|
||||
assert note["frontmatter"].get("title") == "Demo", note["frontmatter"]
|
||||
assert note["tags"] == ["a", "b"], note["tags"]
|
||||
assert "Otra Nota" in note["wikilinks"], note["wikilinks"]
|
||||
os.remove(tmp)
|
||||
print("read_obsidian_note smoke OK")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests para read_obsidian_note."""
|
||||
|
||||
import pytest
|
||||
|
||||
from read_obsidian_note import read_obsidian_note
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_lee_nota_con_frontmatter_y_wikilinks(tmp_path):
|
||||
# Golden path: nota con frontmatter YAML + body con wikilinks.
|
||||
note = _write(
|
||||
tmp_path / "Idea.md",
|
||||
"---\ntitle: Idea\ntags: [proyecto, wip]\n---\n\n"
|
||||
"Cuerpo con [[Otra Nota]] y [[Tercera|alias]].",
|
||||
)
|
||||
result = read_obsidian_note(note)
|
||||
|
||||
assert result["path"] == note
|
||||
assert result["frontmatter"]["title"] == "Idea"
|
||||
assert result["tags"] == ["proyecto", "wip"]
|
||||
assert result["wikilinks"] == ["Otra Nota", "Tercera"]
|
||||
assert "Cuerpo con" in result["body"]
|
||||
|
||||
|
||||
def test_normaliza_tags_csv_a_lista(tmp_path):
|
||||
# Edge: tags como CSV en vez de lista YAML.
|
||||
note = _write(
|
||||
tmp_path / "CSV.md",
|
||||
"---\ntitle: CSV\ntags: proyecto, wip , done\n---\n\nTexto.",
|
||||
)
|
||||
result = read_obsidian_note(note)
|
||||
assert result["tags"] == ["proyecto", "wip", "done"]
|
||||
|
||||
|
||||
def test_nota_sin_frontmatter(tmp_path):
|
||||
# Edge: nota plana sin frontmatter -> frontmatter vacio, tags vacios.
|
||||
note = _write(tmp_path / "Plana.md", "Solo cuerpo, [[Enlace]] suelto.")
|
||||
result = read_obsidian_note(note)
|
||||
assert result["frontmatter"] == {}
|
||||
assert result["tags"] == []
|
||||
assert result["wikilinks"] == ["Enlace"]
|
||||
assert result["body"] == "Solo cuerpo, [[Enlace]] suelto."
|
||||
|
||||
|
||||
def test_archivo_inexistente_lanza_filenotfounderror(tmp_path):
|
||||
# Error path: ruta inexistente.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_obsidian_note(str(tmp_path / "no_existe.md"))
|
||||
|
||||
|
||||
def test_directorio_lanza_isadirectoryerror(tmp_path):
|
||||
# Error path: ruta a directorio, no archivo.
|
||||
sub = tmp_path / "carpeta"
|
||||
sub.mkdir()
|
||||
with pytest.raises(IsADirectoryError):
|
||||
read_obsidian_note(str(sub))
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: resolve_obsidian_embed
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def resolve_obsidian_embed(vault_dir: str, embed_name: str) -> str"
|
||||
description: "Resuelve el path absoluto real de un attachment embebido buscandolo por nombre dentro de un vault de Obsidian. Obsidian resuelve los embeds ![[...]] por nombre de archivo unico (no por path), asi que esta funcion recorre el vault recursivamente (una pasada con os.walk) y devuelve el primer archivo cuyo basename coincida (case-insensitive), excluyendo .obsidian/ y .trash/. Si el embed no trae extension, acepta cualquier match de ese basename. Devuelve cadena vacia si no existe (NO lanza). Lanza si vault_dir no existe. No depende de la app GUI."
|
||||
tags: [obsidian, embed, attachment, resolve, filesystem, migrate]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "ruta a la raiz del vault Obsidian donde buscar el attachment"
|
||||
- name: embed_name
|
||||
desc: "nombre del attachment embebido (el target de un ![[...]], lo que devuelve extract_obsidian_embeds), con o sin extension"
|
||||
output: "string con el path absoluto del primer archivo del vault cuyo basename coincide (case-insensitive) con embed_name, o cadena vacia '' si ningun archivo coincide. Excluye .obsidian/ y .trash/."
|
||||
tested: true
|
||||
tests:
|
||||
- "match exacto en subcarpeta"
|
||||
- "match case insensitive"
|
||||
- "sin extension acepta cualquier match"
|
||||
- "no existe devuelve vacio"
|
||||
- "excluye obsidian y trash"
|
||||
- "vault inexistente lanza"
|
||||
test_file_path: "python/functions/obsidian/resolve_obsidian_embed_test.py"
|
||||
file_path: "python/functions/obsidian/resolve_obsidian_embed.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from obsidian import extract_obsidian_embeds, resolve_obsidian_embed
|
||||
|
||||
body = "Mi DNI: ![[dni enmanuel (2).jpg]]"
|
||||
for embed in extract_obsidian_embeds(body):
|
||||
path = resolve_obsidian_embed("/home/me/MiVault", embed)
|
||||
print(embed, "->", path or "(no encontrado)")
|
||||
# dni enmanuel (2).jpg -> /home/me/MiVault/attachments/dni enmanuel (2).jpg
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala junto a `extract_obsidian_embeds` cuando migres o extraigas un subgrafo de
|
||||
notas y necesites el path fisico real de cada attachment para copiarlo al nuevo
|
||||
destino. Replica la resolucion por-nombre de Obsidian sin abrir la app GUI:
|
||||
basta con el directorio del vault en disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Resuelve por NOMBRE de archivo, no por path.** Igual que Obsidian: busca el
|
||||
basename en TODO el vault. Si hay dos archivos con el mismo nombre en carpetas
|
||||
distintas (`fotos/logo.png` y `assets/logo.png`), devuelve el PRIMERO que
|
||||
encuentra `os.walk` — el orden de `os.walk` no esta garantizado entre sistemas,
|
||||
asi que con nombres duplicados el resultado puede no ser el embed que Obsidian
|
||||
mostraria. Para vaults con nombres ambiguos, deduplica los nombres antes de
|
||||
migrar.
|
||||
- Si el embed no existe en el vault devuelve `""` (cadena vacia), NO lanza
|
||||
excepcion. Comprueba el valor antes de usarlo como ruta.
|
||||
- SI lanza `FileNotFoundError` / `NotADirectoryError` si `vault_dir` no existe o
|
||||
no es un directorio.
|
||||
- Es impura: recorre el filesystem en cada llamada. Si vas a resolver muchos
|
||||
embeds del mismo vault, considera construir tu propio indice `basename -> path`
|
||||
con un unico `os.walk` en lugar de llamar a esta funcion en bucle.
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Resuelve el path absoluto real de un attachment embebido en un vault Obsidian.
|
||||
|
||||
Funcion impura: recorre el filesystem del vault. Obsidian resuelve los embeds
|
||||
![[...]] por nombre de archivo unico (no por path), asi que esta funcion busca
|
||||
recursivamente un archivo cuyo basename coincida con el nombre del embed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Directorios de maquinaria de Obsidian que nunca contienen attachments de usuario.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def resolve_obsidian_embed(vault_dir: str, embed_name: str) -> str:
|
||||
"""Resuelve el path absoluto de un attachment embebido buscandolo por nombre.
|
||||
|
||||
Obsidian resuelve los embeds `![[archivo.jpg]]` por nombre de archivo unico
|
||||
dentro del vault, no por ruta. Esta funcion replica ese comportamiento:
|
||||
recorre ``vault_dir`` recursivamente (una sola pasada con ``os.walk``) y
|
||||
devuelve el primer archivo cuyo basename coincida, de forma
|
||||
case-insensitive, con ``embed_name``.
|
||||
|
||||
Si ``embed_name`` no trae extension (p.ej. ``"foto"``), se acepta cualquier
|
||||
archivo cuyo basename sin extension coincida (p.ej. ``foto.jpg``).
|
||||
|
||||
Los directorios ``.obsidian/`` y ``.trash/`` se excluyen del recorrido.
|
||||
|
||||
Impura: lee el filesystem. NO lanza si el embed no se encuentra (devuelve
|
||||
cadena vacia). SI lanza si ``vault_dir`` no existe.
|
||||
|
||||
Args:
|
||||
vault_dir: Ruta a la raiz del vault Obsidian.
|
||||
embed_name: Nombre del attachment embebido (lo que devuelve
|
||||
``extract_obsidian_embeds``), con o sin extension.
|
||||
|
||||
Returns:
|
||||
El path absoluto del primer archivo que coincide, o cadena vacia ``""``
|
||||
si ningun archivo del vault coincide.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si ``vault_dir`` no existe.
|
||||
NotADirectoryError: si ``vault_dir`` no es un directorio.
|
||||
"""
|
||||
if not os.path.exists(vault_dir):
|
||||
raise FileNotFoundError(f"vault path does not exist: {vault_dir}")
|
||||
if not os.path.isdir(vault_dir):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {vault_dir}")
|
||||
|
||||
target = embed_name.strip()
|
||||
if not target:
|
||||
return ""
|
||||
|
||||
target_lower = target.lower()
|
||||
has_extension = os.path.splitext(target)[1] != ""
|
||||
target_stem_lower = os.path.splitext(target_lower)[0]
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(vault_dir):
|
||||
# Podar maquinaria de Obsidian in-place para no descender en ella.
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
filename_lower = filename.lower()
|
||||
if has_extension:
|
||||
# Comparar basename completo, case-insensitive.
|
||||
if filename_lower == target_lower:
|
||||
return os.path.abspath(os.path.join(dirpath, filename))
|
||||
else:
|
||||
# Sin extension en el embed: comparar solo el stem del archivo.
|
||||
stem_lower = os.path.splitext(filename_lower)[0]
|
||||
if stem_lower == target_stem_lower:
|
||||
return os.path.abspath(os.path.join(dirpath, filename))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "attachments"))
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
with open(os.path.join(tmp, "attachments", "Foto.JPG"), "w") as f:
|
||||
f.write("x")
|
||||
with open(os.path.join(tmp, "doc.pdf"), "w") as f:
|
||||
f.write("y")
|
||||
|
||||
# Match case-insensitive con extension.
|
||||
hit = resolve_obsidian_embed(tmp, "foto.jpg")
|
||||
assert hit.endswith(os.path.join("attachments", "Foto.JPG")), hit
|
||||
|
||||
# Match sin extension en el embed.
|
||||
hit2 = resolve_obsidian_embed(tmp, "doc")
|
||||
assert hit2.endswith("doc.pdf"), hit2
|
||||
|
||||
# No existe -> "".
|
||||
assert resolve_obsidian_embed(tmp, "no_existe.png") == ""
|
||||
|
||||
print("resolve_obsidian_embed smoke OK")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests para resolve_obsidian_embed."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from resolve_obsidian_embed import resolve_obsidian_embed
|
||||
|
||||
|
||||
def _write(path, content="x"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_match_exacto_en_subcarpeta(tmp_path):
|
||||
# Golden path: archivo en subcarpeta resuelto por nombre exacto.
|
||||
target = _write(tmp_path / "attachments" / "imagen.jpg")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "imagen.jpg")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_match_case_insensitive(tmp_path):
|
||||
# Edge: el nombre en disco difiere en mayusculas/minusculas.
|
||||
target = _write(tmp_path / "media" / "Foto.JPG")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "foto.jpg")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_sin_extension_acepta_cualquier_match(tmp_path):
|
||||
# Edge: embed sin extension -> coincide cualquier archivo con ese stem.
|
||||
target = _write(tmp_path / "doc.pdf")
|
||||
result = resolve_obsidian_embed(str(tmp_path), "doc")
|
||||
assert result == os.path.abspath(target)
|
||||
|
||||
|
||||
def test_no_existe_devuelve_vacio(tmp_path):
|
||||
# Error de dominio (no excepcion): nombre inexistente -> "".
|
||||
_write(tmp_path / "otra.png")
|
||||
assert resolve_obsidian_embed(str(tmp_path), "no_existe.png") == ""
|
||||
|
||||
|
||||
def test_excluye_obsidian_y_trash(tmp_path):
|
||||
# Edge: archivos en .obsidian/ y .trash/ no se resuelven.
|
||||
_write(tmp_path / ".obsidian" / "config.jpg")
|
||||
_write(tmp_path / ".trash" / "borrada.jpg")
|
||||
assert resolve_obsidian_embed(str(tmp_path), "config.jpg") == ""
|
||||
assert resolve_obsidian_embed(str(tmp_path), "borrada.jpg") == ""
|
||||
|
||||
|
||||
def test_vault_inexistente_lanza(tmp_path):
|
||||
# Error path: vault_dir que no existe -> FileNotFoundError.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
resolve_obsidian_embed(str(tmp_path / "no_vault"), "imagen.jpg")
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: search_obsidian_notes
|
||||
kind: function
|
||||
lang: py
|
||||
domain: obsidian
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "search_obsidian_notes(vault_dir: str, query: str, in_body: bool = True, in_frontmatter: bool = True) -> list"
|
||||
description: "Busca un substring (case-insensitive) en todas las notas .md de un vault de Obsidian, excluyendo .obsidian/ y .trash/. Devuelve por nota las lineas que contienen el query con su numero de linea. Los flags in_body/in_frontmatter acotan donde buscar."
|
||||
tags: [obsidian, notes, search, grep, vault, frontmatter, filesystem]
|
||||
uses_functions: ["parse_obsidian_frontmatter_py_obsidian"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["os"]
|
||||
params:
|
||||
- name: vault_dir
|
||||
desc: "path a la raiz del vault de Obsidian a buscar"
|
||||
- name: query
|
||||
desc: "substring a buscar (matcheado de forma case-insensitive); no puede ser vacio"
|
||||
- name: in_body
|
||||
desc: "buscar en el cuerpo de la nota cuando es True (defecto True)"
|
||||
- name: in_frontmatter
|
||||
desc: "buscar en el bloque de frontmatter cuando es True (defecto True)"
|
||||
output: "lista de dicts {path, matches} (uno por nota con coincidencias), ordenada por path; cada match es {line, text} con numero de linea 1-based relativo al archivo completo"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/obsidian/search_obsidian_notes.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from obsidian import search_obsidian_notes
|
||||
|
||||
# Buscar "presupuesto" en todo el vault (frontmatter + cuerpo)
|
||||
hits = search_obsidian_notes("/home/enmanuel/Obsidian/Finanzas", "presupuesto")
|
||||
for h in hits:
|
||||
print(h["path"])
|
||||
for m in h["matches"]:
|
||||
print(f" L{m['line']}: {m['text']}")
|
||||
|
||||
# Solo en el cuerpo, ignorando el frontmatter
|
||||
solo_cuerpo = search_obsidian_notes(
|
||||
"/home/enmanuel/Obsidian/Finanzas", "TODO", in_frontmatter=False
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un grep de un solo paso sobre un vault de Obsidian: encontrar en que notas aparece un termino y en que lineas, antes de abrir/editar. Util para auditar tags, localizar referencias o construir un indice de busqueda.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee el filesystem. Lanza `ValueError` si `query` es vacio, `FileNotFoundError` si el vault no existe y `NotADirectoryError` si no es un directorio.
|
||||
- **Exclusion obligatoria**: `.obsidian/` y `.trash/` se podan del recorrido; su contenido nunca se busca ni aparece en resultados.
|
||||
- **Coste en vaults grandes**: abre y lee CADA nota `.md` linea a linea. En vaults pesados como `NotasDeObsidian` (~554M) esto recorre todo el contenido en memoria por archivo; puede tardar. Acota el vault o pre-filtra con `list_obsidian_notes` si necesitas rendimiento.
|
||||
- **Numeros de linea**: son 1-based y relativos al archivo completo (incluyen las lineas del frontmatter `---`), de modo que mapean directamente sobre el archivo en disco aunque se busque solo en cuerpo o solo en frontmatter.
|
||||
- La delimitacion frontmatter/cuerpo se calcula con `parse_obsidian_frontmatter`; si la nota no tiene frontmatter valido, todo se considera cuerpo.
|
||||
- Archivos con encoding invalido se leen con `errors="replace"`.
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Full-text substring search across the notes of an Obsidian vault."""
|
||||
|
||||
import os
|
||||
|
||||
from obsidian import parse_obsidian_frontmatter
|
||||
|
||||
# Directories that are part of Obsidian's machinery, never user notes.
|
||||
_EXCLUDED_DIRS = {".obsidian", ".trash"}
|
||||
|
||||
|
||||
def search_obsidian_notes(
|
||||
vault_dir: str,
|
||||
query: str,
|
||||
in_body: bool = True,
|
||||
in_frontmatter: bool = True,
|
||||
) -> list:
|
||||
"""Search a case-insensitive substring across every note of a vault.
|
||||
|
||||
Walks ``vault_dir`` recursively (pruning ``.obsidian/`` and ``.trash/``),
|
||||
reads every ``.md`` note and looks for ``query`` as a case-insensitive
|
||||
substring. Each line that contains the query is reported together with its
|
||||
1-based line number.
|
||||
|
||||
The ``in_body`` and ``in_frontmatter`` flags control which part of a note is
|
||||
searched. The frontmatter is delimited with ``parse_obsidian_frontmatter``:
|
||||
its raw lines (between the opening and closing ``---``) are searched when
|
||||
``in_frontmatter`` is True, and the body lines when ``in_body`` is True. Line
|
||||
numbers are always relative to the full file so they map directly onto the
|
||||
note on disk.
|
||||
|
||||
Impure: it reads the filesystem. Raises ``ValueError`` if ``query`` is empty,
|
||||
``FileNotFoundError`` if the vault does not exist and ``NotADirectoryError``
|
||||
if it is not a directory.
|
||||
|
||||
Args:
|
||||
vault_dir: Path to the vault root.
|
||||
query: Substring to look for (matched case-insensitively).
|
||||
in_body: Search the note body when True.
|
||||
in_frontmatter: Search the note frontmatter block when True.
|
||||
|
||||
Returns:
|
||||
A list of dicts ``{"path": str, "matches": list}`` (one per matching
|
||||
note), sorted by path. Each match is
|
||||
``{"line": int, "text": str}``.
|
||||
"""
|
||||
if not query:
|
||||
raise ValueError("query must be a non-empty string")
|
||||
|
||||
root = os.path.abspath(vault_dir)
|
||||
if not os.path.exists(root):
|
||||
raise FileNotFoundError(f"vault path does not exist: {root}")
|
||||
if not os.path.isdir(root):
|
||||
raise NotADirectoryError(f"vault path is not a directory: {root}")
|
||||
|
||||
needle = query.lower()
|
||||
results: list[dict] = []
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
for filename in filenames:
|
||||
if not filename.lower().endswith(".md"):
|
||||
continue
|
||||
full = os.path.abspath(os.path.join(dirpath, filename))
|
||||
matches = _search_note(full, needle, in_body, in_frontmatter)
|
||||
if matches:
|
||||
results.append({"path": full, "matches": matches})
|
||||
|
||||
results.sort(key=lambda r: r["path"])
|
||||
return results
|
||||
|
||||
|
||||
def _frontmatter_line_count(content: str) -> int:
|
||||
"""Number of full-file lines occupied by the frontmatter block (0 if none).
|
||||
|
||||
Counts the opening ``---``, the YAML lines and the closing ``---``. Returns
|
||||
0 when the note has no valid frontmatter (per ``parse_obsidian_frontmatter``).
|
||||
"""
|
||||
if parse_obsidian_frontmatter(content).get("frontmatter"):
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
if lines and lines[0].strip() == "---":
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
return i + 1 # inclusive of both delimiters
|
||||
return 0
|
||||
|
||||
|
||||
def _search_note(
|
||||
note_path: str, needle: str, in_body: bool, in_frontmatter: bool
|
||||
) -> list:
|
||||
"""Return the matching lines (with 1-based line numbers) inside one note."""
|
||||
try:
|
||||
with open(note_path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
content = handle.read()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
fm_lines = _frontmatter_line_count(content)
|
||||
|
||||
matches: list[dict] = []
|
||||
for idx, text in enumerate(lines):
|
||||
is_frontmatter = idx < fm_lines
|
||||
if is_frontmatter and not in_frontmatter:
|
||||
continue
|
||||
if not is_frontmatter and not in_body:
|
||||
continue
|
||||
if needle in text.lower():
|
||||
matches.append({"line": idx + 1, "text": text})
|
||||
return matches
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, ".obsidian"))
|
||||
with open(os.path.join(tmp, ".obsidian", "noise.md"), "w") as f:
|
||||
f.write("ALPHA hidden in obsidian config")
|
||||
with open(os.path.join(tmp, "note.md"), "w") as f:
|
||||
f.write("---\ntitle: Alpha note\n---\nfirst line\nsecond ALPHA line\n")
|
||||
|
||||
hits = search_obsidian_notes(tmp, "alpha")
|
||||
assert len(hits) == 1, hits # .obsidian note excluded
|
||||
assert hits[0]["path"].endswith("note.md")
|
||||
lines = [m["line"] for m in hits[0]["matches"]]
|
||||
assert 2 in lines and 5 in lines, hits # frontmatter + body
|
||||
|
||||
body_only = search_obsidian_notes(tmp, "alpha", in_frontmatter=False)
|
||||
body_lines = [m["line"] for m in body_only[0]["matches"]]
|
||||
assert body_lines == [5], body_only
|
||||
|
||||
fm_only = search_obsidian_notes(tmp, "alpha", in_body=False)
|
||||
fm_lines = [m["line"] for m in fm_only[0]["matches"]]
|
||||
assert fm_lines == [2], fm_only
|
||||
|
||||
print("OK")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user