feat: catch-up de decisiones previas (Webpage→Url, anti-bot, UI 2-col, tests cross-platform)

Bloque de cambios revisados y validados con el usuario en sesiones
previas que no habian aterrizado en commits propios. Lista por tema:

* enrichers: web_search ahora usa lite.duckduckgo.com como endpoint
  primario (mas tolerante con bot detection desde IP residencial),
  con fallback al endpoint html. Detecta pagina captcha y emite
  error claro si ambos fallan. Anyade _DDGLiteParser para el formato
  lite + auto-pick de parser por contenido.

* enrichers: tipo Webpage unificado en Url (campos de cuerpo
  cacheado viven en metadata del Url). Manifests actualizados
  (applies_to: [Url]). fetch_webpage ya no convierte Url->Webpage.

* enrichers/manifest: campo `params` parseado a EnricherSpec.params
  (name, type, default_value, description). UI puede renderizar
  dialog de configuracion.

* jobs: fix de path conversion para Python embebido nativo Windows
  (no convertir a /mnt/c/... cuando el subproceso es Windows-native;
  solo cuando es bash o python via WSL).

* main.cpp: ventana ImGui (no modal) "Run enricher" con layout
  2-col (label izq, input der). Inserta job con JSON tipado. Layout
  clustering apretado: hijos del mismo anchor en un solo anillo
  alrededor del padre, sin desperdigar por anillos crecientes.

* views: inspector con layout 2-col via BeginTable (Identity,
  Schema fields, Extras). Description full-width debajo de su label.

* tests: portable conftest (auto-detecta REGISTRY_ROOT, PYTHON_BIN,
  ENRICHERS_DIR para WSL y Windows portable). _runner.py trampoline
  inyecta stub via sys.path porque embedded Python ignora PYTHONPATH.
  Tests bash-only (vendor_script, freeze, dispatcher bash, resolver
  Linux-binary) skipean en Windows. Tests existentes adaptados a
  Webpage->Url.

Resultado actual: 32 passed WSL, 21 passed + 11 skipped Windows.
This commit is contained in:
2026-05-03 14:41:28 +02:00
parent 4be5734ce5
commit 7a94160fd2
26 changed files with 973 additions and 241 deletions
+87 -8
View File
@@ -59,6 +59,57 @@ std::vector<std::string> parse_inline_list(const std::string& v) {
return out;
}
// Split por comas a nivel cero, respetando comillas y nesting de [] / {}.
// El YAML inline `{ name: limit, type: int, default: 10 }` puede contener
// strings con comas entre comillas — un split crudo las rompería.
std::vector<std::string> split_top_level(const std::string& s) {
std::vector<std::string> out;
std::string cur;
int depth_b = 0, depth_c = 0;
char quote = 0;
for (char c : s) {
if (quote) {
cur.push_back(c);
if (c == quote) quote = 0;
continue;
}
if (c == '"' || c == '\'') { quote = c; cur.push_back(c); continue; }
if (c == '[') ++depth_b;
if (c == ']') --depth_b;
if (c == '{') ++depth_c;
if (c == '}') --depth_c;
if (c == ',' && depth_b == 0 && depth_c == 0) {
out.push_back(cur);
cur.clear();
continue;
}
cur.push_back(c);
}
if (!cur.empty()) out.push_back(cur);
return out;
}
// Parsea un objeto YAML inline `{ name: x, type: int, default: 10 }` a un
// EnricherParam. Retorna true si al menos `name` se resolvio.
bool parse_inline_param(const std::string& v, EnricherParam* out) {
std::string s = strip(v);
if (s.size() < 2 || s.front() != '{' || s.back() != '}') return false;
s = s.substr(1, s.size() - 2);
for (auto& kv : split_top_level(s)) {
size_t colon = kv.find(':');
if (colon == std::string::npos) continue;
std::string k = strip(kv.substr(0, colon));
std::string val = strip_quotes(strip(kv.substr(colon + 1)));
if (k == "name") out->name = val;
else if (k == "type") out->type = lower(val);
else if (k == "default") out->default_value = val;
else if (k == "description") out->description = val;
else if (k == "desc") out->description = val;
}
if (out->type.empty()) out->type = "string";
return !out->name.empty();
}
// Manifest YAML soportado (subset):
// id: fetch_webpage
// name: "Fetch web page"
@@ -66,16 +117,19 @@ std::vector<std::string> parse_inline_list(const std::string& v) {
// applies_to: [Webpage, Url]
// lang: python <- issue 0033: go|python|bash (default python)
// exec: run <- basename del binario/script (default "run")
// params: <- v1 ignora bloque
// - { name: timeout_s, ... }
// params:
// - { name: timeout_s, type: int, default: 15 }
// - { name: region, type: string, default: "" }
//
// Las claves anidadas bajo `params:` (y otros bloques con valor vacio
// seguido de lineas indentadas) se ignoran.
// Solo el bloque `params:` se parsea con detalle. Otros bloques con valor
// vacio seguido de lineas indentadas (`emits:`, `relations:`,
// `uses_functions:`) se ignoran como antes.
bool parse_manifest(const std::string& path, EnricherSpec* out) {
std::ifstream f(path);
if (!f) return false;
std::string line;
bool in_skip_block = false;
bool in_skip_block = false;
bool in_params_block = false;
while (std::getline(f, line)) {
// Strip CR de Windows.
if (!line.empty() && line.back() == '\r') line.pop_back();
@@ -84,10 +138,27 @@ bool parse_manifest(const std::string& path, EnricherSpec* out) {
std::string trim = strip(line);
if (trim.empty() || trim.front() == '#') continue;
// Si la linea NO empieza con whitespace, salimos del bloque skip.
// Si la linea NO empieza con whitespace, salimos de los bloques
// anidados — el siguiente top-level reinicia el contexto.
bool indented = !line.empty() && std::isspace((unsigned char)line.front());
if (!indented) in_skip_block = false;
if (!indented) {
in_skip_block = false;
in_params_block = false;
}
if (in_skip_block) continue;
if (in_params_block) {
// Linea esperada: ` - { name: x, type: int, default: 10 }`.
// Tolera variaciones de indent y comilla.
std::string body = trim;
if (!body.empty() && body.front() == '-') {
body = strip(body.substr(1));
}
EnricherParam p;
if (parse_inline_param(body, &p)) {
out->params.push_back(std::move(p));
}
continue;
}
size_t colon = trim.find(':');
if (colon == std::string::npos) continue;
@@ -101,9 +172,17 @@ bool parse_manifest(const std::string& path, EnricherSpec* out) {
else if (key == "applies_to") out->applies_to = parse_inline_list(val);
else if (key == "lang") out->lang = lower(strip_quotes(val));
else if (key == "exec") out->exec_basename = strip_quotes(val);
else if (key == "params" && val.empty()) in_skip_block = true;
else if (key == "params") {
// `params: []` — vacio explicito, nada que hacer.
// `params:` — siguiente bloque indentado son items.
std::string vs = strip(val);
if (vs.empty()) in_params_block = true;
// Si fuese inline (`params: [{...}]`) — formato no usado en
// los manifests actuales, lo ignoramos.
}
else if (key == "emits" && val.empty()) in_skip_block = true;
else if (key == "relations" && val.empty()) in_skip_block = true;
else if (key == "uses_functions" && val.empty()) in_skip_block = true;
}
// Defaults — preservan retrocompat con manifests existentes que no