channel-worker 2.5.40 → 2.5.43
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 +20 -0
- package/lib/command-poller.js +81 -7
- package/package.json +1 -1
- package/scripts/upload_facebook.js +213 -18
- package/scripts/upload_facebook_photo.js +13 -1
- package/scripts/upload_youtube.js +13 -1
package/lib/api-client.js
CHANGED
|
@@ -173,6 +173,26 @@ class ApiClient {
|
|
|
173
173
|
async postExtensionLog({ level = 'info', message = '', data = null, profile_id = '', job_id = null } = {}) {
|
|
174
174
|
return this.request('POST', '/extension/log', { level, message, data, profile_id, job_id });
|
|
175
175
|
}
|
|
176
|
+
|
|
177
|
+
// Persist a browser screenshot captured by a Playwright publish script.
|
|
178
|
+
// The API stores the bytes on the media server; only the returned path/URL
|
|
179
|
+
// is written into extension_log, never the base64 payload itself.
|
|
180
|
+
async uploadDebugScreenshot({ filePath, tag = 'failure', command_id = '', job_id = '', profile_id = '', platform = '' } = {}) {
|
|
181
|
+
const fs = require('fs');
|
|
182
|
+
const path = require('path');
|
|
183
|
+
if (!filePath) throw new Error('debug screenshot filePath required');
|
|
184
|
+
const file = fs.readFileSync(filePath);
|
|
185
|
+
return this.request('POST', '/extension/debug-screenshot', {
|
|
186
|
+
image_base64: file.toString('base64'),
|
|
187
|
+
content_type: 'image/png',
|
|
188
|
+
file_name: path.basename(filePath),
|
|
189
|
+
tag,
|
|
190
|
+
command_id,
|
|
191
|
+
job_id,
|
|
192
|
+
profile_id,
|
|
193
|
+
platform,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
module.exports = { ApiClient };
|
package/lib/command-poller.js
CHANGED
|
@@ -286,6 +286,17 @@ class CommandPoller {
|
|
|
286
286
|
this.api.postExtensionLog?.({ level, message, data, profile_id: profileId, job_id: payload.job_id || null })
|
|
287
287
|
.catch(() => {});
|
|
288
288
|
};
|
|
289
|
+
// Publish scripts attach screenshots to warning logs through this helper.
|
|
290
|
+
// Keep the command/job identity here so the API and idea Publish History
|
|
291
|
+
// can associate an artifact with the exact platform attempt.
|
|
292
|
+
log.uploadDebugScreenshot = (filePath, meta = {}) => this.api.uploadDebugScreenshot({
|
|
293
|
+
filePath,
|
|
294
|
+
tag: meta.tag || 'failure',
|
|
295
|
+
command_id: String(command._id),
|
|
296
|
+
job_id: payload.job_id || '',
|
|
297
|
+
profile_id: profileId,
|
|
298
|
+
platform: scriptName.replace(/^upload_/, ''),
|
|
299
|
+
});
|
|
289
300
|
|
|
290
301
|
try {
|
|
291
302
|
const result = await runPlaywrightScript({
|
|
@@ -1443,6 +1454,33 @@ class CommandPoller {
|
|
|
1443
1454
|
const domQ = typeof queueInfo === 'number' ? queueCount : (queueInfo?.dom_count || 0);
|
|
1444
1455
|
if (!queueCount) { this._dispatching = false; return; }
|
|
1445
1456
|
|
|
1457
|
+
// 4b. WHO can actually claim that queue. An idea pinned to one renderer
|
|
1458
|
+
// produces commands only that profile may claim — a raw total told us
|
|
1459
|
+
// "there's work, open another profile", the freshly-opened profile
|
|
1460
|
+
// claimed nothing, the round-robin below closed it as idle, and the
|
|
1461
|
+
// next cycle reopened it: a browser blinking open/closed every 5s
|
|
1462
|
+
// (observed on i5-pc: idea pinned to veo3-pro1, veo3-pro4 flapping).
|
|
1463
|
+
// unpinned_count === null means an older API that doesn't report the
|
|
1464
|
+
// breakdown → fall back to the previous "everything is claimable".
|
|
1465
|
+
const unpinnedCount = (queueInfo && typeof queueInfo.unpinned_count === 'number')
|
|
1466
|
+
? queueInfo.unpinned_count
|
|
1467
|
+
: null;
|
|
1468
|
+
const pinnedMap = (queueInfo && queueInfo.pinned && typeof queueInfo.pinned === 'object')
|
|
1469
|
+
? queueInfo.pinned
|
|
1470
|
+
: {};
|
|
1471
|
+
const pinnedFor = (r) => {
|
|
1472
|
+
const id = String(r?.nst_profile_id || '');
|
|
1473
|
+
if (!id) return 0;
|
|
1474
|
+
if (typeof pinnedMap[id] === 'number') return pinnedMap[id];
|
|
1475
|
+
const lower = id.toLowerCase();
|
|
1476
|
+
for (const k of Object.keys(pinnedMap)) {
|
|
1477
|
+
if (k.toLowerCase() === lower) return pinnedMap[k];
|
|
1478
|
+
}
|
|
1479
|
+
return 0;
|
|
1480
|
+
};
|
|
1481
|
+
// Can this renderer claim anything at all right now?
|
|
1482
|
+
const canClaim = (r) => (unpinnedCount === null) || unpinnedCount > 0 || pinnedFor(r) > 0;
|
|
1483
|
+
|
|
1446
1484
|
// 5. Parallel limit — sourced from THIS daemon's Worker.parallel_limit
|
|
1447
1485
|
// via /workers/me. Replaces the legacy global Settings
|
|
1448
1486
|
// (flowkit_max_concurrent / veo3_parallel_limit) which throttled
|
|
@@ -1476,7 +1514,19 @@ class CommandPoller {
|
|
|
1476
1514
|
&& (!r.pause_until || new Date(r.pause_until).getTime() > Date.now());
|
|
1477
1515
|
let stillOffline = renderers.filter(r => !runningRenderers.includes(r) && !isPaused(r));
|
|
1478
1516
|
const pausedCount = renderers.filter(isPaused).length;
|
|
1479
|
-
|
|
1517
|
+
const pinSummary = unpinnedCount === null
|
|
1518
|
+
? 'pins=n/a'
|
|
1519
|
+
: `unpinned=${unpinnedCount} pins=${JSON.stringify(pinnedMap)}`;
|
|
1520
|
+
console.log(`[scene-dispatch] running=${runningRenderers.length} cap=${parallelLimit} (flowkit=${flowkitQ} dom=${domQ}) offline=${stillOffline.length} paused=${pausedCount} queue=${queueCount} ${pinSummary} names=[${runningRenderers.map(r=>r.name)}]`);
|
|
1521
|
+
|
|
1522
|
+
// Offline renderers that could actually claim something. Everything else
|
|
1523
|
+
// must stay closed — opening it would only produce the flap described
|
|
1524
|
+
// above (open → claim nothing → closed as idle → reopen).
|
|
1525
|
+
let launchable = stillOffline.filter(canClaim);
|
|
1526
|
+
if (stillOffline.length > launchable.length) {
|
|
1527
|
+
const blocked = stillOffline.filter(r => !canClaim(r)).map(r => r.name || r.nst_profile_id);
|
|
1528
|
+
console.log(`[scene-dispatch] not launching [${blocked.join(',')}] — the whole queue is pinned to another renderer`);
|
|
1529
|
+
}
|
|
1480
1530
|
|
|
1481
1531
|
// ROUND-ROBIN ROTATION — when a running renderer just finished its
|
|
1482
1532
|
// scene (no in-flight cmd) AND there's at least one OFFLINE sibling
|
|
@@ -1490,7 +1540,10 @@ class CommandPoller {
|
|
|
1490
1540
|
// Round-robin close-idle is a SHARED-ACCOUNT safety (keep ≤1 Flow session
|
|
1491
1541
|
// live per Google account). With independent_accounts every renderer is
|
|
1492
1542
|
// its own account → skip it entirely and let them all run concurrently.
|
|
1493
|
-
|
|
1543
|
+
// Rotate only when there is a sibling that can actually take over the
|
|
1544
|
+
// remaining work — rotating toward a renderer the queue is not pinned to
|
|
1545
|
+
// just closes a useful profile and opens a useless one.
|
|
1546
|
+
if (!independentAccounts && queueCount > 0 && launchable.length > 0 && runningRenderers.length > 0) {
|
|
1494
1547
|
const stoppedNames = [];
|
|
1495
1548
|
for (const r of [...runningRenderers]) {
|
|
1496
1549
|
// Skip externally-launched profiles (user opened manually via NST UI)
|
|
@@ -1523,7 +1576,9 @@ class CommandPoller {
|
|
|
1523
1576
|
// the launcher picks the round-robin partner, not the one that just
|
|
1524
1577
|
// finished. API populates last_command_assigned_at on every claim,
|
|
1525
1578
|
// so this naturally implements turn-taking across siblings.
|
|
1526
|
-
|
|
1579
|
+
// Re-filter first: the round-robin above may have pushed a just-closed
|
|
1580
|
+
// renderer back into stillOffline.
|
|
1581
|
+
launchable = stillOffline.filter(canClaim).slice().sort((a, b) => {
|
|
1527
1582
|
const ta = a.last_command_assigned_at ? new Date(a.last_command_assigned_at).getTime() : 0;
|
|
1528
1583
|
const tb = b.last_command_assigned_at ? new Date(b.last_command_assigned_at).getTime() : 0;
|
|
1529
1584
|
return ta - tb;
|
|
@@ -1543,11 +1598,11 @@ class CommandPoller {
|
|
|
1543
1598
|
const needNew = Math.max(0, queueCount);
|
|
1544
1599
|
const neededLaunches = Math.min(
|
|
1545
1600
|
parallelLimit - runningRenderers.length,
|
|
1546
|
-
|
|
1601
|
+
launchable.length,
|
|
1547
1602
|
needNew,
|
|
1548
1603
|
);
|
|
1549
1604
|
for (let li = 0; li < Math.max(0, neededLaunches); li++) {
|
|
1550
|
-
const toLaunch =
|
|
1605
|
+
const toLaunch = launchable[li];
|
|
1551
1606
|
console.log(`[scene-dispatch] Launching ${toLaunch.name} (${runningRenderers.length + li + 1}/${parallelLimit})`);
|
|
1552
1607
|
try {
|
|
1553
1608
|
await this._launchRendererProfile(toLaunch);
|
|
@@ -1793,14 +1848,33 @@ class CommandPoller {
|
|
|
1793
1848
|
const running = await this.nst.getRunningBrowsers();
|
|
1794
1849
|
if (running.length === 0) return;
|
|
1795
1850
|
|
|
1796
|
-
// Check if there are any queued commands — if so, don't close anything
|
|
1851
|
+
// Check if there are any queued commands — if so, don't close anything.
|
|
1852
|
+
// Exception: a queue that is ENTIRELY pinned to specific renderers keeps
|
|
1853
|
+
// every other open profile idle forever, so it must not block the idle
|
|
1854
|
+
// close (each profile is still spared individually below if the queue
|
|
1855
|
+
// holds work pinned to it).
|
|
1797
1856
|
const qi = await this.api.getSceneQueueCount();
|
|
1798
1857
|
const queueCount = typeof qi === 'number' ? qi : (qi?.total || 0);
|
|
1799
|
-
|
|
1858
|
+
const unpinnedCount = (qi && typeof qi.unpinned_count === 'number') ? qi.unpinned_count : null;
|
|
1859
|
+
const pinnedMap = (qi && qi.pinned && typeof qi.pinned === 'object') ? qi.pinned : {};
|
|
1860
|
+
const pinnedForId = (id) => {
|
|
1861
|
+
if (!id) return 0;
|
|
1862
|
+
if (typeof pinnedMap[id] === 'number') return pinnedMap[id];
|
|
1863
|
+
const lower = String(id).toLowerCase();
|
|
1864
|
+
for (const k of Object.keys(pinnedMap)) {
|
|
1865
|
+
if (k.toLowerCase() === lower) return pinnedMap[k];
|
|
1866
|
+
}
|
|
1867
|
+
return 0;
|
|
1868
|
+
};
|
|
1869
|
+
// Old API (no breakdown) → previous behaviour: any queue blocks closing.
|
|
1870
|
+
if (queueCount > 0 && (unpinnedCount === null || unpinnedCount > 0)) return;
|
|
1800
1871
|
|
|
1801
1872
|
for (const browser of running) {
|
|
1802
1873
|
const profileId = browser.profileId;
|
|
1803
1874
|
const name = browser.name?.toLowerCase();
|
|
1875
|
+
// Queue holds work pinned to THIS profile → it's about to be claimed,
|
|
1876
|
+
// leave the browser open.
|
|
1877
|
+
if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
|
|
1804
1878
|
// Match by UUID or name (renderers use name as nst_profile_id)
|
|
1805
1879
|
const lastActivity = this._profileLastActivity[profileId]
|
|
1806
1880
|
|| (name && this._profileLastActivity[name])
|
package/package.json
CHANGED
|
@@ -35,11 +35,11 @@ async function isActionable(el) {
|
|
|
35
35
|
return true;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button' } = {}) {
|
|
38
|
+
async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', clickOpts = null } = {}) {
|
|
39
39
|
const deadline = Date.now() + timeoutMs;
|
|
40
40
|
while (Date.now() < deadline) {
|
|
41
41
|
if (await isActionable(loc)) {
|
|
42
|
-
await loc.click();
|
|
42
|
+
await loc.click(clickOpts || undefined);
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
await loc.page().waitForTimeout(800);
|
|
@@ -47,6 +47,106 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button' } =
|
|
|
47
47
|
throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
|
|
51
|
+
// 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
|
|
52
|
+
// composer's bottom CTA. Playwright's hit-target check then blocks every
|
|
53
|
+
// click — "<b>96 người khác</b> from <div data-visualcompletion='ignore'>
|
|
54
|
+
// subtree intercepts pointer events" — and the job dies at step 1 even though
|
|
55
|
+
// the button is visible + enabled. Same story for the feed hover-cards behind
|
|
56
|
+
// the modal. This mutes whatever sits over the button's centre point: from
|
|
57
|
+
// elementFromPoint, walk up to the outermost ancestor that still does NOT
|
|
58
|
+
// contain the target (= the toast/hover-card portal root) and kill its
|
|
59
|
+
// pointer-events. Muted nodes are tagged so we can restore them right after
|
|
60
|
+
// the click — leaving a portal permanently dead could break a later step that
|
|
61
|
+
// renders into the same container. Returns how many layers were muted.
|
|
62
|
+
async function muteClickInterceptors(page, selector) {
|
|
63
|
+
return page.evaluate((sel) => {
|
|
64
|
+
const el = document.querySelector(sel);
|
|
65
|
+
if (!el) return 0;
|
|
66
|
+
const r = el.getBoundingClientRect();
|
|
67
|
+
const cx = r.x + r.width / 2;
|
|
68
|
+
const cy = r.y + r.height / 2;
|
|
69
|
+
let muted = 0;
|
|
70
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
71
|
+
const top = document.elementFromPoint(cx, cy);
|
|
72
|
+
if (!top || top === el || el.contains(top) || top.contains(el)) break;
|
|
73
|
+
let root = top;
|
|
74
|
+
while (root.parentElement && root.parentElement !== document.body && !root.parentElement.contains(el)) {
|
|
75
|
+
root = root.parentElement;
|
|
76
|
+
}
|
|
77
|
+
if (root === document.body || root.contains(el)) break;
|
|
78
|
+
// Already muted and STILL on top (a stylesheet !important beats our
|
|
79
|
+
// inline style) — muting again would overwrite the saved original with
|
|
80
|
+
// 'none' and leave the node permanently dead after the restore.
|
|
81
|
+
if (root.hasAttribute('__fbpw_muted__')) break;
|
|
82
|
+
root.setAttribute('__fbpw_muted__', root.style.pointerEvents || '_');
|
|
83
|
+
root.style.pointerEvents = 'none';
|
|
84
|
+
muted++;
|
|
85
|
+
}
|
|
86
|
+
return muted;
|
|
87
|
+
}, selector).catch(() => 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function unmuteClickInterceptors(page) {
|
|
91
|
+
return page.evaluate(() => {
|
|
92
|
+
document.querySelectorAll('[__fbpw_muted__]').forEach((el) => {
|
|
93
|
+
const prev = el.getAttribute('__fbpw_muted__');
|
|
94
|
+
el.style.pointerEvents = prev === '_' ? '' : prev;
|
|
95
|
+
el.removeAttribute('__fbpw_muted__');
|
|
96
|
+
});
|
|
97
|
+
}).catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Click that survives FB's overlay noise. Order of escalation:
|
|
101
|
+
// 1. park the cursor in a corner (a mouse resting over the feed behind the
|
|
102
|
+
// modal keeps a hover-card alive right on top of the CTA),
|
|
103
|
+
// 2. native click — highest fidelity,
|
|
104
|
+
// 3. on failure, mute whatever intercepts the click point and retry natively,
|
|
105
|
+
// 4. last resort, JS-dispatch el.click() (bypasses hit-testing entirely).
|
|
106
|
+
// `jsFallback:false` for actions that must never fire twice (publish) — those
|
|
107
|
+
// call sites run their own guarded fallback.
|
|
108
|
+
// `landedCheck` guards the escalation: Playwright can report a timeout for a
|
|
109
|
+
// click that DID register (the node detaches mid-action). Callers pass a probe
|
|
110
|
+
// for "the UI already moved on" so we don't fire a second click on top of it.
|
|
111
|
+
async function resilientClick(page, loc, selector, { timeout = 10_000, log, label = 'button', jsFallback = true, attempts = 3, landedCheck = null } = {}) {
|
|
112
|
+
try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
|
|
113
|
+
await page.mouse.move(2, 2).catch(() => {});
|
|
114
|
+
let lastErr = null;
|
|
115
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
116
|
+
try {
|
|
117
|
+
await loc.click({ timeout });
|
|
118
|
+
await unmuteClickInterceptors(page);
|
|
119
|
+
return 'native';
|
|
120
|
+
} catch (e) {
|
|
121
|
+
lastErr = e;
|
|
122
|
+
if (landedCheck && await landedCheck().catch(() => false)) {
|
|
123
|
+
if (log) log('info', `[fb-pw] ${label} click reported a timeout but the UI advanced → treating as clicked`);
|
|
124
|
+
await unmuteClickInterceptors(page);
|
|
125
|
+
return 'landed';
|
|
126
|
+
}
|
|
127
|
+
const muted = await muteClickInterceptors(page, selector);
|
|
128
|
+
if (log) log('warn', `[fb-pw] ${label} click attempt ${attempt + 1}/${attempts} failed (${e.message.split('\n')[0].slice(0, 70)}) — muted ${muted} overlay layer(s)`);
|
|
129
|
+
await page.waitForTimeout(500);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (jsFallback) {
|
|
133
|
+
const ok = await page.evaluate((sel) => {
|
|
134
|
+
const el = document.querySelector(sel);
|
|
135
|
+
if (!el) return false;
|
|
136
|
+
el.scrollIntoView({ block: 'center' });
|
|
137
|
+
el.click();
|
|
138
|
+
return true;
|
|
139
|
+
}, selector).catch(() => false);
|
|
140
|
+
if (ok) {
|
|
141
|
+
if (log) log('info', `[fb-pw] ${label} clicked via JS-dispatch fallback`);
|
|
142
|
+
await unmuteClickInterceptors(page);
|
|
143
|
+
return 'js';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
await unmuteClickInterceptors(page);
|
|
147
|
+
throw lastErr || new Error(`${label}: click failed`);
|
|
148
|
+
}
|
|
149
|
+
|
|
50
150
|
// Same inventory dump as iter-2 (kept for failure diagnostics).
|
|
51
151
|
async function dumpInventory(page, log, tag) {
|
|
52
152
|
try {
|
|
@@ -119,7 +219,19 @@ async function dumpFailure(page, tag, log) {
|
|
|
119
219
|
fs.mkdirSync(dir, { recursive: true });
|
|
120
220
|
const png = path.join(dir, `fb-${tag}-${Date.now()}.png`);
|
|
121
221
|
await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
|
|
122
|
-
|
|
222
|
+
let debugScreenshot = null;
|
|
223
|
+
if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
|
|
224
|
+
try {
|
|
225
|
+
debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
|
|
226
|
+
} catch (e) {
|
|
227
|
+
log('warn', `[fb-pw] debug screenshot upload failed: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
log(
|
|
231
|
+
'warn',
|
|
232
|
+
`[fb-pw] dump ${tag} — url=${page.url()} screenshot=${debugScreenshot?.signed_url || png}`,
|
|
233
|
+
debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
|
|
234
|
+
);
|
|
123
235
|
} catch {}
|
|
124
236
|
}
|
|
125
237
|
|
|
@@ -283,7 +395,7 @@ async function run({ page, payload, log }) {
|
|
|
283
395
|
} = payload || {};
|
|
284
396
|
if (!video_url) throw new Error('No video_url provided');
|
|
285
397
|
|
|
286
|
-
log('info', '[fb-pw] selectors version=2026.
|
|
398
|
+
log('info', '[fb-pw] selectors version=2026.08.02-overlay-proof-cta');
|
|
287
399
|
|
|
288
400
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
289
401
|
|
|
@@ -929,8 +1041,12 @@ async function run({ page, payload, log }) {
|
|
|
929
1041
|
if (!bestEl) bestEl = tryScope(null);
|
|
930
1042
|
if (!bestEl) return null;
|
|
931
1043
|
// Tag the element so Playwright can find it back. data-attribute is
|
|
932
|
-
// safe + ignored by FB's React reconciliation.
|
|
1044
|
+
// safe + ignored by FB's React reconciliation. Clear stale markers
|
|
1045
|
+
// FIRST: a click that threw never reached the cleanup below, so an
|
|
1046
|
+
// old node could still carry the marker → the locator would resolve
|
|
1047
|
+
// to 2 elements and every later click dies on strict mode.
|
|
933
1048
|
const marker = '__fbpw_target__';
|
|
1049
|
+
document.querySelectorAll(`[${marker}]`).forEach((n) => n.removeAttribute(marker));
|
|
934
1050
|
bestEl.setAttribute(marker, '1');
|
|
935
1051
|
return { selector: `[${marker}='1']`, tag: bestEl.tagName.toLowerCase() };
|
|
936
1052
|
}, { verb: v, bottomHalf: requireBottomHalf });
|
|
@@ -938,17 +1054,23 @@ async function run({ page, payload, log }) {
|
|
|
938
1054
|
const loc = page.locator(found.selector);
|
|
939
1055
|
return {
|
|
940
1056
|
hit: {
|
|
941
|
-
click: async (opts) => {
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
await
|
|
1057
|
+
click: async (opts = {}) => {
|
|
1058
|
+
// resilientClick handles scroll-into-view (off-screen primary
|
|
1059
|
+
// actions like Chia sẻ ngay at y=-274 after a step jump) and,
|
|
1060
|
+
// more importantly, FB's toast/hover-card overlays that cover
|
|
1061
|
+
// the CTA and make Playwright's hit-target check fail forever.
|
|
1062
|
+
await resilientClick(page, loc, found.selector, {
|
|
1063
|
+
timeout: opts?.timeout || 10_000,
|
|
1064
|
+
log,
|
|
1065
|
+
label: opts?.label || v,
|
|
1066
|
+
jsFallback: opts?.jsFallback !== false,
|
|
1067
|
+
landedCheck: opts?.landedCheck || null,
|
|
1068
|
+
});
|
|
947
1069
|
// Remove the marker so subsequent findByVerbs gets a fresh
|
|
948
1070
|
// element instead of clinging to the now-stale node.
|
|
949
|
-
await page.evaluate((
|
|
950
|
-
document.querySelectorAll(
|
|
951
|
-
}
|
|
1071
|
+
await page.evaluate(() => {
|
|
1072
|
+
document.querySelectorAll('[__fbpw_target__]').forEach((el) => el.removeAttribute('__fbpw_target__'));
|
|
1073
|
+
}).catch(() => {});
|
|
952
1074
|
},
|
|
953
1075
|
isVisible: async () => loc.isVisible().catch(() => false),
|
|
954
1076
|
isEnabled: async () => loc.isEnabled().catch(() => true),
|
|
@@ -1565,6 +1687,23 @@ async function run({ page, payload, log }) {
|
|
|
1565
1687
|
// the user re-posted → duplicate reel. So: if the button vanished
|
|
1566
1688
|
// after we clicked, it went through.
|
|
1567
1689
|
const publishBtnGone = async () => !(await findByVerbs(publishVerbs, { requireBottomHalf: true }));
|
|
1690
|
+
// Marker-free twin of publishBtnGone, for use DURING a click retry:
|
|
1691
|
+
// findByVerbs re-tags __fbpw_target__, which would yank the marker
|
|
1692
|
+
// out from under the locator we're mid-click on.
|
|
1693
|
+
const publishBtnGoneDom = async () => page.evaluate((verbs) => {
|
|
1694
|
+
const scopes = [...document.querySelectorAll("[role='dialog']")];
|
|
1695
|
+
for (const s of (scopes.length ? scopes : [document.body])) {
|
|
1696
|
+
for (const b of s.querySelectorAll("button, [role='button']")) {
|
|
1697
|
+
if (!verbs.includes((b.innerText || '').trim())) continue;
|
|
1698
|
+
const r = b.getBoundingClientRect();
|
|
1699
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
1700
|
+
if (r.y < window.innerHeight * 0.4) continue;
|
|
1701
|
+
if (b.getAttribute('aria-disabled') === 'true' || b.disabled) continue;
|
|
1702
|
+
return false;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return true;
|
|
1706
|
+
}, publishVerbs).catch(() => false);
|
|
1568
1707
|
for (let attempt = 0; attempt < 3 && !clickedPublish; attempt++) {
|
|
1569
1708
|
const target = attempt === 0 ? pub : await findByVerbs(publishVerbs, { requireBottomHalf: true });
|
|
1570
1709
|
if (!target) {
|
|
@@ -1573,7 +1712,16 @@ async function run({ page, payload, log }) {
|
|
|
1573
1712
|
}
|
|
1574
1713
|
attemptedClick = true;
|
|
1575
1714
|
try {
|
|
1576
|
-
|
|
1715
|
+
// jsFallback:false — publish must never fire twice. The guarded
|
|
1716
|
+
// JS-dispatch below (attempt 3, after publishBtnGone checks) owns
|
|
1717
|
+
// that escalation; the generic one inside resilientClick would
|
|
1718
|
+
// bypass those guards. Overlay-muting + landedCheck still apply.
|
|
1719
|
+
await target.hit.click({
|
|
1720
|
+
timeout: 15000,
|
|
1721
|
+
label: `Đăng(${step + 1})`,
|
|
1722
|
+
jsFallback: false,
|
|
1723
|
+
landedCheck: publishBtnGoneDom,
|
|
1724
|
+
});
|
|
1577
1725
|
clickedPublish = true;
|
|
1578
1726
|
} catch (ce) {
|
|
1579
1727
|
log('warn', `[fb-pw] publish click attempt ${attempt + 1}/3 threw: ${ce.message.slice(0, 90)}`);
|
|
@@ -1615,6 +1763,10 @@ async function run({ page, payload, log }) {
|
|
|
1615
1763
|
for (const b of btns) {
|
|
1616
1764
|
const t = (b.innerText || '').trim();
|
|
1617
1765
|
if (t === v) {
|
|
1766
|
+
// Clear stale markers first — a previous confirm attempt
|
|
1767
|
+
// that threw left its tag behind, and two tagged nodes
|
|
1768
|
+
// make the locator fail on strict mode.
|
|
1769
|
+
document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
|
|
1618
1770
|
b.setAttribute('__fbpw_confirm__', '1');
|
|
1619
1771
|
return { selector: "[__fbpw_confirm__='1']", verb: v };
|
|
1620
1772
|
}
|
|
@@ -1628,8 +1780,18 @@ async function run({ page, payload, log }) {
|
|
|
1628
1780
|
if (confirmHit) {
|
|
1629
1781
|
log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
|
|
1630
1782
|
const cLoc = page.locator(confirmHit.selector);
|
|
1631
|
-
|
|
1632
|
-
|
|
1783
|
+
// Was a plain 5s click: it failed on nearly EVERY run ("confirm
|
|
1784
|
+
// click failed: Timeout 5000ms exceeded") because the new-post
|
|
1785
|
+
// toast covers this dialog's CTA too. Escalate the same way —
|
|
1786
|
+
// mute the overlay, then JS-dispatch. The confirm button lives in
|
|
1787
|
+
// a dialog that disappears once it fires, so the fallback can't
|
|
1788
|
+
// double-publish (querySelector returns null on the second pass).
|
|
1789
|
+
await resilientClick(page, cLoc, confirmHit.selector, {
|
|
1790
|
+
timeout: 8000,
|
|
1791
|
+
log,
|
|
1792
|
+
label: `confirm "${confirmHit.verb}"`,
|
|
1793
|
+
attempts: 2,
|
|
1794
|
+
}).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.split('\n')[0].slice(0, 80)}`));
|
|
1633
1795
|
await page.waitForTimeout(3000);
|
|
1634
1796
|
} else {
|
|
1635
1797
|
log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
|
|
@@ -1708,8 +1870,38 @@ async function run({ page, payload, log }) {
|
|
|
1708
1870
|
throw new Error(`FB composer step ${step + 1}: neither publish nor Tiếp button found`);
|
|
1709
1871
|
}
|
|
1710
1872
|
log('info', `[fb-pw] click "${next.verb}" via "${next.sel}" (step ${step + 1})`);
|
|
1873
|
+
// The composer's dialog aria-label names the current wizard step ("Tạo
|
|
1874
|
+
// thước phim" → "Chỉnh sửa thước phim" → …). Snapshot it so a click that
|
|
1875
|
+
// registered-then-timed-out is recognised instead of re-fired.
|
|
1876
|
+
const composerStepLabel = async () => page.evaluate(() => {
|
|
1877
|
+
const out = [];
|
|
1878
|
+
document.querySelectorAll("[role='dialog']").forEach((d) => {
|
|
1879
|
+
const r = d.getBoundingClientRect();
|
|
1880
|
+
if (r.width < 8 || r.height < 8) return;
|
|
1881
|
+
out.push(d.getAttribute('aria-label') || '');
|
|
1882
|
+
});
|
|
1883
|
+
return out.join('|');
|
|
1884
|
+
}).catch(() => '');
|
|
1885
|
+
const stepLabelBefore = await composerStepLabel();
|
|
1711
1886
|
try {
|
|
1712
|
-
await waitAndClick(next.hit, {
|
|
1887
|
+
await waitAndClick(next.hit, {
|
|
1888
|
+
timeoutMs: 180_000,
|
|
1889
|
+
log,
|
|
1890
|
+
label: `Tiếp(${step + 1})`,
|
|
1891
|
+
// 5s was too tight: FB's new-notification toast parks itself over the
|
|
1892
|
+
// composer's bottom CTA and Playwright burns the whole budget on
|
|
1893
|
+
// blocked retries. Give the click room + let it escalate (mute the
|
|
1894
|
+
// overlay, then JS-dispatch), with landedCheck so we never advance
|
|
1895
|
+
// the wizard twice.
|
|
1896
|
+
clickOpts: {
|
|
1897
|
+
timeout: 12_000,
|
|
1898
|
+
label: `Tiếp(${step + 1})`,
|
|
1899
|
+
landedCheck: async () => {
|
|
1900
|
+
const now = await composerStepLabel();
|
|
1901
|
+
return !!now && now !== stepLabelBefore;
|
|
1902
|
+
},
|
|
1903
|
+
},
|
|
1904
|
+
});
|
|
1713
1905
|
} catch (e) {
|
|
1714
1906
|
await dumpInventory(page, log, `tiep-not-clickable-${step + 1}`);
|
|
1715
1907
|
await dumpFailure(page, `tiep-not-clickable-${step + 1}`, log);
|
|
@@ -2145,3 +2337,6 @@ async function run({ page, payload, log }) {
|
|
|
2145
2337
|
}
|
|
2146
2338
|
|
|
2147
2339
|
module.exports = { run };
|
|
2340
|
+
// Exposed so the overlay-click escalation can be exercised against a synthetic
|
|
2341
|
+
// page (toast covering the CTA) without driving the whole upload flow.
|
|
2342
|
+
module.exports.__testables = { resilientClick, muteClickInterceptors, unmuteClickInterceptors };
|
|
@@ -47,7 +47,19 @@ async function dumpFailure(page, tag, log) {
|
|
|
47
47
|
fs.mkdirSync(dir, { recursive: true });
|
|
48
48
|
const png = path.join(dir, `fbphoto-${tag}-${Date.now()}.png`);
|
|
49
49
|
await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
|
|
50
|
-
|
|
50
|
+
let debugScreenshot = null;
|
|
51
|
+
if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
|
|
52
|
+
try {
|
|
53
|
+
debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
log('warn', `[fbphoto] debug screenshot upload failed: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
log(
|
|
59
|
+
'warn',
|
|
60
|
+
`[fbphoto] screenshot: ${debugScreenshot?.signed_url || png}`,
|
|
61
|
+
debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
|
|
62
|
+
);
|
|
51
63
|
} catch { /* ignore */ }
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -119,7 +119,19 @@ async function dumpFailure(page, tag, log) {
|
|
|
119
119
|
const url = page.url();
|
|
120
120
|
const title = await page.title().catch(() => '');
|
|
121
121
|
const bodyHead = await page.evaluate(() => (document.body?.innerText || '').slice(0, 400)).catch(() => '');
|
|
122
|
-
|
|
122
|
+
let debugScreenshot = null;
|
|
123
|
+
if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
log('warn', `[yt-pw] debug screenshot upload failed: ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
log(
|
|
131
|
+
'warn',
|
|
132
|
+
`[yt-pw] page dump on ${tag} — url=${url} | title=${title.slice(0, 80)} | head=${bodyHead.replace(/\s+/g, ' ').slice(0, 200)} | screenshot=${debugScreenshot?.signed_url || png}`,
|
|
133
|
+
debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
|
|
134
|
+
);
|
|
123
135
|
|
|
124
136
|
// Snapshot of every VISIBLE interactive element — so when Studio renames
|
|
125
137
|
// an id or swaps a tag we see what's there instead.
|