@chronicle.app/linkedin 0.4.0 → 0.5.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 (25) hide show
  1. package/README.md +5 -4
  2. package/dist/connectors/LinkedInTransformer.js +3 -2
  3. package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.d.ts +5 -1
  4. package/dist/connectors/archive/LinkedInArchiveConnectionsExtractor.js +21 -12
  5. package/dist/connectors/archive/LinkedInArchiveEducationExtractor.d.ts +5 -1
  6. package/dist/connectors/archive/LinkedInArchiveEducationExtractor.js +21 -13
  7. package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.d.ts +5 -1
  8. package/dist/connectors/archive/LinkedInArchiveEndorsementsExtractor.js +12 -4
  9. package/dist/connectors/archive/LinkedInArchiveExtractor.d.ts +14 -6
  10. package/dist/connectors/archive/LinkedInArchiveExtractor.js +39 -33
  11. package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.d.ts +5 -1
  12. package/dist/connectors/archive/LinkedInArchiveFollowsExtractor.js +17 -9
  13. package/dist/connectors/archive/LinkedInArchiveLearningExtractor.d.ts +5 -1
  14. package/dist/connectors/archive/LinkedInArchiveLearningExtractor.js +23 -11
  15. package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.d.ts +5 -1
  16. package/dist/connectors/archive/LinkedInArchiveMessagesExtractor.js +28 -12
  17. package/dist/connectors/archive/LinkedInArchivePositionsExtractor.d.ts +5 -1
  18. package/dist/connectors/archive/LinkedInArchivePositionsExtractor.js +21 -13
  19. package/dist/connectors/archive/LinkedInArchiveProfileExtractor.d.ts +8 -1
  20. package/dist/connectors/archive/LinkedInArchiveProfileExtractor.js +16 -2
  21. package/dist/connectors/archive/LinkedInDefaultExtractor.d.ts +8 -1
  22. package/dist/connectors/archive/LinkedInDefaultExtractor.js +1 -1
  23. package/dist/connectors/fields.d.ts +8 -0
  24. package/dist/connectors/fields.js +17 -0
  25. package/package.json +12 -4
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # LinkedIn plugin
2
2
 
3
3
  Reads the **member data export** — the ZIP that Settings & Privacy → Data
4
- Privacy → "Get a copy of your data" produces, unpacked to a directory.
4
+ Privacy → "Get a copy of your data" produces, read in place or unpacked to a
5
+ directory.
5
6
 
6
7
  ```bash
7
- chronicle extract linkedin --input ~/Downloads/Basic_LinkedInDataExport_08-08-2026 --limit 0
8
+ chronicle extract linkedin --input ~/Downloads/Basic_LinkedInDataExport_08-08-2026.zip --limit 0
8
9
  chronicle extract linkedin --input <dir> --type messages --limit 5 # one record kind
9
10
  ```
10
11
 
@@ -114,8 +115,8 @@ made in 2010 would claim they worked there then. `Positions.csv` and
114
115
  `Education.csv` are snapshots for the same reason. Messages, endorsements,
115
116
  follows and learning are events.
116
117
 
117
- **Re-importing the same directory is a no-op.** The sighting time is the
118
- export's own file time, not the moment of import, so a second run restates
118
+ **Re-importing the same export is a no-op.** The sighting time is the
119
+ export's own file time, in a zip from its index, not the moment of import, so a second run restates
119
120
  identical assertions instead of minting fresh ones.
120
121
 
121
122
  ## Not read, and why
@@ -289,14 +289,15 @@ export default class LinkedInTransformer extends ChronicleTransformer {
289
289
  '@type',
290
290
  'source',
291
291
  'timestamp',
292
- 'object.isPartOf[*].sourceId',
292
+ 'result.isPartOf[*].sourceId',
293
293
  'agent.handle',
294
294
  'agent.name',
295
295
  ],
296
296
  source: SOURCE,
297
297
  timestamp: data.sentAt,
298
298
  ...(sender && { agent: sender }),
299
- object: message,
299
+ // Sending makes the message, so it's the action's result.
300
+ result: message,
300
301
  };
301
302
  return [sent];
302
303
  }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -43,6 +43,10 @@ export declare class LinkedInArchiveConnectionsExtractor extends LinkedInArchive
43
43
  /** The connection's profile URL — LinkedIn's own id for them, and stable per export. */
44
44
  keyOf(record: Record): string | null;
45
45
  extract(): AsyncGenerator<Record>;
46
+ /** The connections a run reads, and the day each was made. */
47
+ survey(options?: SurveyOptions): Promise<Survey>;
48
+ /** Every connection in the export that names someone, newest first. */
49
+ private connections;
46
50
  determineCount(): Promise<number>;
