Files
fn_registry/python/functions/browser/_osint_db_client.py
T
egutierrez 763e06c127 feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-20 18:22:23 +02:00

120 lines
5.0 KiB
Python

"""Cliente HTTP minimo compartido para el service osint_db (FastAPI + DuckDB).
NO es una funcion del registry — es un helper privado (modulo prefijado con `_`)
que comparten las funciones `browser_profile_*`. Por eso no tiene `.md` con
frontmatter ni se indexa. Mantiene KISS: solo dos helpers sobre `urllib.request`
de la stdlib (sin `requests`).
Contrato del service (FIJO): SIEMPRE responde HTTP 200 con un body JSON
`{"status":"ok"|"error", ...}`. El codigo HTTP NO indica exito — se parsea el body.
Estos helpers nunca lanzan por logica de negocio; convierten cualquier fallo de red
o de parseo en un dict `{"status":"error","error":...}` para que las funciones que
los usan respeten el contrato "no lanzar, devolver dict de estado".
"""
import json
import urllib.error
import urllib.request
# Timeout por defecto de cada request HTTP al service (segundos).
_TIMEOUT_S = 10
def _request(base_url: str, path: str, method: str, payload: dict | None = None) -> dict:
"""Hace una request JSON al service osint_db y devuelve el body parseado.
Args:
base_url: base del service (ej. http://127.0.0.1:8771). Se le quita el "/" final.
path: ruta del endpoint (ej. /api/browser-profile). Debe empezar por "/".
method: verbo HTTP (POST, DELETE, GET).
payload: dict a serializar como JSON en el body (None para no enviar body).
Returns:
El body JSON del service como dict. Si el service esta caido, la respuesta no
es JSON, o ocurre cualquier error de transporte, devuelve
{"status":"error","error": <motivo>} para no romper al llamante.
"""
url = base_url.rstrip("/") + path
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
raw = resp.read().decode("utf-8")
parsed = json.loads(raw) if raw else {}
if not isinstance(parsed, dict):
return {"status": "error", "error": f"respuesta no-dict del service: {raw[:200]}"}
return parsed
except urllib.error.HTTPError as e:
# El contrato dice HTTP 200 siempre; un HTTPError es anomalia del transporte.
try:
body = e.read().decode("utf-8")
parsed = json.loads(body) if body else {}
if isinstance(parsed, dict):
return parsed
except Exception: # noqa: BLE001 - el cuerpo del error puede no ser JSON
pass
return {"status": "error", "error": f"HTTP {e.code} desde {url}: {e.reason}"}
except urllib.error.URLError as e:
return {"status": "error", "error": f"service osint_db inaccesible en {url}: {e.reason}"}
except (ValueError, UnicodeDecodeError) as e:
return {"status": "error", "error": f"respuesta no parseable de {url}: {e}"}
except Exception as e: # noqa: BLE001 - contrato: nunca lanzar
return {"status": "error", "error": f"{type(e).__name__}: {e}"}
def post_json(base_url: str, path: str, payload: dict) -> dict:
"""POST JSON al service. Devuelve el body parseado (o dict de error)."""
return _request(base_url, path, "POST", payload)
def delete(base_url: str, path: str) -> dict:
"""DELETE al service. Devuelve el body parseado (o dict de error)."""
return _request(base_url, path, "DELETE", None)
def query(base_url: str, sql: str, params: list | None = None, max_rows: int | None = None) -> dict:
"""POST /api/query (read-only). Devuelve {status, columns, rows, row_count} del service.
Args:
base_url: base del service.
sql: SELECT a ejecutar (read-only en el service).
params: lista de parametros posicionales para el SQL (None -> []).
max_rows: tope opcional de filas devueltas.
Returns:
El body JSON del service. En caso ok trae columns/rows/row_count; en error
trae {"status":"error","error":...}.
"""
body: dict = {"sql": sql}
if params is not None:
body["params"] = params
if max_rows is not None:
body["max_rows"] = max_rows
return _request(base_url, "/api/query", "POST", body)
def rows_to_dicts(resp: dict) -> list:
"""Normaliza las filas de una respuesta de /api/query a lista de dicts.
El service osint_db devuelve ``rows`` YA como lista de dicts (claves =
nombres de columna), así que el caso normal es un passthrough. Por robustez,
si alguna fila viniera como lista/tupla posicional se mapea con ``columns``.
Si la respuesta no es un read ok (sin ``rows``), devuelve [].
"""
rows = resp.get("rows")
if not isinstance(rows, list):
return []
columns = resp.get("columns")
out: list = []
for row in rows:
if isinstance(row, dict):
out.append(row)
elif isinstance(row, (list, tuple)) and isinstance(columns, list):
out.append(dict(zip(columns, row)))
return out