channel-worker 2.5.76 → 2.5.78

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.76",
3
+ "version": "2.5.78",
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": {
@@ -1,11 +1,13 @@
1
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.
2
+ // rồi gửi về API /analytics/post-stats. Chỉ đọc không bấm ngoài cuộn trang.
5
3
  //
6
- // Payload: { channel_id, profile_id, page_url? }
4
+ // Payload: { channel_id, profile_id, page_url?, mode, scroll }
7
5
  // 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").
6
+ // như upload_facebook (anchor /profile.php?id=… hoặc "Dòng thời gian của X").
7
+ // mode: 'deep' (mặc định) = lượt nền 06:30, cuộn sâu cả page + đọc follower
8
+ // 'fresh' = lượt bám bài (giờ đăng +2h/+6h), cuộn nông, bài
9
+ // mới nằm ngay đầu tab nên không cần cuộn xa
10
+ // scroll: số lần cuộn, ghi đè mặc định theo mode (6 / 2)
9
11
  //
10
12
  // Tile reels_tab: <a href="/reel/<id>/" aria-label="Bản xem trước ô thước phim">
11
13
  // 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),
@@ -42,6 +44,41 @@ async function findReelsTabHref(page) {
42
44
  }).catch(() => null);
43
45
  }
44
46
 
