channel-worker 2.5.41 → 2.5.44
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 +11 -0
- package/package.json +1 -1
- package/scripts/upload_facebook.js +387 -37
- 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({
|
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
|
|
|
@@ -236,10 +348,17 @@ async function setVideoFile(page, inputHandle, filePath, log, tag = 'fb-pw') {
|
|
|
236
348
|
// throw no-advance. Poll until a bottom-half publish-verb button is present
|
|
237
349
|
// AND enabled. Returns true once enabled; false on timeout, or fast-false if no
|
|
238
350
|
// publish button ever appears (we're not actually on the final step).
|
|
239
|
-
async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw') {
|
|
351
|
+
async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw', { onPoll = null } = {}) {
|
|
240
352
|
const deadline = Date.now() + timeoutMs;
|
|
241
353
|
let announced = false, sawPresent = false, absent = 0;
|
|
242
354
|
while (Date.now() < deadline) {
|
|
355
|
+
// The final metadata form mounts lazily on some FB cohorts. The first
|
|
356
|
+
// fillMetadata() immediately after Tiếp can run before its textbox exists,
|
|
357
|
+
// leaving the form blank and Đăng disabled forever. Let the caller retry
|
|
358
|
+
// its idempotent metadata fill while we wait for video processing.
|
|
359
|
+
if (onPoll) await onPoll().catch((e) => {
|
|
360
|
+
log('warn', `[${tag}] final metadata retry failed: ${e.message.slice(0, 100)}`);
|
|
361
|
+
});
|
|
243
362
|
const st = await page.evaluate((vbs) => {
|
|
244
363
|
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
245
364
|
const roots = dlgs.length ? Array.from(dlgs) : [document];
|
|
@@ -262,28 +381,53 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw')
|
|
|
262
381
|
}, verbs).catch(() => ({ present: false, enabled: false }));
|
|
263
382
|
if (st.enabled) {
|
|
264
383
|
if (announced) log('info', `[${tag}] "Đăng" is now enabled — video finished processing`);
|
|
265
|
-
return true;
|
|
384
|
+
return { enabled: true, sawPresent: true };
|
|
266
385
|
}
|
|
267
386
|
if (st.present) {
|
|
268
387
|
sawPresent = true;
|
|
269
388
|
if (!announced) { log('info', `[${tag}] final step: "Đăng" disabled (video still processing) — waiting up to ${Math.round(timeoutMs / 1000)}s…`); announced = true; }
|
|
270
389
|
} else if (!sawPresent && ++absent >= 4) {
|
|
271
|
-
return false; // no publish CTA after ~10s → not the final step
|
|
390
|
+
return { enabled: false, sawPresent: false }; // no publish CTA after ~10s → not the final step
|
|
272
391
|
}
|
|
273
392
|
await page.waitForTimeout(2500);
|
|
274
393
|
}
|
|
275
394
|
log('warn', `[${tag}] "Đăng" never became enabled within ${Math.round(timeoutMs / 1000)}s`);
|
|
276
|
-
return false;
|
|
395
|
+
return { enabled: false, sawPresent };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const SAFE_RETRY_COMPOSER_CLOSED = 'FB_SAFE_RETRY_COMPOSER_CLOSED';
|
|
399
|
+
|
|
400
|
+
function safeComposerRetryError(message) {
|
|
401
|
+
const err = new Error(message);
|
|
402
|
+
err.code = SAFE_RETRY_COMPOSER_CLOSED;
|
|
403
|
+
return err;
|
|
277
404
|
}
|
|
278
405
|
|
|
279
|
-
async function
|
|
406
|
+
async function hasVisibleReelComposer(page) {
|
|
407
|
+
return page.evaluate(() => {
|
|
408
|
+
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
409
|
+
const r = dlg.getBoundingClientRect();
|
|
410
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
411
|
+
const cs = getComputedStyle(dlg);
|
|
412
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
|
|
413
|
+
const aria = dlg.getAttribute('aria-label') || '';
|
|
414
|
+
const text = (dlg.innerText || '').slice(0, 500);
|
|
415
|
+
const signature = `${aria}\n${text}`;
|
|
416
|
+
if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(signature)) continue;
|
|
417
|
+
if (/Tạo thước phim|Chỉnh sửa thước phim|Cài đặt thước phim|Create (?:a )?reel|Edit reel|Reel settings/i.test(signature)) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}).catch(() => false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function runOnce({ page, payload, log }) {
|
|
280
424
|
const {
|
|
281
425
|
video_url, title, description = '', tags = [],
|
|
282
426
|
visibility = 'public', format = 'short',
|
|
283
427
|
} = payload || {};
|
|
284
428
|
if (!video_url) throw new Error('No video_url provided');
|
|
285
429
|
|
|
286
|
-
log('info', '[fb-pw] selectors version=2026.06
|
|
430
|
+
log('info', '[fb-pw] selectors version=2026.08.06-composer-retry-metadata-poll');
|
|
287
431
|
|
|
288
432
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
289
433
|
|
|
@@ -730,6 +874,13 @@ async function run({ page, payload, log }) {
|
|
|
730
874
|
"[role='textbox'][aria-placeholder*='Mô tả thước phim']",
|
|
731
875
|
"[role='textbox'][aria-placeholder*='Mô tả']",
|
|
732
876
|
"[role='textbox'][aria-placeholder*='Describe your reel']",
|
|
877
|
+
// Newer Lexical/contenteditable cohorts expose data-placeholder
|
|
878
|
+
// instead of aria-placeholder. Scope these to a dialog so a feed
|
|
879
|
+
// comment box can never receive the reel caption.
|
|
880
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả thước phim']",
|
|
881
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả']",
|
|
882
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Describe your reel']",
|
|
883
|
+
"[role='dialog'] [contenteditable='true'][aria-label*='Mô tả thước phim']",
|
|
733
884
|
// BS composer description — legacy.
|
|
734
885
|
"[role='textbox'][aria-label*='Mô tả']",
|
|
735
886
|
"[role='textbox'][aria-label*='hộp thoại']",
|
|
@@ -744,11 +895,61 @@ async function run({ page, payload, log }) {
|
|
|
744
895
|
const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
|
|
745
896
|
if (!descText) break;
|
|
746
897
|
await f.type(descText, { delay: 12 });
|
|
898
|
+
// Blur commits the Lexical/React value and re-runs the form's
|
|
899
|
+
// validation. Without this, the text can be visible while Đăng
|
|
900
|
+
// remains disabled against the previous empty state.
|
|
901
|
+
await page.keyboard.press('Tab').catch(() => {});
|
|
747
902
|
fillState.description = true;
|
|
748
903
|
log('info', `[fb-pw] description filled (${descText.length} chars) via "${sel}"`);
|
|
749
904
|
break;
|
|
750
905
|
} catch (e) { log('info', `[fb-pw] desc via "${sel}" failed: ${e.message.slice(0, 80)}`); }
|
|
751
906
|
}
|
|
907
|
+
// Attribute shapes move frequently. Last-resort probe the visible Reel
|
|
908
|
+
// dialog for an editable whose combined accessibility signature names
|
|
909
|
+
// the description, tag it, and let Playwright type through the normal
|
|
910
|
+
// input path. This specifically covers the production cohort where the
|
|
911
|
+
// placeholder rendered after step 3 but none of the static selectors
|
|
912
|
+
// matched it on the first pass.
|
|
913
|
+
if (!fillState.description) {
|
|
914
|
+
const probe = await page.evaluate(() => {
|
|
915
|
+
document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'));
|
|
916
|
+
const dialogs = [...document.querySelectorAll("[role='dialog']")];
|
|
917
|
+
for (const dlg of dialogs) {
|
|
918
|
+
const dr = dlg.getBoundingClientRect();
|
|
919
|
+
if (dr.width < 8 || dr.height < 8) continue;
|
|
920
|
+
const ds = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 500)}`;
|
|
921
|
+
if (!/thước phim|reel/i.test(ds)) continue;
|
|
922
|
+
for (const el of dlg.querySelectorAll("[role='textbox'], [contenteditable='true'], textarea, input")) {
|
|
923
|
+
const r = el.getBoundingClientRect();
|
|
924
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
925
|
+
const sig = [
|
|
926
|
+
el.getAttribute('aria-placeholder'), el.getAttribute('data-placeholder'),
|
|
927
|
+
el.getAttribute('placeholder'), el.getAttribute('aria-label'),
|
|
928
|
+
el.textContent,
|
|
929
|
+
].filter(Boolean).join('|');
|
|
930
|
+
if (!/Mô tả(?: thước phim)?|Describe (?:your )?reel/i.test(sig)) continue;
|
|
931
|
+
el.setAttribute('__fbpw_desc__', '1');
|
|
932
|
+
return "[__fbpw_desc__='1']";
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return null;
|
|
936
|
+
}).catch(() => null);
|
|
937
|
+
if (probe) {
|
|
938
|
+
try {
|
|
939
|
+
const f = page.locator(probe);
|
|
940
|
+
const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
|
|
941
|
+
await f.click({ timeout: 3000 });
|
|
942
|
+
await f.type(descText, { delay: 12 });
|
|
943
|
+
await page.keyboard.press('Tab').catch(() => {});
|
|
944
|
+
fillState.description = true;
|
|
945
|
+
log('info', `[fb-pw] description filled (${descText.length} chars) via dynamic-probe`);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
log('info', `[fb-pw] desc dynamic-probe failed: ${e.message.slice(0, 80)}`);
|
|
948
|
+
} finally {
|
|
949
|
+
await page.evaluate(() => document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'))).catch(() => {});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
752
953
|
}
|
|
753
954
|
|
|
754
955
|
// Tags (Thêm thẻ) — comma-separated; STRIP leading "#" since FB's tag
|
|
@@ -929,8 +1130,12 @@ async function run({ page, payload, log }) {
|
|
|
929
1130
|
if (!bestEl) bestEl = tryScope(null);
|
|
930
1131
|
if (!bestEl) return null;
|
|
931
1132
|
// Tag the element so Playwright can find it back. data-attribute is
|
|
932
|
-
// safe + ignored by FB's React reconciliation.
|
|
1133
|
+
// safe + ignored by FB's React reconciliation. Clear stale markers
|
|
1134
|
+
// FIRST: a click that threw never reached the cleanup below, so an
|
|
1135
|
+
// old node could still carry the marker → the locator would resolve
|
|
1136
|
+
// to 2 elements and every later click dies on strict mode.
|
|
933
1137
|
const marker = '__fbpw_target__';
|
|
1138
|
+
document.querySelectorAll(`[${marker}]`).forEach((n) => n.removeAttribute(marker));
|
|
934
1139
|
bestEl.setAttribute(marker, '1');
|
|
935
1140
|
return { selector: `[${marker}='1']`, tag: bestEl.tagName.toLowerCase() };
|
|
936
1141
|
}, { verb: v, bottomHalf: requireBottomHalf });
|
|
@@ -938,17 +1143,23 @@ async function run({ page, payload, log }) {
|
|
|
938
1143
|
const loc = page.locator(found.selector);
|
|
939
1144
|
return {
|
|
940
1145
|
hit: {
|
|
941
|
-
click: async (opts) => {
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
await
|
|
1146
|
+
click: async (opts = {}) => {
|
|
1147
|
+
// resilientClick handles scroll-into-view (off-screen primary
|
|
1148
|
+
// actions like Chia sẻ ngay at y=-274 after a step jump) and,
|
|
1149
|
+
// more importantly, FB's toast/hover-card overlays that cover
|
|
1150
|
+
// the CTA and make Playwright's hit-target check fail forever.
|
|
1151
|
+
await resilientClick(page, loc, found.selector, {
|
|
1152
|
+
timeout: opts?.timeout || 10_000,
|
|
1153
|
+
log,
|
|
1154
|
+
label: opts?.label || v,
|
|
1155
|
+
jsFallback: opts?.jsFallback !== false,
|
|
1156
|
+
landedCheck: opts?.landedCheck || null,
|
|
1157
|
+
});
|
|
947
1158
|
// Remove the marker so subsequent findByVerbs gets a fresh
|
|
948
1159
|
// element instead of clinging to the now-stale node.
|
|
949
|
-
await page.evaluate((
|
|
950
|
-
document.querySelectorAll(
|
|
951
|
-
}
|
|
1160
|
+
await page.evaluate(() => {
|
|
1161
|
+
document.querySelectorAll('[__fbpw_target__]').forEach((el) => el.removeAttribute('__fbpw_target__'));
|
|
1162
|
+
}).catch(() => {});
|
|
952
1163
|
},
|
|
953
1164
|
isVisible: async () => loc.isVisible().catch(() => false),
|
|
954
1165
|
isEnabled: async () => loc.isEnabled().catch(() => true),
|
|
@@ -1047,15 +1258,32 @@ async function run({ page, payload, log }) {
|
|
|
1047
1258
|
// Wait for the thumb-edit modal to mount. Header text =
|
|
1048
1259
|
// "Chỉnh sửa hình thu nhỏ".
|
|
1049
1260
|
await page.evaluate(() => document.querySelectorAll("[__fbpw_thumb_edit__]").forEach((el) => el.removeAttribute('__fbpw_thumb_edit__'))).catch(() => {});
|
|
1261
|
+
const thumbDialogReady = await page.evaluate(() => {
|
|
1262
|
+
document.querySelectorAll('[__fbpw_thumb_dialog__]').forEach((el) => el.removeAttribute('__fbpw_thumb_dialog__'));
|
|
1263
|
+
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
1264
|
+
const r = dlg.getBoundingClientRect();
|
|
1265
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
1266
|
+
const cs = getComputedStyle(dlg);
|
|
1267
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
|
|
1268
|
+
const sig = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 300)}`;
|
|
1269
|
+
if (!/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(sig)) continue;
|
|
1270
|
+
dlg.setAttribute('__fbpw_thumb_dialog__', '1');
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
return false;
|
|
1274
|
+
}).catch(() => false);
|
|
1275
|
+
if (!thumbDialogReady) throw new Error('thumbnail editor dialog did not mount');
|
|
1050
1276
|
|
|
1051
1277
|
// Click "Tải lên" button — opens OS file picker. Use filechooser
|
|
1052
|
-
// race to inject the file path directly.
|
|
1278
|
+
// race to inject the file path directly. Scope every action to the
|
|
1279
|
+
// exact thumbnail dialog: Facebook can simultaneously keep its
|
|
1280
|
+
// Notifications dialog and the outer Reel composer in the DOM.
|
|
1053
1281
|
const uploadCandidates = [
|
|
1054
|
-
"[
|
|
1055
|
-
"[
|
|
1056
|
-
"[
|
|
1057
|
-
"[
|
|
1058
|
-
"[
|
|
1282
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Tải lên')",
|
|
1283
|
+
"[__fbpw_thumb_dialog__='1'] button:has-text('Tải lên')",
|
|
1284
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label='Tải lên']",
|
|
1285
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label*='Tải hình thu nhỏ']",
|
|
1286
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Upload')",
|
|
1059
1287
|
];
|
|
1060
1288
|
let uploaded = false;
|
|
1061
1289
|
for (const sel of uploadCandidates) {
|
|
@@ -1079,7 +1307,7 @@ async function run({ page, payload, log }) {
|
|
|
1079
1307
|
// Fallback — direct setInputFiles on a hidden image-accepting
|
|
1080
1308
|
// file input inside the modal.
|
|
1081
1309
|
if (!uploaded) {
|
|
1082
|
-
const directInput = page.locator("[
|
|
1310
|
+
const directInput = page.locator("[__fbpw_thumb_dialog__='1'] input[type='file'][accept*='image']").last();
|
|
1083
1311
|
if (await directInput.count().catch(() => 0) > 0) {
|
|
1084
1312
|
try {
|
|
1085
1313
|
await directInput.setInputFiles(thumbPath);
|
|
@@ -1095,10 +1323,10 @@ async function run({ page, payload, log }) {
|
|
|
1095
1323
|
if (uploaded) {
|
|
1096
1324
|
// Click "Lưu" to save the new thumbnail.
|
|
1097
1325
|
const saveCandidates = [
|
|
1098
|
-
"[
|
|
1099
|
-
"[
|
|
1100
|
-
"[
|
|
1101
|
-
"[
|
|
1326
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Lưu')",
|
|
1327
|
+
"[__fbpw_thumb_dialog__='1'] button:has-text('Lưu')",
|
|
1328
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label='Lưu']",
|
|
1329
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Save')",
|
|
1102
1330
|
];
|
|
1103
1331
|
let saved = false;
|
|
1104
1332
|
for (const sel of saveCandidates) {
|
|
@@ -1158,13 +1386,21 @@ async function run({ page, payload, log }) {
|
|
|
1158
1386
|
await page.waitForTimeout(1000);
|
|
1159
1387
|
}
|
|
1160
1388
|
if (modalClosed) {
|
|
1389
|
+
// Closing the nested thumbnail editor is NOT enough. In the
|
|
1390
|
+
// 2026-08-05 failure Facebook unmounted the OUTER composer at
|
|
1391
|
+
// the same moment, leaving us on the Page feed. Retrying is
|
|
1392
|
+
// safe here because the publish branch has not run yet.
|
|
1393
|
+
if (!(await hasVisibleReelComposer(page))) {
|
|
1394
|
+
await dumpFailure(page, 'composer-closed-after-thumb-save', log);
|
|
1395
|
+
throw safeComposerRetryError('FB Reel composer closed after saving thumbnail (before publish)');
|
|
1396
|
+
}
|
|
1161
1397
|
thumbApplied = true;
|
|
1162
1398
|
log('info', `[fb-pw] page-wall thumb — modal closed after save (retries=${retryCount})`);
|
|
1163
1399
|
} else {
|
|
1164
1400
|
log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
|
|
1165
1401
|
await page.keyboard.press('Escape').catch(() => {});
|
|
1166
1402
|
await page.waitForTimeout(800);
|
|
1167
|
-
const cancel = await firstVisible(page.locator("[
|
|
1403
|
+
const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
|
|
1168
1404
|
if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
|
|
1169
1405
|
await page.waitForTimeout(1500);
|
|
1170
1406
|
}
|
|
@@ -1173,11 +1409,12 @@ async function run({ page, payload, log }) {
|
|
|
1173
1409
|
}
|
|
1174
1410
|
} else {
|
|
1175
1411
|
log('warn', '[fb-pw] page-wall thumb upload failed — closing modal via Hủy to continue');
|
|
1176
|
-
const cancel = await firstVisible(page.locator("[
|
|
1412
|
+
const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
|
|
1177
1413
|
if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
|
|
1178
1414
|
await page.waitForTimeout(1500);
|
|
1179
1415
|
}
|
|
1180
1416
|
} catch (e) {
|
|
1417
|
+
if (e?.code === SAFE_RETRY_COMPOSER_CLOSED) throw e;
|
|
1181
1418
|
log('warn', `[fb-pw] page-wall thumb flow failed: ${e.message.slice(0, 100)}`);
|
|
1182
1419
|
}
|
|
1183
1420
|
customThumbDone = thumbApplied;
|
|
@@ -1565,6 +1802,23 @@ async function run({ page, payload, log }) {
|
|
|
1565
1802
|
// the user re-posted → duplicate reel. So: if the button vanished
|
|
1566
1803
|
// after we clicked, it went through.
|
|
1567
1804
|
const publishBtnGone = async () => !(await findByVerbs(publishVerbs, { requireBottomHalf: true }));
|
|
1805
|
+
// Marker-free twin of publishBtnGone, for use DURING a click retry:
|
|
1806
|
+
// findByVerbs re-tags __fbpw_target__, which would yank the marker
|
|
1807
|
+
// out from under the locator we're mid-click on.
|
|
1808
|
+
const publishBtnGoneDom = async () => page.evaluate((verbs) => {
|
|
1809
|
+
const scopes = [...document.querySelectorAll("[role='dialog']")];
|
|
1810
|
+
for (const s of (scopes.length ? scopes : [document.body])) {
|
|
1811
|
+
for (const b of s.querySelectorAll("button, [role='button']")) {
|
|
1812
|
+
if (!verbs.includes((b.innerText || '').trim())) continue;
|
|
1813
|
+
const r = b.getBoundingClientRect();
|
|
1814
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
1815
|
+
if (r.y < window.innerHeight * 0.4) continue;
|
|
1816
|
+
if (b.getAttribute('aria-disabled') === 'true' || b.disabled) continue;
|
|
1817
|
+
return false;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return true;
|
|
1821
|
+
}, publishVerbs).catch(() => false);
|
|
1568
1822
|
for (let attempt = 0; attempt < 3 && !clickedPublish; attempt++) {
|
|
1569
1823
|
const target = attempt === 0 ? pub : await findByVerbs(publishVerbs, { requireBottomHalf: true });
|
|
1570
1824
|
if (!target) {
|
|
@@ -1573,7 +1827,16 @@ async function run({ page, payload, log }) {
|
|
|
1573
1827
|
}
|
|
1574
1828
|
attemptedClick = true;
|
|
1575
1829
|
try {
|
|
1576
|
-
|
|
1830
|
+
// jsFallback:false — publish must never fire twice. The guarded
|
|
1831
|
+
// JS-dispatch below (attempt 3, after publishBtnGone checks) owns
|
|
1832
|
+
// that escalation; the generic one inside resilientClick would
|
|
1833
|
+
// bypass those guards. Overlay-muting + landedCheck still apply.
|
|
1834
|
+
await target.hit.click({
|
|
1835
|
+
timeout: 15000,
|
|
1836
|
+
label: `Đăng(${step + 1})`,
|
|
1837
|
+
jsFallback: false,
|
|
1838
|
+
landedCheck: publishBtnGoneDom,
|
|
1839
|
+
});
|
|
1577
1840
|
clickedPublish = true;
|
|
1578
1841
|
} catch (ce) {
|
|
1579
1842
|
log('warn', `[fb-pw] publish click attempt ${attempt + 1}/3 threw: ${ce.message.slice(0, 90)}`);
|
|
@@ -1615,6 +1878,10 @@ async function run({ page, payload, log }) {
|
|
|
1615
1878
|
for (const b of btns) {
|
|
1616
1879
|
const t = (b.innerText || '').trim();
|
|
1617
1880
|
if (t === v) {
|
|
1881
|
+
// Clear stale markers first — a previous confirm attempt
|
|
1882
|
+
// that threw left its tag behind, and two tagged nodes
|
|
1883
|
+
// make the locator fail on strict mode.
|
|
1884
|
+
document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
|
|
1618
1885
|
b.setAttribute('__fbpw_confirm__', '1');
|
|
1619
1886
|
return { selector: "[__fbpw_confirm__='1']", verb: v };
|
|
1620
1887
|
}
|
|
@@ -1628,8 +1895,18 @@ async function run({ page, payload, log }) {
|
|
|
1628
1895
|
if (confirmHit) {
|
|
1629
1896
|
log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
|
|
1630
1897
|
const cLoc = page.locator(confirmHit.selector);
|
|
1631
|
-
|
|
1632
|
-
|
|
1898
|
+
// Was a plain 5s click: it failed on nearly EVERY run ("confirm
|
|
1899
|
+
// click failed: Timeout 5000ms exceeded") because the new-post
|
|
1900
|
+
// toast covers this dialog's CTA too. Escalate the same way —
|
|
1901
|
+
// mute the overlay, then JS-dispatch. The confirm button lives in
|
|
1902
|
+
// a dialog that disappears once it fires, so the fallback can't
|
|
1903
|
+
// double-publish (querySelector returns null on the second pass).
|
|
1904
|
+
await resilientClick(page, cLoc, confirmHit.selector, {
|
|
1905
|
+
timeout: 8000,
|
|
1906
|
+
log,
|
|
1907
|
+
label: `confirm "${confirmHit.verb}"`,
|
|
1908
|
+
attempts: 2,
|
|
1909
|
+
}).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.split('\n')[0].slice(0, 80)}`));
|
|
1633
1910
|
await page.waitForTimeout(3000);
|
|
1634
1911
|
} else {
|
|
1635
1912
|
log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
|
|
@@ -1701,15 +1978,59 @@ async function run({ page, payload, log }) {
|
|
|
1701
1978
|
if (!pubWaitDone) {
|
|
1702
1979
|
pubWaitDone = true;
|
|
1703
1980
|
log('info', `[fb-pw] no "Tiếp" + no enabled publish at step ${step + 1} — waiting for "Đăng" to enable (large-video processing)…`);
|
|
1704
|
-
|
|
1981
|
+
const waitResult = await waitForPublishEnabled(page, publishVerbs, log, 180_000, 'fb-pw', {
|
|
1982
|
+
// Idempotent: once the field is filled, fillState makes this a
|
|
1983
|
+
// no-op. Until then it catches a lazily-mounted final form that
|
|
1984
|
+
// the first post-Tiếp pass raced past.
|
|
1985
|
+
onPoll: fillMetadata,
|
|
1986
|
+
});
|
|
1987
|
+
if (waitResult.enabled) { step--; continue; }
|
|
1988
|
+
await dumpInventory(page, log, `no-advance-${step + 1}`);
|
|
1989
|
+
await dumpFailure(page, `no-advance-${step + 1}`, log);
|
|
1990
|
+
if (!fillState.description) {
|
|
1991
|
+
throw new Error(`FB final description field not found at step ${step + 1}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (waitResult.sawPresent) {
|
|
1994
|
+
throw new Error(`FB publish remained disabled for 180s at step ${step + 1}`);
|
|
1995
|
+
}
|
|
1705
1996
|
}
|
|
1706
1997
|
await dumpInventory(page, log, `no-advance-${step + 1}`);
|
|
1707
1998
|
await dumpFailure(page, `no-advance-${step + 1}`, log);
|
|
1708
1999
|
throw new Error(`FB composer step ${step + 1}: neither publish nor Tiếp button found`);
|
|
1709
2000
|
}
|
|
1710
2001
|
log('info', `[fb-pw] click "${next.verb}" via "${next.sel}" (step ${step + 1})`);
|
|
2002
|
+
// The composer's dialog aria-label names the current wizard step ("Tạo
|
|
2003
|
+
// thước phim" → "Chỉnh sửa thước phim" → …). Snapshot it so a click that
|
|
2004
|
+
// registered-then-timed-out is recognised instead of re-fired.
|
|
2005
|
+
const composerStepLabel = async () => page.evaluate(() => {
|
|
2006
|
+
const out = [];
|
|
2007
|
+
document.querySelectorAll("[role='dialog']").forEach((d) => {
|
|
2008
|
+
const r = d.getBoundingClientRect();
|
|
2009
|
+
if (r.width < 8 || r.height < 8) return;
|
|
2010
|
+
out.push(d.getAttribute('aria-label') || '');
|
|
2011
|
+
});
|
|
2012
|
+
return out.join('|');
|
|
2013
|
+
}).catch(() => '');
|
|
2014
|
+
const stepLabelBefore = await composerStepLabel();
|
|
1711
2015
|
try {
|
|
1712
|
-
await waitAndClick(next.hit, {
|
|
2016
|
+
await waitAndClick(next.hit, {
|
|
2017
|
+
timeoutMs: 180_000,
|
|
2018
|
+
log,
|
|
2019
|
+
label: `Tiếp(${step + 1})`,
|
|
2020
|
+
// 5s was too tight: FB's new-notification toast parks itself over the
|
|
2021
|
+
// composer's bottom CTA and Playwright burns the whole budget on
|
|
2022
|
+
// blocked retries. Give the click room + let it escalate (mute the
|
|
2023
|
+
// overlay, then JS-dispatch), with landedCheck so we never advance
|
|
2024
|
+
// the wizard twice.
|
|
2025
|
+
clickOpts: {
|
|
2026
|
+
timeout: 12_000,
|
|
2027
|
+
label: `Tiếp(${step + 1})`,
|
|
2028
|
+
landedCheck: async () => {
|
|
2029
|
+
const now = await composerStepLabel();
|
|
2030
|
+
return !!now && now !== stepLabelBefore;
|
|
2031
|
+
},
|
|
2032
|
+
},
|
|
2033
|
+
});
|
|
1713
2034
|
} catch (e) {
|
|
1714
2035
|
await dumpInventory(page, log, `tiep-not-clickable-${step + 1}`);
|
|
1715
2036
|
await dumpFailure(page, `tiep-not-clickable-${step + 1}`, log);
|
|
@@ -2144,4 +2465,33 @@ async function run({ page, payload, log }) {
|
|
|
2144
2465
|
}
|
|
2145
2466
|
}
|
|
2146
2467
|
|
|
2468
|
+
async function run(args) {
|
|
2469
|
+
const { page, log } = args;
|
|
2470
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
2471
|
+
try {
|
|
2472
|
+
return await runOnce(args);
|
|
2473
|
+
} catch (e) {
|
|
2474
|
+
const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
|
|
2475
|
+
if (!safeRetry || attempt === 2) throw e;
|
|
2476
|
+
log('warn', `[fb-pw] composer vanished before publish — retrying the full upload once (${attempt}/1)`);
|
|
2477
|
+
// Force a clean React tree before runOnce returns to facebook.com and
|
|
2478
|
+
// opens a fresh composer. The first attempt's finally has already removed
|
|
2479
|
+
// its downloaded temp files; runOnce downloads clean copies on retry.
|
|
2480
|
+
await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
2481
|
+
await page.waitForTimeout(1200);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
throw new Error('FB upload exhausted safe retries');
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2147
2487
|
module.exports = { run };
|
|
2488
|
+
// Exposed so the overlay-click escalation can be exercised against a synthetic
|
|
2489
|
+
// page (toast covering the CTA) without driving the whole upload flow.
|
|
2490
|
+
module.exports.__testables = {
|
|
2491
|
+
resilientClick,
|
|
2492
|
+
muteClickInterceptors,
|
|
2493
|
+
unmuteClickInterceptors,
|
|
2494
|
+
waitForPublishEnabled,
|
|
2495
|
+
hasVisibleReelComposer,
|
|
2496
|
+
SAFE_RETRY_COMPOSER_CLOSED,
|
|
2497
|
+
};
|
|
@@ -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.
|