47
51
  }
48
52
  //# sourceMappingURL=LinkedInArchiveConnectionsExtractor.d.ts.map
@@ -26,6 +26,26 @@ export class LinkedInArchiveConnectionsExtractor extends LinkedInArchiveExtracto
26
26
  return record.data.url ?? null;
27
27
  }
28
28
  async *extract() {
29
+ let count = 0;
30
+ for (const connection of await this.connections()) {
31
+ if (this.shouldStopExtracting(count))
32
+ break;
33
+ if (!this.withinWindow(connection.connectedOn))
34
+ continue;
35
+ yield this.createRecordWithArchiveContext({
36
+ ...connection,
37
+ occurredAt: connection.connectedOn,
38
+ });
39
+ count++;
40
+ }
41
+ }
42
+ /** The connections a run reads, and the day each was made. */
43
+ async survey(options = {}) {
44
+ const dates = (await this.connections()).map(connection => connection.connectedOn);
45
+ return { connections: this.surveyDates(dates, options) };
46
+ }
47
+ /** Every connection in the export that names someone, newest first. */
48
+ async connections() {
29
49
  const rows = await this.readCsv('Connections.csv');
30
50
  const connections = rows
31
51
  .map(row => {
@@ -47,18 +67,7 @@ export class LinkedInArchiveConnectionsExtractor extends LinkedInArchiveExtracto
47
67
  // A row with neither a profile URL nor a name names nobody.
48
68
  .filter(connection => connection.handle || connection.name)
49
69
  .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
- }
70
+ return connections;
62
71
  }
63
72
  async determineCount() {
64
73
  return (await this.readCsv('Connections.csv')).length;
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -31,6 +31,10 @@ export declare class LinkedInArchiveEducationExtractor extends LinkedInArchiveEx
31
31
  limit?: number | undefined;
32
32
  }>;
33
33
  extract(): AsyncGenerator<Record>;
34
+ /** The schools a run reads, and when each began. */
35
+ survey(options?: SurveyOptions): Promise<Survey>;
36
+ /** Every school in the export with a name and a start, newest first. */
37
+ private enrollments;
34
38
  determineCount(): Promise<number>;
35
39
  }
36
40
  //# sourceMappingURL=LinkedInArchiveEducationExtractor.d.ts.map
@@ -12,20 +12,8 @@ export class LinkedInArchiveEducationExtractor extends LinkedInArchiveExtractor
12
12
  static defaultTransformer = LinkedInTransformer;
13
13
  static schema = LinkedInArchiveExtractor.schema;
14
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
15
  let count = 0;
28
- for (const enrollment of enrollments) {
16
+ for (const enrollment of await this.enrollments()) {
29
17
  if (this.shouldStopExtracting(count))
30
18
  break;
31
19
  if (!this.withinWindow(enrollment.startedOn))
@@ -37,6 +25,26 @@ export class LinkedInArchiveEducationExtractor extends LinkedInArchiveExtractor
37
25
  count++;
38
26
  }
39
27
  }
28
+ /** The schools a run reads, and when each began. */
29
+ async survey(options = {}) {
30
+ const dates = (await this.enrollments()).map(enrollment => enrollment.startedOn);
31
+ return { education: this.surveyDates(dates, options) };
32
+ }
33
+ /** Every school in the export with a name and a start, newest first. */
34
+ async enrollments() {
35
+ const rows = await this.readCsv('Education.csv');
36
+ return rows
37
+ .map(row => ({
38
+ school: row['School Name']?.trim(),
39
+ degree: row['Degree Name']?.trim(),
40
+ notes: row.Notes?.trim(),
41
+ activities: row.Activities?.trim(),
42
+ startedOn: parseCoarseDate(row['Start Date']),
43
+ finishedOn: parseCoarseDate(row['End Date']),
44
+ }))
45
+ .filter(enrollment => enrollment.school && enrollment.startedOn)
46
+ .sort((a, b) => (a.startedOn < b.startedOn ? 1 : -1));
47
+ }
40
48
  async determineCount() {
41
49
  return (await this.readCsv('Education.csv')).length;
42
50
  }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -39,6 +39,10 @@ export declare class LinkedInArchiveEndorsementsExtractor extends LinkedInArchiv
39
39
  limit?: number | undefined;
40
40
  }>;
41
41
  extract(): AsyncGenerator<Record>;
42
+ /** The endorsements a run reads, both ways, and when each was made. */
43
+ survey(options?: SurveyOptions): Promise<Survey>;
44
+ /** Both endorsement files as one list, newest first. */
45
+ private endorsements;
42
46
  /** One endorsement file, with the other party's columns read by prefix. */
43
47
  private readEndorsements;
44
48
  determineCount(): Promise<number>;