47
+ // Số người theo dõi của page, đọc ngay trên chính trang đang đứng (header hiện
48
+ // ở mọi tab của page/profile) — không tốn thêm lượt mở profile nào. Ô
49
+ // platform_accounts.stats.followers có sẵn từ lâu nhưng trống 279/279 kênh vì
50
+ // chưa từng có ai ghi vào.
51
+ //
52
+ // KHÔNG quét cả body rồi lấy match ĐẦU TIÊN: đo thật 2026-09-05 trên kênh
53
+ // "Đồ chơi trẻ em 01" ra đúng "1" — trong trang có sẵn chuỗi "… 1 người theo
54
+ // dõi" ở chỗ khác, nó chặn trước số thật của header. Thứ tự ưu tiên:
55
+ // 1. link /followers của chính header (nguồn chắc nhất)
56
+ // 2. nếu không có, quét phần ĐẦU trang (header nằm ở đây) và lấy số LỚN NHẤT
57
+ // Trả về cả danh sách ứng viên để log soi được khi Facebook đổi giao diện.
58
+ const FOLLOWER_RE = /([\d][\d.,]*\s*(?:N|Tr|K|M|B|nghìn|triệu|tỷ)?)\s*(?:người theo dõi|người follow|followers)/gi;
59
+
60
+ async function scrapeFollowers(page) {
61
+ return page.evaluate((reSrc) => {
62
+ const out = { picked: null, from: '', candidates: [] };
63
+ const mk = () => new RegExp(reSrc, 'gi');
64
+
65
+ // 1. Link "N người theo dõi" trong header — href chứa /followers.
66
+ for (const a of document.querySelectorAll("a[href*='followers']")) {
67
+ const t = (a.innerText || '').replace(/\s+/g, ' ').trim();
68
+ const m = mk().exec(t);
69
+ if (m) { out.picked = m[1].trim(); out.from = 'link'; out.candidates.push(t.slice(0, 60)); return out; }
70
+ }
71
+
72
+ // 2. Phần đầu trang: header đứng trên cùng, nên cắt bớt đuôi để tránh vớ
73
+ // phải chuỗi trong danh sách gợi ý/bình luận phía dưới.
74
+ const head = (document.body?.innerText || '').slice(0, 3000);
75
+ let m; const re = mk();
76
+ while ((m = re.exec(head))) out.candidates.push(m[1].trim());
77
+ if (out.candidates.length) { out.from = 'head'; out.picked = out.candidates.slice(); }
78
+ return out;
79
+ }, FOLLOWER_RE.source).catch(() => ({ picked: null, from: 'error', candidates: [] }));
80
+ }
81
+
45
82
  async function scrapeTiles(page) {
46
83
  return page.evaluate(() => {
47
84
  const out = new Map();
@@ -67,8 +104,11 @@ async function scrapeTiles(page) {
67
104
  }
68
105
 
69
106
  async function run({ page, payload, log }) {
70
- const { channel_id, page_url } = payload || {};
107
+ const { channel_id, page_url, mode = 'deep' } = payload || {};
71
108
  if (!channel_id) throw new Error('payload.channel_id required');
109
+ const scrolls = Number.isFinite(Number(payload?.scroll)) && Number(payload.scroll) > 0
110
+ ? Number(payload.scroll)
111
+ : (mode === 'fresh' ? 2 : 6);
72
112
  let tabUrl = null;
73
113
  if (page_url && /sk=reels_tab/.test(page_url)) tabUrl = page_url;
74
114
  if (!tabUrl) {
@@ -77,19 +117,34 @@ async function run({ page, payload, log }) {
77
117
  tabUrl = await findReelsTabHref(page);
78
118
  }
79
119
  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)}`);
120
+ log('info', `[fb-stats] ${mode} · reels_tab: ${tabUrl.slice(0, 100)}`);
81
121
  await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
82
122
  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ố.
123
+
124
+ // Follower chỉ đọc lượt nền: lượt bám bài chạy 2-3 lần/ngày con số này
125
+ // gần như không đổi trong ngày. Đọc hỏng thì bỏ qua — KHÔNG được làm hỏng
126
+ // lượt đọc view, hai việc độc lập nhau.
127
+ let followers = null;
128
+ if (mode === 'deep') {
129
+ const f = await scrapeFollowers(page);
130
+ // Từ link header thì tin thẳng; quét phần đầu trang thì lấy số LỚN NHẤT
131
+ // trong các ứng viên — chuỗi nhiễu ("1 người theo dõi") luôn thua số thật.
132
+ const nums = (Array.isArray(f.picked) ? f.picked : [f.picked]).map(parseViews).filter((n) => Number.isFinite(n) && n > 0);
133
+ followers = nums.length ? Math.max(...nums) : null;
134
+ log('info', `[fb-stats] follower: ${followers ?? 'không đọc được'} (nguồn ${f.from || 'none'}, ứng viên: ${JSON.stringify(f.candidates).slice(0, 200)})`);
135
+ }
136
+
137
+ // Cuộn để tải thêm tile (mỗi lần ~12 reel). 6 lần ≈ 60-80 reel gần nhất — đủ
138
+ // cho cửa sổ 28 ngày của bộ chỉnh trọng số. Lượt bám bài chỉ cần 2.
85
139
  let last = 0;
86
- for (let i = 0; i < 6; i++) {
140
+ for (let i = 0; i < scrolls; i++) {
87
141
  await page.mouse.wheel(0, 2400).catch(() => {});
88
142
  await page.waitForTimeout(1800);
89
143
  const n = await page.evaluate(() => document.querySelectorAll("a[href*='/reel/']").length).catch(() => 0);
90
144
  if (n === last) break;
91
145
  last = n;
92
146
  }
147
+ const scannedAt = new Date().toISOString();
93
148
  const tiles = await scrapeTiles(page);
94
149
  const items = tiles.map((t) => ({ ...t, views: parseViews(t.views_text) })).filter((t) => t.views != null);
95
150
  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})` : ''));
@@ -100,11 +155,11 @@ async function run({ page, payload, log }) {
100
155
  if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
101
156
  // api-client.request trả thẳng `data` (ném lỗi khi success=false).
102
157
  const d = await log.apiRequest('POST', '/analytics/post-stats', {
103
- channel_id, platform: 'facebook',
158
+ channel_id, platform: 'facebook', mode, at: scannedAt, followers,
104
159
  items: items.map(({ post_id, views, views_text }) => ({ post_id, views, views_text })),
105
160
  });
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 };
161
+ log('info', `[fb-stats] đã ghi ${d?.written} điểm đo, khớp idea ${d?.matched}, đang lên ${d?.rising || 0}, chuông ${d?.alerts || 0}`);
162
+ return { ok: true, mode, tiles: tiles.length, written: d?.written || 0, matched: d?.matched || 0, rising: d?.rising || 0, alerts: d?.alerts || 0, followers };
108
163
  }
109
164
 
110
- module.exports = { run, __testables: { parseViews } };
165
+ module.exports = { run, __testables: { parseViews, scrapeFollowers } };