channel-worker 2.5.149 → 2.5.151

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
@@ -1,7 +1,21 @@
1
+ // Final-result reports (done / failed / pending) ride out an API restart:
2
+ // one lost PUT left the command 'running' until the stale cleanup marked it
3
+ // "Timed out — có thể bài ĐÃ lên" and the scheduler stopped retrying the
4
+ // idea. ~2.5 minutes covers a pm2 restart with room to spare.
5
+ const REPORT_RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000, 30000, 60000];
6
+
7
+ // The API never answered: connection refused/reset, a gateway 502/503/504, or
8
+ // a non-JSON page while it restarts. An answer from the API itself — success:
9
+ // false, even a 500 from a handler — is final: repeating it changes nothing.
10
+ class ApiUnreachableError extends Error {}
11
+ const GATEWAY_STATUSES = new Set([502, 503, 504]);
12
+
1
13
  class ApiClient {
2
14
  constructor(baseUrl, workerToken) {
3
15
  this.baseUrl = baseUrl.replace(/\/$/, '');
4
16
  this.workerToken = workerToken || '';
17
+ this.reportRetryDelaysMs = REPORT_RETRY_DELAYS_MS;
18
+ this.sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
5
19
  }
6
20
 
7
21
  async request(method, path, body = null) {
@@ -15,8 +29,21 @@ class ApiClient {
15
29
  };
16
30
  if (body) options.body = JSON.stringify(body);
17
31
 
18
- const res = await fetch(url, options);
19
- const json = await res.json();
32
+ let res;
33
+ try {
34
+ res = await fetch(url, options);
35
+ } catch (err) {
36
+ throw new ApiUnreachableError(`API unreachable: ${err.message}`);
37
+ }
38
+ let json;
39
+ try {
40
+ json = await res.json();
41
+ } catch {
42
+ throw new ApiUnreachableError(`API error ${res.status} (no JSON body)`);
43
+ }
44
+ if (GATEWAY_STATUSES.has(res.status) && !json?.success) {
45
+ throw new ApiUnreachableError(json?.message || `API error ${res.status}`);
46
+ }
20
47
 
21
48
  if (!json.success) {
22
49
  throw new Error(json.message || `API error ${res.status}`);
@@ -24,6 +51,21 @@ class ApiClient {
24
51
  return json.data;
25
52
  }
26
53
 
54
+ // Same request, retried while the API is unreachable. Only for writes that
55
+ // are safe to repeat — every report here sets a final state.
56
+ async reportRequest(method, path, body) {
57
+ for (let attempt = 0; ; attempt++) {
58
+ try {
59
+ return await this.request(method, path, body);
60
+ } catch (err) {
61
+ const delay = this.reportRetryDelaysMs[attempt];
62
+ if (!(err instanceof ApiUnreachableError) || delay === undefined) throw err;
63
+ console.warn(`[api] ${method} ${path} failed (${err.message}) — retry ${attempt + 1}/${this.reportRetryDelaysMs.length} in ${Math.round(delay / 1000)}s`);
64
+ await this.sleep(delay);
65
+ }
66
+ }
67
+ }
68
+
27
69
  // Worker
28
70
  async register(workerData) {
29
71
  return this.request('POST', '/workers/register', workerData);
@@ -79,8 +121,12 @@ class ApiClient {
79
121
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
122
  }
81
123
 
124
+ // A 'running' touch is a liveness beat: NOT retried — a beat landing late
125
+ // would overwrite the final 'done' that followed it.
82
126
  async updateCommand(commandId, data) {
83
- return this.request('PUT', `/workers/commands/${commandId}`, data);
127
+ const path = `/workers/commands/${commandId}`;
128
+ if (data?.status === 'running') return this.request('PUT', path, data);
129
+ return this.reportRequest('PUT', path, data);
84
130
  }
85
131
 
86
132
  // Report a command result via the extension-style endpoint, which also
@@ -88,7 +134,7 @@ class ApiClient {
88
134
  // idea UI reflects pw-pipeline outcomes the same way it does for extension
89
135
  // uploads). Use this for upload_*_pw results.
90
136
  async postCommandResult(commandId, { status, result, error } = {}) {
91
- return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
137
+ return this.reportRequest('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
92
138
  }
93
139
 
94
140
  // Shopee scraper callbacks — worker ships parsed products, API upserts into
@@ -222,4 +268,4 @@ class ApiClient {
222
268
  }
223
269
  }
224
270
 
225
- module.exports = { ApiClient };
271
+ module.exports = { ApiClient, ApiUnreachableError, REPORT_RETRY_DELAYS_MS };
@@ -2,6 +2,7 @@ const { NstManager } = require('./nst-manager');
2
2
  const { refreshPinnedAgentAddress } = require('./nst-agent-locator');
3
3
  const { checkAndUpdateExtension } = require('./extension-updater');
4
4
  const { StatsSyncer } = require('./stats-syncer');
5
+ const { ApiUnreachableError } = require('./api-client');
5
6
 
6
7
  // Quãng RẢNH trước khi đóng profile của luồng đăng bài. Phải đủ dài để lượt
7
8
  // upload nền tảng kế tiếp (facebook → youtube → tiktok của cùng một idea) kịp
@@ -284,11 +285,23 @@ class CommandPoller {
284
285
  )), ms);
285
286
  });
286
287
  try {
287
- const patch = await Promise.race([job, deadline]);
288
- await this.api.updateCommand(command._id, patch);
289
- } catch (err) {
290
- console.error(`${failLog}: ${err.message}`);
291
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
288
+ let patch;
289
+ try {
290
+ patch = await Promise.race([job, deadline]);
291
+ } catch (err) {
292
+ console.error(`${failLog}: ${err.message}`);
293
+ patch = { status: 'failed', error: String(err.message || err).slice(0, 500) };
294
+ }
295
+ // Outside the work's try: a report that cannot reach the API must not
296
+ // turn a finished job into 'failed'.
297
+ try {
298
+ await this.api.updateCommand(command._id, patch);
299
+ } catch (err) {
300
+ console.error(`${failLog}: ${patch?.status} NOT reported: ${err.message}`);
301
+ if (!(err instanceof ApiUnreachableError) && patch?.status !== 'failed') {
302
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) }).catch(() => {});
303
+ }
304
+ }
292
305
  } finally {
293
306
  clearTimeout(timer);
294
307
  abandoned = true;
@@ -747,23 +760,43 @@ class CommandPoller {
747
760
  }, RUN_BEAT_MS);
