@chronicle.app/twitter 0.3.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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ chronicle extract twitter \
30
30
 
31
31
  The extractor supports the following options:
32
32
 
33
- - `--input` (required): Path to the Twitter export data directory
33
+ - `--input` (required): The Twitter/X archive, as a zip or its unzipped folder
34
34
  - `--type`: Which record types to read: `tweets`, `likes`, `direct-messages`,
35
35
  `group-direct-messages` (default: all of them)
36
36
  - `--include-group-dms`: Include group direct messages (default: true)
@@ -50,7 +50,7 @@ npm test
50
50
 
51
51
  ## File Format
52
52
 
53
- The input is the unzipped directory of a Twitter/X data archive. The plugin
53
+ The input is a Twitter/X data archive, zipped or unzipped. The plugin
54
54
  reads these files from its `data/` folder, each a `window.YTD.<name>.part0 = [...]`
55
55
  script wrapping a JSON array:
56
56
 
@@ -63,10 +63,10 @@ script wrapping a JSON array:
63
63
 
64
64
  ## Schema
65
65
 
66
- - Each tweet is a `PublishAction` whose object is a `Post`. The post's
66
+ - Each tweet is a `PublishAction` whose `result` is a `Post`, keyed by it. The post's
67
67
  `references` are the accounts it mentions and the links it contains.
68
68
  - Each like is a `LikeAction` on the liked `Post`.
69
- - Each direct message is a `MessageAction` whose object is a `Message`, sent to
69
+ - Each direct message is a `MessageAction` whose `result` is a `Message`, keyed by it, sent to
70
70
  the other participants of the conversation.
71
71
 
72
72
  The archive owner is an `Agent` linked to `@me`; other accounts are `Agent`s
@@ -35,12 +35,12 @@ export default class TwitterTransformer extends ChronicleTransformer {
35
35
  };
36
36
  const publishAction = {
37
37
  '@type': 'PublishAction',
38
- '@key': ['@type', 'source', 'sourceId'],
38
+ // Keyed by the tweet it published.
39
+ '@key': ['@type', 'source', 'result.sourceId'],
39
40
  source: 'twitter',
40
- sourceId: record.data.id,
41
41
  timestamp: record.data.created_at,
42
42
  agent: user,
43
- object: tweet,
43
+ result: tweet,
44
44
  };
45
45
  actions.push(publishAction);
46
46
  return actions;
@@ -57,9 +57,9 @@ export default class TwitterTransformer extends ChronicleTransformer {
57
57
  };
58
58
  const likeAction = {
59
59
  '@type': 'LikeAction',
60
- '@key': ['@type', 'source', 'sourceId'],
60
+ // You like a tweet once, so the tweet identifies the like.
61
+ '@key': ['@type', 'source', 'object.sourceId'],
61
62
  source: 'twitter',
62
- sourceId: `${record.data.tweetId}-like`,
63
63
  agent: user,
64
64
  object: targetTweet,
65
65
  };
@@ -82,12 +82,12 @@ export default class TwitterTransformer extends ChronicleTransformer {
82
82
  // Media URLs would need to be handled separately or added to schema
83
83
  const sendAction = {
84
84
  '@type': 'MessageAction',
85
- '@key': ['@type', 'source', 'sourceId'],
85
+ // Keyed by the message it sent.
86
+ '@key': ['@type', 'source', 'result.sourceId'],
86
87
  source: 'twitter',
87
- sourceId: record.data.id,
88
88
  timestamp: record.data.created_at,
89
89
  agent: sender,
90
- object: message,
90
+ result: message,
91
91
  };
92
92
  actions.push(sendAction);
93
93
  return actions;
@@ -200,12 +200,12 @@ export default class TwitterTransformer extends ChronicleTransformer {
200
200
  };
201
201
  const sendAction = {
202
202
  '@type': 'MessageAction',
203
- '@key': ['@type', 'source', 'sourceId'],
203
+ // Keyed by the message it sent.
204
+ '@key': ['@type', 'source', 'result.sourceId'],
204
205
  source: 'twitter',
205
- sourceId: record.data.id,
206
206
  timestamp: record.data.created_at,
207
207
  agent: sender,
208
- object: message,
208
+ result: message,
209
209
  };
210
210
  actions.push(sendAction);
211
211
  return actions;
@@ -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 { z } from 'zod';
3
3
  import TwitterTransformer from '../TwitterTransformer.js';
4
4
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
@@ -30,9 +30,13 @@ export declare class TwitterArchiveDirectMessagesExtractor extends TwitterArchiv
30
30
  /** The DM's message id — Twitter's own id, identical through any tool. */
31
31
  keyOf(record: Record): string | null;
32
32
  extract(): AsyncGenerator<Record>;
33
+ /** The 1:1 messages, and group messages if enabled, within since and until. */
34
+ private loadMessages;
33
35
  private loadDirectMessages;
34
36
  private loadGroupHeaders;
35
37
  private loadGroupDirectMessages;
36
38
  determineCount(): Promise<number | null>;
39
+ /** The messages a run reads, when they were sent, and in how many conversations. */
40
+ survey(options?: SurveyOptions): Promise<Survey>;
37
41
  }
