channel-worker 2.5.53 → 2.5.55
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
CHANGED
|
@@ -289,6 +289,9 @@ class CommandPoller {
|
|
|
289
289
|
// Publish scripts attach screenshots to warning logs through this helper.
|
|
290
290
|
// Keep the command/job identity here so the API and idea Publish History
|
|
291
291
|
// can associate an artifact with the exact platform attempt.
|
|
292
|
+
// Script chỉ-đọc (fetch_facebook_reel_stats) gửi số liệu về API bằng token
|
|
293
|
+
// của chính worker — không cần biết base URL hay token.
|
|
294
|
+
log.apiRequest = (method, path, body) => this.api.request(method, path, body);
|
|
292
295
|
log.uploadDebugScreenshot = (filePath, meta = {}) => this.api.uploadDebugScreenshot({
|
|
293
296
|
filePath,
|
|
294
297
|
tag: meta.tag || 'failure',
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Đọc LƯỢT XEM từng reel trên tab Reels của page (profile.php?id=…&sk=reels_tab)
|
|
2
|
+
// rồi gửi về API /analytics/post-stats. Giai đoạn 2 của bộ nhặt KOL: biết bài
|
|
3
|
+
// từ KOL nào có view để tự chỉnh trọng số. 1 lượt/kênh/ngày, chỉ đọc — không
|
|
4
|
+
// bấm gì ngoài cuộn trang.
|
|
5
|
+
//
|
|
6
|
+
// Payload: { channel_id, profile_id, page_url? }
|
|
7
|
+
// page_url: link page (nếu có) — không có thì tự tìm reels_tab từ trang chủ
|
|
8
|
+
// như upload_facebook (anchor /profile.php?id=… hoặc "Dòng thời gian của X").
|
|
9
|
+
//
|
|
10
|
+
// Tile reels_tab: <a href="/reel/<id>/" aria-label="Bản xem trước ô thước phim">
|
|
11
|
+
// chứa số lượt xem dạng rút gọn tiếng Việt: "1,2 N" (nghìn), "3,4 Tr" (triệu),
|
|
12
|
+
// hoặc số thường "856". Giao diện tiếng Anh: "1.2K", "3.4M".
|
|
13
|
+
|
|
14
|
+
function parseViews(txt) {
|
|
15
|
+
const t = String(txt || '').replace(/\s+/g, ' ').trim();
|
|
16
|
+
// Lấy cụm số + hậu tố đầu tiên. Dấu phẩy là thập phân ở VN ("1,2 N"), dấu
|
|
17
|
+
// chấm là nghìn ("1.234") — phân biệt bằng hậu tố: có N/Tr/K/M thì là thập phân.
|
|
18
|
+
const m = /(\d+(?:[.,]\d+)?)\s*(N|Tr|K|M|B|tỷ|nghìn|triệu)?\b/i.exec(t);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
let num = m[1]; const suf = (m[2] || '').toLowerCase();
|
|
21
|
+
if (suf) num = parseFloat(num.replace(',', '.'));
|
|
22
|
+
else num = parseInt(num.replace(/[.,]/g, ''), 10);
|
|
23
|
+
if (!Number.isFinite(num)) return null;
|
|
24
|
+
const mul = { n: 1e3, k: 1e3, 'nghìn': 1e3, tr: 1e6, m: 1e6, 'triệu': 1e6, b: 1e9, 'tỷ': 1e9 }[suf] || 1;
|
|
25
|
+
return Math.round(num * mul);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function findReelsTabHref(page) {
|
|
29
|
+
return page.evaluate(() => {
|
|
30
|
+
const direct = document.querySelectorAll("a[href*='sk=reels_tab']");
|
|
31
|
+
for (const a of direct) { const h = a.getAttribute('href') || ''; if (h) return h.startsWith('http') ? h : `https://www.facebook.com${h}`; }
|
|
32
|
+
for (const a of document.querySelectorAll("a[href*='/profile.php?id=']")) {
|
|
33
|
+
const m = (a.getAttribute('href') || '').match(/\/profile\.php\?id=(\d+)/);
|
|
34
|
+
if (m) return `https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`;
|
|
35
|
+
}
|
|
36
|
+
for (const a of document.querySelectorAll("a[aria-label*='Dòng thời gian'], a[aria-label*='Timeline']")) {
|
|
37
|
+
const h = a.getAttribute('href') || '';
|
|
38
|
+
const m = h.match(/^\/([A-Za-z0-9._-]{3,})\/?(?:\?|$)/);
|
|
39
|
+
if (m && !['profile.php', 'reel', 'video', 'videos'].includes(m[1])) return `https://www.facebook.com/${m[1]}/?sk=reels_tab`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}).catch(() => null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function scrapeTiles(page) {
|
|
46
|
+
return page.evaluate(() => {
|
|
47
|
+
const out = new Map();
|
|
48
|
+
for (const a of document.querySelectorAll("a[href*='/reel/']")) {
|
|
49
|
+
const href = a.getAttribute('href') || '';
|
|
50
|
+
const m = href.match(/\/reel\/(\d{8,20})/);
|
|
51
|
+
if (!m) continue;
|
|
52
|
+
const r = a.getBoundingClientRect();
|
|
53
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
54
|
+
const aria = (a.getAttribute('aria-label') || '').toLowerCase();
|
|
55
|
+
const isTile = /bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria);
|
|
56
|
+
// Số lượt xem nằm trong tile (icon play + số). Lấy text ngắn nhất có số
|
|
57
|
+
// trong subtree để tránh bắt nhầm caption.
|
|
58
|
+
const texts = [];
|
|
59
|
+
const walker = document.createTreeWalker(a, NodeFilter.SHOW_TEXT);
|
|
60
|
+
let n;
|
|
61
|
+
while ((n = walker.nextNode())) { const t = n.textContent.trim(); if (t && /\d/.test(t) && t.length <= 16) texts.push(t); }
|
|
62
|
+
const pick = texts.sort((x, y) => x.length - y.length)[0] || '';
|
|
63
|
+
if (!out.has(m[1]) || (isTile && pick)) out.set(m[1], { post_id: m[1], views_text: pick, is_tile: isTile });
|
|
64
|
+
}
|
|
65
|
+
return [...out.values()];
|
|
66
|
+
}).catch(() => []);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function run({ page, payload, log }) {
|
|
70
|
+
const { channel_id, page_url } = payload || {};
|
|
71
|
+
if (!channel_id) throw new Error('payload.channel_id required');
|
|
72
|
+
let tabUrl = null;
|
|
73
|
+
if (page_url && /sk=reels_tab/.test(page_url)) tabUrl = page_url;
|
|
74
|
+
if (!tabUrl) {
|
|
75
|
+
await page.goto(page_url || 'https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
76
|
+
await page.waitForTimeout(4000);
|
|
77
|
+
tabUrl = await findReelsTabHref(page);
|
|
78
|
+
}
|
|
79
|
+
if (!tabUrl) throw new Error('không tìm được link tab Reels của page');
|
|
80
|
+
log('info', `[fb-stats] reels_tab: ${tabUrl.slice(0, 100)}`);
|
|
81
|
+
await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
82
|
+
await page.waitForTimeout(5000);
|
|
83
|
+
// Cuộn vài lần để tải thêm tile (mỗi lần ~12 reel). 6 lần ≈ 60-80 reel gần
|
|
84
|
+
// nhất — đủ cho cửa sổ 28 ngày của bộ chỉnh trọng số.
|
|
85
|
+
let last = 0;
|
|
86
|
+
for (let i = 0; i < 6; i++) {
|
|
87
|
+
await page.mouse.wheel(0, 2400).catch(() => {});
|
|
88
|
+
await page.waitForTimeout(1800);
|
|
89
|
+
const n = await page.evaluate(() => document.querySelectorAll("a[href*='/reel/']").length).catch(() => 0);
|
|
90
|
+
if (n === last) break;
|
|
91
|
+
last = n;
|
|
92
|
+
}
|
|
93
|
+
const tiles = await scrapeTiles(page);
|
|
94
|
+
const items = tiles.map((t) => ({ ...t, views: parseViews(t.views_text) })).filter((t) => t.views != null);
|
|
95
|
+
log('info', `[fb-stats] ${tiles.length} tile, đọc được lượt xem ${items.length}` + (items[0] ? ` (vd ${items[0].post_id} "${items[0].views_text}" → ${items[0].views})` : ''));
|
|
96
|
+
if (!items.length) {
|
|
97
|
+
throw new Error(`không đọc được lượt xem trên tile nào (${tiles.length} tile) — FB đổi giao diện?`);
|
|
98
|
+
}
|
|
99
|
+
// Gửi về API bằng token của worker (command-poller gắn log.apiRequest).
|
|
100
|
+
if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
|
|
101
|
+
// api-client.request trả thẳng `data` (ném lỗi khi success=false).
|
|
102
|
+
const d = await log.apiRequest('POST', '/analytics/post-stats', {
|
|
103
|
+
channel_id, platform: 'facebook',
|
|
104
|
+
items: items.map(({ post_id, views, views_text }) => ({ post_id, views, views_text })),
|
|
105
|
+
});
|
|
106
|
+
log('info', `[fb-stats] đã ghi ${d?.written} bài, khớp idea ${d?.matched}`);
|
|
107
|
+
return { ok: true, tiles: tiles.length, written: d?.written || 0, matched: d?.matched || 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { run, __testables: { parseViews } };
|
|
@@ -2232,6 +2232,30 @@ async function runOnce({ page, payload, log }) {
|
|
|
2232
2232
|
// - Page wall display order isn't strictly chronological
|
|
2233
2233
|
// - "Quảng bá thước phim" appears on ALL Page reels, not just new
|
|
2234
2234
|
// - Title match alone can't distinguish same-title duplicates
|
|
2235
|
+
// Fresh-tile scrape, dùng ở lượt đầu VÀ lượt retry (a.2b) — cùng một luật:
|
|
2236
|
+
// chỉ nhận tile có timestamp tươi ("Vừa xong"/"X phút"), tuyệt đối không
|
|
2237
|
+
// nhận tile cũ.
|
|
2238
|
+
const scrapeFreshReelTile = () => page.evaluate(() => {
|
|
2239
|
+
const FRESH_RE = /vừa xong|vài giây|^\s*\d{1,2}\s*giây|^\s*[1-5]\s*phút\b|\b[1-5]\s*phút trước|just now|few seconds ago|\b[1-5] min(ute)?s? ago/i;
|
|
2240
|
+
const anchors = document.querySelectorAll("a[href*='/reel/']");
|
|
2241
|
+
for (const a of anchors) {
|
|
2242
|
+
const href = a.getAttribute('href') || '';
|
|
2243
|
+
const m = href.match(/\/reel\/(\d{8,18})/);
|
|
2244
|
+
if (!m) continue;
|
|
2245
|
+
const r = a.getBoundingClientRect();
|
|
2246
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
2247
|
+
let ctx = a;
|
|
2248
|
+
for (let depth = 0; depth < 5 && ctx; depth++) {
|
|
2249
|
+
const raw = (ctx.innerText || ctx.textContent || '').slice(0, 500);
|
|
2250
|
+
if (FRESH_RE.test(raw)) {
|
|
2251
|
+
return { href, id: m[1], depth, timestamp: (raw.match(FRESH_RE) || [''])[0] };
|
|
2252
|
+
}
|
|
2253
|
+
ctx = ctx.parentElement;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return null;
|
|
2257
|
+
}).catch(() => null);
|
|
2258
|
+
let reelsTabUrl = null; // giữ lại cho lượt retry (a.2b)
|
|
2235
2259
|
try {
|
|
2236
2260
|
// Find a link to the page's reels tab. Multi-strategy:
|
|
2237
2261
|
// 1. Any existing href containing "sk=reels_tab"
|
|
@@ -2272,35 +2296,14 @@ async function runOnce({ page, payload, log }) {
|
|
|
2272
2296
|
return null;
|
|
2273
2297
|
}).catch(() => null);
|
|
2274
2298
|
if (reelsTabHref) {
|
|
2299
|
+
reelsTabUrl = reelsTabHref;
|
|
2275
2300
|
log('info', `[fb-pw] navigating to page reels tab: ${reelsTabHref.slice(0, 100)}`);
|
|
2276
2301
|
await page.goto(reelsTabHref, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
2277
2302
|
await page.waitForTimeout(5000);
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
// wrapping a thumbnail + meta. Iterate in DOM order (newest first
|
|
2283
|
-
// on reels tab). For each tile, check if its subtree has a fresh
|
|
2284
|
-
// timestamp.
|
|
2285
|
-
const anchors = document.querySelectorAll("a[href*='/reel/']");
|
|
2286
|
-
for (const a of anchors) {
|
|
2287
|
-
const href = a.getAttribute('href') || '';
|
|
2288
|
-
const m = href.match(/\/reel\/(\d{8,18})/);
|
|
2289
|
-
if (!m) continue;
|
|
2290
|
-
const r = a.getBoundingClientRect();
|
|
2291
|
-
if (r.width < 8 || r.height < 8) continue;
|
|
2292
|
-
// Look within the anchor's subtree + closest meaningful ancestor.
|
|
2293
|
-
let ctx = a;
|
|
2294
|
-
for (let depth = 0; depth < 5 && ctx; depth++) {
|
|
2295
|
-
const raw = (ctx.innerText || ctx.textContent || '').slice(0, 500);
|
|
2296
|
-
if (FRESH_RE.test(raw)) {
|
|
2297
|
-
return { href, id: m[1], depth, timestamp: (raw.match(FRESH_RE) || [''])[0] };
|
|
2298
|
-
}
|
|
2299
|
-
ctx = ctx.parentElement;
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
return null;
|
|
2303
|
-
}).catch(() => null);
|
|
2303
|
+
// Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
|
|
2304
|
+
// wrapping a thumbnail + meta, DOM order = newest first. Only a tile
|
|
2305
|
+
// with a FRESH timestamp counts (see scrapeFreshReelTile).
|
|
2306
|
+
const fresh = await scrapeFreshReelTile();
|
|
2304
2307
|
if (fresh) {
|
|
2305
2308
|
const full = fresh.href.startsWith('http') ? fresh.href : `https://www.facebook.com${fresh.href}`;
|
|
2306
2309
|
postUrl = full;
|
|
@@ -2422,24 +2425,52 @@ async function runOnce({ page, payload, log }) {
|
|
|
2422
2425
|
}
|
|
2423
2426
|
}
|
|
2424
2427
|
|
|
2428
|
+
// (a.2b) RETRY reels_tab sau khi chờ — reel mới thường cần 1-3 phút xử lý
|
|
2429
|
+
// phía FB rồi mới hiện lên tab, nên lượt scrape đầu (chạy ngay sau
|
|
2430
|
+
// khi bấm Đăng) hay trượt và mọi thứ rơi xuống last-resort bốc nhầm
|
|
2431
|
+
// reel cũ (4 lần gần nhất 2026-08-19 đều thế). Một lượt chờ 75s +
|
|
2432
|
+
// re-scrape cứu được phần lớn ca đó, vẫn giữ luật fresh-timestamp
|
|
2433
|
+
// nên không thể nhận nhầm tile cũ.
|
|
2434
|
+
if (!postUrl && reelsTabUrl) {
|
|
2435
|
+
log('info', '[fb-pw] no post URL yet — waiting 75s for FB to surface the new reel on reels_tab, then re-scraping…');
|
|
2436
|
+
await page.waitForTimeout(75_000);
|
|
2437
|
+
await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
2438
|
+
await page.waitForTimeout(5000);
|
|
2439
|
+
const fresh2 = await scrapeFreshReelTile();
|
|
2440
|
+
if (fresh2) {
|
|
2441
|
+
postUrl = fresh2.href.startsWith('http') ? fresh2.href : `https://www.facebook.com${fresh2.href}`;
|
|
2442
|
+
log('info', `[fb-pw] post URL from reels_tab RETRY (id=${fresh2.id}, timestamp="${fresh2.timestamp}"): ${postUrl}`);
|
|
2443
|
+
} else {
|
|
2444
|
+
log('warn', '[fb-pw] reels_tab retry: still no fresh tile');
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2425
2448
|
// (a.3) LAST-RESORT: first reel tile on the profile reels tab. UNRELIABLE —
|
|
2426
2449
|
// the just-published reel may not be on the tab yet (still
|
|
2427
2450
|
// processing), so the first tile can be a STALE older reel. Only used
|
|
2428
2451
|
// when every authoritative source above (fresh-timestamp tile,
|
|
2429
|
-
// title-match, inline CTA, network capture) returned
|
|
2452
|
+
// title-match, inline CTA, network capture, timed retry) returned
|
|
2453
|
+
// nothing. Provably-stale filter: mọi reel ID đã bắt được TRƯỚC khi
|
|
2454
|
+
// bấm Đăng là reel có sẵn trên page — reel mới không thể là chúng.
|
|
2455
|
+
// Thà post_url rỗng còn hơn ghi sai (2026-08-19: nhánh này ghi reel
|
|
2456
|
+
// 02/08 cho bài vừa đăng → link trong DB trỏ nhầm bài cũ).
|
|
2430
2457
|
if (!postUrl) {
|
|
2431
|
-
const
|
|
2458
|
+
const preIds = capturedReelIds.slice(0, capturedReelIdsSnapshotLen);
|
|
2459
|
+
const tileHref = await page.evaluate((staleIds) => {
|
|
2460
|
+
const staleSet = new Set(staleIds);
|
|
2432
2461
|
const anchors = document.querySelectorAll("a[role='link']");
|
|
2433
2462
|
for (const a of anchors) {
|
|
2434
2463
|
const aria = (a.getAttribute('aria-label') || '').toLowerCase();
|
|
2435
2464
|
if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
|
|
2436
2465
|
const href = a.getAttribute('href') || '';
|
|
2437
2466
|
const m = href.match(/\/reel\/(\d{8,20})/);
|
|
2438
|
-
if (m) return { href, aria: aria.slice(0, 60) };
|
|
2467
|
+
if (m) return { href, aria: aria.slice(0, 60), id: m[1], provablyStale: staleSet.has(m[1]) };
|
|
2439
2468
|
}
|
|
2440
2469
|
return null;
|
|
2441
|
-
}).catch(() => null);
|
|
2442
|
-
if (tileHref) {
|
|
2470
|
+
}, preIds).catch(() => null);
|
|
2471
|
+
if (tileHref && tileHref.provablyStale) {
|
|
2472
|
+
log('warn', `[fb-pw] LAST-RESORT tile ${tileHref.id} was already captured PRE-publish — provably an OLD reel; leaving post_url empty instead of recording a wrong one`);
|
|
2473
|
+
} else if (tileHref) {
|
|
2443
2474
|
postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
|
|
2444
2475
|
log('warn', `[fb-pw] post URL from FIRST reel tile on reels_tab (LAST-RESORT, may be STALE): ${postUrl}`);
|
|
2445
2476
|
}
|