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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.53",
3
+ "version": "2.5.55",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -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
- const fresh = await page.evaluate(() => {
2279
- const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
2280
- 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;
2281
- // Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
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 nothing.
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 tileHref = await page.evaluate(() => {
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
  }