| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- /**
- * 视频号:作品维度「纯浏览器自动化」→ 导入 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';
- import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
- 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<void> {
- const svc = new WeixinVideoWorkStatisticsImportService();
- await svc.runDailyImportForAllWeixinVideoAccounts();
- }
- /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */
- static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise<void> {
- 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<void> {
- 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<void> {
- 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 pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
- const pyRes = await fetch(`${pythonUrl}/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;
- }
- }
- }
|