// ==UserScript==
// @name 起点榜单增强
// @namespace https://github.com/local/qd-rank
// @version 2.8.0
// @description 起点移动端榜单页增强:男生/女生切换、榜单tab、分类/月份筛选、无限滚动加载、点书跳转搜索引擎。右上角图书按钮回起点主站。访问m.qidian.com/rank使用
// @author you
// @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 = `