WeixinVideoWorkStatisticsImportService.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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. const PYTHON_SERVICE_URL =
  14. process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
  15. function tryDecryptCookieData(cookieData: string | null): string | null {
  16. if (!cookieData) return null;
  17. const raw = cookieData.trim();
  18. if (!raw) return null;
  19. try {
  20. return CookieManager.decrypt(raw);
  21. } catch {
  22. return raw;
  23. }
  24. }
  25. /** 将 cookie 转为 Python 接口所需格式(JSON 数组或原始字符串) */
  26. function getCookieForPython(cookieData: string | null): string {
  27. const raw = tryDecryptCookieData(cookieData);
  28. if (!raw) return '';
  29. const s = raw.trim();
  30. if (!s) return '';
  31. try {
  32. JSON.parse(s);
  33. return s; // 已是 JSON
  34. } catch {
  35. return JSON.stringify(
  36. s
  37. .split(';')
  38. .filter(Boolean)
  39. .map((part) => {
  40. const idx = part.trim().indexOf('=');
  41. const name = idx >= 0 ? part.trim().slice(0, idx) : part.trim();
  42. const value = idx >= 0 ? part.trim().slice(idx + 1) : '';
  43. return { name, value, domain: '.weixin.qq.com', path: '/' };
  44. })
  45. );
  46. }
  47. }
  48. export class WeixinVideoWorkStatisticsImportService {
  49. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  50. private workRepository = AppDataSource.getRepository(Work);
  51. static async runDailyImport(): Promise<void> {
  52. const svc = new WeixinVideoWorkStatisticsImportService();
  53. await svc.runDailyImportForAllWeixinVideoAccounts();
  54. }
  55. /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */
  56. static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise<void> {
  57. const svc = new WeixinVideoWorkStatisticsImportService();
  58. const account = await svc.accountRepository.findOne({
  59. where: { id: accountId, platform: 'weixin_video' as any },
  60. });
  61. if (!account) {
  62. throw new Error(`未找到视频号账号 id=${accountId}`);
  63. }
  64. logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}`);
  65. await svc.importAccountWorksStatistics(account, showBrowser);
  66. }
  67. async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
  68. const accounts = await this.accountRepository.find({
  69. where: { platform: 'weixin_video' as any },
  70. });
  71. logger.info(`[WX WorkStats] Start import for ${accounts.length} weixin_video accounts`);
  72. for (const account of accounts) {
  73. try {
  74. await this.importAccountWorksStatistics(account);
  75. } catch (e) {
  76. logger.error(
  77. `[WX WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
  78. e
  79. );
  80. }
  81. }
  82. logger.info('[WX WorkStats] All accounts done');
  83. }
  84. private async importAccountWorksStatistics(account: PlatformAccount, showBrowser = false): Promise<void> {
  85. const cookieForPython = getCookieForPython(account.cookieData);
  86. if (!cookieForPython) {
  87. logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
  88. return;
  89. }
  90. const works = await this.workRepository.find({
  91. where: { accountId: account.id, platform: 'weixin_video' as any },
  92. });
  93. if (!works.length) {
  94. logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
  95. return;
  96. }
  97. const worksPayload = works
  98. .filter((w) => (w.platformVideoId ?? '').trim())
  99. .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() }));
  100. if (!worksPayload.length) {
  101. logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
  102. return;
  103. }
  104. logger.info(
  105. `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品`
  106. );
  107. try {
  108. const pyRes = await fetch(`${PYTHON_SERVICE_URL}/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. }