diggylab 0.1.50 → 0.1.52

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 +228 -225
  2. package/cli.js +921 -134
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -8,6 +8,106 @@ import { hostname, homedir } from "node:os";
8
8
  import { dirname as dirname3, join as join4 } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
+ // ../../packages/shared/dist/comment-publishing.js
12
+ var COMMENT_TEXT_MAX_LENGTH = 2e3;
13
+ var COMMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
14
+ var COMMENT_IMAGE_QUOTA_BYTES = 500 * 1024 * 1024;
15
+
16
+ // ../../packages/shared/dist/index.js
17
+ var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
18
+ var DESKTOP_POST_COMMENTS_MAX_LIMIT = 200;
19
+ var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
20
+ var FACEBOOK_DOMAIN = ".facebook.com";
21
+ var FACEBOOK_HOST = /(^|\.)facebook\.com$/i;
22
+ function isFacebookDomain(domain) {
23
+ return Boolean(domain && FACEBOOK_HOST.test(domain.replace(/^\./, "")));
24
+ }
25
+ function normalizeSameSite(value) {
26
+ if (typeof value !== "string")
27
+ return void 0;
28
+ const lower = value.toLowerCase().replace(/[^a-z]/g, "");
29
+ if (lower === "lax")
30
+ return "Lax";
31
+ if (lower === "strict")
32
+ return "Strict";
33
+ if (lower === "none" || lower === "norestriction")
34
+ return "None";
35
+ return void 0;
36
+ }
37
+ function toCookie(value) {
38
+ const name = typeof value.name === "string" ? value.name.trim() : "";
39
+ const cookieValue = typeof value.value === "string" ? value.value : "";
40
+ if (!name || !cookieValue)
41
+ return null;
42
+ return {
43
+ domain: typeof value.domain === "string" ? value.domain : void 0,
44
+ expires: typeof value.expires === "number" ? value.expires : typeof value.expirationDate === "number" ? value.expirationDate : void 0,
45
+ httpOnly: typeof value.httpOnly === "boolean" ? value.httpOnly : void 0,
46
+ name,
47
+ path: typeof value.path === "string" ? value.path : void 0,
48
+ sameSite: normalizeSameSite(value.sameSite),
49
+ secure: typeof value.secure === "boolean" ? value.secure : void 0,
50
+ value: cookieValue
51
+ };
52
+ }
53
+ function parseCookieHeader(input) {
54
+ const header = input.replace(/^\s*cookie\s*:/i, "");
55
+ return header.split(/[;\n]/).map((part) => part.trim()).filter(Boolean).map((part) => {
56
+ const separator = part.indexOf("=");
57
+ if (separator <= 0)
58
+ return null;
59
+ return { name: part.slice(0, separator).trim(), value: part.slice(separator + 1) };
60
+ }).filter((cookie) => cookie !== null);
61
+ }
62
+ function parseCookieTable(input) {
63
+ const cookies = [];
64
+ for (const line of input.split("\n")) {
65
+ const columns = line.split(" ");
66
+ const name = columns[0]?.trim() ?? "";
67
+ const value = columns[1];
68
+ if (!value || !name || name === "Name")
69
+ continue;
70
+ cookies.push({ domain: columns[2]?.trim(), name, value });
71
+ }
72
+ return cookies;
73
+ }
74
+ function parseCookieInput(input) {
75
+ const trimmed = input.trim();
76
+ if (!trimmed)
77
+ return [];
78
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
79
+ return trimmed.includes(" ") ? parseCookieTable(trimmed) : parseCookieHeader(trimmed);
80
+ }
81
+ try {
82
+ const parsed = JSON.parse(trimmed);
83
+ if (Array.isArray(parsed)) {
84
+ return parsed.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
85
+ }
86
+ if (parsed && typeof parsed === "object") {
87
+ const cookies = parsed.cookies;
88
+ if (Array.isArray(cookies)) {
89
+ return cookies.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
90
+ }
91
+ }
92
+ } catch {
93
+ return [];
94
+ }
95
+ return [];
96
+ }
97
+ function toFacebookStorageState(cookies) {
98
+ const usable = cookies.filter((cookie) => isFacebookDomain(cookie.domain) || cookie.domain === void 0).map((cookie) => ({
99
+ domain: isFacebookDomain(cookie.domain) ? cookie.domain : FACEBOOK_DOMAIN,
100
+ expires: typeof cookie.expires === "number" && cookie.expires > 0 ? cookie.expires : -1,
101
+ httpOnly: cookie.httpOnly ?? true,
102
+ name: cookie.name,
103
+ path: cookie.path?.startsWith("/") ? cookie.path : "/",
104
+ sameSite: cookie.sameSite ?? "Lax",
105
+ secure: cookie.secure ?? true,
106
+ value: cookie.value
107
+ }));
108
+ return { cookies: usable, origins: [] };
109
+ }
110
+
11
111
  // ../../packages/facebook-scanner/dist/parser.js
12
112
  var COMMENT_TEXT_LIMIT = 2e3;
13
113
  var COMMENT_AUTHOR_LIMIT = 200;
@@ -675,6 +775,370 @@ function parseCommentDepth(label) {
675
775
  return /\brepl(?:y|ies)\b|trả lời|phản hồi/i.test(parentPart) ? 2 : 1;
676
776
  }
677
777
 
