|
@@ -0,0 +1,483 @@
|
|
|
|
|
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
|
|
|
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
|
|
|
|
|
+import { logger } from '../utils/logger.js';
|
|
|
|
|
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
|
|
|
|
|
+import { AccountService } from './AccountService.js';
|
|
|
|
|
+import type { ProxyConfig } from '@media-manager/shared';
|
|
|
|
|
+import { BrowserManager } from '../automation/browser.js';
|
|
|
|
|
+
|
|
|
|
|
+/** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
|
|
|
|
|
+export class XhsLoginExpiredError extends Error {
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ super('XHS_LOGIN_EXPIRED');
|
|
|
|
|
+ this.name = 'XhsLoginExpiredError';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type PlaywrightCookie = {
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ value: string;
|
|
|
|
|
+ domain?: string;
|
|
|
|
|
+ path?: string;
|
|
|
|
|
+ url?: string;
|
|
|
|
|
+ expires?: number;
|
|
|
|
|
+ httpOnly?: boolean;
|
|
|
|
|
+ secure?: boolean;
|
|
|
|
|
+ sameSite?: 'Lax' | 'None' | 'Strict';
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+interface NoteTrendItem {
|
|
|
|
|
+ date?: number | string;
|
|
|
|
|
+ count?: number | string;
|
|
|
|
|
+ count_with_double?: number | string;
|
|
|
|
|
+ /** API 返回的比率数值字段(用于 cover_click_rate、two_second_exit_rate、completion_rate 等,存时加 "%") */
|
|
|
|
|
+ coun?: number | string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NoteDaySection {
|
|
|
|
|
+ view_list?: NoteTrendItem[];
|
|
|
|
|
+ like_list?: NoteTrendItem[];
|
|
|
|
|
+ comment_list?: NoteTrendItem[];
|
|
|
|
|
+ share_list?: NoteTrendItem[];
|
|
|
|
|
+ collect_list?: NoteTrendItem[];
|
|
|
|
|
+ rise_fans_list?: NoteTrendItem[];
|
|
|
|
|
+ imp_list?: NoteTrendItem[];
|
|
|
|
|
+ cover_click_rate_list?: NoteTrendItem[];
|
|
|
|
|
+ exit_view2s_list?: NoteTrendItem[];
|
|
|
|
|
+ view_time_list?: NoteTrendItem[];
|
|
|
|
|
+ finish_list?: NoteTrendItem[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NoteBaseData {
|
|
|
|
|
+ day?: NoteDaySection;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface DailyWorkStatPatch {
|
|
|
|
|
+ workId: number;
|
|
|
|
|
+ recordDate: Date;
|
|
|
|
|
+ playCount?: number;
|
|
|
|
|
+ exposureCount?: number;
|
|
|
|
|
+ likeCount?: number;
|
|
|
|
|
+ commentCount?: number;
|
|
|
|
|
+ shareCount?: number;
|
|
|
|
|
+ collectCount?: number;
|
|
|
|
|
+ fansIncrease?: number;
|
|
|
|
|
+ coverClickRate?: string;
|
|
|
|
|
+ avgWatchDuration?: string;
|
|
|
|
|
+ totalWatchDuration?: string;
|
|
|
|
|
+ completionRate?: string;
|
|
|
|
|
+ twoSecondExitRate?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
|
|
|
|
|
+ if (!cookieData) return [];
|
|
|
|
|
+ const raw = cookieData.trim();
|
|
|
|
|
+ if (!raw) return [];
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(raw) as PlaywrightCookie[];
|
|
|
|
|
+ if (Array.isArray(parsed)) {
|
|
|
|
|
+ return parsed.map((c) => ({
|
|
|
|
|
+ ...c,
|
|
|
|
|
+ url: c.url || 'https://creator.xiaohongshu.com',
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // fallthrough
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
|
|
|
|
|
+ const cookies: PlaywrightCookie[] = [];
|
|
|
|
|
+ for (const p of pairs) {
|
|
|
|
|
+ const idx = p.indexOf('=');
|
|
|
|
|
+ if (idx <= 0) continue;
|
|
|
|
|
+ const name = p.slice(0, idx).trim();
|
|
|
|
|
+ const value = p.slice(idx + 1).trim();
|
|
|
|
|
+ if (!name) continue;
|
|
|
|
|
+ cookies.push({ name, value, url: 'https://creator.xiaohongshu.com' });
|
|
|
|
|
+ }
|
|
|
|
|
+ return cookies;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
|
|
|
|
|
+ const headless = true;
|
|
|
|
|
+ if (proxy?.enabled) {
|
|
|
|
|
+ const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
|
|
|
|
|
+ const browser = await chromium.launch({
|
|
|
|
|
+ headless,
|
|
|
|
|
+ proxy: {
|
|
|
|
|
+ server,
|
|
|
|
|
+ username: proxy.username,
|
|
|
|
|
+ password: proxy.password,
|
|
|
|
|
+ },
|
|
|
|
|
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
|
|
|
|
|
+ });
|
|
|
|
|
+ return { browser, shouldClose: true };
|
|
|
|
|
+ }
|
|
|
|
|
+ const browser = await BrowserManager.getBrowser({ headless });
|
|
|
|
|
+ return { browser, shouldClose: false };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getChinaDateFromTimestamp(ts: number): Date {
|
|
|
|
|
+ const d = new Date(ts);
|
|
|
|
|
+ const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
+ timeZone: 'Asia/Shanghai',
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ month: '2-digit',
|
|
|
|
|
+ day: '2-digit',
|
|
|
|
|
+ });
|
|
|
|
|
+ const parts = formatter.formatToParts(d);
|
|
|
|
|
+ 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 day = parseInt(get('day'), 10);
|
|
|
|
|
+ return new Date(y, m, day, 0, 0, 0, 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toNumber(val: unknown, defaultValue = 0): number {
|
|
|
|
|
+ if (typeof val === 'number') return Number.isFinite(val) ? val : defaultValue;
|
|
|
|
|
+ if (typeof val === 'string') {
|
|
|
|
|
+ const n = Number(val);
|
|
|
|
|
+ return Number.isFinite(n) ? n : defaultValue;
|
|
|
|
|
+ }
|
|
|
|
|
+ return defaultValue;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toRateString(val: unknown): string | undefined {
|
|
|
|
|
+ const n = toNumber(val, NaN);
|
|
|
|
|
+ if (!Number.isFinite(n)) return undefined;
|
|
|
|
|
+ return n.toString();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 从 item 取 coun(或 count)转为字符串并追加 "%",用于比率类字段 */
|
|
|
|
|
+function toRatePercentString(item: NoteTrendItem): string | undefined {
|
|
|
|
|
+ const raw = item?.coun ?? item?.count;
|
|
|
|
|
+ const n = toNumber(raw, NaN);
|
|
|
|
|
+ if (!Number.isFinite(n)) return undefined;
|
|
|
|
|
+ return `${n}%`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class XiaohongshuWorkNoteStatisticsImportService {
|
|
|
|
|
+ private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
|
|
|
+ private workRepository = AppDataSource.getRepository(Work);
|
|
|
|
|
+ private workDayStatisticsService = new WorkDayStatisticsService();
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 统一入口:定时任务调用,批量为所有小红书账号同步作品日统计
|
|
|
|
|
+ */
|
|
|
|
|
+ static async runDailyImport(): Promise<void> {
|
|
|
|
|
+ const svc = new XiaohongshuWorkNoteStatisticsImportService();
|
|
|
|
|
+ await svc.runDailyImportForAllXhsAccounts();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async runDailyImportForAllXhsAccounts(): Promise<void> {
|
|
|
|
|
+ const accounts = await this.accountRepository.find({
|
|
|
|
|
+ where: { platform: 'xiaohongshu' as any },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`[XHS WorkStats] Start import for ${accounts.length} accounts`);
|
|
|
|
|
+
|
|
|
|
|
+ for (const account of accounts) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.importAccountWorksStatistics(account);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ `[XHS WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
|
|
|
|
|
+ e
|
|
|
|
|
+ );
|
|
|
|
|
+ // 单账号失败(含登录失效、刷新失败等)仅记录日志,不中断循环,其他账号照常同步
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info('[XHS WorkStats] All accounts done');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按账号同步作品日统计。检测到登录失效时:先尝试刷新登录一次;刷新仍失效则执行账号失效,刷新成功则用新 cookie 重试一次。
|
|
|
|
|
+ * @param isRetry 是否为「刷新登录后的重试」,避免无限递归
|
|
|
|
|
+ */
|
|
|
|
|
+ private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
|
|
|
|
|
+ const cookies = parseCookiesFromAccount(account.cookieData);
|
|
|
|
|
+ if (!cookies.length) {
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const works = await this.workRepository.find({
|
|
|
|
|
+ where: {
|
|
|
|
|
+ accountId: account.id,
|
|
|
|
|
+ platform: 'xiaohongshu' as any,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!works.length) {
|
|
|
|
|
+ logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
|
|
|
|
|
+ let context: BrowserContext | null = null;
|
|
|
|
|
+ let closedDueToLoginExpired = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ context = await browser.newContext({
|
|
|
|
|
+ viewport: { width: 1920, height: 1080 },
|
|
|
|
|
+ locale: 'zh-CN',
|
|
|
|
|
+ timezoneId: 'Asia/Shanghai',
|
|
|
|
|
+ userAgent:
|
|
|
|
|
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
|
|
|
+ });
|
|
|
|
|
+ await context.addCookies(cookies as any);
|
|
|
|
|
+ context.setDefaultTimeout(60_000);
|
|
|
|
|
+
|
|
|
|
|
+ const page = await context.newPage();
|
|
|
|
|
+
|
|
|
|
|
+ let totalInserted = 0;
|
|
|
|
|
+ let totalUpdated = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (const work of works) {
|
|
|
|
|
+ const noteId = (work.platformVideoId || '').trim();
|
|
|
|
|
+ if (!noteId) continue;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await this.fetchNoteBaseData(page, noteId);
|
|
|
|
|
+ if (!data) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const patches = this.buildDailyStatisticsFromNoteData(work.id, data);
|
|
|
|
|
+ if (!patches.length) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
|
|
|
|
|
+ patches.map((p) => ({
|
|
|
|
|
+ workId: p.workId,
|
|
|
|
|
+ recordDate: p.recordDate,
|
|
|
|
|
+ playCount: p.playCount,
|
|
|
|
|
+ exposureCount: p.exposureCount,
|
|
|
|
|
+ likeCount: p.likeCount,
|
|
|
|
|
+ commentCount: p.commentCount,
|
|
|
|
|
+ shareCount: p.shareCount,
|
|
|
|
|
+ collectCount: p.collectCount,
|
|
|
|
|
+ fansIncrease: p.fansIncrease,
|
|
|
|
|
+ coverClickRate: p.coverClickRate,
|
|
|
|
|
+ avgWatchDuration: p.avgWatchDuration,
|
|
|
|
|
+ totalWatchDuration: p.totalWatchDuration,
|
|
|
|
|
+ completionRate: p.completionRate,
|
|
|
|
|
+ twoSecondExitRate: p.twoSecondExitRate,
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ totalInserted += result.inserted;
|
|
|
|
|
+ totalUpdated += result.updated;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e instanceof XhsLoginExpiredError) {
|
|
|
|
|
+ closedDueToLoginExpired = true;
|
|
|
|
|
+ if (context) {
|
|
|
|
|
+ await context.close().catch(() => undefined);
|
|
|
|
|
+ context = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (shouldClose) {
|
|
|
|
|
+ await browser.close().catch(() => undefined);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!isRetry) {
|
|
|
|
|
+ logger.info(`[XHS WorkStats] accountId=${account.id} 登录失效,尝试刷新登录...`);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const accountService = new AccountService();
|
|
|
|
|
+ const refreshResult = await accountService.refreshAccount(account.userId, account.id);
|
|
|
|
|
+ if (refreshResult.needReLogin) {
|
|
|
|
|
+ await this.accountRepository.update(account.id, { status: 'expired' as any });
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍需重新登录,已标记账号失效`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
|
|
|
|
|
+ if (refreshed) {
|
|
|
|
|
+ logger.info(`[XHS WorkStats] accountId=${account.id} 刷新成功,重新同步数据`);
|
|
|
|
|
+ return this.importAccountWorksStatistics(refreshed, true);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (refreshErr) {
|
|
|
|
|
+ logger.error(`[XHS WorkStats] accountId=${account.id} 刷新登录失败`, refreshErr);
|
|
|
|
|
+ await this.accountRepository.update(account.id, { status: 'expired' as any });
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] accountId=${account.id} 已标记账号失效`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await this.accountRepository.update(account.id, { status: 'expired' as any });
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍跳转登录,已标记账号失效`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (expireErr) {
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ `[XHS WorkStats] accountId=${account.id} 账号失效处理异常(不影响其他账号)`,
|
|
|
|
|
+ expireErr
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ `[XHS WorkStats] Failed to import note stats. accountId=${account.id} workId=${work.id} noteId=${noteId}`,
|
|
|
|
|
+ e
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ `[XHS WorkStats] accountId=${account.id} completed. inserted=${totalInserted}, updated=${totalUpdated}`
|
|
|
|
|
+ );
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!closedDueToLoginExpired) {
|
|
|
|
|
+ if (context) {
|
|
|
|
|
+ await context.close().catch(() => undefined);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (shouldClose) {
|
|
|
|
|
+ await browser.close().catch(() => undefined);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async fetchNoteBaseData(page: Page, noteId: string): Promise<NoteBaseData | null> {
|
|
|
|
|
+ const noteUrl = `https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(
|
|
|
|
|
+ noteId
|
|
|
|
|
+ )}`;
|
|
|
|
|
+ const apiPattern = /\/api\/galaxy\/creator\/datacenter\/note\/base\?note_id=/i;
|
|
|
|
|
+
|
|
|
|
|
+ const responsePromise = page
|
|
|
|
|
+ .waitForResponse(
|
|
|
|
|
+ (res) => res.url().match(apiPattern) != null && res.request().method() === 'GET',
|
|
|
|
|
+ { timeout: 30_000 }
|
|
|
|
|
+ )
|
|
|
|
|
+ .catch(() => null);
|
|
|
|
|
+
|
|
|
|
|
+ await page.goto(noteUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
|
|
|
|
+ await page.waitForTimeout(1500);
|
|
|
|
|
+
|
|
|
|
|
+ if (page.url().includes('login')) {
|
|
|
|
|
+ logger.warn(
|
|
|
|
|
+ `[XHS WorkStats] note-detail 页面跳转到登录,可能 cookie 失效。noteId=${noteId}`
|
|
|
|
|
+ );
|
|
|
|
|
+ throw new XhsLoginExpiredError();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let res = await responsePromise;
|
|
|
|
|
+ if (!res) {
|
|
|
|
|
+ // 兜底再等一轮
|
|
|
|
|
+ try {
|
|
|
|
|
+ res = await page.waitForResponse(
|
|
|
|
|
+ (r) => r.url().match(apiPattern) != null && r.request().method() === 'GET',
|
|
|
|
|
+ { timeout: 15_000 }
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ logger.warn(
|
|
|
|
|
+ `[XHS WorkStats] 未捕获到 note/base 接口响应,跳过该笔记。noteId=${noteId}`
|
|
|
|
|
+ );
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const body = await res.json().catch(() => null);
|
|
|
|
|
+ if (!body || typeof body !== 'object') {
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] note/base 响应不是 JSON,跳过。noteId=${noteId}`);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = (body as any).data as NoteBaseData | undefined;
|
|
|
|
|
+ if (!data || typeof data !== 'object') {
|
|
|
|
|
+ logger.warn(`[XHS WorkStats] note/base data 为空,跳过。noteId=${noteId}`);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildDailyStatisticsFromNoteData(workId: number, data: NoteBaseData): DailyWorkStatPatch[] {
|
|
|
|
|
+ const day = data.day;
|
|
|
|
|
+ if (!day) return [];
|
|
|
|
|
+
|
|
|
|
|
+ const map = new Map<number, DailyWorkStatPatch>();
|
|
|
|
|
+
|
|
|
|
|
+ const addIntMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
|
|
|
|
|
+ if (!Array.isArray(items)) return;
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const tsRaw = item?.date;
|
|
|
|
|
+ if (tsRaw == null) continue;
|
|
|
|
|
+ const ts = toNumber(tsRaw, NaN);
|
|
|
|
|
+ if (!Number.isFinite(ts)) continue;
|
|
|
|
|
+ const d = getChinaDateFromTimestamp(ts);
|
|
|
|
|
+ const key = d.getTime();
|
|
|
|
|
+ let entry = map.get(key);
|
|
|
|
|
+ if (!entry) {
|
|
|
|
|
+ entry = { workId, recordDate: d };
|
|
|
|
|
+ map.set(key, entry);
|
|
|
|
|
+ }
|
|
|
|
|
+ const prev = (entry as any)[field] ?? 0;
|
|
|
|
|
+ const inc = toNumber(item.count, 0);
|
|
|
|
|
+ (entry as any)[field] = prev + inc;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 比率类:使用 item.coun,转为字符串并加 "%" */
|
|
|
|
|
+ const addRatePercentMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
|
|
|
|
|
+ if (!Array.isArray(items)) return;
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const tsRaw = item?.date;
|
|
|
|
|
+ if (tsRaw == null) continue;
|
|
|
|
|
+ const ts = toNumber(tsRaw, NaN);
|
|
|
|
|
+ if (!Number.isFinite(ts)) continue;
|
|
|
|
|
+ const d = getChinaDateFromTimestamp(ts);
|
|
|
|
|
+ const key = d.getTime();
|
|
|
|
|
+ let entry = map.get(key);
|
|
|
|
|
+ if (!entry) {
|
|
|
|
|
+ entry = { workId, recordDate: d };
|
|
|
|
|
+ map.set(key, entry);
|
|
|
|
|
+ }
|
|
|
|
|
+ const s = toRatePercentString(item);
|
|
|
|
|
+ if (s != null) {
|
|
|
|
|
+ (entry as any)[field] = s;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 时长/数值字符串(不加 %) */
|
|
|
|
|
+ const addRateMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
|
|
|
|
|
+ if (!Array.isArray(items)) return;
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const tsRaw = item?.date;
|
|
|
|
|
+ if (tsRaw == null) continue;
|
|
|
|
|
+ const ts = toNumber(tsRaw, NaN);
|
|
|
|
|
+ if (!Number.isFinite(ts)) continue;
|
|
|
|
|
+ const d = getChinaDateFromTimestamp(ts);
|
|
|
|
|
+ const key = d.getTime();
|
|
|
|
|
+ let entry = map.get(key);
|
|
|
|
|
+ if (!entry) {
|
|
|
|
|
+ entry = { workId, recordDate: d };
|
|
|
|
|
+ map.set(key, entry);
|
|
|
|
|
+ }
|
|
|
|
|
+ const s = toRateString(item.count_with_double ?? item.count ?? item.coun);
|
|
|
|
|
+ if (s != null) {
|
|
|
|
|
+ (entry as any)[field] = s;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 数值型:每日增量
|
|
|
|
|
+ addIntMetric(day.view_list, 'playCount');
|
|
|
|
|
+ addIntMetric(day.like_list, 'likeCount');
|
|
|
|
|
+ addIntMetric(day.comment_list, 'commentCount');
|
|
|
|
|
+ addIntMetric(day.share_list, 'shareCount');
|
|
|
|
|
+ addIntMetric(day.collect_list, 'collectCount');
|
|
|
|
|
+ addIntMetric(day.imp_list, 'exposureCount');
|
|
|
|
|
+ addIntMetric(day.rise_fans_list, 'fansIncrease');
|
|
|
|
|
+
|
|
|
|
|
+ // 比率类:使用 coun 字段,入库时带 "%"
|
|
|
|
|
+ addRatePercentMetric(day.cover_click_rate_list, 'coverClickRate');
|
|
|
|
|
+ addRatePercentMetric(day.exit_view2s_list, 'twoSecondExitRate');
|
|
|
|
|
+ addRatePercentMetric(day.finish_list, 'completionRate');
|
|
|
|
|
+
|
|
|
|
|
+ // 平均观看时长:view_time_list → avg_watch_duration(数值字符串,不加 %)
|
|
|
|
|
+ addRateMetric(day.view_time_list, 'avgWatchDuration');
|
|
|
|
|
+
|
|
|
|
|
+ return Array.from(map.values()).sort(
|
|
|
|
|
+ (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|