Historial de cambios

GitHub

Cada cambio relevante, versión a versión. El mismo archivo se publica en el repositorio y está traducido a los 19 idiomas.

[1.236.0] — 2026-09-18

Paridad con el padre — career-ops main @ 6a9c84c (VERSION 1.33.0, 128 commits). Dos fuentes nuevas, y tres correcciones reflejadas — dos de las cuales perdían ofertas aquí, en silencio.

Añadido

Dos fuentes nuevas para el escáner, 92 → 94 (89 EN + 5 RU). Python.org Jobs (pythonorg) lee el feed público del tablón; la atribución de empleador es obligatoria, así que una fila cuyo título no nombra a ningún empleador se descarta en vez de rellenarse con el nombre de la entrada del portal. Generalist World (generalist-world) extrae las tarjetas del tablón y falla de forma ruidosa ante marcado que no puede analizar — una tarjeta con una forma cambiada lanza una excepción en vez de leerse como un tablón vacío, que es el modo de fallo que vuelve poco fiable a una fuente por scraping. Ambas aparecen en el filtro de fuente de #/scan, ambas están ancladas a su host (regex anclada, solo HTTPS), y ambas están cubiertas de principio a fin por suites de transporte simulado (43 y 36 casos).

Corregido

Una oferta de Workday abierta en varias ciudades quedaba descartada por un filtro de ubicación. El endpoint de listado de Workday responde a una oferta multiubicación con un recuento donde toda otra oferta lleva un lugar: "53 Locations". Eso no es una ubicación, y buildLocationFilter compara por subcadena sin distinguir mayúsculas y minúsculas — así que una oferta abierta en Austin y otras 52 ciudades no coincidía con ninguna entrada allow: [austin] y se descartaba, mientras que la misma oferta listada en solitario sí pasaba. Medido en origen a través de tres tenants: 53 de 291 ofertas eran marcadores de posición, así que este es el caso habitual, no uno marginal. Los lugares reales de la oferta existen solo en el documento de detalle de CXS, así que cada marcador de posición ahora cuesta un GET adicional contra él, con un tope de 200 por entrada — superado el tope, el marcador de posición se deja visible en vez de falsearse, porque una oferta sin resolver nunca debe leerse como resuelta. Un detalle fallido o sin lugar deja la oferta exactamente como la devolvió el listado: un "53 Locations" visible es honesto sobre lo que no pudo resolverse, mientras que una ubicación vaciada pasaría todos los filtros en silencio. El mismo GET también lleva startDate, así que una oferta enriquecida queda fechada con exactitud en vez de por la prosa relativa del endpoint de listado ("Posted 30+ Days Ago" → undefined); todo lo que no sea un YYYY-MM-DD desnudo se deja tal cual en vez de pasarse por Date.parse, cuyo comportamiento de reserva no-ISO depende de la implementación. Un nivel indicado ya no colapsa dos requisiciones en una sola republicación. "Insurance Specialist II" es la propia declaración del empleador de que esta requisición no es hermana de la otra — una banda salarial, un alcance y un número de requisición distintos bajo un mismo título base. El tokenizador no podía verlo: w.length > 3 descarta todo numeral romano hasta VIII y todo dígito suelto, así que "Insurance Specialist" e "Insurance Specialist II" se tokenizaban de forma idéntica, obtenían una ratio de Jaccard perfecta, y el detector de republicaciones marcaba la segunda como una relistada de la primera. role-matcher.mjs de web-ui no tenía manejo de nivel en absoluto, así que esto trae aterrizado el mecanismo completo del padre: los niveles se extraen plegando romano y arábigo sobre un mismo número ("Nurse II" y "Nurse 2" son la misma declaración — y ese par difería incorrectamente antes), dos niveles indicados deben coincidir, y un nivel presente en un solo lado se trata como una reescritura suelta salvo que cada título lleve también una palabra que falta en el otro, lo cual son dos discrepancias y no un adorno. Un tablón de Avature fijado a un filtro recorría igualmente el tablón global completo. La interfaz de búsqueda de Avature genera parámetros de faceta opacos (?42386=[812132] es un filtro de país), y un operador que fija un tablón acotado en portals.yml espera que el escaneo recorra ese. resolveSearch reconstruía la URL como ${origin}${path} y descartaba la cadena de consulta sin más — así que cada petición, incluida la primera, recorría el tablón completo del tenant: más páginas, más peticiones, y ofertas que el operador había filtrado deliberadamente. Las facetas ahora acompañan cada petición paginada, con nuestro propio jobOffset aplicado al final para que una faceta homónima extraviada nunca pueda secuestrar la paginación.

Seguridad

devalue 5.8.1 → 5.9.2 (de forma transitiva vía astro), que cierra la única alerta abierta de Dependabot — una denegación de servicio ante una entrada malformada. Dependencia exclusiva del sitio; no cambió ningún código en tiempo de ejecución.

Notas

No se porta, cada uno con su motivo. El padre dio a lever un timeout de 30s y a manfred uno de 25s frente a su valor por defecto compartido de 10s — un no-port: el transporte de escaneo de web-ui ya permite 60s (DEFAULT_SCAN_TIMEOUT_MS), así que ambos tablones tienen aquí más margen del que concede la corrección allí. El prefijo careers- duplicado de iCIMS tampoco puede darse aquí: web-ui toma el origen del tenant directamente de api/careers_url y nunca construye un host a partir de un nombre de tenant; su nuevo recorrido de host de reserva es una funcionalidad para la configuración más laxa del padre, no una corrección de defecto. deutschebahn y manfred ganaron reintento ante abort transitorio, algo que el techo de 60s de web-ui vuelve mucho menos apremiante — anotado en vez de portado, así que un futuro timeout de recorrido largo ya tiene una primera cosa conocida que probar. Todo lo demás en el delta de 128 commits es CLI, verificador, dashboard o web/ (el frontend propio del padre): liveness-browser, check-jd-archive, verify-ats/verify-portals/verify-cv-facts, update-system, set-status, audit-portals, discover-ats, scaffolder, test-all, manifiestos de plugins. Divergencias del fork reverificadas tras el pull: la corrección cirílica de \p{L} en providers/telegram-channel.mjs, el propio providers/telegram.mjs del fork, hermes en web/src/lib/clis.ts. El upstream career-ops-hq/main está completamente fusionado (0 commits faltantes). 3066 → 3164 pruebas; la ayuda ahora indica 94 adaptadores (89 EN + 5 RU) en los 17 paquetes, y la estructura de encabezados no cambia, en 32 H2 / 122 H3.

[1.235.0] — 2026-09-16

Paridad con el padre — career-ops main @ 68e6b94 (VERSION 1.33.0, 42 commits). Dos correcciones reflejadas; una de ellas perdía ofertas de MokaHR aquí, en silencio.

Corregido

