douyin.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440
  1. /// <reference lib="dom" />
  2. import { BasePlatformAdapter } from './base.js';
  3. import type {
  4. AccountProfile,
  5. PublishParams,
  6. PublishResult,
  7. DateRange,
  8. AnalyticsData,
  9. CommentData,
  10. WorkItem,
  11. } from './base.js';
  12. import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
  13. import { logger } from '../../utils/logger.js';
  14. /**
  15. * 抖音平台适配器
  16. */
  17. export class DouyinAdapter extends BasePlatformAdapter {
  18. readonly platform: PlatformType = 'douyin';
  19. readonly loginUrl = 'https://creator.douyin.com/';
  20. readonly publishUrl = 'https://creator.douyin.com/creator-micro/content/upload';
  21. /**
  22. * 获取扫码登录二维码
  23. */
  24. async getQRCode(): Promise<QRCodeInfo> {
  25. try {
  26. await this.initBrowser();
  27. if (!this.page) throw new Error('Page not initialized');
  28. // 访问创作者中心
  29. await this.page.goto(this.loginUrl);
  30. // 等待二维码出现
  31. await this.waitForSelector('.qrcode-image', 30000);
  32. // 获取二维码图片
  33. const qrcodeImg = await this.page.$('.qrcode-image img');
  34. const qrcodeUrl = await qrcodeImg?.getAttribute('src');
  35. if (!qrcodeUrl) {
  36. throw new Error('Failed to get QR code');
  37. }
  38. const qrcodeKey = `douyin_${Date.now()}`;
  39. return {
  40. qrcodeUrl,
  41. qrcodeKey,
  42. expireTime: Date.now() + 300000, // 5分钟过期
  43. };
  44. } catch (error) {
  45. logger.error('Douyin getQRCode error:', error);
  46. throw error;
  47. }
  48. }
  49. /**
  50. * 检查扫码状态
  51. */
  52. async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
  53. try {
  54. if (!this.page) {
  55. return { status: 'expired', message: '二维码已过期' };
  56. }
  57. // 检查是否登录成功(URL 变化或出现用户头像)
  58. const currentUrl = this.page.url();
  59. if (currentUrl.includes('/creator-micro/home')) {
  60. // 登录成功,获取 cookie
  61. const cookies = await this.getCookies();
  62. await this.closeBrowser();
  63. return {
  64. status: 'success',
  65. message: '登录成功',
  66. cookies,
  67. };
  68. }
  69. // 检查是否扫码
  70. const scanTip = await this.page.$('.scan-tip');
  71. if (scanTip) {
  72. return { status: 'scanned', message: '已扫码,请确认登录' };
  73. }
  74. return { status: 'waiting', message: '等待扫码' };
  75. } catch (error) {
  76. logger.error('Douyin checkQRCodeStatus error:', error);
  77. return { status: 'error', message: '检查状态失败' };
  78. }
  79. }
  80. /**
  81. * 检查登录状态
  82. */
  83. async checkLoginStatus(cookies: string): Promise<boolean> {
  84. try {
  85. // 使用无头浏览器后台运行
  86. await this.initBrowser({ headless: true });
  87. await this.setCookies(cookies);
  88. if (!this.page) throw new Error('Page not initialized');
  89. // 访问个人中心
  90. await this.page.goto('https://creator.douyin.com/creator-micro/home', {
  91. waitUntil: 'domcontentloaded',
  92. timeout: 30000,
  93. });
  94. // 等待页面稳定
  95. await this.page.waitForTimeout(3000);
  96. const url = this.page.url();
  97. logger.info(`Douyin checkLoginStatus URL: ${url}`);
  98. // 如果被重定向到登录页面,说明未登录
  99. const isLoginPage = url.includes('login') ||
  100. url.includes('passport') ||
  101. url.includes('sso');
  102. // 额外检查:页面上是否有登录相关的元素
  103. if (!isLoginPage) {
  104. // 检查是否有登录按钮或二维码(说明需要登录)
  105. const hasLoginButton = await this.page.$('[class*="login"], [class*="qrcode"], .login-btn');
  106. if (hasLoginButton) {
  107. logger.info('Douyin: Found login elements on page, cookie may be expired');
  108. await this.closeBrowser();
  109. return false;
  110. }
  111. }
  112. await this.closeBrowser();
  113. return !isLoginPage;
  114. } catch (error) {
  115. logger.error('Douyin checkLoginStatus error:', error);
  116. await this.closeBrowser();
  117. return false;
  118. }
  119. }
  120. /**
  121. * 获取账号信息
  122. */
  123. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  124. try {
  125. // 使用无头浏览器后台运行
  126. await this.initBrowser({ headless: true });
  127. await this.setCookies(cookies);
  128. if (!this.page) throw new Error('Page not initialized');
  129. // 访问个人主页
  130. await this.page.goto('https://creator.douyin.com/creator-micro/home', {
  131. waitUntil: 'domcontentloaded',
  132. timeout: 30000,
  133. });
  134. // 等待页面加载
  135. await this.page.waitForTimeout(3000);
  136. let accountName = '未知账号';
  137. let avatarUrl = '';
  138. let fansCount = 0;
  139. let worksCount = 0;
  140. let accountId = '';
  141. let worksList: WorkItem[] = [];
  142. // 尝试多种选择器获取用户名
  143. const nameSelectors = [
  144. '[class*="nickname"]',
  145. '[class*="userName"]',
  146. '[class*="user-name"]',
  147. '.creator-info .name',
  148. '.user-info .name',
  149. ];
  150. for (const selector of nameSelectors) {
  151. try {
  152. const el = await this.page.$(selector);
  153. if (el) {
  154. const text = await el.textContent();
  155. if (text && text.trim()) {
  156. accountName = text.trim();
  157. break;
  158. }
  159. }
  160. } catch {}
  161. }
  162. // 尝试获取头像
  163. const avatarSelectors = [
  164. '[class*="avatar"] img',
  165. '.user-avatar img',
  166. '.creator-avatar img',
  167. ];
  168. for (const selector of avatarSelectors) {
  169. try {
  170. const el = await this.page.$(selector);
  171. if (el) {
  172. avatarUrl = await el.getAttribute('src') || '';
  173. if (avatarUrl) break;
  174. }
  175. } catch {}
  176. }
  177. // 尝试从页面数据或Cookie获取账号ID
  178. try {
  179. const cookieList = JSON.parse(cookies);
  180. const uidCookie = cookieList.find((c: { name: string }) =>
  181. c.name === 'passport_uid' || c.name === 'uid' || c.name === 'ssid'
  182. );
  183. if (uidCookie) {
  184. accountId = uidCookie.value;
  185. }
  186. } catch {}
  187. // 如果没有获取到ID,生成一个
  188. if (!accountId) {
  189. accountId = `douyin_${Date.now()}`;
  190. }
  191. // 尝试获取粉丝数
  192. try {
  193. const fansEl = await this.page.$('[class*="fans"] [class*="count"], [class*="粉丝"]');
  194. if (fansEl) {
  195. const text = await fansEl.textContent();
  196. if (text) {
  197. fansCount = this.parseCount(text.replace(/[^\d.万w亿]/g, ''));
  198. }
  199. }
  200. } catch {}
  201. // 访问内容管理页面获取作品数和作品列表
  202. try {
  203. await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', {
  204. waitUntil: 'domcontentloaded',
  205. timeout: 30000,
  206. });
  207. await this.page.waitForTimeout(3000);
  208. // 获取作品总数 - 从 "共 12 个作品" 元素中提取
  209. const totalEl = await this.page.$('[class*="content-header-total"]');
  210. if (totalEl) {
  211. const totalText = await totalEl.textContent();
  212. if (totalText) {
  213. const match = totalText.match(/(\d+)/);
  214. if (match) {
  215. worksCount = parseInt(match[1], 10);
  216. }
  217. }
  218. }
  219. // 获取作品列表
  220. worksList = await this.page.evaluate(() => {
  221. const items: WorkItem[] = [];
  222. const cards = document.querySelectorAll('[class*="video-card-zQ02ng"]');
  223. cards.forEach((card) => {
  224. try {
  225. // 获取封面图片URL
  226. const coverEl = card.querySelector('[class*="video-card-cover"]') as HTMLElement;
  227. let coverUrl = '';
  228. if (coverEl && coverEl.style.backgroundImage) {
  229. const match = coverEl.style.backgroundImage.match(/url\("(.+?)"\)/);
  230. if (match) {
  231. coverUrl = match[1];
  232. }
  233. }
  234. // 获取时长
  235. const durationEl = card.querySelector('[class*="badge-"]');
  236. const duration = durationEl?.textContent?.trim() || '';
  237. // 获取标题
  238. const titleEl = card.querySelector('[class*="info-title-text"]');
  239. const title = titleEl?.textContent?.trim() || '无作品描述';
  240. // 获取发布时间
  241. const timeEl = card.querySelector('[class*="info-time"]');
  242. const publishTime = timeEl?.textContent?.trim() || '';
  243. // 获取状态
  244. const statusEl = card.querySelector('[class*="info-status"]');
  245. const status = statusEl?.textContent?.trim() || '';
  246. // 获取数据指标
  247. const metricItems = card.querySelectorAll('[class*="metric-item-u1CAYE"]');
  248. let playCount = 0, likeCount = 0, commentCount = 0, shareCount = 0;
  249. metricItems.forEach((metric) => {
  250. const labelEl = metric.querySelector('[class*="metric-label"]');
  251. const valueEl = metric.querySelector('[class*="metric-value"]');
  252. const label = labelEl?.textContent?.trim() || '';
  253. const value = parseInt(valueEl?.textContent?.trim() || '0', 10);
  254. switch (label) {
  255. case '播放': playCount = value; break;
  256. case '点赞': likeCount = value; break;
  257. case '评论': commentCount = value; break;
  258. case '分享': shareCount = value; break;
  259. }
  260. });
  261. items.push({
  262. title,
  263. coverUrl,
  264. duration,
  265. publishTime,
  266. status,
  267. playCount,
  268. likeCount,
  269. commentCount,
  270. shareCount,
  271. });
  272. } catch {}
  273. });
  274. return items;
  275. });
  276. logger.info(`Douyin works: total ${worksCount}, fetched ${worksList.length} items`);
  277. } catch (worksError) {
  278. logger.warn('Failed to fetch works list:', worksError);
  279. }
  280. await this.closeBrowser();
  281. logger.info(`Douyin account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`);
  282. return {
  283. accountId,
  284. accountName,
  285. avatarUrl,
  286. fansCount,
  287. worksCount,
  288. worksList,
  289. };
  290. } catch (error) {
  291. logger.error('Douyin getAccountInfo error:', error);
  292. await this.closeBrowser();
  293. // 返回默认值而不是抛出错误
  294. return {
  295. accountId: `douyin_${Date.now()}`,
  296. accountName: '抖音账号',
  297. avatarUrl: '',
  298. fansCount: 0,
  299. worksCount: 0,
  300. };
  301. }
  302. }
  303. /**
  304. * 验证码信息类型
  305. */
  306. private captchaTypes = {
  307. SMS: 'sms', // 短信验证码
  308. IMAGE: 'image', // 图形验证码
  309. } as const;
  310. /**
  311. * 处理验证码弹框(支持短信验证码和图形验证码)
  312. * @param onCaptchaRequired 验证码回调
  313. * @returns 'success' | 'failed' | 'not_needed'
  314. */
  315. private async handleCaptchaIfNeeded(
  316. onCaptchaRequired?: (captchaInfo: {
  317. taskId: string;
  318. type: 'sms' | 'image';
  319. phone?: string;
  320. imageBase64?: string;
  321. }) => Promise<string>
  322. ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
  323. if (!this.page) return 'not_needed';
  324. try {
  325. // 1. 先检测图形验证码弹框("请完成身份验证后继续")
  326. logger.info('[Douyin Publish] Checking for captcha...');
  327. const imageCaptchaResult = await this.handleImageCaptcha(onCaptchaRequired);
  328. if (imageCaptchaResult !== 'not_needed') {
  329. logger.info(`[Douyin Publish] Image captcha result: ${imageCaptchaResult}`);
  330. return imageCaptchaResult;
  331. }
  332. // 2. 再检测短信验证码弹框
  333. const smsCaptchaResult = await this.handleSmsCaptcha(onCaptchaRequired);
  334. if (smsCaptchaResult !== 'not_needed') {
  335. logger.info(`[Douyin Publish] SMS captcha result: ${smsCaptchaResult}`);
  336. }
  337. return smsCaptchaResult;
  338. } catch (error) {
  339. logger.error('[Douyin Publish] Captcha handling error:', error);
  340. return 'not_needed';
  341. }
  342. }
  343. /**
  344. * 处理图形验证码
  345. * @returns 'need_retry_headful' 表示在 headless 模式检测到验证码,需要用 headful 模式重新发布
  346. */
  347. private async handleImageCaptcha(
  348. onCaptchaRequired?: (captchaInfo: {
  349. taskId: string;
  350. type: 'sms' | 'image';
  351. phone?: string;
  352. imageBase64?: string;
  353. }) => Promise<string>
  354. ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
  355. if (!this.page) return 'not_needed';
  356. try {
  357. // 图形验证码检测 - 使用多种方式检测
  358. // 标题:"请完成身份验证后继续"
  359. // 提示:"为保护帐号安全,请根据图片输入验证码"
  360. let hasImageCaptcha = false;
  361. // 方式1: 使用 getByText 直接查找可见的文本
  362. const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
  363. const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
  364. const titleCount = await captchaTitle.count().catch(() => 0);
  365. const hintCount = await captchaHint.count().catch(() => 0);
  366. logger.info(`[Douyin Publish] Image captcha check - title elements: ${titleCount}, hint elements: ${hintCount}`);
  367. if (titleCount > 0) {
  368. const isVisible = await captchaTitle.first().isVisible().catch(() => false);
  369. logger.info(`[Douyin Publish] Image captcha title visible: ${isVisible}`);
  370. if (isVisible) {
  371. hasImageCaptcha = true;
  372. }
  373. }
  374. if (!hasImageCaptcha && hintCount > 0) {
  375. const isVisible = await captchaHint.first().isVisible().catch(() => false);
  376. logger.info(`[Douyin Publish] Image captcha hint visible: ${isVisible}`);
  377. if (isVisible) {
  378. hasImageCaptcha = true;
  379. }
  380. }
  381. // 方式2: 检查页面 HTML 内容作为备用
  382. if (!hasImageCaptcha) {
  383. const pageContent = await this.page.content().catch(() => '');
  384. if (pageContent.includes('请完成身份验证后继续') || pageContent.includes('请根据图片输入验证码')) {
  385. logger.info('[Douyin Publish] Image captcha text found in page content');
  386. // 再次尝试使用选择器
  387. const modalCandidates = [
  388. 'div[class*="modal"]:visible',
  389. 'div[role="dialog"]:visible',
  390. '[class*="verify-modal"]',
  391. '[class*="captcha"]',
  392. ];
  393. for (const selector of modalCandidates) {
  394. const modal = this.page.locator(selector).first();
  395. if (await modal.count().catch(() => 0) > 0 && await modal.isVisible().catch(() => false)) {
  396. hasImageCaptcha = true;
  397. logger.info(`[Douyin Publish] Image captcha modal found via: ${selector}`);
  398. break;
  399. }
  400. }
  401. }
  402. }
  403. if (!hasImageCaptcha) {
  404. return 'not_needed';
  405. }
  406. logger.info('[Douyin Publish] Image captcha modal detected!');
  407. // 截图保存当前状态
  408. try {
  409. const screenshotPath = `uploads/debug/image_captcha_${Date.now()}.png`;
  410. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  411. logger.info(`[Douyin Publish] Image captcha screenshot saved: ${screenshotPath}`);
  412. } catch {}
  413. // 如果当前是 headless 模式,返回特殊状态让调用方用 headful 模式重试
  414. if (this.isHeadless) {
  415. logger.info('[Douyin Publish] Captcha detected in HEADLESS mode, need to restart with HEADFUL');
  416. return 'need_retry_headful';
  417. }
  418. // 已经是 headful 模式,通知前端并等待用户完成验证
  419. logger.info('[Douyin Publish] In HEADFUL mode, waiting for user to complete captcha in browser window...');
  420. // 通知前端验证码需要手动输入
  421. if (onCaptchaRequired) {
  422. const taskId = `captcha_manual_${Date.now()}`;
  423. onCaptchaRequired({
  424. taskId,
  425. type: 'image',
  426. imageBase64: '',
  427. }).catch(() => {});
  428. }
  429. // 等待验证码弹框消失(用户在浏览器窗口中完成验证)
  430. const captchaTimeout = 180000; // 3 分钟超时
  431. const captchaStartTime = Date.now();
  432. while (Date.now() - captchaStartTime < captchaTimeout) {
  433. await this.page.waitForTimeout(2000);
  434. const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
  435. const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
  436. const titleVisible = await captchaTitle.count() > 0 && await captchaTitle.first().isVisible().catch(() => false);
  437. const hintVisible = await captchaHint.count() > 0 && await captchaHint.first().isVisible().catch(() => false);
  438. if (!titleVisible && !hintVisible) {
  439. logger.info('[Douyin Publish] Captcha completed by user!');
  440. await this.page.waitForTimeout(2000);
  441. return 'success';
  442. }
  443. const elapsed = Math.floor((Date.now() - captchaStartTime) / 1000);
  444. logger.info(`[Douyin Publish] Waiting for captcha (${elapsed}s)...`);
  445. }
  446. logger.error('[Douyin Publish] Captcha timeout');
  447. return 'failed';
  448. } catch (error) {
  449. logger.error('[Douyin Publish] Image captcha handling error:', error);
  450. return 'not_needed';
  451. }
  452. }
  453. /**
  454. * 处理短信验证码
  455. */
  456. private async handleSmsCaptcha(
  457. onCaptchaRequired?: (captchaInfo: {
  458. taskId: string;
  459. type: 'sms' | 'image';
  460. phone?: string;
  461. imageBase64?: string;
  462. }) => Promise<string>
  463. ): Promise<'success' | 'failed' | 'not_needed'> {
  464. if (!this.page) return 'not_needed';
  465. try {
  466. // 短信验证码弹框选择器
  467. const smsCaptchaSelectors = [
  468. '.second-verify-panel',
  469. '.uc-ui-verify_sms-verify',
  470. '.uc-ui-verify-new_header-title:has-text("接收短信验证码")',
  471. 'article.uc-ui-verify_sms-verify',
  472. ];
  473. let hasSmsCaptcha = false;
  474. for (const selector of smsCaptchaSelectors) {
  475. const element = this.page.locator(selector).first();
  476. const count = await element.count().catch(() => 0);
  477. if (count > 0) {
  478. const isVisible = await element.isVisible().catch(() => false);
  479. if (isVisible) {
  480. hasSmsCaptcha = true;
  481. logger.info(`[Douyin Publish] SMS captcha detected with selector: ${selector}`);
  482. break;
  483. }
  484. }
  485. }
  486. if (!hasSmsCaptcha) {
  487. return 'not_needed';
  488. }
  489. logger.info('[Douyin Publish] SMS captcha modal detected!');
  490. // 截图保存
  491. try {
  492. const screenshotPath = `uploads/debug/sms_captcha_${Date.now()}.png`;
  493. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  494. logger.info(`[Douyin Publish] SMS captcha screenshot saved: ${screenshotPath}`);
  495. } catch {}
  496. // 获取手机号
  497. let phone = '';
  498. try {
  499. const phoneElement = this.page.locator('.var_TextPrimary').first();
  500. if (await phoneElement.count() > 0) {
  501. phone = await phoneElement.textContent() || '';
  502. }
  503. if (!phone) {
  504. const pageText = await this.page.locator('.second-verify-panel, .uc-ui-verify_sms-verify').first().textContent() || '';
  505. const phoneMatch = pageText.match(/1\d{2}\*{4,6}\d{2}/);
  506. if (phoneMatch) phone = phoneMatch[0];
  507. }
  508. logger.info(`[Douyin Publish] Found phone number: ${phone}`);
  509. } catch {}
  510. // 点击获取验证码按钮
  511. const getCaptchaBtnSelectors = [
  512. '.uc-ui-input_right p:has-text("获取验证码")',
  513. '.uc-ui-input_right:has-text("获取验证码")',
  514. 'p:has-text("获取验证码")',
  515. ];
  516. for (const selector of getCaptchaBtnSelectors) {
  517. const btn = this.page.locator(selector).first();
  518. if (await btn.count() > 0 && await btn.isVisible()) {
  519. try {
  520. await btn.click();
  521. logger.info(`[Douyin Publish] Clicked "获取验证码" button: ${selector}`);
  522. await this.page.waitForTimeout(1500);
  523. break;
  524. } catch {}
  525. }
  526. }
  527. if (!onCaptchaRequired) {
  528. logger.error('[Douyin Publish] SMS captcha required but no callback provided');
  529. return 'failed';
  530. }
  531. const taskId = `captcha_${Date.now()}`;
  532. logger.info(`[Douyin Publish] Requesting SMS captcha from user, taskId: ${taskId}, phone: ${phone}`);
  533. const captchaCode = await onCaptchaRequired({
  534. taskId,
  535. type: 'sms',
  536. phone,
  537. });
  538. if (!captchaCode) {
  539. logger.error('[Douyin Publish] No SMS captcha code received');
  540. return 'failed';
  541. }
  542. logger.info(`[Douyin Publish] Received SMS captcha code: ${captchaCode}`);
  543. // 填写验证码
  544. const inputSelectors = [
  545. '.second-verify-panel input[type="number"]',
  546. '.uc-ui-verify_sms-verify input[type="number"]',
  547. '.uc-ui-input input[placeholder="请输入验证码"]',
  548. 'input[placeholder="请输入验证码"]',
  549. '.uc-ui-input_textbox input',
  550. ];
  551. let inputFilled = false;
  552. for (const selector of inputSelectors) {
  553. const input = this.page.locator(selector).first();
  554. if (await input.count() > 0 && await input.isVisible()) {
  555. await input.click();
  556. await input.fill('');
  557. await input.type(captchaCode, { delay: 50 });
  558. inputFilled = true;
  559. logger.info(`[Douyin Publish] SMS captcha code filled via: ${selector}`);
  560. break;
  561. }
  562. }
  563. if (!inputFilled) {
  564. logger.error('[Douyin Publish] SMS captcha input not found');
  565. return 'failed';
  566. }
  567. await this.page.waitForTimeout(500);
  568. // 点击验证按钮
  569. const verifyBtnSelectors = [
  570. '.uc-ui-verify_sms-verify_button:has-text("验证"):not(.disabled)',
  571. '.uc-ui-button:has-text("验证"):not(.disabled)',
  572. '.second-verify-panel .uc-ui-button:has-text("验证")',
  573. 'div.uc-ui-button:has-text("验证")',
  574. ];
  575. for (const selector of verifyBtnSelectors) {
  576. const btn = this.page.locator(selector).first();
  577. if (await btn.count() > 0 && await btn.isVisible()) {
  578. try {
  579. const isDisabled = await btn.evaluate((el: HTMLElement) => el.classList.contains('disabled'));
  580. if (!isDisabled) {
  581. await btn.click();
  582. logger.info(`[Douyin Publish] Clicked SMS verify button: ${selector}`);
  583. break;
  584. }
  585. } catch {}
  586. }
  587. }
  588. // 等待结果
  589. await this.page.waitForTimeout(3000);
  590. // 检查弹框是否消失
  591. let stillHasCaptcha = false;
  592. for (const selector of smsCaptchaSelectors) {
  593. const element = this.page.locator(selector).first();
  594. const isVisible = await element.isVisible().catch(() => false);
  595. if (isVisible) {
  596. stillHasCaptcha = true;
  597. break;
  598. }
  599. }
  600. if (!stillHasCaptcha) {
  601. logger.info('[Douyin Publish] SMS captcha verified successfully');
  602. return 'success';
  603. }
  604. logger.warn('[Douyin Publish] SMS captcha modal still visible');
  605. return 'failed';
  606. } catch (error) {
  607. logger.error('[Douyin Publish] SMS captcha handling error:', error);
  608. return 'not_needed';
  609. }
  610. }
  611. /**
  612. * 发布视频
  613. * 参考 https://github.com/kebenxiaoming/matrix 项目实现
  614. * @param onCaptchaRequired 验证码回调,返回用户输入的验证码
  615. * @param options.headless 是否使用无头模式,默认 true
  616. */
  617. async publishVideo(
  618. cookies: string,
  619. params: PublishParams,
  620. onProgress?: (progress: number, message: string) => void,
  621. onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
  622. options?: { headless?: boolean }
  623. ): Promise<PublishResult> {
  624. const useHeadless = options?.headless ?? true;
  625. try {
  626. await this.initBrowser({ headless: useHeadless });
  627. await this.setCookies(cookies);
  628. if (!useHeadless) {
  629. logger.info('[Douyin Publish] Running in HEADFUL mode - browser window is visible');
  630. onProgress?.(1, '已打开浏览器窗口,请注意查看...');
  631. }
  632. if (!this.page) throw new Error('Page not initialized');
  633. // 检查视频文件是否存在
  634. const fs = await import('fs');
  635. if (!fs.existsSync(params.videoPath)) {
  636. throw new Error(`视频文件不存在: ${params.videoPath}`);
  637. }
  638. onProgress?.(5, '正在打开上传页面...');
  639. logger.info(`[Douyin Publish] Starting upload for: ${params.videoPath}`);
  640. // 访问上传页面
  641. await this.page.goto(this.publishUrl, {
  642. waitUntil: 'domcontentloaded',
  643. timeout: 60000,
  644. });
  645. // 等待页面加载
  646. await this.page.waitForTimeout(3000);
  647. logger.info(`[Douyin Publish] Page loaded: ${this.page.url()}`);
  648. onProgress?.(10, '正在选择视频文件...');
  649. // 参考 matrix: 点击上传区域触发文件选择
  650. // 选择器: div.container-drag-info-Tl0RGH
  651. const uploadDivSelectors = [
  652. 'div[class*="container-drag-info"]',
  653. 'div[class*="upload-btn"]',
  654. 'div[class*="drag-area"]',
  655. '[class*="upload"] [class*="drag"]',
  656. ];
  657. let uploadTriggered = false;
  658. for (const selector of uploadDivSelectors) {
  659. try {
  660. const uploadDiv = this.page.locator(selector).first();
  661. if (await uploadDiv.count() > 0) {
  662. logger.info(`[Douyin Publish] Found upload div: ${selector}`);
  663. // 使用 expect_file_chooser 方式上传(参考 matrix)
  664. const [fileChooser] = await Promise.all([
  665. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  666. uploadDiv.click(),
  667. ]);
  668. await fileChooser.setFiles(params.videoPath);
  669. uploadTriggered = true;
  670. logger.info(`[Douyin Publish] File selected via file chooser`);
  671. break;
  672. }
  673. } catch (e) {
  674. logger.warn(`[Douyin Publish] Failed with selector ${selector}:`, e);
  675. }
  676. }
  677. // 如果点击方式失败,尝试直接设置 input
  678. if (!uploadTriggered) {
  679. logger.info('[Douyin Publish] Trying direct input method...');
  680. const fileInput = await this.page.$('input[type="file"]');
  681. if (fileInput) {
  682. await fileInput.setInputFiles(params.videoPath);
  683. uploadTriggered = true;
  684. logger.info('[Douyin Publish] File set via input element');
  685. }
  686. }
  687. if (!uploadTriggered) {
  688. throw new Error('无法触发文件上传');
  689. }
  690. onProgress?.(15, '视频上传中,等待跳转到发布页面...');
  691. // 参考 matrix: 等待页面跳转到发布页面
  692. // URL: https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page
  693. const maxWaitTime = 180000; // 3分钟
  694. const startTime = Date.now();
  695. while (Date.now() - startTime < maxWaitTime) {
  696. await this.page.waitForTimeout(2000);
  697. const currentUrl = this.page.url();
  698. if (currentUrl.includes('/content/post/video')) {
  699. logger.info('[Douyin Publish] Entered video post page');
  700. break;
  701. }
  702. // 检查上传进度
  703. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  704. if (progressText) {
  705. const match = progressText.match(/(\d+)%/);
  706. if (match) {
  707. const progress = parseInt(match[1]);
  708. onProgress?.(15 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`);
  709. }
  710. }
  711. // 检查是否上传失败
  712. const failText = await this.page.locator('div:has-text("上传失败")').first().count().catch(() => 0);
  713. if (failText > 0) {
  714. throw new Error('视频上传失败');
  715. }
  716. }
  717. if (!this.page.url().includes('/content/post/video')) {
  718. throw new Error('等待进入发布页面超时');
  719. }
  720. onProgress?.(50, '正在填写视频信息...');
  721. await this.page.waitForTimeout(2000);
  722. // 参考 matrix: 填充标题
  723. // 先尝试找到标题输入框
  724. logger.info('[Douyin Publish] Filling title...');
  725. // 方式1: 找到 "作品标题" 旁边的 input
  726. const titleInput = this.page.getByText('作品标题').locator('..').locator('xpath=following-sibling::div[1]').locator('input');
  727. if (await titleInput.count() > 0) {
  728. await titleInput.fill(params.title.slice(0, 30));
  729. logger.info('[Douyin Publish] Title filled via input');
  730. } else {
  731. // 方式2: 使用 .notranslate 编辑器(参考 matrix)
  732. const editorContainer = this.page.locator('.notranslate, [class*="editor"] [contenteditable="true"]').first();
  733. if (await editorContainer.count() > 0) {
  734. await editorContainer.click();
  735. await this.page.keyboard.press('Control+A');
  736. await this.page.keyboard.press('Backspace');
  737. await this.page.keyboard.type(params.title, { delay: 30 });
  738. await this.page.keyboard.press('Enter');
  739. logger.info('[Douyin Publish] Title filled via editor');
  740. }
  741. }
  742. onProgress?.(60, '正在添加话题标签...');
  743. // 参考 matrix: 添加话题标签
  744. // 使用 .zone-container 选择器
  745. if (params.tags && params.tags.length > 0) {
  746. const tagContainer = '.zone-container, [class*="mention-container"], [class*="hash-tag"]';
  747. for (let i = 0; i < params.tags.length; i++) {
  748. const tag = params.tags[i];
  749. logger.info(`[Douyin Publish] Adding tag ${i + 1}: ${tag}`);
  750. try {
  751. await this.page.type(tagContainer, `#${tag}`, { delay: 50 });
  752. await this.page.keyboard.press('Space');
  753. await this.page.waitForTimeout(500);
  754. } catch (e) {
  755. // 如果失败,尝试在编辑器中添加
  756. try {
  757. await this.page.keyboard.type(` #${tag} `, { delay: 50 });
  758. await this.page.waitForTimeout(500);
  759. } catch {
  760. logger.warn(`[Douyin Publish] Failed to add tag: ${tag}`);
  761. }
  762. }
  763. }
  764. }
  765. onProgress?.(70, '等待视频处理完成...');
  766. // 参考 matrix: 等待 "重新上传" 按钮出现,表示视频上传完成
  767. const uploadCompleteMaxWait = 600000; // 增加到 10 分钟
  768. const uploadStartTime = Date.now();
  769. let videoProcessed = false;
  770. while (Date.now() - uploadStartTime < uploadCompleteMaxWait) {
  771. // 检查多种完成标志
  772. const reuploadCount = await this.page.locator('div').filter({ hasText: '重新上传' }).count().catch(() => 0);
  773. const replaceCount = await this.page.locator('div:has-text("替换"), button:has-text("替换")').count().catch(() => 0);
  774. const completeCount = await this.page.locator('[class*="upload-complete"], [class*="upload-success"]').count().catch(() => 0);
  775. if (reuploadCount > 0 || replaceCount > 0 || completeCount > 0) {
  776. logger.info('[Douyin Publish] Video upload completed');
  777. videoProcessed = true;
  778. break;
  779. }
  780. // 检查发布按钮是否可用(也是上传完成的标志)
  781. const publishBtnEnabled = await this.page.getByRole('button', { name: '发布', exact: true }).isEnabled().catch(() => false);
  782. if (publishBtnEnabled) {
  783. logger.info('[Douyin Publish] Publish button is enabled, video should be ready');
  784. videoProcessed = true;
  785. break;
  786. }
  787. // 检查上传失败
  788. const failCount = await this.page.locator('div:has-text("上传失败")').count().catch(() => 0);
  789. if (failCount > 0) {
  790. throw new Error('视频处理失败');
  791. }
  792. const elapsed = Math.floor((Date.now() - uploadStartTime) / 1000);
  793. logger.info(`[Douyin Publish] Waiting for video processing... (${elapsed}s)`);
  794. await this.page.waitForTimeout(3000);
  795. onProgress?.(70 + Math.min(14, Math.floor(elapsed / 20)), `等待视频处理完成 (${elapsed}s)...`);
  796. }
  797. if (!videoProcessed) {
  798. logger.warn('[Douyin Publish] Video processing timeout, but will try to publish anyway');
  799. }
  800. // 点击 "我知道了" 弹窗(如果存在)
  801. const knownBtn = this.page.getByRole('button', { name: '我知道了' });
  802. if (await knownBtn.count() > 0) {
  803. await knownBtn.first().click();
  804. await this.page.waitForTimeout(1000);
  805. }
  806. onProgress?.(85, '正在发布...');
  807. await this.page.waitForTimeout(3000);
  808. // 参考 matrix: 点击发布按钮
  809. logger.info('[Douyin Publish] Looking for publish button...');
  810. // 尝试多种方式找到发布按钮
  811. let publishClicked = false;
  812. // 方式1: 使用 getByRole
  813. const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
  814. if (await publishBtn.count() > 0) {
  815. // 等待按钮可点击
  816. try {
  817. await publishBtn.waitFor({ state: 'visible', timeout: 10000 });
  818. const isEnabled = await publishBtn.isEnabled();
  819. if (isEnabled) {
  820. await publishBtn.click();
  821. publishClicked = true;
  822. logger.info('[Douyin Publish] Publish button clicked via getByRole');
  823. }
  824. } catch (e) {
  825. logger.warn('[Douyin Publish] getByRole method failed:', e);
  826. }
  827. }
  828. // 方式2: 使用选择器
  829. if (!publishClicked) {
  830. const selectors = [
  831. 'button:has-text("发布")',
  832. '[class*="publish-btn"]',
  833. 'button[class*="primary"]:has-text("发布")',
  834. '.semi-button-primary:has-text("发布")',
  835. ];
  836. for (const selector of selectors) {
  837. const btn = this.page.locator(selector).first();
  838. if (await btn.count() > 0) {
  839. try {
  840. const isEnabled = await btn.isEnabled();
  841. if (isEnabled) {
  842. await btn.click();
  843. publishClicked = true;
  844. logger.info(`[Douyin Publish] Publish button clicked via selector: ${selector}`);
  845. break;
  846. }
  847. } catch {}
  848. }
  849. }
  850. }
  851. if (!publishClicked) {
  852. // 截图帮助调试
  853. try {
  854. const screenshotPath = `uploads/debug/no_publish_btn_${Date.now()}.png`;
  855. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  856. logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`);
  857. } catch {}
  858. throw new Error('未找到可点击的发布按钮');
  859. }
  860. logger.info('[Douyin Publish] Publish button clicked, waiting for result...');
  861. // 点击发布后截图
  862. await this.page.waitForTimeout(2000);
  863. try {
  864. const screenshotPath = `uploads/debug/after_publish_click_${Date.now()}.png`;
  865. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  866. logger.info(`[Douyin Publish] After click screenshot saved: ${screenshotPath}`);
  867. } catch {}
  868. // 检查是否有确认弹窗需要处理
  869. const confirmSelectors = [
  870. 'button:has-text("确认发布")',
  871. 'button:has-text("确定")',
  872. 'button:has-text("确认")',
  873. '.semi-modal button:has-text("发布")',
  874. '[class*="modal"] button[class*="primary"]',
  875. ];
  876. for (const selector of confirmSelectors) {
  877. const confirmBtn = this.page.locator(selector).first();
  878. if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
  879. logger.info(`[Douyin Publish] Found confirm button: ${selector}`);
  880. await confirmBtn.click();
  881. await this.page.waitForTimeout(2000);
  882. logger.info('[Douyin Publish] Confirm button clicked');
  883. break;
  884. }
  885. }
  886. // 检查是否需要验证码
  887. const captchaHandled = await this.handleCaptchaIfNeeded(onCaptchaRequired);
  888. if (captchaHandled === 'failed') {
  889. throw new Error('验证码验证失败');
  890. }
  891. // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布
  892. if (captchaHandled === 'need_retry_headful') {
  893. logger.info('[Douyin Publish] Captcha detected, closing headless and restarting with headful...');
  894. onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...');
  895. await this.closeBrowser();
  896. // 递归调用,使用 headful 模式
  897. return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
  898. }
  899. onProgress?.(90, '等待发布完成...');
  900. // 参考 matrix: 等待跳转到管理页面表示发布成功
  901. // URL: https://creator.douyin.com/creator-micro/content/manage
  902. const publishMaxWait = 180000; // 3 分钟
  903. const publishStartTime = Date.now();
  904. // 记录点击发布时的 URL,用于检测是否跳转
  905. const publishPageUrl = this.page.url();
  906. logger.info(`[Douyin Publish] Publish page URL: ${publishPageUrl}`);
  907. while (Date.now() - publishStartTime < publishMaxWait) {
  908. await this.page.waitForTimeout(3000);
  909. const currentUrl = this.page.url();
  910. const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
  911. logger.info(`[Douyin Publish] Waiting for redirect (${elapsed}s), current URL: ${currentUrl}`);
  912. // 在等待过程中也检测验证码弹框
  913. const captchaResult = await this.handleCaptchaIfNeeded(onCaptchaRequired);
  914. if (captchaResult === 'failed') {
  915. throw new Error('验证码验证失败');
  916. }
  917. // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布
  918. if (captchaResult === 'need_retry_headful') {
  919. logger.info('[Douyin Publish] Captcha detected in wait loop, restarting with headful...');
  920. onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...');
  921. await this.closeBrowser();
  922. return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false });
  923. }
  924. // 检查是否跳转到管理页面 - 这是最可靠的成功标志
  925. if (currentUrl.includes('/content/manage')) {
  926. logger.info('[Douyin Publish] Publish success! Redirected to manage page');
  927. onProgress?.(100, '发布成功!');
  928. await this.closeBrowser();
  929. return {
  930. success: true,
  931. videoUrl: currentUrl,
  932. };
  933. }
  934. // 检查是否有成功提示弹窗(Toast/Modal)
  935. // 使用更精确的选择器,避免匹配按钮文字
  936. const successToast = await this.page.locator('.semi-toast-content:has-text("发布成功"), .semi-modal-body:has-text("发布成功"), [class*="toast"]:has-text("发布成功"), [class*="message"]:has-text("发布成功")').count().catch(() => 0);
  937. if (successToast > 0) {
  938. logger.info('[Douyin Publish] Found success toast/modal');
  939. // 等待一下看是否会跳转
  940. await this.page.waitForTimeout(5000);
  941. const newUrl = this.page.url();
  942. if (newUrl.includes('/content/manage')) {
  943. logger.info('[Douyin Publish] Redirected to manage page after success toast');
  944. onProgress?.(100, '发布成功!');
  945. await this.closeBrowser();
  946. return {
  947. success: true,
  948. videoUrl: newUrl,
  949. };
  950. }
  951. }
  952. // 检查是否有明确的错误提示弹窗
  953. const errorToast = await this.page.locator('.semi-toast-error, [class*="toast-error"], .semi-modal-body:has-text("失败")').first().textContent().catch(() => '');
  954. if (errorToast && errorToast.includes('失败')) {
  955. logger.error(`[Douyin Publish] Error toast found: ${errorToast}`);
  956. throw new Error(`发布失败: ${errorToast}`);
  957. }
  958. // 更新进度
  959. onProgress?.(90 + Math.min(9, Math.floor(elapsed / 20)), `等待发布完成 (${elapsed}s)...`);
  960. }
  961. // 如果超时,最后检查一次当前页面状态
  962. const finalUrl = this.page.url();
  963. logger.info(`[Douyin Publish] Timeout! Final URL: ${finalUrl}`);
  964. if (finalUrl.includes('/content/manage')) {
  965. onProgress?.(100, '发布成功!');
  966. await this.closeBrowser();
  967. return {
  968. success: true,
  969. videoUrl: finalUrl,
  970. };
  971. }
  972. // 截图保存用于调试
  973. try {
  974. const screenshotPath = `uploads/debug/publish_timeout_${Date.now()}.png`;
  975. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  976. logger.info(`[Douyin Publish] Timeout screenshot saved: ${screenshotPath}`);
  977. } catch {}
  978. throw new Error('发布超时,页面未跳转到管理页面,请手动检查是否发布成功');
  979. } catch (error) {
  980. logger.error('[Douyin Publish] Error:', error);
  981. await this.closeBrowser();
  982. return {
  983. success: false,
  984. errorMessage: error instanceof Error ? error.message : '发布失败',
  985. };
  986. }
  987. }
  988. /**
  989. * 获取评论列表
  990. */
  991. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  992. try {
  993. // 使用无头浏览器后台运行
  994. await this.initBrowser({ headless: true });
  995. await this.setCookies(cookies);
  996. if (!this.page) throw new Error('Page not initialized');
  997. // 访问评论管理页面
  998. await this.page.goto(`https://creator.douyin.com/creator-micro/content/comment?video_id=${videoId}`);
  999. await this.page.waitForLoadState('networkidle');
  1000. // 获取评论列表
  1001. const comments = await this.page.$$eval('.comment-item', items =>
  1002. items.map(item => ({
  1003. commentId: item.getAttribute('data-id') || '',
  1004. authorId: item.querySelector('.author-id')?.textContent?.trim() || '',
  1005. authorName: item.querySelector('.author-name')?.textContent?.trim() || '',
  1006. authorAvatar: item.querySelector('.author-avatar img')?.getAttribute('src') || '',
  1007. content: item.querySelector('.comment-content')?.textContent?.trim() || '',
  1008. likeCount: parseInt(item.querySelector('.like-count')?.textContent || '0'),
  1009. commentTime: item.querySelector('.comment-time')?.textContent?.trim() || '',
  1010. }))
  1011. );
  1012. await this.closeBrowser();
  1013. return comments;
  1014. } catch (error) {
  1015. logger.error('Douyin getComments error:', error);
  1016. await this.closeBrowser();
  1017. return [];
  1018. }
  1019. }
  1020. /**
  1021. * 回复评论
  1022. */
  1023. async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
  1024. try {
  1025. // 使用无头浏览器后台运行
  1026. await this.initBrowser({ headless: true });
  1027. await this.setCookies(cookies);
  1028. if (!this.page) throw new Error('Page not initialized');
  1029. // 这里需要实现具体的回复逻辑
  1030. // 由于抖音页面结构可能变化,具体实现需要根据实际情况调整
  1031. logger.info(`Reply to comment ${commentId}: ${content}`);
  1032. await this.closeBrowser();
  1033. return true;
  1034. } catch (error) {
  1035. logger.error('Douyin replyComment error:', error);
  1036. await this.closeBrowser();
  1037. return false;
  1038. }
  1039. }
  1040. /**
  1041. * 获取数据统计
  1042. */
  1043. async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
  1044. try {
  1045. // 使用无头浏览器后台运行
  1046. await this.initBrowser({ headless: true });
  1047. await this.setCookies(cookies);
  1048. if (!this.page) throw new Error('Page not initialized');
  1049. // 访问数据中心
  1050. await this.page.goto('https://creator.douyin.com/creator-micro/data/overview');
  1051. await this.page.waitForLoadState('networkidle');
  1052. // 获取数据
  1053. // 这里需要根据实际页面结构获取数据
  1054. await this.closeBrowser();
  1055. return {
  1056. fansCount: 0,
  1057. fansIncrease: 0,
  1058. viewsCount: 0,
  1059. likesCount: 0,
  1060. commentsCount: 0,
  1061. sharesCount: 0,
  1062. };
  1063. } catch (error) {
  1064. logger.error('Douyin getAnalytics error:', error);
  1065. await this.closeBrowser();
  1066. throw error;
  1067. }
  1068. }
  1069. /**
  1070. * 删除已发布的作品
  1071. */
  1072. async deleteWork(
  1073. cookies: string,
  1074. videoId: string,
  1075. onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
  1076. ): Promise<{ success: boolean; errorMessage?: string }> {
  1077. try {
  1078. // 使用无头浏览器后台运行
  1079. await this.initBrowser({ headless: true });
  1080. await this.setCookies(cookies);
  1081. if (!this.page) throw new Error('Page not initialized');
  1082. logger.info(`[Douyin Delete] Starting delete for video: ${videoId}`);
  1083. // 访问内容管理页面
  1084. await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', {
  1085. waitUntil: 'networkidle',
  1086. timeout: 60000,
  1087. });
  1088. await this.page.waitForTimeout(3000);
  1089. // 找到对应视频的操作按钮
  1090. // 视频列表通常有 data-aweme-id 属性或者可以通过视频 ID 定位
  1091. const videoCard = this.page.locator(`[data-aweme-id="${videoId}"], [data-video-id="${videoId}"]`).first();
  1092. // 如果没找到,尝试通过其他方式定位
  1093. let found = await videoCard.count() > 0;
  1094. if (!found) {
  1095. // 尝试遍历视频列表找到对应的
  1096. const videoCards = this.page.locator('[class*="video-card"], [class*="content-item"], [class*="aweme-item"]');
  1097. const count = await videoCards.count();
  1098. for (let i = 0; i < count; i++) {
  1099. const card = videoCards.nth(i);
  1100. const html = await card.innerHTML().catch(() => '');
  1101. if (html.includes(videoId)) {
  1102. // 找到对应的视频卡片,点击更多操作
  1103. const moreBtn = card.locator('[class*="more"], [class*="action"]').first();
  1104. if (await moreBtn.count() > 0) {
  1105. await moreBtn.click();
  1106. found = true;
  1107. break;
  1108. }
  1109. }
  1110. }
  1111. }
  1112. if (!found) {
  1113. // 直接访问视频详情页尝试删除
  1114. await this.page.goto(`https://creator.douyin.com/creator-micro/content/manage?aweme_id=${videoId}`, {
  1115. waitUntil: 'networkidle',
  1116. });
  1117. await this.page.waitForTimeout(2000);
  1118. }
  1119. // 查找并点击"更多"按钮或"..."
  1120. const moreSelectors = [
  1121. 'button:has-text("更多")',
  1122. '[class*="more-action"]',
  1123. '[class*="dropdown-trigger"]',
  1124. 'button[class*="more"]',
  1125. '.semi-dropdown-trigger',
  1126. ];
  1127. for (const selector of moreSelectors) {
  1128. const moreBtn = this.page.locator(selector).first();
  1129. if (await moreBtn.count() > 0) {
  1130. await moreBtn.click();
  1131. await this.page.waitForTimeout(500);
  1132. break;
  1133. }
  1134. }
  1135. // 查找并点击"删除"选项
  1136. const deleteSelectors = [
  1137. 'div:has-text("删除"):not(:has(*))',
  1138. '[class*="dropdown-item"]:has-text("删除")',
  1139. 'li:has-text("删除")',
  1140. 'span:has-text("删除")',
  1141. ];
  1142. for (const selector of deleteSelectors) {
  1143. const deleteBtn = this.page.locator(selector).first();
  1144. if (await deleteBtn.count() > 0) {
  1145. await deleteBtn.click();
  1146. logger.info('[Douyin Delete] Delete button clicked');
  1147. break;
  1148. }
  1149. }
  1150. await this.page.waitForTimeout(1000);
  1151. // 检查是否需要验证码
  1152. const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
  1153. if (captchaVisible && onCaptchaRequired) {
  1154. logger.info('[Douyin Delete] Captcha required');
  1155. // 点击发送验证码
  1156. const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
  1157. if (await sendCodeBtn.count() > 0) {
  1158. await sendCodeBtn.click();
  1159. logger.info('[Douyin Delete] Verification code sent');
  1160. }
  1161. // 通过回调获取验证码
  1162. const taskId = `delete_${videoId}_${Date.now()}`;
  1163. const code = await onCaptchaRequired({ taskId });
  1164. if (code) {
  1165. // 输入验证码
  1166. const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
  1167. if (await codeInput.count() > 0) {
  1168. await codeInput.fill(code);
  1169. logger.info('[Douyin Delete] Verification code entered');
  1170. }
  1171. // 点击确认按钮
  1172. const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
  1173. if (await confirmBtn.count() > 0) {
  1174. await confirmBtn.click();
  1175. await this.page.waitForTimeout(2000);
  1176. }
  1177. }
  1178. }
  1179. // 确认删除(可能有二次确认弹窗)
  1180. const confirmDeleteSelectors = [
  1181. 'button:has-text("确认删除")',
  1182. 'button:has-text("确定")',
  1183. '.semi-modal-footer button:has-text("确定")',
  1184. ];
  1185. for (const selector of confirmDeleteSelectors) {
  1186. const confirmBtn = this.page.locator(selector).first();
  1187. if (await confirmBtn.count() > 0) {
  1188. await confirmBtn.click();
  1189. await this.page.waitForTimeout(1000);
  1190. }
  1191. }
  1192. logger.info('[Douyin Delete] Delete completed');
  1193. await this.closeBrowser();
  1194. return { success: true };
  1195. } catch (error) {
  1196. logger.error('[Douyin Delete] Error:', error);
  1197. await this.closeBrowser();
  1198. return {
  1199. success: false,
  1200. errorMessage: error instanceof Error ? error.message : '删除失败',
  1201. };
  1202. }
  1203. }
  1204. /**
  1205. * 解析数量字符串
  1206. */
  1207. private parseCount(text: string): number {
  1208. text = text.replace(/,/g, '');
  1209. if (text.includes('万')) {
  1210. return Math.floor(parseFloat(text.replace('万', '')) * 10000);
  1211. }
  1212. if (text.includes('w')) {
  1213. return Math.floor(parseFloat(text.replace('w', '')) * 10000);
  1214. }
  1215. if (text.includes('亿')) {
  1216. return Math.floor(parseFloat(text.replace('亿', '')) * 100000000);
  1217. }
  1218. return parseInt(text) || 0;
  1219. }
  1220. }