Skip to content

Commit

Permalink
Bugfix: Release v2.1.2: Issues around work history
Browse files Browse the repository at this point in the history
 - Fixes #41
 - Addresses multiple issues around work history / experience; missing
 titles, ordering, etc.
 - Overhauled approach to extracting work entries. Extracted into common
 method that always tries to retrieve history in order, and has multiple
 fallbacks in the case of missing lookup paths
  • Loading branch information
joshuatz committed Feb 27, 2021
1 parent 760244d commit ba5d112
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 29 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ If I'm trying to assist you in solving an issue with this tool, I might have you
---

## Updates:
<details>
<summary>Update History (Click to Show / Hide)</summary>

Date | Release | Notes
--- | --- | ---
2/27/2021 | 2.1.2 | Fix: Multiple issues around work history / experience; missing titles, ordering, etc. Overhauled approach to extracting work entries.
12/19/2020 | 2.1.1 | Fix: Ordering of work history with new API endpoint ([#38](https://github.com/joshuatz/linkedin-to-jsonresume/issues/38))
12/7/2020 | 2.1.0 | Fix: Issue with multilingual profile, when exporting your own profile with a different locale than your profile's default. ([#37](https://github.com/joshuatz/linkedin-to-jsonresume/pull/37))
11/12/2020 | 2.0.0 | Support for multiple schema versions ✨ ([#34](https://github.com/joshuatz/linkedin-to-jsonresume/pull/34))
Expand All @@ -102,6 +106,7 @@ Date | Release | Notes
8/3/2019 | NA | Rewrote this tool as a browser extension instead of a bookmarklet to get around the CSP issue. Seems to work great!
7/22/2019 | NA | ***ALERT***: This bookmarklet is currently broken, thanks to LinkedIn adding a new restrictive CSP (Content Security Policy) header to the site. [I've opened an issue](https://github.com/joshuatz/linkedin-to-jsonresume-bookmarklet/issues/1) to discuss this, and both short-term (requires using the console) and long-term (browser extension) solutions.
6/21/2019 | 0.0.3 | I saw the bookmarklet was broken depending on how you came to the profile page, so I refactored a bunch of code and found a much better way to pull the data. Should be much more reliable!
</details>

---

Expand Down Expand Up @@ -150,9 +155,8 @@ Helpful snippets (subject to change; these rely heavily on internals):

```js
// Get main profileDB (after running extension)
var profileRes = await li2JrInstance.getParsedProfile();
var profileDb = await li2JrInstance.internals.buildDbFromLiSchema(profileRes.liResponse);

var profileRes = await liToJrInstance.getParsedProfile(true);
var profileDb = await liToJrInstance.internals.buildDbFromLiSchema(profileRes.liResponse);
```

---
Expand Down
3 changes: 2 additions & 1 deletion global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ declare global {
getElementByUrn: (urn: string) => LiEntity | undefined;
/**
* Get multiple elements by URNs
* - Allows passing a single URN, for convenience if unsure if you have an array
*/
getElementsByUrns: (urns: string[]) => LiEntity[];
getElementsByUrns: (urns: string[] | string) => LiEntity[];
}

interface LiProfileContactInfoResponse extends LiResponse {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linkedin-to-json-resume-exporter",
"version": "2.1.1",
"version": "2.1.2",
"description": "Browser tool to grab details from your open LinkedIn profile page and export to JSON Resume Schema",
"private": true,
"main": "src/main.js",
Expand Down
94 changes: 72 additions & 22 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ window.LinkedinToResumeJson = (() => {
return db.entitiesByUrn[urn];
};
db.getElementsByUrns = function getElementsByUrns(urns) {
return urns.map((urn) => db.entitiesByUrn[urn]);
if (typeof urns === 'string') {
urns = [urns];
}
return Array.isArray(urns) ? 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)
Expand Down Expand Up @@ -310,6 +313,7 @@ window.LinkedinToResumeJson = (() => {
_outputJsonStable.work.push(parsedWork);
_outputJsonLatest.work.push({
name: parsedWork.company,
position: parsedWork.position,
// This is description of company, not position
// description: '',
startDate: parsedWork.startDate,
Expand Down Expand Up @@ -500,14 +504,13 @@ window.LinkedinToResumeJson = (() => {
// Parse work
// First, check paging data
let allWorkCanBeCaptured = true;
const positionView = db.getValueByKey(_liTypeMappings.workPositions.tocKeys);
const positionView = db.getValueByKey([..._liTypeMappings.workPositionGroups.tocKeys, ..._liTypeMappings.workPositions.tocKeys]);
if (positionView.paging) {
const { paging } = positionView;
allWorkCanBeCaptured = paging.start + paging.count >= paging.total;
}
if (allWorkCanBeCaptured) {
const workPositions = db.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
workPositions.forEach((position) => {
_this.getWorkPositions(db).forEach((position) => {
parseAndPushPosition(position, db);
});
_this.debugConsole.log(`All work positions captured directly from profile result.`);
Expand Down Expand Up @@ -970,28 +973,75 @@ window.LinkedinToResumeJson = (() => {
return false;
};

/**
* Extract work positions via traversal through position groups
* - LI groups "positions" by "positionGroups" - e.g. if you had three positions at the same company, with no breaks in-between to work at another company, those three positions are grouped under a single positionGroup
* - LI also uses positionGroups to preserve order, whereas a direct lookup by type or recipe might not return ordered results
* - This method will try to return ordered results first, and then fall back to any matching positition entities if it can't find an ordered lookup path
* @param {InternalDb} db
*/
LinkedinToResumeJson.prototype.getWorkPositions = function getWorkPositions(db) {
const rootElements = db.getElements() || [];
/** @type {LiEntity[]} */
let positions = [];

/**
* There are multiple ways that work positions can be nested within a profileView, or other data structure
* A) **ROOT** -> *profilePositionGroups -> PositionGroup[] -> *profilePositionInPositionGroup (COLLECTION) -> Position[]
* B) **ROOT** -> *positionGroupView -> PositionGroupView -> PositionGroup[] -> *positions -> Position[]
*/

// This is route A - longest recursion chain
// profilePositionGroup responses are a little annoying; the direct children don't point directly to position entities
// Instead, you have to follow path of `profilePositionGroup` -> `*profilePositionInPositionGroup` -> `*elements` -> `Position`
// You can bypass by looking up by `Position` type, but then original ordering is not preserved
let profilePositionGroups = db.getValuesByKey('*profilePositionGroups');
// Check for voyager profilePositionGroups response, where all groups are direct children of root element
if (!profilePositionGroups.length && rootElements.length && rootElements[0].$type === 'com.linkedin.voyager.dash.identity.profile.PositionGroup') {
profilePositionGroups = rootElements;
}
profilePositionGroups.forEach((pGroup) => {
// This element (profilePositionGroup) is one way how LI groups positions
// - Instead of storing *elements (positions) directly,
// there is a pointer to a "collection" that has to be followed
/** @type {string | string[] | undefined} */
let profilePositionInGroupCollectionUrns = pGroup['*profilePositionInPositionGroup'];
if (profilePositionInGroupCollectionUrns) {
const positionCollections = db.getElementsByUrns(profilePositionInGroupCollectionUrns);
// Another level... traverse collections
positionCollections.forEach((collection) => {
// Final lookup via standard collection['*elements']
positions = positions.concat(db.getElementsByUrns(collection['*elements'] || []));
});
}
});

if (!positions.length) {
db.getValuesByKey('*positionGroupView').forEach((pGroup) => {
positions = positions.concat(db.getElementsByUrns(pGroup['*positions'] || []));
});
}

if (!positions.length) {
// Direct lookup - by main TOC keys
positions = db.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
}

if (!positions.length) {
// Direct lookup - by type
positions = db.getElementsByType(_liTypeMappings.workPositions.types);
}

return positions;
};

LinkedinToResumeJson.prototype.parseViaInternalApiWork = async function parseViaInternalApiWork() {
try {
const workResponses = await this.voyagerFetchAutoPaginate(_voyagerEndpoints.dash.profilePositionGroups);
workResponses.forEach((response) => {
const db = buildDbFromLiSchema(response);
// profilePositionGroup responses are a little annoying; the direct children don't point directly to position entities
// Instead, you have to follow path of `profilePositionGroup` -> `*profilePositionInPositionGroup` -> `*elements` -> `Position`
// You can bypass by looking up by `Position` type, but then original ordering is not preserved
db.getElements().forEach((positionGroup) => {
// This element is how LI groups positions
// - E.g. promotions within same company are all grouped
// - Instead of storing *elements (positions) directly,
// there is a pointer to a "collection" that has to be followed
// - This multi-level traversal within the LI response could
// probably be refactored into a `db.*` method.
const collectionResponse = db.getElementByUrn(positionGroup['*profilePositionInPositionGroup']);
if (collectionResponse && Array.isArray(collectionResponse['*elements'])) {
db.getElementsByUrns(collectionResponse['*elements']).forEach((position) => {
// This is *finally* the "Position" element
parseAndPushPosition(position, db);
});
}
this.getWorkPositions(db).forEach((position) => {
parseAndPushPosition(position, db);
});
});
} catch (e) {
Expand Down Expand Up @@ -1575,7 +1625,7 @@ window.LinkedinToResumeJson = (() => {
}
}
// Try to get currently employed organization
const positions = profileDb.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
const positions = this.getWorkPositions(profileDb);
if (positions.length) {
vCard.organization = positions[0].companyName;
vCard.title = positions[0].title;
Expand Down
13 changes: 12 additions & 1 deletion src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,21 @@ export const liTypeMappings = {
},
// Individual work entries (not aggregate (workgroup) with date range)
workPositions: {
tocKeys: ['*positionView', '*profilePositionGroups'],
tocKeys: ['*positionView'],
types: ['com.linkedin.voyager.identity.profile.Position', 'com.linkedin.voyager.dash.identity.profile.Position'],
recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePosition']
},
// Work entry *groups*, aggregated by employer clumping
workPositionGroups: {
tocKeys: ['*positionGroupView', '*profilePositionGroups'],
types: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroupsInjection'],
recipes: [
'com.linkedin.voyager.identity.profile.PositionGroupView',
'com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroup',
// Generic collection response
'com.linkedin.restli.common.CollectionResponse'
]
},
skills: {
tocKeys: ['*skillView', '*profileSkills'],
types: ['com.linkedin.voyager.identity.profile.Skill', 'com.linkedin.voyager.dash.identity.profile.Skill'],
Expand Down

0 comments on commit ba5d112

Please sign in to comment.