Una segunda oferta del mismo tablero de MokaHR desaparecía en vez de añadirse. url-key.mjs reduce la URL de una oferta a una clave de comparación, y descartaba el fragmento sin condiciones — razonable, ya que casi todos los fragmentos son solo de presentación (#apply, #section-2). MokaHR es la excepción en nuestro propio registro: sus ofertas son …/social-recruitment/{tenant}/{id}#/job/{n}, donde todas las ofertas de un tenant comparten la ruta y el id de la oferta existe solo en el fragmento. Así que todas se normalizaban a una sola clave. Medido antes de la corrección: pega dos ofertas distintas de MokaHR en la pipeline y terminas con una línea — sin error, sin aviso, nada que notar; el filtro de novedad del escáner (en-scanner.mjs) lee la misma clave, así que en cuanto cualquier oferta de ese tenant estaba en el historial de escaneo, todas las demás se descartaban como “ya vista”. normalizeUrl ahora promueve una ruta reconocida #/job/{id} o #/jobs/{id} a una clave de consulta solo para comparación antes de descartar el fragmento — MokaHR conserva su nombre ya establecido mokahr_job_id para que las claves ya escritas sigan siendo comparables, cualquier otro host recibe un _career_ops_fragment_job_id interno. El id promovido se añade, no se fija: una URL que ya lleve ese parámetro contiene el id de otra oferta distinta, y sobrescribirlo fusionaría justo las dos cosas que la clave existe para mantener separadas. Todo fragmento no reconocido se sigue colapsando, que es la mitad que ya estaba bien. accept-encoding ahora queda fijado en cada petición a una fuente. Sin fijar, Node negocia lo que sea que ofrezca su build de undici; desde Node ≥ 23 eso incluye zstd, y la respuesta zstd de amazon.jobs vuelve truncada exactamente en 1024 bytes con estado 200 — así que el fallo se lee como Unterminated string in JSON at position 1024, como si fuera una API malformada y no un bug de transporte. Cada petición ahora envía gzip, deflate, br. En nuestros runtimes esto es preventivo, no una reparación: Node 18/20/22 — la matriz de CI y el servidor (22.22.1) — envían gzip, deflate, verificado directamente en vez de asumido, así que aquí nada se truncaba; lo que cambia hoy es que se ofrece br, algo que esas versiones no piden por defecto pero decodifican correctamente. El valor por defecto se aplica sin distinguir mayúsculas y minúsculas, que es la parte que vale la pena revisar: los nombres de cabecera no distinguen mayúsculas de minúsculas y fetch une dos entradas con el mismo nombre en un solo valor separado por comas, así que un llamador que pasara Accept-Encoding: identity de otro modo habría terminado enviando gzip, deflate, br, identity.

Notas

Las cabeceras se mantienen como un objeto plano en el límite de fetchImpl, donde el padre le pasa a fetch una instancia de Headers. Es una divergencia deliberada, no un descuido: los helpers de fetch de web-ui reciben un fetchImpl inyectable, y 45 suites de fuentes leen opts.headers['User-Agent'] de lo que capturan — una instancia de Headers no tiene acceso por corchetes, así que el port fiel es del comportamiento (la sobrescritura sin distinguir mayúsculas y minúsculas, gana el llamador) y no de la implementación. El objeto del llamador se copia, nunca se muta, ya que varias fuentes comparten una misma constante de cabecera. La corrección hermana del padre — aplicar un user-agent por defecto sin distinguir mayúsculas y minúsculas — es un no-port: web-ui no inyecta ningún UA por defecto, cada fuente fija el que exige su tablero, así que no hay ningún valor por defecto con el que pueda colisionar el User-Agent de un llamador. Dos suites nuevas y tres casos nuevos fijan el par: tests/http-accept-encoding.test.mjs (el valor exacto fijado, cada uno de los cuatro helpers de fetch, y una sobrescritura del llamador bajo tres capitalizaciones — comprobando que sobrevive una única clave accept-encoding, que es de lo que el bug de fusión producía dos) y tests/parsers-pipeline-hash-route.test.mjs, que trabaja sobre la costura visible para el usuario en vez de sobre la clave: dos ofertas de MokaHR deben llegar ambas a la pipeline, pegar de nuevo la misma bajo cuatro grafías distintas debe seguir colapsando a una línea, y un fragmento cosmético no debe convertirse en una identidad. Todos se confirmaron fallando primero. El anclaje vive en el transporte, no solo en esos helpers: 25 de las 92 fuentes llaman directamente al fetchImpl inyectado y nunca pasan por ellos, así que lo aplica makeTimeoutFetch — el envoltorio que ambos escáneres inyectan realmente — y ese es el único punto de paso común a todas las fuentes. (El padre no necesita equivalente: providers/_http.mjs es su único transporte.) Una guarda estructural ahora hace fallar CI si una fuente nueva llama a fetch directamente en vez de usar el transporte que recibió, y un caso de comportamiento conduce a greenhouse, una de las 25, a través del envoltorio para probar que la cabecera llega al cable. No se porta, con su motivo: liveness-browser.mjs (guarda de reutilización de Playwright con interfaz gráfica — web-ui no conduce un navegador con interfaz gráfica), check-jd-archive.mjs (sin retransmisión en web-ui), test-all.mjs (arnés del padre; la divergencia vpFixtureEnv del fork sigue intacta), web/ (el frontend propio del padre, no reflejado), los manifiestos de plugins y SIGNATURES.md (solo del repositorio). batch/batch-runner.sh (ampersands escapados antes de la interpolación de sed) y jd-skill-gap.mjs (resuelve cv.md contra la raíz de datos) quedan retransmitidos de solo lectura — web-ui invoca por shell las copias del padre, así que ambos llegan sin ningún cambio de código aquí, y el de jd-skill-gap hace más correcta la retransmisión de /api/jds/:id/skill-gap: cv.md ahora se resuelve desde la raíz de datos en vez de desde el directorio en el que se haya generado el proceso. Divergencias del fork reverificadas tras el pull: la corrección cirílica de \p{L} en providers/telegram-channel.mjs, el propio providers/telegram.mjs del fork, hermes en web/src/lib/clis.ts. Fuentes sin cambios, en 92 (87 EN + 5 RU), así que la ayuda queda intacta. 3042 → 3066 pruebas.

[1.234.0] — 2026-09-15

Paridad con el padre — career-ops main @ 56cce8f (VERSION sigue en 1.32.0, 58 commits por delante de 1.32.0). Una corrección reflejada en 15 fuentes, más una guarda para que no pueda volver.

Corregido

Un único id de oferta malformado ya no vacía toda la página. encodeURIComponent lanza URIError ante un sustituto UTF-16 (surrogate) suelto, y una carga JSON puede llevar uno — JSON.parse('"\\uD800"') lo conserva. Quince fuentes construían la URL de cada oferta a partir de un id o slug controlado por el host dentro del bucle de parseo, así que una oferta defectuosa abortaba el bucle y perdía todas las ofertas de esa página; nada quedaba registrado como fallo de fuente porque la excepción se propagaba antes de que el catch por empresa tuviera algo que informar. El padre lo corrigió en providers/_safe-url.mjs (safeEncodeURIComponent → null ante URIError, relanza cualquier otra cosa) y encauzó por ahí el id de cada bucle, descartando solo esa oferta cuando da null. Ese helper es ahora server/lib/sources/_safe-url.mjs, y el mismo cambio se aplica a las trece fuentes reflejadas — alibaba, arbeitsagentur, bamboohr, feishu-jobs, garena, jibeapply, manfred, meituan, mokahr, phenom, thehub, tkms, vdab — y a dos fuentes exclusivas de web-ui que la nueva guarda sacó a la luz en su primera ejecución, jobstreet y trudvsem, que tenían la misma forma de bucle y el mismo lanzamiento latente. El alcance es deliberado: solo los ids que vienen de la API pasan por el helper. Los valores derivados de la configuración conservan su codificación estricta y fallan de forma ruidosa — el office de garena (un segmento de portals.yml) se queda con el urlSegment que lanza, mientras que su id de API recibe un idUrlSegment que devuelve null; el corpName de csod y el slug de 4dayweek validado por SLUG_RE quedan en la lista blanca por nombre, con su motivo. El fallback de título de rheinmetall podía lanzar ante un href malformado. Cuando una tarjeta no tiene titular, el título se reconstruye a partir del slug de la URL vía decodeURIComponent, que lanza ante una secuencia de porcentaje inválida en el href extraído — la misma forma de «una fila mala mata la página», pero del lado del decode. Ahora decodifica de forma defensiva y recurre al slug en crudo. El registro de fuentes omite los archivos con prefijo _. El autodescubrimiento importaba cada .mjs de server/lib/sources/ salvo a sí mismo y avisaba de cualquiera que careciera de export const meta — así que el helper generaba un aviso al arrancar. Se adopta la convención del padre: un guion bajo inicial significa no es una fuente; esos archivos no se importan ni generan aviso. Un archivo no declarado y sin meta sigue avisando, y la prueba de descubrimiento ahora comprueba ambas mitades (confirmada en rojo contra el filtro anterior). El script de build del sitio sync-assets.mjs cuenta los archivos de adaptadores en disco con la misma regla, así que el helper ya no dispara su guarda contra caídas silenciosas.

Notas

tests/sources-url-encoding-surrogate.test.mjs refleja la suite del padre en tres partes: el contrato del helper (un sustituto emparejado — un emoji normal — sigue codificándose; un lanzamiento del propio toString del valor es un bug del llamador y se propaga), el comportamiento por fuente para las quince (un id malo + un id limpio → sin lanzamiento, el limpio se conserva, el malo se descarta, cada caso llevado a través del parser puro exportado de la fuente o, para thehub, de su fetcher con un transporte simulado), y una guarda a nivel de fuente: todo archivo convertido debe importar el helper y llamarlo, ningún archivo puede referenciarlo sin importarlo, y ningún archivo no revisado puede construir una URL de oferta con un encodeURIComponent a secas — una fuente nueva con la forma antigua falla en CI con el número de línea del culpable. Los quince casos por fuente se confirmaron fallando antes del port. Todo lo demás en el delta del padre no se porta, con su motivo: batch/batch-runner.sh --cli es una opción exclusiva de CLI (web-ui no invoca el batch runner por shell); invite-match.mjs ahora resuelve set-status.mjs desde la raíz del código — web-ui no lo retransmite; company-funded.mjs recibió el mismo cambio de safe-url internamente y la forma de su salida --json, que parsea /api/company-funded, no cambia; test-all.mjs solo movió un presupuesto de timeout (la divergencia vpFixtureEnv del fork sigue intacta); web/ es el frontend propio del padre y no se refleja; AGENTS.md/SIGNATURES.md son solo del repositorio. Divergencias del fork reverificadas tras el pull: la corrección cirílica de \p{L} en providers/telegram-channel.mjs, el propio providers/telegram.mjs del fork, hermes en web/src/lib/clis.ts. El número de fuentes no cambia, en 92 (87 EN + 5 RU), así que la ayuda queda intacta. 3022 → 3042 pruebas.

[1.233.2] — 2026-09-10

Corregido — dos alertas de CodeQL js/remote-property-injection de severidad alta.

Corregido

El bucle que copia los valores guardados a process.env recorría Object.entries(safe) — un objeto construido a partir del cuerpo de la petición — con una guarda explícita contra claves de prototipo dentro. El código era seguro, porque safe solo se rellena desde KNOWN_KEYS veinte líneas antes, pero leído por sí solo el bucle dice asigna a una propiedad cuyo nombre salió de un objeto construido con el cuerpo de la petición, y CodeQL no puede seguir esa restricción entre dos bucles. Ahora recorre el array constante, así que el nombre de la propiedad es demostrablemente un literal de módulo. La guarda sobra y se fue con él. El comportamiento es idéntico.

Notas

Descartarlas como falsos positivos era la alternativa; corregir era preferible, porque una afirmación en un comentario vale menos que una forma verificable. La prueba que fijaba la guarda era un grep del texto fuente; ahora hay una estructural (confirmada en rojo contra la forma antigua) y una de comportamiento que envía __proto__ como JSON en crudo — un literal de objeto habría fijado el prototipo en vez de crear la propiedad. 3021 → 3022 pruebas.

[1.233.1] — 2026-09-10

Corregido — la guía no describía el control que añadió v1.233.0, y la misma sección aún traía instrucciones que dejaron de ser ciertas hace 200 versiones.

Corregido

DOC-1 — la entrada «Usar el valor por defecto (…)» expresa el estado «la clave no está definida», y la diferencia con elegir ese mismo valor de la lista es invisible y con consecuencias: la primera no escribe nada y te sigue dando el valor del proyecto según cambia; la segunda lo fija en .env y te deja en el viejo, en silencio. Tres viñetas en el ### Behavior existente ×17. DOC-2 — una fila de la tabla no nombraba clave alguna: la celda contenía (server uses default UA), describiendo HH_USER_AGENT, que v1.19.0 eliminó, y mandaba registrarse en dev.hh.ru — inútil desde v1.65.0. Las tres menciones obsoletas desaparecen; la fila de solución de problemas se reescribe, no se borra: desde julio de 2026 hh.ru devuelve HTTP 451 a IPs no rusas, así que una IP residencial rusa sí hace falta. La tabla de claves lista 6 de 45 mientras su título promete un registro. Una frase ×17 la declara una selección y apunta a #/config.

Notas

El nuevo help-recognized-keys.test.mjs compara la primera columna de las tablas de claves con KNOWN_KEYS en vivo. 3018 → 3021 pruebas; la ayuda sigue en 32 H2 / 122 H3.

[1.233.0] — 2026-09-10

Corregido — tres hallazgos en la ruta de guardado de #/config; CONFIG-3 lo causó la propia corrección de v1.232.1.

Corregido

CONFIG-3 — una elección deliberada que coincidía con el valor sembrado se descartaba como «sin tocar». initial confunde dos orígenes: sembrado desde .env (la igualdad significa que el campo no se editó — correcto omitirlo) y sembrado desde defaultValue (la igualdad es ambigua: nunca se abrió, o se abrió y se estuvo de acuerdo). Con HOST ausente el control muestra 127.0.0.1; escribir ese mismo valor y guardar enviaba {} y la clave seguía sin existir — el único camino era escribir un valor erróneo, guardar y volver a poner el correcto. Corregido registrando seededFromFile junto a initial; donde la semilla viene del archivo nada cambia, y donde viene del valor por defecto dirty pasa a ser una segunda señal válida. Eliminar una clave informaba «· 0» porque el servidor solo devolvía lo escrito. Ahora responde también con removed, calculado contra la instantánea previa, y el aviso cuenta ambos. Un desplegable podía fijarse pero nunca vaciarse: ninguno de los 18 ofrecía una opción vacía. Ahora cada uno encabeza con «Usar el valor por defecto (…)», y una clave sin definir selecciona esa entrada en lugar del valor. Una nueva clave i18n ×17.

Notas

Línea base de pruebas unitarias sin cambios (3018); navegador 111 → 116, y las cinco nuevas se confirmaron fallando contra el código anterior.

[1.232.1] — 2026-09-10

Corregido — Guardar en #/config escribía campos que el usuario nunca tocó.

Corregido

CONFIG-2. v1.57.1 siembra un control sin valor con su defaultValue para que el campo muestre lo que el servidor realmente usa, mientras que save() consultaba dirty solo para los secretos y enviaba todos los demás campos siempre. Así, el valor puesto ahí para MOSTRAR viajaba al servidor como si el usuario lo hubiera elegido: cambiar un desplegable enviaba 28 claves y fijaba 18 que nadie había abierto. Nada se comporta distinto ese día, porque lo escrito son los valores por defecto; lo que cambia es el estado de cada ajuste, de «sin definir, sigue el valor del proyecto» a «fijado en .env». Cuando cambie un valor por defecto, quien haya pulsado Guardar alguna vez se quedará atrás en silencio.

Notas

Comparar con cfg.values no basta: un select sin valor muestra su defaultValue mientras lo almacenado es '', y difieren con toda honestidad — el cuerpo bajó de 27 claves a 19, no a una. Ahora se compara con aquello con lo que se sembró cada control. Además, el número de claves escritas vuelve a verse en el aviso. 105 → 111 pruebas de navegador.

[1.232.0] — 2026-09-09

Corregido — dos defectos de una pasada de QA en navegador; ninguno proviene de v1.231.5.

Corregido

PROFILE-1 — #/profile desplazaba lateralmente toda la página. El valor de cada campo era un <div> anónimo con solo estilos de fuente en línea, así que su overflow-wrap calculado seguía siendo normal. Una URL de LinkedIn no ofrece a CSS ningún punto de corte legal, de modo que el ancho min-content es la cadena entera; los elementos de rejilla usan min-width: auto y se niegan a encogerse por debajo, así que la tarjeta reventó su pista, luego la fila, la columna principal y el documento — 23 px de desplazamiento real a 1280 px. Corregido con una clase compartida .card-value con overflow-wrap: anywhere, más .card-row > * { min-width: 0 }. anywhere, no break-word: solo anywhere cuenta al calcular min-content. CONFIG-1 — POST /api/config aceptaba cualquier valor en un campo select. {"LLM_PROVIDER":"not-a-provider"} devolvía 200 y se escribía en el .env; después nada fallaba, porque el resolutor caía en su alternativa en silencio y la elección del usuario no surtía efecto. LLM_PROVIDERS ya se exportaba desde env-config.mjs: el validador simplemente no lo consultaba.

Notas

Solo se valida LLM_PROVIDER, un estrechamiento deliberado del informe: el dominio real de los modelos pertenece al proveedor y cambia entre nuestras versiones. Un valor ya guardado queda tolerado — el formulario reenvía todos los campos no secretos en cada guardado, así que la versión estricta congelaba la página. También se añadió el descriptor de OLLAMA_API_KEY. 3013 → 3018 pruebas.

[1.231.5] — 2026-09-09

Seguridad — siete avisos cerrados, uno de ellos una ejecución remota de código crítica. No cambió ningún archivo de código fuente.

Seguridad

Fusionado desde Dependabot: multer 2.2.0 → 2.3.0 (alta — denegación de servicio por fuga de descriptores de fichero al abortar) y svgo 4.0.2 → 4.1.0 en el sitio (alta y media — removeScripts dejaba pasar enlaces ejecutables mediante espacios de nombres y manejo de contenido, y saneaba de forma incompleta el HTML ejecutable dentro de foreignObject). Después otros cinco, revelados por el reescaneo que dispararon esas fusiones. Cada rango afectado ya estaba cubierto por el especificador ^ existente, así que hacía falta una actualización, no una edición del manifiesto: astro 7.1.0 → 7.3.2 — crítica, ejecución remota de código por la optimización de imágenes AVIF (corregida en 7.2.8), y con ella un bypass de autorización por falta de comprobación del límite de segmento de ruta (7.2.4); sharp 0.35.3 → 0.35.4 — alta, vulnerabilidades de libheif; y js-yaml 4.3.1 → 4.3.2 en ambos manifiestos — alta.

Notas

«Solo dependencias» no equivale aquí a «sin riesgo». js-yaml analiza cada configuración que lee la aplicación, multer acepta cada subida, y Astro avanzó una versión menor. Verificado: npm run test:ci 3013/3013, la suite de navegador 4/4 y una reconstrucción completa del sitio con las mismas 86 páginas, los 17 espejos del changelog y un facts.json correcto. npm audit: 0 vulnerabilidades en ambos manifiestos.

[1.231.4] — 2026-09-07

Corregido — v1.231.3 arregló las secuelas del clic en Doctor, no el momento. Reportado de nuevo, desde móvil y desde escritorio.

Corregido

withSpinner seguía reescribiendo el botón mientras la petición estaba en vuelo. v1.231.3 hizo que capturara los nodos hijos y los restaurara en finally, y volvían — pero la señal de ocupado seguía ejecutando button.replaceChildren(document.createTextNode('⏳ ' + original)). Así que durante toda la ejecución de doctor.mjs el cuadrado de 36 px contenía el texto ⏳ 🩺Doctor y se convertía en una pastilla ancha que rompía la fila; en escritorio se veía como un ⏳🩺Doctor apelmazado. Restaurar los hijos arreglaba las secuelas, no el momento. Un botón con hijos elemento ya no se reescribe en absoluto: su señal es la clase .is-loading, que la hoja de estilos ya atenuaba y que ahora cambia el icono por un reloj de arena en CSS, de modo que la geometría no puede variar: 36 px en reposo, 36 px en vuelo, 36 px después a 320 px. Los botones de solo texto — 23 de los 24 puntos de llamada — conservan el reloj antepuesto.

Notas

La lección es sobre la aserción, no sobre el código. La comprobación de v1.231.3 esperaba a que se limpiara aria-busy y solo entonces medía: por construcción únicamente podía ver el estado que el bloque finally ya había reparado. La nueva comprobación mantiene ella misma la promesa abierta en lugar de competir con una petición real, toma la muestra a mitad de vuelo y exige que el ancho no cambie. 3012 → 3013 pruebas. Ambas se confirmaron fallando contra el código de v1.231.3.

[1.231.3] — 2026-09-07

Corregido — pulsar Doctor rompía la barra superior en el móvil. Reportado desde un teléfono el mismo día que salió v1.231.2.

Corregido

UI.withSpinner aplastaba los hijos del botón que decoraba. Su señal de ocupado pasaba por textContent: guardar button.textContent, escribir '⏳ ' + original, volver a escribirlo. Asignar textContent reemplaza todos los hijos por un único nodo de texto — y v1.231.2 acababa de dividir cada acción de la barra superior en .btn-ico + .btn-label, ocultando la etiqueta bajo max-width: 900px. Así que la primera pulsación en Doctor destruía ambos <span> para siempre: las reglas móviles se quedaban sin nada que coincidir, la etiqueta volvía como texto desnudo y el cuadrado de 36 px crecía hasta una pastilla 🩺Doctor que se montaba sobre el conmutador de tema. No se curaba solo — únicamente recargar la página restauraba el marcado. La corrección captura los nodos hijos y los restaura con replaceChildren; los botones de solo texto, que son la mayoría de los 24 puntos de llamada, van y vuelven exactamente igual que antes.

  • FIND-3 — el campo de búsqueda se reducía a 8 px en un teléfono de 320 px. Al poner la barra en una sola fila, el searchbar quedó como el único elemento flexible, y el changelog anterior lo decía: «puede encogerse hasta nada». Y se encogió hasta nada: 8 px de campo a 320 px, 28 px a 340 px, un carácter de un marcador de 21. Pulsable y escribible, pero ilegible como campo. Ningún min-width lo arregla: a 320 px la barra sostiene 32 px de relleno, un menú de 40 px y 162 px de acciones, sin 120 px que dar. Por eso, por debajo de 420 px — el ancho en que el campo por fin supera los 100 px solo — se oculta tras una lupa y se despliega por toda la barra al tocarla: 182 px a 320 px, 222 px a 360 px, 252 px a 390 px, sin desbordamiento en ningún ancho. El botón conserva un único nombre accesible y comunica el estado con aria-expanded, y el cambio 🔍/✕ es CSS puro — ningún JS escribe en ese botón, que es lo que rompió Doctor en esta misma versión. Por encima de 420 px nada cambia. Detectado por una pasada de regresión en navegador contra la build en vivo.

Notas

Es la misma clase de defecto que la trampa de applyI18n() señalada en v1.231.2 — código que asigna textContent a un elemento que ahora tiene hijos elemento — alcanzada por otra vía. La lección se generaliza: dividir el contenido de un elemento en <span> hijos convierte a cada escritor de textContent ya existente sobre ese elemento en un fallo latente, y esos escritores no siempre están en el archivo que estás editando. Asegurado por partida doble: node:vm sobre el withSpinner real y un clic en un navegador real a 320 px. Ambos se confirmaron fallando contra el código anterior. 3009 → 3012 pruebas (el caso de navegador es opcional y no entra en test:ci).

[1.231.2] — 2026-09-06

Corregido — la barra superior móvil se partía en dos filas, el servidor ejecutaba un padre más antiguo y el redirect de cvstart.ru daba 404 en cualquier ruta que ya nombrara un idioma.

Corregido

  • La barra superior móvil vuelve a una sola fila, con acciones solo de icono. Los botones de acción se forzaban a una segunda fila a todo el ancho, así que un teléfono mostraba [☰ · búsqueda] sobre [🔔 🌙 Doctor Abrir Scan] — dos píldoras anchas en una línea propia. Ahora son cuadrados de 36 px (🩺 / ⚡) junto a la campana y el conmutador de tema, y todo cabe en una fila a 320 px. Medido, no estimado a ojo: el ancho del documento iguala al viewport a 320, 360, 390 y 430 px, y en toda la banda 560–760 px, donde antes los botones se superponían a la barra de búsqueda. El idioma ya no afecta a la disposición móvil, porque la etiqueta está oculta y el botón es un cuadrado fijo; por encima de 900 px las etiquetas vuelven sin cambios. El nombre accesible viene de aria-label, ya que una etiqueta con display:none queda fuera del cálculo del nombre.
  • El servidor ejecutaba el padre 1.31.0 aunque la versión se compiló contra 1.32.0. /opt/career-ops/src tenía archivos de 1.31.0, así que /api/health en el servidor de producción informaba parentVersion: 1.31.0. Se sincronizaron 95 archivos de ejecución y se eliminaron dos. Detectado por QA de navegador contra la superficie en producción.
  • cvstart.ru anteponía /ru/ a cualquier ruta, sin mirar lo que ya había. cvstart.ru/ru/help pasaba a cvstart.org/ru/ru/help y cvstart.ru/en/help a cvstart.org/ru/en/help; ambos 404. Lo primero es lo que se obtiene al copiar una URL del sitio principal; lo segundo dejaba al visitante angloparlante sin acceso alguno a la versión en inglés. Ahora una ruta que ya nombre uno de los 16 prefijos de idioma pasa tal cual, en se elimina (cvstart.org sirve el inglés en la raíz y no tiene /en/) y el resto sigue recibiendo /ru/. La comprobación está anclada, así que /enterprise/ no se confunde con en.

Cambiado

  • 98 prompts de QA superados movidos a qa/archive/superseded-prompts/. qa/README.md documenta desde v1.137.0 que en el nivel superior solo queda el prompt de la versión actual — la regla no se aplicó durante 95 versiones. Ahora el README dice que el archivado ocurre al publicar una versión, y se corrigen sus propias líneas base obsoletas (≥1238 → ≥3009 pruebas, 13 → 17 idiomas).
  • Otros cinco prompts salieron del nivel superior: se declaraban vigentes sin serlo. REGRESSION-FINAL.md se llamaba a sí mismo «el único prompt de regresión autorizado e independiente de versión», pero sus §§11–15 son cinco registros de ciclos cerrados (v1.55.x → v1.59.7) y su línea base de ayuda declarada era 28 H2 / 103 H3 en 16 idiomas frente a 32 / 122 y 17 hoy: un agente de QA que lo siguiera habría fallado en §9 al primer paso. QA-REGRESSION-PROMPT.md tenía nombre sin versión y contenido de v1.226.0. QA-REGRESSION-PROMPT-v1.76.0-FULL.md estaba fijado 155 versiones atrás, REGRESSION-PROMPT-FINAL.md cierra el ciclo v1.58.52 → v1.59.10 y QA-FULL-REGRESSION.md los duplicaba a todos con base v1.131.1. Con ellos se fue PARENT-SYNC-WORKLIST-v1.26.0.md (el proyecto padre ya va por 1.32.0) y el fechado UX-AUDIT-2026-07-06.md pasó a reports/. Quedan cuatro drivers, con sus líneas base actualizadas a los valores reales de v1.231.2. qa/README.md se reescribió para describir la carpeta que existe.

Notas

  • Dos trampas de especificidad quedan comentadas en app.css: las reglas de escritorio se declaran más abajo que el bloque max-width: 900px, así que a igual especificidad ganan — el gap móvil se quedó en 24 px (lo que desbordaba 320 px) y el icono siguió oculto (botones cuadrados vacíos).
  • applyI18n() asigna el.textContent, así que la clave i18n pasó del <button> a un <span class="btn-label"> interno; en el botón habría borrado el icono en cada cambio de idioma.
  • El primer rsync del padre se abortó a mitad en un archivo que ya no existe localmente, dejando un despliegue parcial que informó éxito. Los despliegues del padre se verifican ahora por suma de comprobación, nunca por código de salida.

[1.231.1] — 2026-09-06

Corregido — el barrido de recuentos de v1.231.0 reescribió el nombre de la cuenta en todos los enlaces.

Corregido

  • Cada enlace Fighter90 pasó a Fighter92. El barrido del recuento de fuentes de v1.231.0 sustituía 90 por 92 con una guarda de límite de dígito, que descarta correctamente 290 y 901 pero coincide con el 90 dentro de Fighter90: delante hay una letra y detrás el token simplemente termina. 562 enlaces en los 17 README, los 17 paquetes de ayuda y los espejos de ayuda del sitio, más 138 en el wiki, apuntaban a una cuenta inexistente; el banner providers.png y todos los enlaces «editar en GitHub» daban 404. Lo detectó un usuario, por la imagen que faltaba.
  • Cinco referencias históricas a versiones se desplazaron. El barrido 85 → 87 convirtió v1.85.0 en v1.87.0, inventando una versión que nunca se publicó.

Notas

  • Un límite de dígito no basta: protege un número de los dígitos vecinos, no de formar parte de un identificador o de una cadena de versión. Los recuentos en sí son correctos: 92 fuentes, 87 adaptadores EN, 3009 pruebas.
  • El repositorio padre no se vio afectado.

[1.231.0] — 2026-09-05

Añadido — dos fuentes de escaneo del career-ops padre v1.32.0 (Collage, Telegram estricto), el modo REST de Gem y un defecto que el port sacó a la luz en el código del propio padre.

Añadido

  • Dos fuentes nuevas, 90 → 92 (87 EN + 5 RU), ALL_ADAPTERS 85 → 87. Ambas sin token.
    • Collage (provider: collage) — la API pública de sitios de empleo de Collage HR. El segmento de ruta es la dirección del sitio que el inquilino eligió, no un slug del nombre de la empresa, así que se lee de api: o de una URL secure.collage.co/jobs/<dirección> y nunca se adivina: una dirección adivinada escanearía el tablero de otra persona. Una entrada que no nombre ninguna de las dos falla ruidosamente.
    • Telegram (estricto) (provider: telegram-channel) — lee las mismas vistas previas t.me/s/<canal> que la fuente telegram existente, con el intercambio opuesto: un mensaje se convierte en fila solo si nombra a un empleador Y enlaza a una página de vacante. Se descartan los resúmenes, los mensajes de empleador oculto y los de solo contacto. Poca cobertura por diseño —aguas arriba se midió un 32-77% de mensajes que pasan— pero cada fila lleva un empleador real y un enlace real. Ambas se publican, como en el padre: son productos distintos, no duplicados.
  • Gem ganó un modo REST opcional junto a su ruta GraphQL (api: https://api.gem.com/job_board/v0/<board>/job_posts). Nunca se deriva de un id de tablero —una URL derivada sería una suposición sobre qué superficie expone un inquilino— y la ruta GraphQL sigue siendo la predeterminada. Portado del padre #3783.

Corregido

  • El filtro de ubicación de telegram-channel no veía el cirílico y nombraba ciudades como empleadores. El LOCATIONISH_RE del padre usaba \b, un límite solo ASCII que nunca se activa junto al cirílico, así que todas sus alternativas rusas (москва, спб, офис, удалёнк*) eran inalcanzables y Senior Engineer | Москва devolvía «Москва» como empleador. Es exactamente la sustitución de campo equivocado que toda la política de atribución existe para impedir, y golpeaba al público principal del proveedor: un canal en ruso escribe la ubicación en cirílico. El port de web-ui usa comprobaciones Unicode (\p{L} / \p{N}), lo mismo que el padre ya hace una constante más abajo en ROLE_WORD_RE. Fijado por una prueba que falla con la forma ASCII. Aún no corregido aguas arriba.

Notas

  • El trabajo de recuperación por facetas de Workday del padre (#3851, #3874) no necesitó port. web-ui no pagina ni divide Workday por facetas en absoluto —un solo POST con offset: 0—, así que ninguna de esas rutas de código existe aquí. Misma conclusión que en v1.229.0, verificada de nuevo.
  • providers/_types.js cambió solo en documentación (el campo opcional Job.salary).
  • Pruebas: 2962 → 3009 (+47).

[1.230.0] — 2026-09-05

Cambiado — tres correcciones de proveedores desde el career-ops padre v1.32.0, y un defecto que el propio port sacó a la luz.

Añadido

  • Welcome to the Jungle acepta una expresión filters de Algolia aplicada en el servidor (wttj: { filters: "offices.country_code:FR AND contract_type:full_time" }). Una palabra clave por sí sola no estrecha un tablero global: es el ranking de relevancia de Algolia, y no los filtros del escáner, quien decide qué max_hits resultados llegan — los nuestros se aplican después, sobre lo ya recibido, así que todo lo que queda más allá del tope es invisible por bien que encaje. Un filtro reduce el conjunto en lugar de reordenarlo, de modo que el tope por consulta sube de 200 a 1000. Con un filtro, queries pasa a ser opcional: la consulta vacía significa «todo lo que pase el filtro». Portado del padre #3757.

Corregido

  • Radancy ya no avisa «truncated» cuando un inquilino simplemente sirve menos ofertas de las que anuncia su propio banner. El padre contrastó data-total-results con nueve inquilinos reales: cinco coincidieron, pero cuatro — uno pequeño y otro enorme — entregaron entre un 10 % y un 56 % menos de ofertas únicas de las anunciadas, sin una sola fila duplicada. El banner exagera lo que el índice de búsqueda del inquilino sirve realmente, así que comparar el recuento acumulado contra él daba falsos positivos en la mayoría de tableros. El aviso ahora solo aparece cuando el recorrido lo detuvo nuestro propio max_jobs/max_pages, el único caso sobre el que quien llama puede actuar; totalResults queda en el mensaje como contexto y nunca decide. Portado del padre #3839.
  • Radancy evita la caché en cada petición de fragmento JSON. Una capa de caché delante de esa ruta repite respuestas obsoletas en algunos inquilinos: la misma oferta reaparece páginas después mientras otra no se sirve nunca, reproducido nueve veces de nueve aguas arriba. Las cabeceras Cache-Control/Pragma no cambiaron nada; un parámetro aleatorio por petición sí. Debe ser único por llamada, nunca derivado del número de página: un parámetro determinista sigue dando a la caché una clave estable y, con ella, la misma correspondencia estable y equivocada. Portado del padre #3839.
  • Las listas de oficinas de Greenhouse tienen orden determinista. Greenhouse no promete el orden de offices[] entre respuestas, y el conjunto recuperado del árbol /offices va en su orden de recorrido. Ambos llegan al campo location de la oferta, así que un tablero que reordenase sus oficinas reescribía esa cadena — y una oferta sin cambios se leía como nueva. El padre arregló el conjunto (#3839); web-ui tenía una segunda instancia del mismo defecto que el padre no tiene — el respaldo offices[0], usado cuando la oferta no trae localización propia — y ambos van ahora ordenados.

Notas

  • La corrección de iCIMS del padre (#3728) no necesitó port. Lee todas las entradas jobLocation del JSON-LD de la página de detalle, porque algunos inquilinos emiten una entrada íntegramente UNAVAILABLE antes de la dirección real. El iCIMS de web-ui toma la localización del HTML de la página de listado y nunca abre una de detalle, así que esa ruta de código no existe aquí.
  • El cambio de _http.mjs no necesitó port. Extrae isRefusedRedirectError para que discover-ats.mjs y la capa de reintentos dejen de discrepar sobre el mismo error. El comportamiento de isRetryableError no cambia, y web-ui no tiene un segundo consumidor con quien compartir el veredicto.
  • La deduplicación por localización (#3751) no se replica: vive en el pipeline scan.mjs de la CLI del padre; la ruta de escaneo de web-ui tiene la suya.
  • Registro sin cambios: 90 fuentes (85 EN + 5 RU). Pruebas: 2956 → 2962 (+6).

[1.229.0] — 2026-09-03

Añadido — cuatro fuentes de escaneo del career-ops padre v1.31.0: Built In, Feishu Jobs, Garena y MokaHR.

Añadido

  • Cuatro fuentes nuevas, 86 → 90 (85 EN + 5 RU), ALL_ADAPTERS 81 → 85. Las cuatro sin tokens: ni clave de API ni inicio de sesión.
    • Built In (provider: builtin) — el tablón tecnológico estadounidense, en sus nueve mercados. Los empleadores publican allí directamente, así que es un agregador como RemoteOK. No trae consulta por defecto: una entrada sin queries: ni categories: no escanea nada y lo dice, porque una fuente compartida no debe llevar los términos de búsqueda de una persona. Un mercado fuera de la lista permitida se rechaza, nunca se sustituye en silencio por el tablón nacional.
    • Feishu Jobs (provider: feishu-jobs) — 飞书招聘, el endpoint que llama la propia página de empleo del inquilino. Dos formas de host y ninguna más. Usan rutas distintas para la página de la vacante, así que una sola plantilla habría generado enlaces muertos para la mitad.
    • Garena (provider: garena) — una sola empresa, un host fijo. office da forma al enlace, no al listado.
    • MokaHR (provider: mokahr) — SaaS de RR. HH. china. La respuesta viene cifrada: un sobre AES-128-CBC cuya clave viaja junto al texto cifrado. Es ofuscación, no seguridad, pero un análisis JSON normal no ve nada, así que descifrar es parte de leer el tablón.

Corregido

  • htmlToText podía dejar pasar un abridor de etiqueta incompleto al texto plano. safe &lt;img src=x onerror=1 salía como safe <img src=x onerror=1. Ahora cada decodificación va seguida de un barrido de marcado y el abridor final pierde su ángulo: el texto sigue legible y queda inerte. Portado del padre (#3491).

Notas

  • Del arreglo de Workday del padre no se portó nada. Recupera inquilinos cuyo backend limita la paginación en el offset 2000 — web-ui no pagina Workday en absoluto (un POST en offset: 0, 100 filas), así que no hay límite que sortear.
  • Los arreglos de verify-cv-facts y merge-tracker no necesitaron port: web-ui ejecuta esos scripts desde el checkout del padre en vez de replicar su lógica.
  • El salario de Built In se formatea aquí como texto: el parseSalary del padre devuelve { min, max } y el contrato de web-ui guarda salary como cadena.
  • Pruebas: 2909 → 2956 (+47).

[1.228.5] — 2026-08-30

Corregido — la versión informada describía el archivo en disco, no el código en ejecución.

Corregido

  • /api/health releía package.json en cada petición, así que un proceso obsoleto informaba una versión que no estaba ejecutando. QA encontró una instancia local respondiendo version: 1.228.4 mientras devolvía 404 para /api/ping, una ruta añadida en 1.228.3: arrancada antes del despliegue, sirviendo el código viejo e informando la versión del archivo nuevo. Así es exactamente como un despliegue que copia archivos pero nunca reinicia parece un éxito: la única cadena que revisa un operador es la que no puede ver el problema. Misma clase que el enlace simbólico de v1.228.1, donde rsync abortaba a mitad de transferencia y aun así informaba éxito.

    La versión se captura ahora al cargar el módulo, de modo que describe el código en ejecución. Un proceso obsoleto informa la versión antigua — la verdad, y visible de inmediato. parentVersion sigue leyéndose por petición: describe el checkout del proyecto padre, que este proceso no carga y que legítimamente puede cambiar bajo un servidor en marcha.

[1.228.4] — 2026-08-30

Corregido — la sonda de vida sin autenticación hacía trabajo de disco en cada petición.

Corregido

  • GET /api/ping leía y analizaba package.json en cada petición. Es el único endpoint alcanzable sin credenciales, así que una lectura de disco más un análisis JSON por petición es una palanca de denegación de servicio entregada a cualquiera — CodeQL lo marcó como falta de limitación de tasa. La versión se lee ahora una sola vez al registrar la ruta; no puede cambiar sin reiniciar, así que el manejador no hace E/S alguna. Limitar la tasa habría acotado el daño; quitar el trabajo quita la palanca.
  • El enmascarado del nombre real del titular del perfil fuera de loopback queda fijado por una prueba. /api/health ya lo sustituía por hidden, pero solo porque esa fila comparte el guardián hidden ?? value con la raíz del proyecto. Nada lo comprobaba, así que una edición que le diera valor propio habría filtrado el nombre de una persona real a la red local sin que nadie lo notara.

[1.228.3] — 2026-08-30

Fixed — the docs assistant could not answer in English, Telegram company names arrived truncated, and a monitor had no way to see the service was up.

Fixed

  • The docs assistant answered “no matching help sections were found” in English while the same question worked in Russian. Retrieval was fine; the context builder was not. buildAskPrompt did if (ctx.length + sec.body.length > MAX_CONTEXT) break — a break, so one oversized section ended the loop and left the context empty, and the assistant truthfully reported it had nothing. The English §5 had grown to 16 081 bytes against a 14 336-byte budget (partly because v1.228.0 added telegram_channels to it); the Russian §5 was 13 009 and fit. Same ranking in both languages — §5 was first — only the size differed.

    Two changes: a section past 6 KB is now split at its ### boundaries, so telegram_channels is its own retrievable chunk (1 340 bytes) that ranks first in both languages; and an oversized section is skipped or truncated rather than ending the loop. The guide went from 32 chunks to 75.

  • A Telegram company label yielded the sentence after it, not the name. Компания: FinCore Technology (продуктовая разработка, высоконагруженные системы) became FinCore Technology (продуктовая разработка, высоконагруженны — capped at 60 characters, cut mid-word. Three of 141 rows. The name now ends at the first separator that introduces a description — a spaced dash (so Coca-Cola survives), a comma, semicolon, pipe or opening parenthesis — and any remaining cap falls on a word boundary. This source attributes a company as fact; a truncated sentence is as wrong as a guessed name, only less obviously so.

Added

  • GET /api/ping — a liveness probe that leaks nothing. { ok, version } and no more. /api/health reports absolute paths, the profile owner’s real name and which API keys are set: legitimate for an authenticated operator, never for the public internet. A monitor behind a Basic-auth gate previously had no way to see the service was up at all.

[1.228.2] — 2026-08-30

Corregido — una búsqueda de dos palabras en /api/scan-results buscaba la frase, no las palabras, y contaba de menos.

Corregido

  • ?q= ahora compara cada término por separado en lugar de la frase literal. La consulta era un único includes(q) sobre título, empresa y ubicación, así que продакт менеджер solo encontraba filas con esas dos palabras exactamente contiguas: 32 de las 162 que contienen ambas.

    Así una coincidencia estrecha se convierte en una respuesta equivocada. El asistente de Telegram informó 9 — con honestidad, porque 9 fue lo que dijo el endpoint. Su recuento era veraz; el del endpoint no.

    La comparación es Y entre términos: una fila debe contener todos. менеджер разработчик devuelve cero, no la unión.

[1.228.1] — 2026-08-29

Corregido — un enlace simbólico roto en el repositorio que truncaba despliegues en silencio.

Corregido

  • .claude/skills/refero-design era un enlace simbólico versionado a una ruta que nunca ha existido aquí. Apuntaba a ../../.agents/skills/refero-design, es decir a .agents/ en la raíz del repositorio; ese directorio no existe, y el enlace llegó en el paquete de paridad v1.118.0 desde una máquina con otra anidación de carpetas. Desde entonces, quien clonara el repositorio obtenía un enlace roto.

    No era inofensivo. rsync aborta al llegar a él — un despliegue de hoy informó éxito mientras el servidor seguía con la versión anterior, y la discrepancia solo salió a la luz porque después se comprobó la versión. El node test-all.mjs del proyecto padre también falla cuando hay un checkout de web-ui dentro de su árbol. Nada lo referenciaba: las únicas coincidencias de refero son la palabra polaca preferowany.

[1.228.0] — 2026-08-29

Añadido — canales de Telegram como fuente de escaneo y un modo de consulta paginado en /api/scan-results.

Añadido

  • Los canales de Telegram son ahora una fuente escaneable. Un bloque telegram_channels: en portals.yml los lista; cada uno se lee desde la vista previa pública https://t.me/s/<canal>. Registro: 85 → 86 fuentes (81 EN + 5 RU), ALL_ADAPTERS 80 → 81.

    Telegram no publica RSS y la Bot API no puede leer un canal que el bot no administra, así que ninguna de las dos vías obvias sirve. La vista /s/ es HTML plano renderizado en servidor: sin autenticación, sin JS, sin cookies. Verificado en 16 canales: 15 devolvieron 20 publicaciones cada uno con un GET simple.

    channel: acepta cualquier forma de escribirlo. El analizador se ancla en las tres cosas que un rediseño de Telegram moverá al final: data-post, el contenedor del texto y la fecha ISO <time datetime>.

  • GET /api/scan-results incorpora un modo de consulta paginado. ?q=<texto>&limit=50[&region=ru|en][&set=filtered|fresh][&offset=N] devuelve { total, returned, offset, limit, set, rows }, donde total es el recuento ANTES de paginar. Sin parámetros la ruta sigue devolviendo el snapshot íntegro: la tabla #/scan depende de esa forma.

Corregido

  • El asistente de Telegram respondía con una fracción de los datos. Su skill ya apuntaba a /api/scan-results, pero la ruta solo sabía devolver el snapshot completo —unos 2 MB— que no cabe en el contexto de un agente. Ante «encuentra todos los Product Manager» informó 9 coincidencias donde el snapshot tenía cientos, y dijo que «trabajaba con un índice local limitado». Era cierto: veía el endpoint pero no podía consumirlo. Con el modo paginado la misma pregunta cuesta 2 KB y devuelve un total honesto. El skill también se reescribió: pedía resumir las publicaciones nuevas, es decir apuntaba a fresh en lugar de filtered.

Notas

  • Una publicación de canal es prosa, no un registro de empleo. No hay campos estructurados: se toma el título de la primera línea sustantiva, y empresa/ubicación/salario de etiquetas explícitas. Separar ofertas reales de anuncios y resúmenes queda en manos del title_filter existente, el mismo ya afinado para otras fuentes. Son pistas, no un listado.
  • La empresa nunca se adivina. Sin etiqueta, la fila se atribuye al canal (@rabotaphp): un empleador equivocado entra en el tracker como un hecho.
  • Un análisis vacío es un error, no «no hay vacantes». t.me responde con una redirección a un canal privado o inexistente; devolver cero filas se leería como un día tranquilo y ocultaría una errata en la configuración para siempre. jobGeeks hace exactamente eso (HTTP 302) y deliberadamente no está configurado.
  • La detección de remoto usa una frontera de palabra Unicode en lugar de \b, que es solo ASCII: la misma trampa que el filtro de títulos sufrió en v1.227.3, aquí detectada por un test antes de publicar.
  • 15 canales configurados y verificados en vivo: 299 publicaciones, 15/15 canales. 30 tests nuevos. Pruebas: 2865 → 2895.
  • Los recuentos de tests del README saltaron cientos — y no, esta versión no los añadió. Los README localizados llevaban totales congelados: 1856 / 1945 / 1955 / 549 / 419 / 284 / 2527, cada uno obsoleto desde una versión distinta, y varios contradiciendo la insignia de su propia página. Ahora todos dicen 2895, verificado ejecutando la suite y no copiando una insignia. Igual de obsoletos estaban Playwright (70 / 90 / 12 → 101), e2e smoke (20 → 21), módulos de rutas (12 / 32 → 37), flujos smoke (12 → 22) y el número de archivos de test (218 → 329). La fila Total repetía el recuento unitario y por eso no sumaba las tres suites de debajo; ahora muestra la suma real (3040).

[1.227.5] — 2026-08-29

Corregido — el escaneo regional agotaba la memoria del servidor y una suite de Playwright fallaba por su propio desmontaje.

Corregido

  • El escaneo regional mataba el servidor por OOM. ru-scanner acumulaba todos los resultados en bruto de todas las consultas y solo deduplicaba y filtraba al final. La lista de consultas está deliberadamente llena de casi sinónimos (Golang, Go разработчик, Golang разработчик, Senior Go…), así que la misma vacante vuelve una vez por consulta y el array retenía varias veces el recuento único — con las descripciones, que son el grueso del objeto y que los filtros descartan casi por completo. Medido: 742 MB de heap en una ejecución real de 21 consultas. El servidor limita el heap de Node a 490 MB (V8 lo dimensiona a partir de los 956 MB de RAM de la máquina), así que el escaneo tumbaba el servicio con FATAL ERROR: Reached heap limit — cuatro veces en un día. Ahora la deduplicación y el filtrado ocurren por consulta, y la misma ejecución bajó a 177 MB, un 76% menos. Los recuentos informados no cambian: el total único lo lleva un Set de cadenas URL, así que «Total found» significa exactamente lo mismo que antes.
  • net::ERR_SOCKET_NOT_CONNECTED hacía fallar CI al azar. Tres suites de Playwright llaman a window.stop() para detener las peticiones en vuelo antes de afirmar que no hubo errores de consola. Chromium informa de ese único acto deliberado con tres nombres según dónde estuviera el socket: ERR_ABORTED al cancelar, ERR_EMPTY_RESPONSE en vuelo y ERR_SOCKET_NOT_CONNECTED cuando el socket ya no estaba. El filtro compartido solo listaba los dos primeros, así que el mismo desmontaje tumbaba una ejecución al azar. Apareció en el barrido de locales en tr — la suite con más navegaciones — mientras que playwright-forms y playwright-smoke, que también llaman a window.stop(), estaban a una ejecución del mismo fallo.

Notas

  • Por qué el escaneo empezó a fallar ahora. La lista de consultas pasó de 14 a 21 al añadir búsquedas de product manager. La memoria de la implementación anterior escalaba con el total de resultados, así que un 50% más de consultas superó el techo de la máquina. Las consultas nuevas no tienen nada de malo: solo expusieron un patrón de asignación que llevaba mucho tiempo a un cambio de configuración de fallar.
  • El cambio de deduplicación preserva el comportamiento, y eso está probado, no afirmado. tests/ru-scanner-dedup-order.test.mjs fija la dirección incómoda —un duplicado posterior que NO pasa los filtros debe expulsar a uno anterior que sí pasó, porque la pasada final antigua conservaba solo la última copia y luego la filtraba— y ejecuta 200 comprobaciones aleatorias de equivalencia contra la implementación anterior.
  • El filtro de consola sigue estrecho. Solo la familia de peticiones canceladas es benigna. Conexión rechazada, fallo de DNS, reinicio, cualquier 5xx y toda excepción JS no capturada siguen haciendo fallar la aserción, fijado por tests nuevos.
  • Dos hipótesis se probaron y descartaron en vez de enviarse: un timeout de Caddy (su configuración está limpia, flush_interval -1 es correcto para SSE) y el requestTimeout por defecto de 5 minutos de Node (una reproducción a escala mostró que no corta una respuesta larga en streaming). La evidencia real fue Reached heap limit en el journal. Pruebas: 2860 → 2865.

[1.227.4] — 2026-08-29

Corregido — word: / stem: seguían ignorándose en content_filter; la v1.227.3 solo arregló el filtro de títulos.

Corregido

  • content_filter no respetaba los prefijos. La v1.227.3 enseñó a title_filter los prefijos word: / stem: del padre, pero dejó a content_filter con su propio lower.includes(k) en crudo, así que allí una entrada con prefijo seguía comparándose como el texto literal "word:java" — el mismo no-op silencioso, un filtro más allá. content_filter lee la DESCRIPCIÓN de la oferta y su valor por defecto siempre fue subcadena simple: por eso un negativo simple java rechaza todo lo que apenas mencione «JavaScript»; el prefijo es la forma de excluir una entrada de esa regla. Portado el compileContentKeyword del padre, que comparte la maquinaria de prefijos pero deliberadamente no ancla palabras cortas: el filtro de títulos ancla siglas de 2–3 letras porque «COO» dentro de «Coordinator» siempre es un error, mientras que una secuencia corta dentro de un párrafo de prosa suele ser intencionada (aws, gcp, sql, go).

Notas

  • Detectado en la revisión por IA del PR de la v1.227.3, que notó que el código no cubría lo que afirmaba su propio comentario: decía «title_filter / content_filter» cuando solo estaba conectada la ruta de títulos. Ahora el comentario distingue explícitamente: ambos filtros respetan los prefijos, solo el de títulos ancla siglas.
  • También verificado a partir de esa revisión, y sin cambios: pasar la regla de siglas de 2–3 letras de \b a la frontera Unicode es idéntico en comportamiento para ASCII y más estricto solo donde debe serlo — COO_lead y coo1 se conservan (guion bajo y dígito son caracteres de palabra en ambos), Coordinator se conserva, y «Директор COO» y «VP «Продукт»» se descartan correctamente. 14 casos límite comprobados.
  • Las entradas sin prefijo de content_filter mantienen su coincidencia previa byte a byte, una descripción vacía o ausente sigue pasando, y un word: / stem: vacío no coincide con nada en vez de con todo. 6 casos nuevos en tests/content-filter.test.mjs. Pruebas: 2852 → 2858.

[1.227.3] — 2026-08-29

Corregido — los prefijos word: / stem: del filtro de títulos se ignoraban en silencio, dejando esas líneas de filtro sin efecto.

Corregido

  • web-ui no implementaba los prefijos word: / stem: del proyecto padre. title_filter (y content_filter) usan por defecto coincidencia de subcadena sin distinguir mayúsculas, y por eso un negativo simple intern también rechaza «International Product Manager» e «Internal Tools Engineer». En vez de cambiar ese valor por defecto —un cambio rompedor para toda instalación configurada— el padre hizo la precisión opcional por entrada: word:intern solo coincide con la palabra completa y stem:agent debe iniciar una palabra pudiendo continuar (separando «Agentforce» de «Reagents»). web-ui no tenía ninguno, así que hacía coincidir la entrada con el texto literal "word:intern", que no aparece en ningún título de empleo. La línea quedaba en nada: el mismo portals.yml filtraba bien por CLI y nada aquí, de modo que cada oferta de prácticas excluida seguía llegando a la tabla #/scan. Portado del title-keywords.mjs del padre, incluida la frontera de palabra compatible con Unicode (\p{L}\p{M}\p{N}_) en lugar de \b, que es solo ASCII y coincidía dentro de palabras en títulos acentuados o cirílicos.

Notas

  • Detectado al revisar un portals.yml real que acababa de adoptar word:intern precisamente para que un intern simple dejara de descartar «International Product Manager». El prefijo funcionaba en la CLI; web-ui se quedaba con todas las prácticas en silencio. Ahora el comportamiento es idéntico.
  • Los valores por defecto no cambian. Una palabra clave sin prefijo mantiene su coincidencia previa: subcadena para frases y para todo lo que contenga caracteres no alfabéticos (.NET, SAP , L&D), y anclaje a límites de palabra para siglas de 2–3 letras. Un word: / stem: vacío se trata como errata y no coincide con nada: como negativo, un patrón vacío coincidiría con todos los títulos y anularía un escaneo entero por un solo dos puntos suelto.
  • La regla de siglas de 2–3 letras pasa a usar esa misma frontera Unicode: antes usaba \b, así que coo y word:coo discrepaban en títulos no ASCII — dos formas de escribir una misma regla.
  • 6 casos nuevos en tests/title-filter.test.mjs. Pruebas: 2846 → 2852.

[1.227.2] — 2026-08-29

Corregido — #/config se desplazaba lateralmente en móviles estrechos.

Corregido

  • MOBILE-1: #/config desbordaba horizontalmente en viewports ≤ 352 px. Cada campo de tipo select llevaba un min-width: 300px en línea. Un estilo en línea gana a la hoja de estilos, así que el control no podía encogerse con su contenedor: en un viewport de 320 px el borde derecho del select caía en 353 px, la página ganaba scroll horizontal y los controles se salían por la derecha. Afectaba a los 18 selects de proveedor/modelo (cfg-llm-provider y todos los cfg-*-model). Ahora min-width: min(300px, 100%) + max-width: 100%, de modo que los 300 px cómodos se aplican solo cuando hay espacio. Dispositivos en el umbral o por debajo: iPhone SE (1.ª gen, 320 px), Android antiguos, pantalla exterior del Galaxy Fold.

Notas

  • Por qué las pasadas móviles anteriores no lo vieron. 360 px y 375 px tienen justo el margen suficiente para ocultar el desbordamiento, y las suites de Playwright corren en una ventana de 1280 px. Nada en CI había medido nunca un viewport más estrecho que 375 px. El nuevo tests/playwright-narrow-viewport.mjs recorre las rutas con formularios a 320 px y falla ante cualquier desbordamiento horizontal, nombrando en el mensaje el elemento más ancho; un segundo caso fija que ningún select de #/config sobrepasa el viewport. Se verificó primero que reprodujera el fallo (scrollWidth 353 > clientWidth 320, 18 selects con borde derecho 353). Playwright: 99 → 101.
  • Conocido y deliberadamente aún sin gate: 280 px (pantalla exterior del Galaxy Fold) sigue desbordando ~5 px, por una causa distinta — contenido largo dentro de los bloques <details> de la tarjeta de configuración y la barra lateral de 256 px frente a un viewport de 280 px, no el min-width en línea que corrige esta versión. Reflujar a un ancho que ningún móvil mayoritario declara es trabajo aparte; la lista WIDTHS de la suite ya está preparada.
  • También se corrigió el prompt de QA de v1.227.1, que llamaba a la ruta de seguimiento #/modes/followup. Las mode-pages se registran con el slug desnudo (Router.register(cfg.slug)), así que la ruta es #/followup y #/modes/followup da 404 correctamente. Solo documentación.
  • Suite unitaria sin cambios en 2846 (la cobertura nueva es de navegador).

[1.227.1] — 2026-08-29

Corregido — el desglose de fuentes de la ayuda no cuadraba consigo mismo y el banner de GitHub se imprimía como marcado en bruto en la app.

Corregido

  • §17 daba un total y un desglose que no sumaban. Todos los paquetes de ayuda decían «el registro server/lib/sources/ incluye 85 adapters — 78 en inglés + 5 rusos». 78 + 5 = 83, no 85; en ja/ko la deriva llegaba a 77 (= 82). Cada fuente nueva subía el total y dejaba el sumando intacto, y ambos números llevaban varias versiones divergiendo. Ahora el desglose es 80 EN + 5 RU en las 17 locales. Importa más que la estética: docs/help/<lang>.md es el ÚNICO corpus que fundamenta «Ask the docs», así que quien preguntara cuántas fuentes hay recibía dos números contradictorios de una misma frase.
  • La misma frase fijaba un ancla de versión obsoleta. «A partir de v1.213.0 el registro …» sobrevivió en 16 locales mientras el contador se movía dos veces (83 en v1.219.0, 85 en v1.226.0). El ancla ya se había arreglado a mano en v1.210.1 y simplemente volvió a derivar. Se elimina en favor de una redacción sin versión: el contador se comprueba contra el registro vivo, que no puede quedarse obsoleto.
  • El banner de proveedores se imprimía como marcado escapado al inicio de #/help. v1.225.0/v1.225.1 lo daban por «eliminado en la app por UI.md() (sin soporte de imágenes)». Eso mezcla dos cosas: UI.md() escapa primero — escapa cada byte del origen antes de cualquier transformación markdown, que es la frontera XSS correcta pero lo contrario de eliminar. Nadie quitaba el banner porque nadie lo intentaba, y las 17 locales abrían #/help con 268 caracteres de <p align="center"><img src="https://… literal. Un nuevo punto de entrada compartido (server/lib/help-markdown.mjs::stripGithubOnlyBlocks) descarta bloques HTML de imagen aislados al leer, tanto para GET /api/help/:lang como para el corpus del docs-assistant. Los archivos conservan el banner, así que GitHub sigue mostrando el escaparate.
  • La columna «#» del tablero de cadencia estaba vacía desde v1.117.0. Leía entry.appNum, pero followup-cadence.mjs emite num; appNum nunca existió en el payload. Ahora se leen ambos. La línea «Next up» de v1.227.0 heredó el mismo fallo y se corrige con ella.

Notas

  • Los cuatro pasaron desapercibidos porque nada los vigilaba. Los gates de ayuda (canonical-docs-coverage, help-ui, help-ru-config-section) cuentan ENCABEZADOS — 32 H2 / 121 H3 — y ningún test había leído nunca un número dentro de un párrafo ni mirado qué sirve realmente la entrada de ayuda. Dos suites nuevas lo cierran: tests/help-source-counts.test.mjs (el total declarado es igual a SOURCES.length; los sumandos son el reparto EN/RU vivo y suman el total; el párrafo no fija versión) y tests/help-banner-strip.test.mjs (el banner permanece en disco para GitHub, nunca sobrevive al filtrado, la primera línea renderizada de cada paquete es su encabezado, el filtrado sigue siendo estrecho y ambos consumidores pasan por él). Se verificó que ambas fallaran contra estos mismos errores antes de confirmarlas. Pruebas: 2837 → 2846.
  • Sin cambios en registro, escáner ni contratos de rutas; las fuentes siguen en 85 (80 EN + 5 RU) y ALL_ADAPTERS 80.

[1.227.0] — 2026-08-28

Añadido — SEEK Hong Kong (JobsDB), un filtro de solo pendientes en los seguimientos y los empleadores confidenciales muestran quién los presentó.

Añadido

  • JobsDB Hong Kong a través de la fuente Jobstreet/SEEK existente. Hong Kong opera bajo la marca JobsDB pero se apoya en la misma plataforma SEEK tras el mismo endpoint v5, así que no necesita una fuente aparte: basta con hk.jobsdb.com en la lista de hosts permitidos y siteKey: HK-Main. El registro no cambia: 85 fuentes (80 EN + 5 RU) y ALL_ADAPTERS 80 — esto es un MERCADO nuevo sobre un adaptador existente, no un adaptador nuevo.
  • Filtro de solo pendientes en el tablero de cadencia (#/modes/followup). Una casilla nueva reduce el tablero a lo accionable ahora mismo. La condición se lee de la urgency que ya calcula followup-cadence.mjs del proyecto padre (urgent / overdue), nunca del status del tracker, que es applied/responded/interview y no coincidiría con nada. Cuando no hay nada pendiente, el tablero nombra el seguimiento programado más próximo en lugar de mostrar un vacío. La lógica vive en el módulo puro public/js/lib/followup-view.js, espejo de followup-view.mjs del padre.
  • Los empleadores confidenciales se atribuyen a la agencia que los presentó. Una oferta con el empleador reservado se registra como ? en el tracker y el reclutador va en la columna Via. #/tracker mostraba un ? pelado, así que varias filas confidenciales eran indistinguibles y el resolutor de logos no tenía nada que buscar. Ahora esa fila se lee «Confidencial · vía <agencia>», resuelve su logo por el nombre de la agencia y se encuentra por ese nombre en el buscador. Solo presentación: el campo canónico company nunca se sobrescribe con el reclutador. Nuevo módulo puro public/js/lib/company-presentation.js, espejo de company-presentation.mjs del padre.

Corregido

  • Jobstreet/SEEK llamaba a un endpoint obsoleto. La fuente seguía apuntando a chalice-search v4 (/api/chalice-search/v4/search), que SEEK retiró; el padre migró a /api/jobsearch/v5/search y web-ui nunca lo siguió. Ahora toda entrada Jobstreet/SEEK consulta v5. La forma del ítem v5 se lee de forma nativa (URL construida desde id, locations[0].label, advertiser.description, salaryLabel), desaparece el parámetro de proyección solrFields propio de v4, y una entrada cuyo portals.yml aún fije la ruta v4 muerta conserva su hostname de mercado mientras la ruta se reconstruye sobre v5 — sin edición del usuario.

Notas

  • Paridad con career-ops 1.30.0 (HEAD del padre 8d64f65 → 6cc46a4; su VERSION sigue en 1.30.0 — release-please va por detrás de main). No portado, con motivos: relleno de ubicación JSON-LD de iCIMS — la fuente iCIMS de web-ui no tiene gancho enrichDate() (solo páginas de listado), así que no hay ruta que corregir; stderr no fatal del CLI (#1974) — el runner de web-ui decide el fallo solo por el código de salida y nunca tuvo esa heurística; trazado de rutas de Turbopack — web-ui no usa bundler; arreglos de cv-sync-check — se relevan en solo lectura y fail-soft; secuenciación de Playwright en scan.mjs, respuestas Block H, guardas de updater/doctor/update-system, la verificación de sintaxis recursiva, el dashboard en Go y las autopruebas de jd-skill-gap/verify-cv-facts — superficies solo-CLI que web-ui no invoca, o autopruebas sin cambio de comportamiento. Pruebas: 2818 → 2837.

[1.226.0] — 2026-08-27

Añadido — dos fuentes de empleo vietnamitas: ITviec y CareerViet.

Añadido

  • ITviec + CareerViet (Vietnam). Dos portales vietnamitas con host fijado se unen al escáner vía entradas provider: itviec / provider: careerviet: ITviec (itviec.com, tecnología) y CareerViet (careerviet.vn, amplia cobertura). Ambos son scrapers HTML sin tokens, con host-pin anti-SSRF, solo-HTTPS, fail-soft por empresa y topes de página. El registro ya trae 85 fuentes (80 EN + 5 RU), ALL_ADAPTERS 80 — ambos aparecen en el filtro Fuente de #/scan.

Notas

  • Paridad con career-ops 1.30.0 (parentVersion 1.29.0 → 1.30.0). No portado (solo CLI/comunidad o ya cubierto): el Hired Wall, /calibrate, clean-markers, packs de plantillas, doctor, pdf/LaTeX y las CLIs de eval/tracker/scan que web-ui no invoca; el arreglo de _html-to-text y roleFuzzyMatch ya estaban; el prefijo title-filter stem: se difiere. Conjunto de pruebas: 2818.

[1.225.1] — 2026-08-27

Corregido — el banner de logotipos de proveedores ahora se muestra en el README y la ayuda.

Corregido

  • El banner de v1.225.0 no se veía en GitHub: el images/providers.svg tenía un font-family inválido (comillas dobles anidadas) que GitHub rechaza, el <img> usaba un src relativo (el HTML <img> no resuelve rutas relativas en GitHub) y al bloque <p> le faltaba una línea en blanco final. Ahora apunta a un images/providers.png renderizado con URL absoluta y separación por líneas en blanco; el SVG también se corrigió a XML válido.

Notas

  • Solo docs; sin cambios de código ni pruebas. La vitrina de cvstart.org y las fichas de la app ya eran correctas (SVG en línea, no este archivo). 2784

[1.225.0] — 2026-08-27

Añadido — un banner de logotipos de proveedores en el README y la guía de ayuda.

Añadido

  • Banner de logotipos de proveedores. Un único images/providers.svg autónomo — los 18 proveedores de LLM con el logotipo real de marca (simple-icons, CC0) para los 11 que publican uno y un monograma con color de marca para los 7 que no — encabeza ahora el README (×17) y la guía de ayuda integrada. (La app renderiza las fichas con el logotipo real; el banner se muestra en GitHub y el renderizador seguro para XSS lo elimina en la app.)

Notas

  • Solo docs — sin cambios de código ni pruebas; el SVG está incrustado (sin recurso remoto). Fuentes de escaneo sin cambios: 83. Conjunto de pruebas: 2784.

[1.224.0] — 2026-08-26

Cambiado — las fichas de proveedores LLM ahora muestran los logotipos reales de marca.

Cambiado

  • Logotipos reales de proveedores. Las fichas de proveedor — campos de clave API en Ajustes, las filas de Uso, el chip del panel, el resumen “Activo” de Ajustes, el resultado de la ⚡ eval y el banner de bienvenida — ahora muestran el logotipo real de marca de los 11 proveedores que publican un icono de código abierto (Anthropic · Gemini · OpenAI · Qwen · OpenRouter · GitHub · DeepSeek · Kimi · MiniMax · Mistral · Ollama; SVG de una sola ruta de simple-icons, CC0). Los 7 sin icono publicado (Hermes · GLM/Z.ai · Grok · Together · Fireworks · BytePlus Ark · Volcengine Ark) conservan el monograma con color de marca. Sigue siendo seguro para la CSP por construcción: la ruta del logo es una constante estática incrustada — sin recurso remoto, sin innerHTML.

Notas

  • Sin cambios de ruta ni de comportamiento; la misma API ProviderLogo.el(slug). provider-logo.js creció (las rutas de logos incrustadas). Fuentes de escaneo sin cambios: 83. Conjunto de pruebas: 2784.

[1.223.0] — 2026-08-26

Corregido — una evaluación ⚡ en vivo exitosa se pintaba como error; además, instala el coworker de OpenWorker desde su URL de GitHub o .zip.

Corregido

  • Tono del badge de resultado de la ⚡ eval. Una evaluación en vivo exitosa ya no se colorea como fallo. Los proveedores en proceso (OpenRouter, DeepSeek, …) no devuelven code de subproceso, así que el estricto code === 0 era falso y todo éxito salía rojo (aun mostrando “exit 0”). El tono ahora sigue el resultado — solo un subproceso que sale con código distinto de 0 lo pinta de rojo; un resultado en vivo sin código es badge-ok, y el sufijo “· exit N” solo aparece cuando hay un código real.

Cambiado

  • Instala el coworker de OpenWorker desde su URL de GitHub / .zip. La documentación del coworker ahora describe el instalador de un comando (curl … | bash, idempotente — reutiliza un career-ops / web-ui existente) y las tres vías de instalación en OpenWorker (URL de GitHub · .zip · importación de un solo archivo) en docs/integrations/openworker.md, la ayuda §32 (×17) y el README.

Notas

  • Se cerró una brecha de cobertura antigua: la fuente Recruitee ahora tiene una prueba de extremo a extremo del arreglo de ángulo entrecomillado de v1.214.2 (un > dentro de un atributo de etiqueta no debe filtrarse a la descripción). Fuentes de escaneo sin cambios: 83. Conjunto de pruebas: 2783.

[1.222.0] — 2026-08-26

Cambiado — las ayudas de campo de los proveedores extendidos (DeepSeek … Volcengine Ark) ahora están localizadas en los 17 idiomas.

Cambiado

  • Ayudas de campo de proveedores localizadas. Cada config.<slug>Hint de los 11 proveedores compatibles con OpenAI añadidos en v1.216.0–v1.217.0 (DeepSeek · GLM/Z.ai · Kimi · MiniMax · Mistral · Grok · Together · Fireworks · Ollama · BytePlus Ark · Volcengine Ark) — las ayudas de clave API, modelo y URL base en Ajustes → Claves API — ahora se traduce en los 17 idiomas en lugar de recurrir al inglés. Las URLs de registro, los ids de modelo y el marcador ⚡ de eval en vivo se conservan tal cual.

Notas

  • Sin cambios de comportamiento: el hintFallback en inglés de field-specs.js sigue protegiendo una clave ausente, y una nueva prueba de contrato verifica que cada hintKey de los descriptores de campo se resuelve en los 17 idiomas. Fuentes de escaneo sin cambios: 83. Conjunto de pruebas: 2779.

[1.221.0] — 2026-08-26

Seguridad — defensa en profundidad contra DNS-rebinding en la ruta de fetch del escáner.

Seguridad

  • El núcleo HTTP del escáner (fetchJson / fetchText en server/lib/http-json.mjs) ahora resuelve el host de cada fuente en la ruta de red real y se niega a conectar si resuelve a una dirección privada, loopback, link-local, CGNAT o de metadatos de nube — cerrando un vector de DNS-rebinding. Los hosts del escáner ya están fijados a dominios públicos, así que es defensa en profundidad; la ruta de URL del usuario ya tenía fijación de conexión más fuerte (safe-fetch.mjs). El guard corre solo en el transporte fetch real — un fetchImpl de prueba inyectado nunca se resuelve.

Notas

  • Sin cambio de comportamiento para un escaneo sano. Fuentes de escaneo sin cambios: 83. Pruebas: 2775.

[1.220.0] — 2026-08-26

Añadido — escanea varias categorías de Get on Board desde una sola entrada.

Añadido

  • Una entrada getonbrd puede definir categories: [programming, operations-management, machine-learning-ai] (o un único category:) en vez de solo el feed programming; los avisos se deduplican por URL entre categorías, con un tope de 12. Las entradas existentes quedan idénticas (el valor por defecto sigue siendo programming).

Notas

  • Solo servidor; el anclaje de host SSRF (www.getonbrd.com) y redirect:'error' no cambian; un slug de categoría inválido se rechaza antes de cualquier petición. Fuentes de escaneo sin cambios: 83. Pruebas: 2771.

[1.219.0] — 2026-08-26

Añadido — Torre se une al escáner, y ahora toda etiqueta de proveedor es correcta.

Añadido

  • Torre (provider: torre) — el marketplace de talento pan-LatAm de torre.ai, búsqueda pública sin autenticación con roles remotos que no llegan a Greenhouse/Lever/Ashby; una petición acotada por entrada. El registro trae ahora 83 fuentes (78 inglesas + 5 rusas). Además, monograma del proveedor activo en el banner de bienvenida.

Corregido

  • El escaneo de a16z Speedrun se detenía antes de tiempo en un tablero que rota (una página corta a mitad de barrido se tomaba como la última, truncando a 149 de ~300); ahora confía en el total_pages del feed. Y el banner de bienvenida resolvía el proveedor desde un mapa de 4 entradas (mostrando el slug crudo para los otros 14) — ahora usa la etiqueta+monograma de los 18; la nota de proveedores de Ajustes pasa de 7 a 18.

Notas

  • La opción de varias categorías por entrada de Get on Board aún no está aquí (requiere un refactor a nivel de fuente). Pruebas: 2768.

[1.218.0] — 2026-08-26

Añadido — insignias de proveedor (y nombres correctos) allí donde se muestra un proveedor.

Añadido

  • Monogramas de proveedor en el chip del panel, el resumen “Activo” de Ajustes y la cabecera del resultado de la ⚡ evaluación.

Corregido

  • Etiquetas obsoletas: tres superficies resolvían el nombre desde un mapa de 5 entradas (el resultado de evaluación etiquetaba como “Gemini” a cualquier proveedor no-Anthropic); ahora usan ProviderStatus.label (18). El recuento de claves mostraba “/ 7” → 18. La lista de slugs del hint LLM_PROVIDER ganó ark / arkcn en los 17 idiomas.

Notas

  • Solo cliente, seguro para CSP. Sin cambios de servidor. Fuentes de escaneo: 82. Pruebas: 2758.

[1.217.1] — 2026-08-26

Endurecimiento de pruebas — cobertura de endpoint para todo el catálogo de proveedores.

Añadido

  • Una prueba parametrizada de run<Provider> que verifica endpoint, modelo por defecto y autenticación Bearer para Kimi, MiniMax, Mistral y Fireworks.

[1.217.0] — 2026-08-26

Añadido — Ark se une a la lista de LLM (18 proveedores). BytePlus Ark y Volcengine Ark —los modelos Doubao de ByteDance— ya son proveedores ⚡ en vivo, con el mismo núcleo compatible con OpenAI y una URL base por región.

Añadido

  • BytePlus Ark (ARK_API_KEY, internacional) y Volcengine Ark (ARK_CN_API_KEY, China) — Chat Completions compatible con OpenAI vía runOpenAICompatible(). Define ARK_MODEL / ARK_CN_MODEL (un modelo Doubao o tu id de endpoint ep-…); ARK_BASE_URL / ARK_CN_BASE_URL cambian de región. Ambos entran en el orden auto (al final), con su monograma en Ajustes y Uso. La interfaz web abarca ya 18 proveedores en vivo.

Notas

  • Mismo camino de configuración de confianza + comprobación de esquema http(s) que el resto (nunca el validador SSRF de URL de empleo). Fuentes de escaneo sin cambios: 82. Pruebas: 2755.

[1.216.0] — 2026-08-26

Añadido — nueve proveedores LLM más, cada uno con su insignia de marca. Tus evaluaciones ⚡ en vivo ya pueden ejecutarse con DeepSeek, GLM (Z.ai), Kimi (Moonshot), MiniMax, Mistral, Grok (xAI), Together, Fireworks o un Ollama totalmente local — a una clave de distancia, cada uno marcado por un monograma junto a su campo en Ajustes.

Añadido

  • Nueve proveedores compatibles con OpenAI. DeepSeek, GLM (Z.ai), Kimi (Moonshot), MiniMax, Mistral, Grok (xAI), Together AI, Fireworks AI y Ollama (totalmente local, sin clave). Configura una clave — o OLLAMA_BASE_URL para Ollama — en Ajustes y se une al orden auto después de Hermes; fija cualquiera con LLM_PROVIDER. Together también aloja Inkling de Thinking Machines (thinkingmachines/Inkling). La interfaz web abarca ya 16 proveedores en vivo.
  • Insignias de proveedor. Un mosaico con la inicial y el color de marca, seguro para CSP, marca cada proveedor junto a su campo de clave en Ajustes y en su fila de la página de Uso — SVG en línea, sin logos externos, nada sale de tu equipo.

Notas

  • Las URL base de los proveedores son configuración de confianza que pasa por runOpenAICompatible() con comprobación de esquema http(s) (se permite el loopback de Ollama), nunca el validador SSRF de URL de empleo. Fuentes de escaneo sin cambios: 82. Pruebas: 2752.

[1.215.0] — 2026-08-26

Añadido — ejecuta toda tu búsqueda de empleo desde OpenWorker. Un nuevo coworker mueve este pipeline desde la app de coworker de IA open source de Andrew Ng, y la ayuda en la app ahora lo cubre para que el asistente Pregúntale a los docs te guíe.

Añadido

  • Coworker de OpenWorker. Un coworker sin código de OpenWorker — Fighter90/career-ops-coworker — ejecuta toda la búsqueda de empleo (escanear, puntuar, adaptar, rastrear, seguir) y puede abrir este panel, todo desde tu escritorio. Es una persona de un solo archivo en 17 idiomas, verificada como instalable con el propio cargador de OpenWorker.
  • Ayuda §32 “Run it from OpenWorker” (los 17 idiomas) lo documenta, para que el asistente Pregúntale a los docs de la app y el botón flotante de ayuda respondan sobre él. También enlazado desde el pie del landing, el README, la wiki y docs/integrations/openworker.md.

Notas

  • Solo docs + sitio — sin cambios de código de la app. Fuentes de escaneo sin cambios en 82. Conjunto de pruebas: 2742.

[1.214.2] — 2026-08-25

Corregido — la canalización HTML→texto compartida (descripciones de Greenhouse / Recruitee) ya no filtra atributos de etiqueta en la descripción cuando un valor de atributo contiene un carácter >.

Corregido

  • Las descripciones HTML con > dentro de un atributo se limpian correctamente. El removedor de etiquetas tras las descripciones de Greenhouse y Recruitee usaba una coincidencia ingenua que se detenía en el primer >, así que una etiqueta como <a title="salary > 100k"> dejaba el texto de atributo final (100k">) en la descripción. Ahora respeta los valores de atributo entre comillas (simples o dobles), quita el marcado antes de decodificar entidades (para que una comilla codificada dentro de un atributo no se vuelva un delimitador falso), y sigue preservando un <> literal en texto plano.

Notas

  • Conjunto de pruebas: 2742. Fuentes de escaneo sin cambios en 82. Nada más nuevo del padre esta ronda: los demás cambios se retransmiten en solo lectura (arreglos de follow-up / weekly-digest / rejection-latency / salary-gap no requieren cambios en web-ui) o no son superficies de web-ui (un plugin externo X/Xquik BYO-key, arreglos de CLI/verify). Los elementos opcionales — descripciones de detalle de SmartRecruiters, salto de agregadores en detect-reposts, prefijos de título stem: / word:, protección anti-DNS-rebinding — siguen en cola.

[1.214.1] — 2026-08-25

Corregido — las descripciones de Ashby ahora se limitan a la misma longitud que Greenhouse y Recruitee, así que un filtro de contenido se comporta igual en las tres bolsas y una bolsa no puede inflar la caché de escaneo.

Corregido

  • Las descripciones de Ashby ahora respetan el mismo límite de longitud que las demás bolsas. v1.214.0 pasaba el cuerpo de Ashby al filtro de contenido, pero — a diferencia de Greenhouse y Recruitee, que limitan a 4000 caracteres — el de Ashby se mapeaba directo sin límite (algunos eran 4× más largos). Así, la misma palabra de content_filter podía coincidir en Ashby pero no en Greenhouse solo según dónde cayera en el texto, y una bolsa podía añadir cientos de KB a la caché de escaneo que carga #/scan. Ashby ahora se limita a los mismos 4000 caracteres (su texto ya es plano, así que se trunca, nunca se le quitan etiquetas).

Notas

  • Conjunto de pruebas: 2738. Fuentes de escaneo sin cambios en 82.

[1.214.0] — 2026-08-25

Corregido — las descripciones de Greenhouse, Ashby y Recruitee ahora alimentan el filtro de contenido, así que los filtros de palabras / país / visado sí coinciden con esas bolsas; y “Associate <título senior>” (incluidos rangos académicos) ya no se clasifica como entry-level.

Corregido

  • Los filtros de contenido ya funcionan en Greenhouse, Ashby y Recruitee. Estas bolsas ahora llevan el texto completo de cada publicación como su descripción, así que un content_filter (o un filtro de palabras de país / visado) coincide con el cuerpo del puesto en vez de dejar pasar la bolsa a ciegas. Ashby y Recruitee traen el cuerpo en su payload de lista gratis; Greenhouse ya lo hacía. Los tres se depuran por una única canalización HTML→texto compartida.
  • “Associate Director” / “Associate Professor” es senior, no entry-level. El clasificador de seniority (usado por skip_tiers:) tomaba la palabra “associate” como marca de entry-level aun cuando prefija un título senior. Ahora lee “Associate ” — incluidos rangos académicos (Associate Professor, Associate Dean) — como senior, manteniendo las variantes junior reales (Associate Attorney, Associate Editor) como entry-level, y aún clasifica un “Intern, Associate Dean” inicial como prácticas.

Notas

  • Conjunto de pruebas: 2736. Fuentes de escaneo sin cambios en 82. En cola para más adelante: enriquecimiento opcional de descripciones de SmartRecruiters (una petición de detalle por publicación), el salto de tableros aggregator: true de detect-reposts, y los prefijos de título de palabra anclada stem: / word: — cada uno una configuración opcional, no un cambio de comportamiento por defecto. El endurecimiento anti-DNS-rebinding sigue en cola para su propia versión de seguridad.

[1.213.0] — 2026-08-22

Añadido — MyCareersFuture, la bolsa de empleo nacional de Singapur, como fuente de escaneo. Corregido — las vacantes de Greenhouse ahora llevan su texto completo para que los filtros de contenido funcionen, y las vacantes remotas de Ashby ya no quedan ocultas tras una ubicación de solo-ciudad.

Añadido

  • MyCareersFuture (Singapur) (mycareersfuture.gov.sg) — una nueva fuente de escaneo sin tokens para la bolsa de empleo pública nacional de Singapur, gestionada por Workforce Singapore. Selecciónala en el filtro Fuente de #/scan, o añade una empresa con provider: mycareersfuture y una lista opcional de keywords (recurre a los roles objetivo de tu perfil, como Job Bank). Lee la API de búsqueda pública, con host fijado, sin clave.

Corregido

  • Las vacantes de Greenhouse ahora se pueden filtrar por contenido. Las bolsas de Greenhouse se obtienen con el cuerpo completo de la publicación, decodificado a texto plano como la descripción — así un content_filter (o un filtro por palabras de país/visado) que lea la descripción ahora sí coincide con las vacantes de Greenhouse en vez de dejarlas pasar a ciegas.
  • Las vacantes remotas de Ashby ya no se descartan por un filtro de ciudad. Ashby mantiene el modelo de trabajo (Remote/Hybrid/Onsite) separado de la ciudad de la oficina, así que un rol totalmente remoto seguía leyéndose como p. ej. “San Francisco” — y un filtro de ubicación que bloquee esa ciudad ocultaba un puesto que podrías aceptar. Ahora se añade “Remote” a la ubicación cuando el rol es remoto, y workplaceType prevalece sobre un isRemote obsoleto para no etiquetar mal un rol híbrido anclado a oficina.

Notas

  • Fuentes de escaneo: 82 (77 en inglés + 5 rusas). Conjunto de pruebas: 2724. Un endurecimiento contra DNS-rebinding (validar la dirección resuelta de un host antes de conectar) queda en cola para una versión dedicada — necesita un diseño propio de web-ui, no un port directo.

[1.212.1] — 2026-08-21

Corregido — la landing de cvstart.org contaba de menos las fuentes de empleo del escáner (mostraba 80 y omitía Job Bank (Canadá)); ahora vuelve a coincidir con las 81 de la app, y la compilación del sitio falla de forma ruidosa si ambas difieren.

Corregido

  • El recuento de “Fuentes de empleo” de la landing vuelve a estar sincronizado con la app. Tras v1.212.0, cvstart.org mostraba 80 bolsas y le faltaba el nuevo chip de Job Bank (Canadá), mientras que la app, el desplegable de escaneo y la guía de ayuda listaban 81. La landing construye su lista cargando el registro en vivo del escáner, y una fuente no se cargó en esa compilación por cómo importaba una dependencia YAML, así que se descartó en silencio. Ahora Job Bank carga esa dependencia de forma diferida, igual que el resto de la app en el momento del escaneo, así que siempre aparece.
  • La compilación del sitio ahora se niega a publicar un recuento de fuentes desajustado. Si el registro enumera menos fuentes de las que existen en disco (la firma de una fuente que no cargó), la compilación falla con un mensaje claro en vez de publicar el número equivocado.

Notas

  • El comportamiento de la app no cambia — el escáner siempre tuvo las 81 fuentes; solo la landing se vio afectada. Fuentes de escaneo: 81 (76 en inglés + 5 rusas) — sin cambios. Conjunto de pruebas: 2687.

[1.212.0] — 2026-08-21

Añadido — Job Bank (Canadá), la bolsa de empleo nacional federal. Eliminado — EchoJobs (su feed ahora está bloqueado por anti-bots). Corregido — las bolsas con Consider vuelven a dar resultados y las vacantes de Lever multi-ubicación ya no ocultan la mitad de sus ubicaciones.

Añadido

  • Job Bank (Canadá) (jobbank.gc.ca) — una nueva fuente de escaneo sin tokens para el servicio nacional de empleo federal de Canadá, una bolsa de alto volumen que ningún agregador cubre bien. Selecciónala en el filtro Fuente de #/scan, o añade una empresa con provider: jobbankca y una lista opcional de keywords (recurre a los roles objetivo de tu perfil). Lee el feed ATOM público, con host fijado, sin clave.

Eliminado

  • EchoJobs — retirada. Su feed público ahora está tras protección anti-bots y no devuelve nada, así que mantenerla solo malgastaba un espacio de escaneo.

Corregido

  • Las bolsas con Consider vuelven a dar resultados. Consider ahora exige un handshake anónimo (un GET que siembra una cookie de sesión + token CSRF) antes de aceptar la búsqueda; sin él la petición se rechazaba en silencio y la bolsa parecía vacía.
  • Las vacantes de Lever multi-ubicación ya no ocultan la mitad de sus ubicaciones. Lever pone una ciudad principal en location y el resto en allLocations; leer solo la principal hacía que una vacante abierta en Barcelona Y Montevideo pareciera solo-Barcelona (y se descartara por error con un filtro de ubicación). Ahora se fusionan ambas.

Notas

  • Ritmo entre páginas más suave (250 ms, antes 150) en las bolsas paginadas, para ser corteses con los sitios de empleo de un solo host. Fuentes de escaneo: 81 (76 en inglés + 5 rusas) — sin cambios (entra Job Bank, sale EchoJobs). Conjunto de pruebas: 2685.

[1.211.0] — 2026-08-19

Añadido — Yourator, bolsa de empleo tech de Taiwán. Corregido — las entidades acentuadas de título/empresa ahora se decodifican, y una empresa con acento en su nombre ya no se marca por error.

Añadido

  • Yourator (yourator.co) — una nueva fuente de escaneo sin tokens para el mercado tech y digital de Taiwán. Selecciónala en el filtro Fuente de #/scan, o añade una empresa con provider: yourator. Lee la API JSON pública (sin clave, sin navegador), recorre todas las páginas del portal y emite el enlace real del empleador (su propio ATS) con los parámetros de seguimiento eliminados.

Corregido

  • Las entidades con acento ahora se decodifican en todas partes. El decodificador HTML compartido incorporó las letras Latin-1 (&eacute; → é, &ccedil; → ç, …), así que un portal europeo que escribe D&eacute;veloppeur o Fran&ccedil;ais ya no deja ese literal en un título, el tracker o un documento generado. (Las mayúsculas siguen en mayúsculas —&Eacute; es É, no é— y una búsqueda como &constructor; ahora se resuelve a sí misma.)
  • Una empresa con un acento en su nombre ya no se marca por error por estar en su propio dominio. «Işık» ahora se pliega a «isik» y coincide con isik.com.tr; «Société Générale» coincide con societegenerale.com. La comprobación anterior borraba las letras acentuadas en lugar de plegarlas a su base ASCII.

Notas

  • Fuentes de escaneo: 81 (76 en inglés + 5 rusas). Conjunto de pruebas: 2667.

[1.210.1] — 2026-08-19

Corregido — los títulos y nombres de empresa de Habr Career con «&» o comillas ya no llegan corruptos.

Corregido

  • La fuente Habr Career ahora decodifica las entidades HTML del título y del nombre de la empresa antes de continuar. Las tarjetas renderizadas en el servidor llegan con entidades (“Changellenge >>”, “Demand Forecasting & Inventory Optimization”, “ООО "М-ТЕХ"”), así que un «&» sin decodificar fallaba en silencio tu propio filtro de título con «&» —el mismo síntoma que la versión anterior cerró en otros cinco portales— y los nombres de empresa llegaban corruptos al tracker y a los informes. La decodificación de entidades queda completa en las seis fuentes afectadas.

Notas

  • Conjunto de pruebas: 2644.

[1.210.0] — 2026-08-19

Añadido — Senjob, la primera bolsa de empleo africana del escáner (Senegal); coincidencia de títulos más precisa en cinco portales más.

Añadido

  • Senjob (senjob.com) — una nueva fuente de escaneo sin tokens para Senegal, la primera bolsa africana del escáner. Selecciónala en el filtro Fuente de #/scan, o añade una empresa con provider: senjob. Lee el listado público por HTTP simple (sin clave, sin navegador), fija cada solicitud a senjob.com y —al analizar HTML— trata un listado que de repente no arroja nada como un portal roto (un error visible) en lugar de un país sin empleos.

Corregido

  • Los títulos con «&» ya no descartan ofertas en cinco portales — en beesite, Cornerstone (csod), Hacker News “Who is hiring”, Phenom y TKMS los títulos llegan con entidades HTML, así que un «&» escapado en un puesto como “R&D Engineer” fallaba tu propia palabra clave “r&d” y la oferta desaparecía en silencio (un veto “sales & marketing” tampoco se activaba). Ahora los títulos —y las ubicaciones de Phenom— se decodifican antes de filtrar.

Notas

  • Fuentes de escaneo: 80 (75 en inglés + 5 rusas). Conjunto de pruebas: 2643.

[1.209.0] — 2026-08-17

Añadido — la ayuda integrada ahora cubre cómo registrar el resultado de una candidatura, y «Pregunta a la ayuda» puede guiarte hasta ahí.

Añadido

  • La ayuda del Seguimiento (§11) ganó una sección «Registrar un resultado» en los 17 idiomas, que recorre el botón Resultado: elige qué pasó (rechazado / oferta / contratado / rechazada / ignorado / pasó a entrevista), previsualiza lo que hará y regístralo — lo que anota el resultado, archiva el CV y la carta que enviaste, y sincroniza el Estado de la fila por ti. El asistente flotante «Pregunta a la ayuda» lee la guía, así que ahora te lleva a ese botón en lugar de solo sugerirte editar el Estado a mano.

Notas

  • Cada paquete de ayuda es ahora 31 H2 / 119 H3 (antes 118); los guards de paridad se ajustaron. Solo documentación — sin cambios de código ni de comportamiento. Conjunto: 2625.

career-ops-ui v1.267.1 · page 3/9 · mirror of the project changelog for cvstart.org