channel-worker 2.5.78 → 2.5.80

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.78",
3
+ "version": "2.5.80",
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": {
@@ -4,10 +4,11 @@
4
4
  // Payload: { channel_id, profile_id, page_url?, mode, scroll }
5
5
  // page_url: link page (nếu có) — không có thì tự tìm reels_tab từ trang chủ
6
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)
7
+ // mode: 'insights' = lượt nền hằng ngày, ĐỌC TRANG CÔNG CỤ CHUYÊN NGHIỆP
8
+ // (số chính xác tới đơn vị) xem khối INSIGHTS bên dưới
9
+ // 'fresh' = lượt bám bài (giờ đăng +2h/+6h), cuộn nông tab Reels
10
+ // 'deep' = quét sâu tab Reels (cách cũ, giữ để đối chiếu)
11
+ // scroll: số lần cuộn tab Reels, ghi đè mặc định theo mode (6 / 2)
11
12
  //
12
13
  // Tile reels_tab: <a href="/reel/<id>/" aria-label="Bản xem trước ô thước phim">
13
14
  // 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),
@@ -103,9 +104,183 @@ async function scrapeTiles(page) {
103
104
  }).catch(() => []);
104
105
  }
105
106
 
107
+
108
+ // ══ INSIGHTS — số liệu chính chủ của Facebook ════════════════════════════════
109
+ //
110
+ // Vì sao phải bỏ tile cho lượt nền (đo thật 2026-09-05):
111
+ // · tile rút gọn còn 2-3 chữ số. Bài 1129754453051277 đi "6,9K → 7K → 7,1K →
112
+ // 7,3K → 7,7K → 10K" rồi ĐỨNG IM "10K" bảy ngày liền — delta ngày mù hẳn.
113
+ // · cùng bài đó trên Insights: 56.223. Bài top: tile "588K", Insights 588.135.
114
+ // · follower: tile "1,9K" → 1.900, Insights 1.919.
115
+ //
116
+ // Ba câu hỏi, hai lần tải trang (chủ tịch chốt 2026-09-05):
117
+ // date_range=TODAY → tổng view HÔM NAY của kênh + % so hôm qua
118
+ // (dùng tổng NGÀY để phát hiện, không dùng tổng 90 ngày:
119
+ // một cú nổ 50K trên nền 1,33 triệu chỉ nhích 3,7%)
120
+ // date_range=LAST_7D → 8-10 bài GẦN NHẤT kèm view chính xác
121
+ // hiệu hai cái → view của bài CŨ. Đo thật: tổng 7 ngày 216.353, cộng 8
122
+ // bài liệt kê 158.543 → 57.810 view đến từ bài cũ.
123
+ //
124
+ // BẪY: danh sách bài lọc theo NGÀY ĐĂNG, không theo view kiếm được trong kỳ.
125
+ // date_range=TODAY chỉ liệt kê bài ĐĂNG hôm nay (đo thật: đúng 1 bài, 639
126
+ // view), nên bài cũ đang nổ KHÔNG BAO GIỜ hiện trong danh sách — chỉ lộ ra qua
127
+ // hiệu số ở trên.
128
+ //
129
+ // Token date_range đã kiểm: TODAY · LAST_7D · LAST_14D · LAST_28D · LAST_60D ·
130
+ // LAST_90D chạy thật. THIS_YEAR và LIFETIME thì trang LẶNG LẼ rơi về 28 ngày —
131
+ // không báo lỗi, nên phải đối chiếu nhãn khoảng thời gian trả về.
132
+
133
+ const INSIGHTS_BASE = 'https://www.facebook.com/professional_dashboard/profile_insights';
134
+
135
+ // Số trên Insights là số đầy đủ, dấu chấm là phân cách nghìn ("1.333.852").
136
+ // KHÔNG dùng parseViews ở đây — hàm đó dịch "1.2" thành 12 vì phải lo hậu tố K/N.
137
+ function parseExact(txt) {
138
+ const m = /(\d[\d.]*)/.exec(String(txt || '').replace(/\s+/g, ' '));
139
+ if (!m) return null;
140
+ const n = parseInt(m[1].replace(/\./g, ''), 10);
141
+ return Number.isFinite(n) ? n : null;
142
+ }
143
+
144
+ // "31 Tháng 8 11:41" → ISO. Facebook không ghi năm; tháng lớn hơn tháng hiện
145
+ // tại nghĩa là năm ngoái (bài cuối tháng 12 xem vào tháng 1).
146
+ function parseVnPostTime(txt, now = new Date()) {
147
+ const m = /(\d{1,2})\s*Tháng\s*(\d{1,2})\s+(\d{1,2}):(\d{2})/i.exec(String(txt || ''));
148
+ if (!m) return null;
149
+ const [, d, mo, h, mi] = m.map(Number);
150
+ let year = now.getUTCFullYear();
151
+ // Mốc so sánh theo giờ VN vì Facebook hiện giờ địa phương của tài khoản.
152
+ const vnNow = new Date(now.getTime() + 7 * 3600e3);
153
+ if (mo > vnNow.getUTCMonth() + 1) year -= 1;
154
+ const iso = `${year}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
155
+ + `T${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}:00+07:00`;
156
+ const t = new Date(iso);
157
+ return Number.isNaN(t.getTime()) ? null : t.toISOString();
158
+ }
159
+
160
+ // Nhãn khoảng thời gian PHẢI khớp cái đã xin. Trang dựng khung trước rồi mới
161
+ // đổ số, nên đọc quá sớm là lấy trúng số của khoảng TRƯỚC ĐÓ mà không có dấu
162
+ // hiệu gì. Đo thật 2026-09-06: 5/43 lượt xin LAST_7D nhận về "Hôm nay", và một
163
+ // kênh ghi được daily_views 279.946 trong khi views_7d chỉ 278.662 — tức là số
164
+ // 28 ngày bị nhét vào ô "hôm nay". Phải ĐỢI nhãn khớp, không thì thà hỏng to.
165
+ const RANGE_LABEL = {
166
+ TODAY: /hôm nay/i,
167
+ LAST_7D: /7 ngày qua/i,
168
+ LAST_14D: /14 ngày qua/i,
169
+ LAST_28D: /28 ngày qua/i,
170
+ LAST_60D: /60 ngày qua/i,
171
+ LAST_90D: /90 ngày qua/i,
172
+ };
173
+
174
+ async function readViewsPage(page, range, log) {
175
+ const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=${range}`;
176
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
177
+ const want = RANGE_LABEL[range];
178
+ let label = '';
179
+ // Tối đa ~24s: khung trang hiện trong ~3s, số đổ về chậm hơn nhiều.
180
+ for (let i = 0; i < 12; i++) {
181
+ await page.waitForTimeout(2000);
182
+ label = await page.evaluate(() => {
183
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
184
+ for (const e of document.querySelectorAll('div,span')) {
185
+ const t = clean(e.innerText);
186
+ if (t.length < 60 && /Tổng số lượt xem/i.test(t)) return t;
187
+ }
188
+ return '';
189
+ }).catch(() => '');
190
+ if (label && (!want || want.test(label))) break;
191
+ }
192
+ if (want && !want.test(label)) {
193
+ throw new Error(`xin ${range} nhưng sau 24s trang vẫn hiện "${label || 'chưa có số nào'}"`);
194
+ }
195
+ const raw = await page.evaluate(() => {
196
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
197
+ const seen = new Set(); const chunks = [];
198
+ for (const e of document.querySelectorAll('div,span')) {
199
+ const t = clean(e.innerText);
200
+ if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
201
+ if (/Lượt xem|lượt xem/.test(t)) { seen.add(t); chunks.push(t); }
202
+ if (chunks.length > 120) break;
203
+ }
204
+ const label = [...document.querySelectorAll('div,span,button')]
205
+ .map((e) => clean(e.innerText))
206
+ .find((t) => t.length < 60 && /ngày qua:|hôm nay/i.test(t)) || '';
207
+ return { chunks, label };
208
+ }).catch(() => ({ chunks: [], label: '' }));
209
+
210
+ // Tổng: "216.353 -35% Tổng số lượt xem trong 7 ngày qua" / "… hôm nay"
211
+ let total = null; let pct = null;
212
+ for (const c of raw.chunks) {
213
+ const m = /^([\d.]+)\s*(?:(-?[\d.,]+)%)?\s*Tổng số lượt xem/i.exec(c);
214
+ if (m) { total = parseExact(m[1]); pct = m[2] ? parseFloat(String(m[2]).replace(',', '.')) : null; break; }
215
+ }
216
+ // Dòng bài: "31 Tháng 8 11:41 56.243 Lượt xem"
217
+ const posts = [];
218
+ for (const c of raw.chunks) {
219
+ const m = /(\d{1,2}\s*Tháng\s*\d{1,2}\s+\d{1,2}:\d{2})\s+([\d.]+)\s*Lượt xem/i.exec(c);
220
+ if (!m) continue;
221
+ const at = parseVnPostTime(m[1]);
222
+ const views = parseExact(m[2]);
223
+ if (at && views != null && !posts.some((p) => p.at === at)) posts.push({ at, views, raw: m[0].slice(0, 60) });
224
+ }
225
+ log('info', `[insights] ${range}: tổng ${total}${pct != null ? ` (${pct}%)` : ''}, ${posts.length} bài — "${label}"`);
226
+ return { range, label, total, pct, posts };
227
+ }
228
+
229
+ async function readAudiencePage(page, log) {
230
+ await page.goto(`${INSIGHTS_BASE}/audience/`, { waitUntil: 'domcontentloaded', timeout: 45_000 });
231
+ await page.waitForTimeout(7000);
232
+ const chunks = await page.evaluate(() => {
233
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
234
+ const seen = new Set(); const out = [];
235
+ for (const e of document.querySelectorAll('div,span')) {
236
+ const t = clean(e.innerText);
237
+ if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
238
+ if (/người theo dõi|bỏ theo dõi/i.test(t)) { seen.add(t); out.push(t); }
239
+ if (out.length > 60) break;
240
+ }
241
+ return out;
242
+ }).catch(() => []);
243
+ const pick = (re) => { for (const c of chunks) { const m = re.exec(c); if (m) return parseExact(m[1]); } return null; };
244
+ const a = {
245
+ followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Tổng số người theo dõi/i),
246
+ net_followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Số người theo dõi thực/i),
247
+ unfollows: pick(/([\d.]+)\s*Lượt bỏ theo dõi/i),
248
+ };
249
+ log('info', `[insights] đối tượng: ${a.followers} follower, thực ${a.net_followers}, bỏ ${a.unfollows}`);
250
+ return a;
251
+ }
252
+
253
+ // Lượt nền: hai trang view + một trang đối tượng, rồi gửi thẳng số thô về API.
254
+ // Việc khớp bài với idea và tính hiệu để lòi ra "bài cũ đang nổ" làm ở API —
255
+ // worker không giữ trạng thái ngày hôm qua.
256
+ async function runInsights({ page, payload, log }) {
257
+ const today = await readViewsPage(page, 'TODAY', log);
258
+ const week = await readViewsPage(page, 'LAST_7D', log);
259
+ const audience = await readAudiencePage(page, log);
260
+ if (today.total == null && week.total == null) {
261
+ throw new Error('không đọc được tổng lượt xem trên trang Insights — FB đổi giao diện?');
262
+ }
263
+ if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
264
+ const d = await log.apiRequest('POST', '/analytics/fb-insights', {
265
+ channel_id: payload.channel_id,
266
+ at: new Date().toISOString(),
267
+ today: { views: today.total, pct: today.pct, posts: today.posts },
268
+ week: { views: week.total, posts: week.posts },
269
+ audience,
270
+ });
271
+ log('info', `[insights] đã ghi: ngày ${d?.daily_views}, follower ${d?.followers}, bài khớp ${d?.matched}/${d?.posts}, view bài cũ ${d?.unattributed}`);
272
+ return {
273
+ ok: true, mode: 'insights',
274
+ views_today: today.total, views_7d: week.total,
275
+ posts: week.posts.length, matched: d?.matched ?? null,
276
+ followers: audience.followers, unattributed: d?.unattributed ?? null,
277
+ };
278
+ }
279
+
106
280
  async function run({ page, payload, log }) {
107
281
  const { channel_id, page_url, mode = 'deep' } = payload || {};
108
282
  if (!channel_id) throw new Error('payload.channel_id required');
283
+ if (mode === 'insights') return runInsights({ page, payload, log });
109
284
  const scrolls = Number.isFinite(Number(payload?.scroll)) && Number(payload.scroll) > 0
110
285
  ? Number(payload.scroll)
111
286
  : (mode === 'fresh' ? 2 : 6);
@@ -162,4 +337,4 @@ async function run({ page, payload, log }) {
162
337
  return { ok: true, mode, tiles: tiles.length, written: d?.written || 0, matched: d?.matched || 0, rising: d?.rising || 0, alerts: d?.alerts || 0, followers };
163
338
  }
164
339
 
165
- module.exports = { run, __testables: { parseViews, scrapeFollowers } };
340
+ module.exports = { run, __testables: { parseViews, scrapeFollowers, parseExact, parseVnPostTime } };