diggylab 0.1.45 → 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 +382 -68
  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,7 +5,7 @@ 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
@@ -48,11 +48,25 @@ var commentAuthorPaths = [
48
48
  ["author_name"],
49
49
  ["author", "short_name"]
50
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
+ ];
51
57
  var commentTimePaths = [
52
58
  ["created_time"],
53
59
  ["creation_time"],
54
60
  ["publish_time"]
55
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
+ ];
56
70
  var facebookHosts = /* @__PURE__ */ new Set([
57
71
  "facebook.com",
58
72
  "fb.com",
@@ -214,6 +228,55 @@ function getTextFormat(node) {
214
228
  };
215
229
  return Object.values(textFormat).some(Boolean) ? textFormat : void 0;
216
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
+ }
217
280
  function collectImageUrls(value, urls, depth) {
218
281
  if (!value || depth > 12 || urls.size >= 12)
219
282
  return;
@@ -275,12 +338,14 @@ function toItem(node, requestedGroup) {
275
338
  attachmentImages = dedupeImageUrls([...fallbackUrls]);
276
339
  }
277
340
  const textFormat = getTextFormat(node);
341
+ const commentCount = getCommentCount(node);
278
342
  return {
279
343
  text,
280
344
  url,
281
345
  authorName: firstString(node, paths.author),
282
346
  ...authorAvatarUrl ? { authorAvatarUrl } : {},
283
347
  ...attachmentImages.length ? { attachmentImages } : {},
348
+ ...commentCount !== void 0 ? { commentCount } : {},
284
349
  ...textFormat ? { textFormat } : {},
285
350
  publishedAt: toIso(firstString(node, paths.time)),
286
351
  groupName: firstString(node, paths.group)
@@ -315,6 +380,9 @@ function mergeFacebookScanItems(items, limit) {
315
380
  if (!existing.authorAvatarUrl && item.authorAvatarUrl) {
316
381
  existing.authorAvatarUrl = item.authorAvatarUrl;
317
382
  }
383
+ if (existing.commentCount === void 0 && item.commentCount !== void 0) {
384
+ existing.commentCount = item.commentCount;
385
+ }
318
386
  const attachmentImages = dedupeImageUrls([
319
387
  ...existing.attachmentImages ?? [],
320
388
  ...item.attachmentImages ?? []
@@ -350,13 +418,25 @@ function toComment(node) {
350
418
  if (!text || typename && !/comment$/i.test(typename))
351
419
  return void 0;
352
420
  const authorName = firstString(node, commentAuthorPaths);
421
+ const authorAvatarUrl = firstString(node, commentAvatarPaths, false);
422
+ const depth = toDepth(node);
353
423
  const publishedAt = toIso(firstString(node, commentTimePaths));
424
+ const id = firstString(node, [["id"]], false);
425
+ const parentId = firstString(node, [["comment_direct_parent", "id"]], false);
354
426
  return {
355
427
  text: text.trim(),
356
428
  ...authorName ? { authorName } : {},
429
+ ...authorAvatarUrl ? { authorAvatarUrl } : {},
430
+ ...depth !== void 0 ? { depth } : {},
431
+ ...id ? { id } : {},
432
+ ...parentId ? { parentId } : {},
357
433
  ...publishedAt ? { publishedAt } : {}
358
434
  };
359
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
+ }
360
440
  function visitComments(value, items) {
361
441
  if (Array.isArray(value)) {
362
442
  for (const item of value)
@@ -384,27 +464,81 @@ function parseFacebookCommentBodies(body) {
384
464
  }
385
465
  return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
386
466
  }
467
+ function parseFacebookCommentJson(value) {
468
+ const items = [];
469
+ visitComments(value, items);
470
+ return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
471
+ }
387
472
  function mergeFacebookComments(items, limit) {
388
- const seen = /* @__PURE__ */ new Set();
389
- const merged = [];
473
+ const merged = /* @__PURE__ */ new Map();
390
474
  for (const item of items) {
391
475
  const text = item.text.trim();
392
476
  if (!text)
393
477
  continue;
394
478
  const authorName = item.authorName?.trim();
395
- const key = `${authorName ?? ""}\0${text}\0${item.publishedAt ?? ""}`;
396
- if (seen.has(key))
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
+ }
397
494
  continue;
398
- if (merged.length >= limit)
399
- break;
400
- seen.add(key);
401
- merged.push({
495
+ }
496
+ if (merged.size >= limit)
497
+ continue;
498
+ merged.set(key, {
402
499
  text: text.slice(0, COMMENT_TEXT_LIMIT),
403
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 } : {},
404
505
  ...item.publishedAt ? { publishedAt: item.publishedAt } : {}
405
506
  });
406
507
  }
407
- return merged;
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;
408
542
  }
409
543
  async function extractFacebookDomItems(page, groupUrl) {
410
544
  const requestedGroup = requestedGroupIdentifier(groupUrl);
@@ -427,12 +561,16 @@ async function extractFacebookDomItems(page, groupUrl) {
427
561
  const message = Array.from(article.querySelectorAll('[data-ad-preview="message"]')).find((element) => belongsToArticle(element) && element.checkVisibility());
428
562
  const author = Array.from(article.querySelectorAll("h2 a, h3 a, strong a")).find((element) => belongsToArticle(element) && element.checkVisibility());
429
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;
430
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));
431
568
  return {
432
569
  text: message?.innerText ?? "",
433
570
  urls,
434
571
  authorName: author?.innerText,
435
572
  publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0,
573
+ ...commentCount !== void 0 ? { commentCount } : {},
436
574
  ...attachmentImages.length ? { attachmentImages } : {}
437
575
  };
438
576
  }));
@@ -443,6 +581,7 @@ async function extractFacebookDomItems(page, groupUrl) {
443
581
  url,
444
582
  authorName: item.authorName,
445
583
  publishedAt: item.publishedAt,
584
+ ...item.commentCount !== void 0 ? { commentCount: item.commentCount } : {},
446
585
  ...item.attachmentImages?.length ? { attachmentImages: dedupeImageUrls(item.attachmentImages) } : {}
447
586
  }] : [];
448
587
  }), Number.POSITIVE_INFINITY);
