Ethanfly 17 時間 前
コミット
f05ce0f78e
1 ファイル変更309 行追加2 行削除
  1. 309 2
      server/src/services/WeixinVideoDataCenterImportService.ts

+ 309 - 2
server/src/services/WeixinVideoDataCenterImportService.ts

@@ -359,6 +359,168 @@ async function parseWeixinVideoFile(filePath: string): Promise<Map<string, { rec
   return result;
 }
 
+/**
+ * 获取中国时区(Asia/Shanghai)当天 0 点的 Date
+ */
+function getTodayInChina(): Date {
+  const formatter = new Intl.DateTimeFormat('en-CA', {
+    timeZone: 'Asia/Shanghai',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  });
+  const parts = formatter.formatToParts(new Date());
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+  const y = parseInt(get('year'), 10);
+  const m = parseInt(get('month'), 10) - 1;
+  const d = parseInt(get('day'), 10);
+  return new Date(y, m, d, 0, 0, 0, 0);
+}
+
+/**
+ * 生成「最近 N 天」的日期数组(从最早到昨天),按中国时区对齐
+ */
+function getRecentChinaDates(days: number): Date[] {
+  const today = getTodayInChina();
+  // 微信「近30天」的统计通常只包含「昨天往前」的完整数据,这里将结束日期固定为昨天
+  const end = new Date(today);
+  end.setDate(end.getDate() - 1);
+  const dates: Date[] = [];
+  const start = new Date(end);
+  start.setDate(end.getDate() - (days - 1));
+  for (let i = 0; i < days; i++) {
+    const d = new Date(start);
+    d.setDate(start.getDate() + i);
+    d.setHours(0, 0, 0, 0);
+    dates.push(d);
+  }
+  return dates;
+}
+
+/**
+ * 将 new_post_total_data 接口返回的 totalData 映射到 mergedDays(按日聚合)
+ * 说明:totalData.* 数组顺序为「由远到近」,长度通常为近 30 天
+ */
+function applyFollowerTotalDataToMergedDays(
+  totalData: Record<string, unknown>,
+  mergedDays: Map<string, { recordDate: Date } & Record<string, any>>
+): void {
+  const browse = Array.isArray(totalData.browse) ? totalData.browse : [];
+  const like = Array.isArray(totalData.like) ? totalData.like : [];
+  const comment = Array.isArray(totalData.comment) ? totalData.comment : [];
+  const forward = Array.isArray(totalData.forward) ? totalData.forward : [];
+  const fav = Array.isArray(totalData.fav) ? totalData.fav : [];
+  const follow = Array.isArray(totalData.follow) ? totalData.follow : [];
+
+  const maxLen = Math.max(
+    browse.length,
+    like.length,
+    comment.length,
+    forward.length,
+    fav.length,
+    follow.length
+  );
+  if (maxLen === 0) return;
+
+  const dates = getRecentChinaDates(maxLen);
+
+  const parseVal = (arr: unknown[], idx: number): number | null => {
+    if (idx >= arr.length) return null;
+    const n = parseChineseNumberLike(arr[idx]);
+    return typeof n === 'number' ? n : null;
+  };
+
+  for (let i = 0; i < maxLen; i++) {
+    const d = dates[i]!;
+    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
+      d.getDate()
+    ).padStart(2, '0')}`;
+    if (!mergedDays.has(key)) {
+      mergedDays.set(key, { recordDate: d });
+    } else {
+      mergedDays.get(key)!.recordDate = d;
+    }
+    const obj = mergedDays.get(key)! as Record<string, unknown>;
+
+    const browseVal = parseVal(browse, i);
+    if (browseVal !== null) {
+      // 将浏览量视为播放(不再写 exposureCount)
+      obj.playCount = browseVal;
+    }
+    // like:推荐量,映射为 recommendCount
+    const likeVal = parseVal(like, i);
+    if (likeVal !== null) obj.recommendCount = likeVal;
+
+    const commentVal = parseVal(comment, i);
+    if (commentVal !== null) obj.commentCount = commentVal;
+
+    const forwardVal = parseVal(forward, i);
+    if (forwardVal !== null) obj.shareCount = forwardVal;
+
+    // fav:点赞数,映射为 likeCount
+    const favVal = parseVal(fav, i);
+    if (favVal !== null) obj.likeCount = favVal;
+
+    const followVal = parseVal(follow, i);
+    if (followVal !== null) obj.followCount = followVal;
+  }
+}
+
+/**
+ * 将 fans_trend 接口返回的数据映射到 mergedDays(按日聚合)
+ * 说明:add / reduce / netAdd / total 数组顺序同样是「由远到近」
+ */
+function applyFansTrendToMergedDays(
+  trendData: Record<string, unknown>,
+  mergedDays: Map<string, { recordDate: Date } & Record<string, any>>
+): void {
+  const addArr = Array.isArray(trendData.add) ? (trendData.add as unknown[]) : [];
+  const reduceArr = Array.isArray(trendData.reduce) ? (trendData.reduce as unknown[]) : [];
+  const netAddArr = Array.isArray(trendData.netAdd) ? (trendData.netAdd as unknown[]) : [];
+  const totalArr = Array.isArray(trendData.total) ? (trendData.total as unknown[]) : [];
+
+  const maxLen = Math.max(addArr.length, reduceArr.length, netAddArr.length, totalArr.length);
+  if (maxLen === 0) return;
+
+  const dates = getRecentChinaDates(maxLen);
+
+  const parseVal = (arr: unknown[], idx: number): number | null => {
+    if (idx >= arr.length) return null;
+    const n = parseChineseNumberLike(arr[idx]);
+    return typeof n === 'number' ? n : null;
+  };
+
+  for (let i = 0; i < maxLen; i++) {
+    const d = dates[i]!;
+    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
+      d.getDate()
+    ).padStart(2, '0')}`;
+    if (!mergedDays.has(key)) {
+      mergedDays.set(key, { recordDate: d });
+    } else {
+      mergedDays.get(key)!.recordDate = d;
+    }
+    const obj = mergedDays.get(key)! as Record<string, unknown>;
+
+    const addVal = parseVal(addArr, i);
+    const reduceVal = parseVal(reduceArr, i);
+    let netAddVal = parseVal(netAddArr, i);
+    const totalVal = parseVal(totalArr, i);
+
+    // 如果 netAdd 缺失,则用 add - reduce 兜底
+    if (netAddVal === null && addVal !== null && reduceVal !== null) {
+      netAddVal = addVal - reduceVal;
+    }
+
+    if (netAddVal !== null) {
+      obj.fansIncrease = netAddVal;
+    }
+    if (totalVal !== null) {
+      obj.fansCount = totalVal;
+    }
+  }
+}
+
 export class WeixinVideoDataCenterImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private userDayStatisticsService = new UserDayStatisticsService();
