channel-worker 2.5.34 → 2.5.36
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 +1 -1
- package/package.json +1 -1
- package/scripts/upload_facebook_photo.js +299 -0
package/lib/api-client.js
CHANGED
|
@@ -68,7 +68,7 @@ class ApiClient {
|
|
|
68
68
|
async getNextCommand(workerId) {
|
|
69
69
|
// Daemon-handled types. `_pw` variants route to the Playwright pipeline
|
|
70
70
|
// (lib/playwright-runner → scripts/<base>.js) instead of the extension.
|
|
71
|
-
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,scrape_affiliate_products,ingest_shopee_product';
|
|
71
|
+
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,scrape_affiliate_products,ingest_shopee_product';
|
|
72
72
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
73
73
|
}
|
|
74
74
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// upload_facebook_photo — publish an IMAGE (photo) post to a Facebook Page via
|
|
2
|
+
// the "Tạo bài viết" composer (NOT the Reels composer). Driven by the worker
|
|
3
|
+
// daemon's Playwright pipeline; command type upload_facebook_photo_pw →
|
|
4
|
+
// scripts/upload_facebook_photo.js. Payload: { image_url, caption, tags[] }.
|
|
5
|
+
//
|
|
6
|
+
// Strict-input contract (same as upload_facebook.js): every step throws on
|
|
7
|
+
// failure so the cmd is marked failed — no silent false-success. In particular
|
|
8
|
+
// we VERIFY the composer actually closed after clicking "Đăng" (a Playwright
|
|
9
|
+
// click can register without FB committing) and fail loudly otherwise.
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const { downloadToTemp, safeUnlink } = require('./lib/download');
|
|
14
|
+
|
|
15
|
+
async function firstVisible(locator, max = 8) {
|
|
16
|
+
const n = Math.min(await locator.count().catch(() => 0), max);
|
|
17
|
+
for (let i = 0; i < n; i++) {
|
|
18
|
+
const el = locator.nth(i);
|
|
19
|
+
if (await el.isVisible().catch(() => false)) return el;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function isActionable(el) {
|
|
25
|
+
if (!el) return false;
|
|
26
|
+
if (!(await el.isVisible().catch(() => false))) return false;
|
|
27
|
+
const ariaDisabled = await el.getAttribute('aria-disabled').catch(() => null);
|
|
28
|
+
if (ariaDisabled === 'true') return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function waitAndClick(el, { timeoutMs = 60_000, log, label = 'button' } = {}) {
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
if (await isActionable(el)) {
|
|
36
|
+
try { await el.click({ timeout: 3000 }); return; }
|
|
37
|
+
catch { /* retry */ }
|
|
38
|
+
}
|
|
39
|
+
await el.page().waitForTimeout(500);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function dumpFailure(page, tag, log) {
|
|
45
|
+
try {
|
|
46
|
+
const dir = path.join(os.tmpdir(), 'cm-worker-pw');
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
const png = path.join(dir, `fbphoto-${tag}-${Date.now()}.png`);
|
|
49
|
+
await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
|
|
50
|
+
log('warn', `[fbphoto] screenshot: ${png}`);
|
|
51
|
+
} catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Set an image file on a hidden <input type=file> — native path first, CDP
|
|
55
|
+
// DOM.setFileInputFiles fallback for the (rare) >50MB case / CSP blocks.
|
|
56
|
+
async function setImageFile(page, inputHandle, filePath, log) {
|
|
57
|
+
if (!inputHandle) throw new Error('setImageFile: no input handle');
|
|
58
|
+
try { await inputHandle.setInputFiles(filePath); return; }
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (!/larger than 50\s?mb|not connected to the server/i.test(String(e.message || ''))) throw e;
|
|
61
|
+
}
|
|
62
|
+
const ATTR = 'data-cm-bigfile';
|
|
63
|
+
let cdp;
|
|
64
|
+
try {
|
|
65
|
+
await inputHandle.evaluate((el, a) => el.setAttribute(a, '1'), ATTR);
|
|
66
|
+
cdp = await page.context().newCDPSession(page);
|
|
67
|
+
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
|
|
68
|
+
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: `input[${ATTR}="1"]` });
|
|
69
|
+
if (!nodeId) throw new Error('CDP querySelector: tagged input not found');
|
|
70
|
+
await cdp.send('DOM.setFileInputFiles', { files: [filePath], nodeId });
|
|
71
|
+
} finally {
|
|
72
|
+
await inputHandle.evaluate((el, a) => el.removeAttribute(a), ATTR).catch(() => {});
|
|
73
|
+
if (cdp) await cdp.detach().catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Is the post composer modal still open with a publish CTA inside? (used to
|
|
78
|
+
// detect a click that didn't commit — same guard as the reel flow).
|
|
79
|
+
function composerStillOpen(page, verbs) {
|
|
80
|
+
return page.evaluate((vbs) => {
|
|
81
|
+
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
82
|
+
for (const dlg of dlgs) {
|
|
83
|
+
const r = dlg.getBoundingClientRect();
|
|
84
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
85
|
+
const cs = getComputedStyle(dlg);
|
|
86
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
87
|
+
const hay = ((dlg.getAttribute('aria-label') || '') + ' ' + (dlg.innerText || '')).toLowerCase();
|
|
88
|
+
if (!/tạo bài viết|create post|create a post/.test(hay)) continue;
|
|
89
|
+
for (const b of dlg.querySelectorAll("button, [role='button']")) {
|
|
90
|
+
const t = ((b.innerText || '') + ' ' + (b.getAttribute('aria-label') || '')).trim();
|
|
91
|
+
if (vbs.some((v) => t === v || t.split('\n').includes(v))) return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}, verbs).catch(() => false);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function run({ page, payload, log }) {
|
|
99
|
+
const { image_url, caption = '', tags = [] } = payload || {};
|
|
100
|
+
if (!image_url) throw new Error('No image_url provided');
|
|
101
|
+
if (!caption || !caption.trim()) throw new Error('No caption provided');
|
|
102
|
+
|
|
103
|
+
log('info', '[fbphoto] selectors version=2026.07.01a');
|
|
104
|
+
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
105
|
+
|
|
106
|
+
// Best-effort capture of the new post id from FB's graphql mutation.
|
|
107
|
+
const capturedPostIds = [];
|
|
108
|
+
page.on('response', async (resp) => {
|
|
109
|
+
try {
|
|
110
|
+
const u = resp.url();
|
|
111
|
+
if (!/\/api\/graphql|\/api\/graphqlbatch|\/ajax\/composer/i.test(u)) return;
|
|
112
|
+
const text = await resp.text().catch(() => '');
|
|
113
|
+
if (!text) return;
|
|
114
|
+
for (const re of [/"story_fbid"\s*:\s*"(\d{8,20})"/g, /"post_id"\s*:\s*"(\d{8,20})"/g, /"legacy_story_hideable_id"\s*:\s*"(\d{8,20})"/g]) {
|
|
115
|
+
let m; while ((m = re.exec(text)) !== null) capturedPostIds.push(m[1]);
|
|
116
|
+
}
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Full caption = caption + hashtags (space-joined, '#' prefixed).
|
|
121
|
+
const hashtagLine = (Array.isArray(tags) ? tags : [])
|
|
122
|
+
.map((t) => String(t || '').replace(/^#/, '').trim()).filter(Boolean)
|
|
123
|
+
.map((t) => `#${t}`).join(' ');
|
|
124
|
+
const fullCaption = hashtagLine ? `${caption.trim()}\n\n${hashtagLine}` : caption.trim();
|
|
125
|
+
|
|
126
|
+
log('info', '[fbphoto] downloading image to local…');
|
|
127
|
+
const imagePath = await downloadToTemp(image_url, { prefix: 'fbphoto', ext: '.png' });
|
|
128
|
+
log('info', `[fbphoto] image at ${imagePath}`);
|
|
129
|
+
|
|
130
|
+
const publishVerbs = ['Đăng', 'Đăng bài', 'Post', 'Publish'];
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// 1) Open the Page wall.
|
|
134
|
+
log('info', '[fbphoto] navigating to facebook.com…');
|
|
135
|
+
await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
136
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
137
|
+
await page.waitForTimeout(2500);
|
|
138
|
+
|
|
139
|
+
// 2) Open the "Tạo bài viết" composer via the "Ảnh/video" entry (this opens
|
|
140
|
+
// the post composer directly in photo-attach mode). Fall back to the
|
|
141
|
+
// status box ("Bạn đang nghĩ gì") if the photo entry isn't found.
|
|
142
|
+
const openPhotoComposer = async () => {
|
|
143
|
+
const photoBtn = await page.evaluate(() => {
|
|
144
|
+
const els = document.querySelectorAll("div[role='button'], span, a[role='button']");
|
|
145
|
+
for (const el of els) {
|
|
146
|
+
const t = (el.innerText || '').trim();
|
|
147
|
+
if (/^(Ảnh\/video|Photo\/video|Ảnh|Photo)$/i.test(t)) {
|
|
148
|
+
const r = el.getBoundingClientRect();
|
|
149
|
+
if (r.width < 8 || r.height < 8 || r.y > window.innerHeight) continue;
|
|
150
|
+
el.setAttribute('__fbphoto_open__', '1');
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}).catch(() => false);
|
|
156
|
+
if (photoBtn) {
|
|
157
|
+
await page.locator("[__fbphoto_open__='1']").click({ timeout: 4000 }).catch(() => {});
|
|
158
|
+
await page.evaluate(() => document.querySelectorAll('[__fbphoto_open__]').forEach((e) => e.removeAttribute('__fbphoto_open__'))).catch(() => {});
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
// Fallback: click the status box to open the composer, then click Ảnh/video inside.
|
|
162
|
+
const box = await firstVisible(page.locator("div[role='button']:has-text('Bạn đang nghĩ gì'), div[role='button']:has-text(\"What's on your mind\")"), 3);
|
|
163
|
+
if (box) { await box.click({ timeout: 4000 }).catch(() => {}); await page.waitForTimeout(1500); return true; }
|
|
164
|
+
return false;
|
|
165
|
+
};
|
|
166
|
+
if (!(await openPhotoComposer())) {
|
|
167
|
+
await dumpFailure(page, 'no-composer-entry', log);
|
|
168
|
+
throw new Error('FB: không tìm thấy nút "Ảnh/video" / ô "Tạo bài viết" trên trang. Kiểm tra layout Page.');
|
|
169
|
+
}
|
|
170
|
+
await page.waitForTimeout(2500);
|
|
171
|
+
|
|
172
|
+
// 3) Ensure the composer modal is open; click its "Ảnh/video" if the file
|
|
173
|
+
// input isn't ready yet.
|
|
174
|
+
let fileInput = await page.$("input[type='file'][accept*='image']");
|
|
175
|
+
if (!fileInput) {
|
|
176
|
+
const inModalPhoto = await page.evaluate(() => {
|
|
177
|
+
const dlg = document.querySelector("[role='dialog']");
|
|
178
|
+
if (!dlg) return false;
|
|
179
|
+
for (const el of dlg.querySelectorAll("div[role='button'], span")) {
|
|
180
|
+
const t = (el.innerText || '').trim();
|
|
181
|
+
if (/^(Ảnh\/video|Photo\/video|Ảnh|Photo)$/i.test(t)) { el.setAttribute('__fbphoto_add__', '1'); return true; }
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}).catch(() => false);
|
|
185
|
+
if (inModalPhoto) {
|
|
186
|
+
await page.locator("[__fbphoto_add__='1']").click({ timeout: 4000 }).catch(() => {});
|
|
187
|
+
await page.evaluate(() => document.querySelectorAll('[__fbphoto_add__]').forEach((e) => e.removeAttribute('__fbphoto_add__'))).catch(() => {});
|
|
188
|
+
await page.waitForTimeout(1500);
|
|
189
|
+
}
|
|
190
|
+
// Wait for the file input to appear.
|
|
191
|
+
for (let i = 0; i < 10 && !fileInput; i++) {
|
|
192
|
+
fileInput = await page.$("input[type='file'][accept*='image']");
|
|
193
|
+
if (!fileInput) await page.waitForTimeout(1000);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!fileInput) {
|
|
197
|
+
await dumpFailure(page, 'no-file-input', log);
|
|
198
|
+
throw new Error('FB: không tìm thấy ô upload ảnh trong composer.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 4) Attach the image.
|
|
202
|
+
log('info', '[fbphoto] attaching image…');
|
|
203
|
+
await setImageFile(page, fileInput, imagePath, log);
|
|
204
|
+
// Wait for the image preview to render inside the composer.
|
|
205
|
+
await page.waitForTimeout(4000);
|
|
206
|
+
|
|
207
|
+
// 5) Fill caption into the composer's contenteditable textbox.
|
|
208
|
+
log('info', '[fbphoto] filling caption…');
|
|
209
|
+
const editable = await firstVisible(page.locator("[role='dialog'] [contenteditable='true'][role='textbox'], [role='dialog'] [contenteditable='true']"), 4);
|
|
210
|
+
if (!editable) {
|
|
211
|
+
await dumpFailure(page, 'no-caption-box', log);
|
|
212
|
+
throw new Error('FB: không tìm thấy ô nhập caption.');
|
|
213
|
+
}
|
|
214
|
+
await editable.click({ timeout: 4000 }).catch(() => {});
|
|
215
|
+
await page.waitForTimeout(400);
|
|
216
|
+
await page.keyboard.insertText(fullCaption).catch(async () => {
|
|
217
|
+
// insertText can fail on some FB editors → fall back to type.
|
|
218
|
+
await editable.type(fullCaption, { delay: 5 }).catch(() => {});
|
|
219
|
+
});
|
|
220
|
+
await page.waitForTimeout(1000);
|
|
221
|
+
|
|
222
|
+
// 6) Click "Đăng".
|
|
223
|
+
log('info', '[fbphoto] clicking publish…');
|
|
224
|
+
// Tag the first enabled button inside the composer matching `verbs`
|
|
225
|
+
// (bottom-half CTA preferred). Returns the matched verb, or null.
|
|
226
|
+
const tagCta = async (verbs) => page.evaluate((vbs) => {
|
|
227
|
+
const dlg = document.querySelector("[role='dialog']") || document;
|
|
228
|
+
const btns = [...dlg.querySelectorAll("div[role='button'], button, [role='button']")];
|
|
229
|
+
for (const v of vbs) {
|
|
230
|
+
for (const b of btns) {
|
|
231
|
+
const t = (b.innerText || '').trim();
|
|
232
|
+
const a = (b.getAttribute('aria-label') || '').trim();
|
|
233
|
+
if ((t === v || a === v) && b.getAttribute('aria-disabled') !== 'true') {
|
|
234
|
+
const r = b.getBoundingClientRect();
|
|
235
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
236
|
+
b.setAttribute('__fbphoto_cta__', '1');
|
|
237
|
+
return v;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}, verbs).catch(() => null);
|
|
243
|
+
const clickTaggedCta = async () => {
|
|
244
|
+
await page.locator("[__fbphoto_cta__='1']").click({ timeout: 5000 }).catch(() => {});
|
|
245
|
+
await page.evaluate(() => document.querySelectorAll('[__fbphoto_cta__]').forEach((e) => e.removeAttribute('__fbphoto_cta__'))).catch(() => {});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Step loop: the FB photo composer is MULTI-STEP — "Tiếp" advances through
|
|
249
|
+
// audience/settings screens; only the LAST step shows "Đăng"/"Chia sẻ ngay"
|
|
250
|
+
// (the failing run stopped here: it saw a "Tiếp", not a publish button).
|
|
251
|
+
// Prefer a publish button when present; else advance via "Tiếp". Retry a
|
|
252
|
+
// few times when neither is enabled yet (FB disables while ingesting).
|
|
253
|
+
const nextVerbs = ['Tiếp', 'Tiếp theo', 'Next', 'Continue'];
|
|
254
|
+
let published = false;
|
|
255
|
+
let stalls = 0;
|
|
256
|
+
for (let step = 0; step < 8 && !published; step++) {
|
|
257
|
+
const pubHit = await tagCta(publishVerbs);
|
|
258
|
+
if (pubHit) { log('info', `[fbphoto] publish via "${pubHit}" (step ${step + 1})`); await clickTaggedCta(); published = true; break; }
|
|
259
|
+
const nextHit = await tagCta(nextVerbs);
|
|
260
|
+
if (nextHit) { log('info', `[fbphoto] advance via "${nextHit}" (step ${step + 1})`); await clickTaggedCta(); await page.waitForTimeout(2800); stalls = 0; continue; }
|
|
261
|
+
// Neither publish nor Tiếp enabled — FB still ingesting the photo. Wait.
|
|
262
|
+
if (++stalls > 10) break; // ~30s of no CTA → give up
|
|
263
|
+
log('info', `[fbphoto] no CTA yet at step ${step + 1} — waiting…`);
|
|
264
|
+
step--; // don't count a pure wait against the step budget
|
|
265
|
+
await page.waitForTimeout(3000);
|
|
266
|
+
}
|
|
267
|
+
if (!published) {
|
|
268
|
+
await dumpFailure(page, 'no-publish-btn', log);
|
|
269
|
+
throw new Error('FB: không tìm thấy nút "Đăng" sau các bước "Tiếp" (composer đổi layout?).');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Verify commit + retry — a click can register without FB posting.
|
|
273
|
+
await page.waitForTimeout(6000);
|
|
274
|
+
for (let commitTry = 0; commitTry < 3 && (await composerStillOpen(page, publishVerbs)); commitTry++) {
|
|
275
|
+
log('warn', `[fbphoto] composer still open — re-clicking publish (retry ${commitTry + 1}/3)`);
|
|
276
|
+
if (await tagCta(publishVerbs)) await clickTaggedCta();
|
|
277
|
+
await page.waitForTimeout(6000);
|
|
278
|
+
}
|
|
279
|
+
if (await composerStillOpen(page, publishVerbs)) {
|
|
280
|
+
await dumpFailure(page, 'composer-stuck-open', log);
|
|
281
|
+
throw new Error('FB đăng ảnh KHÔNG commit — composer vẫn mở sau 3 lần thử (bài CHƯA được đăng).');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 8) Best-effort post URL.
|
|
285
|
+
await page.waitForTimeout(4000);
|
|
286
|
+
let postUrl = '';
|
|
287
|
+
if (capturedPostIds.length) {
|
|
288
|
+
postUrl = `https://www.facebook.com/${capturedPostIds[capturedPostIds.length - 1]}`;
|
|
289
|
+
}
|
|
290
|
+
if (!postUrl) log('warn', '[fbphoto] post URL not captured — post accepted (composer closed), URL best-effort only');
|
|
291
|
+
|
|
292
|
+
log('info', '[fbphoto] done');
|
|
293
|
+
return { ok: true, post_url: postUrl, caption: fullCaption.slice(0, 80) };
|
|
294
|
+
} finally {
|
|
295
|
+
safeUnlink(imagePath);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = { run };
|