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 +51 -5
- package/lib/command-poller.js +55 -22
- package/package.json +1 -1
- package/scripts/lib/download.js +14 -2
- package/scripts/upload_facebook.js +77 -2
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
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 };
|
package/lib/command-poller.js
CHANGED
|
@@ -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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
package/scripts/lib/download.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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)}") —
|
|
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
|
};
|