@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/** A party to a message: the handle when LinkedIn gave one, the name when it did not. */
|
|
5
|
+
export interface MessageParty {
|
|
6
|
+
handle?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
url?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `messages.csv` — every LinkedIn message, newest-first, grouped by the
|
|
12
|
+
* conversation id LinkedIn assigns.
|
|
13
|
+
*
|
|
14
|
+
* **Names and profile URLs cannot be zipped together.** A group thread lists
|
|
15
|
+
* every participant's name in `TO` but only publishes URLs for the ones you may
|
|
16
|
+
* see, so the two comma-separated lists routinely differ in length and the
|
|
17
|
+
* n-th name is not the n-th URL. Pairing them positionally would attach real
|
|
18
|
+
* people to the wrong profiles. So the parties come from the URLs, which are
|
|
19
|
+
* real identifiers; the names ride along only when both lists are the same
|
|
20
|
+
* length, where the correspondence is unambiguous.
|
|
21
|
+
*
|
|
22
|
+
* **A quarter of the bodies are HTML, and none of it is stored.** Sponsored
|
|
23
|
+
* InMail and templated LinkedIn notices arrive as the message editor's raw
|
|
24
|
+
* markup. `htmlToText` turns each back into the text a reader saw — keeping a
|
|
25
|
+
* link's URL as well as its label, since these anchors mostly say "here" — so
|
|
26
|
+
* a `Message` body is what was said and not how it was styled.
|
|
27
|
+
*
|
|
28
|
+
* **Attachments do not survive the export.** The `ATTACHMENTS` column holds
|
|
29
|
+
* signed `linkedin.com/dms/…` URLs that carry an expiry and a per-export token:
|
|
30
|
+
* the link dies long before the archive is old, and an entity keyed on it would
|
|
31
|
+
* mint a fresh identity every time you export. The bytes themselves are not in
|
|
32
|
+
* the ZIP. `hasAttachment` therefore rides the record for a future pass to use,
|
|
33
|
+
* and the transformer writes nothing for it — better a gap than a dead link
|
|
34
|
+
* dressed as a file.
|
|
35
|
+
*/
|
|
36
|
+
export declare class LinkedInArchiveMessagesExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveMessagesExtractor> {
|
|
37
|
+
static description: string;
|
|
38
|
+
static recordTypes: string[];
|
|
39
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
40
|
+
static schema: import("zod").ZodObject<{
|
|
41
|
+
since: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
42
|
+
until: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
43
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
44
|
+
} & {
|
|
45
|
+
input: import("zod").ZodString;
|
|
46
|
+
'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
|
|
47
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
48
|
+
input: string;
|
|
49
|
+
'profile-url'?: string | undefined;
|
|
50
|
+
since?: Date | undefined;
|
|
51
|
+
until?: Date | undefined;
|
|
52
|
+
limit?: number | undefined;
|
|
53
|
+
}, {
|
|
54
|
+
input: string;
|
|
55
|
+
'profile-url'?: string | undefined;
|
|
56
|
+
since?: Date | undefined;
|
|
57
|
+
until?: Date | undefined;
|
|
58
|
+
limit?: number | undefined;
|
|
59
|
+
}>;
|
|
60
|
+
extract(): AsyncGenerator<Record>;
|
|
61
|
+
determineCount(): Promise<number>;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=LinkedInArchiveMessagesExtractor.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { htmlToText } from '@chronicle.app/etl';
|
|
2
|
+
import { memberName, parseSpaceStamp, profileHandle, profileUrl } from '../fields.js';
|
|
3
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
4
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
5
|
+
/**
|
|
6
|
+
* `messages.csv` — every LinkedIn message, newest-first, grouped by the
|
|
7
|
+
* conversation id LinkedIn assigns.
|
|
8
|
+
*
|
|
9
|
+
* **Names and profile URLs cannot be zipped together.** A group thread lists
|
|
10
|
+
* every participant's name in `TO` but only publishes URLs for the ones you may
|
|
11
|
+
* see, so the two comma-separated lists routinely differ in length and the
|
|
12
|
+
* n-th name is not the n-th URL. Pairing them positionally would attach real
|
|
13
|
+
* people to the wrong profiles. So the parties come from the URLs, which are
|
|
14
|
+
* real identifiers; the names ride along only when both lists are the same
|
|
15
|
+
* length, where the correspondence is unambiguous.
|
|
16
|
+
*
|
|
17
|
+
* **A quarter of the bodies are HTML, and none of it is stored.** Sponsored
|
|
18
|
+
* InMail and templated LinkedIn notices arrive as the message editor's raw
|
|
19
|
+
* markup. `htmlToText` turns each back into the text a reader saw — keeping a
|
|
20
|
+
* link's URL as well as its label, since these anchors mostly say "here" — so
|
|
21
|
+
* a `Message` body is what was said and not how it was styled.
|
|
22
|
+
*
|
|
23
|
+
* **Attachments do not survive the export.** The `ATTACHMENTS` column holds
|
|
24
|
+
* signed `linkedin.com/dms/…` URLs that carry an expiry and a per-export token:
|
|
25
|
+
* the link dies long before the archive is old, and an entity keyed on it would
|
|
26
|
+
* mint a fresh identity every time you export. The bytes themselves are not in
|
|
27
|
+
* the ZIP. `hasAttachment` therefore rides the record for a future pass to use,
|
|
28
|
+
* and the transformer writes nothing for it — better a gap than a dead link
|
|
29
|
+
* dressed as a file.
|
|
30
|
+
*/
|
|
31
|
+
export class LinkedInArchiveMessagesExtractor extends LinkedInArchiveExtractor {
|
|
32
|
+
static description = 'Messages';
|
|
33
|
+
static recordTypes = ['messages'];
|
|
34
|
+
static defaultTransformer = LinkedInTransformer;
|
|
35
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
36
|
+
async *extract() {
|
|
37
|
+
const rows = await this.readCsv('messages.csv');
|
|
38
|
+
const messages = rows
|
|
39
|
+
.map(row => {
|
|
40
|
+
const senderHandle = profileHandle(row['SENDER PROFILE URL']);
|
|
41
|
+
const senderName = memberName(row.FROM);
|
|
42
|
+
return {
|
|
43
|
+
conversationId: row['CONVERSATION ID']?.trim() || undefined,
|
|
44
|
+
conversationTitle: row['CONVERSATION TITLE']?.trim() || undefined,
|
|
45
|
+
sender: party(senderHandle, senderName),
|
|
46
|
+
recipients: parties(row.TO, row['RECIPIENT PROFILE URLS']),
|
|
47
|
+
subject: htmlToText(row.SUBJECT),
|
|
48
|
+
content: htmlToText(row.CONTENT),
|
|
49
|
+
folder: row.FOLDER?.trim() || undefined,
|
|
50
|
+
hasAttachment: Boolean(row.ATTACHMENTS?.trim()),
|
|
51
|
+
sentAt: parseSpaceStamp(row.DATE),
|
|
52
|
+
};
|
|
53
|
+
})
|
|
54
|
+
// The conversation and the moment are what make a message findable again;
|
|
55
|
+
// a row missing either is not a message we can place.
|
|
56
|
+
.filter(message => message.conversationId && message.sentAt)
|
|
57
|
+
.sort((a, b) => ((a.sentAt ?? '') < (b.sentAt ?? '') ? 1 : -1));
|
|
58
|
+
let count = 0;
|
|
59
|
+
for (const message of messages) {
|
|
60
|
+
if (this.shouldStopExtracting(count))
|
|
61
|
+
break;
|
|
62
|
+
if (!this.withinWindow(message.sentAt))
|
|
63
|
+
continue;
|
|
64
|
+
yield this.createRecordWithArchiveContext({
|
|
65
|
+
...message,
|
|
66
|
+
occurredAt: message.sentAt,
|
|
67
|
+
});
|
|
68
|
+
count++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async determineCount() {
|
|
72
|
+
return (await this.readCsv('messages.csv')).length;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** One party, or nothing when LinkedIn identified them neither way. */
|
|
76
|
+
function party(handle, name) {
|
|
77
|
+
if (!handle && !name)
|
|
78
|
+
return undefined;
|
|
79
|
+
return {
|
|
80
|
+
...(handle && { handle, url: profileUrl(handle) }),
|
|
81
|
+
...(name && { name }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The recipients of one message. Handles come from the URL list; names are
|
|
86
|
+
* attached only when the two lists line up one-for-one (see the class note).
|
|
87
|
+
*/
|
|
88
|
+
function parties(rawNames, rawUrls) {
|
|
89
|
+
const names = splitList(rawNames).map(name => memberName(name));
|
|
90
|
+
const handles = splitList(rawUrls).flatMap(url => profileHandle(url) ?? []);
|
|
91
|
+
if (handles.length === 0) {
|
|
92
|
+
return names.flatMap(name => (name ? [{ name }] : []));
|
|
93
|
+
}
|
|
94
|
+
const aligned = names.length === handles.length;
|
|
95
|
+
return handles.map((handle, index) => ({
|
|
96
|
+
handle,
|
|
97
|
+
url: profileUrl(handle),
|
|
98
|
+
...(aligned && names[index] && { name: names[index] }),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
function splitList(raw) {
|
|
102
|
+
return (raw ?? '')
|
|
103
|
+
.split(',')
|
|
104
|
+
.map(part => part.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* `Positions.csv` — the work history, newest-first.
|
|
6
|
+
*
|
|
7
|
+
* `snapshot`, even though every row carries real dates. The dates ride the
|
|
8
|
+
* boundary actions and stay exactly where LinkedIn put them; what re-reads is
|
|
9
|
+
* the mutable part — a title, a description someone rewrote — and that belongs
|
|
10
|
+
* at the moment it was seen.
|
|
11
|
+
*/
|
|
12
|
+
export declare class LinkedInArchivePositionsExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchivePositionsExtractor> {
|
|
13
|
+
static description: string;
|
|
14
|
+
static recordTypes: string[];
|
|
15
|
+
static temporality: "snapshot";
|
|
16
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
17
|
+
static schema: import("zod").ZodObject<{
|
|
18
|
+
since: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
19
|
+
until: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
20
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
21
|
+
} & {
|
|
22
|
+
input: import("zod").ZodString;
|
|
23
|
+
'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
|
|
24
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
25
|
+
input: string;
|
|
26
|
+
'profile-url'?: string | undefined;
|
|
27
|
+
since?: Date | undefined;
|
|
28
|
+
until?: Date | undefined;
|
|
29
|
+
limit?: number | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
input: string;
|
|
32
|
+
'profile-url'?: string | undefined;
|
|
33
|
+
since?: Date | undefined;
|
|
34
|
+
until?: Date | undefined;
|
|
35
|
+
limit?: number | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
extract(): AsyncGenerator<Record>;
|
|
38
|
+
determineCount(): Promise<number>;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=LinkedInArchivePositionsExtractor.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parseCoarseDate } from '../fields.js';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* `Positions.csv` — the work history, newest-first.
|
|
6
|
+
*
|
|
7
|
+
* `snapshot`, even though every row carries real dates. The dates ride the
|
|
8
|
+
* boundary actions and stay exactly where LinkedIn put them; what re-reads is
|
|
9
|
+
* the mutable part — a title, a description someone rewrote — and that belongs
|
|
10
|
+
* at the moment it was seen.
|
|
11
|
+
*/
|
|
12
|
+
export class LinkedInArchivePositionsExtractor extends LinkedInArchiveExtractor {
|
|
13
|
+
static description = 'Work history';
|
|
14
|
+
static recordTypes = ['positions'];
|
|
15
|
+
static temporality = 'snapshot';
|
|
16
|
+
static defaultTransformer = LinkedInTransformer;
|
|
17
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
18
|
+
async *extract() {
|
|
19
|
+
const rows = await this.readCsv('Positions.csv');
|
|
20
|
+
const positions = rows
|
|
21
|
+
.map(row => ({
|
|
22
|
+
company: row['Company Name']?.trim(),
|
|
23
|
+
title: row.Title?.trim(),
|
|
24
|
+
description: row.Description?.trim(),
|
|
25
|
+
location: row.Location?.trim(),
|
|
26
|
+
startedOn: parseCoarseDate(row['Started On']),
|
|
27
|
+
finishedOn: parseCoarseDate(row['Finished On']),
|
|
28
|
+
}))
|
|
29
|
+
.filter(position => position.company && position.startedOn)
|
|
30
|
+
.sort((a, b) => (a.startedOn < b.startedOn ? 1 : -1));
|
|
31
|
+
let count = 0;
|
|
32
|
+
for (const position of positions) {
|
|
33
|
+
if (this.shouldStopExtracting(count))
|
|
34
|
+
break;
|
|
35
|
+
if (!this.withinWindow(position.startedOn))
|
|
36
|
+
continue;
|
|
37
|
+
yield this.createRecordWithArchiveContext({
|
|
38
|
+
...position,
|
|
39
|
+
occurredAt: position.startedOn,
|
|
40
|
+
});
|
|
41
|
+
count++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async determineCount() {
|
|
45
|
+
return (await this.readCsv('Positions.csv')).length;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* The member themself: name, headline, location, and the primary email — one
|
|
6
|
+
* record, assembled from `Profile.csv` and `Email Addresses.csv` by the base
|
|
7
|
+
* class's account read.
|
|
8
|
+
*
|
|
9
|
+
* A re-read of current state, so `snapshot`: a headline changes, and the change
|
|
10
|
+
* belongs at the moment we saw it rather than back-dated to whenever the
|
|
11
|
+
* account was created.
|
|
12
|
+
*/
|
|
13
|
+
export declare class LinkedInArchiveProfileExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveProfileExtractor> {
|
|
14
|
+
static description: string;
|
|
15
|
+
static recordTypes: string[];
|
|
16
|
+
static temporality: "snapshot";
|
|
17
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
18
|
+
static schema: import("zod").ZodObject<{
|
|
19
|
+
since: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
20
|
+
until: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
21
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
22
|
+
} & {
|
|
23
|
+
input: import("zod").ZodString;
|
|
24
|
+
'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
|
|
25
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
26
|
+
input: string;
|
|
27
|
+
'profile-url'?: string | undefined;
|
|
28
|
+
since?: Date | undefined;
|
|
29
|
+
until?: Date | undefined;
|
|
30
|
+
limit?: number | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
input: string;
|
|
33
|
+
'profile-url'?: string | undefined;
|
|
34
|
+
since?: Date | undefined;
|
|
35
|
+
until?: Date | undefined;
|
|
36
|
+
limit?: number | undefined;
|
|
37
|
+
}>;
|
|
38
|
+
extract(): AsyncGenerator<Record>;
|
|
39
|
+
determineCount(): Promise<number>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=LinkedInArchiveProfileExtractor.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
2
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
3
|
+
/**
|
|
4
|
+
* The member themself: name, headline, location, and the primary email — one
|
|
5
|
+
* record, assembled from `Profile.csv` and `Email Addresses.csv` by the base
|
|
6
|
+
* class's account read.
|
|
7
|
+
*
|
|
8
|
+
* A re-read of current state, so `snapshot`: a headline changes, and the change
|
|
9
|
+
* belongs at the moment we saw it rather than back-dated to whenever the
|
|
10
|
+
* account was created.
|
|
11
|
+
*/
|
|
12
|
+
export class LinkedInArchiveProfileExtractor extends LinkedInArchiveExtractor {
|
|
13
|
+
static description = 'Profile: name, headline, location, email';
|
|
14
|
+
static recordTypes = ['profile'];
|
|
15
|
+
static temporality = 'snapshot';
|
|
16
|
+
static defaultTransformer = LinkedInTransformer;
|
|
17
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
18
|
+
async *extract() {
|
|
19
|
+
const account = this.accountInfo;
|
|
20
|
+
if (!account.name && !account.handle && !account.email)
|
|
21
|
+
return;
|
|
22
|
+
yield this.createRecordWithArchiveContext({ ...account });
|
|
23
|
+
}
|
|
24
|
+
async determineCount() {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MergingExtractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveMessagesExtractor } from './LinkedInArchiveMessagesExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* The whole export in one reverse-chronological pass — what a bare
|
|
6
|
+
* `extract linkedin -i <dir>` means.
|
|
7
|
+
*
|
|
8
|
+
* Every file is included rather than a curated subset: a LinkedIn export is
|
|
9
|
+
* small enough to read end to end, and each of its files is the only copy of
|
|
10
|
+
* what it holds. The profile record has no date and so lands last; everything
|
|
11
|
+
* else interleaves by when it actually happened.
|
|
12
|
+
*/
|
|
13
|
+
export declare class LinkedInDefaultExtractor extends MergingExtractor<typeof LinkedInDefaultExtractor> {
|
|
14
|
+
static source: string;
|
|
15
|
+
static description: string;
|
|
16
|
+
static delivery: "export";
|
|
17
|
+
static strategy: string;
|
|
18
|
+
static recordTypes: string[];
|
|
19
|
+
static default: boolean;
|
|
20
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
21
|
+
static children: (typeof LinkedInArchiveMessagesExtractor)[];
|
|
22
|
+
static schema: any;
|
|
23
|
+
/**
|
|
24
|
+
* Every child dates its records the same way, on `occurredAt`. A record with
|
|
25
|
+
* no date at all (the profile) sorts to the end rather than to 1970 by
|
|
26
|
+
* accident — the merge is newest-first, and undated is not "oldest".
|
|
27
|
+
*/
|
|
28
|
+
protected sortKey(record: Record): number;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=LinkedInDefaultExtractor.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MergingExtractor } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
4
|
+
import { LinkedInArchiveConnectionsExtractor } from './LinkedInArchiveConnectionsExtractor.js';
|
|
5
|
+
import { LinkedInArchiveEducationExtractor } from './LinkedInArchiveEducationExtractor.js';
|
|
6
|
+
import { LinkedInArchiveEndorsementsExtractor } from './LinkedInArchiveEndorsementsExtractor.js';
|
|
7
|
+
import { LinkedInArchiveFollowsExtractor } from './LinkedInArchiveFollowsExtractor.js';
|
|
8
|
+
import { LinkedInArchiveLearningExtractor } from './LinkedInArchiveLearningExtractor.js';
|
|
9
|
+
import { LinkedInArchiveMessagesExtractor } from './LinkedInArchiveMessagesExtractor.js';
|
|
10
|
+
import { LinkedInArchivePositionsExtractor } from './LinkedInArchivePositionsExtractor.js';
|
|
11
|
+
import { LinkedInArchiveProfileExtractor } from './LinkedInArchiveProfileExtractor.js';
|
|
12
|
+
/**
|
|
13
|
+
* The whole export in one reverse-chronological pass — what a bare
|
|
14
|
+
* `extract linkedin -i <dir>` means.
|
|
15
|
+
*
|
|
16
|
+
* Every file is included rather than a curated subset: a LinkedIn export is
|
|
17
|
+
* small enough to read end to end, and each of its files is the only copy of
|
|
18
|
+
* what it holds. The profile record has no date and so lands last; everything
|
|
19
|
+
* else interleaves by when it actually happened.
|
|
20
|
+
*/
|
|
21
|
+
export class LinkedInDefaultExtractor extends MergingExtractor {
|
|
22
|
+
static source = 'linkedin';
|
|
23
|
+
static description = 'Everything in the export, newest first';
|
|
24
|
+
static delivery = 'export';
|
|
25
|
+
static strategy = 'archive';
|
|
26
|
+
static recordTypes = [
|
|
27
|
+
'messages',
|
|
28
|
+
'connections',
|
|
29
|
+
'endorsements',
|
|
30
|
+
'follows',
|
|
31
|
+
'positions',
|
|
32
|
+
'education',
|
|
33
|
+
'learning',
|
|
34
|
+
'profile',
|
|
35
|
+
];
|
|
36
|
+
static default = true;
|
|
37
|
+
static defaultTransformer = LinkedInTransformer;
|
|
38
|
+
static children = [
|
|
39
|
+
LinkedInArchiveMessagesExtractor,
|
|
40
|
+
LinkedInArchiveConnectionsExtractor,
|
|
41
|
+
LinkedInArchiveEndorsementsExtractor,
|
|
42
|
+
LinkedInArchiveFollowsExtractor,
|
|
43
|
+
LinkedInArchivePositionsExtractor,
|
|
44
|
+
LinkedInArchiveEducationExtractor,
|
|
45
|
+
LinkedInArchiveLearningExtractor,
|
|
46
|
+
LinkedInArchiveProfileExtractor,
|
|
47
|
+
];
|
|
48
|
+
// The union of the children's flags, so the runner forwards them to the merge
|
|
49
|
+
// and the merge forwards the raw config to each child.
|
|
50
|
+
static schema = MergingExtractor.schema.extend({
|
|
51
|
+
input: z.string().describe('Path to the unpacked LinkedIn export directory'),
|
|
52
|
+
'profile-url': z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Your LinkedIn profile URL (https://www.linkedin.com/in/…). Only needed when the export carries no invitation to infer it from.'),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Every child dates its records the same way, on `occurredAt`. A record with
|
|
59
|
+
* no date at all (the profile) sorts to the end rather than to 1970 by
|
|
60
|
+
* accident — the merge is newest-first, and undated is not "oldest".
|
|
61
|
+
*/
|
|
62
|
+
sortKey(record) {
|
|
63
|
+
const { occurredAt } = record.data;
|
|
64
|
+
if (!occurredAt)
|
|
65
|
+
return Number.NEGATIVE_INFINITY;
|
|
66
|
+
const parsed = Date.parse(occurredAt);
|
|
67
|
+
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field readers shared by the extractors: the identity hiding in a LinkedIn
|
|
3
|
+
* profile URL, and the seven date formats one export manages to use.
|
|
4
|
+
*
|
|
5
|
+
* Everything here returns what LinkedIn actually wrote, at the precision it
|
|
6
|
+
* wrote it. Nothing invents a value it was not given — a date the source knows
|
|
7
|
+
* only to the month stays a month, and an unparseable one comes back undefined
|
|
8
|
+
* rather than as a guess.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The vanity slug out of a member profile URL — LinkedIn's own public handle
|
|
12
|
+
* for a person, and the only identifier the CSVs carry for anyone but you.
|
|
13
|
+
*
|
|
14
|
+
* The export writes the URL three ways: with a scheme (`Connections.csv`),
|
|
15
|
+
* without one (`Endorsement_*.csv`), and with tracking params glued on when the
|
|
16
|
+
* member pasted it out of the mobile app. All three name the same slug.
|
|
17
|
+
*
|
|
18
|
+
* **The slug is not stable.** A member can change their vanity URL, and when
|
|
19
|
+
* they do a later export calls them someone new. It is still the best
|
|
20
|
+
* identifier LinkedIn gives us — the numeric member id never appears in an
|
|
21
|
+
* export — so it is what we key on, with the weakness recorded here.
|
|
22
|
+
*/
|
|
23
|
+
export declare function profileHandle(rawUrl: string | undefined): string | undefined;
|
|
24
|
+
/** The canonical public profile URL for a slug, however the export spelled it. */
|
|
25
|
+
export declare function profileUrl(handle: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* A display name, or nothing when the export withheld one.
|
|
28
|
+
*
|
|
29
|
+
* The private-member placeholder is treated as absent rather than as a name.
|
|
30
|
+
* Keying people on it would collapse every stranger LinkedIn hid from you into
|
|
31
|
+
* a single person who does not exist.
|
|
32
|
+
*/
|
|
33
|
+
export declare function memberName(raw: string | undefined): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* `2026-08-05 19:29:58 UTC` (messages) and `2020-12-29 14:24 UTC` (learning) —
|
|
36
|
+
* an ISO-ish stamp with a trailing zone word instead of an offset.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseSpaceStamp(raw: string | undefined): string | undefined;
|
|
39
|
+
/** `2017/05/07 01:01:57 UTC` — the endorsement files' slash-dated stamp. */
|
|
40
|
+
export declare function parseSlashStamp(raw: string | undefined): string | undefined;
|
|
41
|
+
/** `Fri Jan 24 02:54:41 UTC 2025` — the follow files' `Date.toString()` shape. */
|
|
42
|
+
export declare function parseLongStamp(raw: string | undefined): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* `06 Jun 2026` — a connection date. LinkedIn records the day, not the moment,
|
|
45
|
+
* so this stays a day-precision civil date rather than being widened to
|
|
46
|
+
* midnight UTC.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseDayDate(raw: string | undefined): string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* `Aug 2022`, or a bare `2009` — a position or education boundary. LinkedIn
|
|
51
|
+
* only ever asks for the month, and for older entries only the year, so the
|
|
52
|
+
* result carries that precision: `2022-08` and `2009`. The store parses each at
|
|
53
|
+
* its own precision, which is why widening them to a day would be a lie.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseCoarseDate(raw: string | undefined): string | undefined;
|
|
56
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field readers shared by the extractors: the identity hiding in a LinkedIn
|
|
3
|
+
* profile URL, and the seven date formats one export manages to use.
|
|
4
|
+
*
|
|
5
|
+
* Everything here returns what LinkedIn actually wrote, at the precision it
|
|
6
|
+
* wrote it. Nothing invents a value it was not given — a date the source knows
|
|
7
|
+
* only to the month stays a month, and an unparseable one comes back undefined
|
|
8
|
+
* rather than as a guess.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The vanity slug out of a member profile URL — LinkedIn's own public handle
|
|
12
|
+
* for a person, and the only identifier the CSVs carry for anyone but you.
|
|
13
|
+
*
|
|
14
|
+
* The export writes the URL three ways: with a scheme (`Connections.csv`),
|
|
15
|
+
* without one (`Endorsement_*.csv`), and with tracking params glued on when the
|
|
16
|
+
* member pasted it out of the mobile app. All three name the same slug.
|
|
17
|
+
*
|
|
18
|
+
* **The slug is not stable.** A member can change their vanity URL, and when
|
|
19
|
+
* they do a later export calls them someone new. It is still the best
|
|
20
|
+
* identifier LinkedIn gives us — the numeric member id never appears in an
|
|
21
|
+
* export — so it is what we key on, with the weakness recorded here.
|
|
22
|
+
*/
|
|
23
|
+
export function profileHandle(rawUrl) {
|
|
24
|
+
const url = rawUrl?.trim();
|
|
25
|
+
if (!url)
|
|
26
|
+
return undefined;
|
|
27
|
+
const match = /\/in\/([^/?#\s]+)/.exec(url);
|
|
28
|
+
const slug = match?.[1]?.trim();
|
|
29
|
+
return slug ? decodeURIComponent(slug) : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The canonical public profile URL for a slug, however the export spelled it. */
|
|
32
|
+
export function profileUrl(handle) {
|
|
33
|
+
return `https://www.linkedin.com/in/${handle}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What LinkedIn prints instead of a name for a member you are not allowed to
|
|
37
|
+
* see. It appears across the export — as a message sender, as a recipient —
|
|
38
|
+
* and it is a placeholder, not a name.
|
|
39
|
+
*/
|
|
40
|
+
const PRIVATE_MEMBER = 'LinkedIn Member';
|
|
41
|
+
/**
|
|
42
|
+
* A display name, or nothing when the export withheld one.
|
|
43
|
+
*
|
|
44
|
+
* The private-member placeholder is treated as absent rather than as a name.
|
|
45
|
+
* Keying people on it would collapse every stranger LinkedIn hid from you into
|
|
46
|
+
* a single person who does not exist.
|
|
47
|
+
*/
|
|
48
|
+
export function memberName(raw) {
|
|
49
|
+
const name = raw?.trim();
|
|
50
|
+
return !name || name === PRIVATE_MEMBER ? undefined : name;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `2026-08-05 19:29:58 UTC` (messages) and `2020-12-29 14:24 UTC` (learning) —
|
|
54
|
+
* an ISO-ish stamp with a trailing zone word instead of an offset.
|
|
55
|
+
*/
|
|
56
|
+
export function parseSpaceStamp(raw) {
|
|
57
|
+
const value = raw?.trim();
|
|
58
|
+
if (!value || value === 'N/A')
|
|
59
|
+
return undefined;
|
|
60
|
+
const iso = value.replace(' UTC', 'Z').replace(' ', 'T');
|
|
61
|
+
return isoOrUndefined(iso);
|
|
62
|
+
}
|
|
63
|
+
/** `2017/05/07 01:01:57 UTC` — the endorsement files' slash-dated stamp. */
|
|
64
|
+
export function parseSlashStamp(raw) {
|
|
65
|
+
const value = raw?.trim();
|
|
66
|
+
if (!value)
|
|
67
|
+
return undefined;
|
|
68
|
+
const iso = value.replaceAll('/', '-').replace(' UTC', 'Z').replace(' ', 'T');
|
|
69
|
+
return isoOrUndefined(iso);
|
|
70
|
+
}
|
|
71
|
+
/** `Fri Jan 24 02:54:41 UTC 2025` — the follow files' `Date.toString()` shape. */
|
|
72
|
+
export function parseLongStamp(raw) {
|
|
73
|
+
return isoOrUndefined(raw?.trim());
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `06 Jun 2026` — a connection date. LinkedIn records the day, not the moment,
|
|
77
|
+
* so this stays a day-precision civil date rather than being widened to
|
|
78
|
+
* midnight UTC.
|
|
79
|
+
*/
|
|
80
|
+
export function parseDayDate(raw) {
|
|
81
|
+
const value = raw?.trim();
|
|
82
|
+
if (!value)
|
|
83
|
+
return undefined;
|
|
84
|
+
const match = /^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/.exec(value);
|
|
85
|
+
if (!match)
|
|
86
|
+
return undefined;
|
|
87
|
+
const month = MONTHS[match[2].slice(0, 3).toLowerCase()];
|
|
88
|
+
if (!month)
|
|
89
|
+
return undefined;
|
|
90
|
+
return `${match[3]}-${month}-${match[1].padStart(2, '0')}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `Aug 2022`, or a bare `2009` — a position or education boundary. LinkedIn
|
|
94
|
+
* only ever asks for the month, and for older entries only the year, so the
|
|
95
|
+
* result carries that precision: `2022-08` and `2009`. The store parses each at
|
|
96
|
+
* its own precision, which is why widening them to a day would be a lie.
|
|
97
|
+
*/
|
|
98
|
+
export function parseCoarseDate(raw) {
|
|
99
|
+
const value = raw?.trim();
|
|
100
|
+
if (!value)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (/^\d{4}$/.test(value))
|
|
103
|
+
return value;
|
|
104
|
+
const match = /^([A-Za-z]{3,})\s+(\d{4})$/.exec(value);
|
|
105
|
+
if (!match)
|
|
106
|
+
return undefined;
|
|
107
|
+
const month = MONTHS[match[1].slice(0, 3).toLowerCase()];
|
|
108
|
+
return month ? `${match[2]}-${month}` : undefined;
|
|
109
|
+
}
|
|
110
|
+
const MONTHS = {
|
|
111
|
+
jan: '01',
|
|
112
|
+
feb: '02',
|
|
113
|
+
mar: '03',
|
|
114
|
+
apr: '04',
|
|
115
|
+
may: '05',
|
|
116
|
+
jun: '06',
|
|
117
|
+
jul: '07',
|
|
118
|
+
aug: '08',
|
|
119
|
+
sep: '09',
|
|
120
|
+
oct: '10',
|
|
121
|
+
nov: '11',
|
|
122
|
+
dec: '12',
|
|
123
|
+
};
|
|
124
|
+
function isoOrUndefined(value) {
|
|
125
|
+
if (!value)
|
|
126
|
+
return undefined;
|
|
127
|
+
const date = new Date(value);
|
|
128
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
129
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { LinkedInArchiveExtractor } from './connectors/archive/LinkedInArchiveExtractor.js';
|
|
2
|
+
export { LinkedInArchiveConnectionsExtractor } from './connectors/archive/LinkedInArchiveConnectionsExtractor.js';
|
|
3
|
+
export { LinkedInArchiveEducationExtractor } from './connectors/archive/LinkedInArchiveEducationExtractor.js';
|
|
4
|
+
export { LinkedInArchiveEndorsementsExtractor } from './connectors/archive/LinkedInArchiveEndorsementsExtractor.js';
|
|
5
|
+
export { LinkedInArchiveFollowsExtractor } from './connectors/archive/LinkedInArchiveFollowsExtractor.js';
|
|
6
|
+
export { LinkedInArchiveLearningExtractor } from './connectors/archive/LinkedInArchiveLearningExtractor.js';
|
|
7
|
+
export { LinkedInArchiveMessagesExtractor } from './connectors/archive/LinkedInArchiveMessagesExtractor.js';
|
|
8
|
+
export { LinkedInArchivePositionsExtractor } from './connectors/archive/LinkedInArchivePositionsExtractor.js';
|
|
9
|
+
export { LinkedInArchiveProfileExtractor } from './connectors/archive/LinkedInArchiveProfileExtractor.js';
|
|
10
|
+
export { LinkedInDefaultExtractor } from './connectors/archive/LinkedInDefaultExtractor.js';
|
|
11
|
+
export { default as LinkedInTransformer } from './connectors/LinkedInTransformer.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { LinkedInArchiveExtractor } from './connectors/archive/LinkedInArchiveExtractor.js';
|
|
2
|
+
export { LinkedInArchiveConnectionsExtractor } from './connectors/archive/LinkedInArchiveConnectionsExtractor.js';
|
|
3
|
+
export { LinkedInArchiveEducationExtractor } from './connectors/archive/LinkedInArchiveEducationExtractor.js';
|
|
4
|
+
export { LinkedInArchiveEndorsementsExtractor } from './connectors/archive/LinkedInArchiveEndorsementsExtractor.js';
|
|
5
|
+
export { LinkedInArchiveFollowsExtractor } from './connectors/archive/LinkedInArchiveFollowsExtractor.js';
|
|
6
|
+
export { LinkedInArchiveLearningExtractor } from './connectors/archive/LinkedInArchiveLearningExtractor.js';
|
|
7
|
+
export { LinkedInArchiveMessagesExtractor } from './connectors/archive/LinkedInArchiveMessagesExtractor.js';
|
|
8
|
+
export { LinkedInArchivePositionsExtractor } from './connectors/archive/LinkedInArchivePositionsExtractor.js';
|
|
9
|
+
export { LinkedInArchiveProfileExtractor } from './connectors/archive/LinkedInArchiveProfileExtractor.js';
|
|
10
|
+
export { LinkedInDefaultExtractor } from './connectors/archive/LinkedInDefaultExtractor.js';
|
|
11
|
+
export { default as LinkedInTransformer } from './connectors/LinkedInTransformer.js';
|