├── .eslintrc.json
├── .git-blame-ignore-revs
├── .github
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── bookmarklet-resources
└── install-page-template.html
├── browser-ext
├── background.js
├── icon-128.png
├── icon-16.png
├── icon-48.png
├── manifest.json
├── popup.html
├── popup.js
└── styles.css
├── build-scripts
├── build-bookmarklet.js
├── package-browserext.js
├── prep-browserext.js
└── prep-dirs.js
├── docs
├── LinkedIn-Dev-Notes-README.md
├── demo-bookmarklet.gif
├── demo-chrome_extension-social.gif
├── demo-chrome_extension.gif
└── multilingual-support.png
├── global.d.ts
├── jsonresume.schema.latest.ts
├── jsonresume.schema.legacy.ts
├── package-lock.json
├── package.json
├── src
├── .gitkeep
├── main.js
├── schema.js
├── templates.js
└── utilities.js
├── tsconfig.json
├── webpack.dev.js
├── webpack.prod.js
└── webstore-assets
├── Webstore-Screenshot.png
└── webstore.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb-base","plugin:prettier/recommended", "plugin:@typescript-eslint/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:import/typescript"],
3 | "ignorePatterns": ["**/build*/**", "**/webstore-zips/**", "**/scratch**"],
4 | "plugins": [
5 | "@typescript-eslint"
6 | ],
7 | "parser": "@typescript-eslint/parser",
8 | "parserOptions": {
9 | "ecmaVersion": 2018
10 | },
11 | "env": {
12 | "browser": true
13 | },
14 | "rules": {
15 | "no-self-assign": "off",
16 | "no-param-reassign": "off",
17 | "no-underscore-dangle": "off",
18 | "no-plusplus": "off",
19 | "no-useless-escape": "off",
20 | "no-console": "off",
21 | "no-alert": "off",
22 | "no-lonely-if": "off",
23 | "dot-notation": "off",
24 | "camelcase": [
25 | "error",
26 | {
27 | "allow": ["^OPT_"]
28 | }
29 | ],
30 | "indent": ["error", 4],
31 | "max-len": ["error", 300],
32 | "comma-dangle": "off",
33 | "prefer-destructuring": ["error", {
34 | "array": false,
35 | "object": true
36 | }],
37 | "@typescript-eslint/no-var-requires": "off",
38 | "@typescript-eslint/no-this-alias": "off",
39 | "@typescript-eslint/no-explicit-any": "warn",
40 | "@typescript-eslint/ban-ts-comment": "off",
41 | "@typescript-eslint/no-empty-interface": "off",
42 | "import/extensions": [
43 | "error",
44 | "ignorePackages",
45 | {
46 | "js": "never",
47 | "jsx": "never",
48 | "ts": "never",
49 | "tsx": "never"
50 | }
51 | ]
52 | },
53 | "globals": {
54 | "chrome": "readonly"
55 | },
56 | "settings": {
57 | "import/resolver": {
58 | "typescript": {}
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Eslint + Prettier updates and linting over repo
2 | d6fb09b701da5ccea23996ca662eb0871201007c
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | This is an issue template, meant to help expedite the process of creating and resolving issues. Please fill out the various sections below (feel free to remove placeholder text before submitting). If you are overwhelmed by this, or confused, you can remove it all and just write whatever you want, but be aware that I might not respond as quickly if you do that.
11 |
12 | **Troubleshooting**
13 | Before creating your issue, please run through the following troubleshooting steps to make sure this is not a temporary glitch or caused by a factor outside the control of this tool:
14 |
15 | - [ ] If you have recently installed any new browser extensions, try temporarily disabling them to see if that fixes mine (sometimes extensions interfere with each other)
16 | - [ ] Verify that LinkedIn has not rate-limited or banned your account. If you cannot load profiles, or get random error messages and blank pages, that is outside my control
17 |
18 | **Describe the issue**
19 | What has gone wrong? Or, how is the tool not acting how you are expecting it to?
20 |
21 | **Help me reproduce your issue**
22 | In order to expedite a bug fix, please help me narrow down what could be causing your issue by providing details that will let me replicate the same issue you saw:
23 |
24 | - Where did this happen?
25 | - Was this on a standard LinkedIn profile page? Did the URL start with `https://www.linkedin.com/in/`?
26 | - Is there anything special about the profile you were trying to extract?
27 | - Example of something *"special"*: Multi-lingual profiles
28 | - How consistent is this issue?
29 | - Does this happen 100% of the time, on every profile? On a specific profile? Just some of the time?
30 |
31 | **Screenshots**
32 | If applicable, add screenshots to help explain your problem.
33 |
34 | **Additional context**
35 | Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | build-bookmarklet
4 | build-browserext
5 | webstore-zips
6 | !build/.gitignore
7 | scratch
8 | *.pem
9 | *.crx
10 | .DS_Store
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/build/**/*
2 | **/scratch**/*
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 200,
3 | "useTabs": false,
4 | "singleQuote": true,
5 | "tabWidth": 4,
6 | "endOfLine": "auto",
7 | "trailingComma": "none"
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.insertSpaces": true,
3 | "editor.tabSize": 4,
4 | "[javascript]": {
5 | "editor.formatOnSave": true,
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "files.eol": "\n",
9 | "cSpell.enableFiletypes": ["md", "js"],
10 | "cSpell.ignoreWords": ["Linkedin"]
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Joshua Tzucker
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LinkedIn Profile to JSON Resume Browser Tool 
2 |
3 | > An extremely easy-to-use browser extension for exporting your full LinkedIn Profile to a JSON Resume file or string.
4 |
5 | ## Chrome Extension 📦 - [Webstore Link](https://chrome.google.com/webstore/detail/json-resume-exporter/caobgmmcpklomkcckaenhjlokpmfbdec)
6 |
7 | ## My LinkedIn Profile 👨💼 - [linkedin.com/in/joshuatzucker/](https://www.linkedin.com/in/joshuatzucker/)
8 |
9 | 
10 |
11 | ## What is JSON Resume?
12 | "JSON Resume" is an open-source standard / schema, currently gaining in adoption, that standardizes the content of a resume into a shared underlying structure that others can use in automated resume formatters, parsers, etc. Read more about it [here](https://jsonresume.org/), or on [GitHub](https://github.com/jsonresume).
13 |
14 | ## What is this tool?
15 | I made this because I wanted a way to quickly generate a JSON Resume export from my LinkedIn profile, and got frustrated with how locked down the LinkedIn APIs are and how slow it is to request your data export (up to 72 hours). "Install" the tool to your browser, then click to run it while looking at a LinkedIn profile (preferably your own), and my code will grab the various pieces of information off the page and then show a popup with the full JSON resume export that you can copy and paste to wherever you would like.
16 |
17 | ## A Note About Feature Requests and Bug Reports
18 | I want to make something clear: the only goal of this extension is to be able to export a JSON Resume version of your LinkedIn profile; nothing more, nothing less.
19 |
20 | If the tool is malfunctioning or could do a better job of exporting JSON Resume, by all means please open an issue - I always appreciate it!
21 |
22 | However, I have gotten several feature requests (as both issues and emails) asking me to extend the tool to do things that are far outside this goal. While I appreciate the interest, ultimately I am not interested in having this project grow outside the original scope - you will usually see me close this issues as `wontfix` and will reply to emails the same way. I have limited time, and this project was originally something I whipped up in a few days for _personal_ use - it has ***already*** gotten way out of scope 😅
23 |
24 | ---
25 |
26 | ## Breaking Change - `v1.0` Schema Update
27 | The 10/31/2021 release of this extension (`v3.0.0`) changes the default shape of the JSON exported by this tool, to adhere to the newer `v1` schema offered by JSON Resume:
28 |
29 |
30 | Previously
31 |
32 | - Stable: `v0.0.16`
33 | - Latest: `v0.1.3`
34 | - Beta: `v0.1.3` + `certificates`
35 |
36 |
37 |
38 | Version 3.0.0
39 |
40 | - ***Legacy***: `v0.0.16`
41 | - Stable: `v1.0.0`
42 | - Beta: Even with `v1.0.0` (for now)
43 |
44 |
45 | ## Usage / Installation Options:
46 | There are (or *were*) a few different options for how to use this:
47 | - **Fast and simple**: Chrome Extension - [Get it here](https://chrome.google.com/webstore/detail/json-resume-exporter/caobgmmcpklomkcckaenhjlokpmfbdec)
48 | - Feel free to install, use, and then immediately uninstall if you just need a single export
49 | - No data is collected
50 | - [***Deprecated***] (at least for now): Bookmarklet
51 | - This was originally how this tool worked, but had to be retired as a valid method when LinkedIn added a stricter CSP that prevented it from working
52 | - Code to generate the bookmarklet is still in this repo if LI ever loosens the CSP
53 |
54 | ### Schema Versions
55 | This tool supports multiple version of [the JSON Resume Schema specification](https://github.com/jsonresume/resume-schema) for export, which you can easily swap between in the dropdown selector! ✨
56 |
57 | > "Which schema version should I use?"
58 |
59 | If you are unsure, you should probably just stick with *"stable"*, which is the default. It should have the most widespread support across the largest number of platforms.
60 |
61 | ### Support for Multilingual Profiles
62 | LinkedIn [has a unique feature](https://www.linkedin.com/help/linkedin/answer/1717/create-or-delete-a-profile-in-another-language) that allows you to create different versions of your profile for different languages, rather than relying on limited translation of certain fields.
63 |
64 | For example, if you are bilingual in both English and German, you could create one version of your profile for each language, and then viewers would automatically see the correct one depending on where they live and their language settings.
65 |
66 | I've implemented support (starting with `v1.0.0`) for multilingual profile export through a dropdown selector:
67 |
68 | 
69 |
70 | The dropdown should automatically get populated with the languages that the profile you are currently viewing supports, in addition to your own preferred viewing language in the #1 spot. You should be able to switch between languages in the dropdown and click the export button to get a JSON Resume export with your selected language.
71 |
72 | > Note: LinkedIn offers language choices through [a `Locale` string](https://developer.linkedin.com/docs/ref/v2/object-types#LocaleString), which is a combination of `country` (ISO-3166) and `language` (ISO-639). I do not make decisions as to what languages are supported.
73 |
74 | > This feature is the part of this extension most likely to break in the future; LI has some serious quirks around multilingual profiles - see [my notes](./docs/LinkedIn-Dev-Notes-README.md#voyager---multilingual-and-locales-support) for details.
75 |
76 | ### Export Options
77 | There are several main buttons in the browser extension, with different effects. You can hover over each button to see the alt text describing what they do, or read below:
78 | - *LinkedIn Profile to JSON*: Converts the profile to the JSON Resume format, and then displays it in a popup modal for easy copying and pasting
79 | - *Download JSON Resume Export*: Same as above, but prompts you to download the result as an actual `.json` file.
80 | - *Download vCard File*: Export and download the profile as a Virtual Contact File (`.vcf`) (aka *vCard*)
81 | - There are some caveats with this format; see below
82 |
83 |
84 | #### vCard Limitations and Caveats
85 | - Partial birthdate (aka `BDAY`) values (e.g. where the profile has a month and day, but has not opted to share their birth year), are only supported in v4 (RFC-6350) and above. This extension currently only supports v3, so in these situations the tool will simply omit the BDAY field from the export
86 | - See [#32](https://github.com/joshuatz/linkedin-to-jsonresume/issues/32) for details
87 | - The LinkedIn display photo (included in vCard) served by LI is a temporary URL, with a fixed expiration date set by LinkedIn. From observations, this is often set months into the future, but could still be problematic for address book clients that don't cache images. To work around this, I'm converting it to a base64 string; this should work with most vCard clients, but also increases the vCard file size considerably.
88 |
89 | ### Chrome Side-loading Instructions
90 | Instead of installing from the Chrome Webstore, you might might want to "side-load" a ZIP build for either local development, or to try out a new release that has not yet made it through the Chrome review process. Here are the instructions for doing so:
91 |
92 | 1. Find the ZIP you want to load
93 | - If you want to side-load the latest version, you can download a ZIP from [the releases tab](https://github.com/joshuatz/linkedin-to-jsonresume/releases/)
94 | - If you want to side-load a local build, use `npm run package-browserext` to create a ZIP
95 | 2. Go to Chrome's extension setting page (`chrome://extensions`)
96 | 3. Turn on developer mode (upper right toggle switch)
97 | 4. Drag the downloaded zip to the browser to let it install
98 | 5. Test it out, then uninstall
99 |
100 | You can also unpack the ZIP and load it as "unpacked".
101 |
102 | ## Troubleshooting
103 | When in doubt, refresh the profile page before using this tool.
104 |
105 | ### Troubleshooting - Debug Log
106 | If I'm trying to assist you in solving an issue with this tool, I might have you share some debug info. Currently, the easiest way to do this is to use the Chrome developer's console:
107 |
108 | 1. Append `?li2jr_debug=true` to the end of the URL of the profile you are on
109 | 2. Open Chrome dev tools, and specifically, the console ([instructions](https://developers.google.com/web/tools/chrome-devtools/open#console))
110 | 3. Run the extension (try to export the profile), and then look for red messages that show up in the console (these are errors, as opposed to warnings or info logs).
111 | - You can filter to just `error` messages, in the filter dropdown above the console.
112 |
113 | ---
114 |
115 | ## Updates:
116 |
117 | Update History (Click to Show / Hide)
118 |
119 | Date | Release | Notes
120 | --- | --- | ---
121 | 4/9/2022 | 3.2.3 | Fix: Incomplete work listings extraction (see [#68](https://github.com/joshuatz/linkedin-to-jsonresume/issues/68))
122 | 12/24/2021 | 3.2.2 | Fix: Broken endpoints (see [#63](https://github.com/joshuatz/linkedin-to-jsonresume/issues/63))
123 | 11/14/2021 | 3.2.1 | Fix: Some profiles missing full language proficiency extraction (see [#59](https://github.com/joshuatz/linkedin-to-jsonresume/issues/59)) Fix: Missing Education (regression) (see [#60](https://github.com/joshuatz/linkedin-to-jsonresume/issues/60))
124 | 11/7/2021 | 3.2.0 | Improve/Fix: Include location in work positions (see [#58](https://github.com/joshuatz/linkedin-to-jsonresume/issues/58))
125 | 11/7/2021 | 3.1.0 | Fix: Incorrect sorting of volunteer positions (see [#55](https://github.com/joshuatz/linkedin-to-jsonresume/issues/55)) Fix: Missing Certificates (see [#59](https://github.com/joshuatz/linkedin-to-jsonresume/issues/59)) Improve/Fix: Extract Language proficiencies / languages (see [#59](https://github.com/joshuatz/linkedin-to-jsonresume/issues/59)) Improve: Cleanup, README update
126 | 10/31/2021 | 3.0.0 | **Breaking Update**: This extension has now been updated to output JSON matching the `v1` schema specification released by JSON Resume (see [#53](https://github.com/joshuatz/linkedin-to-jsonresume/pull/53) and [#56](https://github.com/joshuatz/linkedin-to-jsonresume/pull/56)). If you still need the `v0.0.16` schema output, it is no longer the default, but is still available for now under the "legacy" schema option. Thanks @ [anthonyjdella](https://github.com/anthonyjdella) for the PR and code contributions! Fix: Also rolled into this release is a fix for truncated volunteer experiences (see [#55](https://github.com/joshuatz/linkedin-to-jsonresume/issues/55)). Thanks @ [fkrauthan](https://github.com/fkrauthan) for the heads up!
127 | 2/27/2021 | 2.1.2 | Fix: Multiple issues around work history / experience; missing titles, ordering, etc. Overhauled approach to extracting work entries.
128 | 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))
129 | 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))
130 | 11/12/2020 | 2.0.0 | Support for multiple schema versions ✨ ([#34](https://github.com/joshuatz/linkedin-to-jsonresume/pull/34))
131 | 11/8/2020 | 1.5.1 | Fix: Omit partial BDAY export in vCard ([#32](https://github.com/joshuatz/linkedin-to-jsonresume/issues/32))
132 | 10/22/2020 | 1.5.0 | Fix: Incorrect birthday month in exported vCards (off by one) Fix: Better pattern for extracting profile ID from URL, fixes extracting from virtual sub-pages of profile (e.g. `/detail/contact-info`), or with query or hash strings at the end.
133 | 7/7/2020 | 1.4.2 | Fix: For work positions, if fetched via `profilePositionGroups`, LI ordering (the way it looks on your profile) was not being preserved.
134 | 7/31/2020 | 1.4.1 | Fix: In some cases, wrong profileUrnId was extracted from current profile, which led to work history API call being ran against a *different* profile (e.g. from "recommended section", or something like that).
135 | 7/21/2020 | 1.4.0 | Fix: For vCard exports, Previous profile was getting grabbed after SPA navigation between profiles.
136 | 7/6/2020 | 1.3.0 | Fix: Incomplete work position entries for some users; LI was limiting the amount of pre-fetched data. Had to implement request paging to fix. Also refactored a lot of code, improved result caching, and other tweaks.
137 | 6/18/2020 | 1.2.0 | Fix / Improve VCard export feature.
138 | 6/5/2020 | 1.1.0 | New feature: [vCard](https://en.wikipedia.org/wiki/VCard) export, which you can import into Outlook / Google Contacts / etc.
139 | 5/31/2020 | 1.0.0 | Brought output up to par with "spec", integrated schemas as TS, added support for multilingual profiles, overhauled JSDoc types. Definitely a *breaking* change, since the output has changed to mirror schema more closely (biggest change is `website` in several spots has become `url`)
140 | 5/9/2020 | 0.0.9 | Fixed "references", added certificates (behind setting), and formatting tweaks
141 | 4/4/2020 | 0.0.8 | Added version string display to popup
142 | 4/4/2020 | 0.0.7 | Fixed and improved contact info collection (phone, Twitter, and email). Miscellaneous other tweaks.
143 | 10/22/2019 | 0.0.6 | Updated recommendation querySelector after LI changed DOM. Thanks again, @ [lucbpz](https://github.com/lucbpz).
144 | 10/19/2019 | 0.0.5 | Updated LI date parser to produce date string compliant with JSONResume Schema (padded). Thanks @ [lucbpz](https://github.com/lucbpz).
145 | 9/12/2019 | 0.0.4 | Updated Chrome webstore stuff to avoid LI IP usage (Google took down extension page due to complaint). Updated actual scraper code to grab full list of skills vs just highlighted.
146 | 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!
147 | 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.
148 | 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!
149 |
150 |
151 | ---
152 |
153 | ## Development
154 | > With the rewrite to a browser extension, I actually configured the build scripts to be able to still create a bookmarklet from the same codebase, in case the bookmarklet ever becomes a viable option again.
155 |
156 | ### Building the browser extension
157 | `npm run build-browserext` will transpile and copy all the right files to `./build-browserext`, which you can then side-load into your browser. If you want to produce a single ZIP archive for the extension, `npm run package-browserext` will do that.
158 |
159 | > Use `build-browserext-debug` for a source-map debug version. To get more console output, append `li2jr_debug=true` to the query string of the LI profile you are using the tool with.
160 |
161 | ### Building the bookmarklet version
162 | Currently, the build process looks like this:
163 | - `src/main.js` -> (`webpack + babel`) -> `build/main.js` -> [`mrcoles/bookmarklet`](https://github.com/mrcoles/bookmarklet) -> `build/bookmarklet_export.js` -> `build/install-page.html`
164 | - The bookmark can then be dragged to your bookmarks from the final `build/install-page.html`
165 |
166 | All of the above should happen automatically when you do `npm run build-bookmarklet`.
167 |
168 | If this ever garners enough interest and needs to be updated, I will probably want to re-write it with TypeScript to make it more maintainable.
169 |
170 | ### LinkedIn Documentation
171 | For understanding some peculiarities of the LI API, see [LinkedIn-Dev-Notes-README.md](./docs/LinkedIn-Dev-Notes-README.md).
172 |
173 | ### Debugging
174 | Debugging the extension is a little cumbersome, because of the way Chrome sandboxes extension scripts and how code has to be injected. An alternative to setting breakpoints in the extension code itself, is to copy the output of `/build/main.js` and run it via the console.
175 |
176 | ```js
177 | li2jr = new LinkedinToResumeJson(true, true);
178 | li2jr.parseAndShowOutput();
179 | ```
180 |
181 | > Even if you have the repo inside of a local static server, you can't inject it via a script tag or fetch & eval, due to LI's restrictive CSP.
182 |
183 | If you do want to find the actual injected code of the extension in Chrome dev tools, you should be able to find it under `Sources -> Content Scripts -> top -> JSON Resume Exporter -> {main.js}`
184 |
185 | #### Debugging Snippets
186 | Helpful snippets (subject to change; these rely heavily on internals):
187 |
188 | ```js
189 | // Get main profileDB (after running extension)
190 | var profileRes = await liToJrInstance.getParsedProfile(true);
191 | var profileDb = await liToJrInstance.internals.buildDbFromLiSchema(profileRes.liResponse);
192 | ```
193 |
194 | ---
195 |
196 | ## DISCLAIMER:
197 | This tool is not affiliated with LinkedIn in any manner. Intended use is to export your own profile data, and you, as the user, are responsible for using it within the terms and services set out by LinkedIn. I am not responsible for any misuse, or repercussions of said misuse.
198 |
199 | ## Attribution:
200 | Icon for browser extension:
201 | - [https://www.iconfinder.com/icons/95928/arrow_down_download_profile_icon](https://www.iconfinder.com/icons/95928/arrow_down_download_profile_icon)
202 |
--------------------------------------------------------------------------------
/bookmarklet-resources/install-page-template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Linkedin To ResumeJSON Bookmarklet
8 |
9 |
10 | Bookmarklet Installer:
11 | Drag this link - LinkedIn-To-ResumeJSON - to your bookmarks. Then click on it while viewing a LinkedIn profile page.
12 |
13 |
--------------------------------------------------------------------------------
/browser-ext/background.js:
--------------------------------------------------------------------------------
1 | /**
2 | * === Handle Toggling of Button Action based on domain match ===
3 | * This is only necessary because we are using `page_action` instead of `browser_action`
4 | */
5 | chrome.runtime.onInstalled.addListener(() => {
6 | chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
7 | chrome.declarativeContent.onPageChanged.addRules([
8 | {
9 | conditions: [
10 | new chrome.declarativeContent.PageStateMatcher({
11 | pageUrl: {
12 | hostContains: 'linkedin.com'
13 | }
14 | })
15 | ],
16 | actions: [new chrome.declarativeContent.ShowPageAction()]
17 | }
18 | ]);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/browser-ext/icon-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/browser-ext/icon-128.png
--------------------------------------------------------------------------------
/browser-ext/icon-16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/browser-ext/icon-16.png
--------------------------------------------------------------------------------
/browser-ext/icon-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/browser-ext/icon-48.png
--------------------------------------------------------------------------------
/browser-ext/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "JSON Resume Exporter",
3 | "description": "Export a profile page to JSON Resume",
4 | "manifest_version": 2,
5 | "icons": {
6 | "16": "icon-16.png",
7 | "48": "icon-48.png",
8 | "128": "icon-128.png"
9 | },
10 | "page_action": {
11 | "default_popup": "popup.html"
12 | },
13 | "background": {
14 | "scripts": ["background.js"],
15 | "persistent": false
16 | },
17 | "permissions": [
18 | "declarativeContent",
19 | "activeTab",
20 | "storage"
21 | ]
22 | }
--------------------------------------------------------------------------------
/browser-ext/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | LinkedIn to JSON Resume Extension
9 |
10 |
11 |
12 |
13 |
14 | LinkedIn Profile to JSON
15 | 💾
16 | 📠
17 |
18 |
19 |
20 |
21 | Select export language:
22 |
23 | en_US
24 |
25 |
26 |
27 |
28 |
29 |
30 | Select JSONResume version:
31 |
32 | Legacy (v0.0.16)
33 | Stable (v1.0.0)
34 | Currently even with stable (v1.0.0)
35 |
36 |
37 |
38 |
42 |
43 | Version: {version_string}
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/browser-ext/popup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * =============================
3 | * = Constants =
4 | * =============================
5 | */
6 |
7 | const extensionId = chrome.runtime.id;
8 |
9 | const STORAGE_KEYS = {
10 | schemaVersion: 'schemaVersion'
11 | };
12 | const SPEC_SELECT = /** @type {HTMLSelectElement} */ (document.getElementById('specSelect'));
13 | /** @type {SchemaVersion[]} */
14 | const SPEC_OPTIONS = ['legacy', 'stable', 'beta'];
15 | /** @type {HTMLSelectElement} */
16 | const LANG_SELECT = document.querySelector('.langSelect');
17 |
18 | /**
19 | * Generate injectable code for capturing a value from the contentScript scope and passing back via message
20 | * @param {string} valueToCapture - Name of the scoped variable to capture
21 | * @param {string} [optKey] - Key to use as message identifier. Defaults to valueToCapture
22 | */
23 | const createMessageSenderInjectable = (valueToCapture, optKey) => {
24 | return `chrome.runtime.sendMessage('${extensionId}', {
25 | key: '${optKey || valueToCapture}',
26 | value: ${valueToCapture}
27 | });`;
28 | };
29 | const createMainInstanceCode = `
30 | isDebug = window.location.href.includes('li2jr_debug=true');
31 | window.LinkedinToResumeJson = isDebug ? LinkedinToResumeJson : window.LinkedinToResumeJson;
32 | // Reuse existing instance if possible
33 | liToJrInstance = typeof(liToJrInstance) !== 'undefined' ? liToJrInstance : new LinkedinToResumeJson(isDebug);
34 | `;
35 | const getLangStringsCode = `(async () => {
36 | const supported = await liToJrInstance.getSupportedLocales();
37 | const user = liToJrInstance.getViewersLocalLang();
38 | const payload = {
39 | supported,
40 | user
41 | }
42 | ${createMessageSenderInjectable('payload', 'locales')}
43 | })();
44 | `;
45 |
46 | /**
47 | * Get the currently selected lang locale in the selector
48 | */
49 | const getSelectedLang = () => {
50 | return LANG_SELECT.value;
51 | };
52 |
53 | /**
54 | * Get JS string that can be eval'ed to get the program to run and show output
55 | * Note: Be careful of strings versus vars, escaping, etc.
56 | * @param {SchemaVersion} version
57 | */
58 | const getRunAndShowCode = (version) => {
59 | return `liToJrInstance.preferLocale = '${getSelectedLang()}';liToJrInstance.parseAndShowOutput('${version}');`;
60 | };
61 |
62 | /**
63 | * Toggle enabled state of popup
64 | * @param {boolean} isEnabled
65 | */
66 | const toggleEnabled = (isEnabled) => {
67 | document.querySelectorAll('.toggle').forEach((elem) => {
68 | elem.classList.remove(isEnabled ? 'disabled' : 'enabled');
69 | elem.classList.add(isEnabled ? 'enabled' : 'disabled');
70 | });
71 | };
72 |
73 | /**
74 | * Load list of language strings to be displayed as options
75 | * @param {string[]} langs
76 | */
77 | const loadLangs = (langs) => {
78 | LANG_SELECT.innerHTML = '';
79 | langs.forEach((lang) => {
80 | const option = document.createElement('option');
81 | option.value = lang;
82 | option.innerText = lang;
83 | LANG_SELECT.appendChild(option);
84 | });
85 | toggleEnabled(langs.length > 0);
86 | };
87 |
88 | const exportVCard = () => {
89 | chrome.tabs.executeScript({
90 | code: `liToJrInstance.generateVCard()`
91 | });
92 | };
93 |
94 | /**
95 | * Set the desired export lang on the exporter instance
96 | * - Use `null` to unset
97 | * @param {string | null} lang
98 | */
99 | const setLang = (lang) => {
100 | chrome.tabs.executeScript(
101 | {
102 | code: `liToJrInstance.preferLocale = '${lang}';`
103 | },
104 | () => {
105 | chrome.tabs.executeScript({
106 | code: `console.log(liToJrInstance);console.log(liToJrInstance.preferLocale);`
107 | });
108 | }
109 | );
110 | };
111 |
112 | /** @param {SchemaVersion} version */
113 | const setSpecVersion = (version) => {
114 | chrome.storage.sync.set({
115 | [STORAGE_KEYS.schemaVersion]: version
116 | });
117 | };
118 |
119 | /**
120 | * Get user's preference for JSONResume Spec Version
121 | * @returns {Promise}
122 | */
123 | const getSpecVersion = () => {
124 | // Fallback value will be what is already selected in dropdown
125 | const fallbackVersion = /** @type {SchemaVersion} */ (SPEC_SELECT.value);
126 | return new Promise((res) => {
127 | try {
128 | chrome.storage.sync.get([STORAGE_KEYS.schemaVersion], (result) => {
129 | const storedSetting = result[STORAGE_KEYS.schemaVersion] || '';
130 | if (SPEC_OPTIONS.includes(storedSetting)) {
131 | res(storedSetting);
132 | } else {
133 | res(fallbackVersion);
134 | }
135 | });
136 | } catch (err) {
137 | console.error(err);
138 | res(fallbackVersion);
139 | }
140 | });
141 | };
142 |
143 | /**
144 | * =============================
145 | * = Setup Event Listeners =
146 | * =============================
147 | */
148 |
149 | chrome.runtime.onMessage.addListener((message, sender) => {
150 | console.log(message);
151 | if (sender.id === extensionId && message.key === 'locales') {
152 | /** @type {{supported: string[], user: string}} */
153 | const { supported, user } = message.value;
154 | // Make sure user's own locale comes as first option
155 | if (supported.includes(user)) {
156 | supported.splice(supported.indexOf(user), 1);
157 | }
158 | supported.unshift(user);
159 | loadLangs(supported);
160 | }
161 | });
162 |
163 | document.getElementById('liToJsonButton').addEventListener('click', async () => {
164 | const versionOption = await getSpecVersion();
165 | const runAndShowCode = getRunAndShowCode(versionOption);
166 | chrome.tabs.executeScript(
167 | {
168 | code: `${runAndShowCode}`
169 | },
170 | () => {
171 | setTimeout(() => {
172 | // Close popup
173 | window.close();
174 | }, 700);
175 | }
176 | );
177 | });
178 |
179 | document.getElementById('liToJsonDownloadButton').addEventListener('click', () => {
180 | chrome.tabs.executeScript({
181 | code: `liToJrInstance.preferLocale = '${getSelectedLang()}';liToJrInstance.parseAndDownload();`
182 | });
183 | });
184 |
185 | LANG_SELECT.addEventListener('change', () => {
186 | setLang(getSelectedLang());
187 | });
188 |
189 | document.getElementById('vcardExportButton').addEventListener('click', () => {
190 | exportVCard();
191 | });
192 |
193 | SPEC_SELECT.addEventListener('change', () => {
194 | setSpecVersion(/** @type {SchemaVersion} */ (SPEC_SELECT.value));
195 | });
196 |
197 | /**
198 | * =============================
199 | * = Init =
200 | * =============================
201 | */
202 | document.getElementById('versionDisplay').innerText = chrome.runtime.getManifest().version;
203 |
204 | chrome.tabs.executeScript(
205 | {
206 | file: 'main.js'
207 | },
208 | () => {
209 | chrome.tabs.executeScript({
210 | code: `${createMainInstanceCode}${getLangStringsCode}`
211 | });
212 | }
213 | );
214 |
215 | getSpecVersion().then((spec) => {
216 | SPEC_SELECT.value = spec;
217 | });
218 |
--------------------------------------------------------------------------------
/browser-ext/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | min-width: 400px;
3 | min-height: 100px;
4 | }
5 | .mainButton {
6 | font-size: 21px;
7 | -moz-box-shadow:inset 0px 1px 0px 0px #97c4fe;
8 | -webkit-box-shadow:inset 0px 1px 0px 0px #97c4fe;
9 | box-shadow:inset 0px 1px 0px 0px #97c4fe;
10 | background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #3d94f6), color-stop(1, #1e62d0));
11 | background:-moz-linear-gradient(top, #3d94f6 5%, #1e62d0 100%);
12 | background:-webkit-linear-gradient(top, #3d94f6 5%, #1e62d0 100%);
13 | background:-o-linear-gradient(top, #3d94f6 5%, #1e62d0 100%);
14 | background:-ms-linear-gradient(top, #3d94f6 5%, #1e62d0 100%);
15 | background:linear-gradient(to bottom, #3d94f6 5%, #1e62d0 100%);
16 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#3d94f6', endColorstr='#1e62d0',GradientType=0);
17 | background-color:#3d94f6;
18 | -moz-border-radius:6px;
19 | -webkit-border-radius:6px;
20 | border-radius:6px;
21 | border:1px solid #337fed;
22 | display:inline-block;
23 | cursor:pointer;
24 | color:#ffffff;
25 | font-family:Arial;
26 | font-weight:bold;
27 | padding:6px 24px;
28 | text-decoration:none;
29 | text-shadow:0px 1px 0px #1570cd;
30 | }
31 | .mainButton:hover {
32 | background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #1e62d0), color-stop(1, #3d94f6));
33 | background:-moz-linear-gradient(top, #1e62d0 5%, #3d94f6 100%);
34 | background:-webkit-linear-gradient(top, #1e62d0 5%, #3d94f6 100%);
35 | background:-o-linear-gradient(top, #1e62d0 5%, #3d94f6 100%);
36 | background:-ms-linear-gradient(top, #1e62d0 5%, #3d94f6 100%);
37 | background:linear-gradient(to bottom, #1e62d0 5%, #3d94f6 100%);
38 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#1e62d0', endColorstr='#3d94f6',GradientType=0);
39 | background-color:#1e62d0;
40 | }
41 | .mainButton:active {
42 | position:relative;
43 | top:1px;
44 | }
45 | .mainButton.disabled {
46 | background: grey;
47 | border: black;
48 | }
49 | .emojiButton {
50 | padding: 2px 6px 6px 6px;
51 | }
52 | .disabled {
53 | cursor: not-allowed;
54 | opacity: 30%;
55 | }
56 |
57 | .bottomButtonsWrapper {
58 | margin-top: 12px;
59 | }
60 |
61 | .fullCenter {
62 | width: 100%;
63 | text-align: center;
64 | margin-bottom: 10px;
65 | }
66 |
67 | .bottomButton {
68 | width: 40%;
69 | margin-right: 1%;
70 | background-color: #283E4A;
71 | color: white;
72 | font-size: 1.1rem;
73 | text-decoration: none;
74 | display: inline-block;
75 | padding: 2%;
76 | border-radius: 10px;
77 | border: 1px solid #283E4A;
78 | }
79 | .bottomButton:hover {
80 | color: #283E4A;
81 | background-color: white;
82 | }
--------------------------------------------------------------------------------
/build-scripts/build-bookmarklet.js:
--------------------------------------------------------------------------------
1 | // Dependencies
2 | // @ts-ignore
3 | const replace = require('replace');
4 | const fse = require('fs-extra');
5 | const childProc = require('child_process');
6 |
7 | // Paths
8 | const buildFolder = `${__dirname}/../build-bookmarklet/`;
9 | const installFileHtml = `${buildFolder}install-page.html`;
10 | const srcFolder = `${__dirname}/../src/`;
11 |
12 | // Copy src to build and then append some JS that will auto-execute when ran
13 | fse.copyFileSync(`${srcFolder}main.js`, `${buildFolder}main.js`);
14 | fse.appendFileSync(`${buildFolder}main.js`, 'window.linkedinToResumeJsonConverter = new LinkedinToResumeJson();\nwindow.linkedinToResumeJsonConverter.parseAndShowOutput();');
15 |
16 | // Run bookmarklet converter
17 | childProc.execSync('bookmarklet ./build/main.js ./build-bookmarklet/bookmarklet_export.js');
18 |
19 | // Get entire contents of processed bookmarklet code as var
20 | const bookmarkletContent = fse.readFileSync(`${buildFolder}bookmarklet_export.js`);
21 |
22 | // Copy template install page to build folder
23 | fse.copyFileSync('./bookmarklet-resources/install-page-template.html', installFileHtml);
24 |
25 | // Replace placeholder variable in install HTML file with raw contents
26 | replace({
27 | regex: '{{bookmarklet_code}}',
28 | replacement: bookmarkletContent,
29 | paths: [installFileHtml],
30 | recursive: true,
31 | silent: false
32 | });
33 |
--------------------------------------------------------------------------------
/build-scripts/package-browserext.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Creates a ZIP file that contains everything necessary to publish as a browser extension (publish to Chrome webstore)
3 | */
4 | const fse = require('fs-extra');
5 | const archiver = require('archiver');
6 |
7 | // Get version info
8 | const versionString = require('../package.json').version.toString();
9 |
10 | const output = fse.createWriteStream(`${__dirname}/../webstore-zips/build_${versionString}.zip`);
11 | const archive = archiver('zip', {
12 | zlib: { level: 6 } // compression level
13 | });
14 |
15 | // listen for all archive data to be written
16 | output.on('close', () => {
17 | console.log(`${archive.pointer()} total bytes`);
18 | console.log('archiver has been finalized and the output file descriptor has closed.');
19 | });
20 |
21 | // good practice to catch this error explicitly
22 | archive.on('error', (err) => {
23 | throw err;
24 | });
25 |
26 | // pipe archive data to the file
27 | archive.pipe(output);
28 |
29 | // append files from a directory
30 | archive.directory(`${__dirname}/../build-browserext/`, '');
31 |
32 | // finalize the archive (ie we are done appending files but streams have to finish yet)
33 | archive.finalize();
34 |
--------------------------------------------------------------------------------
/build-scripts/prep-browserext.js:
--------------------------------------------------------------------------------
1 | const fse = require('fs-extra');
2 | const packageJson = require('../package.json');
3 | const manifestJson = require('../browser-ext/manifest.json');
4 |
5 | const browserExtSrcDir = `${__dirname}/../browser-ext/`;
6 | const buildDir = `${__dirname}/../build/`;
7 | const browserBuildDir = `${__dirname}/../build-browserext/`;
8 |
9 | // Clean out build dir
10 | fse.emptyDirSync(browserBuildDir);
11 |
12 | // Copy all files from browser extension folder to build
13 | fse.copySync(browserExtSrcDir, browserBuildDir);
14 |
15 | // Copy version number over to manifest, and write it out to build dir
16 | // @ts-ignore
17 | manifestJson.version = packageJson.version;
18 | fse.writeFileSync(`${browserBuildDir}manifest.json`, JSON.stringify(manifestJson, null, 4));
19 |
20 | // Copy main js file, after babel, over to build
21 | fse.copySync(buildDir, browserBuildDir);
22 |
--------------------------------------------------------------------------------
/build-scripts/prep-dirs.js:
--------------------------------------------------------------------------------
1 | const fse = require('fs-extra');
2 |
3 | const requiredDirs = [`${__dirname}/../build`, `${__dirname}/../build-bookmarklet`, `${__dirname}/../build-browserext`, `${__dirname}/../webstore-zips`];
4 |
5 | // Directories to always empty first
6 | const cleanDirs = [`${__dirname}/../build`, `${__dirname}/../build-bookmarklet`, `${__dirname}/../build-browserext`];
7 |
8 | const prep = async () => {
9 | await Promise.all(requiredDirs.map((r) => fse.ensureDir(r)));
10 | await Promise.all(cleanDirs.map((c) => fse.emptyDir(c)));
11 | };
12 |
13 | prep();
14 |
--------------------------------------------------------------------------------
/docs/LinkedIn-Dev-Notes-README.md:
--------------------------------------------------------------------------------
1 | Back to main README: [click here](../README.md)
2 |
3 | ## Resources
4 |
5 | - V1 Docs: https://linkedin.api-docs.io
6 | - V2 Docs:
7 | - https://docs.microsoft.com/en-us/linkedin/
8 | - https://developer.linkedin.com/docs/guide/v2
9 | - Other projects that use the unofficial Voyager API:
10 | - [tomquirk/linkedin-api](https://github.com/tomquirk/linkedin-api)
11 | - [eilonmore/linkedin-private-api](https://github.com/eilonmore/linkedin-private-api)
12 | - [jarivas/linkedin-exporter](https://github.com/jarivas/linkedin-exporter)
13 | - LinkedIn DataHub (this powers a lot of the backend)
14 | - [DataHub Blog Post](https://engineering.linkedin.com/blog/2019/data-hub)
15 | - [DataHub Github Repo](https://github.com/linkedin/datahub)
16 | - LinkedIn `Rest.li` (also powers some of the backend)
17 | - [`Rest.li` GitHub Repo](https://github.com/linkedin/rest.li/)
18 | - [`Rest.li` Docs](https://linkedin.github.io/rest.li/spec/protocol)
19 |
20 | ## Internal API (aka *Voyager*)
21 | ### Query String Syntax
22 | One common way that different pieces of information are requested from Voyager is simply by using different endpoints; `/me` for information about the logged-in user, `/identity/profiles/.../skillCategory` for skills, etc.
23 |
24 | However, certain endpoints have advanced filtering in place, where there is another layer of passing request data - through the **query string**. The syntax and nature of filtering sort of feels like GraphQL or something like that. Especially since these endpoints will sometimes even return `schema` info, which describes the shape of the actual data that is being returned.
25 |
26 | The syntax varies, but as of 2020, the more advanced filtered endpoints usually look like this:
27 |
28 | `{endpoint}/?q={authOrObjectOfInterest}&authOrObjectOfInterest={authOrObjectOfInterest_VALUE}&decorationId={schemaFilter}`
29 |
30 | To break this down further:
31 |
32 | - `q={authOrObjectOfInterest}`
33 | - This can be either *who* is authorizing the request (e.g. `admin`, `viewee`, etc.), or *what* the request is about (e.g. `memberIdentity`, if about another LI user)
34 | - `authOrObjectOfInterest={authOrObjectOfInterest_VALUE}`
35 | - If the endpoint uses the `q=` param (described above), the value for the query is usually passed by repeating the `authOrObjectOfInterest` string, as part of a new param key-pair.
36 | - For example, if the first part of the query string was `q=memberIdentity`, then the next part might be `&memberIdentity=joshuatz`. This says that the query is about a member, with the ID of `joshuatz`.
37 | - `decorationId={schemaFilter}`
38 | - For endpoints that are *generic* base endpoints, and rely heavily on schema filtering, this property is extremely important. Unfortunately, it is also extremely un-documented....
39 | - `schemaFilter` is a string, written with dot notation, which looks like some sort of nested namespace. E.g. `com.linkedin.voyager.dash.deco.identity.profile.PrimaryLocale-3`
40 |
41 |
42 | > For some endpoints, only `decorationId` is required, and the other query parameters are completely omitted. Keep in mind that LI knows who is making the request based on the Auth headers.
43 |
44 | #### GraphQL Endpoints
45 | It looks LinkedIn has been transitioning some page components to pull data from special endpoints flavored with GraphQL. For example, `voyagerIdentityGraphQL`.
46 |
47 | Using these requires knowing a special `queryId` value ahead of time - this could be (maybe?) a server-side generated ID for an allowed query. Getting this ID is... tricky. Short answer is that it relies on some particulars of how LI is using Ember and bundling.
48 |
49 | Here is a quickly cobbled-together function to extract a queryId based on a known registered component path:
50 |
51 | ```js
52 | /**
53 | * Retrieve a GraphQL ID for a pre-registered query (?)
54 | * WARNING: This relies heavily on LI internal APIs - not stable, and should be avoided when other alternatives
55 | * can be used instead
56 | * @param {string} graphQlModuleString - The module path specifier that the GraphQL query is "registered" under. For example, `graphql-queries/queries/profile/profile-components.graphq`
57 | */
58 | function getGraphQlQueryId(graphQlModuleString) {
59 | if (typeof window.require === 'function') {
60 | const frozenRegisteredQuery = window.require(graphQlModuleString);
61 | return window.require('@linkedin/ember-restli-graphql/-private/query').getGraphQLQueryId(frozenRegisteredQuery);
62 | }
63 | return undefined;
64 | }
65 | ```
66 |
67 | ### Protocol Versions
68 | Voyager, like the main LinkedIn API, can use different "protocol versions" of the REST API. You might run into issues with certain endpoints and query string formats if you don't specify the correct version via the `x-restli-protocol-version` header. E.g., the `voyagerIdentityGraphQL` endpoint should probably always use the v2 header:
69 |
70 | ```
71 | x-restli-protocol-version: 2.0.0
72 | ```
73 |
74 | For more details, see the [official API docs for Protocol Versions](https://docs.microsoft.com/en-us/linkedin/shared/api-guide/concepts/protocol-version).
75 |
76 | ### Paging Data
77 | #### Paging in Requests
78 | The usual paging querystring parameters are:
79 |
80 | - `count={number}`
81 | - I think most APIs would call this `limit` instead
82 | - `start={number}`
83 |
84 | #### Paging in Responses
85 | LI usually responds with paging data in every response, regardless if there is enough entities to need paging anyways. Paging data can be returned at multiple levels; at both the root level (for the *thing* you requested), as well as at multiple nested sub-levels (for *children* of the *thing* you requested).
86 |
87 | The paging information object usually looks like this:
88 |
89 | ```ts
90 | type PagingInfo = {
91 | count: number;
92 | start: number;
93 | total?: number;
94 | $recipeTypes?: string[];
95 | // I've never actually seen this property populated...
96 | // This is probably actually `Array`
97 | links?: string[];
98 | }
99 | ```
100 |
101 | > Note that several paging properties are often omitted.
102 |
103 | ### Dash Endpoint(s)
104 | As a quick side-note, I've noticed that a lot of the endpoints with `dash` anywhere in the path use the newer `decorationId` query syntax. This seems to also correspond with a shift in LI's UI towards true SPA functionality, where more of the page is lazy-loaded with filtered data that is slotted into Ember JS templates.
105 |
106 | ### Voyager Responses and Nested Data
107 | Here are some quick notes on Voyager responses and how data is grouped / nested:
108 |
109 | - *Elements* can be nested several layers deep; you might have an initial collection of elements, where each sub-element is actually a group that contains pointer to further collections of elements
110 | - If you want to get the final layer of elements, you have to be careful about how you get them
111 | - If you simply filter by `$type`, you are going to get elements out of order, and LI does not provide indexes (typically) on elements
112 | - The only way to preserve the true order (which will match the rendered result on LI) is to traverse through levels
113 | - Currently, `com.linkedin.restli.common.CollectionResponse` seems to be used for each layer where the element is a collection that points to sub elements (under `*elements`) key
114 | - This can also make paging a little messy.
115 | - LI has limits on certain endpoints, and the amount of nested elements it will return
116 | - See [PR #23](https://github.com/joshuatz/linkedin-to-jsonresume/pull/23) for an example of how this was implemented
117 |
118 | ### Voyager - Misc Notes
119 | - Make sure you always include the `Host` header if making requests outside a web browser (browsers will automatically include this for you)
120 | - Value should be: `www.linkedin.com`
121 | - If you forget it, you will get 400 error (`invalid hostname`)
122 | - For inline data, `
` with request payload usually ***follows*** `
` with *response* payload
123 | - 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).
124 | - You can find this under the main profile object, where you would find `supportedLocales` - the default / initial locale is under - `defaultLocale`
125 |
126 | ### Voyager - Multilingual and Locales Support
127 | > LI seems to be making changes related to this; this section might not be 100% up-to-date.
128 |
129 | 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).
130 |
131 | 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`) 🙃
132 |
133 | 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:
134 |
135 | ```json
136 | {
137 | "firstName": "Алексе́й",
138 | "multiLocaleFirstName": {
139 | "ru_RU": "Алексе́й",
140 | "en_US": "Alexey"
141 | }
142 | }
143 | ```
144 |
145 | ## LinkedIn TS Types
146 | 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.
147 |
--------------------------------------------------------------------------------
/docs/demo-bookmarklet.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/docs/demo-bookmarklet.gif
--------------------------------------------------------------------------------
/docs/demo-chrome_extension-social.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/docs/demo-chrome_extension-social.gif
--------------------------------------------------------------------------------
/docs/demo-chrome_extension.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/docs/demo-chrome_extension.gif
--------------------------------------------------------------------------------
/docs/multilingual-support.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/docs/multilingual-support.png
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | import { ResumeSchemaLegacy as _ResumeSchemaLegacy } from './jsonresume.schema.legacy';
2 | import { ResumeSchemaStable as _ResumeSchemaStable, ResumeSchemaBeyondSpec as _ResumeSchemaBeyondSpec } from './jsonresume.schema.latest';
3 |
4 | declare global {
5 | interface GenObj {
6 | [k: string]: any;
7 | }
8 |
9 | // LI Types
10 |
11 | /**
12 | * Uniform Resource Name (URN) - very common throughout LinkedIn APIs
13 | * @example urn:li:collectionResponse:acb123...
14 | * @see https://docs.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
15 | */
16 | type LiUrn = string;
17 |
18 | /**
19 | * These are used throughout LI APIs, also referred to as "recipes" or "recipeTypes"
20 | * @example com.linkedin.voyager.identity.profile.Profile
21 | */
22 | type LiTypeStr = `com.linkedin.${string}`;
23 |
24 | interface LiPaging {
25 | count: number;
26 | start: number;
27 | total?: number;
28 | $recipeTypes?: LiTypeStr[];
29 | // I've never actually seen this property populated...
30 | // This is probably actually `Array`
31 | links?: string[];
32 | }
33 |
34 | interface LiEntity {
35 | $type: LiTypeStr;
36 | entityUrn: LiUrn;
37 | objectUrn?: LiUrn;
38 | [key: string]: any;
39 | paging?: LiPaging;
40 | }
41 |
42 | interface LiResponse {
43 | data: {
44 | $type: LiTypeStr;
45 | paging?: LiPaging;
46 | // This is kind of a ToC, where each string corresponds to an entityUrn ID of an entity in `included`
47 | '*elements'?: string[];
48 | // Any number of fields can be included, especially if this is a "flat" response (when `included` is empty, and all entity data is directly in `data`)
49 | [k: string]: GenObj | string | boolean;
50 | } & Partial;
51 | included: LiEntity[];
52 | meta?: {
53 | microSchema?: {
54 | isGraphQL: boolean;
55 | types: {
56 | [key: string]: {
57 | baseType: LiTypeStr;
58 | fields: GenObj;
59 | };
60 | };
61 | };
62 | };
63 | }
64 |
65 | interface LiSupportedLocale {
66 | country: string;
67 | language: string;
68 | $type: LiTypeStr;
69 | }
70 |
71 | /**
72 | * ! WARNING ! - Month and day are *not* zero-indexed. E.g. "Feb" is represented as `2`, not `1`
73 | */
74 | interface LiDate {
75 | month?: number;
76 | day?: number;
77 | year?: number;
78 | }
79 |
80 | interface LiPhoneNum {
81 | number: string;
82 | type: 'MOBILE' | 'WORK' | 'HOME' | string;
83 | }
84 |
85 | interface LiWebsite {
86 | url: string;
87 | type: {
88 | $type: LiTypeStr;
89 | category: string;
90 | };
91 | $type: LiTypeStr;
92 | }
93 |
94 | type TocValModifier = (tocVal: string | string[]) => string | string[];
95 |
96 | interface InternalDb {
97 | tableOfContents: LiResponse['data'];
98 | entitiesByUrn: {
99 | [k: string]: LiEntity & { key: LiUrn };
100 | };
101 | entities: Array;
102 | // Methods
103 | getElementKeys: () => string[];
104 | getElements: () => Array;
105 | getValueByKey: (key: string | string[]) => LiEntity | undefined;
106 | getValuesByKey: (key: LiUrn | LiUrn[], optTocValModifier?: TocValModifier) => LiEntity[];
107 | getElementsByType: (typeStr: string | string[]) => LiEntity[];
108 | getElementByUrn: (urn: string) => LiEntity | undefined;
109 | /**
110 | * Get multiple elements by URNs
111 | * - Allows passing a single URN, for convenience if unsure if you have an array
112 | */
113 | getElementsByUrns: (urns: string[] | string) => LiEntity[];
114 | }
115 |
116 | interface LiProfileContactInfoResponse extends LiResponse {
117 | data: LiResponse['data'] &
118 | Partial & {
119 | $type: 'com.linkedin.voyager.identity.profile.ProfileContactInfo';
120 | address: string;
121 | birthDateOn: LiDate;
122 | birthdayVisibilitySetting: any;
123 | connectedAt: null | number;
124 | emailAddress: null | string;
125 | ims: any;
126 | interests: any;
127 | phoneNumbers: null | LiPhoneNum[];
128 | primaryTwitterHandle: null | string;
129 | twitterHandles: any[];
130 | weChatContactInfo: any;
131 | websites: null | LiWebsite[];
132 | };
133 | }
134 |
135 | /**
136 | * LI2JR Types
137 | */
138 | type CaptureResult = 'success' | 'fail' | 'incomplete' | 'empty';
139 |
140 | interface ParseProfileSchemaResultSummary {
141 | liResponse: LiResponse;
142 | profileInfoObj?: LiEntity;
143 | profileSrc: 'profileView' | 'dashFullProfileWithEntities';
144 | pageUrl: string;
145 | localeStr?: string;
146 | parseSuccess: boolean;
147 | sections: {
148 | basics: CaptureResult;
149 | languages: CaptureResult;
150 | attachments: CaptureResult;
151 | education: CaptureResult;
152 | work: CaptureResult;
153 | volunteer: CaptureResult;
154 | certificates: CaptureResult;
155 | skills: CaptureResult;
156 | projects: CaptureResult;
157 | awards: CaptureResult;
158 | publications: CaptureResult;
159 | };
160 | }
161 |
162 | type SchemaVersion = 'legacy' | 'stable' | 'beta';
163 |
164 | type ResumeSchemaLegacy = _ResumeSchemaLegacy;
165 | type ResumeSchemaStable = _ResumeSchemaStable;
166 | type ResumeSchemaBeyondSpec = _ResumeSchemaBeyondSpec;
167 | }
168 |
--------------------------------------------------------------------------------
/jsonresume.schema.latest.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Represents the current version(s) of the schema
3 | * - Currently v1.0.0
4 | * @see https://github.com/jsonresume/resume-schema/blob/v1.0.0/schema.json
5 | * - Permalink of above: https://github.com/jsonresume/resume-schema/blob/8a5b3982f8e5b9f8840398e162a6e0c418d023da/schema.json
6 | */
7 |
8 | // All of these imports are because the spec is the same for the sub-section in both stable and latest (this doc)
9 | import { Iso8601, Award, Location, Profile, Interest, Language, Reference, Skill, ResumeSchemaLegacy } from './jsonresume.schema.legacy.js';
10 |
11 | // Re-export
12 | export { Iso8601, Award, Location, Profile, Interest, Language, Reference, Skill };
13 |
14 | export interface Certificate {
15 | /**
16 | * e.g. Certified Kubernetes Administrator
17 | */
18 | name: string;
19 | /**
20 | * e.g. 1989-06-12
21 | */
22 | date?: Iso8601;
23 | /**
24 | * e.g. http://example.com
25 | */
26 | url?: string;
27 | /**
28 | * e.g. CNCF
29 | */
30 | issuer?: string;
31 | }
32 |
33 | export interface Basics {
34 | /**
35 | * e.g. thomas@gmail.com
36 | */
37 | email?: string;
38 | /**
39 | * URL (as per RFC 3986) to a image in JPEG or PNG format
40 | */
41 | image?: string;
42 | /**
43 | * e.g. Web Developer
44 | */
45 | label?: string;
46 | location?: Location;
47 | name?: string;
48 | /**
49 | * Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923
50 | */
51 | phone?: string;
52 | /**
53 | * Specify any number of social networks that you participate in
54 | */
55 | profiles?: Profile[];
56 | /**
57 | * Write a short 2-3 sentence biography about yourself
58 | */
59 | summary?: string;
60 | /**
61 | * URL (as per RFC 3986) to your website, e.g. personal homepage
62 | */
63 | url?: string;
64 | }
65 |
66 | export interface Education {
67 | /**
68 | * e.g. Arts
69 | */
70 | area?: string;
71 | /**
72 | * List notable courses/subjects
73 | */
74 | courses?: string[];
75 | endDate?: Iso8601;
76 | /**
77 | * grade point average, e.g. 3.67/4.0
78 | */
79 | score?: string;
80 | /**
81 | * e.g. Massachusetts Institute of Technology
82 | */
83 | institution?: string;
84 | startDate?: Iso8601;
85 | /**
86 | * e.g. Bachelor
87 | */
88 | studyType?: string;
89 | /**
90 | * e.g. https://www.mit.edu/
91 | */
92 | url?: string;
93 | }
94 |
95 | /**
96 | * The schema version and any other tooling configuration lives here
97 | */
98 | export interface Meta {
99 | /**
100 | * URL (as per RFC 3986) to latest version of this document
101 | */
102 | canonical?: string;
103 | /**
104 | * Using ISO 8601 with YYYY-MM-DDThh:mm:ss
105 | */
106 | lastModified?: string;
107 | /**
108 | * A version field which follows semver - e.g. v1.0.0
109 | */
110 | version?: string;
111 | }
112 |
113 | export interface Project {
114 | /**
115 | * Short summary of project. e.g. Collated works of 2017.
116 | */
117 | description?: string;
118 | endDate?: Iso8601;
119 | /**
120 | * Specify the relevant company/entity affiliations e.g. 'greenpeace', 'corporationXYZ'
121 | */
122 | entity?: string;
123 | /**
124 | * Specify multiple features
125 | */
126 | highlights?: string[];
127 | /**
128 | * Specify special elements involved
129 | */
130 | keywords?: string[];
131 | /**
132 | * e.g. The World Wide Web
133 | */
134 | name?: string;
135 | /**
136 | * Specify your role on this project or in company
137 | */
138 | roles?: string[];
139 | startDate?: Iso8601;
140 | /**
141 | * e.g. 'volunteering', 'presentation', 'talk', 'application', 'conference'
142 | */
143 | type?: string;
144 | /**
145 | * e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html
146 | */
147 | url?: string;
148 | }
149 |
150 | export type Publication = Omit & {
151 | /**
152 | * e.g. http://www.computer.org.example.com/csdl/mags/co/1996/10/rx069-abs.html
153 | */
154 | url?: string;
155 | };
156 |
157 | export type Volunteer = Omit & {
158 | /**
159 | * e.g. https://www.eff.org/
160 | */
161 | url?: string;
162 | };
163 |
164 | export interface Work {
165 | /**
166 | * e.g. Social Media Company
167 | */
168 | description?: string;
169 | endDate?: Iso8601;
170 | /**
171 | * Specify multiple accomplishments
172 | */
173 | highlights?: string[];
174 | /**
175 | * e.g. San Francisco, CA
176 | */
177 | location?: string;
178 | /**
179 | * e.g. Twitter
180 | */
181 | name?: string;
182 | /**
183 | * e.g. Software Engineer
184 | */
185 | position?: string;
186 | startDate?: Iso8601;
187 | /**
188 | * Give an overview of your responsibilities at the company
189 | */
190 | summary?: string;
191 | /**
192 | * e.g. https://twitter.com
193 | */
194 | url?: string;
195 | }
196 |
197 | export interface ResumeSchemaStable {
198 | /**
199 | * link to the version of the schema that can validate the resume
200 | */
201 | $schema?: string;
202 | /**
203 | * Specify any awards you have received throughout your professional career
204 | */
205 | awards?: Award[];
206 | basics?: Basics;
207 | certificates: Certificate[];
208 | education?: Education[];
209 | interests?: Interest[];
210 | /**
211 | * List any other languages you speak
212 | */
213 | languages?: Language[];
214 | /**
215 | * The schema version and any other tooling configuration lives here
216 | */
217 | meta?: Meta;
218 | /**
219 | * Specify career projects
220 | */
221 | projects?: Project[];
222 | /**
223 | * Specify your publications through your career
224 | */
225 | publications?: Publication[];
226 | /**
227 | * List references you have received
228 | */
229 | references?: Reference[];
230 | /**
231 | * List out your professional skill-set
232 | */
233 | skills?: Skill[];
234 | volunteer?: Volunteer[];
235 | work?: Work[];
236 | }
237 |
238 | /**
239 | * Currently even - nothing beyond v1
240 | */
241 | export interface ResumeSchemaBeyondSpec extends ResumeSchemaStable {}
242 |
--------------------------------------------------------------------------------
/jsonresume.schema.legacy.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Generated (and hand-modified) TS definition for JSON Resume Schema
3 | * - Locked to v0.0.16, @see https://github.com/joshuatz/linkedin-to-jsonresume/issues/33
4 | * @see https://github.com/jsonresume/resume-schema/blob/v0.0.16/schema.json
5 | * - Permalink of above: https://github.com/jsonresume/resume-schema/blob/a6d1ae5c990e3370de6ca305ef32477b9516391b/schema.json
6 | */
7 |
8 | /**
9 | * e.g. 2014-06-29
10 | * Pattern: ^([1-2][0-9]{3}-[0-1][0-9]-[0-3][0-9]|[1-2][0-9]{3}-[0-1][0-9]|[1-2][0-9]{3})$
11 | */
12 | export type Iso8601 = string;
13 |
14 | export interface Award {
15 | /**
16 | * e.g. Time Magazine
17 | */
18 | awarder?: string;
19 | /**
20 | * e.g. 1989-06-12
21 | */
22 | date?: Iso8601;
23 | /**
24 | * e.g. Received for my work with Quantum Physics
25 | */
26 | summary?: string;
27 | /**
28 | * e.g. One of the 100 greatest minds of the century
29 | */
30 | title?: string;
31 | }
32 |
33 | export interface Profile {
34 | /**
35 | * e.g. Facebook or Twitter
36 | */
37 | network?: string;
38 | /**
39 | * e.g. https://twitter.com/TwitterDev
40 | */
41 | url?: string;
42 | /**
43 | * e.g. TwitterDev
44 | */
45 | username?: string;
46 | }
47 |
48 | export interface Location {
49 | /**
50 | * To add multiple address lines, use \n
51 | * For example, 1234 Glücklichkeit Straße\nHinterhaus 5. Etage li.
52 | */
53 | address?: string;
54 | city?: string;
55 | /**
56 | * code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN
57 | */
58 | countryCode?: string;
59 | postalCode?: string;
60 | /**
61 | * The general region where you live. Can be a US state, or a province, for instance.
62 | */
63 | region?: string;
64 | }
65 |
66 | export interface Basics {
67 | /**
68 | * e.g. thomas@gmail.com
69 | */
70 | email?: string;
71 | /**
72 | * e.g. Web Developer
73 | */
74 | label?: string;
75 | location?: Location;
76 | name: string;
77 | /**
78 | * Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923
79 | */
80 | phone?: string;
81 | /**
82 | * URL (as per RFC 3986) to a picture in JPEG or PNG format
83 | */
84 | picture?: string;
85 | /**
86 | * Specify any number of social networks that you participate in
87 | */
88 | profiles?: Profile[];
89 | /**
90 | * Write a short 2-3 sentence biography about yourself
91 | */
92 | summary?: string;
93 | /**
94 | * URL (as per RFC 3986) to your website, e.g. personal homepage
95 | */
96 | website?: string;
97 | }
98 |
99 | export interface Education {
100 | /**
101 | * e.g. Arts
102 | */
103 | area?: string;
104 | /**
105 | * List notable courses/subjects
106 | */
107 | courses?: string[];
108 | /**
109 | * e.g. 2012-06-29
110 | */
111 | endDate?: Iso8601;
112 | /**
113 | * grade point average, e.g. 3.67/4.0
114 | */
115 | gpa?: string;
116 | /**
117 | * e.g. Massachusetts Institute of Technology
118 | */
119 | institution?: string;
120 | /**
121 | * e.g. 2014-06-29
122 | */
123 | startDate?: Iso8601;
124 | /**
125 | * e.g. Bachelor
126 | */
127 | studyType?: string;
128 | }
129 |
130 | export interface Interest {
131 | keywords?: string[];
132 | /**
133 | * e.g. Philosophy
134 | */
135 | name?: string;
136 | }
137 |
138 | export interface Language {
139 | /**
140 | * e.g. Fluent, Beginner
141 | */
142 | fluency?: string;
143 | /**
144 | * e.g. English, Spanish
145 | */
146 | language?: string;
147 | }
148 |
149 | export interface Publication {
150 | /**
151 | * e.g. The World Wide Web
152 | */
153 | name?: string;
154 | /**
155 | * e.g. IEEE, Computer Magazine
156 | */
157 | publisher?: string;
158 | /**
159 | * e.g. 1990-08-01
160 | */
161 | releaseDate?: Iso8601;
162 | /**
163 | * Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, HTML.
164 | */
165 | summary?: string;
166 | /**
167 | * e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html
168 | */
169 | website?: string;
170 | }
171 |
172 | export interface Reference {
173 | /**
174 | * e.g. Timothy Cook
175 | */
176 | name?: string;
177 | /**
178 | * e.g. Joe blogs was a great employee, who turned up to work at least once a week. He
179 | * exceeded my expectations when it came to doing nothing.
180 | */
181 | reference?: string;
182 | }
183 |
184 | export interface Skill {
185 | /**
186 | * List some keywords pertaining to this skill
187 | */
188 | keywords?: string[];
189 | /**
190 | * e.g. Master
191 | */
192 | level?: string;
193 | /**
194 | * e.g. Web Development
195 | */
196 | name?: string;
197 | }
198 |
199 | export interface Volunteer {
200 | /**
201 | * e.g. 2012-06-29
202 | */
203 | endDate?: Iso8601;
204 | /**
205 | * Specify multiple accomplishments
206 | */
207 | highlights?: string[];
208 | /**
209 | * e.g. EFF
210 | */
211 | organization?: string;
212 | /**
213 | * e.g. Software Engineer
214 | */
215 | position?: string;
216 | /**
217 | * resume.json uses the ISO 8601 date standard e.g. 2014-06-29
218 | */
219 | startDate?: Iso8601;
220 | /**
221 | * Give an overview of your responsibilities at the company
222 | */
223 | summary?: string;
224 | /**
225 | * e.g. https://www.eff.org/
226 | */
227 | website?: string;
228 | }
229 |
230 | export interface Work {
231 | /**
232 | * e.g. Twitter
233 | */
234 | company?: string;
235 | /**
236 | * e.g. 2012-06-29
237 | */
238 | endDate?: Iso8601;
239 | /**
240 | * Specify multiple accomplishments
241 | */
242 | highlights?: string[];
243 | /**
244 | * e.g. Software Engineer
245 | */
246 | position?: string;
247 | /**
248 | * resume.json uses the ISO 8601 date standard e.g. 2014-06-29
249 | */
250 | startDate?: Iso8601;
251 | /**
252 | * Give an overview of your responsibilities at the company
253 | */
254 | summary?: string;
255 | /**
256 | * e.g. https://twitter.com
257 | */
258 | website?: string;
259 | }
260 |
261 | export interface ResumeSchemaLegacy {
262 | /**
263 | * Specify any awards you have received throughout your professional career
264 | */
265 | awards?: Award[];
266 | basics?: Basics;
267 | education?: Education[];
268 | interests?: Interest[];
269 | /**
270 | * List any other languages you speak
271 | */
272 | languages?: Language[];
273 | /**
274 | * Specify your publications through your career
275 | */
276 | publications?: Publication[];
277 | /**
278 | * List references you have received
279 | */
280 | references?: Reference[];
281 | /**
282 | * List out your professional skill-set
283 | */
284 | skills?: Skill[];
285 | volunteer?: Volunteer[];
286 | work?: Work[];
287 | }
288 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "linkedin-to-json-resume-exporter",
3 | "version": "3.2.3",
4 | "description": "Browser tool to grab details from your open LinkedIn profile page and export to JSON Resume Schema",
5 | "private": true,
6 | "main": "src/main.js",
7 | "scripts": {
8 | "test": "\"Error: no test specified\" && exit 1",
9 | "lint": "eslint \"**/*.{js,ts}\"",
10 | "lint:fix": "eslint \"**/*.{js,ts}\" --fix",
11 | "type-check": "tsc --noEmit",
12 | "babel": "babel src --out-dir build",
13 | "webpack": "npx webpack --config webpack.prod.js",
14 | "webpack:debug": "npx webpack --config webpack.dev.js",
15 | "build:bookmarklet": "node ./build-scripts/prep-dirs.js && npm run webpack && node ./build-scripts/build-bookmarklet.js",
16 | "build:browserext": "node ./build-scripts/prep-dirs.js && npm run webpack && node ./build-scripts/prep-browserext.js",
17 | "build:browserext-debug": "node ./build-scripts/prep-dirs.js && npm run webpack:debug && node ./build-scripts/prep-browserext.js",
18 | "package:browserext": "node ./build-scripts/prep-dirs.js && npm run build:browserext && node ./build-scripts/package-browserext.js",
19 | "copy:debug-js-win": "npm run build:browserext-debug && less build/main.js | CLIP"
20 | },
21 | "author": {
22 | "name": "Joshua Tzucker",
23 | "url": "https://joshuatz.com/?utm_source=package"
24 | },
25 | "license": "MIT",
26 | "devDependencies": {
27 | "@babel/cli": "^7.17.6",
28 | "@babel/core": "^7.17.9",
29 | "@babel/plugin-transform-runtime": "^7.17.0",
30 | "@babel/preset-env": "^7.16.11",
31 | "@babel/runtime": "^7.17.9",
32 | "@types/archiver": "^5.3.1",
33 | "@types/chrome": "0.0.180",
34 | "@types/fs-extra": "^9.0.13",
35 | "@typescript-eslint/eslint-plugin": "^5.18.0",
36 | "@typescript-eslint/parser": "^5.18.0",
37 | "archiver": "^5.3.0",
38 | "babel-loader": "^8.2.4",
39 | "bookmarklet": "",
40 | "eslint": "^8.13.0",
41 | "eslint-config-airbnb-base": "^15.0.0",
42 | "eslint-config-prettier": "^8.5.0",
43 | "eslint-import-resolver-typescript": "^2.7.1",
44 | "eslint-plugin-import": "^2.26.0",
45 | "eslint-plugin-prettier": "^4.0.0",
46 | "fs-extra": "^10.0.1",
47 | "prettier": "^2.6.2",
48 | "replace": "^1.2.1",
49 | "typescript": "^4.6.3",
50 | "webpack": "^5.72.0",
51 | "webpack-cli": "^4.9.2"
52 | },
53 | "babel": {
54 | "plugins": [
55 | "@babel/transform-runtime"
56 | ],
57 | "presets": [
58 | [
59 | "@babel/preset-env",
60 | {
61 | "targets": {
62 | "browsers": "defaults"
63 | },
64 | "include": [
65 | "transform-regenerator"
66 | ]
67 | }
68 | ]
69 | ]
70 | },
71 | "dependencies": {
72 | "@dan/vcards": "^2.10.0"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/src/.gitkeep
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @preserve
3 | * @author Joshua Tzucker
4 | * @license MIT
5 | * WARNING: This tool is not affiliated with LinkedIn in any manner. Intended use is to export your own profile data, and you, as the user, are responsible for using it within the terms and services set out by LinkedIn. I am not responsible for any misuse, or repercussions of said misuse.
6 | */
7 |
8 | // @ts-ignore
9 | import VCardsJS from '@dan/vcards';
10 | import { resumeJsonTemplateLegacy, resumeJsonTemplateStable, resumeJsonTemplateBetaPartial } from './templates';
11 | import { liSchemaKeys as _liSchemaKeys, liTypeMappings as _liTypeMappings } from './schema';
12 | import {
13 | getCookie,
14 | lazyCopy,
15 | liDateToJSDate,
16 | noNullOrUndef,
17 | parseStartDate,
18 | promptDownload,
19 | setQueryParams,
20 | urlToBase64,
21 | remapNestedLocale,
22 | companyLiPageFromCompanyUrn,
23 | parseAndAttachResumeDates
24 | } from './utilities';
25 |
26 | // ==Bookmarklet==
27 | // @name linkedin-to-jsonresume-bookmarklet
28 | // @author Joshua Tzucker
29 | // ==/Bookmarklet==
30 |
31 | // @ts-ignore
32 | window.LinkedinToResumeJson = (() => {
33 | // private
34 | /** @type {ResumeSchemaLegacy} */
35 | let _outputJsonLegacy = JSON.parse(JSON.stringify(resumeJsonTemplateLegacy));
36 | /** @type {ResumeSchemaStable} */
37 | let _outputJsonStable = JSON.parse(JSON.stringify(resumeJsonTemplateStable));
38 | /** @type {ResumeSchemaBeyondSpec} */
39 | let _outputJsonBetaPartial = JSON.parse(JSON.stringify(resumeJsonTemplateBetaPartial));
40 | /** @type {string[]} */
41 | let _supportedLocales = [];
42 | /** @type {string} */
43 | let _defaultLocale = `en_US`;
44 | const _voyagerBase = 'https://www.linkedin.com/voyager/api';
45 | const _voyagerEndpoints = {
46 | following: '/identity/profiles/{profileId}/following',
47 | followingCompanies: '/identity/profiles/{profileId}/following?count=10&entityType=COMPANY&q=followedEntities',
48 | contactInfo: '/identity/profiles/{profileId}/profileContactInfo',
49 | basicAboutMe: '/me',
50 | advancedAboutMe: '/identity/profiles/{profileId}',
51 | fullProfileView: '/identity/profiles/{profileId}/profileView',
52 | fullSkills: '/identity/profiles/{profileId}/skillCategory',
53 | recommendations: '/identity/profiles/{profileId}/recommendations',
54 | dash: {
55 | profilePositionGroups: {
56 | path: '/identity/dash/profilePositionGroups?q=viewee&profileUrn=urn:li:fsd_profile:{profileUrnId}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroup-50',
57 | template: '/identity/dash/profilePositionGroups?q=viewee&profileUrn=urn:li:fsd_profile:{profileUrnId}&decorationId={decorationId}',
58 | recipe: 'com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroup'
59 | },
60 | fullProfile: {
61 | path: '/identity/dash/profiles?q=memberIdentity&memberIdentity={profileId}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-93',
62 | template: '/identity/dash/profiles?q=memberIdentity&memberIdentity={profileId}&decorationId={decorationId}',
63 | recipe: 'com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities'
64 | },
65 | profileVolunteerExperiences: '/identity/dash/profileVolunteerExperiences?q=viewee&profileUrn=urn:li:fsd_profile:{profileUrnId}'
66 | }
67 | };
68 | let _scrolledToLoad = false;
69 | const _toolPrefix = 'jtzLiToResumeJson';
70 | const _stylesInjected = false;
71 |
72 | /**
73 | * Builds a mini-db out of a LI schema obj
74 | * @param {LiResponse} schemaJson
75 | * @returns {InternalDb}
76 | */
77 | function buildDbFromLiSchema(schemaJson) {
78 | /**
79 | * In LI response, `response.data.*elements _can_ contain an array of ordered URNs
80 | */
81 | const possibleResponseDirectUrnArrayKeys = ['*elements', 'elements'];
82 | /** @type {InternalDb['entitiesByUrn']} */
83 | const entitiesByUrn = {};
84 | /** @type {InternalDb['entities']} */
85 | const entities = [];
86 |
87 | // `response.included` often has a sort order that does *not* match the page. If `response.data.*elements` is
88 | // included, we should try to reorder `included` before passing it through to other parts of the DB, so as to
89 | // preserve intended sort order as much as possible
90 | for (let x = 0; x < possibleResponseDirectUrnArrayKeys.length; x++) {
91 | /** @type {string[] | undefined} */
92 | const elementsUrnArr = schemaJson.data[possibleResponseDirectUrnArrayKeys[x]];
93 | if (Array.isArray(elementsUrnArr)) {
94 | const sorted = [];
95 | elementsUrnArr.forEach((urn) => {
96 | const matching = schemaJson.included.find((e) => e.entityUrn === urn);
97 | if (matching) {
98 | sorted.push(matching);
99 | }
100 | });
101 | // Put any remaining elements in last
102 | sorted.push(...schemaJson.included.filter((e) => !elementsUrnArr.includes(e.entityUrn)));
103 | schemaJson.included = sorted;
104 | break;
105 | }
106 | }
107 |
108 | // Copy all `included` entities to internal DB arrays, which might or might not be sorted at this point
109 | for (let x = 0; x < schemaJson.included.length; x++) {
110 | /** @type {LiEntity & {key: string}} */
111 | const currRow = {
112 | key: schemaJson.included[x].entityUrn,
113 | ...schemaJson.included[x]
114 | };
115 | entitiesByUrn[currRow.entityUrn] = currRow;
116 | entities.push(currRow);
117 | }
118 |
119 | /** @type {Partial & Pick} */
120 | const db = {
121 | entitiesByUrn,
122 | entities,
123 | tableOfContents: schemaJson.data
124 | };
125 | delete db.tableOfContents['included'];
126 | /**
127 | * Get list of element keys (if applicable)
128 | * - Certain LI responses will contain a list of keys that correspond to
129 | * entities via an URN mapping. I think these are in cases where the response
130 | * is returning a mix of entities, both directly related to the inquiry and
131 | * tangentially (e.g. `book` entities and `author` entities, return in the
132 | * same response). In this case, `elements` are those that directly satisfy
133 | * the request, and the other items in `included` are those related
134 | *
135 | * Order provided by LI in HTTP response is passed through, if exists
136 | * @returns {string[]}
137 | */
138 | db.getElementKeys = function getElementKeys() {
139 | for (let x = 0; x < possibleResponseDirectUrnArrayKeys.length; x++) {
140 | const key = possibleResponseDirectUrnArrayKeys[x];
141 | const matchingArr = db.tableOfContents[key];
142 | if (Array.isArray(matchingArr)) {
143 | return matchingArr;
144 | }
145 | }
146 | return [];
147 | };
148 | // Same as above (getElementKeys), but returns elements themselves
149 | db.getElements = function getElements() {
150 | return db.getElementKeys().map((key) => {
151 | return db.entitiesByUrn[key];
152 | });
153 | };
154 | /**
155 | * Get all elements that match type.
156 | * WARNING: Since this gets elements directly by simply iterating through all results, not via ToC, order of entities returned is simply whatever order LI provides them in the response. Not guaranteed to be in order! Use a ToC approach if you need ordered results.
157 | * @param {string | string[]} typeStr - Type, e.g. `$com.linkedin...`
158 | * @returns {LiEntity[]}
159 | */
160 | db.getElementsByType = function getElementByType(typeStr) {
161 | const typeStrArr = Array.isArray(typeStr) ? typeStr : [typeStr];
162 | return db.entities.filter((entity) => typeStrArr.indexOf(entity['$type']) !== -1);
163 | };
164 | /**
165 | * Get an element by URN
166 | * @param {string} urn - URN identifier
167 | * @returns {LiEntity | undefined}
168 | */
169 | db.getElementByUrn = function getElementByUrn(urn) {
170 | return db.entitiesByUrn[urn];
171 | };
172 | db.getElementsByUrns = function getElementsByUrns(urns) {
173 | if (typeof urns === 'string') {
174 | urns = [urns];
175 | }
176 | return Array.isArray(urns) ? urns.map((urn) => db.entitiesByUrn[urn]) : [];
177 | };
178 | // Only meant for 1:1 lookups; will return first match, if more than one
179 | // key provided. Usually returns a "view" (kind of a collection)
180 | db.getValueByKey = function getValueByKey(key) {
181 | const keyArr = Array.isArray(key) ? key : [key];
182 | for (let x = 0; x < keyArr.length; x++) {
183 | const foundVal = db.entitiesByUrn[db.tableOfContents[keyArr[x]]];
184 | if (foundVal) {
185 | return foundVal;
186 | }
187 | }
188 | return undefined;
189 | };
190 | // This, opposed to getValuesByKey, allow for multi-depth traversal
191 | /**
192 | * @type {InternalDb['getValuesByKey']}
193 | */
194 | db.getValuesByKey = function getValuesByKey(key, optTocValModifier) {
195 | /** @type {LiEntity[]} */
196 | const values = [];
197 | if (Array.isArray(key)) {
198 | return values.concat(
199 | ...key.map((k) => {
200 | return this.getValuesByKey(k, optTocValModifier);
201 | })
202 | );
203 | }
204 | let tocVal = this.tableOfContents[key];
205 | if (typeof optTocValModifier === 'function') {
206 | tocVal = optTocValModifier(tocVal);
207 | }
208 | // tocVal will usually be a single string that is a key to another lookup. In rare cases, it is an array of direct keys
209 | let matchingDbIndexes = [];
210 | // Array of direct keys to sub items
211 | if (Array.isArray(tocVal)) {
212 | matchingDbIndexes = tocVal;
213 | }
214 | // String pointing to sub item
215 | else if (tocVal) {
216 | const subToc = this.entitiesByUrn[tocVal];
217 | // Needs secondary lookup if has elements property with list of keys pointing to other sub items
218 | if (subToc['*elements'] && Array.isArray(subToc['*elements'])) {
219 | matchingDbIndexes = subToc['*elements'];
220 | }
221 | // Sometimes they use 'elements' instead of '*elements"...
222 | else if (subToc['elements'] && Array.isArray(subToc['elements'])) {
223 | matchingDbIndexes = subToc['elements'];
224 | } else {
225 | // The object itself should be the return row
226 | values.push(subToc);
227 | }
228 | }
229 | for (let x = 0; x < matchingDbIndexes.length; x++) {
230 | if (typeof this.entitiesByUrn[matchingDbIndexes[x]] !== 'undefined') {
231 | values.push(this.entitiesByUrn[matchingDbIndexes[x]]);
232 | }
233 | }
234 | return values;
235 | };
236 | // @ts-ignore
237 | return db;
238 | }
239 |
240 | /**
241 | * Gets the profile ID from embedded (or api returned) Li JSON Schema
242 | * @param {LiResponse} jsonSchema
243 | * @returns {string} profileId
244 | */
245 | function getProfileIdFromLiSchema(jsonSchema) {
246 | let profileId = '';
247 | // miniprofile is not usually in the TOC, nor does its entry have an entityUrn for looking up (it has objectUrn), so best solution is just to iterate through all entries checking for match.
248 | if (jsonSchema.included && Array.isArray(jsonSchema.included)) {
249 | for (let x = 0; x < jsonSchema.included.length; x++) {
250 | const currEntity = jsonSchema.included[x];
251 | // Test for miniProfile match
252 | if (typeof currEntity['publicIdentifier'] === 'string') {
253 | profileId = currEntity.publicIdentifier;
254 | }
255 | }
256 | }
257 | return profileId.toString();
258 | }
259 |
260 | /**
261 | * Push a new skill to the resume object
262 | * @param {string} skillName
263 | */
264 | function pushSkill(skillName) {
265 | // Try to prevent duplicate skills
266 | // Both legacy and stable use same spec
267 | const skillNames = _outputJsonLegacy.skills.map((skill) => skill.name);
268 | if (skillNames.indexOf(skillName) === -1) {
269 | /** @type {ResumeSchemaLegacy['skills'][0]} */
270 | const formattedSkill = {
271 | name: skillName,
272 | level: '',
273 | keywords: []
274 | };
275 | _outputJsonLegacy.skills.push(formattedSkill);
276 | _outputJsonStable.skills.push(formattedSkill);
277 | }
278 | }
279 |
280 | /**
281 | * Parse a LI education object and push the parsed education entry to Resume
282 | * @param {LiEntity} educationObj
283 | * @param {InternalDb} db
284 | * @param {LinkedinToResumeJson} instance
285 | */
286 | function parseAndPushEducation(educationObj, db, instance) {
287 | const _this = instance;
288 | const edu = educationObj;
289 | /** @type {ResumeSchemaLegacy['education'][0]} */
290 | const parsedEdu = {
291 | institution: noNullOrUndef(edu.schoolName),
292 | area: noNullOrUndef(edu.fieldOfStudy),
293 | studyType: noNullOrUndef(edu.degreeName),
294 | startDate: '',
295 | endDate: '',
296 | gpa: noNullOrUndef(edu.grade),
297 | courses: []
298 | };
299 | parseAndAttachResumeDates(parsedEdu, edu);
300 | if (Array.isArray(edu.courses)) {
301 | // Lookup course names
302 | edu.courses.forEach((courseKey) => {
303 | const courseInfo = db.entitiesByUrn[courseKey];
304 | if (courseInfo) {
305 | parsedEdu.courses.push(`${courseInfo.number} - ${courseInfo.name}`);
306 | } else {
307 | _this.debugConsole.warn('could not find course:', courseKey);
308 | }
309 | });
310 | } else {
311 | // new version (Dash) of education <--> course relationship
312 | // linked on "union" field, instead of directly, so have to iterate
313 | db.getElementsByType(_liTypeMappings.courses.types).forEach((c) => {
314 | if (c.occupationUnion && c.occupationUnion.profileEducation) {
315 | if (c.occupationUnion.profileEducation === edu.entityUrn) {
316 | // union joined!
317 | parsedEdu.courses.push(`${c.number} - ${c.name}`);
318 | }
319 | }
320 | });
321 | }
322 | // Push to final json
323 | _outputJsonLegacy.education.push(parsedEdu);
324 | // Currently, same schema can be re-used; only difference is URL, which I'm not including
325 | _outputJsonStable.education.push({
326 | institution: noNullOrUndef(edu.schoolName),
327 | area: noNullOrUndef(edu.fieldOfStudy),
328 | studyType: noNullOrUndef(edu.degreeName),
329 | startDate: parsedEdu.startDate,
330 | endDate: parsedEdu.endDate,
331 | score: noNullOrUndef(edu.grade),
332 | courses: parsedEdu.courses
333 | });
334 | }
335 |
336 | /**
337 | * Parse a LI position object and push the parsed work entry to Resume
338 | * @param {LiEntity} positionObj
339 | * @param {InternalDb} db
340 | */
341 | function parseAndPushPosition(positionObj, db) {
342 | /** @type {ResumeSchemaLegacy['work'][0]} */
343 | const parsedWork = {
344 | company: positionObj.companyName,
345 | endDate: '',
346 | highlights: [],
347 | position: positionObj.title,
348 | startDate: '',
349 | summary: positionObj.description,
350 | website: companyLiPageFromCompanyUrn(positionObj['companyUrn'], db)
351 | };
352 | parseAndAttachResumeDates(parsedWork, positionObj);
353 | // Lookup company website
354 | if (positionObj.company && positionObj.company['*miniCompany']) {
355 | // @TODO - website is not in schema. Use voyager?
356 | // let companyInfo = db.data[position.company['*miniCompany']];
357 | }
358 |
359 | // Push to final json
360 | _outputJsonLegacy.work.push(parsedWork);
361 | _outputJsonStable.work.push({
362 | name: parsedWork.company,
363 | position: parsedWork.position,
364 | // This is description of company, not position
365 | // description: '',
366 | startDate: parsedWork.startDate,
367 | endDate: parsedWork.endDate,
368 | highlights: parsedWork.highlights,
369 | summary: parsedWork.summary,
370 | url: parsedWork.website,
371 | location: positionObj.locationName
372 | });
373 | }
374 |
375 | /**
376 | * Parse a LI volunteer experience object and push the parsed volunteer entry to Resume
377 | * @param {LiEntity} volunteerEntryObj
378 | * @param {InternalDb} db
379 | */
380 | function parseAndPushVolunteerExperience(volunteerEntryObj, db) {
381 | /** @type {ResumeSchemaLegacy['volunteer'][0]} */
382 | const parsedVolunteerWork = {
383 | organization: volunteerEntryObj.companyName,
384 | position: volunteerEntryObj.role,
385 | website: companyLiPageFromCompanyUrn(volunteerEntryObj['companyUrn'], db),
386 | startDate: '',
387 | endDate: '',
388 | summary: volunteerEntryObj.description,
389 | highlights: []
390 | };
391 | parseAndAttachResumeDates(parsedVolunteerWork, volunteerEntryObj);
392 |
393 | // Push to final json
394 | _outputJsonLegacy.volunteer.push(parsedVolunteerWork);
395 | _outputJsonStable.volunteer.push({
396 | ...lazyCopy(parsedVolunteerWork, ['website']),
397 | url: parsedVolunteerWork.website
398 | });
399 | }
400 |
401 | /**
402 | * Main parser for giant profile JSON block
403 | * @param {LinkedinToResumeJson} instance
404 | * @param {LiResponse} liResponse
405 | * @param {ParseProfileSchemaResultSummary['profileSrc']} [endpoint]
406 | * @returns {Promise}
407 | */
408 | async function parseProfileSchemaJSON(instance, liResponse, endpoint = 'profileView') {
409 | const _this = instance;
410 | const dash = endpoint === 'dashFullProfileWithEntities';
411 | let foundGithub = false;
412 | const foundPortfolio = false;
413 | /** @type {ParseProfileSchemaResultSummary} */
414 | const resultSummary = {
415 | liResponse,
416 | profileSrc: endpoint,
417 | pageUrl: null,
418 | parseSuccess: false,
419 | sections: {
420 | basics: 'fail',
421 | languages: 'fail',
422 | attachments: 'fail',
423 | education: 'fail',
424 | work: 'fail',
425 | volunteer: 'fail',
426 | certificates: 'fail',
427 | skills: 'fail',
428 | projects: 'fail',
429 | awards: 'fail',
430 | publications: 'fail'
431 | }
432 | };
433 | if (_this.preferLocale) {
434 | resultSummary.localeStr = _this.preferLocale;
435 | }
436 | try {
437 | // Build db object
438 | let db = buildDbFromLiSchema(liResponse);
439 |
440 | if (dash && !liResponse.data.hoisted) {
441 | // For FullProfileWithEntities, the main entry point of response
442 | // (response.data) points directly to the profile object, by URN
443 | // This profile obj itself holds the ToC to its content, instead
444 | // of having the ToC in the res.data section (like profileView)
445 | const profileObj = db.getElementByUrn(db.tableOfContents['*elements'][0]);
446 | if (!profileObj || !profileObj.firstName) {
447 | throw new Error('Could not extract nested profile object from Dash endpoint');
448 | }
449 | // To make this easier to work with lookup, we'll unpack the
450 | // profile view nested object BACK into the root (ToC), so
451 | // that subsequent lookups can be performed by key instead of type | recipe
452 | // This is critical for lookups that require precise ordering, preserved by ToCs
453 | /** @type {LiResponse} */
454 | const hoistedRes = {
455 | data: {
456 | ...liResponse.data,
457 | ...profileObj,
458 | // Set flag for future
459 | hoisted: true
460 | },
461 | included: liResponse.included
462 | };
463 | resultSummary.liResponse = hoistedRes;
464 | db = buildDbFromLiSchema(hoistedRes);
465 | }
466 |
467 | // Parse basics / profile
468 | let profileGrabbed = false;
469 | const profileObjs = dash ? [db.getElementByUrn(db.tableOfContents['*elements'][0])] : db.getValuesByKey(_liSchemaKeys.profile);
470 | instance.debugConsole.log({ profileObjs });
471 | profileObjs.forEach((profile) => {
472 | // There should only be one
473 | if (!profileGrabbed) {
474 | profileGrabbed = true;
475 | resultSummary.profileInfoObj = profile;
476 | /**
477 | * What the heck LI, this seems *intentionally* misleading
478 | * @type {LiSupportedLocale}
479 | */
480 | const localeObject = !dash ? profile.defaultLocale : profile.primaryLocale;
481 | /** @type {ResumeSchemaLegacy['basics']} */
482 | const formattedProfileObj = {
483 | name: `${profile.firstName} ${profile.lastName}`,
484 | summary: noNullOrUndef(profile.summary),
485 | label: noNullOrUndef(profile.headline),
486 | location: {
487 | countryCode: localeObject.country
488 | }
489 | };
490 | if (profile.address) {
491 | formattedProfileObj.location.address = noNullOrUndef(profile.address);
492 | } else if (profile.locationName) {
493 | formattedProfileObj.location.address = noNullOrUndef(profile.locationName);
494 | }
495 | _outputJsonLegacy.basics = {
496 | ..._outputJsonLegacy.basics,
497 | ...formattedProfileObj
498 | };
499 | _outputJsonStable.basics = {
500 | ..._outputJsonStable.basics,
501 | ...formattedProfileObj
502 | };
503 | /** @type {ResumeSchemaLegacy['languages'][0]} */
504 | const formatttedLang = {
505 | language: localeObject.language.toLowerCase() === 'en' ? 'English' : localeObject.language,
506 | fluency: 'Native Speaker'
507 | };
508 | _outputJsonLegacy.languages.push(formatttedLang);
509 | _outputJsonStable.languages.push(formatttedLang);
510 | resultSummary.sections.basics = 'success';
511 |
512 | // Also make sure instance defaultLocale is correct, while we are parsing profile
513 | const parsedLocaleStr = `${localeObject.language}_${localeObject.country}`;
514 | _defaultLocale = parsedLocaleStr;
515 | resultSummary.localeStr = parsedLocaleStr;
516 | }
517 | });
518 |
519 | // Parse languages (in _addition_ to the core profile language)
520 | /** @type {ResumeSchemaStable['languages']} */
521 | let languages = [];
522 | const languageElements = db.getValuesByKey(_liTypeMappings.languages.tocKeys);
523 | languageElements.forEach((languageMeta) => {
524 | /** @type {Record} */
525 | const liProficiencyEnumToJsonResumeStr = {
526 | NATIVE_OR_BILINGUAL: 'Native Speaker',
527 | FULL_PROFESSIONAL: 'Full Professional',
528 | EXPERT: 'Expert',
529 | ADVANCED: 'Advanced',
530 | PROFESSIONAL_WORKING: 'Professional Working',
531 | LIMITED_WORKING: 'Limited Working',
532 | INTERMEDIATE: 'intermediate',
533 | BEGINNER: 'Beginner',
534 | ELEMENTARY: 'Elementary'
535 | };
536 | const liProficiency = typeof languageMeta.proficiency === 'string' ? languageMeta.proficiency.toUpperCase() : undefined;
537 | if (liProficiency && liProficiency in liProficiencyEnumToJsonResumeStr) {
538 | languages.push({
539 | fluency: liProficiencyEnumToJsonResumeStr[liProficiency],
540 | language: languageMeta.name
541 | });
542 | }
543 | });
544 | // Merge with main profile language, while preventing duplicate
545 | languages = [
546 | ..._outputJsonStable.languages.filter((e) => {
547 | return !languages.find((l) => l.language === e.language);
548 | }),
549 | ...languages
550 | ];
551 | _outputJsonLegacy.languages = languages;
552 | _outputJsonStable.languages = languages;
553 | resultSummary.sections.languages = languages.length ? 'success' : 'empty';
554 |
555 | // Parse attachments / portfolio links
556 | const attachments = db.getValuesByKey(_liTypeMappings.attachments.tocKeys);
557 | attachments.forEach((attachment) => {
558 | let captured = false;
559 | const url = attachment.data.url || attachment.data.Url;
560 | if (attachment.providerName === 'GitHub' || /github\.com/gim.test(url)) {
561 | const usernameMatch = /github\.com\/([^\/\?]+)[^\/]+$/gim.exec(url);
562 | if (usernameMatch && !foundGithub) {
563 | foundGithub = true;
564 | captured = true;
565 | const formattedProfile = {
566 | network: 'GitHub',
567 | username: usernameMatch[1],
568 | url
569 | };
570 | _outputJsonLegacy.basics.profiles.push(formattedProfile);
571 | _outputJsonStable.basics.profiles.push(formattedProfile);
572 | }
573 | }
574 | // Since most people put potfolio as first link, guess that it will be
575 | if (!captured && !foundPortfolio) {
576 | captured = true;
577 | _outputJsonLegacy.basics.website = url;
578 | _outputJsonStable.basics.url = url;
579 | }
580 | // Finally, put in projects if not yet categorized
581 | if (!captured) {
582 | captured = true;
583 | _outputJsonStable.projects = _outputJsonStable.projects || [];
584 | _outputJsonStable.projects.push({
585 | name: attachment.title || attachment.mediaTitle,
586 | startDate: '',
587 | endDate: '',
588 | description: attachment.description || attachment.mediaDescription,
589 | url
590 | });
591 | }
592 | });
593 | resultSummary.sections.attachments = attachments.length ? 'success' : 'empty';
594 |
595 | // Parse education
596 | let allEducationCanBeCaptured = true;
597 | // educationView contains both paging data, and list of child elements
598 | const educationView = db.getValueByKey(_liTypeMappings.education.tocKeys);
599 | if (educationView.paging) {
600 | const { paging } = educationView;
601 | allEducationCanBeCaptured = paging.start + paging.count >= paging.total;
602 | }
603 | if (allEducationCanBeCaptured) {
604 | const educationEntries = db.getValuesByKey(_liTypeMappings.education.tocKeys);
605 | educationEntries.forEach((edu) => {
606 | parseAndPushEducation(edu, db, _this);
607 | });
608 | _this.debugConsole.log(`All education positions captured directly from profile result.`);
609 | resultSummary.sections.education = 'success';
610 | } else {
611 | _this.debugConsole.warn(`Education positions in profile are truncated.`);
612 | resultSummary.sections.education = 'incomplete';
613 | }
614 |
615 | // Parse work
616 | // First, check paging data
617 | let allWorkCanBeCaptured = true;
618 | // Work can be grouped in multiple ways - check all
619 | // this is [positionView, positionGroupView]
620 | const views = [_liTypeMappings.workPositionGroups.tocKeys, _liTypeMappings.workPositions.tocKeys].map(db.getValueByKey);
621 | for (let x = 0; x < views.length; x++) {
622 | const view = views[x];
623 | if (view && view.paging) {
624 | const { paging } = view;
625 | if (paging.start + paging.count >= paging.total !== true) {
626 | allWorkCanBeCaptured = false;
627 | break;
628 | }
629 | }
630 | }
631 | if (allWorkCanBeCaptured) {
632 | _this.getWorkPositions(db).forEach((position) => {
633 | parseAndPushPosition(position, db);
634 | });
635 | _this.debugConsole.log(`All work positions captured directly from profile result.`);
636 | resultSummary.sections.work = 'success';
637 | } else {
638 | _this.debugConsole.warn(`Work positions in profile are truncated.`);
639 | resultSummary.sections.work = 'incomplete';
640 | }
641 |
642 | // Parse volunteer experience
643 | let allVolunteerCanBeCaptured = true;
644 | const volunteerView = db.getValueByKey([..._liTypeMappings.volunteerWork.tocKeys]);
645 | if (volunteerView.paging) {
646 | const { paging } = volunteerView;
647 | allVolunteerCanBeCaptured = paging.start + paging.count >= paging.total;
648 | }
649 | if (allVolunteerCanBeCaptured) {
650 | const volunteerEntries = db.getValuesByKey(_liTypeMappings.volunteerWork.tocKeys);
651 | volunteerEntries.forEach((volunteering) => {
652 | parseAndPushVolunteerExperience(volunteering, db);
653 | });
654 | resultSummary.sections.volunteer = volunteerEntries.length ? 'success' : 'empty';
655 | } else {
656 | _this.debugConsole.warn('Volunteer entries in profile are truncated');
657 | resultSummary.sections.volunteer = 'incomplete';
658 | }
659 |
660 | /** @type {ResumeSchemaBeyondSpec['certificates']} */
661 | const certificates = [];
662 | db.getValuesByKey(_liTypeMappings.certificates.tocKeys).forEach((cert) => {
663 | /** @type {ResumeSchemaBeyondSpec['certificates'][0]} */
664 | const certObj = {
665 | name: cert.name,
666 | issuer: cert.authority
667 | };
668 | parseAndAttachResumeDates(certObj, cert);
669 | if (typeof cert.url === 'string' && cert.url) {
670 | certObj.url = cert.url;
671 | }
672 | certificates.push(certObj);
673 | });
674 | resultSummary.sections.certificates = certificates.length ? 'success' : 'empty';
675 | _outputJsonStable.certificates = certificates;
676 |
677 | // Parse skills
678 | /** @type {string[]} */
679 | const skillArr = [];
680 | db.getValuesByKey(_liTypeMappings.skills.tocKeys).forEach((skill) => {
681 | skillArr.push(skill.name);
682 | });
683 | document.querySelectorAll('span[class*="skill-category-entity"][class*="name"]').forEach((skillNameElem) => {
684 | // @ts-ignore
685 | const skillName = skillNameElem.innerText;
686 | if (!skillArr.includes(skillName)) {
687 | skillArr.push(skillName);
688 | }
689 | });
690 | skillArr.forEach((skillName) => {
691 | pushSkill(skillName);
692 | });
693 | resultSummary.sections.skills = skillArr.length ? 'success' : 'empty';
694 |
695 | // Parse projects
696 | _outputJsonStable.projects = _outputJsonStable.projects || [];
697 | db.getValuesByKey(_liTypeMappings.projects.tocKeys).forEach((project) => {
698 | const parsedProject = {
699 | name: project.title,
700 | startDate: '',
701 | summary: project.description,
702 | url: project.url
703 | };
704 | parseAndAttachResumeDates(parsedProject, project);
705 | _outputJsonStable.projects.push(parsedProject);
706 | });
707 | resultSummary.sections.projects = _outputJsonStable.projects.length ? 'success' : 'empty';
708 |
709 | // Parse awards
710 | const awardEntries = db.getValuesByKey(_liTypeMappings.awards.tocKeys);
711 | awardEntries.forEach((award) => {
712 | /** @type {ResumeSchemaLegacy['awards'][0]} */
713 | const parsedAward = {
714 | title: award.title,
715 | date: '',
716 | awarder: award.issuer,
717 | summary: noNullOrUndef(award.description)
718 | };
719 | // profileView vs dash key
720 | const issueDateObject = award.issueDate || award.issuedOn;
721 | if (issueDateObject && typeof issueDateObject === 'object') {
722 | parsedAward.date = parseStartDate(issueDateObject);
723 | }
724 | _outputJsonLegacy.awards.push(parsedAward);
725 | _outputJsonStable.awards.push(parsedAward);
726 | });
727 | resultSummary.sections.awards = awardEntries.length ? 'success' : 'empty';
728 |
729 | // Parse publications
730 | const publicationEntries = db.getValuesByKey(_liTypeMappings.publications.tocKeys);
731 | publicationEntries.forEach((publication) => {
732 | /** @type {ResumeSchemaLegacy['publications'][0]} */
733 | const parsedPublication = {
734 | name: publication.name,
735 | publisher: publication.publisher,
736 | releaseDate: '',
737 | website: noNullOrUndef(publication.url),
738 | summary: noNullOrUndef(publication.description)
739 | };
740 | // profileView vs dash key
741 | const publicationDateObj = publication.date || publication.publishedOn;
742 | if (publicationDateObj && typeof publicationDateObj === 'object' && typeof publicationDateObj.year !== 'undefined') {
743 | parsedPublication.releaseDate = parseStartDate(publicationDateObj);
744 | }
745 | _outputJsonLegacy.publications.push(parsedPublication);
746 | _outputJsonStable.publications.push({
747 | ...lazyCopy(parsedPublication, ['website']),
748 | url: parsedPublication.website
749 | });
750 | });
751 | resultSummary.sections.publications = publicationEntries.length ? 'success' : 'empty';
752 |
753 | if (_this.debug) {
754 | console.group(`parseProfileSchemaJSON complete: ${document.location.pathname}`);
755 | console.log({
756 | db,
757 | _outputJsonLegacy,
758 | _outputJsonStable,
759 | resultSummary
760 | });
761 | console.groupEnd();
762 | }
763 |
764 | _this.parseSuccess = true;
765 | resultSummary.parseSuccess = true;
766 | resultSummary.pageUrl = _this.getUrlWithoutQuery();
767 | } catch (e) {
768 | if (_this.debug) {
769 | console.group('Error parsing profile schema');
770 | console.log(e);
771 | console.log('Instance');
772 | console.log(_this);
773 | console.groupEnd();
774 | }
775 | resultSummary.parseSuccess = false;
776 | }
777 | return resultSummary;
778 | }
779 |
780 | /**
781 | * Constructor
782 | * @param {boolean} [OPT_debug] - Debug Mode?
783 | * @param {boolean} [OPT_preferApi] - Prefer Voyager API, rather than DOM scrape?
784 | * @param {boolean} [OPT_getFullSkills] - Retrieve full skills (behind additional API endpoint), rather than just basics
785 | */
786 | function LinkedinToResumeJson(OPT_debug, OPT_preferApi, OPT_getFullSkills) {
787 | const _this = this;
788 | this.profileId = this.getProfileId();
789 | /** @type {string | null} */
790 | this.profileUrnId = null;
791 | /** @type {ParseProfileSchemaResultSummary} */
792 | this.profileParseSummary = null;
793 | /** @type {string | null} */
794 | this.lastScannedLocale = null;
795 | /** @type {string | null} */
796 | this.preferLocale = null;
797 | _defaultLocale = this.getViewersLocalLang();
798 | this.scannedPageUrl = '';
799 | this.parseSuccess = false;
800 | this.getFullSkills = typeof OPT_getFullSkills === 'boolean' ? OPT_getFullSkills : true;
801 | this.preferApi = typeof OPT_preferApi === 'boolean' ? OPT_preferApi : true;
802 | this.debug = typeof OPT_debug === 'boolean' ? OPT_debug : false;
803 | this.debugConsole = {
804 | /** @type {(...args: any[]) => void} */
805 | log: (...args) => {
806 | if (_this.debug) {
807 | console.log.apply(null, args);
808 | }
809 | },
810 | /** @type {(...args: any[]) => void} */
811 | warn: (...args) => {
812 | if (_this.debug) {
813 | console.warn.apply(null, args);
814 | }
815 | },
816 | /** @type {(...args: any[]) => void} */
817 | error: (...args) => {
818 | if (_this.debug) {
819 | console.error.apply(null, args);
820 | }
821 | }
822 | };
823 |
824 | // Try to patch _voyagerEndpoints with correct paths, if possible
825 | // @TODO - get around extension sandbox issue (this doesn't really currently work unless executed outside extension)
826 | if (typeof window.require === 'function') {
827 | try {
828 | const recipeMap = window.require('deco-recipes/pillar-recipes/profile/recipes');
829 | ['profilePositionGroups', 'fullProfile'].forEach((_key) => {
830 | const key = /** @type {'profilePositionGroups' | 'fullProfile'} */ (_key);
831 | const decorationId = recipeMap[_voyagerEndpoints.dash[key].recipe];
832 | if (decorationId) {
833 | const oldPath = _voyagerEndpoints.dash[key].path;
834 | _voyagerEndpoints.dash[key].path = _voyagerEndpoints.dash[key].template.replace('{decorationId}', decorationId);
835 | this.debugConsole.log(`Patched voyagerEndpoint for ${key}; old = ${oldPath}, new = ${_voyagerEndpoints.dash[key].path}`);
836 | }
837 | });
838 | } catch (err) {
839 | console.error(`Error trying to patch _voyagerEndpoints, `, err);
840 | }
841 | } else {
842 | this.debugConsole.log(`Could not live-patch _voyagerEndpoints - missing window.require`);
843 | }
844 |
845 | // Force use of newer Dash endpoints when possible, for debugging
846 | this.preferDash = this.debug && /forceDashEndpoint=true/i.test(document.location.href);
847 | if (this.debug) {
848 | console.warn('LinkedinToResumeJson - DEBUG mode is ON');
849 | this.internals = {
850 | buildDbFromLiSchema,
851 | parseProfileSchemaJSON,
852 | _defaultLocale,
853 | _liSchemaKeys,
854 | _liTypeMappings,
855 | _voyagerEndpoints,
856 | output: {
857 | _outputJsonLegacy,
858 | _outputJsonStable,
859 | _outputJsonBetaPartial
860 | }
861 | };
862 | }
863 | }
864 |
865 | // Regular Methods
866 |
867 | LinkedinToResumeJson.prototype.parseEmbeddedLiSchema = async function parseEmbeddedLiSchema() {
868 | const _this = this;
869 | let doneWithBlockIterator = false;
870 | let foundSomeSchema = false;
871 | const possibleBlocks = document.querySelectorAll('code[id^="bpr-guid-"]');
872 | for (let x = 0; x < possibleBlocks.length; x++) {
873 | const currSchemaBlock = possibleBlocks[x];
874 | // Check if current schema block matches profileView
875 | if (/educationView/.test(currSchemaBlock.innerHTML) && /positionView/.test(currSchemaBlock.innerHTML)) {
876 | try {
877 | const embeddedJson = JSON.parse(currSchemaBlock.innerHTML);
878 | // Due to SPA nature, tag could actually be for profile other than the one currently open
879 | const desiredProfileId = _this.getProfileId();
880 | const schemaProfileId = getProfileIdFromLiSchema(embeddedJson);
881 | if (schemaProfileId === desiredProfileId) {
882 | doneWithBlockIterator = true;
883 | foundSomeSchema = true;
884 | // eslint-disable-next-line no-await-in-loop
885 | const profileParserResult = await parseProfileSchemaJSON(_this, embeddedJson);
886 | _this.debugConsole.log(`Parse from embedded schema, success = ${profileParserResult.parseSuccess}`);
887 | if (profileParserResult.parseSuccess) {
888 | this.profileParseSummary = profileParserResult;
889 | }
890 | } else {
891 | _this.debugConsole.log(`Valid schema found, but schema profile id of "${schemaProfileId}" does not match desired profile ID of "${desiredProfileId}".`);
892 | }
893 | } catch (e) {
894 | if (_this.debug) {
895 | throw e;
896 | }
897 | _this.debugConsole.warn('Could not parse embedded schema!', e);
898 | }
899 | }
900 | if (doneWithBlockIterator) {
901 | _this.parseSuccess = true;
902 | break;
903 | }
904 | }
905 | if (!foundSomeSchema) {
906 | _this.debugConsole.warn('Failed to find any embedded schema blocks!');
907 | }
908 | };
909 |
910 | // This should be called every time
911 | LinkedinToResumeJson.prototype.parseBasics = function parseBasics() {
912 | this.profileId = this.getProfileId();
913 | const formattedProfile = {
914 | network: 'LinkedIn',
915 | username: this.profileId,
916 | url: `https://www.linkedin.com/in/${this.profileId}/`
917 | };
918 | _outputJsonLegacy.basics.profiles.push(formattedProfile);
919 | _outputJsonStable.basics.profiles.push(formattedProfile);
920 | };
921 |
922 | LinkedinToResumeJson.prototype.parseViaInternalApiFullProfile = async function parseViaInternalApiFullProfile(useCache = true) {
923 | try {
924 | // Get full profile
925 | const profileParserResult = await this.getParsedProfile(useCache);
926 |
927 | // Some sections might require additional fetches to fill missing data
928 | if (profileParserResult.sections.work === 'incomplete') {
929 | _outputJsonLegacy.work = [];
930 | _outputJsonStable.work = [];
931 | await this.parseViaInternalApiWork();
932 | }
933 | if (profileParserResult.sections.education === 'incomplete') {
934 | _outputJsonLegacy.education = [];
935 | _outputJsonStable.education = [];
936 | await this.parseViaInternalApiEducation();
937 | }
938 | if (profileParserResult.sections.volunteer === 'incomplete') {
939 | _outputJsonLegacy.volunteer = [];
940 | _outputJsonStable.volunteer = [];
941 | await this.parseViaInternalApiVolunteer();
942 | }
943 |
944 | this.debugConsole.log({
945 | _outputJsonLegacy,
946 | _outputJsonStable
947 | });
948 |
949 | return true;
950 | } catch (e) {
951 | this.debugConsole.warn('Error parsing using internal API (Voyager) - FullProfile', e);
952 | }
953 | return false;
954 | };
955 |
956 | LinkedinToResumeJson.prototype.parseViaInternalApiFullSkills = async function parseViaInternalApiFullSkills() {
957 | try {
958 | const fullSkillsInfo = await this.voyagerFetch(_voyagerEndpoints.fullSkills);
959 | if (fullSkillsInfo && typeof fullSkillsInfo.data === 'object') {
960 | if (Array.isArray(fullSkillsInfo.included)) {
961 | for (let x = 0; x < fullSkillsInfo.included.length; x++) {
962 | const skillObj = fullSkillsInfo.included[x];
963 | if (typeof skillObj.name === 'string') {
964 | pushSkill(skillObj.name);
965 | }
966 | }
967 | }
968 | return true;
969 | }
970 | } catch (e) {
971 | this.debugConsole.warn('Error parsing using internal API (Voyager) - FullSkills', e);
972 | }
973 | return false;
974 | };
975 |
976 | LinkedinToResumeJson.prototype.parseViaInternalApiContactInfo = async function parseViaInternalApiContactInfo() {
977 | try {
978 | const contactInfo = await this.voyagerFetch(_voyagerEndpoints.contactInfo);
979 | if (contactInfo && typeof contactInfo.data === 'object') {
980 | const { websites, twitterHandles, phoneNumbers, emailAddress } = contactInfo.data;
981 | /** @type {Partial} */
982 | const partialBasics = {
983 | location: _outputJsonLegacy.basics.location
984 | };
985 | partialBasics.location.address = noNullOrUndef(contactInfo.data.address, _outputJsonLegacy.basics.location.address);
986 | partialBasics.email = noNullOrUndef(emailAddress, _outputJsonLegacy.basics.email);
987 | if (phoneNumbers && phoneNumbers.length) {
988 | partialBasics.phone = noNullOrUndef(phoneNumbers[0].number);
989 | }
990 | _outputJsonLegacy.basics = {
991 | ..._outputJsonLegacy.basics,
992 | ...partialBasics
993 | };
994 | _outputJsonStable.basics = {
995 | ..._outputJsonStable.basics,
996 | ...partialBasics
997 | };
998 |
999 | // Scrape Websites
1000 | if (Array.isArray(websites)) {
1001 | for (let x = 0; x < websites.length; x++) {
1002 | if (/portfolio/i.test(websites[x].type.category)) {
1003 | _outputJsonLegacy.basics.website = websites[x].url;
1004 | _outputJsonStable.basics.url = websites[x].url;
1005 | }
1006 | }
1007 | }
1008 |
1009 | // Scrape Twitter
1010 | if (Array.isArray(twitterHandles)) {
1011 | twitterHandles.forEach((handleMeta) => {
1012 | const handle = handleMeta.name;
1013 | const formattedProfile = {
1014 | network: 'Twitter',
1015 | username: handle,
1016 | url: `https://twitter.com/${handle}`
1017 | };
1018 | _outputJsonLegacy.basics.profiles.push(formattedProfile);
1019 | _outputJsonStable.basics.profiles.push(formattedProfile);
1020 | });
1021 | }
1022 | return true;
1023 | }
1024 | } catch (e) {
1025 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Contact Info', e);
1026 | }
1027 | return false;
1028 | };
1029 |
1030 | LinkedinToResumeJson.prototype.parseViaInternalApiBasicAboutMe = async function parseViaInternalApiBasicAboutMe() {
1031 | try {
1032 | const basicAboutMe = await this.voyagerFetch(_voyagerEndpoints.basicAboutMe);
1033 | if (basicAboutMe && typeof basicAboutMe.data === 'object') {
1034 | if (Array.isArray(basicAboutMe.included) && basicAboutMe.included.length > 0) {
1035 | const data = basicAboutMe.included[0];
1036 | /** @type {Partial} */
1037 | const partialBasics = {
1038 | name: `${data.firstName} ${data.LastName}`,
1039 | // Note - LI labels this as "occupation", but it is basically the callout that shows up in search results and is in the header of the profile
1040 | label: data.occupation
1041 | };
1042 | _outputJsonLegacy.basics = {
1043 | ..._outputJsonLegacy.basics,
1044 | ...partialBasics
1045 | };
1046 | _outputJsonStable.basics = {
1047 | ..._outputJsonStable.basics,
1048 | ...partialBasics
1049 | };
1050 | }
1051 | return true;
1052 | }
1053 | } catch (e) {
1054 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Basic About Me', e);
1055 | }
1056 | return false;
1057 | };
1058 |
1059 | LinkedinToResumeJson.prototype.parseViaInternalApiAdvancedAboutMe = async function parseViaInternalApiAdvancedAboutMe() {
1060 | try {
1061 | const advancedAboutMe = await this.voyagerFetch(_voyagerEndpoints.advancedAboutMe);
1062 | if (advancedAboutMe && typeof advancedAboutMe.data === 'object') {
1063 | const { data } = advancedAboutMe;
1064 | /** @type {Partial} */
1065 | const partialBasics = {
1066 | name: `${data.firstName} ${data.lastName}`,
1067 | label: data.headline,
1068 | summary: data.summary
1069 | };
1070 | _outputJsonLegacy.basics = {
1071 | ..._outputJsonLegacy.basics,
1072 | ...partialBasics
1073 | };
1074 | _outputJsonStable.basics = {
1075 | ..._outputJsonStable.basics,
1076 | ...partialBasics
1077 | };
1078 | return true;
1079 | }
1080 | } catch (e) {
1081 | this.debugConsole.warn('Error parsing using internal API (Voyager) - AdvancedAboutMe', e);
1082 | }
1083 | return false;
1084 | };
1085 |
1086 | LinkedinToResumeJson.prototype.parseViaInternalApiRecommendations = async function parseViaInternalApiRecommendations() {
1087 | try {
1088 | const recommendationJson = await this.voyagerFetch(`${_voyagerEndpoints.recommendations}?q=received&recommendationStatuses=List(VISIBLE)`);
1089 | // This endpoint return a LI db
1090 | const db = buildDbFromLiSchema(recommendationJson);
1091 | db.getElementKeys().forEach((key) => {
1092 | const elem = db.entitiesByUrn[key];
1093 | if (elem && 'recommendationText' in elem) {
1094 | // Need to do a secondary lookup to get the name of the person who gave the recommendation
1095 | const recommenderElem = db.entitiesByUrn[elem['*recommender']];
1096 | const formattedReference = {
1097 | name: `${recommenderElem.firstName} ${recommenderElem.lastName}`,
1098 | reference: elem.recommendationText
1099 | };
1100 | _outputJsonLegacy.references.push(formattedReference);
1101 | _outputJsonStable.references.push(formattedReference);
1102 | }
1103 | });
1104 | } catch (e) {
1105 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Recommendations', e);
1106 | }
1107 | return false;
1108 | };
1109 |
1110 | /**
1111 | * Some LI entities are "rolled-up" through intermediate groupings; this function takes a multi-pronged approach to
1112 | * try and traverse through to the underlying elements. Right now only used for work position groupings
1113 | * @param {InternalDb} db
1114 | * @param {object} lookupConfig
1115 | * @param {string | string[]} lookupConfig.multiRootKey Example: `'*profilePositionGroups'`
1116 | * @param {string} lookupConfig.singleRootVoyagerTypeString Example: `'com.linkedin.voyager.dash.identity.profile.PositionGroup'`
1117 | * @param {string} lookupConfig.elementsInGroupCollectionResponseKey Example: `'*profilePositionInPositionGroup'`
1118 | * @param {string | undefined} lookupConfig.fallbackElementGroupViewKey Example: `'*positionGroupView'`
1119 | * @param {string | undefined} lookupConfig.fallbackElementGroupUrnArrayKey Example: `'*positions'`
1120 | * @param {string | string[] | undefined} lookupConfig.fallbackTocKeys Example: `['*positionView']`
1121 | * @param {string | string[] | undefined} lookupConfig.fallbackTypeStrings Example: `['com.linkedin.voyager.identity.profile.Position', 'com.linkedin.voyager.dash.identity.profile.Position']`
1122 | */
1123 | LinkedinToResumeJson.prototype.getElementsThroughGroup = function getElementsThroughGroup(
1124 | db,
1125 | { multiRootKey, singleRootVoyagerTypeString, elementsInGroupCollectionResponseKey, fallbackElementGroupViewKey, fallbackElementGroupUrnArrayKey, fallbackTocKeys, fallbackTypeStrings }
1126 | ) {
1127 | const rootElements = db.getElements() || [];
1128 | /** @type {LiEntity[]} */
1129 | let finalEntities = [];
1130 |
1131 | /**
1132 | * There are multiple ways that ordered / grouped elements can be nested within a profileView, or other data structure
1133 | * Using example of work positions:
1134 | * A) **ROOT** -> *profilePositionGroups -> PositionGroup[] -> *profilePositionInPositionGroup (COLLECTION) -> Position[]
1135 | * B) **ROOT** -> *positionGroupView -> PositionGroupView -> PositionGroup[] -> *positions -> Position[]
1136 | */
1137 |
1138 | // This is route A - longest recursion chain
1139 | // profilePositionGroup responses are a little annoying; the direct children don't point directly to position entities
1140 | // Instead, you have to follow path of `profilePositionGroup` -> `*profilePositionInPositionGroup` -> `*elements` -> `Position`
1141 | // You can bypass by looking up by `Position` type, but then original ordering is not preserved
1142 | let profileElementGroups = db.getValuesByKey(multiRootKey);
1143 | // Check for voyager profilePositionGroups response, where all groups are direct children of root element
1144 | if (!profileElementGroups.length && rootElements.length && rootElements[0].$type === singleRootVoyagerTypeString) {
1145 | profileElementGroups = rootElements;
1146 | }
1147 | profileElementGroups.forEach((pGroup) => {
1148 | // This element (profileElementsGroup) is one way how LI groups positions
1149 | // - Instead of storing *elements (positions) directly,
1150 | // there is a pointer to a "collection" that has to be followed
1151 | /** @type {string | string[] | undefined} */
1152 | const profilePositionInGroupCollectionUrns = pGroup[elementsInGroupCollectionResponseKey];
1153 | if (profilePositionInGroupCollectionUrns) {
1154 | const positionCollections = db.getElementsByUrns(profilePositionInGroupCollectionUrns);
1155 | // Another level... traverse collections
1156 | positionCollections.forEach((collection) => {
1157 | // Final lookup via standard collection['*elements']
1158 | finalEntities = finalEntities.concat(db.getElementsByUrns(collection['*elements'] || []));
1159 | });
1160 | }
1161 | });
1162 |
1163 | if (!finalEntities.length && !!fallbackElementGroupViewKey && !!fallbackElementGroupUrnArrayKey) {
1164 | db.getValuesByKey(fallbackElementGroupViewKey).forEach((pGroup) => {
1165 | finalEntities = finalEntities.concat(db.getElementsByUrns(pGroup[fallbackElementGroupUrnArrayKey] || []));
1166 | });
1167 | }
1168 |
1169 | if (!finalEntities.length && !!fallbackTocKeys) {
1170 | // Direct lookup - by main TOC keys
1171 | finalEntities = db.getValuesByKey(fallbackTocKeys);
1172 | }
1173 |
1174 | if (!finalEntities.length && !!fallbackTypeStrings) {
1175 | // Direct lookup - by type
1176 | finalEntities = db.getElementsByType(fallbackTypeStrings);
1177 | }
1178 |
1179 | return finalEntities;
1180 | };
1181 |
1182 | /**
1183 | * Extract work positions via traversal through position groups
1184 | * - 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
1185 | * - LI also uses positionGroups to preserve order, whereas a direct lookup by type or recipe might not return ordered results
1186 | * - This method will try to return ordered results first, and then fall back to any matching position entities if it can't find an ordered lookup path
1187 | * @param {InternalDb} db
1188 | */
1189 | LinkedinToResumeJson.prototype.getWorkPositions = function getWorkPositions(db) {
1190 | return this.getElementsThroughGroup(db, {
1191 | multiRootKey: '*profilePositionGroups',
1192 | singleRootVoyagerTypeString: 'com.linkedin.voyager.dash.identity.profile.PositionGroup',
1193 | elementsInGroupCollectionResponseKey: '*profilePositionInPositionGroup',
1194 | fallbackElementGroupViewKey: '*positionGroupView',
1195 | fallbackElementGroupUrnArrayKey: '*positions',
1196 | fallbackTocKeys: _liTypeMappings.workPositions.tocKeys,
1197 | fallbackTypeStrings: _liTypeMappings.workPositions.types
1198 | });
1199 | };
1200 |
1201 | LinkedinToResumeJson.prototype.parseViaInternalApiWork = async function parseViaInternalApiWork() {
1202 | try {
1203 | const workResponses = await this.voyagerFetchAutoPaginate(_voyagerEndpoints.dash.profilePositionGroups.path);
1204 | workResponses.forEach((response) => {
1205 | const db = buildDbFromLiSchema(response);
1206 | this.getWorkPositions(db).forEach((position) => {
1207 | parseAndPushPosition(position, db);
1208 | });
1209 | });
1210 | } catch (e) {
1211 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Work', e);
1212 | }
1213 | };
1214 |
1215 | LinkedinToResumeJson.prototype.parseViaInternalApiEducation = async function parseViaInternalApiEducation() {
1216 | try {
1217 | // This is a really annoying lookup - I can't find a separate API endpoint, so I have to use the full-FULL (dash) profile endpoint...
1218 | const fullDashProfileObj = await this.voyagerFetch(_voyagerEndpoints.dash.fullProfile.path);
1219 | const db = buildDbFromLiSchema(fullDashProfileObj);
1220 | // Response is missing ToC, so just look up by namespace / schema
1221 | const eduEntries = db.getElementsByType('com.linkedin.voyager.dash.identity.profile.Education');
1222 | eduEntries.forEach((edu) => {
1223 | parseAndPushEducation(edu, db, this);
1224 | });
1225 | } catch (e) {
1226 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Education', e);
1227 | }
1228 | };
1229 |
1230 | LinkedinToResumeJson.prototype.parseViaInternalApiVolunteer = async function parseViaInternalApiVolunteer() {
1231 | try {
1232 | const volunteerResponses = await this.voyagerFetchAutoPaginate(_voyagerEndpoints.dash.profileVolunteerExperiences);
1233 | volunteerResponses.forEach((response) => {
1234 | const db = buildDbFromLiSchema(response);
1235 | db.getElementsByType(_liTypeMappings.volunteerWork.types).forEach((volunteerEntry) => {
1236 | parseAndPushVolunteerExperience(volunteerEntry, db);
1237 | });
1238 | });
1239 | } catch (e) {
1240 | this.debugConsole.warn('Error parsing using internal API (Voyager) - Volunteer Entries', e);
1241 | }
1242 | };
1243 |
1244 | LinkedinToResumeJson.prototype.parseViaInternalApi = async function parseViaInternalApi(useCache = true) {
1245 | try {
1246 | let apiSuccessCount = 0;
1247 | let fullProfileEndpointSuccess = false;
1248 |
1249 | fullProfileEndpointSuccess = await this.parseViaInternalApiFullProfile(useCache);
1250 | if (fullProfileEndpointSuccess) {
1251 | apiSuccessCount++;
1252 | }
1253 |
1254 | // Get full skills, behind voyager endpoint
1255 | if (this.getFullSkills && (await this.parseViaInternalApiFullSkills())) {
1256 | apiSuccessCount++;
1257 | }
1258 |
1259 | // Always get full contact info, behind voyager endpoint
1260 | if (await this.parseViaInternalApiContactInfo()) {
1261 | apiSuccessCount++;
1262 | }
1263 |
1264 | // References / recommendations should also come via voyager; DOM is extremely unreliable for this
1265 | if (await this.parseViaInternalApiRecommendations()) {
1266 | apiSuccessCount++;
1267 | }
1268 |
1269 | // Only continue with other endpoints if full profile API failed
1270 | if (!fullProfileEndpointSuccess) {
1271 | if (await this.parseViaInternalApiBasicAboutMe()) {
1272 | apiSuccessCount++;
1273 | }
1274 | if (await this.parseViaInternalApiAdvancedAboutMe()) {
1275 | apiSuccessCount++;
1276 | }
1277 | }
1278 |
1279 | this.debugConsole.log({
1280 | _outputJsonLegacy,
1281 | _outputJsonStable,
1282 | _outputJsonBetaPartial
1283 | });
1284 | if (apiSuccessCount > 0) {
1285 | this.parseSuccess = true;
1286 | } else {
1287 | this.debugConsole.error('Using internal API (Voyager) failed completely!');
1288 | }
1289 | } catch (e) {
1290 | this.debugConsole.warn('Error parsing using internal API (Voyager)', e);
1291 | }
1292 | };
1293 |
1294 | /**
1295 | * Trigger AJAX loading of content by scrolling
1296 | * @param {boolean} [forceReScroll]
1297 | */
1298 | LinkedinToResumeJson.prototype.triggerAjaxLoadByScrolling = async function triggerAjaxLoadByScrolling(forceReScroll = false) {
1299 | _scrolledToLoad = forceReScroll ? false : _scrolledToLoad;
1300 | if (!_scrolledToLoad) {
1301 | // Capture current location
1302 | const startingLocY = window.scrollY;
1303 | // Scroll to bottom
1304 | const scrollToBottom = () => {
1305 | const maxHeight = document.body.scrollHeight;
1306 | window.scrollTo(0, maxHeight);
1307 | };
1308 | scrollToBottom();
1309 | await new Promise((resolve) => {
1310 | setTimeout(() => {
1311 | scrollToBottom();
1312 | window.scrollTo(0, startingLocY);
1313 | _scrolledToLoad = true;
1314 | resolve();
1315 | }, 400);
1316 | });
1317 | }
1318 |
1319 | return true;
1320 | };
1321 |
1322 | /**
1323 | * Force a re-parse / scrape
1324 | * @param {string} [optLocale]
1325 | */
1326 | LinkedinToResumeJson.prototype.forceReParse = async function forceReParse(optLocale) {
1327 | _scrolledToLoad = false;
1328 | this.parseSuccess = false;
1329 | await this.tryParse(optLocale);
1330 | };
1331 |
1332 | /**
1333 | * See if profile has changed (either URL or otherwise) since last scrape
1334 | * @param {string} [optLocale] preferred locale
1335 | * @returns {boolean} hasProfileChanged
1336 | */
1337 | LinkedinToResumeJson.prototype.getHasChangedSinceLastParse = function getHasChangedSinceLastParse(optLocale) {
1338 | const localeToUse = optLocale || this.preferLocale;
1339 | const localeStayedSame = !localeToUse || optLocale === this.lastScannedLocale;
1340 | const pageUrlChanged = this.scannedPageUrl === this.getUrlWithoutQuery();
1341 |
1342 | return localeStayedSame && pageUrlChanged;
1343 | };
1344 |
1345 | /**
1346 | * Get the parsed version of the LI profile response object
1347 | * - Caches profile object and re-uses when possible
1348 | * @param {boolean} [useCache] default = true
1349 | * @param {string} [optLocale] preferred locale. Defaults to instance.preferLocale
1350 | * @returns {Promise} profile object response summary
1351 | */
1352 | LinkedinToResumeJson.prototype.getParsedProfile = async function getParsedProfile(useCache = true, optLocale = undefined) {
1353 | const localeToUse = optLocale || this.preferLocale;
1354 | const localeMatchesUser = !localeToUse || localeToUse === _defaultLocale;
1355 |
1356 | if (this.profileParseSummary && useCache) {
1357 | const { pageUrl, localeStr, parseSuccess } = this.profileParseSummary;
1358 | const urlChanged = pageUrl !== this.getUrlWithoutQuery();
1359 | const langChanged = !!localeToUse && localeToUse !== localeStr;
1360 | if (parseSuccess && !urlChanged && !langChanged) {
1361 | this.debugConsole.log('getProfileResponse - Used Cache');
1362 | return this.profileParseSummary;
1363 | }
1364 | }
1365 |
1366 | // Embedded schema can't be used for specific locales
1367 | if (this.preferApi === false && localeMatchesUser) {
1368 | await this.triggerAjaxLoadByScrolling(true);
1369 | await this.parseEmbeddedLiSchema();
1370 | if (this.parseSuccess) {
1371 | this.debugConsole.log('getProfileResponse - Used embedded schema. Success.');
1372 | return this.profileParseSummary;
1373 | }
1374 | }
1375 |
1376 | // Get directly via API
1377 | /** @type {ParseProfileSchemaResultSummary['profileSrc']} */
1378 | let endpointType = 'profileView';
1379 | /** @type {LiResponse} */
1380 | let profileResponse;
1381 | /**
1382 | * 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
1383 | */
1384 | if (!localeMatchesUser || this.preferDash === true) {
1385 | endpointType = 'dashFullProfileWithEntities';
1386 | profileResponse = await this.voyagerFetch(_voyagerEndpoints.dash.fullProfile.path);
1387 | } else {
1388 | // use normal profileView
1389 | profileResponse = await this.voyagerFetch(_voyagerEndpoints.fullProfileView);
1390 | }
1391 |
1392 | // Try to use the same parser that I use for embedded
1393 | const profileParserResult = await parseProfileSchemaJSON(this, profileResponse, endpointType);
1394 |
1395 | if (profileParserResult.parseSuccess) {
1396 | this.debugConsole.log('getProfileResponse - Used API. Sucess', {
1397 | profileResponse,
1398 | endpointType,
1399 | profileParserResult
1400 | });
1401 | this.profileParseSummary = profileParserResult;
1402 | return this.profileParseSummary;
1403 | }
1404 |
1405 | throw new Error('Could not get profile response object');
1406 | };
1407 |
1408 | /**
1409 | * Try to scrape / get API and parse
1410 | * - Has some basic cache checking to avoid redundant parsing
1411 | * @param {string} [optLocale]
1412 | */
1413 | LinkedinToResumeJson.prototype.tryParse = async function tryParse(optLocale) {
1414 | const _this = this;
1415 | const localeToUse = optLocale || _this.preferLocale;
1416 | const localeStayedSame = !localeToUse || localeToUse === _this.lastScannedLocale;
1417 | const localeMatchesUser = !localeToUse || localeToUse === _this.getViewersLocalLang();
1418 | _this.preferLocale = localeToUse || null;
1419 |
1420 | // eslint-disable-next-line no-async-promise-executor
1421 | return new Promise(async (resolve) => {
1422 | if (_this.parseSuccess) {
1423 | if (_this.scannedPageUrl === _this.getUrlWithoutQuery() && localeStayedSame) {
1424 | // No need to reparse!
1425 | _this.debugConsole.log('Skipped re-parse; page has not changed');
1426 | resolve(true);
1427 | } else {
1428 | // Parse already done, but page changed (ajax)
1429 | _this.debugConsole.warn('Re-parsing for new results; page has changed between scans');
1430 | await _this.forceReParse(localeToUse);
1431 | resolve(true);
1432 | }
1433 | } else {
1434 | // Reset output to empty template
1435 | _outputJsonLegacy = JSON.parse(JSON.stringify(resumeJsonTemplateLegacy));
1436 | _outputJsonStable = JSON.parse(JSON.stringify(resumeJsonTemplateStable));
1437 | _outputJsonBetaPartial = JSON.parse(JSON.stringify(resumeJsonTemplateBetaPartial));
1438 |
1439 | // Trigger full load
1440 | await _this.triggerAjaxLoadByScrolling();
1441 | _this.parseBasics();
1442 |
1443 | // Embedded schema can't be used for specific locales
1444 | if (_this.preferApi === false && localeMatchesUser) {
1445 | await _this.parseEmbeddedLiSchema();
1446 | if (!_this.parseSuccess) {
1447 | await _this.parseViaInternalApi(false);
1448 | }
1449 | } else {
1450 | await _this.parseViaInternalApi(false);
1451 | if (!_this.parseSuccess) {
1452 | await _this.parseEmbeddedLiSchema();
1453 | }
1454 | }
1455 |
1456 | _this.scannedPageUrl = _this.getUrlWithoutQuery();
1457 | _this.lastScannedLocale = localeToUse;
1458 | _this.debugConsole.log(_this);
1459 | resolve(true);
1460 | }
1461 | });
1462 | };
1463 |
1464 | /** @param {SchemaVersion} version */
1465 | LinkedinToResumeJson.prototype.parseAndGetRawJson = async function parseAndGetRawJson(version = 'stable') {
1466 | await this.tryParse();
1467 | let rawJson = version === 'stable' ? _outputJsonStable : _outputJsonLegacy;
1468 | // If beta, combine with stable
1469 | if (version === 'beta') {
1470 | rawJson = {
1471 | ...rawJson,
1472 | ..._outputJsonBetaPartial
1473 | };
1474 | }
1475 | return rawJson;
1476 | };
1477 |
1478 | /** @param {SchemaVersion} version */
1479 | LinkedinToResumeJson.prototype.parseAndDownload = async function parseAndDownload(version = 'stable') {
1480 | const rawJson = await this.parseAndGetRawJson(version);
1481 | const fileName = `${_outputJsonLegacy.basics.name.replace(/\s/g, '_')}.resume.json`;
1482 | const fileContents = JSON.stringify(rawJson, null, 2);
1483 | this.debugConsole.log(fileContents);
1484 | promptDownload(fileContents, fileName, 'application/json');
1485 | };
1486 |
1487 | /** @param {SchemaVersion} version */
1488 | LinkedinToResumeJson.prototype.parseAndShowOutput = async function parseAndShowOutput(version = 'stable') {
1489 | const rawJson = await this.parseAndGetRawJson(version);
1490 | const parsedExport = {
1491 | raw: rawJson,
1492 | stringified: JSON.stringify(rawJson, null, 2)
1493 | };
1494 | console.log(parsedExport);
1495 | if (this.parseSuccess) {
1496 | this.showModal(parsedExport.raw);
1497 | } else {
1498 | alert('Could not extract JSON from current page. Make sure you are on a profile page that you have access to');
1499 | }
1500 | };
1501 |
1502 | LinkedinToResumeJson.prototype.closeModal = function closeModal() {
1503 | const modalWrapperId = `${_toolPrefix}_modalWrapper`;
1504 | const modalWrapper = document.getElementById(modalWrapperId);
1505 | if (modalWrapper) {
1506 | modalWrapper.style.display = 'none';
1507 | }
1508 | };
1509 |
1510 | /**
1511 | * Show the output modal with the results
1512 | * @param {{[key: string]: any}} jsonResume - JSON Resume
1513 | */
1514 | LinkedinToResumeJson.prototype.showModal = function showModal(jsonResume) {
1515 | const _this = this;
1516 | const modalWrapperId = `${_toolPrefix}_modalWrapper`;
1517 | let modalWrapper = document.getElementById(modalWrapperId);
1518 | if (modalWrapper) {
1519 | modalWrapper.style.display = 'block';
1520 | } else {
1521 | _this.injectStyles();
1522 | modalWrapper = document.createElement('div');
1523 | modalWrapper.id = modalWrapperId;
1524 | modalWrapper.innerHTML = ``;
1533 | document.body.appendChild(modalWrapper);
1534 | // Add event listeners
1535 | modalWrapper.addEventListener('click', (evt) => {
1536 | // Check if click was on modal content, or wrapper (outside content, to trigger close)
1537 | // @ts-ignore
1538 | if (evt.target.id === modalWrapperId) {
1539 | _this.closeModal();
1540 | }
1541 | });
1542 | modalWrapper.querySelector(`.${_toolPrefix}_closeButton`).addEventListener('click', () => {
1543 | _this.closeModal();
1544 | });
1545 | /** @type {HTMLTextAreaElement} */
1546 | const textarea = modalWrapper.querySelector(`#${_toolPrefix}_exportTextField`);
1547 | textarea.addEventListener('click', () => {
1548 | textarea.select();
1549 | });
1550 | }
1551 | // Actually set textarea text
1552 | /** @type {HTMLTextAreaElement} */
1553 | const outputTextArea = modalWrapper.querySelector(`#${_toolPrefix}_exportTextField`);
1554 | outputTextArea.value = JSON.stringify(jsonResume, null, 2);
1555 | };
1556 |
1557 | LinkedinToResumeJson.prototype.injectStyles = function injectStyles() {
1558 | if (!_stylesInjected) {
1559 | const styleElement = document.createElement('style');
1560 | styleElement.innerText = `#${_toolPrefix}_modalWrapper {
1561 | width: 100%;
1562 | height: 100%;
1563 | position: fixed;
1564 | top: 0;
1565 | left: 0;
1566 | background-color: rgba(0, 0, 0, 0.8);
1567 | z-index: 99999999999999999999999999999999
1568 | }
1569 | .${_toolPrefix}_modal {
1570 | width: 80%;
1571 | margin-top: 10%;
1572 | margin-left: 10%;
1573 | background-color: white;
1574 | padding: 20px;
1575 | border-radius: 13px;
1576 | }
1577 | .${_toolPrefix}_topBar {
1578 | width: 100%;
1579 | position: relative;
1580 | }
1581 | .${_toolPrefix}_titleText {
1582 | text-align: center;
1583 | font-size: x-large;
1584 | width: 100%;
1585 | padding-top: 8px;
1586 | }
1587 | .${_toolPrefix}_closeButton {
1588 | position: absolute;
1589 | top: 0px;
1590 | right: 0px;
1591 | padding: 0px 8px;
1592 | margin: 3px;
1593 | border: 4px double black;
1594 | border-radius: 10px;
1595 | font-size: x-large;
1596 | }
1597 | .${_toolPrefix}_modalBody {
1598 | width: 90%;
1599 | margin-left: 5%;
1600 | margin-top: 20px;
1601 | padding-top: 8px;
1602 | }
1603 | #${_toolPrefix}_exportTextField {
1604 | width: 100%;
1605 | min-height: 300px;
1606 | }`;
1607 | document.body.appendChild(styleElement);
1608 | }
1609 | };
1610 |
1611 | LinkedinToResumeJson.prototype.getUrlWithoutQuery = function getUrlWithoutQuery() {
1612 | return document.location.origin + document.location.pathname;
1613 | };
1614 |
1615 | /**
1616 | * Get the profile ID / User ID of the user by parsing URL first, then page.
1617 | */
1618 | LinkedinToResumeJson.prototype.getProfileId = function getProfileId() {
1619 | let profileId = '';
1620 | const linkedProfileRegUrl = /linkedin.com\/in\/([^\/?#]+)[\/?#]?.*$/im;
1621 | const linkedProfileRegApi = /voyager\/api\/.*\/profiles\/([^\/]+)\/.*/im;
1622 | if (linkedProfileRegUrl.test(document.location.href)) {
1623 | profileId = linkedProfileRegUrl.exec(document.location.href)[1];
1624 | }
1625 |
1626 | // Fallback to finding in HTML source.
1627 | // Warning: This can get stale between pages, or might return your own ID instead of current profile
1628 | if (!profileId && linkedProfileRegApi.test(document.body.innerHTML)) {
1629 | profileId = linkedProfileRegApi.exec(document.body.innerHTML)[1];
1630 | }
1631 |
1632 | // In case username contains special characters
1633 | return decodeURI(profileId);
1634 | };
1635 |
1636 | /**
1637 | * Get the local language identifier of the *viewer* (not profile)
1638 | * - This should correspond to LI's defaultLocale, which persists, even across user configuration changes
1639 | * @returns {string}
1640 | */
1641 | LinkedinToResumeJson.prototype.getViewersLocalLang = () => {
1642 | // This *seems* to correspond with profile.defaultLocale, but I'm not 100% sure
1643 | const metaTag = document.querySelector('meta[name="i18nDefaultLocale"]');
1644 | /** @type {HTMLSelectElement | null} */
1645 | const selectTag = document.querySelector('select#globalfooter-select_language');
1646 | if (metaTag) {
1647 | return metaTag.getAttribute('content');
1648 | }
1649 | if (selectTag) {
1650 | return selectTag.value;
1651 | }
1652 | // Default to English
1653 | return 'en_US';
1654 | };
1655 |
1656 | /**
1657 | * Get the locales that the *current* profile (natively) supports (based on `supportedLocales`)
1658 | * Note: Uses cache
1659 | * @returns {Promise}
1660 | */
1661 | LinkedinToResumeJson.prototype.getSupportedLocales = async function getSupportedLocales() {
1662 | if (!_supportedLocales.length) {
1663 | const { liResponse } = await this.getParsedProfile(true, null);
1664 | const profileDb = buildDbFromLiSchema(liResponse);
1665 | const userDetails = profileDb.getValuesByKey(_liSchemaKeys.profile)[0];
1666 | if (userDetails && Array.isArray(userDetails['supportedLocales'])) {
1667 | _supportedLocales = userDetails.supportedLocales.map((locale) => {
1668 | return `${locale.language}_${locale.country}`;
1669 | });
1670 | }
1671 | }
1672 | return _supportedLocales;
1673 | };
1674 |
1675 | /**
1676 | * Get the internal URN ID of the active profile
1677 | * - Not needed for JSON Resume, but for Voyager calls
1678 | * - ID is also used as part of other URNs
1679 | * @param {boolean} [allowFetch] If DOM search fails, allow Voyager call to determine profile URN.
1680 | * @returns {Promise} profile URN ID
1681 | */
1682 | LinkedinToResumeJson.prototype.getProfileUrnId = async function getProfileUrnId(allowFetch = true) {
1683 | const profileViewUrnPatt = /urn:li:fs_profileView:(.+)$/i;
1684 |
1685 | if (this.profileUrnId && this.scannedPageUrl === this.getUrlWithoutQuery()) {
1686 | return this.profileUrnId;
1687 | }
1688 |
1689 | // Try to use cache
1690 | if (this.profileParseSummary && this.profileParseSummary.parseSuccess) {
1691 | const profileDb = buildDbFromLiSchema(this.profileParseSummary.liResponse);
1692 | this.profileUrnId = profileDb.tableOfContents['entityUrn'].match(profileViewUrnPatt)[1];
1693 | return this.profileUrnId;
1694 | }
1695 |
1696 | const endpoint = _voyagerEndpoints.fullProfileView;
1697 | // Make a new API call to get ID - be wary of recursive calls
1698 | if (allowFetch && !endpoint.includes(`{profileUrnId}`)) {
1699 | const fullProfileView = await this.voyagerFetch(endpoint);
1700 | const profileDb = buildDbFromLiSchema(fullProfileView);
1701 | this.profileUrnId = profileDb.tableOfContents['entityUrn'].match(profileViewUrnPatt)[1];
1702 | return this.profileUrnId;
1703 | }
1704 | this.debugConsole.warn('Could not scrape profileUrnId from cache, but fetch is disallowed. Might be using a stale ID!');
1705 |
1706 | // Try to find in DOM, as last resort
1707 | const urnPatt = /miniprofiles\/([A-Za-z0-9-_]+)/g;
1708 | const matches = document.body.innerHTML.match(urnPatt);
1709 | if (matches && matches.length > 1) {
1710 | // eslint-disable-next-line prettier/prettier
1711 | // prettier-ignore
1712 | this.profileUrnId = (urnPatt.exec(matches[matches.length - 1]))[1];
1713 | return this.profileUrnId;
1714 | }
1715 |
1716 | return this.profileUrnId;
1717 | };
1718 |
1719 | LinkedinToResumeJson.prototype.getDisplayPhoto = async function getDisplayPhoto() {
1720 | let photoUrl = '';
1721 | /** @type {HTMLImageElement | null} */
1722 | const photoElem = document.querySelector('[class*="profile"] img[class*="profile-photo"]');
1723 | if (photoElem) {
1724 | photoUrl = photoElem.src;
1725 | } else {
1726 | // Get via miniProfile entity in full profile db
1727 | const { liResponse, profileSrc, profileInfoObj } = await this.getParsedProfile();
1728 | const profileDb = buildDbFromLiSchema(liResponse);
1729 | let pictureMeta;
1730 | if (profileSrc === 'profileView') {
1731 | const miniProfile = profileDb.getElementByUrn(profileInfoObj['*miniProfile']);
1732 | if (miniProfile && !!miniProfile.picture) {
1733 | pictureMeta = miniProfile.picture;
1734 | }
1735 | } else {
1736 | pictureMeta = profileInfoObj.profilePicture.displayImageReference.vectorImage;
1737 | }
1738 | // @ts-ignore
1739 | const smallestArtifact = pictureMeta.artifacts.sort((a, b) => a.width - b.width)[0];
1740 | photoUrl = `${pictureMeta.rootUrl}${smallestArtifact.fileIdentifyingUrlPathSegment}`;
1741 | }
1742 |
1743 | return photoUrl;
1744 | };
1745 |
1746 | LinkedinToResumeJson.prototype.generateVCard = async function generateVCard() {
1747 | const profileResSummary = await this.getParsedProfile();
1748 | const contactInfoObj = await this.voyagerFetch(_voyagerEndpoints.contactInfo);
1749 | this.exportVCard(profileResSummary, contactInfoObj);
1750 | };
1751 |
1752 | /**
1753 | * @param {ParseProfileSchemaResultSummary} profileResult
1754 | * @param {LiResponse} contactInfoObj
1755 | */
1756 | LinkedinToResumeJson.prototype.exportVCard = async function exportVCard(profileResult, contactInfoObj) {
1757 | const vCard = VCardsJS();
1758 | const profileDb = buildDbFromLiSchema(profileResult.liResponse);
1759 | const contactDb = buildDbFromLiSchema(contactInfoObj);
1760 | // Contact info is stored directly in response; no lookup
1761 | const contactInfo = /** @type {LiProfileContactInfoResponse['data']} */ (contactDb.tableOfContents);
1762 | const profile = profileResult.profileInfoObj;
1763 | vCard.formattedName = `${profile.firstName} ${profile.lastName}`;
1764 | vCard.firstName = profile.firstName;
1765 | vCard.lastName = profile.lastName;
1766 | // Geo
1767 | if ('postalCode' in profile.geoLocation) {
1768 | // @ts-ignore
1769 | vCard.homeAddress.postalCode = profile.geoLocation.postalCode;
1770 | }
1771 | vCard.email = contactInfo.emailAddress;
1772 | if (contactInfo.twitterHandles.length) {
1773 | // @ts-ignore
1774 | vCard.socialUrls['twitter'] = `https://twitter.com/${contactInfo.twitterHandles[0].name}`;
1775 | }
1776 | if (contactInfo.phoneNumbers) {
1777 | contactInfo.phoneNumbers.forEach((numberObj) => {
1778 | if (numberObj.type === 'MOBILE') {
1779 | vCard.cellPhone = numberObj.number;
1780 | } else if (numberObj.type === 'WORK') {
1781 | vCard.workPhone = numberObj.number;
1782 | } else {
1783 | vCard.homePhone = numberObj.number;
1784 | }
1785 | });
1786 | }
1787 | // At a minimum, we need month and day in order to include BDAY
1788 | if (profile.birthDate && 'day' in profile.birthDate && 'month' in profile.birthDate) {
1789 | const birthdayLi = /** @type {LiDate} */ (profile.birthDate);
1790 | if (!birthdayLi.year) {
1791 | /**
1792 | * Users can choose to OMIT their birthyear, but leave month and day (thus hiding age)
1793 | * - vCard actually allows this in spec, but only in > v4 (RFC-6350): https://tools.ietf.org/html/rfc6350#:~:text=BDAY%3A--0415, https://tools.ietf.org/html/rfc6350#section-4.3.1
1794 | * - Governed by ISO-8601, which allows truncated under ISO.8601.2000, such as `--MMDD`
1795 | * - Example: `BDAY:--0415`
1796 | * - Since the vCard library I'm using (many platforms) only support V3, I'll just exclude it from the vCard; including a partial date in v3 (violating the spec) will result in a corrupt card that will crash many programs
1797 | */
1798 | console.warn(`Warning: User has a "partial" birthdate (year is omitted). This is not supported in vCard version 3 or under.`);
1799 | } else {
1800 | // Full birthday (can be used for age)
1801 | vCard.birthday = liDateToJSDate(birthdayLi);
1802 | }
1803 | }
1804 | // Try to get currently employed organization
1805 | const positions = this.getWorkPositions(profileDb);
1806 | if (positions.length) {
1807 | vCard.organization = positions[0].companyName;
1808 | vCard.title = positions[0].title;
1809 | }
1810 | vCard.workUrl = this.getUrlWithoutQuery();
1811 | vCard.note = profile.headline;
1812 | // Try to get profile picture
1813 | let photoUrl;
1814 | try {
1815 | photoUrl = await this.getDisplayPhoto();
1816 | } catch (e) {
1817 | this.debugConsole.warn(`Could not extract profile picture.`, e);
1818 | }
1819 | if (photoUrl) {
1820 | try {
1821 | // Since LI photo URLs are temporary, convert to base64 first
1822 | const photoDataBase64 = await urlToBase64(photoUrl, true);
1823 | // @ts-ignore
1824 | vCard.photo.embedFromString(photoDataBase64.dataStr, photoDataBase64.mimeStr);
1825 | } catch (e) {
1826 | this.debugConsole.error(`Failed to convert LI image to base64`, e);
1827 | }
1828 | }
1829 | const fileName = `${profile.firstName}_${profile.lastName}.vcf`;
1830 | const fileContents = vCard.getFormattedString();
1831 | this.debugConsole.log('vCard generated', fileContents);
1832 | promptDownload(fileContents, fileName, 'text/vcard');
1833 | return vCard;
1834 | };
1835 |
1836 | /**
1837 | * API fetching, with auto pagination
1838 | * @param {string} fetchEndpoint
1839 | * @param {Record} [optHeaders]
1840 | * @param {number} [start]
1841 | * @param {number} [limitPerPage]
1842 | * @param {number} [requestLimit]
1843 | * @param {number} [throttleDelayMs]
1844 | * @returns {Promise} responseArr
1845 | */
1846 | LinkedinToResumeJson.prototype.voyagerFetchAutoPaginate = async function voyagerFetchAutoPaginate(
1847 | fetchEndpoint,
1848 | optHeaders = {},
1849 | start = 0,
1850 | limitPerPage = 20,
1851 | requestLimit = 100,
1852 | throttleDelayMs = 100
1853 | ) {
1854 | /** @type {LiResponse[]} */
1855 | const responseArr = [];
1856 | let url = await this.formatVoyagerUrl(fetchEndpoint);
1857 | let done = false;
1858 | let currIndex = start;
1859 | let requestsMade = 0;
1860 | /** @type {(value?: any) => void} */
1861 | let resolver;
1862 | /** @type {(reason?: any) => void} */
1863 | let rejector;
1864 |
1865 | /**
1866 | * @param {any} pagingObj
1867 | */
1868 | const handlePagingData = (pagingObj) => {
1869 | if (pagingObj && typeof pagingObj === 'object' && 'total' in pagingObj) {
1870 | currIndex = pagingObj.start + pagingObj.count;
1871 | done = currIndex >= pagingObj.total;
1872 | } else {
1873 | done = true;
1874 | }
1875 | };
1876 |
1877 | /** @param {LiResponse} liResponse */
1878 | const handleResponse = async (liResponse) => {
1879 | requestsMade++;
1880 | responseArr.push(liResponse);
1881 | handlePagingData(liResponse.data.paging);
1882 | if (!done && requestsMade < requestLimit) {
1883 | await new Promise((res) => {
1884 | setTimeout(() => {
1885 | res();
1886 | }, throttleDelayMs);
1887 | });
1888 | url = setQueryParams(url, {
1889 | start: currIndex,
1890 | count: limitPerPage
1891 | });
1892 | try {
1893 | const response = await this.voyagerFetch(url, optHeaders);
1894 | // Recurse
1895 | handleResponse(response);
1896 | } catch (e) {
1897 | // BAIL
1898 | done = true;
1899 | this.debugConsole.warn(`Bailing out of auto-fetch, request failed.`, e);
1900 | }
1901 | } else {
1902 | done = true;
1903 | }
1904 |
1905 | if (done) {
1906 | if (responseArr.length) {
1907 | resolver(responseArr);
1908 | } else {
1909 | rejector(new Error(`Failed to make any requests`));
1910 | }
1911 | }
1912 | };
1913 |
1914 | // Start off the pagination chain
1915 | this.voyagerFetch(
1916 | setQueryParams(url, {
1917 | start: currIndex,
1918 | count: limitPerPage
1919 | })
1920 | ).then(handleResponse);
1921 |
1922 | return new Promise((res, rej) => {
1923 | resolver = res;
1924 | rejector = rej;
1925 | });
1926 | };
1927 |
1928 | /**
1929 | * Simple formatting for Voyager URLs - macro support, etc.
1930 | * @param {string} fetchEndpoint
1931 | * @returns {Promise} formattedUrl
1932 | */
1933 | LinkedinToResumeJson.prototype.formatVoyagerUrl = async function formatVoyagerUrl(fetchEndpoint) {
1934 | // Macro support
1935 | let endpoint = fetchEndpoint;
1936 | if (endpoint.includes('{profileId}')) {
1937 | endpoint = fetchEndpoint.replace(/{profileId}/g, this.getProfileId());
1938 | }
1939 | if (endpoint.includes('{profileUrnId}')) {
1940 | const profileUrnId = await this.getProfileUrnId();
1941 | endpoint = endpoint.replace(/{profileUrnId}/g, profileUrnId);
1942 | }
1943 | if (!endpoint.startsWith('https')) {
1944 | endpoint = _voyagerBase + endpoint;
1945 | }
1946 | return endpoint;
1947 | };
1948 |
1949 | /**
1950 | * Special - Fetch with authenticated internal API
1951 | * @param {string} fetchEndpoint
1952 | * @param {Record} [optHeaders]
1953 | * @returns {Promise}
1954 | */
1955 | LinkedinToResumeJson.prototype.voyagerFetch = async function voyagerFetch(fetchEndpoint, optHeaders = {}) {
1956 | const _this = this;
1957 | const endpoint = await _this.formatVoyagerUrl(fetchEndpoint);
1958 | // Set requested language
1959 | let langHeaders = {};
1960 | if (_this.preferLocale) {
1961 | langHeaders = {
1962 | 'x-li-lang': _this.preferLocale
1963 | };
1964 | }
1965 | return new Promise((resolve, reject) => {
1966 | // Get the csrf token - should be stored as a cookie
1967 | const csrfTokenString = getCookie('JSESSIONID').replace(/"/g, '');
1968 | if (csrfTokenString) {
1969 | /** @type {RequestInit} */
1970 | const fetchOptions = {
1971 | credentials: 'include',
1972 | headers: {
1973 | ...langHeaders,
1974 | ...optHeaders,
1975 | accept: 'application/vnd.linkedin.normalized+json+2.1',
1976 | 'csrf-token': csrfTokenString,
1977 | 'sec-fetch-mode': 'cors',
1978 | 'sec-fetch-site': 'same-origin'
1979 | },
1980 | referrer: document.location.href,
1981 | body: null,
1982 | method: 'GET',
1983 | mode: 'cors'
1984 | };
1985 | fetch(endpoint, fetchOptions).then((response) => {
1986 | if (response.status !== 200) {
1987 | const errStr = 'Error fetching internal API endpoint';
1988 | reject(new Error(errStr));
1989 | console.warn(errStr, response);
1990 | } else {
1991 | response.text().then((text) => {
1992 | try {
1993 | /** @type {LiResponse} */
1994 | const parsed = JSON.parse(text);
1995 | if (!!_this.preferLocale && _this.preferLocale !== _defaultLocale) {
1996 | _this.debugConsole.log(`Checking for locale mapping and remapping if found.`);
1997 | remapNestedLocale(parsed.included, this.preferLocale, true);
1998 | }
1999 |
2000 | resolve(parsed);
2001 | } catch (e) {
2002 | console.warn('Error parsing internal API response', response, e);
2003 | reject(e);
2004 | }
2005 | });
2006 | }
2007 | });
2008 | } else {
2009 | reject(new Error('Could not find valid LI cookie'));
2010 | }
2011 | });
2012 | };
2013 |
2014 | return LinkedinToResumeJson;
2015 | })();
2016 |
--------------------------------------------------------------------------------
/src/schema.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Lookup keys for the standard profileView object
3 | */
4 | export const liSchemaKeys = {
5 | profile: '*profile',
6 | certificates: '*certificationView',
7 | education: '*educationView',
8 | workPositions: '*positionView',
9 | workPositionGroups: '*positionGroupView',
10 | skills: '*skillView',
11 | projects: '*projectView',
12 | attachments: '*summaryTreasuryMedias',
13 | volunteerWork: '*volunteerExperienceView',
14 | awards: '*honorView',
15 | publications: '*publicationView'
16 | };
17 | /**
18 | * Try to maintain a mapping between generic section types, and LI's schema
19 | * - tocKeys are pointers that often point to a collection of URNs
20 | * - Try to put dash strings last, profileView first
21 | * - Most recipes are dash only
22 | */
23 | export const liTypeMappings = {
24 | profile: {
25 | // There is no tocKey for profile in dash FullProfileWithEntries,
26 | // due to how entry-point is configured
27 | tocKeys: ['*profile'],
28 | types: [
29 | // regular profileView
30 | 'com.linkedin.voyager.identity.profile.Profile',
31 | // dash FullProfile
32 | 'com.linkedin.voyager.dash.identity.profile.Profile'
33 | ],
34 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities']
35 | },
36 | languages: {
37 | tocKeys: ['*languageView', '*profileLanguages'],
38 | types: ['com.linkedin.voyager.identity.profile.Language']
39 | },
40 | certificates: {
41 | tocKeys: ['*certificationView', '*profileCertifications'],
42 | types: ['com.linkedin.voyager.dash.identity.profile.Certification'],
43 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileCertification']
44 | },
45 | education: {
46 | tocKeys: ['*educationView', '*profileEducations'],
47 | types: [
48 | 'com.linkedin.voyager.identity.profile.Education',
49 | // Dash
50 | 'com.linkedin.voyager.dash.identity.profile.Education'
51 | ],
52 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileEducation']
53 | },
54 | courses: {
55 | tocKeys: ['*courseView', '*profileCourses'],
56 | types: ['com.linkedin.voyager.identity.profile.Course', 'com.linkedin.voyager.dash.identity.profile.Course'],
57 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileCourse']
58 | },
59 | // Individual work entries (not aggregate (workgroup) with date range)
60 | workPositions: {
61 | tocKeys: ['*positionView'],
62 | types: ['com.linkedin.voyager.identity.profile.Position', 'com.linkedin.voyager.dash.identity.profile.Position'],
63 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePosition']
64 | },
65 | // Work entry *groups*, aggregated by employer clumping
66 | workPositionGroups: {
67 | tocKeys: ['*positionGroupView', '*profilePositionGroups'],
68 | types: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroupsInjection'],
69 | recipes: [
70 | 'com.linkedin.voyager.identity.profile.PositionGroupView',
71 | 'com.linkedin.voyager.dash.deco.identity.profile.FullProfilePositionGroup',
72 | // Generic collection response
73 | 'com.linkedin.restli.common.CollectionResponse'
74 | ]
75 | },
76 | skills: {
77 | tocKeys: ['*skillView', '*profileSkills'],
78 | types: ['com.linkedin.voyager.identity.profile.Skill', 'com.linkedin.voyager.dash.identity.profile.Skill'],
79 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileSkill']
80 | },
81 | projects: {
82 | tocKeys: ['*projectView', '*profileProjects'],
83 | types: ['com.linkedin.voyager.identity.profile.Project', 'com.linkedin.voyager.dash.identity.profile.Project'],
84 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileProject']
85 | },
86 | attachments: {
87 | tocKeys: ['*summaryTreasuryMedias', '*profileTreasuryMediaPosition'],
88 | types: ['com.linkedin.voyager.identity.profile.Certification', 'com.linkedin.voyager.dash.identity.profile.treasury.TreasuryMedia'],
89 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileTreasuryMedia']
90 | },
91 | volunteerWork: {
92 | tocKeys: ['*volunteerExperienceView', '*profileVolunteerExperiences'],
93 | types: ['com.linkedin.voyager.dash.identity.profile.VolunteerExperience'],
94 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileVolunteerExperience']
95 | },
96 | awards: {
97 | tocKeys: ['*honorView', '*profileHonors'],
98 | types: ['com.linkedin.voyager.identity.profile.Honor', 'com.linkedin.voyager.dash.identity.profile.Honor'],
99 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfileHonor']
100 | },
101 | publications: {
102 | tocKeys: ['*publicationView', '*profilePublications'],
103 | types: ['com.linkedin.voyager.identity.profile.Publication', 'com.linkedin.voyager.dash.identity.profile.Publication'],
104 | recipes: ['com.linkedin.voyager.dash.deco.identity.profile.FullProfilePublication']
105 | }
106 | };
107 |
--------------------------------------------------------------------------------
/src/templates.js:
--------------------------------------------------------------------------------
1 | /** @type {Required} */
2 | export const resumeJsonTemplateLegacy = {
3 | basics: {
4 | name: '',
5 | label: '',
6 | picture: '',
7 | email: '',
8 | phone: '',
9 | website: '',
10 | summary: '',
11 | location: {
12 | address: '',
13 | postalCode: '',
14 | city: '',
15 | countryCode: '',
16 | region: ''
17 | },
18 | profiles: []
19 | },
20 | work: [],
21 | volunteer: [],
22 | education: [],
23 | awards: [],
24 | publications: [],
25 | skills: [],
26 | languages: [],
27 | interests: [],
28 | references: []
29 | };
30 |
31 | /** @type {Required} */
32 | export const resumeJsonTemplateStable = {
33 | $schema: 'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
34 | basics: {
35 | name: '',
36 | label: '',
37 | image: '',
38 | email: '',
39 | phone: '',
40 | url: '',
41 | summary: '',
42 | location: {
43 | address: '',
44 | postalCode: '',
45 | city: '',
46 | countryCode: '',
47 | region: ''
48 | },
49 | profiles: []
50 | },
51 | work: [],
52 | volunteer: [],
53 | education: [],
54 | awards: [],
55 | certificates: [],
56 | publications: [],
57 | skills: [],
58 | languages: [],
59 | interests: [],
60 | references: [],
61 | projects: [],
62 | meta: {
63 | version: 'v1.0.0',
64 | canonical: 'https://github.com/jsonresume/resume-schema/blob/v1.0.0/schema.json'
65 | }
66 | };
67 |
68 | /**
69 | * Beta can be combined with latest, so this is a partial (diff)
70 | * Currently even with 1.0
71 | * @type {Partial}
72 | */
73 | export const resumeJsonTemplateBetaPartial = {};
74 |
--------------------------------------------------------------------------------
/src/utilities.js:
--------------------------------------------------------------------------------
1 | /** @type {Record} */
2 | export const maxDaysOfMonth = {
3 | 1: 31,
4 | 2: 28,
5 | 3: 31,
6 | 4: 30,
7 | 5: 31,
8 | 6: 30,
9 | 7: 31,
10 | 8: 31,
11 | 9: 30,
12 | 10: 31,
13 | 11: 30,
14 | 12: 31
15 | };
16 |
17 | /**
18 | * If less than 10, zero pad left
19 | * @param {number} n - Numerical input
20 | * @returns {string} Left padded, stringified num
21 | */
22 | export function zeroLeftPad(n) {
23 | if (n < 10) {
24 | return `0${n}`;
25 | }
26 |
27 | return n.toString();
28 | }
29 |
30 | /**
31 | * Gets day, 1 if isStart=true else the last day of the month
32 | * @param {boolean} isStart
33 | * @returns {number} month
34 | */
35 | function getDefaultMonth(isStart) {
36 | return isStart ? 1 : 12;
37 | }
38 |
39 | /**
40 | * Gets day, 1 if isStart=true else the last day of the month
41 | * @param {Number} month
42 | * @param {boolean} isStart
43 | * @returns {number} day
44 | */
45 | function getDefaultDay(month, isStart) {
46 | return isStart ? 1 : maxDaysOfMonth[month];
47 | }
48 |
49 | /**
50 | * Parses an object with year, month and day and returns a string with the date.
51 | * @param {LiDate} dateObj
52 | * @param {boolean} isStart
53 | * @returns {string} Date, as string, formatted for JSONResume
54 | */
55 | function parseDate(dateObj, isStart) {
56 | const year = dateObj?.year;
57 |
58 | if (year === undefined) {
59 | return '';
60 | }
61 |
62 | const month = dateObj.month ?? getDefaultMonth(isStart);
63 | const day = dateObj.day ?? getDefaultDay(month, isStart);
64 |
65 | return `${year}-${zeroLeftPad(month)}-${zeroLeftPad(day)}`;
66 | }
67 |
68 | /**
69 | * Parses an object with year, month and day and returns a string with the date.
70 | * - If month is not present, should return 1.
71 | * - If day is not present, should return 1.
72 | *
73 | * @param {LiDate} dateObj
74 | * @returns {string} Date, as string, formatted for JSONResume
75 | */
76 | export function parseStartDate(dateObj) {
77 | return parseDate(dateObj, true);
78 | }
79 |
80 | /**
81 | * Parses an object with year, month and day and returns a string with the date.
82 | * - If month is not present, should return 12.
83 | * - If day is not present, should return last month day.
84 | *
85 | * @param {LiDate} dateObj
86 | * @returns {string} Date, as string, formatted for JSONResume
87 | */
88 | export function parseEndDate(dateObj) {
89 | return parseDate(dateObj, false);
90 | }
91 |
92 | /**
93 | * Converts a LI Voyager style date object into a native JS Date object
94 | * @param {LiDate} liDateObj
95 | * @returns {Date} date object
96 | */
97 | export function liDateToJSDate(liDateObj) {
98 | // This is a cheat; by passing string + 00:00, we can force Date to not offset (by timezone), and also treat month as NOT zero-indexed, which is how LI uses it
99 | return new Date(`${parseStartDate(liDateObj)} 00:00`);
100 | }
101 |
102 | /**
103 | * Trigger a file download prompt with given content
104 | * @see https://davidwalsh.name/javascript-download
105 | * @param {string} data
106 | * @param {string} fileName
107 | * @param {string} [type]
108 | */
109 | export function promptDownload(data, fileName, type = 'text/plain') {
110 | // Create an invisible A element
111 | const a = document.createElement('a');
112 | a.style.display = 'none';
113 | document.body.appendChild(a);
114 |
115 | // Set the HREF to a Blob representation of the data to be downloaded
116 | a.href = window.URL.createObjectURL(new Blob([data], { type }));
117 |
118 | // Use download attribute to set set desired file name
119 | a.setAttribute('download', fileName);
120 |
121 | // Trigger download by simulating click
122 | a.click();
123 |
124 | // Cleanup
125 | window.URL.revokeObjectURL(a.href);
126 | document.body.removeChild(a);
127 | }
128 |
129 | /**
130 | * Get a cookie by name
131 | * @param {string} name
132 | */
133 | export function getCookie(name) {
134 | const v = document.cookie.match(`(^|;) ?${name}=([^;]*)(;|$)`);
135 | return v ? v[2] : null;
136 | }
137 |
138 | /**
139 | * Get URL response as base64
140 | * @param {string} url - URL to convert
141 | * @param {boolean} [omitDeclaration] - remove the `data:...` declaration prefix
142 | * @returns {Promise<{dataStr: string, mimeStr: string}>} base64 results
143 | */
144 | export async function urlToBase64(url, omitDeclaration = false) {
145 | const res = await fetch(url);
146 | const blob = await res.blob();
147 | return new Promise((resolve, reject) => {
148 | const reader = new FileReader();
149 | reader.onloadend = () => {
150 | const declarationPatt = /^data:([^;]+)[^,]+base64,/i;
151 | let dataStr = /** @type {string} */ (reader.result);
152 | const mimeStr = dataStr.match(declarationPatt)[1];
153 | if (omitDeclaration) {
154 | dataStr = dataStr.replace(declarationPatt, '');
155 | }
156 |
157 | resolve({
158 | dataStr,
159 | mimeStr
160 | });
161 | };
162 | reader.onerror = reject;
163 | reader.readAsDataURL(blob);
164 | });
165 | }
166 |
167 | /**
168 | * Set multiple query string params by passing an object
169 | * @param {string} url
170 | * @param {Record} paramPairs
171 | */
172 | export function setQueryParams(url, paramPairs) {
173 | const urlInstance = new URL(url);
174 | /** @type {Record} */
175 | const existingQueryPairs = {};
176 | urlInstance.searchParams.forEach((val, key) => {
177 | existingQueryPairs[key] = val;
178 | });
179 | urlInstance.search = new URLSearchParams({
180 | ...existingQueryPairs,
181 | ...paramPairs
182 | }).toString();
183 | return urlInstance.toString();
184 | }
185 |
186 | /**
187 | * Replace a value with a default if it is null or undefined
188 | * @param {any} value
189 | * @param {any} [optDefaultVal]
190 | */
191 | export function noNullOrUndef(value, optDefaultVal) {
192 | const defaultVal = optDefaultVal || '';
193 | return typeof value === 'undefined' || value === null ? defaultVal : value;
194 | }
195 |
196 | /**
197 | * Copy with `json.parse(json.stringify())`
198 | * @template T
199 | * @param {T & Record} inputObj
200 | * @param {Array} [removeKeys] properties (top-level only) to remove
201 | * @returns {T}
202 | */
203 | export function lazyCopy(inputObj, removeKeys = []) {
204 | const copied = JSON.parse(JSON.stringify(inputObj));
205 | removeKeys.forEach((k) => delete copied[k]);
206 | return copied;
207 | }
208 |
209 | /**
210 | * "Remaps" data that is nested under a multilingual wrapper, hoisting it
211 | * back up to top-level keys (overwriting existing values).
212 | *
213 | * WARNING: Modifies object IN PLACE
214 | * @example
215 | * ```js
216 | * const input = {
217 | * firstName: 'Алексе́й',
218 | * multiLocaleFirstName: {
219 | * ru_RU: 'Алексе́й',
220 | * en_US: 'Alexey'
221 | * }
222 | * }
223 | * console.log(remapNestedLocale(input, 'en_US').firstName);
224 | * // 'Alexey'
225 | * ```
226 | * @param {LiEntity | LiEntity[]} liObject The LI response object(s) to remap
227 | * @param {string} desiredLocale Desired Locale string (LI format / ISO-3166-1). Defaults to instance property
228 | * @param {boolean} [deep] Run remapper recursively, replacing at all applicable levels
229 | */
230 | export function remapNestedLocale(liObject, desiredLocale, deep = true) {
231 | if (Array.isArray(liObject)) {
232 | liObject.forEach((o) => {
233 | remapNestedLocale(o, desiredLocale, deep);
234 | });
235 | } else {
236 | Object.keys(liObject).forEach((prop) => {
237 | const nestedVal = liObject[prop];
238 | if (!!nestedVal && typeof nestedVal === 'object') {
239 | // Test for locale wrapped property
240 | // example: `multiLocaleFirstName`
241 | if (prop.startsWith('multiLocale')) {
242 | /** @type {Record} */
243 | const localeMap = nestedVal;
244 | // eslint-disable-next-line no-prototype-builtins
245 | if (localeMap.hasOwnProperty(desiredLocale)) {
246 | // Transform multiLocaleFirstName to firstName
247 | const nonPrefixedKeyPascalCase = prop.replace(/multiLocale/i, '');
248 | const nonPrefixedKeyLowerCamelCase = nonPrefixedKeyPascalCase.charAt(0).toLocaleLowerCase() + nonPrefixedKeyPascalCase.substring(1);
249 | // Remap nested value to top level
250 | liObject[nonPrefixedKeyLowerCamelCase] = localeMap[desiredLocale];
251 | }
252 | } else if (deep) {
253 | remapNestedLocale(liObject[prop], desiredLocale, deep);
254 | }
255 | }
256 | });
257 | }
258 | }
259 |
260 | /**
261 | * Retrieve a LI Company Page URL from a company URN
262 | * @param {string} companyUrn
263 | * @param {InternalDb} db
264 | */
265 | export function companyLiPageFromCompanyUrn(companyUrn, db) {
266 | if (typeof companyUrn === 'string') {
267 | // Dash
268 | const company = db.getElementByUrn(companyUrn);
269 | if (company && company.url) {
270 | return company.url;
271 | }
272 |
273 | // profileView
274 | const linkableCompanyIdMatch = /urn.+Company:(\d+)/.exec(companyUrn);
275 | if (linkableCompanyIdMatch) {
276 | return `https://www.linkedin.com/company/${linkableCompanyIdMatch[1]}`;
277 | }
278 | }
279 | return '';
280 | }
281 |
282 | /**
283 | * Since LI entities can store dates in different ways, but
284 | * JSONResume only stores in one, this utility method will detect
285 | * which format LI is using, parse it, and attach to Resume object
286 | * (e.g. work position entry), with correct date format
287 | * - NOTE: This modifies object in-place
288 | * @param {GenObj} resumeObj
289 | * @param {LiEntity} liEntity
290 | */
291 | export function parseAndAttachResumeDates(resumeObj, liEntity) {
292 | // Time period can either come as `timePeriod` or `dateRange` prop
293 | const timePeriod = liEntity.timePeriod || liEntity.dateRange;
294 | if (timePeriod) {
295 | const start = timePeriod.startDate || timePeriod.start;
296 | const end = timePeriod.endDate || timePeriod.end;
297 | if (end) {
298 | resumeObj.endDate = parseEndDate(end);
299 | }
300 | if (start) {
301 | resumeObj.startDate = parseStartDate(start);
302 | }
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [
3 | "node_modules",
4 | "build",
5 | "build-bookmarklet",
6 | "build-browserext",
7 | "webstore-zips",
8 | "scratch"
9 | ],
10 | "compilerOptions": {
11 | "baseUrl": ".",
12 | "checkJs": true,
13 | "noEmit": true,
14 | "noImplicitReturns": true,
15 | "alwaysStrict": true,
16 | "declaration": false,
17 | "noImplicitAny": true,
18 | "maxNodeModuleJsDepth": 0,
19 | "target": "es2015",
20 | "moduleResolution": "node",
21 | "allowSyntheticDefaultImports": true,
22 | "resolveJsonModule": true
23 | }
24 | }
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | /** @type {import('webpack').Configuration} */
4 | module.exports = {
5 | mode: 'development',
6 | entry: './src/main.js',
7 | output: {
8 | filename: 'main.js',
9 | path: path.resolve(__dirname, 'build')
10 | },
11 | target: 'web',
12 | module: {
13 | rules: [
14 | {
15 | test: /\.(js|jsx)$/,
16 | exclude: /node_modules/,
17 | loader: 'babel-loader'
18 | }
19 | ]
20 | },
21 | devtool: 'eval-source-map'
22 | };
23 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | /** @type {import('webpack').Configuration} */
4 | module.exports = {
5 | mode: 'production',
6 | entry: './src/main.js',
7 | output: {
8 | filename: 'main.js',
9 | path: path.resolve(__dirname, 'build')
10 | },
11 | target: 'web',
12 | module: {
13 | rules: [
14 | {
15 | test: /\.(js|jsx)$/,
16 | exclude: /node_modules/,
17 | loader: 'babel-loader'
18 | }
19 | ]
20 | }
21 | };
22 |
--------------------------------------------------------------------------------
/webstore-assets/Webstore-Screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshuatz/linkedin-to-jsonresume/772d66522dc60f8ce930eea4190cd38b52e5a24c/webstore-assets/Webstore-Screenshot.png
--------------------------------------------------------------------------------
/webstore-assets/webstore.md:
--------------------------------------------------------------------------------
1 | # Detailed Description
2 | Exports your profile to JSON Resume. Details at https://joshuatz.com/projects/web-stuff/linkedin-profile-to-json-resume-exporter-bookmarklet/.
3 |
4 | # Links
5 | - Link to website
6 | - https://joshuatz.com/projects/web-stuff/linkedin-profile-to-json-resume-exporter-bookmarklet/
7 | - Link to support & FAQ
8 | - https://github.com/joshuatz/linkedin-to-jsonresume
9 |
10 | # Category
11 | Productivity
12 |
13 | # Language
14 | English
--------------------------------------------------------------------------------