channel-worker 2.5.92 → 2.5.94
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 +7 -1
- package/lib/command-poller.js +161 -2
- package/lib/nst-manager.js +32 -0
- package/lib/playwright-runner.js +23 -9
- package/lib/shopee-scraper.js +94 -2
- package/lib/stats-syncer.js +25 -6
- package/package.json +1 -1
- package/scripts/upload_facebook.js +151 -66
package/lib/api-client.js
CHANGED
|
@@ -68,7 +68,7 @@ class ApiClient {
|
|
|
68
68
|
async getNextCommand(workerId) {
|
|
69
69
|
// Daemon-handled types. `_pw` variants route to the Playwright pipeline
|
|
70
70
|
// (lib/playwright-runner → scripts/<base>.js) instead of the extension.
|
|
71
|
-
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,scrape_affiliate_products,ingest_shopee_product';
|
|
71
|
+
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,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
|
|
72
72
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -94,6 +94,12 @@ class ApiClient {
|
|
|
94
94
|
return this.request('POST', '/products/worker-ingest', { product, ...meta });
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Trả LINK AFFILIATE thật về kho. `sub_ids` đi kèm để sau này biết link này
|
|
98
|
+
// gắn cho kênh/video nào — đó là cả lý do link phải sinh riêng từng bài.
|
|
99
|
+
async affiliateLinkResult(links, { user_id, sub_ids, idea_id } = {}) {
|
|
100
|
+
return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
// Return the calling daemon's own Worker doc — primarily for reading
|
|
98
104
|
// parallel_limit (the per-daemon scene-generation concurrency cap that
|
|
99
105
|
// replaced the legacy global flowkit_max_concurrent setting).
|
package/lib/command-poller.js
CHANGED
|
@@ -115,6 +115,9 @@ class CommandPoller {
|
|
|
115
115
|
case 'ingest_shopee_product':
|
|
116
116
|
await this.handleIngestShopeeProduct(command);
|
|
117
117
|
break;
|
|
118
|
+
case 'get_affiliate_link':
|
|
119
|
+
await this.handleGetAffiliateLink(command);
|
|
120
|
+
break;
|
|
118
121
|
default:
|
|
119
122
|
// Playwright-based pipeline: any command whose type ends in '_pw'
|
|
120
123
|
// is routed to scripts/<base>.js (BrowserClaw-style automation
|
|
@@ -252,6 +255,59 @@ class CommandPoller {
|
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
// Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
|
|
259
|
+
// kèm sub-id để đo click/đơn về tới từng kênh và từng video.
|
|
260
|
+
// Open API chính thức đã bị Shopee từ chối, nên phải đi qua phiên đăng nhập
|
|
261
|
+
// như mọi việc Shopee khác — xem ghi chú ở shopee-scraper.generateOfferLink.
|
|
262
|
+
async handleGetAffiliateLink(command) {
|
|
263
|
+
const payload = command.payload || {};
|
|
264
|
+
if (!(await this._ensureNst(command))) return;
|
|
265
|
+
const scraper = require('./shopee-scraper');
|
|
266
|
+
const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
|
|
267
|
+
|
|
268
|
+
let items = Array.isArray(payload.items) ? payload.items : [];
|
|
269
|
+
if (!items.length && payload.url) {
|
|
270
|
+
const ids = scraper.parseProductUrl(payload.url);
|
|
271
|
+
if (ids) items = [ids];
|
|
272
|
+
}
|
|
273
|
+
if (!items.length && payload.shop_id && payload.item_id) items = [{ shop_id: payload.shop_id, item_id: payload.item_id }];
|
|
274
|
+
if (!items.length) {
|
|
275
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: 'cần items[] hoặc url hoặc {shop_id,item_id}' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let conn;
|
|
280
|
+
try {
|
|
281
|
+
conn = await this._connectNstProfileByName(profileName);
|
|
282
|
+
const res = await scraper.generateOfferLink(conn.context, {
|
|
283
|
+
items,
|
|
284
|
+
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
285
|
+
});
|
|
286
|
+
if (res.status === 'needs_verify') {
|
|
287
|
+
// Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
|
|
288
|
+
console.warn(`[shopee] lấy link: needs_verify — ${res.reason}`);
|
|
289
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason } });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (res.status !== 'ok') {
|
|
293
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
297
|
+
await this.api.affiliateLinkResult(res.links, {
|
|
298
|
+
user_id: command.user_id,
|
|
299
|
+
sub_ids: res.sub_ids,
|
|
300
|
+
idea_id: payload.idea_id || null,
|
|
301
|
+
});
|
|
302
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(`[shopee] lấy link thất bại: ${err.message}`);
|
|
305
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
306
|
+
} finally {
|
|
307
|
+
if (conn) conn.disconnect();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
255
311
|
async handlePlaywrightCommand(command) {
|
|
256
312
|
const { runPlaywrightScript } = require('./playwright-runner');
|
|
257
313
|
const payload = command.payload || {};
|
|
@@ -277,13 +333,29 @@ class CommandPoller {
|
|
|
277
333
|
}
|
|
278
334
|
if (!this._pwInFlight) this._pwInFlight = new Map();
|
|
279
335
|
const MUTEX_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
336
|
+
// Nhịp báo còn sống trong lúc XẾP HÀNG. Bộ dọn của API coi lệnh đăng
|
|
337
|
+
// 'running' quá 20 phút là "worker crash giữa chừng": nó đánh failed +
|
|
338
|
+
// ambiguous, idea bị RÚT khỏi kho tự động và bắt người vào Facebook kiểm —
|
|
339
|
+
// trong khi bài còn chưa hề được thử đăng. Hai ngưỡng đá nhau (chờ ở đây
|
|
340
|
+
// tối đa 30 phút > 20 phút của bộ dọn), đo prod 2026-09-09 tối: 6 bài bị
|
|
341
|
+
// khai tử oan trong 7 phút (19:14→19:21) khi lệnh dồn cục vào một profile.
|
|
342
|
+
// Chạm vào lệnh mỗi 2 phút là đủ giữ `updatedAt` tươi mà không nện API.
|
|
343
|
+
const MUTEX_BEAT_MS = 2 * 60 * 1000;
|
|
280
344
|
const waitStart = Date.now();
|
|
345
|
+
let lastBeat = Date.now();
|
|
281
346
|
while (this._pwInFlight.has(profileId)) {
|
|
282
347
|
if (Date.now() - waitStart > MUTEX_MAX_WAIT_MS) {
|
|
283
348
|
await this.api.updateCommand(command._id, { status: 'failed', error: `pw mutex timeout — profile ${profileId} busy with ${this._pwInFlight.get(profileId)} for >30min` });
|
|
284
349
|
return;
|
|
285
350
|
}
|
|
286
351
|
console.log(`[commands/pw] ${command.type} waiting — profile ${profileId} busy with ${this._pwInFlight.get(profileId)}`);
|
|
352
|
+
if (Date.now() - lastBeat > MUTEX_BEAT_MS) {
|
|
353
|
+
lastBeat = Date.now();
|
|
354
|
+
const waitedMin = Math.round((Date.now() - waitStart) / 60000);
|
|
355
|
+
await this.api.updateCommand(command._id, { status: 'running' })
|
|
356
|
+
.then(() => console.log(`[commands/pw] ${command.type} vẫn đang xếp hàng ${waitedMin} phút — báo còn sống để bộ dọn không khai tử`))
|
|
357
|
+
.catch(() => {});
|
|
358
|
+
}
|
|
287
359
|
await new Promise(r => setTimeout(r, 5000));
|
|
288
360
|
}
|
|
289
361
|
this._pwInFlight.set(profileId, command.type);
|
|
@@ -323,6 +395,8 @@ class CommandPoller {
|
|
|
323
395
|
platform: scriptName.replace(/^upload_/, ''),
|
|
324
396
|
});
|
|
325
397
|
|
|
398
|
+
const keepOpen = await this._keepOpenCfg();
|
|
399
|
+
|
|
326
400
|
try {
|
|
327
401
|
const result = await runPlaywrightScript({
|
|
328
402
|
nst: this.nst,
|
|
@@ -330,6 +404,7 @@ class CommandPoller {
|
|
|
330
404
|
scriptName,
|
|
331
405
|
payload,
|
|
332
406
|
log,
|
|
407
|
+
keepOpen: keepOpen.enabled,
|
|
333
408
|
});
|
|
334
409
|
// postCommandResult (not updateCommand) so the existing extension-side
|
|
335
410
|
// logic mirrors status into ContentJob + ContentIdea.publish_results +
|
|
@@ -356,7 +431,17 @@ class CommandPoller {
|
|
|
356
431
|
// Scoped to the browse-only scripts. Publish keeps its profile open on
|
|
357
432
|
// purpose: one profile serves several platform uploads back-to-back.
|
|
358
433
|
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
|
|
359
|
-
|
|
434
|
+
// Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
|
|
435
|
+
// đời đóng-mở bình thường ngay từ lượt này.
|
|
436
|
+
if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
|
|
437
|
+
if (keepOpen.enabled && this.nst) {
|
|
438
|
+
// Máy bị chặn số lượt mở/ngày: KHÔNG đóng, ghi sổ để lượt sau nối lại
|
|
439
|
+
// vào đúng browser này. Chỉ trần `keep_open_max` mới được đóng bớt.
|
|
440
|
+
if (!this._keptOpenProfiles) this._keptOpenProfiles = new Map();
|
|
441
|
+
this._keptOpenProfiles.set(profileId, Date.now());
|
|
442
|
+
console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
|
|
443
|
+
await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
|
|
444
|
+
} else if (CLOSE_AFTER.has(scriptName) && this.nst) {
|
|
360
445
|
try {
|
|
361
446
|
await this.nst.stopProfile(profileId);
|
|
362
447
|
console.log(`[commands/pw] closed profile ${profileId} after ${scriptName}`);
|
|
@@ -379,6 +464,62 @@ class CommandPoller {
|
|
|
379
464
|
}
|
|
380
465
|
}
|
|
381
466
|
|
|
467
|
+
// ─── Giữ profile mở (máy bị chặn số lượt mở/ngày) ─────────────────────────
|
|
468
|
+
// Gói Nstbrowser không nâng cấp chặn ~30 LƯỢT MỞ/ngày (relabs03, acc
|
|
469
|
+
// maithithu). Mặc định daemon đóng profile sau mỗi việc rồi mở lại cho việc
|
|
470
|
+
// sau → mỗi task tốn 1 lượt, cháy hạn mức từ giữa ngày. Cờ `keep_profiles_open`
|
|
471
|
+
// trên hàng Worker (bật trong web, trang Workers) đổi hành vi thành: giữ
|
|
472
|
+
// browser mở và tái dùng, mỗi profile chỉ tốn ĐÚNG 1 lượt/ngày.
|
|
473
|
+
//
|
|
474
|
+
// Đọc qua /workers/me, cache 30s — đổi cờ trên web có tác dụng trong nửa phút,
|
|
475
|
+
// không cần restart daemon.
|
|
476
|
+
async _keepOpenCfg() {
|
|
477
|
+
const CACHE_MS = 30 * 1000;
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
if (this._keepOpenCache && (now - this._keepOpenCache.at) < CACHE_MS) return this._keepOpenCache;
|
|
480
|
+
// Không đọc được /workers/me → fail-safe về hành vi CŨ (vẫn đóng profile).
|
|
481
|
+
// Giữ mở nhầm nguy hiểm hơn đóng nhầm: profile mở tích luỹ sẽ ngốn hết RAM.
|
|
482
|
+
let cfg = { at: now, enabled: false, max: 6 };
|
|
483
|
+
try {
|
|
484
|
+
if (this.api.getMyWorker) {
|
|
485
|
+
const me = await this.api.getMyWorker();
|
|
486
|
+
if (me) {
|
|
487
|
+
cfg.enabled = !!me.keep_profiles_open;
|
|
488
|
+
const n = parseInt(me.keep_open_max, 10);
|
|
489
|
+
if (!Number.isNaN(n) && n >= 1) cfg.max = n;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch (e) {
|
|
493
|
+
if (this.config.verbose) console.warn('[keep-open] getMyWorker failed, giữ hành vi cũ:', e.message);
|
|
494
|
+
}
|
|
495
|
+
this._keepOpenCache = cfg;
|
|
496
|
+
return cfg;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Trần RAM cho chế độ giữ mở: quá `max` thì đóng profile RẢNH LÂU NHẤT.
|
|
500
|
+
// Chỉ đụng tới profile do luồng _pw giữ (sổ `_keptOpenProfiles`) — KHÔNG đụng
|
|
501
|
+
// profile renderer, vòng đời của chúng do scene-dispatch quản.
|
|
502
|
+
async _trimKeptProfiles(max) {
|
|
503
|
+
if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
|
|
504
|
+
const candidates = [...this._keptOpenProfiles.entries()]
|
|
505
|
+
.filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
|
|
506
|
+
.sort((a, b) => a[1] - b[1]);
|
|
507
|
+
let over = this._keptOpenProfiles.size - max;
|
|
508
|
+
for (const [id] of candidates) {
|
|
509
|
+
if (over <= 0) break;
|
|
510
|
+
try {
|
|
511
|
+
await this.nst.stopProfile(id);
|
|
512
|
+
console.log(`[keep-open] đóng ${id} — rảnh lâu nhất, quá trần ${max} profile giữ mở`);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
console.warn(`[keep-open] đóng ${id} lỗi: ${e.message}`);
|
|
515
|
+
}
|
|
516
|
+
// Xoá khỏi sổ dù đóng lỗi: giữ lại chỉ khiến vòng sau thử lại vô ích và
|
|
517
|
+
// chặn mất suất của profile khác.
|
|
518
|
+
this._keptOpenProfiles.delete(id);
|
|
519
|
+
over--;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
382
523
|
// Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
|
|
383
524
|
// hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
|
|
384
525
|
// tiếp vẫn dùng chung một browser như thiết kế cũ.
|
|
@@ -1319,6 +1460,16 @@ class CommandPoller {
|
|
|
1319
1460
|
|
|
1320
1461
|
async handleCloseProfile(command) {
|
|
1321
1462
|
const { profile_id } = command.payload || {};
|
|
1463
|
+
// Máy bị chặn số lượt mở/ngày: bỏ qua lệnh đóng của API. KHÔNG nhả lease
|
|
1464
|
+
// kèm theo — profile vẫn đang mở ở máy này, nhả lease là mời máy khác mở
|
|
1465
|
+
// cùng profile → hai browser một tài khoản, ghi đè cookie của nhau.
|
|
1466
|
+
// Lease tự hết hạn sau TTL 10 phút nếu máy này thật sự bỏ việc.
|
|
1467
|
+
const keepOpen = await this._keepOpenCfg();
|
|
1468
|
+
if (keepOpen.enabled) {
|
|
1469
|
+
console.log(`[keep-open] bỏ qua close_profile ${profile_id} — máy đang bật chế độ giữ profile mở`);
|
|
1470
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { skipped: 'keep_profiles_open' } });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1322
1473
|
console.log(`[commands] Closing profile: ${profile_id}`);
|
|
1323
1474
|
try {
|
|
1324
1475
|
// NstManager exposes stopProfile — there has never been a closeProfile.
|
|
@@ -1440,7 +1591,8 @@ class CommandPoller {
|
|
|
1440
1591
|
// Pass youtube_handle from command payload
|
|
1441
1592
|
channel.youtube_handle = youtube_handle || null;
|
|
1442
1593
|
|
|
1443
|
-
const
|
|
1594
|
+
const keepOpen = await this._keepOpenCfg();
|
|
1595
|
+
const syncer = new StatsSyncer(this.nst, this.api, { keepOpen: keepOpen.enabled });
|
|
1444
1596
|
const stats = await syncer.syncYouTubeStats(channel);
|
|
1445
1597
|
|
|
1446
1598
|
await this.api.updateCommand(command._id, {
|
|
@@ -1946,6 +2098,13 @@ class CommandPoller {
|
|
|
1946
2098
|
async _checkProfileTimeouts() {
|
|
1947
2099
|
try {
|
|
1948
2100
|
if (!this.nst) return;
|
|
2101
|
+
// 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
|
|
2102
|
+
// lần nó đóng là lần sau tốn thêm 1 lượt mở. Tắt hẳn; trần RAM đã do
|
|
2103
|
+
// `keep_open_max` lo. (Vòng xoay round-robin của scene-dispatch thì KHÔNG
|
|
2104
|
+
// tắt — cái đó là chống captcha "2 phiên/1 tài khoản Google", an toàn tài
|
|
2105
|
+
// khoản đứng trên hạn mức lượt mở.)
|
|
2106
|
+
const keepOpen = await this._keepOpenCfg();
|
|
2107
|
+
if (keepOpen.enabled) return;
|
|
1949
2108
|
|
|
1950
2109
|
const IDLE_TIMEOUT = 30 * 1000; // 30s — matches old extension-side behavior
|
|
1951
2110
|
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,
|
|
@@ -281,6 +292,87 @@ async function scrapeOffers(ctx, {
|
|
|
281
292
|
}
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
// Chuẩn hoá một sub-id: CHỈ CHỮ VÀ SỐ. Đo 09/09/2026 — gửi `kenh_C` (có gạch
|
|
296
|
+
// dưới) thì Shopee trả `businessCode:282800002` và data null, còn `kenhB` thì
|
|
297
|
+
// 200 ra link. Placeholder của chính Shopee cũng là SportShoes / InstagramFeed
|
|
298
|
+
// / 1212BirthdaySale. Cắt sạch dấu thay vì để lệnh chết giữa chừng.
|
|
299
|
+
function cleanSubId(v) {
|
|
300
|
+
return String(v == null ? '' : v)
|
|
301
|
+
.replace(/đ/g, 'd').replace(/Đ/g, 'D') // 'đ' KHÔNG tách được bằng NFD (ký tự riêng, không phải chữ + dấu tổ hợp) — bỏ qua là "đợt" thành "ot", hai mã khác nhau có thể trùng nhau
|
|
302
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // bỏ dấu tiếng Việt
|
|
303
|
+
.replace(/[^A-Za-z0-9]/g, '')
|
|
304
|
+
.slice(0, 50);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sinh LINK AFFILIATE THẬT cho một hoặc nhiều sản phẩm.
|
|
308
|
+
//
|
|
309
|
+
// Đây là đường DUY NHẤT còn dùng được: Open API chính thức đã bị Shopee từ chối
|
|
310
|
+
// ("chỉ hỗ trợ một số nhóm KOL/KOC có nhân viên hỗ trợ trực tiếp", trả lời
|
|
311
|
+
// 09/09/2026), nên phải gọi trong phiên đăng nhập như mọi thứ khác ở file này.
|
|
312
|
+
//
|
|
313
|
+
// Bắt được payload bằng cách bấm thật nút Get Link → tick Advanced → điền 5 ô
|
|
314
|
+
// subId → "Add to Link". Link trả về dạng `s.shopee.vn/<mã>`, redirect qua
|
|
315
|
+
// `/opaanlp/` — KHÁC hẳn `long_link` của offer list (universal-link + chữ ký
|
|
316
|
+
// Google Ads, không qua đường ghi nhận affiliate nên không ra hoa hồng).
|
|
317
|
+
//
|
|
318
|
+
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
319
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000 } = {}) {
|
|
320
|
+
const list = (items || [])
|
|
321
|
+
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
322
|
+
.filter((it) => it.itemId && it.shopId);
|
|
323
|
+
if (!list.length) throw new Error('generateOfferLink cần ít nhất một {shop_id,item_id}');
|
|
324
|
+
|
|
325
|
+
const subs = {};
|
|
326
|
+
for (let i = 0; i < 5; i++) subs[`subId${i + 1}`] = cleanSubId(subIds[i]);
|
|
327
|
+
|
|
328
|
+
const pagesOpen = ctx.pages();
|
|
329
|
+
let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
|
|
330
|
+
let opened = false;
|
|
331
|
+
if (!page) { page = await ctx.newPage(); opened = true; }
|
|
332
|
+
try {
|
|
333
|
+
if (!page.url().includes('affiliate.shopee.vn')) {
|
|
334
|
+
await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
|
|
335
|
+
await sleep(1500);
|
|
336
|
+
}
|
|
337
|
+
if (isBlockedState(page.url(), null)) {
|
|
338
|
+
return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
339
|
+
}
|
|
340
|
+
const res = await page.evaluate(async (q) => {
|
|
341
|
+
const body = {
|
|
342
|
+
operationName: 'batchGetProductOfferLink',
|
|
343
|
+
query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
|
|
344
|
+
variables: {
|
|
345
|
+
productOfferLinkParams: q.list,
|
|
346
|
+
sourceCaller: 'WEB_SITE_CALLER',
|
|
347
|
+
advancedLinkParams: q.subs,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
const r = await fetch('/api/v3/gql?q=productOfferLinks', {
|
|
352
|
+
method: 'POST', credentials: 'include',
|
|
353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
354
|
+
body: JSON.stringify(body),
|
|
355
|
+
});
|
|
356
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
357
|
+
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
358
|
+
}, { list, subs });
|
|
359
|
+
|
|
360
|
+
if (isBlockedState(page.url(), res)) {
|
|
361
|
+
return { status: 'needs_verify', links: [], reason: `blocked (http ${res.status})` };
|
|
362
|
+
}
|
|
363
|
+
const err = res.json?.errors?.[0]?.message;
|
|
364
|
+
if (err) return { status: 'failed', links: [], reason: err };
|
|
365
|
+
const rows = res.json?.data?.productOfferLinks || [];
|
|
366
|
+
return {
|
|
367
|
+
status: 'ok',
|
|
368
|
+
sub_ids: subs,
|
|
369
|
+
links: rows.map((r) => ({ item_id: String(r.itemId), shop_id: String(r.shopId), link: r.productOfferLink })),
|
|
370
|
+
};
|
|
371
|
+
} finally {
|
|
372
|
+
if (opened) await page.close().catch(() => {});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
284
376
|
// Parse a Shopee product URL → { shop_id, item_id }. Accepts
|
|
285
377
|
// /product/<shop>/<item> and i.<shop>.<item> forms.
|
|
286
378
|
function parseProductUrl(url = '') {
|
|
@@ -291,4 +383,4 @@ function parseProductUrl(url = '') {
|
|
|
291
383
|
return null;
|
|
292
384
|
}
|
|
293
385
|
|
|
294
|
-
module.exports = { ingestProduct, scrapeOffers, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
|
|
386
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
|
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/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
|
}
|
|
@@ -2784,35 +2869,24 @@ async function runOnce({ page, payload, log }) {
|
|
|
2784
2869
|
}
|
|
2785
2870
|
}
|
|
2786
2871
|
|
|
2787
|
-
// (a.3) LAST-RESORT
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2791
|
-
//
|
|
2792
|
-
//
|
|
2793
|
-
//
|
|
2794
|
-
//
|
|
2795
|
-
//
|
|
2872
|
+
// (a.3) ĐÃ GỠ nhánh LAST-RESORT "bốc tile đầu tab Reels" — 2026-09-09.
|
|
2873
|
+
// Nó là nguồn của mọi link sai: đo prod 30 ngày, 97 video mang link
|
|
2874
|
+
// của BÀI KHÁC (~10% số link), riêng 09/09 là 15 ca; log win-worker
|
|
2875
|
+
// cho thấy nhánh này chạy 250 lần mà bộ lọc "chắc chắn reel cũ" chỉ
|
|
2876
|
+
// chặn được 6, relabs03 chạy 107 lần chặn được 0. Bộ lọc vô dụng vì
|
|
2877
|
+
// nó chỉ biết những reel ID đã đi qua GÓI MẠNG trước lúc bấm Đăng,
|
|
2878
|
+
// mà luồng page-wall không hề mở tab Reels trước đó — tile đầu tiên
|
|
2879
|
+
// của tab hầu như không nằm trong danh sách ấy. Nhánh "tile có mốc
|
|
2880
|
+
// thời gian mới" phía trên (a.2/a.2b) thì chưa từng khớp lần nào
|
|
2881
|
+
// (0/250) vì tile trên tab Reels chỉ hiện lượt xem, không hiện giờ
|
|
2882
|
+
// đăng — nên "last-resort" thực chất là "bốc đại cái đầu tiên".
|
|
2883
|
+
// Ca chủ tịch bắt 09/09 (kênh Bí Quyết Nhà Nông): bài 12:53 báo
|
|
2884
|
+
// thành công, link trỏ vào reel của bài 08/09; bài thật CHƯA lên.
|
|
2885
|
+
// Không có link thì API hạ xuống "mập mờ, chờ người kiểm"
|
|
2886
|
+
// (markUnverifiedPublish) — thà rỗng còn hơn sai, và giờ rỗng cũng
|
|
2887
|
+
// không còn hoá thành "đăng thành công" nữa.
|
|
2796
2888
|
if (!postUrl) {
|
|
2797
|
-
|
|
2798
|
-
const tileHref = await page.evaluate((staleIds) => {
|
|
2799
|
-
const staleSet = new Set(staleIds);
|
|
2800
|
-
const anchors = document.querySelectorAll("a[role='link']");
|
|
2801
|
-
for (const a of anchors) {
|
|
2802
|
-
const aria = (a.getAttribute('aria-label') || '').toLowerCase();
|
|
2803
|
-
if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
|
|
2804
|
-
const href = a.getAttribute('href') || '';
|
|
2805
|
-
const m = href.match(/\/reel\/(\d{8,20})/);
|
|
2806
|
-
if (m) return { href, aria: aria.slice(0, 60), id: m[1], provablyStale: staleSet.has(m[1]) };
|
|
2807
|
-
}
|
|
2808
|
-
return null;
|
|
2809
|
-
}, preIds).catch(() => null);
|
|
2810
|
-
if (tileHref && tileHref.provablyStale) {
|
|
2811
|
-
log('warn', `[fb-pw] LAST-RESORT tile ${tileHref.id} was already captured PRE-publish — provably an OLD reel; leaving post_url empty instead of recording a wrong one`);
|
|
2812
|
-
} else if (tileHref) {
|
|
2813
|
-
postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
|
|
2814
|
-
log('warn', `[fb-pw] post URL from FIRST reel tile on reels_tab (LAST-RESORT, may be STALE): ${postUrl}`);
|
|
2815
|
-
}
|
|
2889
|
+
log('warn', '[fb-pw] không nguồn nào xác nhận được reel mới (mạng/tile mới/tiêu đề đều trượt) — KHÔNG bốc tile đầu tab Reels nữa, để post_url rỗng cho API đánh mập mờ');
|
|
2816
2890
|
}
|
|
2817
2891
|
|
|
2818
2892
|
// (b) URL change — FB sometimes navigates to /reel/<id>/ after publish.
|
|
@@ -2854,7 +2928,13 @@ async function runOnce({ page, payload, log }) {
|
|
|
2854
2928
|
// post_url empty than to record a wrong one. The spam-limit guard above
|
|
2855
2929
|
// now catches the FB block case explicitly.
|
|
2856
2930
|
|
|
2857
|
-
|
|
2931
|
+
// Không lấy được link bài = KHÔNG có reel mới nào hiện ra sau khi đăng
|
|
2932
|
+
// (nhánh last-resort đã từ chối tile cũ theo luật 2026-08-19). Trước đây
|
|
2933
|
+
// dòng này ghi "FB likely accepted" rồi cho qua; đo prod 30 ngày tới
|
|
2934
|
+
// 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ả
|
|
2935
|
+
// ok để KHÔNG tự đăng lại (đăng lại là nguy cơ đăng đúp) — việc hạ xuống
|
|
2936
|
+
// "mập mờ, chờ người kiểm" do API làm, ở markUnverifiedPublish().
|
|
2937
|
+
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
2938
|
|
|
2859
2939
|
// Strip FB tracking params (__cft__, __tn__, etc.) so the stored URL is
|
|
2860
2940
|
// canonical. The reel ID alone uniquely identifies the post and the
|
|
@@ -2875,6 +2955,10 @@ async function runOnce({ page, payload, log }) {
|
|
|
2875
2955
|
title: String(title || description || '').slice(0, 80),
|
|
2876
2956
|
visibility,
|
|
2877
2957
|
post_url: postUrl,
|
|
2958
|
+
// Hai cờ chẩn đoán cho ca "báo xong mà không có link bài" — API đọc
|
|
2959
|
+
// post_url để quyết, hai cờ này để người đọc log biết hỏng ở khâu nào.
|
|
2960
|
+
confirm_click_failed: confirmClickFailed,
|
|
2961
|
+
post_url_captured: !!postUrl,
|
|
2878
2962
|
video_path_local: videoPath,
|
|
2879
2963
|
};
|
|
2880
2964
|
} finally {
|
|
@@ -2912,6 +2996,7 @@ module.exports = { run };
|
|
|
2912
2996
|
// page (toast covering the CTA) without driving the whole upload flow.
|
|
2913
2997
|
module.exports.__testables = {
|
|
2914
2998
|
resilientClick,
|
|
2999
|
+
confirmPublishDialog,
|
|
2915
3000
|
muteClickInterceptors,
|
|
2916
3001
|
unmuteClickInterceptors,
|
|
2917
3002
|
waitForPublishEnabled,
|