@@ -454,20 +593,67 @@ async function extractFacebookDomComments(page) {
454
593
  return void 0;
455
594
  }
456
595
  const belongsToArticle = (element) => element.closest('[role="article"]') === article;
457
- const text = Array.from(article.querySelectorAll('[dir="auto"]')).filter((element) => belongsToArticle(element) && element.checkVisibility()).map((element) => element.innerText.trim()).filter(Boolean).sort((left, right) => right.length - left.length)[0] ?? "";
458
- const authorMatch = /(?:dưới tên|by)\s+(.+?)(?:\s+(?:vào|on)\s+|$)/i.exec(label);
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);
459
610
  const time = Array.from(article.querySelectorAll("time[datetime], abbr[data-utime]")).find((element) => belongsToArticle(element) && element.checkVisibility());
460
611
  return {
461
- text,
462
- authorName: authorMatch?.[1]?.trim(),
612
+ texts: Array.from(new Set(texts)),
613
+ authorLabel: label,
463
614
  publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0
464
615
  };
465
616
  }));
466
- return mergeFacebookComments(raw.flatMap((item) => item?.text.trim() ? [{
467
- text: item.text,
468
- ...item.authorName ? { authorName: item.authorName } : {},
469
- ...toIso(item.publishedAt) ? { publishedAt: toIso(item.publishedAt) } : {}
470
- }] : []), Number.POSITIVE_INFINITY);
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;
471
657
  }
472
658
 
473
659
  // ../../packages/facebook-scanner/dist/session.js
