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