778
+ // ../../packages/facebook-scanner/dist/comment-publish-evidence.js
779
+ function readCommentPublishEvidence(body, parseComments) {
780
+ const evidence = { comments: [], declinedIds: /* @__PURE__ */ new Set(), createdIds: /* @__PURE__ */ new Set() };
781
+ const visit2 = (value, depth = 0) => {
782
+ if (!value || typeof value !== "object" || depth > 60)
783
+ return;
784
+ const node = value;
785
+ const creation = node.comment_create;
786
+ const createdId = commentId(creation?.comment?.legacy_fbid) ?? commentId(creation?.comment?.id);
787
+ if (createdId)
788
+ evidence.createdIds.add(createdId);
789
+ const id = commentId(node.legacy_fbid) ?? commentId(node.id);
790
+ if (id) {
791
+ const groupInfo = node.group_comment_info;
792
+ if (groupInfo?.declined_feedback_text != null)
793
+ evidence.declinedIds.add(id);
794
+ if (node.body || node.comment_body || node.preferred_body) {
795
+ evidence.comments.push(...parseComments(JSON.stringify({ ...node, __typename: "Comment", id })));
796
+ }
797
+ }
798
+ for (const child of Object.values(node))
799
+ visit2(child, depth + 1);
800
+ };
801
+ for (const line of body.split(/\r?\n/)) {
802
+ try {
803
+ visit2(JSON.parse(line.trim().replace(/^for\s*\(;;\);/, "")));
804
+ } catch {
805
+ }
806
+ }
807
+ return evidence;
808
+ }
809
+ function commentId(value) {
810
+ if (typeof value !== "string")
811
+ return void 0;
812
+ if (/^\d{1,100}$/.test(value))
813
+ return value;
814
+ const decoded = Buffer.from(value, "base64").toString("utf8");
815
+ return /^comment:\d+_(\d{1,100})$/.exec(decoded)?.[1];
816
+ }
817
+
818
+ // ../../packages/facebook-scanner/dist/comment-publisher.js
819
+ var SUBMIT_TIMEOUT_MS = 15e3;
820
+ var COMPOSER_TIMEOUT_MS = 1e4;
821
+ var IMAGE_UPLOAD_TIMEOUT_MS = 15e3;
822
+ var APPROVAL_PATTERN = /pending approval|awaiting approval|chờ duyệt|đang chờ duyệt|chờ quản trị viên/i;
823
+ var EXTENSION = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" };
824
+ async function publishCommentOnPage(page, options, config) {
825
+ const started = Date.now();
826
+ const diagnostic = {
827
+ stage: "COMPOSER",
828
+ mutationResponses: 0,
829
+ graphqlResponses: 0,
830
+ readErrors: 0,
831
+ comments: 0,
832
+ createdIds: 0,
833
+ declinedIds: 0,
834
+ reload: "NOT_STARTED",
835
+ durationMs: 0
836
+ };
837
+ try {
838
+ const result = await publishWithDiagnostic(page, options, config, diagnostic);
839
+ diagnostic.outcome = result.outcome;
840
+ diagnostic.errorCode = result.errorCode;
841
+ return result;
842
+ } catch (error) {
843
+ diagnostic.errorCode = options.signal?.aborted ? "PUBLISH_ABORTED" : "PUBLISH_EXCEPTION";
844
+ throw error;
845
+ } finally {
846
+ diagnostic.durationMs = Date.now() - started;
847
+ try {
848
+ options.onDiagnostic?.({ ...diagnostic });
849
+ } catch {
850
+ }
851
+ }
852
+ }
853
+ async function publishWithDiagnostic(page, options, config, diagnostic) {
854
+ const composer = await waitForComposer(page);
855
+ if (!composer)
856
+ return failed("COMPOSER_NOT_FOUND");
857
+ const fileInput = options.image ? await findFileInput(page, composer) : null;
858
+ if (options.image && !fileInput)
859
+ return failed("IMAGE_NOT_SUPPORTED");
860
+ assertNotAborted(options.signal);
861
+ const allowed = await options.beforeSubmit();
862
+ assertNotAborted(options.signal);
863
+ if (!allowed)
864
+ return failed("START_NOT_ALLOWED");
865
+ if (options.content) {
866
+ diagnostic.stage = "FILL";
867
+ await composer.fill(options.content);
868
+ }
869
+ {
870
+ if (options.image && fileInput) {
871
+ const currentFileInput = await findFileInput(page, composer);
872
+ if (!currentFileInput)
873
+ return failed("IMAGE_NOT_SUPPORTED");
874
+ const uploadDeadline = Date.now() + IMAGE_UPLOAD_TIMEOUT_MS;
875
+ const uploadResponse = page.waitForResponse((response2) => {
876
+ const url = new URL(response2.url());
877
+ return (url.hostname === "facebook.com" || url.hostname.endsWith(".facebook.com")) && url.pathname === "/ajax/ufi/upload/" && response2.request().method() === "POST";
878
+ }, { timeout: IMAGE_UPLOAD_TIMEOUT_MS }).catch(() => null);
879
+ await currentFileInput.setInputFiles({
880
+ buffer: options.image.buffer,
881
+ mimeType: options.image.contentType,
882
+ name: `comment.${EXTENSION[options.image.contentType]}`
883
+ });
884
+ const response = await uploadResponse;
885
+ if (!response || !response.ok())
886
+ return failed("IMAGE_UPLOAD_FAILED");
887
+ try {
888
+ let timer;
889
+ const body = await Promise.race([
890
+ response.text(),
891
+ new Promise((_resolve, reject) => {
892
+ timer = setTimeout(() => reject(new Error("IMAGE_UPLOAD_FAILED")), Math.max(0, uploadDeadline - Date.now()));
893
+ })
894
+ ]).finally(() => clearTimeout(timer));
895
+ const uploaded = JSON.parse(body.replace(/^for\s*\(;;\);/, ""));
896
+ const id = uploaded.payload?.fbid;
897
+ if (uploaded.error || !(typeof id === "number" && Number.isSafeInteger(id) && id > 0 || typeof id === "string" && /^[1-9]\d{0,99}$/.test(id)))
898
+ return failed("IMAGE_UPLOAD_FAILED");
899
+ } catch {
900
+ return failed("IMAGE_UPLOAD_FAILED");
901
+ }
902
+ }
903
+ const submit = await findSubmit(page, composer);
904
+ if (!submit)
905
+ return failed("SUBMIT_NOT_FOUND");
906
+ const evidence = { comments: [], declinedIds: /* @__PURE__ */ new Set(), createdIds: /* @__PURE__ */ new Set() };
907
+ const onResponse = (response) => {
908
+ if (!response.url().includes("/api/graphql/"))
909
+ return;
910
+ diagnostic.graphqlResponses += 1;
911
+ const name = new URLSearchParams(response.request().postData() ?? "").get("fb_api_req_friendly_name");
912
+ if (name !== "useCometUFICreateCommentMutation")
913
+ return;
914
+ diagnostic.mutationResponses += 1;
915
+ diagnostic.httpStatus = response.status();
916
+ if (response.status() >= 400)
917
+ return;
918
+ void response.text().then((body) => {
919
+ try {
920
+ diagnostic.stage = "READ_RESPONSE";
921
+ const received = readCommentPublishEvidence(body, config.parseGraphqlComments);
922
+ evidence.comments.push(...received.comments);
923
+ for (const id of received.declinedIds)
924
+ evidence.declinedIds.add(id);
925
+ for (const id of received.createdIds)
926
+ evidence.createdIds.add(id);
927
+ diagnostic.comments = evidence.comments.length;
928
+ diagnostic.createdIds = evidence.createdIds.size;
929
+ diagnostic.declinedIds = evidence.declinedIds.size;
930
+ } catch {
931
+ diagnostic.readErrors += 1;
932
+ }
933
+ }).catch(() => {
934
+ diagnostic.readErrors += 1;
935
+ });
936
+ };
937
+ page.on("response", onResponse);
938
+ try {
939
+ diagnostic.stage = "SUBMIT";
940
+ await submit.click({ timeout: 1e4 });
941
+ if (options.skipVerification) {
942
+ diagnostic.reload = "SKIPPED";
943
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "VERIFICATION_SKIPPED" };
944
+ }
945
+ if (diagnostic.stage === "SUBMIT")
946
+ diagnostic.stage = "WAITING_RESPONSE";
947
+ return await waitForEvidence(page, {
948
+ evidence,
949
+ parseComments: config.parseGraphqlComments,
950
+ diagnostic,
951
+ content: options.content,
952
+ hasImage: Boolean(options.image),
953
+ postUrl: options.postUrl
954
+ });
955
+ } finally {
956
+ page.off("response", onResponse);
957
+ }
958
+ }
959
+ }
960
+ async function waitForComposer(page) {
961
+ const deadline = Date.now() + COMPOSER_TIMEOUT_MS;
962
+ for (; ; ) {
963
+ const composer = await findComposer(page);
964
+ if (composer)
965
+ return composer;
966
+ if (Date.now() >= deadline)
967
+ return null;
968
+ await page.mouse.wheel(0, 800).catch(() => void 0);
969
+ await page.waitForTimeout(400);
970
+ }
971
+ }
972
+ async function findComposer(page) {
973
+ const handle = await page.evaluateHandle(() => {
974
+ const boxes = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"]')).filter((element2) => {
975
+ const style = window.getComputedStyle(element2);
976
+ const label = element2.getAttribute("aria-label") ?? "";
977
+ return style.display !== "none" && style.visibility !== "hidden" && !/reply|trả lời/i.test(label);
978
+ });
979
+ const composer = boxes.find((element2) => /comment|bình luận/i.test(element2.getAttribute("aria-label") ?? "")) ?? boxes[0];
980
+ return composer ?? null;
981
+ });
982
+ const element = handle.asElement();
983
+ if (!element)
984
+ return null;
985
+ return element;
986
+ }
987
+ async function findFileInput(page, composer) {
988
+ const handle = await composer.evaluateHandle((element2) => {
989
+ let node = element2;
990
+ for (let depth = 0; depth < 8 && node; depth += 1, node = node.parentElement) {
991
+ const input = node.querySelector('input[type="file"][accept*="image"]');
992
+ if (input) {
993
+ return input;
994
+ }
995
+ }
996
+ return null;
997
+ });
998
+ const element = handle.asElement();
999
+ return element;
1000
+ }
1001
+ async function findSubmit(page, composer) {
1002
+ const handle = await composer.evaluateHandle((element2) => {
1003
+ let node = element2;
1004
+ for (let depth = 0; depth < 8 && node; depth += 1, node = node.parentElement) {
1005
+ const button = Array.from(node.querySelectorAll('[role="button"], button')).find((candidate) => {
1006
+ const name = (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").trim();
1007
+ return /^(comment|post comment|bình luận|post|đăng|gửi)$/i.test(name) && candidate.getAttribute("aria-disabled") !== "true" && !candidate.disabled;
1008
+ });
1009
+ if (button) {
1010
+ return button;
1011
+ }
1012
+ }
1013
+ return null;
1014
+ });
1015
+ const element = handle.asElement();
1016
+ if (!element)
1017
+ return null;
1018
+ return element;
1019
+ }
1020
+ async function waitForEvidence(page, state) {
1021
+ const deadline = Date.now() + SUBMIT_TIMEOUT_MS;
1022
+ while (Date.now() < deadline) {
1023
+ const declinedCreatedId = [...state.evidence.createdIds].find((id) => state.evidence.declinedIds.has(id));
1024
+ if (declinedCreatedId) {
1025
+ state.diagnostic.commentId = declinedCreatedId;
1026
+ return {
1027
+ outcome: "UNKNOWN",
1028
+ submitAttempted: true,
1029
+ errorCode: "FACEBOOK_COMMENT_DECLINED",
1030
+ facebookCommentId: declinedCreatedId,
1031
+ commentUrl: `${state.postUrl}?comment_id=${encodeURIComponent(declinedCreatedId)}`
1032
+ };
1033
+ }
1034
+ const match = state.evidence.comments.find((comment) => /^\d{1,100}$/.test(comment.id ?? "") && (!state.evidence.createdIds.size || state.evidence.createdIds.has(comment.id ?? "")) && (state.content ? normalize(comment.text) === normalize(state.content) : true) && (!state.hasImage || (comment.attachmentImages?.length ?? 0) > 0));
1035
+ const submittedId = match?.id ?? (state.evidence.createdIds.size === 1 ? state.evidence.createdIds.values().next().value : void 0);
1036
+ if (submittedId) {
1037
+ state.diagnostic.commentId = submittedId;
1038
+ const identity = {
1039
+ commentUrl: `${state.postUrl}?comment_id=${encodeURIComponent(submittedId)}`,
1040
+ facebookCommentId: submittedId
1041
+ };
1042
+ if (state.evidence.declinedIds.has(submittedId)) {
1043
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_COMMENT_DECLINED" };
1044
+ }
1045
+ try {
1046
+ state.diagnostic.stage = "RELOAD";
1047
+ const verification = await verifyAfterReload(page, state, submittedId);
1048
+ state.diagnostic.reload = verification === "PENDING" ? "MISSING" : verification;
1049
+ if (verification === "DECLINED") {
1050
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_COMMENT_DECLINED" };
1051
+ }
1052
+ if (verification === "PENDING") {
1053
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_APPROVAL_PENDING" };
1054
+ }
1055
+ if (verification === "CONFIRMED") {
1056
+ return { ...identity, outcome: "SUCCEEDED", submitAttempted: true };
1057
+ }
1058
+ } catch {
1059
+ state.diagnostic.reload = "ERROR";
1060
+ }
1061
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "SUBMIT_UNCONFIRMED" };
1062
+ }
1063
+ const pending = await page.getByText(APPROVAL_PATTERN).count().catch(() => 0);
1064
+ if (pending > 0) {
1065
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_APPROVAL_PENDING" };
1066
+ }
1067
+ await page.waitForTimeout(500);
1068
+ }
1069
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "SUBMIT_UNCONFIRMED" };
1070
+ }
1071
+ async function verifyAfterReload(page, state, submittedId) {
1072
+ const received = [];
1073
+ let observing = true;
1074
+ let timer;
1075
+ const onResponse = (response) => {
1076
+ if (!response.url().includes("/api/graphql/") || response.status() >= 400)
1077
+ return;
1078
+ void response.text().then((body) => {
1079
+ if (observing)
1080
+ received.push(readCommentPublishEvidence(body, state.parseComments));
1081
+ }).catch(() => void 0);
1082
+ };
1083
+ page.on("response", onResponse);
1084
+ try {
1085
+ const commentUrl = new URL(state.postUrl);
1086
+ commentUrl.searchParams.set("comment_id", submittedId);
1087
+ await page.goto(commentUrl.href, { waitUntil: "domcontentloaded", timeout: 15e3 });
1088
+ const deadline = Date.now() + 3e3;
1089
+ const inspect = async () => {
1090
+ while (observing && Date.now() < deadline) {
1091
+ const visible = await page.evaluate(({ id, postUrl }) => {
1092
+ const target = new URL(postUrl);
1093
+ const found = Array.from(document.querySelectorAll('a[href*="comment_id="]')).some((link) => {
1094
+ const article = link.closest('[role="article"]');
1095
+ const visibility = { visibilityProperty: true, opacityProperty: true };
1096
+ if (!article || !link.checkVisibility(visibility) || !article.checkVisibility(visibility))
1097
+ return false;
1098
+ const url = new URL(link.href);
1099
+ return url.hostname === target.hostname && url.pathname.replace(/\/$/, "") === target.pathname.replace(/\/$/, "") && url.searchParams.get("comment_id") === id;
1100
+ });
1101
+ return {
1102
+ found,
1103
+ scripts: Array.from(document.querySelectorAll('script[type="application/json"]')).map((script) => script.textContent ?? "").join("\n")
1104
+ };
1105
+ }, { id: submittedId, postUrl: state.postUrl });
1106
+ if (!observing)
1107
+ return "MISSING";
1108
+ const evidence = [...received, readCommentPublishEvidence(visible.scripts, state.parseComments)];
1109
+ if (evidence.some((item) => item.declinedIds.has(submittedId)))
1110
+ return "DECLINED";
1111
+ if (await page.getByText(APPROVAL_PATTERN).count())
1112
+ return "PENDING";
1113
+ if (evidence.some((item) => item.comments.some((comment) => comment.id === submittedId && (!state.hasImage || (comment.attachmentImages?.length ?? 0) > 0))) || !state.hasImage && visible.found)
1114
+ return "CONFIRMED";
1115
+ await page.waitForTimeout(Math.min(200, Math.max(0, deadline - Date.now())));
1116
+ }
1117
+ return "MISSING";
1118
+ };
1119
+ return await Promise.race([
1120
+ inspect(),
1121
+ new Promise((resolve) => {
1122
+ timer = setTimeout(() => resolve("MISSING"), 3e3);
1123
+ })
1124
+ ]);
1125
+ } finally {
1126
+ observing = false;
1127
+ clearTimeout(timer);
1128
+ page.off("response", onResponse);
1129
+ }
1130
+ }
1131
+ function normalize(value) {
1132
+ return value.replace(/\s+/g, " ").trim();
1133
+ }
1134
+ function failed(errorCode) {
1135
+ return { errorCode, outcome: "FAILED", submitAttempted: false };
1136
+ }
1137
+ function assertNotAborted(signal) {
1138
+ if (signal?.aborted)
1139
+ throw new Error("SCAN_CANCELED");
1140
+ }
1141
+
678
1142
  // ../../packages/facebook-scanner/dist/session.js
