channel-worker 2.5.61 → 2.5.62

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.62",
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,19 @@ 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 REEL_CREATE_SELECTORS = [
26
+ "a[aria-label='Tạo thước phim']",
27
+ "a[aria-label='Create reel']",
28
+ "a[aria-label='Create a reel']",
29
+ "a[href*='/reels/create']",
30
+ ];
31
+ // Residential proxies can leave Facebook's final "Đang tải..." state active
32
+ // well past three minutes. The API only declares a publish command stale after
33
+ // 20 minutes, so give the real upload enough room instead of failing while FB
34
+ // is visibly still processing it.
35
+ const PUBLISH_ENABLE_TIMEOUT_MS = 10 * 60 * 1000;
36
+
24
37
  async function firstVisible(locator, max = 5) {
25
38
  const n = Math.min(await locator.count().catch(() => 0), max);
26
39
  for (let i = 0; i < n; i++) {
@@ -49,6 +62,35 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', cl
49
62
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
50
63
  }
51
64
 
65
+ // Facebook occasionally never resolves Playwright's `domcontentloaded` wait
66
+ // even though the Page shell is already usable. Treat that specific state as
67
+ // loaded; otherwise clear the half-navigation and retry once. This turns an
68
+ // intermittent navigation timeout into a guarded retry without hiding a dead
69
+ // proxy or an actually blank page.
70
+ async function navigateFacebookHome(page, log) {
71
+ let lastError = null;
72
+ for (let attempt = 1; attempt <= 2; attempt++) {
73
+ try {
74
+ await page.goto(FB_HOME_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
75
+ return;
76
+ } catch (e) {
77
+ lastError = e;
78
+ const shellReady = /^https:\/\/(?:www\.)?facebook\.com\//i.test(page.url())
79
+ && !!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);
80
+ if (shellReady) {
81
+ log('warn', `[fb-pw] home domcontentloaded timed out but Facebook shell is usable — continuing (attempt ${attempt})`);
82
+ return;
83
+ }
84
+ if (attempt < 2) {
85
+ log('warn', `[fb-pw] home navigation failed — retrying once: ${String(e?.message || e).split('\n')[0]}`);
86
+ await page.goto('about:blank', { waitUntil: 'commit', timeout: 15_000 }).catch(() => {});
87
+ await pause(page, 1500);
88
+ }
89
+ }
90
+ }
91
+ throw lastError || new Error('FB home navigation failed');
92
+ }
93
+
52
94
  // FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
53
95
  // 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
54
96
  // composer's bottom CTA. Playwright's hit-target check then blocks every
@@ -265,8 +307,15 @@ async function dismissBsOnboarding(page, log) {
265
307
  const cs = getComputedStyle(d);
266
308
  if (cs.visibility === 'hidden' || cs.display === 'none') continue;
267
309
  const txt = (d.innerText || '').slice(0, 400);
310
+ const aria = d.getAttribute('aria-label') || '';
268
311
  // 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;
312
+ if (/Tạo thước phim|Create reel|Create a reel|Tạo thư�?c phim/i.test(`${aria}\n${txt}`)) continue;
313
+ // Do not classify every anonymous dialog with an X as onboarding.
314
+ // Facebook's newly-mounted Reel composer can briefly have no title;
315
+ // the old code saw that empty dialog and clicked its "Đóng" button.
316
+ const knownOnboarding = /^js_/i.test(aria)
317
+ || /Đã hiểu|Got it|Bỏ qua|Skip|Để sau|Not now|Tiếp tục|Continue/i.test(txt);
318
+ if (!knownOnboarding) continue;
270
319
  d.setAttribute('__fbpw_onboarding__', '1');
271
320
  return { tag: 'ok', preview: txt.slice(0, 80) };
272
321
  }
@@ -537,7 +586,7 @@ async function runOnce({ page, payload, log }) {
537
586
  // Page-wall flow opens a Reel modal whose publish flow either navigates
538
587
  // or shows a confirmation toast linking to the new reel.
539
588
  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 });
589
+ await navigateFacebookHome(page, log);
541
590
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
542
591
  await pause(page, 4000);
543
592
  log('info', `[fb-pw] home loaded — url=${page.url()}`);
@@ -660,47 +709,74 @@ async function runOnce({ page, payload, log }) {
660
709
  .locator("[role='dialog']")
661
710
  .filter({ has: page.locator(":scope :text-matches('^Tạo thước phim$|^Create reel$|^Create a reel$', 'i')") })
662
711
  .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);