@@ -507,7 +669,7 @@ export class WeixinVideoDataCenterImportService {
       await page.getByText('数据中心', { exact: false }).first().click();
       await page.waitForTimeout(800);
 
-      // 关注者数据 + 视频数据,均通过 Excel 下载
+      // 「关注者数据」和「视频数据」分别进入各自的页面,各自监听接口并各自兜底下载 Excel
       const sections: WxSection[] = ['关注者数据', '视频数据'];
       let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
 
@@ -542,6 +704,41 @@ export class WeixinVideoDataCenterImportService {
         await page.waitForTimeout(800);
 
         // 日期范围:点击「近30天」
+        // 注意:接口监听需要根据当前 tab 区分:
+        // - 关注者数据:监听 fans_trend(粉丝新增/净增/总数)
+        // - 视频数据:监听 new_post_total_data(播放/推荐/点赞/评论/分享/收藏等)
+        let followerApiResponsePromise: Promise<import('playwright').Response | null> | null = null;
+        let fansTrendResponsePromise: Promise<import('playwright').Response | null> | null = null;
+        const followerApiPattern =
+          /\/micro\/statistic\/cgi-bin\/mmfinderassistant-bin\/statistic\/new_post_total_data/i;
+        const fansTrendPattern =
+          /\/micro\/statistic\/cgi-bin\/mmfinderassistant-bin\/statistic\/fans_trend/i;
+        if (section === '关注者数据') {
+          // 关注者数据:监听 fans_trend,且限定 pageUrl=follower
+          fansTrendResponsePromise = page
+            .waitForResponse(
+              (res) => {
+                const url = res.url();
+                return fansTrendPattern.test(url) && url.includes('%2Fmicro%2Fstatistic%2Ffollower');
+              },
+              { timeout: 15000 }
+            )
+            .catch(() => null);
+        } else if (section === '视频数据') {
+          // 视频数据:监听 new_post_total_data,且限定 pageUrl=post
+          followerApiResponsePromise = page
+            .waitForResponse(
+              (res) => {
+                const url = res.url();
+                return (
+                  followerApiPattern.test(url) &&
+                  url.includes('%2Fmicro%2Fstatistic%2Fpost')
+                );
+              },
+              { timeout: 15000 }
+            )
+            .catch(() => null);
+        }
         try {
           if (section === '关注者数据') {
             const loc = page.locator(
@@ -569,7 +766,117 @@ export class WeixinVideoDataCenterImportService {
         }
         await page.waitForTimeout(4000);
 
-        // 下载表格
+        // 关注者数据:优先使用 fans_trend 接口,不再依赖 Excel
+        if (section === '关注者数据') {
+          let applied = false;
+
+          // 处理 fans_trend(粉丝新增/净增/总数)
+          let resTrend: import('playwright').Response | null = null;
+          if (fansTrendResponsePromise) {
+            resTrend = await fansTrendResponsePromise;
+          }
+          if (!resTrend) {
+            try {
+              resTrend = await page.waitForResponse(
+                (r) => {
+                  const url = r.url();
+                  return (
+                    fansTrendPattern.test(url) &&
+                    url.includes('%2Fmicro%2Fstatistic%2Ffollower')
+                  );
+                },
+                { timeout: 10000 }
+              );
+            } catch {
+              logger.warn(
+                `[WX Import] No fans_trend response captured. accountId=${account.id}`
+              );
+            }
+          }
+
+          if (resTrend) {
+            const body = (await resTrend.json().catch(() => null)) as
+              | { errCode?: unknown; data?: Record<string, unknown> }
+              | null;
+            if (body && typeof body === 'object' && Number(body.errCode) === 0) {
+              const data = body.data;
+              if (data && typeof data === 'object') {
+                applyFansTrendToMergedDays(data, mergedDays);
+                applied = true;
+              }
+            }
+            if (!applied) {
+              logger.warn(
+                `[WX Import] fans_trend JSON invalid or missing data. accountId=${account.id}`
+              );
+            }
+          }
+
+          if (applied) {
+            logger.info(
+              `[WX Import] Follower data parsed via fans_trend API. accountId=${account.id} days=${mergedDays.size}`
+            );
+            return;
+          }
+
+          // 如果两个接口都没抓到或解析失败,则继续走 Excel 导出逻辑兜底
+        } else if (section === '视频数据') {
+          // 视频数据:优先使用 new_post_total_data 接口,不再依赖 Excel
+          let applied = false;
+
+          let resFollower: import('playwright').Response | null = null;
+          if (followerApiResponsePromise) {
+            resFollower = await followerApiResponsePromise;
+          }
+          if (!resFollower) {
+            // 兜底再监听一次,防止首次监听时机错过
+            try {
+              resFollower = await page.waitForResponse(
+                (r) => {
+                  const url = r.url();
+                  return (
+                    followerApiPattern.test(url) &&
+                    url.includes('%2Fmicro%2Fstatistic%2Fpost')
+                  );
+                },
+                { timeout: 10000 }
+              );
+            } catch {
+              logger.warn(
+                `[WX Import] No new_post_total_data response captured (video). accountId=${account.id}`
+              );
+            }
+          }
+
+          if (resFollower) {
+            const body = (await resFollower.json().catch(() => null)) as
+              | { errCode?: unknown; data?: { totalData?: Record<string, unknown> } }
+              | null;
+            if (body && typeof body === 'object' && Number(body.errCode) === 0) {
+              const total = body.data?.totalData;
+              if (total && typeof total === 'object') {
+                applyFollowerTotalDataToMergedDays(total, mergedDays);
+                applied = true;
+              }
+            }
+            if (!applied) {
+              logger.warn(
+                `[WX Import] new_post_total_data JSON invalid or missing totalData (video). accountId=${account.id}`
+              );
+            }
+          }
+
+          if (applied) {
+            logger.info(
+              `[WX Import] Video data parsed via new_post_total_data API. accountId=${account.id} days=${mergedDays.size}`
+            );
+            return;
+          }
+
+          // 如果接口没抓到或解析失败,则继续走 Excel 兜底
+        }
+
+        // 默认:通过下载表格解析
         const [download] = await Promise.all([
           page.waitForEvent('download', { timeout: 60_000 }),
           tryClick(['下载表格', '下载', '导出数据']),