channel-worker 2.5.81 → 2.5.89
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 +1 -1
- package/scripts/fetch_facebook_reel_stats.js +212 -48
package/package.json
CHANGED
|
@@ -130,31 +130,52 @@ async function scrapeTiles(page) {
|
|
|
130
130
|
// LAST_90D chạy thật. THIS_YEAR và LIFETIME thì trang LẶNG LẼ rơi về 28 ngày —
|
|
131
131
|
// không báo lỗi, nên phải đối chiếu nhãn khoảng thời gian trả về.
|
|
132
132
|
|
|
133
|
+
const vnYesterdayIso = () => new Date(Date.now() + 7 * 3600e3 - 864e5).toISOString().slice(0, 10);
|
|
134
|
+
|
|
133
135
|
const INSIGHTS_BASE = 'https://www.facebook.com/professional_dashboard/profile_insights';
|
|
134
136
|
|
|
135
137
|
// Số trên Insights là số đầy đủ, dấu chấm là phân cách nghìn ("1.333.852").
|
|
136
138
|
// KHÔNG dùng parseViews ở đây — hàm đó dịch "1.2" thành 12 vì phải lo hậu tố K/N.
|
|
139
|
+
// Số trên Insights luôn là số NGUYÊN, chỉ khác dấu ngăn nghìn theo ngôn ngữ
|
|
140
|
+
// của profile: tiếng Việt dùng dấu chấm ("1.333.852"), tiếng Anh dùng dấu phẩy
|
|
141
|
+
// ("312,314"). Bỏ CẢ HAI. Chỉ strip dấu chấm là profile tiếng Anh đọc "312,314"
|
|
142
|
+
// thành 312 — sai 1000 lần mà không có dấu hiệu gì (gặp thật 2026-09-06, 4 kênh).
|
|
137
143
|
function parseExact(txt) {
|
|
138
|
-
const m = /(\d[\d
|
|
144
|
+
const m = /(\d[\d.,]*)/.exec(String(txt || '').replace(/\s+/g, ' '));
|
|
139
145
|
if (!m) return null;
|
|
140
|
-
const n = parseInt(m[1].replace(
|
|
146
|
+
const n = parseInt(m[1].replace(/[.,]/g, ''), 10);
|
|
141
147
|
return Number.isFinite(n) ? n : null;
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
// "31 Tháng 8 11:41" → ISO. Facebook không ghi năm; tháng lớn hơn tháng hiện
|
|
145
151
|
// tại nghĩa là năm ngoái (bài cuối tháng 12 xem vào tháng 1).
|
|
152
|
+
const EN_MONTH = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
|
|
153
|
+
|
|
146
154
|
function parseVnPostTime(txt, now = new Date()) {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
const
|
|
155
|
+
const t = String(txt || '');
|
|
156
|
+
let d; let mo; let h; let mi;
|
|
157
|
+
const vn = /(\d{1,2})\s*Tháng\s*(\d{1,2})\s+(\d{1,2}):(\d{2})/i.exec(t);
|
|
158
|
+
if (vn) {
|
|
159
|
+
[, d, mo, h, mi] = vn.map(Number);
|
|
160
|
+
} else {
|
|
161
|
+
// Profile tiếng Anh: "Fri Sep 4, 2:33pm" (12 giờ, có am/pm).
|
|
162
|
+
const en = /(?:[A-Za-z]{3},?\s+)?([A-Za-z]{3})\s+(\d{1,2}),?\s+(\d{1,2}):(\d{2})\s*(am|pm)/i.exec(t);
|
|
163
|
+
if (!en) return null;
|
|
164
|
+
mo = EN_MONTH[en[1].toLowerCase()];
|
|
165
|
+
if (!mo) return null;
|
|
166
|
+
d = Number(en[2]); h = Number(en[3]); mi = Number(en[4]);
|
|
167
|
+
const pm = en[5].toLowerCase() === 'pm';
|
|
168
|
+
if (pm && h < 12) h += 12;
|
|
169
|
+
if (!pm && h === 12) h = 0;
|
|
170
|
+
}
|
|
150
171
|
let year = now.getUTCFullYear();
|
|
151
172
|
// Mốc so sánh theo giờ VN vì Facebook hiện giờ địa phương của tài khoản.
|
|
152
173
|
const vnNow = new Date(now.getTime() + 7 * 3600e3);
|
|
153
174
|
if (mo > vnNow.getUTCMonth() + 1) year -= 1;
|
|
154
175
|
const iso = `${year}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
|
155
176
|
+ `T${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}:00+07:00`;
|
|
156
|
-
const
|
|
157
|
-
return Number.isNaN(
|
|
177
|
+
const parsed = new Date(iso);
|
|
178
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
// Nhãn khoảng thời gian PHẢI khớp cái đã xin. Trang dựng khung trước rồi mới
|
|
@@ -162,32 +183,107 @@ function parseVnPostTime(txt, now = new Date()) {
|
|
|
162
183
|
// 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
184
|
// kênh ghi được daily_views 279.946 trong khi views_7d chỉ 278.662 — tức là số
|
|
164
185
|
// 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.
|
|
186
|
+
// Profile để tiếng Anh thì Facebook trả nhãn tiếng Anh ("Last 7 days: Aug 30 -
|
|
187
|
+
// Sep 5"). Đo 2026-09-06: 4/52 kênh như vậy, parser thuần tiếng Việt coi trang
|
|
188
|
+
// đó là trống hoàn toàn.
|
|
165
189
|
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,
|
|
190
|
+
TODAY: /hôm nay|^today/i,
|
|
191
|
+
LAST_7D: /7 ngày qua|last 7 days/i,
|
|
192
|
+
LAST_14D: /14 ngày qua|last 14 days/i,
|
|
193
|
+
LAST_28D: /28 ngày qua|last 28 days/i,
|
|
194
|
+
LAST_60D: /60 ngày qua|last 60 days/i,
|
|
195
|
+
LAST_90D: /90 ngày qua|last 90 days/i,
|
|
172
196
|
};
|
|
173
197
|
|
|
198
|
+
// Đọc CHIP khoảng thời gian ("7 ngày qua: 30 Tháng 8 - 5 Tháng 9", "Hôm nay",
|
|
199
|
+
// "Tùy chỉnh: …"). KHÔNG đọc dòng "Tổng số lượt xem": Facebook dựng HAI kiểu
|
|
200
|
+
// giao diện cho cùng trang này — kiểu A có "… Tổng số lượt xem trong 7 ngày
|
|
201
|
+
// qua", kiểu B là "Số liệu chia nhỏ về lượt xem" rồi "26.313 -91% Lượt xem"
|
|
202
|
+
// (không có chữ "Tổng số"). Bám vào dòng đó là ~10/44 kênh kiểu B bị coi như
|
|
203
|
+
// trang trống — đúng ca "Má hồng phai" 2026-09-06.
|
|
204
|
+
async function currentRangeLabel(page) {
|
|
205
|
+
return page.evaluate(() => {
|
|
206
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
207
|
+
for (const e of document.querySelectorAll('div[role="button"],div,span')) {
|
|
208
|
+
const t = clean(e.innerText);
|
|
209
|
+
if (t.length < 70 && /^(Hôm nay$|Today$|Tùy chỉnh:|Custom:|Năm nay:|This year:|\d+ ngày qua:|Last \d+ days:)/i.test(t)) return t;
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
}).catch(() => '');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Tổng lượt xem của khoảng đang hiện, đọc được CẢ HAI kiểu giao diện.
|
|
216
|
+
// Phải neo cuối chuỗi ở "Lượt xem": trang còn có "Lượt xem trong tối thiểu 3
|
|
217
|
+
// giây" và "… 1 phút", khớp lỏng là vớ nhầm mấy số đó.
|
|
218
|
+
// Giữa CON SỐ và NHÃN, Facebook nhét đủ thứ không phải chữ: phần trăm, và có
|
|
219
|
+
// khi cả MŨI TÊN. Ảnh chụp 2026-09-06 kênh "lặn biển 01":
|
|
220
|
+
// "105,474 ↓ -46% Total views from previous 28 days"
|
|
221
|
+
// Regex cũ chỉ cho khoảng trắng ở đó nên trượt sạch — trong khi số nằm ngay
|
|
222
|
+
// đấy. Bản tiếng Việt và khoảng 7 ngày không kèm mũi tên nên vẫn chạy, chỉ
|
|
223
|
+
// khoảng tuỳ chỉnh (có % giảm) mới lộ. `[^\p{L}]*` nuốt mọi thứ không phải chữ.
|
|
224
|
+
const TOTAL_LABELS = [
|
|
225
|
+
/^([\d.,]+)([^\p{L}]*)(?:Tổng số lượt xem|Total views)/iu,
|
|
226
|
+
/^([\d.,]+)([^\p{L}]*)(?:Lượt xem|Views)$/iu,
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
function pickTotal(chunks) {
|
|
230
|
+
for (const re of TOTAL_LABELS) {
|
|
231
|
+
for (const c of chunks) {
|
|
232
|
+
const m = re.exec(c);
|
|
233
|
+
if (!m) continue;
|
|
234
|
+
const pm = /(-?[\d.,]+)%/.exec(m[2] || '');
|
|
235
|
+
return { views: parseExact(m[1]), pct: pm ? parseFloat(pm[1].replace(/,/g, '')) : null };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { views: null, pct: null };
|
|
239
|
+
}
|
|
240
|
+
|
|
174
241
|
async function readViewsPage(page, range, log) {
|
|
175
242
|
const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=${range}`;
|
|
176
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
177
243
|
const want = RANGE_LABEL[range];
|
|
178
244
|
let label = '';
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
245
|
+
// LẦN ĐIỀU HƯỚNG ĐẦU TIÊN THƯỜNG BỊ BỎ QUA: trang giữ mặc định 28 ngày và
|
|
246
|
+
// lờ tham số date_range. Đo thật 2026-09-06 lúc 12h trưa: 8/47 kênh xin
|
|
247
|
+
// TODAY mà trang trả về "… trong 28 ngày qua" — không phải hết dữ liệu, mà
|
|
248
|
+
// là tham số không ăn. Nạp lại là lần sau nó nhận, nên thử tới 3 lượt.
|
|
249
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
250
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
251
|
+
for (let i = 0; i < 8; i++) {
|
|
252
|
+
await page.waitForTimeout(2000);
|
|
253
|
+
label = await currentRangeLabel(page);
|
|
254
|
+
if (label && (!want || want.test(label))) break;
|
|
255
|
+
}
|
|
256
|
+
if (!want || !label || want.test(label)) break;
|
|
257
|
+
log('info', `[insights] ${range}: lần ${attempt + 1} trang vẫn hiện "${label}", nạp lại`);
|
|
258
|
+
// Facebook NHỚ khoảng đã chọn lần trước theo từng profile, và tham số trên
|
|
259
|
+
// URL không phải lúc nào cũng đè được cái nhớ đó — nạp lại mấy lần vẫn y
|
|
260
|
+
// nguyên (đo 2026-09-06: 4 kênh xin LAST_7D mà cứ hiện "Hôm nay"). Đường
|
|
261
|
+
// chắc chắn là BẤM vào mục trong bộ chọn: chúng là thẻ <a> nên bấm là điều
|
|
262
|
+
// hướng client-side, luôn áp dụng.
|
|
263
|
+
if (attempt === 1) {
|
|
264
|
+
// CHUỘT THẬT của Playwright, không phải element.click() trong evaluate:
|
|
265
|
+
// click tổng hợp trong DOM không phải lúc nào cũng kích được handler của
|
|
266
|
+
// React (đo 2026-09-06: evaluate trả false, log không hề có dòng "đổi
|
|
267
|
+
// khoảng"). page.click gửi đủ chuỗi sự kiện chuột.
|
|
268
|
+
const ok = await (async () => {
|
|
269
|
+
try {
|
|
270
|
+
await page.locator('div[role="button"]').filter({ hasText: /^(Hôm nay|Today|Tùy chỉnh:|Custom:|Năm nay:|This year:|\d+ ngày qua:|Last \d+ days:)/ })
|
|
271
|
+
.first().click({ timeout: 8000 });
|
|
272
|
+
await page.waitForTimeout(2000);
|
|
273
|
+
await page.locator(`a[role="menuitemradio"][href*="date_range=${range}"]`).first().click({ timeout: 8000 });
|
|
274
|
+
return true;
|
|
275
|
+
} catch { return false; }
|
|
276
|
+
})();
|
|
277
|
+
if (ok) {
|
|
278
|
+
log('info', `[insights] ${range}: đổi khoảng bằng cách bấm bộ chọn`);
|
|
279
|
+
for (let i = 0; i < 8; i++) {
|
|
280
|
+
await page.waitForTimeout(2000);
|
|
281
|
+
label = await currentRangeLabel(page);
|
|
282
|
+
if (label && want.test(label)) break;
|
|
283
|
+
}
|
|
284
|
+
if (label && want.test(label)) break;
|
|
187
285
|
}
|
|
188
|
-
|
|
189
|
-
}).catch(() => '');
|
|
190
|
-
if (label && (!want || want.test(label))) break;
|
|
286
|
+
}
|
|
191
287
|
}
|
|
192
288
|
// Hai ca KHÁC HẲN nhau, đừng gộp:
|
|
193
289
|
// nhãn RỖNG = khoảng này chưa có số nào. Chuyện thường, không phải lỗi —
|
|
@@ -208,25 +304,23 @@ async function readViewsPage(page, range, log) {
|
|
|
208
304
|
for (const e of document.querySelectorAll('div,span')) {
|
|
209
305
|
const t = clean(e.innerText);
|
|
210
306
|
if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
|
|
211
|
-
if (/
|
|
307
|
+
if (/[Ll]ượt xem|[Vv]iews/.test(t)) { seen.add(t); chunks.push(t); }
|
|
212
308
|
if (chunks.length > 120) break;
|
|
213
309
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
.find((t) => t.length < 60 && /ngày qua:|hôm nay/i.test(t)) || '';
|
|
217
|
-
return { chunks, label };
|
|
218
|
-
}).catch(() => ({ chunks: [], label: '' }));
|
|
310
|
+
return { chunks };
|
|
311
|
+
}).catch(() => ({ chunks: [] }));
|
|
219
312
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (const c of raw.chunks) {
|
|
223
|
-
const m = /^([\d.]+)\s*(?:(-?[\d.,]+)%)?\s*Tổng số lượt xem/i.exec(c);
|
|
224
|
-
if (m) { total = parseExact(m[1]); pct = m[2] ? parseFloat(String(m[2]).replace(',', '.')) : null; break; }
|
|
225
|
-
}
|
|
313
|
+
const t0 = pickTotal(raw.chunks);
|
|
314
|
+
const total = t0.views; const pct = t0.pct;
|
|
226
315
|
// Dòng bài: "31 Tháng 8 11:41 56.243 Lượt xem"
|
|
227
316
|
const posts = [];
|
|
317
|
+
const POST_LINE = [
|
|
318
|
+
/(\d{1,2}\s*Tháng\s*\d{1,2}\s+\d{1,2}:\d{2})\s+([\d.,]+)\s*Lượt xem/i,
|
|
319
|
+
/((?:[A-Za-z]{3},?\s+)?[A-Za-z]{3}\s+\d{1,2},?\s+\d{1,2}:\d{2}\s*(?:am|pm))\s+([\d.,]+)\s*Views/i,
|
|
320
|
+
];
|
|
228
321
|
for (const c of raw.chunks) {
|
|
229
|
-
|
|
322
|
+
let m = null;
|
|
323
|
+
for (const re of POST_LINE) { m = re.exec(c); if (m) break; }
|
|
230
324
|
if (!m) continue;
|
|
231
325
|
const at = parseVnPostTime(m[1]);
|
|
232
326
|
const views = parseExact(m[2]);
|
|
@@ -236,6 +330,70 @@ async function readViewsPage(page, range, log) {
|
|
|
236
330
|
return { range, label, total, pct, posts };
|
|
237
331
|
}
|
|
238
332
|
|
|
333
|
+
// Đọc ĐÚNG MỘT NGÀY qua khoảng tuỳ chỉnh.
|
|
334
|
+
//
|
|
335
|
+
// Vì sao không dùng date_range=TODAY: Facebook KHÔNG có số của hôm nay. Đo
|
|
336
|
+
// 2026-09-06 lúc 12h trưa trên kênh 1,5 triệu view/7 ngày, trang trả về đúng
|
|
337
|
+
// chữ "--" kèm "Chưa đủ dữ liệu để hiển thị". Chip khoảng thời gian của chính
|
|
338
|
+
// nó cũng kết thúc ở HÔM QUA ("28 ngày qua: 9 Tháng 8 - 5 Tháng 9" trong khi
|
|
339
|
+
// đã là ngày 6) — cửa sổ dữ liệu của Facebook đóng sổ ở hôm qua.
|
|
340
|
+
//
|
|
341
|
+
// Không có preset "Hôm qua" (đã thử YESTERDAY/LAST_1D/PREVIOUS_DAY, cả ba rơi
|
|
342
|
+
// về 28 ngày). Định dạng dưới đây lấy được bằng cách bấm thật vào lịch trong
|
|
343
|
+
// mục "Tùy chỉnh" rồi đọc URL nó sinh ra.
|
|
344
|
+
//
|
|
345
|
+
// BẪY: dòng "… Tổng số lượt xem trong 28 ngày qua" ở khoảng tuỳ chỉnh là NHÃN
|
|
346
|
+
// CŨ chưa kịp đổi, nhưng CON SỐ thì đã đúng của ngày đã chọn. Nên phải xác
|
|
347
|
+
// minh bằng CHIP ("Tùy chỉnh: 5 Tháng 9 – 5 Tháng 9"), không phải bằng nhãn đó.
|
|
348
|
+
async function readOneDay(page, iso, log) {
|
|
349
|
+
const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=CUSTOM&start_date=${iso}&end_date=${iso}`;
|
|
350
|
+
let chip = '';
|
|
351
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
352
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
353
|
+
for (let i = 0; i < 8; i++) {
|
|
354
|
+
await page.waitForTimeout(2000);
|
|
355
|
+
chip = await page.evaluate(() => {
|
|
356
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
357
|
+
for (const e of document.querySelectorAll('div[role="button"],span,div')) {
|
|
358
|
+
const t = clean(e.innerText);
|
|
359
|
+
if (t.length < 70 && /^(Tùy chỉnh:|Custom:)/i.test(t)) return t;
|
|
360
|
+
}
|
|
361
|
+
return '';
|
|
362
|
+
}).catch(() => '');
|
|
363
|
+
if (chip) break;
|
|
364
|
+
}
|
|
365
|
+
if (chip) break;
|
|
366
|
+
log('info', `[insights] ngày ${iso}: lần ${attempt + 1} chưa thấy chip tuỳ chỉnh, nạp lại`);
|
|
367
|
+
}
|
|
368
|
+
if (!chip) return { iso, chip: '', views: null };
|
|
369
|
+
// Chip đổi TRƯỚC, con số đổ về SAU — chờ cố định 3s là hên xui: đo 2026-09-06
|
|
370
|
+
// có kênh chip đúng "Custom: Sep 5 - Sep 5" mà số vẫn null. Chờ tới khi thấy
|
|
371
|
+
// số, tối đa ~14s.
|
|
372
|
+
let views = null;
|
|
373
|
+
for (let i = 0; i < 7; i++) {
|
|
374
|
+
await page.waitForTimeout(2000);
|
|
375
|
+
const cs = await readTotalChunks(page);
|
|
376
|
+
views = pickTotal(cs).views;
|
|
377
|
+
if (views != null) break;
|
|
378
|
+
}
|
|
379
|
+
log('info', `[insights] ngày ${iso}: ${views} lượt xem — chip "${chip}"`);
|
|
380
|
+
return { iso, chip, views };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function readTotalChunks(page) {
|
|
384
|
+
return page.evaluate(() => {
|
|
385
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
386
|
+
const seen = new Set(); const out = [];
|
|
387
|
+
for (const e of document.querySelectorAll('div,span')) {
|
|
388
|
+
const t = clean(e.innerText);
|
|
389
|
+
if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
|
|
390
|
+
if (/[Ll]ượt xem|[Vv]iews/.test(t)) { seen.add(t); out.push(t); }
|
|
391
|
+
if (out.length > 120) break;
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}).catch(() => []);
|
|
395
|
+
}
|
|
396
|
+
|
|
239
397
|
async function readAudiencePage(page, log) {
|
|
240
398
|
await page.goto(`${INSIGHTS_BASE}/audience/`, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
241
399
|
await page.waitForTimeout(7000);
|
|
@@ -245,16 +403,19 @@ async function readAudiencePage(page, log) {
|
|
|
245
403
|
for (const e of document.querySelectorAll('div,span')) {
|
|
246
404
|
const t = clean(e.innerText);
|
|
247
405
|
if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
|
|
248
|
-
if (/người theo dõi|bỏ theo dõi/i.test(t)) { seen.add(t); out.push(t); }
|
|
406
|
+
if (/người theo dõi|bỏ theo dõi|followers|follows|unfollows/i.test(t)) { seen.add(t); out.push(t); }
|
|
249
407
|
if (out.length > 60) break;
|
|
250
408
|
}
|
|
251
409
|
return out;
|
|
252
410
|
}).catch(() => []);
|
|
253
|
-
const pick = (
|
|
411
|
+
const pick = (...res) => {
|
|
412
|
+
for (const re of res) for (const c of chunks) { const m = re.exec(c); if (m) return parseExact(m[1]); }
|
|
413
|
+
return null;
|
|
414
|
+
};
|
|
254
415
|
const a = {
|
|
255
|
-
followers: pick(/^([\d
|
|
256
|
-
net_followers: pick(/^([\d
|
|
257
|
-
unfollows: pick(/([\d
|
|
416
|
+
followers: pick(/^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Tổng số người theo dõi/i, /^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Total followers/i),
|
|
417
|
+
net_followers: pick(/^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Số người theo dõi thực/i, /^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Net follows/i),
|
|
418
|
+
unfollows: pick(/([\d.,]+)\s*Lượt bỏ theo dõi/i, /([\d.,]+)\s*Unfollows/i),
|
|
258
419
|
};
|
|
259
420
|
log('info', `[insights] đối tượng: ${a.followers} follower, thực ${a.net_followers}, bỏ ${a.unfollows}`);
|
|
260
421
|
return a;
|
|
@@ -264,24 +425,27 @@ async function readAudiencePage(page, log) {
|
|
|
264
425
|
// 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 —
|
|
265
426
|
// worker không giữ trạng thái ngày hôm qua.
|
|
266
427
|
async function runInsights({ page, payload, log }) {
|
|
267
|
-
|
|
428
|
+
// 7 ngày trước để làm ấm trang (lần điều hướng ĐẦU hay bị bỏ qua tham số),
|
|
429
|
+
// rồi mới tới ngày hôm qua.
|
|
268
430
|
const week = await readViewsPage(page, 'LAST_7D', log);
|
|
431
|
+
const yIso = vnYesterdayIso();
|
|
432
|
+
const yest = await readOneDay(page, yIso, log);
|
|
269
433
|
const audience = await readAudiencePage(page, log);
|
|
270
|
-
if (
|
|
434
|
+
if (yest.views == null && week.total == null) {
|
|
271
435
|
throw new Error('không đọc được tổng lượt xem trên trang Insights — FB đổi giao diện?');
|
|
272
436
|
}
|
|
273
437
|
if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
|
|
274
438
|
const d = await log.apiRequest('POST', '/analytics/fb-insights', {
|
|
275
439
|
channel_id: payload.channel_id,
|
|
276
440
|
at: new Date().toISOString(),
|
|
277
|
-
|
|
441
|
+
yesterday: { date: yest.iso, views: yest.views, chip: yest.chip },
|
|
278
442
|
week: { views: week.total, posts: week.posts },
|
|
279
443
|
audience,
|
|
280
444
|
});
|
|
281
|
-
log('info', `[insights] đã ghi:
|
|
445
|
+
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}`);
|
|
282
446
|
return {
|
|
283
447
|
ok: true, mode: 'insights',
|
|
284
|
-
|
|
448
|
+
views_yesterday: yest.views, views_7d: week.total,
|
|
285
449
|
posts: week.posts.length, matched: d?.matched ?? null,
|
|
286
450
|
followers: audience.followers, unattributed: d?.unattributed ?? null,
|
|
287
451
|
};
|