Просмотр исходного кода

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 18 часов назад
Родитель
Сommit
3fd291baa9

+ 0 - 11
client/src/components.d.ts

@@ -15,18 +15,11 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
-    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
-    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
-    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
-    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
-    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -39,11 +32,9 @@ declare module 'vue' {
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
-    ElPagination: typeof import('element-plus/es')['ElPagination']
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -52,8 +43,6 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
-    ElText: typeof import('element-plus/es')['ElText']
-    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 5 - 2
server/src/config/index.ts

@@ -2,10 +2,13 @@ import dotenv from 'dotenv';
 import path from 'path';
 import { fileURLToPath } from 'url';
 
-dotenv.config();
-
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+// 显式从 server/.env 加载配置,避免在不同工作目录下找不到正确的 env
+dotenv.config({
+  path: path.resolve(__dirname, '../../.env'),
+});
+
 export const config = {
   env: process.env.NODE_ENV || 'development',
   version: process.env.npm_package_version || '1.0.0',

+ 19 - 0
server/src/scripts/print-python-service-url.ts

@@ -0,0 +1,19 @@
+import { AppDataSource, SystemConfig, initDatabase } from '../models/index.js';
+import { getPythonServiceBaseUrl } from '../services/PythonServiceConfigService.js';
+
+async function main() {
+  await initDatabase();
+
+  const url = await getPythonServiceBaseUrl();
+  console.log('Effective Python service base URL:', url);
+
+  const repo = AppDataSource.getRepository(SystemConfig);
+  const row = await repo.findOne({ where: { configKey: 'python_publish_service_url' } });
+  console.log('system_config.python_publish_service_url row:', row);
+}
+
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
+});
+

+ 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(['下载表格', '下载', '导出数据']),

+ 3 - 2
server/tsconfig.json

@@ -5,8 +5,9 @@
     "rootDir": "./src",
     "lib": ["ES2022"],
     "types": ["node"],
-    "emitDecoratorMetadata": true,
-    "experimentalDecorators": true
+    "emitDecoratorMetadata": false,
+    "experimentalDecorators": true,
+    "noEmitOnError": false
   },
   "include": ["src/**/*"],
   "exclude": ["node_modules", "dist"]