// ==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;
}
`);
// todo 添加extend继承的功能
let settings = [
{
// 类型名称
name: "discuz",
// 文章列表选择器
articleListSelector: 'tbody[id^="normalthread_"]',
// 文章链接a标签选择器
articleLinkSelector: '.icn a',
// 文章详情页中文章主体选择器
postContentSelector: 'div[id^="post_"] .plc',
// 需要忽略的图片的正则表达式字符串
ignoreImageRegs: [
"/uc_server/images/*",
"static/image/*",
"/uc_server/data/avatar/*"
],
// 列表最大展示图片数量(todo 支持的有点问题,反正别写太大了)
maxShowLimit: 3,
// 是否启用懒加载
lazyLoad: true,
// 找到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',
ignoreImageRegs: [
"images/post/smile/*",
],
maxShowLimit: 3,
lazyLoad: true,
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',
ignoreImageRegs: [],
maxShowLimit: 3,
lazyLoad: true,
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*"
]
}
]
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);
showImageInList(activeSettingName);
}
/**
* 根据类型来处理列表链接图片的展示
* @param type {string} 类型
*/
function showImageInList(type) {
let setting = getSettingByType(type);
let articleListElement = document.querySelectorAll(setting.articleListSelector);
articleListElement.forEach(element => {
if (setting.lazyLoad) {
lazyLoadImageInList(element, setting);
} else {
loadImageInList(element, setting);
}
})
}
/**
* 懒加载列表链接里面的图片
* @param element {Element}
* @param setting {Object}
*/
function lazyLoadImageInList(element, setting) {
// 注册(不可用)滚动事件,实现懒加载。同时通过节流来避免重复加载
window.addEventListener('scroll', throttle(function () {
const targetElementRect = element.getBoundingClientRect();
if (targetElementRect.top < window.innerHeight && !element.getAttribute("imageLoad")) {
handleSingleArticle(element, setting).then(toAppendElement => {
if (!element.getAttribute("imageLoad")) {
insertElementBelow(element, toAppendElement);
element.setAttribute("imageLoad", "true");
}
})
}
}, 200, 500));
}
/**
* 实时加载列表链接里面的图片
* @param element {Element}
* @param setting {Object}
*/
function loadImageInList(element, setting) {
handleSingleArticle(element, setting).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
* @returns {{ignoreImageRegs: string[], initElementDecorator: (function(*=): HTMLTableSectionElement), name: string, postImageLinkCallback: ((function(*): (string))|*), postContentSelector: string, articleListSelector: string, maxShowLimit: number, lazyLoad: boolean} | {ignoreImageRegs: *[], initElementDecorator: (function(*=): HTMLTableRowElement), name: string, postImageLinkCallback: (function(*): string), postContentSelector: string, articleListSelector: string, maxShowLimit: number, lazyLoad: boolean}}
*/
function getSettingByType(type) {
let setting = settings.find(value => {
return value.name === type;
});
if (setting == null) {
throw new Error("不支持的类型");
}
return setting;
}
/**
* 处理单个文章,返回最后需要拼接的element
* @param element {Element}
* @param setting {Object}
* @returns {Promise<void>}
*/
async function handleSingleArticle(element, setting) {
if (!element) {
throw new Error("参数不能为空");
}
let link = findActualArticleLinkBySelector(setting.articleLinkSelector, element);
let postResult = await httpRequest("GET", link);
if (!postResult) {
throw new Error("请求文章错误");
}
var htmlDivElement = document.createElement("div");
// 初始化图片区域
htmlDivElement.appendChild(getImagesDiv(setting, link, postResult));
// todo 添加自定义元素
return setting.initElementDecorator(htmlDivElement);
}
/**
* 根据设置,解析文章中的图片,并且生成html div
* @param setting {Object}
* @param content {string}
* @param postLink {string}
* @returns {HTMLDivElement}
*/
function getImagesDiv(setting, postLink, content) {
let images = parsePostImages(setting, postLink, content);
if (setting.maxShowLimit && setting.maxShowLimit > 0) {
images = images.slice(0, setting.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 setting {Object} 类型设置
* @param postLink {string} 文章链接
* @param postDetails {string} 文章的内容字符串(解析前的html)
* @returns {*[]}
*/
function parsePostImages(setting, postLink, postDetails) {
let images = [];
let content = new DOMParser().parseFromString(postDetails, "text/html");
if (!content) {
return images;
}
let postContentSelector = setting.postContentSelector;
let postContent = content.querySelector(postContentSelector);
if (!postContent) {
console.log("无法匹配到文章主体,请确认选择器是否正确,并确认点击链接进去是否能正常访问内容,匹配失败的链接为:" + postLink);
return images;
}
let ignoreImageRegs = regStrToReg(setting.ignoreImageRegs);
let imageElements = postContent.querySelectorAll('img');
imageElements.forEach(imageElement => {
let imageLink = setting.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);
}
});
});
}
})();