论坛列表显示图片,同时支持discuz搭建的论坛(如吾#破解)以及phpwind搭建的论坛(如south plus)灯
// ==UserScript== // @name 论坛列表显示图片 // @namespace form_show_images_in_list // @version 1.4 // @description 论坛列表显示图片,同时支持discuz搭建的论坛(如吾#破解)以及phpwind搭建的论坛(如south plus)灯 // @license MIT // @author Gloduck // @note discuz路径匹配 // @match *://*/forum-*.html // @match *://*/forum-*.html?* // @match *://*/forum.php // @match *://*/forum.php?* // @match *://*/*/forum-*.html // @match *://*/*/forum-*.html?* // @match *://*/*/forum.php // @match *://*/*/forum.php?* // @note phpwind路径匹配 // @match *://*/*/thread.php // @match *://*/*/thread.php?* // @match *://*/thread.php // @match *://*/thread.php?* // @note ####路径匹配 // @match *://*/*/thread0806.php* // @match *://*/thread0806.php* // @grant GM_xmlhttpRequest // @grant GM_addStyle // ==/UserScript== (function () { 'use strict'; GM_addStyle(` .zoomable-image { cursor: pointer; } .zoomable-image.zoomed { position: fixed; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; background: rgba(0, 0, 0, 0.9); z-index: 9999; } `); let typeHandlers = [ { // 类型名称 name: "discuz", // 文章列表选择器 articleListSelector: 'tbody[id^="normalthread_"]', // 文章链接a标签选择器 articleLinkSelector: '.icn a', // 文章详情页中文章主体选择器 postContentSelector: 'div[id^="post_"] .plc', // 找到img标签后,解析img标签中链接的callback postImageLinkCallback: function (element) { let fileLink = element.getAttribute('file'); if (fileLink) { return fileLink; } return element.getAttribute('src'); }, // 初始化好放图片的div后,对该div的element包装 initElementDecorator: function (element) { let tbody = document.createElement("tbody"); let tr = document.createElement("tr"); tr.appendChild(element); tbody.appendChild(tr); return tbody; } }, { name: "phpwind", articleListSelector: '#ajaxtable tbody:last-of-type tr[align=center]', articleLinkSelector: 'td a', postContentSelector: '.tpc_content', postImageLinkCallback: function (element) { return element.getAttribute('src'); }, initElementDecorator: function (element) { let tr = document.createElement("tr"); tr.align = "center"; let td = document.createElement("td"); td.colSpan = 5; tr.appendChild(td); td.appendChild(element); return tr; } }, { name: "####", articleListSelector: 'tbody[id="tbody"] tr', articleLinkSelector: '.tal h3 a', postContentSelector: '#conttpc', postImageLinkCallback: function (element) { let fileLink = element.getAttribute('ess-data'); if (fileLink) { return fileLink; } return element.getAttribute('src'); }, initElementDecorator: function (element) { let tr = document.createElement("tr"); tr.align = "center"; let td = document.createElement("td"); td.colSpan = 5; tr.appendChild(td); td.appendChild(element); return tr; } } ]; let urlPatterns = [ { name: "discuz", pattern: [ "*://*/forum-*.html", "*://*/forum-*.html?*", "*://*/forum.php", "*://*/forum.php?*", "*://*/*/forum-*.html", "*://*/*/forum-*.html?*", "*://*/*/forum.php", "*://*/*/forum.php?*" ] }, { name: "phpwind", pattern: [ "*://*/*/thread.php", "*://*/*/thread.php?*", "*://*/thread.php", "*://*/thread.php?*" ] }, { name: "####", pattern: [ "*://*/*/thread0806.php*", "*://*/thread0806.php*" ] } ] // todo 添加自定义设置功能,添加插件功能 // 类型的基础设置,勿动 let typeBaseSettings = [ { pattern: "discuz", lazyLoad : true, maxShowLimit : 3, ignoreImageRegs: [ "/uc_server/images/*", "static/image/*", "/data/avatar/*" ], plugins: [] }, { pattern: "phpwind", lazyLoad : true, maxShowLimit : 3, ignoreImageRegs: [ "images/post/smile/*", ], plugins: [] }, { pattern: "####", lazyLoad : true, maxShowLimit : 3, ignoreImageRegs: [ "https://23img.com/*", "https://img.blr844.com/images/.*.gif", "https://avspda.xyz/*", ], plugins: [] } ]; activeByUrlPattern(); function activeByUrlPattern() { let activeSettingNames = []; urlPatterns.forEach(value => { let urlPatternReg = value.pattern.map(urlPattern => toURlPattern(urlPattern)); if (checkRegMatchStr(urlPatternReg, window.location.href)) { activeSettingNames.push(value.name); } }) if (activeSettingNames.length == 0) { console.log("无法找到要激活的配置"); return; } if (activeSettingNames.length != 1) { console.log("找到多个匹配的配置,默认激活第一个") } let activeSettingName = activeSettingNames[0]; console.log("激活的配置为:" + activeSettingName); enhancementByType(activeSettingName); } /** * 根据当前的host获取用户自定义的配置 * @param type * @returns {Object|{}} */ function getCustomTypeSettingOfHost(type){ return getCustomTypeSettingOrDefault(window.location, type); } /** * 获取用户自定义的配置或者默认的配置 * @param pattern {string} * @param type {string} * @returns {Object|{}} */ function getCustomTypeSettingOrDefault(pattern, type){ let typeBaseSetting = typeBaseSettings.find(p => p.pattern === type); if(!typeBaseSetting){ throw new Error(`类型[${type}],无法找到默认的配置`); } // 深拷贝对象,防止基本设置被更改 typeBaseSetting = deepCopy(typeBaseSetting); let customSetting = typeBaseSettings.find(p => p.pattern == pattern); if(!customSetting){ // 如果没有用户自定义的设置,则返回默认设置的深拷贝 return typeBaseSetting; } customSetting = deepCopy(customSetting); // 添加缺失的属性到用户自定义的设置里,兼容升级 addMissingProperties(customSetting, typeBaseSetting); customSetting.pattern = pattern; return customSetting; } /** * 根据类型来选择增强的处理程序 * @param type {string} 类型 */ function enhancementByType(type) { let typeHandler = getSettingByType(type); let typeSetting = getCustomTypeSettingOfHost(type); let articleListElement = document.querySelectorAll(typeHandler.articleListSelector); articleListElement.forEach(element => { if (typeSetting.lazyLoad) { lazyEnhancement(element, typeHandler, typeSetting); } else { immediateEnhancement(element, typeHandler, typeSetting); } }) } /** * 懒增强 * @param element {Element} * @param typeHandler {Object} * @param typeSetting {Object} */ function lazyEnhancement(element, typeHandler, typeSetting) { // 注册滚动事件,实现懒加载。同时通过节流来避免重复加载 window.addEventListener('scroll', throttle(function () { const targetElementRect = element.getBoundingClientRect(); if (targetElementRect.top < window.innerHeight && !element.getAttribute("has_enhanced")) { handleSingleArticle(element, typeHandler, typeSetting).then(toAppendElement => { if (!element.getAttribute("has_enhanced")) { insertElementBelow(element, toAppendElement); element.setAttribute("has_enhanced", "true"); } }) } }, 200, 500)); } /** * 立即增强 * @param element {Element} * @param typeHandler {Object} * @param typeSetting {Object} */ function immediateEnhancement(element, typeHandler, typeSetting) { handleSingleArticle(element, typeHandler, typeSetting).then(toAppendElement => { insertElementBelow(element, toAppendElement); }) } /** * 插入元素到对应元素之后(需要有夫元素) * @param targetElement {Element} * @param newElement {Element} */ function insertElementBelow(targetElement, newElement) { var parentElement = targetElement.parentNode; parentElement.insertBefore(newElement, targetElement.nextSibling); } /** * 根据类型获取设置信息 * @param type * @return {Object} */ function getSettingByType(type) { let typeHandler = typeHandlers.find(value => { return value.name === type; }); if (typeHandler == null) { throw new Error("不支持的类型"); } return typeHandler; } /** * 处理单个文章,返回最后需要拼接的element * @param element {Element} * @param typeHandler {Object} * @param typeSetting {Object} * @returns {Promise<void>} */ async function handleSingleArticle(element, typeHandler, typeSetting) { if (!element) { throw new Error("参数不能为空"); } let link = findActualArticleLinkBySelector(typeHandler.articleLinkSelector, element); let postR###lt = await httpRequest("GET", link); if (!postR###lt) { throw new Error("请求文章错误"); } var htmlDivElement = document.createElement("div"); // 初始化图片区域 htmlDivElement.appendChild(getImagesDiv(typeHandler, typeSetting, link, postR###lt)); return typeHandler.initElementDecorator(htmlDivElement); } /** * 根据设置,解析文章中的图片,并且生成html div * @param typeHandler {Object} * @param content {string} * @param postLink {string} * @param limitCount {number} * @returns {HTMLDivElement} */ function getImagesDiv(typeHandler, typeSetting, postLink, content) { let images = parsePostImages(typeHandler, postLink, content, typeSetting.ignoreImageRegs); if (typeSetting.maxShowLimit && typeSetting.maxShowLimit > 0) { images = images.slice(0, typeSetting.maxShowLimit); } let imageDiv = document.createElement("div"); imageDiv.style = "display: flex;"; imageDiv.className = "image_list"; images.forEach(value => { let imgElement = document.createElement("img"); imgElement.src = value; imgElement.style = "max-width: 300px;max-height: 300px;margin-right: 10px" imageDiv.appendChild(imgElement); imgElement.addEventListener('click', function () { // 创建一个新的图片元素 var zoomedImg = document.createElement('img'); zoomedImg.src = imgElement.src; // 添加类名以应用放大样式 zoomedImg.classList.add('zoomable-image', 'zoomed'); // 点击放大的功能 zoomedImg.addEventListener('click', function () { // 移除放大的图片元素 document.body.removeChild(zoomedImg); }); // 将放大的图片元素添加到文档中 document.body.appendChild(zoomedImg); }); }) return imageDiv; } /** * 根据类型设置匹配图片中的链接 * @param typeHandler {Object} 类型设置 * @param postLink {string} 文章链接 * @param postDetails {string} 文章的内容字符串(解析前的html) * @param ignoreRegStrs {string} 忽略图片的正则表达式(解析前) * @returns {*[]} */ function parsePostImages(typeHandler, postLink, postDetails, ignoreRegStrs) { let images = []; let content = new DOMParser().parseFromString(postDetails, "text/html"); if (!content) { return images; } let postContentSelector = typeHandler.postContentSelector; let postContent = content.querySelector(postContentSelector); if (!postContent) { console.log("无法匹配到文章主体,请确认选择器是否正确,并确认点击链接进去是否能正常访问内容,匹配失败的链接为:" + postLink); return images; } let ignoreImageRegs = regStrToReg(ignoreRegStrs); let imageElements = postContent.querySelectorAll('img'); imageElements.forEach(imageElement => { let imageLink = typeHandler.postImageLinkCallback(imageElement); if (checkRegMatchStr(ignoreImageRegs, imageLink)) { return; } images.push(convertPathToAccessible(imageLink, postLink)); }) return images; } /** * 通过文章链接选择器获取文章的绝对链接 * @param selector {string} * @param element {Element} */ function findActualArticleLinkBySelector(selector, element) { let linkElement = element.querySelector(selector); if (!linkElement) { throw new Error("通过选择器,无法找到文章的链接元素"); } let href = linkElement.getAttribute("href"); if (!href) { throw new Error("无法获取href元素,请确认选择器是否最终选择了一个a标签,以及a标签上是否有href"); } return convertPathToAccessible(href, window.location.href); } /** * 找到第一个a标签中的链接 * @param element {Element} * @returns {*|string|null} */ function findFirstAnchorLink(element) { const linkElement = element.querySelector("a"); if (linkElement) { return linkElement.getAttribute("href"); } else { const childElements = element.children; for (let i = 0; i < childElements.length; i++) { const link = findFirstAnchorLink(childElements[i]); if (link) { return link; } } } return null; } /** * 正则表达式字符串列表转正则表达式列表 * @param regs {string[]} * @returns {*} */ function regStrToReg(regs) { return regs.map(value => { return new RegExp(value); }); } /** * 校验正则表达式是否匹配内容 * @param regs {RegExp[]} * @param content {string} * @returns {boolean} */ function checkRegMatchStr(regs, content) { if (!content || !regs) { throw new Error("参数不能为空"); } for (var i = 0; i < regs.length; i++) { if (regs[i].test(content)) { return true; } } return false; } function convertPathToAccessible(path, currentPath) { var url = new URL(path, currentPath); return url.href; } /** * 防抖 * @param func {function} 回调函数 * @param wait 等待时间(ms) * @returns {(function(): void)|*} */ function debounce(func, wait) { // 定时器变量 var timeout; return function () { // 每次触发 scroll handler 时先清除定时器 clearTimeout(timeout); // 指定 xx ms 后触发真正想进行的操作 handler timeout = setTimeout(func, wait); }; }; /** * 节流 * @param func {function} 回调函数 * @param wait 延迟执行时间(ms) * @param mustRun 必须执行时间(ms) * @returns {(function(): void)|*} */ function throttle(func, wait, mustRun) { var timeout, startTime = new Date(); return function () { var context = this, args = arguments, curTime = new Date(); clearTimeout(timeout); // 如果达到了规定的触发时间间隔,触发 handler if (curTime - startTime >= mustRun) { func.apply(context, args); startTime = curTime; // 没达到触发间隔,重新设定定时器 } else { timeout = setTimeout(func, wait); } }; }; /** * @param patternStr {string} * @returns {RegExp} */ function toURlPattern(patternStr) { return new RegExp('^' + patternStr .replace(/\*/g, '.*') .replace(/\//g, '\\/')); } /** * 调用油猴脚本发送请求 * @param method {string} 请求方式 * @param url {string} 请求地址 * @returns {Promise<unknown>} */ function httpRequest(method, url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: method, url: url, onload: function (response) { resolve(response.responseText); }, onerror: function (error) { reject(error); } }); }); } /** * 深拷贝对象 * @param obj {Object} * @returns {Object} */ function deepCopy(obj) { if (typeof obj !== 'object' || obj === null) { return obj; // 如果是基本数据类型或 null,则直接返回 } let copy; if (Array.isArray(obj)) { copy = []; for (let i = 0; i < obj.length; i++) { copy[i] = deepCopy(obj[i]); // 递归复制数组中的每个元素 } } else { copy = {}; for (let key in obj) { if (obj.hasOwnProperty(key)) { copy[key] = deepCopy(obj[key]); // 递归复制对象中的每个属性 } } } return copy; } /** * 添加缺少的属性 * @param target {Object} * @param source {Object} */ function addMissingProperties(target, source) { for (let key in source) { if (!target.hasOwnProperty(key)) { target[key] = source[key]; } } } })();