channel-worker 2.5.113 → 2.5.118
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 +5 -1
- package/lib/command-poller.js +166 -5
- package/lib/nst-agent-locator.js +52 -1
- package/lib/nst-manager.js +46 -1
- package/lib/shopee-report.js +107 -0
- package/package.json +1 -1
- package/scripts/lib/fb-guard.js +86 -1
- package/scripts/nurture_facebook.js +2 -28
- package/scripts/upload_facebook.js +80 -3
- 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,fetch_affiliate_report';
|
|
79
79
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -107,6 +107,10 @@ class ApiClient {
|
|
|
107
107
|
return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
async affiliateReportResult(rows, meta = {}) {
|
|
111
|
+
return this.request('POST', '/affiliate-report/worker-ingest', { rows, ...meta });
|
|
112
|
+
}
|
|
113
|
+
|
|
110
114
|
// Return the calling daemon's own Worker doc — primarily for reading
|
|
111
115
|
// parallel_limit (the per-daemon scene-generation concurrency cap that
|
|
112
116
|
// replaced the legacy global flowkit_max_concurrent setting).
|
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;
|
|
@@ -118,6 +125,9 @@ class CommandPoller {
|
|
|
118
125
|
case 'get_affiliate_link':
|
|
119
126
|
await this.handleGetAffiliateLink(command);
|
|
120
127
|
break;
|
|
128
|
+
case 'fetch_affiliate_report':
|
|
129
|
+
await this.handleFetchAffiliateReport(command);
|
|
130
|
+
break;
|
|
121
131
|
default:
|
|
122
132
|
// Playwright-based pipeline: any command whose type ends in '_pw'
|
|
123
133
|
// is routed to scripts/<base>.js (BrowserClaw-style automation
|
|
@@ -332,6 +342,59 @@ class CommandPoller {
|
|
|
332
342
|
}
|
|
333
343
|
}
|
|
334
344
|
|
|
345
|
+
async handleFetchAffiliateReport(command) {
|
|
346
|
+
const payload = command.payload || {};
|
|
347
|
+
if (!(await this._ensureNst(command))) return;
|
|
348
|
+
const report = require('./shopee-report');
|
|
349
|
+
const profileName = payload.profile_name
|
|
350
|
+
|| (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
|
|
351
|
+
|| 'Shopee1';
|
|
352
|
+
let conn;
|
|
353
|
+
try {
|
|
354
|
+
conn = await this._connectNstProfileByName(profileName);
|
|
355
|
+
const result = await report.fetchConversionReport(conn.context, {
|
|
356
|
+
days: payload.days || 90,
|
|
357
|
+
log: (message) => console.log(message),
|
|
358
|
+
});
|
|
359
|
+
if (result.status === 'needs_verify') {
|
|
360
|
+
await this.api.updateCommand(command._id, {
|
|
361
|
+
status: 'done',
|
|
362
|
+
result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (result.status !== 'ok') {
|
|
367
|
+
await this.api.updateCommand(command._id, {
|
|
368
|
+
status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
374
|
+
last_report_date: result.last_report_date,
|
|
375
|
+
last_update_time: result.last_update_time,
|
|
376
|
+
from: result.from,
|
|
377
|
+
to: result.to,
|
|
378
|
+
});
|
|
379
|
+
await this.api.updateCommand(command._id, {
|
|
380
|
+
status: 'done',
|
|
381
|
+
result: {
|
|
382
|
+
count: result.rows.length,
|
|
383
|
+
saved: saved?.saved || 0,
|
|
384
|
+
matched: saved?.matched || 0,
|
|
385
|
+
unmatched: saved?.unmatched || 0,
|
|
386
|
+
last_report_date: result.last_report_date,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} dòng; khớp kênh ${saved?.matched || 0}`);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
|
|
392
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
393
|
+
} finally {
|
|
394
|
+
if (conn) conn.disconnect();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
335
398
|
async handlePlaywrightCommand(command) {
|
|
336
399
|
const { runPlaywrightScript } = require('./playwright-runner');
|
|
337
400
|
const payload = command.payload || {};
|
|
@@ -538,6 +601,8 @@ class CommandPoller {
|
|
|
538
601
|
if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
|
|
539
602
|
const candidates = [...this._keptOpenProfiles.entries()]
|
|
540
603
|
.filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
|
|
604
|
+
// Profile người vừa mở tay không phải ứng viên để nhường suất.
|
|
605
|
+
.filter(([id]) => this._humanHoldLeft(id) <= 0)
|
|
541
606
|
.sort((a, b) => a[1] - b[1]);
|
|
542
607
|
let over = this._keptOpenProfiles.size - max;
|
|
543
608
|
for (const [id] of candidates) {
|
|
@@ -558,7 +623,7 @@ class CommandPoller {
|
|
|
558
623
|
// Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
|
|
559
624
|
// hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
|
|
560
625
|
// tiếp vẫn dùng chung một browser như thiết kế cũ.
|
|
561
|
-
_schedulePwClose(profileId, scriptName) {
|
|
626
|
+
_schedulePwClose(profileId, scriptName, delayMs = PW_IDLE_CLOSE_MS) {
|
|
562
627
|
if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
|
|
563
628
|
const prev = this._pwCloseTimers.get(profileId);
|
|
564
629
|
if (prev) clearTimeout(prev);
|
|
@@ -566,13 +631,22 @@ class CommandPoller {
|
|
|
566
631
|
this._pwCloseTimers.delete(profileId);
|
|
567
632
|
// Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
|
|
568
633
|
if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
|
|
634
|
+
// Người vừa mở profile này bằng tay → hoãn tới khi hết giữ chỗ. Đóng ở
|
|
635
|
+
// đây là đóng ngay trước mũi người đang ngồi trong browser.
|
|
636
|
+
const hold = this._humanHoldLeft(profileId);
|
|
637
|
+
if (hold > 0) {
|
|
638
|
+
console.log(`[commands/pw] hoãn đóng ${profileId} — người đang dùng, `
|
|
639
|
+
+ `còn ${Math.ceil(hold / 1000)}s`);
|
|
640
|
+
this._schedulePwClose(profileId, scriptName, hold + 1000);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
569
643
|
try {
|
|
570
644
|
await this.nst.stopProfile(profileId);
|
|
571
|
-
console.log(`[commands/pw] đóng profile ${profileId} sau ${
|
|
645
|
+
console.log(`[commands/pw] đóng profile ${profileId} sau ${delayMs / 1000}s rảnh (${scriptName})`);
|
|
572
646
|
} catch (e) {
|
|
573
647
|
console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
|
|
574
648
|
}
|
|
575
|
-
},
|
|
649
|
+
}, delayMs);
|
|
576
650
|
// Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
|
|
577
651
|
if (typeof t.unref === 'function') t.unref();
|
|
578
652
|
this._pwCloseTimers.set(profileId, t);
|
|
@@ -640,12 +714,43 @@ class CommandPoller {
|
|
|
640
714
|
}
|
|
641
715
|
}
|
|
642
716
|
|
|
643
|
-
const result = await this.
|
|
717
|
+
const result = await this._launchWithAgentRetry(profile_id, { proxy, extensionPath });
|
|
718
|
+
|
|
719
|
+
// Đ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
|
|
720
|
+
// browser thì với người bấm, cú bấm coi như rơi vào hư không. Đẩy cửa sổ
|
|
721
|
+
// sẵn có lên trước và giữ nó lại cho người dùng.
|
|
722
|
+
if (result.alreadyRunning) {
|
|
723
|
+
this._cancelPwClose(profile_id);
|
|
724
|
+
this._holdProfileForHuman(profile_id);
|
|
725
|
+
const fronted = await this.nst.bringProfileToFront(profile_id);
|
|
726
|
+
console.log(`[commands] Profile ${profile_id} đang mở sẵn — `
|
|
727
|
+
+ `${fronted ? 'đã đưa cửa sổ lên trước' : 'KHÔNG đưa được cửa sổ lên trước'}, `
|
|
728
|
+
+ `giữ cho người dùng ${Math.round(HUMAN_HOLD_MS / 60000)} phút`);
|
|
729
|
+
await this.api.updateCommand(command._id, {
|
|
730
|
+
status: 'done',
|
|
731
|
+
result: {
|
|
732
|
+
profile_id: result.profileId,
|
|
733
|
+
already_running: true,
|
|
734
|
+
brought_to_front: fronted,
|
|
735
|
+
launched_at: new Date().toISOString(),
|
|
736
|
+
message: fronted
|
|
737
|
+
? 'Profile đã mở sẵn — đã đưa cửa sổ lên trước trên máy chạy.'
|
|
738
|
+
: '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ở.',
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
644
744
|
console.log(`[commands] Profile ${profile_id} launched${extensionPath ? ' (ext)' : ''}`);
|
|
745
|
+
this._holdProfileForHuman(profile_id);
|
|
645
746
|
|
|
646
747
|
await this.api.updateCommand(command._id, {
|
|
647
748
|
status: 'done',
|
|
648
|
-
result: {
|
|
749
|
+
result: {
|
|
750
|
+
profile_id: result.profileId,
|
|
751
|
+
launched_at: new Date().toISOString(),
|
|
752
|
+
message: 'Đã mở trình duyệt trên máy chạy.',
|
|
753
|
+
},
|
|
649
754
|
});
|
|
650
755
|
} catch (err) {
|
|
651
756
|
console.error(`[commands] Failed to launch profile: ${err.message}`);
|
|
@@ -656,6 +761,60 @@ class CommandPoller {
|
|
|
656
761
|
}
|
|
657
762
|
}
|
|
658
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Mở profile, thử lại khi agent NST chưa dò ra.
|
|
766
|
+
*
|
|
767
|
+
* Agent đổi cổng mỗi lần Nstbrowser khởi động lại (chủ tịch tắt hết profile =
|
|
768
|
+
* app restart), và nó cần vài giây để nghe cổng mới. Không có vòng thử lại
|
|
769
|
+
* 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
|
|
770
|
+
* lại — đúng cảnh 16/09/2026: lệnh 11:50:48 chết, lệnh 11:51:34 chạy ngon.
|
|
771
|
+
* Các lỗi khác (hết slot NST, profile mở ở phiên khác) KHÔNG thử lại: thử lại
|
|
772
|
+
* cũng vậy, mà còn giấu mất lỗi thật.
|
|
773
|
+
*/
|
|
774
|
+
async _launchWithAgentRetry(profileId, options, { attempts = 3, waitMs = 3000 } = {}) {
|
|
775
|
+
let lastErr = null;
|
|
776
|
+
for (let i = 1; i <= attempts; i++) {
|
|
777
|
+
try {
|
|
778
|
+
return await this.nst.launchProfile(profileId, options);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
lastErr = err;
|
|
781
|
+
if (!/NST_AGENT_UNRESOLVED/.test(err.message || '')) throw err;
|
|
782
|
+
if (i === attempts) break;
|
|
783
|
+
console.warn(`[commands] agent NST chưa sẵn sàng (lần ${i}/${attempts}) — `
|
|
784
|
+
+ `dò lại sau ${waitMs / 1000}s`);
|
|
785
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
786
|
+
await refreshPinnedAgentAddress({ minIntervalMs: 0 });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
throw lastErr;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ─── Giữ chỗ cho NGƯỜI ────────────────────────────────────────────────────
|
|
793
|
+
// Profile do người mở bằng tay không được để automation đóng ngang: lượt đăng
|
|
794
|
+
// bài trước đó đã hẹn đóng sau PW_IDLE_CLOSE_MS và cái hẹn đó không biết có
|
|
795
|
+
// người đang ngồi trong browser.
|
|
796
|
+
_cancelPwClose(profileId) {
|
|
797
|
+
if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
|
|
798
|
+
clearTimeout(this._pwCloseTimers.get(profileId));
|
|
799
|
+
this._pwCloseTimers.delete(profileId);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
_holdProfileForHuman(profileId, ms = HUMAN_HOLD_MS) {
|
|
806
|
+
if (!this._humanHold) this._humanHold = new Map();
|
|
807
|
+
this._humanHold.set(profileId, Date.now() + ms);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Còn bao nhiêu ms giữ chỗ cho người (0 = hết/không có). */
|
|
811
|
+
_humanHoldLeft(profileId) {
|
|
812
|
+
if (!this._humanHold || !profileId) return 0;
|
|
813
|
+
const left = (this._humanHold.get(profileId) || 0) - Date.now();
|
|
814
|
+
if (left <= 0) { this._humanHold.delete(profileId); return 0; }
|
|
815
|
+
return left;
|
|
816
|
+
}
|
|
817
|
+
|
|
659
818
|
async handleLaunchVeo3Profile(command) {
|
|
660
819
|
const { nst_profile_id, name, veo3_worker_id, os, proxy } = command.payload || {};
|
|
661
820
|
console.log(`[commands] Launching Veo3 profile: ${nst_profile_id} (${name}) os=${os || 'windows'}`);
|
|
@@ -2173,6 +2332,8 @@ class CommandPoller {
|
|
|
2173
2332
|
// Queue holds work pinned to THIS profile → it's about to be claimed,
|
|
2174
2333
|
// leave the browser open.
|
|
2175
2334
|
if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
|
|
2335
|
+
// Người vừa mở profile này từ web → để yên cho tới hết giữ chỗ.
|
|
2336
|
+
if (this._humanHoldLeft(profileId) > 0 || this._humanHoldLeft(browser.name) > 0) continue;
|
|
2176
2337
|
// Match by UUID or name (renderers use name as nst_profile_id)
|
|
2177
2338
|
const lastActivity = this._profileLastActivity[profileId]
|
|
2178
2339
|
|| (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);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { findAffiliatePage, isBlockedState } = require('./shopee-scraper');
|
|
2
|
+
|
|
3
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
function reportWindow(days = 90, nowMs = Date.now()) {
|
|
6
|
+
const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
|
|
7
|
+
const vnNow = new Date(nowMs + 7 * 3600e3);
|
|
8
|
+
const vnDayStartAsUtc = Date.parse(`${vnNow.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
|
9
|
+
const end = Math.floor(nowMs / 1000);
|
|
10
|
+
const start = Math.floor((vnDayStartAsUtc - 7 * 3600e3 - (safeDays - 1) * 86400e3) / 1000);
|
|
11
|
+
return { start, end, days: safeDays };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchConversionReport(ctx, {
|
|
15
|
+
days = 90,
|
|
16
|
+
pageSize = 100,
|
|
17
|
+
maxPages = 200,
|
|
18
|
+
delayMs = 750,
|
|
19
|
+
timeoutMs = 60000,
|
|
20
|
+
log = null,
|
|
21
|
+
} = {}) {
|
|
22
|
+
let page = findAffiliatePage(ctx.pages());
|
|
23
|
+
let opened = false;
|
|
24
|
+
if (!page) {
|
|
25
|
+
page = await ctx.newPage();
|
|
26
|
+
opened = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!page.url().includes('/report/conversion_report')) {
|
|
31
|
+
await page.goto('https://affiliate.shopee.vn/report/conversion_report', {
|
|
32
|
+
waitUntil: 'domcontentloaded', timeout: timeoutMs,
|
|
33
|
+
}).catch(() => {});
|
|
34
|
+
await sleep(1500);
|
|
35
|
+
}
|
|
36
|
+
if (isBlockedState(page.url(), null)) {
|
|
37
|
+
return { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const window = reportWindow(days);
|
|
41
|
+
const rows = [];
|
|
42
|
+
let total = null;
|
|
43
|
+
let lastReportDate = null;
|
|
44
|
+
let lastUpdateTime = null;
|
|
45
|
+
|
|
46
|
+
const updated = await page.evaluate(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
|
|
49
|
+
method: 'POST', credentials: 'include',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
operationName: 'conversionReportUpdateTime',
|
|
53
|
+
query: 'query conversionReportUpdateTime { conversionReportUpdateTime { lastReportDate lastUpdateTime } }',
|
|
54
|
+
variables: {},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
return await r.json().catch(() => null);
|
|
58
|
+
} catch { return null; }
|
|
59
|
+
});
|
|
60
|
+
lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
|
|
61
|
+
lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
|
|
62
|
+
|
|
63
|
+
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
64
|
+
const result = await page.evaluate(async (q) => {
|
|
65
|
+
const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
|
|
66
|
+
+ `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
|
|
67
|
+
try {
|
|
68
|
+
const r = await fetch(url, { credentials: 'include' });
|
|
69
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { status: 0, error: String(e?.message || e) };
|
|
72
|
+
}
|
|
73
|
+
}, { pageSize, pageNum, start: window.start, end: window.end });
|
|
74
|
+
|
|
75
|
+
if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
|
|
76
|
+
return { status: 'needs_verify', rows, reason: `blocked while reading report (http ${result.status}, code ${result.json?.code})` };
|
|
77
|
+
}
|
|
78
|
+
if (result.status !== 200 || result.json?.code !== 0) {
|
|
79
|
+
return { status: 'failed', rows, reason: result.json?.msg || result.error || `report HTTP ${result.status}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data = result.json?.data || {};
|
|
83
|
+
const batch = Array.isArray(data.list) ? data.list : [];
|
|
84
|
+
total = Number(data.total_count || 0);
|
|
85
|
+
rows.push(...batch);
|
|
86
|
+
if (log) log(`[shopee-report] trang ${pageNum}: ${batch.length} dòng (${rows.length}/${total})`);
|
|
87
|
+
if (!batch.length || rows.length >= total || batch.length < pageSize) break;
|
|
88
|
+
await sleep(delayMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (total != null && rows.length < total) {
|
|
92
|
+
return { status: 'failed', rows, reason: `report pagination stopped at ${rows.length}/${total} rows` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
status: 'ok', rows, total: total ?? rows.length,
|
|
97
|
+
from: new Date(window.start * 1000).toISOString(),
|
|
98
|
+
to: new Date(window.end * 1000).toISOString(),
|
|
99
|
+
last_report_date: lastReportDate,
|
|
100
|
+
last_update_time: lastUpdateTime,
|
|
101
|
+
};
|
|
102
|
+
} finally {
|
|
103
|
+
if (opened) await page.close().catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { fetchConversionReport, reportWindow };
|
package/package.json
CHANGED
package/scripts/lib/fb-guard.js
CHANGED
|
@@ -21,6 +21,8 @@ const CODES = {
|
|
|
21
21
|
banned: 'FB_ACCOUNT_BANNED',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
const PAGE_NOT_CONFIGURED_CODE = 'FB_PAGE_NOT_CONFIGURED';
|
|
25
|
+
|
|
24
26
|
const PUBLISH_BLOCK_PHRASES = [
|
|
25
27
|
'giới hạn tần suất',
|
|
26
28
|
'để bảo vệ cộng đồng khỏi spam',
|
|
@@ -108,6 +110,81 @@ async function assertAccountUsable(page, log) {
|
|
|
108
110
|
throw err;
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
// Is this NST session browsing Facebook as the person or as a Page?
|
|
114
|
+
//
|
|
115
|
+
// This is shared by nurture and publish. Publishing through a personal profile
|
|
116
|
+
// is a configuration error for this product: every channel is expected to own
|
|
117
|
+
// a Facebook Page. Without this guard, the personal "Tạo bài viết" widget
|
|
118
|
+
// (Video trực tiếp / Ảnh-video / Cảm xúc-hoạt động) falls through to
|
|
119
|
+
// /reels/create/. Facebook can still open a PERSONAL Reel composer there, so
|
|
120
|
+
// the upload fails later with a completely misleading "composer closed" error.
|
|
121
|
+
function classifyProfileModeSignals(signals = {}) {
|
|
122
|
+
// Personal-composer evidence wins over the navigation shortcuts. Facebook's
|
|
123
|
+
// Professional mode gives a PERSON `professional_dashboard` too; that exact
|
|
124
|
+
// combination made "thời trang 03" persist `profile_mode: page` even though
|
|
125
|
+
// its composer still showed "Cảm xúc/hoạt động" and no Reel control.
|
|
126
|
+
if (signals.hasFriends || (signals.hasFeelingActivity && !signals.hasReelControl)) return 'personal';
|
|
127
|
+
const clearPage = (signals.proLink || signals.proText) && !signals.hasFriends;
|
|
128
|
+
if (clearPage) return 'page';
|
|
129
|
+
return 'unknown';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readProfileMode(page) {
|
|
133
|
+
const signals = await page.evaluate(() => {
|
|
134
|
+
const navs = [...document.querySelectorAll("[role='navigation']")];
|
|
135
|
+
const navText = navs.map((n) => n.innerText || '').join(' | ');
|
|
136
|
+
const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
|
|
137
|
+
const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
|
|
138
|
+
const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
|
|
139
|
+
const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
|
|
140
|
+
|
|
141
|
+
let hasFeelingActivity = false;
|
|
142
|
+
let hasReelControl = false;
|
|
143
|
+
for (const region of document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết'], [aria-label='Create post'], [aria-label='Create a post']")) {
|
|
144
|
+
const aria = (region.getAttribute('aria-label') || '').trim();
|
|
145
|
+
if (!/tạo bài viết|create (?:a )?post/i.test(aria)) continue;
|
|
146
|
+
for (const el of region.querySelectorAll("[role='button'], button, a")) {
|
|
147
|
+
const label = `${el.innerText || el.textContent || ''}\n${el.getAttribute('aria-label') || ''}`.trim();
|
|
148
|
+
if (/Cảm xúc\s*\/\s*hoạt động|Feeling\s*\/\s*activity|Feeling or activity/i.test(label)) hasFeelingActivity = true;
|
|
149
|
+
if (/^\s*(?:Thước phim|Reels?|Create (?:a )?reel|Tạo thước phim)\s*$/im.test(label)) hasReelControl = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let timelineLabel = '';
|
|
154
|
+
for (const el of document.querySelectorAll("a[aria-label]")) {
|
|
155
|
+
const label = (el.getAttribute('aria-label') || '').trim();
|
|
156
|
+
if (/^Dòng thời gian của\s+|(?:'s|’s) Timeline$/i.test(label)) {
|
|
157
|
+
timelineLabel = label.slice(0, 120);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
proLink, proText, hasFriends, hasFeelingActivity, hasReelControl, timelineLabel,
|
|
163
|
+
navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
|
|
164
|
+
};
|
|
165
|
+
}).catch(() => null);
|
|
166
|
+
|
|
167
|
+
if (!signals) return { mode: 'unknown', isPage: false, why: 'detect failed', navSample: '', timelineLabel: '' };
|
|
168
|
+
const mode = classifyProfileModeSignals(signals);
|
|
169
|
+
return {
|
|
170
|
+
mode,
|
|
171
|
+
isPage: mode === 'page',
|
|
172
|
+
why: `mode=${mode} proLink=${signals.proLink} proText=${signals.proText} friends=${signals.hasFriends} feeling=${signals.hasFeelingActivity} reel=${signals.hasReelControl}`,
|
|
173
|
+
navSample: signals.navSample || '',
|
|
174
|
+
timelineLabel: signals.timelineLabel || '',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function pageNotConfiguredError(profileMode = {}) {
|
|
179
|
+
const who = profileMode.timelineLabel ? ` (${profileMode.timelineLabel})` : '';
|
|
180
|
+
const err = new Error(
|
|
181
|
+
`${PAGE_NOT_CONFIGURED_CODE}: Kênh chưa cấu hình Facebook Page — NST profile đang dùng Facebook dưới dạng trang cá nhân${who}. `
|
|
182
|
+
+ 'Hãy tạo Page, chuyển profile sang Page đó rồi đăng lại.',
|
|
183
|
+
);
|
|
184
|
+
err.code = PAGE_NOT_CONFIGURED_CODE;
|
|
185
|
+
return err;
|
|
186
|
+
}
|
|
187
|
+
|
|
111
188
|
async function readPublishBlock(page) {
|
|
112
189
|
return page.evaluate((phrases) => {
|
|
113
190
|
const text = (document.body?.innerText || '').toLowerCase();
|
|
@@ -120,5 +197,13 @@ function publishRateLimitError(blockHit) {
|
|
|
120
197
|
}
|
|
121
198
|
|
|
122
199
|
module.exports = {
|
|
123
|
-
readAccountState,
|
|
200
|
+
readAccountState,
|
|
201
|
+
assertAccountUsable,
|
|
202
|
+
readProfileMode,
|
|
203
|
+
classifyProfileModeSignals,
|
|
204
|
+
pageNotConfiguredError,
|
|
205
|
+
readPublishBlock,
|
|
206
|
+
publishRateLimitError,
|
|
207
|
+
CODES,
|
|
208
|
+
PAGE_NOT_CONFIGURED_CODE,
|
|
124
209
|
};
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// A dead session (logged out / checkpoint / disabled) throws a CODED error via
|
|
20
20
|
// lib/fb-guard so the API can stop the schedule and raise a notification.
|
|
21
21
|
|
|
22
|
-
const { assertAccountUsable } = require('./lib/fb-guard');
|
|
22
|
+
const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
|
|
23
23
|
const { pick, clickHandle } = require('./lib/dom-pick');
|
|
24
24
|
const { humanMove, humanWheel, humanClick } = require('./lib/human');
|
|
25
25
|
|
|
@@ -435,32 +435,6 @@ async function sideTrip(page, log) {
|
|
|
435
435
|
return true;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
-
// Is this profile browsing AS A PAGE rather than as the person?
|
|
439
|
-
//
|
|
440
|
-
// Why it matters: a Page's feed contains only the Page's own posts, so there is
|
|
441
|
-
// nothing to nurture — measured live: 1 post, feed bottoms out after ~1500px.
|
|
442
|
-
// And a profile already switched into a Page has, by definition, got its Page —
|
|
443
|
-
// which is the whole goal of nurturing. So this ends the session immediately and
|
|
444
|
-
// takes the channel off the schedule.
|
|
445
|
-
//
|
|
446
|
-
// Signal: the left nav carries Page-only entries (Công cụ chuyên nghiệp / Trung
|
|
447
|
-
// tâm quảng cáo / Ads Manager) and lacks the personal "Bạn bè" entry.
|
|
448
|
-
async function detectProfileMode(page) {
|
|
449
|
-
return page.evaluate(() => {
|
|
450
|
-
const navs = [...document.querySelectorAll("[role='navigation']")];
|
|
451
|
-
const navText = navs.map((n) => n.innerText || '').join(' | ');
|
|
452
|
-
const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
|
|
453
|
-
const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
|
|
454
|
-
const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
|
|
455
|
-
const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
|
|
456
|
-
return {
|
|
457
|
-
isPage: (proLink || proText) && !hasFriends,
|
|
458
|
-
why: `proLink=${proLink} proText=${proText} friends=${hasFriends}`,
|
|
459
|
-
navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
|
|
460
|
-
};
|
|
461
|
-
}).catch(() => ({ isPage: false, why: 'detect failed', navSample: '' }));
|
|
462
|
-
}
|
|
463
|
-
|
|
464
438
|
// ONE tab, always.
|
|
465
439
|
//
|
|
466
440
|
// Two things pile tabs up behind a session, and neither is ours: NSTBrowser
|
|
@@ -568,7 +542,7 @@ async function run({ page, context, payload, log }) {
|
|
|
568
542
|
await dismissDialogs(page, log);
|
|
569
543
|
|
|
570
544
|
// Already a Page? Then the goal is met — stop here, don't fake a session.
|
|
571
|
-
const mode = await
|
|
545
|
+
const mode = await readProfileMode(page);
|
|
572
546
|
if (payload.debug) log('info', `[nurture-fb][dbg] mode ${mode.why} nav="${mode.navSample}"`);
|
|
573
547
|
if (mode.isPage) {
|
|
574
548
|
log('info', '[nurture-fb] profile đang dùng dưới dạng TRANG (đã có Page) — bỏ qua, không cần nuôi nữa');
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const { humanMove, humanClick, humanType, humanWheel, pause, randInt: hRandInt, chance: hChance } = require('./lib/human');
|
|
20
|
-
const {
|
|
20
|
+
const {
|
|
21
|
+
assertAccountUsable,
|
|
22
|
+
readProfileMode,
|
|
23
|
+
pageNotConfiguredError,
|
|
24
|
+
readPublishBlock,
|
|
25
|
+
publishRateLimitError,
|
|
26
|
+
} = require('./lib/fb-guard');
|
|
21
27
|
const path = require('path');
|
|
22
28
|
const { downloadToTemp, safeUnlink } = require('./lib/download');
|
|
23
29
|
|
|
@@ -997,6 +1003,43 @@ async function hasVisibleReelComposer(page) {
|
|
|
997
1003
|
}).catch(() => false);
|
|
998
1004
|
}
|
|
999
1005
|
|
|
1006
|
+
// Dựng câu báo lỗi cho ca "đi thẳng /reels/create/ rồi bị Facebook đá về feed".
|
|
1007
|
+
//
|
|
1008
|
+
// 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
|
|
1009
|
+
// viết" không có mục Thước phim … tài khoản có thể chưa đủ điều kiện đăng
|
|
1010
|
+
// Reels`. Câu đó ĐỔ OAN trong ít nhất hai ca thật cùng ngày:
|
|
1011
|
+
// • "Làm Giàu Từ Chăn Nuôi": log ngay trên nó ghi `page.goto: Timeout
|
|
1012
|
+
// 45000ms` rồi `KHÔNG thấy widget … các region đang có: []` — trang chưa
|
|
1013
|
+
// 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
|
|
1014
|
+
// thường). Chạy lại là được.
|
|
1015
|
+
// • profile "Aly L": widget CÓ, nhưng là tường CÁ NHÂN chứ không phải Page.
|
|
1016
|
+
// 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
|
|
1017
|
+
// phải tự nói đúng tên bệnh và đúng việc cần làm.
|
|
1018
|
+
function describeReelCreateBounce(wallDiag, afterUrl) {
|
|
1019
|
+
const tail = `/reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector.`;
|
|
1020
|
+
|
|
1021
|
+
// Không dò được tường (page.evaluate ném) → đừng đoán bừa bệnh nào.
|
|
1022
|
+
if (!wallDiag) {
|
|
1023
|
+
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.`;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// (1) Không có region nào = trang chưa render. Gần như luôn là trang tải
|
|
1027
|
+
// hỏng/timeout, KHÔNG phải chuyện quyền Reels hay đăng nhập.
|
|
1028
|
+
if (!wallDiag.regionSeen) {
|
|
1029
|
+
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.`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// (2) Tường CÁ NHÂN: composer cá nhân vốn không có mục Thước phim. Chạy lại
|
|
1033
|
+
// bao nhiêu lần cũng vô ích.
|
|
1034
|
+
if (wallDiag.personalWall) {
|
|
1035
|
+
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.`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// (3) Tường Page thật mà vẫn thiếu mục Thước phim → mới đúng là nghi vấn
|
|
1039
|
+
// quyền đăng Reels của Page.
|
|
1040
|
+
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.`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1000
1043
|
async function runOnce({ page, payload, log }) {
|
|
1001
1044
|
const {
|
|
1002
1045
|
video_url, title, description = '', tags = [],
|
|
@@ -1086,6 +1129,16 @@ async function runOnce({ page, payload, log }) {
|
|
|
1086
1129
|
// code and opens the Facebook auto-publish circuit breaker.
|
|
1087
1130
|
await assertAccountUsable(page, log);
|
|
1088
1131
|
|
|
1132
|
+
// This uploader publishes to a Facebook PAGE. A personal account can also
|
|
1133
|
+
// open /reels/create/, which used to let an unconfigured channel get deep
|
|
1134
|
+
// into the wizard and finally surface as the unrelated "composer closed"
|
|
1135
|
+
// retry error. Stop before touching the video and tell the operator what is
|
|
1136
|
+
// actually missing. Unknown layouts continue to the existing DOM probes so
|
|
1137
|
+
// a Facebook A/B change does not falsely block a valid Page.
|
|
1138
|
+
const profileMode = await readProfileMode(page);
|
|
1139
|
+
log('info', `[fb-pw] Facebook identity check: ${profileMode.why}${profileMode.timelineLabel ? ` timeline="${profileMode.timelineLabel}"` : ''}`);
|
|
1140
|
+
if (profileMode.mode === 'personal') throw pageNotConfiguredError(profileMode);
|
|
1141
|
+
|
|
1089
1142
|
// Look around before posting. The old flow was goto → 4s → straight into
|
|
1090
1143
|
// "Thước phim", every single run: a session whose only actions are
|
|
1091
1144
|
// open→post→leave. A person glances at the wall first. 2-5 wheel bursts
|
|
@@ -1152,6 +1205,13 @@ async function runOnce({ page, payload, log }) {
|
|
|
1152
1205
|
break;
|
|
1153
1206
|
} catch {}
|
|
1154
1207
|
}
|
|
1208
|
+
// 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
|
|
1209
|
+
// bệnh. Trước 17/09 câu lỗi là một chuỗi CỨNG luôn khẳng định "có widget
|
|
1210
|
+
// mà thiếu mục Thước phim", kể cả khi log ngay trên nó vừa ghi "KHÔNG thấy
|
|
1211
|
+
// widget nào" — người đọc bị đẩy đi kiểm quyền đăng Reels trong khi bệnh
|
|
1212
|
+
// thật là trang chưa tải xong (ca "Làm Giàu Từ Chăn Nuôi" 17/09:
|
|
1213
|
+
// `page.goto: Timeout 45000ms` ngay trước đó, regions = []).
|
|
1214
|
+
let wallDiag = null;
|
|
1155
1215
|
// Fallback — walk the "Tạo bài viết" region in JS, find a button whose
|
|
1156
1216
|
// text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
|
|
1157
1217
|
if (!reelOpened) {
|
|
@@ -1194,6 +1254,17 @@ async function runOnce({ page, payload, log }) {
|
|
|
1194
1254
|
} else {
|
|
1195
1255
|
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
1256
|
}
|
|
1257
|
+
wallDiag = {
|
|
1258
|
+
regionSeen: probed.regionSeen,
|
|
1259
|
+
inventory: probed.inventory || [],
|
|
1260
|
+
allRegions: probed.allRegions || [],
|
|
1261
|
+
// Composer của TRANG CÁ NHÂN không bao giờ có mục Thước phim — nhận
|
|
1262
|
+
// ra bằng hai nhãn đặc trưng, không phải bằng việc thiếu nút Reel.
|
|
1263
|
+
// Ca thật 17/09: profile "Aly L" đăng nhập tài khoản cá nhân thay vì
|
|
1264
|
+
// Page → al="Dòng thời gian của Aly L", t="Aly ơi, bạn đang nghĩ gì".
|
|
1265
|
+
personalWall: (probed.inventory || []).some(s =>
|
|
1266
|
+
/Dòng thời gian của |ơi, bạn đang nghĩ gì|'s timeline|What's on your mind/i.test(s)),
|
|
1267
|
+
};
|
|
1197
1268
|
}
|
|
1198
1269
|
if (probed && probed.selector) {
|
|
1199
1270
|
try {
|
|
@@ -1236,7 +1307,12 @@ async function runOnce({ page, payload, log }) {
|
|
|
1236
1307
|
// Thước phim. Sửa selector bao nhiêu cũng vô ích.
|
|
1237
1308
|
await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
|
|
1238
1309
|
if (usedCreateRoute) {
|
|
1239
|
-
|
|
1310
|
+
// 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
|
|
1311
|
+
// "widget có mà thiếu mục Thước phim" → đổ oan tài khoản, người đọc đi
|
|
1312
|
+
// kiểm quyền đăng Reels trong khi bệnh thật nằm chỗ khác. Nói đúng tên
|
|
1313
|
+
// 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
|
|
1314
|
+
// web chứ không mở log worker ra đọc.
|
|
1315
|
+
throw new Error(describeReelCreateBounce(wallDiag, afterUrl));
|
|
1240
1316
|
}
|
|
1241
1317
|
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
1318
|
}
|
|
@@ -3405,7 +3481,7 @@ async function run(args) {
|
|
|
3405
3481
|
} catch (e) {
|
|
3406
3482
|
// A checkpoint/logout can appear mid-composer. Prefer the coded account
|
|
3407
3483
|
// error over the selector symptom so the server stops unattended retries.
|
|
3408
|
-
if (!/^FB_(?:ACCOUNT|PUBLISH)_/.test(String(e?.message || ''))) {
|
|
3484
|
+
if (!/^FB_(?:ACCOUNT|PUBLISH|PAGE)_/.test(String(e?.message || ''))) {
|
|
3409
3485
|
try { await assertAccountUsable(page, log); } catch (accountErr) { throw accountErr; }
|
|
3410
3486
|
}
|
|
3411
3487
|
const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
|
|
@@ -3448,6 +3524,7 @@ module.exports.__testables = {
|
|
|
3448
3524
|
waitForPublishEnabled,
|
|
3449
3525
|
isThumbnailEditorVisible,
|
|
3450
3526
|
hasVisibleReelComposer,
|
|
3527
|
+
describeReelCreateBounce,
|
|
3451
3528
|
SAFE_RETRY_COMPOSER_CLOSED,
|
|
3452
3529
|
navigateFacebookHome,
|
|
3453
3530
|
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.
|