facebook-comments-client 0.1.4
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 +225 -0
- package/dist/cli.js +1608 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1540 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +1503 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5
|
+
|
|
6
|
+
// src/client/FacebookCommentsClient.ts
|
|
7
|
+
import { mkdir } from "fs/promises";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { chromium as chromium2 } from "playwright";
|
|
11
|
+
|
|
12
|
+
// src/browser/BrowserManager.ts
|
|
13
|
+
import { chromium } from "playwright";
|
|
14
|
+
var BrowserManager = class {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
options;
|
|
19
|
+
browser = null;
|
|
20
|
+
currentHeadless = null;
|
|
21
|
+
async launch(headless = this.options.headless ?? true) {
|
|
22
|
+
if (this.browser?.isConnected() && this.currentHeadless === headless) {
|
|
23
|
+
return this.browser;
|
|
24
|
+
}
|
|
25
|
+
await this.close();
|
|
26
|
+
this.browser = await chromium.launch({
|
|
27
|
+
headless,
|
|
28
|
+
executablePath: this.options.executablePath,
|
|
29
|
+
args: this.options.args
|
|
30
|
+
});
|
|
31
|
+
this.currentHeadless = headless;
|
|
32
|
+
return this.browser;
|
|
33
|
+
}
|
|
34
|
+
async close() {
|
|
35
|
+
if (this.browser) {
|
|
36
|
+
await this.browser.close().catch(() => {
|
|
37
|
+
});
|
|
38
|
+
this.browser = null;
|
|
39
|
+
this.currentHeadless = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/browser/ContextFactory.ts
|
|
45
|
+
function toStrictCookie(cookie) {
|
|
46
|
+
return {
|
|
47
|
+
name: cookie.name,
|
|
48
|
+
value: cookie.value,
|
|
49
|
+
domain: cookie.domain,
|
|
50
|
+
path: cookie.path ?? "/",
|
|
51
|
+
expires: cookie.expires ?? -1,
|
|
52
|
+
httpOnly: cookie.httpOnly ?? false,
|
|
53
|
+
secure: cookie.secure ?? true,
|
|
54
|
+
sameSite: cookie.sameSite ?? "Lax"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function createLoggedInContext(browser, options) {
|
|
58
|
+
const storageState = options.storageState ? {
|
|
59
|
+
cookies: options.storageState.cookies.filter((cookie) => cookie.name && cookie.value && cookie.domain).map(toStrictCookie),
|
|
60
|
+
origins: options.storageState.origins ?? []
|
|
61
|
+
} : void 0;
|
|
62
|
+
const context = await browser.newContext({
|
|
63
|
+
storageState,
|
|
64
|
+
// Wide desktop viewport: Facebook serves its full layout (comments sidebar
|
|
65
|
+
// on reels/videos) rather than collapsed theater mode.
|
|
66
|
+
viewport: { width: 1680, height: 1050 }
|
|
67
|
+
});
|
|
68
|
+
context.setDefaultTimeout(options.timeoutMs ?? 3e4);
|
|
69
|
+
context.setDefaultNavigationTimeout(options.timeoutMs ?? 3e4);
|
|
70
|
+
return context;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/browser/SessionManager.ts
|
|
74
|
+
var AUTH_COOKIE_NAMES = ["c_user", "xs"];
|
|
75
|
+
function hasAuthCookies(state) {
|
|
76
|
+
if (!state) return false;
|
|
77
|
+
const names = new Set(state.cookies.map((cookie) => cookie.name));
|
|
78
|
+
return AUTH_COOKIE_NAMES.every((name) => names.has(name));
|
|
79
|
+
}
|
|
80
|
+
async function checkAuthenticatedOnPage(context, page) {
|
|
81
|
+
await page.goto("https://www.facebook.com/", { waitUntil: "domcontentloaded" });
|
|
82
|
+
const url = page.url();
|
|
83
|
+
if (/(?:^|\/)login(?:\.php)?(?:[/?#]|$)/i.test(url)) return false;
|
|
84
|
+
if (/(?:^|\/)recover(?:[/?#]|$)/i.test(url)) return false;
|
|
85
|
+
const cookies = await context.cookies("https://www.facebook.com");
|
|
86
|
+
const names = new Set(cookies.map((cookie) => cookie.name));
|
|
87
|
+
return AUTH_COOKIE_NAMES.every((name) => names.has(name));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/errors/FacebookClientError.ts
|
|
91
|
+
var FacebookClientError = class extends Error {
|
|
92
|
+
code;
|
|
93
|
+
retryable;
|
|
94
|
+
constructor(message, options = {}) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = new.target.name;
|
|
97
|
+
this.code = options.code ?? "FB_CLIENT";
|
|
98
|
+
this.retryable = options.retryable ?? false;
|
|
99
|
+
if (options.cause !== void 0) {
|
|
100
|
+
this.cause = options.cause;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// src/errors/FacebookAuthenticationError.ts
|
|
106
|
+
var FacebookAuthenticationError = class extends FacebookClientError {
|
|
107
|
+
constructor(message, options = {}) {
|
|
108
|
+
super(message, { ...options, code: "FB_AUTH" });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/errors/FacebookSessionExpiredError.ts
|
|
113
|
+
var FacebookSessionExpiredError = class extends FacebookClientError {
|
|
114
|
+
constructor(message, options = {}) {
|
|
115
|
+
super(message, { ...options, code: "FB_SESSION_EXPIRED" });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/errors/FacebookPostNotFoundError.ts
|
|
120
|
+
var FacebookPostNotFoundError = class extends FacebookClientError {
|
|
121
|
+
constructor(message, options = {}) {
|
|
122
|
+
super(message, { ...options, code: "FB_POST_NOT_FOUND" });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/errors/FacebookAccessDeniedError.ts
|
|
127
|
+
var FacebookAccessDeniedError = class extends FacebookClientError {
|
|
128
|
+
constructor(message, options = {}) {
|
|
129
|
+
super(message, { ...options, code: "FB_ACCESS_DENIED" });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/errors/FacebookExtractionError.ts
|
|
134
|
+
var FacebookExtractionError = class extends FacebookClientError {
|
|
135
|
+
constructor(message, options = {}) {
|
|
136
|
+
super(message, { ...options, code: "FB_EXTRACTION", retryable: options.retryable ?? true });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/session/StorageStateProvider.ts
|
|
141
|
+
import { z } from "zod";
|
|
142
|
+
var CookieSchema = z.object({
|
|
143
|
+
name: z.string(),
|
|
144
|
+
value: z.string(),
|
|
145
|
+
domain: z.string().optional(),
|
|
146
|
+
path: z.string().optional(),
|
|
147
|
+
expires: z.number().optional(),
|
|
148
|
+
httpOnly: z.boolean().optional(),
|
|
149
|
+
secure: z.boolean().optional(),
|
|
150
|
+
sameSite: z.enum(["Strict", "Lax", "None"]).optional()
|
|
151
|
+
}).passthrough();
|
|
152
|
+
var StorageStateSchema = z.object({
|
|
153
|
+
cookies: z.array(CookieSchema).default([]),
|
|
154
|
+
origins: z.array(
|
|
155
|
+
z.object({
|
|
156
|
+
origin: z.string(),
|
|
157
|
+
localStorage: z.array(z.object({ name: z.string(), value: z.string() })).default([])
|
|
158
|
+
}).passthrough()
|
|
159
|
+
).default([])
|
|
160
|
+
});
|
|
161
|
+
function validateStorageState(input, source) {
|
|
162
|
+
const result = StorageStateSchema.safeParse(input);
|
|
163
|
+
if (!result.success) {
|
|
164
|
+
const paths = result.error.issues.map((issue) => issue.path.join(".")).slice(0, 5);
|
|
165
|
+
throw new FacebookAuthenticationError(
|
|
166
|
+
`Session data from ${source} is not a valid storage state (invalid: ${paths.join(", ")})`,
|
|
167
|
+
{ cause: void 0 }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const cookies = [];
|
|
171
|
+
for (const cookie of result.data.cookies) {
|
|
172
|
+
if (!cookie.name || !cookie.value || !cookie.domain) continue;
|
|
173
|
+
cookies.push({ ...cookie, domain: cookie.domain, path: cookie.path ?? "/" });
|
|
174
|
+
}
|
|
175
|
+
return { cookies, origins: result.data.origins };
|
|
176
|
+
}
|
|
177
|
+
var StorageStateProvider = class {
|
|
178
|
+
constructor(state) {
|
|
179
|
+
this.state = state;
|
|
180
|
+
}
|
|
181
|
+
state;
|
|
182
|
+
type = "state";
|
|
183
|
+
async resolve() {
|
|
184
|
+
return validateStorageState(this.state, "inline session config");
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/session/FileSessionProvider.ts
|
|
189
|
+
import { readFile } from "fs/promises";
|
|
190
|
+
var FileSessionProvider = class {
|
|
191
|
+
constructor(path) {
|
|
192
|
+
this.path = path;
|
|
193
|
+
}
|
|
194
|
+
path;
|
|
195
|
+
type = "file";
|
|
196
|
+
async resolve() {
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = await readFile(this.path, "utf8");
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const code = error.code;
|
|
202
|
+
if (code === "ENOENT") {
|
|
203
|
+
throw new FacebookAuthenticationError(
|
|
204
|
+
`Session file not found: ${this.path}. Run \`fb-comments login --output ${this.path}\` first.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
throw new FacebookAuthenticationError(`Could not read session file: ${this.path} (${code})`);
|
|
208
|
+
}
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(raw);
|
|
212
|
+
} catch {
|
|
213
|
+
throw new FacebookAuthenticationError(`Session file is not valid JSON: ${this.path}`);
|
|
214
|
+
}
|
|
215
|
+
return validateStorageState(parsed, `file ${this.path}`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// src/session/EnvSessionProvider.ts
|
|
220
|
+
var EnvSessionProvider = class {
|
|
221
|
+
constructor(envKey) {
|
|
222
|
+
this.envKey = envKey;
|
|
223
|
+
}
|
|
224
|
+
envKey;
|
|
225
|
+
type = "env";
|
|
226
|
+
async resolve() {
|
|
227
|
+
const encoded = process.env[this.envKey];
|
|
228
|
+
if (!encoded) {
|
|
229
|
+
throw new FacebookAuthenticationError(
|
|
230
|
+
`Environment variable ${this.envKey} is not set. Encode a storage state with: Buffer.from(JSON.stringify(storageState)).toString("base64")`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
234
|
+
let decoded;
|
|
235
|
+
try {
|
|
236
|
+
decoded = Buffer.from(normalized, "base64").toString("utf8");
|
|
237
|
+
} catch {
|
|
238
|
+
throw new FacebookAuthenticationError(
|
|
239
|
+
`Environment variable ${this.envKey} is not valid base64.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
let parsed;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(decoded);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new FacebookAuthenticationError(
|
|
247
|
+
`Environment variable ${this.envKey} does not contain valid JSON after base64 decoding.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return validateStorageState(parsed, `env ${this.envKey}`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/session/CookieProvider.ts
|
|
255
|
+
var CookieProvider = class {
|
|
256
|
+
constructor(cookies) {
|
|
257
|
+
this.cookies = cookies;
|
|
258
|
+
}
|
|
259
|
+
cookies;
|
|
260
|
+
type = "cookies";
|
|
261
|
+
async resolve() {
|
|
262
|
+
const result = CookieSchema.array().safeParse(this.cookies);
|
|
263
|
+
if (!result.success) {
|
|
264
|
+
throw new FacebookAuthenticationError(
|
|
265
|
+
"Cookie session config contains invalid cookies (each needs at least name, value, domain)"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const cookies = [];
|
|
269
|
+
for (const cookie of result.data) {
|
|
270
|
+
if (!cookie.name || !cookie.value || !cookie.domain) continue;
|
|
271
|
+
cookies.push({
|
|
272
|
+
...cookie,
|
|
273
|
+
domain: cookie.domain,
|
|
274
|
+
path: cookie.path ?? "/",
|
|
275
|
+
expires: cookie.expires ?? -1
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return { cookies, origins: [] };
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// src/session/SessionProvider.ts
|
|
283
|
+
function createSessionProvider(config) {
|
|
284
|
+
switch (config.type) {
|
|
285
|
+
case "file":
|
|
286
|
+
case "storage":
|
|
287
|
+
return new FileSessionProvider(config.path);
|
|
288
|
+
case "env":
|
|
289
|
+
return new EnvSessionProvider(config.envKey);
|
|
290
|
+
case "cookies":
|
|
291
|
+
return new CookieProvider(config.cookies);
|
|
292
|
+
case "state":
|
|
293
|
+
return new StorageStateProvider(config.state);
|
|
294
|
+
default:
|
|
295
|
+
throw new FacebookClientError(
|
|
296
|
+
`Unknown session type: ${JSON.stringify(config.type)}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/extractors/ExtractorPipeline.ts
|
|
302
|
+
var ExtractorPipeline = class {
|
|
303
|
+
constructor(extractors) {
|
|
304
|
+
this.extractors = extractors;
|
|
305
|
+
}
|
|
306
|
+
extractors;
|
|
307
|
+
async extract(page, options, scope) {
|
|
308
|
+
const attempted = [];
|
|
309
|
+
for (const extractor of this.extractors) {
|
|
310
|
+
try {
|
|
311
|
+
if (!await extractor.canHandle(page, scope)) continue;
|
|
312
|
+
attempted.push(extractor.name);
|
|
313
|
+
const raw = await extractor.extract(page, options, scope);
|
|
314
|
+
if (raw.length > 0) return raw;
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
throw new FacebookExtractionError(
|
|
319
|
+
attempted.length > 0 ? `No extractor produced comments (tried: ${attempted.join(", ")}). Facebook markup may have changed \u2014 see src/config/selectors.ts.` : "No extractor could handle this page. Facebook markup may have changed \u2014 see src/config/selectors.ts."
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// src/config/selectors.ts
|
|
325
|
+
var selectors = {
|
|
326
|
+
/** Each comment renders as an aria article. The post itself is also an article —
|
|
327
|
+
* CommentParser distinguishes comments via reply affordances and aria-labels. */
|
|
328
|
+
commentArticle: '[role="article"]',
|
|
329
|
+
/** Real comment articles only (top-level "Comment by" + nested "Reply by");
|
|
330
|
+
* excludes post/feed wrapper articles. */
|
|
331
|
+
commentCandidate: '[role="article"][aria-label*="comment by" i], [role="article"][aria-label*="reply by" i]',
|
|
332
|
+
/** Foreground permalink modal — group/photo/video permalinks open the post in
|
|
333
|
+
* a dialog while the logged-in feed stays mounted behind it. Comment
|
|
334
|
+
* interactions and extraction must be scoped to this dialog, or background
|
|
335
|
+
* feed comments leak into results. Plain [role="dialog"] wrappers (without
|
|
336
|
+
* aria-modal) are the nesting chrome, not the boundary. */
|
|
337
|
+
foregroundModal: '[role="dialog"][aria-modal="true"]',
|
|
338
|
+
/** Profile links inside a comment (author avatar/name). */
|
|
339
|
+
authorLink: 'a[href*="/people/"], a[href^="/"], a[href*="facebook.com/"]',
|
|
340
|
+
/** Elements that expand more comments (button or link). Matched by text, see textPatterns.moreComments. */
|
|
341
|
+
commentExpander: '[role="button"], [role="link"], a[href="#"]',
|
|
342
|
+
/** Elements that expand reply threads. Matched by text, see textPatterns.viewMoreReplies. */
|
|
343
|
+
replyExpander: '[role="button"], [role="link"], a[href="#"]',
|
|
344
|
+
/** Embedded structured data blobs (opportunistic structured extractor). */
|
|
345
|
+
structuredDataScripts: 'script[type="application/json"]',
|
|
346
|
+
/** Looser comment containers for the fallback extractor. */
|
|
347
|
+
fallbackCommentContainer: '[aria-label*="comment by" i], [data-testid*="comment" i]',
|
|
348
|
+
/** Icon-only comment openers on reels/videos: aria-label carries "comment" ("Comment",
|
|
349
|
+
* "Comments", "Comment, 5"), while the button text may be empty. */
|
|
350
|
+
commentOpenerControls: '[role="button"][aria-label*="comment" i], [role="link"][aria-label*="comment" i], [aria-label^="comment" i]'
|
|
351
|
+
};
|
|
352
|
+
var textPatterns = {
|
|
353
|
+
/** aria-label of a comment article, e.g. "Comment by John Doe" or "John Doe · 2h". */
|
|
354
|
+
commentBy: /(?:^|\b)comment(?:ed)? by\s+(.+)$/i,
|
|
355
|
+
/** aria-label of a reply article: "Reply by X to Y's comment 3 days ago".
|
|
356
|
+
* Group modals render replies as flat sibling articles — group 1 is the
|
|
357
|
+
* reply author, group 2 the parent comment's author. */
|
|
358
|
+
replyBy: /\breply by\s+(.+?)\s+to\s+(.+?)'s comment\b/i,
|
|
359
|
+
/** aria-label of a reply article without the parent attribution. */
|
|
360
|
+
replyByFallback: /\breply by\s+(.+)$/i,
|
|
361
|
+
/** Author · timestamp header, e.g. "John Doe · 2h". */
|
|
362
|
+
authorTime: /^(.+?)\s*·\s*(?:.*?\d)/,
|
|
363
|
+
/** Reply action affordance. */
|
|
364
|
+
reply: /^\s*(reply|antworten|r\u00e9pondere|responder)\s*$/i,
|
|
365
|
+
/** "View more replies", "View 2 replies", "Load more replies", "Show N previous replies". */
|
|
366
|
+
viewMoreReplies: /\b\d*\s*(?:view|see|show|load)\s*(?:\d+\s*)?(?:more\s*)?(?:previous\s*)?repl(?:y|ies)\b|^\s*\d+\s+repl(?:y|ies)\s*$/i,
|
|
367
|
+
/** "View more comments", "View 12 comments", "Load 25 more comments", "View previous comments". */
|
|
368
|
+
moreComments: /\b(?:view|see|show|load)\s*(?:\d+\s*)?(?:more\s*)?(?:previous\s*)?comments?\b/i,
|
|
369
|
+
/** Comment-panel opener on reels/videos ("Comments", "View comments", "View 12 comments"). */
|
|
370
|
+
commentOpener: /^(?:view\s+)?comments?\b/i,
|
|
371
|
+
/** Composer boxes ("Write a comment") also contain "comment" — never click those. */
|
|
372
|
+
commentComposer: /^(write|leave|reply\s+to)\b/i,
|
|
373
|
+
/** Reaction counts, e.g. "5", "5 reactions", "1.2K". */
|
|
374
|
+
reactions: /^(?:[\d.,]+\s*[KkMm]?|[\d.,]+\s*[KkMm]?\s*(?:reactions?|likes?))$/,
|
|
375
|
+
/** Post unavailable / deleted. */
|
|
376
|
+
unavailable: /this content isn'?t (?:available|viewable)|content you requested cannot|page isn'?t available|this page isn'?t available/i,
|
|
377
|
+
/** Private group wall the account has not joined. */
|
|
378
|
+
joinGroup: /ask to join|join (?:this )?group|this group is private|join to see/i,
|
|
379
|
+
/** Login wall shown to logged-out visitors. */
|
|
380
|
+
loginWall: /log ?in (?:to continue|to see|or sign up)|you must log in to continue/i,
|
|
381
|
+
/** Relative timestamps: "2h", "5m", "3d", "12w", "2y", "just now", "5 hours ago",
|
|
382
|
+
* "a day ago", "Yesterday at 3:04 PM". */
|
|
383
|
+
relativeTime: /\b(?:\d+\s*[smhdyw]\b|\d+\s+(?:seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b(?:\s+ago)?|an?\s+(?:second|minute|hour|day|week|month|year)\s+ago|just now|yesterday(?:\s+at\s+[\d:]+\s*(?:am|pm)?)?)/i,
|
|
384
|
+
/** Timestamp glued onto the end of an author name in an aria-label
|
|
385
|
+
* ("Comment by Mjac Cabildo 5 hours ago") — strip it from the name. */
|
|
386
|
+
trailingTimestamp: /\s*[·-]?\s*(?:just now|yesterday(?:\s+at\s+\d{1,2}:\d{2}\s*(?:am|pm))?|(?:an?|\d+)\s+(?:s|secs?|m|mins?|min(?:utes?)?|h|hrs?|h(?:ours?)?|d|days?|w|wks?|w(?:eeks?)?|mo|months?|y|yrs?|y(?:ears?)?)\s*(?:ago\b)?|\d+\s*[smhdyw]\b|\d{1,2}:\d{2}\s*(?:am|pm)?)$/i,
|
|
387
|
+
/** Absolute dates as Facebook renders them in comment headers:
|
|
388
|
+
* "Wednesday, August 26, 2026 at 2:38 PM", "August 26, 2026 at 2:38 PM",
|
|
389
|
+
* "August 26 at 2:38 PM". */
|
|
390
|
+
absoluteTime: /^(?:[a-z]+day,\s*)?(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sept?(?:ember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s*\d{4})?(?:\s+at\s+\d{1,2}:\d{2}\s*(?:am|pm)?)?$/i,
|
|
391
|
+
/** Per-comment truncation expander ("See more" at the end of long comments). */
|
|
392
|
+
seeMore: /^\s*see more\s*$/i,
|
|
393
|
+
/** Author badges rendered inside comment articles. */
|
|
394
|
+
badge: /^(?:verified account|top fan|new member|founding member|active member|rising fan|contributor|admin|moderator|analyst)$/i,
|
|
395
|
+
/** Separator-only text lines ("·", "•", "…", "!") left between header and body. */
|
|
396
|
+
noiseLine: /^[·•|,!?:;.…\-–—\s]+$/
|
|
397
|
+
};
|
|
398
|
+
var loadMoreLimits = {
|
|
399
|
+
commentExpansions: 40,
|
|
400
|
+
replyExpansions: 60,
|
|
401
|
+
clicksPerRound: 5,
|
|
402
|
+
settleMs: 800
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/parsers/FacebookResponseParser.ts
|
|
406
|
+
function safeJsonParse(input) {
|
|
407
|
+
try {
|
|
408
|
+
return JSON.parse(input);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function walkJson(root, visit, options) {
|
|
414
|
+
let visited = 0;
|
|
415
|
+
const walk = (node, depth) => {
|
|
416
|
+
if (depth > options.maxDepth || visited > options.maxNodes) return;
|
|
417
|
+
if (!node || typeof node !== "object") return;
|
|
418
|
+
visited++;
|
|
419
|
+
visit(node);
|
|
420
|
+
for (const value of Object.values(node)) {
|
|
421
|
+
walk(value, depth + 1);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
walk(root, 0);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/extractors/StructuredDataExtractor.ts
|
|
428
|
+
function isCommentNode(node) {
|
|
429
|
+
if (!node || typeof node !== "object") return false;
|
|
430
|
+
const candidate = node;
|
|
431
|
+
return typeof candidate.message?.text === "string" && typeof candidate.author?.name === "string";
|
|
432
|
+
}
|
|
433
|
+
function mapNode(node) {
|
|
434
|
+
const created = typeof node.created_time === "number" ? node.created_time : null;
|
|
435
|
+
const reactions = typeof node.reaction_count === "number" ? node.reaction_count : typeof node.reactions?.summary?.total_count === "number" ? node.reactions.summary.total_count : null;
|
|
436
|
+
return {
|
|
437
|
+
authorId: typeof node.author?.id === "string" ? node.author.id : null,
|
|
438
|
+
authorName: typeof node.author?.name === "string" ? node.author.name : null,
|
|
439
|
+
authorProfileUrl: typeof node.author?.profile_url === "string" ? node.author.profile_url : null,
|
|
440
|
+
message: String(node.message?.text ?? ""),
|
|
441
|
+
createdAtRaw: created !== null && created > 0 ? new Date(created * 1e3).toISOString() : null,
|
|
442
|
+
reactionsTotal: reactions,
|
|
443
|
+
replies: []
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
var StructuredDataExtractor = class {
|
|
447
|
+
name = "structured-data";
|
|
448
|
+
async canHandle(page) {
|
|
449
|
+
return page.evaluate(
|
|
450
|
+
({ scriptSelector }) => Array.from(document.querySelectorAll(scriptSelector)).some((script) => {
|
|
451
|
+
const text = script.textContent ?? "";
|
|
452
|
+
return text.includes('"message"') && text.includes('"author"');
|
|
453
|
+
}),
|
|
454
|
+
{ scriptSelector: selectors.structuredDataScripts }
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
async extract(page, options) {
|
|
458
|
+
const blobs = await page.evaluate(
|
|
459
|
+
(scriptSelector) => Array.from(document.querySelectorAll(scriptSelector)).map((script) => script.textContent ?? "").filter((text) => text.length > 2 && text.length < 5e6).slice(0, 100),
|
|
460
|
+
selectors.structuredDataScripts
|
|
461
|
+
);
|
|
462
|
+
const out = [];
|
|
463
|
+
const cap = Math.max(options.maxComments * 3, 100);
|
|
464
|
+
for (const blob of blobs) {
|
|
465
|
+
const json = safeJsonParse(blob);
|
|
466
|
+
if (!json) continue;
|
|
467
|
+
walkJson(
|
|
468
|
+
json,
|
|
469
|
+
(node) => {
|
|
470
|
+
if (out.length < cap && isCommentNode(node)) out.push(mapNode(node));
|
|
471
|
+
},
|
|
472
|
+
{ maxNodes: 2e5, maxDepth: 40 }
|
|
473
|
+
);
|
|
474
|
+
if (out.length >= cap) break;
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// src/comments/DiscussionScope.ts
|
|
481
|
+
var DiscussionScope = class _DiscussionScope {
|
|
482
|
+
constructor(page, root) {
|
|
483
|
+
this.page = page;
|
|
484
|
+
this.root = root;
|
|
485
|
+
}
|
|
486
|
+
page;
|
|
487
|
+
root;
|
|
488
|
+
static wholePage(page) {
|
|
489
|
+
return new _DiscussionScope(page, null);
|
|
490
|
+
}
|
|
491
|
+
/** Coalesce an optional scope into a whole-page scope. */
|
|
492
|
+
static of(page, scope) {
|
|
493
|
+
return scope ?? _DiscussionScope.wholePage(page);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Resolve the discussion scope for the loaded page. Modal permalinks hydrate
|
|
497
|
+
* their dialog shortly after load, so wait briefly before deciding.
|
|
498
|
+
*
|
|
499
|
+
* Preference order:
|
|
500
|
+
* 1. visible foreground modal containing a permalink/link to the requested post
|
|
501
|
+
* 2. the single visible foreground modal
|
|
502
|
+
* 3. the whole page (non-modal layouts, or ambiguous nesting)
|
|
503
|
+
*/
|
|
504
|
+
static async resolve(page, postId) {
|
|
505
|
+
await page.waitForSelector(selectors.foregroundModal, { state: "attached", timeout: 5e3 }).catch(() => null);
|
|
506
|
+
const modals = page.locator(selectors.foregroundModal);
|
|
507
|
+
const count = await modals.count().catch(() => 0);
|
|
508
|
+
if (count === 0) return _DiscussionScope.wholePage(page);
|
|
509
|
+
const visible = [];
|
|
510
|
+
for (let i = 0; i < count; i++) {
|
|
511
|
+
if (await modals.nth(i).isVisible().catch(() => false)) {
|
|
512
|
+
visible.push(modals.nth(i));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (postId && /^\d+$/.test(postId) && visible.length > 0) {
|
|
516
|
+
const paths = [`/posts/${postId}/`, `/permalink/${postId}/`];
|
|
517
|
+
for (const modal of visible) {
|
|
518
|
+
const hasPostLink = await modal.evaluate(
|
|
519
|
+
(el, wanted) => Array.from(el.querySelectorAll("a[href]")).some((anchor) => {
|
|
520
|
+
try {
|
|
521
|
+
const pathname = new URL(anchor.href, window.location.href).pathname;
|
|
522
|
+
return wanted.some((segment) => pathname.includes(segment));
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}),
|
|
527
|
+
paths
|
|
528
|
+
).catch(() => false);
|
|
529
|
+
if (hasPostLink) return new _DiscussionScope(page, modal);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (visible.length === 1) return new _DiscussionScope(page, visible[0]);
|
|
533
|
+
return _DiscussionScope.wholePage(page);
|
|
534
|
+
}
|
|
535
|
+
/** Locator scoped to the modal when present, else page-wide. */
|
|
536
|
+
locator(selector) {
|
|
537
|
+
return this.root ? this.root.locator(selector) : this.page.locator(selector);
|
|
538
|
+
}
|
|
539
|
+
/** The modal element itself, for root-scoped page.evaluate calls. */
|
|
540
|
+
async rootElementHandle() {
|
|
541
|
+
return this.root ? await this.root.elementHandle().catch(() => null) : null;
|
|
542
|
+
}
|
|
543
|
+
/** True when interactions are scoped to a foreground modal. */
|
|
544
|
+
get isModal() {
|
|
545
|
+
return this.root !== null;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/extractors/domDump.ts
|
|
550
|
+
async function buildArticleDumps(page, options, scope) {
|
|
551
|
+
try {
|
|
552
|
+
const root = scope?.isModal ? await scope.rootElementHandle() : null;
|
|
553
|
+
const dumps = await page.evaluate(
|
|
554
|
+
({ containerSelector, maxArticles, root: root2 }) => {
|
|
555
|
+
const scopeRoot = root2 ?? document;
|
|
556
|
+
const dump = (el) => {
|
|
557
|
+
const links = [];
|
|
558
|
+
const images = [];
|
|
559
|
+
let ownText = "";
|
|
560
|
+
let avatarUrl = null;
|
|
561
|
+
const walk = (node, inLink) => {
|
|
562
|
+
let linkContext = inLink;
|
|
563
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
564
|
+
const elem = node;
|
|
565
|
+
if (elem !== el && elem.getAttribute("role") === "article") return;
|
|
566
|
+
if (elem.tagName === "A") {
|
|
567
|
+
linkContext = true;
|
|
568
|
+
const label = (elem.getAttribute("aria-label") ?? elem.textContent ?? "").trim() || null;
|
|
569
|
+
links.push({ href: elem.getAttribute("href") ?? "", label });
|
|
570
|
+
const hasImage = Boolean(elem.querySelector("img, image"));
|
|
571
|
+
if (label && !hasImage) ownText += label + "\n";
|
|
572
|
+
if (!hasImage) return;
|
|
573
|
+
}
|
|
574
|
+
const tag = elem.tagName.toUpperCase();
|
|
575
|
+
if (tag === "IMG" || tag === "IMAGE") {
|
|
576
|
+
const image = elem;
|
|
577
|
+
const rawUrl = image.currentSrc || elem.getAttribute("src") || elem.getAttribute("href") || elem.getAttribute("xlink:href") || "";
|
|
578
|
+
const alt = (elem.getAttribute("alt") ?? elem.getAttribute("aria-label") ?? "").trim() || null;
|
|
579
|
+
let attachment = false;
|
|
580
|
+
try {
|
|
581
|
+
const url = new URL(rawUrl, document.baseURI);
|
|
582
|
+
const host = url.hostname.toLowerCase();
|
|
583
|
+
const allowed = url.protocol === "https:" && (host === "fbcdn.net" || host.endsWith(".fbcdn.net"));
|
|
584
|
+
attachment = allowed && Boolean(alt) && (image.naturalWidth >= 120 || image.naturalHeight >= 120 || image.width >= 120 || image.height >= 120) && !/^(?:subscribe to|profile picture|avatar)/i.test(alt ?? "");
|
|
585
|
+
if (attachment) {
|
|
586
|
+
images.push({
|
|
587
|
+
url: url.toString(),
|
|
588
|
+
alt,
|
|
589
|
+
width: image.naturalWidth || image.width || null,
|
|
590
|
+
height: image.naturalHeight || image.height || null
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (!avatarUrl && linkContext && allowed && (!alt || /^(?:profile picture|avatar)/i.test(alt))) {
|
|
594
|
+
avatarUrl = url.toString();
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
if (!attachment && alt && !/^https?:/i.test(alt) && !/^(?:subscribe to|profile picture|avatar)/i.test(alt)) {
|
|
599
|
+
ownText += alt + "\n";
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
605
|
+
ownText += (node.textContent ?? "") + "\n";
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
for (const child of Array.from(node.childNodes)) walk(child, linkContext);
|
|
609
|
+
};
|
|
610
|
+
walk(el, false);
|
|
611
|
+
const children = [];
|
|
612
|
+
const findArticles = (node) => {
|
|
613
|
+
if (node !== el && node.getAttribute("role") === "article") {
|
|
614
|
+
const child = dump(node);
|
|
615
|
+
if (child) children.push(child);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
for (const child of Array.from(node.children)) findArticles(child);
|
|
619
|
+
};
|
|
620
|
+
findArticles(el);
|
|
621
|
+
return {
|
|
622
|
+
ariaLabel: el.getAttribute("aria-label"),
|
|
623
|
+
domId: el.id || null,
|
|
624
|
+
ownText: ownText.trim(),
|
|
625
|
+
fullText: el.innerText ?? ownText.trim(),
|
|
626
|
+
links: links.slice(0, 12),
|
|
627
|
+
images: images.slice(0, 12),
|
|
628
|
+
avatarUrl,
|
|
629
|
+
children
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
const all = Array.from(scopeRoot.querySelectorAll(containerSelector));
|
|
633
|
+
const tops = all.filter((el) => {
|
|
634
|
+
const parent = el.parentElement?.closest(containerSelector) ?? null;
|
|
635
|
+
return !parent || !scopeRoot.contains(parent);
|
|
636
|
+
});
|
|
637
|
+
return tops.slice(0, maxArticles).map(dump).filter((d) => d !== null);
|
|
638
|
+
},
|
|
639
|
+
{ containerSelector: options.containerSelector, maxArticles: options.maxArticles, root }
|
|
640
|
+
);
|
|
641
|
+
return dumps ?? [];
|
|
642
|
+
} catch {
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/comments/CommentParser.ts
|
|
648
|
+
var NON_PROFILE_SEGMENTS = /* @__PURE__ */ new Set([
|
|
649
|
+
"groups",
|
|
650
|
+
"help",
|
|
651
|
+
"policies",
|
|
652
|
+
"login",
|
|
653
|
+
"photo",
|
|
654
|
+
"photos",
|
|
655
|
+
"reel",
|
|
656
|
+
"share",
|
|
657
|
+
"watch",
|
|
658
|
+
"marketplace",
|
|
659
|
+
"hashtag",
|
|
660
|
+
"events",
|
|
661
|
+
"gaming",
|
|
662
|
+
"stories",
|
|
663
|
+
"video",
|
|
664
|
+
"videos",
|
|
665
|
+
"posts",
|
|
666
|
+
"permalink.php",
|
|
667
|
+
"settings",
|
|
668
|
+
"privacy",
|
|
669
|
+
"legal",
|
|
670
|
+
"notes",
|
|
671
|
+
"login.php",
|
|
672
|
+
"recover",
|
|
673
|
+
"pages",
|
|
674
|
+
"bookmarks",
|
|
675
|
+
"friends",
|
|
676
|
+
"feed",
|
|
677
|
+
"notifications",
|
|
678
|
+
"helpcenter"
|
|
679
|
+
]);
|
|
680
|
+
var ACTION_LINE = /^(?:like|reply|share|hide|report|delete|edit|see translation|view translation|most relevant|all comments|see more|show more|by author|liked by author|top sticker|giphy|via giphy|click to view attachment|\d+\s*(?:reactions?|likes?|replies?|comments?)|liked by .*|reply\s*·\s*.*)$/i;
|
|
681
|
+
function parseReactionCount(input) {
|
|
682
|
+
const match = input.trim().match(/^([\d.,]+)\s*([KkMm])?/);
|
|
683
|
+
if (!match) return null;
|
|
684
|
+
const base = Number.parseFloat(match[1]?.replace(/,/g, "") ?? "");
|
|
685
|
+
if (!Number.isFinite(base)) return null;
|
|
686
|
+
const suffix = match[2]?.toLowerCase();
|
|
687
|
+
if (suffix === "k") return Math.round(base * 1e3);
|
|
688
|
+
if (suffix === "m") return Math.round(base * 1e6);
|
|
689
|
+
return Math.round(base);
|
|
690
|
+
}
|
|
691
|
+
function isProfileUrl(href) {
|
|
692
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return false;
|
|
693
|
+
try {
|
|
694
|
+
const url = new URL(href, "https://www.facebook.com");
|
|
695
|
+
if (!/(?:^|\.)facebook\.com$/i.test(url.hostname.replace(/^www\./i, ""))) return false;
|
|
696
|
+
if (url.pathname.startsWith("/people/")) return true;
|
|
697
|
+
if (url.pathname.startsWith("/profile.php")) return true;
|
|
698
|
+
const first = url.pathname.split("/").filter(Boolean)[0];
|
|
699
|
+
if (!first || NON_PROFILE_SEGMENTS.has(first.toLowerCase())) return false;
|
|
700
|
+
return url.pathname.split("/").filter(Boolean).length === 1;
|
|
701
|
+
} catch {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function absoluteUrl(href) {
|
|
706
|
+
if (!href) return null;
|
|
707
|
+
try {
|
|
708
|
+
return new URL(href, "https://www.facebook.com").toString();
|
|
709
|
+
} catch {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function authorIdFromProfileUrl(href) {
|
|
714
|
+
if (!href) return null;
|
|
715
|
+
try {
|
|
716
|
+
const url = new URL(href);
|
|
717
|
+
const people = url.pathname.match(/^\/people\/[^/]+\/(\d+)/);
|
|
718
|
+
if (people?.[1]) return people[1];
|
|
719
|
+
const queryId = url.searchParams.get("id");
|
|
720
|
+
if (queryId) return queryId;
|
|
721
|
+
} catch {
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
function stripTrailingTimestamp(name) {
|
|
726
|
+
let out = name.trim();
|
|
727
|
+
for (let i = 0; i < 2; i++) {
|
|
728
|
+
out = out.replace(textPatterns.trailingTimestamp, "").trim();
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
function commentRefFromProfileUrl(href) {
|
|
733
|
+
if (!href) return null;
|
|
734
|
+
try {
|
|
735
|
+
const url = new URL(href);
|
|
736
|
+
const encoded = url.searchParams.get("comment_id") ?? url.searchParams.get("reply_id");
|
|
737
|
+
if (!encoded) return null;
|
|
738
|
+
const decoded = Buffer.from(
|
|
739
|
+
encoded.replace(/-/g, "+").replace(/_/g, "/"),
|
|
740
|
+
"base64"
|
|
741
|
+
).toString("utf8");
|
|
742
|
+
const match = decoded.match(/^(?:comment|reply):(\d+)_(\d+)/);
|
|
743
|
+
if (!match?.[1] || !match[2]) return null;
|
|
744
|
+
return { postRefId: match[1], commentId: match[2] };
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function timestampFrom(lines, ariaLabel) {
|
|
750
|
+
for (const line of lines) {
|
|
751
|
+
if (textPatterns.absoluteTime.test(line)) {
|
|
752
|
+
return line.replace(/^[a-z]+day,\s*/i, "").trim();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const line of lines) {
|
|
756
|
+
if (textPatterns.relativeTime.test(line)) {
|
|
757
|
+
const matched = line.match(textPatterns.trailingTimestamp)?.[0] ?? line;
|
|
758
|
+
return matched.replace(/^[·\s-]+/, "").trim();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const ariaTime = ariaLabel.split("\xB7").map((part) => part.trim()).find(Boolean);
|
|
762
|
+
if (ariaTime && textPatterns.relativeTime.test(ariaTime)) {
|
|
763
|
+
return ariaTime.match(textPatterns.trailingTimestamp)?.[0]?.trim() ?? ariaTime;
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
function parseArticleDump(dump) {
|
|
768
|
+
const ariaLabel = dump.ariaLabel ?? "";
|
|
769
|
+
const commentByMatch = ariaLabel.match(textPatterns.commentBy);
|
|
770
|
+
const replyByMatch = ariaLabel.match(textPatterns.replyBy) ?? ariaLabel.match(textPatterns.replyByFallback);
|
|
771
|
+
const authorTimeMatch = ariaLabel.match(textPatterns.authorTime);
|
|
772
|
+
const hasReplyLink = dump.links.some(
|
|
773
|
+
(link) => link.label !== null && textPatterns.reply.test(link.label)
|
|
774
|
+
);
|
|
775
|
+
const profileLink = dump.links.find((link) => isProfileUrl(link.href));
|
|
776
|
+
const profileUrl = profileLink ? absoluteUrl(profileLink.href) : null;
|
|
777
|
+
const commentRef = commentRefFromProfileUrl(profileUrl);
|
|
778
|
+
const lines = dump.ownText.split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
|
779
|
+
const contentLines = [];
|
|
780
|
+
const droppedLines = [];
|
|
781
|
+
let headerSkipped = false;
|
|
782
|
+
for (const line of lines) {
|
|
783
|
+
const authorName2 = stripTrailingTimestamp(
|
|
784
|
+
replyByMatch?.[1] ?? commentByMatch?.[1] ?? authorTimeMatch?.[1] ?? ""
|
|
785
|
+
);
|
|
786
|
+
if (!headerSkipped && authorName2 && (line.startsWith(authorName2) || textPatterns.authorTime.test(line))) {
|
|
787
|
+
headerSkipped = true;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (ACTION_LINE.test(line) || textPatterns.noiseLine.test(line) || textPatterns.badge.test(line) || textPatterns.reactions.test(line) || textPatterns.relativeTime.test(line) || textPatterns.absoluteTime.test(line)) {
|
|
791
|
+
droppedLines.push(line);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
contentLines.push(line);
|
|
795
|
+
}
|
|
796
|
+
const message = contentLines.join("\n").trim();
|
|
797
|
+
const attachments = dump.images.map((image) => ({
|
|
798
|
+
type: /sticker/i.test(image.alt ?? "") ? "sticker" : "image",
|
|
799
|
+
...image
|
|
800
|
+
}));
|
|
801
|
+
const looksLikeComment = (message.length > 0 || attachments.length > 0) && message.length < 3e3 && (hasReplyLink || commentByMatch !== null || replyByMatch !== null || authorTimeMatch !== null) && !(dump.children.length > 0 && contentLines.length > 40);
|
|
802
|
+
if (!looksLikeComment) return null;
|
|
803
|
+
const authorName = (profileLink?.label ?? "").trim() || stripTrailingTimestamp(
|
|
804
|
+
replyByMatch?.[1] ?? commentByMatch?.[1] ?? authorTimeMatch?.[1] ?? ""
|
|
805
|
+
) || null;
|
|
806
|
+
const reactionLine = droppedLines.find((line) => textPatterns.reactions.test(line));
|
|
807
|
+
return {
|
|
808
|
+
id: commentRef?.commentId ?? (dump.domId || null),
|
|
809
|
+
postRefId: commentRef?.postRefId ?? null,
|
|
810
|
+
authorId: authorIdFromProfileUrl(profileUrl),
|
|
811
|
+
authorName,
|
|
812
|
+
authorProfileUrl: profileUrl,
|
|
813
|
+
authorAvatarUrl: dump.avatarUrl ?? null,
|
|
814
|
+
message,
|
|
815
|
+
attachments: attachments.length > 0 ? attachments : void 0,
|
|
816
|
+
createdAtRaw: timestampFrom([...lines], ariaLabel),
|
|
817
|
+
reactionsTotal: reactionLine ? parseReactionCount(reactionLine) : null,
|
|
818
|
+
isReply: replyByMatch !== null || void 0,
|
|
819
|
+
parentAuthorName: replyByMatch?.[2]?.trim() || null,
|
|
820
|
+
replies: []
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
function parseArticleTree(dump, parentId) {
|
|
824
|
+
const parsed = parseArticleDump(dump);
|
|
825
|
+
if (parsed) {
|
|
826
|
+
parsed.parentId = parentId;
|
|
827
|
+
const replies = [];
|
|
828
|
+
for (const child of dump.children) {
|
|
829
|
+
replies.push(...parseArticleTree(child, parsed.id ?? null));
|
|
830
|
+
}
|
|
831
|
+
parsed.replies = replies;
|
|
832
|
+
return [parsed];
|
|
833
|
+
}
|
|
834
|
+
return dump.children.flatMap((child) => parseArticleTree(child, null));
|
|
835
|
+
}
|
|
836
|
+
function attachFlatReplies(comments) {
|
|
837
|
+
const tops = [];
|
|
838
|
+
for (const comment of comments) {
|
|
839
|
+
if (comment.isReply) {
|
|
840
|
+
const parent = [...tops].reverse().find((top) => !top.isReply && top.authorName === comment.parentAuthorName);
|
|
841
|
+
if (parent) {
|
|
842
|
+
comment.parentId = parent.id ?? comment.parentId ?? null;
|
|
843
|
+
parent.replies = [...parent.replies ?? [], comment];
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
tops.push(comment);
|
|
848
|
+
}
|
|
849
|
+
return tops;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/extractors/AccessibleDomExtractor.ts
|
|
853
|
+
var AccessibleDomExtractor = class {
|
|
854
|
+
name = "accessible-dom";
|
|
855
|
+
async canHandle(page, scope) {
|
|
856
|
+
return await DiscussionScope.of(page, scope).locator(selectors.commentArticle).count() > 0;
|
|
857
|
+
}
|
|
858
|
+
async extract(page, options, scope) {
|
|
859
|
+
const dumps = await buildArticleDumps(
|
|
860
|
+
page,
|
|
861
|
+
{
|
|
862
|
+
containerSelector: selectors.commentArticle,
|
|
863
|
+
// Headroom for the post wrapper plus reply threads.
|
|
864
|
+
maxArticles: Math.max(options.maxComments * 3, 50)
|
|
865
|
+
},
|
|
866
|
+
scope
|
|
867
|
+
);
|
|
868
|
+
const perRoot = dumps.map((dump) => parseArticleTree(dump, null));
|
|
869
|
+
const counts = perRoot.map((comments) => comments.length).sort((a, b) => b - a);
|
|
870
|
+
const max = counts[0] ?? 0;
|
|
871
|
+
const second = counts[1] ?? 0;
|
|
872
|
+
if (max >= 2 * second && max > 0) {
|
|
873
|
+
const best = perRoot.filter((comments) => comments.length === max);
|
|
874
|
+
if (best.length === 1) return attachFlatReplies(best[0] ?? []);
|
|
875
|
+
}
|
|
876
|
+
return attachFlatReplies(perRoot.flat());
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// src/extractors/FallbackDomExtractor.ts
|
|
881
|
+
var FallbackDomExtractor = class {
|
|
882
|
+
name = "fallback-dom";
|
|
883
|
+
async canHandle(page, scope) {
|
|
884
|
+
return await DiscussionScope.of(page, scope).locator(selectors.fallbackCommentContainer).count() > 0;
|
|
885
|
+
}
|
|
886
|
+
async extract(page, options, scope) {
|
|
887
|
+
const dumps = await buildArticleDumps(
|
|
888
|
+
page,
|
|
889
|
+
{
|
|
890
|
+
containerSelector: selectors.fallbackCommentContainer,
|
|
891
|
+
maxArticles: Math.max(options.maxComments * 3, 50)
|
|
892
|
+
},
|
|
893
|
+
scope
|
|
894
|
+
);
|
|
895
|
+
return attachFlatReplies(dumps.flatMap((dump) => parseArticleTree(dump, null)));
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// src/comments/ReplyFetcher.ts
|
|
900
|
+
async function expandReplies(scope) {
|
|
901
|
+
const rounds = Math.ceil(loadMoreLimits.replyExpansions / loadMoreLimits.clicksPerRound);
|
|
902
|
+
for (let round = 0; round < rounds; round++) {
|
|
903
|
+
const expanders = scope.locator(selectors.replyExpander).filter({ hasText: textPatterns.viewMoreReplies });
|
|
904
|
+
const count = Math.min(await expanders.count().catch(() => 0), loadMoreLimits.clicksPerRound);
|
|
905
|
+
if (count === 0) return;
|
|
906
|
+
for (let i = 0; i < count; i++) {
|
|
907
|
+
await expanders.nth(i).click({ timeout: 3e3 }).catch(() => {
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
await scope.page.waitForTimeout(loadMoreLimits.settleMs).catch(() => {
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/comments/CommentFetcher.ts
|
|
916
|
+
async function detectPageProblem(page) {
|
|
917
|
+
const url = page.url();
|
|
918
|
+
if (/(?:^|\/)login(?:\.php)?(?:[/?#]|$)/i.test(url)) {
|
|
919
|
+
return new FacebookSessionExpiredError(
|
|
920
|
+
"Facebook redirected to a login page \u2014 the session is no longer accepted. Re-run `fb-comments login` to refresh it."
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
let bodyText = "";
|
|
924
|
+
try {
|
|
925
|
+
bodyText = (await page.evaluate(() => document.body?.innerText ?? "")).slice(0, 2e4);
|
|
926
|
+
} catch {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
if (textPatterns.unavailable.test(bodyText)) {
|
|
930
|
+
return new FacebookPostNotFoundError(
|
|
931
|
+
"This content is not available. The post may have been deleted or is not visible to this account."
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
if (textPatterns.joinGroup.test(bodyText)) {
|
|
935
|
+
return new FacebookAccessDeniedError(
|
|
936
|
+
"Access denied: this group is private and the account has not joined it."
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
if (textPatterns.loginWall.test(bodyText)) {
|
|
940
|
+
return new FacebookSessionExpiredError(
|
|
941
|
+
"Facebook is showing a login wall \u2014 the session appears expired. Re-run `fb-comments login`."
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
async function commentCount(scope) {
|
|
947
|
+
return scope.locator(selectors.commentCandidate).count().catch(() => 0);
|
|
948
|
+
}
|
|
949
|
+
async function waitForCommentGrowth(scope, previous, timeoutMs = 5e3) {
|
|
950
|
+
const start = Date.now();
|
|
951
|
+
while (Date.now() - start < timeoutMs) {
|
|
952
|
+
const count = await commentCount(scope);
|
|
953
|
+
if (count > previous) return count;
|
|
954
|
+
await scope.page.waitForTimeout(250).catch(() => {
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
return commentCount(scope);
|
|
958
|
+
}
|
|
959
|
+
async function expandComments(scope, target) {
|
|
960
|
+
let lastCount = await commentCount(scope);
|
|
961
|
+
for (let round = 0; round < loadMoreLimits.commentExpansions; round++) {
|
|
962
|
+
if (lastCount >= target) return true;
|
|
963
|
+
const expanders = scope.locator(selectors.commentExpander).filter({ hasText: textPatterns.moreComments });
|
|
964
|
+
const count = Math.min(
|
|
965
|
+
await expanders.count().catch(() => 0),
|
|
966
|
+
loadMoreLimits.clicksPerRound
|
|
967
|
+
);
|
|
968
|
+
if (count === 0) return false;
|
|
969
|
+
for (let i = 0; i < count; i++) {
|
|
970
|
+
await expanders.nth(i).click({ timeout: 3e3 }).catch(() => {
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
const nextCount = await waitForCommentGrowth(scope, lastCount);
|
|
974
|
+
if (nextCount <= lastCount) return false;
|
|
975
|
+
lastCount = nextCount;
|
|
976
|
+
}
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
979
|
+
async function openCommentsPanel(scope) {
|
|
980
|
+
if (await commentCount(scope) > 0) return;
|
|
981
|
+
await scope.locator(selectors.commentOpenerControls).first().waitFor({ timeout: 8e3 }).catch(() => null);
|
|
982
|
+
const groups = [
|
|
983
|
+
scope.locator(selectors.commentExpander).filter({ hasText: textPatterns.commentOpener }),
|
|
984
|
+
scope.locator(selectors.commentOpenerControls)
|
|
985
|
+
];
|
|
986
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
987
|
+
if (await commentCount(scope) > 0) return;
|
|
988
|
+
let clicked = false;
|
|
989
|
+
for (const group of groups) {
|
|
990
|
+
const count = Math.min(await group.count().catch(() => 0), 8);
|
|
991
|
+
for (let i = 0; i < count && !clicked; i++) {
|
|
992
|
+
const el = group.nth(i);
|
|
993
|
+
const label = await el.getAttribute("aria-label").catch(() => null) ?? "";
|
|
994
|
+
if (textPatterns.commentComposer.test(label)) continue;
|
|
995
|
+
if (!await el.isVisible().catch(() => false)) continue;
|
|
996
|
+
clicked = true;
|
|
997
|
+
await el.click({ timeout: 3e3 }).catch(() => {
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
if (clicked) break;
|
|
1001
|
+
}
|
|
1002
|
+
if (!clicked) return;
|
|
1003
|
+
await scope.locator(selectors.commentArticle).first().waitFor({ timeout: 6e3 }).catch(() => null);
|
|
1004
|
+
await scope.page.waitForTimeout(loadMoreLimits.settleMs).catch(() => {
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function waitForStableArticles(scope, maxWaitMs = 8e3) {
|
|
1009
|
+
const start = Date.now();
|
|
1010
|
+
let last = -1;
|
|
1011
|
+
let stablePolls = 0;
|
|
1012
|
+
while (Date.now() - start < maxWaitMs) {
|
|
1013
|
+
const count = await commentCount(scope).catch(() => 0);
|
|
1014
|
+
if (count === last) {
|
|
1015
|
+
stablePolls++;
|
|
1016
|
+
if (stablePolls >= 2 && count > 0) return;
|
|
1017
|
+
} else {
|
|
1018
|
+
stablePolls = 0;
|
|
1019
|
+
last = count;
|
|
1020
|
+
}
|
|
1021
|
+
await scope.page.waitForTimeout(700).catch(() => {
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
async function expandSeeMore(scope) {
|
|
1026
|
+
for (let round = 0; round < 3; round++) {
|
|
1027
|
+
const buttons = scope.locator(selectors.commentExpander).filter({ hasText: textPatterns.seeMore });
|
|
1028
|
+
const count = Math.min(await buttons.count().catch(() => 0), 25);
|
|
1029
|
+
if (count === 0) return;
|
|
1030
|
+
for (let i = 0; i < count; i++) {
|
|
1031
|
+
await buttons.nth(i).click({ timeout: 1500 }).catch(() => {
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
await scope.page.waitForTimeout(loadMoreLimits.settleMs).catch(() => {
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
var CommentFetcher = class {
|
|
1039
|
+
constructor(pipeline) {
|
|
1040
|
+
this.pipeline = pipeline;
|
|
1041
|
+
}
|
|
1042
|
+
pipeline;
|
|
1043
|
+
async fetch(page, params) {
|
|
1044
|
+
const scope = await DiscussionScope.resolve(page, params.postId);
|
|
1045
|
+
await openCommentsPanel(scope);
|
|
1046
|
+
await waitForStableArticles(scope);
|
|
1047
|
+
const target = params.skipped + params.maxComments;
|
|
1048
|
+
await expandComments(scope, target);
|
|
1049
|
+
await expandSeeMore(scope);
|
|
1050
|
+
if (params.includeReplies) {
|
|
1051
|
+
await expandReplies(scope);
|
|
1052
|
+
}
|
|
1053
|
+
const extracted = await this.pipeline.extract(
|
|
1054
|
+
page,
|
|
1055
|
+
{
|
|
1056
|
+
maxComments: params.maxComments,
|
|
1057
|
+
includeReplies: params.includeReplies,
|
|
1058
|
+
maxReplies: params.includeReplies ? params.maxReplies : 0
|
|
1059
|
+
},
|
|
1060
|
+
scope
|
|
1061
|
+
);
|
|
1062
|
+
const loaderCount = await scope.locator(selectors.commentExpander).filter({ hasText: textPatterns.moreComments }).count().catch(() => 0);
|
|
1063
|
+
const raw = filterWrongPost(extracted, params.postId);
|
|
1064
|
+
return { raw, loaderPresent: loaderCount > 0 };
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
function dedupeComments(comments) {
|
|
1068
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1069
|
+
const seenIdlessContent = /* @__PURE__ */ new Set();
|
|
1070
|
+
const out = [];
|
|
1071
|
+
for (const comment of comments) {
|
|
1072
|
+
const message = comment.message ?? "";
|
|
1073
|
+
const mediaKey = (comment.attachments ?? []).map((attachment) => attachment.url).join("");
|
|
1074
|
+
const contentKey = `${comment.authorName ?? ""}\0${message}\0${mediaKey}` + (message.length < 30 && !mediaKey ? `\0${comment.createdAtRaw ?? ""}` : "");
|
|
1075
|
+
if (comment.id != null) {
|
|
1076
|
+
if (seenIds.has(comment.id)) continue;
|
|
1077
|
+
seenIds.add(comment.id);
|
|
1078
|
+
} else {
|
|
1079
|
+
if (seenIdlessContent.has(contentKey)) continue;
|
|
1080
|
+
seenIdlessContent.add(contentKey);
|
|
1081
|
+
}
|
|
1082
|
+
out.push(comment.replies?.length ? { ...comment, replies: dedupeComments(comment.replies) } : comment);
|
|
1083
|
+
}
|
|
1084
|
+
return out;
|
|
1085
|
+
}
|
|
1086
|
+
function filterWrongPost(comments, postId) {
|
|
1087
|
+
const deduped = dedupeComments(comments);
|
|
1088
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1089
|
+
for (const comment of deduped) {
|
|
1090
|
+
if (!comment.postRefId) continue;
|
|
1091
|
+
counts.set(comment.postRefId, (counts.get(comment.postRefId) ?? 0) + 1);
|
|
1092
|
+
}
|
|
1093
|
+
if (counts.size === 0) return deduped;
|
|
1094
|
+
const urlId = postId && /^\d+$/.test(postId) ? postId : null;
|
|
1095
|
+
let best = urlId && counts.has(urlId) ? urlId : null;
|
|
1096
|
+
if (!best) {
|
|
1097
|
+
let bestCount = 0;
|
|
1098
|
+
let tie = false;
|
|
1099
|
+
for (const [id, count] of counts) {
|
|
1100
|
+
if (count > bestCount) {
|
|
1101
|
+
best = id;
|
|
1102
|
+
bestCount = count;
|
|
1103
|
+
tie = false;
|
|
1104
|
+
} else if (count === bestCount) {
|
|
1105
|
+
tie = true;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (tie || !best) {
|
|
1109
|
+
if (urlId && counts.has(urlId)) best = urlId;
|
|
1110
|
+
else return deduped;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const belongs = (c) => !c.postRefId || c.postRefId === best;
|
|
1114
|
+
return deduped.filter(belongs).map((c) => ({ ...c, replies: c.replies?.filter(belongs) }));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// src/comments/PaginationHandler.ts
|
|
1118
|
+
var encodeBase64Url = (input) => Buffer.from(input, "utf8").toString("base64url");
|
|
1119
|
+
function encodeCursor(payload) {
|
|
1120
|
+
return encodeBase64Url(JSON.stringify(payload));
|
|
1121
|
+
}
|
|
1122
|
+
function decodeCursor(cursor) {
|
|
1123
|
+
let parsed;
|
|
1124
|
+
try {
|
|
1125
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1126
|
+
} catch {
|
|
1127
|
+
throw new FacebookClientError("Invalid pagination cursor.");
|
|
1128
|
+
}
|
|
1129
|
+
const payload = parsed;
|
|
1130
|
+
if (payload.v !== 1 || typeof payload.skipped !== "number" || payload.skipped < 0) {
|
|
1131
|
+
throw new FacebookClientError("Invalid pagination cursor.");
|
|
1132
|
+
}
|
|
1133
|
+
return { v: 1, skipped: payload.skipped, lastId: payload.lastId ?? null };
|
|
1134
|
+
}
|
|
1135
|
+
var initialCursor = () => ({ v: 1, skipped: 0, lastId: null });
|
|
1136
|
+
function paginate(all, cursor, max, sourceHasMore) {
|
|
1137
|
+
const page = all.slice(cursor.skipped, cursor.skipped + max);
|
|
1138
|
+
const remaining = all.length - (cursor.skipped + page.length);
|
|
1139
|
+
const hasMore = remaining > 0 || sourceHasMore && all.length >= cursor.skipped + max;
|
|
1140
|
+
return {
|
|
1141
|
+
comments: page,
|
|
1142
|
+
hasMore,
|
|
1143
|
+
nextCursor: hasMore ? encodeCursor({
|
|
1144
|
+
v: 1,
|
|
1145
|
+
skipped: cursor.skipped + page.length,
|
|
1146
|
+
lastId: page.length > 0 ? page[page.length - 1]?.id ?? null : cursor.lastId
|
|
1147
|
+
}) : null
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/parsers/CommentNormalizer.ts
|
|
1152
|
+
var RELATIVE_UNITS = [
|
|
1153
|
+
{ pattern: /^(\d+)\s*s(econds?)?$/i, unit: 1e3 },
|
|
1154
|
+
{ pattern: /^(\d+)\s*m(in(utes?)?)?$/i, unit: 6e4 },
|
|
1155
|
+
{ pattern: /^(\d+)\s*h(ours?)?$/i, unit: 36e5 },
|
|
1156
|
+
{ pattern: /^(\d+)\s*d(ays?)?$/i, unit: 864e5 },
|
|
1157
|
+
{ pattern: /^(\d+)\s*w(eeks?)?$/i, unit: 6048e5 },
|
|
1158
|
+
{ pattern: /^(\d+)\s*(months?)$/i, unit: 2592e6 },
|
|
1159
|
+
{ pattern: /^(\d+)\s*y(ea?rs?)?$/i, unit: 31536e6 }
|
|
1160
|
+
];
|
|
1161
|
+
function parseTimestamp(input, now = /* @__PURE__ */ new Date()) {
|
|
1162
|
+
if (!input) return null;
|
|
1163
|
+
const raw = input.trim().replace(/\s+ago$/i, "").replace(/^(an?)\s+/i, "1 ").replace(/^[a-z]+day,\s*/i, "").trim();
|
|
1164
|
+
if (!raw) return null;
|
|
1165
|
+
if (/\d{4}-\d{2}-\d{2}/.test(raw)) {
|
|
1166
|
+
const iso = new Date(raw);
|
|
1167
|
+
if (!Number.isNaN(iso.getTime())) return iso.toISOString();
|
|
1168
|
+
}
|
|
1169
|
+
if (/^\d{10}$/.test(raw)) return new Date(Number(raw) * 1e3).toISOString();
|
|
1170
|
+
if (/^just now$/i.test(raw)) return now.toISOString();
|
|
1171
|
+
if (/^yesterday/i.test(raw)) {
|
|
1172
|
+
const timeMatch = raw.match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i);
|
|
1173
|
+
const date = new Date(now);
|
|
1174
|
+
date.setDate(date.getDate() - 1);
|
|
1175
|
+
if (timeMatch) {
|
|
1176
|
+
let hours = Number(timeMatch[1]);
|
|
1177
|
+
const minutes = Number(timeMatch[2]);
|
|
1178
|
+
const meridiem = timeMatch[3]?.toLowerCase();
|
|
1179
|
+
if (meridiem === "pm" && hours < 12) hours += 12;
|
|
1180
|
+
if (meridiem === "am" && hours === 12) hours = 0;
|
|
1181
|
+
date.setHours(hours, minutes, 0, 0);
|
|
1182
|
+
}
|
|
1183
|
+
return date.toISOString();
|
|
1184
|
+
}
|
|
1185
|
+
for (const { pattern, unit } of RELATIVE_UNITS) {
|
|
1186
|
+
const match = raw.match(pattern);
|
|
1187
|
+
if (match?.[1]) {
|
|
1188
|
+
return new Date(now.getTime() - Number(match[1]) * unit).toISOString();
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const monthAt = raw.match(
|
|
1192
|
+
/^([A-Za-z]+)\s+(\d{1,2})(?:,?\s+(\d{4}))?(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)?)?/i
|
|
1193
|
+
);
|
|
1194
|
+
if (monthAt) {
|
|
1195
|
+
const month = (/* @__PURE__ */ new Date(`${monthAt[1]} 1, 2000`)).getMonth();
|
|
1196
|
+
if (!Number.isNaN(month)) {
|
|
1197
|
+
const year = monthAt[3] ? Number(monthAt[3]) : now.getFullYear();
|
|
1198
|
+
const date = new Date(year, month, Number(monthAt[2]), 0, 0, 0, 0);
|
|
1199
|
+
if (monthAt[4]) {
|
|
1200
|
+
let hours = Number(monthAt[4]);
|
|
1201
|
+
const meridiem = monthAt[6]?.toLowerCase();
|
|
1202
|
+
if (meridiem === "pm" && hours < 12) hours += 12;
|
|
1203
|
+
if (meridiem === "am" && hours === 12) hours = 0;
|
|
1204
|
+
date.setHours(hours, Number(monthAt[5] ?? 0), 0, 0);
|
|
1205
|
+
}
|
|
1206
|
+
if (!monthAt[3] && date.getTime() > now.getTime()) date.setFullYear(year - 1);
|
|
1207
|
+
return date.toISOString();
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const dayMonth = raw.match(
|
|
1211
|
+
/^(\d{1,2})\s+([A-Za-z]+),?\s+(\d{4})(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)?)?/i
|
|
1212
|
+
);
|
|
1213
|
+
if (dayMonth) {
|
|
1214
|
+
const month = (/* @__PURE__ */ new Date(`${dayMonth[2]} 1, 2000`)).getMonth();
|
|
1215
|
+
if (!Number.isNaN(month)) {
|
|
1216
|
+
let hours = Number(dayMonth[4] ?? 0);
|
|
1217
|
+
const meridiem = dayMonth[6]?.toLowerCase();
|
|
1218
|
+
if (meridiem === "pm" && hours < 12) hours += 12;
|
|
1219
|
+
if (meridiem === "am" && hours === 12) hours = 0;
|
|
1220
|
+
const date = new Date(
|
|
1221
|
+
Number(dayMonth[3]),
|
|
1222
|
+
month,
|
|
1223
|
+
Number(dayMonth[1]),
|
|
1224
|
+
hours,
|
|
1225
|
+
Number(dayMonth[5] ?? 0)
|
|
1226
|
+
);
|
|
1227
|
+
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
function normalizeComment(raw, parentId = null, now = /* @__PURE__ */ new Date()) {
|
|
1233
|
+
const replies = (raw.replies ?? []).map((reply) => normalizeComment(reply, raw.id ?? null, now));
|
|
1234
|
+
return {
|
|
1235
|
+
id: raw.id ?? null,
|
|
1236
|
+
author: {
|
|
1237
|
+
id: raw.authorId ?? null,
|
|
1238
|
+
name: raw.authorName ?? null,
|
|
1239
|
+
profileUrl: raw.authorProfileUrl ?? null,
|
|
1240
|
+
avatarUrl: raw.authorAvatarUrl ?? null
|
|
1241
|
+
},
|
|
1242
|
+
message: raw.message ?? "",
|
|
1243
|
+
createdAt: parseTimestamp(raw.createdAtRaw, now),
|
|
1244
|
+
...raw.attachments?.length ? { attachments: raw.attachments } : {},
|
|
1245
|
+
reactions: { total: raw.reactionsTotal ?? null },
|
|
1246
|
+
parentId,
|
|
1247
|
+
...replies.length > 0 ? { replies } : {}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/utils/url.ts
|
|
1252
|
+
var FB_HOST = /^(?:[a-z0-9-]+\.)*(?:facebook\.com|fb\.com|fb\.watch)$/i;
|
|
1253
|
+
function parseFacebookPostUrl(input) {
|
|
1254
|
+
let parsed;
|
|
1255
|
+
try {
|
|
1256
|
+
parsed = new URL(input.trim());
|
|
1257
|
+
} catch {
|
|
1258
|
+
throw new FacebookClientError(`Invalid URL: "${input.trim()}"`);
|
|
1259
|
+
}
|
|
1260
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1261
|
+
throw new FacebookClientError(`URL must use http or https: "${parsed.protocol}"`);
|
|
1262
|
+
}
|
|
1263
|
+
if (!FB_HOST.test(parsed.hostname)) {
|
|
1264
|
+
throw new FacebookClientError(`Not a Facebook URL: "${parsed.hostname}"`);
|
|
1265
|
+
}
|
|
1266
|
+
const path = parsed.pathname;
|
|
1267
|
+
const segments = path.split("/").filter(Boolean);
|
|
1268
|
+
let postId = null;
|
|
1269
|
+
let groupId = null;
|
|
1270
|
+
if (segments[0] === "groups" && segments.length >= 4) {
|
|
1271
|
+
groupId = segments[1] ?? null;
|
|
1272
|
+
const kindIndex = segments.findIndex((s) => s === "posts" || s === "permalink");
|
|
1273
|
+
if (kindIndex !== -1 && segments.length > kindIndex + 1) {
|
|
1274
|
+
postId = segments[kindIndex + 1] ?? null;
|
|
1275
|
+
}
|
|
1276
|
+
} else if (segments.length >= 2) {
|
|
1277
|
+
const kind = segments[segments.length - 2];
|
|
1278
|
+
if (kind && ["posts", "videos", "reel", "reels", "p", "photo", "comments", "share", "v", "r"].includes(
|
|
1279
|
+
kind
|
|
1280
|
+
)) {
|
|
1281
|
+
postId = segments[segments.length - 1] ?? null;
|
|
1282
|
+
} else if (segments[0] === "permalink.php") {
|
|
1283
|
+
postId = null;
|
|
1284
|
+
} else {
|
|
1285
|
+
const last = segments[segments.length - 1];
|
|
1286
|
+
if (last && /^(?:pfbid|\d{6,})/i.test(last)) postId = last;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (parsed.searchParams.has("story_fbid")) {
|
|
1290
|
+
postId = parsed.searchParams.get("story_fbid");
|
|
1291
|
+
}
|
|
1292
|
+
const fbid = parsed.searchParams.get("fbid");
|
|
1293
|
+
if (!postId && fbid) postId = fbid;
|
|
1294
|
+
const videoId = parsed.searchParams.get("v");
|
|
1295
|
+
if (!postId && videoId) postId = videoId;
|
|
1296
|
+
return { url: parsed.toString(), postId, groupId };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/parsers/PostParser.ts
|
|
1300
|
+
function parsePost(page, requestedUrl) {
|
|
1301
|
+
const source = page.url().startsWith("http") ? page.url() : requestedUrl;
|
|
1302
|
+
try {
|
|
1303
|
+
const parsed = parseFacebookPostUrl(source);
|
|
1304
|
+
return { id: parsed.postId, url: parsed.url };
|
|
1305
|
+
} catch {
|
|
1306
|
+
return { id: null, url: requestedUrl };
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// src/utils/logger.ts
|
|
1311
|
+
var SENSITIVE_KEY = /cookie|token|secret|password|passwd|storage.?state|authorization|\bxs\b|\bc_user\b|\bsb\b|\bfr\b/i;
|
|
1312
|
+
var MAX_DEPTH = 6;
|
|
1313
|
+
function redact(value, depth = 0) {
|
|
1314
|
+
if (depth > MAX_DEPTH) return "[TRUNCATED]";
|
|
1315
|
+
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
|
1316
|
+
if (value && typeof value === "object") {
|
|
1317
|
+
const out = {};
|
|
1318
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1319
|
+
out[key] = SENSITIVE_KEY.test(key) ? "[REDACTED]" : redact(val, depth + 1);
|
|
1320
|
+
}
|
|
1321
|
+
return out;
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof value === "string" && value.length > 2e3) {
|
|
1324
|
+
return `${value.slice(0, 2e3)}...[truncated]`;
|
|
1325
|
+
}
|
|
1326
|
+
return value;
|
|
1327
|
+
}
|
|
1328
|
+
var LEVEL_ORDER = {
|
|
1329
|
+
silent: 0,
|
|
1330
|
+
error: 1,
|
|
1331
|
+
warn: 2,
|
|
1332
|
+
info: 3,
|
|
1333
|
+
debug: 4
|
|
1334
|
+
};
|
|
1335
|
+
function createConsoleLogger(level = "warn") {
|
|
1336
|
+
const threshold = LEVEL_ORDER[level];
|
|
1337
|
+
const emit = (allowed, sink, args) => {
|
|
1338
|
+
if (threshold >= allowed) sink(args.map((arg) => redact(arg)));
|
|
1339
|
+
};
|
|
1340
|
+
return {
|
|
1341
|
+
debug: (msg, ...args) => emit(LEVEL_ORDER.debug, console.debug, [msg, ...args]),
|
|
1342
|
+
info: (msg, ...args) => emit(LEVEL_ORDER.info, console.info, [msg, ...args]),
|
|
1343
|
+
warn: (msg, ...args) => emit(LEVEL_ORDER.warn, console.warn, [msg, ...args]),
|
|
1344
|
+
error: (msg, ...args) => emit(LEVEL_ORDER.error, console.error, [msg, ...args])
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// src/client/FacebookCommentsClient.ts
|
|
1349
|
+
var DEFAULT_MAX_COMMENTS = 50;
|
|
1350
|
+
var DEFAULT_MAX_REPLIES = 20;
|
|
1351
|
+
function trimReplies(comment, maxReplies) {
|
|
1352
|
+
if (!comment.replies || comment.replies.length === 0) return comment;
|
|
1353
|
+
const replies = comment.replies.slice(0, maxReplies).map((reply) => trimReplies(reply, maxReplies));
|
|
1354
|
+
return { ...comment, replies };
|
|
1355
|
+
}
|
|
1356
|
+
var FacebookCommentsClient = class {
|
|
1357
|
+
constructor(options) {
|
|
1358
|
+
this.options = options;
|
|
1359
|
+
this.logger = options.logger ?? createConsoleLogger("warn");
|
|
1360
|
+
this.browserManager = new BrowserManager(options.browser);
|
|
1361
|
+
}
|
|
1362
|
+
options;
|
|
1363
|
+
browserManager;
|
|
1364
|
+
logger;
|
|
1365
|
+
/**
|
|
1366
|
+
* Interactive login helper: opens a visible browser, waits for you to log in
|
|
1367
|
+
* manually, then exports the storage state. Never enters credentials for you.
|
|
1368
|
+
*/
|
|
1369
|
+
static async login(loginOptions) {
|
|
1370
|
+
const timeoutMs = loginOptions.timeoutMs ?? 3e5;
|
|
1371
|
+
const browser = await chromium2.launch({ headless: false });
|
|
1372
|
+
try {
|
|
1373
|
+
const context = await browser.newContext();
|
|
1374
|
+
const page = await context.newPage();
|
|
1375
|
+
await page.goto("https://www.facebook.com/", { waitUntil: "domcontentloaded" });
|
|
1376
|
+
const deadline = Date.now() + timeoutMs;
|
|
1377
|
+
while (Date.now() < deadline) {
|
|
1378
|
+
const cookies = await context.cookies("https://www.facebook.com");
|
|
1379
|
+
if (cookies.some((cookie) => cookie.name === "c_user")) {
|
|
1380
|
+
const targetDir = dirname(loginOptions.outputPath);
|
|
1381
|
+
if (targetDir && !existsSync(targetDir)) {
|
|
1382
|
+
await mkdir(targetDir, { recursive: true });
|
|
1383
|
+
}
|
|
1384
|
+
await context.storageState({ path: loginOptions.outputPath });
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
await page.waitForTimeout(1500);
|
|
1388
|
+
}
|
|
1389
|
+
throw new FacebookAuthenticationError(
|
|
1390
|
+
`Login timed out after ${Math.round(timeoutMs / 1e3)}s \u2014 no Facebook session was established. Try again and finish logging in within the window.`
|
|
1391
|
+
);
|
|
1392
|
+
} finally {
|
|
1393
|
+
await browser.close().catch(() => {
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/** Fetch and normalize comments for one post URL. */
|
|
1398
|
+
async getComments(options) {
|
|
1399
|
+
const parsedUrl = parseFacebookPostUrl(options.url);
|
|
1400
|
+
const maxComments = options.maxComments ?? DEFAULT_MAX_COMMENTS;
|
|
1401
|
+
const maxReplies = options.maxReplies ?? DEFAULT_MAX_REPLIES;
|
|
1402
|
+
const includeReplies = options.includeReplies ?? false;
|
|
1403
|
+
const cursor = options.cursor ? decodeCursor(options.cursor) : initialCursor();
|
|
1404
|
+
const timeoutMs = options.timeout ?? this.options.browser?.timeoutMs ?? 3e4;
|
|
1405
|
+
const provider = createSessionProvider(this.options.session);
|
|
1406
|
+
const storageState = await provider.resolve();
|
|
1407
|
+
if (!hasAuthCookies(storageState)) {
|
|
1408
|
+
throw new FacebookAuthenticationError(
|
|
1409
|
+
"The configured session contains no Facebook auth cookies (c_user/xs). Capture a fresh session with `fb-comments login`."
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
const headless = options.headless ?? this.options.browser?.headless ?? true;
|
|
1413
|
+
const browser = await this.browserManager.launch(headless);
|
|
1414
|
+
const context = await createLoggedInContext(browser, { storageState, timeoutMs });
|
|
1415
|
+
try {
|
|
1416
|
+
const page = await context.newPage();
|
|
1417
|
+
this.logger.debug(`Navigating to ${parsedUrl.url}`);
|
|
1418
|
+
await page.goto(parsedUrl.url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
1419
|
+
const problem = await detectPageProblem(page);
|
|
1420
|
+
if (problem) throw problem;
|
|
1421
|
+
await page.waitForSelector(`${selectors.commentArticle}, ${selectors.commentOpenerControls}`, {
|
|
1422
|
+
timeout: Math.min(1e4, timeoutMs)
|
|
1423
|
+
}).catch(() => null);
|
|
1424
|
+
const fetcher = new CommentFetcher(
|
|
1425
|
+
new ExtractorPipeline([
|
|
1426
|
+
new AccessibleDomExtractor(),
|
|
1427
|
+
new StructuredDataExtractor(),
|
|
1428
|
+
new FallbackDomExtractor()
|
|
1429
|
+
])
|
|
1430
|
+
);
|
|
1431
|
+
const outcome = await fetcher.fetch(page, {
|
|
1432
|
+
maxComments,
|
|
1433
|
+
includeReplies,
|
|
1434
|
+
maxReplies,
|
|
1435
|
+
skipped: cursor.skipped,
|
|
1436
|
+
postId: parsedUrl.postId
|
|
1437
|
+
});
|
|
1438
|
+
const all = outcome.raw.map((raw) => normalizeComment(raw));
|
|
1439
|
+
const pageResult = paginate(all, cursor, maxComments, outcome.loaderPresent);
|
|
1440
|
+
this.logger.info(
|
|
1441
|
+
`Extracted ${pageResult.comments.length} comments (hasMore: ${pageResult.hasMore})`
|
|
1442
|
+
);
|
|
1443
|
+
return {
|
|
1444
|
+
post: parsePost(page, parsedUrl.url),
|
|
1445
|
+
comments: includeReplies ? pageResult.comments.map((comment) => trimReplies(comment, maxReplies)) : pageResult.comments,
|
|
1446
|
+
pagination: pageResult
|
|
1447
|
+
};
|
|
1448
|
+
} finally {
|
|
1449
|
+
await context.close().catch(() => {
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Cheap session check: resolves the session, verifies auth cookies, and
|
|
1455
|
+
* confirms Facebook still accepts them. Never throws for session problems —
|
|
1456
|
+
* inspect the returned flags instead.
|
|
1457
|
+
*/
|
|
1458
|
+
async validateSession() {
|
|
1459
|
+
let storageState;
|
|
1460
|
+
try {
|
|
1461
|
+
storageState = await createSessionProvider(this.options.session).resolve();
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
if (error instanceof FacebookClientError) {
|
|
1464
|
+
return { valid: false, authenticated: false };
|
|
1465
|
+
}
|
|
1466
|
+
throw error;
|
|
1467
|
+
}
|
|
1468
|
+
if (!hasAuthCookies(storageState)) {
|
|
1469
|
+
return { valid: true, authenticated: false };
|
|
1470
|
+
}
|
|
1471
|
+
const browser = await this.browserManager.launch(
|
|
1472
|
+
this.options.browser?.headless ?? true
|
|
1473
|
+
);
|
|
1474
|
+
const context = await createLoggedInContext(browser, {
|
|
1475
|
+
storageState,
|
|
1476
|
+
timeoutMs: this.options.browser?.timeoutMs ?? 3e4
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
const page = await context.newPage();
|
|
1480
|
+
const authenticated = await checkAuthenticatedOnPage(context, page);
|
|
1481
|
+
return {
|
|
1482
|
+
valid: true,
|
|
1483
|
+
authenticated,
|
|
1484
|
+
user: { name: null }
|
|
1485
|
+
};
|
|
1486
|
+
} finally {
|
|
1487
|
+
await context.close().catch(() => {
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
async close() {
|
|
1492
|
+
await this.browserManager.close();
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
// src/cli.ts
|
|
1497
|
+
var USAGE = `fb-comments \u2014 fetch Facebook post comments with an authorized session
|
|
1498
|
+
|
|
1499
|
+
Usage:
|
|
1500
|
+
fb-comments login [--output <path>] Interactive login (manual, headed browser)
|
|
1501
|
+
fb-comments comments <post-url> [flags] Fetch comments
|
|
1502
|
+
|
|
1503
|
+
Flags:
|
|
1504
|
+
--session <path> Storage-state file (default: ./facebook-session.json or $FACEBOOK_STORAGE_STATE)
|
|
1505
|
+
--replies Include reply threads
|
|
1506
|
+
--limit <n> Max top-level comments (default 50)
|
|
1507
|
+
--max-replies <n> Max replies per comment (default 20)
|
|
1508
|
+
--timeout <ms> Navigation timeout (default 30000)
|
|
1509
|
+
--headless Run headless even if the client default says otherwise
|
|
1510
|
+
--json Print raw JSON result
|
|
1511
|
+
-h, --help Show this help
|
|
1512
|
+
`;
|
|
1513
|
+
function flagValue(args, name) {
|
|
1514
|
+
const index = args.indexOf(name);
|
|
1515
|
+
return index !== -1 ? args[index + 1] : void 0;
|
|
1516
|
+
}
|
|
1517
|
+
function hasFlag(args, name) {
|
|
1518
|
+
return args.includes(name);
|
|
1519
|
+
}
|
|
1520
|
+
function resolveSession(cliSessionPath) {
|
|
1521
|
+
if (cliSessionPath) return { type: "file", path: cliSessionPath };
|
|
1522
|
+
if (process.env.FACEBOOK_STORAGE_STATE) {
|
|
1523
|
+
return { type: "env", envKey: "FACEBOOK_STORAGE_STATE" };
|
|
1524
|
+
}
|
|
1525
|
+
if (existsSync2("facebook-session.json")) {
|
|
1526
|
+
return { type: "file", path: "facebook-session.json" };
|
|
1527
|
+
}
|
|
1528
|
+
console.error(
|
|
1529
|
+
"No session found. Run `fb-comments login` or set FACEBOOK_STORAGE_STATE."
|
|
1530
|
+
);
|
|
1531
|
+
process.exit(1);
|
|
1532
|
+
}
|
|
1533
|
+
function printHuman(comments, hasMore) {
|
|
1534
|
+
if (comments.length === 0) {
|
|
1535
|
+
console.log("No comments found.");
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
for (const comment of comments) {
|
|
1539
|
+
const author = comment.author.name ?? "unknown";
|
|
1540
|
+
const time = comment.createdAt ?? "unknown time";
|
|
1541
|
+
const reactions = comment.reactions.total !== null ? ` [${comment.reactions.total} reactions]` : "";
|
|
1542
|
+
console.log(`
|
|
1543
|
+
${author} (${time})${reactions}
|
|
1544
|
+
${comment.message.replace(/\n/g, "\n ")}`);
|
|
1545
|
+
for (const reply of comment.replies ?? []) {
|
|
1546
|
+
const replyAuthor = reply.author.name ?? "unknown";
|
|
1547
|
+
console.log(` > ${replyAuthor}: ${reply.message.replace(/\n/g, "\n ")}`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
console.log(`
|
|
1551
|
+
${comments.length} comment(s)${hasMore ? " \u2014 more available, use the next cursor" : ""}`);
|
|
1552
|
+
}
|
|
1553
|
+
async function run() {
|
|
1554
|
+
const argv = process.argv.slice(2);
|
|
1555
|
+
const [command, ...rest] = argv;
|
|
1556
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1557
|
+
console.log(USAGE);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (command === "login") {
|
|
1561
|
+
const output = flagValue(rest, "--output") ?? "./facebook-session.json";
|
|
1562
|
+
console.log(`Opening Facebook for manual login. Session will be saved to: ${output}`);
|
|
1563
|
+
await FacebookCommentsClient.login({ outputPath: output });
|
|
1564
|
+
console.log("Session saved.");
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
if (command === "comments") {
|
|
1568
|
+
const url = rest.find((arg) => arg.startsWith("http"));
|
|
1569
|
+
if (!url) {
|
|
1570
|
+
console.error("A post URL is required:\n fb-comments comments <post-url>");
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
const session = resolveSession(flagValue(rest, "--session"));
|
|
1574
|
+
const client = new FacebookCommentsClient({ session });
|
|
1575
|
+
try {
|
|
1576
|
+
const result = await client.getComments({
|
|
1577
|
+
url,
|
|
1578
|
+
includeReplies: hasFlag(rest, "--replies"),
|
|
1579
|
+
maxComments: Number(flagValue(rest, "--limit") ?? 50),
|
|
1580
|
+
maxReplies: Number(flagValue(rest, "--max-replies") ?? 20),
|
|
1581
|
+
timeout: Number(flagValue(rest, "--timeout") ?? 3e4),
|
|
1582
|
+
headless: true
|
|
1583
|
+
});
|
|
1584
|
+
if (hasFlag(rest, "--json")) {
|
|
1585
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1586
|
+
} else {
|
|
1587
|
+
printHuman(result.comments, result.pagination.hasMore);
|
|
1588
|
+
}
|
|
1589
|
+
} finally {
|
|
1590
|
+
await client.close().catch(() => {
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
console.error(`Unknown command: ${command}
|
|
1596
|
+
`);
|
|
1597
|
+
console.log(USAGE);
|
|
1598
|
+
process.exit(1);
|
|
1599
|
+
}
|
|
1600
|
+
run().catch((error) => {
|
|
1601
|
+
if (error instanceof FacebookClientError) {
|
|
1602
|
+
console.error(`${error.name}: ${error.message}`);
|
|
1603
|
+
} else {
|
|
1604
|
+
console.error(error);
|
|
1605
|
+
}
|
|
1606
|
+
process.exit(1);
|
|
1607
|
+
});
|
|
1608
|
+
//# sourceMappingURL=cli.js.map
|