DouyinAccountOverviewImportService.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import type { Browser } from 'playwright';
  4. import { AppDataSource, PlatformAccount } from '../models/index.js';
  5. import { BrowserManager } from '../automation/browser.js';
  6. import { logger } from '../utils/logger.js';
  7. import { UserDayStatisticsService } from './UserDayStatisticsService.js';
  8. import { AccountService } from './AccountService.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. import { launchBrowser } from '../automation/browserProvider.js';
  13. type PlaywrightCookie = {
  14. name: string;
  15. value: string;
  16. domain?: string;
  17. path?: string;
  18. url?: string;
  19. expires?: number;
  20. httpOnly?: boolean;
  21. secure?: boolean;
  22. sameSite?: 'Lax' | 'None' | 'Strict';
  23. };
  24. function ensureDir(p: string) {
  25. return fs.mkdir(p, { recursive: true });
  26. }
  27. function normalizeDateText(input: unknown): Date | null {
  28. if (!input) return null;
  29. if (input instanceof Date && !Number.isNaN(input.getTime())) {
  30. const d = new Date(input);
  31. d.setHours(0, 0, 0, 0);
  32. return d;
  33. }
  34. const s = String(input).trim();
  35. if (!s) return null;
  36. // 2026-01-27 / 2026/01/27
  37. const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})/);
  38. if (m1) {
  39. const yyyy = Number(m1[1]);
  40. const mm = Number(m1[2]);
  41. const dd = Number(m1[3]);
  42. if (!yyyy || !mm || !dd) return null;
  43. const d = new Date(yyyy, mm - 1, dd);
  44. d.setHours(0, 0, 0, 0);
  45. return d;
  46. }
  47. // 01-27(兜底:用当前年份)
  48. const m2 = s.match(/^(\d{1,2})[-/](\d{1,2})$/);
  49. if (m2) {
  50. const yyyy = new Date().getFullYear();
  51. const mm = Number(m2[1]);
  52. const dd = Number(m2[2]);
  53. const d = new Date(yyyy, mm - 1, dd);
  54. d.setHours(0, 0, 0, 0);
  55. return d;
  56. }
  57. return null;
  58. }
  59. function toRatePercentStringFromValue(val: unknown): string | undefined {
  60. const n = typeof val === 'number' ? val : Number(val);
  61. if (!Number.isFinite(n)) return undefined;
  62. if (n === 0) return '0';
  63. const scaled = n * 100;
  64. const rounded = Math.round(scaled * 100) / 100;
  65. const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
  66. return `${s}%`;
  67. }
  68. function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  69. if (!cookieData) return [];
  70. const raw = cookieData.trim();
  71. if (!raw) return [];
  72. // 1) JSON array(最常见:浏览器插件导出/前端保存)
  73. if (raw.startsWith('[') || raw.startsWith('{')) {
  74. try {
  75. const parsed = JSON.parse(raw);
  76. const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
  77. if (!Array.isArray(arr)) return [];
  78. return arr
  79. .map((c: any) => {
  80. const name = String(c?.name ?? '').trim();
  81. const value = String(c?.value ?? '').trim();
  82. if (!name) return null;
  83. const domain = c?.domain ? String(c.domain) : undefined;
  84. const pathVal = c?.path ? String(c.path) : '/';
  85. const url = !domain ? 'https://creator.douyin.com' : undefined;
  86. const sameSiteRaw = c?.sameSite;
  87. const sameSite =
  88. sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
  89. ? sameSiteRaw
  90. : undefined;
  91. return {
  92. name,
  93. value,
  94. domain,
  95. path: pathVal,
  96. url,
  97. expires: typeof c?.expires === 'number' ? c.expires : undefined,
  98. httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
  99. secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
  100. sameSite,
  101. } satisfies PlaywrightCookie;
  102. })
  103. .filter(Boolean) as PlaywrightCookie[];
  104. } catch {
  105. // fallthrough
  106. }
  107. }
  108. // 2) "a=b; c=d" 拼接格式
  109. const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  110. const cookies: PlaywrightCookie[] = [];
  111. for (const p of pairs) {
  112. const idx = p.indexOf('=');
  113. if (idx <= 0) continue;
  114. const name = p.slice(0, idx).trim();
  115. const value = p.slice(idx + 1).trim();
  116. if (!name) continue;
  117. cookies.push({ name, value, url: 'https://creator.douyin.com' });
  118. }
  119. return cookies;
  120. }
  121. async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  122. // 静默同步:默认一律 headless,不弹窗
  123. // 只有在“引导登录/验证”时(DY_STORAGE_STATE_BOOTSTRAP=1 且 DY_IMPORT_HEADLESS=0)才允许 headful
  124. const allowHeadfulForBootstrap = process.env.DY_STORAGE_STATE_BOOTSTRAP === '1' && process.env.DY_IMPORT_HEADLESS === '0';
  125. const headless = !allowHeadfulForBootstrap;
  126. if (proxy?.enabled) {
  127. const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
  128. const browser = await launchBrowser({
  129. headless,
  130. proxy: {
  131. server,
  132. username: proxy.username,
  133. password: proxy.password,
  134. },
  135. args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
  136. });
  137. return { browser, shouldClose: true };
  138. }
  139. const browser = await BrowserManager.getBrowser({ headless });
  140. return { browser, shouldClose: false };
  141. }
  142. type DashboardMetricTrendPoint = {
  143. date_time?: string; // YYYYMMDD
  144. value?: number;
  145. douyin_value?: number;
  146. xigua_value?: number;
  147. yumme_value?: number;
  148. change_rate?: number;
  149. };
  150. type DashboardMetricItem = {
  151. english_metric_name?: string;
  152. metric_name?: string;
  153. metric_value?: number;
  154. trends?: DashboardMetricTrendPoint[];
  155. };
  156. type DashboardResponse = {
  157. status_code?: number;
  158. status_msg?: string;
  159. metrics?: DashboardMetricItem[];
  160. };
  161. function parseYmdCompactToDate(ymd: unknown): Date | null {
  162. const s = String(ymd || '').trim();
  163. if (!/^\d{8}$/.test(s)) return null;
  164. const yyyy = Number(s.slice(0, 4));
  165. const mm = Number(s.slice(4, 6));
  166. const dd = Number(s.slice(6, 8));
  167. if (!yyyy || !mm || !dd) return null;
  168. const d = new Date(yyyy, mm - 1, dd);
  169. d.setHours(0, 0, 0, 0);
  170. return d;
  171. }
  172. function pickTrendValue(pt: DashboardMetricTrendPoint): number | undefined {
  173. // 优先使用聚合 value;若不存在则兜底 douyin_value
  174. const v =
  175. typeof pt?.value === 'number'
  176. ? pt.value
  177. : typeof pt?.douyin_value === 'number'
  178. ? pt.douyin_value
  179. : undefined;
  180. if (!Number.isFinite(v as number)) return undefined;
  181. return v as number;
  182. }
  183. export class DouyinAccountOverviewImportService {
  184. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  185. private userDayStatisticsService = new UserDayStatisticsService();
  186. private stateDir = path.resolve(process.cwd(), 'tmp', 'douyin-storage-state');
  187. private getStatePath(accountId: number) {
  188. return path.join(this.stateDir, `${accountId}.json`);
  189. }
  190. private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
  191. const statePath = this.getStatePath(account.id);
  192. try {
  193. await fs.access(statePath);
  194. return statePath;
  195. } catch {
  196. // no state
  197. }
  198. // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState
  199. // 启用方式:DY_IMPORT_HEADLESS=0 且 DY_STORAGE_STATE_BOOTSTRAP=1
  200. if (!(process.env.DY_IMPORT_HEADLESS === '0' && process.env.DY_STORAGE_STATE_BOOTSTRAP === '1')) {
  201. return null;
  202. }
  203. await ensureDir(this.stateDir);
  204. logger.warn(
  205. `[DY Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`
  206. );
  207. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  208. try {
  209. const context = await browser.newContext({
  210. viewport: { width: 1920, height: 1080 },
  211. locale: 'zh-CN',
  212. timezoneId: 'Asia/Shanghai',
  213. });
  214. await context.addCookies(cookies as any);
  215. const page = await context.newPage();
  216. await page.goto('https://creator.douyin.com/creator-micro/data-center/operation', {
  217. waitUntil: 'domcontentloaded',
  218. });
  219. // 最长等 5 分钟:让你手动完成登录/滑块/短信等
  220. await page
  221. .waitForFunction(() => {
  222. const t = document.body?.innerText || '';
  223. return t.includes('数据中心') || t.includes('账号总览') || t.includes('短视频');
  224. }, { timeout: 5 * 60_000 })
  225. .catch(() => undefined);
  226. await context.storageState({ path: statePath });
  227. logger.info(`[DY Import] storageState saved: ${statePath}`);
  228. await context.close();
  229. return statePath;
  230. } finally {
  231. if (shouldClose) await browser.close().catch(() => undefined);
  232. }
  233. }
  234. /**
  235. * 统一入口:定时任务与添加账号均调用此方法,执行“账号总览-短视频-数据表现-近30天”
  236. */
  237. static async runDailyImport(): Promise<void> {
  238. const svc = new DouyinAccountOverviewImportService();
  239. await svc.runDailyImportForAllDouyinAccounts();
  240. }
  241. /**
  242. * 单账号入口:仅为指定抖音账号执行近30天账号总览导入(用于账号从失效恢复为 active 时补数)
  243. */
  244. static async runDailyImportForAccount(accountId: number): Promise<void> {
  245. const svc = new DouyinAccountOverviewImportService();
  246. const account = await svc.accountRepository.findOne({
  247. where: { id: accountId, platform: 'douyin' as any },
  248. });
  249. if (!account) {
  250. throw new Error(`未找到抖音账号 id=${accountId}`);
  251. }
  252. await svc.importAccountLast30Days(account);
  253. }
  254. /**
  255. * 为所有抖音账号导出“账号总览-短视频-数据表现-近30天”并导入 user_day_statistics
  256. */
  257. async runDailyImportForAllDouyinAccounts(): Promise<void> {
  258. const accounts = await this.accountRepository.find({
  259. where: { platform: 'douyin' as any },
  260. });
  261. logger.info(`[DY Import] Start. total_accounts=${accounts.length}`);
  262. for (const account of accounts) {
  263. try {
  264. await this.importAccountLast30Days(account);
  265. } catch (e) {
  266. logger.error(
  267. `[DY Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
  268. e
  269. );
  270. }
  271. }
  272. logger.info('[DY Import] Done.');
  273. }
  274. /**
  275. * 单账号:导出 Excel → 解析 → 入库 → 删除文件
  276. */
  277. async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
  278. const cookies = parseCookiesFromAccount(account.cookieData);
  279. if (!cookies.length) {
  280. throw new Error('cookieData 为空或无法解析');
  281. }
  282. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  283. try {
  284. const statePath = await this.ensureStorageState(account, cookies);
  285. const context = await browser.newContext({
  286. acceptDownloads: true,
  287. viewport: { width: 1920, height: 1080 },
  288. locale: 'zh-CN',
  289. timezoneId: 'Asia/Shanghai',
  290. userAgent:
  291. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  292. ...(statePath ? { storageState: statePath } : {}),
  293. });
  294. context.setDefaultTimeout(60_000);
  295. // 如果没 state,就退回 cookie-only(可能导出为 0)
  296. if (!statePath) {
  297. await context.addCookies(cookies as any);
  298. }
  299. const page = await context.newPage();
  300. logger.info(`[DY Import] accountId=${account.id} goto data-center...`);
  301. await page.goto('https://creator.douyin.com/creator-micro/data-center/operation', {
  302. waitUntil: 'domcontentloaded',
  303. });
  304. await page.waitForTimeout(1500);
  305. if (page.url().includes('login')) {
  306. // 第一次检测到登录失效时,尝试刷新账号
  307. if (!isRetry) {
  308. logger.info(`[DY Import] Login expired detected for account ${account.id}, attempting to refresh...`);
  309. await context.close();
  310. if (shouldClose) await browser.close();
  311. try {
  312. const accountService = new AccountService();
  313. const refreshResult = await accountService.refreshAccount(account.userId, account.id);
  314. if (refreshResult.needReLogin) {
  315. // 刷新后仍需要重新登录,走原先的失效流程
  316. logger.warn(`[DY Import] Account ${account.id} refresh failed, still needs re-login`);
  317. throw new Error('未登录/需要重新登录(跳转到 login)');
  318. }
  319. // 刷新成功,重新获取账号信息并重试导入
  320. logger.info(`[DY Import] Account ${account.id} refreshed successfully, retrying import...`);
  321. const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
  322. if (!refreshedAccount) {
  323. throw new Error('账号刷新后未找到');
  324. }
  325. // 递归调用,标记为重试
  326. return await this.importAccountLast30Days(refreshedAccount, true);
  327. } catch (refreshError) {
  328. logger.error(`[DY Import] Account ${account.id} refresh failed:`, refreshError);
  329. throw new Error('未登录/需要重新登录(跳转到 login)');
  330. }
  331. } else {
  332. // 已经是重试了,不再尝试刷新
  333. throw new Error('未登录/需要重新登录(跳转到 login)');
  334. }
  335. }
  336. // 检测“暂无访问权限 / 权限申请中 / 暂无数据”提示:标记账号 expired + 推送提示
  337. const bodyText = (await page.textContent('body').catch(() => '')) || '';
  338. if (
  339. bodyText.includes('暂无访问权限') ||
  340. bodyText.includes('权限申请中') ||
  341. bodyText.includes('暂无数据权限') ||
  342. bodyText.includes('暂无数据,请稍后再试')
  343. ) {
  344. await this.accountRepository.update(account.id, { status: 'expired' as any });
  345. wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
  346. account: { id: account.id, status: 'expired', platform: 'douyin' },
  347. });
  348. wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
  349. level: 'warning',
  350. message: `抖音账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到抖音创作者中心申请数据权限(通过后一般次日生效)。`,
  351. platform: 'douyin',
  352. accountId: account.id,
  353. });
  354. throw new Error('抖音数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
  355. }
  356. // 已直达账号总览页(data-center/operation),无需再点「数据中心/账号总览」,直接点「短视频」和「近30天」
  357. await page.waitForTimeout(500);
  358. logger.info(`[DY Import] accountId=${account.id} on 账号总览页, click 短视频 tab (#semiTabaweme)...`);
  359. const shortVideoById = page.locator('#semiTabaweme');
  360. if ((await shortVideoById.count().catch(() => 0)) > 0) {
  361. await shortVideoById.first().click();
  362. } else {
  363. const shortVideoCandidates = ['短视频', '短视频数据'];
  364. let shortVideoClicked = false;
  365. for (const text of shortVideoCandidates) {
  366. const loc = page.getByText(text, { exact: false }).first();
  367. if ((await loc.count().catch(() => 0)) > 0) {
  368. await loc.click().catch(() => undefined);
  369. shortVideoClicked = true;
  370. break;
  371. }
  372. }
  373. if (!shortVideoClicked) {
  374. throw new Error('页面上未找到「短视频」入口,请确认抖音创作者后台是否改版');
  375. }
  376. }
  377. // 切换“近30天”(优先用 ID #addon-aoc08fi,兜底文案)
  378. await page.waitForTimeout(500);
  379. logger.info(`[DY Import] accountId=${account.id} click 近30天 (#addon-aoc08fi)...`);
  380. const last30DaysById = page.locator('#addon-aoc08fi');
  381. if ((await last30DaysById.count().catch(() => 0)) > 0) {
  382. await last30DaysById.first().click();
  383. } else {
  384. await page.getByText(/近\d+天?/).first().click().catch(() => undefined);
  385. await page.getByText('近30天', { exact: true }).click();
  386. }
  387. await page.waitForTimeout(1200);
  388. let totalInserted = 0;
  389. let totalUpdated = 0;
  390. const apiUrl = 'https://creator.douyin.com/janus/douyin/creator/data/overview/dashboard';
  391. logger.info(`[DY Import] accountId=${account.id} fetch dashboard (POST recent_days=30)...`);
  392. // 优先监听页面自身是否会发起该请求;若没有,则在页面上下文里手动 fetch(浏览器自动带 cookie)
  393. const responsePromise = page
  394. .waitForResponse(
  395. (res) => res.request().method() === 'POST' && res.url().includes('/janus/douyin/creator/data/overview/dashboard'),
  396. { timeout: 8000 }
  397. )
  398. .catch(() => null);
  399. const evalPromise = page.evaluate(async (url) => {
  400. try {
  401. const r = await fetch(url, {
  402. method: 'POST',
  403. credentials: 'include',
  404. headers: {
  405. accept: 'application/json, text/plain, */*',
  406. 'content-type': 'application/json',
  407. },
  408. body: JSON.stringify({ recent_days: 30 }),
  409. });
  410. const json = await r.json().catch(() => null);
  411. return json;
  412. } catch (e: any) {
  413. return { status_code: -1, status_msg: String(e?.message || e) };
  414. }
  415. }, apiUrl);
  416. const [res, evalJson] = await Promise.all([responsePromise, evalPromise]);
  417. const body = ((res ? await res.json().catch(() => null) : null) ?? evalJson ?? null) as DashboardResponse | null;
  418. if (!body || typeof body !== 'object') {
  419. throw new Error('overview/dashboard 响应不是 JSON');
  420. }
  421. if (Number(body.status_code) !== 0) {
  422. throw new Error(`overview/dashboard 返回非成功: code=${body.status_code} msg=${body.status_msg || ''}`);
  423. }
  424. const metrics = Array.isArray(body.metrics) ? body.metrics : [];
  425. if (!metrics.length) {
  426. logger.warn(`[DY Import] dashboard metrics empty. accountId=${account.id}`);
  427. }
  428. const mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
  429. const setDay = (d: Date) => {
  430. const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  431. if (!mergedDays.has(key)) mergedDays.set(key, { recordDate: d });
  432. return mergedDays.get(key)!;
  433. };
  434. for (const m of metrics) {
  435. const en = String(m?.english_metric_name || '').trim();
  436. const trends = Array.isArray(m?.trends) ? m.trends : [];
  437. if (!en || !trends.length) continue;
  438. for (const pt of trends) {
  439. const d = parseYmdCompactToDate(pt?.date_time);
  440. if (!d) continue;
  441. const obj = setDay(d);
  442. const v = pickTrendValue(pt);
  443. if (v === undefined) continue;
  444. // 显式排除:主页访问 / 取关粉丝(库里没有对应字段)
  445. if (en === 'homepage_view_cnt' || en === 'cancel_fans_cnt') continue;
  446. if (en === 'total_fans_cnt') (obj as any).fansCount = Math.round(v);
  447. else if (en === 'play_cnt') (obj as any).playCount = Math.round(v);
  448. else if (en === 'digg_cnt') (obj as any).likeCount = Math.round(v);
  449. else if (en === 'comment_cnt') (obj as any).commentCount = Math.round(v);
  450. else if (en === 'share_count') (obj as any).shareCount = Math.round(v);
  451. else if (en === 'net_fans_cnt') (obj as any).fansIncrease = Math.round(v);
  452. else if (en === 'cover_click_ratio') {
  453. const s = toRatePercentStringFromValue(v);
  454. if (s != null) (obj as any).coverClickRate = s;
  455. }
  456. }
  457. }
  458. // 合并完成后统一入库(避免同一天多次 update)
  459. for (const v of mergedDays.values()) {
  460. const { recordDate, ...patch } = v;
  461. const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
  462. totalInserted += r.inserted;
  463. totalUpdated += r.updated;
  464. }
  465. logger.info(
  466. `[DY Import] short-video imported. accountId=${account.id} days=${mergedDays.size} inserted=${totalInserted} updated=${totalUpdated}`
  467. );
  468. await context.close();
  469. } finally {
  470. if (shouldClose) {
  471. await browser.close().catch(() => undefined);
  472. }
  473. }
  474. }
  475. }