diff --git a/.deploy.env.example b/.deploy.env.example index 65c695a..f126d2f 100644 --- a/.deploy.env.example +++ b/.deploy.env.example @@ -32,8 +32,10 @@ SRC_DIR="/root/code/novel/fanqie-go" DOMAIN="fq.20010117.xyz" # ---------- 证书 ---------- -# Certbot 申请证书的邮箱,用于接收过期提醒 +# acme.sh 申请证书的邮箱,用于账户注册与过期提醒 CERT_EMAIL="you@example.com" +# 证书安装目录(acme.sh --install-cert 目标,nginx 配置从此处读取) +# CERT_DIR="/etc/nginx/ssl" # ---------- 编译 ---------- # BUILD_TARGET="./cmd/fanqie" diff --git a/.gitignore b/.gitignore index 57ea76e..460fc18 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ fanqie-* .env .finesse/ fanqie.log +.codegraph/ diff --git a/bookSource.json b/bookSource.json new file mode 100644 index 0000000..2455572 --- /dev/null +++ b/bookSource.json @@ -0,0 +1,63 @@ +[ + { + "bookSourceName": "🔅番茄聚合", + "bookSourceGroup": "📖番茄小说", + "bookSourceType": 0, + "bookSourceUrl": "https://fq.20010117.xyz", + "bookSourceComment": "番茄聚合书源(fanqie-go 后端)\n\n基础使用教程:\n1、搜索:\n 1) 基础搜索:直接搜,默认搜索当前模式内容\n 2) 快捷搜索:使用特殊关键词(x,t,m,d)搜索\n x:书名 如(x:十日终焉) 搜小说(可省略)\n t:书名 如(t:十日终焉) 搜听书\n m:书名 如(m:十日终焉) 搜漫画\n d:书名 如(d:十日终焉) 搜短剧\n 冒号支持中文英文,也可在书源设置中切换默认搜索模式\n2、发现页:\n 顶部筛选选择 类型/频道/字数/更新/排序 后,点击分类宫格浏览,下拉翻页\n3、段评:\n 阅读正文时点击段尾气泡即可查看段落评论,样式可在段评设置中调整\n4、听书/漫画/短剧:\n 听书自动用播放器播放;漫画走服务端解密代理;短剧打开章节自动唤起播放\n\n更换服务器地址:只需修改源URL(bookSourceUrl),全部规则使用相对地址自动跟随", + "bookSourceEnabled": true, + "enabledExplore": true, + "enabledCookieJar": true, + "customOrder": 0, + "weight": 0, + "respondTime": 180000, + "lastUpdateTime": 1791607295124, + "bookUrlPattern": "https?:\\/\\/(?:[a-zA-Z0-9-]+\\.)?[a-zA-Z0-9-]+(?::\\d+)?\\/detail\\?book_id=\\d+.*", + "jsLib": "// 当前书源版本号\nlet localVersion = '26.10.10';\n\n// 初始配置\nconst defaultConfig = {\n 发现页类型: \"小说\",\n 频道: \"男频\",\n 搜索模式: \"小说\"\n};\n\n// 获取源变量\nfunction getVariable(k) {\n if (k == undefined) k = \"\";\n const { source } = this;\n let parsed = {};\n try {\n parsed = JSON.parse(source.getVariable());\n } catch {}\n if (k == \"\") {\n return parsed;\n }\n let value = parsed[k];\n if (value == undefined) {\n value = defaultConfig[k];\n }\n return value != undefined ? value : \"\";\n}\n\n// 设置源变量\nfunction setVariable(k, v, t) {\n if (t == undefined) t = true;\n const { source, java } = this;\n const vs = this.getVariable();\n vs[k] = v;\n source.setVariable(JSON.stringify(vs, null, 4));\n if (k != '搜索关键词' && t) {\n java.toast(`\\n设置 ${k} 为 ${v}`)\n }\n}\n\n// 还原\nfunction deleteVariable() {\n const { source, java } = this;\n source.setVariable('');\n java.longToast('\\n所有设置已还原到初始化导入状态');\n}\n\n// 从 URL 提取内容类型标记(n小说/t听书/m漫画/d短剧)。\n// 注意:jsLib 函数作用域看不到规则变量 baseUrl,必须由调用方显式传入\nfunction fqTab(url, def) {\n if (def == undefined) def = 'n';\n let m = String(url).match(/fqtab=([ntmd])/);\n return m ? m[1] : def;\n}\n\n// 判断当前环境\nfunction checkEnv() {\n const {\n java,\n source\n } = this;\n\n try {\n java.qread();\n return \"轻阅读\";\n } catch (e) {}\n\n try {\n if (typeof java.reLoginView == 'function') {\n return \"改版\";\n }\n new Packages.io.legato.kazusa.utils.TimeoutCancellationException('');\n return \"改版\";\n } catch (e) {}\n try {\n java.deviceID();\n return \"苹果\";\n } catch (e) {}\n\n if (typeof source.loginUi == 'function') {\n return \"安卓\";\n }\n\n return \"改版\";\n}\n\n// 备用设置方案(登录页按钮)\nfunction ste(v) {\n const { java } = this;\n let moreSettings = this.getVariable(\"更多设置\") || {};\n let paraSettings = this.getVariable(\"段评设置\") || {};\n if (v == \"段评\") {\n if (paraSettings[\"段评开关\"] != \"false\") {\n paraSettings[\"段评开关\"] = \"false\";\n java.longToast(\"\\n❌段评已关闭\");\n } else {\n paraSettings[\"段评开关\"] = \"true\";\n java.longToast(\"\\n✅段评已开启\");\n }\n }\n if (v == \"小说\" || v == \"听书\" || v == \"漫画\" || v == \"短剧\") {\n moreSettings[\"搜索模式\"] = v;\n java.longToast(`\\n已设置搜索模式为 ${v}`);\n }\n if (v == \"图片\") {\n if (moreSettings[\"显示图片\"] != \"false\") {\n moreSettings[\"显示图片\"] = \"false\";\n java.longToast(\"\\n❌已关闭图片显示\");\n } else {\n moreSettings[\"显示图片\"] = \"true\";\n java.longToast(\"\\n✅已开启图片显示\");\n }\n }\n if (v == \"简介\") {\n if (moreSettings[\"完整简介\"] != \"false\") {\n moreSettings[\"完整简介\"] = \"false\";\n java.longToast(\"\\n❌已关闭完整简介\");\n } else {\n moreSettings[\"完整简介\"] = \"true\";\n java.longToast(\"\\n✅已开启完整简介\");\n }\n }\n this.setVariable(\"更多设置\", moreSettings, false);\n this.setVariable(\"段评设置\", paraSettings, false);\n}\n\n// 按钮切换时改变源变量并刷新发现\nfunction show(m, t) {\n const { java, source } = this;\n let data = {};\n try {\n data = JSON.parse(source.getVariable())\n } catch {}\n data[t] = m;\n if (t == \"发现页类型\") {\n data[\"频道\"] = '男频';\n }\n source.setVariable(JSON.stringify(data, null, 2));\n java.refreshExplore();\n}\n\nfunction createFilter(title, chars, defaultVal, paramKey, size, viewName) {\n if (viewName == undefined) viewName = \"'请选择类型'\";\n return {\n title: title,\n type: \"select\",\n chars: chars,\n default: defaultVal,\n \"viewName\": viewName,\n action: `show(infoMap['${title}'],'${paramKey}')`,\n style: {\n layout_flexGrow: 1,\n layout_flexBasisPercent: size\n }\n };\n}\n\nfunction createText(title, action, defaultValue, size, viewName) {\n return {\n title: title,\n type: 'text',\n default: defaultValue || '',\n action: action,\n viewName: viewName || title,\n style: {\n layout_flexGrow: 1,\n layout_flexBasisPercent: size\n }\n };\n}\n\nfunction createButton(title, action, size) {\n return {\n title: title,\n type: 'button',\n action: action,\n style: {\n layout_flexGrow: 1,\n layout_flexBasisPercent: size\n }\n };\n}\n\nfunction exploreSearch() {\n let {java, source} = this;\n var key = this.getVariable('搜索关键词');\n var tab = this.getVariable('发现页类型') || '小说';\n if (!key) {\n try { java.toast('请输入搜索关键词'); } catch (e) {}\n return;\n }\n var tabMap = {\n '小说': 'x:',\n '漫画': 'm:',\n '听书': 't:',\n '短剧': 'd:',\n '': ''\n };\n if (['t:', 'x:', 'd:', 'm:', 't:', 'x:', 'd:', 'm:'].some(p => key.startsWith(p))) {\n tab = '';\n }\n key = `${tabMap[tab]}${key}`;\n try {\n java.searchBook(key, source.bookSourceName + \"::\" + source.getKey());\n } catch (e1) {\n try { java.searchBook(key); } catch (e2) {\n try { java.open('search', key); } catch (e3) { java.toast('搜索失败,请手动搜索'); }\n }\n }\n java.refreshExplore();\n}\n\n//去除图片\nfunction removeAllImgTags(htmlString) {\n const imgTagRegex = /]*>|<\\/img>|]*\\/>/gi;\n return htmlString.replace(imgTagRegex, '');\n}\n\nlet svgs = [\n \" $displayText\",\n\n \"$displayText\",\n\n \"$displayText\",\n\n\n\n \"$displayText\",\n\n\n\n \"$displayText\",\n\n\n\n\n `\n \n $displayText\n`\n\n];\n\n// 创建svg\nfunction createSvg(number, encodedUrl, sources) {\n var displayText = number > 99 ? \"99+\" : number.toString();\n let data = this.getVariable('段评设置');\n let svgstyle = data['段评样式'] || \"0\";\n let color = data['段评边框颜色'] || \"#A7A7A7\";\n let tccolor = data['段评填充颜色'] || \"transparent\";\n let fontcolor = data['段评字体颜色'] || \"#A7A7A7\";\n let zdy = svgs[0];\n try {\n zdy = data['自定义段评样式'][svgstyle] || svgs[0];\n } catch {}\n const styleMap = {\n \"0\": svgs[0],\n \"1\": svgs[1],\n \"2\": svgs[2],\n \"3\": svgs[3],\n \"4\": svgs[4],\n \"5\": svgs[5],\n };\n let svg = styleMap[svgstyle] || zdy;\n svg = svg.replace('$color', color)\n .replace('$tccolor', tccolor)\n .replace('$fontcolor', fontcolor)\n .replace('$displayText', displayText);\n var jc = 'js';\n let islyc = this.checkEnv()\n if (islyc == \"改版\") {\n jc = 'click'\n }\n let style = data['气泡增大'] == \"true\" ? 'TEXT' : 'text';\n var encodedSvg = this.java.base64Encode(svg);\n return `data:image/svg+xml;base64,${encodedSvg},{\"${jc}\":\"showCmt('${encodedUrl}', '${sources}' ,'段评')\",\"style\":\"${style}\"}`;\n}\n\n\n// 段评气泡注入(正文

