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.
- package/lib/nst-manager.js +23 -2
- package/package.json +1 -1
- package/scripts/lib/fb-guard.js +25 -1
- package/scripts/upload_facebook.js +126 -53
- package/scripts/upload_facebook_photo.js +9 -0
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
package/scripts/lib/fb-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|