748
761
 
749
762
  try {
750
- const result = await runPlaywrightScript({
751
- nst: this.nst,
752
- profileId,
753
- scriptName,
754
- payload,
755
- log,
756
- keepOpen: keepOpen.enabled,
757
- info: runInfo,
758
- });
759
- // postCommandResult (not updateCommand) so the existing extension-side
760
- // logic mirrors status into ContentJob + ContentIdea.publish_results +
761
- // broadcasts the WS update — pw and extension paths share idea-state UX.
762
- await this.api.postCommandResult(command._id, { status: 'done', result: result || {} });
763
- console.log(`[commands/pw] ${command.type} done`);
764
- } catch (err) {
765
- console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
766
- await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
763
+ let outcome;
764
+ try {
765
+ const result = await runPlaywrightScript({
766
+ nst: this.nst,
767
+ profileId,
768
+ scriptName,
769
+ payload,
770
+ log,
771
+ keepOpen: keepOpen.enabled,
772
+ info: runInfo,
773
+ });
774
+ outcome = { status: 'done', result: result || {} };
775
+ console.log(`[commands/pw] ${command.type} done`);
776
+ } catch (err) {
777
+ console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
778
+ outcome = { status: 'failed', error: String(err.message || err).slice(0, 500) };
779
+ }
780
+ // Stop the 'running' beat first: a beat landing after the result would
781
+ // put the command back to 'running'.
782
+ clearInterval(beat);
783
+ // Reported once, outside the script's try: a report that cannot reach
784
+ // the API used to fall into the catch above and report the posted video
785
+ // as 'failed'. postCommandResult (not updateCommand) so the existing
786
+ // extension-side logic mirrors status into ContentJob +
787
+ // ContentIdea.publish_results + broadcasts the WS update — pw and
788
+ // extension paths share idea-state UX.
789
+ try {
790
+ await this.api.postCommandResult(command._id, outcome);
791
+ } catch (err) {
792
+ console.error(`[commands/pw] ${command.type} ${outcome.status} NOT reported: ${err.message}`);
793
+ // The API answered and refused it: settle the command as failed
794
+ // rather than leave it 'running'. Unreachable after every retry:
795
+ // nothing more can be sent.
796
+ if (!(err instanceof ApiUnreachableError) && outcome.status !== 'failed') {
797
+ await this.api.postCommandResult(command._id, { status: 'failed', error: `API từ chối kết quả: ${String(err.message).slice(0, 300)}` }).catch(() => {});
798
+ }
799
+ }
767
800
  } finally {
768
801
  clearInterval(beat);
769
802
  // Always release the per-profile mutex — even on throw — or sibling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.149",
3
+ "version": "2.5.151",
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": {
@@ -8,8 +8,20 @@ const path = require('path');
8
8
  const https = require('https');
9
9
  const http = require('http');
10
10
 
11
+ // Thư mục tải về nằm dưới HOME của daemon, không phải os.tmpdir(). File >50 MB
12
+ // đi đường CDP DOM.setFileInputFiles chỉ truyền ĐƯỜNG DẪN — trình duyệt tự mở
13
+ // file bằng quyền user Windows của NÓ. Sự cố 02-04/10 (kênh 78, win-worker-2):
14
+ // daemon chạy user gatoasang94, os.tmpdir() = C:\Users\gatoa\AppData\Local\Temp
15
+ // (riêng user đó) trong khi trình duyệt NST chạy user gatoasang95 → không mở
16
+ // được file, FB báo "Không thể tải file của bạn lên" 21/21 lượt. HOME của
17
+ // worker-2 (C:\agents\worker2-home) được dựng sẵn cho mọi user đọc; HOME của
18
+ // worker-1 cùng user với trình duyệt của nó.
19
+ function uploadDir() {
20
+ return path.join(os.homedir(), '.channel-worker', 'upload-tmp');
21
+ }
22
+
11
23
  async function downloadToTemp(url, { prefix = 'pw', ext = '.mp4', maxRedirects = 5 } = {}) {
12
- const dir = path.join(os.tmpdir(), 'cm-worker-pw');
24
+ const dir = uploadDir();
13
25
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
14
26
  const dest = path.join(dir, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
15
27
 
@@ -47,4 +59,4 @@ function safeUnlink(p) {
47
59
  try { fs.unlinkSync(p); } catch {}
48
60
  }
49
61
 
50
- module.exports = { downloadToTemp, safeUnlink };
62
+ module.exports = { downloadToTemp, safeUnlink, uploadDir };
@@ -439,7 +439,9 @@ async function dismissBsOnboarding(page, log) {
439
439
  // fileChooser.setFiles() throws and the upload never starts. Bypass via the raw
440
440
  // CDP command DOM.setFileInputFiles: the browser reads the path off its OWN
441
441
  // local disk (worker + Nstbrowser are co-located on the same machine), so no
442
- // bytes cross the wire and there is no size cap. Unlike an in-page fetch() this
442
+ // bytes cross the wire and there is no size cap. Cùng máy CHƯA đủ: trình duyệt
443
+ // mở file bằng quyền user Windows của nó, nên thư mục tải về phải cho user đó
444
+ // đọc — xem probeBrowserRead bên dưới. Unlike an in-page fetch() this
443
445
  // is also immune to facebook.com's strict connect-src CSP. We tag the exact
444
446
  // <input> with a unique attribute so CDP resolves its nodeId unambiguously
445
447
  // (the page carries several file inputs).
@@ -463,6 +465,68 @@ async function cdpSetInputFiles(page, inputHandle, filePath, log, tag) {
463
465
  }
464
466
  }
465
467
 
468
+ // Trình duyệt có tự đọc được file không? Đường CDP chỉ truyền ĐƯỜNG DẪN nên
469
+ // nếu trình duyệt chạy dưới user Windows khác daemon mà thư mục không cấp quyền
470
+ // đọc, FB chỉ báo "Không thể tải file của bạn lên" — trông y như FB chê file
471
+ // to. Sự cố 02-04/10 kênh 78 (win-worker-2): 21/21 lượt, câu lỗi cũ khuyên nén
472
+ // file làm lạc hướng. Đo trên Chrome thật: file bị chặn quyền vẫn báo đủ
473
+ // size nhưng đọc thì NotReadableError; file không tồn tại thì size 0. Nên đọc
474
+ // thử 64 KB đầu trên một <input> RIÊNG gắn vào <html> (ngoài cây React của FB,
475
+ // không kích hoạt luồng upload), rồi gỡ đi.
476
+ const READ_PROBE_BYTES = 64 * 1024;
477
+
478
+ function readProbeVerdict(result, localSize) {
479
+ if (!result) return { ok: false, detail: 'không lấy được kết quả đọc thử' };
480
+ if (result.err) return { ok: false, detail: result.err };
481
+ const want = Math.min(READ_PROBE_BYTES, localSize);
482
+ if ((result.read || 0) < want) {
483
+ return { ok: false, detail: `trình duyệt đọc được ${result.read || 0}/${want} byte (size thấy ${result.size || 0}, trên đĩa ${localSize})` };
484
+ }
485
+ return { ok: true };
486
+ }
487
+
488
+ // Trả { ok, detail } khi đọc thử được; null khi bản thân phép thử hỏng (khi đó
489
+ // cứ nạp file như cũ — phép thử chỉ được CHẶN khi có bằng chứng không đọc được).
490
+ async function probeBrowserRead(page, filePath, log, tag) {
491
+ const ID = 'cm-read-probe';
492
+ let cdp;
493
+ try {
494
+ const localSize = fs.statSync(filePath).size;
495
+ await page.evaluate((id) => {
496
+ document.getElementById(id)?.remove();
497
+ const el = document.createElement('input');
498
+ el.type = 'file';
499
+ el.id = id;
500
+ el.style.display = 'none';
501
+ document.documentElement.appendChild(el);
502
+ }, ID);
503
+ cdp = await page.context().newCDPSession(page);
504
+ const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
505
+ const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: `#${ID}` });
506
+ if (!nodeId) throw new Error('probe input not attached');
507
+ await cdp.send('DOM.setFileInputFiles', { files: [filePath], nodeId });
508
+ const result = await page.evaluate(async ({ id, bytes }) => {
509
+ const el = document.getElementById(id);
510
+ const file = el && el.files && el.files[0];
511
+ try {
512
+ if (!file) return { err: 'input không nhận file' };
513
+ const buf = await file.slice(0, bytes).arrayBuffer();
514
+ return { size: file.size, read: buf.byteLength };
515
+ } catch (e) {
516
+ return { size: file.size, err: `${e.name}: ${e.message}` };
517
+ } finally {
518
+ if (el) el.remove();
519
+ }
520
+ }, { id: ID, bytes: READ_PROBE_BYTES });
521
+ return readProbeVerdict(result, localSize);
522
+ } catch (e) {
523
+ log('warn', `[${tag}] đọc thử file từ trình duyệt không chạy được (${String(e.message || '').slice(0, 80)}) — nạp file như cũ`);
524
+ return null;
525
+ } finally {
526
+ if (cdp) await cdp.detach().catch(() => {});
527
+ }
528
+ }
529
+
466
530
  // Set a video file on a file <input>, transparently bypassing the CDP 50MB cap.
467
531
  // Tries Playwright's native path first (fine for <50MB / thumbnails), then
468
532
  // falls back to the CDP command only on the size error.
@@ -487,6 +551,13 @@ async function setVideoFile(page, inputHandle, filePath, log, tag = 'fb-pw') {
487
551
  } catch (e) {
488
552
  log('info', `[${tag}] native setInputFiles không xong (${String(e.message || '').slice(0, 70)}) — chuyển sang DOM.setFileInputFiles…`);
489
553
  }
554
+ const probe = await probeBrowserRead(page, filePath, log, tag);
555
+ if (probe && !probe.ok) {
556
+ // Mã FB_PUBLISH_ để run() không đi dò tài khoản. Tránh chữ "Không thể tải
557
+ // file" để PublishRetry không xếp nhầm vào luật fb_rejected_file.
558
+ throw new Error(`FB_PUBLISH_FILE_UNREADABLE: trình duyệt không đọc được file video ${filePath} (${probe.detail}) — trình duyệt NST chạy dưới user Windows khác daemon và thư mục tải về không cho user đó đọc; nén file KHÔNG giúp gì, phải sửa quyền thư mục trên máy worker`);
559
+ }
560
+ if (probe) log('info', `[${tag}] trình duyệt đọc thử được file (${READ_PROBE_BYTES / 1024} KB đầu)`);
490
561
  await cdpSetInputFiles(page, inputHandle, filePath, log, tag);
491
562
  }
