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