@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,46 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Skill endorsements, both directions, merged newest-first:
|
|
6
|
+
* `Endorsement_Given_Info.csv` and `Endorsement_Received_Info.csv`.
|
|
7
|
+
*
|
|
8
|
+
* `direction` records which file a row came from, which is the only thing that
|
|
9
|
+
* says who vouched for whom — the two files are otherwise identical in shape.
|
|
10
|
+
*
|
|
11
|
+
* A row LinkedIn has not marked `ACCEPTED` still describes an endorsement that
|
|
12
|
+
* happened, so it is extracted like any other. The status itself is a moderation
|
|
13
|
+
* state rather than history — it can flip long after the act — and the
|
|
14
|
+
* transformer leaves it off the payload instead of dating today's value to the
|
|
15
|
+
* day of the endorsement.
|
|
16
|
+
*/
|
|
17
|
+
export declare class LinkedInArchiveEndorsementsExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveEndorsementsExtractor> {
|
|
18
|
+
static description: string;
|
|
19
|
+
static recordTypes: string[];
|
|
20
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
21
|
+
static schema: import("zod").ZodObject<{
|
|
22
|
+
since: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
23
|
+
until: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
24
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
25
|
+
} & {
|
|
26
|
+
input: import("zod").ZodString;
|
|
27
|
+
'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
|
|
28
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
29
|
+
input: string;
|
|
30
|
+
'profile-url'?: string | undefined;
|
|
31
|
+
since?: Date | undefined;
|
|
32
|
+
until?: Date | undefined;
|
|
33
|
+
limit?: number | undefined;
|
|
34
|
+
}, {
|
|
35
|
+
input: string;
|
|
36
|
+
'profile-url'?: string | undefined;
|
|
37
|
+
since?: Date | undefined;
|
|
38
|
+
until?: Date | undefined;
|
|
39
|
+
limit?: number | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
extract(): AsyncGenerator<Record>;
|
|
42
|
+
/** One endorsement file, with the other party's columns read by prefix. */
|
|
43
|
+
private readEndorsements;
|
|
44
|
+
determineCount(): Promise<number>;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=LinkedInArchiveEndorsementsExtractor.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { memberName, parseSlashStamp, profileHandle, profileUrl } from '../fields.js';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Skill endorsements, both directions, merged newest-first:
|
|
6
|
+
* `Endorsement_Given_Info.csv` and `Endorsement_Received_Info.csv`.
|
|
7
|
+
*
|
|
8
|
+
* `direction` records which file a row came from, which is the only thing that
|
|
9
|
+
* says who vouched for whom — the two files are otherwise identical in shape.
|
|
10
|
+
*
|
|
11
|
+
* A row LinkedIn has not marked `ACCEPTED` still describes an endorsement that
|
|
12
|
+
* happened, so it is extracted like any other. The status itself is a moderation
|
|
13
|
+
* state rather than history — it can flip long after the act — and the
|
|
14
|
+
* transformer leaves it off the payload instead of dating today's value to the
|
|
15
|
+
* day of the endorsement.
|
|
16
|
+
*/
|
|
17
|
+
export class LinkedInArchiveEndorsementsExtractor extends LinkedInArchiveExtractor {
|
|
18
|
+
static description = 'Skill endorsements given and received';
|
|
19
|
+
static recordTypes = ['endorsements'];
|
|
20
|
+
static defaultTransformer = LinkedInTransformer;
|
|
21
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
22
|
+
async *extract() {
|
|
23
|
+
const given = await this.readEndorsements('Endorsement_Given_Info.csv', 'given');
|
|
24
|
+
const received = await this.readEndorsements('Endorsement_Received_Info.csv', 'received');
|
|
25
|
+
const endorsements = [...given, ...received].sort((a, b) => (a.endorsedAt ?? '') < (b.endorsedAt ?? '') ? 1 : -1);
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const endorsement of endorsements) {
|
|
28
|
+
if (this.shouldStopExtracting(count))
|
|
29
|
+
break;
|
|
30
|
+
if (!this.withinWindow(endorsement.endorsedAt))
|
|
31
|
+
continue;
|
|
32
|
+
yield this.createRecordWithArchiveContext({
|
|
33
|
+
...endorsement,
|
|
34
|
+
occurredAt: endorsement.endorsedAt,
|
|
35
|
+
});
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** One endorsement file, with the other party's columns read by prefix. */
|
|
40
|
+
async readEndorsements(fileName, direction) {
|
|
41
|
+
const prefix = direction === 'given' ? 'Endorsee' : 'Endorser';
|
|
42
|
+
const rows = await this.readCsv(fileName);
|
|
43
|
+
return rows
|
|
44
|
+
.map(row => {
|
|
45
|
+
const handle = profileHandle(row[`${prefix} Public Url`]);
|
|
46
|
+
const name = memberName([row[`${prefix} First Name`], row[`${prefix} Last Name`]]
|
|
47
|
+
.map(part => part?.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' '));
|
|
50
|
+
return {
|
|
51
|
+
direction,
|
|
52
|
+
skill: row['Skill Name']?.trim(),
|
|
53
|
+
status: row['Endorsement Status']?.trim() || undefined,
|
|
54
|
+
otherHandle: handle,
|
|
55
|
+
otherName: name || undefined,
|
|
56
|
+
otherUrl: handle ? profileUrl(handle) : undefined,
|
|
57
|
+
endorsedAt: parseSlashStamp(row['Endorsement Date']),
|
|
58
|
+
};
|
|
59
|
+
})
|
|
60
|
+
.filter(row => row.skill && (row.otherHandle || row.otherName));
|
|
61
|
+
}
|
|
62
|
+
async determineCount() {
|
|
63
|
+
const given = await this.readCsv('Endorsement_Given_Info.csv');
|
|
64
|
+
const received = await this.readCsv('Endorsement_Received_Info.csv');
|
|
65
|
+
return given.length + received.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ArchiveExtractor } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** What one export knows about the member who requested it. */
|
|
4
|
+
export interface LinkedInAccount {
|
|
5
|
+
/** The vanity slug, when the export names it. */
|
|
6
|
+
handle?: string;
|
|
7
|
+
/** `First Last` from Profile.csv, when it has one. */
|
|
8
|
+
name?: string;
|
|
9
|
+
/** The address flagged `Primary` in Email Addresses.csv. */
|
|
10
|
+
email?: string;
|
|
11
|
+
headline?: string;
|
|
12
|
+
location?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Base for the LinkedIn member data export — the ZIP that Settings & Privacy →
|
|
16
|
+
* Data Privacy → "Get a copy of your data" produces, unpacked to a directory of
|
|
17
|
+
* CSVs.
|
|
18
|
+
*
|
|
19
|
+
* Shared here: reading a named CSV out of the export, the account info every
|
|
20
|
+
* record carries, and the date window. One subclass per file, because the files
|
|
21
|
+
* have nothing in common but their extension.
|
|
22
|
+
*
|
|
23
|
+
* **Two archives, and this reads either.** The *fast* archive arrives in
|
|
24
|
+
* minutes with your profile, network, and messages. The *full* archive takes
|
|
25
|
+
* up to a day and adds your activity — `Shares.csv`, `Comments.csv`,
|
|
26
|
+
* `Reactions.csv`. Every extractor here treats a missing file as an empty one,
|
|
27
|
+
* so a fast archive simply yields fewer record types than a full one.
|
|
28
|
+
*/
|
|
29
|
+
export declare abstract class LinkedInArchiveExtractor<T extends typeof LinkedInArchiveExtractor = typeof LinkedInArchiveExtractor> extends ArchiveExtractor<T> {
|
|
30
|
+
static source: string;
|
|
31
|
+
static strategy: string;
|
|
32
|
+
static schema: z.ZodObject<{
|
|
33
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
34
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
35
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
} & {
|
|
37
|
+
input: z.ZodString;
|
|
38
|
+
'profile-url': z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, "strip", z.ZodTypeAny, {
|
|
40
|
+
input: string;
|
|
41
|
+
'profile-url'?: string | undefined;
|
|
42
|
+
since?: Date | undefined;
|
|
43
|
+
until?: Date | undefined;
|
|
44
|
+
limit?: number | undefined;
|
|
45
|
+
}, {
|
|
46
|
+
input: string;
|
|
47
|
+
'profile-url'?: string | undefined;
|
|
48
|
+
since?: Date | undefined;
|
|
49
|
+
until?: Date | undefined;
|
|
50
|
+
limit?: number | undefined;
|
|
51
|
+
}>;
|
|
52
|
+
protected accountInfo: LinkedInAccount;
|
|
53
|
+
/** Lower-cased file name → real file name, so a renamed case still resolves. */
|
|
54
|
+
private fileIndex?;
|
|
55
|
+
/** When LinkedIn wrote this export — see {@link asOfTime}. */
|
|
56
|
+
private compiledAt?;
|
|
57
|
+
/**
|
|
58
|
+
* Read the export's own facts before extraction: who it belongs to, and when
|
|
59
|
+
* LinkedIn built it. The base class defines `loadAccountInfo` but never calls
|
|
60
|
+
* it — each archive plugin decides when its export is readable.
|
|
61
|
+
*/
|
|
62
|
+
setup(): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* The "as of" time for the snapshot records: when LinkedIn compiled the
|
|
65
|
+
* export, taken as the newest file time in the directory.
|
|
66
|
+
*
|
|
67
|
+
* Not the moment of the import, which is the default. An export is a frozen
|
|
68
|
+
* artifact — its profile was true when LinkedIn wrote it, not when you got
|
|
69
|
+
* around to reading it — and dating the sighting to the artifact is also what
|
|
70
|
+
* makes a second import of the same directory a byte-identical no-op instead
|
|
71
|
+
* of a fresh sighting of unchanged facts.
|
|
72
|
+
*/
|
|
73
|
+
protected asOfTime(): Date;
|
|
74
|
+
/** The newest mtime among the export's files, or none if it has none. */
|
|
75
|
+
private newestFileTime;
|
|
76
|
+
/**
|
|
77
|
+
* The export's own account facts, read once per run: the profile, the primary
|
|
78
|
+
* email, and — the interesting one — the member's own profile URL.
|
|
79
|
+
*
|
|
80
|
+
* **The profile URL is not in Profile.csv.** LinkedIn omits it. It appears
|
|
81
|
+
* only on the other side of an edge: `Invitations.csv` carries both parties'
|
|
82
|
+
* URLs *and* a `Direction` column, so an `OUTGOING` row states outright that
|
|
83
|
+
* the inviter is you. That is the source declaring it, not us guessing from a
|
|
84
|
+
* name match — and a name match is exactly the guess to avoid, because two
|
|
85
|
+
* members share a name often enough to matter.
|
|
86
|
+
*
|
|
87
|
+
* With no invitations to read, the `profile-url` config supplies it, and
|
|
88
|
+
* failing that the self falls back to `selfAgent`'s per-source singleton. All
|
|
89
|
+
* three routes still merge onto the one person through `@me`.
|
|
90
|
+
*/
|
|
91
|
+
protected loadAccountInfo(): Promise<LinkedInAccount>;
|
|
92
|
+
/**
|
|
93
|
+
* The member's own slug, taken from whichever side of an invitation LinkedIn
|
|
94
|
+
* labelled as theirs. Undefined when the export has no invitations.
|
|
95
|
+
*/
|
|
96
|
+
private selfHandleFromInvitations;
|
|
97
|
+
/**
|
|
98
|
+
* Parse one CSV out of the export, as an array of row objects.
|
|
99
|
+
*
|
|
100
|
+
* A file the export did not include comes back empty rather than throwing —
|
|
101
|
+
* see the fast/full archive note on the class. Every value arrives trimmed,
|
|
102
|
+
* and `Connections.csv`'s three-line prose preamble is dropped: LinkedIn
|
|
103
|
+
* writes a `Notes:` paragraph above the header there and nowhere else.
|
|
104
|
+
*/
|
|
105
|
+
protected readCsv(fileName: string): Promise<Array<{
|
|
106
|
+
[column: string]: string | undefined;
|
|
107
|
+
}>>;
|
|
108
|
+
/** The real path for a file name, matched case-insensitively, or none. */
|
|
109
|
+
private resolveFile;
|
|
110
|
+
/** Every file in the export, listed once and indexed for case-insensitive lookup. */
|
|
111
|
+
private fileNames;
|
|
112
|
+
/**
|
|
113
|
+
* Whether a record's date clears the configured since/until window. A record
|
|
114
|
+
* LinkedIn dated only to the year or month is compared at the start of that
|
|
115
|
+
* span, and an undated one always passes — dropping it would lose a real
|
|
116
|
+
* record on the strength of a date the source never wrote.
|
|
117
|
+
*/
|
|
118
|
+
protected withinWindow(date: string | undefined): boolean;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=LinkedInArchiveExtractor.d.ts.map
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ArchiveExtractor, Extractor } from '@chronicle.app/etl';
|
|
2
|
+
import { parse } from 'csv-parse/sync';
|
|
3
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { profileHandle } from '../fields.js';
|
|
7
|
+
/**
|
|
8
|
+
* Base for the LinkedIn member data export — the ZIP that Settings & Privacy →
|
|
9
|
+
* Data Privacy → "Get a copy of your data" produces, unpacked to a directory of
|
|
10
|
+
* CSVs.
|
|
11
|
+
*
|
|
12
|
+
* Shared here: reading a named CSV out of the export, the account info every
|
|
13
|
+
* record carries, and the date window. One subclass per file, because the files
|
|
14
|
+
* have nothing in common but their extension.
|
|
15
|
+
*
|
|
16
|
+
* **Two archives, and this reads either.** The *fast* archive arrives in
|
|
17
|
+
* minutes with your profile, network, and messages. The *full* archive takes
|
|
18
|
+
* up to a day and adds your activity — `Shares.csv`, `Comments.csv`,
|
|
19
|
+
* `Reactions.csv`. Every extractor here treats a missing file as an empty one,
|
|
20
|
+
* so a fast archive simply yields fewer record types than a full one.
|
|
21
|
+
*/
|
|
22
|
+
export class LinkedInArchiveExtractor extends ArchiveExtractor {
|
|
23
|
+
static source = 'linkedin';
|
|
24
|
+
static strategy = 'archive';
|
|
25
|
+
static schema = Extractor.schema.extend({
|
|
26
|
+
input: z.string().describe('Path to the unpacked LinkedIn export directory'),
|
|
27
|
+
'profile-url': z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Your LinkedIn profile URL (https://www.linkedin.com/in/…). Only needed when the export carries no invitation to infer it from.'),
|
|
31
|
+
});
|
|
32
|
+
accountInfo = {};
|
|
33
|
+
/** Lower-cased file name → real file name, so a renamed case still resolves. */
|
|
34
|
+
fileIndex;
|
|
35
|
+
/** When LinkedIn wrote this export — see {@link asOfTime}. */
|
|
36
|
+
compiledAt;
|
|
37
|
+
/**
|
|
38
|
+
* Read the export's own facts before extraction: who it belongs to, and when
|
|
39
|
+
* LinkedIn built it. The base class defines `loadAccountInfo` but never calls
|
|
40
|
+
* it — each archive plugin decides when its export is readable.
|
|
41
|
+
*/
|
|
42
|
+
async setup() {
|
|
43
|
+
await super.setup();
|
|
44
|
+
this.compiledAt = await this.newestFileTime();
|
|
45
|
+
await this.loadAccountInfo();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The "as of" time for the snapshot records: when LinkedIn compiled the
|
|
49
|
+
* export, taken as the newest file time in the directory.
|
|
50
|
+
*
|
|
51
|
+
* Not the moment of the import, which is the default. An export is a frozen
|
|
52
|
+
* artifact — its profile was true when LinkedIn wrote it, not when you got
|
|
53
|
+
* around to reading it — and dating the sighting to the artifact is also what
|
|
54
|
+
* makes a second import of the same directory a byte-identical no-op instead
|
|
55
|
+
* of a fresh sighting of unchanged facts.
|
|
56
|
+
*/
|
|
57
|
+
asOfTime() {
|
|
58
|
+
return this.compiledAt ?? super.asOfTime();
|
|
59
|
+
}
|
|
60
|
+
/** The newest mtime among the export's files, or none if it has none. */
|
|
61
|
+
async newestFileTime() {
|
|
62
|
+
const { input } = this.config;
|
|
63
|
+
let newest;
|
|
64
|
+
for (const name of await this.fileNames()) {
|
|
65
|
+
try {
|
|
66
|
+
const { mtimeMs } = await stat(join(input, name));
|
|
67
|
+
if (newest === undefined || mtimeMs > newest)
|
|
68
|
+
newest = mtimeMs;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// A file that vanished between listing and stat tells us nothing.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return newest === undefined ? undefined : new Date(newest);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The export's own account facts, read once per run: the profile, the primary
|
|
78
|
+
* email, and — the interesting one — the member's own profile URL.
|
|
79
|
+
*
|
|
80
|
+
* **The profile URL is not in Profile.csv.** LinkedIn omits it. It appears
|
|
81
|
+
* only on the other side of an edge: `Invitations.csv` carries both parties'
|
|
82
|
+
* URLs *and* a `Direction` column, so an `OUTGOING` row states outright that
|
|
83
|
+
* the inviter is you. That is the source declaring it, not us guessing from a
|
|
84
|
+
* name match — and a name match is exactly the guess to avoid, because two
|
|
85
|
+
* members share a name often enough to matter.
|
|
86
|
+
*
|
|
87
|
+
* With no invitations to read, the `profile-url` config supplies it, and
|
|
88
|
+
* failing that the self falls back to `selfAgent`'s per-source singleton. All
|
|
89
|
+
* three routes still merge onto the one person through `@me`.
|
|
90
|
+
*/
|
|
91
|
+
async loadAccountInfo() {
|
|
92
|
+
const account = {};
|
|
93
|
+
const [profile] = await this.readCsv('Profile.csv');
|
|
94
|
+
if (profile) {
|
|
95
|
+
const name = [profile['First Name'], profile['Last Name']]
|
|
96
|
+
.map(part => part?.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join(' ');
|
|
99
|
+
if (name)
|
|
100
|
+
account.name = name;
|
|
101
|
+
if (profile.Headline?.trim())
|
|
102
|
+
account.headline = profile.Headline.trim();
|
|
103
|
+
if (profile['Geo Location']?.trim()) {
|
|
104
|
+
account.location = profile['Geo Location'].trim();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const emails = await this.readCsv('Email Addresses.csv');
|
|
108
|
+
const primary = emails.find(row => row.Primary?.trim() === 'Yes');
|
|
109
|
+
const email = primary?.['Email Address']?.trim();
|
|
110
|
+
if (email)
|
|
111
|
+
account.email = email;
|
|
112
|
+
const configured = profileHandle(this.config['profile-url']);
|
|
113
|
+
account.handle = configured ?? (await this.selfHandleFromInvitations());
|
|
114
|
+
this.accountInfo = account;
|
|
115
|
+
return account;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The member's own slug, taken from whichever side of an invitation LinkedIn
|
|
119
|
+
* labelled as theirs. Undefined when the export has no invitations.
|
|
120
|
+
*/
|
|
121
|
+
async selfHandleFromInvitations() {
|
|
122
|
+
const invitations = await this.readCsv('Invitations.csv');
|
|
123
|
+
for (const row of invitations) {
|
|
124
|
+
const direction = row.Direction?.trim().toUpperCase();
|
|
125
|
+
if (direction === 'OUTGOING') {
|
|
126
|
+
const handle = profileHandle(row.inviterProfileUrl);
|
|
127
|
+
if (handle)
|
|
128
|
+
return handle;
|
|
129
|
+
}
|
|
130
|
+
if (direction === 'INCOMING') {
|
|
131
|
+
const handle = profileHandle(row.inviteeProfileUrl);
|
|
132
|
+
if (handle)
|
|
133
|
+
return handle;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Parse one CSV out of the export, as an array of row objects.
|
|
140
|
+
*
|
|
141
|
+
* A file the export did not include comes back empty rather than throwing —
|
|
142
|
+
* see the fast/full archive note on the class. Every value arrives trimmed,
|
|
143
|
+
* and `Connections.csv`'s three-line prose preamble is dropped: LinkedIn
|
|
144
|
+
* writes a `Notes:` paragraph above the header there and nowhere else.
|
|
145
|
+
*/
|
|
146
|
+
async readCsv(fileName) {
|
|
147
|
+
const path = await this.resolveFile(fileName);
|
|
148
|
+
if (!path)
|
|
149
|
+
return [];
|
|
150
|
+
const text = stripPreamble(await readFile(path, 'utf8'));
|
|
151
|
+
if (!text.trim())
|
|
152
|
+
return [];
|
|
153
|
+
return parse(text, {
|
|
154
|
+
columns: (header) => header.map(column => column.trim()),
|
|
155
|
+
skip_empty_lines: true,
|
|
156
|
+
relax_column_count: true,
|
|
157
|
+
trim: true,
|
|
158
|
+
bom: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** The real path for a file name, matched case-insensitively, or none. */
|
|
162
|
+
async resolveFile(fileName) {
|
|
163
|
+
await this.fileNames();
|
|
164
|
+
const resolved = this.fileIndex.get(fileName.toLowerCase());
|
|
165
|
+
const { input } = this.config;
|
|
166
|
+
return resolved ? join(input, resolved) : undefined;
|
|
167
|
+
}
|
|
168
|
+
/** Every file in the export, listed once and indexed for case-insensitive lookup. */
|
|
169
|
+
async fileNames() {
|
|
170
|
+
if (!this.fileIndex) {
|
|
171
|
+
const { input } = this.config;
|
|
172
|
+
let entries = [];
|
|
173
|
+
try {
|
|
174
|
+
entries = await readdir(input);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new Error(`Could not read the LinkedIn export directory ${input}`);
|
|
178
|
+
}
|
|
179
|
+
this.fileIndex = new Map(entries.map(name => [name.toLowerCase(), name]));
|
|
180
|
+
}
|
|
181
|
+
return [...this.fileIndex.values()];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether a record's date clears the configured since/until window. A record
|
|
185
|
+
* LinkedIn dated only to the year or month is compared at the start of that
|
|
186
|
+
* span, and an undated one always passes — dropping it would lose a real
|
|
187
|
+
* record on the strength of a date the source never wrote.
|
|
188
|
+
*/
|
|
189
|
+
withinWindow(date) {
|
|
190
|
+
if (!date)
|
|
191
|
+
return true;
|
|
192
|
+
const parsed = new Date(date);
|
|
193
|
+
return Number.isNaN(parsed.getTime()) ? true : this.isWithinDateRange(parsed);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Drop the prose LinkedIn writes above the header of `Connections.csv` — a
|
|
198
|
+
* `Notes:` line, a quoted paragraph about missing email addresses, then a blank
|
|
199
|
+
* line. Detected by that literal opener rather than by counting lines, so a
|
|
200
|
+
* longer or shorter note still parses.
|
|
201
|
+
*/
|
|
202
|
+
function stripPreamble(text) {
|
|
203
|
+
if (!text.startsWith('Notes:'))
|
|
204
|
+
return text;
|
|
205
|
+
const blankLine = /\r?\n\s*\r?\n/.exec(text);
|
|
206
|
+
return blankLine ? text.slice(blankLine.index + blankLine[0].length) : text;
|
|
207
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* `Company Follows.csv` — the organizations and topic pages you follow, with
|
|
6
|
+
* the moment you followed each, newest-first.
|
|
7
|
+
*
|
|
8
|
+
* LinkedIn files a topic page ("Scrum", "Web Development") in the same list as
|
|
9
|
+
* a real employer, and gives neither an id — the organization's display name is
|
|
10
|
+
* all we get, so it is what the follow points at.
|
|
11
|
+
*/
|
|
12
|
+
export declare class LinkedInArchiveFollowsExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveFollowsExtractor> {
|
|
13
|
+
static description: string;
|
|
14
|
+
static recordTypes: string[];
|
|
15
|
+
static defaultTransformer: typeof LinkedInTransformer;
|
|
16
|
+
static schema: import("zod").ZodObject<{
|
|
17
|
+
since: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
18
|
+
until: import("zod").ZodOptional<import("zod").ZodDate>;
|
|
19
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
20
|
+
} & {
|
|
21
|
+
input: import("zod").ZodString;
|
|
22
|
+
'profile-url': import("zod").ZodOptional<import("zod").ZodString>;
|
|
23
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
24
|
+
input: string;
|
|
25
|
+
'profile-url'?: string | undefined;
|
|
26
|
+
since?: Date | undefined;
|
|
27
|
+
until?: Date | undefined;
|
|
28
|
+
limit?: number | undefined;
|
|
29
|
+
}, {
|
|
30
|
+
input: string;
|
|
31
|
+
'profile-url'?: string | undefined;
|
|
32
|
+
since?: Date | undefined;
|
|
33
|
+
until?: Date | undefined;
|
|
34
|
+
limit?: number | undefined;
|
|
35
|
+
}>;
|
|
36
|
+
extract(): AsyncGenerator<Record>;
|
|
37
|
+
determineCount(): Promise<number>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=LinkedInArchiveFollowsExtractor.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseLongStamp } from '../fields.js';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* `Company Follows.csv` — the organizations and topic pages you follow, with
|
|
6
|
+
* the moment you followed each, newest-first.
|
|
7
|
+
*
|
|
8
|
+
* LinkedIn files a topic page ("Scrum", "Web Development") in the same list as
|
|
9
|
+
* a real employer, and gives neither an id — the organization's display name is
|
|
10
|
+
* all we get, so it is what the follow points at.
|
|
11
|
+
*/
|
|
12
|
+
export class LinkedInArchiveFollowsExtractor extends LinkedInArchiveExtractor {
|
|
13
|
+
static description = 'Companies you follow';
|
|
14
|
+
static recordTypes = ['follows'];
|
|
15
|
+
static defaultTransformer = LinkedInTransformer;
|
|
16
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
17
|
+
async *extract() {
|
|
18
|
+
const rows = await this.readCsv('Company Follows.csv');
|
|
19
|
+
const follows = rows
|
|
20
|
+
.map(row => ({
|
|
21
|
+
organization: row.Organization?.trim(),
|
|
22
|
+
followedAt: parseLongStamp(row['Followed On']),
|
|
23
|
+
}))
|
|
24
|
+
.filter(follow => follow.organization)
|
|
25
|
+
.sort((a, b) => ((a.followedAt ?? '') < (b.followedAt ?? '') ? 1 : -1));
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const follow of follows) {
|
|
28
|
+
if (this.shouldStopExtracting(count))
|
|
29
|
+
break;
|
|
30
|
+
if (!this.withinWindow(follow.followedAt))
|
|
31
|
+
continue;
|
|
32
|
+
yield this.createRecordWithArchiveContext({
|
|
33
|
+
...follow,
|
|
34
|
+
occurredAt: follow.followedAt,
|
|
35
|
+
});
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async determineCount() {
|
|
40
|
+
return (await this.readCsv('Company Follows.csv')).length;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
+
* `Learning.csv` — LinkedIn Learning courses, newest-watched first.
|
|
6
|
+
*
|
|
7
|
+
* Stays `event` rather than `snapshot`. The row re-reports one moving value,
|
|
8
|
+
* the last time you watched, and each export pins that value to a real date —
|
|
9
|
+
* so successive exports accumulate the distinct sittings instead of one
|
|
10
|
+
* attribute overwriting itself.
|
|
11
|
+
*
|
|
12
|
+
* LinkedIn writes `N/A` where it means "never", which is not a date.
|
|
13
|
+
*/
|
|
14
|
+
export declare class LinkedInArchiveLearningExtractor extends LinkedInArchiveExtractor<typeof LinkedInArchiveLearningExtractor> {
|
|
15
|
+
static description: string;
|
|
16
|
+
static recordTypes: string[];
|
|
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=LinkedInArchiveLearningExtractor.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parseSpaceStamp } from '../fields.js';
|
|
2
|
+
import LinkedInTransformer from '../LinkedInTransformer.js';
|
|
3
|
+
import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* `Learning.csv` — LinkedIn Learning courses, newest-watched first.
|
|
6
|
+
*
|
|
7
|
+
* Stays `event` rather than `snapshot`. The row re-reports one moving value,
|
|
8
|
+
* the last time you watched, and each export pins that value to a real date —
|
|
9
|
+
* so successive exports accumulate the distinct sittings instead of one
|
|
10
|
+
* attribute overwriting itself.
|
|
11
|
+
*
|
|
12
|
+
* LinkedIn writes `N/A` where it means "never", which is not a date.
|
|
13
|
+
*/
|
|
14
|
+
export class LinkedInArchiveLearningExtractor extends LinkedInArchiveExtractor {
|
|
15
|
+
static description = 'LinkedIn Learning courses watched or saved';
|
|
16
|
+
static recordTypes = ['learning'];
|
|
17
|
+
static defaultTransformer = LinkedInTransformer;
|
|
18
|
+
static schema = LinkedInArchiveExtractor.schema;
|
|
19
|
+
async *extract() {
|
|
20
|
+
const rows = await this.readCsv('Learning.csv');
|
|
21
|
+
const courses = rows
|
|
22
|
+
.map(row => ({
|
|
23
|
+
title: row['Content Title']?.trim(),
|
|
24
|
+
description: row['Content Description']?.trim() || undefined,
|
|
25
|
+
contentType: row['Content Type']?.trim() || undefined,
|
|
26
|
+
watchedAt: parseSpaceStamp(row['Content Last Watched Date (if viewed)']),
|
|
27
|
+
completedAt: parseSpaceStamp(row['Content Completed At (if completed)']),
|
|
28
|
+
saved: row['Content Saved']?.trim().toLowerCase() === 'true',
|
|
29
|
+
}))
|
|
30
|
+
.filter(course => course.title)
|
|
31
|
+
.sort((a, b) => ((a.watchedAt ?? '') < (b.watchedAt ?? '') ? 1 : -1));
|
|
32
|
+
let count = 0;
|
|
33
|
+
for (const course of courses) {
|
|
34
|
+
if (this.shouldStopExtracting(count))
|
|
35
|
+
break;
|
|
36
|
+
const occurredAt = course.watchedAt ?? course.completedAt;
|
|
37
|
+
if (!this.withinWindow(occurredAt))
|
|
38
|
+
continue;
|
|
39
|
+
yield this.createRecordWithArchiveContext({ ...course, occurredAt });
|
|
40
|
+
count++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async determineCount() {
|
|
44
|
+
return (await this.readCsv('Learning.csv')).length;
|
|
45
|
+
}
|
|
46
|
+
}
|