@@ -20,11 +20,8 @@ export class LinkedInArchiveEndorsementsExtractor extends LinkedInArchiveExtract
20
20
  static defaultTransformer = LinkedInTransformer;
21
21
  static schema = LinkedInArchiveExtractor.schema;
22
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
23
  let count = 0;
27
- for (const endorsement of endorsements) {
24
+ for (const endorsement of await this.endorsements()) {
28
25
  if (this.shouldStopExtracting(count))
29
26
  break;
30
27
  if (!this.withinWindow(endorsement.endorsedAt))
@@ -36,6 +33,17 @@ export class LinkedInArchiveEndorsementsExtractor extends LinkedInArchiveExtract
36
33
  count++;
37
34
  }
38
35
  }
36
+ /** The endorsements a run reads, both ways, and when each was made. */
37
+ async survey(options = {}) {
38
+ const dates = (await this.endorsements()).map(endorsement => endorsement.endorsedAt);
39
+ return { endorsements: this.surveyDates(dates, options) };
40
+ }
41
+ /** Both endorsement files as one list, newest first. */
42
+ async endorsements() {
43
+ const given = await this.readEndorsements('Endorsement_Given_Info.csv', 'given');
44
+ const received = await this.readEndorsements('Endorsement_Received_Info.csv', 'received');
45
+ return [...given, ...received].sort((a, b) => (a.endorsedAt ?? '') < (b.endorsedAt ?? '') ? 1 : -1);
46
+ }
39
47
  /** One endorsement file, with the other party's columns read by prefix. */