@@ -507,6 +693,7 @@ var FacebookScanner = class {
507
693
  extractDomComments;
508
694
  parseGraphqlBody;
509
695
  parseGraphqlComments;
696
+ parseCommentJson;
510
697
  graphqlResponseTimeoutMs;
511
698
  running = false;
512
699
  constructor(options) {
@@ -515,6 +702,7 @@ var FacebookScanner = class {
515
702
  this.extractDomComments = options.extractDomComments ?? extractFacebookDomComments;
516
703
  this.parseGraphqlBody = options.parseGraphqlBody ?? parseFacebookGraphqlBody;
517
704
  this.parseGraphqlComments = options.parseGraphqlComments ?? parseFacebookCommentBodies;
705
+ this.parseCommentJson = options.parseCommentJson ?? parseFacebookCommentJson;
518
706
  this.graphqlResponseTimeoutMs = options.graphqlResponseTimeoutMs ?? 1e4;
519
707
  }
520
708
  async scanMany(options) {
@@ -569,6 +757,7 @@ var FacebookScanner = class {
569
757
  if (condition !== "READY")
570
758
  throw new FacebookScannerError(condition);
571
759
  await options.onProgress?.({ phase: "FETCHING", commentCount: 0 });
760
+ const embeddedComments = await readEmbeddedComments(page, this.parseCommentJson);
572
761
  let comments = [];
573
762
  let idleRounds = 0;
574
763
  let rounds = 0;
@@ -579,19 +768,21 @@ var FacebookScanner = class {
579
768
  await page.waitForTimeout(1200);
580
769
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
581
770
  const domComments2 = await this.extractDomComments(page);
582
- comments = mergeFacebookComments([...graphqlComments, ...domComments2], options.limit);
771
+ comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments2], options.limit);
583
772
  if (comments.length !== previousCount) {
584
773
  await options.onProgress?.({ phase: "FETCHING", commentCount: comments.length });
585
774
  }
586
775
  const expanded = await clickCommentExpanders(page);
587
776
  idleRounds = comments.length === previousCount && !expanded ? idleRounds + 1 : 0;
588
- if (comments.length < options.limit)
777
+ if (comments.length < options.limit) {
778
+ await scrollCommentArea(page);
589
779
  await page.mouse.wheel(0, 2e3);
780
+ }
590
781
  }
591
782
  assertNotAborted(options.signal);
592
783
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
593
784
  const domComments = await this.extractDomComments(page);
594
- comments = mergeFacebookComments([...graphqlComments, ...domComments], options.limit);
785
+ comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments], options.limit);
595
786
  await options.onProgress?.({ phase: "COMPLETE", commentCount: comments.length });
596
787
  return comments;
597
788
  } finally {
@@ -685,29 +876,58 @@ function withTimeout(promise, timeoutMs) {
685
876
  });
686
877
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
687
878
  }
688
- var COMMENT_EXPANDER_PATTERNS = [
689
- /^xem thêm bình luận$/i,
690
- /^xem \d+ bình luận$/i,
691
- /^xem các bình luận khác$/i,
692
- /^view more comments$/i,
693
- /^view \d+ more comments$/i,
694
- /^xem thêm câu trả lời$/i,
695
- /^view more replies$/i
696
- ];
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;
697
881
  async function clickCommentExpanders(page) {
698
882
  let clicked = 0;
699
- for (const pattern of COMMENT_EXPANDER_PATTERNS) {
700
- for (let attempt = 0; attempt < 2; attempt += 1) {
701
- try {
702
- await page.getByRole("button", { name: pattern }).first().click({ timeout: 1500 });
703
- clicked += 1;
704
- } catch {
705
- break;
706
- }
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;
707
898
  }
708
899
  }
709
900
  return clicked;
710
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
+ }
711
931
 
712
932
  // src/cli/cli.ts
713
933
  import { chromium } from "playwright";
@@ -823,7 +1043,7 @@ function sanitize(value) {
823
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();
824
1044
  }
825
1045
  function isEntry(value) {
826
- 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";
827
1047
  }
828
1048
 
829
1049
  // src/main/browser-runtime.ts
@@ -1124,6 +1344,7 @@ import { randomUUID } from "node:crypto";
1124
1344
 
1125
1345
  // ../../packages/shared/src/index.ts
1126
1346
  var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
1347
+ var DESKTOP_POST_COMMENTS_MAX_LIMIT = 200;
1127
1348
  var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
1128
1349
 
1129
1350
  // src/main/desktop-agent-session.ts
@@ -1291,7 +1512,7 @@ var DesktopAgentSession = class {
1291
1512
  state: this.reportedState,
1292
1513
  facebookReady: this.options.getFacebookReady(),
1293
1514
  currentJobId: this.reportedJobId,
1294
- capabilities: ["POST_COMMENTS"]
1515
+ capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE"]
1295
1516
  });
