// ==UserScript==
// @name 起点目录直显发布时间与字数
// @namespace qidian-catalog-meta
// @version 1.0.6
// @description 把章节链接 title(悬停提示/tooltip)里的首发时间和章节字数显示在章节下方第二行(时间首字与章节对齐,标题和锁图标位置不动)
// @match https://www.qidian.com/book/*
// @match http://www.qidian.com/book/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ===== 开关(配置/config):改成 false 即可隐藏对应项 =====
var SHOW_TIME = true; // 显示发布时间
var SHOW_COUNT = true; // 显示章节字数(字数/word count)
var TIME_RE = /(?:首发|更新)时间[::]\s*(\d{4}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)?)/;
var COUNT_RE = /章节字数[::]\s*(\d+)/;
var META_CLASS = 'qd-cat-meta';
var WRAP_CLASS = 'qd-cat-wrap';
var PARENT_CLASS = 'qd-cat-has-meta';
var FLOW_CLASS = 'qd-cat-flow'; // 常规流时间行:同一容器塞多章时的兜底(保证可读、不重叠)
// "2024-12-08 00:02:41" → "2024-12-08 00:02":去掉秒以节省宽度;没有秒则原样返回
function shortTime(s) {
return String(s).replace(/(\d{1,2}:\d{1,2}):\d{2}$/, '$1');
}
function hasMeta(title) {
return title.indexOf('章节字数') !== -1 ||
title.indexOf('首发时间') !== -1 ||
title.indexOf('更新时间') !== -1;
}
// v1.0.2 的教训:绝不改动 自身及其标题子节点(起点靠它们自己的
// overflow/ellipsis 做截断,一碰就叠字)。
// v1.0.3 的教训:锁图标(🔒/lock icon)是 后面的兄弟节点,meta 不能插在
// 和锁之间,否则锁被顶到第三行。meta 永远跟在"本章整行"最后面。
// v1.0.4 的教训:meta 以常规流待在同一行容器里,会参与原站弹性布局,
// 标题一长就把锁挤到下一行。meta 默认绝对定位(absolute positioning),
// 彻底退出第一行的布局竞争;父级只加 position:relative + 底部留白,
// 标题和锁的排列与原站一模一样。
// v1.0.6:时间首字 MUST 与章节标题左对齐(用链接的 offsetLeft + 内边距实测,
// 不再假设 left:0 就是对齐的);垂直间距 MUST 分组明确(时间贴自己章节,
// 与下一行拉开),直接调的只有 meta/容器外边距,不碰第一行。
// 注意:调用方 MUST 先调 preScanMark(parent) 快照决策,否则逐个包裹过程中
// parent 的子节点会被 wrap 替换,导致后面的 被误判走错分支。
function countRawLinks(p) {
if (!p || p.nodeType !== 1) return 0;
var links = 0;
var kids = p.childNodes || [];
for (var i = 0; i < kids.length; i++) {
var k = kids[i];
if (k.nodeType !== 1) continue;
if (k.classList && k.classList.contains(WRAP_CLASS)) continue; // 已包好的整体不再计数
if (k.tagName !== 'A') continue;
var done = k.dataset && k.dataset.qdMetaDone;
var ti = (k.getAttribute && k.getAttribute('title')) || '';
if (!done && hasMeta(ti) && ++links > 3) return links;
}
return links;
}
// 扫描 parent 下所有待处理章节,一次性决定用"兄弟插入"还是"逐个包裹",
// 结论记在 parent 上,后续 enhance 直接沿用,不再逐个重判。
function preScanMark(parent) {
if (!parent || parent.nodeType !== 1) return false;
if (parent.dataset && parent.dataset.qdWrapMode) {
return parent.dataset.qdWrapMode === 'wrap';
}
var useWrap = countRawLinks(parent) > 3;
if (parent.dataset) parent.dataset.qdWrapMode = useWrap ? 'wrap' : 'sibling';
return useWrap;
}
function isItemContainer(p) {
if (!p || p.nodeType !== 1) return false;
if (!/^(LI|H1|H2|H3|H4|P|DD|DT|SECTION|ARTICLE|DIV)$/.test(p.tagName)) return false;
var links = 0;
var kids = p.childNodes || [];
for (var i = 0; i < kids.length; i++) {
var k = kids[i];
if (k.nodeType !== 1) continue;
if (k.classList && k.classList.contains(WRAP_CLASS)) continue;
if (k.tagName === 'A' && ++links > 3) return false;
}
return true;
}
// 章节链接是否带有"时间/字数"元数据(用于识别下一章,停住分组)
function isChapterLink(el) {
if (!el || el.nodeType !== 1 || el.tagName !== 'A') return false;
var ti = (el.getAttribute && el.getAttribute('title')) || '';
return hasMeta(ti);
}
function isMeta(el) {
return !!(el && el.nodeType === 1 && el.classList &&
(el.classList.contains(META_CLASS) || el.classList.contains(WRAP_CLASS)));
}
// 容器直系章节数(结构计数,与是否处理过无关):==1 才能用绝对定位;
// 多章挤在同一容器里时改用常规流,保证各行可读、不互相重叠。
function chapterCount(p) {
if (!p || p.nodeType !== 1) return 0;
var n = 0;
var kids = p.childNodes || [];
for (var i = 0; i < kids.length; i++) {
var k = kids[i];
if (!k || k.nodeType !== 1) continue;
if (k.classList && k.classList.contains(WRAP_CLASS)) { n++; continue; }
if (k.tagName === 'A' && isChapterLink(k)) n++;
}
return n;
}
// 时间行左对齐到章节标题的文字起点:父级已是相对定位(position:relative),
// 链接的 offsetLeft 就是它相对父级衬垫盒(padding box)的横向偏移,再补上
// 链接自身的左边框+内边距,meta 文字就和标题文字从同一 x 起笔。
// 失败时保持 CSS 默认(left:0),绝不影响主功能。
function alignMetaToLink(meta, a) {
if (!meta || !a) return;
try {
var parent = meta.parentNode;
if (!parent || parent.nodeType !== 1) return;
var shift = 0;
var ok = false;
if (a.offsetParent === parent && typeof a.offsetLeft === 'number' && isFinite(a.offsetLeft)) {
shift = a.offsetLeft;
ok = true;
} else if (parent.getBoundingClientRect && a.getBoundingClientRect) {
var pr = parent.getBoundingClientRect();
var ar = a.getBoundingClientRect();
var bw = 0;
try {
var cs0 = window.getComputedStyle(parent);
bw = parseFloat(cs0.borderLeftWidth) || 0;
} catch (e0) { /* 取不到边框就按 0 算 */ }
if (isFinite(ar.left) && isFinite(pr.left)) {
shift = ar.left - (pr.left + bw);
ok = true;
}
}
if (!ok) return;
try {
var cs = window.getComputedStyle(a);
shift += (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
var tind = cs.textIndent;
if (tind && typeof tind === 'string' && tind.slice(-2) === 'px') shift += parseFloat(tind) || 0;
} catch (e1) { /* 取不到链接内边距就用 offsetLeft 本身 */ }
if (isFinite(shift) && shift >= 0 && shift < 200) {
meta.style.left = shift + 'px';
}
} catch (e) { /* 对齐失败就保持默认,不断整个功能 */ }
}
// 找到某条时间行所属的章节链接(同级往前找 / wrap 内找第一个)
function findChapterForMeta(m) {
if (!m || !m.parentNode) return null;
var p = m.parentNode;
var i, ks;
if (p.classList && p.classList.contains(WRAP_CLASS)) {
ks = p.childNodes || [];
for (i = 0; i < ks.length; i++) {
if (isChapterLink(ks[i])) return ks[i];
}
return null;
}
var cur = m.previousSibling;
while (cur) {
if (cur.nodeType === 1) {
if (cur.tagName === 'A' && isChapterLink(cur)) return cur;
if (cur.classList && cur.classList.contains(WRAP_CLASS)) {
ks = cur.childNodes || [];
for (i = 0; i < ks.length; i++) {
if (isChapterLink(ks[i])) return ks[i];
}
}
}
cur = cur.previousSibling;
}
return null;
}
// 全量对齐(初次 / 延迟补扫 / 字体加载完 / 窗口变化后调用,已对齐的跳过)
function alignAll(root) {
var metas;
try {
metas = (root || document).querySelectorAll('span.' + META_CLASS);
} catch (e) { return; }
for (var i = 0; i < metas.length; i++) {
var m = metas[i];
if (m.dataset && m.dataset.qdAligned) continue;
var a = findChapterForMeta(m);
if (a) {
alignMetaToLink(m, a);
if (m.dataset) m.dataset.qdAligned = '1';
}
}
}
function enhance(a) {
if (!a || a.nodeType !== 1 || a.tagName !== 'A') return false;
if (a.dataset.qdMetaDone) return false; // 已处理过,避免重复插入
// 注意:不再校验 href 格式,只要 title 里带有时间/字数元数据就处理,
// 兼容章节链接各种写法(绝对地址、相对地址、动态渲染改写后的地址)
var title = a.getAttribute('title') || ''; // 悬停提示文本,发布时间和字数就藏在这里
if (!hasMeta(title)) return false;
var t = title.match(TIME_RE);
var c = title.match(COUNT_RE);
var parts = [];
if (SHOW_TIME && t) parts.push(shortTime(t[1]));
if (SHOW_COUNT && c) parts.push(c[1] + '字');
if (!parts.length) return false;
var parent = a.parentNode;
if (!parent || parent.nodeType !== 1) return false;
// 已被 wrap 包过:wrap 里有 meta 即处理过,否则补一个进去
if (parent.classList && parent.classList.contains(WRAP_CLASS)) {
if (a.querySelector('span.' + META_CLASS) || parent.querySelector('span.' + META_CLASS)) {
a.dataset.qdMetaDone = '1';
return false;
}
var metaInWrap = document.createElement('span');
metaInWrap.className = META_CLASS;
metaInWrap.textContent = parts.join(' · ');
parent.appendChild(metaInWrap);
alignMetaToLink(metaInWrap, a);
if (metaInWrap.dataset) metaInWrap.dataset.qdAligned = '1';
a.dataset.qdMetaDone = '1';
return true;
}
// v1.0.2 旧版残留(meta/标题行被塞进 里):拆出来还原,保证不叠字
var oldTitle = null;
var innerMeta = null;
try {
oldTitle = a.querySelector('span.qd-cat-title');
innerMeta = a.querySelector('span.' + META_CLASS);
} catch (e) { /* 老旧选择器异常就跳过修复 */ }
if (oldTitle && oldTitle.parentNode === a) {
while (oldTitle.firstChild) a.insertBefore(oldTitle.firstChild, oldTitle);
a.removeChild(oldTitle);
}
if (innerMeta && innerMeta.parentNode === a) a.removeChild(innerMeta);
var meta = document.createElement('span');
meta.className = META_CLASS;
meta.textContent = parts.join(' · ');
// 快照决策优先(scan 里已对每个父级做过 preScanMark),单点 enhance 兜底时才现算
var useWrap = (parent.dataset && parent.dataset.qdWrapMode)
? parent.dataset.qdWrapMode === 'wrap'
: !isItemContainer(parent);
if (!useWrap) {
// 从 a 往后扫到下一章为止:中间的锁图标等装饰节点都属于本章,
// meta 跟在它们后面(标题+锁相对位置不动);扫到 meta 说明已处理
var ref = a;
var cur = a.nextSibling;
while (cur) {
if (cur.nodeType === 1) {
if (isMeta(cur)) {
a.dataset.qdMetaDone = '1';
return false;
}
if (cur.tagName === 'A' && isChapterLink(cur)) break; // 下一章,停
ref = cur; // 锁图标等本章装饰节点,meta 跟在它后面
} else if (cur.nodeValue && /\S/.test(cur.nodeValue)) {
ref = cur;
}
cur = cur.nextSibling;
}
parent.insertBefore(meta, ref.nextSibling);
// 父级只做三件事:相对定位(给绝对定位的时间行当锚点)+ 底部留白
// (给第二行腾地方)+ 底部外边距(把下一行推远,视觉成组);
// 不碰 、标题、锁图标的任何样式,第一行与原站一模一样
if (parent.classList) parent.classList.add(PARENT_CLASS);
// 单章容器用绝对定位;多章同容器退化为常规流,保证可读不重叠
if (chapterCount(parent) !== 1 && meta.classList) meta.classList.add(FLOW_CLASS);
alignMetaToLink(meta, a);
if (meta.dataset) meta.dataset.qdAligned = '1';
} else {
// 把"链接+后面跟着的锁图标等"整个搬进 wrap,meta 排最后(wrap 自带相对定位+留白)
var wrap = document.createElement('span');
wrap.className = WRAP_CLASS;
parent.insertBefore(wrap, a);
wrap.appendChild(a);
var nxt = wrap.nextSibling;
while (nxt) {
var goNext = nxt.nextSibling;
if (nxt.nodeType === 1) {
if (isMeta(nxt)) break;
if (nxt.tagName === 'A' && isChapterLink(nxt)) break; // 下一章,停
wrap.appendChild(nxt); // 锁图标等本章装饰节点,跟标题一起进 wrap
}
nxt = goNext;
}
wrap.appendChild(meta);
alignMetaToLink(meta, a);
if (meta.dataset) meta.dataset.qdAligned = '1';
}
a.dataset.qdMetaDone = '1';
return true;
}
function scan(root) {
if (!root) return 0;
if (root.nodeType === 9) root = root.documentElement || root.body;
if (!root || root.nodeType !== 1) return 0;
if (root.tagName === 'A') return enhance(root) ? 1 : 0;
var nodes = root.querySelectorAll('a[title]');
// 先对涉及的父级快照"兄弟插入 vs 逐个包裹"决策,避免包裹过程中误判
for (var k = 0; k < nodes.length; k++) {
if (nodes[k].parentNode && nodes[k].parentNode.nodeType === 1) preScanMark(nodes[k].parentNode);
}
var n = 0;
for (var i = 0; i < nodes.length; i++) {
if (enhance(nodes[i])) n++;
}
return n;
}
function count() {
return document.querySelectorAll('span.' + META_CLASS).length;
}
function injectStyle() {
// 核心:meta 默认绝对定位,不参与第一行布局(锁再也不会被挤走),
// 左偏移由 JS 实测写入(首字与章节对齐),这里只定上下;
// 父级/wrap 负责"相对定位 + 底部留白 + 底部外边距":留白给时间行住,
// 外边距把下一行推远,时间就跟自己章节成组了;
// 标题和锁的样式一个字都不动。hover 高亮挂在容器上。
var css =
'span.' + META_CLASS + '{display:block;position:absolute;left:0;right:0;bottom:0;box-sizing:border-box;max-width:100%;margin:0;padding:0;font-size:12px;font-weight:400;line-height:1.5;color:#a0a0a0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none!important;}' +
'span.' + META_CLASS + '.' + FLOW_CLASS + '{position:static;margin:2px 0 10px;}' +
'span.' + WRAP_CLASS + '{display:block;position:relative;width:100%;max-width:100%;box-sizing:border-box;padding:0 0 20px;margin:0 0 10px;}' +
'.' + PARENT_CLASS + '{position:relative!important;height:auto!important;max-height:none!important;overflow:visible!important;padding-bottom:20px!important;margin-bottom:10px!important;}' +
'.' + PARENT_CLASS + ':hover span.' + META_CLASS + ',span.' + WRAP_CLASS + ':hover span.' + META_CLASS + '{color:#666;}';
var st = document.getElementById('qd-cat-meta-style');
if (st) {
if (st.textContent !== css) st.textContent = css; // 跨版本升级时刷新旧样式
return;
}
st = document.createElement('style');
st.id = 'qd-cat-meta-style';
st.textContent = css;
(document.head || document.documentElement).appendChild(st);
}
function observe() {
// 目录是动态渲染的(书籍页点"目录"Tab、独立目录页翻页/倒序都会重排 DOM),用观察器(MutationObserver)自动补上新节点
var ob = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) {
if (added[j].nodeType === 1) scan(added[j]);
}
alignAll(document);
}
});
ob.observe(document.documentElement || document.body, { childList: true, subtree: true });
}
var alignTimer = null;
function scheduleRealign() {
clearTimeout(alignTimer);
alignTimer = setTimeout(function () { alignAll(document); }, 200);
}
function init() {
injectStyle();
var n = scan(document); // 首次全量扫描:处理服务端直出的目录
alignAll(document);
observe();
// 窗口变化/字体加载会改变横向偏移,清空对齐标记后重对齐(纵向间距是 CSS 管的,不用动)
if (window.addEventListener) {
window.addEventListener('resize', function () {
try {
var all = document.querySelectorAll('span.' + META_CLASS);
for (var i = 0; i < all.length; i++) {
if (all[i].dataset) all[i].dataset.qdAligned = '';
}
} catch (e) { /* 清标记失败就直接重对齐,alignAll 自己会跳过已对齐的 */ }
scheduleRealign();
});
}
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
document.fonts.ready.then(function () { scheduleRealign(); });
}
// 目录可能是点"目录"Tab 后才异步加载,补几次延迟扫描;顺手在控制台打已处理数量,方便确认脚本生效
var round = 0;
var timer = setInterval(function () {
scan(document);
alignAll(document);
if (++round >= 5) {
clearInterval(timer);
if (window.console) console.log('[qd-cat-meta] 共显示 ' + count() + ' 章的时间/字数');
}
}, 1500);
window.addEventListener('load', function () { scan(document); alignAll(document); });
if (window.console) console.log('[qd-cat-meta] 首次扫描处理 ' + n + ' 章');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();