feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user