@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
@@ -0,0 +1,473 @@
1
+ import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
2
+ import { profileUrl } from './fields.js';
3
+ const SOURCE = 'linkedin';
4
+ /**
5
+ * The one transformer for every file in the export.
6
+ *
7
+ * Two decisions run through all of it:
8
+ *
9
+ * **People key on the vanity slug.** LinkedIn never puts a member's numeric id
10
+ * in an export, so `/in/<slug>` is the only identifier there is. It is
11
+ * user-changeable, which makes it an imperfect key — but it is a real one, and
12
+ * the alternative is keying strangers on their display name. Each person also
13
+ * carries their profile URL, which mints the portable url-namespace identity
14
+ * that merges them with the same page seen from anywhere else.
15
+ *
16
+ * **Only what LinkedIn wrote gets written.** Where the export has no id, the
17
+ * key is built from the natural fields LinkedIn did provide (an organization's
18
+ * name, a conversation's id, a skill's name) — never from a value invented
19
+ * here.
20
+ */
21
+ export default class LinkedInTransformer extends ChronicleTransformer {
22
+ async transform(record) {
23
+ switch (record.extraction.recordType) {
24
+ case 'profile': {
25
+ return this.buildProfile(record);
26
+ }
27
+ case 'positions': {
28
+ return this.buildPosition(record);
29
+ }
30
+ case 'education': {
31
+ return this.buildEducation(record);
32
+ }
33
+ case 'connections': {
34
+ return this.buildConnection(record);
35
+ }
36
+ case 'endorsements': {
37
+ return this.buildEndorsement(record);
38
+ }
39
+ case 'follows': {
40
+ return this.buildFollow(record);
41
+ }
42
+ case 'messages': {
43
+ return this.buildMessage(record);
44
+ }
45
+ case 'learning': {
46
+ return this.buildLearning(record);
47
+ }
48
+ default: {
49
+ return [];
50
+ }
51
+ }
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // The profile
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Your own profile, carried by the `UpdateAction` that sighted it.
58
+ *
59
+ * The store takes actions, not loose entities, so a standing fact needs an
60
+ * occurrence to arrive on — the same carrier the Obsidian plugin uses for a
61
+ * note's subject. Its timestamp is when LinkedIn compiled the export, which
62
+ * is genuinely when the profile read this way, so a second import of the same
63
+ * directory restates the identical sighting rather than adding a new one.
64
+ *
65
+ * Everything here also rides every other record as the acting agent, so this
66
+ * matters most for the export that has nothing else in it.
67
+ */
68
+ buildProfile(record) {
69
+ const sightedAt = record.extraction.assertedAt;
70
+ const update = {
71
+ '@type': 'UpdateAction',
72
+ '@key': ['@type', 'source', 'object.handle', 'timestamp'],
73
+ source: SOURCE,
74
+ agent: this.self(record),
75
+ object: this.self(record),
76
+ ...(sightedAt && { timestamp: sightedAt }),
77
+ };
78
+ return [update];
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Work and school — a bounded span, told as its two boundaries
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * A job as a `JoinAction`/`LeaveAction` pair over the employer, carrying a
85
+ * `Tenure` as their shared result. The pair is what the fold turns into a
86
+ * `memberOf` run across the span; the employer is the boundary's object,
87
+ * never a property on the tenure.
88
+ *
89
+ * The boundaries key on the tenure and the employer, never on the date. A
90
+ * position whose start month you fix in a later export then revises the run
91
+ * you already have, instead of appearing beside it as a second job.
92
+ */
93
+ buildPosition(record) {
94
+ const data = record.data;
95
+ if (!data.company || !data.startedOn)
96
+ return [];
97
+ const employer = this.organization(data.company);
98
+ const tenure = {
99
+ '@type': 'Tenure',
100
+ '@key': ['@type', 'source', 'role', 'action.object.name'],
101
+ source: SOURCE,
102
+ ...(data.title && { role: data.title }),
103
+ ...(data.description && { description: data.description }),
104
+ };
105
+ return this.episode('JoinAction', 'LeaveAction', record, employer, tenure, data);
106
+ }
107
+ /** Schooling, in the same shape as a job: two boundaries around an `Enrollment`. */
108
+ buildEducation(record) {
109
+ const data = record.data;
110
+ if (!data.school || !data.startedOn)
111
+ return [];
112
+ const school = this.organization(data.school);
113
+ const description = [data.notes, data.activities].filter(Boolean).join('\n\n');
114
+ const enrollment = {
115
+ '@type': 'Enrollment',
116
+ '@key': ['@type', 'source', 'degree', 'action.object.name'],
117
+ source: SOURCE,
118
+ ...(data.degree && { degree: data.degree }),
119
+ ...(description && { description }),
120
+ };
121
+ return this.episode('JoinAction', 'LeaveAction', record, school, enrollment, data);
122
+ }
123
+ /** The opener, plus the closer when the span has actually ended. */
124
+ episode(opener, closer, record, object, result, dates) {
125
+ const agent = this.self(record);
126
+ const boundary = (type, timestamp) => ({
127
+ '@type': type,
128
+ '@key': ['@type', 'source', 'result.role', 'result.degree', 'object.name'],
129
+ source: SOURCE,
130
+ agent,
131
+ object,
132
+ timestamp,
133
+ result,
134
+ });
135
+ const actions = [boundary(opener, dates.startedOn)];
136
+ if (dates.finishedOn)
137
+ actions.push(boundary(closer, dates.finishedOn));
138
+ return actions;
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // The network
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * A connection, as a dated `FollowAction` onto the other person.
145
+ *
146
+ * **This is the closest verb the ontology has, and it is not an exact fit.**
147
+ * A LinkedIn connection is mutual and consented; a follow is neither. What
148
+ * makes it defensible is that connecting on LinkedIn *does* create a follow,
149
+ * in both directions — so the edge recorded here is real, just not the whole
150
+ * of what happened. Only your side is written: this is your archive, and
151
+ * inventing the reciprocal edge would double every connection on the timeline
152
+ * to say something the row never said.
153
+ */
154
+ buildConnection(record) {
155
+ const data = record.data;
156
+ const person = this.person({
157
+ handle: data.handle,
158
+ name: data.name,
159
+ email: data.email,
160
+ // LinkedIn's own one-line descriptor of them — the title it prints under
161
+ // their name. Their current job, sighted now, not a dated appointment.
162
+ description: data.position,
163
+ company: data.company,
164
+ });
165
+ if (!person)
166
+ return [];
167
+ const follow = {
168
+ '@type': 'FollowAction',
169
+ '@key': ['@type', 'source', 'agent.handle', 'object.handle', 'object.name'],
170
+ source: SOURCE,
171
+ agent: this.self(record),
172
+ object: person,
173
+ ...(data.connectedOn && { timestamp: data.connectedOn }),
174
+ };
175
+ return [follow];
176
+ }
177
+ /**
178
+ * Following an organization — the one place `FollowAction` means exactly what
179
+ * it says. LinkedIn files topic pages ("Scrum", "Web Development") in the
180
+ * same list as real employers and gives none of them an id, so the name is
181
+ * both the label and the key.
182
+ */
183
+ buildFollow(record) {
184
+ const data = record.data;
185
+ if (!data.organization)
186
+ return [];
187
+ const follow = {
188
+ '@type': 'FollowAction',
189
+ '@key': ['@type', 'source', 'agent.handle', 'object.name'],
190
+ source: SOURCE,
191
+ agent: this.self(record),
192
+ object: this.organization(data.organization),
193
+ ...(data.followedAt && { timestamp: data.followedAt }),
194
+ };
195
+ return [follow];
196
+ }
197
+ /**
198
+ * An endorsement: one person vouching that another has a skill.
199
+ *
200
+ * `RespondAction` is the schema's verb for a reaction that carries a payload
201
+ * — the endorsement's payload being *which* skill — so the skill rides the
202
+ * result `Response` as its body and its `about`, and the action itself points
203
+ * at the person. A plain `LikeAction` would lose the skill, which is the only
204
+ * interesting part.
205
+ *
206
+ * The response keys through `action.*` onto both parties. Without that, one
207
+ * endorser vouching for the same skill for two different people would collapse
208
+ * into a single response shared by both endorsements.
209
+ */
210
+ buildEndorsement(record) {
211
+ const data = record.data;
212
+ if (!data.skill)
213
+ return [];
214
+ const other = this.person({
215
+ handle: data.otherHandle,
216
+ name: data.otherName,
217
+ });
218
+ if (!other)
219
+ return [];
220
+ const self = this.self(record);
221
+ const endorser = data.direction === 'given' ? self : other;
222
+ const endorsee = data.direction === 'given' ? other : self;
223
+ const skill = {
224
+ '@type': 'DefinedTerm',
225
+ '@key': ['@type', 'source', 'name'],
226
+ source: SOURCE,
227
+ name: data.skill,
228
+ };
229
+ const endorsement = {
230
+ '@type': 'RespondAction',
231
+ '@key': ['@type', 'source', 'agent.handle', 'object.handle', 'result.body'],
232
+ source: SOURCE,
233
+ agent: endorser,
234
+ object: endorsee,
235
+ ...(data.endorsedAt && { timestamp: data.endorsedAt }),
236
+ result: {
237
+ '@type': 'Response',
238
+ '@key': ['@type', 'source', 'action.agent.handle', 'action.object.handle', 'body'],
239
+ source: SOURCE,
240
+ body: data.skill,
241
+ author: [endorser],
242
+ about: [skill],
243
+ },
244
+ };
245
+ return [endorsement];
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Messages
249
+ // ---------------------------------------------------------------------------
250
+ /**
251
+ * One message, in the conversation LinkedIn assigned it to.
252
+ *
253
+ * The `Thread` is the only thing in the whole export with a first-class id, so
254
+ * it keys on that id and the messages hang off it with `isPartOf`.
255
+ *
256
+ * **Individual messages have no id.** LinkedIn ships the conversation, the
257
+ * moment, the sender, and the text — nothing more — so the message keys on
258
+ * that natural tuple and the action keys on the same. The cost is visible and
259
+ * accepted: two messages with identical text from one person in one thread
260
+ * resolve to a single `Message`, though their sends stay two distinct
261
+ * `MessageAction`s at their own timestamps.
262
+ */
263
+ buildMessage(record) {
264
+ const data = record.data;
265
+ if (!data.conversationId || !data.sentAt)
266
+ return [];
267
+ const thread = {
268
+ '@type': 'Thread',
269
+ '@key': ['@type', 'source', 'sourceId'],
270
+ source: SOURCE,
271
+ sourceId: data.conversationId,
272
+ ...(data.conversationTitle && { name: data.conversationTitle }),
273
+ };
274
+ const sender = this.party(data.sender, record);
275
+ const recipients = (data.recipients ?? []).flatMap(party => this.party(party, record) ?? []);
276
+ const message = {
277
+ '@type': 'Message',
278
+ '@key': ['@type', 'source', 'isPartOf[*].sourceId', 'author[*].handle', 'name', 'body'],
279
+ source: SOURCE,
280
+ isPartOf: [thread],
281
+ ...(data.subject && { name: data.subject }),
282
+ ...(data.content && { body: data.content }),
283
+ ...(sender && { author: [sender] }),
284
+ ...(recipients.length > 0 && { recipient: recipients }),
285
+ };
286
+ const sent = {
287
+ '@type': 'MessageAction',
288
+ '@key': [
289
+ '@type',
290
+ 'source',
291
+ 'timestamp',
292
+ 'object.isPartOf[*].sourceId',
293
+ 'agent.handle',
294
+ 'agent.name',
295
+ ],
296
+ source: SOURCE,
297
+ timestamp: data.sentAt,
298
+ ...(sender && { agent: sender }),
299
+ object: message,
300
+ };
301
+ return [sent];
302
+ }
303
+ // ---------------------------------------------------------------------------
304
+ // LinkedIn Learning
305
+ // ---------------------------------------------------------------------------
306
+ /**
307
+ * A course, and whichever of the three things the row records about it: you
308
+ * watched it, you finished it, you saved it. LinkedIn gives courses no id, so
309
+ * the title is the key — it is what the export identifies them by.
310
+ *
311
+ * The saved flag has no date of its own. The bookmark is emitted undated
312
+ * rather than borrowed from the watch, which happened at a different moment.
313
+ */
314
+ buildLearning(record) {
315
+ const data = record.data;
316
+ if (!data.title)
317
+ return [];
318
+ const agent = this.self(record);
319
+ const course = {
320
+ '@type': 'CreativeWork',
321
+ '@key': ['@type', 'source', 'name'],
322
+ source: SOURCE,
323
+ name: data.title,
324
+ ...(data.description && { description: data.description }),
325
+ ...(data.contentType && { sourceFormat: data.contentType.toLowerCase() }),
326
+ };
327
+ const actions = [];
328
+ if (data.watchedAt) {
329
+ actions.push({
330
+ '@type': 'WatchAction',
331
+ '@key': ['@type', 'source', 'object.name', 'timestamp'],
332
+ source: SOURCE,
333
+ agent,
334
+ object: course,
335
+ timestamp: data.watchedAt,
336
+ });
337
+ }
338
+ if (data.completedAt) {
339
+ actions.push({
340
+ '@type': 'CompleteAction',
341
+ '@key': ['@type', 'source', 'object.name', 'timestamp'],
342
+ source: SOURCE,
343
+ agent,
344
+ object: course,
345
+ timestamp: data.completedAt,
346
+ });
347
+ }
348
+ if (data.saved) {
349
+ // The export says the course is saved, not when it was saved, so the
350
+ // bookmark carries no occurrence. It still needs a sighting: an
351
+ // occurrence-less claim is otherwise timeless, which the inbox drain
352
+ // refuses rather than stamping drain-time. The sighting is the export's
353
+ // own compile time, so a re-import restates it instead of re-sighting it.
354
+ const sightedAt = record.extraction.assertedAt;
355
+ actions.push({
356
+ '@type': 'BookmarkAction',
357
+ '@key': ['@type', 'source', 'object.name'],
358
+ source: SOURCE,
359
+ agent,
360
+ object: course,
361
+ ...(sightedAt && { '@assertedAt': sightedAt }),
362
+ });
363
+ }
364
+ return actions;
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // Shared nodes
368
+ // ---------------------------------------------------------------------------
369
+ /**
370
+ * The account owner.
371
+ *
372
+ * The handle comes from the export when it names one — see the extractor's
373
+ * note on reading it off an outgoing invitation — and otherwise the self falls
374
+ * back to `selfAgent`'s per-source singleton. Either way `@me` merges it onto
375
+ * the one person, and the primary email rides as a `sameAs` in the `email`
376
+ * namespace, where the mail and messaging plugins already key their people. A
377
+ * shared email is evidence of one person; a shared display name is not.
378
+ */
379
+ self(record) {
380
+ const account = (record.context?.accountInfo ?? {});
381
+ const sameAs = account.email
382
+ ? [
383
+ {
384
+ '@type': 'Person',
385
+ '@key': ['@type', 'source', 'handle'],
386
+ source: 'email',
387
+ handle: account.email,
388
+ ...(account.name && { name: account.name }),
389
+ },
390
+ ]
391
+ : [];
392
+ return {
393
+ ...selfAgent({
394
+ source: SOURCE,
395
+ handle: account.handle,
396
+ name: account.name,
397
+ sameAs,
398
+ }),
399
+ ...(account.handle && { url: profileUrl(account.handle) }),
400
+ ...(account.headline && { description: account.headline }),
401
+ };
402
+ }
403
+ /**
404
+ * Another member. Returns nothing when LinkedIn identified them neither by
405
+ * slug nor by name — a row about nobody in particular is not a person.
406
+ *
407
+ * With a slug, the key is that slug and the profile URL comes along, which
408
+ * also mints the url-namespace identity every other source can meet them at.
409
+ * Without one, the display name is all that is left; it is a weak key and two
410
+ * namesakes will merge, but dropping the row entirely would lose a real
411
+ * relationship over a missing field.
412
+ */
413
+ person(fields) {
414
+ if (!fields.handle && !fields.name)
415
+ return undefined;
416
+ const sameAs = fields.email
417
+ ? [
418
+ {
419
+ '@type': 'Person',
420
+ '@key': ['@type', 'source', 'handle'],
421
+ source: 'email',
422
+ handle: fields.email,
423
+ ...(fields.name && { name: fields.name }),
424
+ },
425
+ ]
426
+ : undefined;
427
+ return {
428
+ '@type': 'Person',
429
+ '@key': fields.handle ? ['@type', 'source', 'handle'] : ['@type', 'source', 'name'],
430
+ source: SOURCE,
431
+ ...(fields.handle && {
432
+ handle: fields.handle,
433
+ url: profileUrl(fields.handle),
434
+ }),
435
+ ...(fields.name && { name: fields.name }),
436
+ ...(fields.description && { description: fields.description }),
437
+ ...(fields.company && { memberOf: [this.organization(fields.company)] }),
438
+ ...(sameAs && { sameAs }),
439
+ };
440
+ }
441
+ /**
442
+ * A message party, which is a person LinkedIn may have declined to name.
443
+ *
444
+ * You are a party to every message in your own export, so a party whose slug
445
+ * is your own resolves to the self node rather than to a stranger who happens
446
+ * to share your key. Both spellings key identically and would merge anyway;
447
+ * this way the `@me` edge is on the node from the start, so messages alone
448
+ * are enough to place you even if the profile record never runs.
449
+ */
450
+ party(party, record) {
451
+ if (!party)
452
+ return undefined;
453
+ const account = (record.context?.accountInfo ?? {});
454
+ if (party.handle && party.handle === account.handle)
455
+ return this.self(record);
456
+ return this.person({ handle: party.handle, name: party.name });
457
+ }
458
+ /**
459
+ * An organization, keyed on its name. LinkedIn puts no company id anywhere in
460
+ * an export — not in the positions, not in the follows — so the name is the
461
+ * only identifier available, and two different companies sharing a name will
462
+ * merge. Recorded here rather than worked around: the alternative is a
463
+ * fabricated id, which would be worse and less honest.
464
+ */
465
+ organization(name) {
466
+ return {
467
+ '@type': 'Organization',
468
+ '@key': ['@type', 'source', 'name'],
469
+ source: SOURCE,
470
+ name,
471
+ };
472
+ }
473
+ }
@@ -0,0 +1,48 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import LinkedInTransformer from '../LinkedInTransformer.js';
3
+ import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
+ /**
5
+ * `Connections.csv` — everyone you are connected to, with the day it happened,
6
+ * newest-first.
7
+ *
8
+ * The email column is nearly always blank: LinkedIn only releases a
9
+ * connection's address when they opted into sharing it. The rows that do carry
10
+ * one are worth keeping — an email is a portable identity that merges this
11
+ * person with the one your mail archive already knows.
12
+ *
13
+ * `snapshot`, because a row is two things at once. "Connected On" is a real
14
+ * past event and stays where LinkedIn put it. Their company and title are
15
+ * whatever they are *today* — back-dating those to a connection made in 2010
16
+ * would claim they worked there then, which is a claim the export never made.
17
+ */
18
+ export declare class LinkedInArchiveConnectionsExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveConnectionsExtractor> {
19
+ static description: string;
20
+ static recordTypes: string[];
21
+ static temporality: "snapshot";
22
+ static defaultTransformer: typeof LinkedInTransformer;
23
+ static schema: import("zod").ZodObject<{
24
+ since: import("zod").ZodOptional<import("zod").ZodDate>;
25
+ until: import("zod").ZodOptional<import("zod").ZodDate>;
26
+ limit: import("zod").ZodOptional<import("zod").ZodNumber>;
27
+ } & {
28
+ input: import("zod").ZodString;
29
+ 'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
30
+ }, "strip", import("zod").ZodTypeAny, {
31
+ input: string;
32
+ 'profile-url'?: string | undefined;
33
+ since?: Date | undefined;
34
+ until?: Date | undefined;
35
+ limit?: number | undefined;
36
+ }, {
37
+ input: string;
38
+ 'profile-url'?: string | undefined;
39
+ since?: Date | undefined;
40
+ until?: Date | undefined;
41
+ limit?: number | undefined;
42
+ }>;
43
+ /** The connection's profile URL — LinkedIn's own id for them, and stable per export. */
44
+ keyOf(record: Record): string | null;
45
+ extract(): AsyncGenerator<Record>;
46
+ determineCount(): Promise<number>;
47
+ }
48
+ //# sourceMappingURL=LinkedInArchiveConnectionsExtractor.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { memberName, parseDayDate, profileHandle, profileUrl } from '../fields.js';
2
+ import LinkedInTransformer from '../LinkedInTransformer.js';
3
+ import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
+ /**
5
+ * `Connections.csv` — everyone you are connected to, with the day it happened,
6
+ * newest-first.
7
+ *
8
+ * The email column is nearly always blank: LinkedIn only releases a
9
+ * connection's address when they opted into sharing it. The rows that do carry
10
+ * one are worth keeping — an email is a portable identity that merges this
11
+ * person with the one your mail archive already knows.
12
+ *
13
+ * `snapshot`, because a row is two things at once. "Connected On" is a real
14
+ * past event and stays where LinkedIn put it. Their company and title are
15
+ * whatever they are *today* — back-dating those to a connection made in 2010
16
+ * would claim they worked there then, which is a claim the export never made.
17
+ */
18
+ export class LinkedInArchiveConnectionsExtractor extends LinkedInArchiveExtractor {
19
+ static description = 'Connections';
20
+ static recordTypes = ['connections'];
21
+ static temporality = 'snapshot';
22
+ static defaultTransformer = LinkedInTransformer;
23
+ static schema = LinkedInArchiveExtractor.schema;
24
+ /** The connection's profile URL — LinkedIn's own id for them, and stable per export. */
25
+ keyOf(record) {
26
+ return record.data.url ?? null;
27
+ }
28
+ async *extract() {
29
+ const rows = await this.readCsv('Connections.csv');
30
+ const connections = rows
31
+ .map(row => {
32
+ const handle = profileHandle(row.URL);
33
+ const name = memberName([row['First Name'], row['Last Name']]
34
+ .map(part => part?.trim())
35
+ .filter(Boolean)
36
+ .join(' '));
37
+ return {
38
+ handle,
39
+ name: name || undefined,
40
+ url: handle ? profileUrl(handle) : undefined,
41
+ email: row['Email Address']?.trim() || undefined,
42
+ company: row.Company?.trim() || undefined,
43
+ position: row.Position?.trim() || undefined,
44
+ connectedOn: parseDayDate(row['Connected On']),
45
+ };
46
+ })
47
+ // A row with neither a profile URL nor a name names nobody.
48
+ .filter(connection => connection.handle || connection.name)
49
+ .sort((a, b) => ((a.connectedOn ?? '') < (b.connectedOn ?? '') ? 1 : -1));
50
+ let count = 0;
51
+ for (const connection of connections) {
52
+ if (this.shouldStopExtracting(count))
53
+ break;
54
+ if (!this.withinWindow(connection.connectedOn))
55
+ continue;
56
+ yield this.createRecordWithArchiveContext({
57
+ ...connection,
58
+ occurredAt: connection.connectedOn,
59
+ });
60
+ count++;
61
+ }
62
+ }
63
+ async determineCount() {
64
+ return (await this.readCsv('Connections.csv')).length;
65
+ }
66
+ }
@@ -0,0 +1,36 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import LinkedInTransformer from '../LinkedInTransformer.js';
3
+ import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
+ /**
5
+ * `Education.csv` — schools attended, newest-first. `snapshot` for the same
6
+ * reason as positions: the dates are real, the degree text is a re-read.
7
+ */
8
+ export declare class LinkedInArchiveEducationExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveEducationExtractor> {
9
+ static description: string;
10
+ static recordTypes: string[];
11
+ static temporality: "snapshot";
12
+ static defaultTransformer: typeof LinkedInTransformer;
13
+ static schema: import("zod").ZodObject<{
14
+ since: import("zod").ZodOptional<import("zod").ZodDate>;
15
+ until: import("zod").ZodOptional<import("zod").ZodDate>;
16
+ limit: import("zod").ZodOptional<import("zod").ZodNumber>;
17
+ } & {
18
+ input: import("zod").ZodString;
19
+ 'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
20
+ }, "strip", import("zod").ZodTypeAny, {
21
+ input: string;
22
+ 'profile-url'?: string | undefined;
23
+ since?: Date | undefined;
24
+ until?: Date | undefined;
25
+ limit?: number | undefined;
26
+ }, {
27
+ input: string;
28
+ 'profile-url'?: string | undefined;
29
+ since?: Date | undefined;
30
+ until?: Date | undefined;
31
+ limit?: number | undefined;
32
+ }>;
33
+ extract(): AsyncGenerator<Record>;
34
+ determineCount(): Promise<number>;
35
+ }
36
+ //# sourceMappingURL=LinkedInArchiveEducationExtractor.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { parseCoarseDate } from '../fields.js';
2
+ import LinkedInTransformer from '../LinkedInTransformer.js';
3
+ import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
+ /**
5
+ * `Education.csv` — schools attended, newest-first. `snapshot` for the same
6
+ * reason as positions: the dates are real, the degree text is a re-read.
7
+ */
8
+ export class LinkedInArchiveEducationExtractor extends LinkedInArchiveExtractor {
9
+ static description = 'Education history';
10
+ static recordTypes = ['education'];
11
+ static temporality = 'snapshot';
12
+ static defaultTransformer = LinkedInTransformer;
13
+ static schema = LinkedInArchiveExtractor.schema;
14
+ async *extract() {
15
+ const rows = await this.readCsv('Education.csv');
16
+ const enrollments = rows
17
+ .map(row => ({
18
+ school: row['School Name']?.trim(),
19
+ degree: row['Degree Name']?.trim(),
20
+ notes: row.Notes?.trim(),
21
+ activities: row.Activities?.trim(),
22
+ startedOn: parseCoarseDate(row['Start Date']),
23
+ finishedOn: parseCoarseDate(row['End Date']),
24
+ }))
25
+ .filter(enrollment => enrollment.school && enrollment.startedOn)
26
+ .sort((a, b) => (a.startedOn < b.startedOn ? 1 : -1));
27
+ let count = 0;
28
+ for (const enrollment of enrollments) {
29
+ if (this.shouldStopExtracting(count))
30
+ break;
31
+ if (!this.withinWindow(enrollment.startedOn))
32
+ continue;
33
+ yield this.createRecordWithArchiveContext({
34
+ ...enrollment,
35
+ occurredAt: enrollment.startedOn,
36
+ });
37
+ count++;
38
+ }
39
+ }
40
+ async determineCount() {
41
+ return (await this.readCsv('Education.csv')).length;
42
+ }
43
+ }