// ==UserScript== // @name 笔趣阁优化 // @namespace https://gitee.com/linhq1999/OhMyScript // @version 5.2 // @description 专注阅读 // @author LinHQ // @match http*://www.shuquge.com/*.html // @exclude http*://www.shuquge.com/*index.html // @match http*://www.sywx8.com/*.html // @match http*://www.biqugetv.com/*.html // @match http*://www.bqxs520.com/*.html // @match https://www.dshfood.net/*.html // @grant GM_addStyle // @grant GM_openInTab // @grant GM_xmlhttpRequest // @require https://greasyfork.org/scripts/427726-gbk-url-js/code/GBK_URLjs.js?version=953098 // @inject-into auto // @license MIT // ==/UserScript== 'use strict'; /** 配置示例 * 建议在定制 search 函数时, rq 函数始终把参数写全 * "sites": [ * { * "desc": "shuquge", 网站链接关键字 * "url": "https://....", 网站首页链接 * "main": "div.reader", 主要部分选择器 * "title": ".reader h1", 标题选择器 * "txt": "#content", 文字部分选择器 * "toc": "dd a", 目录链接选择器 * "tocJump": 12, 跳过前面多少章 * "filter": ["div.header", "div.nav", "div.link"], 带有此选择器的元素将被删除 * "txtfilter": ["shuqu"] 带有此关键字的行将被删除 * "funcFilter"?: () => void, 自定义过滤器 * "nodash"?: boolean, 判断是否应该在书籍详情页链接后加额外的斜杠 * "search"?: (keywords: string, baseurl:string) => Promise 搜索行为 * } * ] */ (() => { // 缺省值,一般不用修改 const lineHeight = 1.3; // const defaultFont = "楷体"; const defaultFont = "Source Han Sans SC VF"; let C = { "sites": [ { "desc": "shuquge", "url": "https://www.shuquge.com/", "main": "div.reader", "title": ".reader h1", "txt": "#content", "toc": "dd a", "tocJump": 12, "filter": [ "div.header", "div.nav", "div.link", "img", "#coupletleft", "#coupletright", "#HMRichBox" ], "txtfilter": ["shuqu"], "funcFilter": () => { var _a, _b; return (_b = (_a = fd(document, "#content")) === null || _a === void 0 ? void 0 : _a.previousSibling) === null || _b === void 0 ? void 0 : _b.remove(); } }, { "desc": "sywx", "url": "https://www.sywx8.com/", "main": "div#container", "title": "div>h1", "toc": "li a", "tocJump": 0, "txt": "div#BookText", "filter": ["div.top", ".link.xb", "#footer"], "txtfilter": ["最快更新", "松语", "本章完", "本章未完"], // javascript 不支持 gbk 的 uri 编码,所以无法实现 // 但是用 gbk.js 就不一样了 "search": async (keywords, baseurl) => { let links = []; let doc = await rq({ "url": `https://www.sywx8.com/modules/article/search.php?searchkey=${$URL.encode(keywords)}` }, 8000, "GBK"); for (let a of doc.querySelectorAll(".c_row .c_subject a")) { // 这个网站比较特殊,链接默认是完整的 links.push({ "title": `(sywx) ${a.textContent}`, "href": attr(a, "href") }); } return links; } }, { "desc": "bqxs", "url": "http://www.bqxs520.com/", "main": ".box_con", "title": "div.content_read h1", "toc": "#list dd a", "tocJump": 9, "txt": "#content", "filter": [".ywtop", ".header", ".nav", ".bottem1", ".lm", "#page_set", ".bookname~.box_con"], "txtfilter": ["请记住本书", "http"], "search": async (keywords, baseurl) => { let links = []; let doc = await rq({ "method": "POST", "headers": { "Content-Type": "application/x-www-form-urlencoded" }, "url": encodeURI(`http://www.bqxs520.com/case.php?m=search`), "data": `&key=${encodeURI(keywords)}` }, 7000, "UTF-8"); for (let a of doc.querySelectorAll(".l .s2 a")) { links.push({ "title": `(bqxs) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href")) }); } return links; } }, { "desc": "biqugetv", "url": "https://www.biqugetv.com/", "main": ".box_con", "title": "div.content_read h1", "toc": "#list dd a", "tocJump": 0, "txt": "#content", "filter": [".ywtop", ".header", ".nav", ".bottem1", ".lm", "#page_set"], "txtfilter": [], "search": async (keywords, baseurl) => { let links = []; let doc = await rq({ "url": encodeURI(`https://www.biqugetv.com/search.php?keyword=${keywords}`) }, 6000, "UTF-8"); for (let a of doc.querySelectorAll("h3 a")) { links.push({ "title": `(biqugetv) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href")) }); } return links; } }, { "desc": "dshfood", "url": "https://www.dshfood.net/", "main": ".box_con", "title": "div.content_read h1", "toc": "#list dd a", "tocJump": 9, "txt": "#content", "filter": [".ywtop", ".header", ".nav", ".bottem1", "#page_set", "#content>div"], "txtfilter": ["笔趣阁"], "nodash": true, "funcFilter": () => document.querySelectorAll("img") .forEach(e => { var _a; return (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.remove(); }), "search": async (keywords, baseurl) => { let links = []; let doc = await rq({ "method": "POST", "headers": { "Content-Type": "application/x-www-form-urlencoded", "referer": "https://www.dshfood.net/so/" }, "url": "https://www.dshfood.net/so/", // 鉴于使用了 GBK 进行编码,不能再使用 URLSearchParams "data": `?searchtype=articlename&searchkey=${$URL.encode(keywords)}&submit=` }, 6000, "GBK"); for (let a of doc.querySelectorAll(".line a.blue")) { links.push({ "title": `(dshfood) ${a.textContent}`, "href": concatURL(baseurl, attr(a, "href")) }); } return links; } } ], "states": { "fontSize": 16, "lineHeight": 16 * lineHeight, "toc": false, "flow": false }, "style": ` body { background-color: #EAEAEF !important; } .bqg.inject.win { width: 55vw !important; min-width: 600px; border: 2px double gray !important; border-radius: 8px; } .bqg.inject.txt { font-family: Calibri,'${defaultFont}',serif!important; background-color: #EAEAEF !important; padding: 0.5em 1em !important; margin: 0.5em auto !important; width: auto !important; white-space: pre-wrap; } .bqg.inject.title { color: black; background-color: #EAEAEF; font-family: Calibri,'${defaultFont}',serif!important; cursor: pointer !important; } .bqg.inject.title:hover { color: #0258d8 !important; } .hq.inject.toc { font-family: Calibri,sans-serif; width: 275px; position: fixed; top: 30px; left: 8px; /*目录默认是关闭的*/ transform: translateX(-300px); opacity: 0; padding: 5px; display: flex; flex-flow: column; box-shadow: #7b7b7b 5px 4px 5px; transition-property: transform, box-shadow, opacity; transition-duration: .5s; transition-timing-function:cubic-bezier(0.35, 1.06, 0.83, 0.99); background: rgb(246 246 246 / 60%); backdrop-filter: blur(2px); border-radius: 8px; } .hq.inject ul { height: 280px; width: 100%; /*offsetTop 计算需要*/ position:relative; overflow: auto; } .hq.inject ul li { cursor: pointer; margin: 2px; width: 95%; padding: 1px 4px; font-size: 12px; border-radius: 4px; } .hq.inject ul li:hover { background: #0258d8; color: #f6f6f6; } .hq.inject.toc>h3 { font-size: 1.1rem; font-weight: bold; border-radius: 2px; align-self: center; cursor: pointer; margin: 4px 0 8px 0; } .hq.inject.toc>h3:hover { color: #ffa631 !important; } .hq.inject.search { font-family: Calibri,sans-serif; width: 275px; position: fixed; top: 30px; padding: 5px; display: flex; flex-flow: column; transition: right 0.5s cubic-bezier(0.35, 1.06, 0.83, 0.99); background: rgb(246 246 246 / 60%); border-radius: 8px; } .hq.inject.search input { margin: 8px auto; width: 95%; } ` }; // 查询已经保存的字体信息 let savedStates = localStorage.getItem("bqg_cfg"); // 检查是否存在已有设置且和当前版本相符 let states; if (savedStates === null) { states = C.states; console.warn("当前状态已保存"); } else { let cfg = JSON.parse(savedStates); let defaultStates = Object.keys(C.states); let cfg_ = Object.keys(cfg); let useSaved = true; // 检查键是否匹配 if (defaultStates.length == cfg_.length) { for (let key of Object.keys(cfg)) { if (!defaultStates.includes(key)) { useSaved = false; break; } } } else { useSaved = false; } if (useSaved) { states = cfg; } else { states = C.states; console.warn("检测到版本变化,状态已重置"); } } // 检测当前的网址,应用对应的设置 let tmp = C.sites.filter(site => document.URL.includes(site.desc)); if (tmp.length == 0) { console.warn("没有匹配的设置,脚本已终止!"); return; } let currentSite = tmp[0]; // 完成样式注入 GM_addStyle(C.style); /** * 保存交互式状态 */ function saveStates() { localStorage.setItem("bqg_cfg", JSON.stringify(states)); } /** * 上一章,同时移除所有 flow 拼接结果 */ function prevChapter() { var _a; (_a = fd(document, "a", "上一")) === null || _a === void 0 ? void 0 : _a.click(); } /** * 下一章,同时移除所有 flow 拼接结果 */ function nextChapter() { var _a; (_a = fd(document, "a", "下一")) === null || _a === void 0 ? void 0 : _a.click(); } /** * 异步,向下拼页 * 绑定到事件上时务必注意重复触发的情况 */ async function concatNextCh() { var _a; let next = fd(document, "a", "下一"); let prev = fd(document, "a", "上一"); let currentText = fd(document, currentSite.txt); try { let doc = await rq({ url: next === null || next === void 0 ? void 0 : next.href }); let text = fd(doc, currentSite.txt); // console.log(text.textContent) // 更好的性能 currentText.insertAdjacentHTML("beforeend", "

