///
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;
}
}