679
1143
  async function detectFacebookSession(page, context) {
680
1144
  let url;
@@ -708,6 +1172,7 @@ var FacebookScannerError = class extends Error {
708
1172
  };
709
1173
  var FacebookScanner = class {
710
1174
  browserType;
1175
+ proxy;
711
1176
  extractDomItems;
712
1177
  extractDomComments;
713
1178
  parseGraphqlBody;
@@ -717,6 +1182,7 @@ var FacebookScanner = class {
717
1182
  running = false;
718
1183
  constructor(options) {
719
1184
  this.browserType = options.browserType;
1185
+ this.proxy = options.proxy;
720
1186
  this.extractDomItems = options.extractDomItems ?? extractFacebookDomItems;
721
1187
  this.extractDomComments = options.extractDomComments ?? extractFacebookDomComments;
722
1188
  this.parseGraphqlBody = options.parseGraphqlBody ?? parseFacebookGraphqlBody;
@@ -730,10 +1196,13 @@ var FacebookScanner = class {
730
1196
  this.running = true;
731
1197
  let browser = null;
732
1198
  try {
733
- browser = await this.browserType.launch({ headless: true });
1199
+ browser = await this.browserType.launch({
1200
+ headless: true,
1201
+ ...this.proxy ? { proxy: this.proxy } : {}
1202
+ });
734
1203
  const results = [];
735
1204
  for (const [index, groupUrl] of options.groupUrls.entries()) {
736
- assertNotAborted(options.signal);
1205
+ assertNotAborted2(options.signal);
737
1206
  const items = await this.scanGroup(browser, options, groupUrl, index);
738
1207
  results.push(items);
739
1208
  }
@@ -749,11 +1218,14 @@ var FacebookScanner = class {
749
1218
  const target = normalizePostUrl(options.postUrl);
750
1219
  if (!target)
751
1220
  throw new FacebookScannerError("POST_URL_INVALID");
752
- assertNotAborted(options.signal);
1221
+ assertNotAborted2(options.signal);
753
1222
  this.running = true;
754
1223
  let browser = null;
755
1224
  try {
756
- browser = await this.browserType.launch({ headless: true });
1225
+ browser = await this.browserType.launch({
1226
+ headless: true,
1227
+ ...this.proxy ? { proxy: this.proxy } : {}
1228
+ });
757
1229
  const context = await browser.newContext({ storageState: options.storageState });
758
1230
  let page = null;
759
1231
  const graphqlComments = [];
@@ -781,7 +1253,7 @@ var FacebookScanner = class {
781
1253
  let idleRounds = 0;
782
1254
  let rounds = 0;
783
1255
  while (comments.length < options.limit && idleRounds < 3 && rounds < 20) {
784
- assertNotAborted(options.signal);
1256
+ assertNotAborted2(options.signal);
785
1257
  rounds += 1;
786
1258
  const previousCount = comments.length;
787
1259
  await page.waitForTimeout(1200);
@@ -798,7 +1270,7 @@ var FacebookScanner = class {
798
1270
  await page.mouse.wheel(0, 2e3);
799
1271
  }
800
1272
  }
801
- assertNotAborted(options.signal);
1273
+ assertNotAborted2(options.signal);
802
1274
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
803
1275
  const domComments = await this.extractDomComments(page);
804
1276
  comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments], options.limit);
@@ -816,6 +1288,57 @@ var FacebookScanner = class {
816
1288
  this.running = false;
817
1289
  }
818
1290
  }
1291
+ async publishComment(options) {
1292
+ if (this.running)
1293
+ throw new FacebookScannerError("SCANNER_BUSY");
1294
+ const target = normalizePostUrl(options.postUrl);
1295
+ if (!target)
1296
+ throw new FacebookScannerError("POST_URL_INVALID");
1297
+ assertNotAborted2(options.signal);
1298
+ this.running = true;
1299
+ let browser = null;
1300
+ try {
1301
+ browser = await this.browserType.launch({
1302
+ headless: true,
1303
+ ...this.proxy ? { proxy: this.proxy } : {}
1304
+ });
1305
+ const context = await browser.newContext({ storageState: options.storageState });
1306
+ let page = null;
1307
+ const closeOnAbort = () => {
1308
+ void page?.close().catch(() => void 0);
1309
+ void context.close().catch(() => void 0);
1310
+ void browser?.close().catch(() => void 0);
1311
+ };
1312
+ options.signal?.addEventListener("abort", closeOnAbort, { once: true });
1313
+ if (options.signal?.aborted)
1314
+ closeOnAbort();
1315
+ try {
1316
+ page = await context.newPage();
1317
+ if (options.signal?.aborted)
1318
+ closeOnAbort();
1319
+ await page.route("**/*", (route) => ["font", "media"].includes(route.request().resourceType()) ? route.abort() : route.continue());
1320
+ try {
1321
+ await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45e3 });
1322
+ } catch {
1323
+ throw new FacebookScannerError("NAVIGATION_FAILED");
1324
+ }
1325
+ const condition = await detectFacebookSession(page, context);
1326
+ if (condition !== "READY")
1327
+ throw new FacebookScannerError(condition);
1328
+ return await publishCommentOnPage(page, options, {
1329
+ parseGraphqlComments: this.parseGraphqlComments
1330
+ });
1331
+ } finally {
1332
+ options.signal?.removeEventListener("abort", closeOnAbort);
1333
+ await page?.unroute("**/*").catch(() => void 0);
1334
+ await page?.close().catch(() => void 0);
1335
+ await context.close().catch(() => void 0);
1336
+ }
1337
+ } finally {
1338
+ await browser?.close();
1339
+ this.running = false;
1340
+ }
1341
+ }
819
1342
  async scanGroup(browser, options, groupUrl, groupIndex) {
820
1343
  const context = await browser.newContext({ storageState: options.storageState });
821
1344
  let page = null;
@@ -845,7 +1368,7 @@ var FacebookScanner = class {
845
1368
  let items = [];
846
1369
  let idleRounds = 0;
847
1370
  while (items.length < options.limit && idleRounds < 3) {
848
- assertNotAborted(options.signal);
1371
+ assertNotAborted2(options.signal);
849
1372
  const previousCount = items.length;
850
1373
  await page.waitForTimeout(1500);
851
1374
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
@@ -863,7 +1386,7 @@ var FacebookScanner = class {
863
1386
  if (items.length < options.limit)
864
1387
  await page.mouse.wheel(0, 2e3);
865
1388
  }
866
- assertNotAborted(options.signal);
1389
+ assertNotAborted2(options.signal);
867
1390
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
868
1391
  items = mergeFacebookScanItems([...graphqlItems, ...domItems], options.limit);
869
1392
  if (!items.length)
@@ -879,7 +1402,7 @@ var FacebookScanner = class {
879
1402
  }
880
1403
  }
881
1404
  };
882
- function assertNotAborted(signal) {
1405
+ function assertNotAborted2(signal) {
883
1406
  if (signal?.aborted)
884
1407
  throw new FacebookScannerError("SCAN_CANCELED");
885
1408
  }
@@ -1360,13 +1883,6 @@ function isDeviceSummary(value) {
1360
1883
 
1361
1884
  // src/main/desktop-agent-session.ts
1362
1885
  import { randomUUID } from "node:crypto";
1363
-
1364
- // ../../packages/shared/src/index.ts
1365
- var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
1366
- var DESKTOP_POST_COMMENTS_MAX_LIMIT = 200;
1367
- var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
1368
-
1369
- // src/main/desktop-agent-session.ts
1370
1886
  var MESSAGE_FRESHNESS_MS = 5 * 60 * 1e3;
1371
1887
  var MAX_REPLAY_IDS = 512;
1372
1888
  var DesktopAgentSession = class {
@@ -1527,11 +2043,12 @@ var DesktopAgentSession = class {
1527
2043
  envelope.payload.challenge
1528
2044
  ),
1529
2045
  agentVersion: this.options.agentVersion,
2046
+ ...this.options.clientType ? { clientType: this.options.clientType } : {},
1530
2047
  platform: this.options.platform,
1531
2048
  state: this.reportedState,
1532
2049
  facebookReady: this.options.getFacebookReady(),
1533
2050
  currentJobId: this.reportedJobId,
1534
- capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE"]
2051
+ capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE", "POST_COMMENT_PUBLISH"]
1535
2052
  });
1536
2053
  this.handshake = "WELCOME";
1537
2054
  return;
@@ -1575,6 +2092,16 @@ var DesktopAgentSession = class {
1575
2092
  this.emitJob({ type: "STOP", ...envelope.payload });
1576
2093
  return;
1577
2094
  }
2095
+ if (envelope.type === "POST_COMMENT_PUBLISH") {
2096
+ const payload = envelope.payload;
2097
+ if (!isCommentPublish(payload)) {
2098
+ this.protocolFailure(socket, stored, generation);
2099
+ return;
2100
+ }
2101
+ if (Date.parse(payload.expiresAt) <= Date.now()) return;
2102
+ this.emitJob({ type: "COMMENT_PUBLISH", ...payload });
2103
+ return;
2104
+ }
1578
2105
  if (envelope.type === "POST_COMMENTS_FETCH") {
1579
2106
  if (!isPostCommentsFetch(envelope.payload)) {
1580
2107
  this.protocolFailure(socket, stored, generation);
@@ -1594,10 +2121,12 @@ var DesktopAgentSession = class {
1594
2121
  this.clearHeartbeat();
1595
2122
  this.heartbeatTimer = this.options.setTimer(() => {
1596
2123
  if (generation !== this.generation || socket !== this.socket) return;
2124
+ const metrics = this.options.metrics?.();
1597
2125
  this.send(socket, deviceId, "HEARTBEAT", {
1598
2126
  state: this.reportedState,
1599
2127
  facebookReady: this.options.getFacebookReady(),
1600
- currentJobId: this.reportedJobId
2128
+ currentJobId: this.reportedJobId,
2129
+ ...metrics ? { metrics } : {}
1601
2130
  });
1602
2131
  this.scheduleHeartbeat(socket, deviceId, interval, generation);
1603
2132
  }, interval);
@@ -1703,9 +2232,9 @@ var DesktopAgentSession = class {
1703
2232
  if (this.accessRefreshTimer) this.options.clearTimer(this.accessRefreshTimer);
1704
2233
  this.accessRefreshTimer = null;
1705
2234
  }
1706
- update(snapshot) {
1707
- this.snapshot = snapshot;
1708
- for (const listener of this.listeners) listener(snapshot);
2235
+ update(snapshot2) {
2236
+ this.snapshot = snapshot2;
2237
+ for (const listener of this.listeners) listener(snapshot2);
1709
2238
  }
1710
2239
  };
1711
2240
  function parseServerEnvelope(data) {
@@ -1732,6 +2261,20 @@ function isPostCommentsFetch(value) {
1732
2261
  const payload = value;
1733
2262
  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;
1734
2263
  }
2264
+ function isCommentPublish(value) {
2265
+ if (!value || typeof value !== "object") return false;
2266
+ const payload = value;
2267
+ const image = payload.image;
2268
+ if (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.content !== "string" || payload.content.length > COMMENT_TEXT_MAX_LENGTH || payload.skipVerification !== void 0 && typeof payload.skipVerification !== "boolean" || typeof payload.expiresAt !== "string" || !Number.isFinite(Date.parse(payload.expiresAt))) {
2269
+ return false;
2270
+ }
2271
+ const hasImage = image !== null && image !== void 0;
2272
+ if (!payload.content.trim() && !hasImage) return false;
2273
+ if (!hasImage) return true;
2274
+ if (typeof image !== "object" || Array.isArray(image)) return false;
2275
+ const asset = image;
2276
+ return typeof asset.id === "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(asset.id) && ["image/jpeg", "image/png", "image/webp"].includes(String(asset.contentType)) && typeof asset.byteSize === "number" && Number.isInteger(asset.byteSize) && asset.byteSize > 0 && asset.byteSize <= COMMENT_IMAGE_MAX_BYTES && typeof asset.sha256 === "string" && /^[0-9a-f]{64}$/i.test(asset.sha256);
2277
+ }
1735
2278
  function emptySnapshot(state) {
1736
2279
  return {
1737
2280
  state,
@@ -1761,6 +2304,184 @@ function readErrorCode(error) {
1761
2304
  return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN_ERROR";
1762
2305
  }
1763
2306
 
2307
+ // src/main/desktop-comment-publish-runner.ts
2308
+ var DEFAULT_REPORT_ATTEMPTS = 3;
2309
+ var DesktopCommentPublishRunner = class {
2310
+ options;
2311
+ listeners = /* @__PURE__ */ new Set();
2312
+ stopController = new AbortController();
2313
+ currentController = null;
2314
+ activeRequestId = null;
2315
+ finishedRequestIds = /* @__PURE__ */ new Set();
2316
+ active = null;
2317
+ unsubscribe = null;
2318
+ stopped = false;
2319
+ constructor(options) {
2320
+ this.options = options;
2321
+ }
2322
+ start() {
2323
+ if (this.unsubscribe) return;
2324
+ this.unsubscribe = this.options.session.subscribeJobs((event) => this.handleEvent(event));
2325
+ }
2326
+ stop() {
2327
+ this.stopped = true;
2328
+ this.currentController?.abort();
2329
+ this.stopController.abort();
2330
+ this.unsubscribe?.();
2331
+ this.unsubscribe = null;
2332
+ }
2333
+ isRunning() {
2334
+ return this.active !== null;
2335
+ }
2336
+ subscribe(listener) {
2337
+ this.listeners.add(listener);
2338
+ return () => this.listeners.delete(listener);
2339
+ }
2340
+ async whenIdle() {
2341
+ await this.active;
2342
+ }
2343
+ handleEvent(event) {
2344
+ if (event.type !== "COMMENT_PUBLISH" || this.stopped) return;
2345
+ if (this.activeRequestId === event.requestId || this.finishedRequestIds.has(event.requestId)) return;
2346
+ if (this.active) {
2347
+ void this.reportOnly(event, "AGENT_BUSY", "FAILED");
2348
+ return;
2349
+ }
2350
+ this.active = this.run(event).finally(() => {
2351
+ this.active = null;
2352
+ });
2353
+ }
2354
+ async run(request) {
2355
+ const controller = new AbortController();
2356
+ const timeout = this.options.setTimer(
2357
+ () => controller.abort(),
2358
+ this.options.publishTimeoutMs ?? 3 * 60 * 1e3
2359
+ );
2360
+ this.currentController = controller;
2361
+ this.activeRequestId = request.requestId;
2362
+ this.emit({ type: "STARTED", requestId: request.requestId, postUrl: request.postUrl });
2363
+ let granted = false;
2364
+ try {
2365
+ if (this.options.isScanActive()) {
2366
+ await this.reportOnly(request, "SCANNER_BUSY", "FAILED");
2367
+ return;
2368
+ }
2369
+ if (this.options.isCommentFetchActive()) {
2370
+ await this.reportOnly(request, "COMMENT_FETCH_ACTIVE", "FAILED");
2371
+ return;
2372
+ }
2373
+ if (this.options.isLoginActive()) {
2374
+ await this.reportOnly(request, "LOGIN_ACTIVE", "FAILED");
2375
+ return;
2376
+ }
2377
+ let image;
2378
+ if (request.image) {
2379
+ try {
2380
+ image = await this.options.http.downloadCommentImage(request.requestId, request.image, controller.signal);
2381
+ } catch {
2382
+ await this.reportOnly(request, "IMAGE_DOWNLOAD_FAILED", "FAILED");
2383
+ return;
2384
+ }
2385
+ }
2386
+ const storageState = await this.options.facebookState.load();
2387
+ if (!storageState) {
2388
+ await this.reportOnly(request, "FACEBOOK_SESSION_MISSING", "FAILED");
2389
+ return;
2390
+ }
2391
+ let result;
2392
+ try {
2393
+ result = await this.options.scanner.publishComment({
2394
+ onDiagnostic: (diagnostic) => this.emit({ type: "DIAGNOSTIC", requestId: request.requestId, diagnostic }),
2395
+ beforeSubmit: async () => {
2396
+ const started = await this.options.http.startCommentPublish(request.requestId, controller.signal);
2397
+ granted = started.allowed;
2398
+ return started.allowed;
2399
+ },
2400
+ content: request.content,
2401
+ skipVerification: request.skipVerification,
2402
+ ...image && request.image ? { image: { buffer: image, contentType: request.image.contentType } } : {},
2403
+ postUrl: request.postUrl,
2404
+ signal: controller.signal,
2405
+ storageState
2406
+ });
2407
+ } catch {
2408
+ await this.reportOnly(
2409
+ request,
2410
+ granted ? "SUBMIT_UNCONFIRMED" : "PUBLISH_START_FAILED",
2411
+ granted ? "UNKNOWN" : "FAILED"
2412
+ );
2413
+ return;
2414
+ }
2415
+ await this.report(request, {
2416
+ ...result.commentUrl ? { commentUrl: result.commentUrl } : {},
2417
+ ...result.errorCode ? { errorCode: result.errorCode } : {},
2418
+ ...result.facebookCommentId ? { facebookCommentId: result.facebookCommentId } : {},
2419
+ outcome: result.outcome,
2420
+ submitAttempted: result.submitAttempted
2421
+ });
2422
+ } finally {
2423
+ this.options.clearTimer(timeout);
2424
+ if (this.currentController === controller) this.currentController = null;
2425
+ this.activeRequestId = null;
2426
+ this.finishedRequestIds.add(request.requestId);
2427
+ if (this.finishedRequestIds.size > 100) {
2428
+ const oldest = this.finishedRequestIds.values().next().value;
2429
+ if (oldest) this.finishedRequestIds.delete(oldest);
2430
+ }
2431
+ }
2432
+ }
2433
+ async reportOnly(request, errorCode, outcome) {
2434
+ await this.report(request, { errorCode, outcome, submitAttempted: outcome === "UNKNOWN" });
2435
+ }
2436
+ async report(request, result) {
2437
+ const attempts = Math.max(1, this.options.reportAttempts ?? DEFAULT_REPORT_ATTEMPTS);
2438
+ let delivered = false;
2439
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
2440
+ try {
2441
+ await this.options.http.reportCommentPublish(request.requestId, result);
2442
+ delivered = true;
2443
+ break;
2444
+ } catch {
2445
+ if (attempt === attempts || this.stopController.signal.aborted) break;
2446
+ try {
2447
+ await this.options.wait(1e3 * 2 ** (attempt - 1), this.stopController.signal);
2448
+ } catch {
2449
+ break;
2450
+ }
2451
+ }
2452
+ }
2453
+ if (!delivered) {
2454
+ this.emit({
2455
+ type: "REPORT_FAILED",
2456
+ errorCode: "RESULT_REPORT_FAILED",
2457
+ requestId: request.requestId,
2458
+ postUrl: request.postUrl
2459
+ });
2460
+ } else {
2461
+ if (result.outcome === "SUCCEEDED") {
2462
+ this.emit({ type: "SUCCEEDED", requestId: request.requestId, postUrl: request.postUrl });
2463
+ } else if (result.outcome === "UNKNOWN") {
2464
+ this.emit({
2465
+ type: "UNKNOWN",
2466
+ errorCode: result.errorCode ?? "UNKNOWN",
2467
+ requestId: request.requestId,
2468
+ postUrl: request.postUrl
2469
+ });
2470
+ } else {
2471
+ this.emit({
2472
+ type: "FAILED",
2473
+ errorCode: result.errorCode ?? "FAILED",
2474
+ requestId: request.requestId,
2475
+ postUrl: request.postUrl
2476
+ });
2477
+ }
2478
+ }
2479
+ }
2480
+ emit(event) {
2481
+ for (const listener of this.listeners) listener(event);
2482
+ }
2483
+ };
2484
+
1764
2485
  // src/main/desktop-comment-runner.ts
1765
2486
  var DEFAULT_FETCH_TIMEOUT_MS = 2 * 60 * 1e3;
1766
2487
  var DEFAULT_MAX_QUEUE = 5;
@@ -1958,7 +2679,60 @@ var DesktopScanHttp = class {
1958
2679
  signal
1959
2680
  );
1960
2681
  }
2682
+ startCommentPublish(requestId, signal) {
2683
+ return this.signedPost(
2684
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/start`,
2685
+ {},
2686
+ signal
2687
+ );
2688
+ }
2689
+ reportCommentPublish(requestId, result, signal) {
2690
+ return this.signedPost(
2691
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/result`,
2692
+ result,
2693
+ signal
2694
+ );
2695
+ }
2696
+ async downloadCommentImage(requestId, metadata, signal) {
2697
+ const response = await this.signedRequest(
2698
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/image`,
2699
+ {},
2700
+ signal,
2701
+ "image"
2702
+ );
2703
+ const lengthHeader = response.headers.get("content-length");
2704
+ if (lengthHeader !== null) {
2705
+ const length = Number(lengthHeader);
2706
+ if (!Number.isFinite(length) || length > COMMENT_IMAGE_MAX_BYTES || length !== metadata.byteSize) {
2707
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2708
+ }
2709
+ }
2710
+ if (!response.body) throw new DesktopScanHttpError("IMAGE_INVALID");
2711
+ const reader = response.body.getReader();
2712
+ const chunks = [];
2713
+ let size = 0;
2714
+ for (; ; ) {
2715
+ const { done, value } = await reader.read();
2716
+ if (done) break;
2717
+ size += value.byteLength;
2718
+ if (size > COMMENT_IMAGE_MAX_BYTES || size > metadata.byteSize) {
2719
+ await reader.cancel();
2720
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2721
+ }
2722
+ chunks.push(value);
2723
+ }
2724
+ const bytes = Buffer.concat(chunks, size);
2725
+ if (bytes.byteLength !== metadata.byteSize) throw new DesktopScanHttpError("IMAGE_INVALID");
2726
+ if (createHash("sha256").update(bytes).digest("hex") !== metadata.sha256) {
2727
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2728
+ }
2729
+ return bytes;
2730
+ }
1961
2731
  async signedPost(path, businessPayload, signal) {
2732
+ const response = await this.signedRequest(path, businessPayload, signal);
2733
+ return readBoundedJson2(response);
2734
+ }
2735
+ async signedRequest(path, businessPayload, signal, responseType = "json") {
1962
2736
  const credential = this.getCredential();
1963
2737
  if (!credential) throw new DesktopScanHttpError("CREDENTIAL_UNAVAILABLE");
1964
2738
  const signedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1992,21 +2766,26 @@ var DesktopScanHttp = class {
1992
2766
  "content-type": "application/json"
1993
2767
  },
1994
2768
  method: "POST",
2769
+ redirect: "error",
1995
2770
  signal: requestSignal
1996
2771
  });
1997
2772
  } catch {
1998
2773
  throw new DesktopScanHttpError("NETWORK_ERROR", true);
1999
2774
  }
2000
- const contentType = response.headers.get("content-type")?.split(";", 1)[0];
2001
- if (contentType !== "application/json") throw new DesktopScanHttpError("RESPONSE_INVALID");
2002
- const value = await readBoundedJson2(response);
2003
2775
  if (!response.ok) {
2004
2776
  throw new DesktopScanHttpError(
2005
2777
  response.status >= 500 ? "SERVER_UNAVAILABLE" : response.status === 413 ? "BATCH_TOO_LARGE" : "BATCH_REJECTED",
2006
2778
  response.status >= 500
2007
2779
  );
2008
2780
  }
2009
- return value;
2781
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "";
2782
+ if (responseType === "json" && contentType !== "application/json") {
2783
+ throw new DesktopScanHttpError("RESPONSE_INVALID");
2784
+ }
2785
+ if (responseType === "image" && !contentType.startsWith("image/")) {
2786
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2787
+ }
2788
+ return response;
2010
2789
  }
2011
2790
  };
2012
2791
  async function readBoundedJson2(response) {
@@ -2223,7 +3002,7 @@ var DesktopScanRunner = class {
2223
3002
  return true;
2224
3003
  }
2225
3004
  async handleEvent(event) {
2226
- if (event.type === "COMMENTS") return;
3005
+ if (event.type === "COMMENTS" || event.type === "COMMENT_PUBLISH") return;
2227
3006
  if (event.type === "AVAILABLE") {
2228
3007
  await this.claimIfReady(event.jobId);
2229
3008
  return;
@@ -2542,9 +3321,9 @@ var DesktopScanRunner = class {
2542
3321
  leaseExpiresAt(job) {
2543
3322
  return this.activeAttempt?.jobId === job.jobId && this.activeAttempt.attemptToken === job.attemptToken ? this.activeAttempt.leaseExpiresAt : job.leaseExpiresAt;
2544
3323
  }
2545
- update(snapshot) {
2546
- this.snapshot = snapshot;
2547
- for (const listener of this.listeners) listener(snapshot);
3324
+ update(snapshot2) {
3325
+ this.snapshot = snapshot2;
3326
+ for (const listener of this.listeners) listener(snapshot2);
2548
3327
  }
2549
3328
  };
2550
3329
  function toDesktopPost(item) {
@@ -2870,7 +3649,7 @@ var FacebookLoginService = class {
2870
3649
  await this.finish({ state: "READY", errorCode: null, accountId });
2871
3650
  return true;
2872
3651
  }
2873
- async finish(snapshot) {
3652
+ async finish(snapshot2) {
2874
3653
  if (this.timer) this.options.clearTimer(this.timer);
2875
3654
  this.timer = null;
2876
3655
  const page = this.page;
@@ -2882,12 +3661,12 @@ var FacebookLoginService = class {
2882
3661
  page?.off("framenavigated", this.navigationHandler);
2883
3662
  await context?.close();
2884
3663
  await browser?.close();
2885
- this.update(snapshot);
3664
+ this.update(snapshot2);
2886
3665
  }
2887
- update(snapshot) {
2888
- if (snapshot.state === this.snapshot.state && snapshot.errorCode === this.snapshot.errorCode && snapshot.accountId === this.snapshot.accountId) return;
2889
- this.snapshot = snapshot;
2890
- for (const listener of this.listeners) listener(snapshot);
3666
+ update(snapshot2) {
3667
+ if (snapshot2.state === this.snapshot.state && snapshot2.errorCode === this.snapshot.errorCode && snapshot2.accountId === this.snapshot.accountId) return;
3668
+ this.snapshot = snapshot2;
3669
+ for (const listener of this.listeners) listener(snapshot2);
2891
3670
  }
2892
3671
  serialize(operation) {
2893
3672
  const result = this.pending.catch(() => void 0).then(operation);
@@ -2943,6 +3722,41 @@ function getFacebookAccountId(state) {
2943
3722
  return value && /^\d{1,32}$/.test(value) ? value : null;
2944
3723
  }
2945
3724
 
3725
+ // src/shared/resource-metrics.ts
3726
+ import os from "node:os";
3727
+ function snapshot(cpus) {
3728
+ let idle = 0;
3729
+ let total = 0;
3730
+ for (const cpu of cpus) {
3731
+ const times = cpu.times;
3732
+ idle += times.idle;
3733
+ total += times.user + times.nice + times.sys + times.idle + times.irq;
3734
+ }
3735
+ return { idle, total };
3736
+ }
3737
+ function usageBetween(previous, current) {
3738
+ const totalDelta = current.total - previous.total;
3739
+ const idleDelta = current.idle - previous.idle;
3740
+ if (totalDelta <= 0) return 0;
3741
+ return Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100));
3742
+ }
3743
+ function createMetricsCollector() {
3744
+ let previous = null;
3745
+ return () => {
3746
+ const cpus = os.cpus();
3747
+ const current = snapshot(cpus);
3748
+ const cpuPercent = previous ? usageBetween(previous, current) : usageBetween({ idle: 0, total: 0 }, current);
3749
+ previous = current;
3750
+ const memTotalBytes = os.totalmem();
3751
+ return {
3752
+ cpuCount: cpus.length,
3753
+ cpuPercent: Math.round(cpuPercent * 10) / 10,
3754
+ memTotalBytes,
3755
+ memUsedBytes: Math.max(0, memTotalBytes - os.freemem())
3756
+ };
3757
+ };
3758
+ }
3759
+
2946
3760
  // src/main/graphql-dump.ts
2947
3761
  import { writeFile as writeFile4 } from "node:fs/promises";
2948
3762
  import { join as join3 } from "node:path";
@@ -3047,90 +3861,6 @@ function browserInstallCommands(platform) {
3047
3861
  return platform === "linux" ? [["install", "--with-deps", "chromium"], install] : [install];
3048
3862
  }
3049
3863
 
3050
- // src/cli/cookie-import.ts
3051
- var FACEBOOK_DOMAIN = ".facebook.com";
3052
- var FACEBOOK_HOST = /(^|\.)facebook\.com$/i;
3053
- function isFacebookDomain(domain) {
3054
- return Boolean(domain && FACEBOOK_HOST.test(domain.replace(/^\./, "")));
3055
- }
3056
- function normalizeSameSite(value) {
3057
- if (typeof value !== "string") return void 0;
3058
- const lower = value.toLowerCase().replace(/[^a-z]/g, "");
3059
- if (lower === "lax") return "Lax";
3060
- if (lower === "strict") return "Strict";
3061
- if (lower === "none" || lower === "norestriction") return "None";
3062
- return void 0;
3063
- }
3064
- function toCookie(value) {
3065
- const name = typeof value.name === "string" ? value.name.trim() : "";
3066
- const cookieValue = typeof value.value === "string" ? value.value : "";
3067
- if (!name || !cookieValue) return null;
3068
- return {
3069
- domain: typeof value.domain === "string" ? value.domain : void 0,
3070
- expires: typeof value.expires === "number" ? value.expires : typeof value.expirationDate === "number" ? value.expirationDate : void 0,
3071
- httpOnly: typeof value.httpOnly === "boolean" ? value.httpOnly : void 0,
3072
- name,
3073
- path: typeof value.path === "string" ? value.path : void 0,
3074
- sameSite: normalizeSameSite(value.sameSite),
3075
- secure: typeof value.secure === "boolean" ? value.secure : void 0,
3076
- value: cookieValue
3077
- };
3078
- }
3079
- function parseCookieHeader(input) {
3080
- const header = input.replace(/^\s*cookie\s*:/i, "");
3081
- return header.split(/[;\n]/).map((part) => part.trim()).filter(Boolean).map((part) => {
3082
- const separator = part.indexOf("=");
3083
- if (separator <= 0) return null;
3084
- return { name: part.slice(0, separator).trim(), value: part.slice(separator + 1) };
3085
- }).filter((cookie) => cookie !== null);
3086
- }
3087
- function parseCookieTable(input) {
3088
- const cookies = [];
3089
- for (const line of input.split("\n")) {
3090
- const columns = line.split(" ");
3091
- const name = columns[0]?.trim() ?? "";
3092
- const value = columns[1];
3093
- if (!value || !name || name === "Name") continue;
3094
- cookies.push({ domain: columns[2]?.trim(), name, value });
3095
- }
3096
- return cookies;
3097
- }
3098
- function parseCookieInput(input) {
3099
- const trimmed = input.trim();
3100
- if (!trimmed) return [];
3101
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
3102
- return trimmed.includes(" ") ? parseCookieTable(trimmed) : parseCookieHeader(trimmed);
3103
- }
3104
- try {
3105
- const parsed = JSON.parse(trimmed);
3106
- if (Array.isArray(parsed)) {
3107
- return parsed.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
3108
- }
3109
- if (parsed && typeof parsed === "object") {
3110
- const cookies = parsed.cookies;
3111
- if (Array.isArray(cookies)) {
3112
- return cookies.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
3113
- }
3114
- }
3115
- } catch {
3116
- return [];
3117
- }
3118
- return [];
3119
- }
3120
- function toFacebookStorageState(cookies) {
3121
- const usable = cookies.filter((cookie) => isFacebookDomain(cookie.domain) || cookie.domain === void 0).map((cookie) => ({
3122
- domain: isFacebookDomain(cookie.domain) ? cookie.domain : FACEBOOK_DOMAIN,
3123
- expires: typeof cookie.expires === "number" && cookie.expires > 0 ? cookie.expires : -1,
3124
- httpOnly: cookie.httpOnly ?? true,
3125
- name: cookie.name,
3126
- path: cookie.path?.startsWith("/") ? cookie.path : "/",
3127
- sameSite: cookie.sameSite ?? "Lax",
3128
- secure: cookie.secure ?? true,
3129
- value: cookie.value
3130
- }));
3131
- return { cookies: usable, origins: [] };
3132
- }
3133
-
3134
3864
  // src/cli/file-crypto.ts
3135
3865
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
3136
3866
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -3184,15 +3914,15 @@ var SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
3184
3914
  function quote(value) {
3185
3915
  return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
3186
3916
  }
3187
- function buildSystemdUnit(options) {
3917
+ function buildServiceUnit(options) {
3188
3918
  return `[Unit]
3189
- Description=DiggyLab Desktop Agent
3919
+ Description=${options.description}
3190
3920
  After=network-online.target
3191
3921
  Wants=network-online.target
3192
3922
 
3193
3923
  [Service]
3194
3924
  ExecStart=${options.execStart.map(quote).join(" ")} run
3195
- Environment="DIGGYLAB_AGENT_DIR=${options.configDirectory}"
3925
+ Environment="${options.environmentName}=${options.configDirectory}"
3196
3926
  Environment="DIGGYLAB_API_URL=${options.apiOrigin}"
3197
3927
  Restart=on-failure
3198
3928
  RestartSec=10
@@ -3201,6 +3931,13 @@ RestartSec=10
3201
3931
  WantedBy=multi-user.target
3202
3932
  `;
3203
3933
  }
3934
+ function buildSystemdUnit(options) {
3935
+ return buildServiceUnit({
3936
+ ...options,
3937
+ description: "DiggyLab Desktop Agent",
3938
+ environmentName: "DIGGYLAB_AGENT_DIR"
3939
+ });
3940
+ }
3204
3941
 
3205
3942
  // src/cli/cli.ts
3206
3943
  var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
@@ -3289,17 +4026,17 @@ function waitForFacebookLogin(login, timeoutMs) {
3289
4026
  reject(new Error("Timed out waiting for the Facebook login."));
3290
4027
  }, timeoutMs);
3291
4028
  timer.unref();
3292
- const unsubscribe = login.subscribe((snapshot) => {
3293
- if (snapshot.state === "READY") {
4029
+ const unsubscribe = login.subscribe((snapshot2) => {
4030
+ if (snapshot2.state === "READY") {
3294
4031
  clearTimeout(timer);
3295
4032
  unsubscribe();
3296
4033
  resolve();
3297
4034
  return;
3298
4035
  }
3299
- if (snapshot.state === "NOT_READY" && snapshot.errorCode) {
4036
+ if (snapshot2.state === "NOT_READY" && snapshot2.errorCode) {
3300
4037
  clearTimeout(timer);
3301
4038
  unsubscribe();
3302
- reject(new Error(`Facebook login failed (${snapshot.errorCode}).`));
4039
+ reject(new Error(`Facebook login failed (${snapshot2.errorCode}).`));
3303
4040
  }
3304
4041
  });
3305
4042
  void login.open().catch((error) => {
@@ -3609,6 +4346,8 @@ async function commandRun(runtime, io) {
3609
4346
  const facebookReady = await runtime.facebook.isReady();
3610
4347
  const session = new DesktopAgentSession({
3611
4348
  agentVersion: agentVersion(),
4349
+ clientType: "CLI",
4350
+ metrics: createMetricsCollector(),
3612
4351
  clearTimer: (timer) => clearTimeout(timer),
3613
4352
  createSocket: (url, accessToken) => {
3614
4353
  const socket = new WebSocket(url, {
@@ -3645,6 +4384,7 @@ async function commandRun(runtime, io) {
3645
4384
  browserType: runtime.browserType,
3646
4385
  parseGraphqlBody: createGraphqlDumpParser(process.env.FACEBOOK_SCAN_DUMP_DIR)
3647
4386
  });
4387
+ let publishRunner = null;
3648
4388
  const commentRunner = new DesktopCommentRunner({
3649
4389
  clearTimer: (timer) => clearTimeout(timer),
3650
4390
  facebookState: runtime.facebook,
@@ -3664,7 +4404,7 @@ async function commandRun(runtime, io) {
3664
4404
  clearTimer: (timer) => clearTimeout(timer),
3665
4405
  facebookState: runtime.facebook,
3666
4406
  http: scanHttp,
3667
- isCommentFetchActive: () => commentRunner.isRunning(),
4407
+ isCommentFetchActive: () => commentRunner.isRunning() || (publishRunner?.isRunning() ?? false),
3668
4408
  isLoginActive: () => false,
3669
4409
  now: Date.now,
3670
4410
  randomUuid: () => randomUUID3(),
@@ -3681,25 +4421,42 @@ async function commandRun(runtime, io) {
3681
4421
  spool: runtime.spool,
3682
4422
  wait: abortableDelay
3683
4423
  });
4424
+ publishRunner = new DesktopCommentPublishRunner({
4425
+ clearTimer: (timer) => clearTimeout(timer),
4426
+ facebookState: runtime.facebook,
4427
+ http: scanHttp,
4428
+ isCommentFetchActive: () => commentRunner.isRunning(),
4429
+ isLoginActive: () => false,
4430
+ isScanActive: () => scanRunner.isRunning(),
4431
+ scanner,
4432
+ session,
4433
+ setTimer: (callback, delay) => {
4434
+ const timer = setTimeout(callback, delay);
4435
+ timer.unref();
4436
+ return timer;
4437
+ },
4438
+ wait: abortableDelay
4439
+ });
3684
4440
  let onReplaced = () => void 0;
3685
4441
  const replaced = new Promise((resolve) => {
3686
4442
  onReplaced = resolve;
3687
4443
  });
3688
- session.subscribe((snapshot) => {
3689
- io.log(`${stamp()} session: ${snapshot.state}${snapshot.errorCode ? ` (${snapshot.errorCode})` : ""}`);
3690
- recordActivity(`Session state changed to ${snapshot.state}.`, snapshot.state);
3691
- if (snapshot.errorCode === "CONNECTION_REPLACED") {
4444
+ session.subscribe((snapshot2) => {
4445
+ io.log(`${stamp()} session: ${snapshot2.state}${snapshot2.errorCode ? ` (${snapshot2.errorCode})` : ""}`);
4446
+ recordActivity(`Session state changed to ${snapshot2.state}.`, snapshot2.state);
4447
+ if (snapshot2.errorCode === "CONNECTION_REPLACED") {
3692
4448
  io.error("Another worker is already running with this device, so this one stopped.");
3693
4449
  io.error("Stop the other worker (for example: systemctl stop diggylab), then start this one again.");
3694
4450
  onReplaced();
3695
4451
  }
3696
4452
  });
3697
- scanRunner.subscribe((snapshot) => {
3698
- io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
3699
- recordActivity(`Scanner state changed to ${snapshot.state}.`, snapshot.state);
4453
+ scanRunner.subscribe((snapshot2) => {
4454
+ io.log(`scanner: ${snapshot2.state}${snapshot2.jobId ? ` (job ${snapshot2.jobId})` : ""}`);
4455
+ recordActivity(`Scanner state changed to ${snapshot2.state}.`, snapshot2.state);
3700
4456
  });
3701
4457
  scanRunner.start();
3702
4458
  commentRunner.start();
4459
+ publishRunner.start();
3703
4460
  const unsubscribeComments = commentRunner.subscribe((event) => {
3704
4461
  if (event.type === "STARTED") {
3705
4462
  io.log(`${stamp()} comments: fetching ${event.postUrl}`);
@@ -3723,6 +4480,34 @@ async function commandRun(runtime, io) {
3723
4480
  level: "WARN"
3724
4481
  });
3725
4482
  });
4483
+ const unsubscribePublish = publishRunner.subscribe((event) => {
4484
+ if (event.type === "DIAGNOSTIC") {
4485
+ const message = JSON.stringify({ requestId: event.requestId, ...event.diagnostic });
4486
+ io.log(`${stamp()} comments: diagnostic ${message}`);
4487
+ recordActivity(message, "COMMENT_PUBLISH_DIAGNOSTIC", { category: "COMMENTS" });
4488
+ return;
4489
+ }
4490
+ if (event.type === "STARTED") {
4491
+ io.log(`${stamp()} comments: publishing on ${event.postUrl}`);
4492
+ recordActivity(`Publishing a comment on ${event.postUrl}.`, "COMMENT_PUBLISH_STARTED", {
4493
+ category: "COMMENTS"
4494
+ });
4495
+ return;
4496
+ }
4497
+ if (event.type === "SUCCEEDED") {
4498
+ io.log(`${stamp()} comments: published`);
4499
+ recordActivity(`Published a comment on ${event.postUrl}.`, "COMMENT_PUBLISH_SUCCEEDED", {
4500
+ category: "COMMENTS"
4501
+ });
4502
+ return;
4503
+ }
4504
+ io.log(`${stamp()} comments: publish ${event.type.toLowerCase()} (${event.errorCode})`);
4505
+ recordActivity(
4506
+ `Comment publish ${event.type.toLowerCase()} (${event.errorCode}) on ${event.postUrl}.`,
4507
+ event.errorCode,
4508
+ { category: "COMMENTS", level: "WARN" }
4509
+ );
4510
+ });
3726
4511
  await scanRunner.recover();
3727
4512
  await session.start();
3728
4513
  io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
@@ -3739,7 +4524,9 @@ async function commandRun(runtime, io) {
3739
4524
  clearInterval(keepAlive);
3740
4525
  session.stop();
3741
4526
  commentRunner.stop();
4527
+ publishRunner.stop();
3742
4528
  unsubscribeComments();
4529
+ unsubscribePublish();
3743
4530
  scanRunner.stop();
3744
4531
  }
3745
4532
  return 0;