diggylab 0.1.44 → 0.1.45

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 (2) hide show
  1. package/cli.js +380 -10
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -9,6 +9,8 @@ import { dirname as dirname3, join as join3 } 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,21 @@ 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 commentTimePaths = [
52
+ ["created_time"],
53
+ ["creation_time"],
54
+ ["publish_time"]
55
+ ];
39
56
  var facebookHosts = /* @__PURE__ */ new Set([
40
57
  "facebook.com",
41
58
  "fb.com",
@@ -327,6 +344,68 @@ function parseFacebookGraphqlBody(body, groupUrl) {
327
344
  }
328
345
  return mergeFacebookScanItems(items, Number.POSITIVE_INFINITY);
329
346
  }
347
+ function toComment(node) {
348
+ const typename = typeof node.__typename === "string" ? node.__typename : void 0;
349
+ const text = firstString(node, commentTextPaths);
350
+ if (!text || typename && !/comment$/i.test(typename))
351
+ return void 0;
352
+ const authorName = firstString(node, commentAuthorPaths);
353
+ const publishedAt = toIso(firstString(node, commentTimePaths));
354
+ return {
355
+ text: text.trim(),
356
+ ...authorName ? { authorName } : {},
357
+ ...publishedAt ? { publishedAt } : {}
358
+ };
359
+ }
360
+ function visitComments(value, items) {
361
+ if (Array.isArray(value)) {
362
+ for (const item of value)
363
+ visitComments(item, items);
364
+ return;
365
+ }
366
+ if (!isData(value))
367
+ return;
368
+ const comment = toComment(value);
369
+ if (comment)
370
+ items.push(comment);
371
+ for (const child of Object.values(value))
372
+ visitComments(child, items);
373
+ }
374
+ function parseFacebookCommentBodies(body) {
375
+ const items = [];
376
+ for (const line of body.split(/\r?\n/)) {
377
+ const json = line.trim().replace(/^for\s*\(;;\);/, "");
378
+ if (!json.startsWith("{") && !json.startsWith("["))
379
+ continue;
380
+ try {
381
+ visitComments(JSON.parse(json), items);
382
+ } catch {
383
+ }
384
+ }
385
+ return mergeFacebookComments(items, Number.POSITIVE_INFINITY);
386
+ }
387
+ function mergeFacebookComments(items, limit) {
388
+ const seen = /* @__PURE__ */ new Set();
389
+ const merged = [];
390
+ for (const item of items) {
391
+ const text = item.text.trim();
392
+ if (!text)
393
+ continue;
394
+ const authorName = item.authorName?.trim();
395
+ const key = `${authorName ?? ""}\0${text}\0${item.publishedAt ?? ""}`;
396
+ if (seen.has(key))
397
+ continue;
398
+ if (merged.length >= limit)
399
+ break;
400
+ seen.add(key);
401
+ merged.push({
402
+ text: text.slice(0, COMMENT_TEXT_LIMIT),
403
+ ...authorName ? { authorName: authorName.slice(0, COMMENT_AUTHOR_LIMIT) } : {},
404
+ ...item.publishedAt ? { publishedAt: item.publishedAt } : {}
405
+ });
406
+ }
407
+ return merged;
408
+ }
330
409
  async function extractFacebookDomItems(page, groupUrl) {
331
410
  const requestedGroup = requestedGroupIdentifier(groupUrl);
332
411
  if (!requestedGroup)
@@ -368,6 +447,28 @@ async function extractFacebookDomItems(page, groupUrl) {
368
447
  }] : [];
369
448
  }), Number.POSITIVE_INFINITY);
370
449
  }
450
+ async function extractFacebookDomComments(page) {
451
+ const raw = await page.locator('[role="article"][aria-label]').evaluateAll((articles) => articles.map((article) => {
452
+ const label = (article.getAttribute("aria-label") ?? "").trim();
453
+ if (!/^(?:bình luận|comment|trả lời|reply)\b/i.test(label) || !article.checkVisibility()) {
454
+ return void 0;
455
+ }
456
+ 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);
459
+ const time = Array.from(article.querySelectorAll("time[datetime], abbr[data-utime]")).find((element) => belongsToArticle(element) && element.checkVisibility());
460
+ return {
461
+ text,
462
+ authorName: authorMatch?.[1]?.trim(),
463
+ publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0
464
+ };
465
+ }));
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);
471
+ }
371
472
 
