diggylab 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/cli.js +706 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,19 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
52
52
|
|
|
53
53
|
`CLI_PACKAGE_VERSION=0.1.31` works as well.
|
|
54
54
|
|
|
55
|
+
## Local development
|
|
56
|
+
|
|
57
|
+
Run the CLI from source against the local API without touching a real installation:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pnpm dev:cli # prints status against http://localhost:4000 and ~/.diggylab-dev
|
|
61
|
+
pnpm dev:cli pair # forwards any command to the CLI
|
|
62
|
+
pnpm dev:cli --build run # rebuilds dist-cli first, then runs the worker
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The wrapper points `DIGGYLAB_API_URL` at `http://localhost:4000` and `DIGGYLAB_AGENT_DIR` at
|
|
66
|
+
`~/.diggylab-dev`; set either variable to override the defaults.
|
|
67
|
+
|
|
55
68
|
## Commands
|
|
56
69
|
|
|
57
70
|
| Command | What it does |
|
package/cli.js
CHANGED
|
@@ -5,10 +5,12 @@ import { randomUUID as randomUUID3 } from "node:crypto";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7
7
|
import { hostname, homedir } from "node:os";
|
|
8
|
-
import { dirname as dirname3, join as
|
|
8
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
11
|
// ../../packages/facebook-scanner/dist/parser.js
|
|
12
|
+
var COMMENT_TEXT_LIMIT = 2e3;
|
|
13
|
+
var COMMENT_AUTHOR_LIMIT = 200;
|
|
12
14
|
var paths = {
|
|
13
15
|
text: [["message", "text"], ["comet_sections", "content", "story", "message", "text"], ["text"]],
|
|
14
16
|
url: [["url"], ["permalink_url"], ["wwwURL"], ["comet_sections", "content", "story", "url"]],
|
|
@@ -36,6 +38,35 @@ var textFormatPaths = [
|
|
|
36
38
|
"text_format_metadata"
|
|
37
39
|
]
|
|
38
40
|
];
|
|
41
|
+
var commentTextPaths = [
|
|
42
|
+
["body", "text"],
|
|
43
|
+
["comment_body", "text"],
|
|
44
|
+
["preferred_body", "text"]
|
|
45
|
+
];
|
|
46
|
+
var commentAuthorPaths = [
|
|
47
|
+
["author", "name"],
|
|
48
|
+
["author_name"],
|
|
49
|
+
["author", "short_name"]
|
|
50
|
+
];
|
|
51
|
+
var commentAvatarPaths = [
|
|
52
|
+
["author", "profile_picture_depth_0", "uri"],
|
|
53
|
+
["author", "profile_picture_depth_1", "uri"],
|
|
54
|
+
["author", "profile_picture_depth_0_increased", "uri"],
|
|
55
|
+
["author", "profile_picture", "uri"]
|
|
56
|
+
];
|
|
57
|
+
var commentTimePaths = [
|
|
58
|
+
["created_time"],
|
|
59
|
+
["creation_time"],
|
|
60
|
+
["publish_time"]
|
|
61
|
+
];
|
|
62
|
+
var commentCountPaths = [
|
|
63
|
+
["feedback", "comment_count", "total_count"],
|
|
64
|
+
["feedback", "comments", "total_count"],
|
|
65
|
+
["feedback", "total_comment_count"],
|
|
66
|
+
["comment_count", "total_count"],
|
|
67
|
+
["comments", "total_count"],
|
|
68
|
+
["total_comment_count"]
|
|
69
|
+
];
|
|
39
70
|
var facebookHosts = /* @__PURE__ */ new Set([
|
|
40
71
|
"facebook.com",
|
|
41
72
|
"fb.com",
|
|
@@ -197,6 +228,55 @@ function getTextFormat(node) {
|
|
|
197
228
|
};
|
|
198
229
|
return Object.values(textFormat).some(Boolean) ? textFormat : void 0;
|
|
199
230
|
}
|
|
231
|
+
function getCommentCount(node) {
|
|
232
|
+
return firstCount(node, commentCountPaths) ?? sweepCommentCount(node, 0);
|
|
233
|
+
}
|
|
234
|
+
function firstCount(root, candidates) {
|
|
235
|
+
for (const path of candidates) {
|
|
236
|
+
const count = toCount(get(root, path));
|
|
237
|
+
if (count !== void 0)
|
|
238
|
+
return count;
|
|
239
|
+
}
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
function sweepCommentCount(value, depth) {
|
|
243
|
+
if (!value || depth > 10)
|
|
244
|
+
return void 0;
|
|
245
|
+
if (Array.isArray(value)) {
|
|
246
|
+
for (const item of value) {
|
|
247
|
+
const count = sweepCommentCount(item, depth + 1);
|
|
248
|
+
if (count !== void 0)
|
|
249
|
+
return count;
|
|
250
|
+
}
|
|
251
|
+
return void 0;
|
|
252
|
+
}
|
|
253
|
+
if (!isData(value))
|
|
254
|
+
return void 0;
|
|
255
|
+
if ("comment_id" in value || "parent_comment" in value)
|
|
256
|
+
return void 0;
|
|
257
|
+
const typename = typeof value.__typename === "string" ? value.__typename : void 0;
|
|
258
|
+
if (typename && /comment$/i.test(typename))
|
|
259
|
+
return void 0;
|
|
260
|
+
const direct = toCount(value.comment_count) ?? toCount(value.total_comment_count) ?? toCount(value.comments);
|
|
261
|
+
if (direct !== void 0)
|
|
262
|
+
return direct;
|
|
263
|
+
for (const child of Object.values(value)) {
|
|
264
|
+
const count = sweepCommentCount(child, depth + 1);
|
|
265
|
+
if (count !== void 0)
|
|
266
|
+
return count;
|
|
267
|
+
}
|
|
268
|
+
return void 0;
|
|
269
|
+
}
|
|
270
|
+
function toCount(value) {
|
|
271
|
+
if (typeof value === "number") {
|
|
272
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
273
|
+
}
|
|
274
|
+
if (typeof value === "string" && /^\d{1,9}$/.test(value))
|
|
275
|
+
return Number(value);
|
|
276
|
+
if (isData(value))
|
|
277
|
+
return toCount(value.total_count);
|
|
278
|
+
return void 0;
|
|
279
|
+
}
|
|
200
280
|
function collectImageUrls(value, urls, depth) {
|
|
201
281
|
if (!value || depth > 12 || urls.size >= 12)
|
|
202
282
|
return;
|
|
@@ -258,12 +338,14 @@ function toItem(node, requestedGroup) {
|
|
|
258
338
|
attachmentImages = dedupeImageUrls([...fallbackUrls]);
|
|
259
339
|
}
|
|
260
340
|
const textFormat = getTextFormat(node);
|
|
341
|
+
const commentCount = getCommentCount(node);
|
|
261
342
|
return {
|
|
262
343
|
text,
|
|
263
344
|
url,
|
|
264
345
|
authorName: firstString(node, paths.author),
|
|
265
346
|
...authorAvatarUrl ? { authorAvatarUrl } : {},
|
|
266
347
|
...attachmentImages.length ? { attachmentImages } : {},
|
|
348
|
+
...commentCount !== void 0 ? { commentCount } : {},
|
|
267
349
|
...textFormat ? { textFormat } : {},
|
|
268
350
|
publishedAt: toIso(firstString(node, paths.time)),
|
|
269
351
|
groupName: firstString(node, paths.group)
|
|
@@ -298,6 +380,9 @@ function mergeFacebookScanItems(items, limit) {
|
|
|
298
380
|
if (!existing.authorAvatarUrl && item.authorAvatarUrl) {
|
|
299
381
|
existing.authorAvatarUrl = item.authorAvatarUrl;
|
|
300
382
|
}
|
|
383
|
+
if (existing.commentCount === void 0 && item.commentCount !== void 0) {
|
|
384
|
+
existing.commentCount = item.commentCount;
|
|
385
|
+
}
|
|
301
386
|
const attachmentImages = dedupeImageUrls([
|
|
302
387
|
...existing.attachmentImages ?? [],
|
|
303
388
|
...item.attachmentImages ?? []
|
|
@@ -327,6 +412,134 @@ function parseFacebookGraphqlBody(body, groupUrl) {
|
|
|
327
412
|
}
|
|
328
413
|
return mergeFacebookScanItems(items, Number.POSITIVE_INFINITY);
|
|
329
414
|
}
|
|
415
|
+
function toComment(node) {
|
|
416
|
+
const typename = typeof node.__typename === "string" ? node.__typename : void 0;
|
|
417
|
+
const text = firstString(node, commentTextPaths);
|
|
418
|
+
if (!text || typename && !/comment$/i.test(typename))
|
|
419
|
+
return void 0;
|
|
420
|
+
const authorName = firstString(node, commentAuthorPaths);
|
|
421
|
+
const authorAvatarUrl = firstString(node, commentAvatarPaths, false);
|
|
422
|
+
const depth = toDepth(node);
|
|
423
|
+
const publishedAt = toIso(firstString(node, commentTimePaths));
|
|
424
|
+
const id = firstString(node, [["id"]], false);
|
|
425
|
+
const parentId = firstString(node, [["comment_direct_parent", "id"]], false);
|
|
426
|
+
return {
|
|
427
|
+
text: text.trim(),
|
|
428
|
+
...authorName ? { authorName } : {},
|
|
429
|
+
...authorAvatarUrl ? { authorAvatarUrl } : {},
|
|
430
|
+
...depth !== void 0 ? { depth } : {},
|
|
431
|
+
...id ? { id } : {},
|
|
432
|
+
...parentId ? { parentId } : {},
|
|
433
|
+
...publishedAt ? { publishedAt } : {}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function toDepth(node) {
|
|
437
|
+
const value = node.depth;
|
|
438
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 5 ? value : void 0;
|
|
439
|
+
}
|
|
440
|
+
function visitComments(value, items) {
|
|
441
|
+
if (Array.isArray(value)) {
|
|
442
|
+
for (const item of value)
|
|
443
|
+
visitComments(item, items);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!isData(value))
|
|
447
|
+
return;
|
|
448
|
+
const comment = toComment(value);
|
|
449
|
+
if (comment)
|
|
450
|
+
items.push(comment);
|
|
451
|
+
for (const child of Object.values(value))
|
|
452
|
+
visitComments(child, items);
|
|
453
|
+
}
|
|
454
|
+
function parseFacebookCommentBodies(body) {
|
|
455
|
+
const items = [];
|
|
456
|
+
for (const line of body.split(/\r?\n/)) {
|
|
457
|
+
const json = line.trim().replace(/^for\s*\(;;\);/, "");
|
|
458
|
+
if (!json.startsWith("{") && !json.startsWith("["))
|
|
459
|
+
continue;
|
|
460
|
+
try {
|
|
461
|
+
visitComments(JSON.parse(json), items);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
|
|
466
|
+
}
|
|
467
|
+
function parseFacebookCommentJson(value) {
|
|
468
|
+
const items = [];
|
|
469
|
+
visitComments(value, items);
|
|
470
|
+
return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
|
|
471
|
+
}
|
|
472
|
+
function mergeFacebookComments(items, limit) {
|
|
473
|
+
const merged = /* @__PURE__ */ new Map();
|
|
474
|
+
for (const item of items) {
|
|
475
|
+
const text = item.text.trim();
|
|
476
|
+
if (!text)
|
|
477
|
+
continue;
|
|
478
|
+
const authorName = item.authorName?.trim();
|
|
479
|
+
const key = `${authorName ?? ""}\0${commentKeyText(text)}`;
|
|
480
|
+
const existing = merged.get(key);
|
|
481
|
+
if (existing) {
|
|
482
|
+
if (!existing.publishedAt && item.publishedAt)
|
|
483
|
+
existing.publishedAt = item.publishedAt;
|
|
484
|
+
if (!existing.authorAvatarUrl && item.authorAvatarUrl) {
|
|
485
|
+
existing.authorAvatarUrl = item.authorAvatarUrl;
|
|
486
|
+
}
|
|
487
|
+
if (!existing.id && item.id)
|
|
488
|
+
existing.id = item.id;
|
|
489
|
+
if (!existing.parentId && item.parentId)
|
|
490
|
+
existing.parentId = item.parentId;
|
|
491
|
+
if (item.depth !== void 0) {
|
|
492
|
+
existing.depth = existing.depth === void 0 ? item.depth : Math.min(existing.depth, item.depth);
|
|
493
|
+
}
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (merged.size >= limit)
|
|
497
|
+
continue;
|
|
498
|
+
merged.set(key, {
|
|
499
|
+
text: text.slice(0, COMMENT_TEXT_LIMIT),
|
|
500
|
+
...authorName ? { authorName: authorName.slice(0, COMMENT_AUTHOR_LIMIT) } : {},
|
|
501
|
+
...item.authorAvatarUrl ? { authorAvatarUrl: item.authorAvatarUrl } : {},
|
|
502
|
+
...item.depth !== void 0 ? { depth: item.depth } : {},
|
|
503
|
+
...item.id ? { id: item.id } : {},
|
|
504
|
+
...item.parentId ? { parentId: item.parentId } : {},
|
|
505
|
+
...item.publishedAt ? { publishedAt: item.publishedAt } : {}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return orderFacebookCommentThreads([...merged.values()]);
|
|
509
|
+
}
|
|
510
|
+
function orderFacebookCommentThreads(items) {
|
|
511
|
+
const byId = /* @__PURE__ */ new Map();
|
|
512
|
+
for (const item of items) {
|
|
513
|
+
if (item.id && !byId.has(item.id))
|
|
514
|
+
byId.set(item.id, item);
|
|
515
|
+
}
|
|
516
|
+
const children = /* @__PURE__ */ new Map();
|
|
517
|
+
const roots = [];
|
|
518
|
+
for (const item of items) {
|
|
519
|
+
if (item.parentId && byId.has(item.parentId)) {
|
|
520
|
+
children.set(item.parentId, [...children.get(item.parentId) ?? [], item]);
|
|
521
|
+
} else {
|
|
522
|
+
roots.push(item);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const ordered = [];
|
|
526
|
+
const visited = /* @__PURE__ */ new Set();
|
|
527
|
+
const append = (item, depth) => {
|
|
528
|
+
if (visited.has(item))
|
|
529
|
+
return;
|
|
530
|
+
visited.add(item);
|
|
531
|
+
ordered.push({ ...item, depth: Math.max(depth, item.depth ?? 0) });
|
|
532
|
+
for (const child of item.id ? children.get(item.id) ?? [] : [])
|
|
533
|
+
append(child, depth + 1);
|
|
534
|
+
};
|
|
535
|
+
for (const root of roots)
|
|
536
|
+
append(root, 0);
|
|
537
|
+
for (const item of items) {
|
|
538
|
+
if (!visited.has(item))
|
|
539
|
+
append(item, item.depth ?? 0);
|
|
540
|
+
}
|
|
541
|
+
return ordered;
|
|
542
|
+
}
|
|
330
543
|
async function extractFacebookDomItems(page, groupUrl) {
|
|
331
544
|
const requestedGroup = requestedGroupIdentifier(groupUrl);
|
|
332
545
|
if (!requestedGroup)
|
|
@@ -348,12 +561,16 @@ async function extractFacebookDomItems(page, groupUrl) {
|
|
|
348
561
|
const message = Array.from(article.querySelectorAll('[data-ad-preview="message"]')).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
349
562
|
const author = Array.from(article.querySelectorAll("h2 a, h3 a, strong a")).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
350
563
|
const time = Array.from(article.querySelectorAll("time[datetime], abbr[data-utime]")).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
564
|
+
const countText = Array.from(article.querySelectorAll('a, [role="button"]')).filter((element) => belongsToArticle(element) && element.checkVisibility()).map((element) => element.innerText.trim()).find((text) => /(?:^|\s)\d+\s*(?:bình luận|comments?)$/i.test(text));
|
|
565
|
+
const countMatch = countText ? /(\d+)\s*(?:bình luận|comments?)$/i.exec(countText) : null;
|
|
566
|
+
const commentCount = countMatch ? Number(countMatch[1]) : void 0;
|
|
351
567
|
const attachmentImages = Array.from(article.querySelectorAll("img")).filter((image) => belongsToArticle(image) && image.naturalWidth >= 200).map((image) => image.currentSrc || image.src).filter((src) => /^https?:\/\//i.test(src) && /(scontent\.|fbcdn\.net)/i.test(src));
|
|
352
568
|
return {
|
|
353
569
|
text: message?.innerText ?? "",
|
|
354
570
|
urls,
|
|
355
571
|
authorName: author?.innerText,
|
|
356
572
|
publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0,
|
|
573
|
+
...commentCount !== void 0 ? { commentCount } : {},
|
|
357
574
|
...attachmentImages.length ? { attachmentImages } : {}
|
|
358
575
|
};
|
|
359
576
|
}));
|
|
@@ -364,10 +581,80 @@ async function extractFacebookDomItems(page, groupUrl) {
|
|
|
364
581
|
url,
|
|
365
582
|
authorName: item.authorName,
|
|
366
583
|
publishedAt: item.publishedAt,
|
|
584
|
+
...item.commentCount !== void 0 ? { commentCount: item.commentCount } : {},
|
|
367
585
|
...item.attachmentImages?.length ? { attachmentImages: dedupeImageUrls(item.attachmentImages) } : {}
|
|
368
586
|
}] : [];
|
|
369
587
|
}), Number.POSITIVE_INFINITY);
|
|
370
588
|
}
|
|
589
|
+
async function extractFacebookDomComments(page) {
|
|
590
|
+
const raw = await page.locator('[role="article"][aria-label]').evaluateAll((articles) => articles.map((article) => {
|
|
591
|
+
const label = (article.getAttribute("aria-label") ?? "").trim();
|
|
592
|
+
if (!/^(?:bình luận|comment|trả lời|reply)\b/i.test(label) || !article.checkVisibility()) {
|
|
593
|
+
return void 0;
|
|
594
|
+
}
|
|
595
|
+
const belongsToArticle = (element) => element.closest('[role="article"]') === article;
|
|
596
|
+
const readText = (element) => {
|
|
597
|
+
let text = "";
|
|
598
|
+
for (const node of Array.from(element.childNodes)) {
|
|
599
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
600
|
+
text += node.textContent ?? "";
|
|
601
|
+
} else if (node instanceof HTMLImageElement) {
|
|
602
|
+
text += node.getAttribute("alt") ?? "";
|
|
603
|
+
} else if (node instanceof HTMLElement) {
|
|
604
|
+
text += readText(node);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return text.trim();
|
|
608
|
+
};
|
|
609
|
+
const texts = Array.from(article.querySelectorAll('[dir="auto"]')).filter((element) => belongsToArticle(element) && element.checkVisibility()).map((element) => readText(element)).filter(Boolean);
|
|
610
|
+
const time = Array.from(article.querySelectorAll("time[datetime], abbr[data-utime]")).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
611
|
+
return {
|
|
612
|
+
texts: Array.from(new Set(texts)),
|
|
613
|
+
authorLabel: label,
|
|
614
|
+
publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0
|
|
615
|
+
};
|
|
616
|
+
}));
|
|
617
|
+
return mergeFacebookComments(raw.flatMap((item) => {
|
|
618
|
+
if (!item)
|
|
619
|
+
return [];
|
|
620
|
+
const authorName = parseCommentAuthor(item.authorLabel);
|
|
621
|
+
const text = pickCommentText(item.texts, authorName);
|
|
622
|
+
if (!text)
|
|
623
|
+
return [];
|
|
624
|
+
const depth = parseCommentDepth(item.authorLabel);
|
|
625
|
+
return [{
|
|
626
|
+
text,
|
|
627
|
+
...authorName ? { authorName } : {},
|
|
628
|
+
...depth !== void 0 ? { depth } : {},
|
|
629
|
+
...toIso(item.publishedAt) ? { publishedAt: toIso(item.publishedAt) } : {}
|
|
630
|
+
}];
|
|
631
|
+
}), Number.POSITIVE_INFINITY);
|
|
632
|
+
}
|
|
633
|
+
function pickCommentText(texts, authorName) {
|
|
634
|
+
if (!Array.isArray(texts) || !texts.length)
|
|
635
|
+
return "";
|
|
636
|
+
const candidates = texts.filter((text) => text !== authorName && !COMMENT_UI_LABELS.test(text));
|
|
637
|
+
return candidates.reduce((longest, text) => text.length > longest.length ? text : longest, "");
|
|
638
|
+
}
|
|
639
|
+
var COMMENT_UI_LABELS = /^(?:follow|theo dõi|like|thích|reply|trả lời|share|chia sẻ|see more|xem thêm|hide|ẩn|report|báo cáo)$/i;
|
|
640
|
+
function commentKeyText(text) {
|
|
641
|
+
const normalized = text.replace(/[\p{Extended_Pictographic}\u200d\uFE0F]/gu, "").replace(/[:;=8xX]-?[)(\]\[dDpP/\\|]+/g, "").replace(/\s+/g, " ").trim();
|
|
642
|
+
return normalized || text.replace(/\s+/g, " ");
|
|
643
|
+
}
|
|
644
|
+
function parseCommentAuthor(label) {
|
|
645
|
+
const match = /^(?:bình luận|comment|trả lời|reply)\s+(?:dưới tên|by)\s+(.+)$/i.exec(label.trim());
|
|
646
|
+
if (!match)
|
|
647
|
+
return void 0;
|
|
648
|
+
const name = match[1].replace(/\s+vào\s+.*$/i, "").replace(/\s+on\s+.*$/i, "").replace(/\s+to\s+.*$/i, "").replace(/\s+\d+\s*(?:giây|phút|giờ|ngày|tuần|tháng|năm|seconds?|minutes?|hours?|days?|weeks?|months?|years?)\s*(?:ago|trước)?\s*$/i, "").trim();
|
|
649
|
+
return name || void 0;
|
|
650
|
+
}
|
|
651
|
+
function parseCommentDepth(label) {
|
|
652
|
+
const trimmed = label.trim();
|
|
653
|
+
if (/^(?:bình luận|comment)\b/i.test(trimmed))
|
|
654
|
+
return 0;
|
|
655
|
+
const parentPart = trimmed.split(/\s+(?:to|về|của)\s+/i)[1] ?? "";
|
|
656
|
+
return /\brepl(?:y|ies)\b|trả lời|phản hồi/i.test(parentPart) ? 2 : 1;
|
|
657
|
+
}
|
|
371
658
|
|
|
372
659
|
// ../../packages/facebook-scanner/dist/session.js
|
|
373
660
|
async function detectFacebookSession(page, context) {
|
|
@@ -403,13 +690,19 @@ var FacebookScannerError = class extends Error {
|
|
|
403
690
|
var FacebookScanner = class {
|
|
404
691
|
browserType;
|
|
405
692
|
extractDomItems;
|
|
693
|
+
extractDomComments;
|
|
406
694
|
parseGraphqlBody;
|
|
695
|
+
parseGraphqlComments;
|
|
696
|
+
parseCommentJson;
|
|
407
697
|
graphqlResponseTimeoutMs;
|
|
408
698
|
running = false;
|
|
409
699
|
constructor(options) {
|
|
410
700
|
this.browserType = options.browserType;
|
|
411
701
|
this.extractDomItems = options.extractDomItems ?? extractFacebookDomItems;
|
|
702
|
+
this.extractDomComments = options.extractDomComments ?? extractFacebookDomComments;
|
|
412
703
|
this.parseGraphqlBody = options.parseGraphqlBody ?? parseFacebookGraphqlBody;
|
|
704
|
+
this.parseGraphqlComments = options.parseGraphqlComments ?? parseFacebookCommentBodies;
|
|
705
|
+
this.parseCommentJson = options.parseCommentJson ?? parseFacebookCommentJson;
|
|
413
706
|
this.graphqlResponseTimeoutMs = options.graphqlResponseTimeoutMs ?? 1e4;
|
|
414
707
|
}
|
|
415
708
|
async scanMany(options) {
|
|
@@ -431,6 +724,79 @@ var FacebookScanner = class {
|
|
|
431
724
|
this.running = false;
|
|
432
725
|
}
|
|
433
726
|
}
|
|
727
|
+
async fetchComments(options) {
|
|
728
|
+
if (this.running)
|
|
729
|
+
throw new FacebookScannerError("SCANNER_BUSY");
|
|
730
|
+
const target = normalizePostUrl(options.postUrl);
|
|
731
|
+
if (!target)
|
|
732
|
+
throw new FacebookScannerError("POST_URL_INVALID");
|
|
733
|
+
assertNotAborted(options.signal);
|
|
734
|
+
this.running = true;
|
|
735
|
+
let browser = null;
|
|
736
|
+
try {
|
|
737
|
+
browser = await this.browserType.launch({ headless: true });
|
|
738
|
+
const context = await browser.newContext({ storageState: options.storageState });
|
|
739
|
+
let page = null;
|
|
740
|
+
const graphqlComments = [];
|
|
741
|
+
const responseTasks = [];
|
|
742
|
+
const responseHandler = (response) => {
|
|
743
|
+
if (!response.url().includes("/api/graphql/"))
|
|
744
|
+
return;
|
|
745
|
+
responseTasks.push(withTimeout(response.text(), this.graphqlResponseTimeoutMs).then((body) => graphqlComments.push(...this.parseGraphqlComments(body))).then(() => void 0).catch(() => void 0));
|
|
746
|
+
};
|
|
747
|
+
try {
|
|
748
|
+
page = await context.newPage();
|
|
749
|
+
page.on("response", responseHandler);
|
|
750
|
+
await page.route("**/*", (route) => ["font", "image", "media"].includes(route.request().resourceType()) ? route.abort() : route.continue());
|
|
751
|
+
try {
|
|
752
|
+
await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
753
|
+
} catch {
|
|
754
|
+
throw new FacebookScannerError("NAVIGATION_FAILED");
|
|
755
|
+
}
|
|
756
|
+
const condition = await detectFacebookSession(page, context);
|
|
757
|
+
if (condition !== "READY")
|
|
758
|
+
throw new FacebookScannerError(condition);
|
|
759
|
+
await options.onProgress?.({ phase: "FETCHING", commentCount: 0 });
|
|
760
|
+
const embeddedComments = await readEmbeddedComments(page, this.parseCommentJson);
|
|
761
|
+
let comments = [];
|
|
762
|
+
let idleRounds = 0;
|
|
763
|
+
let rounds = 0;
|
|
764
|
+
while (comments.length < options.limit && idleRounds < 3 && rounds < 20) {
|
|
765
|
+
assertNotAborted(options.signal);
|
|
766
|
+
rounds += 1;
|
|
767
|
+
const previousCount = comments.length;
|
|
768
|
+
await page.waitForTimeout(1200);
|
|
769
|
+
await drain(responseTasks, this.graphqlResponseTimeoutMs);
|
|
770
|
+
const domComments2 = await this.extractDomComments(page);
|
|
771
|
+
comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments2], options.limit);
|
|
772
|
+
if (comments.length !== previousCount) {
|
|
773
|
+
await options.onProgress?.({ phase: "FETCHING", commentCount: comments.length });
|
|
774
|
+
}
|
|
775
|
+
const expanded = await clickCommentExpanders(page);
|
|
776
|
+
idleRounds = comments.length === previousCount && !expanded ? idleRounds + 1 : 0;
|
|
777
|
+
if (comments.length < options.limit) {
|
|
778
|
+
await scrollCommentArea(page);
|
|
779
|
+
await page.mouse.wheel(0, 2e3);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
assertNotAborted(options.signal);
|
|
783
|
+
await drain(responseTasks, this.graphqlResponseTimeoutMs);
|
|
784
|
+
const domComments = await this.extractDomComments(page);
|
|
785
|
+
comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments], options.limit);
|
|
786
|
+
await options.onProgress?.({ phase: "COMPLETE", commentCount: comments.length });
|
|
787
|
+
return comments;
|
|
788
|
+
} finally {
|
|
789
|
+
page?.off("response", responseHandler);
|
|
790
|
+
await page?.unroute("**/*").catch(() => void 0);
|
|
791
|
+
await Promise.allSettled(responseTasks);
|
|
792
|
+
await page?.close();
|
|
793
|
+
await context.close();
|
|
794
|
+
}
|
|
795
|
+
} finally {
|
|
796
|
+
await browser?.close();
|
|
797
|
+
this.running = false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
434
800
|
async scanGroup(browser, options, groupUrl, groupIndex) {
|
|
435
801
|
const context = await browser.newContext({ storageState: options.storageState });
|
|
436
802
|
let page = null;
|
|
@@ -510,6 +876,58 @@ function withTimeout(promise, timeoutMs) {
|
|
|
510
876
|
});
|
|
511
877
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
512
878
|
}
|
|
879
|
+
var COMMENT_EXPANDER_PATTERN = /^(?:xem|view)\b.*(?:bình luận|comments?|câu trả lời|repl(?:y|ies))/i;
|
|
880
|
+
var MAX_EXPANDER_CLICKS = 4;
|
|
881
|
+
async function clickCommentExpanders(page) {
|
|
882
|
+
let clicked = 0;
|
|
883
|
+
for (let attempt = 0; attempt < MAX_EXPANDER_CLICKS; attempt += 1) {
|
|
884
|
+
const buttons = page.getByRole("button", { name: COMMENT_EXPANDER_PATTERN });
|
|
885
|
+
let count = 0;
|
|
886
|
+
try {
|
|
887
|
+
count = await buttons.count();
|
|
888
|
+
} catch {
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
if (!count)
|
|
892
|
+
break;
|
|
893
|
+
try {
|
|
894
|
+
await buttons.first().click({ timeout: 1e3 });
|
|
895
|
+
clicked += 1;
|
|
896
|
+
} catch {
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return clicked;
|
|
901
|
+
}
|
|
902
|
+
async function scrollCommentArea(page) {
|
|
903
|
+
await page.evaluate(() => {
|
|
904
|
+
const article = document.querySelector('[role="article"][aria-label]');
|
|
905
|
+
let node = article instanceof HTMLElement ? article.parentElement : null;
|
|
906
|
+
while (node) {
|
|
907
|
+
if (node.scrollHeight > node.clientHeight + 80) {
|
|
908
|
+
node.scrollTop = node.scrollHeight;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
node = node.parentElement;
|
|
912
|
+
}
|
|
913
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
914
|
+
}).catch(() => void 0);
|
|
915
|
+
}
|
|
916
|
+
async function readEmbeddedComments(page, parse) {
|
|
917
|
+
const blocks = await page.evaluate(() => Array.from(document.querySelectorAll('script[type="application/json"]')).map((script) => script.textContent ?? "").filter((text) => text.includes('"Comment"'))).catch(() => []);
|
|
918
|
+
if (!Array.isArray(blocks))
|
|
919
|
+
return [];
|
|
920
|
+
const items = [];
|
|
921
|
+
for (const block of blocks) {
|
|
922
|
+
if (typeof block !== "string")
|
|
923
|
+
continue;
|
|
924
|
+
try {
|
|
925
|
+
items.push(...parse(JSON.parse(block)));
|
|
926
|
+
} catch {
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
|
|
930
|
+
}
|
|
513
931
|
|
|
514
932
|
// src/cli/cli.ts
|
|
515
933
|
import { chromium } from "playwright";
|
|
@@ -625,7 +1043,7 @@ function sanitize(value) {
|
|
|
625
1043
|
return value.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]").replace(/\b(authorization|cookie|set-cookie|token|access[ _-]?token|refresh[ _-]?token|api[ _-]?key|private[ _-]?key|signature|pairing[ _-]?code|storage[ _-]?state)\b\s*[:=]\s*[^\s;]+/gi, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/gi, "https://[REDACTED]@").replace(/\b[A-F0-9]{32}\b/gi, "[REDACTED]").replace(/[\r\n\t]+/g, " ").trim();
|
|
626
1044
|
}
|
|
627
1045
|
function isEntry(value) {
|
|
628
|
-
return typeof value?.id === "string" && typeof value.timestamp === "string" && ["INFO", "WARN", "ERROR"].includes(value.level) && ["CONNECTION", "PAIRING", "FACEBOOK", "SCAN", "UPLOAD", "SYSTEM"].includes(value.category) && typeof value.code === "string" && typeof value.message === "string";
|
|
1046
|
+
return typeof value?.id === "string" && typeof value.timestamp === "string" && ["INFO", "WARN", "ERROR"].includes(value.level) && ["CONNECTION", "PAIRING", "FACEBOOK", "SCAN", "COMMENTS", "UPLOAD", "SYSTEM"].includes(value.category) && typeof value.code === "string" && typeof value.message === "string";
|
|
629
1047
|
}
|
|
630
1048
|
|
|
631
1049
|
// src/main/browser-runtime.ts
|
|
@@ -926,6 +1344,8 @@ import { randomUUID } from "node:crypto";
|
|
|
926
1344
|
|
|
927
1345
|
// ../../packages/shared/src/index.ts
|
|
928
1346
|
var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
|
|
1347
|
+
var DESKTOP_POST_COMMENTS_MAX_LIMIT = 200;
|
|
1348
|
+
var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
|
|
929
1349
|
|
|
930
1350
|
// src/main/desktop-agent-session.ts
|
|
931
1351
|
var MESSAGE_FRESHNESS_MS = 5 * 60 * 1e3;
|
|
@@ -1091,7 +1511,8 @@ var DesktopAgentSession = class {
|
|
|
1091
1511
|
platform: this.options.platform,
|
|
1092
1512
|
state: this.reportedState,
|
|
1093
1513
|
facebookReady: this.options.getFacebookReady(),
|
|
1094
|
-
currentJobId: this.reportedJobId
|
|
1514
|
+
currentJobId: this.reportedJobId,
|
|
1515
|
+
capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE"]
|
|
1095
1516
|
});
|
|
1096
1517
|
this.handshake = "WELCOME";
|
|
1097
1518
|
return;
|
|
@@ -1135,8 +1556,15 @@ var DesktopAgentSession = class {
|
|
|
1135
1556
|
this.emitJob({ type: "STOP", ...envelope.payload });
|
|
1136
1557
|
return;
|
|
1137
1558
|
}
|
|
1559
|
+
if (envelope.type === "POST_COMMENTS_FETCH") {
|
|
1560
|
+
if (!isPostCommentsFetch(envelope.payload)) {
|
|
1561
|
+
this.protocolFailure(socket, stored, generation);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
this.emitJob({ type: "COMMENTS", ...envelope.payload });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1138
1567
|
if (envelope.type !== "SCAN_JOB_START") {
|
|
1139
|
-
this.protocolFailure(socket, stored, generation);
|
|
1140
1568
|
return;
|
|
1141
1569
|
}
|
|
1142
1570
|
this.update({ ...this.snapshot, currentJobId: envelope.payload.jobId, pendingJobId: null });
|
|
@@ -1280,6 +1708,11 @@ function describeEnvelopeProblem(envelope, deviceId, replayIds) {
|
|
|
1280
1708
|
if (replayIds.has(envelope.messageId)) return `replayed message ${envelope.messageId}`;
|
|
1281
1709
|
return "unknown";
|
|
1282
1710
|
}
|
|
1711
|
+
function isPostCommentsFetch(value) {
|
|
1712
|
+
if (!value || typeof value !== "object") return false;
|
|
1713
|
+
const payload = value;
|
|
1714
|
+
return typeof payload.requestId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.requestId) && typeof payload.postUrl === "string" && payload.postUrl.length <= 2e3 && /^https:\/\/[\w.-]*facebook\.com\//i.test(payload.postUrl) && typeof payload.limit === "number" && Number.isInteger(payload.limit) && payload.limit > 0 && payload.limit <= DESKTOP_POST_COMMENTS_MAX_LIMIT;
|
|
1715
|
+
}
|
|
1283
1716
|
function emptySnapshot(state) {
|
|
1284
1717
|
return {
|
|
1285
1718
|
state,
|
|
@@ -1309,6 +1742,161 @@ function readErrorCode(error) {
|
|
|
1309
1742
|
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN_ERROR";
|
|
1310
1743
|
}
|
|
1311
1744
|
|
|
1745
|
+
// src/main/desktop-comment-runner.ts
|
|
1746
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 2 * 60 * 1e3;
|
|
1747
|
+
var DEFAULT_MAX_QUEUE = 5;
|
|
1748
|
+
var DesktopCommentRunner = class {
|
|
1749
|
+
options;
|
|
1750
|
+
queue = [];
|
|
1751
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1752
|
+
stopController = new AbortController();
|
|
1753
|
+
active = null;
|
|
1754
|
+
currentController = null;
|
|
1755
|
+
unsubscribe = null;
|
|
1756
|
+
stopped = false;
|
|
1757
|
+
constructor(options) {
|
|
1758
|
+
this.options = options;
|
|
1759
|
+
}
|
|
1760
|
+
start() {
|
|
1761
|
+
if (this.unsubscribe) return;
|
|
1762
|
+
this.unsubscribe = this.options.session.subscribeJobs((event) => this.handleEvent(event));
|
|
1763
|
+
}
|
|
1764
|
+
stop() {
|
|
1765
|
+
this.stopped = true;
|
|
1766
|
+
this.queue.length = 0;
|
|
1767
|
+
this.stopController.abort();
|
|
1768
|
+
this.currentController?.abort();
|
|
1769
|
+
this.unsubscribe?.();
|
|
1770
|
+
this.unsubscribe = null;
|
|
1771
|
+
}
|
|
1772
|
+
isRunning() {
|
|
1773
|
+
return this.active !== null;
|
|
1774
|
+
}
|
|
1775
|
+
subscribe(listener) {
|
|
1776
|
+
this.listeners.add(listener);
|
|
1777
|
+
return () => this.listeners.delete(listener);
|
|
1778
|
+
}
|
|
1779
|
+
async whenIdle() {
|
|
1780
|
+
await this.active;
|
|
1781
|
+
}
|
|
1782
|
+
async handleEvent(event) {
|
|
1783
|
+
if (event.type !== "COMMENTS" || this.stopped) return;
|
|
1784
|
+
if (this.queue.length >= (this.options.maxQueue ?? DEFAULT_MAX_QUEUE)) {
|
|
1785
|
+
await this.fail(event, "QUEUE_FULL");
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
this.queue.push(event);
|
|
1789
|
+
await this.drain();
|
|
1790
|
+
}
|
|
1791
|
+
drain() {
|
|
1792
|
+
if (!this.active) {
|
|
1793
|
+
this.active = this.run().finally(() => {
|
|
1794
|
+
this.active = null;
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
return this.active;
|
|
1798
|
+
}
|
|
1799
|
+
async run() {
|
|
1800
|
+
while (this.queue.length > 0 && !this.stopped) {
|
|
1801
|
+
const request = this.queue.shift();
|
|
1802
|
+
if (request) await this.process(request);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
async process(request) {
|
|
1806
|
+
const controller = new AbortController();
|
|
1807
|
+
const timeout = this.options.setTimer(
|
|
1808
|
+
() => controller.abort(),
|
|
1809
|
+
this.options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
|
1810
|
+
);
|
|
1811
|
+
this.currentController = controller;
|
|
1812
|
+
this.emit({ type: "STARTED", requestId: request.requestId, postUrl: request.postUrl });
|
|
1813
|
+
try {
|
|
1814
|
+
if (this.options.isScanActive()) {
|
|
1815
|
+
await this.fail(request, "SCANNER_BUSY");
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
if (this.options.isLoginActive()) {
|
|
1819
|
+
await this.fail(request, "LOGIN_ACTIVE");
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const storageState = await this.options.facebookState.load();
|
|
1823
|
+
if (!storageState) {
|
|
1824
|
+
await this.fail(request, "FACEBOOK_SESSION_MISSING");
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
let comments;
|
|
1828
|
+
try {
|
|
1829
|
+
comments = await this.options.scanner.fetchComments({
|
|
1830
|
+
limit: request.limit,
|
|
1831
|
+
postUrl: request.postUrl,
|
|
1832
|
+
signal: controller.signal,
|
|
1833
|
+
storageState
|
|
1834
|
+
});
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
await this.fail(request, readErrorCode2(error));
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (!await this.upload(request.requestId, { comments })) {
|
|
1840
|
+
if (!this.stopped) {
|
|
1841
|
+
this.emit({
|
|
1842
|
+
type: "FAILED",
|
|
1843
|
+
requestId: request.requestId,
|
|
1844
|
+
postUrl: request.postUrl,
|
|
1845
|
+
errorCode: "UPLOAD_FAILED"
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
this.emit({
|
|
1851
|
+
type: "SUCCEEDED",
|
|
1852
|
+
requestId: request.requestId,
|
|
1853
|
+
postUrl: request.postUrl,
|
|
1854
|
+
commentCount: comments.length
|
|
1855
|
+
});
|
|
1856
|
+
} finally {
|
|
1857
|
+
this.options.clearTimer(timeout);
|
|
1858
|
+
if (this.currentController === controller) this.currentController = null;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
async fail(request, errorCode) {
|
|
1862
|
+
await this.upload(request.requestId, { comments: [], errorCode });
|
|
1863
|
+
this.emit({
|
|
1864
|
+
type: "FAILED",
|
|
1865
|
+
requestId: request.requestId,
|
|
1866
|
+
postUrl: request.postUrl,
|
|
1867
|
+
errorCode
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
emit(event) {
|
|
1871
|
+
for (const listener of this.listeners) listener(event);
|
|
1872
|
+
}
|
|
1873
|
+
async upload(requestId, payload) {
|
|
1874
|
+
const signal = this.stopController.signal;
|
|
1875
|
+
if (signal.aborted) return false;
|
|
1876
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1877
|
+
try {
|
|
1878
|
+
await this.options.http.uploadComments(requestId, payload, signal);
|
|
1879
|
+
return true;
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
if (!isRecoverable(error) || attempt >= 2 || signal.aborted) return false;
|
|
1882
|
+
try {
|
|
1883
|
+
await this.options.wait(1e3 * 2 ** attempt, signal);
|
|
1884
|
+
} catch {
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
function readErrorCode2(error) {
|
|
1892
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "";
|
|
1893
|
+
const sanitized = code.replace(/[^A-Z0-9_]/g, "_").slice(0, 100);
|
|
1894
|
+
return sanitized || "COMMENT_FETCH_FAILED";
|
|
1895
|
+
}
|
|
1896
|
+
function isRecoverable(error) {
|
|
1897
|
+
return Boolean(error && typeof error === "object" && "recoverable" in error && error.recoverable === true);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1312
1900
|
// src/main/desktop-scan-http.ts
|
|
1313
1901
|
import { createHash, createPrivateKey, randomUUID as randomUUID2, sign } from "node:crypto";
|
|
1314
1902
|
var RESPONSE_LIMIT2 = 64 * 1024;
|
|
@@ -1344,6 +1932,13 @@ var DesktopScanHttp = class {
|
|
|
1344
1932
|
cancel(jobId, attemptToken) {
|
|
1345
1933
|
return this.signedPost(`/leads/desktop-agent/jobs/${jobId}/cancel`, { attemptToken });
|
|
1346
1934
|
}
|
|
1935
|
+
uploadComments(requestId, payload, signal) {
|
|
1936
|
+
return this.signedPost(
|
|
1937
|
+
`/leads/desktop-agent/post-comments/${requestId}`,
|
|
1938
|
+
normalizeComments(payload),
|
|
1939
|
+
signal
|
|
1940
|
+
);
|
|
1941
|
+
}
|
|
1347
1942
|
async signedPost(path, businessPayload, signal) {
|
|
1348
1943
|
const credential = this.getCredential();
|
|
1349
1944
|
if (!credential) throw new DesktopScanHttpError("CREDENTIAL_UNAVAILABLE");
|
|
@@ -1425,10 +2020,12 @@ function normalizeBatch(payload) {
|
|
|
1425
2020
|
const authorAvatarUrl = normalizeMediaUrl(item.authorAvatarUrl);
|
|
1426
2021
|
const attachmentImages = item.attachmentImages?.map(normalizeMediaUrl).filter((url) => Boolean(url)).slice(0, 6);
|
|
1427
2022
|
const groupName = typeof item.groupName === "string" && item.groupName.trim() ? item.groupName.trim().slice(0, 200) : void 0;
|
|
2023
|
+
const commentCount = normalizeCount(item.commentCount);
|
|
1428
2024
|
const textFormat = normalizeTextFormat(item.textFormat);
|
|
1429
2025
|
return {
|
|
1430
2026
|
...item,
|
|
1431
2027
|
...groupName ? { groupName } : {},
|
|
2028
|
+
...commentCount !== void 0 ? { commentCount } : {},
|
|
1432
2029
|
sourceUrl: normalizeFacebookPostUrl(item.sourceUrl),
|
|
1433
2030
|
authorAvatarUrl,
|
|
1434
2031
|
attachmentImages: attachmentImages?.length ? attachmentImages : void 0,
|
|
@@ -1485,6 +2082,24 @@ function normalizeFormatToken(value) {
|
|
|
1485
2082
|
const normalized = value?.trim();
|
|
1486
2083
|
return normalized && normalized.length <= 50 ? normalized : void 0;
|
|
1487
2084
|
}
|
|
2085
|
+
function normalizeCount(value) {
|
|
2086
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 1e7 ? value : void 0;
|
|
2087
|
+
}
|
|
2088
|
+
function normalizeComments(payload) {
|
|
2089
|
+
const comments = payload.comments.slice(0, DESKTOP_POST_COMMENTS_MAX_LIMIT).map((comment) => {
|
|
2090
|
+
const authorAvatarUrl = normalizeMediaUrl(comment.authorAvatarUrl);
|
|
2091
|
+
const depth = normalizeCount(comment.depth);
|
|
2092
|
+
return {
|
|
2093
|
+
text: comment.text.trim().slice(0, 2e3),
|
|
2094
|
+
...comment.authorName?.trim() ? { authorName: comment.authorName.trim().slice(0, 200) } : {},
|
|
2095
|
+
...authorAvatarUrl ? { authorAvatarUrl } : {},
|
|
2096
|
+
...depth !== void 0 && depth <= 5 ? { depth } : {},
|
|
2097
|
+
...comment.publishedAt ? { publishedAt: comment.publishedAt } : {}
|
|
2098
|
+
};
|
|
2099
|
+
}).filter((comment) => comment.text.length > 0);
|
|
2100
|
+
const errorCode = payload.errorCode?.replace(/[^A-Z0-9_]/g, "_").slice(0, 100);
|
|
2101
|
+
return { comments, ...errorCode ? { errorCode } : {} };
|
|
2102
|
+
}
|
|
1488
2103
|
function normalizeFacebookPostUrl(value) {
|
|
1489
2104
|
try {
|
|
1490
2105
|
const url = new URL(value);
|
|
@@ -1525,7 +2140,7 @@ var DesktopScanRunner = class {
|
|
|
1525
2140
|
records = await this.options.spool.loadAll();
|
|
1526
2141
|
} catch (error) {
|
|
1527
2142
|
this.recoveryBlocked = true;
|
|
1528
|
-
this.update({ ...idleSnapshot(), state: "SPOOL_FULL", errorCode:
|
|
2143
|
+
this.update({ ...idleSnapshot(), state: "SPOOL_FULL", errorCode: readErrorCode3(error) });
|
|
1529
2144
|
return;
|
|
1530
2145
|
}
|
|
1531
2146
|
const current = records.find((record) => Date.parse(record.job.leaseExpiresAt) > this.options.now()) ?? null;
|
|
@@ -1587,6 +2202,7 @@ var DesktopScanRunner = class {
|
|
|
1587
2202
|
return true;
|
|
1588
2203
|
}
|
|
1589
2204
|
async handleEvent(event) {
|
|
2205
|
+
if (event.type === "COMMENTS") return;
|
|
1590
2206
|
if (event.type === "AVAILABLE") {
|
|
1591
2207
|
await this.claimIfReady(event.jobId);
|
|
1592
2208
|
return;
|
|
@@ -1653,6 +2269,7 @@ var DesktopScanRunner = class {
|
|
|
1653
2269
|
async claimIfReady(jobId) {
|
|
1654
2270
|
const session = this.options.session.getSnapshot();
|
|
1655
2271
|
if (this.recoveryBlocked || this.active || this.snapshot.jobId || session.currentJobId || session.state !== "ONLINE" || session.accessMode !== "FULL" || this.options.isLoginActive() || !await this.options.facebookState.isReady()) return;
|
|
2272
|
+
if (this.options.isCommentFetchActive?.()) return;
|
|
1656
2273
|
this.options.session.claimJob(jobId);
|
|
1657
2274
|
}
|
|
1658
2275
|
async run(job, resume) {
|
|
@@ -1783,7 +2400,7 @@ var DesktopScanRunner = class {
|
|
|
1783
2400
|
this.recovered = null;
|
|
1784
2401
|
this.options.session.finishJob(job.jobId);
|
|
1785
2402
|
} catch (error) {
|
|
1786
|
-
const code =
|
|
2403
|
+
const code = readErrorCode3(error);
|
|
1787
2404
|
const checkpoint = code === "LOGIN_REQUIRED" || code === "CHECKPOINT_REQUIRED";
|
|
1788
2405
|
if (code === "OFFLINE_PENDING") {
|
|
1789
2406
|
this.recovered = record;
|
|
@@ -1820,7 +2437,7 @@ var DesktopScanRunner = class {
|
|
|
1820
2437
|
await this.options.spool.put(record);
|
|
1821
2438
|
confirmed = await this.completeWithRetry(job, completion, controller.signal);
|
|
1822
2439
|
} catch (completionError) {
|
|
1823
|
-
const completionCode =
|
|
2440
|
+
const completionCode = readErrorCode3(completionError);
|
|
1824
2441
|
terminalPersistenceFailed = true;
|
|
1825
2442
|
this.recovered = record;
|
|
1826
2443
|
this.update({
|
|
@@ -1856,7 +2473,7 @@ var DesktopScanRunner = class {
|
|
|
1856
2473
|
await this.options.http.uploadBatch(job.jobId, batch, signal);
|
|
1857
2474
|
return true;
|
|
1858
2475
|
} catch (error) {
|
|
1859
|
-
if (!
|
|
2476
|
+
if (!isRecoverable2(error)) throw error;
|
|
1860
2477
|
if (attempt >= 4) return false;
|
|
1861
2478
|
attempt += 1;
|
|
1862
2479
|
await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
|
|
@@ -1871,7 +2488,7 @@ var DesktopScanRunner = class {
|
|
|
1871
2488
|
await this.options.http.complete(job.jobId, payload, signal);
|
|
1872
2489
|
return true;
|
|
1873
2490
|
} catch (error) {
|
|
1874
|
-
if (!
|
|
2491
|
+
if (!isRecoverable2(error)) throw error;
|
|
1875
2492
|
if (attempt >= 4) return false;
|
|
1876
2493
|
attempt += 1;
|
|
1877
2494
|
await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
|
|
@@ -1916,6 +2533,7 @@ function toDesktopPost(item) {
|
|
|
1916
2533
|
...item.authorName ? { authorName: item.authorName } : {},
|
|
1917
2534
|
...item.authorAvatarUrl ? { authorAvatarUrl: item.authorAvatarUrl } : {},
|
|
1918
2535
|
...item.attachmentImages?.length ? { attachmentImages: item.attachmentImages } : {},
|
|
2536
|
+
...item.commentCount !== void 0 ? { commentCount: item.commentCount } : {},
|
|
1919
2537
|
...item.textFormat ? { textFormat: item.textFormat } : {},
|
|
1920
2538
|
...item.publishedAt ? { publishedAt: item.publishedAt } : {},
|
|
1921
2539
|
...item.groupName ? { groupName: item.groupName } : {}
|
|
@@ -1960,10 +2578,10 @@ function createSpoolRecord(job, now) {
|
|
|
1960
2578
|
function nowIso(now) {
|
|
1961
2579
|
return new Date(now).toISOString();
|
|
1962
2580
|
}
|
|
1963
|
-
function
|
|
2581
|
+
function readErrorCode3(error) {
|
|
1964
2582
|
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code.slice(0, 500) : "SCAN_FAILED";
|
|
1965
2583
|
}
|
|
1966
|
-
function
|
|
2584
|
+
function isRecoverable2(error) {
|
|
1967
2585
|
return Boolean(error && typeof error === "object" && "recoverable" in error && error.recoverable === true);
|
|
1968
2586
|
}
|
|
1969
2587
|
function abortableDelay(milliseconds, signal) {
|
|
@@ -2304,6 +2922,21 @@ function getFacebookAccountId(state) {
|
|
|
2304
2922
|
return value && /^\d{1,32}$/.test(value) ? value : null;
|
|
2305
2923
|
}
|
|
2306
2924
|
|
|
2925
|
+
// src/main/graphql-dump.ts
|
|
2926
|
+
import { writeFile as writeFile4 } from "node:fs/promises";
|
|
2927
|
+
import { join as join3 } from "node:path";
|
|
2928
|
+
function createGraphqlDumpParser(directory) {
|
|
2929
|
+
if (!directory) return void 0;
|
|
2930
|
+
return (body, groupUrl) => {
|
|
2931
|
+
void writeFile4(
|
|
2932
|
+
join3(directory, `graphql-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`),
|
|
2933
|
+
`// group: ${groupUrl}
|
|
2934
|
+
${body}`
|
|
2935
|
+
).catch(() => void 0);
|
|
2936
|
+
return parseFacebookGraphqlBody(body, groupUrl);
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2307
2940
|
// src/main/reconnect-backoff.ts
|
|
2308
2941
|
var BASE_DELAY_MS = 1e3;
|
|
2309
2942
|
var MAX_DELAY_MS = 3e4;
|
|
@@ -2574,7 +3207,7 @@ var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `d
|
|
|
2574
3207
|
function agentVersion() {
|
|
2575
3208
|
let directory = fileURLToPath(new URL(".", import.meta.url));
|
|
2576
3209
|
for (let depth = 0; depth < 4; depth += 1) {
|
|
2577
|
-
const manifest =
|
|
3210
|
+
const manifest = join4(directory, "package.json");
|
|
2578
3211
|
if (existsSync2(manifest)) {
|
|
2579
3212
|
try {
|
|
2580
3213
|
const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
|
|
@@ -2591,7 +3224,7 @@ function resolvePlatform() {
|
|
|
2591
3224
|
return process.platform === "linux" ? "LINUX" : "WINDOWS";
|
|
2592
3225
|
}
|
|
2593
3226
|
function resolveConfigDirectory(options) {
|
|
2594
|
-
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() ||
|
|
3227
|
+
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() || join4(homedir(), CONFIG_DIRECTORY);
|
|
2595
3228
|
}
|
|
2596
3229
|
function resolveEndpoint(options) {
|
|
2597
3230
|
const configured = (options.api ?? process.env.DIGGYLAB_API_URL)?.trim() || "https://api.diggylab.com";
|
|
@@ -2601,12 +3234,12 @@ function resolveEndpoint(options) {
|
|
|
2601
3234
|
function createRuntime(options) {
|
|
2602
3235
|
const directory = resolveConfigDirectory(options);
|
|
2603
3236
|
const endpoint = resolveEndpoint(options);
|
|
2604
|
-
const crypto2 = createFileCrypto(
|
|
2605
|
-
const credentials = new CredentialStore(
|
|
3237
|
+
const crypto2 = createFileCrypto(join4(directory, "key"));
|
|
3238
|
+
const credentials = new CredentialStore(join4(directory, "credentials.json"), crypto2);
|
|
2606
3239
|
const sessions = new DeviceSessionStore(credentials);
|
|
2607
3240
|
const facebook = new FacebookSessionStore(credentials);
|
|
2608
|
-
const spool = new EncryptedScanSpool(
|
|
2609
|
-
const activity = new ActivityLog({ directory:
|
|
3241
|
+
const spool = new EncryptedScanSpool(join4(directory, "scan-spool"), crypto2);
|
|
3242
|
+
const activity = new ActivityLog({ directory: join4(directory, "activity") });
|
|
2610
3243
|
const http = new DesktopAgentHttpClient(endpoint.httpOrigin);
|
|
2611
3244
|
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2612
3245
|
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
@@ -2937,8 +3570,13 @@ async function commandRun(runtime, io) {
|
|
|
2937
3570
|
io.error("This agent is not paired yet. Run `diggylab pair --code <code>` first.");
|
|
2938
3571
|
return 2;
|
|
2939
3572
|
}
|
|
2940
|
-
const recordActivity = (message, code) => {
|
|
2941
|
-
void runtime.activity.append({
|
|
3573
|
+
const recordActivity = (message, code, options = {}) => {
|
|
3574
|
+
void runtime.activity.append({
|
|
3575
|
+
category: options.category ?? "CONNECTION",
|
|
3576
|
+
code,
|
|
3577
|
+
level: options.level ?? "INFO",
|
|
3578
|
+
message
|
|
3579
|
+
}).catch(() => void 0);
|
|
2942
3580
|
};
|
|
2943
3581
|
const facebookReady = await runtime.facebook.isReady();
|
|
2944
3582
|
const session = new DesktopAgentSession({
|
|
@@ -2975,17 +3613,37 @@ async function commandRun(runtime, io) {
|
|
|
2975
3613
|
},
|
|
2976
3614
|
origin: runtime.endpoint.httpOrigin
|
|
2977
3615
|
});
|
|
3616
|
+
const scanner = new FacebookScanner({
|
|
3617
|
+
browserType: runtime.browserType,
|
|
3618
|
+
parseGraphqlBody: createGraphqlDumpParser(process.env.FACEBOOK_SCAN_DUMP_DIR)
|
|
3619
|
+
});
|
|
3620
|
+
const commentRunner = new DesktopCommentRunner({
|
|
3621
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
3622
|
+
facebookState: runtime.facebook,
|
|
3623
|
+
http: scanHttp,
|
|
3624
|
+
isLoginActive: () => false,
|
|
3625
|
+
isScanActive: () => scanRunner?.isRunning() ?? false,
|
|
3626
|
+
scanner,
|
|
3627
|
+
session,
|
|
3628
|
+
setTimer: (callback, delay) => {
|
|
3629
|
+
const timer = setTimeout(callback, delay);
|
|
3630
|
+
timer.unref();
|
|
3631
|
+
return timer;
|
|
3632
|
+
},
|
|
3633
|
+
wait: abortableDelay
|
|
3634
|
+
});
|
|
2978
3635
|
const scanRunner = new DesktopScanRunner({
|
|
2979
3636
|
clearTimer: (timer) => clearTimeout(timer),
|
|
2980
3637
|
facebookState: runtime.facebook,
|
|
2981
3638
|
http: scanHttp,
|
|
3639
|
+
isCommentFetchActive: () => commentRunner.isRunning(),
|
|
2982
3640
|
isLoginActive: () => false,
|
|
2983
3641
|
now: Date.now,
|
|
2984
3642
|
randomUuid: () => randomUUID3(),
|
|
2985
3643
|
requestFacebookLogin: async () => {
|
|
2986
3644
|
io.error("The Facebook session expired. Stop this worker, run `diggylab login`, then start it again.");
|
|
2987
3645
|
},
|
|
2988
|
-
scanner
|
|
3646
|
+
scanner,
|
|
2989
3647
|
session,
|
|
2990
3648
|
setTimer: (callback, delay) => {
|
|
2991
3649
|
const timer = setTimeout(callback, delay);
|
|
@@ -3013,6 +3671,30 @@ async function commandRun(runtime, io) {
|
|
|
3013
3671
|
recordActivity(`Scanner state changed to ${snapshot.state}.`, snapshot.state);
|
|
3014
3672
|
});
|
|
3015
3673
|
scanRunner.start();
|
|
3674
|
+
commentRunner.start();
|
|
3675
|
+
const unsubscribeComments = commentRunner.subscribe((event) => {
|
|
3676
|
+
if (event.type === "STARTED") {
|
|
3677
|
+
io.log(`${stamp()} comments: fetching ${event.postUrl}`);
|
|
3678
|
+
recordActivity(`Fetching comments for ${event.postUrl}.`, "COMMENT_FETCH_STARTED", {
|
|
3679
|
+
category: "COMMENTS"
|
|
3680
|
+
});
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
if (event.type === "SUCCEEDED") {
|
|
3684
|
+
io.log(`${stamp()} comments: fetched ${event.commentCount} comment(s)`);
|
|
3685
|
+
recordActivity(
|
|
3686
|
+
`Fetched ${event.commentCount} comment(s) for ${event.postUrl}.`,
|
|
3687
|
+
"COMMENT_FETCH_SUCCEEDED",
|
|
3688
|
+
{ category: "COMMENTS" }
|
|
3689
|
+
);
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
io.log(`${stamp()} comments: failed (${event.errorCode})`);
|
|
3693
|
+
recordActivity(`Comment fetch failed (${event.errorCode}) for ${event.postUrl}.`, event.errorCode, {
|
|
3694
|
+
category: "COMMENTS",
|
|
3695
|
+
level: "WARN"
|
|
3696
|
+
});
|
|
3697
|
+
});
|
|
3016
3698
|
await scanRunner.recover();
|
|
3017
3699
|
await session.start();
|
|
3018
3700
|
io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
|
|
@@ -3028,6 +3710,8 @@ async function commandRun(runtime, io) {
|
|
|
3028
3710
|
} finally {
|
|
3029
3711
|
clearInterval(keepAlive);
|
|
3030
3712
|
session.stop();
|
|
3713
|
+
commentRunner.stop();
|
|
3714
|
+
unsubscribeComments();
|
|
3031
3715
|
scanRunner.stop();
|
|
3032
3716
|
}
|
|
3033
3717
|
return 0;
|
|
@@ -3058,7 +3742,7 @@ function runCommand(command, args) {
|
|
|
3058
3742
|
});
|
|
3059
3743
|
}
|
|
3060
3744
|
function resolveServiceExecStart() {
|
|
3061
|
-
const candidates = [
|
|
3745
|
+
const candidates = [join4(dirname3(process.execPath), "diggylab"), "/usr/local/bin/diggylab", "/usr/bin/diggylab"];
|
|
3062
3746
|
const link = candidates.find((candidate) => existsSync2(candidate));
|
|
3063
3747
|
return link ? [link] : [process.execPath, fileURLToPath(import.meta.url)];
|
|
3064
3748
|
}
|
|
@@ -3084,7 +3768,7 @@ async function commandInstallService(runtime, io) {
|
|
|
3084
3768
|
}
|
|
3085
3769
|
io.log(`Wrote ${SERVICE_PATH}.`);
|
|
3086
3770
|
io.log(`State directory: ${runtime.directory}`);
|
|
3087
|
-
if (process.env.SUDO_USER && process.env.SUDO_USER !== "root" && runtime.directory ===
|
|
3771
|
+
if (process.env.SUDO_USER && process.env.SUDO_USER !== "root" && runtime.directory === join4(homedir(), CONFIG_DIRECTORY)) {
|
|
3088
3772
|
io.error(`Heads up: this ran as root, so the service will use ${runtime.directory} (root's home).`);
|
|
3089
3773
|
io.error(`If you paired as "${process.env.SUDO_USER}", pass --config to point at that user's ~/${CONFIG_DIRECTORY} instead.`);
|
|
3090
3774
|
}
|