Ctrl K
ComunidadGuías…

src/lib/resume-parse-linkedin.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Making a LinkedIn profile look like a resume, before the generic parser reads
 * it.
 *
 * People paste LinkedIn far more often than they paste a resume — it is the
 * document they actually keep up to date — and what the clipboard gives you is
 * not a resume. It repeats the employer, hangs a duration off every date, marks
 * employment type with a middot, and groups several titles under one company
 * with the company named once. Read straight, that produces jobs called
 * "Full-time", employers called "3 yrs 8 mos", and promotions filed as jobs at
 * no company at all.
 *
 * This is a normaliser, not a second parser. It rewrites the text into the
 * shape resume-parse.ts already reads well and hands it on, so there is one
 * implementation of "what is a job" rather than two that drift. Everything it
 * does is visible in the review step, because it changes what the fields say.
 */

/** "Jan 2021 - Present · 3 yrs 8 mos" — the tail is LinkedIn's arithmetic. */
const DURATION_TAIL = /\s*[·•]\s*(?:(?:about|over|less than)\s+)?\d+\s*(?:yrs?|years?|mos?|months?)(?:\s+\d+\s*(?:mos?|months?))?\s*$/i;

/** "(3 years 8 months)" — the same thing in LinkedIn's PDF export. */
const DURATION_PARENS = /\s*\((?:(?:about|over|less than)\s+)?\d+\s*(?:yrs?|years?|mos?|months?)(?:[ ,]+\d+\s*(?:mos?|months?))?\)\s*$/i;

const EMPLOYMENT_TYPES = [
  "Full-time",
  "Part-time",
  "Self-employed",
  "Freelance",
  "Contract",
  "Internship",
  "Apprenticeship",
  "Seasonal",
];

/** "· Hybrid", "· Remote", "· On-site" — where, not what. */
const WORKPLACE = /\s*[·•]\s*(?:Hybrid|Remote|On-?site)\s*$/i;

/** LinkedIn's own furniture: logos, follower counts, its call to action. */
const FURNITURE =
  /^(?:.*\blogo\b.*|\d[\d,]*\s+followers?|\d[\d,]*\s+connections?|Show all .*|See more|…see more|Show credential|Endorsed by .*|Skills?:.*|Top skills?:.*|Contact info|Message|Follow|Connect|Open to work|Providing services)$/i;

