channel-worker 2.5.60 → 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.60",
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,17 @@ const CODES = {
21
21
  banned: 'FB_ACCOUNT_BANNED',
22
22
  };
23
23
 
24
+ const PUBLISH_BLOCK_PHRASES = [
25
+ 'giới hạn tần suất',
26
+ 'để bảo vệ cộng đồng khỏi spam',
27
+ 'chỉnh sửa và đăng lại',
28
+ 'spam protection',
29
+ 'temporarily blocked',
30
+ 'you are temporarily blocked',
31
+ 'limit how often',
32
+ 'để giữ cho cộng đồng',
33
+ ];
34
+
24
35
  // Phrases FB shows on a disabled/suspended account (vi + en). Kept narrow on
25
36
  // purpose — a false "banned" would switch off a healthy account's nurture loop.
26
37
  const BANNED_RE = /(tài khoản của bạn đã bị (vô hiệu hoá|vô hiệu hóa|khoá|khóa|đình chỉ))|(your account has been disabled)|(we (have )?(suspended|disabled) your account)|(tài khoản này đã bị vô hiệu hoá)|(account (is )?(disabled|suspended|restricted) )/i;
@@ -97,4 +108,17 @@ async function assertAccountUsable(page, log) {
97
108
  throw err;
98
109
  }
99
110
 
100
- module.exports = { readAccountState, assertAccountUsable, CODES };
111
+ async function readPublishBlock(page) {
112
+ return page.evaluate((phrases) => {
113
+ const text = (document.body?.innerText || '').toLowerCase();
114
+ return phrases.find((p) => text.includes(p)) || null;
115
+ }, PUBLISH_BLOCK_PHRASES).catch(() => null);
116
+ }
117
+
118
+ function publishRateLimitError(blockHit) {
119
+ return new Error(`FB_PUBLISH_RATE_LIMITED: Facebook rate-limited / spam-blocked this account — publish was NOT accepted. Detected phrase: "${blockHit}". Auto-publish must stay paused until manually resumed.`);
120
+ }
121
+
122
+ module.exports = {
123
+ readAccountState, assertAccountUsable, readPublishBlock, publishRateLimitError, CODES,
124
+ };
@@ -17,9 +17,23 @@
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
21
  const path = require('path');
21
22
  const { downloadToTemp, safeUnlink } = require('./lib/download');
22
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
+
23
37
  async function firstVisible(locator, max = 5) {
24
38
  const n = Math.min(await locator.count().catch(() => 0), max);
25
39
  for (let i = 0; i < n; i++) {
@@ -48,6 +62,35 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', cl
48
62
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
49
63
  }
50
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
+
51
94
  // FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
52
95
  // 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
53
96
  // composer's bottom CTA. Playwright's hit-target check then blocks every
@@ -264,8 +307,15 @@ async function dismissBsOnboarding(page, log) {
264
307
  const cs = getComputedStyle(d);
265
308
  if (cs.visibility === 'hidden' || cs.display === 'none') continue;
266
309
  const txt = (d.innerText || '').slice(0, 400);
310
+ const aria = d.getAttribute('aria-label') || '';
267
311
  // Skip the Reels composer — that's the modal we just opened.
268
- 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;
269
319
  d.setAttribute('__fbpw_onboarding__', '1');
270
320
  return { tag: 'ok', preview: txt.slice(0, 80) };
271
321
  }
@@ -536,10 +586,14 @@ async function runOnce({ page, payload, log }) {
536
586
  // Page-wall flow opens a Reel modal whose publish flow either navigates
537
587
  // or shows a confirmation toast linking to the new reel.
538
588
  log('info', '[fb-pw] open https://www.facebook.com/ (page wall flow) …');
539
- await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
589
+ await navigateFacebookHome(page, log);
540
590
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
541
591
  await pause(page, 4000);
542
592
  log('info', `[fb-pw] home loaded — url=${page.url()}`);
593
+ // Fail with a stable account-state code before selectors turn a logout or
594
+ // checkpoint into a generic "Reels entry not found". The API consumes the
595
+ // code and opens the Facebook auto-publish circuit breaker.
596
+ await assertAccountUsable(page, log);
543
597
 
544
598
  // Look around before posting. The old flow was goto → 4s → straight into
545
599
  // "Thước phim", every single run: a session whose only actions are
@@ -655,47 +709,74 @@ async function runOnce({ page, payload, log }) {
655
709
  .locator("[role='dialog']")
656
710
  .filter({ has: page.locator(":scope :text-matches('^Tạo thước phim$|^Create reel$|^Create a reel$', 'i')") })
657
711
  .first();
658
- if (!(await reelsDialog.isVisible({ timeout: 6000 }).catch(() => false))) {
659
- // FALLBACK: some accounts render a different UI from the create-post
660
- // "Thước phim" entry the composer modal never mounts. Click the Reels
661
- // link (any <a href*='/reels'>) instead, then re-wait for the modal.
662
- log('warn', '[fb-pw] Reels modal not open — fallback: click <a href*="/reels"> then re-wait…');
663
- let xpClicked = false;
664
- try {
665
- const reelsLink = page.locator("xpath=//a[contains(@href, '/reels')]").first();
666
- if (await reelsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
667
- await reelsLink.click({ timeout: 4000 });
668
- xpClicked = true;
669
- log('info', '[fb-pw] clicked Reels link via xpath //a[contains(@href,"/reels")]');
670
- } else {
671
- log('warn', '[fb-pw] fallback: no visible <a href*="/reels"> found');
672
- }
673
- } catch (e) { log('warn', `[fb-pw] fallback Reels-link click failed: ${e.message.slice(0, 80)}`); }
674
- if (xpClicked) {
675
- 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);
676
743
  await dismissBsOnboarding(page, log).catch(() => {});
744
+ reelsRoot = await findComposerRoot(5000);
677
745
  }
678
- if (!(await reelsDialog.isVisible({ timeout: 8000 }).catch(() => false))) {
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);
755
+ await dismissBsOnboarding(page, log).catch(() => {});
756
+ reelsRoot = await findComposerRoot(8000);
757
+ }
758
+
759
+ if (!reelsRoot) {
679
760
  await dumpInventory(page, log, 'no-reels-modal');
680
761
  await dumpFailure(page, 'no-reels-modal', log);
681
- 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/)');
682
763
  }
683
- log('info', '[fb-pw] Reels modal opened after xpath /reels fallback');
764
+ log('info', '[fb-pw] Reel composer opened after create-link fallback');
684
765
  }