40
48
  async readEndorsements(fileName, direction) {
41
49
  const prefix = direction === 'given' ? 'Endorsee' : 'Endorser';
@@ -1,4 +1,4 @@
1
- import { ArchiveExtractor } from '@chronicle.app/etl';
1
+ import { ArchiveExtractor, type KindSurvey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { z } from 'zod';
3
3
  /** What one export knows about the member who requested it. */
4
4
  export interface LinkedInAccount {
@@ -13,8 +13,8 @@ export interface LinkedInAccount {
13
13
  }
14
14
  /**
15
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.
16
+ * Data Privacy → "Get a copy of your data" produces: a folder of CSVs, read
17
+ * as the zip or unpacked.
18
18
  *
19
19
  * Shared here: reading a named CSV out of the export, the account info every
20
20
  * record carries, and the date window. One subclass per file, because the files
@@ -50,10 +50,11 @@ export declare abstract class LinkedInArchiveExtractor<T extends typeof LinkedIn
50
50
  limit?: number | undefined;
51
51
  }>;
52
52
  protected accountInfo: LinkedInAccount;
53
+ protected readonly exportName = "LinkedIn export";
53
54
  /** Lower-cased file name → real file name, so a renamed case still resolves. */
54
55
  private fileIndex?;
55
56
  /** When LinkedIn wrote this export — see {@link asOfTime}. */
56
- private compiledAt?;
57
+ protected compiledAt?: Date;
57
58
  /**
58
59
  * Read the export's own facts before extraction: who it belongs to, and when
59
60
  * LinkedIn built it. The base class defines `loadAccountInfo` but never calls
@@ -71,7 +72,7 @@ export declare abstract class LinkedInArchiveExtractor<T extends typeof LinkedIn
71
72
  * of a fresh sighting of unchanged facts.
72
73
  */
73
74
  protected asOfTime(): Date;
74
- /** The newest mtime among the export's files, or none if it has none. */
75
+ /** The newest mtime among the export's files, or none if it has none; in a zip, from its index. */
75
76
  private newestFileTime;
76
77
  /**
77
78
  * The export's own account facts, read once per run: the profile, the primary
@@ -105,7 +106,7 @@ export declare abstract class LinkedInArchiveExtractor<T extends typeof LinkedIn
105
106
  protected readCsv(fileName: string): Promise<Array<{
106
107
  [column: string]: string | undefined;
107
108
  }>>;
108
- /** The real path for a file name, matched case-insensitively, or none. */
109
+ /** The real name of a file in the export, matched case-insensitively, or none. */
109
110
  private resolveFile;
110
111
  /** Every file in the export, listed once and indexed for case-insensitive lookup. */
111
112
  private fileNames;
@@ -116,5 +117,12 @@ export declare abstract class LinkedInArchiveExtractor<T extends typeof LinkedIn
116
117
  * record on the strength of a date the source never wrote.
117
118
  */
118
119
  protected withinWindow(date: string | undefined): boolean;
120
+ /**
121
+ * One record type's survey, from the date of each record a run would read,
122
+ * undated ones too: kept or dropped by the same since/until window, and
123
+ * placed by {@link occurrenceTime}. An undated record counts, but has no
124
+ * time or month to add.
125
+ */
126
+ protected surveyDates(dates: Array<string | undefined>, options: SurveyOptions): KindSurvey;
119
127
  }
120
128
  //# sourceMappingURL=LinkedInArchiveExtractor.d.ts.map
@@ -1,13 +1,11 @@
1
- import { ArchiveExtractor, Extractor, InputNotFound, fileError } from '@chronicle.app/etl';
1
+ import { ArchiveExtractor, countMonth, Extractor, } from '@chronicle.app/etl';
2
2
  import { parse } from 'csv-parse/sync';
3
- import { readdir, readFile, stat } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
3
  import { z } from 'zod';
6
- import { profileHandle } from '../fields.js';
4
+ import { occurrenceTime, profileHandle } from '../fields.js';
7
5
  /**
8
6
  * 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.
7
+ * Data Privacy → "Get a copy of your data" produces: a folder of CSVs, read
8
+ * as the zip or unpacked.
11
9
  *
12
10
  * Shared here: reading a named CSV out of the export, the account info every
13
11
  * record carries, and the date window. One subclass per file, because the files
@@ -23,13 +21,14 @@ export class LinkedInArchiveExtractor extends ArchiveExtractor {
23
21
  static source = 'linkedin';
24
22
  static strategy = 'archive';
25
23
  static schema = Extractor.schema.extend({
26
- input: z.string().describe('Path to the unpacked LinkedIn export directory'),
24
+ input: z.string().describe('The LinkedIn export, as a zip or its unpacked folder'),
27
25
  'profile-url': z
28
26
  .string()
29
27
  .optional()
30
28
  .describe('Your LinkedIn profile URL (https://www.linkedin.com/in/…). Only needed when the export carries no invitation to infer it from.'),
31
29
  });
32
30
  accountInfo = {};
31
+ exportName = 'LinkedIn export';
33
32
  /** Lower-cased file name → real file name, so a renamed case still resolves. */
34
33
  fileIndex;
35
34
  /** When LinkedIn wrote this export — see {@link asOfTime}. */
@@ -57,19 +56,14 @@ export class LinkedInArchiveExtractor extends ArchiveExtractor {
57
56
  asOfTime() {
58
57
  return this.compiledAt ?? super.asOfTime();
59
58
  }
60
- /** The newest mtime among the export's files, or none if it has none. */
59
+ /** The newest mtime among the export's files, or none if it has none; in a zip, from its index. */
61
60
  async newestFileTime() {
62
- const { input } = this.config;
63
61
  let newest;
64
62
  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
- }
63
+ // A file that vanished between listing and stat tells us nothing.
64
+ const mtimeMs = (await this.files.modified(name))?.getTime();
65
+ if (mtimeMs !== undefined && (newest === undefined || mtimeMs > newest))
66
+ newest = mtimeMs;
73
67
  }
74
68
  return newest === undefined ? undefined : new Date(newest);
75
69
  }
@@ -147,7 +141,7 @@ export class LinkedInArchiveExtractor extends ArchiveExtractor {
147
141
  const path = await this.resolveFile(fileName);
148
142
  if (!path)
149
143
  return [];
150
- const text = stripPreamble(await readFile(path, 'utf8'));
144
+ const text = stripPreamble(await this.files.readText(path));
151
145
  if (!text.trim())
152
146
  return [];
153
147
  return parse(text, {
@@ -158,27 +152,15 @@ export class LinkedInArchiveExtractor extends ArchiveExtractor {
158
152
  bom: true,
159
153
  });
160
154
  }
161
- /** The real path for a file name, matched case-insensitively, or none. */
155
+ /** The real name of a file in the export, matched case-insensitively, or none. */
162
156
  async resolveFile(fileName) {
163
157
  await this.fileNames();
164
- const resolved = this.fileIndex.get(fileName.toLowerCase());
165
- const { input } = this.config;
166
- return resolved ? join(input, resolved) : undefined;
158
+ return this.fileIndex.get(fileName.toLowerCase());
167
159
  }
168
160
  /** Every file in the export, listed once and indexed for case-insensitive lookup. */
169
161
  async fileNames() {
170
162
  if (!this.fileIndex) {
171
- const { input } = this.config;
172
- let entries = [];
173
- try {
174
- entries = await readdir(input);
175
- }
176
- catch (error) {
177
- const typed = fileError(error, input, 'LinkedIn export');
178
- throw typed === error
179
- ? new InputNotFound("Couldn't read the LinkedIn export", { path: input, cause: error })
180
- : typed;
181
- }
163
+ const entries = (await this.files.children('')).filter(name => !name.endsWith('/'));
182
164
  this.fileIndex = new Map(entries.map(name => [name.toLowerCase(), name]));
183
165
  }
184
166
  return [...this.fileIndex.values()];
@@ -195,6 +177,30 @@ export class LinkedInArchiveExtractor extends ArchiveExtractor {
195
177
  const parsed = new Date(date);
196
178
  return Number.isNaN(parsed.getTime()) ? true : this.isWithinDateRange(parsed);
197
179
  }
180
+ /**
181
+ * One record type's survey, from the date of each record a run would read,
182
+ * undated ones too: kept or dropped by the same since/until window, and
183
+ * placed by {@link occurrenceTime}. An undated record counts, but has no
184
+ * time or month to add.
185
+ */
186
+ surveyDates(dates, options) {
187
+ const kind = { count: 0, ...(options.byMonth && { byMonth: {} }) };
188
+ for (const date of dates) {
189
+ if (!this.withinWindow(date))
190
+ continue;
191
+ kind.count++;
192
+ const time = occurrenceTime(date);
193
+ if (!time)
194
+ continue;
195
+ if (!kind.earliest || time < kind.earliest)
196
+ kind.earliest = time;
197
+ if (!kind.latest || time > kind.latest)
198
+ kind.latest = time;
199
+ if (kind.byMonth)
200
+ countMonth(kind.byMonth, time);
201
+ }
202
+ return kind;
203
+ }
198
204
  }
199
205
  /**
200
206
  * Drop the prose LinkedIn writes above the header of `Connections.csv` — a
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -34,6 +34,10 @@ export declare class LinkedInArchiveFollowsExtractor extends LinkedInArchiveExtr
34
34
  limit?: number | undefined;
35
35
  }>;
36
36
  extract(): AsyncGenerator<Record>;
37
+ /** The follows a run reads, and when each was made. */
38
+ survey(options?: SurveyOptions): Promise<Survey>;
39
+ /** Every follow in the export that names what it follows, newest first. */
40
+ private follows;
37
41
  determineCount(): Promise<number>;
38
42
  }
39
43
  //# sourceMappingURL=LinkedInArchiveFollowsExtractor.d.ts.map
@@ -15,16 +15,8 @@ export class LinkedInArchiveFollowsExtractor extends LinkedInArchiveExtractor {
15
15
  static defaultTransformer = LinkedInTransformer;
16
16
  static schema = LinkedInArchiveExtractor.schema;
17
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
18
  let count = 0;
27
- for (const follow of follows) {
19
+ for (const follow of await this.follows()) {
28
20
  if (this.shouldStopExtracting(count))
29
21
  break;
30
22
  if (!this.withinWindow(follow.followedAt))
@@ -36,6 +28,22 @@ export class LinkedInArchiveFollowsExtractor extends LinkedInArchiveExtractor {
36
28
  count++;
37
29
  }
38
30
  }
31
+ /** The follows a run reads, and when each was made. */
32
+ async survey(options = {}) {
33
+ const dates = (await this.follows()).map(follow => follow.followedAt);
34
+ return { follows: this.surveyDates(dates, options) };
35
+ }
36
+ /** Every follow in the export that names what it follows, newest first. */
37
+ async follows() {
38
+ const rows = await this.readCsv('Company Follows.csv');
39
+ return rows
40
+ .map(row => ({
41
+ organization: row.Organization?.trim(),
42
+ followedAt: parseLongStamp(row['Followed On']),
43
+ }))
44
+ .filter(follow => follow.organization)
45
+ .sort((a, b) => ((a.followedAt ?? '') < (b.followedAt ?? '') ? 1 : -1));
46
+ }
39
47
  async determineCount() {
40
48
  return (await this.readCsv('Company Follows.csv')).length;
41
49
  }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -36,6 +36,10 @@ export declare class LinkedInArchiveLearningExtractor extends LinkedInArchiveExt
36
36
  limit?: number | undefined;
37
37
  }>;
38
38
  extract(): AsyncGenerator<Record>;
39
+ /** The courses a run reads, and when each was last watched, else completed. */
40
+ survey(options?: SurveyOptions): Promise<Survey>;
41
+ /** Every course in the export with a title, newest-watched first. */
42
+ private courses;
39
43
  determineCount(): Promise<number>;
40
44
  }
41
45
  //# sourceMappingURL=LinkedInArchiveLearningExtractor.d.ts.map
@@ -17,8 +17,26 @@ export class LinkedInArchiveLearningExtractor extends LinkedInArchiveExtractor {
17
17
  static defaultTransformer = LinkedInTransformer;
18
18
  static schema = LinkedInArchiveExtractor.schema;
19
19
  async *extract() {
20
+ let count = 0;
21
+ for (const course of await this.courses()) {
22
+ if (this.shouldStopExtracting(count))
23
+ break;
24
+ const occurredAt = lastSeen(course);
25
+ if (!this.withinWindow(occurredAt))
26
+ continue;
27
+ yield this.createRecordWithArchiveContext({ ...course, occurredAt });
28
+ count++;
29
+ }
30
+ }
31
+ /** The courses a run reads, and when each was last watched, else completed. */
32
+ async survey(options = {}) {
33
+ const dates = (await this.courses()).map(course => lastSeen(course));
34
+ return { learning: this.surveyDates(dates, options) };
35
+ }
36
+ /** Every course in the export with a title, newest-watched first. */
37
+ async courses() {
20
38
  const rows = await this.readCsv('Learning.csv');
21
- const courses = rows
39
+ return rows
22
40
  .map(row => ({
23
41
  title: row['Content Title']?.trim(),
24
42
  description: row['Content Description']?.trim() || undefined,
@@ -29,18 +47,12 @@ export class LinkedInArchiveLearningExtractor extends LinkedInArchiveExtractor {
29
47
  }))
30
48
  .filter(course => course.title)
31
49
  .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
50
  }
43
51
  async determineCount() {
44
52
  return (await this.readCsv('Learning.csv')).length;
45
53
  }
46
54
  }
55
+ /** When a course dates to: the last time it was watched, else when it was completed. */
56
+ function lastSeen(course) {
57
+ return course.watchedAt ?? course.completedAt;
58
+ }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /** A party to a message: the handle when LinkedIn gave one, the name when it did not. */
@@ -58,6 +58,10 @@ export declare class LinkedInArchiveMessagesExtractor extends LinkedInArchiveExt
58
58
  limit?: number | undefined;
59
59
  }>;
60
60
  extract(): AsyncGenerator<Record>;
61
+ /** The messages a run reads and when they were sent, with how many conversations they're in. */
62
+ survey(options?: SurveyOptions): Promise<Survey>;
63
+ /** Every message in the export that can be placed, newest first. */
64
+ private messages;
61
65
  determineCount(): Promise<number>;
62
66
  }
