channel-worker 2.5.130 → 2.5.132

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/updater.js CHANGED
@@ -54,6 +54,39 @@ function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
54
54
  return 'install';
55
55
  }
56
56
 
57
+ // Đang chạy DƯỚI pm2 hay không. pm2 nhét `pm_id` vào env của tiến trình nó
58
+ // quản (đo tay trên relabs03 22/09/2026 bằng một app thử: env có `pm_id`,
59
+ // `pm_exec_path`, `name`, `unique_id`, `PM2_HOME`, `NODE_APP_INSTANCE`).
60
+ // `pm_id` là cái hẹp nhất trong số đó — `PM2_HOME` có thể do shell đặt sẵn mà
61
+ // chẳng có pm2 nào đang quản mình.
62
+ const underPm2 = () => !!process.env.pm_id;
63
+
64
+ // Sau khi cài xong thì khởi động lại KIỂU GÌ. Tách thành hàm thuần để test
65
+ // được: phần còn lại của checkAndUpdate đụng npm, đĩa và process.exit.
66
+ //
67
+ // 'spawn' — đẻ một daemon detached rồi tự thoát (cách chạy độc lập)
68
+ // 'exit' — chỉ thoát, để pm2 dựng lại, hoặc vì mình là bản thừa
69
+ // 'stay' — không khởi động lại gì cả
70
+ //
71
+ // ⚠️ DƯỚI pm2 TUYỆT ĐỐI KHÔNG 'spawn'. pm2 tự dựng lại tiến trình nó quản mỗi
72
+ // khi tiến trình đó thoát, nên spawn thêm một daemon detached rồi exit(0) là đẻ
73
+ // ra HAI ứng viên cho CÙNG MỘT khoá: con detached giành được khoá, con của pm2
74
+ // thua và chết ngay, pm2 lại dựng con mới, cứ thế mãi.
75
+ //
76
+ // Đo trên relabs03 22/09/2026: vòng lặp chạy mỗi ~30 giây, đã cộng dồn 15.242
77
+ // lần restart cho app `channel-worker` và 16.776 cho `shopee-worker`;
78
+ // `channel-worker-error.log` toàn dòng "Đã có daemon khác giữ
79
+ // /Users/…/.channel-worker (PID 50281)". Daemon THẬT vẫn chạy nên không ai để ý
80
+ // suốt mấy ngày — đó mới là chỗ nguy: bộ đếm restart thành vô nghĩa, và một lần
81
+ // chết thật sẽ lẫn vào giữa hàng vạn lần chết giả.
82
+ function restartPlan({ autoRestart, wasOwner, pm2 = underPm2() }) {
83
+ if (!autoRestart) return 'stay';
84
+ // Bản THỪA (không giữ khoá): thoát, không đẻ thêm — nhờ vậy mỗi vòng cập nhật
85
+ // tự rút sĩ số về đúng một daemon, thay vì N con cũ đẻ ra N con mới.
86
+ if (!wasOwner) return 'exit';
87
+ return pm2 ? 'exit' : 'spawn';
88
+ }
89
+
57
90
  // Cảnh báo 'blocked' mỗi 6 giờ một lần thôi, không phải mỗi 5 phút.
58
91
  const BLOCKED_WARN_EVERY_MS = 6 * 3600 * 1000;
59
92
  let lastBlockedWarnAt = 0;
@@ -163,7 +196,6 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
163
196
 
164
197
  const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
165
198
  const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
166
- const logFd = fs.openSync(LOG_FILE, 'a');
167
199
 
168
200
  // Nhả khoá TRƯỚC khi spawn: con mới phải giành được khoá mới chạy. Nếu
169
201
  // tiến trình này là daemon THỪA (không giữ khoá) thì release trả false —
@@ -172,15 +204,23 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
172
204
  // thay vì N con cũ đẻ ra N con mới như sự cố relabs03 04/09/2026.
173
205
  const singleton = require('./singleton');
174
206
  const wasOwner = singleton.release(CONFIG_DIR);
