Skip to content

Commit ba5d112

Browse files
committed
Bugfix: Release v2.1.2: Issues around work 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
1 parent 760244d commit ba5d112

File tree

6 files changed

+95
-29
lines changed

6 files changed

+95
-29
lines changed

README.md

+7-3
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ If I'm trying to assist you in solving an issue with this tool, I might have you
7979
---
8080

8181
## Updates:
82+
<details>
83+
<summary>Update History (Click to Show / Hide)</summary>
84+
8285
Date | Release | Notes
8386
--- | --- | ---
87+
2/27/2021 | 2.1.2 | Fix: Multiple issues around work history / experience; missing titles, ordering, etc. Overhauled approach to extracting work entries.
8488
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))
8589
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))
8690
11/12/2020 | 2.0.0 | Support for multiple schema versions ✨ ([#34](https://github.com/joshuatz/linkedin-to-jsonresume/pull/34))
@@ -102,6 +106,7 @@ Date | Release | Notes
102106
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!
103107
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.
104108
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!
109+
</details>
105110

106111
---
107112

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

151156
```js
152157
// Get main profileDB (after running extension)
153-
var profileRes = await li2JrInstance.getParsedProfile();
154-
var profileDb = await li2JrInstance.internals.buildDbFromLiSchema(profileRes.liResponse);
155-
158+
var profileRes = await liToJrInstance.getParsedProfile(true);
159+
var profileDb = await liToJrInstance.internals.buildDbFromLiSchema(profileRes.liResponse);
156160
```
157161

158162
---

global.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ declare global {
8989
getElementByUrn: (urn: string) => LiEntity | undefined;
9090
/**
9191
* Get multiple elements by URNs
92+
* - Allows passing a single URN, for convenience if unsure if you have an array
9293
*/
93-
getElementsByUrns: (urns: string[]) => LiEntity[];
94+
getElementsByUrns: (urns: string[] | string) => LiEntity[];
9495
}
9596

9697
interface LiProfileContactInfoResponse extends LiResponse {

package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "linkedin-to-json-resume-exporter",
3-
"version": "2.1.1",
3+
"version": "2.1.2",
44
"description": "Browser tool to grab details from your open LinkedIn profile page and export to JSON Resume Schema",
55
"private": true,
66
"main": "src/main.js",

src/main.js

+72-22
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ window.LinkedinToResumeJson = (() => {
132132
return db.entitiesByUrn[urn];
133133
};
134134
db.getElementsByUrns = function getElementsByUrns(urns) {
135-
return urns.map((urn) => db.entitiesByUrn[urn]);
135+
if (typeof urns === 'string') {
136+
urns = [urns];
137+
}
138+
return Array.isArray(urns) ? urns.map((urn) => db.entitiesByUrn[urn]) : [];
136139
};
137140
// Only meant for 1:1 lookups; will return first match, if more than one
138141
// key provided. Usually returns a "view" (kind of a collection)
@@ -310,6 +313,7 @@ window.LinkedinToResumeJson = (() => {
310313
_outputJsonStable.work.push(parsedWork);
311314
_outputJsonLatest.work.push({
312315
name: parsedWork.company,
316+
position: parsedWork.position,
313317
// This is description of company, not position
314318
// description: '',
315319
startDate: parsedWork.startDate,
@@ -500,14 +504,13 @@ window.LinkedinToResumeJson = (() => {
500504
// Parse work
501505
// First, check paging data
502506
let allWorkCanBeCaptured = true;
503-
const positionView = db.getValueByKey(_liTypeMappings.workPositions.tocKeys);
507+
const positionView = db.getValueByKey([..._liTypeMappings.workPositionGroups.tocKeys, ..._liTypeMappings.workPositions.tocKeys]);
504508
if (positionView.paging) {
505509
const { paging } = positionView;
506510
allWorkCanBeCaptured = paging.start + paging.count >= paging.total;
507511
}
508512
if (allWorkCanBeCaptured) {
509-
const workPositions = db.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
510-
workPositions.forEach((position) => {
513+
_this.getWorkPositions(db).forEach((position) => {
511514
parseAndPushPosition(position, db);
512515
});
513516
_this.debugConsole.log(`All work positions captured directly from profile result.`);
@@ -970,28 +973,75 @@ window.LinkedinToResumeJson = (() => {
970973
return false;
971974
};
972975

976+
/**
977+
* Extract work positions via traversal through position groups
978+
* - 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
979+
* - LI also uses positionGroups to preserve order, whereas a direct lookup by type or recipe might not return ordered results
980+
* - 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
981+
* @param {InternalDb} db
982+
*/
983+
LinkedinToResumeJson.prototype.getWorkPositions = function getWorkPositions(db) {
984+
const rootElements = db.getElements() || [];
985+
/** @type {LiEntity[]} */
986+
let positions = [];
987+
988+
/**
989+
* There are multiple ways that work positions can be nested within a profileView, or other data structure
990+
* A) **ROOT** -> *profilePositionGroups -> PositionGroup[] -> *profilePositionInPositionGroup (COLLECTION) -> Position[]
991+
* B) **ROOT** -> *positionGroupView -> PositionGroupView -> PositionGroup[] -> *positions -> Position[]
992+
*/
993+
994+
// This is route A - longest recursion chain
995+
// profilePositionGroup responses are a little annoying; the direct children don't point directly to position entities
996+
// Instead, you have to follow path of `profilePositionGroup` -> `*profilePositionInPositionGroup` -> `*elements` -> `Position`
997+
// You can bypass by looking up by `Position` type, but then original ordering is not preserved
998+
let profilePositionGroups = db.getValuesByKey('*profilePositionGroups');
999+
// Check for voyager profilePositionGroups response, where all groups are direct children of root element
1000+
if (!profilePositionGroups.length && rootElements.length && rootElements[0].$type === 'com.linkedin.voyager.dash.identity.profile.PositionGroup') {
1001+
profilePositionGroups = rootElements;
1002+
}
1003+
profilePositionGroups.forEach((pGroup) => {
1004+
// This element (profilePositionGroup) is one way how LI groups positions
1005+
// - Instead of storing *elements (positions) directly,
1006+
// there is a pointer to a "collection" that has to be followed
1007+
/** @type {string | string[] | undefined} */
1008+
let profilePositionInGroupCollectionUrns = pGroup['*profilePositionInPositionGroup'];
1009+
if (profilePositionInGroupCollectionUrns) {
1010+
const positionCollections = db.getElementsByUrns(profilePositionInGroupCollectionUrns);
1011+
// Another level... traverse collections
1012+
positionCollections.forEach((collection) => {
1013+
// Final lookup via standard collection['*elements']
1014+
positions = positions.concat(db.getElementsByUrns(collection['*elements'] || []));
1015+
});
1016+
}
1017+
});
1018+
1019+
if (!positions.length) {
1020+
db.getValuesByKey('*positionGroupView').forEach((pGroup) => {
1021+
positions = positions.concat(db.getElementsByUrns(pGroup['*positions'] || []));
1022+
});
1023+
}
1024+
1025+
if (!positions.length) {
1026+
// Direct lookup - by main TOC keys
1027+
positions = db.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
1028+
}
1029+
1030+
if (!positions.length) {
1031+
// Direct lookup - by type
1032+
positions = db.getElementsByType(_liTypeMappings.workPositions.types);
1033+
}
1034+
1035+
return positions;
1036+
};
1037+
9731038
LinkedinToResumeJson.prototype.parseViaInternalApiWork = async function parseViaInternalApiWork() {
9741039
try {
9751040
const workResponses = await this.voyagerFetchAutoPaginate(_voyagerEndpoints.dash.profilePositionGroups);
9761041
workResponses.forEach((response) => {
9771042
const db = buildDbFromLiSchema(response);
978-
// profilePositionGroup responses are a little annoying; the direct children don't point directly to position entities
979-
// Instead, you have to follow path of `profilePositionGroup` -> `*profilePositionInPositionGroup` -> `*elements` -> `Position`
980-
// You can bypass by looking up by `Position` type, but then original ordering is not preserved
981-
db.getElements().forEach((positionGroup) => {
982-
// This element is how LI groups positions
983-
// - E.g. promotions within same company are all grouped
984-
// - Instead of storing *elements (positions) directly,
985-
// there is a pointer to a "collection" that has to be followed
986-
// - This multi-level traversal within the LI response could
987-
// probably be refactored into a `db.*` method.
988-
const collectionResponse = db.getElementByUrn(positionGroup['*profilePositionInPositionGroup']);
989-
if (collectionResponse && Array.isArray(collectionResponse['*elements'])) {
990-
db.getElementsByUrns(collectionResponse['*elements']).forEach((position) => {
991-
// This is *finally* the "Position" element
992-
parseAndPushPosition(position, db);
993-
});
994-
}
1043+
this.getWorkPositions(db).forEach((position) => {
1044+
parseAndPushPosition(position, db);
9951045
});
9961046
});
9971047
} catch (e) {
@@ -1575,7 +1625,7 @@ window.LinkedinToResumeJson = (() => {
15751625
}
15761626
}
15771627
// Try to get currently employed organization
1578-
const positions = profileDb.getValuesByKey(_liTypeMappings.workPositions.tocKeys);
1628+
const positions = this.getWorkPositions(profileDb);
15791629
if (positions.length) {
15801630
vCard.organization = positions[0].companyName;
15811631
vCard.title = positions[0].title;

src/schema.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,21 @@ export const liTypeMappings = {
5454
},
5555
// Individual work entries (not aggregate (workgroup) with date range)
5656
workPositions: {
57-
tocKeys: ['*positionView', '*profilePositionGroups'],
57+
tocKeys: ['*positionView'],
5858
types: ['com.linkedin.voyager.identity.profile.Position', 'com.linkedin.voyager.dash.identity.profile.Position'],
5959
recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePosition']
6060
},
61+
// Work entry *groups*, aggregated by employer clumping
62+
workPositionGroups: {
63+
tocKeys: ['*positionGroupView', '*profilePositionGroups'],
64+
types: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroupsInjection'],
65+
recipes: [
66+
'com.linkedin.voyager.identity.profile.PositionGroupView',
67+
'com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroup',
68+
// Generic collection response
69+
'com.linkedin.restli.common.CollectionResponse'
70+
]
71+
},
6172
skills: {
6273
tocKeys: ['*skillView', '*profileSkills'],
6374
types: ['com.linkedin.voyager.identity.profile.Skill', 'com.linkedin.voyager.dash.identity.profile.Skill'],

0 commit comments

Comments
 (0)