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