channel-worker 2.5.33 → 2.5.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api-client.js CHANGED
@@ -68,7 +68,7 @@ class ApiClient {
68
68
  async getNextCommand(workerId) {
69
69
  // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
70
  // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.33",
3
+ "version": "2.5.35",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -1743,6 +1743,62 @@ async function run({ page, payload, log }) {
1743
1743
  throw new Error(`FB rate-limited / spam-blocked this account — publish was NOT accepted. Detected phrase: "${blockHit}". Cool down account before retry.`);
1744
1744
  }
1745
1745
 
1746
+ // 7b) COMMIT VERIFY + RETRY. A Playwright click on an enabled "Đăng" can
1747
+ // register without FB actually publishing (handler no-op / transient
1748
+ // glitch) — the composer modal ("Cài đặt thước phim") just stays open
1749
+ // (this is the "đứng ở nút Đăng" the user reported). We used to still
1750
+ // return ok:true with an empty post_url → FALSE SUCCESS. Detect the
1751
+ // still-open composer, re-click "Đăng" a few times, and fail loudly if
1752
+ // it refuses to commit (so the cmd is marked failed, not done).
1753
+ const composerStillOpen = () => page.evaluate((verbs) => {
1754
+ const dlgs = document.querySelectorAll("[role='dialog']");
1755
+ for (const dlg of dlgs) {
1756
+ const r = dlg.getBoundingClientRect();
1757
+ if (r.width < 8 || r.height < 8) continue;
1758
+ const cs = getComputedStyle(dlg);
1759
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1760
+ const hay = ((dlg.getAttribute('aria-label') || '') + ' ' + (dlg.innerText || '')).toLowerCase();
1761
+ if (!/cài đặt thước phim|reel settings|tạo thước phim|create reel|create a reel|tạo bài viết|create post/.test(hay)) continue;
1762
+ const btns = dlg.querySelectorAll("button, [role='button']");
1763
+ for (const b of btns) {
1764
+ const t = ((b.innerText || '') + ' ' + (b.getAttribute('aria-label') || '')).trim();
1765
+ if (verbs.some((v) => t === v || t.split('\n').includes(v))) return true; // publish CTA still present → not committed
1766
+ }
1767
+ }
1768
+ return false;
1769
+ }, publishVerbs).catch(() => false);
1770
+
1771
+ for (let commitTry = 0; commitTry < 3 && (await composerStillOpen()); commitTry++) {
1772
+ log('warn', `[fb-pw] composer STILL open after publish click — re-clicking "Đăng" (retry ${commitTry + 1}/3)`);
1773
+ const reClicked = await page.evaluate((verbs) => {
1774
+ const dlgs = document.querySelectorAll("[role='dialog']");
1775
+ for (const dlg of dlgs) {
1776
+ const btns = dlg.querySelectorAll("button, [role='button']");
1777
+ for (const v of verbs) {
1778
+ for (const b of btns) {
1779
+ const t = (b.innerText || '').trim();
1780
+ const a = (b.getAttribute('aria-label') || '').trim();
1781
+ if ((t === v || a === v) && b.getAttribute('aria-disabled') !== 'true') {
1782
+ b.setAttribute('__fbpw_recommit__', '1');
1783
+ return true;
1784
+ }
1785
+ }
1786
+ }
1787
+ }
1788
+ return false;
1789
+ }, publishVerbs).catch(() => false);
1790
+ if (reClicked) {
1791
+ await page.locator("[__fbpw_recommit__='1']").click({ timeout: 5000 }).catch(() => {});
1792
+ await page.evaluate(() => document.querySelectorAll("[__fbpw_recommit__]").forEach((el) => el.removeAttribute('__fbpw_recommit__'))).catch(() => {});
1793
+ }
1794
+ await page.waitForTimeout(8000);
1795
+ }
1796
+ if (await composerStillOpen()) {
1797
+ await dumpInventory(page, log, 'composer-stuck-open');
1798
+ await dumpFailure(page, 'composer-stuck-open', log);
1799
+ throw new Error('FB publish KHÔNG commit — composer vẫn mở ở nút "Đăng" sau 3 lần thử (video CHƯA được đăng). Thử lại sau.');
1800
+ }
1801
+
1746
1802
  let postUrl = '';
1747
1803
 
1748
1804
  // (a) Title-anchored page-wall scrape — FB renders the just-published
@@ -0,0 +1,285 @@
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
+ const clickPublish = async () => page.evaluate((vbs) => {
225
+ const dlg = document.querySelector("[role='dialog']") || document;
226
+ for (const v of vbs) {
227
+ for (const b of dlg.querySelectorAll("div[role='button'], button, [role='button']")) {
228
+ const t = (b.innerText || '').trim();
229
+ const a = (b.getAttribute('aria-label') || '').trim();
230
+ if ((t === v || a === v) && b.getAttribute('aria-disabled') !== 'true') {
231
+ b.setAttribute('__fbphoto_pub__', '1');
232
+ return true;
233
+ }
234
+ }
235
+ }
236
+ return false;
237
+ }, publishVerbs).catch(() => false);
238
+
239
+ // Wait for the publish button to enable (image still processing).
240
+ let clicked = false;
241
+ for (let i = 0; i < 30 && !clicked; i++) {
242
+ if (await clickPublish()) {
243
+ await page.locator("[__fbphoto_pub__='1']").click({ timeout: 5000 }).catch(() => {});
244
+ await page.evaluate(() => document.querySelectorAll('[__fbphoto_pub__]').forEach((e) => e.removeAttribute('__fbphoto_pub__'))).catch(() => {});
245
+ clicked = true;
246
+ } else {
247
+ await page.waitForTimeout(2000);
248
+ }
249
+ }
250
+ if (!clicked) {
251
+ await dumpFailure(page, 'no-publish-btn', log);
252
+ throw new Error('FB: nút "Đăng" không khả dụng (ảnh chưa xử lý xong / không tìm thấy).');
253
+ }
254
+
255
+ // 7) Verify commit + retry — a click can register without FB posting.
256
+ await page.waitForTimeout(6000);
257
+ for (let commitTry = 0; commitTry < 3 && (await composerStillOpen(page, publishVerbs)); commitTry++) {
258
+ log('warn', `[fbphoto] composer still open — re-clicking "Đăng" (retry ${commitTry + 1}/3)`);
259
+ if (await clickPublish()) {
260
+ await page.locator("[__fbphoto_pub__='1']").click({ timeout: 5000 }).catch(() => {});
261
+ await page.evaluate(() => document.querySelectorAll('[__fbphoto_pub__]').forEach((e) => e.removeAttribute('__fbphoto_pub__'))).catch(() => {});
262
+ }
263
+ await page.waitForTimeout(6000);
264
+ }
265
+ if (await composerStillOpen(page, publishVerbs)) {
266
+ await dumpFailure(page, 'composer-stuck-open', log);
267
+ throw new Error('FB đăng ảnh KHÔNG commit — composer vẫn mở sau 3 lần thử (bài CHƯA được đăng).');
268
+ }
269
+
270
+ // 8) Best-effort post URL.
271
+ await page.waitForTimeout(4000);
272
+ let postUrl = '';
273
+ if (capturedPostIds.length) {
274
+ postUrl = `https://www.facebook.com/${capturedPostIds[capturedPostIds.length - 1]}`;
275
+ }
276
+ if (!postUrl) log('warn', '[fbphoto] post URL not captured — post accepted (composer closed), URL best-effort only');
277
+
278
+ log('info', '[fbphoto] done');
279
+ return { ok: true, post_url: postUrl, caption: fullCaption.slice(0, 80) };
280
+ } finally {
281
+ safeUnlink(imagePath);
282
+ }
283
+ }
284
+
285
+ module.exports = { run };