Skip to content

Instantly share code, notes, and snippets.

@saulodias
Last active July 24, 2023 21:21
Show Gist options
  • Save saulodias/49c3090297b5dcb124679d88cded10e9 to your computer and use it in GitHub Desktop.
Save saulodias/49c3090297b5dcb124679d88cded10e9 to your computer and use it in GitHub Desktop.
Extract and display CV data from LinkedIn profiles

README

LinkedIn CV Generator UserScript

This UserScript allows you to extract and display CV data from LinkedIn profiles and generate a custom CV in PDF format.

Installation

  1. Install the Tampermonkey extension for Google Chrome here.
  2. Open the LinkedIn CV Generator script here.
  3. Click the "Raw" button to view the script code.
  4. Click the "Install" button.
  5. The LinkedIn CV Generator UserScript should now be installed and ready to use.

Usage

  1. Go to a LinkedIn profile page.
  2. Click on the "More..." button on the top of the profile page.
  3. Click on the "Download PDF" button.
  4. Wait for a print dialog to appear.
  5. In the print dialog, select "Save as PDF" and click "Save".

For a better layout, set the margins to "None" in the print settings before saving the PDF file.

Known issues

  • The section titles will be in whatever language selected at the bottom of the page, regardless of the language used to insert the profile data.
  • Missing sections for other available section types on LinkedIn, such as certifications and other less-used types.
  • Location information is missing. This is a useful piece of information that is available but has not yet been scraped.
  • Some sections on LinkedIn are not well internationalized, such as the language sections, which could be problematic when exporting to different languages.
  • The job position information is hard to identify because the classes and IDs are not descriptive. Using regular expressions to hack this information may not work well, especially when trying to scrape CVs in non-Latin alphabet profiles.

Note: This project is for fun and a workaround solution. Accessing the LinkedIn API would be a more reliable way to access job position information, but it requires a LinkedIn Developer account, which in turn requires registering as a business entity, such as an LLC.

