channel-worker 2.4.5 → 2.5.0

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.
@@ -0,0 +1,1754 @@
1
+ // Facebook (Page) upload via Page-wall Reel composer.
2
+ // Flow: navigate https://www.facebook.com/ → click "Thước phim" entry in the
3
+ // "Tạo bài viết" widget → modal opens fresh each time. Switched from Business
4
+ // Suite composer (business.facebook.com/latest/reels_composer) because BS
5
+ // stays on its own URL after publish — no way to capture the new reel URL.
6
+ // Page-wall flow either navigates to /reel/<id>/ or surfaces the new reel
7
+ // at the top of the Page wall so we can grab its href post-publish.
8
+ //
9
+ // UI elements in the Page-wall Reel modal (same as BS composer):
10
+ // "Thêm video" (Add video — opens native filechooser)
11
+ // 3 inputs: Tiêu đề / Mô tả / Thẻ
12
+ // "Tiếp" → publish step → final "Đăng" / "Chia sẻ ngay"
13
+ //
14
+ // Strict-input contract (same as upload_youtube.js): every step throws on
15
+ // failure. No silent bypass — the cmd is marked failed so the idea-mirror
16
+ // surfaces the exact problem.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { downloadToTemp, safeUnlink } = require('./lib/download');
21
+
22
+ async function firstVisible(locator, max = 5) {
23
+ const n = Math.min(await locator.count().catch(() => 0), max);
24
+ for (let i = 0; i < n; i++) {
25
+ if (await locator.nth(i).isVisible().catch(() => false)) return locator.nth(i);
26
+ }
27
+ return null;
28
+ }
29
+
30
+ async function isActionable(el) {
31
+ if (!(await el.isVisible().catch(() => false))) return false;
32
+ if (!(await el.isEnabled().catch(() => true))) return false;
33
+ const ariaDisabled = await el.getAttribute('aria-disabled').catch(() => null);
34
+ if (ariaDisabled === 'true') return false;
35
+ return true;
36
+ }
37
+
38
+ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button' } = {}) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ while (Date.now() < deadline) {
41
+ if (await isActionable(loc)) {
42
+ await loc.click();
43
+ return;
44
+ }
45
+ await loc.page().waitForTimeout(800);
46
+ }
47
+ throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
48
+ }
49
+
50
+ // Same inventory dump as iter-2 (kept for failure diagnostics).
51
+ async function dumpInventory(page, log, tag) {
52
+ try {
53
+ const inv = await page.evaluate(() => {
54
+ const out = { fileInputs: [], buttons: [], dialogs: [] };
55
+ document.querySelectorAll("input[type='file']").forEach((el) => {
56
+ const r = el.getBoundingClientRect();
57
+ out.fileInputs.push({
58
+ accept: (el.accept || '').slice(0, 60),
59
+ hidden: r.width === 0 && r.height === 0,
60
+ parent: el.parentElement?.tagName?.toLowerCase() || '',
61
+ });
62
+ });
63
+ const seen = new Set();
64
+ document.querySelectorAll("[role='button'], button, [aria-label], [data-testid], [role='textbox']").forEach((el) => {
65
+ const r = el.getBoundingClientRect();
66
+ if (r.width < 8 || r.height < 8) return;
67
+ const aria = el.getAttribute('aria-label') || '';
68
+ const test = el.getAttribute('data-testid') || '';
69
+ const role = el.getAttribute('role') || '';
70
+ const text = (el.innerText || '').trim().slice(0, 60);
71
+ const key = `${aria}|${test}|${text}`;
72
+ if (seen.has(key)) return;
73
+ seen.add(key);
74
+ if (!aria && !test && !text) return;
75
+ out.buttons.push({ tag: el.tagName.toLowerCase(), role, aria: aria.slice(0, 60), testid: test.slice(0, 40), text });
76
+ if (out.buttons.length >= 60) return;
77
+ });
78
+ document.querySelectorAll("[role='dialog']").forEach((el) => {
79
+ out.dialogs.push({ aria: (el.getAttribute('aria-label') || '').slice(0, 80), text: (el.innerText || '').trim().slice(0, 120) });
80
+ });
81
+ return out;
82
+ }).catch(() => ({ fileInputs: [], buttons: [], dialogs: [] }));
83
+ log('info', `[fb-pw] inv-${tag}: fileInputs=${inv.fileInputs.length} buttons=${inv.buttons.length} dialogs=${inv.dialogs.length}`);
84
+ inv.dialogs.slice(0, 4).forEach((d, i) => log('info', ` ${tag} dialog[${i}] aria="${d.aria}" text="${d.text}"`));
85
+ inv.buttons.slice(0, 60).forEach((b, i) => log('info', ` ${tag} btn[${i}] <${b.tag} role="${b.role}" aria="${b.aria}" testid="${b.testid}">${b.text}</>`));
86
+ // Page-wall flow opens a modal; dump dialog-scoped buttons separately so
87
+ // we can see the publish CTA inside the modal (which is otherwise drowned
88
+ // out by the background page's nav buttons).
89
+ try {
90
+ const dlgBtns = await page.evaluate(() => {
91
+ const out = [];
92
+ document.querySelectorAll("[role='dialog']").forEach((dlg, di) => {
93
+ const seen = new Set();
94
+ dlg.querySelectorAll("[role='button'], button").forEach((el) => {
95
+ const r = el.getBoundingClientRect();
96
+ if (r.width < 8 || r.height < 8) return;
97
+ const aria = (el.getAttribute('aria-label') || '').slice(0, 80);
98
+ const text = (el.innerText || '').trim().slice(0, 60);
99
+ if (!aria && !text) return;
100
+ const key = `${di}|${aria}|${text}`;
101
+ if (seen.has(key)) return;
102
+ seen.add(key);
103
+ out.push({ di, aria, text, y: Math.round(r.y) });
104
+ if (out.length >= 40) return;
105
+ });
106
+ });
107
+ return out;
108
+ }).catch(() => []);
109
+ dlgBtns.forEach((b, i) => log('info', ` ${tag} dlg-btn[${i}] d${b.di} y=${b.y} aria="${b.aria}" "${b.text}"`));
110
+ } catch {}
111
+ return inv;
112
+ } catch (e) { log('warn', `[fb-pw] inv-${tag} failed: ${e.message}`); return null; }
113
+ }
114
+
115
+ async function dumpFailure(page, tag, log) {
116
+ try {
117
+ const os = require('os');
118
+ const dir = path.join(os.tmpdir(), 'cm-worker-pw');
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ const png = path.join(dir, `fb-${tag}-${Date.now()}.png`);
121
+ await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
122
+ log('warn', `[fb-pw] dump ${tag} — url=${page.url()} screenshot=${png}`);
123
+ } catch {}
124
+ }
125
+
126
+ // Some Business Suite views overlay an onboarding tutorial dialog (`js_*` aria-
127
+ // label) that intercepts all clicks until dismissed. Click any visible "X" /
128
+ // "Đã hiểu" / "Bỏ qua" inside the dialog, then check again.
129
+ async function dismissBsOnboarding(page, log) {
130
+ for (let pass = 0; pass < 3; pass++) {
131
+ const dlg = page.locator("[role='dialog']").first();
132
+ const has = await dlg.count().catch(() => 0);
133
+ if (!has || !(await dlg.isVisible().catch(() => false))) return;
134
+ const phrases = ['Đã hiểu', 'Got it', 'Bỏ qua', 'Skip', 'Để sau', 'Not now', 'Đóng', 'Close', 'Tiếp tục', 'Continue'];
135
+ let clicked = false;
136
+ for (const p of phrases) {
137
+ const candidates = [
138
+ `[role='dialog'] [aria-label*='${p}']`,
139
+ `[role='dialog'] [role='button']:has-text('${p}')`,
140
+ `[role='dialog'] button:has-text('${p}')`,
141
+ ];
142
+ for (const sel of candidates) {
143
+ const hit = await firstVisible(page.locator(sel), 3);
144
+ if (!hit) continue;
145
+ try {
146
+ log('info', `[fb-pw] dismissing BS onboarding via "${sel}"`);
147
+ await hit.click({ timeout: 2500 });
148
+ await page.waitForTimeout(700);
149
+ clicked = true;
150
+ break;
151
+ } catch {}
152
+ }
153
+ if (clicked) break;
154
+ }
155
+ if (!clicked) {
156
+ try { await page.keyboard.press('Escape'); } catch {}
157
+ await page.waitForTimeout(500);
158
+ return;
159
+ }
160
+ }
161
+ }
162
+
163
+ async function run({ page, payload, log }) {
164
+ const {
165
+ video_url, title, description = '', tags = [],
166
+ visibility = 'public', format = 'short',
167
+ } = payload || {};
168
+ if (!video_url) throw new Error('No video_url provided');
169
+
170
+ log('info', '[fb-pw] selectors version=2026.06.01s-pagewall-thumb-60s-retry');
171
+
172
+ page.on('dialog', (d) => { d.accept().catch(() => {}); });
173
+
174
+ // Capture the freshly-published Reel's ID from FB's Graph API mutation
175
+ // response. Without this, the script's DOM scrape after publish was
176
+ // matching whatever stale /reel/ anchor was already in the page (FB shows
177
+ // recommendation tiles below the composer). The Graph mutation returns
178
+ // the new video/reel id in the JSON body — far more reliable.
179
+ const capturedReelIds = [];
180
+ let capturedReelIdsSnapshotLen = 0; // set right before publish click
181
+ page.on('response', async (resp) => {
182
+ try {
183
+ const u = resp.url();
184
+ // Widen URL match: FB publish mutations can fire under graphql,
185
+ // graphqlbatch, or legacy ajax/reels endpoints.
186
+ if (!/\/api\/graphql|\/api\/graphqlbatch|\/ajax\/reels|\/ajax\/composer|\/reel\/create|\/videos\/create/i.test(u)) return;
187
+ const ct = (resp.headers()['content-type'] || '');
188
+ if (!/json|javascript|text/i.test(ct)) return;
189
+ const text = await resp.text().catch(() => '');
190
+ if (!text) return;
191
+ // Capture VIDEO/REEL IDs only — NOT story_id/post_id (those are FB's
192
+ // newer 18-digit container IDs, e.g. 122101397445343435, that wrap a
193
+ // reel but don't resolve as canonical /reel/<id>/ URLs. The actual
194
+ // reel ID is a 15-digit shorter number that lives in videoID/reelID
195
+ // fields).
196
+ const matches = [];
197
+ for (const re of [
198
+ /"videoID"\s*:\s*"(\d{8,18})"/g,
199
+ /"video_id"\s*:\s*"(\d{8,18})"/g,
200
+ /"short_form_video_id"\s*:\s*"(\d{8,18})"/g,
201
+ /"reelID"\s*:\s*"(\d{8,18})"/g,
202
+ /"reel_id"\s*:\s*"(\d{8,18})"/g,
203
+ /"id"\s*:\s*"(\d{8,18})"[^}]*"__typename"\s*:\s*"Video"/g,
204
+ /"id"\s*:\s*"(\d{8,18})"[^}]*"__typename"\s*:\s*"Reel"/g,
205
+ /\/reel\/(\d{8,18})/g,
206
+ ]) {
207
+ let m;
208
+ while ((m = re.exec(text)) !== null) {
209
+ // Reel IDs are typically 15-16 digits. 17-18 digit IDs are FB's
210
+ // newer "story container" format that doesn't resolve as reel.
211
+ if (m[1].length >= 13 && m[1].length <= 16) matches.push(m[1]);
212
+ }
213
+ }
214
+ if (matches.length) {
215
+ capturedReelIds.push(...matches);
216
+ }
217
+ } catch { /* ignore */ }
218
+ });
219
+
220
+ log('info', '[fb-pw] downloading video to local…');
221
+ const videoPath = await downloadToTemp(video_url, { prefix: 'fb', ext: '.mp4' });
222
+ log('info', `[fb-pw] video at ${videoPath}`);
223
+
224
+ // Custom thumbnail — FB Reels DOES allow custom thumbnail uploads via web
225
+ // (unlike YT Shorts which is mobile-only). Best-effort: download if URL
226
+ // present, attempt upload on the thumbnail step, throw if the URL was
227
+ // provided but the upload UI is missing/broken (strict-input contract).
228
+ let thumbPath = null;
229
+ if (payload.thumbnail_url) {
230
+ try {
231
+ thumbPath = await downloadToTemp(payload.thumbnail_url, { prefix: 'fb-thumb', ext: '.png' });
232
+ log('info', `[fb-pw] thumbnail at ${thumbPath}`);
233
+ } catch (e) { log('warn', `[fb-pw] thumbnail download failed: ${e.message}`); }
234
+ }
235
+
236
+ try {
237
+ // 1) Open the Facebook Page wall (NOT Business Suite). The BS composer
238
+ // couldn't capture the post URL after publish — the page stayed at
239
+ // business.facebook.com/... with no navigation to the new reel. The
240
+ // Page-wall flow opens a Reel modal whose publish flow either navigates
241
+ // or shows a confirmation toast linking to the new reel.
242
+ log('info', '[fb-pw] open https://www.facebook.com/ (page wall flow) …');
243
+ await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
244
+ await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
245
+ await page.waitForTimeout(4000);
246
+ log('info', `[fb-pw] home loaded — url=${page.url()}`);
247
+
248
+ // 2) Click the "Thước phim" entry inside the Page's "Tạo bài viết" widget.
249
+ // This opens a FRESH Reel composer modal each time (no stale-draft
250
+ // issue that plagued the BS composer). CRITICAL: scope to the "Tạo
251
+ // bài viết" region — `[aria-label='Thước phim']` ALSO matches the
252
+ // Reels-feed link in the sidebar nav, which navigates to /reel/...
253
+ // instead of opening the composer (observed on the brain-made-simple
254
+ // page wall — clicked feed link, played random viral reel).
255
+ log('info', '[fb-pw] click "Thước phim" entry inside "Tạo bài viết"…');
256
+ const reelEntryCandidates = [
257
+ // Scoped to the create-post widget — this is the only correct trigger.
258
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Thước phim']",
259
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reel']",
260
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reels']",
261
+ "div[role='region'][aria-label='Create post'] [aria-label='Reels']",
262
+ ];
263
+ let reelOpened = false;
264
+ for (const sel of reelEntryCandidates) {
265
+ const btn = await firstVisible(page.locator(sel), 5);
266
+ if (!btn) continue;
267
+ try {
268
+ await btn.click({ timeout: 3000 });
269
+ log('info', `[fb-pw] Thước phim clicked via "${sel}"`);
270
+ reelOpened = true;
271
+ break;
272
+ } catch {}
273
+ }
274
+ // Fallback — walk the "Tạo bài viết" region in JS, find a button whose
275
+ // text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
276
+ if (!reelOpened) {
277
+ const probed = await page.evaluate(() => {
278
+ const regions = document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết']");
279
+ for (const region of regions) {
280
+ const aria = (region.getAttribute('aria-label') || '').toLowerCase();
281
+ if (!/tạo bài viết|create post/.test(aria)) continue;
282
+ const btns = region.querySelectorAll("[role='button'], button, a");
283
+ for (const b of btns) {
284
+ const t = (b.innerText || b.textContent || '').trim();
285
+ const al = (b.getAttribute('aria-label') || '').trim();
286
+ const sig = (t + '|' + al).toLowerCase();
287
+ if (/^thước phim|^reels?$/i.test(t.trim()) || /thước phim/i.test(al)) {
288
+ const r = b.getBoundingClientRect();
289
+ if (r.width < 8 || r.height < 8) continue;
290
+ b.setAttribute('__fbpw_reel_entry__', '1');
291
+ return { selector: "[__fbpw_reel_entry__='1']", text: t.slice(0, 40), aria: al.slice(0, 40) };
292
+ }
293
+ }
294
+ }
295
+ return null;
296
+ }).catch(() => null);
297
+ if (probed) {
298
+ try {
299
+ await page.locator(probed.selector).click({ timeout: 3000 });
300
+ log('info', `[fb-pw] Thước phim clicked via region-probe (text="${probed.text}" aria="${probed.aria}")`);
301
+ reelOpened = true;
302
+ } catch (e) {
303
+ log('warn', `[fb-pw] region-probe click failed: ${e.message.slice(0, 80)}`);
304
+ }
305
+ }
306
+ }
307
+ if (!reelOpened) {
308
+ await dumpInventory(page, log, 'no-reel-entry');
309
+ await dumpFailure(page, 'no-reel-entry', log);
310
+ throw new Error('FB home: "Thước phim" entry not found in "Tạo bài viết" widget (sidebar Reels feed link doesn\'t count — it navigates instead of opening composer)');
311
+ }
312
+ await page.waitForTimeout(4000);
313
+ const afterUrl = page.url();
314
+ log('info', `[fb-pw] after Thước phim click — url=${afterUrl}`);
315
+ // Sanity check: clicking the sidebar Reels link navigates to /reel/<id>.
316
+ // That means we clicked the wrong button — fail fast so the user knows.
317
+ if (/\/reel\/\d+/.test(afterUrl)) {
318
+ throw new Error(`FB Thước phim click navigated to Reels feed (${afterUrl}) — wrong button matched, should be composer modal. Check "Tạo bài viết" widget layout on this Page.`);
319
+ }
320
+
321
+ // 3) Dismiss any onboarding/tutorial dialog.
322
+ await dismissBsOnboarding(page, log);
323
+
324
+ // 3) Click "Thêm video" — opens the native OS file picker. Catch the
325
+ // filechooser event and inject the video file path. Multiple aria/text
326
+ // candidates so we survive minor i18n drift.
327
+ log('info', '[fb-pw] click "Thêm video" + filechooser…');
328
+ const addVideoCandidates = [
329
+ "div[role='button']:has-text('Thêm video')",
330
+ "div[role='button']:has-text('Add video')",
331
+ "[aria-label='Thêm video']",
332
+ "[aria-label='Add video']",
333
+ ];
334
+ let videoSet = false;
335
+ for (const sel of addVideoCandidates) {
336
+ if (videoSet) break;
337
+ const btn = await firstVisible(page.locator(sel), 3);
338
+ if (!btn) continue;
339
+ try {
340
+ const [chooser] = await Promise.all([
341
+ page.waitForEvent('filechooser', { timeout: 8000 }),
342
+ btn.click({ timeout: 3000 }),
343
+ ]);
344
+ await chooser.setFiles(videoPath);
345
+ videoSet = true;
346
+ log('info', `[fb-pw] video file set via "${sel}"`);
347
+ } catch (e) {
348
+ log('info', `[fb-pw] "Thêm video" via "${sel}" did not open picker: ${e.message.slice(0, 80)}`);
349
+ }
350
+ }
351
+ if (!videoSet) {
352
+ // Fallback — some BS variants expose the hidden input directly. Try
353
+ // setInputFiles on input[type='file'] anywhere on the page that accepts video.
354
+ const fi = page.locator("input[type='file']").last();
355
+ if (await fi.count().catch(() => 0) > 0) {
356
+ try {
357
+ await fi.setInputFiles(videoPath);
358
+ videoSet = true;
359
+ log('info', `[fb-pw] video file set via fallback input[type='file']`);
360
+ } catch (e) { log('info', `[fb-pw] fallback input setInputFiles failed: ${e.message.slice(0, 80)}`); }
361
+ }
362
+ }
363
+ if (!videoSet) {
364
+ await dumpInventory(page, log, 'no-add-video');
365
+ await dumpFailure(page, 'no-add-video', log);
366
+ throw new Error('FB "Thêm video" trigger not found — could not start video upload');
367
+ }
368
+
369
+ // 4) Wait for the video to finish processing on FB's side before we can
370
+ // fill the caption + advance. FB shows a progress indicator and only
371
+ // enables the "Tiếp" button once upload + thumbnail extraction is done.
372
+ log('info', '[fb-pw] waiting for video upload to process (up to 60s)…');
373
+ await page.waitForTimeout(30000); // FB processes 10-60s for short Reels
374
+
375
+ // 5) Fill metadata. FB Reels has THREE separate fields (verified visually
376
+ // by user — different from how YT lumps everything into title + desc):
377
+ // - Tiêu đề thước phim (title — short, ~80 chars)
378
+ // - Mô tả thước phim (description — long, supports emoji)
379
+ // - Thêm thẻ (tags — comma-separated, NO hashtag prefix)
380
+ // We attempt to fill all 3 here on step 1 + again after each Tiếp
381
+ // (some FB builds only show them on the final Chia sẻ step). The
382
+ // helper is idempotent: it tracks per-field "done" so we don't
383
+ // re-fill on subsequent steps.
384
+ const fillState = { title: false, description: false, tags: false };
385
+ const fillMetadata = async () => {
386
+ // Title (Tiêu đề thước phim). Variant-A input is shown by default in the
387
+ // BS composer — NO reveal click needed. Earlier `revealCandidates` clicks
388
+ // were accidentally landing on the "+" button (add A/B variant) which
389
+ // creates an empty variant-B and triggers "Không được để trống tiêu đề
390
+ // thay thế" validation, blocking Tiếp.
391
+ if (!fillState.title && title) {
392
+ // If a previous run accidentally created variant B (empty + warning
393
+ // triangle icon), delete it via its trash button so FB doesn't block
394
+ // Tiếp on the empty-variant-B error.
395
+ const variantBDeleted = await page.evaluate(() => {
396
+ // Look for a trash/remove icon button BESIDE a 2nd title input.
397
+ // FB's A/B title rows are siblings; the 2nd row has a trash button
398
+ // and an empty input with the same "Tiêu đề..." placeholder.
399
+ const inputs = Array.from(document.querySelectorAll("input"))
400
+ .filter((el) => {
401
+ const p = el.getAttribute('placeholder') || '';
402
+ const ap = el.getAttribute('aria-placeholder') || '';
403
+ return /tiêu đề.*thước phim|tiêu đề.*thay thế|thước phim.*tiêu đề/i.test(p + ap);
404
+ });
405
+ if (inputs.length < 2) return false;
406
+ // Variant B is the LAST title input. Look for a trash button near it.
407
+ const last = inputs[inputs.length - 1];
408
+ let parent = last.parentElement;
409
+ for (let depth = 0; depth < 5 && parent; depth++) {
410
+ const trash = parent.querySelector("[aria-label*='Xóa'], [aria-label*='Remove'], [aria-label*='Trash']");
411
+ if (trash) {
412
+ trash.click();
413
+ return true;
414
+ }
415
+ parent = parent.parentElement;
416
+ }
417
+ return false;
418
+ }).catch(() => false);
419
+ if (variantBDeleted) {
420
+ log('info', '[fb-pw] removed stale empty variant-B title row');
421
+ await page.waitForTimeout(800);
422
+ }
423
+ const titleSels = [
424
+ // PAGE-WALL form 1 — "Bạn đang nghĩ gì?" caption textbox is the
425
+ // TITLE field per FB layout (user-confirmed: form 1 = title, form
426
+ // 2 = description + tags). Match these FIRST.
427
+ "[role='textbox'][aria-placeholder*='Bạn đang nghĩ gì']",
428
+ "[role='textbox'][aria-placeholder*='đang nghĩ gì']",
429
+ "[role='textbox'][aria-placeholder*='Whats on your mind']",
430
+ "[role='textbox'][aria-placeholder*='thinking']",
431
+ "[role='textbox'][aria-label*='Bạn đang nghĩ gì']",
432
+ // BS-composer title (Tiêu đề thước phim) — kept for legacy BS flow.
433
+ "[role='textbox'][aria-placeholder*='Tiêu đề thước phim']",
434
+ "[role='textbox'][aria-placeholder*='Tiêu đề']",
435
+ "[contenteditable='true'][aria-placeholder*='Tiêu đề']",
436
+ "input[placeholder*='Tiêu đề thước phim']",
437
+ "input[placeholder*='Tiêu đề']",
438
+ "textarea[placeholder*='Tiêu đề']",
439
+ "[aria-label='Tiêu đề thước phim']",
440
+ "[aria-label*='Tiêu đề thước phim']",
441
+ "[aria-label*='Tiêu đề']",
442
+ ];
443
+ const clippedTitle = String(title).length > 80 ? String(title).slice(0, 79) + '…' : String(title);
444
+ for (const sel of titleSels) {
445
+ const f = await firstVisible(page.locator(sel), 3);
446
+ if (!f) continue;
447
+ try {
448
+ await f.click({ timeout: 3000 });
449
+ const tag = await f.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
450
+ if (tag === 'input' || tag === 'textarea') {
451
+ await f.fill(clippedTitle);
452
+ } else {
453
+ await f.type(clippedTitle, { delay: 12 });
454
+ }
455
+ // Blur to commit (FB's React validation re-renders on blur).
456
+ await page.keyboard.press('Tab').catch(() => {});
457
+ fillState.title = true;
458
+ log('info', `[fb-pw] title filled (${clippedTitle.length} chars) via "${sel}"`);
459
+ break;
460
+ } catch (e) { log('info', `[fb-pw] title via "${sel}" failed: ${e.message.slice(0, 80)}`); }
461
+ }
462
+ // Last-resort dynamic probe — JS scan for any input/contenteditable
463
+ // whose attributes mention "Tiêu đề". Returns a CSS path we can target.
464
+ if (!fillState.title) {
465
+ const probe = await page.evaluate(() => {
466
+ const all = document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']");
467
+ for (let i = 0; i < all.length; i++) {
468
+ const el = all[i];
469
+ const r = el.getBoundingClientRect();
470
+ if (r.width < 8 || r.height < 8) continue;
471
+ const attrs = (
472
+ (el.getAttribute('placeholder') || '') + '|' +
473
+ (el.getAttribute('aria-placeholder') || '') + '|' +
474
+ (el.getAttribute('aria-label') || '') + '|' +
475
+ (el.getAttribute('data-placeholder') || '')
476
+ ).toLowerCase();
477
+ if (/tiêu đề|title.*reel|reel.*title/i.test(attrs)) {
478
+ return {
479
+ tag: el.tagName.toLowerCase(),
480
+ attrs,
481
+ bbox: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
482
+ };
483
+ }
484
+ }
485
+ return null;
486
+ }).catch(() => null);
487
+ if (probe) {
488
+ log('info', `[fb-pw] title dynamic-probe found <${probe.tag}> attrs="${probe.attrs.slice(0, 80)}"`);
489
+ try {
490
+ await page.mouse.click(probe.bbox.x, probe.bbox.y);
491
+ await page.waitForTimeout(300);
492
+ // FB Reels title cap is 80 chars (BS composer shows red invalid
493
+ // state above that and blocks Tiếp). Be conservative — clip with
494
+ // ellipsis suffix so the AI-generated hook is preserved at front.
495
+ const clipped = String(title).length > 80
496
+ ? String(title).slice(0, 79) + '…'
497
+ : String(title);
498
+ await page.keyboard.type(clipped, { delay: 12 });
499
+ // Tab away to blur — commits the value and triggers React's
500
+ // validation re-render, otherwise the form keeps the invalid
501
+ // state from a previous typing session.
502
+ await page.keyboard.press('Tab');
503
+ fillState.title = true;
504
+ log('info', `[fb-pw] title filled via dynamic-probe (${clipped.length} chars)`);
505
+ } catch (e) {
506
+ log('info', `[fb-pw] title dynamic-probe click failed: ${e.message.slice(0, 80)}`);
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ // Description (Mô tả) — multi-line. Best-known aria: "Hãy viết vào ô
513
+ // hộp thoại...". On the metadata page it's the same field but with
514
+ // placeholder "Mô tả thước phim của bạn".
515
+ // Description selectors — Page-wall form 2 uses "Mô tả thước phim của
516
+ // bạn..." textbox (verified via screenshot). MUST be specific to avoid
517
+ // matching form 1's "Bạn đang nghĩ gì?" caption (which is the TITLE
518
+ // field — would re-write title with description text otherwise).
519
+ if (!fillState.description) {
520
+ const descSels = [
521
+ // Page-wall form 2 description — match by aria-placeholder.
522
+ "[role='textbox'][aria-placeholder*='Mô tả thước phim']",
523
+ "[role='textbox'][aria-placeholder*='Mô tả']",
524
+ "[role='textbox'][aria-placeholder*='Describe your reel']",
525
+ // BS composer description — legacy.
526
+ "[role='textbox'][aria-label*='Mô tả']",
527
+ "[role='textbox'][aria-label*='hộp thoại']",
528
+ "[role='textbox'][aria-label*='Hãy viết']",
529
+ "textarea[placeholder*='Mô tả']",
530
+ ];
531
+ for (const sel of descSels) {
532
+ const f = await firstVisible(page.locator(sel), 3);
533
+ if (!f) continue;
534
+ try {
535
+ await f.click({ timeout: 3000 });
536
+ const descText = (description || title || '').toString().slice(0, 2100);
537
+ if (!descText) break;
538
+ await f.type(descText, { delay: 12 });
539
+ fillState.description = true;
540
+ log('info', `[fb-pw] description filled (${descText.length} chars) via "${sel}"`);
541
+ break;
542
+ } catch (e) { log('info', `[fb-pw] desc via "${sel}" failed: ${e.message.slice(0, 80)}`); }
543
+ }
544
+ }
545
+
546
+ // Tags (Thêm thẻ) — comma-separated; STRIP leading "#" since FB's tag
547
+ // input is keyword-style, not hashtag-style. The tag input is at the
548
+ // BOTTOM of the BS composer form; firstVisible was missing it because
549
+ // it sat below the fold. Each candidate now scrollIntoView before
550
+ // attempting click.
551
+ if (!fillState.tags && tags && tags.length) {
552
+ const tagSels = [
553
+ // Page-wall form 2 — "Thêm thẻ" textbox.
554
+ "[role='textbox'][aria-placeholder='Thêm thẻ']",
555
+ "[role='textbox'][aria-placeholder*='Thêm thẻ']",
556
+ "[role='textbox'][aria-label='Thêm thẻ']",
557
+ "[aria-label='Thêm thẻ']",
558
+ "[aria-label*='Thêm thẻ']",
559
+ // BS composer tag input.
560
+ "[aria-label*='thẻ phân tách']",
561
+ "input[placeholder*='thẻ phân tách']",
562
+ "input[placeholder*='thẻ']",
563
+ "textarea[placeholder*='thẻ phân tách']",
564
+ ];
565
+ // First locate the tag input via JS so we can scroll it into view
566
+ // even when it's outside the current viewport. Two strategies, in
567
+ // order: (1) attribute match (aria-label/placeholder/aria-placeholder
568
+ // contains "thẻ" or "tag"); (2) DOM-walk from a label/heading text
569
+ // "Thẻ" / "Tag" to the nearest input under it (FB sometimes renders
570
+ // the input with no descriptive attrs and only a sibling label).
571
+ const tagLoc = await page.evaluate(() => {
572
+ // Strategy 1: attribute match.
573
+ const cands = document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']");
574
+ for (const el of cands) {
575
+ const a = (el.getAttribute('aria-label') || '').toLowerCase();
576
+ const p = (el.getAttribute('placeholder') || '').toLowerCase();
577
+ const ap = (el.getAttribute('aria-placeholder') || '').toLowerCase();
578
+ const sig = a + '|' + p + '|' + ap;
579
+ if (/thẻ|tag/i.test(sig)) {
580
+ const r = el.getBoundingClientRect();
581
+ if (r.width < 8) continue;
582
+ el.scrollIntoView({ block: 'center' });
583
+ el.setAttribute('__fbpw_tag__', '1');
584
+ return { selector: "[__fbpw_tag__='1']", tag: el.tagName.toLowerCase(), via: 'attr', sig };
585
+ }
586
+ }
587
+ // Strategy 2: find a label/heading whose text is exactly "Thẻ" /
588
+ // "Tag" / contains those — then locate the nearest input under it.
589
+ const labels = document.querySelectorAll("label, h2, h3, h4, span, div");
590
+ for (const lbl of labels) {
591
+ const t = (lbl.innerText || '').trim();
592
+ if (!/^thẻ$|^tag$|^thẻ\s|tag\s/i.test(t)) continue;
593
+ const r = lbl.getBoundingClientRect();
594
+ if (r.width < 8) continue;
595
+ // Walk up to find a container, then look for input inside.
596
+ let container = lbl.parentElement;
597
+ for (let depth = 0; depth < 4 && container; depth++) {
598
+ const inp = container.querySelector("input, textarea, [contenteditable='true'], [role='textbox']");
599
+ if (inp) {
600
+ const ir = inp.getBoundingClientRect();
601
+ if (ir.width >= 8) {
602
+ inp.scrollIntoView({ block: 'center' });
603
+ inp.setAttribute('__fbpw_tag__', '1');
604
+ return { selector: "[__fbpw_tag__='1']", tag: inp.tagName.toLowerCase(), via: 'label', label: t };
605
+ }
606
+ }
607
+ container = container.parentElement;
608
+ }
609
+ }
610
+ return null;
611
+ }).catch(() => null);
612
+ if (tagLoc) log('info', `[fb-pw] tag input located via ${tagLoc.via}${tagLoc.sig ? ` (sig=${tagLoc.sig.slice(0, 60)})` : ''}${tagLoc.label ? ` (label="${tagLoc.label}")` : ''}`);
613
+ if (tagLoc) {
614
+ try {
615
+ await page.waitForTimeout(400);
616
+ const f = page.locator(tagLoc.selector);
617
+ await f.click({ timeout: 5000 });
618
+ const cleaned = tags.map((t) => String(t).replace(/^#+/, '').trim()).filter(Boolean);
619
+ const tagsStr = cleaned.join(', ');
620
+ if (tagLoc.tag === 'input' || tagLoc.tag === 'textarea') {
621
+ await f.fill(tagsStr);
622
+ } else {
623
+ await f.type(tagsStr, { delay: 10 });
624
+ }
625
+ fillState.tags = true;
626
+ log('info', `[fb-pw] tags filled (${cleaned.length}: ${tagsStr.slice(0, 60)}…) via scrollIntoView`);
627
+ await page.evaluate((sel) => document.querySelectorAll(sel).forEach((el) => el.removeAttribute('__fbpw_tag__')), tagLoc.selector).catch(() => {});
628
+ } catch (e) {
629
+ log('info', `[fb-pw] tags fill via scrollIntoView failed: ${e.message.slice(0, 80)}`);
630
+ }
631
+ }
632
+ // Fallback: standard CSS selectors (in case the JS probe missed).
633
+ if (!fillState.tags) {
634
+ for (const sel of tagSels) {
635
+ const f = await firstVisible(page.locator(sel), 3);
636
+ if (!f) continue;
637
+ try {
638
+ await f.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
639
+ const cleaned = tags.map((t) => String(t).replace(/^#+/, '').trim()).filter(Boolean);
640
+ const tagsStr = cleaned.join(', ');
641
+ await f.click({ timeout: 3000 });
642
+ const tag = await f.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
643
+ if (tag === 'input' || tag === 'textarea') {
644
+ await f.fill(tagsStr);
645
+ } else {
646
+ await f.type(tagsStr, { delay: 10 });
647
+ }
648
+ fillState.tags = true;
649
+ log('info', `[fb-pw] tags filled (${cleaned.length}: ${tagsStr.slice(0, 60)}…) via "${sel}"`);
650
+ break;
651
+ } catch (e) { log('info', `[fb-pw] tags via "${sel}" failed: ${e.message.slice(0, 80)}`); }
652
+ }
653
+ }
654
+ }
655
+ };
656
+
657
+ log('info', '[fb-pw] fill metadata (title/description/tags) — step 1…');
658
+ await fillMetadata();
659
+ await page.waitForTimeout(800);
660
+
661
+ // 6) Multi-step "Tiếp" → "Tiếp" → "Đăng" loop. FB Reels composer is 3
662
+ // pages: caption (now) → thumbnail (auto-selected, just advance) →
663
+ // visibility/publish (final). Each step has a "Tiếp" button until the
664
+ // last, which has "Đăng" / "Chia sẻ ngay" instead. Loop with a max
665
+ // bound of 5 to handle future composer changes without infinite-clicking.
666
+ // Publish verbs ordered by specificity. Critical: "Chia sẻ" (bare) is
667
+ // the ACTUAL primary action button at the bottom-right of step 3 (blue).
668
+ // "Chia sẻ ngay" is just a publish-mode RADIO option (defaults selected).
669
+ // The wizard header also has "Chia sẻ" text — but our findByVerbs prefers
670
+ // LAST occurrence in DOM order, which is the bottom action button.
671
+ // "Chia sẻ" REMOVED from publish verbs: in the Page-wall Reel modal,
672
+ // there's a "Chia sẻ" button whose aria-label is "Gửi nội dung này cho
673
+ // bạn bè hoặc đăng lên trang cá nhân của bạn." — that's the share-to-
674
+ // friends action, NOT publish. The real Page-wall publish CTA is "Đăng".
675
+ // In BS composer "Chia sẻ" was the publish CTA, but the dialog-scoped
676
+ // findByVerbs in Page wall avoids the BS-only confusion.
677
+ const publishVerbs = ['Đăng', 'Đăng bài', 'Đăng ngay', 'Đăng video', 'Đăng thước phim', 'Post now', 'Share now', 'Publish', 'Post'];
678
+ const nextVerbs = ['Tiếp', 'Tiếp theo', 'Next', 'Continue'];
679
+
680
+ // Exact-text match via page.evaluate, returning a CSS selector path so
681
+ // Playwright's locator-based click handles scrollIntoView + auto-wait.
682
+ // `requireBottomHalf` filters out wizard step-indicator buttons at the
683
+ // top (e.g., "Chia sẻ" step header at y < 200 which navigates the wizard
684
+ // instead of publishing). Used by publish verb search.
685
+ const findByVerbs = async (verbs, opts = {}) => {
686
+ const requireBottomHalf = !!opts.requireBottomHalf;
687
+ for (const v of verbs) {
688
+ const found = await page.evaluate(({ verb, bottomHalf }) => {
689
+ // Page-wall flow renders the composer inside [role='dialog']. Prefer
690
+ // matches INSIDE a dialog — otherwise we hit background-page buttons
691
+ // (e.g. the home page's share-to-friends "Chia sẻ" button at the
692
+ // bottom of a feed item, aria-label "Gửi nội dung này cho bạn bè
693
+ // hoặc đăng lên trang cá nhân của bạn." — which is NOT the publish
694
+ // CTA). For BS composer (no dialog), this falls through to body.
695
+ const vh = window.innerHeight;
696
+ const tryScope = (rootSelectorOrNull) => {
697
+ const roots = rootSelectorOrNull
698
+ ? Array.from(document.querySelectorAll(rootSelectorOrNull))
699
+ : [document];
700
+ for (const root of roots) {
701
+ const els = root.querySelectorAll("button, [role='button']");
702
+ let bestEl = null;
703
+ for (const el of els) {
704
+ const t = (el.innerText || el.textContent || '').trim();
705
+ if (t !== verb) continue;
706
+ const r = el.getBoundingClientRect();
707
+ if (r.width < 8 || r.height < 8) continue;
708
+ const cs = getComputedStyle(el);
709
+ if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
710
+ if (el.getAttribute('aria-disabled') === 'true' || el.disabled) continue;
711
+ if (bottomHalf && r.y < vh * 0.4) continue;
712
+ bestEl = el;
713
+ }
714
+ if (bestEl) return bestEl;
715
+ }
716
+ return null;
717
+ };
718
+ // Pass 1: inside any [role='dialog'] (modal composer).
719
+ let bestEl = tryScope("[role='dialog']");
720
+ // Pass 2: fall back to whole document (BS composer non-modal flow).
721
+ if (!bestEl) bestEl = tryScope(null);
722
+ if (!bestEl) return null;
723
+ // Tag the element so Playwright can find it back. data-attribute is
724
+ // safe + ignored by FB's React reconciliation.
725
+ const marker = '__fbpw_target__';
726
+ bestEl.setAttribute(marker, '1');
727
+ return { selector: `[${marker}='1']`, tag: bestEl.tagName.toLowerCase() };
728
+ }, { verb: v, bottomHalf: requireBottomHalf });
729
+ if (found) {
730
+ const loc = page.locator(found.selector);
731
+ return {
732
+ hit: {
733
+ click: async (opts) => {
734
+ // scrollIntoViewIfNeeded handles off-screen primary actions
735
+ // (e.g. Chia sẻ ngay at y=-274 after a step jump). Then
736
+ // Playwright's click auto-waits visible+stable.
737
+ try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
738
+ await loc.click({ timeout: opts?.timeout || 5000 });
739
+ // Remove the marker so subsequent findByVerbs gets a fresh
740
+ // element instead of clinging to the now-stale node.
741
+ await page.evaluate((sel) => {
742
+ document.querySelectorAll(sel).forEach((el) => el.removeAttribute(sel.replace(/[\[\]'=1]/g, '')));
743
+ }, found.selector).catch(() => {});
744
+ },
745
+ isVisible: async () => loc.isVisible().catch(() => false),
746
+ isEnabled: async () => loc.isEnabled().catch(() => true),
747
+ getAttribute: (a) => loc.getAttribute(a).catch(() => null),
748
+ page: () => page,
749
+ },
750
+ sel: `exact-text:'${v}' (<${found.tag}>)`,
751
+ verb: v,
752
+ };
753
+ }
754
+ }
755
+ return null;
756
+ };
757
+
758
+ // Simplified step loop — no stuck-detect (false-positive prone because
759
+ // BS composer's wizard header buttons are the same across all 3 steps).
760
+ // Each iteration: fillMetadata + pick thumbnail if on thumb step +
761
+ // (publish if available, else Tiếp). If we never find publish in 6
762
+ // iterations, throw with diagnostics.
763
+ let published = false;
764
+ let customThumbDone = false;
765
+ for (let step = 0; step < 6 && !published; step++) {
766
+ await page.waitForTimeout(3000);
767
+ await fillMetadata();
768
+
769
+ // PAGE-WALL thumb flow (form 2 has a "Chỉnh sửa" overlay button on the
770
+ // video preview → opens "Chỉnh sửa hình thu nhỏ" modal → "Tải lên" →
771
+ // file picker → "Lưu"). Per user screenshots, this is the only way to
772
+ // attach a custom thumbnail in the Page-wall composer.
773
+ if (!customThumbDone && thumbPath && /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url())) {
774
+ const editBtn = await page.evaluate(() => {
775
+ // Look for the thumbnail-edit button inside ANY visible composer
776
+ // dialog. The overlay is a small button on the video preview with:
777
+ // - aria-label containing "hình thu nhỏ" / "thumbnail"
778
+ // - OR text "Chỉnh sửa" with size < ~120px wide (overlay vs full
779
+ // "Chỉnh sửa ảnh bìa" page cover button which is much wider)
780
+ const dlgs = document.querySelectorAll("[role='dialog']");
781
+ for (const dlg of dlgs) {
782
+ const r = dlg.getBoundingClientRect();
783
+ if (r.width < 8 || r.height < 8) continue;
784
+ const cs = getComputedStyle(dlg);
785
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
786
+ const cands = dlg.querySelectorAll("[role='button'], button, [aria-label]");
787
+ for (const el of cands) {
788
+ const t = (el.innerText || '').trim();
789
+ const al = (el.getAttribute('aria-label') || '').trim();
790
+ const ar = el.getBoundingClientRect();
791
+ if (ar.width < 8 || ar.height < 8) continue;
792
+ // Specific aria match for the thumb-edit overlay.
793
+ if (/hình thu nhỏ|thumbnail|edit thumb/i.test(al)) {
794
+ el.setAttribute('__fbpw_thumb_edit__', '1');
795
+ return { selector: "[__fbpw_thumb_edit__='1']", aria: al.slice(0, 60), text: t.slice(0, 40), via: 'aria-thumb' };
796
+ }
797
+ // Bare "Chỉnh sửa" text — but ONLY if narrow (overlay pill)
798
+ // AND not a page-cover edit button (which is wide).
799
+ if ((t === 'Chỉnh sửa' || t === 'Edit') && ar.width < 140 && ar.height < 60) {
800
+ // Avoid matching page-cover "Chỉnh sửa ảnh bìa" / sidebar.
801
+ if (/ảnh bìa|cover|trang cá nhân|profile|lối tắt|shortcut/i.test(al)) continue;
802
+ el.setAttribute('__fbpw_thumb_edit__', '1');
803
+ return { selector: "[__fbpw_thumb_edit__='1']", aria: al.slice(0, 60), text: t.slice(0, 40), via: 'narrow-pill', size: `${Math.round(ar.width)}x${Math.round(ar.height)}` };
804
+ }
805
+ }
806
+ }
807
+ return null;
808
+ }).catch(() => null);
809
+
810
+ if (editBtn) {
811
+ log('info', `[fb-pw] page-wall thumb — opening "Chỉnh sửa hình thu nhỏ" modal (via=${editBtn.via}, aria="${editBtn.aria}", text="${editBtn.text}"${editBtn.size ? `, size=${editBtn.size}` : ''})…`);
812
+ try {
813
+ await page.locator(editBtn.selector).click({ timeout: 5000 });
814
+ await page.waitForTimeout(2500);
815
+
816
+ // Wait for the thumb-edit modal to mount. Header text =
817
+ // "Chỉnh sửa hình thu nhỏ".
818
+ await page.evaluate(() => document.querySelectorAll("[__fbpw_thumb_edit__]").forEach((el) => el.removeAttribute('__fbpw_thumb_edit__'))).catch(() => {});
819
+
820
+ // Click "Tải lên" button — opens OS file picker. Use filechooser
821
+ // race to inject the file path directly.
822
+ const uploadCandidates = [
823
+ "[role='dialog'] div[role='button']:has-text('Tải lên')",
824
+ "[role='dialog'] button:has-text('Tải lên')",
825
+ "[role='dialog'] [aria-label='Tải lên']",
826
+ "[role='dialog'] [aria-label*='Tải hình thu nhỏ']",
827
+ "[role='dialog'] div[role='button']:has-text('Upload')",
828
+ ];
829
+ let uploaded = false;
830
+ for (const sel of uploadCandidates) {
831
+ const btn = await firstVisible(page.locator(sel), 5);
832
+ if (!btn) continue;
833
+ try {
834
+ const [chooser] = await Promise.all([
835
+ page.waitForEvent('filechooser', { timeout: 8000 }),
836
+ btn.click({ timeout: 3000 }),
837
+ ]);
838
+ await chooser.setFiles(thumbPath);
839
+ log('info', `[fb-pw] page-wall thumb — file set via "${sel}"`);
840
+ uploaded = true;
841
+ await page.waitForTimeout(3500); // FB processes image
842
+ break;
843
+ } catch (e) {
844
+ log('info', `[fb-pw] "Tải lên" via "${sel}" failed: ${e.message.slice(0, 80)}`);
845
+ }
846
+ }
847
+
848
+ // Fallback — direct setInputFiles on a hidden image-accepting
849
+ // file input inside the modal.
850
+ if (!uploaded) {
851
+ const directInput = page.locator("[role='dialog'] input[type='file'][accept*='image']").last();
852
+ if (await directInput.count().catch(() => 0) > 0) {
853
+ try {
854
+ await directInput.setInputFiles(thumbPath);
855
+ log('info', '[fb-pw] page-wall thumb — file set via direct input[type=file]');
856
+ uploaded = true;
857
+ await page.waitForTimeout(3500);
858
+ } catch (e) {
859
+ log('warn', `[fb-pw] direct file input failed: ${e.message.slice(0, 80)}`);
860
+ }
861
+ }
862
+ }
863
+
864
+ if (uploaded) {
865
+ // Click "Lưu" to save the new thumbnail.
866
+ const saveCandidates = [
867
+ "[role='dialog'] div[role='button']:has-text('Lưu')",
868
+ "[role='dialog'] button:has-text('Lưu')",
869
+ "[role='dialog'] [aria-label='Lưu']",
870
+ "[role='dialog'] div[role='button']:has-text('Save')",
871
+ ];
872
+ let saved = false;
873
+ for (const sel of saveCandidates) {
874
+ const btn = await firstVisible(page.locator(sel), 3);
875
+ if (!btn) continue;
876
+ try {
877
+ await btn.click({ timeout: 3000 });
878
+ log('info', `[fb-pw] page-wall thumb — saved via "${sel}"`);
879
+ saved = true;
880
+ break;
881
+ } catch (e) {
882
+ log('info', `[fb-pw] "Lưu" via "${sel}" failed: ${e.message.slice(0, 80)}`);
883
+ }
884
+ }
885
+ if (saved) {
886
+ // Poll up to 60s for the "Chỉnh sửa hình thu nhỏ" modal to
887
+ // CLOSE. FB processes the uploaded image on the server side
888
+ // before dismissing the modal — for large images or slow
889
+ // worker connections, this can take 20-40s. If the modal
890
+ // doesn't close within 12s of polling, RE-CLICK Lưu (in case
891
+ // the first click didn't register because the button was
892
+ // disabled while FB was still hashing the upload).
893
+ const closeDeadline = Date.now() + 60_000;
894
+ let modalClosed = false;
895
+ let retryCount = 0;
896
+ const RETRY_AFTER_MS = 12_000;
897
+ let lastRetryAt = Date.now();
898
+ while (Date.now() < closeDeadline) {
899
+ const still = await page.evaluate(() => {
900
+ const dlgs = document.querySelectorAll("[role='dialog']");
901
+ for (const dlg of dlgs) {
902
+ const txt = (dlg.innerText || '').slice(0, 200);
903
+ if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(txt)) {
904
+ const r = dlg.getBoundingClientRect();
905
+ if (r.width > 8 && r.height > 8) return true;
906
+ }
907
+ }
908
+ return false;
909
+ }).catch(() => false);
910
+ if (!still) { modalClosed = true; break; }
911
+ // Retry Lưu click after RETRY_AFTER_MS without progress —
912
+ // limit to 3 retries (so we still leave time for FB to
913
+ // finish processing on the last attempt).
914
+ if (retryCount < 3 && Date.now() - lastRetryAt >= RETRY_AFTER_MS) {
915
+ retryCount++;
916
+ lastRetryAt = Date.now();
917
+ log('info', `[fb-pw] thumb modal still open after ${retryCount * (RETRY_AFTER_MS / 1000)}s — re-clicking Lưu (retry ${retryCount}/3)`);
918
+ for (const sel of saveCandidates) {
919
+ const btn = await firstVisible(page.locator(sel), 2);
920
+ if (!btn) continue;
921
+ try {
922
+ await btn.click({ timeout: 2500 });
923
+ break;
924
+ } catch {}
925
+ }
926
+ }
927
+ await page.waitForTimeout(1000);
928
+ }
929
+ if (modalClosed) {
930
+ log('info', `[fb-pw] page-wall thumb — modal closed after save (retries=${retryCount})`);
931
+ } else {
932
+ log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
933
+ await page.keyboard.press('Escape').catch(() => {});
934
+ await page.waitForTimeout(800);
935
+ const cancel = await firstVisible(page.locator("[role='dialog'] div[role='button']:has-text('Hủy'), [role='dialog'] button:has-text('Hủy')"), 2);
936
+ if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
937
+ await page.waitForTimeout(1500);
938
+ }
939
+ } else {
940
+ log('warn', '[fb-pw] page-wall thumb upload OK but "Lưu" click failed — modal may stay open');
941
+ }
942
+ } else {
943
+ log('warn', '[fb-pw] page-wall thumb upload failed — closing modal via Hủy to continue');
944
+ const cancel = await firstVisible(page.locator("[role='dialog'] div[role='button']:has-text('Hủy'), [role='dialog'] button:has-text('Hủy')"), 2);
945
+ if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
946
+ await page.waitForTimeout(1500);
947
+ }
948
+ } catch (e) {
949
+ log('warn', `[fb-pw] page-wall thumb flow failed: ${e.message.slice(0, 100)}`);
950
+ }
951
+ customThumbDone = true;
952
+ }
953
+ }
954
+
955
+ // Thumbnail step handling (BS composer legacy).
956
+ const onThumbStep = await firstVisible(page.locator(
957
+ "[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail'], div[role='button']:has-text('Tải hình ảnh lên'), div[role='button']:has-text('Upload image')"
958
+ ), 3);
959
+ if (onThumbStep && !customThumbDone) {
960
+ if (thumbPath) {
961
+ log('info', '[fb-pw] thumbnail step — uploading custom thumb…');
962
+ let uploaded = false;
963
+
964
+ // Probe: dump ALL file inputs (incl. hidden) for diagnostic + find
965
+ // the image-accepting one. FB usually mounts a hidden
966
+ // <input type="file" accept="image/*"> tied to the upload button.
967
+ const fileInputDiag = await page.evaluate(() => {
968
+ const all = document.querySelectorAll("input[type='file']");
969
+ return Array.from(all).slice(0, 6).map((el, i) => ({
970
+ i,
971
+ accept: (el.accept || '').slice(0, 60),
972
+ name: el.name || '',
973
+ id: el.id || '',
974
+ hidden: el.offsetParent === null,
975
+ parent: el.parentElement?.tagName?.toLowerCase() || '',
976
+ }));
977
+ }).catch(() => []);
978
+ log('info', `[fb-pw] thumb-step file inputs found: ${fileInputDiag.length}`);
979
+ fileInputDiag.forEach((fi) => log('info', ` thumb-file-input[${fi.i}] accept=${fi.accept} hidden=${fi.hidden} parent=${fi.parent}`));
980
+
981
+ // Path A — setInputFiles on a hidden image-accepting file input.
982
+ // Some FB builds expose the input directly; setting files bypasses
983
+ // the click-to-OS-picker flow that's flaky in CDP-attached Chrome.
984
+ const directImgInputs = page.locator("input[type='file'][accept*='image']");
985
+ const dCount = Math.min(await directImgInputs.count().catch(() => 0), 3);
986
+ for (let i = 0; i < dCount && !uploaded; i++) {
987
+ try {
988
+ await directImgInputs.nth(i).setInputFiles(thumbPath);
989
+ uploaded = true;
990
+ log('info', `[fb-pw] custom thumbnail set via direct image input[${i}]`);
991
+ await page.waitForTimeout(3000);
992
+ } catch (e) { log('info', `[fb-pw] direct img input[${i}] failed: ${e.message.slice(0, 80)}`); }
993
+ }
994
+
995
+ // Path B — scope dispatchFile to ANY file input even if its accept
996
+ // attribute doesn't strictly match `image`. Some FB cohorts use
997
+ // `accept="image/*,image/heif"` and our `*=image` matches; others
998
+ // use `accept=""` and need a looser selector.
999
+ if (!uploaded) {
1000
+ const anyFile = page.locator("input[type='file']");
1001
+ const aCount = Math.min(await anyFile.count().catch(() => 0), 5);
1002
+ for (let i = 0; i < aCount && !uploaded; i++) {
1003
+ try {
1004
+ await anyFile.nth(i).setInputFiles(thumbPath);
1005
+ uploaded = true;
1006
+ log('info', `[fb-pw] custom thumbnail set via any-file input[${i}]`);
1007
+ await page.waitForTimeout(3000);
1008
+ } catch (e) { log('info', `[fb-pw] any-file input[${i}] failed: ${e.message.slice(0, 80)}`); }
1009
+ }
1010
+ }
1011
+
1012
+ // Path C — TWO-step flow (verified manually by user):
1013
+ // 1. Click "Tải hình ảnh lên" tab to switch to the upload pane.
1014
+ // 2. INSIDE the pane, click the <a> element containing "Upload"
1015
+ // text — THIS is what actually fires the OS file chooser.
1016
+ // The original 1-step click on the tab itself only switches panes,
1017
+ // it does NOT trigger the picker.
1018
+ if (!uploaded) {
1019
+ const tabCandidates = [
1020
+ "div[role='button']:has-text('Tải hình ảnh lên')",
1021
+ "div[role='button']:has-text('Upload image')",
1022
+ "[aria-label='Tải hình ảnh lên']",
1023
+ "[aria-label='Upload image']",
1024
+ ];
1025
+ // Step 1: switch tab.
1026
+ let tabClicked = false;
1027
+ for (const sel of tabCandidates) {
1028
+ const btn = await firstVisible(page.locator(sel), 3);
1029
+ if (!btn) continue;
1030
+ try {
1031
+ await btn.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
1032
+ await btn.click({ timeout: 3000 });
1033
+ tabClicked = true;
1034
+ log('info', `[fb-pw] upload-tab switched via "${sel}"`);
1035
+ break;
1036
+ } catch (e) {
1037
+ log('info', `[fb-pw] tab switch via "${sel}" failed: ${e.message.slice(0, 80)}`);
1038
+ }
1039
+ }
1040
+ if (tabClicked) {
1041
+ await page.waitForTimeout(2500); // let pane render fully
1042
+ // Step 2: click the actual <a> trigger inside the Upload pane.
1043
+ // Per user's xpath `.//a[contains(text(), "Upload")]`. VN UI
1044
+ // shows "Tải hình ảnh lên". Try multiple variants + force-click
1045
+ // + mouse fallback (the locator click sometimes times out due
1046
+ // to FB's overlay heuristics, even though the element is alive).
1047
+ const aCandidates = [
1048
+ "a:has-text('Upload image')",
1049
+ "a:has-text('Tải hình ảnh lên')",
1050
+ "a:has-text('Tải hình')",
1051
+ "a:has-text('Upload')",
1052
+ "a:has-text('Tải lên')",
1053
+ "a:has-text('Chọn ảnh')",
1054
+ ];
1055
+ for (const sel of aCandidates) {
1056
+ if (uploaded) break;
1057
+ const link = page.locator(sel).first();
1058
+ if (await link.count().catch(() => 0) === 0) continue;
1059
+ if (!(await link.isVisible().catch(() => false))) continue;
1060
+
1061
+ // Attempt 1 — normal click + filechooser race.
1062
+ try {
1063
+ await link.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
1064
+ const [chooser] = await Promise.all([
1065
+ page.waitForEvent('filechooser', { timeout: 8000 }),
1066
+ link.click({ timeout: 3000 }),
1067
+ ]);
1068
+ await chooser.setFiles(thumbPath);
1069
+ uploaded = true;
1070
+ log('info', `[fb-pw] custom thumbnail set via <a> "${sel}"`);
1071
+ // FB needs ~10s after thumbnail upload to process the image
1072
+ // and re-enable the Tiếp button. Without this wait, Tiếp
1073
+ // stays aria-disabled and our findByVerbs skips it.
1074
+ await page.waitForTimeout(10000);
1075
+ break;
1076
+ } catch (e1) {
1077
+ log('info', `[fb-pw] <a> click via "${sel}" failed (normal): ${e1.message.slice(0, 80)}`);
1078
+ }
1079
+
1080
+ // Attempt 2 — force click (bypass actionability checks).
1081
+ try {
1082
+ const [chooser] = await Promise.all([
1083
+ page.waitForEvent('filechooser', { timeout: 8000 }),
1084
+ link.click({ timeout: 3000, force: true }),
1085
+ ]);
1086
+ await chooser.setFiles(thumbPath);
1087
+ uploaded = true;
1088
+ log('info', `[fb-pw] custom thumbnail set via <a> "${sel}" (force)`);
1089
+ await page.waitForTimeout(3000);
1090
+ break;
1091
+ } catch (e2) {
1092
+ log('info', `[fb-pw] <a> click via "${sel}" failed (force): ${e2.message.slice(0, 80)}`);
1093
+ }
1094
+
1095
+ // Attempt 3 — CDP mouse click at element bbox center.
1096
+ const bbox = await link.boundingBox().catch(() => null);
1097
+ if (bbox) {
1098
+ try {
1099
+ const [chooser] = await Promise.all([
1100
+ page.waitForEvent('filechooser', { timeout: 8000 }),
1101
+ page.mouse.click(bbox.x + bbox.width / 2, bbox.y + bbox.height / 2),
1102
+ ]);
1103
+ await chooser.setFiles(thumbPath);
1104
+ uploaded = true;
1105
+ log('info', `[fb-pw] custom thumbnail set via <a> "${sel}" (mouse @ bbox)`);
1106
+ await page.waitForTimeout(3000);
1107
+ break;
1108
+ } catch (e3) {
1109
+ log('info', `[fb-pw] <a> mouse click via "${sel}" failed: ${e3.message.slice(0, 80)}`);
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ // Path D — JS-native click on "Tải hình ảnh lên" via evaluate, then
1117
+ // poll for a lazy-injected file input. Playwright's click sometimes
1118
+ // gets swallowed by FB's React layer; el.click() in page context
1119
+ // dispatches a synthetic but page-trusted event that triggers the
1120
+ // file picker.
1121
+ if (!uploaded) {
1122
+ const jsClicked = await page.evaluate(() => {
1123
+ const els = document.querySelectorAll("[role='button'], button, label");
1124
+ for (const el of els) {
1125
+ const t = (el.innerText || '').trim();
1126
+ if (/^Tải hình ảnh lên$|^Upload image$/i.test(t)) {
1127
+ const r = el.getBoundingClientRect();
1128
+ if (r.width < 8 || r.height < 8) continue;
1129
+ el.click();
1130
+ return true;
1131
+ }
1132
+ }
1133
+ return null;
1134
+ }).catch(() => null);
1135
+ if (jsClicked) {
1136
+ log('info', '[fb-pw] JS-click on Tải hình ảnh lên fired — polling for file input…');
1137
+ // Wait + poll for input[type=file] to materialize
1138
+ for (let i = 0; i < 12 && !uploaded; i++) {
1139
+ await page.waitForTimeout(500);
1140
+ const lazy = page.locator("input[type='file']").last();
1141
+ if (await lazy.count().catch(() => 0) > 0) {
1142
+ try {
1143
+ await lazy.setInputFiles(thumbPath);
1144
+ uploaded = true;
1145
+ log('info', `[fb-pw] custom thumbnail set via JS-click + lazy input (poll #${i + 1})`);
1146
+ await page.waitForTimeout(3000);
1147
+ break;
1148
+ } catch (e) { /* keep polling */ }
1149
+ }
1150
+ }
1151
+ if (!uploaded) log('info', '[fb-pw] JS-click did not produce a usable file input within 6s');
1152
+ }
1153
+ }
1154
+
1155
+ // Path E — last attempt: dispatch a "trusted click" via CDP's
1156
+ // Input.dispatchMouseEvent at the button's center. CDP-dispatched
1157
+ // mouse events ARE trusted in real Chromium (unlike DOM .click()).
1158
+ if (!uploaded) {
1159
+ const bbox = await page.evaluate(() => {
1160
+ const els = document.querySelectorAll("[role='button'], button, label");
1161
+ for (const el of els) {
1162
+ const t = (el.innerText || '').trim();
1163
+ if (/^Tải hình ảnh lên$|^Upload image$/i.test(t)) {
1164
+ const r = el.getBoundingClientRect();
1165
+ if (r.width < 8 || r.height < 8) continue;
1166
+ el.scrollIntoView({ block: 'center' });
1167
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
1168
+ }
1169
+ }
1170
+ return null;
1171
+ }).catch(() => null);
1172
+ if (bbox) {
1173
+ try {
1174
+ const [chooser] = await Promise.all([
1175
+ page.waitForEvent('filechooser', { timeout: 5000 }),
1176
+ page.mouse.click(bbox.x, bbox.y),
1177
+ ]);
1178
+ await chooser.setFiles(thumbPath);
1179
+ uploaded = true;
1180
+ log('info', `[fb-pw] custom thumbnail set via CDP-mouse @ (${Math.round(bbox.x)},${Math.round(bbox.y)})`);
1181
+ await page.waitForTimeout(3000);
1182
+ } catch (e) {
1183
+ log('info', `[fb-pw] CDP-mouse + filechooser failed: ${e.message.slice(0, 80)}`);
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ // Path C — fall back to auto-thumb #1. Thumbnail upload isn't a
1189
+ // hard requirement for FB Reels publish; pick auto-thumb so the
1190
+ // post still goes out, log a warning so the failure is visible.
1191
+ if (!uploaded) {
1192
+ log('warn', '[fb-pw] custom thumb upload failed — falling back to auto-thumb #1');
1193
+ const auto1 = await firstVisible(page.locator("[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail 1']"), 3);
1194
+ if (auto1) await auto1.click({ timeout: 3000 }).catch(() => {});
1195
+ await page.waitForTimeout(1500);
1196
+ }
1197
+ customThumbDone = true;
1198
+ } else {
1199
+ log('info', '[fb-pw] thumbnail step — picking auto-thumb #1 (no custom thumb)');
1200
+ const auto1 = await firstVisible(page.locator("[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail 1']"), 3);
1201
+ if (auto1) {
1202
+ await auto1.click({ timeout: 3000 }).catch(() => {});
1203
+ await page.waitForTimeout(1500);
1204
+ }
1205
+ customThumbDone = true;
1206
+ }
1207
+ }
1208
+
1209
+ // BEFORE looking for the publish button, dismiss any FB cross-sell
1210
+ // pop-ups (WhatsApp button, story share, promote post) that appear as
1211
+ // overlay dialogs and BLOCK clicks on the underlying publish CTA. On
1212
+ // brain-made-simple page wall, "Thêm nút WhatsApp / Lúc khác" pops up
1213
+ // and covers the Đăng button at y=812 — Playwright's auto-retry click
1214
+ // still "succeeds" but FB's handler doesn't fire because the overlay
1215
+ // intercepts pointer events.
1216
+ const dismissVerbsPre = ['Lúc khác', 'Không phải bây giờ', 'Để sau', 'Bỏ qua', 'Không, cảm ơn', 'Later', 'Not now', 'Skip', 'No thanks'];
1217
+ for (let dr = 0; dr < 4; dr++) {
1218
+ const dh = await page.evaluate((verbs) => {
1219
+ const dlgs = document.querySelectorAll("[role='dialog']");
1220
+ for (const dlg of dlgs) {
1221
+ const r = dlg.getBoundingClientRect();
1222
+ if (r.width < 8 || r.height < 8) continue;
1223
+ const cs = getComputedStyle(dlg);
1224
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1225
+ const dlgAria = (dlg.getAttribute('aria-label') || '').toLowerCase();
1226
+ // Don't touch the composer dialog itself.
1227
+ if (/tạo bài viết|create post|cài đặt thước phim|reel settings/i.test(dlgAria)) continue;
1228
+ const btns = dlg.querySelectorAll("button, [role='button']");
1229
+ for (const v of verbs) {
1230
+ for (const b of btns) {
1231
+ const t = (b.innerText || '').trim();
1232
+ const a = (b.getAttribute('aria-label') || '').trim();
1233
+ if (t === v || a === v) {
1234
+ b.setAttribute('__fbpw_predismiss__', '1');
1235
+ return { selector: "[__fbpw_predismiss__='1']", verb: v };
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ return null;
1241
+ }, dismissVerbsPre).catch(() => null);
1242
+ if (!dh) break;
1243
+ log('info', `[fb-pw] pre-publish dismissing overlay via "${dh.verb}"`);
1244
+ try {
1245
+ await page.locator(dh.selector).click({ timeout: 3000 });
1246
+ await page.waitForTimeout(1500);
1247
+ await page.evaluate(() => document.querySelectorAll("[__fbpw_predismiss__]").forEach((el) => el.removeAttribute('__fbpw_predismiss__'))).catch(() => {});
1248
+ } catch (e) {
1249
+ log('warn', `[fb-pw] pre-dismiss click failed: ${e.message.slice(0, 80)}`);
1250
+ break;
1251
+ }
1252
+ }
1253
+
1254
+ // Prefer a publish button over a Tiếp. If we see Đăng/Chia sẻ ngay,
1255
+ // click it and break. requireBottomHalf=true filters out wizard step
1256
+ // indicators at the top (e.g., "Chia sẻ" header that navigates instead
1257
+ // of publishing).
1258
+ const pub = await findByVerbs(publishVerbs, { requireBottomHalf: true });
1259
+ if (pub) {
1260
+ log('info', `[fb-pw] click publish "${pub.verb}" via "${pub.sel}" (step ${step + 1})`);
1261
+ // Snapshot the captured-IDs list RIGHT BEFORE the publish click. The
1262
+ // network listener captures from EVERY graph response, including the
1263
+ // pre-publish page wall feed (~100+ IDs). Only IDs captured AFTER
1264
+ // this snapshot are candidates for the new reel — and the FIRST
1265
+ // new one is the publish mutation response.
1266
+ capturedReelIdsSnapshotLen = capturedReelIds.length;
1267
+ try {
1268
+ await waitAndClick(pub.hit, { timeoutMs: 120_000, log, label: `Publish(${pub.verb})` });
1269
+
1270
+ // FB shows a confirmation dialog ("Chia sẻ ai biết tin trên Facebook"
1271
+ // / "Are you sure you want to share?") after the primary publish
1272
+ // click. Without confirming, the post stays as a draft and the page
1273
+ // doesn't actually publish. Poll for the dialog up to 8s (FB's
1274
+ // dialog mount is slow — a single 2.5s wait often missed it).
1275
+ const confirmVerbs = ['Xác nhận', 'Xác minh', 'Đồng ý', 'Confirm', 'Continue', 'OK', 'Đăng'];
1276
+ let confirmHit = null;
1277
+ const cfmDeadline = Date.now() + 8000;
1278
+ while (Date.now() < cfmDeadline && !confirmHit) {
1279
+ confirmHit = await page.evaluate((verbs) => {
1280
+ const dlgs = document.querySelectorAll("[role='dialog']");
1281
+ for (const dlg of dlgs) {
1282
+ const r = dlg.getBoundingClientRect();
1283
+ if (r.width < 8 || r.height < 8) continue;
1284
+ const cs = getComputedStyle(dlg);
1285
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1286
+ const btns = dlg.querySelectorAll("button, [role='button']");
1287
+ for (const v of verbs) {
1288
+ for (const b of btns) {
1289
+ const t = (b.innerText || '').trim();
1290
+ if (t === v) {
1291
+ b.setAttribute('__fbpw_confirm__', '1');
1292
+ return { selector: "[__fbpw_confirm__='1']", verb: v };
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+ return null;
1298
+ }, confirmVerbs).catch(() => null);
1299
+ if (!confirmHit) await page.waitForTimeout(600);
1300
+ }
1301
+ if (confirmHit) {
1302
+ log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
1303
+ const cLoc = page.locator(confirmHit.selector);
1304
+ try { await cLoc.scrollIntoViewIfNeeded({ timeout: 2000 }); } catch {}
1305
+ await cLoc.click({ timeout: 5000 }).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.slice(0, 80)}`));
1306
+ await page.waitForTimeout(3000);
1307
+ } else {
1308
+ log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
1309
+ }
1310
+
1311
+ // Dismiss post-publish prompts that block the actual commit. After
1312
+ // clicking Đăng, FB sometimes pops a SECONDARY dialog asking to
1313
+ // "Add WhatsApp button" / "Add to story" / "Promote post" with a
1314
+ // "Lúc khác" (Later) / "Không" / "Skip" CTA. If we don't dismiss
1315
+ // these, the post sits in a half-committed state and never actually
1316
+ // appears on the wall. Run a short loop trying to find + click
1317
+ // these dismiss CTAs in any visible dialog.
1318
+ const dismissVerbs = ['Lúc khác', 'Không phải bây giờ', 'Để sau', 'Bỏ qua', 'Không, cảm ơn', 'Later', 'Not now', 'Skip', 'No thanks', 'Dismiss', 'Close'];
1319
+ for (let dismissRound = 0; dismissRound < 4; dismissRound++) {
1320
+ const dismissHit = await page.evaluate((verbs) => {
1321
+ const dlgs = document.querySelectorAll("[role='dialog']");
1322
+ for (const dlg of dlgs) {
1323
+ const r = dlg.getBoundingClientRect();
1324
+ if (r.width < 8 || r.height < 8) continue;
1325
+ const cs = getComputedStyle(dlg);
1326
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1327
+ // Skip the main composer dialog (aria-label "Tạo bài viết"
1328
+ // contains the publish button — don't accidentally close it).
1329
+ const dlgAria = (dlg.getAttribute('aria-label') || '').toLowerCase();
1330
+ if (/tạo bài viết|create post|cài đặt thước phim|reel settings/i.test(dlgAria)) continue;
1331
+ const btns = dlg.querySelectorAll("button, [role='button']");
1332
+ for (const v of verbs) {
1333
+ for (const b of btns) {
1334
+ const t = (b.innerText || '').trim();
1335
+ const a = (b.getAttribute('aria-label') || '').trim();
1336
+ if (t === v || a === v) {
1337
+ b.setAttribute('__fbpw_dismiss__', '1');
1338
+ return { selector: "[__fbpw_dismiss__='1']", verb: v };
1339
+ }
1340
+ }
1341
+ }
1342
+ }
1343
+ return null;
1344
+ }, dismissVerbs).catch(() => null);
1345
+ if (!dismissHit) break;
1346
+ log('info', `[fb-pw] dismissing post-publish prompt via "${dismissHit.verb}"`);
1347
+ try {
1348
+ await page.locator(dismissHit.selector).click({ timeout: 3000 });
1349
+ await page.waitForTimeout(2000);
1350
+ await page.evaluate(() => document.querySelectorAll("[__fbpw_dismiss__]").forEach((el) => el.removeAttribute('__fbpw_dismiss__'))).catch(() => {});
1351
+ } catch (e) {
1352
+ log('warn', `[fb-pw] dismiss click failed: ${e.message.slice(0, 80)}`);
1353
+ break;
1354
+ }
1355
+ }
1356
+
1357
+ published = true;
1358
+ break;
1359
+ } catch (e) {
1360
+ await dumpInventory(page, log, `publish-not-clickable-${step + 1}`);
1361
+ await dumpFailure(page, `publish-not-clickable-${step + 1}`, log);
1362
+ throw new Error(`FB publish button "${pub.verb}" found but did not click: ${e.message}`);
1363
+ }
1364
+ }
1365
+
1366
+ // No publish yet — advance via Tiếp.
1367
+ const next = await findByVerbs(nextVerbs);
1368
+ if (!next) {
1369
+ await dumpInventory(page, log, `no-advance-${step + 1}`);
1370
+ await dumpFailure(page, `no-advance-${step + 1}`, log);
1371
+ throw new Error(`FB composer step ${step + 1}: neither publish nor Tiếp button found`);
1372
+ }
1373
+ log('info', `[fb-pw] click "${next.verb}" via "${next.sel}" (step ${step + 1})`);
1374
+ try {
1375
+ await waitAndClick(next.hit, { timeoutMs: 180_000, log, label: `Tiếp(${step + 1})` });
1376
+ } catch (e) {
1377
+ await dumpInventory(page, log, `tiep-not-clickable-${step + 1}`);
1378
+ await dumpFailure(page, `tiep-not-clickable-${step + 1}`, log);
1379
+ throw new Error(`FB Tiếp not clickable at step ${step + 1}: ${e.message}`);
1380
+ }
1381
+
1382
+ // BS-only side-wizard jump fallback — DO NOT run in Page-wall mode.
1383
+ // The BS composer has a left-side wizard with clickable step headers
1384
+ // ("Thước phim" / "Tùy chọn" / "Chia sẻ"). Clicking "Chia sẻ" jumps to
1385
+ // the publish step. The Page-wall modal has NO such wizard; clicking
1386
+ // any "Chia sẻ" text on the page falls through to a feed-item's share
1387
+ // button or sidebar nav, navigating AWAY from the composer and
1388
+ // breaking the flow (iter-38 ended up on /profile.php?sk=reels_tab).
1389
+ const onPageWall = /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url());
1390
+ if (step >= 2 && !published && !onPageWall) {
1391
+ log('info', '[fb-pw] Tiếp ineffective — trying side-wizard jump to "Chia sẻ" step (BS only)');
1392
+ const jumpBbox = await page.evaluate(() => {
1393
+ const els = document.querySelectorAll("button, [role='button']");
1394
+ for (const el of els) {
1395
+ const t = (el.innerText || '').trim();
1396
+ if (t !== 'Chia sẻ') continue;
1397
+ const r = el.getBoundingClientRect();
1398
+ if (r.width < 8) continue;
1399
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
1400
+ }
1401
+ return null;
1402
+ });
1403
+ if (jumpBbox) {
1404
+ await page.mouse.click(jumpBbox.x, jumpBbox.y);
1405
+ log('info', `[fb-pw] jumped to Chia sẻ step @ (${Math.round(jumpBbox.x)},${Math.round(jumpBbox.y)})`);
1406
+ await page.waitForTimeout(3000);
1407
+ }
1408
+ }
1409
+ }
1410
+ if (!published) {
1411
+ await dumpInventory(page, log, 'no-publish-after-5');
1412
+ await dumpFailure(page, 'no-publish-after-5', log);
1413
+ throw new Error('FB composer: ran out of Tiếp steps without finding a publish button');
1414
+ }
1415
+
1416
+ // 7) Post-publish — capture URL via multiple sources. After Page-wall
1417
+ // flow publish, FB often shows a "Đã đăng" toast OR navigates to the
1418
+ // page wall where the new reel is the latest item.
1419
+ log('info', '[fb-pw] publish clicked — waiting for confirmation…');
1420
+ await page.waitForTimeout(15000); // longer wait for Graph mutation
1421
+ await dumpInventory(page, log, 'post-publish');
1422
+ await dumpFailure(page, 'post-publish', log);
1423
+
1424
+ // 7a) Spam-limit / block detection — FB rate-limits accounts that publish
1425
+ // too frequently. The block is rendered as a card (not always a
1426
+ // role='dialog') with text "Để bảo vệ cộng đồng khỏi spam, chúng tôi
1427
+ // giới hạn tần suất bài đăng…" and "Chỉnh sửa và đăng lại" / "Đóng"
1428
+ // buttons. If we see this, the reel WAS NOT POSTED — fail loudly so
1429
+ // the user knows to wait + cool down the account.
1430
+ const blockPhrases = [
1431
+ 'giới hạn tần suất',
1432
+ 'để bảo vệ cộng đồng khỏi spam',
1433
+ 'chỉnh sửa và đăng lại',
1434
+ 'spam protection',
1435
+ 'temporarily blocked',
1436
+ 'you are temporarily blocked',
1437
+ 'limit how often',
1438
+ 'để giữ cho cộng đồng',
1439
+ ];
1440
+ const blockHit = await page.evaluate((phrases) => {
1441
+ const text = (document.body?.innerText || '').toLowerCase();
1442
+ for (const p of phrases) {
1443
+ if (text.includes(p)) return p;
1444
+ }
1445
+ return null;
1446
+ }, blockPhrases).catch(() => null);
1447
+ if (blockHit) {
1448
+ throw new Error(`FB rate-limited / spam-blocked this account — publish was NOT accepted. Detected phrase: "${blockHit}". Cool down account before retry.`);
1449
+ }
1450
+
1451
+ let postUrl = '';
1452
+
1453
+ // (a) Title-anchored page-wall scrape — FB renders the just-published
1454
+ // reel inline on the page wall in a "Bài viết đã xuất" / "Vừa được
1455
+ // đăng" section. The reel has an `<a href="/reel/<id>/">` anchor
1456
+ // wrapping its thumbnail/title. We FIND that anchor by matching its
1457
+ // surrounding text against the title we just published (first 30
1458
+ // chars — collision-free signature). This is RELIABLE because:
1459
+ // - feed-recommended reels have OTHER titles
1460
+ // - the just-published reel uses the canonical /reel/<id>/ URL FB
1461
+ // expects (15-digit format, not the 18-digit story_id container)
1462
+ // (a.0) NAVIGATE TO PAGE REELS TAB — most reliable for finding the
1463
+ // just-published reel. /profile.php?sk=reels_tab lists Page's reels
1464
+ // in chronological order (newest first). The first tile with a
1465
+ // FRESH timestamp ("Vừa xong" / "X phút" / "X giây") is the new
1466
+ // reel. Skip tiles without a fresh timestamp — those are stale
1467
+ // same-title duplicates that lured iter-40/42 into wrong picks.
1468
+ //
1469
+ // This costs ~5s but is the only deterministic way given that:
1470
+ // - Page wall display order isn't strictly chronological
1471
+ // - "Quảng bá thước phim" appears on ALL Page reels, not just new
1472
+ // - Title match alone can't distinguish same-title duplicates
1473
+ try {
1474
+ // Find a link to the page's reels tab. Multi-strategy:
1475
+ // 1. Any existing href containing "sk=reels_tab"
1476
+ // 2. Extract Page ID from "Dòng thời gian của X" timeline anchor or
1477
+ // any /profile.php?id=<id> anchor, then build the reels_tab URL
1478
+ // 3. Extract Page slug from any /<handle>/ anchor
1479
+ const reelsTabHref = await page.evaluate(() => {
1480
+ // Strategy 1: direct reels_tab link.
1481
+ const direct = document.querySelectorAll("a[href*='sk=reels_tab']");
1482
+ for (const a of direct) {
1483
+ const h = a.getAttribute('href') || '';
1484
+ if (h) return h.startsWith('http') ? h : `https://www.facebook.com${h}`;
1485
+ }
1486
+ // Strategy 2: extract Page ID from a /profile.php?id=<id> anchor.
1487
+ // The "Dòng thời gian của X" anchor and the page-shortcut anchor
1488
+ // both have hrefs like /profile.php?id=61590303065684.
1489
+ const profAnchors = document.querySelectorAll("a[href*='/profile.php?id=']");
1490
+ for (const a of profAnchors) {
1491
+ const h = a.getAttribute('href') || '';
1492
+ const m = h.match(/\/profile\.php\?id=(\d+)/);
1493
+ if (m) return `https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`;
1494
+ }
1495
+ // Strategy 3: page handle via "Dòng thời gian của X" anchor (no
1496
+ // profile.php — uses /<handle> instead).
1497
+ const tlAnchors = document.querySelectorAll("a[aria-label*='Dòng thời gian'], a[aria-label*='Timeline']");
1498
+ for (const a of tlAnchors) {
1499
+ const h = a.getAttribute('href') || '';
1500
+ // /<handle> or /<handle>/?...
1501
+ const m = h.match(/^\/([A-Za-z0-9._-]{3,})\/?(?:\?|$)/);
1502
+ if (m && !['profile.php', 'reel', 'video', 'videos'].includes(m[1])) {
1503
+ return `https://www.facebook.com/${m[1]}/?sk=reels_tab`;
1504
+ }
1505
+ if (h.match(/\/profile\.php\?id=(\d+)/)) {
1506
+ const pid = h.match(/\/profile\.php\?id=(\d+)/)[1];
1507
+ return `https://www.facebook.com/profile.php?id=${pid}&sk=reels_tab`;
1508
+ }
1509
+ }
1510
+ return null;
1511
+ }).catch(() => null);
1512
+ if (reelsTabHref) {
1513
+ log('info', `[fb-pw] navigating to page reels tab: ${reelsTabHref.slice(0, 100)}`);
1514
+ await page.goto(reelsTabHref, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
1515
+ await page.waitForTimeout(5000);
1516
+ const fresh = await page.evaluate(() => {
1517
+ const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
1518
+ const FRESH_RE = /vừa xong|vài giây|^\s*\d{1,2}\s*giây|^\s*[1-5]\s*phút\b|\b[1-5]\s*phút trước|just now|few seconds ago|\b[1-5] min(ute)?s? ago/i;
1519
+ // Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
1520
+ // wrapping a thumbnail + meta. Iterate in DOM order (newest first
1521
+ // on reels tab). For each tile, check if its subtree has a fresh
1522
+ // timestamp.
1523
+ const anchors = document.querySelectorAll("a[href*='/reel/']");
1524
+ for (const a of anchors) {
1525
+ const href = a.getAttribute('href') || '';
1526
+ const m = href.match(/\/reel\/(\d{8,18})/);
1527
+ if (!m) continue;
1528
+ const r = a.getBoundingClientRect();
1529
+ if (r.width < 8 || r.height < 8) continue;
1530
+ // Look within the anchor's subtree + closest meaningful ancestor.
1531
+ let ctx = a;
1532
+ for (let depth = 0; depth < 5 && ctx; depth++) {
1533
+ const raw = (ctx.innerText || ctx.textContent || '').slice(0, 500);
1534
+ if (FRESH_RE.test(raw)) {
1535
+ return { href, id: m[1], depth, timestamp: (raw.match(FRESH_RE) || [''])[0] };
1536
+ }
1537
+ ctx = ctx.parentElement;
1538
+ }
1539
+ }
1540
+ return null;
1541
+ }).catch(() => null);
1542
+ if (fresh) {
1543
+ const full = fresh.href.startsWith('http') ? fresh.href : `https://www.facebook.com${fresh.href}`;
1544
+ postUrl = full;
1545
+ log('info', `[fb-pw] post URL from reels_tab fresh tile (id=${fresh.id}, depth=${fresh.depth}, timestamp="${fresh.timestamp}"): ${postUrl}`);
1546
+ } else {
1547
+ log('warn', '[fb-pw] reels_tab navigated but no tile with fresh timestamp found');
1548
+ }
1549
+ } else {
1550
+ log('warn', '[fb-pw] no reels_tab link found on page; skipping reels-tab strategy');
1551
+ }
1552
+ } catch (e) {
1553
+ log('warn', `[fb-pw] reels_tab nav strategy failed: ${e.message.slice(0, 100)}`);
1554
+ }
1555
+
1556
+ const titleSig = !postUrl && String(title || description || '').slice(0, 30).replace(/[\s\n]+/g, ' ').trim();
1557
+ if (titleSig && titleSig.length >= 12) {
1558
+ const titleHref = await page.evaluate((sig) => {
1559
+ // Lowercase + accent-fold compare to be tolerant of FB's CSS-styled
1560
+ // title rendering (sometimes uppercase, etc.).
1561
+ const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
1562
+ const sigNorm = norm(sig);
1563
+ // FB Vietnamese fresh-publish timestamps: "Vừa xong", "1 phút", "X
1564
+ // giây", "vài giây trước". Anything older ("1 giờ", "2 ngày", "1
1565
+ // tuần", explicit date like "5 tháng 12") indicates an OLDER reel
1566
+ // with the same title — NOT what we just published. The fresh
1567
+ // pattern matches up to 5 minutes for safety (FB sometimes lags).
1568
+ const FRESH_RE = /vừa xong|vài giây|^\s*\d{1,2}\s*giây|^\s*[1-5]\s*phút\b|\b[1-5]\s*phút trước|just now|few seconds ago|\b[1-5] min(ute)?s? ago/i;
1569
+ const candidates = [];
1570
+ const anchors = document.querySelectorAll("a[role='link']");
1571
+ for (const a of anchors) {
1572
+ const href = a.getAttribute('href') || '';
1573
+ const m = href.match(/\/reel\/(\d{8,18})/);
1574
+ if (!m) continue;
1575
+ // Walk up to 8 levels collecting subtree text. Track FIRST level
1576
+ // where title sig appears + check if a fresh timestamp also
1577
+ // appears in the same container at any depth up to 8.
1578
+ let ctx = a;
1579
+ let titleDepth = -1;
1580
+ let freshDepth = -1;
1581
+ for (let depth = 0; depth < 8 && ctx; depth++) {
1582
+ const raw = (ctx.innerText || ctx.textContent || '').slice(0, 1000);
1583
+ const txt = norm(raw);
1584
+ if (titleDepth < 0 && txt.includes(sigNorm)) titleDepth = depth;
1585
+ if (freshDepth < 0 && FRESH_RE.test(raw)) freshDepth = depth;
1586
+ ctx = ctx.parentElement;
1587
+ if (titleDepth >= 0 && freshDepth >= 0) break;
1588
+ }
1589
+ if (titleDepth < 0) continue;
1590
+ candidates.push({
1591
+ href, id: m[1],
1592
+ titleDepth,
1593
+ freshDepth,
1594
+ hasFresh: freshDepth >= 0,
1595
+ });
1596
+ }
1597
+ if (!candidates.length) return null;
1598
+ // Prefer candidates with fresh timestamp (just-published). Among
1599
+ // them, pick the one with the SHALLOWEST title depth (closest
1600
+ // semantic association). If none are fresh, return null so caller
1601
+ // can fall through to other strategies — DO NOT return an old
1602
+ // same-title reel.
1603
+ const fresh = candidates.filter((c) => c.hasFresh);
1604
+ if (!fresh.length) {
1605
+ return { skipped: true, reason: `${candidates.length} title matches but none with fresh-publish timestamp` };
1606
+ }
1607
+ fresh.sort((a, b) => a.titleDepth - b.titleDepth);
1608
+ return { href: fresh[0].href, id: fresh[0].id, depth: fresh[0].titleDepth, freshDepth: fresh[0].freshDepth, total: candidates.length };
1609
+ }, titleSig).catch(() => null);
1610
+ if (titleHref && titleHref.skipped) {
1611
+ log('warn', `[fb-pw] title-match scrape SKIPPED: ${titleHref.reason} (avoiding stale duplicate)`);
1612
+ } else if (titleHref) {
1613
+ const full = titleHref.href.startsWith('http') ? titleHref.href : `https://www.facebook.com${titleHref.href}`;
1614
+ postUrl = full;
1615
+ log('info', `[fb-pw] post URL via title-match scrape (id=${titleHref.id}, depth=${titleHref.depth}, freshDepth=${titleHref.freshDepth}, total candidates=${titleHref.total}): ${postUrl}`);
1616
+ }
1617
+ }
1618
+
1619
+ // (a.1) Fallback: "Xem thông tin chi tiết" / "Quảng bá thước phim"
1620
+ // anchors. These sit next to the just-published reel display.
1621
+ // Note: "Xem thông tin chi tiết" is sometimes a <div> not <a>;
1622
+ // only the <a role="link"> variant of these CTAs has a useful
1623
+ // href.
1624
+ if (!postUrl) {
1625
+ const inlineHref = await page.evaluate(() => {
1626
+ const anchors = document.querySelectorAll("a[role='link']");
1627
+ for (const a of anchors) {
1628
+ const aria = (a.getAttribute('aria-label') || '').trim();
1629
+ if (/Xem thông tin chi tiết|View (post )?details|Quảng bá (thước phim|bài viết)|Promote (reel|post)/i.test(aria)) {
1630
+ const href = a.getAttribute('href') || '';
1631
+ if (/\/reel\/\d{8,18}/.test(href)) {
1632
+ return { href, aria: aria.slice(0, 60) };
1633
+ }
1634
+ }
1635
+ }
1636
+ return null;
1637
+ }).catch(() => null);
1638
+ if (inlineHref) {
1639
+ postUrl = inlineHref.href.startsWith('http') ? inlineHref.href : `https://www.facebook.com${inlineHref.href}`;
1640
+ log('info', `[fb-pw] post URL from inline reel "${inlineHref.aria}": ${postUrl}`);
1641
+ }
1642
+ }
1643
+
1644
+ // (a.2) Reel-preview tile on profile reels tab — FB sometimes navigates
1645
+ // to /profile.php?sk=reels_tab after publish. The FIRST tile in
1646
+ // DOM order (with aria-label="Bản xem trước ô thước phim" /
1647
+ // "Reel tile preview") is the NEWEST reel = our just-published.
1648
+ // Each tile is an <a href="/reel/<id>/"> anchor — scraping that
1649
+ // href is far more reliable than network-id ordering.
1650
+ if (!postUrl) {
1651
+ const tileHref = await page.evaluate(() => {
1652
+ const anchors = document.querySelectorAll("a[role='link']");
1653
+ for (const a of anchors) {
1654
+ const aria = (a.getAttribute('aria-label') || '').toLowerCase();
1655
+ if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
1656
+ const href = a.getAttribute('href') || '';
1657
+ const m = href.match(/\/reel\/(\d{8,20})/);
1658
+ if (m) return { href, aria: aria.slice(0, 60) };
1659
+ }
1660
+ return null;
1661
+ }).catch(() => null);
1662
+ if (tileHref) {
1663
+ postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
1664
+ log('info', `[fb-pw] post URL from FIRST reel tile on reels_tab: ${postUrl}`);
1665
+ }
1666
+ }
1667
+
1668
+ // (b) Graph API network capture — only consider IDs captured AFTER the
1669
+ // publish click snapshot (pre-snapshot IDs are from the page-wall
1670
+ // feed pre-render, NOT the publish mutation). Take the FIRST new ID
1671
+ // since FB's publish-mutation response comes back before any
1672
+ // feed-reload responses.
1673
+ if (!postUrl && capturedReelIds.length > capturedReelIdsSnapshotLen) {
1674
+ const newIds = capturedReelIds.slice(capturedReelIdsSnapshotLen);
1675
+ // Filter out IDs that appeared in the pre-snapshot to be safe (Set
1676
+ // diff). Then take the FIRST new one.
1677
+ const preSet = new Set(capturedReelIds.slice(0, capturedReelIdsSnapshotLen));
1678
+ const trulyNew = newIds.filter((id) => !preSet.has(id));
1679
+ if (trulyNew.length) {
1680
+ const firstNew = trulyNew[0];
1681
+ postUrl = `https://www.facebook.com/reel/${firstNew}/`;
1682
+ log('info', `[fb-pw] post URL from network (FIRST new ID after snapshot): ${postUrl} (snapshotLen=${capturedReelIdsSnapshotLen} total=${capturedReelIds.length} new=${trulyNew.length})`);
1683
+ }
1684
+ }
1685
+
1686
+ // (b) URL change — FB sometimes navigates to /reel/<id>/ after publish.
1687
+ if (!postUrl) {
1688
+ const cur = page.url();
1689
+ const m = cur.match(/\/reel\/(\d{8,20})|\/videos\/(\d{8,20})/);
1690
+ if (m) {
1691
+ postUrl = cur;
1692
+ log('info', `[fb-pw] post URL from page nav: ${postUrl}`);
1693
+ }
1694
+ }
1695
+
1696
+ // (c) Scoped dialog link — only links INSIDE a post-publish confirmation
1697
+ // dialog. Page-wall flow usually shows "Đã đăng" toast with a link.
1698
+ if (!postUrl) {
1699
+ const dialogs = page.locator("[role='dialog']");
1700
+ const dlgCount = await dialogs.count().catch(() => 0);
1701
+ for (let i = 0; i < dlgCount && !postUrl; i++) {
1702
+ const dlg = dialogs.nth(i);
1703
+ if (!(await dlg.isVisible().catch(() => false))) continue;
1704
+ const anchors = dlg.locator("a[href*='/reel/'], a[href*='/videos/']");
1705
+ const aCount = Math.min(await anchors.count().catch(() => 0), 4);
1706
+ for (let j = 0; j < aCount; j++) {
1707
+ const href = (await anchors.nth(j).getAttribute('href').catch(() => '')) || '';
1708
+ if (/\/reel\/\d{8,20}|\/videos\/\d{8,20}/.test(href)) {
1709
+ postUrl = href.startsWith('http') ? href : `https://www.facebook.com${href}`;
1710
+ log('info', `[fb-pw] post URL from confirmation dialog: ${postUrl}`);
1711
+ break;
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+
1717
+ // NOTE: page-wall probe was removed (was unreliable — it grabbed the
1718
+ // FIRST visible reel on the Page wall, which is the LATEST EXISTING reel,
1719
+ // not necessarily our just-published one. When FB rate-limited the
1720
+ // publish but we missed the spam dialog, the probe returned a stale reel
1721
+ // and we reported it as "done" — a false success). Better to leave
1722
+ // post_url empty than to record a wrong one. The spam-limit guard above
1723
+ // now catches the FB block case explicitly.
1724
+
1725
+ if (!postUrl) log('warn', '[fb-pw] post URL not captured — upload still considered ok (no spam-block detected, FB likely accepted)');
1726
+
1727
+ // Strip FB tracking params (__cft__, __tn__, etc.) so the stored URL is
1728
+ // canonical. The reel ID alone uniquely identifies the post and the
1729
+ // tracking params expire / leak referrer state.
1730
+ if (postUrl) {
1731
+ const m = postUrl.match(/(https?:\/\/[^/]+\/(?:reel|videos)\/\d{8,20})/);
1732
+ if (m) {
1733
+ const clean = m[1] + '/';
1734
+ if (clean !== postUrl) {
1735
+ log('info', `[fb-pw] canonicalized post URL: ${postUrl.slice(0, 80)}… → ${clean}`);
1736
+ postUrl = clean;
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ return {
1742
+ ok: true,
1743
+ title: String(title || description || '').slice(0, 80),
1744
+ visibility,
1745
+ post_url: postUrl,
1746
+ video_path_local: videoPath,
1747
+ };
1748
+ } finally {
1749
+ safeUnlink(videoPath);
1750
+ if (thumbPath) safeUnlink(thumbPath);
1751
+ }
1752
+ }
1753
+
1754
+ module.exports = { run };