@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 +21 -0
- package/README.md +64 -0
- package/dist/connectors/FacebookTransformer.d.ts +35 -0
- package/dist/connectors/FacebookTransformer.js +238 -0
- package/dist/connectors/archive/FacebookArchiveExtractor.d.ts +36 -0
- package/dist/connectors/archive/FacebookArchiveExtractor.js +163 -0
- package/dist/connectors/archive/FacebookArchiveMessagesExtractor.d.ts +16 -0
- package/dist/connectors/archive/FacebookArchiveMessagesExtractor.js +181 -0
- package/dist/connectors/archive/FacebookArchiveReactionsExtractor.d.ts +26 -0
- package/dist/connectors/archive/FacebookArchiveReactionsExtractor.js +238 -0
- package/dist/connectors/archive/FacebookArchiveSearchHistoryExtractor.d.ts +17 -0
- package/dist/connectors/archive/FacebookArchiveSearchHistoryExtractor.js +144 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/package.json +76 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|