channel-worker 2.5.132 → 2.5.134
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 +0 -23
- package/lib/shopee-report.js +1 -75
- package/lib/shopee-scraper.js +58 -9
- package/package.json +1 -1
- package/scripts/fetch_facebook_reel_stats.js +1 -15
- package/scripts/upload_facebook.js +420 -69
package/lib/command-poller.js
CHANGED
|
@@ -388,29 +388,8 @@ 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
|
-
|
|
408
391
|
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
409
392
|
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,
|
|
414
393
|
last_report_date: result.last_report_date,
|
|
415
394
|
last_update_time: result.last_update_time,
|
|
416
395
|
from: result.from,
|
|
@@ -427,8 +406,6 @@ class CommandPoller {
|
|
|
427
406
|
click_saved: saved?.click_saved || 0,
|
|
428
407
|
click_matched: saved?.click_matched || 0,
|
|
429
408
|
click_unmatched: saved?.click_unmatched || 0,
|
|
430
|
-
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
431
|
-
media_error: mediaError || undefined,
|
|
432
409
|
last_report_date: result.last_report_date,
|
|
433
410
|
},
|
|
434
411
|
});
|
package/lib/shopee-report.js
CHANGED
|
@@ -183,78 +183,4 @@ async function fetchClickReport(ctx, {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
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 };
|
|
186
|
+
module.exports = { fetchConversionReport, fetchClickReport, reportWindow };
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -436,6 +436,29 @@ function cleanSubId(v) {
|
|
|
436
436
|
// `/opaanlp/` — KHÁC hẳn `long_link` của offer list (universal-link + chữ ký
|
|
437
437
|
// Google Ads, không qua đường ghi nhận affiliate nên không ra hoa hồng).
|
|
438
438
|
//
|
|
439
|
+
// Vì sao Shopee không trả link — gom mọi chỗ nó có thể nhét lý do vào.
|
|
440
|
+
//
|
|
441
|
+
// Nó KHÔNG phải lúc nào cũng dùng `errors[]` của GraphQL: đã gặp lượt trả
|
|
442
|
+
// `code 599` + `data: null` (ca sub-id có gạch dưới, 09/09/2026) với
|
|
443
|
+
// `businessCode` nằm thẳng trong thân. Đọc không ra thì in nguyên văn đã cắt
|
|
444
|
+
// ngắn — còn hơn một câu rỗng nghĩa bắt người sau mở trình duyệt ra mò lại.
|
|
445
|
+
function linkFailureReason(res, wanted = 0) {
|
|
446
|
+
const j = res && res.json;
|
|
447
|
+
const bits = [];
|
|
448
|
+
if (res && res.error) bits.push(String(res.error));
|
|
449
|
+
if (res && res.status && res.status !== 200) bits.push(`http ${res.status}`);
|
|
450
|
+
const msg = j?.errors?.[0]?.message || j?.msg || j?.message || j?.error_msg;
|
|
451
|
+
if (msg) bits.push(String(msg));
|
|
452
|
+
for (const k of ['businessCode', 'business_code', 'code', 'error']) {
|
|
453
|
+
const v = j?.[k];
|
|
454
|
+
if (v !== undefined && v !== null && v !== 0 && v !== '') { bits.push(`${k} ${v}`); break; }
|
|
455
|
+
}
|
|
456
|
+
if (!bits.length) {
|
|
457
|
+
bits.push(`Shopee trả 0 link cho ${wanted} sản phẩm: ${JSON.stringify(j ?? res ?? null).slice(0, 300)}`);
|
|
458
|
+
}
|
|
459
|
+
return bits.join(' · ').slice(0, 500);
|
|
460
|
+
}
|
|
461
|
+
|
|
439
462
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
440
463
|
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, log = null } = {}) {
|
|
441
464
|
const list = (items || [])
|
|
@@ -483,27 +506,53 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
483
506
|
}
|
|
484
507
|
const err = res.json?.errors?.[0]?.message;
|
|
485
508
|
if (err) return { status: 'failed', links: [], reason: err };
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
509
|
+
|
|
510
|
+
// CHỈ nhận link của đúng những món đã hỏi, và chỉ nhận dòng CÓ link.
|
|
511
|
+
// Shopee trả `itemId` kèm từng link nên khớp được — không khớp mà vẫn lấy
|
|
512
|
+
// là có ngày ghi link của món khác vào bài, mà `workerLink` bên API khoá
|
|
513
|
+
// theo chính `item_id` này nên sai một chỗ là sai hết chuỗi.
|
|
514
|
+
const wanted = new Set(list.map((it) => it.itemId));
|
|
515
|
+
const rows = Array.isArray(res.json?.data?.productOfferLinks) ? res.json.data.productOfferLinks : [];
|
|
516
|
+
const links = rows
|
|
517
|
+
.map((r) => ({
|
|
518
|
+
item_id: String(r?.itemId ?? ''),
|
|
519
|
+
shop_id: String(r?.shopId ?? ''),
|
|
520
|
+
link: String(r?.productOfferLink || ''),
|
|
521
|
+
}))
|
|
522
|
+
.filter((r) => wanted.has(r.item_id) && r.link);
|
|
523
|
+
const missing = list.map((it) => it.itemId).filter((id) => !links.some((l) => l.item_id === id));
|
|
524
|
+
|
|
525
|
+
// KHÔNG có link nào = THẤT BẠI, không phải "xong với 0 kết quả". Bản đầu
|
|
526
|
+
// trả `status: 'ok'` cho ca này nên lệnh ghi 'done' và giao diện chỉ nói
|
|
527
|
+
// được "Worker không trả về link" — mù nguyên nhân. Mọi kiểu Shopee từ
|
|
528
|
+
// chối mà không đi qua `errors[]` (http 5xx, `data: null`, businessCode
|
|
529
|
+
// nằm trong thân) đều rơi vào đây.
|
|
530
|
+
if (!links.length) {
|
|
531
|
+
return { status: 'failed', links: [], sub_ids: subs, reason: linkFailureReason(res, list.length) };
|
|
532
|
+
}
|
|
533
|
+
if (missing.length && log) log(`[shopee] thiếu link cho ${missing.length}/${list.length} món: ${missing.join(', ')}`);
|
|
534
|
+
return { status: 'ok', sub_ids: subs, links, missing };
|
|
492
535
|
} finally {
|
|
493
536
|
if (opened) await page.close().catch(() => {});
|
|
494
537
|
}
|
|
495
538
|
}
|
|
496
539
|
|
|
540
|
+
// Dạng `-i.<shop>.<item>` neo hai đầu: mở bằng `-` hoặc `/`, đóng ở cuối
|
|
541
|
+
// đường dẫn. Cùng một luật với `api/services/ShopeeUrl.js` (ghi chú dài ở đó)
|
|
542
|
+
// — hai bên lệch nhau thì cùng một link cho ra hai mã sản phẩm khác nhau tuỳ
|
|
543
|
+
// đường đi. Đo 23/09/2026: bản cũ đọc "Mini.2.4G" ra shop 2 / item 4.
|
|
544
|
+
const I_FORM = /[-/]i\.(\d+)\.(\d+)\/?(?:[?#]|$)/;
|
|
545
|
+
|
|
497
546
|
// Parse a Shopee product URL → { shop_id, item_id }. Accepts
|
|
498
547
|
// /product/<shop>/<item> and i.<shop>.<item> forms.
|
|
499
548
|
function parseProductUrl(url = '') {
|
|
500
549
|
// `/opaanlp/` là đích của link affiliate (đo 12/09: link rút gọn s.shopee.vn
|
|
501
550
|
// đá về đúng dạng này) — vẫn là một sản phẩm, vẫn lấy được shop/item.
|
|
502
|
-
let m = url.match(/\/(?:product|opaanlp)\/(\d+)\/(\d+)/);
|
|
551
|
+
let m = String(url || '').match(/\/(?:product|opaanlp)\/(\d+)\/(\d+)/);
|
|
503
552
|
if (m) return { shop_id: m[1], item_id: m[2] };
|
|
504
|
-
m = url.match(
|
|
553
|
+
m = String(url || '').match(I_FORM);
|
|
505
554
|
if (m) return { shop_id: m[1], item_id: m[2] };
|
|
506
555
|
return null;
|
|
507
556
|
}
|
|
508
557
|
|
|
509
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, findAffiliatePage, pruneTabs, isJunkTab };
|
|
558
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, findAffiliatePage, pruneTabs, isJunkTab };
|
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
|
|
41
|
+
const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef } = 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,20 +821,6 @@ 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
824
|
if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
|
|
839
825
|
|
|
840
826
|
const d = await log.apiRequest('POST', '/analytics/post-stats', {
|
|
@@ -722,6 +722,407 @@ async function sampleCustomThumb(page, { samples = 3, gapMs = 1500 } = {}) {
|
|
|
722
722
|
return { verdict: thumbVerdict(out), samples: out };
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
+
// ─── Composer MỚI của Facebook (bắt 22-23/09/2026) ──────────────────────────
|
|
726
|
+
// Ba page (LACCA, Nhà Nhỏ Có Võ, Gà Vườn Mỗi Ngày) hỏng 11/11 lượt với
|
|
727
|
+
// "Custom thumbnail required but not applied". Chuỗi log lượt nào cũng như
|
|
728
|
+
// nhau: nút "Thước phim" trên tường không mở hộp thoại → nhánh dự phòng bấm
|
|
729
|
+
// link "Tạo thước phim" → link đó giờ đưa sang giao diện mới:
|
|
730
|
+
// /post/create form MỘT trang (9 lượt)
|
|
731
|
+
// /reels/create/?surface=ADDL_PROFILE_PLUS wizard "Chỉnh sửa › Cài đặt" (2 lượt)
|
|
732
|
+
// Khối ảnh bìa cũ chỉ chạy khi URL là facebook.com/ trần, nên cả khối bị bỏ
|
|
733
|
+
// qua (log mấy lượt đó không có một dòng thumb nào) và cổng trước Đăng chặn.
|
|
734
|
+
//
|
|
735
|
+
// Giao diện mới không có nút "Chỉnh sửa hình thu nhỏ". Mục "Hình thu nhỏ" là
|
|
736
|
+
// MỘT HÀNG Ô: ô đầu viền nét đứt ghi "Chọn hình thu nhỏ", tiếp theo là các
|
|
737
|
+
// khung FB tự cắt từ video, khung đang chọn viền xanh. KHÔNG được nới regex
|
|
738
|
+
// cho khối cũ: trên /post/create ô video có nút "Chỉnh sửa" riêng, bộ dò
|
|
739
|
+
// "narrow-pill" của khối cũ sẽ bấm nhầm vào trình sửa video.
|
|
740
|
+
const NEW_COMPOSER_URL_RE = /^https?:\/\/(www\.)?facebook\.com\/(post\/create|reels\/create)(\/|\?|#|$)/i;
|
|
741
|
+
function isNewComposerUrl(url) {
|
|
742
|
+
return NEW_COMPOSER_URL_RE.test(String(url || ''));
|
|
743
|
+
}
|
|
744
|
+
// 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
|
+
// khoảng trắng) để không dính caption hay nhãn mục "Hình thu nhỏ".
|
|
746
|
+
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;
|
|
747
|
+
|
|
748
|
+
// Đọc hàng ô ảnh bìa của composer mới. Trả về ảnh chụp trạng thái để so
|
|
749
|
+
// trước/sau khi dán (xem newComposerThumbVerdict), và gắn cờ DOM cho:
|
|
750
|
+
// __fbpw_thumb_pick__ — chỗ bấm của ô "Chọn hình thu nhỏ"
|
|
751
|
+
// __fbpw_thumb_row__ — chính hàng ô (để tìm lại khi ô chọn-ảnh đã biến mất)
|
|
752
|
+
// __fbpw_thumb_input__ — input file CHỈ nhận ảnh nằm trong mục ảnh bìa
|
|
753
|
+
// __fbpw_thumb_tile__ — ô thứ `tagIndex` (dùng khi phải bấm chọn lại)
|
|
754
|
+
// Không bao giờ ném: đọc hỏng trả null, như isCustomThumbApplied.
|
|
755
|
+
async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {}) {
|
|
756
|
+
return page.evaluate(({ pickSrc, diag, tagIndex }) => {
|
|
757
|
+
const PICK = new RegExp(pickSrc, 'i');
|
|
758
|
+
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
759
|
+
const vis = (el) => {
|
|
760
|
+
const r = el.getBoundingClientRect();
|
|
761
|
+
if (r.width < 8 || r.height < 8) return false;
|
|
762
|
+
const cs = getComputedStyle(el);
|
|
763
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
|
|
764
|
+
};
|
|
765
|
+
for (const a of ['__fbpw_thumb_pick__', '__fbpw_thumb_input__', '__fbpw_thumb_tile__']) {
|
|
766
|
+
document.querySelectorAll(`[${a}]`).forEach((el) => el.removeAttribute(a));
|
|
767
|
+
}
|
|
768
|
+
const isPick = (el) => PICK.test(norm(el.getAttribute && el.getAttribute('aria-label')))
|
|
769
|
+
|| PICK.test(norm(el.innerText));
|
|
770
|
+
// Ảnh của một ô: <img>, <image> trong SVG, hoặc background-image. Bỏ qua
|
|
771
|
+
// icon nhỏ (ô chọn-ảnh có icon ~20px).
|
|
772
|
+
const mediaOf = (el) => {
|
|
773
|
+
for (const m of el.querySelectorAll("img, image, [style*='background-image']")) {
|
|
774
|
+
const r = m.getBoundingClientRect();
|
|
775
|
+
if (r.width < 40 || r.height < 40) continue;
|
|
776
|
+
const tag = m.tagName.toLowerCase();
|
|
777
|
+
let src = '';
|
|
778
|
+
if (tag === 'img') src = m.currentSrc || m.src || '';
|
|
779
|
+
else if (tag === 'image') src = m.getAttribute('href') || m.getAttribute('xlink:href') || '';
|
|
780
|
+
else src = ((m.style.backgroundImage || '').match(/url\(["']?(.*?)["']?\)/) || [])[1] || '';
|
|
781
|
+
if (src) return src.slice(0, 500);
|
|
782
|
+
}
|
|
783
|
+
return '';
|
|
784
|
+
};
|
|
785
|
+
const tileLike = (el) => vis(el) && (!!mediaOf(el) || isPick(el));
|
|
786
|
+
// Ô đang chọn: viền/bóng xanh của FB (#0866FF, #1877F2) hoặc aria-*.
|
|
787
|
+
const blueish = (c) => {
|
|
788
|
+
const m = String(c || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/);
|
|
789
|
+
if (!m) return false;
|
|
790
|
+
const [r, g, b] = [+m[1], +m[2], +m[3]];
|
|
791
|
+
const a = m[4] === undefined ? 1 : +m[4];
|
|
792
|
+
return a > 0.5 && b >= 180 && r <= 100 && g >= 60 && g <= 180;
|
|
793
|
+
};
|
|
794
|
+
const selOf = (tile) => {
|
|
795
|
+
for (const e of [tile, ...tile.querySelectorAll('*')].slice(0, 40)) {
|
|
796
|
+
for (const a of ['aria-checked', 'aria-selected', 'aria-pressed', 'aria-current']) {
|
|
797
|
+
if (e.getAttribute(a) === 'true') return true;
|
|
798
|
+
}
|
|
799
|
+
const cs = getComputedStyle(e);
|
|
800
|
+
if (parseFloat(cs.borderTopWidth) >= 2 && cs.borderTopStyle === 'solid' && blueish(cs.borderTopColor)) return true;
|
|
801
|
+
if (cs.boxShadow && cs.boxShadow !== 'none' && blueish(cs.boxShadow)) return true;
|
|
802
|
+
}
|
|
803
|
+
return false;
|
|
804
|
+
};
|
|
805
|
+
// Hàng ô = tổ tiên gần nhất có >= 2 con trông như ô ảnh.
|
|
806
|
+
const rowFrom = (el) => {
|
|
807
|
+
let node = el;
|
|
808
|
+
for (let d = 0; d < 10 && node && node.parentElement; d++) {
|
|
809
|
+
const p = node.parentElement;
|
|
810
|
+
if ([...p.children].filter(tileLike).length >= 2) return p;
|
|
811
|
+
node = p;
|
|
812
|
+
}
|
|
813
|
+
return null;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const roots = [...document.querySelectorAll("[role='dialog']")].filter((d) => vis(d)
|
|
817
|
+
&& /thước phim|reel/i.test(`${d.getAttribute('aria-label') || ''}\n${(d.innerText || '').slice(0, 600)}`));
|
|
818
|
+
const main = document.querySelector("[role='main'], main");
|
|
819
|
+
if (main && vis(main) && /^\/(post|reels)\/create/i.test(location.pathname)) roots.push(main);
|
|
820
|
+
|
|
821
|
+
// Đi theo NÚT CHỮ thay vì đọc innerText của mọi phần tử: composer thật có
|
|
822
|
+
// vài nghìn phần tử, innerText mỗi cái bắt trình duyệt dàn trang lại.
|
|
823
|
+
// Leo tối đa 4 tầng vì chữ có thể bị tách ("Chọn hình" / "thu nhỏ").
|
|
824
|
+
const textHits = (root, keyRe, fullRe) => {
|
|
825
|
+
const out = [];
|
|
826
|
+
const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
827
|
+
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
|
|
828
|
+
if (!keyRe.test(n.data)) continue;
|
|
829
|
+
for (let el = n.parentElement, d = 0; el && d < 4; el = el.parentElement, d++) {
|
|
830
|
+
if (fullRe.test(norm(el.innerText)) && vis(el)) { out.push(el); break; }
|
|
831
|
+
if (el === root) break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return out;
|
|
835
|
+
};
|
|
836
|
+
let pickLeaf = null;
|
|
837
|
+
for (const root of roots) {
|
|
838
|
+
const hits = [...root.querySelectorAll('[aria-label]')].filter((el) => PICK.test(norm(el.getAttribute('aria-label'))) && vis(el))
|
|
839
|
+
.concat(textHits(root, /hình|ảnh|thumbnail/i, PICK));
|
|
840
|
+
if (hits.length) { pickLeaf = hits[0]; break; }
|
|
841
|
+
}
|
|
842
|
+
let row = null;
|
|
843
|
+
let via = '';
|
|
844
|
+
if (pickLeaf) { row = rowFrom(pickLeaf); via = 'pick'; }
|
|
845
|
+
if (!row) {
|
|
846
|
+
const tagged = document.querySelector('[__fbpw_thumb_row__]');
|
|
847
|
+
if (tagged && tagged.isConnected && vis(tagged)) { row = tagged; via = 'tagged'; }
|
|
848
|
+
}
|
|
849
|
+
if (!row) {
|
|
850
|
+
// Ô chọn-ảnh đã biến mất (bị ảnh vừa dán thay chỗ) và FB dựng lại hàng:
|
|
851
|
+
// lần theo nhãn mục "Hình thu nhỏ".
|
|
852
|
+
for (const root of roots) {
|
|
853
|
+
// Leo từ nút chữ lên nên được phần tử SÂU nhất. Quan trọng: khi ô
|
|
854
|
+
// chọn-ảnh đã thành ảnh, cả khung mục (nhãn + hàng ô) cũng chỉ còn
|
|
855
|
+
// đúng chữ "Hình thu nhỏ" — ảnh không có chữ — nên dò từ trên xuống
|
|
856
|
+
// sẽ vớ khung mục chứ không phải nhãn (trang giả lập bắt được ca này).
|
|
857
|
+
const head = textHits(root, /^\s*(Hình thu nhỏ|Thumbnail)\s*$/i, /^(Hình thu nhỏ|Thumbnail)$/i)[0];
|
|
858
|
+
if (!head) continue;
|
|
859
|
+
let node = head;
|
|
860
|
+
for (let d = 0; d < 4 && node && !row; d++) {
|
|
861
|
+
for (let sib = node.nextElementSibling; sib && !row; sib = sib.nextElementSibling) {
|
|
862
|
+
const all = [sib, ...sib.querySelectorAll('*')];
|
|
863
|
+
row = all.find((el) => [...el.children].filter(tileLike).length >= 2) || null;
|
|
864
|
+
}
|
|
865
|
+
node = node.parentElement;
|
|
866
|
+
}
|
|
867
|
+
if (row) { via = 'heading'; break; }
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const customMarker = [...document.querySelectorAll('[aria-label]')].some((el) =>
|
|
871
|
+
/Gỡ hình thu nhỏ tùy chỉnh|Remove( your)? custom thumbnail/i.test(el.getAttribute('aria-label') || '') && vis(el));
|
|
872
|
+
if (!row) {
|
|
873
|
+
const miss = { found: false, pickerPresent: !!pickLeaf, roots: roots.length, customMarker };
|
|
874
|
+
if (diag) {
|
|
875
|
+
// Không thấy hàng ô: kê các nút của composer, như thumb-diag của khối cũ,
|
|
876
|
+
// để biết ảnh bìa nằm ở đâu trên biến thể này.
|
|
877
|
+
miss.inventory = [];
|
|
878
|
+
for (const root of roots) {
|
|
879
|
+
for (const el of root.querySelectorAll("[role='button'], button, [aria-label], input[type='file']")) {
|
|
880
|
+
if (miss.inventory.length >= 45) break;
|
|
881
|
+
const r = el.getBoundingClientRect();
|
|
882
|
+
const t = norm(el.innerText).slice(0, 28);
|
|
883
|
+
const al = (el.getAttribute('aria-label') || '').slice(0, 40);
|
|
884
|
+
const acc = el.tagName === 'INPUT' ? ` accept=${el.getAttribute('accept') || ''}` : '';
|
|
885
|
+
if ((t || al || acc) && (acc || (r.width >= 10 && r.height >= 10))) miss.inventory.push(`<${el.tagName.toLowerCase()}> t="${t}" al="${al}"${acc} ${Math.round(r.width)}x${Math.round(r.height)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return miss;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
document.querySelectorAll('[__fbpw_thumb_row__]').forEach((el) => { if (el !== row) el.removeAttribute('__fbpw_thumb_row__'); });
|
|
893
|
+
row.setAttribute('__fbpw_thumb_row__', '1');
|
|
894
|
+
const tiles = [...row.children].filter(tileLike);
|
|
895
|
+
const out = tiles.map((t) => ({ img: mediaOf(t), placeholder: isPick(t), selected: selOf(t) }));
|
|
896
|
+
const flagged = out.map((t, i) => (t.selected ? i : -1)).filter((i) => i >= 0);
|
|
897
|
+
|
|
898
|
+
let pickerPresent = false;
|
|
899
|
+
const pickTile = tiles.find((t) => isPick(t));
|
|
900
|
+
if (pickTile) {
|
|
901
|
+
pickerPresent = true;
|
|
902
|
+
// Bấm vào phần tử bấm-được sâu nhất bên trong ô, không có thì bấm cả ô.
|
|
903
|
+
let target = null;
|
|
904
|
+
const leaf = [...pickTile.querySelectorAll('*')].filter((el) => vis(el) && isPick(el)).pop() || pickTile;
|
|
905
|
+
for (let n = leaf; n && n !== row; n = n.parentElement) {
|
|
906
|
+
if (n.matches("[role='button'], button, label, a, [tabindex]")) { target = n; break; }
|
|
907
|
+
}
|
|
908
|
+
(target || pickTile).setAttribute('__fbpw_thumb_pick__', '1');
|
|
909
|
+
}
|
|
910
|
+
let input = null;
|
|
911
|
+
for (let scope = row, d = 0; scope && d < 3 && !input; scope = scope.parentElement, d++) {
|
|
912
|
+
for (const el of scope.querySelectorAll("input[type='file']")) {
|
|
913
|
+
const accept = (el.getAttribute('accept') || '').toLowerCase();
|
|
914
|
+
if (!/image/.test(accept) || /video/.test(accept)) continue; // input của "Thêm file khác" nhận cả video
|
|
915
|
+
el.setAttribute('__fbpw_thumb_input__', '1');
|
|
916
|
+
input = { accept: accept.slice(0, 60) };
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (tagIndex >= 0 && tiles[tagIndex]) tiles[tagIndex].setAttribute('__fbpw_thumb_tile__', '1');
|
|
921
|
+
|
|
922
|
+
const res = {
|
|
923
|
+
found: true,
|
|
924
|
+
via,
|
|
925
|
+
pickerPresent,
|
|
926
|
+
tiles: out,
|
|
927
|
+
selectedIndex: flagged.length === 1 ? flagged[0] : null,
|
|
928
|
+
customMarker,
|
|
929
|
+
input,
|
|
930
|
+
};
|
|
931
|
+
if (diag) {
|
|
932
|
+
// Khung xương mục ảnh bìa, để lần sau FB đổi DOM còn có cái mà soi.
|
|
933
|
+
const sec = row.parentElement || row;
|
|
934
|
+
res.skeleton = [sec, ...sec.querySelectorAll('*')].filter((el) => el.tagName !== 'path').slice(0, 60).map((el) => {
|
|
935
|
+
const r = el.getBoundingClientRect();
|
|
936
|
+
const role = el.getAttribute('role');
|
|
937
|
+
const al = el.getAttribute('aria-label');
|
|
938
|
+
const own = el.children.length ? '' : norm(el.innerText).slice(0, 20);
|
|
939
|
+
const acc = el.tagName === 'INPUT' ? ` accept=${el.getAttribute('accept') || ''}` : '';
|
|
940
|
+
return `<${el.tagName.toLowerCase()}${role ? ` role=${role}` : ''}${al ? ` al="${al.slice(0, 30)}"` : ''}${own ? ` t="${own}"` : ''}${acc}> ${Math.round(r.width)}x${Math.round(r.height)}`;
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
return res;
|
|
944
|
+
}, { pickSrc: THUMB_PICKER_TEXT_RE.source, diag, tagIndex }).catch(() => null);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Ảnh bìa tự chọn đã VÀO composer mới chưa? So hàng ô trước và sau khi dán
|
|
948
|
+
// (thuần, test được). Chỉ nhận hai dấu hiệu gắn với CHÍNH ô chọn-ảnh, không
|
|
949
|
+
// nhận "có ảnh lạ xuất hiện đâu đó": FB có thể dựng lại các khung tự động với
|
|
950
|
+
// URL mới, coi đó là ảnh của mình thì bài lên với ảnh bìa tự động — đúng thứ
|
|
951
|
+
// cổng trước Đăng sinh ra để chặn.
|
|
952
|
+
// A. ô chọn-ảnh bị thay bằng một ảnh mới (cùng vị trí, hết chữ "Chọn…")
|
|
953
|
+
// B. hàng thêm đúng MỘT ô, ô mới có ảnh chưa từng thấy và nằm sát ô chọn-ảnh
|
|
954
|
+
// Rồi mới xét ô đang chọn: đọc được mà không phải ô của mình → 'not-selected'.
|
|
955
|
+
function newComposerThumbVerdict(before, after) {
|
|
956
|
+
if (after && after.customMarker) return { verdict: 'applied', why: 'nút "Gỡ hình thu nhỏ tùy chỉnh" đang hiện' };
|
|
957
|
+
if (!before || !before.found || !after || !after.found) {
|
|
958
|
+
return { verdict: 'unknown', why: 'không đọc được hàng ô ảnh bìa' };
|
|
959
|
+
}
|
|
960
|
+
const bTiles = before.tiles || [];
|
|
961
|
+
const aTiles = after.tiles || [];
|
|
962
|
+
const seen = new Set(bTiles.map((t) => t.img).filter(Boolean));
|
|
963
|
+
const p = bTiles.findIndex((t) => t.placeholder);
|
|
964
|
+
let custom = -1;
|
|
965
|
+
let how = '';
|
|
966
|
+
if (p >= 0 && aTiles[p] && aTiles[p].img && !aTiles[p].placeholder && !seen.has(aTiles[p].img)) {
|
|
967
|
+
custom = p;
|
|
968
|
+
how = 'ô "Chọn hình thu nhỏ" đã thành ảnh mới';
|
|
969
|
+
}
|
|
970
|
+
if (custom < 0 && aTiles.length === bTiles.length + 1) {
|
|
971
|
+
const anchor = Math.max(p, 0);
|
|
972
|
+
const i = aTiles.findIndex((t, k) => t.img && !seen.has(t.img) && Math.abs(k - anchor) <= 1);
|
|
973
|
+
if (i >= 0) { custom = i; how = `thêm ô ảnh mới ở vị trí ${i}`; }
|
|
974
|
+
}
|
|
975
|
+
if (custom < 0) return { verdict: 'absent', why: 'hàng ô không có ảnh nào mới ở chỗ ô chọn-ảnh' };
|
|
976
|
+
const sel = after.selectedIndex;
|
|
977
|
+
if (sel === null || sel === undefined) {
|
|
978
|
+
return { verdict: 'applied', customIndex: custom, why: `${how}; không đọc được ô đang chọn` };
|
|
979
|
+
}
|
|
980
|
+
if (sel === custom) return { verdict: 'applied', customIndex: custom, why: `${how}, đang được chọn` };
|
|
981
|
+
return { verdict: 'not-selected', customIndex: custom, why: `${how} nhưng ô đang chọn là ${sel}` };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Tóm tắt một hàng ô cho log: "5 ô [chọn-ảnh, *khung, khung…] chọn=1 input=image/*".
|
|
985
|
+
function describeThumbRow(snap) {
|
|
986
|
+
if (!snap) return 'không đọc được';
|
|
987
|
+
if (!snap.found) return `không thấy hàng ô (composer=${snap.roots || 0}, ô chọn-ảnh=${snap.pickerPresent ? 'có' : 'không'})`;
|
|
988
|
+
const cells = (snap.tiles || []).map((t) => `${t.selected ? '*' : ''}${t.placeholder ? 'chọn-ảnh' : (t.img ? (/^(blob|data):/.test(t.img) ? 'ảnh-local' : 'khung') : '?')}`);
|
|
989
|
+
return `${cells.length} ô [${cells.join(', ')}] chọn=${snap.selectedIndex ?? '?'} input=${snap.input ? snap.input.accept || '?' : 'không'} via=${snap.via}`;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Hộp phụ FB có thể bật lên sau khi nhận ảnh (cắt ảnh / xác nhận). Chưa thấy
|
|
993
|
+
// tận mắt — chỉ bấm nút xác nhận của một dialog KHÁC composer mà trong chữ
|
|
994
|
+
// có nhắc ảnh bìa, để không bao giờ trúng nút "Lưu" (= lưu nháp) của composer.
|
|
995
|
+
async function confirmNewComposerThumbDialog(page, log) {
|
|
996
|
+
const hit = await page.evaluate((compSrc) => {
|
|
997
|
+
const COMP = new RegExp(compSrc, 'i');
|
|
998
|
+
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
999
|
+
const r = dlg.getBoundingClientRect();
|
|
1000
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
1001
|
+
const head = `${dlg.getAttribute('aria-label') || ''} | ${(dlg.innerText || '').slice(0, 300)}`;
|
|
1002
|
+
if (COMP.test(head)) continue;
|
|
1003
|
+
if (!/hình thu nhỏ|ảnh thu nhỏ|thumbnail|cắt ảnh|crop/i.test(head)) continue;
|
|
1004
|
+
for (const b of dlg.querySelectorAll("button, [role='button']")) {
|
|
1005
|
+
const t = (b.innerText || '').trim();
|
|
1006
|
+
const br = b.getBoundingClientRect();
|
|
1007
|
+
if (br.width < 8 || br.height < 8 || b.getAttribute('aria-disabled') === 'true') continue;
|
|
1008
|
+
if (!/^(Lưu|Xong|Áp dụng|Save|Done|Apply)$/i.test(t)) continue;
|
|
1009
|
+
b.setAttribute('__fbpw_thumb_confirm__', '1');
|
|
1010
|
+
return { verb: t, head: head.slice(0, 60).replace(/\s+/g, ' ') };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}, COMPOSER_SIGNATURE_RE.source).catch(() => null);
|
|
1015
|
+
if (!hit) return false;
|
|
1016
|
+
log('info', `[fb-pw] composer mới — hộp phụ ảnh bìa "${hit.head}" → bấm "${hit.verb}"`);
|
|
1017
|
+
await page.locator("[__fbpw_thumb_confirm__='1']").click({ timeout: 3000 }).catch(() => {});
|
|
1018
|
+
await page.evaluate(() => document.querySelectorAll('[__fbpw_thumb_confirm__]').forEach((el) => el.removeAttribute('__fbpw_thumb_confirm__'))).catch(() => {});
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Dán ảnh bìa vào composer mới. Trả { applied, reason }. Không ném: thất bại
|
|
1023
|
+
// thì cờ customThumbDone không bật và cổng trước Đăng chặn như cũ.
|
|
1024
|
+
async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_000, verifyMs = 30_000 } = {}) {
|
|
1025
|
+
const t0 = Date.now();
|
|
1026
|
+
const ready = (s) => !!(s && s.found && s.pickerPresent);
|
|
1027
|
+
let snap = await readNewComposerThumbRow(page);
|
|
1028
|
+
// Hàng ảnh bìa chỉ hiện sau khi FB cắt xong khung từ video.
|
|
1029
|
+
while (!ready(snap) && Date.now() - t0 < waitPickerMs) {
|
|
1030
|
+
await pause(page, 2500);
|
|
1031
|
+
snap = await readNewComposerThumbRow(page);
|
|
1032
|
+
}
|
|
1033
|
+
if (!ready(snap)) {
|
|
1034
|
+
const d = await readNewComposerThumbRow(page, { diag: true });
|
|
1035
|
+
log('info', `[fb-pw] composer mới — chưa thấy ô "Chọn hình thu nhỏ" sau ${Math.round((Date.now() - t0) / 1000)}s: ${describeThumbRow(d)}${d && d.skeleton ? ` skeleton=${JSON.stringify(d.skeleton)}` : ''}${d && d.inventory ? ` nút=${JSON.stringify(d.inventory)}` : ''}`);
|
|
1036
|
+
return { applied: false, reason: 'no-picker' };
|
|
1037
|
+
}
|
|
1038
|
+
// Lấy mốc "trước" khi số ô đã đứng yên: FB mọc thêm khung dần dần, một khung
|
|
1039
|
+
// mọc muộn không được bị tưởng là ảnh bìa của mình.
|
|
1040
|
+
for (let i = 0; i < 3; i++) {
|
|
1041
|
+
await pause(page, 1500);
|
|
1042
|
+
const again = await readNewComposerThumbRow(page);
|
|
1043
|
+
if (!ready(again)) continue;
|
|
1044
|
+
const settled = again.tiles.length === snap.tiles.length;
|
|
1045
|
+
snap = again;
|
|
1046
|
+
if (settled) break;
|
|
1047
|
+
}
|
|
1048
|
+
const before = snap;
|
|
1049
|
+
log('info', `[fb-pw] composer mới — hàng ảnh bìa trước khi dán: ${describeThumbRow(before)}`);
|
|
1050
|
+
|
|
1051
|
+
let via = '';
|
|
1052
|
+
try {
|
|
1053
|
+
const [chooser] = await Promise.all([
|
|
1054
|
+
page.waitForEvent('filechooser', { timeout: 8000 }),
|
|
1055
|
+
page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }),
|
|
1056
|
+
]);
|
|
1057
|
+
await chooser.setFiles(thumbPath);
|
|
1058
|
+
via = 'hộp chọn file';
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
log('info', `[fb-pw] composer mới — bấm ô "Chọn hình thu nhỏ" không mở hộp chọn file: ${String(e && e.message || e).slice(0, 80)}`);
|
|
1061
|
+
}
|
|
1062
|
+
if (!via) {
|
|
1063
|
+
await readNewComposerThumbRow(page); // cú bấm có thể làm FB dựng lại DOM → gắn cờ lại
|
|
1064
|
+
const input = page.locator("[__fbpw_thumb_input__='1']").first();
|
|
1065
|
+
if (await input.count().catch(() => 0)) {
|
|
1066
|
+
try {
|
|
1067
|
+
await input.setInputFiles(thumbPath);
|
|
1068
|
+
via = 'input file chỉ nhận ảnh';
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
log('info', `[fb-pw] composer mới — input ảnh bìa từ chối file: ${String(e && e.message || e).slice(0, 80)}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (!via) {
|
|
1075
|
+
const d = await readNewComposerThumbRow(page, { diag: true });
|
|
1076
|
+
log('warn', `[fb-pw] composer mới — KHÔNG đưa được file ảnh bìa vào: ${describeThumbRow(d)}${d && d.skeleton ? ` skeleton=${JSON.stringify(d.skeleton)}` : ''}`);
|
|
1077
|
+
return { applied: false, reason: 'no-upload' };
|
|
1078
|
+
}
|
|
1079
|
+
log('info', `[fb-pw] composer mới — đã đưa file ảnh bìa vào qua ${via}, chờ FB nhận…`);
|
|
1080
|
+
|
|
1081
|
+
let v = { verdict: 'unknown', why: 'chưa đo' };
|
|
1082
|
+
let after = null;
|
|
1083
|
+
// Đòi HAI nhịp liền cùng thấy ảnh đã vào: ô có thể hiện ảnh xem trước ngay
|
|
1084
|
+
// khi nhận file, lúc FB còn đang tải lên và có thể trả lỗi rồi gỡ ra.
|
|
1085
|
+
let appliedStreak = 0;
|
|
1086
|
+
let notSelectedPolls = 0;
|
|
1087
|
+
let selectTried = false;
|
|
1088
|
+
let confirmTried = false;
|
|
1089
|
+
const deadline = Date.now() + verifyMs;
|
|
1090
|
+
while (Date.now() < deadline) {
|
|
1091
|
+
await pause(page, 2500);
|
|
1092
|
+
after = await readNewComposerThumbRow(page);
|
|
1093
|
+
v = newComposerThumbVerdict(before, after);
|
|
1094
|
+
if (v.verdict === 'applied') {
|
|
1095
|
+
if (++appliedStreak >= 2) break;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
appliedStreak = 0;
|
|
1099
|
+
if (v.verdict === 'absent' && !confirmTried) {
|
|
1100
|
+
confirmTried = await confirmNewComposerThumbDialog(page, log);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
// FB có thể cần một nhịp mới chuyển viền xanh sang ảnh vừa dán; hai nhịp
|
|
1104
|
+
// liền vẫn lệch thì bấm chọn ô đó MỘT lần. Canh sẵn hộp chọn file: nếu cú
|
|
1105
|
+
// bấm lại mở hộp chọn (ô vẫn là nút tải lên), dán lại đúng file đó chứ
|
|
1106
|
+
// không để hộp thoại hệ điều hành treo trên trình duyệt.
|
|
1107
|
+
if (v.verdict === 'not-selected' && ++notSelectedPolls >= 2 && !selectTried) {
|
|
1108
|
+
selectTried = true;
|
|
1109
|
+
await readNewComposerThumbRow(page, { tagIndex: v.customIndex });
|
|
1110
|
+
const chooserP = page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null);
|
|
1111
|
+
await page.locator("[__fbpw_thumb_tile__='1']").first().click({ timeout: 3000 }).catch(() => {});
|
|
1112
|
+
const ch = await chooserP;
|
|
1113
|
+
if (ch) await ch.setFiles(thumbPath).catch(() => {});
|
|
1114
|
+
log('info', `[fb-pw] composer mới — ${v.why} → bấm chọn ô ${v.customIndex}${ch ? ' (mở lại hộp chọn file, dán lại)' : ''}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (v.verdict === 'applied' && appliedStreak >= 2) {
|
|
1118
|
+
log('info', `[fb-pw] composer mới — ảnh bìa đã vào: ${v.why} — ${describeThumbRow(after)}`);
|
|
1119
|
+
return { applied: true, reason: v.why };
|
|
1120
|
+
}
|
|
1121
|
+
const d = await readNewComposerThumbRow(page, { diag: true });
|
|
1122
|
+
log('warn', `[fb-pw] composer mới — dán ảnh bìa KHÔNG xác nhận được (${v.verdict}: ${v.why}) — trước: ${describeThumbRow(before)} — sau: ${describeThumbRow(d)}${d && d.skeleton ? ` skeleton=${JSON.stringify(d.skeleton)}` : ''}`);
|
|
1123
|
+
return { applied: false, reason: v.verdict };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
725
1126
|
// Composer đang ở trạng thái gì? Đọc từ CHỮ trong hộp thoại — ba chữ ký nhìn
|
|
726
1127
|
// được từ ảnh dump 10-11/09 (5 ca "KHÔNG commit" + 2 ca no-advance-3):
|
|
727
1128
|
// rightsCheck : dòng đỏ "Không hoàn tất được quy trình kiểm tra bản quyền."
|
|
@@ -1155,73 +1556,6 @@ function parsePageRef(reelsTabUrl) {
|
|
|
1155
1556
|
return { id: '', handle: seg, url: raw };
|
|
1156
1557
|
}
|
|
1157
1558
|
|
|
1158
|
-
// ID page ĐỌC TỪ CHÍNH TRANG, cho ca URL chỉ có tên riêng (`/<handle>/?sk=reels_tab`).
|
|
1159
|
-
//
|
|
1160
|
-
// Vì sao cần: bên Shopee, 6/8 page đang liên kết được lưu dạng
|
|
1161
|
-
// `profile.php?id=…`, KHÔNG có tên riêng trong link. Kênh nào chỉ biết tên riêng
|
|
1162
|
-
// thì không có gì để so → bị báo "chưa liên kết" OAN (đo bằng
|
|
1163
|
-
// services/AffiliateMediaLink 22/09). Dạng tên riêng có thật: ca "Chuyện Nghề
|
|
1164
|
-
// Nhà Nông" 14/09 — dạng profile.php ra 0 tile, chỉ `/chuyennghenhanong/` chạy.
|
|
1165
|
-
//
|
|
1166
|
-
// CHỈ gọi khi đang đứng trên trang tab Reels ĐÃ chứng minh có tile. Đứng ở trang
|
|
1167
|
-
// khác thì mấy khoá dưới đây là của trang đó, không phải của page mình.
|
|
1168
|
-
//
|
|
1169
|
-
// Đo thật 22/09 trên 6 profile của đàn (Toc-xinh, Meo-luoi, Tam-cai-tao-nha,
|
|
1170
|
-
// Beo-banh-beo, duong-nhan-cung-anh, Do-nghe-len-hinh):
|
|
1171
|
-
// `profile_id` == `actorID` == ID page trong URL ở CẢ 6/6.
|
|
1172
|
-
// ⛔ `delegate_page_id` LUÔN là một số KHÁC (vd page 61591917189886 →
|
|
1173
|
-
// delegate 1240255979169594). Bắt nhầm khoá này là ghi sai danh tính rồi
|
|
1174
|
-
// báo sai liên kết cho đúng cái kênh mình định cứu.
|
|
1175
|
-
//
|
|
1176
|
-
// 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.
|
|
1177
|
-
// Một khoá JSON đứng lẻ không đủ — trang Facebook đầy số 15-16 chữ số, và nếu
|
|
1178
|
-
// profile đang đăng nhập bằng tài khoản CÁ NHÂN quản lý page thì `userID` là của
|
|
1179
|
-
// 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.
|
|
1180
|
-
const PAGE_ID_JSON_KEYS = ['profile_id', 'actorID', 'pageID'];
|
|
1181
|
-
|
|
1182
|
-
async function readPageIdFromDom(page) {
|
|
1183
|
-
const votes = await page.evaluate((keys) => {
|
|
1184
|
-
const out = {};
|
|
1185
|
-
const add = (src, v) => {
|
|
1186
|
-
if (!/^\d{5,25}$/.test(String(v || ''))) return;
|
|
1187
|
-
(out[src] = out[src] || []).push(String(v));
|
|
1188
|
-
};
|
|
1189
|
-
// Nguồn 1: anchor profile.php NGOÀI bảng tin. Anchor trong feed là của bài
|
|
1190
|
-
// người khác — đúng cái bẫy đã đẻ ra 2 link sai sáng 10/09.
|
|
1191
|
-
for (const a of document.querySelectorAll('a[href]')) {
|
|
1192
|
-
const href = a.getAttribute('href') || '';
|
|
1193
|
-
if (/[?&]__cft__|[?&]__tn__/.test(href)) continue;
|
|
1194
|
-
if (a.closest("[role='feed'], [role='article'], [aria-posinset]")) continue;
|
|
1195
|
-
const m = href.match(/profile\.php\?id=(\d{5,25})/);
|
|
1196
|
-
if (m) add('anchor', m[1]);
|
|
1197
|
-
}
|
|
1198
|
-
// Nguồn 2..n: khoá JSON trong bundle. `delegate_page_id` cố ý KHÔNG có mặt.
|
|
1199
|
-
const html = document.documentElement.innerHTML;
|
|
1200
|
-
for (const key of keys) {
|
|
1201
|
-
const re = new RegExp(`"${key}"\\s*:\\s*"?(\\d{5,25})"?`, 'g');
|
|
1202
|
-
let m; let n = 0;
|
|
1203
|
-
while ((m = re.exec(html)) && n < 8) { add(key, m[1]); n++; }
|
|
1204
|
-
}
|
|
1205
|
-
return out;
|
|
1206
|
-
}, PAGE_ID_JSON_KEYS).catch(() => ({}));
|
|
1207
|
-
|
|
1208
|
-
// Đếm theo NGUỒN, không theo số lần xuất hiện: một khoá lặp 8 lần trong bundle
|
|
1209
|
-
// vẫn chỉ là MỘT nguồn, không được tự nó đủ phiếu.
|
|
1210
|
-
const bySource = new Map();
|
|
1211
|
-
for (const [src, vals] of Object.entries(votes || {})) {
|
|
1212
|
-
for (const v of new Set(vals)) {
|
|
1213
|
-
if (!bySource.has(v)) bySource.set(v, new Set());
|
|
1214
|
-
bySource.get(v).add(src);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
let best = null;
|
|
1218
|
-
for (const [id, srcs] of bySource) {
|
|
1219
|
-
if (srcs.size < 2) continue;
|
|
1220
|
-
if (!best || srcs.size > best.sources) best = { id, sources: srcs.size, from: [...srcs].sort() };
|
|
1221
|
-
}
|
|
1222
|
-
return best;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
1559
|
async function hasVisibleReelComposer(page) {
|
|
1226
1560
|
return page.evaluate(() => {
|
|
1227
1561
|
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
@@ -1291,7 +1625,7 @@ async function runOnce({ page, payload, log }) {
|
|
|
1291
1625
|
} = payload || {};
|
|
1292
1626
|
if (!video_url) throw new Error('No video_url provided');
|
|
1293
1627
|
|
|
1294
|
-
log('info', '[fb-pw] selectors version=2026.09.
|
|
1628
|
+
log('info', '[fb-pw] selectors version=2026.09.23-new-composer-thumb');
|
|
1295
1629
|
|
|
1296
1630
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
1297
1631
|
|
|
@@ -2156,6 +2490,15 @@ async function runOnce({ page, payload, log }) {
|
|
|
2156
2490
|
await pause(page, 3000);
|
|
2157
2491
|
await fillMetadata();
|
|
2158
2492
|
|
|
2493
|
+
// COMPOSER MỚI (/post/create, /reels/create…) — ảnh bìa là ô "Chọn hình
|
|
2494
|
+
// thu nhỏ" trong hàng khung, không có nút "Chỉnh sửa hình thu nhỏ". Xem
|
|
2495
|
+
// applyNewComposerThumb. Chạy ở mọi bước cho tới khi dán được: form một
|
|
2496
|
+
// trang chỉ có một bước, wizard có thể để hàng ô ở bước sau.
|
|
2497
|
+
if (!customThumbDone && thumbPath && isNewComposerUrl(page.url())) {
|
|
2498
|
+
const nt = await applyNewComposerThumb(page, thumbPath, log);
|
|
2499
|
+
if (nt.applied) customThumbDone = true;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2159
2502
|
// PAGE-WALL thumb flow (form 2 has a "Chỉnh sửa" overlay button on the
|
|
2160
2503
|
// video preview → opens "Chỉnh sửa hình thu nhỏ" modal → "Tải lên" →
|
|
2161
2504
|
// file picker → "Lưu"). Per user screenshots, this is the only way to
|
|
@@ -3748,6 +4091,15 @@ module.exports.__testables = {
|
|
|
3748
4091
|
// Phán quyết "ảnh bìa đã áp chưa" từ nhiều mẫu đo — một mẫu đo hỏng không
|
|
3749
4092
|
// được biến thành "chưa áp" rồi kéo theo hành động PHÁ (Hủy = gỡ ảnh bìa).
|
|
3750
4093
|
thumbVerdict,
|
|
4094
|
+
// Composer mới (/post/create, /reels/create…) — ảnh bìa là ô "Chọn hình thu
|
|
4095
|
+
// nhỏ" trong hàng khung. Phán quyết thuần + luồng dán để chạy thử trên trang
|
|
4096
|
+
// giả lập.
|
|
4097
|
+
isNewComposerUrl,
|
|
4098
|
+
THUMB_PICKER_TEXT_RE,
|
|
4099
|
+
newComposerThumbVerdict,
|
|
4100
|
+
describeThumbRow,
|
|
4101
|
+
readNewComposerThumbRow,
|
|
4102
|
+
applyNewComposerThumb,
|
|
3751
4103
|
// Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
|
|
3752
4104
|
// lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
|
|
3753
4105
|
dumpFailure,
|
|
@@ -3758,7 +4110,6 @@ module.exports.__testables = {
|
|
|
3758
4110
|
pickReelsTabUrl,
|
|
3759
4111
|
pickReelsTabUrls,
|
|
3760
4112
|
parsePageRef,
|
|
3761
|
-
readPageIdFromDom,
|
|
3762
4113
|
discoverReelsTabUrls,
|
|
3763
4114
|
openReelsTabWithTiles,
|
|
3764
4115
|
networkIdVerdict,
|