Ethanfly 14 hours ago
parent
commit
d4f3aa4042

+ 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
+});
+```

+ 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');