/** * 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics * * 流程:调用 Python 纯浏览器接口,由 Python 完成: * 1. 打开 statistic/post → 点击单篇视频 → 点击近30天 * 2. 监听 post_list 获取 exportId->objectId * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页近30天 → 下载表格 * 4. 解析 CSV 存入 work_day_statistics */ import { AppDataSource, PlatformAccount, Work } from '../models/index.js'; import { logger } from '../utils/logger.js'; import { CookieManager } from '../automation/cookie.js'; const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005'; 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 转为 Python 接口所需格式(JSON 数组或原始字符串) */ function getCookieForPython(cookieData: string | null): string { const raw = tryDecryptCookieData(cookieData); if (!raw) return ''; const s = raw.trim(); if (!s) return ''; try { JSON.parse(s); return s; // 已是 JSON } catch { return JSON.stringify( s .split(';') .filter(Boolean) .map((part) => { const idx = part.trim().indexOf('='); const name = idx >= 0 ? part.trim().slice(0, idx) : part.trim(); const value = idx >= 0 ? part.trim().slice(idx + 1) : ''; return { name, value, domain: '.weixin.qq.com', path: '/' }; }) ); } } export class WeixinVideoWorkStatisticsImportService { private accountRepository = AppDataSource.getRepository(PlatformAccount); private workRepository = AppDataSource.getRepository(Work); static async runDailyImport(): Promise { const svc = new WeixinVideoWorkStatisticsImportService(); await svc.runDailyImportForAllWeixinVideoAccounts(); } /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */ static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise { const svc = new WeixinVideoWorkStatisticsImportService(); const account = await svc.accountRepository.findOne({ where: { id: accountId, platform: 'weixin_video' as any }, }); if (!account) { throw new Error(`未找到视频号账号 id=${accountId}`); } logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}`); await svc.importAccountWorksStatistics(account, showBrowser); } 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, showBrowser = false): Promise { const cookieForPython = getCookieForPython(account.cookieData); if (!cookieForPython) { 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 worksPayload = works .filter((w) => (w.platformVideoId ?? '').trim()) .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() })); if (!worksPayload.length) { logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`); return; } logger.info( `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品` ); try { const pyRes = await fetch(`${PYTHON_SERVICE_URL}/sync_weixin_account_works_daily_stats`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ works: worksPayload, cookie: cookieForPython, show_browser: showBrowser, }), signal: AbortSignal.timeout(600_000), // 10 分钟,批量可能较久 }); const data = (await pyRes.json().catch(() => ({}))) as { success?: boolean; error?: string; message?: string; total_processed?: number; total_skipped?: number; inserted?: number; updated?: number; works_updated?: number; }; if (!pyRes.ok) { logger.warn(`[WX WorkStats] accountId=${account.id} Python 请求失败: ${pyRes.status} ${data.error || ''}`); return; } if (!data.success) { logger.warn(`[WX WorkStats] accountId=${account.id} 同步失败: ${data.error || ''}`); return; } const worksUpdated = data.works_updated ?? 0; logger.info( `[WX WorkStats] accountId=${account.id} 完成: 处理 ${data.total_processed ?? 0} 个, 跳过 ${data.total_skipped ?? 0} 个, 新增 ${data.inserted ?? 0} 条, 更新 ${data.updated ?? 0} 条` + (worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '') ); } catch (e) { logger.error(`[WX WorkStats] accountId=${account.id} 调用 Python 失败:`, e); throw e; } } }