|
@@ -359,6 +359,168 @@ async function parseWeixinVideoFile(filePath: string): Promise<Map<string, { rec
|
|
|
return result;
|
|
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 {
|
|
export class WeixinVideoDataCenterImportService {
|
|
|
private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
|
private userDayStatisticsService = new UserDayStatisticsService();
|
|
private userDayStatisticsService = new UserDayStatisticsService();
|
|
@@ -507,7 +669,7 @@ export class WeixinVideoDataCenterImportService {
|
|
|
await page.getByText('数据中心', { exact: false }).first().click();
|
|
await page.getByText('数据中心', { exact: false }).first().click();
|
|
|
await page.waitForTimeout(800);
|
|
await page.waitForTimeout(800);
|
|
|
|
|
|
|
|
- // 关注者数据 + 视频数据,均通过 Excel 下载
|
|
|
|
|
|
|
+ // 「关注者数据」和「视频数据」分别进入各自的页面,各自监听接口并各自兜底下载 Excel
|
|
|
const sections: WxSection[] = ['关注者数据', '视频数据'];
|
|
const sections: WxSection[] = ['关注者数据', '视频数据'];
|
|
|
let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
|
|
let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
|
|
|
|
|
|
|
@@ -542,6 +704,41 @@ export class WeixinVideoDataCenterImportService {
|
|
|
await page.waitForTimeout(800);
|
|
await page.waitForTimeout(800);
|
|
|
|
|
|
|
|
// 日期范围:点击「近30天」
|
|
// 日期范围:点击「近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 {
|
|
try {
|
|
|
if (section === '关注者数据') {
|
|
if (section === '关注者数据') {
|
|
|
const loc = page.locator(
|
|
const loc = page.locator(
|
|
@@ -569,7 +766,117 @@ export class WeixinVideoDataCenterImportService {
|
|
|
}
|
|
}
|
|
|
await page.waitForTimeout(4000);
|
|
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([
|
|
const [download] = await Promise.all([
|
|
|
page.waitForEvent('download', { timeout: 60_000 }),
|
|
page.waitForEvent('download', { timeout: 60_000 }),
|
|
|
tryClick(['下载表格', '下载', '导出数据']),
|
|
tryClick(['下载表格', '下载', '导出数据']),
|