// ==UserScript== // @name 西子拆书(浏览器版) // @namespace https://local.xizi.reader // @version 1.1.1 // @description TXT/番茄小说在线阅读器,支持目录搜索、编辑净化、标签书签、本地/火山/腾讯云朗读与 OpenAI 兼容 AI 对话。 // @author Codex // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @connect fanqienovel.com // @connect openspeech.bytedance.com // @connect tts.tencentcloudapi.com // @connect * // @run-at document-idle // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; const APP_ID = 'xizi-reader'; const LIBRARY_KEY = 'xizi.library.v1'; const PREFS_KEY = 'xizi.prefs.v1'; const API_KEY_KEY = 'xizi.apiKey.v1'; const ANNOTATION_PREFIX = 'xizi.annotations.v1.'; const AI_PREFIX = 'xizi.ai.v1.'; const BOOK_PREFIX = 'xizi.book.v1.'; const ACTIVE_BOOK_KEY = 'xizi.activeBook.v1'; const DEFAULT_PREFS = Object.freeze({ theme: 'dark', accent: 0, fontFamily: 'Microsoft YaHei', fontSize: 19, lineHeight: 1.9, contentWidth: 820, tts: { engine: 'local', localVoice: '', volcanoVoice: 'zh_female_shuangkuaisisi_moon_bigtts', tencentVoice: '502003', volcano: { appid: '', token: '', cluster: 'volcano_tts', }, tencent: { secretId: '', secretKey: '', }, rate: 0, pitch: 0, volume: 0, autoRate: false, autoVolume: false, range: 'chapter', }, ai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini', includeChapter: true, systemPrompt: '你是专业的小说拆书助手。请忠实依据原文,分析剧情、人物、冲突、伏笔和写作技法;回答清晰、简洁、可操作。', }, }); const DAY_THEMES = [ { name: '米纸', bg: '#f7f3e8', panel: '#fffdf8', soft: '#ece6d8', text: '#25231f', sub: '#746f65', line: '#d9d1c1', accent: '#6d5bd0' }, { name: '青笺', bg: '#eef6f0', panel: '#fbfffc', soft: '#deebe1', text: '#203027', sub: '#64736a', line: '#c8d9cd', accent: '#277b58' }, { name: '晴蓝', bg: '#eef4fa', panel: '#fbfdff', soft: '#dde9f4', text: '#1f2d3b', sub: '#657486', line: '#c9d8e7', accent: '#2f6fb3' }, { name: '粉霞', bg: '#faf0f1', panel: '#fffafb', soft: '#f2dfe2', text: '#372729', sub: '#7f686b', line: '#e6ccd0', accent: '#b14f71' }, ]; const DARK_THEMES = [ { name: '曜石', bg: '#121418', panel: '#1a1d23', soft: '#242831', text: '#e9ebef', sub: '#9da3ae', line: '#323844', accent: '#9b87ff' }, { name: '深海', bg: '#0e171b', panel: '#152127', soft: '#1d3038', text: '#e4eef0', sub: '#90a5aa', line: '#29414a', accent: '#42b7c8' }, { name: '森林', bg: '#111814', panel: '#18221c', soft: '#243128', text: '#e8eee9', sub: '#9baa9f', line: '#33443a', accent: '#6fc28b' }, { name: '绛夜', bg: '#191313', panel: '#241b1b', soft: '#352626', text: '#efe8e8', sub: '#b5a2a2', line: '#493535', accent: '#df7d91' }, ]; const TENCENT_VOICES = [ { id: 502003, name: '智小敏(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502001, name: '智小柔(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502005, name: '智小解(超自然·解说男声)', group: '✨ 超自然大模型' }, { id: 502006, name: '智小悟(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603004, name: '温柔小柠(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603003, name: '随和老李(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603007, name: '邻家女孩(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603006, name: '沉稳青叔(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 501000, name: '智斌(大模型·阅读男声)', group: '🤖 大模型音色' }, { id: 501002, name: '智菊(大模型·阅读女声)', group: '🤖 大模型音色' }, { id: 501001, name: '智兰(大模型·资讯女声)', group: '🤖 大模型音色' }, { id: 601008, name: '爱小豪(大模型·多情感男声)', group: '🤖 大模型音色' }, { id: 601009, name: '爱小芊(大模型·多情感女声)', group: '🤖 大模型音色' }, { id: 601013, name: '爱小伊(大模型·阅读女声)', group: '🤖 大模型音色' }, { id: 101001, name: '智瑜(精品·情感女声)', group: '💎 精品音色' }, { id: 101004, name: '智云(精品·通用男声)', group: '💎 精品音色' }, { id: 101011, name: '智燕(精品·新闻女声)', group: '💎 精品音色' }, { id: 101013, name: '智辉(精品·新闻男声)', group: '💎 精品音色' }, { id: 101019, name: '智彤(精品·粤语女声)', group: '💎 精品音色' }, { id: 101016, name: '智甜(精品·女童声)', group: '💎 精品音色' }, { id: 101015, name: '智萌(精品·男童声)', group: '💎 精品音色' }, ]; const ONLINE_VOICES = [ { id: 'zh_female_shuangkuaisisi_moon_bigtts', name: '爽快思思(普通话·女·活泼)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_tianmeixiaoyuan_moon_bigtts', name: '甜美小源(普通话·女·甜美)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_qingxinnvsheng_moon_bigtts', name: '清新女生(普通话·女·清新)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_wenrouxiaoya_moon_bigtts', name: '温柔小雅(普通话·女·温柔)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_linjiatong_moon_bigtts', name: '邻家童(普通话·女童·可爱)', group: '🇨🇳 普通话·女声' }, { id: 'zh_male_qingshuang_moon_bigtts', name: '清爽男声(普通话·男·自然)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_zhubo_moon_bigtts', name: '专业主播(普通话·男·播音)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_chunhou_moon_bigtts', name: '醇厚男声(普通话·男·低沉)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_dongfang_moon_bigtts', name: '东方男声(普通话·男·磁性)', group: '🇨🇳 普通话·男声' }, { id: 'zh_female_story_moon_bigtts', name: '故事女生(有声书·温情)', group: '📖 有声书专区' }, { id: 'zh_male_story_moon_bigtts', name: '故事男生(有声书·磁性)', group: '📖 有声书专区' }, { id: 'zh_female_yuanshen_moon_bigtts', name: '元神女声(有声书·悬疑)', group: '📖 有声书专区' }, { id: 'zh_female_cantonese_qingxin_bigmodel', name: '粤语·清新女声', group: '🗣️ 方言' }, { id: 'zh_male_cantonese_chunhou_bigmodel', name: '粤语·醇厚男声', group: '🗣️ 方言' }, ]; const VOICE_ZH_NAME = { 'Microsoft Huihui': '微软 慧慧(普通话·女)', 'Microsoft Kangkang': '微软 康康(普通话·男)', 'Microsoft Yaoyao': '微软 瑶瑶(普通话·女童)', 'Microsoft Tracy': '微软 晓晓(粤语·女)', 'Microsoft Danny': '微软 丹尼(粤语·男)', 'Microsoft HiuGaai': '微软 晓佳(粤语·女)', 'Microsoft HiuMaan': '微软 晓曼(粤语·女)', 'Microsoft WanLung': '微软 云龙(粤语·男)', 'Microsoft Xiaoxiao': '微软 晓晓(普通话·女)', 'Microsoft Yunxi': '微软 云希(普通话·男)', 'Microsoft Yunjian': '微软 云健(普通话·男)', 'Microsoft Xiaochen': '微软 晓辰(普通话·女)', 'Microsoft Xiaohan': '微软 晓涵(普通话·女)', 'Microsoft Xiaomeng': '微软 晓梦(普通话·女)', 'Microsoft Xiaomo': '微软 晓墨(普通话·女)', 'Microsoft Xiaoqiu': '微软 晓秋(普通话·女)', 'Microsoft Xiaorui': '微软 晓睿(普通话·女)', 'Microsoft Xiaoshuang': '微软 晓双(普通话·女童)', 'Microsoft Xiaoyan': '微软 晓颜(普通话·女)', 'Microsoft Xiaoyou': '微软 晓悠(普通话·女童)', 'Microsoft Xiaozhen': '微软 晓甄(普通话·女)', 'Microsoft Yunfeng': '微软 云枫(普通话·男)', 'Microsoft Yunhao': '微软 云皓(普通话·男)', 'Microsoft Yunxia': '微软 云夏(普通话·男)', 'Microsoft Yunyang': '微软 云扬(普通话·男)', 'Microsoft Yunye': '微软 云野(普通话·男)', 'Microsoft Yunze': '微软 云泽(普通话·男)', 'Ting-Ting': '婷婷(普通话·女)', 'Sin-ji': '善慈(粤语·女)', 'Mei-Jia': '美佳(闽南话·女)', 'Yu-shu': '玉书(台湾·女)', 'Google 普通话(中国大陆)': '谷歌 普通话(大陆·女)', 'Google 粤語(香港)': '谷歌 粤语(香港·女)', 'Google 國語(臺灣)': '谷歌 国语(台湾·女)', 'Google 中文(中国大陆)': '谷歌 普通话(大陆)', }; // 番茄小说网页字体反混淆表。DOM 中的私有区字符会先还原,再显示与朗读。 const FANQIE_GLYPH_TABLE = 'E3E8=D,E3E9=在,E3EA=主,E3EB=特,E3EC=家,E3ED=军,E3EE=然,E3EF=表,E3F0=场,E3F1=4,E3F2=要,E3F3=只,E3F4=v,E3F5=和,E3F7=6,E3F8=别,E3F9=还,E3FA=g,E3FB=现,E3FC=儿,E3FD=岁,E400=此,E401=象,E402=月,E403=3,E404=出,E405=战,E406=工,E407=相,E408=o,E409=男,E40A=直,E40B=失,E40C=世,E40D=F,E40E=都,E40F=平,E410=文,E411=什,E412=V,E413=O,E414=将,E415=真,E416=T,E417=那,E418=当,E41A=会,E41B=立,E41C=些,E41D=u,E41E=是,E41F=十,E420=张,E421=学,E422=气,E423=大,E424=爱,E425=两,E426=命,E427=全,E428=后,E429=东,E42A=性,E42B=通,E42C=被,E42D=1,E42E=它,E42F=乐,E430=接,E431=而,E432=感,E433=车,E434=山,E435=公,E436=了,E437=常,E438=以,E439=何,E43A=可,E43B=话,E43C=先,E43D=p,E43E=i,E43F=叫,E440=轻,E441=M,E442=士,E443=w,E444=着,E445=变,E446=尔,E447=快,E448=l,E449=个,E44A=说,E44B=少,E44C=色,E44D=里,E44E=安,E44F=花,E450=远,E451=7,E452=难,E453=师,E454=放,E455=t,E456=报,E457=认,E458=面,E459=道,E45A=S,E45C=克,E45D=地,E45E=度,E45F=I,E460=好,E461=机,E462=U,E463=民,E464=写,E465=把,E466=万,E467=同,E468=水,E469=新,E46A=没,E46B=书,E46C=电,E46D=吃,E46E=像,E46F=斯,E470=5,E471=为,E472=y,E473=白,E474=几,E475=日,E476=教,E477=看,E478=但,E479=第,E47A=加,E47B=候,E47C=作,E47D=上,E47E=拉,E47F=住,E480=有,E481=法,E482=r,E483=事,E484=应,E485=位,E486=利,E487=你,E488=声,E489=身,E48A=国,E48B=问,E48C=马,E48D=女,E48E=他,E48F=Y,E490=比,E491=父,E492=x,E493=A,E494=H,E495=N,E496=s,E497=X,E498=边,E499=美,E49A=对,E49B=所,E49C=金,E49D=活,E49E=回,E49F=意,E4A0=到,E4A1=z,E4A2=从,E4A3=j,E4A4=知,E4A5=又,E4A6=内,E4A7=因,E4A8=点,E4A9=Q,E4AA=三,E4AB=定,E4AC=8,E4AD=R,E4AE=b,E4AF=正,E4B0=或,E4B1=夫,E4B2=向,E4B3=德,E4B4=听,E4B5=更,E4B7=得,E4B8=告,E4B9=并,E4BA=本,E4BB=q,E4BC=过,E4BD=记,E4BE=L,E4BF=让,E4C0=打,E4C1=f,E4C2=人,E4C3=就,E4C4=者,E4C5=去,E4C6=原,E4C7=满,E4C8=体,E4C9=做,E4CA=经,E4CB=K,E4CC=走,E4CD=如,E4CE=孩,E4CF=c,E4D0=G,E4D1=给,E4D2=使,E4D3=物,E4D5=最,E4D6=笑,E4D7=部,E4D9=员,E4DA=等,E4DB=受,E4DC=k,E4DD=行,E4DE=一,E4DF=条,E4E0=果,E4E1=动,E4E2=光,E4E3=门,E4E4=头,E4E5=见,E4E6=往,E4E7=自,E4E8=解,E4E9=成,E4EA=处,E4EB=天,E4EC=能,E4ED=于,E4EE=名,E4EF=其,E4F0=发,E4F1=总,E4F2=母,E4F3=的,E4F4=死,E4F5=手,E4F6=入,E4F7=路,E4F8=进,E4F9=心,E4FA=来,E4FB=h,E4FC=时,E4FD=力,E4FE=多,E4FF=开,E500=已,E501=许,E502=d,E503=至,E504=由,E505=很,E506=界,E507=n,E508=小,E509=与,E50A=Z,E50B=想,E50C=代,E50D=么,E50E=分,E50F=生,E510=口,E511=再,E512=妈,E513=望,E514=次,E515=西,E516=风,E517=种,E518=带,E519=J,E51B=实,E51C=情,E51D=才,E51E=这,E520=E,E521=我,E522=神,E523=格,E524=长,E525=觉,E526=间,E527=年,E528=眼,E529=无,E52A=不,E52B=亲,E52C=关,E52D=结,E52E=0,E52F=友,E530=信,E531=下,E532=却,E533=重,E534=己,E535=老,E536=2,E537=音,E538=字,E539=m,E53A=呢,E53B=明,E53C=之,E53D=前,E53E=高,E53F=P,E540=B,E541=目,E542=太,E543=e,E544=9,E545=起,E546=稜,E547=她,E548=也,E549=W,E54A=用,E54B=方,E54C=子,E54D=英,E54E=每,E54F=理,E550=便,E551=四,E552=数,E553=期,E554=中,E555=C,E556=外,E557=样,E558=a,E559=海,E55A=们,E55B=任'; const FANQIE_GLYPHS = Object.create(null); FANQIE_GLYPH_TABLE.split(',').forEach(pair => { const separator = pair.indexOf('='); if (separator <= 0) return; FANQIE_GLYPHS[String.fromCharCode(parseInt(pair.slice(0, separator), 16))] = pair.slice(separator + 1); }); const AUTO_RATE_KEYWORDS = { '+20%': ['紧急', '危险', '快跑', '快点', '立刻', '马上', '迅速'], '+15%': ['快', '急', '赶紧', '匆忙', '飞快', '急忙', '兴奋', '激动'], '-15%': ['慢慢', '缓缓', '悠然', '从容', '慢悠悠'], '-10%': ['平静', '安静', '宁静', '沉思', '回忆', '往事', '曾经', '疲惫'], }; const AUTO_VOLUME_KEYWORDS = { '+30%': ['大声', '吼', '喊', '咆哮', '怒吼', '高声', '洪亮'], '-30%': ['悄悄', '小声', '低声', '轻声', '嘘', '安静', '静悄悄'], '+15%': ['兴奋', '激动', '加油', '冲啊'], '-15%': ['叹气', '唉', '无奈', '疲惫', '累了', '困了'], }; const state = { prefs: mergePrefs(DEFAULT_PREFS, {}), library: [], book: null, chapterIndex: 0, annotations: { bookmarks: [], tags: [] }, aiHistory: [], voices: [], sidebarTab: 'chapters', appOpen: false, editMode: false, activeParagraph: 0, pendingSelection: null, importCancelled: false, tts: { engine: 'local', playing: false, paused: false, token: 0, segments: [], index: 0, utterance: null, audioEl: null, audioUrl: '', onlinePending: false, fallbackLocal: false, }, }; let shadow; let app; let fab; let savePrefsTimer = 0; let saveChapterTimer = 0; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function mergePrefs(base, incoming) { const result = JSON.parse(JSON.stringify(base)); Object.entries(incoming || {}).forEach(([key, value]) => { if ( value && typeof value === 'object' && !Array.isArray(value) && result[key] && typeof result[key] === 'object' ) { result[key] = mergePrefs(result[key], value); } else if (value !== undefined) { result[key] = value; } }); return result; } function storageGet(key, fallback) { try { const value = GM_getValue(key, fallback); return value === undefined ? fallback : value; } catch (_) { return fallback; } } function storageSet(key, value) { try { GM_setValue(key, value); return true; } catch (error) { toast('存储失败:内容可能过大', 'error'); console.error(error); return false; } } function storageDelete(key) { try { GM_deleteValue(key); } catch (_) { // 某些用户脚本管理器未提供删除接口,保留旧值不影响使用。 } } function loadPrefs() { const saved = storageGet(PREFS_KEY, ''); if (saved) { try { const parsed = typeof saved === 'string' ? JSON.parse(saved) : saved; if (parsed?.tts && !parsed.tts.localVoice && parsed.tts.voice) { parsed.tts.localVoice = parsed.tts.voice; } if (!['local', 'volcano', 'tencent'].includes(parsed?.tts?.engine)) { if (parsed?.tts) parsed.tts.engine = 'local'; } state.prefs = mergePrefs(DEFAULT_PREFS, parsed); } catch (_) { state.prefs = mergePrefs(DEFAULT_PREFS, {}); } } const legacyEngine = storageGet('voiceMode', ''); if (!saved && ['local', 'volcano', 'tencent'].includes(legacyEngine)) { state.prefs.tts.engine = legacyEngine; } const legacyVoice = storageGet('selectedVoiceId', ''); if (legacyVoice) { if (legacyEngine === 'volcano' && (!saved || !state.prefs.tts.volcanoVoice)) { state.prefs.tts.volcanoVoice = legacyVoice; } else if (legacyEngine === 'tencent' && (!saved || !state.prefs.tts.tencentVoice)) { state.prefs.tts.tencentVoice = legacyVoice; } else if ( legacyEngine !== 'volcano' && legacyEngine !== 'tencent' && (!saved || !state.prefs.tts.localVoice) ) { state.prefs.tts.localVoice = legacyVoice; } } const volcanoAppId = storageGet('volcano_appid', ''); const volcanoToken = storageGet('volcano_token', ''); if (!state.prefs.tts.volcano.appid && volcanoAppId) state.prefs.tts.volcano.appid = volcanoAppId; if (!state.prefs.tts.volcano.token && volcanoToken) state.prefs.tts.volcano.token = volcanoToken; const volcanoCluster = storageGet('volcano_cluster', ''); if (volcanoCluster) state.prefs.tts.volcano.cluster = volcanoCluster; const tencentSecretId = storageGet('tencent_secretId', ''); const tencentSecretKey = storageGet('tencent_secretKey', ''); if (!state.prefs.tts.tencent.secretId && tencentSecretId) { state.prefs.tts.tencent.secretId = tencentSecretId; } if (!state.prefs.tts.tencent.secretKey && tencentSecretKey) { state.prefs.tts.tencent.secretKey = tencentSecretKey; } } function scheduleSavePrefs() { clearTimeout(savePrefsTimer); savePrefsTimer = setTimeout(() => { storageSet(PREFS_KEY, JSON.stringify(state.prefs)); }, 250); } function loadLibrary() { const saved = storageGet(LIBRARY_KEY, ''); if (!saved) { state.library = []; return; } try { const parsed = typeof saved === 'string' ? JSON.parse(saved) : saved; state.library = Array.isArray(parsed) ? parsed : []; } catch (_) { state.library = []; } } function saveLibrary() { storageSet(LIBRARY_KEY, JSON.stringify(state.library)); } function bookStorageKey(id) { return BOOK_PREFIX + id; } function annotationStorageKey(id) { return ANNOTATION_PREFIX + id; } function aiStorageKey(id) { return AI_PREFIX + id; } function getTheme() { const themes = state.prefs.theme === 'dark' ? DARK_THEMES : DAY_THEMES; return themes[clamp(Number(state.prefs.accent) || 0, 0, themes.length - 1)]; } function applyTheme() { if (!app) return; const theme = getTheme(); app.dataset.theme = state.prefs.theme; const root = app.style; root.setProperty('--bg', theme.bg); root.setProperty('--panel', theme.panel); root.setProperty('--soft', theme.soft); root.setProperty('--text', theme.text); root.setProperty('--sub', theme.sub); root.setProperty('--line', theme.line); root.setProperty('--accent', theme.accent); root.setProperty('--font-reader', state.prefs.fontFamily); root.setProperty('--font-size-reader', state.prefs.fontSize + 'px'); root.setProperty('--line-height-reader', String(state.prefs.lineHeight)); root.setProperty('--reader-width', state.prefs.contentWidth + 'px'); } function createId(prefix) { return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } function normalizeText(text) { return String(text || '') .replace(/^\uFEFF/, '') .replace(/\r\n?/g, '\n') .replace(/[\t\f\v]+/g, ' ') .replace(/[ \u00A0]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function decodeFanqieText(text) { return String(text || '').replace(/[\uE000-\uF8FF]/g, char => ( FANQIE_GLYPHS[char] || char )); } function cleanNovelText(text) { return normalizeText(String(text || '') .replace(/[◆◇★☆●◎■□『』]/g, '') .replace(/(?:网址|微信|公众号|关注公众号)[::].*$/gm, '') .replace(/https?:\/\/\S+/gi, '') .replace(/[ \t]{2,}/g, ' ')); } function splitParagraphs(text) { return normalizeText(text) .split(/\n+/) .map(paragraph => paragraph.trim()) .filter(Boolean); } function splitSpeechText(text, maxLength = 360) { const source = String(text || '').trim(); if (!source) return []; if (source.length <= maxLength) return [source]; const pieces = source.match(/[^。!?!?;;…]+[。!?!?;;…]*/g) || [source]; const result = []; let buffer = ''; for (const piece of pieces) { if ((buffer + piece).length > maxLength && buffer) { result.push(buffer.trim()); buffer = ''; } if (piece.length > maxLength) { for (let index = 0; index < piece.length; index += maxLength) { const chunk = piece.slice(index, index + maxLength).trim(); if (chunk) result.push(chunk); } } else { buffer += piece; } } if (buffer.trim()) result.push(buffer.trim()); return result; } function formatDate(timestamp) { if (!timestamp) return ''; try { return new Date(timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } catch (_) { return ''; } } function debounce(callback, wait) { let timer = 0; return function (...args) { clearTimeout(timer); timer = setTimeout(() => callback.apply(this, args), wait); }; } function toast(message, type = 'info') { if (!shadow) return; const node = shadow.getElementById('xiziToast'); if (!node) return; node.textContent = message; node.className = 'toast show ' + type; clearTimeout(node._timer); node._timer = setTimeout(() => { node.className = 'toast'; }, 2800); } function setStatus(message) { if (!shadow) return; const node = shadow.getElementById('statusText'); if (node) node.textContent = message; } function downloadText(filename, content, mime = 'text/plain;charset=utf-8') { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.style.display = 'none'; document.documentElement.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function safeFileName(name) { return String(name || '小说').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 80); } const CSS_TEXT = ` :host { all: initial; } *, *::before, *::after { box-sizing: border-box; } button, input, select, textarea { font: inherit; } button { color: inherit; } [hidden] { display: none !important; } .fab { position: fixed; right: 22px; bottom: 92px; z-index: 2147483646; width: 56px; height: 56px; border: 1px solid rgba(255,255,255,.24); border-radius: 50%; background: linear-gradient(145deg, #8267ff, #d55cff); color: #fff; font-size: 25px; box-shadow: 0 12px 32px rgba(81, 54, 180, .42); cursor: grab; pointer-events: auto; touch-action: none; transition: transform .18s ease, box-shadow .18s ease; } .fab:hover { transform: translateY(-2px) scale(1.04); box-shadow: 0 16px 40px rgba(81, 54, 180, .55); } .fab:active { cursor: grabbing; transform: scale(.96); } .app { --bg: #121418; --panel: #1a1d23; --soft: #242831; --text: #e9ebef; --sub: #9da3ae; --line: #323844; --accent: #9b87ff; --danger: #ff6b7a; --ok: #6ed48f; --shadow: 0 24px 80px rgba(0,0,0,.48); position: fixed; inset: 12px; z-index: 2147483647; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 22px; background: var(--bg); color: var(--text); box-shadow: var(--shadow); font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif; font-size: 14px; line-height: 1.5; pointer-events: auto; } .app[data-theme="light"] { --shadow: 0 24px 80px rgba(45, 40, 30, .22); } .topbar { min-height: 58px; display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 94%, transparent); backdrop-filter: blur(16px); } .brand { display: flex; align-items: center; gap: 9px; min-width: 150px; font-weight: 800; letter-spacing: .02em; } .brand-logo { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 45%, #ff8a76)); color: #fff; box-shadow: 0 5px 16px color-mix(in srgb, var(--accent) 35%, transparent); } .brand small { display: block; color: var(--sub); font-size: 10px; font-weight: 500; letter-spacing: 0; } .toolbar { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; overflow-x: auto; scrollbar-width: none; } .toolbar::-webkit-scrollbar { display: none; } .tool, .icon-button, .small-button, .primary-button, .danger-button { border: 1px solid var(--line); border-radius: 10px; background: var(--soft); color: var(--text); cursor: pointer; transition: border-color .16s ease, background .16s ease, transform .16s ease; } .tool, .small-button, .primary-button, .danger-button { min-height: 34px; padding: 0 11px; white-space: nowrap; } .icon-button { width: 34px; height: 34px; padding: 0; display: grid; place-items: center; } .tool:hover, .icon-button:hover, .small-button:hover { border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); transform: translateY(-1px); } .primary-button { border-color: transparent; background: var(--accent); color: #fff; font-weight: 700; } .primary-button:hover { filter: brightness(1.08); } .danger-button { color: var(--danger); } .small-button { min-height: 29px; padding: 0 9px; font-size: 12px; } button:disabled { opacity: .45; cursor: not-allowed; transform: none !important; } .workspace { position: relative; display: grid; grid-template-columns: 290px minmax(0, 1fr); flex: 1; min-height: 0; } .app.sidebar-hidden .workspace { grid-template-columns: minmax(0, 1fr); } .app.sidebar-hidden .sidebar { display: none; } .sidebar { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); } .side-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 12px 8px; } .side-title { font-size: 13px; font-weight: 800; } .side-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 0 10px 10px; } .side-tab { min-height: 30px; border: 0; border-radius: 8px; background: transparent; color: var(--sub); cursor: pointer; font-size: 12px; } .side-tab.active { background: var(--soft); color: var(--text); font-weight: 700; } .side-pane { min-height: 0; flex: 1; overflow: auto; padding: 0 9px 12px; } .side-search { width: 100%; height: 34px; margin: 0 0 8px; padding: 0 10px; border: 1px solid var(--line); border-radius: 9px; outline: none; background: var(--bg); color: var(--text); } .side-search:focus { border-color: var(--accent); } .book-card, .chapter-item, .annotation-item { width: 100%; border: 1px solid transparent; border-radius: 11px; background: transparent; color: var(--text); cursor: pointer; text-align: left; } .book-card { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 9px; min-height: 58px; padding: 8px; margin-bottom: 5px; } .book-card:hover, .chapter-item:hover, .annotation-item:hover { background: var(--soft); } .book-card.active, .chapter-item.active { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 13%, var(--panel)); } .book-icon { width: 36px; height: 42px; display: grid; place-items: center; border-radius: 7px; background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 45%, #2b2430)); color: #fff; font-size: 17px; } .book-meta, .annotation-meta { min-width: 0; } .book-title, .chapter-name, .annotation-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; } .book-sub, .annotation-sub { color: var(--sub); font-size: 11px; margin-top: 3px; } .delete-inline { opacity: 0; width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; color: var(--danger); cursor: pointer; } .book-card:hover .delete-inline { opacity: 1; } .chapter-item { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 7px 9px; margin-bottom: 2px; font-size: 13px; } .chapter-index { width: 30px; flex: none; color: var(--sub); font-size: 10px; text-align: right; } .annotation-item { display: block; padding: 9px; margin-bottom: 6px; } .empty-side { padding: 30px 12px; color: var(--sub); text-align: center; font-size: 12px; line-height: 1.8; } .main { position: relative; min-width: 0; min-height: 0; display: flex; overflow: hidden; background: radial-gradient(circle at 80% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 38%), var(--bg); } .welcome { width: 100%; display: grid; place-items: center; padding: 30px; overflow: auto; } .welcome-card { width: min(660px, 100%); text-align: center; } .welcome-logo { width: 86px; height: 86px; margin: 0 auto 18px; display: grid; place-items: center; border-radius: 26px; background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 40%, #ff9b83)); color: #fff; font-size: 40px; box-shadow: 0 18px 46px color-mix(in srgb, var(--accent) 30%, transparent); } .welcome h1 { margin: 0; font-size: clamp(25px, 4vw, 38px); letter-spacing: .04em; } .welcome p { margin: 10px auto 24px; color: var(--sub); line-height: 1.8; } .welcome-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; } .welcome-actions .primary-button, .welcome-actions .small-button { min-height: 42px; padding: 0 18px; } .feature-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 24px; } .feature-card { padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: color-mix(in srgb, var(--panel) 88%, transparent); text-align: left; } .feature-card b { display: block; margin-bottom: 5px; } .feature-card span { color: var(--sub); font-size: 12px; line-height: 1.6; } .reader-view { display: flex; flex: 1; min-width: 0; flex-direction: column; } .chapter-head { display: flex; align-items: center; gap: 12px; padding: 13px 22px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 84%, transparent); backdrop-filter: blur(12px); } .chapter-heading { min-width: 0; flex: 1; } .chapter-heading h2 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 17px; } .chapter-heading small { display: block; margin-top: 2px; color: var(--sub); } .head-actions { display: flex; gap: 6px; } .reader-scroll { min-height: 0; flex: 1; overflow: auto; scroll-behavior: smooth; } .reader-paper { width: min(var(--reader-width), calc(100% - 44px)); min-height: calc(100% - 80px); margin: 22px auto 70px; padding: clamp(24px, 5vw, 58px); border: 1px solid var(--line); border-radius: 17px; background: color-mix(in srgb, var(--panel) 97%, transparent); box-shadow: 0 18px 48px rgba(0,0,0,.12); outline: none; } .reader-content { color: var(--text); font-family: var(--font-reader); font-size: var(--font-size-reader); line-height: var(--line-height-reader); letter-spacing: .025em; caret-color: var(--accent); } .reader-content[contenteditable="true"] { outline: 2px dashed color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 10px; border-radius: 8px; } .reader-content p { position: relative; margin: 0 0 .92em; text-indent: 2em; border-radius: 6px; transition: background .15s ease, box-shadow .15s ease; } .reader-content p:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); } .reader-content p.active { box-shadow: -12px 0 0 color-mix(in srgb, var(--accent) 18%, transparent); } .reader-content p.speaking { background: color-mix(in srgb, var(--accent) 18%, transparent); box-shadow: -12px 0 0 var(--accent); } .reader-content mark.reader-tag { border-radius: 3px; padding: 0 .08em; background: color-mix(in srgb, #ffd34d 62%, transparent); color: inherit; box-shadow: inset 0 -1px 0 rgba(130, 90, 0, .35); cursor: help; } .chapter-pager { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: min(var(--reader-width), calc(100% - 44px)); margin: -46px auto 28px; position: relative; z-index: 2; } .chapter-pager button { min-width: 108px; } .chapter-pager span { color: var(--sub); font-size: 12px; } .empty-reader { display: grid; place-items: center; height: 100%; color: var(--sub); } .statusbar { min-height: 32px; display: flex; align-items: center; gap: 12px; padding: 5px 14px; border-top: 1px solid var(--line); background: var(--panel); color: var(--sub); font-size: 11px; } .statusbar .grow { flex: 1; } .tts-state { color: var(--accent); font-weight: 650; } .tts-bar { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 96%, transparent); } .tts-bar select, .field select, .field input, .field textarea { border: 1px solid var(--line); border-radius: 9px; outline: none; background: var(--bg); color: var(--text); } .tts-bar select { height: 34px; min-width: 150px; padding: 0 8px; } .tts-bar .tts-progress { flex: 1; min-width: 90px; display: flex; align-items: center; gap: 8px; color: var(--sub); font-size: 11px; } .progress-track { height: 5px; flex: 1; overflow: hidden; border-radius: 99px; background: var(--soft); } .progress-fill { width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 42%, #ff8a76)); transition: width .2s ease; } .drawer { position: absolute; top: 0; right: 0; bottom: 0; z-index: 10; width: min(440px, 92%); display: flex; flex-direction: column; border-left: 1px solid var(--line); background: var(--panel); box-shadow: -18px 0 48px rgba(0,0,0,.22); transform: translateX(105%); transition: transform .22s ease; } .drawer.open { transform: translateX(0); } .drawer-head { display: flex; align-items: center; justify-content: space-between; min-height: 52px; padding: 10px 14px; border-bottom: 1px solid var(--line); } .drawer-head h3 { margin: 0; font-size: 15px; } .drawer-body { min-height: 0; flex: 1; overflow: auto; padding: 14px; } .section-title { margin: 4px 0 10px; color: var(--sub); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } .field { display: grid; gap: 5px; margin-bottom: 12px; } .field > label { color: var(--sub); font-size: 12px; } .field input, .field select { width: 100%; height: 36px; padding: 0 9px; } .field textarea { width: 100%; min-height: 86px; padding: 9px; resize: vertical; line-height: 1.55; } .field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); } .field-row { display: grid; grid-template-columns: 1fr 60px; gap: 10px; align-items: center; } .field-row input[type="range"] { padding: 0; border: 0; accent-color: var(--accent); } .range-value { color: var(--text); font-variant-numeric: tabular-nums; text-align: right; } .check { display: flex; align-items: center; gap: 8px; margin: 9px 0; color: var(--text); } .check input { accent-color: var(--accent); } .settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; } .modal-mask { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; padding: 18px; background: rgba(6, 8, 12, .52); backdrop-filter: blur(6px); } .modal { width: min(520px, 100%); max-height: calc(100% - 12px); overflow: auto; padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); box-shadow: var(--shadow); } .modal h3 { margin: 0 0 6px; font-size: 19px; } .modal p { margin: 0 0 16px; color: var(--sub); font-size: 12px; line-height: 1.7; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } .import-progress { margin-top: 14px; } .import-log { max-height: 150px; overflow: auto; margin-top: 10px; padding: 9px; border-radius: 9px; background: var(--bg); color: var(--sub); font: 11px/1.6 Consolas, monospace; white-space: pre-wrap; } .ai-messages { min-height: 200px; display: flex; flex-direction: column; gap: 9px; margin-bottom: 12px; } .ai-bubble { padding: 10px 11px; border-radius: 12px; white-space: pre-wrap; word-break: break-word; line-height: 1.65; font-size: 13px; } .ai-bubble.user { align-self: flex-end; max-width: 88%; background: var(--accent); color: #fff; } .ai-bubble.assistant { align-self: flex-start; max-width: 94%; background: var(--soft); } .ai-bubble.system { align-self: center; color: var(--sub); background: transparent; text-align: center; font-size: 11px; } .ai-compose { display: grid; gap: 8px; } .ai-compose textarea { width: 100%; min-height: 76px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; outline: none; resize: vertical; background: var(--bg); color: var(--text); line-height: 1.6; } .quick-prompts { display: flex; gap: 6px; flex-wrap: wrap; } .toast { position: fixed; left: 50%; bottom: 28px; z-index: 30; max-width: min(620px, calc(100% - 40px)); padding: 9px 16px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--text); box-shadow: 0 12px 32px rgba(0,0,0,.28); opacity: 0; pointer-events: none; transform: translate(-50%, 12px); transition: opacity .18s ease, transform .18s ease; font-size: 12px; } .toast.show { opacity: 1; transform: translate(-50%, 0); } .toast.error { color: var(--danger); } .toast.ok { color: var(--ok); } @media (max-width: 840px) { .app { inset: 0; border-radius: 0; border: 0; } .workspace { grid-template-columns: 1fr; } .sidebar { position: absolute; inset: 0 auto 0 0; z-index: 12; width: min(86vw, 330px); box-shadow: 16px 0 40px rgba(0,0,0,.3); transform: translateX(-104%); transition: transform .2s ease; } .sidebar.mobile-open { transform: translateX(0); } .brand { min-width: 0; } .brand > div:last-child { display: none; } .tool span { display: none; } .tool { width: 36px; padding: 0; } .chapter-head { padding: 10px 12px; } .head-actions .small-button { min-height: 30px; padding: 0 7px; font-size: 11px; } .reader-paper { width: calc(100% - 20px); margin-top: 10px; padding: 23px 20px 38px; } .chapter-pager { width: calc(100% - 24px); } .feature-grid { grid-template-columns: 1fr; } .tts-bar { display: flex; flex-wrap: wrap; gap: 5px; padding: 7px 9px; } .tts-bar select { min-width: 0; width: auto; flex: 1 1 82px; height: 31px; padding: 0 5px; font-size: 11px; } .tts-bar .icon-button { flex: 0 0 34px; width: 34px; height: 34px; } .tts-bar .tts-progress { flex: 1 0 100%; } .statusbar .desktop-only { display: none; } } `; const MARKUP_TEXT = ` `; function createUI() { const host = document.createElement('div'); host.id = APP_ID; host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;'; document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS_TEXT; shadow.appendChild(style); const wrapper = document.createElement('div'); wrapper.innerHTML = MARKUP_TEXT; while (wrapper.firstChild) shadow.appendChild(wrapper.firstChild); app = shadow.getElementById('xiziApp'); fab = shadow.getElementById('xiziFab'); applyTheme(); bindEvents(); populateVoices(); loadLibrary(); loadPrefs(); applyTheme(); syncSettingsControls(); renderLibrary(); renderSidebar(); updateToolbarState(); updateTtsBar(); const activeBookId = storageGet(ACTIVE_BOOK_KEY, ''); if (activeBookId) { const meta = state.library.find(item => item.id === activeBookId); if (meta) { setStatus('上次阅读:' + meta.title + ',点击目录或书本继续'); state.sidebarTab = 'library'; renderSidebar(); } } } function getBookMeta(book) { return { id: book.id, title: book.title, type: book.type || 'local', source: book.source || '', createdAt: book.createdAt || Date.now(), updatedAt: Date.now(), chapterCount: book.chapters.length, lastChapter: state.chapterIndex || 0, }; } function updateLibraryMeta(book) { const meta = getBookMeta(book); const index = state.library.findIndex(item => item.id === meta.id); if (index >= 0) state.library[index] = { ...state.library[index], ...meta }; else state.library.unshift(meta); state.library.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); saveLibrary(); renderLibrary(); } function saveCurrentBook() { if (!state.book) return false; state.book.updatedAt = Date.now(); const saved = storageSet(bookStorageKey(state.book.id), JSON.stringify(state.book)); if (saved) updateLibraryMeta(state.book); return saved; } function saveAnnotations() { if (!state.book) return; storageSet(annotationStorageKey(state.book.id), JSON.stringify(state.annotations)); } function loadAnnotations(bookId) { const saved = storageGet(annotationStorageKey(bookId), ''); if (!saved) return { bookmarks: [], tags: [] }; try { const parsed = typeof saved === 'string' ? JSON.parse(saved) : saved; return { bookmarks: Array.isArray(parsed.bookmarks) ? parsed.bookmarks : [], tags: Array.isArray(parsed.tags) ? parsed.tags : [], }; } catch (_) { return { bookmarks: [], tags: [] }; } } function saveAiHistory() { if (!state.book) return; storageSet(aiStorageKey(state.book.id), JSON.stringify(state.aiHistory.slice(-40))); } function loadAiHistory(bookId) { const saved = storageGet(aiStorageKey(bookId), ''); if (!saved) return []; try { const parsed = typeof saved === 'string' ? JSON.parse(saved) : saved; return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } function parseNovelText(rawText, title = '未命名小说') { const text = normalizeText(rawText); const chapterPattern = /^[ \t]*(第[0-9一二三四五六七八九十百千零两]+[章节卷集回篇][^\n]{0,60})[ \t]*$/gm; const matches = [...text.matchAll(chapterPattern)]; const chapters = []; if (matches.length) { const preface = text.slice(0, matches[0].index).trim(); if (preface) { chapters.push({ title: '前言', content: cleanNovelText(preface), sourceUrl: '', }); } matches.forEach((match, index) => { const heading = match[1].trim(); const contentStart = match.index + match[0].length; const contentEnd = index + 1 < matches.length ? matches[index + 1].index : text.length; const content = cleanNovelText(text.slice(contentStart, contentEnd)); chapters.push({ title: heading || '第 ' + (index + 1) + ' 章', content, sourceUrl: '', }); }); } else { chapters.push({ title: title || '全文', content: cleanNovelText(text), sourceUrl: '', }); } return { id: createId('local'), title, type: 'local', source: '本地 TXT', createdAt: Date.now(), updatedAt: Date.now(), chapters, }; } function collectCurrentPageParagraphs() { const fanqieRoot = document.querySelector('.muye-reader-content'); if (fanqieRoot) { const paragraphs = [...fanqieRoot.querySelectorAll('p')] .map(node => decodeFanqieText(node.innerText || node.textContent || '').trim()) .filter(Boolean); return { title: document.title.split(/第[0-9一二三四五六七八九十百千零]+章/)[0].trim() || '网页正文', chapterTitle: document.querySelector('.muye-reader-title')?.textContent?.trim() || document.title.trim() || '当前网页', paragraphs, }; } const selectors = [ 'article', 'main', '.content', '.chapter', '.book-content', '#content', '#chapter', '.reader-content', '.novel-content', '.text-content', '.article-content', '.post-content', '[role="main"]', '.entry-content', '.read-content', '.story-body', '.article-body', '.page-content', ]; let root = null; for (const selector of selectors) { const candidate = document.querySelector(selector); if (candidate && (candidate.innerText || candidate.textContent || '').trim().length > 150) { root = candidate; break; } } root = root || document.body; const ignoredTags = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT', 'NAV', 'FOOTER', 'HEADER', ]); const isIgnored = node => { let current = node; while (current && current !== document.body) { if (ignoredTags.has(current.tagName)) return true; current = current.parentElement; } return false; }; const isVisible = node => { if (!node.getClientRects().length) return false; const style = getComputedStyle(node); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; }; const nodes = [...root.querySelectorAll('p,article,section,blockquote,h1,h2,h3,h4,h5,h6,li')] .filter(node => !isIgnored(node) && isVisible(node)) .map(node => ({ node, text: decodeFanqieText(node.innerText || node.textContent || '') .replace(/\s+/g, ' ') .trim(), })) .filter(item => /[\p{L}\p{N}]/u.test(item.text)); const leaves = nodes.filter(item => ( !nodes.some(other => other.node !== item.node && item.node.contains(other.node)) )); let paragraphs = leaves.map(item => item.text); if (!paragraphs.length) { paragraphs = decodeFanqieText(root.innerText || root.textContent || '') .split(/\n+/) .map(text => text.trim()) .filter(Boolean); } const title = document.querySelector('meta[property="og:title"]')?.content?.trim() || document.title.trim() || '网页正文'; const chapterTitle = root.querySelector('h1')?.textContent?.trim() || document.querySelector('h1')?.textContent?.trim() || title; return { title, chapterTitle, paragraphs }; } function importCurrentPage() { try { const extracted = collectCurrentPageParagraphs(); if (!extracted.paragraphs.length) { toast('当前网页没有提取到可朗读的正文', 'error'); return; } const paragraphs = extracted.paragraphs.slice(); if ( paragraphs.length > 1 && [extracted.title, extracted.chapterTitle].filter(Boolean).includes(paragraphs[0]) ) { paragraphs.shift(); } const content = cleanNovelText(paragraphs.join('\n\n')); const book = { id: createId('webpage'), title: extracted.title || '网页正文', type: 'webpage', source: location.href, createdAt: Date.now(), updatedAt: Date.now(), chapters: [{ title: extracted.chapterTitle || extracted.title || '当前网页', content, sourceUrl: location.href, }], }; openNewBook(book); } catch (error) { console.error(error); toast('读取当前网页失败:' + error.message, 'error'); } } function decodeTextBuffer(buffer) { const bytes = new Uint8Array(buffer); const candidates = []; if (bytes[0] === 0xFF && bytes[1] === 0xFE) candidates.push('utf-16le'); if (bytes[0] === 0xFE && bytes[1] === 0xFF) candidates.push('utf-16be'); if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) candidates.push('utf-8'); candidates.push('utf-8', 'gb18030', 'gbk', 'utf-16le', 'utf-16be'); for (const encoding of [...new Set(candidates)]) { try { return new TextDecoder(encoding, { fatal: true }).decode(buffer); } catch (_) { // 尝试下一种常见中文编码。 } } return new TextDecoder('utf-8').decode(buffer); } async function readFileAsText(file) { const buffer = await file.arrayBuffer(); return decodeTextBuffer(buffer); } function importProject(data) { if (!data || data.format !== 'xizi-reader-project' || !data.book) { throw new Error('不是有效的西子拆书备份文件'); } const book = data.book; if (!Array.isArray(book.chapters) || !book.chapters.length) { throw new Error('备份中没有章节内容'); } stopTts(true); if (!book.id) book.id = createId('import'); book.updatedAt = Date.now(); state.book = book; state.chapterIndex = clamp(Number(data.chapterIndex) || 0, 0, book.chapters.length - 1); state.annotations = data.annotations || { bookmarks: [], tags: [] }; state.aiHistory = []; state.editMode = false; if (!saveCurrentBook()) return; saveAnnotations(); storageSet(ACTIVE_BOOK_KEY, book.id); renderBook(); showLibraryView(); toast('备份导入成功', 'ok'); } async function handleFile(file) { if (!file) return; try { setStatus('正在读取文件…'); const text = await readFileAsText(file); const trimmed = text.trim(); if (trimmed.startsWith('{')) { importProject(JSON.parse(trimmed)); } else { const title = file.name.replace(/\.(txt|json)$/i, '') || '未命名小说'; const book = parseNovelText(text, title); if (!book.chapters.some(chapter => chapter.content.trim())) { throw new Error('文件正文为空'); } openNewBook(book); } setStatus('文件导入完成'); } catch (error) { console.error(error); toast('导入失败:' + error.message, 'error'); setStatus('导入失败'); } finally { const input = shadow.getElementById('fileInput'); if (input) input.value = ''; } } function openNewBook(book) { stopTts(true); state.book = book; state.chapterIndex = 0; state.annotations = { bookmarks: [], tags: [] }; state.aiHistory = []; state.editMode = false; if (!saveCurrentBook()) return; saveAnnotations(); storageSet(ACTIVE_BOOK_KEY, book.id); renderBook(); showLibraryView(); toast('已导入《' + book.title + '》', 'ok'); } function loadBook(bookId) { const saved = storageGet(bookStorageKey(bookId), ''); if (!saved) { toast('书籍内容不存在,可能已被清理', 'error'); return; } try { stopTts(true); state.book = typeof saved === 'string' ? JSON.parse(saved) : saved; if (!state.book || !Array.isArray(state.book.chapters)) { throw new Error('数据结构异常'); } const meta = state.library.find(item => item.id === bookId); state.chapterIndex = clamp( Number(meta?.lastChapter) || 0, 0, Math.max(0, state.book.chapters.length - 1) ); state.annotations = loadAnnotations(bookId); state.aiHistory = loadAiHistory(bookId); state.editMode = false; state.pendingSelection = null; storageSet(ACTIVE_BOOK_KEY, bookId); renderBook(); setStatus('已打开《' + state.book.title + '》'); } catch (error) { console.error(error); toast('打开书籍失败:' + error.message, 'error'); } } function deleteBook(bookId) { const meta = state.library.find(item => item.id === bookId); if (!meta) return; if (!confirm('删除《' + meta.title + '》及其书签、标签和 AI 对话?')) return; state.library = state.library.filter(item => item.id !== bookId); saveLibrary(); storageDelete(bookStorageKey(bookId)); storageDelete(annotationStorageKey(bookId)); storageDelete(aiStorageKey(bookId)); if (state.book?.id === bookId) { stopTts(true); state.book = null; state.annotations = { bookmarks: [], tags: [] }; state.aiHistory = []; state.editMode = false; storageDelete(ACTIVE_BOOK_KEY); renderBook(); } renderLibrary(); toast('书籍已删除', 'ok'); } function renderLibrary() { if (!shadow) return; const list = shadow.getElementById('libraryList'); if (!list) return; list.textContent = ''; if (!state.library.length) { const empty = document.createElement('div'); empty.className = 'empty-side'; empty.textContent = '书架还是空的。点击“本地导入”或“番茄导入”添加第一本书。'; list.appendChild(empty); return; } state.library.forEach(meta => { const card = document.createElement('div'); card.className = 'book-card' + (state.book?.id === meta.id ? ' active' : ''); card.dataset.openBook = meta.id; card.tabIndex = 0; card.setAttribute('role', 'button'); const icon = document.createElement('div'); icon.className = 'book-icon'; icon.textContent = meta.type === 'fanqie' ? '🍅' : '📚'; const info = document.createElement('div'); info.className = 'book-meta'; const title = document.createElement('div'); title.className = 'book-title'; title.textContent = meta.title; const sub = document.createElement('div'); sub.className = 'book-sub'; sub.textContent = `${meta.chapterCount || 0} 章 · ${formatDate(meta.updatedAt)}`; info.append(title, sub); const remove = document.createElement('button'); remove.className = 'delete-inline'; remove.type = 'button'; remove.title = '删除书籍'; remove.textContent = '✕'; remove.dataset.deleteBook = meta.id; card.append(icon, info, remove); list.appendChild(card); }); } function renderSidebar() { if (!shadow) return; const titleMap = { library: '我的书架', chapters: '章节目录', annotations: '书签与标签' }; shadow.getElementById('sideTitle').textContent = titleMap[state.sidebarTab] || '我的书架'; shadow.querySelectorAll('[data-side-tab]').forEach(button => { button.classList.toggle('active', button.dataset.sideTab === state.sidebarTab); }); shadow.getElementById('libraryPane').hidden = state.sidebarTab !== 'library'; shadow.getElementById('chapterPane').hidden = state.sidebarTab !== 'chapters'; shadow.getElementById('annotationPane').hidden = state.sidebarTab !== 'annotations'; if (state.sidebarTab === 'library') renderLibrary(); if (state.sidebarTab === 'chapters') renderChapterList(); if (state.sidebarTab === 'annotations') renderAnnotations(); } function showLibraryView() { state.sidebarTab = 'library'; renderSidebar(); openSidebarMobile(); } function renderChapterList() { if (!shadow) return; const container = shadow.getElementById('chapterList'); if (!container) return; container.textContent = ''; if (!state.book) { const empty = document.createElement('div'); empty.className = 'empty-side'; empty.textContent = '尚未打开书籍。'; container.appendChild(empty); return; } const query = (shadow.getElementById('chapterSearch')?.value || '').trim().toLowerCase(); const chapters = state.book.chapters .map((chapter, index) => ({ chapter, index })) .filter(({ chapter }) => !query || chapter.title.toLowerCase().includes(query)); if (!chapters.length) { const empty = document.createElement('div'); empty.className = 'empty-side'; empty.textContent = '没有匹配的章节。'; container.appendChild(empty); return; } chapters.forEach(({ chapter, index }) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'chapter-item' + (index === state.chapterIndex ? ' active' : ''); button.dataset.chapterIndex = String(index); const number = document.createElement('span'); number.className = 'chapter-index'; number.textContent = String(index + 1).padStart(2, '0'); const name = document.createElement('span'); name.className = 'chapter-name'; name.textContent = chapter.title; button.append(number, name); container.appendChild(button); }); } function renderAnnotations() { if (!shadow) return; const bookmarkList = shadow.getElementById('bookmarkList'); const tagList = shadow.getElementById('tagList'); if (!bookmarkList || !tagList) return; bookmarkList.textContent = ''; tagList.textContent = ''; if (!state.annotations.bookmarks.length) { const empty = document.createElement('div'); empty.className = 'empty-side'; empty.textContent = '暂无书签。阅读时点击章节标题栏的 🔖。'; bookmarkList.appendChild(empty); } else { state.annotations.bookmarks.forEach(bookmark => { bookmarkList.appendChild(createAnnotationNode( bookmark, state.book?.chapters[bookmark.chapterIndex]?.title || '未知章节', 'bookmark' )); }); } if (!state.annotations.tags.length) { const empty = document.createElement('div'); empty.className = 'empty-side'; empty.textContent = '暂无文本标签。选中一段文字后点击 🏷️。'; tagList.appendChild(empty); } else { state.annotations.tags.forEach(tag => { tagList.appendChild(createAnnotationNode( { ...tag, title: tag.note || tag.quote }, state.book?.chapters[tag.chapterIndex]?.title || '未知章节', 'tag' )); }); } updateAnnotationActionLabels(); } function updateAnnotationActionLabels() { if (!shadow) return; const bookmarkButton = shadow.querySelector('[data-action="add-bookmark"]'); const tagButton = shadow.querySelector('[data-action="add-tag"]'); if (bookmarkButton) { bookmarkButton.textContent = `🔖 书签${state.annotations.bookmarks.length ? ' ' + state.annotations.bookmarks.length : ''}`; } if (tagButton) { tagButton.textContent = `🏷️ 标签${state.annotations.tags.length ? ' ' + state.annotations.tags.length : ''}`; } } function revealAnnotationPanel() { state.sidebarTab = 'annotations'; app.classList.remove('sidebar-hidden'); renderSidebar(); if (window.innerWidth <= 840) openSidebarMobile(); } function createAnnotationNode(item, chapterTitle, type) { const node = document.createElement('div'); node.className = 'annotation-item'; node.dataset.jumpAnnotation = String(item.id); node.dataset.annotationType = type; const title = document.createElement('div'); title.className = 'annotation-title'; title.textContent = item.title || item.quote || '标记'; const sub = document.createElement('div'); sub.className = 'annotation-sub'; sub.textContent = chapterTitle + (item.quote ? ' · ' + item.quote.slice(0, 34) : ''); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'delete-inline'; remove.style.opacity = '1'; remove.style.float = 'right'; remove.textContent = '✕'; remove.title = '删除标记'; remove.dataset.deleteAnnotation = String(item.id); remove.dataset.annotationType = type; node.append(remove, title, sub); return node; } function renderBook() { if (!shadow) return; const welcome = shadow.getElementById('welcomeView'); const reader = shadow.getElementById('readerView'); const ttsBar = shadow.getElementById('ttsBar'); const hasBook = Boolean(state.book?.chapters?.length); welcome.hidden = hasBook; reader.hidden = !hasBook; ttsBar.hidden = !hasBook; if (!hasBook) { shadow.getElementById('readerContent').textContent = ''; shadow.getElementById('statusBook').textContent = ''; renderSidebar(); updateToolbarState(); updateTtsBar(); return; } state.chapterIndex = clamp(state.chapterIndex, 0, state.book.chapters.length - 1); shadow.getElementById('statusBook').textContent = `${state.book.title} · ${state.chapterIndex + 1}/${state.book.chapters.length} 章`; renderChapter(); renderSidebar(); renderAnnotations(); renderAiMessages(); updateToolbarState(); updateTtsBar(); } function renderChapter(options = {}) { if (!shadow || !state.book) return; const chapter = state.book.chapters[state.chapterIndex]; if (!chapter) return; const content = shadow.getElementById('readerContent'); const title = shadow.getElementById('chapterTitle'); const meta = shadow.getElementById('chapterMeta'); const pager = shadow.getElementById('pagerText'); if (!content || !chapter) return; stopHighlight(); content.textContent = ''; content.contentEditable = state.editMode ? 'true' : 'false'; title.textContent = chapter.title || '未命名章节'; const paragraphs = splitParagraphs(chapter.content || ''); const chapterTags = state.annotations.tags.filter(tag => tag.chapterIndex === state.chapterIndex); if (!paragraphs.length) { const empty = document.createElement('p'); empty.textContent = '本章暂无正文。'; empty.style.textAlign = 'center'; empty.style.textIndent = '0'; empty.style.color = 'var(--sub)'; content.appendChild(empty); } else { paragraphs.forEach((text, paragraphIndex) => { const paragraph = document.createElement('p'); paragraph.dataset.paragraphIndex = String(paragraphIndex); appendTaggedText( paragraph, text, chapterTags.filter(tag => tag.paragraphIndex === paragraphIndex) ); content.appendChild(paragraph); }); } state.activeParagraph = clamp( Number.isFinite(options.paragraphIndex) ? options.paragraphIndex : state.activeParagraph, 0, Math.max(0, paragraphs.length - 1) ); const active = content.querySelector(`[data-paragraph-index="${state.activeParagraph}"]`); if (active) active.classList.add('active'); const charCount = (chapter.content || '').replace(/\s/g, '').length; meta.textContent = `第 ${state.chapterIndex + 1} / ${state.book.chapters.length} 章 · ${charCount.toLocaleString('zh-CN')} 字`; pager.textContent = `第 ${state.chapterIndex + 1} / ${state.book.chapters.length} 章`; shadow.querySelector('[data-action="prev-chapter"]').disabled = state.chapterIndex <= 0; shadow.querySelector('[data-action="next-chapter"]').disabled = state.chapterIndex >= state.book.chapters.length - 1; shadow.getElementById('statusBook').textContent = `${state.book.title} · ${state.chapterIndex + 1}/${state.book.chapters.length} 章`; updateToolbarState(); updateTtsBar(); if (options.restoreSpeaking) { const segment = state.tts.segments[state.tts.index]; if (segment && segment.chapterIndex === state.chapterIndex) { highlightTtsSegment(segment); } } } function appendTaggedText(paragraph, text, tags) { const sorted = tags .map(tag => ({ ...tag, start: clamp(Number(tag.start) || 0, 0, text.length), end: clamp(Number(tag.end) || 0, 0, text.length), })) .filter(tag => tag.end > tag.start) .sort((a, b) => a.start - b.start); let cursor = 0; sorted.forEach(tag => { if (tag.start < cursor) return; if (tag.start > cursor) paragraph.appendChild(document.createTextNode(text.slice(cursor, tag.start))); const mark = document.createElement('mark'); mark.className = 'reader-tag'; mark.dataset.tagId = tag.id; mark.title = tag.note || '文本标签'; mark.textContent = text.slice(tag.start, tag.end); paragraph.appendChild(mark); cursor = tag.end; }); if (cursor < text.length) paragraph.appendChild(document.createTextNode(text.slice(cursor))); } function setChapter(index, options = {}) { if (!state.book?.chapters?.length) return; const next = clamp(Number(index) || 0, 0, state.book.chapters.length - 1); if (next === state.chapterIndex && options.force !== true) { if (options.scroll !== false) scrollReaderTop(); return; } if (!options.preserveTts && (state.tts.playing || state.tts.paused)) stopTts(); if (state.editMode && !saveEditedChapter(true)) return; state.chapterIndex = next; state.activeParagraph = 0; state.pendingSelection = null; const meta = state.library.find(item => item.id === state.book.id); if (meta) { meta.lastChapter = state.chapterIndex; meta.updatedAt = Date.now(); saveLibrary(); } storageSet(ACTIVE_BOOK_KEY, state.book.id); renderChapter(); renderChapterList(); if (options.scroll !== false) scrollReaderTop(); if (window.innerWidth <= 840) closeSidebarMobile(); } function scrollReaderTop() { const scroll = shadow?.getElementById('readerScroll'); if (scroll) scroll.scrollTop = 0; } function setActiveParagraph(index, scroll = false) { if (!shadow) return; const content = shadow.getElementById('readerContent'); if (!content) return; state.activeParagraph = Math.max(0, Number(index) || 0); content.querySelectorAll('p.active').forEach(node => node.classList.remove('active')); const paragraph = content.querySelector(`[data-paragraph-index="${state.activeParagraph}"]`); if (paragraph) { paragraph.classList.add('active'); if (scroll) paragraph.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } function updateToolbarState() { if (!shadow) return; const editButton = shadow.querySelector('[data-action="toggle-edit"] span'); if (editButton) editButton.textContent = state.editMode ? '保存编辑' : '编辑'; if (!state.book) { shadow.querySelectorAll('[data-action="add-bookmark"],[data-action="add-tag"],[data-action="clean-text"]').forEach(button => { button.disabled = true; }); } else { shadow.querySelectorAll('[data-action="add-bookmark"],[data-action="add-tag"],[data-action="clean-text"]').forEach(button => { button.disabled = false; }); } } function toggleEditMode() { if (!state.book) { toast('请先打开一本书'); return; } if (state.editMode) { if (saveEditedChapter(false)) { state.editMode = false; renderChapter(); toast('编辑内容已保存', 'ok'); } return; } state.editMode = true; const content = shadow.getElementById('readerContent'); if (content) { content.contentEditable = 'true'; content.focus(); } updateToolbarState(); setStatus('编辑模式:修改后点击“保存编辑”'); clearTimeout(saveChapterTimer); saveChapterTimer = setTimeout(() => saveEditedChapter(true), 1200); } function saveEditedChapter(silent = false) { if (!state.book || !state.editMode) return true; const content = shadow.getElementById('readerContent'); const chapter = state.book.chapters[state.chapterIndex]; if (!content || !chapter) return false; const newText = normalizeText(content.innerText || content.textContent || ''); const changed = newText !== normalizeText(chapter.content || ''); if (changed) { chapter.content = newText; state.annotations.tags = state.annotations.tags.filter(tag => tag.chapterIndex !== state.chapterIndex); saveAnnotations(); if (!saveCurrentBook()) return false; renderAnnotations(); if (!silent) setStatus('当前章节已保存'); else toast('已自动保存当前章节', 'ok'); } return true; } function captureSelection() { if (!shadow || state.editMode) return; const selection = window.getSelection(); if (!selection || !selection.rangeCount || selection.isCollapsed) return; const range = selection.getRangeAt(0); const content = shadow.getElementById('readerContent'); if (!content || !content.contains(range.commonAncestorContainer)) return; const startParagraph = closestParagraph(range.startContainer); const endParagraph = closestParagraph(range.endContainer); if (!startParagraph || !endParagraph || !content.contains(startParagraph)) return; const preRange = document.createRange(); preRange.selectNodeContents(startParagraph); preRange.setEnd(range.startContainer, range.startOffset); const start = preRange.toString().length; const selectedRange = range.cloneRange(); selectedRange.selectNodeContents(startParagraph); selectedRange.setEnd(range.endContainer, range.endOffset); const end = selectedRange.toString().length; const quote = range.toString().trim(); if (!quote) return; state.pendingSelection = { chapterIndex: state.chapterIndex, paragraphIndex: Number(startParagraph.dataset.paragraphIndex) || 0, start: Math.min(start, end), end: Math.max(start, end), quote, multipleParagraphs: startParagraph !== endParagraph, }; setActiveParagraph(state.pendingSelection.paragraphIndex); } function closestParagraph(node) { if (!node) return null; const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; return element?.closest?.('#readerContent p') || null; } function addBookmark() { if (!state.book) return; const content = shadow.getElementById('readerContent'); const paragraph = content?.querySelector(`[data-paragraph-index="${state.activeParagraph}"]`); const quote = (paragraph?.textContent || '').trim(); if (!quote) { toast('当前位置没有可添加书签的正文'); return; } const exists = state.annotations.bookmarks.some( bookmark => bookmark.chapterIndex === state.chapterIndex && bookmark.paragraphIndex === state.activeParagraph ); if (exists) { toast('当前段落已有书签'); return; } state.annotations.bookmarks.unshift({ id: createId('bookmark'), chapterIndex: state.chapterIndex, paragraphIndex: state.activeParagraph, title: quote.slice(0, 26) + (quote.length > 26 ? '…' : ''), quote, createdAt: Date.now(), }); saveAnnotations(); renderAnnotations(); revealAnnotationPanel(); toast('书签已添加', 'ok'); } function addTag() { if (!state.book) return; captureSelection(); let selection = state.pendingSelection; let usedCurrentParagraph = false; if (!selection || selection.chapterIndex !== state.chapterIndex) { const content = shadow.getElementById('readerContent'); const paragraph = content?.querySelector(`[data-paragraph-index="${state.activeParagraph}"]`); const text = (paragraph?.textContent || '').trim(); if (!text) { toast('当前位置没有可添加标签的正文'); return; } selection = { chapterIndex: state.chapterIndex, paragraphIndex: state.activeParagraph, start: 0, end: text.length, quote: text, multipleParagraphs: false, }; usedCurrentParagraph = true; } if (selection.multipleParagraphs) { toast('请在同一自然段内选择文字'); return; } if (state.annotations.tags.length >= 200) { toast('标签数量已达上限'); return; } const suggestion = selection.quote.slice(0, 24) + (selection.quote.length > 24 ? '…' : ''); const note = prompt( usedCurrentParagraph ? '未选中文字,将为当前段落添加标签。请输入标签名称:' : '标签名称或备注:', suggestion ); if (note === null) return; state.annotations.tags.push({ id: createId('tag'), chapterIndex: selection.chapterIndex, paragraphIndex: selection.paragraphIndex, start: selection.start, end: selection.end, quote: selection.quote, note: note.trim() || suggestion, createdAt: Date.now(), }); state.pendingSelection = null; saveAnnotations(); renderChapter(); renderAnnotations(); revealAnnotationPanel(); toast('文字标签已添加', 'ok'); } function deleteAnnotation(id, type) { if (type === 'bookmark') { state.annotations.bookmarks = state.annotations.bookmarks.filter(item => item.id !== id); } else { state.annotations.tags = state.annotations.tags.filter(item => item.id !== id); } saveAnnotations(); renderAnnotations(); renderChapter(); } function jumpToAnnotation(id, type) { const list = type === 'bookmark' ? state.annotations.bookmarks : state.annotations.tags; const item = list.find(entry => entry.id === id); if (!item) return; setChapter(item.chapterIndex, { force: true }); setTimeout(() => { const paragraph = shadow .getElementById('readerContent') ?.querySelector(`[data-paragraph-index="${item.paragraphIndex}"]`); if (paragraph) { setActiveParagraph(item.paragraphIndex); paragraph.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 60); } function cleanCurrentBook() { if (!state.book) return; if (!confirm('清除所有章节中的网址、公众号推广和装饰符号?建议先导出备份。')) return; state.book.chapters.forEach(chapter => { chapter.content = cleanNovelText(chapter.content); }); saveCurrentBook(); renderChapter(); toast('文本净化完成', 'ok'); } function formatShortBook() { if (!state.book) return; if (!confirm('将纯数字行依次替换为“第X章”,并重新生成目录?')) return; stopTts(true); let chapterNumber = 1; const combined = state.book.chapters.map(chapter => chapter.content).join('\n\n'); const formatted = combined .split('\n') .map(line => { const value = line.trim(); if (/^\d{1,6}$/.test(value)) { return `第${chapterNumber++}章`; } return line; }) .join('\n'); const parsed = parseNovelText(formatted, state.book.title); state.book.chapters = parsed.chapters; state.book.updatedAt = Date.now(); state.chapterIndex = 0; state.activeParagraph = 0; state.editMode = false; state.annotations = { bookmarks: [], tags: [] }; saveCurrentBook(); saveAnnotations(); renderBook(); toast('短篇章节格式化完成', 'ok'); } function exportCurrentBook() { if (!state.book) { toast('请先打开一本书'); return; } const content = state.book.chapters .map(chapter => `${chapter.title}\n\n${chapter.content}`) .join('\n\n\n'); downloadText(safeFileName(state.book.title) + '.txt', content); } function exportCurrentProject() { if (!state.book) { toast('请先打开一本书'); return; } const project = { format: 'xizi-reader-project', version: 1, exportedAt: new Date().toISOString(), chapterIndex: state.chapterIndex, book: state.book, annotations: state.annotations, }; downloadText( safeFileName(state.book.title) + '.xizi.json', JSON.stringify(project, null, 2), 'application/json;charset=utf-8' ); } function gmRequest(options) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('当前用户脚本管理器不支持跨域请求')); return; } GM_xmlhttpRequest({ method: options.method || 'GET', url: options.url, headers: options.headers || {}, data: options.data, timeout: options.timeout || 30000, onload: response => { if (response.status >= 200 && response.status < 300) { resolve(response); } else { reject(new Error(`HTTP ${response.status}`)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), onabort: () => reject(new Error('请求已取消')), }); }); } async function requestText(url) { try { const parsed = new URL(url, location.href); if (parsed.origin === location.origin) { const response = await fetch(parsed.href, { credentials: 'include', headers: { Accept: 'text/html,application/xhtml+xml,text/plain' }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } } catch (error) { if (error.message.startsWith('HTTP ')) throw error; } const response = await gmRequest({ url, headers: { Accept: 'text/html,application/xhtml+xml,text/plain', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.6', }, timeout: 30000, }); return response.responseText; } function normalizeFanqieInput(raw) { let value = String(raw || '').trim(); if (!value) return null; if (/^\d+$/.test(value)) { return { pageUrl: `https://fanqienovel.com/page/${value}`, bookId: value, }; } if (!/^https?:\/\//i.test(value)) value = 'https://' + value; try { const url = new URL(value); if (!/(^|\.)fanqienovel\.com$/i.test(url.hostname)) return null; const pageMatch = url.pathname.match(/\/page\/(\d+)/); if (pageMatch) { return { pageUrl: `https://fanqienovel.com/page/${pageMatch[1]}`, bookId: pageMatch[1], }; } const readerMatch = url.pathname.match(/\/reader\/(\d+)/); if (readerMatch) { return { pageUrl: url.href, bookId: 'reader_' + readerMatch[1], singleChapter: true, }; } return null; } catch (_) { return null; } } function parseFanqieChapter(html, title) { const doc = new DOMParser().parseFromString(html, 'text/html'); const root = doc.querySelector('.muye-reader-content'); if (!root) return null; const paragraphs = [...root.querySelectorAll('p')] .map(node => decodeFanqieText(node.textContent || '').trim()) .filter(Boolean); if (!paragraphs.length) return null; return { title: title || doc.querySelector('.muye-reader-title')?.textContent?.trim() || '未命名章节', content: cleanNovelText(paragraphs.join('\n\n')), sourceUrl: '', }; } function parseFanqieCatalog(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const title = doc.querySelector('.info-name h1')?.textContent?.trim() || '番茄小说'; const anchors = [ ...doc.querySelectorAll('.page-directory-content .chapter-item-title[href]'), ]; const seen = new Set(); const chapters = anchors .map(anchor => ({ title: (anchor.textContent || '').trim(), url: new URL(anchor.getAttribute('href'), 'https://fanqienovel.com').href, })) .filter(item => { if (!item.url || seen.has(item.url) || !item.title) return false; seen.add(item.url); return true; }); return { title, chapters }; } function setOnlineProgress(done, total, text) { if (!shadow) return; const fill = shadow.getElementById('onlineProgressFill'); const label = shadow.getElementById('onlineProgressText'); const percent = total > 0 ? clamp((done / total) * 100, 0, 100) : 0; if (fill) fill.style.width = percent + '%'; if (label) label.textContent = text; } function appendOnlineLog(message) { if (!shadow) return; const log = shadow.getElementById('onlineLog'); if (!log) return; log.hidden = false; log.textContent += (log.textContent ? '\n' : '') + message; log.scrollTop = log.scrollHeight; } function findFanqieBookIdOnPage() { if (!/(^|\.)fanqienovel\.com$/i.test(location.hostname)) return ''; const pageMatch = location.pathname.match(/\/page\/(\d+)/); if (pageMatch) return pageMatch[1]; const scripts = [...document.scripts].map(script => script.textContent || '').join('\n'); const patterns = [ /"bookId"\s*:\s*"(\d+)"/, /\\?"bookId\\?"\s*:\s*\\?"(\d+)\\?"/, /"book_id"\s*:\s*"(\d+)"/, ]; for (const pattern of patterns) { const match = scripts.match(pattern); if (match) return match[1]; } return ''; } function openOnlineModal() { if (!shadow) return; shadow.getElementById('onlineModal').hidden = false; shadow.getElementById('onlineStartButton').hidden = false; shadow.getElementById('onlineCancelButton').hidden = true; shadow.getElementById('onlineCancelButton').disabled = false; shadow.getElementById('onlineStartButton').disabled = false; const input = shadow.getElementById('onlineUrl'); const currentUrl = location.href; const pageBookId = findFanqieBookIdOnPage(); if (/fanqienovel\.com\/reader\//.test(currentUrl) && pageBookId) { input.value = `https://fanqienovel.com/page/${pageBookId}`; } else if (/fanqienovel\.com\/(page|reader)\//.test(currentUrl)) { input.value = currentUrl; } input.focus(); input.select(); } function closeOnlineModal() { if (!shadow) return; shadow.getElementById('onlineModal').hidden = true; } async function importFanqieBook() { if (!shadow) return; const raw = shadow.getElementById('onlineUrl').value.trim(); const limit = clamp(parseInt(shadow.getElementById('onlineLimit').value, 10) || 80, 1, 999); const normalized = normalizeFanqieInput(raw); if (!normalized) { toast('请输入番茄小说书籍页链接或纯数字书籍 ID', 'error'); return; } state.importCancelled = false; const startButton = shadow.getElementById('onlineStartButton'); const cancelButton = shadow.getElementById('onlineCancelButton'); startButton.disabled = true; cancelButton.hidden = false; cancelButton.disabled = false; shadow.getElementById('onlineLog').textContent = ''; setOnlineProgress(0, 1, '正在读取书籍信息…'); try { const baseHtml = await requestText(normalized.pageUrl); const baseDoc = new DOMParser().parseFromString(baseHtml, 'text/html'); let bookTitle = baseDoc.querySelector('.info-name h1')?.textContent?.trim(); if (!bookTitle) { bookTitle = document.title.split(/第[0-9一二三四五六七八九十百千零]+章/)[0].trim(); } bookTitle = bookTitle || '番茄小说'; let chapterEntries = []; if (normalized.singleChapter) { chapterEntries = [{ title: baseDoc.querySelector('.muye-reader-title')?.textContent?.trim() || '第 1 章', url: normalized.pageUrl, }]; } else { chapterEntries = parseFanqieCatalog(baseHtml).chapters; } if (!chapterEntries.length) { throw new Error('未找到章节目录,页面结构可能已更新'); } chapterEntries = chapterEntries.slice(0, limit); setOnlineProgress(0, chapterEntries.length, `已找到 ${chapterEntries.length} 章,开始抓取…`); appendOnlineLog(`书籍:${bookTitle}`); appendOnlineLog(`计划抓取:${chapterEntries.length} 章`); const chapters = []; for (let index = 0; index < chapterEntries.length; index += 1) { if (state.importCancelled) break; const item = chapterEntries[index]; setOnlineProgress( index, chapterEntries.length, `正在抓取 ${index + 1}/${chapterEntries.length}:${item.title}` ); try { const html = index === 0 && normalized.singleChapter ? baseHtml : await requestText(item.url); const chapter = parseFanqieChapter(html, item.title); if (!chapter?.content) { appendOnlineLog(`跳过空章节:${item.title}`); } else { chapter.sourceUrl = item.url; chapters.push(chapter); } } catch (error) { appendOnlineLog(`抓取失败:${item.title}(${error.message})`); } setOnlineProgress(index + 1, chapterEntries.length, `已完成 ${index + 1}/${chapterEntries.length}`); await new Promise(resolve => setTimeout(resolve, 100)); } if (!chapters.length) throw new Error('没有成功抓取到正文'); const bookId = 'fanqie_' + normalized.bookId; const book = { id: bookId, title: bookTitle, type: 'fanqie', source: normalized.pageUrl, createdAt: Date.now(), updatedAt: Date.now(), chapters, }; appendOnlineLog( state.importCancelled ? `已停止,保留 ${chapters.length} 章` : `导入完成,共 ${chapters.length} 章` ); setOnlineProgress(chapters.length, chapterEntries.length, '导入完成'); openNewBook(book); toast(state.importCancelled ? '已保存部分章节' : '番茄小说导入完成', 'ok'); setTimeout(closeOnlineModal, 500); } catch (error) { console.error(error); appendOnlineLog('失败:' + error.message); setOnlineProgress(0, 1, '导入失败'); toast('导入失败:' + error.message, 'error'); } finally { startButton.disabled = false; cancelButton.hidden = true; } } function cancelFanqieImport() { state.importCancelled = true; const cancelButton = shadow?.getElementById('onlineCancelButton'); if (cancelButton) cancelButton.disabled = true; appendOnlineLog('已请求停止,当前章节完成后结束…'); } function getBrowserVoices() { if (!('speechSynthesis' in window)) return []; return speechSynthesis .getVoices() .slice() .sort((a, b) => { const aChinese = /^zh/i.test(a.lang) ? 0 : 1; const bChinese = /^zh/i.test(b.lang) ? 0 : 1; return aChinese - bChinese || a.name.localeCompare(b.name); }); } function getVoiceDisplayName(voice) { for (const key of Object.keys(VOICE_ZH_NAME)) { if (voice.name.includes(key)) return VOICE_ZH_NAME[key]; } return `${voice.name} · ${voice.lang}`; } function getSelectedVoice(engine = state.prefs.tts.engine) { if (engine === 'volcano') return state.prefs.tts.volcanoVoice; if (engine === 'tencent') return state.prefs.tts.tencentVoice; return state.prefs.tts.localVoice; } function setSelectedVoice(value, engine = state.prefs.tts.engine) { if (engine === 'volcano') state.prefs.tts.volcanoVoice = value; else if (engine === 'tencent') state.prefs.tts.tencentVoice = value; else state.prefs.tts.localVoice = value; storageSet('selectedVoiceId', value); scheduleSavePrefs(); } function populateVoices(preferred) { if (!shadow) return; state.voices = getBrowserVoices(); const select = shadow.getElementById('ttsVoice'); if (!select) return; const engine = state.prefs.tts.engine || 'local'; const current = preferred ?? getSelectedVoice(engine) ?? select.value; select.textContent = ''; if (engine === 'local') { const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.textContent = '系统默认音色'; select.appendChild(defaultOption); state.voices.forEach(voice => { const option = document.createElement('option'); option.value = voice.voiceURI; option.textContent = getVoiceDisplayName(voice); select.appendChild(option); }); const exists = state.voices.some(voice => voice.voiceURI === current); select.value = exists ? current : ''; if (state.voices.length > 0) setSelectedVoice(select.value, engine); else if (current) setSelectedVoice(current, engine); return; } const list = engine === 'tencent' ? TENCENT_VOICES : ONLINE_VOICES; const groupMap = new Map(); list.forEach(voice => { if (!groupMap.has(voice.group)) groupMap.set(voice.group, []); groupMap.get(voice.group).push(voice); }); groupMap.forEach((voices, group) => { const optgroup = document.createElement('optgroup'); optgroup.label = group; voices.forEach(voice => { const option = document.createElement('option'); option.value = String(voice.id); option.textContent = voice.name; optgroup.appendChild(option); }); select.appendChild(optgroup); }); const fallbackId = String(list[0]?.id || ''); const exists = list.some(voice => String(voice.id) === String(current)); select.value = exists ? String(current) : fallbackId; setSelectedVoice(select.value, engine); } function syncCloudTtsConfigUi() { if (!shadow) return; const engine = state.prefs.tts.engine || 'local'; const settingEngine = shadow.getElementById('settingTtsEngine'); const barEngine = shadow.getElementById('ttsEngine'); if (settingEngine) settingEngine.value = engine; if (barEngine) barEngine.value = engine; const volcanoFields = shadow.getElementById('volcanoConfigFields'); const tencentFields = shadow.getElementById('tencentConfigFields'); const actions = shadow.getElementById('cloudTtsConfigActions'); if (volcanoFields) volcanoFields.hidden = engine !== 'volcano'; if (tencentFields) tencentFields.hidden = engine !== 'tencent'; if (actions) actions.hidden = engine === 'local'; const volcano = state.prefs.tts.volcano; const tencent = state.prefs.tts.tencent; const setValue = (id, value) => { const node = shadow.getElementById(id); if (node && shadow.activeElement !== node) node.value = value || ''; }; setValue('ttsVolcanoAppId', volcano.appid); setValue('ttsVolcanoToken', volcano.token); setValue('ttsVolcanoCluster', volcano.cluster || 'volcano_tts'); setValue('ttsTencentSecretId', tencent.secretId); setValue('ttsTencentSecretKey', tencent.secretKey); } function saveCloudTtsConfig() { if (!shadow) return; state.prefs.tts.volcano = { appid: shadow.getElementById('ttsVolcanoAppId').value.trim(), token: shadow.getElementById('ttsVolcanoToken').value.trim(), cluster: shadow.getElementById('ttsVolcanoCluster').value.trim() || 'volcano_tts', }; state.prefs.tts.tencent = { secretId: shadow.getElementById('ttsTencentSecretId').value.trim(), secretKey: shadow.getElementById('ttsTencentSecretKey').value.trim(), }; storageSet('volcano_appid', state.prefs.tts.volcano.appid); storageSet('volcano_token', state.prefs.tts.volcano.token); storageSet('volcano_cluster', state.prefs.tts.volcano.cluster); storageSet('tencent_secretId', state.prefs.tts.tencent.secretId); storageSet('tencent_secretKey', state.prefs.tts.tencent.secretKey); clearTimeout(savePrefsTimer); storageSet(PREFS_KEY, JSON.stringify(state.prefs)); toast('云端 TTS 配置已保存', 'ok'); } function switchTtsEngine(engine) { if (!['local', 'volcano', 'tencent'].includes(engine)) return; if (state.tts.playing || state.tts.paused) stopTts(true); state.prefs.tts.engine = engine; state.tts.engine = engine; state.tts.fallbackLocal = false; storageSet('voiceMode', engine); scheduleSavePrefs(); populateVoices(); syncCloudTtsConfigUi(); updateTtsBar(); } function hasCloudTtsConfig(engine) { if (engine === 'volcano') { return Boolean(state.prefs.tts.volcano.appid && state.prefs.tts.volcano.token); } if (engine === 'tencent') { return Boolean(state.prefs.tts.tencent.secretId && state.prefs.tts.tencent.secretKey); } return true; } function detectOffset(text, keywordRules, punctuationRules) { for (const [offset, words] of Object.entries(keywordRules)) { if (words.some(word => text.includes(word))) return parseInt(offset, 10) || 0; } for (const [punctuation, offset] of punctuationRules) { if (text.includes(punctuation)) return parseInt(offset, 10) || 0; } return 0; } function detectAutoRate(text) { return detectOffset(text, AUTO_RATE_KEYWORDS, [ ['!', 12], ['!', 12], ['?', -5], ['?', -5], ['…', -18], ['...', -18], ]); } function detectAutoVolume(text) { return detectOffset(text, AUTO_VOLUME_KEYWORDS, [ ['!', 24], ['!', 24], ['?', 8], ['?', 8], ['…', -18], ['...', -18], ]); } function makeTtsSegments(rangeMode) { if (!state.book) return []; const segments = []; const engine = state.tts.engine || state.prefs.tts.engine || 'local'; const maxLength = engine === 'tencent' ? 130 : engine === 'volcano' ? 450 : 360; if (rangeMode === 'selection') { captureSelection(); const selection = state.pendingSelection; if (!selection || !selection.quote.trim()) return []; splitSpeechText(selection.quote, maxLength).forEach(text => { segments.push({ text, chapterIndex: selection.chapterIndex, paragraphIndex: selection.paragraphIndex, }); }); return segments; } const chapterStart = rangeMode === 'full' ? 0 : state.chapterIndex; const chapterEnd = rangeMode === 'full' ? state.book.chapters.length : state.chapterIndex + 1; for (let chapterIndex = chapterStart; chapterIndex < chapterEnd; chapterIndex += 1) { const paragraphs = splitParagraphs(state.book.chapters[chapterIndex]?.content || ''); paragraphs.forEach((paragraph, paragraphIndex) => { splitSpeechText(paragraph, maxLength).forEach(text => { segments.push({ text, chapterIndex, paragraphIndex }); }); }); } return segments; } function currentSpeechRate(text) { const auto = state.prefs.tts.autoRate ? detectAutoRate(text) : 0; return clamp(1 + (Number(state.prefs.tts.rate) + auto) / 100, 0.5, 2); } function currentSpeechPitch() { return clamp(1 + Number(state.prefs.tts.pitch) / 100, 0.1, 2); } function currentSpeechVolume(text) { const auto = state.prefs.tts.autoVolume ? detectAutoVolume(text) : 0; return clamp(1 + (Number(state.prefs.tts.volume) + auto) / 100, 0, 1); } function currentTtsRatePercent(text) { const auto = state.prefs.tts.autoRate ? detectAutoRate(text) : 0; return clamp(Number(state.prefs.tts.rate) + auto, -60, 60); } function currentTtsVolumePercent(text) { const auto = state.prefs.tts.autoVolume ? detectAutoVolume(text) : 0; return clamp(Number(state.prefs.tts.volume) + auto, -60, 60); } function genUuid() { if (crypto.randomUUID) return crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => { const random = (Math.random() * 16) | 0; const value = char === 'x' ? random : (random & 0x3) | 0x8; return value.toString(16); }); } function base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes.buffer; } async function fetchVolcanoTtsAudio(text) { const config = state.prefs.tts.volcano; if (!config.appid || !config.token) throw new Error('请先配置火山引擎 AppID 和 Token'); const body = JSON.stringify({ app: { appid: config.appid, token: config.token, cluster: config.cluster || 'volcano_tts', }, user: { uid: 'xizi_reader' }, audio: { voice_type: state.prefs.tts.volcanoVoice || ONLINE_VOICES[0].id, encoding: 'mp3', rate: 24000, speed_ratio: clamp(1 + currentTtsRatePercent(text) / 100, 0.2, 3), volume_ratio: clamp(1 + currentTtsVolumePercent(text) / 100, 0.1, 2), pitch_ratio: clamp(1 + Number(state.prefs.tts.pitch) / 100, 0.1, 2), }, request: { reqid: genUuid(), text, text_type: 'plain', operation: 'query', }, }); const response = await gmRequest({ method: 'POST', url: 'https://openspeech.bytedance.com/api/v1/tts', headers: { 'Content-Type': 'application/json', Authorization: `Bearer;${config.token}`, }, data: body, timeout: 20000, }); let json; try { json = JSON.parse(response.responseText); } catch (_) { throw new Error('火山引擎返回解析失败'); } if (json.code !== 3000) throw new Error(`火山引擎错误 ${json.code}:${json.message || '未知'}`); if (!json.data) throw new Error('火山引擎返回无音频数据'); return { buffer: base64ToArrayBuffer(json.data), mime: 'audio/mpeg' }; } function bufferToHex(buffer) { return Array.from(new Uint8Array(buffer)) .map(value => value.toString(16).padStart(2, '0')) .join(''); } async function sha256Hex(message) { const data = new TextEncoder().encode(message); return bufferToHex(await crypto.subtle.digest('SHA-256', data)); } async function hmacSha256Raw(keyData, message) { const encoder = new TextEncoder(); const key = typeof keyData === 'string' ? encoder.encode(keyData) : keyData; const cryptoKey = await crypto.subtle.importKey( 'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message))); } async function hmacSha256Hex(keyData, message) { return bufferToHex(await hmacSha256Raw(keyData, message)); } async function buildTencentAuth(secretId, secretKey, payload) { const service = 'tts'; const host = 'tts.tencentcloudapi.com'; const algorithm = 'TC3-HMAC-SHA256'; const timestamp = Math.floor(Date.now() / 1000); const date = new Date(timestamp * 1000).toISOString().slice(0, 10); const canonicalHeaders = `content-type:application/json\nhost:${host}\nx-tc-action:texttovoice\n`; const signedHeaders = 'content-type;host;x-tc-action'; const canonicalRequest = [ 'POST', '/', '', canonicalHeaders, signedHeaders, await sha256Hex(payload), ].join('\n'); const credentialScope = `${date}/${service}/tc3_request`; const stringToSign = [ algorithm, String(timestamp), credentialScope, await sha256Hex(canonicalRequest), ].join('\n'); const encoder = new TextEncoder(); const secretDate = await hmacSha256Raw(encoder.encode('TC3' + secretKey), date); const secretService = await hmacSha256Raw(secretDate, service); const secretSigning = await hmacSha256Raw(secretService, 'tc3_request'); const signature = await hmacSha256Hex(secretSigning, stringToSign); return { timestamp, authorization: `${algorithm} Credential=${secretId}/${credentialScope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`, }; } async function fetchTencentTtsAudio(text) { const config = state.prefs.tts.tencent; if (!config.secretId || !config.secretKey) { throw new Error('请先配置腾讯云 SecretId 和 SecretKey'); } const ratePercent = currentTtsRatePercent(text); const volumePercent = currentTtsVolumePercent(text); const speed = ratePercent >= 0 ? ratePercent * 0.2 : ratePercent * (2 / 30); const payload = JSON.stringify({ Text: text, SessionId: genUuid(), VoiceType: parseInt(state.prefs.tts.tencentVoice || TENCENT_VOICES[0].id, 10), Codec: 'mp3', Speed: Number(clamp(speed, -2, 6).toFixed(1)), Volume: Math.round(clamp(volumePercent / 3, -10, 10)), }); let auth; try { auth = await buildTencentAuth(config.secretId, config.secretKey, payload); } catch (error) { throw new Error('腾讯云签名失败:' + error.message); } const response = await gmRequest({ method: 'POST', url: 'https://tts.tencentcloudapi.com/', headers: { 'Content-Type': 'application/json', Host: 'tts.tencentcloudapi.com', 'X-TC-Action': 'TextToVoice', 'X-TC-Version': '2019-08-23', 'X-TC-Timestamp': String(auth.timestamp), Authorization: auth.authorization, }, data: payload, timeout: 20000, }); let json; try { json = JSON.parse(response.responseText); } catch (_) { throw new Error('腾讯云返回解析失败'); } const result = json.Response; if (!result) throw new Error('腾讯云返回格式异常'); if (result.Error) throw new Error(`腾讯云错误 ${result.Error.Code}:${result.Error.Message}`); if (!result.Audio) throw new Error('腾讯云返回无音频数据'); return { buffer: base64ToArrayBuffer(result.Audio), mime: 'audio/mpeg' }; } function cleanupTtsAudio() { if (state.tts.audioEl) { state.tts.audioEl.pause(); state.tts.audioEl.removeAttribute?.('src'); state.tts.audioEl = null; } if (state.tts.audioUrl) { URL.revokeObjectURL(state.tts.audioUrl); state.tts.audioUrl = ''; } state.tts.onlinePending = false; } function advanceTts(token) { if (token !== state.tts.token || !state.tts.playing) return; state.tts.index += 1; updateTtsBar(); speakNextSegment(token); } function speakLocalSegment(token, segment) { if (!('speechSynthesis' in window)) { toast('当前浏览器不支持 Web Speech', 'error'); finishTts(); return; } speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(segment.text); const voice = state.voices.find(item => item.voiceURI === state.prefs.tts.localVoice); if (voice) utterance.voice = voice; utterance.lang = voice?.lang || 'zh-CN'; utterance.rate = currentSpeechRate(segment.text); utterance.pitch = currentSpeechPitch(); utterance.volume = currentSpeechVolume(segment.text); utterance.onend = () => advanceTts(token); utterance.onerror = event => { if (token !== state.tts.token || !state.tts.playing) return; if (event.error === 'canceled' || event.error === 'interrupted') return; toast('本段朗读失败,已跳到下一段', 'error'); advanceTts(token); }; state.tts.utterance = utterance; speechSynthesis.speak(utterance); setStatus(`本地朗读:${segment.text.slice(0, 28)}${segment.text.length > 28 ? '…' : ''}`); } function playOnlineAudio(result, token) { cleanupTtsAudio(); const blob = new Blob([result.buffer], { type: result.mime || 'audio/mpeg' }); const url = URL.createObjectURL(blob); const audio = new Audio(url); state.tts.audioEl = audio; state.tts.audioUrl = url; state.tts.onlinePending = false; audio.onended = () => { cleanupTtsAudio(); advanceTts(token); }; audio.onerror = () => { cleanupTtsAudio(); if (token !== state.tts.token) return; toast('云端音频播放失败,已降级为本地音色', 'error'); state.tts.fallbackLocal = true; speakLocalSegment(token, state.tts.segments[state.tts.index]); }; if (state.tts.paused) { setStatus('云端音频已就绪,点击继续播放'); updateTtsBar(); return; } audio.play().catch(() => { cleanupTtsAudio(); if (token !== state.tts.token || !state.tts.playing) return; toast('无法自动播放云端音频,已降级为本地音色', 'error'); state.tts.fallbackLocal = true; speakLocalSegment(token, state.tts.segments[state.tts.index]); }); } async function speakCloudSegment(token, segment) { const engine = state.tts.engine; cleanupTtsAudio(); state.tts.onlinePending = true; updateTtsBar(); setStatus(`${engine === 'tencent' ? '腾讯云' : '火山引擎'}合成中…`); try { const result = engine === 'tencent' ? await fetchTencentTtsAudio(segment.text) : await fetchVolcanoTtsAudio(segment.text); if (token !== state.tts.token || !state.tts.playing) return; playOnlineAudio(result, token); setStatus(`${engine === 'tencent' ? '腾讯云' : '火山引擎'}朗读中:${segment.text.slice(0, 24)}${segment.text.length > 24 ? '…' : ''}`); } catch (error) { if (token !== state.tts.token || !state.tts.playing) return; state.tts.onlinePending = false; state.tts.fallbackLocal = true; toast((error.message || '云端合成失败') + ',已降级为本地音色', 'error'); updateTtsBar(); speakLocalSegment(token, segment); } } function startTts() { if (!state.book) { toast('请先打开一本书'); return; } const engine = state.prefs.tts.engine || 'local'; if (engine === 'local' && !('speechSynthesis' in window)) { toast('当前浏览器不支持 Web Speech 朗读', 'error'); return; } if (engine !== 'local' && !hasCloudTtsConfig(engine)) { openSettingsDrawer(); setTimeout(() => { const target = shadow.getElementById( engine === 'volcano' ? 'ttsVolcanoAppId' : 'ttsTencentSecretId' ); target?.scrollIntoView({ block: 'center' }); }, 60); toast(`请先配置${engine === 'volcano' ? '火山引擎' : '腾讯云'} TTS 密钥`, 'error'); return; } stopTts(); state.tts.engine = engine; state.tts.fallbackLocal = false; const rangeMode = state.prefs.tts.range || 'chapter'; const segments = makeTtsSegments(rangeMode); if (!segments.length) { toast(rangeMode === 'selection' ? '请先在正文中选中文字' : '没有可朗读的正文'); return; } populateVoices(getSelectedVoice(engine)); const token = ++state.tts.token; state.tts.playing = true; state.tts.paused = false; state.tts.segments = segments; state.tts.index = 0; updateTtsBar(); setStatus('开始朗读'); setTimeout(() => speakNextSegment(token), 30); } function speakNextSegment(token) { if (token !== state.tts.token || !state.tts.playing) return; if (state.tts.index >= state.tts.segments.length) { finishTts(); return; } const segment = state.tts.segments[state.tts.index]; if (!segment) { finishTts(); return; } const changesChapter = segment.chapterIndex !== state.chapterIndex; if (changesChapter) { setChapter(segment.chapterIndex, { preserveTts: true, scroll: false, force: true }); } setTimeout(() => { if (token !== state.tts.token || !state.tts.playing) return; highlightTtsSegment(segment); if (state.tts.engine === 'local' || state.tts.fallbackLocal) { speakLocalSegment(token, segment); } else { speakCloudSegment(token, segment); } updateTtsBar(); }, changesChapter ? 30 : 0); } function highlightTtsSegment(segment) { if (!shadow) return; stopHighlight(); const paragraph = shadow .getElementById('readerContent') ?.querySelector(`[data-paragraph-index="${segment.paragraphIndex}"]`); if (paragraph) { paragraph.classList.add('speaking'); paragraph.scrollIntoView({ behavior: 'smooth', block: 'center' }); setActiveParagraph(segment.paragraphIndex); } } function stopHighlight() { if (!shadow) return; shadow.getElementById('readerContent')?.querySelectorAll('p.speaking').forEach(node => { node.classList.remove('speaking'); }); } function toggleTts() { if (state.tts.paused) { if (state.tts.audioEl) { state.tts.audioEl.play().catch(() => { toast('音频继续播放失败', 'error'); }); } else if (!state.tts.onlinePending && 'speechSynthesis' in window) { speechSynthesis.resume(); } state.tts.paused = false; state.tts.playing = true; setStatus('继续朗读'); updateTtsBar(); } else if (state.tts.playing) { if (state.tts.audioEl) state.tts.audioEl.pause(); else if (!state.tts.onlinePending && 'speechSynthesis' in window) speechSynthesis.pause(); state.tts.paused = true; setStatus('朗读已暂停'); updateTtsBar(); } else { startTts(); } } function stopTts(silent = false) { state.tts.token += 1; state.tts.playing = false; state.tts.paused = false; state.tts.utterance = null; if ('speechSynthesis' in window) speechSynthesis.cancel(); cleanupTtsAudio(); stopHighlight(); updateTtsBar(); if (!silent) setStatus('朗读已停止'); } function finishTts() { state.tts.token += 1; state.tts.playing = false; state.tts.paused = false; state.tts.utterance = null; cleanupTtsAudio(); stopHighlight(); updateTtsBar(); setStatus('朗读结束'); } function moveTtsSegment(direction) { if (!state.tts.playing && !state.tts.paused) { const current = state.activeParagraph + direction; const content = shadow?.getElementById('readerContent'); const count = content?.querySelectorAll('p').length || 0; setActiveParagraph(clamp(current, 0, Math.max(0, count - 1)), true); return; } const token = ++state.tts.token; state.tts.paused = false; if ('speechSynthesis' in window) speechSynthesis.cancel(); cleanupTtsAudio(); state.tts.index = clamp(state.tts.index + direction, 0, state.tts.segments.length - 1); updateTtsBar(); state.tts.playing = true; setTimeout(() => speakNextSegment(token), 40); } function updateTtsBar() { if (!shadow) return; const engineSelect = shadow.getElementById('ttsEngine'); const rangeSelect = shadow.getElementById('ttsRange'); const toggle = shadow.getElementById('ttsToggleButton'); const progressFill = shadow.getElementById('ttsProgressFill'); const progressText = shadow.getElementById('ttsProgressText'); const statusTts = shadow.getElementById('statusTts'); if (engineSelect) engineSelect.value = state.prefs.tts.engine; if (rangeSelect) rangeSelect.value = state.prefs.tts.range; if (toggle) toggle.textContent = state.tts.paused ? '▶' : state.tts.playing ? '⏸' : '▶'; const total = state.tts.segments.length || 0; const current = total ? clamp(state.tts.index + 1, 0, total) : 0; const percent = total ? Math.round((current / total) * 100) : 0; if (progressFill) progressFill.style.width = percent + '%'; if (progressText) progressText.textContent = total ? `${current}/${total}` : '未播放'; if (statusTts) { statusTts.textContent = state.tts.playing ? state.tts.paused ? '朗读暂停' : state.tts.onlinePending ? '云端合成中' : state.tts.fallbackLocal && state.prefs.tts.engine !== 'local' ? '本地降级朗读' : '朗读中' : ''; } } function syncSettingsControls() { if (!shadow) return; state.tts.engine = state.prefs.tts.engine || 'local'; const themes = state.prefs.theme === 'dark' ? DARK_THEMES : DAY_THEMES; const themeSelect = shadow.getElementById('settingAccent'); themeSelect.textContent = ''; themes.forEach((theme, index) => { const option = document.createElement('option'); option.value = String(index); option.textContent = theme.name; themeSelect.appendChild(option); }); themeSelect.value = String(clamp(Number(state.prefs.accent) || 0, 0, themes.length - 1)); shadow.getElementById('settingTheme').value = state.prefs.theme; shadow.getElementById('settingFont').value = state.prefs.fontFamily; shadow.getElementById('settingSize').value = String(state.prefs.fontSize); shadow.getElementById('settingSizeValue').textContent = state.prefs.fontSize + 'px'; shadow.getElementById('settingLine').value = String(state.prefs.lineHeight); shadow.getElementById('settingLineValue').textContent = Number(state.prefs.lineHeight).toFixed(2); shadow.getElementById('settingWidth').value = String(state.prefs.contentWidth); shadow.getElementById('settingWidthValue').textContent = state.prefs.contentWidth + 'px'; shadow.getElementById('settingRate').value = String(state.prefs.tts.rate); shadow.getElementById('settingRateValue').textContent = formatSignedPercent(state.prefs.tts.rate); shadow.getElementById('settingPitch').value = String(state.prefs.tts.pitch); shadow.getElementById('settingPitchValue').textContent = formatSigned(state.prefs.tts.pitch) + 'Hz'; shadow.getElementById('settingVolume').value = String(state.prefs.tts.volume); shadow.getElementById('settingVolumeValue').textContent = formatSignedPercent(state.prefs.tts.volume); shadow.getElementById('settingAutoRate').checked = Boolean(state.prefs.tts.autoRate); shadow.getElementById('settingAutoVolume').checked = Boolean(state.prefs.tts.autoVolume); shadow.getElementById('settingTtsEngine').value = state.prefs.tts.engine; shadow.getElementById('ttsRange').value = state.prefs.tts.range; populateVoices(getSelectedVoice(state.prefs.tts.engine)); syncCloudTtsConfigUi(); applyTheme(); } function formatSigned(value) { const number = Number(value) || 0; return number >= 0 ? '+' + number : String(number); } function formatSignedPercent(value) { return formatSigned(value) + '%'; } function syncAiControls() { if (!shadow) return; shadow.getElementById('aiBaseUrl').value = state.prefs.ai.baseUrl || ''; shadow.getElementById('aiModel').value = state.prefs.ai.model || ''; shadow.getElementById('aiApiKey').value = storageGet(API_KEY_KEY, ''); shadow.getElementById('aiSystemPrompt').value = state.prefs.ai.systemPrompt || ''; shadow.getElementById('aiIncludeChapter').checked = Boolean(state.prefs.ai.includeChapter); } function saveAiConfig() { if (!shadow) return; state.prefs.ai.baseUrl = shadow.getElementById('aiBaseUrl').value.trim().replace(/\/+$/, '') || DEFAULT_PREFS.ai.baseUrl; state.prefs.ai.model = shadow.getElementById('aiModel').value.trim() || DEFAULT_PREFS.ai.model; state.prefs.ai.systemPrompt = shadow.getElementById('aiSystemPrompt').value.trim() || DEFAULT_PREFS.ai.systemPrompt; state.prefs.ai.includeChapter = shadow.getElementById('aiIncludeChapter').checked; storageSet(API_KEY_KEY, shadow.getElementById('aiApiKey').value.trim()); scheduleSavePrefs(); toast('AI 接口配置已保存', 'ok'); } function renderAiMessages() { if (!shadow) return; const container = shadow.getElementById('aiMessages'); if (!container) return; container.textContent = ''; if (!state.aiHistory.length) { const empty = document.createElement('div'); empty.className = 'ai-bubble system'; empty.textContent = state.book ? '选中文字、章节内容或直接提问,开始拆书。' : '打开一本书后可使用章节上下文;也可以直接进行普通对话。'; container.appendChild(empty); return; } state.aiHistory.slice(-30).forEach(message => { const bubble = document.createElement('div'); bubble.className = 'ai-bubble ' + (message.role === 'user' ? 'user' : 'assistant'); bubble.textContent = message.content; container.appendChild(bubble); }); container.scrollTop = container.scrollHeight; } function openAiDrawer() { closeDrawers(); syncAiControls(); renderAiMessages(); shadow.getElementById('aiDrawer').classList.add('open'); } async function sendAiMessage(overridePrompt) { if (!shadow) return; const input = shadow.getElementById('aiInput'); const prompt = String(overridePrompt ?? input.value).trim(); if (!prompt) return; const apiKey = shadow.getElementById('aiApiKey').value.trim() || storageGet(API_KEY_KEY, ''); const baseUrl = shadow.getElementById('aiBaseUrl').value.trim() || state.prefs.ai.baseUrl || DEFAULT_PREFS.ai.baseUrl; const model = shadow.getElementById('aiModel').value.trim() || state.prefs.ai.model || DEFAULT_PREFS.ai.model; if (!apiKey) { shadow.getElementById('aiConfigDetails').open = true; toast('请先填写 API Key', 'error'); return; } const includeChapter = shadow.getElementById('aiIncludeChapter').checked; let content = prompt; if (includeChapter && state.book) { const chapter = state.book.chapters[state.chapterIndex]; const context = splitParagraphs(chapter?.content || '').join('\n').slice(0, 24000); if (context) { content += `\n\n【当前章节:${chapter.title}】\n` + context + (chapter.content.length > context.length ? '\n[章节内容已截断]' : ''); } } state.aiHistory.push({ role: 'user', content: prompt }); input.value = ''; renderAiMessages(); saveAiHistory(); const button = shadow.getElementById('aiSendButton'); button.disabled = true; button.textContent = '思考中…'; const systemPrompt = shadow.getElementById('aiSystemPrompt').value.trim() || state.prefs.ai.systemPrompt || DEFAULT_PREFS.ai.systemPrompt; const endpoint = /\/chat\/completions$/i.test(baseUrl) ? baseUrl : baseUrl.replace(/\/+$/, '') + '/chat/completions'; const history = state.aiHistory .slice(-12, -1) .filter(item => item.role === 'user' || item.role === 'assistant') .map(item => ({ role: item.role, content: item.content })); try { const response = await gmRequest({ method: 'POST', url: endpoint, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + apiKey, }, data: JSON.stringify({ model, temperature: 0.7, messages: [ { role: 'system', content: systemPrompt }, ...history, { role: 'user', content }, ], }), timeout: 90000, }); const data = JSON.parse(response.responseText); const raw = data.choices?.[0]?.message?.content; const reply = Array.isArray(raw) ? raw.map(item => item.text || item.content || '').join('\n') : String(raw || '').trim(); if (!reply) throw new Error('接口没有返回文本'); state.aiHistory.push({ role: 'assistant', content: reply }); saveAiHistory(); renderAiMessages(); setStatus('AI 回答完成'); } catch (error) { const message = error.message || String(error); state.aiHistory.push({ role: 'assistant', content: '请求失败:' + message, }); renderAiMessages(); saveAiHistory(); toast('AI 请求失败:' + message, 'error'); } finally { button.disabled = false; button.textContent = '发送'; } } function clearAiHistory() { if (!state.book || !state.aiHistory.length) return; if (!confirm('清空当前书籍的 AI 对话记录?')) return; state.aiHistory = []; saveAiHistory(); renderAiMessages(); } function openApp() { if (!app) return; state.appOpen = true; app.hidden = false; fab.hidden = true; if (!state.book) { const activeBookId = storageGet(ACTIVE_BOOK_KEY, ''); if (activeBookId && state.library.some(item => item.id === activeBookId)) { loadBook(activeBookId); } } if (state.book) renderBook(); else { state.sidebarTab = 'library'; renderSidebar(); } renderAiMessages(); } function closeApp() { if (!app) return; stopTts(true); closeOnlineModal(); closeDrawers(); state.appOpen = false; app.hidden = true; fab.hidden = false; } function closeDrawers() { if (!shadow) return; shadow.getElementById('settingsDrawer')?.classList.remove('open'); shadow.getElementById('aiDrawer')?.classList.remove('open'); } function openSettingsDrawer() { closeDrawers(); syncSettingsControls(); shadow.getElementById('settingsDrawer').classList.add('open'); } function openSidebarMobile() { if (!shadow) return; app.classList.remove('sidebar-hidden'); if (window.innerWidth <= 840) shadow.getElementById('sidebar').classList.add('mobile-open'); } function closeSidebarMobile() { shadow?.getElementById('sidebar')?.classList.remove('mobile-open'); } function toggleSidebar() { if (window.innerWidth <= 840) { shadow.getElementById('sidebar').classList.toggle('mobile-open'); return; } app.classList.toggle('sidebar-hidden'); } function toggleTheme() { state.prefs.theme = state.prefs.theme === 'dark' ? 'light' : 'dark'; const themes = state.prefs.theme === 'dark' ? DARK_THEMES : DAY_THEMES; state.prefs.accent = clamp(Number(state.prefs.accent) || 0, 0, themes.length - 1); applyTheme(); syncSettingsControls(); scheduleSavePrefs(); } function updatePrefsFromControls() { if (!shadow) return; const themeValue = shadow.getElementById('settingTheme').value; if (themeValue !== state.prefs.theme) { state.prefs.theme = themeValue; state.prefs.accent = 0; applyTheme(); syncSettingsControls(); scheduleSavePrefs(); return; } state.prefs.accent = Number(shadow.getElementById('settingAccent').value) || 0; state.prefs.fontFamily = shadow.getElementById('settingFont').value; state.prefs.fontSize = Number(shadow.getElementById('settingSize').value); state.prefs.lineHeight = Number(shadow.getElementById('settingLine').value); state.prefs.contentWidth = Number(shadow.getElementById('settingWidth').value); state.prefs.tts.rate = Number(shadow.getElementById('settingRate').value); state.prefs.tts.pitch = Number(shadow.getElementById('settingPitch').value); state.prefs.tts.volume = Number(shadow.getElementById('settingVolume').value); state.prefs.tts.autoRate = shadow.getElementById('settingAutoRate').checked; state.prefs.tts.autoVolume = shadow.getElementById('settingAutoVolume').checked; shadow.getElementById('settingSizeValue').textContent = state.prefs.fontSize + 'px'; shadow.getElementById('settingLineValue').textContent = Number(state.prefs.lineHeight).toFixed(2); shadow.getElementById('settingWidthValue').textContent = state.prefs.contentWidth + 'px'; shadow.getElementById('settingRateValue').textContent = formatSignedPercent(state.prefs.tts.rate); shadow.getElementById('settingPitchValue').textContent = formatSigned(state.prefs.tts.pitch) + 'Hz'; shadow.getElementById('settingVolumeValue').textContent = formatSignedPercent(state.prefs.tts.volume); applyTheme(); scheduleSavePrefs(); } function handleAction(action) { if (!action) return; switch (action) { case 'open-app': openApp(); break; case 'close-app': closeApp(); break; case 'open-file': shadow.getElementById('fileInput').click(); break; case 'online-import': openOnlineModal(); break; case 'current-page': importCurrentPage(); break; case 'start-online': importFanqieBook(); break; case 'cancel-online': cancelFanqieImport(); break; case 'close-online': closeOnlineModal(); break; case 'toggle-sidebar': toggleSidebar(); break; case 'mobile-close-sidebar': closeSidebarMobile(); break; case 'show-library': showLibraryView(); break; case 'toggle-edit': toggleEditMode(); break; case 'clean-text': cleanCurrentBook(); break; case 'toggle-theme': toggleTheme(); break; case 'open-settings': openSettingsDrawer(); break; case 'close-settings': shadow.getElementById('settingsDrawer').classList.remove('open'); break; case 'open-ai': openAiDrawer(); break; case 'close-ai': shadow.getElementById('aiDrawer').classList.remove('open'); break; case 'add-bookmark': addBookmark(); break; case 'add-tag': addTag(); break; case 'prev-chapter': setChapter(state.chapterIndex - 1); break; case 'next-chapter': setChapter(state.chapterIndex + 1); break; case 'format-short': formatShortBook(); break; case 'export-txt': exportCurrentBook(); break; case 'export-project': exportCurrentProject(); break; case 'save-ai-config': saveAiConfig(); break; case 'save-cloud-tts': saveCloudTtsConfig(); break; case 'send-ai': sendAiMessage(); break; case 'clear-ai': clearAiHistory(); break; case 'tts-toggle': toggleTts(); break; case 'tts-stop': stopTts(); break; case 'tts-prev': moveTtsSegment(-1); break; case 'tts-next': moveTtsSegment(1); break; default: break; } } function bindEvents() { shadow.addEventListener('click', event => { const target = event.target; const deleteBookButton = target.closest('[data-delete-book]'); if (deleteBookButton) { event.stopPropagation(); deleteBook(deleteBookButton.dataset.deleteBook); return; } const openBookTarget = target.closest('[data-open-book]'); if (openBookTarget) { loadBook(openBookTarget.dataset.openBook); return; } const chapterTarget = target.closest('[data-chapter-index]'); if (chapterTarget) { setChapter(Number(chapterTarget.dataset.chapterIndex)); return; } const sideTab = target.closest('[data-side-tab]'); if (sideTab) { state.sidebarTab = sideTab.dataset.sideTab; renderSidebar(); return; } const deleteAnnotationButton = target.closest('[data-delete-annotation]'); if (deleteAnnotationButton) { event.stopPropagation(); deleteAnnotation( deleteAnnotationButton.dataset.deleteAnnotation, deleteAnnotationButton.dataset.annotationType ); return; } const annotationTarget = target.closest('[data-jump-annotation]'); if (annotationTarget) { jumpToAnnotation( annotationTarget.dataset.jumpAnnotation, annotationTarget.dataset.annotationType ); return; } const aiPrompt = target.closest('[data-ai-prompt]'); if (aiPrompt) { sendAiMessage(aiPrompt.dataset.aiPrompt); return; } const paragraph = target.closest('#readerContent p'); if (paragraph) { setActiveParagraph(Number(paragraph.dataset.paragraphIndex)); } const actionTarget = target.closest('[data-action]'); if (actionTarget) { event.preventDefault(); handleAction(actionTarget.dataset.action); } }); shadow.getElementById('fileInput').addEventListener('change', event => { handleFile(event.target.files?.[0]); }); shadow.getElementById('chapterSearch').addEventListener('input', renderChapterList); shadow.addEventListener('input', event => { const id = event.target.id; if ( id === 'settingAccent' || id === 'settingFont' || id === 'settingSize' || id === 'settingLine' || id === 'settingWidth' || id === 'settingRate' || id === 'settingPitch' || id === 'settingVolume' ) { updatePrefsFromControls(); } }); shadow.addEventListener('change', event => { const id = event.target.id; if ( id === 'settingTheme' || id === 'settingAccent' || id === 'settingFont' || id === 'settingSize' || id === 'settingLine' || id === 'settingWidth' || id === 'settingRate' || id === 'settingPitch' || id === 'settingVolume' || id === 'settingAutoRate' || id === 'settingAutoVolume' ) { updatePrefsFromControls(); } else if (id === 'settingTtsEngine' || id === 'ttsEngine') { switchTtsEngine(event.target.value); } else if (id === 'ttsVoice') { setSelectedVoice(event.target.value); } else if (id === 'ttsRange') { state.prefs.tts.range = event.target.value; scheduleSavePrefs(); updateTtsBar(); } }); shadow.getElementById('aiInput').addEventListener('keydown', event => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); sendAiMessage(); } }); shadow.getElementById('readerContent').addEventListener('pointerup', () => { setTimeout(captureSelection, 20); }); document.addEventListener('selectionchange', debounce(() => { if (state.appOpen && app && !app.hidden) captureSelection(); }, 100), true); if ('speechSynthesis' in window) { speechSynthesis.addEventListener?.('voiceschanged', () => { populateVoices(getSelectedVoice()); }); } fab.addEventListener('click', event => { if (fab.dataset.dragged === '1') { fab.dataset.dragged = '0'; event.preventDefault(); return; } openApp(); }); installFabDrag(); document.addEventListener('keydown', event => { if (event.altKey && (event.key === 'x' || event.key === 'X')) { event.preventDefault(); if (state.appOpen) closeApp(); else openApp(); return; } if (!state.appOpen) return; if (event.altKey && (event.key === 's' || event.key === 'S')) { event.preventDefault(); toggleTts(); } else if (event.altKey && (event.key === 'q' || event.key === 'Q')) { event.preventDefault(); stopTts(); } else if (event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); if (event.shiftKey) setChapter(state.chapterIndex - 1); else moveTtsSegment(-1); } else if (event.altKey && event.key === 'ArrowRight') { event.preventDefault(); if (event.shiftKey) setChapter(state.chapterIndex + 1); else moveTtsSegment(1); } else if (event.altKey && (event.key === 'h' || event.key === 'H')) { event.preventDefault(); openApp(); shadow.getElementById('ttsBar')?.scrollIntoView({ block: 'nearest' }); } else if (event.key === 'Escape') { const online = shadow.getElementById('onlineModal'); const settings = shadow.getElementById('settingsDrawer'); const ai = shadow.getElementById('aiDrawer'); if (!online.hidden) closeOnlineModal(); else if (settings.classList.contains('open') || ai.classList.contains('open')) closeDrawers(); else closeApp(); } }); } function installFabDrag() { let drag = null; fab.addEventListener('pointerdown', event => { if (event.button !== 0) return; const rect = fab.getBoundingClientRect(); drag = { startX: event.clientX, startY: event.clientY, left: rect.left, top: rect.top, moved: false, }; fab.setPointerCapture?.(event.pointerId); }); fab.addEventListener('pointermove', event => { if (!drag) return; const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 5) return; drag.moved = true; fab.style.left = clamp(drag.left + dx, 6, window.innerWidth - 62) + 'px'; fab.style.top = clamp(drag.top + dy, 6, window.innerHeight - 62) + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto'; }); fab.addEventListener('pointerup', () => { if (drag?.moved) fab.dataset.dragged = '1'; drag = null; }); fab.addEventListener('pointercancel', () => { drag = null; }); } function initialize() { if (document.getElementById(APP_ID)) return; loadPrefs(); createUI(); setStatus('就绪 · Alt+X 打开/关闭'); console.info('[西子拆书] 用户脚本已加载'); window.__XIZI_READER__ = { open: openApp, close: closeApp, importText: (text, title) => openNewBook(parseNovelText(text, title)), parseNovelText, parseFanqieChapter, parseFanqieCatalog, makeTtsSegments, decodeFanqieText, switchTtsEngine, fetchVolcanoTtsAudio, fetchTencentTtsAudio, collectCurrentPageParagraphs, importCurrentPage, getState: () => state, }; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize, { once: true }); } else { initialize(); } })();