38
42
  //# sourceMappingURL=TwitterArchiveDirectMessagesExtractor.d.ts.map
@@ -1,4 +1,3 @@
1
- import { readFile } from 'node:fs/promises';
2
1
  import TwitterTransformer from '../TwitterTransformer.js';
3
2
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
3
  export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtractor {
@@ -14,20 +13,11 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
14
13
  return record.data.id ?? null;
15
14
  }
16
15
  async *extract() {
17
- const config = this.config;
18
16
  try {
19
17
  // Load account info first
20
- await this.loadAccountInfoFrom(config.input);
18
+ this.accountInfo = await this.loadAccountInfo();
21
19
  // Collect all messages from both sources
22
- const allMessages = [];
23
- // Process 1:1 direct messages
24
- const oneOnOneMessages = await this.loadDirectMessages(`${config.input}/data/direct-messages.js`);
25
- allMessages.push(...oneOnOneMessages);
26
- // Process group direct messages (if enabled)
27
- if (config['include-group-dms']) {
28
- const groupMessages = await this.loadGroupDirectMessages(`${config.input}/data/direct-messages-group.js`);
29
- allMessages.push(...groupMessages);
30
- }
20
+ const allMessages = await this.loadMessages();
31
21
  // Sort by timestamp descending (newest first)
32
22
  allMessages.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
33
23
  // Yield messages in chronological order (newest first)
@@ -35,20 +25,29 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
35
25
  for (const message of allMessages) {
36
26
  if (this.shouldStopExtracting(count))
37
27
  break;
38
- if (this.isWithinDateRange(new Date(message.created_at))) {
39
- yield this.createRecordWithArchiveContext(message.data, message.metadata);
40
- count++;
41
- }
28
+ yield this.createRecordWithArchiveContext(message.data, message.metadata);
29
+ count++;
42
30
  }
43
31
  }
44
32
  catch (error) {
45
33
  throw new Error(`Failed to process Twitter direct messages: ${error}`);
46
34
  }
47
35
  }
36
+ /** The 1:1 messages, and group messages if enabled, within since and until. */
37
+ async loadMessages() {
38
+ const config = this.config;
39
+ // Process 1:1 direct messages
40
+ const messages = await this.loadDirectMessages(`data/direct-messages.js`);
41
+ // Process group direct messages (if enabled)
42
+ if (config['include-group-dms']) {
43
+ messages.push(...(await this.loadGroupDirectMessages(`data/direct-messages-group.js`)));
44
+ }
45
+ return messages.filter(message => this.isWithinDateRange(new Date(message.created_at)));
46
+ }
48
47
  async loadDirectMessages(filePath) {
49
48
  const messages = [];
50
49
  try {
51
- const content = await readFile(filePath, 'utf-8');
50
+ const content = await this.files.readText(filePath);
52
51
  const jsData = this.parseTwitterJS(content, 'direct_messages');
53
52
  for (const item of jsData) {
54
53
  const conversation = item.dmConversation;
@@ -77,14 +76,14 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
77
76
  }
78
77
  }
79
78
  catch (error) {
80
- console.warn(`Could not process 1:1 direct messages: ${error}`);
79
+ this.logger.warn('Could not process 1:1 direct messages', { error: String(error) });
81
80
  }
82
81
  return messages;
83
82
  }