支持任意属性)\nfunction paraForAndroid(content, sources) {\n let { cache } = this;\n const createSvg = this.createSvg.bind(this);\n return content.replace(/(]*>)([\\s\\S]*?)(?:)?(<\\/p>)/g,\n function (match, open, text, url, count, close) {\n if (url && count) {\n cache.putMemory(url, 0);\n return `

${text}

`;\n }\n return `

${text}

`;\n });\n}\n\n//源阅(iOS)段评\nfunction paraForiOS(content, sources) {\n return content.replace(/(]*>)([\\s\\S]*?)(?:)?(<\\/p>)/g,\n function (match, open, text, url, count, close) {\n if (url && count) {\n const numCount = Number(count);\n const displayCount = !isNaN(numCount) && numCount > 99 ? '99' : count;\n return `
${text}
`;\n }\n return `

${text}

`;\n });\n}\n\n// 显示段评:优先魔改版(阅读R/Sigma等)的原地底部弹窗(点击遮罩关闭、可拖拽下滑);\n// 官方版无此 API,自动降级为 startBrowser 打开自包含评论页\nfunction showCmt(url, sources, ident) {\n let { java } = this;\n if (ident == null) ident = '段评';\n var title = (sources || '番茄') + ident;\n var hasPopup = false;\n try { if (typeof java.showBrowser == 'function') hasPopup = true; } catch (e) {}\n if (hasPopup) {\n try {\n let html = '';\n // 正文注入的 ident 可能是 http://(反代 301 到 https),ajax 对 301 处理不稳,强制 https\n try { html = String(java.ajax(url.replace(/^http:\\/\\//, 'https://'))); } catch (e) {}\n java.showBrowser(url, html, 'window.java=java;', JSON.stringify({\n expandedCornersRadius: 20,\n dismissOnTouchOutside: true,\n isDraggable: true,\n shouldDimBackground: true,\n backgroundDimAmount: 0.5,\n hardwareAccelerated: true,\n isNestedScrollingEnabled: true,\n setFitToContents: true,\n isHideable: true,\n heightPercentage: 0.8\n }));\n return;\n } catch (e) {}\n }\n try {\n java.startBrowser(url, title);\n return;\n } catch (e) {}\n try { java.startBrowserDp(url, title); } catch (e2) { java.toast('当前版本不支持查看评论'); }\n}\n\n// ============ 书源设置页 ============\nfunction getHtmlSettings() {\n const { java } = this;\n let data = this.getVariable('更多设置');\n let html = `\n\n\n\n\n番茄聚合 - 书源设置\n\n\n\n
\n
\n
\n
🍅 番茄聚合 · 书源设置
\n
数据服务由 fanqie-go 提供
\n
\n
\n

