| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130 |
- /// <reference lib="dom" />
- 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<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')) {
- // 在判断登录成功之前,先检查是否有二次校验弹框
- // 抖音二次校验弹框特征:标题"身份验证"、选项包含"接收短信验证码"等
- 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<boolean> {
- 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<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;
- }
- }
- /**
- * 获取账号信息
- * 通过拦截 API 响应获取准确数据
- */
- 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');
- 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<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;
- }
- // 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<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';
- }
- }
- /**
- * 检查 Python 发布服务是否可用
- */
- private async checkPythonServiceAvailable(): Promise<boolean> {
- 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<string>
- ): Promise<PublishResult & { needCaptcha?: boolean; captchaType?: string }> {
- 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<string, unknown>;
- 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<string>,
- options?: { headless?: boolean }
- ): Promise<PublishResult> {
- // 只使用 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<CommentData[]> {
- 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<CommentData[]> {
- // 优先尝试使用 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<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;
- }
- }
|