84
83
  async loadGroupHeaders(filePath) {
85
84
  const groupHeaders = new Map();
86
85
  try {
87
- const content = await readFile(filePath, 'utf-8');
86
+ const content = await this.files.readText(filePath);
88
87
  const jsData = this.parseTwitterJS(content, 'direct_message_group_headers');
89
88
  for (const item of jsData) {
90
89
  const conversation = item.dmConversation;
@@ -102,7 +101,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
102
101
  }
103
102
  }
104
103
  catch (error) {
105
- console.warn(`Could not load group headers: ${error}`);
104
+ this.logger.warn('Could not load group headers', { error: String(error) });
106
105
  }
107
106
  return groupHeaders;
108
107
  }
@@ -111,7 +110,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
111
110
  try {
112
111
  // First load group headers to get participant info
113
112
  const groupHeaders = await this.loadGroupHeaders(`${filePath.replace('direct-messages-group.js', 'direct-message-group-headers.js')}`);
114
- const content = await readFile(filePath, 'utf-8');
113
+ const content = await this.files.readText(filePath);
115
114
  const jsData = this.parseTwitterJS(content, 'direct_messages_group');
116
115
  for (const item of jsData) {
117
116
  const conversation = item.dmConversation;
@@ -146,7 +145,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
146
145
  }
147
146
  }
148
147
  catch (error) {
149
- console.warn(`Could not process group direct messages: ${error}`);
148
+ this.logger.warn('Could not process group direct messages', { error: String(error) });
150
149
  }
151
150
  return messages;
152
151
  }
@@ -156,7 +155,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
156
155
  let totalMessages = 0;
157
156
  // Count 1:1 direct messages