685
766
  // Candidates inside the modal only:
686
767
  // - "Tải lên" — the explicit blue upload button at the bottom (preferred)
687
768
  // - "Thêm video" — the drop-zone label (clickable on some variants)
688
769
  // - English equivalents
689
770
  const addVideoCandidates = [
690
- reelsDialog.locator("div[role='button']:has-text('Tải lên')"),
691
- reelsDialog.locator("button:has-text('Tải lên')"),
692
- reelsDialog.locator("[aria-label='Tải lên']"),
693
- reelsDialog.locator("div[role='button']:has-text('Upload')"),
694
- reelsDialog.locator("[aria-label='Upload']"),
695
- reelsDialog.locator("div[role='button']:has-text('Thêm video')"),
696
- reelsDialog.locator("div[role='button']:has-text('Add video')"),
697
- reelsDialog.locator("[aria-label='Thêm video']"),
698
- 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']"),
699
780
  ];
700
781
  let videoSet = false;
701
782
  for (const loc of addVideoCandidates) {
@@ -719,7 +800,7 @@ async function runOnce({ page, payload, log }) {
719
800
  if (!videoSet) {
720
801
  // Fallback — find an input[type='file'] that lives INSIDE the Reels
721
802
  // modal (not the page-wall composer's hidden input).
722
- const fi = reelsDialog.locator("input[type='file']").last();
803
+ const fi = reelsRoot.locator("input[type='file']").last();
723
804
  if (await fi.count().catch(() => 0) > 0) {
724
805
  try {
725
806
  await setVideoFile(page, await fi.elementHandle(), videoPath, log);
@@ -2082,7 +2163,7 @@ async function runOnce({ page, payload, log }) {
2082
2163
  if (!pubWaitDone) {
2083
2164
  pubWaitDone = true;
2084
2165
  log('info', `[fb-pw] no "Tiếp" + no enabled publish at step ${step + 1} — waiting for "Đăng" to enable (large-video processing)…`);
2085
- 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', {
2086
2167
  // Idempotent: once the field is filled, fillState makes this a
2087
2168
  // no-op. Until then it catches a lazily-mounted final form that
2088
2169
  // the first post-Tiếp pass raced past.
@@ -2101,7 +2182,7 @@ async function runOnce({ page, payload, log }) {
2101
2182
  throw new Error(`FB final description field not found at step ${step + 1}`);
2102
2183
  }
2103
2184
  if (waitResult.sawPresent) {
2104
- 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}`);
2105
2186
  }
2106
2187
  }
2107
2188
  await dumpInventory(page, log, `no-advance-${step + 1}`);
@@ -2210,25 +2291,9 @@ async function runOnce({ page, payload, log }) {
2210
2291
  // giới hạn tần suất bài đăng…" and "Chỉnh sửa và đăng lại" / "Đóng"
2211
2292
  // buttons. If we see this, the reel WAS NOT POSTED — fail loudly so
2212
2293
  // the user knows to wait + cool down the account.
2213
- const blockPhrases = [
2214
- 'giới hạn tần suất',
2215
- 'để bảo vệ cộng đồng khỏi spam',
2216
- 'chỉnh sửa và đăng lại',
2217
- 'spam protection',
2218
- 'temporarily blocked',
2219
- 'you are temporarily blocked',
2220
- 'limit how often',
2221
- 'để giữ cho cộng đồng',
2222
- ];
2223
- const blockHit = await page.evaluate((phrases) => {
2224
- const text = (document.body?.innerText || '').toLowerCase();
2225
- for (const p of phrases) {
2226
- if (text.includes(p)) return p;
2227
- }
2228
- return null;
2229
- }, blockPhrases).catch(() => null);
2294
+ const blockHit = await readPublishBlock(page);
2230
2295
  if (blockHit) {
2231
- throw new Error(`FB rate-limited / spam-blocked this account — publish was NOT accepted. Detected phrase: "${blockHit}". Cool down account before retry.`);
2296
+ throw publishRateLimitError(blockHit);
2232
2297
  }
2233
2298
 
2234
2299
  // 7b) COMMIT VERIFY + RETRY. A Playwright click on an enabled "Đăng" can
@@ -2627,6 +2692,11 @@ async function run(args) {
2627
2692
  try {
2628
2693
  return await runOnce(args);
2629
2694
  } catch (e) {
2695
+ // A checkpoint/logout can appear mid-composer. Prefer the coded account
2696
+ // error over the selector symptom so the server stops unattended retries.
2697
+ if (!/^FB_(?:ACCOUNT|PUBLISH)_/.test(String(e?.message || ''))) {
2698
+ try { await assertAccountUsable(page, log); } catch (accountErr) { throw accountErr; }
2699
+ }
2630
2700
  const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
2631
2701
  if (!safeRetry || attempt === 2) throw e;
2632
2702
  log('warn', `[fb-pw] composer vanished before publish — retrying the full upload once (${attempt}/1)`);
@@ -2650,4 +2720,7 @@ module.exports.__testables = {
2650
2720
  waitForPublishEnabled,
2651
2721
  hasVisibleReelComposer,
2652
2722
  SAFE_RETRY_COMPOSER_CLOSED,
2723
+ navigateFacebookHome,
2724
+ PUBLISH_ENABLE_TIMEOUT_MS,
2725
+ REEL_CREATE_SELECTORS,
2653
2726
  };
@@ -11,6 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
  const { humanType, pause, randInt: hRandInt } = require('./lib/human');
14
+ const { assertAccountUsable, readPublishBlock, publishRateLimitError } = require('./lib/fb-guard');
14
15
  const { downloadToTemp, safeUnlink } = require('./lib/download');
15
16
 
16
17
  async function firstVisible(locator, max = 8) {
@@ -148,6 +149,7 @@ async function run({ page, payload, log }) {
148
149
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
149
150
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
150
151
  await pause(page, 2500);
152
+ await assertAccountUsable(page, log);
151
153
 
152
154
  // 2) Open the "Tạo bài viết" composer via the "Ảnh/video" entry (this opens
153
155
  // the post composer directly in photo-attach mode). Fall back to the
@@ -312,6 +314,8 @@ async function run({ page, payload, log }) {
312
314
 
313
315
  // Verify commit + retry — a click can register without FB posting.
314
316
  await pause(page, 6000);
317
+ const blockHit = await readPublishBlock(page);
318
+ if (blockHit) throw publishRateLimitError(blockHit);
315
319
  for (let commitTry = 0; commitTry < 3 && (await composerStillOpen(page, publishVerbs)); commitTry++) {
316
320
  log('warn', `[fbphoto] composer still open — re-clicking publish (retry ${commitTry + 1}/3)`);
317
321
  if (await tagCta(publishVerbs)) await clickTaggedCta();
@@ -332,6 +336,11 @@ async function run({ page, payload, log }) {
332
336
 
333
337
  log('info', '[fbphoto] done');
334
338
  return { ok: true, post_url: postUrl, caption: fullCaption.slice(0, 80) };
339
+ } catch (e) {
340
+ if (!/^FB_(?:ACCOUNT|PUBLISH)_/.test(String(e?.message || ''))) {
341
+ try { await assertAccountUsable(page, log); } catch (accountErr) { throw accountErr; }
342
+ }
343
+ throw e;
335
344
  } finally {
336
345
  safeUnlink(imagePath);
337
346
  }