// ==UserScript== // @name 知乎阅读增强助手 // @namespace js-hub/zhihu-enhance // @version 1.5.1 // @description 净化(登录弹窗/侧边栏/顶栏)、阅读(时间置顶/原图/限高/聚焦框/角标高亮/GIF/阅读宽度自定)、链接直链化、夜间模式 —— 11 个开关 + 1 项数值调节,4 组分类,菜单打开设置面板,零依赖零网络请求 // @author EFate // @license MIT // @match *://*.zhihu.com/* // @run-at document-start // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== (function () { 'use strict'; // ====================================================================== // L1 配置层 · 选项定义表(一切开关的唯一来源)与存储键约定 zh.* // ====================================================================== var VERSION = '1.5.1'; var PREFIX = 'zh'; // 存储键前缀:zh.<开关名> var MARK = 'data-zhx'; // DOM 幂等标记前缀:data-zhx-<任务> // 文章页阅读宽度的「默认值」(px)。它同时是数值项 readWidth 的默认值 —— 用户可在设置 // 面板里 900~2400px 之间自定,故这里只作缺省与回退,不再是唯一定值。 // 真实取值一律走 widthCap(opt):L3 样式层与 L5 执行层都从它取,避免「CSS 写一个值、 // 运行时写另一个值」的漂移(本项目曾在版本号上踩过同类两处不一致的坑)。 var READ_W = 1500; var GROUPS = ['净化', '阅读', '链接', '外观']; var OPT_DEFS = { hideLogin: { group: '净化', label: '隐藏登录弹窗', tip: '未登录浏览时不再弹出登录框,页面保持可滚动', def: true }, hideSidebar: { group: '净化', label: '隐藏侧边栏并居中内容', tip: '隐藏各页面右侧边栏与推荐卡片,主栏内容居中显示', def: true }, autoHideHeader: { group: '净化', label: '下滚自动隐藏顶栏', tip: '向下滚动时收起顶部导航栏,向上滚动时恢复', def: false }, timeTop: { group: '阅读', label: '发布时间移至顶部', tip: '回答的发布/编辑时间显示在开头;只有「编辑于」时补全具体发布日期', def: true }, picOriginal: { group: '阅读', label: '图片原图显示', tip: '自动加载未压缩的原始尺寸图片', def: true }, picMaxHeight: { group: '阅读', label: '限制图片最大高度', tip: '正文图片最高 500px,避免长图刷屏', def: true }, hoverFocus: { group: '阅读', label: '悬停时高亮当前卡片', tip: '鼠标悬停的回答/搜索结果卡片显示淡蓝色边框', def: true }, refHighlight: { group: '阅读', label: '引用角标高亮', tip: '文内引用序号以蓝色加粗显示,便于定位参考资料', def: true }, gifPlay: { group: '阅读', label: 'GIF 自动播放', tip: '点击页面任意位置后,静置的 GIF 图自动开始播放', def: false }, // 数值型选项:带 type/min/max/step/unit,面板自动渲染成一行滑块(见 L6 mountRangeRow)。 // dep 声明依赖的开关 ——「隐藏侧边栏并居中内容」关闭时本项不生效,面板据此置灰并提示。 readWidth: { group: '阅读', label: '文章页阅读宽度', tip: '专栏/回答正文的最大宽度,向右拖动更宽;窗口变窄时自动收缩', def: READ_W, type: 'range', min: 900, max: 2400, step: 50, unit: 'px', dep: 'hideSidebar' }, directLink: { group: '链接', label: '跳转链接直链化', tip: '站内外链不再经过中转确认页,直接打开目标网址', def: true }, nightMode: { group: '外观', label: '夜间模式', tip: '深色主题,切换即时生效无需刷新', def: false } }; // ====================================================================== // L2 数据结构层 · OPT 单一数据源(loadOpts 唯一入口,saveOpt 唯一出口,readOpt 唯一归一) // ====================================================================== var OPT = {}; // 数值项归一(唯一的合法值判据):取整到 step、夹进 [min,max],非法输入回落默认值。 // 读、写、渲染三条路都过它 ——「存进去的」与「用起来的」因此永远一致; // 手改存储或旧版本遗留的越界值,也不会把版面拉坏。 function clampRange(v, def) { var n = (typeof v === 'number') ? v : parseFloat(v); if (!isFinite(n)) return def.def; var st = def.step || 1; n = Math.round(n / st) * st; if (n < def.min) n = def.min; if (n > def.max) n = def.max; return n; } // 单值归一:数值项走 clampRange,布尔项非布尔即回落默认 function readOpt(name, v) { var d = OPT_DEFS[name]; if (d.type === 'range') return clampRange(v, d); return (typeof v === 'boolean') ? v : d.def; } function loadOpts() { for (var name in OPT_DEFS) { OPT[name] = readOpt(name, GM_getValue(PREFIX + '.' + name)); } return OPT; } function saveOpt(name, value) { var v = readOpt(name, value); // 写前归一:坏值不落盘 OPT[name] = v; GM_setValue(PREFIX + '.' + name, v); return v; } // 阅读宽度上限的单一取值入口(L3 样式层与 L5 执行层共用它,杜绝两处写死) function widthCap(opt) { return clampRange(opt && opt.readWidth, OPT_DEFS.readWidth); } // ====================================================================== // L4 核心逻辑层 · 纯函数(UMD 导出,测试直接 require 真实代码) // ====================================================================== // 知乎跳转链接 → 目标直链。返回 null 表示无需改写。 // 三条路径(命中即返回,优先最明确的): // ① link.zhihu.com/?target= 参数解码;② 任意 ?next=/&next= 内嵌编码 URL 解码; // ③ href 内嵌编码完整 URL 兜底。幂等:已是直链不含中转特征,原样返回 null。 function resolveLink(href) { if (!href || href.indexOf('http') !== 0) return null; // ① 最明确的中转形态:link.zhihu.com/?target=<编码URL> var idx = href.indexOf('link.zhihu.com/?target='); if (idx > -1) { var target = href.substring(idx + 'link.zhihu.com/?target='.length); var amp = target.indexOf('&'); if (amp > -1) target = target.substring(0, amp); target = safeDecode(target); return isForeign(target) ? target : null; } // ② 知乎站内链接携带编码目标(?next= / &next=)——最普遍的直跳场景 var q = href.match(/[?&]next=([^&]+)/); if (q) { var next = safeDecode(q[1]); return isForeign(next) ? next : null; } // ③ 兜底:href 内嵌编码的完整 URL(中转脚本拼参)。 // 先在编码态按 & 截断再解码 —— 目标 URL 自身的 & 是 %26,不会被误切。 var pos = Math.max(href.lastIndexOf('https%3A%2F%2F'), href.lastIndexOf('http%3A%2F%2F')); if (pos > -1) { var frag = href.substring(pos); var a2 = frag.indexOf('&'); if (a2 > -1) frag = frag.substring(0, a2); frag = safeDecode(frag); if (isForeign(frag)) return frag; } return null; } function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return s; } } // 目标必须是 http(s) 且不是知乎站内(站内链接无中转,不动) function isForeign(u) { return /^https?:\/\//i.test(u) && !/^https?:\/\/([a-z0-9-]+\.)*zhihu\.com(\/|$)/i.test(u); } // zhimg 尺寸后缀白名单 —— 白名单之外一律不动,避免误伤 hash var IMG_SUFFIX = /^_(xs|s|m|l|xl|hd|r|b|qk|is|it|wdaz|720w|1440w)\.(jpg|jpeg|png|webp|gif)$/i; function normalizeImg(src) { if (!src || src.indexOf('zhimg.com') === -1) return null; var out = src.replace(/\/50\//, '/'); var m = out.match(/(_[^_\/]+)\.(jpg|jpeg|png|webp|gif)(\?.*)?$/i); // 仅剥离白名单尺寸后缀;非白名单后缀(含 hash 特征)一律不动。 // 后缀在扩展名之前,用整段匹配替换而非按长度截断 if (m && IMG_SUFFIX.test(m[1] + '.' + m[2])) { out = out.replace(m[0], '.' + m[2] + (m[3] || '')); } return out === src ? null : out; } // 原图目标解析:优先信任知乎原生 data-actualsrc 懒加载原图(最准确, // 带 hash 的新尺寸参数也不会被误剥),该属性缺失时才回退到 normalizeImg 剥尺寸后缀。 // 返回 null 表示无需替换(保持现状)。 function pickOriginal(img) { var src = img.getAttribute('src') || ''; var actual = img.hasAttribute ? img.getAttribute('data-actualsrc') : null; // data-actualsrc 是知乎官方提供的原图地址,且与当前 src 不同 → 直接信任 if (actual && actual !== src && actual.indexOf('zhimg.com') > -1) return actual; // 无原生原图信息,回退到尺寸后缀剥离 return normalizeImg(src); } // 从「发布于 …」「编辑于 …」文本中提取绝对时间;取不到返回 null,不硬造 var TIME_RE = /(发布于|发布时间)\s*((?:\d{4}[年\-/])?\d{1,2}[月\-/]\d{1,2}[日]?(?:\s*\d{2}:\d{2})?)|\u7f16\u8f91\u4e8e\s*((?:\d{4}[年\-/])?\d{1,2}[月\-/]\d{1,2}[日]?(?:\s*\d{2}:\d{2})?)/g; function pickTime(text) { if (!text) return null; TIME_RE.lastIndex = 0; var m, publish = null, edit = null; while ((m = TIME_RE.exec(text)) !== null) { if (m[1] && !publish) publish = m[2]; else if (!m[1] && !edit) edit = m[3]; } if (!publish && !edit) return null; return { publish: publish, edit: edit }; } // ====================================================================== // L3 样式层 · buildCSS 按开关拼装;夜间主题常驻、由 data-theme 门控 // ====================================================================== function buildCSS(opt) { var css = ''; if (opt.hideLogin) css += ` html { overflow: auto !important; margin-right: 0 !important; } .Modal-enter, .Modal-enter-active, .Modal-enter-done { display: none !important; } `; if (opt.hideSidebar) css += ` /* 侧栏隐藏:四轨选取,先稳后兜。 ① 知乎埋点语义属性(data-za-detail-view-path-module 标记右侧栏)—— 跨改版最稳定; ② 语义类名(GlobalSideBar / *-sideColumn / Card.*)—— 长期沿用; ③ 目录面板 .Catalog —— 知乎「文章目录」模块的根判据。ref/知乎优化1.js:10303 正是用 .Catalog.isCatalogV2 隐藏目录;其内部 .CatalogModule-title- 的后缀是构建哈希(ref 里 sggN4 / 9caZz 两个版本并存即证),每发版必变, 因此只能认 .Catalog 这一层,不能写到模块名; ④ 结构特征(sticky 容器内的推荐卡等)—— 兜住无类名的内联布局。 */ div[data-za-detail-view-path-module="RightSideBar"], .GlobalSideBar, .Question-sideColumn, .Search-sideColumn, .Topstory-sideColumn, .Post-SideActions, .Post-Sub, .Post-Row-Content-right, .Catalog, .Card.AnswerAuthor, .Card.AuthorCard, .HotSearchCard, .Question-sideColumnAdContainer, .Recommendations-Main, .Question-mainColumnLogin, .Pc-card.Card, div[style*="position: sticky"] .Card, div[style*="position:sticky"] .Card, .Post-SideActions + div[style*="position: sticky"], .Post-SideActions + div[style*="position:sticky"] { display: none !important; } html { overflow-y: scroll !important; overflow-x: hidden !important; } /* —— 首页 / 搜索页:锁定标准内容宽度并居中 —— */ .Topstory-container, .Search-container { width: 694px !important; min-width: 694px !important; margin: 0 auto !important; padding: 0 !important; } .Topstory { display: flex !important; justify-content: center !important; } .Topstory-mainColumn, .Search-mainColumn { width: 100% !important; margin: 0 !important; float: none !important; } /* —— 问题页:主栏居中 —— */ .Question-main { display: block !important; width: 694px !important; margin: 0 auto !important; } .Question-mainColumn { width: 694px !important; margin: 0 auto !important; float: none !important; } .QuestionPage .ListShortcut { width: 694px !important; margin: 0 auto !important; } /* —— 专栏文章页:隐藏左侧目录 + 让正文真正居中放宽 —— 知乎文章页的布局容器类名「三代演进」,写死任何一代都迟早过时: 第一代(纯哈希类名):行容器 .css-kjzwqj / 正文列 .css-c0fani / 侧栏 .css-1ni4jcm —— ref/知乎优化1.js:997-1001,且它的「放宽」是运行时取宽: $(".css-c0fani").width($(".css-kjzwqj").width()); 第二代(旧语义名):.Post-Row-Content > .Post-Row-Content-left + .Post-Row-Content-right —— ref/知乎优化4.js:108-125; 第三代(新语义名):.Post-NormalMain / .Post-NormalSub —— ref/知乎优化3.js:385,且只在 zhuanlan 域生效(location.hostname 含 zhuanlan)。 「侧栏都隐藏了为什么还靠左」——这是本轮的核心问题: 正文列宽度被**写死**(通常 690px),而它的父级是 display:flex 的整行(正文列 + 目录 + 右侧栏)。 把侧栏 display:none 之后,行里只剩正文列,而行的默认 justify-content:flex-start 让它**贴在左侧**,右侧腾出的空间全成了空白。所以光「隐藏」不够,必须 ① 让正文列本身变宽(放宽)或 ② 让行容器居中 —— ref 的做法是两者都做。 本轮修正(v1.4.1):阅读上限 1000px → 1500px 基线,且上限随视口自适应 (宽屏取上限、窄屏 88vw)。起因是用户反馈「居中好了,但左右空太多」—— 居中解决的是「位置」,加宽解决的是「宽度」,两者缺一不可。 本轮补充(v1.5.0):该上限不再是写死的常量,改由设置面板的「文章页阅读宽度」 滑块在 900~2400px 间自定;CSS(此处)与运行时统一从 widthCap(opt) 取同一值。 ② 居中改用「双 auto 外边距」写法 —— 对 block 父级(需自身有确定宽度)与 flex 父级 (auto 外边距优先吸收剩余空间)**都成立**。上一版用 flex:0 1 auto + 父级 justify-content,一旦父级不是 flex 就整体失效,这是上一版在真实页面不生效的关键; ③ 每个可能充当「行容器」的层都先解除宽度约束(width/max-width 双 100%), 避免上溯链上出现「窄墙」把正文困在左侧; ④ 目录面板改用 .Catalog 判据(见上方隐藏列表),执行层另有上溯兜底。 */ .Post-content, .Post-Row-Content, .Post-NormalMain, .Post-NormalSub { width: 100% !important; max-width: 100% !important; margin: 0 auto !important; } /* 正文列:撑满可用宽度(上限 = 面板设定的阅读宽度,窄屏再按 88vw 收)并居中。 margin-left/right:auto 是唯一同时适配 block 与 flex 两种父级的居中手段。 box-sizing 与 width:100% 必须成对给出,否则内边距会叠加把宽列顶出容器。 */ .Post-NormalMain > div, .Post-NormalSub > div, .Post-Row-Content-left, .Post-Main, .Post-NormalMain .Post-Header, .Post-NormalMain .Post-RichTextContainer { width: 100% !important; max-width: ${widthCap(opt)}px !important; box-sizing: border-box !important; margin-left: auto !important; margin-right: auto !important; } .Comment-container { width: 100% !important; max-width: ${widthCap(opt)}px !important; box-sizing: border-box !important; margin-left: auto !important; margin-right: auto !important; padding-left: 0 !important; padding-right: 0 !important; } .ColumnPageHeader-content { max-width: ${widthCap(opt)}px !important; margin-left: auto !important; margin-right: auto !important; } .Topstory-container, .Topstory-mainColumn, .Question-mainColumn, .Question-main, .Post-content, .Post-Row-Content, .Post-Row-Content-left, .Post-NormalMain, .Post-NormalSub, .Post-Main { transition: none !important; animation: none !important; transform: none !important; } /* 首屏页脚防闪现:知乎 SPA 在正文水合前会先把页脚(帮助/举报/备案)渲染出来, 造成「打开时闪一下再消失」。启动期由 html[data-zhx-booting] 先藏住, 主内容就绪后脚本移除该属性,页脚恢复正常显示(正常的关于页不受影响)。 判据同时覆盖新版