/// 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'; /** * 抖音平台适配器 */ 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')) { // 登录成功,获取 cookie const cookies = await this.getCookies(); await this.closeBrowser(); return { status: 'success', message: '登录成功', cookies, }; } // 检查是否扫码 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: '检查状态失败' }; } } /** * 检查登录状态 */ 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; } } /** * 获取账号信息 */ async getAccountInfo(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); let accountName = '未知账号'; let avatarUrl = ''; let fansCount = 0; let worksCount = 0; let accountId = ''; let worksList: WorkItem[] = []; // 尝试多种选择器获取用户名 const nameSelectors = [ '[class*="nickname"]', '[class*="userName"]', '[class*="user-name"]', '.creator-info .name', '.user-info .name', ]; for (const selector of nameSelectors) { try { const el = await this.page.$(selector); if (el) { const text = await el.textContent(); if (text && text.trim()) { accountName = text.trim(); break; } } } catch {} } // 尝试获取头像 const avatarSelectors = [ '[class*="avatar"] img', '.user-avatar img', '.creator-avatar img', ]; for (const selector of avatarSelectors) { try { const el = await this.page.$(selector); if (el) { avatarUrl = await el.getAttribute('src') || ''; if (avatarUrl) break; } } catch {} } // 尝试从页面数据或Cookie获取账号ID try { const cookieList = JSON.parse(cookies); const uidCookie = cookieList.find((c: { name: string }) => c.name === 'passport_uid' || c.name === 'uid' || c.name === 'ssid' ); if (uidCookie) { accountId = uidCookie.value; } } catch {} // 如果没有获取到ID,生成一个 if (!accountId) { accountId = `douyin_${Date.now()}`; } // 尝试获取粉丝数 try { const fansEl = await this.page.$('[class*="fans"] [class*="count"], [class*="粉丝"]'); if (fansEl) { const text = await fansEl.textContent(); if (text) { fansCount = this.parseCount(text.replace(/[^\d.万w亿]/g, '')); } } } catch {} // 访问内容管理页面获取作品数和作品列表 try { await this.page.goto('https://creator.douyin.com/creator-micro/content/manage', { waitUntil: 'domcontentloaded', timeout: 30000, }); await this.page.waitForTimeout(3000); // 获取作品总数 - 从 "共 12 个作品" 元素中提取 const totalEl = await this.page.$('[class*="content-header-total"]'); if (totalEl) { const totalText = await totalEl.textContent(); if (totalText) { const match = totalText.match(/(\d+)/); if (match) { worksCount = parseInt(match[1], 10); } } } // 获取作品列表 worksList = await this.page.evaluate(() => { const items: WorkItem[] = []; const cards = document.querySelectorAll('[class*="video-card-zQ02ng"]'); cards.forEach((card) => { try { // 获取封面图片URL const coverEl = card.querySelector('[class*="video-card-cover"]') as HTMLElement; let coverUrl = ''; if (coverEl && coverEl.style.backgroundImage) { const match = coverEl.style.backgroundImage.match(/url\("(.+?)"\)/); if (match) { coverUrl = match[1]; } } // 获取时长 const durationEl = card.querySelector('[class*="badge-"]'); const duration = durationEl?.textContent?.trim() || ''; // 获取标题 const titleEl = card.querySelector('[class*="info-title-text"]'); const title = titleEl?.textContent?.trim() || '无作品描述'; // 获取发布时间 const timeEl = card.querySelector('[class*="info-time"]'); const publishTime = timeEl?.textContent?.trim() || ''; // 获取状态 const statusEl = card.querySelector('[class*="info-status"]'); const status = statusEl?.textContent?.trim() || ''; // 获取数据指标 const metricItems = card.querySelectorAll('[class*="metric-item-u1CAYE"]'); let playCount = 0, likeCount = 0, commentCount = 0, shareCount = 0; metricItems.forEach((metric) => { const labelEl = metric.querySelector('[class*="metric-label"]'); const valueEl = metric.querySelector('[class*="metric-value"]'); const label = labelEl?.textContent?.trim() || ''; const value = parseInt(valueEl?.textContent?.trim() || '0', 10); switch (label) { case '播放': playCount = value; break; case '点赞': likeCount = value; break; case '评论': commentCount = value; break; case '分享': shareCount = value; break; } }); items.push({ title, coverUrl, duration, publishTime, status, playCount, likeCount, commentCount, shareCount, }); } catch {} }); return items; }); logger.info(`Douyin works: total ${worksCount}, fetched ${worksList.length} items`); } catch (worksError) { logger.warn('Failed to fetch works list:', worksError); } 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; /** * 处理验证码弹框(支持短信验证码和图形验证码) * @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; } 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'; } } /** * 发布视频 * 参考 https://github.com/kebenxiaoming/matrix 项目实现 * @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 { const useHeadless = options?.headless ?? true; 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, '正在选择视频文件...'); // 参考 matrix: 点击上传区域触发文件选择 // 选择器: div.container-drag-info-Tl0RGH const uploadDivSelectors = [ 'div[class*="container-drag-info"]', 'div[class*="upload-btn"]', 'div[class*="drag-area"]', '[class*="upload"] [class*="drag"]', ]; let uploadTriggered = false; for (const selector of uploadDivSelectors) { try { const uploadDiv = this.page.locator(selector).first(); if (await uploadDiv.count() > 0) { logger.info(`[Douyin Publish] Found upload div: ${selector}`); // 使用 expect_file_chooser 方式上传(参考 matrix) const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 10000 }), uploadDiv.click(), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info(`[Douyin Publish] File selected via file chooser`); break; } } catch (e) { logger.warn(`[Douyin Publish] Failed with selector ${selector}:`, e); } } // 如果点击方式失败,尝试直接设置 input if (!uploadTriggered) { logger.info('[Douyin Publish] Trying direct input method...'); const fileInput = await this.page.$('input[type="file"]'); if (fileInput) { await fileInput.setInputFiles(params.videoPath); uploadTriggered = true; logger.info('[Douyin Publish] File set via input element'); } } if (!uploadTriggered) { 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(); 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; } // 检查上传进度 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}%`); } } // 检查是否上传失败 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('验证码验证失败'); } // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布 if (captchaHandled === 'need_retry_headful') { logger.info('[Douyin Publish] Captcha detected, closing headless and restarting with headful...'); onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...'); await this.closeBrowser(); // 递归调用,使用 headful 模式 return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false }); } 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}`); 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('验证码验证失败'); } // 如果在 headless 模式检测到验证码,关闭浏览器并用 headful 模式从头开始发布 if (captchaResult === 'need_retry_headful') { logger.info('[Douyin Publish] Captcha detected in wait loop, restarting with headful...'); onProgress?.(85, '检测到验证码,正在打开浏览器窗口重新发布...'); await this.closeBrowser(); return this.publishVideo(cookies, params, onProgress, onCaptchaRequired, { headless: false }); } // 检查是否跳转到管理页面 - 这是最可靠的成功标志 if (currentUrl.includes('/content/manage')) { logger.info('[Douyin Publish] Publish success! Redirected to manage page'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: currentUrl, }; } // 检查是否有成功提示弹窗(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}`); } // 更新进度 onProgress?.(90 + Math.min(9, Math.floor(elapsed / 20)), `等待发布完成 (${elapsed}s)...`); } // 如果超时,最后检查一次当前页面状态 const finalUrl = this.page.url(); logger.info(`[Douyin Publish] Timeout! Final URL: ${finalUrl}`); 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 : '发布失败', }; } } /** * 获取评论列表 */ async getComments(cookies: string, videoId: 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/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; } }