372
473
  // ../../packages/facebook-scanner/dist/session.js
373
474
  async function detectFacebookSession(page, context) {
@@ -403,13 +504,17 @@ var FacebookScannerError = class extends Error {
403
504
  var FacebookScanner = class {
404
505
  browserType;
405
506
  extractDomItems;
507
+ extractDomComments;
406
508
  parseGraphqlBody;
509
+ parseGraphqlComments;
407
510
  graphqlResponseTimeoutMs;
408
511
  running = false;
409
512
  constructor(options) {
410
513
  this.browserType = options.browserType;
411
514
  this.extractDomItems = options.extractDomItems ?? extractFacebookDomItems;
515
+ this.extractDomComments = options.extractDomComments ?? extractFacebookDomComments;
412
516
  this.parseGraphqlBody = options.parseGraphqlBody ?? parseFacebookGraphqlBody;
517
+ this.parseGraphqlComments = options.parseGraphqlComments ?? parseFacebookCommentBodies;
413
518
  this.graphqlResponseTimeoutMs = options.graphqlResponseTimeoutMs ?? 1e4;
414
519
  }
415
520
  async scanMany(options) {
@@ -431,6 +536,76 @@ var FacebookScanner = class {
431
536
  this.running = false;
432
537
  }
433
538
  }
539
+ async fetchComments(options) {
540
+ if (this.running)
541
+ throw new FacebookScannerError("SCANNER_BUSY");
542
+ const target = normalizePostUrl(options.postUrl);
543
+ if (!target)
544
+ throw new FacebookScannerError("POST_URL_INVALID");
545
+ assertNotAborted(options.signal);
546
+ this.running = true;
547
+ let browser = null;
548
+ try {
549
+ browser = await this.browserType.launch({ headless: true });
550
+ const context = await browser.newContext({ storageState: options.storageState });
551
+ let page = null;
552
+ const graphqlComments = [];
553
+ const responseTasks = [];
554
+ const responseHandler = (response) => {
555
+ if (!response.url().includes("/api/graphql/"))
556
+ return;
557
+ responseTasks.push(withTimeout(response.text(), this.graphqlResponseTimeoutMs).then((body) => graphqlComments.push(...this.parseGraphqlComments(body))).then(() => void 0).catch(() => void 0));
558
+ };
559
+ try {
560
+ page = await context.newPage();
561
+ page.on("response", responseHandler);
562
+ await page.route("**/*", (route) => ["font", "image", "media"].includes(route.request().resourceType()) ? route.abort() : route.continue());
563
+ try {
564
+ await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45e3 });
565
+ } catch {
566
+ throw new FacebookScannerError("NAVIGATION_FAILED");
567
+ }
568
+ const condition = await detectFacebookSession(page, context);
569
+ if (condition !== "READY")
570
+ throw new FacebookScannerError(condition);
571
+ await options.onProgress?.({ phase: "FETCHING", commentCount: 0 });
572
+ let comments = [];
573
+ let idleRounds = 0;
574
+ let rounds = 0;
575
+ while (comments.length < options.limit && idleRounds < 3 && rounds < 20) {
576
+ assertNotAborted(options.signal);
577
+ rounds += 1;
578
+ const previousCount = comments.length;
579
+ await page.waitForTimeout(1200);
580
+ await drain(responseTasks, this.graphqlResponseTimeoutMs);
581
+ const domComments2 = await this.extractDomComments(page);
582
+ comments = mergeFacebookComments([...graphqlComments, ...domComments2], options.limit);
583
+ if (comments.length !== previousCount) {
584
+ await options.onProgress?.({ phase: "FETCHING", commentCount: comments.length });
585
+ }
586
+ const expanded = await clickCommentExpanders(page);
587
+ idleRounds = comments.length === previousCount && !expanded ? idleRounds + 1 : 0;
588
+ if (comments.length < options.limit)
589
+ await page.mouse.wheel(0, 2e3);
590
+ }
591
+ assertNotAborted(options.signal);
592
+ await drain(responseTasks, this.graphqlResponseTimeoutMs);
593
+ const domComments = await this.extractDomComments(page);
594
+ comments = mergeFacebookComments([...graphqlComments, ...domComments], options.limit);
595
+ await options.onProgress?.({ phase: "COMPLETE", commentCount: comments.length });
596
+ return comments;
597
+ } finally {
598
+ page?.off("response", responseHandler);
599
+ await page?.unroute("**/*").catch(() => void 0);
600
+ await Promise.allSettled(responseTasks);
601
+ await page?.close();
602
+ await context.close();
603
+ }
604
+ } finally {
605
+ await browser?.close();
606
+ this.running = false;
607
+ }
608
+ }
434
609
  async scanGroup(browser, options, groupUrl, groupIndex) {
435
610
  const context = await browser.newContext({ storageState: options.storageState });
436
611
  let page = null;
@@ -510,6 +685,29 @@ function withTimeout(promise, timeoutMs) {
510
685
  });
511
686
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
512
687
  }
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
+ ];
697
+ async function clickCommentExpanders(page) {
698
+ 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
+ }
707
+ }
708
+ }
709
+ return clicked;
710
+ }
513
711
 
