channel-worker 2.5.43 → 2.5.45

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/api-client.js CHANGED
@@ -68,7 +68,7 @@ class ApiClient {
68
68
  async getNextCommand(workerId) {
69
69
  // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
70
  // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,scrape_affiliate_products,ingest_shopee_product';
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,scrape_affiliate_products,ingest_shopee_product';
72
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
73
73
  }
74
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.43",
3
+ "version": "2.5.45",
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": {
@@ -0,0 +1,100 @@
1
+ // Facebook session guard — shared by warmup_facebook.js and nurture_facebook.js.
2
+ //
3
+ // WHY: both scripts drive a long-lived NST profile that is SUPPOSED to be logged
4
+ // into Facebook. When that session dies (cookie expired, FB forced a re-login,
5
+ // checkpoint, or the account got disabled), the scripts used to fail with a
6
+ // generic "Reels entry not found" / "no posts found" — indistinguishable from a
7
+ // layout change. The nurture scheduler then kept driving a dead profile once a
8
+ // day, silently, for weeks.
9
+ //
10
+ // So: detect the account state explicitly and throw an error whose message
11
+ // STARTS with a machine-readable code. command-poller posts the thrown message
12
+ // verbatim (truncated to 500 chars) into the command's `error`, and the API's
13
+ // result hook parses the prefix → writes channel.nurture.account_status →
14
+ // disables the scheduler → the notification centre rings.
15
+ //
16
+ // Codes: FB_ACCOUNT_LOGGED_OUT | FB_ACCOUNT_CHECKPOINT | FB_ACCOUNT_BANNED
17
+
18
+ const CODES = {
19
+ logged_out: 'FB_ACCOUNT_LOGGED_OUT',
20
+ checkpoint: 'FB_ACCOUNT_CHECKPOINT',
21
+ banned: 'FB_ACCOUNT_BANNED',
22
+ };
23
+
24
+ // Phrases FB shows on a disabled/suspended account (vi + en). Kept narrow on
25
+ // purpose — a false "banned" would switch off a healthy account's nurture loop.
26
+ 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;
27
+ // Identity/security interstitials — recoverable by a human, not by the script.
28
+ const CHECKPOINT_RE = /(xác nhận danh tính)|(xác minh danh tính)|(hãy xác nhận đây là bạn)|(confirm your identity)|(help us confirm)|(we need to confirm)|(bảo mật tài khoản của bạn)|(unusual (login|activity))|(hoạt động bất thường)/i;
29
+
30
+ // Read the account state of whatever page is currently open.
31
+ // Returns { status: 'ok'|'logged_out'|'checkpoint'|'banned', detail }.
32
+ async function readAccountState(page) {
33
+ const url = page.url() || '';
34
+ // URL is the strongest signal — FB redirects hard on all three states.
35
+ if (/\/checkpoint\//i.test(url)) return { status: 'checkpoint', detail: `redirected to ${url.slice(0, 120)}` };
36
+ if (/facebook\.com\/(login|recover)\b|login\.php|\/login\/device-based/i.test(url)) {
37
+ return { status: 'logged_out', detail: `redirected to ${url.slice(0, 120)}` };
38
+ }
39
+
40
+ const dom = await page.evaluate(() => {
41
+ const vis = (el) => {
42
+ if (!el) return false;
43
+ const r = el.getBoundingClientRect();
44
+ return r.width > 8 && r.height > 8 && el.offsetParent !== null;
45
+ };
46
+ // Login form present = the session is gone (FB renders email+pass on the
47
+ // logged-out home page too, so this catches a soft logout with no redirect).
48
+ const hasLoginForm = vis(document.querySelector("input[name='email'], input[name='pass'], input#email, input#pass"));
49
+ // A rendered FEED is proof of a live session: FB serves no posts on a
50
+ // login / checkpoint / disabled-account page. This matters because the
51
+ // text patterns below would otherwise fire on somebody's viral post about
52
+ // *their* account being disabled — a false "banned" would switch off a
53
+ // perfectly healthy account's nurture schedule.
54
+ const hasFeed = !!document.querySelector("[role='article']") ||
55
+ !!document.querySelector("[role='feed']");
56
+ return {
57
+ hasLoginForm,
58
+ hasFeed,
59
+ text: (document.body?.innerText || '').slice(0, 4000),
60
+ };
61
+ }).catch(() => null);
62
+
63
+ if (!dom) return { status: 'ok', detail: '' }; // page not readable — let the caller's own checks decide
64
+ if (dom.hasFeed) return { status: 'ok', detail: '' }; // posts render → session is alive
65
+
66
+ if (BANNED_RE.test(dom.text)) {
67
+ const m = dom.text.match(BANNED_RE);
68
+ return { status: 'banned', detail: (m && m[0] ? m[0] : 'disabled-account notice').slice(0, 160) };
69
+ }
70
+ if (CHECKPOINT_RE.test(dom.text)) {
71
+ const m = dom.text.match(CHECKPOINT_RE);
72
+ return { status: 'checkpoint', detail: (m && m[0] ? m[0] : 'checkpoint notice').slice(0, 160) };
73
+ }
74
+ if (dom.hasLoginForm) {
75
+ return { status: 'logged_out', detail: 'login form on page (session expired)' };
76
+ }
77
+ return { status: 'ok', detail: '' };
78
+ }
79
+
80
+ // Throw a coded error if the account is not usable. Call right after landing on
81
+ // facebook.com, and again whenever a step fails unexpectedly (a mid-session
82
+ // checkpoint is common — FB throws one the moment automated-looking activity
83
+ // trips a threshold).
84
+ async function assertAccountUsable(page, log) {
85
+ const st = await readAccountState(page);
86
+ if (st.status === 'ok') return st;
87
+ const code = CODES[st.status];
88
+ const human = st.status === 'banned'
89
+ ? 'Account Facebook đã bị vô hiệu hoá/khoá — phải xử lý bằng tay.'
90
+ : st.status === 'checkpoint'
91
+ ? 'Facebook chặn ở bước xác minh (checkpoint) — phải mở profile và xác minh bằng tay.'
92
+ : 'Profile đã ĐĂNG XUẤT khỏi Facebook — đăng nhập lại trong NST profile rồi bật lại.';
93
+ if (log) log('info', `[fb-guard] ${st.status}: ${st.detail}`);
94
+ // Code FIRST — command-poller truncates the message at 500 chars.
95
+ const err = new Error(`${code}: ${human} (${st.detail})`);
96
+ err.account_status = st.status;
97
+ throw err;
98
+ }
99
+
100
+ module.exports = { readAccountState, assertAccountUsable, CODES };
@@ -0,0 +1,369 @@
1
+ // Facebook account nurture — ages an account so FB trusts it (the owner then
2
+ // creates the Page BY HAND; this script never touches Page creation).
3
+ //
4
+ // Runs on the channel's own NST profile + residential proxy, once or twice a
5
+ // day, driven by the API scheduler. One run = one "session": browse the real
6
+ // newsfeed the way a person does, and — only once the account is old enough —
7
+ // leave a few likes.
8
+ //
9
+ // PHASES (server decides, passed in payload.phase):
10
+ // 1 (day 1-3) read only — scroll, pause to read, watch a feed video. ZERO taps.
11
+ // 2 (day 4-7) + likes (3-5), + open a post to read comments.
12
+ // 3 (day 8+) + likes (5-10), + follow a page that shows up in the feed.
13
+ //
14
+ // NOT here: messaging (dropped — messaging strangers is the fastest way to a
15
+ // spam checkpoint) and joining groups (approval questions strand the script).
16
+ //
17
+ // Contract: run({ page, payload, log }) → { phase, posts_seen, likes,
18
+ // videos_watched, pages_followed, posts_opened, duration_sec }.
19
+ // A dead session (logged out / checkpoint / disabled) throws a CODED error via
20
+ // lib/fb-guard so the API can stop the schedule and raise a notification.
21
+
22
+ const { assertAccountUsable } = require('./lib/fb-guard');
23
+
24
+ // ─── helpers ────────────────────────────────────────────────────────────────
25
+ function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
26
+ function chance(p) { return Math.random() < p; }
27
+
28
+ // Dismiss FB cookie / cross-sell / "not now" popups. Best-effort, never throws.
29
+ // Never touches the composer or a reel viewer.
30
+ async function dismissDialogs(page, log) {
31
+ const verbs = ['Cho phép tất cả cookie', 'Allow all cookies', 'Để sau', 'Not now', 'Lúc khác', 'Không phải bây giờ', 'Bỏ qua', 'Skip', 'Đóng', 'Close', 'OK', 'Đã hiểu', 'Got it'];
32
+ for (let round = 0; round < 3; round++) {
33
+ const hit = await page.evaluate((vs) => {
34
+ for (const dlg of document.querySelectorAll("[role='dialog']")) {
35
+ const r = dlg.getBoundingClientRect();
36
+ if (r.width < 8 || r.height < 8) continue;
37
+ const aria = (dlg.getAttribute('aria-label') || '').toLowerCase();
38
+ if (/tạo bài viết|create post|thước phim|reel/i.test(aria)) continue;
39
+ for (const v of vs) {
40
+ for (const b of dlg.querySelectorAll("[role='button'], button")) {
41
+ const t = (b.innerText || '').trim();
42
+ if ((t === v || (b.getAttribute('aria-label') || '').trim() === v) && b.offsetParent !== null) {
43
+ b.setAttribute('__nur_dismiss__', '1');
44
+ return v;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return null;
50
+ }, verbs).catch(() => null);
51
+ if (!hit) break;
52
+ try { await page.locator("[__nur_dismiss__='1']").click({ timeout: 2500 }); } catch {}
53
+ await page.evaluate(() => document.querySelectorAll('[__nur_dismiss__]').forEach(e => e.removeAttribute('__nur_dismiss__'))).catch(() => {});
54
+ await page.waitForTimeout(800);
55
+ }
56
+ }
57
+
58
+ // Count feed posts that entered the viewport since the last call, marking them
59
+ // so they're counted once. Sponsored posts are marked too (so they're never
60
+ // liked or watched) but do NOT count as "seen" — an ad isn't reading.
61
+ async function markPostsInView(page) {
62
+ return page.evaluate(() => {
63
+ let fresh = 0;
64
+ for (const art of document.querySelectorAll("[role='article']")) {
65
+ // Comments render as NESTED articles — only top-level posts count.
66
+ if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
67
+ if (art.hasAttribute('data-nur-seen')) continue;
68
+ const r = art.getBoundingClientRect();
69
+ const inView = r.bottom > 0 && r.top < window.innerHeight && r.height > 80;
70
+ if (!inView) continue;
71
+ art.setAttribute('data-nur-seen', '1');
72
+ const head = (art.innerText || '').slice(0, 400);
73
+ if (/Được tài trợ|Sponsored|Tài trợ/i.test(head)) { art.setAttribute('data-nur-ad', '1'); continue; }
74
+ fresh++;
75
+ }
76
+ return fresh;
77
+ }).catch(() => 0);
78
+ }
79
+
80
+ // Like ONE organic post near the centre of the viewport. Returns true on a
81
+ // confirmed like. Deliberately picky: skips ads, skips already-liked posts, and
82
+ // never grabs a COMMENT's like button (nested article).
83
+ async function likeVisiblePost(page, log) {
84
+ const found = await page.evaluate(() => {
85
+ const mid = window.innerHeight / 2;
86
+ let best = null, bestDist = Infinity;
87
+ for (const art of document.querySelectorAll("[role='article']")) {
88
+ if (art.parentElement && art.parentElement.closest("[role='article']")) continue; // comment
89
+ if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-liked')) continue;
90
+ const r = art.getBoundingClientRect();
91
+ if (r.bottom < 60 || r.top > window.innerHeight - 60 || r.height < 120) continue;
92
+ const dist = Math.abs((r.top + r.bottom) / 2 - mid);
93
+ if (dist < bestDist) { best = art; bestDist = dist; }
94
+ }
95
+ if (!best) return null;
96
+ if (/Được tài trợ|Sponsored/i.test((best.innerText || '').slice(0, 400))) { best.setAttribute('data-nur-ad', '1'); return null; }
97
+ for (const b of best.querySelectorAll("[role='button']")) {
98
+ const aria = (b.getAttribute('aria-label') || '').trim();
99
+ if (!/^(thích|like)$/i.test(aria)) continue;
100
+ if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
101
+ if (b.closest("[role='article']") !== best) continue; // belongs to a comment
102
+ const r = b.getBoundingClientRect();
103
+ if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
104
+ best.setAttribute('data-nur-liked', 'pending');
105
+ b.setAttribute('__nur_like__', '1');
106
+ return { aria };
107
+ }
108
+ return null;
109
+ }).catch(() => null);
110
+ if (!found) return false;
111
+
112
+ const btn = page.locator("[__nur_like__='1']").first();
113
+ let ok = false;
114
+ try {
115
+ await btn.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
116
+ // Hover first, then a short beat — a real cursor travels before it clicks.
117
+ await btn.hover({ timeout: 3000 }).catch(() => {});
118
+ await page.waitForTimeout(randInt(500, 1400));
119
+ // MUST be a Playwright click: an el.click() from page.evaluate is synthetic
120
+ // and React's handler ignores it (same lesson as the YouTube ad-skip).
121
+ await btn.click({ timeout: 4000 });
122
+ await page.waitForTimeout(randInt(900, 1800));
123
+ ok = await btn.evaluate((el) => el.getAttribute('aria-pressed') === 'true').catch(() => true);
124
+ if (ok === null || ok === undefined) ok = true;
125
+ } catch (e) {
126
+ log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
127
+ }
128
+ await page.evaluate((liked) => {
129
+ document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'));
130
+ document.querySelectorAll("[data-nur-liked='pending']").forEach(e => e.setAttribute('data-nur-liked', liked ? '1' : 'fail'));
131
+ }, !!ok).catch(() => {});
132
+ if (ok) log('info', '[nurture-fb] liked a post');
133
+ return !!ok;
134
+ }
135
+
136
+ // Watch a video that's playing in the feed, counting only seconds where
137
+ // playback actually ADVANCES (a frozen/buffering player must not bank time).
138
+ async function watchFeedVideo(page, minSec, maxSec, log) {
139
+ const has = await page.evaluate(() => {
140
+ const vids = [...document.querySelectorAll('video')];
141
+ for (const v of vids) {
142
+ const r = v.getBoundingClientRect();
143
+ if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
144
+ const art = v.closest("[role='article']");
145
+ if (art && art.hasAttribute('data-nur-ad')) continue; // never watch ads
146
+ if (v.hasAttribute('data-nur-watched')) continue;
147
+ v.setAttribute('data-nur-watched', '1');
148
+ if (v.paused) { try { v.play(); } catch {} }
149
+ return true;
150
+ }
151
+ return false;
152
+ }).catch(() => false);
153
+ if (!has) return false;
154
+
155
+ const budgetMs = randInt(minSec, maxSec) * 1000;
156
+ let watched = 0, lastT = -1, frozen = 0;
157
+ while (watched < budgetMs) {
158
+ const st = await page.evaluate(() => {
159
+ const v = document.querySelector("video[data-nur-watched='1']");
160
+ if (!v) return null;
161
+ if (v.paused && !v.ended) { try { v.play(); } catch {} }
162
+ return { t: Number(v.currentTime) || 0, ended: !!v.ended, dur: (isFinite(v.duration) && v.duration > 0) ? v.duration : 0, loop: !!v.loop };
163
+ }).catch(() => null);
164
+ if (!st) break;
165
+ if (!st.loop && (st.ended || (st.dur > 0 && st.t >= st.dur - 0.6))) break;
166
+ const advanced = st.t > lastT + 0.2 || st.t < lastT - 0.5; // forward, or loop wrap
167
+ if (advanced) { watched += 1000; frozen = 0; } else if (++frozen >= 6) break;
168
+ lastT = st.t;
169
+ await page.waitForTimeout(1000);
170
+ }
171
+ // Release the marker so a later video in the feed can be picked.
172
+ await page.evaluate(() => document.querySelectorAll("video[data-nur-watched='1']").forEach(v => v.setAttribute('data-nur-watched', 'done'))).catch(() => {});
173
+ if (watched > 0) log('info', `[nurture-fb] watched a feed video ~${Math.round(watched / 1000)}s`);
174
+ return watched > 0;
175
+ }
176
+
177
+ // Open a post's permalink, read the comments for a bit, then go back.
178
+ // Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
179
+ async function openPostAndRead(page, log) {
180
+ const urlBefore = page.url();
181
+ const marked = await page.evaluate(() => {
182
+ const mid = window.innerHeight / 2;
183
+ let best = null, bestDist = Infinity;
184
+ for (const art of document.querySelectorAll("[role='article']")) {
185
+ if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
186
+ if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-opened')) continue;
187
+ const r = art.getBoundingClientRect();
188
+ if (r.bottom < 60 || r.top > window.innerHeight - 60) continue;
189
+ const dist = Math.abs((r.top + r.bottom) / 2 - mid);
190
+ if (dist < bestDist) { best = art; bestDist = dist; }
191
+ }
192
+ if (!best) return false;
193
+ for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
194
+ const r = a.getBoundingClientRect();
195
+ if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
196
+ best.setAttribute('data-nur-opened', '1');
197
+ a.setAttribute('__nur_open__', '1');
198
+ return true;
199
+ }
200
+ return false;
201
+ }).catch(() => false);
202
+ if (!marked) return false;
203
+
204
+ try {
205
+ await page.locator("[__nur_open__='1']").first().click({ timeout: 4000 });
206
+ } catch {
207
+ await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
208
+ return false;
209
+ }
210
+ await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
211
+ await page.waitForTimeout(randInt(2500, 4000));
212
+
213
+ // Read comments: a couple of small scrolls with human pauses.
214
+ for (let i = 0; i < randInt(2, 4); i++) {
215
+ await page.mouse.wheel(0, randInt(200, 600)).catch(() => {});
216
+ await page.waitForTimeout(randInt(1500, 4000));
217
+ }
218
+ log('info', '[nurture-fb] opened a post and read comments');
219
+
220
+ // Back to the feed. A photo/post overlay closes with Escape, a real
221
+ // navigation needs goBack — try both, feed check happens next loop.
222
+ if (page.url() !== urlBefore) {
223
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
224
+ } else {
225
+ await page.keyboard.press('Escape').catch(() => {});
226
+ }
227
+ await page.waitForTimeout(randInt(1500, 3000));
228
+ return true;
229
+ }
230
+
231
+ // Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
232
+ // Only clicks a Follow/Like-Page button that's already on screen — never hunts.
233
+ async function followVisiblePage(page, log) {
234
+ const marked = await page.evaluate(() => {
235
+ for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
236
+ const t = (b.innerText || '').trim();
237
+ const aria = (b.getAttribute('aria-label') || '').trim();
238
+ const label = t || aria;
239
+ if (!/^(theo dõi|follow|thích trang|like page)$/i.test(label)) continue;
240
+ const r = b.getBoundingClientRect();
241
+ if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
242
+ if (r.bottom < 0 || r.top > window.innerHeight) continue;
243
+ const art = b.closest("[role='article']");
244
+ if (art && art.hasAttribute('data-nur-ad')) continue; // not an ad's CTA
245
+ b.setAttribute('__nur_follow__', '1');
246
+ return label;
247
+ }
248
+ return null;
249
+ }).catch(() => null);
250
+ if (!marked) return false;
251
+ let ok = false;
252
+ try {
253
+ const btn = page.locator("[__nur_follow__='1']").first();
254
+ await btn.hover({ timeout: 2500 }).catch(() => {});
255
+ await page.waitForTimeout(randInt(400, 1100));
256
+ await btn.click({ timeout: 4000 });
257
+ await page.waitForTimeout(randInt(1200, 2200));
258
+ ok = true;
259
+ log('info', `[nurture-fb] followed a page ("${marked}")`);
260
+ } catch {}
261
+ await page.evaluate(() => document.querySelectorAll('[__nur_follow__]').forEach(e => e.removeAttribute('__nur_follow__'))).catch(() => {});
262
+ return ok;
263
+ }
264
+
265
+ // ─── main ───────────────────────────────────────────────────────────────────
266
+ async function run({ page, payload, log }) {
267
+ const t0 = Date.now();
268
+ const cfg = payload.config || {};
269
+ const phase = Math.max(1, Math.min(3, parseInt(payload.phase, 10) || 1));
270
+ const dayIndex = parseInt(payload.day_index, 10) || 1;
271
+
272
+ const sessionSec = randInt(cfg.session_min_sec ?? 480, cfg.session_max_sec ?? 900);
273
+ const likesTarget = randInt(cfg.likes_min ?? 0, cfg.likes_max ?? 0);
274
+ const likeGap = Math.max(1, cfg.like_gap_posts ?? 5);
275
+ const videosMax = cfg.videos_max ?? 3;
276
+ const openPostMax = cfg.open_post_max ?? 0;
277
+ const followMax = cfg.follow_page_max ?? 0;
278
+ const watchMin = cfg.video_watch_min_sec ?? 10;
279
+ const watchMax = Math.max(watchMin, cfg.video_watch_max_sec ?? 30);
280
+
281
+ log('info', `[nurture-fb] session start — phase ${phase} (ngày ${dayIndex}), ~${Math.round(sessionSec / 60)} phút, like tối đa ${likesTarget}, video ≤${videosMax}`);
282
+
283
+ page.on('dialog', (d) => { d.accept().catch(() => {}); });
284
+
285
+ await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
286
+ await page.waitForTimeout(randInt(3000, 5000));
287
+ // Guard FIRST — everything below assumes a live logged-in session.
288
+ await assertAccountUsable(page, log);
289
+ await dismissDialogs(page, log);
290
+
291
+ let postsSeen = 0, likes = 0, videos = 0, pagesFollowed = 0, postsOpened = 0;
292
+ let postsSinceLike = likeGap; // allow the first like once enough posts scroll by
293
+ let ticks = 0, emptyTicks = 0;
294
+ const deadline = t0 + sessionSec * 1000;
295
+
296
+ while (Date.now() < deadline) {
297
+ ticks++;
298
+ const fresh = await markPostsInView(page);
299
+ postsSeen += fresh;
300
+ postsSinceLike += fresh;
301
+ // Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
302
+ // silent interstitial. Re-check the account, then nudge back to the top.
303
+ if (fresh === 0) {
304
+ if (++emptyTicks >= 15) {
305
+ emptyTicks = 0;
306
+ await assertAccountUsable(page, log);
307
+ log('info', '[nurture-fb] feed idle — quay lại đầu trang');
308
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' })).catch(() => {});
309
+ await page.waitForTimeout(randInt(2000, 4000));
310
+ }
311
+ } else {
312
+ emptyTicks = 0;
313
+ }
314
+
315
+ // Watch a feed video now and then (all phases — watching is passive).
316
+ if (videos < videosMax && chance(0.22)) {
317
+ if (await watchFeedVideo(page, watchMin, watchMax, log)) videos++;
318
+ }
319
+
320
+ // Like — phase 2+ only, never two posts in a row (enforced by likeGap).
321
+ if (likes < likesTarget && postsSinceLike >= likeGap) {
322
+ if (await likeVisiblePost(page, log)) { likes++; postsSinceLike = 0; }
323
+ else postsSinceLike = Math.max(0, postsSinceLike - 1); // retry a bit later
324
+ }
325
+
326
+ // Open a post to read comments — phase 2+.
327
+ if (postsOpened < openPostMax && chance(0.05)) {
328
+ if (await openPostAndRead(page, log)) postsOpened++;
329
+ }
330
+
331
+ // Follow a page the feed already showed — phase 3.
332
+ if (pagesFollowed < followMax && chance(0.06)) {
333
+ if (await followVisiblePage(page, log)) pagesFollowed++;
334
+ }
335
+
336
+ // Human-shaped movement: mostly forward, sometimes a long read, sometimes
337
+ // a scroll back up (people re-read what they just passed).
338
+ const roll = Math.random();
339
+ if (roll < 0.15) {
340
+ await page.waitForTimeout(randInt(6000, 20000)); // stop and read
341
+ } else if (roll < 0.23) {
342
+ await page.mouse.wheel(0, -randInt(150, 400)).catch(() => {});
343
+ await page.waitForTimeout(randInt(1200, 3000));
344
+ } else {
345
+ await page.mouse.wheel(0, randInt(250, 900)).catch(() => {});
346
+ await page.waitForTimeout(randInt(800, 4000));
347
+ }
348
+
349
+ // Mid-session health check (~ every 12 ticks). FB throws a checkpoint the
350
+ // moment activity trips a threshold — better to stop than to keep poking.
351
+ if (ticks % 12 === 0) await assertAccountUsable(page, log);
352
+ }
353
+
354
+ const durationSec = Math.round((Date.now() - t0) / 1000);
355
+ log('info', `[nurture-fb] session done — posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} pages=${pagesFollowed} opened=${postsOpened} duration=${durationSec}s`);
356
+ return {
357
+ phase,
358
+ day_index: dayIndex,
359
+ posts_seen: postsSeen,
360
+ likes,
361
+ videos_watched: videos,
362
+ pages_followed: pagesFollowed,
363
+ posts_opened: postsOpened,
364
+ duration_sec: durationSec,
365
+ account_status: 'ok',
366
+ };
367
+ }
368
+
369
+ module.exports = { run };
@@ -348,10 +348,17 @@ async function setVideoFile(page, inputHandle, filePath, log, tag = 'fb-pw') {
348
348
  // throw no-advance. Poll until a bottom-half publish-verb button is present
349
349
  // AND enabled. Returns true once enabled; false on timeout, or fast-false if no
350
350
  // publish button ever appears (we're not actually on the final step).
351
- async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw') {
351
+ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw', { onPoll = null } = {}) {
352
352
  const deadline = Date.now() + timeoutMs;
353
353
  let announced = false, sawPresent = false, absent = 0;
354
354
  while (Date.now() < deadline) {
355
+ // The final metadata form mounts lazily on some FB cohorts. The first
356
+ // fillMetadata() immediately after Tiếp can run before its textbox exists,
357
+ // leaving the form blank and Đăng disabled forever. Let the caller retry
358
+ // its idempotent metadata fill while we wait for video processing.
359
+ if (onPoll) await onPoll().catch((e) => {
360
+ log('warn', `[${tag}] final metadata retry failed: ${e.message.slice(0, 100)}`);
361
+ });
355
362
  const st = await page.evaluate((vbs) => {
356
363
  const dlgs = document.querySelectorAll("[role='dialog']");
357
364
  const roots = dlgs.length ? Array.from(dlgs) : [document];
@@ -374,28 +381,53 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw')
374
381
  }, verbs).catch(() => ({ present: false, enabled: false }));
375
382
  if (st.enabled) {
376
383
  if (announced) log('info', `[${tag}] "Đăng" is now enabled — video finished processing`);
377
- return true;
384
+ return { enabled: true, sawPresent: true };
378
385
  }
379
386
  if (st.present) {
380
387
  sawPresent = true;
381
388
  if (!announced) { log('info', `[${tag}] final step: "Đăng" disabled (video still processing) — waiting up to ${Math.round(timeoutMs / 1000)}s…`); announced = true; }
382
389
  } else if (!sawPresent && ++absent >= 4) {
383
- return false; // no publish CTA after ~10s → not the final step
390
+ return { enabled: false, sawPresent: false }; // no publish CTA after ~10s → not the final step
384
391
  }
385
392
  await page.waitForTimeout(2500);
386
393
  }
387
394
  log('warn', `[${tag}] "Đăng" never became enabled within ${Math.round(timeoutMs / 1000)}s`);
388
- return false;
395
+ return { enabled: false, sawPresent };
396
+ }
397
+
398
+ const SAFE_RETRY_COMPOSER_CLOSED = 'FB_SAFE_RETRY_COMPOSER_CLOSED';
399
+
400
+ function safeComposerRetryError(message) {
401
+ const err = new Error(message);
402
+ err.code = SAFE_RETRY_COMPOSER_CLOSED;
403
+ return err;
389
404
  }
390
405
 
391
- async function run({ page, payload, log }) {
406
+ async function hasVisibleReelComposer(page) {
407
+ return page.evaluate(() => {
408
+ for (const dlg of document.querySelectorAll("[role='dialog']")) {
409
+ const r = dlg.getBoundingClientRect();
410
+ if (r.width < 8 || r.height < 8) continue;
411
+ const cs = getComputedStyle(dlg);
412
+ if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
413
+ const aria = dlg.getAttribute('aria-label') || '';
414
+ const text = (dlg.innerText || '').slice(0, 500);
415
+ const signature = `${aria}\n${text}`;
416
+ if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(signature)) continue;
417
+ if (/Tạo thước phim|Chỉnh sửa thước phim|Cài đặt thước phim|Create (?:a )?reel|Edit reel|Reel settings/i.test(signature)) return true;
418
+ }
419
+ return false;
420
+ }).catch(() => false);
421
+ }
422
+
423
+ async function runOnce({ page, payload, log }) {
392
424
  const {
393
425
  video_url, title, description = '', tags = [],
394
426
  visibility = 'public', format = 'short',
395
427
  } = payload || {};
396
428
  if (!video_url) throw new Error('No video_url provided');
397
429
 
398
- log('info', '[fb-pw] selectors version=2026.08.02-overlay-proof-cta');
430
+ log('info', '[fb-pw] selectors version=2026.08.06-composer-retry-metadata-poll');
399
431
 
400
432
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
401
433
 
@@ -842,6 +874,13 @@ async function run({ page, payload, log }) {
842
874
  "[role='textbox'][aria-placeholder*='Mô tả thước phim']",
843
875
  "[role='textbox'][aria-placeholder*='Mô tả']",
844
876
  "[role='textbox'][aria-placeholder*='Describe your reel']",
877
+ // Newer Lexical/contenteditable cohorts expose data-placeholder
878
+ // instead of aria-placeholder. Scope these to a dialog so a feed
879
+ // comment box can never receive the reel caption.
880
+ "[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả thước phim']",
881
+ "[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả']",
882
+ "[role='dialog'] [contenteditable='true'][data-placeholder*='Describe your reel']",
883
+ "[role='dialog'] [contenteditable='true'][aria-label*='Mô tả thước phim']",
845
884
  // BS composer description — legacy.
846
885
  "[role='textbox'][aria-label*='Mô tả']",
847
886
  "[role='textbox'][aria-label*='hộp thoại']",
@@ -856,11 +895,61 @@ async function run({ page, payload, log }) {
856
895
  const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
857
896
  if (!descText) break;
858
897
  await f.type(descText, { delay: 12 });
898
+ // Blur commits the Lexical/React value and re-runs the form's
899
+ // validation. Without this, the text can be visible while Đăng
900
+ // remains disabled against the previous empty state.
901
+ await page.keyboard.press('Tab').catch(() => {});
859
902
  fillState.description = true;
860
903
  log('info', `[fb-pw] description filled (${descText.length} chars) via "${sel}"`);
861
904
  break;
862
905
  } catch (e) { log('info', `[fb-pw] desc via "${sel}" failed: ${e.message.slice(0, 80)}`); }
863
906
  }
907
+ // Attribute shapes move frequently. Last-resort probe the visible Reel
908
+ // dialog for an editable whose combined accessibility signature names
909
+ // the description, tag it, and let Playwright type through the normal
910
+ // input path. This specifically covers the production cohort where the
911
+ // placeholder rendered after step 3 but none of the static selectors
912
+ // matched it on the first pass.
913
+ if (!fillState.description) {
914
+ const probe = await page.evaluate(() => {
915
+ document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'));
916
+ const dialogs = [...document.querySelectorAll("[role='dialog']")];
917
+ for (const dlg of dialogs) {
918
+ const dr = dlg.getBoundingClientRect();
919
+ if (dr.width < 8 || dr.height < 8) continue;
920
+ const ds = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 500)}`;
921
+ if (!/thước phim|reel/i.test(ds)) continue;
922
+ for (const el of dlg.querySelectorAll("[role='textbox'], [contenteditable='true'], textarea, input")) {
923
+ const r = el.getBoundingClientRect();
924
+ if (r.width < 8 || r.height < 8) continue;
925
+ const sig = [
926
+ el.getAttribute('aria-placeholder'), el.getAttribute('data-placeholder'),
927
+ el.getAttribute('placeholder'), el.getAttribute('aria-label'),
928
+ el.textContent,
929
+ ].filter(Boolean).join('|');
930
+ if (!/Mô tả(?: thước phim)?|Describe (?:your )?reel/i.test(sig)) continue;
931
+ el.setAttribute('__fbpw_desc__', '1');
932
+ return "[__fbpw_desc__='1']";
933
+ }
934
+ }
935
+ return null;
936
+ }).catch(() => null);
937
+ if (probe) {
938
+ try {
939
+ const f = page.locator(probe);
940
+ const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
941
+ await f.click({ timeout: 3000 });
942
+ await f.type(descText, { delay: 12 });
943
+ await page.keyboard.press('Tab').catch(() => {});
944
+ fillState.description = true;
945
+ log('info', `[fb-pw] description filled (${descText.length} chars) via dynamic-probe`);
946
+ } catch (e) {
947
+ log('info', `[fb-pw] desc dynamic-probe failed: ${e.message.slice(0, 80)}`);
948
+ } finally {
949
+ await page.evaluate(() => document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'))).catch(() => {});
950
+ }
951
+ }
952
+ }
864
953
  }
865
954
 
866
955
  // Tags (Thêm thẻ) — comma-separated; STRIP leading "#" since FB's tag
@@ -1169,15 +1258,32 @@ async function run({ page, payload, log }) {
1169
1258
  // Wait for the thumb-edit modal to mount. Header text =
1170
1259
  // "Chỉnh sửa hình thu nhỏ".
1171
1260
  await page.evaluate(() => document.querySelectorAll("[__fbpw_thumb_edit__]").forEach((el) => el.removeAttribute('__fbpw_thumb_edit__'))).catch(() => {});
1261
+ const thumbDialogReady = await page.evaluate(() => {
1262
+ document.querySelectorAll('[__fbpw_thumb_dialog__]').forEach((el) => el.removeAttribute('__fbpw_thumb_dialog__'));
1263
+ for (const dlg of document.querySelectorAll("[role='dialog']")) {
1264
+ const r = dlg.getBoundingClientRect();
1265
+ if (r.width < 8 || r.height < 8) continue;
1266
+ const cs = getComputedStyle(dlg);
1267
+ if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
1268
+ const sig = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 300)}`;
1269
+ if (!/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(sig)) continue;
1270
+ dlg.setAttribute('__fbpw_thumb_dialog__', '1');
1271
+ return true;
1272
+ }
1273
+ return false;
1274
+ }).catch(() => false);
1275
+ if (!thumbDialogReady) throw new Error('thumbnail editor dialog did not mount');
1172
1276
 
1173
1277
  // Click "Tải lên" button — opens OS file picker. Use filechooser
1174
- // race to inject the file path directly.
1278
+ // race to inject the file path directly. Scope every action to the
1279
+ // exact thumbnail dialog: Facebook can simultaneously keep its
1280
+ // Notifications dialog and the outer Reel composer in the DOM.
1175
1281
  const uploadCandidates = [
1176
- "[role='dialog'] div[role='button']:has-text('Tải lên')",
1177
- "[role='dialog'] button:has-text('Tải lên')",
1178
- "[role='dialog'] [aria-label='Tải lên']",
1179
- "[role='dialog'] [aria-label*='Tải hình thu nhỏ']",
1180
- "[role='dialog'] div[role='button']:has-text('Upload')",
1282
+ "[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Tải lên')",
1283
+ "[__fbpw_thumb_dialog__='1'] button:has-text('Tải lên')",
1284
+ "[__fbpw_thumb_dialog__='1'] [aria-label='Tải lên']",
1285
+ "[__fbpw_thumb_dialog__='1'] [aria-label*='Tải hình thu nhỏ']",
1286
+ "[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Upload')",
1181
1287
  ];
1182
1288
  let uploaded = false;
1183
1289
  for (const sel of uploadCandidates) {
@@ -1201,7 +1307,7 @@ async function run({ page, payload, log }) {
1201
1307
  // Fallback — direct setInputFiles on a hidden image-accepting
1202
1308
  // file input inside the modal.
1203
1309
  if (!uploaded) {
1204
- const directInput = page.locator("[role='dialog'] input[type='file'][accept*='image']").last();
1310
+ const directInput = page.locator("[__fbpw_thumb_dialog__='1'] input[type='file'][accept*='image']").last();
1205
1311
  if (await directInput.count().catch(() => 0) > 0) {
1206
1312
  try {
1207
1313
  await directInput.setInputFiles(thumbPath);
@@ -1217,10 +1323,10 @@ async function run({ page, payload, log }) {
1217
1323
  if (uploaded) {
1218
1324
  // Click "Lưu" to save the new thumbnail.
1219
1325
  const saveCandidates = [
1220
- "[role='dialog'] div[role='button']:has-text('Lưu')",
1221
- "[role='dialog'] button:has-text('Lưu')",
1222
- "[role='dialog'] [aria-label='Lưu']",
1223
- "[role='dialog'] div[role='button']:has-text('Save')",
1326
+ "[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Lưu')",
1327
+ "[__fbpw_thumb_dialog__='1'] button:has-text('Lưu')",
1328
+ "[__fbpw_thumb_dialog__='1'] [aria-label='Lưu']",
1329
+ "[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Save')",
1224
1330
  ];
1225
1331
  let saved = false;
1226
1332
  for (const sel of saveCandidates) {
@@ -1280,13 +1386,21 @@ async function run({ page, payload, log }) {
1280
1386
  await page.waitForTimeout(1000);
1281
1387
  }
1282
1388
  if (modalClosed) {
1389
+ // Closing the nested thumbnail editor is NOT enough. In the
1390
+ // 2026-08-05 failure Facebook unmounted the OUTER composer at
1391
+ // the same moment, leaving us on the Page feed. Retrying is
1392
+ // safe here because the publish branch has not run yet.
1393
+ if (!(await hasVisibleReelComposer(page))) {
1394
+ await dumpFailure(page, 'composer-closed-after-thumb-save', log);
1395
+ throw safeComposerRetryError('FB Reel composer closed after saving thumbnail (before publish)');
1396
+ }
1283
1397
  thumbApplied = true;
1284
1398
  log('info', `[fb-pw] page-wall thumb — modal closed after save (retries=${retryCount})`);
1285
1399
  } else {
1286
1400
  log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
1287
1401
  await page.keyboard.press('Escape').catch(() => {});
1288
1402
  await page.waitForTimeout(800);
1289
- const cancel = await firstVisible(page.locator("[role='dialog'] div[role='button']:has-text('Hủy'), [role='dialog'] button:has-text('Hủy')"), 2);
1403
+ const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
1290
1404
  if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
1291
1405
  await page.waitForTimeout(1500);
1292
1406
  }
@@ -1295,11 +1409,12 @@ async function run({ page, payload, log }) {
1295
1409
  }
1296
1410
  } else {
1297
1411
  log('warn', '[fb-pw] page-wall thumb upload failed — closing modal via Hủy to continue');
1298
- const cancel = await firstVisible(page.locator("[role='dialog'] div[role='button']:has-text('Hủy'), [role='dialog'] button:has-text('Hủy')"), 2);
1412
+ const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
1299
1413
  if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
1300
1414
  await page.waitForTimeout(1500);
1301
1415
  }
1302
1416
  } catch (e) {
1417
+ if (e?.code === SAFE_RETRY_COMPOSER_CLOSED) throw e;
1303
1418
  log('warn', `[fb-pw] page-wall thumb flow failed: ${e.message.slice(0, 100)}`);
1304
1419
  }
1305
1420
  customThumbDone = thumbApplied;
@@ -1863,7 +1978,21 @@ async function run({ page, payload, log }) {
1863
1978
  if (!pubWaitDone) {
1864
1979
  pubWaitDone = true;
1865
1980
  log('info', `[fb-pw] no "Tiếp" + no enabled publish at step ${step + 1} — waiting for "Đăng" to enable (large-video processing)…`);
1866
- if (await waitForPublishEnabled(page, publishVerbs, log, 180_000)) { step--; continue; }
1981
+ const waitResult = await waitForPublishEnabled(page, publishVerbs, log, 180_000, 'fb-pw', {
1982
+ // Idempotent: once the field is filled, fillState makes this a
1983
+ // no-op. Until then it catches a lazily-mounted final form that
1984
+ // the first post-Tiếp pass raced past.
1985
+ onPoll: fillMetadata,
1986
+ });
1987
+ if (waitResult.enabled) { step--; continue; }
1988
+ await dumpInventory(page, log, `no-advance-${step + 1}`);
1989
+ await dumpFailure(page, `no-advance-${step + 1}`, log);
1990
+ if (!fillState.description) {
1991
+ throw new Error(`FB final description field not found at step ${step + 1}`);
1992
+ }
1993
+ if (waitResult.sawPresent) {
1994
+ throw new Error(`FB publish remained disabled for 180s at step ${step + 1}`);
1995
+ }
1867
1996
  }
1868
1997
  await dumpInventory(page, log, `no-advance-${step + 1}`);
1869
1998
  await dumpFailure(page, `no-advance-${step + 1}`, log);
@@ -2336,7 +2465,33 @@ async function run({ page, payload, log }) {
2336
2465
  }
2337
2466
  }
2338
2467
 
2468
+ async function run(args) {
2469
+ const { page, log } = args;
2470
+ for (let attempt = 1; attempt <= 2; attempt++) {
2471
+ try {
2472
+ return await runOnce(args);
2473
+ } catch (e) {
2474
+ const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
2475
+ if (!safeRetry || attempt === 2) throw e;
2476
+ log('warn', `[fb-pw] composer vanished before publish — retrying the full upload once (${attempt}/1)`);
2477
+ // Force a clean React tree before runOnce returns to facebook.com and
2478
+ // opens a fresh composer. The first attempt's finally has already removed
2479
+ // its downloaded temp files; runOnce downloads clean copies on retry.
2480
+ await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
2481
+ await page.waitForTimeout(1200);
2482
+ }
2483
+ }
2484
+ throw new Error('FB upload exhausted safe retries');
2485
+ }
2486
+
2339
2487
  module.exports = { run };
2340
2488
  // Exposed so the overlay-click escalation can be exercised against a synthetic
2341
2489
  // page (toast covering the CTA) without driving the whole upload flow.
2342
- module.exports.__testables = { resilientClick, muteClickInterceptors, unmuteClickInterceptors };
2490
+ module.exports.__testables = {
2491
+ resilientClick,
2492
+ muteClickInterceptors,
2493
+ unmuteClickInterceptors,
2494
+ waitForPublishEnabled,
2495
+ hasVisibleReelComposer,
2496
+ SAFE_RETRY_COMPOSER_CLOSED,
2497
+ };
@@ -16,6 +16,8 @@
16
16
  // entry are hard requirements — their absence (not logged in / consent wall /
17
17
  // layout change) FAILS the session loudly (no silent bypass).
18
18
 
19
+ const { assertAccountUsable } = require('./lib/fb-guard');
20
+
19
21
  // ─── helpers ────────────────────────────────────────────────────────────────
20
22
  function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
21
23
  function shuffle(arr) {
@@ -353,12 +355,16 @@ async function run({ page, payload, log }) {
353
355
  // 1) Land on the FB home feed (organic entry — not a deep link).
354
356
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
355
357
  await page.waitForTimeout(randInt(3000, 5000));
358
+ // A dead session (logged out / checkpoint / disabled account) used to surface
359
+ // as "Reels entry not found" — indistinguishable from a layout change. Name it.
360
+ await assertAccountUsable(page, log);
356
361
  await dismissDialogs(page, log);
357
362
  await organicScroll(page, randInt(1, 3));
358
363
 
359
364
  // 2) Click the "Reels" entry (left nav / shortcut) — natural navigation.
360
365
  const reelsOk = await clickReelsEntry(page, log);
361
366
  if (!reelsOk) {
367
+ await assertAccountUsable(page, log); // may throw a coded account error instead
362
368
  throw new Error('warmup-fb: "Reels" entry not found on FB home — check the profile is logged in to Facebook and the left-nav Reels shortcut is present');
363
369
  }
364
370
  await dismissDialogs(page, log);
@@ -372,6 +378,7 @@ async function run({ page, payload, log }) {
372
378
  // 3) Search the keyword via the top bar (from the Reels surface).
373
379
  const focused = await focusFbSearch(page, log);
374
380
  if (!focused) {
381
+ await assertAccountUsable(page, log); // logged out mid-session → coded error
375
382
  throw new Error('warmup-fb: search box not found — FB layout/login issue (cannot warm up without searching)');
376
383
  }
377
384
  log('info', `[warmup-fb] searching: "${kw}"`);