Files
2026-06-19 18:45:55 +08:00

5635 lines
241 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, BrowserContext> = 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<string, string[]> = {
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<string, string[]> = {
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<PublishExecutionResult> {
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<BrowserContext> {
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<string, { selectors: string[]; urlExclude: string[] }> = {
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<boolean> => {
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<string> => {
try {
// 各平台的昵称提取选择器(按优先级)
const nicknameSelectors: Record<string, string[]> = {
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<any> {
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-avatarcookies 可能已过期');
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('检测到自动发布标志,准备执行点击发布按钮...');
// 查找发布按钮(抖音创作者平台的发布按钮)
// 抖音的发布按钮: <button class="button-xxx primary...">发布</button>
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<any> {
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<void> {
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<any> {
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<Page> {
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<any> {
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 = [
// 优先查找 <p data-placeholder> 标签(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) {
// <p> 标签的处理(ProseMirror编辑器)
Log.info('检测到 <p> 标签编辑器,先填写描述,然后逐个输入话题...');
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<void> {
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 };