973 lines
29 KiB
TypeScript
973 lines
29 KiB
TypeScript
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
|
|
import { join } from 'path'
|
|
import { chromium, BrowserContext } from 'playwright'
|
|
import { existsSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
import { ScraperFactory } from './scrapers'
|
|
import { XiaoheiheScrap } from './scrapers/xiaoheihe'
|
|
import { GenericScraper } from './scrapers/generic'
|
|
import { PlatformServiceFactory } from './platforms'
|
|
import { XiaoheiheService } from './platforms/xiaoheihe'
|
|
|
|
let floatingWindow: BrowserWindow | null = null
|
|
let settingsWindow: BrowserWindow | null = null
|
|
let chatWindow: BrowserWindow | null = null
|
|
let toolsPanelWindow: BrowserWindow | null = null
|
|
|
|
// Initialize scraper factory
|
|
const scraperFactory = new ScraperFactory()
|
|
scraperFactory.register(new XiaoheiheScrap())
|
|
scraperFactory.register(new GenericScraper()) // Generic must be last (catch-all)
|
|
|
|
// Initialize platform service factory
|
|
const platformServiceFactory = new PlatformServiceFactory()
|
|
platformServiceFactory.register(new XiaoheiheService())
|
|
|
|
// Persistent browser context for maintaining login state
|
|
let persistentContext: BrowserContext | null = null
|
|
const userDataDir = join(app.getPath('userData'), 'browser-data')
|
|
let contextInitializing = false
|
|
let contextInitRetries = 0
|
|
const MAX_CONTEXT_INIT_RETRIES = 3
|
|
|
|
// Settings file path
|
|
const settingsDir = join(app.getPath('userData'), 'settings')
|
|
const settingsFilePath = join(settingsDir, 'config.json')
|
|
const loginInfoFilePath = join(settingsDir, 'login-info.json')
|
|
|
|
// Constants
|
|
const RATE_LIMIT_CONFIG = {
|
|
MIN_INTERVAL_MS: 3000, // 最小间隔 3 秒
|
|
MAX_SEARCH_PER_MINUTE: 10, // 每分钟最多 10 次
|
|
RESET_INTERVAL_MS: 60000, // 1 分钟重置计数
|
|
PAGE_TIMEOUT_MS: 30000, // 页面加载超时 30 秒
|
|
BROWSER_INIT_TIMEOUT_MS: 30000 // 浏览器初始化超时 30 秒
|
|
} as const
|
|
|
|
// Rate limiter for search operations
|
|
class SearchRateLimiter {
|
|
private lastSearchTime: number = 0
|
|
private searchCount: number = 0
|
|
private readonly minInterval: number = RATE_LIMIT_CONFIG.MIN_INTERVAL_MS
|
|
private readonly maxSearchPerMinute: number = RATE_LIMIT_CONFIG.MAX_SEARCH_PER_MINUTE
|
|
private readonly resetInterval: number = RATE_LIMIT_CONFIG.RESET_INTERVAL_MS
|
|
|
|
canSearch(): { allowed: boolean; waitTime?: number; reason?: string } {
|
|
const now = Date.now()
|
|
const timeSinceLastSearch = now - this.lastSearchTime
|
|
|
|
// 检查是否需要重置计数器
|
|
if (timeSinceLastSearch > this.resetInterval) {
|
|
this.searchCount = 0
|
|
}
|
|
|
|
// 检查是否超过频率限制
|
|
if (this.searchCount >= this.maxSearchPerMinute) {
|
|
const waitTime = this.resetInterval - timeSinceLastSearch
|
|
return {
|
|
allowed: false,
|
|
waitTime: Math.ceil(waitTime / 1000),
|
|
reason: '搜索过于频繁,请稍后再试'
|
|
}
|
|
}
|
|
|
|
// 检查最小间隔
|
|
if (timeSinceLastSearch < this.minInterval) {
|
|
const waitTime = this.minInterval - timeSinceLastSearch
|
|
return {
|
|
allowed: false,
|
|
waitTime: Math.ceil(waitTime / 1000),
|
|
reason: '请求过快,请稍后再试'
|
|
}
|
|
}
|
|
|
|
return { allowed: true }
|
|
}
|
|
|
|
recordSearch(): void {
|
|
this.lastSearchTime = Date.now()
|
|
this.searchCount++
|
|
}
|
|
|
|
reset(): void {
|
|
this.searchCount = 0
|
|
this.lastSearchTime = 0
|
|
}
|
|
}
|
|
|
|
const searchRateLimiter = new SearchRateLimiter()
|
|
|
|
// Ensure settings directory exists
|
|
function ensureSettingsDir(): void {
|
|
if (!existsSync(settingsDir)) {
|
|
mkdirSync(settingsDir, { recursive: true })
|
|
}
|
|
}
|
|
|
|
// Read settings from file
|
|
function readSettings(): any {
|
|
try {
|
|
ensureSettingsDir()
|
|
if (existsSync(settingsFilePath)) {
|
|
const data = readFileSync(settingsFilePath, 'utf-8')
|
|
return JSON.parse(data)
|
|
}
|
|
return {}
|
|
} catch (error) {
|
|
console.error('Failed to read settings:', error)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
// Write settings to file
|
|
function writeSettings(settings: any): { success: boolean; error?: string } {
|
|
try {
|
|
ensureSettingsDir()
|
|
writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2), 'utf-8')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Failed to write settings:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '保存设置失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read login info from file
|
|
function readLoginInfo(): any {
|
|
try {
|
|
ensureSettingsDir()
|
|
if (existsSync(loginInfoFilePath)) {
|
|
const data = readFileSync(loginInfoFilePath, 'utf-8')
|
|
return JSON.parse(data)
|
|
}
|
|
return {}
|
|
} catch (error) {
|
|
console.error('Failed to read login info:', error)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
// Write login info to file
|
|
function writeLoginInfo(loginInfo: any): { success: boolean; error?: string } {
|
|
try {
|
|
ensureSettingsDir()
|
|
writeFileSync(loginInfoFilePath, JSON.stringify(loginInfo, null, 2), 'utf-8')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Failed to write login info:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '保存登录信息失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
function createFloatingWindow(): void {
|
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
|
|
|
floatingWindow = new BrowserWindow({
|
|
width: 160,
|
|
height: 200,
|
|
x: width - 160,
|
|
y: Math.floor((height - 200) / 2),
|
|
frame: false,
|
|
transparent: true,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
resizable: false,
|
|
hasShadow: false,
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: false,
|
|
nodeIntegration: false,
|
|
contextIsolation: true
|
|
}
|
|
})
|
|
|
|
floatingWindow.setIgnoreMouseEvents(true, { forward: true })
|
|
floatingWindow.setAlwaysOnTop(true, 'floating')
|
|
floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
|
|
|
// Load the floating window HTML
|
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
|
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
|
} else {
|
|
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
|
|
}
|
|
}
|
|
|
|
function createSettingsWindow(): void {
|
|
// If settings window already exists, focus it
|
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
settingsWindow.focus()
|
|
return
|
|
}
|
|
|
|
settingsWindow = new BrowserWindow({
|
|
width: 900,
|
|
height: 600,
|
|
title: '设置',
|
|
resizable: false,
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: false,
|
|
nodeIntegration: false,
|
|
contextIsolation: true
|
|
}
|
|
})
|
|
|
|
// Load settings page
|
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
|
settingsWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings.html`)
|
|
} else {
|
|
settingsWindow.loadFile(join(__dirname, '../renderer/settings.html'))
|
|
}
|
|
|
|
settingsWindow.on('closed', () => {
|
|
settingsWindow = null
|
|
})
|
|
}
|
|
|
|
function createChatWindow(initialText?: string): void {
|
|
const startTime = Date.now()
|
|
console.log('[PERF] createChatWindow called at:', startTime)
|
|
|
|
// If chat window already exists, focus it and send new text if provided
|
|
if (chatWindow && !chatWindow.isDestroyed()) {
|
|
console.log('Chat window already exists, focusing and sending text')
|
|
chatWindow.focus()
|
|
if (initialText) {
|
|
// Add a small delay to ensure the renderer is ready
|
|
setTimeout(() => {
|
|
chatWindow?.webContents.send('set-initial-text', initialText)
|
|
console.log('Sent initial text to existing window:', initialText)
|
|
}, 100)
|
|
}
|
|
return
|
|
}
|
|
|
|
console.log('[PERF] Creating new chat window')
|
|
chatWindow = new BrowserWindow({
|
|
width: 800,
|
|
height: 600,
|
|
title: 'AI 对话',
|
|
show: false, // Hide window during load for better perceived performance
|
|
backgroundColor: '#ffffff', // Set background color to avoid flash
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: false,
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
spellcheck: false, // Disable spell check to avoid conflicts with IME
|
|
backgroundThrottling: false // Prevent throttling for better performance
|
|
}
|
|
})
|
|
|
|
// Load chat page
|
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
|
chatWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/chat.html`)
|
|
} else {
|
|
chatWindow.loadFile(join(__dirname, '../renderer/chat.html'))
|
|
}
|
|
|
|
// Show window when ready to avoid showing loading state
|
|
chatWindow.once('ready-to-show', () => {
|
|
const readyTime = Date.now()
|
|
console.log('[PERF] Chat window ready-to-show at:', readyTime)
|
|
console.log('[PERF] Time from create to ready:', readyTime - startTime, 'ms')
|
|
chatWindow?.show()
|
|
})
|
|
|
|
// Send initial text after page loads
|
|
if (initialText) {
|
|
console.log('Setting up did-finish-load listener for initial text')
|
|
chatWindow.webContents.once('did-finish-load', () => {
|
|
console.log('Chat window did-finish-load event fired')
|
|
// Add a small delay to ensure Vue components are mounted
|
|
setTimeout(() => {
|
|
if (chatWindow && !chatWindow.isDestroyed()) {
|
|
console.log('Sending initial text to new window:', initialText)
|
|
chatWindow.webContents.send('set-initial-text', initialText)
|
|
}
|
|
}, 200)
|
|
})
|
|
}
|
|
|
|
chatWindow.on('closed', () => {
|
|
chatWindow = null
|
|
})
|
|
}
|
|
|
|
// Create tools panel window
|
|
function createToolsPanelWindow(): void {
|
|
console.log('Creating tools panel window')
|
|
|
|
// If tools panel already exists, focus it
|
|
if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) {
|
|
console.log('Tools panel window already exists, focusing')
|
|
toolsPanelWindow.focus()
|
|
return
|
|
}
|
|
|
|
toolsPanelWindow = new BrowserWindow({
|
|
width: 900,
|
|
height: 700,
|
|
title: '工具箱',
|
|
show: false,
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: false,
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
spellcheck: false
|
|
}
|
|
})
|
|
|
|
// Load tools panel page
|
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
|
toolsPanelWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/tools.html`)
|
|
} else {
|
|
toolsPanelWindow.loadFile(join(__dirname, '../renderer/tools.html'))
|
|
}
|
|
|
|
// Show window when ready
|
|
toolsPanelWindow.once('ready-to-show', () => {
|
|
console.log('Tools panel window ready-to-show')
|
|
toolsPanelWindow?.show()
|
|
})
|
|
|
|
toolsPanelWindow.on('closed', () => {
|
|
toolsPanelWindow = null
|
|
})
|
|
}
|
|
|
|
// Fetch article content using Playwright with factory pattern
|
|
async function fetchArticleContent(url: string): Promise<{
|
|
title: string
|
|
author?: string
|
|
authorIp?: string
|
|
publishTime?: string
|
|
content: string
|
|
tags?: string[]
|
|
comments: Array<{
|
|
author: string
|
|
content: string
|
|
time?: string
|
|
replies?: Array<{ author: string; content: string; time?: string }>
|
|
}>
|
|
stats?: {
|
|
likes: number
|
|
favorites: number
|
|
commentCount: number
|
|
hotScore: number
|
|
}
|
|
}> {
|
|
console.log('fetchArticleContent: Starting to fetch article from URL:', url)
|
|
let browser
|
|
try {
|
|
console.log('fetchArticleContent: Launching browser...')
|
|
browser = await chromium.launch({ headless: true })
|
|
const context = await browser.newContext({
|
|
userAgent:
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
})
|
|
const page = await context.newPage()
|
|
|
|
console.log('fetchArticleContent: Navigating to URL...')
|
|
// Navigate to the URL
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: RATE_LIMIT_CONFIG.PAGE_TIMEOUT_MS })
|
|
|
|
// Get appropriate scraper for this URL
|
|
console.log('fetchArticleContent: Getting scraper for URL...')
|
|
const scraper = scraperFactory.getScraper(url)
|
|
if (!scraper) {
|
|
throw new Error('No suitable scraper found for this URL')
|
|
}
|
|
|
|
console.log('fetchArticleContent: Using scraper:', scraper.constructor.name)
|
|
// Use scraper to extract content
|
|
const result = await scraper.scrape(page)
|
|
console.log('fetchArticleContent: Scraping completed, article title:', result.title)
|
|
return result
|
|
} catch (error) {
|
|
console.error('fetchArticleContent: Error occurred:', error)
|
|
throw error
|
|
} finally {
|
|
// Always close browser in finally block to ensure cleanup
|
|
if (browser) {
|
|
try {
|
|
await browser.close()
|
|
console.log('fetchArticleContent: Browser closed')
|
|
} catch (closeError) {
|
|
console.error('Failed to close browser:', closeError)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get or create persistent browser context
|
|
async function getPersistentContext(headless = true): Promise<BrowserContext> {
|
|
// Return existing context if available and not closed
|
|
if (persistentContext && !persistentContext.pages().length) {
|
|
console.log('Browser context exists but has no pages, reinitializing')
|
|
try {
|
|
await persistentContext.close()
|
|
} catch (error) {
|
|
console.error('Error closing stale context:', error)
|
|
}
|
|
persistentContext = null
|
|
}
|
|
|
|
if (persistentContext) {
|
|
try {
|
|
// Test if context is still alive
|
|
await persistentContext.pages()
|
|
return persistentContext
|
|
} catch (error) {
|
|
console.error('Browser context is dead, reinitializing:', error)
|
|
persistentContext = null
|
|
}
|
|
}
|
|
|
|
// Prevent multiple simultaneous initialization attempts
|
|
if (contextInitializing) {
|
|
console.log('Context initialization in progress, waiting...')
|
|
// Wait for initialization to complete
|
|
while (contextInitializing && contextInitRetries < MAX_CONTEXT_INIT_RETRIES) {
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
if (persistentContext) {
|
|
return persistentContext
|
|
}
|
|
}
|
|
|
|
// Initialize new context with retry logic
|
|
contextInitializing = true
|
|
let lastError: Error | null = null
|
|
|
|
for (let attempt = 1; attempt <= MAX_CONTEXT_INIT_RETRIES; attempt++) {
|
|
try {
|
|
console.log(`Initializing browser context (attempt ${attempt}/${MAX_CONTEXT_INIT_RETRIES})`)
|
|
|
|
persistentContext = await chromium.launchPersistentContext(userDataDir, {
|
|
headless,
|
|
viewport: { width: 1280, height: 800 },
|
|
userAgent:
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
// Add timeout for initialization
|
|
timeout: RATE_LIMIT_CONFIG.BROWSER_INIT_TIMEOUT_MS
|
|
})
|
|
|
|
// Setup crash handler
|
|
persistentContext.on('close', () => {
|
|
console.log('Browser context closed')
|
|
persistentContext = null
|
|
contextInitRetries = 0
|
|
})
|
|
|
|
console.log('Browser context initialized successfully')
|
|
contextInitializing = false
|
|
contextInitRetries = 0
|
|
return persistentContext
|
|
} catch (error) {
|
|
lastError = error as Error
|
|
console.error(`Browser context initialization failed (attempt ${attempt}):`, error)
|
|
|
|
// Clean up failed context
|
|
if (persistentContext) {
|
|
try {
|
|
await persistentContext.close()
|
|
} catch (closeError) {
|
|
console.error('Error closing failed context:', closeError)
|
|
}
|
|
persistentContext = null
|
|
}
|
|
|
|
// Wait before retry (exponential backoff)
|
|
if (attempt < MAX_CONTEXT_INIT_RETRIES) {
|
|
const delayMs = 1000 * attempt
|
|
console.log(`Waiting ${delayMs}ms before retry...`)
|
|
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
}
|
|
}
|
|
}
|
|
|
|
contextInitializing = false
|
|
contextInitRetries++
|
|
|
|
throw new Error(
|
|
`Failed to initialize browser context after ${MAX_CONTEXT_INIT_RETRIES} attempts: ${lastError?.message}`
|
|
)
|
|
}
|
|
|
|
// Get login QR code
|
|
async function getLoginQrCode(): Promise<{
|
|
success: boolean
|
|
qrCodeDataUrl?: string
|
|
error?: string
|
|
}> {
|
|
try {
|
|
// 使用无头模式
|
|
const context = await getPersistentContext(true)
|
|
const service = new XiaoheiheService()
|
|
return await service.getLoginQrCode(context)
|
|
} catch (error) {
|
|
console.error('Get login QR code error:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '获取二维码失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for QR code login
|
|
async function waitForQrCodeLogin(): Promise<{
|
|
success: boolean
|
|
username?: string
|
|
error?: string
|
|
}> {
|
|
try {
|
|
if (!persistentContext) {
|
|
return { success: false, error: '浏览器上下文未初始化' }
|
|
}
|
|
const service = new XiaoheiheService()
|
|
const result = await service.waitForQrCodeLogin(persistentContext)
|
|
|
|
// Save login info if successful
|
|
if (result.success && result.username) {
|
|
const savedLoginInfo = readLoginInfo()
|
|
savedLoginInfo['www.xiaoheihe.cn'] = {
|
|
username: result.username,
|
|
lastUpdate: new Date().toISOString()
|
|
}
|
|
writeLoginInfo(savedLoginInfo)
|
|
console.log('Login info saved for user:', result.username)
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error('Wait for QR code login error:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '等待登录失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check login status for a platform (fast - cookie-based only)
|
|
async function checkPlatformLoginFast(url: string): Promise<{
|
|
success: boolean
|
|
isLoggedIn: boolean
|
|
username?: string
|
|
error?: string
|
|
}> {
|
|
try {
|
|
const service = platformServiceFactory.getService(url)
|
|
if (!service) {
|
|
return { success: false, isLoggedIn: false, error: '不支持的平台' }
|
|
}
|
|
|
|
const context = await getPersistentContext()
|
|
const loginStatus = await service.checkLoginStatusFast(context)
|
|
|
|
// 如果已登录,尝试从本地文件读取用户名(如果 service 没有返回用户名)
|
|
if (loginStatus.isLoggedIn && !loginStatus.username) {
|
|
const savedLoginInfo = readLoginInfo()
|
|
const platformKey = url.replace(/https?:\/\//, '').split('/')[0]
|
|
if (savedLoginInfo[platformKey]?.username) {
|
|
loginStatus.username = savedLoginInfo[platformKey].username
|
|
}
|
|
}
|
|
|
|
// 如果已登录且有用户名,保存到本地文件
|
|
if (loginStatus.isLoggedIn && loginStatus.username) {
|
|
const savedLoginInfo = readLoginInfo()
|
|
const platformKey = url.replace(/https?:\/\//, '').split('/')[0]
|
|
savedLoginInfo[platformKey] = {
|
|
username: loginStatus.username,
|
|
lastUpdate: new Date().toISOString()
|
|
}
|
|
writeLoginInfo(savedLoginInfo)
|
|
}
|
|
|
|
return { success: true, ...loginStatus }
|
|
} catch (error) {
|
|
console.error('Check platform login fast error:', error)
|
|
return {
|
|
success: false,
|
|
isLoggedIn: false,
|
|
error: error instanceof Error ? error.message : '检查登录状态失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check login status for a platform
|
|
async function checkPlatformLogin(url: string): Promise<{
|
|
success: boolean
|
|
isLoggedIn: boolean
|
|
username?: string
|
|
error?: string
|
|
}> {
|
|
let page: Awaited<ReturnType<BrowserContext['newPage']>> | undefined
|
|
try {
|
|
const service = platformServiceFactory.getService(url)
|
|
if (!service) {
|
|
return { success: false, isLoggedIn: false, error: '不支持的平台' }
|
|
}
|
|
|
|
const context = await getPersistentContext()
|
|
page = await context.newPage()
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: RATE_LIMIT_CONFIG.PAGE_TIMEOUT_MS })
|
|
|
|
const loginStatus = await service.checkLoginStatus(page)
|
|
|
|
return { success: true, ...loginStatus }
|
|
} catch (error) {
|
|
console.error('Check platform login error:', error)
|
|
return {
|
|
success: false,
|
|
isLoggedIn: false,
|
|
error: error instanceof Error ? error.message : '检查登录状态失败'
|
|
}
|
|
} finally {
|
|
// Always close page in finally block to ensure cleanup
|
|
if (page) {
|
|
try {
|
|
await page.close()
|
|
} catch (closeError) {
|
|
console.error('Failed to close page:', closeError)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Post comment to platform
|
|
async function postCommentToPlatform(
|
|
url: string,
|
|
comment: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
console.log('postCommentToPlatform: Starting with URL:', url)
|
|
console.log('postCommentToPlatform: Comment length:', comment?.length)
|
|
|
|
const service = platformServiceFactory.getService(url)
|
|
if (!service) {
|
|
console.error('postCommentToPlatform: No service found for URL')
|
|
return { success: false, error: '不支持的平台' }
|
|
}
|
|
console.log('postCommentToPlatform: Service found')
|
|
|
|
console.log('postCommentToPlatform: Getting persistent context...')
|
|
const context = await getPersistentContext()
|
|
console.log('postCommentToPlatform: Context obtained, calling service.postComment...')
|
|
|
|
const result = await service.postComment(context, url, comment)
|
|
console.log('postCommentToPlatform: Result from service.postComment:', JSON.stringify(result))
|
|
return result
|
|
} catch (error) {
|
|
console.error('postCommentToPlatform: Exception occurred:', error)
|
|
console.error(
|
|
'postCommentToPlatform: Error stack:',
|
|
error instanceof Error ? error.stack : 'No stack trace'
|
|
)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '发送评论失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search platform content
|
|
async function searchPlatform(
|
|
platform: string,
|
|
query: string
|
|
): Promise<{
|
|
success: boolean
|
|
results?: Array<{
|
|
title: string
|
|
url: string
|
|
author?: string
|
|
publishTime?: string
|
|
summary?: string
|
|
commentCount?: number
|
|
likeCount?: number
|
|
}>
|
|
error?: string
|
|
}> {
|
|
try {
|
|
console.log('searchPlatform: Starting search on platform:', platform)
|
|
console.log('searchPlatform: Query:', query)
|
|
|
|
// 检查搜索频率限制
|
|
const rateLimitCheck = searchRateLimiter.canSearch()
|
|
if (!rateLimitCheck.allowed) {
|
|
console.log('searchPlatform: Rate limit exceeded')
|
|
return {
|
|
success: false,
|
|
error: 'RATE_LIMIT_EXCEEDED',
|
|
results: []
|
|
}
|
|
}
|
|
|
|
// 根据平台名称构造一个URL来获取对应的服务
|
|
const platformUrls: Record<string, string> = {
|
|
xiaoheihe: 'https://www.xiaoheihe.cn'
|
|
}
|
|
|
|
const platformUrl = platformUrls[platform]
|
|
if (!platformUrl) {
|
|
return { success: false, error: '不支持的平台' }
|
|
}
|
|
|
|
const service = platformServiceFactory.getService(platformUrl)
|
|
if (!service) {
|
|
return { success: false, error: '未找到平台服务' }
|
|
}
|
|
|
|
console.log('searchPlatform: Getting persistent context...')
|
|
const context = await getPersistentContext()
|
|
console.log('searchPlatform: Context obtained, calling service.search...')
|
|
|
|
// 记录本次搜索
|
|
searchRateLimiter.recordSearch()
|
|
|
|
const result = await service.search(context, query)
|
|
console.log('searchPlatform: Search completed, found', result.results?.length || 0, 'results')
|
|
return result
|
|
} catch (error) {
|
|
console.error('searchPlatform: Exception occurred:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '搜索失败'
|
|
}
|
|
}
|
|
}
|
|
|
|
function registerGlobalShortcuts(): void {
|
|
// Register Command+K (Mac) or Ctrl+K (Windows/Linux)
|
|
const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K'
|
|
|
|
const registered = globalShortcut.register(shortcut, () => {
|
|
if (floatingWindow && !floatingWindow.isDestroyed()) {
|
|
// Read clipboard content (user should copy text with Command+C first)
|
|
// We only read the main clipboard, not the selection clipboard
|
|
const text = clipboard.readText()
|
|
|
|
console.log('Command+K pressed, clipboard content:', text?.substring(0, 50))
|
|
|
|
// Always send the event to toggle action menu
|
|
// Pass clipboard text (empty string if clipboard is empty)
|
|
floatingWindow.webContents.send('show-text-prompt', text || '')
|
|
floatingWindow.focus()
|
|
}
|
|
})
|
|
|
|
if (!registered) {
|
|
console.error('Global shortcut registration failed')
|
|
}
|
|
}
|
|
|
|
// This method will be called when Electron has finished
|
|
// initialization and is ready to create browser windows.
|
|
// Some APIs can only be used after this event occurs.
|
|
app.whenReady().then(() => {
|
|
// IPC test
|
|
ipcMain.on('ping', () => console.log('pong'))
|
|
|
|
// Handle mouse enter/leave events to control click-through
|
|
ipcMain.on('set-ignore-mouse-events', (_, ignore: boolean, options?: { forward: boolean }) => {
|
|
if (floatingWindow) {
|
|
floatingWindow.setIgnoreMouseEvents(ignore, options)
|
|
}
|
|
})
|
|
|
|
// Handle open settings from renderer
|
|
ipcMain.on('open-settings', () => {
|
|
createSettingsWindow()
|
|
})
|
|
|
|
// Handle open tools panel from renderer
|
|
ipcMain.on('open-tools-panel', () => {
|
|
createToolsPanelWindow()
|
|
})
|
|
|
|
// Handle close tools panel from renderer
|
|
ipcMain.on('close-tools-panel', () => {
|
|
if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) {
|
|
toolsPanelWindow.close()
|
|
}
|
|
})
|
|
|
|
// Handle open chat window from renderer
|
|
ipcMain.on('open-chat', (_, selectedText?: string) => {
|
|
console.log('open-chat event received, selectedText:', selectedText)
|
|
createChatWindow(selectedText)
|
|
})
|
|
|
|
// Handle quit app from renderer
|
|
ipcMain.on('quit-app', () => {
|
|
app.quit()
|
|
})
|
|
|
|
// Handle window drag
|
|
ipcMain.on('floating-window-move', (_, { x, y }) => {
|
|
if (floatingWindow) {
|
|
floatingWindow.setPosition(x, y)
|
|
}
|
|
})
|
|
|
|
// Handle get window bounds
|
|
ipcMain.handle('get-window-bounds', () => {
|
|
if (floatingWindow) {
|
|
const bounds = floatingWindow.getBounds()
|
|
return { x: bounds.x, y: bounds.y }
|
|
}
|
|
return { x: 0, y: 0 }
|
|
})
|
|
|
|
// Handle fetch article content
|
|
ipcMain.handle('fetch-article', async (_, url: string) => {
|
|
try {
|
|
const result = await fetchArticleContent(url)
|
|
return { success: true, ...result }
|
|
} catch (error) {
|
|
console.error('Failed to fetch article:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '抓取文章失败'
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle check platform login status (fast - cookie-based only)
|
|
ipcMain.handle('check-platform-login-fast', async (_, url: string) => {
|
|
return await checkPlatformLoginFast(url)
|
|
})
|
|
|
|
// Handle check platform login status (accepts platform name or URL)
|
|
ipcMain.handle('check-platform-login', async (_, arg: string | { platform: string }) => {
|
|
// Support both old format (URL string) and new format (object with platform)
|
|
let url: string
|
|
if (typeof arg === 'string') {
|
|
url = arg
|
|
} else {
|
|
const platformUrls: Record<string, string> = {
|
|
xiaoheihe: 'https://www.xiaoheihe.cn'
|
|
}
|
|
url = platformUrls[arg.platform]
|
|
if (!url) {
|
|
return { success: false, isLoggedIn: false, error: '不支持的平台' }
|
|
}
|
|
}
|
|
return await checkPlatformLoginFast(url)
|
|
})
|
|
|
|
// Handle post comment to platform
|
|
ipcMain.handle('post-comment', async (_, { url, comment }: { url: string; comment: string }) => {
|
|
return await postCommentToPlatform(url, comment)
|
|
})
|
|
|
|
// Handle get login QR code
|
|
ipcMain.handle('get-login-qrcode', async () => {
|
|
return await getLoginQrCode()
|
|
})
|
|
|
|
// Handle wait for QR code login
|
|
ipcMain.handle('wait-qrcode-login', async () => {
|
|
return await waitForQrCodeLogin()
|
|
})
|
|
|
|
// Handle logout
|
|
ipcMain.handle('logout-platform', async (_, platform: string) => {
|
|
try {
|
|
if (platform === 'xiaoheihe') {
|
|
// Close the persistent context to clear cookies and session
|
|
if (persistentContext) {
|
|
await persistentContext.close()
|
|
persistentContext = null
|
|
}
|
|
|
|
// Delete the user data directory to completely clear all browser data
|
|
if (existsSync(userDataDir)) {
|
|
console.log('Deleting user data directory:', userDataDir)
|
|
rmSync(userDataDir, { recursive: true, force: true })
|
|
console.log('User data directory deleted successfully')
|
|
}
|
|
|
|
// Clear saved login info from file
|
|
const savedLoginInfo = readLoginInfo()
|
|
delete savedLoginInfo['www.xiaoheihe.cn']
|
|
writeLoginInfo(savedLoginInfo)
|
|
console.log('Login info cleared for xiaoheihe')
|
|
|
|
return { success: true }
|
|
}
|
|
return { success: false, error: '不支持的平台' }
|
|
} catch (error) {
|
|
console.error('Logout error:', error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '退出登录失败'
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle search platform
|
|
ipcMain.handle(
|
|
'search-platform',
|
|
async (_, { platform, query }: { platform: string; query: string }) => {
|
|
return await searchPlatform(platform, query)
|
|
}
|
|
)
|
|
|
|
// Handle read settings
|
|
ipcMain.handle('read-settings', () => {
|
|
return readSettings()
|
|
})
|
|
|
|
// Handle write settings
|
|
ipcMain.handle('write-settings', (_, settings: any) => {
|
|
return writeSettings(settings)
|
|
})
|
|
|
|
createFloatingWindow()
|
|
registerGlobalShortcuts()
|
|
|
|
app.on('activate', function () {
|
|
// On macOS it's common to re-create a window in the app when the
|
|
// dock icon is clicked and there are no other windows open.
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createFloatingWindow()
|
|
}
|
|
})
|
|
})
|
|
|
|
// Quit when all windows are closed, except on macOS. There, it's common
|
|
// for applications and their menu bar to stay active until the user quits
|
|
// explicitly with Cmd + Q.
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit()
|
|
}
|
|
})
|
|
|
|
// Unregister all shortcuts when app is about to quit
|
|
app.on('will-quit', async () => {
|
|
globalShortcut.unregisterAll()
|
|
|
|
// Clean up browser context
|
|
if (persistentContext) {
|
|
console.log('Cleaning up browser context on app quit')
|
|
try {
|
|
await persistentContext.close()
|
|
persistentContext = null
|
|
} catch (error) {
|
|
console.error('Error closing browser context on quit:', error)
|
|
}
|
|
}
|
|
})
|
|
|
|
// In this file you can include the rest of your app's specific main process
|
|
// code. You can also put them in separate files and require them here.
|