| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440 |
- /// <reference lib="dom" />
- 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<QRCodeInfo> {
- 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<LoginStatusResult> {
- 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<boolean> {
- 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<AccountProfile> {
- 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<string>
- ): 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<string>
- ): 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<string>
- ): 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<string>,
- options?: { headless?: boolean }
- ): Promise<PublishResult> {
- 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<CommentData[]> {
- 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<boolean> {
- 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<AnalyticsData> {
- 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<string>
- ): 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;
- }
- }
|