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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/cli.js +706 -22
  3. 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 join3 } from "node:path";
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: readErrorCode2(error) });
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 = readErrorCode2(error);
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 = readErrorCode2(completionError);
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 (!isRecoverable(error)) throw error;
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 (!isRecoverable(error)) throw error;
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 readErrorCode2(error) {
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 isRecoverable(error) {
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 = join3(directory, "package.json");
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() || join3(homedir(), CONFIG_DIRECTORY);
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(join3(directory, "key"));
2605
- const credentials = new CredentialStore(join3(directory, "credentials.json"), crypto2);
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(join3(directory, "scan-spool"), crypto2);
2609
- const activity = new ActivityLog({ directory: join3(directory, "activity") });
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({ category: "CONNECTION", code, level: "INFO", message }).catch(() => void 0);
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: new FacebookScanner({ browserType: runtime.browserType }),
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 = [join3(dirname3(process.execPath), "diggylab"), "/usr/local/bin/diggylab", "/usr/bin/diggylab"];
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 === join3(homedir(), CONFIG_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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "DiggyLab Desktop Agent collects posts and information from Facebook Groups on a paired computer. (headless CLI)",
5
5
  "type": "module",
6
6
  "bin": {