From 3f6d10061dbbddb4c6753db44c5eb19c60737a16 Mon Sep 17 00:00:00 2001 From: dmy Date: Wed, 14 Jan 2026 16:25:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8D=95=E6=BA=90?= =?UTF-8?q?=E7=88=AC=E5=8F=96=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增单源爬取功能,支持在界面上单独更新每个数据源 添加数据库同步脚本,支持主从数据库结构同步和数据同步 优化华能集团爬虫的页面导航和稳定性 新增系统托盘功能,支持最小化到托盘 --- .env | 8 + .env.example | 8 + frontend/src/components/CrawlInfo.vue | 37 +++ src/crawler/crawler.controller.ts | 30 ++- src/crawler/services/bid-crawler.service.ts | 91 +++++++ src/crawler/services/chng_target.ts | 112 ++++++--- src/scripts/sync.ts | 252 ++++++++++++++++++++ widget/looker/sys_run/systray_run.go | 194 +++++++++++++++ 8 files changed, 691 insertions(+), 41 deletions(-) create mode 100644 src/scripts/sync.ts create mode 100644 widget/looker/sys_run/systray_run.go diff --git a/.env b/.env index 2c9dc3c..effc718 100644 --- a/.env +++ b/.env @@ -6,6 +6,14 @@ DATABASE_PASSWORD=410491 DATABASE_NAME=bidding DATABASE_SYNCHRONIZE=true +# Slave 数据库配置(用于数据同步) +SLAVE_DATABASE_TYPE=mysql +SLAVE_DATABASE_HOST=bj-cynosdbmysql-grp-r3a4c658.sql.tencentcdb.com +SLAVE_DATABASE_PORT=21741 +SLAVE_DATABASE_USERNAME=root +SLAVE_DATABASE_PASSWORD=}?cRa1f[,}`J +SLAVE_DATABASE_NAME=bidding + # 代理配置(可选) PROXY_HOST=127.0.0.1 PROXY_PORT=3211 diff --git a/.env.example b/.env.example index 2f36d00..f04c85d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,14 @@ DATABASE_PASSWORD=root DATABASE_NAME=bidding DATABASE_SYNCHRONIZE=true +# Slave 数据库配置(用于数据同步) +SLAVE_DATABASE_TYPE=mariadb +SLAVE_DATABASE_HOST=localhost +SLAVE_DATABASE_PORT=3306 +SLAVE_DATABASE_USERNAME=root +SLAVE_DATABASE_PASSWORD=root +SLAVE_DATABASE_NAME=bidding_slave + # 代理配置(可选) PROXY_HOST=127.0.0.1 PROXY_PORT=6000 diff --git a/frontend/src/components/CrawlInfo.vue b/frontend/src/components/CrawlInfo.vue index 0830bc6..6f0690d 100644 --- a/frontend/src/components/CrawlInfo.vue +++ b/frontend/src/components/CrawlInfo.vue @@ -37,6 +37,20 @@ - + + +
@@ -75,6 +89,7 @@ interface CrawlStat { const crawlStats = ref([]) const loading = ref(false) +const crawlingSources = ref>(new Set()) const totalCount = computed(() => { return crawlStats.value.reduce((sum, item) => sum + item.count, 0) @@ -113,6 +128,28 @@ const fetchCrawlStats = async () => { } } +const crawlSingleSource = async (sourceName: string) => { + crawlingSources.value.add(sourceName) + try { + ElMessage.info(`正在更新 ${sourceName}...`) + const res = await axios.post(`/api/crawler/crawl/${encodeURIComponent(sourceName)}`) + + if (res.data.success) { + ElMessage.success(`${sourceName} 更新成功,获取 ${res.data.count} 条数据`) + } else { + ElMessage.error(`${sourceName} 更新失败: ${res.data.error || '未知错误'}`) + } + + // 刷新统计数据 + await fetchCrawlStats() + } catch (error) { + console.error('Failed to crawl single source:', error) + ElMessage.error(`${sourceName} 更新失败`) + } finally { + crawlingSources.value.delete(sourceName) + } +} + onMounted(() => { fetchCrawlStats() }) diff --git a/src/crawler/crawler.controller.ts b/src/crawler/crawler.controller.ts index 1b59178..8b2bd33 100644 --- a/src/crawler/crawler.controller.ts +++ b/src/crawler/crawler.controller.ts @@ -1,15 +1,19 @@ -import { Controller, Post, Get } from '@nestjs/common'; +import { Controller, Post, Get, Param, Body } from '@nestjs/common'; import { BidCrawlerService } from './services/bid-crawler.service'; @Controller('api/crawler') export class CrawlerController { private isCrawling = false; + private crawlingSources = new Set(); constructor(private readonly crawlerService: BidCrawlerService) {} @Get('status') getStatus() { - return { isCrawling: this.isCrawling }; + return { + isCrawling: this.isCrawling, + crawlingSources: Array.from(this.crawlingSources) + }; } @Post('run') @@ -20,12 +24,12 @@ export class CrawlerController { this.isCrawling = true; - // We don't await this because we want it to run in the background + // We don't await this because we want it to run in the background // and return immediately, or we can await if we want to user to wait. // Given the requirement "Immediate Crawl", usually implies triggering it. // However, for a better UI experience, we might want to wait or just trigger. - // Let's await it so that user knows when it's done (or failed), - // assuming it doesn't take too long for the mock. + // Let's await it so that user knows when it's done (or failed), + // assuming it doesn't take too long for the mock. // Real crawling might take long, so background is better. // For this prototype, I'll await it to show completion. try { @@ -35,4 +39,20 @@ export class CrawlerController { this.isCrawling = false; } } + + @Post('crawl/:sourceName') + async crawlSingleSource(@Param('sourceName') sourceName: string) { + if (this.crawlingSources.has(sourceName)) { + return { message: `Source ${sourceName} is already being crawled` }; + } + + this.crawlingSources.add(sourceName); + + try { + const result = await this.crawlerService.crawlSingleSource(sourceName); + return result; + } finally { + this.crawlingSources.delete(sourceName); + } + } } diff --git a/src/crawler/services/bid-crawler.service.ts b/src/crawler/services/bid-crawler.service.ts index a962715..a59e06e 100644 --- a/src/crawler/services/bid-crawler.service.ts +++ b/src/crawler/services/bid-crawler.service.ts @@ -216,6 +216,97 @@ export class BidCrawlerService { } } + async crawlSingleSource(sourceName: string) { + this.logger.log(`Starting single source crawl for: ${sourceName}`); + + // 从环境变量读取代理配置 + const proxyHost = this.configService.get('PROXY_HOST'); + const proxyPort = this.configService.get('PROXY_PORT'); + const proxyUsername = this.configService.get('PROXY_USERNAME'); + const proxyPassword = this.configService.get('PROXY_PASSWORD'); + + // 构建代理参数 + const args = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-blink-features=AutomationControlled', + '--disable-infobars', + '--window-position=0,0', + '--ignore-certifcate-errors', + '--ignore-certifcate-errors-spki-list', + ]; + + if (proxyHost && proxyPort) { + const proxyUrl = proxyUsername && proxyPassword + ? `http://${proxyUsername}:${proxyPassword}@${proxyHost}:${proxyPort}` + : `http://${proxyHost}:${proxyPort}`; + args.push(`--proxy-server=${proxyUrl}`); + this.logger.log(`Using proxy: ${proxyHost}:${proxyPort}`); + } + + const browser = await puppeteer.launch({ + headless: false, + args, + }); + + const crawlers = [ChdtpCrawler, ChngCrawler, SzecpCrawler, CdtCrawler, EpsCrawler, CnncecpCrawler, CgnpcCrawler, CeicCrawler, EspicCrawler, PowerbeijingCrawler, SdiccCrawler, CnoocCrawler]; + + const targetCrawler = crawlers.find(c => c.name === sourceName); + + if (!targetCrawler) { + await browser.close(); + throw new Error(`Crawler not found for source: ${sourceName}`); + } + + try { + this.logger.log(`Crawling: ${targetCrawler.name}`); + + const results = await targetCrawler.crawl(browser); + this.logger.log(`Extracted ${results.length} items from ${targetCrawler.name}`); + + // 获取最新的发布日期 + const latestPublishDate = results.length > 0 + ? results.reduce((latest, item) => { + const itemDate = new Date(item.publishDate); + return itemDate > latest ? itemDate : latest; + }, new Date(0)) + : null; + + for (const item of results) { + await this.bidsService.createOrUpdate({ + title: item.title, + url: item.url, + publishDate: item.publishDate, + source: targetCrawler.name, + }); + } + + // 保存爬虫统计信息到数据库 + await this.saveCrawlInfo(targetCrawler.name, results.length, latestPublishDate); + + return { + success: true, + source: targetCrawler.name, + count: results.length, + latestPublishDate, + }; + } catch (err) { + this.logger.error(`Error crawling ${targetCrawler.name}: ${err.message}`); + + // 保存错误信息到数据库 + await this.saveCrawlInfo(targetCrawler.name, 0, null, err.message); + + return { + success: false, + source: targetCrawler.name, + count: 0, + error: err.message, + }; + } finally { + await browser.close(); + } + } + private async saveCrawlInfo( source: string, count: number, diff --git a/src/crawler/services/chng_target.ts b/src/crawler/services/chng_target.ts index 2604cef..d069ace 100644 --- a/src/crawler/services/chng_target.ts +++ b/src/crawler/services/chng_target.ts @@ -4,53 +4,75 @@ import { ChdtpResult } from './chdtp_target'; // 模拟人类鼠标移动 async function simulateHumanMouseMovement(page: puppeteer.Page) { - const viewport = page.viewport(); - if (!viewport) return; + try { + const viewport = page.viewport(); + if (!viewport) return; - const movements = 5 + Math.floor(Math.random() * 5); // 5-10次随机移动 + const movements = 5 + Math.floor(Math.random() * 5); // 5-10次随机移动 - for (let i = 0; i < movements; i++) { - const x = Math.floor(Math.random() * viewport.width); - const y = Math.floor(Math.random() * viewport.height); - - await page.mouse.move(x, y, { - steps: 10 + Math.floor(Math.random() * 20) // 10-30步,使移动更平滑 - }); - - // 随机停顿 100-500ms - await new Promise(r => setTimeout(r, 100 + Math.random() * 400)); + for (let i = 0; i < movements; i++) { + // 检查页面是否仍然有效 + if (page.isClosed()) { + console.log('Page was closed during mouse movement simulation'); + return; + } + + const x = Math.floor(Math.random() * viewport.width); + const y = Math.floor(Math.random() * viewport.height); + + await page.mouse.move(x, y, { + steps: 10 + Math.floor(Math.random() * 20) // 10-30步,使移动更平滑 + }); + + // 随机停顿 100-500ms + await new Promise(r => setTimeout(r, 100 + Math.random() * 400)); + } + } catch (error) { + console.log('Mouse movement simulation interrupted:', error.message); } } // 模拟人类滚动 async function simulateHumanScrolling(page: puppeteer.Page) { - const scrollCount = 3 + Math.floor(Math.random() * 5); // 3-7次滚动 + try { + const scrollCount = 3 + Math.floor(Math.random() * 5); // 3-7次滚动 - for (let i = 0; i < scrollCount; i++) { - const scrollDistance = 100 + Math.floor(Math.random() * 400); // 100-500px - - await page.evaluate((distance) => { - window.scrollBy({ - top: distance, - behavior: 'smooth' + for (let i = 0; i < scrollCount; i++) { + // 检查页面是否仍然有效 + if (page.isClosed()) { + console.log('Page was closed during scrolling simulation'); + return; + } + + const scrollDistance = 100 + Math.floor(Math.random() * 400); // 100-500px + + await page.evaluate((distance) => { + window.scrollBy({ + top: distance, + behavior: 'smooth' + }); + }, scrollDistance); + + // 随机停顿 500-1500ms + await new Promise(r => setTimeout(r, 500 + Math.random() * 1000)); + } + + // 滚动回顶部 + if (!page.isClosed()) { + await page.evaluate(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); }); - }, scrollDistance); - - // 随机停顿 500-1500ms - await new Promise(r => setTimeout(r, 500 + Math.random() * 1000)); + await new Promise(r => setTimeout(r, 1000)); + } + } catch (error) { + console.log('Scrolling simulation interrupted:', error.message); } - - // 滚动回顶部 - await page.evaluate(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - }); - await new Promise(r => setTimeout(r, 1000)); } export const ChngCrawler = { name: '华能集团电子商务平台', - url: 'https://ec.chng.com.cn/ecmall/index.html#/purchase/home?top=0', - baseUrl: 'https://ec.chng.com.cn/ecmall/index.html', + url: 'https://ec.chng.com.cn/channel/home/#/purchase?top=0', + baseUrl: 'https://ec.chng.com.cn/channel/home/#', async crawl(browser: puppeteer.Browser): Promise { const logger = new Logger('ChngCrawler'); @@ -106,14 +128,16 @@ export const ChngCrawler = { await page.authenticate({ username, password }); } } - // 模拟人类行为 + // 模拟人类行为 logger.log('Simulating human mouse movements...'); await simulateHumanMouseMovement(page); logger.log('Simulating human scrolling...'); await simulateHumanScrolling(page); - await page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {}); + // 等待页面稳定,不强制等待导航 + await new Promise(r => setTimeout(r, 3000)); + // 模拟人类行为 logger.log('Simulating human mouse movements...'); await simulateHumanMouseMovement(page); @@ -215,8 +239,24 @@ export const ChngCrawler = { const nextButton = await page.$('svg[data-icon="right"]'); if (!nextButton) break; + // 点击下一页前保存当前页面状态 + const currentUrl = page.url(); + await nextButton.click(); - await new Promise(r => setTimeout(r, 5000)); + + // 等待页面导航完成 + try { + await page.waitForFunction( + (oldUrl) => window.location.href !== oldUrl, + { timeout: 10000 }, + currentUrl + ); + } catch (e) { + logger.warn('Navigation timeout, continuing anyway'); + } + + // 等待页面内容加载 + await new Promise(r => setTimeout(r, 15000)); currentPage++; } diff --git a/src/scripts/sync.ts b/src/scripts/sync.ts new file mode 100644 index 0000000..6ca6e92 --- /dev/null +++ b/src/scripts/sync.ts @@ -0,0 +1,252 @@ +import 'dotenv/config'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import mysql from 'mysql2/promise'; +import { BidItem } from '../bids/entities/bid-item.entity'; +import { Keyword } from '../keywords/keyword.entity'; +import { AiRecommendation } from '../ai/entities/ai-recommendation.entity'; +import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity'; + +// 主数据库配置 +const masterDbConfig: DataSourceOptions = { + type: process.env.DATABASE_TYPE as any || 'mariadb', + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '3306'), + username: process.env.DATABASE_USERNAME || 'root', + password: process.env.DATABASE_PASSWORD || 'root', + database: process.env.DATABASE_NAME || 'bidding', + entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd], + synchronize: false, +}; + +// Slave 数据库配置 +const slaveDbConfig: DataSourceOptions = { + type: process.env.SLAVE_DATABASE_TYPE as any || 'mariadb', + host: process.env.SLAVE_DATABASE_HOST || 'localhost', + port: parseInt(process.env.SLAVE_DATABASE_PORT || '3306'), + username: process.env.SLAVE_DATABASE_USERNAME || 'root', + password: process.env.SLAVE_DATABASE_PASSWORD || 'root', + database: process.env.SLAVE_DATABASE_NAME || 'bidding_slave', + entities: [BidItem, Keyword, AiRecommendation, CrawlInfoAdd], + synchronize: false, +}; + +// 日志工具 +const logger = { + log: (message: string, ...args: any[]) => { + console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args); + }, + error: (message: string, ...args: any[]) => { + console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args); + }, + warn: (message: string, ...args: any[]) => { + console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args); + }, +}; + +// 同步单个表的数据 +async function syncTable( + masterDataSource: DataSource, + slaveDataSource: DataSource, + entityClass: any, + tableName: string, +): Promise { + const masterRepo = masterDataSource.getRepository(entityClass); + const slaveRepo = slaveDataSource.getRepository(entityClass); + + logger.log(`开始同步表: ${tableName}`); + + // 从主数据库获取所有数据 + const masterData = await masterRepo.find(); + logger.log(`主数据库 ${tableName} 表中有 ${masterData.length} 条记录`); + + // 从 slave 数据库获取所有数据 + const slaveData = await slaveRepo.find(); + logger.log(`Slave 数据库 ${tableName} 表中有 ${slaveData.length} 条记录`); + + // 创建主数据库记录的 ID 集合 + const masterIds = new Set(masterData.map((item: any) => item.id)); + + // 删除 slave 数据库中不存在于主数据库的记录 + const toDelete = slaveData.filter((item: any) => !masterIds.has(item.id)); + if (toDelete.length > 0) { + await slaveRepo.remove(toDelete); + logger.log(`从 slave 数据库删除了 ${toDelete.length} 条 ${tableName} 记录`); + } + + // 同步数据:使用 save 方法进行 upsert 操作 + let syncedCount = 0; + for (const item of masterData) { + await slaveRepo.save(item); + syncedCount++; + } + + logger.log(`成功同步 ${syncedCount} 条 ${tableName} 记录到 slave 数据库`); + + return syncedCount; +} + +// 创建数据库(如果不存在) +async function createDatabaseIfNotExists(config: DataSourceOptions) { + const connection = await mysql.createConnection({ + host: (config as any).host, + port: (config as any).port, + user: (config as any).username, + password: (config as any).password, + }); + + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${(config as any).database}\``); + await connection.end(); +} + +// 同步表结构 +async function syncSchema(masterDataSource: DataSource, slaveDataSource: DataSource): Promise { + logger.log('开始同步表结构...'); + + // 获取主数据库的所有表 + const tables = await masterDataSource.query(` + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '${(masterDbConfig as any).database}' + `); + + for (const table of tables) { + const tableName = table.TABLE_NAME; + logger.log(`同步表结构: ${tableName}`); + + // 获取主数据库的建表语句 + const createTableResult = await masterDataSource.query(` + SHOW CREATE TABLE \`${tableName}\` + `); + + let createTableSql = createTableResult[0]['Create Table']; + + // 转换 MariaDB 语法到 MySQL 语法 + // 将 uuid 类型转换为 CHAR(36) + createTableSql = createTableSql.replace(/\buuid\b/gi, 'CHAR(36)'); + + // 检查 slave 数据库中是否存在该表 + const tableExists = await slaveDataSource.query(` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = '${(slaveDbConfig as any).database}' + AND TABLE_NAME = '${tableName}' + `); + + const tempTableName = `temp_${tableName}_${Date.now()}`; + + if (tableExists[0].count > 0) { + // 表存在,先备份数据到临时表 + logger.log(`备份表 ${tableName} 的数据到 ${tempTableName}...`); + await slaveDataSource.query(`CREATE TABLE ${tempTableName} AS SELECT * FROM \`${tableName}\``); + logger.log(`备份完成,共备份 ${await slaveDataSource.query(`SELECT COUNT(*) as count FROM ${tempTableName}`).then(r => r[0].count)} 条记录`); + } + + // 删除 slave 数据库中的表(如果存在) + await slaveDataSource.query(`DROP TABLE IF EXISTS \`${tableName}\``); + + // 在 slave 数据库中创建表 + await slaveDataSource.query(createTableSql); + + // 如果之前有备份数据,尝试恢复 + if (tableExists[0].count > 0) { + try { + logger.log(`从 ${tempTableName} 恢复数据到 ${tableName}...`); + + // 获取临时表的列名 + const columns = await slaveDataSource.query(` + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '${(slaveDbConfig as any).database}' + AND TABLE_NAME = '${tempTableName}' + `); + + const columnNames = columns.map((c: any) => `\`${c.COLUMN_NAME}\``).join(', '); + + // 将数据从临时表插入到新表 + await slaveDataSource.query(` + INSERT INTO \`${tableName}\` (${columnNames}) + SELECT ${columnNames} FROM ${tempTableName} + `); + + const restoredCount = await slaveDataSource.query(`SELECT COUNT(*) as count FROM \`${tableName}\``); + logger.log(`数据恢复完成,共恢复 ${restoredCount[0].count} 条记录`); + + // 删除临时表 + await slaveDataSource.query(`DROP TABLE IF EXISTS ${tempTableName}`); + } catch (error) { + logger.warn(`恢复数据失败: ${error.message}`); + logger.warn(`临时表 ${tempTableName} 保留,请手动处理`); + } + } + } + + logger.log('表结构同步完成'); + + // 重新初始化 slave 数据库连接以清除 TypeORM 元数据缓存 + logger.log('重新初始化 slave 数据库连接...'); + await slaveDataSource.destroy(); + await slaveDataSource.initialize(); + logger.log('Slave 数据库连接重新初始化完成'); + + return slaveDataSource; +} + +// 主同步函数 +async function syncDatabase() { + let masterDataSource: DataSource | null = null; + let slaveDataSource: DataSource | null = null; + + try { + logger.log('开始数据库同步...'); + + // 创建 slave 数据库(如果不存在) + logger.log('检查并创建 slave 数据库...'); + await createDatabaseIfNotExists(slaveDbConfig); + logger.log('Slave 数据库准备就绪'); + + // 创建主数据库连接 + masterDataSource = new DataSource(masterDbConfig); + await masterDataSource.initialize(); + logger.log('主数据库连接成功'); + + // 创建 slave 数据库连接 + slaveDataSource = new DataSource(slaveDbConfig); + await slaveDataSource.initialize(); + logger.log('Slave 数据库连接成功'); + + // 同步表结构 + slaveDataSource = await syncSchema(masterDataSource, slaveDataSource); + + // 同步各个表 + const tables = [ + { entity: BidItem, name: 'bid_items' }, + { entity: Keyword, name: 'keywords' }, + { entity: AiRecommendation, name: 'ai_recommendations' }, + { entity: CrawlInfoAdd, name: 'crawl_info_add' }, + ]; + + let totalSynced = 0; + for (const table of tables) { + const count = await syncTable(masterDataSource, slaveDataSource, table.entity, table.name); + totalSynced += count; + } + + logger.log(`数据库同步完成,共同步 ${totalSynced} 条记录`); + + await masterDataSource.destroy(); + await slaveDataSource.destroy(); + process.exit(0); + } catch (error) { + logger.error('数据库同步失败:', error); + if (masterDataSource && masterDataSource.isInitialized) { + await masterDataSource.destroy(); + } + if (slaveDataSource && slaveDataSource.isInitialized) { + await slaveDataSource.destroy(); + } + process.exit(1); + } +} + +// 执行同步 +syncDatabase(); diff --git a/widget/looker/sys_run/systray_run.go b/widget/looker/sys_run/systray_run.go new file mode 100644 index 0000000..c8e5a64 --- /dev/null +++ b/widget/looker/sys_run/systray_run.go @@ -0,0 +1,194 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/getlantern/systray" +) + +var ( + sendPipe *os.File + receivePipe *os.File + logFile *os.File +) + +func initLog() { + var err error + logFile, err = os.OpenFile("systray_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + // failsafe + return + } +} + +func logMsg(format string, args ...interface{}) { + if logFile != nil { + fmt.Fprintf(logFile, format+"\n", args...) + } +} + +func main() { + initLog() + logMsg("Systray process started") + + // 使用 Stdin 和 Stdout 与主进程通信 + // 主进程将 cmd.Stdin 连接到 pipes,将 cmd.Stdout 连接到 pipes + // 所以这里我们直接使用 os.Stdin 读取,os.Stdout 发送 + + sendPipe = os.Stdout + receivePipe = os.Stdin + + // 启动系统托盘 + logMsg("Calling systray.Run") + systray.Run(onReady, onExit) +} + +func onReady() { + logMsg("systray onReady called") + // 设置托盘图标 - 使用简单的图标 + systray.SetIcon(getIcon()) + systray.SetTitle("Bidding Looker") + systray.SetTooltip("Bidding Looker - 招标信息查看器") + + // 显示窗口菜单项 + mShow := systray.AddMenuItem("显示窗口", "显示主窗口") + mQuit := systray.AddMenuItem("退出", "退出程序") + + // 监听来自主进程的消息 + go monitorPipe() + + // 处理菜单点击 + go func() { + for { + select { + case <-mShow.ClickedCh: + logMsg("Show menu clicked") + // 发送显示窗口消息到主进程 + sendMessage("SHOW") + case <-mQuit.ClickedCh: + logMsg("Quit menu clicked") + // 发送退出消息到主进程 + sendMessage("QUIT") + systray.Quit() + } + } + }() + + // 监听系统信号 + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + logMsg("Received signal, quitting") + systray.Quit() + }() +} + +func onExit() { + logMsg("systray onExit called") + // 清理资源 +} + +func monitorPipe() { + logMsg("Starting monitorPipe") + reader := bufio.NewReader(receivePipe) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + logMsg("Pipe EOF, quitting") + // 主进程关闭了管道(可能退出了),我们也退出 + systray.Quit() + return + } + logMsg("Pipe read error: %v", err) + fmt.Fprintf(os.Stderr, "Pipe read error: %v\n", err) + return + } + // 处理来自主进程的消息 (日志输出到 Stderr,避免污染 Stdout) + logMsg("Received from main: %s", line) + fmt.Fprintf(os.Stderr, "Received from main: %s", line) + } +} + +func sendMessage(message string) { + logMsg("Sending message: %s", message) + _, err := sendPipe.WriteString(message + "\n") + if err != nil { + logMsg("Failed to send message: %v", err) + fmt.Fprintf(os.Stderr, "Failed to send message: %v\n", err) + } +} + +// 简单的图标数据(16x16 像素的简单图标) +// 使用 PNG 格式,这是 systray 库在 Windows 上的推荐格式 +func getIcon() []byte { + // PNG 文件签名 + // IHDR chunk (图像头): 16x16, 8-bit RGBA + // IDAT chunk (图像数据): 简单的蓝色方块图标 + // IEND chunk (文件结束) + return []byte{ + // PNG 签名 (8 bytes) + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + + // IHDR chunk + 0x00, 0x00, 0x00, 0x0D, // Length: 13 bytes + 0x49, 0x48, 0x44, 0x52, // Type: IHDR + 0x00, 0x00, 0x00, 0x10, // Width: 16 + 0x00, 0x00, 0x00, 0x10, // Height: 16 + 0x08, // Bit depth: 8 + 0x06, // Color type: RGBA + 0x00, 0x00, 0x00, // Compression, Filter, Interlace + 0x5D, 0x8A, 0x7F, 0xD4, // CRC + + // IDAT chunk (使用最简单的 PNG 编码) + 0x00, 0x00, 0x01, 0x6D, // Length: 365 bytes + 0x49, 0x44, 0x41, 0x54, // Type: IDAT + // 压缩数据 (zlib 格式) + 0x78, 0x9C, // zlib header (default compression) + // Deflate 压缩块 + 0x63, 0x18, 0x19, 0x60, 0x28, 0x55, 0xF6, 0x7F, 0x00, 0x00, 0x00, + 0x00, 0x05, 0x00, 0x01, 0x0A, 0x8C, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x52, 0x4B, 0x8C, 0x36, // CRC + + // IEND chunk + 0x00, 0x00, 0x00, 0x00, // Length: 0 bytes + 0x49, 0x45, 0x4E, 0x44, // Type: IEND + 0xAE, 0x42, 0x60, 0x82, // CRC + } +}