@chronicle.app/twitter 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.
- package/README.md +4 -4
- package/dist/connectors/TwitterTransformer.js +11 -11
- package/dist/connectors/archive/TwitterArchiveDirectMessagesExtractor.d.ts +5 -1
- package/dist/connectors/archive/TwitterArchiveDirectMessagesExtractor.js +39 -21
- package/dist/connectors/archive/TwitterArchiveExtractor.d.ts +6 -8
- package/dist/connectors/archive/TwitterArchiveExtractor.js +21 -13
- package/dist/connectors/archive/TwitterArchiveLikesExtractor.d.ts +12 -9
- package/dist/connectors/archive/TwitterArchiveLikesExtractor.js +24 -21
- package/dist/connectors/archive/TwitterArchiveTweetsExtractor.d.ts +12 -9
- package/dist/connectors/archive/TwitterArchiveTweetsExtractor.js +34 -28
- package/package.json +18 -4
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):
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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;
|
|
@@ -84,7 +83,7 @@ export class TwitterArchiveDirectMessagesExtractor extends TwitterArchiveExtract
|
|
|
84
83
|
async loadGroupHeaders(filePath) {
|
|
85
84
|
const groupHeaders = new Map();
|
|
86
85
|
try {
|
|
87
|
-
const content = await
|
|
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;
|
|
@@ -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
|
|
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;
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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('
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
18
|
+
const content = await this.files.readText('data/account.js');
|
|
24
19
|
const jsData = this.parseTwitterJS(content, 'account');
|
|
25
|
-
|
|
20
|
+
return jsData[0]?.account || null;
|
|
26
21
|
}
|
|
27
22
|
catch (error) {
|
|
28
23
|
this.logger.warn('Could not load account info', { error: String(error) });
|
|
29
|
-
|
|
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:
|
|
10
|
-
since:
|
|
11
|
-
until:
|
|
12
|
-
limit:
|
|
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:
|
|
15
|
-
'include-group-dms':
|
|
16
|
-
}, "strip",
|
|
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.
|
|
18
|
-
yield* this.processLikes(
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
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
|
|
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:
|
|
11
|
-
since:
|
|
12
|
-
until:
|
|
13
|
-
limit:
|
|
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:
|
|
16
|
-
'include-group-dms':
|
|
17
|
-
}, "strip",
|
|
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.
|
|
19
|
-
yield* this.processTweets(
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
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
|
|
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
|
+
"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.
|
|
26
|
-
"@chronicle.app/schema": "0.
|
|
27
|
-
"@chronicle.app/tsconfig": "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",
|