// ==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);
});
})();