DouyinAccountOverviewImportService.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  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 { AccountService } from './AccountService.js';
  10. import type { ProxyConfig } from '@media-manager/shared';
  11. import { WS_EVENTS } from '@media-manager/shared';
  12. import { wsManager } from '../websocket/index.js';
  13. // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
  14. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  15. const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any);
  16. type PlaywrightCookie = {
  17. name: string;
  18. value: string;
  19. domain?: string;
  20. path?: string;
  21. url?: string;
  22. expires?: number;
  23. httpOnly?: boolean;
  24. secure?: boolean;
  25. sameSite?: 'Lax' | 'None' | 'Strict';
  26. };
  27. function ensureDir(p: string) {
  28. return fs.mkdir(p, { recursive: true });
  29. }
  30. function normalizeDateText(input: unknown): Date | null {
  31. if (!input) return null;
  32. if (input instanceof Date && !Number.isNaN(input.getTime())) {
  33. const d = new Date(input);
  34. d.setHours(0, 0, 0, 0);
  35. return d;
  36. }
  37. const s = String(input).trim();
  38. if (!s) return null;
  39. // 2026-01-27 / 2026/01/27
  40. const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})/);
  41. if (m1) {
  42. const yyyy = Number(m1[1]);
  43. const mm = Number(m1[2]);
  44. const dd = Number(m1[3]);
  45. if (!yyyy || !mm || !dd) return null;
  46. const d = new Date(yyyy, mm - 1, dd);
  47. d.setHours(0, 0, 0, 0);
  48. return d;
  49. }
  50. // 01-27(兜底:用当前年份)
  51. const m2 = s.match(/^(\d{1,2})[-/](\d{1,2})$/);
  52. if (m2) {
  53. const yyyy = new Date().getFullYear();
  54. const mm = Number(m2[1]);
  55. const dd = Number(m2[2]);
  56. const d = new Date(yyyy, mm - 1, dd);
  57. d.setHours(0, 0, 0, 0);
  58. return d;
  59. }
  60. return null;
  61. }
  62. function parseChineseNumberLike(input: unknown): number | null {
  63. if (input === null || input === undefined) return null;
  64. const s = String(input).trim();
  65. if (!s) return null;
  66. // 8,077
  67. const plain = s.replace(/,/g, '');
  68. // 4.8万
  69. const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
  70. if (wan) return Math.round(Number(wan[1]) * 10000);
  71. const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
  72. if (yi) return Math.round(Number(yi[1]) * 100000000);
  73. const n = Number(plain.replace(/[^\d.-]/g, ''));
  74. if (Number.isFinite(n)) return Math.round(n);
  75. return null;
  76. }
  77. function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  78. if (!cookieData) return [];
  79. const raw = cookieData.trim();
  80. if (!raw) return [];
  81. // 1) JSON array(最常见:浏览器插件导出/前端保存)
  82. if (raw.startsWith('[') || raw.startsWith('{')) {
  83. try {
  84. const parsed = JSON.parse(raw);
  85. const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
  86. if (!Array.isArray(arr)) return [];
  87. return arr
  88. .map((c: any) => {
  89. const name = String(c?.name ?? '').trim();
  90. const value = String(c?.value ?? '').trim();
  91. if (!name) return null;
  92. const domain = c?.domain ? String(c.domain) : undefined;
  93. const pathVal = c?.path ? String(c.path) : '/';
  94. const url = !domain ? 'https://creator.douyin.com' : undefined;
  95. const sameSiteRaw = c?.sameSite;
  96. const sameSite =
  97. sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
  98. ? sameSiteRaw
  99. : undefined;
  100. return {
  101. name,
  102. value,
  103. domain,
  104. path: pathVal,
  105. url,
  106. expires: typeof c?.expires === 'number' ? c.expires : undefined,
  107. httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
  108. secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
  109. sameSite,
  110. } satisfies PlaywrightCookie;
  111. })
  112. .filter(Boolean) as PlaywrightCookie[];
  113. } catch {
  114. // fallthrough
  115. }
  116. }
  117. // 2) "a=b; c=d" 拼接格式
  118. const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  119. const cookies: PlaywrightCookie[] = [];
  120. for (const p of pairs) {
  121. const idx = p.indexOf('=');
  122. if (idx <= 0) continue;
  123. const name = p.slice(0, idx).trim();
  124. const value = p.slice(idx + 1).trim();
  125. if (!name) continue;
  126. cookies.push({ name, value, url: 'https://creator.douyin.com' });
  127. }
  128. return cookies;
  129. }
  130. async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  131. // 静默同步:默认一律 headless,不弹窗
  132. // 只有在“引导登录/验证”时(DY_STORAGE_STATE_BOOTSTRAP=1 且 DY_IMPORT_HEADLESS=0)才允许 headful
  133. const allowHeadfulForBootstrap = process.env.DY_STORAGE_STATE_BOOTSTRAP === '1' && process.env.DY_IMPORT_HEADLESS === '0';
  134. const headless = !allowHeadfulForBootstrap;
  135. if (proxy?.enabled) {
  136. const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
  137. const browser = await chromium.launch({
  138. headless,
  139. proxy: {
  140. server,
  141. username: proxy.username,
  142. password: proxy.password,
  143. },
  144. args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
  145. });
  146. return { browser, shouldClose: true };
  147. }
  148. const browser = await BrowserManager.getBrowser({ headless });
  149. return { browser, shouldClose: false };
  150. }
  151. function parseDouyinExcel(
  152. filePath: string
  153. ): Map<string, { recordDate: Date } & Record<string, any>> {
  154. const wb = XLSX.readFile(filePath);
  155. const result = new Map<string, { recordDate: Date } & Record<string, any>>();
  156. logger.info(
  157. `[DY Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`
  158. );
  159. for (const sheetName of wb.SheetNames) {
  160. const sheet = wb.Sheets[sheetName];
  161. const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
  162. if (!rows.length) {
  163. logger.warn(`[DY Import] Sheet empty. name=${sheetName}`);
  164. continue;
  165. }
  166. const keys = Object.keys(rows[0] || {});
  167. logger.info(`[DY Import] Sheet parsed. name=${sheetName} rows=${rows.length} keys=${keys.join(',')}`);
  168. const normalizeKey = (k: string) => k.replace(/^\uFEFF/, '').trim();
  169. for (const row of rows) {
  170. const rawKeys = Object.keys(row || {});
  171. if (!rawKeys.length) continue;
  172. const keysNormalized = rawKeys.map((k) => ({ raw: k, norm: normalizeKey(k) }));
  173. // 兼容 Excel 表头带 BOM/空格:优先找包含“日期”的列作为日期列
  174. const dateKey =
  175. keysNormalized.find((k) => k.norm === '日期')?.raw ??
  176. keysNormalized.find((k) => k.norm.includes('日期'))?.raw ??
  177. keysNormalized.find((k) => k.norm.toLowerCase() === 'date')?.raw ??
  178. keysNormalized[0]!.raw;
  179. const dateVal = (row as any)[dateKey];
  180. const d = normalizeDateText(dateVal);
  181. if (!d) continue;
  182. const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  183. if (!result.has(key)) result.set(key, { recordDate: d });
  184. const obj = result.get(key)!;
  185. // 抖音导出的 Excel 通常是两列:日期 + 指标名(如“作品分享/净增粉丝/作品点赞/播放量...”)
  186. // 因此优先按“第二列标题”做自动映射,避免漏掉“沈凉音”这种全量导出格式。
  187. const metricKeyRaw = keysNormalized.find((k) => k.raw !== dateKey)?.raw;
  188. if (!metricKeyRaw) continue;
  189. const metricKey = normalizeKey(metricKeyRaw);
  190. // 显式排除:主页访问 / 取关粉丝
  191. if (metricKey.includes('主页访问') || metricKey.includes('取关粉丝')) continue;
  192. const rawVal = (row as any)[metricKeyRaw];
  193. if (rawVal === undefined || rawVal === null) continue;
  194. // 1)封面点击率:字符串百分比直接存
  195. if (metricKey.includes('封面点击率')) {
  196. const s = String(rawVal).trim();
  197. if (s) (obj as any).coverClickRate = s;
  198. continue;
  199. }
  200. // 2)其余按数值解析
  201. const n = parseChineseNumberLike(rawVal);
  202. if (typeof n !== 'number') continue;
  203. if (metricKey.includes('播放')) (obj as any).playCount = n;
  204. else if (metricKey.includes('点赞')) (obj as any).likeCount = n;
  205. else if (metricKey.includes('评论')) (obj as any).commentCount = n;
  206. else if (metricKey.includes('分享')) (obj as any).shareCount = n;
  207. else if (metricKey.includes('净增粉丝') || metricKey.includes('新增粉丝')) (obj as any).fansIncrease = n;
  208. // 总粉丝数/总粉丝量:入库 fans_count
  209. else if (metricKey.includes('总粉丝')) (obj as any).fansCount = n;
  210. }
  211. }
  212. return result;
  213. }
  214. export class DouyinAccountOverviewImportService {
  215. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  216. private userDayStatisticsService = new UserDayStatisticsService();
  217. private downloadDir = path.resolve(process.cwd(), 'tmp', 'douyin-account-overview');
  218. private stateDir = path.resolve(process.cwd(), 'tmp', 'douyin-storage-state');
  219. private getStatePath(accountId: number) {
  220. return path.join(this.stateDir, `${accountId}.json`);
  221. }
  222. private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
  223. const statePath = this.getStatePath(account.id);
  224. try {
  225. await fs.access(statePath);
  226. return statePath;
  227. } catch {
  228. // no state
  229. }
  230. // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState
  231. // 启用方式:DY_IMPORT_HEADLESS=0 且 DY_STORAGE_STATE_BOOTSTRAP=1
  232. if (!(process.env.DY_IMPORT_HEADLESS === '0' && process.env.DY_STORAGE_STATE_BOOTSTRAP === '1')) {
  233. return null;
  234. }
  235. await ensureDir(this.stateDir);
  236. logger.warn(
  237. `[DY Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`
  238. );
  239. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  240. try {
  241. const context = await browser.newContext({
  242. viewport: { width: 1920, height: 1080 },
  243. locale: 'zh-CN',
  244. timezoneId: 'Asia/Shanghai',
  245. });
  246. await context.addCookies(cookies as any);
  247. const page = await context.newPage();
  248. await page.goto('https://creator.douyin.com/creator-micro/data-center/operation', {
  249. waitUntil: 'domcontentloaded',
  250. });
  251. // 最长等 5 分钟:让你手动完成登录/滑块/短信等
  252. await page
  253. .waitForFunction(() => {
  254. const t = document.body?.innerText || '';
  255. return t.includes('数据中心') || t.includes('账号总览') || t.includes('短视频');
  256. }, { timeout: 5 * 60_000 })
  257. .catch(() => undefined);
  258. await context.storageState({ path: statePath });
  259. logger.info(`[DY Import] storageState saved: ${statePath}`);
  260. await context.close();
  261. return statePath;
  262. } finally {
  263. if (shouldClose) await browser.close().catch(() => undefined);
  264. }
  265. }
  266. /**
  267. * 统一入口:定时任务与添加账号均调用此方法,执行“账号总览-短视频-数据表现-近30天”
  268. */
  269. static async runDailyImport(): Promise<void> {
  270. const svc = new DouyinAccountOverviewImportService();
  271. await svc.runDailyImportForAllDouyinAccounts();
  272. }
  273. /**
  274. * 为所有抖音账号导出“账号总览-短视频-数据表现-近30天”并导入 user_day_statistics
  275. */
  276. async runDailyImportForAllDouyinAccounts(): Promise<void> {
  277. await ensureDir(this.downloadDir);
  278. const accounts = await this.accountRepository.find({
  279. where: { platform: 'douyin' as any },
  280. });
  281. logger.info(`[DY Import] Start. total_accounts=${accounts.length}`);
  282. for (const account of accounts) {
  283. try {
  284. await this.importAccountLast30Days(account);
  285. } catch (e) {
  286. logger.error(
  287. `[DY Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
  288. e
  289. );
  290. }
  291. }
  292. logger.info('[DY Import] Done.');
  293. }
  294. /**
  295. * 单账号:导出 Excel → 解析 → 入库 → 删除文件
  296. */
  297. async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
  298. const cookies = parseCookiesFromAccount(account.cookieData);
  299. if (!cookies.length) {
  300. throw new Error('cookieData 为空或无法解析');
  301. }
  302. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  303. try {
  304. const statePath = await this.ensureStorageState(account, cookies);
  305. const context = await browser.newContext({
  306. acceptDownloads: true,
  307. viewport: { width: 1920, height: 1080 },
  308. locale: 'zh-CN',
  309. timezoneId: 'Asia/Shanghai',
  310. userAgent:
  311. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  312. ...(statePath ? { storageState: statePath } : {}),
  313. });
  314. context.setDefaultTimeout(60_000);
  315. // 如果没 state,就退回 cookie-only(可能导出为 0)
  316. if (!statePath) {
  317. await context.addCookies(cookies as any);
  318. }
  319. const page = await context.newPage();
  320. logger.info(`[DY Import] accountId=${account.id} goto data-center...`);
  321. await page.goto('https://creator.douyin.com/creator-micro/data-center/operation', {
  322. waitUntil: 'domcontentloaded',
  323. });
  324. await page.waitForTimeout(1500);
  325. if (page.url().includes('login')) {
  326. // 第一次检测到登录失效时,尝试刷新账号
  327. if (!isRetry) {
  328. logger.info(`[DY Import] Login expired detected for account ${account.id}, attempting to refresh...`);
  329. await context.close();
  330. if (shouldClose) await browser.close();
  331. try {
  332. const accountService = new AccountService();
  333. const refreshResult = await accountService.refreshAccount(account.userId, account.id);
  334. if (refreshResult.needReLogin) {
  335. // 刷新后仍需要重新登录,走原先的失效流程
  336. logger.warn(`[DY Import] Account ${account.id} refresh failed, still needs re-login`);
  337. throw new Error('未登录/需要重新登录(跳转到 login)');
  338. }
  339. // 刷新成功,重新获取账号信息并重试导入
  340. logger.info(`[DY Import] Account ${account.id} refreshed successfully, retrying import...`);
  341. const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
  342. if (!refreshedAccount) {
  343. throw new Error('账号刷新后未找到');
  344. }
  345. // 递归调用,标记为重试
  346. return await this.importAccountLast30Days(refreshedAccount, true);
  347. } catch (refreshError) {
  348. logger.error(`[DY Import] Account ${account.id} refresh failed:`, refreshError);
  349. throw new Error('未登录/需要重新登录(跳转到 login)');
  350. }
  351. } else {
  352. // 已经是重试了,不再尝试刷新
  353. throw new Error('未登录/需要重新登录(跳转到 login)');
  354. }
  355. }
  356. // 检测“暂无访问权限 / 权限申请中 / 暂无数据”提示:标记账号 expired + 推送提示
  357. const bodyText = (await page.textContent('body').catch(() => '')) || '';
  358. if (
  359. bodyText.includes('暂无访问权限') ||
  360. bodyText.includes('权限申请中') ||
  361. bodyText.includes('暂无数据权限') ||
  362. bodyText.includes('暂无数据,请稍后再试')
  363. ) {
  364. await this.accountRepository.update(account.id, { status: 'expired' as any });
  365. wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
  366. account: { id: account.id, status: 'expired', platform: 'douyin' },
  367. });
  368. wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
  369. level: 'warning',
  370. message: `抖音账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到抖音创作者中心申请数据权限(通过后一般次日生效)。`,
  371. platform: 'douyin',
  372. accountId: account.id,
  373. });
  374. throw new Error('抖音数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
  375. }
  376. // 已直达账号总览页(data-center/operation),无需再点「数据中心/账号总览」,直接点「短视频」和「近30天」
  377. await page.waitForTimeout(500);
  378. logger.info(`[DY Import] accountId=${account.id} on 账号总览页, click 短视频 tab (#semiTabaweme)...`);
  379. const shortVideoById = page.locator('#semiTabaweme');
  380. if ((await shortVideoById.count().catch(() => 0)) > 0) {
  381. await shortVideoById.first().click();
  382. } else {
  383. const shortVideoCandidates = ['短视频', '短视频数据'];
  384. let shortVideoClicked = false;
  385. for (const text of shortVideoCandidates) {
  386. const loc = page.getByText(text, { exact: false }).first();
  387. if ((await loc.count().catch(() => 0)) > 0) {
  388. await loc.click().catch(() => undefined);
  389. shortVideoClicked = true;
  390. break;
  391. }
  392. }
  393. if (!shortVideoClicked) {
  394. throw new Error('页面上未找到「短视频」入口,请确认抖音创作者后台是否改版');
  395. }
  396. }
  397. // 切换“近30天”(优先用 ID #addon-aoc08fi,兜底文案)
  398. await page.waitForTimeout(500);
  399. logger.info(`[DY Import] accountId=${account.id} click 近30天 (#addon-aoc08fi)...`);
  400. const last30DaysById = page.locator('#addon-aoc08fi');
  401. if ((await last30DaysById.count().catch(() => 0)) > 0) {
  402. await last30DaysById.first().click();
  403. } else {
  404. await page.getByText(/近\d+天?/).first().click().catch(() => undefined);
  405. await page.getByText('近30天', { exact: true }).click();
  406. }
  407. await page.waitForTimeout(1200);
  408. // 逐个指标导出(排除:主页访问 / 取关粉丝)
  409. // 说明:抖音导出通常是“日期 + 指标”两列,每次只能导出当前选中的指标
  410. // 注意:抖音 UI 上“总粉丝”文案可能是「总粉丝量」而不是「总粉丝数」
  411. const metricsToExport: Array<{ name: string; candidates: string[] }> = [
  412. { name: '播放量', candidates: ['播放量'] },
  413. { name: '作品点赞', candidates: ['作品点赞', '点赞'] },
  414. { name: '作品评论', candidates: ['作品评论', '评论'] },
  415. { name: '作品分享', candidates: ['作品分享', '分享'] },
  416. { name: '封面点击率', candidates: ['封面点击率'] },
  417. { name: '净增粉丝', candidates: ['净增粉丝', '新增粉丝'] },
  418. { name: '总粉丝量', candidates: ['总粉丝量', '总粉丝数', '粉丝总量'] },
  419. ];
  420. let totalInserted = 0;
  421. let totalUpdated = 0;
  422. let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
  423. const savedExcelPaths: string[] = [];
  424. const clickMetric = async (metric: { name: string; candidates: string[] }) => {
  425. // 先精确匹配,失败后用包含匹配(适配 UI 文案差异)
  426. for (const c of metric.candidates) {
  427. const locatorExact = page.getByText(c, { exact: true }).first();
  428. const exactCount = await locatorExact.count().catch(() => 0);
  429. if (exactCount > 0) {
  430. await locatorExact.click().catch(() => undefined);
  431. await page.waitForTimeout(800);
  432. return c;
  433. }
  434. }
  435. for (const c of metric.candidates) {
  436. const locatorFuzzy = page.getByText(c, { exact: false }).first();
  437. const fuzzyCount = await locatorFuzzy.count().catch(() => 0);
  438. if (fuzzyCount > 0) {
  439. await locatorFuzzy.click().catch(() => undefined);
  440. await page.waitForTimeout(800);
  441. return c;
  442. }
  443. }
  444. logger.warn(`[DY Import] metric not found on page. accountId=${account.id} metric=${metric.name}`);
  445. return null;
  446. };
  447. for (const metric of metricsToExport) {
  448. logger.info(`[DY Import] accountId=${account.id} exporting metric: ${metric.name}...`);
  449. await clickMetric(metric);
  450. const [download] = await Promise.all([
  451. page.waitForEvent('download', { timeout: 60_000 }),
  452. page.getByText('导出数据', { exact: true }).first().click(),
  453. ]);
  454. const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
  455. const filePath = path.join(this.downloadDir, filename);
  456. await download.saveAs(filePath);
  457. // 保留 Excel 不删除,便于核对数据;路径打日志方便查看
  458. const absolutePath = path.resolve(filePath);
  459. savedExcelPaths.push(absolutePath);
  460. logger.info(
  461. `[DY Import] Excel saved (${metric.name}): ${absolutePath}`
  462. );
  463. try {
  464. const perDay = parseDouyinExcel(filePath);
  465. // 合并不同指标到同一日期 patch(与小红书维度一致)
  466. for (const [k, v] of perDay.entries()) {
  467. if (!mergedDays.has(k)) mergedDays.set(k, { recordDate: v.recordDate });
  468. const base = mergedDays.get(k)!;
  469. Object.assign(base, v);
  470. }
  471. logger.info(
  472. `[DY Import] metric exported & parsed. accountId=${account.id} metric=${metric.name} file=${path.basename(filePath)} days=${perDay.size}`
  473. );
  474. } finally {
  475. // 默认导入后删除 Excel,避免磁盘堆积;仅在显式 KEEP_DY_XLSX=true 时保留(用于调试)
  476. if (process.env.KEEP_DY_XLSX === 'true') {
  477. logger.warn(`[DY Import] KEEP_DY_XLSX=true, keep file: ${filePath}`);
  478. } else {
  479. await fs.unlink(filePath).catch(() => undefined);
  480. }
  481. }
  482. }
  483. // 汇总:本账号导出的 7 个 Excel 已解析
  484. logger.info(
  485. `[DY Import] accountId=${account.id} 共 ${savedExcelPaths.length} 个 Excel 已解析`
  486. );
  487. if (savedExcelPaths.length !== 7) {
  488. logger.warn(`[DY Import] accountId=${account.id} 预期 7 个 Excel,实际 ${savedExcelPaths.length} 个`);
  489. }
  490. // 合并完成后统一入库(避免同一天多次 update)
  491. for (const v of mergedDays.values()) {
  492. const { recordDate, ...patch } = v;
  493. const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
  494. totalInserted += r.inserted;
  495. totalUpdated += r.updated;
  496. }
  497. logger.info(
  498. `[DY Import] short-video imported. accountId=${account.id} days=${mergedDays.size} inserted=${totalInserted} updated=${totalUpdated}`
  499. );
  500. await context.close();
  501. } finally {
  502. if (shouldClose) {
  503. await browser.close().catch(() => undefined);
  504. }
  505. }
  506. }
  507. }