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.
- package/lib/nst-manager.js +23 -2
- package/package.json +1 -1
- package/scripts/upload_facebook.js +142 -47
package/lib/nst-manager.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
};
|