Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions chrome-extension/background.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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(),
};
}
221 changes: 152 additions & 69 deletions chrome-extension/content.js
Original file line number Diff line number Diff line change
@@ -1,111 +1,194 @@
/**
* 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/<id>
* 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";
}
}
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;
});

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;
}
Loading
Loading