492
563
 
@@ -4150,7 +4221,7 @@ async function runOnce({ page, payload, log }) {
4150
4221
  if (waitResult.rejected) {
4151
4222
  // Mã FB_PUBLISH_ để run() không đi dò tài khoản (không phải lỗi
4152
4223
  // đăng nhập), và PublishRetry phân loại đúng "file bị từ chối".
4153
- throw new Error(`FB_PUBLISH_REJECTED: Facebook từ chối file video ("${waitResult.rejected.slice(0, 100)}") — thường gặp với file >50 MB đi đường CDP; nén/render lại nhỏ hơn rồi đăng`);
4224
+ throw new Error(`FB_PUBLISH_REJECTED: Facebook từ chối file video ("${waitResult.rejected.slice(0, 100)}") — lỗi quyền đọc file (máy nhiều user Windows) đã báo riêng mã FB_PUBLISH_FILE_UNREADABLE; lặp lại thì nén/render lại nhỏ hơn rồi đăng`);
4154
4225
  }
4155
4226
  // Bảng cài đặt không tải → đóng composer, chạy lại cả lượt upload
4156
4227
  // TRONG PHIÊN (đường safe-retry sẵn có: bắt đầu lại từ trang chủ nên
@@ -4844,5 +4915,9 @@ module.exports.__testables = {
4844
4915
  REEL_ENTRY_SELECTORS,
4845
4916
  ADD_VIDEO_SELECTORS,
4846
4917
  setVideoFile,
4918
+ // Đọc thử file từ phía trình duyệt trước khi đi đường CDP (máy nhiều user
4919
+ // Windows: trình duyệt không mở được file daemon tải về).
4920
+ probeBrowserRead,
4921
+ readProbeVerdict,
4847
4922
  FB_REEL_CREATE_URL,
4848
4923
  };