channel-worker 2.5.113 → 2.5.118

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,fetch_affiliate_report';
79
79
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
80
  }
81
81
 
@@ -107,6 +107,10 @@ class ApiClient {
107
107
  return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
108
108
  }
109
109
 
110
+ async affiliateReportResult(rows, meta = {}) {
111
+ return this.request('POST', '/affiliate-report/worker-ingest', { rows, ...meta });
112
+ }
113
+
110
114
  // Return the calling daemon's own Worker doc — primarily for reading
111
115
  // parallel_limit (the per-daemon scene-generation concurrency cap that
112
116
  // replaced the legacy global flowkit_max_concurrent setting).
@@ -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;
@@ -118,6 +125,9 @@ class CommandPoller {
118
125
  case 'get_affiliate_link':
119
126
  await this.handleGetAffiliateLink(command);
120
127
  break;
128
+ case 'fetch_affiliate_report':
129
+ await this.handleFetchAffiliateReport(command);
130
+ break;
121
131
  default:
122
132
  // Playwright-based pipeline: any command whose type ends in '_pw'
123
133
  // is routed to scripts/<base>.js (BrowserClaw-style automation
@@ -332,6 +342,59 @@ class CommandPoller {
332
342
  }
333
343
  }
334
344
 
345
+ async handleFetchAffiliateReport(command) {
346
+ const payload = command.payload || {};
347
+ if (!(await this._ensureNst(command))) return;
348
+ const report = require('./shopee-report');
349
+ const profileName = payload.profile_name
350
+ || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
351
+ || 'Shopee1';
352
+ let conn;
353
+ try {
354
+ conn = await this._connectNstProfileByName(profileName);
355
+ const result = await report.fetchConversionReport(conn.context, {
356
+ days: payload.days || 90,
357
+ log: (message) => console.log(message),
358
+ });
359
+ if (result.status === 'needs_verify') {
360
+ await this.api.updateCommand(command._id, {
361
+ status: 'done',
362
+ result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
363
+ });
364
+ return;
365
+ }
366
+ if (result.status !== 'ok') {
367
+ await this.api.updateCommand(command._id, {
368
+ status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
369
+ });
370
+ return;
371
+ }
372
+
373
+ const saved = await this.api.affiliateReportResult(result.rows, {
374
+ last_report_date: result.last_report_date,
375
+ last_update_time: result.last_update_time,
376
+ from: result.from,
377
+ to: result.to,
378
+ });
379
+ await this.api.updateCommand(command._id, {
380
+ status: 'done',
381
+ result: {
382
+ count: result.rows.length,
383
+ saved: saved?.saved || 0,
384
+ matched: saved?.matched || 0,
385
+ unmatched: saved?.unmatched || 0,
386
+ last_report_date: result.last_report_date,
387
+ },
388
+ });
389
+ console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} dòng; khớp kênh ${saved?.matched || 0}`);
390
+ } catch (err) {
391
+ console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
392
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
393
+ } finally {
394
+ if (conn) conn.disconnect();
395
+ }
396
+ }
397
+
335
398
  async handlePlaywrightCommand(command) {
336
399
  const { runPlaywrightScript } = require('./playwright-runner');
337
400
  const payload = command.payload || {};
@@ -538,6 +601,8 @@ class CommandPoller {
538
601
  if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
539
602
  const candidates = [...this._keptOpenProfiles.entries()]
540
603
  .filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
604
+ // Profile người vừa mở tay không phải ứng viên để nhường suất.
605
+ .filter(([id]) => this._humanHoldLeft(id) <= 0)
541
606
  .sort((a, b) => a[1] - b[1]);
542
607
  let over = this._keptOpenProfiles.size - max;
543
608
  for (const [id] of candidates) {
@@ -558,7 +623,7 @@ class CommandPoller {
558
623
  // Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
559
624
  // hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
560
625
  // tiếp vẫn dùng chung một browser như thiết kế cũ.
561
- _schedulePwClose(profileId, scriptName) {
626
+ _schedulePwClose(profileId, scriptName, delayMs = PW_IDLE_CLOSE_MS) {
562
627
  if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
563
628
  const prev = this._pwCloseTimers.get(profileId);
564
629
  if (prev) clearTimeout(prev);
@@ -566,13 +631,22 @@ class CommandPoller {
566
631
  this._pwCloseTimers.delete(profileId);
567
632
  // Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
568
633
  if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
634
+ // Người vừa mở profile này bằng tay → hoãn tới khi hết giữ chỗ. Đóng ở
635
+ // đây là đóng ngay trước mũi người đang ngồi trong browser.
636
+ const hold = this._humanHoldLeft(profileId);
637
+ if (hold > 0) {
638
+ console.log(`[commands/pw] hoãn đóng ${profileId} — người đang dùng, `
639
+ + `còn ${Math.ceil(hold / 1000)}s`);
640
+ this._schedulePwClose(profileId, scriptName, hold + 1000);
641
+ return;
642
+ }
569
643
  try {
570
644
  await this.nst.stopProfile(profileId);
571
- console.log(`[commands/pw] đóng profile ${profileId} sau ${PW_IDLE_CLOSE_MS / 1000}s rảnh (${scriptName})`);
645
+ console.log(`[commands/pw] đóng profile ${profileId} sau ${delayMs / 1000}s rảnh (${scriptName})`);
572
646
  } catch (e) {
573
647
  console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
574
648
  }
575
- }, PW_IDLE_CLOSE_MS);
649
+ }, delayMs);
576
650
  // Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
577
651
  if (typeof t.unref === 'function') t.unref();
578
652
  this._pwCloseTimers.set(profileId, t);
@@ -640,12 +714,43 @@ class CommandPoller {
640
714
  }
641
715
  }
642
716
 
643
- const result = await this.nst.launchProfile(profile_id, { proxy, extensionPath });
717
+ const result = await this._launchWithAgentRetry(profile_id, { proxy, extensionPath });
718
+
719
+ // Đ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
720
+ // browser thì với người bấm, cú bấm coi như rơi vào hư không. Đẩy cửa sổ
721
+ // sẵn có lên trước và giữ nó lại cho người dùng.
722
+ if (result.alreadyRunning) {
723
+ this._cancelPwClose(profile_id);
724
+ this._holdProfileForHuman(profile_id);
725
+ const fronted = await this.nst.bringProfileToFront(profile_id);
726
+ console.log(`[commands] Profile ${profile_id} đang mở sẵn — `
727
+ + `${fronted ? 'đã đưa cửa sổ lên trước' : 'KHÔNG đưa được cửa sổ lên trước'}, `
728
+ + `giữ cho người dùng ${Math.round(HUMAN_HOLD_MS / 60000)} phút`);
729
+ await this.api.updateCommand(command._id, {
730
+ status: 'done',
731
+ result: {
732
+ profile_id: result.profileId,
733
+ already_running: true,
734
+ brought_to_front: fronted,
735
+ launched_at: new Date().toISOString(),
736
+ message: fronted
737
+ ? 'Profile đã mở sẵn — đã đưa cửa sổ lên trước trên máy chạy.'
738
+ : '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ở.',
739
+ },
740
+ });
741
+ return;
742
+ }
743
+
644
744
  console.log(`[commands] Profile ${profile_id} launched${extensionPath ? ' (ext)' : ''}`);
745
+ this._holdProfileForHuman(profile_id);
645
746
 
646
747
  await this.api.updateCommand(command._id, {
647
748
  status: 'done',
648
- result: { profile_id: result.profileId, launched_at: new Date().toISOString() },
749
+ result: {
750
+ profile_id: result.profileId,
751
+ launched_at: new Date().toISOString(),
752
+ message: 'Đã mở trình duyệt trên máy chạy.',
753
+ },
649
754
  });
650
755
  } catch (err) {
651
756
  console.error(`[commands] Failed to launch profile: ${err.message}`);
@@ -656,6 +761,60 @@ class CommandPoller {
656
761
  }
657
762
  }
658
763
 
764
+ /**
765
+ * Mở profile, thử lại khi agent NST chưa dò ra.
766
+ *
767
+ * Agent đổi cổng mỗi lần Nstbrowser khởi động lại (chủ tịch tắt hết profile =
768
+ * app restart), và nó cần vài giây để nghe cổng mới. Không có vòng thử lại
769
+ * 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
770
+ * lại — đúng cảnh 16/09/2026: lệnh 11:50:48 chết, lệnh 11:51:34 chạy ngon.
771
+ * Các lỗi khác (hết slot NST, profile mở ở phiên khác) KHÔNG thử lại: thử lại
772
+ * cũng vậy, mà còn giấu mất lỗi thật.
773
+ */
774
+ async _launchWithAgentRetry(profileId, options, { attempts = 3, waitMs = 3000 } = {}) {
775
+ let lastErr = null;
776
+ for (let i = 1; i <= attempts; i++) {
777
+ try {
778
+ return await this.nst.launchProfile(profileId, options);
779
+ } catch (err) {
780
+ lastErr = err;
781
+ if (!/NST_AGENT_UNRESOLVED/.test(err.message || '')) throw err;
782
+ if (i === attempts) break;
783
+ console.warn(`[commands] agent NST chưa sẵn sàng (lần ${i}/${attempts}) — `
784
+ + `dò lại sau ${waitMs / 1000}s`);
785
+ await new Promise(r => setTimeout(r, waitMs));
786
+ await refreshPinnedAgentAddress({ minIntervalMs: 0 });
787
+ }
788
+ }
789
+ throw lastErr;
790
+ }
791
+
792
+ // ─── Giữ chỗ cho NGƯỜI ────────────────────────────────────────────────────
793
+ // Profile do người mở bằng tay không được để automation đóng ngang: lượt đăng
794
+ // bài trước đó đã hẹn đóng sau PW_IDLE_CLOSE_MS và cái hẹn đó không biết có
795
+ // người đang ngồi trong browser.
796
+ _cancelPwClose(profileId) {
797
+ if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
798
+ clearTimeout(this._pwCloseTimers.get(profileId));
799
+ this._pwCloseTimers.delete(profileId);
800
+ return true;
801
+ }
802
+ return false;
803
+ }
804
+
805
+ _holdProfileForHuman(profileId, ms = HUMAN_HOLD_MS) {
806
+ if (!this._humanHold) this._humanHold = new Map();
807
+ this._humanHold.set(profileId, Date.now() + ms);
808
+ }
809
+
810
+ /** Còn bao nhiêu ms giữ chỗ cho người (0 = hết/không có). */
811
+ _humanHoldLeft(profileId) {
812
+ if (!this._humanHold || !profileId) return 0;
813
+ const left = (this._humanHold.get(profileId) || 0) - Date.now();
814
+ if (left <= 0) { this._humanHold.delete(profileId); return 0; }
815
+ return left;
816
+ }
817
+
659
818
  async handleLaunchVeo3Profile(command) {
660
819
  const { nst_profile_id, name, veo3_worker_id, os, proxy } = command.payload || {};
661
820
  console.log(`[commands] Launching Veo3 profile: ${nst_profile_id} (${name}) os=${os || 'windows'}`);
@@ -2173,6 +2332,8 @@ class CommandPoller {
2173
2332
  // Queue holds work pinned to THIS profile → it's about to be claimed,
2174
2333
  // leave the browser open.
2175
2334
  if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
2335
+ // Người vừa mở profile này từ web → để yên cho tới hết giữ chỗ.
2336
+ if (this._humanHoldLeft(profileId) > 0 || this._humanHoldLeft(browser.name) > 0) continue;
2176
2337
  // Match by UUID or name (renderers use name as nst_profile_id)
2177
2338
  const lastActivity = this._profileLastActivity[profileId]
2178
2339
  || (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);
@@ -0,0 +1,107 @@
1
+ const { findAffiliatePage, isBlockedState } = require('./shopee-scraper');
2
+
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+
5
+ function reportWindow(days = 90, nowMs = Date.now()) {
6
+ const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
7
+ const vnNow = new Date(nowMs + 7 * 3600e3);
8
+ const vnDayStartAsUtc = Date.parse(`${vnNow.toISOString().slice(0, 10)}T00:00:00.000Z`);
9
+ const end = Math.floor(nowMs / 1000);
10
+ const start = Math.floor((vnDayStartAsUtc - 7 * 3600e3 - (safeDays - 1) * 86400e3) / 1000);
11
+ return { start, end, days: safeDays };
12
+ }
13
+
14
+ async function fetchConversionReport(ctx, {
15
+ days = 90,
16
+ pageSize = 100,
17
+ maxPages = 200,
18
+ delayMs = 750,
19
+ timeoutMs = 60000,
20
+ log = null,
21
+ } = {}) {
22
+ let page = findAffiliatePage(ctx.pages());
23
+ let opened = false;
24
+ if (!page) {
25
+ page = await ctx.newPage();
26
+ opened = true;
27
+ }
28
+
29
+ try {
30
+ if (!page.url().includes('/report/conversion_report')) {
31
+ await page.goto('https://affiliate.shopee.vn/report/conversion_report', {
32
+ waitUntil: 'domcontentloaded', timeout: timeoutMs,
33
+ }).catch(() => {});
34
+ await sleep(1500);
35
+ }
36
+ if (isBlockedState(page.url(), null)) {
37
+ return { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' };
38
+ }
39
+
40
+ const window = reportWindow(days);
41
+ const rows = [];
42
+ let total = null;
43
+ let lastReportDate = null;
44
+ let lastUpdateTime = null;
45
+
46
+ const updated = await page.evaluate(async () => {
47
+ try {
48
+ const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
49
+ method: 'POST', credentials: 'include',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ operationName: 'conversionReportUpdateTime',
53
+ query: 'query conversionReportUpdateTime { conversionReportUpdateTime { lastReportDate lastUpdateTime } }',
54
+ variables: {},
55
+ }),
56
+ });
57
+ return await r.json().catch(() => null);
58
+ } catch { return null; }
59
+ });
60
+ lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
61
+ lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
62
+
63
+ for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
64
+ const result = await page.evaluate(async (q) => {
65
+ const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
66
+ + `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
67
+ try {
68
+ const r = await fetch(url, { credentials: 'include' });
69
+ return { status: r.status, json: await r.json().catch(() => null) };
70
+ } catch (e) {
71
+ return { status: 0, error: String(e?.message || e) };
72
+ }
73
+ }, { pageSize, pageNum, start: window.start, end: window.end });
74
+
75
+ if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
76
+ return { status: 'needs_verify', rows, reason: `blocked while reading report (http ${result.status}, code ${result.json?.code})` };
77
+ }
78
+ if (result.status !== 200 || result.json?.code !== 0) {
79
+ return { status: 'failed', rows, reason: result.json?.msg || result.error || `report HTTP ${result.status}` };
80
+ }
81
+
82
+ const data = result.json?.data || {};
83
+ const batch = Array.isArray(data.list) ? data.list : [];
84
+ total = Number(data.total_count || 0);
85
+ rows.push(...batch);
86
+ if (log) log(`[shopee-report] trang ${pageNum}: ${batch.length} dòng (${rows.length}/${total})`);
87
+ if (!batch.length || rows.length >= total || batch.length < pageSize) break;
88
+ await sleep(delayMs);
89
+ }
90
+
91
+ if (total != null && rows.length < total) {
92
+ return { status: 'failed', rows, reason: `report pagination stopped at ${rows.length}/${total} rows` };
93
+ }
94
+
95
+ return {
96
+ status: 'ok', rows, total: total ?? rows.length,
97
+ from: new Date(window.start * 1000).toISOString(),
98
+ to: new Date(window.end * 1000).toISOString(),
99
+ last_report_date: lastReportDate,
100
+ last_update_time: lastUpdateTime,
101
+ };
102
+ } finally {
103
+ if (opened) await page.close().catch(() => {});
104
+ }
105
+ }
106
+
107
+ module.exports = { fetchConversionReport, reportWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.113",
3
+ "version": "2.5.118",
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": {
@@ -21,6 +21,8 @@ const CODES = {
21
21
  banned: 'FB_ACCOUNT_BANNED',
22
22
  };
23
23
 
24
+ const PAGE_NOT_CONFIGURED_CODE = 'FB_PAGE_NOT_CONFIGURED';
25
+
24
26
  const PUBLISH_BLOCK_PHRASES = [
25
27
  'giới hạn tần suất',
26
28
  'để bảo vệ cộng đồng khỏi spam',
@@ -108,6 +110,81 @@ async function assertAccountUsable(page, log) {
108
110
  throw err;
109
111
  }
110
112
 
113
+ // Is this NST session browsing Facebook as the person or as a Page?
114
+ //
115
+ // This is shared by nurture and publish. Publishing through a personal profile
116
+ // is a configuration error for this product: every channel is expected to own
117
+ // a Facebook Page. Without this guard, the personal "Tạo bài viết" widget
118
+ // (Video trực tiếp / Ảnh-video / Cảm xúc-hoạt động) falls through to
119
+ // /reels/create/. Facebook can still open a PERSONAL Reel composer there, so
120
+ // the upload fails later with a completely misleading "composer closed" error.
121
+ function classifyProfileModeSignals(signals = {}) {
122
+ // Personal-composer evidence wins over the navigation shortcuts. Facebook's
123
+ // Professional mode gives a PERSON `professional_dashboard` too; that exact
124
+ // combination made "thời trang 03" persist `profile_mode: page` even though
125
+ // its composer still showed "Cảm xúc/hoạt động" and no Reel control.
126
+ if (signals.hasFriends || (signals.hasFeelingActivity && !signals.hasReelControl)) return 'personal';
127
+ const clearPage = (signals.proLink || signals.proText) && !signals.hasFriends;
128
+ if (clearPage) return 'page';
129
+ return 'unknown';
130
+ }
131
+
132
+ async function readProfileMode(page) {
133
+ const signals = await page.evaluate(() => {
134
+ const navs = [...document.querySelectorAll("[role='navigation']")];
135
+ const navText = navs.map((n) => n.innerText || '').join(' | ');
136
+ const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
137
+ const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
138
+ const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
139
+ const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
140
+
141
+ let hasFeelingActivity = false;
142
+ let hasReelControl = false;
143
+ for (const region of document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết'], [aria-label='Create post'], [aria-label='Create a post']")) {
144
+ const aria = (region.getAttribute('aria-label') || '').trim();
145
+ if (!/tạo bài viết|create (?:a )?post/i.test(aria)) continue;
146
+ for (const el of region.querySelectorAll("[role='button'], button, a")) {
147
+ const label = `${el.innerText || el.textContent || ''}\n${el.getAttribute('aria-label') || ''}`.trim();
148
+ if (/Cảm xúc\s*\/\s*hoạt động|Feeling\s*\/\s*activity|Feeling or activity/i.test(label)) hasFeelingActivity = true;
149
+ if (/^\s*(?:Thước phim|Reels?|Create (?:a )?reel|Tạo thước phim)\s*$/im.test(label)) hasReelControl = true;
150
+ }
151
+ }
152
+
153
+ let timelineLabel = '';
154
+ for (const el of document.querySelectorAll("a[aria-label]")) {
155
+ const label = (el.getAttribute('aria-label') || '').trim();
156
+ if (/^Dòng thời gian của\s+|(?:'s|’s) Timeline$/i.test(label)) {
157
+ timelineLabel = label.slice(0, 120);
158
+ break;
159
+ }
160
+ }
161
+ return {
162
+ proLink, proText, hasFriends, hasFeelingActivity, hasReelControl, timelineLabel,
163
+ navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
164
+ };
165
+ }).catch(() => null);
166
+
167
+ if (!signals) return { mode: 'unknown', isPage: false, why: 'detect failed', navSample: '', timelineLabel: '' };
168
+ const mode = classifyProfileModeSignals(signals);
169
+ return {
170
+ mode,
171
+ isPage: mode === 'page',
172
+ why: `mode=${mode} proLink=${signals.proLink} proText=${signals.proText} friends=${signals.hasFriends} feeling=${signals.hasFeelingActivity} reel=${signals.hasReelControl}`,
173
+ navSample: signals.navSample || '',
174
+ timelineLabel: signals.timelineLabel || '',
175
+ };
176
+ }
177
+
178
+ function pageNotConfiguredError(profileMode = {}) {
179
+ const who = profileMode.timelineLabel ? ` (${profileMode.timelineLabel})` : '';
180
+ const err = new Error(
181
+ `${PAGE_NOT_CONFIGURED_CODE}: Kênh chưa cấu hình Facebook Page — NST profile đang dùng Facebook dưới dạng trang cá nhân${who}. `
182
+ + 'Hãy tạo Page, chuyển profile sang Page đó rồi đăng lại.',
183
+ );
184
+ err.code = PAGE_NOT_CONFIGURED_CODE;
185
+ return err;
186
+ }
187
+
111
188
  async function readPublishBlock(page) {
112
189
  return page.evaluate((phrases) => {
113
190
  const text = (document.body?.innerText || '').toLowerCase();
@@ -120,5 +197,13 @@ function publishRateLimitError(blockHit) {
120
197
  }
121
198
 
122
199
  module.exports = {
123
- readAccountState, assertAccountUsable, readPublishBlock, publishRateLimitError, CODES,
200
+ readAccountState,
201
+ assertAccountUsable,
202
+ readProfileMode,
203
+ classifyProfileModeSignals,
204
+ pageNotConfiguredError,
205
+ readPublishBlock,
206
+ publishRateLimitError,
207
+ CODES,
208
+ PAGE_NOT_CONFIGURED_CODE,
124
209
  };
@@ -19,7 +19,7 @@
19
19
  // A dead session (logged out / checkpoint / disabled) throws a CODED error via
20
20
  // lib/fb-guard so the API can stop the schedule and raise a notification.
21
21
 
22
- const { assertAccountUsable } = require('./lib/fb-guard');
22
+ const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
23
23
  const { pick, clickHandle } = require('./lib/dom-pick');
24
24
  const { humanMove, humanWheel, humanClick } = require('./lib/human');
25
25
 
@@ -435,32 +435,6 @@ async function sideTrip(page, log) {
435
435
  return true;
436
436
  }
437
437
 
438
- // Is this profile browsing AS A PAGE rather than as the person?
439
- //
440
- // Why it matters: a Page's feed contains only the Page's own posts, so there is
441
- // nothing to nurture — measured live: 1 post, feed bottoms out after ~1500px.
442
- // And a profile already switched into a Page has, by definition, got its Page —
443
- // which is the whole goal of nurturing. So this ends the session immediately and
444
- // takes the channel off the schedule.
445
- //
446
- // Signal: the left nav carries Page-only entries (Công cụ chuyên nghiệp / Trung
447
- // tâm quảng cáo / Ads Manager) and lacks the personal "Bạn bè" entry.
448
- async function detectProfileMode(page) {
449
- return page.evaluate(() => {
450
- const navs = [...document.querySelectorAll("[role='navigation']")];
451
- const navText = navs.map((n) => n.innerText || '').join(' | ');
452
- const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
453
- const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
454
- const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
455
- const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
456
- return {
457
- isPage: (proLink || proText) && !hasFriends,
458
- why: `proLink=${proLink} proText=${proText} friends=${hasFriends}`,
459
- navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
460
- };
461
- }).catch(() => ({ isPage: false, why: 'detect failed', navSample: '' }));
462
- }
463
-
464
438
  // ONE tab, always.
465
439
  //
466
440
  // Two things pile tabs up behind a session, and neither is ours: NSTBrowser
@@ -568,7 +542,7 @@ async function run({ page, context, payload, log }) {
568
542
  await dismissDialogs(page, log);
569
543
 
570
544
  // Already a Page? Then the goal is met — stop here, don't fake a session.
571
- const mode = await detectProfileMode(page);
545
+ const mode = await readProfileMode(page);
572
546
  if (payload.debug) log('info', `[nurture-fb][dbg] mode ${mode.why} nav="${mode.navSample}"`);
573
547
  if (mode.isPage) {
574
548
  log('info', '[nurture-fb] profile đang dùng dưới dạng TRANG (đã có Page) — bỏ qua, không cần nuôi nữa');
@@ -17,7 +17,13 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const { humanMove, humanClick, humanType, humanWheel, pause, randInt: hRandInt, chance: hChance } = require('./lib/human');
20
- const { assertAccountUsable, readPublishBlock, publishRateLimitError } = require('./lib/fb-guard');
20
+ const {
21
+ assertAccountUsable,
22
+ readProfileMode,
23
+ pageNotConfiguredError,
24
+ readPublishBlock,
25
+ publishRateLimitError,
26
+ } = require('./lib/fb-guard');
21
27
  const path = require('path');
22
28
  const { downloadToTemp, safeUnlink } = require('./lib/download');
23
29
 
@@ -997,6 +1003,43 @@ async function hasVisibleReelComposer(page) {
997
1003
  }).catch(() => false);
998
1004
  }
999
1005
 
1006
+ // Dựng câu báo lỗi cho ca "đi thẳng /reels/create/ rồi bị Facebook đá về feed".
1007
+ //
1008
+ // 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
1009
+ // viết" không có mục Thước phim … tài khoản có thể chưa đủ điều kiện đăng
1010
+ // Reels`. Câu đó ĐỔ OAN trong ít nhất hai ca thật cùng ngày:
1011
+ // • "Làm Giàu Từ Chăn Nuôi": log ngay trên nó ghi `page.goto: Timeout
1012
+ // 45000ms` rồi `KHÔNG thấy widget … các region đang có: []` — trang chưa
1013
+ // 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
1014
+ // thường). Chạy lại là được.
1015
+ // • profile "Aly L": widget CÓ, nhưng là tường CÁ NHÂN chứ không phải Page.
1016
+ // 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
1017
+ // phải tự nói đúng tên bệnh và đúng việc cần làm.
1018
+ function describeReelCreateBounce(wallDiag, afterUrl) {
1019
+ const tail = `/reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector.`;
1020
+
1021
+ // Không dò được tường (page.evaluate ném) → đừng đoán bừa bệnh nào.
1022
+ if (!wallDiag) {
1023
+ 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.`;
1024
+ }
1025
+
1026
+ // (1) Không có region nào = trang chưa render. Gần như luôn là trang tải
1027
+ // hỏng/timeout, KHÔNG phải chuyện quyền Reels hay đăng nhập.
1028
+ if (!wallDiag.regionSeen) {
1029
+ 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.`;
1030
+ }
1031
+
1032
+ // (2) Tường CÁ NHÂN: composer cá nhân vốn không có mục Thước phim. Chạy lại
1033
+ // bao nhiêu lần cũng vô ích.
1034
+ if (wallDiag.personalWall) {
1035
+ 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.`;
1036
+ }
1037
+
1038
+ // (3) Tường Page thật mà vẫn thiếu mục Thước phim → mới đúng là nghi vấn
1039
+ // quyền đăng Reels của Page.
1040
+ 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.`;
1041
+ }
1042
+
1000
1043
  async function runOnce({ page, payload, log }) {
1001
1044
  const {
1002
1045
  video_url, title, description = '', tags = [],
@@ -1086,6 +1129,16 @@ async function runOnce({ page, payload, log }) {
1086
1129
  // code and opens the Facebook auto-publish circuit breaker.
1087
1130
  await assertAccountUsable(page, log);
1088
1131
 
1132
+ // This uploader publishes to a Facebook PAGE. A personal account can also
1133
+ // open /reels/create/, which used to let an unconfigured channel get deep
1134
+ // into the wizard and finally surface as the unrelated "composer closed"
1135
+ // retry error. Stop before touching the video and tell the operator what is
1136
+ // actually missing. Unknown layouts continue to the existing DOM probes so
1137
+ // a Facebook A/B change does not falsely block a valid Page.
1138
+ const profileMode = await readProfileMode(page);
1139
+ log('info', `[fb-pw] Facebook identity check: ${profileMode.why}${profileMode.timelineLabel ? ` timeline="${profileMode.timelineLabel}"` : ''}`);
1140
+ if (profileMode.mode === 'personal') throw pageNotConfiguredError(profileMode);
1141
+
1089
1142
  // Look around before posting. The old flow was goto → 4s → straight into
1090
1143
  // "Thước phim", every single run: a session whose only actions are
1091
1144
  // open→post→leave. A person glances at the wall first. 2-5 wheel bursts
@@ -1152,6 +1205,13 @@ async function runOnce({ page, payload, log }) {
1152
1205
  break;
1153
1206
  } catch {}
1154
1207
  }
1208
+ // 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
1209
+ // bệnh. Trước 17/09 câu lỗi là một chuỗi CỨNG luôn khẳng định "có widget
1210
+ // mà thiếu mục Thước phim", kể cả khi log ngay trên nó vừa ghi "KHÔNG thấy
1211
+ // widget nào" — người đọc bị đẩy đi kiểm quyền đăng Reels trong khi bệnh
1212
+ // thật là trang chưa tải xong (ca "Làm Giàu Từ Chăn Nuôi" 17/09:
1213
+ // `page.goto: Timeout 45000ms` ngay trước đó, regions = []).
1214
+ let wallDiag = null;
1155
1215
  // Fallback — walk the "Tạo bài viết" region in JS, find a button whose
1156
1216
  // text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
1157
1217
  if (!reelOpened) {
@@ -1194,6 +1254,17 @@ async function runOnce({ page, payload, log }) {
1194
1254
  } else {
1195
1255
  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
1256
  }
1257
+ wallDiag = {
1258
+ regionSeen: probed.regionSeen,
1259
+ inventory: probed.inventory || [],
1260
+ allRegions: probed.allRegions || [],
1261
+ // Composer của TRANG CÁ NHÂN không bao giờ có mục Thước phim — nhận
1262
+ // ra bằng hai nhãn đặc trưng, không phải bằng việc thiếu nút Reel.
1263
+ // Ca thật 17/09: profile "Aly L" đăng nhập tài khoản cá nhân thay vì
1264
+ // Page → al="Dòng thời gian của Aly L", t="Aly ơi, bạn đang nghĩ gì".
1265
+ personalWall: (probed.inventory || []).some(s =>
1266
+ /Dòng thời gian của |ơi, bạn đang nghĩ gì|'s timeline|What's on your mind/i.test(s)),
1267
+ };
1197
1268
  }
1198
1269
  if (probed && probed.selector) {
1199
1270
  try {
@@ -1236,7 +1307,12 @@ async function runOnce({ page, payload, log }) {
1236
1307
  // Thước phim. Sửa selector bao nhiêu cũng vô ích.
1237
1308
  await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
1238
1309
  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).`);
1310
+ // 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
1311
+ // "widget có mà thiếu mục Thước phim" → đổ oan tài khoản, người đọc đi
1312
+ // kiểm quyền đăng Reels trong khi bệnh thật nằm chỗ khác. Nói đúng tên
1313
+ // 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
1314
+ // web chứ không mở log worker ra đọc.
1315
+ throw new Error(describeReelCreateBounce(wallDiag, afterUrl));
1240
1316
  }
1241
1317
  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
1318
  }
@@ -3405,7 +3481,7 @@ async function run(args) {
3405
3481
  } catch (e) {
3406
3482
  // A checkpoint/logout can appear mid-composer. Prefer the coded account
3407
3483
  // error over the selector symptom so the server stops unattended retries.
3408
- if (!/^FB_(?:ACCOUNT|PUBLISH)_/.test(String(e?.message || ''))) {
3484
+ if (!/^FB_(?:ACCOUNT|PUBLISH|PAGE)_/.test(String(e?.message || ''))) {
3409
3485
  try { await assertAccountUsable(page, log); } catch (accountErr) { throw accountErr; }
3410
3486
  }
3411
3487
  const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
@@ -3448,6 +3524,7 @@ module.exports.__testables = {
3448
3524
  waitForPublishEnabled,
3449
3525
  isThumbnailEditorVisible,
3450
3526
  hasVisibleReelComposer,
3527
+ describeReelCreateBounce,
3451
3528
  SAFE_RETRY_COMPOSER_CLOSED,
3452
3529
  navigateFacebookHome,
3453
3530
  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');