// ==UserScript==
// @name LinkedIn CV Generator
// @namespace http://tampermonkey.net/
// @version 3.0.0
// @description Extract and display CV data from LinkedIn profiles
// @author Saulo Dias
// @match https://www.linkedin.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=linkedin.com
// @grant none
// ==/UserScript==
/// <reference path="./cv-schema.d.ts" />
// Comement the import of the types in production.
/*
import {
CVSchema,
ContactInformation,
EducationProgram,
Language,
Skill,
Experience,
Position,
Project,
Address,
Score,
} from "./cv-schema";
*/
const DEVELOPMENT_MODE = false;
const PDF_ICON = `<svg viewBox="0 0 24 24" data-supported-dps="24x24" fill="currentColor" class="mercado-match" width="24" height="24" focusable="false" version="1.1" id="svg4" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <defs id="defs8"/> <path id="path2" d="M 5.2636719 3.0820312 C 3.6068193 3.0820312 2.2636719 4.4251787 2.2636719 6.0820312 L 2.2636719 18.082031 C 2.2636719 19.738884 3.6068193 21.082031 5.2636719 21.082031 L 17.263672 21.082031 C 18.920524 21.082031 20.263672 19.738884 20.263672 18.082031 L 20.263672 6.0820312 C 20.263672 4.4251787 18.920524 3.0820312 17.263672 3.0820312 L 5.2636719 3.0820312 z M 4.4707031 9.5625 L 6.2519531 9.5625 C 6.9055983 9.5625 7.3990889 9.7109378 7.7324219 10.007812 C 8.068359 10.302083 8.2363281 10.734376 8.2363281 11.304688 C 8.2363281 11.880208 8.068359 12.315104 7.7324219 12.609375 C 7.3990889 12.903646 6.9055983 13.050781 6.2519531 13.050781 L 5.2597656 13.050781 L 5.2597656 15.394531 L 4.4707031 15.394531 L 4.4707031 9.5625 z M 9.2949219 9.5625 L 10.916016 9.5625 C 12.046223 9.5625 12.875652 9.7981776 13.404297 10.269531 C 13.932942 10.738281 14.197266 11.472657 14.197266 12.472656 C 14.197266 13.477864 13.93164 14.216146 13.400391 14.6875 C 12.869141 15.158854 12.041014 15.394531 10.916016 15.394531 L 9.2949219 15.394531 L 9.2949219 9.5625 z M 15.455078 9.5625 L 18.806641 9.5625 L 18.806641 10.226562 L 16.244141 10.226562 L 16.244141 11.945312 L 18.556641 11.945312 L 18.556641 12.609375 L 16.244141 12.609375 L 16.244141 15.394531 L 15.455078 15.394531 L 15.455078 9.5625 z M 5.2597656 10.210938 L 5.2597656 12.402344 L 6.2519531 12.402344 C 6.6191403 12.402344 6.902995 12.307291 7.1035156 12.117188 C 7.3040363 11.927084 7.4042969 11.65625 7.4042969 11.304688 C 7.4042969 10.95573 7.3040363 10.686198 7.1035156 10.496094 C 6.902995 10.30599 6.6191403 10.210938 6.2519531 10.210938 L 5.2597656 10.210938 z M 10.083984 10.210938 L 10.083984 14.746094 L 11.037109 14.746094 C 11.841796 14.746094 12.430339 14.563802 12.802734 14.199219 C 13.177734 13.834636 13.365234 13.259114 13.365234 12.472656 C 13.365234 11.691407 13.177734 11.119791 12.802734 10.757812 C 12.430339 10.39323 11.841796 10.210938 11.037109 10.210938 L 10.083984 10.210938 z "/></svg>`;
const getElement = (selector, parentElement = document, timeout = 1000) => {
return new Promise((resolve, reject) => {
// Check if the element is already available
const element = parentElement.querySelector(selector);
if (element) {
// Element is available, resolve the Promise
resolve(element);
return;
}
// Element not found, wait and check again
const intervalId = setInterval(() => {
const element = parentElement.querySelector(selector);
if (element) {
clearInterval(intervalId);
resolve(element);
}
}, 100);
// Set a timeout to reject the Promise
setTimeout(() => {
clearInterval(intervalId);
reject(
new Error(
`getElement for selector "${selector}" timed out after ${timeout} ms`
)
);
}, timeout);
});
};
const filterOutChildren = (element, filterClasses) => {
const newElement = element.cloneNode(false);
const children = element.childNodes;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.nodeType === Node.ELEMENT_NODE) {
const newChild = filterOutChildren(child, filterClasses);
if (
!filterClasses.some((className) =>
newChild.classList.contains(className)
)
) {
newElement.appendChild(newChild);
}
} else {
newElement.appendChild(child.cloneNode(true));
}
}
return newElement;
};
/**
* @param {CVSchema} cvData
* @returns {string}
*/
const generateHtml = (cvData) => {
/**
* @param {EducationProgram[]} data
* @returns {string}
*/
const generateEducationHTML = (data) => {
let educationHTML = "";
data.forEach((education) => {
educationHTML += `
<li>
<h3>${education.institution}</h3>
<p class="education-school">${education.degree}</p>
<p class="education-date">(${education.startDate} - ${education.endDate})</p>
<p class="education-description">${education.description}</p>
</li>
`;
});
return educationHTML;
}
/**
* @param {Language[]} data
* @returns {string}
*/
const generateLanguagesHTML = (data) => {
let languagesHTML = "";
data.forEach((language) => {
languagesHTML += `
<li>
<span>${language.language} (${language.proficiency})</span>
</li>
`;
});
return languagesHTML;
}
/**
* @param {Skill[]} data
* @returns {string}
*/
function generateSkillsHTML(data) {
let skillsHTML = "";
data.forEach((skill) => {
skillsHTML += `
<li>
<div class="skill">
<span class="skill-name">${skill.skill}</span>
</div>
</li>
`;
});
return skillsHTML;
}
/**
* @param {Experience[]} data
* @returns {string}
*/
function generateExperienceHTML(data) {
let experienceHTML = "";
data.forEach((company, index) => {
experienceHTML += `
<div class="company ${index ? "print-no-break" : ""}">
<h3 class="company-name">${company.name}</h3>
<ul class="position-list">
`;
company.positions.forEach((position) => {
experienceHTML += `
<li>
<div class="job print-no-break">
<div class="job-title">${position.title}</div>
<div class="job-type">${position.type}</div>
<div class="job-location">${position.location}</div>
<div class="job-duration">${position.startDate} - ${position.endDate} (${position.duration})</div>
<div class="job-description">${position.description}</div>
<ul class="job-skills">
${
position.skills
?.map((skill) => `<li class="job-skill">${skill}</li>`)
.join("") || ""
}
</ul>
</div>
</li>
`;
});
experienceHTML += `
</ul>
</div>
`;
});
return experienceHTML;
}
function generateAboutHTML(data) {
return `
<div class="about">
<p>${data}</p>
</div>
`;
}
/**
* @param {ContactInformation} data
* @returns {string}
*/
function generateContactHTML(data) {
let websitesHTML = "";
data.websites.forEach((website) => {
websitesHTML += `
<li><a href="https://${website.url}" target="_blank">${website.url}</a> (${website.type})</li>
`;
});
return `
<ul>
<li><a href="https://${data.linkedin}" target="_blank">${data.linkedin}</a></li>
<li><a href="mailto:${data.email}">${data.email}</a></li>
${websitesHTML}
</ul>
`;
}
const bodyHtml = `
<body>
<!-- CV Template -->
<div class="cv">
<!-- Sidebar -->
<div class="sidebar">
<img class="profile-img" src="${cvData.imageData}" alt="Profile Picture">
<h1 class="name">${cvData.name}</h1>
<p class="title">${cvData.title}</p>
<div class="contact">
<h2>${cvData.translation.contact || ""}</h2>
<ul class="contact-list">
${generateContactHTML(cvData.contact)}
</ul>
</div>
<div class="languages">
<h2>${cvData.translation.languages || ""}</h2>
<ul class="language-list">
${generateLanguagesHTML(cvData.languages)}
</ul>
</div>
<div class="skills">
<h2>${cvData.translation.skills || ""}</h2>
<ul class="skills-list">
${generateSkillsHTML(cvData.skills)}
</ul>
</div>
</div>
<!-- Content -->
<div class="main">
<!-- About Me -->
<div class="section">
<h2>${cvData.translation.about || ""}</h2>
<p class="about-me">${cvData.about}</p>
</div>
<!-- Experience -->
<div class="section">
<h2>${cvData.translation.experience || ""}</h2>
<ul class="experience-list">
${generateExperienceHTML(cvData.experience)}
</ul>
</div>
<!-- Education -->
<div class="section">
<h2>${cvData.translation.education || ""}</h2>
<ul class="education-list">
${generateEducationHTML(cvData.education)}
</ul>
</div>
</div>
</div>
</body>
`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${cvData.name} - Custom CV</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
:root {
--primary-color: #003366;
--secondary-color: #e5e5e5;
--text-color: #333;
--border-color: #ddd;
--bg-color: #f5f5f5;
--main-bg-color: #fff;
--sidebar-bg-color: var(--primary-color);
--sidebar-text-color: #f2f2f2;
}
body {
background-color: var(--bg-color);
font-family: 'Roboto', sans-serif;
margin: 0;
padding: 0;
}
.cv {
background-color: var(--main-bg-color);
border-radius: 5px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: row;
max-width: 900px;
margin: 30px auto;
}
.cv .sidebar {
background-color: var(--sidebar-bg-color);
color: var(--sidebar-text-color);
display: flex;
flex-direction: column;
justify-content: start;
padding: 30px 10px;
text-align: center;
width: 27%;
}
.cv .sidebar .profile-img {
margin: 0 auto;
border: 5px solid white;
border-radius: 50%;
height: 150px;
margin-bottom: 20px;
width: 150px;
}
.cv .sidebar a {
color: var(--sidebar-text-color);
}
.cv .sidebar h1,
.cv .sidebar p {
margin: 0;
}
.cv .sidebar h1 {
font-size: 2em;
margin-bottom: 10px;
}
.cv .sidebar p {
font-size: 1.2em;
margin-bottom: 20px;
}
.cv .sidebar .contact {
margin-top: 40px;
}
.cv .sidebar .contact h2 {
margin-top: 0;
}
.cv .sidebar .contact ul {
list-style: none;
margin: 0;
padding: 0;
text-align: left;
}
.cv .sidebar .contact li {
margin-bottom: 5px;
}
.cv .main {
padding: 0 20px;
width: 73%;
}
.cv .main h2 {
font-size: 1.8em;
margin-top: 1rem;
margin-block: 0;
}
.cv .main h3 {
font-size: 1.4em;
margin-top: 0;
margin-bottom: 10px;
color: var(--text-color);
}
.cv .main ul {
list-style: none;
padding: 0;
text-align: left;
}
.cv .main li {
margin-bottom: 5px;
}
.job {
margin-bottom: 10px;
}
.job-type,
.job-location,
.job-duration {
font-size: 1em;
margin-bottom: 5px;
color: var(--text-color);
}
.job-duration {
font-weight: bold;
}
ul {
page-break-inside: always;
}
.section {
margin-top: 2rem;
}
.section h2 {
font-size: 1.8em;
color: var(--primary-color);
}
.about-me {
font-size: 1rem;
line-height: 1.5;
margin: 0;
color: var(--text-color);
margin-top: 1rem;
}
/* Header */
.header {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background-color: var(--primary-color);
color: #fff;
}
.name {
font-size: 2.5rem;
margin-bottom: 10px;
}
.title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 10px;
}
/* Education */
.education-list h3 {
font-size: 1.5rem;
margin-bottom: 5px;
color: var(--primary-color);
}
.education-list p {
margin: 0;
font-size: 1rem;
}
/* Skills */
/* Languages */
.language-list,
.skills-list {
list-style: none;
margin: 0;
padding: 0;
}
.language-list li,
.skills-list li {
text-align: left;
border-radius: 20px;
font-size: 0.9rem;
font-weight: bold;
padding: 5px 0;
text-transform: uppercase;
}
/* Experience */
.company {
padding-top: 1rem;
}
ul.experience-list {
margin-block: 0;
}
.experience-list li,
.education-list p {
padding-left: 10px;
}
.job-title {
font-size: 1rem;
font-weight: bold;
margin-bottom: 5px;
color: var(--primary-color);
}
.education-school {
margin-bottom: 5px;
font-size: 1.2em;
font-weight: bold;
color: var(--primary-color);
}
.education-date {
margin-bottom: 5px;
font-size: 1em;
color: var(--text-color);
}
.education-description {
margin-bottom: 10px;
font-size: 1em;
color: var(--text-color);
line-height: 1.5;
}
.job-type,
.job-location,
.job-duration {
margin-bottom: 5px;
font-size: 1rem;
color: var(--text-color);
}
.job-description {
margin-top: 5px;
font-size: 1rem;
color: var(--text-color);
}
.job-skills {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
margin-bottom: 0;
}
.job-skill {
padding: 3px 8px;
margin-right: 3px;
margin-bottom: 5px;
border-radius: 20px;
background-color: var(--secondary-color);
color: var(--text-color);
font-size: 0.6rem;
font-weight: bold;
text-transform: uppercase;
}
@media print {
.cv .sidebar {
display: flex;
}
/* Remove box-shadow */
.cv {
box-shadow: none;
}
body {
box-shadow: none;
margin: 0;
}
.cv {
border-radius: 0;
box-shadow: none;
margin: 0;
}
.sidebar {
display: block !important;
}
.header {
display: none;
}
.print-no-break {
page-break-inside: avoid;
}
}
</style>
</head>
${bodyHtml}
</html>
`;
return html;
}
function addUrlObserver(func) {
const observer = new MutationObserver(() => {
if (window.location.href !== observer.currentUrl) {
observer.currentUrl = window.location.href;
func();
}
});
observer.currentUrl = window.location.href;
observer.observe(document.body, { childList: true, subtree: true });
}
function createButton(text, onClick, icon) {
const button = document.createElement("div");
button.classList.add(
"display-flex",
"align-items-center",
"full-width",
"artdeco-dropdown__item",
"artdeco-dropdown__item--is-dropdown",
"ember-view"
);
button.setAttribute("role", "button");
button.setAttribute("id", `${text.toLowerCase().replace(/\s/g, "-")}-button`);
button.setAttribute("tabindex", "0");
button.innerHTML = `
<li-icon aria-hidden="true" type="document-verified" class="mr3 flex-grow-0" size="large">
${icon}
</li-icon>
<span class="display-flex t-normal flex-1" aria-hidden="true">${text}</span>
<span class="a11y-text" aria-live="off">${text}</span>
`;
button.addEventListener("click", onClick);
return button;
}
async function addCustomButtons(pdfOnClick, markdownOnClick, jsonOnClick) {
let buttonExists = true;
try {
await getElement("#download-pdf-button", document, 2000);
} catch {
buttonExists = false;
}
if (buttonExists) return;
const moreButtonElements = document.querySelectorAll(
".pvs-profile-actions__action"
);
if (!moreButtonElements.length) return;
const actionsDropdownElem =
moreButtonElements[moreButtonElements.length - 1].nextElementSibling;
const buttonsList = actionsDropdownElem.querySelector("ul");
const pdfButton = createButton("Download PDF", pdfOnClick, PDF_ICON);
buttonsList.appendChild(pdfButton);
const markdownButton = createButton(
"Download Markdown",
markdownOnClick,
"MD"
);
buttonsList.appendChild(markdownButton);
const jsonButton = createButton("Download JSON", jsonOnClick, "JSON");
buttonsList.appendChild(jsonButton);
}
/**
* @param {CVSchema} data
* @returns {string}
*/
function generateMarkdown(data) {
let markdown = `# ${data.name}\n\n`;
markdown += data.imageData
? `!<img src="${data.imageData}" alt="Profile Picture" width="200" height="200">
\n\n`
: "";
markdown += data.title ? `## ${data.title}\n\n` : "";
if (data.contact) {
markdown += data.translation.contact
? `### ${data.translation.contact}\n\n`
: "";
markdown += data.contact.email
? `- E-mail: [${data.contact.email}](mailto:${data.contact.email})\n`
: "";
if (data.contact.websites) {
markdown += `- Websites:\n`;
for (const website of data.contact.websites) {
markdown +=
website.url && website.type
? ` - ${website.type}: [${website.url}](https://${website.url})\n`
: "";
}
}
}
if (data.about) {
markdown += data.translation.about
? `\n### ${data.translation.about}\n\n`
: "";
markdown += `${data.about}\n`;
}
if (data.education) {
markdown += data.translation.education
? `### ${data.translation.education}\n\n`
: "";
for (const education of data.education) {
markdown += `#### ${education.degree}\n`;
markdown += education.institution ? `##### ${education.institution}` : "";
markdown += education.startDate ? ` (${education.startDate}` : "\n\n";
markdown += education.endDate ? ` - ${education.endDate})\n\n` : ")\n\n";
markdown += education.description ? `${education.description}\n\n` : "";
}
}
if (data.languages) {
markdown += data.translation.languages
? `### ${data.translation.languages}\n\n`
: "";
for (const language of data.languages) {
markdown +=
language.language && language.proficiency
? `- ${language.language}: ${language.proficiency}\n`
: "";
}
}
if (data.skills) {
markdown += data.translation.skills
? `\n### ${data.translation.skills}\n\n`
: "";
for (const skill of data.skills) {
markdown += skill.skill ? `- ${skill.skill}\n` : "";
}
}
if (data.experience) {
markdown += data.translation.experience
? `\n### ${data.translation.experience}\n\n`
: "";
for (const experience of data.experience) {
markdown += `#### ${experience.name}\n`;
for (const position of experience.positions) {
markdown += `\n##### ${position.title}`;
markdown += position.duration ? ` (${position.duration})` : "";
markdown += "\n";
markdown += position.jobType ? `- ${position.type}\n` : "";
markdown +=
position.locationType && position.location
? `- ${position.locationType}: ${position.location}\n`
: "";
markdown +=
position.startDate && position.endDate
? `- ${position.startDate} - ${position.endDate}\n`
: "";
markdown += position.description ? `- ${position.description}\n` : "";
markdown +=
position.skills && position.skills.length > 0
? `> ${data.translation.skills}: ${position.skills.join(", ")}\n`
: "";
}
}
}
return markdown;
}
const downloadMarkdown = (data) => {
const markdown = generateMarkdown(data);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "cv.md";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const printAsPDF = (result) => {
const html = generateHtml(result);
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
// Create a new iframe to load the HTML blob
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = url;
// Wait for the iframe to finish loading the HTML
iframe.onload = () => {
// Set the document of the iframe to the blob's HTML
iframe.contentWindow.document.open();
iframe.contentWindow.document.write(html);
iframe.contentWindow.document.close();
if (DEVELOPMENT_MODE) {
// Open the HTML in a separate window for debugging
window.open(url, "_blank");
} else {
// Use the iframe's window.print() method to open the print dialog
iframe.contentWindow.print();
}
};
// Append the iframe to the document to load the HTML blob
document.body.appendChild(iframe);
};
const downloadJson = (content, filename = "cv") => {
const blob = new Blob([JSON.stringify(content, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const imageUrlToBase64 = async (imageUrl) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = this.width;
canvas.height = this.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(this, 0, 0);
const dataURL = canvas.toDataURL("image/jpeg");
resolve(dataURL);
};
img.onerror = function () {
reject(new Error("Failed to load image"));
};
img.src = imageUrl;
});
};
let translation = {};
const jobTypeRegex =
/(Full(-|\s*)time|Part(-|\s*)time|Temps\s*plein|Temps\s*partiel|Tempo\s*integral|Meio\s*per[ií]odo|Aut[oô]nomo|Freelance|Est[aá]gio|Aprendiz|Trainee|Terceirizado|Temporal\/Contrato|Trabalho tempor[aá]rio|Est[aá]gio remunerado|Stage rémunéré|Trabalho a tempo parcial|Praktikum|Lehre)/gi;
const jobLocationRegex =
/[\p{L}\p{M}][\p{L}\p{M}' .-]*(?:,[ ]*[\p{L}\p{M}' .-]+)+/u;
const jobLocationTypeRegex =
/(Presencial|H[ií]brida|Remota|In-person|Hybrid|Remote|Présentiel|Hybride|À distance|Presencialmente|Híbrido|Remoto|Präsenz|Hybrid|Remote|En personne|Mixte|À distance|Em pessoa|Híbrido|Remoto|On-site)/gi;
const durationRegex =
/\b(?:\d{1,2} )?(?:(?:ene|feb|mar|abr|may|jun|jul|ago|sep|oct|nov|dic|janv|fév|mars|avr|mai|juin|juil|août|sept|oct|nov|déc|jan|feb|märz|apr|mai|jun|jul|aug|sep|okt|nov|dez|janv|févr|mars|avr|juin|juil|août|sept|oct|déc|jan|feb)\b(?: \d{4})? - )?(?:actualidad|hoy|\d{1,2} (?:meses|mes|m|años|año|an|years|year|yr))/i;
// Helper functions
const getTextFromSelector = async (selector, parentElement) => {
let element = null;
try {
element = await getElement(selector, parentElement);
} catch (err) {
console.error(err);
}
return element ? element.textContent.trim() : "";
};
const setTranslation = (key, value) => {
translation[key] = value;
};
const hideOverlay = async () => {
const modalOverlay = await getElement(".artdeco-modal-overlay");
modalOverlay.classList.toggle("visually-hidden", true);
};
const extractCompanyName = (item) => {
const companyNameElement = item.querySelector(
".pvs-entity__path-node + .pvs-entity > a span"
);
const companyNameFallbackElement = item.querySelector(
".display-flex.flex-wrap.align-items-center.full-height span.hoverable-link-text.t-bold"
);
const companyNameFallbackSingleJob = item.querySelector(
".t-14.t-normal > span"
);
if (companyNameElement) {
return companyNameElement.textContent.trim();
} else if (companyNameFallbackElement) {
return companyNameFallbackElement.textContent.trim();
} else if (companyNameFallbackSingleJob) {
return companyNameFallbackSingleJob.textContent.trim().split("·")[0].trim();
}
return "";
};
const getContentContainer = async (id) => {
let anchor = null;
try {
anchor = await getElement(`.pv-profile-card__anchor[id="${id}"]`);
} catch (err) {
console.error(err);
}
if (!anchor) return null;
const header = filterOutChildren(
anchor.nextElementSibling.querySelector(".pvs-header__title"),
["visually-hidden"]
);
setTranslation(id, header.textContent.trim());
return filterOutChildren(anchor.nextElementSibling.nextElementSibling, [
"visually-hidden",
"inline-show-more-text__button",
]);
};
const getSectionList = async (content) => {
const listOuterContainer = await getContentContainer(content);
if (!listOuterContainer) return [];
return listOuterContainer.querySelectorAll(
".pvs-list__outer-container > .pvs-list > li.pvs-list__item--line-separated"
);
};
/**
* @returns {Promise<{ imageData: CVSchema["imageData"]; name: CVSchema["name"]; title: CVSchema["title"]; }>}
*/
const extractUserProfile = async () => {
const profileName = await getTextFromSelector(".text-heading-xlarge");
const profilePhotoButton = await getElement(
`.pv-top-card-profile-picture, .profile-photo-edit__edit-btn`
);
profilePhotoButton.click();
await hideOverlay();
const profilePhoto = await getElement(
`.imgedit-profile-photo-frame-viewer__image-container img, .pv-member-photo-modal__content img`
);
let base64Image = "";
try {
base64Image = await imageUrlToBase64(profilePhoto?.getAttribute("src"));
} catch (err) {
console.log(err);
}
document.querySelector(".artdeco-modal__dismiss").click();
return {
name: profileName,
title: await getTextFromSelector(".text-body-medium"),
imageData: base64Image,
};
};
/**
* @returns {Promise<Language[]>}
*/
const extractLanguages = async () => {
const languagesList = await getSectionList("languages");
/**
* @type {Language[]}
*/
const languages = [];
for (const languageElement of languagesList) {
const [languageName, proficiency] = await Promise.all([
getTextFromSelector(".mr1.t-bold > span", languageElement),
getTextFromSelector(".t-14.t-normal.t-black--light", languageElement),
]);
languages.push({
language: languageName,
proficiency: proficiency,
});
}
return languages;
};
/**
* @returns {Promise<EducationProgram[]>}
*/
const extractEducation = async () => {
const educationItems = await getSectionList("education");
/**
* @type {EducationProgram[]}
*/
const education = [];
for (const item of educationItems) {
const institution = await getTextFromSelector(
".hoverable-link-text.t-bold > span",
item
);
const [degree, date, description] = await Promise.all([
getTextFromSelector(".t-14.t-normal", item),
getTextFromSelector(".t-14.t-normal.t-black--light", item),
getTextFromSelector(".inline-show-more-text", item, 500),
]);
const [startDate, endDate] = date.split(" - ");
education.push({
institution,
degree,
startDate,
endDate,
description,
});
}
return education;
};
/**
* @returns {Promise<Skill[]>}
*/
const extractSkills = async () => {
const skillsItems = await getSectionList("skills");
/**
* @type {Skill[]}
*/
const skills = [];
skillsItems.forEach(async (skillElement) => {
const skillName = await getTextFromSelector(
".mr1.t-bold > span",
skillElement
);
skills.push({
skill: skillName,
});
});
return skills;
};
/**
* @returns {Promise<Position>}
*/
const processPosition = async (text) => {
/**
* @type {Position}
*/
const position = {
title: "",
type: "",
location: "",
duration: "",
startDate: "",
endDate: "",
description: "",
locationType: "",
};
const tokens = text;
// Identifica a localidade e modalidade do trabalho, se disponível
const locationType = tokens.find((t) => t?.match(jobLocationTypeRegex));
if (locationType) {
const [jobLocation, jobLocationType] = locationType.trim().split(" · ");
position.locationType = jobLocationType;
position.location = jobLocation;
}
// Se a abordagem anterior não funcionar
const location = tokens.find(
(t) => t?.length < 50 && t?.match(jobLocationRegex)
);
if (!position.location && location) {
position.location = location;
}
// Identifica a duração do trabalho, se disponível
const durationMatch = tokens.find((t) => t?.match(durationRegex));
if (durationMatch) {
const [startAndEnd, duration] = durationMatch.split(" · ");
const [startDate, endDate] = startAndEnd.split(" - ");
position.duration = duration;
position.startDate = startDate;
position.endDate = endDate;
}
position.title = tokens[0];
const hasSkillsRegex = /^[^\s:]+:/;
const hasSkills = tokens.find((t) => t?.match(hasSkillsRegex));
const offset = hasSkills ? 2 : 1;
const description = tokens[tokens.length - offset];
if (
(!description.includes(position.location) || !position.location) &&
(!description.includes(durationMatch) || !durationMatch)
) {
position.description = description;
}
if (hasSkills) {
position["skills"] = tokens[tokens.length - 1]
.split(":")[1]
.trim()
.split(" · ");
}
// Verifica se o job type tem o nome da empresa também
const type = tokens.find((t) => t?.match(jobTypeRegex));
if (type?.includes(" · ")) {
const [companyName, jType] = type.split(" · ");
position.type = jType;
position["companyName"] = companyName;
} else if (type) {
position.type = type;
}
return position;
};
/**
* @returns {Experience[]}
*/
const extractExperience = async () => {
const experienceItems = await getSectionList("experience");
/**
* @returns {Experience[]}
*/
const experience = [];
for (const item of experienceItems) {
const companyName = extractCompanyName(item);
// Select the first element with class 'li-icon'
const icon = item.querySelector("li-icon");
if (icon) {
icon.parentNode.remove();
}
const positions = [];
let positionItems = item.querySelectorAll(
".pvs-entity__path-node + .pvs-entity"
);
if (!positionItems.length) {
positionItems = [item.querySelector(".pvs-entity")];
}
for (const positionItem of positionItems) {
const newLinesAndSpacesRegex = /(?:\n\s*){2,}(?!\n)/g;
const textContents = positionItem.textContent.replace(
newLinesAndSpacesRegex,
"\n"
);
const position = await processPosition(textContents.trim().split("\n"));
positions.push(position);
}
/**
* @returns {Experience}
*/
const exp = {
name: companyName || positions[0]?.companyName || "",
positions,
};
experience.push(exp);
}
return experience;
};
/**
* @returns {Promise<CVSchema["about"]>}
*/
const extractAbout = async () => {
const about = await getContentContainer("about");
return about ? about.textContent.trim() : "";
};
/**
* @returns {Promise<ContactInformation>}
*/
const extractContact = async () => {
const contactInfoButton = await getElement(
"#top-card-text-details-contact-info"
);
contactInfoButton.click();
await hideOverlay();
const contact = await getElement(".pv-profile-section__section-info");
setTranslation(
"contact",
document.querySelector(".pv-profile-section h2").textContent.trim()
);
document.querySelector(".artdeco-modal__dismiss").click();
// Extract contact information using DOM API
const linkedinElement = contact.querySelector(".ci-vanity-url a");
const websiteElements = contact.querySelectorAll(".ci-websites li");
const emailElement = contact.querySelector(".ci-email a");
const birthdayElement = contact.querySelector(".ci-birthday span");
/**
* @type {ContactInformation["websites"]}
*/
const websites = [];
if (linkedinElement) {
websites.push({
url: linkedinElement.textContent.trim(),
type: "LinkedIn",
});
}
websiteElements.forEach((websiteElement) => {
const website = {};
website.url = websiteElement.querySelector("a")
? websiteElement.querySelector("a").textContent.trim()
: "";
website.type = websiteElement.querySelector("span")
? websiteElement
.querySelector("span")
.textContent.replace(/[\(\)]/g, "")
.trim()
: "";
websites.push(website);
});
/**
* @returns {ContactInformation}
*/
const contactObject = {
email: emailElement ? emailElement.textContent.trim() : "",
websites,
};
return contactObject;
};
/**
* @returns {CVSchema}
*/
const extractData = async () => {
const [userProfile, education, languages, skills, experience, about] =
await Promise.all([
extractUserProfile(),
extractEducation(),
extractLanguages(),
extractSkills(),
extractExperience(),
extractAbout(),
]);
const contact = await extractContact();
const {name, title, imageData} = userProfile;
return {
name,
title,
contact,
education,
languages,
skills,
experience,
about,
imageData,
translation,
};
};
(function () {
"use strict";
const handleClick = async (callback) => {
const result = await extractData();
callback(result);
};
const addButtons = async () => {
await addCustomButtons(
() => handleClick(printAsPDF),
() => handleClick(downloadMarkdown),
() => handleClick(downloadJson)
);
};
setTimeout(addButtons, 2000);
addUrlObserver(addButtons);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment