WeixinVideoWorkStatisticsImportService.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
  3. *
  4. * 流程:调用 Python 纯浏览器接口,由 Python 完成:
  5. * 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
  6. * 2. 监听 post_list 获取 exportId->objectId
  7. * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页近30天 → 下载表格
  8. * 4. 解析 CSV 存入 work_day_statistics
  9. */
  10. import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
  11. import { logger } from '../utils/logger.js';
  12. import { CookieManager } from '../automation/cookie.js';
  13. import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
  14. function tryDecryptCookieData(cookieData: string | null): string | null {
  15. if (!cookieData) return null;
  16. const raw = cookieData.trim();
  17. if (!raw) return null;
  18. try {
  19. return CookieManager.decrypt(raw);
  20. } catch {
  21. return raw;
  22. }
  23. }
  24. /** 将 cookie 转为 Python 接口所需格式(JSON 数组或原始字符串) */
  25. function getCookieForPython(cookieData: string | null): string {
  26. const raw = tryDecryptCookieData(cookieData);
  27. if (!raw) return '';
  28. const s = raw.trim();
  29. if (!s) return '';
  30. try {
  31. JSON.parse(s);
  32. return s; // 已是 JSON
  33. } catch {
  34. return JSON.stringify(
  35. s
  36. .split(';')
  37. .filter(Boolean)
  38. .map((part) => {
  39. const idx = part.trim().indexOf('=');
  40. const name = idx >= 0 ? part.trim().slice(0, idx) : part.trim();
  41. const value = idx >= 0 ? part.trim().slice(idx + 1) : '';
  42. return { name, value, domain: '.weixin.qq.com', path: '/' };
  43. })
  44. );
  45. }
  46. }
  47. export class WeixinVideoWorkStatisticsImportService {
  48. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  49. private workRepository = AppDataSource.getRepository(Work);
  50. static async runDailyImport(): Promise<void> {
  51. const svc = new WeixinVideoWorkStatisticsImportService();
  52. await svc.runDailyImportForAllWeixinVideoAccounts();
  53. }
  54. /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */
  55. static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise<void> {
  56. const svc = new WeixinVideoWorkStatisticsImportService();
  57. const account = await svc.accountRepository.findOne({
  58. where: { id: accountId, platform: 'weixin_video' as any },
  59. });
  60. if (!account) {
  61. throw new Error(`未找到视频号账号 id=${accountId}`);
  62. }
  63. logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}`);
  64. await svc.importAccountWorksStatistics(account, showBrowser);
  65. }
  66. async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
  67. const accounts = await this.accountRepository.find({
  68. where: { platform: 'weixin_video' as any },
  69. });
  70. logger.info(`[WX WorkStats] Start import for ${accounts.length} weixin_video accounts`);
  71. for (const account of accounts) {
  72. try {
  73. await this.importAccountWorksStatistics(account);
  74. } catch (e) {
  75. logger.error(
  76. `[WX WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
  77. e
  78. );
  79. }
  80. }
  81. logger.info('[WX WorkStats] All accounts done');
  82. }
  83. private async importAccountWorksStatistics(account: PlatformAccount, showBrowser = false): Promise<void> {
  84. const cookieForPython = getCookieForPython(account.cookieData);
  85. if (!cookieForPython) {
  86. logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
  87. return;
  88. }
  89. const works = await this.workRepository.find({
  90. where: { accountId: account.id, platform: 'weixin_video' as any },
  91. });
  92. if (!works.length) {
  93. logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
  94. return;
  95. }
  96. const worksPayload = works
  97. .filter((w) => (w.platformVideoId ?? '').trim())
  98. .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() }));
  99. if (!worksPayload.length) {
  100. logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
  101. return;
  102. }
  103. logger.info(
  104. `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品`
  105. );
  106. try {
  107. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  108. const pyRes = await fetch(`${pythonUrl}/sync_weixin_account_works_daily_stats`, {
  109. method: 'POST',
  110. headers: { 'Content-Type': 'application/json' },
  111. body: JSON.stringify({
  112. works: worksPayload,
  113. cookie: cookieForPython,
  114. show_browser: showBrowser,
  115. }),
  116. signal: AbortSignal.timeout(600_000), // 10 分钟,批量可能较久
  117. });
  118. const data = (await pyRes.json().catch(() => ({}))) as {
  119. success?: boolean;
  120. error?: string;
  121. message?: string;
  122. total_processed?: number;
  123. total_skipped?: number;
  124. inserted?: number;
  125. updated?: number;
  126. works_updated?: number;
  127. };
  128. if (!pyRes.ok) {
  129. logger.warn(`[WX WorkStats] accountId=${account.id} Python 请求失败: ${pyRes.status} ${data.error || ''}`);
  130. return;
  131. }
  132. if (!data.success) {
  133. logger.warn(`[WX WorkStats] accountId=${account.id} 同步失败: ${data.error || ''}`);
  134. return;
  135. }
  136. const worksUpdated = data.works_updated ?? 0;
  137. logger.info(
  138. `[WX WorkStats] accountId=${account.id} 完成: 处理 ${data.total_processed ?? 0} 个, 跳过 ${data.total_skipped ?? 0} 个, 新增 ${data.inserted ?? 0} 条, 更新 ${data.updated ?? 0} 条` +
  139. (worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '')
  140. );
  141. } catch (e) {
  142. logger.error(`[WX WorkStats] accountId=${account.id} 调用 Python 失败:`, e);
  143. throw e;
  144. }
  145. }
  146. }