channel-worker 2.5.78 → 2.5.79
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
|
@@ -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: '
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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,156 @@ 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
|
+
async function readViewsPage(page, range, log) {
|
|
161
|
+
const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=${range}`;
|
|
162
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
163
|
+
await page.waitForTimeout(7000);
|
|
164
|
+
const raw = await page.evaluate(() => {
|
|
165
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
166
|
+
const seen = new Set(); const chunks = [];
|
|
167
|
+
for (const e of document.querySelectorAll('div,span')) {
|
|
168
|
+
const t = clean(e.innerText);
|
|
169
|
+
if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
|
|
170
|
+
if (/Lượt xem|lượt xem/.test(t)) { seen.add(t); chunks.push(t); }
|
|
171
|
+
if (chunks.length > 120) break;
|
|
172
|
+
}
|
|
173
|
+
const label = [...document.querySelectorAll('div,span,button')]
|
|
174
|
+
.map((e) => clean(e.innerText))
|
|
175
|
+
.find((t) => t.length < 60 && /ngày qua:|hôm nay/i.test(t)) || '';
|
|
176
|
+
return { chunks, label };
|
|
177
|
+
}).catch(() => ({ chunks: [], label: '' }));
|
|
178
|
+
|
|
179
|
+
// Tổng: "216.353 -35% Tổng số lượt xem trong 7 ngày qua" / "… hôm nay"
|
|
180
|
+
let total = null; let pct = null;
|
|
181
|
+
for (const c of raw.chunks) {
|
|
182
|
+
const m = /^([\d.]+)\s*(?:(-?[\d.,]+)%)?\s*Tổng số lượt xem/i.exec(c);
|
|
183
|
+
if (m) { total = parseExact(m[1]); pct = m[2] ? parseFloat(String(m[2]).replace(',', '.')) : null; break; }
|
|
184
|
+
}
|
|
185
|
+
// Dòng bài: "31 Tháng 8 11:41 56.243 Lượt xem"
|
|
186
|
+
const posts = [];
|
|
187
|
+
for (const c of raw.chunks) {
|
|
188
|
+
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);
|
|
189
|
+
if (!m) continue;
|
|
190
|
+
const at = parseVnPostTime(m[1]);
|
|
191
|
+
const views = parseExact(m[2]);
|
|
192
|
+
if (at && views != null && !posts.some((p) => p.at === at)) posts.push({ at, views, raw: m[0].slice(0, 60) });
|
|
193
|
+
}
|
|
194
|
+
log('info', `[insights] ${range}: tổng ${total}${pct != null ? ` (${pct}%)` : ''}, ${posts.length} bài — nhãn "${raw.label}"`);
|
|
195
|
+
return { range, label: raw.label, total, pct, posts };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function readAudiencePage(page, log) {
|
|
199
|
+
await page.goto(`${INSIGHTS_BASE}/audience/`, { waitUntil: 'domcontentloaded', timeout: 45_000 });
|
|
200
|
+
await page.waitForTimeout(7000);
|
|
201
|
+
const chunks = await page.evaluate(() => {
|
|
202
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
203
|
+
const seen = new Set(); const out = [];
|
|
204
|
+
for (const e of document.querySelectorAll('div,span')) {
|
|
205
|
+
const t = clean(e.innerText);
|
|
206
|
+
if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
|
|
207
|
+
if (/người theo dõi|bỏ theo dõi/i.test(t)) { seen.add(t); out.push(t); }
|
|
208
|
+
if (out.length > 60) break;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}).catch(() => []);
|
|
212
|
+
const pick = (re) => { for (const c of chunks) { const m = re.exec(c); if (m) return parseExact(m[1]); } return null; };
|
|
213
|
+
const a = {
|
|
214
|
+
followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Tổng số người theo dõi/i),
|
|
215
|
+
net_followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Số người theo dõi thực/i),
|
|
216
|
+
unfollows: pick(/([\d.]+)\s*Lượt bỏ theo dõi/i),
|
|
217
|
+
};
|
|
218
|
+
log('info', `[insights] đối tượng: ${a.followers} follower, thực ${a.net_followers}, bỏ ${a.unfollows}`);
|
|
219
|
+
return a;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Lượt nền: hai trang view + một trang đối tượng, rồi gửi thẳng số thô về API.
|
|
223
|
+
// 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 —
|
|
224
|
+
// worker không giữ trạng thái ngày hôm qua.
|
|
225
|
+
async function runInsights({ page, payload, log }) {
|
|
226
|
+
const today = await readViewsPage(page, 'TODAY', log);
|
|
227
|
+
const week = await readViewsPage(page, 'LAST_7D', log);
|
|
228
|
+
const audience = await readAudiencePage(page, log);
|
|
229
|
+
if (today.total == null && week.total == null) {
|
|
230
|
+
throw new Error('không đọc được tổng lượt xem trên trang Insights — FB đổi giao diện?');
|
|
231
|
+
}
|
|
232
|
+
// Nhãn phải khớp khoảng đã xin: token sai thì trang im lặng rơi về 28 ngày.
|
|
233
|
+
if (week.label && !/7 ngày qua/i.test(week.label)) {
|
|
234
|
+
throw new Error(`xin LAST_7D nhưng trang trả về "${week.label}" — token date_range đã đổi`);
|
|
235
|
+
}
|
|
236
|
+
if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
|
|
237
|
+
const d = await log.apiRequest('POST', '/analytics/fb-insights', {
|
|
238
|
+
channel_id: payload.channel_id,
|
|
239
|
+
at: new Date().toISOString(),
|
|
240
|
+
today: { views: today.total, pct: today.pct, posts: today.posts },
|
|
241
|
+
week: { views: week.total, posts: week.posts },
|
|
242
|
+
audience,
|
|
243
|
+
});
|
|
244
|
+
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}`);
|
|
245
|
+
return {
|
|
246
|
+
ok: true, mode: 'insights',
|
|
247
|
+
views_today: today.total, views_7d: week.total,
|
|
248
|
+
posts: week.posts.length, matched: d?.matched ?? null,
|
|
249
|
+
followers: audience.followers, unattributed: d?.unattributed ?? null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
106
253
|
async function run({ page, payload, log }) {
|
|
107
254
|
const { channel_id, page_url, mode = 'deep' } = payload || {};
|
|
108
255
|
if (!channel_id) throw new Error('payload.channel_id required');
|
|
256
|
+
if (mode === 'insights') return runInsights({ page, payload, log });
|
|
109
257
|
const scrolls = Number.isFinite(Number(payload?.scroll)) && Number(payload.scroll) > 0
|
|
110
258
|
? Number(payload.scroll)
|
|
111
259
|
: (mode === 'fresh' ? 2 : 6);
|
|
@@ -162,4 +310,4 @@ async function run({ page, payload, log }) {
|
|
|
162
310
|
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
311
|
}
|
|
164
312
|
|
|
165
|
-
module.exports = { run, __testables: { parseViews, scrapeFollowers } };
|
|
313
|
+
module.exports = { run, __testables: { parseViews, scrapeFollowers, parseExact, parseVnPostTime } };
|