channel-worker 2.5.136 → 2.5.138
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 +23 -0
- package/lib/shopee-report.js +75 -1
- package/package.json +1 -1
- package/scripts/fetch_facebook_reel_stats.js +24 -1
- package/scripts/upload_facebook.js +201 -9
package/lib/command-poller.js
CHANGED
|
@@ -388,8 +388,29 @@ class CommandPoller {
|
|
|
388
388
|
return;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
// Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
|
|
392
|
+
// nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
|
|
393
|
+
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
394
|
+
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
395
|
+
let mediaAccounts = null;
|
|
396
|
+
let mediaError = null;
|
|
397
|
+
try {
|
|
398
|
+
const media = await report.fetchSocialMediaAccounts(conn.context, {
|
|
399
|
+
log: (message) => console.log(message),
|
|
400
|
+
});
|
|
401
|
+
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
402
|
+
else mediaError = media.reason || media.status;
|
|
403
|
+
} catch (err) {
|
|
404
|
+
mediaError = String(err.message || err).slice(0, 200);
|
|
405
|
+
}
|
|
406
|
+
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
407
|
+
|
|
391
408
|
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
392
409
|
clicks: clickResult.rows,
|
|
410
|
+
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
411
|
+
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
412
|
+
// chỉ vì một lượt đọc hỏng.
|
|
413
|
+
media_accounts: mediaAccounts,
|
|
393
414
|
last_report_date: result.last_report_date,
|
|
394
415
|
last_update_time: result.last_update_time,
|
|
395
416
|
from: result.from,
|
|
@@ -406,6 +427,8 @@ class CommandPoller {
|
|
|
406
427
|
click_saved: saved?.click_saved || 0,
|
|
407
428
|
click_matched: saved?.click_matched || 0,
|
|
408
429
|
click_unmatched: saved?.click_unmatched || 0,
|
|
430
|
+
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
431
|
+
media_error: mediaError || undefined,
|
|
409
432
|
last_report_date: result.last_report_date,
|
|
410
433
|
},
|
|
411
434
|
});
|
package/lib/shopee-report.js
CHANGED
|
@@ -183,4 +183,78 @@ async function fetchClickReport(ctx, {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
// Danh sách tài khoản mạng xã hội ĐÃ LIÊN KẾT với tài khoản affiliate — cùng
|
|
187
|
+
// thứ trang `account_setting` hiện ở tab "Media Information".
|
|
188
|
+
//
|
|
189
|
+
// KHÔNG mở account_setting và KHÔNG bấm tab: đo thật 22/09/2026 trên phiên
|
|
190
|
+
// Shopee1, endpoint này trả đủ dữ liệu khi gọi từ MỘT tab affiliate bất kỳ
|
|
191
|
+
// (thử từ /dashboard). Đi đường DOM thì phải mở thêm trang, bấm đúng nhãn
|
|
192
|
+
// tiếng Anh "Media Information", và nhãn đó đổi theo ngôn ngữ giao diện.
|
|
193
|
+
//
|
|
194
|
+
// Mỗi dòng có `user_id` là ID Facebook THẬT của page (vd 1298751909986110) —
|
|
195
|
+
// đúng con số nằm trong link `facebook.com/profile.php?id=…`. Nhờ vậy việc so
|
|
196
|
+
// khớp với kênh của mình không phải parse URL, vốn có nhiều biến thể
|
|
197
|
+
// (?referral_source=…, /<handle>/, m.facebook.com…).
|
|
198
|
+
//
|
|
199
|
+
// `platform`: 6 = Facebook, 3 = TikTok (đo được trên tài khoản thật; các mã
|
|
200
|
+
// khác giữ nguyên số để sau này đọc lại, không đoán tên).
|
|
201
|
+
const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
|
|
202
|
+
|
|
203
|
+
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
|
|
204
|
+
let page = findAffiliatePage(ctx.pages());
|
|
205
|
+
let opened = false;
|
|
206
|
+
if (!page) {
|
|
207
|
+
page = await ctx.newPage();
|
|
208
|
+
opened = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
if (!page.url().includes('affiliate.shopee.vn/')) {
|
|
213
|
+
await page.goto('https://affiliate.shopee.vn/offer/product_offer', {
|
|
214
|
+
waitUntil: 'domcontentloaded', timeout: timeoutMs,
|
|
215
|
+
}).catch(() => {});
|
|
216
|
+
await sleep(1500);
|
|
217
|
+
}
|
|
218
|
+
if (isBlockedState(page.url(), null)) {
|
|
219
|
+
return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = await page.evaluate(async () => {
|
|
223
|
+
try {
|
|
224
|
+
const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
|
|
225
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { status: 0, error: String(e?.message || e) };
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
|
|
232
|
+
return { status: 'needs_verify', accounts: [], reason: `blocked while reading media info (http ${result.status}, code ${result.json?.code})` };
|
|
233
|
+
}
|
|
234
|
+
if (result.status !== 200 || result.json?.code !== 0) {
|
|
235
|
+
return { status: 'failed', accounts: [], reason: result.json?.msg || result.error || `media info HTTP ${result.status}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const list = Array.isArray(result.json?.data?.accounts) ? result.json.data.accounts : [];
|
|
239
|
+
const accounts = list.map((row) => ({
|
|
240
|
+
media_user_id: String(row.user_id || row.unique_id || '').trim(),
|
|
241
|
+
media_name: String(row.user_name || '').trim(),
|
|
242
|
+
website_url: String(row.website_url || '').trim(),
|
|
243
|
+
platform_code: Number(row.platform) || 0,
|
|
244
|
+
platform: SM_PLATFORM[Number(row.platform)] || 'other',
|
|
245
|
+
link_status: Number(row.link_status) || 0,
|
|
246
|
+
facebook_page_type: Number(row.facebook_page_type) || 0,
|
|
247
|
+
partner_link_status: Number(row.partner_link_status) || 0,
|
|
248
|
+
follower_number: Number(row.follower_number) || 0,
|
|
249
|
+
// Shopee trả giây, không phải mili-giây.
|
|
250
|
+
verification_time: Number(row.verification_time) || 0,
|
|
251
|
+
})).filter((a) => a.media_user_id || a.website_url);
|
|
252
|
+
|
|
253
|
+
if (log) log(`[shopee-report] media: ${accounts.length} tài khoản liên kết (${accounts.filter((a) => a.platform === 'facebook').length} Facebook)`);
|
|
254
|
+
return { status: 'ok', accounts };
|
|
255
|
+
} finally {
|
|
256
|
+
if (opened) await page.close().catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@ function parseViews(txt) {
|
|
|
38
38
|
// 09/09 tìm được tab để so trước/sau. Bản riêng còn thiếu cả bộ lọc "anchor
|
|
39
39
|
// của bài trong bảng tin" (gốc 2 link page lạ sáng 10/09) nên có thể quét
|
|
40
40
|
// nhầm tab Reels của page KHÁC mà không hay.
|
|
41
|
-
const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef } = require('./upload_facebook').__testables;
|
|
41
|
+
const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef, readPageIdFromDom, readDelegatePageId } = require('./upload_facebook').__testables;
|
|
42
42
|
|
|
43
43
|
// Số người theo dõi của page, đọc ngay trên chính trang đang đứng (header hiện
|
|
44
44
|
// ở mọi tab của page/profile) — không tốn thêm lượt mở profile nào. Ô
|
|
@@ -821,6 +821,29 @@ async function run({ page, payload, log }) {
|
|
|
821
821
|
// được với danh sách page đã liên kết bên Shopee. Không có thì bỏ qua, lượt
|
|
822
822
|
// ghi số liệu không phụ thuộc vào nó.
|
|
823
823
|
const pageRef = opened.verified ? parsePageRef(opened.url) : null;
|
|
824
|
+
// URL chỉ có TÊN RIÊNG thì chưa đủ để đối chiếu với Shopee: 6/8 page đang liên
|
|
825
|
+
// kết bên đó lưu dạng `profile.php?id=…`, không mang tên riêng. Thiếu số ID là
|
|
826
|
+
// kênh bị báo "chưa liên kết" oan. Đọc thêm từ chính trang đang mở (đã chứng
|
|
827
|
+
// minh có tile ⇒ đúng page của kênh). Đọc hỏng thì giữ nguyên tên riêng, không
|
|
828
|
+
// làm hỏng lượt ghi số liệu.
|
|
829
|
+
if (pageRef && !pageRef.id) {
|
|
830
|
+
const hit = await readPageIdFromDom(page).catch(() => null);
|
|
831
|
+
if (hit) {
|
|
832
|
+
pageRef.id = hit.id;
|
|
833
|
+
log('info', `[fb-stats] page id đọc từ trang: ${hit.id} (${hit.sources} nguồn khớp: ${hit.from.join(', ')})`);
|
|
834
|
+
} else {
|
|
835
|
+
log('warn', '[fb-stats] URL chỉ có tên riêng và KHÔNG đọc được id từ trang — kênh sẽ hiện "chưa rõ Page" thay vì báo sai liên kết');
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// MÃ TRANG Shopee dùng để đối chiếu (`delegate_page_id`) — KHÁC profile id.
|
|
839
|
+
// Không có nó thì API không kết luận được kênh đã liên kết hay chưa, và cố
|
|
840
|
+
// tình để ở "chưa rõ" còn hơn báo sai. Xem readDelegatePageId.
|
|
841
|
+
if (pageRef) {
|
|
842
|
+
const dg = await readDelegatePageId(page, pageRef.id).catch(() => ({ id: '', checked: false }));
|
|
843
|
+
pageRef.delegate_id = dg.id;
|
|
844
|
+
pageRef.delegate_checked = dg.checked;
|
|
845
|
+
log('info', `[fb-stats] mã Trang (Shopee dùng): ${dg.id || (dg.checked ? 'không có — page kiểu cổ điển' : 'CHƯA rõ')}`);
|
|
846
|
+
}
|
|
824
847
|
if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
|
|
825
848
|
|
|
826
849
|
const d = await log.apiRequest('POST', '/analytics/post-stats', {
|
|
@@ -741,6 +741,15 @@ const NEW_COMPOSER_URL_RE = /^https?:\/\/(www\.)?facebook\.com\/(post\/create|re
|
|
|
741
741
|
function isNewComposerUrl(url) {
|
|
742
742
|
return NEW_COMPOSER_URL_RE.test(String(url || ''));
|
|
743
743
|
}
|
|
744
|
+
// /reel/ hoặc /reel/<id>: Facebook có lúc PHỦ hộp thoại composer lên feed
|
|
745
|
+
// Reels (10/09; Nuôi Gà Làm Giàu 23/09 13:16Z — đúng composer KIỂU CŨ "Cài đặt
|
|
746
|
+
// thước phim" + nút "Chỉnh sửa" trên ảnh video, ảnh dump lệnh 6ab3d12d). Trước
|
|
747
|
+
// đây cả hai khối ảnh bìa đều bỏ qua URL này → "Custom thumbnail required".
|
|
748
|
+
// Ở đây phải hỏi DOM xem là composer nào (xem vòng bước đăng).
|
|
749
|
+
const REEL_OVERLAY_URL_RE = /^https?:\/\/(www\.)?facebook\.com\/reel\/(\d+\/?)?($|\?|#)/i;
|
|
750
|
+
function isReelOverlayUrl(url) {
|
|
751
|
+
return REEL_OVERLAY_URL_RE.test(String(url || ''));
|
|
752
|
+
}
|
|
744
753
|
// Chữ trên ô "tải ảnh bìa của mình lên" — khớp NGUYÊN chuỗi (sau khi gộp
|
|
745
754
|
// khoảng trắng) để không dính caption hay nhãn mục "Hình thu nhỏ".
|
|
746
755
|
const THUMB_PICKER_TEXT_RE = /^(?:Chọn hình thu nhỏ|Chọn ảnh thu nhỏ|Thêm hình thu nhỏ|Tải hình thu nhỏ lên|(?:Choose|Select|Add|Upload) (?:a )?(?:custom )?thumbnail)$/i;
|
|
@@ -1231,6 +1240,11 @@ async function uploadViaThumbPickerModal(page, thumbPath, log, modal, { previewM
|
|
|
1231
1240
|
// đổi-URL, kèm ghi rõ vào log. Ảnh chưa nạp xong thì chờ nhịp sau.
|
|
1232
1241
|
lastCmp = await previewMatchesThumb(page, thumbDataUrl);
|
|
1233
1242
|
if (lastCmp.err === 'preview-not-loaded') continue;
|
|
1243
|
+
if (lastCmp.err === 'tainted') {
|
|
1244
|
+
// Ảnh xem trước từ CDN → tải về phía Node mà so, không đoán theo URL.
|
|
1245
|
+
const rm = await remoteImagesMatchThumb(page, [m.preview], thumbDataUrl);
|
|
1246
|
+
if (rm) lastCmp = { diff: rm.diff, via: 'cdn' };
|
|
1247
|
+
}
|
|
1234
1248
|
if (lastCmp.diff !== undefined && lastCmp.diff > THUMB_PREVIEW_MAX_DIFF) {
|
|
1235
1249
|
if (!loggedMismatch) log('info', `[fb-pw] composer mới — ảnh xem trước đổi nhưng KHÔNG giống ảnh bìa của mình (lệch ${lastCmp.diff}/255) — chờ tiếp`);
|
|
1236
1250
|
loggedMismatch = true;
|
|
@@ -1304,6 +1318,49 @@ async function clickPickerAndUpload(page, thumbPath, log) {
|
|
|
1304
1318
|
return { via: '' };
|
|
1305
1319
|
}
|
|
1306
1320
|
|
|
1321
|
+
// So ảnh nạp từ CDN của Facebook (khác nguồn → canvas bị khoá) với ảnh bìa:
|
|
1322
|
+
// tải bytes bằng page.request phía Node (không qua CORS), đưa lại vào trang
|
|
1323
|
+
// dưới dạng data: URL (cùng nguồn) rồi so 16x16 như previewMatchesThumb. Cần
|
|
1324
|
+
// cho lượt Gà Vườn 23/09 12:55Z: hộp thoại nhận ảnh xong, composer hiện ảnh bìa
|
|
1325
|
+
// bằng URL scontent → "đọc được 0 ảnh, khoá 4" → không so được → chặn oan.
|
|
1326
|
+
// `cache` (Map src→diff) để vòng chờ không tải lại cùng một ảnh mỗi nhịp.
|
|
1327
|
+
async function remoteImagesMatchThumb(page, srcs, thumbDataUrl, cache = new Map()) {
|
|
1328
|
+
let best = null;
|
|
1329
|
+
for (const src of [...new Set(srcs || [])].slice(0, 6)) {
|
|
1330
|
+
if (!/^https?:/i.test(src)) continue;
|
|
1331
|
+
let diff = cache.has(src) ? cache.get(src) : undefined;
|
|
1332
|
+
if (diff === undefined) {
|
|
1333
|
+
diff = null;
|
|
1334
|
+
try {
|
|
1335
|
+
const resp = await page.request.get(src, { timeout: 10_000 });
|
|
1336
|
+
const buf = resp.ok() ? await resp.body() : null;
|
|
1337
|
+
if (buf && buf.length && buf.length < 8 * 1024 * 1024) {
|
|
1338
|
+
const ct = String(resp.headers()['content-type'] || 'image/jpeg').split(';')[0];
|
|
1339
|
+
diff = await page.evaluate(async ({ a, b }) => {
|
|
1340
|
+
const load = (u) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => res(null); i.src = u; });
|
|
1341
|
+
const [ia, ib] = await Promise.all([load(a), load(b)]);
|
|
1342
|
+
if (!ia || !ib) return null;
|
|
1343
|
+
const sig = (img) => {
|
|
1344
|
+
const c = document.createElement('canvas');
|
|
1345
|
+
c.width = 16; c.height = 16;
|
|
1346
|
+
const x = c.getContext('2d');
|
|
1347
|
+
x.drawImage(img, 0, 0, 16, 16);
|
|
1348
|
+
return x.getImageData(0, 0, 16, 16).data;
|
|
1349
|
+
};
|
|
1350
|
+
const p = sig(ia), q = sig(ib);
|
|
1351
|
+
let d = 0;
|
|
1352
|
+
for (let k = 0; k < p.length; k += 4) d += Math.abs(p[k] - q[k]) + Math.abs(p[k + 1] - q[k + 1]) + Math.abs(p[k + 2] - q[k + 2]);
|
|
1353
|
+
return Math.round(d / (256 * 3));
|
|
1354
|
+
}, { a: `data:${ct};base64,${buf.toString('base64')}`, b: thumbDataUrl }).catch(() => null);
|
|
1355
|
+
}
|
|
1356
|
+
} catch { /* tải hỏng = không tính ảnh này */ }
|
|
1357
|
+
cache.set(src, diff);
|
|
1358
|
+
}
|
|
1359
|
+
if (diff !== null && diff !== undefined && (!best || diff < best.diff)) best = { diff, src: src.slice(0, 80) };
|
|
1360
|
+
}
|
|
1361
|
+
return best;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1307
1364
|
// Ảnh bìa của MÌNH có đang hiện trong composer không? Quét mọi ảnh ≥40px trong
|
|
1308
1365
|
// composer (dialog Reel + vùng main của trang tạo), trừ hộp thoại chọn ảnh, so
|
|
1309
1366
|
// nội dung 16x16 với file ảnh bìa như previewMatchesThumb. Dùng cho biến thể nút
|
|
@@ -1333,6 +1390,7 @@ async function composerShowsThumb(page, thumbDataUrl) {
|
|
|
1333
1390
|
const b = sig(mine);
|
|
1334
1391
|
const seen = new Set();
|
|
1335
1392
|
let readable = 0, locked = 0, best = null;
|
|
1393
|
+
const lockedSrcs = [];
|
|
1336
1394
|
for (const root of roots) {
|
|
1337
1395
|
for (const im of root.querySelectorAll('img')) {
|
|
1338
1396
|
if (seen.has(im) || im.closest('[__fbpw_thumb_modal__]')) continue;
|
|
@@ -1340,7 +1398,7 @@ async function composerShowsThumb(page, thumbDataUrl) {
|
|
|
1340
1398
|
const r = im.getBoundingClientRect();
|
|
1341
1399
|
if (r.width < 40 || r.height < 40 || !vis(im) || !im.complete || !im.naturalWidth) continue;
|
|
1342
1400
|
let a;
|
|
1343
|
-
try { a = sig(im); } catch { locked++; continue; }
|
|
1401
|
+
try { a = sig(im); } catch { locked++; lockedSrcs.push(String(im.currentSrc || im.src || '')); continue; }
|
|
1344
1402
|
readable++;
|
|
1345
1403
|
let d = 0;
|
|
1346
1404
|
for (let k = 0; k < a.length; k += 4) d += Math.abs(a[k] - b[k]) + Math.abs(a[k + 1] - b[k + 1]) + Math.abs(a[k + 2] - b[k + 2]);
|
|
@@ -1348,7 +1406,7 @@ async function composerShowsThumb(page, thumbDataUrl) {
|
|
|
1348
1406
|
if (!best || d < best.diff) best = { diff: d, size: `${Math.round(r.width)}x${Math.round(r.height)}` };
|
|
1349
1407
|
}
|
|
1350
1408
|
}
|
|
1351
|
-
return { readable, locked, best };
|
|
1409
|
+
return { readable, locked, best, lockedSrcs: lockedSrcs.slice(0, 8) };
|
|
1352
1410
|
}, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
|
|
1353
1411
|
}
|
|
1354
1412
|
|
|
@@ -1376,15 +1434,22 @@ async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30
|
|
|
1376
1434
|
log('info', `[fb-pw] composer mới — đã đưa file ảnh bìa vào qua ${up.via}, tìm chính ảnh đó trong composer…`);
|
|
1377
1435
|
let streak = 0;
|
|
1378
1436
|
let scan = null;
|
|
1437
|
+
let remote = null;
|
|
1438
|
+
const remoteCache = new Map();
|
|
1379
1439
|
const deadline = Date.now() + verifyMs;
|
|
1380
1440
|
while (Date.now() < deadline) {
|
|
1381
1441
|
await pause(page, 2500);
|
|
1382
1442
|
if (await isCustomThumbApplied(page)) { if (++streak >= 2) break; continue; }
|
|
1383
1443
|
scan = await composerShowsThumb(page, thumbDataUrl);
|
|
1384
1444
|
if (scan.best && scan.best.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
|
|
1445
|
+
// Ảnh CDN khoá canvas → tải về phía Node mà so (xem remoteImagesMatchThumb).
|
|
1446
|
+
if (scan.lockedSrcs && scan.lockedSrcs.length) {
|
|
1447
|
+
remote = await remoteImagesMatchThumb(page, scan.lockedSrcs, thumbDataUrl, remoteCache);
|
|
1448
|
+
if (remote && remote.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
|
|
1449
|
+
}
|
|
1385
1450
|
streak = 0;
|
|
1386
1451
|
}
|
|
1387
|
-
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})` : '—'}` : `lỗi ${x && x.err}`);
|
|
1452
|
+
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}`);
|
|
1388
1453
|
if (streak >= 2) {
|
|
1389
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)})`);
|
|
1390
1455
|
return { applied: true, reason: 'composer hiện đúng ảnh' };
|
|
@@ -1925,6 +1990,115 @@ function parsePageRef(reelsTabUrl) {
|
|
|
1925
1990
|
return { id: '', handle: seg, url: raw };
|
|
1926
1991
|
}
|
|
1927
1992
|
|
|
1993
|
+
// ID page ĐỌC TỪ CHÍNH TRANG, cho ca URL chỉ có tên riêng (`/<handle>/?sk=reels_tab`).
|
|
1994
|
+
//
|
|
1995
|
+
// Vì sao cần: bên Shopee, 6/8 page đang liên kết được lưu dạng
|
|
1996
|
+
// `profile.php?id=…`, KHÔNG có tên riêng trong link. Kênh nào chỉ biết tên riêng
|
|
1997
|
+
// thì không có gì để so → bị báo "chưa liên kết" OAN (đo bằng
|
|
1998
|
+
// services/AffiliateMediaLink 22/09). Dạng tên riêng có thật: ca "Chuyện Nghề
|
|
1999
|
+
// Nhà Nông" 14/09 — dạng profile.php ra 0 tile, chỉ `/chuyennghenhanong/` chạy.
|
|
2000
|
+
//
|
|
2001
|
+
// CHỈ gọi khi đang đứng trên trang tab Reels ĐÃ chứng minh có tile. Đứng ở trang
|
|
2002
|
+
// khác thì mấy khoá dưới đây là của trang đó, không phải của page mình.
|
|
2003
|
+
//
|
|
2004
|
+
// Đo thật 22/09 trên 6 profile của đàn (Toc-xinh, Meo-luoi, Tam-cai-tao-nha,
|
|
2005
|
+
// Beo-banh-beo, duong-nhan-cung-anh, Do-nghe-len-hinh):
|
|
2006
|
+
// `profile_id` == `actorID` == ID page trong URL ở CẢ 6/6.
|
|
2007
|
+
// ⛔ `delegate_page_id` LUÔN là một số KHÁC (vd page 61591917189886 →
|
|
2008
|
+
// delegate 1240255979169594). Bắt nhầm khoá này là ghi sai danh tính rồi
|
|
2009
|
+
// báo sai liên kết cho đúng cái kênh mình định cứu.
|
|
2010
|
+
//
|
|
2011
|
+
// Cổng an toàn: phải có **ít nhất hai nguồn độc lập cùng ra một số** mới nhận.
|
|
2012
|
+
// Một khoá JSON đứng lẻ không đủ — trang Facebook đầy số 15-16 chữ số, và nếu
|
|
2013
|
+
// profile đang đăng nhập bằng tài khoản CÁ NHÂN quản lý page thì `userID` là của
|
|
2014
|
+
// người, không phải của page. Hai nguồn khớp nhau thì khả năng cùng sai rất thấp.
|
|
2015
|
+
const PAGE_ID_JSON_KEYS = ['profile_id', 'actorID', 'pageID'];
|
|
2016
|
+
|
|
2017
|
+
async function readPageIdFromDom(page) {
|
|
2018
|
+
const votes = await page.evaluate((keys) => {
|
|
2019
|
+
const out = {};
|
|
2020
|
+
const add = (src, v) => {
|
|
2021
|
+
if (!/^\d{5,25}$/.test(String(v || ''))) return;
|
|
2022
|
+
(out[src] = out[src] || []).push(String(v));
|
|
2023
|
+
};
|
|
2024
|
+
// Nguồn 1: anchor profile.php NGOÀI bảng tin. Anchor trong feed là của bài
|
|
2025
|
+
// người khác — đúng cái bẫy đã đẻ ra 2 link sai sáng 10/09.
|
|
2026
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
2027
|
+
const href = a.getAttribute('href') || '';
|
|
2028
|
+
if (/[?&]__cft__|[?&]__tn__/.test(href)) continue;
|
|
2029
|
+
if (a.closest("[role='feed'], [role='article'], [aria-posinset]")) continue;
|
|
2030
|
+
const m = href.match(/profile\.php\?id=(\d{5,25})/);
|
|
2031
|
+
if (m) add('anchor', m[1]);
|
|
2032
|
+
}
|
|
2033
|
+
// Nguồn 2..n: khoá JSON trong bundle. `delegate_page_id` cố ý KHÔNG có mặt.
|
|
2034
|
+
const html = document.documentElement.innerHTML;
|
|
2035
|
+
for (const key of keys) {
|
|
2036
|
+
const re = new RegExp(`"${key}"\\s*:\\s*"?(\\d{5,25})"?`, 'g');
|
|
2037
|
+
let m; let n = 0;
|
|
2038
|
+
while ((m = re.exec(html)) && n < 8) { add(key, m[1]); n++; }
|
|
2039
|
+
}
|
|
2040
|
+
return out;
|
|
2041
|
+
}, PAGE_ID_JSON_KEYS).catch(() => ({}));
|
|
2042
|
+
|
|
2043
|
+
// Đếm theo NGUỒN, không theo số lần xuất hiện: một khoá lặp 8 lần trong bundle
|
|
2044
|
+
// vẫn chỉ là MỘT nguồn, không được tự nó đủ phiếu.
|
|
2045
|
+
const bySource = new Map();
|
|
2046
|
+
for (const [src, vals] of Object.entries(votes || {})) {
|
|
2047
|
+
for (const v of new Set(vals)) {
|
|
2048
|
+
if (!bySource.has(v)) bySource.set(v, new Set());
|
|
2049
|
+
bySource.get(v).add(src);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
let best = null;
|
|
2053
|
+
for (const [id, srcs] of bySource) {
|
|
2054
|
+
if (srcs.size < 2) continue;
|
|
2055
|
+
if (!best || srcs.size > best.sources) best = { id, sources: srcs.size, from: [...srcs].sort() };
|
|
2056
|
+
}
|
|
2057
|
+
return best;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// MÃ TRANG mà Shopee dùng để đối chiếu — `delegate_page_id`.
|
|
2061
|
+
//
|
|
2062
|
+
// ⚠️ Page Facebook kiểu mới (New Pages Experience) mang HAI số: `profile id`
|
|
2063
|
+
// nằm trong URL (14 số, mở đầu `61…`) và `delegate_page_id` là ID Trang cổ
|
|
2064
|
+
// điển nằm sau nó (15-16 số, mở đầu `1…`). **Shopee lưu số THỨ HAI.**
|
|
2065
|
+
// Đo 22/09: page `chuyennghenhanong` (profile 61591308775391) hiện trên Shopee
|
|
2066
|
+
// là 1165088293355326 = đúng delegate của nó. So bằng profile id là KHÔNG BAO
|
|
2067
|
+
// GIỜ khớp — lỗi đã lỡ đưa lên prod một lần.
|
|
2068
|
+
//
|
|
2069
|
+
// Khác `profile_id`/`actorID` (là của NGƯỜI ĐANG XEM), `delegate_page_id` nói
|
|
2070
|
+
// về TRANG ĐANG MỞ: đo trên 3 page lạ, nó trả đúng delegate của trang đó.
|
|
2071
|
+
//
|
|
2072
|
+
// Trả { id, checked }:
|
|
2073
|
+
// - đúng MỘT giá trị → id = nó, checked = true
|
|
2074
|
+
// - KHÔNG có giá trị nào → page kiểu CỔ ĐIỂN (profile id chính là khoá
|
|
2075
|
+
// Shopee) → id rỗng, checked = true. Chỉ dám kết luận vậy khi profile id
|
|
2076
|
+
// KHÔNG mang hình dạng page kiểu mới; nếu nó là `61…` mà không đọc được
|
|
2077
|
+
// delegate thì đó là đọc HỤT, không phải page cổ điển.
|
|
2078
|
+
// - NHIỀU giá trị → profile quản nhiều page (đo thật ở
|
|
2079
|
+
// `duong-nhan-cung-anh`: 2 delegate). Không đoán → checked = false, để bên
|
|
2080
|
+
// API giữ trạng thái "chưa rõ" thay vì báo sai.
|
|
2081
|
+
const NEW_STYLE_PROFILE_ID = /^61\d{12}$/;
|
|
2082
|
+
|
|
2083
|
+
async function readDelegatePageId(page, profileId = '') {
|
|
2084
|
+
const found = await page.evaluate(() => {
|
|
2085
|
+
const out = new Set();
|
|
2086
|
+
const re = /"delegate_page_id"\s*:\s*"?(\d{5,25})"?/g;
|
|
2087
|
+
const html = document.documentElement.innerHTML;
|
|
2088
|
+
let m; let n = 0;
|
|
2089
|
+
while ((m = re.exec(html)) && n < 20) { out.add(m[1]); n++; }
|
|
2090
|
+
return [...out];
|
|
2091
|
+
}).catch(() => null);
|
|
2092
|
+
|
|
2093
|
+
if (found === null) return { id: '', checked: false };
|
|
2094
|
+
if (found.length === 1) return { id: found[0], checked: true };
|
|
2095
|
+
if (found.length === 0) {
|
|
2096
|
+
// Không có delegate. Chỉ coi là page cổ điển khi profile id không phải dạng mới.
|
|
2097
|
+
return { id: '', checked: !NEW_STYLE_PROFILE_ID.test(String(profileId || '')) };
|
|
2098
|
+
}
|
|
2099
|
+
return { id: '', checked: false }; // nhiều page trong cùng profile — không đoán
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1928
2102
|
async function hasVisibleReelComposer(page) {
|
|
1929
2103
|
return page.evaluate(() => {
|
|
1930
2104
|
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
@@ -1994,7 +2168,7 @@ async function runOnce({ page, payload, log }) {
|
|
|
1994
2168
|
} = payload || {};
|
|
1995
2169
|
if (!video_url) throw new Error('No video_url provided');
|
|
1996
2170
|
|
|
1997
|
-
log('info', '[fb-pw] selectors version=2026.09.23-
|
|
2171
|
+
log('info', '[fb-pw] selectors version=2026.09.23-reel-overlay-cdn');
|
|
1998
2172
|
|
|
1999
2173
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
2000
2174
|
|
|
@@ -2863,7 +3037,16 @@ async function runOnce({ page, payload, log }) {
|
|
|
2863
3037
|
// thu nhỏ" trong hàng khung, không có nút "Chỉnh sửa hình thu nhỏ". Xem
|
|
2864
3038
|
// applyNewComposerThumb. Chạy ở mọi bước cho tới khi dán được: form một
|
|
2865
3039
|
// trang chỉ có một bước, wizard có thể để hàng ô ở bước sau.
|
|
2866
|
-
|
|
3040
|
+
// Ở /reel/ (composer phủ lên feed Reels) hỏi DOM: có ô "Chọn hình thu
|
|
3041
|
+
// nhỏ"/nút "Chọn ảnh bìa" thì là composer mới, không thì khối cũ lo.
|
|
3042
|
+
const onReelOverlay = isReelOverlayUrl(page.url());
|
|
3043
|
+
let reelOverlayNew = false;
|
|
3044
|
+
if (!customThumbDone && thumbPath && onReelOverlay) {
|
|
3045
|
+
const probe = await readNewComposerThumbRow(page);
|
|
3046
|
+
reelOverlayNew = !!(probe && probe.pickerPresent);
|
|
3047
|
+
log('info', `[fb-pw] composer phủ trên /reel/ — ${reelOverlayNew ? 'có dấu hiệu composer mới → luồng mới' : 'composer kiểu cũ → khối ảnh bìa cũ'}`);
|
|
3048
|
+
}
|
|
3049
|
+
if (!customThumbDone && thumbPath && (isNewComposerUrl(page.url()) || reelOverlayNew)) {
|
|
2867
3050
|
const nt = await applyNewComposerThumb(page, thumbPath, log);
|
|
2868
3051
|
if (nt.applied) customThumbDone = true;
|
|
2869
3052
|
}
|
|
@@ -2872,8 +3055,8 @@ async function runOnce({ page, payload, log }) {
|
|
|
2872
3055
|
// video preview → opens "Chỉnh sửa hình thu nhỏ" modal → "Tải lên" →
|
|
2873
3056
|
// file picker → "Lưu"). Per user screenshots, this is the only way to
|
|
2874
3057
|
// attach a custom thumbnail in the Page-wall composer.
|
|
2875
|
-
if (!customThumbDone && thumbPath && /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url())) {
|
|
2876
|
-
const findThumbBtn = () => page.evaluate(() => {
|
|
3058
|
+
if (!customThumbDone && thumbPath && (/^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url()) || (onReelOverlay && !reelOverlayNew))) {
|
|
3059
|
+
const findThumbBtn = () => page.evaluate((guardNew) => {
|
|
2877
3060
|
// Look for the thumbnail-edit button inside the REEL COMPOSER dialog
|
|
2878
3061
|
// only — scanning all dialogs risked matching a button on the
|
|
2879
3062
|
// page-wall photo composer (which would post a separate "Tin dạng
|
|
@@ -2893,6 +3076,11 @@ async function runOnce({ page, payload, log }) {
|
|
|
2893
3076
|
if (r.width < 8 || r.height < 8) continue;
|
|
2894
3077
|
const cs = getComputedStyle(dlg);
|
|
2895
3078
|
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
3079
|
+
// Trên /reel/: composer MỚI có nút "Chỉnh sửa" riêng trên ô video —
|
|
3080
|
+
// bộ dò narrow-pill dưới đây sẽ bấm nhầm vào trình sửa video. Thấy
|
|
3081
|
+
// dấu hiệu composer mới thì không đụng (luồng mới sẽ lo ở bước sau).
|
|
3082
|
+
if (guardNew && (/Chọn hình thu nhỏ|File phương tiện đã tải lên/i.test(dlg.innerText || '')
|
|
3083
|
+
|| dlg.querySelector("[aria-label='Chọn ảnh bìa']"))) continue;
|
|
2896
3084
|
const cands = dlg.querySelectorAll("[role='button'], button, [aria-label]");
|
|
2897
3085
|
for (const el of cands) {
|
|
2898
3086
|
const t = (el.innerText || '').trim();
|
|
@@ -2915,7 +3103,7 @@ async function runOnce({ page, payload, log }) {
|
|
|
2915
3103
|
}
|
|
2916
3104
|
}
|
|
2917
3105
|
return null;
|
|
2918
|
-
}).catch(() => null);
|
|
3106
|
+
}, onReelOverlay).catch(() => null);
|
|
2919
3107
|
let editBtn = await findThumbBtn();
|
|
2920
3108
|
// The "Chỉnh sửa hình thu nhỏ" pill appears AFTER FB finishes processing
|
|
2921
3109
|
// the video AND, on the Page-wall composer, only on a LATER step (the
|
|
@@ -3928,7 +4116,7 @@ async function runOnce({ page, payload, log }) {
|
|
|
3928
4116
|
// any "Chia sẻ" text on the page falls through to a feed-item's share
|
|
3929
4117
|
// button or sidebar nav, navigating AWAY from the composer and
|
|
3930
4118
|
// breaking the flow (iter-38 ended up on /profile.php?sk=reels_tab).
|
|
3931
|
-
const onPageWall = /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url());
|
|
4119
|
+
const onPageWall = /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url()) || isReelOverlayUrl(page.url());
|
|
3932
4120
|
if (step >= 2 && !published && !onPageWall) {
|
|
3933
4121
|
log('info', '[fb-pw] Tiếp ineffective — trying side-wizard jump to "Chia sẻ" step (BS only)');
|
|
3934
4122
|
const jumpBbox = await page.evaluate(() => {
|
|
@@ -4477,6 +4665,8 @@ module.exports.__testables = {
|
|
|
4477
4665
|
COVER_HEAD_RE,
|
|
4478
4666
|
composerShowsThumb,
|
|
4479
4667
|
applyCoverButtonThumb,
|
|
4668
|
+
remoteImagesMatchThumb,
|
|
4669
|
+
isReelOverlayUrl,
|
|
4480
4670
|
clickPickerAndUpload,
|
|
4481
4671
|
// Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
|
|
4482
4672
|
// lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
|
|
@@ -4488,6 +4678,8 @@ module.exports.__testables = {
|
|
|
4488
4678
|
pickReelsTabUrl,
|
|
4489
4679
|
pickReelsTabUrls,
|
|
4490
4680
|
parsePageRef,
|
|
4681
|
+
readPageIdFromDom,
|
|
4682
|
+
readDelegatePageId,
|
|
4491
4683
|
discoverReelsTabUrls,
|
|
4492
4684
|
openReelsTabWithTiles,
|
|
4493
4685
|
networkIdVerdict,
|