// ==UserScript== // @name 起点榜单增强 // @namespace https://github.com/local/qd-rank // @version 2.8.1 // @description 起点移动端榜单页增强:男生/女生切换、榜单tab、分类/月份筛选、无限滚动加载、点书跳转搜索引擎。右上角图书按钮回起点主站。 // @author 失辛向南 // @license MIT // @match *://m.qidian.com/rank/* // @match *://m.qdmm.com/rank/* // @run-at document-idle // @noframes // @grant GM_xmlhttpRequest // ==/UserScript== (function () { 'use strict'; // 禁用双击缩放 var _vp = document.querySelector('meta[name="viewport"]'); if (_vp) _vp.setAttribute('content', 'width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no'); function initQdRank() { 'use strict'; // ---------- 配置 ---------- const ENGINES = [ { key: 'quark', name: '夸克', prefix: 'https://m.quark.cn/s?q=' }, { key: 'bing', name: '必应', prefix: 'https://www.bing.com/search?q=' }, { key: 'sogou', name: '搜狗', prefix: 'https://www.sogou.com/web?query=' }, { key: 'toutiao', name: '头条', prefix: 'https://so.toutiao.com/search/?keyword=' }, ]; const RANK_TABS = { male: [ ['yuepiao', '月票榜'], ['hotsales', '畅销榜'], ['readindex', '阅读榜'], ['newfans', '书友榜'], ['rec', '推荐榜'], ['update', '更新榜'], ['sign', '签约榜'], ['newbook', '新书榜'], ['newauthor', '新人榜'], ], female: [ ['yuepiao', '月票榜'], ['hotsales', '畅销榜'], ['readindex', '阅读榜'], ['newfans', '书友榜'], ['rec', '推荐榜'], ['update', '更新榜'], ['collect', '收藏榜'], ['free', '免费榜'], ], }; // 从当前 URL 解析出 (tab, catId, month) function parseLocation() { const m = location.pathname.match(/\/rank\/([a-z]+)(?:\/catid(-?\d+))?(?:\/(\d{6}))?/); const site = location.hostname.includes('qdmm') ? 'female' : 'male'; const tab = m ? m[1] : 'yuepiao'; const catId = m && m[2] ? m[2] : '-1'; const month = m && m[3] ? m[3] : new Date().toISOString().slice(0,7).replace('-',''); return { site, tab, catId, month }; } const state = { ...parseLocation(), engine: localStorage.getItem('qd-rank-engine') || 'quark' }; // ---------- 样式 ---------- const style = document.createElement('style'); style.textContent = ` /* 屏蔽广告、打开App、原站所有右上角按钮 */ .y-popup__content--bottom, .y-mask, #app-dl, ._openAppText_sm6ke_52, ._openApp_sm6ke_9, div[class*="_openApp_"], div[class*="_openAppText_"], div[class*="_doubleRight_"], div[class*="_rightCombination_"], div[class*="_icon_"], i[class*="y-icon--00145"], div[class*="y-bottom-bar"], div[class*="downloadBar"] { display: none !important; } /* 隐藏原站所有内容,我们自己渲染完整页面 */ body > *:not(#qd-root):not(#qd-engine-menu) { display: none !important; } #qd-root { max-width: 480px; margin: 0 auto; padding-bottom: 40px; background: #f5f5f7; min-height: 100vh; } /* 顶部 header */ #qd-root .qd-header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; padding: 12px 16px; background: #fff; position: sticky; top: 0; z-index: 10; touch-action: manipulation; } #qd-root .qd-site { display: flex; background: #f0f0f3; border-radius: 18px; padding: 3px; justify-self: center; } #qd-root .qd-menu:first-child { justify-self: start; } #qd-root .qd-menu:last-child { justify-self: end; } #qd-root .qd-site button { border: 0; background: transparent; padding: 5px 14px; border-radius: 15px; font-size: 13px; color: #666; cursor: pointer; } #qd-root .qd-site button.active { background: #fff; color: #e94f4f; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,.08); } #qd-root .qd-menu { width: 32px; height: 32px; border: 0; background: #f5f5f7; border-radius: 16px; font-size: 18px; color: #333; cursor: pointer; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; } #qd-root .qd-menu.right { margin-left: auto; } #qd-root .qd-tabs { display: flex; gap: 8px; overflow-x: auto; padding: 12px 16px; background: #fff; scrollbar-width: none; } #qd-root .qd-tabs::-webkit-scrollbar { display: none; } #qd-root .qd-tabs button { flex: 0 0 auto; border: 0; background: #f5f5f7; color: #555; padding: 7px 14px; border-radius: 16px; font-size: 14px; cursor: pointer; } #qd-root .qd-tabs button.active { background: #ffe4e6; color: #e94f4f; font-weight: 600; } #qd-root .qd-filters { display: flex; gap: 8px; overflow-x: auto; padding: 8px 16px; scrollbar-width: none; } #qd-root .qd-filters::-webkit-scrollbar { display: none; } #qd-root .qd-filters button { flex: 0 0 auto; border: 0; background: #fff; color: #666; padding: 6px 12px; border-radius: 14px; font-size: 13px; cursor: pointer; } #qd-root .qd-filters button.active { background: #ffe4e6; color: #e94f4f; } #qd-root .qd-list { padding: 8px 12px; } #qd-root .qd-book { display: flex; gap: 12px; background: #fff; border-radius: 10px; padding: 12px; margin-bottom: 10px; cursor: pointer; } #qd-root .qd-cover { width: 60px; height: 82px; border-radius: 4px; object-fit: cover; background: #eee; flex: 0 0 auto; } #qd-root .qd-info { flex: 1; min-width: 0; } #qd-root .qd-row { display: flex; align-items: center; gap: 8px; } #qd-root .qd-rank { font-size: 16px; font-weight: 700; color: #bbb; min-width: 22px; } #qd-root .qd-rank.r1{color:#ff4d4f}.qd-root .qd-rank.r2{color:#ff7a45}.qd-root .qd-rank.r3{color:#ffa940} #qd-root .qd-name { font-size: 16px; font-weight: 600; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #qd-root .qd-hot { margin-left: auto; font-size: 12px; color: #e94f4f; flex: 0 0 auto; } #qd-root .qd-meta { margin-top: 4px; font-size: 13px; color: #999; } #qd-root .qd-intro { margin-top: 4px; font-size: 13px; color: #666; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.4; } #qd-root .qd-loading, #qd-root .qd-error { text-align: center; color: #999; padding: 60px 20px; font-size: 14px; } #qd-root .qd-error { color: #e94f4f; } #qd-menu-btn { position: fixed; top: 10px; right: 12px; width: 36px; height: 36px; border: 0; background: rgba(255,255,255,.9); border-radius: 18px; font-size: 20px; color: #333; cursor: pointer; z-index: 9998; box-shadow: 0 1px 4px rgba(0,0,0,.1); } #qd-engine-menu { position: fixed; top: 54px; left: 12px; z-index: 9999; background: #fff; border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,.18); min-width: 180px; padding: 6px 0; display: none; } #qd-engine-menu.show { display: block; } #qd-engine-menu h3 { font-size: 13px; color: #999; font-weight: 500; margin: 0; padding: 8px 16px; } #qd-engine-menu button { display: flex; align-items: center; width: 100%; border: 0; background: transparent; padding: 12px 16px; font-size: 15px; cursor: pointer; text-align: left; color: #333; } #qd-engine-menu button .check { margin-left: auto; color: #e94f4f; font-weight: 700; } #qd-engine-menu button.active { background: #fff5f5; color: #e94f4f; } `; document.head.appendChild(style); // ---------- 请求 ---------- async function req(url) { const same = (location.hostname.includes('qidian') && /qidian\.com/.test(url)) || (/qdmm\.com/.test(location.hostname) && /qdmm\.com/.test(url)); const tryFetch = (u, useGm) => new Promise((resolve, reject) => { if (!useGm) { fetch(u, { credentials: 'include' }) .then(r => { if (!r.ok) throw new Error('HTTP '+r.status); return r.text(); }) .then(resolve).catch(reject); } else { GM_xmlhttpRequest({ method: 'GET', url: u, timeout: 15000, headers: { 'User-Agent': navigator.userAgent, 'Referer': location.href }, onload: r => { if (r.status>=300 && r.status!==304) reject(new Error('HTTP '+r.status)); else resolve(r.responseText); }, onerror: () => reject(new Error('network')), ontimeout: () => reject(new Error('timeout')), }); } }); try { return await tryFetch(url, !same); } catch (e) { // 女生站 m.qdmm.com WAF 常拦同域 fetch,fallback 到 www.qdmm.com if (/qdmm\.com/.test(url)) { const alt = url.replace('m.qdmm.com', 'www.qdmm.com'); console.log('[qd-rank] fallback to', alt); return await tryFetch(alt, true); } throw e; } } // ---------- 解析 ---------- function parseAll(htmlText) { const doc = new DOMParser().parseFromString(htmlText, 'text/html'); // 1) 分类 pill:_optionsGray_ 且 href 含 catid const cats = []; doc.querySelectorAll('a[class*="_optionsGray_"]').forEach(a => { const m = (a.getAttribute('href')||'').match(/catid(-?\d+)/); if (!m) return; cats.push({ id: m[1], label: a.textContent.trim(), selected: a.className.includes('_optionSelect_') }); }); // 2) 月份 pill:_optionWhite_ const months = []; doc.querySelectorAll('a[class*="_optionWhite_"]').forEach(a => { const m = (a.getAttribute('href')||'').match(/(\d{6})/); if (!m) return; months.push({ id: m[1], label: a.textContent.trim(), selected: a.className.includes('_optionSelect_') }); }); // 3) 书单:移动端 a._bookItem_ const books = []; const seen = new Set(); doc.querySelectorAll('a[href*="/book/"]').forEach(a => { const m = (a.getAttribute('href')||'').match(/\/book\/(\d+)/); if (!m || seen.has(m[1])) return; seen.add(m[1]); const img = a.querySelector('img'); let cover = img ? (img.getAttribute('data-src') || img.getAttribute('src') || '') : ''; if (cover.startsWith('//')) cover = 'https:'+cover; const h2 = a.querySelector('h2'); const title = h2 ? h2.textContent.trim() : (a.getAttribute('title')||'').replace(/(最新章节)?在线阅读.*$/,''); const rankEl = a.querySelector('[class*="_ranking_"]'); const rank = rankEl ? parseInt(rankEl.textContent.trim(),10)||books.length+1 : books.length+1; const hotEl = a.querySelector('[class*="_bookTitleR_"]'); const hot = hotEl ? hotEl.textContent.trim() : ''; const subEl = a.querySelector('[class*="_subTitle_"]'); let author='', category='', words=''; if (subEl) { const parts = subEl.textContent.split(/[·•]/).map(s=>s.trim()).filter(Boolean); if (parts[0]) author=parts[0]; if (parts[1]) category=parts[1]; if (parts[2]) words=parts[2]; } const descEl = a.querySelector('[class*="_bookDesc_"]'); const intro = descEl ? descEl.textContent.trim() : ''; books.push({ rank, bookId: m[1], title, author, category, wordCount: words, hotScore: hot, cover, intro }); }); return { cats, months, books: books.sort((a,b)=>a.rank-b.rank) }; } // ---------- 渲染 ---------- function ensureRoot() { let root = document.getElementById('qd-root'); if (!root) { root = document.createElement('div'); root.id = 'qd-root'; document.body.appendChild(root); } return root; } function siteUrl() { return state.site === 'female' ? 'https://m.qdmm.com' : 'https://m.qidian.com'; } function currentUrl() { return `${siteUrl()}/rank/${state.tab}/catid${state.catId}/${state.month}/`; } // ---------- 无限滚动状态 ---------- let page = 1; let isLast = false; let loadingMore = false; let currentBooks = []; // 从 cookie 读 csrf token function getCookie(name) { const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function getCsrf() { const m = document.cookie.match(/_csrfToken=([^;]+)/); return m ? m[1] : ''; } async function loadMore() { if (loadingMore || isLast) return; loadingMore = true; updateSentinel(); page++; try { // 同域 API:/webcommon/rank/{tab}list?gender=male&pageNum=N&_csrfToken=xxx const tabApiName = state.tab === 'readindex' ? 'readIndex' : state.tab; const gender = state.site === 'female' ? 'female' : 'male'; const base = siteUrl(); const csrf = getCsrf(); const apiUrl = `${base}/webcommon/rank/${tabApiName}list?gender=${gender}&pageNum=${page}&catId=${state.catId}&_csrfToken=${csrf}`; console.log('[qd-rank] API p'+page, apiUrl); const r = await fetch(apiUrl, { credentials: 'include' }); const txt = await r.text(); console.log('[qd-rank] resp', r.status, txt.slice(0,200)); let json; try { json = JSON.parse(txt); } catch(e) { throw new Error('HTTP '+r.status+': '+txt.slice(0,80)); } if (json.code !== 0 || !json.data || !json.data.records) { isLast = true; const s = document.getElementById('qd-sentinel'); if (s) s.textContent = '— 没有更多了 —'; return; } isLast = !!json.data.isLast; const newBooks = json.data.records.map(r => ({ rank: r.rankNum, bookId: String(r.bid), title: r.bName, author: r.bAuth, category: r.cat, wordCount: r.cnt, hotScore: r.rankCnt || '', cover: `https://bookcover.yuewen.com/qdbimg/349573/${r.bid}/180`, intro: r.desc || '', })); appendBooks(newBooks); } catch (e) { page--; console.error('[qd-rank] loadMore failed:', e); const s = document.getElementById('qd-sentinel'); if (s) s.textContent = '加载失败:' + (e.message||e) + '(点击重试)'; if (s) s.style.cursor = 'pointer'; if (s) s.onclick = () => { s.onclick = null; loadMore(); }; } finally { loadingMore = false; updateSentinel(); } } function appendBooks(books) { const list = document.querySelector('#qd-root .qd-list'); if (!list) return; const sentinel = document.getElementById('qd-sentinel'); books.forEach(b => { const div = document.createElement('div'); div.className = 'qd-book'; div.dataset.title = b.title; div.innerHTML = `
${b.rank} ${b.title} ${b.hotScore?`${b.hotScore}`:''}
${[b.author,b.category,b.wordCount].filter(Boolean).join(' · ')}
${b.intro?`
${b.intro}
`:''}
`; div.onclick = () => searchBook(b.title); // 插到 sentinel 前面,保持 sentinel 在末尾 if (sentinel) list.insertBefore(div, sentinel); else list.appendChild(div); }); } // 滚动加载:用 IntersectionObserver 监听末尾哨兵 let observer = null; function setupInfiniteScroll() { if (observer) observer.disconnect(); const list = document.querySelector('#qd-root .qd-list'); if (!list) return; // 末尾加哨兵 let sentinel = document.getElementById('qd-sentinel'); if (!sentinel) { sentinel = document.createElement('div'); sentinel.id = 'qd-sentinel'; sentinel.style.cssText = 'padding:20px;text-align:center;color:#999;font-size:13px;'; list.appendChild(sentinel); } updateSentinel(); observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) loadMore(); }, { rootMargin: '300px' }); observer.observe(sentinel); } function updateSentinel() { const s = document.getElementById('qd-sentinel'); if (!s) return; if (isLast) s.textContent = '— 没有更多了 —'; else if (loadingMore) s.textContent = '加载中…'; else s.textContent = ''; } async function load() { const root = ensureRoot(); root.innerHTML = `
加载中…
`; page = 1; loadingMore = false; try { const html = await req(currentUrl()); const { cats, months, books } = parseAll(html); isLast = false; // 让 loadMore 自己试 currentBooks = books; render({ cats, months, books }); window.scrollTo(0, 0); } catch (e) { root.innerHTML = `
加载失败:${e.message||e}
`; } } function render({ cats, months, books }) { const root = ensureRoot(); const tabs = RANK_TABS[state.site]; let html = `
`; html += `
`; tabs.forEach(([k,label]) => { html += ``; }); html += `
`; if (cats.length) { html += `
`; cats.forEach(c => { html += ``; }); html += `
`; } if (months.length) { html += `
`; months.forEach(mo => { html += ``; }); html += `
`; } html += `
`; if (!books.length) { html += `
该榜单暂无可显示内容。
`; } else { books.forEach(b => { html += `
${b.rank} ${b.title} ${b.hotScore?`${b.hotScore}`:''}
${[b.author,b.category,b.wordCount].filter(Boolean).join(' · ')}
${b.intro?`
${b.intro}
`:''}
`; }); } html += `
`; root.innerHTML = html; // 绑定事件 root.querySelectorAll('[data-site]').forEach(b => b.onclick = () => { // 跨站切换直接跳转,绕过 WAF const target = b.dataset.site === 'female' ? 'https://m.qdmm.com/rank/' : 'https://m.qidian.com/rank/'; location.href = target; }); root.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; state.catId = '-1'; load(); }); root.querySelectorAll('[data-cat]').forEach(b => b.onclick = () => { state.catId = b.dataset.cat; load(); }); root.querySelectorAll('[data-month]').forEach(b => b.onclick = () => { state.month = b.dataset.month; load(); }); root.querySelectorAll('.qd-book').forEach(el => el.onclick = () => searchBook(el.dataset.title)); const menuBtn = root.querySelector('#qd-menu-inline'); if (menuBtn) menuBtn.onclick = (ev) => { ev.stopPropagation(); toggleMenu(); }; const bcBtn = root.querySelector('#qd-bookcity-btn'); if (bcBtn) bcBtn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); location.href = siteUrl() + '/'; }; // 自动滚动到选中的 tab const activeTab = root.querySelector('[data-tab].active'); if (activeTab) activeTab.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); // 初始化无限滚动 setupInfiniteScroll(); } // ---------- 点书搜索 ---------- async function searchBook(title) { if (!title) return; const key = localStorage.getItem('qd-rank-engine') || state.engine || 'quark'; const eng = ENGINES.find(e => e.key === key) || ENGINES[0]; window.open(eng.prefix + encodeURIComponent(title), '_blank'); } // ---------- 引擎菜单 ---------- function toggleMenu() { const menu = document.getElementById('qd-engine-menu'); menu.classList.toggle('show'); } function injectMenu() { const menu = document.createElement('div'); menu.id = 'qd-engine-menu'; document.body.appendChild(menu); function render() { menu.innerHTML = '

选择搜索引擎

'; ENGINES.forEach(en => { const b = document.createElement('button'); if (en.key === state.engine) b.classList.add('active'); b.innerHTML = `${en.name}${en.key===state.engine?'✓':''}`; b.onclick = (ev) => { ev.stopPropagation(); state.engine = en.key; localStorage.setItem('qd-rank-engine', en.key); render(); setTimeout(()=>menu.classList.remove('show'),150); }; menu.appendChild(b); }); } render(); document.addEventListener('click', (ev) => { if (!menu.contains(ev.target) && ev.target.id !== 'qd-menu-inline') menu.classList.remove('show'); }); } // ---------- 拦截原站点击 ---------- document.addEventListener('click', (e) => { // 1) 男生/女生 nav 链接 const navA = e.target.closest('nav[class*="_nav_"] a, [class*="_nav_"] a'); if (navA) { const href = navA.getAttribute('href') || ''; if (href.includes('qidian.com/rank') || href.includes('qdmm.com/rank')) { e.preventDefault(); e.stopPropagation(); state.site = href.includes('qdmm') ? 'female' : 'male'; state.tab = 'yuepiao'; state.catId = '-1'; load(); return; } } // 2) book 链接(我们自己渲染的 #qd-root 里的已经绑了 onclick,这里管原站 DOM 里的) const a = e.target.closest('a[href*="/book/"]'); if (a && !a.closest('#qd-root')) { e.preventDefault(); e.stopPropagation(); const h2 = a.querySelector('h2'); const title = h2 ? h2.textContent.trim() : (a.getAttribute('title')||'').replace(/最新章节在线阅读.*$/,''); searchBook(title); } }, true); // ---------- 启动 ---------- injectMenu(); load(); } try { initQdRank(); } catch(e) { console.error('qd-rank init failed', e); } })();