channel-worker 2.5.141 → 2.5.143
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/command-poller.js +105 -84
- package/lib/shopee-report.js +41 -10
- package/lib/shopee-scraper.js +6 -1
- package/package.json +1 -1
- package/scripts/upload_facebook.js +94 -6
package/lib/command-poller.js
CHANGED
|
@@ -14,6 +14,13 @@ const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
|
14
14
|
// trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
|
|
15
15
|
const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
|
|
16
16
|
|
|
17
|
+
// Hạn chót cho CẢ lượt đọc báo cáo Shopee (nối profile + đơn + click + liên
|
|
18
|
+
// kết). Lượt thật 90 ngày mất ~1 phút (26/09: 808 đơn + 9.268 click trong 54s).
|
|
19
|
+
// Không có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lệnh của
|
|
20
|
+
// Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả daemon
|
|
21
|
+
// (drain chờ `_handling` rỗng). API có bộ dọn riêng ở ngưỡng dài hơn.
|
|
22
|
+
const AFF_REPORT_DEADLINE_MS = 10 * 60 * 1000;
|
|
23
|
+
|
|
17
24
|
class CommandPoller {
|
|
18
25
|
constructor(api, config) {
|
|
19
26
|
this.api = api;
|
|
@@ -32,6 +39,7 @@ class CommandPoller {
|
|
|
32
39
|
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
33
40
|
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
34
41
|
this._pollsInFlight = 0;
|
|
42
|
+
this.affReportDeadlineMs = AFF_REPORT_DEADLINE_MS;
|
|
35
43
|
|
|
36
44
|
// Init Nstbrowser if API key available
|
|
37
45
|
if (config.nst_api_key) {
|
|
@@ -376,102 +384,115 @@ class CommandPoller {
|
|
|
376
384
|
async handleFetchAffiliateReport(command) {
|
|
377
385
|
const payload = command.payload || {};
|
|
378
386
|
if (!(await this._ensureNst(command))) return;
|
|
379
|
-
const report = require('./shopee-report');
|
|
380
387
|
const profileName = payload.profile_name
|
|
381
388
|
|| (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
|
|
382
389
|
|| 'Shopee1';
|
|
383
|
-
let conn;
|
|
384
|
-
try {
|
|
385
|
-
conn = await this._connectNstProfileByName(profileName);
|
|
386
|
-
const result = await report.fetchConversionReport(conn.context, {
|
|
387
|
-
days: payload.days || 90,
|
|
388
|
-
log: (message) => console.log(message),
|
|
389
|
-
});
|
|
390
|
-
if (result.status === 'needs_verify') {
|
|
391
|
-
await this.api.updateCommand(command._id, {
|
|
392
|
-
status: 'done',
|
|
393
|
-
result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
|
|
394
|
-
});
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (result.status !== 'ok') {
|
|
398
|
-
await this.api.updateCommand(command._id, {
|
|
399
|
-
status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
|
|
400
|
-
});
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
390
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
425
|
-
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
426
|
-
let mediaAccounts = null;
|
|
427
|
-
let mediaError = null;
|
|
428
|
-
try {
|
|
429
|
-
const media = await report.fetchSocialMediaAccounts(conn.context, {
|
|
430
|
-
log: (message) => console.log(message),
|
|
431
|
-
});
|
|
432
|
-
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
433
|
-
else mediaError = media.reason || media.status;
|
|
434
|
-
} catch (err) {
|
|
435
|
-
mediaError = String(err.message || err).slice(0, 200);
|
|
436
|
-
}
|
|
437
|
-
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
438
|
-
|
|
439
|
-
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
440
|
-
clicks: clickResult.rows,
|
|
441
|
-
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
442
|
-
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
443
|
-
// chỉ vì một lượt đọc hỏng.
|
|
444
|
-
media_accounts: mediaAccounts,
|
|
445
|
-
last_report_date: result.last_report_date,
|
|
446
|
-
last_update_time: result.last_update_time,
|
|
447
|
-
from: result.from,
|
|
448
|
-
to: result.to,
|
|
449
|
-
});
|
|
450
|
-
await this.api.updateCommand(command._id, {
|
|
451
|
-
status: 'done',
|
|
452
|
-
result: {
|
|
453
|
-
count: result.rows.length,
|
|
454
|
-
saved: saved?.saved || 0,
|
|
455
|
-
matched: saved?.matched || 0,
|
|
456
|
-
unmatched: saved?.unmatched || 0,
|
|
457
|
-
click_count: clickResult.rows.length,
|
|
458
|
-
click_saved: saved?.click_saved || 0,
|
|
459
|
-
click_matched: saved?.click_matched || 0,
|
|
460
|
-
click_unmatched: saved?.click_unmatched || 0,
|
|
461
|
-
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
462
|
-
media_error: mediaError || undefined,
|
|
463
|
-
last_report_date: result.last_report_date,
|
|
464
|
-
},
|
|
465
|
-
});
|
|
466
|
-
console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
|
|
391
|
+
// Việc thật chạy trong `work`, chạy đua với hạn chót. Hết hạn thì chốt
|
|
392
|
+
// `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ văng ra); kết nối
|
|
393
|
+
// nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
|
|
394
|
+
let conn = null;
|
|
395
|
+
let abandoned = false;
|
|
396
|
+
const work = (async () => {
|
|
397
|
+
const c = await this._connectNstProfileByName(profileName);
|
|
398
|
+
if (abandoned) { c.disconnect(); return null; }
|
|
399
|
+
conn = c;
|
|
400
|
+
return this._readAffiliateReport(c.context, payload);
|
|
401
|
+
})();
|
|
402
|
+
let timer;
|
|
403
|
+
const deadline = new Promise((_, reject) => {
|
|
404
|
+
timer = setTimeout(() => reject(new Error(
|
|
405
|
+
`quá ${Math.round(this.affReportDeadlineMs / 60000)} phút chưa đọc xong báo cáo Shopee [${profileName}] — huỷ lượt này`,
|
|
406
|
+
)), this.affReportDeadlineMs);
|
|
407
|
+
});
|
|
408
|
+
try {
|
|
409
|
+
const patch = await Promise.race([work, deadline]);
|
|
410
|
+
await this.api.updateCommand(command._id, patch);
|
|
467
411
|
} catch (err) {
|
|
468
412
|
console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
|
|
469
413
|
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
470
414
|
} finally {
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
abandoned = true;
|
|
471
417
|
if (conn) conn.disconnect();
|
|
472
418
|
}
|
|
473
419
|
}
|
|
474
420
|
|
|
421
|
+
// Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
|
|
422
|
+
async _readAffiliateReport(context, payload) {
|
|
423
|
+
const report = require('./shopee-report');
|
|
424
|
+
const result = await report.fetchConversionReport(context, {
|
|
425
|
+
days: payload.days || 90,
|
|
426
|
+
log: (message) => console.log(message),
|
|
427
|
+
});
|
|
428
|
+
if (result.status === 'needs_verify') {
|
|
429
|
+
return { status: 'done', result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 } };
|
|
430
|
+
}
|
|
431
|
+
if (result.status !== 'ok') {
|
|
432
|
+
return { status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const clickResult = await report.fetchClickReport(context, {
|
|
436
|
+
days: payload.days || 90,
|
|
437
|
+
log: (message) => console.log(message),
|
|
438
|
+
});
|
|
439
|
+
if (clickResult.status === 'needs_verify') {
|
|
440
|
+
return {
|
|
441
|
+
status: 'done',
|
|
442
|
+
result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (clickResult.status !== 'ok') {
|
|
446
|
+
return { status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
|
|
450
|
+
// nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
|
|
451
|
+
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
452
|
+
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
453
|
+
let mediaAccounts = null;
|
|
454
|
+
let mediaError = null;
|
|
455
|
+
try {
|
|
456
|
+
const media = await report.fetchSocialMediaAccounts(context, {
|
|
457
|
+
log: (message) => console.log(message),
|
|
458
|
+
});
|
|
459
|
+
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
460
|
+
else mediaError = media.reason || media.status;
|
|
461
|
+
} catch (err) {
|
|
462
|
+
mediaError = String(err.message || err).slice(0, 200);
|
|
463
|
+
}
|
|
464
|
+
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
465
|
+
|
|
466
|
+
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
467
|
+
clicks: clickResult.rows,
|
|
468
|
+
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
469
|
+
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
470
|
+
// chỉ vì một lượt đọc hỏng.
|
|
471
|
+
media_accounts: mediaAccounts,
|
|
472
|
+
last_report_date: result.last_report_date,
|
|
473
|
+
last_update_time: result.last_update_time,
|
|
474
|
+
from: result.from,
|
|
475
|
+
to: result.to,
|
|
476
|
+
});
|
|
477
|
+
console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
|
|
478
|
+
return {
|
|
479
|
+
status: 'done',
|
|
480
|
+
result: {
|
|
481
|
+
count: result.rows.length,
|
|
482
|
+
saved: saved?.saved || 0,
|
|
483
|
+
matched: saved?.matched || 0,
|
|
484
|
+
unmatched: saved?.unmatched || 0,
|
|
485
|
+
click_count: clickResult.rows.length,
|
|
486
|
+
click_saved: saved?.click_saved || 0,
|
|
487
|
+
click_matched: saved?.click_matched || 0,
|
|
488
|
+
click_unmatched: saved?.click_unmatched || 0,
|
|
489
|
+
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
490
|
+
media_error: mediaError || undefined,
|
|
491
|
+
last_report_date: result.last_report_date,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
475
496
|
async handlePlaywrightCommand(command) {
|
|
476
497
|
const { runPlaywrightScript } = require('./playwright-runner');
|
|
477
498
|
const payload = command.payload || {};
|
package/lib/shopee-report.js
CHANGED
|
@@ -2,6 +2,30 @@ const { findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON } = re
|
|
|
2
2
|
|
|
3
3
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
4
|
|
|
5
|
+
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
6
|
+
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
7
|
+
// (Shopee2, relabs03) một lượt nằm im 12 tiếng, chỉ nhả khi người gỡ captcha
|
|
8
|
+
// làm trang chuyển hướng — suốt lúc đó lệnh `running` khoá nút Đồng bộ và lịch
|
|
9
|
+
// của máy. Hết hạn thì trả `{ status: 0, timedOut }` để bên gọi xử như một
|
|
10
|
+
// lượt hỏng; trang lúc đó đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
11
|
+
const EVAL_TIMEOUT_MS = 60000;
|
|
12
|
+
|
|
13
|
+
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
14
|
+
let timer;
|
|
15
|
+
const expired = new Promise((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve({
|
|
17
|
+
status: 0,
|
|
18
|
+
timedOut: true,
|
|
19
|
+
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
20
|
+
}), ms);
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
5
29
|
function reportWindow(days = 90, nowMs = Date.now()) {
|
|
6
30
|
const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
|
|
7
31
|
const vnNow = new Date(nowMs + 7 * 3600e3);
|
|
@@ -17,6 +41,7 @@ async function fetchConversionReport(ctx, {
|
|
|
17
41
|
maxPages = 200,
|
|
18
42
|
delayMs = 750,
|
|
19
43
|
timeoutMs = 60000,
|
|
44
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
20
45
|
log = null,
|
|
21
46
|
} = {}) {
|
|
22
47
|
let page = findAffiliatePage(ctx.pages());
|
|
@@ -43,7 +68,7 @@ async function fetchConversionReport(ctx, {
|
|
|
43
68
|
let lastReportDate = null;
|
|
44
69
|
let lastUpdateTime = null;
|
|
45
70
|
|
|
46
|
-
const updated = await page
|
|
71
|
+
const updated = await evaluateWithin(page, async () => {
|
|
47
72
|
try {
|
|
48
73
|
const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
|
|
49
74
|
method: 'POST', credentials: 'include',
|
|
@@ -56,12 +81,17 @@ async function fetchConversionReport(ctx, {
|
|
|
56
81
|
});
|
|
57
82
|
return await r.json().catch(() => null);
|
|
58
83
|
} catch { return null; }
|
|
59
|
-
});
|
|
84
|
+
}, undefined, evalTimeoutMs);
|
|
85
|
+
if (updated?.timedOut) {
|
|
86
|
+
return isBlockedState(page.url(), null)
|
|
87
|
+
? { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' }
|
|
88
|
+
: { status: 'failed', rows: [], reason: updated.error };
|
|
89
|
+
}
|
|
60
90
|
lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
|
|
61
91
|
lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
|
|
62
92
|
|
|
63
93
|
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
64
|
-
const result = await page
|
|
94
|
+
const result = await evaluateWithin(page, async (q) => {
|
|
65
95
|
const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
|
|
66
96
|
+ `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
|
|
67
97
|
try {
|
|
@@ -70,7 +100,7 @@ async function fetchConversionReport(ctx, {
|
|
|
70
100
|
} catch (e) {
|
|
71
101
|
return { status: 0, error: String(e?.message || e) };
|
|
72
102
|
}
|
|
73
|
-
}, { pageSize, pageNum, start: window.start, end: window.end });
|
|
103
|
+
}, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
|
|
74
104
|
|
|
75
105
|
if (isAntiBotBody(result.json)) {
|
|
76
106
|
return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
|
|
@@ -113,6 +143,7 @@ async function fetchClickReport(ctx, {
|
|
|
113
143
|
maxPages = 200,
|
|
114
144
|
delayMs = 250,
|
|
115
145
|
timeoutMs = 60000,
|
|
146
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
116
147
|
log = null,
|
|
117
148
|
} = {}) {
|
|
118
149
|
let page = findAffiliatePage(ctx.pages());
|
|
@@ -138,7 +169,7 @@ async function fetchClickReport(ctx, {
|
|
|
138
169
|
let total = null;
|
|
139
170
|
|
|
140
171
|
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
141
|
-
const result = await page
|
|
172
|
+
const result = await evaluateWithin(page, async (q) => {
|
|
142
173
|
const url = `/api/v1/click_report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
|
|
143
174
|
+ `&click_time_s=${q.start}&click_time_e=${q.end}`;
|
|
144
175
|
try {
|
|
@@ -147,7 +178,7 @@ async function fetchClickReport(ctx, {
|
|
|
147
178
|
} catch (e) {
|
|
148
179
|
return { status: 0, error: String(e?.message || e) };
|
|
149
180
|
}
|
|
150
|
-
}, { pageSize, pageNum, start: window.start, end: window.end });
|
|
181
|
+
}, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
|
|
151
182
|
|
|
152
183
|
if (isAntiBotBody(result.json)) {
|
|
153
184
|
return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
|
|
@@ -206,7 +237,7 @@ async function fetchClickReport(ctx, {
|
|
|
206
237
|
// khác giữ nguyên số để sau này đọc lại, không đoán tên).
|
|
207
238
|
const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
|
|
208
239
|
|
|
209
|
-
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
|
|
240
|
+
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
|
|
210
241
|
let page = findAffiliatePage(ctx.pages());
|
|
211
242
|
let opened = false;
|
|
212
243
|
if (!page) {
|
|
@@ -225,14 +256,14 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
|
|
|
225
256
|
return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
226
257
|
}
|
|
227
258
|
|
|
228
|
-
const result = await page
|
|
259
|
+
const result = await evaluateWithin(page, async () => {
|
|
229
260
|
try {
|
|
230
261
|
const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
|
|
231
262
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
232
263
|
} catch (e) {
|
|
233
264
|
return { status: 0, error: String(e?.message || e) };
|
|
234
265
|
}
|
|
235
|
-
});
|
|
266
|
+
}, undefined, evalTimeoutMs);
|
|
236
267
|
|
|
237
268
|
if (isAntiBotBody(result.json)) {
|
|
238
269
|
return { status: 'needs_verify', accounts: [], reason: ANTI_BOT_REASON };
|
|
@@ -266,4 +297,4 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
|
|
|
266
297
|
}
|
|
267
298
|
}
|
|
268
299
|
|
|
269
|
-
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
|
|
300
|
+
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, evaluateWithin, SM_PLATFORM };
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -178,7 +178,12 @@ function isBlockedState(pageUrl, res) {
|
|
|
178
178
|
try { u = new URL(pageUrl || ''); } catch { return false; }
|
|
179
179
|
// CHỈ xét path + query của CHÍNH trang này. Regex quét cả chuỗi URL đọc nhầm
|
|
180
180
|
// tham số `next=` của trang khác — xem ghi chú ở findAffiliatePage.
|
|
181
|
-
|
|
181
|
+
//
|
|
182
|
+
// MỌI trang /verify/*, không riêng captcha: Shopee đẩy qua `verify/traffic`
|
|
183
|
+
// trước rồi mới tới `verify/captcha`. 28/09/2026 (Shopee2) lượt đọc báo cáo
|
|
184
|
+
// gặp đúng trang traffic, không nhận ra, chạy fetch trên trang chống bot và
|
|
185
|
+
// treo 12 tiếng tới khi người gỡ captcha.
|
|
186
|
+
if (/^\/(verify\/|buyer\/login)/.test(u.pathname)) return true;
|
|
182
187
|
if (u.searchParams.has('is_from_login')) return true;
|
|
183
188
|
return false;
|
|
184
189
|
}
|
package/package.json
CHANGED
|
@@ -1410,13 +1410,75 @@ async function composerShowsThumb(page, thumbDataUrl) {
|
|
|
1410
1410
|
}, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
|
|
1411
1411
|
}
|
|
1412
1412
|
|
|
1413
|
+
// Video còn đang tải lên Facebook không? Biến thể nút "Chọn ảnh bìa" hiện ảnh
|
|
1414
|
+
// bìa ở ô media 132x132 cạnh nút, và ô đó chỉ dựng xong khi video tải lên xong.
|
|
1415
|
+
// Gà Vườn Mỗi Ngày 23-26/09: 4/4 lượt "Custom thumbnail required" đều có ô media
|
|
1416
|
+
// còn là vòng xoay aria "Đang tải..." + ảnh xem trước "Đang tải lên 5x-9x%" (file
|
|
1417
|
+
// 26-45 MB), trong khi 3 lượt thành công cũng dán lúc ô chưa dựng rồi thấy ĐÚNG
|
|
1418
|
+
// ảnh của mình (lệch 0/255) ngay khi video tải xong trong 30s chờ. Tức FB giữ
|
|
1419
|
+
// ảnh bìa đã chọn trong lúc tải; worker chỉ hết giờ trước. Trả
|
|
1420
|
+
// { pct, spinner } hoặc null khi đọc hỏng.
|
|
1421
|
+
const COVER_UPLOAD_WAIT_MS = 8 * 60_000;
|
|
1422
|
+
async function readComposerUploadState(page) {
|
|
1423
|
+
return page.evaluate(({ coverSrc }) => {
|
|
1424
|
+
const COVER = new RegExp(coverSrc, 'i');
|
|
1425
|
+
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1426
|
+
const sized = (el) => {
|
|
1427
|
+
const r = el.getBoundingClientRect();
|
|
1428
|
+
return r.width >= 8 && r.height >= 8;
|
|
1429
|
+
};
|
|
1430
|
+
const roots = [...document.querySelectorAll("[role='dialog']")].filter((d) => sized(d)
|
|
1431
|
+
&& /thước phim|reel/i.test(`${d.getAttribute('aria-label') || ''}\n${(d.innerText || '').slice(0, 600)}`));
|
|
1432
|
+
const main = document.querySelector("[role='main'], main");
|
|
1433
|
+
if (main && sized(main)) roots.push(main);
|
|
1434
|
+
let pct = null;
|
|
1435
|
+
for (const root of roots) {
|
|
1436
|
+
const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1437
|
+
for (let n = tw.nextNode(); n && pct === null; n = tw.nextNode()) {
|
|
1438
|
+
if (!/%/.test(n.data)) continue;
|
|
1439
|
+
for (let el = n.parentElement, d = 0; el && d < 3 && pct === null; el = el.parentElement, d++) {
|
|
1440
|
+
const m = norm(el.innerText).match(/(?:Đang tải lên|Uploading)\s*(\d{1,3})\s*%/i);
|
|
1441
|
+
if (m) pct = Number(m[1]);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (pct !== null) break;
|
|
1445
|
+
}
|
|
1446
|
+
// Vòng xoay ở ô media cạnh nút ảnh bìa (không đòi vis: sang bước Cài đặt cả
|
|
1447
|
+
// khối bị ẩn nhưng vẫn giữ kích thước, và video thì vẫn đang tải).
|
|
1448
|
+
let spinner = false;
|
|
1449
|
+
for (const root of roots) {
|
|
1450
|
+
const btn = [...root.querySelectorAll('[aria-label]')].find((el) => COVER.test(norm(el.getAttribute('aria-label'))));
|
|
1451
|
+
for (let box = btn && btn.parentElement, d = 0; box && d < 4 && !spinner; box = box.parentElement, d++) {
|
|
1452
|
+
spinner = [...box.querySelectorAll("[aria-label], [role='progressbar']")].some((el) => el !== btn && sized(el)
|
|
1453
|
+
&& (el.getAttribute('role') === 'progressbar' || /^(Đang tải|Loading)(\.\.\.|…)?$/i.test(norm(el.getAttribute('aria-label')))));
|
|
1454
|
+
}
|
|
1455
|
+
if (btn) break;
|
|
1456
|
+
}
|
|
1457
|
+
return { pct, spinner };
|
|
1458
|
+
}, { coverSrc: COVER_BUTTON_RE.source }).catch(() => null);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Thuần, test được: còn phải chờ video tải lên không.
|
|
1462
|
+
function coverUploadPending(st) {
|
|
1463
|
+
if (!st) return false;
|
|
1464
|
+
return (typeof st.pct === 'number' && st.pct < 100) || !!st.spinner;
|
|
1465
|
+
}
|
|
1466
|
+
function describeUploadState(st) {
|
|
1467
|
+
if (!st) return 'không đọc được trạng thái tải video';
|
|
1468
|
+
if (!coverUploadPending(st)) return 'video đã tải xong';
|
|
1469
|
+
return `video đang tải lên${typeof st.pct === 'number' ? ` ${st.pct}%` : ''}${st.spinner ? ', ô media còn vòng xoay' : ''}`;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1413
1472
|
// Biến thể nút "Chọn ảnh bìa" (wizard, không có hàng ô). Chỉ nhận là đã dán khi:
|
|
1414
1473
|
// a) hộp thoại chọn ảnh đã cho thấy ĐÚNG ảnh của mình (so nội dung) rồi mới
|
|
1415
1474
|
// Lưu và đóng, hoặc
|
|
1416
1475
|
// b) sau khi dán, composer hiện một ảnh giống ảnh bìa của mình (lệch ≤ ngưỡng)
|
|
1417
1476
|
// hai nhịp liền, hoặc nút "Gỡ hình thu nhỏ tùy chỉnh" hiện.
|
|
1477
|
+
// Video còn đang tải thì ô media chưa có gì để so: gia hạn vòng chờ tới khi tải
|
|
1478
|
+
// xong (trần `uploadWaitMs`), rồi mới tính tiếp `verifyMs`. Tải xong mà vẫn
|
|
1479
|
+
// không thấy ảnh thì dán lại MỘT lần — lúc này ô media đã dựng.
|
|
1418
1480
|
// Không thì trả không-dán, cổng trước Đăng chặn như mọi biến thể khác.
|
|
1419
|
-
async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30_000 } = {}) {
|
|
1481
|
+
async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30_000, uploadWaitMs = COVER_UPLOAD_WAIT_MS, retried = false } = {}) {
|
|
1420
1482
|
log('info', `[fb-pw] composer mới — ${describeThumbRow(snap)} → dán ảnh bìa qua nút này`);
|
|
1421
1483
|
let thumbDataUrl = '';
|
|
1422
1484
|
try { thumbDataUrl = `data:image/png;base64,${fs.readFileSync(thumbPath).toString('base64')}`; } catch { /* composerShowsThumb sẽ báo thumb-not-decodable */ }
|
|
@@ -1436,7 +1498,12 @@ async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30
|
|
|
1436
1498
|
let scan = null;
|
|
1437
1499
|
let remote = null;
|
|
1438
1500
|
const remoteCache = new Map();
|
|
1439
|
-
const
|
|
1501
|
+
const t0 = Date.now();
|
|
1502
|
+
const hardDeadline = t0 + uploadWaitMs;
|
|
1503
|
+
let deadline = t0 + verifyMs;
|
|
1504
|
+
let upload = null;
|
|
1505
|
+
let sawPending = false;
|
|
1506
|
+
let doneAt = 0;
|
|
1440
1507
|
while (Date.now() < deadline) {
|
|
1441
1508
|
await pause(page, 2500);
|
|
1442
1509
|
if (await isCustomThumbApplied(page)) { if (++streak >= 2) break; continue; }
|
|
@@ -1448,15 +1515,32 @@ async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30
|
|
|
1448
1515
|
if (remote && remote.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
|
|
1449
1516
|
}
|
|
1450
1517
|
streak = 0;
|
|
1518
|
+
// Chưa thấy ảnh: video còn đang tải thì ô media chưa dựng — gia hạn để còn
|
|
1519
|
+
// đủ `verifyMs` SAU lúc tải xong, không vượt trần.
|
|
1520
|
+
upload = await readComposerUploadState(page);
|
|
1521
|
+
if (coverUploadPending(upload)) {
|
|
1522
|
+
if (!sawPending) log('info', `[fb-pw] composer mới — nút ảnh bìa: ${describeUploadState(upload)} → ô media chưa dựng, chờ video tải xong rồi mới so (tối đa ${Math.round(uploadWaitMs / 60_000)} phút)`);
|
|
1523
|
+
sawPending = true;
|
|
1524
|
+
deadline = Math.min(hardDeadline, Math.max(deadline, Date.now() + verifyMs));
|
|
1525
|
+
} else if (sawPending && !doneAt) {
|
|
1526
|
+
doneAt = Date.now();
|
|
1527
|
+
log('info', `[fb-pw] composer mới — nút ảnh bìa: video tải xong sau ${Math.round((doneAt - t0) / 1000)}s, so ảnh bìa trong ô media…`);
|
|
1528
|
+
}
|
|
1451
1529
|
}
|
|
1452
1530
|
const fmt = (x) => (x && !x.err ? `đọc được ${x.readable} ảnh, khoá ${x.locked}, gần nhất lệch ${x.best ? `${x.best.diff}/255 (${x.best.size})` : '—'}${x === scan && remote ? `, ảnh CDN tải về gần nhất lệch ${remote.diff}/255` : ''}` : `lỗi ${x && x.err}`);
|
|
1453
1531
|
if (streak >= 2) {
|
|
1454
|
-
log('info', `[fb-pw] composer mới — ảnh bìa đã vào: composer hiện đúng ảnh của mình (${fmt(scan)}; trước khi dán: ${fmt(base)})`);
|
|
1532
|
+
log('info', `[fb-pw] composer mới — ảnh bìa đã vào: composer hiện đúng ảnh của mình (${fmt(scan)}; trước khi dán: ${fmt(base)})${sawPending ? ` sau ${Math.round((Date.now() - t0) / 1000)}s chờ video tải` : ''}`);
|
|
1455
1533
|
return { applied: true, reason: 'composer hiện đúng ảnh' };
|
|
1456
1534
|
}
|
|
1457
1535
|
const d = await readNewComposerThumbRow(page, { diag: true });
|
|
1458
|
-
log('warn', `[fb-pw] composer mới — nút ảnh bìa: KHÔNG thấy ảnh của mình trong composer sau ${Math.round(
|
|
1459
|
-
|
|
1536
|
+
log('warn', `[fb-pw] composer mới — nút ảnh bìa: KHÔNG thấy ảnh của mình trong composer sau ${Math.round((Date.now() - t0) / 1000)}s (${fmt(scan)}; trước khi dán: ${fmt(base)}; ${describeUploadState(upload)})${d && d.inventory ? ` nút=${JSON.stringify(d.inventory)}` : ''}`);
|
|
1537
|
+
// Video tải xong trong lúc chờ mà ô media vẫn không có ảnh của mình → dán
|
|
1538
|
+
// lại một lần: lần dán trước rơi vào lúc ô còn đang dựng.
|
|
1539
|
+
if (!retried && doneAt && d && d.mode === 'cover-button') {
|
|
1540
|
+
log('info', '[fb-pw] composer mới — nút ảnh bìa: video đã tải xong, dán lại ảnh bìa MỘT lần');
|
|
1541
|
+
return applyCoverButtonThumb(page, thumbPath, log, d, { verifyMs, uploadWaitMs: verifyMs, retried: true });
|
|
1542
|
+
}
|
|
1543
|
+
return { applied: false, reason: coverUploadPending(upload) ? 'cover-upload-still-pending' : 'cover-not-shown' };
|
|
1460
1544
|
}
|
|
1461
1545
|
|
|
1462
1546
|
// Dán ảnh bìa vào composer mới. Trả { applied, reason }. Không ném: thất bại
|
|
@@ -2168,7 +2252,7 @@ async function runOnce({ page, payload, log }) {
|
|
|
2168
2252
|
} = payload || {};
|
|
2169
2253
|
if (!video_url) throw new Error('No video_url provided');
|
|
2170
2254
|
|
|
2171
|
-
log('info', '[fb-pw] selectors version=2026.09.
|
|
2255
|
+
log('info', '[fb-pw] selectors version=2026.09.26-cover-wait-upload');
|
|
2172
2256
|
|
|
2173
2257
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
2174
2258
|
|
|
@@ -4665,6 +4749,10 @@ module.exports.__testables = {
|
|
|
4665
4749
|
COVER_HEAD_RE,
|
|
4666
4750
|
composerShowsThumb,
|
|
4667
4751
|
applyCoverButtonThumb,
|
|
4752
|
+
readComposerUploadState,
|
|
4753
|
+
coverUploadPending,
|
|
4754
|
+
describeUploadState,
|
|
4755
|
+
COVER_UPLOAD_WAIT_MS,
|
|
4668
4756
|
remoteImagesMatchThumb,
|
|
4669
4757
|
isReelOverlayUrl,
|
|
4670
4758
|
clickPickerAndUpload,
|