├── .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 ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/joshuatz/linkedin-to-jsonresume) 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 | ![Demo GIF](./docs/demo-chrome_extension.gif "Demo Gif") 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 | ![Export Language Selector](./docs/multilingual-support.png) 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 | 15 | 16 | 17 |
18 |
19 |
20 | 26 |
27 |
28 |
29 |
30 | Select JSONResume version: 31 | 36 |
37 |
38 |
39 |
Info
40 |
Report Issue
41 |
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 = `
1525 |
1526 |
Profile Export:
1527 |
X
1528 |
1529 |
1530 | 1531 |
1532 |
`; 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 --------------------------------------------------------------------------------