channel-worker 2.5.113 → 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.113",
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": {
@@ -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
  }
@@ -3448,6 +3508,7 @@ module.exports.__testables = {
3448
3508
  waitForPublishEnabled,
3449
3509
  isThumbnailEditorVisible,
3450
3510
  hasVisibleReelComposer,
3511
+ describeReelCreateBounce,
3451
3512
  SAFE_RETRY_COMPOSER_CLOSED,
3452
3513
  navigateFacebookHome,
3453
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');