channel-worker 2.5.137 → 2.5.139
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/bin/cli.js +90 -1
- package/lib/command-poller.js +54 -3
- package/lib/daemon.js +199 -21
- package/lib/drain.js +93 -0
- package/lib/job-poller.js +13 -2
- package/lib/shopee-report.js +75 -1
- package/lib/singleton.js +4 -0
- package/lib/updater.js +42 -11
- package/package.json +1 -1
- package/scripts/fetch_facebook_reel_stats.js +24 -1
- package/scripts/upload_facebook.js +111 -0
package/bin/cli.js
CHANGED
|
@@ -285,6 +285,93 @@ if (cmd === 'pair') {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
} else if (cmd === 'drain') {
|
|
289
|
+
// Tắt NHẸ: daemon ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt.
|
|
290
|
+
// Dùng thay `taskkill /f` / `stop` khi phát hành — xem lib/drain.js (soát
|
|
291
|
+
// 23/09/2026: 9/9 phiên nuôi "Timed out" còn log là bị giết giữa phiên lúc
|
|
292
|
+
// thay bản). Mã thoát: 0 = daemon đã tắt sạch (hoặc không có daemon);
|
|
293
|
+
// 2 = hết hạn, daemon QUAY LẠI làm việc, người quyết định dừng cứng hay chờ;
|
|
294
|
+
// 3 = daemon cũ tắt nhưng đã có daemon MỚI giữ khoá (watchdog/updater bật lại).
|
|
295
|
+
const singleton = require('../lib/singleton');
|
|
296
|
+
const drain = require('../lib/drain');
|
|
297
|
+
const lock = singleton.readLock(CONFIG_DIR);
|
|
298
|
+
if (args.cancel) {
|
|
299
|
+
drain.clearRequest(CONFIG_DIR);
|
|
300
|
+
console.log('[channel-worker] Đã huỷ yêu cầu xả — daemon nhận việc lại ở nhịp kế (≤5s).');
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
if (!lock || singleton.isStale(lock)) {
|
|
304
|
+
drain.clearRequest(CONFIG_DIR);
|
|
305
|
+
console.log(`[channel-worker] Không có daemon nào đang sống ở ${CONFIG_DIR} — không có gì để xả.`);
|
|
306
|
+
process.exit(0);
|
|
307
|
+
}
|
|
308
|
+
const timeoutMin = Number(args.timeout || drain.DEFAULT_TIMEOUT_MIN);
|
|
309
|
+
if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
|
|
310
|
+
console.error('[channel-worker] --timeout phải là số phút > 0');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
const req = drain.writeRequest(CONFIG_DIR, { timeoutMin });
|
|
314
|
+
console.log(`[channel-worker] Đã gửi yêu cầu xả tới daemon PID ${lock.pid} (hạn ${req.deadline_at}).`);
|
|
315
|
+
if (process.platform === 'win32') {
|
|
316
|
+
console.log('[channel-worker] Nhớ: watchdog bật lại daemon sau ≤5 phút nếu chưa có C:\\agents\\watchdog.pause.');
|
|
317
|
+
}
|
|
318
|
+
if (lock.supervisor === 'pm2') {
|
|
319
|
+
console.log('[channel-worker] Daemon này chạy dưới pm2: xả xong nó tự tắt nhưng pm2 dựng lại ngay (lệnh sẽ trả mã 3). '
|
|
320
|
+
+ 'Trên máy pm2 cứ để bộ tự cập nhật lo — từ 2.5.139 nó tự xả trước khi cài.');
|
|
321
|
+
}
|
|
322
|
+
if (args['no-wait']) process.exit(0);
|
|
323
|
+
|
|
324
|
+
(async () => {
|
|
325
|
+
// Daemon soi yêu cầu mỗi 5s và tự quay lại làm việc khi hết hạn — chờ quá
|
|
326
|
+
// hạn thêm 30s là chắc chắn đã có kết cục.
|
|
327
|
+
const hardStop = Date.parse(req.deadline_at) + 30000;
|
|
328
|
+
let lastSay = 0;
|
|
329
|
+
let deadAt = 0;
|
|
330
|
+
for (;;) {
|
|
331
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
332
|
+
const cur = singleton.readLock(CONFIG_DIR);
|
|
333
|
+
const newHolder = cur && cur.pid !== lock.pid && !singleton.isStale(cur) ? cur : null;
|
|
334
|
+
if (!singleton.pidAlive(lock.pid)) {
|
|
335
|
+
// "Đã tắt" chỉ tin khi TIẾN TRÌNH chết thật — khoá nhả sớm hơn thế (tắt
|
|
336
|
+
// dở, hoặc bộ tự cập nhật nhả khoá rồi đẻ bản thay). Chết rồi thì soi
|
|
337
|
+
// thêm 2 nhịp: bản thay của updater giành khoá ngay sau đó, và báo
|
|
338
|
+
// "đã dừng" lúc ấy là để người phát hành cài đè lên cây một daemon MỚI
|
|
339
|
+
// đang nạp (đúng loại sự cố MODULE_NOT_FOUND 08/09).
|
|
340
|
+
if (newHolder) {
|
|
341
|
+
console.error(`[channel-worker] Daemon PID ${lock.pid} đã tắt, nhưng daemon MỚI PID ${newHolder.pid} đang giữ khoá `
|
|
342
|
+
+ `(${newHolder.supervisor === 'pm2' ? 'pm2 dựng lại' : 'watchdog hoặc bộ tự cập nhật vừa bật lại'}) — chưa phải trạng thái "đã dừng".`);
|
|
343
|
+
process.exit(3);
|
|
344
|
+
}
|
|
345
|
+
if (!deadAt) deadAt = Date.now();
|
|
346
|
+
if (Date.now() - deadAt >= 4000) {
|
|
347
|
+
console.log(`[channel-worker] Xả xong — daemon PID ${lock.pid} đã tự tắt, không cắt việc nào.`);
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (Date.now() > hardStop) {
|
|
353
|
+
console.error(`[channel-worker] Quá hạn mà daemon PID ${lock.pid} vẫn sống `
|
|
354
|
+
+ `(${cur && cur.pid === lock.pid ? 'vẫn giữ khoá — bản daemon cũ chưa biết lệnh drain?' : 'đã nhả khoá nhưng chưa thoát'}). `
|
|
355
|
+
+ 'Kiểm "channel-worker logs".');
|
|
356
|
+
process.exit(2);
|
|
357
|
+
}
|
|
358
|
+
// Khoá đã nhả / sang tay khác mà tiến trình cũ còn sống = đang tắt dở →
|
|
359
|
+
// chờ nó thoát hẳn.
|
|
360
|
+
if (!cur || cur.pid !== lock.pid) continue;
|
|
361
|
+
const status = drain.readStatus(CONFIG_DIR);
|
|
362
|
+
if (!drain.readRequest(CONFIG_DIR)) {
|
|
363
|
+
console.error('[channel-worker] Daemon đã THÔI XẢ và quay lại làm việc (hết hạn hoặc bị huỷ). '
|
|
364
|
+
+ `Việc còn dở lúc đó: ${drain.describeBusy(status && status.busy)}. `
|
|
365
|
+
+ 'Chờ thêm: chạy lại "channel-worker drain". Chấp nhận cắt: "channel-worker stop".');
|
|
366
|
+
process.exit(2);
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - lastSay >= 30000) {
|
|
369
|
+
lastSay = Date.now();
|
|
370
|
+
console.log(`[channel-worker] … đang chờ: ${status ? drain.describeBusy(status.busy) : 'daemon chưa nhận yêu cầu (nhịp 5s)'}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
})();
|
|
374
|
+
|
|
288
375
|
} else if (cmd === 'logs') {
|
|
289
376
|
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
290
377
|
if (!fs.existsSync(LOG_FILE)) {
|
|
@@ -337,7 +424,9 @@ Commands:
|
|
|
337
424
|
pair Pair with dashboard using a one-time code (recommended)
|
|
338
425
|
init Configure worker manually
|
|
339
426
|
start Start the daemon in background
|
|
340
|
-
stop Stop the daemon
|
|
427
|
+
stop Stop the daemon (cắt ngang việc đang chạy)
|
|
428
|
+
drain Xả: ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt
|
|
429
|
+
(--timeout <phút>, mặc định 25 · --no-wait · --cancel)
|
|
341
430
|
restart Restart the daemon
|
|
342
431
|
logs Show recent daemon logs (--lines <n>, default 50)
|
|
343
432
|
update Check and install updates manually
|
package/lib/command-poller.js
CHANGED
|
@@ -21,6 +21,17 @@ class CommandPoller {
|
|
|
21
21
|
this.timer = null;
|
|
22
22
|
this.pollIntervalMs = 3000;
|
|
23
23
|
this.nst = null;
|
|
24
|
+
// XẢ (lib/drain.js): tạm ngừng NHẬN việc mới, việc đang chạy vẫn chạy tiếp.
|
|
25
|
+
this._paused = false;
|
|
26
|
+
// Mọi lệnh đang xử lý, MỌI loại (không riêng _pw): id → type. poll() chạy
|
|
27
|
+
// theo setInterval, không chờ lệnh trước xong, nên một phiên nuôi 15 phút
|
|
28
|
+
// chạy song song với vài lượt lấy số liệu — daemon cần sổ này để biết lúc
|
|
29
|
+
// nào hết việc thật.
|
|
30
|
+
this._handling = new Map();
|
|
31
|
+
// Lượt poll đang bay (đã gửi GET /workers/commands, chưa về). API lật lệnh
|
|
32
|
+
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
33
|
+
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
34
|
+
this._pollsInFlight = 0;
|
|
24
35
|
|
|
25
36
|
// Init Nstbrowser if API key available
|
|
26
37
|
if (config.nst_api_key) {
|
|
@@ -69,10 +80,25 @@ class CommandPoller {
|
|
|
69
80
|
if (this._profileTimeoutTimer) { clearInterval(this._profileTimeoutTimer); this._profileTimeoutTimer = null; }
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// Không dừng timer: đồng bộ profile và đóng profile rảnh vẫn phải chạy trong
|
|
84
|
+
// lúc xả, chỉ riêng việc NHẬN lệnh / phát cảnh là ngừng.
|
|
85
|
+
pauseClaims() { this._paused = true; }
|
|
86
|
+
resumeClaims() { this._paused = false; }
|
|
87
|
+
|
|
72
88
|
async poll() {
|
|
89
|
+
if (this._paused) return;
|
|
90
|
+
let command = null;
|
|
91
|
+
this._pollsInFlight++;
|
|
73
92
|
try {
|
|
74
|
-
|
|
93
|
+
try {
|
|
94
|
+
command = await this.api.getNextCommand(this.config.worker_id);
|
|
95
|
+
} finally {
|
|
96
|
+
this._pollsInFlight--;
|
|
97
|
+
}
|
|
75
98
|
if (!command) return;
|
|
99
|
+
// Lượt poll đã gửi đi TRƯỚC lúc xả vẫn có thể nhận về một lệnh — nó được
|
|
100
|
+
// ghi sổ như mọi lệnh khác, và daemon chờ nó xong mới tắt.
|
|
101
|
+
this._handling.set(String(command._id), command.type);
|
|
76
102
|
|
|
77
103
|
console.log(`[commands] Received: ${command.type} (${command._id})`);
|
|
78
104
|
|
|
@@ -146,6 +172,8 @@ class CommandPoller {
|
|
|
146
172
|
if (this.config.verbose) {
|
|
147
173
|
console.error(`[commands] Poll error: ${err.message}`);
|
|
148
174
|
}
|
|
175
|
+
} finally {
|
|
176
|
+
if (command) this._handling.delete(String(command._id));
|
|
149
177
|
}
|
|
150
178
|
}
|
|
151
179
|
|
|
@@ -388,8 +416,29 @@ class CommandPoller {
|
|
|
388
416
|
return;
|
|
389
417
|
}
|
|
390
418
|
|
|
419
|
+
// Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
|
|
420
|
+
// nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
|
|
421
|
+
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
422
|
+
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
423
|
+
let mediaAccounts = null;
|
|
424
|
+
let mediaError = null;
|
|
425
|
+
try {
|
|
426
|
+
const media = await report.fetchSocialMediaAccounts(conn.context, {
|
|
427
|
+
log: (message) => console.log(message),
|
|
428
|
+
});
|
|
429
|
+
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
430
|
+
else mediaError = media.reason || media.status;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
mediaError = String(err.message || err).slice(0, 200);
|
|
433
|
+
}
|
|
434
|
+
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
435
|
+
|
|
391
436
|
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
392
437
|
clicks: clickResult.rows,
|
|
438
|
+
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
439
|
+
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
440
|
+
// chỉ vì một lượt đọc hỏng.
|
|
441
|
+
media_accounts: mediaAccounts,
|
|
393
442
|
last_report_date: result.last_report_date,
|
|
394
443
|
last_update_time: result.last_update_time,
|
|
395
444
|
from: result.from,
|
|
@@ -406,6 +455,8 @@ class CommandPoller {
|
|
|
406
455
|
click_saved: saved?.click_saved || 0,
|
|
407
456
|
click_matched: saved?.click_matched || 0,
|
|
408
457
|
click_unmatched: saved?.click_unmatched || 0,
|
|
458
|
+
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
459
|
+
media_error: mediaError || undefined,
|
|
409
460
|
last_report_date: result.last_report_date,
|
|
410
461
|
},
|
|
411
462
|
});
|
|
@@ -1837,7 +1888,7 @@ class CommandPoller {
|
|
|
1837
1888
|
// Processes ONE command per cycle — sequential, no race conditions.
|
|
1838
1889
|
|
|
1839
1890
|
async _dispatchScenes() {
|
|
1840
|
-
if (this._dispatching) return;
|
|
1891
|
+
if (this._paused || this._dispatching) return;
|
|
1841
1892
|
this._dispatching = true;
|
|
1842
1893
|
try {
|
|
1843
1894
|
// 1. Init Nstbrowser if needed
|
|
@@ -2247,7 +2298,7 @@ class CommandPoller {
|
|
|
2247
2298
|
// generate_thumbnail command is pending, so Veo3 renderers stay untouched.
|
|
2248
2299
|
|
|
2249
2300
|
async _dispatchChatgpt() {
|
|
2250
|
-
if (this._chatgptDispatching) return;
|
|
2301
|
+
if (this._paused || this._chatgptDispatching) return;
|
|
2251
2302
|
this._chatgptDispatching = true;
|
|
2252
2303
|
try {
|
|
2253
2304
|
// Lazy-init NstManager — the daemon config often ships with an empty
|
package/lib/daemon.js
CHANGED
|
@@ -7,9 +7,19 @@ const { checkAndUpdateExtension } = require('./extension-updater');
|
|
|
7
7
|
const { CacheServer } = require('./cache-server');
|
|
8
8
|
const { agentAddressForUser } = require('./nst-agent-locator');
|
|
9
9
|
const singleton = require('./singleton');
|
|
10
|
+
const drain = require('./drain');
|
|
10
11
|
const os = require('os');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
|
|
14
|
+
// Bộ tự cập nhật thấy bản mới mà đang bận → xả tối đa ngần này rồi cài luôn.
|
|
15
|
+
// Quá hạn mà còn việc = việc dài bất thường; cài luôn còn hơn kẹt mãi bản cũ.
|
|
16
|
+
const UPDATE_DRAIN_MS = drain.DEFAULT_TIMEOUT_MIN * 60e3;
|
|
17
|
+
// Lượt cài sau khi xả mà hỏng (registry chưa công bố tarball, cây cài cụt…)
|
|
18
|
+
// thì chờ ngần này mới được xả lại để cập nhật. Không có nó, máy bận cứ quay
|
|
19
|
+
// vòng: xả → cài hỏng → nhận việc 10 giây → xả tiếp — tệ hơn bản trước, vốn
|
|
20
|
+
// cứ làm việc bình thường và thử lại 5 phút một lần.
|
|
21
|
+
const UPDATE_RETRY_MS = 15 * 60e3;
|
|
22
|
+
|
|
13
23
|
class Daemon {
|
|
14
24
|
constructor(config) {
|
|
15
25
|
this.config = config;
|
|
@@ -30,20 +40,45 @@ class Daemon {
|
|
|
30
40
|
// Pass daemon URL down so command-poller can write it into per-profile config
|
|
31
41
|
config.daemon_url = this.cacheServer.url();
|
|
32
42
|
this.commandPoller = new CommandPoller(this.api, config);
|
|
33
|
-
|
|
43
|
+
// Có bản mới mà đang bận thì KHÔNG khởi động lại ngang — xả trước (xem
|
|
44
|
+
// _tickDrain), rảnh rồi mới cài.
|
|
45
|
+
this.updateChecker = new UpdateChecker(5 * 60 * 1000, {
|
|
46
|
+
isBusy: () => this._isBusy(),
|
|
47
|
+
onDeferred: (r) => this._onUpdateDeferred(r),
|
|
48
|
+
});
|
|
49
|
+
this._updateRetryAt = 0;
|
|
34
50
|
this.extCheckTimer = null;
|
|
35
51
|
this.nstAgentTimer = null;
|
|
36
52
|
this.lockTimer = null;
|
|
53
|
+
this.drainTimer = null;
|
|
37
54
|
this.configDir = path.join(os.homedir(), '.channel-worker');
|
|
38
55
|
this._evictLogged = false;
|
|
56
|
+
// Yêu cầu xả ghi trước mốc này là của đời daemon trước — xem drain.isForMe.
|
|
57
|
+
this.startedAt = Date.now();
|
|
58
|
+
this._drain = null; // { reason: 'cli'|'update', since, deadline, latest }
|
|
59
|
+
this._drainFinishing = false;
|
|
39
60
|
}
|
|
40
61
|
|
|
41
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Đang bận = có job đang chạy, có phiên Playwright đang giữ profile, hoặc có
|
|
64
|
+
* lệnh nào đang xử lý dở (mọi loại — sổ _handling của command poller).
|
|
65
|
+
*/
|
|
42
66
|
_isBusy() {
|
|
67
|
+
const b = this._busyDetail();
|
|
68
|
+
return b.jobs > 0 || b.pw > 0 || b.commands.length > 0 || b.polls > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_busyDetail() {
|
|
43
72
|
const jobs = (this.poller && this.poller.running) ? this.poller.running.size : 0;
|
|
44
73
|
const pw = (this.commandPoller && this.commandPoller._pwInFlight)
|
|
45
74
|
? this.commandPoller._pwInFlight.size : 0;
|
|
46
|
-
|
|
75
|
+
const commands = (this.commandPoller && this.commandPoller._handling)
|
|
76
|
+
? [...this.commandPoller._handling.values()] : [];
|
|
77
|
+
// Lượt hỏi việc đang bay: API nhận lệnh/job ngay lúc trả về, tắt lúc này là
|
|
78
|
+
// giết một việc vừa bị nhận (xem CommandPoller._pollsInFlight).
|
|
79
|
+
const polls = ((this.commandPoller && this.commandPoller._pollsInFlight) || 0)
|
|
80
|
+
+ ((this.poller && this.poller.pollsInFlight) || 0);
|
|
81
|
+
return { jobs, pw, commands, polls };
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
/**
|
|
@@ -76,6 +111,158 @@ class Daemon {
|
|
|
76
111
|
else process.exit(0);
|
|
77
112
|
}
|
|
78
113
|
|
|
114
|
+
// ─── XẢ: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt ──────────
|
|
115
|
+
// Hai đường vào:
|
|
116
|
+
// 'cli' — người phát hành chạy `channel-worker drain` (ghi drain.request).
|
|
117
|
+
// Xả xong thì TẮT. Hết hạn mà còn việc thì QUAY LẠI làm việc —
|
|
118
|
+
// người quyết định dừng cứng hay chờ thêm, đàn không bao giờ đứng
|
|
119
|
+
// im chỉ vì một lệnh xả bị bỏ quên.
|
|
120
|
+
// 'update' — bộ tự cập nhật thấy bản mới mà đang bận. Xả xong thì cài +
|
|
121
|
+
// khởi động lại. Hết hạn thì cài luôn (cắt việc dài bất thường).
|
|
122
|
+
// Trong lúc xả: heartbeat, nhịp khoá, đồng bộ/đóng profile rảnh vẫn chạy;
|
|
123
|
+
// chỉ ngừng nhận lệnh, nhận job, phát cảnh, và tắt bộ tự cập nhật (một lượt
|
|
124
|
+
// cài + khởi động lại lọt vào giữa là cắt đúng việc đang chờ).
|
|
125
|
+
_enterDrain({ reason, deadline, latest = null }) {
|
|
126
|
+
if (this._drain) return;
|
|
127
|
+
this._drain = { reason, since: Date.now(), deadline, latest };
|
|
128
|
+
if (this.commandPoller.pauseClaims) this.commandPoller.pauseClaims();
|
|
129
|
+
if (this.poller.pause) this.poller.pause();
|
|
130
|
+
this.updateChecker.stop();
|
|
131
|
+
const why = reason === 'update' ? `để cập nhật lên ${latest}` : 'theo lệnh "channel-worker drain"';
|
|
132
|
+
console.log(`[daemon] XẢ ${why}: ngừng nhận việc mới, chờ ${drain.describeBusy(this._busyDetail())} `
|
|
133
|
+
+ `— hạn ${new Date(deadline).toISOString()}`);
|
|
134
|
+
this._writeDrainStatus();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_resumeFromDrain(why) {
|
|
138
|
+
console.warn(`[daemon] Thôi xả (${why}) — nhận việc lại bình thường.`);
|
|
139
|
+
const wasCli = !!this._drain && this._drain.reason === 'cli';
|
|
140
|
+
this._drain = null;
|
|
141
|
+
// Chỉ dọn yêu cầu khi đang xả VÌ nó (hết hạn / huỷ) — đó là tín hiệu cho lệnh
|
|
142
|
+
// drain đang chờ. Thôi xả để cập nhật mà xoá luôn là nuốt mất lệnh drain
|
|
143
|
+
// người phát hành vừa gửi giữa lúc npm đang cài; để nguyên thì nhịp sau
|
|
144
|
+
// nhận nó và xả để TẮT.
|
|
145
|
+
if (wasCli) drain.clearRequest(this.configDir);
|
|
146
|
+
drain.clearStatus(this.configDir);
|
|
147
|
+
if (this.commandPoller.resumeClaims) this.commandPoller.resumeClaims();
|
|
148
|
+
if (this.poller.resume) this.poller.resume();
|
|
149
|
+
// Không bật lại lượt kiểm 10 giây đầu: vừa thôi xả vì cài hỏng mà kiểm ngay
|
|
150
|
+
// là quay lại đúng vòng xả-cài-hỏng.
|
|
151
|
+
this.updateChecker.start({ initialDelay: false });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_onUpdateDeferred(r, now = Date.now()) {
|
|
155
|
+
if (now < this._updateRetryAt) return; // vừa cài hỏng — đợi, đừng xả lại liên tục
|
|
156
|
+
this._enterDrain({ reason: 'update', deadline: now + UPDATE_DRAIN_MS, latest: r.latest });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_writeDrainStatus() {
|
|
160
|
+
if (!this._drain) return;
|
|
161
|
+
drain.writeStatus(this.configDir, {
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
reason: this._drain.reason,
|
|
164
|
+
since: new Date(this._drain.since).toISOString(),
|
|
165
|
+
deadline: new Date(this._drain.deadline).toISOString(),
|
|
166
|
+
busy: this._busyDetail(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async _tickDrain(now = Date.now()) {
|
|
171
|
+
if (this._drainFinishing) return;
|
|
172
|
+
// Lệnh drain là cho CHỦ KHOÁ. Daemon thừa (mất khoá) đã tự tắt lúc rảnh ở
|
|
173
|
+
// _tickLock; để nó nghe lệnh là nó xả xong trước rồi xoá mất yêu cầu, chủ
|
|
174
|
+
// khoá đang xả dở thấy "bị huỷ" và quay lại nhận việc.
|
|
175
|
+
if (!singleton.owns(this.configDir)) return;
|
|
176
|
+
const req = drain.readRequest(this.configDir);
|
|
177
|
+
if (req && !drain.isForMe(req, this.startedAt)) {
|
|
178
|
+
console.log(`[daemon] Bỏ yêu cầu xả cũ (${req.requested_at}) — của daemon đời trước.`);
|
|
179
|
+
drain.clearRequest(this.configDir);
|
|
180
|
+
} else if (req && (!this._drain || this._drain.reason !== 'cli')) {
|
|
181
|
+
// Người phát hành thắng bộ tự cập nhật: họ muốn daemon TẮT, không muốn
|
|
182
|
+
// nó tự cài rồi bật lại ngay dưới tay họ.
|
|
183
|
+
if (this._drain) {
|
|
184
|
+
this._drain.reason = 'cli';
|
|
185
|
+
this._drain.deadline = Date.parse(req.deadline_at);
|
|
186
|
+
console.log('[daemon] Lệnh drain đến giữa lúc xả để cập nhật — chuyển sang xả để TẮT.');
|
|
187
|
+
} else {
|
|
188
|
+
this._enterDrain({ reason: 'cli', deadline: Date.parse(req.deadline_at) });
|
|
189
|
+
}
|
|
190
|
+
} else if (!req && this._drain && this._drain.reason === 'cli') {
|
|
191
|
+
this._resumeFromDrain('yêu cầu xả đã bị huỷ');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!this._drain) return;
|
|
195
|
+
|
|
196
|
+
this._writeDrainStatus();
|
|
197
|
+
if (!this._isBusy()) {
|
|
198
|
+
await this._finishDrain();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (now > this._drain.deadline) {
|
|
202
|
+
const left = drain.describeBusy(this._busyDetail());
|
|
203
|
+
if (this._drain.reason === 'update') {
|
|
204
|
+
console.warn(`[daemon] Hết hạn xả mà vẫn còn ${left} — cài bản mới luôn, việc đó sẽ bị cắt.`);
|
|
205
|
+
await this._finishDrain();
|
|
206
|
+
} else {
|
|
207
|
+
this._resumeFromDrain(`hết hạn xả, vẫn còn ${left}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _finishDrain() {
|
|
213
|
+
this._drainFinishing = true;
|
|
214
|
+
try {
|
|
215
|
+
if (this._drain.reason === 'update') {
|
|
216
|
+
console.log('[daemon] Xả xong — cài bản mới rồi khởi động lại.');
|
|
217
|
+
// Cài xong thì check() tự process.exit. Tới được dòng sau = KHÔNG khởi
|
|
218
|
+
// động lại (registry chưa công bố tarball, cây cài hỏng…) → làm tiếp.
|
|
219
|
+
const r = await this.updateChecker.check({ ignoreBusy: true });
|
|
220
|
+
this._updateRetryAt = Date.now() + UPDATE_RETRY_MS;
|
|
221
|
+
this._resumeFromDrain(`chưa cập nhật được${r && r.error ? `: ${r.error}` : ''}, `
|
|
222
|
+
+ `${UPDATE_RETRY_MS / 60e3} phút nữa mới xả lại để cập nhật`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
console.log('[daemon] Xả xong — không còn việc dở, tự tắt.');
|
|
226
|
+
// shutdown nhả khoá rồi mới dọn drain.request (xem ghi chú ở đó).
|
|
227
|
+
if (this._shutdown) await this._shutdown();
|
|
228
|
+
else process.exit(0);
|
|
229
|
+
} finally {
|
|
230
|
+
this._drainFinishing = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Dừng mọi nhịp, nhả khoá, dọn file xả, báo offline — mọi thứ trừ process.exit. */
|
|
235
|
+
async _teardown() {
|
|
236
|
+
this.heartbeat.stop();
|
|
237
|
+
this.poller.stop();
|
|
238
|
+
this.commandPoller.stop();
|
|
239
|
+
this.updateChecker.stop();
|
|
240
|
+
if (this.extCheckTimer) clearInterval(this.extCheckTimer);
|
|
241
|
+
if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
|
|
242
|
+
if (this.lockTimer) clearInterval(this.lockTimer);
|
|
243
|
+
if (this.drainTimer) clearInterval(this.drainTimer);
|
|
244
|
+
try { this.cacheServer.stop(); } catch {}
|
|
245
|
+
// Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
|
|
246
|
+
let wasOwner = false;
|
|
247
|
+
try { wasOwner = singleton.release(this.configDir); } catch {}
|
|
248
|
+
// Dọn file xả SAU khi nhả khoá: lệnh drain đang chờ thấy khoá nhả trước
|
|
249
|
+
// → "xả xong"; ngược thứ tự nó thấy yêu cầu biến mất khi khoá còn đó và
|
|
250
|
+
// báo nhầm "daemon thôi xả". Chỉ dọn khi CHÍNH mình là chủ khoá đang xả —
|
|
251
|
+
// daemon thừa tự tắt (kể cả con từng xả dở trước khi bị `start --force`
|
|
252
|
+
// cướp khoá) mà xoá thì yêu cầu gửi cho chủ khoá MỚI biến mất giữa chừng.
|
|
253
|
+
if (wasOwner && this._drain) {
|
|
254
|
+
drain.clearRequest(this.configDir);
|
|
255
|
+
drain.clearStatus(this.configDir);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Mark offline
|
|
259
|
+
try {
|
|
260
|
+
await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
|
|
261
|
+
status: 'offline',
|
|
262
|
+
});
|
|
263
|
+
} catch { /* ignore */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
79
266
|
/**
|
|
80
267
|
* Point NST_API_ADDRESS at the agent of the Windows user we're paired with.
|
|
81
268
|
*
|
|
@@ -195,6 +382,14 @@ class Daemon {
|
|
|
195
382
|
this.lockTimer = setInterval(() => { this._tickLock().catch(() => {}); }, singleton.REFRESH_MS);
|
|
196
383
|
console.log(`[daemon] Singleton lock watcher started (every ${singleton.REFRESH_MS / 1000}s)`);
|
|
197
384
|
|
|
385
|
+
// Nhịp xả — soi drain.request (lệnh "channel-worker drain") và theo dõi
|
|
386
|
+
// việc dở trong lúc xả. Trạng thái xả của đời trước là rác, dọn luôn.
|
|
387
|
+
drain.clearStatus(this.configDir);
|
|
388
|
+
this.drainTimer = setInterval(() => {
|
|
389
|
+
this._tickDrain().catch((e) => console.warn(`[daemon] nhịp xả lỗi: ${e.message}`));
|
|
390
|
+
}, drain.TICK_MS);
|
|
391
|
+
console.log(`[daemon] Drain watcher started (every ${drain.TICK_MS / 1000}s)`);
|
|
392
|
+
|
|
198
393
|
// Start auto-update checker
|
|
199
394
|
this.updateChecker.start();
|
|
200
395
|
console.log('[daemon] Auto-update checker started (every 5min)');
|
|
@@ -219,24 +414,7 @@ class Daemon {
|
|
|
219
414
|
// Graceful shutdown
|
|
220
415
|
const shutdown = async () => {
|
|
221
416
|
console.log('\n[daemon] Shutting down...');
|
|
222
|
-
this.
|
|
223
|
-
this.poller.stop();
|
|
224
|
-
this.commandPoller.stop();
|
|
225
|
-
this.updateChecker.stop();
|
|
226
|
-
if (this.extCheckTimer) clearInterval(this.extCheckTimer);
|
|
227
|
-
if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
|
|
228
|
-
if (this.lockTimer) clearInterval(this.lockTimer);
|
|
229
|
-
try { this.cacheServer.stop(); } catch {}
|
|
230
|
-
// Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
|
|
231
|
-
try { singleton.release(this.configDir); } catch {}
|
|
232
|
-
|
|
233
|
-
// Mark offline
|
|
234
|
-
try {
|
|
235
|
-
await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
|
|
236
|
-
status: 'offline',
|
|
237
|
-
});
|
|
238
|
-
} catch { /* ignore */ }
|
|
239
|
-
|
|
417
|
+
await this._teardown();
|
|
240
418
|
console.log('[daemon] Goodbye.');
|
|
241
419
|
process.exit(0);
|
|
242
420
|
};
|
package/lib/drain.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XẢ daemon: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt.
|
|
3
|
+
*
|
|
4
|
+
* Vì sao có (soát 23/09/2026): phát hành worker tắt daemon bằng
|
|
5
|
+
* `taskkill /f /im node.exe`, còn bộ tự cập nhật thấy bản mới là cài + khởi
|
|
6
|
+
* động lại ngay — cả hai đều không nhìn việc đang chạy. 9/9 phiên nuôi FB
|
|
7
|
+
* "Timed out … crash giữa chừng" còn log đều là daemon bị thay bằng bản mới
|
|
8
|
+
* GIỮA phiên (31 bản trong 10 ngày), và cùng cơ chế đã cắt ngang lượt đăng bài
|
|
9
|
+
* (kenh-71 lúc 22:29 ngày 23/09, đang ở khung soạn Reel).
|
|
10
|
+
*
|
|
11
|
+
* Giao tiếp qua FILE trong CONFIG_DIR, không qua tín hiệu: trên Windows
|
|
12
|
+
* `process.kill(pid, 'SIGTERM')` là TerminateProcess — không có cách nào nhờ
|
|
13
|
+
* tiến trình khác "tắt nhẹ nhàng". Người phát hành chạy `channel-worker drain`,
|
|
14
|
+
* lệnh đó ghi `drain.request`; daemon soi file mỗi TICK_MS.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const REQUEST_FILE = 'drain.request';
|
|
21
|
+
const STATUS_FILE = 'drain.status';
|
|
22
|
+
// Phiên nuôi dài nhất đo được 18 phút, lượt đăng FB có thể chờ nút Đăng 10 phút
|
|
23
|
+
// cộng các bước trước — 25 phút phủ cả hai mà không treo lượt phát hành cả buổi.
|
|
24
|
+
const DEFAULT_TIMEOUT_MIN = 25;
|
|
25
|
+
const TICK_MS = 5000;
|
|
26
|
+
|
|
27
|
+
const requestPath = (dir) => path.join(dir, REQUEST_FILE);
|
|
28
|
+
const statusPath = (dir) => path.join(dir, STATUS_FILE);
|
|
29
|
+
|
|
30
|
+
function writeRequest(dir, { timeoutMin = DEFAULT_TIMEOUT_MIN, now = Date.now() } = {}) {
|
|
31
|
+
const req = {
|
|
32
|
+
requested_at: new Date(now).toISOString(),
|
|
33
|
+
deadline_at: new Date(now + timeoutMin * 60e3).toISOString(),
|
|
34
|
+
by_pid: process.pid,
|
|
35
|
+
};
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(requestPath(dir), JSON.stringify(req));
|
|
38
|
+
return req;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readRequest(dir) {
|
|
42
|
+
try {
|
|
43
|
+
const req = JSON.parse(fs.readFileSync(requestPath(dir), 'utf-8'));
|
|
44
|
+
if (!req || !Number.isFinite(Date.parse(req.requested_at)) || !Number.isFinite(Date.parse(req.deadline_at))) return null;
|
|
45
|
+
return req;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Yêu cầu có phải cho daemon này không. Yêu cầu ghi TRƯỚC lúc daemon khởi
|
|
53
|
+
* động là rác của đời trước (daemon cũ chết trước khi kịp dọn) — nghe theo nó
|
|
54
|
+
* thì daemon mới vừa lên đã tự tắt, và watchdog bật lại vô tận.
|
|
55
|
+
*/
|
|
56
|
+
function isForMe(req, startedAt) {
|
|
57
|
+
return !!req && Date.parse(req.requested_at) >= startedAt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clearRequest(dir) {
|
|
61
|
+
try { fs.unlinkSync(requestPath(dir)); } catch { /* đã không có */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeStatus(dir, status) {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(statusPath(dir), JSON.stringify({ ...status, at: new Date().toISOString() }));
|
|
67
|
+
} catch { /* trạng thái chỉ để người xem, hỏng thì thôi */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readStatus(dir) {
|
|
71
|
+
try { return JSON.parse(fs.readFileSync(statusPath(dir), 'utf-8')); } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearStatus(dir) {
|
|
75
|
+
try { fs.unlinkSync(statusPath(dir)); } catch { /* đã không có */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** "2 lệnh (nurture_facebook_pw, upload_facebook_pw) · 1 job" — cho log và lệnh drain. */
|
|
79
|
+
function describeBusy(busy) {
|
|
80
|
+
if (!busy) return 'không rõ';
|
|
81
|
+
const parts = [];
|
|
82
|
+
const cmds = busy.commands || [];
|
|
83
|
+
if (cmds.length) parts.push(`${cmds.length} lệnh (${cmds.join(', ')})`);
|
|
84
|
+
if (busy.jobs) parts.push(`${busy.jobs} job`);
|
|
85
|
+
if (busy.polls) parts.push(`${busy.polls} lượt hỏi việc đang bay`);
|
|
86
|
+
return parts.length ? parts.join(' · ') : 'không còn việc';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
REQUEST_FILE, STATUS_FILE, DEFAULT_TIMEOUT_MIN, TICK_MS,
|
|
91
|
+
requestPath, statusPath, writeRequest, readRequest, isForMe, clearRequest,
|
|
92
|
+
writeStatus, readStatus, clearStatus, describeBusy,
|
|
93
|
+
};
|
package/lib/job-poller.js
CHANGED
|
@@ -8,8 +8,14 @@ class JobPoller {
|
|
|
8
8
|
this.running = new Set(); // job IDs currently executing
|
|
9
9
|
this.timer = null;
|
|
10
10
|
this.pollIntervalMs = 5000;
|
|
11
|
+
this.paused = false; // XẢ: ngừng nhận job mới, job đang chạy vẫn chạy tiếp
|
|
12
|
+
// Lượt hỏi/nhận job đang bay — xem CommandPoller._pollsInFlight.
|
|
13
|
+
this.pollsInFlight = 0;
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
pause() { this.paused = true; }
|
|
17
|
+
resume() { this.paused = false; }
|
|
18
|
+
|
|
13
19
|
start() {
|
|
14
20
|
console.log(`[poller] Started — max concurrent: ${this.config.max_concurrent}`);
|
|
15
21
|
this.poll(); // immediate first poll
|
|
@@ -28,8 +34,11 @@ class JobPoller {
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
async poll() {
|
|
31
|
-
if (this.availableSlots <= 0) return;
|
|
37
|
+
if (this.paused || this.availableSlots <= 0) return;
|
|
32
38
|
|
|
39
|
+
this.pollsInFlight++;
|
|
40
|
+
let inFlight = true;
|
|
41
|
+
const settle = () => { if (inFlight) { inFlight = false; this.pollsInFlight--; } };
|
|
33
42
|
try {
|
|
34
43
|
const job = await this.api.getNextJob(this.config.worker_id);
|
|
35
44
|
if (!job) return; // no jobs in queue
|
|
@@ -39,7 +48,7 @@ class JobPoller {
|
|
|
39
48
|
|
|
40
49
|
// Claim the job
|
|
41
50
|
await this.api.claimJob(jobId, this.config.worker_id);
|
|
42
|
-
this.running.add(jobId);
|
|
51
|
+
this.running.add(jobId); // từ đây `running` giữ nó trong sổ bận (finally trả lượt bay)
|
|
43
52
|
|
|
44
53
|
console.log(`[poller] Claimed job ${jobId} (${this.running.size}/${this.config.max_concurrent} slots used)`);
|
|
45
54
|
|
|
@@ -56,6 +65,8 @@ class JobPoller {
|
|
|
56
65
|
if (this.config.verbose) {
|
|
57
66
|
console.error(`[poller] Poll error: ${err.message}`);
|
|
58
67
|
}
|
|
68
|
+
} finally {
|
|
69
|
+
settle();
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
}
|
package/lib/shopee-report.js
CHANGED
|
@@ -183,4 +183,78 @@ async function fetchClickReport(ctx, {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
// Danh sách tài khoản mạng xã hội ĐÃ LIÊN KẾT với tài khoản affiliate — cùng
|
|
187
|
+
// thứ trang `account_setting` hiện ở tab "Media Information".
|
|
188
|
+
//
|
|
189
|
+
// KHÔNG mở account_setting và KHÔNG bấm tab: đo thật 22/09/2026 trên phiên
|
|
190
|
+
// Shopee1, endpoint này trả đủ dữ liệu khi gọi từ MỘT tab affiliate bất kỳ
|
|
191
|
+
// (thử từ /dashboard). Đi đường DOM thì phải mở thêm trang, bấm đúng nhãn
|
|
192
|
+
// tiếng Anh "Media Information", và nhãn đó đổi theo ngôn ngữ giao diện.
|
|
193
|
+
//
|
|
194
|
+
// Mỗi dòng có `user_id` là ID Facebook THẬT của page (vd 1298751909986110) —
|
|
195
|
+
// đúng con số nằm trong link `facebook.com/profile.php?id=…`. Nhờ vậy việc so
|
|
196
|
+
// khớp với kênh của mình không phải parse URL, vốn có nhiều biến thể
|
|
197
|
+
// (?referral_source=…, /<handle>/, m.facebook.com…).
|
|
198
|
+
//
|
|
199
|
+
// `platform`: 6 = Facebook, 3 = TikTok (đo được trên tài khoản thật; các mã
|
|
200
|
+
// khác giữ nguyên số để sau này đọc lại, không đoán tên).
|
|
201
|
+
const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
|
|
202
|
+
|
|
203
|
+
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
|
|
204
|
+
let page = findAffiliatePage(ctx.pages());
|
|
205
|
+
let opened = false;
|
|
206
|
+
if (!page) {
|
|
207
|
+
page = await ctx.newPage();
|
|
208
|
+
opened = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
if (!page.url().includes('affiliate.shopee.vn/')) {
|
|
213
|
+
await page.goto('https://affiliate.shopee.vn/offer/product_offer', {
|
|
214
|
+
waitUntil: 'domcontentloaded', timeout: timeoutMs,
|
|
215
|
+
}).catch(() => {});
|
|
216
|
+
await sleep(1500);
|
|
217
|
+
}
|
|
218
|
+
if (isBlockedState(page.url(), null)) {
|
|
219
|
+
return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = await page.evaluate(async () => {
|
|
223
|
+
try {
|
|
224
|
+
const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
|
|
225
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { status: 0, error: String(e?.message || e) };
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
|
|
232
|
+
return { status: 'needs_verify', accounts: [], reason: `blocked while reading media info (http ${result.status}, code ${result.json?.code})` };
|
|
233
|
+
}
|
|
234
|
+
if (result.status !== 200 || result.json?.code !== 0) {
|
|
235
|
+
return { status: 'failed', accounts: [], reason: result.json?.msg || result.error || `media info HTTP ${result.status}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const list = Array.isArray(result.json?.data?.accounts) ? result.json.data.accounts : [];
|
|
239
|
+
const accounts = list.map((row) => ({
|
|
240
|
+
media_user_id: String(row.user_id || row.unique_id || '').trim(),
|
|
241
|
+
media_name: String(row.user_name || '').trim(),
|
|
242
|
+
website_url: String(row.website_url || '').trim(),
|
|
243
|
+
platform_code: Number(row.platform) || 0,
|
|
244
|
+
platform: SM_PLATFORM[Number(row.platform)] || 'other',
|
|
245
|
+
link_status: Number(row.link_status) || 0,
|
|
246
|
+
facebook_page_type: Number(row.facebook_page_type) || 0,
|
|
247
|
+
partner_link_status: Number(row.partner_link_status) || 0,
|
|
248
|
+
follower_number: Number(row.follower_number) || 0,
|
|
249
|
+
// Shopee trả giây, không phải mili-giây.
|
|
250
|
+
verification_time: Number(row.verification_time) || 0,
|
|
251
|
+
})).filter((a) => a.media_user_id || a.website_url);
|
|
252
|
+
|
|
253
|
+
if (log) log(`[shopee-report] media: ${accounts.length} tài khoản liên kết (${accounts.filter((a) => a.platform === 'facebook').length} Facebook)`);
|
|
254
|
+
return { status: 'ok', accounts };
|
|
255
|
+
} finally {
|
|
256
|
+
if (opened) await page.close().catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
|
package/lib/singleton.js
CHANGED
|
@@ -70,6 +70,10 @@ function writeLock(configDir, info, { overwrite = false } = {}) {
|
|
|
70
70
|
version: info.version || '',
|
|
71
71
|
started_at: info.started_at || new Date().toISOString(),
|
|
72
72
|
refreshed_at: new Date().toISOString(),
|
|
73
|
+
// Ai dựng lại daemon khi nó tắt: 'pm2' thì `channel-worker drain` biết
|
|
74
|
+
// trước là xả xong pm2 sẽ bật lại ngay (relabs03). Làm tươi khoá chép
|
|
75
|
+
// nguyên giá trị cũ; lần ghi đầu đọc từ môi trường của chính daemon.
|
|
76
|
+
supervisor: info.supervisor !== undefined ? info.supervisor : (process.env.pm_id !== undefined ? 'pm2' : ''),
|
|
73
77
|
});
|
|
74
78
|
// 'wx' = tạo mới, thất bại nếu đã có → chính là phép thử nguyên tử.
|
|
75
79
|
const fd = fs.openSync(lockPath(configDir), overwrite ? 'w' : 'wx');
|
package/lib/updater.js
CHANGED
|
@@ -38,18 +38,26 @@ function restartBlocker() {
|
|
|
38
38
|
// Quyết định THUẦN của một lượt kiểm (test được, không đụng đĩa/mạng):
|
|
39
39
|
// 'none' — đang chạy bản mới nhất
|
|
40
40
|
// 'blocked' — có bản mới nhưng không khởi động lại được → KHÔNG cài
|
|
41
|
+
// 'deferred-busy'— có bản mới nhưng daemon đang có việc dở → CHƯA cài, CHƯA
|
|
42
|
+
// khởi động lại; daemon chuyển sang XẢ rồi quay lại (drain.js)
|
|
41
43
|
// 'restart-only' — bản mới đã có sẵn trên đĩa → chỉ kiểm + khởi động lại
|
|
42
44
|
// 'install' — cài rồi kiểm rồi khởi động lại
|
|
43
45
|
//
|
|
46
|
+
// Vì sao có 'deferred-busy' (soát 23/09/2026): khởi động lại = process.exit, và
|
|
47
|
+
// mọi việc đang chạy chết theo — 9/9 phiên nuôi FB "Timed out … crash giữa
|
|
48
|
+
// chừng" còn log là daemon bị thay bằng bản mới giữa phiên. Cài khi bận cũng
|
|
49
|
+
// không được: npm xoá cây đang chạy trước khi ghi cây mới.
|
|
50
|
+
//
|
|
44
51
|
// Vì sao phải có 'blocked' và 'restart-only' (sự cố 17-19/09/2026, win-worker):
|
|
45
52
|
// worker-1 được bật tay qua start-worker.bat nên restart luôn chết EBUSY. Bản cũ
|
|
46
53
|
// so bản ĐANG CHẠY (2.5.117) với registry (2.5.120) nên lượt nào cũng
|
|
47
54
|
// `npm i -g` lại từ đầu rồi mới vấp EBUSY: 413 lần cài đè trong ~34 giờ, lên
|
|
48
55
|
// đúng thư mục cài mà worker-2 đang chạy. Cài đè cây đang chạy chính là thứ đã
|
|
49
56
|
// giết worker Windows nhiều lần (lib/ bị xoá giữa chừng).
|
|
50
|
-
function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
|
|
57
|
+
function planUpdate({ running, onDisk, latest, restartBlocked = '', busy = false }) {
|
|
51
58
|
if (!latest || !isNewer(latest, running)) return 'none';
|
|
52
59
|
if (restartBlocked) return 'blocked';
|
|
60
|
+
if (busy) return 'deferred-busy';
|
|
53
61
|
if (onDisk && !isNewer(latest, onDisk)) return 'restart-only';
|
|
54
62
|
return 'install';
|
|
55
63
|
}
|
|
@@ -136,7 +144,7 @@ function verifyInstall() {
|
|
|
136
144
|
return { ok: out.status === 0, detail: detail.slice(-600) };
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
147
|
+
async function checkAndUpdate({ autoRestart = false, isBusy = null } = {}) {
|
|
140
148
|
const local = getLocalVersion();
|
|
141
149
|
const latest = await getLatestVersion();
|
|
142
150
|
|
|
@@ -148,7 +156,11 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
148
156
|
// update` gõ tay thì cứ cài như cũ.
|
|
149
157
|
const onDisk = getInstalledVersion();
|
|
150
158
|
const blocker = autoRestart ? restartBlocker() : '';
|
|
151
|
-
const
|
|
159
|
+
const busy = !!(autoRestart && typeof isBusy === 'function' && isBusy());
|
|
160
|
+
const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker, busy });
|
|
161
|
+
if (plan === 'deferred-busy') {
|
|
162
|
+
return { updated: false, local, latest, deferredBusy: true };
|
|
163
|
+
}
|
|
152
164
|
if (plan === 'blocked') {
|
|
153
165
|
if (Date.now() - lastBlockedWarnAt > BLOCKED_WARN_EVERY_MS) {
|
|
154
166
|
lastBlockedWarnAt = Date.now();
|
|
@@ -238,33 +250,52 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
class UpdateChecker {
|
|
241
|
-
|
|
253
|
+
/**
|
|
254
|
+
* @param {object} [opts]
|
|
255
|
+
* @param {() => boolean} [opts.isBusy] daemon còn việc dở không
|
|
256
|
+
* @param {(r: object) => void} [opts.onDeferred] có bản mới mà đang bận —
|
|
257
|
+
* daemon chuyển sang XẢ, rảnh rồi gọi lại check({ ignoreBusy: true })
|
|
258
|
+
* @param {Function} [opts.checkFn] thay checkAndUpdate (test)
|
|
259
|
+
*/
|
|
260
|
+
constructor(intervalMs = 5 * 60 * 1000, { isBusy = null, onDeferred = null, checkFn = checkAndUpdate } = {}) {
|
|
242
261
|
this.intervalMs = intervalMs;
|
|
243
262
|
this.timer = null;
|
|
263
|
+
this.startTimer = null;
|
|
264
|
+
this.isBusy = isBusy;
|
|
265
|
+
this.onDeferred = onDeferred;
|
|
266
|
+
this.checkFn = checkFn;
|
|
244
267
|
}
|
|
245
268
|
|
|
246
|
-
start() {
|
|
247
|
-
// Check on startup after 10s delay
|
|
248
|
-
|
|
269
|
+
start({ initialDelay = true } = {}) {
|
|
270
|
+
// Check on startup after 10s delay (bỏ qua khi daemon vừa thôi xả — xem
|
|
271
|
+
// Daemon._resumeFromDrain)
|
|
272
|
+
if (initialDelay) this.startTimer = setTimeout(() => this.check(), 10000);
|
|
249
273
|
// Then periodically
|
|
250
274
|
this.timer = setInterval(() => this.check(), this.intervalMs);
|
|
251
275
|
}
|
|
252
276
|
|
|
253
277
|
stop() {
|
|
278
|
+
// Dọn cả lượt kiểm 10s đầu: dừng ngay sau start() mà để nó chạy là một lượt
|
|
279
|
+
// cài + khởi động lại lọt ra giữa lúc daemon đang xả.
|
|
280
|
+
if (this.startTimer) { clearTimeout(this.startTimer); this.startTimer = null; }
|
|
254
281
|
if (this.timer) {
|
|
255
282
|
clearInterval(this.timer);
|
|
256
283
|
this.timer = null;
|
|
257
284
|
}
|
|
258
285
|
}
|
|
259
286
|
|
|
260
|
-
|
|
287
|
+
/** @param {{ignoreBusy?: boolean}} [opts] ignoreBusy = daemon đã xả xong (hoặc hết hạn xả) */
|
|
288
|
+
async check({ ignoreBusy = false } = {}) {
|
|
261
289
|
try {
|
|
262
|
-
const result = await
|
|
263
|
-
if (
|
|
264
|
-
|
|
290
|
+
const result = await this.checkFn({ autoRestart: true, isBusy: ignoreBusy ? null : this.isBusy });
|
|
291
|
+
if (result && result.deferredBusy) {
|
|
292
|
+
console.log(`[updater] Có bản ${result.latest} nhưng đang có việc dở — xả trước, rảnh rồi mới cài.`);
|
|
293
|
+
if (this.onDeferred) this.onDeferred(result);
|
|
265
294
|
}
|
|
295
|
+
return result;
|
|
266
296
|
} catch (err) {
|
|
267
297
|
console.warn(`[updater] Check failed: ${err.message}`);
|
|
298
|
+
return { updated: false, error: err.message };
|
|
268
299
|
}
|
|
269
300
|
}
|
|
270
301
|
}
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@ function parseViews(txt) {
|
|
|
38
38
|
// 09/09 tìm được tab để so trước/sau. Bản riêng còn thiếu cả bộ lọc "anchor
|
|
39
39
|
// của bài trong bảng tin" (gốc 2 link page lạ sáng 10/09) nên có thể quét
|
|
40
40
|
// nhầm tab Reels của page KHÁC mà không hay.
|
|
41
|
-
const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef } = require('./upload_facebook').__testables;
|
|
41
|
+
const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef, readPageIdFromDom, readDelegatePageId } = require('./upload_facebook').__testables;
|
|
42
42
|
|
|
43
43
|
// Số người theo dõi của page, đọc ngay trên chính trang đang đứng (header hiện
|
|
44
44
|
// ở mọi tab của page/profile) — không tốn thêm lượt mở profile nào. Ô
|
|
@@ -821,6 +821,29 @@ async function run({ page, payload, log }) {
|
|
|
821
821
|
// được với danh sách page đã liên kết bên Shopee. Không có thì bỏ qua, lượt
|
|
822
822
|
// ghi số liệu không phụ thuộc vào nó.
|
|
823
823
|
const pageRef = opened.verified ? parsePageRef(opened.url) : null;
|
|
824
|
+
// URL chỉ có TÊN RIÊNG thì chưa đủ để đối chiếu với Shopee: 6/8 page đang liên
|
|
825
|
+
// kết bên đó lưu dạng `profile.php?id=…`, không mang tên riêng. Thiếu số ID là
|
|
826
|
+
// kênh bị báo "chưa liên kết" oan. Đọc thêm từ chính trang đang mở (đã chứng
|
|
827
|
+
// minh có tile ⇒ đúng page của kênh). Đọc hỏng thì giữ nguyên tên riêng, không
|
|
828
|
+
// làm hỏng lượt ghi số liệu.
|
|
829
|
+
if (pageRef && !pageRef.id) {
|
|
830
|
+
const hit = await readPageIdFromDom(page).catch(() => null);
|
|
831
|
+
if (hit) {
|
|
832
|
+
pageRef.id = hit.id;
|
|
833
|
+
log('info', `[fb-stats] page id đọc từ trang: ${hit.id} (${hit.sources} nguồn khớp: ${hit.from.join(', ')})`);
|
|
834
|
+
} else {
|
|
835
|
+
log('warn', '[fb-stats] URL chỉ có tên riêng và KHÔNG đọc được id từ trang — kênh sẽ hiện "chưa rõ Page" thay vì báo sai liên kết');
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// MÃ TRANG Shopee dùng để đối chiếu (`delegate_page_id`) — KHÁC profile id.
|
|
839
|
+
// Không có nó thì API không kết luận được kênh đã liên kết hay chưa, và cố
|
|
840
|
+
// tình để ở "chưa rõ" còn hơn báo sai. Xem readDelegatePageId.
|
|
841
|
+
if (pageRef) {
|
|
842
|
+
const dg = await readDelegatePageId(page, pageRef.id).catch(() => ({ id: '', checked: false }));
|
|
843
|
+
pageRef.delegate_id = dg.id;
|
|
844
|
+
pageRef.delegate_checked = dg.checked;
|
|
845
|
+
log('info', `[fb-stats] mã Trang (Shopee dùng): ${dg.id || (dg.checked ? 'không có — page kiểu cổ điển' : 'CHƯA rõ')}`);
|
|
846
|
+
}
|
|
824
847
|
if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
|
|
825
848
|
|
|
826
849
|
const d = await log.apiRequest('POST', '/analytics/post-stats', {
|
|
@@ -1990,6 +1990,115 @@ function parsePageRef(reelsTabUrl) {
|
|
|
1990
1990
|
return { id: '', handle: seg, url: raw };
|
|
1991
1991
|
}
|
|
1992
1992
|
|
|
1993
|
+
// ID page ĐỌC TỪ CHÍNH TRANG, cho ca URL chỉ có tên riêng (`/<handle>/?sk=reels_tab`).
|
|
1994
|
+
//
|
|
1995
|
+
// Vì sao cần: bên Shopee, 6/8 page đang liên kết được lưu dạng
|
|
1996
|
+
// `profile.php?id=…`, KHÔNG có tên riêng trong link. Kênh nào chỉ biết tên riêng
|
|
1997
|
+
// thì không có gì để so → bị báo "chưa liên kết" OAN (đo bằng
|
|
1998
|
+
// services/AffiliateMediaLink 22/09). Dạng tên riêng có thật: ca "Chuyện Nghề
|
|
1999
|
+
// Nhà Nông" 14/09 — dạng profile.php ra 0 tile, chỉ `/chuyennghenhanong/` chạy.
|
|
2000
|
+
//
|
|
2001
|
+
// CHỈ gọi khi đang đứng trên trang tab Reels ĐÃ chứng minh có tile. Đứng ở trang
|
|
2002
|
+
// khác thì mấy khoá dưới đây là của trang đó, không phải của page mình.
|
|
2003
|
+
//
|
|
2004
|
+
// Đo thật 22/09 trên 6 profile của đàn (Toc-xinh, Meo-luoi, Tam-cai-tao-nha,
|
|
2005
|
+
// Beo-banh-beo, duong-nhan-cung-anh, Do-nghe-len-hinh):
|
|
2006
|
+
// `profile_id` == `actorID` == ID page trong URL ở CẢ 6/6.
|
|
2007
|
+
// ⛔ `delegate_page_id` LUÔN là một số KHÁC (vd page 61591917189886 →
|
|
2008
|
+
// delegate 1240255979169594). Bắt nhầm khoá này là ghi sai danh tính rồi
|
|
2009
|
+
// báo sai liên kết cho đúng cái kênh mình định cứu.
|
|
2010
|
+
//
|
|
2011
|
+
// Cổng an toàn: phải có **ít nhất hai nguồn độc lập cùng ra một số** mới nhận.
|
|
2012
|
+
// Một khoá JSON đứng lẻ không đủ — trang Facebook đầy số 15-16 chữ số, và nếu
|
|
2013
|
+
// profile đang đăng nhập bằng tài khoản CÁ NHÂN quản lý page thì `userID` là của
|
|
2014
|
+
// người, không phải của page. Hai nguồn khớp nhau thì khả năng cùng sai rất thấp.
|
|
2015
|
+
const PAGE_ID_JSON_KEYS = ['profile_id', 'actorID', 'pageID'];
|
|
2016
|
+
|
|
2017
|
+
async function readPageIdFromDom(page) {
|
|
2018
|
+
const votes = await page.evaluate((keys) => {
|
|
2019
|
+
const out = {};
|
|
2020
|
+
const add = (src, v) => {
|
|
2021
|
+
if (!/^\d{5,25}$/.test(String(v || ''))) return;
|
|
2022
|
+
(out[src] = out[src] || []).push(String(v));
|
|
2023
|
+
};
|
|
2024
|
+
// Nguồn 1: anchor profile.php NGOÀI bảng tin. Anchor trong feed là của bài
|
|
2025
|
+
// người khác — đúng cái bẫy đã đẻ ra 2 link sai sáng 10/09.
|
|
2026
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
2027
|
+
const href = a.getAttribute('href') || '';
|
|
2028
|
+
if (/[?&]__cft__|[?&]__tn__/.test(href)) continue;
|
|
2029
|
+
if (a.closest("[role='feed'], [role='article'], [aria-posinset]")) continue;
|
|
2030
|
+
const m = href.match(/profile\.php\?id=(\d{5,25})/);
|
|
2031
|
+
if (m) add('anchor', m[1]);
|
|
2032
|
+
}
|
|
2033
|
+
// Nguồn 2..n: khoá JSON trong bundle. `delegate_page_id` cố ý KHÔNG có mặt.
|
|
2034
|
+
const html = document.documentElement.innerHTML;
|
|
2035
|
+
for (const key of keys) {
|
|
2036
|
+
const re = new RegExp(`"${key}"\\s*:\\s*"?(\\d{5,25})"?`, 'g');
|
|
2037
|
+
let m; let n = 0;
|
|
2038
|
+
while ((m = re.exec(html)) && n < 8) { add(key, m[1]); n++; }
|
|
2039
|
+
}
|
|
2040
|
+
return out;
|
|
2041
|
+
}, PAGE_ID_JSON_KEYS).catch(() => ({}));
|
|
2042
|
+
|
|
2043
|
+
// Đếm theo NGUỒN, không theo số lần xuất hiện: một khoá lặp 8 lần trong bundle
|
|
2044
|
+
// vẫn chỉ là MỘT nguồn, không được tự nó đủ phiếu.
|
|
2045
|
+
const bySource = new Map();
|
|
2046
|
+
for (const [src, vals] of Object.entries(votes || {})) {
|
|
2047
|
+
for (const v of new Set(vals)) {
|
|
2048
|
+
if (!bySource.has(v)) bySource.set(v, new Set());
|
|
2049
|
+
bySource.get(v).add(src);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
let best = null;
|
|
2053
|
+
for (const [id, srcs] of bySource) {
|
|
2054
|
+
if (srcs.size < 2) continue;
|
|
2055
|
+
if (!best || srcs.size > best.sources) best = { id, sources: srcs.size, from: [...srcs].sort() };
|
|
2056
|
+
}
|
|
2057
|
+
return best;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// MÃ TRANG mà Shopee dùng để đối chiếu — `delegate_page_id`.
|
|
2061
|
+
//
|
|
2062
|
+
// ⚠️ Page Facebook kiểu mới (New Pages Experience) mang HAI số: `profile id`
|
|
2063
|
+
// nằm trong URL (14 số, mở đầu `61…`) và `delegate_page_id` là ID Trang cổ
|
|
2064
|
+
// điển nằm sau nó (15-16 số, mở đầu `1…`). **Shopee lưu số THỨ HAI.**
|
|
2065
|
+
// Đo 22/09: page `chuyennghenhanong` (profile 61591308775391) hiện trên Shopee
|
|
2066
|
+
// là 1165088293355326 = đúng delegate của nó. So bằng profile id là KHÔNG BAO
|
|
2067
|
+
// GIỜ khớp — lỗi đã lỡ đưa lên prod một lần.
|
|
2068
|
+
//
|
|
2069
|
+
// Khác `profile_id`/`actorID` (là của NGƯỜI ĐANG XEM), `delegate_page_id` nói
|
|
2070
|
+
// về TRANG ĐANG MỞ: đo trên 3 page lạ, nó trả đúng delegate của trang đó.
|
|
2071
|
+
//
|
|
2072
|
+
// Trả { id, checked }:
|
|
2073
|
+
// - đúng MỘT giá trị → id = nó, checked = true
|
|
2074
|
+
// - KHÔNG có giá trị nào → page kiểu CỔ ĐIỂN (profile id chính là khoá
|
|
2075
|
+
// Shopee) → id rỗng, checked = true. Chỉ dám kết luận vậy khi profile id
|
|
2076
|
+
// KHÔNG mang hình dạng page kiểu mới; nếu nó là `61…` mà không đọc được
|
|
2077
|
+
// delegate thì đó là đọc HỤT, không phải page cổ điển.
|
|
2078
|
+
// - NHIỀU giá trị → profile quản nhiều page (đo thật ở
|
|
2079
|
+
// `duong-nhan-cung-anh`: 2 delegate). Không đoán → checked = false, để bên
|
|
2080
|
+
// API giữ trạng thái "chưa rõ" thay vì báo sai.
|
|
2081
|
+
const NEW_STYLE_PROFILE_ID = /^61\d{12}$/;
|
|
2082
|
+
|
|
2083
|
+
async function readDelegatePageId(page, profileId = '') {
|
|
2084
|
+
const found = await page.evaluate(() => {
|
|
2085
|
+
const out = new Set();
|
|
2086
|
+
const re = /"delegate_page_id"\s*:\s*"?(\d{5,25})"?/g;
|
|
2087
|
+
const html = document.documentElement.innerHTML;
|
|
2088
|
+
let m; let n = 0;
|
|
2089
|
+
while ((m = re.exec(html)) && n < 20) { out.add(m[1]); n++; }
|
|
2090
|
+
return [...out];
|
|
2091
|
+
}).catch(() => null);
|
|
2092
|
+
|
|
2093
|
+
if (found === null) return { id: '', checked: false };
|
|
2094
|
+
if (found.length === 1) return { id: found[0], checked: true };
|
|
2095
|
+
if (found.length === 0) {
|
|
2096
|
+
// Không có delegate. Chỉ coi là page cổ điển khi profile id không phải dạng mới.
|
|
2097
|
+
return { id: '', checked: !NEW_STYLE_PROFILE_ID.test(String(profileId || '')) };
|
|
2098
|
+
}
|
|
2099
|
+
return { id: '', checked: false }; // nhiều page trong cùng profile — không đoán
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1993
2102
|
async function hasVisibleReelComposer(page) {
|
|
1994
2103
|
return page.evaluate(() => {
|
|
1995
2104
|
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
@@ -4569,6 +4678,8 @@ module.exports.__testables = {
|
|
|
4569
4678
|
pickReelsTabUrl,
|
|
4570
4679
|
pickReelsTabUrls,
|
|
4571
4680
|
parsePageRef,
|
|
4681
|
+
readPageIdFromDom,
|
|
4682
|
+
readDelegatePageId,
|
|
4572
4683
|
discoverReelsTabUrls,
|
|
4573
4684
|
openReelsTabWithTiles,
|
|
4574
4685
|
networkIdVerdict,
|