设置完成后请点击右上角的√应用设置

\n
\n
\n
\n
搜索模式
\n
切换后搜索对应类型,也可以在搜索时使用关键词:t:书名 搜听书,d:书名 搜短剧,m:书名 搜漫画
\n
\n
\n \n
\n
\n
\n
\n
显示图片
\n
关闭后,文章内的图片将会清除
\n
\n \n
\n
\n
\n
完整简介
\n
关闭后,书籍详情页会精简简介内容
\n
\n \n
\n
\n ${(data && data.搜索模式) || '小说'}\n ${(data && data.显示图片) || 'true'}\n ${(data && data.完整简介) || 'true'}\n
\n
\n
\n\n\n`;\n let body;\n try {\n body = java.startBrowserAwait(`data:text/html;base64,${java.base64Encode(html)}`, '番茄聚合书源设置', false).body();\n } catch (e) {\n java.longToast(\"\\n请点击页面右上角√才能设置成功哦~\");\n return;\n }\n let settings = {};\n try {\n const getSpanText = (spanId) => {\n const regex = new RegExp('id=\"' + spanId + '\"\\s*>\\s*([^<]*?)\\s*<\\/span>');\n const match = body.match(regex);\n return match ? match[1].trim() : \"\";\n };\n settings = {\n '搜索模式': getSpanText('searchModeValue') || '小说',\n '显示图片': getSpanText('showImageSwitchValue') || 'true',\n '完整简介': getSpanText('fullDescSwitchValue') || 'true',\n };\n const settingsText = Object.keys(settings).map(key => `${key}: ${settings[key]}`).join('\\n');\n java.longToast(\"\\n\" + settingsText);\n } catch (e) {}\n this.setVariable(\"更多设置\", settings, false);\n}\n\n// ============ 段评设置页 ============\nfunction getSvgSettings() {\n const { java } = this;\n const islyc = this.checkEnv();\n if (islyc == \"苹果\") {\n java.longToast(\"\\n当前软件不支持设置段评样式\");\n return;\n }\n let data = this.getVariable('段评设置');\n let html = `\n\n\n\n\n番茄聚合 - 段评设置\n\n\n\n
\n
\n
\n
🗨 番茄聚合 · 段评设置
\n
点击正文段尾气泡查看段落评论
\n
\n
\n

设置完成后请点击右上角的√应用设置