712
+ const fullPageComposer = page.locator("main, [role='main']").first();
713
+ const findComposerRoot = async (timeout = 1000) => {
714
+ if (await reelsDialog.isVisible({ timeout }).catch(() => false)) return reelsDialog;
715
+ if (/\/reels\/create\/?/i.test(page.url())
716
+ && await fullPageComposer.isVisible({ timeout: 1000 }).catch(() => false)) {
717
+ 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']")
718
+ .count().catch(() => 0);
719
+ if (hasUploadUi) return fullPageComposer;
720
+ }
721
+ return null;
722
+ };
723
+
724
+ let reelsRoot = await findComposerRoot(6000);
725
+ if (!reelsRoot) {
726
+ // FALLBACK: target the CREATE link, never the generic /reels feed link.
727
+ // Inventory from the failing accounts contains both; the old broad xpath
728
+ // picked the feed link and left us on the wall with no composer.
729
+ log('warn', '[fb-pw] Reels composer not open — fallback: click exact "Tạo thước phim" create link…');
730
+ let createClicked = false;
731
+ for (const sel of REEL_CREATE_SELECTORS) {
732
+ const link = await firstVisible(page.locator(sel), 3);
733
+ if (!link) continue;
734
+ try {
735
+ await humanClick(page, link, { timeout: 4000 });
736
+ createClicked = true;
737
+ log('info', `[fb-pw] clicked Reel create link via "${sel}"`);
738
+ break;
739
+ } catch (e) { log('warn', `[fb-pw] create-link click failed (${sel}): ${e.message.slice(0, 80)}`); }
740
+ }
741
+ if (createClicked) {
742
+ await pause(page, 7000);
743
+ await dismissBsOnboarding(page, log).catch(() => {});
744
+ reelsRoot = await findComposerRoot(5000);
745
+ }
746
+
747
+ // Last safe fallback: a hard navigation avoids Facebook's occasionally
748
+ // stuck client-side router. No video has been selected yet, so this
749
+ // cannot create a duplicate post.
750
+ if (!reelsRoot) {
751
+ log('warn', '[fb-pw] create link still produced no composer — hard navigating to /reels/create/…');
752
+ await page.goto('https://www.facebook.com/reels/create/', { waitUntil: 'commit', timeout: 60_000 });
753
+ await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {});
754
+ await pause(page, 7000);
681
755
  await dismissBsOnboarding(page, log).catch(() => {});
756
+ reelsRoot = await findComposerRoot(8000);
682
757
  }
683
- if (!(await reelsDialog.isVisible({ timeout: 8000 }).catch(() => false))) {
758
+
759
+ if (!reelsRoot) {
684
760
  await dumpInventory(page, log, 'no-reels-modal');
685
761
  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"])');
762
+ throw new Error('FB Reel composer did not open (đã thử nút Tạo thước phim và /reels/create/)');
687
763
  }
688
- log('info', '[fb-pw] Reels modal opened after xpath /reels fallback');
764
+ log('info', '[fb-pw] Reel composer opened after create-link fallback');
689
765
  }
690
766
  // Candidates inside the modal only:
691
767
  // - "Tải lên" — the explicit blue upload button at the bottom (preferred)
692
768
  // - "Thêm video" — the drop-zone label (clickable on some variants)
693
769
  // - English equivalents
694
770
  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']"),
771
+ reelsRoot.locator("div[role='button']:has-text('Tải lên')"),
772
+ reelsRoot.locator("button:has-text('Tải lên')"),
773
+ reelsRoot.locator("[aria-label='Tải lên']"),
774
+ reelsRoot.locator("div[role='button']:has-text('Upload')"),
775
+ reelsRoot.locator("[aria-label='Upload']"),
776
+ reelsRoot.locator("div[role='button']:has-text('Thêm video')"),
777
+ reelsRoot.locator("div[role='button']:has-text('Add video')"),
778
+ reelsRoot.locator("[aria-label='Thêm video']"),
779
+ reelsRoot.locator("[aria-label='Add video']"),
704
780
  ];
705
781
  let videoSet = false;
706
782
  for (const loc of addVideoCandidates) {
@@ -724,7 +800,7 @@ async function runOnce({ page, payload, log }) {
724
800
  if (!videoSet) {
725
801
  // Fallback — find an input[type='file'] that lives INSIDE the Reels
726
802
  // modal (not the page-wall composer's hidden input).
727
- const fi = reelsDialog.locator("input[type='file']").last();
803
+ const fi = reelsRoot.locator("input[type='file']").last();
728
804
  if (await fi.count().catch(() => 0) > 0) {
729
805
  try {
730
806
  await setVideoFile(page, await fi.elementHandle(), videoPath, log);
@@ -2087,7 +2163,7 @@ async function runOnce({ page, payload, log }) {
2087
2163
  if (!pubWaitDone) {
2088
2164
  pubWaitDone = true;
2089
2165
  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', {
2166
+ const waitResult = await waitForPublishEnabled(page, publishVerbs, log, PUBLISH_ENABLE_TIMEOUT_MS, 'fb-pw', {
2091
2167
  // Idempotent: once the field is filled, fillState makes this a
2092
2168
  // no-op. Until then it catches a lazily-mounted final form that
2093
2169
  // the first post-Tiếp pass raced past.
@@ -2106,7 +2182,7 @@ async function runOnce({ page, payload, log }) {
2106
2182
  throw new Error(`FB final description field not found at step ${step + 1}`);
2107
2183
  }
2108
2184
  if (waitResult.sawPresent) {
2109
- throw new Error(`FB publish remained disabled for 180s at step ${step + 1}`);
2185
+ throw new Error(`FB publish remained disabled for ${Math.round(PUBLISH_ENABLE_TIMEOUT_MS / 1000)}s at step ${step + 1}`);
2110
2186
  }
2111
2187
  }
2112
2188
  await dumpInventory(page, log, `no-advance-${step + 1}`);
@@ -2644,4 +2720,7 @@ module.exports.__testables = {
2644
2720
  waitForPublishEnabled,
2645
2721
  hasVisibleReelComposer,
2646
2722
  SAFE_RETRY_COMPOSER_CLOSED,
2723
+ navigateFacebookHome,
2724
+ PUBLISH_ENABLE_TIMEOUT_MS,
2725
+ REEL_CREATE_SELECTORS,
2647
2726
  };