63
67
  //# sourceMappingURL=LinkedInArchiveMessagesExtractor.d.ts.map
@@ -34,6 +34,33 @@ export class LinkedInArchiveMessagesExtractor extends LinkedInArchiveExtractor {
34
34
  static defaultTransformer = LinkedInTransformer;
35
35
  static schema = LinkedInArchiveExtractor.schema;
36
36
  async *extract() {
37
+ let count = 0;
38
+ for (const message of await this.messages()) {
39
+ if (this.shouldStopExtracting(count))
40
+ break;
41
+ if (!this.withinWindow(message.sentAt))
42
+ continue;
43
+ yield this.createRecordWithArchiveContext({
44
+ ...message,
45
+ occurredAt: message.sentAt,
46
+ });
47
+ count++;
48
+ }
49
+ }
50
+ /** The messages a run reads and when they were sent, with how many conversations they're in. */
51
+ async survey(options = {}) {
52
+ const messages = (await this.messages()).filter(message => this.withinWindow(message.sentAt));
53
+ const dates = messages.map(message => message.sentAt);
54
+ const conversations = new Set(messages.map(message => message.conversationId)).size;
55
+ return {
56
+ messages: {
57
+ ...this.surveyDates(dates, options),
58
+ measures: [{ key: 'conversations', label: 'conversations', value: conversations }],
59
+ },
60
+ };
61
+ }
62
+ /** Every message in the export that can be placed, newest first. */
63
+ async messages() {
37
64
  const rows = await this.readCsv('messages.csv');
38
65
  const messages = rows
39
66
  .map(row => {
@@ -55,18 +82,7 @@ export class LinkedInArchiveMessagesExtractor extends LinkedInArchiveExtractor {
55
82
  // a row missing either is not a message we can place.
56
83
  .filter(message => message.conversationId && message.sentAt)
57
84
  .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
- }
85
+ return messages;
70
86
  }
71
87
  async determineCount() {
72
88
  return (await this.readCsv('messages.csv')).length;
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -35,6 +35,10 @@ export declare class LinkedInArchivePositionsExtractor extends LinkedInArchiveEx
35
35
  limit?: number | undefined;
36
36
  }>;
37
37
  extract(): AsyncGenerator<Record>;
38
+ /** The positions a run reads, and when each began. */
39
+ survey(options?: SurveyOptions): Promise<Survey>;
40
+ /** Every position in the export with a company and a start, newest first. */
41
+ private positions;
38
42
  determineCount(): Promise<number>;
39
43
  }
40
44
  //# sourceMappingURL=LinkedInArchivePositionsExtractor.d.ts.map
@@ -16,20 +16,8 @@ export class LinkedInArchivePositionsExtractor extends LinkedInArchiveExtractor
16
16
  static defaultTransformer = LinkedInTransformer;
17
17
  static schema = LinkedInArchiveExtractor.schema;
18
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
19
  let count = 0;
32
- for (const position of positions) {
20
+ for (const position of await this.positions()) {
33
21
  if (this.shouldStopExtracting(count))
34
22
  break;
35
23
  if (!this.withinWindow(position.startedOn))
@@ -41,6 +29,26 @@ export class LinkedInArchivePositionsExtractor extends LinkedInArchiveExtractor
41
29
  count++;
42
30
  }
43
31
  }
32
+ /** The positions a run reads, and when each began. */
33
+ async survey(options = {}) {
34
+ const dates = (await this.positions()).map(position => position.startedOn);
35
+ return { positions: this.surveyDates(dates, options) };
36
+ }
37
+ /** Every position in the export with a company and a start, newest first. */
38
+ async positions() {
39
+ const rows = await this.readCsv('Positions.csv');
40
+ return rows
41
+ .map(row => ({
42
+ company: row['Company Name']?.trim(),
43
+ title: row.Title?.trim(),
44
+ description: row.Description?.trim(),
45
+ location: row.Location?.trim(),
46
+ startedOn: parseCoarseDate(row['Started On']),
47
+ finishedOn: parseCoarseDate(row['Finished On']),
48
+ }))
49
+ .filter(position => position.company && position.startedOn)
50
+ .sort((a, b) => (a.startedOn < b.startedOn ? 1 : -1));
51
+ }
44
52
  async determineCount() {
45
53
  return (await this.readCsv('Positions.csv')).length;
46
54
  }
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type Survey } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
3
  import { LinkedInArchiveExtractor } from './LinkedInArchiveExtractor.js';
4
4
  /**
@@ -36,6 +36,13 @@ export declare class LinkedInArchiveProfileExtractor extends LinkedInArchiveExtr
36
36
  limit?: number | undefined;
37
37
  }>;
38
38
  extract(): AsyncGenerator<Record>;
39
+ /**
40
+ * The profile a run reads, as of when LinkedIn compiled the export: it has
41
+ * no time of its own, and dates its sighting to that.
42
+ */
43
+ survey(): Promise<Survey>;
44
+ /** The account, when the export names its member at all. */
45
+ private profile;
39
46
  determineCount(): Promise<number>;
40
47
  }
