channel-worker 2.5.112 → 2.5.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api-client.js CHANGED
@@ -75,7 +75,7 @@ class ApiClient {
75
75
  // nào — xảy ra 14/09 với verify_facebook_reels_pw (cổng thứ nhất là enum
76
76
  // của app/models/WorkerCommand.js, cũng đã quên một lần cùng ngày).
77
77
  // api/tests/publish-verify-wiring.test.js canh cả hai cổng.
78
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
78
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
79
79
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
80
  }
81
81
 
@@ -1,4 +1,5 @@
1
1
  const { NstManager } = require('./nst-manager');
2
+ const { refreshPinnedAgentAddress } = require('./nst-agent-locator');
2
3
  const { checkAndUpdateExtension } = require('./extension-updater');
3
4
  const { StatsSyncer } = require('./stats-syncer');
4
5
 
@@ -7,6 +8,12 @@ const { StatsSyncer } = require('./stats-syncer');
7
8
  // tới và dùng lại browser, nhưng không được để mở qua đêm.
8
9
  const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
9
10
 
11
+ // Người bấm "Mở trình duyệt" trên web → profile đó là của NGƯỜI trong quãng
12
+ // này, automation không được đóng ngang. Trước 16/09/2026 không có hàng rào
13
+ // nào: hẹn đóng 300s của lượt đăng bài trước đó vẫn chạy và đóng profile ngay
14
+ // trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
15
+ const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
16
+
10
17
  class CommandPoller {
11
18
  constructor(api, config) {
12
19
  this.api = api;
@@ -538,6 +545,8 @@ class CommandPoller {
538
545
  if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
539
546
  const candidates = [...this._keptOpenProfiles.entries()]
540
547
  .filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
548
+ // Profile người vừa mở tay không phải ứng viên để nhường suất.
549
+ .filter(([id]) => this._humanHoldLeft(id) <= 0)
541
550
  .sort((a, b) => a[1] - b[1]);
542
551
  let over = this._keptOpenProfiles.size - max;
543
552
  for (const [id] of candidates) {
@@ -558,7 +567,7 @@ class CommandPoller {
558
567
  // Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
559
568
  // hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
560
569
  // tiếp vẫn dùng chung một browser như thiết kế cũ.
561
- _schedulePwClose(profileId, scriptName) {
570
+ _schedulePwClose(profileId, scriptName, delayMs = PW_IDLE_CLOSE_MS) {
562
571
  if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
563
572
  const prev = this._pwCloseTimers.get(profileId);
564
573
  if (prev) clearTimeout(prev);
@@ -566,13 +575,22 @@ class CommandPoller {
566
575
  this._pwCloseTimers.delete(profileId);
567
576
  // Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
568
577
  if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
578
+ // Người vừa mở profile này bằng tay → hoãn tới khi hết giữ chỗ. Đóng ở
579
+ // đây là đóng ngay trước mũi người đang ngồi trong browser.
580
+ const hold = this._humanHoldLeft(profileId);
581
+ if (hold > 0) {
582
+ console.log(`[commands/pw] hoãn đóng ${profileId} — người đang dùng, `
583
+ + `còn ${Math.ceil(hold / 1000)}s`);
584
+ this._schedulePwClose(profileId, scriptName, hold + 1000);
585
+ return;
586
+ }
569
587
  try {
570
588
  await this.nst.stopProfile(profileId);
571
- console.log(`[commands/pw] đóng profile ${profileId} sau ${PW_IDLE_CLOSE_MS / 1000}s rảnh (${scriptName})`);
589
+ console.log(`[commands/pw] đóng profile ${profileId} sau ${delayMs / 1000}s rảnh (${scriptName})`);
572
590
  } catch (e) {
573
591
  console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
574
592
  }
575
- }, PW_IDLE_CLOSE_MS);
593
+ }, delayMs);
576
594
  // Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
577
595
  if (typeof t.unref === 'function') t.unref();
578
596
  this._pwCloseTimers.set(profileId, t);
@@ -640,12 +658,43 @@ class CommandPoller {
640
658
  }
641
659
  }
642
660
 
643
- const result = await this.nst.launchProfile(profile_id, { proxy, extensionPath });
661
+ const result = await this._launchWithAgentRetry(profile_id, { proxy, extensionPath });
662
+
663
+ // Đang mở sẵn: KHÔNG có cửa sổ mới nào bật lên. Trên máy đang có hơn chục
664
+ // browser thì với người bấm, cú bấm coi như rơi vào hư không. Đẩy cửa sổ
665
+ // sẵn có lên trước và giữ nó lại cho người dùng.
666
+ if (result.alreadyRunning) {
667
+ this._cancelPwClose(profile_id);
668
+ this._holdProfileForHuman(profile_id);
669
+ const fronted = await this.nst.bringProfileToFront(profile_id);
670
+ console.log(`[commands] Profile ${profile_id} đang mở sẵn — `
671
+ + `${fronted ? 'đã đưa cửa sổ lên trước' : 'KHÔNG đưa được cửa sổ lên trước'}, `
672
+ + `giữ cho người dùng ${Math.round(HUMAN_HOLD_MS / 60000)} phút`);
673
+ await this.api.updateCommand(command._id, {
674
+ status: 'done',
675
+ result: {
676
+ profile_id: result.profileId,
677
+ already_running: true,
678
+ brought_to_front: fronted,
679
+ launched_at: new Date().toISOString(),
680
+ message: fronted
681
+ ? 'Profile đã mở sẵn — đã đưa cửa sổ lên trước trên máy chạy.'
682
+ : 'Profile đã mở sẵn trên máy chạy, nhưng không đưa được cửa sổ lên trước — tìm trong đám cửa sổ đang mở.',
683
+ },
684
+ });
685
+ return;
686
+ }
687
+
644
688
  console.log(`[commands] Profile ${profile_id} launched${extensionPath ? ' (ext)' : ''}`);
689
+ this._holdProfileForHuman(profile_id);
645
690
 
646
691
  await this.api.updateCommand(command._id, {
647
692
  status: 'done',
648
- result: { profile_id: result.profileId, launched_at: new Date().toISOString() },
693
+ result: {
694
+ profile_id: result.profileId,
695
+ launched_at: new Date().toISOString(),
696
+ message: 'Đã mở trình duyệt trên máy chạy.',
697
+ },
649
698
  });
650
699
  } catch (err) {
651
700
  console.error(`[commands] Failed to launch profile: ${err.message}`);
@@ -656,6 +705,60 @@ class CommandPoller {
656
705
  }
657
706
  }
658
707
 
708
+ /**
709
+ * Mở profile, thử lại khi agent NST chưa dò ra.
710
+ *
711
+ * Agent đổi cổng mỗi lần Nstbrowser khởi động lại (chủ tịch tắt hết profile =
712
+ * app restart), và nó cần vài giây để nghe cổng mới. Không có vòng thử lại
713
+ * này thì cú bấm rơi đúng khoảng đó chết hẳn, người bấm phải tự đoán mà bấm
714
+ * lại — đúng cảnh 16/09/2026: lệnh 11:50:48 chết, lệnh 11:51:34 chạy ngon.
715
+ * Các lỗi khác (hết slot NST, profile mở ở phiên khác) KHÔNG thử lại: thử lại
716
+ * cũng vậy, mà còn giấu mất lỗi thật.
717
+ */
718
+ async _launchWithAgentRetry(profileId, options, { attempts = 3, waitMs = 3000 } = {}) {
719
+ let lastErr = null;
720
+ for (let i = 1; i <= attempts; i++) {
721
+ try {
722
+ return await this.nst.launchProfile(profileId, options);
723
+ } catch (err) {
724
+ lastErr = err;
725
+ if (!/NST_AGENT_UNRESOLVED/.test(err.message || '')) throw err;
726
+ if (i === attempts) break;
727
+ console.warn(`[commands] agent NST chưa sẵn sàng (lần ${i}/${attempts}) — `
728
+ + `dò lại sau ${waitMs / 1000}s`);
729
+ await new Promise(r => setTimeout(r, waitMs));
730
+ await refreshPinnedAgentAddress({ minIntervalMs: 0 });
731
+ }
732
+ }
733
+ throw lastErr;
734
+ }
735
+
736
+ // ─── Giữ chỗ cho NGƯỜI ────────────────────────────────────────────────────
737
+ // Profile do người mở bằng tay không được để automation đóng ngang: lượt đăng
738
+ // bài trước đó đã hẹn đóng sau PW_IDLE_CLOSE_MS và cái hẹn đó không biết có
739
+ // người đang ngồi trong browser.
740
+ _cancelPwClose(profileId) {
741
+ if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
742
+ clearTimeout(this._pwCloseTimers.get(profileId));
743
+ this._pwCloseTimers.delete(profileId);
744
+ return true;
745
+ }
746
+ return false;
747
+ }
748
+
749
+ _holdProfileForHuman(profileId, ms = HUMAN_HOLD_MS) {
750
+ if (!this._humanHold) this._humanHold = new Map();
751
+ this._humanHold.set(profileId, Date.now() + ms);
752
+ }
753
+
754
+ /** Còn bao nhiêu ms giữ chỗ cho người (0 = hết/không có). */
755
+ _humanHoldLeft(profileId) {
756
+ if (!this._humanHold || !profileId) return 0;
757
+ const left = (this._humanHold.get(profileId) || 0) - Date.now();
758
+ if (left <= 0) { this._humanHold.delete(profileId); return 0; }
759
+ return left;
760
+ }
761
+
659
762
  async handleLaunchVeo3Profile(command) {
660
763
  const { nst_profile_id, name, veo3_worker_id, os, proxy } = command.payload || {};
661
764
  console.log(`[commands] Launching Veo3 profile: ${nst_profile_id} (${name}) os=${os || 'windows'}`);
@@ -2173,6 +2276,8 @@ class CommandPoller {
2173
2276
  // Queue holds work pinned to THIS profile → it's about to be claimed,
2174
2277
  // leave the browser open.
2175
2278
  if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
2279
+ // Người vừa mở profile này từ web → để yên cho tới hết giữ chỗ.
2280
+ if (this._humanHoldLeft(profileId) > 0 || this._humanHoldLeft(browser.name) > 0) continue;
2176
2281
  // Match by UUID or name (renderers use name as nst_profile_id)
2177
2282
  const lastActivity = this._profileLastActivity[profileId]
2178
2283
  || (name && this._profileLastActivity[name])
@@ -159,7 +159,58 @@ function listAgentsCached(ttlMs = 60000, now = Date.now()) {
159
159
  return agents;
160
160
  }
161
161
 
162
+ // Quên bản cache đi, lần dò sau đọc lại tiến trình thật. Gọi khi vừa biết
163
+ // địa chỉ cũ đã sai — cache 60s đang giữ đúng cái bản đồ lỗi thời đó.
164
+ function invalidateAgentCache() {
165
+ _agentCache = { at: 0, agents: [] };
166
+ }
167
+
168
+ /**
169
+ * Dò LẠI NGAY agent của user đang ghim, ghi thẳng vào NST_API_ADDRESS.
170
+ *
171
+ * Agent đổi cổng mỗi lần Nstbrowser khởi động lại (8848 ↔ 8849 theo thứ tự
172
+ * login). Watcher của daemon chỉ dò mỗi 60s, nên lệnh rơi vào khoảng trống đó
173
+ * chết oan bằng NST_AGENT_UNRESOLVED dù agent vẫn sống, chỉ nằm ở cổng khác.
174
+ * Đo thật trên win-worker 16/09/2026: chủ tịch tắt hết profile → agent nhảy
175
+ * 8849 → 8848; cú bấm "Mở trình duyệt" lúc 11:50:48 chết, cú 11:51:34 (sau khi
176
+ * watcher kịp chạy) mở được. Hàm này xoá hẳn khoảng trống đó.
177
+ *
178
+ * Fail-closed vẫn nguyên: dò không ra thì trả null và KHÔNG đặt địa chỉ nào —
179
+ * người gọi tiếp tục gặp NST_AGENT_UNRESOLVED thay vì rơi về :8848 của phiên
180
+ * Windows khác.
181
+ */
182
+ let _pinnedRefresh = null;
183
+ let _pinnedRefreshAt = 0;
184
+ async function refreshPinnedAgentAddress({ minIntervalMs = 2000, now = Date.now() } = {}) {
185
+ const user = process.env.NST_AGENT_PINNED_USER;
186
+ if (!user) return process.env.NST_API_ADDRESS || null;
187
+ // Nhiều lệnh cùng vấp một lúc (đăng bài + nút bấm) — chỉ dò một lần, cả đám
188
+ // chờ chung kết quả; tasklist + netstat là lệnh nặng, đừng nhân lên.
189
+ if (_pinnedRefresh) return _pinnedRefresh;
190
+ if (now - _pinnedRefreshAt < minIntervalMs) return process.env.NST_API_ADDRESS || null;
191
+ _pinnedRefresh = (async () => {
192
+ try {
193
+ invalidateAgentCache();
194
+ const found = await agentAddressForUser(user);
195
+ if (!found) return null;
196
+ if (process.env.NST_API_ADDRESS !== found.address) {
197
+ console.log(`[nst] agent của "${user}" dò lại tại chỗ: ${found.address} `
198
+ + `(pid ${found.pid}, session ${found.sessionId}/${found.sessionName})`);
199
+ }
200
+ process.env.NST_API_ADDRESS = found.address;
201
+ return found.address;
202
+ } catch {
203
+ return null;
204
+ } finally {
205
+ _pinnedRefreshAt = Date.now();
206
+ _pinnedRefresh = null;
207
+ }
208
+ })();
209
+ return _pinnedRefresh;
210
+ }
211
+
162
212
  module.exports = {
163
- listAgents, listAgentsCached, resolveAgentForUser, agentAddressForUser, probeIsNstAgent, lanAddress,
213
+ listAgents, listAgentsCached, invalidateAgentCache, resolveAgentForUser, agentAddressForUser,
214
+ refreshPinnedAgentAddress, probeIsNstAgent, lanAddress,
164
215
  parseTasklistCsv, parseListening,
165
216
  };
@@ -1,4 +1,4 @@
1
- const { listAgentsCached, lanAddress } = require('./nst-agent-locator');
1
+ const { listAgentsCached, lanAddress, refreshPinnedAgentAddress } = require('./nst-agent-locator');
2
2
 
3
3
  function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
4
4
  const raw = String(response?.msg || 'Failed to connect browser').trim();
@@ -51,7 +51,21 @@ class NstManager {
51
51
  return 'http://localhost:8848/api/v2';
52
52
  }
53
53
 
54
+ /**
55
+ * Có địa chỉ agent chưa? Chưa thì dò lại NGAY thay vì chờ watcher 60s.
56
+ *
57
+ * Không đặt bừa địa chỉ nào khi dò không ra: `baseUrl` vẫn ném
58
+ * NST_AGENT_UNRESOLVED như cũ (fail-closed — mở nhầm agent của phiên Windows
59
+ * khác = profile trắng = báo logout oan cả loạt kênh).
60
+ */
61
+ async ensureAgent() {
62
+ if (this._apiAddress || process.env.NST_API_ADDRESS) return true;
63
+ if (!process.env.NST_AGENT_PINNED_USER) return true;
64
+ return Boolean(await refreshPinnedAgentAddress());
65
+ }
66
+
54
67
  async api(path, options = {}) {
68
+ await this.ensureAgent();
55
69
  const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
56
70
  const res = await fetch(url, {
57
71
  ...options,
@@ -92,6 +106,7 @@ class NstManager {
92
106
  // Get all running browsers
93
107
  async getRunningBrowsers() {
94
108
  try {
109
+ await this.ensureAgent();
95
110
  const rawRes = await fetch(`${this.baseUrl}/browsers`, {
96
111
  headers: { 'x-api-key': this.apiKey },
97
112
  });
@@ -139,6 +154,34 @@ class NstManager {
139
154
  return { profileId, httpEndpoint, wsEndpoint, port };
140
155
  }
141
156
 
157
+ /**
158
+ * Đưa cửa sổ của một profile ĐANG MỞ lên trước mặt người dùng.
159
+ *
160
+ * Người bấm "Mở trình duyệt" mà profile đã mở sẵn (lượt đăng bài tự động mở
161
+ * trước đó) thì `launchProfile` bỏ qua, không có cửa sổ nào bật lên — trên máy
162
+ * đang có hơn chục browser thì coi như không có gì xảy ra (chủ tịch gặp
163
+ * 16/09/2026 lúc 11:42).
164
+ *
165
+ * Đi qua cổng debug của chính Chrome (`/json/activate/<targetId>` → CDP
166
+ * Target.activateTarget): KHÔNG tốn lượt mở NST và không cần thư viện CDP.
167
+ * → true nếu đã đẩy được cửa sổ lên trước.
168
+ */
169
+ async bringProfileToFront(profileIdOrName) {
170
+ const ep = await this.resolveRunningEndpoint(profileIdOrName);
171
+ if (!ep) return false;
172
+ try {
173
+ const res = await fetch(`${ep.httpEndpoint}/json/list`);
174
+ const targets = await res.json();
175
+ const page = (Array.isArray(targets) ? targets : []).find(t => t.type === 'page');
176
+ if (!page || !page.id) return false;
177
+ const act = await fetch(`${ep.httpEndpoint}/json/activate/${page.id}`);
178
+ return act.ok;
179
+ } catch (e) {
180
+ console.warn(`[nst] bringProfileToFront ${profileIdOrName}: ${e.message}`);
181
+ return false;
182
+ }
183
+ }
184
+
142
185
  // Check if profile is already running
143
186
  async isProfileRunning(profileId) {
144
187
  const running = await this.getRunningBrowsers();
@@ -232,6 +275,7 @@ class NstManager {
232
275
  if (!proxyUrl) return;
233
276
  console.log(`[nst] Setting proxy for ${profileId}: ${proxyUrl}`);
234
277
  try {
278
+ await this.ensureAgent();
235
279
  const res = await fetch(`${this.baseUrl}/profiles/${profileId}/proxy`, {
236
280
  method: 'PUT',
237
281
  headers: { 'Content-Type': 'application/json', 'x-api-key': this.apiKey },
@@ -293,6 +337,7 @@ class NstManager {
293
337
 
294
338
  // Launch browser — skip if already running, set proxy if provided
295
339
  async launchProfile(profileIdOrName, options = {}) {
340
+ await this.ensureAgent();
296
341
  let profileId = profileIdOrName;
297
342
  if (!this.isUUID(profileIdOrName)) {
298
343
  profileId = await this.ensureProfile(profileIdOrName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.112",
3
+ "version": "2.5.115",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -25,6 +25,25 @@
25
25
  const { assertAccountUsable, readPublishBlock } = require('./lib/fb-guard');
26
26
  const { humanClick, humanType, pause, randInt } = require('./lib/human');
27
27
 
28
+ // Chụp màn hình lúc trượt — mượn của upload_facebook, nhưng require MUỘN và có
29
+ // đường lùi.
30
+ //
31
+ // Vì sao không require ở đầu file: `lib/playwright-runner` chỉ xoá require.cache
32
+ // của ĐÚNG script được gọi. Chép nóng cả hai file vào máy thì bản
33
+ // comment_facebook mới được nạp lại ngay, còn upload_facebook có thể vẫn là bản
34
+ // CŨ còn nằm trong cache từ lượt đăng trước — bản cũ chưa export `dumpFailure`,
35
+ // require ở đầu file là cả script chết ngay dòng đầu. Chụp được ảnh thì tốt,
36
+ // không chụp được thì thôi, đừng vì nó mà hỏng cả lượt thả bình luận.
37
+ async function dumpFailure(page, tag, log) {
38
+ try {
39
+ const f = require('./upload_facebook').__testables?.dumpFailure;
40
+ if (typeof f === 'function') await f(page, tag, log);
41
+ else log('info', '[fb-cmt] bản upload_facebook đang nạp chưa có dumpFailure — bỏ qua bước chụp màn hình');
42
+ } catch (e) {
43
+ log('info', `[fb-cmt] không chụp được màn hình: ${String(e.message || e).slice(0, 80)}`);
44
+ }
45
+ }
46
+
28
47
  const NAV_TIMEOUT_MS = 45_000;
29
48
  // Bình luận hiện ra gần như tức thì, nhưng lượt đầu sau khi mở bài thì Facebook
30
49
  // còn đang dựng khung bình luận. 20 giây là dư cho trường hợp chậm nhất đo được
@@ -94,6 +113,34 @@ async function openCommentsIfNeeded(page, log) {
94
113
  // Facebook đặt tên tác giả ngay trong aria-label của khối bình luận
95
114
  // ("Bình luận của <Tên>" / "Comment by <Name>") — đọc thẳng ở đó chắc hơn dò
96
115
  // anchor đầu tiên, vì trong khối còn có link tới bài, tới trang được nhắc tên.
116
+ // ĐẾM số chỗ trên trang có chứa chuỗi link, BỎ QUA ô soạn.
117
+ //
118
+ // Đây là cách xác minh chắc hơn hẳn việc dò cấu trúc bình luận: mình đo TRƯỚC
119
+ // khi gõ và ĐO LẠI sau khi gửi, thấy tăng lên là bình luận đã lên. Không cần
120
+ // biết Facebook dựng khung bình luận bằng thẻ gì, cũng không vỡ khi họ đổi.
121
+ //
122
+ // Vì sao phải đổi (15/09): bản đầu đòi khối bình luận mang nhãn
123
+ // `aria-label="Bình luận của <Tên>"` — đó là cấu trúc bài trên BẢNG TIN. Trang
124
+ // reel dựng khác, nên 5/5 lượt thả thật đều báo "không đọc lại thấy" trong khi
125
+ // chủ tịch mở bài ra thì bình luận NẰM ĐÓ. Suýt nữa thì mỗi bài hai bình luận
126
+ // vì nút hiện "Thả lại".
127
+ async function countLinkOnPage(page, link) {
128
+ return page.evaluate((needle) => {
129
+ let n = 0;
130
+ const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
131
+ while (walk.nextNode()) {
132
+ const t = walk.currentNode;
133
+ if (!t.nodeValue || !t.nodeValue.includes(needle)) continue;
134
+ // Chữ mình vừa gõ trong ô soạn không tính.
135
+ if (t.parentElement && t.parentElement.closest("[contenteditable='true']")) continue;
136
+ n++;
137
+ }
138
+ return n;
139
+ }, link).catch(() => -1);
140
+ }
141
+
142
+ // Cố lấy TÊN người bình luận + permalink. Best-effort thuần: không lấy được thì
143
+ // để trống chứ KHÔNG coi là thất bại — xác minh đã do phép đếm ở trên lo.
97
144
  async function findPostedComment(page, link) {
98
145
  return page.evaluate((needle) => {
99
146
  const out = { found: false, author: '', comment_url: '' };
@@ -121,16 +168,22 @@ async function findPostedComment(page, link) {
121
168
  }, link).catch(() => ({ found: false, author: '', comment_url: '' }));
122
169
  }
123
170
 
124
- async function waitForPostedComment(page, link, log, timeoutMs = VERIFY_TIMEOUT_MS) {
171
+ async function waitForPostedComment(page, link, log, timeoutMs = VERIFY_TIMEOUT_MS, baseline = 0) {
125
172
  const deadline = Date.now() + timeoutMs;
126
- let last = { found: false, author: '', comment_url: '' };
173
+ let meta = { found: false, author: '', comment_url: '' };
127
174
  while (Date.now() < deadline) {
128
- last = await findPostedComment(page, link);
129
- if (last.found) return last;
175
+ const n = await countLinkOnPage(page, link);
176
+ // Tăng so với lúc trước khi gõ = bình luận đã lên. Đây mới là bằng chứng;
177
+ // tên tác giả và permalink chỉ là thông tin kèm theo.
178
+ if (n > baseline) {
179
+ meta = await findPostedComment(page, link);
180
+ return { ...meta, found: true, count: n };
181
+ }
130
182
  await page.waitForTimeout(1200);
131
183
  }
132
- log('warn', `[fb-cmt] hết ${Math.round(timeoutMs / 1000)}s vẫn chưa đọc lại thấy bình luận mang link`);
133
- return last;
184
+ const n = await countLinkOnPage(page, link);
185
+ log('warn', `[fb-cmt] hết ${Math.round(timeoutMs / 1000)}s: số chỗ có link vẫn là ${n} (trước khi gõ ${baseline}) — chưa thấy bình luận lên`);
186
+ return { ...meta, found: false, count: n };
134
187
  }
135
188
 
136
189
  // ─── ghim ───────────────────────────────────────────────────────────────────
@@ -217,6 +270,11 @@ async function run({ page, payload = {}, log }) {
217
270
  throw new Error(`Không tìm thấy ô soạn bình luận trên ${postUrl} (đã thử ${BOX_SELECTORS.length} nhãn) — nhiều khả năng bài tắt bình luận hoặc Facebook đổi giao diện`);
218
271
  }
219
272
 
273
+ // Đo NỀN trước khi gõ: link có thể đã nằm sẵn trong caption, hoặc bài đã
274
+ // được thả bình luận từ lượt trước.
275
+ const baseline = Math.max(await countLinkOnPage(page, link), 0);
276
+ if (baseline > 0) log('info', `[fb-cmt] link đã xuất hiện ${baseline} chỗ trên bài từ trước — chỉ tính là xong khi tăng thêm`);
277
+
220
278
  await box.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
221
279
  await humanClick(page, box).catch(async () => { await box.click({ timeout: 5000 }); });
222
280
  await pause(page, 900);
@@ -240,9 +298,12 @@ async function run({ page, payload = {}, log }) {
240
298
  throw new Error(`FB_COMMENT_RATE_LIMITED: Facebook chặn bình luận vì tần suất/nghi spam — bình luận KHÔNG được nhận. Chuỗi bắt được: "${block}". Dừng thả link trên kênh này cho tới khi người kiểm tra.`);
241
299
  }
242
300
 
243
- const found = await waitForPostedComment(page, link, log);
301
+ const found = await waitForPostedComment(page, link, log, VERIFY_TIMEOUT_MS, baseline);
244
302
  if (!found.found) {
245
303
  await assertAccountUsable(page, log);
304
+ // Chụp lại màn hình: lần trước trượt xác minh mà không có gì để soi, phải
305
+ // đợi chủ tịch tự mở Facebook mới biết bình luận có lên hay không.
306
+ await dumpFailure(page, 'cmt-verify', log);
246
307
  // KHÔNG ném lỗi kiểu "có thể đã thả": trả về verified=false để API ghi
247
308
  // 'failed' kèm nguyên văn. Ném lỗi ở đây thì lượt chạy lại sẽ gõ thêm một
248
309
  // bình luận nữa, mà bình luận trước có thể đã nằm đó rồi.
@@ -268,4 +329,4 @@ async function run({ page, payload = {}, log }) {
268
329
  }
269
330
 
270
331
  module.exports = { run };
271
- module.exports.__testables = { findPostedComment, findCommentBox, BOX_SELECTORS, sel };
332
+ module.exports.__testables = { findPostedComment, countLinkOnPage, waitForPostedComment, findCommentBox, BOX_SELECTORS, sel };
@@ -997,6 +997,43 @@ async function hasVisibleReelComposer(page) {
997
997
  }).catch(() => false);
998
998
  }
999
999
 
1000
+ // Dựng câu báo lỗi cho ca "đi thẳng /reels/create/ rồi bị Facebook đá về feed".
1001
+ //
1002
+ // Trước 17/09/2026 chỗ này là MỘT chuỗi cứng luôn khẳng định `widget "Tạo bài
1003
+ // viết" không có mục Thước phim … tài khoản có thể chưa đủ điều kiện đăng
1004
+ // Reels`. Câu đó ĐỔ OAN trong ít nhất hai ca thật cùng ngày:
1005
+ // • "Làm Giàu Từ Chăn Nuôi": log ngay trên nó ghi `page.goto: Timeout
1006
+ // 45000ms` rồi `KHÔNG thấy widget … các region đang có: []` — trang chưa
1007
+ // tải xong chứ tài khoản không sao (chủ tịch xác nhận vẫn đăng nhập bình
1008
+ // thường). Chạy lại là được.
1009
+ // • profile "Aly L": widget CÓ, nhưng là tường CÁ NHÂN chứ không phải Page.
1010
+ // Người vận hành chỉ đọc câu này trên web, không mở log worker — nên câu lỗi
1011
+ // phải tự nói đúng tên bệnh và đúng việc cần làm.
1012
+ function describeReelCreateBounce(wallDiag, afterUrl) {
1013
+ const tail = `/reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector.`;
1014
+
1015
+ // Không dò được tường (page.evaluate ném) → đừng đoán bừa bệnh nào.
1016
+ if (!wallDiag) {
1017
+ return `FB không mở được trình tạo Thước phim: không đọc được tường trang, và ${tail} Cần xem log worker dòng "[fb-pw]" ngay trước lỗi này để biết nguyên nhân.`;
1018
+ }
1019
+
1020
+ // (1) Không có region nào = trang chưa render. Gần như luôn là trang tải
1021
+ // hỏng/timeout, KHÔNG phải chuyện quyền Reels hay đăng nhập.
1022
+ if (!wallDiag.regionSeen) {
1023
+ return `FB không mở được trình tạo Thước phim: TRANG CHƯA TẢI XONG — không có một vùng nội dung nào trên trang chủ (regions=${JSON.stringify(wallDiag.allRegions)}), và ${tail} Thường do mạng/proxy chậm hoặc page.goto timeout; KHÔNG phải tài khoản thiếu quyền đăng Reels. Cách xử: chạy lại lượt đăng; nếu lặp lại nhiều lần thì kiểm proxy của profile này.`;
1024
+ }
1025
+
1026
+ // (2) Tường CÁ NHÂN: composer cá nhân vốn không có mục Thước phim. Chạy lại
1027
+ // bao nhiêu lần cũng vô ích.
1028
+ if (wallDiag.personalWall) {
1029
+ return `FB không mở được trình tạo Thước phim: profile này đang đăng nhập TÀI KHOẢN CÁ NHÂN chứ không phải Page (nút đang có: ${JSON.stringify(wallDiag.inventory)}), và ${tail} Composer cá nhân không có mục Thước phim — chạy lại vô ích. Cách xử: đăng nhập lại profile và chuyển sang đúng Page.`;
1030
+ }
1031
+
1032
+ // (3) Tường Page thật mà vẫn thiếu mục Thước phim → mới đúng là nghi vấn
1033
+ // quyền đăng Reels của Page.
1034
+ return `FB không mở được trình tạo Thước phim: widget "Tạo bài viết" CÓ nhưng thiếu mục Thước phim (nút đang có: ${JSON.stringify(wallDiag.inventory)}), và ${tail} Cách xử: cần người đăng nhập profile này và thử tạo Thước phim bằng tay — Page có thể chưa đủ điều kiện đăng Reels.`;
1035
+ }
1036
+
1000
1037
  async function runOnce({ page, payload, log }) {
1001
1038
  const {
1002
1039
  video_url, title, description = '', tags = [],
@@ -1152,6 +1189,13 @@ async function runOnce({ page, payload, log }) {
1152
1189
  break;
1153
1190
  } catch {}
1154
1191
  }
1192
+ // Chẩn đoán của lượt dò tường, giữ lại để câu BÁO LỖI cuối cùng nói đúng
1193
+ // bệnh. Trước 17/09 câu lỗi là một chuỗi CỨNG luôn khẳng định "có widget
1194
+ // mà thiếu mục Thước phim", kể cả khi log ngay trên nó vừa ghi "KHÔNG thấy
1195
+ // widget nào" — người đọc bị đẩy đi kiểm quyền đăng Reels trong khi bệnh
1196
+ // thật là trang chưa tải xong (ca "Làm Giàu Từ Chăn Nuôi" 17/09:
1197
+ // `page.goto: Timeout 45000ms` ngay trước đó, regions = []).
1198
+ let wallDiag = null;
1155
1199
  // Fallback — walk the "Tạo bài viết" region in JS, find a button whose
1156
1200
  // text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
1157
1201
  if (!reelOpened) {
@@ -1194,6 +1238,17 @@ async function runOnce({ page, payload, log }) {
1194
1238
  } else {
1195
1239
  log('warn', `[fb-pw] có widget "Tạo bài viết" nhưng KHÔNG có mục Thước phim — nút đang có: ${JSON.stringify(probed.inventory)}`);
1196
1240
  }
1241
+ wallDiag = {
1242
+ regionSeen: probed.regionSeen,
1243
+ inventory: probed.inventory || [],
1244
+ allRegions: probed.allRegions || [],
1245
+ // Composer của TRANG CÁ NHÂN không bao giờ có mục Thước phim — nhận
1246
+ // ra bằng hai nhãn đặc trưng, không phải bằng việc thiếu nút Reel.
1247
+ // Ca thật 17/09: profile "Aly L" đăng nhập tài khoản cá nhân thay vì
1248
+ // Page → al="Dòng thời gian của Aly L", t="Aly ơi, bạn đang nghĩ gì".
1249
+ personalWall: (probed.inventory || []).some(s =>
1250
+ /Dòng thời gian của |ơi, bạn đang nghĩ gì|'s timeline|What's on your mind/i.test(s)),
1251
+ };
1197
1252
  }
1198
1253
  if (probed && probed.selector) {
1199
1254
  try {
@@ -1236,7 +1291,12 @@ async function runOnce({ page, payload, log }) {
1236
1291
  // Thước phim. Sửa selector bao nhiêu cũng vô ích.
1237
1292
  await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
1238
1293
  if (usedCreateRoute) {
1239
- throw new Error(`FB không mở được trình tạo Thước phim cho tài khoản này: widget "Tạo bài viết" không có mục Thước phim, /reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector — cần người đăng nhập profile này và thử tạo Thước phim bằng tay (tài khoản có thể chưa đủ điều kiện đăng Reels, hoặc đang đăng nhập sai trang).`);
1294
+ // BA bệnh khác hẳn nhau, trước 17/09 gộp chung MỘT câu cứng luôn phán
1295
+ // "widget có mà thiếu mục Thước phim" → đổ oan tài khoản, người đọc đi
1296
+ // kiểm quyền đăng Reels trong khi bệnh thật nằm chỗ khác. Nói đúng tên
1297
+ // bệnh ngay trong câu lỗi, vì người vận hành chỉ nhìn thấy câu này trên
1298
+ // web chứ không mở log worker ra đọc.
1299
+ throw new Error(describeReelCreateBounce(wallDiag, afterUrl));
1240
1300
  }
1241
1301
  throw new Error(`FB Thước phim click navigated to Reels feed (${afterUrl}) — wrong button matched, should be composer modal. Check "Tạo bài viết" widget layout on this Page.`);
1242
1302
  }
@@ -3425,6 +3485,9 @@ module.exports = { run };
3425
3485
  // Exposed so the overlay-click escalation can be exercised against a synthetic
3426
3486
  // page (toast covering the CTA) without driving the whole upload flow.
3427
3487
  module.exports.__testables = {
3488
+ // Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
3489
+ // lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
3490
+ dumpFailure,
3428
3491
  resilientClick,
3429
3492
  confirmPublishDialog,
3430
3493
  diffNewReelIds,
@@ -3445,6 +3508,7 @@ module.exports.__testables = {
3445
3508
  waitForPublishEnabled,
3446
3509
  isThumbnailEditorVisible,
3447
3510
  hasVisibleReelComposer,
3511
+ describeReelCreateBounce,
3448
3512
  SAFE_RETRY_COMPOSER_CLOSED,
3449
3513
  navigateFacebookHome,
3450
3514
  navigateFacebookReelCreate,
@@ -1,7 +1,7 @@
1
- // upload_facebook_photo — publish an IMAGE (photo) post to a Facebook Page via
1
+ // upload_facebook_photo / upload_facebook_post — publish an IMAGE or TEXT post to a Facebook Page via
2
2
  // the "Tạo bài viết" composer (NOT the Reels composer). Driven by the worker
3
3
  // daemon's Playwright pipeline; command type upload_facebook_photo_pw →
4
- // scripts/upload_facebook_photo.js. Payload: { image_url, caption, tags[] }.
4
+ // scripts/upload_facebook_photo.js. Payload: { image_url?, caption, tags[] }.
5
5
  //
6
6
  // Strict-input contract (same as upload_facebook.js): every step throws on
7
7
  // failure so the cmd is marked failed — no silent false-success. In particular
@@ -111,8 +111,8 @@ function composerStillOpen(page, verbs) {
111
111
 
112
112
  async function run({ page, payload, log }) {
113
113
  const { image_url, caption = '', tags = [] } = payload || {};
114
- if (!image_url) throw new Error('No image_url provided');
115
114
  if (!caption || !caption.trim()) throw new Error('No caption provided');
115
+ const hasImage = !!String(image_url || '').trim();
116
116
 
117
117
  log('info', '[fbphoto] selectors version=2026.07.01a');
118
118
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
@@ -137,13 +137,15 @@ async function run({ page, payload, log }) {
137
137
  .map((t) => `#${t}`).join(' ');
138
138
  const fullCaption = hashtagLine ? `${caption.trim()}\n\n${hashtagLine}` : caption.trim();
139
139
 
140
- log('info', '[fbphoto] downloading image to local…');
141
- const imagePath = await downloadToTemp(image_url, { prefix: 'fbphoto', ext: '.png' });
142
- log('info', `[fbphoto] image at ${imagePath}`);
143
-
140
+ let imagePath = '';
144
141
  const publishVerbs = ['Đăng', 'Đăng bài', 'Post', 'Publish'];
145
142
 
146
143
  try {
144
+ if (hasImage) {
145
+ log('info', '[fbphoto] downloading image to local…');
146
+ imagePath = await downloadToTemp(image_url, { prefix: 'fbphoto', ext: '.png' });
147
+ log('info', `[fbphoto] image at ${imagePath}`);
148
+ }
147
149
  // 1) Open the Page wall.
148
150
  log('info', '[fbphoto] navigating to facebook.com…');
149
151
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
@@ -154,8 +156,8 @@ async function run({ page, payload, log }) {
154
156
  // 2) Open the "Tạo bài viết" composer via the "Ảnh/video" entry (this opens
155
157
  // the post composer directly in photo-attach mode). Fall back to the
156
158
  // status box ("Bạn đang nghĩ gì") if the photo entry isn't found.
157
- const openPhotoComposer = async () => {
158
- const photoBtn = await page.evaluate(() => {
159
+ const openComposer = async () => {
160
+ const photoBtn = hasImage ? await page.evaluate(() => {
159
161
  const els = document.querySelectorAll("div[role='button'], span, a[role='button']");
160
162
  for (const el of els) {
161
163
  const t = (el.innerText || '').trim();
@@ -167,7 +169,7 @@ async function run({ page, payload, log }) {
167
169
  }
168
170
  }
169
171
  return false;
170
- }).catch(() => false);
172
+ }).catch(() => false) : false;
171
173
  if (photoBtn) {
172
174
  await page.locator("[__fbphoto_open__='1']").click({ timeout: 4000 }).catch(() => {});
173
175
  await page.evaluate(() => document.querySelectorAll('[__fbphoto_open__]').forEach((e) => e.removeAttribute('__fbphoto_open__'))).catch(() => {});
@@ -178,16 +180,16 @@ async function run({ page, payload, log }) {
178
180
  if (box) { await box.click({ timeout: 4000 }).catch(() => {}); await pause(page, 1500); return true; }
179
181
  return false;
180
182
  };
181
- if (!(await openPhotoComposer())) {
183
+ if (!(await openComposer())) {
182
184
  await dumpFailure(page, 'no-composer-entry', log);
183
- throw new Error('FB: không tìm thấy nút "Ảnh/video" / ô "Tạo bài viết" trên trang. Kiểm tra layout Page.');
185
+ throw new Error('FB: không tìm thấy ô "Tạo bài viết" trên trang. Kiểm tra layout Page.');
184
186
  }
185
187
  await pause(page, 2500);
186
188
 
187
189
  // 3) Ensure the composer modal is open; click its "Ảnh/video" if the file
188
190
  // input isn't ready yet.
189
- let fileInput = await page.$("input[type='file'][accept*='image']");
190
- if (!fileInput) {
191
+ let fileInput = hasImage ? await page.$("input[type='file'][accept*='image']") : null;
192
+ if (hasImage && !fileInput) {
191
193
  const inModalPhoto = await page.evaluate(() => {
192
194
  const dlg = document.querySelector("[role='dialog']");
193
195
  if (!dlg) return false;
@@ -208,16 +210,18 @@ async function run({ page, payload, log }) {
208
210
  if (!fileInput) await pause(page, 1000);
209
211
  }
210
212
  }
211
- if (!fileInput) {
213
+ if (hasImage && !fileInput) {
212
214
  await dumpFailure(page, 'no-file-input', log);
213
215
  throw new Error('FB: không tìm thấy ô upload ảnh trong composer.');
214
216
  }
215
217
 
216
218
  // 4) Attach the image.
217
- log('info', '[fbphoto] attaching image…');
218
- await setImageFile(page, fileInput, imagePath, log);
219
- // Wait for the image preview to render inside the composer.
220
- await pause(page, 4000);
219
+ if (hasImage) {
220
+ log('info', '[fbphoto] attaching image…');
221
+ await setImageFile(page, fileInput, imagePath, log);
222
+ // Wait for the image preview to render inside the composer.
223
+ await pause(page, 4000);
224
+ }
221
225
 
222
226
  // 5) Fill caption into the composer's contenteditable textbox.
223
227
  log('info', '[fbphoto] filling caption…');
@@ -323,7 +327,7 @@ async function run({ page, payload, log }) {
323
327
  }
324
328
  if (await composerStillOpen(page, publishVerbs)) {
325
329
  await dumpFailure(page, 'composer-stuck-open', log);
326
- throw new Error('FB đăng ảnh KHÔNG commit — composer vẫn mở sau 3 lần thử (bài CHƯA được đăng).');
330
+ throw new Error('FB đăng bài KHÔNG commit — composer vẫn mở sau 3 lần thử (bài CHƯA được đăng).');
327
331
  }
328
332
 
329
333
  // 8) Best-effort post URL.
@@ -0,0 +1,4 @@
1
+ // Standalone Page posts share the proven Facebook composer implementation with
2
+ // idea-derived photo posts. The shared runner treats image_url as optional, so
3
+ // an empty image_url publishes a text-only post.
4
+ module.exports = require('./upload_facebook_photo');