From 80552175da351c40cdddf54bc139e62e833aa9a0 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 08:26:22 -0500 Subject: [PATCH 1/6] Add canonical LinkedIn Jobs capture flow --- chrome-extension/background.js | 35 ++- chrome-extension/content.js | 220 +++++++++++----- chrome-extension/linkedin-job-helpers.js | 217 ++++++++++++++++ chrome-extension/manifest.json | 10 +- chrome-extension/sidepanel.html | 1 + chrome-extension/sidepanel.js | 316 ++++++++++++++--------- 6 files changed, 611 insertions(+), 188 deletions(-) create mode 100644 chrome-extension/linkedin-job-helpers.js diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 1a37511..0b8a4ea 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -1,8 +1,21 @@ +importScripts("linkedin-job-helpers.js"); + const API_BASE = "http://localhost:8001"; const ACTIVE_TASK_KEY = "activeAnalysisTasks"; +const LINKEDIN_HELPERS = globalThis.LinkedInJobHelpers; function normalizarUrlVacante(url) { - return (url || "").split("?")[0]; + return LINKEDIN_HELPERS.normalizeLink(url); +} + +function resolverClaveVacante({ vacancyKey, link, pageUrl }) { + return ( + vacancyKey || + LINKEDIN_HELPERS.buildVacancyKey(link) || + LINKEDIN_HELPERS.buildVacancyKey(pageUrl) || + normalizarUrlVacante(link) || + normalizarUrlVacante(pageUrl) + ); } if (chrome.sidePanel?.setPanelBehavior) { @@ -18,11 +31,11 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { } if (request.action === "clearActiveTask") { - const pageUrl = normalizarUrlVacante(request.pageUrl); + const targetKey = resolverClaveVacante(request); chrome.storage.local.get(ACTIVE_TASK_KEY, (result) => { const tasks = result[ACTIVE_TASK_KEY] || {}; - if (pageUrl) { - delete tasks[pageUrl]; + if (targetKey) { + delete tasks[targetKey]; chrome.storage.local.set({ [ACTIVE_TASK_KEY]: tasks }, () => sendResponse({ success: true })); return; } @@ -50,17 +63,24 @@ async function guardarVacanteEnSegundoPlano(payload) { throw new Error(data.detail || data.message || "No se pudo guardar la vacante"); } + const vacancyKey = resolverClaveVacante({ + vacancyKey: payload.vacancyKey, + link: payload.link, + pageUrl: payload.pageUrl, + }); + const activeTask = { taskId: data.task_id || data.job_id, vacancyId: data.id, status: data.status, message: data.message, pageUrl: normalizarUrlVacante(payload.link), + vacancyKey, savedAt: new Date().toISOString(), }; const storage = await chrome.storage.local.get(ACTIVE_TASK_KEY); const tasks = storage[ACTIVE_TASK_KEY] || {}; - tasks[activeTask.pageUrl] = activeTask; + tasks[vacancyKey] = activeTask; await chrome.storage.local.set({ [ACTIVE_TASK_KEY]: tasks }); return activeTask; } @@ -83,6 +103,11 @@ async function guardarVacanteConApiLegacy(payload) { status: "completed", message: "Vacante guardada. La API local no soporta analisis en segundo plano todavia.", legacyMode: true, + vacancyKey: resolverClaveVacante({ + vacancyKey: payload.vacancyKey, + link: payload.link, + pageUrl: payload.pageUrl, + }), savedAt: new Date().toISOString(), }; } diff --git a/chrome-extension/content.js b/chrome-extension/content.js index bc3b367..2281e65 100644 --- a/chrome-extension/content.js +++ b/chrome-extension/content.js @@ -1,49 +1,40 @@ /** - * content.js — v2 - * Selectores confirmados por inspección real del DOM de LinkedIn. - * - * Estrategia por campo: - * Cargo → document.title ("Cargo | Empresa | LinkedIn") - * Empresa → document.title (mismo parse) - * Modalidad → primer SPAN nodo-hoja con texto exacto de modalidad - * Descripción→ el P/SPAN más largo de la página (500–8000 chars) - * Link → window.location.href limpio + * content.js + * Extrae la vacante activa desde LinkedIn Jobs, tanto en /jobs/view/ + * como en /jobs/search con panel de detalle abierto. */ -// ───────────────────────────────────────────────────────────── -// CARGO Y EMPRESA — desde document.title -// Formato estable: "Cargo | Empresa | LinkedIn" -// ───────────────────────────────────────────────────────────── +const MODALIDADES_VALIDAS = new Set([ + "Remoto", "Hibrido", "Híbrido", "Presencial", + "Remote", "Hybrid", "On-site", "On site", +]); + +const LINKEDIN_HELPERS = globalThis.LinkedInJobHelpers; + +function textoLimpio(value) { + return (value || "").replace(/\s+/g, " ").trim(); +} function extraerDesdeTitle() { - const title = document.title || ""; - const partes = title.split(" | ").map(p => p.trim()).filter(Boolean); - const cargo = partes[0] || ""; + const title = document.title || ""; + const partes = title.split(" | ").map((value) => value.trim()).filter(Boolean); + const cargo = partes[0] || ""; const empresa = partes.length >= 3 ? partes[1] : (partes[1] || ""); return { cargo, empresa }; } -// ───────────────────────────────────────────────────────────── -// MODALIDAD — primer SPAN nodo-hoja con texto exacto -// ───────────────────────────────────────────────────────────── - -const MODALIDADES_VALIDAS = new Set([ - "Remoto", "Híbrido", "Presencial", - "Remote", "Hybrid", "On-site", "On site", -]); - function normalizarModalidad(texto) { - const t = texto.toLowerCase().trim(); - if (t === "remote" || t === "remoto") return "Remoto"; - if (t === "hybrid" || t === "híbrido" || t === "hibrido") return "Híbrido"; + const t = textoLimpio(texto).toLowerCase(); + if (t === "remote" || t === "remoto") return "Remoto"; + if (t === "hybrid" || t === "hibrido" || t === "híbrido") return "Híbrido"; if (t === "on-site" || t === "on site" || t === "presencial") return "Presencial"; return null; } -function extraerModalidad() { - for (const span of document.querySelectorAll("span")) { +function extraerModalidad(root = document) { + for (const span of root.querySelectorAll("span")) { if (span.children.length > 0) continue; - const texto = span.innerText?.trim() || ""; + const texto = textoLimpio(span.innerText); if (MODALIDADES_VALIDAS.has(texto)) { return normalizarModalidad(texto) || "Remoto"; } @@ -51,61 +42,158 @@ function extraerModalidad() { return "Remoto"; } -// ───────────────────────────────────────────────────────────── -// DESCRIPCIÓN — el P o SPAN más largo entre 500 y 8000 chars -// ───────────────────────────────────────────────────────────── - -function extraerDescripcion() { - let mejor = ""; +function extraerDescripcion(root = document) { + const candidatos = root.querySelectorAll("p, span, div"); + let mejor = ""; let mejorLen = 0; - for (const el of document.querySelectorAll("p, span")) { - const texto = el.innerText?.trim() || ""; - if (texto.length > 500 && texto.length < 8000 && texto.length > mejorLen) { - mejor = texto; + for (const el of candidatos) { + const texto = textoLimpio(el.innerText); + if (texto.length > 500 && texto.length < 12000 && texto.length > mejorLen) { + mejor = texto; mejorLen = texto.length; } } + return mejor; } -// ───────────────────────────────────────────────────────────── -// FUNCIÓN PRINCIPAL -// ───────────────────────────────────────────────────────────── +function obtenerTextoPorSelectores(selectores, root = document) { + for (const selector of selectores) { + const node = root.querySelector(selector); + const texto = textoLimpio(node?.innerText || node?.textContent || ""); + if (texto) return texto; + } + return ""; +} -function extraerDatosVacante() { +function obtenerRootDetalleBusqueda() { + const selectors = [ + ".jobs-search__job-details--container", + ".jobs-search__job-details", + ".jobs-details", + ".scaffold-layout__detail", + ".jobs-unified-top-card", + "main", + ]; + + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node) return node; + } + + return document; +} + +function extraerHeaderBusqueda(root) { + const cargo = obtenerTextoPorSelectores([ + ".job-details-jobs-unified-top-card__job-title", + ".jobs-unified-top-card__job-title", + ".jobs-details-top-card__job-title", + ".t-24.job-details-jobs-unified-top-card__job-title", + "h1", + ], root); + + const empresa = obtenerTextoPorSelectores([ + ".job-details-jobs-unified-top-card__company-name", + ".jobs-unified-top-card__company-name", + ".jobs-details-top-card__company-url", + ".jobs-details-top-card__company-info a", + "a[href*='/company/']", + ], root); + + return { cargo, empresa }; +} + +function extraerVacanteJobView(identity) { const { cargo, empresa } = extraerDesdeTitle(); - const modalidad = extraerModalidad(); - const descripcion = extraerDescripcion(); - const link = window.location.href.split("?")[0]; + const modalidad = extraerModalidad(document); + const descripcion = extraerDescripcion(document); return { cargo, empresa, modalidad, descripcion, - link, + link: identity.canonicalLink, + vacancyKey: identity.vacancyKey, + _identity: identity, _calidad: { - cargo: cargo.length > 0, - empresa: empresa.length > 0, - modalidad: MODALIDADES_VALIDAS.has(modalidad), + cargo: cargo.length > 0, + empresa: empresa.length > 0, + modalidad: MODALIDADES_VALIDAS.has(modalidad), descripcion: descripcion.length > 200, + jobId: Boolean(identity.jobId), }, }; } -// ───────────────────────────────────────────────────────────── -// LISTENER -// ───────────────────────────────────────────────────────────── - -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (request.action === "extraerVacante") { - try { - const datos = extraerDatosVacante(); - sendResponse({ success: true, datos }); - } catch (error) { - sendResponse({ success: false, error: error.message }); - } +function extraerVacanteSearchPanel(identity) { + const root = obtenerRootDetalleBusqueda(); + const fallback = extraerDesdeTitle(); + const header = extraerHeaderBusqueda(root); + const cargo = header.cargo || fallback.cargo; + const empresa = header.empresa || fallback.empresa; + const modalidad = extraerModalidad(root); + + const descripcion = obtenerTextoPorSelectores([ + ".jobs-description__container", + ".jobs-box__html-content", + ".jobs-description-content__text", + ".jobs-description", + ], root) || extraerDescripcion(root); + + return { + cargo, + empresa, + modalidad, + descripcion, + link: identity.canonicalLink, + vacancyKey: identity.vacancyKey, + _identity: identity, + _calidad: { + cargo: cargo.length > 0, + empresa: empresa.length > 0, + modalidad: MODALIDADES_VALIDAS.has(modalidad), + descripcion: descripcion.length > 200, + jobId: Boolean(identity.jobId), + }, + }; +} + +function extraerDatosVacante() { + const identity = LINKEDIN_HELPERS.resolveVacancyIdentity({ + url: window.location.href, + doc: document, + }); + + if (identity.context === "unsupported") { + throw new Error("Esta pagina de LinkedIn Jobs no esta soportada por la extension."); + } + + if (!identity.canSave) { + throw new Error("No se pudo resolver un job_id estable para la vacante activa."); + } + + if (identity.context === "job_view") { + return extraerVacanteJobView(identity); } - return true; -}); \ No newline at end of file + + return extraerVacanteSearchPanel(identity); +} + +if (!globalThis.__CVS_OPTIMIZADOR_LINKEDIN_CONTENT_LISTENER__) { + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === "extraerVacante") { + try { + const datos = extraerDatosVacante(); + sendResponse({ success: true, datos }); + } catch (error) { + sendResponse({ success: false, error: error.message }); + } + } + return true; + }); + + globalThis.__CVS_OPTIMIZADOR_LINKEDIN_CONTENT_LISTENER__ = true; +} diff --git a/chrome-extension/linkedin-job-helpers.js b/chrome-extension/linkedin-job-helpers.js new file mode 100644 index 0000000..b198091 --- /dev/null +++ b/chrome-extension/linkedin-job-helpers.js @@ -0,0 +1,217 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) { + module.exports = api; + } + root.LinkedInJobHelpers = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + const LINKEDIN_HOST_RE = /(^|\.)linkedin\.com$/i; + const JOB_VIEW_RE = /^\/jobs\/view\/(\d+)(?:\/|$)/i; + const JOB_SEARCH_RE = /^\/jobs\/search(?:\/|$)/i; + const JOB_SEARCH_RESULTS_RE = /^\/jobs\/search-results(?:\/|$)/i; + const JOB_LINK_RE = /\/jobs\/view\/(\d+)(?:\/|$)/i; + const MIN_JOB_ID_LENGTH = 6; + + function parseUrl(url) { + if (!url) return null; + try { + return new URL(url); + } catch (_) { + return null; + } + } + + function isLinkedInHost(hostname) { + return LINKEDIN_HOST_RE.test(hostname || ""); + } + + function normalizeJobId(value) { + const raw = String(value || "").trim(); + if (!raw) return null; + + const digits = raw.match(/\b(\d{6,})\b/); + if (!digits) return null; + + return digits[1].length >= MIN_JOB_ID_LENGTH ? digits[1] : null; + } + + function detectLinkedInContext(url) { + const parsed = parseUrl(url); + if (!parsed || !isLinkedInHost(parsed.hostname)) { + return "unsupported"; + } + + if (JOB_VIEW_RE.test(parsed.pathname || "")) { + return "job_view"; + } + + if (JOB_SEARCH_RE.test(parsed.pathname || "") || JOB_SEARCH_RESULTS_RE.test(parsed.pathname || "")) { + return "job_search_active"; + } + + return "unsupported"; + } + + function resolveJobIdFromViewUrl(url) { + const parsed = parseUrl(url); + if (!parsed) return null; + + const match = (parsed.pathname || "").match(JOB_VIEW_RE); + return normalizeJobId(match ? match[1] : null); + } + + function resolveJobIdFromSearchParams(url) { + const parsed = parseUrl(url); + if (!parsed) return null; + + return ( + normalizeJobId(parsed.searchParams.get("currentJobId")) || + normalizeJobId(parsed.searchParams.get("jobId")) + ); + } + + function resolveJobIdFromHref(href) { + if (!href) return null; + const match = String(href).match(JOB_LINK_RE); + return normalizeJobId(match ? match[1] : null); + } + + function queryAll(doc, selector) { + if (!doc || typeof doc.querySelectorAll !== "function") { + return []; + } + try { + return Array.from(doc.querySelectorAll(selector)); + } catch (_) { + return []; + } + } + + function readAttr(node, attrName) { + if (!node) return null; + if (typeof node.getAttribute === "function") { + return node.getAttribute(attrName); + } + return node[attrName] || null; + } + + function resolveJobIdFromNode(node) { + if (!node) return null; + + const attributeCandidates = [ + readAttr(node, "data-job-id"), + readAttr(node, "data-occludable-job-id"), + readAttr(node, "data-entity-urn"), + node.dataset?.jobId, + node.dataset?.occludableJobId, + node.href, + ]; + + for (const candidate of attributeCandidates) { + const resolved = resolveJobIdFromHref(candidate) || normalizeJobId(candidate); + if (resolved) return resolved; + } + + return null; + } + + function resolveJobIdFromDocument(doc) { + const prioritizedSelectors = [ + "[aria-current='true'][data-job-id]", + "[aria-current='true'][data-occludable-job-id]", + "[aria-current='true'][data-entity-urn]", + "[aria-current='true'] a[href*='/jobs/view/']", + "[class*='active'][data-job-id]", + "[class*='active'][data-occludable-job-id]", + "[class*='active'][data-entity-urn]", + "[class*='active'] a[href*='/jobs/view/']", + "[data-job-id]", + "[data-occludable-job-id]", + "[data-entity-urn]", + "a[href*='/jobs/view/']", + ]; + + for (const selector of prioritizedSelectors) { + for (const node of queryAll(doc, selector)) { + const jobId = resolveJobIdFromNode(node); + if (jobId) return jobId; + } + } + + return null; + } + + function resolveJobId(input) { + const url = typeof input === "string" ? input : input?.url; + const doc = typeof input === "string" ? null : input?.doc; + + return ( + resolveJobIdFromViewUrl(url) || + resolveJobIdFromSearchParams(url) || + resolveJobIdFromDocument(doc) + ); + } + + function buildCanonicalJobLink(jobId) { + const normalized = normalizeJobId(jobId); + if (!normalized) return null; + return `https://www.linkedin.com/jobs/view/${normalized}`; + } + + function normalizeLink(link) { + if (!link) return null; + const parsed = parseUrl(link); + if (!parsed) { + const trimmed = String(link).trim(); + return trimmed ? trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "") : null; + } + + parsed.search = ""; + parsed.hash = ""; + + const normalized = parsed.toString().replace(/\/+$/, ""); + return normalized || null; + } + + function buildVacancyKey(link) { + const normalizedLink = normalizeLink(link); + return normalizedLink ? `linkedin:${normalizedLink}` : null; + } + + function canSaveVacancy(identity) { + return Boolean(identity?.jobId && identity?.canonicalLink && identity?.vacancyKey); + } + + function resolveVacancyIdentity(input) { + const url = typeof input === "string" ? input : input?.url; + const doc = typeof input === "string" ? null : input?.doc; + const context = detectLinkedInContext(url); + const jobId = resolveJobId({ url, doc }); + const canonicalLink = buildCanonicalJobLink(jobId); + const vacancyKey = buildVacancyKey(canonicalLink); + + return { + context, + jobId, + canonicalLink, + vacancyKey, + supported: context !== "unsupported", + canSave: canSaveVacancy({ jobId, canonicalLink, vacancyKey }), + }; + } + + return { + buildCanonicalJobLink, + buildVacancyKey, + canSaveVacancy, + detectLinkedInContext, + normalizeJobId, + normalizeLink, + resolveJobId, + resolveJobIdFromDocument, + resolveJobIdFromHref, + resolveJobIdFromSearchParams, + resolveJobIdFromViewUrl, + resolveVacancyIdentity, + }; +}); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index a0fad51..65fc070 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -29,8 +29,12 @@ "content_scripts": [ { - "matches": ["https://www.linkedin.com/jobs/view/*"], - "js": ["content.js"], + "matches": [ + "https://www.linkedin.com/jobs/view/*", + "https://www.linkedin.com/jobs/search/*", + "https://www.linkedin.com/jobs/search-results/*" + ], + "js": ["linkedin-job-helpers.js", "content.js"], "run_at": "document_idle" } ], @@ -44,6 +48,8 @@ "host_permissions": [ "https://www.linkedin.com/jobs/view/*", + "https://www.linkedin.com/jobs/search/*", + "https://www.linkedin.com/jobs/search-results/*", "http://localhost:8001/*" ] } diff --git a/chrome-extension/sidepanel.html b/chrome-extension/sidepanel.html index 657c8a7..96d9733 100644 --- a/chrome-extension/sidepanel.html +++ b/chrome-extension/sidepanel.html @@ -317,6 +317,7 @@

CVs-Optimizador

+ diff --git a/chrome-extension/sidepanel.js b/chrome-extension/sidepanel.js index 85f1011..c1ff059 100644 --- a/chrome-extension/sidepanel.js +++ b/chrome-extension/sidepanel.js @@ -1,37 +1,34 @@ /** * sidepanel.js - * Orquesta toda la lógica del panel lateral: - * 1. Verifica que la API local esté corriendo - * 2. Inyecta content.js y solicita extracción del DOM - * 3. Renderiza el formulario con los datos extraídos (editables) - * 4. Envía el POST a la API y muestra el resultado + * Orquesta la extraccion, guardado y seguimiento desde el panel lateral. */ const API_BASE = "http://localhost:8001"; const ACTIVE_TASK_KEY = "activeAnalysisTasks"; const LAST_ANALYSIS_KEY = "lastAnalysisResults"; +const LINKEDIN_HELPERS = globalThis.LinkedInJobHelpers; + let pollingTimer = null; let currentTabId = null; let currentTabUrl = null; +let currentCanonicalLink = null; +let currentVacancyKey = null; +let currentStorageKeys = []; +let currentContext = "unsupported"; let watchersInitialized = false; const MODALIDADES = ["Remoto", "Presencial", "Híbrido"]; -// ───────────────────────────────────────────────────────────── -// HELPERS DOM -// ───────────────────────────────────────────────────────────── - const $ = (id) => document.getElementById(id); function setStatus(estado, texto) { - const dot = $("statusDot"); + const dot = $("statusDot"); const span = $("statusText"); - dot.className = `status-dot ${estado}`; + dot.className = `status-dot ${estado}`; span.textContent = texto; } function mostrarToast(tipo, mensaje) { - // Eliminar toast previo si existe const prev = document.querySelector(".toast"); if (prev) prev.remove(); @@ -43,7 +40,7 @@ function mostrarToast(tipo, mensaje) { } function limpiarVistaTransitoria() { - document.querySelectorAll(".toast").forEach((t) => t.remove()); + document.querySelectorAll(".toast").forEach((toast) => toast.remove()); const analysisPanel = document.getElementById("analysisPanel"); if (analysisPanel) analysisPanel.remove(); } @@ -72,40 +69,98 @@ async function storageGet(key) { return result[key]; } -async function storageRemove(key) { - await chrome.storage.local.remove(key); -} - async function storageSet(values) { await chrome.storage.local.set(values); } -async function guardarDatoPorUrl(storageKey, pageUrl, value) { - const normalizedUrl = normalizarUrlVacante(pageUrl); - if (!normalizedUrl) return; +function normalizarUrlVacante(url) { + return LINKEDIN_HELPERS.normalizeLink(url); +} + +function esPaginaLinkedInSoportada(url) { + return LINKEDIN_HELPERS.detectLinkedInContext(url) !== "unsupported"; +} + +function construirStorageKeys({ canonicalLink, tabUrl, context }) { + const keys = []; + const vacancyKey = LINKEDIN_HELPERS.buildVacancyKey(canonicalLink); + const canonicalLegacy = normalizarUrlVacante(canonicalLink); + const tabLegacy = normalizarUrlVacante(tabUrl); + + if (vacancyKey) keys.push(vacancyKey); + if (canonicalLegacy && !keys.includes(canonicalLegacy)) keys.push(canonicalLegacy); + + const puedeUsarTabFallback = !canonicalLink || context === "job_view"; + if (puedeUsarTabFallback && tabLegacy && !keys.includes(tabLegacy)) { + keys.push(tabLegacy); + } + + return keys; +} + +function actualizarIdentidadActual(datos, tabUrl) { + currentContext = datos?._identity?.context || LINKEDIN_HELPERS.detectLinkedInContext(tabUrl); + currentCanonicalLink = datos?.link || null; + currentVacancyKey = datos?.vacancyKey || LINKEDIN_HELPERS.buildVacancyKey(currentCanonicalLink); + currentStorageKeys = construirStorageKeys({ + canonicalLink: currentCanonicalLink, + tabUrl, + context: currentContext, + }); +} + +function resetIdentidadActual(tabUrl) { + currentContext = LINKEDIN_HELPERS.detectLinkedInContext(tabUrl); + currentCanonicalLink = null; + currentVacancyKey = null; + currentStorageKeys = construirStorageKeys({ + canonicalLink: null, + tabUrl, + context: currentContext, + }); +} + +function primaryStorageKey() { + return currentStorageKeys[0] || currentVacancyKey || currentCanonicalLink || currentTabUrl || null; +} + +async function guardarDatoPorClaves(storageKey, keys, value) { + const normalizedKeys = (keys || []).filter(Boolean); + if (!normalizedKeys.length) return; + const current = (await storageGet(storageKey)) || {}; - current[normalizedUrl] = value; + current[normalizedKeys[0]] = value; await storageSet({ [storageKey]: current }); } -async function leerDatoPorUrl(storageKey, pageUrl) { - const normalizedUrl = normalizarUrlVacante(pageUrl); - if (!normalizedUrl) return null; +async function leerDatoPorClaves(storageKey, keys) { + const normalizedKeys = (keys || []).filter(Boolean); + if (!normalizedKeys.length) return null; + const current = (await storageGet(storageKey)) || {}; - return current[normalizedUrl] || null; + for (const key of normalizedKeys) { + if (current[key]) return current[key]; + } + return null; } -async function borrarDatoPorUrl(storageKey, pageUrl) { - const normalizedUrl = normalizarUrlVacante(pageUrl); - if (!normalizedUrl) return; +async function borrarDatoPorClaves(storageKey, keys) { + const normalizedKeys = (keys || []).filter(Boolean); + if (!normalizedKeys.length) return; + const current = (await storageGet(storageKey)) || {}; - if (!(normalizedUrl in current)) return; - delete current[normalizedUrl]; - await storageSet({ [storageKey]: current }); -} + let changed = false; -function normalizarUrlVacante(url) { - return (url || "").split("?")[0]; + for (const key of normalizedKeys) { + if (key in current) { + delete current[key]; + changed = true; + } + } + + if (changed) { + await storageSet({ [storageKey]: current }); + } } function describirTarea(task) { @@ -202,14 +257,15 @@ async function iniciarSeguimientoTarea(taskId) { if (task.status === "completed") { try { const analysis = await consultarAnalisisVacante(task.vacancy_id); - await guardarDatoPorUrl(LAST_ANALYSIS_KEY, currentTabUrl, { + await guardarDatoPorClaves(LAST_ANALYSIS_KEY, currentStorageKeys, { vacancyId: task.vacancy_id, - pageUrl: currentTabUrl, + pageUrl: currentCanonicalLink || currentTabUrl, + vacancyKey: currentVacancyKey, analysis, }); renderAnalisis(analysis); } catch (_) { - // Si el analisis todavia no esta visible por API, mantenemos solo el estado. + // Si el analisis aun no esta visible por API, mantenemos solo el estado. } mostrarToast("success", `✅ ${task.message}`); setGuardarEstado("✅ Guardada y analizada", true); @@ -221,8 +277,13 @@ async function iniciarSeguimientoTarea(taskId) { clearInterval(pollingTimer); pollingTimer = null; - await borrarDatoPorUrl(ACTIVE_TASK_KEY, currentTabUrl); - await runtimeSendMessage({ action: "clearActiveTask", pageUrl: currentTabUrl }); + await borrarDatoPorClaves(ACTIVE_TASK_KEY, currentStorageKeys); + await runtimeSendMessage({ + action: "clearActiveTask", + vacancyKey: currentVacancyKey, + link: currentCanonicalLink, + pageUrl: currentTabUrl, + }); } catch (_) { setStatus("checking", "Analisis en segundo plano. Reabre el panel para refrescar."); } @@ -233,14 +294,14 @@ async function iniciarSeguimientoTarea(taskId) { } async function restaurarTareaActiva() { - const activeTask = await leerDatoPorUrl(ACTIVE_TASK_KEY, currentTabUrl); + const activeTask = await leerDatoPorClaves(ACTIVE_TASK_KEY, currentStorageKeys); if (!activeTask?.taskId) return; mostrarToast("info", "ℹ️ Hay una vacante procesandose en segundo plano."); await iniciarSeguimientoTarea(activeTask.taskId); } async function restaurarUltimoAnalisis() { - const lastAnalysis = await leerDatoPorUrl(LAST_ANALYSIS_KEY, currentTabUrl); + const lastAnalysis = await leerDatoPorClaves(LAST_ANALYSIS_KEY, currentStorageKeys); if (!lastAnalysis?.analysis) return; renderAnalisis(lastAnalysis.analysis); } @@ -254,9 +315,10 @@ async function cargarAnalisisHistorico(link) { return false; } - await guardarDatoPorUrl(LAST_ANALYSIS_KEY, link, { + await guardarDatoPorClaves(LAST_ANALYSIS_KEY, currentStorageKeys, { vacancyId: result.vacancy.id, pageUrl: normalizarUrlVacante(link), + vacancyKey: currentVacancyKey, analysis: result.analysis, }); renderAnalisis(result.analysis); @@ -267,10 +329,6 @@ async function cargarAnalisisHistorico(link) { } } -// ───────────────────────────────────────────────────────────── -// 1. VERIFICAR API -// ───────────────────────────────────────────────────────────── - async function verificarAPI() { try { const res = await fetch(`${API_BASE}/health`, { signal: AbortSignal.timeout(3000) }); @@ -279,44 +337,35 @@ async function verificarAPI() { if (data.db_connected) { setStatus("ok", "API activa · BD conectada"); return true; - } else { - setStatus("error", "API activa pero sin conexión a BD"); - return false; } + + setStatus("error", "API activa pero sin conexion a BD"); + return false; } catch (_) { setStatus("error", "API no disponible — ejecuta: uvicorn api:app --port 8001"); return false; } } -// ───────────────────────────────────────────────────────────── -// 2. DETECTAR PÁGINA Y EXTRAER DATOS -// ───────────────────────────────────────────────────────────── - async function obtenerTab() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab; } -function esLinkedInJobView(url) { - return url && url.includes("linkedin.com/jobs/view/"); -} - async function extraerDatosDeTab(tabId) { - // Inyectar content.js si no está ya inyectado try { await chrome.scripting.executeScript({ target: { tabId }, - files: ["content.js"], + files: ["linkedin-job-helpers.js", "content.js"], }); } catch (_) { - // Si falla la inyección, probablemente ya estaba cargado + // Si falla la inyeccion, probablemente la pagina no la permite o ya estaba cargada. } return new Promise((resolve) => { chrome.tabs.sendMessage(tabId, { action: "extraerVacante" }, (response) => { if (chrome.runtime.lastError || !response) { - resolve({ success: false, error: "No se pudo comunicar con la página." }); + resolve({ success: false, error: "No se pudo comunicar con la pagina." }); } else { resolve(response); } @@ -324,10 +373,6 @@ async function extraerDatosDeTab(tabId) { }); } -// ───────────────────────────────────────────────────────────── -// 3. RENDERIZAR FORMULARIO -// ───────────────────────────────────────────────────────────── - function chipCalidad(campo, ok) { return `${ok ? "✓" : "⚠"} ${campo}`; } @@ -335,19 +380,18 @@ function chipCalidad(campo, ok) { function renderFormulario(datos) { const { cargo, empresa, modalidad, descripcion, link, _calidad } = datos; - // Indicadores de calidad de extracción const calidadHTML = `
- ${chipCalidad("Cargo", _calidad.cargo)} - ${chipCalidad("Empresa", _calidad.empresa)} - ${chipCalidad("Modalidad", _calidad.modalidad)} - ${chipCalidad("Descripción", _calidad.descripcion)} + ${chipCalidad("Cargo", _calidad.cargo)} + ${chipCalidad("Empresa", _calidad.empresa)} + ${chipCalidad("Modalidad", _calidad.modalidad)} + ${chipCalidad("Descripcion", _calidad.descripcion)} + ${chipCalidad("job_id", _calidad.jobId)}
`; - // Opciones de modalidad - const optsModalidad = MODALIDADES.map((m) => - `` + const optsModalidad = MODALIDADES.map((item) => + `` ).join(""); const html = ` @@ -355,7 +399,7 @@ function renderFormulario(datos) {
- +
@@ -370,14 +414,14 @@ function renderFormulario(datos) {
- - + +

- +
@@ -385,18 +429,18 @@ function renderFormulario(datos) { `; $("mainContent").innerHTML = html; - - // Evento del botón $("btnGuardar").addEventListener("click", guardarVacante); } -function renderPaginaIncorrecta(url) { +function renderPaginaIncorrecta() { $("mainContent").innerHTML = `
- Página no compatible
- Esta extensión solo funciona en páginas de detalle de vacantes de LinkedIn.

- Navega a una URL del tipo:
- linkedin.com/jobs/view/... + Pagina no compatible
+ Esta extension funciona solo en vacantes de LinkedIn Jobs.

+ Usa una URL del tipo:
+ linkedin.com/jobs/view/<id>
+ o
+ linkedin.com/jobs/search/?currentJobId=<id>
`; } @@ -404,14 +448,13 @@ function renderPaginaIncorrecta(url) { function renderError(mensaje) { $("mainContent").innerHTML = `
- Error de extracción
+ Error de extraccion
${escHtml(mensaje)}

- Recarga la página de LinkedIn e intenta de nuevo. + Si estas en Jobs Search, abre una vacante activa visible en el panel de detalle e intenta de nuevo.
`; } -// Escapar HTML para evitar XSS al insertar texto de LinkedIn en innerHTML function escHtml(str) { if (!str) return ""; return str @@ -422,27 +465,65 @@ function escHtml(str) { .replace(/'/g, "'"); } -// ───────────────────────────────────────────────────────────── -// 4. GUARDAR VACANTE -// ───────────────────────────────────────────────────────────── +async function validarVacanteActivaAntesDeGuardar() { + const resultado = await extraerDatosDeTab(currentTabId); + if (!resultado.success) { + throw new Error(resultado.error || "No se pudo verificar la vacante activa."); + } + + const freshData = resultado.datos; + const freshPrimaryKey = + freshData.vacancyKey || + LINKEDIN_HELPERS.buildVacancyKey(freshData.link) || + freshData.link; + const currentFormKey = + currentVacancyKey || + LINKEDIN_HELPERS.buildVacancyKey($("f_link")?.value) || + currentCanonicalLink; + + if (freshPrimaryKey && currentFormKey && freshPrimaryKey !== currentFormKey) { + await refrescarTabActiva({ skipApiCheck: true, toastMessage: "La vacante activa cambio. Actualizamos el formulario antes de guardar." }); + return { ready: false, datos: null }; + } + + actualizarIdentidadActual(freshData, currentTabUrl); + return { ready: true, datos: freshData }; +} async function guardarVacante() { setGuardarEstado("💾 Guardando vacante...", true); + document.querySelectorAll(".toast").forEach((toast) => toast.remove()); - // Eliminar toast previo - document.querySelectorAll(".toast").forEach((t) => t.remove()); + const sync = await validarVacanteActivaAntesDeGuardar().catch((error) => { + mostrarToast("error", `❌ ${error.message}`); + setStatus("error", "No se pudo validar la vacante activa"); + setGuardarEstado("💾 Guardar en CVs-Optimizador", false); + return null; + }); + if (!sync || !sync.ready) { + setGuardarEstado("💾 Guardar en CVs-Optimizador", false); + return; + } const payload = { - cargo: $("f_cargo").value.trim(), - empresa: $("f_empresa").value.trim(), - modalidad: $("f_modalidad").value, - link: $("f_link").value.trim() || null, + cargo: $("f_cargo").value.trim(), + empresa: $("f_empresa").value.trim(), + modalidad: $("f_modalidad").value, + link: sync.datos.link, descripcion: $("f_descripcion").value.trim(), + vacancyKey: sync.datos.vacancyKey, + pageUrl: currentTabUrl, }; - // Validación básica + if (!payload.link || !payload.vacancyKey) { + mostrarToast("error", "❌ No se pudo resolver un job_id estable para esta vacante."); + setStatus("error", "Falta job_id estable"); + setGuardarEstado("💾 Guardar en CVs-Optimizador", false); + return; + } + if (!payload.cargo || !payload.empresa || !payload.descripcion) { - mostrarToast("error", "⚠️ Cargo, empresa y descripción son obligatorios."); + mostrarToast("error", "⚠️ Cargo, empresa y descripcion son obligatorios."); setGuardarEstado("💾 Guardar en CVs-Optimizador", false); return; } @@ -459,9 +540,10 @@ async function guardarVacante() { setStatus("ok", "Vacante guardada"); try { const analysis = await consultarAnalisisVacante(response.vacancyId); - await guardarDatoPorUrl(LAST_ANALYSIS_KEY, payload.link, { + await guardarDatoPorClaves(LAST_ANALYSIS_KEY, currentStorageKeys, { vacancyId: response.vacancyId, - pageUrl: normalizarUrlVacante(payload.link), + pageUrl: payload.link, + vacancyKey: payload.vacancyKey, analysis, }); renderAnalisis(analysis); @@ -475,24 +557,27 @@ async function guardarVacante() { mostrarToast("info", `ℹ️ Vacante guardada (ID: ${response.vacancyId}). Analisis en segundo plano iniciado.`); await iniciarSeguimientoTarea(response.taskId); - } else { - const detalle = response?.error || "Error desconocido"; - mostrarToast("error", `❌ ${detalle}`); - setStatus("error", "No se pudo guardar la vacante"); - setGuardarEstado("💾 Guardar en CVs-Optimizador", false); + return; } - } catch (err) { + + const detalle = response?.error || "Error desconocido"; + mostrarToast("error", `❌ ${detalle}`); + setStatus("error", "No se pudo guardar la vacante"); + setGuardarEstado("💾 Guardar en CVs-Optimizador", false); + } catch (_) { mostrarToast("error", "❌ No se pudo conectar con la API. Ejecuta: uvicorn api:app --reload --port 8001"); setStatus("error", "API no disponible"); setGuardarEstado("💾 Guardar en CVs-Optimizador", false); } } -async function refrescarTabActiva() { - const apiOk = await verificarAPI(); +async function refrescarTabActiva(options = {}) { + const { skipApiCheck = false, toastMessage = null } = options; + const apiOk = skipApiCheck ? true : await verificarAPI(); const tab = await obtenerTab(); currentTabId = tab?.id ?? null; currentTabUrl = normalizarUrlVacante(tab?.url); + resetIdentidadActual(tab?.url); limpiarVistaTransitoria(); if (pollingTimer) { @@ -500,8 +585,8 @@ async function refrescarTabActiva() { pollingTimer = null; } - if (!esLinkedInJobView(tab?.url)) { - renderPaginaIncorrecta(tab?.url); + if (!esPaginaLinkedInSoportada(tab?.url)) { + renderPaginaIncorrecta(); return; } @@ -509,15 +594,20 @@ async function refrescarTabActiva() { const resultado = await extraerDatosDeTab(tab.id); if (!resultado.success) { - renderError(resultado.error || "Error desconocido al leer la página."); + renderError(resultado.error || "Error desconocido al leer la pagina."); return; } + actualizarIdentidadActual(resultado.datos, tab?.url); renderFormulario(resultado.datos); await restaurarTareaActiva(); await restaurarUltimoAnalisis(); await cargarAnalisisHistorico(resultado.datos.link); + if (toastMessage) { + mostrarToast("info", `ℹ️ ${toastMessage}`); + } + if (!apiOk) { const btn = $("btnGuardar"); if (btn) { @@ -545,10 +635,6 @@ function inicializarObservadoresTabs() { }); } -// ───────────────────────────────────────────────────────────── -// INIT -// ───────────────────────────────────────────────────────────── - async function init() { inicializarObservadoresTabs(); await refrescarTabActiva(); From bf01f5adb5dc65a2293f80631d386f852acd27ab Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 08:26:29 -0500 Subject: [PATCH 2/6] Add LinkedIn job helper coverage --- .../tests/linkedin-job-helpers.test.js | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 chrome-extension/tests/linkedin-job-helpers.test.js diff --git a/chrome-extension/tests/linkedin-job-helpers.test.js b/chrome-extension/tests/linkedin-job-helpers.test.js new file mode 100644 index 0000000..649f719 --- /dev/null +++ b/chrome-extension/tests/linkedin-job-helpers.test.js @@ -0,0 +1,106 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const helpers = require("../linkedin-job-helpers.js"); + +function createNode(attrs = {}) { + return { + dataset: attrs.dataset || {}, + href: attrs.href || null, + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; + }, + }; +} + +function createDocument(selectorMap) { + return { + querySelectorAll(selector) { + return selectorMap[selector] || []; + }, + }; +} + +test("detects /jobs/view/ as job_view", () => { + assert.equal( + helpers.detectLinkedInContext("https://www.linkedin.com/jobs/view/4421695645/?tracking=abc"), + "job_view", + ); +}); + +test("detects /jobs/search with currentJobId as job_search_active", () => { + assert.equal( + helpers.detectLinkedInContext("https://www.linkedin.com/jobs/search/?currentJobId=4421695645&keywords=data"), + "job_search_active", + ); +}); + +test("resolves job_id from /jobs/view URL", () => { + assert.equal( + helpers.resolveJobIdFromViewUrl("https://www.linkedin.com/jobs/view/4421695645/?tracking=abc"), + "4421695645", + ); +}); + +test("resolves job_id from currentJobId", () => { + assert.equal( + helpers.resolveJobIdFromSearchParams("https://www.linkedin.com/jobs/search/?currentJobId=4421695645"), + "4421695645", + ); +}); + +test("resolves job_id from jobId", () => { + assert.equal( + helpers.resolveJobIdFromSearchParams("https://www.linkedin.com/jobs/search/?jobId=4400000001"), + "4400000001", + ); +}); + +test("resolves job_id from internal href fallback", () => { + const doc = createDocument({ + "a[href*='/jobs/view/']": [ + createNode({ href: "https://www.linkedin.com/jobs/view/4382412003/" }), + ], + }); + + assert.equal( + helpers.resolveJobId({ url: "https://www.linkedin.com/jobs/search/?keywords=analyst", doc }), + "4382412003", + ); +}); + +test("resolves job_id from DOM attributes fallback", () => { + const doc = createDocument({ + "[data-job-id]": [createNode({ "data-job-id": "4411111111" })], + }); + + assert.equal( + helpers.resolveJobId({ url: "https://www.linkedin.com/jobs/search/?keywords=analyst", doc }), + "4411111111", + ); +}); + +test("builds canonical link from job_id", () => { + assert.equal( + helpers.buildCanonicalJobLink("4421695645"), + "https://www.linkedin.com/jobs/view/4421695645", + ); +}); + +test("generates stable vacancy key from canonical link", () => { + assert.equal( + helpers.buildVacancyKey("https://www.linkedin.com/jobs/view/4421695645/?tracking=abc"), + "linkedin:https://www.linkedin.com/jobs/view/4421695645", + ); +}); + +test("marks vacancy as unsavable when no stable job_id exists", () => { + const identity = helpers.resolveVacancyIdentity( + "https://www.linkedin.com/jobs/search/?keywords=analyst" + ); + + assert.equal(identity.context, "job_search_active"); + assert.equal(identity.jobId, null); + assert.equal(identity.canSave, false); + assert.equal(helpers.canSaveVacancy(identity), false); +}); From a5f9cf90d4d106b10d00fa803dcfd400e922e049 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 08:42:13 -0500 Subject: [PATCH 3/6] Restore legacy LinkedIn job view extractor --- chrome-extension/content.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/chrome-extension/content.js b/chrome-extension/content.js index 2281e65..f0499d3 100644 --- a/chrome-extension/content.js +++ b/chrome-extension/content.js @@ -42,7 +42,22 @@ function extraerModalidad(root = document) { return "Remoto"; } -function extraerDescripcion(root = document) { +function extraerDescripcionLegacyJobView() { + let mejor = ""; + let mejorLen = 0; + + for (const el of document.querySelectorAll("p, span")) { + const texto = el.innerText?.trim() || ""; + if (texto.length > 500 && texto.length < 8000 && texto.length > mejorLen) { + mejor = texto; + mejorLen = texto.length; + } + } + + return mejor; +} + +function extraerDescripcionSearchPanel(root = document) { const candidatos = root.querySelectorAll("p, span, div"); let mejor = ""; let mejorLen = 0; @@ -108,7 +123,7 @@ function extraerHeaderBusqueda(root) { function extraerVacanteJobView(identity) { const { cargo, empresa } = extraerDesdeTitle(); const modalidad = extraerModalidad(document); - const descripcion = extraerDescripcion(document); + const descripcion = extraerDescripcionLegacyJobView(); return { cargo, @@ -141,7 +156,7 @@ function extraerVacanteSearchPanel(identity) { ".jobs-box__html-content", ".jobs-description-content__text", ".jobs-description", - ], root) || extraerDescripcion(root); + ], root) || extraerDescripcionSearchPanel(root); return { cargo, From b1db3edb867432f97ecc773db7f44aa53f32de3c Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 11:47:46 -0500 Subject: [PATCH 4/6] Normalize extracted LinkedIn descriptions --- chrome-extension/content.js | 7 ++-- chrome-extension/linkedin-job-helpers.js | 20 +++++++++++ .../tests/linkedin-job-helpers.test.js | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/chrome-extension/content.js b/chrome-extension/content.js index f0499d3..e324469 100644 --- a/chrome-extension/content.js +++ b/chrome-extension/content.js @@ -123,7 +123,7 @@ function extraerHeaderBusqueda(root) { function extraerVacanteJobView(identity) { const { cargo, empresa } = extraerDesdeTitle(); const modalidad = extraerModalidad(document); - const descripcion = extraerDescripcionLegacyJobView(); + const descripcion = LINKEDIN_HELPERS.normalizeDescription(extraerDescripcionLegacyJobView()); return { cargo, @@ -157,12 +157,13 @@ function extraerVacanteSearchPanel(identity) { ".jobs-description-content__text", ".jobs-description", ], root) || extraerDescripcionSearchPanel(root); + const descripcionNormalizada = LINKEDIN_HELPERS.normalizeDescription(descripcion); return { cargo, empresa, modalidad, - descripcion, + descripcion: descripcionNormalizada, link: identity.canonicalLink, vacancyKey: identity.vacancyKey, _identity: identity, @@ -170,7 +171,7 @@ function extraerVacanteSearchPanel(identity) { cargo: cargo.length > 0, empresa: empresa.length > 0, modalidad: MODALIDADES_VALIDAS.has(modalidad), - descripcion: descripcion.length > 200, + descripcion: descripcionNormalizada.length > 200, jobId: Boolean(identity.jobId), }, }; diff --git a/chrome-extension/linkedin-job-helpers.js b/chrome-extension/linkedin-job-helpers.js index b198091..57ded37 100644 --- a/chrome-extension/linkedin-job-helpers.js +++ b/chrome-extension/linkedin-job-helpers.js @@ -178,6 +178,25 @@ return normalizedLink ? `linkedin:${normalizedLink}` : null; } + function normalizeDescription(text) { + if (text == null) return ""; + + let normalized = String(text).replace(/\r\n?/g, "\n").trim(); + if (!normalized) return ""; + + normalized = normalized.replace(/^Acerca del empleo\b(?:\s*:\s*|\s*\n+|\s+)/i, ""); + normalized = normalized.replace(/\n[ \t]+\n/g, "\n\n"); + normalized = normalized + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"); + normalized = normalized.replace(/\n{3,}/g, "\n\n"); + normalized = normalized.replace(/(?:\u2026|\.\.\.)\s*m[aá]s$/i, ""); + normalized = normalized.replace(/Ver m[aá]s$/i, ""); + + return normalized.trim(); + } + function canSaveVacancy(identity) { return Boolean(identity?.jobId && identity?.canonicalLink && identity?.vacancyKey); } @@ -205,6 +224,7 @@ buildVacancyKey, canSaveVacancy, detectLinkedInContext, + normalizeDescription, normalizeJobId, normalizeLink, resolveJobId, diff --git a/chrome-extension/tests/linkedin-job-helpers.test.js b/chrome-extension/tests/linkedin-job-helpers.test.js index 649f719..2b79391 100644 --- a/chrome-extension/tests/linkedin-job-helpers.test.js +++ b/chrome-extension/tests/linkedin-job-helpers.test.js @@ -104,3 +104,38 @@ test("marks vacancy as unsavable when no stable job_id exists", () => { assert.equal(identity.canSave, false); assert.equal(helpers.canSaveVacancy(identity), false); }); + +test("removes 'Acerca del empleo' at the start", () => { + assert.equal( + helpers.normalizeDescription("Acerca del empleo\nBuscamos una persona con experiencia en analitica."), + "Buscamos una persona con experiencia en analitica.", + ); +}); + +test("removes trailing '... más' and variants at the end", () => { + assert.equal( + helpers.normalizeDescription("Descripcion de la vacante...\n... más"), + "Descripcion de la vacante...", + ); + assert.equal( + helpers.normalizeDescription("Descripcion completa del rol Ver más"), + "Descripcion completa del rol", + ); +}); + +test("preserves bullets and real content while reducing excessive newlines", () => { + assert.equal( + helpers.normalizeDescription("Acerca del empleo\n\n- SQL\n- Python\n\n\n- ETL\n... más"), + "- SQL\n- Python\n\n- ETL", + ); +}); + +test("does not change normal descriptions", () => { + const text = "Responsabilidades principales\n- Analizar datos\n- Presentar hallazgos"; + assert.equal(helpers.normalizeDescription(text), text); +}); + +test("returns empty string for empty description input", () => { + assert.equal(helpers.normalizeDescription(" "), ""); + assert.equal(helpers.normalizeDescription(null), ""); +}); From c7320e9ed3116dd31932f06fc603a565ff5d8380 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 12:06:39 -0500 Subject: [PATCH 5/6] Improve LinkedIn search panel description structure --- chrome-extension/content.js | 33 ++- chrome-extension/linkedin-job-helpers.js | 211 ++++++++++++++++++ .../tests/linkedin-job-helpers.test.js | 100 +++++++++ 3 files changed, 337 insertions(+), 7 deletions(-) diff --git a/chrome-extension/content.js b/chrome-extension/content.js index e324469..e66d7c4 100644 --- a/chrome-extension/content.js +++ b/chrome-extension/content.js @@ -73,6 +73,22 @@ function extraerDescripcionSearchPanel(root = document) { return mejor; } +function obtenerContenedorDescripcionBusqueda(root = document) { + const selectores = [ + ".jobs-description__container", + ".jobs-box__html-content", + ".jobs-description-content__text", + ".jobs-description", + ]; + + for (const selector of selectores) { + const node = root.querySelector(selector); + if (node) return node; + } + + return null; +} + function obtenerTextoPorSelectores(selectores, root = document) { for (const selector of selectores) { const node = root.querySelector(selector); @@ -150,13 +166,16 @@ function extraerVacanteSearchPanel(identity) { const cargo = header.cargo || fallback.cargo; const empresa = header.empresa || fallback.empresa; const modalidad = extraerModalidad(root); - - const descripcion = obtenerTextoPorSelectores([ - ".jobs-description__container", - ".jobs-box__html-content", - ".jobs-description-content__text", - ".jobs-description", - ], root) || extraerDescripcionSearchPanel(root); + const descriptionContainer = obtenerContenedorDescripcionBusqueda(root); + const structuredDescription = LINKEDIN_HELPERS.extractStructuredDescriptionText(descriptionContainer); + const descripcion = structuredDescription.length >= 120 + ? structuredDescription + : (descriptionContainer ? obtenerTextoPorSelectores([ + ".jobs-description__container", + ".jobs-box__html-content", + ".jobs-description-content__text", + ".jobs-description", + ], root) : "") || extraerDescripcionSearchPanel(root); const descripcionNormalizada = LINKEDIN_HELPERS.normalizeDescription(descripcion); return { diff --git a/chrome-extension/linkedin-job-helpers.js b/chrome-extension/linkedin-job-helpers.js index 57ded37..1699944 100644 --- a/chrome-extension/linkedin-job-helpers.js +++ b/chrome-extension/linkedin-job-helpers.js @@ -11,6 +11,23 @@ const JOB_SEARCH_RESULTS_RE = /^\/jobs\/search-results(?:\/|$)/i; const JOB_LINK_RE = /\/jobs\/view\/(\d+)(?:\/|$)/i; const MIN_JOB_ID_LENGTH = 6; + const BLOCK_TAGS = new Set([ + "article", "div", "header", "h1", "h2", "h3", "h4", "h5", "h6", + "li", "ol", "p", "section", "ul", + ]); + const LIST_TAGS = new Set(["ul", "ol"]); + const INLINE_TEXT_TAGS = new Set([ + "a", "b", "em", "i", "small", "span", "strong", "u", + ]); + const IGNORED_TAGS = new Set([ + "button", "footer", "nav", "script", "style", "svg", + ]); + const LINKEDIN_NOISE_PATTERNS = [ + "inicio mi red empleos mensajes", + "linkedin corporation", + "seleccionar idioma", + "premium", + ]; function parseUrl(url) { if (!url) return null; @@ -178,6 +195,198 @@ return normalizedLink ? `linkedin:${normalizedLink}` : null; } + function collapseWhitespace(text) { + return String(text || "").replace(/\s+/g, " ").trim(); + } + + function getNodeTagName(node) { + return String(node?.tagName || node?.nodeName || "").toLowerCase(); + } + + function getNodeType(node) { + if (!node) return 0; + if (typeof node.nodeType === "number") return node.nodeType; + if (node.childNodes || node.tagName || node.nodeName) return 1; + return 3; + } + + function getChildNodes(node) { + if (!node) return []; + if (Array.isArray(node.childNodes)) return node.childNodes; + if (typeof node.childNodes?.length === "number") return Array.from(node.childNodes); + return []; + } + + function getNodeText(node) { + if (!node) return ""; + if (getNodeType(node) === 3) { + return String(node.textContent || node.nodeValue || ""); + } + return String(node.innerText || node.textContent || ""); + } + + function isLikelyLinkedInNoise(text) { + const normalized = collapseWhitespace(text).toLowerCase(); + if (!normalized) return false; + + const matches = LINKEDIN_NOISE_PATTERNS.filter((pattern) => normalized.includes(pattern)); + if (matches.length >= 2) return true; + if (matches.length === 1 && normalized.length <= 120) return true; + return false; + } + + function uniquePush(target, value) { + if (!value) return; + if (!target.includes(value)) { + target.push(value); + } + } + + function inlineTextFromNode(node) { + if (!node) return ""; + + const nodeType = getNodeType(node); + if (nodeType === 3) { + return String(node.textContent || node.nodeValue || ""); + } + + const tag = getNodeTagName(node); + if (IGNORED_TAGS.has(tag)) return ""; + if (tag === "br") return "\n"; + + const children = getChildNodes(node); + if (!children.length) { + return getNodeText(node); + } + + let text = ""; + for (const child of children) { + const childTag = getNodeTagName(child); + if (LIST_TAGS.has(childTag) || BLOCK_TAGS.has(childTag)) { + continue; + } + const childText = inlineTextFromNode(child); + if (childText === "\n") { + text += "\n"; + } else if (childText) { + text += childText; + } + } + + return text || getNodeText(node); + } + + function structuredBlocksFromNode(node) { + if (!node) return []; + + const nodeType = getNodeType(node); + if (nodeType === 3) { + const text = collapseWhitespace(node.textContent || node.nodeValue || ""); + return text ? [text] : []; + } + + const tag = getNodeTagName(node); + if (IGNORED_TAGS.has(tag)) return []; + if (tag === "br") return []; + + const children = getChildNodes(node); + + if (LIST_TAGS.has(tag)) { + const blocks = []; + for (const child of children) { + const childTag = getNodeTagName(child); + if (childTag === "li") { + const itemText = collapseWhitespace(inlineTextFromNode(child)); + if (!itemText || isLikelyLinkedInNoise(itemText)) continue; + uniquePush(blocks, itemText.startsWith("-") ? itemText : `- ${itemText}`); + continue; + } + for (const block of structuredBlocksFromNode(child)) { + if (!isLikelyLinkedInNoise(block)) uniquePush(blocks, block); + } + } + return blocks; + } + + if (tag === "li") { + const itemText = collapseWhitespace(inlineTextFromNode(node)); + if (!itemText || isLikelyLinkedInNoise(itemText)) return []; + return [itemText.startsWith("-") ? itemText : `- ${itemText}`]; + } + + const childBlockTags = children + .map((child) => getNodeTagName(child)) + .filter((childTag) => BLOCK_TAGS.has(childTag) || childTag === "br"); + + if (!childBlockTags.length) { + const text = collapseWhitespace(inlineTextFromNode(node)); + if (!text || isLikelyLinkedInNoise(text)) return []; + return [text]; + } + + const blocks = []; + let inlineBuffer = ""; + + const flushInlineBuffer = () => { + const text = collapseWhitespace(inlineBuffer); + inlineBuffer = ""; + if (!text || isLikelyLinkedInNoise(text)) return; + uniquePush(blocks, text); + }; + + for (const child of children) { + const childType = getNodeType(child); + const childTag = getNodeTagName(child); + + if (childType === 3) { + inlineBuffer += ` ${child.textContent || child.nodeValue || ""}`; + continue; + } + + if (IGNORED_TAGS.has(childTag)) continue; + + if (childTag === "br") { + inlineBuffer += "\n"; + continue; + } + + if (BLOCK_TAGS.has(childTag) || LIST_TAGS.has(childTag)) { + flushInlineBuffer(); + for (const block of structuredBlocksFromNode(child)) { + if (!isLikelyLinkedInNoise(block)) uniquePush(blocks, block); + } + continue; + } + + if (INLINE_TEXT_TAGS.has(childTag) || !childTag) { + inlineBuffer += ` ${inlineTextFromNode(child)}`; + } + } + + flushInlineBuffer(); + + if (!blocks.length) { + const text = collapseWhitespace(getNodeText(node)); + if (!text || isLikelyLinkedInNoise(text)) return []; + return [text]; + } + + return blocks; + } + + function extractStructuredDescriptionText(root) { + if (!root) return ""; + + const blocks = structuredBlocksFromNode(root) + .map((block) => collapseWhitespace(block)) + .filter(Boolean) + .filter((block) => !isLikelyLinkedInNoise(block)); + + if (!blocks.length) return ""; + + return blocks.join("\n\n"); + } + function normalizeDescription(text) { if (text == null) return ""; @@ -224,6 +433,8 @@ buildVacancyKey, canSaveVacancy, detectLinkedInContext, + extractStructuredDescriptionText, + isLikelyLinkedInNoise, normalizeDescription, normalizeJobId, normalizeLink, diff --git a/chrome-extension/tests/linkedin-job-helpers.test.js b/chrome-extension/tests/linkedin-job-helpers.test.js index 2b79391..27457b5 100644 --- a/chrome-extension/tests/linkedin-job-helpers.test.js +++ b/chrome-extension/tests/linkedin-job-helpers.test.js @@ -21,6 +21,27 @@ function createDocument(selectorMap) { }; } +function textNode(text) { + return { + nodeType: 3, + textContent: text, + nodeValue: text, + }; +} + +function element(tagName, ...children) { + const normalizedChildren = children.flat(); + const node = { + nodeType: 1, + tagName: tagName.toUpperCase(), + nodeName: tagName.toUpperCase(), + childNodes: normalizedChildren, + textContent: normalizedChildren.map((child) => child.textContent || child.nodeValue || "").join(""), + innerText: normalizedChildren.map((child) => child.textContent || child.nodeValue || "").join(""), + }; + return node; +} + test("detects /jobs/view/ as job_view", () => { assert.equal( helpers.detectLinkedInContext("https://www.linkedin.com/jobs/view/4421695645/?tracking=abc"), @@ -139,3 +160,82 @@ test("returns empty string for empty description input", () => { assert.equal(helpers.normalizeDescription(" "), ""); assert.equal(helpers.normalizeDescription(null), ""); }); + +test("structured extraction separates headings and paragraphs in Search Panel content", () => { + const container = element( + "div", + element("h2", textNode("Propósito del cargo")), + element("p", textNode("Participar y gestionar iniciativas de analitica.")), + element("h3", textNode("Educación")), + element("p", textNode("Pregrado en ingenieria o carreras afines.")) + ); + + assert.equal( + helpers.extractStructuredDescriptionText(container), + [ + "Propósito del cargo", + "Participar y gestionar iniciativas de analitica.", + "Educación", + "Pregrado en ingenieria o carreras afines.", + ].join("\n\n"), + ); +}); + +test("structured extraction preserves list-style lines", () => { + const container = element( + "div", + element("h3", textNode("Conocimientos Técnicos")), + element( + "ul", + element("li", textNode("Transformación de datos")), + element("li", textNode("Consultas con SQL")), + element("li", textNode("Bases de datos")) + ) + ); + + assert.equal( + helpers.extractStructuredDescriptionText(container), + [ + "Conocimientos Técnicos", + "- Transformación de datos", + "- Consultas con SQL", + "- Bases de datos", + ].join("\n\n"), + ); +}); + +test("structured extraction does not duplicate nested text", () => { + const container = element( + "div", + element( + "section", + element("h3", textNode("Competencias")), + element( + "div", + element("p", textNode("Pensamiento analitico.")), + element("p", textNode("Trabajo colaborativo.")) + ) + ) + ); + + assert.equal( + helpers.extractStructuredDescriptionText(container), + [ + "Competencias", + "Pensamiento analitico.", + "Trabajo colaborativo.", + ].join("\n\n"), + ); +}); + +test("structured extraction rejects global LinkedIn noise blocks", () => { + const noisyContainer = element( + "div", + element("div", textNode("Inicio Mi red Empleos Mensajes")), + element("div", textNode("Premium")), + element("div", textNode("Seleccionar idioma")), + element("div", textNode("LinkedIn Corporation")), + ); + + assert.equal(helpers.extractStructuredDescriptionText(noisyContainer), ""); +}); From f2385f6843955c34b7ac75758f3946cbc5b6c56a Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 8 Jun 2026 12:26:04 -0500 Subject: [PATCH 6/6] Reuse legacy description extraction in search panel --- chrome-extension/content.js | 44 +--- chrome-extension/linkedin-job-helpers.js | 231 ++---------------- .../tests/linkedin-job-helpers.test.js | 137 +++++------ 3 files changed, 80 insertions(+), 332 deletions(-) diff --git a/chrome-extension/content.js b/chrome-extension/content.js index e66d7c4..fefbf26 100644 --- a/chrome-extension/content.js +++ b/chrome-extension/content.js @@ -42,37 +42,6 @@ function extraerModalidad(root = document) { return "Remoto"; } -function extraerDescripcionLegacyJobView() { - let mejor = ""; - let mejorLen = 0; - - for (const el of document.querySelectorAll("p, span")) { - const texto = el.innerText?.trim() || ""; - if (texto.length > 500 && texto.length < 8000 && texto.length > mejorLen) { - mejor = texto; - mejorLen = texto.length; - } - } - - return mejor; -} - -function extraerDescripcionSearchPanel(root = document) { - const candidatos = root.querySelectorAll("p, span, div"); - let mejor = ""; - let mejorLen = 0; - - for (const el of candidatos) { - const texto = textoLimpio(el.innerText); - if (texto.length > 500 && texto.length < 12000 && texto.length > mejorLen) { - mejor = texto; - mejorLen = texto.length; - } - } - - return mejor; -} - function obtenerContenedorDescripcionBusqueda(root = document) { const selectores = [ ".jobs-description__container", @@ -139,7 +108,7 @@ function extraerHeaderBusqueda(root) { function extraerVacanteJobView(identity) { const { cargo, empresa } = extraerDesdeTitle(); const modalidad = extraerModalidad(document); - const descripcion = LINKEDIN_HELPERS.normalizeDescription(extraerDescripcionLegacyJobView()); + const descripcion = LINKEDIN_HELPERS.extractLegacyDescriptionFrom(document); return { cargo, @@ -167,16 +136,7 @@ function extraerVacanteSearchPanel(identity) { const empresa = header.empresa || fallback.empresa; const modalidad = extraerModalidad(root); const descriptionContainer = obtenerContenedorDescripcionBusqueda(root); - const structuredDescription = LINKEDIN_HELPERS.extractStructuredDescriptionText(descriptionContainer); - const descripcion = structuredDescription.length >= 120 - ? structuredDescription - : (descriptionContainer ? obtenerTextoPorSelectores([ - ".jobs-description__container", - ".jobs-box__html-content", - ".jobs-description-content__text", - ".jobs-description", - ], root) : "") || extraerDescripcionSearchPanel(root); - const descripcionNormalizada = LINKEDIN_HELPERS.normalizeDescription(descripcion); + const descripcionNormalizada = LINKEDIN_HELPERS.extractLegacyDescriptionFrom(descriptionContainer || root); return { cargo, diff --git a/chrome-extension/linkedin-job-helpers.js b/chrome-extension/linkedin-job-helpers.js index 1699944..8781ed3 100644 --- a/chrome-extension/linkedin-job-helpers.js +++ b/chrome-extension/linkedin-job-helpers.js @@ -11,23 +11,6 @@ const JOB_SEARCH_RESULTS_RE = /^\/jobs\/search-results(?:\/|$)/i; const JOB_LINK_RE = /\/jobs\/view\/(\d+)(?:\/|$)/i; const MIN_JOB_ID_LENGTH = 6; - const BLOCK_TAGS = new Set([ - "article", "div", "header", "h1", "h2", "h3", "h4", "h5", "h6", - "li", "ol", "p", "section", "ul", - ]); - const LIST_TAGS = new Set(["ul", "ol"]); - const INLINE_TEXT_TAGS = new Set([ - "a", "b", "em", "i", "small", "span", "strong", "u", - ]); - const IGNORED_TAGS = new Set([ - "button", "footer", "nav", "script", "style", "svg", - ]); - const LINKEDIN_NOISE_PATTERNS = [ - "inicio mi red empleos mensajes", - "linkedin corporation", - "seleccionar idioma", - "premium", - ]; function parseUrl(url) { if (!url) return null; @@ -195,198 +178,6 @@ return normalizedLink ? `linkedin:${normalizedLink}` : null; } - function collapseWhitespace(text) { - return String(text || "").replace(/\s+/g, " ").trim(); - } - - function getNodeTagName(node) { - return String(node?.tagName || node?.nodeName || "").toLowerCase(); - } - - function getNodeType(node) { - if (!node) return 0; - if (typeof node.nodeType === "number") return node.nodeType; - if (node.childNodes || node.tagName || node.nodeName) return 1; - return 3; - } - - function getChildNodes(node) { - if (!node) return []; - if (Array.isArray(node.childNodes)) return node.childNodes; - if (typeof node.childNodes?.length === "number") return Array.from(node.childNodes); - return []; - } - - function getNodeText(node) { - if (!node) return ""; - if (getNodeType(node) === 3) { - return String(node.textContent || node.nodeValue || ""); - } - return String(node.innerText || node.textContent || ""); - } - - function isLikelyLinkedInNoise(text) { - const normalized = collapseWhitespace(text).toLowerCase(); - if (!normalized) return false; - - const matches = LINKEDIN_NOISE_PATTERNS.filter((pattern) => normalized.includes(pattern)); - if (matches.length >= 2) return true; - if (matches.length === 1 && normalized.length <= 120) return true; - return false; - } - - function uniquePush(target, value) { - if (!value) return; - if (!target.includes(value)) { - target.push(value); - } - } - - function inlineTextFromNode(node) { - if (!node) return ""; - - const nodeType = getNodeType(node); - if (nodeType === 3) { - return String(node.textContent || node.nodeValue || ""); - } - - const tag = getNodeTagName(node); - if (IGNORED_TAGS.has(tag)) return ""; - if (tag === "br") return "\n"; - - const children = getChildNodes(node); - if (!children.length) { - return getNodeText(node); - } - - let text = ""; - for (const child of children) { - const childTag = getNodeTagName(child); - if (LIST_TAGS.has(childTag) || BLOCK_TAGS.has(childTag)) { - continue; - } - const childText = inlineTextFromNode(child); - if (childText === "\n") { - text += "\n"; - } else if (childText) { - text += childText; - } - } - - return text || getNodeText(node); - } - - function structuredBlocksFromNode(node) { - if (!node) return []; - - const nodeType = getNodeType(node); - if (nodeType === 3) { - const text = collapseWhitespace(node.textContent || node.nodeValue || ""); - return text ? [text] : []; - } - - const tag = getNodeTagName(node); - if (IGNORED_TAGS.has(tag)) return []; - if (tag === "br") return []; - - const children = getChildNodes(node); - - if (LIST_TAGS.has(tag)) { - const blocks = []; - for (const child of children) { - const childTag = getNodeTagName(child); - if (childTag === "li") { - const itemText = collapseWhitespace(inlineTextFromNode(child)); - if (!itemText || isLikelyLinkedInNoise(itemText)) continue; - uniquePush(blocks, itemText.startsWith("-") ? itemText : `- ${itemText}`); - continue; - } - for (const block of structuredBlocksFromNode(child)) { - if (!isLikelyLinkedInNoise(block)) uniquePush(blocks, block); - } - } - return blocks; - } - - if (tag === "li") { - const itemText = collapseWhitespace(inlineTextFromNode(node)); - if (!itemText || isLikelyLinkedInNoise(itemText)) return []; - return [itemText.startsWith("-") ? itemText : `- ${itemText}`]; - } - - const childBlockTags = children - .map((child) => getNodeTagName(child)) - .filter((childTag) => BLOCK_TAGS.has(childTag) || childTag === "br"); - - if (!childBlockTags.length) { - const text = collapseWhitespace(inlineTextFromNode(node)); - if (!text || isLikelyLinkedInNoise(text)) return []; - return [text]; - } - - const blocks = []; - let inlineBuffer = ""; - - const flushInlineBuffer = () => { - const text = collapseWhitespace(inlineBuffer); - inlineBuffer = ""; - if (!text || isLikelyLinkedInNoise(text)) return; - uniquePush(blocks, text); - }; - - for (const child of children) { - const childType = getNodeType(child); - const childTag = getNodeTagName(child); - - if (childType === 3) { - inlineBuffer += ` ${child.textContent || child.nodeValue || ""}`; - continue; - } - - if (IGNORED_TAGS.has(childTag)) continue; - - if (childTag === "br") { - inlineBuffer += "\n"; - continue; - } - - if (BLOCK_TAGS.has(childTag) || LIST_TAGS.has(childTag)) { - flushInlineBuffer(); - for (const block of structuredBlocksFromNode(child)) { - if (!isLikelyLinkedInNoise(block)) uniquePush(blocks, block); - } - continue; - } - - if (INLINE_TEXT_TAGS.has(childTag) || !childTag) { - inlineBuffer += ` ${inlineTextFromNode(child)}`; - } - } - - flushInlineBuffer(); - - if (!blocks.length) { - const text = collapseWhitespace(getNodeText(node)); - if (!text || isLikelyLinkedInNoise(text)) return []; - return [text]; - } - - return blocks; - } - - function extractStructuredDescriptionText(root) { - if (!root) return ""; - - const blocks = structuredBlocksFromNode(root) - .map((block) => collapseWhitespace(block)) - .filter(Boolean) - .filter((block) => !isLikelyLinkedInNoise(block)); - - if (!blocks.length) return ""; - - return blocks.join("\n\n"); - } - function normalizeDescription(text) { if (text == null) return ""; @@ -406,6 +197,25 @@ return normalized.trim(); } + function extractLegacyDescriptionFrom(root) { + if (!root || typeof root.querySelectorAll !== "function") { + return ""; + } + + let mejor = ""; + let mejorLen = 0; + + for (const el of root.querySelectorAll("p, span")) { + const texto = String(el?.innerText || el?.textContent || "").trim(); + if (texto.length > 500 && texto.length < 8000 && texto.length > mejorLen) { + mejor = texto; + mejorLen = texto.length; + } + } + + return normalizeDescription(mejor); + } + function canSaveVacancy(identity) { return Boolean(identity?.jobId && identity?.canonicalLink && identity?.vacancyKey); } @@ -433,8 +243,7 @@ buildVacancyKey, canSaveVacancy, detectLinkedInContext, - extractStructuredDescriptionText, - isLikelyLinkedInNoise, + extractLegacyDescriptionFrom, normalizeDescription, normalizeJobId, normalizeLink, diff --git a/chrome-extension/tests/linkedin-job-helpers.test.js b/chrome-extension/tests/linkedin-job-helpers.test.js index 27457b5..83125c6 100644 --- a/chrome-extension/tests/linkedin-job-helpers.test.js +++ b/chrome-extension/tests/linkedin-job-helpers.test.js @@ -21,25 +21,19 @@ function createDocument(selectorMap) { }; } -function textNode(text) { +function createTextElement(text) { return { - nodeType: 3, + innerText: text, textContent: text, - nodeValue: text, }; } -function element(tagName, ...children) { - const normalizedChildren = children.flat(); - const node = { - nodeType: 1, - tagName: tagName.toUpperCase(), - nodeName: tagName.toUpperCase(), - childNodes: normalizedChildren, - textContent: normalizedChildren.map((child) => child.textContent || child.nodeValue || "").join(""), - innerText: normalizedChildren.map((child) => child.textContent || child.nodeValue || "").join(""), +function createRootWithTexts(textsBySelector) { + return { + querySelectorAll(selector) { + return (textsBySelector[selector] || []).map((text) => createTextElement(text)); + }, }; - return node; } test("detects /jobs/view/ as job_view", () => { @@ -161,81 +155,66 @@ test("returns empty string for empty description input", () => { assert.equal(helpers.normalizeDescription(null), ""); }); -test("structured extraction separates headings and paragraphs in Search Panel content", () => { - const container = element( - "div", - element("h2", textNode("Propósito del cargo")), - element("p", textNode("Participar y gestionar iniciativas de analitica.")), - element("h3", textNode("Educación")), - element("p", textNode("Pregrado en ingenieria o carreras afines.")) - ); +test("extractLegacyDescriptionFrom returns the longest p/span text inside the root", () => { + const shortText = "x".repeat(520); + const longText = "y".repeat(900); + const root = createRootWithTexts({ + "p, span": [shortText, longText], + }); - assert.equal( - helpers.extractStructuredDescriptionText(container), - [ - "Propósito del cargo", - "Participar y gestionar iniciativas de analitica.", - "Educación", - "Pregrado en ingenieria o carreras afines.", - ].join("\n\n"), - ); + assert.equal(helpers.extractLegacyDescriptionFrom(root), longText); }); -test("structured extraction preserves list-style lines", () => { - const container = element( - "div", - element("h3", textNode("Conocimientos Técnicos")), - element( - "ul", - element("li", textNode("Transformación de datos")), - element("li", textNode("Consultas con SQL")), - element("li", textNode("Bases de datos")) - ) - ); +test("extractLegacyDescriptionFrom ignores texts shorter than 500 chars", () => { + const root = createRootWithTexts({ + "p, span": ["corto", "a".repeat(499)], + }); - assert.equal( - helpers.extractStructuredDescriptionText(container), - [ - "Conocimientos Técnicos", - "- Transformación de datos", - "- Consultas con SQL", - "- Bases de datos", - ].join("\n\n"), - ); + assert.equal(helpers.extractLegacyDescriptionFrom(root), ""); }); -test("structured extraction does not duplicate nested text", () => { - const container = element( - "div", - element( - "section", - element("h3", textNode("Competencias")), - element( - "div", - element("p", textNode("Pensamiento analitico.")), - element("p", textNode("Trabajo colaborativo.")) - ) - ) - ); +test("extractLegacyDescriptionFrom ignores texts longer than 8000 chars", () => { + const validText = "a".repeat(700); + const tooLongText = "b".repeat(8001); + const root = createRootWithTexts({ + "p, span": [tooLongText, validText], + }); - assert.equal( - helpers.extractStructuredDescriptionText(container), - [ - "Competencias", - "Pensamiento analitico.", - "Trabajo colaborativo.", - ].join("\n\n"), - ); + assert.equal(helpers.extractLegacyDescriptionFrom(root), validText); }); -test("structured extraction rejects global LinkedIn noise blocks", () => { - const noisyContainer = element( - "div", - element("div", textNode("Inicio Mi red Empleos Mensajes")), - element("div", textNode("Premium")), - element("div", textNode("Seleccionar idioma")), - element("div", textNode("LinkedIn Corporation")), +test("extractLegacyDescriptionFrom applies normalizeDescription", () => { + const root = createRootWithTexts({ + "p, span": ["Acerca del empleo\n" + "a".repeat(650) + "\n... más"], + }); + + assert.equal(helpers.extractLegacyDescriptionFrom(root), "a".repeat(650)); +}); + +test("extractLegacyDescriptionFrom only uses text inside the provided root", () => { + const root = createRootWithTexts({ + "p, span": ["Contenido local " + "a".repeat(650)], + }); + const globalNoise = createRootWithTexts({ + "p, span": ["Contenido global " + "b".repeat(1200)], + }); + + assert.equal(helpers.extractLegacyDescriptionFrom(root), "Contenido local " + "a".repeat(650)); + assert.notEqual( + helpers.extractLegacyDescriptionFrom(root), + helpers.extractLegacyDescriptionFrom(globalNoise), ); +}); + +test("extractLegacyDescriptionFrom does not accept global LinkedIn noise when root has no valid description", () => { + const root = createRootWithTexts({ + "p, span": [ + "Inicio Mi red Empleos Mensajes", + "Premium", + "Seleccionar idioma", + "LinkedIn Corporation", + ], + }); - assert.equal(helpers.extractStructuredDescriptionText(noisyContainer), ""); + assert.equal(helpers.extractLegacyDescriptionFrom(root), ""); });