Browse Source

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

Ethanfly 14 hours ago
parent
commit
51bdb424a4

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

@@ -15,15 +15,10 @@ 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']
@@ -43,7 +38,6 @@ declare module 'vue' {
     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 +46,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']

+ 50 - 7
client/src/views/Analytics/Work/index.vue

@@ -770,13 +770,20 @@ function formatDurationSeconds(secLike: any): string {
   return `${s}秒`;
 }
 
-/** 平均观看时长:保留 2 位小数,不取整 */
+/** 平均观看时长:保留 2 位小数(非小红书口径) */
 function formatAvgWatchDurationSeconds(secLike: any): string {
   const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
   if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
   return `${s.toFixed(2)}秒`;
 }
 
+/** 小红书平均观看时长:取整(秒) */
+function formatAvgWatchDurationSecondsInt(secLike: any): string {
+  const s = Math.max(0, Math.round(parseFloat(String(secLike ?? '0')) || 0));
+  if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
+  return `${s}秒`;
+}
+
 function formatRate(rateLike: any): string {
   const raw = String(rateLike ?? '0').trim();
   if (!raw) return '0%';
@@ -789,9 +796,18 @@ function formatRate(rateLike: any): string {
 }
 
 function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
+  const publish = dayjs(selectedWork.value?.publishTime);
+
+  // 小红书:趋势固定为「发布后 14 天(含发布日)」,与页面时间范围无关
+  if (selectedWork.value?.platform === 'xiaohongshu' && publish.isValid()) {
+    const start = publish.startOf('day');
+    const end = start.add(13, 'day'); // 发布日 + 13 天 = 共 14 天
+    return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
+  }
+
+  // 其他平台:保持原逻辑(以页面筛选 endDate 为截止的近 14 天,并且不早于发布时间)
   const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
   const start = end.subtract(13, 'day'); // 近14天(含当天)
-  const publish = dayjs(selectedWork.value?.publishTime);
   const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
   return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
 }
@@ -812,7 +828,7 @@ const xhsMetricCards = computed<MetricCardConfig[]>(() => {
     { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 0) },
     { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
     { key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
-    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
+    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSecondsInt(d.avgWatchDuration) },
     { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
     { key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
     { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
@@ -1012,9 +1028,13 @@ function updatePlayTrendChart(stats: Array<any>) {
   const metric = activeTrendMetric.value;
   const seriesName = trendTitle.value.replace('趋势', '');
 
+  const isRateMetric =
+    metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate';
+  const isDurationMetric = metric === 'avgWatchDuration' || metric === 'totalWatchDuration';
+
   const values = sortedStats.map((s) => {
     const v = s?.[metric];
-    if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
+    if (isRateMetric) {
       const raw = String(v ?? '0').trim();
       if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
       const n = Number(raw);
@@ -1022,7 +1042,7 @@ function updatePlayTrendChart(stats: Array<any>) {
       return n <= 1 ? n * 100 : n;
     }
     if (metric === 'avgWatchDuration') {
-      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+      return Math.max(0, Math.round(parseFloat(String(v ?? '0')) || 0));
     }
     if (metric === 'totalWatchDuration') {
       return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
@@ -1036,6 +1056,24 @@ function updatePlayTrendChart(stats: Array<any>) {
       axisPointer: {
         type: 'cross',
       },
+      formatter: (params: any) => {
+        const arr = Array.isArray(params) ? params : [params];
+        if (arr.length === 0) return '';
+        const dateLabel = arr[0]?.axisValueLabel || arr[0]?.axisValue || '';
+        const lines: string[] = [`${dateLabel}`];
+        for (const p of arr) {
+          const name = p?.seriesName ?? '';
+          const val = typeof p?.data === 'number' ? p.data : Number(p?.data ?? 0);
+          if (isRateMetric) {
+            lines.push(`${name}:${(Number.isFinite(val) ? val : 0).toFixed(2).replace(/\.00$/, '')}%`);
+          } else if (isDurationMetric) {
+            lines.push(`${name}:${formatDurationSeconds(Number.isFinite(val) ? Math.round(val) : 0)}`);
+          } else {
+            lines.push(`${name}:${formatNumber(Number.isFinite(val) ? val : 0)}`);
+          }
+        }
+        return lines.join('<br/>');
+      },
     },
     grid: {
       left: '3%',
@@ -1057,9 +1095,14 @@ function updatePlayTrendChart(stats: Array<any>) {
       type: 'value',
       axisLabel: {
         formatter: (value: number) => {
-          if (value >= 10000) {
-            return (value / 10000).toFixed(1) + '万';
+          if (isRateMetric) {
+            const n = Number.isFinite(value) ? value : 0;
+            return `${n.toFixed(2).replace(/\.00$/, '')}%`;
+          }
+          if (isDurationMetric) {
+            return formatDurationSeconds(Number.isFinite(value) ? Math.round(value) : 0);
           }
+          if (value >= 10000) return (value / 10000).toFixed(1) + '万';
           return String(value);
         },
       },

+ 27 - 0
docs/xiaohongshu-account-base-api-field-mapping.md

@@ -0,0 +1,27 @@
+# 小红书账号概览接口 data.thirty 字段映射
+
+接口:`GET https://creator.xiaohongshu.com/api/galaxy/v2/creator/datacenter/account/base`  
+使用返回体中的 **`data.thirty`**(近 30 天)各 `*_list`,按 `date`(毫秒时间戳)合并为「按日一条」后写入 `user_day_statistics`。
+
+**流程**:访问 `https://creator.xiaohongshu.com/statistics/account/v2` 后等待几秒,页面加载时会自动请求 account/base,监听该响应即可,无需点击 账号概览/笔记数据。
+
+## 映射表(供核对)
+
+| user_day_statistics 字段 | data.thirty 来源 | 说明 |
+|--------------------------|------------------|------|
+| record_date | 各 list 项的 `date`(毫秒 → 当天 0 点) | 按日期合并多条 list |
+| play_count | view_list[].count | 播放(阅读)量 |
+| exposure_count | impl_count_list[].count | 曝光量 |
+| comment_count | comment_list[].count | 评论量 |
+| like_count | like_list[].count | 点赞量 |
+| share_count | share_list[].count | 分享量 |
+| collect_count | collect_list[].count | 收藏量 |
+| fans_increase | net_rise_fans_count_list[].count | 净涨粉(可为负) |
+| cover_click_rate | cover_click_rate_list[].count | 封面点击率,格式化为 "14%" 等字符串 |
+| avg_watch_duration | avg_view_time_list[].count | 平均观看时长(秒),格式化为 "12秒" 等 |
+| total_watch_duration | view_time_list[].count | 观看总时长(秒),格式化为 "1866秒" 等 |
+| completion_rate | video_full_view_rate_list[].count | 完播率(%),格式化为 "15%" 等 |
+| works_count | publish_note_num_list[].count | 发布笔记数 |
+| fans_count | **不来自本接口** | 仍由「粉丝数据」页 overall_new 接口写入 |
+
+说明:`date` 为毫秒时间戳,按 UTC 取年月日转成「当天 0 点」作为 `record_date`;多条 list 按同一 `date` 合并成一条日维度记录后写入。

+ 116 - 0
docs/xiaohongshu-work-note-statistics-field-mapping.md

@@ -0,0 +1,116 @@
+# 小红书作品(笔记)每日统计数据导入逻辑与字段映射
+
+## 接口信息
+
+- **接口**:`GET https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id={noteId}`
+- **页面**:`https://creator.xiaohongshu.com/statistics/note-detail?noteId={noteId}`
+- **数据来源**:返回体中的 `data.day`(日维度趋势数据)
+
+## 导入流程
+
+1. **遍历账号下所有作品**:从 `works` 表查询该账号的所有小红书作品
+2. **访问笔记详情页**:打开 `statistics/note-detail?noteId={noteId}`
+3. **监听接口响应**:等待 `note/base` 接口响应(超时 30 秒)
+4. **解析数据**:
+   - 提取 `data.day` 下的各 `*_list` 数组
+   - 按 `date`(毫秒时间戳)合并为「按日一条」的数据
+   - 使用 `getChinaDateFromTimestamp` 将时间戳转成中国时区日期
+5. **过滤时间范围**:默认只保留「作品发布后 14 天内」的数据(`publishTime + 13 天`)
+6. **批量写入**:调用 `WorkDayStatisticsService.saveStatisticsForDateBatch` 写入 `work_day_statistics` 表
+
+## 字段映射表
+
+### 数值型字段(每日增量,累加)
+
+| work_day_statistics 字段 | data.day 来源 | 说明 |
+|--------------------------|---------------|------|
+| record_date | 各 list 项的 `date`(毫秒 → 中国时区当天 0 点) | 按日期合并多条 list |
+| play_count | view_list[].count | 播放(阅读)量 |
+| exposure_count | imp_list[].count | 曝光量/展现量 |
+| like_count | like_list[].count | 点赞量 |
+| comment_count | comment_list[].count | 评论量 |
+| share_count | share_list[].count | 分享量 |
+| collect_count | collect_list[].count | 收藏量 |
+| fans_increase | rise_fans_list[].count | 涨粉数 |
+
+### 比率类字段(百分比字符串,使用 `coun` 字段)
+
+| work_day_statistics 字段 | data.day 来源 | 格式 |
+|--------------------------|---------------|------|
+| cover_click_rate | cover_click_rate_list[].coun | "14%" 或 "0" |
+| two_second_exit_rate | exit_view2s_list[].coun | "5%" 或 "0" |
+| completion_rate | finish_list[].coun | "15%" 或 "0" |
+
+**说明**:比率类字段使用 `item.coun`(不是 `count`),如果值为 0 则存 "0",否则存 `${n}%"`。
+
+### 时长/数值字符串字段(不加 %)
+
+| work_day_statistics 字段 | data.day 来源 | 格式 |
+|--------------------------|---------------|------|
+| avg_watch_duration | view_time_list[].count_with_double 或 count | 保留两位小数的字符串,如 "12.34" |
+
+**说明**:优先使用 `count_with_double`,否则用 `count`,保留两位小数(四舍五入)。
+
+## 数据汇总(同步到 works 表)
+
+除了日统计,还会将 `data` 顶层的汇总指标同步到 `works` 表的 `yesterday*` 字段:
+
+| works 字段 | data 来源 | 说明 |
+|------------|-----------|------|
+| yesterday_play_count | view_count | 总播放数 |
+| yesterday_like_count | like_count | 总点赞数 |
+| yesterday_comment_count | comment_count | 总评论数 |
+| yesterday_share_count | share_count | 总分享数 |
+| yesterday_collect_count | collect_count | 总收藏数 |
+| yesterday_fans_increase | rise_fans_count | 总涨粉数 |
+| yesterday_exposure_count | impl_count | 总曝光数 |
+| yesterday_cover_click_rate | cover_click_rate | 封面点击率(格式化为 "14%") |
+| yesterday_avg_watch_duration | view_time_avg_with_double 或 view_time_avg | 平均观看时长(保留两位小数) |
+| yesterday_completion_rate | full_view_rate | 完播率(格式化为 "15%") |
+| yesterday_two_second_exit_rate | exit_view2s_rate | 2秒退出率(格式化为 "5%") |
+
+## 时区处理
+
+- **接口返回的 `date`**:中国时区(Asia/Shanghai)该日 0 点的 UTC 时间戳
+- **解析方式**:使用 `getChinaDateFromTimestamp(ts)` 函数,通过 `Intl.DateTimeFormat` 按中国时区解析年月日
+- **存储**:`record_date` 字段存为 DATE 类型,代表中国时区的日历日
+
+## 时间范围限制
+
+- **默认**:只保留「作品发布后 14 天内」的数据
+  - 计算方式:`publishTime` 的当天 + 13 天 = 共 14 天
+  - 例如:2026-01-01 发布,则保留 2026-01-01 至 2026-01-14 的数据
+- **首批补数据**:可通过 `ignorePublishTimeLimit: true` 选项跳过此限制
+
+## 错误处理
+
+- **登录失效**:检测到跳转到 `login` 页面时,抛出 `XhsLoginExpiredError`
+  - 首次失效:尝试刷新账号 cookie
+  - 刷新成功:用新 cookie 重试一次
+  - 刷新失败或重试仍失效:标记账号为 `expired` 状态
+- **接口超时**:30 秒内未捕获到响应则跳过该作品,继续处理下一个
+- **数据缺失**:`data.day` 为空或不存在时返回空数组,不写入
+
+## 调用方式
+
+### 定时任务(批量同步所有账号)
+
+```typescript
+await XiaohongshuWorkNoteStatisticsImportService.runDailyImport();
+```
+
+### 单账号同步
+
+```typescript
+const svc = new XiaohongshuWorkNoteStatisticsImportService();
+await svc.importAccountWorksStatistics(account);
+```
+
+### 指定作品(首批补数据)
+
+```typescript
+await svc.importAccountWorksStatistics(account, false, {
+  workIdFilter: [workId1, workId2],
+  ignorePublishTimeLimit: true
+});
+```

+ 1 - 0
server/src/services/TaskQueueService.ts

@@ -258,6 +258,7 @@ class TaskQueueService {
       publish_video: '发布视频',
       batch_reply: '批量回复评论',
       delete_work: '删除作品',
+      xhs_work_stats_backfill: '小红书作品补数',
     };
     return titles[type] || '未知任务';
   }

+ 14 - 14
server/src/services/WorkDayStatisticsService.ts

@@ -1393,13 +1393,10 @@ export class WorkDayStatisticsService {
     const startDateStr = startDate;
     const endDateStr = endDate;
 
-    // 基础查询:当前用户的作品(先不分页,后面在内存中做分页,避免部分数据库在 GROUP BY + JOIN 场景下忽略 skip/take)
+    // 作品列表:指标直接取 works 表 yesterday_* 快照;时间范围仅用于筛选「发布时间」落在范围内的作品
+    // 说明:不再按日期范围聚合 work_day_statistics(避免口径随筛选范围变化)
     const qb = this.workRepository
       .createQueryBuilder('w')
-      .leftJoin(WorkDayStatistics, 'wds', 'wds.work_id = w.id AND wds.record_date >= :wStart AND wds.record_date <= :wEnd', {
-        wStart: startDateStr,
-        wEnd: endDateStr,
-      })
       .innerJoin(PlatformAccount, 'pa', 'pa.id = w.accountId')
       .select('w.id', 'id')
       .addSelect('w.title', 'title')
@@ -1410,12 +1407,17 @@ export class WorkDayStatisticsService {
       .addSelect('pa.avatarUrl', 'accountAvatar')
       .addSelect('w.status', 'workType')
       .addSelect('w.publish_time', 'publishTime')
-      .addSelect('COALESCE(SUM(wds.play_count), 0)', 'viewsCount')
-      .addSelect('COALESCE(SUM(wds.comment_count), 0)', 'commentsCount')
-      .addSelect('COALESCE(SUM(wds.share_count), 0)', 'sharesCount')
-      .addSelect('COALESCE(SUM(wds.collect_count), 0)', 'collectsCount')
-      .addSelect('COALESCE(SUM(wds.like_count), 0)', 'likesCount')
-      .where('w.userId = :userId', { userId });
+      .addSelect('COALESCE(w.yesterday_play_count, 0)', 'viewsCount')
+      .addSelect('COALESCE(w.yesterday_comment_count, 0)', 'commentsCount')
+      .addSelect('COALESCE(w.yesterday_share_count, 0)', 'sharesCount')
+      .addSelect('COALESCE(w.yesterday_collect_count, 0)', 'collectsCount')
+      .addSelect('COALESCE(w.yesterday_like_count, 0)', 'likesCount')
+      .where('w.userId = :userId', { userId })
+      .andWhere('w.publish_time IS NOT NULL')
+      .andWhere('DATE(w.publish_time) >= :startDate AND DATE(w.publish_time) <= :endDate', {
+        startDate: startDateStr,
+        endDate: endDateStr,
+      });
 
     if (platform) {
       qb.andWhere('w.platform = :platform', { platform });
@@ -1428,11 +1430,9 @@ export class WorkDayStatisticsService {
     }
     if (keyword && keyword.trim()) {
       const kw = `%${keyword.trim()}%`;
-      qb.andWhere('(w.title LIKE :kw OR pa.accountName LIKE :kw)', { kw });
+      qb.andWhere('w.title LIKE :kw', { kw });
     }
 
-    qb.groupBy('w.id');
-
     // 排序:统一按发布时间倒序(最新的在前)
     qb.orderBy('w.publish_time', 'DESC');
 

+ 10 - 5
server/src/services/WorkService.ts

@@ -6,7 +6,7 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
-import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
+import { taskQueueService } from './TaskQueueService.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -468,10 +468,15 @@ export class WorkService {
             logger.info(
               `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
             );
-            const svc = new XiaohongshuWorkNoteStatisticsImportService();
-            await svc.importAccountWorksStatistics(account, false, {
-              workIdFilter: needInitIds,
-              ignorePublishTimeLimit: true,
+            // 放入任务队列异步执行,避免阻塞同步作品流程
+            taskQueueService.createTask(account.userId, {
+              type: 'xhs_work_stats_backfill',
+              title: `小红书作品补数(${needInitIds.length})`,
+              accountId: account.id,
+              platform: 'xiaohongshu',
+              data: {
+                workIds: needInitIds,
+              },
             });
           }
         }

+ 148 - 71
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -386,8 +386,16 @@ export class XiaohongshuAccountOverviewImportService {
       }
 
       const page = await context.newPage();
+
+      // account/base 在页面加载时自动请求,先挂监听再访问
+      const accountBasePattern = /\/api\/galaxy\/v2\/creator\/datacenter\/account\/base/i;
+      const responsePromise = page.waitForResponse(
+        (r) => r.url().match(accountBasePattern) != null && r.request().method() === 'GET',
+        { timeout: 30_000 }
+      );
+
       await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' });
-      await page.waitForTimeout(1500);
+      await page.waitForTimeout(3000); // 等几秒,让页面发起 account/base 请求
 
       if (page.url().includes('login')) {
         // 第一次检测到登录失效时,尝试刷新账号
@@ -438,77 +446,10 @@ export class XiaohongshuAccountOverviewImportService {
         throw new Error('小红书数据看板暂无访问权限/申请中,已通知用户');
       }
 
-      // 统一入口:账号概览 -> 笔记数据
-      await page.getByText('账号概览', { exact: true }).first().click().catch(() => undefined);
-      await page.getByText('笔记数据', { exact: true }).first().click();
-
-      const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据' | '发布数据', mode: ExportMode) => {
-        await page.getByText(tabText, { exact: true }).first().click();
-        await page.getByText(/近\d+日/).first().click().catch(() => undefined);
-        await page.getByText('近30日', { exact: true }).click();
-        await page.waitForTimeout(1200);
-
-        const [download] = await Promise.all([
-          page.waitForEvent('download', { timeout: 60_000 }),
-          page.getByText('导出数据', { exact: true }).first().click(),
-        ]);
-
-        const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
-        const filePath = path.join(this.downloadDir, filename);
-        await download.saveAs(filePath);
-
-        let perDay = new Map<string, { recordDate: Date } & Record<string, any>>();
-        let inserted = 0;
-        let updated = 0;
-        try {
-          perDay = parseXhsExcel(filePath, mode);
-          for (const v of perDay.values()) {
-            const { recordDate, ...patch } = v;
-
-            // 修正:如果导入的数据是今天的,且没有粉丝总数(Excel只有涨粉数),则使用账号当前的粉丝数
-            // 避免因为导入导致今天的粉丝数被重置为 0
-            const today = new Date();
-            today.setHours(0, 0, 0, 0);
-
-            // 比较时间戳
-            if (recordDate.getTime() === today.getTime()) {
-              if ((patch as any).fansCount === undefined && account.fansCount !== undefined && account.fansCount > 0) {
-                (patch as any).fansCount = account.fansCount;
-                logger.info(`[XHS Import] Injected current fansCount=${account.fansCount} for today's record (accountId=${account.id})`);
-              }
-            }
+      // 直接监听 account/base,无需点击 账号概览/笔记数据
+      await this.importFromAccountBaseApi(responsePromise, page, account);
 
-            const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
-            inserted += r.inserted;
-            updated += r.updated;
-          }
-
-          logger.info(
-            `[XHS Import] ${tabText} imported. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
-          );
-        } finally {
-          // 默认导入后删除 Excel,避免磁盘堆积;仅在显式 KEEP_XHS_XLSX=true 时保留(用于调试)
-          if (process.env.KEEP_XHS_XLSX === 'true') {
-            logger.warn(`[XHS Import] KEEP_XHS_XLSX=true, keep file: ${filePath}`);
-          } else {
-            await fs.unlink(filePath).catch(() => undefined);
-          }
-        }
-      };
-
-      // 1) 观看数据:播放数 + 点击率/时长/完播率
-      await exportAndImport('观看数据', 'watch');
-
-      // 2) 互动数据:点赞/评论/收藏/分享
-      await exportAndImport('互动数据', 'interaction');
-
-      // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
-      await exportAndImport('涨粉数据', 'fans');
-
-      // 4) 发布数据:近30日导出,解析「总发布趋势」→ user_day_statistics.works_count
-      await exportAndImport('发布数据', 'publish');
-
-      // 5) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
+      // 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
       await this.importFansDataTrendFromPage(context, page, account);
 
       logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`);
@@ -522,6 +463,142 @@ export class XiaohongshuAccountOverviewImportService {
   }
 
   /**
+   * 等待 account/base 接口响应,解析 data.thirty 各 *_list 按 date 合并为按日数据并写入 user_day_statistics
+   * 字段映射:view_list→playCount, impl_count_list→exposureCount, comment_list→commentCount,
+   * like_list→likeCount, share_list→shareCount, collect_list→collectCount,
+   * net_rise_fans_count_list→fansIncrease, cover_click_rate_list→coverClickRate(格式化为"14%"),
+   * avg_view_time_list→avgWatchDuration("12秒"), view_time_list→totalWatchDuration("1866秒"),
+   * video_full_view_rate_list→completionRate("15%"), publish_note_num_list→worksCount
+   */
+  private async importFromAccountBaseApi(
+    responsePromise: Promise<import('playwright').Response>,
+    _page: Page,
+    account: PlatformAccount
+  ): Promise<void> {
+    let res: import('playwright').Response;
+    try {
+      res = await responsePromise;
+    } catch {
+      logger.warn(`[XHS Import] account/base response not captured, skip. accountId=${account.id}`);
+      return;
+    }
+
+    const body = await res.json().catch(() => null);
+    if (!body || typeof body !== 'object') {
+      logger.warn(`[XHS Import] account/base not valid JSON. accountId=${account.id}`);
+      return;
+    }
+
+    const data = (body as Record<string, unknown>).data as Record<string, unknown> | undefined;
+    const thirty = data?.thirty as Record<string, unknown> | undefined;
+    if (!thirty || typeof thirty !== 'object') {
+      logger.warn(`[XHS Import] account/base data.thirty missing. accountId=${account.id}`);
+      return;
+    }
+
+    const perDay = this.parseAccountBaseThirty(thirty);
+    if (perDay.size === 0) {
+      logger.info(`[XHS Import] account/base no days parsed. accountId=${account.id}`);
+      return;
+    }
+
+    let inserted = 0;
+    let updated = 0;
+    const today = new Date();
+    today.setHours(0, 0, 0, 0);
+
+    for (const v of perDay.values()) {
+      const { recordDate, ...patch } = v;
+      if (recordDate.getTime() === today.getTime() && patch.fansCount === undefined && account.fansCount != null && account.fansCount > 0) {
+        (patch as Record<string, unknown>).fansCount = account.fansCount;
+      }
+      const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
+      inserted += r.inserted;
+      updated += r.updated;
+    }
+
+    logger.info(
+      `[XHS Import] account/base imported. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
+    );
+  }
+
+  /**
+   * 解析 data.thirty:各 *_list 每项 { date: 毫秒, count[, count_with_double] },按 date 合并为按日一条
+   * 注意:接口返回的 date 是「中国时区(Asia/Shanghai)该日 0 点」的 UTC 时间戳,需按中国时区解析日期
+   */
+  private parseAccountBaseThirty(thirty: Record<string, unknown>): Map<string, { recordDate: Date } & Record<string, unknown>> {
+    const map = new Map<string, { recordDate: Date } & Record<string, unknown>>();
+
+    // 使用 Intl.DateTimeFormat 获取中国时区的年月日
+    const cstFormatter = new Intl.DateTimeFormat('en-CA', {
+      timeZone: 'Asia/Shanghai',
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    });
+
+    const toKey = (ms: number): string => {
+      // 将 UTC 时间戳转成中国时区的日期字符串 YYYY-MM-DD
+      return cstFormatter.format(new Date(ms));
+    };
+
+    const toRecordDate = (ms: number): Date => {
+      // 获取中国时区的年月日
+      const parts = cstFormatter.formatToParts(new Date(ms));
+      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; // month 是 1-12,Date 构造函数需要 0-11
+      const d = parseInt(get('day'), 10);
+      // 构造本地时区的该日 0 点(如果服务器在中国时区,就是中国时区的 0 点)
+      return new Date(y, m, d, 0, 0, 0, 0);
+    };
+
+    const setFromList = (
+      listKey: string,
+      field: string,
+      formatter?: (n: number) => string | number
+    ) => {
+      const arr = thirty[listKey];
+      if (!Array.isArray(arr)) return;
+      for (const item of arr) {
+        if (!item || typeof item !== 'object') continue;
+        const o = item as Record<string, unknown>;
+        const dateMs = o.date;
+        const countRaw = o.count;
+        if (dateMs == null || countRaw == null) continue;
+        const ts = typeof dateMs === 'number' ? dateMs : Number(dateMs);
+        if (!Number.isFinite(ts)) continue;
+        const key = toKey(ts);
+        if (!map.has(key)) {
+          map.set(key, { recordDate: toRecordDate(ts) });
+        } else {
+          (map.get(key)!.recordDate as Date) = toRecordDate(ts);
+        }
+        const rec = map.get(key)!;
+        const n = typeof countRaw === 'number' ? countRaw : Number(countRaw);
+        if (!Number.isFinite(n)) continue;
+        const val = formatter ? formatter(n) : n;
+        (rec as Record<string, unknown>)[field] = val;
+      }
+    };
+
+    setFromList('view_list', 'playCount');
+    setFromList('impl_count_list', 'exposureCount');
+    setFromList('comment_list', 'commentCount');
+    setFromList('like_list', 'likeCount');
+    setFromList('share_list', 'shareCount');
+    setFromList('collect_list', 'collectCount');
+    setFromList('net_rise_fans_count_list', 'fansIncrease');
+    setFromList('cover_click_rate_list', 'coverClickRate', (n) => `${Math.round(n)}%`);
+    setFromList('avg_view_time_list', 'avgWatchDuration', (n) => `${Math.round(n)}秒`);
+    setFromList('view_time_list', 'totalWatchDuration', (n) => `${Math.round(n)}秒`);
+    setFromList('video_full_view_rate_list', 'completionRate', (n) => `${typeof n === 'number' ? Math.round(n) : n}%`);
+    setFromList('publish_note_num_list', 'worksCount');
+
+    return map;
+  }
+
+  /**
    * 粉丝数据页:打开粉丝数据、点击「粉丝数据概览」近30天,监听 overall_new 接口响应,解析每日粉丝总数并写入 user_day_statistics.fans_count
    */
   private async importFansDataTrendFromPage(

+ 58 - 3
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -241,11 +241,17 @@ export class XiaohongshuWorkNoteStatisticsImportService {
    * 按账号同步作品日统计
    * @param options.workIdFilter 仅处理指定 workId(用于「同步作品后为新作品补首批日统计」)
    * @param options.ignorePublishTimeLimit 是否忽略「发布日期+14天」限制(用于首批补数据)
+   * @param options.ignorePublishAgeLimit 是否忽略「发布超过 1 年跳过」限制(用于首次全量补数)
    */
   async importAccountWorksStatistics(
     account: PlatformAccount,
     isRetry = false,
-    options?: { workIdFilter?: number[]; ignorePublishTimeLimit?: boolean }
+    options?: {
+      workIdFilter?: number[];
+      ignorePublishTimeLimit?: boolean;
+      ignorePublishAgeLimit?: boolean;
+      onProgress?: (payload: { index: number; total: number; work: Work }) => void;
+    }
   ): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
@@ -261,13 +267,57 @@ export class XiaohongshuWorkNoteStatisticsImportService {
       where.id = In(options.workIdFilter);
     }
 
-    const works = await this.workRepository.find({ where });
+    let works = await this.workRepository.find({ where });
 
     if (!works.length) {
       logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
       return;
     }
 
+    // 用户更关心最近发布的作品:按发布时间从近到远处理;无发布时间的排最后
+    works.sort((a, b) => {
+      const ta = a.publishTime ? new Date(a.publishTime).getTime() : -Infinity;
+      const tb = b.publishTime ? new Date(b.publishTime).getTime() : -Infinity;
+      if (tb !== ta) return tb - ta;
+      return (b.id || 0) - (a.id || 0);
+    });
+
+    // 定时任务优化:发布超过 1 年的作品,跳过 note/base 拉取(不更新 works.yesterday_*,也不写 work_day_statistics)
+    // 仅在首次同步作品的“全量补数”场景(ignorePublishAgeLimit=true)放开此限制
+    if (!options?.ignorePublishAgeLimit) {
+      const now = new Date();
+      const formatter = new Intl.DateTimeFormat('en-CA', {
+        timeZone: 'Asia/Shanghai',
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+      });
+      const parts = formatter.formatToParts(now);
+      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);
+      const todayChina = new Date(y, m, d, 0, 0, 0, 0);
+      const cutoff = new Date(todayChina);
+      cutoff.setFullYear(cutoff.getFullYear() - 1);
+
+      const before = works.length;
+      works = works.filter((w) => {
+        if (!w.publishTime) return true; // 没有发布时间:保守起见不跳过
+        const pub = new Date(w.publishTime);
+        pub.setHours(0, 0, 0, 0);
+        return pub.getTime() >= cutoff.getTime();
+      });
+      const skipped = before - works.length;
+      if (skipped > 0) {
+        logger.info(`[XHS WorkStats] accountId=${account.id} skip old works (>1y). skipped=${skipped} remain=${works.length}`);
+      }
+      if (!works.length) {
+        logger.info(`[XHS WorkStats] accountId=${account.id} all works are older than 1y, skip.`);
+        return;
+      }
+    }
+
     const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
     let context: BrowserContext | null = null;
     let closedDueToLoginExpired = false;
@@ -287,7 +337,12 @@ export class XiaohongshuWorkNoteStatisticsImportService {
       let totalInserted = 0;
       let totalUpdated = 0;
 
-      for (const work of works) {
+      const total = works.length;
+      for (let i = 0; i < works.length; i++) {
+        const work = works[i];
+        if (options?.onProgress) {
+          options.onProgress({ index: i + 1, total, work });
+        }
         const noteId = (work.platformVideoId || '').trim();
         if (!noteId) continue;
 

+ 52 - 0
server/src/services/taskExecutors.ts

@@ -8,6 +8,8 @@ import { CommentService } from './CommentService.js';
 import { WorkService } from './WorkService.js';
 import { AccountService } from './AccountService.js';
 import { PublishService } from './PublishService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
+import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
 // 创建服务实例
@@ -15,6 +17,7 @@ const commentService = new CommentService();
 const workService = new WorkService();
 const accountService = new AccountService();
 const publishService = new PublishService();
+const xhsWorkStatsService = new XiaohongshuWorkNoteStatisticsImportService();
 
 type ProgressUpdater = (update: Partial<TaskProgressUpdate>) => void;
 
@@ -211,6 +214,54 @@ async function deleteWorkExecutor(task: Task, updateProgress: ProgressUpdater):
 }
 
 /**
+ * 小红书作品首批日统计/快照补数任务执行器(不阻塞同步作品)
+ * 任务 data: { workIds: number[] }
+ */
+async function xhsWorkStatsBackfillExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 5, currentStep: '准备补数任务...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) throw new Error('缺少用户ID');
+  if (!task.accountId) throw new Error('缺少账号ID');
+
+  const workIdsRaw = (task as Task & { workIds?: unknown }).workIds;
+  const workIds = Array.isArray(workIdsRaw) ? workIdsRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) : [];
+  if (!workIds.length) throw new Error('缺少 workIds');
+
+  // 仅允许当前用户自己的账号
+  const account = await AppDataSource.getRepository(PlatformAccount).findOne({
+    where: { id: task.accountId, userId, platform: 'xiaohongshu' as any },
+  });
+  if (!account) throw new Error('未找到账号或无权限');
+
+  const total = workIds.length;
+  updateProgress({ progress: 15, currentStep: `开始补数(作品数:${total})...`, totalSteps: total });
+
+  await xhsWorkStatsService.importAccountWorksStatistics(account, false, {
+    workIdFilter: workIds,
+    ignorePublishTimeLimit: true,
+    ignorePublishAgeLimit: true,
+    onProgress: ({ index, total, work }) => {
+      const pct = Math.min(99, Math.max(15, Math.round(15 + (index / Math.max(1, total)) * 84)));
+      updateProgress({
+        progress: pct,
+        currentStepIndex: index,
+        totalSteps: total,
+        currentStep: `第 ${index}/${total} 个作品:${(work.title || '').trim() || `workId=${work.id}`}`,
+      });
+    },
+  });
+
+  updateProgress({ progress: 100, currentStep: '补数完成' });
+
+  return {
+    success: true,
+    message: `补数完成,作品数:${workIds.length}`,
+    data: { workIdsCount: workIds.length },
+  };
+}
+
+/**
  * 注册所有任务执行器
  */
 export function registerTaskExecutors(): void {
@@ -219,6 +270,7 @@ export function registerTaskExecutors(): void {
   taskQueueService.registerExecutor('sync_account', syncAccountExecutor);
   taskQueueService.registerExecutor('publish_video', publishVideoExecutor);
   taskQueueService.registerExecutor('delete_work', deleteWorkExecutor);
+  taskQueueService.registerExecutor('xhs_work_stats_backfill', xhsWorkStatsBackfillExecutor);
   
   logger.info('All task executors registered');
 }

+ 7 - 1
shared/src/types/task.ts

@@ -9,7 +9,8 @@ export type TaskType =
   | 'sync_account'       // 同步账号信息
   | 'publish_video'      // 发布视频
   | 'batch_reply'        // 批量回复评论
-  | 'delete_work';       // 删除平台作品
+  | 'delete_work'        // 删除平台作品
+  | 'xhs_work_stats_backfill'; // 小红书作品首批日统计/快照补数(后台执行)
 
 // 任务状态
 export type TaskStatus = 
@@ -89,6 +90,11 @@ export const TASK_TYPE_CONFIG: Record<TaskType, {
     icon: 'Delete',
     color: '#f56c6c',
   },
+  xhs_work_stats_backfill: {
+    name: '小红书作品补数',
+    icon: 'DataLine',
+    color: '#9b59b6',
+  },
 };
 
 // WebSocket 任务事件类型