158
157
  try {
159
- const dmContent = await readFile(`${config.input}/data/direct-messages.js`, 'utf-8');
158
+ const dmContent = await this.files.readText(`data/direct-messages.js`);
160
159
  const dmData = this.parseTwitterJS(dmContent, 'direct_messages');
161
160
  for (const item of dmData) {
162
161
  const conversation = item.dmConversation;
@@ -171,7 +170,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
171
170
  // Count group direct messages (if enabled)
172
171
  if (config['include-group-dms']) {
173
172
  try {
174
- const groupContent = await readFile(`${config.input}/data/direct-messages-group.js`, 'utf-8');
173
+ const groupContent = await this.files.readText(`data/direct-messages-group.js`);
175
174
  const groupData = this.parseTwitterJS(groupContent, 'direct_messages_group');
176
175
  for (const item of groupData) {
177
176
  const conversation = item.dmConversation;
@@ -190,4 +189,23 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
190
189
  return null;
191
190
  }
192
191
  }
192
+ /** The messages a run reads, when they were sent, and in how many conversations. */
193
+ async survey(options = {}) {
194
+ const config = this.config;
195
+ const messages = await this.loadMessages();
196
+ const survey = {};
197
+ for (const recordType of TwitterArchiveDirectMessagesExtractor.recordTypes) {
198
+ if (recordType === 'group-direct-messages' && !config['include-group-dms'])
199
+ continue;
200
+ // A 1:1 message names no record type, so its record is the first, as in a run.
201
+ const sent = messages.filter(message => (message.metadata.recordType ?? 'direct-messages') === recordType);
202
+ const times = sent.map(message => new Date(message.created_at));
203
+ const conversations = new Set(sent.map(message => message.data.conversationId)).size;
204
+ survey[recordType] = {
205
+ ...this.surveyOf(times, options),
206
+ measures: [{ key: 'conversations', label: 'conversations', value: conversations }],
207
+ };
208
+ }
209
+ return survey;
210
+ }
193
211
  }
@@ -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
  export declare abstract class TwitterArchiveExtractor<T extends typeof TwitterArchiveExtractor> extends ArchiveExtractor<T> {
4
4
  static source: string;
@@ -23,14 +23,12 @@ export declare abstract class TwitterArchiveExtractor<T extends typeof TwitterAr
23
23
  until?: Date | undefined;
24
24
  limit?: number | undefined;
25
25
  }>;
26
- /**
27
- * Twitter's export needs the archive's base directory to locate
28
- * `data/account.js`, so this can't share the base class's no-arg
29
- * `loadAccountInfo()` signature — kept as its own method instead of an
30
- * incompatible override.
31
- */
32
- protected loadAccountInfoFrom(basePath: string): Promise<void>;
26
+ protected readonly exportName = "Twitter archive";
27
+ /** Whose archive this is, from `data/account.js`. */
28
+ protected loadAccountInfo(): Promise<any>;
33
29
  protected parseTwitterJS(content: string, dataType: string): any[];
30
+ /** How many records a run reads and when, from the time it dates each by. */
31
+ protected surveyOf(times: Date[], options: SurveyOptions): KindSurvey;
34
32
  protected convertTwitterDateToISO(twitterDate: string): string;
35
33
  }
36
34
  //# sourceMappingURL=TwitterArchiveExtractor.d.ts.map
@@ -1,32 +1,27 @@
1
- import { ArchiveExtractor, Extractor } from '@chronicle.app/etl';
2
- import { readFile } from 'node:fs/promises';
1
+ import { ArchiveExtractor, countMonth, Extractor, } from '@chronicle.app/etl';
3
2
  import { z } from 'zod';
4
3
  export class TwitterArchiveExtractor extends ArchiveExtractor {
5
4
  static source = 'twitter';
6
5
  static strategy = 'archive';
7
6
  static schema = Extractor.schema.extend({
8
- input: z.string().describe('Path to Twitter export data directory'),
7
+ input: z.string().describe('The Twitter archive, as a zip or its unpacked folder'),
9
8
  'include-group-dms': z
10
9
  .boolean()
11
10
  .optional()
12
11
  .default(true)
13
12
  .describe('Include group direct messages in extraction'),
14
13
  });
15
- /**
16
- * Twitter's export needs the archive's base directory to locate
17
- * `data/account.js`, so this can't share the base class's no-arg
18
- * `loadAccountInfo()` signature — kept as its own method instead of an
19
- * incompatible override.
20
- */
21
- async loadAccountInfoFrom(basePath) {
14
+ exportName = 'Twitter archive';
15
+ /** Whose archive this is, from `data/account.js`. */
16
+ async loadAccountInfo() {
22
17
  try {
23
- const content = await readFile(`${basePath}/data/account.js`, 'utf-8');
18
+ const content = await this.files.readText('data/account.js');
24
19
  const jsData = this.parseTwitterJS(content, 'account');
25
- this.accountInfo = jsData[0]?.account || null;
20
+ return jsData[0]?.account || null;
26
21
  }
27
22
  catch (error) {
28
- console.warn(`Could not load account info: ${error}`);
29
- this.accountInfo = null;
23
+ this.logger.warn('Could not load account info', { error: String(error) });
24
+ return null;
30
25
  }
31
26
  }
32
27
  parseTwitterJS(content, dataType) {
@@ -42,6 +37,19 @@ export class TwitterArchiveExtractor extends ArchiveExtractor {
42
37
  throw new Error(`Invalid JSON in ${dataType} file: ${error}`);
43
38
  }
44
39
  }
40
+ /** How many records a run reads and when, from the time it dates each by. */
41
+ surveyOf(times, options) {
42
+ const kind = { count: times.length, ...(options.byMonth && { byMonth: {} }) };
43
+ for (const time of times) {
44
+ if (!kind.earliest || time < kind.earliest)
45
+ kind.earliest = time;
46
+ if (!kind.latest || time > kind.latest)
47
+ kind.latest = time;
48
+ if (kind.byMonth)
49
+ countMonth(kind.byMonth, time);
50
+ }
51
+ return kind;
52
+ }
45
53
  convertTwitterDateToISO(twitterDate) {
46
54
  // Convert Twitter's date format "Sat Jan 11 17:10:28 +0000 2025" to ISO
47
55
  // Twitter uses format: "ddd MMM DD HH:mm:ss +0000 YYYY"
@@ -1,19 +1,18 @@
1
- import { Record } from '@chronicle.app/etl';
2
- import { z } from 'zod';
1
+ import { Record, type Survey } from '@chronicle.app/etl';
3
2
  import TwitterTransformer from '../TwitterTransformer.js';
4
3
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
5
4
  export declare class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor<typeof TwitterArchiveLikesExtractor> {
6
5
  static description: string;
7
6
  static recordTypes: string[];
8
7
  static defaultTransformer: typeof TwitterTransformer;
9
- static schema: z.ZodObject<{
10
- since: z.ZodOptional<z.ZodDate>;
11
- until: z.ZodOptional<z.ZodDate>;
12
- limit: z.ZodOptional<z.ZodNumber>;
8
+ static schema: import("zod").ZodObject<{
9
+ since: import("zod").ZodOptional<import("zod").ZodDate>;
10
+ until: import("zod").ZodOptional<import("zod").ZodDate>;
11
+ limit: import("zod").ZodOptional<import("zod").ZodNumber>;
13
12
  } & {
14
- input: z.ZodString;
15
- 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
16
- }, "strip", z.ZodTypeAny, {
13
+ input: import("zod").ZodString;
14
+ 'include-group-dms': import("zod").ZodDefault<import("zod").ZodOptional<import("zod").ZodBoolean>>;
15
+ }, "strip", import("zod").ZodTypeAny, {
17
16
  input: string;
18
17
  'include-group-dms': boolean;
19
18
  since?: Date | undefined;
@@ -30,6 +29,10 @@ export declare class TwitterArchiveLikesExtractor extends TwitterArchiveExtracto
30
29
  keyOf(record: Record): string | null;
31
30
  extract(): AsyncGenerator<Record>;
32
31
  private processLikes;
32
+ /** The archive's likes, as it writes them: with no time, so since and until can't apply. */
33
+ private loadLikes;
33
34
  determineCount(): Promise<number | null>;
35
+ /** The likes a run reads, counted alone: the archive doesn't say when any were made. */
36
+ survey(): Promise<Survey>;
34
37
  }
35
38
  //# sourceMappingURL=TwitterArchiveLikesExtractor.d.ts.map
@@ -1,4 +1,3 @@
1
- import { readFile } from 'node:fs/promises';
2
1
  import TwitterTransformer from '../TwitterTransformer.js';
3
2
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
3
  export class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor {
@@ -11,11 +10,10 @@ export class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor {
11
10
  return record.data.tweetId ?? null;
12
11
  }
13
12
  async *extract() {
14
- const config = this.config;
15
13
  try {
16
14
  // Load account info first
17
- await this.loadAccountInfoFrom(config.input);
18
- yield* this.processLikes(`${config.input}/data/like.js`);
15
+ this.accountInfo = await this.loadAccountInfo();
16
+ yield* this.processLikes(`data/like.js`);
19
17
  }
20
18
  catch (error) {
21
19
  throw new Error(`Failed to process Twitter likes: ${error}`);
@@ -23,32 +21,33 @@ export class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor {
23
21
  }
24
22
  async *processLikes(filePath) {
25
23
  try {
26
- const content = await readFile(filePath, 'utf-8');
27
- const jsData = this.parseTwitterJS(content, 'like');
28
24
  let count = 0;
29
- for (const item of jsData) {
30
- const { like } = item;
31
- if (like) {
32
- if (this.shouldStopExtracting(count))
33
- break;
34
- yield this.createRecordWithArchiveContext({
35
- type: 'like',
36
- tweetId: like.tweetId,
37
- fullText: like.fullText,
38
- expandedUrl: like.expandedUrl,
39
- }, { originalData: like });
40
- count++;
41
- }
25
+ for (const like of await this.loadLikes(filePath)) {
26
+ if (this.shouldStopExtracting(count))
27
+ break;
28
+ yield this.createRecordWithArchiveContext({
29
+ type: 'like',
30
+ tweetId: like.tweetId,
31
+ fullText: like.fullText,
32
+ expandedUrl: like.expandedUrl,
33
+ }, { originalData: like });
34
+ count++;
42
35
  }
43
36
  }
44
37
  catch (error) {
45
- console.warn(`Could not process likes: ${error}`);
38
+ this.logger.warn('Could not process likes', { error: String(error) });
46
39
  }
47
40
  }
41
+ /** The archive's likes, as it writes them: with no time, so since and until can't apply. */
42
+ async loadLikes(filePath) {
43
+ const content = await this.files.readText(filePath);
44
+ return this.parseTwitterJS(content, 'like')
45
+ .map(item => item.like)
46
+ .filter(Boolean);
47
+ }
48
48
  async determineCount() {
49
- const config = this.config;
50
49
  try {
51
- const likesContent = await readFile(`${config.input}/data/like.js`, 'utf-8');
50
+ const likesContent = await this.files.readText(`data/like.js`);
52
51
  const likesData = this.parseTwitterJS(likesContent, 'like');
53
52
  return likesData.length;
54
53
  }
@@ -56,4 +55,8 @@ export class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor {
56
55
  return null;
57
56
  }
58
57
  }
58
+ /** The likes a run reads, counted alone: the archive doesn't say when any were made. */
59
+ async survey() {
60
+ return { likes: { count: (await this.loadLikes(`data/like.js`)).length } };
61
+ }
59
62
  }
@@ -1,5 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
2
- import { z } from 'zod';
1
+ import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
3
2
  import TwitterTransformer from '../TwitterTransformer.js';
4
3
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
5
4
  export declare class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor<typeof TwitterArchiveTweetsExtractor> {
@@ -7,14 +6,14 @@ export declare class TwitterArchiveTweetsExtractor extends TwitterArchiveExtract
7
6
  static recordTypes: string[];
8
7
  static default: boolean;
9
8
  static defaultTransformer: typeof TwitterTransformer;
10
- static schema: z.ZodObject<{
11
- since: z.ZodOptional<z.ZodDate>;
12
- until: z.ZodOptional<z.ZodDate>;
13
- limit: z.ZodOptional<z.ZodNumber>;
9
+ static schema: import("zod").ZodObject<{
10
+ since: import("zod").ZodOptional<import("zod").ZodDate>;
11
+ until: import("zod").ZodOptional<import("zod").ZodDate>;
12
+ limit: import("zod").ZodOptional<import("zod").ZodNumber>;
14
13
  } & {
15
- input: z.ZodString;
16
- 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
17
- }, "strip", z.ZodTypeAny, {
14
+ input: import("zod").ZodString;
15
+ 'include-group-dms': import("zod").ZodDefault<import("zod").ZodOptional<import("zod").ZodBoolean>>;
16
+ }, "strip", import("zod").ZodTypeAny, {
18
17
  input: string;
19
18
  'include-group-dms': boolean;
20
19
  since?: Date | undefined;
@@ -31,6 +30,10 @@ export declare class TwitterArchiveTweetsExtractor extends TwitterArchiveExtract
31
30
  keyOf(record: Record): string | null;
32
31
  extract(): AsyncGenerator<Record>;
33
32
  private processTweets;
33
+ /** The archive's tweets within since and until, as it writes them. */
34
+ private loadTweets;
34
35
  determineCount(): Promise<number | null>;
36
+ /** The tweets a run reads and when they were posted. */
37
+ survey(options?: SurveyOptions): Promise<Survey>;
35
38
  }
36
39
  //# sourceMappingURL=TwitterArchiveTweetsExtractor.d.ts.map
@@ -1,4 +1,3 @@
1
- import { readFile } from 'node:fs/promises';
2
1
  import TwitterTransformer from '../TwitterTransformer.js';
3
2
  import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
3
  export class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor {
@@ -12,11 +11,10 @@ export class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor {
12
11
  return record.data.id ?? null;
13
12
  }
14
13
  async *extract() {
15
- const config = this.config;
16
14
  try {
17
15
  // Load account info first
18
- await this.loadAccountInfoFrom(config.input);
19
- yield* this.processTweets(`${config.input}/data/tweets.js`);
16
+ this.accountInfo = await this.loadAccountInfo();
17
+ yield* this.processTweets(`data/tweets.js`);
20
18
  }
21
19
  catch (error) {
22
20
  throw new Error(`Failed to process Twitter tweets: ${error}`);
@@ -24,39 +22,40 @@ export class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor {
24
22
  }
25
23
  async *processTweets(filePath) {
26
24
  try {
27
- const content = await readFile(filePath, 'utf-8');
28
- const jsData = this.parseTwitterJS(content, 'tweets');
29
25
  let count = 0;
30
- for (const item of jsData) {
31
- const { tweet } = item;
32
- if (tweet && this.isWithinDateRange(new Date(tweet.created_at))) {
33
- if (this.shouldStopExtracting(count))
34
- break;
35
- yield this.createRecordWithArchiveContext({
36
- type: 'tweet',
37
- id: tweet.id_str,
38
- text: tweet.full_text || tweet.text,
39
- created_at: this.convertTwitterDateToISO(tweet.created_at),
40
- favorite_count: Number.parseInt(tweet.favorite_count) || 0,
41
- retweet_count: Number.parseInt(tweet.retweet_count) || 0,
42
- in_reply_to_status_id: tweet.in_reply_to_status_id_str,
43
- in_reply_to_user_id: tweet.in_reply_to_user_id_str,
44
- entities: tweet.entities,
45
- source: tweet.source,
46
- retweeted: tweet.retweeted,
47
- }, { originalData: tweet });
48
- count++;
49
- }
26
+ for (const tweet of await this.loadTweets(filePath)) {
27
+ if (this.shouldStopExtracting(count))
28
+ break;
29
+ yield this.createRecordWithArchiveContext({
30
+ type: 'tweet',
31
+ id: tweet.id_str,
32
+ text: tweet.full_text || tweet.text,
33
+ created_at: this.convertTwitterDateToISO(tweet.created_at),
34
+ favorite_count: Number.parseInt(tweet.favorite_count) || 0,
35
+ retweet_count: Number.parseInt(tweet.retweet_count) || 0,
36
+ in_reply_to_status_id: tweet.in_reply_to_status_id_str,
37
+ in_reply_to_user_id: tweet.in_reply_to_user_id_str,
38
+ entities: tweet.entities,
39
+ source: tweet.source,
40
+ retweeted: tweet.retweeted,
41
+ }, { originalData: tweet });
42
+ count++;
50
43
  }
51
44
  }
52
45
  catch (error) {
53
- console.warn(`Could not process tweets: ${error}`);
46
+ this.logger.warn('Could not process tweets', { error: String(error) });
54
47
  }
55
48
  }
49
+ /** The archive's tweets within since and until, as it writes them. */
50
+ async loadTweets(filePath) {
51
+ const content = await this.files.readText(filePath);
52
+ return this.parseTwitterJS(content, 'tweets')
53
+ .map(item => item.tweet)
54
+ .filter(tweet => tweet && this.isWithinDateRange(new Date(tweet.created_at)));
55
+ }
56
56
  async determineCount() {
57
- const config = this.config;
58
57
  try {
59
- const tweetsContent = await readFile(`${config.input}/data/tweets.js`, 'utf-8');
58
+ const tweetsContent = await this.files.readText(`data/tweets.js`);
60
59
  const tweetsData = this.parseTwitterJS(tweetsContent, 'tweets');
61
60
  return tweetsData.length;
62
61
  }
@@ -64,4 +63,11 @@ export class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor {
64
63
  return null;
65
64
  }
66
65
  }
66
+ /** The tweets a run reads and when they were posted. */
67
+ async survey(options = {}) {
68
+ const tweets = await this.loadTweets(`data/tweets.js`);
69
+ return {
70
+ tweets: this.surveyOf(tweets.map(tweet => new Date(tweet.created_at)), options),
71
+ };
72
+ }
67
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/twitter",
3
- "version": "0.3.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 twitter",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.3.0",
26
- "@chronicle.app/schema": "0.3.0",
27
- "@chronicle.app/tsconfig": "0.3.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
  },
@@ -61,6 +61,20 @@
61
61
  "strategies": {
62
62
  "archive": {
63
63
  "delivery": "export",
64
+ "layouts": [
65
+ {
66
+ "files": [
67
+ "data/account.js",
68
+ "data/tweets.js"
69
+ ],
70
+ "match": [
71
+ {
72
+ "path": "data/account.js",
73
+ "head": "^window\\.YTD\\.account\\.part0"
74
+ }
75
+ ]
76
+ }
77
+ ],
64
78
  "recordTypes": [
65
79
  "direct-messages",
66
80
  "group-direct-messages",