channel-worker 2.5.81 → 2.5.88

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.81",
3
+ "version": "2.5.88",
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": {
@@ -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.]*)/.exec(String(txt || '').replace(/\s+/g, ' '));
144
+ const m = /(\d[\d.,]*)/.exec(String(txt || '').replace(/\s+/g, ' '));
139
145
  if (!m) return null;
140
- const n = parseInt(m[1].replace(/\./g, ''), 10);
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 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);
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 t = new Date(iso);
157
- return Number.isNaN(t.getTime()) ? null : t.toISOString();
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,101 @@ 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
+ const TOTAL_PATTERNS = [
219
+ /^([\d.,]+)\s*(?:([-+]?[\d.,]+)%)?\s*Tổng số lượt xem/i,
220
+ /^([\d.,]+)\s*(?:([-+]?[\d.,]+)%)?\s*Total views/i,
221
+ /^([\d.,]+)\s*(?:([-+]?[\d.,]+)%)?\s*Lượt xem$/i,
222
+ /^([\d.,]+)\s*(?:([-+]?[\d.,]+)%)?\s*Views$/i,
223
+ ];
224
+
225
+ function pickTotal(chunks) {
226
+ for (const re of TOTAL_PATTERNS) {
227
+ for (const c of chunks) {
228
+ const m = re.exec(c);
229
+ if (m) return { views: parseExact(m[1]), pct: m[2] ? parseFloat(String(m[2]).replace(/,/g, '')) : null };
230
+ }
231
+ }
232
+ return { views: null, pct: null };
233
+ }
234
+
174
235
  async function readViewsPage(page, range, log) {
175
236
  const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=${range}`;
176
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
177
237
  const want = RANGE_LABEL[range];
178
238
  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;
239
+ // LẦN ĐIỀU HƯỚNG ĐẦU TIÊN THƯỜNG BỊ BỎ QUA: trang giữ mặc định 28 ngày và
240
+ // lờ tham số date_range. Đo thật 2026-09-06 lúc 12h trưa: 8/47 kênh xin
241
+ // TODAY mà trang trả về "… trong 28 ngày qua" — không phải hết dữ liệu, mà
242
+ // tham số không ăn. Nạp lại là lần sau nó nhận, nên thử tới 3 lượt.
243
+ for (let attempt = 0; attempt < 3; attempt++) {
244
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
245
+ for (let i = 0; i < 8; i++) {
246
+ await page.waitForTimeout(2000);
247
+ label = await currentRangeLabel(page);
248
+ if (label && (!want || want.test(label))) break;
249
+ }
250
+ if (!want || !label || want.test(label)) break;
251
+ log('info', `[insights] ${range}: lần ${attempt + 1} trang vẫn hiện "${label}", nạp lại`);
252
+ // Facebook NHỚ khoảng đã chọn lần trước theo từng profile, và tham số trên
253
+ // 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
254
+ // nguyên (đo 2026-09-06: 4 kênh xin LAST_7D mà cứ hiện "Hôm nay"). Đường
255
+ // 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
256
+ // hướng client-side, luôn áp dụng.
257
+ if (attempt === 1) {
258
+ // CHUỘT THẬT của Playwright, không phải element.click() trong evaluate:
259
+ // click tổng hợp trong DOM không phải lúc nào cũng kích được handler của
260
+ // React (đo 2026-09-06: evaluate trả false, log không hề có dòng "đổi
261
+ // khoảng"). page.click gửi đủ chuỗi sự kiện chuột.
262
+ const ok = await (async () => {
263
+ try {
264
+ 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:)/ })
265
+ .first().click({ timeout: 8000 });
266
+ await page.waitForTimeout(2000);
267
+ await page.locator(`a[role="menuitemradio"][href*="date_range=${range}"]`).first().click({ timeout: 8000 });
268
+ return true;
269
+ } catch { return false; }
270
+ })();
271
+ if (ok) {
272
+ log('info', `[insights] ${range}: đổi khoảng bằng cách bấm bộ chọn`);
273
+ for (let i = 0; i < 8; i++) {
274
+ await page.waitForTimeout(2000);
275
+ label = await currentRangeLabel(page);
276
+ if (label && want.test(label)) break;
277
+ }
278
+ if (label && want.test(label)) break;
187
279
  }
188
- return '';
189
- }).catch(() => '');
190
- if (label && (!want || want.test(label))) break;
280
+ }
191
281
  }
192
282
  // Hai ca KHÁC HẲN nhau, đừng gộp:
193
283
  // 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,7 +298,7 @@ async function readViewsPage(page, range, log) {
208
298
  for (const e of document.querySelectorAll('div,span')) {
209
299
  const t = clean(e.innerText);
210
300
  if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
211
- if (/Lượt xem|lượt xem/.test(t)) { seen.add(t); chunks.push(t); }
301
+ if (/[Ll]ượt xem|[Vv]iews/.test(t)) { seen.add(t); chunks.push(t); }
212
302
  if (chunks.length > 120) break;
213
303
  }
214
304
  const label = [...document.querySelectorAll('div,span,button')]
@@ -217,16 +307,17 @@ async function readViewsPage(page, range, log) {
217
307
  return { chunks, label };
218
308
  }).catch(() => ({ chunks: [], label: '' }));
219
309
 
220
- // Tổng: "216.353 -35% Tổng số lượt xem trong 7 ngày qua" / "… hôm nay"
221
- let total = null; let pct = null;
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
- }
310
+ const t0 = pickTotal(raw.chunks);
311
+ const total = t0.views; const pct = t0.pct;
226
312
  // Dòng bài: "31 Tháng 8 11:41 56.243 Lượt xem"
227
313
  const posts = [];
314
+ const POST_LINE = [
315
+ /(\d{1,2}\s*Tháng\s*\d{1,2}\s+\d{1,2}:\d{2})\s+([\d.,]+)\s*Lượt xem/i,
316
+ /((?:[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,
317
+ ];
228
318
  for (const c of raw.chunks) {
229
- 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);
319
+ let m = null;
320
+ for (const re of POST_LINE) { m = re.exec(c); if (m) break; }
230
321
  if (!m) continue;
231
322
  const at = parseVnPostTime(m[1]);
232
323
  const views = parseExact(m[2]);
@@ -236,6 +327,70 @@ async function readViewsPage(page, range, log) {
236
327
  return { range, label, total, pct, posts };
237
328
  }
238
329
 
330
+ // Đọc ĐÚNG MỘT NGÀY qua khoảng tuỳ chỉnh.
331
+ //
332
+ // Vì sao không dùng date_range=TODAY: Facebook KHÔNG có số của hôm nay. Đo
333
+ // 2026-09-06 lúc 12h trưa trên kênh 1,5 triệu view/7 ngày, trang trả về đúng
334
+ // chữ "--" kèm "Chưa đủ dữ liệu để hiển thị". Chip khoảng thời gian của chính
335
+ // nó cũng kết thúc ở HÔM QUA ("28 ngày qua: 9 Tháng 8 - 5 Tháng 9" trong khi
336
+ // đã là ngày 6) — cửa sổ dữ liệu của Facebook đóng sổ ở hôm qua.
337
+ //
338
+ // Không có preset "Hôm qua" (đã thử YESTERDAY/LAST_1D/PREVIOUS_DAY, cả ba rơi
339
+ // 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
340
+ // mục "Tùy chỉnh" rồi đọc URL nó sinh ra.
341
+ //
342
+ // BẪY: dòng "… Tổng số lượt xem trong 28 ngày qua" ở khoảng tuỳ chỉnh là NHÃN
343
+ // CŨ chưa kịp đổi, nhưng CON SỐ thì đã đúng của ngày đã chọn. Nên phải xác
344
+ // minh bằng CHIP ("Tùy chỉnh: 5 Tháng 9 – 5 Tháng 9"), không phải bằng nhãn đó.
345
+ async function readOneDay(page, iso, log) {
346
+ const url = `${INSIGHTS_BASE}/views/?tab=reels&date_range=CUSTOM&start_date=${iso}&end_date=${iso}`;
347
+ let chip = '';
348
+ for (let attempt = 0; attempt < 3; attempt++) {
349
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
350
+ for (let i = 0; i < 8; i++) {
351
+ await page.waitForTimeout(2000);
352
+ chip = await page.evaluate(() => {
353
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
354
+ for (const e of document.querySelectorAll('div[role="button"],span,div')) {
355
+ const t = clean(e.innerText);
356
+ if (t.length < 70 && /^(Tùy chỉnh:|Custom:)/i.test(t)) return t;
357
+ }
358
+ return '';
359
+ }).catch(() => '');
360
+ if (chip) break;
361
+ }
362
+ if (chip) break;
363
+ log('info', `[insights] ngày ${iso}: lần ${attempt + 1} chưa thấy chip tuỳ chỉnh, nạp lại`);
364
+ }
365
+ if (!chip) return { iso, chip: '', views: null };
366
+ // Chip đổi TRƯỚC, con số đổ về SAU — chờ cố định 3s là hên xui: đo 2026-09-06
367
+ // có kênh chip đúng "Custom: Sep 5 - Sep 5" mà số vẫn null. Chờ tới khi thấy
368
+ // số, tối đa ~14s.
369
+ let views = null;
370
+ for (let i = 0; i < 7; i++) {
371
+ await page.waitForTimeout(2000);
372
+ const cs = await readTotalChunks(page);
373
+ views = pickTotal(cs).views;
374
+ if (views != null) break;
375
+ }
376
+ log('info', `[insights] ngày ${iso}: ${views} lượt xem — chip "${chip}"`);
377
+ return { iso, chip, views };
378
+ }
379
+
380
+ async function readTotalChunks(page) {
381
+ return page.evaluate(() => {
382
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim();
383
+ const seen = new Set(); const out = [];
384
+ for (const e of document.querySelectorAll('div,span')) {
385
+ const t = clean(e.innerText);
386
+ if (!t || t.length > 90 || !/\d/.test(t) || seen.has(t)) continue;
387
+ if (/[Ll]ượt xem/.test(t)) { seen.add(t); out.push(t); }
388
+ if (out.length > 120) break;
389
+ }
390
+ return out;
391
+ }).catch(() => []);
392
+ }
393
+
239
394
  async function readAudiencePage(page, log) {
240
395
  await page.goto(`${INSIGHTS_BASE}/audience/`, { waitUntil: 'domcontentloaded', timeout: 45_000 });
241
396
  await page.waitForTimeout(7000);
@@ -245,16 +400,19 @@ async function readAudiencePage(page, log) {
245
400
  for (const e of document.querySelectorAll('div,span')) {
246
401
  const t = clean(e.innerText);
247
402
  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); }
403
+ if (/người theo dõi|bỏ theo dõi|followers|follows|unfollows/i.test(t)) { seen.add(t); out.push(t); }
249
404
  if (out.length > 60) break;
250
405
  }
251
406
  return out;
252
407
  }).catch(() => []);
253
- const pick = (re) => { for (const c of chunks) { const m = re.exec(c); if (m) return parseExact(m[1]); } return null; };
408
+ const pick = (...res) => {
409
+ for (const re of res) for (const c of chunks) { const m = re.exec(c); if (m) return parseExact(m[1]); }
410
+ return null;
411
+ };
254
412
  const a = {
255
- followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Tổng số người theo dõi/i),
256
- net_followers: pick(/^([\d.]+)\s*(?:[-+][\d.,]+%\s*)?Số người theo dõi thực/i),
257
- unfollows: pick(/([\d.]+)\s*Lượt bỏ theo dõi/i),
413
+ followers: pick(/^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Tổng số người theo dõi/i, /^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Total followers/i),
414
+ net_followers: pick(/^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Số người theo dõi thực/i, /^([\d.,]+)\s*(?:[-+][\d.,]+%\s*)?Net follows/i),
415
+ unfollows: pick(/([\d.,]+)\s*Lượt bỏ theo dõi/i, /([\d.,]+)\s*Unfollows/i),
258
416
  };
259
417
  log('info', `[insights] đối tượng: ${a.followers} follower, thực ${a.net_followers}, bỏ ${a.unfollows}`);
260
418
  return a;
@@ -264,24 +422,27 @@ async function readAudiencePage(page, log) {
264
422
  // 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
423
  // worker không giữ trạng thái ngày hôm qua.
266
424
  async function runInsights({ page, payload, log }) {
267
- const today = await readViewsPage(page, 'TODAY', log);
425
+ // 7 ngày trước để làm ấm trang (lần điều hướng ĐẦU hay bị bỏ qua tham số),
426
+ // rồi mới tới ngày hôm qua.
268
427
  const week = await readViewsPage(page, 'LAST_7D', log);
428
+ const yIso = vnYesterdayIso();
429
+ const yest = await readOneDay(page, yIso, log);
269
430
  const audience = await readAudiencePage(page, log);
270
- if (today.total == null && week.total == null) {
431
+ if (yest.views == null && week.total == null) {
271
432
  throw new Error('không đọc được tổng lượt xem trên trang Insights — FB đổi giao diện?');
272
433
  }
273
434
  if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
274
435
  const d = await log.apiRequest('POST', '/analytics/fb-insights', {
275
436
  channel_id: payload.channel_id,
276
437
  at: new Date().toISOString(),
277
- today: { views: today.total, pct: today.pct, posts: today.posts },
438
+ yesterday: { date: yest.iso, views: yest.views, chip: yest.chip },
278
439
  week: { views: week.total, posts: week.posts },
279
440
  audience,
280
441
  });
281
- 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}`);
442
+ 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
443
  return {
283
444
  ok: true, mode: 'insights',
284
- views_today: today.total, views_7d: week.total,
445
+ views_yesterday: yest.views, views_7d: week.total,
285
446
  posts: week.posts.length, matched: d?.matched ?? null,
286
447
  followers: audience.followers, unattributed: d?.unattributed ?? null,
287
448
  };