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

752 lines
29 KiB
TypeScript

import { ipcMain, BrowserWindow } from 'electron';
import { Log } from '../log/main';
import DbMain from '../db/main';
import { PlatformAccount, LoginSession } from './types';
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getPlaywrightChromiumPath, COMMON_BROWSER_ARGS } from '../../lib/browser-path';
import { getRuntimeDataPath } from '../../lib/resource-path';
// 登录会话存储
const loginSessions: Map<string, LoginSession> = new Map();
// 浏览器实例管理
let browser: Browser | null = null;
const browserContexts: Map<string, BrowserContext> = new Map();
/**
* 初始化浏览器
*/
async function initBrowser(): Promise<Browser> {
if (!browser) {
const executablePath = getPlaywrightChromiumPath();
browser = await chromium.launch({
executablePath, // ✅ 使用打包的浏览器,支持自定义安装路径
headless: false, // 显示浏览器窗口,方便扫码
args: COMMON_BROWSER_ARGS
});
}
return browser;
}
/**
* 获取或创建浏览器上下文
*/
async function getBrowserContext(platform: string): Promise<BrowserContext> {
if (!browserContexts.has(platform)) {
const browser = await initBrowser();
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai'
});
browserContexts.set(platform, context);
}
return browserContexts.get(platform)!;
}
/**
* 生成会话ID
*/
function generateSessionId(): string {
return `session_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
}
/**
* 启动扫码登录
*/
ipcMain.handle('platform:startQRLogin', async (event, params: { platform: string }) => {
try {
const { platform } = params;
Log.info('platform.startQRLogin', { platform });
const sessionId = generateSessionId();
let qrCodeUrl = '';
const context = await getBrowserContext(platform);
const page = await context.newPage();
// 根据平台访问不同的登录页面
if (platform === 'douyin') {
await page.goto('https://creator.douyin.com/');
// 等待二维码出现
try {
await page.waitForSelector('.qrcode-img', { timeout: 10000 });
const qrImgElement = await page.$('.qrcode-img');
qrCodeUrl = await qrImgElement?.getAttribute('src') || '';
if (!qrCodeUrl) {
// 尝试其他选择器
const qrCanvas = await page.$('canvas[class*="qrcode"]');
if (qrCanvas) {
qrCodeUrl = await qrCanvas.screenshot({ type: 'png' }).then(buffer =>
`data:image/png;base64,${buffer.toString('base64')}`
);
}
}
} catch (error) {
Log.error('Failed to get QR code', error);
}
} else if (platform === 'kuaishou') {
await page.goto('https://cp.kuaishou.com/');
try {
await page.waitForSelector('.qrcode-box img', { timeout: 10000 });
const qrImgElement = await page.$('.qrcode-box img');
qrCodeUrl = await qrImgElement?.getAttribute('src') || '';
} catch (error) {
Log.error('Failed to get QR code', error);
}
} else if (platform === 'shipin') {
await page.goto('https://channels.weixin.qq.com/');
try {
await page.waitForSelector('.qrcode img', { timeout: 10000 });
const qrImgElement = await page.$('.qrcode img');
qrCodeUrl = await qrImgElement?.getAttribute('src') || '';
} catch (error) {
Log.error('Failed to get QR code', error);
}
}
// 存储登录会话
const session: LoginSession = {
sessionId,
platform,
qrCodeUrl,
status: 'pending',
createdAt: Date.now()
};
loginSessions.set(sessionId, session);
// 将page存储到context的metadata中,以便后续使用
(context as any)._loginPage = page;
return {
success: true,
sessionId,
qrCodeUrl
};
} catch (error: any) {
Log.error('platform.startQRLogin error', error);
return {
success: false,
message: error.message
};
}
});
/**
* 检查登录状态
*/
ipcMain.handle('platform:checkLoginStatus', async (event, params: { sessionId: string }) => {
try {
const { sessionId } = params;
const session = loginSessions.get(sessionId);
if (!session) {
return { status: 'expired' };
}
// 检查会话是否过期 (5分钟)
if (Date.now() - session.createdAt > 5 * 60 * 1000) {
session.status = 'expired';
return { status: 'expired' };
}
const context = browserContexts.get(session.platform);
if (!context) {
return { status: 'expired' };
}
const page = (context as any)._loginPage as Page;
if (!page) {
return { status: 'expired' };
}
// 检查是否登录成功(通过检查页面URL或特定元素)
const currentUrl = page.url();
let isLoggedIn = false;
if (session.platform === 'douyin') {
isLoggedIn = currentUrl.includes('/creator/') && !currentUrl.includes('/login');
} else if (session.platform === 'kuaishou') {
isLoggedIn = currentUrl.includes('/cp.kuaishou.com/article');
} else if (session.platform === 'shipin') {
isLoggedIn = currentUrl.includes('/channels.weixin.qq.com/platform');
}
if (isLoggedIn) {
// 登录成功,获取Cookie
const cookies = await context.cookies();
// 提取用户信息
let userInfo = await extractUserInfo(page, session.platform);
// 保存到数据库
const now = Date.now();
await DbMain.insert(
`INSERT INTO platform_accounts (platform, nickname, uid, avatar, cookies, tokens, loginStatus, expiresAt, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
session.platform,
userInfo.nickname || '',
userInfo.uid || '',
userInfo.avatar || '',
JSON.stringify(cookies),
'', // tokens
'active',
now + 30 * 24 * 60 * 60 * 1000, // 30天后过期
now,
now
]
);
session.status = 'success';
// 🔧 修改:检测到已登录后,必须关闭浏览器(不是关页签,是关闭整个浏览器)
try {
// 关闭登录页面
await page.close();
(context as any)._loginPage = null;
// 关闭浏览器上下文(这会关闭整个浏览器窗口)
Log.info(`✅ 检测到${session.platform}已登录,准备关闭浏览器`);
await context.close();
browserContexts.delete(session.platform);
Log.info(`✅ 浏览器已关闭`);
} catch (closeError: any) {
Log.error('关闭浏览器失败:', closeError.message);
}
return {
status: 'success',
userInfo
};
}
return { status: 'pending' };
} catch (error: any) {
Log.error('platform.checkLoginStatus error', error);
return {
status: 'failed',
message: error.message
};
}
});
/**
* 提取用户信息
*/
async function extractUserInfo(page: Page, platform: string): Promise<any> {
try {
if (platform === 'douyin') {
// 尝试提取抖音用户信息
const nickname = await page.locator('.username, .account-name').first().textContent().catch(() => '');
const avatar = await page.locator('.avatar img').first().getAttribute('src').catch(() => '');
return { nickname: nickname?.trim() || '', avatar: avatar || '', uid: '' };
} else if (platform === 'kuaishou') {
const nickname = await page.locator('.user-name').first().textContent().catch(() => '');
const avatar = await page.locator('.user-avatar img').first().getAttribute('src').catch(() => '');
return { nickname: nickname?.trim() || '', avatar: avatar || '', uid: '' };
} else if (platform === 'shipin') {
const nickname = await page.locator('.nickname').first().textContent().catch(() => '');
const avatar = await page.locator('.avatar img').first().getAttribute('src').catch(() => '');
return { nickname: nickname?.trim() || '', avatar: avatar || '', uid: '' };
}
} catch (error) {
Log.error('extractUserInfo error', error);
}
return { nickname: '', avatar: '', uid: '' };
}
/**
* 获取账号列表
*/
ipcMain.handle('platform:getAccounts', async (event, params: { platform?: string } = {}) => {
try {
let query = 'SELECT * FROM platform_accounts WHERE loginStatus = ?';
let queryParams: any[] = ['active'];
if (params.platform) {
query += ' AND platform = ?';
queryParams.push(params.platform);
}
query += ' ORDER BY updatedAt DESC';
const accounts = await DbMain.select(query, queryParams);
// 解密cookies(这里简化处理,实际应该加密存储)
const result = accounts.map(acc => ({
...acc,
cookies: null, // 不返回敏感信息到前端
tokens: null
}));
return {
success: true,
accounts: result
};
} catch (error: any) {
Log.error('platform.getAccounts error', error);
return {
success: false,
message: error.message,
accounts: []
};
}
});
/**
* 登出账号
*/
ipcMain.handle('platform:logout', async (event, params: { accountId: number }) => {
try {
const { accountId } = params;
await DbMain.execute(
'UPDATE platform_accounts SET loginStatus = ?, updatedAt = ? WHERE id = ?',
['expired', Date.now(), accountId]
);
return { success: true };
} catch (error: any) {
Log.error('platform.logout error', error);
return {
success: false,
message: error.message
};
}
});
/**
* 清除平台的浏览器数据和Cookie(完全清空登录状态)
*/
ipcMain.handle('platform:clearBrowserData', async (event, params: { platform: string }) => {
try {
const { platform } = params;
Log.info(`[clearBrowserData] 开始清除${platform}的浏览器数据`);
// 1. 关闭 Publish 模块管理的浏览器上下文 (关键步骤:释放文件锁)
try {
// 动态导入 publish 模块,避免循环依赖
const { closeBrowserContext } = await import('../publish/main');
await closeBrowserContext(platform);
Log.info(`[clearBrowserData] 已调用 publish.closeBrowserContext 关闭${platform}浏览器`);
} catch (e: any) {
Log.warn(`[clearBrowserData] 关闭Publish模块浏览器失败 (可能未打开):`, e.message);
}
// 2. 关闭 Platform 模块自己管理的浏览器上下文 (如果有)
// 注意:Platform 模块的 key 是 platform,不是 publish_${platform}
if (browserContexts.has(platform)) {
try {
const context = browserContexts.get(platform)!;
await context.close();
browserContexts.delete(platform);
Log.info(`[clearBrowserData] 已关闭并删除Platform模块的${platform}浏览器上下文`);
} catch (error: any) {
Log.warn(`[clearBrowserData] 关闭Platform模块浏览器上下文失败:`, error.message);
}
}
// 0. 🔴 终极手段:强制杀死所有 Chromium 进程,确保没有文件被锁
try {
const { execSync } = require('child_process');
Log.info('[clearBrowserData] 正在强制关闭所有浏览器进程...');
// Windows 下强制杀死 chrome.exe 和 chromium.exe
if (process.platform === 'win32') {
execSync('taskkill /F /IM chrome.exe /IM chromium.exe /T 2>nul', { stdio: 'ignore' });
} else {
execSync('pkill -9 -f chromium || true', { stdio: 'ignore' });
execSync('pkill -9 -f chrome || true', { stdio: 'ignore' });
}
Log.info('[clearBrowserData] 浏览器进程已强制清理');
} catch (e) {
Log.warn('[clearBrowserData] 强制关闭进程时出错(可忽略):', e);
}
// 等待进程完全退出释放文件锁
await new Promise(resolve => setTimeout(resolve, 1000));
// 🔥 3. 删除持久化浏览器数据目录(增强版:彻底清除)
const userDataDir = getRuntimeDataPath('browser-data', 'platform', platform);
if (fs.existsSync(userDataDir)) {
try {
// 第一步:强制关闭占用这个目录的所有进程
const { execSync } = require('child_process');
try {
Log.info(`[clearBrowserData] 尝试解除${userDataDir}上的文件锁...`);
if (process.platform === 'win32') {
// Windows 下使用 handle.exe 或直接杀 chrome 进程
execSync(`taskkill /F /IM chrome.exe /IM chromium.exe 2>nul`, { stdio: 'ignore' });
}
} catch (e) {
Log.debug(`[clearBrowserData] 解除文件锁失败(可忽略):`, e);
}
// 第二步:等待文件锁释放
await new Promise(resolve => setTimeout(resolve, 500));
// 第三步:增加重试机制,防止文件锁释放延迟
let retries = 10; // 增加重试次数到10
while (retries > 0) {
try {
// 尝试先清空目录内容
if (fs.existsSync(userDataDir)) {
const files = fs.readdirSync(userDataDir);
for (const file of files) {
const curPath = path.join(userDataDir, file);
try {
if (fs.lstatSync(curPath).isDirectory()) {
fs.rmSync(curPath, { recursive: true, force: true });
} else {
fs.unlinkSync(curPath);
}
} catch (e) { } // 忽略单个文件删除失败
}
// 然后删除目录本身
fs.rmSync(userDataDir, { recursive: true, force: true });
}
// 第四步:验证目录是否真的被删除了
if (fs.existsSync(userDataDir)) {
throw new Error('目录仍然存在');
}
Log.info(`[clearBrowserData] ✅ 已彻底删除浏览器数据目录: ${userDataDir}`);
break;
} catch (rmError: any) {
retries--;
if (retries === 0) {
throw new Error(`无法删除目录 (尝试${10}次后放弃): ${rmError.message}`);
}
Log.warn(`[clearBrowserData] 删除目录失败,等待后重试 (${retries} left)...`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
} catch (error: any) {
Log.error(`[clearBrowserData] 删除浏览器数据目录失败:`, error.message);
// 最后手段:重命名目录
try {
const trashPath = getRuntimeDataPath('trash', `${platform}_${Date.now()}`);
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(userDataDir, trashPath);
Log.warn(`[clearBrowserData] ⚠️ 无法删除,已将目录重命名为: ${trashPath}`);
} catch (mvError) {
Log.error(`[clearBrowserData] 重命名也失败:`, mvError);
throw mvError;
}
}
} else {
Log.info(`[clearBrowserData] 数据目录不存在,无需删除: ${userDataDir}`);
}
// 🔥 3.5 关键:清除全局 Chromium User Data 目录中的 cookies
// 这是真正存储所有 cookies 的地方!(所有 Chromium 实例共享)
try {
let globalChromiumDataDir: string;
if (process.platform === 'win32') {
globalChromiumDataDir = path.join(os.homedir(), 'AppData', 'Local', 'Chromium', 'User Data', 'Default');
} else if (process.platform === 'darwin') {
globalChromiumDataDir = path.join(os.homedir(), 'Library', 'Application Support', 'Chromium', 'Default');
} else {
globalChromiumDataDir = path.join(os.homedir(), '.config', 'chromium', 'Default');
}
if (fs.existsSync(globalChromiumDataDir)) {
const filesToClean = [
'Network/Cookies',
'Network/Cookies-journal',
'Network/Network Persistent State',
'Cookies',
'Cookies-journal',
];
for (const file of filesToClean) {
try {
const filePath = path.join(globalChromiumDataDir, file);
if (fs.existsSync(filePath)) {
if (fs.statSync(filePath).isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
fs.unlinkSync(filePath);
}
Log.info(`[clearBrowserData] 已清除全局Chromium cookies: ${file}`);
}
} catch (e: any) {
Log.warn(`[clearBrowserData] 清除全局Chromium文件失败 ${file} (可忽略):`, e.message);
}
}
}
} catch (error: any) {
Log.warn(`[clearBrowserData] 清除全局Chromium User Data失败(可忽略):`, error.message);
}
// 3.6 🔧 新增:清除 ipAgent 模块使用的 douyin-browser-data 目录
if (platform === 'douyin') {
// 先关闭 DouyinBrowserManager 管理的浏览器实例
try {
const { DouyinBrowserManager } = await import('../ipAgent/browserManager');
await DouyinBrowserManager.cleanup();
Log.info(`[clearBrowserData] 已关闭 DouyinBrowserManager 的浏览器实例`);
} catch (e: any) {
Log.warn(`[clearBrowserData] 关闭 DouyinBrowserManager 失败 (可能未初始化):`, e.message);
}
const { app } = await import('electron');
const douyinBrowserDataDir = path.join(app.getPath('userData'), 'douyin-browser-data');
if (fs.existsSync(douyinBrowserDataDir)) {
try {
let retries = 5;
while (retries > 0) {
try {
fs.rmSync(douyinBrowserDataDir, { recursive: true, force: true });
if (fs.existsSync(douyinBrowserDataDir)) throw new Error('目录仍存在');
Log.info(`[clearBrowserData] 已删除抖音浏览器数据目录: ${douyinBrowserDataDir}`);
break;
} catch (rmError: any) {
retries--;
if (retries === 0) throw rmError;
Log.warn(`[clearBrowserData] 删除抖音目录失败,等待1秒后重试 (${retries} left)...`);
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
} catch (error: any) {
Log.error(`[clearBrowserData] 删除抖音浏览器数据目录失败:`, error.message);
// 尝试重命名
try {
const trashPath = path.join(app.getPath('userData'), 'trash', `douyin_${Date.now()}`);
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(douyinBrowserDataDir, trashPath);
Log.info(`[clearBrowserData] 删除失败,已将目录重命名为: ${trashPath}`);
} catch (mvError) { }
}
} else {
Log.info(`[clearBrowserData] 抖音数据目录不存在,无需删除: ${douyinBrowserDataDir}`);
}
}
// 4. 从数据库中删除该平台的所有账号
try {
const result = await DbMain.execute(
'DELETE FROM platform_accounts WHERE platform = ?',
[platform]
);
Log.info(`[clearBrowserData] 已从数据库删除${platform}的所有账号`);
} catch (error: any) {
Log.error(`[clearBrowserData] 删除数据库账号失败:`, error.message);
throw error;
}
// 🔥 4.5 关键步骤:清除内存中的浏览器实例和持久化上下文缓存
// 这是彻底清除cookies的关键!否则Playwright会从内存恢复旧数据
try {
// 关闭全局浏览器实例,强制释放内存中的cookies和session
if (browser) {
try {
await browser.close();
} catch (e) {
Log.warn(`[clearBrowserData] browser.close() 失败:`, e);
}
browser = null;
}
// 清除所有浏览器上下文的缓存引用(包括该平台的)
browserContexts.clear();
Log.info(`[clearBrowserData] ✅ 已关闭全局浏览器实例和所有上下文,清除内存中的所有cookies和session数据`);
} catch (error: any) {
Log.warn(`[clearBrowserData] 清除内存缓存时出错(但可以继续):`, error.message);
}
Log.info(`[clearBrowserData] ${platform} 数据清除成功`);
return { success: true, message: `已清除${platform}的所有浏览器数据和登录信息` };
} catch (error: any) {
// 关键:确保记录完整的错误信息
Log.error('[clearBrowserData] 清除浏览器数据失败:', {
message: error.message,
stack: error.stack
});
return {
success: false,
message: error.message || '未知错误'
};
}
});
/**
* 刷新账号信息(真正打开浏览器检测登录状态)
*/
ipcMain.handle('platform:refreshAccount', async (event, params: { accountId: number }) => {
try {
const { accountId } = params;
const account = await DbMain.first(
'SELECT * FROM platform_accounts WHERE id = ?',
[accountId]
);
if (!account) {
return { success: false, message: '账号不存在' };
}
// 🔧 修改:调用 publish 模块的检查登录状态功能(这会真正打开浏览器检测)
const platform = account.platform;
Log.info(`开始刷新${platform}账号,真正检测登录状态`);
// 直接使用 publish 模块的函数来检测登录状态
const { getBrowserContext, closeBrowserContext } = await import('../publish/main');
const context = await getBrowserContext(platform);
const page = await context.newPage();
let isLoggedIn = false;
let userInfo: any = {};
try {
// 根据平台确定检查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';
}
if (checkUrl) {
await page.goto(checkUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
await new Promise(resolve => setTimeout(resolve, 2000));
const currentUrl = page.url();
// 检测登录状态
if (platform === 'douyin') {
const fileInputCount = await page.locator('input[type="file"]').count();
isLoggedIn = fileInputCount > 0 || (currentUrl.includes('creator.douyin.com') && !currentUrl.includes('login'));
userInfo.nickname = '用户';
} else if (platform === 'kuaishou') {
isLoggedIn = currentUrl.includes('cp.kuaishou.com') && !currentUrl.includes('login');
userInfo.nickname = '用户';
} else if (platform === 'shipin') {
const fileInputCount = await page.locator('input[type="file"]').count();
isLoggedIn = fileInputCount > 0 || (currentUrl.includes('channels.weixin.qq.com') && !currentUrl.includes('login'));
userInfo.nickname = '用户';
} else if (platform === 'xiaohongshu') {
const fileInputCount = await page.locator('input[type="file"]').count();
isLoggedIn = fileInputCount > 0 || (currentUrl.includes('creator.xiaohongshu.com') && !currentUrl.includes('login'));
userInfo.nickname = '用户';
}
// 如果已登录,获取最新的Cookie
if (isLoggedIn) {
const cookies = await context.cookies();
const now = Date.now();
// 更新数据库中的账号信息
await DbMain.execute(
'UPDATE platform_accounts SET cookies = ?, nickname = ?, updatedAt = ?, loginStatus = ? WHERE id = ?',
[JSON.stringify(cookies), userInfo.nickname || account.nickname, now, 'active', accountId]
);
Log.info(`${platform}刷新成功,已登录`);
} else {
// 未登录,更新状态为 inactive
await DbMain.execute(
'UPDATE platform_accounts SET loginStatus = ?, updatedAt = ? WHERE id = ?',
['inactive', Date.now(), accountId]
);
Log.info(`${platform}刷新完成,未登录`);
}
}
} catch (error: any) {
Log.error(`刷新${platform}账号时检测登录状态失败:`, error);
} finally {
// 关闭页面和浏览器
try {
if (page && !page.isClosed()) {
await page.close();
}
// 关闭浏览器上下文
await closeBrowserContext(platform);
} catch (closeError: any) {
Log.error('关闭浏览器失败:', closeError.message);
}
}
// 重新查询更新后的账号信息
const updatedAccount = await DbMain.first(
'SELECT * FROM platform_accounts WHERE id = ?',
[accountId]
);
return {
success: true,
isLoggedIn,
account: {
...updatedAccount,
cookies: null, // 不返回敏感信息
tokens: null
},
userInfo
};
} catch (error: any) {
Log.error('platform.refreshAccount error', error);
return {
success: false,
message: error.message
};
}
});
/**
* 清理资源
*/
async function cleanup() {
for (const context of browserContexts.values()) {
await context.close();
}
browserContexts.clear();
if (browser) {
await browser.close();
browser = null;
}
loginSessions.clear();
}
// 应用退出时清理
process.on('exit', () => {
cleanup();
});
export default {
async init() {
Log.info('platform module initialized');
}
};