@chronicle.app/facebook 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,64 @@
1
+ # @chronicle.app/facebook
2
+
3
+ Chronicle plugin for facebook
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/facebook
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic extraction
14
+
15
+ ```bash
16
+ chronicle extract facebook --input /path/to/facebook/file
17
+ ```
18
+
19
+ This reads Messenger conversations, the default. Pass `--type reactions`, `--type comments` or `--type searches` for the rest of the export.
20
+
21
+ ### With date filtering
22
+
23
+ ```bash
24
+ chronicle extract facebook \
25
+ --type messages \
26
+ --input /path/to/facebook/file \
27
+ --since 2024-01-01 \
28
+ --until 2024-12-31 \
29
+ --limit 1000
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ The extractor supports the following options:
35
+
36
+ - `--input` (required): Path to the facebook file
37
+ - `--type` (required): What to read: `messages`, `reactions`, `comments`, or `searches`
38
+ - `--since`: Extract records from this date onwards
39
+ - `--until`: Extract records up to this date
40
+ - `--limit`: Maximum number of records to extract
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ # Install dependencies
46
+ npm install
47
+
48
+ # Build the plugin
49
+ npm run build
50
+ ```
51
+
52
+ ## File Format
53
+
54
+ TODO: Document the expected file format and any parsing specifics.
55
+
56
+ ## Schema
57
+
58
+ TODO: Document what Chronicle schema types are generated from this data source.
59
+
60
+ ## Tests
61
+
62
+ Tests write a small synthetic Facebook export to a temporary directory and run
63
+ it through each extractor and the transformer. They never read the host's data
64
+ or accounts.
@@ -0,0 +1,35 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class FacebookTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildMessageActions;
6
+ private buildMessageSender;
7
+ private buildMessage;
8
+ private buildSearchActions;
9
+ /**
10
+ * The like button routes to LikeAction — payload-free, driving the boolean
11
+ * `liked` run — while the emotive reactions (LOVE/HAHA/WOW/SAD/ANGRY/CARE)
12
+ * are payloads: RespondAction → result Response carrying the export's own
13
+ * reaction word verbatim in `body` (never mapped to an emoji or a sentiment;
14
+ * that is a derivation, not data). A record whose reaction type didn't parse
15
+ * keeps today's LikeAction routing rather than minting a fabricated payload.
16
+ */
17
+ private buildReactionActions;
18
+ /**
19
+ * The reacted-to post. The label_values export format carries its URL and
20
+ * owner; the data-array format carries the owner alone, leaving the post as
21
+ * weak as the export makes it.
22
+ */
23
+ private buildReactionTarget;
24
+ /**
25
+ * A comment is a prose response — RespondAction → result Comment (the
26
+ * Response leaf), joining the same family as reactions. The action carries
27
+ * no `object`: the export's only pointer to the commented-on post is a
28
+ * localized prose title ("X commented on Y's post."), and scraping a name
29
+ * out of that sentence would break silently on non-English archives — so
30
+ * the edge is left unasserted rather than fabricated.
31
+ */
32
+ private buildCommentActions;
33
+ private buildFacebookAgent;
34
+ }
35
+ //# sourceMappingURL=FacebookTransformer.d.ts.map
@@ -0,0 +1,238 @@
1
+ import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
2
+ export default class FacebookTransformer extends ChronicleTransformer {
3
+ async transform(record) {
4
+ const actions = [];
5
+ switch (record.extraction.recordType) {
6
+ case 'messages': {
7
+ actions.push(...this.buildMessageActions(record));
8
+ break;
9
+ }
10
+ case 'searches': {
11
+ actions.push(...this.buildSearchActions(record));
12
+ break;
13
+ }
14
+ case 'reactions': {
15
+ actions.push(...this.buildReactionActions(record));
16
+ break;
17
+ }
18
+ case 'comments': {
19
+ actions.push(...this.buildCommentActions(record));
20
+ break;
21
+ }
22
+ // No default
23
+ }
24
+ return actions;
25
+ }
26
+ buildMessageActions(record) {
27
+ const actions = [];
28
+ if (!record.data.timestamp) {
29
+ return actions;
30
+ }
31
+ try {
32
+ const sender = this.buildMessageSender(record);
33
+ const message = this.buildMessage(record);
34
+ const messageAction = {
35
+ '@type': 'MessageAction',
36
+ timestamp: record.data.timestamp,
37
+ '@key': ['@type', 'source', 'timestamp', 'agent.name', 'object.body'],
38
+ source: 'facebook',
39
+ agent: sender,
40
+ object: message,
41
+ };
42
+ actions.push(messageAction);
43
+ }
44
+ catch {
45
+ // Skip messages where we can't identify the sender
46
+ // Skipping message due to unidentifiable sender
47
+ }
48
+ return actions;
49
+ }
50
+ buildMessageSender(record) {
51
+ // Improve name resolution - try to find a better name than "Unknown"
52
+ let { senderName } = record.data;
53
+ if (!senderName && record.data.participants?.length === 1) {
54
+ // If only one participant, that must be the sender
55
+ senderName = record.data.participants[0];
56
+ }
57
+ else if (!senderName && record.data.participants?.length === 2) {
58
+ // In a 2-person conversation, try to identify which participant is the sender
59
+ // This would require more context about the user's own name, which we don't have
60
+ // For now, we'll skip this message rather than use "Unknown"
61
+ senderName = null;
62
+ }
63
+ if (!senderName) {
64
+ // Skip messages without identifiable senders rather than using "Unknown"
65
+ throw new Error('Cannot identify sender for message');
66
+ }
67
+ return {
68
+ '@type': 'Agent',
69
+ '@key': ['@type', 'source', 'name'],
70
+ source: 'facebook',
71
+ name: senderName,
72
+ };
73
+ }
74
+ buildMessage(record) {
75
+ const recipients = record.data.participants
76
+ ?.filter((name) => name !== record.data.senderName)
77
+ ?.map((name) => ({
78
+ '@type': 'Agent',
79
+ '@key': ['@type', 'source', 'name'],
80
+ source: 'facebook',
81
+ name,
82
+ })) || [];
83
+ return {
84
+ '@type': 'Message',
85
+ '@key': ['@type', 'source', 'body', 'recipient'],
86
+ source: 'facebook',
87
+ body: record.data.content || '',
88
+ recipient: recipients,
89
+ };
90
+ }
91
+ buildSearchActions(record) {
92
+ const actions = [];
93
+ if (!record.data.timestamp || !record.data.searchText) {
94
+ return actions;
95
+ }
96
+ const agent = this.buildFacebookAgent();
97
+ const findAction = {
98
+ '@type': 'FindAction',
99
+ timestamp: record.data.timestamp,
100
+ '@key': ['@type', 'source', 'timestamp', 'agent.name', 'object.body'],
101
+ source: 'facebook',
102
+ agent,
103
+ object: {
104
+ '@type': 'Query',
105
+ '@key': ['@type', 'source', 'body'],
106
+ source: 'facebook',
107
+ name: record.data.searchText,
108
+ body: record.data.searchText,
109
+ },
110
+ };
111
+ actions.push(findAction);
112
+ return actions;
113
+ }
114
+ /**
115
+ * The like button routes to LikeAction — payload-free, driving the boolean
116
+ * `liked` run — while the emotive reactions (LOVE/HAHA/WOW/SAD/ANGRY/CARE)
117
+ * are payloads: RespondAction → result Response carrying the export's own
118
+ * reaction word verbatim in `body` (never mapped to an emoji or a sentiment;
119
+ * that is a derivation, not data). A record whose reaction type didn't parse
120
+ * keeps today's LikeAction routing rather than minting a fabricated payload.
121
+ */
122
+ buildReactionActions(record) {
123
+ const actions = [];
124
+ if (!record.data.timestamp) {
125
+ return actions;
126
+ }
127
+ const agent = this.buildFacebookAgent();
128
+ const target = this.buildReactionTarget(record);
129
+ const { reactionType } = record.data;
130
+ if (!reactionType || reactionType === 'like' || reactionType === 'unknown') {
131
+ const likeAction = {
132
+ '@type': 'LikeAction',
133
+ timestamp: record.data.timestamp,
134
+ '@key': ['@type', 'source', 'timestamp', 'agent.name', 'object.url'],
135
+ source: 'facebook',
136
+ agent,
137
+ object: target,
138
+ };
139
+ actions.push(likeAction);
140
+ return actions;
141
+ }
142
+ // The Response keys on real export data via `action.`-prefixed dot-paths
143
+ // into the enclosing action's roles — no synthesized id. `action.timestamp`
144
+ // stays in the key because the data-array export format carries no post
145
+ // URL: without it, every same-word reaction by the same agent would
146
+ // collapse into one artifact.
147
+ const response = {
148
+ '@type': 'Response',
149
+ '@key': [
150
+ '@type',
151
+ 'source',
152
+ 'body',
153
+ 'action.timestamp',
154
+ 'action.agent.name',
155
+ 'action.object.url',
156
+ ],
157
+ source: 'facebook',
158
+ body: reactionType,
159
+ };
160
+ const respondAction = {
161
+ '@type': 'RespondAction',
162
+ timestamp: record.data.timestamp,
163
+ '@key': ['@type', 'source', 'timestamp', 'agent.name', 'object.url', 'result.body'],
164
+ source: 'facebook',
165
+ agent,
166
+ object: target,
167
+ result: response,
168
+ };
169
+ actions.push(respondAction);
170
+ return actions;
171
+ }
172
+ /**
173
+ * The reacted-to post. The label_values export format carries its URL and
174
+ * owner; the data-array format carries the owner alone, leaving the post as
175
+ * weak as the export makes it.
176
+ */
177
+ buildReactionTarget(record) {
178
+ return {
179
+ '@type': 'Post',
180
+ '@key': ['@type', 'source', 'url'],
181
+ source: 'facebook',
182
+ url: record.data.contentUrl,
183
+ author: record.data.targetPerson
184
+ ? [
185
+ {
186
+ '@type': 'Person',
187
+ '@key': ['@type', 'source', 'name'],
188
+ source: 'facebook',
189
+ name: record.data.targetPerson,
190
+ },
191
+ ]
192
+ : undefined,
193
+ };
194
+ }
195
+ /**
196
+ * A comment is a prose response — RespondAction → result Comment (the
197
+ * Response leaf), joining the same family as reactions. The action carries
198
+ * no `object`: the export's only pointer to the commented-on post is a
199
+ * localized prose title ("X commented on Y's post."), and scraping a name
200
+ * out of that sentence would break silently on non-English archives — so
201
+ * the edge is left unasserted rather than fabricated.
202
+ */
203
+ buildCommentActions(record) {
204
+ const actions = [];
205
+ if (!record.data.timestamp || !record.data.commentText) {
206
+ return actions;
207
+ }
208
+ const agent = this.buildFacebookAgent(record.data.author);
209
+ const respondAction = {
210
+ '@type': 'RespondAction',
211
+ timestamp: record.data.timestamp,
212
+ '@key': ['@type', 'source', 'timestamp', 'agent.name', 'result.body'],
213
+ source: 'facebook',
214
+ agent,
215
+ result: {
216
+ '@type': 'Comment',
217
+ '@key': ['@type', 'source', 'body'],
218
+ source: 'facebook',
219
+ body: record.data.commentText,
220
+ author: [agent],
221
+ },
222
+ };
223
+ actions.push(respondAction);
224
+ return actions;
225
+ }
226
+ buildFacebookAgent(name) {
227
+ // This builder is only ever the account owner — their own searches,
228
+ // reactions, and comments. Message senders go through buildMessageSender
229
+ // (which can be the other party) and are left untagged. When the archive
230
+ // carries no account name there is nothing to key on, so the node falls
231
+ // back to the per-source singleton — identity is never invented.
232
+ return selfAgent({
233
+ type: 'Agent',
234
+ source: 'facebook',
235
+ ...(name ? { name, key: ['@type', 'source', 'name'] } : {}),
236
+ });
237
+ }
238
+ }
@@ -0,0 +1,36 @@
1
+ import { ArchiveExtractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ export declare abstract class FacebookArchiveExtractor extends ArchiveExtractor<typeof FacebookArchiveExtractor> {
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
+ }, "strip", z.ZodTypeAny, {
13
+ input: string;
14
+ since?: Date | undefined;
15
+ until?: Date | undefined;
16
+ limit?: number | undefined;
17
+ }, {
18
+ input: string;
19
+ since?: Date | undefined;
20
+ until?: Date | undefined;
21
+ limit?: number | undefined;
22
+ }>;
23
+ protected exportSegments: string[];
24
+ initialize(): Promise<void>;
25
+ protected discoverExportSegments(): Promise<void>;
26
+ protected loadAccountInfo(): Promise<any>;
27
+ protected iterateExportSegments(): AsyncGenerator<{
28
+ segmentPath: string;
29
+ facebookDir: string;
30
+ fullPath: string;
31
+ }>;
32
+ protected readFacebookJson(filePath: string): Promise<any>;
33
+ protected convertFacebookTimestamp(timestamp: number): string;
34
+ protected fixFacebookTextEncoding(text: string): string;
35
+ }
36
+ //# sourceMappingURL=FacebookArchiveExtractor.d.ts.map
@@ -0,0 +1,163 @@
1
+ import { ArchiveExtractor, Extractor } from '@chronicle.app/etl';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ export class FacebookArchiveExtractor extends ArchiveExtractor {
6
+ static source = 'facebook';
7
+ static strategy = 'archive';
8
+ static schema = Extractor.schema.extend({
9
+ input: z.string().describe('Path to the Facebook export directory'),
10
+ });
11
+ exportSegments = [];
12
+ async initialize() {
13
+ await this.discoverExportSegments();
14
+ this.accountInfo = await this.loadAccountInfo();
15
+ }
16
+ async discoverExportSegments() {
17
+ const config = this.config;
18
+ try {
19
+ const entries = await readdir(config.input, { withFileTypes: true });
20
+ // Check if this is a new format export (direct structure)
21
+ const hasDirectStructure = entries.some(entry => entry.isDirectory() &&
22
+ (entry.name === 'personal_information' || entry.name === 'your_facebook_activity'));
23
+ if (hasDirectStructure) {
24
+ // New format: direct structure
25
+ this.exportSegments = [config.input];
26
+ // Detected new Facebook export format (direct structure)
27
+ }
28
+ else {
29
+ // Old format: Find all export segment directories (meta-* or facebook-*)
30
+ this.exportSegments = entries
31
+ .filter(entry => entry.isDirectory())
32
+ .map(entry => entry.name)
33
+ .filter(name => name.startsWith('meta-') || name.startsWith('facebook-'))
34
+ .map(name => join(config.input, name));
35
+ // Discovered export segments (legacy format)
36
+ }
37
+ }
38
+ catch {
39
+ // Could not discover export segments
40
+ this.exportSegments = [config.input]; // Fallback to input path
41
+ }
42
+ }
43
+ async loadAccountInfo() {
44
+ // Try to find account info in any of the segments
45
+ for (const segmentPath of this.exportSegments) {
46
+ try {
47
+ // Check for new format (direct structure)
48
+ const directProfilePath = join(segmentPath, 'personal_information', 'profile_information', 'profile_information.json');
49
+ try {
50
+ const content = await readFile(directProfilePath, 'utf-8');
51
+ const data = JSON.parse(content);
52
+ return {
53
+ name: data.profile_v2?.name?.full_name || data.profile_v2?.name?.value || 'unknown',
54
+ email: data.profile_v2?.emails?.emails?.[0] ||
55
+ data.profile_v2?.emails?.primary_email ||
56
+ null,
57
+ username: data.profile_v2?.username || null,
58
+ phone: data.profile_v2?.phone_numbers?.[0]?.phone_number ||
59
+ data.profile_v2?.phone_numbers?.primary_phone ||
60
+ null,
61
+ };
62
+ }
63
+ catch {
64
+ // Not new format, try legacy format
65
+ }
66
+ // Legacy format: look for facebook-* subdirectories
67
+ const entries = await readdir(segmentPath, { withFileTypes: true });
68
+ const facebookDirs = entries
69
+ .filter(entry => entry.isDirectory() && entry.name.startsWith('facebook-'))
70
+ .map(entry => entry.name);
71
+ for (const fbDir of facebookDirs) {
72
+ try {
73
+ const personalInfoPath = join(segmentPath, fbDir, 'personal_information', 'profile_information', 'profile_information.json');
74
+ const content = await readFile(personalInfoPath, 'utf-8');
75
+ const data = JSON.parse(content);
76
+ return {
77
+ name: data.profile_v2?.name?.full_name || data.profile_v2?.name?.value || 'unknown',
78
+ email: data.profile_v2?.emails?.emails?.[0] ||
79
+ data.profile_v2?.emails?.primary_email ||
80
+ null,
81
+ username: data.profile_v2?.username || null,
82
+ phone: data.profile_v2?.phone_numbers?.[0]?.phone_number ||
83
+ data.profile_v2?.phone_numbers?.primary_phone ||
84
+ null,
85
+ };
86
+ }
87
+ catch {
88
+ // Continue to next directory
89
+ }
90
+ }
91
+ }
92
+ catch {
93
+ // Continue to next segment
94
+ }
95
+ }
96
+ // Could not load Facebook account info
97
+ return { name: 'unknown' };
98
+ }
99
+ async *iterateExportSegments() {
100
+ for (const segmentPath of this.exportSegments) {
101
+ try {
102
+ // Check if this is new format (direct structure)
103
+ const entries = await readdir(segmentPath, { withFileTypes: true });
104
+ const hasDirectStructure = entries.some(entry => entry.isDirectory() &&
105
+ (entry.name === 'personal_information' || entry.name === 'your_facebook_activity'));
106
+ if (hasDirectStructure) {
107
+ // New format: yield the segment path directly
108
+ yield { segmentPath, facebookDir: '', fullPath: segmentPath };
109
+ }
110
+ else {
111
+ // Legacy format: look for facebook-* subdirectories
112
+ const facebookDirs = entries
113
+ .filter(entry => entry.isDirectory() && entry.name.startsWith('facebook-'))
114
+ .map(entry => entry.name);
115
+ for (const facebookDir of facebookDirs) {
116
+ const fullPath = join(segmentPath, facebookDir);
117
+ yield { segmentPath, facebookDir, fullPath };
118
+ }
119
+ }
120
+ }
121
+ catch {
122
+ // Could not read segment
123
+ }
124
+ }
125
+ }
126
+ async readFacebookJson(filePath) {
127
+ return this.readArchiveJson(filePath);
128
+ }
129
+ convertFacebookTimestamp(timestamp) {
130
+ // Facebook timestamps are in milliseconds
131
+ return new Date(timestamp).toISOString();
132
+ }
133
+ fixFacebookTextEncoding(text) {
134
+ if (!text)
135
+ return text;
136
+ // Fix common social media export encoding issues
137
+ const fixed = text
138
+ // Handle escaped Unicode sequences (in JSON strings)
139
+ .replaceAll('\\u00e2\\u0080\\u0099', "'") // Fix apostrophe
140
+ .replaceAll('\\u00e2\\u0080\\u009c', '"') // Fix opening quote
141
+ .replaceAll('\\u00e2\\u0080\\u009d', '"') // Fix closing quote
142
+ .replaceAll('\\u00e2\\u0080\\u0094', '—') // Fix em dash
143
+ .replaceAll('\\u00e2\\u0080\\u0093', '–') // Fix en dash
144
+ .replaceAll('\\u00e2\\u0080\\u00a6', '...') // Fix ellipsis
145
+ // Handle direct UTF-8 bytes interpreted as Latin-1
146
+ .replaceAll('\u00E2\u0080\u0099', "'") // Fix apostrophe
147
+ .replaceAll('\u00E2\u0080\u009C', '"') // Fix opening quote
148
+ .replaceAll('\u00E2\u0080\u009D', '"') // Fix closing quote
149
+ .replaceAll('\u00E2\u0080\u0094', '—') // Fix em dash
150
+ .replaceAll('\u00E2\u0080\u0093', '–') // Fix en dash
151
+ .replaceAll('\u00E2\u0080\u00A6', '...') // Fix ellipsis
152
+ // Handle additional accented characters and emojis
153
+ .replaceAll('\u00E2\u009D\u00A4', '❤') // Fix heart emoji
154
+ .replaceAll('\u00C3\u00A9', 'é') // Fix é
155
+ .replaceAll('\u00C3\u00A1', 'á') // Fix á
156
+ .replaceAll('\u00C3\u00AD', 'í') // Fix í
157
+ .replaceAll('\u00C3\u00B3', 'ó') // Fix ó
158
+ .replaceAll('\u00C3\u00BA', 'ú') // Fix ú
159
+ .replaceAll('\u00C3\u00B1', 'ñ') // Fix ñ
160
+ .trim();
161
+ return fixed;
162
+ }
163
+ }
@@ -0,0 +1,16 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
3
+ import FacebookTransformer from '../FacebookTransformer.js';
4
+ export declare class FacebookArchiveMessagesExtractor extends FacebookArchiveExtractor {
5
+ static recordTypes: string[];
6
+ static description: string;
7
+ static default: boolean;
8
+ static defaultTransformer: typeof FacebookTransformer;
9
+ extract(): AsyncGenerator<Record>;
10
+ private extractMessagesFromSegment;
11
+ private extractFromMessageFolder;
12
+ private extractMessagesFromConversation;
13
+ private processConversationData;
14
+ private determineMessageType;
15
+ }
16
+ //# sourceMappingURL=FacebookArchiveMessagesExtractor.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
4
+ import FacebookTransformer from '../FacebookTransformer.js';
5
+ export class FacebookArchiveMessagesExtractor extends FacebookArchiveExtractor {
6
+ static recordTypes = ['messages'];
7
+ static description = 'Messenger conversations';
8
+ static default = true;
9
+ static defaultTransformer = FacebookTransformer;
10
+ async *extract() {
11
+ await this.initialize();
12
+ const processedConversations = new Set();
13
+ let recordCount = 0;
14
+ for await (const { fullPath } of this.iterateExportSegments()) {
15
+ for await (const record of this.extractMessagesFromSegment(fullPath, processedConversations)) {
16
+ yield record;
17
+ recordCount++;
18
+ if (this.shouldStopExtracting(recordCount)) {
19
+ return;
20
+ }
21
+ }
22
+ }
23
+ }
24
+ async *extractMessagesFromSegment(segmentPath, processedConversations) {
25
+ const messagesPath = join(segmentPath, 'your_facebook_activity', 'messages');
26
+ try {
27
+ await stat(messagesPath);
28
+ }
29
+ catch {
30
+ // No messages directory found
31
+ return;
32
+ }
33
+ // Process different message types
34
+ yield* this.extractFromMessageFolder(messagesPath, 'inbox', processedConversations);
35
+ yield* this.extractFromMessageFolder(messagesPath, 'archived_threads', processedConversations);
36
+ yield* this.extractFromMessageFolder(messagesPath, 'message_requests', processedConversations);
37
+ yield* this.extractFromMessageFolder(messagesPath, 'filtered_threads', processedConversations);
38
+ }
39
+ async *extractFromMessageFolder(messagesPath, folderName, processedConversations) {
40
+ const folderPath = join(messagesPath, folderName);
41
+ try {
42
+ await stat(folderPath);
43
+ }
44
+ catch {
45
+ return; // Folder doesn't exist in this segment
46
+ }
47
+ try {
48
+ const conversations = await readdir(folderPath, { withFileTypes: true });
49
+ for (const conversation of conversations) {
50
+ if (!conversation.isDirectory())
51
+ continue;
52
+ const conversationId = conversation.name;
53
+ // Skip if already processed (avoid duplicates across segments)
54
+ if (processedConversations.has(conversationId)) {
55
+ continue;
56
+ }
57
+ const conversationPath = join(folderPath, conversationId);
58
+ try {
59
+ yield* this.extractMessagesFromConversation(conversationPath, conversationId, folderName);
60
+ processedConversations.add(conversationId);
61
+ }
62
+ catch {
63
+ // Error processing conversation
64
+ }
65
+ }
66
+ }
67
+ catch {
68
+ // Error reading message folder
69
+ }
70
+ }
71
+ async *extractMessagesFromConversation(conversationPath, conversationId, threadType) {
72
+ try {
73
+ const files = await readdir(conversationPath);
74
+ const messageFiles = files.filter(file => file.startsWith('message_') && file.endsWith('.json'));
75
+ if (messageFiles.length === 0) {
76
+ // No message files found in conversation
77
+ return;
78
+ }
79
+ // Sort message files to process in order
80
+ messageFiles.sort((a, b) => {
81
+ const aNum = Number.parseInt(a.match(/message_(\d+)\.json/)?.[1] || '0');
82
+ const bNum = Number.parseInt(b.match(/message_(\d+)\.json/)?.[1] || '0');
83
+ return aNum - bNum;
84
+ });
85
+ for (const messageFile of messageFiles) {
86
+ const filePath = join(conversationPath, messageFile);
87
+ try {
88
+ const data = await this.readFacebookJson(filePath);
89
+ yield* this.processConversationData(data, conversationId, threadType);
90
+ }
91
+ catch {
92
+ // Error reading message file
93
+ }
94
+ }
95
+ }
96
+ catch {
97
+ // Error processing conversation
98
+ }
99
+ }
100
+ async *processConversationData(data, conversationId, threadType) {
101
+ if (!data.messages || !Array.isArray(data.messages)) {
102
+ // No messages array found in conversation
103
+ return;
104
+ }
105
+ const participants = data.participants || [];
106
+ const conversationTitle = data.title || conversationId;
107
+ for (const message of data.messages) {
108
+ // Skip messages outside date range if configured
109
+ if (message.timestamp_ms && !this.isWithinDateRange(new Date(message.timestamp_ms))) {
110
+ continue;
111
+ }
112
+ const processedMessage = {
113
+ conversationId,
114
+ conversationTitle,
115
+ threadType,
116
+ participants: participants.map((p) => p.name),
117
+ senderName: message.sender_name,
118
+ content: this.fixFacebookTextEncoding(message.content || ''),
119
+ timestamp: message.timestamp_ms
120
+ ? this.convertFacebookTimestamp(message.timestamp_ms)
121
+ : null,
122
+ messageType: this.determineMessageType(message),
123
+ hasMedia: Boolean(message.photos || message.videos || message.audio_files || message.files || message.gifs),
124
+ photos: message.photos?.map((p) => ({
125
+ uri: p.uri,
126
+ creation_timestamp: p.creation_timestamp,
127
+ })) || [],
128
+ videos: message.videos?.map((v) => ({
129
+ uri: v.uri,
130
+ creation_timestamp: v.creation_timestamp,
131
+ })) || [],
132
+ audioFiles: message.audio_files?.map((a) => ({
133
+ uri: a.uri,
134
+ creation_timestamp: a.creation_timestamp,
135
+ })) || [],
136
+ files: message.files?.map((f) => ({
137
+ uri: f.uri,
138
+ creation_timestamp: f.creation_timestamp,
139
+ })) || [],
140
+ gifs: message.gifs?.map((g) => ({
141
+ uri: g.uri,
142
+ })) || [],
143
+ sticker: message.sticker
144
+ ? {
145
+ uri: message.sticker.uri,
146
+ }
147
+ : null,
148
+ reactions: message.reactions?.map((r) => ({
149
+ reaction: r.reaction,
150
+ actor: r.actor,
151
+ })) || [],
152
+ isUnsent: message.is_unsent || false,
153
+ rawData: message,
154
+ };
155
+ yield this.createRecordWithArchiveContext(processedMessage, {
156
+ conversationId,
157
+ threadType,
158
+ messageFile: true,
159
+ });
160
+ }
161
+ }
162
+ determineMessageType(message) {
163
+ if (message.photos?.length > 0)
164
+ return 'photo';
165
+ if (message.videos?.length > 0)
166
+ return 'video';
167
+ if (message.audio_files?.length > 0)
168
+ return 'audio';
169
+ if (message.files?.length > 0)
170
+ return 'file';
171
+ if (message.gifs?.length > 0)
172
+ return 'gif';
173
+ if (message.sticker)
174
+ return 'sticker';
175
+ if (message.content)
176
+ return 'text';
177
+ if (message.is_unsent)
178
+ return 'unsent';
179
+ return 'unknown';
180
+ }
181
+ }
@@ -0,0 +1,26 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
3
+ import FacebookTransformer from '../FacebookTransformer.js';
4
+ export declare class FacebookArchiveReactionsExtractor extends FacebookArchiveExtractor {
5
+ static recordTypes: string[];
6
+ static description: string;
7
+ static default: boolean;
8
+ static defaultTransformer: typeof FacebookTransformer;
9
+ /**
10
+ * The reaction's `fbid` — Facebook's own numeric id, carried verbatim by the
11
+ * `label_values` export dialect. The data-array dialect and the comments file
12
+ * carry no id at all, so those records are keyless.
13
+ */
14
+ keyOf(record: Record): string | null;
15
+ extract(): AsyncGenerator<Record>;
16
+ private extractReactionsFromSegment;
17
+ private extractFromReactionsFile;
18
+ private extractFromCommentsFile;
19
+ private processReactionsArrayData;
20
+ private processCommentsData;
21
+ private parseReactionData;
22
+ private parseCommentData;
23
+ private extractPostIdFromUrl;
24
+ private hashReactionContent;
25
+ }
26
+ //# sourceMappingURL=FacebookArchiveReactionsExtractor.d.ts.map
@@ -0,0 +1,238 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
4
+ import FacebookTransformer from '../FacebookTransformer.js';
5
+ export class FacebookArchiveReactionsExtractor extends FacebookArchiveExtractor {
6
+ static recordTypes = ['reactions', 'comments'];
7
+ static description = 'Reactions, likes, and comments';
8
+ static default = false;
9
+ static defaultTransformer = FacebookTransformer;
10
+ /**
11
+ * The reaction's `fbid` — Facebook's own numeric id, carried verbatim by the
12
+ * `label_values` export dialect. The data-array dialect and the comments file
13
+ * carry no id at all, so those records are keyless.
14
+ */
15
+ keyOf(record) {
16
+ return record.data.fbid ?? null;
17
+ }
18
+ async *extract() {
19
+ await this.initialize();
20
+ const processedReactions = new Set();
21
+ let recordCount = 0;
22
+ for await (const { fullPath } of this.iterateExportSegments()) {
23
+ for await (const record of this.extractReactionsFromSegment(fullPath, processedReactions)) {
24
+ yield record;
25
+ recordCount++;
26
+ if (this.shouldStopExtracting(recordCount)) {
27
+ return;
28
+ }
29
+ }
30
+ }
31
+ }
32
+ async *extractReactionsFromSegment(segmentPath, processedReactions) {
33
+ const reactionsPath = join(segmentPath, 'your_facebook_activity', 'comments_and_reactions');
34
+ try {
35
+ await stat(reactionsPath);
36
+ }
37
+ catch {
38
+ // No reactions directory found
39
+ return;
40
+ }
41
+ // Process different reaction/comment files
42
+ yield* this.extractFromReactionsFile(reactionsPath, 'likes_and_reactions.json', processedReactions);
43
+ yield* this.extractFromReactionsFile(reactionsPath, 'likes_and_reactions_1.json', processedReactions);
44
+ yield* this.extractFromCommentsFile(reactionsPath, 'comments.json', processedReactions);
45
+ }
46
+ async *extractFromReactionsFile(reactionsPath, fileName, processedReactions) {
47
+ const filePath = join(reactionsPath, fileName);
48
+ try {
49
+ await stat(filePath);
50
+ }
51
+ catch {
52
+ return; // File doesn't exist
53
+ }
54
+ try {
55
+ const data = await this.readFacebookJson(filePath);
56
+ if (Array.isArray(data)) {
57
+ // Handle array format (likes_and_reactions.json)
58
+ yield* this.processReactionsArrayData(data, fileName, processedReactions);
59
+ }
60
+ else if (data.reactions_v2 || data.likes_and_reactions_v2) {
61
+ // Handle object format with versioned arrays
62
+ const reactions = data.reactions_v2 || data.likes_and_reactions_v2 || [];
63
+ yield* this.processReactionsArrayData(reactions, fileName, processedReactions);
64
+ }
65
+ }
66
+ catch {
67
+ // Error reading reactions file
68
+ }
69
+ }
70
+ async *extractFromCommentsFile(reactionsPath, fileName, processedReactions) {
71
+ const filePath = join(reactionsPath, fileName);
72
+ try {
73
+ await stat(filePath);
74
+ }
75
+ catch {
76
+ return; // File doesn't exist
77
+ }
78
+ try {
79
+ const data = await this.readFacebookJson(filePath);
80
+ if (data.comments_v2 && Array.isArray(data.comments_v2)) {
81
+ yield* this.processCommentsData(data.comments_v2, fileName, processedReactions);
82
+ }
83
+ }
84
+ catch {
85
+ // Error reading comments file
86
+ }
87
+ }
88
+ async *processReactionsArrayData(reactions, fileName, processedReactions) {
89
+ for (const reaction of reactions) {
90
+ if (!reaction.timestamp)
91
+ continue;
92
+ // Skip reactions outside date range if configured
93
+ if (!this.isWithinDateRange(new Date(reaction.timestamp * 1000))) {
94
+ continue;
95
+ }
96
+ const reactionId = `reaction-${reaction.timestamp}-${fileName}-${this.hashReactionContent(reaction)}`;
97
+ // Skip if already processed (avoid duplicates)
98
+ if (processedReactions.has(reactionId)) {
99
+ continue;
100
+ }
101
+ const processedReaction = this.parseReactionData(reaction, reactionId, fileName);
102
+ if (processedReaction) {
103
+ yield this.createRecordWithArchiveContext(processedReaction, {
104
+ recordType: 'reactions',
105
+ reactionType: processedReaction.reactionType,
106
+ fileName,
107
+ dataSource: 'reactions',
108
+ });
109
+ processedReactions.add(reactionId);
110
+ }
111
+ }
112
+ }
113
+ async *processCommentsData(comments, fileName, processedReactions) {
114
+ for (const commentEntry of comments) {
115
+ if (!commentEntry.timestamp || !commentEntry.data)
116
+ continue;
117
+ // Skip comments outside date range if configured
118
+ if (!this.isWithinDateRange(new Date(commentEntry.timestamp * 1000))) {
119
+ continue;
120
+ }
121
+ const commentId = `comment-${commentEntry.timestamp}-${fileName}-${this.hashReactionContent(commentEntry)}`;
122
+ // Skip if already processed (avoid duplicates)
123
+ if (processedReactions.has(commentId)) {
124
+ continue;
125
+ }
126
+ const processedComment = this.parseCommentData(commentEntry, commentId, fileName);
127
+ if (processedComment) {
128
+ yield this.createRecordWithArchiveContext(processedComment, {
129
+ recordType: 'comments',
130
+ reactionType: 'comment',
131
+ fileName,
132
+ dataSource: 'comments',
133
+ });
134
+ processedReactions.add(commentId);
135
+ }
136
+ }
137
+ }
138
+ parseReactionData(reaction, reactionId, fileName) {
139
+ let reactionType = 'unknown';
140
+ let contentUrl = null;
141
+ let targetPerson = null;
142
+ let targetPostId = null;
143
+ let title = null;
144
+ // Handle different reaction data formats
145
+ if (reaction.label_values) {
146
+ // Format: likes_and_reactions.json (label_values format)
147
+ for (const labelValue of reaction.label_values) {
148
+ switch (labelValue.label) {
149
+ case 'Reaction':
150
+ reactionType = labelValue.value.toLowerCase();
151
+ break;
152
+ case 'URL':
153
+ contentUrl = labelValue.value;
154
+ targetPostId = this.extractPostIdFromUrl(labelValue.value);
155
+ break;
156
+ case 'Name':
157
+ targetPerson = labelValue.value;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ else if (reaction.data && Array.isArray(reaction.data)) {
163
+ // Format: likes_and_reactions_1.json (data array format)
164
+ const reactionData = reaction.data[0]?.reaction;
165
+ if (reactionData) {
166
+ reactionType = reactionData.reaction ? reactionData.reaction.toLowerCase() : 'like';
167
+ targetPerson = reactionData.actor;
168
+ }
169
+ title = reaction.title;
170
+ }
171
+ // Extract additional metadata
172
+ const hasMedia = Boolean(reaction.media && reaction.media.length > 0);
173
+ const { fbid } = reaction;
174
+ return {
175
+ id: reactionId,
176
+ timestamp: this.convertFacebookTimestamp(reaction.timestamp * 1000),
177
+ type: 'reaction',
178
+ reactionType,
179
+ contentUrl,
180
+ targetPerson: targetPerson ? this.fixFacebookTextEncoding(targetPerson) : null,
181
+ targetPostId,
182
+ title: title ? this.fixFacebookTextEncoding(title) : null,
183
+ hasMedia,
184
+ fbid,
185
+ fileName,
186
+ rawData: reaction,
187
+ };
188
+ }
189
+ parseCommentData(commentEntry, commentId, fileName) {
190
+ if (!commentEntry.data || !Array.isArray(commentEntry.data) || !commentEntry.data[0]?.comment) {
191
+ return null;
192
+ }
193
+ const { comment } = commentEntry.data[0];
194
+ return {
195
+ id: commentId,
196
+ timestamp: this.convertFacebookTimestamp(commentEntry.timestamp * 1000),
197
+ type: 'comment',
198
+ reactionType: 'comment',
199
+ commentText: comment.comment ? this.fixFacebookTextEncoding(comment.comment) : null,
200
+ author: comment.author ? this.fixFacebookTextEncoding(comment.author) : null,
201
+ title: commentEntry.title ? this.fixFacebookTextEncoding(commentEntry.title) : null,
202
+ commentTimestamp: comment.timestamp
203
+ ? this.convertFacebookTimestamp(comment.timestamp * 1000)
204
+ : null,
205
+ fileName,
206
+ rawData: commentEntry,
207
+ };
208
+ }
209
+ extractPostIdFromUrl(url) {
210
+ if (!url)
211
+ return null;
212
+ // Extract post ID from Facebook URLs
213
+ const patterns = [/\/posts\/([^/?]+)/, /story_fbid=([^&]+)/, /pfbid([^/?&]+)/];
214
+ for (const pattern of patterns) {
215
+ const match = url.match(pattern);
216
+ if (match) {
217
+ return match[1];
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+ hashReactionContent(reaction) {
223
+ // Create a simple hash of the reaction content for deduplication
224
+ const content = JSON.stringify({
225
+ label_values: reaction.label_values,
226
+ data: reaction.data,
227
+ title: reaction.title,
228
+ fbid: reaction.fbid,
229
+ });
230
+ // Simple hash function
231
+ let hash = 0;
232
+ for (let i = 0; i < content.length; i++) {
233
+ const char = content.codePointAt(i) ?? 0;
234
+ hash = (hash * 33 + char) % 2_147_483_647; // Simple hash without bitwise operators
235
+ }
236
+ return Math.abs(hash).toString(36);
237
+ }
238
+ }
@@ -0,0 +1,17 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
3
+ import FacebookTransformer from '../FacebookTransformer.js';
4
+ export declare class FacebookArchiveSearchHistoryExtractor extends FacebookArchiveExtractor {
5
+ static recordTypes: string[];
6
+ static description: string;
7
+ static default: boolean;
8
+ static defaultTransformer: typeof FacebookTransformer;
9
+ extract(): AsyncGenerator<Record>;
10
+ private extractSearchHistoryFromSegment;
11
+ private processSearchHistoryData;
12
+ private parseSearchData;
13
+ private determineSearchType;
14
+ private isProfileSearch;
15
+ private hashSearchText;
16
+ }
17
+ //# sourceMappingURL=FacebookArchiveSearchHistoryExtractor.d.ts.map
@@ -0,0 +1,144 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FacebookArchiveExtractor } from './FacebookArchiveExtractor.js';
4
+ import FacebookTransformer from '../FacebookTransformer.js';
5
+ export class FacebookArchiveSearchHistoryExtractor extends FacebookArchiveExtractor {
6
+ static recordTypes = ['searches'];
7
+ static description = 'Search history';
8
+ static default = false;
9
+ static defaultTransformer = FacebookTransformer;
10
+ async *extract() {
11
+ await this.initialize();
12
+ const processedSearches = new Set();
13
+ let recordCount = 0;
14
+ for await (const { fullPath } of this.iterateExportSegments()) {
15
+ for await (const record of this.extractSearchHistoryFromSegment(fullPath, processedSearches)) {
16
+ yield record;
17
+ recordCount++;
18
+ if (this.shouldStopExtracting(recordCount)) {
19
+ return;
20
+ }
21
+ }
22
+ }
23
+ }
24
+ async *extractSearchHistoryFromSegment(segmentPath, processedSearches) {
25
+ const searchHistoryPath = join(segmentPath, 'logged_information', 'search', 'your_search_history.json');
26
+ try {
27
+ await stat(searchHistoryPath);
28
+ }
29
+ catch {
30
+ // No search history file found
31
+ return;
32
+ }
33
+ try {
34
+ const data = await this.readFacebookJson(searchHistoryPath);
35
+ yield* this.processSearchHistoryData(data, processedSearches);
36
+ }
37
+ catch {
38
+ // Error reading search history file
39
+ }
40
+ }
41
+ async *processSearchHistoryData(data, processedSearches) {
42
+ if (!data.searches_v2 || !Array.isArray(data.searches_v2)) {
43
+ // No searches_v2 array found in search history data
44
+ return;
45
+ }
46
+ for (const search of data.searches_v2) {
47
+ if (!search.timestamp)
48
+ continue;
49
+ // Skip searches outside date range if configured
50
+ if (!this.isWithinDateRange(new Date(search.timestamp * 1000))) {
51
+ continue;
52
+ }
53
+ const searchId = `search-${search.timestamp}-${this.hashSearchText(search)}`;
54
+ // Skip if already processed (avoid duplicates)
55
+ if (processedSearches.has(searchId)) {
56
+ continue;
57
+ }
58
+ const processedSearch = this.parseSearchData(search, searchId);
59
+ if (processedSearch) {
60
+ yield this.createRecordWithArchiveContext(processedSearch, {
61
+ searchType: 'facebook_search',
62
+ dataSource: 'search_history',
63
+ });
64
+ processedSearches.add(searchId);
65
+ }
66
+ }
67
+ }
68
+ parseSearchData(search, searchId) {
69
+ // Extract search text from multiple possible locations
70
+ let searchText = '';
71
+ let searchQuery = '';
72
+ // Try to get search text from data array
73
+ if (search.data && Array.isArray(search.data) && search.data[0]?.text) {
74
+ searchText = search.data[0].text;
75
+ }
76
+ // Try to get search text from attachments
77
+ if (search.attachments && Array.isArray(search.attachments)) {
78
+ for (const attachment of search.attachments) {
79
+ if (attachment.data && Array.isArray(attachment.data) && attachment.data[0]?.text) {
80
+ searchQuery = attachment.data[0].text;
81
+ // Remove quotes if present
82
+ searchQuery = searchQuery.replace(/^"(.*)"$/, '$1');
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ // Use searchQuery if available, otherwise fallback to searchText
88
+ const finalSearchText = searchQuery || searchText;
89
+ if (!finalSearchText) {
90
+ return null;
91
+ }
92
+ const searchType = this.determineSearchType(search.title || '');
93
+ const isProfileSearch = this.isProfileSearch(finalSearchText, search.title || '');
94
+ return {
95
+ id: searchId,
96
+ timestamp: this.convertFacebookTimestamp(search.timestamp * 1000),
97
+ searchText: this.fixFacebookTextEncoding(finalSearchText),
98
+ searchQuery: searchQuery ? this.fixFacebookTextEncoding(searchQuery) : null,
99
+ title: search.title ? this.fixFacebookTextEncoding(search.title) : null,
100
+ searchType,
101
+ isProfileSearch,
102
+ isVisited: (search.title || '').includes('visited'),
103
+ isSearched: (search.title || '').includes('searched'),
104
+ rawSearchText: searchText,
105
+ hasAttachments: Boolean(search.attachments && search.attachments.length > 0),
106
+ rawData: search,
107
+ };
108
+ }
109
+ determineSearchType(title) {
110
+ const lowerTitle = title.toLowerCase();
111
+ if (lowerTitle.includes('visited'))
112
+ return 'visit';
113
+ if (lowerTitle.includes('searched'))
114
+ return 'search';
115
+ if (lowerTitle.includes('looked for'))
116
+ return 'lookup';
117
+ return 'unknown';
118
+ }
119
+ isProfileSearch(searchText, title) {
120
+ // Check if this appears to be searching for a person
121
+ const hasPersonIndicators = [
122
+ /^[A-Z][a-z]+ [A-Z]/, // First Last name pattern
123
+ title.includes('visited'),
124
+ title.includes('profile'),
125
+ !searchText.includes(' ') && searchText.length > 2, // Single word that might be a name
126
+ ];
127
+ return hasPersonIndicators.some(indicator => typeof indicator === 'boolean' ? indicator : indicator.test(searchText));
128
+ }
129
+ hashSearchText(search) {
130
+ // Create a simple hash of the search content for deduplication
131
+ const content = JSON.stringify({
132
+ data: search.data,
133
+ attachments: search.attachments,
134
+ title: search.title,
135
+ });
136
+ // Simple hash function
137
+ let hash = 0;
138
+ for (let i = 0; i < content.length; i++) {
139
+ const char = content.codePointAt(i) ?? 0;
140
+ hash = (hash * 33 + char) % 2_147_483_647; // Simple hash without bitwise operators
141
+ }
142
+ return Math.abs(hash).toString(36);
143
+ }
144
+ }
@@ -0,0 +1,6 @@
1
+ export { default as FacebookTransformer } from './connectors/FacebookTransformer.js';
2
+ export { FacebookArchiveExtractor } from './connectors/archive/FacebookArchiveExtractor.js';
3
+ export { FacebookArchiveMessagesExtractor } from './connectors/archive/FacebookArchiveMessagesExtractor.js';
4
+ export { FacebookArchiveReactionsExtractor } from './connectors/archive/FacebookArchiveReactionsExtractor.js';
5
+ export { FacebookArchiveSearchHistoryExtractor } from './connectors/archive/FacebookArchiveSearchHistoryExtractor.js';
6
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { default as FacebookTransformer } from './connectors/FacebookTransformer.js';
2
+ export { FacebookArchiveExtractor } from './connectors/archive/FacebookArchiveExtractor.js';
3
+ export { FacebookArchiveMessagesExtractor } from './connectors/archive/FacebookArchiveMessagesExtractor.js';
4
+ export { FacebookArchiveReactionsExtractor } from './connectors/archive/FacebookArchiveReactionsExtractor.js';
5
+ export { FacebookArchiveSearchHistoryExtractor } from './connectors/archive/FacebookArchiveSearchHistoryExtractor.js';
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@chronicle.app/facebook",
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
+ "facebook"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for Chronicle plugin for facebook",
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/facebook"
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
+ "facebook": {
61
+ "strategies": {
62
+ "archive": {
63
+ "delivery": "export",
64
+ "recordTypes": [
65
+ "messages",
66
+ "reactions",
67
+ "comments",
68
+ "searches"
69
+ ],
70
+ "default": true
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }