/** * 视频号:作品维度「作品列表 + 按天聚合数据」→ 导入 work_day_statistics * * 流程: * 1. 获取 works 表中 platform=weixin_video 的作品(platform_video_id 存的是 exportId) * 2. 调用 post_list 接口获取作品列表,通过 exportId 匹配得到 objectId * 3. 对每个作品调用 feed_aggreagate_data_by_tab_type,取「全部」tab 的按天数据 * 4. 将 browse→播放、like→点赞、comment→评论 写入 work_day_statistics(follow=关注、fav/forward 暂不入库) */ import crypto from 'crypto'; import { AppDataSource, PlatformAccount, Work } from '../models/index.js'; import { logger } from '../utils/logger.js'; import { WorkDayStatisticsService } from './WorkDayStatisticsService.js'; import { CookieManager } from '../automation/cookie.js'; const POST_LIST_BASE = 'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/post_list'; const FEED_AGGREGATE_BASE = 'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/feed_aggreagate_data_by_tab_type'; /** 列表页 _pageUrl(与浏览器「数据统计-作品」列表一致) */ const POST_LIST_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/post'; /** 详情页 _pageUrl(与浏览器 postDetail 一致,feed_aggreagate 用) */ const POST_DETAIL_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/postDetail'; /** 生成随机 _rid(格式如 6982df69-ff6e46a5,8hex-8hex) */ function generateRandomRid(): string { const a = crypto.randomBytes(4).toString('hex'); const b = crypto.randomBytes(4).toString('hex'); return `${a}-${b}`; } /** * 构建带 _aid、_rid、_pageUrl 的 URL。 * 若传入 sessionAid/sessionRid 则优先使用(本账号 post_list 生成的,复用于 feed_aggreagate); * 否则读环境变量 WX_VIDEO_AID、WX_VIDEO_RID。 */ function buildUrlWithAidRid( base: string, pageUrl: string, sessionAid?: string, sessionRid?: string ): string { const aid = sessionAid ?? process.env.WX_VIDEO_AID?.trim() ?? ''; const rid = sessionRid ?? process.env.WX_VIDEO_RID?.trim() ?? ''; const params = new URLSearchParams(); if (aid) params.set('_aid', aid); if (rid) params.set('_rid', rid); params.set('_pageUrl', pageUrl); const qs = params.toString(); return qs ? `${base}?${qs}` : base; } function tryDecryptCookieData(cookieData: string | null): string | null { if (!cookieData) return null; const raw = cookieData.trim(); if (!raw) return null; try { return CookieManager.decrypt(raw); } catch { return raw; } } /** 将账号 cookie_data 转为 HTTP Cookie 头字符串 */ function getCookieHeaderString(cookieData: string | null): string { const raw = tryDecryptCookieData(cookieData); if (!raw) return ''; const s = raw.trim(); if (!s) return ''; if (s.startsWith('[') || s.startsWith('{')) { try { const parsed = JSON.parse(s); const arr = Array.isArray(parsed) ? parsed : parsed?.cookies ?? []; if (!Array.isArray(arr)) return ''; return arr .map((c: { name?: string; value?: string }) => { const name = String(c?.name ?? '').trim(); const value = String(c?.value ?? '').trim(); return name ? `${name}=${value}` : ''; }) .filter(Boolean) .join('; '); } catch { return s; } } return s; } /** 从 Cookie 字符串中解析 x-wechat-uin(可选) */ function getXWechatUinFromCookie(cookieHeader: string): string | undefined { const match = cookieHeader.match(/\bwxuin=(\d+)/i); return match ? match[1] : undefined; } /** 从账号 account_id 得到 _log_finder_id(去掉 weixin_video_ 前缀,保证以 @finder 结尾) */ function getLogFinderId(accountId: string | null): string { if (!accountId) return ''; const s = String(accountId).trim(); const prefix = 'weixin_video_'; const id = s.startsWith(prefix) ? s.slice(prefix.length) : s; if (!id) return ''; return id.endsWith('@finder') ? id : `${id}@finder`; } function buildPostListUrl(sessionAid?: string, sessionRid?: string): string { return buildUrlWithAidRid(POST_LIST_BASE, POST_LIST_PAGE_URL, sessionAid, sessionRid); } function buildFeedAggregateUrl(sessionAid?: string, sessionRid?: string): string { return buildUrlWithAidRid(FEED_AGGREGATE_BASE, POST_DETAIL_PAGE_URL, sessionAid, sessionRid); } /** 近30天到昨天:返回 [startTime, endTime] Unix 秒(中国时间 00:00:00 起算) */ function getLast30DaysRange(): { startTime: number; endTime: number; startDate: Date; endDate: Date } { const now = new Date(); const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); const startDate = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate() - 30); startDate.setHours(0, 0, 0, 0); yesterday.setHours(23, 59, 59, 999); const startTime = Math.floor(startDate.getTime() / 1000); const endTime = Math.floor(yesterday.getTime() / 1000); const endDateNorm = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()); endDateNorm.setHours(0, 0, 0, 0); return { startTime, endTime, startDate, endDate: endDateNorm }; } function toInt(val: unknown, defaultVal = 0): number { if (typeof val === 'number') return Number.isFinite(val) ? Math.round(val) : defaultVal; if (typeof val === 'string') { const n = parseInt(val, 10); return Number.isFinite(n) ? n : defaultVal; } return defaultVal; } interface PostListItem { objectId?: string; exportId?: string; } interface FeedAggregateDataByTabType { tabType?: number; tabTypeName?: string; data?: { browse?: string[]; like?: string[]; comment?: string[]; forward?: string[]; fav?: string[]; follow?: string[]; }; } export class WeixinVideoWorkStatisticsImportService { private accountRepository = AppDataSource.getRepository(PlatformAccount); private workRepository = AppDataSource.getRepository(Work); private workDayStatisticsService = new WorkDayStatisticsService(); static async runDailyImport(): Promise { const svc = new WeixinVideoWorkStatisticsImportService(); await svc.runDailyImportForAllWeixinVideoAccounts(); } async runDailyImportForAllWeixinVideoAccounts(): Promise { const accounts = await this.accountRepository.find({ where: { platform: 'weixin_video' as any }, }); logger.info(`[WX WorkStats] Start import for ${accounts.length} weixin_video accounts`); for (const account of accounts) { try { await this.importAccountWorksStatistics(account); } catch (e) { logger.error( `[WX WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e ); } } logger.info('[WX WorkStats] All accounts done'); } private async importAccountWorksStatistics(account: PlatformAccount): Promise { const cookieHeader = getCookieHeaderString(account.cookieData); if (!cookieHeader) { logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`); return; } const works = await this.workRepository.find({ where: { accountId: account.id, platform: 'weixin_video' as any }, }); if (!works.length) { logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`); return; } const { startTime, endTime, startDate, endDate } = getLast30DaysRange(); const logFinderId = getLogFinderId(account.accountId); const xWechatUin = getXWechatUinFromCookie(cookieHeader); // _aid:post_list 时生成一次,本批次请求数据接口(feed_aggreagate)时复用;_rid 每次请求随机 const sessionAid = process.env.WX_VIDEO_AID?.trim() || crypto.randomUUID(); logger.info(`[WX WorkStats] accountId=${account.id} post_list 生成 aid=${sessionAid},数据接口复用此 aid,rid 每次随机`); const headers: Record = { accept: '*/*', 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', 'content-type': 'application/json', cookie: cookieHeader, origin: 'https://channels.weixin.qq.com', referer: 'https://channels.weixin.qq.com/micro/statistic/post', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0', }; if (xWechatUin) headers['x-wechat-uin'] = xWechatUin; const postListBody = { pageSize: 100, currentPage: 1, sort: 0, order: 0, startTime, endTime, timestamp: String(Date.now()), _log_finder_uin: '', _log_finder_id: logFinderId, rawKeyBuff: null, pluginSessionId: null, scene: 7, reqScene: 7, }; const postListUrl = buildPostListUrl(sessionAid, generateRandomRid()); let res: Response; try { res = await fetch(postListUrl, { method: 'POST', headers, body: JSON.stringify(postListBody), signal: AbortSignal.timeout(30_000), }); } catch (e) { logger.error(`[WX WorkStats] post_list request failed. accountId=${account.id}`, e); throw e; } if (!res.ok) { logger.warn(`[WX WorkStats] post_list HTTP ${res.status}. accountId=${account.id}`); return; } const postListJson = (await res.json().catch(() => null)) as { errCode?: number; errMsg?: string; data?: { list?: PostListItem[]; totalCount?: number }; } | null; if (!postListJson || postListJson.errCode !== 0) { logger.warn( `[WX WorkStats] post_list errCode=${postListJson?.errCode} errMsg=${postListJson?.errMsg}. accountId=${account.id}` ); return; } const list = postListJson.data?.list ?? []; const totalCount = postListJson.data?.totalCount ?? list.length; const exportIdToObjectId = new Map(); for (const item of list) { const exportId = item.exportId ?? ''; const objectId = item.objectId ?? ''; if (exportId && objectId) exportIdToObjectId.set(exportId, objectId); } // 日志:对比 API 与 DB 的 exportId logger.info( `[WX WorkStats] accountId=${account.id} post_list 返回 totalCount=${totalCount} list.length=${list.length}` ); const apiExportIds: string[] = []; for (let i = 0; i < list.length; i++) { const item = list[i]; const eid = (item.exportId ?? '').trim(); const oid = (item.objectId ?? '').trim(); apiExportIds.push(eid); logger.info(`[WX WorkStats] post_list[${i}] exportId=${eid} objectId=${oid}`); } for (const work of works) { const dbExportId = (work.platformVideoId ?? '').trim(); if (!dbExportId) continue; const matched = exportIdToObjectId.has(dbExportId); logger.info( `[WX WorkStats] DB workId=${work.id} platform_video_id(exportId)=${dbExportId} 匹配post_list=${matched}` ); if (!matched && apiExportIds.length > 0) { const sameLength = apiExportIds.filter((e) => e.length === dbExportId.length).length; const containsDb = apiExportIds.some((e) => e === dbExportId || e.includes(dbExportId) || dbExportId.includes(e)); logger.info( `[WX WorkStats] 对比: DB长度=${dbExportId.length} API条数=${apiExportIds.length} 同长API条数=${sameLength} 是否包含关系=${containsDb}` ); } } let totalInserted = 0; let totalUpdated = 0; const feedHeaders: Record = { ...headers, referer: 'https://channels.weixin.qq.com/micro/statistic/postDetail?isImageMode=0', 'finger-print-device-id': process.env.WX_VIDEO_FINGERPRINT_DEVICE_ID?.trim() || '4605bc28ad3962eb9ee791897b199217', }; for (const work of works) { const exportId = (work.platformVideoId ?? '').trim(); if (!exportId) continue; const objectId = exportIdToObjectId.get(exportId); if (!objectId) { logger.debug(`[WX WorkStats] workId=${work.id} exportId=${exportId} 未在 post_list 中匹配到 objectId,跳过`); continue; } const feedBody = { startTs: String(startTime), endTs: String(endTime), interval: 3, feedId: objectId, timestamp: String(Date.now()), _log_finder_uin: '', _log_finder_id: logFinderId, rawKeyBuff: null, pluginSessionId: null, scene: 7, reqScene: 7, }; const feedUrl = buildFeedAggregateUrl(sessionAid, generateRandomRid()); let feedRes: Response; try { feedRes = await fetch(feedUrl, { method: 'POST', headers: feedHeaders, body: JSON.stringify(feedBody), signal: AbortSignal.timeout(30_000), }); } catch (e) { logger.error(`[WX WorkStats] feed_aggreagate request failed. workId=${work.id} feedId=${objectId}`, e); continue; } if (!feedRes.ok) { logger.warn(`[WX WorkStats] feed_aggreagate HTTP ${feedRes.status}. workId=${work.id}`); continue; } const feedJson = (await feedRes.json().catch(() => null)) as { errCode?: number; data?: { dataByFanstype?: { dataByTabtype?: FeedAggregateDataByTabType[] }[]; feedData?: { dataByTabtype?: FeedAggregateDataByTabType[] }[]; }; } | null; const isTestWork = work.id === 866 || work.id === 867 || work.id === 902 || work.id === 903; if (isTestWork) { logger.info(`[WX WorkStats] feed_aggreagate 原始响应 workId=${work.id} errCode=${feedJson?.errCode} errMsg=${(feedJson as any)?.errMsg} 完整body=${JSON.stringify(feedJson ?? null)}`); } if (!feedJson || feedJson.errCode !== 0) { if (isTestWork) { logger.warn(`[WX WorkStats] workId=${work.id} feed_aggreagate 非成功 errCode=${feedJson?.errCode} 跳过`); } continue; } const dataByFanstype = feedJson.data?.dataByFanstype ?? []; const firstFans = dataByFanstype[0]; const dataByTabtype = firstFans?.dataByTabtype ?? feedJson.data?.feedData?.[0]?.dataByTabtype ?? []; const tabAll = dataByTabtype.find((t) => t.tabTypeName === '全部' || t.tabType === 999); if (isTestWork) { logger.info( `[WX WorkStats] workId=${work.id} dataByTabtype.length=${dataByTabtype.length} tabAll=${!!tabAll} tabAll.tabTypeName=${tabAll?.tabTypeName}` ); } if (!tabAll?.data) continue; const data = tabAll.data; const browse = data.browse ?? []; const like = data.like ?? []; const comment = data.comment ?? []; if (isTestWork) { logger.info( `[WX WorkStats] workId=${work.id} 「全部」data: browse.length=${browse.length} like.length=${like.length} comment.length=${comment.length}` ); logger.info(`[WX WorkStats] workId=${work.id} browse=${JSON.stringify(browse)}`); logger.info(`[WX WorkStats] workId=${work.id} like=${JSON.stringify(like)}`); logger.info(`[WX WorkStats] workId=${work.id} comment=${JSON.stringify(comment)}`); } const len = Math.max(browse.length, like.length, comment.length); if (len === 0) continue; const patches: Array<{ workId: number; recordDate: Date; playCount?: number; likeCount?: number; commentCount?: number; }> = []; for (let i = 0; i < len; i++) { const recordDate = new Date(startDate); recordDate.setDate(recordDate.getDate() + i); recordDate.setHours(0, 0, 0, 0); if (recordDate > endDate) break; patches.push({ workId: work.id, recordDate, playCount: toInt(browse[i], 0), likeCount: toInt(like[i], 0), commentCount: toInt(comment[i], 0), }); } if (isTestWork) { logger.info(`[WX WorkStats] workId=${work.id} 生成 patches.length=${patches.length} 前3条=${JSON.stringify(patches.slice(0, 3))}`); } if (patches.length) { const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(patches); if (isTestWork) { logger.info(`[WX WorkStats] workId=${work.id} saveStatisticsForDateBatch inserted=${result.inserted} updated=${result.updated}`); } totalInserted += result.inserted; totalUpdated += result.updated; } } logger.info( `[WX WorkStats] accountId=${account.id} completed. inserted=${totalInserted} updated=${totalUpdated}` ); } }