lib/linkedin-import.mjs
Rama main · réplica de lectura del repositorio original.
import { linkedInJobId } from './linkedin-url.mjs';
export const LINKEDIN_DOCTOR_LABEL =
'LinkedIn jobs: paste an alert email on the dashboard, or run node linkedin-import.mjs. No Google account.';
export const LINKEDIN_SKIP_GOOGLE =
'You can skip Google setup. Paste the LinkedIn alert email on the dashboard, or run node linkedin-import.mjs.';
export const LINKEDIN_IMPORT_MAX_CHARS = 2_000_000;
const EMPTY_MESSAGE = 'No LinkedIn jobs found. Open a job alert email, select all, copy, and paste it here.';
const TOO_LARGE_MESSAGE = 'That paste is too large. Paste one alert email.';
// A full URL, or a bare linkedin.com/jobs link that does not sit inside
// another URL. One pattern keeps the matches in text order.
const JOB_LINK = /https?:\/\/[^\s"'<>]+|(?<![\w.\-/%@:=&?#~+])(?:[a-z0-9-]+\.)?linkedin\.com\/(?:comm\/)?jobs\/[^\s"'<>]+/gi;
/**
* @param {string} pluginId
* @returns {string|null}
*/
export function linkedInPluginFix(pluginId) {
if (pluginId !== 'gmail') return null;
return `${pluginId}: ${LINKEDIN_SKIP_GOOGLE}`;
}
/**
* @param {string} id
* @returns {string}
*/
export function canonicalLinkedInJobUrl(id) {
return `https://www.linkedin.com/jobs/view/${id}`;
}
function prepare(text) {
return text.replace(/=\r?\n/g, '').replaceAll('&', '&');
}
function stripTrail(url) {
return url.replace(/[),.;!?\]]+$/g, '');
}
/**
* @param {string} raw
* @param {(id: string) => void} push
* @param {number} depth
*/
function consider(raw, push, depth) {
if (!raw || depth > 3) return;
let url;
try {
url = new URL(raw);
} catch {
return;
}
const id = linkedInJobId(url);
if (id) push(id);
for (const value of url.searchParams.values()) consider(value, push, depth + 1);
}
/**
* Every LinkedIn posting in `text`, first-seen order.
* The URL is always `https://www.linkedin.com/jobs/view/{id}`.
*
* @param {string} text
* @returns {{id: string, url: string}[]}
*/
export function linkedInJobsFromText(text) {
if (typeof text !== 'string' || text.length === 0) return [];
const body = prepare(text);
/** @type {string[]} */
const ids = [];
const seen = new Set();
const push = (id) => {
if (!/^\d+$/.test(id) || seen.has(id)) return;
seen.add(id);
ids.push(id);
};
for (const [match] of body.matchAll(JOB_LINK)) {
const url = stripTrail(match);
consider(/^https?:/i.test(url) ? url : `https://${url}`, push, 0);
}
return ids.map((id) => ({ id, url: canonicalLinkedInJobUrl(id) }));
}
/**
* @param {{added: number, already: number, found: number, dryRun?: boolean}} counts
* @returns {string}
*/
export function linkedInImportMessage({ added, already, found, dryRun = false }) {
if (found === 0) return EMPTY_MESSAGE;
if (dryRun && added > 0) {
return added === 1
? '1 LinkedIn job found. Nothing was saved.'
: `${added} LinkedIn jobs found. Nothing was saved.`;
}
if (added === 0) {
return already === 1
? 'That LinkedIn job is already in your list.'
: `Those ${already} LinkedIn jobs are already in your list.`;
}
const addedLine = added === 1
? '1 LinkedIn job added. Open Pipeline to review it.'
: `${added} LinkedIn jobs added. Open Pipeline to review them.`;
if (already === 0) return addedLine;
const alreadyLine = already === 1 ? '1 was already in your list' : `${already} were already in your list`;
return `${addedLine} ${alreadyLine}.`;
}
/**
* @param {string} text Alert email, a list of URLs, or a saved .eml.
* @param {string} seenText pipeline.md and applications.md, concatenated.
* @param {{dryRun?: boolean}} [options]
* @returns {{found: number, added: {id: string, url: string}[], already: {id: string, url: string}[], message: string, error?: string}}
*/
export function planLinkedInImport(text, seenText, { dryRun = false } = {}) {
if (typeof text !== 'string' || text.trim() === '') {
return { found: 0, added: [], already: [], message: EMPTY_MESSAGE };
}
if (text.length > LINKEDIN_IMPORT_MAX_CHARS) {
return { found: 0, added: [], already: [], error: 'too-large', message: TOO_LARGE_MESSAGE };
}
const jobs = linkedInJobsFromText(text);
const seen = new Set(linkedInJobsFromText(typeof seenText === 'string' ? seenText : '').map((job) => job.id));
const added = [];
const already = [];
for (const job of jobs) {
if (seen.has(job.id)) already.push(job);
else added.push(job);
}
return {
found: jobs.length,
added,
already,
message: linkedInImportMessage({
added: added.length,
already: already.length,
found: jobs.length,
dryRun,
}),
};
}
/**
* @param {{id: string, url: string}[]} jobs
* @returns {{url: string, company: string, title: string, location: string, source: string}[]}
*/
const MAIL_HEADER = /^(from|to|subject|date|reply-to|cc|bcc|mime-version|content-type)\s*:/i;
const BOILERPLATE = /^(view job|see job|see all jobs|easy apply|apply now)\b/i;
function looksLikeLocation(line) {
const text = line.trim();
// "Remote Software Engineer" is a title. A place is the mode alone,
// the mode plus a separator ("Remote, US"), or the mode plus one token
// ("Remote US", "Remote Europe").
if (/^(?:remote|hybrid|on-?site)(?:\s*$|\s*[-,(\/|·•]|\s+\S+$)/i.test(text)) return true;
return /,\s*[A-Z]{2}\b/.test(text) && text.length < 80;
}
function titleFromSlug(body, id) {
const match = body.match(new RegExp(String.raw`/jobs/view/([a-z0-9]+(?:-[a-z0-9]+)*)-${id}(?!\d)`, 'i'));
if (!match) return null;
const words = match[1].split('-').filter(Boolean);
if (!words.length) return null;
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
function linesAboveIndex(lines, index) {
if (index < 0) return [];
const collected = [];
for (let i = index - 1; i >= 0 && collected.length < 4; i--) {
const line = lines[i].trim();
if (!line) {
if (collected.length) break;
continue;
}
if (/^https?:\/\//i.test(line) || /linkedin\.com\/(?:comm\/)?jobs\//i.test(line)) break;
if (MAIL_HEADER.test(line)) break;
if (BOILERPLATE.test(line)) continue;
collected.push(line);
}
return collected.reverse();
}
// First job-link line for each id. Callers were splitting and scanning the
// whole alert once per job, which is quadratic near the size cap.
function jobLineIndex(lines) {
const index = new Map();
for (let i = 0; i < lines.length; i++) {
if (!/jobs\/view|currentJobId/i.test(lines[i])) continue;
for (const id of lines[i].match(/\d+/g) || []) {
if (!index.has(id)) index.set(id, i);
}
}
return index;
}
// A pasted line can be a whole paragraph. A real title or company name is far
// shorter, so a longer line is cut before it reaches the pipeline row.
export const MAX_FIELD_LENGTH = 120;
function bounded(line) {
return line.length > MAX_FIELD_LENGTH ? `${line.slice(0, MAX_FIELD_LENGTH - 1).trimEnd()}…` : line;
}
function fieldsFromAbove(body, lines, lineOf, id) {
const useful = [];
let location = '';
for (const line of linesAboveIndex(lines, id && lineOf.has(id) ? lineOf.get(id) : -1)) {
if (looksLikeLocation(line)) {
location = line.trim();
continue;
}
useful.push(bounded(line));
}
location = bounded(location);
if (useful.length >= 2) return { title: useful[0], company: useful[1], location };
if (useful.length === 1) return { title: useful[0], company: 'LinkedIn', location };
return { title: (id && titleFromSlug(body, id)) || 'Job alert', company: 'LinkedIn', location };
}
/**
* Title, company, and location for one parsed job. The job list itself stays `{id, url}`.
* A non-URL block above the link wins (title, then company). A Remote, Hybrid,
* On-site, or short City, ST line is the location, not the title or the company.
* Otherwise the words in `/jobs/view/{words}-{id}` become the title.
*
* @param {string} text
* @param {{id?: string}} job
* @returns {{title: string, company: string, location: string}}
*/
export function linkedInOfferFields(text, job) {
const body = typeof text === 'string' ? prepare(text) : '';
const lines = body.split(/\r?\n/);
const id = job?.id && /^\d+$/.test(job.id) ? job.id : '';
return fieldsFromAbove(body, lines, jobLineIndex(lines), id);
}
export function linkedInOffers(jobs, text = '') {
const body = typeof text === 'string' ? prepare(text) : '';
const lines = body.split(/\r?\n/);
const lineOf = jobLineIndex(lines);
return jobs.map((job) => {
const id = job?.id && /^\d+$/.test(job.id) ? job.id : '';
const fields = fieldsFromAbove(body, lines, lineOf, id);
return {
url: job.url,
company: fields.company,
title: fields.title,
location: fields.location,
source: 'linkedin-import',
};
});
}