// ==UserScript==
// @name MJJBOX 增强
// @namespace http://tampermonkey.net/
// @version 1.0.1
// @description 为 MJJBox 设置自动签到、等级进度、自动阅读、自动点赞、快速收藏、话题时间显示、精确回复时间、新标签页打开、返回顶部 等功能。
// @description:en Adds features like Auto Check-in, Level Progress, Auto Reading, Auto Liking, Quick Bookmarking, Topic Time Display, Precise Reply Time, Open in New Tab, and Back to Top to MJJBox.
// @description:zh-CN 为 MJJBox 设置自动签到、等级进度、自动阅读、自动点赞、快速收藏、话题时间显示、精确回复时间、新标签页打开、返回顶部 等功能。
// @author Zz
// @match https://mjjbox.com/*
// @icon https://www.google.com/s2/favicons?domain=mjjbox.com
// @license MIT
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// ==/UserScript==
(function() {
'use strict';
var settings = {};
const settingsConfig = {
class_label_topic: "💠 话题内容相关:",
quick_mark: { type: 'checkbox', label: '快速收藏 ', default: true, style: '', info: '在帖子上增加一个⭐用于快速收藏到书签' },
show_floor_time: { type: 'checkbox', label: '更精确的回复时间', default: true, style: '', info: '帖子的回复时间改为绝对时间并精确到分钟' },
class_label_list: "💠 话题列表相关:",
show_up_time: { type: 'checkbox', label: '显示话题时间', default: true, style: '', info: '话题列表的帖子显示创建/更新时间,老的帖子会褪色泛黄' },
class_label_profile: "💠 个人信息与签到:",
show_level_and_checkin: { type: 'checkbox', label: '等级与签到助手', default: true, style: '', info: '在页面右上角显示等级徽章,点击查看进度与签到' },
auto_checkin: { type: 'checkbox', label: '自动签到', default: false, style: '', info: '每天自动完成签到任务' },
checkin_reminder: { type: 'checkbox', label: '签到提醒', default: true, style: '', info: '如果当天未签到,则进行提醒' },
class_label_all: "💠 通用:",
open_in_new: { type: 'checkbox', label: '新标签页打开', default: false, style: '', info: '让所有链接默认从新标签页打开' },
back_to_top: { type: 'checkbox', label: '“返回顶部”按钮', default: true, style: '', info: '在页面右下角显示一个快速返回顶部的按钮' },
class_label_auto: "💠 自动阅读功能:",
show_auto_read_controls: { type: 'checkbox', label: '显示自动阅读控件', default: false, style: '', info: '在页面左下角显示“开始阅读”和“自动点赞”的控制按钮' },
class_label_end: "",
};
// Load settings from storage
Object.keys(settingsConfig).forEach(key => {
if (typeof settingsConfig[key] === 'object') {
settings[key] = GM_getValue(key, settingsConfig[key].default);
}
});
// --- Individual Menu Commands ---
Object.keys(settingsConfig).forEach(key => {
const config = settingsConfig[key];
if (typeof config === 'object' && config.type === 'checkbox') {
const isEnabled = settings[key];
const label = `${isEnabled ? '✅' : '❌'} ${config.label.trim()}`;
GM_registerMenuCommand(label, () => {
GM_setValue(key, !isEnabled);
window.location.reload();
});
}
});
GM_registerMenuCommand('⚙️ 打开详细设置面板 (Open Settings Panel)', openSettings);
// --- Settings Panel ---
function openSettings() {
if (document.querySelector('#mjjbox-custom-setting')) return;
const shadow = document.createElement('div');
shadow.style = `position: fixed; top: 0; left: 0; z-index: 8888; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.6);`;
const panel = document.createElement('div');
panel.style = `max-width: calc(100% - 100px); width: max-content; position: fixed; top: 50%; left: 50%; z-index: 9999; transform: translate(-50%, -50%); background-color: var(--secondary); color: var(--primary); padding: 15px 25px; box-shadow: 0 0 15px rgba(0, 0, 0, 0.7); max-height: calc(95vh - 40px); overflow-y: auto; border-radius: 8px;`;
panel.id = "mjjbox-custom-setting";
let html = `<style type="text/css">#mjjbox-custom-setting { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } #mjjbox-custom-setting label { font-size: 16px; display: flex; justify-content: space-between; align-items: center; margin: 12px; } #mjjbox-custom-setting label span { color: #6bc; font-size: 12px; font-weight: normal; padding: 0 6px; margin-right: auto; } #mjjbox-custom-setting label input { margin: 0 5px 0 15px; } .settings-buttons { display: flex; justify-content: space-around; margin-top: 20px; } .settings-buttons button { user-select: none; color: #333; padding: 8px 16px; border-radius: 5px; border: none; line-height: normal; cursor: pointer; } #mjjbox-custom-setting hr { display: block; height: 1px; border: 0; border-top: 1px solid var(--primary-low); margin: 1em 0; padding: 0; }</style><h2 style="text-align:center; margin-top:.5rem;">MJJBox 自定义设置</h2>`;
Object.keys(settingsConfig).forEach(key => {
const cfg = settingsConfig[key];
if (typeof cfg === 'string') {
html += `<hr><h3 style="margin-top:5px; font-size: 1.1em;">${cfg}</h3>`;
} else {
const val = settings[key];
const checked = cfg.type === 'checkbox' && val ? 'checked' : '';
html += `<label style="${cfg.style}">${cfg.label}<span>${cfg.info}</span><input type="${cfg.type}" id="ujs_set_${key}" value="${val}" ${checked}></label>`;
}
});
html += `<div class="settings-buttons"><button id="ld_userjs_apply" style="font-weight: bold; background: var(--tertiary); color: var(--secondary)">保存并刷新</button><button id="ld_userjs_reset" style="background: #fbb;">重置</button><button id="ld_userjs_close" style="background: #ddd;">取消</button></div>`;
panel.innerHTML = html;
document.body.append(shadow, panel);
function saveAndReload() {
Object.keys(settingsConfig).forEach(key => {
const element = document.getElementById(`ujs_set_${key}`);
if (element) GM_setValue(key, element.type === 'checkbox' ? element.checked : element.value);
});
window.location.reload();
}
document.querySelector('button#ld_userjs_apply').addEventListener('click', saveAndReload);
document.querySelector('button#ld_userjs_reset').addEventListener('click', () => {
Object.keys(settingsConfig).forEach(key => { if (typeof settingsConfig[key] === 'object') GM_deleteValue(key); });
window.location.reload();
});
function setting_hide() { panel.remove(); shadow.remove(); }
document.querySelector('button#ld_userjs_close').addEventListener('click', setting_hide);
shadow.addEventListener('click', setting_hide);
}
// == 功能区 ==
// Function 1: 快速收藏
if (settings.quick_mark) {
const starSvg = `<svg class="svg-icon" aria-hidden="true" style="text-indent: 1px; transform: scale(1); width:18px; height:18px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z"></path></svg></svg> `;
let markMap = new Map();
function handleResponse(xhr, s, e) { xhr.onreadystatechange = () => { if (xhr.readyState === 4) (xhr.status === 200 ? s(xhr) : e(xhr)); }; }
function TryParseJson(str) { try { return JSON.stringify(JSON.parse(str), null, 1); } catch (err) { return str; } }
function deleteStarMark(btn, id) { if (markMap.has(id)) { const mid = markMap.get(id); const x = new XMLHttpRequest(); x.open('DELETE', `/bookmarks/${mid}`, !0); x.setRequestHeader('Content-Type','application/json'); x.setRequestHeader('x-requested-with','XMLHttpRequest'); x.setRequestHeader("x-csrf-token",document.head.querySelector("meta[name=csrf-token]")?.content); handleResponse(x, () => { btn.style.color='#777'; btn.title="收藏"; btn.onclick=()=>addStarMark(btn,id); },(err)=>console.error('删除收藏失败!',err.statusText,TryParseJson(err.responseText))); x.send(); } }
function addStarMark(btn, id) { const x = new XMLHttpRequest(); x.open('POST','/bookmarks',!0); x.setRequestHeader('Content-Type','application/x-www-form-urlencoded; charset=UTF-8'); x.setRequestHeader('x-requested-with','XMLHttpRequest'); x.setRequestHeader("x-csrf-token",document.head.querySelector("meta[name=csrf-token]")?.content); const postData=`name=%E6%94%B6%E8%97%8F&auto_delete_preference=3&bookmarkable_id=${id}&bookmarkable_type=Post`; handleResponse(x,(res)=>{ btn.style.color='#fdd459'; btn.title="删除收藏"; const newMark=JSON.parse(res.responseText); markMap.set(String(newMark.bookmarkable_id),String(newMark.id)); btn.onclick=()=>deleteStarMark(btn,id); },(err)=>console.error('收藏失败!',err.statusText,TryParseJson(err.responseText))); x.send(postData); }
function addMarkBtn() { document.querySelectorAll("article[data-post-id]").forEach(art=>{ const target=art.querySelector("div.topic-body.clearfix > div.regular.contents > section > nav > div.actions"); if (target&&!art.querySelector("span.star-bookmark")) { const id=art.getAttribute('data-post-id'); const star=document.createElement('span'); star.innerHTML=starSvg; star.className="star-bookmark"; star.style.cssText='cursor:pointer;margin:0 12px;'; if(markMap.has(id)){star.style.color='#fdd459';star.title="删除收藏";star.onclick=()=>deleteStarMark(star,id);}else{star.style.color='#777';star.title="收藏";star.onclick=()=>addStarMark(star,id);} target.after(star); } }); }
function getStarMark() { const userEl = document.querySelector('#current-user button > img[src]'); if(!userEl) return; const match = userEl.getAttribute('src').match(/\/user_avatar\/[^\/]+\/([^\/]+)\/\d+\//); const username = match?.[1]; if(!username) return; const x = new XMLHttpRequest(); x.open('GET',`/u/${username}/user-menu-bookmarks`,!0); x.setRequestHeader("x-csrf-token",document.head.querySelector("meta[name=csrf-token]")?.content); handleResponse(x,(res)=>{ var r=JSON.parse(res.responseText); markMap.clear(); r.bookmarks.forEach(m=>markMap.set(m.bookmarkable_id.toString(),m.id.toString())); addMarkBtn();},(err)=>console.error('获取收藏列表失败:',err.statusText)); x.send(); }
let lastUpdateTime=0; const mainNode=document.querySelector("#main-outlet"); if(mainNode){ new MutationObserver(()=>{const now=Date.now();if(now-lastUpdateTime>5e3){getStarMark();lastUpdateTime=now;}else{addMarkBtn();}}).observe(mainNode,{childList:!0,subtree:!0}); getStarMark();}
}
// Function 2: 显示话题时间
if (settings.show_up_time) {
const getHue=(d,c)=>{const diff=Math.abs(c-d),base=2592e6,ratio=Math.min(Math.log(diff/base+1),1);return 120-140*ratio;};
const formatDate=d=>{const p=s=>String(s).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;};
const parseDate=s=>{
let p;
if ((p=s.match(/(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日\s*(\d+):(\d+)/))) return new Date(p[1],p[2]-1,p[3],p[4],p[5]);
if ((p=s.match(/(\w+)\s*(\d+),\s*(\d+)\s*(\d+):(\d+)\s*(am|pm)/i))) {
const m={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11};
let h=parseInt(p[4],10);
if(p[6].toLowerCase()==='pm'&&h<12)h+=12;
else if(p[6].toLowerCase()==='am'&&h===12)h=0;
return new Date(p[3],m[p[1]],p[2],h,p[5]);
}
if ((p=s.match(/(\d+)\s*(?:分钟|minute|min)s?前?/i))) return new Date(Date.now()-parseInt(p[1],10)*6e4);
if ((p=s.match(/(\d+)\s*(?:小时|hour)s?前?/i))) return new Date(Date.now()-parseInt(p[1],10)*36e5);
return null;
};
GM_addStyle(`.topic-list .topic-list-data.age.activity{width:12em;text-align:left;}.topic-list .topic-list-data.age.activity>a.post-activity{font-size:13px;line-height:1.5;text-wrap:nowrap;padding:4px 5px;display:block;}`);
const creatTimeShow = () => {
document.querySelectorAll(".topic-list-item").forEach(row => {
const item = row.querySelector(".num.topic-list-data.age.activity");
if (!item || item.dataset.customized) return;
item.dataset.customized = "true";
const timeSpan = item.querySelector("a.post-activity");
if (!timeSpan) return;
const timeInfo = item.title;
const now = new Date();
let createDate = null;
let updateDate = null;
let createStrMatch = timeInfo.match(/创建日期:([\s\S]+?)最新:/i) || timeInfo.match(/Created: ([\s\S]+?)Latest:/i);
let updateStrMatch = timeInfo.match(/最新:([\s\S]+)/i) || timeInfo.match(/Latest: ([\s\S]+)/i);
if (createStrMatch && createStrMatch[1]) {
// Primary method: parse from title attribute for older posts
createDate = parseDate(createStrMatch[1].trim());
if (updateStrMatch && updateStrMatch[1]) {
updateDate = parseDate(updateStrMatch[1].trim());
}
} else {
// Fallback method: parse from visible text for recent posts
const relativeTimeStr = timeSpan.textContent.trim();
const parsedRelativeDate = parseDate(relativeTimeStr);
if (parsedRelativeDate) {
createDate = parsedRelativeDate;
updateDate = parsedRelativeDate;
}
}
if (!createDate) return;
// --- Rendering ---
const createHue = getHue(createDate, now);
const formatCreate = formatDate(createDate);
let html = `<span style="color:hsl(${createHue}, 35%, 50%);">创建: ${formatCreate}</span><br>`;
if (updateDate) {
const updateHue = getHue(updateDate, now);
const formatNew = formatDate(updateDate);
html += `<span style="color:hsl(${updateHue}, 35%, 50%);">最新: ${formatNew}</span>`;
} else {
html += `<span style="color:#888;">最新: 暂无回复</span>`;
}
timeSpan.innerHTML = html;
// --- Fading old posts ---
const pastDays = Math.abs(createDate - now) / 864e5;
const title = row.querySelector(".main-link");
if (title) {
if (pastDays > 120) { title.style.filter = "sepia(90%) brightness(85%)"; }
else if (pastDays > 60) { title.style.opacity = 0.8; title.style.filter = "sepia(40%) brightness(85%)"; }
else if (pastDays > 30) { title.style.opacity = 0.9; title.style.filter = "grayscale(10%) sepia(10%)"; }
}
});
};
setInterval(creatTimeShow,1000);
}
// Function 3: 新窗口打开
if (settings.open_in_new) {
document.addEventListener('click', e => { const a = e.target.closest('a'); if (!a || !a.href || a.target || e.button !== 0 || e.ctrlKey || e.metaKey || a.href.startsWith('javascript:')) return; const ex = ['.d-header-icons','.nav-pills','.post-controls','.topic-meta-data .contents','.topic-timeline','.user-card-actions','.category-breadcrumb','.select-kit-header','.select-kit-row','.modal-body','.actions'].join(', '); if (a.closest(ex)) return; e.preventDefault(); e.stopImmediatePropagation(); window.open(a.href, '_blank'); }, true);
}
// Function 4: 显示更精确的回复时间
if (settings.show_floor_time) {
GM_addStyle(`.post-info.post-date > a > .relative-date {font-size: 0;} .post-info.post-date > a > .relative-date::after {content: attr(title); font-size: 14px;}`);
}
// Function 5: 等级与签到助手
if (settings.show_level_and_checkin) {
/* global Discourse */
(() => {
if (window !== window.top) return;
// ========== 脚本配置 ==========
const SCRIPT_CONFIG = {
baseURL: 'https://mjjbox.com',
notificationDuration: 3500,
requestTimeout: 15000,
maxInitAttempts: 50,
permissionsURL: 'https://mjjbox.com/t/topic/322',
defaults: {
autoCheckin: settings.auto_checkin, // Use main settings
checkinReminder: settings.checkin_reminder, // Use main settings
},
fallbackAvatar: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2NjYyI+PHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyIDE2di0yYzAtMi42NiA1LjMzLTQgOC00eiIvPjwvc3ZnPg==`,
};
// ========== 脚本状态 ==========
let isLoadingData = false;
let lastFetchedData = {};
/* ========== 等级与条件数据 ========== */
const levelNames = { 0: '青铜会员', 1: '白银会员', 2: '黄金会员', 3: '钻石会员', 4: '星曜会员' };
const levelRequirements = {
1: { topics_entered: 5, posts_read: 30, time_read: 10 * 60 },
2: { days_visited: 15, topics_entered: 20, posts_read: 100, time_read: 60 * 60, posts_created: 1, likes_received: 15, likes_given: 20, has_avatar: true, has_bio: true },
3: { days_visited_in_100: 50, topics_entered: 200, posts_read: 500, posts_created_in_100: 10, likes_received: 50, likes_given: 100, flagged_posts_ratio: 0.05 },
4: { manual_promotion: true }
};
const requirementMeta = {
topics_entered: { unit: '个', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg>` },
posts_read: { unit: '篇', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>` },
time_read: { unit: '分钟', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>` },
days_visited: { unit: '天', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>` },
days_visited_in_100: { unit: '天', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>` },
posts_created: { unit: '篇', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>` },
posts_created_in_100: { unit: '篇', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>` },
likes_received: { unit: '次', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>` },
likes_given: { unit: '次', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg>` },
has_avatar: { unit: '', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>` },
has_bio: { unit: '', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>` },
flagged_posts_ratio: { unit: '', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>` }
};
/* ========== CSS ========== */
const styles = `
:root { --mjj-purple: #7c3aed; --success-color: #00C0A8; --text-color-primary: #1f2937; --text-color-secondary: #6b7280; --bg-color-light: #ffffff; --bg-color-medium: #f7f8fa; --border-color: #e5e7eb; }
.mjjbox-level-badge { position: fixed; top: 20px; right: 20px; width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; color: #fff; cursor: pointer; z-index: 9999; box-shadow: 0 5px 25px -5px rgba(0,0,0,.3); border: 2px solid #fff; opacity: 0.4 !important; transition: transform .3s, box-shadow .3s, opacity .5s; }
.mjjbox-level-badge.loaded { opacity: 0.85 !important; }
.mjjbox-level-badge:hover { transform: scale(1.1); box-shadow: 0 8px 30px -8px rgba(0,0,0,.4); opacity: 1 !important; }
.mjjbox-level-badge.level-0 { background: linear-gradient(135deg,#9ca3af 0%,#6b7280 100%); }
.mjjbox-level-badge.level-1 { background: linear-gradient(135deg,#a7b9c9 0%,#7f92a5 100%); }
.mjjbox-level-badge.level-2 { background: linear-gradient(135deg,#fcc400 0%,#e1a100 100%); }
.mjjbox-level-badge.level-3 { background: linear-gradient(135deg,#9ae6b4 0%,#38a169 100%); }
.mjjbox-level-badge.level-4 { background: linear-gradient(135deg,#8b5cf6 0%,#7c3aed 100%); }
.mjjbox-level-modal { position: fixed; inset: 0; background: rgba(0,0,0,.2); z-index: 10000; opacity: 0; visibility: hidden; transition: opacity .35s,visibility .35s; backdrop-filter: blur(8px); }
.mjjbox-level-modal.show { opacity: 1; visibility: visible; }
.mjjbox-level-modal.show .mjjbox-level-modal-content { transform: scale(1); opacity: 1; }
.mjjbox-level-modal-content { position: absolute; top: 60px; right: 88px; background: var(--bg-color-medium); border-radius: 16px; width: 380px; box-shadow: 0 10px 40px -10px rgba(0,0,0,.3); overflow: hidden; transform-origin: top right; transform: scale(0.9); opacity: 0; transition: transform .3s, opacity .3s; }
.mjjbox-header { padding: 20px 45px 20px 20px; background: linear-gradient(135deg, #438EE2, #3973E0); color: white; display: flex; align-items: center; justify-content: space-between; gap: 10px; position: relative; }
.mjjbox-user-details { display: flex; align-items: center; gap: 15px; }
.mjjbox-avatar-link { display: block; line-height: 0; transition: transform 0.2s; border-radius: 50%; }
.mjjbox-avatar-link:hover { transform: scale(1.1); }
.mjjbox-avatar { width: 50px; height: 50px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.5); }
.mjjbox-user-info .username { font-size: 18px; font-weight: bold; margin: 0; }
.mjjbox-user-title { font-size: 12px; margin: 4px 0; background: rgba(255,255,255,0.15); padding: 2px 8px; border-radius: 8px; display: inline-block; font-weight: 500; }
.mjjbox-user-info .level { font-size: 14px; margin: 2px 0 0; }
.mjjbox-rank-info { text-align: right; flex-shrink: 0; }
.mjjbox-rank-info > div:first-child { margin-bottom: 6px; }
.mjjbox-rank-info .rank-value { font-size: 18px; font-weight: 700; color: #fff; margin-right: 5px; }
.mjjbox-rank-info .rank-label { font-size: 12px; opacity: 0.8; }
.mjjbox-close-btn { position: absolute; top: 12px; right: 12px; background: none; border: none; font-size: 24px; cursor: pointer; color: white; opacity: 0.7; transition: opacity 0.2s; }
.mjjbox-close-btn:hover { opacity: 1; }
.mjjbox-level-link, .mjjbox-level-link:visited { color: white !important; text-decoration: none; display: inline-block; transition: transform 0.2s, opacity 0.2s; opacity: 0.8; }
.mjjbox-level-link:hover { transform: scale(1.05); opacity: 1; }
.mjjbox-body { padding: 0 10px 15px; max-height: calc(80vh - 90px); overflow-y: auto; scrollbar-width: thin; scrollbar-color: #ccc #f7f8fa; }
.mjjbox-body::-webkit-scrollbar { width: 5px; }
.mjjbox-body::-webkit-scrollbar-track { background: var(--bg-color-medium); }
.mjjbox-body::-webkit-scrollbar-thumb { background-color: #ccc; border-radius: 3px; }
.mjjbox-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; padding: 15px 10px; }
.stat-item { background: linear-gradient(135deg, #5A9BE5, #438EE2); padding: 12px; border-radius: 10px; text-align: center; box-shadow: 0 4px 12px -2px rgba(74, 144, 226, 0.4); }
.stat-item .value, .stat-item .label { color: white; }
.stat-item .value { font-size: 18px; font-weight: bold; }
.stat-item .label { font-size: 12px; margin-top: 4px; opacity: 0.8; }
.mjjbox-progress-title { font-size: 16px; font-weight: 600; color: var(--text-color-primary); padding: 15px 10px 10px; margin: 0; border-top: 1px solid var(--border-color); }
.mjjbox-progress-list { display: flex; flex-direction: column; gap: 8px; padding: 0 10px; }
.progress-item { display: flex; align-items: center; gap: 12px; padding: 10px; background: var(--bg-color-light); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.progress-icon svg { width: 20px; height: 20px; color: var(--text-color-secondary); }
.progress-details { flex-grow: 1; }
.progress-label { display: flex; justify-content: space-between; font-size: 14px; font-weight: 500; color: var(--text-color-primary); margin-bottom: 5px; }
.progress-values { font-size: 12px; color: var(--text-color-secondary); }
.progress-bar { height: 6px; background: #eef2f7; border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #438EE2, #3973E0); transition: width .4s; }
.progress-item.met .progress-fill { background: var(--success-color); }
.progress-item.met .progress-icon svg { color: var(--success-color); }
.progress-item.info-only .progress-details { text-align: left; }
.progress-item.info-only .progress-label { margin-bottom: 0; }
.mjjbox-notification { position: fixed; top: 90px; right: 24px; padding: 12px 18px; border-radius: 8px; color: #fff; font-weight: 600; z-index: 10001; opacity: 0; transform: translateX(120%); transition: all .3s; }
.mjjbox-demotion-warning { padding: 10px; margin: 10px 10px 0; font-size: 12px; color: var(--text-color-secondary); background-color: #eef2f7; border-radius: 8px; }
.mjjbox-checkin-area { padding: 0 10px 10px; }
.mjjbox-checkin-btn { width: 100%; background: var(--mjj-purple); color: white; border: none; padding: 12px; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all .2s; box-shadow: 0 4px 15px -5px var(--mjj-purple); }
.mjjbox-checkin-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px -5px var(--mjj-purple); }
.mjjbox-checkin-btn:disabled { background: var(--success-color); box-shadow: 0 4px 15px -5px var(--success-color); cursor: not-allowed; }
@media (max-width: 480px) {
.mjjbox-level-badge { top: 85px; right: 15px; bottom: auto; }
.mjjbox-avatar-link { min-height: 44px; min-width: 44px; display: flex; align-items: center; justify-content: center; }
.mjjbox-level-modal-content { width: 92vw; max-width: 380px; top: 50%; left: 50%; right: auto; transform: translate(-50%, -50%) scale(0.95); max-height: 90vh; }
.mjjbox-level-modal.show .mjjbox-level-modal-content { transform: translate(-50%, -50%) scale(1); }
}
`;
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
/* ========== 帮助函数 ========== */
const getDiscourseUser = () => unsafeWindow.Discourse?.User?.current();
const getCurrentUsername = () => getDiscourseUser()?.username || null;
const getCsrfToken = () => getDiscourseUser()?.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content;
const formatRank = (rank) => {
if (rank === null || typeof rank !== 'number') return '--';
if (rank < 10000) return rank.toString();
return (rank / 10000).toFixed(1) + 'w';
};
const showNotification = (msg, type = 'info', dur = SCRIPT_CONFIG.notificationDuration) => {
const existingN = document.querySelector('.mjjbox-notification');
if (existingN) existingN.remove();
const n = document.createElement('div');
n.className = 'mjjbox-notification';
n.style.cssText += `background: ${type === 'error' ? '#ef4444' : (type === 'info' ? '#3b82f6' : '#10b981')};`;
n.textContent = msg;
document.body.appendChild(n);
setTimeout(() => { n.style.opacity = '1'; n.style.transform = 'translateX(0)'; }, 100);
setTimeout(() => { n.style.opacity = '0'; n.style.transform = 'translateX(120%)'; setTimeout(() => n.remove(), 300); }, dur);
};
/* ========== UI 创建 ========== */
const createLevelBadge = () => {
const badge = document.createElement('div');
badge.className = 'mjjbox-level-badge';
badge.innerHTML = 'LV ?';
badge.title = '点击加载等级与签到';
badge.addEventListener('click', () => fetchUserData(false));
document.body.appendChild(badge);
return badge;
};
const updateLevelBadge = (level, username) => {
const badge = document.querySelector('.mjjbox-level-badge');
if (!badge) return;
badge.innerHTML = `LV ${level}`;
const currentClasses = badge.className.split(' ').filter(c => !c.startsWith('level-')).join(' ');
badge.className = `${currentClasses} level-${level}`;
badge.title = `${username} - ${levelNames[level] || '未知等级'}(点击查看详情)`;
};
/* ========== 数据获取 ========== */
const fetchUserData = async (isRefresh = false) => {
if (isLoadingData) return;
isLoadingData = true;
const username = getCurrentUsername();
if (!username) { isLoadingData = false; return showNotification('❌ 无法获取当前用户名', 'error'); }
if (!isRefresh) { showNotification('🔄 正在获取最新数据...', 'info', 1500); }
try {
const [summaryData, userData, checkinData, dailyRankData, allTimeRankData] = await Promise.all([
new Promise(resolve => GM_xmlhttpRequest({ method: 'GET', url: `${SCRIPT_CONFIG.baseURL}/u/${username}/summary.json`, headers: { Accept: 'application/json' }, timeout: SCRIPT_CONFIG.requestTimeout, onload: r => resolve(r.status === 200 ? JSON.parse(r.responseText) : null), onerror: () => resolve(null) })),
new Promise(resolve => GM_xmlhttpRequest({ method: 'GET', url: `${SCRIPT_CONFIG.baseURL}/u/${username}.json`, headers: { Accept: 'application/json' }, timeout: SCRIPT_CONFIG.requestTimeout, onload: r => resolve(r.status === 200 ? JSON.parse(r.responseText) : null), onerror: () => resolve(null) })),
new Promise(resolve => GM_xmlhttpRequest({ method: 'GET', url: `${SCRIPT_CONFIG.baseURL}/checkin.json`, headers: { Accept: 'application/json' }, timeout: SCRIPT_CONFIG.requestTimeout, onload: r => resolve(r.status === 200 ? JSON.parse(r.responseText) : null), onerror: () => resolve(null) })),
new Promise(resolve => GM_xmlhttpRequest({ method: 'GET', url: `${SCRIPT_CONFIG.baseURL}/leaderboard/1.json?period=daily`, headers: { Accept: 'application/json' }, onload: r => resolve(r.status === 200 ? JSON.parse(r.responseText) : null), onerror: () => resolve(null) })),
new Promise(resolve => GM_xmlhttpRequest({ method: 'GET', url: `${SCRIPT_CONFIG.baseURL}/leaderboard/1.json`, headers: { Accept: 'application/json' }, onload: r => resolve(r.status === 200 ? JSON.parse(r.responseText) : null), onerror: () => resolve(null) }))
]);
processUserData(summaryData, userData, checkinData, dailyRankData, allTimeRankData, username);
} catch (error) {
showNotification('❌ 数据请求时发生未知错误', 'error');
console.error("Fetch User Data Error:", error);
} finally {
isLoadingData = false;
}
};
const handleRefreshAfterCheckin = async () => {
if (!getCurrentUsername()) return;
try {
// Refresh all data including ranks
fetchUserData(true);
} catch (error) {
showNotification('❌ 刷新签到数据失败', 'error');
console.error("Refresh After Checkin Error:", error);
}
};
const processUserData = (summaryData, userData, checkinData, dailyRankData, allTimeRankData, username) => {
if (!userData || !userData.user) { return showNotification('❌ 获取核心用户数据(user.json)失败', 'error'); }
const userSummary = summaryData?.user_summary || userData.user?.user_summary;
if (!userSummary) { return showNotification('❌ 用户摘要数据不完整或格式错误', 'error'); }
lastFetchedData = { summaryData, userData, checkinData, dailyRankData, allTimeRankData };
const user = userData.user;
const dailyRank = dailyRankData?.personal?.position || null;
const allTimeRank = allTimeRankData?.personal?.position || null;
if (typeof user.trust_level === 'number') {
const level = user.trust_level;
updateLevelBadge(level, username);
const badge = document.querySelector('.mjjbox-level-badge');
if (badge) badge.classList.add('loaded');
createLevelModal({
level, username, checkinData, dailyRank, allTimeRank,
userData: { user, userSummary, gamification_score: user.gamification_score || 0 }
});
} else {
showNotification('❌ 无法解析用户等级信息', 'error');
}
};
const calculateLevelProgress = (currentLevel, userData) => {
if (!userData?.userSummary) return { items: [] };
const us = userData.userSummary;
const u = userData.user;
const next = currentLevel + 1;
const req = levelRequirements[next];
if (!req) return { items: [], isMaxLevel: true };
const items = [];
const add = (label, current, required, key, title = '') => {
const met = current >= required;
items.push({ label, current, required, isMet: met, percentage: Math.min((current / required) * 100, 100), key, unit: requirementMeta[key]?.unit || '', title });
};
const addBoolean = (label, isMet, key) => {
items.push({ label, current: isMet ? '已完成' : '未完成', required: '需完成', isMet, key, unit: '' });
};
const addInfo = (label, key) => {
items.push({ label, key, isInfo: true });
};
if (req.topics_entered !== undefined) add('阅读主题', us.topics_entered || 0, req.topics_entered, 'topics_entered');
if (req.posts_read !== undefined) add('阅读帖子', us.posts_read_count || 0, req.posts_read, 'posts_read');
if (req.time_read !== undefined) add('阅读时长', Math.floor((us.time_read || 0) / 60), Math.floor(req.time_read / 60), 'time_read');
if (req.days_visited !== undefined) add('访问天数', us.days_visited || 0, req.days_visited, 'days_visited');
if (req.days_visited_in_100 !== undefined) add('⚠️ 近期访问', us.days_visited || 0, req.days_visited_in_100, 'days_visited_in_100', '注意:此为总访问天数(近似值),非精确的近期数值,仅供参考。');
if (req.posts_created !== undefined) add('发布主题', us.topic_count || 0, req.posts_created, 'posts_created');
if (req.posts_created_in_100 !== undefined) add('⚠️ 近期发帖/回复', (us.topic_count || 0) + (us.post_count || 0), req.posts_created_in_100, 'posts_created_in_100', '注意:此为总发帖/回复数(近似值),非精确的近期数值,仅供参考。');
if (req.likes_received !== undefined) add('收到点赞', us.likes_received || 0, req.likes_received, 'likes_received');
if (req.likes_given !== undefined) add('送出点赞', us.likes_given || 0, req.likes_given, 'likes_given');
if (req.has_avatar !== undefined) addBoolean('设置头像', !!(u.avatar_template && !u.avatar_template.includes('letter_avatar')), 'has_avatar');
if (req.has_bio !== undefined) addBoolean('填写简介', !!(u.bio_raw && u.bio_raw.trim()), 'has_bio');
if (req.flagged_posts_ratio !== undefined) addInfo('帖子声誉良好', 'flagged_posts_ratio');
items.sort((a, b) => a.isMet - b.isMet);
return { items, isMaxLevel: !levelRequirements[next] };
};
const _performCheckinRequest = () => {
return new Promise((resolve, reject) => {
const csrfToken = getCsrfToken();
if (!csrfToken) {
return reject({ type: 'Auth', message: '无法获取安全令牌,请刷新页面' });
}
GM_xmlhttpRequest({
method: 'POST',
url: `${SCRIPT_CONFIG.baseURL}/checkin`,
headers: { 'Accept': 'application/json', 'X-CSRF-Token': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
timeout: SCRIPT_CONFIG.requestTimeout,
onload: (resp) => {
let responseData;
try { responseData = JSON.parse(resp.responseText); }
catch (e) {
if (resp.status === 200) return reject({ type: 'Parse', message: '签到响应格式异常', status: resp.status });
}
switch (resp.status) {
case 200:
responseData.success ? resolve(responseData) : reject({ type: 'API', message: responseData.message || '签到失败', data: responseData });
break;
case 403:
reject({ type: 'Auth', message: '权限不足,请重新登录', status: resp.status });
break;
case 422:
if (resp.responseText?.includes("already checked in") || resp.responseText?.includes("已经签到过")) {
reject({ type: 'Duplicate', message: '您今天已经签到过了', data: responseData });
} else {
reject({ type: 'Validation', message: '安全验证失败,请刷新页面', status: resp.status });
}
break;
default:
reject({ type: 'HTTP', message: `签到失败,服务器响应: ${resp.status}`, status: resp.status });
}
},
onerror: () => reject({ type: 'Network', message: '签到请求失败,请检查网络连接' })
});
});
};
const handleCheckin = async (btn) => {
if (!getCurrentUsername()) return showNotification('❌ 请先登录后再签到', 'error');
btn.disabled = true;
btn.textContent = '处理中...';
try {
const result = await _performCheckinRequest();
showNotification(`🎉 ${result.message || '签到成功!'}`, 'success');
const existingModal = document.querySelector('.mjjbox-level-modal');
if (existingModal) {
existingModal.classList.remove('show');
setTimeout(() => { existingModal.remove(); handleRefreshAfterCheckin(); }, 350);
}
} catch (error) {
console.error("签到错误:", error);
if (error.type === 'Duplicate') {
showNotification(`😅 ${error.message}`, 'info');
const existingModal = document.querySelector('.mjjbox-level-modal');
if (existingModal) {
existingModal.classList.remove('show');
setTimeout(() => { existingModal.remove(); handleRefreshAfterCheckin(); }, 350);
}
} else {
showNotification(`❌ ${error.message}`, 'error');
btn.disabled = false;
btn.textContent = '🚀 立即签到';
}
}
};
const runAutomation = async () => {
if (!settings.auto_checkin && !settings.checkin_reminder) {
return;
}
const liveStatus = await new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: `${SCRIPT_CONFIG.baseURL}/checkin.json`,
headers: { Accept: 'application/json' },
timeout: SCRIPT_CONFIG.requestTimeout,
onload: r => {
if (r.status === 200) {
try {
const data = JSON.parse(r.responseText);
resolve({ today_checked_in: data.today_checked_in === true });
} catch (e) {
console.error("MJJBOX 脚本: 解析 checkin.json 失败。", e);
resolve({ today_checked_in: true });
}
} else {
console.warn(`MJJBOX 脚本: 自动签到状态检查失败, HTTP 状态: ${r.status}`);
resolve({ today_checked_in: true });
}
},
onerror: (e) => {
console.error("MJJBOX 脚本: 自动签到状态检查网络错误。", e);
resolve({ today_checked_in: true });
}
});
});
if (liveStatus.today_checked_in) {
console.log("MJJBOX 脚本: 服务器报告今日已签到,自动化任务跳过。");
return;
}
console.log("MJJBOX 脚本: 服务器报告今日未签到,开始执行自动化任务。");
if (settings.auto_checkin) {
showNotification('🚀 正在尝试自动签到...', 'info', 2000);
try {
const result = await _performCheckinRequest();
showNotification(`✅ 自动签到成功: ${result.message || ''}`, 'success');
} catch (error) {
if (error.type !== 'Duplicate') {
showNotification(`❌ 自动签到失败: ${error.message}`, 'error');
}
}
} else if (settings.checkin_reminder) {
setTimeout(() => {
showNotification('🤔 今日尚未签到,记得点击徽章签到哦!', 'info', 6000);
}, 3000);
}
};
const createLevelModal = ({ level, username, userData, checkinData, dailyRank, allTimeRank }) => {
const oldModal = document.querySelector('.mjjbox-level-modal');
if(oldModal) oldModal.remove();
const modal = document.createElement('div');
modal.className = 'mjjbox-level-modal';
document.body.appendChild(modal);
const progress = calculateLevelProgress(level, userData);
const hasCheckedIn = checkinData?.today_checked_in || false;
const canCheckIn = checkinData?.can_check_in ?? !hasCheckedIn;
const isButtonDisabled = hasCheckedIn || !canCheckIn;
const pointsToday = (checkinData?.checkin_history?.[0]?.points_earned) ?? 0;
const totalCheckins = checkinData?.user_checkin_count || 0;
const consecutiveDays = checkinData?.consecutive_days || 0;
const avatarUrl = userData.user.avatar_template.replace('{size}', '100');
const userTitle = userData.user.title || '';
let checkinBtnText = '🚀 立即签到';
if (hasCheckedIn) {
checkinBtnText = `✅ 今日已签到 (获得 ${pointsToday} 积分)`;
} else if (!canCheckIn) {
checkinBtnText = '🚫 无签到权限';
}
const currentPoints = userData.gamification_score || 0;
const content = document.createElement('div');
content.className = 'mjjbox-level-modal-content';
content.innerHTML = `
<div class="mjjbox-header">
<div class="mjjbox-user-details">
<a href="${SCRIPT_CONFIG.baseURL}/u/${encodeURIComponent(username)}/summary" target="_blank" class="mjjbox-avatar-link" title="点击查看个人总结页">
<img src="${avatarUrl}" alt="avatar" class="mjjbox-avatar" onerror="this.onerror=null; this.src='${SCRIPT_CONFIG.fallbackAvatar}'">
</a>
<div class="mjjbox-user-info">
<h2 class="username">${username}</h2>
${userTitle ? `<p class="mjjbox-user-title">${userTitle}</p>` : ''}
<a href="${SCRIPT_CONFIG.permissionsURL}" target="_blank" class="mjjbox-level-link" title="点击查看社区权限对照表">
<p class="level">LV${level} ${levelNames[level]}</p>
</a>
</div>
</div>
<div class="mjjbox-rank-info">
<div>
<span class="rank-value">#${formatRank(allTimeRank)}</span>
<span class="rank-label">总排名</span>
</div>
<div>
<span class="rank-value">#${formatRank(dailyRank)}</span>
<span class="rank-label">今日排名</span>
</div>
</div>
<button class="mjjbox-close-btn">×</button>
</div>
<div class="mjjbox-body">
<div class="mjjbox-stats-grid">
<div class="stat-item"><div class="value">${totalCheckins} 次</div><div class="label">总签到</div></div>
<div class="stat-item"><div class="value">${consecutiveDays} 天</div><div class="label">连续签到</div></div>
<div class="stat-item"><div class="value">${currentPoints}</div><div class="label">总积分</div></div>
</div>
<div class="mjjbox-checkin-area">
<button id="do-checkin-btn" class="mjjbox-checkin-btn" ${isButtonDisabled ? 'disabled' : ''}>${checkinBtnText}</button>
</div>
<h3 class="mjjbox-progress-title">${progress.isMaxLevel ? '🎉 已是最高等级' : `LV${level+1} 晋级进度`}</h3>
<div class="mjjbox-progress-list">
${progress.items.map(item => {
if (item.isInfo) {
return `
<div class="progress-item info-only">
<div class="progress-icon">${requirementMeta[item.key]?.icon || ''}</div>
<div class="progress-details">
<div class="progress-label">
<span>${item.label}</span>
<span class="progress-values">由系统自动评估</span>
</div>
</div>
</div>`;
}
return `
<div class="progress-item ${item.isMet ? 'met' : ''}" title="${item.title || ''}">
<div class="progress-icon">${requirementMeta[item.key]?.icon || ''}</div>
<div class="progress-details">
<div class="progress-label">
<span>${item.label}</span>
<span class="progress-values">${item.current}${item.unit} / ${item.required}${item.unit}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${item.percentage || 0}%"></div>
</div>
</div>
</div>`;
}).join('')}
</div>
${(level >= 2 && level < 4) ? `
<div class="mjjbox-demotion-warning">
💡 <strong>友情提示</strong>:达到 LV3 后,需保持活跃度,否则系统会自动降级哦。
</div>
` : ''}
</div>
`;
modal.appendChild(content);
setTimeout(() => modal.classList.add('show'), 10);
const checkinBtn = content.querySelector('#do-checkin-btn');
if (checkinBtn) { checkinBtn.addEventListener('click', () => handleCheckin(checkinBtn)); }
const closeModal = () => {
modal.classList.remove('show');
document.removeEventListener('keydown', escListener);
setTimeout(() => { modal.remove(); }, 350);
};
const escListener = e => { if (e.key === 'Escape') closeModal(); };
content.querySelector('.mjjbox-close-btn').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', escListener);
return modal;
};
const init = () => {
let attempts = 0;
const interval = setInterval(() => {
if (typeof unsafeWindow.Discourse?.User?.current === 'function') {
clearInterval(interval);
console.log("MJJBOX 脚本: Discourse 对象已加载,开始初始化。");
createLevelBadge();
if (getCurrentUsername()) {
setTimeout(() => runAutomation(), 2000);
}
} else {
attempts++;
if (attempts >= SCRIPT_CONFIG.maxInitAttempts) {
clearInterval(interval);
showNotification('❌ 脚本初始化失败,请刷新页面', 'error', 5000);
console.error("MJJBOX 脚本: 等待 Discourse 对象超时。");
}
}
}, 200);
};
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
else { init(); }
})();
}
// Function 6: 返回顶部按钮
if (settings.back_to_top) {
GM_addStyle(`#back-to-top-btn { position: fixed; bottom: 28px; right: 90px; width: 45px; height: 45px; background-color: var(--primary-medium, #666); color: #fff; border: none; border-radius: 50%; cursor: pointer; display: none; justify-content: center; align-items: center; font-size: 24px; z-index: 9998; opacity: 0.7; transition: opacity 0.3s, background-color 0.3s, transform 0.3s; } #back-to-top-btn:hover { opacity: 1; transform: scale(1.1); background-color: var(--tertiary, #3b82f6); }`);
const btn = document.createElement('button');
btn.id = 'back-to-top-btn';
btn.innerHTML = '↑';
btn.title = '返回顶部';
document.body.appendChild(btn);
window.addEventListener('scroll', () => { if (window.scrollY > 300) { btn.style.display = 'flex'; } else { btn.style.display = 'none'; } });
btn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); });
}
// Function 7: 自动阅读控件
if (settings.show_auto_read_controls) {
// 定义基本URL
const possibleBaseURLs = [
"https://mjjbox.com"
];
const commentLimit = 1000;
const topicListLimit = 100;
const likeLimit = 50;
// 获取当前页面的URL
const currentURL = window.location.href;
// 确定当前页面对应的BASE_URL
let BASE_URL = possibleBaseURLs.find((url) => currentURL.startsWith(url));
console.log("currentURL:", currentURL);
// 环境变量:阅读网址,如果没有找到匹配的URL,则默认为第一个
if (!BASE_URL) {
BASE_URL = possibleBaseURLs[0];
console.log("默认BASE_URL设置为: " + BASE_URL);
} else {
console.log("当前BASE_URL是: " + BASE_URL);
}
console.log("脚本正在运行在: " + BASE_URL);
// 检查是否是第一次运行脚本
function checkFirstRun() {
if (localStorage.getItem("isFirstRun") === null) {
console.log("脚本第一次运行,执行初始化操作...");
updateInitialData();
localStorage.setItem("isFirstRun", "false");
} else {
console.log("脚本非第一次运行");
}
}
// 更新初始数据的函数
function updateInitialData() {
localStorage.setItem("read", "false"); // 开始时自动滚动关闭
localStorage.setItem("autoLikeEnabled", "false"); //默认关闭自动点赞
console.log("执行了初始数据更新操作");
}
const delay = 2000; // 滚动检查的间隔(毫秒)
let scrollInterval = null;
let checkScrollTimeout = null;
let autoLikeInterval = null;
function scrollToBottomSlowly(distancePerStep = 20, delayPerStep = 50) {
if (scrollInterval !== null) {
clearInterval(scrollInterval);
}
scrollInterval = setInterval(() => {
window.scrollBy(0, distancePerStep);
}, delayPerStep); // 每50毫秒滚动20像素
}
function getLatestTopic() {
let latestPage = Number(localStorage.getItem("latestPage")) || 0;
let topicList = [];
let isDataSufficient = false;
while (!isDataSufficient) {
latestPage++;
const url = `${BASE_URL}/latest.json?no_definitions=true&page=${latestPage}`;
// This part requires jQuery ($), which might not be available.
// Using GM_xmlhttpRequest for consistency if jQuery is not guaranteed.
// However, Discourse sites usually have jQuery.
if (typeof $ !== 'undefined') {
$.ajax({
url: url,
async: false,
success: function (result) {
if (
result &&
result.topic_list &&
result.topic_list.topics.length > 0
) {
result.topic_list.topics.forEach((topic) => {
if (commentLimit > topic.posts_count) {
topicList.push(topic);
}
});
if (topicList.length >= topicListLimit) {
isDataSufficient = true;
}
} else {
isDataSufficient = true;
}
},
error: function (XMLHttpRequest, textStatus, errorThrown) {
console.error(XMLHttpRequest, textStatus, errorThrown);
isDataSufficient = true;
},
});
} else {
console.error("jQuery not found for auto-read function.");
isDataSufficient = true; // Stop loop if no jQuery
}
}
if (topicList.length > topicListLimit) {
topicList = topicList.slice(0, topicListLimit);
}
localStorage.setItem("topicList", JSON.stringify(topicList));
}
function openNewTopic() {
let topicListStr = localStorage.getItem("topicList");
let topicList = topicListStr ? JSON.parse(topicListStr) : [];
if (topicList.length === 0) {
getLatestTopic();
topicListStr = localStorage.getItem("topicList");
topicList = topicListStr ? JSON.parse(topicListStr) : [];
}
if (topicList.length > 0) {
const topic = topicList.shift();
localStorage.setItem("topicList", JSON.stringify(topicList));
if (topic.last_read_post_number) {
window.location.href = `${BASE_URL}/t/topic/${topic.id}/${topic.last_read_post_number}`;
} else {
window.location.href = `${BASE_URL}/t/topic/${topic.id}`;
}
}
}
function checkScroll() {
if (localStorage.getItem("read") === "true") {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
console.log("已滚动到底部");
openNewTopic();
} else {
scrollToBottomSlowly();
if (checkScrollTimeout !== null) {
clearTimeout(checkScrollTimeout);
}
checkScrollTimeout = setTimeout(checkScroll, delay);
}
}
}
window.addEventListener("load", () => {
checkFirstRun();
if (localStorage.getItem("read") === "true") {
console.log("自动阅读已启用,判断页面类型...");
if (currentURL.includes('/t/topic/')) {
console.log("当前是话题页面,执行滚动逻辑");
checkScroll();
if (isAutoLikeEnabled()) {
autoLike();
}
} else {
console.log("当前是列表页面,直接打开新话题");
openNewTopic();
}
}
});
const currentTime = Date.now();
const defaultTimestamp = new Date("1999-01-01T00:00:00Z").getTime();
const storedTime = parseInt(localStorage.getItem("clickCounterTimestamp") || defaultTimestamp.toString(), 10);
let clickCounter = parseInt(localStorage.getItem("clickCounter") || "0", 10);
if (currentTime - storedTime > 24 * 60 * 60 * 1000) {
clickCounter = 0;
localStorage.setItem("clickCounter", "0");
localStorage.setItem("clickCounterTimestamp", currentTime.toString());
}
function triggerClick(button) {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
});
button.dispatchEvent(event);
}
function autoLike() {
const buttons = document.querySelectorAll("button.like:not(.user-liked)");
if (buttons.length === 0) {
return;
}
buttons.forEach((button, index) => {
if (button.title !== "赞" || clickCounter >= likeLimit) {
return;
}
autoLikeInterval = setTimeout(() => {
triggerClick(button);
clickCounter++;
localStorage.setItem("clickCounter", clickCounter.toString());
if (clickCounter === likeLimit) {
localStorage.setItem("autoLikeEnabled", "false");
}
}, index * 3000);
});
}
const button = document.createElement("button");
button.textContent = localStorage.getItem("read") === "true" ? "停止阅读" : "开始阅读";
button.style.cssText = "position: fixed; bottom: 10px; left: 10px; z-index: 1000; background-color: #f0f0f0; color: #000; border: 1px solid #ddd; padding: 5px 10px; border-radius: 5px;";
document.body.appendChild(button);
button.onclick = function () {
const currentlyReading = localStorage.getItem("read") === "true";
const newReadState = !currentlyReading;
localStorage.setItem("read", newReadState.toString());
button.textContent = newReadState ? "停止阅读" : "开始阅读";
if (!newReadState) {
if (scrollInterval !== null) clearInterval(scrollInterval);
if (checkScrollTimeout !== null) clearTimeout(checkScrollTimeout);
scrollInterval = null;
checkScrollTimeout = null;
localStorage.removeItem("navigatingToNextTopic");
} else {
openNewTopic();
}
};
const toggleAutoLikeButton = document.createElement("button");
const isAutoLikeEnabled = () => localStorage.getItem("autoLikeEnabled") === "true";
const setAutoLikeEnabled = (enabled) => localStorage.setItem("autoLikeEnabled", enabled ? "true" : "false");
toggleAutoLikeButton.textContent = isAutoLikeEnabled() ? "禁用自动点赞" : "启用自动点赞";
toggleAutoLikeButton.style.cssText = "position: fixed; bottom: 50px; left: 10px; z-index: 1000; background-color: #f0f0f0; color: #000; border: 1px solid #ddd; padding: 5px 10px; border-radius: 5px;";
document.body.appendChild(toggleAutoLikeButton);
toggleAutoLikeButton.addEventListener("click", () => {
const isEnabled = !isAutoLikeEnabled();
setAutoLikeEnabled(isEnabled);
toggleAutoLikeButton.textContent = isEnabled ? "禁用自动点赞" : "启用自动点赞";
});
}
})();