514
712
  // src/cli/cli.ts
515
713
  import { chromium } from "playwright";
@@ -926,6 +1124,7 @@ import { randomUUID } from "node:crypto";
926
1124
 
927
1125
  // ../../packages/shared/src/index.ts
928
1126
  var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
1127
+ var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
929
1128
 
930
1129
  // src/main/desktop-agent-session.ts
931
1130
  var MESSAGE_FRESHNESS_MS = 5 * 60 * 1e3;
@@ -1091,7 +1290,8 @@ var DesktopAgentSession = class {
1091
1290
  platform: this.options.platform,
1092
1291
  state: this.reportedState,
1093
1292
  facebookReady: this.options.getFacebookReady(),
1094
- currentJobId: this.reportedJobId
1293
+ currentJobId: this.reportedJobId,
1294
+ capabilities: ["POST_COMMENTS"]
1095
1295
  });
1096
1296
  this.handshake = "WELCOME";
1097
1297
  return;
@@ -1135,8 +1335,15 @@ var DesktopAgentSession = class {
1135
1335
  this.emitJob({ type: "STOP", ...envelope.payload });
1136
1336
  return;
1137
1337
  }
1338
+ if (envelope.type === "POST_COMMENTS_FETCH") {
1339
+ if (!isPostCommentsFetch(envelope.payload)) {
1340
+ this.protocolFailure(socket, stored, generation);
1341
+ return;
1342
+ }
1343
+ this.emitJob({ type: "COMMENTS", ...envelope.payload });
1344
+ return;
1345
+ }
1138
1346
  if (envelope.type !== "SCAN_JOB_START") {
1139
- this.protocolFailure(socket, stored, generation);
1140
1347
  return;
1141
1348
  }
1142
1349
  this.update({ ...this.snapshot, currentJobId: envelope.payload.jobId, pendingJobId: null });
@@ -1280,6 +1487,11 @@ function describeEnvelopeProblem(envelope, deviceId, replayIds) {
1280
1487
  if (replayIds.has(envelope.messageId)) return `replayed message ${envelope.messageId}`;
1281
1488
  return "unknown";
1282
1489
  }
