channel-worker 2.5.113 → 2.5.115
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 +1 -1
- package/lib/command-poller.js +110 -5
- package/lib/nst-agent-locator.js +52 -1
- package/lib/nst-manager.js +46 -1
- package/package.json +1 -1
- package/scripts/upload_facebook.js +62 -1
- package/scripts/upload_facebook_photo.js +24 -20
- package/scripts/upload_facebook_post.js +4 -0
package/lib/api-client.js
CHANGED
|
@@ -75,7 +75,7 @@ class ApiClient {
|
|
|
75
75
|
// nào — xảy ra 14/09 với verify_facebook_reels_pw (cổng thứ nhất là enum
|
|
76
76
|
// của app/models/WorkerCommand.js, cũng đã quên một lần cùng ngày).
|
|
77
77
|
// api/tests/publish-verify-wiring.test.js canh cả hai cổng.
|
|
78
|
-
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
|
|
78
|
+
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
|
|
79
79
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
80
80
|
}
|
|
81
81
|
|
package/lib/command-poller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { NstManager } = require('./nst-manager');
|
|
2
|
+
const { refreshPinnedAgentAddress } = require('./nst-agent-locator');
|
|
2
3
|
const { checkAndUpdateExtension } = require('./extension-updater');
|
|
3
4
|
const { StatsSyncer } = require('./stats-syncer');
|
|
4
5
|
|
|
@@ -7,6 +8,12 @@ const { StatsSyncer } = require('./stats-syncer');
|
|
|
7
8
|
// tới và dùng lại browser, nhưng không được để mở qua đêm.
|
|
8
9
|
const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
9
10
|
|
|
11
|
+
// Người bấm "Mở trình duyệt" trên web → profile đó là của NGƯỜI trong quãng
|
|
12
|
+
// này, automation không được đóng ngang. Trước 16/09/2026 không có hàng rào
|
|
13
|
+
// nào: hẹn đóng 300s của lượt đăng bài trước đó vẫn chạy và đóng profile ngay
|
|
14
|
+
// trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
|
|
15
|
+
const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
|
|
16
|
+
|
|
10
17
|
class CommandPoller {
|
|
11
18
|
constructor(api, config) {
|
|
12
19
|
this.api = api;
|
|
@@ -538,6 +545,8 @@ class CommandPoller {
|
|
|
538
545
|
if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
|
|
539
546
|
const candidates = [...this._keptOpenProfiles.entries()]
|
|
540
547
|
.filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
|
|
548
|
+
// Profile người vừa mở tay không phải ứng viên để nhường suất.
|
|
549
|
+
.filter(([id]) => this._humanHoldLeft(id) <= 0)
|
|
541
550
|
.sort((a, b) => a[1] - b[1]);
|
|
542
551
|
let over = this._keptOpenProfiles.size - max;
|
|
543
552
|
for (const [id] of candidates) {
|
|
@@ -558,7 +567,7 @@ class CommandPoller {
|
|
|
558
567
|
// Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
|
|
559
568
|
// hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
|
|
560
569
|
// tiếp vẫn dùng chung một browser như thiết kế cũ.
|
|
561
|
-
_schedulePwClose(profileId, scriptName) {
|
|
570
|
+
_schedulePwClose(profileId, scriptName, delayMs = PW_IDLE_CLOSE_MS) {
|
|
562
571
|
if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
|
|
563
572
|
const prev = this._pwCloseTimers.get(profileId);
|
|
564
573
|
if (prev) clearTimeout(prev);
|
|
@@ -566,13 +575,22 @@ class CommandPoller {
|
|
|
566
575
|
this._pwCloseTimers.delete(profileId);
|
|
567
576
|
// Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
|
|
568
577
|
if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
|
|
578
|
+
// Người vừa mở profile này bằng tay → hoãn tới khi hết giữ chỗ. Đóng ở
|
|
579
|
+
// đây là đóng ngay trước mũi người đang ngồi trong browser.
|
|
580
|
+
const hold = this._humanHoldLeft(profileId);
|
|
581
|
+
if (hold > 0) {
|
|
582
|
+
console.log(`[commands/pw] hoãn đóng ${profileId} — người đang dùng, `
|
|
583
|
+
+ `còn ${Math.ceil(hold / 1000)}s`);
|
|
584
|
+
this._schedulePwClose(profileId, scriptName, hold + 1000);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
569
587
|
try {
|
|
570
588
|
await this.nst.stopProfile(profileId);
|
|
571
|
-
console.log(`[commands/pw] đóng profile ${profileId} sau ${
|
|
589
|
+
console.log(`[commands/pw] đóng profile ${profileId} sau ${delayMs / 1000}s rảnh (${scriptName})`);
|
|
572
590
|
} catch (e) {
|
|
573
591
|
console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
|
|
574
592
|
}
|
|
575
|
-
},
|
|
593
|
+
}, delayMs);
|
|
576
594
|
// Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
|
|
577
595
|
if (typeof t.unref === 'function') t.unref();
|
|
578
596
|
this._pwCloseTimers.set(profileId, t);
|
|
@@ -640,12 +658,43 @@ class CommandPoller {
|
|
|
640
658
|
}
|
|
641
659
|
}
|
|
642
660
|
|
|
643
|
-
const result = await this.
|
|
661
|
+
const result = await this._launchWithAgentRetry(profile_id, { proxy, extensionPath });
|
|
662
|
+
|
|
663
|
+
// Đang mở sẵn: KHÔNG có cửa sổ mới nào bật lên. Trên máy đang có hơn chục
|
|
664
|
+
// browser thì với người bấm, cú bấm coi như rơi vào hư không. Đẩy cửa sổ
|
|
665
|
+
// sẵn có lên trước và giữ nó lại cho người dùng.
|
|
666
|
+
if (result.alreadyRunning) {
|
|
667
|
+
this._cancelPwClose(profile_id);
|
|
668
|
+
this._holdProfileForHuman(profile_id);
|
|
669
|
+
const fronted = await this.nst.bringProfileToFront(profile_id);
|
|
670
|
+
console.log(`[commands] Profile ${profile_id} đang mở sẵn — `
|
|
671
|
+
+ `${fronted ? 'đã đưa cửa sổ lên trước' : 'KHÔNG đưa được cửa sổ lên trước'}, `
|
|
672
|
+
+ `giữ cho người dùng ${Math.round(HUMAN_HOLD_MS / 60000)} phút`);
|
|
673
|
+
await this.api.updateCommand(command._id, {
|
|
674
|
+
status: 'done',
|
|
675
|
+
result: {
|
|
676
|
+
profile_id: result.profileId,
|
|
677
|
+
already_running: true,
|
|
678
|
+
brought_to_front: fronted,
|
|
679
|
+
launched_at: new Date().toISOString(),
|
|
680
|
+
message: fronted
|
|
681
|
+
? 'Profile đã mở sẵn — đã đưa cửa sổ lên trước trên máy chạy.'
|
|
682
|
+
: 'Profile đã mở sẵn trên máy chạy, nhưng không đưa được cửa sổ lên trước — tìm trong đám cửa sổ đang mở.',
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
644
688
|
console.log(`[commands] Profile ${profile_id} launched${extensionPath ? ' (ext)' : ''}`);
|
|
689
|
+
this._holdProfileForHuman(profile_id);
|
|
645
690
|
|
|
646
691
|
await this.api.updateCommand(command._id, {
|
|
647
692
|
status: 'done',
|
|
648
|
-
result: {
|
|
693
|
+
result: {
|
|
694
|
+
profile_id: result.profileId,
|
|
695
|
+
launched_at: new Date().toISOString(),
|
|
696
|
+
message: 'Đã mở trình duyệt trên máy chạy.',
|
|
697
|
+
},
|
|
649
698
|
});
|
|
650
699
|
} catch (err) {
|
|
651
700
|
console.error(`[commands] Failed to launch profile: ${err.message}`);
|
|
@@ -656,6 +705,60 @@ class CommandPoller {
|
|
|
656
705
|
}
|
|
657
706
|
}
|
|
658
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Mở profile, thử lại khi agent NST chưa dò ra.
|
|
710
|
+
*
|
|
711
|
+
* Agent đổi cổng mỗi lần Nstbrowser khởi động lại (chủ tịch tắt hết profile =
|
|
712
|
+
* app restart), và nó cần vài giây để nghe cổng mới. Không có vòng thử lại
|
|
713
|
+
* này thì cú bấm rơi đúng khoảng đó chết hẳn, người bấm phải tự đoán mà bấm
|
|
714
|
+
* lại — đúng cảnh 16/09/2026: lệnh 11:50:48 chết, lệnh 11:51:34 chạy ngon.
|
|
715
|
+
* Các lỗi khác (hết slot NST, profile mở ở phiên khác) KHÔNG thử lại: thử lại
|
|
716
|
+
* cũng vậy, mà còn giấu mất lỗi thật.
|
|
717
|
+
*/
|
|
718
|
+
async _launchWithAgentRetry(profileId, options, { attempts = 3, waitMs = 3000 } = {}) {
|
|
719
|
+
let lastErr = null;
|
|
720
|
+
for (let i = 1; i <= attempts; i++) {
|
|
721
|
+
try {
|
|
722
|
+
return await this.nst.launchProfile(profileId, options);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
lastErr = err;
|
|
725
|
+
if (!/NST_AGENT_UNRESOLVED/.test(err.message || '')) throw err;
|
|
726
|
+
if (i === attempts) break;
|
|
727
|
+
console.warn(`[commands] agent NST chưa sẵn sàng (lần ${i}/${attempts}) — `
|
|
728
|
+
+ `dò lại sau ${waitMs / 1000}s`);
|
|
729
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
730
|
+
await refreshPinnedAgentAddress({ minIntervalMs: 0 });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
throw lastErr;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ─── Giữ chỗ cho NGƯỜI ────────────────────────────────────────────────────
|
|
737
|
+
// Profile do người mở bằng tay không được để automation đóng ngang: lượt đăng
|
|
738
|
+
// bài trước đó đã hẹn đóng sau PW_IDLE_CLOSE_MS và cái hẹn đó không biết có
|
|
739
|
+
// người đang ngồi trong browser.
|
|
740
|
+
_cancelPwClose(profileId) {
|
|
741
|
+
if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
|
|
742
|
+
clearTimeout(this._pwCloseTimers.get(profileId));
|
|
743
|
+
this._pwCloseTimers.delete(profileId);
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_holdProfileForHuman(profileId, ms = HUMAN_HOLD_MS) {
|
|
750
|
+
if (!this._humanHold) this._humanHold = new Map();
|
|
751
|
+
this._humanHold.set(profileId, Date.now() + ms);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Còn bao nhiêu ms giữ chỗ cho người (0 = hết/không có). */
|
|
755
|
+
_humanHoldLeft(profileId) {
|
|
756
|
+
if (!this._humanHold || !profileId) return 0;
|
|
757
|
+
const left = (this._humanHold.get(profileId) || 0) - Date.now();
|
|
758
|
+
if (left <= 0) { this._humanHold.delete(profileId); return 0; }
|
|
759
|
+
return left;
|
|
760
|
+
}
|
|
761
|
+
|
|
659
762
|
async handleLaunchVeo3Profile(command) {
|
|
660
763
|
const { nst_profile_id, name, veo3_worker_id, os, proxy } = command.payload || {};
|
|
661
764
|
console.log(`[commands] Launching Veo3 profile: ${nst_profile_id} (${name}) os=${os || 'windows'}`);
|
|
@@ -2173,6 +2276,8 @@ class CommandPoller {
|
|
|
2173
2276
|
// Queue holds work pinned to THIS profile → it's about to be claimed,
|
|
2174
2277
|
// leave the browser open.
|
|
2175
2278
|
if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
|
|
2279
|
+
// Người vừa mở profile này từ web → để yên cho tới hết giữ chỗ.
|
|
2280
|
+
if (this._humanHoldLeft(profileId) > 0 || this._humanHoldLeft(browser.name) > 0) continue;
|
|
2176
2281
|
// Match by UUID or name (renderers use name as nst_profile_id)
|
|
2177
2282
|
const lastActivity = this._profileLastActivity[profileId]
|
|
2178
2283
|
|| (name && this._profileLastActivity[name])
|
package/lib/nst-agent-locator.js
CHANGED
|
@@ -159,7 +159,58 @@ function listAgentsCached(ttlMs = 60000, now = Date.now()) {
|
|
|
159
159
|
return agents;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Quên bản cache đi, lần dò sau đọc lại tiến trình thật. Gọi khi vừa biết
|
|
163
|
+
// địa chỉ cũ đã sai — cache 60s đang giữ đúng cái bản đồ lỗi thời đó.
|
|
164
|
+
function invalidateAgentCache() {
|
|
165
|
+
_agentCache = { at: 0, agents: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Dò LẠI NGAY agent của user đang ghim, ghi thẳng vào NST_API_ADDRESS.
|
|
170
|
+
*
|
|
171
|
+
* Agent đổi cổng mỗi lần Nstbrowser khởi động lại (8848 ↔ 8849 theo thứ tự
|
|
172
|
+
* login). Watcher của daemon chỉ dò mỗi 60s, nên lệnh rơi vào khoảng trống đó
|
|
173
|
+
* chết oan bằng NST_AGENT_UNRESOLVED dù agent vẫn sống, chỉ nằm ở cổng khác.
|
|
174
|
+
* Đo thật trên win-worker 16/09/2026: chủ tịch tắt hết profile → agent nhảy
|
|
175
|
+
* 8849 → 8848; cú bấm "Mở trình duyệt" lúc 11:50:48 chết, cú 11:51:34 (sau khi
|
|
176
|
+
* watcher kịp chạy) mở được. Hàm này xoá hẳn khoảng trống đó.
|
|
177
|
+
*
|
|
178
|
+
* Fail-closed vẫn nguyên: dò không ra thì trả null và KHÔNG đặt địa chỉ nào —
|
|
179
|
+
* người gọi tiếp tục gặp NST_AGENT_UNRESOLVED thay vì rơi về :8848 của phiên
|
|
180
|
+
* Windows khác.
|
|
181
|
+
*/
|
|
182
|
+
let _pinnedRefresh = null;
|
|
183
|
+
let _pinnedRefreshAt = 0;
|
|
184
|
+
async function refreshPinnedAgentAddress({ minIntervalMs = 2000, now = Date.now() } = {}) {
|
|
185
|
+
const user = process.env.NST_AGENT_PINNED_USER;
|
|
186
|
+
if (!user) return process.env.NST_API_ADDRESS || null;
|
|
187
|
+
// Nhiều lệnh cùng vấp một lúc (đăng bài + nút bấm) — chỉ dò một lần, cả đám
|
|
188
|
+
// chờ chung kết quả; tasklist + netstat là lệnh nặng, đừng nhân lên.
|
|
189
|
+
if (_pinnedRefresh) return _pinnedRefresh;
|
|
190
|
+
if (now - _pinnedRefreshAt < minIntervalMs) return process.env.NST_API_ADDRESS || null;
|
|
191
|
+
_pinnedRefresh = (async () => {
|
|
192
|
+
try {
|
|
193
|
+
invalidateAgentCache();
|
|
194
|
+
const found = await agentAddressForUser(user);
|
|
195
|
+
if (!found) return null;
|
|
196
|
+
if (process.env.NST_API_ADDRESS !== found.address) {
|
|
197
|
+
console.log(`[nst] agent của "${user}" dò lại tại chỗ: ${found.address} `
|
|
198
|
+
+ `(pid ${found.pid}, session ${found.sessionId}/${found.sessionName})`);
|
|
199
|
+
}
|
|
200
|
+
process.env.NST_API_ADDRESS = found.address;
|
|
201
|
+
return found.address;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
} finally {
|
|
205
|
+
_pinnedRefreshAt = Date.now();
|
|
206
|
+
_pinnedRefresh = null;
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
return _pinnedRefresh;
|
|
210
|
+
}
|
|
211
|
+
|
|
162
212
|
module.exports = {
|
|
163
|
-
listAgents, listAgentsCached, resolveAgentForUser, agentAddressForUser,
|
|
213
|
+
listAgents, listAgentsCached, invalidateAgentCache, resolveAgentForUser, agentAddressForUser,
|
|
214
|
+
refreshPinnedAgentAddress, probeIsNstAgent, lanAddress,
|
|
164
215
|
parseTasklistCsv, parseListening,
|
|
165
216
|
};
|
package/lib/nst-manager.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { listAgentsCached, lanAddress } = require('./nst-agent-locator');
|
|
1
|
+
const { listAgentsCached, lanAddress, refreshPinnedAgentAddress } = require('./nst-agent-locator');
|
|
2
2
|
|
|
3
3
|
function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
|
|
4
4
|
const raw = String(response?.msg || 'Failed to connect browser').trim();
|
|
@@ -51,7 +51,21 @@ class NstManager {
|
|
|
51
51
|
return 'http://localhost:8848/api/v2';
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Có địa chỉ agent chưa? Chưa thì dò lại NGAY thay vì chờ watcher 60s.
|
|
56
|
+
*
|
|
57
|
+
* Không đặt bừa địa chỉ nào khi dò không ra: `baseUrl` vẫn ném
|
|
58
|
+
* NST_AGENT_UNRESOLVED như cũ (fail-closed — mở nhầm agent của phiên Windows
|
|
59
|
+
* khác = profile trắng = báo logout oan cả loạt kênh).
|
|
60
|
+
*/
|
|
61
|
+
async ensureAgent() {
|
|
62
|
+
if (this._apiAddress || process.env.NST_API_ADDRESS) return true;
|
|
63
|
+
if (!process.env.NST_AGENT_PINNED_USER) return true;
|
|
64
|
+
return Boolean(await refreshPinnedAgentAddress());
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
async api(path, options = {}) {
|
|
68
|
+
await this.ensureAgent();
|
|
55
69
|
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
|
56
70
|
const res = await fetch(url, {
|
|
57
71
|
...options,
|
|
@@ -92,6 +106,7 @@ class NstManager {
|
|
|
92
106
|
// Get all running browsers
|
|
93
107
|
async getRunningBrowsers() {
|
|
94
108
|
try {
|
|
109
|
+
await this.ensureAgent();
|
|
95
110
|
const rawRes = await fetch(`${this.baseUrl}/browsers`, {
|
|
96
111
|
headers: { 'x-api-key': this.apiKey },
|
|
97
112
|
});
|
|
@@ -139,6 +154,34 @@ class NstManager {
|
|
|
139
154
|
return { profileId, httpEndpoint, wsEndpoint, port };
|
|
140
155
|
}
|
|
141
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Đưa cửa sổ của một profile ĐANG MỞ lên trước mặt người dùng.
|
|
159
|
+
*
|
|
160
|
+
* Người bấm "Mở trình duyệt" mà profile đã mở sẵn (lượt đăng bài tự động mở
|
|
161
|
+
* trước đó) thì `launchProfile` bỏ qua, không có cửa sổ nào bật lên — trên máy
|
|
162
|
+
* đang có hơn chục browser thì coi như không có gì xảy ra (chủ tịch gặp
|
|
163
|
+
* 16/09/2026 lúc 11:42).
|
|
164
|
+
*
|
|
165
|
+
* Đi qua cổng debug của chính Chrome (`/json/activate/<targetId>` → CDP
|
|
166
|
+
* Target.activateTarget): KHÔNG tốn lượt mở NST và không cần thư viện CDP.
|
|
167
|
+
* → true nếu đã đẩy được cửa sổ lên trước.
|
|
168
|
+
*/
|
|
169
|
+
async bringProfileToFront(profileIdOrName) {
|
|
170
|
+
const ep = await this.resolveRunningEndpoint(profileIdOrName);
|
|
171
|
+
if (!ep) return false;
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(`${ep.httpEndpoint}/json/list`);
|
|
174
|
+
const targets = await res.json();
|
|
175
|
+
const page = (Array.isArray(targets) ? targets : []).find(t => t.type === 'page');
|
|
176
|
+
if (!page || !page.id) return false;
|
|
177
|
+
const act = await fetch(`${ep.httpEndpoint}/json/activate/${page.id}`);
|
|
178
|
+
return act.ok;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.warn(`[nst] bringProfileToFront ${profileIdOrName}: ${e.message}`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
142
185
|
// Check if profile is already running
|
|
143
186
|
async isProfileRunning(profileId) {
|
|
144
187
|
const running = await this.getRunningBrowsers();
|
|
@@ -232,6 +275,7 @@ class NstManager {
|
|
|
232
275
|
if (!proxyUrl) return;
|
|
233
276
|
console.log(`[nst] Setting proxy for ${profileId}: ${proxyUrl}`);
|
|
234
277
|
try {
|
|
278
|
+
await this.ensureAgent();
|
|
235
279
|
const res = await fetch(`${this.baseUrl}/profiles/${profileId}/proxy`, {
|
|
236
280
|
method: 'PUT',
|
|
237
281
|
headers: { 'Content-Type': 'application/json', 'x-api-key': this.apiKey },
|
|
@@ -293,6 +337,7 @@ class NstManager {
|
|
|
293
337
|
|
|
294
338
|
// Launch browser — skip if already running, set proxy if provided
|
|
295
339
|
async launchProfile(profileIdOrName, options = {}) {
|
|
340
|
+
await this.ensureAgent();
|
|
296
341
|
let profileId = profileIdOrName;
|
|
297
342
|
if (!this.isUUID(profileIdOrName)) {
|
|
298
343
|
profileId = await this.ensureProfile(profileIdOrName);
|
package/package.json
CHANGED
|
@@ -997,6 +997,43 @@ async function hasVisibleReelComposer(page) {
|
|
|
997
997
|
}).catch(() => false);
|
|
998
998
|
}
|
|
999
999
|
|
|
1000
|
+
// Dựng câu báo lỗi cho ca "đi thẳng /reels/create/ rồi bị Facebook đá về feed".
|
|
1001
|
+
//
|
|
1002
|
+
// Trước 17/09/2026 chỗ này là MỘT chuỗi cứng luôn khẳng định `widget "Tạo bài
|
|
1003
|
+
// viết" không có mục Thước phim … tài khoản có thể chưa đủ điều kiện đăng
|
|
1004
|
+
// Reels`. Câu đó ĐỔ OAN trong ít nhất hai ca thật cùng ngày:
|
|
1005
|
+
// • "Làm Giàu Từ Chăn Nuôi": log ngay trên nó ghi `page.goto: Timeout
|
|
1006
|
+
// 45000ms` rồi `KHÔNG thấy widget … các region đang có: []` — trang chưa
|
|
1007
|
+
// tải xong chứ tài khoản không sao (chủ tịch xác nhận vẫn đăng nhập bình
|
|
1008
|
+
// thường). Chạy lại là được.
|
|
1009
|
+
// • profile "Aly L": widget CÓ, nhưng là tường CÁ NHÂN chứ không phải Page.
|
|
1010
|
+
// Người vận hành chỉ đọc câu này trên web, không mở log worker — nên câu lỗi
|
|
1011
|
+
// phải tự nói đúng tên bệnh và đúng việc cần làm.
|
|
1012
|
+
function describeReelCreateBounce(wallDiag, afterUrl) {
|
|
1013
|
+
const tail = `/reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector.`;
|
|
1014
|
+
|
|
1015
|
+
// Không dò được tường (page.evaluate ném) → đừng đoán bừa bệnh nào.
|
|
1016
|
+
if (!wallDiag) {
|
|
1017
|
+
return `FB không mở được trình tạo Thước phim: không đọc được tường trang, và ${tail} Cần xem log worker dòng "[fb-pw]" ngay trước lỗi này để biết nguyên nhân.`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// (1) Không có region nào = trang chưa render. Gần như luôn là trang tải
|
|
1021
|
+
// hỏng/timeout, KHÔNG phải chuyện quyền Reels hay đăng nhập.
|
|
1022
|
+
if (!wallDiag.regionSeen) {
|
|
1023
|
+
return `FB không mở được trình tạo Thước phim: TRANG CHƯA TẢI XONG — không có một vùng nội dung nào trên trang chủ (regions=${JSON.stringify(wallDiag.allRegions)}), và ${tail} Thường do mạng/proxy chậm hoặc page.goto timeout; KHÔNG phải tài khoản thiếu quyền đăng Reels. Cách xử: chạy lại lượt đăng; nếu lặp lại nhiều lần thì kiểm proxy của profile này.`;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// (2) Tường CÁ NHÂN: composer cá nhân vốn không có mục Thước phim. Chạy lại
|
|
1027
|
+
// bao nhiêu lần cũng vô ích.
|
|
1028
|
+
if (wallDiag.personalWall) {
|
|
1029
|
+
return `FB không mở được trình tạo Thước phim: profile này đang đăng nhập TÀI KHOẢN CÁ NHÂN chứ không phải Page (nút đang có: ${JSON.stringify(wallDiag.inventory)}), và ${tail} Composer cá nhân không có mục Thước phim — chạy lại vô ích. Cách xử: đăng nhập lại profile và chuyển sang đúng Page.`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// (3) Tường Page thật mà vẫn thiếu mục Thước phim → mới đúng là nghi vấn
|
|
1033
|
+
// quyền đăng Reels của Page.
|
|
1034
|
+
return `FB không mở được trình tạo Thước phim: widget "Tạo bài viết" CÓ nhưng thiếu mục Thước phim (nút đang có: ${JSON.stringify(wallDiag.inventory)}), và ${tail} Cách xử: cần người đăng nhập profile này và thử tạo Thước phim bằng tay — Page có thể chưa đủ điều kiện đăng Reels.`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1000
1037
|
async function runOnce({ page, payload, log }) {
|
|
1001
1038
|
const {
|
|
1002
1039
|
video_url, title, description = '', tags = [],
|
|
@@ -1152,6 +1189,13 @@ async function runOnce({ page, payload, log }) {
|
|
|
1152
1189
|
break;
|
|
1153
1190
|
} catch {}
|
|
1154
1191
|
}
|
|
1192
|
+
// Chẩn đoán của lượt dò tường, giữ lại để câu BÁO LỖI cuối cùng nói đúng
|
|
1193
|
+
// bệnh. Trước 17/09 câu lỗi là một chuỗi CỨNG luôn khẳng định "có widget
|
|
1194
|
+
// mà thiếu mục Thước phim", kể cả khi log ngay trên nó vừa ghi "KHÔNG thấy
|
|
1195
|
+
// widget nào" — người đọc bị đẩy đi kiểm quyền đăng Reels trong khi bệnh
|
|
1196
|
+
// thật là trang chưa tải xong (ca "Làm Giàu Từ Chăn Nuôi" 17/09:
|
|
1197
|
+
// `page.goto: Timeout 45000ms` ngay trước đó, regions = []).
|
|
1198
|
+
let wallDiag = null;
|
|
1155
1199
|
// Fallback — walk the "Tạo bài viết" region in JS, find a button whose
|
|
1156
1200
|
// text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
|
|
1157
1201
|
if (!reelOpened) {
|
|
@@ -1194,6 +1238,17 @@ async function runOnce({ page, payload, log }) {
|
|
|
1194
1238
|
} else {
|
|
1195
1239
|
log('warn', `[fb-pw] có widget "Tạo bài viết" nhưng KHÔNG có mục Thước phim — nút đang có: ${JSON.stringify(probed.inventory)}`);
|
|
1196
1240
|
}
|
|
1241
|
+
wallDiag = {
|
|
1242
|
+
regionSeen: probed.regionSeen,
|
|
1243
|
+
inventory: probed.inventory || [],
|
|
1244
|
+
allRegions: probed.allRegions || [],
|
|
1245
|
+
// Composer của TRANG CÁ NHÂN không bao giờ có mục Thước phim — nhận
|
|
1246
|
+
// ra bằng hai nhãn đặc trưng, không phải bằng việc thiếu nút Reel.
|
|
1247
|
+
// Ca thật 17/09: profile "Aly L" đăng nhập tài khoản cá nhân thay vì
|
|
1248
|
+
// Page → al="Dòng thời gian của Aly L", t="Aly ơi, bạn đang nghĩ gì".
|
|
1249
|
+
personalWall: (probed.inventory || []).some(s =>
|
|
1250
|
+
/Dòng thời gian của |ơi, bạn đang nghĩ gì|'s timeline|What's on your mind/i.test(s)),
|
|
1251
|
+
};
|
|
1197
1252
|
}
|
|
1198
1253
|
if (probed && probed.selector) {
|
|
1199
1254
|
try {
|
|
@@ -1236,7 +1291,12 @@ async function runOnce({ page, payload, log }) {
|
|
|
1236
1291
|
// Thước phim. Sửa selector bao nhiêu cũng vô ích.
|
|
1237
1292
|
await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
|
|
1238
1293
|
if (usedCreateRoute) {
|
|
1239
|
-
|
|
1294
|
+
// BA bệnh khác hẳn nhau, trước 17/09 gộp chung MỘT câu cứng luôn phán
|
|
1295
|
+
// "widget có mà thiếu mục Thước phim" → đổ oan tài khoản, người đọc đi
|
|
1296
|
+
// kiểm quyền đăng Reels trong khi bệnh thật nằm chỗ khác. Nói đúng tên
|
|
1297
|
+
// bệnh ngay trong câu lỗi, vì người vận hành chỉ nhìn thấy câu này trên
|
|
1298
|
+
// web chứ không mở log worker ra đọc.
|
|
1299
|
+
throw new Error(describeReelCreateBounce(wallDiag, afterUrl));
|
|
1240
1300
|
}
|
|
1241
1301
|
throw new Error(`FB Thước phim click navigated to Reels feed (${afterUrl}) — wrong button matched, should be composer modal. Check "Tạo bài viết" widget layout on this Page.`);
|
|
1242
1302
|
}
|
|
@@ -3448,6 +3508,7 @@ module.exports.__testables = {
|
|
|
3448
3508
|
waitForPublishEnabled,
|
|
3449
3509
|
isThumbnailEditorVisible,
|
|
3450
3510
|
hasVisibleReelComposer,
|
|
3511
|
+
describeReelCreateBounce,
|
|
3451
3512
|
SAFE_RETRY_COMPOSER_CLOSED,
|
|
3452
3513
|
navigateFacebookHome,
|
|
3453
3514
|
navigateFacebookReelCreate,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// upload_facebook_photo — publish an IMAGE
|
|
1
|
+
// upload_facebook_photo / upload_facebook_post — publish an IMAGE or TEXT post to a Facebook Page via
|
|
2
2
|
// the "Tạo bài viết" composer (NOT the Reels composer). Driven by the worker
|
|
3
3
|
// daemon's Playwright pipeline; command type upload_facebook_photo_pw →
|
|
4
|
-
// scripts/upload_facebook_photo.js. Payload: { image_url
|
|
4
|
+
// scripts/upload_facebook_photo.js. Payload: { image_url?, caption, tags[] }.
|
|
5
5
|
//
|
|
6
6
|
// Strict-input contract (same as upload_facebook.js): every step throws on
|
|
7
7
|
// failure so the cmd is marked failed — no silent false-success. In particular
|
|
@@ -111,8 +111,8 @@ function composerStillOpen(page, verbs) {
|
|
|
111
111
|
|
|
112
112
|
async function run({ page, payload, log }) {
|
|
113
113
|
const { image_url, caption = '', tags = [] } = payload || {};
|
|
114
|
-
if (!image_url) throw new Error('No image_url provided');
|
|
115
114
|
if (!caption || !caption.trim()) throw new Error('No caption provided');
|
|
115
|
+
const hasImage = !!String(image_url || '').trim();
|
|
116
116
|
|
|
117
117
|
log('info', '[fbphoto] selectors version=2026.07.01a');
|
|
118
118
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
@@ -137,13 +137,15 @@ async function run({ page, payload, log }) {
|
|
|
137
137
|
.map((t) => `#${t}`).join(' ');
|
|
138
138
|
const fullCaption = hashtagLine ? `${caption.trim()}\n\n${hashtagLine}` : caption.trim();
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
const imagePath = await downloadToTemp(image_url, { prefix: 'fbphoto', ext: '.png' });
|
|
142
|
-
log('info', `[fbphoto] image at ${imagePath}`);
|
|
143
|
-
|
|
140
|
+
let imagePath = '';
|
|
144
141
|
const publishVerbs = ['Đăng', 'Đăng bài', 'Post', 'Publish'];
|
|
145
142
|
|
|
146
143
|
try {
|
|
144
|
+
if (hasImage) {
|
|
145
|
+
log('info', '[fbphoto] downloading image to local…');
|
|
146
|
+
imagePath = await downloadToTemp(image_url, { prefix: 'fbphoto', ext: '.png' });
|
|
147
|
+
log('info', `[fbphoto] image at ${imagePath}`);
|
|
148
|
+
}
|
|
147
149
|
// 1) Open the Page wall.
|
|
148
150
|
log('info', '[fbphoto] navigating to facebook.com…');
|
|
149
151
|
await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
@@ -154,8 +156,8 @@ async function run({ page, payload, log }) {
|
|
|
154
156
|
// 2) Open the "Tạo bài viết" composer via the "Ảnh/video" entry (this opens
|
|
155
157
|
// the post composer directly in photo-attach mode). Fall back to the
|
|
156
158
|
// status box ("Bạn đang nghĩ gì") if the photo entry isn't found.
|
|
157
|
-
const
|
|
158
|
-
const photoBtn = await page.evaluate(() => {
|
|
159
|
+
const openComposer = async () => {
|
|
160
|
+
const photoBtn = hasImage ? await page.evaluate(() => {
|
|
159
161
|
const els = document.querySelectorAll("div[role='button'], span, a[role='button']");
|
|
160
162
|
for (const el of els) {
|
|
161
163
|
const t = (el.innerText || '').trim();
|
|
@@ -167,7 +169,7 @@ async function run({ page, payload, log }) {
|
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
return false;
|
|
170
|
-
}).catch(() => false);
|
|
172
|
+
}).catch(() => false) : false;
|
|
171
173
|
if (photoBtn) {
|
|
172
174
|
await page.locator("[__fbphoto_open__='1']").click({ timeout: 4000 }).catch(() => {});
|
|
173
175
|
await page.evaluate(() => document.querySelectorAll('[__fbphoto_open__]').forEach((e) => e.removeAttribute('__fbphoto_open__'))).catch(() => {});
|
|
@@ -178,16 +180,16 @@ async function run({ page, payload, log }) {
|
|
|
178
180
|
if (box) { await box.click({ timeout: 4000 }).catch(() => {}); await pause(page, 1500); return true; }
|
|
179
181
|
return false;
|
|
180
182
|
};
|
|
181
|
-
if (!(await
|
|
183
|
+
if (!(await openComposer())) {
|
|
182
184
|
await dumpFailure(page, 'no-composer-entry', log);
|
|
183
|
-
throw new Error('FB: không tìm thấy
|
|
185
|
+
throw new Error('FB: không tìm thấy ô "Tạo bài viết" trên trang. Kiểm tra layout Page.');
|
|
184
186
|
}
|
|
185
187
|
await pause(page, 2500);
|
|
186
188
|
|
|
187
189
|
// 3) Ensure the composer modal is open; click its "Ảnh/video" if the file
|
|
188
190
|
// input isn't ready yet.
|
|
189
|
-
let fileInput = await page.$("input[type='file'][accept*='image']");
|
|
190
|
-
if (!fileInput) {
|
|
191
|
+
let fileInput = hasImage ? await page.$("input[type='file'][accept*='image']") : null;
|
|
192
|
+
if (hasImage && !fileInput) {
|
|
191
193
|
const inModalPhoto = await page.evaluate(() => {
|
|
192
194
|
const dlg = document.querySelector("[role='dialog']");
|
|
193
195
|
if (!dlg) return false;
|
|
@@ -208,16 +210,18 @@ async function run({ page, payload, log }) {
|
|
|
208
210
|
if (!fileInput) await pause(page, 1000);
|
|
209
211
|
}
|
|
210
212
|
}
|
|
211
|
-
if (!fileInput) {
|
|
213
|
+
if (hasImage && !fileInput) {
|
|
212
214
|
await dumpFailure(page, 'no-file-input', log);
|
|
213
215
|
throw new Error('FB: không tìm thấy ô upload ảnh trong composer.');
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
// 4) Attach the image.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
if (hasImage) {
|
|
220
|
+
log('info', '[fbphoto] attaching image…');
|
|
221
|
+
await setImageFile(page, fileInput, imagePath, log);
|
|
222
|
+
// Wait for the image preview to render inside the composer.
|
|
223
|
+
await pause(page, 4000);
|
|
224
|
+
}
|
|
221
225
|
|
|
222
226
|
// 5) Fill caption into the composer's contenteditable textbox.
|
|
223
227
|
log('info', '[fbphoto] filling caption…');
|
|
@@ -323,7 +327,7 @@ async function run({ page, payload, log }) {
|
|
|
323
327
|
}
|
|
324
328
|
if (await composerStillOpen(page, publishVerbs)) {
|
|
325
329
|
await dumpFailure(page, 'composer-stuck-open', log);
|
|
326
|
-
throw new Error('FB đăng
|
|
330
|
+
throw new Error('FB đăng bài KHÔNG commit — composer vẫn mở sau 3 lần thử (bài CHƯA được đăng).');
|
|
327
331
|
}
|
|
328
332
|
|
|
329
333
|
// 8) Best-effort post URL.
|