// ==UserScript== // @name 番茄小说正-文关关同学自用版 // @namespace http://tampermonkey.net/ // @version 2.2 // @description 修正接口调用(ids 直取正文)+ 可折叠/可隐藏的悬浮面板,实时显示书籍ID与章节ID // @author You // @match https://fanqienovel.com/* // @grant GM_xmlhttpRequest // @connect oiapi.net // @run-at document-end // ==/UserScript== (function () { 'use strict'; const API_KEY = ''; const API_URL = 'https://oiapi.net/api/FqRead'; const LS_KEY = 'fq_reader_panel_v21'; let chapterListCache = null; let cacheBookId = null; let lastDoneKey = ''; let isReplacing = false; let resolvedBookId = ''; let currentChapter = null; const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ''); // 19 位 ID 超过 JS 安全整数,JSON.parse 会丢精度 // (7173216089122439711 → 7173216089122440000),只能按前 15 位比对 const sameId = (a, b) => { const sa = norm(a), sb = norm(b); if (!sa || !sb) return false; return sa === sb || (sa.length === sb.length && sa.slice(0, 15) === sb.slice(0, 15)); }; /* ================= 样式 ================= */ const style = document.createElement('style'); style.textContent = ` #fq-panel{ position:fixed;top:120px;right:20px;z-index:99999;width:236px; background:#fff;border:1px solid #dcdfe6;border-radius:10px; box-shadow:0 4px 18px rgba(0,0,0,.15); font:13px/1.5 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; color:#303133;user-select:none;overflow:hidden; } #fq-head{ display:flex;align-items:center;gap:6px;padding:7px 10px; background:#f5f7fa;border-bottom:1px solid #ebeef5;cursor:move; } #fq-title{font-size:13px;font-weight:500;flex:1;} .fq-hbtn{ width:18px;height:18px;line-height:16px;text-align:center;cursor:pointer; border:1px solid #dcdfe6;border-radius:4px;color:#909399;font-size:13px; } .fq-hbtn:hover{background:#ecf5ff;color:#409eff;border-color:#b3d8ff;} #fq-body{padding:8px 10px 10px;} #fq-body.fq-hidden{display:none;} .fq-row{display:flex;gap:6px;margin-bottom:5px;font-size:12px;} .fq-k{width:44px;flex:none;color:#909399;} .fq-v{flex:1;word-break:break-all;font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#303133;} .fq-v.fq-copy{cursor:pointer;} .fq-v.fq-copy:hover{color:#409eff;text-decoration:underline;} #fq-run{ width:100%;margin-top:6px;padding:6px 0;border:none;border-radius:6px; background:#409eff;color:#fff;font-size:13px;cursor:pointer; } #fq-run:hover{background:#66b1ff;} #fq-launcher{ position:fixed;top:120px;right:20px;z-index:99999; width:30px;height:30px;line-height:30px;text-align:center; background:#409eff;color:#fff;border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.2);cursor:pointer; font:13px/30px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif; } #fq-launcher.fq-hidden{display:none;}`; document.head.appendChild(style); /* ================= 面板 DOM ================= */ const panel = document.createElement('div'); panel.id = 'fq-panel'; panel.innerHTML = `
番茄解析 − ×
书籍ID—
章节ID—
章节—
标题—
状态待命
`; document.body.appendChild(panel); const launcher = document.createElement('div'); launcher.id = 'fq-launcher'; launcher.textContent = '番'; launcher.title = '显示番茄解析面板'; document.body.appendChild(launcher); const elBody = panel.querySelector('#fq-body'); const elBook = panel.querySelector('#fq-book'); const elItem = panel.querySelector('#fq-item'); const elChap = panel.querySelector('#fq-chap'); const elName = panel.querySelector('#fq-name'); const elStatus = panel.querySelector('#fq-status'); const elFold = panel.querySelector('#fq-fold'); const runBtn = panel.querySelector('#fq-run'); const head = panel.querySelector('#fq-head'); /* ================= 面板状态(折叠 / 隐藏 / 位置) ================= */ const store = { collapsed: false, hidden: false, pos: null }; try { Object.assign(store, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); } catch (e) { /* ignore */ } const saveStore = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(store)); } catch (e) { /* ignore */ } }; function applyCollapsed() { elBody.classList.toggle('fq-hidden', store.collapsed); elFold.textContent = store.collapsed ? '+' : '−'; panel.style.width = store.collapsed ? '130px' : '236px'; saveStore(); } function applyHidden() { panel.classList.toggle('fq-hidden', store.hidden); launcher.classList.toggle('fq-hidden', !store.hidden); saveStore(); } function applyPos() { if (store.pos) { panel.style.left = store.pos.l + 'px'; panel.style.top = store.pos.t + 'px'; panel.style.right = 'auto'; launcher.style.left = store.pos.l + 'px'; launcher.style.top = store.pos.t + 'px'; launcher.style.right = 'auto'; } } applyCollapsed(); applyHidden(); applyPos(); panel.querySelector('#fq-fold').onclick = () => { store.collapsed = !store.collapsed; applyCollapsed(); }; panel.querySelector('#fq-hide').onclick = () => { store.hidden = true; applyHidden(); }; launcher.onclick = () => { store.hidden = false; applyHidden(); }; /* 拖动 */ let dragging = false, ox = 0, oy = 0; head.addEventListener('mousedown', (e) => { if (e.target.classList.contains('fq-hbtn')) return; dragging = true; const r = panel.getBoundingClientRect(); ox = e.clientX - r.left; oy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const l = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - ox)); const t = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - oy)); store.pos = { l, t }; applyPos(); }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; saveStore(); } }); /* 点击 ID 复制 */ function bindCopy(el, getVal) { el.addEventListener('click', () => { const v = getVal(); if (!v || v === '—') return; navigator.clipboard.writeText(v).then( () => setStatus('已复制:' + v, '#67c23a', 1500), () => setStatus('复制失败,请手动选中', '#e6a23c', 2000) ); }); } bindCopy(elBook, () => elBook.textContent); bindCopy(elItem, () => elItem.textContent); /* ================= 面板信息刷新 ================= */ function setStatus(text, color, backMs) { elStatus.textContent = text; elStatus.style.color = color || '#303133'; if (backMs) { clearTimeout(setStatus._t); setStatus._t = setTimeout(() => { elStatus.textContent = '待命'; elStatus.style.color = '#909399'; }, backMs); } } function updatePanel() { const raw = getBookId(); const bookId = raw && raw.startsWith('ITEM:') ? (resolvedBookId || raw.slice(5)) : (raw || ''); elBook.textContent = bookId || '—'; elItem.textContent = getCurrentItemId() || '—'; elName.textContent = getCurrentTitle() || '—'; elChap.textContent = currentChapter ? '第 ' + currentChapter.index + ' 章' + (currentChapter.volume_name ? ' · ' + currentChapter.volume_name : '') : '—'; } /* ================= 统一请求封装 ================= */ function api(params) { const qs = new URLSearchParams(Object.assign({ key: API_KEY, type: 'json' }, params)).toString(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_URL}?${qs}`, timeout: 20000, onload(res) { let json; try { json = JSON.parse(res.responseText); } catch (e) { return reject(new Error('返回的不是 JSON:' + String(res.responseText).slice(0, 120))); } if (json.code !== 0 && json.code !== 1) { return reject(new Error(json.message || ('接口返回异常 code=' + json.code))); } resolve(json); }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); } /* ========== 修复点 1:取真正的 bookId ========== 番茄 /reader/<数字> 里的数字是【章节 itemId】,不是 bookId! bookId 要从页面注入的 __INITIAL_STATE__.reader.chapterData 里拿。 */ function getBookId() { const st = window.__INITIAL_STATE__; const fromState = st && st.reader && st.reader.chapterData && st.reader.chapterData.bookId; if (fromState) return String(fromState); const pageMatch = location.pathname.match(/\/page\/(\d+)/); if (pageMatch) return pageMatch[1]; const m = document.documentElement.innerHTML.match(/"bookId"\s*:\s*"(\d{6,})"/); if (m) return m[1]; // 最后兜底:把 URL 里的 itemId 交给接口 ids 反查 const itemMatch = location.pathname.match(/\/reader\/(\d+)/); return itemMatch ? 'ITEM:' + itemMatch[1] : null; } async function resolveBookId(raw) { if (!raw) return null; if (!raw.startsWith('ITEM:')) { resolvedBookId = raw; return raw; } const itemId = raw.slice(5); const r = await api({ method: 'ids', id: itemId }); // ids 支持传 chapterId const d = Array.isArray(r.data) ? r.data[0] : r.data; const id = d && d.id ? String(d.id) : null; resolvedBookId = id || ''; return id; } /* ========== 修复点 2:按文档规范取章节列表 ========== method=chapters 时 id 必须是 bookId。 实测返回是【按卷分组的二维数组】,且字段是 title / index (不是文档里写的 chapter_title / chapter),这里做展平 + 归一化。 */ async function getChapterList(bookId) { if (chapterListCache && cacheBookId === bookId) return chapterListCache; const json = await api({ method: 'chapters', id: bookId }); const flat = []; (Array.isArray(json.data) ? json.data : []).forEach((vol) => { if (Array.isArray(vol)) vol.forEach((c) => flat.push(c)); else flat.push(vol); }); const list = flat.map((c, i) => ({ chapter_id: String(c.chapter_id || ''), title: String(c.title != null ? c.title : c.chapter_title || ''), index: Number(c.index != null ? c.index : (c.chapter != null ? c.chapter : i + 1)), volume: c.volume, volume_name: c.volume_name || '', pay: !!c.pay, })); if (!list.length) throw new Error('章节列表为空'); chapterListCache = list; cacheBookId = bookId; return list; } /* ========== 取单章正文 ========== chapter 参数要传【index 序号】,传 chapter_id 会报 "请检测章节选择是否正确" */ async function getChapterContent(bookId, chapterIndex) { return api({ method: 'chapter', id: bookId, chapter: String(chapterIndex) }); } /* ========== 定位当前章节 ========== */ function getCurrentTitle() { const el = document.querySelector('.muye-reader-title'); return el ? el.innerText.trim().replace(/\s+/g, '') : ''; } function getCurrentItemId() { const st = window.__INITIAL_STATE__; const id = st && st.reader && st.reader.chapterData && st.reader.chapterData.itemId; if (id) return String(id); const m = location.pathname.match(/\/reader\/(\d+)/); return m ? m[1] : ''; } function matchChapter(list, title, itemId) { // 优先用 itemId ↔ chapter_id 匹配(走 sameId 规避大整数精度丢失) if (itemId) { const hit = list.find((c) => sameId(c.chapter_id, itemId)); if (hit) return hit; } // 回退:按章节标题匹配 if (title) { const t = norm(title); const exact = list.find((c) => norm(c.title) === t); if (exact) return exact; const fuzzy = list.find((c) => norm(c.title) && t.indexOf(norm(c.title)) >= 0); if (fuzzy) return fuzzy; } return null; } /* ========== 去遮挡 ========== */ function removeMask() { ['.muye-to-fanqie', '.muye-to-vip', '.muye-reader-footer', '.reader-limit-tip', '.chapter-limit-mask', '.reader-bottom-download'] .forEach((sel) => document.querySelectorAll(sel).forEach((el) => el.remove())); const box = document.querySelector('.muye-reader-content'); if (box) { box.style.maxHeight = 'none'; box.style.height = 'auto'; box.style.overflow = 'auto'; box.style.filter = 'none'; box.style.opacity = '1'; box.style.paddingBottom = '60px'; box.style.userSelect = 'text'; } const outer = document.querySelector('.muye-reader-box'); if (outer) { outer.style.maxHeight = 'none'; outer.style.overflow = 'visible'; } } /* ========== 主路径:ids 传章节 itemId,一次请求拿全 ========== 实测 method=ids&id=<章节itemId> 直接返回该章正文, data[0] 里同时带 bookId / chapter 序号 / chapter_title / content。 */ async function fetchChapterById() { const itemId = getCurrentItemId(); if (!itemId) throw new Error('没取到章节 ID,请确认在阅读页'); const json = await api({ method: 'ids', id: itemId }); const item = Array.isArray(json.data) ? json.data[0] : json.data; if (!item || !item.chapter_id) throw new Error('ids 未返回章节数据'); const text = (typeof item.content === 'string' && item.content.length > 30) ? item.content : String(json.message || ''); if (text.length < 30) throw new Error('接口没返回正文内容'); return { bookId: String(item.id || ''), index: Number(item.chapter), title: String(item.chapter_title || ''), volumeName: String(item.volume_name || ''), text: text, }; } /* ========== 备用路径:章节列表 + 序号 ========== */ async function fetchChapterByIndex() { const bookId = await resolveBookId(getBookId()); if (!bookId) throw new Error('没取到书籍 ID,请确认在阅读页'); const list = await getChapterList(bookId); const cur = matchChapter(list, getCurrentTitle(), getCurrentItemId()); if (!cur) throw new Error('章节列表里没匹配到当前章'); const json = await getChapterContent(bookId, cur.index); const first = Array.isArray(json.data) ? json.data[0] : json.data; const text = (first && typeof first.content === 'string' && first.content.length > 30) ? first.content : String(json.message || ''); if (text.length < 30) throw new Error('接口没返回正文内容'); return { bookId: bookId, index: cur.index, title: cur.title, volumeName: cur.volume_name, text: text, }; } function toParagraphs(text, titleHint) { const lines = text.split(/\n+/).map((s) => s.trim()).filter(Boolean); if (titleHint && lines.length && norm(lines[0]) === norm(titleHint)) lines.shift(); // 去掉首行标题 return lines; } function render(paragraphs) { const box = document.querySelector('.muye-reader-content'); if (!box) throw new Error('页面上没找到正文容器'); box.innerHTML = ''; paragraphs.forEach((t) => { const p = document.createElement('p'); p.innerText = t; p.style.cssText = 'text-indent:2em;margin:0 0 18px;line-height:1.8;font-size:18px;color:#333;'; box.appendChild(p); }); } /* ========== 主流程 ========== */ async function replaceContent() { if (isReplacing) return; isReplacing = true; runBtn.disabled = true; setStatus('加载中…', '#409eff'); try { const itemId = getCurrentItemId(); const key = (itemId || '') + '#' + (getCurrentTitle() || ''); if (key === lastDoneKey) { setStatus('本章已解析', '#909399', 1500); return; } let info = null; try { info = await fetchChapterById(); } catch (e1) { console.warn('[番茄解析] ids 直取失败,改用章节列表:', e1.message); info = await fetchChapterByIndex(); } resolvedBookId = info.bookId || ''; currentChapter = { index: info.index, title: info.title, volume_name: info.volumeName }; updatePanel(); const paragraphs = toParagraphs(info.text, info.title); if (!paragraphs.length) throw new Error('正文为空'); render(paragraphs); removeMask(); lastDoneKey = key; setStatus('解析完成 · ' + paragraphs.length + ' 段', '#67c23a', 2500); } catch (err) { console.error('[番茄解析]', err); setStatus('失败:' + err.message, '#f56c6c'); } finally { isReplacing = false; runBtn.disabled = false; } } /* ========== 监听 / 触发 ========== */ function watch() { let lastUrl = location.href; new MutationObserver(() => { removeMask(); if (location.href !== lastUrl) { lastUrl = location.href; lastDoneKey = ''; resolvedBookId = ''; currentChapter = null; chapterListCache = null; // 换书时清缓存 updatePanel(); setTimeout(() => { removeMask(); replaceContent(); }, 1200); } }).observe(document.body, { childList: true, subtree: true }); setInterval(() => { if (!location.pathname.includes('/reader/')) return; removeMask(); updatePanel(); const key = (resolvedBookId || getBookId()) + '#' + (getCurrentItemId() || getCurrentTitle()); if (key !== lastDoneKey && !isReplacing) replaceContent(); }, 2000); } runBtn.onclick = () => { lastDoneKey = ''; removeMask(); replaceContent(); }; window.addEventListener('load', () => { removeMask(); updatePanel(); setTimeout(() => { removeMask(); updatePanel(); replaceContent(); watch(); }, 2000); }); })();