1490
+ function isPostCommentsFetch(value) {
1491
+ if (!value || typeof value !== "object") return false;
1492
+ 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;
1494
+ }
1283
1495
  function emptySnapshot(state) {
1284
1496
  return {
1285
1497
  state,
@@ -1309,6 +1521,127 @@ function readErrorCode(error) {
1309
1521
  return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN_ERROR";
1310
1522
  }
1311
1523
 
1524
+ // src/main/desktop-comment-runner.ts
1525
+ var DEFAULT_FETCH_TIMEOUT_MS = 2 * 60 * 1e3;
1526
+ var DEFAULT_MAX_QUEUE = 5;
1527
+ var DesktopCommentRunner = class {
1528
+ options;
1529
+ queue = [];
1530
+ stopController = new AbortController();
1531
+ active = null;
1532
+ currentController = null;
1533
+ unsubscribe = null;
1534
+ stopped = false;
1535
+ constructor(options) {
1536
+ this.options = options;
1537
+ }
1538
+ start() {
1539
+ if (this.unsubscribe) return;
1540
+ this.unsubscribe = this.options.session.subscribeJobs((event) => this.handleEvent(event));
1541
+ }
1542
+ stop() {
1543
+ this.stopped = true;
1544
+ this.queue.length = 0;
1545
+ this.stopController.abort();
1546
+ this.currentController?.abort();
1547
+ this.unsubscribe?.();
1548
+ this.unsubscribe = null;
1549
+ }
1550
+ isRunning() {
1551
+ return this.active !== null;
1552
+ }
1553
+ async whenIdle() {
1554
+ await this.active;
1555
+ }
1556
+ async handleEvent(event) {
1557
+ if (event.type !== "COMMENTS" || this.stopped) return;
1558
+ if (this.queue.length >= (this.options.maxQueue ?? DEFAULT_MAX_QUEUE)) {
1559
+ await this.upload(event.requestId, { comments: [], errorCode: "QUEUE_FULL" });
1560
+ return;
1561
+ }
1562
+ this.queue.push(event);
1563
+ await this.drain();
1564
+ }
1565
+ drain() {
1566
+ if (!this.active) {
1567
+ this.active = this.run().finally(() => {
1568
+ this.active = null;
1569
+ });
1570
+ }
1571
+ return this.active;
1572
+ }
1573
+ async run() {
1574
+ while (this.queue.length > 0 && !this.stopped) {
1575
+ const request = this.queue.shift();
1576
+ if (request) await this.process(request);
1577
+ }
1578
+ }
1579
+ async process(request) {
1580
+ const controller = new AbortController();
1581
+ const timeout = this.options.setTimer(
1582
+ () => controller.abort(),
1583
+ this.options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
1584
+ );
1585
+ this.currentController = controller;
1586
+ try {
1587
+ if (this.options.isScanActive()) {
1588
+ await this.upload(request.requestId, { comments: [], errorCode: "SCANNER_BUSY" });
1589
+ return;
1590
+ }
1591
+ if (this.options.isLoginActive()) {
1592
+ await this.upload(request.requestId, { comments: [], errorCode: "LOGIN_ACTIVE" });
1593
+ return;
1594
+ }
1595
+ const storageState = await this.options.facebookState.load();
1596
+ if (!storageState) {
1597
+ await this.upload(request.requestId, { comments: [], errorCode: "FACEBOOK_SESSION_MISSING" });
1598
+ return;
1599
+ }
1600
+ let comments;
1601
+ try {
1602
+ comments = await this.options.scanner.fetchComments({
1603
+ limit: request.limit,
1604
+ postUrl: request.postUrl,
1605
+ signal: controller.signal,
1606
+ storageState
1607
+ });
1608
+ } catch (error) {
1609
+ await this.upload(request.requestId, { comments: [], errorCode: readErrorCode2(error) });
1610
+ return;
1611
+ }
1612
+ await this.upload(request.requestId, { comments });
1613
+ } finally {
1614
+ this.options.clearTimer(timeout);
1615
+ if (this.currentController === controller) this.currentController = null;
1616
+ }
1617
+ }
1618
+ async upload(requestId, payload) {
1619
+ const signal = this.stopController.signal;
1620
+ if (signal.aborted) return;
1621
+ for (let attempt = 0; ; attempt += 1) {
1622
+ try {
1623
+ await this.options.http.uploadComments(requestId, payload, signal);
1624
+ return;
1625
+ } catch (error) {
1626
+ if (!isRecoverable(error) || attempt >= 2 || signal.aborted) return;
1627
+ try {
1628
+ await this.options.wait(1e3 * 2 ** attempt, signal);
1629
+ } catch {
1630
+ return;
1631
+ }
1632
+ }
1633
+ }
1634
+ }
1635
+ };
1636
+ function readErrorCode2(error) {
1637
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "";
1638
+ const sanitized = code.replace(/[^A-Z0-9_]/g, "_").slice(0, 100);
1639
+ return sanitized || "COMMENT_FETCH_FAILED";
1640
+ }
1641
+ function isRecoverable(error) {
1642
+ return Boolean(error && typeof error === "object" && "recoverable" in error && error.recoverable === true);
1643
+ }
1644
+
1312
1645
  // src/main/desktop-scan-http.ts
1313
1646
  import { createHash, createPrivateKey, randomUUID as randomUUID2, sign } from "node:crypto";
1314
1647
  var RESPONSE_LIMIT2 = 64 * 1024;
@@ -1344,6 +1677,13 @@ var DesktopScanHttp = class {
1344
1677
  cancel(jobId, attemptToken) {
1345
1678
  return this.signedPost(`/leads/desktop-agent/jobs/${jobId}/cancel`, { attemptToken });
1346
1679
  }
1680
+ uploadComments(requestId, payload, signal) {
1681
+ return this.signedPost(
1682
+ `/leads/desktop-agent/post-comments/${requestId}`,
1683
+ normalizeComments(payload),
1684
+ signal
1685
+ );
1686
+ }
1347
1687
  async signedPost(path, businessPayload, signal) {
1348
1688
  const credential = this.getCredential();
1349
1689
  if (!credential) throw new DesktopScanHttpError("CREDENTIAL_UNAVAILABLE");
@@ -1485,6 +1825,15 @@ function normalizeFormatToken(value) {
1485
1825
  const normalized = value?.trim();
1486
1826
  return normalized && normalized.length <= 50 ? normalized : void 0;
1487
1827
  }
1828
+ 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);
1834
+ const errorCode = payload.errorCode?.replace(/[^A-Z0-9_]/g, "_").slice(0, 100);
1835
+ return { comments, ...errorCode ? { errorCode } : {} };
1836
+ }
1488
1837
  function normalizeFacebookPostUrl(value) {
1489
1838
  try {
1490
1839
  const url = new URL(value);
@@ -1525,7 +1874,7 @@ var DesktopScanRunner = class {
1525
1874
  records = await this.options.spool.loadAll();
1526
1875
  } catch (error) {
1527
1876
  this.recoveryBlocked = true;
1528
- this.update({ ...idleSnapshot(), state: "SPOOL_FULL", errorCode: readErrorCode2(error) });
1877
+ this.update({ ...idleSnapshot(), state: "SPOOL_FULL", errorCode: readErrorCode3(error) });
1529
1878
  return;
1530
1879
  }
1531
1880
  const current = records.find((record) => Date.parse(record.job.leaseExpiresAt) > this.options.now()) ?? null;
@@ -1587,6 +1936,7 @@ var DesktopScanRunner = class {
1587
1936
  return true;
1588
1937
  }
1589
1938
  async handleEvent(event) {
1939
+ if (event.type === "COMMENTS") return;
1590
1940
  if (event.type === "AVAILABLE") {
1591
1941
  await this.claimIfReady(event.jobId);
1592
1942
  return;
@@ -1653,6 +2003,7 @@ var DesktopScanRunner = class {
1653
2003
  async claimIfReady(jobId) {
1654
2004
  const session = this.options.session.getSnapshot();
1655
2005
  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;
2006
+ if (this.options.isCommentFetchActive?.()) return;
1656
2007
  this.options.session.claimJob(jobId);
1657
2008
  }
1658
2009
  async run(job, resume) {
@@ -1783,7 +2134,7 @@ var DesktopScanRunner = class {
1783
2134
  this.recovered = null;
1784
2135
  this.options.session.finishJob(job.jobId);
1785
2136
  } catch (error) {
1786
- const code = readErrorCode2(error);
2137
+ const code = readErrorCode3(error);
1787
2138
  const checkpoint = code === "LOGIN_REQUIRED" || code === "CHECKPOINT_REQUIRED";
1788
2139
  if (code === "OFFLINE_PENDING") {
1789
2140
  this.recovered = record;
@@ -1820,7 +2171,7 @@ var DesktopScanRunner = class {
1820
2171
  await this.options.spool.put(record);
1821
2172
  confirmed = await this.completeWithRetry(job, completion, controller.signal);
1822
2173
  } catch (completionError) {
1823
- const completionCode = readErrorCode2(completionError);
2174
+ const completionCode = readErrorCode3(completionError);
1824
2175
  terminalPersistenceFailed = true;
1825
2176
  this.recovered = record;
1826
2177
  this.update({
@@ -1856,7 +2207,7 @@ var DesktopScanRunner = class {
1856
2207
  await this.options.http.uploadBatch(job.jobId, batch, signal);
1857
2208
  return true;
1858
2209
  } catch (error) {
1859
- if (!isRecoverable(error)) throw error;
2210
+ if (!isRecoverable2(error)) throw error;
1860
2211
  if (attempt >= 4) return false;
1861
2212
  attempt += 1;
1862
2213
  await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
@@ -1871,7 +2222,7 @@ var DesktopScanRunner = class {
1871
2222
  await this.options.http.complete(job.jobId, payload, signal);
1872
2223
  return true;
1873
2224
  } catch (error) {
1874
- if (!isRecoverable(error)) throw error;
2225
+ if (!isRecoverable2(error)) throw error;
1875
2226
  if (attempt >= 4) return false;
1876
2227
  attempt += 1;
1877
2228
  await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
@@ -1960,10 +2311,10 @@ function createSpoolRecord(job, now) {
1960
2311
  function nowIso(now) {
1961
2312
  return new Date(now).toISOString();
1962
2313
  }
1963
- function readErrorCode2(error) {
2314
+ function readErrorCode3(error) {
1964
2315
  return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code.slice(0, 500) : "SCAN_FAILED";
1965
2316
  }
1966
- function isRecoverable(error) {
2317
+ function isRecoverable2(error) {
1967
2318
  return Boolean(error && typeof error === "object" && "recoverable" in error && error.recoverable === true);
1968
2319
  }
1969
2320
  function abortableDelay(milliseconds, signal) {
@@ -2975,17 +3326,34 @@ async function commandRun(runtime, io) {
2975
3326
  },
2976
3327
  origin: runtime.endpoint.httpOrigin
2977
3328
  });
3329
+ const scanner = new FacebookScanner({ browserType: runtime.browserType });
3330
+ const commentRunner = new DesktopCommentRunner({
3331
+ clearTimer: (timer) => clearTimeout(timer),
3332
+ facebookState: runtime.facebook,
3333
+ http: scanHttp,
3334
+ isLoginActive: () => false,
3335
+ isScanActive: () => scanRunner?.isRunning() ?? false,
3336
+ scanner,
3337
+ session,
3338
+ setTimer: (callback, delay) => {
3339
+ const timer = setTimeout(callback, delay);
3340
+ timer.unref();
3341
+ return timer;
3342
+ },
3343
+ wait: abortableDelay
3344
+ });
2978
3345
  const scanRunner = new DesktopScanRunner({
2979
3346
  clearTimer: (timer) => clearTimeout(timer),
2980
3347
  facebookState: runtime.facebook,
2981
3348
  http: scanHttp,
3349
+ isCommentFetchActive: () => commentRunner.isRunning(),
2982
3350
  isLoginActive: () => false,
2983
3351
  now: Date.now,
2984
3352
  randomUuid: () => randomUUID3(),
2985
3353
  requestFacebookLogin: async () => {
2986
3354
  io.error("The Facebook session expired. Stop this worker, run `diggylab login`, then start it again.");
2987
3355
  },
2988
- scanner: new FacebookScanner({ browserType: runtime.browserType }),
3356
+ scanner,
2989
3357
  session,
2990
3358
  setTimer: (callback, delay) => {
2991
3359
  const timer = setTimeout(callback, delay);
@@ -3013,6 +3381,7 @@ async function commandRun(runtime, io) {
3013
3381
  recordActivity(`Scanner state changed to ${snapshot.state}.`, snapshot.state);
3014
3382
  });
3015
3383
  scanRunner.start();
3384
+ commentRunner.start();
3016
3385
  await scanRunner.recover();
3017
3386
  await session.start();
3018
3387
  io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
@@ -3028,6 +3397,7 @@ async function commandRun(runtime, io) {
3028
3397
  } finally {
3029
3398
  clearInterval(keepAlive);
3030
3399
  session.stop();
3400
+ commentRunner.stop();
3031
3401
  scanRunner.stop();
3032
3402
  }
3033
3403
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
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": {