DouyinWorkStatisticsImportService.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. /// <reference lib="dom" />
  2. import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
  3. import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
  4. import { logger } from '../utils/logger.js';
  5. import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
  6. import { AccountService } from './AccountService.js';
  7. import { BrowserManager } from '../automation/browser.js';
  8. import type { ProxyConfig } from '@media-manager/shared';
  9. import { CookieManager } from '../automation/cookie.js';
  10. import { WS_EVENTS } from '@media-manager/shared';
  11. import { wsManager } from '../websocket/index.js';
  12. import type { PlatformType } from '@media-manager/shared';
  13. /** 抖音 metrics_trend 返回 user not match / 未登录时抛出,用于触发「先刷新账号、再决定是否账号失效」 */
  14. export class DouyinLoginExpiredError extends Error {
  15. constructor(message = 'DOUYIN_LOGIN_EXPIRED') {
  16. super(message);
  17. this.name = 'DouyinLoginExpiredError';
  18. }
  19. }
  20. type PlaywrightCookie = {
  21. name: string;
  22. value: string;
  23. domain?: string;
  24. path?: string;
  25. url?: string;
  26. expires?: number;
  27. httpOnly?: boolean;
  28. secure?: boolean;
  29. sameSite?: 'Lax' | 'None' | 'Strict';
  30. };
  31. type TrendPoint = { date_time?: string; value?: string | number };
  32. type MetricsTrendResponse = {
  33. status_code: number;
  34. status_msg?: string;
  35. trend_map?: Record<
  36. string,
  37. Record<string, TrendPoint[]>
  38. >;
  39. };
  40. interface DailyWorkStatPatch {
  41. workId: number;
  42. recordDate: Date;
  43. playCount?: number;
  44. likeCount?: number;
  45. commentCount?: number;
  46. shareCount?: number;
  47. collectCount?: number;
  48. fansIncrease?: number;
  49. completionRate?: string;
  50. twoSecondExitRate?: string;
  51. }
  52. function tryDecryptCookieData(cookieData: string | null): string | null {
  53. if (!cookieData) return null;
  54. const raw = cookieData.trim();
  55. if (!raw) return null;
  56. try {
  57. return CookieManager.decrypt(raw);
  58. } catch {
  59. return raw;
  60. }
  61. }
  62. function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  63. const rawOrDecrypted = tryDecryptCookieData(cookieData);
  64. if (!rawOrDecrypted) return [];
  65. const raw = rawOrDecrypted.trim();
  66. if (!raw) return [];
  67. // 1) JSON array(最常见)
  68. if (raw.startsWith('[') || raw.startsWith('{')) {
  69. try {
  70. const parsed = JSON.parse(raw);
  71. const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
  72. if (!Array.isArray(arr)) return [];
  73. return arr
  74. .map((c: any) => {
  75. const name = String(c?.name ?? '').trim();
  76. const value = String(c?.value ?? '').trim();
  77. if (!name) return null;
  78. const domain = c?.domain ? String(c.domain) : undefined;
  79. const pathVal = c?.path ? String(c.path) : '/';
  80. const url = !domain ? 'https://creator.douyin.com' : undefined;
  81. const sameSiteRaw = c?.sameSite;
  82. const sameSite =
  83. sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
  84. ? sameSiteRaw
  85. : undefined;
  86. return {
  87. name,
  88. value,
  89. domain,
  90. path: pathVal,
  91. url,
  92. expires: typeof c?.expires === 'number' ? c.expires : undefined,
  93. httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
  94. secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
  95. sameSite,
  96. } satisfies PlaywrightCookie;
  97. })
  98. .filter(Boolean) as PlaywrightCookie[];
  99. } catch {
  100. // fallthrough
  101. }
  102. }
  103. // 2) "a=b; c=d" 拼接格式
  104. const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  105. const cookies: PlaywrightCookie[] = [];
  106. for (const p of pairs) {
  107. const idx = p.indexOf('=');
  108. if (idx <= 0) continue;
  109. const name = p.slice(0, idx).trim();
  110. const value = p.slice(idx + 1).trim();
  111. if (!name) continue;
  112. cookies.push({ name, value, url: 'https://creator.douyin.com' });
  113. }
  114. return cookies;
  115. }
  116. async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  117. const headless = true;
  118. if (proxy?.enabled) {
  119. const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
  120. const browser = await chromium.launch({
  121. headless,
  122. proxy: {
  123. server,
  124. username: proxy.username,
  125. password: proxy.password,
  126. },
  127. args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
  128. });
  129. return { browser, shouldClose: true };
  130. }
  131. const browser = await BrowserManager.getBrowser({ headless });
  132. return { browser, shouldClose: false };
  133. }
  134. function parseChinaDateFromDateTimeString(dateTime: unknown): Date | null {
  135. if (!dateTime) return null;
  136. const s = String(dateTime).trim();
  137. if (s.length < 10) return null;
  138. const ymd = s.slice(0, 10); // YYYY-MM-DD
  139. const m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  140. if (!m) return null;
  141. const yyyy = Number(m[1]);
  142. const mm = Number(m[2]);
  143. const dd = Number(m[3]);
  144. if (!yyyy || !mm || !dd) return null;
  145. const d = new Date(yyyy, mm - 1, dd, 0, 0, 0, 0);
  146. return d;
  147. }
  148. function toNumber(val: unknown, defaultValue = 0): number {
  149. if (typeof val === 'number') return Number.isFinite(val) ? val : defaultValue;
  150. if (typeof val === 'string') {
  151. const n = Number(val);
  152. return Number.isFinite(n) ? n : defaultValue;
  153. }
  154. return defaultValue;
  155. }
  156. function toInt(val: unknown, defaultValue = 0): number {
  157. const n = toNumber(val, NaN);
  158. if (!Number.isFinite(n)) return defaultValue;
  159. return Math.round(n);
  160. }
  161. function normalizePercentString(val: unknown): string | undefined {
  162. const n = toNumber(val, NaN);
  163. if (!Number.isFinite(n)) return undefined;
  164. if (n === 0) return '0';
  165. // 去掉多余的 0:48.730000 -> 48.73
  166. const s = n.toString();
  167. return `${s}%`;
  168. }
  169. function isDouyinLoginExpiredByApi(body: any): boolean {
  170. const code = Number(body?.status_code);
  171. const msg = String(body?.status_msg || '');
  172. if (code === 20001) return true; // user not match
  173. if (msg.includes('user not match')) return true;
  174. if (msg.includes('登录') && msg.includes('失效')) return true;
  175. return false;
  176. }
  177. class DouyinMetricsTrendClient {
  178. private capturedHeaders: Record<string, string> | null = null;
  179. private buildTrendUrl(itemId: string, metric: string): string {
  180. const base = 'https://creator.douyin.com/janus/douyin/creator/data/item_analysis/metrics_trend';
  181. const params = new URLSearchParams({
  182. aid: '2906',
  183. app_name: 'aweme_creator_platform',
  184. device_platform: 'web',
  185. // referer/user_agent 等埋点参数保留,尽量贴近浏览器请求
  186. referer: 'https://creator.douyin.com/creator-micro/data-center/content',
  187. user_agent:
  188. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
  189. cookie_enabled: 'true',
  190. screen_width: '1920',
  191. screen_height: '1080',
  192. browser_language: 'zh-CN',
  193. browser_platform: 'Win32',
  194. browser_name: 'Mozilla',
  195. browser_version:
  196. '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
  197. browser_online: 'true',
  198. timezone_name: 'Asia/Shanghai',
  199. item_id: itemId,
  200. trend_type: '1',
  201. time_unit: '1',
  202. metrics_group: '0,1,3',
  203. metrics: metric,
  204. });
  205. return `${base}?${params.toString()}`;
  206. }
  207. private filterCapturedHeaders(h: Record<string, string>): Record<string, string> {
  208. const out: Record<string, string> = {};
  209. const allowList = new Set([
  210. 'accept',
  211. 'accept-language',
  212. 'agw-js-conv',
  213. 'user-agent',
  214. 'x-secsdk-csrf-token',
  215. ]);
  216. for (const [k, v] of Object.entries(h || {})) {
  217. const key = k.toLowerCase();
  218. if (key === 'cookie') continue;
  219. if (key === 'host') continue;
  220. if (key === 'authority') continue;
  221. if (key === 'content-length') continue;
  222. if (key === 'referer') continue; // 每次按作品详情页动态设置
  223. if (allowList.has(key)) out[key] = v;
  224. // 保留所有 x- 前缀的 header(有些风控字段会放在 x- 里)
  225. else if (key.startsWith('x-')) out[key] = v;
  226. }
  227. return out;
  228. }
  229. private async captureHeadersFromRealRequest(page: Page, metricLabel: string, itemId: string, metric: string): Promise<void> {
  230. // 通过点击 UI 触发一次真实请求,抓取其 headers(尤其是 x-secsdk-csrf-token)
  231. const apiPattern = /\/janus\/douyin\/creator\/data\/item_analysis\/metrics_trend/i;
  232. const wait = page.waitForResponse((res) => {
  233. if (res.request().method() !== 'GET') return false;
  234. const u = res.url();
  235. return apiPattern.test(u) && u.includes(`item_id=${itemId}`) && u.includes(`metrics=${metric}`);
  236. }, { timeout: 25_000 });
  237. // 指标卡片(如:点赞量/播放量/评论量...)
  238. // 允许用 "|" 提供多个候选文案(适配 UI 文案差异)
  239. const labels = metricLabel
  240. .split('|')
  241. .map((s) => s.trim())
  242. .filter(Boolean);
  243. let clicked = false;
  244. for (const label of labels.length ? labels : [metricLabel]) {
  245. const loc = page.getByText(label, { exact: false }).first();
  246. const cnt = await loc.count().catch(() => 0);
  247. if (!cnt) continue;
  248. await loc.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => undefined);
  249. await loc.click().catch(() => undefined);
  250. clicked = true;
  251. break;
  252. }
  253. if (!clicked) {
  254. // 不强制失败:有些情况下 UI 不可点击,但直连请求仍可能成功
  255. logger.warn(`[DY WorkStats] Could not click metric label on page for header capture. label=${metricLabel}`);
  256. }
  257. const res = await wait;
  258. const headers = res.request().headers();
  259. this.capturedHeaders = this.filterCapturedHeaders(headers);
  260. logger.info(`[DY WorkStats] Captured request headers for metrics_trend. keys=${Object.keys(this.capturedHeaders).join(',')}`);
  261. }
  262. async fetchTrend(
  263. ctx: BrowserContext,
  264. page: Page,
  265. itemId: string,
  266. metric: string,
  267. metricLabelForFallback: string,
  268. refererUrl: string
  269. ): Promise<MetricsTrendResponse> {
  270. const url = this.buildTrendUrl(itemId, metric);
  271. const headers: Record<string, string> = {
  272. accept: 'application/json, text/plain, */*',
  273. 'accept-language': 'zh-CN,zh;q=0.9',
  274. referer: refererUrl,
  275. 'user-agent':
  276. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
  277. ...(this.capturedHeaders || {}),
  278. };
  279. // 1) 先尝试直接请求(最快)
  280. try {
  281. const res = await ctx.request.get(url, {
  282. headers,
  283. timeout: 25_000,
  284. });
  285. const json = (await res.json().catch(() => null)) as MetricsTrendResponse | null;
  286. if (json && typeof json === 'object') return json;
  287. } catch {
  288. // fallthrough
  289. }
  290. // 2) 如果直连失败,抓一次真实请求 header 后重试
  291. if (!this.capturedHeaders) {
  292. await this.captureHeadersFromRealRequest(page, metricLabelForFallback, itemId, metric).catch(() => undefined);
  293. }
  294. const headers2: Record<string, string> = {
  295. accept: 'application/json, text/plain, */*',
  296. 'accept-language': 'zh-CN,zh;q=0.9',
  297. referer: refererUrl,
  298. 'user-agent':
  299. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
  300. ...(this.capturedHeaders || {}),
  301. };
  302. const res2 = await ctx.request.get(url, {
  303. headers: headers2,
  304. timeout: 25_000,
  305. });
  306. const json2 = (await res2.json().catch(() => null)) as MetricsTrendResponse | null;
  307. if (!json2) throw new Error('metrics_trend 响应不是 JSON');
  308. return json2;
  309. }
  310. }
  311. export class DouyinWorkStatisticsImportService {
  312. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  313. private workRepository = AppDataSource.getRepository(Work);
  314. private workDayStatisticsService = new WorkDayStatisticsService();
  315. static async runDailyImport(): Promise<void> {
  316. const svc = new DouyinWorkStatisticsImportService();
  317. await svc.runDailyImportForAllDouyinAccounts();
  318. }
  319. async runDailyImportForAllDouyinAccounts(): Promise<void> {
  320. const accounts = await this.accountRepository.find({
  321. where: { platform: 'douyin' as any },
  322. });
  323. logger.info(`[DY WorkStats] Start import for ${accounts.length} accounts`);
  324. for (const account of accounts) {
  325. try {
  326. await this.importAccountWorksStatistics(account);
  327. } catch (e) {
  328. logger.error(
  329. `[DY WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
  330. e
  331. );
  332. // 单账号失败仅记录日志,不中断循环,其他账号照常同步
  333. }
  334. }
  335. logger.info('[DY WorkStats] All accounts done');
  336. }
  337. /**
  338. * 按账号同步作品日统计。检测到 cookie 失效时:先尝试同步/刷新账号一次;刷新仍失效则标记账号 expired。
  339. * @param isRetry 是否为「刷新账号后的重试」,避免无限递归
  340. */
  341. private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
  342. const cookies = parseCookiesFromAccount(account.cookieData);
  343. if (!cookies.length) {
  344. logger.warn(`[DY WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
  345. return;
  346. }
  347. const works = await this.workRepository.find({
  348. where: {
  349. accountId: account.id,
  350. platform: 'douyin' as any,
  351. },
  352. });
  353. if (!works.length) {
  354. logger.info(`[DY WorkStats] accountId=${account.id} 没有作品,跳过`);
  355. return;
  356. }
  357. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  358. let context: BrowserContext | null = null;
  359. let closedDueToLoginExpired = false;
  360. try {
  361. context = await browser.newContext({
  362. viewport: { width: 1920, height: 1080 },
  363. locale: 'zh-CN',
  364. timezoneId: 'Asia/Shanghai',
  365. userAgent:
  366. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
  367. });
  368. await context.addCookies(cookies as any);
  369. context.setDefaultTimeout(60_000);
  370. if (!context) {
  371. throw new Error('BrowserContext 初始化失败');
  372. }
  373. const ctx = context;
  374. const page = await context.newPage();
  375. const client = new DouyinMetricsTrendClient();
  376. let totalInserted = 0;
  377. let totalUpdated = 0;
  378. for (const work of works) {
  379. const itemId = (work.platformVideoId || '').trim();
  380. if (!itemId) continue;
  381. const detailUrl = `https://creator.douyin.com/creator-micro/work-management/work-detail/${encodeURIComponent(
  382. itemId
  383. )}?enter_from=item_data`;
  384. try {
  385. await page.goto(detailUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
  386. await page.waitForTimeout(1200);
  387. if (page.url().includes('login') || page.url().includes('passport')) {
  388. throw new DouyinLoginExpiredError('work-detail 页面跳转登录,cookie 可能失效');
  389. }
  390. // metrics -> 入库字段映射
  391. const metricsPlan: Array<{
  392. metric: string;
  393. label: string;
  394. apply: (patch: DailyWorkStatPatch, v: unknown) => void;
  395. }> = [
  396. { metric: 'view_count', label: '播放量', apply: (p, v) => (p.playCount = toInt(v, 0)) },
  397. { metric: 'like_count', label: '点赞量', apply: (p, v) => (p.likeCount = toInt(v, 0)) },
  398. { metric: 'comment_count', label: '评论量', apply: (p, v) => (p.commentCount = toInt(v, 0)) },
  399. { metric: 'share_count', label: '分享量', apply: (p, v) => (p.shareCount = toInt(v, 0)) },
  400. { metric: 'favorite_count', label: '收藏量|收藏数', apply: (p, v) => (p.collectCount = toInt(v, 0)) },
  401. { metric: 'subscribe_count', label: '涨粉量|涨粉数', apply: (p, v) => (p.fansIncrease = toInt(v, 0)) },
  402. {
  403. metric: 'completion_rate',
  404. label: '完播率',
  405. apply: (p, v) => {
  406. const s = normalizePercentString(v);
  407. if (s != null) p.completionRate = s;
  408. },
  409. },
  410. {
  411. metric: 'bounce_rate_2s',
  412. label: '2s退出率|2s跳出率|2s跳出',
  413. apply: (p, v) => {
  414. const s = normalizePercentString(v);
  415. if (s != null) p.twoSecondExitRate = s;
  416. },
  417. },
  418. ];
  419. const dayMap = new Map<number, DailyWorkStatPatch>();
  420. for (const m of metricsPlan) {
  421. const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
  422. if (!body || typeof body !== 'object') continue;
  423. if (isDouyinLoginExpiredByApi(body)) {
  424. throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
  425. }
  426. if (Number(body.status_code) !== 0) {
  427. logger.warn(
  428. `[DY WorkStats] metrics_trend 非成功返回. accountId=${account.id} workId=${work.id} itemId=${itemId} metric=${m.metric} code=${body.status_code} msg=${body.status_msg || ''}`
  429. );
  430. continue;
  431. }
  432. const trendMap = body.trend_map || {};
  433. const metricMap = (trendMap as any)[m.metric] as Record<string, TrendPoint[]> | undefined;
  434. if (!metricMap) continue;
  435. // 优先取 group "0"(一般为“总计/全部”),否则兜底合并全部 group
  436. const points = Array.isArray(metricMap['0'])
  437. ? metricMap['0']
  438. : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));
  439. for (const pt of points) {
  440. const d = parseChinaDateFromDateTimeString(pt?.date_time);
  441. if (!d) continue;
  442. const key = d.getTime();
  443. let entry = dayMap.get(key);
  444. if (!entry) {
  445. entry = { workId: work.id, recordDate: d };
  446. dayMap.set(key, entry);
  447. }
  448. m.apply(entry, pt?.value);
  449. }
  450. }
  451. const patches = Array.from(dayMap.values()).sort(
  452. (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
  453. );
  454. if (!patches.length) continue;
  455. const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
  456. patches.map((p) => ({
  457. workId: p.workId,
  458. recordDate: p.recordDate,
  459. playCount: p.playCount,
  460. likeCount: p.likeCount,
  461. commentCount: p.commentCount,
  462. shareCount: p.shareCount,
  463. collectCount: p.collectCount,
  464. fansIncrease: p.fansIncrease,
  465. completionRate: p.completionRate,
  466. twoSecondExitRate: p.twoSecondExitRate,
  467. }))
  468. );
  469. totalInserted += result.inserted;
  470. totalUpdated += result.updated;
  471. } catch (e) {
  472. if (e instanceof DouyinLoginExpiredError) {
  473. closedDueToLoginExpired = true;
  474. if (context) {
  475. await context.close().catch(() => undefined);
  476. context = null;
  477. }
  478. if (shouldClose) {
  479. await browser.close().catch(() => undefined);
  480. }
  481. // cookie 过期处理:先刷新一次账号,再决定是否标记 expired
  482. if (!isRetry) {
  483. logger.info(`[DY WorkStats] accountId=${account.id} 登录失效,尝试同步账号后重试...`);
  484. try {
  485. const accountService = new AccountService();
  486. const refreshResult = await accountService.refreshAccount(account.userId, account.id);
  487. if (refreshResult.needReLogin) {
  488. await this.markAccountExpired(account, 'cookie 过期,需要重新登录');
  489. return;
  490. }
  491. const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
  492. if (refreshed) {
  493. logger.info(`[DY WorkStats] accountId=${account.id} 同步账号成功,重新拉取作品数据`);
  494. return this.importAccountWorksStatistics(refreshed, true);
  495. }
  496. } catch (refreshErr) {
  497. logger.error(`[DY WorkStats] accountId=${account.id} 同步账号失败`, refreshErr);
  498. await this.markAccountExpired(account, '同步账号失败,已标记过期');
  499. return;
  500. }
  501. } else {
  502. await this.markAccountExpired(account, '同步后仍失效,已标记过期');
  503. return;
  504. }
  505. }
  506. logger.error(
  507. `[DY WorkStats] Failed to import work stats. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
  508. e
  509. );
  510. }
  511. }
  512. logger.info(
  513. `[DY WorkStats] accountId=${account.id} completed. inserted=${totalInserted}, updated=${totalUpdated}`
  514. );
  515. } finally {
  516. if (!closedDueToLoginExpired) {
  517. if (context) {
  518. await context.close().catch(() => undefined);
  519. }
  520. if (shouldClose) {
  521. await browser.close().catch(() => undefined);
  522. }
  523. }
  524. }
  525. }
  526. private async markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
  527. await this.accountRepository.update(account.id, { status: 'expired' as any });
  528. wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
  529. account: { id: account.id, status: 'expired', platform: 'douyin' as PlatformType },
  530. });
  531. wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
  532. level: 'warning',
  533. message: `抖音账号「${account.accountName || account.accountId || account.id}」登录已失效:${reason}`,
  534. platform: 'douyin',
  535. accountId: account.id,
  536. });
  537. }
  538. }