41
48
  //# sourceMappingURL=LinkedInArchiveProfileExtractor.d.ts.map
@@ -16,11 +16,25 @@ export class LinkedInArchiveProfileExtractor extends LinkedInArchiveExtractor {
16
16
  static defaultTransformer = LinkedInTransformer;
17
17
  static schema = LinkedInArchiveExtractor.schema;
18
18
  async *extract() {
19
- const account = this.accountInfo;
20
- if (!account.name && !account.handle && !account.email)
19
+ const account = this.profile();
20
+ if (!account)
21
21
  return;
22
22
  yield this.createRecordWithArchiveContext({ ...account });
23
23
  }
24
+ /**
25
+ * The profile a run reads, as of when LinkedIn compiled the export: it has
26
+ * no time of its own, and dates its sighting to that.
27
+ */
28
+ async survey() {
29
+ if (!this.profile())
30
+ return { profile: { count: 0 } };
31
+ return { profile: { count: 1, ...(this.compiledAt && { asOf: this.compiledAt }) } };
32
+ }
33
+ /** The account, when the export names its member at all. */
34
+ profile() {
35
+ const account = this.accountInfo;
36
+ return account.name || account.handle || account.email ? account : undefined;
37
+ }
24
38
  async determineCount() {
25
39
  return 1;
26
40
  }
@@ -1,6 +1,13 @@
1
1
  import { MergingExtractor, Record } from '@chronicle.app/etl';
2
2
  import LinkedInTransformer from '../LinkedInTransformer.js';
3
+ import { LinkedInArchiveConnectionsExtractor } from './LinkedInArchiveConnectionsExtractor.js';
4
+ import { LinkedInArchiveEducationExtractor } from './LinkedInArchiveEducationExtractor.js';
5
+ import { LinkedInArchiveEndorsementsExtractor } from './LinkedInArchiveEndorsementsExtractor.js';
6
+ import { LinkedInArchiveFollowsExtractor } from './LinkedInArchiveFollowsExtractor.js';
7
+ import { LinkedInArchiveLearningExtractor } from './LinkedInArchiveLearningExtractor.js';
3
8
  import { LinkedInArchiveMessagesExtractor } from './LinkedInArchiveMessagesExtractor.js';
9
+ import { LinkedInArchivePositionsExtractor } from './LinkedInArchivePositionsExtractor.js';
10
+ import { LinkedInArchiveProfileExtractor } from './LinkedInArchiveProfileExtractor.js';
4
11
  /**
5
12
  * The whole export in one reverse-chronological pass — what a bare
6
13
  * `extract linkedin -i <dir>` means.
@@ -18,7 +25,7 @@ export declare class LinkedInDefaultExtractor extends MergingExtractor<typeof Li
18
25
  static recordTypes: string[];
19
26
  static default: boolean;
20
27
  static defaultTransformer: typeof LinkedInTransformer;
21
- static children: (typeof LinkedInArchiveMessagesExtractor)[];
28
+ static children: (typeof LinkedInArchiveMessagesExtractor | typeof LinkedInArchiveConnectionsExtractor | typeof LinkedInArchiveEducationExtractor | typeof LinkedInArchiveEndorsementsExtractor | typeof LinkedInArchiveFollowsExtractor | typeof LinkedInArchiveLearningExtractor | typeof LinkedInArchivePositionsExtractor | typeof LinkedInArchiveProfileExtractor)[];
22
29
  static schema: any;
23
30
  /**
24
31
  * Every child dates its records the same way, on `occurredAt`. A record with
@@ -48,7 +48,7 @@ export class LinkedInDefaultExtractor extends MergingExtractor {
48
48
  // The union of the children's flags, so the runner forwards them to the merge
49
49
  // and the merge forwards the raw config to each child.
50
50
  static schema = MergingExtractor.schema.extend({
51
- input: z.string().describe('Path to the unpacked LinkedIn export directory'),
51
+ input: z.string().describe('The LinkedIn export, as a zip or its unpacked folder'),
52
52
  'profile-url': z
53
53
  .string()
54
54
  .optional()
@@ -53,4 +53,12 @@ export declare function parseDayDate(raw: string | undefined): string | undefine
53
53
  * its own precision, which is why widening them to a day would be a lie.
54
54
  */
55
55
  export declare function parseCoarseDate(raw: string | undefined): string | undefined;
56
+ /**
57
+ * A date one of these readers returned, as the moment a survey counts it at:
58
+ * an instant as itself, and a civil date (`2009`, `2022-08`, `2026-06-06`) at
59
+ * the start of its year, month, or day in local time. `new Date` would put a
60
+ * civil date at midnight UTC, which west of Greenwich is the day, month, or
61
+ * year before.
62
+ */
63
+ export declare function occurrenceTime(value: string | undefined): Date | undefined;
56
64
  //# sourceMappingURL=fields.d.ts.map
@@ -107,6 +107,23 @@ export function parseCoarseDate(raw) {
107
107
  const month = MONTHS[match[1].slice(0, 3).toLowerCase()];
108
108
  return month ? `${match[2]}-${month}` : undefined;
109
109
  }
110
+ /**
111
+ * A date one of these readers returned, as the moment a survey counts it at:
112
+ * an instant as itself, and a civil date (`2009`, `2022-08`, `2026-06-06`) at
113
+ * the start of its year, month, or day in local time. `new Date` would put a
114
+ * civil date at midnight UTC, which west of Greenwich is the day, month, or
115
+ * year before.
116
+ */
117
+ export function occurrenceTime(value) {
118
+ if (!value)
119
+ return undefined;
120
+ const civil = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(value);
121
+ if (civil) {
122
+ return new Date(Number(civil[1]), Number(civil[2] ?? 1) - 1, Number(civil[3] ?? 1));
123
+ }
124
+ const date = new Date(value);
125
+ return Number.isNaN(date.getTime()) ? undefined : date;
126
+ }
110
127
  const MONTHS = {
111
128
  jan: '01',
112
129
  feb: '02',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/linkedin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -22,9 +22,9 @@
22
22
  "type": "module",
23
23
  "description": "Chronicle plugin for the LinkedIn member data export archive",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.4.0",
26
- "@chronicle.app/schema": "0.4.0",
27
- "@chronicle.app/tsconfig": "0.4.0",
25
+ "@chronicle.app/etl": "0.5.0",
26
+ "@chronicle.app/schema": "0.5.0",
27
+ "@chronicle.app/tsconfig": "0.5.0",
28
28
  "@types/node": "^22.10.1",
29
29
  "typescript": "^5.7.2"
30
30
  },
@@ -62,6 +62,14 @@
62
62
  "strategies": {
63
63
  "archive": {
64
64
  "delivery": "export",
65
+ "layouts": [
66
+ {
67
+ "files": [
68
+ "Connections.csv",
69
+ "Profile.csv"
70
+ ]
71
+ }
72
+ ],
65
73
  "recordTypes": [
66
74
  "connections",
67
75
  "education",