@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.
Files changed (29) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/dist/connectors/LinkedInTransformer.d.ts +147 -0
  4. package/dist/connectors/LinkedInTransformer.js +473 -0
  5. package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.d.ts +48 -0
  6. package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.js +66 -0
  7. package/dist/connectors/archive/LinkedInArchiveEducationExtractor.d.ts +36 -0
  8. package/dist/connectors/archive/LinkedInArchiveEducationExtractor.js +43 -0
  9. package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.d.ts +46 -0
  10. package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.js +67 -0
  11. package/dist/connectors/archive/LinkedInArchiveExtractor.d.ts +120 -0
  12. package/dist/connectors/archive/LinkedInArchiveExtractor.js +207 -0
  13. package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.d.ts +39 -0
  14. package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.js +42 -0
  15. package/dist/connectors/archive/LinkedInArchiveLearningExtractor.d.ts +41 -0
  16. package/dist/connectors/archive/LinkedInArchiveLearningExtractor.js +46 -0
  17. package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.d.ts +63 -0
  18. package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.js +106 -0
  19. package/dist/connectors/archive/LinkedInArchivePositionsExtractor.d.ts +40 -0
  20. package/dist/connectors/archive/LinkedInArchivePositionsExtractor.js +47 -0
  21. package/dist/connectors/archive/LinkedInArchiveProfileExtractor.d.ts +41 -0
  22. package/dist/connectors/archive/LinkedInArchiveProfileExtractor.js +27 -0
  23. package/dist/connectors/archive/LinkedInDefaultExtractor.d.ts +30 -0
  24. package/dist/connectors/archive/LinkedInDefaultExtractor.js +69 -0
  25. package/dist/connectors/fields.d.ts +56 -0
  26. package/dist/connectors/fields.js +129 -0
  27. package/dist/index.d.ts +12 -0
  28. package/dist/index.js +11 -0
  29. 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 `&nbsp;` 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