// ==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 1024路径匹配
// @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: "1024",
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: "1024",
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: "1024",
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 postResult = await httpRequest("GET", link);
if (!postResult) {
throw new Error("请求文章错误");
}
var htmlDivElement = document.createElement("div");
// 初始化图片区域
htmlDivElement.appendChild(getImagesDiv(typeHandler, typeSetting, link, postResult));
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];
}
}
}
})();