@chronicle.app/linkedin 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/connectors/LinkedInTransformer.d.ts +147 -0
- package/dist/connectors/LinkedInTransformer.js +473 -0
- package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.d.ts +48 -0
- package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.js +66 -0
- package/dist/connectors/archive/LinkedInArchiveEducationExtractor.d.ts +36 -0
- package/dist/connectors/archive/LinkedInArchiveEducationExtractor.js +43 -0
- package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.d.ts +46 -0
- package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.js +67 -0
- package/dist/connectors/archive/LinkedInArchiveExtractor.d.ts +120 -0
- package/dist/connectors/archive/LinkedInArchiveExtractor.js +207 -0
- package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.d.ts +39 -0
- package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.js +42 -0
- package/dist/connectors/archive/LinkedInArchiveLearningExtractor.d.ts +41 -0
- package/dist/connectors/archive/LinkedInArchiveLearningExtractor.js +46 -0
- package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.d.ts +63 -0
- package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.js +106 -0
- package/dist/connectors/archive/LinkedInArchivePositionsExtractor.d.ts +40 -0
- package/dist/connectors/archive/LinkedInArchivePositionsExtractor.js +47 -0
- package/dist/connectors/archive/LinkedInArchiveProfileExtractor.d.ts +41 -0
- package/dist/connectors/archive/LinkedInArchiveProfileExtractor.js +27 -0
- package/dist/connectors/archive/LinkedInDefaultExtractor.d.ts +30 -0
- package/dist/connectors/archive/LinkedInDefaultExtractor.js +69 -0
- package/dist/connectors/fields.d.ts +56 -0
- package/dist/connectors/fields.js +129 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# LinkedIn plugin
|
|
2
|
+
|
|
3
|
+
Reads the **member data export** — the ZIP that Settings & Privacy → Data
|
|
4
|
+
Privacy → "Get a copy of your data" produces, unpacked to a directory.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
chronicle extract linkedin --input ~/Downloads/Basic_LinkedInDataExport_08-08-2026 --limit 0
|
|
8
|
+
chronicle extract linkedin --input <dir> --type messages --limit 5 # one record kind
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`--limit 0` means no limit. The export is small enough to read end to end.
|
|
12
|
+
|
|
13
|
+
## Why the export and not an API
|
|
14
|
+
|
|
15
|
+
LinkedIn's only programmatic route to your own data is **Member Data
|
|
16
|
+
Portability (Member)**, built for the EU Digital Markets Act: a Snapshot API
|
|
17
|
+
for history and a Changelog API for live interactions. Only members located in
|
|
18
|
+
the EEA or Switzerland can consent and mint a token. The ordinary LinkedIn API
|
|
19
|
+
returns your name, photo and email — no posts, no activity. There is no RSS.
|
|
20
|
+
|
|
21
|
+
Even where the API is open, its changelog only answers for the last 28 days and
|
|
22
|
+
only from the moment you consent, so the export stays the only route to
|
|
23
|
+
history.
|
|
24
|
+
|
|
25
|
+
## Two archives
|
|
26
|
+
|
|
27
|
+
LinkedIn builds two. The **fast** archive arrives in minutes with your profile,
|
|
28
|
+
network, and messages — that is what this plugin was written against. The
|
|
29
|
+
**full** archive takes up to a day and adds your activity: `Shares.csv`,
|
|
30
|
+
`Comments.csv`, `Reactions.csv`. Those three are not read yet.
|
|
31
|
+
|
|
32
|
+
Every extractor treats a missing file as an empty one, so a fast archive simply
|
|
33
|
+
yields fewer record types than a full one.
|
|
34
|
+
|
|
35
|
+
## What it reads
|
|
36
|
+
|
|
37
|
+
| File | `--type` | Becomes |
|
|
38
|
+
| --------------------------------------- | -------------- | -------------------------------------------------- |
|
|
39
|
+
| `messages.csv` | `messages` | `MessageAction` → `Message`, `isPartOf` a `Thread` |
|
|
40
|
+
| `Connections.csv` | `connections` | `FollowAction` → `Person` (see below) |
|
|
41
|
+
| `Endorsement_{Given,Received}_Info.csv` | `endorsements` | `RespondAction` → `Response` about a `DefinedTerm` |
|
|
42
|
+
| `Company Follows.csv` | `follows` | `FollowAction` → `Organization` |
|
|
43
|
+
| `Positions.csv` | `positions` | `JoinAction`/`LeaveAction` over a `Tenure` |
|
|
44
|
+
| `Education.csv` | `education` | `JoinAction`/`LeaveAction` over an `Enrollment` |
|
|
45
|
+
| `Learning.csv` | `learning` | `WatchAction`, `CompleteAction`, `BookmarkAction` |
|
|
46
|
+
| `Profile.csv`, `Email Addresses.csv` | `profile` | `UpdateAction` → the self `Person` |
|
|
47
|
+
|
|
48
|
+
A bare run merges all eight newest-first (`LinkedInDefaultExtractor`).
|
|
49
|
+
|
|
50
|
+
## Identity
|
|
51
|
+
|
|
52
|
+
**People key on the vanity slug** out of `/in/<slug>`. LinkedIn never puts a
|
|
53
|
+
member's numeric id in an export, so this is the only identifier there is. It
|
|
54
|
+
is user-changeable, which makes it imperfect — a member who renames their
|
|
55
|
+
profile arrives in the next export as someone new — but the alternative is
|
|
56
|
+
keying strangers on their display name. Each person also carries their profile
|
|
57
|
+
URL, which mints the portable url-namespace identity that merges them with the
|
|
58
|
+
same page seen from any other source.
|
|
59
|
+
|
|
60
|
+
**You** come from `Profile.csv` for the name and headline and from
|
|
61
|
+
`Email Addresses.csv` for the primary address, which rides as a `sameAs` in the
|
|
62
|
+
`email` namespace — evidence that merges your LinkedIn self with the self your
|
|
63
|
+
mail and messaging plugins already know.
|
|
64
|
+
|
|
65
|
+
Your own **profile URL is not in `Profile.csv`**; LinkedIn omits it. It appears
|
|
66
|
+
only in `Invitations.csv`, which carries both parties' URLs _and_ a `Direction`
|
|
67
|
+
column — so an `OUTGOING` row states outright that the inviter is you. That is
|
|
68
|
+
the source declaring it, not a name match. Failing that, pass `--profile-url`;
|
|
69
|
+
failing that too, the self falls back to the per-source singleton. All three
|
|
70
|
+
routes still merge onto one person through `@me`.
|
|
71
|
+
|
|
72
|
+
**Organizations key on their name.** There is no company id anywhere in an
|
|
73
|
+
export, so two different companies sharing a name will merge.
|
|
74
|
+
|
|
75
|
+
**Messages have no id of their own.** LinkedIn ships the conversation id, the
|
|
76
|
+
moment, the sender and the text. The `Thread` keys on that conversation id; a
|
|
77
|
+
`Message` keys on the natural tuple. Two messages with identical text from one
|
|
78
|
+
person in one thread therefore resolve to a single `Message` — though their
|
|
79
|
+
sends stay distinct `MessageAction`s at their own timestamps.
|
|
80
|
+
|
|
81
|
+
## Judgment calls worth knowing
|
|
82
|
+
|
|
83
|
+
**A connection is recorded as a `FollowAction`, and that is not an exact fit.**
|
|
84
|
+
A LinkedIn connection is mutual and consented; a follow is neither. What makes
|
|
85
|
+
it defensible is that connecting does create a follow in both directions. Only
|
|
86
|
+
your side is written — inventing the reciprocal edge would double every
|
|
87
|
+
connection to say something the row never said. The ontology has no
|
|
88
|
+
"connected with" verb; if one is ever added, this is what should move to it.
|
|
89
|
+
|
|
90
|
+
**A message body is text, never markup.** A quarter of the messages in an
|
|
91
|
+
export arrive as the LinkedIn editor's raw HTML — sponsored InMail and every
|
|
92
|
+
templated notice. `htmlToText` from `@chronicle.app/etl` turns each back into
|
|
93
|
+
what a reader saw: paragraphs
|
|
94
|
+
and `<br>` become line breaks, list items become lines, entities are decoded,
|
|
95
|
+
and ` ` becomes an ordinary space rather than an invisible U+00A0 that
|
|
96
|
+
would make the words unfindable by search.
|
|
97
|
+
|
|
98
|
+
A link keeps **both** its label and its URL, rendered `label (url)`. LinkedIn's
|
|
99
|
+
anchors mostly read "here" or "Get started", so the label alone would say
|
|
100
|
+
nothing about where it went. A tag is only recognized when its name is followed
|
|
101
|
+
by whitespace, `/` or `>`, which is what keeps a bare `<https://example.com>` in
|
|
102
|
+
a plain-text message from being deleted as markup — a tag name cannot contain a
|
|
103
|
+
colon. A body with no tags is entity-decoded and otherwise left exactly as
|
|
104
|
+
typed, because its line breaks are the sender's own.
|
|
105
|
+
|
|
106
|
+
**Dates keep the precision LinkedIn wrote them at.** A position reads
|
|
107
|
+
`2022-08`, an old one `2009`, a connection `2026-06-06`. The store parses each
|
|
108
|
+
at its own precision, so widening them to a moment would be a lie.
|
|
109
|
+
|
|
110
|
+
**Snapshot vs event is decided per file.** `Connections.csv` is a `snapshot`
|
|
111
|
+
read even though every row carries a real "Connected On": the date is history,
|
|
112
|
+
but their company and title are current, and back-dating those to a connection
|
|
113
|
+
made in 2010 would claim they worked there then. `Positions.csv` and
|
|
114
|
+
`Education.csv` are snapshots for the same reason. Messages, endorsements,
|
|
115
|
+
follows and learning are events.
|
|
116
|
+
|
|
117
|
+
**Re-importing the same directory is a no-op.** The sighting time is the
|
|
118
|
+
export's own file time, not the moment of import, so a second run restates
|
|
119
|
+
identical assertions instead of minting fresh ones.
|
|
120
|
+
|
|
121
|
+
## Not read, and why
|
|
122
|
+
|
|
123
|
+
- **`Ad_Targeting.csv`** — LinkedIn's inferences _about_ you, not anything you
|
|
124
|
+
did. Interesting, but not history.
|
|
125
|
+
- **`Skills.csv`** — a bare list with no dates. The schema has no predicate
|
|
126
|
+
attaching a skill to a person, and the endorsements already mint the skills
|
|
127
|
+
as `DefinedTerm`s with real dates behind them.
|
|
128
|
+
- **`Invitations.csv`** — no verb for it, and an accepted invitation is already
|
|
129
|
+
recorded as the connection. It is read for the self-detection above.
|
|
130
|
+
- **`Rich_Media.csv`** — profile photo uploads, behind signed `licdn.com` URLs
|
|
131
|
+
that expire.
|
|
132
|
+
- **Message attachments** — signed `linkedin.com/dms/…` URLs carrying an expiry
|
|
133
|
+
and a per-export token. The link dies long before the archive is old, and an
|
|
134
|
+
entity keyed on it would mint a fresh identity every export. The bytes are
|
|
135
|
+
not in the ZIP.
|
|
136
|
+
- **Free-text locations** (`Geo Location`, a position's `Location`) — a
|
|
137
|
+
name-keyed `Place` would collide with the geocoded places other plugins mint.
|
|
138
|
+
- **The message folder** (`INBOX` / `ARCHIVE`) — no home for it that is not a
|
|
139
|
+
stretch.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm run build -w @chronicle.app/linkedin
|
|
145
|
+
npm test -w @chronicle.app/linkedin
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tests write a small synthetic export to a temporary directory. It reproduces
|
|
149
|
+
the awkward parts of a real one: the `Notes:` preamble above
|
|
150
|
+
`Connections.csv`'s header, a connection LinkedIn withheld entirely, a group
|
|
151
|
+
thread whose name list is longer than its URL list, an HTML message body, and
|
|
152
|
+
`N/A` where a date should be. The tests pass the export directory explicitly,
|
|
153
|
+
so they never read the host's LinkedIn data or any account.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { BaseAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
/**
|
|
4
|
+
* The one transformer for every file in the export.
|
|
5
|
+
*
|
|
6
|
+
* Two decisions run through all of it:
|
|
7
|
+
*
|
|
8
|
+
* **People key on the vanity slug.** LinkedIn never puts a member's numeric id
|
|
9
|
+
* in an export, so `/in/<slug>` is the only identifier there is. It is
|
|
10
|
+
* user-changeable, which makes it an imperfect key — but it is a real one, and
|
|
11
|
+
* the alternative is keying strangers on their display name. Each person also
|
|
12
|
+
* carries their profile URL, which mints the portable url-namespace identity
|
|
13
|
+
* that merges them with the same page seen from anywhere else.
|
|
14
|
+
*
|
|
15
|
+
* **Only what LinkedIn wrote gets written.** Where the export has no id, the
|
|
16
|
+
* key is built from the natural fields LinkedIn did provide (an organization's
|
|
17
|
+
* name, a conversation's id, a skill's name) — never from a value invented
|
|
18
|
+
* here.
|
|
19
|
+
*/
|
|
20
|
+
export default class LinkedInTransformer extends ChronicleTransformer {
|
|
21
|
+
transform(record: Record): Promise<BaseAndChildren[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Your own profile, carried by the `UpdateAction` that sighted it.
|
|
24
|
+
*
|
|
25
|
+
* The store takes actions, not loose entities, so a standing fact needs an
|
|
26
|
+
* occurrence to arrive on — the same carrier the Obsidian plugin uses for a
|
|
27
|
+
* note's subject. Its timestamp is when LinkedIn compiled the export, which
|
|
28
|
+
* is genuinely when the profile read this way, so a second import of the same
|
|
29
|
+
* directory restates the identical sighting rather than adding a new one.
|
|
30
|
+
*
|
|
31
|
+
* Everything here also rides every other record as the acting agent, so this
|
|
32
|
+
* matters most for the export that has nothing else in it.
|
|
33
|
+
*/
|
|
34
|
+
private buildProfile;
|
|
35
|
+
/**
|
|
36
|
+
* A job as a `JoinAction`/`LeaveAction` pair over the employer, carrying a
|
|
37
|
+
* `Tenure` as their shared result. The pair is what the fold turns into a
|
|
38
|
+
* `memberOf` run across the span; the employer is the boundary's object,
|
|
39
|
+
* never a property on the tenure.
|
|
40
|
+
*
|
|
41
|
+
* The boundaries key on the tenure and the employer, never on the date. A
|
|
42
|
+
* position whose start month you fix in a later export then revises the run
|
|
43
|
+
* you already have, instead of appearing beside it as a second job.
|
|
44
|
+
*/
|
|
45
|
+
private buildPosition;
|
|
46
|
+
/** Schooling, in the same shape as a job: two boundaries around an `Enrollment`. */
|
|
47
|
+
private buildEducation;
|
|
48
|
+
/** The opener, plus the closer when the span has actually ended. */
|
|
49
|
+
private episode;
|
|
50
|
+
/**
|
|
51
|
+
* A connection, as a dated `FollowAction` onto the other person.
|
|
52
|
+
*
|
|
53
|
+
* **This is the closest verb the ontology has, and it is not an exact fit.**
|
|
54
|
+
* A LinkedIn connection is mutual and consented; a follow is neither. What
|
|
55
|
+
* makes it defensible is that connecting on LinkedIn *does* create a follow,
|
|
56
|
+
* in both directions — so the edge recorded here is real, just not the whole
|
|
57
|
+
* of what happened. Only your side is written: this is your archive, and
|
|
58
|
+
* inventing the reciprocal edge would double every connection on the timeline
|
|
59
|
+
* to say something the row never said.
|
|
60
|
+
*/
|
|
61
|
+
private buildConnection;
|
|
62
|
+
/**
|
|
63
|
+
* Following an organization — the one place `FollowAction` means exactly what
|
|
64
|
+
* it says. LinkedIn files topic pages ("Scrum", "Web Development") in the
|
|
65
|
+
* same list as real employers and gives none of them an id, so the name is
|
|
66
|
+
* both the label and the key.
|
|
67
|
+
*/
|
|
68
|
+
private buildFollow;
|
|
69
|
+
/**
|
|
70
|
+
* An endorsement: one person vouching that another has a skill.
|
|
71
|
+
*
|
|
72
|
+
* `RespondAction` is the schema's verb for a reaction that carries a payload
|
|
73
|
+
* — the endorsement's payload being *which* skill — so the skill rides the
|
|
74
|
+
* result `Response` as its body and its `about`, and the action itself points
|
|
75
|
+
* at the person. A plain `LikeAction` would lose the skill, which is the only
|
|
76
|
+
* interesting part.
|
|
77
|
+
*
|
|
78
|
+
* The response keys through `action.*` onto both parties. Without that, one
|
|
79
|
+
* endorser vouching for the same skill for two different people would collapse
|
|
80
|
+
* into a single response shared by both endorsements.
|
|
81
|
+
*/
|
|
82
|
+
private buildEndorsement;
|
|
83
|
+
/**
|
|
84
|
+
* One message, in the conversation LinkedIn assigned it to.
|
|
85
|
+
*
|
|
86
|
+
* The `Thread` is the only thing in the whole export with a first-class id, so
|
|
87
|
+
* it keys on that id and the messages hang off it with `isPartOf`.
|
|
88
|
+
*
|
|
89
|
+
* **Individual messages have no id.** LinkedIn ships the conversation, the
|
|
90
|
+
* moment, the sender, and the text — nothing more — so the message keys on
|
|
91
|
+
* that natural tuple and the action keys on the same. The cost is visible and
|
|
92
|
+
* accepted: two messages with identical text from one person in one thread
|
|
93
|
+
* resolve to a single `Message`, though their sends stay two distinct
|
|
94
|
+
* `MessageAction`s at their own timestamps.
|
|
95
|
+
*/
|
|
96
|
+
private buildMessage;
|
|
97
|
+
/**
|
|
98
|
+
* A course, and whichever of the three things the row records about it: you
|
|
99
|
+
* watched it, you finished it, you saved it. LinkedIn gives courses no id, so
|
|
100
|
+
* the title is the key — it is what the export identifies them by.
|
|
101
|
+
*
|
|
102
|
+
* The saved flag has no date of its own. The bookmark is emitted undated
|
|
103
|
+
* rather than borrowed from the watch, which happened at a different moment.
|
|
104
|
+
*/
|
|
105
|
+
private buildLearning;
|
|
106
|
+
/**
|
|
107
|
+
* The account owner.
|
|
108
|
+
*
|
|
109
|
+
* The handle comes from the export when it names one — see the extractor's
|
|
110
|
+
* note on reading it off an outgoing invitation — and otherwise the self falls
|
|
111
|
+
* back to `selfAgent`'s per-source singleton. Either way `@me` merges it onto
|
|
112
|
+
* the one person, and the primary email rides as a `sameAs` in the `email`
|
|
113
|
+
* namespace, where the mail and messaging plugins already key their people. A
|
|
114
|
+
* shared email is evidence of one person; a shared display name is not.
|
|
115
|
+
*/
|
|
116
|
+
private self;
|
|
117
|
+
/**
|
|
118
|
+
* Another member. Returns nothing when LinkedIn identified them neither by
|
|
119
|
+
* slug nor by name — a row about nobody in particular is not a person.
|
|
120
|
+
*
|
|
121
|
+
* With a slug, the key is that slug and the profile URL comes along, which
|
|
122
|
+
* also mints the url-namespace identity every other source can meet them at.
|
|
123
|
+
* Without one, the display name is all that is left; it is a weak key and two
|
|
124
|
+
* namesakes will merge, but dropping the row entirely would lose a real
|
|
125
|
+
* relationship over a missing field.
|
|
126
|
+
*/
|
|
127
|
+
private person;
|
|
128
|
+
/**
|
|
129
|
+
* A message party, which is a person LinkedIn may have declined to name.
|
|
130
|
+
*
|
|
131
|
+
* You are a party to every message in your own export, so a party whose slug
|
|
132
|
+
* is your own resolves to the self node rather than to a stranger who happens
|
|
133
|
+
* to share your key. Both spellings key identically and would merge anyway;
|
|
134
|
+
* this way the `@me` edge is on the node from the start, so messages alone
|
|
135
|
+
* are enough to place you even if the profile record never runs.
|
|
136
|
+
*/
|
|
137
|
+
private party;
|
|
138
|
+
/**
|
|
139
|
+
* An organization, keyed on its name. LinkedIn puts no company id anywhere in
|
|
140
|
+
* an export — not in the positions, not in the follows — so the name is the
|
|
141
|
+
* only identifier available, and two different companies sharing a name will
|
|
142
|
+
* merge. Recorded here rather than worked around: the alternative is a
|
|
143
|
+
* fabricated id, which would be worse and less honest.
|
|
144
|
+
*/
|
|
145
|
+
private organization;
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=LinkedInTransformer.d.ts.map
|