\n
\n
\n
\n
段评
\n
阅读正文时在段落末尾显示评论气泡
\n
\n \n
\n
\n
\n
气泡增大
\n
开启后,气泡显示会加大一些
\n
\n \n
\n
\n
\n
边框颜色
\n
设置段评边框颜色
\n
\n
\n \n \n
\n
\n
\n
\n
字体颜色
\n
设置段评字体颜色
\n
\n
\n \n \n
\n
\n
\n
\n
填充颜色
\n
设置段评背景填充颜色
\n
\n
\n \n \n \n
\n
\n
\n
实时预览(点击选择样式)
\n
\n
\n
\n ${(data && data.段评开关) || 'true'}\n ${(data && data.气泡增大) || 'false'}\n ${(data && data.段评样式) || '0'}\n ${(data && data.段评边框颜色) || '#A7A7A7'}\n ${(data && data.段评字体颜色) || '#A7A7A7'}\n ${(data && data.段评填充颜色) || 'transparent'}\n
\n
\n
\n\n\n`;\n let body;\n try {\n body = java.startBrowserAwait(`data:text/html;base64,${java.base64Encode(html)}`, '番茄聚合段评设置', false).body();\n } catch (e) {\n if (islyc == \"安卓\") {\n java.longToast(\"\\n当前软件版本过低,请更新软件,或者使用底部备用设置方案~\");\n } else {\n java.longToast(\"\\n请点击页面右上角√才能设置成功哦~\");\n }\n return;\n }\n let settings = {};\n try {\n const getSpanText = (spanId) => {\n const regex = new RegExp('id=\"' + spanId + '\"\\s*>\\s*([^<]*?)\\s*<\\/span>');\n const match = body.match(regex);\n return match ? match[1].trim() : \"\";\n };\n settings = {\n '段评开关': getSpanText('duanpingSwitchValue') || 'true',\n '气泡增大': getSpanText('bubbleLargeSwitchValue') || 'false',\n '段评样式': getSpanText('duanpingStyleValue') || '0',\n '段评边框颜色': getSpanText('borderColorValue') || '#A7A7A7',\n '段评字体颜色': getSpanText('fontColorValue') || '#A7A7A7',\n '段评填充颜色': getSpanText('fillColorValue') || 'transparent',\n };\n const settingsText = Object.keys(settings).map(key => `${key}: ${settings[key]}`).join('\\n');\n java.longToast(\"\\n\" + settingsText);\n } catch (e) {\n settings = {\n '段评开关': 'true',\n '气泡增大': 'false',\n '段评样式': '0',\n '段评边框颜色': '#A7A7A7',\n '段评字体颜色': '#A7A7A7',\n '段评填充颜色': 'transparent'\n };\n }\n this.setVariable(\"段评设置\", settings, false);\n}\n", + "loginUi": "[\n {\n \"name\": \"⚙️ 书源设置\",\n \"type\": \"button\",\n \"action\": \"getHtmlSettings()\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"🗨 段评设置\",\n \"type\": \"button\",\n \"action\": \"getSvgSettings()\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"===下方为备用设置方案===\",\n \"type\": \"button\",\n \"action\": \"\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 1 }\n }, {\n \"name\": \"📖搜索小说\",\n \"type\": \"button\",\n \"action\": \"ste('小说')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"🎧搜索听书\",\n \"type\": \"button\",\n \"action\": \"ste('听书')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"🎥搜索短剧\",\n \"type\": \"button\",\n \"action\": \"ste('短剧')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"🎨搜索漫画\",\n \"type\": \"button\",\n \"action\": \"ste('漫画')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.4 }\n }, {\n \"name\": \"段评开关\",\n \"type\": \"button\",\n \"action\": \"ste('段评')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.25 }\n }, {\n \"name\": \"图片开关\",\n \"type\": \"button\",\n \"action\": \"ste('图片')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.25 }\n }, {\n \"name\": \"简介开关\",\n \"type\": \"button\",\n \"action\": \"ste('简介')\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 0.25 }\n }, {\n \"name\": \"⛔‼️ 清空还原设置\",\n \"type\": \"button\",\n \"action\": \"deleteVariable()\",\n \"style\": { \"layout_flexGrow\": 1, \"layout_flexBasisPercent\": 1 }\n }\n]", + "searchUrl": "\nlet moreSettings = getVariable('更多设置');\nlet tab = moreSettings && moreSettings['搜索模式'] || '小说';\nvar k = String(key);\nlet m = k.match(/^[xtmdXTMD][::]\\s*/);\nif (m) {\n tab = { x: '小说', t: '听书', m: '漫画', d: '短剧' }[m[0][0].toLowerCase()];\n k = k.slice(m[0].length);\n}\nlet tabType = { 小说: 3, 听书: 2, 漫画: 8, 短剧: 11 }[tab] || 3;\n`/search?query=${encodeURIComponent(k)}&offset=${(page - 1) * 10}&tab_type=${tabType}`;\n", + "exploreUrl": "\nvar base = String(source.getKey()).replace(/\\/$/, '');\nvar tab = getVariable('发现页类型') || '小说';\nvar channel = getVariable('频道') || '男频';\nvar wordNumMap = { '不限': '0', '30万字以内': '4', '100万字以内': '1', '30万-100万': '5', '100万-300万': '2', '300万字以上': '3', '100万字以上': '9' };\nvar updateMap = { '不限': '2', '完结': '0', '连载中': '1', '今日更新': '3' };\nvar sortMap = { '综合': '0', '热门': '24', '最新': '76', '评分': '12' };\nvar wordNum = getVariable('字数') || '不限';\nvar update = getVariable('更新') || '不限';\nvar sort = getVariable('排序') || '综合';\nvar q = `&word_number=${wordNumMap[wordNum] || '0'}&book_status=${updateMap[update] || '2'}&sort_by=${sortMap[sort] || '0'}`;\nvar tabCode = { 小说: 'n', 听书: 't', 漫画: 'm', 短剧: 'd' }[tab] || 'n';\n\nvar list = [];\nlist.push(createFilter('类型', ['小说', '听书', '短剧', '漫画'], tab, '发现页类型', 0.4));\nlist.push(createFilter('频道', ['男频', '女频'], channel, '频道', 0.4));\nlist.push(createText('搜索关键词', \"setVariable('搜索关键词',(infoMap['搜索关键词'] || (infoMap.get && infoMap.get('搜索关键词'))))\", getVariable('搜索关键词') || '', 0.5, \"'搜索书名或作者'\"));\nlist.push(createButton('🔍搜索', 'exploreSearch()', 0.3));\nlist.push({ title: '⚙️书源设置', type: 'button', action: 'getHtmlSettings()', style: { layout_flexGrow: 1, layout_flexBasisPercent: 0.25 } });\nlist.push({ title: '🗨段评设置', type: 'button', action: 'getSvgSettings()', style: { layout_flexGrow: 1, layout_flexBasisPercent: 0.25 } });\nlist.push(createFilter('字数', Object.keys(wordNumMap), wordNum, '字数', 0.4));\nlist.push(createFilter('更新', Object.keys(updateMap), update, '更新', 0.4));\nlist.push(createFilter('排序', Object.keys(sortMap), sort, '排序', 0.1));\n\nif (tab == '短剧') {\n list.push({ title: '短剧暂无分类,请使用上方搜索 🔍', url: '', style: { layout_flexGrow: 1, layout_flexBasisPercent: 1 } });\n} else {\n var ctab = tab == '小说' ? (channel == '女频' ? 0 : 1) : (tab == '听书' ? 3 : 5);\n try {\n var res = JSON.parse(java.ajax(base + '/category-front?tab=' + ctab));\n var cells = ((res.data || {}).category_tab_data || {}).cell_data || [];\n cells.forEach(function (cell) {\n (cell.atom_data || []).forEach(function (a) {\n var c = a.category_data;\n if (c && c.category_id != null) {\n list.push({\n title: c.name,\n url: base + '/category-landing?category_id=' + c.category_id + '&offset={{(page-1)*20}}' + q + '&fqtab=' + tabCode,\n style: { layout_flexGrow: 1, layout_flexBasisPercent: 0.25 }\n });\n }\n });\n });\n } catch (e) {\n java.toast('发现页分类获取失败');\n }\n}\nJSON.stringify(list);\n", + "ruleSearch": { + "bookList": "\nvar d = JSON.parse(result);\nvar isVideo = /tab_type=11/.test(baseUrl);\nvar arr = [];\n(d.data || []).forEach(function (b) {\n var list = isVideo ? (b.video_data || []) : (b.book_data || []);\n list.forEach(function (x) {\n if (isVideo) {\n arr.push({\n book_id: x.series_id,\n book_name: x.title,\n author: '',\n abstract: x.rec_text || '',\n thumb_url: x.horiz_cover || x.cover,\n category: '短剧',\n creation_status: '1',\n score: x.score,\n word_number: String(x.episode_cnt || '')\n });\n } else {\n arr.push(x);\n }\n });\n});\narr;\n", + "name": "$.book_name", + "author": "$.author", + "kind": "{{$.creation_status=='0'?'完结':'连载'}},{{$.category}},{{$.score}}分", + "wordCount": "$.word_number##^0+$", + "intro": "$.abstract", + "coverUrl": "$.thumb_url", + "bookUrl": "\nvar t = /tab_type=11/.test(baseUrl) ? 'd' : /tab_type=2/.test(baseUrl) ? 't' : /tab_type=8/.test(baseUrl) ? 'm' : 'n';\n`/detail?book_id=${result.book_id}&fqtab=${t}`;\n", + "checkKeyWord": "斗罗大陆" + }, + "ruleExplore": { + "bookList": "$.data.book_info[*]", + "name": "$.book_name", + "author": "$.author", + "kind": "{{$.category}},{{$.score}}分", + "wordCount": "$.word_number##^0+$", + "intro": "$.abstract", + "coverUrl": "$.thumb_url", + "bookUrl": "\nvar m = String(baseUrl).match(/fqtab=([ntmd])/);\n`/detail?book_id=${result.book_id}&fqtab=${m ? m[1] : 'n'}`;\n" + }, + "ruleBookInfo": { + "init": "$.data", + "name": "$.book_name", + "author": "$.author", + "kind": "{{$.creation_status=='0'?'完结':'连载'}},{{$.category}}", + "wordCount": "$.word_number##^0+$", + "lastChapter": "$.last_chapter_title", + "coverUrl": "$.thumb_url", + "intro": "\nconst { book_id, last_chapter_title, last_chapter_update_time, word_number, score, abstract, read_count, category } = result;\nlet m0 = String(baseUrl).match(/fqtab=([ntmd])/);\nconst tab = { n: '小说', t: '听书', m: '漫画', d: '短剧' }[(m0 ? m0[1] : 'n')];\nlet moreSettings = getVariable('更多设置');\nconst lightDivider = \"❇️───────❇️───────❇️\";\nconst heavyDivider = \"‎\\n‎\";\nconst isValid = (v) => String(v).length > 1;\nlet ctitle = book.durChapterTitle || '未开始';\nconst two = (n) => ('0' + n).slice(-2);\nconst fmtTime = (t) => {\n let n = parseInt(t);\n if (!n || n <= 0) return '';\n let d = new Date(n * 1000);\n return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;\n};\nconst formatAbstract = (text) => String(text).split(\"\\n\").map(line => ` ${line}`).join(\"\\n\");\nconst buildBasicInfo = (items) => {\n let info = \"\";\n for (let [value, prefix, icon] of items) {\n if (isValid(value)) info += ` ${icon} ${prefix} ${value}\\n`;\n }\n return info;\n};\nlet info = ` 🏷 数据来源:番茄小说(fanqie-go)\\n 🔄 当前模式:${tab}\\n 📖 阅读至:${ctitle}\\n`;\nconst fullBasicInfo = buildBasicInfo([\n [category, \"书籍分类:\", \"🌈\"],\n [last_chapter_title, \"最新章节:\", \"📚\"],\n [fmtTime(last_chapter_update_time), \"更新时间:\", \"⏳\"],\n [word_number, \"书籍字数:\", \"📝\"],\n [score, \"书籍评分:\", \"⭐\"],\n [read_count, \"今阅读人数:\", \"📃\"]\n]);\nif (fullBasicInfo) info += `${lightDivider}\\n${fullBasicInfo}`;\nif (isValid(abstract)) info += `${heavyDivider}\\n 📖 书籍简介:\\n${formatAbstract(abstract)}\\n`;\ninfo += `${lightDivider}\\n${heavyDivider}\\n 数据更新于 ${new Date().toLocaleString()}\\n`;\nconst abstractMode = moreSettings && moreSettings['完整简介'] || 'true';\nif (abstractMode == \"false\") {\n const simplifiedBasicInfo = buildBasicInfo([\n [last_chapter_title, \"最新章节:\", \"📚\"],\n [fmtTime(last_chapter_update_time), \"更新时间:\", \"⏳\"],\n [word_number, \"书籍字数:\", \"📝\"],\n [score, \"书籍评分:\", \"⭐\"]\n ]);\n info = `\\n‎${isValid(abstract) ? `📖 书籍简介:\\n${formatAbstract(abstract)}\\n${heavyDivider}` : ''}🏷 数据来源:番茄小说\\n${simplifiedBasicInfo}`;\n} else {\n info = info.split(\"\\n\").map(line => line.replace(/^ {4}/, \"\")).join(\"\\n\");\n}\ninfo;\n", + "tocUrl": "\nvar m = String(baseUrl).match(/fqtab=([ntmd])/);\n`/catalog?book_id=${result.book_id}&fqtab=${m ? m[1] : 'n'}`;\n", + "canReName": "1" + }, + "ruleToc": { + "chapterList": "\n// 类型随书切换(照光遇 typeMap:安卓用 default,苹果端用其类型体系)\nconst typeMap = {\n \"小说\": { \"苹果\": 0, \"default\": 8 },\n \"听书\": { \"苹果\": 1, \"default\": 32 },\n \"漫画\": { \"苹果\": 2, \"default\": 64 },\n \"短剧\": { \"苹果\": 3, \"default\": 8 }\n};\nconst device_type = checkEnv();\nvar tabName = { n: '小说', t: '听书', m: '漫画', d: '短剧' }[fqTab(baseUrl, 'n')];\nvar currentType = typeMap[tabName] || typeMap[\"小说\"];\nif (device_type == '苹果') {\n book.type = currentType[device_type];\n} else {\n book.type = currentType.default;\n}\ntry {\n book.imageStyle = checkEnv() == '改版' ? 'FULL' : 'TEXT';\n} catch (e) {}\nresult;\n$.data.item_data_list[*]", + "chapterName": "$.title", + "chapterUrl": "\nvar t = fqTab(baseUrl, 'n');\nvar id = result.item_id;\nvar u = {\n n: `/content?item_id=${id}&text_mode=html&review=keep&image_mode=keep&fqtab=n`,\n t: `/audio?item_ids=${id}&fqtab=t`,\n m: `/manga?item_ids=${id}&output=proxy&fqtab=m`,\n d: `/video?item_ids=${id}&fqtab=d`\n}[t];\nu;\n" + }, + "ruleContent": { + "content": "\nvar t = fqTab(baseUrl, 'n');\nlet moreSettings = getVariable('更多设置');\nlet dpSettings = getVariable('段评设置');\nlet islyc = checkEnv();\n\nif (t == 't') {\n // 听书:直接返回音频地址\n JSON.parse(result).data[0].main_url;\n} else if (t == 'm') {\n // 漫画:拼装图片\n var d = JSON.parse(result);\n var org = String(baseUrl).match(/^https?:\\/\\/[^\\/]+/)[0];\n var imgs = (d.data || {}).images || [];\n var out = [];\n for (var i = 0; i < imgs.length; i++) {\n out.push('');\n }\n out.join('\\n');\n} else if (t == 'd') {\n // 短剧:选最高清晰度,当前章节自动唤起播放\n var d = JSON.parse(result).data || {};\n var vl = d.video_list || {};\n var list = [];\n for (var k in vl) { list.push(vl[k]); }\n list.sort(function (a, b) { return (b.vheight || 0) - (a.vheight || 0); });\n var org = String(baseUrl).match(/^https?:\\/\\/[^\\/]+/)[0];\n var url = list.length ? String(list[0].main_url) : '';\n if (url && url.indexOf('http') != 0) { url = org + url; }\n if (url && book.durChapterIndex == chapter.index) {\n java.startBrowser(url, chapter.title);\n }\n '▶️ 本集已唤起播放(菜单-刷新可重新打开):\\n' + url;\n} else {\n // 小说:正文 + 段评气泡\n var d = JSON.parse(result);\n var content = String((d.data || {}).content || '');\n let show_img = moreSettings && moreSettings['显示图片'] || 'true';\n if (show_img == 'false') {\n content = removeAllImgTags(content);\n }\n let para = dpSettings && dpSettings['段评开关'] || 'true';\n if (para == 'true' && content.indexOf('", + "title": "$.data.title" + } + } +] \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index 43d1b8a..cd59253 100755 --- a/deploy.sh +++ b/deploy.sh @@ -7,6 +7,11 @@ # ./deploy.sh v1.2.0 # 编译 + 部署(指定版本) # ./deploy.sh v1.2.0 --init # 首次初始化(写 systemd/nginx 配置) # ./deploy.sh v1.2.0 --no-pull # 跳过 git pull +# ./deploy.sh v1.2.0 --no-autostart # 部署后不开机自启 +# ./deploy.sh v1.2.0 --no-restart # 只部署,不重启服务 +# ./deploy.sh --stop # 停止服务 +# ./deploy.sh --start # 启动服务 +# ./deploy.sh --clean # 清除所有部署内容(服务/nginx/证书),重新 --init 前用 # ./deploy.sh --build-only # 只编译,不部署 # ./deploy.sh --rollback # 交互式回滚到指定版本 # ./deploy.sh --rollback v1.0.0 # 直接回滚到指定版本 @@ -36,9 +41,11 @@ NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/${APP_NAME}.conf}" APP_PORT="${APP_PORT:-40180}" # 程序监听端口 DOMAIN="${DOMAIN:-fq.20010117.xyz}" # 域名 CERT_EMAIL="${CERT_EMAIL:-admin@${DOMAIN}}" # 证书申请邮箱(过期提醒) +CERT_DIR="${CERT_DIR:-/etc/nginx/ssl}" # 证书目录(acme.sh --install-cert 安装位置) BUILD_TARGET="${BUILD_TARGET:-./cmd/fanqie}" # go build 的包路径 ENV_FILE="${ENV_FILE:-${SRC_DIR}/.env}" # 应用环境变量(.env.example 为模板) KEEP_VERSIONS="${KEEP_VERSIONS:-5}" # 保留最近几个版本 +AUTO_START="${AUTO_START:-true}" # 服务是否开机自启(--no-autostart 覆盖) # ==================== 颜色与日志 ==================== C_GREEN='\033[1;32m'; C_YELLOW='\033[1;33m'; C_RED='\033[1;31m'; C_BLUE='\033[1;34m'; C_RESET='\033[0m' @@ -63,6 +70,11 @@ ROLLBACK_VERSION="" LIST=false FORCE=false SYNC_NGINX=false +AUTO_START="${AUTO_START:-true}" +NO_RESTART=false +STOP=false +START=false +CLEAN=false print_help() { cat </dev/null 2>&1; then - $SUDO systemctl restart "${APP_NAME}" +# 定位 acme.sh:PATH → 当前用户目录 → root 目录(sudo 后 HOME 可能被重置) +find_acme_sh() { + local acme + acme="$(command -v acme.sh 2>/dev/null || true)" + for c in "${HOME}/.acme.sh/acme.sh" "/root/.acme.sh/acme.sh"; do + if [ -z "$acme" ] && [ -x "$c" ]; then acme="$c"; fi + done + echo "$acme" +} + +# 应用开机自启设置(AUTO_START 由 --no-autostart / .deploy.env 决定) +apply_autostart() { + if [ "$AUTO_START" = true ]; then $SUDO systemctl enable "${APP_NAME}" >/dev/null 2>&1 || true else + $SUDO systemctl disable "${APP_NAME}" >/dev/null 2>&1 || true + info "开机自启已禁用(--no-autostart)" + fi +} + +# 重启服务 + 验证 +restart_and_verify() { + # 注意:不要用 "list-unit-files | grep -q"——grep -q 提前退出会让 systemctl 收到 + # SIGPIPE,在 pipefail 下整条管道判假(unit 多的机器必踩),误报"服务不存在" + if ! systemctl cat "${APP_NAME}" >/dev/null 2>&1; then warn "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init" return 0 fi + apply_autostart + + if [ "$NO_RESTART" = true ]; then + info "跳过服务重启(--no-restart),需要时执行: sudo systemctl restart ${APP_NAME}" + return 0 + fi + + log "重启 ${APP_NAME} 服务..." + $SUDO systemctl restart "${APP_NAME}" + if command -v nginx >/dev/null 2>&1; then $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true fi @@ -212,14 +261,12 @@ restart_and_verify() { # 写入 nginx 配置(--init 与 --sync-nginx 共用的唯一模板来源) +# 模板自带完整 443/证书配置(证书存在时启用),不依赖签发工具注入—— +# 这样 --sync-nginx 覆盖配置时不会丢失 HTTPS 段(注入式配置会被覆盖回 80-only) write_nginx_conf() { - $SUDO tee "${NGINX_CONF}" >/dev/null </dev/null </dev/null 2>&1 || { err "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init"; exit 1; } + if [ "$STOP" = true ]; then + $SUDO systemctl stop "${APP_NAME}" + log "✅ 已停止 ${APP_NAME}(开机自启设置不变)" + else + $SUDO systemctl start "${APP_NAME}" + sleep 1 + $SUDO systemctl status "${APP_NAME}" --no-pager -l | head -n 15 || true + fi + exit 0 +fi + +# ==================== --clean ==================== +# 清除本脚本部署的全部内容,便于重新 --init 初次部署 +if [ "$CLEAN" = true ]; then + warn "将删除以下内容:" + echo " - systemd 服务: ${SERVICE_FILE}" + echo " - nginx 配置: ${NGINX_CONF}" + echo " - 二进制目录: ${BIN_DIR}" + echo " - 证书: ${CERT_DIR}/${DOMAIN}(并移除 acme.sh 对该域名的管理)" + echo " - certbot 残留: ${DOMAIN} 的证书、续期 timer 与 hook" + echo " (保留 ${SRC_DIR} 下的 .env / 配置 / 数据库;acme.sh 本体保留供重新部署使用)" + read -rp "确认清理?输入 yes 继续: " REPLY + [ "$REPLY" = "yes" ] || { info "已取消"; exit 0; } + + # 服务 + if systemctl cat "${APP_NAME}" >/dev/null 2>&1; then + $SUDO systemctl disable --now "${APP_NAME}" || true + $SUDO rm -f "${SERVICE_FILE}" + $SUDO systemctl daemon-reload + log "已删除服务 ${APP_NAME}.service" + else + info "服务不存在,跳过" + fi + + # nginx 配置 + if [ -f "${NGINX_CONF}" ]; then + $SUDO rm -f "${NGINX_CONF}" + if $SUDO nginx -t 2>/dev/null; then + $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true + fi + log "已删除 nginx 配置并重载" + fi + + # 二进制与版本目录 + $SUDO rm -rf "${BIN_DIR}" + log "已删除二进制目录: ${BIN_DIR}" + + # 证书:移除 acme.sh 管理(--remove 不吊销,LE 证书自然过期)+ 删安装目录 + ACME_SH="$(find_acme_sh)" + if [ -n "$ACME_SH" ]; then + $SUDO "$ACME_SH" --remove -d "${DOMAIN}" >/dev/null 2>&1 || true + fi + $SUDO rm -rf "${CERT_DIR}/${DOMAIN}" + log "已清除证书: ${CERT_DIR}/${DOMAIN}" + + # certbot 残留(历史部署用 certbot 时遗留) + if command -v certbot &>/dev/null; then + $SUDO systemctl disable --now certbot.timer >/dev/null 2>&1 || true + $SUDO certbot delete --cert-name "${DOMAIN}" --non-interactive >/dev/null 2>&1 || true + fi + $SUDO rm -rf "/etc/letsencrypt/live/${DOMAIN}" "/etc/letsencrypt/archive/${DOMAIN}" \ + "/etc/letsencrypt/renewal/${DOMAIN}.conf" \ + "/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh" + log "已清理 certbot 残留" + + echo "" + log "✅ 清理完成。重新部署: $0 --init" + exit 0 +fi + # ==================== --rollback ==================== if [ "$ROLLBACK" = true ]; then if [ ! -d "${BIN_DIR}" ]; then @@ -410,31 +581,27 @@ EOF write_nginx_conf - # ---- Certbot 续期后自动重载 Nginx(deploy-hook 只在续期成功时执行)---- - HOOK_FILE="/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh" - if [ ! -f "${HOOK_FILE}" ]; then - log "创建 Certbot 续期重载 Hook: ${HOOK_FILE}" - $SUDO mkdir -p /etc/letsencrypt/renewal-hooks/deploy/ - $SUDO tee "${HOOK_FILE}" >/dev/null <<'HOOK' -#!/bin/bash -if /usr/sbin/nginx -t 2>/dev/null; then - systemctl reload nginx -fi -HOOK - $SUDO chmod +x "${HOOK_FILE}" - fi - - # ---- 首次初始化自动申请 HTTPS 证书(一次性;续期由 certbot.timer 自动处理)---- - if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then - if command -v certbot >/dev/null 2>&1; then - log "未检测到证书,尝试自动申请: ${DOMAIN}" - $SUDO certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos -m "${CERT_EMAIL}" --redirect \ - || warn "证书申请失败,请检查域名解析与 80 端口;可稍后手动执行 certbot --nginx -d ${DOMAIN}" - else - warn "未安装 certbot,跳过证书申请。可手动执行: dnf install certbot python3-certbot-nginx" - fi - else + # ---- 首次初始化自动申请 HTTPS 证书(一次性;续期由 acme.sh cron 自动处理)---- + if [ -f "${CERT_DIR}/${DOMAIN}/fullchain.pem" ]; then log "证书已存在,跳过申请" + else + ACME_SH="$(find_acme_sh)" + if [ -z "$ACME_SH" ]; then + err "未找到 acme.sh,请先安装: curl https://get.acme.sh | sh -s email=${CERT_EMAIL}" + exit 1 + fi + log "未检测到证书,使用 acme.sh 申请: ${DOMAIN}" + $SUDO mkdir -p /var/www/html "${CERT_DIR}/${DOMAIN}" + if $SUDO "$ACME_SH" --issue -d "${DOMAIN}" -w /var/www/html --server letsencrypt --keylength ec-256 -m "${CERT_EMAIL}" \ + && $SUDO "$ACME_SH" --install-cert -d "${DOMAIN}" --ecc \ + --fullchain-file "${CERT_DIR}/${DOMAIN}/fullchain.pem" \ + --key-file "${CERT_DIR}/${DOMAIN}/privkey.pem" \ + --reloadcmd "systemctl reload nginx"; then + log "证书申请成功,写入 443 配置..." + write_nginx_conf + else + warn "证书申请失败,请检查域名解析与 80 端口;成功后重跑: $0 --sync-nginx" + fi fi if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then diff --git a/internal/handlers/content.go b/internal/handlers/content.go index d35b356..cd01595 100644 --- a/internal/handlers/content.go +++ b/internal/handlers/content.go @@ -124,23 +124,17 @@ func addReviewToContent(jsonResult, itemID string, r *http.Request) string { } } - // 获取基础 URL + // 获取基础 URL(反代场景识别 X-Forwarded-Proto,避免生成 http:// 被 301) cfg := config.GetConfig() baseURL := "" - if cfg != nil { + if r.Host != "" { + baseURL = externalBaseURL(r) + } else if cfg != nil { host := cfg.Server.Host if host == "0.0.0.0" { host = "localhost" } - port := cfg.Server.Port - scheme := "http" - - // 检查是否来自外部请求 - if r.Host != "" { - baseURL = scheme + "://" + r.Host - } else { - baseURL = scheme + "://" + host + ":" + port - } + baseURL = "http://" + host + ":" + cfg.Server.Port } // 生成带段评标记的内容 diff --git a/internal/handlers/review.go b/internal/handlers/review.go index 9fd00d1..5d3cd9c 100644 --- a/internal/handlers/review.go +++ b/internal/handlers/review.go @@ -12,6 +12,16 @@ import ( "time" ) +// externalBaseURL 取外部可访问的基础 URL(nginx 反代下识别 X-Forwarded-Proto, +// 避免生成 http:// 链接被 301,弹窗 WebView 内 fetch 对 301 处理不稳) +func externalBaseURL(r *http.Request) string { + scheme := "http" + if p := r.Header.Get("X-Forwarded-Proto"); p != "" { + scheme = p + } + return scheme + "://" + r.Host +} + // validateParams 验证必需参数 func validateParams(r *http.Request, required ...string) (map[string]string, error) { params := make(map[string]string) @@ -207,7 +217,7 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - html := generateReviewHTML(data, params["item_id"], bookID, para, page, pageSize, sort) + html := generateReviewHTML(r, data, params["item_id"], bookID, para, page, pageSize, sort) fmt.Fprint(w, html) } @@ -318,6 +328,7 @@ var reviewTmpl = template.Must(template.New("review").Parse(reviewHTML)) // reviewPageData 评论页模板数据 type reviewPageData struct { ItemID, BookID string + BaseURL string ParaPlus1, Para, Sort int Total, NextOffset int PageSize, CommentCount int @@ -327,7 +338,7 @@ type reviewPageData struct { } // generateReviewHTML 生成评论 HTML 页面 -func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para, page, pageSize, sort int) string { +func generateReviewHTML(r *http.Request, data map[string]interface{}, itemID, bookID string, para, page, pageSize, sort int) string { total := 0 hasMore := false nextOffset := 0 @@ -383,6 +394,7 @@ func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para var b strings.Builder if err := reviewTmpl.Execute(&b, reviewPageData{ ItemID: itemID, BookID: bookID, + BaseURL: externalBaseURL(r), ParaPlus1: para + 1, Para: para, Sort: sort, Total: total, NextOffset: nextOffset, PageSize: pageSize, CommentCount: len(pageComments), diff --git a/internal/handlers/templates/review.html b/internal/handlers/templates/review.html index fbe04da..521775e 100644 --- a/internal/handlers/templates/review.html +++ b/internal/handlers/templates/review.html @@ -49,25 +49,50 @@ box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } + html, body { height: 100%; } body { background-color: var(--bg-primary); color: var(--text-primary); - padding: 15px; max-width: 600px; margin: 0 auto; + display: flex; + flex-direction: column; + overflow: hidden; transition: background-color 0.3s ease, color 0.3s ease; } .header { + flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; - padding: 10px 15px; + padding: 10px 15px 12px; background: var(--bg-secondary); - border-radius: 12px; + border-radius: 20px 20px 0 0; + border-bottom: 1px solid var(--border-color); box-shadow: 0 2px 8px var(--shadow); + position: relative; + z-index: 10; transition: background-color 0.3s ease; } + /* BottomSheet 顶部拖拽把手 */ + .header::before { + content: ''; + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + width: 36px; + height: 4px; + border-radius: 2px; + background: var(--border-color); + } + #comment-list { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 15px; + overscroll-behavior: contain; + } .header h1 { font-size: 18px; color: var(--text-primary); @@ -333,8 +358,8 @@
-
最热
-
最新
+
最热
+
最新
@@ -370,6 +395,8 @@ const bookID = '{{.BookID}}'; const para = {{.Para}}; let currentSort = {{.Sort}}; + // 服务端注入的绝对地址:弹窗 WebView(loadDataWithBaseURL) 中相对路径解析不可靠 + const API_BASE = '{{.BaseURL}}'; const commentList = document.getElementById('comment-list'); const imageViewer = document.getElementById('imageViewer'); @@ -380,7 +407,7 @@ // 已加载回复的评论 const loadedReplies = new Set(); - // URL 构建 + // URL 构建(绝对地址,兼容弹窗 WebView) function buildReviewURL(params) { const baseParams = { item_id: itemID, @@ -388,7 +415,7 @@ para: para }; const query = new URLSearchParams({...baseParams, ...params}); - return '/review?' + query.toString(); + return API_BASE + '/review?' + query.toString(); } // 时间格式化 @@ -429,11 +456,11 @@ }; } - // 滚动信息获取 + // 滚动信息获取(评论区为独立滚动容器) function getScrollInfo() { - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const scrollHeight = document.documentElement.scrollHeight; - const clientHeight = document.documentElement.clientHeight; + const scrollTop = commentList.scrollTop; + const scrollHeight = commentList.scrollHeight; + const clientHeight = commentList.clientHeight; return { scrollTop, scrollHeight, @@ -443,11 +470,11 @@ } // 切换排序 - async function switchSort(newSort) { + async function switchSort(newSort, tab) { if (currentSort === newSort) return; - - document.querySelectorAll('.sort-tab').forEach(tab => tab.classList.remove('active')); - event.target.closest('.sort-tab').classList.add('active'); + + document.querySelectorAll('.sort-tab').forEach(t => t.classList.remove('active')); + if (tab) tab.classList.add('active'); commentList.innerHTML = LOADING_HTML; @@ -479,7 +506,7 @@ } loadedReplies.clear(); - window.scrollTo({ top: 0, behavior: 'smooth' }); + commentList.scrollTo({ top: 0, behavior: 'smooth' }); } else { commentList.innerHTML = '
加载失败,请重试
'; commentIndex = 0; @@ -751,25 +778,25 @@ // 回到顶部 function scrollToTop() { - window.scrollTo({ + commentList.scrollTo({ top: 0, behavior: 'smooth' }); } - - // 滚动监听 + + // 滚动监听(挂在评论容器上,顶栏固定不参与滚动) const handleScroll = debounce(() => { const scroll = getScrollInfo(); - + // 显示/隐藏回到顶部按钮 backToTopBtn.classList.toggle('show', scroll.scrollTop > 300); - + // 距离底部 200px 时触发加载 if (scroll.distanceFromBottom <= 200) { loadMoreComments(); } }, 100); - - window.addEventListener('scroll', handleScroll, { passive: true }); + + commentList.addEventListener('scroll', handleScroll, { passive: true });