/// import path from 'path'; import { BasePlatformAdapter } from './base.js'; import type { AccountProfile, PublishParams, PublishResult, DateRange, AnalyticsData, CommentData, WorkItem, } from './base.js'; import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared'; import { logger } from '../../utils/logger.js'; import { getPythonServiceBaseUrl } from '../../services/PythonServiceConfigService.js'; // 服务器根目录(用于构造绝对路径) const SERVER_ROOT = path.resolve(process.cwd()); /** * 抖音平台适配器 */ export class DouyinAdapter extends BasePlatformAdapter { readonly platform: PlatformType = 'douyin'; readonly loginUrl = 'https://creator.douyin.com/'; readonly publishUrl = 'https://creator.douyin.com/creator-micro/content/upload'; /** * 获取扫码登录二维码 */ async getQRCode(): Promise { try { await this.initBrowser(); if (!this.page) throw new Error('Page not initialized'); // 访问创作者中心 await this.page.goto(this.loginUrl); // 等待二维码出现 await this.waitForSelector('.qrcode-image', 30000); // 获取二维码图片 const qrcodeImg = await this.page.$('.qrcode-image img'); const qrcodeUrl = await qrcodeImg?.getAttribute('src'); if (!qrcodeUrl) { throw new Error('Failed to get QR code'); } const qrcodeKey = `douyin_${Date.now()}`; return { qrcodeUrl, qrcodeKey, expireTime: Date.now() + 300000, // 5分钟过期 }; } catch (error) { logger.error('Douyin getQRCode error:', error); throw error; } } /** * 检查扫码状态 */ async checkQRCodeStatus(qrcodeKey: string): Promise { try { if (!this.page) { return { status: 'expired', message: '二维码已过期' }; } // 检查是否登录成功(URL 变化或出现用户头像) const currentUrl = this.page.url(); if (currentUrl.includes('/creator-micro/home')) { // 在判断登录成功之前,先检查是否有二次校验弹框 // 抖音二次校验弹框特征:标题"身份验证"、选项包含"接收短信验证码"等 const hasSecondaryVerification = await this.checkSecondaryVerification(); if (hasSecondaryVerification) { logger.info('[Douyin] Secondary verification detected, waiting for user to complete'); return { status: 'scanned', message: '需要二次校验,请在手机上完成身份验证' }; } // 登录成功,获取 cookie const cookies = await this.getCookies(); await this.closeBrowser(); return { status: 'success', message: '登录成功', cookies, }; } // 检查是否有二次校验弹框(扫码后可能直接弹出,URL 还没变化) const hasSecondaryVerification = await this.checkSecondaryVerification(); if (hasSecondaryVerification) { logger.info('[Douyin] Secondary verification detected after scan'); return { status: 'scanned', message: '需要二次校验,请在手机上完成身份验证' }; } // 检查是否扫码 const scanTip = await this.page.$('.scan-tip'); if (scanTip) { return { status: 'scanned', message: '已扫码,请确认登录' }; } return { status: 'waiting', message: '等待扫码' }; } catch (error) { logger.error('Douyin checkQRCodeStatus error:', error); return { status: 'error', message: '检查状态失败' }; } } /** * 检查是否存在二次校验弹框 * 抖音登录时可能弹出身份验证弹框,要求短信验证码或刷脸验证 */ private async checkSecondaryVerification(): Promise { if (!this.page) return false; try { // 检测多种可能的二次校验弹框 // 1. 身份验证弹框(标题"身份验证") const verifyTitle = this.page.getByText('身份验证', { exact: false }); const titleCount = await verifyTitle.count().catch(() => 0); if (titleCount > 0) { const isVisible = await verifyTitle.first().isVisible().catch(() => false); if (isVisible) { logger.info('[Douyin] Found "身份验证" dialog'); return true; } } // 2. 检查是否有"接收短信验证码"选项 const smsOption = this.page.getByText('接收短信验证码', { exact: false }); const smsCount = await smsOption.count().catch(() => 0); if (smsCount > 0) { const isVisible = await smsOption.first().isVisible().catch(() => false); if (isVisible) { logger.info('[Douyin] Found "接收短信验证码" option'); return true; } } // 3. 检查是否有"手机刷脸验证"选项 const faceOption = this.page.getByText('手机刷脸验证', { exact: false }); const faceCount = await faceOption.count().catch(() => 0); if (faceCount > 0) { const isVisible = await faceOption.first().isVisible().catch(() => false); if (isVisible) { logger.info('[Douyin] Found "手机刷脸验证" option'); return true; } } // 4. 检查是否有"发送短信验证"选项 const sendSmsOption = this.page.getByText('发送短信验证', { exact: false }); const sendSmsCount = await sendSmsOption.count().catch(() => 0); if (sendSmsCount > 0) { const isVisible = await sendSmsOption.first().isVisible().catch(() => false); if (isVisible) { logger.info('[Douyin] Found "发送短信验证" option'); return true; } } // 5. 检查页面内容中是否包含二次校验关键文本 const pageContent = await this.page.content().catch(() => ''); if (pageContent.includes('为保障账号安全') && (pageContent.includes('身份验证') || pageContent.includes('完成身份验证'))) { logger.info('[Douyin] Found secondary verification text in page content'); return true; } // 6. 检查是否有验证相关的弹框容器 const verifySelectors = [ '[class*="verify-modal"]', '[class*="identity-verify"]', '[class*="second-verify"]', '[class*="security-verify"]', ]; for (const selector of verifySelectors) { const element = this.page.locator(selector).first(); const count = await element.count().catch(() => 0); if (count > 0) { const isVisible = await element.isVisible().catch(() => false); if (isVisible) { logger.info(`[Douyin] Found verification modal via selector: ${selector}`); return true; } } } return false; } catch (error) { logger.error('[Douyin] Error checking secondary verification:', error); return false; } } /** * 检查登录状态 */ async checkLoginStatus(cookies: string): Promise { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); // 访问个人中心 await this.page.goto('https://creator.douyin.com/creator-micro/home', { waitUntil: 'domcontentloaded', timeout: 30000, }); // 等待页面稳定 await this.page.waitForTimeout(3000); const url = this.page.url(); logger.info(`Douyin checkLoginStatus URL: ${url}`); // 如果被重定向到登录页面,说明未登录 const isLoginPage = url.includes('login') || url.includes('passport') || url.includes('sso'); // 额外检查:页面上是否有登录相关的元素 if (!isLoginPage) { // 检查是否有登录按钮或二维码(说明需要登录) const hasLoginButton = await this.page.$('[class*="login"], [class*="qrcode"], .login-btn'); if (hasLoginButton) { logger.info('Douyin: Found login elements on page, cookie may be expired'); await this.closeBrowser(); return false; } } await this.closeBrowser(); return !isLoginPage; } catch (error) { logger.error('Douyin checkLoginStatus error:', error); await this.closeBrowser(); return false; } } /** * 获取账号信息 * 通过拦截 API 响应获取准确数据 */ async getAccountInfo(cookies: string): Promise { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); let accountName = '未知账号'; let avatarUrl = ''; let fansCount = 0; let worksCount = 0; let accountId = ''; let worksList: WorkItem[] = []; // 捕获的 API 数据 const capturedData: { userInfo?: { nickname?: string; avatar?: string; uid?: string; fans?: number; following?: number; }; dataOverview?: { fans_count?: number; total_works?: number; total_play?: number; }; workList?: { total?: number; items?: any[]; aweme_list?: any[]; }; } = {}; // 设置 API 响应监听器 this.page.on('response', async (response) => { const url = response.url(); try { // 监听用户信息 API if (url.includes('/creator/user/info') || url.includes('/user/info') || url.includes('/passport/sso/check')) { const data = await response.json(); logger.info(`[Douyin API] User info:`, JSON.stringify(data).slice(0, 500)); const userInfo = data?.user || data?.data?.user || data?.data; if (userInfo) { capturedData.userInfo = { nickname: userInfo.nickname || userInfo.nick_name || userInfo.name, avatar: userInfo.avatar_url || userInfo.avatar || userInfo.avatar_larger?.url_list?.[0], uid: userInfo.uid || userInfo.user_id || userInfo.sec_uid, fans: userInfo.follower_count || userInfo.fans_count, following: userInfo.following_count, }; logger.info(`[Douyin API] Captured user:`, capturedData.userInfo); } } // 监听数据概览 API if (url.includes('/data/overview') || url.includes('/creator-micro/data') || url.includes('/data_center/overview')) { const data = await response.json(); logger.info(`[Douyin API] Data overview:`, JSON.stringify(data).slice(0, 500)); if (data?.data) { capturedData.dataOverview = { fans_count: data.data.fans_count || data.data.follower_count, total_works: data.data.total_item_cnt || data.data.works_count || data.data.video_count, total_play: data.data.total_play_cnt, }; logger.info(`[Douyin API] Captured data overview:`, capturedData.dataOverview); } } // 监听首页数据 if (url.includes('/creator-micro/home') && url.includes('api')) { const data = await response.json(); logger.info(`[Douyin API] Home data:`, JSON.stringify(data).slice(0, 500)); } // 监听作品列表 API - 获取准确的作品总数 if (url.includes('/janus/douyin/creator/pc/work_list')) { const data = await response.json(); logger.info(`[Douyin API] Work list:`, JSON.stringify(data).slice(0, 500)); const awemeList = data.aweme_list || data.items || []; let totalWorks = data.total || 0; // 如果API没有返回total,尝试从第一个作品的作者信息获取 if (!totalWorks && awemeList.length > 0) { const firstAweme = awemeList[0]; const authorAwemeCount = firstAweme?.author?.aweme_count; if (authorAwemeCount && authorAwemeCount > 0) { totalWorks = authorAwemeCount; logger.info(`[Douyin API] 从 author.aweme_count 获取总作品数: ${totalWorks}`); } } capturedData.workList = { total: totalWorks, items: awemeList, aweme_list: awemeList, }; logger.info(`[Douyin API] Captured work list: total=${totalWorks}, count=${awemeList.length}`); } } catch { // 忽略非 JSON 响应 } }); // 访问创作者中心首页 logger.info('[Douyin] Navigating to creator home...'); await this.page.goto('https://creator.douyin.com/creator-micro/home', { waitUntil: 'networkidle', timeout: 30000, }); await this.page.waitForTimeout(4000); // 尝试从 Cookie 获取账号ID try { const cookieList = JSON.parse(cookies); const uidCookie = cookieList.find((c: { name: string }) => c.name === 'passport_uid' || c.name === 'uid' || c.name === 'ttwid' ); if (uidCookie) { accountId = uidCookie.value; } } catch { } // 使用捕获的 API 数据 if (capturedData.userInfo) { if (capturedData.userInfo.nickname) accountName = capturedData.userInfo.nickname; if (capturedData.userInfo.avatar) avatarUrl = capturedData.userInfo.avatar; if (capturedData.userInfo.uid) accountId = capturedData.userInfo.uid; if (capturedData.userInfo.fans) fansCount = capturedData.userInfo.fans; } if (capturedData.dataOverview) { // dataOverview.fans_count 可能不准确(可能是增量而非总量),不覆盖 userInfo.fans if (capturedData.dataOverview.total_works) worksCount = capturedData.dataOverview.total_works; } // 使用作品列表 API 数据(最准确) if (capturedData.workList) { if (capturedData.workList.total && capturedData.workList.total > 0) { worksCount = capturedData.workList.total; logger.info(`[Douyin] 使用 work_list API 获取作品数: ${worksCount}`); } } // 如果还没有获取到作品数,跳转到作品管理页触发 work_list API if (worksCount === 0) { logger.info('[Douyin] 跳转到作品管理页获取作品数...'); await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', { waitUntil: 'networkidle', timeout: 30000, }); await this.page.waitForTimeout(4000); // 再次检查是否获取到作品数 if (capturedData.workList?.total && capturedData.workList.total > 0) { worksCount = capturedData.workList.total; logger.info(`[Douyin] 从作品管理页获取作品数: ${worksCount}`); } } // 如果 API 没捕获到,尝试从页面 DOM 获取 if (!accountName || accountName === '未知账号') { const nameEl = await this.page.$('[class*="nickname"], [class*="userName"], [class*="name"]'); if (nameEl) { const text = await nameEl.textContent(); if (text?.trim()) accountName = text.trim(); } } if (!avatarUrl) { const avatarEl = await this.page.$('[class*="avatar"] img'); if (avatarEl) { avatarUrl = await avatarEl.getAttribute('src') || ''; } } // 如果还没获取到粉丝数和作品数,从页面元素获取 if (fansCount === 0 || worksCount === 0) { const statsData = await this.page.evaluate(() => { const result = { fans: 0, works: 0 }; // 查找所有包含数字的元素 const allText = document.body.innerText; // 尝试匹配 "粉丝 xxx" 或 "xxx 粉丝" const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/); if (fansMatch) { const numStr = fansMatch[1] || fansMatch[2]; result.fans = parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1); } // 尝试匹配作品数 const worksMatch = allText.match(/作品[::\s]*(\d+)|(\d+)\s*个?作品|共\s*(\d+)\s*个/); if (worksMatch) { result.works = parseInt(worksMatch[1] || worksMatch[2] || worksMatch[3]); } return result; }); if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans; if (worksCount === 0 && statsData.works > 0) worksCount = statsData.works; } // 如果没有获取到ID,生成一个 if (!accountId) { accountId = `douyin_${Date.now()}`; } await this.closeBrowser(); logger.info(`[Douyin] Account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`); return { accountId, accountName, avatarUrl, fansCount, worksCount, worksList, }; } catch (error) { logger.error('Douyin getAccountInfo error:', error); await this.closeBrowser(); // 返回默认值而不是抛出错误 return { accountId: `douyin_${Date.now()}`, accountName: '抖音账号', avatarUrl: '', fansCount: 0, worksCount: 0, }; } } /** * 验证码信息类型 */ private captchaTypes = { SMS: 'sms', // 短信验证码 IMAGE: 'image', // 图形验证码 } as const; /** * 处理验证码弹框(支持短信验证码和图形验证码) * 优先使用传统方式检测,AI 作为辅助 * @param onCaptchaRequired 验证码回调 * @returns 'success' | 'failed' | 'not_needed' */ private async handleCaptchaIfNeeded( onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string; }) => Promise ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> { if (!this.page) return 'not_needed'; try { // 1. 先使用传统方式检测图形验证码弹框 logger.info('[Douyin Publish] Checking for captcha...'); const imageCaptchaResult = await this.handleImageCaptcha(onCaptchaRequired); if (imageCaptchaResult !== 'not_needed') { logger.info(`[Douyin Publish] Image captcha result: ${imageCaptchaResult}`); return imageCaptchaResult; } // 2. 再检测短信验证码弹框 const smsCaptchaResult = await this.handleSmsCaptcha(onCaptchaRequired); if (smsCaptchaResult !== 'not_needed') { logger.info(`[Douyin Publish] SMS captcha result: ${smsCaptchaResult}`); return smsCaptchaResult; } // 3. 如果传统方式没检测到,使用 AI 辅助检测 const aiStatus = await this.aiAnalyzePublishStatus(); if (aiStatus?.status === 'need_captcha') { logger.info(`[Douyin Publish] AI detected captcha: type=${aiStatus.captchaType}, desc=${aiStatus.captchaDescription}`); // AI 检测到验证码,尝试处理 if (onCaptchaRequired && aiStatus.captchaType) { // 获取验证码截图 const imageBase64 = await this.screenshotBase64(); try { const captchaCode = await onCaptchaRequired({ taskId: `ai_captcha_${Date.now()}`, type: aiStatus.captchaType === 'sms' ? 'sms' : 'image', captchaDescription: aiStatus.captchaDescription, imageBase64, }); if (captchaCode) { // 使用 AI 指导输入验证码 const guide = await this.aiGetPublishOperationGuide('需要输入验证码'); if (guide?.hasAction && guide.targetSelector) { await this.page.fill(guide.targetSelector, captchaCode); await this.page.waitForTimeout(500); // 查找确认按钮 const confirmGuide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认按钮'); if (confirmGuide?.hasAction && confirmGuide.targetSelector) { await this.page.click(confirmGuide.targetSelector); await this.page.waitForTimeout(2000); } // 验证是否成功 const afterStatus = await this.aiAnalyzePublishStatus(); if (afterStatus?.status === 'need_captcha') { logger.warn('[Douyin Publish] AI: Captcha still present after input'); return 'failed'; } return 'success'; } } } catch (captchaError) { logger.error('[Douyin Publish] AI captcha handling failed:', captchaError); } } // 没有回调或处理失败,需要手动介入 if (this.isHeadless) { return 'need_retry_headful'; } } return 'not_needed'; } catch (error) { logger.error('[Douyin Publish] Captcha handling error:', error); return 'not_needed'; } } /** * 处理图形验证码 * @returns 'need_retry_headful' 表示在 headless 模式检测到验证码,需要用 headful 模式重新发布 */ private async handleImageCaptcha( onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string; }) => Promise ): Promise<'success' | 'failed' | 'not_needed' | 'need_retry_headful'> { if (!this.page) return 'not_needed'; try { // 图形验证码检测 - 使用多种方式检测 // 标题:"请完成身份验证后继续" // 提示:"为保护帐号安全,请根据图片输入验证码" let hasImageCaptcha = false; // 方式1: 使用 getByText 直接查找可见的文本 const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false }); const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false }); const titleCount = await captchaTitle.count().catch(() => 0); const hintCount = await captchaHint.count().catch(() => 0); logger.info(`[Douyin Publish] Image captcha check - title elements: ${titleCount}, hint elements: ${hintCount}`); if (titleCount > 0) { const isVisible = await captchaTitle.first().isVisible().catch(() => false); logger.info(`[Douyin Publish] Image captcha title visible: ${isVisible}`); if (isVisible) { hasImageCaptcha = true; } } if (!hasImageCaptcha && hintCount > 0) { const isVisible = await captchaHint.first().isVisible().catch(() => false); logger.info(`[Douyin Publish] Image captcha hint visible: ${isVisible}`); if (isVisible) { hasImageCaptcha = true; } } // 方式2: 检查页面 HTML 内容作为备用 if (!hasImageCaptcha) { const pageContent = await this.page.content().catch(() => ''); if (pageContent.includes('请完成身份验证后继续') || pageContent.includes('请根据图片输入验证码')) { logger.info('[Douyin Publish] Image captcha text found in page content'); // 再次尝试使用选择器 const modalCandidates = [ 'div[class*="modal"]:visible', 'div[role="dialog"]:visible', '[class*="verify-modal"]', '[class*="captcha"]', ]; for (const selector of modalCandidates) { const modal = this.page.locator(selector).first(); if (await modal.count().catch(() => 0) > 0 && await modal.isVisible().catch(() => false)) { hasImageCaptcha = true; logger.info(`[Douyin Publish] Image captcha modal found via: ${selector}`); break; } } } } if (!hasImageCaptcha) { return 'not_needed'; } logger.info('[Douyin Publish] Image captcha modal detected!'); // 截图保存当前状态 try { const screenshotPath = `uploads/debug/image_captcha_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] Image captcha screenshot saved: ${screenshotPath}`); } catch { } // 如果当前是 headless 模式,返回特殊状态让调用方用 headful 模式重试 if (this.isHeadless) { logger.info('[Douyin Publish] Captcha detected in HEADLESS mode, need to restart with HEADFUL'); return 'need_retry_headful'; } // 已经是 headful 模式,通知前端并等待用户完成验证 logger.info('[Douyin Publish] In HEADFUL mode, waiting for user to complete captcha in browser window...'); // 通知前端验证码需要手动输入 if (onCaptchaRequired) { const taskId = `captcha_manual_${Date.now()}`; onCaptchaRequired({ taskId, type: 'image', imageBase64: '', }).catch(() => { }); } // 等待验证码弹框消失(用户在浏览器窗口中完成验证) const captchaTimeout = 180000; // 3 分钟超时 const captchaStartTime = Date.now(); while (Date.now() - captchaStartTime < captchaTimeout) { await this.page.waitForTimeout(2000); const captchaTitle = this.page.getByText('请完成身份验证后继续', { exact: false }); const captchaHint = this.page.getByText('请根据图片输入验证码', { exact: false }); const titleVisible = await captchaTitle.count() > 0 && await captchaTitle.first().isVisible().catch(() => false); const hintVisible = await captchaHint.count() > 0 && await captchaHint.first().isVisible().catch(() => false); if (!titleVisible && !hintVisible) { logger.info('[Douyin Publish] Captcha completed by user!'); await this.page.waitForTimeout(2000); return 'success'; } const elapsed = Math.floor((Date.now() - captchaStartTime) / 1000); logger.info(`[Douyin Publish] Waiting for captcha (${elapsed}s)...`); } logger.error('[Douyin Publish] Captcha timeout'); return 'failed'; } catch (error) { logger.error('[Douyin Publish] Image captcha handling error:', error); return 'not_needed'; } } /** * 处理短信验证码 */ private async handleSmsCaptcha( onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string; }) => Promise ): Promise<'success' | 'failed' | 'not_needed'> { if (!this.page) return 'not_needed'; try { // 短信验证码弹框选择器 const smsCaptchaSelectors = [ '.second-verify-panel', '.uc-ui-verify_sms-verify', '.uc-ui-verify-new_header-title:has-text("接收短信验证码")', 'article.uc-ui-verify_sms-verify', ]; let hasSmsCaptcha = false; for (const selector of smsCaptchaSelectors) { const element = this.page.locator(selector).first(); const count = await element.count().catch(() => 0); if (count > 0) { const isVisible = await element.isVisible().catch(() => false); if (isVisible) { hasSmsCaptcha = true; logger.info(`[Douyin Publish] SMS captcha detected with selector: ${selector}`); break; } } } if (!hasSmsCaptcha) { return 'not_needed'; } logger.info('[Douyin Publish] SMS captcha modal detected!'); // 截图保存 try { const screenshotPath = `uploads/debug/sms_captcha_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] SMS captcha screenshot saved: ${screenshotPath}`); } catch { } // 获取手机号 let phone = ''; try { const phoneElement = this.page.locator('.var_TextPrimary').first(); if (await phoneElement.count() > 0) { phone = await phoneElement.textContent() || ''; } if (!phone) { const pageText = await this.page.locator('.second-verify-panel, .uc-ui-verify_sms-verify').first().textContent() || ''; const phoneMatch = pageText.match(/1\d{2}\*{4,6}\d{2}/); if (phoneMatch) phone = phoneMatch[0]; } logger.info(`[Douyin Publish] Found phone number: ${phone}`); } catch { } // 点击获取验证码按钮 const getCaptchaBtnSelectors = [ '.uc-ui-input_right p:has-text("获取验证码")', '.uc-ui-input_right:has-text("获取验证码")', 'p:has-text("获取验证码")', ]; for (const selector of getCaptchaBtnSelectors) { const btn = this.page.locator(selector).first(); if (await btn.count() > 0 && await btn.isVisible()) { try { await btn.click(); logger.info(`[Douyin Publish] Clicked "获取验证码" button: ${selector}`); await this.page.waitForTimeout(1500); break; } catch { } } } if (!onCaptchaRequired) { logger.error('[Douyin Publish] SMS captcha required but no callback provided'); return 'failed'; } const taskId = `captcha_${Date.now()}`; logger.info(`[Douyin Publish] Requesting SMS captcha from user, taskId: ${taskId}, phone: ${phone}`); const captchaCode = await onCaptchaRequired({ taskId, type: 'sms', phone, }); if (!captchaCode) { logger.error('[Douyin Publish] No SMS captcha code received'); return 'failed'; } logger.info(`[Douyin Publish] Received SMS captcha code: ${captchaCode}`); // 填写验证码 const inputSelectors = [ '.second-verify-panel input[type="number"]', '.uc-ui-verify_sms-verify input[type="number"]', '.uc-ui-input input[placeholder="请输入验证码"]', 'input[placeholder="请输入验证码"]', '.uc-ui-input_textbox input', ]; let inputFilled = false; for (const selector of inputSelectors) { const input = this.page.locator(selector).first(); if (await input.count() > 0 && await input.isVisible()) { await input.click(); await input.fill(''); await input.type(captchaCode, { delay: 50 }); inputFilled = true; logger.info(`[Douyin Publish] SMS captcha code filled via: ${selector}`); break; } } if (!inputFilled) { logger.error('[Douyin Publish] SMS captcha input not found'); return 'failed'; } await this.page.waitForTimeout(500); // 点击验证按钮 const verifyBtnSelectors = [ '.uc-ui-verify_sms-verify_button:has-text("验证"):not(.disabled)', '.uc-ui-button:has-text("验证"):not(.disabled)', '.second-verify-panel .uc-ui-button:has-text("验证")', 'div.uc-ui-button:has-text("验证")', ]; for (const selector of verifyBtnSelectors) { const btn = this.page.locator(selector).first(); if (await btn.count() > 0 && await btn.isVisible()) { try { const isDisabled = await btn.evaluate((el: HTMLElement) => el.classList.contains('disabled')); if (!isDisabled) { await btn.click(); logger.info(`[Douyin Publish] Clicked SMS verify button: ${selector}`); break; } } catch { } } } // 等待结果 await this.page.waitForTimeout(3000); // 检查弹框是否消失 let stillHasCaptcha = false; for (const selector of smsCaptchaSelectors) { const element = this.page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { stillHasCaptcha = true; break; } } if (!stillHasCaptcha) { logger.info('[Douyin Publish] SMS captcha verified successfully'); return 'success'; } logger.warn('[Douyin Publish] SMS captcha modal still visible'); return 'failed'; } catch (error) { logger.error('[Douyin Publish] SMS captcha handling error:', error); return 'not_needed'; } } /** * 检查 Python 发布服务是否可用 */ private async checkPythonServiceAvailable(): Promise { try { const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/health`, { method: 'GET', signal: AbortSignal.timeout(3000), }); if (response.ok) { const data = await response.json(); return data.status === 'ok' && data.supported_platforms?.includes('douyin'); } return false; } catch { return false; } } /** * 通过 Python 服务发布视频(带 AI 辅助) * @returns PublishResult - 如果需要验证码,返回 { success: false, needCaptcha: true, captchaType: '...' } */ private async publishVideoViaPython( cookies: string, params: PublishParams, onProgress?: (progress: number, message: string) => void, onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise ): Promise { logger.info('[Douyin Python] Starting publish via Python service with AI assist...'); onProgress?.(5, '正在通过 Python 服务发布...'); try { // 将相对路径转换为绝对路径 const absoluteVideoPath = path.isAbsolute(params.videoPath) ? params.videoPath : path.resolve(SERVER_ROOT, params.videoPath); const absoluteCoverPath = params.coverPath ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath)) : undefined; // 使用 AI 辅助发布接口 const extra = (params.extra || {}) as Record; const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/publish/ai-assisted`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'douyin', cookie: cookies, user_id: extra.userId, publish_task_id: extra.publishTaskId, publish_account_id: extra.publishAccountId, proxy: (extra as any).publishProxy || null, title: params.title, description: params.description || params.title, video_path: absoluteVideoPath, cover_path: absoluteCoverPath, tags: params.tags || [], post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null, location: params.location || '重庆市', }), signal: AbortSignal.timeout(600000), // 10分钟超时 }); const result = await response.json(); if (result.success) { onProgress?.(100, '发布成功'); logger.info('[Douyin Python] Publish successful'); return { success: true, platformVideoId: result.video_id || `douyin_${Date.now()}`, videoUrl: result.video_url || '', }; } // 如果返回了截图,使用 AI 分析 if (result.screenshot_base64) { logger.info('[Douyin Python] Got screenshot, analyzing with AI...'); const { aiService } = await import('../../ai/index.js'); if (aiService.isAvailable()) { const aiStatus = await aiService.analyzePublishStatus(result.screenshot_base64, 'douyin'); logger.info(`[Douyin Python] AI analysis: status=${aiStatus.status}, confidence=${aiStatus.confidence}%`); // AI 判断发布成功 if (aiStatus.status === 'success' && aiStatus.confidence >= 70) { onProgress?.(100, '发布成功'); return { success: true, platformVideoId: `douyin_${Date.now()}`, videoUrl: result.video_url || result.page_url || '', }; } // AI 检测到需要验证码 if (aiStatus.status === 'need_captcha') { logger.info(`[Douyin Python] AI detected captcha: ${aiStatus.captchaDescription}`); // 如果有验证码回调,尝试处理 if (onCaptchaRequired) { onProgress?.(50, `AI 检测到验证码: ${aiStatus.captchaDescription || '请输入验证码'}`); // 返回需要验证码的状态,让上层处理 return { success: false, needCaptcha: true, captchaType: aiStatus.captchaType || 'image', errorMessage: aiStatus.captchaDescription || '需要验证码', }; } } // AI 判断发布失败 if (aiStatus.status === 'failed') { throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败'); } } } // Python 返回需要验证码 if (result.need_captcha || result.status === 'need_captcha') { logger.info(`[Douyin Python] Captcha required: type=${result.captcha_type}`); onProgress?.(0, `检测到需要${result.captcha_type || ''}验证码,切换到浏览器模式...`); return { success: false, needCaptcha: true, captchaType: result.captcha_type || 'image', errorMessage: result.error || `需要验证码`, }; } throw new Error(result.error || '发布失败'); } catch (error) { logger.error('[Douyin Python] Publish failed:', error); throw error; } } /** * 发布视频 * 参考 https://github.com/kebenxiaoming/matrix 项目实现 * 只使用 Python 服务发布,如果检测到验证码返回错误让前端用有头浏览器重试 * @param onCaptchaRequired 验证码回调,返回用户输入的验证码 * @param options.headless 是否使用无头模式,默认 true */ async publishVideo( cookies: string, params: PublishParams, onProgress?: (progress: number, message: string) => void, onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise, options?: { headless?: boolean } ): Promise { // 只使用 Python 服务发布 const pythonAvailable = await this.checkPythonServiceAvailable(); if (!pythonAvailable) { logger.error('[Douyin] Python service not available'); return { success: false, errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动', }; } logger.info('[Douyin] Using Python service for publishing'); try { const pythonResult = await this.publishVideoViaPython(cookies, params, onProgress); // 检查是否需要验证码 - 返回错误让前端用有头浏览器重试 if (pythonResult.needCaptcha) { logger.info(`[Douyin] Python detected captcha (${pythonResult.captchaType}), need headful browser`); onProgress?.(0, `检测到${pythonResult.captchaType}验证码,请使用有头浏览器重试...`); return { success: false, errorMessage: `CAPTCHA_REQUIRED:检测到${pythonResult.captchaType}验证码,需要使用有头浏览器完成验证`, }; } if (pythonResult.success) { return pythonResult; } return { success: false, errorMessage: pythonResult.errorMessage || '发布失败', }; } catch (pythonError) { logger.error('[Douyin] Python publish failed:', pythonError); return { success: false, errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败', }; } /* ========== Playwright 方式已注释,只使用 Python API ========== try { await this.initBrowser({ headless: useHeadless }); await this.setCookies(cookies); if (!useHeadless) { logger.info('[Douyin Publish] Running in HEADFUL mode - browser window is visible'); onProgress?.(1, '已打开浏览器窗口,请注意查看...'); } if (!this.page) throw new Error('Page not initialized'); // 检查视频文件是否存在 const fs = await import('fs'); if (!fs.existsSync(params.videoPath)) { throw new Error(`视频文件不存在: ${params.videoPath}`); } onProgress?.(5, '正在打开上传页面...'); logger.info(`[Douyin Publish] Starting upload for: ${params.videoPath}`); // 访问上传页面 await this.page.goto(this.publishUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }); // 等待页面加载 await this.page.waitForTimeout(3000); logger.info(`[Douyin Publish] Page loaded: ${this.page.url()}`); onProgress?.(10, '正在选择视频文件...'); // 上传视频 - 优先使用 AI 截图分析找到上传入口 let uploadTriggered = false; // 方法1: AI 截图分析找到上传入口 logger.info('[Douyin Publish] Using AI to find upload entry...'); try { const screenshot = await this.screenshotBase64(); const guide = await aiService.getPageOperationGuide(screenshot, 'douyin', '找到视频上传入口并点击上传按钮'); logger.info(`[Douyin Publish] AI analysis result:`, guide); if (guide.hasAction && guide.targetSelector) { logger.info(`[Douyin Publish] AI suggested selector: ${guide.targetSelector}`); try { const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 10000 }), this.page.click(guide.targetSelector), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info('[Douyin Publish] Upload triggered via AI selector'); } catch (e) { logger.warn(`[Douyin Publish] AI selector click failed: ${e}`); } } } catch (e) { logger.warn(`[Douyin Publish] AI analysis failed: ${e}`); } // 方法2: 尝试点击常见的上传区域触发 file chooser if (!uploadTriggered) { logger.info('[Douyin Publish] Trying common upload selectors...'); const uploadSelectors = [ // 抖音常见上传区域选择器 'div[class*="container-drag-info"]', 'div[class*="container-drag"]', 'div[class*="upload-drag"]', 'div[class*="drag-info"]', 'div[class*="upload-btn"]', 'div[class*="drag-area"]', '[class*="upload"] [class*="drag"]', 'div[class*="upload-area"]', '.upload-trigger', 'button:has-text("上传")', 'div:has-text("上传视频"):not(:has(div))', 'span:has-text("点击上传")', ]; for (const selector of uploadSelectors) { if (uploadTriggered) break; try { const element = this.page.locator(selector).first(); if (await element.count() > 0 && await element.isVisible()) { logger.info(`[Douyin Publish] Trying selector: ${selector}`); const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 5000 }), element.click(), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info(`[Douyin Publish] Upload triggered via selector: ${selector}`); } } catch (e) { // 继续尝试下一个选择器 } } } // 方法3: 直接设置 file input if (!uploadTriggered) { logger.info('[Douyin Publish] Trying file input method...'); const fileInputs = await this.page.$$('input[type="file"]'); logger.info(`[Douyin Publish] Found ${fileInputs.length} file inputs`); for (const fileInput of fileInputs) { try { const accept = await fileInput.getAttribute('accept'); if (!accept || accept.includes('video') || accept.includes('*')) { await fileInput.setInputFiles(params.videoPath); uploadTriggered = true; logger.info('[Douyin Publish] Upload triggered via file input'); break; } } catch (e) { logger.warn(`[Douyin Publish] File input method failed: ${e}`); } } } if (!uploadTriggered) { // 截图调试 try { if (this.page) { const screenshotPath = `uploads/debug/douyin_no_upload_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`); } } catch {} throw new Error('未找到上传入口'); } onProgress?.(15, '视频上传中,等待跳转到发布页面...'); // 参考 matrix: 等待页面跳转到发布页面 // URL: https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page const maxWaitTime = 180000; // 3分钟 const startTime = Date.now(); let lastAiCheckTime = 0; const aiCheckInterval = 10000; // 每10秒使用AI检测一次 while (Date.now() - startTime < maxWaitTime) { await this.page.waitForTimeout(2000); const currentUrl = this.page.url(); if (currentUrl.includes('/content/post/video')) { logger.info('[Douyin Publish] Entered video post page'); break; } // 检查上传进度(通过DOM) let progressDetected = false; const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => ''); if (progressText) { const match = progressText.match(/(\d+)%/); if (match) { const progress = parseInt(match[1]); onProgress?.(15 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`); progressDetected = true; } } // 使用AI检测上传进度(每隔一段时间检测一次) if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) { lastAiCheckTime = Date.now(); try { const screenshot = await this.screenshotBase64(); const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'douyin'); logger.info(`[Douyin Publish] AI upload status:`, uploadStatus); if (uploadStatus.isComplete) { logger.info('[Douyin Publish] AI detected upload complete'); // 继续等待页面跳转 } if (uploadStatus.isFailed) { throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`); } if (uploadStatus.progress !== null) { onProgress?.(15 + Math.floor(uploadStatus.progress * 0.3), `视频上传中: ${uploadStatus.progress}%`); } } catch (aiError) { logger.warn('[Douyin Publish] AI progress check failed:', aiError); } } // 检查是否上传失败 const failText = await this.page.locator('div:has-text("上传失败")').first().count().catch(() => 0); if (failText > 0) { throw new Error('视频上传失败'); } } if (!this.page.url().includes('/content/post/video')) { throw new Error('等待进入发布页面超时'); } onProgress?.(50, '正在填写视频信息...'); await this.page.waitForTimeout(2000); // 参考 matrix: 填充标题 // 先尝试找到标题输入框 logger.info('[Douyin Publish] Filling title...'); // 方式1: 找到 "作品标题" 旁边的 input const titleInput = this.page.getByText('作品标题').locator('..').locator('xpath=following-sibling::div[1]').locator('input'); if (await titleInput.count() > 0) { await titleInput.fill(params.title.slice(0, 30)); logger.info('[Douyin Publish] Title filled via input'); } else { // 方式2: 使用 .notranslate 编辑器(参考 matrix) const editorContainer = this.page.locator('.notranslate, [class*="editor"] [contenteditable="true"]').first(); if (await editorContainer.count() > 0) { await editorContainer.click(); await this.page.keyboard.press('Control+A'); await this.page.keyboard.press('Backspace'); await this.page.keyboard.type(params.title, { delay: 30 }); await this.page.keyboard.press('Enter'); logger.info('[Douyin Publish] Title filled via editor'); } } onProgress?.(60, '正在添加话题标签...'); // 参考 matrix: 添加话题标签 // 使用 .zone-container 选择器 if (params.tags && params.tags.length > 0) { const tagContainer = '.zone-container, [class*="mention-container"], [class*="hash-tag"]'; for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i]; logger.info(`[Douyin Publish] Adding tag ${i + 1}: ${tag}`); try { await this.page.type(tagContainer, `#${tag}`, { delay: 50 }); await this.page.keyboard.press('Space'); await this.page.waitForTimeout(500); } catch (e) { // 如果失败,尝试在编辑器中添加 try { await this.page.keyboard.type(` #${tag} `, { delay: 50 }); await this.page.waitForTimeout(500); } catch { logger.warn(`[Douyin Publish] Failed to add tag: ${tag}`); } } } } onProgress?.(70, '等待视频处理完成...'); // 参考 matrix: 等待 "重新上传" 按钮出现,表示视频上传完成 const uploadCompleteMaxWait = 600000; // 增加到 10 分钟 const uploadStartTime = Date.now(); let videoProcessed = false; while (Date.now() - uploadStartTime < uploadCompleteMaxWait) { // 检查多种完成标志 const reuploadCount = await this.page.locator('div').filter({ hasText: '重新上传' }).count().catch(() => 0); const replaceCount = await this.page.locator('div:has-text("替换"), button:has-text("替换")').count().catch(() => 0); const completeCount = await this.page.locator('[class*="upload-complete"], [class*="upload-success"]').count().catch(() => 0); if (reuploadCount > 0 || replaceCount > 0 || completeCount > 0) { logger.info('[Douyin Publish] Video upload completed'); videoProcessed = true; break; } // 检查发布按钮是否可用(也是上传完成的标志) const publishBtnEnabled = await this.page.getByRole('button', { name: '发布', exact: true }).isEnabled().catch(() => false); if (publishBtnEnabled) { logger.info('[Douyin Publish] Publish button is enabled, video should be ready'); videoProcessed = true; break; } // 检查上传失败 const failCount = await this.page.locator('div:has-text("上传失败")').count().catch(() => 0); if (failCount > 0) { throw new Error('视频处理失败'); } const elapsed = Math.floor((Date.now() - uploadStartTime) / 1000); logger.info(`[Douyin Publish] Waiting for video processing... (${elapsed}s)`); await this.page.waitForTimeout(3000); onProgress?.(70 + Math.min(14, Math.floor(elapsed / 20)), `等待视频处理完成 (${elapsed}s)...`); } if (!videoProcessed) { logger.warn('[Douyin Publish] Video processing timeout, but will try to publish anyway'); } // 点击 "我知道了" 弹窗(如果存在) const knownBtn = this.page.getByRole('button', { name: '我知道了' }); if (await knownBtn.count() > 0) { await knownBtn.first().click(); await this.page.waitForTimeout(1000); } onProgress?.(85, '正在发布...'); await this.page.waitForTimeout(3000); // 参考 matrix: 点击发布按钮 logger.info('[Douyin Publish] Looking for publish button...'); // 尝试多种方式找到发布按钮 let publishClicked = false; // 方式1: 使用 getByRole const publishBtn = this.page.getByRole('button', { name: '发布', exact: true }); if (await publishBtn.count() > 0) { // 等待按钮可点击 try { await publishBtn.waitFor({ state: 'visible', timeout: 10000 }); const isEnabled = await publishBtn.isEnabled(); if (isEnabled) { await publishBtn.click(); publishClicked = true; logger.info('[Douyin Publish] Publish button clicked via getByRole'); } } catch (e) { logger.warn('[Douyin Publish] getByRole method failed:', e); } } // 方式2: 使用选择器 if (!publishClicked) { const selectors = [ 'button:has-text("发布")', '[class*="publish-btn"]', 'button[class*="primary"]:has-text("发布")', '.semi-button-primary:has-text("发布")', ]; for (const selector of selectors) { const btn = this.page.locator(selector).first(); if (await btn.count() > 0) { try { const isEnabled = await btn.isEnabled(); if (isEnabled) { await btn.click(); publishClicked = true; logger.info(`[Douyin Publish] Publish button clicked via selector: ${selector}`); break; } } catch { } } } } if (!publishClicked) { // 截图帮助调试 try { const screenshotPath = `uploads/debug/no_publish_btn_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] Screenshot saved: ${screenshotPath}`); } catch { } throw new Error('未找到可点击的发布按钮'); } logger.info('[Douyin Publish] Publish button clicked, waiting for result...'); // 点击发布后截图 await this.page.waitForTimeout(2000); try { const screenshotPath = `uploads/debug/after_publish_click_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] After click screenshot saved: ${screenshotPath}`); } catch { } // 检查是否有确认弹窗需要处理 const confirmSelectors = [ 'button:has-text("确认发布")', 'button:has-text("确定")', 'button:has-text("确认")', '.semi-modal button:has-text("发布")', '[class*="modal"] button[class*="primary"]', ]; for (const selector of confirmSelectors) { const confirmBtn = this.page.locator(selector).first(); if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) { logger.info(`[Douyin Publish] Found confirm button: ${selector}`); await confirmBtn.click(); await this.page.waitForTimeout(2000); logger.info('[Douyin Publish] Confirm button clicked'); break; } } // 检查是否需要验证码 const captchaHandled = await this.handleCaptchaIfNeeded(onCaptchaRequired); if (captchaHandled === 'failed') { throw new Error('验证码验证失败'); } // 如果检测到验证码,通知前端需要手动验证 if (captchaHandled === 'need_retry_headful') { logger.info('[Douyin Publish] Captcha detected, requesting user to complete verification...'); onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试'); await this.closeBrowser(); // 返回特殊错误,让前端知道需要手动验证 return { success: false, errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布', }; } onProgress?.(90, '等待发布完成...'); // 参考 matrix: 等待跳转到管理页面表示发布成功 // URL: https://creator.douyin.com/creator-micro/content/manage const publishMaxWait = 180000; // 3 分钟 const publishStartTime = Date.now(); // 记录点击发布时的 URL,用于检测是否跳转 const publishPageUrl = this.page.url(); logger.info(`[Douyin Publish] Publish page URL: ${publishPageUrl}`); // AI 检测计数器,避免过于频繁 let aiCheckCounter = 0; let lastProgressCheckTime = 0; const progressCheckInterval = 5000; // 每5秒检测一次发布进度 while (Date.now() - publishStartTime < publishMaxWait) { await this.page.waitForTimeout(3000); const currentUrl = this.page.url(); const elapsed = Math.floor((Date.now() - publishStartTime) / 1000); logger.info(`[Douyin Publish] Waiting for redirect (${elapsed}s), current URL: ${currentUrl}`); // 在等待过程中也检测验证码弹框 const captchaResult = await this.handleCaptchaIfNeeded(onCaptchaRequired); if (captchaResult === 'failed') { throw new Error('验证码验证失败'); } // 如果检测到验证码,通知前端需要手动验证 if (captchaResult === 'need_retry_headful') { logger.info('[Douyin Publish] Captcha detected in wait loop, requesting user verification...'); onProgress?.(85, '检测到验证码,请在浏览器中完成验证后重试'); await this.closeBrowser(); return { success: false, errorMessage: 'CAPTCHA_REQUIRED:检测到验证码,请在浏览器中登录账号完成验证后重试发布', }; } // 检查是否跳转到管理页面 if (currentUrl.includes('/content/manage')) { logger.info('[Douyin Publish] Redirected to manage page, checking for background upload...'); // 检查是否有后台上传进度条(抖音特有:右下角的上传进度框) const uploadProgressBox = await this.page.locator('[class*="upload-progress"], [class*="uploading"], div:has-text("作品上传中"), div:has-text("请勿关闭页面")').count().catch(() => 0); const uploadProgressText = await this.page.locator('div:has-text("上传中"), div:has-text("上传完成后")').first().textContent().catch(() => ''); // 如果有后台上传进度,继续等待 if (uploadProgressBox > 0 || uploadProgressText.includes('上传')) { logger.info(`[Douyin Publish] Background upload in progress: ${uploadProgressText}`); const match = uploadProgressText.match(/(\d+)%/); if (match) { const progress = parseInt(match[1]); onProgress?.(85 + Math.floor(progress * 0.15), `后台上传中: ${progress}%`); if (progress >= 100) { logger.info('[Douyin Publish] Background upload complete!'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: currentUrl }; } } // 继续等待上传完成 continue; } // 没有后台上传进度,发布完成 logger.info('[Douyin Publish] No background upload, publish complete!'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: currentUrl, }; } // 检查发布进度条(DOM方式) const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"], [class*="publishing"]').first().textContent().catch(() => ''); if (publishProgressText) { const match = publishProgressText.match(/(\d+)%/); if (match) { const progress = parseInt(match[1]); onProgress?.(85 + Math.floor(progress * 0.15), `发布中: ${progress}%`); logger.info(`[Douyin Publish] Publish progress: ${progress}%`); } } // AI检测发布进度(定期检测) if (Date.now() - lastProgressCheckTime > progressCheckInterval) { lastProgressCheckTime = Date.now(); try { const screenshot = await this.screenshotBase64(); const publishStatus = await aiService.analyzePublishProgress(screenshot, 'douyin'); logger.info(`[Douyin Publish] AI publish progress status:`, publishStatus); if (publishStatus.isComplete) { logger.info('[Douyin Publish] AI detected publish complete'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: this.page.url() }; } if (publishStatus.isFailed) { throw new Error(`发布失败: ${publishStatus.statusDescription}`); } if (publishStatus.progress !== null) { onProgress?.(85 + Math.floor(publishStatus.progress * 0.15), `发布中: ${publishStatus.progress}%`); } if (publishStatus.isPublishing) { logger.info(`[Douyin Publish] Still publishing: ${publishStatus.statusDescription}`); } } catch (aiError) { logger.warn('[Douyin Publish] AI publish progress check failed:', aiError); } } // 检查是否有成功提示弹窗(Toast/Modal) // 使用更精确的选择器,避免匹配按钮文字 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); if (successToast > 0) { logger.info('[Douyin Publish] Found success toast/modal'); // 等待一下看是否会跳转 await this.page.waitForTimeout(5000); const newUrl = this.page.url(); if (newUrl.includes('/content/manage')) { logger.info('[Douyin Publish] Redirected to manage page after success toast'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: newUrl, }; } } // 检查是否有明确的错误提示弹窗 const errorToast = await this.page.locator('.semi-toast-error, [class*="toast-error"], .semi-modal-body:has-text("失败")').first().textContent().catch(() => ''); if (errorToast && errorToast.includes('失败')) { logger.error(`[Douyin Publish] Error toast found: ${errorToast}`); throw new Error(`发布失败: ${errorToast}`); } // 每隔几次循环使用 AI 辅助检测发布状态 aiCheckCounter++; if (aiCheckCounter >= 3) { aiCheckCounter = 0; const aiStatus = await this.aiAnalyzePublishStatus(); if (aiStatus) { logger.info(`[Douyin Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`); if (aiStatus.status === 'success' && aiStatus.confidence >= 70) { logger.info('[Douyin Publish] AI detected publish success'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: currentUrl, }; } if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) { logger.error(`[Douyin Publish] AI detected failure: ${aiStatus.errorMessage}`); throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败'); } // AI 建议需要操作 if (aiStatus.status === 'need_action' && aiStatus.nextAction) { logger.info(`[Douyin Publish] AI suggests action: ${aiStatus.nextAction.targetDescription}`); const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription); if (guide?.hasAction) { await this.aiExecuteOperation(guide); } } } } // 更新进度 onProgress?.(90 + Math.min(9, Math.floor(elapsed / 20)), `等待发布完成 (${elapsed}s)...`); } // 如果超时,使用 AI 做最后一次状态检查 const finalUrl = this.page.url(); logger.info(`[Douyin Publish] Timeout! Final URL: ${finalUrl}`); // AI 最终检查 const finalAiStatus = await this.aiAnalyzePublishStatus(); if (finalAiStatus) { logger.info(`[Douyin Publish] Final AI status: ${finalAiStatus.status}`); if (finalAiStatus.status === 'success') { onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: finalUrl, }; } if (finalAiStatus.status === 'failed') { throw new Error(finalAiStatus.errorMessage || 'AI 检测到发布失败'); } } if (finalUrl.includes('/content/manage')) { onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: finalUrl, }; } // 截图保存用于调试 try { const screenshotPath = `uploads/debug/publish_timeout_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Douyin Publish] Timeout screenshot saved: ${screenshotPath}`); } catch { } throw new Error('发布超时,页面未跳转到管理页面,请手动检查是否发布成功'); } catch (error) { logger.error('[Douyin Publish] Error:', error); await this.closeBrowser(); return { success: false, errorMessage: error instanceof Error ? error.message : '发布失败', }; } ========== Playwright 方式已注释结束 ========== */ } /** * 通过 Python API 获取评论 */ private async getCommentsViaPython(cookies: string, videoId: string): Promise { logger.info('[Douyin] Getting comments via Python API...'); const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'douyin', cookie: cookies, work_id: videoId, }), }); if (!response.ok) { throw new Error(`Python API returned ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to get comments'); } // 转换数据格式 return (result.comments || []).map((comment: { comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; reply_count: number; replies?: Array<{ comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; }>; }) => ({ commentId: comment.comment_id, authorId: comment.author_id, authorName: comment.author_name, authorAvatar: comment.author_avatar, content: comment.content, likeCount: comment.like_count, commentTime: comment.create_time, replyCount: comment.reply_count, replies: comment.replies?.map((reply: { comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; }) => ({ commentId: reply.comment_id, authorId: reply.author_id, authorName: reply.author_name, authorAvatar: reply.author_avatar, content: reply.content, likeCount: reply.like_count, commentTime: reply.create_time, })), })); } /** * 获取评论列表 */ async getComments(cookies: string, videoId: string): Promise { // 优先尝试使用 Python API const pythonAvailable = await this.checkPythonServiceAvailable(); if (pythonAvailable) { logger.info('[Douyin] Python service available, using Python API for comments'); try { return await this.getCommentsViaPython(cookies, videoId); } catch (pythonError) { logger.warn('[Douyin] Python API getComments failed, falling back to Playwright:', pythonError); } } // 回退到 Playwright 方式 try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); // 访问评论管理页面 await this.page.goto(`https://creator.douyin.com/creator-micro/content/comment?video_id=${videoId}`); await this.page.waitForLoadState('networkidle'); // 获取评论列表 const comments = await this.page.$$eval('.comment-item', items => items.map(item => ({ commentId: item.getAttribute('data-id') || '', authorId: item.querySelector('.author-id')?.textContent?.trim() || '', authorName: item.querySelector('.author-name')?.textContent?.trim() || '', authorAvatar: item.querySelector('.author-avatar img')?.getAttribute('src') || '', content: item.querySelector('.comment-content')?.textContent?.trim() || '', likeCount: parseInt(item.querySelector('.like-count')?.textContent || '0'), commentTime: item.querySelector('.comment-time')?.textContent?.trim() || '', })) ); await this.closeBrowser(); return comments; } catch (error) { logger.error('Douyin getComments error:', error); await this.closeBrowser(); return []; } } /** * 回复评论 */ async replyComment(cookies: string, commentId: string, content: string): Promise { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); // 这里需要实现具体的回复逻辑 // 由于抖音页面结构可能变化,具体实现需要根据实际情况调整 logger.info(`Reply to comment ${commentId}: ${content}`); await this.closeBrowser(); return true; } catch (error) { logger.error('Douyin replyComment error:', error); await this.closeBrowser(); return false; } } /** * 获取数据统计 */ async getAnalytics(cookies: string, dateRange: DateRange): Promise { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); // 访问数据中心 await this.page.goto('https://creator.douyin.com/creator-micro/data/overview'); await this.page.waitForLoadState('networkidle'); // 获取数据 // 这里需要根据实际页面结构获取数据 await this.closeBrowser(); return { fansCount: 0, fansIncrease: 0, viewsCount: 0, likesCount: 0, commentsCount: 0, sharesCount: 0, }; } catch (error) { logger.error('Douyin getAnalytics error:', error); await this.closeBrowser(); throw error; } } /** * 删除已发布的作品 */ async deleteWork( cookies: string, videoId: string, onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise ): Promise<{ success: boolean; errorMessage?: string }> { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); logger.info(`[Douyin Delete] Starting delete for video: ${videoId}`); // 访问内容管理页面 await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', { waitUntil: 'networkidle', timeout: 60000, }); await this.page.waitForTimeout(3000); // 找到对应视频的操作按钮 // 视频列表通常有 data-aweme-id 属性或者可以通过视频 ID 定位 const videoCard = this.page.locator(`[data-aweme-id="${videoId}"], [data-video-id="${videoId}"]`).first(); // 如果没找到,尝试通过其他方式定位 let found = await videoCard.count() > 0; if (!found) { // 尝试遍历视频列表找到对应的 const videoCards = this.page.locator('[class*="video-card"], [class*="content-item"], [class*="aweme-item"]'); const count = await videoCards.count(); for (let i = 0; i < count; i++) { const card = videoCards.nth(i); const html = await card.innerHTML().catch(() => ''); if (html.includes(videoId)) { // 找到对应的视频卡片,点击更多操作 const moreBtn = card.locator('[class*="more"], [class*="action"]').first(); if (await moreBtn.count() > 0) { await moreBtn.click(); found = true; break; } } } } if (!found) { // 直接访问视频详情页尝试删除 await this.page.goto(`https://creator.douyin.com/creator-micro/content/manage?aweme_id=${videoId}`, { waitUntil: 'networkidle', }); await this.page.waitForTimeout(2000); } // 查找并点击"更多"按钮或"..." const moreSelectors = [ 'button:has-text("更多")', '[class*="more-action"]', '[class*="dropdown-trigger"]', 'button[class*="more"]', '.semi-dropdown-trigger', ]; for (const selector of moreSelectors) { const moreBtn = this.page.locator(selector).first(); if (await moreBtn.count() > 0) { await moreBtn.click(); await this.page.waitForTimeout(500); break; } } // 查找并点击"删除"选项 const deleteSelectors = [ 'div:has-text("删除"):not(:has(*))', '[class*="dropdown-item"]:has-text("删除")', 'li:has-text("删除")', 'span:has-text("删除")', ]; for (const selector of deleteSelectors) { const deleteBtn = this.page.locator(selector).first(); if (await deleteBtn.count() > 0) { await deleteBtn.click(); logger.info('[Douyin Delete] Delete button clicked'); break; } } await this.page.waitForTimeout(1000); // 检查是否需要验证码 const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0; if (captchaVisible && onCaptchaRequired) { logger.info('[Douyin Delete] Captcha required'); // 点击发送验证码 const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first(); if (await sendCodeBtn.count() > 0) { await sendCodeBtn.click(); logger.info('[Douyin Delete] Verification code sent'); } // 通过回调获取验证码 const taskId = `delete_${videoId}_${Date.now()}`; const code = await onCaptchaRequired({ taskId }); if (code) { // 输入验证码 const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first(); if (await codeInput.count() > 0) { await codeInput.fill(code); logger.info('[Douyin Delete] Verification code entered'); } // 点击确认按钮 const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first(); if (await confirmBtn.count() > 0) { await confirmBtn.click(); await this.page.waitForTimeout(2000); } } } // 确认删除(可能有二次确认弹窗) const confirmDeleteSelectors = [ 'button:has-text("确认删除")', 'button:has-text("确定")', '.semi-modal-footer button:has-text("确定")', ]; for (const selector of confirmDeleteSelectors) { const confirmBtn = this.page.locator(selector).first(); if (await confirmBtn.count() > 0) { await confirmBtn.click(); await this.page.waitForTimeout(1000); } } logger.info('[Douyin Delete] Delete completed'); await this.closeBrowser(); return { success: true }; } catch (error) { logger.error('[Douyin Delete] Error:', error); await this.closeBrowser(); return { success: false, errorMessage: error instanceof Error ? error.message : '删除失败', }; } } /** * 解析数量字符串 */ private parseCount(text: string): number { text = text.replace(/,/g, ''); if (text.includes('万')) { return Math.floor(parseFloat(text.replace('万', '')) * 10000); } if (text.includes('w')) { return Math.floor(parseFloat(text.replace('w', '')) * 10000); } if (text.includes('亿')) { return Math.floor(parseFloat(text.replace('亿', '')) * 100000000); } return parseInt(text) || 0; } }