src/lib/linkedin-archive.ts
Rama main · réplica de lectura del repositorio original.
import type { ResumeImport } from "@/lib/data/me";
/**
* LinkedIn's own data export, read structurally.
*
* `import_resume` exists because a resume is prose an assistant has to
* interpret, and `resume-parse-linkedin.ts` exists because a pasted profile is
* prose wearing a layout. The ARCHIVE is neither: it is CSV with column
* headers, and guessing at it would be throwing away the one export in this
* product's world that does not need guessing.
*
* PURE, beside resume-parse.ts and for the same reason those files are: it is a
* translation, not a data rule, and it has to run in the browser (the import
* dialog) and on the server (the tool) without either owning it. There is one
* definition of "already on file" in this app and it lives in `runImport`; this
* file never grows a second one — it produces exactly the payload
* `importResume` already takes and hands it over.
*/
/** One file out of the archive, as it arrived. */
export type ArchiveFile = { name: string; text: string };
/** What a translation found, and what it could not use. */
export type ArchiveReport = {
/** Files it recognised, by the canonical name, with the row count each gave. */
read: { file: string; rows: number }[];
/** Files in the payload it has no reader for. Named, never silently dropped. */
ignored: string[];
/** Files it recognised and could not read — a missing header, a truncated row. */
problems: string[];
};
export type LinkedInApplication = {
company: string;
roleTitle: string;
jobUrl: string;
/** The date as LinkedIn wrote it, or "" when it gave one that did not parse. */
appliedAt: string;
};
export type LinkedInConnection = {
name: string;
email: string;
title: string;
company: string;
linkedin: string;
connectedAt: string;
};
export type LinkedInTranslation = {
/** Exactly the shape me.importResume takes. */
payload: ResumeImport;
/** Jobs LinkedIn recorded you applying to. Empty unless `jobs` was asked for. */
applications: LinkedInApplication[];
/** Connections worth filing. Empty unless `connections` was asked for. */
connections: LinkedInConnection[];
report: ArchiveReport;
};
export const ARCHIVE_MAX_BYTES = 8_000_000;
export const ARCHIVE_MAX_FILE_BYTES = 4_000_000;
// ---------------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------------
/**
* A real RFC-4180 reader: quoted fields, doubled quotes, embedded newlines and
* commas.
*
* LinkedIn's `Description` column contains all three, and a split on commas
* eats the rest of the file the first time somebody's job description has one
* in it. An unterminated quote at EOF is reported as a problem by the caller
* rather than thrown: an import that fails whole because of one bad row is an
* import nobody runs twice.
*/
export function parseCsv(text: string): Record<string, string>[] {
const rows = csvRows(text);
if (rows.length === 0) return [];
const [header, ...body] = rows;
const keys = header.map((key) => key.trim());
return body
.filter((row) => row.some((cell) => cell.trim() !== ""))
.map((row) => {
const record: Record<string, string> = {};
keys.forEach((key, index) => {
record[key] = (row[index] ?? "").trim();
});
return record;
});
}
function csvRows(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
// Strip a BOM: LinkedIn writes one, and it turns the first header into
// "First Name", which matches nothing.
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (quoted) {
if (char === '"') {
if (source[i + 1] === '"') {
field += '"';
i += 1;
} else {
quoted = false;
}
} else {
field += char;
}
continue;
}
if (char === '"') {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
/** True when a quote was opened and never closed. */
function hasUnbalancedQuote(text: string): boolean {
let count = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] !== '"') continue;
if (text[i + 1] === '"') {
i += 1;
continue;
}
count += 1;
}
return count % 2 === 1;
}
// ---------------------------------------------------------------------------
// Which file is which
// ---------------------------------------------------------------------------
/** The basename, lower-cased, so "Jobs/Job Applications.csv" lands. */
function basename(name: string): string {
return name.split(/[\\/]/).pop()!.trim().toLowerCase();
}
const FILES = {
profile: "profile.csv",
positions: "positions.csv",
education: "education.csv",
skills: "skills.csv",
languages: "languages.csv",
certifications: "certifications.csv",
projects: "projects.csv",
emails: "email addresses.csv",
jobs: "job applications.csv",
connections: "connections.csv",
} as const;
const KNOWN = new Set<string>(Object.values(FILES));
/**
* The first value present, by any of its known header spellings.
*
* LinkedIn renames headers without notice — `Company Name` has also been
* `Company`, `Started On` has also been `Start Date` — and the single most
* likely failure of this whole feature is a rename that makes the import
* silently read nothing. Aliases plus a row count per file is what makes that
* visible instead.
*/
function cell(row: Record<string, string>, ...names: string[]): string {
for (const name of names) {
const found = Object.keys(row).find((key) => key.trim().toLowerCase() === name.toLowerCase());
if (found && row[found]?.trim()) return row[found].trim();
}
return "";
}
/** LinkedIn writes websites as "[PERSONAL:https://…]". Pull the address out. */
function firstUrl(value: string): string {
const bracketed = value.match(/https?:\/\/[^\],\s]+/);
return bracketed ? bracketed[0] : value.split(",")[0]?.trim() ?? "";
}
function firstHandle(value: string): string {
return value.split(",")[0]?.trim() ?? "";
}
// ---------------------------------------------------------------------------
// The translation
// ---------------------------------------------------------------------------
export function translateLinkedInArchive(
files: ArchiveFile[],
options?: { jobs?: boolean; connections?: boolean },
): LinkedInTranslation {
const report: ArchiveReport = { read: [], ignored: [], problems: [] };
const payload: ResumeImport = {};
const applications: LinkedInApplication[] = [];
const connections: LinkedInConnection[] = [];
const byName = new Map<string, ArchiveFile>();
for (const file of files) {
const key = basename(file.name);
if (!KNOWN.has(key)) {
report.ignored.push(file.name);
continue;
}
byName.set(key, file);
}
/** Read one recognised file, or say why it could not be read. */
const rowsOf = (key: string, ...expect: string[]): Record<string, string>[] => {
const file = byName.get(key);
if (!file) return [];
if (hasUnbalancedQuote(file.text)) {
report.problems.push(
`${file.name} has a quote that is opened and never closed, so everything after it is one field. Nothing from it was read.`,
);
return [];
}
// Connections.csv opens with a three-line preamble — a "Notes:" line, a
// sentence about the export, and a blank — BEFORE the header row.
// Everything else in the archive starts at the header.
let text = file.text;
if (expect.length > 0) {
const lines = text.split("\n");
const headerAt = lines.findIndex((line) =>
expect.some((word) => line.toLowerCase().includes(word.toLowerCase())),
);
if (headerAt > 0) text = lines.slice(headerAt).join("\n");
if (headerAt < 0) {
report.problems.push(
`${file.name} has no header row naming ${expect.join(" or ")}. It had: ${(lines[0] ?? "").slice(0, 120)}`,
);
return [];
}
}
const rows = parseCsv(text);
report.read.push({ file: file.name, rows: rows.length });
return rows;
};
// --- Profile -------------------------------------------------------------
const profileRows = rowsOf(FILES.profile, "First Name");
if (profileRows.length > 0) {
const row = profileRows[0];
const name = [cell(row, "First Name"), cell(row, "Last Name")].filter(Boolean).join(" ");
const website = firstUrl(cell(row, "Websites"));
const twitter = firstHandle(cell(row, "Twitter Handles"));
payload.profile = {
...(name ? { fullName: name } : {}),
...(cell(row, "Headline") ? { headline: cell(row, "Headline") } : {}),
...(cell(row, "Summary") ? { summary: cell(row, "Summary") } : {}),
...(cell(row, "Geo Location", "Location") ? { location: cell(row, "Geo Location", "Location") } : {}),
...(website ? { website } : {}),
...(twitter ? { twitter } : {}),
};
// Named rather than silently dropped, so nobody has to wonder.
report.ignored.push(
"Profile.csv: Address, Birth Date, Zip Code, Instant Messengers, Maiden Name and Industry are not read",
);
}
const emailRows = rowsOf(FILES.emails, "Email Address");
const primary =
emailRows.find((row) => cell(row, "Primary").toLowerCase() === "yes") ?? emailRows[0];
if (primary && cell(primary, "Email Address")) {
payload.profile = { ...(payload.profile ?? {}), email: cell(primary, "Email Address") };
}
// --- Positions -----------------------------------------------------------
const positions = rowsOf(FILES.positions, "Company Name", "Company");
if (positions.length > 0) {
payload.roles = positions
.map((row) => {
const finished = cell(row, "Finished On", "End Date");
return {
company: cell(row, "Company Name", "Company"),
title: cell(row, "Title", "Position"),
location: cell(row, "Location"),
// Stored as the string LinkedIn gave ("Mar 2021"): Role.startDate is
// a String column and the rest of the app reads it as text.
startDate: cell(row, "Started On", "Start Date"),
endDate: finished,
// An empty Finished On is the job they are still in.
isCurrent: finished === "",
// The whole description goes to BACKGROUND, never to bullets.
// runImport turns every bullet into a Highlight, so splitting prose
// on newlines would manufacture achievement lines the person never
// wrote. Roles from an archive carry no bullets, deliberately.
background: cell(row, "Description"),
};
})
.filter((role) => role.company && role.title);
if (payload.roles.length < positions.length) {
report.problems.push(
`Positions.csv: ${positions.length - payload.roles.length} row(s) had no employer or no title and were skipped.`,
);
}
}
// --- Education -----------------------------------------------------------
const education = rowsOf(FILES.education, "School Name", "School");
if (education.length > 0) {
payload.education = education
.map((row) => ({
school: cell(row, "School Name", "School"),
degree: cell(row, "Degree Name", "Degree"),
startDate: cell(row, "Start Date", "Started On"),
endDate: cell(row, "End Date", "Finished On"),
details: [cell(row, "Notes"), cell(row, "Activities")].filter(Boolean).join("\n"),
}))
.filter((entry) => entry.school);
}
// --- Skills and languages ------------------------------------------------
const skillGroups: NonNullable<ResumeImport["skillGroups"]> = [];
const skills = rowsOf(FILES.skills, "Name");
const skillNames = skills.map((row) => cell(row, "Name")).filter(Boolean);
if (skillNames.length > 0) skillGroups.push({ name: "Skills", skills: skillNames });
const languages = rowsOf(FILES.languages, "Name");
const languageNames = languages
.map((row) => {
const proficiency = cell(row, "Proficiency");
const name = cell(row, "Name");
return name ? (proficiency ? `${name} (${proficiency})` : name) : "";
})
.filter(Boolean);
if (languageNames.length > 0) skillGroups.push({ name: "Languages", skills: languageNames });
if (skillGroups.length > 0) payload.skillGroups = skillGroups;
// --- Certifications ------------------------------------------------------
const certifications = rowsOf(FILES.certifications, "Name");
if (certifications.length > 0) {
payload.certifications = certifications
.map((row) => ({
name: cell(row, "Name"),
issuer: cell(row, "Authority", "Issuer"),
date: cell(row, "Started On", "Start Date"),
url: cell(row, "Url", "URL"),
}))
.filter((entry) => entry.name);
}
// --- Projects ------------------------------------------------------------
const projects = rowsOf(FILES.projects, "Title", "Name");
if (projects.length > 0) {
payload.projects = projects
.map((row) => ({
name: cell(row, "Title", "Name"),
description: cell(row, "Description"),
url: cell(row, "Url", "URL"),
startDate: cell(row, "Started On", "Start Date"),
endDate: cell(row, "Finished On", "End Date"),
}))
.filter((entry) => entry.name);
}
// --- Applications, only when asked for -----------------------------------
if (options?.jobs) {
for (const row of rowsOf(FILES.jobs, "Company Name", "Company")) {
const company = cell(row, "Company Name", "Company");
const roleTitle = cell(row, "Job Title", "Title");
if (!company || !roleTitle) continue;
applications.push({
company,
roleTitle,
jobUrl: cell(row, "Job Url", "Job URL"),
appliedAt: cell(row, "Application Date"),
});
}
}
// --- Connections, only when asked for ------------------------------------
if (options?.connections) {
for (const row of rowsOf(FILES.connections, "First Name")) {
const name = [cell(row, "First Name"), cell(row, "Last Name")].filter(Boolean).join(" ");
if (!name) continue;
connections.push({
name,
email: cell(row, "Email Address", "Email"),
title: cell(row, "Position", "Title"),
company: cell(row, "Company"),
linkedin: cell(row, "URL", "Url"),
connectedAt: cell(row, "Connected On"),
});
}
}
return { payload, applications, connections, report };
}
// ---------------------------------------------------------------------------
// The zip
// ---------------------------------------------------------------------------
/**
* Read the CSVs out of a LinkedIn archive.
*
* The mirror of src/lib/zip.ts, which writes one by hand for the same stated
* reason — not worth a dependency. It reads the central directory,
* `DecompressionStream("deflate-raw")` inflates each entry, and everything that
* is not a `.csv` is skipped.
*
* A zip bomb is a real shape here, so an entry whose DECLARED uncompressed size
* is over the cap is refused before anything is inflated.
*/
export async function unzipEntries(input: Uint8Array): Promise<ArchiveFile[]> {
// Copied into a plain ArrayBuffer so the slices below are BlobParts whatever
// the caller handed in — a Uint8Array over a SharedArrayBuffer is not one.
const bytes = new Uint8Array(input);
const view = new DataView(bytes.buffer);
// Find the end-of-central-directory record, scanning back from the tail.
let eocd = -1;
for (let i = bytes.length - 22; i >= 0 && i > bytes.length - 66_000; i -= 1) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("That does not look like a zip file.");
const count = view.getUint16(eocd + 10, true);
let offset = view.getUint32(eocd + 16, true);
const files: ArchiveFile[] = [];
const decoder = new TextDecoder();
for (let i = 0; i < count; i += 1) {
if (view.getUint32(offset, true) !== 0x02014b50) break;
const method = view.getUint16(offset + 10, true);
const compressedSize = view.getUint32(offset + 20, true);
const uncompressedSize = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const localOffset = view.getUint32(offset + 42, true);
const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
offset += 46 + nameLength + extraLength + commentLength;
if (!name.toLowerCase().endsWith(".csv")) continue;
// Refused BEFORE inflating: the declared size is the only warning a zip
// bomb gives before it costs memory.
if (uncompressedSize > ARCHIVE_MAX_FILE_BYTES) continue;
const localNameLength = view.getUint16(localOffset + 26, true);
const localExtraLength = view.getUint16(localOffset + 28, true);
const start = localOffset + 30 + localNameLength + localExtraLength;
const raw = bytes.subarray(start, start + compressedSize);
if (method === 0) {
files.push({ name, text: decoder.decode(raw) });
continue;
}
if (method !== 8) continue;
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
const inflated = new Uint8Array(await new Response(stream).arrayBuffer());
files.push({ name, text: decoder.decode(inflated) });
}
return files;
}