channel-worker 2.5.91 → 2.5.93
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 +36 -0
- package/lib/command-poller.js +89 -2
- package/lib/nst-manager.js +32 -0
- package/lib/playwright-runner.js +23 -9
- package/lib/shopee-scraper.js +12 -1
- package/lib/stats-syncer.js +25 -6
- package/lib/updater.js +42 -0
- package/package.json +1 -1
- package/scripts/upload_facebook.js +134 -38
package/bin/cli.js
CHANGED
|
@@ -201,6 +201,42 @@ if (cmd === 'pair') {
|
|
|
201
201
|
process.exit(0);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
} else if (cmd === 'selftest') {
|
|
205
|
+
// Nạp thử MỌI module trong lib/ rồi thoát 0. Bộ tự-cập-nhật chạy lệnh này
|
|
206
|
+
// sau khi cài và chỉ khởi động lại khi nó xanh.
|
|
207
|
+
//
|
|
208
|
+
// Vì sao cần: 08/09/2026, một bản npm publish làm mọi daemon tự lao vào
|
|
209
|
+
// `npm install -g` ĐÈ LÊN CHÍNH MODULE ĐANG CHẠY. Trên Windows npm xoá cây
|
|
210
|
+
// cũ trước khi ghi cây mới, và lượt đó dừng giữa chừng: lib/daemon.js và
|
|
211
|
+
// lib/command-poller.js biến mất, package.json vẫn ghi version mới nên nhìn
|
|
212
|
+
// vào đó thấy "cài xong". Daemon thay thế chết ngay MODULE_NOT_FOUND, hai
|
|
213
|
+
// máy Windows tắt lịm ĐÚNG LÚC đang đăng 9 video — 9 lượt đăng thành "mập
|
|
214
|
+
// mờ" phải kiểm tay. Đọc version là không đủ; phải nạp thử thật.
|
|
215
|
+
const fs = require('fs');
|
|
216
|
+
const libDir = path.join(__dirname, '..', 'lib');
|
|
217
|
+
const bad = [];
|
|
218
|
+
let files = [];
|
|
219
|
+
try {
|
|
220
|
+
files = fs.readdirSync(libDir).filter((f) => f.endsWith('.js')).sort();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
console.error(`[channel-worker] selftest FAILED — không đọc nổi ${libDir}: ${e.code || e.message}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
if (files.length < 10) {
|
|
226
|
+
// Cây đủ có 15 file; ít hơn 10 là chắc chắn cụt, không cần require từng cái.
|
|
227
|
+
console.error(`[channel-worker] selftest FAILED — lib/ chỉ còn ${files.length} file (cây cài dở).`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
for (const f of files) {
|
|
231
|
+
try { require(path.join(libDir, f)); } catch (e) { bad.push(`${f}: ${e.code || e.message}`); }
|
|
232
|
+
}
|
|
233
|
+
if (bad.length) {
|
|
234
|
+
console.error(`[channel-worker] selftest FAILED — ${bad.length}/${files.length} module không nạp được:\n ${bad.join('\n ')}`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
console.log(`[channel-worker] selftest OK — v${require('../package.json').version}, ${files.length} module nạp được.`);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
|
|
204
240
|
} else if (cmd === 'update') {
|
|
205
241
|
const { checkAndUpdate, getLocalVersion } = require('../lib/updater');
|
|
206
242
|
console.log(`[channel-worker] Current version: ${getLocalVersion()}`);
|
package/lib/command-poller.js
CHANGED
|
@@ -323,6 +323,8 @@ class CommandPoller {
|
|
|
323
323
|
platform: scriptName.replace(/^upload_/, ''),
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
+
const keepOpen = await this._keepOpenCfg();
|
|
327
|
+
|
|
326
328
|
try {
|
|
327
329
|
const result = await runPlaywrightScript({
|
|
328
330
|
nst: this.nst,
|
|
@@ -330,6 +332,7 @@ class CommandPoller {
|
|
|
330
332
|
scriptName,
|
|
331
333
|
payload,
|
|
332
334
|
log,
|
|
335
|
+
keepOpen: keepOpen.enabled,
|
|
333
336
|
});
|
|
334
337
|
// postCommandResult (not updateCommand) so the existing extension-side
|
|
335
338
|
// logic mirrors status into ContentJob + ContentIdea.publish_results +
|
|
@@ -356,7 +359,17 @@ class CommandPoller {
|
|
|
356
359
|
// Scoped to the browse-only scripts. Publish keeps its profile open on
|
|
357
360
|
// purpose: one profile serves several platform uploads back-to-back.
|
|
358
361
|
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
|
|
359
|
-
|
|
362
|
+
// Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
|
|
363
|
+
// đời đóng-mở bình thường ngay từ lượt này.
|
|
364
|
+
if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
|
|
365
|
+
if (keepOpen.enabled && this.nst) {
|
|
366
|
+
// Máy bị chặn số lượt mở/ngày: KHÔNG đóng, ghi sổ để lượt sau nối lại
|
|
367
|
+
// vào đúng browser này. Chỉ trần `keep_open_max` mới được đóng bớt.
|
|
368
|
+
if (!this._keptOpenProfiles) this._keptOpenProfiles = new Map();
|
|
369
|
+
this._keptOpenProfiles.set(profileId, Date.now());
|
|
370
|
+
console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
|
|
371
|
+
await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
|
|
372
|
+
} else if (CLOSE_AFTER.has(scriptName) && this.nst) {
|
|
360
373
|
try {
|
|
361
374
|
await this.nst.stopProfile(profileId);
|
|
362
375
|
console.log(`[commands/pw] closed profile ${profileId} after ${scriptName}`);
|
|
@@ -379,6 +392,62 @@ class CommandPoller {
|
|
|
379
392
|
}
|
|
380
393
|
}
|
|
381
394
|
|
|
395
|
+
// ─── Giữ profile mở (máy bị chặn số lượt mở/ngày) ─────────────────────────
|
|
396
|
+
// Gói Nstbrowser không nâng cấp chặn ~30 LƯỢT MỞ/ngày (relabs03, acc
|
|
397
|
+
// maithithu). Mặc định daemon đóng profile sau mỗi việc rồi mở lại cho việc
|
|
398
|
+
// sau → mỗi task tốn 1 lượt, cháy hạn mức từ giữa ngày. Cờ `keep_profiles_open`
|
|
399
|
+
// trên hàng Worker (bật trong web, trang Workers) đổi hành vi thành: giữ
|
|
400
|
+
// browser mở và tái dùng, mỗi profile chỉ tốn ĐÚNG 1 lượt/ngày.
|
|
401
|
+
//
|
|
402
|
+
// Đọc qua /workers/me, cache 30s — đổi cờ trên web có tác dụng trong nửa phút,
|
|
403
|
+
// không cần restart daemon.
|
|
404
|
+
async _keepOpenCfg() {
|
|
405
|
+
const CACHE_MS = 30 * 1000;
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
if (this._keepOpenCache && (now - this._keepOpenCache.at) < CACHE_MS) return this._keepOpenCache;
|
|
408
|
+
// Không đọc được /workers/me → fail-safe về hành vi CŨ (vẫn đóng profile).
|
|
409
|
+
// Giữ mở nhầm nguy hiểm hơn đóng nhầm: profile mở tích luỹ sẽ ngốn hết RAM.
|
|
410
|
+
let cfg = { at: now, enabled: false, max: 6 };
|
|
411
|
+
try {
|
|
412
|
+
if (this.api.getMyWorker) {
|
|
413
|
+
const me = await this.api.getMyWorker();
|
|
414
|
+
if (me) {
|
|
415
|
+
cfg.enabled = !!me.keep_profiles_open;
|
|
416
|
+
const n = parseInt(me.keep_open_max, 10);
|
|
417
|
+
if (!Number.isNaN(n) && n >= 1) cfg.max = n;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (this.config.verbose) console.warn('[keep-open] getMyWorker failed, giữ hành vi cũ:', e.message);
|
|
422
|
+
}
|
|
423
|
+
this._keepOpenCache = cfg;
|
|
424
|
+
return cfg;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Trần RAM cho chế độ giữ mở: quá `max` thì đóng profile RẢNH LÂU NHẤT.
|
|
428
|
+
// Chỉ đụng tới profile do luồng _pw giữ (sổ `_keptOpenProfiles`) — KHÔNG đụng
|
|
429
|
+
// profile renderer, vòng đời của chúng do scene-dispatch quản.
|
|
430
|
+
async _trimKeptProfiles(max) {
|
|
431
|
+
if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
|
|
432
|
+
const candidates = [...this._keptOpenProfiles.entries()]
|
|
433
|
+
.filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
|
|
434
|
+
.sort((a, b) => a[1] - b[1]);
|
|
435
|
+
let over = this._keptOpenProfiles.size - max;
|
|
436
|
+
for (const [id] of candidates) {
|
|
437
|
+
if (over <= 0) break;
|
|
438
|
+
try {
|
|
439
|
+
await this.nst.stopProfile(id);
|
|
440
|
+
console.log(`[keep-open] đóng ${id} — rảnh lâu nhất, quá trần ${max} profile giữ mở`);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.warn(`[keep-open] đóng ${id} lỗi: ${e.message}`);
|
|
443
|
+
}
|
|
444
|
+
// Xoá khỏi sổ dù đóng lỗi: giữ lại chỉ khiến vòng sau thử lại vô ích và
|
|
445
|
+
// chặn mất suất của profile khác.
|
|
446
|
+
this._keptOpenProfiles.delete(id);
|
|
447
|
+
over--;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
382
451
|
// Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
|
|
383
452
|
// hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
|
|
384
453
|
// tiếp vẫn dùng chung một browser như thiết kế cũ.
|
|
@@ -1319,6 +1388,16 @@ class CommandPoller {
|
|
|
1319
1388
|
|
|
1320
1389
|
async handleCloseProfile(command) {
|
|
1321
1390
|
const { profile_id } = command.payload || {};
|
|
1391
|
+
// Máy bị chặn số lượt mở/ngày: bỏ qua lệnh đóng của API. KHÔNG nhả lease
|
|
1392
|
+
// kèm theo — profile vẫn đang mở ở máy này, nhả lease là mời máy khác mở
|
|
1393
|
+
// cùng profile → hai browser một tài khoản, ghi đè cookie của nhau.
|
|
1394
|
+
// Lease tự hết hạn sau TTL 10 phút nếu máy này thật sự bỏ việc.
|
|
1395
|
+
const keepOpen = await this._keepOpenCfg();
|
|
1396
|
+
if (keepOpen.enabled) {
|
|
1397
|
+
console.log(`[keep-open] bỏ qua close_profile ${profile_id} — máy đang bật chế độ giữ profile mở`);
|
|
1398
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { skipped: 'keep_profiles_open' } });
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1322
1401
|
console.log(`[commands] Closing profile: ${profile_id}`);
|
|
1323
1402
|
try {
|
|
1324
1403
|
// NstManager exposes stopProfile — there has never been a closeProfile.
|
|
@@ -1440,7 +1519,8 @@ class CommandPoller {
|
|
|
1440
1519
|
// Pass youtube_handle from command payload
|
|
1441
1520
|
channel.youtube_handle = youtube_handle || null;
|
|
1442
1521
|
|
|
1443
|
-
const
|
|
1522
|
+
const keepOpen = await this._keepOpenCfg();
|
|
1523
|
+
const syncer = new StatsSyncer(this.nst, this.api, { keepOpen: keepOpen.enabled });
|
|
1444
1524
|
const stats = await syncer.syncYouTubeStats(channel);
|
|
1445
1525
|
|
|
1446
1526
|
await this.api.updateCommand(command._id, {
|
|
@@ -1946,6 +2026,13 @@ class CommandPoller {
|
|
|
1946
2026
|
async _checkProfileTimeouts() {
|
|
1947
2027
|
try {
|
|
1948
2028
|
if (!this.nst) return;
|
|
2029
|
+
// Máy bị chặn số lượt mở/ngày: bộ quét này thuần tiết kiệm RAM, mà mỗi
|
|
2030
|
+
// lần nó đóng là lần sau tốn thêm 1 lượt mở. Tắt hẳn; trần RAM đã do
|
|
2031
|
+
// `keep_open_max` lo. (Vòng xoay round-robin của scene-dispatch thì KHÔNG
|
|
2032
|
+
// tắt — cái đó là chống captcha "2 phiên/1 tài khoản Google", an toàn tài
|
|
2033
|
+
// khoản đứng trên hạn mức lượt mở.)
|
|
2034
|
+
const keepOpen = await this._keepOpenCfg();
|
|
2035
|
+
if (keepOpen.enabled) return;
|
|
1949
2036
|
|
|
1950
2037
|
const IDLE_TIMEOUT = 30 * 1000; // 30s — matches old extension-side behavior
|
|
1951
2038
|
const now = Date.now();
|
package/lib/nst-manager.js
CHANGED
|
@@ -107,6 +107,38 @@ class NstManager {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Điểm nối CDP của một profile ĐANG CHẠY, lấy từ cổng debug của chính nó.
|
|
112
|
+
*
|
|
113
|
+
* Sinh ra cho máy dùng gói Nstbrowser bị chặn số LƯỢT MỞ/ngày (relabs03 —
|
|
114
|
+
* 30 lượt). Trước đây mọi đường muốn có endpoint đều tắt-mở lại profile
|
|
115
|
+
* (`forceRelaunch`), nên một profile đang mở sẵn vẫn tốn thêm 1 lượt cho mỗi
|
|
116
|
+
* việc. Đọc thẳng `remoteDebuggingPort` thì không tốn lượt nào.
|
|
117
|
+
*
|
|
118
|
+
* → { httpEndpoint, wsEndpoint, port } | null nếu profile không chạy.
|
|
119
|
+
*/
|
|
120
|
+
async resolveRunningEndpoint(profileIdOrName) {
|
|
121
|
+
let profileId = profileIdOrName;
|
|
122
|
+
if (!this.isUUID(profileIdOrName)) {
|
|
123
|
+
profileId = await this.findProfile(profileIdOrName);
|
|
124
|
+
if (!profileId) return null;
|
|
125
|
+
}
|
|
126
|
+
const running = await this.getRunningBrowsers();
|
|
127
|
+
const b = running.find(x => x.profileId === profileId);
|
|
128
|
+
const port = b?.remoteDebuggingPort;
|
|
129
|
+
if (!port) return null;
|
|
130
|
+
const httpEndpoint = `http://127.0.0.1:${port}`;
|
|
131
|
+
// Playwright nối được bằng httpEndpoint, nhưng đường CDP thô (stats-syncer)
|
|
132
|
+
// cần đúng ws:// — /json/version của chính Chrome trả về nó.
|
|
133
|
+
let wsEndpoint = null;
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(`${httpEndpoint}/json/version`);
|
|
136
|
+
const data = await res.json();
|
|
137
|
+
wsEndpoint = data?.webSocketDebuggerUrl || null;
|
|
138
|
+
} catch { /* cổng chưa sẵn sàng — httpEndpoint vẫn dùng được cho Playwright */ }
|
|
139
|
+
return { profileId, httpEndpoint, wsEndpoint, port };
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
// Check if profile is already running
|
|
111
143
|
async isProfileRunning(profileId) {
|
|
112
144
|
const running = await this.getRunningBrowsers();
|
package/lib/playwright-runner.js
CHANGED
|
@@ -26,7 +26,7 @@ function loadScript(scriptName) {
|
|
|
26
26
|
return mod;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {} }) {
|
|
29
|
+
async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false }) {
|
|
30
30
|
let playwright;
|
|
31
31
|
try { playwright = require('playwright-core'); }
|
|
32
32
|
catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
|
|
@@ -38,19 +38,33 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
|
|
|
38
38
|
// for the channel-manager-ext on this path. (The chagpt cgpt path still
|
|
39
39
|
// launches with content-creator-ext via a different code path.)
|
|
40
40
|
const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
|
|
41
|
-
let
|
|
41
|
+
let endpoint = launchRes?.wsEndpoint;
|
|
42
42
|
|
|
43
43
|
// If the profile was already running, launchProfile returns { alreadyRunning:
|
|
44
|
-
// true } without a wsEndpoint.
|
|
45
|
-
if (!
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// true } without a wsEndpoint.
|
|
45
|
+
if (!endpoint && launchRes?.alreadyRunning) {
|
|
46
|
+
// keepOpen: máy bị chặn số lượt mở/ngày. Tắt-mở lại một profile đang chạy
|
|
47
|
+
// sẵn chỉ để lấy endpoint mới là đốt 1 lượt cho không — nối thẳng vào cổng
|
|
48
|
+
// debug của nó thay vì relaunch.
|
|
49
|
+
if (keepOpen) {
|
|
50
|
+
const live = await nst.resolveRunningEndpoint(profileId).catch(() => null);
|
|
51
|
+
if (live?.httpEndpoint) {
|
|
52
|
+
endpoint = live.httpEndpoint;
|
|
53
|
+
log('info', `[pw] profile đang chạy — nối vào cổng debug ${live.port} (không tốn lượt mở)`);
|
|
54
|
+
} else {
|
|
55
|
+
log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại (tốn 1 lượt)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!endpoint) {
|
|
59
|
+
log('info', `[pw] profile already running — force-relaunching for fresh CDP endpoint`);
|
|
60
|
+
const r = await nst.launchProfile(profileId, { forceRelaunch: true });
|
|
61
|
+
endpoint = r?.wsEndpoint;
|
|
62
|
+
}
|
|
49
63
|
}
|
|
50
|
-
if (!
|
|
64
|
+
if (!endpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
|
|
51
65
|
|
|
52
66
|
log('info', `[pw] connectOverCDP…`);
|
|
53
|
-
const browser = await playwright.chromium.connectOverCDP(
|
|
67
|
+
const browser = await playwright.chromium.connectOverCDP(endpoint);
|
|
54
68
|
try {
|
|
55
69
|
const context = browser.contexts()[0] || await browser.newContext();
|
|
56
70
|
const page = context.pages()[0] || await context.newPage();
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -117,7 +117,18 @@ function parseOfferRow(row) {
|
|
|
117
117
|
name,
|
|
118
118
|
category_group: categoryGroup(name),
|
|
119
119
|
product_url: row.product_link || (shopId && itemId ? `https://shopee.vn/product/${shopId}/${itemId}` : ''),
|
|
120
|
-
|
|
120
|
+
// ĐỪNG gán `long_link` vào đây (đo 09/09/2026). Offer list chỉ trả hai
|
|
121
|
+
// link và KHÔNG cái nào là link affiliate:
|
|
122
|
+
// long_link = shopee.vn/universal-link/...?gads_t_sig=… (chữ ký
|
|
123
|
+
// Google Ads, không mang mã cộng tác viên)
|
|
124
|
+
// product_link = link sản phẩm trần
|
|
125
|
+
// Gán nhầm thì kho đầy link trông y như thật, gắn vào caption chạy trơn,
|
|
126
|
+
// người xem mua được — mà mình KHÔNG nhận đồng hoa hồng nào và không một
|
|
127
|
+
// dòng lỗi nào báo. 34/34 sản phẩm trong kho đang dính đúng lỗi này.
|
|
128
|
+
// Link affiliate thật phải sinh qua nút "Get Link" trên trang; cách tự
|
|
129
|
+
// động hoá còn đang tìm, nên để TRỐNG thay vì để một link giả.
|
|
130
|
+
affiliate_link: '',
|
|
131
|
+
universal_link: row.long_link || '',
|
|
121
132
|
commission_rate: rate,
|
|
122
133
|
commission_seller_rate: sellerRate,
|
|
123
134
|
commission_value: price && rate ? Math.round(price * rate / 100) : null,
|
package/lib/stats-syncer.js
CHANGED
|
@@ -5,9 +5,12 @@ const WebSocket = require('ws');
|
|
|
5
5
|
* Flow: Launch profile → navigate to youtube.com/@handle/about → read stats table
|
|
6
6
|
*/
|
|
7
7
|
class StatsSyncer {
|
|
8
|
-
constructor(nstManager, apiClient) {
|
|
8
|
+
constructor(nstManager, apiClient, options = {}) {
|
|
9
9
|
this.nst = nstManager;
|
|
10
10
|
this.api = apiClient;
|
|
11
|
+
// Máy bị chặn số lượt mở Nstbrowser/ngày → không đóng profile sau khi đọc
|
|
12
|
+
// số liệu, và tái dùng browser đang chạy thay vì mở lại.
|
|
13
|
+
this.keepOpen = !!options.keepOpen;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
async syncYouTubeStats(channel) {
|
|
@@ -16,9 +19,21 @@ class StatsSyncer {
|
|
|
16
19
|
|
|
17
20
|
console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
|
|
18
21
|
|
|
19
|
-
const
|
|
22
|
+
const launched = await this.nst.launchProfile(profileId, {
|
|
20
23
|
proxy: channel.proxy || null,
|
|
21
24
|
});
|
|
25
|
+
let wsEndpoint = launched?.wsEndpoint;
|
|
26
|
+
|
|
27
|
+
// Profile đang chạy sẵn → launchProfile trả { alreadyRunning: true } và
|
|
28
|
+
// KHÔNG có wsEndpoint. Trước đây chỗ này ném lỗi luôn; chế độ giữ mở khiến
|
|
29
|
+
// tình huống đó thành mặc định, nên lấy endpoint từ cổng debug đang chạy.
|
|
30
|
+
if (!wsEndpoint && launched?.alreadyRunning) {
|
|
31
|
+
const live = await this.nst.resolveRunningEndpoint(profileId).catch(() => null);
|
|
32
|
+
if (live?.wsEndpoint) {
|
|
33
|
+
wsEndpoint = live.wsEndpoint;
|
|
34
|
+
console.log(`[stats] Dùng lại browser đang chạy (cổng debug ${live.port})`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
|
|
24
39
|
|
|
@@ -40,10 +55,14 @@ class StatsSyncer {
|
|
|
40
55
|
console.log(`[stats] "${channel.name}" stats saved to API`);
|
|
41
56
|
return stats;
|
|
42
57
|
} finally {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
58
|
+
if (this.keepOpen) {
|
|
59
|
+
console.log(`[stats] Giữ profile ${profileId} mở (máy bị chặn số lượt mở/ngày)`);
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
await this.nst.stopProfile(profileId);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn(`[stats] Failed to stop profile: ${err.message}`);
|
|
65
|
+
}
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
68
|
}
|
package/lib/updater.js
CHANGED
|
@@ -31,6 +31,28 @@ function installUpdate(version) {
|
|
|
31
31
|
console.log(`[updater] Installed ${PKG_NAME}@${version}`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Cây vừa cài có nạp được không? `channel-worker selftest` require từng file
|
|
36
|
+
* trong lib/ và thoát 0 nếu đủ.
|
|
37
|
+
*
|
|
38
|
+
* npm cài ĐÈ LÊN module đang chạy, và trên Windows nó xoá cây cũ trước khi ghi
|
|
39
|
+
* cây mới. Lượt cài dừng giữa chừng để lại package.json version MỚI cạnh một
|
|
40
|
+
* lib/ THIẾU FILE — nhìn version thì thấy "cài xong". 08/09/2026 daemon thay
|
|
41
|
+
* thế chết ngay MODULE_NOT_FOUND (lib/daemon.js + lib/command-poller.js biến
|
|
42
|
+
* mất), hai máy Windows tắt giữa lúc đăng 9 video.
|
|
43
|
+
*/
|
|
44
|
+
function verifyInstall() {
|
|
45
|
+
const { spawnSync } = require('child_process');
|
|
46
|
+
const isWindows = process.platform === 'win32';
|
|
47
|
+
const bin = isWindows ? 'channel-worker.cmd' : 'channel-worker';
|
|
48
|
+
const out = spawnSync(bin, ['selftest'], {
|
|
49
|
+
shell: isWindows, windowsHide: true, encoding: 'utf8', timeout: 120000,
|
|
50
|
+
});
|
|
51
|
+
// status null = không chạy nổi (timeout, binary không thấy) → coi như hỏng.
|
|
52
|
+
const detail = `${out.stdout || ''}${out.stderr || ''}`.trim().split('\n').slice(-6).join(' | ');
|
|
53
|
+
return { ok: out.status === 0, detail: detail.slice(-600) };
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
35
57
|
const local = getLocalVersion();
|
|
36
58
|
const latest = await getLatestVersion();
|
|
@@ -42,6 +64,26 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
42
64
|
console.log(`[updater] Update available: ${local} → ${latest}`);
|
|
43
65
|
installUpdate(latest);
|
|
44
66
|
|
|
67
|
+
// Cài xong CHƯA CHẮC chạy được — xem verifyInstall(). Hỏng thì thử cài lại
|
|
68
|
+
// đúng một lần, vẫn hỏng thì Ở YÊN: tiến trình này vẫn đang chạy code cũ đã
|
|
69
|
+
// nạp trong bộ nhớ, nên không restart là còn worker; restart vào cây cụt là
|
|
70
|
+
// mất worker. Bản mới sẽ vào ở lượt kiểm kế tiếp, hoặc do người cài tay.
|
|
71
|
+
let check = verifyInstall();
|
|
72
|
+
if (!check.ok) {
|
|
73
|
+
console.warn(`[updater] Bản vừa cài KHÔNG nạp được (${check.detail}) — cài lại một lần.`);
|
|
74
|
+
try {
|
|
75
|
+
installUpdate(latest);
|
|
76
|
+
check = verifyInstall();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
check = { ok: false, detail: `cài lại lỗi: ${e.message}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!check.ok) {
|
|
82
|
+
console.error('[updater] BỎ khởi động lại: cây cài dở, daemon cũ chạy tiếp. '
|
|
83
|
+
+ `Cần cài tay: npm i -g ${PKG_NAME}@${latest} --prefer-online (dừng daemon trước). Chi tiết: ${check.detail}`);
|
|
84
|
+
return { updated: false, local, latest, verifyFailed: true, detail: check.detail };
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
if (autoRestart) {
|
|
46
88
|
console.log('[updater] Restarting daemon with new version...');
|
|
47
89
|
const { spawn } = require('child_process');
|
package/package.json
CHANGED
|
@@ -631,6 +631,41 @@ async function dismissToasts(page) {
|
|
|
631
631
|
}).catch(() => 0);
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
// Vòng bấm nút xác nhận đăng của FB. Tách khỏi runOnce để test được bằng hàm
|
|
635
|
+
// THẬT: đây đúng là chỗ đẻ ra "đăng thành công" giả ngày 2026-09-09 (3 video
|
|
636
|
+
// kênh Kho Mẹo Nhà Nông báo đã đăng, Facebook không có bài).
|
|
637
|
+
//
|
|
638
|
+
// Luật, đổi bằng máu:
|
|
639
|
+
// 1. MỖI lượt phải ĐÁNH DẤU LẠI nút. Log prod của lượt hỏng cho thấy cả 2 cú
|
|
640
|
+
// bấm đều timeout và KHÔNG có dòng JS-dispatch — tức thẻ `__fbpw_confirm__`
|
|
641
|
+
// nằm trên node React đã bị dựng lại, `querySelector` trả null. Dùng lại
|
|
642
|
+
// thẻ của lượt trước là bấm vào hư không.
|
|
643
|
+
// 2. Bấm trượt thì chốt bằng TÌM-VÀ-BẤM trong cùng một evaluate — không chừa
|
|
644
|
+
// khoảng hở cho re-render.
|
|
645
|
+
// 3. Hết lượt mà hộp còn đó thì TRẢ VỀ confirmed=false. Code cũ nuốt lỗi rồi
|
|
646
|
+
// vẫn `published = true`.
|
|
647
|
+
// Trả { confirmed } — KHÔNG ném lỗi: bài có thể đã lên thật, ném lỗi ở đây là
|
|
648
|
+
// mở đường cho lịch đăng lại → đăng đúp (đã xảy ra 2026-08-19).
|
|
649
|
+
async function confirmPublishDialog({ firstHit, tagConfirmButton, clickTagged, clickInPlace, wait, log, rounds = 3 }) {
|
|
650
|
+
let pending = true;
|
|
651
|
+
for (let round = 0; round < rounds; round++) {
|
|
652
|
+
const hit = round === 0 ? firstHit : await tagConfirmButton();
|
|
653
|
+
if (!hit) { pending = false; break; } // hộp đã đóng = đã xác nhận
|
|
654
|
+
let clicked = false;
|
|
655
|
+
try {
|
|
656
|
+
await clickTagged(hit);
|
|
657
|
+
clicked = true;
|
|
658
|
+
} catch (e) {
|
|
659
|
+
log('warn', `[fb-pw] confirm click lượt ${round + 1}/${rounds} hỏng (${String(e && e.message || e).split('\n')[0].slice(0, 70)}) — bấm thẳng trong DOM`);
|
|
660
|
+
clicked = await clickInPlace();
|
|
661
|
+
if (clicked) log('info', '[fb-pw] confirm bấm được bằng tìm-và-bấm cùng lượt');
|
|
662
|
+
}
|
|
663
|
+
await wait(clicked ? 3000 : 1500);
|
|
664
|
+
if (!(await tagConfirmButton())) { pending = false; break; }
|
|
665
|
+
}
|
|
666
|
+
return { confirmed: !pending };
|
|
667
|
+
}
|
|
668
|
+
|
|
634
669
|
async function hasVisibleReelComposer(page) {
|
|
635
670
|
return page.evaluate(() => {
|
|
636
671
|
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
@@ -1479,6 +1514,9 @@ async function runOnce({ page, payload, log }) {
|
|
|
1479
1514
|
// (publish if available, else Tiếp). If we never find publish in 6
|
|
1480
1515
|
// iterations, throw with diagnostics.
|
|
1481
1516
|
let published = false;
|
|
1517
|
+
// Bấm nút xác nhận của FB hỏng cả 3 lượt → kết quả lệnh mang cờ này để
|
|
1518
|
+
// phía API biết đây là ca "báo xong nhưng chưa chắc đã lên".
|
|
1519
|
+
let confirmClickFailed = false;
|
|
1482
1520
|
let customThumbDone = false;
|
|
1483
1521
|
let pubWaitDone = false;
|
|
1484
1522
|
let reelViewerRecoveries = 0; // bấm Tiếp trượt vào toast → FB mở reel viewer, xem bên dưới
|
|
@@ -2242,51 +2280,98 @@ async function runOnce({ page, payload, log }) {
|
|
|
2242
2280
|
// doesn't actually publish. Poll for the dialog up to 8s (FB's
|
|
2243
2281
|
// dialog mount is slow — a single 2.5s wait often missed it).
|
|
2244
2282
|
const confirmVerbs = ['Xác nhận', 'Xác minh', 'Đồng ý', 'Confirm', 'Continue', 'OK', 'Đăng'];
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
}
|
|
2283
|
+
// Tìm nút xác nhận và ĐÁNH DẤU nó. Tách thành hàm vì phải gọi LẠI
|
|
2284
|
+
// trước MỖI lượt bấm: FB dựng lại cây React liên tục, thẻ đánh dấu
|
|
2285
|
+
// rụng theo node cũ nên dùng lại thẻ của lượt trước là trỏ vào hư
|
|
2286
|
+
// không (xem ghi chú ở khối bấm bên dưới).
|
|
2287
|
+
const tagConfirmButton = () => page.evaluate((verbs) => {
|
|
2288
|
+
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
2289
|
+
for (const dlg of dlgs) {
|
|
2290
|
+
const r = dlg.getBoundingClientRect();
|
|
2291
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
2292
|
+
const cs = getComputedStyle(dlg);
|
|
2293
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
2294
|
+
const btns = dlg.querySelectorAll("button, [role='button']");
|
|
2295
|
+
for (const v of verbs) {
|
|
2296
|
+
for (const b of btns) {
|
|
2297
|
+
const t = (b.innerText || '').trim();
|
|
2298
|
+
if (t === v) {
|
|
2299
|
+
// Clear stale markers first — a previous confirm attempt
|
|
2300
|
+
// that threw left its tag behind, and two tagged nodes
|
|
2301
|
+
// make the locator fail on strict mode.
|
|
2302
|
+
document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
|
|
2303
|
+
b.setAttribute('__fbpw_confirm__', '1');
|
|
2304
|
+
return { selector: "[__fbpw_confirm__='1']", verb: v };
|
|
2267
2305
|
}
|
|
2268
2306
|
}
|
|
2269
2307
|
}
|
|
2270
|
-
|
|
2271
|
-
|
|
2308
|
+
}
|
|
2309
|
+
return null;
|
|
2310
|
+
}, confirmVerbs).catch(() => null);
|
|
2311
|
+
// Cú chốt: TÌM VÀ BẤM trong cùng một evaluate — không có khoảng hở
|
|
2312
|
+
// giữa lúc đánh dấu và lúc bấm để React chen vào.
|
|
2313
|
+
const clickConfirmInPlace = () => page.evaluate((verbs) => {
|
|
2314
|
+
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
2315
|
+
for (const dlg of dlgs) {
|
|
2316
|
+
const r = dlg.getBoundingClientRect();
|
|
2317
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
2318
|
+
const cs = getComputedStyle(dlg);
|
|
2319
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
2320
|
+
const btns = dlg.querySelectorAll("button, [role='button']");
|
|
2321
|
+
for (const v of verbs) {
|
|
2322
|
+
for (const b of btns) {
|
|
2323
|
+
const t = (b.innerText || '').trim();
|
|
2324
|
+
if (t !== v) continue;
|
|
2325
|
+
if (b.getAttribute('aria-disabled') === 'true') continue;
|
|
2326
|
+
b.scrollIntoView({ block: 'center' });
|
|
2327
|
+
b.click();
|
|
2328
|
+
return true;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
return false;
|
|
2333
|
+
}, confirmVerbs).catch(() => false);
|
|
2334
|
+
let confirmHit = null;
|
|
2335
|
+
const cfmDeadline = Date.now() + 8000;
|
|
2336
|
+
while (Date.now() < cfmDeadline && !confirmHit) {
|
|
2337
|
+
confirmHit = await tagConfirmButton();
|
|
2272
2338
|
if (!confirmHit) await pause(page, 600);
|
|
2273
2339
|
}
|
|
2274
2340
|
if (confirmHit) {
|
|
2275
2341
|
log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
|
|
2276
|
-
|
|
2277
|
-
//
|
|
2278
|
-
//
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
|
|
2284
|
-
|
|
2342
|
+
// ⚠️ CHỖ NÀY TỪNG NUỐT LỖI VÀ ĐẺ RA "ĐĂNG THÀNH CÔNG" GIẢ.
|
|
2343
|
+
// Chủ tịch bắt được 2026-09-09 (kênh Kho Mẹo Nhà Nông): 3 video
|
|
2344
|
+
// báo đã đăng, vào Facebook kiểm thì KHÔNG có bài. Log prod của
|
|
2345
|
+
// đúng lượt đó: "confirm click attempt 1/2 failed (locator.waitFor:
|
|
2346
|
+
// Timeout 5000ms)" → "2/2 failed (locator.click: Timeout 8000ms)"
|
|
2347
|
+
// → "confirm click failed", và KHÔNG hề có dòng JS-dispatch
|
|
2348
|
+
// fallback — tức `document.querySelector('[__fbpw_confirm__]')` trả
|
|
2349
|
+
// null: nút đã bị React dựng lại, thẻ đánh dấu nằm trên node cũ đã
|
|
2350
|
+
// rời DOM. Bấm trượt xong code cũ chỉ ghi một dòng warn rồi vẫn
|
|
2351
|
+
// `published = true` → lệnh báo done, API cho idea sang "Đã đăng",
|
|
2352
|
+
// lịch không bao giờ đăng lại. Đo prod 30 ngày: ~39 ca.
|
|
2353
|
+
//
|
|
2354
|
+
// Sửa: mỗi lượt ĐÁNH DẤU LẠI từ đầu; bấm trượt thì chốt bằng
|
|
2355
|
+
// tìm-và-bấm trong cùng một evaluate; hết 3 lượt mà hộp xác nhận
|
|
2356
|
+
// vẫn còn thì GHI NHẬN thất bại thay vì im lặng.
|
|
2357
|
+
const { confirmed } = await confirmPublishDialog({
|
|
2358
|
+
firstHit: confirmHit,
|
|
2359
|
+
tagConfirmButton,
|
|
2360
|
+
clickTagged: (hit) => resilientClick(page, page.locator(hit.selector), hit.selector, {
|
|
2361
|
+
timeout: 6000,
|
|
2362
|
+
log,
|
|
2363
|
+
label: `confirm "${hit.verb}"`,
|
|
2364
|
+
attempts: 1,
|
|
2365
|
+
}),
|
|
2366
|
+
clickInPlace: clickConfirmInPlace,
|
|
2367
|
+
wait: (ms) => pause(page, ms),
|
|
2285
2368
|
log,
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2369
|
+
});
|
|
2370
|
+
if (!confirmed) {
|
|
2371
|
+
confirmClickFailed = true;
|
|
2372
|
+
log('warn', '[fb-pw] hộp xác nhận đăng VẪN MỞ sau 3 lượt bấm — bài nhiều khả năng CHƯA lên');
|
|
2373
|
+
await dumpFailure(page, 'confirm-stuck', log);
|
|
2374
|
+
}
|
|
2290
2375
|
} else {
|
|
2291
2376
|
log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
|
|
2292
2377
|
}
|
|
@@ -2854,7 +2939,13 @@ async function runOnce({ page, payload, log }) {
|
|
|
2854
2939
|
// post_url empty than to record a wrong one. The spam-limit guard above
|
|
2855
2940
|
// now catches the FB block case explicitly.
|
|
2856
2941
|
|
|
2857
|
-
|
|
2942
|
+
// Không lấy được link bài = KHÔNG có reel mới nào hiện ra sau khi đăng
|
|
2943
|
+
// (nhánh last-resort đã từ chối tile cũ theo luật 2026-08-19). Trước đây
|
|
2944
|
+
// dòng này ghi "FB likely accepted" rồi cho qua; đo prod 30 ngày tới
|
|
2945
|
+
// 2026-09-09 cho thấy phần lớn ca như vậy là bài CHƯA lên. Lệnh vẫn trả
|
|
2946
|
+
// ok để KHÔNG tự đăng lại (đăng lại là nguy cơ đăng đúp) — việc hạ xuống
|
|
2947
|
+
// "mập mờ, chờ người kiểm" do API làm, ở markUnverifiedPublish().
|
|
2948
|
+
if (!postUrl) log('warn', `[fb-pw] KHÔNG lấy được link bài — bài có thể CHƯA lên (confirm_click_failed=${confirmClickFailed}). API sẽ đánh dấu mập mờ để người kiểm.`);
|
|
2858
2949
|
|
|
2859
2950
|
// Strip FB tracking params (__cft__, __tn__, etc.) so the stored URL is
|
|
2860
2951
|
// canonical. The reel ID alone uniquely identifies the post and the
|
|
@@ -2875,6 +2966,10 @@ async function runOnce({ page, payload, log }) {
|
|
|
2875
2966
|
title: String(title || description || '').slice(0, 80),
|
|
2876
2967
|
visibility,
|
|
2877
2968
|
post_url: postUrl,
|
|
2969
|
+
// Hai cờ chẩn đoán cho ca "báo xong mà không có link bài" — API đọc
|
|
2970
|
+
// post_url để quyết, hai cờ này để người đọc log biết hỏng ở khâu nào.
|
|
2971
|
+
confirm_click_failed: confirmClickFailed,
|
|
2972
|
+
post_url_captured: !!postUrl,
|
|
2878
2973
|
video_path_local: videoPath,
|
|
2879
2974
|
};
|
|
2880
2975
|
} finally {
|
|
@@ -2912,6 +3007,7 @@ module.exports = { run };
|
|
|
2912
3007
|
// page (toast covering the CTA) without driving the whole upload flow.
|
|
2913
3008
|
module.exports.__testables = {
|
|
2914
3009
|
resilientClick,
|
|
3010
|
+
confirmPublishDialog,
|
|
2915
3011
|
muteClickInterceptors,
|
|
2916
3012
|
unmuteClickInterceptors,
|
|
2917
3013
|
waitForPublishEnabled,
|