175
- if (!wasOwner) {
207
+ const plan = restartPlan({ autoRestart: true, wasOwner });
208
+
209
+ if (plan === 'exit' && !wasOwner) {
176
210
  console.log('[updater] Tiến trình này không giữ khoá daemon (bản thừa) — '
177
211
  + 'thoát, không spawn bản thay thế.');
212
+ } else if (plan === 'exit') {
213
+ console.log('[updater] Chạy dưới pm2 — thoát để pm2 dựng lại, KHÔNG spawn bản '
214
+ + 'thay thế (spawn nữa là hai ứng viên tranh một khoá → pm2 quay vòng vô tận).');
178
215
  }
179
216
 
180
- // Spawn new daemon using global binary (picks up new version)
181
- const isWindows = process.platform === 'win32';
182
- const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
183
- if (wasOwner) {
217
+ if (plan === 'spawn') {
218
+ // Mở log CHỈ khi thật sự spawn: fd này là để truyền cho tiến trình con.
219
+ // Mở nó ở nhánh pm2 là giữ khoá file thừa — trên Windows đó đúng là nguồn
220
+ // của EBUSY daemon.log (xem restartBlocker).
221
+ const logFd = fs.openSync(LOG_FILE, 'a');
222
+ const isWindows = process.platform === 'win32';
223
+ const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
184
224
  const child = spawn(binName, ['start'], {
185
225
  detached: true,
186
226
  stdio: ['ignore', logFd, logFd],
@@ -229,4 +269,4 @@ class UpdateChecker {
229
269
  }
230
270
  }
231
271
 
232
- module.exports = { checkAndUpdate, getLocalVersion, getInstalledVersion, getLatestVersion, isNewer, planUpdate, restartBlocker, UpdateChecker };
272
+ module.exports = { checkAndUpdate, getLocalVersion, getInstalledVersion, getLatestVersion, isNewer, planUpdate, restartPlan, underPm2, restartBlocker, UpdateChecker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.130",
3
+ "version": "2.5.132",
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": {
@@ -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 } = require('./upload_facebook').__testables;
41
+ const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef, readPageIdFromDom } = 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. Ô
@@ -127,6 +127,214 @@ async function scrapeTiles(page) {
127
127
 
128
128
  const vnYesterdayIso = () => new Date(Date.now() + 7 * 3600e3 - 864e5).toISOString().slice(0, 10);
129
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // THƯ VIỆN NỘI DUNG (professional_dashboard/content/content_library)
132
+ //
133
+ // Trang Insights ở trên chỉ liệt kê bài ĐĂNG trong kỳ, nên bài cũ đang nổ view
134
+ // không bao giờ có tên — chuông chỉ nói được "có bài cũ nào đó đang lên". Trang
135
+ // này liệt kê MỌI bài trong khoảng, mỗi bài một dòng, số chính xác tới đơn vị.
136
+ // Dò thật 22/09/2026 (kênh Nhà Nông Chia Sẻ, 29 bài/28 ngày). Bốn điều đã đo,
137
+ // sai cái nào cũng ra số rác:
138
+ //
139
+ // 1. SỐ LÀ LUỸ KẾ, không phải theo kỳ. Bài 16/09 hiện 185.522 ở khoảng 90 ngày
140
+ // VÀ 185.522 ở khoảng tuỳ chỉnh 16–17/09 (chỉ phủ 2 ngày đầu đời nó). Đổi
141
+ // khoảng chỉ đổi DANH SÁCH bài. → view theo ngày = hiệu hai mốc, làm ở API.
142
+ // 2. Bộ lọc khoảng lọc theo NGÀY ĐĂNG, giờ Mỹ, END LOẠI TRỪ (xin 21–21/09 ra 0
143
+ // bài; xin 20–21/09 ra đúng bài đăng 21/09). Đừng dùng nó để xin "view ngày
144
+ // X" — nó không làm việc đó. Chỉ dùng khoảng RỘNG để lấy đủ danh sách bài.
145
+ // 3. Bảng ẢO HOÁ, dòng bị TÁI DÙNG khi cuộn (đo: 10 → 36 → 32 dòng). Đọc một
146
+ // phát sau khi cuộn xong là mất khúc giữa → phải nhặt TỪNG BƯỚC rồi gộp.
147
+ // Cuộn khối chứa bảng KHÔNG ăn (mọi khối cha overflow:visible, cửa sổ cuộn
148
+ // 69px rồi đứng). Cách ăn: đưa DÒNG CUỐI vào tầm nhìn + PageDown.
149
+ // 4. Mỗi hàng có 14 ô nhưng 2 ô RỖNG (ô tích chọn + ô ảnh), và vị trí của chúng
150
+ // ĐỔI THEO CỠ CỬA SỔ. Đánh số theo ô thô là đọc caption vào ô "Lượt xem" —
151
+ // đã dính thật lúc dò. Phải bỏ ô rỗng RỒI mới đánh số.
152
+ //
153
+ // KHÔNG moi được reel id ở đây: hàng chỉ có một link `/content/insights/?content_id=…`,
154
+ // trang chi tiết cũng chỉ trỏ `/reel/?s=tab`, và content_id giải mã ra chỉ có
155
+ // profile id + story id. Khớp bài làm ở API bằng CAPTION + giờ đăng.
156
+ const LIBRARY_URL = 'https://www.facebook.com/professional_dashboard/content/content_library/';
157
+ const LIB_RANGE = process.env.FB_LIB_RANGE || 'LAST_28D';
158
+ // Trần vòng cuộn. Đo 22/09: kênh 35 bài ăn hết 14 vòng (mỗi vòng lộ ~5 dòng)
159
+ // nên 14 là CHẠM TRẦN, không phải đủ — kênh đăng đều 28 ngày có thể 40-50 bài.
160
+ // Cổng dừng-sớm mới là thứ giữ thời gian, trần chỉ là dây an toàn.
161
+ const LIB_MAX_STEPS = Number(process.env.FB_LIB_MAX_STEPS || 26);
162
+ // Trần THỜI GIAN cho cả vòng cuộn, tính từ lúc bảng đã vẽ xong. Trần vòng lặp
163
+ // không đủ: một vòng có thể kéo dài bất thường khi trang nghẽn, mà lượt này
164
+ // chạy cho 44 kênh mỗi ngày nên phải có điểm dừng đo bằng giây.
165
+ const LIB_BUDGET_MS = Number(process.env.FB_LIB_BUDGET_MS || 75_000);
166
+
167
+ // "16 tháng 9 lúc 15:37" · "Hôm nay lúc 11:50" · "Hôm qua lúc 11:59"
168
+ // · "Sep 16 at 3:37 PM" (profile tiếng Anh — 4/52 kênh, xem ghi chú parseVnPostTime).
169
+ // Trả ISO, hoặc null nếu không đọc được (API tự bỏ dòng không có giờ).
170
+ function parseLibraryTime(txt, now = new Date()) {
171
+ const t = String(txt || '').replace(/\s+/g, ' ').trim();
172
+ const vnNow = new Date(now.getTime() + 7 * 3600e3);
173
+ const hm = /(\d{1,2}):(\d{2})\s*(am|pm|AM|PM)?/.exec(t);
174
+ if (!hm) return null;
175
+ let h = Number(hm[1]); const mi = Number(hm[2]);
176
+ const ap = (hm[3] || '').toLowerCase();
177
+ if (ap === 'pm' && h < 12) h += 12;
178
+ if (ap === 'am' && h === 12) h = 0;
179
+
180
+ let y = vnNow.getUTCFullYear(); let mo; let d;
181
+ const rel = /^(Hôm nay|Hôm qua|Today|Yesterday)/i.exec(t);
182
+ if (rel) {
183
+ const back = /qua|yesterday/i.test(rel[1]) ? 864e5 : 0;
184
+ const day = new Date(vnNow.getTime() - back);
185
+ y = day.getUTCFullYear(); mo = day.getUTCMonth() + 1; d = day.getUTCDate();
186
+ } else {
187
+ const vn = /(\d{1,2})\s*tháng\s*(\d{1,2})/i.exec(t);
188
+ if (vn) { d = Number(vn[1]); mo = Number(vn[2]); } else {
189
+ const en = /([A-Za-z]{3})[a-z]*\s+(\d{1,2})/.exec(t);
190
+ if (!en) return null;
191
+ mo = EN_MONTH[en[1].toLowerCase()]; d = Number(en[2]);
192
+ if (!mo) return null;
193
+ }
194
+ // Facebook không ghi năm. Tháng lớn hơn tháng hiện tại = năm ngoái.
195
+ if (mo > vnNow.getUTCMonth() + 1) y -= 1;
196
+ }
197
+ const iso = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
198
+ + `T${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}:00+07:00`;
199
+ const parsed = new Date(iso);
200
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
201
+ }
202
+
203
+ // Hàm chạy TRONG TRANG — phải tự chứa, closure của module không đi theo.
204
+ function readLibraryRowsInPage() {
205
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
206
+ let chip = '';
207
+ const CHIP = /^(Hôm nay$|Today$|Tùy chỉnh:|Custom:|Năm nay:|This year:|\d+ ngày qua:|Last \d+ days:)/i;
208
+ for (const e of document.querySelectorAll('div[role="button"],span,div')) {
209
+ const t = clean(e.innerText);
210
+ if (t.length >= 70 || !CHIP.test(t)) continue;
211
+ const r = e.getBoundingClientRect();
212
+ if (r && r.width > 0 && r.height > 0) { chip = t; break; }
213
+ }
214
+ const rows = [];
215
+ for (const e of document.querySelectorAll('[role="row"]')) {
216
+ // BỎ Ô RỖNG trước khi đánh số — xem bẫy 4 ở đầu khối.
217
+ const cells = [...e.querySelectorAll('[role="gridcell"],[role="cell"],td')]
218
+ .map((c) => clean(c.innerText)).filter(Boolean);
219
+ if (cells.length < 3) continue;
220
+ const head = cells[0];
221
+ if (/^(Bản xem trước|Preview)/i.test(head)) continue; // hàng tiêu đề
222
+ const m = /Đã đăng\s*•?\s*(.+)$/i.exec(head) || /Published\s*•?\s*(.+)$/i.exec(head);
223
+ if (!m) continue; // chưa đăng (nháp/lên lịch)
224
+ const a = e.querySelector('a[href*="content_id="]');
225
+ rows.push({
226
+ posted_txt: clean(m[1]).slice(0, 40),
227
+ caption: clean(head.replace(/\s*Đã đăng[\s\S]*$/i, '').replace(/\s*Published[\s\S]*$/i, '')).slice(0, 200),
228
+ views_text: cells[1] || '',
229
+ viewers_text: cells[2] || '',
230
+ engagements_text: cells[3] || '',
231
+ net_followers_text: cells[4] || '',
232
+ content_id: a ? ((a.getAttribute('href') || '').match(/content_id=([^&]+)/) || [])[1] || '' : '',
233
+ });
234
+ }
235
+ return { chip, rows };
236
+ }
237
+
238
+ // Kéo thêm dòng: đưa dòng cuối vào tầm nhìn (cuộn khối không ăn — xem bẫy 3).
239
+ function scrollLibraryInPage() {
240
+ for (const el of [document.scrollingElement, ...document.querySelectorAll('div,main,section')]) {
241
+ if (!el || el.scrollHeight <= el.clientHeight + 30) continue;
242
+ const before = el.scrollTop;
243
+ el.scrollTop = before + 1800;
244
+ if (el.scrollTop !== before) break;
245
+ }
246
+ const rows = document.querySelectorAll('[role="row"]');
247
+ const last = rows[rows.length - 1];
248
+ if (last) last.scrollIntoView({ block: 'end' });
249
+ return rows.length;
250
+ }
251
+
252
+ async function readLibraryPage(page, log, { range = LIB_RANGE, maxSteps = LIB_MAX_STEPS } = {}) {
253
+ // ĐẶT CỠ CỬA SỔ TRƯỚC KHI TẢI TRANG. Đặt sau thì bảng đã dựng xong ở cỡ mặc
254
+ // định và không dựng lại: đo 22/09 kênh Nhà Nông ra 10/29 bài, cuộn thêm 4
255
+ // vòng không ra thêm dòng nào. Cùng đoạn code, chỉ đổi thứ tự hai dòng này,
256
+ // là 29/29.
257
+ await page.setViewportSize({ width: 1400, height: 1000 }).catch(() => {});
258
+ await page.goto(`${LIBRARY_URL}?date_range=${range}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
259
+
260
+ // Chờ bảng vẽ XONG, không phải chờ "có dòng nào chưa".
261
+ //
262
+ // Bản đầu thoát ngay khi `rows.length` khác 0 và nó SAI THẬT: đo 22/09 trên
263
+ // kênh Nhà Nông Chia Sẻ ra 5 bài trong khi bảng có 29 — trang đổ dòng dần,
264
+ // bắt được lứa đầu rồi bỏ đi thì mất phần lớn bảng, mà không có dấu hiệu gì
265
+ // vì 5 bài vẫn là một kết quả "hợp lệ".
266
+ //
267
+ // Bảng đầy đủ luôn render 10 dòng một lúc (trần một trang là 50), nên mốc
268
+ // "đủ 10 dòng" là dấu hiệu chắc chắn. Kênh ít bài hơn 10 thì không bao giờ
269
+ // đạt mốc đó — với chúng, dấu hiệu là số dòng ĐỨNG YÊN ba vòng liền.
270
+ let first = { chip: '', rows: [] };
271
+ let same = 0;
272
+ for (let i = 0; i < 15; i++) {
273
+ await page.waitForTimeout(2000);
274
+ const got = await page.evaluate(readLibraryRowsInPage).catch(() => ({ chip: '', rows: [] }));
275
+ same = got.rows.length === first.rows.length && got.rows.length > 0 ? same + 1 : 0;
276
+ first = got;
277
+ if (first.rows.length >= 10 || same >= 3) break;
278
+ }
279
+ if (!first.rows.length) {
280
+ return { range, chip: first.chip || '', posts: [], steps: 0, note: 'không đọc được dòng nào' };
281
+ }
282
+
283
+ // Nhặt từng bước rồi gộp — bảng tái dùng dòng, đọc một phát là mất khúc giữa.
284
+ const seen = new Map();
285
+ const key = (r) => `${r.posted_txt}|${r.caption.slice(0, 40)}`;
286
+ for (const r of first.rows) seen.set(key(r), r);
287
+ let chip = first.chip; let stable = 0; let steps = 0; let grew = false;
288
+ const deadline = Date.now() + LIB_BUDGET_MS;
289
+ for (let i = 0; i < maxSteps; i++) {
290
+ if (Date.now() > deadline) break;
291
+ await page.evaluate(scrollLibraryInPage).catch(() => 0);
292
+ await page.keyboard.press('PageDown').catch(() => {});
293
+ await page.waitForTimeout(2200);
294
+ const got = await page.evaluate(readLibraryRowsInPage).catch(() => ({ rows: [] }));
295
+ chip = got.chip || chip;
296
+ const before = seen.size;
297
+ for (const r of got.rows || []) seen.set(key(r), r);
298
+ steps = i + 1;
299
+ if (seen.size > before) grew = true;
300
+ // Ba vòng liền không thêm bài nào = hết bảng. Dừng sớm để khỏi đốt thời
301
+ // gian của 44 kênh mỗi ngày.
302
+ //
303
+ // `grew`: chỉ được tin cổng dừng SAU KHI đã thấy bảng lớn lên ít nhất một
304
+ // lần. Không có nó thì mấy vòng đầu — lúc trang còn đang đổ dòng — tự cộng
305
+ // dồn thành "hết bảng" và cắt lượt đọc ngay từ vòng 3 (đúng lỗi 5/29 bài).
306
+ //
307
+ // Nhưng kênh ít bài thì bảng KHÔNG BAO GIỜ lớn lên (6 bài, hiện hết ngay từ
308
+ // đầu) — chỉ đòi `grew` là nó cuộn hết 26 vòng cho 6 bài, tốn 73 giây vô
309
+ // ích (đo thật 22/09, kênh meo-nho-khoe-to). Nên sau 5 vòng vẫn đứng yên
310
+ // thì cũng là hết bảng.
311
+ // Ngưỡng 5 chứ không phải 3: bảng có lúc NGHỈ một hai vòng rồi mới nạp
312
+ // tiếp. Đo 22/09 kênh Nhà Nông — ngưỡng 3 cắt ở 25/29 bài, ngưỡng 5 ra đủ.
313
+ // Bài thiếu không kêu lỗi ở đâu cả, nên chỗ này phải rộng tay.
314
+ stable = seen.size === before ? stable + 1 : 0;
315
+ if (stable >= 5 && (grew || i >= 5)) break;
316
+ }
317
+
318
+ const now = new Date();
319
+ const posts = [];
320
+ for (const r of seen.values()) {
321
+ const views = parseExact(r.views_text);
322
+ if (views == null) continue; // dòng chưa đổ số — bỏ, đừng ghi 0
323
+ posts.push({
324
+ at: parseLibraryTime(r.posted_txt, now),
325
+ posted_txt: r.posted_txt,
326
+ caption: r.caption,
327
+ views,
328
+ viewers: parseExact(r.viewers_text),
329
+ engagements: parseExact(r.engagements_text),
330
+ net_followers: parseExact(r.net_followers_text),
331
+ content_id: String(r.content_id || '').slice(0, 120),
332
+ });
333
+ }
334
+ log('info', `[library] ${range}: ${posts.length} bài sau ${steps} vòng cuộn — "${chip}"`);
335
+ return { range, chip, posts, steps };
336
+ }
337
+
130
338
  const INSIGHTS_BASE = 'https://www.facebook.com/professional_dashboard/profile_insights';
131
339
 
132
340
  // Số trên Insights là số đầy đủ, dấu chấm là phân cách nghìn ("1.333.852").
@@ -490,7 +698,7 @@ async function dumpInsightsFailure(page, tag, log) {
490
698
  // chip "Tùy chỉnh: 9 Tháng 9" ra đều). Một chip kẹt làm mất view hôm qua +
491
699
  // follower — hai số quan trọng nhất — cho cả ngày, /analytics báo "Chưa quét".
492
700
  // Ngày hôm qua tự có vòng nạp lại 3 lần nên không cần trang khác làm ấm.
493
- const DEFAULT_READERS = { readOneDay, readAudiencePage, readViewsPage, dump: dumpInsightsFailure };
701
+ const DEFAULT_READERS = { readOneDay, readAudiencePage, readViewsPage, readLibraryPage, dump: dumpInsightsFailure };
494
702
  async function runInsights({ page, payload, log }, R = DEFAULT_READERS) {
495
703
  const yIso = vnYesterdayIso();
496
704
  const yest = await R.readOneDay(page, yIso, log);
@@ -509,6 +717,25 @@ async function runInsights({ page, payload, log }, R = DEFAULT_READERS) {
509
717
  ? `không đọc được cả ngày hôm qua lẫn 7 ngày — ${weekError}`
510
718
  : 'không đọc được tổng lượt xem trên trang Insights — FB đổi giao diện?');
511
719
  }
720
+
721
+ // Thư viện nội dung: bảng MỌI bài, số luỹ kế chính xác tới đơn vị. Đây là chỗ
722
+ // duy nhất nhìn thấy bài CŨ, nên nó gánh việc gọi tên bài đang nổ.
723
+ //
724
+ // ĐỌC SAU CÙNG và hỏng thì CHỈ MẤT NÓ (cùng lý do như 7 ngày, vá 10/09): ba
725
+ // số quan trọng nhất — view hôm qua, follower, tổng 7 ngày — đã nằm trong tay
726
+ // rồi, không được để một trang mới làm hỏng cả lượt.
727
+ let library = { range: LIB_RANGE, chip: '', posts: [] };
728
+ let libraryError = null;
729
+ try {
730
+ // Bộ đọc THIẾU không phải lỗi — bên gọi truyền vào bộ đọc rút gọn (test
731
+ // hiện có cho phần 7-ngày làm đúng vậy) thì chỉ là không có phần này.
732
+ if (typeof R.readLibraryPage === 'function') library = await R.readLibraryPage(page, log);
733
+ } catch (e) {
734
+ libraryError = String((e && e.message) || e);
735
+ log('warn', `[library] hỏng (${libraryError}) — vẫn ghi phần Insights, chỉ mất số từng bài`);
736
+ await R.dump(page, 'content-library', log);
737
+ }
738
+
512
739
  if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
513
740
  const d = await log.apiRequest('POST', '/analytics/fb-insights', {
514
741
  channel_id: payload.channel_id,
@@ -516,14 +743,19 @@ async function runInsights({ page, payload, log }, R = DEFAULT_READERS) {
516
743
  yesterday: { date: yest.iso, views: yest.views, chip: yest.chip },
517
744
  week: { views: week.total, posts: week.posts },
518
745
  audience,
746
+ library: { range: library.range, chip: library.chip, posts: library.posts },
519
747
  });
520
- log('info', `[insights] đã ghi: hôm qua ${d?.prev_day_views}, follower ${d?.followers}, bài khớp ${d?.matched}/${d?.posts}, view bài cũ ${d?.unattributed}`);
748
+ log('info', `[insights] đã ghi: hôm qua ${d?.prev_day_views}, follower ${d?.followers}, bài khớp ${d?.matched}/${d?.posts}, view bài cũ ${d?.unattributed}`
749
+ + `, thư viện ${library.posts.length} bài (khớp ${d?.library_matched ?? '?'}, bài cũ đang lên ${d?.spike_posts ?? 0})`);
521
750
  return {
522
751
  ok: true, mode: 'insights',
523
752
  views_yesterday: yest.views, views_7d: week.total,
524
753
  posts: week.posts.length, matched: d?.matched ?? null,
525
754
  followers: audience.followers, unattributed: d?.unattributed ?? null,
755
+ library_posts: library.posts.length,
756
+ library_matched: d?.library_matched ?? null,
526
757
  ...(weekError ? { week_error: weekError } : {}),
758
+ ...(libraryError ? { library_error: libraryError } : {}),
527
759
  };
528
760
  }
529
761
 
@@ -589,6 +821,20 @@ async function run({ page, payload, log }) {
589
821
  // được với danh sách page đã liên kết bên Shopee. Không có thì bỏ qua, lượt
590
822
  // ghi số liệu không phụ thuộc vào nó.
591
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
+ }
592
838
  if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
593
839
 
594
840
  const d = await log.apiRequest('POST', '/analytics/post-stats', {
@@ -600,4 +846,4 @@ async function run({ page, payload, log }) {
600
846
  return { ok: true, mode, tiles: tiles.length, written: d?.written || 0, matched: d?.matched || 0, rising: d?.rising || 0, alerts: d?.alerts || 0, followers };
601
847
  }
602
848
 
603
- module.exports = { run, __testables: { parseViews, scrapeFollowers, parseExact, parseVnPostTime, runInsights, dumpInsightsFailure, pickRangeLabelInPage, discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles } };
849
+ module.exports = { run, __testables: { parseLibraryTime, readLibraryPage, parseViews, scrapeFollowers, parseExact, parseVnPostTime, runInsights, dumpInsightsFailure, pickRangeLabelInPage, discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles } };
@@ -1155,6 +1155,73 @@ function parsePageRef(reelsTabUrl) {
1155
1155
  return { id: '', handle: seg, url: raw };
1156
1156
  }
1157
1157
 
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
+
1158
1225
  async function hasVisibleReelComposer(page) {
1159
1226
  return page.evaluate(() => {
1160
1227
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
@@ -3691,6 +3758,7 @@ module.exports.__testables = {
3691
3758
  pickReelsTabUrl,
3692
3759
  pickReelsTabUrls,
3693
3760
  parsePageRef,
3761
+ readPageIdFromDom,
3694
3762
  discoverReelsTabUrls,
3695
3763
  openReelsTabWithTiles,
3696
3764
  networkIdVerdict,