@chronicle.app/twitter 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @chronicle.app/twitter
2
+
3
+ Chronicle plugin for twitter
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/twitter
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic extraction
14
+
15
+ ```bash
16
+ chronicle extract twitter --input /path/to/twitter/export
17
+ ```
18
+
19
+ ### With date filtering
20
+
21
+ ```bash
22
+ chronicle extract twitter \
23
+ --input /path/to/twitter/export \
24
+ --since 2024-01-01 \
25
+ --until 2024-12-31 \
26
+ --limit 1000
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ The extractor supports the following options:
32
+
33
+ - `--input` (required): Path to the Twitter export data directory
34
+ - `--type`: Which record types to read: `tweets`, `likes`, `direct-messages`,
35
+ `group-direct-messages` (default: all of them)
36
+ - `--include-group-dms`: Include group direct messages (default: true)
37
+ - `--since`: Extract records from this date onwards
38
+ - `--until`: Extract records up to this date
39
+ - `--limit`: Maximum number of records to extract
40
+
41
+ ## Development
42
+
43
+ ```bash
44
+ # Build the plugin
45
+ npm run build
46
+
47
+ # Run the tests
48
+ npm test
49
+ ```
50
+
51
+ ## File Format
52
+
53
+ The input is the unzipped directory of a Twitter/X data archive. The plugin
54
+ reads these files from its `data/` folder, each a `window.YTD.<name>.part0 = [...]`
55
+ script wrapping a JSON array:
56
+
57
+ - `account.js`: the archive owner's account ID, username, and display name
58
+ - `tweets.js`: your tweets
59
+ - `like.js`: the tweets you liked
60
+ - `direct-messages.js`: one-to-one direct messages
61
+ - `direct-messages-group.js` and `direct-message-group-headers.js`: group direct
62
+ messages and their participants
63
+
64
+ ## Schema
65
+
66
+ - Each tweet is a `PublishAction` whose object is a `Post`. The post's
67
+ `references` are the accounts it mentions and the links it contains.
68
+ - Each like is a `LikeAction` on the liked `Post`.
69
+ - Each direct message is a `MessageAction` whose object is a `Message`, sent to
70
+ the other participants of the conversation.
71
+
72
+ The archive owner is an `Agent` linked to `@me`; other accounts are `Agent`s
73
+ keyed by their Twitter user ID.
74
+
75
+ Tests build a small synthetic archive in a temporary directory and never read
76
+ the host's data or accounts.
@@ -0,0 +1,14 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class TwitterTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildTweetActions;
6
+ private buildLikeActions;
7
+ private buildDirectMessageActions;
8
+ private buildAccountUser;
9
+ private buildPersonFromUserId;
10
+ private extractMentionedUsersFromEntities;
11
+ private extractUrlReferencesFromEntities;
12
+ private buildGroupDirectMessageActions;
13
+ }
14
+ //# sourceMappingURL=TwitterTransformer.d.ts.map
@@ -0,0 +1,213 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ export default class TwitterTransformer extends ChronicleTransformer {
3
+ async transform(record) {
4
+ const actions = [];
5
+ switch (record.extraction.recordType) {
6
+ case 'tweets':
7
+ actions.push(...(await this.buildTweetActions(record)));
8
+ break;
9
+ case 'likes':
10
+ actions.push(...this.buildLikeActions(record));
11
+ break;
12
+ case 'direct-messages':
13
+ actions.push(...this.buildDirectMessageActions(record));
14
+ break;
15
+ case 'group-direct-messages':
16
+ actions.push(...this.buildGroupDirectMessageActions(record));
17
+ break;
18
+ }
19
+ return actions;
20
+ }
21
+ async buildTweetActions(record) {
22
+ const actions = [];
23
+ const user = this.buildAccountUser(record);
24
+ const mentions = this.extractMentionedUsersFromEntities(record.data.entities, record.data.text);
25
+ const urls = this.extractUrlReferencesFromEntities(record.data.entities, record.data.text);
26
+ const references = [...mentions, ...urls];
27
+ const tweet = {
28
+ '@type': 'Post',
29
+ '@key': ['@type', 'source', 'sourceId'],
30
+ source: 'twitter',
31
+ sourceId: record.data.id,
32
+ body: record.data.text, // Keep original text with t.co URLs
33
+ author: [user],
34
+ ...(references.length > 0 && { references }),
35
+ };
36
+ const publishAction = {
37
+ '@type': 'PublishAction',
38
+ '@key': ['@type', 'source', 'sourceId'],
39
+ source: 'twitter',
40
+ sourceId: record.data.id,
41
+ timestamp: record.data.created_at,
42
+ agent: user,
43
+ object: tweet,
44
+ };
45
+ actions.push(publishAction);
46
+ return actions;
47
+ }
48
+ buildLikeActions(record) {
49
+ const actions = [];
50
+ const user = this.buildAccountUser(record);
51
+ const targetTweet = {
52
+ '@type': 'Post',
53
+ '@key': ['@type', 'source', 'sourceId'],
54
+ source: 'twitter',
55
+ sourceId: record.data.tweetId,
56
+ body: record.data.fullText,
57
+ };
58
+ const likeAction = {
59
+ '@type': 'LikeAction',
60
+ '@key': ['@type', 'source', 'sourceId'],
61
+ source: 'twitter',
62
+ sourceId: `${record.data.tweetId}-like`,
63
+ agent: user,
64
+ object: targetTweet,
65
+ };
66
+ actions.push(likeAction);
67
+ return actions;
68
+ }
69
+ buildDirectMessageActions(record) {
70
+ const actions = [];
71
+ const sender = this.buildPersonFromUserId(record.data.senderId);
72
+ const recipient = this.buildPersonFromUserId(record.data.recipientId);
73
+ const message = {
74
+ '@type': 'Message',
75
+ '@key': ['@type', 'source', 'sourceId'],
76
+ source: 'twitter',
77
+ sourceId: record.data.id,
78
+ body: record.data.text,
79
+ recipient: [recipient],
80
+ author: [sender],
81
+ };
82
+ // Media URLs would need to be handled separately or added to schema
83
+ const sendAction = {
84
+ '@type': 'MessageAction',
85
+ '@key': ['@type', 'source', 'sourceId'],
86
+ source: 'twitter',
87
+ sourceId: record.data.id,
88
+ timestamp: record.data.created_at,
89
+ agent: sender,
90
+ object: message,
91
+ };
92
+ actions.push(sendAction);
93
+ return actions;
94
+ }
95
+ buildAccountUser(record) {
96
+ // const strategy = record.context?.strategy || 'archive'; // Future use
97
+ const accountInfo = record.context?.accountInfo;
98
+ if (!accountInfo) {
99
+ throw new Error('Account information is required to build user agent. Ensure account.js is present in Twitter export.');
100
+ }
101
+ return {
102
+ '@type': 'Agent',
103
+ '@key': ['@type', 'source', 'sourceId'],
104
+ source: 'twitter',
105
+ sourceId: accountInfo.accountId,
106
+ handle: accountInfo.username,
107
+ name: accountInfo.accountDisplayName,
108
+ url: `https://twitter.com/${accountInfo.username}`,
109
+ sameAs: ['@me'],
110
+ };
111
+ }
112
+ buildPersonFromUserId(userId) {
113
+ return {
114
+ '@type': 'Agent',
115
+ '@key': ['@type', 'source', 'sourceId'],
116
+ source: 'twitter',
117
+ sourceId: userId,
118
+ };
119
+ }
120
+ extractMentionedUsersFromEntities(entities, text) {
121
+ const mentions = [];
122
+ // Primary: Use entities.user_mentions for structured data
123
+ if (entities?.user_mentions && entities.user_mentions.length > 0) {
124
+ for (const mention of entities.user_mentions) {
125
+ mentions.push({
126
+ '@type': 'Agent',
127
+ '@key': ['@type', 'source', 'sourceId'],
128
+ source: 'twitter',
129
+ sourceId: mention.id_str,
130
+ handle: mention.screen_name,
131
+ name: mention.name,
132
+ url: `https://twitter.com/${mention.screen_name}`,
133
+ });
134
+ }
135
+ }
136
+ else {
137
+ // Fallback: Use regex parsing for edge cases where entities is empty
138
+ const mentionRegex = /@([a-zA-Z0-9_]+)/g;
139
+ let match;
140
+ while ((match = mentionRegex.exec(text)) !== null) {
141
+ const username = match[1];
142
+ mentions.push({
143
+ '@type': 'Agent',
144
+ '@key': ['@type', 'source', 'handle'],
145
+ source: 'twitter',
146
+ handle: username,
147
+ url: `https://twitter.com/${username}`,
148
+ });
149
+ }
150
+ }
151
+ return mentions;
152
+ }
153
+ extractUrlReferencesFromEntities(entities, text) {
154
+ const urls = [];
155
+ // Primary: Use entities.urls for structured data
156
+ if (entities?.urls && entities.urls.length > 0) {
157
+ for (const urlEntity of entities.urls) {
158
+ // Use expanded URL if available, otherwise fall back to original
159
+ const finalUrl = urlEntity.expanded_url || urlEntity.url;
160
+ urls.push({
161
+ '@type': 'Entity',
162
+ '@key': ['@type', 'url'],
163
+ url: finalUrl,
164
+ });
165
+ }
166
+ }
167
+ else {
168
+ // Fallback: Use regex parsing for edge cases where entities is empty
169
+ const urlRegex = /https?:\/\/[^\s)]+/g;
170
+ let match;
171
+ while ((match = urlRegex.exec(text)) !== null) {
172
+ let url = match[0];
173
+ // Remove trailing punctuation that shouldn't be part of the URL
174
+ url = url.replace(/[).,;!?]+$/, '');
175
+ urls.push({
176
+ '@type': 'Entity',
177
+ '@key': ['@type', 'url'],
178
+ url,
179
+ });
180
+ }
181
+ }
182
+ return urls;
183
+ }
184
+ buildGroupDirectMessageActions(record) {
185
+ const actions = [];
186
+ const sender = this.buildPersonFromUserId(record.data.senderId);
187
+ // Build recipients from all participants except the sender
188
+ const participants = record.data.participants || [];
189
+ const recipients = participants
190
+ .filter((participantId) => participantId !== record.data.senderId)
191
+ .map((participantId) => this.buildPersonFromUserId(participantId));
192
+ const message = {
193
+ '@type': 'Message',
194
+ '@key': ['@type', 'source', 'sourceId'],
195
+ source: 'twitter',
196
+ sourceId: record.data.id,
197
+ body: record.data.text,
198
+ recipient: recipients,
199
+ author: [sender],
200
+ };
201
+ const sendAction = {
202
+ '@type': 'MessageAction',
203
+ '@key': ['@type', 'source', 'sourceId'],
204
+ source: 'twitter',
205
+ sourceId: record.data.id,
206
+ timestamp: record.data.created_at,
207
+ agent: sender,
208
+ object: message,
209
+ };
210
+ actions.push(sendAction);
211
+ return actions;
212
+ }
213
+ }
@@ -0,0 +1,38 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import TwitterTransformer from '../TwitterTransformer.js';
4
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
5
+ export declare class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtractor<typeof TwitterArchiveDirectMessagesExtractor> {
6
+ static description: string;
7
+ static recordTypes: string[];
8
+ static defaultTransformer: typeof TwitterTransformer;
9
+ static newestFirst: boolean;
10
+ static schema: z.ZodObject<{
11
+ since: z.ZodOptional<z.ZodDate>;
12
+ until: z.ZodOptional<z.ZodDate>;
13
+ limit: z.ZodOptional<z.ZodNumber>;
14
+ } & {
15
+ input: z.ZodString;
16
+ 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ input: string;
19
+ 'include-group-dms': boolean;
20
+ since?: Date | undefined;
21
+ until?: Date | undefined;
22
+ limit?: number | undefined;
23
+ }, {
24
+ input: string;
25
+ 'include-group-dms'?: boolean | undefined;
26
+ since?: Date | undefined;
27
+ until?: Date | undefined;
28
+ limit?: number | undefined;
29
+ }>;
30
+ /** The DM's message id — Twitter's own id, identical through any tool. */
31
+ keyOf(record: Record): string | null;
32
+ extract(): AsyncGenerator<Record>;
33
+ private loadDirectMessages;
34
+ private loadGroupHeaders;
35
+ private loadGroupDirectMessages;
36
+ determineCount(): Promise<number | null>;
37
+ }
38
+ //# sourceMappingURL=TwitterArchiveDirectMessagesExtractor.d.ts.map
@@ -0,0 +1,193 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import TwitterTransformer from '../TwitterTransformer.js';
3
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
+ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtractor {
5
+ static description = 'Direct messages';
6
+ static recordTypes = ['direct-messages', 'group-direct-messages'];
7
+ static defaultTransformer = TwitterTransformer;
8
+ // extract() sorts every message by created_at descending before yielding,
9
+ // which with keyOf opts this extractor into the frontier cursor.
10
+ static newestFirst = true;
11
+ static schema = TwitterArchiveExtractor.schema;
12
+ /** The DM's message id — Twitter's own id, identical through any tool. */
13
+ keyOf(record) {
14
+ return record.data.id ?? null;
15
+ }
16
+ async *extract() {
17
+ const config = this.config;
18
+ try {
19
+ // Load account info first
20
+ await this.loadAccountInfoFrom(config.input);
21
+ // 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
+ }
31
+ // Sort by timestamp descending (newest first)
32
+ allMessages.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
33
+ // Yield messages in chronological order (newest first)
34
+ let count = 0;
35
+ for (const message of allMessages) {
36
+ if (this.shouldStopExtracting(count))
37
+ break;
38
+ if (this.isWithinDateRange(new Date(message.created_at))) {
39
+ yield this.createRecordWithArchiveContext(message.data, message.metadata);
40
+ count++;
41
+ }
42
+ }
43
+ }
44
+ catch (error) {
45
+ throw new Error(`Failed to process Twitter direct messages: ${error}`);
46
+ }
47
+ }
48
+ async loadDirectMessages(filePath) {
49
+ const messages = [];
50
+ try {
51
+ const content = await readFile(filePath, 'utf-8');
52
+ const jsData = this.parseTwitterJS(content, 'direct_messages');
53
+ for (const item of jsData) {
54
+ const conversation = item.dmConversation;
55
+ if (conversation && conversation.messages) {
56
+ for (const message of conversation.messages) {
57
+ const msg = message.messageCreate;
58
+ if (msg) {
59
+ messages.push({
60
+ created_at: msg.createdAt,
61
+ data: {
62
+ type: 'direct-message',
63
+ id: msg.id,
64
+ conversationId: conversation.conversationId,
65
+ senderId: msg.senderId,
66
+ recipientId: msg.recipientId,
67
+ text: msg.text,
68
+ created_at: msg.createdAt,
69
+ mediaUrls: msg.mediaUrls,
70
+ urls: msg.urls,
71
+ },
72
+ metadata: { originalData: msg },
73
+ });
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ catch (error) {
80
+ console.warn(`Could not process 1:1 direct messages: ${error}`);
81
+ }
82
+ return messages;
83
+ }
84
+ async loadGroupHeaders(filePath) {
85
+ const groupHeaders = new Map();
86
+ try {
87
+ const content = await readFile(filePath, 'utf-8');
88
+ const jsData = this.parseTwitterJS(content, 'direct_message_group_headers');
89
+ for (const item of jsData) {
90
+ const conversation = item.dmConversation;
91
+ if (conversation) {
92
+ // Find the joinConversation event to get participants
93
+ const joinEvent = conversation.messages?.find((msg) => msg.joinConversation);
94
+ if (joinEvent) {
95
+ groupHeaders.set(conversation.conversationId, {
96
+ conversationId: conversation.conversationId,
97
+ participants: joinEvent.joinConversation.participantsSnapshot || [],
98
+ initiator: joinEvent.joinConversation.initiatingUserId,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ catch (error) {
105
+ console.warn(`Could not load group headers: ${error}`);
106
+ }
107
+ return groupHeaders;
108
+ }
109
+ async loadGroupDirectMessages(filePath) {
110
+ const messages = [];
111
+ try {
112
+ // First load group headers to get participant info
113
+ const groupHeaders = await this.loadGroupHeaders(`${filePath.replace('direct-messages-group.js', 'direct-message-group-headers.js')}`);
114
+ const content = await readFile(filePath, 'utf-8');
115
+ const jsData = this.parseTwitterJS(content, 'direct_messages_group');
116
+ for (const item of jsData) {
117
+ const conversation = item.dmConversation;
118
+ if (conversation && conversation.messages) {
119
+ const groupInfo = groupHeaders.get(conversation.conversationId);
120
+ for (const message of conversation.messages) {
121
+ // Only process messageCreate events (actual messages), skip other events
122
+ if (message.messageCreate) {
123
+ const msg = message.messageCreate;
124
+ messages.push({
125
+ created_at: msg.createdAt,
126
+ data: {
127
+ type: 'group-direct-message',
128
+ id: msg.id,
129
+ conversationId: conversation.conversationId,
130
+ senderId: msg.senderId,
131
+ participants: groupInfo?.participants || [],
132
+ text: msg.text || '',
133
+ created_at: msg.createdAt,
134
+ mediaUrls: msg.mediaUrls || [],
135
+ urls: msg.urls || [],
136
+ },
137
+ metadata: {
138
+ recordType: 'group-direct-messages',
139
+ originalData: msg,
140
+ groupInfo,
141
+ },
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ catch (error) {
149
+ console.warn(`Could not process group direct messages: ${error}`);
150
+ }
151
+ return messages;
152
+ }
153
+ async determineCount() {
154
+ const config = this.config;
155
+ try {
156
+ let totalMessages = 0;
157
+ // Count 1:1 direct messages
158
+ try {
159
+ const dmContent = await readFile(`${config.input}/data/direct-messages.js`, 'utf-8');
160
+ const dmData = this.parseTwitterJS(dmContent, 'direct_messages');
161
+ for (const item of dmData) {
162
+ const conversation = item.dmConversation;
163
+ if (conversation && conversation.messages) {
164
+ totalMessages += conversation.messages.filter((msg) => msg.messageCreate).length;
165
+ }
166
+ }
167
+ }
168
+ catch {
169
+ // 1:1 DMs file may not exist
170
+ }
171
+ // Count group direct messages (if enabled)
172
+ if (config['include-group-dms']) {
173
+ try {
174
+ const groupContent = await readFile(`${config.input}/data/direct-messages-group.js`, 'utf-8');
175
+ const groupData = this.parseTwitterJS(groupContent, 'direct_messages_group');
176
+ for (const item of groupData) {
177
+ const conversation = item.dmConversation;
178
+ if (conversation && conversation.messages) {
179
+ totalMessages += conversation.messages.filter((msg) => msg.messageCreate).length;
180
+ }
181
+ }
182
+ }
183
+ catch {
184
+ // Group DMs file may not exist
185
+ }
186
+ }
187
+ return totalMessages;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,36 @@
1
+ import { ArchiveExtractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ export declare abstract class TwitterArchiveExtractor<T extends typeof TwitterArchiveExtractor> extends ArchiveExtractor<T> {
4
+ static source: string;
5
+ static strategy: string;
6
+ static schema: z.ZodObject<{
7
+ since: z.ZodOptional<z.ZodDate>;
8
+ until: z.ZodOptional<z.ZodDate>;
9
+ limit: z.ZodOptional<z.ZodNumber>;
10
+ } & {
11
+ input: z.ZodString;
12
+ 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ input: string;
15
+ 'include-group-dms': boolean;
16
+ since?: Date | undefined;
17
+ until?: Date | undefined;
18
+ limit?: number | undefined;
19
+ }, {
20
+ input: string;
21
+ 'include-group-dms'?: boolean | undefined;
22
+ since?: Date | undefined;
23
+ until?: Date | undefined;
24
+ limit?: number | undefined;
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>;
33
+ protected parseTwitterJS(content: string, dataType: string): any[];
34
+ protected convertTwitterDateToISO(twitterDate: string): string;
35
+ }
36
+ //# sourceMappingURL=TwitterArchiveExtractor.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { ArchiveExtractor, Extractor } from '@chronicle.app/etl';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { z } from 'zod';
4
+ export class TwitterArchiveExtractor extends ArchiveExtractor {
5
+ static source = 'twitter';
6
+ static strategy = 'archive';
7
+ static schema = Extractor.schema.extend({
8
+ input: z.string().describe('Path to Twitter export data directory'),
9
+ 'include-group-dms': z
10
+ .boolean()
11
+ .optional()
12
+ .default(true)
13
+ .describe('Include group direct messages in extraction'),
14
+ });
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) {
22
+ try {
23
+ const content = await readFile(`${basePath}/data/account.js`, 'utf-8');
24
+ const jsData = this.parseTwitterJS(content, 'account');
25
+ this.accountInfo = jsData[0]?.account || null;
26
+ }
27
+ catch (error) {
28
+ console.warn(`Could not load account info: ${error}`);
29
+ this.accountInfo = null;
30
+ }
31
+ }
32
+ parseTwitterJS(content, dataType) {
33
+ const regex = new RegExp(`window\\.YTD\\.${dataType}\\.part0\\s*=\\s*(.+)`, 's');
34
+ const match = content.match(regex);
35
+ if (!match) {
36
+ throw new Error(`Could not parse ${dataType} data from Twitter export`);
37
+ }
38
+ try {
39
+ return JSON.parse(match[1]);
40
+ }
41
+ catch (error) {
42
+ throw new Error(`Invalid JSON in ${dataType} file: ${error}`);
43
+ }
44
+ }
45
+ convertTwitterDateToISO(twitterDate) {
46
+ // Convert Twitter's date format "Sat Jan 11 17:10:28 +0000 2025" to ISO
47
+ // Twitter uses format: "ddd MMM DD HH:mm:ss +0000 YYYY"
48
+ const date = new Date(twitterDate);
49
+ return date.toISOString();
50
+ }
51
+ }
@@ -0,0 +1,35 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import TwitterTransformer from '../TwitterTransformer.js';
4
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
5
+ export declare class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor<typeof TwitterArchiveLikesExtractor> {
6
+ static description: string;
7
+ static recordTypes: string[];
8
+ 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>;
13
+ } & {
14
+ input: z.ZodString;
15
+ 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
16
+ }, "strip", z.ZodTypeAny, {
17
+ input: string;
18
+ 'include-group-dms': boolean;
19
+ since?: Date | undefined;
20
+ until?: Date | undefined;
21
+ limit?: number | undefined;
22
+ }, {
23
+ input: string;
24
+ 'include-group-dms'?: boolean | undefined;
25
+ since?: Date | undefined;
26
+ until?: Date | undefined;
27
+ limit?: number | undefined;
28
+ }>;
29
+ /** The liked tweet's id — the like entry's only id, and Twitter's own. */
30
+ keyOf(record: Record): string | null;
31
+ extract(): AsyncGenerator<Record>;
32
+ private processLikes;
33
+ determineCount(): Promise<number | null>;
34
+ }
35
+ //# sourceMappingURL=TwitterArchiveLikesExtractor.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import TwitterTransformer from '../TwitterTransformer.js';
3
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
+ export class TwitterArchiveLikesExtractor extends TwitterArchiveExtractor {
5
+ static description = 'Likes';
6
+ static recordTypes = ['likes'];
7
+ static defaultTransformer = TwitterTransformer;
8
+ static schema = TwitterArchiveExtractor.schema;
9
+ /** The liked tweet's id — the like entry's only id, and Twitter's own. */
10
+ keyOf(record) {
11
+ return record.data.tweetId ?? null;
12
+ }
13
+ async *extract() {
14
+ const config = this.config;
15
+ try {
16
+ // Load account info first
17
+ await this.loadAccountInfoFrom(config.input);
18
+ yield* this.processLikes(`${config.input}/data/like.js`);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`Failed to process Twitter likes: ${error}`);
22
+ }
23
+ }
24
+ async *processLikes(filePath) {
25
+ try {
26
+ const content = await readFile(filePath, 'utf-8');
27
+ const jsData = this.parseTwitterJS(content, 'like');
28
+ 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
+ }
42
+ }
43
+ }
44
+ catch (error) {
45
+ console.warn(`Could not process likes: ${error}`);
46
+ }
47
+ }
48
+ async determineCount() {
49
+ const config = this.config;
50
+ try {
51
+ const likesContent = await readFile(`${config.input}/data/like.js`, 'utf-8');
52
+ const likesData = this.parseTwitterJS(likesContent, 'like');
53
+ return likesData.length;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,36 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import TwitterTransformer from '../TwitterTransformer.js';
4
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
5
+ export declare class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor<typeof TwitterArchiveTweetsExtractor> {
6
+ static description: string;
7
+ static recordTypes: string[];
8
+ static default: boolean;
9
+ 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>;
14
+ } & {
15
+ input: z.ZodString;
16
+ 'include-group-dms': z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ input: string;
19
+ 'include-group-dms': boolean;
20
+ since?: Date | undefined;
21
+ until?: Date | undefined;
22
+ limit?: number | undefined;
23
+ }, {
24
+ input: string;
25
+ 'include-group-dms'?: boolean | undefined;
26
+ since?: Date | undefined;
27
+ until?: Date | undefined;
28
+ limit?: number | undefined;
29
+ }>;
30
+ /** The tweet id (`id_str`) — Twitter's own id, identical through any tool. */
31
+ keyOf(record: Record): string | null;
32
+ extract(): AsyncGenerator<Record>;
33
+ private processTweets;
34
+ determineCount(): Promise<number | null>;
35
+ }
36
+ //# sourceMappingURL=TwitterArchiveTweetsExtractor.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import TwitterTransformer from '../TwitterTransformer.js';
3
+ import { TwitterArchiveExtractor } from './TwitterArchiveExtractor.js';
4
+ export class TwitterArchiveTweetsExtractor extends TwitterArchiveExtractor {
5
+ static description = 'Tweets';
6
+ static recordTypes = ['tweets'];
7
+ static default = true;
8
+ static defaultTransformer = TwitterTransformer;
9
+ static schema = TwitterArchiveExtractor.schema;
10
+ /** The tweet id (`id_str`) — Twitter's own id, identical through any tool. */
11
+ keyOf(record) {
12
+ return record.data.id ?? null;
13
+ }
14
+ async *extract() {
15
+ const config = this.config;
16
+ try {
17
+ // Load account info first
18
+ await this.loadAccountInfoFrom(config.input);
19
+ yield* this.processTweets(`${config.input}/data/tweets.js`);
20
+ }
21
+ catch (error) {
22
+ throw new Error(`Failed to process Twitter tweets: ${error}`);
23
+ }
24
+ }
25
+ async *processTweets(filePath) {
26
+ try {
27
+ const content = await readFile(filePath, 'utf-8');
28
+ const jsData = this.parseTwitterJS(content, 'tweets');
29
+ 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
+ }
50
+ }
51
+ }
52
+ catch (error) {
53
+ console.warn(`Could not process tweets: ${error}`);
54
+ }
55
+ }
56
+ async determineCount() {
57
+ const config = this.config;
58
+ try {
59
+ const tweetsContent = await readFile(`${config.input}/data/tweets.js`, 'utf-8');
60
+ const tweetsData = this.parseTwitterJS(tweetsContent, 'tweets');
61
+ return tweetsData.length;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,6 @@
1
+ export { TwitterArchiveExtractor } from './connectors/archive/TwitterArchiveExtractor.js';
2
+ export { TwitterArchiveTweetsExtractor } from './connectors/archive/TwitterArchiveTweetsExtractor.js';
3
+ export { TwitterArchiveLikesExtractor } from './connectors/archive/TwitterArchiveLikesExtractor.js';
4
+ export { TwitterArchiveDirectMessagesExtractor } from './connectors/archive/TwitterArchiveDirectMessagesExtractor.js';
5
+ export { default as TwitterTransformer } from './connectors/TwitterTransformer.js';
6
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { TwitterArchiveExtractor } from './connectors/archive/TwitterArchiveExtractor.js';
2
+ export { TwitterArchiveTweetsExtractor } from './connectors/archive/TwitterArchiveTweetsExtractor.js';
3
+ export { TwitterArchiveLikesExtractor } from './connectors/archive/TwitterArchiveLikesExtractor.js';
4
+ export { TwitterArchiveDirectMessagesExtractor } from './connectors/archive/TwitterArchiveDirectMessagesExtractor.js';
5
+ export { default as TwitterTransformer } from './connectors/TwitterTransformer.js';
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@chronicle.app/twitter",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "twitter"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for twitter",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/twitter"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "zod": "^3.24.1"
43
+ },
44
+ "peerDependencies": {
45
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
46
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.13.0"
50
+ },
51
+ "exports": {
52
+ ".": {
53
+ "types": "./dist/index.d.ts",
54
+ "import": "./dist/index.js"
55
+ }
56
+ },
57
+ "chronicle": {
58
+ "plugin": true,
59
+ "sources": {
60
+ "twitter": {
61
+ "strategies": {
62
+ "archive": {
63
+ "delivery": "export",
64
+ "recordTypes": [
65
+ "direct-messages",
66
+ "group-direct-messages",
67
+ "likes",
68
+ "tweets"
69
+ ],
70
+ "default": true
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }