Sleazy Fork is available in English.
Unlock lpsg video access(in thread and in gallery).It also includes other useful features I like, such as hiding plain text posts to skip boring bickering, automatically displaying attached photos, etc. Download and try it if you're interested.
// ==UserScript==
// @name unlock lpsg videos
// @namespace MBing
// @version 8.7
// @description Unlock lpsg video access(in thread and in gallery).It also includes other useful features I like, such as hiding plain text posts to skip boring bickering, automatically displaying attached photos, etc. Download and try it if you're interested.
// @author MBing
// @connect cdn-videos.lpsg.com
// @match https://www.lpsg.com/threads*
// @match https://www.lpsg.com/gallery*
// @icon https://www.google.com/s2/favicons?sz=64&domain=lpsg.com
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ==========================================
// 第一部分:配置和状态管理
// ==========================================
/**
* CONFIG 对象:集中存放所有固定的数值配置
* 这样以后要改数字,只需要改这里一处
*/
//const VIDEO_EXTENSIONS = [ "m4a", "ogv" ];
const VIDEO_EXTENSIONS = [ "mp4", "mov", "m4v", "webm", "mpeg", "mpg", "m4a", "ogv" ];
const CONFIG = {
DEFAULT_VOLUME: 0.5, // 默认视频音量 50%
AUTO_SWITCH_INTERVAL: 2500, // 视频格式自动切换间隔(毫秒)
AUTO_SWITCH_INTERVAL_SLOW: 5000,// 视频多的时候用更长的间隔
VIDEO_THRESHOLD: 5, // 超过这个数量的视频算"多"
SCROLL_OFFSET: 800, // 距离底部多少像素时停止滚动
NEXT_PAGE_DELAY: 1500, // 自动跳转到下一页前的等待时间
AUTO_START_DELAY: 2000, // 页面加载后自动开始滚动的延迟
VIDEO_WIDTH: '800px', // 视频显示宽度
VIDEO_MAX_HEIGHT: '750px' // 视频最大高度
};
/**
* state 对象:存放运行时会变化的值
* 用对象包起来是为了让函数内部能修改这些值
*/
const state = {
initDone:false,//脚本初始化是否结束
shouldContinue: true, // 视频格式自动切换是否继续
volume: CONFIG.DEFAULT_VOLUME, // 当前音量
autoSwitchInterval: CONFIG.AUTO_SWITCH_INTERVAL // 当前切换间隔
};
// 从 localStorage 读取之前保存的音量设置
// 如果用户之前改过,就用用户的;没改过就用默认的 0.05
const savedVolume = localStorage.getItem('default-volume-key');
if (savedVolume !== null) {
state.volume = Number(savedVolume)/100;
}
// ==========================================
// 第二部分:通用工具函数(各种小帮手)
// ==========================================
/**
* 从 localStorage 读取布尔值
* @param {string} key - 存储的键名
* @returns {boolean} - true 或 false
*
* 为什么用 'true' 字符串判断?
* 因为 localStorage 只能存字符串,存 true 会变成 "true"
*/
function getStorageBool(key) {
return localStorage.getItem(key) === 'true';
}
/**
* 保存布尔值到 localStorage
* @param {string} key - 存储的键名
* @param {boolean} value - 要保存的值
*/
function setStorageBool(key, value) {
localStorage.setItem(key, value.toString());
}
/**
* 修改URL的文件扩展名
* @param {string} url - 原始URL
* @param {string} newExt - 新扩展名(不含点)
* @returns {string} - 修改后的URL
*/
function setUrlFileExtension(url, newExt) {
// 移除旧的扩展名,添加新的
// 使用全局 VIDEO_EXTENSIONS 构建正则,添加图片格式
const extPattern = VIDEO_EXTENSIONS.join('|');
// 动态构建正则表达式,匹配所有已知扩展名
return url.replace(new RegExp(`\\.(${extPattern})$`, 'i'), `.${newExt}`);
}
/**
* 用HEAD请求探测视频URL是否存在
* @param {string} url - 要探测的URL
* @returns {Promise<boolean>} - 是否存在
*/
function probeVideoUrl(url) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "HEAD",
url: url,
headers: { "Accept": "video/*" },
onload: (response) => {
const isSuccess = response.status >= 200 && response.status < 300;
if (isSuccess) {
console.log(`链接测试成功${url}`);
}
resolve(isSuccess);
},
onerror: () => resolve(false),
ontimeout: () => resolve(false)
});
});
}
/**
* 异步查找有效的视频URL
* 先尝试从图片URL构造,逐个格式探测
* @param {string} previewImgSrc - 预览图片的src
* @returns {Promise<string|null>} - 有效的视频URL或null
*/
async function findVideoUrl(previewImgSrc) {
// 检查URL是否符合预期格式
if (!previewImgSrc.includes("/attachments/posters/") &&
!previewImgSrc.includes("/lsvideo/thumbnails/")) {
console.warn(`预览图片链接不符合规则: ${previewImgSrc}`);
return null;
}
// 构造基础视频URL(替换路径)
let videoBaseUrl = previewImgSrc
.replace('/attachments/posters', '/video')
.replace('/lsvideo/thumbnails', '/lsvideo/videos')
.replace('.jpg', '.mp4'); // 先给个默认后缀,后面会替换
// 逐个格式探测
for (const ext of VIDEO_EXTENSIONS) {
const potentialVideoUrl = setUrlFileExtension(videoBaseUrl, ext);
if (await probeVideoUrl(potentialVideoUrl)) {
return potentialVideoUrl;
}
}
console.error(`找不到对应有效的视频链接: ${previewImgSrc}`);
return null;
}
// ==========================================
// 第三部分:UI 创建工具(核心封装)
// ==========================================
/**
* 【核心函数】创建通用按钮(无 localStorage)
* 用于创建各种功能按钮,风格与 createStorageCheckbox 保持一致
*
* @param {Object} options - 配置选项
* @param {string} options.id - 按钮的 ID
* @param {string} options.text - 按钮显示的文字
* @param {Function} options.onClick - 点击回调函数
* @param {string} [options.tip=''] - 悬浮提示文字
* @param {HTMLElement} [options.container] - 放到哪个容器里(默认是左上角的控制面板)
* @returns {HTMLButtonElement} - 创建好的按钮元素
*/
function createActionButton(options) {
const {
id,
text,
html, // 【新增】可选,直接传 HTML
onClick,
tip = '',
container = document.getElementById('custom-control-div')
} = options;
// -------- 第 1 步:创建 DOM --------
// 创建包装器 div,与其他控件保持一致的布局
const wrapper = document.createElement('div');
wrapper.style.marginLeft = '5px';
wrapper.style.marginTop = '2px'; // 与其他元素间距一致
wrapper.style.marginBottom = '7px'; // 底部也加一点,平衡视觉
// 创建按钮元素
const button = document.createElement('button');
button.id = id;
if (html) {
button.innerHTML = html;
} else {
button.textContent = text;
}
// 【修改】按钮样式:更透明背景、fit-content 宽度、左对齐、小字体
button.style.cssText = `
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.1);
color: white;
border: 1px solid rgba(255,255,255,0.3);
padding: 1px 5px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
width: fit-content;
transition: all 0.2s;
`;
// 添加提示
if (tip) {
button.title = tip;
}
// 组装并添加到页面
wrapper.appendChild(button);
container.appendChild(wrapper);
// -------- 第 2 步:绑定事件 --------
// 悬停效果
button.addEventListener('mouseenter', () => {
button.style.background = 'rgba(255,255,255,0.2)';
});
button.addEventListener('mouseleave', () => {
button.style.background = 'rgba(255,255,255,0.1)';
});
// 点击事件
button.addEventListener('click', (e) => {
if (typeof onClick === 'function') {
onClick(e, button);
}
});
return button;
}
/**
* 【核心函数】创建带 localStorage 状态的复选框
*
* 这个函数替代了原来 10 多个重复的创建函数
* 统一处理:创建 DOM → 读取状态 → 事件监听 → 保存状态
*
* @param {Object} options - 配置选项
* @param {string} options.id - 复选框的 ID(也是 localStorage 的 key)
* @param {string} options.labelText - 标签显示的文字
* @param {boolean} [options.defaultValue=false] - 默认是否勾选(第一次用时的状态)
* @param {Function} [options.onChange] - 勾选状态变化时的回调函数(可选)
* @param {HTMLElement} [options.container] - 放到哪个容器里(默认是左上角的控制面板)
* @param {HTMLElement} [options.insertBefore] - 插入到哪个元素前面(可选,用于调整顺序)
* @returns {HTMLInputElement} - 创建好的复选框元素
*/
function createStorageCheckbox(options) {
// 从 options 对象里解构出各个参数
// 带 = 号的是默认值,调用时没传就用这个
const {
id, // 复选框 ID,也是 localStorage 的 key
labelText, // 标签文字
defaultValue = false, // 默认不勾选
onChange, // 变化回调(可选)
container = document.getElementById('custom-control-div'), // 默认容器
insertBefore = null, // 默认不指定插入位置,就往后追加
tip = '' // 提示文字,默认空
} = options;
// -------- 第 1 步:创建 DOM 元素 --------
// 创建一个 div 作为包装器,把复选框和标签包在一起
const wrapper = document.createElement('div');
// 创建复选框 input 元素
const checkbox = document.createElement('input');
checkbox.type = 'checkbox'; // 类型是复选框
checkbox.id = id; // 设置 ID,用于 label 关联和后续查找
checkbox.style.marginLeft = '5px'; // 【保留原始样式】左边距 5px
// 创建标签 label 元素
const label = document.createElement('label');
label.htmlFor = id; // 关联到对应的复选框(点击标签也能 toggle)
label.textContent = labelText; // 显示的文字
//如果有提示文字,添加到 label
if (tip) {
label.title = tip; // 浏览器默认悬浮提示
checkbox.title = tip; // checkbox 也加上
}
// 把复选框和标签装进 wrapper
wrapper.appendChild(checkbox);
wrapper.appendChild(label);
// 决定插入到哪里
if (insertBefore && insertBefore.parentNode) {
// 如果指定了 insertBefore,就插在它前面
insertBefore.parentNode.insertBefore(wrapper, insertBefore);
} else {
// 否则追加到 container 末尾
container.appendChild(wrapper);
}
// -------- 第 2 步:初始化状态 --------
// 从 localStorage 读取之前保存的状态
const savedState = localStorage.getItem(id);
const initialState = savedState !== null ? savedState === 'true' : defaultValue;
// 设置复选框状态
checkbox.checked = initialState;
// 如果没有保存过,写入默认值
if (savedState === null) {
localStorage.setItem(id, defaultValue.toString());
}
// 【新增】初始化时执行一次 onChange
if (typeof onChange === 'function') {
onChange(initialState, checkbox);
}
// -------- 第 3 步:绑定事件 --------
// 当用户点击复选框时触发
checkbox.addEventListener('change', () => {
// 【关键】读取复选框当前的实际状态(true/false)
const currentState = checkbox.checked;
// 把这个状态保存到 localStorage(转成字符串存)
localStorage.setItem(id, currentState.toString());
// 如果调用时传了 onChange 回调函数,就执行它
// 把当前状态和复选框元素都传过去,方便外部使用
if (typeof onChange === 'function') {
onChange(currentState, checkbox);
}
});
// 返回创建好的复选框元素,外部可以用变量接收后操作
return checkbox;
}
/**
* 【核心函数】创建带 localStorage 的文本输入框
*
* 用于:音量设置、滚动速度、图片最大宽度等需要输入数字的场景
*
* @param {Object} options - 配置选项
* @param {string} options.id - 输入框 ID,也是 localStorage 的 key
* @param {string} options.labelText - 标签文字
* @param {string} options.defaultValue - 默认值(字符串)
* @param {Function} options.validator - 验证函数,接收输入值,返回 true/false
* @param {Function} [options.onValid] - 验证通过后的回调(可选)
* @returns {HTMLInputElement} - 创建好的输入框元素
*/
function createStorageInput(options) {
const {
id,
labelText,
defaultValue,
validator, // 验证函数:判断输入是否合法
onValid, // 验证通过后的回调(可选)
tip = ''//悬浮提示
} = options;
// -------- 第 1 步:创建 DOM --------
const wrapper = document.createElement('div');
// 添加 flex 布局,让 checkbox 和 label-input 在同一行
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
// 【新增】创建控制复选框
const checkboxId = `checkbox-${id}`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = checkboxId;
checkbox.style.marginLeft = '5px';
// 创建标签
const label = document.createElement('label');
label.textContent = labelText;
//label.style.marginLeft = '5px'; // 【保留原始样式】
// 创建输入框
const input = document.createElement('input');
input.id = id;
input.type = 'text'; // 文本输入
input.style.width = '60px';
input.style.textOverflow = 'ellipsis'; // 超出显示省略号...
input.style.marginLeft = '5px';
input.style.background = 'transparent'; // 透明背景
input.style.border = 'none'; // 无边框
input.style.borderBottom = '1px solid #fff'; // 底部白线
input.style.outline = 'none'; // 无焦点边框
input.style.color = '#4f4e4e'; // 深灰色文字
input.style.fontWeight = 'bold';
input.style.textAlign = 'center'; // 文字居中
// 添加提示
if (tip) {
checkbox.title = tip;
label.title = tip;
input.title = tip;
}
// 组装并添加到页面
wrapper.appendChild(checkbox); // 【新增】先加 checkbox
wrapper.appendChild(label);
wrapper.appendChild(input);
document.getElementById('custom-control-div').appendChild(wrapper);
// -------- 第 2 步:绑定事件 --------
// 【新增】复选框 change 事件
checkbox.addEventListener('change', () => {
// 保存复选框状态到 localStorage
localStorage.setItem(checkboxId, checkbox.checked.toString());
// 触发 input 的 change 事件
input.dispatchEvent(new Event('change', {
bubbles: true,
cancelable: true
}));
});
// 当输入框内容变化且失去焦点时触发(比 input 事件更适合)
input.addEventListener('change', () => {
// 去掉首尾空格
const value = input.value.trim();
// 用传入的验证函数检查输入是否合法
if (validator(value)) {
// 合法:保存到 localStorage
localStorage.setItem(id, value);
// 执行验证通过后的回调(如果有)
if (typeof onValid === 'function') {
onValid(value, input);
}
} else {
// 不合法:恢复默认值
localStorage.setItem(id, defaultValue);
input.value = defaultValue;
}
});
// -------- 第 3 步:初始化 --------
// 【新增】初始化复选框状态
const savedCheckboxState = localStorage.getItem(checkboxId);
checkbox.checked = savedCheckboxState !== null ? savedCheckboxState === 'true' : false;
// 如果没有保存过,写入默认 false(未勾选)
if (savedCheckboxState === null) {
localStorage.setItem(checkboxId, 'false');
};
// 从 localStorage 读取值,没有就用默认值
input.value = localStorage.getItem(id) || defaultValue;
// 主动触发一次 change 事件,让验证和 onValid 执行
// 这样页面加载时就能应用保存的设置
input.dispatchEvent(new Event('change', {
bubbles: true, // 事件冒泡
cancelable: true // 可以取消
}));
return input;
}
/**
* 创建黄色标题栏标签
* 用于分组显示:Video Setting、User Info Area 等
* @param {string} text - 显示的文字
* @param {string} [color='#e8d68e'] - 标签颜色(可选,默认黄色)
*/
function createSectionLabel(text, color = '#e8d68e') {
const wrapper = document.createElement('div');
const label = document.createElement('label');
label.textContent = text;
label.style.color = color; // 使用传入的颜色或默认值
wrapper.appendChild(label);
document.getElementById('custom-control-div').appendChild(wrapper);
}
// ==========================================
// 第四部分:具体功能实现(调用上面的工具函数)
// ==========================================
/**
* 创建 TTS 优化按钮
*/
function createTTSOptimizeButton() {
createActionButton({
id: 'tts-optimize-btn',
html: `
<svg width="14" height="14" viewBox="2 2 20 20" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<span>Optimize for TTS</span>
`,
tip: 'Replace words that TTS struggles with (eh, uh, lol, etc.)',
onClick: () => {
const result = optimizeForTTS();
if (result.totalReplacements === 0) {
alert('No TTS-unfriendly words found to replace.');
} else {
// 显示结果弹窗
showTTSResultModal(result);
}
}
});
}
/**
* 创建左上角的控制面板
* 点击图标按钮切换显示/隐藏,附带关闭按钮
*/
function createControlDiv() {
// 外层容器:保留你原本的样式
const foldableDiv = document.createElement('div');
foldableDiv.id = 'foldable-div';
foldableDiv.style.cssText = `
position: fixed;
top: 5px;
left: 5px;
max-width: 300px;
z-index: 999;
padding: 8px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 14px;
font-weight: bold;
background-color: #6ba65e;
color: white;
`;
// 【新增】按钮容器:放图标按钮和关闭按钮
const btnContainer = document.createElement('div');
btnContainer.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
`;
// 【新增】图标按钮(替代原来的文字提示)
const toggleBtn = document.createElement('button');
toggleBtn.id = 'control-toggle-btn';
toggleBtn.title = 'Extra Enhancements';
toggleBtn.style.cssText = `
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
`;
// SVG 工具图标
toggleBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
`;
// 【新增】关闭按钮(展开时显示)
const closeBtn = document.createElement('button');
closeBtn.id = 'control-close-btn';
closeBtn.style.cssText = `
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 0;
display: none; /* 默认隐藏 */
align-items: center;
margin-left: 8px;
`;
// SVG 关闭图标(X)
closeBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
// 组装按钮容器
btnContainer.appendChild(toggleBtn);
btnContainer.appendChild(closeBtn);
foldableDiv.appendChild(btnContainer);
// 内部容器:保留你原本的样式,默认隐藏
const controlDiv = document.createElement('div');
controlDiv.id = 'custom-control-div';
controlDiv.style.display = 'none';
controlDiv.style.marginTop = '8px'; // 和按钮间距
foldableDiv.appendChild(controlDiv);
// 添加到页面
document.body.appendChild(foldableDiv);
// 【修改】点击图标按钮切换显示
let isExpanded = false;
toggleBtn.addEventListener('click', () => {
isExpanded = !isExpanded;
if (isExpanded) {
controlDiv.style.display = 'block';
closeBtn.style.display = 'flex'; // 显示关闭按钮
} else {
controlDiv.style.display = 'none';
closeBtn.style.display = 'none'; // 隐藏关闭按钮
}
});
// 【新增】点击关闭按钮直接关闭
closeBtn.addEventListener('click', () => {
isExpanded = false;
controlDiv.style.display = 'none';
closeBtn.style.display = 'none';
});
// 点击页面其他位置收起菜单
document.addEventListener('click', (e) => {
// 如果菜单未展开,不处理
if (!isExpanded) return;
// 如果点击的是菜单内部(foldableDiv 或其子元素),不处理
if (foldableDiv.contains(e.target)) return;
// 否则收起菜单
isExpanded = false;
controlDiv.style.display = 'none';
closeBtn.style.display = 'none';
});
}
/**
* 创建音量输入框
* 范围 0-1,比如 0.1 表示 10% 音量
*/
function createVolumeInput() {
const inputId = 'default-volume-key'; // 外部变量
createStorageInput({
id: inputId, // 【统一】ID 即 storageKey
labelText: 'Default Volume %:',
defaultValue: '10',
// 验证:必须是数字,且在 0-100 之间
validator: (v) => !isNaN(v) && Number(v) >= 0 && Number(v) <= 100 && v!=="",
// 验证通过后:更新全局音量状态
onValid: (v) => {
if(getStorageBool(`checkbox-${inputId}`)){
state.volume = Number(v)/100;
}else{
state.volume=CONFIG.DEFAULT_VOLUME;
}
},
tip: 'The default volume of all videos on this page, enter a number between 0 and 100.'
});
}
/**
* 创建滚动速度输入框
* 必须是正数
*/
function createScrollSpeedInput() {
createStorageInput({
id: 'scroll-speed',
labelText: 'Scroll Speed:',
defaultValue: '5',
validator: (v) => !isNaN(v) && Number(v) > 0,
tip: 'Control the automatic scrolling speed of the page.'
});
}
/**
* 创建图片最大宽度输入框
* 空字符串表示不限制,或者输入正数
*/
function createMaxWidthInput() {
const inputId = 'max-img-width'; // 外部变量
createStorageInput({
id: inputId,
labelText: 'Pic Max Width:',
defaultValue: '',
tip: 'Control the maximum width of images on the page.',
// 验证:空字符串 或 正数
validator: (v) => v === '' || (!isNaN(v) && Number(v) > 0),
// 验证通过后:应用到所有图片
onValid: (v) => {
const images = document.querySelectorAll(
'.message-cell.message-cell--main img:not(.smilie)'
);
images.forEach(img => {
// 如果有值就设置 maxWidth,否则清空
img.style.maxWidth = v&&getStorageBool(`checkbox-${inputId}`) ? `${v}px` : '';
//img.style.width = v ? `100%` : '';
});
}
});
}
/**
* 创建"自动滚动"复选框
* 控制页面加载后是否自动开始滚动
*/
function createAutoscrollCheckbox() {
createStorageCheckbox({
id: 'autoscroll-key',
labelText: 'Auto Scroll On Load',
defaultValue: false,
tip: 'The page will automatically scroll down after it finishes loading.'
});
}
/**
* 创建"滚动到底自动跳转下一页"复选框
*/
function createAutoscrollJumpCheckbox() {
createStorageCheckbox({
id: 'autoscroll-jump-key',
labelText: 'Auto Jump To Next Page',
defaultValue: false,
tip: 'Automatically switch to the next page when scrolling to the bottom of the page.'
});
}
/**
* 创建"向后翻页"复选框
* 勾选后自动跳转会变成向前翻(看历史内容)
*/
function createAutoJumpBackwardCheckbox() {
createStorageCheckbox({
id: 'auto-jump-backward',
labelText: 'Jump To Prev Page',
defaultValue: false,
tip: 'Control whether the page navigates to the previous or next page.'
});
}
/**
* 创建"自动跳过纯文字页面"复选框
* 如果页面没有图片/视频,自动跳转到下一页
*/
function createAutoJumpTextOnlyCheckbox() {
const checkbox = createStorageCheckbox({
id: 'auto-jump-text-only-page',
labelText: 'Autoskip Textonly Pages',
defaultValue: false,
tip: 'Automatically redirect to the next page when the page contains only text.'
});
// 【保留原始样式】设置容器 ID 和左边距
checkbox.parentElement.id = 'auto-jump-text-only-page-container';
checkbox.parentElement.style.marginLeft = '10px';
checkbox.parentElement.style.display = getStorageBool( 'hide-text-only-post') ? '' : 'none';
}
/**
* 创建"显示自动滚动按钮"复选框
* 用于控制右下角悬浮滚动按钮的显示/隐藏
*/
function createShowScrollButtonCheckbox() {
createStorageCheckbox({
id: 'show-scroll-button',
labelText: 'Show Scroll Button',
defaultValue: false,
tip: 'Display the button for automatic scroll down.',
onChange: (checked) => {
const btn = document.getElementById('auto-scroll-btn');
if (!btn) return;
// 控制按钮显示/隐藏
btn.style.display = checked ? 'flex' : 'none';
}
});
}
/**
* 创建"隐藏纯文字帖子"复选框
* 把没有图片/视频的帖子隐藏掉,只看有料的
*/
function createHideTextOnlyCheckbox() {
createStorageCheckbox({
id: 'hide-text-only-post',
labelText: 'Hide Text-Only Post',
defaultValue: false,
tip: 'Hide posts containing only text.',
// 变化时:勾选就隐藏,取消就显示
onChange: (checked) => {
if (checked) {
// 【互斥】取消用户筛选
const filterCheckbox = document.getElementById('checkbox-filter-by-user');
if (filterCheckbox && filterCheckbox.checked) {
filterCheckbox.click(); // 模拟点击取消勾选
}
hideTextOnlyPosts();
} else {
if(state.initDone){//在初始化的时候,如果没有勾选,不需要执行showall
showAllPosts();
}
}
},
});
}
/**
* 创建"强制在新窗口打开"复选框
* 在gallery浏览页面把链接改成强制新窗口打开
*/
function createForceNewWindowCheckbox() {
createStorageCheckbox({
id: 'force-new-window',
labelText: 'Force Open In New Window',
defaultValue: false,
tip: 'Force links in media page to open in new window.',
onChange: (checked) => {
if (checked) {
state.initDone && rewriteGalleryLinks();
} else {
state.initDone && location.reload();
}
},
});
}
/**
* 创建"隐藏用户信息"复选框
* 隐藏头像、等级等,页面更清爽
*/
function createHideUserInfoCheckbox() {
createStorageCheckbox({
id: 'hide-user-info',
labelText: 'Hide Extra User Info',
defaultValue: false,
tip: 'Hide user details.',
onChange: (checked) => {
// 找到所有用户信息区域,显示或隐藏
document.querySelectorAll('.message-userExtras').forEach(el => {
el.style.display = checked ? 'none' : '';
});
}
});
}
/**
* 创建"每行一张图片"复选框
* 勾选后图片独占一行,不并排显示
*/
function createOnePicPerLineCheckbox() {
// 两种显示模式
const DISPLAY = {
ONE: 'block', // 独占一行
MULTI: 'inline-block' // 并排显示
};
// 需要修改的图片选择器
const IMG_SELECTORS = '.bbImageWrapper, .inserted-img, .bbImage, .bbMediaWrapper';
createStorageCheckbox({
id: 'one-pic-per-line',
labelText: 'One Pic Per Line',
defaultValue: false,
tip: 'Only one photo can be displayed per line.',
onChange: (checked) => {
const display = checked ? DISPLAY.ONE : DISPLAY.MULTI;
document.querySelectorAll(IMG_SELECTORS).forEach(el => {
el.style.display = display;
});
}
});
}
/**
* 创建"只看某用户帖子"输入框
* 输入用户名,只显示该用户的帖子,空则显示全部
*/
function createFilterByUserInput() {
const inputId = 'filter-by-user'; // 外部变量
createStorageInput({
id: inputId,
labelText: 'Filter Post By User:',
defaultValue: '',
tip: 'Enter username to show only their posts. Leave empty to show all.',
// 验证:任意字符串都合法(包括空字符串)
validator: (v) => true,
// 验证通过后:应用过滤
onValid: (v) => {
// 【互斥】如果勾选了筛选,取消隐藏纯文字
const userFilterCheckbox = document.getElementById(`checkbox-${inputId}`);
const hideTextCheckbox = document.getElementById('hide-text-only-post');
if (hideTextCheckbox && hideTextCheckbox.checked && userFilterCheckbox && userFilterCheckbox.checked) {
hideTextCheckbox.click(); // 模拟点击取消勾选
}
const username = userFilterCheckbox?.checked ? v.trim() : "";
//初始化结束后,无论有没勾选都调用,初始化没结束,只有勾选才调用(因为如果调用,就会覆盖掉hide-text-only的操作)
if(state.initDone||(!state.initDone && userFilterCheckbox?.checked)){
filterPostsByUser(username);
}
}
});
}
/**
* 创建"放大附件图片"复选框
* 把附件里的小图变成大图直接显示
*/
function createEnlargeAttachmentCheckbox() {
createStorageCheckbox({
id: 'enlarge-attachment-pics',
labelText: 'Enlarge Attachment Pics',
defaultValue: false,
tip: 'Enlarge the images in the attachment.',
onChange: (checked) => {
if (checked) {
displayBigPreview();
} else {
hideBigPreview();
}
}
});
}
/**
* 创建"视频网页内全屏"复选框
* 播放时视频占满网页可视区,暂停/结束时恢复
*/
function createVideoAutoFullscreenCheckbox() {
createStorageCheckbox({
id: 'video-auto-fullscreen',
labelText: 'Auto Fullscreen On Play',
defaultValue: false,
tip: 'Automatically full screen when playing videos',
onChange: (checked) => {
bindVideoFullscreenEvents(checked);
}
});
}
/**
* 创建文本提取按钮
* 【修改】使用新的 createActionButton 函数创建
* 放在 User Related 分组下
*/
function createTextExtractButton() {
createActionButton({
id: 'text-extract-btn',
html: `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Extract Text Posts</span>
`,
tip: 'Extract text content from posts. Respects user filter if enabled.',
onClick: () => {
const text = extractPostsText();
if (text) {
showExtractOptions(text);
}
}
});
}
// ==========================================
// 第五部分:具体业务逻辑函数
// ==========================================
// ==========================================
// 新增:首次使用引导弹窗
// ==========================================
/**
* 显示一次性欢迎弹窗
* 使用 localStorage 记录是否已显示
*/
function showFirstTimeWelcome() {
const WELCOME_KEY = 'lpsg-script-welcome-shown';
// 检查是否已显示过
if (localStorage.getItem(WELCOME_KEY) === 'true') {
return; // 已显示过,直接返回
}
// 创建弹窗
const modal = document.createElement('div');
modal.id = 'first-time-welcome';
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #6ba65e;
color: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 100001;
max-width: 500px;
width: 85%;
font-family: inherit;
text-align: center;
`;
modal.innerHTML = `
<div style="font-size: 32px; margin-bottom: 12px;">👋</div>
<h3 style="margin: 0 0 12px 0; font-size: 18px;">Hi! This user script dose more than just unlocking video access</h3>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 1.5; opacity: 0.95;">
Click the <b>top-left button</b> to explore extra features:<br>
better pic display, auto-scroll, hide useless posts, and more.<br>
(This message will only be displayed once.)
</p>
<button id="welcome-got-it" style="
background: white;
color: #6ba65e;
border: none;
padding: 10px 24px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 14px;
">Got it!</button>
`;
// 遮罩层
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 100000;
`;
// 关闭并记录
const closeWelcome = () => {
modal.remove();
overlay.remove();
localStorage.setItem(WELCOME_KEY, 'true');
};
modal.querySelector('#welcome-got-it').addEventListener('click', closeWelcome);
overlay.addEventListener('click', closeWelcome);
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
/**
* 绑定或解绑视频全屏事件(事件委托版本)
* 监听 document 上的 play/ended 事件,自动处理动态插入的视频
*/
function bindVideoFullscreenEvents(enable) {
// 移除旧的委托监听(防止重复绑定)
document.removeEventListener('play', handleDelegatedPlay, true);
document.removeEventListener('ended', handleDelegatedEnded, true);
if (enable) {
// 使用捕获阶段监听,确保在视频自己的监听器之前执行
document.addEventListener('play', handleDelegatedPlay, true);
document.addEventListener('ended', handleDelegatedEnded, true);
}
}
/**
* 委托的 play 事件处理器
* 检查事件源是否是视频元素,且是我们替换的视频
*/
function handleDelegatedPlay(e) {
// 确保是 video 元素,且开启了自动全屏功能
if (e.target.tagName !== 'VIDEO') return;
// 检查全局开关(从 localStorage 实时读取)
if (!getStorageBool('video-auto-fullscreen')) return;
// 调用原有的全屏处理逻辑
handleVideoPlay(e);
}
/**
* 委托的 ended 事件处理器
*/
function handleDelegatedEnded(e) {
if (e.target.tagName !== 'VIDEO') return;
// 检查是否是全屏状态的视频
if (e.target.classList.contains('video-page-fullscreen')) {
handleVideoEnded(e);
}
}
/**
* 进入网页内全屏(带独立退出按钮)
*/
function handleVideoPlay(e) {
const video = e.target;
video.dataset.ready = 'true';//开始播放就把video改成ready状态
if (video.classList.contains('video-page-fullscreen')) return;
// 保存原始样式
video._originalStyle = {
position: video.style.position,
top: video.style.top,
left: video.style.left,
width: video.style.width,
height: video.style.height,
zIndex: video.style.zIndex,
maxWidth: video.style.maxWidth,
maxHeight: video.style.maxHeight
};
// 【新增】创建退出按钮
const exitBtn = document.createElement('button');
exitBtn.textContent = '✕';
exitBtn.id = 'video-exit-fullscreen-btn';
exitBtn.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1000000;
padding: 8px 16px;
background: rgba(0,0,0,0.7);
color: white;
border: 1px solid white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
exitBtn.onclick = () => {
video.pause();
exitVideoFullscreen(video);
};
document.body.appendChild(exitBtn);
// 应用全屏样式(原来的代码)
video.classList.add('video-page-fullscreen');
video.style.cssText = `
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 999999 !important;
max-width: none !important;
max-height: none !important;
object-fit: contain !important;
background: black !important;
`;
// 【删除】原来的 showFullscreenTip 调用
}
/**
* 播放结束退出全屏
*/
function handleVideoEnded(e) {
const video = e.target;
if (video.classList.contains('video-page-fullscreen')) {
exitVideoFullscreen(video);
}
}
/**
* 退出网页内全屏
*/
function exitVideoFullscreen(video) {
if (!video._originalStyle) return;
// 【新增】移除退出按钮
const exitBtn = document.getElementById('video-exit-fullscreen-btn');
if (exitBtn) exitBtn.remove();
video.classList.remove('video-page-fullscreen');
// 【修改】恢复原始样式(简化写法)
Object.assign(video.style, video._originalStyle);
delete video._originalStyle;
// 【删除】原来的 hideFullscreenTip 调用
}
/**
* 显示附件大图
* 在附件区域直接插入大图,不用点击预览
*/
function displayBigPreview() {
// 先检查是否已经有插入过的大图了
const existing = document.querySelectorAll('img.inserted-img');
if (existing.length > 0) {
// 有的话,显示出来就行
existing.forEach(img => img.style.display = '');
} else {
// 没有的话,需要创建大图插入
document.querySelectorAll('section.message-attachments').forEach(section => {
section.querySelectorAll('a.file-preview.js-lbImage').forEach(link => {
const img = document.createElement('img');
img.src = link.href; // 用原图链接
img.alt = 'Inserted Image';
// 如果设置了最大宽度,应用上去
const maxWidth = localStorage.getItem('max-img-width');
if (maxWidth) {
img.style.maxWidth = `${maxWidth}px`;
}
img.style.height = 'auto';
img.style.margin = '5px';
if (getStorageBool('one-pic-per-line')) img.style.display = 'block';
img.classList.add('inserted-img'); // 标记类名,方便后续操作
section.appendChild(img);
});
});
}
// 隐藏原来的附件列表(因为已经直接显示大图了)
document.querySelectorAll('ul.attachmentList').forEach(list => {
// 检查是否有非图片附件(比如 zip、pdf)
// 如果有,不能隐藏列表,否则下载不了
const hasNonImage = Array.from(list.querySelectorAll('a')).some(a =>
a.className === 'file-preview'
);
if (!hasNonImage) {
list.style.display = 'none';
}
});
}
/**
* 隐藏附件大图,恢复原始列表
*/
function hideBigPreview() {
// 隐藏所有插入的大图
document.querySelectorAll('img.inserted-img').forEach(img => {
img.style.display = 'none';
});
// 显示原始附件列表
document.querySelectorAll('ul.attachmentList').forEach(list => {
list.style.display = '';
});
}
/**
* 根据用户名过滤帖子显示
* @param {string} username - 要显示的用户名,空字符串表示显示全部
*/
function filterPostsByUser(username) {
const articles = document.querySelectorAll(
'article.message.message--post.js-post.js-inlineModContainer'
);
articles.forEach(article => {
// 查找用户名元素
const nameElement = article.querySelector('.message-name .username ');
if (!nameElement) {
// 找不到用户名,默认显示
article.style.display = '';
return;
}
const postUsername = nameElement.textContent.trim();
if (username === '') {
// 空字符串,显示全部
article.style.display = '';
} else {
// 非空,只匹配对应用户(不区分大小写)
const match = postUsername.toLowerCase() === username.toLowerCase();
if(match){
article.style.display = '';
}else{
article.style.display = 'none';
}
}
});
console.log(username === '' ? '显示全部帖子' : `只显示用户 "${username}" 的帖子`);
}
/**
* 隐藏纯文字帖子(没有图片、视频、链接的帖子)
*/
function hideTextOnlyPosts() {
// 需要显示/隐藏的元素 ID 列表
const containerIds = [
'auto-jump-text-only-page-container'
];
containerIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = '';
});
// 获取所有帖子
const articles = document.querySelectorAll(
'article.message.message--post.js-post.js-inlineModContainer'
);
let textPostCount = 0; // 纯文字帖子数
let nonTextPostCount = 0; // 有图片/视频的帖子数
articles.forEach(article => {
// 找到帖子内容区
const originalContent = article.querySelector('.message-content.js-messageContent');
if (!originalContent) return;
// 克隆一份内容,用于检查(避免修改原 DOM)
const content = originalContent.cloneNode(true);
// 去掉引用和签名,这些不算内容
content.querySelectorAll('blockquote, aside.message-signature').forEach(el => el.remove());
// 检查视频元素
const hasVideo = content.querySelector('video') !== null ;
const hasVideoProcessing = content.querySelector('.thap-processing-placeholder') !== null;
const hasIframe = content.querySelector('iframe') !== null ;
//检查链接元素(排除用户名链接)
const hasLink = Array.from(content.querySelectorAll('a')).some(a =>
!a.classList.contains('username')
);
// 检查是否有非表情图片(表情不算内容)
const hasValidImage = Array.from(content.querySelectorAll('img')).some(img =>
!img.className.includes('smilie')
) ;
// 判断:没有视频、没有处理中的视频、没有有效图片、没有 iframe、没有链接
if (!hasVideo && !hasVideoProcessing && !hasValidImage && !hasIframe && !hasLink) {
textPostCount++;
article.style.display = 'none';// 隐藏纯文字帖子
} else {
article.style.display = '';//显示帖子
nonTextPostCount++;
}
});
// 如果当前页面全是纯文字,且勾选了"自动跳过",就跳转到下一页
if (nonTextPostCount === 0 && getStorageBool('auto-jump-text-only-page')) {
setTimeout(clickNextPage, CONFIG.NEXT_PAGE_DELAY);
}
console.log(`隐藏了${textPostCount}篇纯文字帖子`);
}
/**
* 显示所有帖子(取消隐藏)
*/
function showAllPosts() {
// 需要显示/隐藏的元素 ID 列表
const containerIds = [
'auto-jump-text-only-page-container'
];
containerIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// 显示所有帖子
document.querySelectorAll(
'article.message.message--post.js-post.js-inlineModContainer'
).forEach(article => {
article.style.display = '';
});
}
/**
* 点击下一页按钮(或上一页)
* 根据"向后翻页"复选框决定方向
*/
function clickNextPage() {
// 检查是否向后翻页
const isBackward = getStorageBool( 'auto-jump-backward');
// 根据方向选择对应的选择器
const selector = isBackward
? 'a.pageNav-jump.pageNav-jump--prev' // 上一页
: 'a.pageNav-jump.pageNav-jump--next'; // 下一页
const nextLink = document.querySelector(selector);
if (nextLink) {
console.log('自动点击下一页按钮');
nextLink.click();
} else {
console.warn('未找到下一页按钮');
}
}
// ==========================================
// 新增:TTS 优化功能模块
// ==========================================
/**
* TTS 替换词典
* 键:原文本(支持正则)
* 值:替换后的 TTS 友好文本
*/
const TTS_REPLACEMENTS = {
// 语气词(不区分大小写)
'\\beh\\b': 'er', // eh → er (更自然的停顿)
'\\buh\\b': 'uhh', // uh → uhh
'\\bah\\b': 'ahh', // ah → ahh
'\\boh\\b': 'ohh', // oh → ohh
'\\bmm\\b': 'hmm', // mm → hmm
'\\bmmm\\b': 'hmm', // mmm → hmm
'\\bhuh\\b': 'huhh', // huh → huhh
'\\bheh\\b': 'heh', // heh → hey (轻笑)
'\\bha\\b': 'hah', // ha → hah (笑声)
// 重复字母(如 sooooo → so)
'\\b(so+)\\b': 'so',
'\\b(no+)\\b': 'no',
'\\b(yes+)\\b': 'yes',
'\\b(yeah+)\\b': 'yeah',
// 网络缩写(可选,按需启用)
'\\blol\\b': 'laughing out loud',
'\\blmao\\b': 'laughing my ass off',
'\\brofl\\b': 'rolling on the floor laughing',
'\\bbtw\\b': 'by the way',
'\\bimo\\b': 'in my opinion',
'\\bimho\\b': 'in my humble opinion',
'\\btbh\\b': 'to be honest',
'\\btbf\\b': 'to be fair',
'\\bidk\\b': 'I do not know',
'\\bikr\\b': 'I know right',
'\\bnvm\\b': 'never mind',
'\\bftw\\b': 'for the win',
'\\bffs\\b': 'for fucks sake',
'\\bsmh\\b': 'shaking my head',
'\\btho\\b': 'though',
'\\bcuz\\b': 'because',
'\\bcos\\b': 'because',
// 拟声词优化
'\\bshh+\\b': 'shush',
'\\bwoah\\b': 'whoa',
'\\bwooo+\\b': 'woo',
'\\byay\\b': 'yay',
'\\bwhew\\b': 'few',
'\\bphew\\b': 'few',
};
/**
* 执行 TTS 优化替换
* 遍历所有帖子正文,替换 TTS 不友好词汇
*/
function optimizeForTTS() {
const articles = document.querySelectorAll(
'article.message.message--post.js-post.js-inlineModContainer'
);
let modifiedCount = 0;
let totalReplacements = 0;
articles.forEach(article => {
// 跳过隐藏的帖子
if (article.style.display === 'none') return;
const bodyElement = article.querySelector('.message-body.js-selectToQuote');
if (!bodyElement) return;
// 获取所有文本节点(排除代码块、引用等)
const walker = document.createTreeWalker(
bodyElement,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// 跳过代码块、引用内的文本
const parent = node.parentElement;
if (parent.closest('code, pre, blockquote, .bbCodeBlock')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
// 执行替换
let nodeModified = false;
textNodes.forEach(textNode => {
let originalText = textNode.textContent;
let modifiedText = originalText;
let nodeReplacements = 0;
Object.entries(TTS_REPLACEMENTS).forEach(([pattern, replacement]) => {
const regex = new RegExp(pattern, 'gi');
const matches = modifiedText.match(regex);
if (matches) {
nodeReplacements += matches.length;
modifiedText = modifiedText.replace(regex, replacement);
}
});
if (modifiedText !== originalText) {
textNode.textContent = modifiedText;
nodeModified = true;
totalReplacements += nodeReplacements;
}
});
if (nodeModified) {
modifiedCount++;
}
});
console.log(`TTS 优化完成:修改了 ${modifiedCount} 个帖子,共 ${totalReplacements} 处替换`);
return { modifiedCount, totalReplacements };
}
/**
* 恢复原始文本(通过重新加载页面或保存原始文本)
* 简单方案:提示用户刷新页面
*/
function restoreOriginalText() {
location.reload();
}
/**
* 显示 TTS 优化结果弹窗
*/
function showTTSResultModal(result) {
let existingModal = document.getElementById('tts-result-modal');
if (existingModal) existingModal.remove();
const modal = document.createElement('div');
modal.id = 'tts-result-modal';
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #6ba65e;
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 100000;
max-width: 400px;
width: 90%;
font-family: inherit;
`;
modal.innerHTML = `
<h3 style="margin: 0 0 15px 0; font-size: 16px;">TTS Optimization Done</h3>
<p style="margin: 0 0 10px 0; font-size: 14px;">
Modified ${result.modifiedCount} posts<br>
Total replacements: ${result.totalReplacements}
</p>
<p style="margin: 0 0 15px 0; font-size: 12px; opacity: 0.9;">
Replaced: eh→er, uh→uhh, lol→laughing out loud, etc.
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button id="tts-undo-btn" style="background: rgba(255,255,255,0.2); color: white; border: 1px solid white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; flex: 1;">
Undo
</button>
<button id="tts-close-btn" style="background: white; color: #6ba65e; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; flex: 1;">
Done
</button>
</div>
`;
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 99999;
`;
const closeModal = () => {
modal.remove();
overlay.remove();
};
overlay.addEventListener('click', closeModal);
modal.querySelector('#tts-close-btn').addEventListener('click', closeModal);
modal.querySelector('#tts-undo-btn').addEventListener('click', restoreOriginalText);
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
// ==========================================
// 新增:文本提取功能模块
// ==========================================
/**
* 提取帖子文本内容
* 根据当前筛选状态(checkbox-filter-by-user)决定是否只提取特定用户
*/
function extractPostsText() {
// 检查是否启用了用户筛选
const isFilterEnabled = getStorageBool('checkbox-filter-by-user');
const targetUsername = isFilterEnabled ?
(localStorage.getItem('filter-by-user') || '').trim().toLowerCase() : '';
const articles = document.querySelectorAll(
'article.message.message--post.js-post.js-inlineModContainer'
);
let extractedTexts = [];
let includedCount = 0;
let skippedCount = 0;
articles.forEach((article, index) => {
// 如果帖子被隐藏了(通过 hide-text-only-post 或其他方式),跳过
if (article.style.display === 'none') {
return;
}
// 检查用户名筛选
const nameElement = article.querySelector('.message-name .username ');
const postUsername = nameElement ? nameElement.textContent.trim() : '';
// 如果启用了筛选且用户名不匹配,跳过
//但其实这段不需要,因为如果开启了用户名筛选,post就会被隐藏,开头就被跳过了,所以这段注释掉
/**
if (isFilterEnabled && targetUsername &&
postUsername.toLowerCase() !== targetUsername) {
skippedCount++;
return;
}
*/
// 提取正文内容
const bodyElement = article.querySelector('.message-body.js-selectToQuote');
if (!bodyElement) return;
// 克隆元素以避免修改原 DOM
const clone = bodyElement.cloneNode(true);
// 移除引用块、签名等不需要的内容(参考原有的 hideTextOnlyPosts 逻辑)
clone.querySelectorAll('blockquote, aside.message-signature, .bbMediaWrapper, .video-easter-egg-poster, img, .inserted-img').forEach(el => el.remove());
// 获取纯文本
let text = clone.textContent || '';
// 保留换行和段落结构,只清理行内多余空格
text = text
.replace(/[ \t]+\n/g, '\n') // 行尾空格清理
.replace(/\n[ \t]+/g, '\n') // 行首空格清理.replace(/[^\S\n]+/g, ' ') // 非换行的空白合并为单个空格
.replace(/\n{2,}/g, '\n') // 超过1个的连续换行合并为1个
.trim();
if (text.length > 0) {
// 【修改】修复时间戳获取:使用 data-date-string 和 data-time-string
const timeElement = article.querySelector('.message-attribution-main time.u-dt');
let timestamp = '';
if (timeElement) {
const dateStr = timeElement.getAttribute('data-date-string') || '';
const timeStr = timeElement.getAttribute('data-time-string') || '';
if (dateStr && timeStr) {
timestamp = `${dateStr} ${timeStr}`;
} else {
// 降级:使用 textContent
timestamp = timeElement.textContent.trim();
}
}
extractedTexts.push({
index: index + 1,
username: postUsername,
text: text,
timestamp: timestamp
});
includedCount++;
}
});
console.log(`提取完成:包含 ${includedCount} 条,跳过 ${skippedCount} 条`);
if (extractedTexts.length === 0) {
alert('No text content found to extract.');
return null;
}
return formatExtractedText(extractedTexts);
}
/**
* 格式化提取的文本
*/
function formatExtractedText(posts) {
const header = `Extracted from: ${window.location.href}\n` +
`Date: ${new Date().toLocaleString()}\n` +
`Total posts: ${posts.length}\n` +
`${'='.repeat(50)}\n\n`;
const body = posts.map((post, idx) => {
return `[Post #${post.index}] ${post.username}${post.timestamp ? ' - ' + post.timestamp : ''}\n\n${post.text}\n`;
}).join('\n\n');
return header + body;
}
/**
* 下载文本为文件
*/
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}
/**
* 复制到剪贴板
*/
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Clipboard API failed:', err);
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
} catch (e) {
document.body.removeChild(textarea);
return false;
}
}
}
/**
* 显示提取选项弹窗(使用现有的绿色主题风格)
* 【修改】增加弹窗宽度,确保按钮文字显示完整
*/
function showExtractOptions(text) {
// 检查是否已存在弹窗
let existingModal = document.getElementById('text-extract-modal');
if (existingModal) {
existingModal.remove();
}
// 创建弹窗
const modal = document.createElement('div');
modal.id = 'text-extract-modal';
// 【修改】增加最大宽度到 500px,确保按钮文字不换行
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #6ba65e;
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 100000;
max-width: 500px;
width: 90%;
font-family: inherit;
`;
// 内容预览(截断)
const previewText = text.length > 200 ? text.substring(0, 200) + '...' : text;
const postCount = (text.match(/\[Post #/g) || []).length;
modal.innerHTML = `
<h3 style="margin: 0 0 15px 0; font-size: 16px;">Text Extracted</h3>
<p style="margin: 0 0 10px 0; font-size: 13px; opacity: 0.9;">
${postCount} posts extracted (${text.length} characters)
</p>
<div style="background: rgba(255,255,255,0.1); padding: 10px; border-radius: 4px; margin-bottom: 15px; max-height: 150px; overflow-y: auto; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-word;">
${previewText.replace(/</g, '<').replace(/>/g, '>')}
</div>
<div style="display: flex; gap: 10px; justify-content: center;">
<button id="extract-download-btn" style="background: white; color: #6ba65e; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; flex: 1; white-space: nowrap;">
Download
</button>
<button id="extract-copy-btn" style="background: rgba(255,255,255,0.2); color: white; border: 1px solid white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; flex: 1; white-space: nowrap;">
Copy
</button>
<button id="extract-close-btn" style="background: transparent; color: white; border: 1px solid rgba(255,255,255,0.5); padding: 8px 16px; border-radius: 4px; cursor: pointer; flex: 0.5; white-space: nowrap;">
✕
</button>
</div>
`;
// 遮罩层
const overlay = document.createElement('div');
overlay.id = 'text-extract-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 99999;
`;
// 关闭函数
const closeModal = () => {
modal.remove();
overlay.remove();
};
// 事件绑定
overlay.addEventListener('click', closeModal);
modal.querySelector('#extract-close-btn').addEventListener('click', closeModal);
modal.querySelector('#extract-download-btn').addEventListener('click', () => {
// 【修改开始】获取帖子标题和页码
let filename;
// 获取标题
const titleElement = document.querySelector('h1.p-title-value');
const title = titleElement ? titleElement.textContent.trim() : '';
// 获取当前页码
const pageElement = document.querySelector('li.pageNav-page--current a');
const pageNum = pageElement ? pageElement.textContent.trim() : '';
// 如果能获取到标题和页码,使用它们生成文件名
if (title && pageNum) {
// 页码格式化为3位数 (001, 002, ...)
const pageStr = pageNum.padStart(3, '0');
// 清理非法文件名字符: \ / : * ? " < > |
const safeTitle = title.replace(/[\\/:*?"<>|]/g, '_');
filename = `${safeTitle}_p${pageStr}.txt`;
} else {
// fallback:使用默认格式 + 时间精确到秒
const now = new Date();
const timeStr = now.toISOString()
.replace(/[-:T]/g, '') // 去掉 - : T
.slice(0, 15); // 取到秒: YYYYMMDDHHmmss
filename = `lpsg_posts_${timeStr}.txt`;
}
downloadTextFile(text, filename);
closeModal();
});
modal.querySelector('#extract-copy-btn').addEventListener('click', async () => {
const success = await copyToClipboard(text);
if (success) {
const btn = modal.querySelector('#extract-copy-btn');
const originalText = btn.textContent;
btn.textContent = '✓ Copied!';
btn.style.background = 'rgba(255,255,255,0.4)';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = 'rgba(255,255,255,0.2)';
}, 1500);
} else {
alert('Failed to copy to clipboard');
}
});
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
// ==========================================
// 第六部分:视频处理模块
// ==========================================
/**
* 替换!!!Thread里!!!!!无法播放的视频
* 把网站的预览图替换成直接可播放的视频播放器
* 【修改】使用异步HEAD请求预探测有效URL,替代定时轮询切换
* 【新增】分两阶段处理:先同步显示所有"测试链接中",再逐个异步探测
* 【优化】并发探测视频URL,哪个先完成就先更新UI,无需等待全部完成
*/
async function replaceThreadUnplayableVideos() {
// 找到所有视频预览图(这些是需要替换的)
const posters = document.getElementsByClassName('video-easter-egg-poster');
// 如果没有需要替换的,说明是官方视频,直接放大处理
if (posters.length === 0) {
enlargeAndMuteVideo();
return;
}
// ==========================================
// 第一阶段:同步收集信息并显示所有"测试链接中"状态
// 正序遍历(i=0开始),用户看到的加载顺序更自然
// ==========================================
const videoTasks = [];
for (let i = 0; i < posters.length; i++) {
const poster = posters[i];
const img = poster.children[0];
if (!img) continue;
const imageUrl = img.src;
// 创建容器和UI(先显示加载状态)
const newDiv = document.createElement('div');
newDiv.className = 'newVideoDiv';
newDiv.id = `video-container-${i}`;
// 显示"测试链接中"状态
newDiv.innerHTML = `
<div id="video-loading-${i}" style="
padding: 5px;
background: #f0f0f0;
border: 2px dashed #6ba65e;
border-radius: 8px;
text-align: center;
color: #333;
font-weight: bold;
margin: 5px;
">
<div style="margin-bottom: 0px;">🎬 Testing video link...</div>
</div>
`;
// 插入到预览图后面
const parent = poster.parentElement?.parentElement;
if (!parent) continue;
parent.appendChild(newDiv);
// 保存原始poster的包装器,方便后续立即删除
const originalWrapper = poster.parentElement;
videoTasks.push({
index: i,
imageUrl: imageUrl,
parent: parent,
newDiv: newDiv,
originalWrapper: originalWrapper
});
}
// ==========================================
// 第二阶段:并发探测所有视频URL,哪个先完成就先更新UI
// ==========================================
// 为每个任务创建一个Promise,探测完成后立即更新UI
const updatePromises = videoTasks.map(task =>
findVideoUrl(task.imageUrl).then(validVideoUrl => {
// 探测完成,立即更新这个视频的UI
updateVideoUI(task, validVideoUrl);
}).catch(err => {
console.error(`视频 #${task.index} 探测失败:`, err);
// 出错时按无有效URL处理
updateVideoUI(task, null);
})
);
// 等待所有更新完成(但每个已经各自更新了)
await Promise.allSettled(updatePromises);
// 处理官方视频
enlargeAndMuteVideo();
// ==========================================
// 辅助函数:更新单个视频的UI
// ==========================================
function updateVideoUI(task, validVideoUrl) {
const { index, imageUrl, parent, newDiv, originalWrapper } = task;
const loadingDiv = document.getElementById(`video-loading-${index}`);
if (validVideoUrl) {
// 探测成功:创建视频播放器
// 从URL中提取当前格式
const extPattern = VIDEO_EXTENSIONS.join('|');
const currentFormat = validVideoUrl.match(new RegExp(`\\.(${extPattern})$`, 'i'))?.[1] || 'mp4';
// 移除加载提示
if (loadingDiv) loadingDiv.remove();
// 创建视频HTML
const videoHtml = `
<video data-video-id="${index}"
onloadstart="this.volume=${state.volume}"
style="width:${CONFIG.VIDEO_WIDTH};max-width:${CONFIG.VIDEO_WIDTH}; max-height:${CONFIG.VIDEO_MAX_HEIGHT};"
controls
data-xf-init="video-init"
data-poster="${imageUrl}"
poster="${imageUrl}">
<source data-src="${validVideoUrl}" src="${validVideoUrl}">
<div class="bbMediaWrapper-fallback">Your browser is not able to display this video.</div>
</video>
`;
newDiv.innerHTML = videoHtml;
} else {
// 探测失败:显示错误信息,但仍提供手动切换
if (loadingDiv) {
loadingDiv.innerHTML = `
<div style="color: #c5455c; margin-bottom: 0px;">
⚠️ Video link not found
</div>
<div style="font-size: 12px; color: #666; margin-bottom: 0px;">
Auto-detection failed. Please try manual format switching.
</div>
`;
loadingDiv.style.borderColor = '#c5455c';
loadingDiv.style.background = '#fff5f5';
}
// 即使探测失败,也创建一个默认视频元素(用mp4),让用户可以手动切换
const defaultVideoUrl = imageUrl
.replace('/attachments/posters', '/video')
.replace('/lsvideo/thumbnails', '/lsvideo/videos')
.replace('.jpg', '.mp4');
const videoHtml = `
<video data-video-id="${index}"
onloadstart="this.volume=${state.volume}"
style="width:${CONFIG.VIDEO_WIDTH};max-width:${CONFIG.VIDEO_WIDTH}; max-height:${CONFIG.VIDEO_MAX_HEIGHT}; "
controls
data-xf-init="video-init"
data-poster="${imageUrl}"
poster="${imageUrl}">
<source data-src="${defaultVideoUrl}" src="${defaultVideoUrl}">
<div class="bbMediaWrapper-fallback">Your browser is not able to display this video.</div>
</video>
`;
const videoDiv = document.createElement('div');
videoDiv.innerHTML = videoHtml;
newDiv.insertBefore(videoDiv, loadingDiv);
// 添加格式切换选择器(手动模式)
const selectorWrapper = document.createElement('span');
selectorWrapper.style.marginLeft = '5px';
selectorWrapper.appendChild(document.createTextNode('Format: '));
const selector = createFormatSelector(index, 'mp4', true);
selectorWrapper.appendChild(selector);
parent.appendChild(selectorWrapper);
}
// 立即删除原始poster元素
if (originalWrapper && originalWrapper.parentElement) {
originalWrapper.parentElement.removeChild(originalWrapper);
}
}
}
/**
* 替换!!!Gallery里!!!!!无法播放的视频
* 把网站的预览图替换成直接可播放的视频播放器
*/
function replaceGalleryUnplayableVideos() {
/**
* 读取Gallery页面中的视频地址
* @returns {string|null} - contentUrl 或 null
*/
function getVideoUrl() {
// 找到 JSON-LD 脚本
const script = document.querySelector('script[type="application/ld+json"]');
if (!script) return null;
try {
// 解析 JSON
const data = JSON.parse(script.textContent);
// 直接读取 mainEntity.contentUrl
return data.mainEntity?.contentUrl || null;
} catch (e) {
console.warn('JSON-LD 解析失败:', e);
return null;
}
}
// 找到所有视频预览图(这些是需要替换的)
const posters = document.getElementsByClassName('video-easter-egg-poster');
// 如果没有需要替换的,说明是官方视频,直接放大处理
if (posters.length === 0) {
enlargeAndMuteVideo();
return;
}
// 倒序遍历:从最后一个视频开始处理
// 这样先处理后面的,不会影响到前面元素的索引
for (let i = posters.length - 1; i >= 0; i--) {
const poster = posters[i];
const img = poster.children[0];
if (!img) {
continue;
}
const imageUrl = img.src;
// 构造视频 URL:替换路径和扩展名
const videoUrl = getVideoUrl();
if (!videoUrl) {
console.log('找不到视频url');
continue;
}
// 创建视频播放器 HTML
// ✅ 给 video 添加 data-video-id 属性,用于精确对应
const videoHtml = `
<video data-video-id="${i}"
onloadstart="this.volume=${state.volume}"
style="width:100%; max-width:1070px; max-height:${CONFIG.VIDEO_MAX_HEIGHT};"
controls
data-xf-init="video-init"
data-poster="${imageUrl}"
poster="${imageUrl}">
<source data-src="${videoUrl}" src="${videoUrl}">
<div class="bbMediaWrapper-fallback">Your browser is not able to display this video.</div>
</video>
`;
// 创建容器插入视频
const newDiv = document.createElement('div');
newDiv.className = 'newVideoDiv';
newDiv.innerHTML = videoHtml;
// 插入到预览图后面
const parent = poster.parentElement?.parentElement;
if (parent) {
parent.appendChild(newDiv);
}
}
// 清理原始元素
['video-easter-egg-poster'].forEach(className => {
Array.from(document.getElementsByClassName(className)).reverse().forEach(el => {
const wrapper = el.parentElement;
if (wrapper) {
wrapper.parentElement?.removeChild(wrapper);
}
});
});
}
/**
* 创建视频格式选择器(替代原来的3个按钮)
* 【修改】支持传入默认选中的格式,以及手动模式(探测失败时使用)
* @param {number} videoId - 视频索引,用于与 video 元素精确对应
* @param {string} [defaultFormat='mp4'] - 默认选中的格式
* @param {boolean} [isManualMode=false] - 是否为手动模式(探测失败)
* @returns {HTMLSelectElement} - 下拉选择元素
*/
function createFormatSelector(videoId, defaultFormat = 'mp4', isManualMode = false) {
// 创建下拉选择器
const select = document.createElement('select');
// 给 select 添加 data-video-id,与 video 对应
select.dataset.videoId = videoId;
select.id = `format-select-${videoId}`;
select.style.cssText = `
margin-left: 5px;
padding: 2px 5px;
border: 1px solid #ccc;
border-radius: 3px;
background: white;
cursor: pointer;
font-size: 12px;
`;
// 添加选项
const formats = VIDEO_EXTENSIONS.map(ext => ({
value: ext,
label: ext.toUpperCase()
}));
formats.forEach(fmt => {
const option = document.createElement('option');
option.value = fmt.value;
option.textContent = fmt.label;
select.appendChild(option);
});
// 设置默认值
select.value = defaultFormat;
// 如果是手动模式,添加提示样式
if (isManualMode) {
select.style.borderColor = '#c5455c';
select.style.background = '#fff5f5';
select.title = 'Auto-detection failed, please select format manually';
}
// 切换事件
select.addEventListener('change', () => {
const newFormat = select.value;
// 通过 data-video-id 精确查找对应的 video
const video = document.querySelector(`video[data-video-id="${videoId}"]`);
if (!video) {
console.error(`找不到 video[data-video-id="${videoId}"]`);
return;
}
// 显示视频(如果是手动模式且之前隐藏了)
video.style.display = '';
//移除提示
const loadingDiv = document.getElementById(`video-loading-${videoId}`);
if (loadingDiv) loadingDiv.remove();
// 直接修改 source 属性,不碰 innerHTML,避免销毁事件监听器
const sources = video.querySelectorAll('source');
sources.forEach(source => {
['src', 'data-src'].forEach(attr => {
const url = source.getAttribute(attr);
if (url) {
// 只替换最后的后缀,使用全局 VIDEO_EXTENSIONS 构建正则
const extPattern = VIDEO_EXTENSIONS.join('|');
const newUrl = url.replace(new RegExp(`\\.(${extPattern})$`, 'i'), `.${newFormat}`);
source.setAttribute(attr, newUrl);
}
});
});
// 重新加载视频
video.load();
// 移除手动模式的警告样式(如果用户成功切换了)
select.style.borderColor = '#ccc';
select.style.background = 'white';
console.log(`视频 #${videoId} 切换到格式: ${newFormat}`);
});
return select;
}
/**
* 放大官方视频并静音
* 官方视频不需要替换,但默认太小,需要放大
*/
function enlargeAndMuteVideo() {
const videos = document.querySelectorAll('video');
let count = 0;
videos.forEach(video => {
const parent = video.parentElement;
const fullUrl = window.location.href;
// 处理替换的视频在(newVideoDiv 里),只处理thread里的,变回随窗口变化
if ((!parent || parent.className === 'newVideoDiv') && fullUrl.includes('www.lpsg.com/threads')) {
video.style.width = "100%";
return;
}
//开始处理官方视频
count++;
// 设置样式
video.style.width = "100%";
video.style.maxWidth = CONFIG.VIDEO_WIDTH;
video.style.maxHeight = CONFIG.VIDEO_MAX_HEIGHT;
video.volume = state.volume; // 应用默认音量
// 把视频提升到父元素同级,并删除父元素(去掉包裹层)
parent.before(video);
parent.remove();
});
// 同时放大视频容器的宽度
document.querySelectorAll('div.bbMediaWrapper.bbMediaWrapper--inline').forEach(el => {
el.style.width = CONFIG.VIDEO_WIDTH;
});
console.log(`放大了${count}个视频`);
}
/**
* itemList-item 容器内所有 a链接全部重写
* 强制在新窗口打开
*/
function rewriteGalleryLinks() {
if (!getStorageBool('force-new-window')) return;
document.querySelectorAll('.itemList-item.js-inlineModContainer a[data-fancybox]').forEach(link => {
// 只保留必要的属性和内容
const href = link.href;
const text = link.textContent;
const img = link.querySelector('img'); // 如果有缩略图
// 清空并重建
link.removeAttribute('data-type');
link.removeAttribute('data-fancybox');
link.removeAttribute('data-src');
link.removeAttribute('data-lb-type-override');
link.removeAttribute('data-lb-sidebar');
link.removeAttribute('data-lb-caption-desc');
link.removeAttribute('data-lb-caption-href');
link.removeAttribute('data-caption');
link.removeAttribute('class'); // 移除 js-lbImage 等类
// 设置新窗口打开
link.target = '_blank';
link.className = 'gallery-link'; // 可选:加自定义类
console.log('重写链接:', href);
});
}
// ==========================================
// 第七部分:自动滚动按钮模块
// ==========================================
/**
* 创建右下角的悬浮自动滚动按钮
* 点击开始/停止滚动,滚到底可自动跳转下一页
*/
function createAutoScrollButton() {
// 按钮配置
const BTN_CONFIG = {
scrollSpeed: 5, // 默认滚动速度
buttonSize: 50, // 按钮大小(像素)
buttonRight: 30, // 距离右边距
buttonBottom: '50%', // 垂直位置:50% 表示居中
buttonOffset: 0, // 垂直偏移修正
primaryColor: '#6ba65e', // 主色:靛蓝
hoverColor: '#77b969', // 悬停色
activeColor: '#538d47', // 激活色(滚动中)
zIndex: 99999 // 层级,确保在最上面
};
// 状态
let isScrolling = false; // 是否正在滚动
let animationId = null; // 动画帧 ID,用于取消
let button = null; // 按钮元素引用
/**
* 检查元素是否在视口内(可见)
*/
function isElementInViewport(el) {
if (!el || el.offsetParent === null) return false;
const rect = el.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
// 检查元素是否在视口范围内(至少部分可见)
return (
rect.top < windowHeight &&
rect.bottom > 0 &&
rect.left < windowWidth &&
rect.right > 0 &&
rect.width > 0 &&
rect.height > 0
);
}
/**
* 检查是否有视频进入视口
*/
function isVideoInViewport() {
const videos = document.querySelectorAll('video');
for (let video of videos) {
if (isElementInViewport(video)) {
return true;
}
}
return false;
}
/**
* 创建按钮 DOM
*/
function createButton() {
const btn = document.createElement('div');
btn.id = 'auto-scroll-btn';
// 计算垂直位置
const offset = typeof BTN_CONFIG.buttonBottom === 'string' &&
BTN_CONFIG.buttonBottom.includes('%')
? `calc(${BTN_CONFIG.buttonBottom} + ${BTN_CONFIG.buttonOffset}px)`
: `${BTN_CONFIG.buttonBottom + BTN_CONFIG.buttonOffset}px`;
// 设置样式:玻璃拟态效果
btn.style.cssText = `
position: fixed;
right: ${BTN_CONFIG.buttonRight}px;
top: ${offset};
transform: translateY(-50%);
width: ${BTN_CONFIG.buttonSize}px;
height: ${BTN_CONFIG.buttonSize}px;
background: rgba(107, 166, 94,0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
cursor: pointer;
z-index: ${BTN_CONFIG.zIndex};
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
scale: 0.8;
`;
// 内部图标:默认是向下箭头
btn.innerHTML = `
<svg id="scroll-icon" width="24" height="24" viewBox="0 0 24 24"
style="transition: all 0.3s ease;">
<path d="M12 5v14M5 12l7 7 7-7"
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
`;
// 鼠标悬停效果(未滚动时)
btn.addEventListener('mouseenter', () => {
if (isScrolling) return;
btn.style.background = BTN_CONFIG.hoverColor;
btn.style.transform = 'translateY(-50%) scale(1.1)';
});
btn.addEventListener('mouseleave', () => {
if (isScrolling) return;
btn.style.background = BTN_CONFIG.primaryColor;
btn.style.transform = 'translateY(-50%) scale(1)';
});
// 点击切换滚动状态
btn.addEventListener('click', toggleScroll);
// 页面加载时:如果之前设置为隐藏,立即应用
if (!getStorageBool('show-scroll-button')) {
btn.style.display = 'none';
}
return btn;
}
/**
* 开始自动滚动
*/
function startScroll() {
if (isScrolling) return; // 已经在滚动了,忽略
isScrolling = true;
// 从 localStorage 读取速度设置
const speed = getStorageBool(`checkbox-scroll-speed`)?(Number(localStorage.getItem('scroll-speed')) || BTN_CONFIG.scrollSpeed):BTN_CONFIG.scrollSpeed;
// 更新按钮样式为激活状态
button.style.background = BTN_CONFIG.activeColor;
button.style.transform = 'translateY(-50%) scale(0.95)';
// 图标变成停止方块
button.querySelector('#scroll-icon').innerHTML =
'<rect x="6" y="6" width="12" height="12" rx="2" fill="white" stroke="none"/>';
/**
* 滚动步进函数
* 使用 requestAnimationFrame 实现平滑滚动
*/
function step() {
if (!isScrolling) return; // 被停止了
// 获取当前滚动位置和最大高度
const current = window.pageYOffset || document.documentElement.scrollTop;
const max = document.documentElement.scrollHeight - window.innerHeight;
// 距离底部 CONFIG.SCROLL_OFFSET 像素时停止
if (current >= max - CONFIG.SCROLL_OFFSET) {
stopScroll();
// 如果勾选了"自动跳转下一页",就跳转
if (getStorageBool('autoscroll-jump-key')) {
setTimeout(clickNextPage, CONFIG.NEXT_PAGE_DELAY);
}
return;
}
// 【新增】检测视频逻辑:首次检测到视频后,再滚动几次再停止
if (isVideoInViewport()) {
// 使用静态变量记录还需要继续滚动的次数
if (typeof step.continueScrollCount === 'undefined') {
step.continueScrollCount = 90; // 再滚动一些让视频进入界面
//console.log('检测到视频进入视口,继续滚动3次后停止');
}
if (step.continueScrollCount > 0) {
step.continueScrollCount--;
window.scrollBy(0, speed);
animationId = requestAnimationFrame(step);
return;
} else {
// 继续滚动次数用完,真正停止
console.log('视频已进入视窗中心,停止滚动');
stopScroll();
// 重置计数器,为下次做准备
step.continueScrollCount = undefined;
return;
}
} else {
// 视频不在视口内,重置计数器
step.continueScrollCount = undefined;
}
// 正常滚动一小段距离
window.scrollBy(0, speed);
// 继续下一帧
animationId = requestAnimationFrame(step);
}
// 开始动画循环
animationId = requestAnimationFrame(step);
}
/**
* 停止自动滚动
*/
function stopScroll() {
if (!isScrolling) return; // 已经停止了,忽略
isScrolling = false;
cancelAnimationFrame(animationId); // 取消动画帧
// 恢复按钮样式
button.style.background = BTN_CONFIG.primaryColor;
button.style.transform = 'translateY(-50%) scale(1)';
// 图标恢复向下箭头
button.querySelector('#scroll-icon').innerHTML = `
<path d="M12 5v14M5 12l7 7 7-7"
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"/>
`;
}
/**
* 切换滚动状态(开始/停止)
*/
function toggleScroll() {
isScrolling ? stopScroll() : startScroll();
}
/**
* 初始化按钮
*/
function init() {
// 防止重复初始化
if (document.getElementById('auto-scroll-btn')) return;
// 添加 CSS 动画关键帧
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0%, 100% { box-shadow: 0 8px 32px rgba(99, 102, 241, 0.4), 0 0 0 0 rgba(99, 102, 241, 0.4); }
50% { box-shadow: 0 8px 32px rgba(99, 102, 241, 0.4), 0 0 0 20px rgba(99, 102, 241, 0); }
}
`;
document.head.appendChild(style);
// 创建并添加按钮
button = createButton();
document.body.appendChild(button);
// 入场动画:延迟显示
setTimeout(() => {
button.style.opacity = '1';
button.style.scale = '1';
}, 100);
// 监听滚轮事件:用户手动滚动时停止自动滚动
window.addEventListener('wheel', () => {
if (isScrolling) stopScroll();
}, { passive: true });
// 如果勾选了"自动滚动",延迟后开始滚动
if (getStorageBool('autoscroll-key')) {
setTimeout(startScroll, CONFIG.AUTO_START_DELAY);
}
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}
// ==========================================
// 第八部分:总初始化
// ==========================================
/**
* 初始化所有功能
* 按顺序创建控制面板和各种选项
*/
function initThread() {
//创建控制面板容器(左上角折叠面板)
createControlDiv();
//处理视频替换(核心功能)
replaceThreadUnplayableVideos();
//跳过设置组
createSectionLabel('Skip Useless Stuff');
createHideTextOnlyCheckbox();
createAutoJumpTextOnlyCheckbox();
//页面控制组
createSectionLabel('Page Jump Setting');
createAutoJumpBackwardCheckbox();
//视频设置组
createSectionLabel('Video Setting');
createVolumeInput();
createVideoAutoFullscreenCheckbox();
//图片显示组
createSectionLabel('Better Pic Display');
createEnlargeAttachmentCheckbox();
createOnePicPerLineCheckbox();
createMaxWidthInput();
//用户信息组
createSectionLabel('User Related');
createHideUserInfoCheckbox();
createFilterByUserInput();
//自动滚动组
createSectionLabel('Auto Scroll');
createShowScrollButtonCheckbox();
createAutoscrollCheckbox();
createAutoscrollJumpCheckbox();
createScrollSpeedInput();
//文本处理组
createSectionLabel('Erotic Stories');
createTextExtractButton(); // 添加文本提取按钮
//createTTSOptimizeButton(); // 【新增】TTS 优化按钮
//创建悬浮滚动按钮
createAutoScrollButton();
}
/**
* 初始化所有功能
* 按顺序创建控制面板和各种选项
*/
function initGallery() {
// 1. 创建控制面板容器(左上角折叠面板)
createControlDiv();
// 2. 处理视频替换(核心功能)
replaceGalleryUnplayableVideos();
rewriteGalleryLinks();//在浏览页把所有链接强制为在新窗口打开
// 3. 视频设置组
createSectionLabel('Script only works on new window!!!','#c5455c');
createSectionLabel('Video Setting');
createForceNewWindowCheckbox();
createVolumeInput();
createVideoAutoFullscreenCheckbox();
}
const fullUrl = window.location.href;
// 判断执行不同代码
if (fullUrl.includes('www.lpsg.com/threads')) {
// thread代码
initThread();
} else if (fullUrl.includes('www.lpsg.com/gallery')) {
// gallery代码
initGallery();
}
state.initDone=true;
showFirstTimeWelcome();
})();