"); currentText.insertAdjacentText("beforeend", txtFilter((_a = text.innerText) !== null && _a !== void 0 ? _a : "文本过滤错误", /(?![a-zA-Z0-9!.'"])\s+/)); // /id/xxx_1.html -> /id/xxx_1 let href = attr(next, "href").replace(/\.html$/, ""); // 重新渲染目录,currentBookToc 不可能为 null renderTOC(JSON.parse(currentBookToc), ul, href); // 重设上一页和下一页按钮的链接 prev.href = fd(doc, "a", "上一").href; next.href = fd(doc, "a", "下一").href; } catch (error) { currentText.innerText = currentText.innerText.concat("\n\n\t获取下一页错误,上下滚动以重新获取"); } } // 目录切换 function switchToc(open) { let toc = fd(document, ".hq.inject.toc"); if (open) { toc.style.transform = "translateX(0)"; toc.style.opacity = "1"; toc.style.boxShadow = "box-shadow: #7b7b7b 5px 3px 4px 0px;"; states.toc = true; } else { toc.style.transform = "translateX(-300px)"; toc.style.opacity = "0"; toc.style.boxShadow = "box-shadow: #7b7b7b 5px 2px 0px 0px;"; states.toc = false; } saveStates(); } // 目录开关 function toggleToc() { if (states.toc) { switchToc(false); } else { switchToc(true); } } /** * 根据 site 中的条件进行过滤,同时将缩进统一 * * @param itxt 需要过滤的,innerText 通用性最好 * @param delim 默认的切分点,从网页解析得到的内容和 ajax 获取到的内容切分点不一致 * @returns 过滤后字符串 */ function txtFilter(itxt, delim = /\n/g) { var _a; // innerText 相对于 textContent 保留了视觉上的换行(块的换行) return (_a = itxt === null || itxt === void 0 ? void 0 : itxt.split(delim)) === null || _a === void 0 ? void 0 : _a.filter(line => { if (/^\s*$/.test(line)) return false; // 去除白行和包含的关键字 for (const keyword of currentSite.txtfilter) { if (line.includes(keyword)) { return false; } } return true; }).map(line => `${" ".repeat(2)}${line.trim()}`).join("\n\n"); } if (states.flow) { // 变相 throttle 一下不然顶不住 let loading = false; document.onscroll = async (_) => { if (!loading && chkBoundry(true, window.innerHeight * 0.75)) { loading = true; // 意思是上一次拼页完过1.5秒才允许继续拼页,避免在加载下一页时反复调用拼页函数 // 效果比固定延迟要稳定 await concatNextCh(); setTimeout(() => { loading = false; }, 1500); } }; } // 对可变部分产生影响 let doInject = function () { var _a; // 执行元素过滤 currentSite.filter.forEach(filter => { var _a; return (_a = document.querySelectorAll(filter)) === null || _a === void 0 ? void 0 : _a.forEach(ele => ele.remove()); }); // 执行自定义过滤 if (currentSite.funcFilter) { currentSite.funcFilter(); } // 应用已经保存的状态 let textWin = fd(document, currentSite.txt); textWin.setAttribute("style", `font-size:${states.fontSize}px;line-height:${states.lineHeight}px`); textWin.classList.add("bqg", "inject", "txt"); // 执行文字过滤 textWin.textContent = txtFilter((_a = textWin.innerText) !== null && _a !== void 0 ? _a : "文本过滤错误"); let mainWin = fd(document, currentSite.main); mainWin.classList.add("bqg", "inject", "win"); let title = fd(document, currentSite.title); title.title = "点击显示目录"; title.classList.add("bqg", "inject", "title"); title.onclick = (ev) => { toggleToc(); // 避免跳到上一章 // 比下面的更为具体,所以有效。 ev.stopPropagation(); }; // 阻止双击事件被捕获(双击会回到顶部) document.body.ondblclick = (ev) => ev.stopImmediatePropagation(); document.body.onclick = (ev) => { let root = document.documentElement; let winHeight = window.innerHeight; // 下半屏单击下滚,反之上滚 if (ev.clientY > root.clientHeight / 2) { if (chkBoundry() && !states.flow) nextChapter(); window.scrollBy({ top: (window.innerHeight - lineHeight) * 1 }); } else { if (chkBoundry(false)) { prevChapter(); } window.scrollBy({ top: (window.innerHeight - lineHeight) * -1 }); } }; document.body.onkeydown = (ev) => { switch (ev.key) { case "-": states.fontSize -= 2; textWin.style.fontSize = `${states.fontSize}px`; states.lineHeight = states.fontSize * lineHeight; textWin.style.lineHeight = `${states.lineHeight}px`; saveStates(); break; case "=": states.fontSize += 2; textWin.style.fontSize = `${states.fontSize}px`; states.lineHeight = states.fontSize * lineHeight; textWin.style.lineHeight = `${states.lineHeight}px`; saveStates(); break; case "j": if (chkBoundry() && !states.flow) { nextChapter(); } else { window.scrollBy({ top: window.innerHeight - states.lineHeight }); } break; case "k": // 考虑在 flow 模式下也允许上一章 if (chkBoundry(false) && !states.flow) { prevChapter(); } else { window.scrollBy({ top: -1 * (window.innerHeight - states.lineHeight) }); } break; case "h": prevChapter(); break; case "l": nextChapter(); break; case "t": toggleToc(); break; case "s": toggleSearch(); break; case "f": states.flow = !states.flow; saveStates(); break; default: break; } }; }; // 先调用一次,后面是有变化时才会触发,避免有时无法起作用 doInject(); // 强力覆盖 new MutationObserver((_, ob) => { doInject(); }).observe(document.body, { childList: true }); // 添加目录 let toc = document.createElement("div"); toc.className = "hq inject toc"; toc.onclick = ev => ev.stopPropagation(); // 已保存状态读取 document.body.append(toc); if (states.toc) switchToc(true); // 目录状态指示灯 let pointer = document.createElement("h3"); // 当然也可以靠不同类名实现 let pointerColors = { "loaded": "#afdd22", "loading": "#ffa631", "unload": "#ed5736" }; pointer.title = "点击以重新加载目录"; pointer.innerHTML = "目录"; pointer.style.cursor = "pointer"; pointer.style.color = pointerColors.unload; toc.append(pointer); // 目录列表 let ul = document.createElement("ul"); toc.append(ul); /** * 从源渲染目录到指定元素 * * @param toc 目录源 * @param ul 容器 * @param href 定位链接,格式 http://host/id/chp.html 中最短为 /id/chp 部分 */ function renderTOC(toc, ul, href) { var _a; // 清空旧内容 ul.innerHTML = ""; let current = null; // 进度计数器 let counter = 1; for (let lnk of toc) { let li = document.createElement("li"); li.textContent = lnk.title; // 根据传入的 href 是否包含目录中的链接来判定,因为有的网站包含子页面 XXXX_1.html 形式 // 比对时标准目录链接 lnk: /id/chp.html 之中,仅取用 chp let last = (_a = lnk.href.replace(".html", "")) !== null && _a !== void 0 ? _a : ""; if (current == null && href.includes(last)) { li.innerHTML = `${lnk.title}${(counter / toc.length * 100).toFixed(1)}%`; current = li; } li.onclick = (ev) => { document.location.href = lnk.href; ev.stopPropagation(); }; ul.append(li); counter++; } // 渲染完修改指示灯状态 pointer.style.color = pointerColors.loaded; // 滚动到当前位置,并高亮 if (current !== null) { current.setAttribute("style", "display:flex;font-weight:bold;background: #0258d8;color: #f6f6f6;"); ul.scrollTo({ top: current.offsetTop - 130 }); } } /** * 获取目录信息 * * @param currentBookLink 当前书的链接,用作存储的键 * @param pointer 指示灯,在需要的时候修改状态 */ async function fetchTOC(currentBookLink, pointer) { var _a; // 修改指示灯状态 pointer.style.color = pointerColors.loading; try { let doc = await rq({ url: currentBookLink }); let tocs = doc.querySelectorAll(currentSite.toc); let data = []; // 序列化存储准备 for (let link of tocs) { // 使用字面意义上的链接 /chapter.html 而不是 http://**/id/chapter.html 以减小存储量 data.push({ "title": (_a = link.textContent) !== null && _a !== void 0 ? _a : "", "href": attr(link, "href") }); } if (currentSite.tocJump) data = data.slice(currentSite.tocJump); // 缓存目录信息 let stdata = JSON.stringify(data); sessionStorage.setItem(currentBookLink, stdata); // 更新变量,避免章节拼接时以为找不到 currentBookToc = stdata; renderTOC(data, ul, href); } catch (_) { pointer.style.color = pointerColors.unload; } } let source = document.URL.split("/"); source.pop(); // 用来定位的 url let href = document.URL.replace(/\.html$/, ""); // 最后加斜杠保险 let currentBook = source.join("/"); if (!currentSite.nodash) { currentBook += "/"; } let currentBookToc = sessionStorage.getItem(currentBook); if (currentBookToc === null) { fetchTOC(currentBook, pointer); } else { renderTOC(JSON.parse(currentBookToc), ul, href); } // 单击指示灯刷新目录缓存 pointer.onclick = _ => fetchTOC(currentBook, pointer); // 添加聚合搜索 let searchBox = document.createElement("div"); searchBox.onclick = ev => ev.stopPropagation(); searchBox.onkeydown = ev => ev.stopPropagation(); searchBox.className = "hq inject search"; searchBox.style.right = "-300px"; searchBox.innerHTML = ` 已就绪 `; document.body.append(searchBox); let inputBox = fd(searchBox, "#insearch"); let search_ul = document.createElement("ul"); searchBox.append(search_ul); let search_pointer = fd(searchBox, "#insearch~span"); // debounce 一下不然顶不住 let timer = null; inputBox.oninput = _ => { if (timer !== null) clearTimeout(timer); timer = setTimeout(async () => { var _a, _b; // 放外面也可 if (((_a = inputBox) === null || _a === void 0 ? void 0 : _a.value.length) < 2) return; // 更新指示灯 search_pointer.textContent = `正在搜索:${inputBox.value}`; search_pointer.style.color = pointerColors.loading; let requests = []; let others = [{ "title": "没有搜索结果,也可以看看:", "href": "#" }]; for (let s of C.sites) { if (s.search !== undefined) { // 搜索开始 requests.push(s.search((_b = inputBox) === null || _b === void 0 ? void 0 : _b.value, s.url)); } else { others.push({ "title": s.desc, "href": s.url }); } } let result_count = 0, failed = 0; let list = await Promise.allSettled(requests); // 获取结果后清空旧内容 search_ul.innerHTML = ""; for (let site of list) { if (site.status === "fulfilled") { for (let lnk of site.value) { let li = document.createElement("li"); li.textContent = lnk.title.trim(); li.onclick = ev => GM_openInTab(lnk.href, { active: true }); search_ul.append(li); result_count++; } } else { failed++; } } // 处理一下没有结果的情况,把没有实现 search 的网站摆上去 if (result_count === 0) { for (let o of others) { let li = document.createElement("li"); li.textContent = o.title; li.onclick = ev => GM_openInTab(o.href, { active: true }); search_ul.append(li); } } // 更新指示 search_pointer.textContent = `搜索完成:${result_count} 条结果 [${failed} 错误]`; search_pointer.style.color = pointerColors.loaded; }, 1000); }; // 搜索框开关 function toggleSearch() { if (parseInt(searchBox.style.right) < 0) { searchBox.style.right = "8px"; } else { searchBox.style.right = "-300px"; } } /* 以下是工具函数 */ /** * 发起请求 * * @param details 油猴标准请求格式,onload,onerror,responseType 会被忽略 * @param timeout 超时时间 默认:5000 * @param encoding 请求数据的编码 默认:当前所在页面的编码 * @returns Promise */ function rq(details, timeout = 5000, encoding) { // 自动探测一手 if (!encoding) encoding = document.characterSet; return new Promise((res, rej) => { details.onerror = rej; details.ontimeout = rej; details.timeout = timeout; details.responseType = "arraybuffer"; details.onload = resp => { if (resp.status != 200) rej(); let decoder = new TextDecoder(encoding); res(new DOMParser() .parseFromString(decoder.decode(resp.response), "text/html")); }; GM_xmlhttpRequest(details); }); } /** * 返回符合条件的第一个元素 * * @param doc 被查找的文档 * @param selector 选择器 * @param text 可选 元素的文本(子字符串) * @returns 符合条件的元素 */ function fd(doc, selector, text) { var _a; if (text) { for (let e of doc.querySelectorAll(selector)) { if ((_a = e.textContent) === null || _a === void 0 ? void 0 : _a.includes(text)) { return e; } } } else { return doc.querySelector(selector); } return null; } /** * 拼接 URL * * @param host 网站域名 * @param path 一般是链接之中的相对路径 * @returns 完整的 URL */ function concatURL(host, path) { let url = new URL(host); url.pathname = path; return url.toString(); } /** * 如果是 a 标签,且想要获取字面上的 href,必须使用此方法,不可以用 a.href * * @param ele 标签名 * @param attr 属性名 * @returns 属性值 */ function attr(ele, attr) { var _a; return (_a = ele.getAttribute(attr)) !== null && _a !== void 0 ? _a : ""; } /** * 检查当前位置是否处于边界 * * @param bottom 是否检查到达底部,否则检查是否处于顶部 * @param range 距离底部多少,默认是 0(最底部) * @returns boolean */ function chkBoundry(bottom = true, range = 0) { let root = document.documentElement; let winHeight = window.innerHeight; if (bottom) { return (root.scrollTop + winHeight + range >= root.scrollHeight); } else { return (root.scrollTop == 0); } } })();