新增小黑盒内容爬取,AI消息自动发送评论

This commit is contained in:
2025-11-12 13:48:17 +08:00
parent 1094191020
commit 7b8d31f899
13 changed files with 2191 additions and 493 deletions
+318 -72
View File
@@ -1,19 +1,37 @@
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
import { join } from 'path'
import { chromium, BrowserContext } from 'playwright'
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 floatingWindowReady = false
let tooltipOpenCache = false
let chatWindow: 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')
function createFloatingWindow(): void {
const { width } = screen.getPrimaryDisplay().workAreaSize
floatingWindow = new BrowserWindow({
width: 400,
height: 400,
x: width - 420,
y: 60,
width: 240,
height: 210,
x: width - 100,
y: 20,
frame: false,
transparent: true,
alwaysOnTop: true,
@@ -32,63 +50,12 @@ function createFloatingWindow(): void {
floatingWindow.setAlwaysOnTop(true, 'floating')
floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
// Mark window as ready when content is loaded
floatingWindow.webContents.on('did-finish-load', () => {
floatingWindowReady = true
})
// 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 quit app from renderer
ipcMain.on('quit-app', () => {
app.quit()
})
// 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'))
}
// 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 }
})
// Listen for tooltip state changes from renderer to update cache
ipcMain.on('tooltip-state-changed', (_, isOpen: boolean) => {
tooltipOpenCache = isOpen
})
// Handle check if tooltip is open
ipcMain.handle('is-tooltip-open', () => {
if (floatingWindow) {
return floatingWindow.webContents.executeJavaScript('window.__tooltipOpen || false')
}
return false
})
}
function createSettingsWindow(): void {
@@ -123,28 +90,234 @@ function createSettingsWindow(): void {
})
}
function createChatWindow(initialText?: string): void {
// If chat window already exists, focus it and send new text if provided
if (chatWindow && !chatWindow.isDestroyed()) {
chatWindow.focus()
if (initialText) {
chatWindow.webContents.send('set-initial-text', initialText)
}
return
}
chatWindow = new BrowserWindow({
width: 800,
height: 600,
title: 'AI 对话',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: false,
contextIsolation: true
}
})
// 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'))
}
// Send initial text after page loads
if (initialText) {
chatWindow.webContents.once('did-finish-load', () => {
chatWindow?.webContents.send('set-initial-text', initialText)
})
}
chatWindow.on('closed', () => {
chatWindow = 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
}
}> {
let browser
try {
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()
// Navigate to the URL
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
// Get appropriate scraper for this URL
const scraper = scraperFactory.getScraper(url)
if (!scraper) {
throw new Error('No suitable scraper found for this URL')
}
// Use scraper to extract content
const result = await scraper.scrape(page)
await browser.close()
return result
} catch (error) {
if (browser) {
await browser.close()
}
throw error
}
}
// Get or create persistent browser context
async function getPersistentContext(headless = true): Promise<BrowserContext> {
if (!persistentContext) {
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'
})
}
return persistentContext
}
// 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()
return await service.waitForQrCodeLogin(persistentContext)
} 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
async function checkPlatformLogin(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 page = await context.newPage()
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
const loginStatus = await service.checkLoginStatus(page)
await page.close()
return { success: true, ...loginStatus }
} catch (error) {
console.error('Check platform login error:', error)
return {
success: false,
isLoggedIn: false,
error: error instanceof Error ? error.message : '检查登录状态失败'
}
}
}
// 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 : '发送评论失败'
}
}
}
function registerGlobalShortcuts(): void {
// Register Cmd+K (Mac) or Ctrl+K (Windows/Linux)
// 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() && floatingWindowReady) {
// Get clipboard text immediately for faster response
if (floatingWindow && !floatingWindow.isDestroyed()) {
// Get clipboard text for selected text
const selectedText = clipboard.readText('selection')
const text = selectedText || clipboard.readText()
// Use cached state for instant response
if (tooltipOpenCache) {
// If tooltip is open, close it
tooltipOpenCache = false
floatingWindow.webContents.send('close-tooltip')
} else {
// If tooltip is closed, open it with selected text
if (text && text.trim()) {
tooltipOpenCache = true
floatingWindow.webContents.send('show-text-action-prompt', text)
floatingWindow.focus()
}
if (text && text.trim()) {
// Send event to renderer to show prompt
floatingWindow.webContents.send('show-text-prompt', text.trim())
floatingWindow.focus()
}
}
})
@@ -161,6 +334,79 @@ 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 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
ipcMain.handle('check-platform-login', async (_, url: string) => {
return await checkPlatformLogin(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()
})
createFloatingWindow()
registerGlobalShortcuts()