WeixinVideoDataCenterImportService.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import { chromium, type Browser } from 'playwright';
  4. import * as XLSXNS from 'xlsx';
  5. import { AppDataSource, PlatformAccount } from '../models/index.js';
  6. import { BrowserManager } from '../automation/browser.js';
  7. import { logger } from '../utils/logger.js';
  8. import { UserDayStatisticsService } from './UserDayStatisticsService.js';
  9. import type { ProxyConfig } from '@media-manager/shared';
  10. import { WS_EVENTS } from '@media-manager/shared';
  11. import { wsManager } from '../websocket/index.js';
  12. // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
  13. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  14. const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any);
  15. type PlaywrightCookie = {
  16. name: string;
  17. value: string;
  18. domain?: string;
  19. path?: string;
  20. url?: string;
  21. expires?: number;
  22. httpOnly?: boolean;
  23. secure?: boolean;
  24. sameSite?: 'Lax' | 'None' | 'Strict';
  25. };
  26. function ensureDir(p: string) {
  27. return fs.mkdir(p, { recursive: true });
  28. }
  29. function normalizeDateText(input: unknown): Date | null {
  30. if (!input) return null;
  31. if (input instanceof Date && !Number.isNaN(input.getTime())) {
  32. const d = new Date(input);
  33. d.setHours(0, 0, 0, 0);
  34. return d;
  35. }
  36. const s = String(input).trim();
  37. if (!s) return null;
  38. // 2026/1/27 or 2026-01-27
  39. const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})/);
  40. if (m1) {
  41. const yyyy = Number(m1[1]);
  42. const mm = Number(m1[2]);
  43. const dd = Number(m1[3]);
  44. if (!yyyy || !mm || !dd) return null;
  45. const d = new Date(yyyy, mm - 1, dd);
  46. d.setHours(0, 0, 0, 0);
  47. return d;
  48. }
  49. // 20260127
  50. const m2 = s.match(/^(\d{4})(\d{2})(\d{2})$/);
  51. if (m2) {
  52. const yyyy = Number(m2[1]);
  53. const mm = Number(m2[2]);
  54. const dd = Number(m2[3]);
  55. const d = new Date(yyyy, mm - 1, dd);
  56. d.setHours(0, 0, 0, 0);
  57. return d;
  58. }
  59. return null;
  60. }
  61. function parseChineseNumberLike(input: unknown): number | null {
  62. if (input === null || input === undefined) return null;
  63. const s = String(input).trim();
  64. if (!s) return null;
  65. const plain = s.replace(/,/g, '');
  66. const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
  67. if (wan) return Math.round(Number(wan[1]) * 10000);
  68. const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
  69. if (yi) return Math.round(Number(yi[1]) * 100000000);
  70. const n = Number(plain.replace(/[^\d.-]/g, ''));
  71. if (Number.isFinite(n)) return Math.round(n);
  72. return null;
  73. }
  74. function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  75. if (!cookieData) return [];
  76. const raw = cookieData.trim();
  77. if (!raw) return [];
  78. // 1) JSON array / 对象
  79. if (raw.startsWith('[') || raw.startsWith('{')) {
  80. try {
  81. const parsed = JSON.parse(raw);
  82. const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
  83. if (!Array.isArray(arr)) return [];
  84. return arr
  85. .map((c: any) => {
  86. const name = String(c?.name ?? '').trim();
  87. const value = String(c?.value ?? '').trim();
  88. if (!name) return null;
  89. const domain = c?.domain ? String(c.domain) : undefined;
  90. const pathVal = c?.path ? String(c.path) : '/';
  91. const url = !domain ? 'https://channels.weixin.qq.com' : undefined;
  92. const sameSiteRaw = c?.sameSite;
  93. const sameSite =
  94. sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
  95. ? sameSiteRaw
  96. : undefined;
  97. return {
  98. name,
  99. value,
  100. domain,
  101. path: pathVal,
  102. url,
  103. expires: typeof c?.expires === 'number' ? c.expires : undefined,
  104. httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
  105. secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
  106. sameSite,
  107. } satisfies PlaywrightCookie;
  108. })
  109. .filter(Boolean) as PlaywrightCookie[];
  110. } catch {
  111. // fallthrough
  112. }
  113. }
  114. // 2) "a=b; c=d"
  115. const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  116. const cookies: PlaywrightCookie[] = [];
  117. for (const p of pairs) {
  118. const idx = p.indexOf('=');
  119. if (idx <= 0) continue;
  120. const name = p.slice(0, idx).trim();
  121. const value = p.slice(idx + 1).trim();
  122. if (!name) continue;
  123. cookies.push({ name, value, url: 'https://channels.weixin.qq.com' });
  124. }
  125. return cookies;
  126. }
  127. async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  128. // 默认 headless;但视频号在 headless 下经常会强制跳登录/风控,
  129. // 因此允许通过 WX_IMPORT_HEADLESS=0 强制用有头浏览器跑导入。
  130. const headless = process.env.WX_IMPORT_HEADLESS === '0' ? false : true;
  131. if (proxy?.enabled) {
  132. const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
  133. const browser = await chromium.launch({
  134. headless,
  135. proxy: {
  136. server,
  137. username: proxy.username,
  138. password: proxy.password,
  139. },
  140. args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
  141. });
  142. return { browser, shouldClose: true };
  143. }
  144. const browser = await BrowserManager.getBrowser({ headless });
  145. return { browser, shouldClose: false };
  146. }
  147. type WxSection = '关注者数据' | '视频数据' | '图文数据';
  148. function parseCsvLine(line: string): string[] {
  149. // 简单 CSV 解析(处理双引号包裹与转义)
  150. const out: string[] = [];
  151. let cur = '';
  152. let inQuotes = false;
  153. for (let i = 0; i < line.length; i++) {
  154. const ch = line[i]!;
  155. if (ch === '"') {
  156. const next = line[i + 1];
  157. if (inQuotes && next === '"') {
  158. cur += '"';
  159. i++;
  160. } else {
  161. inQuotes = !inQuotes;
  162. }
  163. continue;
  164. }
  165. if (ch === ',' && !inQuotes) {
  166. out.push(cur);
  167. cur = '';
  168. continue;
  169. }
  170. cur += ch;
  171. }
  172. out.push(cur);
  173. return out.map((s) => s.trim());
  174. }
  175. async function parseWeixinVideoFile(filePath: string): Promise<Map<string, { recordDate: Date } & Record<string, any>>> {
  176. const ext = path.extname(filePath).toLowerCase();
  177. if (ext === '.csv') {
  178. const text = await fs.readFile(filePath, 'utf8');
  179. const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter((l) => l.trim().length > 0);
  180. const result = new Map<string, { recordDate: Date } & Record<string, any>>();
  181. logger.info(`[WX Import] CSV loaded. file=${path.basename(filePath)} lines=${lines.length}`);
  182. // 找表头行(含“时间”或“日期”)
  183. const headerLineIdx = lines.findIndex((l) => l.includes('"时间"') || l.includes('"日期"') || l.startsWith('时间,') || l.startsWith('日期,'));
  184. if (headerLineIdx < 0) return result;
  185. const header = parseCsvLine(lines[headerLineIdx]!).map((c) => c.replace(/^"|"$/g, '').trim());
  186. logger.info(`[WX Import] Header detected. headerRow=${headerLineIdx + 1} headers=${header.join('|')}`);
  187. const colIndex = (names: string[]) => {
  188. for (const n of names) {
  189. const idx = header.findIndex((h) => h === n);
  190. if (idx >= 0) return idx;
  191. }
  192. for (const n of names) {
  193. const idx = header.findIndex((h) => h.includes(n));
  194. if (idx >= 0) return idx;
  195. }
  196. return -1;
  197. };
  198. const dateCol = colIndex(['时间', '日期']);
  199. const playCol = colIndex(['播放', '播放量', '曝光量', '阅读/播放量', '阅读量']);
  200. const likeCol = colIndex(['喜欢', '点赞', '点赞量']);
  201. const commentCol = colIndex(['评论', '评论量']);
  202. const shareCol = colIndex(['分享', '分享量']);
  203. const fansIncCol = colIndex(['净增关注', '新增关注']);
  204. const fansTotalCol = colIndex(['关注者总数', '关注者总量', '粉丝总数', '粉丝总量']);
  205. for (let i = headerLineIdx + 1; i < lines.length; i++) {
  206. const cols = parseCsvLine(lines[i]!).map((c) => c.replace(/^"|"$/g, '').trim());
  207. if (dateCol < 0 || cols.length <= dateCol) continue;
  208. const d = normalizeDateText(cols[dateCol]);
  209. if (!d) continue;
  210. const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  211. if (!result.has(key)) result.set(key, { recordDate: d });
  212. const obj = result.get(key)!;
  213. if (playCol >= 0 && cols.length > playCol) {
  214. const n = parseChineseNumberLike(cols[playCol]);
  215. if (typeof n === 'number') (obj as any).playCount = n;
  216. }
  217. if (likeCol >= 0 && cols.length > likeCol) {
  218. const n = parseChineseNumberLike(cols[likeCol]);
  219. if (typeof n === 'number') (obj as any).likeCount = n;
  220. }
  221. if (commentCol >= 0 && cols.length > commentCol) {
  222. const n = parseChineseNumberLike(cols[commentCol]);
  223. if (typeof n === 'number') (obj as any).commentCount = n;
  224. }
  225. if (shareCol >= 0 && cols.length > shareCol) {
  226. const n = parseChineseNumberLike(cols[shareCol]);
  227. if (typeof n === 'number') (obj as any).shareCount = n;
  228. }
  229. if (fansIncCol >= 0 && cols.length > fansIncCol) {
  230. const n = parseChineseNumberLike(cols[fansIncCol]);
  231. if (typeof n === 'number') (obj as any).fansIncrease = n;
  232. }
  233. if (fansTotalCol >= 0 && cols.length > fansTotalCol) {
  234. const n = parseChineseNumberLike(cols[fansTotalCol]);
  235. if (typeof n === 'number') (obj as any).fansCount = n;
  236. }
  237. }
  238. return result;
  239. }
  240. // xlsx/xls:走 xlsx 解析
  241. const wb = XLSX.readFile(filePath);
  242. const result = new Map<string, { recordDate: Date } & Record<string, any>>();
  243. logger.info(`[WX Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`);
  244. for (const sheetName of wb.SheetNames) {
  245. const sheet = wb.Sheets[sheetName];
  246. const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
  247. if (!rows.length) continue;
  248. let headerIdx = rows.findIndex(
  249. (r) => Array.isArray(r) && r.some((c) => ['时间', '日期'].includes(String(c).trim()))
  250. );
  251. if (headerIdx < 0) continue;
  252. const header = rows[headerIdx].map((c) => String(c).trim());
  253. logger.info(`[WX Import] Header detected. sheet=${sheetName} headerRow=${headerIdx + 1} headers=${header.join('|')}`);
  254. const colIndex = (names: string[]) => {
  255. for (const n of names) {
  256. const idx = header.findIndex((h) => h === n);
  257. if (idx >= 0) return idx;
  258. }
  259. for (const n of names) {
  260. const idx = header.findIndex((h) => h.includes(n));
  261. if (idx >= 0) return idx;
  262. }
  263. return -1;
  264. };
  265. const dateCol = colIndex(['时间', '日期']);
  266. if (dateCol < 0) continue;
  267. const playCol = colIndex(['播放', '播放量', '曝光量', '阅读/播放量', '阅读量']);
  268. const likeCol = colIndex(['喜欢', '点赞', '点赞量']);
  269. const commentCol = colIndex(['评论', '评论量']);
  270. const shareCol = colIndex(['分享', '分享量']);
  271. const fansIncCol = colIndex(['净增关注', '新增关注']);
  272. const fansTotalCol = colIndex(['关注者总数', '关注者总量', '粉丝总数', '粉丝总量']);
  273. for (let i = headerIdx + 1; i < rows.length; i++) {
  274. const r = rows[i];
  275. if (!r || !Array.isArray(r) || r.length <= dateCol) continue;
  276. const d = normalizeDateText(r[dateCol]);
  277. if (!d) continue;
  278. const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  279. if (!result.has(key)) result.set(key, { recordDate: d });
  280. const obj = result.get(key)!;
  281. if (playCol >= 0) {
  282. const n = parseChineseNumberLike(r[playCol]);
  283. if (typeof n === 'number') (obj as any).playCount = n;
  284. }
  285. if (likeCol >= 0) {
  286. const n = parseChineseNumberLike(r[likeCol]);
  287. if (typeof n === 'number') (obj as any).likeCount = n;
  288. }
  289. if (commentCol >= 0) {
  290. const n = parseChineseNumberLike(r[commentCol]);
  291. if (typeof n === 'number') (obj as any).commentCount = n;
  292. }
  293. if (shareCol >= 0) {
  294. const n = parseChineseNumberLike(r[shareCol]);
  295. if (typeof n === 'number') (obj as any).shareCount = n;
  296. }
  297. if (fansIncCol >= 0) {
  298. const n = parseChineseNumberLike(r[fansIncCol]);
  299. if (typeof n === 'number') (obj as any).fansIncrease = n;
  300. }
  301. if (fansTotalCol >= 0) {
  302. const n = parseChineseNumberLike(r[fansTotalCol]);
  303. if (typeof n === 'number') (obj as any).fansCount = n;
  304. }
  305. }
  306. }
  307. return result;
  308. }
  309. export class WeixinVideoDataCenterImportService {
  310. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  311. private userDayStatisticsService = new UserDayStatisticsService();
  312. // 兼容 monorepo 从根目录/从 server 目录启动
  313. private baseDir =
  314. path.basename(process.cwd()).toLowerCase() === 'server'
  315. ? process.cwd()
  316. : path.resolve(process.cwd(), 'server');
  317. private downloadDir = path.resolve(this.baseDir, 'tmp', 'weixin-video-data-center');
  318. private stateDir = path.resolve(this.baseDir, 'tmp', 'weixin-video-storage-state');
  319. private getStatePath(accountId: number) {
  320. return path.join(this.stateDir, `${accountId}.json`);
  321. }
  322. private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
  323. const statePath = this.getStatePath(account.id);
  324. try {
  325. await fs.access(statePath);
  326. return statePath;
  327. } catch {
  328. // no state
  329. }
  330. if (!(process.env.WX_IMPORT_HEADLESS === '0' && process.env.WX_STORAGE_STATE_BOOTSTRAP === '1')) {
  331. return null;
  332. }
  333. await ensureDir(this.stateDir);
  334. logger.warn(`[WX Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`);
  335. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  336. try {
  337. const context = await browser.newContext({
  338. viewport: { width: 1920, height: 1080 },
  339. locale: 'zh-CN',
  340. timezoneId: 'Asia/Shanghai',
  341. });
  342. await context.addCookies(cookies as any);
  343. const page = await context.newPage();
  344. await page.goto('https://channels.weixin.qq.com/platform', { waitUntil: 'domcontentloaded' });
  345. await page
  346. .waitForFunction(() => {
  347. const t = document.body?.innerText || '';
  348. return t.includes('数据中心') || t.includes('关注者数据') || t.includes('视频数据');
  349. }, { timeout: 5 * 60_000 })
  350. .catch(() => undefined);
  351. await context.storageState({ path: statePath });
  352. logger.info(`[WX Import] storageState saved: ${statePath}`);
  353. await context.close();
  354. return statePath;
  355. } finally {
  356. if (shouldClose) await browser.close().catch(() => undefined);
  357. }
  358. }
  359. async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
  360. await ensureDir(this.downloadDir);
  361. const accounts = await this.accountRepository.find({ where: { platform: 'weixin_video' as any } });
  362. logger.info(`[WX Import] Start. total_accounts=${accounts.length}`);
  363. for (const account of accounts) {
  364. try {
  365. await this.importAccountLast30Days(account);
  366. } catch (e) {
  367. logger.error(`[WX Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
  368. }
  369. }
  370. logger.info('[WX Import] Done.');
  371. }
  372. async importAccountLast30Days(account: PlatformAccount): Promise<void> {
  373. const cookies = parseCookiesFromAccount(account.cookieData);
  374. if (!cookies.length) throw new Error('cookieData 为空或无法解析');
  375. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  376. try {
  377. const statePath = await this.ensureStorageState(account, cookies);
  378. logger.info(
  379. `[WX Import] Context init. accountId=${account.id} storageState=${statePath ? statePath : 'none'}`
  380. );
  381. const context = await browser.newContext({
  382. acceptDownloads: true,
  383. viewport: { width: 1920, height: 1080 },
  384. locale: 'zh-CN',
  385. timezoneId: 'Asia/Shanghai',
  386. userAgent:
  387. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  388. ...(statePath ? { storageState: statePath } : {}),
  389. });
  390. context.setDefaultTimeout(60_000);
  391. if (!statePath) await context.addCookies(cookies as any);
  392. const page = await context.newPage();
  393. await page.goto('https://channels.weixin.qq.com/platform', { waitUntil: 'domcontentloaded' });
  394. await page.waitForTimeout(1500);
  395. if (page.url().includes('login') || page.url().includes('passport')) {
  396. throw new Error('未登录/需要重新登录(跳转到登录页)');
  397. }
  398. // 进入 数据中心
  399. await page.getByText('数据中心', { exact: false }).first().click();
  400. await page.waitForTimeout(800);
  401. // 目前只需要关注者数据 + 视频数据,图文数据暂不采集
  402. const sections: WxSection[] = ['关注者数据', '视频数据'];
  403. let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
  404. const tryClick = async (texts: string[]) => {
  405. for (const t of texts) {
  406. const loc = page.getByText(t, { exact: true }).first();
  407. if ((await loc.count().catch(() => 0)) > 0) {
  408. await loc.click().catch(() => undefined);
  409. return true;
  410. }
  411. }
  412. for (const t of texts) {
  413. const loc = page.getByText(t, { exact: false }).first();
  414. if ((await loc.count().catch(() => 0)) > 0) {
  415. await loc.click().catch(() => undefined);
  416. return true;
  417. }
  418. }
  419. return false;
  420. };
  421. const exportSection = async (section: WxSection) => {
  422. const ok = await tryClick([section]);
  423. if (!ok) {
  424. logger.warn(`[WX Import] Section not found, skip. accountId=${account.id} section=${section}`);
  425. return;
  426. }
  427. await page.waitForTimeout(1200);
  428. // 进入 增长详情/数据详情(页面上可能显示“增长详情”或“数据详情”)
  429. await tryClick(['增长详情', '数据详情']);
  430. await page.waitForTimeout(800);
  431. // 日期范围:点击「近30天」
  432. try {
  433. if (section === '关注者数据') {
  434. const loc = page.locator(
  435. '#container-wrap > div.container-center > div > div > div.follower-growth-wrap > div:nth-child(4) > div > div > div.card-body > div.filter-wrap > div > div.filter-content > div > div > div.weui-desktop-radio-group.radio-group > label:nth-child(2)'
  436. );
  437. if ((await loc.count().catch(() => 0)) > 0) {
  438. await loc.click().catch(() => undefined);
  439. } else {
  440. await tryClick(['近30天', '近30日', '近30']);
  441. }
  442. } else if (section === '视频数据') {
  443. const loc = page.locator(
  444. '#container-wrap > div.container-center > div > div > div > div.post-total-wrap > div.post-statistic-common > div:nth-child(3) > div > div > div.card-body > div.filter-wrap > div:nth-child(2) > div.filter-content > div > div > div.weui-desktop-radio-group.radio-group > label:nth-child(2)'
  445. );
  446. if ((await loc.count().catch(() => 0)) > 0) {
  447. await loc.click().catch(() => undefined);
  448. } else {
  449. await tryClick(['近30天', '近30日', '近30']);
  450. }
  451. } else {
  452. await tryClick(['近30天', '近30日', '近30']);
  453. }
  454. } catch {
  455. await tryClick(['近30天', '近30日', '近30']);
  456. }
  457. await page.waitForTimeout(4000);
  458. // 下载表格
  459. const [download] = await Promise.all([
  460. page.waitForEvent('download', { timeout: 60_000 }),
  461. tryClick(['下载表格', '下载', '导出数据']),
  462. ]);
  463. const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
  464. const filePath = path.join(this.downloadDir, filename);
  465. await download.saveAs(filePath);
  466. try {
  467. const perDay = await parseWeixinVideoFile(filePath);
  468. for (const [k, v] of perDay.entries()) {
  469. if (!mergedDays.has(k)) mergedDays.set(k, { recordDate: v.recordDate });
  470. Object.assign(mergedDays.get(k)!, v);
  471. }
  472. logger.info(`[WX Import] Section parsed. accountId=${account.id} section=${section} days=${perDay.size}`);
  473. } finally {
  474. if (process.env.KEEP_WX_XLSX === 'true') {
  475. logger.warn(`[WX Import] KEEP_WX_XLSX=true, keep file: ${filePath}`);
  476. } else {
  477. await fs.unlink(filePath).catch(() => undefined);
  478. }
  479. }
  480. };
  481. for (const s of sections) {
  482. await exportSection(s);
  483. }
  484. let inserted = 0;
  485. let updated = 0;
  486. for (const v of mergedDays.values()) {
  487. const { recordDate, ...patch } = v;
  488. const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
  489. inserted += r.inserted;
  490. updated += r.updated;
  491. }
  492. logger.info(`[WX Import] Account imported. accountId=${account.id} days=${mergedDays.size} inserted=${inserted} updated=${updated}`);
  493. await context.close();
  494. } finally {
  495. if (shouldClose) await browser.close().catch(() => undefined);
  496. }
  497. }
  498. }