1296
1517
  this.handshake = "WELCOME";
1297
1518
  return;
@@ -1490,7 +1711,7 @@ function describeEnvelopeProblem(envelope, deviceId, replayIds) {
1490
1711
  function isPostCommentsFetch(value) {
1491
1712
  if (!value || typeof value !== "object") return false;
1492
1713
  const payload = value;
1493
- 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 <= 50;
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;
1494
1715
  }
1495
1716
  function emptySnapshot(state) {
1496
1717
  return {
@@ -1527,6 +1748,7 @@ var DEFAULT_MAX_QUEUE = 5;
1527
1748
  var DesktopCommentRunner = class {
1528
1749
  options;
1529
1750
  queue = [];
1751
+ listeners = /* @__PURE__ */ new Set();
1530
1752
  stopController = new AbortController();
1531
1753
  active = null;
1532
1754
  currentController = null;
@@ -1550,13 +1772,17 @@ var DesktopCommentRunner = class {
1550
1772
  isRunning() {
1551
1773
  return this.active !== null;
1552
1774
  }
1775
+ subscribe(listener) {
1776
+ this.listeners.add(listener);
1777
+ return () => this.listeners.delete(listener);
1778
+ }
1553
1779
  async whenIdle() {
1554
1780
  await this.active;
1555
1781
  }
1556
1782
  async handleEvent(event) {
1557
1783
  if (event.type !== "COMMENTS" || this.stopped) return;
1558
1784
  if (this.queue.length >= (this.options.maxQueue ?? DEFAULT_MAX_QUEUE)) {
1559
- await this.upload(event.requestId, { comments: [], errorCode: "QUEUE_FULL" });
1785
+ await this.fail(event, "QUEUE_FULL");
1560
1786
  return;
1561
1787
  }
1562
1788
  this.queue.push(event);
@@ -1583,18 +1809,19 @@ var DesktopCommentRunner = class {
1583
1809
  this.options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
1584
1810
  );
1585
1811
  this.currentController = controller;
1812
+ this.emit({ type: "STARTED", requestId: request.requestId, postUrl: request.postUrl });
1586
1813
  try {
1587
1814
  if (this.options.isScanActive()) {
1588
- await this.upload(request.requestId, { comments: [], errorCode: "SCANNER_BUSY" });
1815
+ await this.fail(request, "SCANNER_BUSY");
1589
1816
  return;
1590
1817
  }
1591
1818
  if (this.options.isLoginActive()) {
1592
- await this.upload(request.requestId, { comments: [], errorCode: "LOGIN_ACTIVE" });
1819
+ await this.fail(request, "LOGIN_ACTIVE");
1593
1820
  return;
1594
1821
  }
1595
1822
  const storageState = await this.options.facebookState.load();
1596
1823
  if (!storageState) {
1597
- await this.upload(request.requestId, { comments: [], errorCode: "FACEBOOK_SESSION_MISSING" });
1824
+ await this.fail(request, "FACEBOOK_SESSION_MISSING");
1598
1825
  return;
1599
1826
  }
1600
1827
  let comments;
@@ -1606,28 +1833,56 @@ var DesktopCommentRunner = class {
1606
1833
  storageState
1607
1834
  });
1608
1835
  } catch (error) {
1609
- await this.upload(request.requestId, { comments: [], errorCode: readErrorCode2(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
+ }
1610
1848
  return;
1611
1849
  }
1612
- await this.upload(request.requestId, { comments });
1850
+ this.emit({
1851
+ type: "SUCCEEDED",
1852
+ requestId: request.requestId,
1853
+ postUrl: request.postUrl,
1854
+ commentCount: comments.length
1855
+ });
1613
1856
  } finally {
1614
1857
  this.options.clearTimer(timeout);
1615
1858
  if (this.currentController === controller) this.currentController = null;
1616
1859
  }
1617
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
+ }
1618
1873
  async upload(requestId, payload) {
1619
1874
  const signal = this.stopController.signal;
1620
- if (signal.aborted) return;
1875
+ if (signal.aborted) return false;
1621
1876
  for (let attempt = 0; ; attempt += 1) {
1622
1877
  try {
1623
1878
  await this.options.http.uploadComments(requestId, payload, signal);
1624
- return;
1879
+ return true;
1625
1880
  } catch (error) {
1626
- if (!isRecoverable(error) || attempt >= 2 || signal.aborted) return;
1881
+ if (!isRecoverable(error) || attempt >= 2 || signal.aborted) return false;
1627
1882
  try {
1628
1883
  await this.options.wait(1e3 * 2 ** attempt, signal);
1629
1884
  } catch {
1630
- return;
1885
+ return false;
1631
1886
  }
1632
1887
  }
1633
1888
  }
@@ -1765,10 +2020,12 @@ function normalizeBatch(payload) {
1765
2020
  const authorAvatarUrl = normalizeMediaUrl(item.authorAvatarUrl);
1766
2021
  const attachmentImages = item.attachmentImages?.map(normalizeMediaUrl).filter((url) => Boolean(url)).slice(0, 6);
1767
2022
  const groupName = typeof item.groupName === "string" && item.groupName.trim() ? item.groupName.trim().slice(0, 200) : void 0;
2023
+ const commentCount = normalizeCount(item.commentCount);
1768
2024
  const textFormat = normalizeTextFormat(item.textFormat);
1769
2025
  return {
1770
2026
  ...item,
1771
2027
  ...groupName ? { groupName } : {},
2028
+ ...commentCount !== void 0 ? { commentCount } : {},
1772
2029
  sourceUrl: normalizeFacebookPostUrl(item.sourceUrl),
1773
2030
  authorAvatarUrl,
1774
2031
  attachmentImages: attachmentImages?.length ? attachmentImages : void 0,
@@ -1825,12 +2082,21 @@ function normalizeFormatToken(value) {
1825
2082
  const normalized = value?.trim();
1826
2083
  return normalized && normalized.length <= 50 ? normalized : void 0;
1827
2084
  }
2085
+ function normalizeCount(value) {
2086
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 1e7 ? value : void 0;
2087
+ }
1828
2088
  function normalizeComments(payload) {
1829
- const comments = payload.comments.slice(0, 50).map((comment) => ({
1830
- text: comment.text.trim().slice(0, 2e3),
1831
- ...comment.authorName?.trim() ? { authorName: comment.authorName.trim().slice(0, 200) } : {},
1832
- ...comment.publishedAt ? { publishedAt: comment.publishedAt } : {}
1833
- })).filter((comment) => comment.text.length > 0);
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);
1834
2100
  const errorCode = payload.errorCode?.replace(/[^A-Z0-9_]/g, "_").slice(0, 100);
1835
2101
  return { comments, ...errorCode ? { errorCode } : {} };
1836
2102
  }
@@ -2267,6 +2533,7 @@ function toDesktopPost(item) {
2267
2533
  ...item.authorName ? { authorName: item.authorName } : {},
2268
2534
  ...item.authorAvatarUrl ? { authorAvatarUrl: item.authorAvatarUrl } : {},
2269
2535
  ...item.attachmentImages?.length ? { attachmentImages: item.attachmentImages } : {},
2536
+ ...item.commentCount !== void 0 ? { commentCount: item.commentCount } : {},
2270
2537
  ...item.textFormat ? { textFormat: item.textFormat } : {},
2271
2538
  ...item.publishedAt ? { publishedAt: item.publishedAt } : {},
2272
2539
  ...item.groupName ? { groupName: item.groupName } : {}
@@ -2655,6 +2922,21 @@ function getFacebookAccountId(state) {
2655
2922
  return value && /^\d{1,32}$/.test(value) ? value : null;
2656
2923
  }
2657
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
+
2658
2940
  // src/main/reconnect-backoff.ts
2659
2941
  var BASE_DELAY_MS = 1e3;
2660
2942
  var MAX_DELAY_MS = 3e4;
@@ -2925,7 +3207,7 @@ var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `d
2925
3207
  function agentVersion() {
2926
3208
  let directory = fileURLToPath(new URL(".", import.meta.url));
2927
3209
  for (let depth = 0; depth < 4; depth += 1) {
2928
- const manifest = join3(directory, "package.json");
3210
+ const manifest = join4(directory, "package.json");
2929
3211
  if (existsSync2(manifest)) {
2930
3212
  try {
2931
3213
  const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
@@ -2942,7 +3224,7 @@ function resolvePlatform() {
2942
3224
  return process.platform === "linux" ? "LINUX" : "WINDOWS";
2943
3225
  }
2944
3226
  function resolveConfigDirectory(options) {
2945
- 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);
2946
3228
  }
2947
3229
  function resolveEndpoint(options) {
2948
3230
  const configured = (options.api ?? process.env.DIGGYLAB_API_URL)?.trim() || "https://api.diggylab.com";
@@ -2952,12 +3234,12 @@ function resolveEndpoint(options) {
2952
3234
  function createRuntime(options) {
2953
3235
  const directory = resolveConfigDirectory(options);
2954
3236
  const endpoint = resolveEndpoint(options);
2955
- const crypto2 = createFileCrypto(join3(directory, "key"));
2956
- 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);
2957
3239
  const sessions = new DeviceSessionStore(credentials);
2958
3240
  const facebook = new FacebookSessionStore(credentials);
2959
- const spool = new EncryptedScanSpool(join3(directory, "scan-spool"), crypto2);
2960
- 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") });
2961
3243
  const http = new DesktopAgentHttpClient(endpoint.httpOrigin);
2962
3244
  const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
2963
3245
  return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
@@ -3288,8 +3570,13 @@ async function commandRun(runtime, io) {
3288
3570
  io.error("This agent is not paired yet. Run `diggylab pair --code <code>` first.");
3289
3571
  return 2;
3290
3572
  }
3291
- const recordActivity = (message, code) => {
3292
- 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);
3293
3580
  };
3294
3581
  const facebookReady = await runtime.facebook.isReady();
3295
3582
  const session = new DesktopAgentSession({
@@ -3326,7 +3613,10 @@ async function commandRun(runtime, io) {
3326
3613
  },
3327
3614
  origin: runtime.endpoint.httpOrigin
3328
3615
  });
3329
- const scanner = new FacebookScanner({ browserType: runtime.browserType });
3616
+ const scanner = new FacebookScanner({
3617
+ browserType: runtime.browserType,
3618
+ parseGraphqlBody: createGraphqlDumpParser(process.env.FACEBOOK_SCAN_DUMP_DIR)
3619
+ });
3330
3620
  const commentRunner = new DesktopCommentRunner({
3331
3621
  clearTimer: (timer) => clearTimeout(timer),
3332
3622
  facebookState: runtime.facebook,
@@ -3382,6 +3672,29 @@ async function commandRun(runtime, io) {
3382
3672
  });
3383
3673
  scanRunner.start();
3384
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
+ });
3385
3698
  await scanRunner.recover();
3386
3699
  await session.start();
3387
3700
  io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
@@ -3398,6 +3711,7 @@ async function commandRun(runtime, io) {
3398
3711
  clearInterval(keepAlive);
3399
3712
  session.stop();
3400
3713
  commentRunner.stop();
3714
+ unsubscribeComments();
3401
3715
  scanRunner.stop();
3402
3716
  }
3403
3717
  return 0;
@@ -3428,7 +3742,7 @@ function runCommand(command, args) {
3428
3742
  });
3429
3743
  }
3430
3744
  function resolveServiceExecStart() {
3431
- 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"];
3432
3746
  const link = candidates.find((candidate) => existsSync2(candidate));
3433
3747
  return link ? [link] : [process.execPath, fileURLToPath(import.meta.url)];
3434
3748
  }
@@ -3454,7 +3768,7 @@ async function commandInstallService(runtime, io) {
3454
3768
  }
3455
3769
  io.log(`Wrote ${SERVICE_PATH}.`);
3456
3770
  io.log(`State directory: ${runtime.directory}`);
3457
- 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)) {
3458
3772
  io.error(`Heads up: this ran as root, so the service will use ${runtime.directory} (root's home).`);
3459
3773
  io.error(`If you paired as "${process.env.SUDO_USER}", pass --config to point at that user's ~/${CONFIG_DIRECTORY} instead.`);
3460
3774
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.45",
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": {