import { ipcMain, app } from 'electron'; import { Log } from '../log/main'; import DbMain from '../db/main'; import { CoverAdapter } from './coverAdapter'; import { ExecutePublishParams, PublishTask, PublishHistory } from './types'; import { chromium, Browser, BrowserContext, Page, Frame } from 'playwright'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { getPlaywrightChromiumPath, COMMON_BROWSER_ARGS, STEALTH_INIT_SCRIPT } from '../../lib/browser-path'; import { getRuntimeDataPath } from '../../lib/resource-path'; const browserContexts: Map = new Map(); type PublishResultStatus = 'success' | 'failed' | 'verification_required' | 'manual_pending'; type PublishExecutionResult = { success: boolean; status: PublishResultStatus; message: string; videoUrl?: string; videoId?: string; requiresVerification?: boolean; keepPageOpen?: boolean; }; const STEALTH_UA = process.platform === 'darwin' ? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const PUBLISH_SUCCESS_SELECTORS: Record = { douyin: [ 'text="发布成功"', 'text="已发布"', 'text="作品发布成功"', 'text=/发布成功|已发布|发布完成/', ], kuaishou: [ 'text="发布成功"', 'text="已发布"', 'text="作品发布成功"', 'text=/发布成功|已发布|发布完成/', ], shipin: [ 'text="发表成功"', 'text="发布成功"', 'text="已发表"', 'text="已发布"', 'text=/发表成功|发布成功|已发表|已发布/', ], xiaohongshu: [ 'text="发布成功"', 'text="已发布"', 'text="笔记发布成功"', 'text=/发布成功|已发布|笔记发布成功/', ], }; const PUBLISH_PAGE_MARKERS: Record = { douyin: ['/creator-micro/content/upload'], kuaishou: ['/article/publish/video'], shipin: ['/platform/post/create'], xiaohongshu: ['/publish/publish'], }; const VERIFICATION_TEXT_SELECTORS = [ 'text=/短信验证码|接收短信验证码|请输入验证码|输入验证码|手机验证码|安全验证|验证手机号|获取验证码|发送验证码|发送短信验证码|验证码错误/', ]; const VERIFICATION_INPUT_SELECTORS = [ 'input[placeholder*="验证码"]', 'input[placeholder*="输入验证码"]', 'input[maxlength="6"]', 'input[maxlength="4"]', ]; const VERIFICATION_ACTION_SELECTORS = [ 'button:has-text("获取验证码")', 'button:has-text("发送验证码")', 'button:has-text("验证")', 'button:has-text("确认")', ]; function getPersistentUserDataDir(platform: string, accountId?: number | string): string { const dir = getRuntimeDataPath('browser-data', 'publish', `${platform}_${accountId || 'default'}`); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } return dir; } function getSearchFrames(page: Page): Frame[] { try { return page.frames(); } catch { return []; } } async function findVisibleSelectorInFrames(page: Page, selectors: string[]): Promise<{ selector: string; frameUrl: string } | null> { for (const frame of getSearchFrames(page)) { for (const selector of selectors) { try { const locator = frame.locator(selector).first(); const count = await locator.count().catch(() => 0); if (count > 0) { const visible = await locator.isVisible().catch(() => false); if (visible) { return { selector, frameUrl: frame.url(), }; } } } catch { // ignore selector failures } } } return null; } async function detectVerificationPrompt(page: Page): Promise<{ detected: boolean; selector?: string; frameUrl?: string }> { const titleMatch = await findVisibleSelectorInFrames(page, VERIFICATION_TEXT_SELECTORS); const inputMatch = await findVisibleSelectorInFrames(page, VERIFICATION_INPUT_SELECTORS); const actionMatch = await findVisibleSelectorInFrames(page, VERIFICATION_ACTION_SELECTORS); if ((titleMatch && inputMatch) || (inputMatch && actionMatch)) { return { detected: true, selector: titleMatch?.selector || inputMatch?.selector || actionMatch?.selector, frameUrl: titleMatch?.frameUrl || inputMatch?.frameUrl || actionMatch?.frameUrl, }; } return { detected: false }; } async function detectPublishSuccess(page: Page, platform: string): Promise<{ detected: boolean; reason?: string }> { const successMatch = await findVisibleSelectorInFrames(page, PUBLISH_SUCCESS_SELECTORS[platform] || []); if (successMatch) { return { detected: true, reason: `success_selector:${successMatch.selector}`, }; } const currentUrl = page.url(); const publishMarkers = PUBLISH_PAGE_MARKERS[platform] || []; const leftPublishPage = publishMarkers.length > 0 && !publishMarkers.some(marker => currentUrl.includes(marker)); if (leftPublishPage && !currentUrl.includes('login') && !currentUrl.includes('verify') && !currentUrl.includes('passport')) { return { detected: true, reason: `url_changed:${currentUrl}`, }; } return { detected: false }; } async function waitForPublishConfirmation( page: Page, platform: string, timeoutMs = 15 * 60 * 1000 ): Promise { const startTime = Date.now(); let verificationDetected = false; while (Date.now() - startTime < timeoutMs) { if (page.isClosed()) { return { success: false, status: verificationDetected ? 'verification_required' : 'failed', message: verificationDetected ? '发布页面已关闭,验证码流程未完成' : '发布页面已关闭', keepPageOpen: false, }; } const verificationResult = await detectVerificationPrompt(page); if (verificationResult.detected) { if (!verificationDetected) { verificationDetected = true; Log.warn(`[Publish] 检测到验证码弹窗,进入等待用户验证状态: ${platform}`, verificationResult); } } const successResult = await detectPublishSuccess(page, platform); if (successResult.detected) { Log.info(`[Publish] 检测到明确发布成功信号: ${platform}`, successResult); return { success: true, status: 'success', message: '发布成功', videoUrl: page.url(), videoId: '', keepPageOpen: false, }; } await page.waitForTimeout(1500); } if (verificationDetected) { return { success: false, status: 'verification_required', message: '检测到短信验证码,请在浏览器中完成验证码输入并确认发布', videoUrl: page.url(), videoId: '', requiresVerification: true, keepPageOpen: true, }; } return { success: false, status: 'manual_pending', message: '已点击发布按钮,但未检测到明确的发布成功标识,请在浏览器中确认是否仍需验证码或手动确认', videoUrl: page.url(), videoId: '', keepPageOpen: true, }; } async function getBrowserContext(platform: string, accountId?: number | string): Promise { const contextKey = accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`; if (browserContexts.has(contextKey)) { const existingContext = browserContexts.get(contextKey)!; try { const pages = existingContext.pages(); Log.info('[Publish] 复用现有持久化上下文, pages:', pages.length); return existingContext; } catch { Log.info('[Publish] 现有上下文已失效,将重新创建'); browserContexts.delete(contextKey); } } try { const executablePath = getPlaywrightChromiumPath(); const userDataDir = getPersistentUserDataDir(platform, accountId); Log.info(`[Publish] 创建持久化浏览器上下文: ${contextKey}, dataDir: ${userDataDir}`); const context = await chromium.launchPersistentContext(userDataDir, { executablePath, headless: false, viewport: null, userAgent: STEALTH_UA, ignoreHTTPSErrors: true, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', permissions: ['geolocation', 'notifications', 'clipboard-read', 'clipboard-write'], acceptDownloads: true, args: COMMON_BROWSER_ARGS, }); await context.addInitScript(STEALTH_INIT_SCRIPT); Log.info('[Publish] 持久化浏览器上下文创建成功'); context.on('close', () => { Log.info(`[Publish] 浏览器上下文已关闭: ${contextKey}`); browserContexts.delete(contextKey); }); browserContexts.set(contextKey, context); return context; } catch (error: any) { Log.error('[Publish] 创建浏览器上下文失败:', error.message); let errorMessage = error.message || '未知错误'; if (errorMessage.includes('3221225781') || errorMessage.includes('0xC0000135') || errorMessage.includes('0xc0000135')) { errorMessage = '浏览器启动失败(错误码: 0xC0000135)。\n' + '可能原因:\n' + '1. 系统缺少 Visual C++ 运行时库,请安装: https://aka.ms/vs/17/release/vc_redist.x64.exe\n' + '2. 系统版本过低,Chromium 要求 Windows 10 或更高版本\n' + '3. 杀毒软件拦截了浏览器进程\n' + '安装 VC++ 运行库后重启应用即可。'; } else if (errorMessage.includes('Executable doesn\'t exist')) { errorMessage = '浏览器可执行文件不存在,请重新安装程序'; } else if (errorMessage.includes('EADDRINUSE')) { errorMessage = '浏览器端口被占用,请关闭其他浏览器实例后重试'; } else if (errorMessage.includes('EACCES') || errorMessage.includes('Permission denied')) { errorMessage = '权限不足,请以管理员身份运行程序'; } throw new Error(errorMessage); } } /** * 创建发布任务 */ ipcMain.handle('publish:createTask', async (event, params: { videoPath: string; coverPath: string; platforms: string[]; publishConfig: any; scheduledTime?: number; title?: string; }) => { try { const now = Date.now(); const result = await DbMain.insert( `INSERT INTO publish_tasks (title, videoPath, coverPath, platforms, publishConfig, scheduledTime, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ params.title || '视频发布任务', params.videoPath, params.coverPath, JSON.stringify(params.platforms), JSON.stringify(params.publishConfig || {}), params.scheduledTime || null, 'pending', now, now ] ); return { success: true, taskId: result }; } catch (error: any) { Log.error('publish.createTask error', error); return { success: false, message: error.message }; } }); /** * 平台登录 */ ipcMain.handle('publish:login', async (event, params: { platform: string; accountId?: number }) => { let page: Page | null = null; try { const { platform, accountId } = params; Log.info(`开始登录${platform}, accountId=${accountId}`); Log.info('正在获取持久化浏览器上下文...'); const context = await getBrowserContext(platform, accountId); Log.info('浏览器上下文就绪'); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); let loginUrl = ''; if (platform === 'douyin') { loginUrl = 'https://creator.douyin.com/'; } else if (platform === 'kuaishou') { loginUrl = 'https://cp.kuaishou.com/'; } else if (platform === 'shipin') { loginUrl = 'https://channels.weixin.qq.com/'; } else if (platform === 'xiaohongshu') { loginUrl = 'https://creator.xiaohongshu.com/'; } else { throw new Error(`不支持的平台: ${platform}`); } Log.info(`准备导航到: ${loginUrl}`); try { await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); Log.info(`页面已打开: ${loginUrl}`); } catch (error: any) { Log.info(`页面加载超时: ${error.message}`); Log.info('继续执行...'); } // 等待页面渲染(缩短等待时间) await new Promise(resolve => setTimeout(resolve, 2000)); Log.info(`登录页面已打开: ${loginUrl}`); // === 简化的登录检测逻辑(参考 aigc-human 项目) === // 不做任何额外导航,只在当前页面等待登录标志出现 let isLoggedIn = false; let userInfo: any = {}; // 各平台的登录标志选择器 const platformLoginSelectors: Record = { douyin: { selectors: ['#header-avatar', '.creator-avatar', 'input[type="file"]'], urlExclude: ['login', 'passport'], }, kuaishou: { selectors: ['[class*="avatar"]', '.user-avatar', '.user-name', '[class*="upload"]'], urlExclude: ['login', 'passport'], }, shipin: { selectors: ['[class*="avatar"]', '.avatar', '.user-info'], urlExclude: ['login', 'auth', 'qr', 'mp.weixin.qq.com'], }, xiaohongshu: { selectors: ['[class*="avatar"]', '.user-avatar', '.user-name', '[class*="upload"]'], urlExclude: ['login', 'passport', 'account'], }, }; const loginConfig = platformLoginSelectors[platform] || platformLoginSelectors.douyin; // 等页面稳定 await new Promise(resolve => setTimeout(resolve, 3000)); // 通用登录检测函数 const checkLogin = async (): Promise => { try { const currentUrl = page!.url(); if (loginConfig.urlExclude.some(kw => currentUrl.includes(kw))) { return false; } for (const selector of loginConfig.selectors) { const count = await page!.locator(selector).count().catch(() => 0); if (count > 0) return true; } return false; } catch { return false; } }; // 🔥 从页面提取真实昵称的函数 const extractNickname = async (): Promise => { try { // 各平台的昵称提取选择器(按优先级) const nicknameSelectors: Record = { douyin: [ '#header-avatar', // alt 属性可能有昵称 '.creator-avatar', '.nickname', '.user-name', '.userName', '[class*="nickname"]', '[class*="userName"]', ], kuaishou: [ '.user-name', '.userName', '.nickname', '[class*="nickname"]', '[class*="userName"]', ], shipin: [ '.nickname', '.user-name', '[class*="nickname"]', '[class*="userName"]', ], xiaohongshu: [ '.user-name', '.userName', '.nickname', '[class*="nickname"]', '[class*="userName"]', ], }; const selectors = nicknameSelectors[platform] || []; for (const selector of selectors) { try { const el = await page!.$(selector); if (el) { // 尝试多种方式获取名字 const alt = await el.getAttribute('alt').catch(() => ''); if (alt && alt.length > 0 && alt !== '头像') return alt; const title = await el.getAttribute('title').catch(() => ''); if (title && title.length > 0) return title; const text = await el.textContent().catch(() => ''); if (text && text.trim().length > 0 && text.trim().length < 20) return text.trim(); } } catch { /* 继续 */ } } // 回退:从 cookies 中提取(某些平台会在 cookie 中存用户名) const cookies = await context.cookies(); for (const cookie of cookies) { if (cookie.name === 'LOGIN_STATUS' || cookie.name === 'passport_user_name') { try { const decoded = decodeURIComponent(cookie.value); if (decoded.length > 0 && decoded.length < 30) return decoded; } catch { } } } // 最终回退 return `用户_${Date.now().toString(36).slice(-4)}`; } catch { return `用户_${Date.now().toString(36).slice(-4)}`; } }; isLoggedIn = await checkLogin(); if (isLoggedIn) { Log.info(`${platform} 通过已保存的cookies自动登录成功`); userInfo.nickname = await extractNickname(); Log.info(`提取到昵称: ${userInfo.nickname}`); } // 如果未登录,等待用户扫码 if (!isLoggedIn) { Log.info('等待用户扫码登录(页面不会自动跳转)...'); const maxWaitTime = 300000; const startTime = Date.now(); let lastLogTime = 0; while (!isLoggedIn && (Date.now() - startTime) < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, 3000)); try { if (page!.isClosed()) { Log.info('页面已关闭,退出等待'); return { success: false, message: '页面已关闭' }; } } catch { /* 忽略 */ } isLoggedIn = await checkLogin(); const elapsed = Date.now() - startTime; if (elapsed - lastLogTime >= 15000) { Log.info(`等待登录中... [${Math.round(elapsed / 1000)}s]`); lastLogTime = elapsed; } } if (isLoggedIn) { userInfo.nickname = await extractNickname(); Log.info(`${platform} 扫码登录成功!昵称: ${userInfo.nickname}`); } else { try { if (page && !page.isClosed()) await page.close(); } catch { /* 忽略 */ } return { success: false, message: '登录超时,请在5分钟内完成扫码登录' }; } } // 保存Cookie到数据库 const cookies = await context.cookies(); Log.info(`获取到 ${cookies.length} 个Cookie`); const now = Date.now(); const userId = userInfo.nickname || `user_${now}`; let targetAccountId: number | string; if (accountId) { targetAccountId = accountId; await DbMain.execute( 'UPDATE platform_accounts SET cookies = ?, nickname = ?, uid = ?, updatedAt = ?, loginStatus = ? WHERE id = ?', [JSON.stringify(cookies), userInfo.nickname, userId, now, 'active', accountId] ); Log.info(`账号更新成功, ID: ${targetAccountId}`); } else { const existingAccount = await DbMain.first( 'SELECT * FROM platform_accounts WHERE platform = ? ORDER BY updatedAt DESC LIMIT 1', [platform] ); if (existingAccount) { Log.info(`更新已存在的账号: ${existingAccount.nickname}`); try { await DbMain.execute( 'UPDATE platform_accounts SET cookies = ?, nickname = ?, uid = ?, updatedAt = ?, loginStatus = ? WHERE id = ?', [JSON.stringify(cookies), userInfo.nickname, userId, now, 'active', existingAccount.id] ); targetAccountId = existingAccount.id; Log.info(`账号更新成功, ID: ${targetAccountId}`); } catch (error: any) { Log.error(`更新账号失败`, error); throw error; } } else { Log.info(`创建新账号: ${userInfo.nickname}`); try { targetAccountId = await DbMain.insert( `INSERT INTO platform_accounts (platform, uid, nickname, cookies, loginStatus, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ platform, userId, userInfo.nickname || '用户', JSON.stringify(cookies), 'active', now, now ] ); Log.info(`账号创建成功, ID: ${targetAccountId}`); } catch (error: any) { Log.error(`创建账号失败`, error); throw error; } } } // 🔧 关键修复:只关闭页面,不关闭浏览器上下文 // 原因:浏览器上下文中的Cookie是持久化的,需要保留给后续检查和发布使用 // 如果关闭上下文,会导致页面被关了又打开,且会丢失登录状态 if (page && !page.isClosed()) { try { await page.close(); Log.info('登录页面已关闭'); } catch (closeError: any) { Log.error('关闭登录页面失败:', closeError.message); } } // 🔧 优化:保留一个页面供后续检查使用,而不是关闭所有页面 // 这样可以避免检查登录状态时重复打开浏览器 try { const context = await getBrowserContext(platform, accountId); const allPages = context.pages(); // 保留最后一个页面(通常是登录成功的页面),关闭其他多余页签 if (allPages.length > 1) { // 关闭除最后一个页面外的所有页面 for (let i = 0; i < allPages.length - 1; i++) { try { if (!allPages[i].isClosed()) { await allPages[i].close(); Log.info(`已关闭额外页签: ${allPages[i].url()}`); } } catch (e) { Log.debug('关闭页签失败(忽略)'); } } } // 🔧 修改:检测到已登录后,必须关闭浏览器(不是关页签,是关闭整个浏览器) Log.info(`✅ 登录成功,准备关闭浏览器`); await closeBrowserContext(platform, accountId); Log.info(`✅ 浏览器已关闭`); } catch (closeBrowserError: any) { Log.error('关闭浏览器失败:', closeBrowserError.message); try { await closeBrowserContext(platform, accountId); } catch (e) { Log.error('强制关闭浏览器失败:', e); } } Log.info(`${platform}登录成功`, { nickname: userInfo.nickname, accountId, cookieCount: cookies.length }); return { success: true, userInfo: { nickname: userInfo.nickname, userId: userId, accountId: accountId } }; } catch (error: any) { Log.error('publish.login error', error); // 发生错误时也尝试关闭页面(但不关闭浏览器上下文) try { if (page && !page.isClosed()) { await page.close(); Log.info('错误处理:登录页面已关闭'); } } catch (e) { Log.debug('关闭页面失败(忽略)'); } // 不关闭浏览器上下文,保留Cookie供后续使用 Log.info('✅ 登录出错,但浏览器上下文已保留供后续使用'); return { success: false, message: error.message }; } }); /** * 实际检查登录状态(打开浏览器页面验证) */ async function actuallyCheckLoginStatus(platform: string, accountId?: number | string): Promise<{ isLoggedIn: boolean; userInfo?: any }> { let page: Page | null = null; try { Log.info(`开始实际检查${platform}登录状态, accountId=${accountId}`); const context = await getBrowserContext(platform, accountId); // 🔧 优化:先检查是否有已打开的页面可以复用 const existingPages = context.pages(); const availablePage = existingPages.find(p => !p.isClosed()); if (availablePage) { // 如果有可用页面,复用它而不是创建新页面 page = availablePage; Log.info(`复用已存在的页面: ${page.url()}`); } else { // 如果没有可用页面,创建新页面 page = await context.newPage(); Log.info('创建新页面用于登录检查'); } page.setDefaultTimeout(20000); // 减少默认超时时间 page.setDefaultNavigationTimeout(20000); // 减少导航超时时间 // 根据平台确定检查URL let checkUrl = ''; if (platform === 'douyin') { checkUrl = 'https://creator.douyin.com/creator-micro/content/upload'; } else if (platform === 'kuaishou') { checkUrl = 'https://cp.kuaishou.com/article/publish'; } else if (platform === 'shipin') { checkUrl = 'https://channels.weixin.qq.com/platform/post/create'; } else if (platform === 'xiaohongshu') { checkUrl = 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=video'; } else { throw new Error(`不支持的平台: ${platform}`); } // 导航到检查页面(使用domcontentloaded更快,不等待所有资源) let currentUrl = ''; // 🔧 优化:如果页面已经在目标URL,就不需要重新导航 const currentPageUrl = page.url(); if (currentPageUrl.includes(checkUrl.split('/').slice(-2).join('/')) || (platform === 'douyin' && currentPageUrl.includes('creator.douyin.com') && !currentPageUrl.includes('login'))) { // 页面已经在目标URL或相关页面,直接使用 currentUrl = currentPageUrl; Log.info(`页面已在目标URL,无需重新导航: ${currentUrl}`); } else { // 需要导航到检查页面 try { await page.goto(checkUrl, { waitUntil: 'domcontentloaded', timeout: 20000 // 减少超时时间 }); Log.info('页面已打开'); currentUrl = page.url(); } catch (error: any) { Log.info(`页面加载超时或失败: ${error.message}`); // 即使超时,也尝试获取当前URL try { currentUrl = page.url(); Log.info(`获取到当前URL: ${currentUrl}`); } catch (urlError: any) { Log.error(`无法获取URL: ${urlError.message}`); // 如果无法获取URL,直接返回未登录 return { isLoggedIn: false }; } Log.info('继续执行登录状态检查...'); } } // 等待页面渲染(缩短等待时间) await new Promise(resolve => setTimeout(resolve, 1500)); // 如果之前获取URL失败,再次尝试 if (!currentUrl) { try { currentUrl = page.url(); } catch (e) { Log.error('无法获取页面URL'); return { isLoggedIn: false }; } } Log.info(`${platform}当前URL: ${currentUrl}`); // 根据平台检查登录状态 let isLoggedIn = false; let userInfo: any = {}; if (platform === 'douyin') { // 参考 aigc-human 项目:用 #header-avatar 精确检测登录状态 if (currentUrl.includes('login') || currentUrl.includes('passport') || currentUrl.includes('sso') || currentUrl.includes('verify')) { isLoggedIn = false; Log.info(`${platform}检测到登录页面,未登录`); } else if (currentUrl.includes('creator.douyin.com')) { try { // 等页面完全加载 await new Promise(resolve => setTimeout(resolve, 3000)); // 🔥 核心检测:查找 #header-avatar(参考 aigc-human douYinService.js:247) const avatarElement = await page.$('#header-avatar'); if (avatarElement) { isLoggedIn = true; userInfo.nickname = '用户'; Log.info(`${platform}检测到 #header-avatar,已登录`); } else { // 备用:检查 input[type=file](上传页面的标志) const fileInputCount = await page.locator('input[type="file"]').count(); if (fileInputCount > 0) { isLoggedIn = true; userInfo.nickname = '用户'; Log.info(`${platform}检测到文件输入框,已登录`); } else { isLoggedIn = false; Log.info(`${platform}未检测到 #header-avatar 和文件输入框,未登录`); } } } catch (error: any) { Log.error(`${platform}登录检测过程出错:`, error); isLoggedIn = false; } } else { isLoggedIn = false; Log.info(`${platform}URL不在creator.douyin.com,未登录`); } } else if (platform === 'kuaishou') { if (currentUrl.includes('cp.kuaishou.com') && !currentUrl.includes('login')) { try { const fileInputCount = await page.locator('input[type="file"]').count(); if (fileInputCount > 0 || currentUrl.includes('publish') || currentUrl.includes('article')) { isLoggedIn = true; userInfo.nickname = '用户'; } else { isLoggedIn = false; } } catch { isLoggedIn = false; } } else { isLoggedIn = false; } } else if (platform === 'shipin') { // 视频号登录状态检测(严格模式) // 根本原则:只有确实有文件输入框才是真正登录 // 首先检查是否在登录/认证页面(明确的未登录标志) const isLoginPage = currentUrl.includes('login') || currentUrl.includes('auth') || currentUrl.includes('weixin.qq.com/qr') || currentUrl.includes('mp.weixin.qq.com') || currentUrl.includes('servicelogin'); if (isLoginPage) { isLoggedIn = false; Log.info(`${platform}检测到登录页面,未登录`); } else if (currentUrl.includes('channels.weixin.qq.com')) { try { // 🔧 关键修复:等待5秒让页面完全加载(之前只等2秒,导致误判为未登录) Log.info(`${platform}等待5秒让页面完全加载...`); await new Promise(resolve => setTimeout(resolve, 5000)); // 严格检查1:必须有文件输入框(真正的登录标志) const fileInputCount = await page.locator('input[type="file"]').count(); Log.info(`${platform}检测到文件输入框数量: ${fileInputCount}`); if (fileInputCount > 0) { // 🔧 关键修复:只要文件输入框存在就说明已登录 // 不需要检查它是否可见,因为在视频号页面上文件输入框通常被隐藏(这是正常的) // 只要页面加载完整并能找到输入框,就证明用户已登录 isLoggedIn = true; userInfo.nickname = '用户'; Log.info(`${platform}检测到文件输入框(${fileInputCount}个),确认已登录`); } else { // 没有文件输入框 = 未登录(即使在上传页面) isLoggedIn = false; Log.info(`${platform}未找到文件输入框,确认未登录`); // 额外诊断:检查页面内容是什么 const bodyText = await page.locator('body').textContent().catch(() => ''); const pageInfo = bodyText?.substring(0, 100) || '无法获取'; Log.info(`${platform}页面内容诊断: ${pageInfo}`); } } catch (error: any) { Log.error(`${platform}登录检测过程出错: ${error.message}`); isLoggedIn = false; } } else { isLoggedIn = false; Log.info(`${platform}URL不在视频号域名,未登录`); } } else if (platform === 'xiaohongshu') { // 检查是否在登录页面 const isLoginPage = currentUrl.includes('login') || currentUrl.includes('passport') || currentUrl.includes('account'); if (isLoginPage) { isLoggedIn = false; Log.info(`${platform}检测到登录页面,未登录`); } else if (currentUrl.includes('creator.xiaohongshu.com')) { try { // 必须找到文件输入框才能确定已登录(真正登录后才能看到上传界面) await page.waitForTimeout(2000); // 等待页面加载 const fileInputCount = await page.locator('input[type="file"]').count(); if (fileInputCount > 0) { // 再次验证:检查文件输入框是否真的可用 const isFileInputVisible = await page.locator('input[type="file"]').first() .evaluate((el: any) => { // 检查输入框是否在可见的容器中 let parent = el.parentElement; let depth = 0; while (parent && depth < 5) { const style = window.getComputedStyle(parent); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { return false; } parent = parent.parentElement; depth++; } return true; }).catch(() => true); if (isFileInputVisible) { isLoggedIn = true; userInfo.nickname = '用户'; Log.info(`${platform}检测到文件输入框,已登录`); } else { isLoggedIn = false; Log.info(`${platform}文件输入框不可见,未登录`); } } else { isLoggedIn = false; Log.info(`${platform}未找到文件输入框,未登录`); } } catch (error: any) { Log.error(`${platform}登录检测过程出错:`, error); isLoggedIn = false; } } else { isLoggedIn = false; Log.info(`${platform}URL不在小红书域名,未登录`); } } Log.info(`${platform}登录状态检查完成: ${isLoggedIn ? '已登录' : '未登录'}`); // 🔧 优化:不关闭浏览器上下文,保留以供后续发布使用 // 这样可以复用已登录的会话,提高效率 if (isLoggedIn) { Log.info(`✅ 检测到已登录,保留浏览器供后续发布使用`); } else { Log.info(`⚠️ 未登录,保留浏览器供用户继续登录`); } return { isLoggedIn, userInfo }; } catch (error: any) { Log.error(`检查${platform}登录状态失败:`, { message: error.message, name: error.name, stack: error.stack }); // 超时错误不算严重,返回未登录状态即可 if (error.name === 'TimeoutError') { Log.info(`${platform}登录状态检查超时,返回未登录状态`); } // 🔧 优化:即使出错,也保留页面和浏览器上下文供后续使用 // 不关闭页面和浏览器上下文!即使发生错误,也要保留供后续发布使用 Log.info(`⚠️ 登录检查出错,但页面和浏览器上下文已保留供后续发布使用`); return { isLoggedIn: false }; } } /** * 检查登录状态 */ ipcMain.handle('publish:checkLoginStatus', async (event, params: { platform: string; accountId?: number; checkBrowser?: boolean }) => { try { const { platform, accountId, checkBrowser = true } = params; // 默认为true,总是进行真实检查 // 🔧 修改:如果 checkBrowser 为 false,只从数据库读取状态,不打开浏览器 // 如果 checkBrowser 为 true(默认),必须真正打开浏览器检测登录状态 if (checkBrowser === false) { let account; if (accountId) { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE id = ?', [accountId] ); } else { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE platform = ? AND loginStatus = ? ORDER BY updatedAt DESC LIMIT 1', [platform, 'active'] ); } if (account) { return { success: true, isLoggedIn: true, userInfo: { nickname: account.nickname, userId: account.uid } }; } else { return { success: true, isLoggedIn: false }; } } // checkBrowser 为 true 时,必须真正打开浏览器检测登录状态 // 实际检查登录状态(打开浏览器页面验证) // 这确保每次都获取最新的真实登录状态 const actualStatus = await actuallyCheckLoginStatus(platform, accountId); let account; if (accountId) { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE id = ?', [accountId] ); } else { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE platform = ? AND loginStatus = ? ORDER BY updatedAt DESC LIMIT 1', [platform, 'active'] ); } // 如果实际检查结果是未登录,更新数据库状态 if (!actualStatus.isLoggedIn) { if (account) { Log.info(`检测到${platform}实际未登录,更新数据库状态`); await DbMain.execute( 'UPDATE platform_accounts SET loginStatus = ? WHERE id = ?', ['inactive', account.id] ); } return { success: true, isLoggedIn: false }; } // 如果实际检查结果是已登录 if (actualStatus.isLoggedIn) { const now = Date.now(); // 如果数据库中有账号记录,更新它 if (account) { Log.info(`检测到${platform}已登录,更新数据库信息`); await DbMain.execute( 'UPDATE platform_accounts SET nickname = ?, updatedAt = ?, loginStatus = ? WHERE id = ?', [actualStatus.userInfo?.nickname || account.nickname, now, 'active', account.id] ); return { success: true, isLoggedIn: true, userInfo: { nickname: actualStatus.userInfo?.nickname || account.nickname, userId: account.uid } }; } else { // 如果数据库中没有账号记录,但实际已登录,创建新记录 Log.info(`检测到${platform}已登录但数据库无记录,创建新记录`); const userId = `user_${now}`; const accountId = await DbMain.insert( `INSERT INTO platform_accounts (platform, uid, nickname, cookies, loginStatus, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ platform, userId, actualStatus.userInfo?.nickname || '用户', '[]', 'active', now, now ] ); return { success: true, isLoggedIn: true, userInfo: { nickname: actualStatus.userInfo?.nickname || '用户', userId: userId } }; } } // 如果实际检查结果是未登录,已在上面的分支处理 return { success: false, isLoggedIn: false }; } catch (error: any) { Log.error('publish.checkLoginStatus error', error); return { success: false, message: error.message, isLoggedIn: false }; } }); /** * 关闭指定平台的浏览器 */ ipcMain.handle('publish:closeBrowser', async (event, params: { platform: string }) => { try { const { platform } = params; Log.info(`关闭${platform}浏览器`); await closeBrowserContext(platform); return { success: true, message: `已关闭${platform}浏览器` }; } catch (error: any) { Log.error('publish.closeBrowser error', error); return { success: false, message: error.message }; } }); /** * 执行发布 */ ipcMain.handle('publish:execute', async (event, params: ExecutePublishParams) => { try { const { platform, videoPath, coverPath, title, description, tags } = params; Log.info(`开始发布到${platform}`, { platform, accountId: params.accountId }); Log.info(`发布参数检查: autoPublish=${params.autoPublish}, type=${typeof params.autoPublish}`); if (!videoPath || !fs.existsSync(videoPath)) { throw new Error('视频文件不存在'); } if (!coverPath || !fs.existsSync(coverPath)) { throw new Error('封面文件不存在'); } // 🔥 优先使用前端传入的 accountId,否则自动获取最新活跃账号 let account; if (params.accountId) { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE id = ?', [params.accountId] ); } else { account = await DbMain.first( 'SELECT * FROM platform_accounts WHERE platform = ? AND loginStatus = ? ORDER BY updatedAt DESC LIMIT 1', [platform, 'active'] ); } if (!account) { throw new Error('未找到登录账号,请先登录'); } const accountId = account.id; Log.info(`使用账号: ${account.nickname}`, { accountId }); const outputDir = path.join(os.homedir(), '.aigcpanel', 'data', 'hub'); let adaptedCoverPath = coverPath; try { Log.info('开始适配封面...', { 原始封面: coverPath, 平台: platform, 输出目录: outputDir }); const adaptResult = await CoverAdapter.adaptCover({ coverPath, platform, outputDir }); adaptedCoverPath = adaptResult.adaptedPath; // 验证生成的文件 if (fs.existsSync(adaptedCoverPath)) { const stats = fs.statSync(adaptedCoverPath); const ext = path.extname(adaptedCoverPath).toLowerCase(); Log.info('封面适配完成', { platform, 适配后路径: adaptedCoverPath, 文件大小: `${(stats.size / 1024).toFixed(2)}KB`, 文件扩展名: ext, adjustments: adaptResult.adjustments }); // 确保是jpg格式 if (ext !== '.jpg' && ext !== '.jpeg') { Log.error(`警告:生成的封面格式不是JPG: ${ext}`); } } else { Log.error('适配后的封面文件不存在!'); throw new Error('适配后的封面文件不存在'); } } catch (error: any) { Log.error('封面适配失败,将使用原文件', { 错误信息: error.message, 原始文件: coverPath, 原始文件是否存在: fs.existsSync(coverPath) }); // 检查原始文件格式 const originalExt = path.extname(coverPath).toLowerCase(); if (originalExt !== '.jpg' && originalExt !== '.jpeg') { Log.error(`原始封面格式不是JPG: ${originalExt},抖音可能不支持`); } } // 获取浏览器上下文(加载指定账号的 cookies) const context = await getBrowserContext(platform, accountId); // 执行平台特定的发布操作 let result: any = {}; if (platform === 'douyin') { result = await publishToDouyin(context, { videoPath, coverPath: adaptedCoverPath, title, description, tags, autoPublish: params.autoPublish }); } else if (platform === 'kuaishou') { result = await publishToKuaishou(context, { videoPath, coverPath: adaptedCoverPath, title, description, tags, autoPublish: params.autoPublish }); } else if (platform === 'shipin') { result = await publishToShipin(context, { videoPath, coverPath: adaptedCoverPath, title, description, tags, autoPublish: params.autoPublish }); } else if (platform === 'xiaohongshu') { result = await publishToXiaohongshu(context, { videoPath, coverPath: adaptedCoverPath, title, description, tags, autoPublish: params.autoPublish }); } else { throw new Error(`不支持的平台: ${platform}`); } const resultStatus: PublishResultStatus = result.status || (result.success ? 'success' : 'failed'); if (resultStatus === 'verification_required' || resultStatus === 'manual_pending') { await DbMain.insert( `INSERT INTO publish_history (taskId, platform, accountId, videoUrl, videoId, status, errorMessage, publishedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ params.taskId || 0, platform, accountId, result.videoUrl || '', result.videoId || '', resultStatus, result.message || '', Date.now() ] ); Log.warn(`发布进入待确认状态: ${platform}`, { status: resultStatus, message: result.message, videoUrl: result.videoUrl }); return { success: false, pending: true, status: resultStatus, message: result.message, videoUrl: result.videoUrl, videoId: result.videoId, platform }; } if (!result.success) { throw new Error(result.message || '发布失败'); } await DbMain.insert( `INSERT INTO publish_history (taskId, platform, accountId, videoUrl, videoId, status, errorMessage, publishedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ params.taskId || 0, platform, accountId, result.videoUrl || '', result.videoId || '', 'success', '', Date.now() ] ); Log.info(`发布成功: ${platform}`, { videoId: result.videoId, videoUrl: result.videoUrl }); return { success: true, status: 'success', videoUrl: result.videoUrl, videoId: result.videoId, platform }; } catch (error: any) { Log.error('publish.execute error', { message: error.message, stack: error.stack, platform: params.platform }); // 记录失败历史 try { // 尝试获取账号ID let accountId = 0; try { const account = await DbMain.first( 'SELECT id FROM platform_accounts WHERE platform = ? AND loginStatus = ? ORDER BY updatedAt DESC LIMIT 1', [params.platform, 'active'] ); accountId = account?.id || 0; } catch (e) { // 忽略错误 } await DbMain.insert( `INSERT INTO publish_history (taskId, platform, accountId, status, errorMessage, publishedAt) VALUES (?, ?, ?, ?, ?, ?)`, [ params.taskId || 0, params.platform, accountId, 'failed', error.message, Date.now() ] ); } catch (e) { Log.error('Failed to save error history', e); } return { success: false, message: error.message, platform: params.platform }; } }); /** * 发布到抖音(使用改进版脚本逻辑) */ async function publishToDouyin(context: BrowserContext, params: { videoPath: string; coverPath: string; title: string; description: string; tags: string[]; autoPublish?: boolean; }): Promise { let page: Page | null = null; try { Log.info('开始发布到抖音'); // 创建新页面 page = await context.newPage(); page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); Log.info('浏览器页面创建成功'); // 导航到抖音上传页面(直接访问上传URL) Log.info('导航到抖音上传页面'); const uploadUrl = 'https://creator.douyin.com/creator-micro/content/upload'; Log.info(`URL: ${uploadUrl}`); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); const currentUrl = page.url(); Log.info(`当前URL: ${currentUrl}`); // 🔥 参考 aigc-human 项目:先检查 #header-avatar 确认登录状态 const avatarElement = await page.$('#header-avatar'); if (!avatarElement) { // 检查是否被重定向到登录页 if (currentUrl.includes('login') || currentUrl.includes('passport')) { Log.error('未登录:被重定向到登录页'); throw new Error('未登录抖音,请先在账号管理中扫码登录'); } // 再等几秒看看 await page.waitForTimeout(5000); const avatarRetry = await page.$('#header-avatar'); if (!avatarRetry) { Log.error('未检测到 #header-avatar,cookies 可能已过期'); throw new Error('抖音登录已过期,请重新扫码登录'); } } Log.info('✅ 检测到 #header-avatar,已登录'); // 确保在上传页面 if (!currentUrl.includes('content/upload')) { Log.info('不在上传页面,导航到上传页...'); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); } // ========== 步骤1: 上传视频 ========== Log.info('【步骤1】上传视频文件'); // 🔥 参考 aigc-human 项目:用 waitForSelector 等待上传控件出现 Log.info('等待上传输入框就绪...'); try { await page.waitForSelector('input[type="file"]', { state: 'attached', timeout: 60000 }); Log.info('上传输入框已就绪'); } catch (e) { Log.error('等待上传输入框超时(60秒)'); throw new Error('上传页面加载超时,请检查网络或重新登录'); } // 查找文件输入框 const fileInput = await page.$('input[type="file"]'); if (!fileInput) { throw new Error('未找到文件上传输入框'); } Log.info(`上传文件: ${params.videoPath}`); try { await fileInput.setInputFiles(params.videoPath); Log.info('视频文件已上传'); } catch (error: any) { Log.error(`文件上传失败: ${error.message}`); throw error; } await page.waitForTimeout(3000); // ========== 步骤2: 等待视频处理 ========== Log.info('【步骤2】等待视频处理'); try { await Promise.race([ page.locator('text="预览视频"').waitFor({ timeout: 60000 }).catch(() => null), page.locator('text="预览"').waitFor({ timeout: 60000 }).catch(() => null), new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), 60000)) ]).catch(() => { Log.info('未检测到预览元素,继续...'); }); Log.info('视频处理完成'); } catch (error: any) { Log.info(`预览检测失败: ${error.message}`); } await page.waitForTimeout(2000); // 等待上传完成(最多5分钟) Log.info('等待视频上传完成...'); let uploadComplete = false; const maxWaitTime = 300000; // 5分钟 const startTime = Date.now(); while (!uploadComplete && (Date.now() - startTime) < maxWaitTime) { try { // 检查是否有上传成功的标识 const successIndicator = page.locator('[data-e2e="upload-success"], .upload-success, [class*="success"]').first(); if (await successIndicator.isVisible({ timeout: 1000 })) { uploadComplete = true; Log.info('检测到上传成功标识'); break; } } catch (e) { // 继续等待 } // 检查是否可以填写标题(说明上传完成) try { const titleInput = page.locator('textarea[placeholder*="标题"], input[placeholder*="标题"]').first(); if (await titleInput.isVisible({ timeout: 1000 }) && await titleInput.isEnabled()) { uploadComplete = true; Log.info('标题输入框可用,上传应该已完成'); break; } } catch (e) { // 继续等待 } await page.waitForTimeout(2000); Log.info(`上传中... 已等待 ${Math.floor((Date.now() - startTime) / 1000)} 秒`); } if (!uploadComplete) { Log.info('上传超时,但继续尝试填写表单'); } // ========== 步骤3: 上传封面 ========== // 🔥 参考 aigc-human 项目的 uploadCustomCover 方法 Log.info('【步骤3】上传封面'); await page.waitForTimeout(3000); try { // 查找封面控制区域(竖封面 3:4) const coverControlXPath = '//div[contains(@class, "coverControl-CjlzqC")][.//div[contains(text(), "竖封面3:4")]]'; let coverControl = await page.$(coverControlXPath); // 如果没找到竖封面控制,尝试点击"选择封面"按钮 if (!coverControl) { Log.info('未找到封面控制区域,尝试点击"选择封面"按钮'); const coverBtn = page.locator('text="选择封面"').first(); if (await coverBtn.count() > 0) { await coverBtn.click(); Log.info('已点击选择封面'); await page.waitForTimeout(3000); // 再次查找 coverControl = await page.$(coverControlXPath); } } if (coverControl) { // 点击封面控制区域打开弹窗 await coverControl.click(); Log.info('已点击封面控制区域'); await page.waitForTimeout(2000); // 🔥 精确定位弹窗内的上传 input(参考 aigc-human douYinService.js:690) try { await page.waitForSelector('.upload-BvM5FF .semi-upload-hidden-input', { state: 'attached', timeout: 10000 }); const uploadInput = await page.$('.upload-BvM5FF .semi-upload-hidden-input'); if (uploadInput) { await uploadInput.setInputFiles(params.coverPath); Log.info(`封面文件已上传: ${params.coverPath}`); await page.waitForTimeout(3000); } else { Log.info('未找到 .upload-BvM5FF 上传输入框,尝试通用选择器'); // 回退:查找弹窗中第一个图片 input const imageInput = await page.$('input[type="file"][accept*="image/png"]'); if (imageInput) { await imageInput.setInputFiles(params.coverPath); Log.info('使用通用图片输入框上传封面'); await page.waitForTimeout(3000); } else { Log.info('未找到任何可用的图片上传输入框'); } } } catch (waitError: any) { Log.info(`等待上传输入框失败: ${waitError.message},尝试通用选择器`); const imageInput = await page.$('input[type="file"][accept*="image/png"]'); if (imageInput) { await imageInput.setInputFiles(params.coverPath); Log.info('使用通用图片输入框上传封面'); await page.waitForTimeout(3000); } } // 点击完成按钮(参考 aigc-human douYinService.js:698) const completeBtnXPath = '//*[@id="tooltip-container"]//button[1]'; try { await page.waitForSelector(completeBtnXPath, { timeout: 10000 }); const completeBtn = await page.$(completeBtnXPath); if (completeBtn) { // 等待按钮可点击 await page.waitForTimeout(2000); await completeBtn.click(); Log.info('已点击完成按钮'); await page.waitForTimeout(2000); } } catch (e: any) { // 尝试其他完成按钮选择器 const doneBtn = page.locator('button:has-text("完成")').first(); if (await doneBtn.count() > 0) { await doneBtn.click(); Log.info('已点击完成(备用选择器)'); await page.waitForTimeout(2000); } } Log.info('封面设置完成'); } else { Log.info('未找到封面控制区域,跳过封面上传'); } } catch (error: any) { Log.info(`封面上传失败: ${error.message}`); // 不抛出错误,继续后续步骤 } // 🔥 自动关闭所有可能弹出的弹窗(如"设置横封面获取更多流量") try { await page.waitForTimeout(1000); // 点击"暂不设置"跳过横封面设置 const skipBtn = page.locator('button:has-text("暂不设置"), button:has-text("暂不"), button:has-text("跳过")').first(); if (await skipBtn.count() > 0) { await skipBtn.click(); Log.info('已关闭弹窗:点击"暂不设置"'); await page.waitForTimeout(1000); } // 关闭可能残留的其他弹窗(X 关闭按钮) const closeButtons = await page.$$('.semi-modal-close, [aria-label="close"], [class*="close-icon"], [class*="CloseBtn"]'); for (const closeBtn of closeButtons) { try { await closeBtn.click(); Log.info('已关闭弹窗:点击 X'); await page.waitForTimeout(500); } catch { } } } catch (e: any) { Log.debug(`关闭弹窗时出错(忽略): ${e.message}`); } // ========== 步骤4: 填写标题 ========== Log.info('【步骤4】填写标题'); try { const titleSelectors = [ 'input[placeholder*="填写作品标题"]', 'input[placeholder*="标题"]', 'input[placeholder*="作品"]', '//input[contains(@placeholder, "填写作品标题")]' ]; let titleInput: any = null; for (const selector of titleSelectors) { const count = await page.locator(selector).count(); if (count > 0) { titleInput = page.locator(selector).first(); Log.debug(`找到标题输入框: ${selector}`); break; } } if (titleInput) { const title = params.title.slice(0, 20); // 抖音标题限制20字 await titleInput.fill(title); Log.info(`标题已填写: "${title}"`); } else { Log.info('未找到标题输入框'); } } catch (error: any) { Log.info(`标题填写失败: ${error.message}`); } await page.waitForTimeout(1000); // 填写描述(填写话题内容) Log.info('填写描述(使用话题内容)...'); try { // 尝试多种选择器查找描述输入框 const descSelectors = [ 'textarea[placeholder*="描述"]', 'textarea[placeholder*="简介"]', 'textarea[placeholder*="作品"]', '[class*="desc"] textarea', '[class*="content"] textarea', 'div[contenteditable="true"]' ]; let descInput = null; for (const selector of descSelectors) { try { const element = page.locator(selector).first(); await element.waitFor({ timeout: 2000 }); if (await element.isVisible()) { descInput = element; Log.info(`找到描述输入框: ${selector}`); break; } } catch (e) { // 继续尝试 } } if (descInput) { // 点击输入框激活 await descInput.click(); await page.waitForTimeout(300); // 清空输入框 await descInput.fill(''); await page.waitForTimeout(300); // 逐个输入话题,每个话题后按空格让平台识别 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag : `#${tag}`; Log.info(`输入话题 ${i + 1}/${params.tags.length}: ${cleanTag}`); // 使用键盘输入话题(而不是fill,以触发平台的话题识别) await page.keyboard.type(cleanTag, { delay: 50 }); await page.waitForTimeout(300); // 按空格键触发话题识别和选择 await page.keyboard.press('Space'); await page.waitForTimeout(500); // 检查是否有话题选择弹窗,如果有则选择第一个 try { const topicPopup = page.locator('[class*="topic"], [class*="hashtag"]').first(); if (await topicPopup.isVisible({ timeout: 1000 })) { Log.info('检测到话题选择弹窗,选择第一个选项'); await page.keyboard.press('Enter'); await page.waitForTimeout(300); } } catch (e) { // 没有弹窗,继续 Log.debug('未检测到话题弹窗,继续'); } } Log.info(`所有话题已逐个输入完成`); } else { Log.error('未找到描述输入框'); } } catch (error) { Log.error('填写描述失败', { message: error.message }); } await page.waitForTimeout(1000); // 标签已在步骤4中输入完成,无需重复输入 await page.waitForTimeout(1000); // 所有准备工作已完成,等待用户手动发布 // 所有准备工作已完成 Log.info('=== 所有准备工作已完成 ==='); Log.info('视频、封面、标题、描述、话题标签都已填写完成'); if (params.autoPublish) { Log.info('检测到自动发布标志,准备执行点击发布按钮...'); // 查找发布按钮(抖音创作者平台的发布按钮) // 抖音的发布按钮: const publishBtnSelectors = [ 'button.primary:text-is("发布")', 'button[class*="primary"]:text-is("发布")', 'button[class*="button-"]:text-is("发布")', '#popover-tip-container button:text-is("发布")', 'button:text-is("发布")', 'button[class*="primary"]:has-text("发布")', 'button:has-text("确定发布")' ]; let publishBtn = null; for (const selector of publishBtnSelectors) { try { const btns = await page.locator(selector).all(); for (const btn of btns) { if (await btn.isVisible().catch(() => false)) { const text = await btn.textContent().catch(() => ''); const trimmedText = text?.trim() || ''; Log.info(`检查发布按钮: selector=${selector}, text="${trimmedText}"`); // 确保按钮文字就是"发布" if (trimmedText === '发布' || trimmedText === '确定发布') { publishBtn = btn; Log.info(`找到发布按钮: ${selector}, text="${trimmedText}"`); break; } } } if (publishBtn) break; } catch (e) { Log.debug(`选择器 ${selector} 检查失败: ${e.message}`); } } if (publishBtn) { await page.waitForTimeout(2000); // 稍等片刻 await publishBtn.click(); // publish-triggered publishTriggered = true; Log.info('已自动点击发布按钮'); // 等待发布成功提示 try { await Promise.race([ page.locator('text="发布成功"').waitFor({ timeout: 10000 }), page.locator('text="上传成功"').waitFor({ timeout: 10000 }), page.waitForTimeout(5000) ]); Log.info('发布操作已提交'); } catch (e) { Log.info('未检测到明确的发布成功提示,但已执行点击'); } } else { Log.warn('未找到发布按钮,请手动点击'); } } else { Log.info('请在浏览器中检查内容,然后手动点击发布按钮'); Log.info('浏览器窗口将保持打开状态,等待您手动操作...'); // 等待30秒,给用户足够时间检查和手动发布 await page.waitForTimeout(30000); } if (params.autoPublish) { if (publishTriggered) { const publishResult = await waitForPublishConfirmation(page, 'douyin'); if (publishResult.success) { shouldClosePage = true; } return publishResult; } return { success: false, status: 'manual_pending', message: '未找到明确的发布按钮,请在浏览器中手动确认发布', videoUrl: page.url(), videoId: '', keepPageOpen: true }; } const finalUrl = page.url(); Log.info(`当前页面URL: ${finalUrl}`); return { success: false, status: 'manual_pending', message: '准备工作已完成,请在浏览器中手动点击发布按钮', videoUrl: finalUrl, videoId: 'manual_publish', keepPageOpen: true }; } catch (error: any) { Log.error('发布到抖音失败', { message: error.message, stack: error.stack }); // 尝试保存错误截图 if (page) { try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'douyin_error.png'); await page.screenshot({ path: screenshotPath }); Log.info(`错误截图已保存: ${screenshotPath}`); } catch (e) { Log.error('保存截图失败', e); } } throw error; } finally { if (page && shouldClosePage) { try { await page.close(); Log.info('页面已关闭'); } catch (error) { Log.error('关闭页面失败', { message: error.message }); } } // 注意:不关闭context,保持浏览器打开以便下次使用 } } /** * 发布到快手 */ async function publishToKuaishou(context: BrowserContext, params: { videoPath: string; coverPath: string; title: string; description: string; tags: string[]; autoPublish?: boolean; }): Promise { let page: Page | null = null; try { Log.info('开始发布到快手'); // 验证文件 if (!fs.existsSync(params.videoPath)) { throw new Error(`视频文件不存在: ${params.videoPath}`); } if (params.coverPath && !fs.existsSync(params.coverPath)) { Log.info('封面文件不存在,将跳过封面上传'); } Log.info('文件验证通过'); Log.info(`视频: ${path.basename(params.videoPath)}`); if (params.coverPath && fs.existsSync(params.coverPath)) { Log.info(`封面: ${path.basename(params.coverPath)}`); } page = await context.newPage(); page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); // 导航到快手视频发布页面 Log.info('【步骤1】导航到快手视频发布页面'); const uploadUrl = 'https://cp.kuaishou.com/article/publish/video?tabType=1'; Log.info(`目标URL: ${uploadUrl}`); // 直接导航到目标页面 Log.info('正在导航到上传页面...'); let currentUrl = ''; try { await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); Log.info('页面导航完成'); // 等待页面加载 await page.waitForTimeout(5000); currentUrl = page.url(); Log.info(`导航后URL: ${currentUrl}`); // 如果不在目标页面,保存截图 if (!currentUrl.includes('cp.kuaishou.com/article/publish/video')) { try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_nav_result.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`导航后页面截图已保存: ${screenshotPath}`); Log.info(`当前URL: ${currentUrl}`); Log.info(`目标URL: ${uploadUrl}`); } catch (screenshotError) { // 忽略截图错误 } } } catch (error: any) { Log.error(`页面导航出错: ${error.message}`); currentUrl = page.url(); Log.info(`当前URL: ${currentUrl}`); // 保存错误截图 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_nav_error.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`导航错误截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } } // 检查是否在目标页面 const isTargetPage = currentUrl.includes('cp.kuaishou.com/article/publish/video') || currentUrl.includes('cp.kuaishou.com/article/publish') || (currentUrl.includes('cp.kuaishou.com') && currentUrl.includes('publish')); const isLoginPage = currentUrl.includes('login') || currentUrl.includes('auth') || currentUrl.includes('passport') || (!currentUrl.includes('cp.kuaishou.com') && currentUrl !== 'about:blank'); // 如果不在目标页面,尝试导航或等待登录 if (!isTargetPage && !isLoginPage) { Log.info('当前不在目标页面,尝试导航到上传页面...'); try { await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(3000); currentUrl = page.url(); Log.info(`导航后URL: ${currentUrl}`); } catch (navError: any) { Log.info(`导航失败: ${navError.message}`); } } // 如果检测到需要登录,等待用户登录 if (isLoginPage || (!currentUrl.includes('cp.kuaishou.com/article/publish') && currentUrl.includes('cp.kuaishou.com'))) { Log.info('可能需要登录或页面未加载完成'); Log.info('请在浏览器窗口中扫码或登录快手账号'); Log.info('脚本将自动检测登录完成状态(最多等待3分钟)...'); const maxWaitMs = 180000; const checkIntervalMs = 2000; const startTime = Date.now(); let logged = false; while (Date.now() - startTime < maxWaitMs) { try { await page.waitForTimeout(checkIntervalMs); currentUrl = page.url(); const fileInputCount = await page.locator('input[type="file"]').count(); const elapsed = Math.round((Date.now() - startTime) / 1000); if (elapsed % 5 === 0) { Log.info(`[${elapsed}s] 检查状态: 文件框=${fileInputCount}, URL=${currentUrl.substring(0, 80)}...`); } // 检查是否已经在目标页面 if (currentUrl.includes('cp.kuaishou.com/article/publish/video')) { logged = true; Log.info('检测到已进入目标上传页面,登录成功!'); break; } // 检查是否登录但不在目标页面 if (!currentUrl.includes('login') && !currentUrl.includes('auth') && currentUrl.includes('cp.kuaishou.com')) { logged = true; Log.info('检测到已登录,但不在目标页面,将导航到上传页面'); break; } } catch (e) { Log.debug(`检查过程中出错: ${(e as Error).message}`); } } if (!logged) { Log.info('等待登录超时(180秒),尝试继续...'); } else { // 登录成功后,明确导航到目标上传页面 Log.info('登录成功,立即导航到目标上传页面...'); Log.info(`目标URL: ${uploadUrl}`); // 等待一下确保登录状态已保存 await page.waitForTimeout(2000); // 明确导航到目标URL try { Log.info('开始导航到目标上传页面...'); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(5000); // 等待页面完全加载 currentUrl = page.url(); Log.info(`登录后导航URL: ${currentUrl}`); // 保存导航后的截图 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_after_login_nav.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`登录后导航截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } // 检查是否成功导航 if (currentUrl.includes('cp.kuaishou.com/article/publish/video')) { Log.info('✓ 成功导航到目标上传页面'); } else if (currentUrl.includes('cp.kuaishou.com/article/publish')) { Log.info('导航到了发布页面,但URL不完全匹配,继续执行...'); } else { Log.info('警告:登录后导航URL不匹配'); Log.info(`当前URL: ${currentUrl}`); Log.info(`目标URL: ${uploadUrl}`); Log.info('可能需要手动导航到目标页面'); } } catch (navError: any) { Log.error(`登录后导航失败: ${navError.message}`); currentUrl = page.url(); Log.info(`当前URL: ${currentUrl}`); // 保存错误截图 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_login_nav_error.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`登录后导航错误截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } } } } // 最终确保在正确的上传页面 currentUrl = page.url(); Log.info(`准备开始上传,当前URL: ${currentUrl}`); // 如果不在目标页面,保存页面截图并记录详细信息 if (!currentUrl.includes('cp.kuaishou.com/article/publish/video')) { Log.info('当前不在目标上传页面'); // 保存页面截图以便调试 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_before_upload.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`页面截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } // 尝试最后一次导航 Log.info('尝试最后一次导航到目标上传页面...'); try { Log.info(`导航URL: ${uploadUrl}`); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(5000); // 等待页面完全加载 currentUrl = page.url(); Log.info(`最后导航后URL: ${currentUrl}`); if (!currentUrl.includes('cp.kuaishou.com/article/publish/video')) { Log.info('警告:多次尝试后仍无法导航到目标页面'); Log.info(`目标URL应该是: ${uploadUrl}`); Log.info(`实际URL是: ${currentUrl}`); Log.info('将尝试在当前页面查找上传元素...'); } else { Log.info('✓ 最后导航成功,已到达目标上传页面'); } } catch (navError: any) { Log.error(`最后导航失败: ${navError.message}`); Log.info(`当前URL: ${page.url()}`); Log.info('将尝试在当前页面查找上传元素...'); } } else { Log.info('✓ 已在目标上传页面'); } // 等待页面元素加载 Log.info('等待页面元素加载...'); await page.waitForTimeout(3000); // ========== 步骤2: 上传视频 ========== Log.info('【步骤2】上传视频文件'); // 等待页面完全加载并查找文件输入框(最多等待30秒) Log.info('等待页面加载并查找上传视频输入框...'); let videoInput: any = null; let videoInputFound = false; const maxWaitTime = 30000; // 最多等待30秒 const checkInterval = 1000; // 每1秒检查一次 const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime && !videoInputFound) { try { // 先检查当前URL是否还在目标页面 currentUrl = page.url(); if (!currentUrl.includes('cp.kuaishou.com')) { Log.info('页面已跳转,尝试重新导航...'); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(3000); } // 查找视频文件输入框 const videoInputSelectors = [ 'input[type="file"][accept*="video"]', 'input[type="file"]' ]; for (const selector of videoInputSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const candidate = page.locator(selector).first(); const accept = await candidate.getAttribute('accept').catch(() => ''); Log.info(`尝试选择器: "${selector}" - 找到 ${count} 个元素`); if (accept.includes('video') || !accept || count === 1) { videoInput = candidate; videoInputFound = true; Log.info(`✓ 找到视频文件输入框: ${selector} ${accept ? `(accept="${accept}")` : ''}`); break; } } } catch (e: any) { // 继续尝试下一个选择器 } } if (videoInputFound) { break; } const elapsed = Math.round((Date.now() - startTime) / 1000); if (elapsed % 5 === 0 && elapsed > 0) { Log.info(`[${elapsed}s] 继续查找视频文件输入框...`); } await page.waitForTimeout(checkInterval); } catch (e: any) { Log.debug(`查找文件输入框时出错: ${e.message}`); await page.waitForTimeout(checkInterval); } } if (!videoInputFound || !videoInput) { // 保存错误截图 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_error.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`错误截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } throw new Error('未找到视频文件输入框,页面可能未正确加载'); } // 上传视频文件 Log.info(`准备上传文件: ${path.basename(params.videoPath)}`); // 等待一下确保输入框已准备好 await page.waitForTimeout(1000); // 重新查找文件输入框,使用 all() 获取元素列表,避免 locator 超时 Log.info('重新查找文件输入框以确保元素可用...'); let actualFileInput: any = null; try { // 使用 all() 获取所有文件输入框 const allFileInputs = await page.locator('input[type="file"]').all(); Log.info(`找到 ${allFileInputs.length} 个文件输入框`); for (let i = 0; i < allFileInputs.length; i++) { try { const input = allFileInputs[i]; const accept = await input.getAttribute('accept').catch(() => ''); const isVisible = await input.isVisible().catch(() => false); Log.info(`输入框 ${i + 1}: accept="${accept}", visible=${isVisible}`); if (accept.includes('video') || !accept || allFileInputs.length === 1) { actualFileInput = input; Log.info(`选择输入框 ${i + 1} 用于上传视频`); break; } } catch (e: any) { Log.debug(`检查输入框 ${i + 1} 时出错: ${e.message}`); } } if (!actualFileInput) { // 如果没找到,使用第一个 if (allFileInputs.length > 0) { actualFileInput = allFileInputs[0]; Log.info('使用第一个文件输入框'); } } } catch (e: any) { Log.info(`重新查找文件输入框失败: ${e.message},使用之前找到的元素`); actualFileInput = videoInput; } if (!actualFileInput) { throw new Error('无法找到可用的文件输入框'); } // 阻止系统文件选择器弹出 await page.evaluate(() => { const inputs = document.querySelectorAll('input[type="file"]'); inputs.forEach((input: any) => { input.addEventListener('click', (e: Event) => { e.stopPropagation(); e.stopImmediatePropagation(); }, true); input.addEventListener('mousedown', (e: Event) => { e.stopPropagation(); e.stopImmediatePropagation(); }, true); input.addEventListener('mouseup', (e: Event) => { e.stopPropagation(); e.stopImmediatePropagation(); }, true); input.addEventListener('focus', (e: Event) => { e.stopPropagation(); e.stopImmediatePropagation(); }, true); }); }); // 尝试上传文件,设置较长的超时时间 try { Log.info('开始上传视频文件...'); // 直接使用 setInputFiles,不等待 locator await actualFileInput.setInputFiles(params.videoPath, { timeout: 60000 }); Log.info('视频文件已上传'); } catch (uploadError: any) { Log.error('文件上传失败', { message: uploadError.message, stack: uploadError.stack }); // 保存错误截图 try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_upload_error.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`上传错误截图已保存: ${screenshotPath}`); } catch (screenshotError) { // 忽略截图错误 } throw new Error(`文件上传失败: ${uploadError.message}`); } // 等待视频上传和处理 await page.waitForTimeout(3000); Log.info('等待视频上传处理...'); // ========== 步骤3: 填写标题、描述和标签 ========== Log.info('【步骤3】填写标题、描述和标签'); // 步骤3.1: 先查找独立的标题输入框(如果有) Log.info('查找标题输入框...'); const titleSelectors = [ 'input[placeholder*="标题"]', 'input[placeholder*="作品标题"]', 'input[type="text"][placeholder*="标题"]', 'input[class*="title"]', 'textarea[placeholder*="标题"]' ]; let titleInput: any = null; let titleFound = false; for (const selector of titleSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { titleInput = page.locator(selector).first(); const isVisible = await titleInput.isVisible().catch(() => false); if (isVisible) { titleFound = true; Log.info(`✓ 找到标题输入框: ${selector}`); break; } } } catch (e: any) { Log.debug(`标题选择器 "${selector}" 失败: ${e.message}`); } } // 如果找到标题输入框,填写标题 if (titleFound && titleInput) { try { if (params.title) { Log.info(`填写标题: ${params.title}`); await titleInput.click(); await page.waitForTimeout(300); await titleInput.fill(''); await page.waitForTimeout(200); await titleInput.fill(params.title); await page.waitForTimeout(500); Log.info('标题已填写'); } } catch (error: any) { Log.info(`标题填写失败: ${error.message},继续填写描述和标签...`); } } else { Log.info('未找到独立的标题输入框,标题将不单独填写'); } // 步骤3.2: 查找描述输入框(contenteditable div),只填写描述和标签,不填写标题 Log.info('查找描述输入框...'); const descSelectors = [ '#work-description-edit', 'div#work-description-edit', 'div._description_eho7l_59', 'div[contenteditable="true"][placeholder*="添加合适的话题"]', 'div[contenteditable="true"][id*="description"]', 'div[contenteditable="true"][id*="work-description"]', 'div[contenteditable="true"]' ]; let descInput: any = null; let descFound = false; for (const selector of descSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { descInput = page.locator(selector).first(); const isVisible = await descInput.isVisible().catch(() => false); const placeholder = await descInput.getAttribute('placeholder').catch(() => ''); Log.info(`尝试选择器: "${selector}" - 找到 ${count} 个元素`); if (isVisible && (placeholder.includes('添加合适的话题') || selector.includes('work-description'))) { descFound = true; Log.info(`✓ 找到描述输入框: ${selector} ${placeholder ? `(placeholder="${placeholder}")` : ''}`); break; } } } catch (e: any) { Log.debug(`选择器 "${selector}" 失败: ${e.message}`); } } if (!descFound || !descInput) { Log.info('未找到指定的描述输入框,尝试查找其他contenteditable div...'); // 尝试查找所有contenteditable div const allContentEditable = await page.locator('div[contenteditable="true"]').all(); for (let i = 0; i < allContentEditable.length; i++) { try { const div = allContentEditable[i]; const placeholder = await div.getAttribute('placeholder').catch(() => ''); const id = await div.getAttribute('id').catch(() => ''); const isVisible = await div.isVisible().catch(() => false); if (isVisible && (placeholder.includes('添加合适的话题') || id.includes('description') || id.includes('work-description'))) { descInput = div; descFound = true; Log.info(`找到描述输入框(通过遍历): id="${id}", placeholder="${placeholder}"`); break; } } catch (e) { // 继续 } } } if (descFound && descInput) { try { await descInput.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); // 点击元素以激活它 await descInput.click(); await page.waitForTimeout(300); // 快手的标题、描述、标签都在同一个输入框 // 先输入标题,然后输入标签,不需要描述内容 Log.info('快手输入框:先填写标题,然后填写话题标签(不填写描述内容)'); // 步骤1: 先输入标题 Log.info(`输入标题: ${params.title}`); await page.keyboard.type(params.title, { delay: 50 }); await page.waitForTimeout(300); // 步骤2: 输入换行符,分隔标题和标签 await page.keyboard.press('Enter'); await page.waitForTimeout(200); // 步骤3: 逐个输入话题标签,每个话题后自动空格 if (params.tags && params.tags.length > 0) { Log.info('开始逐个输入话题标签...'); for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i]; Log.info(`输入话题 ${i + 1}/${params.tags.length}: ${tag}`); // 输入#号 await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(200); // 输入话题文字 await page.keyboard.type(tag, { delay: 100 }); await page.waitForTimeout(300); // 输入空格(自动空格,每个话题后自动空格) await page.keyboard.press('Space'); await page.waitForTimeout(200); Log.info(`话题已输入: #${tag} `); } Log.info(`所有话题已输入完成: ${params.tags.map(tag => `#${tag}`).join(' ')}`); } await page.waitForTimeout(500); // 验证是否填写成功 const filledValue = await descInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); if (filledValue) { Log.info(`描述和标签已填写: "${filledValue.substring(0, 100)}..."`); } else { Log.info('内容已填写(已执行填写操作)'); } } catch (error: any) { Log.error('填写描述失败', error); throw new Error(`填写描述失败: ${error.message}`); } } else { Log.info('未找到描述输入框,跳过填写'); } // ========== 步骤4: 上传封面 ========== if (params.coverPath && fs.existsSync(params.coverPath)) { Log.info('【步骤4】上传封面'); try { // 步骤1: 点击"封面设置" - 使用用户提供的选择器 Log.info('查找并点击"封面设置"...'); const coverSettingsSelectors = [ 'div._cover-full-editor_ps02t_40:has-text("封面设置")', 'div._cover-full-editor_ps02t_40', 'div[class*="_cover-full-editor"]:has-text("封面设置")', 'div[class*="_cover-full-editor"]', 'text=/封面设置/' ]; let coverSettingsFound = false; for (const selector of coverSettingsSelectors) { try { const count = await page.locator(selector).count(); Log.info(`尝试选择器 "${selector}": 找到 ${count} 个元素`); if (count > 0) { // 尝试所有匹配的元素 const allMatches = await page.locator(selector).all(); for (const element of allMatches) { try { const isVisible = await element.isVisible().catch(() => false); const text = await element.textContent().catch(() => ''); const className = await element.getAttribute('class').catch(() => ''); Log.info(`检查元素: visible=${isVisible}, text="${text}", class="${className}"`); if (isVisible && (text && text.includes('封面设置') || className.includes('_cover-full-editor'))) { Log.info(`找到"封面设置"按钮: ${selector}, class="${className}"`); await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); await element.click(); await page.waitForTimeout(1500); // 等待面板打开 coverSettingsFound = true; Log.info('已点击"封面设置"按钮'); break; } } catch (e: any) { Log.debug(`点击元素时出错: ${e.message}`); } } if (coverSettingsFound) break; } } catch (e: any) { Log.debug(`选择器 "${selector}" 失败: ${e.message}`); } } if (!coverSettingsFound) { Log.info('未找到"封面设置"按钮,尝试直接查找上传封面按钮...'); } else { // 等待封面设置面板完全打开 await page.waitForTimeout(1000); Log.info('封面设置面板应已打开'); } // 步骤2: 点击"上传封面" - 使用用户提供的选择器 Log.info('查找并点击"上传封面"...'); const uploadCoverSelectors = [ 'div._header-title-item_2t3fe_27:has-text("上传封面")', 'div._header-title-item_2t3fe_27', 'div[class*="_header-title-item"]:has-text("上传封面")', 'div[class*="_header-title-item"]', 'text=/上传封面/' ]; let uploadCoverFound = false; for (const selector of uploadCoverSelectors) { try { const count = await page.locator(selector).count(); Log.info(`尝试选择器 "${selector}": 找到 ${count} 个元素`); if (count > 0) { // 尝试所有匹配的元素 const allMatches = await page.locator(selector).all(); for (const element of allMatches) { try { const isVisible = await element.isVisible().catch(() => false); const text = await element.textContent().catch(() => ''); const className = await element.getAttribute('class').catch(() => ''); Log.info(`检查元素: visible=${isVisible}, text="${text}", class="${className}"`); if (isVisible && (text && text.includes('上传封面') || className.includes('_header-title-item'))) { Log.info(`找到"上传封面"按钮: ${selector}, class="${className}"`); await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); await element.click(); await page.waitForTimeout(1500); // 等待对话框打开 uploadCoverFound = true; Log.info('已点击"上传封面"按钮'); break; } } catch (e: any) { Log.debug(`点击元素时出错: ${e.message}`); } } if (uploadCoverFound) break; } } catch (e: any) { Log.debug(`选择器 "${selector}" 失败: ${e.message}`); } } if (!uploadCoverFound) { Log.info('未找到"上传封面"按钮,尝试直接查找"上传图片"按钮...'); } else { // 等待上传封面对话框完全打开 await page.waitForTimeout(1000); Log.info('上传封面对话框应已打开'); } // 步骤3: 查找"上传图片"对应的文件输入框(不点击按钮,避免弹出系统选择框) Log.info('查找"上传图片"对应的文件输入框...'); // 等待页面加载完成 await page.waitForTimeout(1000); // 步骤4: 查找并上传封面文件 Log.info('查找封面文件输入框...'); const coverInputSelectors = [ 'input[type="file"][accept*="image"]:not([accept*="video"])', 'input[type="file"][accept*="image"]', 'input[type="file"]' ]; let coverInput: any = null; let coverInputFound = false; // 先查找所有文件输入框 const allFileInputs = await page.locator('input[type="file"]').all(); Log.info(`页面中共有 ${allFileInputs.length} 个文件输入框`); // 收集所有符合条件的输入框,选择最后一个(最新打开的对话框) const validInputs: any[] = []; for (let i = 0; i < allFileInputs.length; i++) { try { const input = allFileInputs[i]; const accept = await input.getAttribute('accept').catch(() => ''); const isVisible = await input.isVisible().catch(() => false); Log.info(`文件输入框 ${i + 1}: accept="${accept}", visible=${isVisible}`); if (accept.includes('image') && !accept.includes('video')) { validInputs.push({ input, accept, index: i + 1 }); Log.info(`✓ 输入框 ${i + 1} 符合条件(接受图片)`); } } catch (e) { // 继续 } } // 选择最后一个符合条件的输入框(最新打开的对话框) if (validInputs.length > 0) { const lastValid = validInputs[validInputs.length - 1]; coverInput = lastValid.input; coverInputFound = true; Log.info(`选择输入框 ${lastValid.index}/${allFileInputs.length}(最后一个符合条件的): accept="${lastValid.accept}"`); } if (coverInputFound && coverInput) { Log.info(`上传封面文件: ${path.basename(params.coverPath)}`); // 阻止系统文件选择器弹出 await page.evaluate(() => { const inputs = document.querySelectorAll('input[type="file"]'); inputs.forEach((input: any) => { input.addEventListener('click', (e: Event) => { e.stopPropagation(); e.stopImmediatePropagation(); }, true); }); }); await coverInput.setInputFiles(params.coverPath); Log.info('封面文件已上传'); // 等待抖音加载并显示图片预览 // 抖音需要时间来处理上传的图片并显示可拖动的预览界面 await page.waitForTimeout(3000); // 额外等待:确保图片预览界面完全加载 // 检查是否有预览图片或处理中的指示器 try { // 等待预览图片出现或加载完成 await Promise.race([ page.waitForSelector('img[class*="preview"], img[class*="crop"], canvas', { timeout: 3000 }).catch(() => null), new Promise(r => setTimeout(r, 3000)) ]); Log.info('图片预览已加载'); } catch (e) { Log.info('等待预览图片超时,继续执行'); } // 再等待一下确保UI完全渲染 await page.waitForTimeout(1000); // 步骤4: 点击确定按钮确认封面上传 Log.info('查找并点击确定按钮...'); const confirmSelectors = [ 'button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("保存")', 'button:has-text("完成")', 'text=/确定/', 'text=/确认/', 'text=/保存/', 'text=/完成/' ]; let confirmFound = false; for (const selector of confirmSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const allMatches = await page.locator(selector).all(); for (const element of allMatches) { try { const isVisible = await element.isVisible().catch(() => false); const text = await element.textContent().catch(() => ''); if (isVisible && text && (text.includes('确定') || text.includes('确认') || text.includes('保存') || text.includes('完成'))) { Log.info(`找到确定按钮: "${text}"`); await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); await element.click(); await page.waitForTimeout(1000); confirmFound = true; Log.info('已点击确定按钮'); break; } } catch (e: any) { Log.debug(`点击确定按钮时出错: ${e.message}`); } } if (confirmFound) break; } } catch (e: any) { Log.debug(`选择器 "${selector}" 失败: ${e.message}`); } } if (!confirmFound) { Log.info('未找到确定按钮,封面上传可能已完成或需要手动确认'); } else { // 等待确定按钮处理完成 await page.waitForTimeout(1000); } } else { Log.info('未找到封面文件输入框,跳过封面上传'); } } catch (error: any) { Log.error('上传封面失败', error); Log.info('继续执行,封面上传失败不影响发布'); } } else { Log.info('未提供封面文件,跳过封面上传'); } // ========== 步骤5: 点击发布按钮 ========== Log.info('【步骤5】点击发布按钮'); // 等待一下确保所有内容都已保存 await page.waitForTimeout(2000); // 查找发布按钮(快手的发布按钮可能是div元素) const publishButtonSelectors = [ 'div:text-is("发布")', 'div:has-text("发布"):not(:has-text("发布作品"))', 'button:text-is("发布")', 'button:has-text("发布")', 'button:has-text("立即发布")', 'button:has-text("确认发布")', 'button:has-text("上传并发布")', 'div[class*="publish"]:has-text("发布")', 'span:text-is("发布")', 'button[type="submit"]:has-text("发布")', 'button[class*="publish"]', 'button[class*="submit"]' ]; let publishButtonFound = false; let publishClicked = false; for (const selector of publishButtonSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const allMatches = await page.locator(selector).all(); for (const element of allMatches) { try { const isVisible = await element.isVisible().catch(() => false); const text = await element.textContent().catch(() => ''); const disabled = await element.getAttribute('disabled').catch(() => null); Log.info(`检查发布按钮: selector=${selector}, visible=${isVisible}, text="${text}", disabled=${disabled}`); // 精确匹配:文字必须是"发布"或包含"发布"但不是"发布作品"等 const trimmedText = text?.trim() || ''; const isPublishButton = isVisible && !disabled && (trimmedText === '发布' || trimmedText === '立即发布' || trimmedText === '确认发布'); if (isPublishButton) { Log.info(`找到发布按钮: "${trimmedText}"`); await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); // 尝试点击发布按钮 try { await element.click(); publishButtonFound = true; publishClicked = true; Log.info('已点击发布按钮'); await page.waitForTimeout(2000); break; } catch (clickError: any) { Log.info(`点击失败: ${clickError.message},尝试使用JavaScript点击...`); // 使用JavaScript点击 await element.evaluate((el: any) => { el.click(); }); publishButtonFound = true; publishClicked = true; Log.info('已通过JavaScript点击发布按钮'); await page.waitForTimeout(2000); break; } } } catch (e: any) { Log.debug(`检查发布按钮时出错: ${e.message}`); } } if (publishClicked) break; } } catch (e: any) { Log.debug(`选择器 "${selector}" 失败: ${e.message}`); } } if (!publishClicked) { Log.info('未找到或无法点击发布按钮,请手动点击发布'); Log.info('浏览器将保持打开以便您检查发布状态'); } else { Log.info('发布按钮已点击,等待发布结果...'); await page.waitForTimeout(3000); // 检查是否有确认对话框 const confirmDialogSelectors = [ 'button:has-text("确认发布")', 'button:has-text("确定")', 'button:has-text("确认")', 'text=/确认发布/' ]; for (const selector of confirmDialogSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const btn = page.locator(selector).first(); const isVisible = await btn.isVisible().catch(() => false); if (isVisible) { Log.info('检测到确认发布对话框,点击确认...'); await btn.click(); await page.waitForTimeout(2000); break; } } } catch (e) { // 继续 } } } // 返回成功 if (publishClicked) { return await waitForPublishConfirmation(page, 'kuaishou'); } return { success: false, status: 'manual_pending', videoUrl: page.url(), videoId: '', message: '视频已上传,但未能自动确认发布,请在浏览器中手动完成发布', keepPageOpen: true }; } catch (error: any) { Log.error('发布到快手失败', { message: error.message, stack: error.stack }); // 保存错误截图 try { if (page && !page.isClosed()) { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'kuaishou_error.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); Log.info(`错误截图已保存: ${screenshotPath}`); } } catch (screenshotError) { // 忽略截图错误 } throw error; } finally { // 不关闭页面,让用户手动操作 // await page?.close(); } } /** * 安全的页面等待函数(检查页面是否关闭) */ async function safePageWait(page: Page, ms: number): Promise { try { if (page && !page.isClosed()) { await page.waitForTimeout(ms); } else { await new Promise(resolve => setTimeout(resolve, ms)); } } catch (e) { await new Promise(resolve => setTimeout(resolve, ms)); } } /** * 发布到视频号(使用改进版脚本逻辑) */ async function publishToShipin(context: BrowserContext, params: { videoPath: string; coverPath: string; title: string; description: string; tags: string[]; autoPublish?: boolean; }): Promise { let page: Page | null = null; let shouldClosePage = false; let publishTriggered = false; try { Log.info('开始发布到视频号'); // 检查上下文中的Cookie const cookies = await context.cookies().catch(() => []); Log.info('上下文中的Cookie信息', { cookie总数: cookies.length, weixin相关cookie: cookies.filter(c => c.domain && c.domain.includes('weixin')).length, channels相关cookie: cookies.filter(c => c.domain && c.domain.includes('channels')).length }); page = await context.newPage(); page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); // 导航到视频号上传页面 Log.info('导航到视频号上传页面'); const uploadUrl = 'https://channels.weixin.qq.com/platform/post/create'; Log.info(`URL: ${uploadUrl}`); try { Log.info('开始导航到页面...'); const response = await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); Log.info('页面导航成功', { 响应状态: response?.status(), 响应URL: response?.url(), 是否成功: response?.ok() }); if (response && !response.ok()) { Log.warn(`页面返回非成功状态码: ${response.status()}`); } } catch (navError: any) { Log.error('页面导航失败', { 错误信息: navError.message, 错误类型: navError.name, URL: uploadUrl, 当前页面URL: page ? page.url() : '未知' }); // 尝试获取更多诊断信息 if (page) { try { const pageContent = await page.content().catch(() => '无法获取页面内容'); const contentLength = typeof pageContent === 'string' ? pageContent.length : 0; Log.error('页面内容诊断', { 页面URL: page.url(), 内容长度: contentLength, 内容示例: contentLength > 0 ? pageContent.substring(0, 200) : '空内容' }); } catch (e) { Log.error('无法获取页面诊断信息:', e); } } throw navError; } // 🔧 关键修复:导航后立即等待5秒,让页面完全加载和重定向 // 某些情况下,页面导航到上传URL时,会先显示中间状态,然后重定向到真正的页面 Log.info('页面导航完成,等待5秒让页面完全加载和重定向...'); await page.waitForTimeout(5000); const currentUrl = page.url(); Log.info(`当前URL: ${currentUrl}`); // 🔧 改进的登录检测逻辑 // 检查URL是否表示登录页 const urlIndicatesLogin = currentUrl.includes('login') || currentUrl.includes('auth') || currentUrl.includes('weixin.qq.com/qr') || currentUrl.includes('mp.weixin.qq.com'); // 检查页面中是否有文件输入框(这是真正的登录指标) const fileInputCount = await page.locator('input[type="file"]').count().catch(() => 0); const hasFileInputs = fileInputCount > 0; Log.info('登录状态检查', { 当前URL: currentUrl, URL表示登录: urlIndicatesLogin, 页面有文件框: hasFileInputs, 文件框数量: fileInputCount }); // 🔧 关键修复:只有当URL表示登录 AND 页面中没有文件输入框时,才判断为需要登录 // 这防止了误判:如果页面中已有文件输入框,说明已经登录,不需要等待 const isLoginPage = urlIndicatesLogin && !hasFileInputs; Log.info('登录页判断结果', { URL表示登录: urlIndicatesLogin, 页面有文件框: hasFileInputs, 判断为需要登录: isLoginPage }); if (isLoginPage) { Log.info('可能需要登录'); Log.info('请在浏览器窗口中扫码登录微信账号'); Log.info('脚本将自动检测登录完成状态(最多等待3分钟)...'); const maxWaitMs = 180000; const checkIntervalMs = 2000; const startTime = Date.now(); let logged = false; while (Date.now() - startTime < maxWaitMs) { try { const fileInputCount = await page.locator('input[type="file"]').count(); const currentUrlNow = page.url(); const elapsed = Math.round((Date.now() - startTime) / 1000); if (elapsed % 5 === 0) { Log.info(`[${elapsed}s] 检查状态: 文件框=${fileInputCount}, URL=${currentUrlNow.substring(currentUrlNow.lastIndexOf('/'))}`); } if (fileInputCount > 0 && !currentUrlNow.includes('login') && !currentUrlNow.includes('auth')) { logged = true; Log.info('检测到文件输入框,登录成功!'); break; } if (currentUrlNow.includes('platform/post') || currentUrlNow.includes('channels') || currentUrlNow.includes('create')) { const fileInputCountAfterCheck = await page.locator('input[type="file"]').count(); if (fileInputCountAfterCheck > 0) { logged = true; Log.info('检测到已进入上传页面,登录成功!'); break; } } } catch (e) { Log.debug(`检查过程中出错: ${(e as Error).message}`); } await page.waitForTimeout(checkIntervalMs); } if (!logged) { Log.info('等待超时(180秒),尝试继续...'); } else { await page.goto(uploadUrl, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(3000); } } else { // 🔧 关键修复:如果页面中已有文件输入框,说明已登录,直接跳过登录等待 if (hasFileInputs) { Log.info('✅ 检测到用户已登录(页面中有文件输入框),跳过登录等待步骤'); } else { Log.info('⚠️ 既不是登录页也没有文件输入框,继续尝试...'); } } // ========== 步骤1: 上传视频 ========== Log.info('【步骤1】上传视频文件'); // 等待页面完全加载(增加等待时间,确保文件输入框已渲染) Log.info('等待页面加载完成...'); await safePageWait(page, 5000); // 增加到5秒 // 等待文件输入框出现 try { await page.locator('input[type="file"]').first().waitFor({ timeout: 10000, state: 'attached' }); Log.info('文件输入框已出现'); } catch (e) { Log.info('等待文件输入框超时,尝试继续...'); } // 检查视频是否已上传(方法1: 检查文件输入框) let videoAlreadyUploaded = false; const allFileInputs = await page.locator('input[type="file"]').all(); for (let i = 0; i < allFileInputs.length; i++) { try { const input = allFileInputs[i]; const accept = await input.getAttribute('accept').catch(() => ''); const hasFiles = await input.evaluate((el: any) => el.files && el.files.length > 0).catch(() => false); if (hasFiles && (accept.includes('video') || accept.includes('mp4') || !accept)) { videoAlreadyUploaded = true; Log.info('检测到视频已上传(通过文件输入框),跳过视频上传步骤'); break; } } catch (e) { // 忽略错误 } } // 方法2: 检查页面是否有处理中/上传成功的提示 if (!videoAlreadyUploaded) { try { const processingSelectors = [ 'text="处理中"', 'text="上传中"', 'text="转码中"', '*:has-text("处理中")', '*:has-text("上传中")' ]; for (const selector of processingSelectors) { const count = await page.locator(selector).count(); if (count > 0) { Log.info('检测到视频正在处理中...'); break; } } const successSelectors = [ 'text="上传成功"', 'text="处理完成"', '*:has-text("上传成功")', '*:has-text("处理完成")' ]; for (const selector of successSelectors) { const count = await page.locator(selector).count(); if (count > 0) { videoAlreadyUploaded = true; Log.info('检测到视频已上传或正在处理(通过页面状态),跳过视频上传步骤'); break; } } } catch (e) { Log.debug(`检查页面状态失败: ${(e as Error).message}`); } } // 方法3: 检查是否有视频预览元素 if (!videoAlreadyUploaded) { try { const videoInfo = await page.evaluate(() => { const videos = Array.from(document.querySelectorAll('video')); for (const video of videos) { if (video.src || video.poster || video.currentSrc) { return { hasFile: true, reason: '视频元素有源文件' }; } } const previewImages = document.querySelectorAll('img[src*="video"], img[class*="preview"], img[class*="thumbnail"]'); if (previewImages.length > 0) { for (const img of previewImages as any) { if (img.complete && img.naturalWidth > 0) { return { hasFile: true, reason: '有视频预览图片' }; } } } return { hasFile: false, reason: '未找到实际上传的视频' }; }); if (videoInfo.hasFile) { videoAlreadyUploaded = true; Log.info(`检测到视频已上传(${videoInfo.reason}),跳过视频上传步骤`); } } catch (e) { Log.debug(`检查视频元素失败: ${(e as Error).message}`); } } if (!videoAlreadyUploaded) { Log.info('使用JavaScript直接上传视频(不触发系统文件选择器)...'); // 在页面级别阻止所有文件输入框触发系统选择器 await page.evaluate(() => { // 阻止所有文件输入框的点击事件,防止系统选择器弹出 const preventFileDialog = (e: any) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }; // 为所有文件输入框添加事件阻止 document.querySelectorAll('input[type="file"]').forEach((input: any) => { input.onclick = preventFileDialog; input.onmousedown = preventFileDialog; input.onmouseup = preventFileDialog; input.onfocus = preventFileDialog; input.addEventListener('click', preventFileDialog, { capture: true, passive: false }); input.addEventListener('mousedown', preventFileDialog, { capture: true, passive: false }); input.addEventListener('mouseup', preventFileDialog, { capture: true, passive: false }); input.addEventListener('focus', preventFileDialog, { capture: true, passive: false }); }); }); Log.info('已在页面级别阻止所有文件输入框触发系统选择器'); // 直接查找文件输入框(不点击任何按钮) let fileInput: any = null; let uploadSuccess = false; const fileInputSelectors = [ 'input[type="file"][accept*="video"]', 'input[type="file"]' ]; for (const selector of fileInputSelectors) { const count = await page.locator(selector).count(); if (count > 0) { fileInput = page.locator(selector).first(); const accept = await fileInput.getAttribute('accept').catch(() => ''); if (accept.includes('video') || !accept) { Log.info(`找到视频文件输入框: ${selector}`); break; } } } if (fileInput) { try { const hasFiles = await fileInput.evaluate((el: any) => el.files && el.files.length > 0).catch(() => false); if (!hasFiles) { Log.info(`使用setInputFiles直接上传文件: ${path.basename(params.videoPath)}`); // 直接使用setInputFiles设置文件(不会触发系统选择器) await fileInput.setInputFiles(params.videoPath, { timeout: 60000 }); Log.info('视频文件已通过setInputFiles直接上传(未触发系统文件选择器)'); // 触发change事件,确保上传被处理 await fileInput.evaluate((el: any) => { const changeEvent = new Event('change', { bubbles: true, cancelable: true }); el.dispatchEvent(changeEvent); }); uploadSuccess = true; await safePageWait(page, 3000); } else { Log.info('文件输入框已有文件,跳过上传'); uploadSuccess = true; } } catch (error: any) { Log.error(`文件上传失败: ${error.message}`); throw error; } } else { Log.error('未找到视频上传输入框'); throw new Error('未找到视频上传输入框'); } if (uploadSuccess) { await safePageWait(page, 3000); videoAlreadyUploaded = true; Log.info('视频上传完成,继续执行'); } } // ========== 步骤2: 等待视频处理 ========== Log.info('【步骤2】等待视频处理完成'); // 等待封面编辑按钮出现(最多5分钟) let coverEditFound = false; const maxWaitTime = 300000; const checkInterval = 1000; const startTime = Date.now(); Log.info('正在查找封面编辑按钮...'); while (Date.now() - startTime < maxWaitTime) { try { const coverEditSelectors = [ '*:has-text("个人主页和分享卡片") *:has-text("编辑")', 'text="编辑"', 'button:has-text("编辑")' ]; for (const selector of coverEditSelectors) { const count = await page.locator(selector).count(); if (count > 0) { const element = page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { coverEditFound = true; Log.info('找到封面编辑按钮'); break; } } } if (coverEditFound) break; const elapsed = Math.round((Date.now() - startTime) / 1000); if (elapsed % 10 === 0 && elapsed > 0) { Log.info(`[${elapsed}s] 继续查找封面编辑按钮...`); } await page.waitForTimeout(checkInterval); } catch (e) { await page.waitForTimeout(checkInterval); } } // ========== 步骤3: 上传封面 ========== Log.info('【步骤3】上传封面'); try { // 查找封面编辑按钮 const coverSelectors = [ '*:has-text("个人主页和分享卡片") *:has-text("编辑")', 'text="编辑"', 'button:has-text("编辑")' ]; let coverBtn: any = null; for (const selector of coverSelectors) { const count = await page.locator(selector).count(); if (count > 0) { coverBtn = page.locator(selector).first(); const isVisible = await coverBtn.isVisible().catch(() => false); if (isVisible) { break; } } } if (coverBtn) { await coverBtn.click(); Log.info('已点击封面编辑按钮'); await page.waitForTimeout(2000); // 查找封面图片并点击 const coverImageSelectors = [ 'img.cover-img-vertical', 'img[class*="cover-img-vertical"]', '[class*="cover-img"]' ]; let coverImageClicked = false; for (const selector of coverImageSelectors) { const count = await page.locator(selector).count(); if (count > 0) { const img = page.locator(selector).first(); const isVisible = await img.isVisible().catch(() => false); if (isVisible) { await img.click(); Log.info('已点击封面图片'); coverImageClicked = true; await page.waitForTimeout(1500); break; } } } if (coverImageClicked) { // 点击封面图片后,直接查找文件输入框(使用JavaScript直接上传,不触发系统文件选择器) Log.info('点击封面图片后,直接查找文件输入框(使用JavaScript直接上传,不触发系统文件选择器)...'); await page.waitForTimeout(1000); // 等待上传界面出现 // 在页面级别阻止所有文件输入框触发系统选择器 await page.evaluate(() => { // 阻止所有文件输入框的点击事件,防止系统选择器弹出 const preventFileDialog = (e: any) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; }; // 为所有文件输入框添加事件阻止 document.querySelectorAll('input[type="file"]').forEach((input: any) => { input.onclick = preventFileDialog; input.onmousedown = preventFileDialog; input.onmouseup = preventFileDialog; input.onfocus = preventFileDialog; input.addEventListener('click', preventFileDialog, { capture: true, passive: false }); input.addEventListener('mousedown', preventFileDialog, { capture: true, passive: false }); input.addEventListener('mouseup', preventFileDialog, { capture: true, passive: false }); input.addEventListener('focus', preventFileDialog, { capture: true, passive: false }); }); }); Log.info('已在页面级别阻止所有文件输入框触发系统选择器'); // 查找所有文件输入框 const allFileInputs = await page.locator('input[type="file"]').all(); Log.info(`找到 ${allFileInputs.length} 个文件输入框`); let coverInput: any = null; // 查找图片输入框(排除视频输入框) for (let i = 0; i < allFileInputs.length; i++) { try { const input = allFileInputs[i]; const accept = await input.getAttribute('accept').catch(() => ''); const hasFiles = await input.evaluate((el: any) => el.files && el.files.length > 0).catch(() => false); Log.info(`文件输入框 ${i + 1}: accept="${accept}", hasFiles=${hasFiles}`); // 排除视频输入框 if (hasFiles && (accept.includes('video') || accept.includes('mp4'))) { Log.info(` 跳过视频输入框`); continue; } // 查找图片输入框(accept包含image但不包含video) if (!hasFiles && accept.includes('image') && !accept.includes('video')) { coverInput = input; Log.info(` 找到图片输入框用于封面: accept="${accept}"`); break; } } catch (e: any) { Log.info(` 检查文件输入框 ${i + 1} 失败: ${e.message}`); } } if (!coverInput && allFileInputs.length > 0) { // 如果没找到图片输入框,尝试使用第二个输入框(通常是封面输入框) for (let i = 0; i < allFileInputs.length; i++) { try { const input = allFileInputs[i]; const accept = await input.getAttribute('accept').catch(() => ''); const hasFiles = await input.evaluate((el: any) => el.files && el.files.length > 0).catch(() => false); // 如果输入框没有文件且不是视频输入框,使用它 if (!hasFiles && !accept.includes('video') && !accept.includes('mp4')) { coverInput = input; Log.info(` 使用文件输入框 ${i + 1} 作为封面输入框`); break; } } catch (e: any) { // 继续 } } } if (coverInput) { try { Log.info('找到文件输入框,使用setInputFiles直接上传(不触发系统文件选择器)...'); // 直接使用setInputFiles设置文件(这个方法本身不会触发系统选择器) // 关键:setInputFiles是Playwright提供的方法,直接设置文件值,不会触发系统文件选择器 // 只有用户点击文件输入框时才会触发系统选择器 await coverInput.setInputFiles(params.coverPath, { timeout: 30000 }); Log.info('封面文件已通过setInputFiles直接设置(未触发系统文件选择器)'); // 触发change事件,确保上传被处理 await coverInput.evaluate((el: any) => { // 触发change事件 const changeEvent = new Event('change', { bubbles: true, cancelable: true }); el.dispatchEvent(changeEvent); // 触发input事件(某些框架监听这个) const inputEvent = new Event('input', { bubbles: true, cancelable: true }); el.dispatchEvent(inputEvent); }); Log.info('已触发change和input事件'); // 验证文件是否已设置 const hasFiles = await coverInput.evaluate((el: any) => el.files && el.files.length > 0).catch(() => false); if (hasFiles) { const fileName = await coverInput.evaluate((el: any) => el.files[0]?.name || '').catch(() => ''); Log.info(`验证成功:文件已设置到输入框 (${fileName})`); } else { Log.info('文件已通过setInputFiles设置(files属性可能延迟更新,这是正常的)'); } await page.waitForTimeout(2000); // 查找确认按钮 const confirmSelectors = [ 'button:has-text("确认")', 'button:has-text("确定")', 'button:has-text("保存")', '[role="dialog"] button:has-text("确认")', '.weui-desktop-dialog button:has-text("确认")' ]; for (const selector of confirmSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const btn = page.locator(selector).first(); const isVisible = await btn.isVisible().catch(() => false); if (isVisible) { await btn.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); await btn.click(); Log.info(`已点击确认按钮: ${selector}`); await page.waitForTimeout(2000); break; } } } catch (e: any) { // 继续尝试下一个 } } } catch (uploadError: any) { Log.info(`直接上传失败: ${uploadError.message}`); Log.info('尝试其他方法...'); } } else { Log.info('未找到可用的文件输入框'); } } } } catch (error: any) { Log.info(`封面上传失败: ${error.message}`); } // ========== 步骤4: 填写标题 ========== Log.info('【步骤4】填写标题'); // 等待页面完全加载 await safePageWait(page, 2000); // 根据用户提示:查找包含"添加描述"的文字,这是标题输入框 Log.info('查找标题输入框(包含"添加描述"的文字)...'); const titleSelectors = [ // 文本匹配 'text="添加描述"', '*:has-text("添加描述")', // placeholder匹配(优先input和textarea,不包含contenteditable div) 'input[placeholder*="添加描述"]', 'textarea[placeholder*="添加描述"]', 'input[placeholder*="描述"]', 'textarea[placeholder*="描述"]', 'input[placeholder*="标题"]', 'textarea[placeholder*="标题"]', // class匹配 '[class*="description"] input', '[class*="description"] textarea', '[class*="desc"] input', '[class*="desc"] textarea', '[class*="title"] input', '[class*="title"] textarea', // 通用输入框(排除文件输入框) 'input[type="text"]', 'input[type="text"]:not([type="file"])', 'textarea', // 通过label查找 'label:has-text("描述") + input', 'label:has-text("描述") + textarea', 'label:has-text("标题") + input', 'label:has-text("标题") + textarea' ]; let titleInput: any = null; let titleFound = false; // 方法1: 通过选择器查找 for (const selector of titleSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { // 如果是文本选择器,查找附近的输入框 if (selector.startsWith('text') || selector.includes('has-text')) { try { const textElement = page.locator(selector).first(); const isVisible = await textElement.isVisible().catch(() => false); if (isVisible) { // 尝试在同一容器内查找input或textarea(不包含contenteditable div) const container = textElement.locator('..'); const inputInContainer = container.locator('input, textarea').first(); const inputCount = await inputInContainer.count(); if (inputCount > 0) { const inputVisible = await inputInContainer.isVisible().catch(() => false); const inputType = await inputInContainer.getAttribute('type').catch(() => ''); if (inputVisible && inputType !== 'file') { titleInput = inputInContainer; titleFound = true; Log.info('找到标题输入框(通过"添加描述"文字附近的输入框)'); break; } } } } catch (e) { // 继续 } } else { // 普通选择器 titleInput = page.locator(selector).first(); const isVisible = await titleInput.isVisible().catch(() => false); const inputType = await titleInput.getAttribute('type').catch(() => ''); const tagName = await titleInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); // 只支持textarea和input(排除文件输入框和contenteditable div) if (isVisible && inputType !== 'file' && (tagName === 'textarea' || inputType === 'text' || !inputType)) { titleFound = true; Log.info(`找到标题输入框: ${selector}`); break; } } } } catch (e) { // 继续 } } // 方法2: 如果通过选择器找不到,尝试查找所有输入框和文本域 if (!titleFound) { Log.info('未通过选择器找到输入框,尝试查找所有输入框...'); const allInputs = await page.locator('input, textarea').all(); Log.info(`页面中共有 ${allInputs.length} 个输入框/文本域`); for (let i = 0; i < allInputs.length; i++) { try { const input = allInputs[i]; const placeholder = await input.getAttribute('placeholder').catch(() => ''); const inputType = await input.getAttribute('type').catch(() => ''); const isVisible = await input.isVisible().catch(() => false); const className = await input.getAttribute('class').catch(() => ''); const id = await input.getAttribute('id').catch(() => ''); // 排除文件输入框和隐藏输入框 if (inputType === 'file' || inputType === 'hidden' || inputType === 'submit' || inputType === 'button') { continue; } // 检查是否可见且可能是标题输入框 if (isVisible && ( placeholder.includes('描述') || placeholder.includes('标题') || placeholder.includes('添加') || className.includes('desc') || className.includes('title') || className.includes('description') || id.includes('desc') || id.includes('title') || (inputType === 'text' || !inputType) )) { titleInput = input; titleFound = true; Log.info(`找到标题输入框(通过属性匹配: placeholder="${placeholder}")`); break; } } catch (e) { // 继续 } } } if (titleInput && titleFound) { try { await titleInput.scrollIntoViewIfNeeded(); await safePageWait(page, 500); // 检查输入框是否已有内容 let currentValue = ''; try { const tagName = await titleInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await titleInput.getAttribute('contenteditable').catch(() => ''); const isContentEditable = tagName === 'div' && contentEditable !== ''; if (isContentEditable) { currentValue = await titleInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); } else { currentValue = await titleInput.inputValue().catch(() => ''); } } catch (e) { // 忽略 } // 如果输入框已有内容且包含描述,说明描述已经填写,跳过标题填写 if (currentValue && currentValue.includes(params.description.substring(0, 10))) { Log.info('检测到描述已填写,跳过标题填写(标题和描述使用同一个输入框)'); } else if (currentValue && currentValue.trim() === params.title) { // 如果当前值已经是标题,跳过 Log.info('标题已存在,跳过填写'); } else { // 如果输入框为空或只有部分内容,填写标题 if (currentValue && currentValue.trim()) { Log.info(`输入框已有内容: "${currentValue.substring(0, 30)}...",将追加标题`); await titleInput.fill(params.title + '\n\n' + currentValue); } else { // 清空现有内容(如果有) await titleInput.fill(''); await safePageWait(page, 200); // 填写标题 await titleInput.fill(params.title); } await safePageWait(page, 300); // 验证是否填写成功 let filledValue = ''; try { const tagName = await titleInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await titleInput.getAttribute('contenteditable').catch(() => ''); const isContentEditable = tagName === 'div' && contentEditable !== ''; if (isContentEditable) { filledValue = await titleInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); } else { filledValue = await titleInput.inputValue().catch(() => ''); } } catch (e) { filledValue = ''; } if (filledValue.includes(params.title.substring(0, 10))) { Log.info(`标题已填写: "${params.title}"`); } else { Log.info(`标题填写可能失败,当前值: "${filledValue}"`); } } } catch (error: any) { Log.info(`标题填写失败: ${error.message}`); } } else { Log.info('未找到标题输入框'); } // ========== 步骤5: 准备输入话题标签(不填写描述) ========== Log.info('【步骤5】准备输入话题标签(跳过描述填写)'); // 改进的描述输入框查找逻辑 Log.info('查找描述输入框(在"添加描述"区域)...'); const descSelectors = [ // contenteditable div匹配(优先,因为这是实际的描述输入框) 'div[contenteditable][data-placeholder*="添加描述"]', 'div[contenteditable][data-placeholder*="描述"]', 'div[contenteditable="true"][data-placeholder*="添加描述"]', 'div[contenteditable="true"][data-placeholder*="描述"]', '[class*="input-editor"][contenteditable]', '[class*="input-editor"]', 'div[contenteditable][class*="editor"]', 'div[contenteditable][class*="input"]', // 文本匹配 'text="添加描述"', '*:has-text("添加描述")', // placeholder匹配 'textarea[placeholder*="添加描述"]', 'textarea[placeholder*="描述"]', 'textarea[placeholder*="简介"]', 'textarea[placeholder*="介绍"]', 'input[placeholder*="添加描述"]', 'input[placeholder*="描述"]', // class匹配 '[class*="description"] textarea', '[class*="description"] input', '[class*="desc"] textarea', '[class*="desc"] input', // 通用textarea(排除文件输入框) 'textarea:visible', 'textarea' ]; let descInput: any = null; let descFound = false; // 方法1: 通过选择器查找 for (const selector of descSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { // 如果是文本选择器,查找附近的输入框 if (selector.startsWith('text') || selector.includes('has-text')) { try { const textElement = page.locator(selector).first(); const isVisible = await textElement.isVisible().catch(() => false); if (isVisible) { // 尝试在同一容器内查找contenteditable div、textarea或input const container = textElement.locator('..'); const inputInContainer = container.locator('div[contenteditable], textarea, input[type="text"]').first(); const inputCount = await inputInContainer.count(); if (inputCount > 0) { const inputVisible = await inputInContainer.isVisible().catch(() => false); const inputType = await inputInContainer.getAttribute('type').catch(() => ''); const contentEditable = await inputInContainer.getAttribute('contenteditable').catch(() => ''); if (inputVisible && inputType !== 'file' && (contentEditable !== '' || inputType === 'text' || !inputType)) { descInput = inputInContainer; descFound = true; Log.info('找到描述输入框(通过"添加描述"文字附近的输入框)'); break; } } } } catch (e) { // 继续 } } else { // 普通选择器 descInput = page.locator(selector).first(); const isVisible = await descInput.isVisible().catch(() => false); const inputType = await descInput.getAttribute('type').catch(() => ''); const tagName = await descInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await descInput.getAttribute('contenteditable').catch(() => ''); // 支持contenteditable div、textarea和input(排除文件输入框) if (isVisible && inputType !== 'file' && ( contentEditable !== '' || tagName === 'textarea' || inputType === 'text' || !inputType )) { descFound = true; Log.info(`找到描述输入框: ${selector}`); break; } } } } catch (e) { // 继续 } } // 方法2: 如果通过选择器找不到,尝试查找所有contenteditable div和textarea if (!descFound) { Log.info('未通过选择器找到描述输入框,尝试查找所有contenteditable div和textarea...'); // 先查找contenteditable div const allContentEditableDivs = await page.locator('div[contenteditable]').all(); Log.info(`页面中共有 ${allContentEditableDivs.length} 个contenteditable div`); for (let i = 0; i < allContentEditableDivs.length; i++) { try { const div = allContentEditableDivs[i]; const dataPlaceholder = await div.getAttribute('data-placeholder').catch(() => ''); const placeholder = await div.getAttribute('placeholder').catch(() => ''); const isVisible = await div.isVisible().catch(() => false); const className = await div.getAttribute('class').catch(() => ''); // 检查是否可见且可能是描述输入框 if (isVisible && ( dataPlaceholder.includes('描述') || dataPlaceholder.includes('添加') || placeholder.includes('描述') || placeholder.includes('添加') || className.includes('input-editor') || className.includes('editor') || className.includes('desc') || className.includes('description') )) { descInput = div; descFound = true; Log.info(`找到描述输入框(contenteditable div: data-placeholder="${dataPlaceholder}")`); break; } } catch (e) { // 继续 } } // 如果还没找到,查找所有textarea if (!descFound) { const allTextareas = await page.locator('textarea').all(); Log.info(`页面中共有 ${allTextareas.length} 个textarea`); for (let i = 0; i < allTextareas.length; i++) { try { const textarea = allTextareas[i]; const placeholder = await textarea.getAttribute('placeholder').catch(() => ''); const isVisible = await textarea.isVisible().catch(() => false); const className = await textarea.getAttribute('class').catch(() => ''); // 检查是否可见且可能是描述输入框 if (isVisible && ( placeholder.includes('描述') || placeholder.includes('添加') || className.includes('desc') || className.includes('description') )) { descInput = textarea; descFound = true; Log.info(`找到描述输入框(通过属性匹配: placeholder="${placeholder}")`); break; } } catch (e) { // 继续 } } } } // 方法3: 如果还是找不到,检查标题输入框是否是textarea,如果是,可以使用它(标题和描述可能是同一个输入框) if (!descFound && titleInput) { try { const tagName = await titleInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); if (tagName === 'textarea') { descInput = titleInput; descFound = true; Log.info('标题和描述使用同一个输入框(textarea)'); } } catch (e) { // 忽略 } } if (descInput && descFound) { try { await descInput.scrollIntoViewIfNeeded(); await safePageWait(page, 500); // 检查是否是contenteditable div const tagName = await descInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await descInput.getAttribute('contenteditable').catch(() => ''); const isContentEditable = tagName === 'div' && contentEditable !== ''; if (isContentEditable) { // contenteditable div的处理方式 Log.info('检测到contenteditable div,跳过描述填写,仅激活输入框准备输入话题...'); // 点击元素以激活它 await descInput.click(); await safePageWait(page, 300); // 聚焦元素并将光标移到末尾 await descInput.evaluate((el: any) => { el.focus(); // 将光标移到末尾 const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); }); Log.info('描述输入框已激活,等待输入话题标签'); } else { // 普通input或textarea的处理方式 Log.info('检测到普通input/textarea,跳过描述填写,仅激活输入框准备输入话题...'); // 点击输入框激活 await descInput.click(); await safePageWait(page, 300); // 将光标移到末尾 await descInput.evaluate((el: any) => { el.focus(); if (el.setSelectionRange) { el.setSelectionRange(el.value.length, el.value.length); } }); Log.info('描述输入框已激活,等待输入话题标签'); } } catch (error: any) { Log.info(`描述填写失败: ${error.message}`); } } else { Log.info('未找到描述输入框'); } // ========== 步骤6: 添加标签 ========== if (params.tags && params.tags.length > 0) { Log.info('【步骤6】添加标签'); Log.info('查找标签输入框...'); const tagSelectors = [ // contenteditable div匹配(标签可能也在描述输入框中) 'div[contenteditable][data-placeholder*="标签"]', 'div[contenteditable][data-placeholder*="话题"]', 'div[contenteditable="true"][data-placeholder*="标签"]', 'div[contenteditable="true"][data-placeholder*="话题"]', '[class*="tag"] div[contenteditable]', '[class*="topic"] div[contenteditable]', // input和textarea匹配 'input[placeholder*="标签"]', 'input[placeholder*="话题"]', 'textarea[placeholder*="标签"]', 'textarea[placeholder*="话题"]', '[class*="tag"] input', '[class*="topic"] input', '[class*="tag"] textarea', '[class*="topic"] textarea' ]; let tagInput: any = null; let tagFound = false; // 方法1: 通过选择器查找 for (const selector of tagSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { tagInput = page.locator(selector).first(); const isVisible = await tagInput.isVisible().catch(() => false); const tagName = await tagInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await tagInput.getAttribute('contenteditable').catch(() => ''); if (isVisible) { tagFound = true; Log.info(`找到标签输入框: ${selector}`); break; } } } catch (e) { // 继续 } } // 方法2: 如果找不到标签输入框,尝试在描述输入框中继续输入标签(如果它们是同一个输入框) let useDescInputForTags = false; if (!tagFound && descInput && descFound) { Log.info('未找到独立的标签输入框,将在描述输入框中继续输入标签(#号已输入)...'); tagInput = descInput; tagFound = true; useDescInputForTags = true; } if (tagInput && tagFound) { try { await tagInput.scrollIntoViewIfNeeded(); await safePageWait(page, 300); // 检查是否是contenteditable div const tagName = await tagInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await tagInput.getAttribute('contenteditable').catch(() => ''); const isContentEditable = tagName === 'div' && contentEditable !== ''; if (isContentEditable) { // contenteditable div的处理方式 Log.info('检测到contenteditable div,使用键盘输入方式添加标签...'); // 视频号不需要填写描述内容,只需要标签 Log.info('视频号:跳过描述填写,仅输入话题标签'); // 确保输入框已激活(#号已输入,光标应该在末尾) await tagInput.evaluate((el: any) => { el.focus(); const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); // 移到末尾,不选中内容 selection.removeAllRanges(); selection.addRange(range); }); await safePageWait(page, 200); // 验证内容是否还在(防止点击时内容被清空) const contentAfterFocus = await tagInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); // 如果内容丢失了,先恢复 if (!contentAfterFocus || contentAfterFocus.trim().length === 0 || (params.description && params.description.length > 0 && contentAfterFocus.length < params.description.length / 2)) { Log.info('检测到内容可能丢失,尝试恢复...'); await tagInput.evaluate((el: any, content: string) => { el.focus(); // 恢复内容 el.textContent = content; // 将光标移到末尾 const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); // 触发input事件 const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: content }); el.dispatchEvent(inputEvent); }, params.description || params.description); await safePageWait(page, 500); } // 确保光标在末尾(再次设置,确保位置正确) await tagInput.evaluate((el: any) => { el.focus(); const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); }); // 检查#号是否已存在,如果不存在则添加 const finalContentBeforeTags = await tagInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); // 逐个输入标签,每个标签后按空格让平台识别 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; Log.info(`输入话题 ${i + 1}/${params.tags.length}: #${cleanTag}`); // 输入#号 await page.keyboard.type('#', { delay: 50 }); await safePageWait(page, 100); // 输入标签文字 await page.keyboard.type(cleanTag, { delay: 50 }); await safePageWait(page, 300); // 按空格键触发话题识别 await page.keyboard.press('Space'); await safePageWait(page, 500); // 检查是否有话题选择弹窗,如果有则按回车确认 try { const topicPopup = page.locator('[class*="topic"], [class*="hashtag"], [class*="suggest"]').first(); if (await topicPopup.isVisible({ timeout: 1000 })) { Log.info('检测到话题选择弹窗,选择第一个选项'); await page.keyboard.press('Enter'); await safePageWait(page, 300); } } catch (e) { Log.debug('未检测到话题弹窗,继续'); } } await safePageWait(page, 500); // 验证最终内容 const finalContent = await tagInput.evaluate((el: any) => { return el.innerText || el.textContent || ''; }).catch(() => ''); // 验证标签是否添加成功 const tagsText = params.tags.map(tag => `#${tag}`).join(' '); Log.info(`标签已添加: ${tagsText}`); } else { // 普通input或textarea的处理方式 // 视频号不需要填写描述,直接输入标签 Log.info('视频号:跳过描述填写,直接输入话题标签'); // 使用键盘逐个输入标签 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; Log.info(`输入话题 ${i + 1}/${params.tags.length}: #${cleanTag}`); // 输入#号 await page.keyboard.type('#', { delay: 50 }); await safePageWait(page, 100); // 输入标签文字 await page.keyboard.type(cleanTag, { delay: 50 }); await safePageWait(page, 300); // 按空格键触发话题识别 await page.keyboard.press('Space'); await safePageWait(page, 500); } // 验证最终内容 const finalValue = await tagInput.inputValue().catch(() => ''); // 验证描述内容是否还在(如果使用描述输入框) if (useDescInputForTags) { if (finalValue.includes(params.description.substring(0, 10))) { const tagsText = params.tags.map(tag => `#${tag}`).join(' '); Log.info(`标签已添加: ${tagsText}`); Log.info('描述内容已保留'); } else { Log.info('警告:描述内容可能丢失,请检查'); const tagsText = params.tags.map(tag => `#${tag}`).join(' '); Log.info(`标签已添加: ${tagsText}`); } } else { const tagsText = params.tags.map(tag => `#${tag}`).join(' '); Log.info(`标签已添加: ${tagsText}`); } } } catch (error: any) { Log.info(`标签添加失败: ${error.message}`); } } else { Log.info('未找到标签输入框'); } } Log.info('=== 所有准备工作已完成 ==='); Log.info('视频、封面、标题、描述、标签都已填写完成'); if (params.autoPublish) { Log.info('检测到自动发布标志,准备执行点击发表按钮...'); // 查找发表按钮(视频号使用"发表"而不是"发布") const publishBtnSelectors = [ 'button:has-text("发表")', 'div:text-is("发表")', 'button:has-text("发布")', 'div[class*="publish"] button', 'button[class*="publish"]', 'div:text-is("发布")', 'button:has-text("确定发布")' ]; let publishBtn = null; for (const selector of publishBtnSelectors) { const btn = page.locator(selector).first(); if (await btn.count() > 0 && await btn.isVisible()) { publishBtn = btn; Log.info(`找到发表按钮: ${selector}`); break; } } if (publishBtn) { await page.waitForTimeout(2000); // 稍等片刻 await publishBtn.click(); // publish-triggered publishTriggered = true; Log.info('已自动点击发表按钮'); // 等待发布成功提示 try { await Promise.race([ page.locator('text="发表成功"').waitFor({ timeout: 10000 }), page.locator('text="发布成功"').waitFor({ timeout: 10000 }), page.locator('text="上传成功"').waitFor({ timeout: 10000 }), page.waitForTimeout(5000) ]); Log.info('发表操作已提交'); } catch (e) { Log.info('未检测到明确的发表成功提示,但已执行点击'); } } else { Log.warn('未找到发表按钮,请手动点击'); } } else { Log.info('请在浏览器中检查内容,然后手动点击发表按钮'); Log.info('浏览器窗口将保持打开状态,等待您手动操作...'); // 等待30秒,给用户足够时间检查和手动发布 await page.waitForTimeout(30000); } if (params.autoPublish) { if (publishTriggered) { const publishResult = await waitForPublishConfirmation(page, 'shipin'); if (publishResult.success) { shouldClosePage = true; } return publishResult; } return { success: false, status: 'manual_pending', message: '未找到明确的发表按钮,请在浏览器中手动确认发布', videoUrl: page.url(), videoId: '', keepPageOpen: true }; } const finalUrl = page.url(); Log.info(`当前页面URL: ${finalUrl}`); return { success: false, status: 'manual_pending', message: '准备工作已完成,请在浏览器中手动点击发表按钮', videoUrl: finalUrl, videoId: 'manual_publish', keepPageOpen: true }; } catch (error: any) { Log.error('发布到视频号失败', { message: error.message, stack: error.stack }); if (page) { try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'shipin_error.png'); await page.screenshot({ path: screenshotPath }); Log.info(`错误截图已保存: ${screenshotPath}`); } catch (e) { // 忽略错误 } } throw error; } finally { if (page && shouldClosePage) { try { await page.close(); } catch (error) { // 忽略错误 } } } } /** * 检查和切换到正确的页面(用于处理页面切换) */ async function ensureCorrectPage(context: BrowserContext, currentPage: Page | null, uploadUrl: string): Promise { if (!currentPage || currentPage.isClosed()) { Log.info('当前页面已关闭,查找可用页面...'); const pages = context.pages(); if (pages.length > 0) { for (let i = pages.length - 1; i >= 0; i--) { if (!pages[i].isClosed()) { const url = pages[i].url(); if (url.includes('creator.xiaohongshu.com')) { Log.info(`找到可用页面: ${url}`); return pages[i]; } } } // 如果没有找到合适的页面,使用第一个可用页面 for (let i = pages.length - 1; i >= 0; i--) { if (!pages[i].isClosed()) { Log.info('使用第一个可用页面'); return pages[i]; } } } // 如果所有页面都关闭了,创建新页面 Log.info('所有页面都已关闭,创建新页面'); const newPage = await context.newPage(); await newPage.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => { }); return newPage; } // 检查是否有新页面打开 const pages = context.pages(); if (pages.length > 1) { // 查找包含publish的页面(通常是编辑页面) for (const p of pages) { if (p !== currentPage && !p.isClosed()) { try { const url = p.url(); if (url.includes('creator.xiaohongshu.com') && url.includes('publish')) { Log.info(`检测到编辑页面: ${url},切换到该页面`); await p.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => { }); return p; } } catch (e) { // 继续 } } } } // 检查当前页面URL是否变化 try { const currentUrl = currentPage.url(); if (!currentUrl.includes('creator.xiaohongshu.com')) { Log.info(`当前页面URL不在创作中心: ${currentUrl},查找正确的页面...`); for (const p of pages) { if (p !== currentPage && !p.isClosed()) { try { const url = p.url(); if (url.includes('creator.xiaohongshu.com')) { Log.info(`切换到创作中心页面: ${url}`); await p.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => { }); return p; } } catch (e) { // 继续 } } } } } catch (e) { // URL检查失败,继续使用当前页面 } return currentPage; } /** * 发布到小红书(使用改进版脚本逻辑) */ async function publishToXiaohongshu(context: BrowserContext, params: { videoPath: string; coverPath: string; title: string; description: string; tags: string[]; autoPublish?: boolean; }): Promise { let page: Page | null = null; let shouldClosePage = false; let publishTriggered = false; try { Log.info('开始发布到小红书'); // 验证文件 if (!fs.existsSync(params.videoPath)) { throw new Error(`视频文件不存在: ${params.videoPath}`); } if (!fs.existsSync(params.coverPath)) { throw new Error(`封面文件不存在: ${params.coverPath}`); } Log.info('文件验证通过'); Log.info(`视频: ${path.basename(params.videoPath)}`); Log.info(`封面: ${path.basename(params.coverPath)}`); page = await context.newPage(); page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); // 导航到小红书创作中心 Log.info('【步骤1】导航到小红书创作中心'); const uploadUrl = 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=video'; Log.info(`URL: ${uploadUrl}`); try { await page.goto(uploadUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); Log.info('页面已打开'); } catch (error: any) { Log.info(`页面加载超时: ${error.message}`); Log.info('继续执行...'); } await page.waitForTimeout(3000); let currentUrl = page.url(); Log.info(`当前URL: ${currentUrl}`); // 🔥 登录前置检查(参考 aigc-human xiaoHongShuService.js:190-194) if (currentUrl.includes('login') || currentUrl.includes('passport') || currentUrl.includes('account')) { Log.error('未登录:被重定向到登录页'); throw new Error('未登录小红书,请先在账号管理中扫码登录'); } // 检查登录状态:查找用户头像等特征元素 const loginIndicator = await page.$('.user-info, .avatar, .user-avatar, [data-testid="user-avatar"]'); if (!loginIndicator) { await page.waitForTimeout(3000); const retryIndicator = await page.$('.user-info, .avatar, .user-avatar, [data-testid="user-avatar"]'); if (!retryIndicator) { // 再检查是否有 file input(可能页面结构不同但确实已登录) const fileCheck = await page.locator('input[type="file"]').count(); if (fileCheck === 0) { Log.error('未检测到登录特征且无上传输入框,cookies 可能已过期'); throw new Error('小红书登录已过期,请重新扫码登录'); } } } Log.info('✅ 检测到已登录'); // 确保在发布页面 if (!currentUrl.includes('/publish/publish')) { Log.info('不在发布页面,重新导航...'); await page.goto(uploadUrl, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); } Log.info('页面加载完成'); // ========== 步骤2: 上传视频 ========== Log.info('【步骤2】上传视频文件'); // 🔥 参考 aigc-human:用 waitForSelector 等待上传控件(state: 'attached' 因为 input 通常是 hidden 的) Log.info('等待上传输入框就绪...'); try { await page.waitForSelector('input[type="file"]', { state: 'attached', timeout: 60000 }); Log.info('上传输入框已就绪'); } catch (e) { Log.error('等待上传输入框超时(60秒)'); throw new Error('发布页面加载超时,请检查网络或重新登录'); } // 查找视频上传输入框(参考 aigc-human xiaoHongShuService.js:317-327) let fileInput = await page.$('input.upload-input[type="file"][accept*=".mp4,.mov"]'); if (!fileInput) { fileInput = await page.$('input[type="file"][accept*="video"]'); } if (!fileInput) { fileInput = await page.$('input[type="file"]'); } let uploadSuccess = false; if (fileInput) { try { Log.info(`上传文件: ${path.basename(params.videoPath)}`); await fileInput.setInputFiles(params.videoPath); Log.info('视频文件已上传'); uploadSuccess = true; // 等待页面响应 await page.waitForTimeout(3000); } catch (error: any) { Log.error(`文件上传失败: ${error.message}`); } } else { Log.error('未找到视频上传输入框'); throw new Error('未找到文件上传输入框,请检查页面或重新登录'); } Log.info(''); // ========== 步骤3: 等待视频处理完成并查找封面编辑按钮 ========== Log.info('【步骤3】等待视频处理完成并查找封面编辑按钮'); // 视频上传后页面可能已经跳转,检测并切换到编辑页面 Log.info('检测页面是否跳转...'); // 等待页面响应(视频上传后可能需要一些时间才会跳转) await page.waitForTimeout(3000); // 循环检查所有页面,找到正确的编辑页面(最多等待10秒) let correctPageFound = false; const maxWaitTime = 10000; // 最多等待10秒 const checkInterval = 1000; // 每1秒检查一次 const startTime = Date.now(); while (!correctPageFound && (Date.now() - startTime) < maxWaitTime) { try { // 检查所有页面 const allPages = context.pages(); Log.info(`检查页面状态(${allPages.length} 个页面)...`); // 首先检查当前页面的URL是否已经变化 try { const currentUrl = page.url(); Log.info(`当前页面URL: ${currentUrl}`); // 检查是否是编辑页面(URL包含/publish/后跟数字,而不是只是/publish) if (currentUrl.includes('creator.xiaohongshu.com')) { // 编辑页面的URL通常是 /publish/数字 格式 const isEditPage = (currentUrl.match(/\/publish\/\d+/) || (currentUrl.includes('/publish/') && !currentUrl.endsWith('/publish') && currentUrl !== 'https://creator.xiaohongshu.com/publish/publish')); if (isEditPage) { Log.info('当前页面已经是编辑页面'); correctPageFound = true; break; } } } catch (urlError: any) { Log.info(`检查当前页面URL失败: ${urlError.message}`); } // 检查所有页面,找到编辑页面 for (let i = 0; i < allPages.length; i++) { const p = allPages[i]; if (p.isClosed()) continue; try { const url = p.url(); // 跳过about:blank页面和当前页面 if (url === 'about:blank' || p === page) { continue; } Log.info(`检查页面 ${i + 1}: ${url}`); // 查找编辑页面(URL包含/publish/后跟数字) if (url.includes('creator.xiaohongshu.com')) { const isEditPage = (url.match(/\/publish\/\d+/) || (url.includes('/publish/') && !url.endsWith('/publish') && url !== 'https://creator.xiaohongshu.com/publish/publish')); if (isEditPage) { Log.info('找到编辑页面,切换到该页面'); await p.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { }); await p.waitForTimeout(2000); page = p; correctPageFound = true; break; } } } catch (e: any) { // 继续检查下一个页面 } } if (correctPageFound) { break; } // 等待一段时间后再次检查 if ((Date.now() - startTime) < maxWaitTime) { await page.waitForTimeout(checkInterval); } } catch (checkError: any) { Log.info(`页面检查过程中出错: ${checkError.message}`); await page.waitForTimeout(checkInterval); } } if (!correctPageFound) { Log.info('未找到编辑页面,继续使用当前页面'); } Log.info(`最终操作页面URL: ${page.url()}`); // 先等待视频处理完成(视频上传后需要时间处理) Log.info('等待视频处理完成...'); await page.waitForTimeout(5000); // 等待5秒让视频开始处理 // 等待视频处理完成的标志出现 let videoProcessed = false; const processMaxWait = 120000; // 最多等待2分钟 const processStartTime = Date.now(); while (Date.now() - processStartTime < processMaxWait) { try { // 检查是否有处理完成的标志 const processedSelectors = [ 'text="处理完成"', 'text="上传成功"', '*:has-text("处理完成")', '*:has-text("上传成功")', 'video[src]', // 有视频源说明已处理 '[class*="preview"]', // 预览区域出现 '[class*="cover"]' // 封面区域出现 ]; for (const selector of processedSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { videoProcessed = true; Log.info('检测到视频处理完成'); break; } } catch (selectorError: any) { // 继续检查下一个选择器 } } if (videoProcessed) break; const elapsed = Math.round((Date.now() - processStartTime) / 1000); if (elapsed % 10 === 0 && elapsed > 0) { Log.info(`[${elapsed}s] 等待视频处理...`); } await page.waitForTimeout(2000); } catch (e: any) { Log.info(`检查视频处理状态失败: ${e.message}`); await page.waitForTimeout(2000); } } if (!videoProcessed) { Log.info('未检测到视频处理完成标志,继续执行...'); await page.waitForTimeout(5000); // 额外等待5秒 } Log.info('每1秒查找一次封面编辑按钮,找到后立即执行下一步...'); let coverEditFound = false; const coverMaxWaitTime = 300000; // 最多等待5分钟 const coverCheckInterval = 1000; const coverStartTime = Date.now(); Log.info('正在查找封面编辑按钮...'); while (Date.now() - coverStartTime < coverMaxWaitTime) { try { const coverEditSelectors = [ // 优先查找"设置封面"按钮 'text="设置封面"', 'div:has-text("设置封面")', '*:has-text("设置封面")', '[class*="text"]:has-text("设置封面")', 'div.text:has-text("设置封面")', // 其他封面相关按钮 '*:has-text("封面") *:has-text("编辑")', '*:has-text("更换封面")', 'button:has-text("编辑封面")', 'button:has-text("更换封面")', '*:has-text("编辑封面")', 'text="编辑"', 'button:has-text("编辑")', '[class*="cover"] button:has-text("编辑")', '[class*="cover"] *:has-text("编辑")', '[class*="edit-cover"]', '[class*="cover-edit"]' ]; for (const selector of coverEditSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { const element = page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { coverEditFound = true; Log.info('找到封面编辑按钮,立即执行下一步'); break; } } } catch (selectorError: any) { // 继续尝试下一个选择器 } } if (coverEditFound) { break; } const elapsed = Math.round((Date.now() - coverStartTime) / 1000); if (elapsed % 10 === 0 && elapsed > 0) { Log.info(`[${elapsed}s] 继续查找封面编辑按钮...`); } await page.waitForTimeout(coverCheckInterval); } catch (e: any) { Log.info(`查找封面编辑按钮失败: ${e.message}`); await page.waitForTimeout(coverCheckInterval); } } if (!coverEditFound) { Log.info('查找封面编辑按钮超时(5分钟)'); Log.info('继续执行,如果封面编辑按钮未出现,可能需要更长时间'); } // ========== 步骤4: 上传封面 ========== Log.info('【步骤4】上传封面'); Log.info('查找封面编辑按钮("设置封面")...'); const coverSelectors = [ // 优先查找"设置封面"按钮 'text="设置封面"', 'div:has-text("设置封面")', '*:has-text("设置封面")', '[class*="text"]:has-text("设置封面")', 'div.text:has-text("设置封面")', // 其他封面相关按钮 '*:has-text("封面") *:has-text("编辑")', '*:has-text("更换封面")', 'button:has-text("编辑封面")', 'button:has-text("更换封面")', 'input[type="file"][accept*="image"]' ]; let coverBtn: any = null; let coverBtnFound = false; for (const selector of coverSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { coverBtn = page.locator(selector).first(); const isVisible = await coverBtn.isVisible().catch(() => false); if (isVisible) { coverBtnFound = true; Log.info(`✓ 找到封面编辑按钮: ${selector}`); break; } } } catch (e: any) { Log.info(`选择器 ${selector} 失败: ${e.message}`); } } if (coverBtnFound && coverBtn) { try { await coverBtn.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await coverBtn.click(); Log.info('已点击封面编辑按钮'); await page.waitForTimeout(2000); // 查找文件输入框 const coverInputSelectors = [ 'input[type="file"][accept*="image"]', 'input[type="file"]' ]; let coverInput: any = null; for (const selector of coverInputSelectors) { const count = await page.locator(selector).count(); if (count > 0) { coverInput = page.locator(selector).first(); const accept = await coverInput.getAttribute('accept').catch(() => ''); if (accept.includes('image') || !accept) { Log.info(`✓ 找到封面文件输入框`); break; } } } if (coverInput) { try { Log.info(`上传封面: ${path.basename(params.coverPath)}`); await coverInput.setInputFiles(params.coverPath, { timeout: 30000 }); Log.info('封面文件已上传'); await page.waitForTimeout(2000); // 查找确认按钮 const confirmSelectors = [ 'button:has-text("确认")', 'button:has-text("确定")', 'button:has-text("保存")', 'button:has-text("完成")' ]; for (const selector of confirmSelectors) { const count = await page.locator(selector).count(); if (count > 0) { const btn = page.locator(selector).first(); const isVisible = await btn.isVisible().catch(() => false); if (isVisible) { await btn.click(); Log.info('已点击确认按钮'); await page.waitForTimeout(2000); break; } } } } catch (error: any) { Log.info(`封面上传失败: ${error.message}`); Log.info('请手动在浏览器中上传封面'); } } else { Log.info('未找到封面文件输入框'); Log.info('请手动在浏览器中上传封面'); } } catch (error: any) { Log.info(`封面编辑失败: ${error.message}`); Log.info('请手动在浏览器中上传封面'); } } else { Log.info('未找到封面编辑按钮'); Log.info('请手动在浏览器中上传封面'); } Log.info(''); // ========== 步骤5: 填写标题 ========== Log.info('【步骤5】填写标题'); await page.waitForTimeout(2000); Log.info('查找标题输入框(placeholder="填写标题会有更多赞哦~")...'); const titleSelectors = [ // 优先查找包含"填写标题会有更多赞哦~"的输入框 'input[placeholder="填写标题会有更多赞哦~"]', 'input[placeholder*="填写标题会有更多赞哦"]', 'input[placeholder*="填写标题"]', 'textarea[placeholder="填写标题会有更多赞哦~"]', 'textarea[placeholder*="填写标题会有更多赞哦"]', 'textarea[placeholder*="填写标题"]', // 通用标题选择器 'input[placeholder*="标题"]', 'textarea[placeholder*="标题"]', '[class*="title"] input', '[class*="title"] textarea' ]; let titleInput: any = null; let titleFound = false; for (const selector of titleSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { // 如果有多个,优先选择包含"填写标题会有更多赞哦~"的 for (let i = 0; i < count; i++) { const input = page.locator(selector).nth(i); const isVisible = await input.isVisible().catch(() => false); if (isVisible) { // 检查placeholder是否匹配 const placeholder = await input.getAttribute('placeholder').catch(() => ''); if (placeholder.includes('填写标题会有更多赞哦') || selector.includes('填写标题')) { titleInput = input; titleFound = true; Log.info(`✓ 找到标题输入框: ${selector} (placeholder="${placeholder}")`); break; } else if (i === 0 && placeholder.includes('标题')) { // 如果没有找到完全匹配的,使用第一个包含"标题"的 titleInput = input; titleFound = true; Log.info(`✓ 找到标题输入框: ${selector} (placeholder="${placeholder}")`); break; } } } if (titleFound) break; } } catch (e: any) { Log.info(`选择器 ${selector} 失败: ${e.message}`); } } if (titleInput && titleFound) { try { await titleInput.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await titleInput.fill(params.title); await page.waitForTimeout(300); Log.info(`标题已填写: "${params.title}"`); } catch (error: any) { Log.info(`标题填写失败: ${error.message}`); Log.info('请手动填写标题'); } } else { Log.info('未找到标题输入框'); Log.info('请手动在"填写标题会有更多赞哦~"处填写标题'); } Log.info(''); // ========== 步骤6: 填写描述和话题 ========== Log.info('【步骤6】填写描述和话题'); await page.waitForTimeout(1000); Log.info('查找描述和话题输入框(data-placeholder="输入正文描述,真诚有价值的分享予人温暖")...'); Log.info('描述和话题可以在同一个输入框中一起输入'); const descSelectors = [ // 优先查找

标签(ProseMirror编辑器) 'p[data-placeholder="输入正文描述,真诚有价值的分享予人温暖"]', 'p[data-placeholder*="输入正文描述"]', 'p[data-placeholder*="真诚有价值的分享予人温暖"]', 'p.is-editor-empty[data-placeholder*="输入正文描述"]', 'p[class*="is-editor-empty"][data-placeholder*="输入正文描述"]', 'p[class*="is-empty"][data-placeholder*="输入正文描述"]', // 查找父容器(可能包含contenteditable) '[contenteditable] p[data-placeholder*="输入正文描述"]', '[contenteditable="true"] p[data-placeholder*="输入正文描述"]', // 其他可能的输入框类型 'textarea[placeholder="输入正文描述,真诚有价值的分享予人温暖"]', 'textarea[placeholder*="输入正文描述"]', 'textarea[placeholder*="真诚有价值的分享予人温暖"]', 'div[contenteditable][placeholder="输入正文描述,真诚有价值的分享予人温暖"]', 'div[contenteditable][placeholder*="输入正文描述"]', 'div[contenteditable][placeholder*="真诚有价值的分享予人温暖"]', // 通用选择器 'textarea[placeholder*="描述"]', 'textarea[placeholder*="正文"]', 'div[contenteditable][placeholder*="描述"]', 'div[contenteditable][placeholder*="正文"]', '[class*="description"] textarea', '[class*="desc"] textarea', '[class*="content"] textarea', '[class*="editor"] textarea', '[class*="input-editor"]', 'div[contenteditable="true"]' ]; let descInput: any = null; let descFound = false; // 方法1: 通过data-placeholder或placeholder直接查找 for (const selector of descSelectors) { try { const count = await page.locator(selector).count(); if (count > 0) { // 如果有多个,优先选择包含提示文字的 for (let i = 0; i < count; i++) { const input = page.locator(selector).nth(i); const isVisible = await input.isVisible().catch(() => false); if (isVisible) { // 检查data-placeholder或placeholder是否匹配 const dataPlaceholder = await input.getAttribute('data-placeholder').catch(() => ''); const placeholder = await input.getAttribute('placeholder').catch(() => ''); const combinedPlaceholder = dataPlaceholder || placeholder; if (combinedPlaceholder.includes('输入正文描述') || combinedPlaceholder.includes('真诚有价值的分享予人温暖')) { descInput = input; descFound = true; Log.info(`✓ 找到描述和话题输入框: ${selector} (data-placeholder="${dataPlaceholder}", placeholder="${placeholder}")`); break; } else if (i === 0 && (combinedPlaceholder.includes('描述') || combinedPlaceholder.includes('正文'))) { // 如果没有找到完全匹配的,使用第一个包含"描述"或"正文"的 descInput = input; descFound = true; Log.info(`✓ 找到描述和话题输入框: ${selector} (data-placeholder="${dataPlaceholder}", placeholder="${placeholder}")`); break; } } } if (descFound) break; } } catch (e: any) { Log.info(`选择器 ${selector} 失败: ${e.message}`); } } // 方法2: 如果方法1失败,查找包含contenteditable的父容器,然后找其中的p标签 if (!descFound) { try { const editableContainers = await page.locator('[contenteditable="true"]').all(); for (const container of editableContainers) { try { const pTag = container.locator('p[data-placeholder*="输入正文描述"]').first(); const pCount = await pTag.count(); if (pCount > 0) { const isVisible = await pTag.isVisible().catch(() => false); if (isVisible) { descInput = pTag; descFound = true; Log.info('✓ 找到描述和话题输入框(通过contenteditable容器中的p标签)'); break; } } } catch (e) { // 继续查找 } } } catch (e: any) { Log.info(`通过contenteditable容器查找失败: ${e.message}`); } } // 方法3: 如果方法1和2失败,通过文本查找包含提示文字的元素 if (!descFound) { try { const textElement = page.locator('*:has-text("输入正文描述")').first(); const textCount = await textElement.count(); if (textCount > 0) { Log.info('找到包含提示文字的元素,查找附近的输入框...'); // 在同一容器内查找输入框(包括p标签) const container = textElement.locator('..'); const inputInContainer = container.locator('p[data-placeholder], textarea, div[contenteditable], input[type="text"]').first(); const inputCount = await inputInContainer.count(); if (inputCount > 0) { const isVisible = await inputInContainer.isVisible().catch(() => false); if (isVisible) { descInput = inputInContainer; descFound = true; Log.info('✓ 找到描述和话题输入框(通过提示文字)'); } } } } catch (e: any) { Log.info(`通过文本查找失败: ${e.message}`); } } if (descInput && descFound) { try { await descInput.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); const tagName = await descInput.evaluate((el: any) => el.tagName.toLowerCase()).catch(() => ''); const contentEditable = await descInput.getAttribute('contenteditable').catch(() => ''); const dataPlaceholder = await descInput.getAttribute('data-placeholder').catch(() => ''); const isPTag = tagName === 'p'; const isContentEditable = contentEditable !== '' || isPTag; if (isPTag) { //

标签的处理(ProseMirror编辑器) Log.info('检测到

标签编辑器,先填写描述,然后逐个输入话题...'); await descInput.click(); await page.waitForTimeout(300); // 先一次性填写描述 Log.info('填写描述...'); await descInput.evaluate((el: any, text: string) => { // 清空现有内容(包括br标签) el.innerHTML = ''; // 设置文本内容(只包含描述) el.textContent = text; // 移除is-empty类(如果有) el.classList.remove('is-empty', 'is-editor-empty'); // 触发input事件 const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: text }); el.dispatchEvent(inputEvent); // 查找父容器(可能包含contenteditable),也在父容器上触发事件 let parent = el.parentElement; while (parent) { if (parent.getAttribute('contenteditable') === 'true') { const parentInputEvent = new InputEvent('input', { bubbles: true, cancelable: true }); parent.dispatchEvent(parentInputEvent); break; } parent = parent.parentElement; } }, params.description); await page.waitForTimeout(500); Log.info(`描述已填写: "${params.description.substring(0, 50)}..."`); // 然后逐个输入话题,每个话题后按回车(不进行任何操作,直接输入#号) if (params.tags && params.tags.length > 0) { Log.info('直接输入#号...'); // 直接输入#号,不进行任何其他操作 await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(500); // 增加等待时间,确保#号输入完成 // 逐个输入话题 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; Log.info(`输入话题 ${i + 1}/${params.tags.length}: #${cleanTag}`); // 输入话题文字(增加延迟确保输入完成) await page.keyboard.type(cleanTag, { delay: 100 }); await page.waitForTimeout(300); // 按空格键触发话题识别(改用空格而不是回车) await page.keyboard.press('Space'); await page.waitForTimeout(500); // 检查是否有话题选择弹窗,如果有则按回车确认 try { const topicPopup = page.locator('[class*="topic"], [class*="hashtag"], [class*="suggest"]').first(); if (await topicPopup.isVisible({ timeout: 1000 })) { Log.info('检测到话题选择弹窗,选择第一个选项'); await page.keyboard.press('Enter'); await page.waitForTimeout(300); } } catch (e) { Log.debug('未检测到话题弹窗,继续'); } // 如果不是最后一个话题,输入#号准备下一个话题 if (i < params.tags.length - 1) { await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(300); } } await page.waitForTimeout(500); Log.info(`话题已添加: ${params.tags.map(tag => `#${tag}`).join(' ')}`); } } else if (isContentEditable) { // contenteditable div的处理 Log.info('先填写描述,然后逐个输入话题...'); await descInput.click(); await page.waitForTimeout(300); // 先一次性填写描述 Log.info('填写描述...'); await descInput.evaluate((el: any, text: string) => { el.focus(); el.textContent = text; const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true }); el.dispatchEvent(inputEvent); }, params.description); await page.waitForTimeout(500); Log.info(`描述已填写: "${params.description.substring(0, 50)}..."`); // 然后逐个输入话题,每个话题后按回车(不进行任何操作,直接输入#号) if (params.tags && params.tags.length > 0) { Log.info('直接输入#号...'); // 直接输入#号,不进行任何其他操作 await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(500); // 增加等待时间,确保#号输入完成 // 逐个输入话题 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; Log.info(`输入话题 ${i + 1}/${params.tags.length}: #${cleanTag}`); // 输入话题文字(增加延迟确保输入完成) await page.keyboard.type(cleanTag, { delay: 100 }); await page.waitForTimeout(300); // 按空格键触发话题识别(改用空格而不是回车) await page.keyboard.press('Space'); await page.waitForTimeout(500); // 检查是否有话题选择弹窗,如果有则按回车确认 try { const topicPopup = page.locator('[class*="topic"], [class*="hashtag"], [class*="suggest"]').first(); if (await topicPopup.isVisible({ timeout: 1000 })) { Log.info('检测到话题选择弹窗,选择第一个选项'); await page.keyboard.press('Enter'); await page.waitForTimeout(300); } } catch (e) { Log.debug('未检测到话题弹窗,继续'); } // 如果不是最后一个话题,输入#号准备下一个话题 if (i < params.tags.length - 1) { await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(300); } } await page.waitForTimeout(500); Log.info(`话题已添加: ${params.tags.map(tag => `#${tag}`).join(' ')}`); } } else { // 普通textarea或input - 先填写描述,然后逐个输入话题 Log.info('先填写描述,然后逐个输入话题...'); // 先一次性填写描述 Log.info('填写描述...'); await descInput.fill(params.description); await page.waitForTimeout(500); Log.info(`描述已填写: "${params.description.substring(0, 50)}..."`); // 然后逐个输入话题,每个话题后按回车(不进行任何操作,直接输入#号) if (params.tags && params.tags.length > 0) { Log.info('直接输入#号...'); // 直接输入#号,不进行任何其他操作 await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(500); // 增加等待时间,确保#号输入完成 // 逐个输入话题 for (let i = 0; i < params.tags.length; i++) { const tag = params.tags[i].trim(); const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag; Log.info(`输入话题 ${i + 1}/${params.tags.length}: #${cleanTag}`); // 输入话题文字(增加延迟确保输入完成) await page.keyboard.type(cleanTag, { delay: 100 }); await page.waitForTimeout(300); // 按空格键触发话题识别(改用空格而不是回车) await page.keyboard.press('Space'); await page.waitForTimeout(500); // 检查是否有话题选择弹窗,如果有则按回车确认 try { const topicPopup = page.locator('[class*="topic"], [class*="hashtag"], [class*="suggest"]').first(); if (await topicPopup.isVisible({ timeout: 1000 })) { Log.info('检测到话题选择弹窗,选择第一个选项'); await page.keyboard.press('Enter'); await page.waitForTimeout(300); } } catch (e) { Log.debug('未检测到话题弹窗,继续'); } // 如果不是最后一个话题,输入#号准备下一个话题 if (i < params.tags.length - 1) { await page.keyboard.type('#', { delay: 100 }); await page.waitForTimeout(300); } } await page.waitForTimeout(500); Log.info(`话题已添加: ${params.tags.map(tag => `#${tag}`).join(' ')}`); } } } catch (error: any) { Log.info(`描述和话题填写失败: ${error.message}`); Log.info('请手动填写描述和话题'); } } else { Log.info('未找到描述和话题输入框'); Log.info('请手动在"输入正文描述"处填写描述和话题(话题用#号添加)'); } Log.info(''); // ========== 步骤7: 验证封面是否已添加 ========== Log.info('【步骤7】验证封面是否已添加'); // 验证封面是否已上传 let coverUploaded = false; try { // 检查是否有封面图片 const coverImageSelectors = [ 'img[class*="cover"]', '[class*="cover"] img', 'img[src*="cover"]', '[class*="thumbnail"] img' ]; for (const selector of coverImageSelectors) { const count = await page.locator(selector).count(); if (count > 0) { const img = page.locator(selector).first(); const src = await img.getAttribute('src').catch(() => ''); // 如果图片有src且不是占位符,认为封面已上传 if (src && !src.includes('placeholder') && !src.includes('default')) { coverUploaded = true; Log.info('验证:封面已上传'); break; } } } if (!coverUploaded) { Log.info('警告:未检测到封面图片,封面可能未上传'); Log.info('请检查封面是否已正确上传'); } } catch (e: any) { Log.info(`验证封面失败: ${e.message}`); Log.info('无法验证封面状态,请手动检查'); } // 话题已经在上面成功添加,不需要验证 Log.info('话题已在上面成功添加,跳过验证'); // 如果封面未添加,等待一段时间让用户手动操作 if (!coverUploaded) { Log.info('重要提示:'); Log.info(' - 封面可能未上传,请检查并手动上传封面'); Log.info('等待5秒,请手动完成封面上传...'); await page.waitForTimeout(5000); } else { Log.info('验证完成:封面已上传,话题已添加'); } Log.info(''); Log.info('=== 所有准备工作已完成 ==='); Log.info('视频、封面、标题、描述、话题都已填写完成'); if (params.autoPublish) { Log.info('检测到自动发布标志,准备执行点击发布按钮...'); // 查找发布按钮 const publishBtnSelectors = [ 'button:has-text("发布")', 'div[class*="publish"] button', 'button[class*="submit"]', 'button:has-text("立即发布")', 'div:text-is("发布")' ]; let publishBtn = null; for (const selector of publishBtnSelectors) { const btn = page.locator(selector).first(); if (await btn.count() > 0 && await btn.isVisible()) { publishBtn = btn; Log.info(`找到发布按钮: ${selector}`); break; } } if (publishBtn) { await page.waitForTimeout(2000); // 稍等片刻 await publishBtn.click(); // publish-triggered publishTriggered = true; Log.info('已自动点击发布按钮'); // 等待发布成功提示 try { await Promise.race([ page.locator('text="发布成功"').waitFor({ timeout: 10000 }), page.locator('text="上传成功"').waitFor({ timeout: 10000 }), page.locator('text="已发布"').waitFor({ timeout: 10000 }), page.waitForTimeout(5000) ]); Log.info('发布操作已提交'); } catch (e) { Log.info('未检测到明确的发布成功提示,但已执行点击'); } } else { Log.warn('未找到发布按钮,请手动点击'); } } else { Log.info('请在浏览器中检查内容,然后手动点击保存草稿或发布按钮'); Log.info('浏览器窗口将保持打开状态,等待您手动操作...'); await page.waitForTimeout(30000); } if (params.autoPublish) { if (publishTriggered) { const publishResult = await waitForPublishConfirmation(page, 'xiaohongshu'); if (publishResult.success) { shouldClosePage = true; } return publishResult; } return { success: false, status: 'manual_pending', message: '未找到明确的发布按钮,请在浏览器中手动确认发布', videoUrl: page.url(), videoId: '', keepPageOpen: true }; } const finalUrl = page.url(); Log.info(`当前页面URL: ${finalUrl}`); return { success: false, status: 'manual_pending', message: '准备工作已完成,请在浏览器中手动点击保存草稿或发布按钮', videoUrl: finalUrl, videoId: 'manual_publish', keepPageOpen: true }; } catch (error: any) { Log.error('发布到小红书失败', { message: error.message, stack: error.stack }); if (page) { try { const screenshotPath = path.join(os.homedir(), '.aigcpanel', 'xiaohongshu_error.png'); await page.screenshot({ path: screenshotPath }); Log.info(`错误截图已保存: ${screenshotPath}`); } catch (e) { // 忽略错误 } } throw error; } finally { if (page && shouldClosePage) { try { await page.close(); } catch (error) { // 忽略错误 } } } } /** * 从URL提取视频ID */ function extractVideoIdFromUrl(url: string): string { try { const match = url.match(/(?:id=|video\/)([a-zA-Z0-9_-]+)/); return match ? match[1] : ''; } catch (error) { return ''; } } /** * 获取发布进度 */ ipcMain.handle('publish:getProgress', async (event, params: { taskId: number }) => { try { const task = await DbMain.first('SELECT * FROM publish_tasks WHERE id = ?', [params.taskId]); if (!task) { return { success: false, message: '任务不存在' }; } return { success: true, status: task.status, progress: 0 }; } catch (error: any) { return { success: false, message: error.message }; } }); /** * 取消发布任务 */ ipcMain.handle('publish:cancel', async (event, params: { taskId: number }) => { try { await DbMain.execute( 'UPDATE publish_tasks SET status = ?, updatedAt = ? WHERE id = ?', ['cancelled', Date.now(), params.taskId] ); return { success: true }; } catch (error: any) { return { success: false, message: error.message }; } }); /** * 查询发布历史 */ ipcMain.handle('publish:getHistory', async (event, params: { startDate?: number; endDate?: number; platform?: string; limit?: number; } = {}) => { try { let query = 'SELECT * FROM publish_history WHERE 1=1'; const queryParams: any[] = []; if (params.startDate) { query += ' AND publishedAt >= ?'; queryParams.push(params.startDate); } if (params.endDate) { query += ' AND publishedAt <= ?'; queryParams.push(params.endDate); } if (params.platform) { query += ' AND platform = ?'; queryParams.push(params.platform); } query += ' ORDER BY publishedAt DESC'; if (params.limit) { query += ` LIMIT ${params.limit}`; } const history = await DbMain.select(query, queryParams); return { success: true, history }; } catch (error: any) { return { success: false, message: error.message, history: [] }; } }); /** * 清理资源 */ async function cleanup() { const contexts = Array.from(browserContexts.values()); for (const context of contexts) { await context.close(); } browserContexts.clear(); } /** * 关闭指定平台的浏览器上下文 * @param platform 平台名称,如 'douyin' */ async function closeBrowserContext(platform: string, accountId?: number | string): Promise { try { const contextKey = accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`; Log.info(`[closeBrowserContext] 开始关闭 ${contextKey} 的浏览器上下文`); try { const context = browserContexts.get(contextKey); if (!context) { Log.info(`[closeBrowserContext] ${platform} 的浏览器上下文不存在于缓存中`); return; } Log.info(`[closeBrowserContext] 获取到 ${platform} 的浏览器上下文,开始关闭...`); const allPages = context.pages(); Log.info(`[closeBrowserContext] 发现 ${allPages.length} 个页面,准备关闭`); for (const p of allPages) { try { const pageUrl = p.url(); await p.close(); Log.info(`[closeBrowserContext] 已关闭页签: ${pageUrl}`); } catch (e) { Log.debug(`[closeBrowserContext] 关闭页签失败(忽略): ${e}`); } } Log.info(`[closeBrowserContext] 正在调用 context.close()...`); await context.close(); Log.info(`[closeBrowserContext] context.close() 调用完成`); browserContexts.delete(contextKey); Log.info(`[closeBrowserContext] ✅ ${platform} 浏览器上下文已从缓存删除`); } catch (closeBrowserError: any) { Log.error(`[closeBrowserContext] 关闭浏览器失败:`, closeBrowserError.message); browserContexts.delete(contextKey); } } catch (error) { Log.error(`[closeBrowserContext] 出现异常:`, error); } } process.on('exit', () => { cleanup(); }); export default { async init() { Log.info('publish module initialized'); } }; /** * 导出getBrowserContext和closeBrowserContext函数,供其他模块使用(如视频处理) */ export { getBrowserContext, closeBrowserContext };