douyin.ts 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130
  1. /// <reference lib="dom" />
  2. import path from 'path';
  3. import { BasePlatformAdapter } from './base.js';
  4. import type {
  5. AccountProfile,
  6. PublishParams,
  7. PublishResult,
  8. DateRange,
  9. AnalyticsData,
  10. CommentData,
  11. WorkItem,
  12. } from './base.js';
  13. import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
  14. import { logger } from '../../utils/logger.js';
  15. import { getPythonServiceBaseUrl } from '../../services/PythonServiceConfigService.js';
  16. // 服务器根目录(用于构造绝对路径)
  17. const SERVER_ROOT = path.resolve(process.cwd());
  18. /**
  19. * 抖音平台适配器
  20. */
  21. export class DouyinAdapter extends BasePlatformAdapter {
  22. readonly platform: PlatformType = 'douyin';
  23. readonly loginUrl = 'https://creator.douyin.com/';
  24. readonly publishUrl = 'https://creator.douyin.com/creator-micro/content/upload';
  25. /**
  26. * 获取扫码登录二维码
  27. */
  28. async getQRCode(): Promise<QRCodeInfo> {
  29. try {
  30. await this.initBrowser();
  31. if (!this.page) throw new Error('Page not initialized');
  32. // 访问创作者中心
  33. await this.page.goto(this.loginUrl);
  34. // 等待二维码出现
  35. await this.waitForSelector('.qrcode-image', 30000);
  36. // 获取二维码图片
  37. const qrcodeImg = await this.page.$('.qrcode-image img');
  38. const qrcodeUrl = await qrcodeImg?.getAttribute('src');
  39. if (!qrcodeUrl) {
  40. throw new Error('Failed to get QR code');
  41. }
  42. const qrcodeKey = `douyin_${Date.now()}`;
  43. return {
  44. qrcodeUrl,
  45. qrcodeKey,
  46. expireTime: Date.now() + 300000, // 5分钟过期
  47. };
  48. } catch (error) {
  49. logger.error('Douyin getQRCode error:', error);
  50. throw error;
  51. }
  52. }
  53. /**
  54. * 检查扫码状态
  55. */
  56. async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
  57. try {
  58. if (!this.page) {
  59. return { status: 'expired', message: '二维码已过期' };
  60. }
  61. // 检查是否登录成功(URL 变化或出现用户头像)
  62. const currentUrl = this.page.url();
  63. if (currentUrl.includes('/creator-micro/home')) {
  64. // 在判断登录成功之前,先检查是否有二次校验弹框
  65. // 抖音二次校验弹框特征:标题"身份验证"、选项包含"接收短信验证码"等
  66. const hasSecondaryVerification = await this.checkSecondaryVerification();
  67. if (hasSecondaryVerification) {
  68. logger.info('[Douyin] Secondary verification detected, waiting for user to complete');
  69. return {
  70. status: 'scanned',
  71. message: '需要二次校验,请在手机上完成身份验证'
  72. };
  73. }
  74. // 登录成功,获取 cookie
  75. const cookies = await this.getCookies();
  76. await this.closeBrowser();
  77. return {
  78. status: 'success',
  79. message: '登录成功',
  80. cookies,
  81. };
  82. }
  83. // 检查是否有二次校验弹框(扫码后可能直接弹出,URL 还没变化)
  84. const hasSecondaryVerification = await this.checkSecondaryVerification();
  85. if (hasSecondaryVerification) {
  86. logger.info('[Douyin] Secondary verification detected after scan');
  87. return {
  88. status: 'scanned',
  89. message: '需要二次校验,请在手机上完成身份验证'
  90. };
  91. }
  92. // 检查是否扫码
  93. const scanTip = await this.page.$('.scan-tip');
  94. if (scanTip) {
  95. return { status: 'scanned', message: '已扫码,请确认登录' };
  96. }
  97. return { status: 'waiting', message: '等待扫码' };
  98. } catch (error) {
  99. logger.error('Douyin checkQRCodeStatus error:', error);
  100. return { status: 'error', message: '检查状态失败' };
  101. }
  102. }
  103. /**
  104. * 检查是否存在二次校验弹框
  105. * 抖音登录时可能弹出身份验证弹框,要求短信验证码或刷脸验证
  106. */
  107. private async checkSecondaryVerification(): Promise<boolean> {
  108. if (!this.page) return false;
  109. try {
  110. // 检测多种可能的二次校验弹框
  111. // 1. 身份验证弹框(标题"身份验证")
  112. const verifyTitle = this.page.getByText('身份验证', { exact: false });
  113. const titleCount = await verifyTitle.count().catch(() => 0);
  114. if (titleCount > 0) {
  115. const isVisible = await verifyTitle.first().isVisible().catch(() => false);
  116. if (isVisible) {
  117. logger.info('[Douyin] Found "身份验证" dialog');
  118. return true;
  119. }
  120. }
  121. // 2. 检查是否有"接收短信验证码"选项
  122. const smsOption = this.page.getByText('接收短信验证码', { exact: false });
  123. const smsCount = await smsOption.count().catch(() => 0);
  124. if (smsCount > 0) {
  125. const isVisible = await smsOption.first().isVisible().catch(() => false);
  126. if (isVisible) {
  127. logger.info('[Douyin] Found "接收短信验证码" option');
  128. return true;
  129. }
  130. }
  131. // 3. 检查是否有"手机刷脸验证"选项
  132. const faceOption = this.page.getByText('手机刷脸验证', { exact: false });
  133. const faceCount = await faceOption.count().catch(() => 0);
  134. if (faceCount > 0) {
  135. const isVisible = await faceOption.first().isVisible().catch(() => false);
  136. if (isVisible) {
  137. logger.info('[Douyin] Found "手机刷脸验证" option');
  138. return true;
  139. }
  140. }
  141. // 4. 检查是否有"发送短信验证"选项
  142. const sendSmsOption = this.page.getByText('发送短信验证', { exact: false });
  143. const sendSmsCount = await sendSmsOption.count().catch(() => 0);
  144. if (sendSmsCount > 0) {
  145. const isVisible = await sendSmsOption.first().isVisible().catch(() => false);
  146. if (isVisible) {
  147. logger.info('[Douyin] Found "发送短信验证" option');
  148. return true;
  149. }
  150. }
  151. // 5. 检查页面内容中是否包含二次校验关键文本
  152. const pageContent = await this.page.content().catch(() => '');
  153. if (pageContent.includes('为保障账号安全') &&
  154. (pageContent.includes('身份验证') || pageContent.includes('完成身份验证'))) {
  155. logger.info('[Douyin] Found secondary verification text in page content');
  156. return true;
  157. }
  158. // 6. 检查是否有验证相关的弹框容器
  159. const verifySelectors = [
  160. '[class*="verify-modal"]',
  161. '[class*="identity-verify"]',
  162. '[class*="second-verify"]',
  163. '[class*="security-verify"]',
  164. ];
  165. for (const selector of verifySelectors) {
  166. const element = this.page.locator(selector).first();
  167. const count = await element.count().catch(() => 0);
  168. if (count > 0) {
  169. const isVisible = await element.isVisible().catch(() => false);
  170. if (isVisible) {
  171. logger.info(`[Douyin] Found verification modal via selector: ${selector}`);
  172. return true;
  173. }
  174. }
  175. }
  176. return false;
  177. } catch (error) {
  178. logger.error('[Douyin] Error checking secondary verification:', error);
  179. return false;
  180. }
  181. }
  182. /**
  183. * 检查登录状态
  184. */
  185. async checkLoginStatus(cookies: string): Promise<boolean> {
  186. try {
  187. // 使用无头浏览器后台运行
  188. await this.initBrowser({ headless: true });
  189. await this.setCookies(cookies);
  190. if (!this.page) throw new Error('Page not initialized');
  191. // 访问个人中心
  192. await this.page.goto('https://creator.douyin.com/creator-micro/home', {
  193. waitUntil: 'domcontentloaded',
  194. timeout: 30000,
  195. });
  196. // 等待页面稳定
  197. await this.page.waitForTimeout(3000);
  198. const url = this.page.url();
  199. logger.info(`Douyin checkLoginStatus URL: ${url}`);
  200. // 如果被重定向到登录页面,说明未登录
  201. const isLoginPage = url.includes('login') ||
  202. url.includes('passport') ||
  203. url.includes('sso');
  204. // 额外检查:页面上是否有登录相关的元素
  205. if (!isLoginPage) {
  206. // 检查是否有登录按钮或二维码(说明需要登录)
  207. const hasLoginButton = await this.page.$('[class*="login"], [class*="qrcode"], .login-btn');
  208. if (hasLoginButton) {
  209. logger.info('Douyin: Found login elements on page, cookie may be expired');
  210. await this.closeBrowser();
  211. return false;
  212. }
  213. }
  214. await this.closeBrowser();
  215. return !isLoginPage;
  216. } catch (error) {
  217. logger.error('Douyin checkLoginStatus error:', error);
  218. await this.closeBrowser();
  219. return false;
  220. }
  221. }
  222. /**
  223. * 获取账号信息
  224. * 通过拦截 API 响应获取准确数据
  225. */
  226. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  227. try {
  228. // 使用无头浏览器后台运行
  229. await this.initBrowser({ headless: true });
  230. await this.setCookies(cookies);
  231. if (!this.page) throw new Error('Page not initialized');
  232. let accountName = '未知账号';
  233. let avatarUrl = '';
  234. let fansCount = 0;
  235. let worksCount = 0;
  236. let accountId = '';
  237. let worksList: WorkItem[] = [];
  238. // 捕获的 API 数据
  239. const capturedData: {
  240. userInfo?: {
  241. nickname?: string;
  242. avatar?: string;
  243. uid?: string;
  244. fans?: number;
  245. following?: number;
  246. };
  247. dataOverview?: {
  248. fans_count?: number;
  249. total_works?: number;
  250. total_play?: number;
  251. };
  252. workList?: {
  253. total?: number;
  254. items?: any[];
  255. aweme_list?: any[];
  256. };
  257. } = {};
  258. // 设置 API 响应监听器
  259. this.page.on('response', async (response) => {
  260. const url = response.url();
  261. try {
  262. // 监听用户信息 API
  263. if (url.includes('/creator/user/info') ||
  264. url.includes('/user/info') ||
  265. url.includes('/passport/sso/check')) {
  266. const data = await response.json();
  267. logger.info(`[Douyin API] User info:`, JSON.stringify(data).slice(0, 500));
  268. const userInfo = data?.user || data?.data?.user || data?.data;
  269. if (userInfo) {
  270. capturedData.userInfo = {
  271. nickname: userInfo.nickname || userInfo.nick_name || userInfo.name,
  272. avatar: userInfo.avatar_url || userInfo.avatar || userInfo.avatar_larger?.url_list?.[0],
  273. uid: userInfo.uid || userInfo.user_id || userInfo.sec_uid,
  274. fans: userInfo.follower_count || userInfo.fans_count,
  275. following: userInfo.following_count,
  276. };
  277. logger.info(`[Douyin API] Captured user:`, capturedData.userInfo);
  278. }
  279. }
  280. // 监听数据概览 API
  281. if (url.includes('/data/overview') ||
  282. url.includes('/creator-micro/data') ||
  283. url.includes('/data_center/overview')) {
  284. const data = await response.json();
  285. logger.info(`[Douyin API] Data overview:`, JSON.stringify(data).slice(0, 500));
  286. if (data?.data) {
  287. capturedData.dataOverview = {
  288. fans_count: data.data.fans_count || data.data.follower_count,
  289. total_works: data.data.total_item_cnt || data.data.works_count || data.data.video_count,
  290. total_play: data.data.total_play_cnt,
  291. };
  292. logger.info(`[Douyin API] Captured data overview:`, capturedData.dataOverview);
  293. }
  294. }
  295. // 监听首页数据
  296. if (url.includes('/creator-micro/home') && url.includes('api')) {
  297. const data = await response.json();
  298. logger.info(`[Douyin API] Home data:`, JSON.stringify(data).slice(0, 500));
  299. }
  300. // 监听作品列表 API - 获取准确的作品总数
  301. if (url.includes('/janus/douyin/creator/pc/work_list')) {
  302. const data = await response.json();
  303. logger.info(`[Douyin API] Work list:`, JSON.stringify(data).slice(0, 500));
  304. const awemeList = data.aweme_list || data.items || [];
  305. let totalWorks = data.total || 0;
  306. // 如果API没有返回total,尝试从第一个作品的作者信息获取
  307. if (!totalWorks && awemeList.length > 0) {
  308. const firstAweme = awemeList[0];
  309. const authorAwemeCount = firstAweme?.author?.aweme_count;
  310. if (authorAwemeCount && authorAwemeCount > 0) {
  311. totalWorks = authorAwemeCount;
  312. logger.info(`[Douyin API] 从 author.aweme_count 获取总作品数: ${totalWorks}`);
  313. }
  314. }
  315. capturedData.workList = {
  316. total: totalWorks,
  317. items: awemeList,
  318. aweme_list: awemeList,
  319. };
  320. logger.info(`[Douyin API] Captured work list: total=${totalWorks}, count=${awemeList.length}`);
  321. }
  322. } catch {
  323. // 忽略非 JSON 响应
  324. }
  325. });
  326. // 访问创作者中心首页
  327. logger.info('[Douyin] Navigating to creator home...');
  328. await this.page.goto('https://creator.douyin.com/creator-micro/home', {
  329. waitUntil: 'networkidle',
  330. timeout: 30000,
  331. });
  332. await this.page.waitForTimeout(4000);
  333. // 尝试从 Cookie 获取账号ID
  334. try {
  335. const cookieList = JSON.parse(cookies);
  336. const uidCookie = cookieList.find((c: { name: string }) =>
  337. c.name === 'passport_uid' || c.name === 'uid' || c.name === 'ttwid'
  338. );
  339. if (uidCookie) {
  340. accountId = uidCookie.value;
  341. }
  342. } catch { }
  343. // 使用捕获的 API 数据
  344. if (capturedData.userInfo) {
  345. if (capturedData.userInfo.nickname) accountName = capturedData.userInfo.nickname;
  346. if (capturedData.userInfo.avatar) avatarUrl = capturedData.userInfo.avatar;
  347. if (capturedData.userInfo.uid) accountId = capturedData.userInfo.uid;
  348. if (capturedData.userInfo.fans) fansCount = capturedData.userInfo.fans;
  349. }
  350. if (capturedData.dataOverview) {
  351. // dataOverview.fans_count 可能不准确(可能是增量而非总量),不覆盖 userInfo.fans
  352. if (capturedData.dataOverview.total_works) worksCount = capturedData.dataOverview.total_works;
  353. }
  354. // 使用作品列表 API 数据(最准确)
  355. if (capturedData.workList) {
  356. if (capturedData.workList.total && capturedData.workList.total > 0) {
  357. worksCount = capturedData.workList.total;
  358. logger.info(`[Douyin] 使用 work_list API 获取作品数: ${worksCount}`);
  359. }
  360. }
  361. // 如果还没有获取到作品数,跳转到作品管理页触发 work_list API
  362. if (worksCount === 0) {
  363. logger.info('[Douyin] 跳转到作品管理页获取作品数...');
  364. await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', {
  365. waitUntil: 'networkidle',
  366. timeout: 30000,
  367. });
  368. await this.page.waitForTimeout(4000);
  369. // 再次检查是否获取到作品数
  370. if (capturedData.workList?.total && capturedData.workList.total > 0) {
  371. worksCount = capturedData.workList.total;
  372. logger.info(`[Douyin] 从作品管理页获取作品数: ${worksCount}`);
  373. }
  374. }
  375. // 如果 API 没捕获到,尝试从页面 DOM 获取
  376. if (!accountName || accountName === '未知账号') {
  377. const nameEl = await this.page.$('[class*="nickname"], [class*="userName"], [class*="name"]');
  378. if (nameEl) {
  379. const text = await nameEl.textContent();
  380. if (text?.trim()) accountName = text.trim();
  381. }
  382. }
  383. if (!avatarUrl) {
  384. const avatarEl = await this.page.$('[class*="avatar"] img');
  385. if (avatarEl) {
  386. avatarUrl = await avatarEl.getAttribute('src') || '';
  387. }
  388. }
  389. // 如果还没获取到粉丝数和作品数,从页面元素获取
  390. if (fansCount === 0 || worksCount === 0) {
  391. const statsData = await this.page.evaluate(() => {
  392. const result = { fans: 0, works: 0 };
  393. // 查找所有包含数字的元素
  394. const allText = document.body.innerText;
  395. // 尝试匹配 "粉丝 xxx" 或 "xxx 粉丝"
  396. const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/);
  397. if (fansMatch) {
  398. const numStr = fansMatch[1] || fansMatch[2];
  399. result.fans = parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1);
  400. }
  401. // 尝试匹配作品数
  402. const worksMatch = allText.match(/作品[::\s]*(\d+)|(\d+)\s*个?作品|共\s*(\d+)\s*个/);
  403. if (worksMatch) {
  404. result.works = parseInt(worksMatch[1] || worksMatch[2] || worksMatch[3]);
  405. }
  406. return result;
  407. });
  408. if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans;
  409. if (worksCount === 0 && statsData.works > 0) worksCount = statsData.works;
  410. }
  411. // 如果没有获取到ID,生成一个
  412. if (!accountId) {
  413. accountId = `douyin_${Date.now()}`;
  414. }
  415. await this.closeBrowser();
  416. logger.info(`[Douyin] Account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`);
  417. return {
  418. accountId,
  419. accountName,
  420. avatarUrl,
  421. fansCount,
  422. worksCount,
  423. worksList,
  424. };
  425. } catch (error) {
  426. logger.error('Douyin getAccountInfo error:', error);
  427. await this.closeBrowser();
  428. // 返回默认值而不是抛出错误
  429. return {
  430. accountId: `douyin_${Date.now()}`,
  431. accountName: '抖音账号',
  432. avatarUrl: '',
  433. fansCount: 0,
  434. worksCount: 0,
  435. };
  436. }
  437. }
  438. /**
  439. * 验证码信息类型
  440. */
  441. private captchaTypes = {
  442. SMS: 'sms', // 短信验证码
  443. IMAGE: 'image', // 图形验证码
  444. } as const;
  445. /**
  446. * 处理验证码弹框(支持短信验证码和图形验证码)
  447. * 优先使用传统方式检测,AI 作为辅助
  448. * @param onCaptchaRequired 验证码回调
  449. * @returns 'success' | 'failed' | 'not_needed'
  450. */
  451. private async handleCaptchaIfNeeded(
  452. onCaptchaRequired?: (captchaInfo: {
  453. taskId: string;
  454. type: 'sms' | 'image';
  455. phone?: string;
  456. imageBase64?: string;
  457. }) => Promise<string>
  458. ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
  459. if (!this.page) return 'not_needed';
  460. try {
  461. // 1. 先使用传统方式检测图形验证码弹框
  462. logger.info('[Douyin Publish] Checking for captcha...');
  463. const imageCaptchaResult = await this.handleImageCaptcha(onCaptchaRequired);
  464. if (imageCaptchaResult !== 'not_needed') {
  465. logger.info(`[Douyin Publish] Image captcha result: ${imageCaptchaResult}`);
  466. return imageCaptchaResult;
  467. }
  468. // 2. 再检测短信验证码弹框
  469. const smsCaptchaResult = await this.handleSmsCaptcha(onCaptchaRequired);
  470. if (smsCaptchaResult !== 'not_needed') {
  471. logger.info(`[Douyin Publish] SMS captcha result: ${smsCaptchaResult}`);
  472. return smsCaptchaResult;
  473. }
  474. // 3. 如果传统方式没检测到,使用 AI 辅助检测
  475. const aiStatus = await this.aiAnalyzePublishStatus();
  476. if (aiStatus?.status === 'need_captcha') {
  477. logger.info(`[Douyin Publish] AI detected captcha: type=${aiStatus.captchaType}, desc=${aiStatus.captchaDescription}`);
  478. // AI 检测到验证码,尝试处理
  479. if (onCaptchaRequired && aiStatus.captchaType) {
  480. // 获取验证码截图
  481. const imageBase64 = await this.screenshotBase64();
  482. try {
  483. const captchaCode = await onCaptchaRequired({
  484. taskId: `ai_captcha_${Date.now()}`,
  485. type: aiStatus.captchaType === 'sms' ? 'sms' : 'image',
  486. captchaDescription: aiStatus.captchaDescription,
  487. imageBase64,
  488. });
  489. if (captchaCode) {
  490. // 使用 AI 指导输入验证码
  491. const guide = await this.aiGetPublishOperationGuide('需要输入验证码');
  492. if (guide?.hasAction && guide.targetSelector) {
  493. await this.page.fill(guide.targetSelector, captchaCode);
  494. await this.page.waitForTimeout(500);
  495. // 查找确认按钮
  496. const confirmGuide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认按钮');
  497. if (confirmGuide?.hasAction && confirmGuide.targetSelector) {
  498. await this.page.click(confirmGuide.targetSelector);
  499. await this.page.waitForTimeout(2000);
  500. }
  501. // 验证是否成功
  502. const afterStatus = await this.aiAnalyzePublishStatus();
  503. if (afterStatus?.status === 'need_captcha') {
  504. logger.warn('[Douyin Publish] AI: Captcha still present after input');
  505. return 'failed';
  506. }
  507. return 'success';
  508. }
  509. }
  510. } catch (captchaError) {
  511. logger.error('[Douyin Publish] AI captcha handling failed:', captchaError);
  512. }
  513. }
  514. // 没有回调或处理失败,需要手动介入
  515. if (this.isHeadless) {
  516. return 'need_retry_headful';
  517. }
  518. }
  519. return 'not_needed';
  520. } catch (error) {
  521. logger.error('[Douyin Publish] Captcha handling error:', error);
  522. return 'not_needed';
  523. }
  524. }
  525. /**
  526. * 处理图形验证码
  527. * @returns 'need_retry_headful' 表示在 headless 模式检测到验证码,需要用 headful 模式重新发布
  528. */
  529. private async handleImageCaptcha(
  530. onCaptchaRequired?: (captchaInfo: {
  531. taskId: string;
  532. type: 'sms' | 'image';
  533. phone?: string;
  534. imageBase64?: string;
  535. }) => Promise<string>
  536. ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> {
  537. if (!this.page) return 'not_needed';
  538. try {
  539. // 图形验证码检测 - 使用多种方式检测
  540. // 标题:"请完成身份验证后继续"
  541. // 提示:"为保护帐号安全,请根据图片输入验证码"
  542. let hasImageCaptcha = false;
  543. // 方式1: 使用 getByText 直接查找可见的文本
  544. const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
  545. const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
  546. const titleCount = await captchaTitle.count().catch(() => 0);
  547. const hintCount = await captchaHint.count().catch(() => 0);
  548. logger.info(`[Douyin Publish] Image captcha check - title elements: ${titleCount}, hint elements: ${hintCount}`);
  549. if (titleCount > 0) {
  550. const isVisible = await captchaTitle.first().isVisible().catch(() => false);
  551. logger.info(`[Douyin Publish] Image captcha title visible: ${isVisible}`);
  552. if (isVisible) {
  553. hasImageCaptcha = true;
  554. }
  555. }
  556. if (!hasImageCaptcha && hintCount > 0) {
  557. const isVisible = await captchaHint.first().isVisible().catch(() => false);
  558. logger.info(`[Douyin Publish] Image captcha hint visible: ${isVisible}`);
  559. if (isVisible) {
  560. hasImageCaptcha = true;
  561. }
  562. }
  563. // 方式2: 检查页面 HTML 内容作为备用
  564. if (!hasImageCaptcha) {
  565. const pageContent = await this.page.content().catch(() => '');
  566. if (pageContent.includes('请完成身份验证后继续') || pageContent.includes('请根据图片输入验证码')) {
  567. logger.info('[Douyin Publish] Image captcha text found in page content');
  568. // 再次尝试使用选择器
  569. const modalCandidates = [
  570. 'div[class*="modal"]:visible',
  571. 'div[role="dialog"]:visible',
  572. '[class*="verify-modal"]',
  573. '[class*="captcha"]',
  574. ];
  575. for (const selector of modalCandidates) {
  576. const modal = this.page.locator(selector).first();
  577. if (await modal.count().catch(() => 0) > 0 && await modal.isVisible().catch(() => false)) {
  578. hasImageCaptcha = true;
  579. logger.info(`[Douyin Publish] Image captcha modal found via: ${selector}`);
  580. break;
  581. }
  582. }
  583. }
  584. }
  585. if (!hasImageCaptcha) {
  586. return 'not_needed';
  587. }
  588. logger.info('[Douyin Publish] Image captcha modal detected!');
  589. // 截图保存当前状态
  590. try {
  591. const screenshotPath = `uploads/debug/image_captcha_${Date.now()}.png`;
  592. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  593. logger.info(`[Douyin Publish] Image captcha screenshot saved: ${screenshotPath}`);
  594. } catch { }
  595. // 如果当前是 headless 模式,返回特殊状态让调用方用 headful 模式重试
  596. if (this.isHeadless) {
  597. logger.info('[Douyin Publish] Captcha detected in HEADLESS mode, need to restart with HEADFUL');
  598. return 'need_retry_headful';
  599. }
  600. // 已经是 headful 模式,通知前端并等待用户完成验证
  601. logger.info('[Douyin Publish] In HEADFUL mode, waiting for user to complete captcha in browser window...');
  602. // 通知前端验证码需要手动输入
  603. if (onCaptchaRequired) {
  604. const taskId = `captcha_manual_${Date.now()}`;
  605. onCaptchaRequired({
  606. taskId,
  607. type: 'image',
  608. imageBase64: '',
  609. }).catch(() => { });
  610. }
  611. // 等待验证码弹框消失(用户在浏览器窗口中完成验证)
  612. const captchaTimeout = 180000; // 3 分钟超时
  613. const captchaStartTime = Date.now();
  614. while (Date.now() - captchaStartTime < captchaTimeout) {
  615. await this.page.waitForTimeout(2000);
  616. const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false });
  617. const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false });
  618. const titleVisible = await captchaTitle.count() > 0 && await captchaTitle.first().isVisible().catch(() => false);
  619. const hintVisible = await captchaHint.count() > 0 && await captchaHint.first().isVisible().catch(() => false);
  620. if (!titleVisible && !hintVisible) {
  621. logger.info('[Douyin Publish] Captcha completed by user!');
  622. await this.page.waitForTimeout(2000);
  623. return 'success';
  624. }
  625. const elapsed = Math.floor((Date.now() - captchaStartTime) / 1000);
  626. logger.info(`[Douyin Publish] Waiting for captcha (${elapsed}s)...`);
  627. }
  628. logger.error('[Douyin Publish] Captcha timeout');
  629. return 'failed';
  630. } catch (error) {
  631. logger.error('[Douyin Publish] Image captcha handling error:', error);
  632. return 'not_needed';
  633. }
  634. }
  635. /**
  636. * 处理短信验证码
  637. */
  638. private async handleSmsCaptcha(
  639. onCaptchaRequired?: (captchaInfo: {
  640. taskId: string;
  641. type: 'sms' | 'image';
  642. phone?: string;
  643. imageBase64?: string;
  644. }) => Promise<string>
  645. ): Promise<'success' | 'failed' | 'not_needed'> {
  646. if (!this.page) return 'not_needed';
  647. try {
  648. // 短信验证码弹框选择器
  649. const smsCaptchaSelectors = [
  650. '.second-verify-panel',
  651. '.uc-ui-verify_sms-verify',
  652. '.uc-ui-verify-new_header-title:has-text("接收短信验证码")',
  653. 'article.uc-ui-verify_sms-verify',
  654. ];
  655. let hasSmsCaptcha = false;
  656. for (const selector of smsCaptchaSelectors) {
  657. const element = this.page.locator(selector).first();
  658. const count = await element.count().catch(() => 0);
  659. if (count > 0) {
  660. const isVisible = await element.isVisible().catch(() => false);
  661. if (isVisible) {
  662. hasSmsCaptcha = true;
  663. logger.info(`[Douyin Publish] SMS captcha detected with selector: ${selector}`);
  664. break;
  665. }
  666. }
  667. }
  668. if (!hasSmsCaptcha) {
  669. return 'not_needed';
  670. }
  671. logger.info('[Douyin Publish] SMS captcha modal detected!');
  672. // 截图保存
  673. try {
  674. const screenshotPath = `uploads/debug/sms_captcha_${Date.now()}.png`;
  675. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  676. logger.info(`[Douyin Publish] SMS captcha screenshot saved: ${screenshotPath}`);
  677. } catch { }
  678. // 获取手机号
  679. let phone = '';
  680. try {
  681. const phoneElement = this.page.locator('.var_TextPrimary').first();
  682. if (await phoneElement.count() > 0) {
  683. phone = await phoneElement.textContent() || '';
  684. }
  685. if (!phone) {
  686. const pageText = await this.page.locator('.second-verify-panel, .uc-ui-verify_sms-verify').first().textContent() || '';
  687. const phoneMatch = pageText.match(/1\d{2}\*{4,6}\d{2}/);
  688. if (phoneMatch) phone = phoneMatch[0];
  689. }
  690. logger.info(`[Douyin Publish] Found phone number: ${phone}`);
  691. } catch { }
  692. // 点击获取验证码按钮
  693. const getCaptchaBtnSelectors = [
  694. '.uc-ui-input_right p:has-text("获取验证码")',
  695. '.uc-ui-input_right:has-text("获取验证码")',
  696. 'p:has-text("获取验证码")',
  697. ];
  698. for (const selector of getCaptchaBtnSelectors) {
  699. const btn = this.page.locator(selector).first();
  700. if (await btn.count() > 0 && await btn.isVisible()) {
  701. try {
  702. await btn.click();
  703. logger.info(`[Douyin Publish] Clicked "获取验证码" button: ${selector}`);
  704. await this.page.waitForTimeout(1500);
  705. break;
  706. } catch { }
  707. }
  708. }
  709. if (!onCaptchaRequired) {
  710. logger.error('[Douyin Publish] SMS captcha required but no callback provided');
  711. return 'failed';
  712. }
  713. const taskId = `captcha_${Date.now()}`;
  714. logger.info(`[Douyin Publish] Requesting SMS captcha from user, taskId: ${taskId}, phone: ${phone}`);
  715. const captchaCode = await onCaptchaRequired({
  716. taskId,
  717. type: 'sms',
  718. phone,
  719. });
  720. if (!captchaCode) {
  721. logger.error('[Douyin Publish] No SMS captcha code received');
  722. return 'failed';
  723. }
  724. logger.info(`[Douyin Publish] Received SMS captcha code: ${captchaCode}`);
  725. // 填写验证码
  726. const inputSelectors = [
  727. '.second-verify-panel input[type="number"]',
  728. '.uc-ui-verify_sms-verify input[type="number"]',
  729. '.uc-ui-input input[placeholder="请输入验证码"]',
  730. 'input[placeholder="请输入验证码"]',
  731. '.uc-ui-input_textbox input',
  732. ];
  733. let inputFilled = false;
  734. for (const selector of inputSelectors) {
  735. const input = this.page.locator(selector).first();
  736. if (await input.count() > 0 && await input.isVisible()) {
  737. await input.click();
  738. await input.fill('');
  739. await input.type(captchaCode, { delay: 50 });
  740. inputFilled = true;
  741. logger.info(`[Douyin Publish] SMS captcha code filled via: ${selector}`);
  742. break;
  743. }
  744. }
  745. if (!inputFilled) {
  746. logger.error('[Douyin Publish] SMS captcha input not found');
  747. return 'failed';
  748. }
  749. await this.page.waitForTimeout(500);
  750. // 点击验证按钮
  751. const verifyBtnSelectors = [
  752. '.uc-ui-verify_sms-verify_button:has-text("验证"):not(.disabled)',
  753. '.uc-ui-button:has-text("验证"):not(.disabled)',
  754. '.second-verify-panel .uc-ui-button:has-text("验证")',
  755. 'div.uc-ui-button:has-text("验证")',
  756. ];
  757. for (const selector of verifyBtnSelectors) {
  758. const btn = this.page.locator(selector).first();
  759. if (await btn.count() > 0 && await btn.isVisible()) {
  760. try {
  761. const isDisabled = await btn.evaluate((el: HTMLElement) => el.classList.contains('disabled'));
  762. if (!isDisabled) {
  763. await btn.click();
  764. logger.info(`[Douyin Publish] Clicked SMS verify button: ${selector}`);
  765. break;
  766. }
  767. } catch { }
  768. }
  769. }
  770. // 等待结果
  771. await this.page.waitForTimeout(3000);
  772. // 检查弹框是否消失
  773. let stillHasCaptcha = false;
  774. for (const selector of smsCaptchaSelectors) {
  775. const element = this.page.locator(selector).first();
  776. const isVisible = await element.isVisible().catch(() => false);
  777. if (isVisible) {
  778. stillHasCaptcha = true;
  779. break;
  780. }
  781. }
  782. if (!stillHasCaptcha) {
  783. logger.info('[Douyin Publish] SMS captcha verified successfully');
  784. return 'success';
  785. }
  786. logger.warn('[Douyin Publish] SMS captcha modal still visible');
  787. return 'failed';
  788. } catch (error) {
  789. logger.error('[Douyin Publish] SMS captcha handling error:', error);
  790. return 'not_needed';
  791. }
  792. }
  793. /**
  794. * 检查 Python 发布服务是否可用
  795. */
  796. private async checkPythonServiceAvailable(): Promise<boolean> {
  797. try {
  798. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  799. const response = await fetch(`${pythonUrl}/health`, {
  800. method: 'GET',
  801. signal: AbortSignal.timeout(3000),
  802. });
  803. if (response.ok) {
  804. const data = await response.json();
  805. return data.status === 'ok' && data.supported_platforms?.includes('douyin');
  806. }
  807. return false;
  808. } catch {
  809. return false;
  810. }
  811. }
  812. /**
  813. * 通过 Python 服务发布视频(带 AI 辅助)
  814. * @returns PublishResult - 如果需要验证码,返回 { success: false, needCaptcha: true, captchaType: '...' }
  815. */
  816. private async publishVideoViaPython(
  817. cookies: string,
  818. params: PublishParams,
  819. onProgress?: (progress: number, message: string) => void,
  820. onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise<string>
  821. ): Promise<PublishResult & { needCaptcha?: boolean; captchaType?: string }> {
  822. logger.info('[Douyin Python] Starting publish via Python service with AI assist...');
  823. onProgress?.(5, '正在通过 Python 服务发布...');
  824. try {
  825. // 将相对路径转换为绝对路径
  826. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  827. ? params.videoPath
  828. : path.resolve(SERVER_ROOT, params.videoPath);
  829. const absoluteCoverPath = params.coverPath
  830. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  831. : undefined;
  832. // 使用 AI 辅助发布接口
  833. const extra = (params.extra || {}) as Record<string, unknown>;
  834. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  835. const response = await fetch(`${pythonUrl}/publish/ai-assisted`, {
  836. method: 'POST',
  837. headers: {
  838. 'Content-Type': 'application/json',
  839. },
  840. body: JSON.stringify({
  841. platform: 'douyin',
  842. cookie: cookies,
  843. user_id: extra.userId,
  844. publish_task_id: extra.publishTaskId,
  845. publish_account_id: extra.publishAccountId,
  846. proxy: (extra as any).publishProxy || null,
  847. title: params.title,
  848. description: params.description || params.title,
  849. video_path: absoluteVideoPath,
  850. cover_path: absoluteCoverPath,
  851. tags: params.tags || [],
  852. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  853. location: params.location || '重庆市',
  854. }),
  855. signal: AbortSignal.timeout(600000), // 10分钟超时
  856. });
  857. const result = await response.json();
  858. if (result.success) {
  859. onProgress?.(100, '发布成功');
  860. logger.info('[Douyin Python] Publish successful');
  861. return {
  862. success: true,
  863. platformVideoId: result.video_id || `douyin_${Date.now()}`,
  864. videoUrl: result.video_url || '',
  865. };
  866. }
  867. // 如果返回了截图,使用 AI 分析
  868. if (result.screenshot_base64) {
  869. logger.info('[Douyin Python] Got screenshot, analyzing with AI...');
  870. const { aiService } = await import('../../ai/index.js');
  871. if (aiService.isAvailable()) {
  872. const aiStatus = await aiService.analyzePublishStatus(result.screenshot_base64, 'douyin');
  873. logger.info(`[Douyin Python] AI analysis: status=${aiStatus.status}, confidence=${aiStatus.confidence}%`);
  874. // AI 判断发布成功
  875. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  876. onProgress?.(100, '发布成功');
  877. return {
  878. success: true,
  879. platformVideoId: `douyin_${Date.now()}`,
  880. videoUrl: result.video_url || result.page_url || '',
  881. };
  882. }
  883. // AI 检测到需要验证码
  884. if (aiStatus.status === 'need_captcha') {
  885. logger.info(`[Douyin Python] AI detected captcha: ${aiStatus.captchaDescription}`);
  886. // 如果有验证码回调,尝试处理
  887. if (onCaptchaRequired) {
  888. onProgress?.(50, `AI 检测到验证码: ${aiStatus.captchaDescription || '请输入验证码'}`);
  889. // 返回需要验证码的状态,让上层处理
  890. return {
  891. success: false,
  892. needCaptcha: true,
  893. captchaType: aiStatus.captchaType || 'image',
  894. errorMessage: aiStatus.captchaDescription || '需要验证码',
  895. };
  896. }
  897. }
  898. // AI 判断发布失败
  899. if (aiStatus.status === 'failed') {
  900. throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
  901. }
  902. }
  903. }
  904. // Python 返回需要验证码
  905. if (result.need_captcha || result.status === 'need_captcha') {
  906. logger.info(`[Douyin Python] Captcha required: type=${result.captcha_type}`);
  907. onProgress?.(0, `检测到需要${result.captcha_type || ''}验证码,切换到浏览器模式...`);
  908. return {
  909. success: false,
  910. needCaptcha: true,
  911. captchaType: result.captcha_type || 'image',
  912. errorMessage: result.error || `需要验证码`,
  913. };
  914. }
  915. throw new Error(result.error || '发布失败');
  916. } catch (error) {
  917. logger.error('[Douyin Python] Publish failed:', error);
  918. throw error;
  919. }
  920. }
  921. /**
  922. * 发布视频
  923. * 参考 https://github.com/kebenxiaoming/matrix 项目实现
  924. * 只使用 Python 服务发布,如果检测到验证码返回错误让前端用有头浏览器重试
  925. * @param onCaptchaRequired 验证码回调,返回用户输入的验证码
  926. * @param options.headless 是否使用无头模式,默认 true
  927. */
  928. async publishVideo(
  929. cookies: string,
  930. params: PublishParams,
  931. onProgress?: (progress: number, message: string) => void,
  932. onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
  933. options?: { headless?: boolean }
  934. ): Promise<PublishResult> {
  935. // 只使用 Python 服务发布
  936. const pythonAvailable = await this.checkPythonServiceAvailable();
  937. if (!pythonAvailable) {
  938. logger.error('[Douyin] Python service not available');
  939. return {
  940. success: false,
  941. errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
  942. };
  943. }
  944. logger.info('[Douyin] Using Python service for publishing');
  945. try {
  946. const pythonResult = await this.publishVideoViaPython(cookies, params, onProgress);
  947. // 检查是否需要验证码 - 返回错误让前端用有头浏览器重试
  948. if (pythonResult.needCaptcha) {
  949. logger.info(`[Douyin] Python detected captcha (${pythonResult.captchaType}), need headful browser`);
  950. onProgress?.(0, `检测到${pythonResult.captchaType}验证码,请使用有头浏览器重试...`);
  951. return {
  952. success: false,
  953. errorMessage: `CAPTCHA_REQUIRED:检测到${pythonResult.captchaType}验证码,需要使用有头浏览器完成验证`,
  954. };
  955. }
  956. if (pythonResult.success) {
  957. return pythonResult;
  958. }
  959. return {
  960. success: false,
  961. errorMessage: pythonResult.errorMessage || '发布失败',
  962. };
  963. } catch (pythonError) {
  964. logger.error('[Douyin] Python publish failed:', pythonError);
  965. return {
  966. success: false,
  967. errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
  968. };
  969. }
  970. /* ========== Playwright 方式已注释,只使用 Python API ==========
  971. try {
  972. await this.initBrowser({ headless: useHeadless });
  973. await this.setCookies(cookies);
  974. if (!useHeadless) {
  975. logger.info('[Douyin Publish] Running in HEADFUL mode - browser window is visible');
  976. onProgress?.(1, '已打开浏览器窗口,请注意查看...');
  977. }
  978. if (!this.page) throw new Error('Page not initialized');
  979. // 检查视频文件是否存在
  980. const fs = await import('fs');
  981. if (!fs.existsSync(params.videoPath)) {
  982. throw new Error(`视频文件不存在: ${params.videoPath}`);
  983. }
  984. onProgress?.(5, '正在打开上传页面...');
  985. logger.info(`[Douyin Publish] Starting upload for: ${params.videoPath}`);
  986. // 访问上传页面
  987. await this.page.goto(this.publishUrl, {
  988. waitUntil: 'domcontentloaded',
  989. timeout: 60000,
  990. });
  991. // 等待页面加载
  992. await this.page.waitForTimeout(3000);
  993. logger.info(`[Douyin Publish] Page loaded: ${this.page.url()}`);
  994. onProgress?.(10, '正在选择视频文件...');
  995. // 上传视频 - 优先使用 AI 截图分析找到上传入口
  996. let uploadTriggered = false;
  997. // 方法1: AI 截图分析找到上传入口
  998. logger.info('[Douyin Publish] Using AI to find upload entry...');
  999. try {
  1000. const screenshot = await this.screenshotBase64();
  1001. const guide = await aiService.getPageOperationGuide(screenshot, 'douyin', '找到视频上传入口并点击上传按钮');
  1002. logger.info(`[Douyin Publish] AI analysis result:`, guide);
  1003. if (guide.hasAction && guide.targetSelector) {
  1004. logger.info(`[Douyin Publish] AI suggested selector: ${guide.targetSelector}`);
  1005. try {
  1006. const [fileChooser] = await Promise.all([
  1007. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  1008. this.page.click(guide.targetSelector),
  1009. ]);
  1010. await fileChooser.setFiles(params.videoPath);
  1011. uploadTriggered = true;
  1012. logger.info('[Douyin Publish] Upload triggered via AI selector');
  1013. } catch (e) {
  1014. logger.warn(`[Douyin Publish] AI selector click failed: ${e}`);
  1015. }
  1016. }
  1017. } catch (e) {
  1018. logger.warn(`[Douyin Publish] AI analysis failed: ${e}`);
  1019. }
  1020. // 方法2: 尝试点击常见的上传区域触发 file chooser
  1021. if (!uploadTriggered) {
  1022. logger.info('[Douyin Publish] Trying common upload selectors...');
  1023. const uploadSelectors = [
  1024. // 抖音常见上传区域选择器
  1025. 'div[class*="container-drag-info"]',
  1026. 'div[class*="container-drag"]',
  1027. 'div[class*="upload-drag"]',
  1028. 'div[class*="drag-info"]',
  1029. 'div[class*="upload-btn"]',
  1030. 'div[class*="drag-area"]',
  1031. '[class*="upload"] [class*="drag"]',
  1032. 'div[class*="upload-area"]',
  1033. '.upload-trigger',
  1034. 'button:has-text("上传")',
  1035. 'div:has-text("上传视频"):not(:has(div))',
  1036. 'span:has-text("点击上传")',
  1037. ];
  1038. for (const selector of uploadSelectors) {
  1039. if (uploadTriggered) break;
  1040. try {
  1041. const element = this.page.locator(selector).first();
  1042. if (await element.count() > 0 && await element.isVisible()) {
  1043. logger.info(`[Douyin Publish] Trying selector: ${selector}`);
  1044. const [fileChooser] = await Promise.all([
  1045. this.page.waitForEvent('filechooser', { timeout: 5000 }),
  1046. element.click(),
  1047. ]);
  1048. await fileChooser.setFiles(params.videoPath);
  1049. uploadTriggered = true;
  1050. logger.info(`[Douyin Publish] Upload triggered via selector: ${selector}`);
  1051. }
  1052. } catch (e) {
  1053. // 继续尝试下一个选择器
  1054. }
  1055. }
  1056. }
  1057. // 方法3: 直接设置 file input
  1058. if (!uploadTriggered) {
  1059. logger.info('[Douyin Publish] Trying file input method...');
  1060. const fileInputs = await this.page.$$('input[type="file"]');
  1061. logger.info(`[Douyin Publish] Found ${fileInputs.length} file inputs`);
  1062. for (const fileInput of fileInputs) {
  1063. try {
  1064. const accept = await fileInput.getAttribute('accept');
  1065. if (!accept || accept.includes('video') || accept.includes('*')) {
  1066. await fileInput.setInputFiles(params.videoPath);
  1067. uploadTriggered = true;
  1068. logger.info('[Douyin Publish] Upload triggered via file input');
  1069. break;
  1070. }
  1071. } catch (e) {
  1072. logger.warn(`[Douyin Publish] File input method failed: ${e}`);
  1073. }
  1074. }
  1075. }
  1076. if (!uploadTriggered) {
  1077. // 截图调试
  1078. try {
  1079. if (this.page) {
  1080. const screenshotPath = `uploads/debug/douyin_no_upload_${Date.now()}.png`;
  1081. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1082. logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`);
  1083. }
  1084. } catch {}
  1085. throw new Error('未找到上传入口');
  1086. }
  1087. onProgress?.(15, '视频上传中,等待跳转到发布页面...');
  1088. // 参考 matrix: 等待页面跳转到发布页面
  1089. // URL: https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page
  1090. const maxWaitTime = 180000; // 3分钟
  1091. const startTime = Date.now();
  1092. let lastAiCheckTime = 0;
  1093. const aiCheckInterval = 10000; // 每10秒使用AI检测一次
  1094. while (Date.now() - startTime < maxWaitTime) {
  1095. await this.page.waitForTimeout(2000);
  1096. const currentUrl = this.page.url();
  1097. if (currentUrl.includes('/content/post/video')) {
  1098. logger.info('[Douyin Publish] Entered video post page');
  1099. break;
  1100. }
  1101. // 检查上传进度(通过DOM)
  1102. let progressDetected = false;
  1103. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  1104. if (progressText) {
  1105. const match = progressText.match(/(\d+)%/);
  1106. if (match) {
  1107. const progress = parseInt(match[1]);
  1108. onProgress?.(15 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`);
  1109. progressDetected = true;
  1110. }
  1111. }
  1112. // 使用AI检测上传进度(每隔一段时间检测一次)
  1113. if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) {
  1114. lastAiCheckTime = Date.now();
  1115. try {
  1116. const screenshot = await this.screenshotBase64();
  1117. const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'douyin');
  1118. logger.info(`[Douyin Publish] AI upload status:`, uploadStatus);
  1119. if (uploadStatus.isComplete) {
  1120. logger.info('[Douyin Publish] AI detected upload complete');
  1121. // 继续等待页面跳转
  1122. }
  1123. if (uploadStatus.isFailed) {
  1124. throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
  1125. }
  1126. if (uploadStatus.progress !== null) {
  1127. onProgress?.(15 + Math.floor(uploadStatus.progress * 0.3), `视频上传中: ${uploadStatus.progress}%`);
  1128. }
  1129. } catch (aiError) {
  1130. logger.warn('[Douyin Publish] AI progress check failed:', aiError);
  1131. }
  1132. }
  1133. // 检查是否上传失败
  1134. const failText = await this.page.locator('div:has-text("上传失败")').first().count().catch(() => 0);
  1135. if (failText > 0) {
  1136. throw new Error('视频上传失败');
  1137. }
  1138. }
  1139. if (!this.page.url().includes('/content/post/video')) {
  1140. throw new Error('等待进入发布页面超时');
  1141. }
  1142. onProgress?.(50, '正在填写视频信息...');
  1143. await this.page.waitForTimeout(2000);
  1144. // 参考 matrix: 填充标题
  1145. // 先尝试找到标题输入框
  1146. logger.info('[Douyin Publish] Filling title...');
  1147. // 方式1: 找到 "作品标题" 旁边的 input
  1148. const titleInput = this.page.getByText('作品标题').locator('..').locator('xpath=following-sibling::div[1]').locator('input');
  1149. if (await titleInput.count() > 0) {
  1150. await titleInput.fill(params.title.slice(0, 30));
  1151. logger.info('[Douyin Publish] Title filled via input');
  1152. } else {
  1153. // 方式2: 使用 .notranslate 编辑器(参考 matrix)
  1154. const editorContainer = this.page.locator('.notranslate, [class*="editor"] [contenteditable="true"]').first();
  1155. if (await editorContainer.count() > 0) {
  1156. await editorContainer.click();
  1157. await this.page.keyboard.press('Control+A');
  1158. await this.page.keyboard.press('Backspace');
  1159. await this.page.keyboard.type(params.title, { delay: 30 });
  1160. await this.page.keyboard.press('Enter');
  1161. logger.info('[Douyin Publish] Title filled via editor');
  1162. }
  1163. }
  1164. onProgress?.(60, '正在添加话题标签...');
  1165. // 参考 matrix: 添加话题标签
  1166. // 使用 .zone-container 选择器
  1167. if (params.tags && params.tags.length > 0) {
  1168. const tagContainer = '.zone-container, [class*="mention-container"], [class*="hash-tag"]';
  1169. for (let i = 0; i < params.tags.length; i++) {
  1170. const tag = params.tags[i];
  1171. logger.info(`[Douyin Publish] Adding tag ${i + 1}: ${tag}`);
  1172. try {
  1173. await this.page.type(tagContainer, `#${tag}`, { delay: 50 });
  1174. await this.page.keyboard.press('Space');
  1175. await this.page.waitForTimeout(500);
  1176. } catch (e) {
  1177. // 如果失败,尝试在编辑器中添加
  1178. try {
  1179. await this.page.keyboard.type(` #${tag} `, { delay: 50 });
  1180. await this.page.waitForTimeout(500);
  1181. } catch {
  1182. logger.warn(`[Douyin Publish] Failed to add tag: ${tag}`);
  1183. }
  1184. }
  1185. }
  1186. }
  1187. onProgress?.(70, '等待视频处理完成...');
  1188. // 参考 matrix: 等待 "重新上传" 按钮出现,表示视频上传完成
  1189. const uploadCompleteMaxWait = 600000; // 增加到 10 分钟
  1190. const uploadStartTime = Date.now();
  1191. let videoProcessed = false;
  1192. while (Date.now() - uploadStartTime < uploadCompleteMaxWait) {
  1193. // 检查多种完成标志
  1194. const reuploadCount = await this.page.locator('div').filter({ hasText: '重新上传' }).count().catch(() => 0);
  1195. const replaceCount = await this.page.locator('div:has-text("替换"), button:has-text("替换")').count().catch(() => 0);
  1196. const completeCount = await this.page.locator('[class*="upload-complete"], [class*="upload-success"]').count().catch(() => 0);
  1197. if (reuploadCount > 0 || replaceCount > 0 || completeCount > 0) {
  1198. logger.info('[Douyin Publish] Video upload completed');
  1199. videoProcessed = true;
  1200. break;
  1201. }
  1202. // 检查发布按钮是否可用(也是上传完成的标志)
  1203. const publishBtnEnabled = await this.page.getByRole('button', { name: '发布', exact: true }).isEnabled().catch(() => false);
  1204. if (publishBtnEnabled) {
  1205. logger.info('[Douyin Publish] Publish button is enabled, video should be ready');
  1206. videoProcessed = true;
  1207. break;
  1208. }
  1209. // 检查上传失败
  1210. const failCount = await this.page.locator('div:has-text("上传失败")').count().catch(() => 0);
  1211. if (failCount > 0) {
  1212. throw new Error('视频处理失败');
  1213. }
  1214. const elapsed = Math.floor((Date.now() - uploadStartTime) / 1000);
  1215. logger.info(`[Douyin Publish] Waiting for video processing... (${elapsed}s)`);
  1216. await this.page.waitForTimeout(3000);
  1217. onProgress?.(70 + Math.min(14, Math.floor(elapsed / 20)), `等待视频处理完成 (${elapsed}s)...`);
  1218. }
  1219. if (!videoProcessed) {
  1220. logger.warn('[Douyin Publish] Video processing timeout, but will try to publish anyway');
  1221. }
  1222. // 点击 "我知道了" 弹窗(如果存在)
  1223. const knownBtn = this.page.getByRole('button', { name: '我知道了' });
  1224. if (await knownBtn.count() > 0) {
  1225. await knownBtn.first().click();
  1226. await this.page.waitForTimeout(1000);
  1227. }
  1228. onProgress?.(85, '正在发布...');
  1229. await this.page.waitForTimeout(3000);
  1230. // 参考 matrix: 点击发布按钮
  1231. logger.info('[Douyin Publish] Looking for publish button...');
  1232. // 尝试多种方式找到发布按钮
  1233. let publishClicked = false;
  1234. // 方式1: 使用 getByRole
  1235. const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
  1236. if (await publishBtn.count() > 0) {
  1237. // 等待按钮可点击
  1238. try {
  1239. await publishBtn.waitFor({ state: 'visible', timeout: 10000 });
  1240. const isEnabled = await publishBtn.isEnabled();
  1241. if (isEnabled) {
  1242. await publishBtn.click();
  1243. publishClicked = true;
  1244. logger.info('[Douyin Publish] Publish button clicked via getByRole');
  1245. }
  1246. } catch (e) {
  1247. logger.warn('[Douyin Publish] getByRole method failed:', e);
  1248. }
  1249. }
  1250. // 方式2: 使用选择器
  1251. if (!publishClicked) {
  1252. const selectors = [
  1253. 'button:has-text("发布")',
  1254. '[class*="publish-btn"]',
  1255. 'button[class*="primary"]:has-text("发布")',
  1256. '.semi-button-primary:has-text("发布")',
  1257. ];
  1258. for (const selector of selectors) {
  1259. const btn = this.page.locator(selector).first();
  1260. if (await btn.count() > 0) {
  1261. try {
  1262. const isEnabled = await btn.isEnabled();
  1263. if (isEnabled) {
  1264. await btn.click();
  1265. publishClicked = true;
  1266. logger.info(`[Douyin Publish] Publish button clicked via selector: ${selector}`);
  1267. break;
  1268. }
  1269. } catch { }
  1270. }
  1271. }
  1272. }
  1273. if (!publishClicked) {
  1274. // 截图帮助调试
  1275. try {
  1276. const screenshotPath = `uploads/debug/no_publish_btn_${Date.now()}.png`;
  1277. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1278. logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`);
  1279. } catch { }
  1280. throw new Error('未找到可点击的发布按钮');
  1281. }
  1282. logger.info('[Douyin Publish] Publish button clicked, waiting for result...');
  1283. // 点击发布后截图
  1284. await this.page.waitForTimeout(2000);
  1285. try {
  1286. const screenshotPath = `uploads/debug/after_publish_click_${Date.now()}.png`;
  1287. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1288. logger.info(`[Douyin Publish] After click screenshot saved: ${screenshotPath}`);
  1289. } catch { }
  1290. // 检查是否有确认弹窗需要处理
  1291. const confirmSelectors = [
  1292. 'button:has-text("确认发布")',
  1293. 'button:has-text("确定")',
  1294. 'button:has-text("确认")',
  1295. '.semi-modal button:has-text("发布")',
  1296. '[class*="modal"] button[class*="primary"]',
  1297. ];
  1298. for (const selector of confirmSelectors) {
  1299. const confirmBtn = this.page.locator(selector).first();
  1300. if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
  1301. logger.info(`[Douyin Publish] Found confirm button: ${selector}`);
  1302. await confirmBtn.click();
  1303. await this.page.waitForTimeout(2000);
  1304. logger.info('[Douyin Publish] Confirm button clicked');
  1305. break;
  1306. }
  1307. }
  1308. // 检查是否需要验证码
  1309. const captchaHandled = await this.handleCaptchaIfNeeded(onCaptchaRequired);
  1310. if (captchaHandled === 'failed') {
  1311. throw new Error('验证码验证失败');
  1312. }
  1313. // 如果检测到验证码,通知前端需要手动验证
  1314. if (captchaHandled === 'need_retry_headful') {
  1315. logger.info('[Douyin Publish] Captcha detected, requesting user to complete verification...');
  1316. onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试');
  1317. await this.closeBrowser();
  1318. // 返回特殊错误,让前端知道需要手动验证
  1319. return {
  1320. success: false,
  1321. errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布',
  1322. };
  1323. }
  1324. onProgress?.(90, '等待发布完成...');
  1325. // 参考 matrix: 等待跳转到管理页面表示发布成功
  1326. // URL: https://creator.douyin.com/creator-micro/content/manage
  1327. const publishMaxWait = 180000; // 3 分钟
  1328. const publishStartTime = Date.now();
  1329. // 记录点击发布时的 URL,用于检测是否跳转
  1330. const publishPageUrl = this.page.url();
  1331. logger.info(`[Douyin Publish] Publish page URL: ${publishPageUrl}`);
  1332. // AI 检测计数器,避免过于频繁
  1333. let aiCheckCounter = 0;
  1334. let lastProgressCheckTime = 0;
  1335. const progressCheckInterval = 5000; // 每5秒检测一次发布进度
  1336. while (Date.now() - publishStartTime < publishMaxWait) {
  1337. await this.page.waitForTimeout(3000);
  1338. const currentUrl = this.page.url();
  1339. const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
  1340. logger.info(`[Douyin Publish] Waiting for redirect (${elapsed}s), current URL: ${currentUrl}`);
  1341. // 在等待过程中也检测验证码弹框
  1342. const captchaResult = await this.handleCaptchaIfNeeded(onCaptchaRequired);
  1343. if (captchaResult === 'failed') {
  1344. throw new Error('验证码验证失败');
  1345. }
  1346. // 如果检测到验证码,通知前端需要手动验证
  1347. if (captchaResult === 'need_retry_headful') {
  1348. logger.info('[Douyin Publish] Captcha detected in wait loop, requesting user verification...');
  1349. onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试');
  1350. await this.closeBrowser();
  1351. return {
  1352. success: false,
  1353. errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布',
  1354. };
  1355. }
  1356. // 检查是否跳转到管理页面
  1357. if (currentUrl.includes('/content/manage')) {
  1358. logger.info('[Douyin Publish] Redirected to manage page, checking for background upload...');
  1359. // 检查是否有后台上传进度条(抖音特有:右下角的上传进度框)
  1360. const uploadProgressBox = await this.page.locator('[class*="upload-progress"], [class*="uploading"], div:has-text("作品上传中"), div:has-text("请勿关闭页面")').count().catch(() => 0);
  1361. const uploadProgressText = await this.page.locator('div:has-text("上传中"), div:has-text("上传完成后")').first().textContent().catch(() => '');
  1362. // 如果有后台上传进度,继续等待
  1363. if (uploadProgressBox > 0 || uploadProgressText.includes('上传')) {
  1364. logger.info(`[Douyin Publish] Background upload in progress: ${uploadProgressText}`);
  1365. const match = uploadProgressText.match(/(\d+)%/);
  1366. if (match) {
  1367. const progress = parseInt(match[1]);
  1368. onProgress?.(85 + Math.floor(progress * 0.15), `后台上传中: ${progress}%`);
  1369. if (progress >= 100) {
  1370. logger.info('[Douyin Publish] Background upload complete!');
  1371. onProgress?.(100, '发布成功!');
  1372. await this.closeBrowser();
  1373. return { success: true, videoUrl: currentUrl };
  1374. }
  1375. }
  1376. // 继续等待上传完成
  1377. continue;
  1378. }
  1379. // 没有后台上传进度,发布完成
  1380. logger.info('[Douyin Publish] No background upload, publish complete!');
  1381. onProgress?.(100, '发布成功!');
  1382. await this.closeBrowser();
  1383. return {
  1384. success: true,
  1385. videoUrl: currentUrl,
  1386. };
  1387. }
  1388. // 检查发布进度条(DOM方式)
  1389. const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"], [class*="publishing"]').first().textContent().catch(() => '');
  1390. if (publishProgressText) {
  1391. const match = publishProgressText.match(/(\d+)%/);
  1392. if (match) {
  1393. const progress = parseInt(match[1]);
  1394. onProgress?.(85 + Math.floor(progress * 0.15), `发布中: ${progress}%`);
  1395. logger.info(`[Douyin Publish] Publish progress: ${progress}%`);
  1396. }
  1397. }
  1398. // AI检测发布进度(定期检测)
  1399. if (Date.now() - lastProgressCheckTime > progressCheckInterval) {
  1400. lastProgressCheckTime = Date.now();
  1401. try {
  1402. const screenshot = await this.screenshotBase64();
  1403. const publishStatus = await aiService.analyzePublishProgress(screenshot, 'douyin');
  1404. logger.info(`[Douyin Publish] AI publish progress status:`, publishStatus);
  1405. if (publishStatus.isComplete) {
  1406. logger.info('[Douyin Publish] AI detected publish complete');
  1407. onProgress?.(100, '发布成功!');
  1408. await this.closeBrowser();
  1409. return { success: true, videoUrl: this.page.url() };
  1410. }
  1411. if (publishStatus.isFailed) {
  1412. throw new Error(`发布失败: ${publishStatus.statusDescription}`);
  1413. }
  1414. if (publishStatus.progress !== null) {
  1415. onProgress?.(85 + Math.floor(publishStatus.progress * 0.15), `发布中: ${publishStatus.progress}%`);
  1416. }
  1417. if (publishStatus.isPublishing) {
  1418. logger.info(`[Douyin Publish] Still publishing: ${publishStatus.statusDescription}`);
  1419. }
  1420. } catch (aiError) {
  1421. logger.warn('[Douyin Publish] AI publish progress check failed:', aiError);
  1422. }
  1423. }
  1424. // 检查是否有成功提示弹窗(Toast/Modal)
  1425. // 使用更精确的选择器,避免匹配按钮文字
  1426. 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);
  1427. if (successToast > 0) {
  1428. logger.info('[Douyin Publish] Found success toast/modal');
  1429. // 等待一下看是否会跳转
  1430. await this.page.waitForTimeout(5000);
  1431. const newUrl = this.page.url();
  1432. if (newUrl.includes('/content/manage')) {
  1433. logger.info('[Douyin Publish] Redirected to manage page after success toast');
  1434. onProgress?.(100, '发布成功!');
  1435. await this.closeBrowser();
  1436. return {
  1437. success: true,
  1438. videoUrl: newUrl,
  1439. };
  1440. }
  1441. }
  1442. // 检查是否有明确的错误提示弹窗
  1443. const errorToast = await this.page.locator('.semi-toast-error, [class*="toast-error"], .semi-modal-body:has-text("失败")').first().textContent().catch(() => '');
  1444. if (errorToast && errorToast.includes('失败')) {
  1445. logger.error(`[Douyin Publish] Error toast found: ${errorToast}`);
  1446. throw new Error(`发布失败: ${errorToast}`);
  1447. }
  1448. // 每隔几次循环使用 AI 辅助检测发布状态
  1449. aiCheckCounter++;
  1450. if (aiCheckCounter >= 3) {
  1451. aiCheckCounter = 0;
  1452. const aiStatus = await this.aiAnalyzePublishStatus();
  1453. if (aiStatus) {
  1454. logger.info(`[Douyin Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
  1455. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  1456. logger.info('[Douyin Publish] AI detected publish success');
  1457. onProgress?.(100, '发布成功!');
  1458. await this.closeBrowser();
  1459. return {
  1460. success: true,
  1461. videoUrl: currentUrl,
  1462. };
  1463. }
  1464. if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
  1465. logger.error(`[Douyin Publish] AI detected failure: ${aiStatus.errorMessage}`);
  1466. throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
  1467. }
  1468. // AI 建议需要操作
  1469. if (aiStatus.status === 'need_action' && aiStatus.nextAction) {
  1470. logger.info(`[Douyin Publish] AI suggests action: ${aiStatus.nextAction.targetDescription}`);
  1471. const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription);
  1472. if (guide?.hasAction) {
  1473. await this.aiExecuteOperation(guide);
  1474. }
  1475. }
  1476. }
  1477. }
  1478. // 更新进度
  1479. onProgress?.(90 + Math.min(9, Math.floor(elapsed / 20)), `等待发布完成 (${elapsed}s)...`);
  1480. }
  1481. // 如果超时,使用 AI 做最后一次状态检查
  1482. const finalUrl = this.page.url();
  1483. logger.info(`[Douyin Publish] Timeout! Final URL: ${finalUrl}`);
  1484. // AI 最终检查
  1485. const finalAiStatus = await this.aiAnalyzePublishStatus();
  1486. if (finalAiStatus) {
  1487. logger.info(`[Douyin Publish] Final AI status: ${finalAiStatus.status}`);
  1488. if (finalAiStatus.status === 'success') {
  1489. onProgress?.(100, '发布成功!');
  1490. await this.closeBrowser();
  1491. return {
  1492. success: true,
  1493. videoUrl: finalUrl,
  1494. };
  1495. }
  1496. if (finalAiStatus.status === 'failed') {
  1497. throw new Error(finalAiStatus.errorMessage || 'AI 检测到发布失败');
  1498. }
  1499. }
  1500. if (finalUrl.includes('/content/manage')) {
  1501. onProgress?.(100, '发布成功!');
  1502. await this.closeBrowser();
  1503. return {
  1504. success: true,
  1505. videoUrl: finalUrl,
  1506. };
  1507. }
  1508. // 截图保存用于调试
  1509. try {
  1510. const screenshotPath = `uploads/debug/publish_timeout_${Date.now()}.png`;
  1511. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1512. logger.info(`[Douyin Publish] Timeout screenshot saved: ${screenshotPath}`);
  1513. } catch { }
  1514. throw new Error('发布超时,页面未跳转到管理页面,请手动检查是否发布成功');
  1515. } catch (error) {
  1516. logger.error('[Douyin Publish] Error:', error);
  1517. await this.closeBrowser();
  1518. return {
  1519. success: false,
  1520. errorMessage: error instanceof Error ? error.message : '发布失败',
  1521. };
  1522. }
  1523. ========== Playwright 方式已注释结束 ========== */
  1524. }
  1525. /**
  1526. * 通过 Python API 获取评论
  1527. */
  1528. private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
  1529. logger.info('[Douyin] Getting comments via Python API...');
  1530. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  1531. const response = await fetch(`${pythonUrl}/comments`, {
  1532. method: 'POST',
  1533. headers: {
  1534. 'Content-Type': 'application/json',
  1535. },
  1536. body: JSON.stringify({
  1537. platform: 'douyin',
  1538. cookie: cookies,
  1539. work_id: videoId,
  1540. }),
  1541. });
  1542. if (!response.ok) {
  1543. throw new Error(`Python API returned ${response.status}`);
  1544. }
  1545. const result = await response.json();
  1546. if (!result.success) {
  1547. throw new Error(result.error || 'Failed to get comments');
  1548. }
  1549. // 转换数据格式
  1550. return (result.comments || []).map((comment: {
  1551. comment_id: string;
  1552. author_id: string;
  1553. author_name: string;
  1554. author_avatar: string;
  1555. content: string;
  1556. like_count: number;
  1557. create_time: string;
  1558. reply_count: number;
  1559. replies?: Array<{
  1560. comment_id: string;
  1561. author_id: string;
  1562. author_name: string;
  1563. author_avatar: string;
  1564. content: string;
  1565. like_count: number;
  1566. create_time: string;
  1567. }>;
  1568. }) => ({
  1569. commentId: comment.comment_id,
  1570. authorId: comment.author_id,
  1571. authorName: comment.author_name,
  1572. authorAvatar: comment.author_avatar,
  1573. content: comment.content,
  1574. likeCount: comment.like_count,
  1575. commentTime: comment.create_time,
  1576. replyCount: comment.reply_count,
  1577. replies: comment.replies?.map((reply: {
  1578. comment_id: string;
  1579. author_id: string;
  1580. author_name: string;
  1581. author_avatar: string;
  1582. content: string;
  1583. like_count: number;
  1584. create_time: string;
  1585. }) => ({
  1586. commentId: reply.comment_id,
  1587. authorId: reply.author_id,
  1588. authorName: reply.author_name,
  1589. authorAvatar: reply.author_avatar,
  1590. content: reply.content,
  1591. likeCount: reply.like_count,
  1592. commentTime: reply.create_time,
  1593. })),
  1594. }));
  1595. }
  1596. /**
  1597. * 获取评论列表
  1598. */
  1599. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  1600. // 优先尝试使用 Python API
  1601. const pythonAvailable = await this.checkPythonServiceAvailable();
  1602. if (pythonAvailable) {
  1603. logger.info('[Douyin] Python service available, using Python API for comments');
  1604. try {
  1605. return await this.getCommentsViaPython(cookies, videoId);
  1606. } catch (pythonError) {
  1607. logger.warn('[Douyin] Python API getComments failed, falling back to Playwright:', pythonError);
  1608. }
  1609. }
  1610. // 回退到 Playwright 方式
  1611. try {
  1612. // 使用无头浏览器后台运行
  1613. await this.initBrowser({ headless: true });
  1614. await this.setCookies(cookies);
  1615. if (!this.page) throw new Error('Page not initialized');
  1616. // 访问评论管理页面
  1617. await this.page.goto(`https://creator.douyin.com/creator-micro/content/comment?video_id=${videoId}`);
  1618. await this.page.waitForLoadState('networkidle');
  1619. // 获取评论列表
  1620. const comments = await this.page.$$eval('.comment-item', items =>
  1621. items.map(item => ({
  1622. commentId: item.getAttribute('data-id') || '',
  1623. authorId: item.querySelector('.author-id')?.textContent?.trim() || '',
  1624. authorName: item.querySelector('.author-name')?.textContent?.trim() || '',
  1625. authorAvatar: item.querySelector('.author-avatar img')?.getAttribute('src') || '',
  1626. content: item.querySelector('.comment-content')?.textContent?.trim() || '',
  1627. likeCount: parseInt(item.querySelector('.like-count')?.textContent || '0'),
  1628. commentTime: item.querySelector('.comment-time')?.textContent?.trim() || '',
  1629. }))
  1630. );
  1631. await this.closeBrowser();
  1632. return comments;
  1633. } catch (error) {
  1634. logger.error('Douyin getComments error:', error);
  1635. await this.closeBrowser();
  1636. return [];
  1637. }
  1638. }
  1639. /**
  1640. * 回复评论
  1641. */
  1642. async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
  1643. try {
  1644. // 使用无头浏览器后台运行
  1645. await this.initBrowser({ headless: true });
  1646. await this.setCookies(cookies);
  1647. if (!this.page) throw new Error('Page not initialized');
  1648. // 这里需要实现具体的回复逻辑
  1649. // 由于抖音页面结构可能变化,具体实现需要根据实际情况调整
  1650. logger.info(`Reply to comment ${commentId}: ${content}`);
  1651. await this.closeBrowser();
  1652. return true;
  1653. } catch (error) {
  1654. logger.error('Douyin replyComment error:', error);
  1655. await this.closeBrowser();
  1656. return false;
  1657. }
  1658. }
  1659. /**
  1660. * 获取数据统计
  1661. */
  1662. async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
  1663. try {
  1664. // 使用无头浏览器后台运行
  1665. await this.initBrowser({ headless: true });
  1666. await this.setCookies(cookies);
  1667. if (!this.page) throw new Error('Page not initialized');
  1668. // 访问数据中心
  1669. await this.page.goto('https://creator.douyin.com/creator-micro/data/overview');
  1670. await this.page.waitForLoadState('networkidle');
  1671. // 获取数据
  1672. // 这里需要根据实际页面结构获取数据
  1673. await this.closeBrowser();
  1674. return {
  1675. fansCount: 0,
  1676. fansIncrease: 0,
  1677. viewsCount: 0,
  1678. likesCount: 0,
  1679. commentsCount: 0,
  1680. sharesCount: 0,
  1681. };
  1682. } catch (error) {
  1683. logger.error('Douyin getAnalytics error:', error);
  1684. await this.closeBrowser();
  1685. throw error;
  1686. }
  1687. }
  1688. /**
  1689. * 删除已发布的作品
  1690. */
  1691. async deleteWork(
  1692. cookies: string,
  1693. videoId: string,
  1694. onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
  1695. ): Promise<{ success: boolean; errorMessage?: string }> {
  1696. try {
  1697. // 使用无头浏览器后台运行
  1698. await this.initBrowser({ headless: true });
  1699. await this.setCookies(cookies);
  1700. if (!this.page) throw new Error('Page not initialized');
  1701. logger.info(`[Douyin Delete] Starting delete for video: ${videoId}`);
  1702. // 访问内容管理页面
  1703. await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', {
  1704. waitUntil: 'networkidle',
  1705. timeout: 60000,
  1706. });
  1707. await this.page.waitForTimeout(3000);
  1708. // 找到对应视频的操作按钮
  1709. // 视频列表通常有 data-aweme-id 属性或者可以通过视频 ID 定位
  1710. const videoCard = this.page.locator(`[data-aweme-id="${videoId}"], [data-video-id="${videoId}"]`).first();
  1711. // 如果没找到,尝试通过其他方式定位
  1712. let found = await videoCard.count() > 0;
  1713. if (!found) {
  1714. // 尝试遍历视频列表找到对应的
  1715. const videoCards = this.page.locator('[class*="video-card"], [class*="content-item"], [class*="aweme-item"]');
  1716. const count = await videoCards.count();
  1717. for (let i = 0; i < count; i++) {
  1718. const card = videoCards.nth(i);
  1719. const html = await card.innerHTML().catch(() => '');
  1720. if (html.includes(videoId)) {
  1721. // 找到对应的视频卡片,点击更多操作
  1722. const moreBtn = card.locator('[class*="more"], [class*="action"]').first();
  1723. if (await moreBtn.count() > 0) {
  1724. await moreBtn.click();
  1725. found = true;
  1726. break;
  1727. }
  1728. }
  1729. }
  1730. }
  1731. if (!found) {
  1732. // 直接访问视频详情页尝试删除
  1733. await this.page.goto(`https://creator.douyin.com/creator-micro/content/manage?aweme_id=${videoId}`, {
  1734. waitUntil: 'networkidle',
  1735. });
  1736. await this.page.waitForTimeout(2000);
  1737. }
  1738. // 查找并点击"更多"按钮或"..."
  1739. const moreSelectors = [
  1740. 'button:has-text("更多")',
  1741. '[class*="more-action"]',
  1742. '[class*="dropdown-trigger"]',
  1743. 'button[class*="more"]',
  1744. '.semi-dropdown-trigger',
  1745. ];
  1746. for (const selector of moreSelectors) {
  1747. const moreBtn = this.page.locator(selector).first();
  1748. if (await moreBtn.count() > 0) {
  1749. await moreBtn.click();
  1750. await this.page.waitForTimeout(500);
  1751. break;
  1752. }
  1753. }
  1754. // 查找并点击"删除"选项
  1755. const deleteSelectors = [
  1756. 'div:has-text("删除"):not(:has(*))',
  1757. '[class*="dropdown-item"]:has-text("删除")',
  1758. 'li:has-text("删除")',
  1759. 'span:has-text("删除")',
  1760. ];
  1761. for (const selector of deleteSelectors) {
  1762. const deleteBtn = this.page.locator(selector).first();
  1763. if (await deleteBtn.count() > 0) {
  1764. await deleteBtn.click();
  1765. logger.info('[Douyin Delete] Delete button clicked');
  1766. break;
  1767. }
  1768. }
  1769. await this.page.waitForTimeout(1000);
  1770. // 检查是否需要验证码
  1771. const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
  1772. if (captchaVisible && onCaptchaRequired) {
  1773. logger.info('[Douyin Delete] Captcha required');
  1774. // 点击发送验证码
  1775. const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
  1776. if (await sendCodeBtn.count() > 0) {
  1777. await sendCodeBtn.click();
  1778. logger.info('[Douyin Delete] Verification code sent');
  1779. }
  1780. // 通过回调获取验证码
  1781. const taskId = `delete_${videoId}_${Date.now()}`;
  1782. const code = await onCaptchaRequired({ taskId });
  1783. if (code) {
  1784. // 输入验证码
  1785. const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
  1786. if (await codeInput.count() > 0) {
  1787. await codeInput.fill(code);
  1788. logger.info('[Douyin Delete] Verification code entered');
  1789. }
  1790. // 点击确认按钮
  1791. const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
  1792. if (await confirmBtn.count() > 0) {
  1793. await confirmBtn.click();
  1794. await this.page.waitForTimeout(2000);
  1795. }
  1796. }
  1797. }
  1798. // 确认删除(可能有二次确认弹窗)
  1799. const confirmDeleteSelectors = [
  1800. 'button:has-text("确认删除")',
  1801. 'button:has-text("确定")',
  1802. '.semi-modal-footer button:has-text("确定")',
  1803. ];
  1804. for (const selector of confirmDeleteSelectors) {
  1805. const confirmBtn = this.page.locator(selector).first();
  1806. if (await confirmBtn.count() > 0) {
  1807. await confirmBtn.click();
  1808. await this.page.waitForTimeout(1000);
  1809. }
  1810. }
  1811. logger.info('[Douyin Delete] Delete completed');
  1812. await this.closeBrowser();
  1813. return { success: true };
  1814. } catch (error) {
  1815. logger.error('[Douyin Delete] Error:', error);
  1816. await this.closeBrowser();
  1817. return {
  1818. success: false,
  1819. errorMessage: error instanceof Error ? error.message : '删除失败',
  1820. };
  1821. }
  1822. }
  1823. /**
  1824. * 解析数量字符串
  1825. */
  1826. private parseCount(text: string): number {
  1827. text = text.replace(/,/g, '');
  1828. if (text.includes('万')) {
  1829. return Math.floor(parseFloat(text.replace('万', '')) * 10000);
  1830. }
  1831. if (text.includes('w')) {
  1832. return Math.floor(parseFloat(text.replace('w', '')) * 10000);
  1833. }
  1834. if (text.includes('亿')) {
  1835. return Math.floor(parseFloat(text.replace('亿', '')) * 100000000);
  1836. }
  1837. return parseInt(text) || 0;
  1838. }
  1839. }