channel-worker 2.5.61 → 2.5.63

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.
@@ -1,3 +1,24 @@
1
+ function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
2
+ const raw = String(response?.msg || 'Failed to connect browser').trim();
3
+ if (!/exceeded plan limits/i.test(raw)) return new Error(raw);
4
+
5
+ const nstCode = response?.code || 6001;
6
+ const localState = runningCount > 0
7
+ ? `Máy này đang có ${runningCount} browser NST chạy.`
8
+ : 'Máy này hiện không còn browser NST chạy; slot cloud có thể chưa được nhả sau batch trước.';
9
+ const error = new Error(
10
+ `NST_PLAN_LIMIT (mã ${nstCode}): NSTBrowser đã chạm giới hạn slot/phiên của gói, `
11
+ + `không mở được profile "${profileId}". Tác vụ CHƯA truy cập nền tảng — đây là lỗi hạ tầng NST, `
12
+ + 'không phải do nền tảng từ chối bài. '
13
+ + `${localState} Chờ/đóng bớt profile NST hoặc kiểm tra gói NST, rồi đăng lại.`,
14
+ );
15
+ error.code = 'NST_PLAN_LIMIT';
16
+ error.nstCode = nstCode;
17
+ error.profileId = profileId;
18
+ error.localRunningCount = runningCount;
19
+ return error;
20
+ }
21
+
1
22
  class NstManager {
2
23
  constructor(apiKey, options = {}) {
3
24
  this.apiKey = apiKey;
@@ -241,7 +262,7 @@ class NstManager {
241
262
  const apiUrl = `${this.baseUrl}/connect/${profileId}?config=${encodeURIComponent(JSON.stringify(connectConfig))}`;
242
263
  const rawRes = await fetch(apiUrl, { headers: { 'x-api-key': this.apiKey } });
243
264
  const res = await rawRes.json();
244
- if (res.err) throw new Error(res.msg || 'Failed to connect browser');
265
+ if (res.err) throw nstLaunchError(res, { profileId: profileIdOrName, runningCount: running.length });
245
266
 
246
267
  console.log(`[nst] Browser started`);
247
268
  return { profileId, wsEndpoint: res?.data?.webSocketDebuggerUrl, response: res };
@@ -265,4 +286,4 @@ class NstManager {
265
286
  }
266
287
  }
267
288
 
268
- module.exports = { NstManager };
289
+ module.exports = { NstManager, nstLaunchError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.61",
3
+ "version": "2.5.63",
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,29 @@ const { assertAccountUsable, readPublishBlock, publishRateLimitError } = require
21
21
  const path = require('path');
22
22
  const { downloadToTemp, safeUnlink } = require('./lib/download');
23
23
 
24
+ const FB_HOME_URL = 'https://www.facebook.com/';
25
+ const FB_REEL_CREATE_URL = 'https://www.facebook.com/reels/create/';
26
+ const REEL_CREATE_SELECTORS = [
27
+ "a[aria-label='Tạo thước phim']",
28
+ "a[aria-label='Create reel']",
29
+ "a[aria-label='Create a reel']",
30
+ "a[href*='/reels/create']",
31
+ ];
32
+ const REEL_ENTRY_SELECTORS = [
33
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Thước phim']",
34
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reel']",
35
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reels']",
36
+ "div[role='region'][aria-label='Create post'] [aria-label='Reel']",
37
+ "div[role='region'][aria-label='Create post'] [aria-label='Reels']",
38
+ "div[role='region'][aria-label='Create post'] [aria-label='Create reel']",
39
+ "div[role='region'][aria-label='Create post'] [aria-label='Create a reel']",
40
+ ];
41
+ // Residential proxies can leave Facebook's final "Đang tải..." state active
42
+ // well past three minutes. The API only declares a publish command stale after
43
+ // 20 minutes, so give the real upload enough room instead of failing while FB
44
+ // is visibly still processing it.
45
+ const PUBLISH_ENABLE_TIMEOUT_MS = 10 * 60 * 1000;
46
+
24
47
  async function firstVisible(locator, max = 5) {
25
48
  const n = Math.min(await locator.count().catch(() => 0), max);
26
49
  for (let i = 0; i < n; i++) {
@@ -49,6 +72,47 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', cl
49
72
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
50
73
  }
51
74
 
75
+ // Facebook occasionally never resolves Playwright's `domcontentloaded` wait
76
+ // even though the Page shell is already usable. Treat that specific state as
77
+ // loaded; otherwise clear the half-navigation and retry once. This turns an
78
+ // intermittent navigation timeout into a guarded retry without hiding a dead
79
+ // proxy or an actually blank page.
80
+ async function navigateFacebookHome(page, log) {
81
+ let lastError = null;
82
+ for (let attempt = 1; attempt <= 2; attempt++) {
83
+ try {
84
+ await page.goto(FB_HOME_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
85
+ return;
86
+ } catch (e) {
87
+ lastError = e;
88
+ const shellReady = /^https:\/\/(?:www\.)?facebook\.com\//i.test(page.url())
89
+ && !!await firstVisible(page.locator("a[aria-label='Facebook'], [role='region'][aria-label='Tạo bài viết'], [role='region'][aria-label='Create post']"), 5);
90
+ if (shellReady) {
91
+ log('warn', `[fb-pw] home domcontentloaded timed out but Facebook shell is usable — continuing (attempt ${attempt})`);
92
+ return;
93
+ }
94
+ if (attempt < 2) {
95
+ log('warn', `[fb-pw] home navigation failed — retrying once: ${String(e?.message || e).split('\n')[0]}`);
96
+ await page.goto('about:blank', { waitUntil: 'commit', timeout: 15_000 }).catch(() => {});
97
+ await pause(page, 1500);
98
+ }
99
+ }
100
+ }
101
+ throw lastError || new Error('FB home navigation failed');
102
+ }
103
+
104
+ // Some Facebook cohorts render the Page-wall create-post widget as icon-only
105
+ // controls (no Reel text/aria-label). In that layout there is no safe DOM
106
+ // selector that distinguishes the composer trigger from the sidebar Reels feed.
107
+ // Navigate to Facebook's dedicated CREATE route instead. This is only called
108
+ // before a file is selected, so repeating it cannot create a duplicate post.
109
+ async function navigateFacebookReelCreate(page, log) {
110
+ log('warn', '[fb-pw] Reel entry unavailable — hard navigating to /reels/create/ before upload…');
111
+ await page.goto(FB_REEL_CREATE_URL, { waitUntil: 'commit', timeout: 60_000 });
112
+ await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {});
113
+ await pause(page, 7000);
114
+ }
115
+
52
116
  // FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
53
117
  // 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
54
118
  // composer's bottom CTA. Playwright's hit-target check then blocks every
@@ -265,8 +329,15 @@ async function dismissBsOnboarding(page, log) {
265
329
  const cs = getComputedStyle(d);
266
330
  if (cs.visibility === 'hidden' || cs.display === 'none') continue;
267
331
  const txt = (d.innerText || '').slice(0, 400);
332
+ const aria = d.getAttribute('aria-label') || '';
268
333
  // Skip the Reels composer — that's the modal we just opened.
269
- if (/Tạo thước phim|Create reel|Create a reel|Tạo thư�?c phim/i.test(txt)) continue;
334
+ if (/Tạo thước phim|Create reel|Create a reel|Tạo thư�?c phim/i.test(`${aria}\n${txt}`)) continue;
335
+ // Do not classify every anonymous dialog with an X as onboarding.
336
+ // Facebook's newly-mounted Reel composer can briefly have no title;
337
+ // the old code saw that empty dialog and clicked its "Đóng" button.
338
+ const knownOnboarding = /^js_/i.test(aria)
339
+ || /Đã hiểu|Got it|Bỏ qua|Skip|Để sau|Not now|Tiếp tục|Continue/i.test(txt);
340
+ if (!knownOnboarding) continue;
270
341
  d.setAttribute('__fbpw_onboarding__', '1');
271
342
  return { tag: 'ok', preview: txt.slice(0, 80) };
272
343
  }
@@ -464,7 +535,7 @@ async function runOnce({ page, payload, log }) {
464
535
  } = payload || {};
465
536
  if (!video_url) throw new Error('No video_url provided');
466
537
 
467
- log('info', '[fb-pw] selectors version=2026.08.06-composer-retry-metadata-poll');
538
+ log('info', '[fb-pw] selectors version=2026.08.28-icon-only-create-route-fallback');
468
539
 
469
540
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
470
541
 
@@ -537,7 +608,7 @@ async function runOnce({ page, payload, log }) {
537
608
  // Page-wall flow opens a Reel modal whose publish flow either navigates
538
609
  // or shows a confirmation toast linking to the new reel.
539
610
  log('info', '[fb-pw] open https://www.facebook.com/ (page wall flow) …');
540
- await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
611
+ await navigateFacebookHome(page, log);
541
612
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
542
613
  await pause(page, 4000);
543
614
  log('info', `[fb-pw] home loaded — url=${page.url()}`);
@@ -576,15 +647,8 @@ async function runOnce({ page, payload, log }) {
576
647
  // instead of opening the composer (observed on the brain-made-simple
577
648
  // page wall — clicked feed link, played random viral reel).
578
649
  log('info', '[fb-pw] click "Thước phim" entry inside "Tạo bài viết"…');
579
- const reelEntryCandidates = [
580
- // Scoped to the create-post widget — this is the only correct trigger.
581
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Thước phim']",
582
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reel']",
583
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reels']",
584
- "div[role='region'][aria-label='Create post'] [aria-label='Reels']",
585
- ];
586
650
  let reelOpened = false;
587
- for (const sel of reelEntryCandidates) {
651
+ for (const sel of REEL_ENTRY_SELECTORS) {
588
652
  const btn = await firstVisible(page.locator(sel), 5);
589
653
  if (!btn) continue;
590
654
  try {
@@ -628,9 +692,10 @@ async function runOnce({ page, payload, log }) {
628
692
  }
629
693
  }
630
694
  if (!reelOpened) {
631
- await dumpInventory(page, log, 'no-reel-entry');
632
- await dumpFailure(page, 'no-reel-entry', log);
633
- throw new Error('FB home: "Thước phim" entry not found in "Tạo bài viết" widget (sidebar Reels feed link doesn\'t count — it navigates instead of opening composer)');
695
+ // English/icon-only Page layouts expose Live video, Photo/video and Reel
696
+ // as bare SVG controls. The old code failed here even though the official
697
+ // create route was usable. Go there before touching any file input.
698
+ await navigateFacebookReelCreate(page, log);
634
699
  }
635
700
  await pause(page, 4000);
636
701
  const afterUrl = page.url();
@@ -660,47 +725,71 @@ async function runOnce({ page, payload, log }) {
660
725
  .locator("[role='dialog']")
661
726
  .filter({ has: page.locator(":scope :text-matches('^Tạo thước phim$|^Create reel$|^Create a reel$', 'i')") })
662
727
  .first();
663
- if (!(await reelsDialog.isVisible({ timeout: 6000 }).catch(() => false))) {
664
- // FALLBACK: some accounts render a different UI from the create-post
665
- // "Thước phim" entry the composer modal never mounts. Click the Reels
666
- // link (any <a href*='/reels'>) instead, then re-wait for the modal.
667
- log('warn', '[fb-pw] Reels modal not open — fallback: click <a href*="/reels"> then re-wait…');
668
- let xpClicked = false;
669
- try {
670
- const reelsLink = page.locator("xpath=//a[contains(@href, '/reels')]").first();
671
- if (await reelsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
672
- await reelsLink.click({ timeout: 4000 });
673
- xpClicked = true;
674
- log('info', '[fb-pw] clicked Reels link via xpath //a[contains(@href,"/reels")]');
675
- } else {
676
- log('warn', '[fb-pw] fallback: no visible <a href*="/reels"> found');
677
- }
678
- } catch (e) { log('warn', `[fb-pw] fallback Reels-link click failed: ${e.message.slice(0, 80)}`); }
679
- if (xpClicked) {
680
- await pause(page, 5000);
728
+ const fullPageComposer = page.locator("main, [role='main']").first();
729
+ const findComposerRoot = async (timeout = 1000) => {
730
+ if (await reelsDialog.isVisible({ timeout }).catch(() => false)) return reelsDialog;
731
+ if (/\/reels\/create\/?/i.test(page.url())
732
+ && await fullPageComposer.isVisible({ timeout: 1000 }).catch(() => false)) {
733
+ const hasUploadUi = await fullPageComposer.locator("input[type='file'], [aria-label='Tải lên'], [aria-label='Upload'], [aria-label='Thêm video'], [aria-label='Add video']")
734
+ .count().catch(() => 0);
735
+ if (hasUploadUi) return fullPageComposer;
736
+ }
737
+ return null;
738
+ };
739
+
740
+ let reelsRoot = await findComposerRoot(6000);
741
+ if (!reelsRoot) {
742
+ // FALLBACK: target the CREATE link, never the generic /reels feed link.
743
+ // Inventory from the failing accounts contains both; the old broad xpath
744
+ // picked the feed link and left us on the wall with no composer.
745
+ log('warn', '[fb-pw] Reels composer not open — fallback: click exact "Tạo thước phim" create link…');
746
+ let createClicked = false;
747
+ for (const sel of REEL_CREATE_SELECTORS) {
748
+ const link = await firstVisible(page.locator(sel), 3);
749
+ if (!link) continue;
750
+ try {
751
+ await humanClick(page, link, { timeout: 4000 });
752
+ createClicked = true;
753
+ log('info', `[fb-pw] clicked Reel create link via "${sel}"`);
754
+ break;
755
+ } catch (e) { log('warn', `[fb-pw] create-link click failed (${sel}): ${e.message.slice(0, 80)}`); }
756
+ }
757
+ if (createClicked) {
758
+ await pause(page, 7000);
681
759
  await dismissBsOnboarding(page, log).catch(() => {});
760
+ reelsRoot = await findComposerRoot(5000);
682
761
  }
683
- if (!(await reelsDialog.isVisible({ timeout: 8000 }).catch(() => false))) {
762
+
763
+ // Last safe fallback: a hard navigation avoids Facebook's occasionally
764
+ // stuck client-side router. No video has been selected yet, so this
765
+ // cannot create a duplicate post.
766
+ if (!reelsRoot) {
767
+ await navigateFacebookReelCreate(page, log);
768
+ await dismissBsOnboarding(page, log).catch(() => {});
769
+ reelsRoot = await findComposerRoot(8000);
770
+ }
771
+
772
+ if (!reelsRoot) {
684
773
  await dumpInventory(page, log, 'no-reels-modal');
685
774
  await dumpFailure(page, 'no-reels-modal', log);
686
- throw new Error('FB Reels modal "Tạo thước phim" did not open (kể cả sau fallback xpath //a[href*="/reels"])');
775
+ throw new Error('FB Reel composer did not open (đã thử nút Tạo thước phim và /reels/create/)');
687
776
  }
688
- log('info', '[fb-pw] Reels modal opened after xpath /reels fallback');
777
+ log('info', '[fb-pw] Reel composer opened after create-link fallback');
689
778
  }
690
779
  // Candidates inside the modal only:
691
780
  // - "Tải lên" — the explicit blue upload button at the bottom (preferred)
692
781
  // - "Thêm video" — the drop-zone label (clickable on some variants)
693
782
  // - English equivalents
694
783
  const addVideoCandidates = [
695
- reelsDialog.locator("div[role='button']:has-text('Tải lên')"),
696
- reelsDialog.locator("button:has-text('Tải lên')"),
697
- reelsDialog.locator("[aria-label='Tải lên']"),
698
- reelsDialog.locator("div[role='button']:has-text('Upload')"),
699
- reelsDialog.locator("[aria-label='Upload']"),
700
- reelsDialog.locator("div[role='button']:has-text('Thêm video')"),
701
- reelsDialog.locator("div[role='button']:has-text('Add video')"),
702
- reelsDialog.locator("[aria-label='Thêm video']"),
703
- reelsDialog.locator("[aria-label='Add video']"),
784
+ reelsRoot.locator("div[role='button']:has-text('Tải lên')"),
785
+ reelsRoot.locator("button:has-text('Tải lên')"),
786
+ reelsRoot.locator("[aria-label='Tải lên']"),
787
+ reelsRoot.locator("div[role='button']:has-text('Upload')"),
788
+ reelsRoot.locator("[aria-label='Upload']"),
789
+ reelsRoot.locator("div[role='button']:has-text('Thêm video')"),
790
+ reelsRoot.locator("div[role='button']:has-text('Add video')"),
791
+ reelsRoot.locator("[aria-label='Thêm video']"),
792
+ reelsRoot.locator("[aria-label='Add video']"),
704
793
  ];
705
794
  let videoSet = false;
706
795
  for (const loc of addVideoCandidates) {
@@ -724,7 +813,7 @@ async function runOnce({ page, payload, log }) {
724
813
  if (!videoSet) {
725
814
  // Fallback — find an input[type='file'] that lives INSIDE the Reels
726
815
  // modal (not the page-wall composer's hidden input).
727
- const fi = reelsDialog.locator("input[type='file']").last();
816
+ const fi = reelsRoot.locator("input[type='file']").last();
728
817
  if (await fi.count().catch(() => 0) > 0) {
729
818
  try {
730
819
  await setVideoFile(page, await fi.elementHandle(), videoPath, log);
@@ -2087,7 +2176,7 @@ async function runOnce({ page, payload, log }) {
2087
2176
  if (!pubWaitDone) {
2088
2177
  pubWaitDone = true;
2089
2178
  log('info', `[fb-pw] no "Tiếp" + no enabled publish at step ${step + 1} — waiting for "Đăng" to enable (large-video processing)…`);
2090
- const waitResult = await waitForPublishEnabled(page, publishVerbs, log, 180_000, 'fb-pw', {
2179
+ const waitResult = await waitForPublishEnabled(page, publishVerbs, log, PUBLISH_ENABLE_TIMEOUT_MS, 'fb-pw', {
2091
2180
  // Idempotent: once the field is filled, fillState makes this a
2092
2181
  // no-op. Until then it catches a lazily-mounted final form that
2093
2182
  // the first post-Tiếp pass raced past.
@@ -2106,7 +2195,7 @@ async function runOnce({ page, payload, log }) {
2106
2195
  throw new Error(`FB final description field not found at step ${step + 1}`);
2107
2196
  }
2108
2197
  if (waitResult.sawPresent) {
2109
- throw new Error(`FB publish remained disabled for 180s at step ${step + 1}`);
2198
+ throw new Error(`FB publish remained disabled for ${Math.round(PUBLISH_ENABLE_TIMEOUT_MS / 1000)}s at step ${step + 1}`);
2110
2199
  }
2111
2200
  }
2112
2201
  await dumpInventory(page, log, `no-advance-${step + 1}`);
@@ -2644,4 +2733,10 @@ module.exports.__testables = {
2644
2733
  waitForPublishEnabled,
2645
2734
  hasVisibleReelComposer,
2646
2735
  SAFE_RETRY_COMPOSER_CLOSED,
2736
+ navigateFacebookHome,
2737
+ navigateFacebookReelCreate,
2738
+ PUBLISH_ENABLE_TIMEOUT_MS,
2739
+ REEL_CREATE_SELECTORS,
2740
+ REEL_ENTRY_SELECTORS,
2741
+ FB_REEL_CREATE_URL,
2647
2742
  };