diff --git a/.gitignore b/.gitignore index da13430..09f56ce 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts +testSource/ \ No newline at end of file diff --git a/package.json b/package.json index 8faf7d8..b0396cb 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", "lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", + "release": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", diff --git a/release/app/package.json b/release/app/package.json index 834677b..3050b32 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "source-debug", - "version": "0.1.5", + "version": "0.1.6", "description": "书源调试器 - Legado/异次元书源调试工具", "license": "MIT", "author": { diff --git a/src/main/debug/analyze-url.ts b/src/main/debug/analyze-url.ts index a55c571..b40315b 100644 --- a/src/main/debug/analyze-url.ts +++ b/src/main/debug/analyze-url.ts @@ -384,17 +384,11 @@ export class AnalyzeUrl { ); // 3. 转换 E4X XML 字面量 (简单处理,转为字符串) - // ... -> '...' - result = result.replace( - /(<\w+[^>]*>[\s\S]*?<\/\w+>)/g, - (match) => { - // 检查是否在字符串内 - if (match.startsWith("'") || match.startsWith('"') || match.startsWith('`')) { - return match; - } - return `'${match.replace(/'/g, "\\'")}'`; - } - ); + // 注意:只转换独立的 XML 字面量,不转换字符串中的 XML + // 这个转换很容易出错,所以只在非常明确的情况下才转换 + // 例如: var x = ... 但不转换 var x = '...' + // 暂时禁用此转换,因为大多数书源不使用 E4X + // result = result.replace(...); // 4. 转换 Java 风格的数组声明 // new java.lang.String[] -> [] @@ -437,6 +431,7 @@ export class AnalyzeUrl { // jsLib 可能是 JSON 对象(指向远程 JS 文件的 URL)或直接的 JS 代码 const jsLib = this.variables['_jsLib']; if (jsLib) { + console.log('[AnalyzeUrl] Loading jsLib, length:', jsLib.length); try { // 检查是否是 JSON 对象 if (jsLib.trim().startsWith('{') && jsLib.trim().endsWith('}')) { @@ -464,18 +459,24 @@ export class AnalyzeUrl { } } catch (jsonError) { // 不是有效的 JSON,作为普通 JS 代码执行 + console.log('[AnalyzeUrl] jsLib is not JSON, executing as JS code'); const convertedJsLib = this.convertRhinoToES6(jsLib); const jsLibScript = new vm.Script(convertedJsLib); jsLibScript.runInContext(vmContext, { timeout: 5000 }); + console.log('[AnalyzeUrl] jsLib executed successfully'); } } else { // 直接作为 JS 代码执行 + console.log('[AnalyzeUrl] Executing jsLib as JS code directly'); const convertedJsLib = this.convertRhinoToES6(jsLib); const jsLibScript = new vm.Script(convertedJsLib); jsLibScript.runInContext(vmContext, { timeout: 5000 }); + console.log('[AnalyzeUrl] jsLib executed successfully'); } } catch (e) { console.error('[AnalyzeUrl] jsLib execution error:', e); + // jsLib 执行失败时,不继续执行主代码 + throw e; } } @@ -487,6 +488,88 @@ export class AnalyzeUrl { } } + /** + * 执行 JS 获取发现分类 - 公开方法供外部调用 + * 用于解析 exploreUrl 中的 ... 或 @js:... 规则 + */ + evalJsForExplore(jsCode: string): string { + try { + const result = this.evalJS(jsCode, null); + if (result === null || result === undefined) return ''; + if (typeof result === 'string') return result; + if (typeof result === 'object') return JSON.stringify(result); + return String(result); + } catch (error) { + console.error('[AnalyzeUrl] evalJsForExplore error:', error); + return ''; + } + } + + /** + * 执行 JS 并传入登录数据 + * 用于登录功能,参考 Legado BaseSource.evalJS() + */ + evalJsWithLoginData(jsCode: string, loginData: Record): any { + try { + // 转换 Rhino JS 语法为标准 ES6 + const convertedCode = this.convertRhinoToES6(jsCode); + + // 创建沙箱环境,传入登录数据 + const sandbox = this.createJsSandbox(loginData); + // 额外注入登录数据作为 result + sandbox.result = loginData; + + const vmContext = vm.createContext(sandbox); + + // 加载 jsLib + const jsLib = this.variables['_jsLib']; + if (jsLib) { + try { + if (jsLib.trim().startsWith('{') && jsLib.trim().endsWith('}')) { + // JSON 格式的 jsLib(远程 JS 文件) + try { + const jsMap = JSON.parse(jsLib); + for (const [, url] of Object.entries(jsMap)) { + if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) { + const cacheKey = `_jsLib_${url}`; + let jsCode = CacheManager.get(cacheKey); + if (!jsCode) { + const response = syncHttpRequest(url); + if (response.body) { + jsCode = response.body; + CacheManager.put(cacheKey, jsCode, 86400); + } + } + if (jsCode) { + const convertedJsLib = this.convertRhinoToES6(jsCode); + const jsLibScript = new vm.Script(convertedJsLib); + jsLibScript.runInContext(vmContext, { timeout: 10000 }); + } + } + } + } catch { + const convertedJsLib = this.convertRhinoToES6(jsLib); + const jsLibScript = new vm.Script(convertedJsLib); + jsLibScript.runInContext(vmContext, { timeout: 5000 }); + } + } else { + const convertedJsLib = this.convertRhinoToES6(jsLib); + const jsLibScript = new vm.Script(convertedJsLib); + jsLibScript.runInContext(vmContext, { timeout: 5000 }); + } + } catch (e) { + console.error('[AnalyzeUrl] jsLib execution error in login:', e); + } + } + + const script = new vm.Script(convertedCode); + return script.runInContext(vmContext, { timeout: 30000 }); // 登录可能需要更长时间 + } catch (error) { + console.error('[AnalyzeUrl] evalJsWithLoginData error:', error); + throw error; + } + } + /** * 创建 JS 沙箱环境 - 提供 Legado 兼容的 java 对象 * 参考 Legado JsExtensions.kt @@ -495,6 +578,7 @@ export class AnalyzeUrl { const self = this; // cookie 对象 - 使用持久化的 CookieStore + // 参考 Legado JsExtensions.kt 中的 cookie 对象 const cookie = { getCookie: (tag: string, key?: string) => { if (key) { @@ -502,6 +586,9 @@ export class AnalyzeUrl { } return CookieStore.getCookie(tag); }, + getKey: (tag: string, key: string) => { + return CookieStore.getKey(tag, key); + }, setCookie: (tag: string, cookie: string) => { CookieStore.setCookie(tag, cookie); }, diff --git a/src/main/debug/index.ts b/src/main/debug/index.ts index b125b28..02f2c15 100644 --- a/src/main/debug/index.ts +++ b/src/main/debug/index.ts @@ -1,5 +1,29 @@ -export { SourceDebugger } from './source-debugger'; -export type { BookSource, DebugResult, DebugLog, ParsedBook, ParsedChapter } from './source-debugger'; +export { + SourceDebugger, + parseExploreUrl, + // 登录相关 + parseLoginUi, + getLoginJs, + getLoginInfo, + putLoginInfo, + removeLoginInfo, + getLoginHeader, + putLoginHeader, + removeLoginHeader, + executeLogin, + executeButtonAction, + checkLoginStatus, +} from './source-debugger'; +export type { + BookSource, + DebugResult, + DebugLog, + ParsedBook, + ParsedChapter, + ExploreCategory, + LoginUiItem, + LoginResult, +} from './source-debugger'; export { YiciyuanDebugger, isYiciyuanSource } from './yiciyuan-debugger'; export type { YiciyuanSource } from './yiciyuan-debugger'; export { httpRequest, parseHeaders } from './http-client'; diff --git a/src/main/debug/source-debugger.ts b/src/main/debug/source-debugger.ts index 9a83233..a53cdd1 100644 --- a/src/main/debug/source-debugger.ts +++ b/src/main/debug/source-debugger.ts @@ -1684,4 +1684,442 @@ export class SourceDebugger { } } +/** + * 解析发现分类列表 + * 支持 Legado 的多种格式: + * 1. 文本格式:分类名::URL(用 && 或换行分隔) + * 2. JSON 格式:[{"title":"分类","url":"..."}] + * 3. JS 动态格式:... 或 @js:... + * 4. 分组:没有 URL 的项作为分组标题 + */ +export interface ExploreCategory { + title: string; + url: string; + group: string; + style?: any; +} + +export async function parseExploreUrl( + source: BookSource, + variables?: Record +): Promise { + const exploreUrl = source.exploreUrl || (source as any).ruleFindUrl || ''; + if (!exploreUrl) return []; + + let ruleStr = exploreUrl; + + // 处理 JS 动态规则 + if (exploreUrl.trim().startsWith('') || exploreUrl.trim().toLowerCase().startsWith('@js:')) { + console.log('[parseExploreUrl] Detected JS dynamic rule'); + console.log('[parseExploreUrl] jsLib available:', !!source.jsLib); + try { + const analyzeUrl = new AnalyzeUrl(source.bookSourceUrl, { + source, + variables: { + ...variables, + _jsLib: source.jsLib, + }, + }); + + // 提取 JS 代码 - 参考 Legado BookSourceExtensions.kt + let jsCode: string; + if (exploreUrl.trim().startsWith('')) { + // ... 格式:取 到最后一个 < 之间的内容 + const startIndex = exploreUrl.indexOf('>') + 1; + const endIndex = exploreUrl.lastIndexOf('<'); + jsCode = exploreUrl.substring(startIndex, endIndex > startIndex ? endIndex : exploreUrl.length); + } else { + // @js: 格式:取 @js: 之后的所有内容 + jsCode = exploreUrl.substring(4); + } + + console.log('[parseExploreUrl] JS code length:', jsCode.length); + + // 执行 JS + ruleStr = analyzeUrl.evalJsForExplore(jsCode); + console.log('[parseExploreUrl] JS result length:', ruleStr?.length || 0); + if (!ruleStr) { + console.log('[parseExploreUrl] JS returned empty result'); + return []; + } + } catch (error) { + console.error('[parseExploreUrl] JS execution error:', error); + return []; + } + } + + const categories: ExploreCategory[] = []; + let currentGroup = '默认'; + + // 尝试 JSON 格式 + if (ruleStr.trim().startsWith('[')) { + try { + const jsonData = JSON.parse(ruleStr); + if (Array.isArray(jsonData)) { + for (const item of jsonData) { + if (item.title) { + if (item.url) { + categories.push({ + title: item.title, + url: item.url, + group: currentGroup, + style: item.style, + }); + } else { + // 没有 URL,是分组标题 + currentGroup = item.title; + } + } + } + } + return categories; + } catch { + // 不是有效 JSON,继续尝试文本格式 + } + } + + // 文本格式解析(支持 && 和换行分隔) + const lines = ruleStr.split(/&&|\n/).filter((l: string) => l.trim()); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('::')) { + const separatorIndex = trimmed.indexOf('::'); + const name = trimmed.substring(0, separatorIndex).trim(); + const url = trimmed.substring(separatorIndex + 2).trim(); + if (name && url) { + categories.push({ + title: name, + url: url, + group: currentGroup, + }); + } else if (name && !url) { + // 只有名称没有 URL,是分组标题 + currentGroup = name; + } + } else if (trimmed.startsWith('http')) { + // 纯 URL + categories.push({ + title: trimmed, + url: trimmed, + group: currentGroup, + }); + } else if (trimmed) { + // 纯文本,作为分组标题 + currentGroup = trimmed; + } + } + + return categories; +} + +// ============================================ +// 登录功能实现 +// 参考 Legado BaseSource.kt +// ============================================ + +/** + * 登录 UI 配置项 + */ +export interface LoginUiItem { + name: string; + type: 'text' | 'password' | 'button'; + action?: string; // 按钮动作(URL 或 JS) +} + +/** + * 登录结果 + */ +export interface LoginResult { + success: boolean; + message?: string; + loginHeader?: Record; +} + +// 登录信息存储(内存 + 持久化) +const loginInfoStore = new Map>(); +const loginHeaderStore = new Map>(); + +/** + * 解析 loginUi JSON + * 支持非标准 JSON(单引号) + */ +export function parseLoginUi(loginUi?: string): LoginUiItem[] { + if (!loginUi) return []; + try { + // 尝试标准 JSON 解析 + let items: any[]; + try { + items = JSON.parse(loginUi); + } catch { + // 如果失败,尝试修复非标准 JSON + // 使用更智能的方式处理单引号 + let fixedJson = loginUi; + + // 1. 先将已转义的单引号临时替换 + fixedJson = fixedJson.replace(/\\'/g, '___ESCAPED_QUOTE___'); + + // 2. 将属性名的单引号替换为双引号: 'name': -> "name": + fixedJson = fixedJson.replace(/'(\w+)'(\s*:)/g, '"$1"$2'); + + // 3. 将字符串值的单引号替换为双引号: : 'value' -> : "value" + // 但要小心处理值中包含单引号的情况 + fixedJson = fixedJson.replace(/:\s*'([^']*)'/g, ': "$1"'); + + // 4. 恢复转义的单引号(在双引号字符串中变成普通单引号) + fixedJson = fixedJson.replace(/___ESCAPED_QUOTE___/g, "'"); + + // 5. 移除尾随逗号 + fixedJson = fixedJson.replace(/,(\s*[}\]])/g, '$1'); + + items = JSON.parse(fixedJson); + } + + if (Array.isArray(items)) { + return items.map((item: any) => ({ + name: item.name || '', + type: item.type || 'text', + action: item.action, + })); + } + } catch (e) { + console.error('[parseLoginUi] Failed to parse loginUi:', e); + // 尝试使用 eval 作为最后手段(不推荐,但某些书源可能需要) + try { + // eslint-disable-next-line no-eval + const items = eval(`(${loginUi})`); + if (Array.isArray(items)) { + return items.map((item: any) => ({ + name: item.name || '', + type: item.type || 'text', + action: item.action, + })); + } + } catch (evalError) { + console.error('[parseLoginUi] Eval fallback also failed:', evalError); + } + } + return []; +} + +/** + * 提取登录 JS 代码 + * 参考 Legado BaseSource.getLoginJs() + */ +export function getLoginJs(loginUrl?: string): string | null { + if (!loginUrl) return null; + if (loginUrl.startsWith('@js:')) { + return loginUrl.substring(4); + } + if (loginUrl.startsWith('')) { + const endIndex = loginUrl.lastIndexOf('<'); + return loginUrl.substring(4, endIndex > 4 ? endIndex : loginUrl.length); + } + return loginUrl; +} + +/** + * 获取登录信息 + */ +export function getLoginInfo(sourceKey: string): Record | null { + // 先从内存获取 + if (loginInfoStore.has(sourceKey)) { + return loginInfoStore.get(sourceKey)!; + } + // 从持久化存储获取 + const cached = CacheManager.get(`loginInfo_${sourceKey}`); + if (cached) { + try { + const info = JSON.parse(cached); + loginInfoStore.set(sourceKey, info); + return info; + } catch { + // ignore + } + } + return null; +} + +/** + * 保存登录信息 + */ +export function putLoginInfo(sourceKey: string, info: Record): void { + loginInfoStore.set(sourceKey, info); + CacheManager.put(`loginInfo_${sourceKey}`, JSON.stringify(info)); +} + +/** + * 删除登录信息 + */ +export function removeLoginInfo(sourceKey: string): void { + loginInfoStore.delete(sourceKey); + CacheManager.delete(`loginInfo_${sourceKey}`); +} + +/** + * 获取登录头部 + */ +export function getLoginHeader(sourceKey: string): Record | null { + if (loginHeaderStore.has(sourceKey)) { + return loginHeaderStore.get(sourceKey)!; + } + const cached = CacheManager.get(`loginHeader_${sourceKey}`); + if (cached) { + try { + const header = JSON.parse(cached); + loginHeaderStore.set(sourceKey, header); + return header; + } catch { + // ignore + } + } + return null; +} + +/** + * 保存登录头部 + */ +export function putLoginHeader(sourceKey: string, header: Record): void { + loginHeaderStore.set(sourceKey, header); + CacheManager.put(`loginHeader_${sourceKey}`, JSON.stringify(header)); + // 如果有 Cookie,同步到 CookieStore + const cookie = header['Cookie'] || header['cookie']; + if (cookie) { + CookieStore.replaceCookie(sourceKey, cookie); + } +} + +/** + * 删除登录头部 + */ +export function removeLoginHeader(sourceKey: string): void { + loginHeaderStore.delete(sourceKey); + CacheManager.delete(`loginHeader_${sourceKey}`); + CookieStore.removeCookie(sourceKey); +} + +/** + * 执行登录 + * 参考 Legado BaseSource.login() + */ +export async function executeLogin( + source: BookSource, + loginData: Record +): Promise { + const sourceKey = source.bookSourceUrl; + + // 保存登录信息 + putLoginInfo(sourceKey, loginData); + + const loginJs = getLoginJs(source.loginUrl); + if (!loginJs) { + return { success: false, message: '未配置登录规则' }; + } + + try { + // 创建 AnalyzeUrl 执行 JS + const analyzeUrl = new AnalyzeUrl(sourceKey, { + source, + variables: { + _jsLib: source.jsLib, + }, + }); + + // 构建登录 JS - 参考 Legado + // loginJs 中应该定义一个 login 函数 + const fullJs = ` + ${loginJs} + if (typeof login === 'function') { + login.apply(this); + } else { + throw new Error('Function login not implemented!'); + } + `; + + // 执行登录 JS,传入登录数据 + const result = analyzeUrl.evalJsWithLoginData(fullJs, loginData); + + // 检查结果 + if (result && typeof result === 'object') { + // 如果返回了 header,保存它 + if (result.header || result.headers) { + putLoginHeader(sourceKey, result.header || result.headers); + } + return { + success: true, + message: result.message || '登录成功', + loginHeader: result.header || result.headers, + }; + } + + return { success: true, message: '登录成功' }; + } catch (error: any) { + console.error('[executeLogin] Error:', error); + return { + success: false, + message: error.message || '登录失败', + }; + } +} + +/** + * 执行按钮动作 + */ +export async function executeButtonAction( + source: BookSource, + action: string, + loginData: Record +): Promise<{ success: boolean; message?: string; result?: any }> { + if (!action) { + return { success: false, message: '未配置按钮动作' }; + } + + // 如果是 URL,返回让前端打开 + if (action.startsWith('http://') || action.startsWith('https://')) { + return { success: true, result: { type: 'url', url: action } }; + } + + // 执行 JS + try { + const loginJs = getLoginJs(source.loginUrl) || ''; + const analyzeUrl = new AnalyzeUrl(source.bookSourceUrl, { + source, + variables: { + _jsLib: source.jsLib, + }, + }); + + const fullJs = `${loginJs}\n${action}`; + const result = analyzeUrl.evalJsWithLoginData(fullJs, loginData); + + return { success: true, result }; + } catch (error: any) { + return { success: false, message: error.message }; + } +} + +/** + * 检查登录状态 + */ +export function checkLoginStatus(source: BookSource): { + hasLoginUrl: boolean; + hasLoginUi: boolean; + isLoggedIn: boolean; + loginInfo: Record | null; +} { + const sourceKey = source.bookSourceUrl; + const loginInfo = getLoginInfo(sourceKey); + const loginHeader = getLoginHeader(sourceKey); + + return { + hasLoginUrl: !!source.loginUrl, + hasLoginUi: !!source.loginUi, + isLoggedIn: !!(loginInfo || loginHeader), + loginInfo, + }; +} + +// 导入 CacheManager 和 CookieStore +import CacheManager from './cache-manager'; +import CookieStore from './cookie-manager'; + export default SourceDebugger; diff --git a/src/main/main.ts b/src/main/main.ts index 600be0b..d71e763 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,7 +15,21 @@ import { autoUpdater } from 'electron-updater'; import log from 'electron-log'; // import MenuBuilder from './menu'; // 已禁用默认菜单 import { resolveHtmlPath } from './util'; -import { SourceDebugger, BookSource, YiciyuanDebugger, isYiciyuanSource } from './debug'; +import { + SourceDebugger, + BookSource, + YiciyuanDebugger, + isYiciyuanSource, + parseExploreUrl, + parseLoginUi, + checkLoginStatus, + executeLogin, + executeButtonAction, + getLoginInfo, + removeLoginInfo, + getLoginHeader, + removeLoginHeader, +} from './debug'; import { getAIService, ChatMessage } from './ai/ai-service'; class AppUpdater { @@ -26,6 +40,11 @@ class AppUpdater { // 配置自动更新 autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + + // 开发模式下也检查更新(需要 dev-app-update.yml) + if (!app.isPackaged) { + autoUpdater.forceDevUpdateConfig = true; + } // 检查更新事件 autoUpdater.on('checking-for-update', () => { @@ -59,7 +78,18 @@ class AppUpdater { autoUpdater.on('error', (err) => { log.error('更新错误:', err); - this.sendStatusToWindow('error', err.message); + // 简化错误信息,避免显示过长的技术细节 + let errorMessage = '网络连接失败,请稍后重试'; + if (err.message) { + if (err.message.includes('404') || err.message.includes('latest.yml')) { + errorMessage = '暂无更新信息,请前往 GitHub 查看'; + } else if (err.message.includes('net::') || err.message.includes('ENOTFOUND') || err.message.includes('ETIMEDOUT')) { + errorMessage = '网络连接失败,请检查网络后重试'; + } else if (err.message.includes('certificate') || err.message.includes('SSL')) { + errorMessage = '网络安全验证失败,请检查网络环境'; + } + } + this.sendStatusToWindow('error', errorMessage); }); autoUpdater.on('download-progress', (progressObj) => { @@ -163,6 +193,26 @@ ipcMain.handle( } ); +/** + * 解析发现分类列表 + * 支持 JS 动态规则 + */ +ipcMain.handle( + 'debug:parseExploreCategories', + async (_event, source: any) => { + try { + const categories = await parseExploreUrl(source as BookSource); + return { success: true, categories }; + } catch (error: any) { + return { + success: false, + categories: [], + error: error.message || String(error), + }; + } + } +); + /** * 书籍详情测试 * 自动识别源格式(Legado 或 异次元) @@ -244,6 +294,106 @@ ipcMain.handle( } ); +// ============================================ +// IPC 通信接口 - 登录功能 +// ============================================ + +/** + * 解析登录 UI 配置 + */ +ipcMain.handle('debug:parseLoginUi', async (_event, loginUi: string) => { + try { + const items = parseLoginUi(loginUi); + return { success: true, items }; + } catch (error: any) { + return { success: false, items: [], error: error.message }; + } +}); + +/** + * 检查登录状态 + */ +ipcMain.handle('debug:checkLoginStatus', async (_event, source: any) => { + try { + const status = checkLoginStatus(source as BookSource); + return { success: true, ...status }; + } catch (error: any) { + return { success: false, error: error.message }; + } +}); + +/** + * 执行登录 + */ +ipcMain.handle('debug:executeLogin', async (_event, source: any, loginData: Record) => { + try { + const result = await executeLogin(source as BookSource, loginData); + return result; + } catch (error: any) { + return { success: false, message: error.message }; + } +}); + +/** + * 执行按钮动作 + */ +ipcMain.handle('debug:executeButtonAction', async (_event, source: any, action: string, loginData: Record) => { + try { + const result = await executeButtonAction(source as BookSource, action, loginData); + return result; + } catch (error: any) { + return { success: false, message: error.message }; + } +}); + +/** + * 获取登录信息 + */ +ipcMain.handle('debug:getLoginInfo', async (_event, sourceKey: string) => { + try { + const info = getLoginInfo(sourceKey); + return { success: true, info }; + } catch (error: any) { + return { success: false, error: error.message }; + } +}); + +/** + * 删除登录信息 + */ +ipcMain.handle('debug:removeLoginInfo', async (_event, sourceKey: string) => { + try { + removeLoginInfo(sourceKey); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +}); + +/** + * 获取登录头部 + */ +ipcMain.handle('debug:getLoginHeader', async (_event, sourceKey: string) => { + try { + const header = getLoginHeader(sourceKey); + return { success: true, header }; + } catch (error: any) { + return { success: false, error: error.message }; + } +}); + +/** + * 删除登录头部 + */ +ipcMain.handle('debug:removeLoginHeader', async (_event, sourceKey: string) => { + try { + removeLoginHeader(sourceKey); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +}); + // ============================================ // IPC 通信接口 - 文件操作 // ============================================ @@ -370,7 +520,40 @@ ipcMain.handle('app:quitAndInstall', () => { * 获取应用版本 */ ipcMain.handle('app:getVersion', () => { - return app.getVersion(); + // 打包后使用 app.getVersion(),开发模式下从 package.json 读取 + if (app.isPackaged) { + return app.getVersion(); + } + // 开发模式下读取 release/app/package.json + try { + const path = require('path'); + const fs = require('fs'); + // 从项目根目录查找 + const possiblePaths = [ + path.join(process.cwd(), 'release/app/package.json'), + path.join(__dirname, '../../../release/app/package.json'), + path.join(__dirname, '../../../../release/app/package.json'), + ]; + for (const pkgPath of possiblePaths) { + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.version) { + return pkg.version; + } + } + } + } catch (e) { + console.error('Failed to read version:', e); + } + return '0.0.0'; +}); + +/** + * 打开外部链接 + */ +ipcMain.handle('app:openExternal', async (_event, url: string) => { + const { shell } = require('electron'); + await shell.openExternal(url); }); // ============================================ diff --git a/src/main/preload.ts b/src/main/preload.ts index 30f8867..5fd8a6d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,7 +2,7 @@ /* eslint no-unused-vars: off */ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; -export type Channels = 'ipc-example'; +export type Channels = 'ipc-example' | 'update-status'; const electronHandler = { ipcRenderer: { @@ -21,6 +21,12 @@ const electronHandler = { once(channel: Channels, func: (...args: unknown[]) => void) { ipcRenderer.once(channel, (_event, ...args) => func(...args)); }, + invoke(channel: string, ...args: unknown[]) { + return ipcRenderer.invoke(channel, ...args); + }, + removeListener(channel: string, func: (...args: unknown[]) => void) { + ipcRenderer.removeListener(channel, func); + }, }, }; @@ -42,6 +48,13 @@ const debugApiHandler = { return ipcRenderer.invoke('debug:explore', source, exploreUrl); }, + /** + * 解析发现分类列表(支持 JS 动态规则) + */ + parseExploreCategories: (source: any) => { + return ipcRenderer.invoke('debug:parseExploreCategories', source); + }, + /** * 书籍详情测试 */ @@ -62,6 +75,64 @@ const debugApiHandler = { content: (source: any, contentUrl: string) => { return ipcRenderer.invoke('debug:content', source, contentUrl); }, + + // ===== 登录相关 ===== + + /** + * 解析登录 UI 配置 + */ + parseLoginUi: (loginUi: string) => { + return ipcRenderer.invoke('debug:parseLoginUi', loginUi); + }, + + /** + * 检查登录状态 + */ + checkLoginStatus: (source: any) => { + return ipcRenderer.invoke('debug:checkLoginStatus', source); + }, + + /** + * 执行登录 + */ + executeLogin: (source: any, loginData: Record) => { + return ipcRenderer.invoke('debug:executeLogin', source, loginData); + }, + + /** + * 执行按钮动作 + */ + executeButtonAction: (source: any, action: string, loginData: Record) => { + return ipcRenderer.invoke('debug:executeButtonAction', source, action, loginData); + }, + + /** + * 获取登录信息 + */ + getLoginInfo: (sourceKey: string) => { + return ipcRenderer.invoke('debug:getLoginInfo', sourceKey); + }, + + /** + * 删除登录信息 + */ + removeLoginInfo: (sourceKey: string) => { + return ipcRenderer.invoke('debug:removeLoginInfo', sourceKey); + }, + + /** + * 获取登录头部 + */ + getLoginHeader: (sourceKey: string) => { + return ipcRenderer.invoke('debug:getLoginHeader', sourceKey); + }, + + /** + * 删除登录头部 + */ + removeLoginHeader: (sourceKey: string) => { + return ipcRenderer.invoke('debug:removeLoginHeader', sourceKey); + }, }; contextBridge.exposeInMainWorld('debugApi', debugApiHandler); diff --git a/src/renderer/components/DebugPanel.tsx b/src/renderer/components/DebugPanel.tsx index 66986c1..69f3fe8 100644 --- a/src/renderer/components/DebugPanel.tsx +++ b/src/renderer/components/DebugPanel.tsx @@ -19,6 +19,7 @@ import { SegmentedControl, Tooltip, Modal, + Select, useMantineColorScheme, } from '@mantine/core'; import { @@ -50,9 +51,14 @@ import { IconLayoutColumns, IconPlayerSkipBack, IconPlayerSkipForward, + IconSearch, + IconCode, + IconLogin, + IconUser, } from '@tabler/icons-react'; import { useBookSourceStore } from '../stores/bookSourceStore'; import type { BookItem, ChapterItem, TestMode } from '../types'; +import { SourceLoginDialog } from './SourceLoginDialog'; const testModeOptions: { label: string; value: TestMode }[] = [ { label: '搜索', value: 'search' }, @@ -81,8 +87,171 @@ export function DebugPanel() { setAiAnalysisEnabled, chapterList, currentChapterIndex, + sources, + activeSourceId, + sourceCode, } = useBookSourceStore(); + // 发现分类状态(支持 JS 动态规则,需要后端解析) + const [exploreCategories, setExploreCategories] = useState< + Array<{ label: string; value: string }> | Array<{ group: string; items: { label: string; value: string }[] }> + >([]); + const [exploreCategoryCount, setExploreCategoryCount] = useState(0); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + + // 解析发现分类列表(支持 JS 动态规则) + useEffect(() => { + const parseCategories = async () => { + // 获取当前书源 + let currentSource: any = null; + try { + if (sourceCode) { + currentSource = JSON.parse(sourceCode); + } + } catch { + currentSource = sources.find(s => s.bookSourceUrl === activeSourceId); + } + + if (!currentSource) { + setExploreCategories([]); + setExploreCategoryCount(0); + return; + } + + const exploreUrl = currentSource.exploreUrl || currentSource.ruleFindUrl || ''; + if (!exploreUrl) { + setExploreCategories([]); + setExploreCategoryCount(0); + return; + } + + // 检查是否是 JS 动态规则 + const isJsRule = exploreUrl.trim().startsWith('') || + exploreUrl.trim().toLowerCase().startsWith('@js:'); + + if (isJsRule) { + // JS 动态规则,调用后端 API 解析 + setIsLoadingCategories(true); + try { + const result = await window.debugApi?.parseExploreCategories(currentSource); + if (result?.success && result?.categories) { + // 去重:使用 Set 记录已出现的 value + const seenValues = new Set(); + const items = result.categories + .map((cat: any) => ({ + label: cat.title, + value: `${cat.title}::${cat.url}`, + group: cat.group || '默认', + })) + .filter((item: { label: string; value: string; group: string }) => { + if (seenValues.has(item.value)) { + return false; + } + seenValues.add(item.value); + return true; + }); + + // 转换为 Mantine Select 格式 + const formatted = formatCategoriesToSelect(items); + setExploreCategories(formatted.data); + setExploreCategoryCount(formatted.count); + } else { + setExploreCategories([]); + setExploreCategoryCount(0); + } + } catch (error) { + console.error('Failed to parse explore categories:', error); + setExploreCategories([]); + setExploreCategoryCount(0); + } finally { + setIsLoadingCategories(false); + } + } else { + // 静态规则,前端直接解析 + const items: { label: string; value: string; group: string }[] = []; + let currentGroup = '默认'; + + // 尝试 JSON 格式 + if (exploreUrl.trim().startsWith('[')) { + try { + const jsonData = JSON.parse(exploreUrl); + if (Array.isArray(jsonData)) { + jsonData.forEach((item: any) => { + if (item.title) { + if (item.url) { + items.push({ + label: item.title, + value: `${item.title}::${item.url}`, + group: currentGroup + }); + } else { + currentGroup = item.title; + } + } + }); + } + } catch { + // 不是有效 JSON + } + } + + // 文本格式解析 + if (items.length === 0) { + currentGroup = '默认'; + const lines = exploreUrl.split(/&&|\n/).filter((l: string) => l.trim()); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('::')) { + const separatorIndex = trimmed.indexOf('::'); + const name = trimmed.substring(0, separatorIndex).trim(); + const url = trimmed.substring(separatorIndex + 2).trim(); + if (name && url) { + items.push({ label: name, value: trimmed, group: currentGroup }); + } else if (name && !url) { + currentGroup = name; + } + } else if (trimmed.startsWith('http')) { + items.push({ label: trimmed, value: trimmed, group: currentGroup }); + } else if (trimmed) { + currentGroup = trimmed; + } + } + } + + const formatted = formatCategoriesToSelect(items); + setExploreCategories(formatted.data); + setExploreCategoryCount(formatted.count); + } + }; + + parseCategories(); + }, [sourceCode, sources, activeSourceId]); + + // 辅助函数:转换分类为 Mantine Select 格式 + const formatCategoriesToSelect = (items: { label: string; value: string; group: string }[]) => { + const groupMap = new Map(); + for (const item of items) { + if (!groupMap.has(item.group)) { + groupMap.set(item.group, []); + } + groupMap.get(item.group)!.push({ label: item.label, value: item.value }); + } + + const count = items.length; + + // 如果只有一个分组且是默认分组,返回扁平数组 + if (groupMap.size === 1 && groupMap.has('默认')) { + return { data: groupMap.get('默认')!, count }; + } + + // 返回分组格式 + const result: Array<{ group: string; items: { label: string; value: string }[] }> = []; + for (const [group, groupItems] of groupMap) { + result.push({ group, items: groupItems }); + } + return { data: result, count }; + }; + const { colorScheme } = useMantineColorScheme(); const [showConfig, setShowConfig] = useState(false); const [showHistory, setShowHistory] = useState(false); @@ -94,6 +263,53 @@ export function DebugPanel() { const [currentImageIndex, setCurrentImageIndex] = useState(0); const [viewMode, setViewMode] = useState<'horizontal' | 'vertical'>('horizontal'); // 横向翻页 / 纵向条漫 + // 原始响应搜索和格式化状态 + const [rawSearchKeyword, setRawSearchKeyword] = useState(''); + const [isRawFormatted, setIsRawFormatted] = useState(false); + + // 登录对话框状态 + const [loginDialogOpen, setLoginDialogOpen] = useState(false); + const [loginStatus, setLoginStatus] = useState<{ + hasLoginUrl: boolean; + isLoggedIn: boolean; + }>({ hasLoginUrl: false, isLoggedIn: false }); + + // 获取当前书源对象 + const currentSource = useMemo(() => { + try { + if (sourceCode) { + return JSON.parse(sourceCode); + } + } catch { + // ignore + } + return sources.find(s => s.bookSourceUrl === activeSourceId); + }, [sourceCode, sources, activeSourceId]); + + // 检查登录状态 + useEffect(() => { + const checkLogin = async () => { + if (!currentSource) { + setLoginStatus({ hasLoginUrl: false, isLoggedIn: false }); + return; + } + + try { + const result = await window.debugApi?.checkLoginStatus(currentSource); + if (result?.success) { + setLoginStatus({ + hasLoginUrl: result.hasLoginUrl ?? false, + isLoggedIn: result.isLoggedIn ?? false, + }); + } + } catch { + // ignore + } + }; + + checkLogin(); + }, [currentSource]); + // 可视化数据 const visualData = useMemo(() => { if (!testResult?.rawParsedItems) return { books: [], chapters: [], content: '', bookDetail: null, imageUrls: [] }; @@ -265,6 +481,19 @@ export function DebugPanel() { > 规则测试器 + {/* 登录按钮 - 仅当书源配置了 loginUrl 时显示 */} + {loginStatus.hasLoginUrl && ( + + setLoginDialogOpen(true)} + > + {loginStatus.isLoggedIn ? : } + + + )} @@ -311,20 +540,38 @@ export function DebugPanel() { {/* URL/关键词输入 */} - setTestInput(e.currentTarget.value)} - onKeyDown={(e) => e.key === 'Enter' && handleTest()} - style={{ flex: 1 }} - rightSection={ - testHistory.length > 0 && ( - setShowHistory(!showHistory)}> - - - ) - } - /> + {testMode === 'explore' && (exploreCategoryCount > 0 || isLoadingCategories) ? ( + // 发现模式:显示下拉框选择分类(支持分组) +