├── .github ├── funding.yml └── workflows │ └── main.yml ├── .npmrc ├── src ├── index.ts ├── utils.test.ts ├── utils.ts ├── linkedin-client.test.ts ├── linkedin-utils.ts ├── types.ts └── linkedin-client.ts ├── .eslintrc.json ├── .editorconfig ├── .prettierrc ├── tsup.config.ts ├── .env.example ├── .gitignore ├── tsconfig.json ├── license ├── bin └── example.ts ├── package.json └── readme.md /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | package-manager-strict=false 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './linkedin-client' 2 | export * from './linkedin-utils' 3 | export * from './types' 4 | export * from './utils' 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@fisch0920/eslint-config/node"], 4 | "rules": { 5 | "unicorn/no-array-reduce": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "bracketSameLine": false, 9 | "arrowParens": "always", 10 | "trailingComma": "none" 11 | } 12 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['src/index.ts'], 6 | outDir: 'dist', 7 | target: 'node18', 8 | platform: 'node', 9 | format: ['esm'], 10 | splitting: false, 11 | sourcemap: true, 12 | minify: false, 13 | shims: true, 14 | dts: true 15 | } 16 | ]) 17 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | 3 | import { omit } from './utils' 4 | 5 | test('omit', () => { 6 | expect(omit({ a: 1, b: 2, c: 3 }, 'a', 'c')).toEqual({ b: 2 }) 7 | expect(omit({ a: { b: 'foo' }, d: -1, foo: null }, 'b', 'foo')).toEqual({ 8 | a: { b: 'foo' }, 9 | d: -1 10 | }) 11 | expect(omit({ a: 1, b: 2, c: 3 }, 'foo', 'bar', 'c')).toEqual({ a: 1, b: 2 }) 12 | }) 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # This is an example .env file. 3 | # 4 | # All of these environment vars must be defined either in your environment or in 5 | # a local .env file in order to run the examples in this project. 6 | # ------------------------------------------------------------------------------ 7 | 8 | LINKEDIN_EMAIL= 9 | LINKEDIN_PASSWORD= 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | .next/ 13 | 14 | # production 15 | build/ 16 | dist/ 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # turbo 32 | .turbo 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | .env 42 | 43 | old/ 44 | out/ 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "lib": ["esnext", "dom.iterable"], 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "noEmit": true, 9 | "target": "es2020", 10 | "outDir": "dist", 11 | 12 | "allowImportingTsExtensions": false, 13 | "allowJs": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "incremental": false, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "noUncheckedIndexedAccess": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "sourceMap": true, 23 | "strict": true, 24 | "useDefineForClassFields": true 25 | // "verbatimModuleSyntax": true 26 | }, 27 | "include": ["src", "bin"] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | node-version: 13 | - 18 14 | - 22 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v3 22 | id: pnpm-install 23 | with: 24 | version: 9.12.3 25 | run_install: false 26 | 27 | - name: Install Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'pnpm' 32 | 33 | - name: Install dependencies 34 | run: pnpm install --frozen-lockfile --strict-peer-dependencies 35 | 36 | - name: Run test 37 | run: pnpm test 38 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Travis Fischer 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 | -------------------------------------------------------------------------------- /bin/example.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import ky from 'ky' 4 | import { EnvHttpProxyAgent } from 'undici' 5 | 6 | import { LinkedInClient } from '../src' 7 | 8 | /** 9 | * Scratch pad for testing. 10 | */ 11 | async function main() { 12 | const linkedin = new LinkedInClient({ 13 | ky: ky.extend({ 14 | dispatcher: new EnvHttpProxyAgent() as any 15 | }), 16 | debug: true 17 | }) 18 | 19 | // await linkedin.authenticate() 20 | console.log(linkedin.config.path) 21 | 22 | const res = await linkedin.getProfile('fisch2') 23 | // const res = await linkedin.getProfileExperiences( 24 | // 'ACoAAAdVCacB9uO3u3vDtvGPnDQeweefI2nV0gw' 25 | // ) 26 | 27 | // await linkedin.authenticate() 28 | // console.log('authenticated', linkedin.config.get('cookies')) 29 | 30 | // const res = await linkedin.getMe() 31 | // const res = await linkedin.getSchool('brown-university') 32 | // const res = await linkedin.getSchool('157343') 33 | // const res = await linkedin.getSchool('urn:li:fs_normalized_company:157343') 34 | // const res = await linkedin.getCompany('microsoft') 35 | // const res = await linkedin.getProfileUpdates( 36 | // 'fisch2' 37 | // ) 38 | // const res = await linkedin.searchPeople('travis fischer') 39 | // const res = await linkedin.searchCompanies('automagical ai video') 40 | console.log(JSON.stringify(res, null, 2)) 41 | } 42 | 43 | await main() 44 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SetCookie } from 'cookie-es' 2 | import Conf from 'conf' 3 | 4 | export function assert( 5 | value: unknown, 6 | message?: string | Error 7 | ): asserts value { 8 | if (value) { 9 | return 10 | } 11 | 12 | if (!message) { 13 | throw new Error('Assertion failed') 14 | } 15 | 16 | throw typeof message === 'string' ? new Error(message) : message 17 | } 18 | 19 | export function getConfigForUser(email: string) { 20 | return new Conf({ projectName: 'linkedin-api', configName: email }) 21 | } 22 | 23 | export function getEnv(name: string): string | undefined { 24 | try { 25 | return typeof process !== 'undefined' 26 | ? // eslint-disable-next-line no-process-env 27 | process.env?.[name] 28 | : undefined 29 | } catch { 30 | return undefined 31 | } 32 | } 33 | 34 | export function encodeCookies(cookies: Record): string { 35 | return Object.values(cookies) 36 | .map((cookie) => `${cookie.name}=${cookie.value}`) 37 | .join('; ') 38 | } 39 | 40 | /** 41 | * From `inputObj`, create a new object that does not include `keys`. 42 | * 43 | * @example 44 | * ```js 45 | * omit({ a: 1, b: 2, c: 3 }, 'a', 'c') // { b: 2 } 46 | * ``` 47 | */ 48 | export const omit = < 49 | T extends Record | object, 50 | K extends keyof any 51 | >( 52 | inputObj: T, 53 | ...keys: K[] 54 | ): Omit => { 55 | const keysSet = new Set(keys) 56 | return Object.fromEntries( 57 | Object.entries(inputObj).filter(([k]) => !keysSet.has(k as any)) 58 | ) as any 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkedin-api-fetch", 3 | "version": "1.0.1", 4 | "description": "TypeScript client for LinkedIn's unofficial API.", 5 | "author": "Travis Fischer ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/transitive-bullshit/linkedin-api.git" 10 | }, 11 | "packageManager": "pnpm@9.12.3", 12 | "engines": { 13 | "node": ">=18" 14 | }, 15 | "type": "module", 16 | "main": "./dist/index.js", 17 | "source": "./src/index.ts", 18 | "types": "./dist/index.d.ts", 19 | "sideEffects": false, 20 | "exports": { 21 | ".": { 22 | "types": "./dist/index.d.ts", 23 | "default": "./dist/index.js" 24 | } 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "scripts": { 30 | "build": "tsup", 31 | "clean": "del clean", 32 | "prebuild": "run-s clean", 33 | "pretest": "run-s build", 34 | "test": "run-s test:*", 35 | "test:format": "prettier --check \"**/*.{js,ts,tsx}\"", 36 | "test:lint": "eslint .", 37 | "test:typecheck": "tsc --noEmit", 38 | "test-unit": "vitest run" 39 | }, 40 | "dependencies": { 41 | "conf": "^13.0.1", 42 | "cookie-es": "^1.2.2", 43 | "delay": "^6.0.0", 44 | "ky": "^1.7.2", 45 | "p-throttle": "^6.2.0" 46 | }, 47 | "devDependencies": { 48 | "@fisch0920/eslint-config": "^1.4.0", 49 | "@types/node": "^22.9.0", 50 | "del-cli": "^6.0.0", 51 | "dotenv": "^16.4.5", 52 | "eslint": "^8.57.1", 53 | "npm-run-all2": "^7.0.1", 54 | "prettier": "^3.3.3", 55 | "tsup": "^8.3.5", 56 | "typescript": "^5.6.3", 57 | "undici": "^6.19.8", 58 | "vitest": "2.1.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/linkedin-client.test.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import ky from 'ky' 4 | import { EnvHttpProxyAgent } from 'undici' 5 | import { beforeAll, describe, expect, test } from 'vitest' 6 | 7 | import { LinkedInClient } from './linkedin-client' 8 | 9 | describe('LinkedInClient', () => { 10 | let linkedin: LinkedInClient 11 | 12 | beforeAll(async () => { 13 | linkedin = new LinkedInClient({ 14 | ky: ky.extend({ 15 | dispatcher: new EnvHttpProxyAgent() as any 16 | }) 17 | }) 18 | 19 | await linkedin.ensureAuthenticated() 20 | }) 21 | 22 | test( 23 | 'getMe()', 24 | { 25 | timeout: 30_000 26 | }, 27 | async () => { 28 | const res = await linkedin.getMe() 29 | expect(res.miniProfile.entityUrn).toBeTruthy() 30 | expect(res.miniProfile.firstName).toBeTruthy() 31 | expect(res.miniProfile.lastName).toBeTruthy() 32 | } 33 | ) 34 | 35 | test( 36 | "getProfile('fisch2')", 37 | { 38 | timeout: 30_000 39 | }, 40 | async () => { 41 | const res = await linkedin.getProfile('fisch2') 42 | expect(res.firstName).toBe('Travis') 43 | expect(res.lastName).toBe('Fischer') 44 | expect(res.id).toBe('ACoAAAdVCacB9uO3u3vDtvGPnDQeweefI2nV0gw') 45 | } 46 | ) 47 | 48 | test( 49 | "getProfileExperiences('fisch2')", 50 | { 51 | timeout: 30_000 52 | }, 53 | async () => { 54 | const res = await linkedin.getProfileExperiences( 55 | 'ACoAAAdVCacB9uO3u3vDtvGPnDQeweefI2nV0gw' 56 | ) 57 | expect(res.length).toBeGreaterThanOrEqual(5) 58 | } 59 | ) 60 | 61 | test( 62 | "getSchool('brown-university')", 63 | { 64 | timeout: 30_000 65 | }, 66 | async () => { 67 | const res = await linkedin.getSchool('brown-university') 68 | expect(res.name).toBe('Brown University') 69 | expect(res.id).toBe('157343') 70 | } 71 | ) 72 | 73 | test( 74 | "getCompany('microsoft')", 75 | { 76 | timeout: 30_000 77 | }, 78 | async () => { 79 | const res = await linkedin.getCompany('microsoft') 80 | expect(res.name).toBe('Microsoft') 81 | expect(res.id).toBe('1035') 82 | } 83 | ) 84 | }) 85 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # linkedin-api-fetch 2 | 3 | > TypeScript client for LinkedIn's unofficial API. 4 | 5 |

6 | Build Status 7 | NPM 8 | MIT License 9 | Prettier Code Formatting 10 |

11 | 12 | - [Intro](#intro) 13 | - [Install](#install) 14 | - [Usage](#usage) 15 | - [Authentication](#authentication) 16 | - [Rate Limiting](#rate-limiting) 17 | - [Proxies](#proxies) 18 | - [Troubleshooting](#troubleshooting) 19 | - [`CHALLENGE` Errors](#challenge-errors) 20 | - [401 Errors](#401-errors) 21 | - [TODO](#todo) 22 | - [Disclaimer](#disclaimer) 23 | - [License](#license) 24 | 25 | ## Intro 26 | 27 | This package provides a HTTP API client for accessing LinkedIn's readonly Voyager APIs. These are the same APIs that the official LinkedIn webapp uses to fetch data about user profiles, companies, and jobs. 28 | 29 | No official API access is required. All you need is a valid LinkedIn user account (email and password). 30 | 31 | > [!IMPORTANT] 32 | > This library is not officially supported by LinkedIn. Using this library might violate LinkedIn's Terms of Service. Use it at your own risk. 33 | 34 | ## Install 35 | 36 | ```sh 37 | npm install linkedin-api-fetch 38 | ``` 39 | 40 | ## Usage 41 | 42 | ```ts 43 | import { LinkedInClient } from 'linkedin-api-fetch' 44 | 45 | const linkedin = new LinkedInClient({ 46 | email: 'todo@example.com', // defaults to LINKEDIN_EMAIL 47 | password: 'todo' // defaults to LINKEDIN_PASSWORD 48 | }) 49 | 50 | const user = await linkedin.getProfile('fisch2') 51 | const company = await linkedin.getCompany('microsoft') 52 | const school = await linkedin.getSchool('brown-university') 53 | 54 | const peopleSearchResults = await linkedin.searchPeople('travis fischer') 55 | const companySearchResults = await linkedin.searchCompanies('openai') 56 | ``` 57 | 58 | LinkedIn's internal data format is pretty verbose, so these methods all normalize the raw responses into a more reasonable format. Most API methods include a `Raw` version to return the original data: `getProfileRaw`, `getCompanyRaw`, `getSchoolRaw`, etc. 59 | 60 | ### Authentication 61 | 62 | `LinkedInClient` will authenticate lazily using the provided email and password, or you can authenticate eagerly by calling `LinkedInClient.ensureAuthenticated()`. 63 | 64 | The resulting cookies are stored using [conf](https://github.com/sindresorhus/conf) in a platform-dependent user data directory. You can access the cookie data via `linkedin.config.path` which will point to a path on your filesystem. 65 | 66 | Auth cookies are re-initialized automatically either when they expire or when the client runs into a `401`/`403` HTTP error. You can force the auth cookie to refresh manually by calling `linkedin.authenticate()` which returns a `Promise`. 67 | 68 | If you want to force re-authentication and ignore the existing cookies, use `LinkedInClient.authenticate()`. 69 | 70 | > [!IMPORTANT] 71 | > I recommend not using your personal LinkedIn account credentials with any LinkedIn scraping library unless you don't care about the possibility of being banned. Create a throwaway account for testing purposes. 72 | 73 | ### Rate Limiting 74 | 75 | It is highly recommended that you throttle your API requests to LinkedIn to avoid being blocked. The default `LinkedInClient` adds a random delay between 1-5 seconds before each API request in order to try and evade detection. The default throttle also enforces a low rate-limit. It's easy to customize this default rate limit by disabling the default `throttle` and overriding the default `ky` instance: 76 | 77 | ```ts 78 | import { LinkedInClient } from 'linkedin-api-fetch' 79 | import pThrottle from 'p-throttle' 80 | import throttleKy from 'throttle-ky' 81 | import ky from 'ky' 82 | 83 | // Custom rate-limit allowing up to 1 request every 5 seconds 84 | const throttle = pThrottle({ 85 | limit: 1, 86 | interval: 5 * 1000 87 | }) 88 | 89 | const linkedin = new LinkedInClient({ 90 | // Override the default `ky` instance which all API requests will use 91 | ky: throttleKy(ky, throttle), 92 | 93 | // Disable the default throttling 94 | throttle: false 95 | }) 96 | ``` 97 | 98 | ### Proxies 99 | 100 | The easiest way to use a proxy with Node.js `fetch` is via undici's [EnvHttpProxyAgent](https://github.com/nodejs/undici/blob/main/docs/docs/api/EnvHttpProxyAgent.md), which will respect the `http_proxy`, `https_proxy`, and `no_proxy` environment variables. 101 | 102 | ```sh 103 | npm install undici 104 | ``` 105 | 106 | ```ts 107 | import { LinkedInClient } from 'linkedin-api-fetch' 108 | import { EnvHttpProxyAgent } from 'undici' 109 | import ky from 'ky' 110 | 111 | const linkedin = new LinkedInClient({ 112 | ky: ky.extend({ 113 | dispatcher: new EnvHttpProxyAgent() as any 114 | }) 115 | }) 116 | ``` 117 | 118 | ## Troubleshooting 119 | 120 | ### `CHALLENGE` Errors 121 | 122 | LinkedIn will sometimes respond to authentication requests with a Challenge URL. This can happen if LinkedIn suspects your account is being used programatically (possibly a combination of IP-based, usage-based, and/or workload-based). 123 | 124 | If you get a `CHALLENGE` error, you'll need to manually log out and log back in to your account using a browser. 125 | 126 | **Known reasons for Challenge** include: 127 | 128 | - 2FA 129 | - Rate-limit - "It looks like you’re visiting a very high number of pages on LinkedIn.". Note - n=1 experiment where this page was hit after ~900 contiguous requests in a single session (within the hour) (these included random delays between each request), as well as a bunch of testing, so who knows the actual limit. 130 | 131 | ### 401 Errors 132 | 133 | If you get a 401 error when trying to authenticate, you likely need to log in via your browser. LinkedIn will sometimes see traffic as suspicious and require a combination of email code verification and CAPTCHA. 134 | 135 | Once you can log in via a browser with being challenged with additional auth, then this library should be able to authenticate properly. 136 | 137 | ## TODO 138 | 139 | - `searchJobs()` 140 | - port more methods from the python version https://github.com/tomquirk/linkedin-api 141 | 142 | ## Disclaimer 143 | 144 | This library is not endorsed or supported by LinkedIn. It is an unofficial library intended for educational purposes and personal use only. By using this library, you agree to not hold the author or contributors responsible for any consequences resulting from its usage. 145 | 146 | ## License 147 | 148 | MIT © [Travis Fischer](https://x.com/transitive_bs) 149 | 150 | This package is a TypeScript port of the popular [Python linkedin-api](https://github.com/tomquirk/linkedin-api). 151 | 152 | If you found this project helpful, please consider starring it and [following me on Twitter](https://x.com/transitive_bs). 153 | -------------------------------------------------------------------------------- /src/linkedin-utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AffiliatedCompany, 3 | Artifact, 4 | ExperienceItem, 5 | Group, 6 | LIDate, 7 | LinkedVectorImage, 8 | Organization, 9 | RawOrganization, 10 | ShowcasePage, 11 | VectorImage 12 | } from './types' 13 | import { assert, omit } from './utils' 14 | 15 | /** 16 | * Return the ID of a given Linkedin URN. 17 | * 18 | * Example: urn:li:fs_miniProfile: 19 | */ 20 | export function getIdFromUrn(urn?: string) { 21 | return urn?.split(':').at(-1) 22 | } 23 | 24 | /** 25 | * Return the URN of a raw group update 26 | * 27 | * Example: urn:li:fs_miniProfile: 28 | * Example: urn:li:fs_updateV2:(,GROUP_FEED,EMPTY,DEFAULT,false) 29 | */ 30 | export function getUrnFromRawUpdate(update?: string) { 31 | return update?.split('(')[1]?.split(',').at(0)?.trim() 32 | } 33 | 34 | export function isLinkedInUrn(urn?: string) { 35 | return urn?.startsWith('urn:li:') && urn.split(':').length >= 4 36 | } 37 | 38 | export function parseExperienceItem( 39 | item: any, 40 | { isGroupItem = false, included }: { isGroupItem?: boolean; included: any[] } 41 | ): ExperienceItem { 42 | const component = item.components.entityComponent 43 | const title = component.titleV2.text.text 44 | const subtitle = component.subtitle 45 | const subtitleParts = subtitle?.text?.split(' · ') 46 | const company = subtitleParts?.[0] 47 | const employmentType = subtitleParts?.[1] 48 | const companyId: string | undefined = 49 | getIdFromUrn(component.image?.attributes?.[0]?.['*companyLogo']) ?? 50 | component.image?.actionTarget?.split('/').findLast(Boolean) 51 | const companyUrn = companyId ? `urn:li:fsd_company:${companyId}` : undefined 52 | let companyImage: string | undefined 53 | 54 | if (companyId) { 55 | const companyEntity = included.find((i: any) => 56 | i.entityUrn?.endsWith(companyId) 57 | ) 58 | 59 | if (companyEntity) { 60 | companyImage = resolveImageUrl( 61 | companyEntity.logoResolutionResult?.vectorImage 62 | ) 63 | } 64 | } 65 | 66 | const metadata = component?.metadata || {} 67 | const location = metadata?.text 68 | 69 | const durationText = component.caption?.text 70 | const durationParts = durationText?.split(' · ') 71 | const dateParts = durationParts?.[0]?.split(' - ') 72 | 73 | const duration = durationParts?.[1] 74 | const startDate = dateParts?.[0] 75 | const endDate = dateParts?.[1] 76 | 77 | const subComponents = component.subComponents 78 | const fixedListComponent = 79 | subComponents?.components?.[0]?.components?.fixedListComponent 80 | 81 | const fixedListTextComponent = 82 | fixedListComponent?.components?.[0]?.components?.textComponent 83 | 84 | const description = fixedListTextComponent?.text?.text 85 | 86 | const parsedData: ExperienceItem = { 87 | title, 88 | companyName: !isGroupItem ? company : undefined, 89 | employmentType: isGroupItem ? company : employmentType, 90 | location, 91 | duration, 92 | startDate, 93 | endDate, 94 | description, 95 | company: { 96 | entityUrn: companyUrn, 97 | id: companyId, 98 | name: !isGroupItem ? company : undefined, 99 | logo: companyImage 100 | } 101 | } 102 | 103 | return parsedData 104 | } 105 | 106 | export function getGroupedItemId(item: any): string | undefined { 107 | const subComponents = item.components?.entityComponent?.subComponents 108 | const subComponentsComponents = subComponents?.components?.[0]?.components 109 | 110 | const pagedListComponentId = subComponentsComponents?.['*pagedListComponent'] 111 | 112 | if (pagedListComponentId?.includes('fsd_profilePositionGroup')) { 113 | const pattern = /urn:li:fsd_profilePositionGroup:\([\dA-z]+,[\dA-z]+\)/ 114 | const match = pagedListComponentId.match(pattern) 115 | return match?.[0] 116 | } 117 | 118 | return undefined 119 | } 120 | 121 | export function resolveImageUrl(vectorImage?: VectorImage): string | undefined { 122 | if (!vectorImage?.rootUrl) return 123 | if (!vectorImage.artifacts?.length) return 124 | 125 | const largestArtifact = vectorImage.artifacts.reduce( 126 | (a, b) => { 127 | if (b.width > a.width) return b 128 | return a 129 | }, 130 | vectorImage.artifacts[0] ?? ({ width: 0, height: 0 } as Artifact) 131 | ) 132 | 133 | if (!largestArtifact?.fileIdentifyingUrlPathSegment) return 134 | 135 | return `${vectorImage.rootUrl}${largestArtifact.fileIdentifyingUrlPathSegment}` 136 | } 137 | 138 | export function resolveLinkedVectorImageUrl( 139 | linkedVectorImage?: LinkedVectorImage 140 | ): string | undefined { 141 | return resolveImageUrl(linkedVectorImage?.['com.linkedin.common.VectorImage']) 142 | } 143 | 144 | export function stringifyLinkedInDate(date?: LIDate): string | undefined { 145 | if (!date) return undefined 146 | if (date.year === undefined) return undefined 147 | 148 | return [date.year, date.month].filter(Boolean).join('-') 149 | } 150 | 151 | export function normalizeRawOrganization(o?: RawOrganization): Organization { 152 | assert(o, 'Missing organization') 153 | assert(o.entityUrn, 'Invalid organization: missing entityUrn') 154 | 155 | const id = getIdFromUrn(o.entityUrn) 156 | assert(id, `Invalid organization ID: ${o.entityUrn}`) 157 | 158 | return { 159 | ...omit( 160 | o, 161 | 'universalName', 162 | 'logo', 163 | 'backgroundCoverImage', 164 | 'coverPhoto', 165 | 'overviewPhoto', 166 | '$recipeType', 167 | 'callToAction', 168 | 'phone', 169 | 'permissions', 170 | 'followingInfo', 171 | 'adsRule', 172 | 'autoGenerated', 173 | 'lcpTreatment', 174 | 'staffingCompany', 175 | 'showcase', 176 | 'paidCompany', 177 | 'claimable', 178 | 'claimableByViewer', 179 | 'viewerPendingAdministrator', 180 | 'viewerConnectedToAdministrator', 181 | 'viewerFollowingJobsUpdates', 182 | 'viewerEmployee', 183 | 'associatedHashtags', 184 | 'associatedHashtagsResolutionResults', 185 | 'affiliatedCompaniesResolutionResults', 186 | 'groupsResolutionResults', 187 | 'showcasePagesResolutionResults' 188 | ), 189 | id, 190 | publicIdentifier: o.universalName, 191 | logo: resolveLinkedVectorImageUrl(o.logo?.image), 192 | backgroundCoverImage: resolveLinkedVectorImageUrl( 193 | o.backgroundCoverImage?.image 194 | ), 195 | coverPhoto: 196 | o.coverPhoto?.['com.linkedin.voyager.common.MediaProcessorImage']?.id, 197 | overviewPhoto: 198 | o.overviewPhoto?.['com.linkedin.voyager.common.MediaProcessorImage']?.id, 199 | callToActionUrl: o.callToAction?.url, 200 | phone: o.phone?.number, 201 | numFollowers: o.followingInfo?.followerCount, 202 | affiliatedCompaniesResolutionResults: Object.fromEntries( 203 | Object.entries(o.affiliatedCompaniesResolutionResults ?? {}).map( 204 | ([k, v]) => [ 205 | k, 206 | { 207 | ...omit( 208 | v, 209 | 'universalName', 210 | 'logo', 211 | '$recipeType', 212 | 'followingInfo', 213 | 'showcase', 214 | 'paidCompany' 215 | ), 216 | id: getIdFromUrn(v.entityUrn)!, 217 | publicIdentifier: v.universalName, 218 | numFollowers: v.followingInfo?.followerCount, 219 | logo: resolveLinkedVectorImageUrl(v.logo?.image) 220 | } as AffiliatedCompany 221 | ] 222 | ) 223 | ), 224 | groupsResolutionResults: Object.fromEntries( 225 | Object.entries(o.groupsResolutionResults ?? {}).map(([k, v]) => [ 226 | k, 227 | { 228 | ...omit(v, 'logo', '$recipeType'), 229 | id: getIdFromUrn(v.entityUrn)!, 230 | logo: resolveLinkedVectorImageUrl(v.logo) 231 | } as Group 232 | ]) 233 | ), 234 | showcasePagesResolutionResults: Object.fromEntries( 235 | Object.entries(o.showcasePagesResolutionResults ?? {}).map(([k, v]) => [ 236 | k, 237 | { 238 | ...omit( 239 | v, 240 | 'universalName', 241 | 'logo', 242 | '$recipeType', 243 | 'followingInfo', 244 | 'showcase', 245 | 'paidCompany' 246 | ), 247 | id: getIdFromUrn(v.entityUrn)!, 248 | publicIdentifier: v.universalName, 249 | numFollowers: v.followingInfo?.followerCount, 250 | logo: resolveLinkedVectorImageUrl(v.logo?.image) 251 | } as ShowcasePage 252 | ]) 253 | ) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ProfileView { 2 | entityUrn: string 3 | profile: ProfileViewProfile 4 | positionGroupView: PositionGroupView 5 | positionView: PositionView 6 | patentView: PatentView 7 | summaryTreasuryMediaCount: number 8 | summaryTreasuryMedias: any[] 9 | educationView: EducationView 10 | organizationView: OrganizationView 11 | projectView: ProjectView 12 | languageView: LanguageView 13 | certificationView: CertificationView 14 | testScoreView: TestScoreView 15 | volunteerCauseView: VolunteerCauseView 16 | courseView: CourseView 17 | honorView: HonorView 18 | skillView: SkillView 19 | volunteerExperienceView: VolunteerExperienceView 20 | primaryLocale: PrimaryLocale 21 | publicationView: PublicationView 22 | } 23 | 24 | export interface PositionGroupView { 25 | entityUrn: string 26 | profileId: string 27 | elements: Element[] 28 | paging: Paging 29 | } 30 | 31 | export interface Paging { 32 | start: number 33 | count: number 34 | total: number 35 | links: any[] 36 | } 37 | 38 | export interface Element { 39 | entityUrn: string 40 | name: string 41 | positions?: Position[] 42 | paging?: Paging 43 | timePeriod?: TimePeriod 44 | miniCompany?: MiniCompany 45 | } 46 | 47 | export interface TimePeriod { 48 | startDate?: LIDate 49 | endDate?: LIDate 50 | } 51 | 52 | export interface LIDate { 53 | month?: number 54 | year?: number 55 | } 56 | 57 | export interface Position { 58 | entityUrn: string 59 | companyName: string 60 | timePeriod: TimePeriod 61 | description?: string 62 | title: string 63 | companyUrn: string 64 | company?: Company 65 | locationName?: string 66 | geoLocationName?: string 67 | geoUrn?: string 68 | region?: string 69 | } 70 | 71 | export interface Company { 72 | miniCompany: MiniCompany 73 | employeeCountRange: EmployeeCountRange 74 | industries: string[] 75 | } 76 | 77 | export interface MiniCompany { 78 | objectUrn: string 79 | entityUrn: string 80 | name: string 81 | showcase: boolean 82 | active: boolean 83 | logo: LinkedVectorImage 84 | universalName: string 85 | dashCompanyUrn: string 86 | trackingId: string 87 | } 88 | 89 | export interface VectorImage { 90 | artifacts: Artifact[] 91 | rootUrl: string 92 | } 93 | 94 | export interface Artifact { 95 | fileIdentifyingUrlPathSegment: string 96 | width: number 97 | height: number 98 | expiresAt: number 99 | $recipeTypes?: string[] 100 | $type?: string 101 | } 102 | 103 | export interface EmployeeCountRange { 104 | start: number 105 | end?: number 106 | } 107 | 108 | export interface PatentView { 109 | paging: Paging 110 | entityUrn: string 111 | profileId: string 112 | elements: any[] 113 | } 114 | 115 | export interface EducationView { 116 | paging: Paging 117 | entityUrn: string 118 | profileId: string 119 | elements: { 120 | entityUrn: string 121 | school?: MiniSchool 122 | timePeriod: TimePeriod 123 | degreeName: string 124 | schoolName: string 125 | fieldOfStudy?: string 126 | schoolUrn?: string 127 | }[] 128 | } 129 | 130 | export interface MiniSchool { 131 | objectUrn: string 132 | entityUrn: string 133 | active: boolean 134 | logo: LinkedVectorImage 135 | schoolName: string 136 | trackingId: string 137 | } 138 | 139 | export interface OrganizationView { 140 | paging: Paging 141 | entityUrn: string 142 | profileId: string 143 | elements: any[] 144 | } 145 | 146 | export interface ProjectView { 147 | paging: Paging 148 | entityUrn: string 149 | profileId: string 150 | elements: any[] 151 | } 152 | 153 | export interface PositionView { 154 | paging: Paging 155 | entityUrn: string 156 | profileId: string 157 | elements: { 158 | entityUrn: string 159 | title: string 160 | description?: string 161 | timePeriod: TimePeriod 162 | companyUrn: string 163 | companyName: string 164 | company?: Company 165 | locationName?: string 166 | geoLocationName?: string 167 | geoUrn?: string 168 | region?: string 169 | }[] 170 | } 171 | 172 | export interface ProfileViewProfile { 173 | entityUrn: string 174 | firstName: string 175 | lastName: string 176 | headline: string 177 | summary: string 178 | locationName: string 179 | location: Location 180 | miniProfile: MiniProfile 181 | industryName: string 182 | industryUrn: string 183 | versionTag: string 184 | defaultLocale: DefaultLocale 185 | supportedLocales: SupportedLocale[] 186 | geoCountryName: string 187 | geoCountryUrn: string 188 | elt: boolean 189 | student: boolean 190 | geoLocationBackfilled: boolean 191 | showEducationOnProfileTopCard: boolean 192 | geoLocation: GeoLocation 193 | geoLocationName: string 194 | } 195 | 196 | export interface SupportedLocale { 197 | country: string 198 | language: string 199 | } 200 | 201 | export interface DefaultLocale { 202 | country: string 203 | language: string 204 | } 205 | 206 | export interface GeoLocation { 207 | geoUrn: string 208 | } 209 | 210 | export interface Location { 211 | basicLocation: BasicLocation 212 | } 213 | 214 | export interface BasicLocation { 215 | countryCode: string 216 | } 217 | 218 | export interface MiniProfile { 219 | entityUrn: string 220 | firstName: string 221 | lastName: string 222 | occupation: string 223 | dashEntityUrn: string 224 | objectUrn: string 225 | publicIdentifier: string 226 | trackingId: string 227 | backgroundImage?: LinkedVectorImage 228 | picture?: LinkedVectorImage 229 | } 230 | 231 | export interface LinkedVectorImage { 232 | 'com.linkedin.common.VectorImage': VectorImage 233 | } 234 | 235 | export interface LinkedMediaProcessorImage { 236 | 'com.linkedin.voyager.common.MediaProcessorImage': { 237 | id: string 238 | } 239 | } 240 | 241 | export interface LanguageView { 242 | paging: Paging 243 | entityUrn: string 244 | profileId: string 245 | elements: any[] 246 | } 247 | 248 | export interface CertificationView { 249 | paging: Paging 250 | entityUrn: string 251 | profileId: string 252 | elements: any[] 253 | } 254 | 255 | export interface TestScoreView { 256 | paging: Paging 257 | entityUrn: string 258 | profileId: string 259 | elements: any[] 260 | } 261 | 262 | export interface VolunteerCauseView { 263 | paging: Paging 264 | entityUrn: string 265 | profileId: string 266 | elements: any[] 267 | } 268 | 269 | export interface CourseView { 270 | paging: Paging 271 | entityUrn: string 272 | profileId: string 273 | elements: any[] 274 | } 275 | 276 | export interface HonorView { 277 | paging: Paging 278 | entityUrn: string 279 | profileId: string 280 | elements: any[] 281 | } 282 | 283 | export interface SkillView { 284 | paging: Paging 285 | entityUrn: string 286 | profileId: string 287 | elements: Element[] 288 | } 289 | 290 | export interface VolunteerExperienceView { 291 | paging: Paging 292 | entityUrn: string 293 | profileId: string 294 | elements: any[] 295 | } 296 | 297 | export interface PrimaryLocale { 298 | country: string 299 | language: string 300 | } 301 | 302 | export interface PublicationView { 303 | paging: Paging 304 | entityUrn: string 305 | profileId: string 306 | elements: any[] 307 | } 308 | 309 | export interface Profile { 310 | entityUrn: string 311 | id: string 312 | publicIdentifier: string 313 | firstName: string 314 | lastName: string 315 | headline: string 316 | summary: string 317 | occupation: string 318 | location: string 319 | industryName: string 320 | industryUrn: string 321 | trackingId: string 322 | defaultLocale: DefaultLocale 323 | backgroundImage?: string 324 | image?: string 325 | education?: PagedList 326 | experience?: PagedList 327 | } 328 | 329 | export type PagedList = { 330 | paging: PagingResponse 331 | elements: T[] 332 | } 333 | 334 | export type EducationItem = { 335 | entityUrn?: string 336 | schoolName: string 337 | degreeName?: string 338 | fieldOfStudy?: string 339 | startDate?: string 340 | endDate?: string 341 | school: { 342 | name: string 343 | entityUrn?: string 344 | id?: string 345 | active?: boolean 346 | logo?: string 347 | } 348 | } 349 | 350 | export type ExperienceItem = { 351 | entityUrn?: string 352 | title: string 353 | companyName?: string 354 | description?: string 355 | location?: string 356 | employmentType?: string 357 | duration?: string 358 | startDate?: string 359 | endDate?: string 360 | company: { 361 | name: string 362 | entityUrn?: string 363 | id?: string 364 | publicIdentifier?: string 365 | industry?: string 366 | logo?: string 367 | employeeCountRange?: EmployeeCountRange 368 | } 369 | } 370 | 371 | export type ProfileContactInfo = { 372 | entityUrn: string 373 | 374 | websites?: Array<{ 375 | type: Record< 376 | string, 377 | { 378 | category: string 379 | } 380 | > 381 | url: string 382 | }> 383 | 384 | twitterHandles?: Array<{ 385 | name: string 386 | credentialId: string 387 | }> 388 | 389 | emailAddress?: any[] 390 | phoneNumbers?: any[] 391 | ims?: any[] 392 | birthDateOn?: any 393 | } 394 | 395 | export type ProfileSkills = { 396 | paging: Paging 397 | elements: Element[] 398 | } 399 | 400 | export type SelfProfile = { 401 | plainId: number 402 | miniProfile: MiniProfile 403 | publicContactInfo?: any 404 | premiumSubscriber: boolean 405 | } 406 | 407 | export type Industry = { 408 | localizedName: string 409 | entityUrn: string 410 | } 411 | 412 | export type FollowingInfo = { 413 | entityUrn: string 414 | following: boolean 415 | dashFollowingStateUrn: string 416 | followingType: string 417 | followerCount: number 418 | } 419 | 420 | export type RawAffiliatedCompany = { 421 | entityUrn: string 422 | name: string 423 | universalName: string 424 | url: string 425 | description: string 426 | followingInfo: FollowingInfo 427 | companyIndustries: Array 428 | school: string 429 | logo: { 430 | image: LinkedVectorImage 431 | type: string 432 | } 433 | paidCompany: boolean 434 | showcase: boolean 435 | $recipeType: string 436 | } 437 | 438 | export type AffiliatedCompany = Omit< 439 | RawAffiliatedCompany, 440 | | 'universalName' 441 | | 'logo' 442 | | '$recipeType' 443 | | 'followingInfo' 444 | | 'showcase' 445 | | 'paidCompany' 446 | > & { 447 | id: string 448 | publicIdentifier: string 449 | logo?: string 450 | numFollowers?: number 451 | } 452 | 453 | export type RawAssociatedHashtag = { 454 | entityUrn: string 455 | feedTopic: { 456 | topic: { 457 | name: string 458 | trending: boolean 459 | recommendationTrackingId: string 460 | useCase: string 461 | backendUrn: string 462 | } 463 | entityUrn: string 464 | tracking: { 465 | trackingId: string 466 | } 467 | } 468 | $recipeType: string 469 | followAction: { 470 | followingInfo: FollowingInfo 471 | unfollowTrackingActionType: string 472 | followTrackingActionType: string 473 | trackingActionType: string 474 | type: string 475 | } 476 | } 477 | 478 | export type FundingData = { 479 | fundingRoundListCrunchbaseUrl: string 480 | lastFundingRound: { 481 | investorsCrunchbaseUrl: string 482 | leadInvestors: Array<{ 483 | name: { 484 | text: string 485 | } 486 | investorCrunchbaseUrl: string 487 | image: { 488 | attributes: Array<{ 489 | sourceType: string 490 | imageUrl: string 491 | }> 492 | } 493 | }> 494 | fundingRoundCrunchbaseUrl: string 495 | fundingType: string 496 | moneyRaised: { 497 | currencyCode: string 498 | amount: string 499 | } 500 | numOtherInvestors: number 501 | announcedOn: { 502 | month: number 503 | day: number 504 | year: number 505 | } 506 | } 507 | companyCrunchbaseUrl: string 508 | numFundingRounds: number 509 | updatedAt: number 510 | } 511 | 512 | export type RawGroup = { 513 | groupName: string 514 | entityUrn: string 515 | memberCount: number 516 | logo: LinkedVectorImage 517 | url: string 518 | $recipeType: string 519 | } 520 | 521 | export type Group = Omit & { 522 | id: string 523 | logo?: string 524 | } 525 | 526 | export type RawShowcasePage = { 527 | entityUrn: string 528 | name: string 529 | universalName: string 530 | description: string 531 | url: string 532 | followingInfo: { 533 | entityUrn: string 534 | following: boolean 535 | dashFollowingStateUrn: string 536 | followingType: string 537 | followerCount: number 538 | } 539 | companyIndustries: Array 540 | logo: { 541 | image: LinkedVectorImage 542 | type: string 543 | } 544 | paidCompany: boolean 545 | showcase: boolean 546 | $recipeType: string 547 | } 548 | 549 | export type ShowcasePage = Omit< 550 | RawShowcasePage, 551 | | 'universalName' 552 | | 'logo' 553 | | '$recipeType' 554 | | 'followingInfo' 555 | | 'showcase' 556 | | 'paidCompany' 557 | > & { 558 | id: string 559 | publicIdentifier: string 560 | logo?: string 561 | numFollowers?: number 562 | } 563 | 564 | /** School or Company */ 565 | export type RawOrganization = { 566 | name: string 567 | universalName: string 568 | tagline: string 569 | description: string 570 | entityUrn: string 571 | url: string 572 | staffingCompany: boolean 573 | companyIndustries: Array 574 | staffCount: number 575 | callToAction?: { 576 | callToActionType: string 577 | visible: boolean 578 | callToActionMessage: { 579 | textDirection: string 580 | text: string 581 | } 582 | url: string 583 | } 584 | companyEmployeesSearchPageUrl: string 585 | viewerFollowingJobsUpdates: boolean 586 | school?: string 587 | staffCountRange: EmployeeCountRange 588 | permissions: { 589 | landingPageAdmin: boolean 590 | admin: boolean 591 | adAccountHolder: boolean 592 | } 593 | logo: { 594 | image: LinkedVectorImage 595 | type: string 596 | } 597 | claimable: boolean 598 | specialities: Array 599 | confirmedLocations: Array 600 | followingInfo: FollowingInfo 601 | viewerEmployee: boolean 602 | lcpTreatment: boolean 603 | phone?: { 604 | number: string 605 | } 606 | $recipeType: string 607 | fundingData: FundingData 608 | overviewPhoto: LinkedMediaProcessorImage 609 | coverPhoto: LinkedMediaProcessorImage 610 | multiLocaleTaglines: { 611 | localized: { 612 | en_US: string 613 | } 614 | preferredLocale: { 615 | country: string 616 | language: string 617 | } 618 | } 619 | headquarter?: FullLocation 620 | paidCompany: boolean 621 | viewerPendingAdministrator: boolean 622 | companyPageUrl: string 623 | viewerConnectedToAdministrator: boolean 624 | dataVersion: number 625 | foundedOn: { 626 | year: number 627 | } 628 | companyType: { 629 | localizedName: string 630 | code: string 631 | } 632 | claimableByViewer: boolean 633 | jobSearchPageUrl: string 634 | showcase: boolean 635 | autoGenerated: boolean 636 | backgroundCoverImage?: { 637 | image: LinkedVectorImage 638 | cropInfo: { 639 | x: number 640 | y: number 641 | width: number 642 | height: number 643 | } 644 | } 645 | affiliatedCompanies: Array 646 | affiliatedCompaniesResolutionResults?: Record 647 | affiliatedCompaniesWithEmployeesRollup: Array 648 | affiliatedCompaniesWithJobsRollup: Array 649 | associatedHashtags: Array 650 | associatedHashtagsResolutionResults?: Record 651 | groups: Array 652 | groupsResolutionResults?: Record 653 | showcasePages: Array 654 | showcasePagesResolutionResults?: Record 655 | } 656 | 657 | export type RawOrganizationResponse = { 658 | elements: Array 659 | } 660 | 661 | /** School or Company */ 662 | export type Organization = Omit< 663 | RawOrganization, 664 | | 'universalName' 665 | | 'logo' 666 | | 'backgroundCoverImage' 667 | | 'coverPhoto' 668 | | 'overviewPhoto' 669 | | '$recipeType' 670 | | 'callToAction' 671 | | 'phone' 672 | | 'permissions' 673 | | 'followingInfo' 674 | | 'adsRule' 675 | | 'autoGenerated' 676 | | 'lcpTreatment' 677 | | 'staffingCompany' 678 | | 'showcase' 679 | | 'paidCompany' 680 | | 'claimable' 681 | | 'claimableByViewer' 682 | | 'viewerPendingAdministrator' 683 | | 'viewerConnectedToAdministrator' 684 | | 'viewerFollowingJobsUpdates' 685 | | 'viewerEmployee' 686 | | 'associatedHashtags' 687 | | 'associatedHashtagsResolutionResults' 688 | | 'affiliatedCompaniesResolutionResults' 689 | | 'groupsResolutionResults' 690 | | 'showcasePagesResolutionResults' 691 | > & { 692 | id: string 693 | publicIdentifier: string 694 | logo?: string 695 | backgroundCoverImage?: string 696 | coverPhoto?: string 697 | overviewPhoto?: string 698 | callToActionUrl?: string 699 | phone?: string 700 | numFollowers?: number 701 | affiliatedCompaniesResolutionResults: Record 702 | groupsResolutionResults: Record 703 | showcasePagesResolutionResults: Record 704 | } 705 | 706 | export type NetworkDepth = 'F' | 'S' | 'O' 707 | 708 | export type SearchParams = { 709 | offset?: number 710 | limit?: number 711 | filters?: string 712 | query?: string 713 | } 714 | 715 | export type SearchPeopleParams = Omit & { 716 | connectionOf?: string 717 | networkDepths?: NetworkDepth[] 718 | currentCompany?: string[] 719 | pastCompanies?: string[] 720 | nonprofitInterests?: string[] 721 | profileLanguages?: string[] 722 | regions?: string[] 723 | industries?: string[] 724 | schools?: string[] 725 | contactInterests?: string[] 726 | serviceCategories?: string[] 727 | includePrivateProfiles?: boolean 728 | keywordFirstName?: string 729 | keywordLastName?: string 730 | keywordTitle?: string 731 | keywordCompany?: string 732 | keywordSchool?: string 733 | 734 | /** @deprecated use `networkDepths` instead. */ 735 | networkDepth?: NetworkDepth 736 | 737 | /** @deprecated Use `keywordTitle` instead. */ 738 | title?: string 739 | } 740 | 741 | export type SearchCompaniesParams = Omit 742 | 743 | export interface ProfileSearchResult { 744 | urnId: string 745 | name: string 746 | url: string 747 | image?: string 748 | distance?: string 749 | jobTitle?: string 750 | location?: string 751 | summary?: string 752 | } 753 | 754 | export interface CompanySearchResult { 755 | urnId: string 756 | name: string 757 | url: string 758 | image?: string 759 | industry?: string 760 | location?: string 761 | numFollowers?: string 762 | summary?: string 763 | } 764 | 765 | export interface PagingResponse { 766 | offset: number 767 | count: number 768 | total: number 769 | } 770 | 771 | export interface SearchResponse { 772 | paging: PagingResponse 773 | results: EntitySearchResult[] 774 | } 775 | 776 | export interface SearchPeopleResponse { 777 | paging: PagingResponse 778 | results: ProfileSearchResult[] 779 | } 780 | 781 | export interface SearchCompaniesResponse { 782 | paging: PagingResponse 783 | results: CompanySearchResult[] 784 | } 785 | 786 | export interface TextData { 787 | textDirection: string 788 | text: string 789 | attributesV2: Array 790 | accessibilityTextAttributesV2: Array 791 | accessibilityText: string 792 | $recipeTypes: Array 793 | $type: string 794 | } 795 | 796 | export type FullLocation = { 797 | country: string 798 | geographicArea: string 799 | city: string 800 | postalCode: string 801 | line1: string 802 | headquarter?: boolean 803 | streetAddressOptOut?: boolean 804 | } 805 | 806 | export type ImageViewModel = { 807 | attributes: Array<{ 808 | scalingType: any 809 | detailData: { 810 | imageUrl: any 811 | icon: string 812 | systemImage: any 813 | vectorImage: any 814 | ghostImage: any 815 | profilePicture: any 816 | profilePictureWithoutFrame: any 817 | profilePictureWithRingStatus: any 818 | companyLogo: any 819 | professionalEventLogo: any 820 | groupLogo: any 821 | schoolLogo: any 822 | nonEntityGroupLogo?: any 823 | nonEntityProfessionalEventLogo?: any 824 | nonEntityCompanyLogo?: any 825 | nonEntitySchoolLogo?: any 826 | nonEntityProfilePicture?: any 827 | } 828 | tintColor: any 829 | $recipeTypes: Array 830 | tapTargets: Array 831 | displayAspectRatio: any 832 | $type: string 833 | }> 834 | editableAccessibilityText: boolean 835 | actionTarget: any 836 | accessibilityTextAttributes: Array 837 | totalCount: any 838 | accessibilityText: any 839 | $recipeTypes: Array 840 | $type: string 841 | } 842 | 843 | export type EntitySearchResult = { 844 | $type: string 845 | entityUrn: string 846 | title: TextData 847 | summary: TextData 848 | primarySubtitle: TextData 849 | secondarySubtitle: TextData 850 | badgeText: TextData 851 | navigationUrl: string 852 | template: string 853 | actorNavigationContext: any 854 | bserpEntityNavigationalUrl: string 855 | trackingUrn: string 856 | controlName: any 857 | interstitialComponent: any 858 | primaryActions: Array 859 | entityCustomTrackingInfo: { 860 | memberDistance: string 861 | privacySettingsInjectionHolder: any 862 | $recipeTypes: Array 863 | nameMatch: boolean 864 | $type: string 865 | } 866 | badgeData: { 867 | badgeHoverText: string 868 | targetUrn: string 869 | $recipeTypes: Array 870 | $type: string 871 | } 872 | overflowActions: Array 873 | '*lazyLoadedActions'?: string 874 | searchActionType: any 875 | actorInsights: Array 876 | insightsResolutionResults: Array<{ 877 | jobPostingInsight: any 878 | relationshipsInsight: any 879 | serviceProviderRatingInsight: any 880 | simpleInsight: { 881 | image: ImageViewModel 882 | controlName: any 883 | navigationUrl: any 884 | title: TextData 885 | $recipeTypes: Array 886 | $type: string 887 | searchActionType: string 888 | subtitleMaxNumLines: number 889 | titleFontSize: any 890 | subtitle: any 891 | subtitleFontSize: any 892 | titleMaxNumLines: number 893 | } 894 | jobPostingFooterInsight: any 895 | socialActivityCountsInsight: any 896 | labelsInsight: any 897 | premiumCustomCtaInsight: any 898 | }> 899 | image: ImageViewModel 900 | badgeIcon: ImageViewModel 901 | showAdditionalCluster: boolean 902 | ringStatus: any 903 | trackingId: string 904 | addEntityToSearchHistory: boolean 905 | actorNavigationUrl: any 906 | entityEmbeddedObject: any 907 | unreadIndicatorDetails: any 908 | $recipeTypes: Array 909 | target: any 910 | actorTrackingUrn: any 911 | navigationContext: { 912 | openExternally: boolean 913 | $recipeTypes: Array 914 | url: string 915 | $type: string 916 | } 917 | } 918 | -------------------------------------------------------------------------------- /src/linkedin-client.ts: -------------------------------------------------------------------------------- 1 | import type Conf from 'conf' 2 | import { parseSetCookie, type SetCookie, splitSetCookieString } from 'cookie-es' 3 | import { rangeDelay } from 'delay' 4 | import defaultKy, { type KyInstance } from 'ky' 5 | import pThrottle from 'p-throttle' 6 | 7 | import type { 8 | EducationItem, 9 | EntitySearchResult, 10 | ExperienceItem, 11 | Organization, 12 | Profile, 13 | ProfileContactInfo, 14 | ProfileSkills, 15 | ProfileView, 16 | RawOrganization, 17 | RawOrganizationResponse, 18 | SearchCompaniesParams, 19 | SearchCompaniesResponse, 20 | SearchParams, 21 | SearchPeopleParams, 22 | SearchPeopleResponse, 23 | SearchResponse, 24 | SelfProfile 25 | } from './types' 26 | import { 27 | getGroupedItemId, 28 | getIdFromUrn, 29 | getUrnFromRawUpdate, 30 | isLinkedInUrn, 31 | normalizeRawOrganization, 32 | parseExperienceItem, 33 | resolveImageUrl, 34 | resolveLinkedVectorImageUrl, 35 | stringifyLinkedInDate 36 | } from './linkedin-utils' 37 | import { assert, encodeCookies, getConfigForUser, getEnv } from './utils' 38 | 39 | // Allow up to 1 request per second by default. 40 | const defaultThrottle = pThrottle({ 41 | limit: 1, 42 | interval: 1000 43 | }) 44 | 45 | export class LinkedInClient { 46 | // max seems to be 100 posts per page (currently unused) 47 | // static readonly MAX_POST_COUNT = 100 48 | 49 | // max seems to be 100 50 | static readonly MAX_UPDATE_COUNT = 100 51 | 52 | // max seems to be 49, and min seems to be 2 53 | static readonly MAX_SEARCH_COUNT = 49 54 | 55 | // very conservative max requests count to avoid rate-limit 56 | static readonly MAX_REPEATED_REQUESTS = 200 57 | 58 | public readonly email: string 59 | public readonly password: string 60 | public readonly config: Conf 61 | 62 | protected authKy: KyInstance 63 | protected apiKy: KyInstance 64 | protected debug: boolean 65 | 66 | protected _cookies?: Record 67 | protected _sessionId?: string 68 | protected _isAuthenticated = false 69 | protected _isAuthenticating = false 70 | protected _isReauthenticating = false 71 | 72 | constructor({ 73 | email = getEnv('LINKEDIN_EMAIL'), 74 | password = getEnv('LINKEDIN_PASSWORD'), 75 | baseUrl = 'https://www.linkedin.com', 76 | ky = defaultKy, 77 | throttle = true, 78 | debug = false, 79 | apiHeaders = {}, 80 | authHeaders = {} 81 | }: { 82 | email?: string 83 | password?: string 84 | baseUrl?: string 85 | ky?: KyInstance 86 | throttle?: boolean 87 | debug?: boolean 88 | apiHeaders?: Record 89 | authHeaders?: Record 90 | } = {}) { 91 | assert( 92 | email, 93 | 'LinkedInClient missing required "email" (defaults to "LINKEDIN_EMAIL")' 94 | ) 95 | assert( 96 | password, 97 | 'LinkedInClient missing required "password" (defaults to "LINKEDIN_PASSWORD")' 98 | ) 99 | 100 | this.email = email 101 | this.password = password 102 | this.config = getConfigForUser(email) 103 | this.debug = !!debug 104 | 105 | this.authKy = ky.extend({ 106 | prefixUrl: baseUrl, 107 | headers: { 108 | 'x-li-user-agent': 109 | 'LIAuthLibrary:0.0.3 com.linkedin.android:4.1.881 Asus_ASUS_Z01QD:android_9', 110 | 'user-agent': 'ANDROID OS', 111 | 'x-user-language': 'en', 112 | 'x-user-locale': 'en_US', 113 | 'accept-language': 'en-us', 114 | ...authHeaders 115 | } 116 | 117 | // Note that by default, auth requests are not throttled the same as 118 | // API requests. 119 | }) 120 | 121 | this.apiKy = ky.extend({ 122 | prefixUrl: `${baseUrl}/voyager/api`, 123 | headers: { 124 | 'user-agent': [ 125 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)', 126 | 'AppleWebKit/537.36 (KHTML, like Gecko)', 127 | 'Chrome/83.0.4103.116 Safari/537.36' 128 | ].join(' '), 129 | 'accept-language': 'en-AU,en-GB;q=0.9,en-US;q=0.8,en;q=0.7', 130 | 'x-li-lang': 'en_US', 131 | 'x-restli-protocol-version': '2.0.0', 132 | ...apiHeaders 133 | }, 134 | hooks: { 135 | ...(throttle 136 | ? { 137 | beforeRequest: [ 138 | async () => { 139 | // Add a random delay before each API request in an attempt to 140 | // avoid suspicion. 141 | await rangeDelay(1000, 5000) 142 | }, 143 | 144 | // Also enforce a default rate-limit. 145 | defaultThrottle(() => Promise.resolve(undefined)) 146 | ] 147 | } 148 | : undefined), 149 | 150 | afterResponse: [ 151 | async (request, _options, response) => { 152 | try { 153 | // Attempt to automatically re-authenticate after receiving an auth error. 154 | if (response.status === 403 || response.status === 401) { 155 | console.log( 156 | 'LinkedInClient auth error (attempting to re-authenticate)', 157 | { 158 | method: request.method, 159 | url: request.url, 160 | status: response.status 161 | // responseHeaders: Object.fromEntries(response.headers.entries()) 162 | } 163 | ) 164 | 165 | this._isAuthenticated = false 166 | 167 | if (this._isAuthenticating || this._isReauthenticating) { 168 | // Avoid infinite authentication loops 169 | return response 170 | } 171 | 172 | this._isReauthenticating = true 173 | 174 | try { 175 | try { 176 | await this.authenticate() 177 | } catch (err: any) { 178 | console.warn( 179 | `LinkedInClient auth error ${response.status} from request ${request.method} ${request.url} error re-authenticating: ${err.message}` 180 | ) 181 | return response 182 | } 183 | 184 | assert(this._sessionId) 185 | assert(this._cookies) 186 | 187 | // Update the failed request after successfully re-authenticating. 188 | const csrfToken = this._sessionId.replaceAll('"', '') 189 | request.headers.set('csrf-token', csrfToken) 190 | request.headers.set('cookie', encodeCookies(this._cookies)) 191 | 192 | return await ky(request) 193 | } finally { 194 | this._isReauthenticating = false 195 | } 196 | } 197 | } catch (err) { 198 | console.error( 199 | 'LinkedInClient unhandled auth error', 200 | { 201 | method: request.method, 202 | url: request.url, 203 | status: response.status 204 | }, 205 | err 206 | ) 207 | } 208 | } 209 | ] 210 | } 211 | }) 212 | } 213 | 214 | get isAuthenticated() { 215 | return this._isAuthenticated 216 | } 217 | 218 | async ensureAuthenticated() { 219 | if (this._isAuthenticated) return 220 | 221 | const setCookies = this.config.get('cookies') as string 222 | if (setCookies) { 223 | try { 224 | this._setAuthCookies(setCookies) 225 | this._isAuthenticated = true 226 | } catch (err: any) { 227 | console.warn( 228 | 'LinkedInClient renewing expired auth cookies', 229 | err.message 230 | ) 231 | return this.authenticate() 232 | } 233 | } else { 234 | return this.authenticate() 235 | } 236 | } 237 | 238 | protected async _getAuthCookieString() { 239 | const res = await this.authKy.get('uas/authenticate') 240 | const cookieString = res.headers.get('set-cookie') 241 | assert(cookieString) 242 | if (this.debug) { 243 | console.log('GET uas/authenticate', res) 244 | } 245 | 246 | return cookieString 247 | } 248 | 249 | protected async _setAuthCookies(setCookiesString: string) { 250 | assert( 251 | setCookiesString, 252 | 'LinkedInClient authenticate missing set-cookie header' 253 | ) 254 | 255 | const setCookies = splitSetCookieString(setCookiesString) 256 | const parsedCookies = setCookies.map((c) => parseSetCookie(c)) 257 | 258 | this._cookies = parsedCookies.reduce>( 259 | (acc, c) => { 260 | return { 261 | ...acc, 262 | [c.name]: c 263 | } 264 | }, 265 | {} 266 | ) 267 | 268 | const sessionCookie = this._cookies.JSESSIONID 269 | assert(sessionCookie, 'LinkedInClient session missing JSESSIONID cookie') 270 | 271 | if (sessionCookie.expires && Date.now() > sessionCookie.expires.getTime()) { 272 | throw new Error('LinkedInClient auth cookie expired') 273 | } 274 | 275 | this._sessionId = sessionCookie.value 276 | const csrfToken = this._sessionId.replaceAll('"', '') 277 | 278 | this.authKy = this.authKy.extend({ 279 | headers: { 280 | 'csrf-token': csrfToken, 281 | cookie: encodeCookies(this._cookies!) 282 | } 283 | }) 284 | 285 | this.apiKy = this.apiKy.extend({ 286 | headers: { 287 | 'csrf-token': csrfToken, 288 | cookie: encodeCookies(this._cookies!) 289 | } 290 | }) 291 | 292 | return this._cookies 293 | } 294 | 295 | async authenticate() { 296 | this._isAuthenticating = true 297 | 298 | try { 299 | await this._setAuthCookies(await this._getAuthCookieString()) 300 | assert(this._sessionId) 301 | 302 | const res = await this.authKy.post('uas/authenticate', { 303 | body: new URLSearchParams({ 304 | session_key: this.email, 305 | session_password: this.password, 306 | JSESSIONID: this._sessionId! 307 | }), 308 | headers: { 309 | 'content-type': 'application/x-www-form-urlencoded' 310 | } 311 | }) 312 | 313 | if (this.debug && res.status !== 200) { 314 | console.log('POST uas/authenticate', res) 315 | } 316 | 317 | if (res.status === 401) { 318 | throw new Error( 319 | `LinkedInClient authenticate HTTP error: invalid credentials ${res.status}` 320 | ) 321 | } 322 | 323 | if (res.status !== 200) { 324 | throw new Error(`LinkedInClient authenticate HTTP error: ${res.status}`) 325 | } 326 | 327 | const data = await res.json<{ 328 | login_result?: string 329 | challenge_url?: string 330 | }>() 331 | 332 | if (data.login_result !== 'PASS') { 333 | throw new Error( 334 | `LinkedInClient authenticate challenge error: ${data.login_result} ${data.challenge_url}` 335 | ) 336 | } 337 | 338 | // TODO: handle challenge_url 339 | 340 | const setCookies = res.headers.get('set-cookie')! 341 | this._setAuthCookies(setCookies) 342 | this.config.set('cookies', setCookies) 343 | this._isAuthenticated = true 344 | } finally { 345 | this._isAuthenticating = false 346 | } 347 | } 348 | 349 | /** 350 | * Fetches basic profile information for the authenticated user. 351 | */ 352 | async getMe() { 353 | await this.ensureAuthenticated() 354 | 355 | const res = await this.apiKy.get('me') 356 | 357 | return res.json() 358 | } 359 | 360 | /** 361 | * Fetches basic profile information for a given LinkedIn user. 362 | * 363 | * Returns the raw data from the LinkedIn API without normalizing it. 364 | * 365 | * @param id The LinkedIn user's public identifier or internal URN ID. 366 | */ 367 | async getProfileRaw(id: string): Promise { 368 | if (isLinkedInUrn(id)) { 369 | id = getIdFromUrn(id)! 370 | } 371 | 372 | await this.ensureAuthenticated() 373 | 374 | // NOTE: the `/profileView` sub-route returns more detailed data. 375 | return this.apiKy 376 | .get(`identity/profiles/${id}/profileView`) 377 | .json() 378 | } 379 | 380 | /** 381 | * Fetches basic profile information for a given LinkedIn user. 382 | * 383 | * @param id The LinkedIn user's public identifier or internal URN ID. 384 | */ 385 | async getProfile(id: string): Promise { 386 | const res = await this.getProfileRaw(id) 387 | 388 | const { profile, educationView, positionView } = res 389 | const miniProfile = profile.miniProfile 390 | const education: Profile['education'] = educationView 391 | ? { 392 | paging: { 393 | offset: educationView.paging.start, 394 | count: educationView.paging.count, 395 | total: educationView.paging.total 396 | }, 397 | elements: educationView.elements.map((item) => { 398 | const educationItem: EducationItem = { 399 | entityUrn: item.entityUrn, 400 | schoolName: item.schoolName, 401 | degreeName: item.degreeName, 402 | fieldOfStudy: item.fieldOfStudy, 403 | startDate: stringifyLinkedInDate(item.timePeriod?.startDate), 404 | endDate: stringifyLinkedInDate(item.timePeriod?.endDate), 405 | school: { 406 | name: item.school?.schoolName ?? item.schoolName, 407 | entityUrn: item.school?.entityUrn, 408 | id: getIdFromUrn(item.school?.entityUrn), 409 | active: item.school?.active, 410 | logo: resolveLinkedVectorImageUrl(item.school?.logo) 411 | } 412 | } 413 | 414 | return educationItem 415 | }) 416 | } 417 | : undefined 418 | 419 | const experience: Profile['experience'] = positionView 420 | ? { 421 | paging: { 422 | offset: positionView.paging.start, 423 | count: positionView.paging.count, 424 | total: positionView.paging.total 425 | }, 426 | elements: positionView.elements.map((item) => { 427 | const companyUrn = 428 | item.companyUrn ?? item.company?.miniCompany?.entityUrn! 429 | 430 | const experienceItem: ExperienceItem = { 431 | entityUrn: item.entityUrn, 432 | title: item.title, 433 | companyName: item.companyName, 434 | description: item.description, 435 | location: item.locationName, 436 | startDate: stringifyLinkedInDate(item.timePeriod?.startDate), 437 | endDate: stringifyLinkedInDate(item.timePeriod?.endDate), 438 | company: { 439 | entityUrn: companyUrn, 440 | id: getIdFromUrn(companyUrn), 441 | publicIdentifier: item.company?.miniCompany?.universalName, 442 | name: item.company?.miniCompany?.name ?? item.companyName, 443 | industry: item.company?.industries?.[0], 444 | logo: resolveLinkedVectorImageUrl( 445 | item.company?.miniCompany?.logo 446 | ), 447 | employeeCountRange: item.company?.employeeCountRange 448 | } 449 | } 450 | 451 | return experienceItem 452 | }) 453 | } 454 | : undefined 455 | 456 | // TODO: add other sections (skills, recommendations, etc.) 457 | const result: Profile = { 458 | id: getIdFromUrn(res.entityUrn)!, 459 | entityUrn: res.entityUrn, 460 | firstName: profile.firstName, 461 | lastName: profile.lastName, 462 | headline: profile.headline, 463 | summary: profile.summary, 464 | occupation: miniProfile?.occupation, 465 | location: profile.locationName, 466 | industryName: profile.industryName, 467 | industryUrn: profile.industryUrn, 468 | publicIdentifier: miniProfile?.publicIdentifier, 469 | trackingId: miniProfile?.trackingId, 470 | defaultLocale: profile.defaultLocale, 471 | backgroundImage: resolveLinkedVectorImageUrl( 472 | miniProfile?.backgroundImage 473 | ), 474 | image: resolveLinkedVectorImageUrl(miniProfile?.picture), 475 | education, 476 | experience 477 | } 478 | 479 | return result 480 | } 481 | 482 | /** 483 | * @param id The target LinkedIn user's public identifier or internal URN ID. 484 | */ 485 | async getProfileContactInfo(id: string) { 486 | if (isLinkedInUrn(id)) { 487 | id = getIdFromUrn(id)! 488 | } 489 | 490 | await this.ensureAuthenticated() 491 | 492 | return this.apiKy 493 | .get(`identity/profiles/${id}/profileContactInfo`) 494 | .json() 495 | } 496 | 497 | /** 498 | * @param id The target LinkedIn user's public identifier or internal URN ID. 499 | */ 500 | async getProfileSkills( 501 | idOrOptions: string | { id: string; offset?: number; limit?: number } 502 | ) { 503 | const { 504 | id, 505 | offset = 0, 506 | limit = 100 507 | } = typeof idOrOptions === 'string' ? { id: idOrOptions } : idOrOptions 508 | 509 | const resolvedId = isLinkedInUrn(id) ? getIdFromUrn(id)! : id 510 | 511 | await this.ensureAuthenticated() 512 | 513 | return this.apiKy 514 | .get(`identity/profiles/${resolvedId}/skills`, { 515 | searchParams: { 516 | count: limit, 517 | start: offset 518 | } 519 | }) 520 | .json() 521 | } 522 | 523 | /** 524 | * @param urnId The target LinkedIn user's internal URN ID. 525 | */ 526 | async getProfileExperiences(urnId: string): Promise { 527 | if (isLinkedInUrn(urnId)) { 528 | urnId = getIdFromUrn(urnId)! 529 | } 530 | 531 | await this.ensureAuthenticated() 532 | 533 | const profileUrn = `urn:li:fsd_profile:${urnId}` 534 | const variables = [ 535 | `profileUrn:${encodeURIComponent(profileUrn)}`, 536 | 'sectionType:experience' 537 | ].join(',') 538 | const queryId = 539 | 'voyagerIdentityDashProfileComponents.7af5d6f176f11583b382e37e5639e69e' 540 | 541 | const data = await this.apiKy 542 | .get( 543 | `graphql?variables=(${variables})&queryId=${queryId}&includeWebMeta=true`, 544 | { 545 | headers: { 546 | accept: 'application/vnd.linkedin.normalized+json+2.1' 547 | } 548 | } 549 | ) 550 | .json() 551 | 552 | const experienceItems: ExperienceItem[] = [] 553 | const included = data.included 554 | if (!included) return experienceItems 555 | 556 | const elements = included[0]?.components?.elements 557 | 558 | for (const item of elements) { 559 | const groupedItemId = getGroupedItemId(item) 560 | 561 | if (groupedItemId) { 562 | const component = item.components.entityComponent 563 | const company = component.titleV2.text.text 564 | const location = component.caption?.text || null 565 | 566 | const group = data.included.find((i: any) => 567 | i.entityUrn?.includes(groupedItemId) 568 | ) 569 | if (!group) continue 570 | 571 | for (const groupItem of group.components.elements) { 572 | const parsedData = parseExperienceItem(groupItem, { 573 | isGroupItem: true, 574 | included 575 | }) 576 | parsedData.companyName = company 577 | parsedData.location = location 578 | experienceItems.push(parsedData) 579 | } 580 | } else { 581 | // Parse the regular item 582 | const parsedData = parseExperienceItem(item, { 583 | included 584 | }) 585 | experienceItems.push(parsedData) 586 | } 587 | } 588 | 589 | return experienceItems 590 | } 591 | 592 | /** 593 | * Fetch profile updates (newsfeed activity) for a given LinkedIn profile. 594 | * 595 | * @TODO This method is currently untested and may not be working. 596 | 597 | * @param id The target LinkedIn user's public identifier or internal URN ID. 598 | */ 599 | async getProfileUpdates( 600 | idOrOptions: string | { id: string; offset?: number; limit?: number } 601 | ) { 602 | const { 603 | id, 604 | offset = 0, 605 | limit = LinkedInClient.MAX_UPDATE_COUNT 606 | } = typeof idOrOptions === 'string' ? { id: idOrOptions } : idOrOptions 607 | 608 | const res = await this.apiKy 609 | .get('feed/updates', { 610 | searchParams: { 611 | profileId: id, 612 | q: 'memberShareFeed', 613 | moduleKey: 'member-share', 614 | count: Math.max(2, Math.min(limit, LinkedInClient.MAX_UPDATE_COUNT)), 615 | start: offset 616 | } 617 | }) 618 | // TODO 619 | .json() 620 | 621 | return res.elements 622 | } 623 | 624 | /** 625 | * Fetch company updates (newsfeed activity) for a given LinkedIn company. 626 | * 627 | * @TODO This method is currently untested and may not be working. 628 | * 629 | * @param id The target LinkedIn company's public identifier or internal URN ID. 630 | */ 631 | async getCompanyUpdates( 632 | idOrOptions: string | { id: string; offset?: number; limit?: number } 633 | ) { 634 | const { 635 | id, 636 | offset = 0, 637 | limit = LinkedInClient.MAX_UPDATE_COUNT 638 | } = typeof idOrOptions === 'string' ? { id: idOrOptions } : idOrOptions 639 | 640 | const res = await this.apiKy 641 | .get('feed/updates', { 642 | searchParams: { 643 | companyUniversalName: id, 644 | q: 'companyFeedByUniversalName', 645 | moduleKey: 'member-share', 646 | count: Math.max(2, Math.min(limit, LinkedInClient.MAX_UPDATE_COUNT)), 647 | start: offset 648 | } 649 | }) 650 | // TODO 651 | .json() 652 | 653 | return res.elements 654 | } 655 | 656 | /** 657 | * Fetches basic data about a school on LinkedIn. Returns the raw data from 658 | * the LinkedIn API without normalizing it. 659 | * 660 | * @param id The company's public LinkedIn identifier or internal URN ID. E.g. "brown-university" 661 | * 662 | * @note When using a URN, it should be the school company's entityUrn ID, not 663 | * the school's URN ID. 664 | */ 665 | async getSchoolRaw(id: string): Promise { 666 | if (isLinkedInUrn(id)) { 667 | id = getIdFromUrn(id)! 668 | } 669 | 670 | await this.ensureAuthenticated() 671 | 672 | const res = await this.apiKy 673 | .get('organization/companies', { 674 | searchParams: { 675 | decorationId: 676 | 'com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12', 677 | q: 'universalName', 678 | universalName: id 679 | } 680 | }) 681 | .json() 682 | 683 | return res.elements[0]! 684 | } 685 | 686 | /** 687 | * Fetches basic data about a school on LinkedIn. 688 | * 689 | * @param id The company's public LinkedIn identifier or internal URN ID. E.g. "brown-university" 690 | * 691 | * @note When using a URN, it should be the school company's entityUrn ID, not 692 | * the school's URN ID. 693 | */ 694 | async getSchool(id: string): Promise { 695 | const rawOrganization = await this.getSchoolRaw(id) 696 | return normalizeRawOrganization(rawOrganization) 697 | } 698 | 699 | /** 700 | * Fetches basic data about a company on LinkedIn. Returns the raw data from 701 | * the LinkedIn API without normalizing it. 702 | * 703 | * @param id The company's public LinkedIn identifier or internal URN ID. E.g. "microsoft" 704 | */ 705 | async getCompanyRaw(id: string): Promise { 706 | if (isLinkedInUrn(id)) { 707 | id = getIdFromUrn(id)! 708 | } 709 | 710 | await this.ensureAuthenticated() 711 | 712 | const res = await this.apiKy 713 | .get('organization/companies', { 714 | searchParams: { 715 | decorationId: 716 | 'com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12', 717 | q: 'universalName', 718 | universalName: id 719 | } 720 | }) 721 | .json() 722 | 723 | return res.elements[0]! 724 | } 725 | 726 | /** 727 | * Fetches basic data about a company on LinkedIn. 728 | * 729 | * @param id The company's public LinkedIn identifier or internal URN ID. E.g. "microsoft" 730 | */ 731 | async getCompany(id: string): Promise { 732 | const rawOrganization = await this.getCompanyRaw(id) 733 | return normalizeRawOrganization(rawOrganization) 734 | } 735 | 736 | /** 737 | * Fetches data about a job posting on LinkedIn. 738 | * 739 | * @param jobId The ID of the job posting. 740 | */ 741 | async getJob(jobId: string) { 742 | if (isLinkedInUrn(jobId)) { 743 | jobId = getIdFromUrn(jobId)! 744 | } 745 | 746 | await this.ensureAuthenticated() 747 | 748 | const res = await this.apiKy 749 | .get(`jobs/jobPostings/${jobId}`, { 750 | searchParams: { 751 | decorationId: 752 | 'com.linkedin.voyager.deco.jobs.web.shared.WebLightJobPosting-23' 753 | } 754 | }) 755 | .json() 756 | 757 | return res 758 | } 759 | 760 | /** 761 | * Raw search method for Linkedin. 762 | * 763 | * You probably want to use `searchPeople` or `searchCompanies` instead. 764 | */ 765 | async search({ 766 | offset = 0, 767 | limit = LinkedInClient.MAX_SEARCH_COUNT, 768 | ...opts 769 | }: SearchParams): Promise { 770 | await this.ensureAuthenticated() 771 | 772 | const response: SearchResponse = { 773 | paging: { 774 | offset, 775 | count: 0, 776 | total: -1 777 | }, 778 | results: [] 779 | } 780 | const params: any = { 781 | start: offset, 782 | count: Math.min(limit, LinkedInClient.MAX_SEARCH_COUNT), 783 | filters: 'List()', 784 | origin: 'GLOBAL_SEARCH_HEADER', 785 | ...opts 786 | } 787 | 788 | const keywords = params.query 789 | ? `keywords:${encodeURIComponent(params.query)},` 790 | : '' 791 | 792 | // graphql?variables=(start:0,origin:GLOBAL_SEARCH_HEADER,query:(keywords:kevin%20raheja%20heygen,flagshipSearchIntent:SEARCH_SRP,queryParameters:List((key:resultType,value:List(PEOPLE))),includeFiltersInResponse:false))&queryId=voyagerSearchDashClusters.bb967969ef89137e6dec45d038310505 793 | 794 | // TODO: make use of `limit` 795 | 796 | const uri = 797 | `graphql?variables=(start:${params.start},origin:${params.origin},` + 798 | `query:(${keywords}flagshipSearchIntent:SEARCH_SRP,` + 799 | `queryParameters:${params.filters},includeFiltersInResponse:false))` + 800 | `&queryId=voyagerSearchDashClusters.bb967969ef89137e6dec45d038310505` 801 | // console.log(uri) 802 | 803 | const res = await this.apiKy 804 | .get(uri, { 805 | headers: { 806 | accept: 'application/vnd.linkedin.normalized+json+2.1' 807 | } 808 | }) 809 | .json() 810 | // console.log(JSON.stringify(res, null, 2)) 811 | 812 | const dataClusters = res?.data?.data?.searchDashClustersByAll 813 | if (!dataClusters) return response 814 | 815 | if ( 816 | dataClusters.$type !== 'com.linkedin.restli.common.CollectionResponse' 817 | ) { 818 | return response 819 | } 820 | 821 | response.paging.count = dataClusters.paging.count 822 | response.paging.total = dataClusters.paging.total 823 | 824 | for (const element of dataClusters.elements ?? []) { 825 | if ( 826 | element.$type !== 827 | 'com.linkedin.voyager.dash.search.SearchClusterViewModel' 828 | ) { 829 | continue 830 | } 831 | 832 | for (const it of element.items ?? []) { 833 | if (it.$type !== 'com.linkedin.voyager.dash.search.SearchItem') { 834 | continue 835 | } 836 | 837 | const item = it?.item 838 | if (!item) continue 839 | 840 | let entity: EntitySearchResult | undefined = item.entityResult 841 | if (!entity) { 842 | const linkedEntityUrn = item['*entityResult'] 843 | if (!linkedEntityUrn) continue 844 | 845 | entity = res.included?.find( 846 | (e: any) => e.entityUrn === linkedEntityUrn 847 | ) 848 | if (!entity) continue 849 | } 850 | 851 | if ( 852 | entity.$type !== 853 | 'com.linkedin.voyager.dash.search.EntityResultViewModel' 854 | ) { 855 | continue 856 | } 857 | 858 | response.results.push(entity) 859 | } 860 | } 861 | 862 | return response 863 | } 864 | 865 | /** 866 | * Performs a search for people profiles on LinkedIn. 867 | * 868 | * Takes in a google-style search query or an object containing more fine- 869 | * grained search parameters. 870 | */ 871 | async searchPeople( 872 | queryOrParams: string | SearchPeopleParams 873 | ): Promise { 874 | const { includePrivateProfiles = true, ...params } = 875 | typeof queryOrParams === 'string' 876 | ? { query: queryOrParams } 877 | : queryOrParams 878 | const filters: string[] = ['(key:resultType,value:List(PEOPLE))'] 879 | 880 | if (params.connectionOf) { 881 | filters.push(`(key:connectionOf,value:List(${params.connectionOf}))`) 882 | } 883 | 884 | if (params.networkDepths) { 885 | const stringify = params.networkDepths.join(' | ') 886 | filters.push(`(key:network,value:List(${stringify}))`) 887 | } else if (params.networkDepth) { 888 | filters.push(`(key:network,value:List(${params.networkDepth}))`) 889 | } 890 | 891 | if (params.regions) { 892 | const stringify = params.regions.join(' | ') 893 | filters.push(`(key:geoUrn,value:List(${stringify}))`) 894 | } 895 | 896 | if (params.industries) { 897 | const stringify = params.industries.join(' | ') 898 | filters.push(`(key:industry,value:List(${stringify}))`) 899 | } 900 | 901 | if (params.currentCompany) { 902 | const stringify = params.currentCompany.join(' | ') 903 | filters.push(`(key:currentCompany,value:List(${stringify}))`) 904 | } 905 | 906 | if (params.pastCompanies) { 907 | const stringify = params.pastCompanies.join(' | ') 908 | filters.push(`(key:pastCompany,value:List(${stringify}))`) 909 | } 910 | 911 | if (params.profileLanguages) { 912 | const stringify = params.profileLanguages.join(' | ') 913 | filters.push(`(key:profileLanguage,value:List(${stringify}))`) 914 | } 915 | 916 | if (params.nonprofitInterests) { 917 | const stringify = params.nonprofitInterests.join(' | ') 918 | filters.push(`(key:nonprofitInterest,value:List(${stringify}))`) 919 | } 920 | 921 | if (params.schools) { 922 | const stringify = params.schools.join(' | ') 923 | filters.push(`(key:schools,value:List(${stringify}))`) 924 | } 925 | 926 | if (params.serviceCategories) { 927 | const stringify = params.serviceCategories.join(' | ') 928 | filters.push(`(key:serviceCategory,value:List(${stringify}))`) 929 | } 930 | 931 | // `Keywords` filter 932 | const keywordTitle = params.keywordTitle ?? params.title 933 | if (params.keywordFirstName) { 934 | filters.push(`(key:firstName,value:List(${params.keywordFirstName}))`) 935 | } 936 | 937 | if (params.keywordLastName) { 938 | filters.push(`(key:lastName,value:List(${params.keywordLastName}))`) 939 | } 940 | 941 | if (keywordTitle) { 942 | filters.push(`(key:title,value:List(${keywordTitle}))`) 943 | } 944 | 945 | if (params.keywordCompany) { 946 | filters.push(`(key:company,value:List(${params.keywordCompany}))`) 947 | } 948 | 949 | if (params.keywordSchool) { 950 | filters.push(`(key:school,value:List(${params.keywordSchool}))`) 951 | } 952 | 953 | const res = await this.search({ 954 | offset: params.offset, 955 | limit: params.limit, 956 | filters: `List(${filters.join(',')})`, 957 | ...(params.query && { query: params.query }) 958 | }) 959 | 960 | const response: SearchPeopleResponse = { 961 | paging: res.paging, 962 | results: [] 963 | } 964 | 965 | for (const result of res.results) { 966 | if ( 967 | !includePrivateProfiles && 968 | result.entityCustomTrackingInfo?.memberDistance === 'OUT_OF_NETWORK' 969 | ) { 970 | continue 971 | } 972 | 973 | const urnId = getIdFromUrn(getUrnFromRawUpdate(result.entityUrn)) 974 | assert(urnId) 975 | 976 | const name = result.title?.text 977 | assert(name) 978 | 979 | const url = result.navigationUrl?.split('?')[0] 980 | assert(url) 981 | 982 | response.results.push({ 983 | urnId, 984 | name, 985 | url, 986 | distance: result.entityCustomTrackingInfo?.memberDistance, 987 | jobTitle: result.primarySubtitle?.text, 988 | location: result.secondarySubtitle?.text, 989 | summary: result.summary?.text, 990 | image: resolveImageUrl( 991 | result.image?.attributes?.[0]?.detailData?.nonEntityProfilePicture 992 | ?.vectorImage 993 | ) 994 | }) 995 | } 996 | 997 | return response 998 | } 999 | 1000 | /** 1001 | * Performs a search for companies on LinkedIn. 1002 | * 1003 | * Takes in a google-style search query or an object containing more fine- 1004 | * grained search parameters. 1005 | */ 1006 | async searchCompanies( 1007 | queryOrParams: string | SearchCompaniesParams 1008 | ): Promise { 1009 | const params = 1010 | typeof queryOrParams === 'string' 1011 | ? { query: queryOrParams } 1012 | : queryOrParams 1013 | const filters: string[] = ['(key:resultType,value:List(COMPANIES))'] 1014 | 1015 | // TODO: support more company filter options 1016 | 1017 | const res = await this.search({ 1018 | ...params, 1019 | filters: `List(${filters.join(',')})` 1020 | }) 1021 | 1022 | const response: SearchCompaniesResponse = { 1023 | paging: res.paging, 1024 | results: [] 1025 | } 1026 | 1027 | for (const result of res.results) { 1028 | const urn = getUrnFromRawUpdate(result.entityUrn) ?? result.trackingUrn 1029 | assert(urn) 1030 | 1031 | if (!urn.includes('company:')) continue 1032 | 1033 | const urnId = getIdFromUrn(urn) 1034 | assert(urnId) 1035 | 1036 | const name = result.title?.text 1037 | assert(name) 1038 | 1039 | const url = result.navigationUrl?.split('?')[0] 1040 | assert(url) 1041 | 1042 | const primarySubtitle = result.primarySubtitle?.text 1043 | const [industry, location] = primarySubtitle?.split(' • ') ?? [] 1044 | const numFollowers = result.secondarySubtitle?.text?.split(' ')[0]?.trim() 1045 | 1046 | // TODO: parse insightsResolutionResults for an estimate of number of jobs 1047 | response.results.push({ 1048 | urnId, 1049 | name, 1050 | url, 1051 | industry, 1052 | location, 1053 | numFollowers, 1054 | summary: result.summary?.text, 1055 | image: resolveImageUrl( 1056 | result.image?.attributes?.[0]?.detailData?.nonEntityCompanyLogo 1057 | ?.vectorImage 1058 | ) 1059 | }) 1060 | } 1061 | 1062 | return response 1063 | } 1064 | } 1065 | --------------------------------------------------------------------------------