const MONTH =
  /(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?/i;

const DATE_LINE = new RegExp(
  `^\\s*(?:${MONTH.source}\\s+)?\\d{4}\\s*(?:–|—|-|to)\\s*(?:(?:${MONTH.source}\\s+)?\\d{4}|present|current|now)\\s*$`,
  "i",
);

/**
 * Does this read like something copied off LinkedIn?
 *
 * Two independent markers, not one. A single "· Full-time" could be a line in
 * anybody's resume, and normalising an ordinary document as though it were a
 * profile would be worse than not trying: the whole point of this file is that
 * it only fires where it helps.
 */
export function looksLikeLinkedIn(text: string): boolean {
  const markers = [
    DURATION_TAIL.test(text) || /\b\d+\s+yrs?\b/i.test(text),
    new RegExp(`[·•]\\s*(?:${EMPLOYMENT_TYPES.join("|")})\\b`, "i").test(text),
    /\b\d[\d,]*\s+(?:followers|connections)\b/i.test(text),
    /(?:^|\n)\s*(?:…see more|Show credential|Top skills)\b/i.test(text),
    // The clipboard repeats an employer under itself, once per job.
    hasRepeatedLine(text),
  ].filter(Boolean).length;
  return markers >= 2;
}

/**
 * Does this line read like the name of a company, rather than a sentence about
 * one?
 *
 * The structural rule "a line two above a date is the employer" is true of a
 * profile and also true of the last bullet of the job above, which sits
 * directly on top of the next job's title. Filing "Grew the data team from two
 * to nine." as an employer is the worst thing this file can do, so a name has
 * to look like one: short, and not a finished sentence.
 */
function readsLikeAName(line: string): boolean {
  return line.split(/\s+/).length <= 7 && !/[.!?]$/.test(line);
}

/** LinkedIn's copy names the employer twice in a row, logo line then text. */
function hasRepeatedLine(text: string): boolean {
  const lines = text.split("\n").map((line) => line.trim());
  return lines.some(
    (line, index) =>
      line.length > 1 &&
      index > 0 &&
      lines[index - 1] === line &&
      !/^[•‣▪◦·*-]/.test(line),
  );
}

/**
 * Rewrite a pasted profile into the shape the resume parser reads.
 *
 * Two passes, and it matters. The first only cleans lines — furniture out,
 * durations and workplace tags off, employment type lifted out of the middot
 * list. The second reads structure, and can only do that against lines that are
 * already clean: whether something is a company or a title is decided by what
 * follows it, and a first attempt that cleaned and looked ahead at once was
 * looking ahead at text it had not cleaned yet. It pasted the employer onto
 * location lines and bullets.
 *
 * The structural job is carrying an employer down. LinkedIn groups a promotion
 * under the company it happened at, naming the company once and then listing
 * titles; read straight, the second title is a job at no employer. Here the
 * company is written onto each title line, so three promotions at one employer
 * arrive as three jobs at that employer — which is what they are, and what a
 * resume would say.
 */
export function flattenLinkedIn(text: string): string {
  type Line = { text: string; employment?: string };

  // --- pass one: clean, and only clean.
  const clean: Line[] = [];
  for (const raw of text.replace(/\r/g, "").split("\n")) {
    let line = raw.trim();
    if (!line || FURNITURE.test(line)) continue;
    const employment = EMPLOYMENT_TYPES.find((type) =>
      new RegExp(`[·•]\\s*${type}\\b`, "i").test(line),
    );
    line = line
      .replace(DURATION_TAIL, "")
      .replace(DURATION_PARENS, "")
      .replace(WORKPLACE, "")
      .replace(new RegExp(`\\s*[·•]\\s*(?:${EMPLOYMENT_TYPES.join("|")})\\b`, "i"), "")
      .replace(/\s*[·•]\s*$/, "")
      .trim();
    if (!line) continue;
    // "Stripe" straight after "Stripe": the logo's alt text and the name. This
    // has to happen AFTER cleaning — the clipboard writes the employer once
    // bare and once with "· Full-time" on it, and those are only the same line
    // once the suffix is gone.
    const previous = clean[clean.length - 1];
    if (previous && previous.text === line) {
      if (employment && !previous.employment) previous.employment = employment;
      continue;
    }
    clean.push({ text: line, ...(employment ? { employment } : {}) });
  }

  // --- pass two: read structure against clean lines.
  const out: string[] = [];
  const isDate = (at: number) => Boolean(clean[at]) && DATE_LINE.test(clean[at].text);
  let employer = "";
  let employerType: string | undefined;
  let inExperience = false;

  for (let index = 0; index < clean.length; index++) {
    const { text: line, employment } = clean[index];

    if (/^(experience|education|licenses?\s*&?\s*certifications?|projects|skills|about|summary)$/i.test(line)) {
      inExperience = /^experience$/i.test(line);
      employer = "";
      employerType = undefined;
      out.push(line);
      continue;
    }

    if (inExperience && !isDate(index) && !/^[•‣▪◦·*\-–—]/.test(line)) {
      // Followed by dates: a job title. Followed by something that is followed
      // by dates: the employer that title sits under.
      if (isDate(index + 1)) {
        const type = employment ?? employerType;
        // A blank line first: the generic parser separates one job from the
        // next by blank lines, and a profile has none — every job in it runs
        // straight into the one below. Without this, three jobs arrive as one
        // with the others' titles read as its bullets.
        if (out.length && out[out.length - 1] !== "") out.push("");
        out.push(employer ? `${line} — ${employer}` : line);
        // On its own line, not inside the name: the generic parser reads
        // "Contract", "Internship" and the rest out of an entry's text, and
        // anything welded into the employer becomes part of the employer.
        if (type) out.push(type);
        continue;
      }
      if (isDate(index + 2) && readsLikeAName(line)) {
        employer = line;
        employerType = employment;
        // Not emitted on its own: it goes onto the title line below, where
        // "Title — Company" is a shape the parser already reads.
        continue;
      }
    }

    out.push(line);
  }

  return out.join("\n");
}