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..fefbf26 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,153 @@ function extraerModalidad() { return "Remoto"; } -// ───────────────────────────────────────────────────────────── -// DESCRIPCIÓN — el P o SPAN más largo entre 500 y 8000 chars -// ───────────────────────────────────────────────────────────── +function obtenerContenedorDescripcionBusqueda(root = document) { + const selectores = [ + ".jobs-description__container", + ".jobs-box__html-content", + ".jobs-description-content__text", + ".jobs-description", + ]; -function extraerDescripcion() { - let mejor = ""; - let mejorLen = 0; + for (const selector of selectores) { + const node = root.querySelector(selector); + if (node) return node; + } - 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 null; +} + +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 mejor; + return ""; } -// ───────────────────────────────────────────────────────────── -// FUNCIÓN PRINCIPAL -// ───────────────────────────────────────────────────────────── +function obtenerRootDetalleBusqueda() { + const selectors = [ + ".jobs-search__job-details--container", + ".jobs-search__job-details", + ".jobs-details", + ".scaffold-layout__detail", + ".jobs-unified-top-card", + "main", + ]; -function extraerDatosVacante() { + 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 = LINKEDIN_HELPERS.extractLegacyDescriptionFrom(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 descriptionContainer = obtenerContenedorDescripcionBusqueda(root); + const descripcionNormalizada = LINKEDIN_HELPERS.extractLegacyDescriptionFrom(descriptionContainer || root); + + return { + cargo, + empresa, + modalidad, + descripcion: descripcionNormalizada, + link: identity.canonicalLink, + vacancyKey: identity.vacancyKey, + _identity: identity, + _calidad: { + cargo: cargo.length > 0, + empresa: empresa.length > 0, + modalidad: MODALIDADES_VALIDAS.has(modalidad), + descripcion: descripcionNormalizada.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..8781ed3 --- /dev/null +++ b/chrome-extension/linkedin-job-helpers.js @@ -0,0 +1,257 @@ +(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 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 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); + } + + 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, + extractLegacyDescriptionFrom, + normalizeDescription, + 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(); 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..83125c6 --- /dev/null +++ b/chrome-extension/tests/linkedin-job-helpers.test.js @@ -0,0 +1,220 @@ +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] || []; + }, + }; +} + +function createTextElement(text) { + return { + innerText: text, + textContent: text, + }; +} + +function createRootWithTexts(textsBySelector) { + return { + querySelectorAll(selector) { + return (textsBySelector[selector] || []).map((text) => createTextElement(text)); + }, + }; +} + +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); +}); + +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), ""); +}); + +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.extractLegacyDescriptionFrom(root), longText); +}); + +test("extractLegacyDescriptionFrom ignores texts shorter than 500 chars", () => { + const root = createRootWithTexts({ + "p, span": ["corto", "a".repeat(499)], + }); + + assert.equal(helpers.extractLegacyDescriptionFrom(root), ""); +}); + +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.extractLegacyDescriptionFrom(root), validText); +}); + +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.extractLegacyDescriptionFrom(root), ""); +});