diff --git a/.eslintrc.json b/.eslintrc.json index 66f5436..f5943a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,6 +8,8 @@ "browser": true }, "rules": { + "no-self-assign": "off", + "no-param-reassign": "off", "no-underscore-dangle": "off", "no-plusplus": "off", "no-useless-escape": "off", diff --git a/LinkedIn-Notes.md b/LinkedIn-Notes.md index 078591f..c144618 100644 --- a/LinkedIn-Notes.md +++ b/LinkedIn-Notes.md @@ -5,7 +5,9 @@ Back to main README: [click here](./README.md) - V2 Docs: - https://docs.microsoft.com/en-us/linkedin/ - https://developer.linkedin.com/docs/guide/v2 - - Another project that uses the unofficial Voyager API: [tomquirk/linkedin-api](https://github.com/tomquirk/linkedin-api) + - Other projects that use the unofficial Voyager API: + - [tomquirk/linkedin-api](https://github.com/tomquirk/linkedin-api) + - [eilonmore/linkedin-private-api](https://github.com/eilonmore/linkedin-private-api) - LinkedIn DataHub (this powers a lot of the backend) - [Blog Post](https://engineering.linkedin.com/blog/2019/data-hub) - [Github Repo](https://github.com/linkedin/datahub) @@ -76,5 +78,32 @@ Here are some quick notes on Voyager responses and how data is grouped / nested: - LI has limits on certain endpoints, and the amount of nested elements it will return - See [PR #23](https://github.com/joshuatz/linkedin-to-jsonresume/pull/23) for an example of how this was implemented +### Voyager - Misc Notes + - Make sure you always include the `Host` header if making requests outside a web browser (browsers will automatically include this for you) + - Value should be: `www.linkedin.com` + - If you forget it, you will get 400 error (`invalid hostname`) + - For inline data, `` with request payload usually ***follows*** `` with *response* payload + - It appears as though whatever language the profile was ***first*** created with sticks as the "principal language", regardless if user changes language settings (more on this below). + - You can find this under the main profile object, where you would find `supportedLocales` - the default / initial locale is under - `defaultLocale` + +### Voyager - Multilingual and Locales Support +> LI seems to be making changes related to this; this section might not be 100% up-to-date. + +There are some really strange quirks around multi-locale profiles. When a multi-locale user is logged in and requesting *their own* profile, LI will *refuse* to let the `x-li-lang` header override the `defaultLocale` as specified by the profile (see [issue #35](https://github.com/joshuatz/linkedin-to-jsonresume/issues/35)). However, if *someone else* exports their profile, the same exact endpoints will respect the header and will return the correct data for the requested locale (assuming creator made a version of their profile with the requested locale). + +Even stranger, this quirk only seems to apply to *certain* endpoints; e.g. `/me` respects the requested language, but `/profileView` does not (and *always* returns data corresponding with `defaultLocale`) 🙃 + +Furthermore, the `/dash` subset of endpoints does not ever (AFAIK) change the main key-value pairs based on `x-li-lang`; instead, it nests multi-locale data under `multiLocale` prefixed keys. For example: + +```json +{ + "firstName": "Алексе́й", + "multiLocaleFirstName": { + "ru_RU": "Алексе́й", + "en_US": "Alexey" + } +} +``` + ## LinkedIn TS Types -I've put some basics LI types in my `global.d.ts`. Eventually, it would be nice to re-write the core of this project as TS, as opposed to the current VSCode-powered typed JS approached. \ No newline at end of file +I've put some basics LI types in my `global.d.ts`. Eventually, it would be nice to re-write the core of this project as TS, as opposed to the current VSCode-powered typed JS approached. diff --git a/README.md b/README.md index 6788feb..c03c210 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # LinkedIn Profile to JSON Resume Browser Tool ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/joshuatz/linkedin-to-jsonresume) +> An extremely easy-to-use browser extension for exporting your full LinkedIn Profile to a JSON Resume file or string. + ## Chrome Extension 📦 - [Webstore Link](https://chrome.google.com/webstore/detail/json-resume-exporter/caobgmmcpklomkcckaenhjlokpmfbdec) -## My LinkedIn Profile 👨‍💼 - [https://www.linkedin.com/in/joshuatzucker/](https://www.linkedin.com/in/joshuatzucker/) +## My LinkedIn Profile 👨‍💼 - [linkedin.com/in/joshuatzucker/](https://www.linkedin.com/in/joshuatzucker/) ![Demo GIF](demo-chrome_extension.gif "Demo Gif") diff --git a/browser-ext/popup.js b/browser-ext/popup.js index 64f4d80..3caa4f7 100644 --- a/browser-ext/popup.js +++ b/browser-ext/popup.js @@ -12,6 +12,8 @@ const STORAGE_KEYS = { const SPEC_SELECT = /** @type {HTMLSelectElement} */ (document.getElementById('specSelect')); /** @type {SchemaVersion[]} */ const SPEC_OPTIONS = ['beta', 'stable', 'latest']; +/** @type {HTMLSelectElement} */ +const LANG_SELECT = document.querySelector('.langSelect'); /** * Generate injectable code for capturing a value from the contentScript scope and passing back via message @@ -41,13 +43,20 @@ const getLangStringsCode = `(async () => { })(); `; +/** + * Get the currently selected lang locale in the selector + */ +const getSelectedLang = () => { + return LANG_SELECT.value; +}; + /** * Get JS string that can be eval'ed to get the program to run and show output * Note: Be careful of strings versus vars, escaping, etc. * @param {SchemaVersion} version */ const getRunAndShowCode = (version) => { - return `liToJrInstance.parseAndShowOutput('${version}');`; + return `liToJrInstance.preferLocale = '${getSelectedLang()}';liToJrInstance.parseAndShowOutput('${version}');`; }; /** @@ -66,14 +75,12 @@ const toggleEnabled = (isEnabled) => { * @param {string[]} langs */ const loadLangs = (langs) => { - /** @type {HTMLSelectElement} */ - const selectElem = document.querySelector('.langSelect'); - selectElem.innerHTML = ''; + LANG_SELECT.innerHTML = ''; langs.forEach((lang) => { const option = document.createElement('option'); option.value = lang; option.innerText = lang; - selectElem.appendChild(option); + LANG_SELECT.appendChild(option); }); toggleEnabled(langs.length > 0); }; @@ -171,13 +178,12 @@ document.getElementById('liToJsonButton').addEventListener('click', async () => document.getElementById('liToJsonDownloadButton').addEventListener('click', () => { chrome.tabs.executeScript({ - code: `liToJrInstance.parseAndDownload();` + code: `liToJrInstance.preferLocale = '${getSelectedLang()}';liToJrInstance.parseAndDownload();` }); }); -document.getElementById('langSelect').addEventListener('change', (evt) => { - const updatedLang = /** @type {HTMLSelectElement} */ (evt.target).value; - setLang(updatedLang); +LANG_SELECT.addEventListener('change', () => { + setLang(getSelectedLang()); }); document.getElementById('vcardExportButton').addEventListener('click', () => { diff --git a/global.d.ts b/global.d.ts index e23d81e..da62524 100644 --- a/global.d.ts +++ b/global.d.ts @@ -37,6 +37,7 @@ declare global { interface LiEntity { $type: string; entityUrn: LiUrn; + objectUrn?: LiUrn; [key: string]: any; paging?: LiPaging; } @@ -82,9 +83,9 @@ declare global { // Methods getElementKeys: () => string[]; getElements: () => Array; - getValueByKey: (key: string) => LiEntity; - getValuesByKey: (key: LiUrn, optTocValModifier?: TocValModifier) => LiEntity[]; - getElementsByType: (typeStr: string) => LiEntity[]; + getValueByKey: (key: string | string[]) => LiEntity; + getValuesByKey: (key: LiUrn | LiUrn[], optTocValModifier?: TocValModifier) => LiEntity[]; + getElementsByType: (typeStr: string | string []) => LiEntity[]; getElementByUrn: (urn: string) => LiEntity | undefined; /** * Get multiple elements by URNs @@ -116,7 +117,9 @@ declare global { type CaptureResult = 'success' | 'fail' | 'incomplete' | 'empty'; interface ParseProfileSchemaResultSummary { - profileObj: LiResponse; + liResponse: LiResponse; + profileInfoObj?: LiEntity; + profileSrc: 'profileView' | 'dashFullProfileWithEntities'; pageUrl: string; localeStr?: string; parseSuccess: boolean; diff --git a/src/main.js b/src/main.js index fd64cef..6d782f8 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ const VCardsJS = require('@dan/vcards'); const { resumeJsonTemplateLatest, resumeJsonTemplateStable, resumeJsonTemplateBetaPartial } = require('./templates'); +const { liSchemaKeys: _liSchemaKeys, liTypeMappings: _liTypeMappings } = require('./schema'); // ==Bookmarklet== // @name linkedin-to-jsonresume-bookmarklet @@ -122,19 +123,10 @@ window.LinkedinToResumeJson = (() => { let _outputJsonLatest = JSON.parse(JSON.stringify(resumeJsonTemplateLatest)); /** @type {ResumeSchemaBeyondSpec} */ let _outputJsonBetaPartial = JSON.parse(JSON.stringify(resumeJsonTemplateBetaPartial)); - const _liSchemaKeys = { - profile: '*profile', - certificates: '*certificationView', - education: '*educationView', - workPositions: '*positionView', - workPositionGroups: '*positionGroupView', - skills: '*skillView', - projects: '*projectView', - attachments: '*summaryTreasuryMedias', - volunteerWork: '*volunteerExperienceView', - awards: '*honorView', - publications: '*publicationView' - }; + /** @type {string[]} */ + let _supportedLocales = []; + /** @type {string} */ + let _defaultLocale = `en_US`; const _voyagerBase = 'https://www.linkedin.com/voyager/api'; const _voyagerEndpoints = { following: '/identity/profiles/{profileId}/following', @@ -290,11 +282,12 @@ window.LinkedinToResumeJson = (() => { }; /** * Get all elements that match type. Should usually just be one - * @param {string} typeStr - Type, e.g. `$com.linkedin...` + * @param {string | string[]} typeStr - Type, e.g. `$com.linkedin...` * @returns {LiEntity[]} */ db.getElementsByType = function getElementByType(typeStr) { - return db.entities.filter((entity) => entity['$type'] === typeStr); + const typeStrArr = Array.isArray(typeStr) ? typeStr : [typeStr]; + return db.entities.filter((entity) => typeStrArr.indexOf(entity['$type']) !== -1); }; /** * Get an element by URN @@ -307,12 +300,31 @@ window.LinkedinToResumeJson = (() => { db.getElementsByUrns = function getElementsByUrns(urns) { return urns.map((urn) => db.entitiesByUrn[urn]); }; + // Only meant for 1:1 lookups; will return first match, if more than one + // key provided. Usually returns a "view" (kind of a collection) db.getValueByKey = function getValueByKey(key) { - return db.entitiesByUrn[db.tableOfContents[key]]; + const keyArr = Array.isArray(key) ? key : [key]; + for (let x = 0; x < keyArr.length; x++) { + const foundVal = db.entitiesByUrn[db.tableOfContents[keyArr[x]]]; + if (foundVal) { + return foundVal; + } + } + return undefined; }; // This, opposed to getValuesByKey, allow for multi-depth traversal + /** + * @type {InternalDb['getValuesByKey']} + */ db.getValuesByKey = function getValuesByKey(key, optTocValModifier) { const values = []; + if (Array.isArray(key)) { + return [].concat( + ...key.map((k) => { + return this.getValuesByKey(k, optTocValModifier); + }) + ); + } let tocVal = this.tableOfContents[key]; if (typeof optTocValModifier === 'function') { tocVal = optTocValModifier(tocVal); @@ -349,6 +361,57 @@ window.LinkedinToResumeJson = (() => { return db; } + /** + * "Remaps" data that is nested under a multilingual wrapper, hoisting it + * back up to top-level keys (overwriting existing values). + * + * WARNING: Modifies object IN PLACE + * @example + * ```js + * const input = { + * firstName: 'Алексе́й', + * multiLocaleFirstName: { + * ru_RU: 'Алексе́й', + * en_US: 'Alexey' + * } + * } + * console.log(remapNestedLocale(input, 'en_US').firstName); + * // 'Alexey' + * ``` + * @param {LiEntity | LiEntity[]} liObject The LI response object(s) to remap + * @param {string} desiredLocale Desired Locale string (LI format / ISO-3166-1). Defaults to instance property + * @param {boolean} [deep] Run remapper recursively, replacing at all applicable levels + */ + function remapNestedLocale(liObject, desiredLocale, deep = true) { + if (Array.isArray(liObject)) { + liObject.forEach((o) => { + remapNestedLocale(o, desiredLocale, deep); + }); + } else { + Object.keys(liObject).forEach((prop) => { + const nestedVal = liObject[prop]; + if (!!nestedVal && typeof nestedVal === 'object') { + // Test for locale wrapped property + // example: `multiLocaleFirstName` + if (prop.startsWith('multiLocale')) { + /** @type {Record} */ + const localeMap = nestedVal; + // eslint-disable-next-line no-prototype-builtins + if (localeMap.hasOwnProperty(desiredLocale)) { + // Transform multiLocaleFirstName to firstName + const nonPrefixedKeyPascalCase = prop.replace(/multiLocale/i, ''); + const nonPrefixedKeyLowerCamelCase = nonPrefixedKeyPascalCase.charAt(0).toLocaleLowerCase() + nonPrefixedKeyPascalCase.substring(1); + // Remap nested value to top level + liObject[nonPrefixedKeyLowerCamelCase] = localeMap[desiredLocale]; + } + } else if (deep) { + remapNestedLocale(liObject[prop], desiredLocale, deep); + } + } + }); + } + } + /** * Gets the profile ID from embedded (or api returned) Li JSON Schema * @param {LiResponse} jsonSchema @@ -420,11 +483,9 @@ window.LinkedinToResumeJson = (() => { const start = timePeriod.startDate || timePeriod.start; const end = timePeriod.endDate || timePeriod.end; if (end) { - // eslint-disable-next-line no-param-reassign resumeObj.endDate = parseDate(end); } if (start) { - // eslint-disable-next-line no-param-reassign resumeObj.startDate = parseDate(start); } } @@ -506,15 +567,19 @@ window.LinkedinToResumeJson = (() => { /** * Main parser for giant profile JSON block * @param {LinkedinToResumeJson} instance - * @param {LiResponse} profileObj + * @param {LiResponse} liResponse + * @param {ParseProfileSchemaResultSummary['profileSrc']} [endpoint] + * @returns {Promise} */ - async function parseProfileSchemaJSON(instance, profileObj) { + async function parseProfileSchemaJSON(instance, liResponse, endpoint = 'profileView') { const _this = instance; + const dash = endpoint === 'dashFullProfileWithEntities'; let foundGithub = false; const foundPortfolio = false; /** @type {ParseProfileSchemaResultSummary} */ const resultSummary = { - profileObj, + liResponse, + profileSrc: endpoint, pageUrl: null, parseSuccess: false, sections: { @@ -535,21 +600,55 @@ window.LinkedinToResumeJson = (() => { } try { // Build db object - const db = buildDbFromLiSchema(profileObj); + let db = buildDbFromLiSchema(liResponse); + + if (dash && !liResponse.data.hoisted) { + // For FullProfileWithEntities, the main entry point of response + // (response.data) points directly to the profile object, by URN + // This profile obj itself holds the ToC to its content, instead + // of having the ToC in the res.data section (like profileView) + const profileObj = db.getElementByUrn(db.tableOfContents['*elements'][0]); + if (!profileObj || !profileObj.firstName) { + throw new Error('Could not extract nested profile object from Dash endpoint'); + } + // To make this easier to work with lookup, we'll unpack the + // profile view nested object BACK into the root (ToC), so + // that lookups can be performed by key instead of type | recipe + /** @type {LiResponse} */ + const hoistedRes = { + data: { + ...liResponse.data, + ...profileObj, + // Set flag for future + hoisted: true + }, + included: liResponse.included + }; + resultSummary.liResponse = hoistedRes; + db = buildDbFromLiSchema(hoistedRes); + } // Parse basics / profile let profileGrabbed = false; - db.getValuesByKey(_liSchemaKeys.profile).forEach((profile) => { + const profileObjs = dash ? [db.getElementByUrn(db.tableOfContents['*elements'][0])] : db.getValuesByKey(_liSchemaKeys.profile); + instance.debugConsole.log({ profileObjs }); + profileObjs.forEach((profile) => { // There should only be one if (!profileGrabbed) { profileGrabbed = true; + resultSummary.profileInfoObj = profile; + /** + * What the heck LI, this seems *intentionally* misleading + * @type {LiSupportedLocale} + */ + const localeObject = !dash ? profile.defaultLocale : profile.primaryLocale; /** @type {ResumeSchemaStable['basics']} */ const formattedProfileObj = { name: `${profile.firstName} ${profile.lastName}`, summary: noNullOrUndef(profile.summary), label: noNullOrUndef(profile.headline), location: { - countryCode: profile.defaultLocale.country + countryCode: localeObject.country } }; if (profile.address) { @@ -567,20 +666,25 @@ window.LinkedinToResumeJson = (() => { }; /** @type {ResumeSchemaStable['languages'][0]} */ const formatttedLang = { - language: profile.defaultLocale.language, + language: localeObject.language, fluency: 'Native Speaker' }; _outputJsonStable.languages.push(formatttedLang); _outputJsonLatest.languages.push(formatttedLang); resultSummary.sections.basics = 'success'; + + // Also make sure instance defaultLocale is correct, while we are parsing profile + const parsedLocaleStr = `${localeObject.language}_${localeObject.country}`; + _defaultLocale = parsedLocaleStr; + resultSummary.localeStr = parsedLocaleStr; } }); // Parse attachments / portfolio links - const attachments = db.getValuesByKey(_liSchemaKeys.attachments); + const attachments = db.getValuesByKey(_liTypeMappings.attachments.tocKeys); attachments.forEach((attachment) => { let captured = false; - const { url } = attachment.data; + const url = attachment.data.url || attachment.data.Url; if (attachment.providerName === 'GitHub' || /github\.com/gim.test(url)) { const usernameMatch = /github\.com\/([^\/\?]+)[^\/]+$/gim.exec(url); if (usernameMatch && !foundGithub) { @@ -606,10 +710,10 @@ window.LinkedinToResumeJson = (() => { captured = true; _outputJsonLatest.projects = _outputJsonLatest.projects || []; _outputJsonLatest.projects.push({ - name: attachment.title, + name: attachment.title || attachment.mediaTitle, startDate: '', endDate: '', - description: attachment.description, + description: attachment.description || attachment.mediaDescription, url }); } @@ -619,13 +723,13 @@ window.LinkedinToResumeJson = (() => { // Parse education let allEducationCanBeCaptured = true; // educationView contains both paging data, and list of child elements - const educationView = db.getValueByKey(_liSchemaKeys.education); + const educationView = db.getValueByKey(_liTypeMappings.education.tocKeys); if (educationView.paging) { const { paging } = educationView; allEducationCanBeCaptured = paging.start + paging.count >= paging.total; } if (allEducationCanBeCaptured) { - const educationEntries = db.getValuesByKey(_liSchemaKeys.education); + const educationEntries = db.getValuesByKey(_liTypeMappings.education.tocKeys); educationEntries.forEach((edu) => { parseAndPushEducation(edu, db, _this); }); @@ -639,13 +743,14 @@ window.LinkedinToResumeJson = (() => { // Parse work // First, check paging data let allWorkCanBeCaptured = true; - const positionView = db.getValueByKey(_liSchemaKeys.workPositions); + const positionView = db.getValueByKey(_liTypeMappings.workPositions.tocKeys); if (positionView.paging) { const { paging } = positionView; allWorkCanBeCaptured = paging.start + paging.count >= paging.total; } if (allWorkCanBeCaptured) { - db.getValuesByKey(_liSchemaKeys.workPositions).forEach((position) => { + const workPositions = db.getElementsByType(_liTypeMappings.workPositions.types); + workPositions.forEach((position) => { parseAndPushPosition(position); }); _this.debugConsole.log(`All work positions captured directly from profile result.`); @@ -656,7 +761,7 @@ window.LinkedinToResumeJson = (() => { } // Parse volunteer experience - const volunteerEntries = db.getValuesByKey(_liSchemaKeys.volunteerWork); + const volunteerEntries = db.getValuesByKey(_liTypeMappings.volunteerWork.tocKeys); volunteerEntries.forEach((volunteering) => { /** @type {ResumeSchemaStable['volunteer'][0]} */ const parsedVolunteerWork = { @@ -687,7 +792,7 @@ window.LinkedinToResumeJson = (() => { */ /** @type {ResumeSchemaBeyondSpec['certificates']} */ const certificates = []; - db.getValuesByKey(_liSchemaKeys.certificates).forEach((cert) => { + db.getValuesByKey(_liTypeMappings.certificates.tocKeys).forEach((cert) => { /** @type {ResumeSchemaBeyondSpec['certificates'][0]} */ const certObj = { title: cert.name, @@ -705,7 +810,7 @@ window.LinkedinToResumeJson = (() => { // Parse skills /** @type {string[]} */ const skillArr = []; - db.getValuesByKey(_liSchemaKeys.skills).forEach((skill) => { + db.getValuesByKey(_liTypeMappings.skills.tocKeys).forEach((skill) => { skillArr.push(skill.name); }); document.querySelectorAll('span[class*="skill-category-entity"][class*="name"]').forEach((skillNameElem) => { @@ -722,7 +827,7 @@ window.LinkedinToResumeJson = (() => { // Parse projects _outputJsonLatest.projects = _outputJsonLatest.projects || []; - db.getValuesByKey(_liSchemaKeys.projects).forEach((project) => { + db.getValuesByKey(_liTypeMappings.projects.tocKeys).forEach((project) => { const parsedProject = { name: project.title, startDate: '', @@ -735,7 +840,7 @@ window.LinkedinToResumeJson = (() => { resultSummary.sections.projects = _outputJsonLatest.projects.length ? 'success' : 'empty'; // Parse awards - const awardEntries = db.getValuesByKey(_liSchemaKeys.awards); + const awardEntries = db.getValuesByKey(_liTypeMappings.awards.tocKeys); awardEntries.forEach((award) => { /** @type {ResumeSchemaStable['awards'][0]} */ const parsedAward = { @@ -744,8 +849,10 @@ window.LinkedinToResumeJson = (() => { awarder: award.issuer, summary: noNullOrUndef(award.description) }; - if (award.issueDate && typeof award.issueDate === 'object') { - parsedAward.date = parseDate(award.issueDate); + // profileView vs dash key + const issueDateObject = award.issueDate || award.issuedOn; + if (issueDateObject && typeof issueDateObject === 'object') { + parsedAward.date = parseDate(issueDateObject); } _outputJsonStable.awards.push(parsedAward); _outputJsonLatest.awards.push(parsedAward); @@ -753,7 +860,7 @@ window.LinkedinToResumeJson = (() => { resultSummary.sections.awards = awardEntries.length ? 'success' : 'empty'; // Parse publications - const publicationEntries = db.getValuesByKey(_liSchemaKeys.publications); + const publicationEntries = db.getValuesByKey(_liTypeMappings.publications.tocKeys); publicationEntries.forEach((publication) => { /** @type {ResumeSchemaStable['publications'][0]} */ const parsedPublication = { @@ -763,8 +870,10 @@ window.LinkedinToResumeJson = (() => { website: noNullOrUndef(publication.url), summary: noNullOrUndef(publication.description) }; - if (publication.date && typeof publication.date === 'object' && typeof publication.date.year !== 'undefined') { - parsedPublication.releaseDate = parseDate(publication.date); + // profileView vs dash key + const publicationDateObj = publication.date || publication.publishedOn; + if (publicationDateObj && typeof publicationDateObj === 'object' && typeof publicationDateObj.year !== 'undefined') { + parsedPublication.releaseDate = parseDate(publicationDateObj); } _outputJsonStable.publications.push(parsedPublication); _outputJsonLatest.publications.push({ @@ -818,6 +927,7 @@ window.LinkedinToResumeJson = (() => { this.lastScannedLocale = null; /** @type {string | null} */ this.preferLocale = null; + _defaultLocale = this.getViewersLocalLang(); this.scannedPageUrl = ''; this.parseSuccess = false; this.getFullSkills = typeof OPT_getFullSkills === 'boolean' ? OPT_getFullSkills : true; @@ -825,7 +935,19 @@ window.LinkedinToResumeJson = (() => { this.debug = typeof OPT_debug === 'boolean' ? OPT_debug : false; if (this.debug) { console.warn('LinkedinToResumeJson - DEBUG mode is ON'); - this.buildDbFromLiSchema = buildDbFromLiSchema; + this.internals = { + buildDbFromLiSchema, + parseProfileSchemaJSON, + _defaultLocale, + _liSchemaKeys, + _liTypeMappings, + _voyagerEndpoints, + output: { + _outputJsonStable, + _outputJsonLatest, + _outputJsonBetaPartial + } + }; } this.debugConsole = { /** @type {(...args: any[]) => void} */ @@ -906,34 +1028,29 @@ window.LinkedinToResumeJson = (() => { _outputJsonLatest.basics.profiles.push(formattedProfile); }; - LinkedinToResumeJson.prototype.parseViaInternalApiFullProfile = async function parseViaInternalApiFullProfile() { + LinkedinToResumeJson.prototype.parseViaInternalApiFullProfile = async function parseViaInternalApiFullProfile(useCache = true) { try { // Get full profile - const fullProfileView = await this.voyagerFetch(_voyagerEndpoints.fullProfileView); - if (fullProfileView && typeof fullProfileView.data === 'object') { - // Try to use the same parser that I use for embedded - const profileParserResult = await parseProfileSchemaJSON(this, fullProfileView); - this.debugConsole.log(`Parse full profile via internal API, success = ${profileParserResult.parseSuccess}`); - if (profileParserResult.parseSuccess) { - this.profileParseSummary = profileParserResult; - } - // Some sections might require additional fetches to fill missing data - if (profileParserResult.sections.work === 'incomplete') { - _outputJsonStable.work = []; - _outputJsonLatest.work = []; - await this.parseViaInternalApiWork(); - } - if (profileParserResult.sections.education === 'incomplete') { - _outputJsonStable.education = []; - _outputJsonLatest.education = []; - await this.parseViaInternalApiEducation(); - } - this.debugConsole.log({ - _outputJsonStable, - _outputJsonLatest - }); - return true; + const profileParserResult = await this.getParsedProfile(useCache); + + // Some sections might require additional fetches to fill missing data + if (profileParserResult.sections.work === 'incomplete') { + _outputJsonStable.work = []; + _outputJsonLatest.work = []; + await this.parseViaInternalApiWork(); } + if (profileParserResult.sections.education === 'incomplete') { + _outputJsonStable.education = []; + _outputJsonLatest.education = []; + await this.parseViaInternalApiEducation(); + } + + this.debugConsole.log({ + _outputJsonStable, + _outputJsonLatest + }); + + return true; } catch (e) { this.debugConsole.warn('Error parsing using internal API (Voyager) - FullProfile', e); } @@ -1138,12 +1255,12 @@ window.LinkedinToResumeJson = (() => { } }; - LinkedinToResumeJson.prototype.parseViaInternalApi = async function parseViaInternalApi() { + LinkedinToResumeJson.prototype.parseViaInternalApi = async function parseViaInternalApi(useCache = true) { try { let apiSuccessCount = 0; let fullProfileEndpointSuccess = false; - fullProfileEndpointSuccess = await this.parseViaInternalApiFullProfile(); + fullProfileEndpointSuccess = await this.parseViaInternalApiFullProfile(useCache); if (fullProfileEndpointSuccess) { apiSuccessCount++; } @@ -1240,14 +1357,15 @@ window.LinkedinToResumeJson = (() => { }; /** - * Get the LI profile response - * @param {boolean} useCache - * @param {string} [optLocale] preferred locale + * Get the parsed version of the LI profile response object + * - Caches profile object and re-uses when possible + * @param {boolean} [useCache] default = true + * @param {string} [optLocale] preferred locale. Defaults to instance.preferLocale * @returns {Promise} profile object response summary */ - LinkedinToResumeJson.prototype.getProfileResponseSummary = async function getProfileResponseSummary(useCache = true, optLocale) { + LinkedinToResumeJson.prototype.getParsedProfile = async function getParsedProfile(useCache = true, optLocale) { const localeToUse = optLocale || this.preferLocale; - const localeMatchesUser = !localeToUse || optLocale === this.getViewersLocalLang(); + const localeMatchesUser = !localeToUse || optLocale === _defaultLocale; if (this.profileParseSummary && useCache) { const { pageUrl, localeStr, parseSuccess } = this.profileParseSummary; @@ -1270,11 +1388,30 @@ window.LinkedinToResumeJson = (() => { } // Get directly via API - const fullProfileView = await this.voyagerFetch(_voyagerEndpoints.fullProfileView); + /** @type {ParseProfileSchemaResultSummary['profileSrc']} */ + let endpointType = 'profileView'; + /** @type {LiResponse} */ + let profileResponse; + if (!localeMatchesUser) { + /** + * LI acts strange if user is a multilingual user, with defaultLocale different than the resource being requested. It will *not* respect x-li-lang header for profileView, and you instead have to use the Dash fullprofile endpoint + */ + endpointType = 'dashFullProfileWithEntities'; + profileResponse = await this.voyagerFetch(_voyagerEndpoints.dash.fullProfile); + } else { + // use normal profileView + profileResponse = await this.voyagerFetch(_voyagerEndpoints.fullProfileView); + } + // Try to use the same parser that I use for embedded - const profileParserResult = await parseProfileSchemaJSON(this, fullProfileView); + const profileParserResult = await parseProfileSchemaJSON(this, profileResponse, endpointType); + if (profileParserResult.parseSuccess) { - this.debugConsole.log('getProfileResponse - Used API. Sucess'); + this.debugConsole.log('getProfileResponse - Used API. Sucess', { + profileResponse, + endpointType, + profileParserResult + }); this.profileParseSummary = profileParserResult; return this.profileParseSummary; } @@ -1312,21 +1449,24 @@ window.LinkedinToResumeJson = (() => { _outputJsonStable = JSON.parse(JSON.stringify(resumeJsonTemplateStable)); _outputJsonLatest = JSON.parse(JSON.stringify(resumeJsonTemplateLatest)); _outputJsonBetaPartial = JSON.parse(JSON.stringify(resumeJsonTemplateBetaPartial)); + // Trigger full load await _this.triggerAjaxLoadByScrolling(); _this.parseBasics(); + // Embedded schema can't be used for specific locales if (_this.preferApi === false && localeMatchesUser) { await _this.parseEmbeddedLiSchema(); if (!_this.parseSuccess) { - await _this.parseViaInternalApi(); + await _this.parseViaInternalApi(false); } } else { - await _this.parseViaInternalApi(); + await _this.parseViaInternalApi(false); if (!_this.parseSuccess) { await _this.parseEmbeddedLiSchema(); } } + _this.scannedPageUrl = _this.getUrlWithoutQuery(); _this.lastScannedLocale = localeToUse; _this.debugConsole.log(_this); @@ -1509,9 +1649,11 @@ window.LinkedinToResumeJson = (() => { /** * Get the local language identifier of the *viewer* (not profile) + * - This should correspond to LI's defaultLocale, which persists, even across user configuration changes * @returns {string} */ LinkedinToResumeJson.prototype.getViewersLocalLang = () => { + // This *seems* to correspond with profile.defaultLocale, but I'm not 100% sure const metaTag = document.querySelector('meta[name="i18nDefaultLocale"]'); /** @type {HTMLSelectElement | null} */ const selectTag = document.querySelector('select#globalfooter-select_language'); @@ -1527,20 +1669,21 @@ window.LinkedinToResumeJson = (() => { /** * Get the locales that the *current* profile (natively) supports (based on `supportedLocales`) + * Note: Uses cache * @returns {Promise} */ LinkedinToResumeJson.prototype.getSupportedLocales = async function getSupportedLocales() { - /** @type {string[]} */ - let supportedLocales = []; - const { profileObj } = await this.getProfileResponseSummary(); - const profileDb = buildDbFromLiSchema(profileObj); - const userDetails = profileDb.getValuesByKey(_liSchemaKeys.profile)[0]; - if (userDetails && Array.isArray(userDetails['supportedLocales'])) { - supportedLocales = userDetails.supportedLocales.map((locale) => { - return `${locale.language}_${locale.country}`; - }); + if (!_supportedLocales.length) { + const { liResponse } = await this.getParsedProfile(true, null); + const profileDb = buildDbFromLiSchema(liResponse); + const userDetails = profileDb.getValuesByKey(_liSchemaKeys.profile)[0]; + if (userDetails && Array.isArray(userDetails['supportedLocales'])) { + _supportedLocales = userDetails.supportedLocales.map((locale) => { + return `${locale.language}_${locale.country}`; + }); + } } - return supportedLocales; + return _supportedLocales; }; /** @@ -1559,7 +1702,7 @@ window.LinkedinToResumeJson = (() => { // Try to use cache if (this.profileParseSummary && this.profileParseSummary.parseSuccess) { - const profileDb = buildDbFromLiSchema(this.profileParseSummary.profileObj); + const profileDb = buildDbFromLiSchema(this.profileParseSummary.liResponse); this.profileUrnId = profileDb.tableOfContents['entityUrn'].match(profileViewUrnPatt)[1]; return this.profileUrnId; } @@ -1595,38 +1738,42 @@ window.LinkedinToResumeJson = (() => { photoUrl = photoElem.src; } else { // Get via miniProfile entity in full profile db - const { profileObj } = await this.getProfileResponseSummary(); - const profileDb = buildDbFromLiSchema(profileObj); - const fullProfile = profileDb.getValuesByKey(_liSchemaKeys.profile)[0]; - const miniProfile = profileDb.getElementByUrn(fullProfile['*miniProfile']); - if (miniProfile && !!miniProfile.picture) { - const pictureMeta = miniProfile.picture; - // @ts-ignore - const smallestArtifact = pictureMeta.artifacts.sort((a, b) => a.width - b.width)[0]; - photoUrl = `${pictureMeta.rootUrl}${smallestArtifact.fileIdentifyingUrlPathSegment}`; + const { liResponse, profileSrc, profileInfoObj } = await this.getParsedProfile(); + const profileDb = buildDbFromLiSchema(liResponse); + let pictureMeta; + if (profileSrc === 'profileView') { + const miniProfile = profileDb.getElementByUrn(profileInfoObj['*miniProfile']); + if (miniProfile && !!miniProfile.picture) { + pictureMeta = miniProfile.picture; + } + } else { + pictureMeta = profileInfoObj.profilePicture.displayImageReference.vectorImage; } + // @ts-ignore + const smallestArtifact = pictureMeta.artifacts.sort((a, b) => a.width - b.width)[0]; + photoUrl = `${pictureMeta.rootUrl}${smallestArtifact.fileIdentifyingUrlPathSegment}`; } return photoUrl; }; LinkedinToResumeJson.prototype.generateVCard = async function generateVCard() { - const { profileObj } = await this.getProfileResponseSummary(); + const profileResSummary = await this.getParsedProfile(); const contactInfoObj = await this.voyagerFetch(_voyagerEndpoints.contactInfo); - this.exportVCard(profileObj, contactInfoObj); + this.exportVCard(profileResSummary, contactInfoObj); }; /** - * @param {LiResponse} profileObj + * @param {ParseProfileSchemaResultSummary} profileResult * @param {LiResponse} contactInfoObj */ - LinkedinToResumeJson.prototype.exportVCard = async function exportVCard(profileObj, contactInfoObj) { + LinkedinToResumeJson.prototype.exportVCard = async function exportVCard(profileResult, contactInfoObj) { const vCard = VCardsJS(); - const profileDb = buildDbFromLiSchema(profileObj); + const profileDb = buildDbFromLiSchema(profileResult.liResponse); const contactDb = buildDbFromLiSchema(contactInfoObj); // Contact info is stored directly in response; no lookup const contactInfo = /** @type {LiProfileContactInfoResponse['data']} */ (contactDb.tableOfContents); - const profile = profileDb.getValuesByKey(_liSchemaKeys.profile)[0]; + const profile = profileResult.profileInfoObj; vCard.formattedName = `${profile.firstName} ${profile.lastName}`; vCard.firstName = profile.firstName; vCard.lastName = profile.lastName; @@ -1638,7 +1785,7 @@ window.LinkedinToResumeJson = (() => { vCard.email = contactInfo.emailAddress; if (contactInfo.twitterHandles.length) { // @ts-ignore - vCard.socialUrls['twitter'] = `https://twitter.com/${contactInfo.twitterHandles[0]}`; + vCard.socialUrls['twitter'] = `https://twitter.com/${contactInfo.twitterHandles[0].name}`; } if (contactInfo.phoneNumbers) { contactInfo.phoneNumbers.forEach((numberObj) => { @@ -1669,7 +1816,7 @@ window.LinkedinToResumeJson = (() => { } } // Try to get currently employed organization - const positions = profileDb.getValuesByKey(_liSchemaKeys.workPositions); + const positions = profileDb.getElementsByType(_liTypeMappings.workPositions.types); if (positions.length) { vCard.organization = positions[0].companyName; vCard.title = positions[0].title; @@ -1677,7 +1824,12 @@ window.LinkedinToResumeJson = (() => { vCard.workUrl = this.getUrlWithoutQuery(); vCard.note = profile.headline; // Try to get profile picture - const photoUrl = await this.getDisplayPhoto(); + let photoUrl; + try { + photoUrl = await this.getDisplayPhoto(); + } catch (e) { + this.debugConsole.warn(`Could not extract profile picture.`, e); + } if (photoUrl) { try { // Since LI photo URLs are temporary, convert to base64 first @@ -1696,7 +1848,7 @@ window.LinkedinToResumeJson = (() => { }; /** - * + * API fetching, with auto pagination * @param {string} fetchEndpoint * @param {Record} [optHeaders] * @param {number} [start] @@ -1844,7 +1996,6 @@ window.LinkedinToResumeJson = (() => { method: 'GET', mode: 'cors' }; - _this.debugConsole.log(`Fetching: ${endpoint}`, fetchOptions); fetch(endpoint, fetchOptions).then((response) => { if (response.status !== 200) { const errStr = 'Error fetching internal API endpoint'; @@ -1853,7 +2004,13 @@ window.LinkedinToResumeJson = (() => { } else { response.text().then((text) => { try { + /** @type {LiResponse} */ const parsed = JSON.parse(text); + if (!!_this.preferLocale && _this.preferLocale !== _defaultLocale) { + _this.debugConsole.log(`Checking for locale mapping and remapping if found.`); + remapNestedLocale(parsed.included, this.preferLocale, true); + } + resolve(parsed); } catch (e) { console.warn('Error parsing internal API response', response, e); diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..ee37aec --- /dev/null +++ b/src/schema.js @@ -0,0 +1,91 @@ +/** + * Lookup keys for the standard profileView object + */ +const liSchemaKeys = { + profile: '*profile', + certificates: '*certificationView', + education: '*educationView', + workPositions: '*positionView', + workPositionGroups: '*positionGroupView', + skills: '*skillView', + projects: '*projectView', + attachments: '*summaryTreasuryMedias', + volunteerWork: '*volunteerExperienceView', + awards: '*honorView', + publications: '*publicationView' +}; +/** + * Try to maintain a mapping between generic section types, and LI's schema + * - tocKeys are pointers that often point to a collection of URNs + * - Try to put dash strings last, profileView first + * - Most recipes are dash only + */ +const liTypeMappings = { + profile: { + // There is no tocKey for profile in dash FullProfileWithEntries, + // due to how entry-point is configured + tocKeys: ['*profile'], + types: [ + // regular profileView + 'com.linkedin.voyager.identity.profile.Profile', + // dash FullProfile + 'com.linkedin.voyager.dash.identity.profile.Profile' + ], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities'] + }, + certificates: { + tocKeys: ['*certificationView', '*profileCertifications'], + types: ['com.linkedin.voyager.dash.identity.profile.Certification'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileCertification'] + }, + education: { + tocKeys: ['*educationView', '*profileEducations'], + types: [ + 'com.linkedin.voyager.identity.profile.Education', + // Dash + 'com.linkedin.voyager.dash.identity.profile.Education' + ], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileEducation'] + }, + // Individual work entries (not aggregate (workgroup) with date range) + workPositions: { + tocKeys: ['*positionView', '*profilePositionGroups'], + types: ['com.linkedin.voyager.identity.profile.Position', 'com.linkedin.voyager.dash.identity.profile.Position'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePosition'] + }, + skills: { + tocKeys: ['*skillView', '*profileSkills'], + types: ['com.linkedin.voyager.identity.profile.Skill', 'com.linkedin.voyager.dash.identity.profile.Skill'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileSkill'] + }, + projects: { + tocKeys: ['*projectView', '*profileProjects'], + types: ['com.linkedin.voyager.identity.profile.Project', 'com.linkedin.voyager.dash.identity.profile.Project'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileProject'] + }, + attachments: { + tocKeys: ['*summaryTreasuryMedias', '*profileTreasuryMediaPosition'], + types: ['com.linkedin.voyager.identity.profile.Certification', 'com.linkedin.voyager.dash.identity.profile.treasury.TreasuryMedia'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileTreasuryMedia'] + }, + volunteerWork: { + tocKeys: ['*volunteerExperienceView', '*profileVolunteerExperiences'], + types: ['com.linkedin.voyager.dash.identity.profile.VolunteerExperience'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileVolunteerExperience'] + }, + awards: { + tocKeys: ['*honorView', '*profileHonors'], + types: ['com.linkedin.voyager.identity.profile.Honor', 'com.linkedin.voyager.dash.identity.profile.Honor'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileHonor'] + }, + publications: { + tocKeys: ['*publicationView', '*profilePublications'], + types: ['com.linkedin.voyager.identity.profile.Publication', 'com.linkedin.voyager.dash.identity.profile.Publication'], + recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePublication'] + } +}; + +module.exports = { + liSchemaKeys, + liTypeMappings +};