// ==UserScript==
// @name NexusPHP PT 自动晋级助手
// @namespace https://github.com/493Arceus/nexusphp-auto-promote
// @version 1.6.2
// @description 自动解析 NexusPHP PT 站点常见问题中的等级晋级/降级规则,根据规则使用魔力值兑换上传/下载量,辅助用户保持分享率并自动晋级
// @author superuser and Codex
// @license MIT
//
// @match *://*/*
// @exclude *://github.com/*
// @exclude *://*.github.com/*
// @noframes
//
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_notification
// @grant GM_registerMenuCommand
// @grant unsafeWindow
//
// @connect *
// @run-at document-end
// @supportURL https://scriptcat.org/zh-CN/script-show-page/7191
//
// @description:zh-CN 自动解析 NexusPHP PT 站点常见问题中的等级晋级/降级规则,根据规则使用魔力值兑换上传/下载量,辅助用户保持分享率并自动晋级
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// 1. CONFIGURATION
// ============================================================
const CONFIG = {
// 功能开关
ENABLE_AUTO_EXCHANGE: true, // 是否启用自动兑换
ENABLE_DEMOTION_WARNING: true, // 是否启用降级警告
ENABLE_PROMOTION_CHECK: true, // 是否启用晋级提示
// 兑换策略
EXCHANGE_STRATEGY: 'balanced', // 'conservative' | 'balanced' | 'aggressive'
CONSERVATIVE_BUFFER: 1.05, // 保守策略:目标分享率 = 要求 * 1.05
BALANCED_BUFFER: 1.10, // 平衡策略:目标分享率 = 要求 * 1.10
AGGRESSIVE_BUFFER: 1.20, // 激进策略:目标分享率 = 要求 * 1.20
// 界面
PANEL_POSITION: 'top-right', // 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
PANEL_OPACITY: 0.95,
AUTO_HIDE_DELAY: 0, // 自动隐藏延迟(毫秒),0 表示不隐藏
// 节流
STATS_REFRESH_INTERVAL: 60000, // 状态刷新间隔(毫秒)
EXCHANGE_COOLDOWN: 10000, // GGPT 站点限制:10 秒内只能交换一次
// 自动兑换默认值(均可在设置面板修改)
AUTO_EXCHANGE_DEFAULTS: {
autoExchange: false, // 自动兑换总开关(默认关,需手动开启)
autoExchangeAccountId: null,
autoExchangeTaskVersion: 0,
autoExchangeMode: 'promotion',// 'promotion' 晋级驱动 | 'fixed' 定时定额 | 'amount' 按量完成
autoExchangeIntervalMin: 60, // 兑换间隔(分钟)
autoExchangeFixedGB: 100, // 定时定额模式:每次兑换 GB 数
autoExchangeFixedType: 'upload', // 定时定额模式:兑换类型 'upload' | 'download'
autoExchangeAmountGB: 0, // 按量完成模式:目标兑换量(GB)
autoExchangeAmountTierKey: '',// 按量完成模式:站点档位,格式 type:value
autoExchangeAmountRemainingGB: 0, // 按量完成模式:尚需完成的目标量(GB)
autoExchangeAmountCompletedGB: 0, // 按量完成模式:已完成的实际兑换量(GB)
autoExchangeCooldownMode: 'auto', // 'auto' 读取站点提示 | 'manual' 手动填写秒数
autoExchangeCooldownSeconds: 10, // 手动冷却时间(秒)
autoExchangeMaxGBPerRun: 100, // 单次兑换上限(GB,安全保护)
autoExchangeMinBonusKeep: 0, // 兑换后保留的最低魔力值
},
// 存储键名
STORAGE_KEYS: {
RULES: 'NP_RULES',
USER_STATS: 'NP_USER_STATS',
LAST_EXCHANGE: 'NP_LAST_EXCHANGE',
EXCHANGE_STOP: 'NP_EXCHANGE_STOP',
SETTINGS: 'NP_SETTINGS',
PARSED_FAQ: 'NP_PARSED_FAQ',
},
};
// ============================================================
// 2. CONSTANTS & ENUMS
// ============================================================
const CLASS_LEVELS = {
PEASANT: { order: 0, cn: ['出游者', '降级', '新手', 'Peasant'], weight: 0 },
USER: { order: 1, cn: ['初游者', '用户', 'User'], weight: 1 },
POWER_USER: { order: 2, cn: ['游侠', 'Power User', 'PU', '中级'], weight: 2 },
ELITE_USER: { order: 3, cn: ['游戏精通', 'Elite User', 'EU', '高级'], weight: 3 },
CRAZY_USER: { order: 4, cn: ['游戏大师', 'Crazy User', 'CU', '精英'], weight: 4 },
INSANE_USER: { order: 5, cn: ['游戏大宗', 'Insane User', 'IU', 'Insane'], weight: 5 },
VETERAN_USER: { order: 6, cn: ['游戏大神', 'Veteran User', 'VU', '专家', 'Veteran'], weight: 6 },
EXTREME_USER: { order: 7, cn: ['游戏大仙', 'Extreme User', 'Extreme', '冠军'], weight: 7 },
ULTIMATE_USER: { order: 8, cn: ['修仙', 'Ultimate User', 'UU', '大师', 'Ultimate'], weight: 8 },
NEXUS_MASTER: { order: 9, cn: ['游戏帝', 'Nexus Master', 'NM', '传说'], weight: 9 },
VIP: { order: 10, cn: ['入尘散仙', 'VIP', '贵宾'], weight: 10, special: true },
UPLOADER: { order: 11, cn: ['Uploader', '发布员'], weight: 11, special: true },
MODERATOR: { order: 12, cn: ['Moderator', '总版主'], weight: 12, special: true },
ADMIN: { order: 13, cn: ['Administrator', '管理员', 'Admin'], weight: 13, special: true },
SYSOP: { order: 14, cn: ['Sysop', '维护人员'], weight: 14, special: true },
STAFF_LEADER: { order: 15, cn: ['Staff Leader', '站长'], weight: 15, special: true },
};
/**
* 站点专属等级要求。
* GGPT 数据来源与 PT-depiler 保持一致:站点规则是静态 metadata,用户数据另从登录态页面读取。
* Source (pinned): https://github.com/pt-plugins/PT-depiler/blob/26eab03daccdb06f6ef6348d3f1f00f02a576468/src/packages/site/definitions/ggpt.ts#L74-L150
*/
const SITE_LEVEL_METADATA = {
ggpt: {
hosts: ['gamegamept.com'],
version: 'pt-depiler-26eab03d',
levelRequirements: [
{ id: 0, name: 'User' },
{ id: 1, name: 'Power User', interval: 'P4W', downloaded: '50GB', ratio: 2, seedingBonus: 40000 },
{ id: 2, name: 'Elite User', interval: 'P8W', downloaded: '100GB', ratio: 2.5, seedingBonus: 80000 },
{ id: 3, name: 'Crazy User', interval: 'P15W', downloaded: '300GB', ratio: 3, seedingBonus: 150000 },
{ id: 4, name: 'Insane User', interval: 'P25W', downloaded: '500GB', ratio: 3.5, seedingBonus: 250000 },
{ id: 5, name: 'Veteran User', interval: 'P40W', downloaded: '1TB', ratio: 4, seedingBonus: 400000 },
{ id: 6, name: 'Extreme User', interval: 'P60W', downloaded: '2TB', ratio: 4.5, seedingBonus: 600000, isKept: true },
{ id: 7, name: 'Ultimate User', interval: 'P80W', downloaded: '5TB', ratio: 5, seedingBonus: 800000, isKept: true },
{ id: 8, name: 'Nexus Master', interval: 'P100W', downloaded: '10TB', ratio: 5.5, seedingBonus: 1000000, isKept: true },
],
},
};
// 正则表达式模式(用于解析规则)
const PATTERNS = {
// 注册时间要求
REG_TIME: /注册(?:至少|满|超过)?\s*(\d+)\s*(周|周|weeks?|weeks?|天|days?|月|months?)/i,
// 上传量要求
UPLOAD_REQ: /上[传傳](?:至少|超过|大于|>=|>)?\s*(\d+(?:\.\d+)?)\s*(TB|GB|MB|TiB|GiB|MiB)?/i,
// 下载量要求
DOWNLOAD_REQ: /下[载載](?:至少|超过|大于|>=|>)?\s*(\d+(?:\.\d+)?)\s*(TB|GB|MB|TiB|GiB|MiB)?/i,
// 分享率要求(包含"大于或等于""大于等于""≥"等变体)
RATIO_REQ: /分享率(?:大于或等于|大于等于|大于|高于|超过|≥|>=|>)?\s*(\d+(?:\.\d+)?)/i,
// 分享率降级阈值
RATIO_DEMOTE: /(?:分享率|share\s*ratio|ratio)\s*(?:低于|小于或等于|小于等于|小于|≤|<=|<|below|less\s+than)\s*(\d+(?:\.\d+)?)/i,
// 做种积分要求
BONUS_REQ: /(?:做种积分|做種積分|Seeding Points?|保种积分|seeding bonus)(?:大于|高于|超过|>=|>|至少)?\s*(\d+(?:\.\d+)?)/i,
// 通用大小格式
SIZE_WITH_UNIT: /(\d+(?:\.\d+)?)\s*(TB|GB|MB|TiB|GiB|MiB)/i,
// 降级关键词
DEMOTE_KEYWORDS: /降级|demoted?|downgrade|降格/i,
// 自动提升关键词
PROMOTE_KEYWORDS: /自动提升|自动升级|promot(?:e|ed|ion)|upgrade/i,
};
// ============================================================
// 3. UTILITY FUNCTIONS
// ============================================================
/** 解析大小字符串为字节数 */
function parseSize(sizeStr, unit) {
const val = parseFloat(sizeStr);
if (isNaN(val)) return 0;
const u = (unit || 'GB').toUpperCase();
if (u === 'TB' || u === 'TIB') return val * 1024 * 1024 * 1024 * 1024;
if (u === 'GB' || u === 'GIB') return val * 1024 * 1024 * 1024;
if (u === 'MB' || u === 'MIB') return val * 1024 * 1024;
if (u === 'KB' || u === 'KIB') return val * 1024;
return val;
}
/** 将字节格式化为可读大小 */
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const val = (bytes / Math.pow(k, i)).toFixed(2);
return val + ' ' + units[i];
}
/** 格式化百分比 */
function formatPercent(val) {
return (val * 100).toFixed(1) + '%';
}
/** 将等级名归一化,只用于完整字段值比较,不做全文子串扫描。 */
function normalizeClassName(text) {
return String(text || '').toLowerCase().replace(/[\s_\-()()[\]【】]/g, '');
}
/**
* 精确匹配等级名。允许“游戏大仙 (Extreme User)”这类括号双语值,
* 但不会把用户名 superuser 或普通正文中的 user 误认成 User。
*/
function matchClassOrderExact(text) {
if (!text) return -1;
const raw = String(text).trim();
const candidates = [raw]
.concat(raw.split(/[()()[\]【】|/]/))
.map(normalizeClassName)
.filter(Boolean);
for (const level of Object.values(CLASS_LEVELS)) {
for (const name of level.cn) {
if (candidates.includes(normalizeClassName(name))) return level.order;
}
}
return -1;
}
/** 将站点中文等级与脚本中的标准英文等级组合展示。 */
function formatClassDisplayName(className, classOrder) {
const current = String(className || '').trim();
if (!current) return '未知';
const level = Object.values(CLASS_LEVELS).find(item => item.order === Number(classOrder));
const englishName = level?.cn?.find(name => /[A-Za-z]/.test(name));
if (!englishName) return current;
const normalizedCurrent = normalizeClassName(current);
const normalizedEnglish = normalizeClassName(englishName);
if (normalizedCurrent === normalizedEnglish) return englishName;
if (normalizedCurrent.includes(normalizedEnglish)) return current;
return `${current}(${englishName})`;
}
/** 获取当前域名对应的站点等级 metadata。 */
function getSiteLevelMetadata(hostname) {
const host = String(hostname || window.location.hostname || '').toLowerCase();
return Object.values(SITE_LEVEL_METADATA).find(site =>
site.hosts.some(domain => host === domain || host.endsWith(`.${domain}`))
) || null;
}
/** 将 PT-depiler 风格的 levelRequirements 转为本脚本规则结构。 */
function getCanonicalSiteRules(hostname) {
const site = getSiteLevelMetadata(hostname);
if (!site) return [];
return site.levelRequirements.map(level => {
const intervalMatch = String(level.interval || '').match(/^P(\d+)W$/i);
const sizeMatch = String(level.downloaded || '').match(/([\d.]+)\s*(TB|GB|MB|TiB|GiB|MiB)/i);
const promotion = {};
if (intervalMatch) promotion.regWeeks = Number(intervalMatch[1]);
if (sizeMatch) {
promotion.downloadReq = { value: Number(sizeMatch[1]), unit: sizeMatch[2] };
promotion.downloadBytes = parseSize(sizeMatch[1], sizeMatch[2]);
}
if (Number.isFinite(level.ratio)) promotion.ratioReq = level.ratio;
if (Number.isFinite(level.seedingBonus)) promotion.bonusReq = level.seedingBonus;
return {
className: level.name,
rawName: level.name,
order: matchClassOrderExact(level.name),
promotion,
demotion: {},
isPromotable: Object.keys(promotion).length > 0,
isDemotable: false,
isKept: !!level.isKept,
description: 'GGPT site metadata (PT-depiler)',
source: 'site-metadata',
sourceVersion: site.version,
};
}).filter(rule => rule.order >= 0);
}
/** DOM 辅助: 查找包含指定文本的 td.rowhead 的相邻 td(支持传入根文档,如 fetch 解析出的 userdetails 页面) */
function findRowheadSibling(textPatterns, root) {
const doc = root || document;
const rows = doc.querySelectorAll('td.rowhead');
const normalizedPatterns = textPatterns.map(p => String(p).replace(/[\s::]/g, '').toLowerCase());
// 先精确匹配字段名,避免“加入日期说明”等相似标签抢在真实字段之前。
for (const row of rows) {
const text = (row.textContent || '').replace(/[\s::]/g, '').toLowerCase();
if (normalizedPatterns.includes(text)) return row.nextElementSibling;
}
// 兼容少数站点在字段名后附带固定说明,但仍限定在 rowhead 中。
for (const row of rows) {
const text = (row.textContent || '').replace(/[\s::]/g, '').toLowerCase();
if (normalizedPatterns.some(p => text.startsWith(p))) {
return row.nextElementSibling;
}
}
return null;
}
/**
* 灵活的日期解析:兼容 NexusPHP 各种日期格式
* 支持: "2023-01-15 12:30:45" / "2023/01/15" / "2023年1月15日 12时30分" / ISO 字符串
* @returns {Date|null}
*/
function parseDateFlexible(text) {
if (!text) return null;
const t = String(text).trim();
if (!t) return null;
// 带时区的 ISO 时间必须交给原生解析。若落入下面的本地时间正则,
// 缓存中的 "...Z" 会被误当成本地时间,导致注册时长偏移一个时区。
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(t)) {
const isoDate = new Date(t);
return isNaN(isoDate.getTime()) ? null : isoDate;
}
// 中文格式: 2023年1月15日 (12时30分45秒 可选)
let m = t.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日?(?:\s*(\d{1,2})\s*[时::]\s*(\d{1,2})\s*(?:[分::]\s*(\d{1,2})\s*秒?)?)?/);
if (m) {
return createValidatedLocalDate(+m[1], +m[2], +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
}
// ISO 风格: 2023-01-15 12:30:45 / 2023.01.15 / 2023/01/15
m = t.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:[T\s]+(\d{1,2})[::](\d{1,2})(?:[::](\d{1,2}))?)?/);
if (m) {
return createValidatedLocalDate(+m[1], +m[2], +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
}
// 兜底: 浏览器原生解析
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
}
function createValidatedLocalDate(year, month, day, hour, minute, second) {
const result = new Date(year, month - 1, day, hour, minute, second);
return result.getFullYear() === year
&& result.getMonth() === month - 1
&& result.getDate() === day
&& result.getHours() === hour
&& result.getMinutes() === minute
&& result.getSeconds() === second
? result
: null;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function addCalendarDays(date, days) {
const result = new Date(date.getTime());
result.setDate(result.getDate() + days);
return result;
}
function addCalendarMonths(date, months) {
const result = new Date(date.getTime());
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + months);
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
}
function getCompletedCalendarDays(start, end) {
if (!start || !end || end <= start) return 0;
let days = Math.max(0, Math.round(
(Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
- Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())) / DAY_MS
));
while (days > 0 && addCalendarDays(start, days) > end) days--;
while (addCalendarDays(start, days + 1) <= end) days++;
return days;
}
function formatLocalDate(date) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 计算两个时刻之间完整经过的日历年、月、日,语义与 PT-depiler 的 duration 展示一致。 */
function getCalendarDuration(start, end) {
const startTime = start && typeof start.getTime === 'function' ? start.getTime() : NaN;
const endTime = end && typeof end.getTime === 'function' ? end.getTime() : NaN;
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
return { years: 0, months: 0, days: 0 };
}
let cursor = new Date(startTime);
let years = end.getFullYear() - cursor.getFullYear();
while (years > 0 && addCalendarMonths(cursor, years * 12) > end) years--;
cursor = addCalendarMonths(cursor, years * 12);
let months = (end.getFullYear() - cursor.getFullYear()) * 12 + end.getMonth() - cursor.getMonth();
while (months > 0 && addCalendarMonths(cursor, months) > end) months--;
cursor = addCalendarMonths(cursor, months);
let days = Math.max(0, Math.round(
(Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
- Date.UTC(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())) / DAY_MS
));
while (days > 0 && addCalendarDays(cursor, days) > end) days--;
while (addCalendarDays(cursor, days + 1) <= end) days++;
return { years, months, days };
}
function formatRegistrationRemaining(duration, lessThanDay = false) {
const parts = [];
if (duration.years) parts.push(`${duration.years}年`);
if (duration.months) parts.push(`${duration.months}个月`);
if (duration.days) parts.push(`${duration.days}天`);
return parts.join('') || (lessThanDay ? '不足1天' : '0天');
}
/**
* 计算下一等级的注册时长条件。缺少可靠加入日期时绝不从“现在”起算,
* 避免产生看似精确但实际错误的倒计时。
*/
function calculateRegistrationCountdown(joinTime, requiredWeeks, now = new Date()) {
const joinDate = parseDateFlexible(joinTime);
const weeks = Number(requiredWeeks);
const nowTime = now && typeof now.getTime === 'function' ? now.getTime() : NaN;
if (!joinDate || !Number.isFinite(weeks) || weeks <= 0 || !Number.isFinite(nowTime)) {
return {
known: false,
met: false,
expectedDate: null,
targetTimestamp: null,
remainingDays: null,
remainingText: '注册时间未知',
};
}
const currentDate = new Date(nowTime);
const targetDate = addCalendarDays(joinDate, weeks * 7);
const remainingMs = targetDate.getTime() - currentDate.getTime();
const met = remainingMs <= 0;
const duration = met ? { years: 0, months: 0, days: 0 } : getCalendarDuration(currentDate, targetDate);
return {
known: true,
met,
expectedDate: formatLocalDate(targetDate),
targetTimestamp: targetDate.getTime(),
remainingDays: met ? 0 : Math.max(1, Math.ceil(remainingMs / DAY_MS)),
remainingText: met ? '已达标' : formatRegistrationRemaining(duration, true),
duration,
};
}
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char]);
}
function formatInteger(value) {
return (Number(value) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 6 });
}
function formatExchangeEstimateParts(estimate) {
const parts = [];
if (estimate?.upload?.runs) {
parts.push(`增加上传 ${estimate.upload.actualGB} GB / ${estimate.upload.runs} 次 / ${formatInteger(estimate.upload.cost)} G`);
}
if (estimate?.download?.runs) {
parts.push(`${estimate.download.deltaGB < 0 ? '减少' : '增加'}下载 ${estimate.download.actualGB} GB / ${estimate.download.runs} 次 / ${formatInteger(estimate.download.cost)} G`);
}
return parts;
}
function confirmPromotionExchange(context) {
return Panel._confirmExchange({ ...context, title: '确认晋级兑换' });
}
/** DOM 辅助: 查找包含指定文本的元素 */
function findElementContaining(selector, textPatterns) {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
const text = el.textContent || '';
if (textPatterns.some(p => text.includes(p))) {
return el;
}
}
return null;
}
/** DOM 辅助: 查找所有包含指定文本的元素 */
function findAllElementsContaining(selector, textPatterns) {
const results = [];
const elements = document.querySelectorAll(selector);
for (const el of elements) {
const text = el.textContent || '';
if (textPatterns.some(p => text.includes(p))) {
results.push(el);
}
}
return results;
}
/** 获取当前 URL 路径 */
function getPath() {
return window.location.pathname.split('?')[0].split('#')[0];
}
/** 判断是否是 NexusPHP 页面(通过页面特征) */
function isNexusPHP() {
// 已知站点使用明确域名 metadata,不需要扫描正文。
if (getSiteLevelMetadata(window.location.hostname)) return true;
// 快速检测: 检查页面标题 (NexusPHP 站点通常包含)
const title = document.title || '';
if (/Powered by NexusPHP/i.test(title)) return true;
// 未知站点必须同时具备登录用户区和 NexusPHP 导航结构。
// 禁止扫描整个 innerHTML:GitHub 等源码页面会展示“NexusPHP”文字,但不是 PT 站。
const infoBlock = document.getElementById('info_block');
const userLink = infoBlock && infoBlock.querySelector('a[href*="userdetails.php"][href*="id="]');
const siteLink = document.querySelector(
'a[href*="torrents.php"], a[href*="mybonus.php"], a[href*="usercp.php"], a[href*="logout.php"]'
);
return !!(infoBlock && userLink && siteLink);
}
/** 延迟函数 */
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** 对象深合并 */
function deepMerge(target, ...sources) {
if (!sources.length) return target;
const source = sources.shift();
if (source && typeof source === 'object') {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {};
}
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
}
return deepMerge(target, ...sources);
}
// ============================================================
// 4. STORAGE LAYER
// ============================================================
const Storage = {
_accountId: null,
setAccount(userId) {
this._accountId = /^\d+$/.test(String(userId)) && Number(userId) > 0 ? String(userId) : null;
},
getAccount() {
return this._accountId;
},
_key(key) {
const name = CONFIG.STORAGE_KEYS[key] || key;
const accountScoped = name === CONFIG.STORAGE_KEYS.USER_STATS || name === CONFIG.STORAGE_KEYS.LAST_EXCHANGE;
if (accountScoped && !this._accountId) return null;
return `${window.location.hostname.toLowerCase()}:${name}${accountScoped ? `:${this._accountId}` : ''}`;
},
get(key, defaultValue) {
try {
const storageKey = this._key(key);
if (!storageKey) return defaultValue;
const val = GM_getValue(storageKey);
return val !== undefined && val !== null
? JSON.parse(val, (name, value) => value === '__NP_INFINITY__' ? Infinity : value)
: defaultValue;
} catch (e) {
return defaultValue;
}
},
set(key, value) {
try {
const storageKey = this._key(key);
if (!storageKey) return;
GM_setValue(storageKey, JSON.stringify(value, (name, item) => item === Infinity ? '__NP_INFINITY__' : item));
} catch (e) {
console.error('[NP Auto-Promote] Storage.set error:', e);
}
},
delete(key) {
try {
const storageKey = this._key(key);
if (storageKey) GM_deleteValue(storageKey);
} catch (e) {
console.error('[NP Auto-Promote] Storage.delete error:', e);
}
},
getRules() {
return this.get('RULES', []);
},
setRules(rules) {
this.set('RULES', rules);
},
getUserStats() {
const stats = this.get('USER_STATS', null);
if (stats && stats.ratio === null) {
stats.ratio = stats.downloaded > 0 ? stats.uploaded / stats.downloaded : (stats.uploaded > 0 ? Infinity : 0);
}
return stats;
},
setUserStats(stats) {
this.set('USER_STATS', stats);
},
getLastExchange() {
return this.get('LAST_EXCHANGE', null);
},
setLastExchange(time) {
this.set('LAST_EXCHANGE', time);
},
getSettings() {
// 新版默认值与旧存档深合并,保证新增字段(自动兑换参数)始终存在
const defaults = {
...CONFIG.AUTO_EXCHANGE_DEFAULTS,
strategy: CONFIG.EXCHANGE_STRATEGY,
demotionWarning: CONFIG.ENABLE_DEMOTION_WARNING,
promotionCheck: CONFIG.ENABLE_PROMOTION_CHECK,
panelPosition: CONFIG.PANEL_POSITION,
};
const saved = this.get('SETTINGS', null);
return saved ? deepMerge(defaults, saved) : defaults;
},
setSettings(settings) {
this.set('SETTINGS', settings);
},
getParsedFAQ() {
return this.get('PARSED_FAQ', null);
},
setParsedFAQ(data) {
this.set('PARSED_FAQ', data);
},
};
/**
* 已知站点优先使用 PT-depiler 风格的站点 metadata。
* 只保留新版 FAQ 解析确认的降级条件,避免旧缓存把晋级分享率当作降级线。
*/
function ensureCanonicalSiteRules() {
const canonical = getCanonicalSiteRules(window.location.hostname);
if (canonical.length === 0) return [];
const savedRules = Storage.getRules();
const merged = canonical.map(rule => {
const saved = savedRules.find(item => item.order === rule.order && item.demotionSource === 'faq-v2');
return saved ? { ...rule, demotion: saved.demotion, isDemotable: saved.isDemotable, isKept: rule.isKept || saved.isKept, demotionSource: 'faq-v2' } : rule;
});
Storage.setRules(merged);
Storage.setParsedFAQ({
rules: merged,
parsedAt: Date.now(),
url: window.location.href,
source: 'site-metadata',
sourceVersion: merged[0]?.sourceVersion || '',
});
return merged;
}
// ============================================================
// 5. LOGGING
// ============================================================
const Log = {
prefix: '[NP ⚡]',
info(...args) {
console.info(this.prefix, ...args);
},
warn(...args) {
console.warn(this.prefix, ...args);
},
error(...args) {
console.error(this.prefix, ...args);
},
debug(...args) {
console.debug(this.prefix, ...args);
},
};
// ============================================================
// 6. STYLES
// ============================================================
const STYLES = `
/* NexusPHP Auto-Promote - Floating Panel */
#np-panel {
position: fixed;
z-index: 99999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #e0e0e0;
background: rgba(30, 30, 40, 0.95);
border: 1px solid rgba(100, 120, 200, 0.3);
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
width: min(360px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
box-sizing: border-box;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
transition: opacity 0.3s ease, transform 0.3s ease;
}
#np-panel.np-top-right { top: 60px; right: 20px; }
#np-panel.np-top-left { top: 60px; left: 20px; }
#np-panel.np-bottom-right { bottom: 20px; right: 20px; }
#np-panel.np-bottom-left { bottom: 20px; left: 20px; }
#np-panel.np-hidden { opacity: 0; transform: translateY(-10px); pointer-events: none; }
#np-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(50, 55, 80, 0.6);
border-radius: 10px 10px 0 0;
cursor: move;
border-bottom: 1px solid rgba(100, 120, 200, 0.15);
}
#np-panel-title {
font-weight: 600;
font-size: 13px;
color: #8ab4f8;
}
#np-panel-toggle {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
#np-panel-toggle:hover { color: #fff; }
#np-panel-body {
padding: 10px 12px;
max-height: min(560px, calc(100dvh - 120px));
overflow-y: auto;
}
#np-panel-body::-webkit-scrollbar { width: 4px; }
#np-panel-body::-webkit-scrollbar-track { background: transparent; }
#np-panel-body::-webkit-scrollbar-thumb { background: rgba(100, 120, 200, 0.3); border-radius: 2px; }
.np-section {
margin-bottom: 8px;
}
.np-section:last-child { margin-bottom: 0; }
.np-section-title {
font-size: 11px;
text-transform: uppercase;
color: #888;
margin-bottom: 4px;
letter-spacing: 0;
}
.np-stat-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.np-stat-label { color: #aaa; }
.np-stat-value { color: #e0e0e0; font-weight: 500; }
.np-stat-value.good { color: #81c995; }
.np-stat-value.warning { color: #fdd663; }
.np-stat-value.danger { color: #f28b82; }
.np-progress-bar {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
margin: 4px 0;
overflow: hidden;
}
.np-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.np-progress-fill.good { background: #81c995; }
.np-progress-fill.warning { background: #fdd663; }
.np-progress-fill.danger { background: #f28b82; }
.np-btn {
display: inline-block;
padding: 4px 12px;
border: 1px solid rgba(100, 120, 200, 0.4);
border-radius: 4px;
background: rgba(60, 70, 120, 0.5);
color: #8ab4f8;
cursor: pointer;
font-size: 13px;
min-height: 32px;
transition: all 0.2s ease;
}
.np-btn:hover {
background: rgba(60, 70, 120, 0.8);
border-color: rgba(100, 120, 200, 0.6);
}
.np-btn:active { transform: scale(0.97); }
.np-btn.primary {
background: rgba(50, 110, 200, 0.6);
border-color: rgba(50, 110, 200, 0.5);
color: #fff;
}
.np-btn.primary:hover { background: rgba(50, 110, 200, 0.8); }
.np-btn.danger {
background: rgba(200, 50, 50, 0.4);
border-color: rgba(200, 50, 50, 0.4);
color: #f28b82;
}
.np-btn.danger:hover { background: rgba(200, 50, 50, 0.6); }
.np-btn.success {
background: rgba(50, 160, 80, 0.4);
border-color: rgba(50, 160, 80, 0.4);
color: #81c995;
}
.np-btn.success:hover { background: rgba(50, 160, 80, 0.6); }
.np-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
#np-panel [hidden], .np-dialog [hidden] { display:none !important; }
.np-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
}
.np-badge.good { background: rgba(129, 201, 149, 0.2); color: #81c995; }
.np-badge.warning { background: rgba(253, 214, 99, 0.2); color: #fdd663; }
.np-badge.danger { background: rgba(242, 139, 130, 0.2); color: #f28b82; }
.np-badge.info { background: rgba(138, 180, 248, 0.2); color: #8ab4f8; }
.np-rule-item {
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.np-rule-item:last-child { border-bottom: none; }
.np-rule-class { font-weight: 600; color: #8ab4f8; }
.np-rule-detail { font-size: 12px; color: #aaa; }
.np-rule-status { text-align: right; }
.np-notification {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100000;
padding: 12px 20px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
animation: np-slideIn 0.3s ease;
pointer-events: none;
max-width: calc(100vw - 32px);
box-sizing: border-box;
overflow-wrap: anywhere;
}
.np-notification.info { background: rgba(30, 60, 120, 0.95); color: #8ab4f8; border: 1px solid rgba(50, 110, 200, 0.4); }
.np-notification.success { background: rgba(30, 80, 50, 0.95); color: #81c995; border: 1px solid rgba(50, 160, 80, 0.4); }
.np-notification.warning { background: rgba(80, 70, 30, 0.95); color: #fdd663; border: 1px solid rgba(200, 160, 50, 0.4); }
.np-notification.error { background: rgba(80, 30, 30, 0.95); color: #f28b82; border: 1px solid rgba(200, 50, 50, 0.4); }
@keyframes np-slideIn {
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
#np-panel button:focus-visible, #np-panel input:focus-visible, #np-panel select:focus-visible,
.np-dialog button:focus-visible, .np-dialog input:focus-visible, .np-dialog select:focus-visible {
outline: 2px solid #a8c7fa;
outline-offset: 2px;
}
.np-overlay { position:fixed; inset:0; z-index:100001; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; }
.np-dialog { background:#24242c; color:#e0e0e0; border:1px solid #55555e; border-radius:8px; padding:20px; width:min(460px,calc(100vw - 32px)); max-height:calc(100dvh - 40px); overflow:auto; box-sizing:border-box; font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; overflow-wrap:anywhere; }
.np-dialog h3 { margin:0 0 12px; font-size:16px; }
.np-dialog p { margin:8px 0; }
.np-dialog-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
#np-calculator { color:#ededf1; font-size:15px; }
#np-calculator label { display:block; color:#ededf1; font-weight:600; margin-bottom:6px; }
#np-calculator h3 { color:#ededf1; font-size:18px; }
#np-calculator #np-calc-result { color:#ededf1; line-height:1.7; font-variant-numeric:tabular-nums; }
#np-calculator #np-calc-result strong { color:#a8d9b8; }
#np-calculator .np-price-table { width:100%; table-layout:fixed; border-collapse:collapse; background:#24242c !important; color:#ededf1 !important; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif !important; }
#np-calculator .np-price-table th, #np-calculator .np-price-table td { padding:10px 8px !important; border:0 !important; border-bottom:1px solid #50505b !important; color:#ededf1 !important; background:#24242c !important; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif !important; vertical-align:middle; text-align:left; height:auto !important; }
#np-calculator .np-price-table th { background:#32323d !important; color:#d9d9e2 !important; font-weight:600 !important; }
#np-calculator .np-price-table tbody tr:nth-child(even) td { background:#2c2c36 !important; }
#np-calculator .np-price-table th:not(:first-child), #np-calculator .np-price-table td:not(:first-child) { text-align:right; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
#np-calculator .np-price-table th:first-child { width:44%; }
#np-calculator .np-price-table caption { text-align:left; padding:0 0 8px; color:#ededf1 !important; background:transparent !important; font-weight:600; }
.np-condition { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:4px 12px; padding:7px 0; border-bottom:1px solid #45454d; }
.np-condition-detail { color:#c5c5ce; font-size:12px; grid-column:1 / -1; }
.np-task { margin:8px 0; padding:8px 0; border-top:1px solid #45454d; border-bottom:1px solid #45454d; }
.np-task p { margin:4px 0; }
.np-task progress { width:100%; height:8px; accent-color:#81c995; }
#np-auto-state { color:#c5c5ce; font-size:12px; margin-bottom:6px; }
@media (max-width:480px) {
#np-panel.np-top-right, #np-panel.np-top-left { top:12px; right:12px; left:auto; }
#np-panel-body { max-height:calc(100dvh - 80px); }
#np-panel, .np-dialog { font-size:15px; }
#np-panel .np-btn, .np-dialog .np-btn { min-height:44px; }
#np-panel input[type="number"], #np-panel select, .np-dialog input[type="number"], .np-dialog select { min-height:40px; font-size:14px !important; }
.np-dialog label:has(input[type="checkbox"]) { min-height:44px; }
#np-panel-toggle { min-width:44px; min-height:44px; }
.np-condition-detail, #np-auto-state { font-size:13px; }
}
@media (prefers-reduced-motion:reduce) {
#np-panel, .np-btn, .np-notification { animation:none; transition:none; }
.np-btn:active { transform:none; }
}
`;
// ============================================================
// 7. PAGE DETECTION & ROUTING
// ============================================================
/** 获取当前页面类型 */
function getPageType() {
const path = getPath();
const file = path.split('/').pop() || '';
if (file === 'faq.php') return 'faq';
if (file === 'index.php' || path === '/' || path === '') return 'index';
if (file === 'userdetails.php') return 'userdetails';
if (file === 'mybonus.php' || file === 'bonus.php') return 'bonus';
return 'other';
}
// ============================================================
// 8. NOTIFICATION SYSTEM
// ============================================================
function showNotification(message, type = 'info', duration = 5000) {
// 移除旧通知
const old = document.querySelector('.np-notification');
if (old) old.remove();
const el = document.createElement('div');
el.className = `np-notification ${type}`;
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => {
if (el.parentNode) {
el.style.opacity = '0';
el.style.transition = 'opacity 0.3s ease';
setTimeout(() => el.remove(), 300);
}
}, duration);
}
// ============================================================
// 9. FAQ PARSER — 解析晋级/降级规则
// ============================================================
const FAQParser = {
/**
* 从 FAQ 页面解析晋级/降级规则
* @returns {Array} 规则数组,每个元素包含 className, requirements, demotion, order
*/
parse() {
Log.info('开始解析 FAQ 晋级/降级规则...');
// 已知站点保留 metadata 晋级条件,仅在 FAQ 页补充明确的降级条件。
const canonicalRules = getCanonicalSiteRules(window.location.hostname);
if (canonicalRules.length > 0 && getPageType() !== 'faq') {
const rules = ensureCanonicalSiteRules();
Log.info(`已加载 ${rules.length} 个站点等级规则`);
return rules;
}
// 尝试找到包含等级规则的表格
let rules = [];
// 策略 1: 查找 FAQ 中关于用户等级的问题
const faqAnswers = this._findFAQAnswers();
Log.debug(`找到 ${faqAnswers.length} 个 FAQ 答案块`);
for (const answer of faqAnswers) {
const text = answer.textContent || '';
// 检查是否包含等级相关关键词
if (this._isPromotionRelated(text)) {
Log.debug('找到晋级相关 FAQ 内容');
const tableRules = this._parseTable(answer.element || answer);
if (tableRules && tableRules.length > 0) {
rules = rules.concat(tableRules);
} else {
// 尝试从纯文本解析
const textRules = this._parseText(text);
if (textRules && textRules.length > 0) {
rules = rules.concat(textRules);
}
}
}
}
// 策略 2: 直接查找页面中的等级表格
if (rules.length === 0) {
Log.debug('FAQ 未找到规则,尝试直接查找等级表格');
const tables = document.querySelectorAll('table');
for (const table of tables) {
const tableText = table.textContent || '';
if (this._isPromotionRelated(tableText)) {
const tableRules = this._parseTable(table);
if (tableRules && tableRules.length > 0) {
rules = rules.concat(tableRules);
}
}
}
}
// 策略 3: 查找所有包含等级信息的表格行
if (rules.length === 0) {
Log.debug('仍无结果,尝试通配查找等级行');
rules = this._findClassRowsWildcard();
}
// 排序(按等级权重)
rules.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
// 去重
rules = this._deduplicate(rules);
// 过滤掉没有有效晋级/降级数据的条目(保留有下载要求、分享率要求、积分要求、注册时间要求、降级阈值的)
rules = rules.filter(r =>
r.promotion?.regWeeks ||
r.promotion?.downloadBytes ||
r.promotion?.uploadBytes ||
r.promotion?.ratioReq ||
r.promotion?.bonusReq ||
Number.isFinite(r.demotion?.ratioDemote) || r.isKept
);
if (canonicalRules.length > 0) {
const savedRules = ensureCanonicalSiteRules();
rules = savedRules.map(rule => {
const parsed = rules.find(item => item.order === rule.order &&
(Number.isFinite(item.demotion?.ratioDemote) || item.isKept));
return parsed ? { ...rule, demotion: parsed.demotion, isDemotable: parsed.isDemotable, isKept: rule.isKept || parsed.isKept, demotionSource: 'faq-v2' } : rule;
});
}
Log.info(`解析完成,共找到 ${rules.length} 个等级规则`);
if (rules.length > 0) {
Storage.setRules(rules);
Storage.setParsedFAQ({ rules, parsedAt: Date.now(), url: window.location.href });
}
return rules;
},
/** 查找 FAQ 页面中所有的答案块 */
_findFAQAnswers() {
const answers = [];
// NexusPHP 典型 FAQ 结构:每个 item 是一个问答对
const items = document.querySelectorAll(
'span[id^="id"] > b, ' + // 问题
'.faq_answer, ' +
'div[id^="faq_"]'
);
// 另一种结构:FAQ 的内容在
后的文本中
const allSpans = document.querySelectorAll('span[id^="id"]');
for (const span of allSpans) {
// 获取 span 后面的所有 sibling 直到下一个 span
let sibling = span.nextSibling;
let content = '';
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === 'SPAN' && sibling.id && sibling.id.startsWith('id')) {
break;
}
if (sibling.nodeType === Node.TEXT_NODE || sibling.nodeType === Node.ELEMENT_NODE) {
content += sibling.textContent || '';
}
// 对于元素节点,检查其内部
if (sibling.nodeType === Node.ELEMENT_NODE) {
// 如果包含表格,添加到内容中
const tables = sibling.querySelectorAll('table');
for (const table of tables) {
content += ' ' + (table.textContent || '');
}
}
sibling = sibling.nextSibling;
}
if (content.trim()) {
answers.push({ textContent: content, element: span.parentElement });
}
}
// 也检查 frame/box 内容
const frames = document.querySelectorAll('.frame, .begin_frame, .faq_question');
for (const frame of frames) {
const text = frame.textContent || '';
if (text.trim()) {
answers.push({ textContent: text, element: frame });
}
}
return answers;
},
/** 判断文本是否与晋级/降级相关 */
_isPromotionRelated(text) {
const keywords = [
'等级', '用户等级', 'Class', 'class',
'提升', '晋级', '升级', '降级', 'promot', 'demot',
'Power User', 'Elite User', 'Crazy User', 'Nexus Master',
'uploaded', 'downloaded', '分享率', 'ratio',
'做种积分', 'seed bonus', 'seeding',
'中级', '高级', '精英', '专家', '冠军', '大师', '传说',
'注册', '注册时间', 'joined',
];
const lower = text.toLowerCase();
return keywords.some(kw => lower.includes(kw.toLowerCase()));
},
/** 从表格解析等级规则 */
_parseTable(container) {
const tables = container.matches?.('table') ? [container] :
(container.querySelectorAll ? container.querySelectorAll('table') : []);
const rules = [];
for (const table of tables) {
const rows = table.querySelectorAll('tr');
if (rows.length === 0) continue;
// 检测表头,找到层级列索引
const firstRow = rows[0];
const firstCells = firstRow.querySelectorAll('td, th');
let hasHeader = false;
let classIdx = -1;
let reqIdx = -1;
for (let i = 0; i < firstCells.length; i++) {
const text = (firstCells[i].textContent || '').toLowerCase();
if (/等级|class|级别|group|角色|身份/i.test(text)) { classIdx = i; hasHeader = true; }
if (/要求|条件|requirement|condition|晋级|提升|升级|规则|降级|说明|privileges?|权限|描述/i.test(text)) { reqIdx = i; hasHeader = true; }
}
if (this._isClassKeyword((firstCells[0]?.textContent || '').trim())) hasHeader = false;
// 如果没找到表头,根据列数推断列索引(无表头时第一行就是数据)
if (!hasHeader) {
if (firstCells.length >= 3) {
classIdx = 0;
reqIdx = 2;
} else if (firstCells.length >= 2) {
classIdx = 0;
reqIdx = 1;
}
}
// 解析数据行(有表头则从第2行开始,无表头则从第1行开始)
const startRow = hasHeader ? 1 : 0;
for (let r = startRow; r < rows.length; r++) {
const cells = rows[r].querySelectorAll('td');
if (cells.length <= Math.max(classIdx, reqIdx)) continue;
const className = (cells[classIdx >= 0 ? classIdx : 0].textContent || '').trim();
if (!className || className.length > 50) continue;
const reqText = Array.from(cells).filter((cell, index) => index !== (classIdx >= 0 ? classIdx : 0))
.map(cell => (cell.textContent || '').trim()).join(' | ');
if (!reqText || reqText.length < 5) continue;
const parsed = this._extractRequirements(reqText);
if (parsed) {
parsed.className = this._cleanClassName(className);
parsed.rawName = className;
parsed.order = this._getClassOrder(parsed.className);
rules.push(parsed);
}
}
}
return rules;
},
/** 从纯文本解析规则 */
_parseText(text) {
const rules = [];
// 按行拆分
const lines = text.split(/\n|
/i);
let currentClass = null;
let currentReq = '';
for (const line of lines) {
const cleanLine = line.replace(/<[^>]+>/g, '').trim();
if (!cleanLine) continue;
// 检测等级名称行(包含 Class 名称的行)
const classMatch = cleanLine.match(/([A-Za-z\u4e00-\u9fa5]+(?:[\s(][A-Za-z\u4e00-\u9fa5]+)*)\s*(?:\(([A-Za-z\s]+)\))?/);
if (classMatch && this._isClassKeyword(classMatch[1] || classMatch[2])) {
// 保存前一个等级
if (currentClass && currentReq) {
const parsed = this._extractRequirements(currentReq);
if (parsed) {
parsed.className = this._cleanClassName(currentClass);
parsed.rawName = currentClass;
parsed.order = this._getClassOrder(parsed.className);
rules.push(parsed);
}
}
currentClass = cleanLine;
currentReq = '';
} else if (currentClass && cleanLine.length > 5) {
currentReq += ' ' + cleanLine;
}
}
// 处理最后一个
if (currentClass && currentReq) {
const parsed = this._extractRequirements(currentReq);
if (parsed) {
parsed.className = this._cleanClassName(currentClass);
parsed.rawName = currentClass;
parsed.order = this._getClassOrder(parsed.className);
rules.push(parsed);
}
}
return rules;
},
/** 从表格行外部的纯文本中查找等级行 */
_findClassRowsWildcard() {
const rules = [];
const bodyText = document.body.textContent || '';
// 尝试匹配典型的等级描述模式
// 例如: "中级训练家(PU)" 或 "Power User (PU)"
const classPatterns = [
/([A-Za-z\u4e00-\u9fa5]+(?:[((]\s*[A-Za-z]+\s*[))])?)\s*[::]?\s*(.+?)(?=(?:\n\n|$|[A-Za-z\u4e00-\u9fa5]+[((]\s*[A-Za-z]+\s*[))]))/g,
/([A-Za-z\u4e00-\u9fa5]+(?:用户|User|训练家|Master|VIP|Uploader|Moderator))\s*[::]?\s*(.+?)(?=\n|$)/g,
];
// 直接查找所有包含等级关键词的独立行
const allText = document.body.innerHTML;
const tableMatch = allText.match(/
]*>(.*?)<\/td>/gs);
if (tableMatch) {
let classes = [];
let requirements = [];
for (let i = 0; i < tableMatch.length; i++) {
const cellText = tableMatch[i].replace(/<[^>]+>/g, '').trim();
if (!cellText) continue;
// 检测等级名
if (this._isClassKeyword(cellText)) {
classes.push({ index: i, text: cellText });
}
// 检测要求文本
if (/(?:注册|下载|上传|分享率|做种积分|ratio|seed|upload|download)/i.test(cellText) && cellText.length > 15) {
requirements.push({ index: i, text: cellText });
}
}
// 配对等级和要求(基于表格位置)
if (classes.length > 0 && requirements.length > 0) {
for (const cls of classes) {
// 找到最近的后续要求
const nearestReq = requirements.find(r => r.index > cls.index);
if (nearestReq) {
const parsed = this._extractRequirements(nearestReq.text);
if (parsed) {
parsed.className = this._cleanClassName(cls.text);
parsed.rawName = cls.text;
parsed.order = this._getClassOrder(parsed.className);
rules.push(parsed);
}
}
}
}
}
return rules;
},
/** 判断文本是否为等级关键词 */
_isClassKeyword(text) {
return matchClassOrderExact(text) >= 0;
},
/** 从要求文本中提取结构化的条件 */
_extractRequirements(text) {
if (!text || text.length < 5) return null;
const result = {
raw: text,
promotion: {}, // { regWeeks, downloadReq, ratioReq, bonusReq }
demotion: {}, // { ratioDemote }
isPromotable: false,
isDemotable: false,
description: text.substring(0, 200),
};
// 检查是否可提升/可降级
result.isPromotable = PATTERNS.PROMOTE_KEYWORDS.test(text);
result.isDemotable = PATTERNS.DEMOTE_KEYWORDS.test(text);
result.isKept = /(?:不会|不再|永不|不)(?:被|自动)?降级|never\s+(?:be\s+)?demoted|not\s+(?:be\s+)?demoted/i.test(text);
// 注册时间
const regMatch = text.match(PATTERNS.REG_TIME);
if (regMatch) {
let weeks = parseInt(regMatch[1]);
const unit = (regMatch[2] || '').toLowerCase();
if (unit.includes('天') || unit.includes('day')) weeks = Math.round(weeks / 7);
if (unit.includes('月') || unit.includes('month')) weeks = weeks * 4;
result.promotion.regWeeks = weeks;
}
// 下载量要求
const dlMatch = text.match(PATTERNS.DOWNLOAD_REQ);
if (dlMatch) {
const val = parseFloat(dlMatch[1]);
const unit = dlMatch[2] || 'GB';
result.promotion.downloadReq = { value: val, unit };
result.promotion.downloadBytes = parseSize(val, unit);
}
// 上传量要求(有些站点用上传代替下载)
const ulMatch = text.match(PATTERNS.UPLOAD_REQ);
if (ulMatch) {
const val = parseFloat(ulMatch[1]);
const unit = ulMatch[2] || 'GB';
result.promotion.uploadReq = { value: val, unit };
result.promotion.uploadBytes = parseSize(val, unit);
}
// 分享率要求(晋级)
const ratioMatch = text.match(PATTERNS.RATIO_REQ);
if (ratioMatch) {
result.promotion.ratioReq = parseFloat(ratioMatch[1]);
}
// 分享率降级阈值
const ratioDemoteMatch = text.match(PATTERNS.RATIO_DEMOTE);
if (ratioDemoteMatch && !result.isKept) {
result.demotion.ratioDemote = parseFloat(ratioDemoteMatch[1]);
result.isDemotable = true;
}
if (result.isKept) result.isDemotable = false;
result.demotionSource = 'faq-v2';
// 做种积分要求
const bonusMatch = text.match(PATTERNS.BONUS_REQ);
if (bonusMatch) {
result.promotion.bonusReq = parseFloat(bonusMatch[1]);
}
return result;
},
/** 清理等级名称 */
_cleanClassName(name) {
return name.replace(/<[^>]+>/g, '').replace(/^\s+|\s+$/g, '');
},
/** 获取等级排序权重 */
_getClassOrder(className) {
const order = matchClassOrderExact(className);
return order >= 0 ? order : 99;
},
/** 去重 */
_deduplicate(rules) {
const seen = new Set();
return rules.filter(r => {
const key = r.className + '-' + (r.promotion.downloadBytes || 0) + '-' + (r.promotion.ratioReq || 0);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
},
};
// ============================================================
// 10. USER STATS COLLECTOR — 收集用户当前数据
// ============================================================
const UserStats = {
_pageLoadedAt: Date.now(),
/** 从页面收集用户状态(异步:本地信息不全时会主动抓取 userdetails 页面补齐) */
async collect({ forceDetailsRefresh = false } = {}) {
Log.info('收集用户状态...');
const stats = {
uploaded: 0,
downloaded: 0,
ratio: 0,
bonus: null,
seedingBonus: 0,
className: '',
classOrder: -1,
joinTime: null,
registeredDays: 0,
hourlyBonus: 0,
messageCount: 0,
userId: null,
};
// 从 info_block 获取用户 ID
this._extractUserId(stats);
// PT-depiler/NexusPHP 流程:当前页无 UID 时先读首页,再请求自己的 userdetails。
if (!stats.userId) await this._fetchUserIdFromIndex(stats);
Storage.setAccount(stats.userId);
const cached = Storage.getUserStats() || {};
const exchangeAtStart = Storage.getLastExchange();
const sameUser = !!stats.userId && String(cached.userId) === String(stats.userId);
const exchangedOnPage = sameUser && exchangeAtStart >= this._pageLoadedAt;
// 从用户详情页或首页提取传输数据
if (exchangedOnPage) {
stats.uploaded = cached.uploaded;
stats.downloaded = cached.downloaded;
stats.ratio = cached.ratio;
stats.bonus = cached.bonus;
} else {
this._extractTraffic(stats);
}
// 提取等级
this._extractClass(stats);
// 提取魔力值
if (!exchangedOnPage) this._extractBonus(stats);
// 提取注册时间
this._extractJoinTime(stats);
// 已知站点默认优先使用用户详情页;用户手动刷新时绕过本地节流,
// 防止仍停留在旧页面 DOM 时覆盖掉刚验证过的兑换结果。
const forceAuthoritative = exchangedOnPage || forceDetailsRefresh || !!getSiteLevelMetadata(window.location.hostname);
await this._maybeFetchUserDetails(stats, forceAuthoritative, exchangedOnPage || forceDetailsRefresh);
// 兑换确认比在途详情请求更新,不能让旧响应覆盖已确认的余额和流量。
if (Storage.getAccount() === String(stats.userId) && Storage.getLastExchange() !== exchangeAtStart) {
return Storage.getUserStats() || stats;
}
// 个别页面既不含详情字段,也可能没有可用 UID。此时不能把已确认的数据写成 0。
// 如果当前页面识别到的是另一个 UID,则绝不复用旧账号缓存。
if (sameUser) {
if (!stats.uploaded && cached.uploaded) stats.uploaded = cached.uploaded;
if (!stats.downloaded && cached.downloaded) stats.downloaded = cached.downloaded;
if (!stats.ratio && cached.ratio) stats.ratio = cached.ratio;
if (!Number.isFinite(stats.bonus) && Number.isFinite(cached.bonus)) stats.bonus = cached.bonus;
if (!stats.className && cached.className) {
stats.className = cached.className;
stats.classOrder = cached.classOrder;
}
if (!stats.joinTime && cached.joinTime) stats.joinTime = cached.joinTime;
if (!stats.seedingBonus && cached.seedingBonus) stats.seedingBonus = cached.seedingBonus;
if (!stats.lastEvaluation && cached.lastEvaluation) stats.lastEvaluation = cached.lastEvaluation;
}
// 计算已注册天数
if (stats.joinTime) {
const joinDate = parseDateFlexible(stats.joinTime);
if (joinDate) {
const diff = Date.now() - joinDate.getTime();
stats.registeredDays = Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
}
}
if (!stats.registeredDays && sameUser && cached.registeredDays) {
stats.registeredDays = cached.registeredDays;
}
// 计算分享率(如果缺失)
if (stats.ratio === 0 && stats.downloaded > 0) {
stats.ratio = stats.uploaded / stats.downloaded;
} else if (stats.downloaded === 0 && stats.uploaded > 0) {
stats.ratio = Infinity;
}
Log.info('用户状态:', stats);
if (Storage.getAccount() === String(stats.userId)) Storage.setUserStats(stats);
return stats;
},
/**
* 当等级/魔力值/注册时间缺失或置信度低时,
* 主动 fetch 自己的 userdetails.php 页面,在解析后的 DOM 中提取
*/
async _maybeFetchUserDetails(stats, forceAuthoritative = false, bypassFetchThrottle = false) {
const needClass = forceAuthoritative || !stats.className || stats.classOrder < 0 || stats.classOrder === 99;
const needBonus = forceAuthoritative || !Number.isFinite(stats.bonus);
const needJoin = forceAuthoritative || !stats.joinTime;
const needTraffic = forceAuthoritative || (!stats.uploaded && !stats.downloaded);
if (!needClass && !needBonus && !needJoin && !needTraffic) return;
if (!stats.userId) {
Log.warn('无法获取用户 ID,跳过 userdetails 页面抓取');
return;
}
// 5 分钟内不重复抓取,避免频繁请求
const now = Date.now();
if (!bypassFetchThrottle && this._lastFetchUserId === stats.userId && this._lastFetchTime && now - this._lastFetchTime < 300000) return;
try {
const url = `/userdetails.php?id=${stats.userId}`;
Log.info(`读取用户详情页: ${url}`);
const resp = await fetch(url, {
credentials: 'same-origin',
cache: forceAuthoritative ? 'no-store' : 'default',
});
if (!resp.ok) {
Log.warn(`userdetails 页面请求失败: HTTP ${resp.status}`);
return;
}
if (resp.redirected && /login|verify|checkpoint|returnto/i.test(resp.url || '')) {
Log.warn('userdetails 页面被重定向到登录/验证页,保留已有数据');
return;
}
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const hasUserDetailsFields = !!findRowheadSibling(['等级', '等級', 'Class', '传输', '傳送', 'Transfers'], doc);
const isLoginPage = !!doc.querySelector('form[action*="takelogin"], input[name="username"][type="text"]');
if (isLoginPage || !hasUserDetailsFields) {
Log.warn('userdetails 响应不是有效用户详情页,保留已有数据');
return;
}
if (needTraffic) this._extractTraffic(stats, doc);
if (needClass) this._extractClass(stats, doc);
if (needBonus) this._extractBonus(stats, doc);
if (needJoin) this._extractJoinTime(stats, doc);
this._lastFetchTime = now;
this._lastFetchUserId = stats.userId;
stats.detailsFetchedAt = now;
} catch (e) {
Log.warn('抓取用户详情页失败:', e && e.message ? e.message : e);
}
},
/** 提取用户 ID */
_extractUserId(stats, root) {
const doc = root || document;
// 方法 1: info_block 中的链接
const infoLink = doc.querySelector('#info_block a[href*="userdetails.php"][href*="id="]');
if (infoLink) {
const match = infoLink.getAttribute('href').match(/id=(\d+)/);
if (match) stats.userId = parseInt(match[1]);
}
},
/** NexusPHP 通用流程的第一步:从首页 #info_block 获取当前登录用户 ID。 */
async _fetchUserIdFromIndex(stats) {
try {
const resp = await fetch('/index.php', { credentials: 'same-origin' });
if (!resp.ok) return;
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
this._extractUserId(stats, doc);
} catch (e) {
Log.warn('首页用户 ID 请求失败:', e && e.message ? e.message : e);
}
},
/** 提取传输数据(上传量、下载量、分享率) */
_extractTraffic(stats, root) {
const doc = root || document;
// 方法 1: NexusPHP userdetails 的“传输/Transfers”字段。
const trafficRow = findRowheadSibling(['传输', '傳送', 'Transfers'], doc);
if (trafficRow) {
const text = trafficRow.textContent || '';
this._parseTrafficText(text, stats);
}
// 部分 NexusPHP 主题将上传/下载/分享率合并在 rowfollow 中。
if ((!stats.uploaded || !stats.downloaded || !stats.ratio) && doc.querySelectorAll) {
const rowfollows = doc.querySelectorAll('td.rowfollow');
for (const cell of rowfollows) {
const text = cell.textContent || '';
if (/分享率|Ratio/i.test(text) && /上[传傳]量|Uploaded/i.test(text)) {
this._parseTrafficText(text, stats);
break;
}
}
}
// 方法 2: 从首页顶部导航或用户栏
const userInfoStats = doc.querySelector && doc.querySelector('#info_block, .userinfo, .ratio-bar');
if ((!stats.uploaded || !stats.downloaded || !stats.ratio) && userInfoStats) {
const text = userInfoStats.textContent || '';
this._parseTrafficText(text, stats);
}
},
/** 从文本解析传输数据 */
_parseTrafficText(text, stats) {
if (!text) return;
const clean = String(text).replace(/,/g, '');
// 上传量
const uploadMatch = clean.match(/(?:上[传傳]量|Uploaded).+?([\d.]+)\s*(TB|GB|MB|TiB|GiB|MiB)/i);
if (uploadMatch) {
stats.uploaded = parseSize(uploadMatch[1], uploadMatch[2]);
}
// 下载量
const downloadMatch = clean.match(/(?:下[载載]量|Downloaded).+?([\d.]+)\s*(TB|GB|MB|TiB|GiB|MiB)/i);
if (downloadMatch) {
stats.downloaded = parseSize(downloadMatch[1], downloadMatch[2]);
}
// 分享率
const ratioMatch = clean.match(/(?:分享率|Ratio)\s*[::]?\s*([\d.]+)/i);
if (ratioMatch) {
stats.ratio = parseFloat(ratioMatch[1]);
}
},
/** 提取等级 */
_extractClass(stats, root) {
const doc = root || document;
// 方法 1: userdetails 页面中的"等级"行(优先 img 的 title/alt,其次文本)
const classCell = findRowheadSibling(['等级', '等級', 'Class'], doc);
if (classCell) {
const img = classCell.querySelector('img[title], img[alt]');
const label = img
? (img.getAttribute('title') || img.getAttribute('alt') || '').trim()
: (classCell.textContent || '').trim();
if (label && this._matchClassOrder(label) >= 0) {
stats.className = label;
this._setClassOrder(stats);
return;
}
}
// 方法 2: info_block 内提取
const infoBlock = doc.querySelector ? doc.querySelector('#info_block') : null;
if (infoBlock) {
// 2a: 用户名链接的 class 命名规则 "XxxUser_Name"(GGPT 等站,如 ExtremeUser_Name → Extreme User)
const ownSelector = stats.userId
? `a[href*="userdetails.php"][href*="id=${stats.userId}"]`
: 'a[href*="userdetails.php"][href*="id="]';
const userLink = infoBlock.querySelector(ownSelector);
if (userLink && userLink.className) {
const classToken = String(userLink.className).split(/\s+/).find(token => /_Name$/i.test(token));
const levelName = classToken ? classToken.replace(/_Name$/i, '') : '';
if (levelName && this._matchClassOrder(levelName) >= 0) {
stats.className = levelName;
this._setClassOrder(stats);
return;
}
}
// 2b: 等级图标(部分站点在用户名旁显示等级图片)
const imgs = infoBlock.querySelectorAll('img[title], img[alt]');
for (const img of imgs) {
const label = (img.getAttribute('title') || img.getAttribute('alt') || '').trim();
if (label && this._matchClassOrder(label) >= 0) {
stats.className = label;
this._setClassOrder(stats);
return;
}
}
}
// 注意:不再做全文文本扫描 —— 页面上"用户""User"等字样无处不在,
// 旧实现按 CLASS_LEVELS 顺序第一个就命中 USER,导致等级几乎总是被误判为 User
},
/**
* 判断文本匹配哪个等级,返回 order;未匹配返回 -1
* 对结构化字段值做精确匹配;允许括号中的中英文别名,禁止正文子串匹配。
*/
_matchClassOrder(text) {
return matchClassOrderExact(text);
},
/** 设置等级排序权重 */
_setClassOrder(stats) {
if (!stats.className) {
stats.classOrder = -1;
return;
}
const order = this._matchClassOrder(stats.className);
stats.classOrder = order >= 0 ? order : 99;
},
/** 提取魔力值 */
_extractBonus(stats, root) {
const doc = root || document;
let bonus = null;
// 方法 1: userdetails 页面 rowhead 旁的魔力值
const bonusRow = findRowheadSibling(['魔力值', '魔力', 'Karma'], doc);
if (bonusRow) {
const m = (bonusRow.textContent || '').match(/([\d,]+(?:\.\d+)?)/);
if (m) bonus = parseFloat(m[1].replace(/,/g, ''));
}
// 方法 2: 顶栏 #info_block / mybonus 链接附近
// NexusPHP 顶栏典型结构: 魔力值: 1,234.5 —— 数字在链接外的文本节点
if (!Number.isFinite(bonus)) {
const scopes = [];
const infoBlock = doc.querySelector ? doc.querySelector('#info_block') : null;
if (infoBlock) scopes.push(infoBlock);
const bonusLink = doc.querySelector ? doc.querySelector('a[href*="mybonus.php"], a[href*="bonus.php"]') : null;
if (bonusLink && bonusLink.parentElement) scopes.push(bonusLink.parentElement);
for (const scope of scopes) {
const text = (scope.textContent || '').replace(/\s+/g, ' ');
// 冒号可选,兼容 "魔力值 1,234.5" / "魔力值 : 1,234.5" / "魔力值(1,234.5)"
// 以及 GGPT 顶栏 "G值 [使用]: 3,163,332.8"(关键词与数字间隔着方括号链接)
const m = text.match(/(?:魔力值|魔力|Karma|Bonus|G\s*值)\s*(?:\[[^\]]{0,8}\]\s*)?[:::]?\s*[\[(]?\s*([\d,]+(?:\.\d+)?)/i);
if (m) {
bonus = parseFloat(m[1].replace(/,/g, ''));
break;
}
}
}
// 方法 3: 全局扫描兜底 —— 句式优先,防误匹配兑换价格表
// mybonus 页典型句式: "你现在有 1,234.5 个魔力值"(数字在关键词前)
// 价格表干扰项: "500 魔力值 = 1GB"、"1GB 需要 500 魔力值"(不能匹配)
if (!Number.isFinite(bonus)) {
const bodyText = ((doc.body || doc.documentElement).textContent || '').replace(/\s+/g, ' ');
const candidates = [
// GGPT mybonus 正文: "用你的魔力值(当前3,163,332.8)换东东"
/魔力值[((]\s*当前\s*([\d,]+(?:\.\d+)?)\s*[))]/,
// 句式 A: "你有/当前有/现在有/剩余 X 个魔力值"、"当前魔力值为 X"
/(?:你有|当前有|现在有|现有|剩余|你的魔力值|我的魔力值|当前魔力值)\s*(?:为|是|[::])?\s*([\d,]+(?:\.\d+)?)\s*(?:个|点|枚)?/,
// 句式 B: "1,234.5 个魔力值"(带量词"个/点/枚"的几乎不会是价格表)
/([\d,]+(?:\.\d+)?)\s*(?:个|点|枚)\s*魔力值/,
// 句式 C: "魔力值: X"(冒号后紧跟数字,且数字后不是 = /GB,排除价格写法)
/(?:魔力值|魔力)\s*[::]\s*([\d,]+(?:\.\d+)?)(?![\d.,]*\s*(?:[=个/]|魔力|GB|GiB|上传|兑换))/,
// 句式 D: "G值 [使用]: X"(GGPT 顶栏)
/G\s*值\s*(?:\[[^\]]{0,8}\]\s*)?[:::]\s*([\d,]+(?:\.\d+)?)/,
];
for (const p of candidates) {
const m = bodyText.match(p);
if (m) {
const val = parseFloat(m[1].replace(/,/g, ''));
if (Number.isFinite(val) && val >= 0) {
bonus = val;
break;
}
}
}
}
if (Number.isFinite(bonus)) stats.bonus = bonus;
// 做种积分(独立字段,userdetails 页面)
const seedBonusRow = findRowheadSibling(['做种积分', 'Seeding Points', '做種積分', '保种积分'], doc);
if (seedBonusRow) {
const m = (seedBonusRow.textContent || '').match(/([\d,]+(?:\.\d+)?)/);
if (m) stats.seedingBonus = parseFloat(m[1].replace(/,/g, ''));
}
},
/** 提取注册时间 */
_extractJoinTime(stats, root) {
const doc = root || document;
const joinRow = findRowheadSibling(
['加入日期', '加入时间', '注册日期', '注册时间', 'Join date', 'Joined'],
doc
);
if (joinRow) {
// NexusPHP 常用 3 年前 显示相对时间,
// 绝对时间在 title 属性里,优先读取
const titled = joinRow.querySelector('span[title], time[title]');
const raw = (titled && titled.getAttribute('title'))
|| (joinRow.textContent || '').split(' (')[0].trim();
const d = parseDateFlexible(raw);
if (d) {
stats.joinTime = d.toISOString();
return;
}
// 解析失败也保留原文,避免完全丢失
const fallback = (joinRow.textContent || '').trim();
if (fallback) stats.joinTime = fallback;
}
},
};
// ============================================================
// 11. RULE ENGINE — 比较当前状态与规则
// ============================================================
const RuleEngine = {
/**
* 评估用户状态与规则的匹配情况
* @returns {Object} 评估结果
*/
evaluate() {
const rules = Storage.getRules();
const stats = Storage.getUserStats();
if (!rules || rules.length === 0) {
return { status: 'no-rules', message: '尚未解析晋级规则,请访问 FAQ 页面', messages: ['尚未解析晋级规则,请访问 FAQ 页面'], details: {}, needsExchange: false };
}
if (!stats) {
return { status: 'no-stats', message: '无法获取用户数据', messages: ['无法获取用户数据'], details: {}, needsExchange: false };
}
const result = {
status: 'ok',
currentClass: stats.className,
currentOrder: stats.classOrder,
nextClass: null,
prevClass: null,
promotionStatus: null, // 'met' | 'close' | 'far' | null
demotionStatus: null, // 'safe' | 'warning' | 'danger' | 'exempt' | 'unknown' | null
needsExchange: false,
exchangeReason: null,
exchangeAmount: 0, // 需要兑换的上传量(字节)
exchangeType: null, // 'upload' | 'download'
messages: [],
details: {},
};
// 找到当前等级在规则中的索引
const currentIdx = rules.findIndex(r => r.order === stats.classOrder);
const statsClassRecognized = stats.classOrder >= 0 && stats.classOrder !== 99;
// 等级是否成功识别(-1 = 未获取到,99 = 获取到但无法匹配规则表),
// 同时必须在 FAQ 规则中找到同一 order 才能进行晋级/降级评估。
const classIdentified = statsClassRecognized && currentIdx >= 0;
const nextRule = currentIdx >= 0 && currentIdx < rules.length - 1 ? rules[currentIdx + 1] : null;
const prevRule = currentIdx > 0 ? rules[currentIdx - 1] : null;
const demotionRule = classIdentified ? rules[currentIdx] : null;
// 等级未识别时给出明确提示,而不是误报"最高等级"或"降级风险"
if (!statsClassRecognized) {
result.messages.push('❓ 未能识别当前等级,无法评估晋级/降级。请访问你的用户详情页 (userdetails.php) 后点击面板"刷新"');
} else if (currentIdx < 0) {
result.messages.push(`❓ 已识别当前等级“${stats.className}”,但 FAQ 规则中没有对应等级。请在 FAQ 页面重新解析规则后再刷新。`);
}
result.nextClass = nextRule;
result.prevClass = prevRule;
// ---- 降级检查 ----
const demotionWarning = Storage.getSettings().demotionWarning !== false;
if (demotionRule?.isKept) {
result.demotionStatus = 'exempt';
result.details.demotion = { current: stats.ratio, status: 'exempt' };
} else if (Number.isFinite(demotionRule?.demotion?.ratioDemote) &&
(Number.isFinite(stats.ratio) || stats.ratio === Infinity)) {
const threshold = demotionRule.demotion.ratioDemote;
const ratio = stats.ratio;
if (ratio <= threshold) {
result.demotionStatus = 'danger';
if (demotionWarning) result.messages.push(`⚠️ 分享率 ${stats.ratio.toFixed(3)} 已低于降级阈值 ${threshold},有降级风险!`);
result.needsExchange = true;
result.exchangeReason = 'demotion';
result.exchangeType = 'upload';
// 计算需要兑换多少上传量来提升分享率
const targetRatio = threshold * 1.15; // 目标分享率比阈值高 15%
const neededUpload = Math.ceil(stats.downloaded * targetRatio - stats.uploaded);
result.exchangeAmount = Math.max(neededUpload, 0);
} else if (ratio <= threshold * 1.15) {
result.demotionStatus = 'warning';
if (demotionWarning) result.messages.push(`⚠️ 分享率 ${stats.ratio.toFixed(3)} 接近降级阈值 ${threshold}`);
result.needsExchange = true;
result.exchangeReason = 'demotion-prevent';
result.exchangeType = 'upload';
const targetRatio = threshold * 1.3;
const neededUpload = Math.ceil(stats.downloaded * targetRatio - stats.uploaded);
result.exchangeAmount = Math.max(neededUpload, 0);
} else {
result.demotionStatus = 'safe';
}
result.details.demotion = { threshold, current: stats.ratio, status: result.demotionStatus };
} else {
result.demotionStatus = 'unknown';
result.details.demotion = {
current: stats.ratio,
status: 'unknown',
reason: !classIdentified ? '等级未识别' : !Number.isFinite(demotionRule.demotion?.ratioDemote)
? '缺少降级规则,请打开 FAQ' : '分享率未知',
};
}
// ---- 晋级检查 ----
if (nextRule && nextRule.promotion) {
const prom = nextRule.promotion;
const GB = 1024 * 1024 * 1024;
let allMet = true;
let closeCount = 0;
const nextPromo = {};
// 检查注册时间
if (prom.regWeeks) {
const countdown = calculateRegistrationCountdown(stats.joinTime, prom.regWeeks);
const joinDate = countdown.known ? parseDateFlexible(stats.joinTime) : null;
const regWeeks = joinDate
? Math.floor(getCompletedCalendarDays(joinDate, new Date()) / 7)
: 0;
nextPromo.regWeeks = {
required: prom.regWeeks,
current: regWeeks,
met: countdown.met,
known: countdown.known,
remainingDays: countdown.remainingDays,
remainingText: countdown.remainingText,
expectedDate: countdown.expectedDate,
targetTimestamp: countdown.targetTimestamp,
};
if (!nextPromo.regWeeks.met) allMet = false;
if (countdown.known && regWeeks >= prom.regWeeks * 0.85) closeCount++;
}
// 检查下载量
if (prom.downloadBytes) {
const dlGB = stats.downloaded / GB;
const reqGB = prom.downloadBytes / GB;
nextPromo.downloadReq = { required: reqGB, current: dlGB, met: stats.downloaded >= prom.downloadBytes };
if (!nextPromo.downloadReq.met) allMet = false;
if (stats.downloaded >= prom.downloadBytes * 0.85) closeCount++;
}
// 检查上传量
if (prom.uploadBytes) {
const ulGB = stats.uploaded / GB;
const reqGB = prom.uploadBytes / GB;
nextPromo.uploadReq = { required: reqGB, current: ulGB, met: stats.uploaded >= prom.uploadBytes };
if (!nextPromo.uploadReq.met) allMet = false;
if (stats.uploaded >= prom.uploadBytes * 0.85) closeCount++;
}
// 检查分享率
if (prom.ratioReq) {
const ratio = stats.ratio === Infinity ? 999 : stats.ratio;
nextPromo.ratioReq = { required: prom.ratioReq, current: ratio, met: ratio >= prom.ratioReq };
if (!nextPromo.ratioReq.met) allMet = false;
if (ratio >= prom.ratioReq * 0.85) closeCount++;
}
// 检查做种积分
if (prom.bonusReq) {
nextPromo.bonusReq = { required: prom.bonusReq, current: stats.seedingBonus, met: stats.seedingBonus >= prom.bonusReq };
if (!nextPromo.bonusReq.met) allMet = false;
if (stats.seedingBonus >= prom.bonusReq * 0.85) closeCount++;
}
result.details.nextPromotion = nextPromo;
if (allMet) {
result.promotionStatus = 'met';
result.messages.push(`✅ 已达到 "${nextRule.className}" 的晋级条件!`);
} else {
result.promotionStatus = closeCount >= Math.max(1, Object.keys(prom).length - 1) ? 'close' : 'far';
// ---- 晋级兑换计划:算出流量类条件的缺口,直接兑换补足 ----
// 非流量条件(注册时间/做种积分)不达标时,兑换流量也无法立即晋级
const blockedByTime = prom.regWeeks && !nextPromo.regWeeks.met;
const blockedByBonus = prom.bonusReq && !nextPromo.bonusReq.met;
// 1) 下载量缺口 → 兑换下载量
let downloadGB = 0;
if (prom.downloadBytes && stats.downloaded < prom.downloadBytes) {
downloadGB = (prom.downloadBytes - stats.downloaded) / GB;
}
// 2) 上传量缺口 = max(上传量要求缺口, 分享率要求缺口)
// 分享率缺口基于"兑换下载量之后"的下载量计算(兑下载量会拉低分享率)
let uploadGB = 0;
if (prom.uploadBytes && stats.uploaded < prom.uploadBytes) {
uploadGB = (prom.uploadBytes - stats.uploaded) / GB;
}
if (prom.ratioReq) {
const projectedDownloaded = stats.downloaded + downloadGB * GB;
const neededUpload = prom.ratioReq * projectedDownloaded - stats.uploaded;
if (neededUpload > 0) {
uploadGB = Math.max(uploadGB, neededUpload / GB);
}
}
if (downloadGB > 0 || uploadGB > 0) {
const projectedDownloaded = stats.downloaded + downloadGB * GB;
const projectedUploaded = stats.uploaded + uploadGB * GB;
result.exchangePlan = {
downloadGB,
uploadGB,
currentUploadedGB: stats.uploaded / GB,
currentDownloadedGB: stats.downloaded / GB,
requiredRatio: prom.ratioReq || 0,
minUploadGB: (prom.uploadBytes || 0) / GB,
minDownloadGB: (prom.downloadBytes || 0) / GB,
projectedRatio: projectedDownloaded > 0 ? projectedUploaded / projectedDownloaded : (projectedUploaded > 0 ? Infinity : 0),
blockedByTime: !!blockedByTime,
blockedByBonus: !!blockedByBonus,
// 注册时间不足允许用户手动确认后提前兑换;做种积分不足仍保持阻止。
requiresTimeConfirmation: !!blockedByTime,
blocked: !!blockedByBonus,
};
const parts = [];
if (downloadGB > 0) parts.push(`下载量 ${downloadGB.toFixed(2)} GB`);
if (uploadGB > 0) parts.push(`上传量 ${uploadGB.toFixed(2)} GB`);
if (blockedByTime) parts.push('(另需注册时间达标)');
if (blockedByBonus) parts.push('(另需做种积分达标)');
result.messages.push(`🎯 距 "${nextRule.className}" 晋级缺口: ${parts.join(' + ')}`);
if (!blockedByBonus) {
result.needsExchange = true;
result.exchangeReason = 'promotion';
// 兼容旧字段:主类型取缺口更大的
result.exchangeType = uploadGB >= downloadGB ? 'upload' : 'download';
result.exchangeAmount = Math.max(uploadGB, downloadGB) * GB;
if (blockedByTime) {
result.messages.push('ℹ️ 注册时间尚未达标;可提前兑换流量,但执行前必须确认,兑换后仍不会立即晋级');
}
} else {
result.messages.push('ℹ️ 做种积分未达标,兑换流量暂无法晋级,请先补齐积分条件');
}
} else {
result.messages.push(`ℹ️ 距 "${nextRule.className}" 晋级仅差非流量条件(注册时间/积分),无需兑换`);
}
}
} else if (!nextRule && classIdentified) {
result.promotionStatus = 'max';
result.messages.push('🏆 已达到最高等级!');
}
// ---- 兑换建议 ----
if (result.needsExchange && result.exchangePlan) {
const p = result.exchangePlan;
const parts = [];
if (p.downloadGB > 0) parts.push(`下载量 ${p.downloadGB.toFixed(2)} GB`);
if (p.uploadGB > 0) parts.push(`上传量 ${p.uploadGB.toFixed(2)} GB`);
result.messages.push(`💡 建议兑换: ${parts.join(' + ')}(当前剩余魔力值: ${stats.bonus?.toFixed(0) || '未知'})`);
} else if (result.needsExchange && result.exchangeAmount > 0 && demotionWarning) {
const amountGB = result.exchangeAmount / (1024 * 1024 * 1024);
result.messages.push(`💡 建议兑换 ${amountGB.toFixed(2)} GB 上传量以提升分享率(当前剩余魔力值: ${stats.bonus?.toFixed(0) || '未知'})`);
}
// 更新存储
Storage.setUserStats({ ...stats, lastEvaluation: result });
return result;
},
};
// ============================================================
// 12. BONUS EXCHANGE HANDLER — 魔力值兑换
// ============================================================
const BonusExchange = {
taskState: { running: false, stopRequested: false, phase: 'idle', type: '', completedRuns: 0, totalRuns: 0, completedGB: 0, cost: 0, waitUntil: 0, reason: '', message: '' },
_taskAccount: null,
_automatic: false,
_taskVersion: null,
_taskStopToken: null,
_setTaskState(patch) {
Object.assign(this.taskState, patch);
Panel.updateTaskState?.();
},
requestStop() {
Storage.set('EXCHANGE_STOP', `${Date.now()}:${Math.random().toString(36).slice(2)}`);
this._setTaskState({ stopRequested: true, message: '正在停止;已发出的请求仍需核验' });
const settings = Storage.getSettings();
Storage.setSettings({ ...settings, autoExchange: false });
},
_stopReason() {
if (this.taskState.stopRequested) return '用户已停止兑换';
if (Storage.get('EXCHANGE_STOP', null) !== this._taskStopToken) return '用户已从其他页面停止兑换';
if (this._taskAccount !== Storage.getAccount()) return '账号已切换,兑换已停止';
if (this._automatic) {
const settings = Storage.getSettings();
if (!settings.autoExchangeAccountId || settings.autoExchangeAccountId !== Storage.getAccount()) return '自动任务所属账号已变化,请重新核对设置后开启';
if (!settings.autoExchange || settings.autoExchangeTaskVersion !== this._taskVersion) return '自动任务已关闭或配置已变更';
}
return '';
},
async _wait(ms) {
this._setTaskState({ phase: 'cooldown', waitUntil: Date.now() + ms });
let remaining = ms;
while (remaining > 0 && !this._stopReason()) {
const slice = Math.min(250, remaining);
await sleep(slice);
remaining -= slice;
}
this._setTaskState({ waitUntil: 0 });
return !this._stopReason();
},
async _runTask(type, run, automatic = false) {
if (this.taskState.running) return { success: false, reason: '已有兑换任务正在执行' };
if (!globalThis.navigator?.locks?.request) {
const result = { success: false, reason: '浏览器不支持跨标签兑换锁,请在支持 Web Locks 的 HTTPS 页面执行' };
this._setTaskState({ phase: 'error', reason: result.reason, message: '', result });
return result;
}
const account = Storage.getAccount();
if (!account) {
const result = { success: false, reason: '未识别登录账号,拒绝兑换' };
this._setTaskState({ phase: 'error', reason: result.reason, message: '', result });
return result;
}
const stopToken = Storage.get('EXCHANGE_STOP', null);
return navigator.locks.request(`np-exchange:${window.location.origin}`, { ifAvailable: true }, async lock => {
if (this.taskState.running) return { success: false, reason: '已有兑换任务正在执行' };
if (!lock) {
const result = { success: false, reason: '另一标签页已有兑换任务正在执行' };
this._setTaskState({ phase: 'error', reason: result.reason, message: '', result });
return result;
}
this._taskAccount = account;
this._automatic = automatic;
this._taskVersion = Storage.getSettings().autoExchangeTaskVersion;
this._taskStopToken = stopToken;
this._setTaskState({ running: true, stopRequested: false, phase: 'preparing', type, completedRuns: 0, totalRuns: 0, completedGB: 0, cost: 0, waitUntil: 0, reason: '', message: '', result: null });
let result;
try {
result = await run();
if (Number.isFinite(result)) result = { success: true, nextDelay: result, idle: true };
} catch (error) {
result = { success: false, reason: error.message || '兑换任务异常' };
} finally {
result = result || { success: false, reason: '兑换任务未完成' };
if (!result.success && this.taskState.completedRuns > 0) result.partial = true;
result.gb ??= this.taskState.completedGB;
result.cost ??= this.taskState.cost;
this._setTaskState({ running: false, phase: result.idle ? 'idle' : (result.success ? 'success' : (result.partial ? 'partial' : (this._stopReason() || result.cancelled ? 'stopped' : 'error'))), waitUntil: 0, reason: result.reason || '', message: result.message || '', result });
}
return result;
});
},
/** 检查是否在冷却中 */
isOnCooldown() {
const last = Storage.getLastExchange();
if (!last) return false;
return (Date.now() - last) < CONFIG.EXCHANGE_COOLDOWN;
},
/** 获取距冷却结束的时间(毫秒) */
getCooldownRemaining() {
const last = Storage.getLastExchange();
if (!last) return 0;
const remaining = CONFIG.EXCHANGE_COOLDOWN - (Date.now() - last);
return Math.max(0, remaining);
},
getCooldownReason() {
const remainingMs = this.getCooldownRemaining();
return remainingMs < 60000
? `冷却中(剩余 ${Math.ceil(remainingMs / 1000)} 秒)`
: `冷却中(剩余 ${Math.ceil(remainingMs / 60000)} 分钟)`;
},
/** 读取服务器当前 G 值;不信任当前停留页面的旧 DOM。 */
async _fetchCurrentBonus(cacheKey = 'np_bonus', nonce = Date.now()) {
try {
const bonusUrl = new URL('mybonus.php', window.location.href);
bonusUrl.searchParams.set(cacheKey, String(nonce));
const response = await fetch(bonusUrl.href, {
credentials: 'same-origin',
cache: 'no-store',
});
if (!response.ok) return null;
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
const stats = { bonus: null };
UserStats._extractBonus(stats, doc);
return Number.isFinite(stats.bonus) && stats.bonus >= 0 ? stats.bonus : null;
} catch (e) {
Log.warn('读取服务器当前魔力值失败:', e);
return null;
}
},
/**
* GGPT 的 POST 返回页可能仍是提交前的旧余额;必须再发一次禁缓存 GET,
* 读取服务器最新 G 值后才能判断兑换是否真正完成。
*/
async _verifyBonusDeduction(beforeBonus, result, expectedCost = 0, verification = {}) {
if (!result.success && !result.uncertain) return result;
if (!Number.isFinite(beforeBonus) || beforeBonus < 0) {
return { success: false, uncertain: true, reason: '无法读取兑换前的魔力值,未确认兑换成功' };
}
try {
for (let attempt = 0; attempt < 3; attempt++) {
const afterBonus = await this._fetchCurrentBonus('np_verify', `${Date.now()}-${attempt}`);
const afterSnapshot = verification.beforeSnapshot ? await this._fetchExchangeSnapshot() : null;
if (verification.beforeSnapshot && String(Storage.getAccount()) !== String(verification.beforeSnapshot.userId)) {
return { success: false, uncertain: true, reason: '核验期间账号已变化,已停止兑换' };
}
const trafficConfirmed = this._matchesExchangeTraffic(verification.beforeSnapshot, afterSnapshot, verification.tier);
if (trafficConfirmed) {
const deducted = Number.isFinite(afterBonus) ? beforeBonus - afterBonus : null;
const costDelta = deducted === null ? null : deducted - expectedCost;
const stopAfterSuccess = costDelta === null || Math.abs(costDelta) > Math.min(1, expectedCost * 0.01);
return {
...result, success: true, uncertain: false, cooldown: false,
afterBonus, afterSnapshot, deducted, costDelta,
accountingWarning: costDelta === null || Math.abs(costDelta) > 0.1,
stopAfterSuccess,
reason: stopAfterSuccess ? '传输量已确认兑换完成,但G值扣款异常;已计入进度并停止后续兑换' : '',
};
}
if (Number.isFinite(afterBonus) && afterBonus < beforeBonus) {
const deducted = beforeBonus - afterBonus;
if (expectedCost === 0) return { success: false, uncertain: true, afterBonus, reason: '免费档发生余额扣减且流量无法证实,已停止以避免重复兑换' };
// 站点余额只显示到 0.1 G,且后台结算可能产生极小残差。
// 对 10,000 G 档最多接受 1 G;较低档最多接受 0.2 G,绝不放宽为大额百分比。
const tolerance = Math.max(0.2, Math.min(1, Number(expectedCost) * 0.0001));
if (expectedCost > 0 && Math.abs(deducted - expectedCost) > tolerance) {
return {
success: false,
uncertain: true,
afterBonus,
reason: `G值实际减少 ${deducted.toFixed(1)},与本档应扣 ${expectedCost} 不一致;已停止循环以避免重复兑换`,
};
}
const costDelta = expectedCost > 0 ? deducted - expectedCost : 0;
const accountingWarning = Math.abs(costDelta) > 0.1;
if (accountingWarning) {
Log.warn(`站点余额核验存在 ${costDelta.toFixed(1)} G 小额残差,按已完成处理`);
}
const field = verification.tier?.type === 'download' ? 'downloaded' : 'uploaded';
const before = verification.beforeSnapshot;
const signedDelta = before && afterSnapshot
? (afterSnapshot[field] - before[field]) * (verification.tier?.effect === 'decrease' ? -1 : 1) : NaN;
const precision = before && afterSnapshot ? Math.max(before[`${field}Precision`], afterSnapshot[`${field}Precision`]) : NaN;
const stopAfterSuccess = Number.isFinite(signedDelta) && Number.isFinite(precision) && signedDelta < -precision;
return {
...result, success: true, uncertain: false, cooldown: false, afterBonus, afterSnapshot,
deducted, costDelta, accountingWarning, stopAfterSuccess,
reason: stopAfterSuccess ? 'G值扣款已确认,但传输量变化方向不符,已停止后续兑换' : '',
};
}
if (attempt < 2) await sleep((attempt + 1) * 500);
}
return {
success: false,
uncertain: true,
reason: '无法确认站点扣款或传输量变化,已停止以避免重复兑换',
};
} catch (e) {
Log.warn('核验兑换结果失败:', e);
return { success: false, uncertain: true, reason: '无法核验站点兑换结果,已停止以避免重复兑换' };
}
},
async _fetchExchangeSnapshot() {
const userId = Storage.getAccount();
if (!/^\d+$/.test(String(userId)) || Number(userId) <= 0) return null;
try {
const url = new URL('userdetails.php', window.location.href);
url.searchParams.set('id', userId);
url.searchParams.set('np_exchange', String(Date.now()));
const response = await fetch(url.href, { credentials: 'same-origin', cache: 'no-store' });
if (!response.ok || String(Storage.getAccount()) !== String(userId)) return null;
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
const identity = {};
UserStats._extractUserId(identity, doc);
if (String(identity.userId) !== String(userId)) return null;
const row = findRowheadSibling(['传输', '傳送', 'Transfers'], doc);
if (!row) return null;
const text = row.textContent.replace(/,/g, '');
const snapshot = { userId: String(userId) };
for (const [field, pattern] of [
['uploaded', /(?:上[传傳]量|Uploaded)[\s::]*([\d.]+)\s*(TB|GB|MB|TiB|GiB|MiB)/i],
['downloaded', /(?:下[载載]量|Downloaded)[\s::]*([\d.]+)\s*(TB|GB|MB|TiB|GiB|MiB)/i],
]) {
const match = text.match(pattern);
if (!match) return null;
snapshot[field] = parseSize(match[1], match[2]);
const decimals = (match[1].split('.')[1] || '').length;
snapshot[`${field}Precision`] = parseSize('1', match[2]) * Math.pow(10, -decimals);
if (!Number.isFinite(snapshot[field]) || snapshot[field] < 0 ||
!Number.isFinite(snapshot[`${field}Precision`]) || snapshot[`${field}Precision`] < 0) return null;
}
return String(Storage.getAccount()) === String(userId) ? snapshot : null;
} catch (e) {
Log.warn('读取服务器传输量失败:', e);
return null;
}
},
_matchesExchangeTraffic(before, after, tier) {
if (!before || !after || !tier || before.userId !== after.userId || String(Storage.getAccount()) !== String(before.userId)) return false;
if (!['upload', 'download'].includes(tier.type) || !['increase', 'decrease'].includes(tier.effect)) return false;
const field = tier.type === 'upload' ? 'uploaded' : 'downloaded';
const expected = Number(tier.gb) * Math.pow(1024, 3);
const precision = Math.max(before[`${field}Precision`], after[`${field}Precision`]);
if (!Number.isFinite(expected) || expected <= 0 || !Number.isFinite(precision) || precision >= expected / 2) return false;
if (!Number.isFinite(before[field]) || !Number.isFinite(after[field])) return false;
const delta = (after[field] - before[field]) * (tier.effect === 'decrease' ? -1 : 1);
return delta > 0 && Math.abs(delta - expected) <= precision + 1;
},
/** 无界动态规划:先最少 G 值,再最少超兑,最后最少请求。 */
_estimateRepeatedCost(parsed, type, targetGB, opts = {}) {
const target = Number(targetGB);
if (!Number.isFinite(target) || target < 0) return { success: false, reason: '无效的目标兑换量' };
const costScale = 1000000;
const empty = { success: true, type, effect: opts.effect || 'increase', runs: 0, targetGB: target, actualGB: 0, deltaGB: 0, unitCost: 0, cost: 0, costUnits: 0, costScale, steps: [] };
if (!target && !opts.allCandidates) return empty;
const known = (parsed?.tiers || []).filter(t => t.type === type && t.priceKnown === true && Number.isFinite(t.cost) && t.cost >= 0 && Number.isFinite(t.gb) && t.gb > 0 && ['increase', 'decrease'].includes(t.effect));
const effects = [...new Set(known.map(t => t.effect))];
const effect = opts.effect || (type === 'upload' ? 'increase' : effects.length === 1 ? effects[0] : null);
if (!effect) return { success: false, reason: effects.length > 1 ? '下载量有增加与减免两种方向,请明确选择后兑换' : '没有价格和方向均明确的下载量档位' };
const tiers = known.filter(t => t.effect === effect);
if (!tiers.length) return { success: false, blocked: true, reason: `没有价格明确的${type === 'download' ? '下载量' : '上传量'}${effect === 'decrease' ? '减免' : '增加'}档位` };
const tierCosts = tiers.map(t => {
const [mantissa, exponent = '0'] = String(t.cost).split('e');
const [whole, fraction = ''] = mantissa.split('.');
const precision = fraction.length - Number(exponent);
return precision > 6 ? NaN : Number(whole + fraction) * (10 ** (6 - precision));
});
if (tierCosts.some(cost => !Number.isSafeInteger(cost))) return { success: false, reason: '档位金额精度超过六位小数或安全整数范围,无法精确规划' };
const cap = opts.maxActualGB === undefined ? Infinity : Number(opts.maxActualGB);
if (!(cap >= target)) return { success: false, reason: '目标超过本次实际兑换量上限' };
let scale = 1;
while (scale < 1e6 && tiers.some(t => Math.abs(t.gb * scale - Math.round(t.gb * scale)) > 1e-7)) scale *= 10;
if (tiers.some(t => !Number.isSafeInteger(Math.round(t.gb * scale)) || Math.abs(t.gb * scale - Math.round(t.gb * scale)) > 1e-7)) return { success: false, reason: '档位精度超出精确规划范围,未生成近似方案' };
const gcd = (a, b) => { while (b) { const r = a % b; a = b; b = r; } return a; };
const divisor = tiers.map(t => Math.round(t.gb * scale)).reduce(gcd);
const units = tiers.map(t => Math.round(t.gb * scale) / divisor);
const unitGB = divisor / scale;
const minimum = Math.ceil(target / unitGB - 1e-10);
const maximum = Math.min(Math.ceil(target / unitGB - 1e-10) + Math.max(...units), Math.floor(cap / unitGB + 1e-10));
if (!Number.isSafeInteger(Math.max(...tierCosts) * Math.floor(maximum / Math.min(...units)))) return { success: false, reason: '方案总金额超过精确整数范围,无法精确规划' };
const budget = opts.budget || { remaining: 5000000 };
const operations = (maximum + 1) * tiers.length;
if (!Number.isSafeInteger(maximum) || maximum > 200000 || operations > budget.remaining) return { success: false, reason: '方案复杂度过大,无法在安全计算范围内求出精确最优解' };
budget.remaining -= operations;
const costs = new Float64Array(maximum + 1).fill(Infinity);
const runs = new Uint32Array(maximum + 1);
const previous = new Int32Array(maximum + 1).fill(-1);
costs[0] = 0;
for (let amount = 1; amount <= maximum; amount++) {
for (let index = 0; index < tiers.length; index++) {
const from = amount - units[index];
if (from < 0 || !Number.isFinite(costs[from])) continue;
const cost = costs[from] + tierCosts[index];
const count = runs[from] + 1;
if (cost < costs[amount] || (cost === costs[amount] && count < runs[amount])) {
costs[amount] = cost;
runs[amount] = count;
previous[amount] = index;
}
}
}
const candidates = [];
let best = -1;
for (let amount = minimum; amount <= maximum; amount++) {
if (!Number.isFinite(costs[amount])) continue;
if (opts.allCandidates) candidates.push(amount);
if (best < 0 || costs[amount] < costs[best] || (costs[amount] === costs[best] && (amount < best || (amount === best && runs[amount] < runs[best])))) best = amount;
}
if (best < 0) return { success: false, reason: '现有档位无法在实际兑换量上限内满足目标' };
const selected = opts.allCandidates ? candidates : [best];
const reconstructionCost = selected.reduce((sum, amount) => sum + runs[amount], 0);
if (reconstructionCost > budget.remaining) return { success: false, reason: '方案复杂度过大,无法在安全计算范围内求出精确最优解' };
budget.remaining -= reconstructionCost;
const details = selected.map(amount => {
const counts = new Map();
for (let remaining = amount; remaining > 0;) {
const index = previous[remaining];
counts.set(index, (counts.get(index) || 0) + 1);
remaining -= units[index];
}
const steps = [...counts].map(([index, count]) => ({ tier: tiers[index], count }));
const actualGB = amount * divisor / scale;
const hundred = tiers.filter(t => t.gb === 100).sort((a, b) => a.cost - b.cost)[0];
const baselineUnits = hundred ? Math.ceil(target / 100) * tierCosts[tiers.indexOf(hundred)] : null;
const baseline100Cost = baselineUnits !== null && Number.isSafeInteger(baselineUnits) ? baselineUnits / costScale : null;
return { success: true, type, effect, targetGB: target, actualGB, deltaGB: effect === 'decrease' ? -actualGB : actualGB, cost: costs[amount] / costScale, costUnits: costs[amount], costScale, runs: runs[amount], steps, unitCost: steps[0]?.tier.cost || 0, baseline100Cost, savings: baseline100Cost === null ? null : (baselineUnits - costs[amount]) / costScale };
});
return opts.allCandidates ? { success: true, candidates: details } : details[0];
},
_estimatePlanCost(parsed, plan, opts = {}) {
if (plan?.blocked) return { success: false, blocked: true, reason: '非流量晋级条件未达标,不能执行晋级兑换' };
const budget = { remaining: 5000000 };
const cap = opts.maxActualGB === undefined ? Infinity : Number(opts.maxActualGB);
const downloadTarget = Math.max(0, Number(plan?.downloadGB) || 0);
const zeroDownload = this._estimateRepeatedCost(parsed, 'download', 0, { effect: 'increase' });
let candidates = [zeroDownload];
if (downloadTarget > 0) {
const downloads = this._estimateRepeatedCost(parsed, 'download', downloadTarget, { effect: 'increase', allCandidates: true, maxActualGB: cap, budget });
if (!downloads.success) return downloads;
candidates = downloads.candidates;
}
const coupled = Number.isFinite(plan?.currentDownloadedGB) && Number.isFinite(plan?.currentUploadedGB);
let best = null;
let lastFailure = null;
for (const download of candidates) {
const uploadTarget = coupled
? Math.max(0, (plan.minUploadGB || 0) - plan.currentUploadedGB, (plan.requiredRatio || 0) * (plan.currentDownloadedGB + download.deltaGB) - plan.currentUploadedGB)
: Math.max(0, Number(plan?.uploadGB) || 0);
const upload = this._estimateRepeatedCost(parsed, 'upload', uploadTarget, { effect: 'increase', maxActualGB: cap - download.actualGB, budget });
if (!upload.success) {
if (upload.reason.includes('复杂度') || upload.reason.includes('精度') || upload.reason.includes('精确整数')) return upload;
lastFailure = upload;
continue;
}
const totalCostUnits = upload.costUnits + download.costUnits;
if (!Number.isSafeInteger(totalCostUnits)) return { success: false, reason: '联合方案总金额超过精确整数范围,无法精确规划' };
const candidate = { success: true, upload, download, totalCost: totalCostUnits / upload.costScale, totalCostUnits, costScale: upload.costScale, totalRuns: upload.runs + download.runs, totalActualGB: upload.actualGB + download.actualGB, optimizationScope: 'increase-only' };
if (!best || candidate.totalCostUnits < best.totalCostUnits || (candidate.totalCostUnits === best.totalCostUnits && (candidate.totalActualGB < best.totalActualGB || (candidate.totalActualGB === best.totalActualGB && candidate.totalRuns < best.totalRuns)))) best = candidate;
}
if (!best) return lastFailure || { success: false, reason: '没有满足全部晋级条件的兑换方案' };
const hundredDownload = downloadTarget > 0 ? Math.ceil(downloadTarget / 100) * 100 : 0;
const hundredUploadTarget = coupled ? Math.max(0, (plan.minUploadGB || 0) - plan.currentUploadedGB, (plan.requiredRatio || 0) * (plan.currentDownloadedGB + hundredDownload) - plan.currentUploadedGB) : Math.max(0, Number(plan?.uploadGB) || 0);
const hundredTiers = { tiers: (parsed?.tiers || []).filter(t => t.gb === 100) };
const baselineUpload = this._estimateRepeatedCost(hundredTiers, 'upload', hundredUploadTarget, { effect: 'increase' });
const baselineDownload = this._estimateRepeatedCost(hundredTiers, 'download', downloadTarget, { effect: 'increase' });
const baselineUnits = baselineUpload.success && baselineDownload.success ? baselineUpload.costUnits + baselineDownload.costUnits : null;
best.baseline100Cost = baselineUnits !== null && Number.isSafeInteger(baselineUnits) ? baselineUnits / best.costScale : null;
best.savings = best.baseline100Cost === null ? null : (baselineUnits - best.totalCostUnits) / best.costScale;
return best;
},
/** 读取站点当前档位,供面板展示晋级方案预计魔力值。 */
async estimatePromotionPlan(evaluation = RuleEngine.evaluate()) {
const plan = evaluation?.exchangePlan;
if (!plan || (!plan.uploadGB && !plan.downloadGB)) {
return { success: false, reason: '当前没有需要兑换的流量缺口' };
}
const parsed = await this._getTiers();
if (!parsed) return { success: false, reason: '无法读取站点兑换档位' };
const estimate = this._estimatePlanCost(parsed, plan);
const value = {
...estimate,
pageBonus: parsed.pageBonus ?? null,
plan,
};
return value;
},
async estimateManual(gb, type, opts = {}) {
const parsed = await this._getTiers();
if (!parsed) return { success: false, reason: '无法读取站点兑换档位' };
const stats = Storage.getUserStats() || {};
const knownEffects = [...new Set(parsed.tiers.filter(t => t.type === type && t.priceKnown && ['increase', 'decrease'].includes(t.effect)).map(t => t.effect))];
const effect = opts.effect || (knownEffects.length === 1 ? knownEffects[0] : undefined);
const maxActualGB = effect === 'decrease' ? Math.min(opts.maxActualGB ?? Infinity, (stats.downloaded || 0) / (1024 ** 3)) : opts.maxActualGB;
const detail = this._estimateRepeatedCost(parsed, type, gb, { ...opts, effect, maxActualGB });
if (!detail.success) return detail;
const empty = this._estimateRepeatedCost(parsed, type === 'upload' ? 'download' : 'upload', 0);
return { success: true, upload: type === 'upload' ? detail : empty, download: type === 'download' ? detail : empty, totalCost: detail.cost, totalRuns: detail.runs, totalActualGB: detail.actualGB, baseline100Cost: detail.baseline100Cost, savings: detail.savings, pageBonus: parsed.pageBonus ?? null, parsed, detail };
},
/**
* 晋级计划兑换:按 evaluate() 算出的缺口,先兑上传量、再兑下载量
* 不再被"无需兑换"拦截——只要存在流量缺口就会执行
*/
async executeExchange(opts = {}) {
return this._runTask('promotion', () => this._executePromotion(opts));
},
async _executePromotion(opts) {
Log.info('开始执行兑换(晋级计划驱动)...');
const stats = await UserStats.collect({ forceDetailsRefresh: true });
const evaluation = RuleEngine.evaluate();
if (!evaluation.needsExchange) {
const why = evaluation.promotionStatus === 'met'
? '已满足下一等级晋级条件,等待系统自动晋级'
: (evaluation.messages[0] || '当前无流量缺口,无需兑换');
Log.info(why);
return { success: false, reason: why };
}
const parsed = await this._getTiers();
if (!parsed) return { success: false, reason: '未找到兑换档位(无法解析 mybonus 页面)' };
const GB = 1024 * 1024 * 1024;
const plan = evaluation.exchangePlan
|| { downloadGB: 0, uploadGB: (evaluation.exchangeAmount || 0) / GB };
const estimate = this._estimatePlanCost(parsed, plan, opts);
if (!estimate.success) return estimate;
{
const confirmationContext = {
evaluation,
plan,
estimate,
currentBonus: parsed.pageBonus ?? stats.bonus,
minBonusKeep: Math.max(0, Number(Storage.getSettings().autoExchangeMinBonusKeep) || 0),
projectedRatio: (stats.downloaded + estimate.download.deltaGB * GB) > 0 ? (stats.uploaded + estimate.upload.deltaGB * GB) / (stats.downloaded + estimate.download.deltaGB * GB) : Infinity,
};
this._setTaskState({ phase: 'confirming', totalRuns: estimate.totalRuns });
if (typeof opts.confirmExchange !== 'function') {
return {
success: false,
confirmationRequired: true,
reason: '晋级兑换需要用户确认实际数量和费用',
...confirmationContext,
};
}
const confirmed = await opts.confirmExchange(confirmationContext);
if (!confirmed) {
return {
success: false,
cancelled: true,
reason: '用户取消了兑换',
estimate,
};
}
}
const done = [];
const completed = { uploadGB: 0, downloadGB: 0, cost: 0 };
let confirmedBonus = parsed.pageBonus ?? stats.bonus;
// 必须先补上传量,再补下载量;否则下载会先拉低分享率。
if (estimate.upload.runs > 0) {
const r = await this._exchangeRepeated(parsed, 'upload', estimate.upload.targetGB, confirmedBonus, { ...opts, detail: estimate.upload });
completed.uploadGB += r.gb || 0;
completed.cost += r.cost || 0;
if (!r.success) return { ...r, ...completed, partial: completed.uploadGB > 0 };
confirmedBonus = r.afterBonus;
done.push(`上传量 ${r.gb} GB(-${formatInteger(r.cost)} 魔力值)`);
}
// 上传成功并经过站点冷却后,才兑换下载量。
if (estimate.download.runs > 0) {
const r = await this._exchangeRepeated(parsed, 'download', estimate.download.targetGB, confirmedBonus, { ...opts, detail: estimate.download });
completed.downloadGB += r.gb || 0;
completed.cost += r.cost || 0;
if (!r.success) {
return { ...r, ...completed, success: false, partial: completed.uploadGB + completed.downloadGB > 0, message: done.join(' + ') + `;下载量兑换失败: ${r.reason}` };
}
confirmedBonus = r.afterBonus;
done.push(`下载量 ${r.gb} GB(-${formatInteger(r.cost)} 魔力值)`);
}
return { success: true, ...completed, afterBonus: confirmedBonus, message: done.join(' + '), plan, estimatedCost: estimate.totalCost };
},
/** 执行已确认的档位组合;不确定的提交结果绝不重试。 */
async _exchangeRepeated(parsed, type, targetGB, beforeBonus, opts = {}) {
const typeName = type === 'download' ? '下载量' : '上传量';
const detail = opts.detail || this._estimateRepeatedCost(parsed, type, targetGB, opts);
if (!detail.success) return detail;
if (detail.actualGB > (opts.maxActualGB ?? Infinity)) return { success: false, reason: '实际兑换量超过本次上限,未提交任何兑换' };
if (detail.type !== type || detail.targetGB !== Number(targetGB) || (opts.effect && opts.effect !== detail.effect) || !detail.steps.every(step => step.tier.type === type && step.tier.priceKnown === true && step.tier.effect === detail.effect && Number.isInteger(step.count) && step.count > 0)) return { success: false, reason: '已确认方案与兑换目标不一致' };
const totalRuns = detail.runs;
if (!totalRuns) return { success: false, reason: '无效的目标兑换量' };
let currentBonus = beforeBonus;
let totalCost = 0;
let completedRuns = 0;
let completedGB = 0;
const stopped = () => ({ success: false, stopped: true, partial: completedRuns > 0, gb: completedGB, deltaGB: detail.effect === 'decrease' ? -completedGB : completedGB, cost: totalCost, afterBonus: currentBonus, reason: this._stopReason() });
this._setTaskState({ type, totalRuns: Math.max(this.taskState.totalRuns, this.taskState.completedRuns + totalRuns) });
let stepIndex = 0;
let stepRuns = 0;
for (let i = 0; i < totalRuns; i++) {
const tier = detail.steps[stepIndex].tier;
let retries = 0;
while (true) {
if (this._stopReason()) return stopped();
const waitMs = this.getCooldownRemaining();
if (waitMs > 0) {
opts.onProgress?.({ phase: 'cooldown', completedRuns, totalRuns, waitMs, type });
if (!await this._wait(waitMs + 250)) return stopped();
}
// 估算页与当前页面都可能是旧 DOM;每批提交前以服务器余额作唯一基准。
const freshBonus = await this._fetchCurrentBonus('np_before', `${Date.now()}-${i}-${retries}`);
if (freshBonus === null) {
return {
success: false,
partial: completedRuns > 0,
gb: completedGB,
cost: totalCost,
afterBonus: currentBonus,
reason: '无法读取提交前的服务器魔力值,已停止以避免误兑换',
};
}
currentBonus = freshBonus;
if (this._stopReason()) return stopped();
const minBonusKeep = Math.max(0, Number(Storage.getSettings().autoExchangeMinBonusKeep) || 0);
if (tier.cost > currentBonus - minBonusKeep) {
return {
success: false,
partial: completedRuns > 0,
gb: completedGB,
cost: totalCost,
afterBonus: currentBonus,
reason: `魔力值不足(下一次需 ${tier.cost},当前 ${currentBonus.toFixed(1)},保留 ${minBonusKeep})`,
};
}
const beforeSnapshot = await this._fetchExchangeSnapshot();
if (tier.effect === 'decrease' && (!Number.isFinite(beforeSnapshot?.downloaded) || beforeSnapshot.downloaded < tier.gb * (1024 ** 3))) return { ...stopped(), reason: '无法确认当前下载量足够减免,已停止' };
if (this._stopReason()) return stopped();
this._setTaskState({ phase: 'submitting', waitUntil: 0 });
const submitted = await AutoExchange._submitTier(parsed, tier);
this._setTaskState({ phase: 'verifying' });
const result = await this._verifyBonusDeduction(
currentBonus,
submitted,
tier.cost,
{ beforeSnapshot, tier }
);
if (result.success) {
currentBonus = result.afterBonus;
totalCost += Number.isFinite(result.deducted) ? result.deducted : tier.cost;
completedRuns++;
completedGB += tier.gb;
stepRuns++;
if (stepRuns === detail.steps[stepIndex].count) { stepIndex++; stepRuns = 0; }
this._setTaskState({ completedRuns: this.taskState.completedRuns + 1, completedGB: this.taskState.completedGB + tier.gb, cost: this.taskState.cost + (Number.isFinite(result.deducted) ? result.deducted : tier.cost) });
if (this._taskAccount !== Storage.getAccount()) return { ...stopped(), reason: '账号已切换,已完成兑换未写入新账号缓存' };
Storage.setLastExchange(Date.now());
const stored = Storage.getUserStats() || {};
const GB = 1024 * 1024 * 1024;
const updated = { ...stored, bonus: currentBonus };
const deltaGB = tier.effect === 'decrease' ? -tier.gb : tier.gb;
if (type === 'upload') updated.uploaded = (stored.uploaded || 0) + deltaGB * GB;
else updated.downloaded = (stored.downloaded || 0) + deltaGB * GB;
if (Number.isFinite(result.afterSnapshot?.uploaded) && Number.isFinite(result.afterSnapshot?.downloaded)) {
updated.uploaded = result.afterSnapshot.uploaded;
updated.downloaded = result.afterSnapshot.downloaded;
}
updated.ratio = updated.downloaded > 0 ? updated.uploaded / updated.downloaded : Infinity;
Storage.setUserStats(updated);
if (this._stopReason()) return stopped();
opts.onProgress?.({
phase: 'success', completedRuns, totalRuns, gb: completedGB, type,
accountingWarning: result.accountingWarning,
});
Log.info(`循环兑换进度: ${typeName} ${completedRuns}/${totalRuns}(${completedGB} GB)`);
if (result.stopAfterSuccess || result.stopAfterVerification) return { ...stopped(), reason: result.reason || '已确认本次兑换,但核验提示停止后续兑换' };
break;
}
if (result.cooldown && !result.uncertain && retries < 2) {
retries++;
Storage.setLastExchange(Date.now());
Log.warn(`站点冷却拒绝,等待后重试(${retries}/2)`);
continue;
}
return {
...result,
partial: completedRuns > 0,
gb: completedGB,
cost: totalCost,
};
}
}
return {
success: true,
gb: completedGB,
deltaGB: detail.effect === 'decrease' ? -completedGB : completedGB,
cost: totalCost,
type,
runs: completedRuns,
afterBonus: currentBonus,
};
},
/**
* 手动兑换:指定 GB 数和类型。使用后台请求并核验余额,避免 DOM 点击后虚报成功。
*/
async executeManual(gb, type = 'upload', opts = {}) {
return this._runTask(type, () => this._executeManual(gb, type, opts));
},
async _executeManual(gb, type, opts) {
gb = parseFloat(gb);
if (!Number.isFinite(gb) || gb <= 0 || !['upload', 'download'].includes(type)) return { success: false, reason: '无效的兑换量或类型' };
const stats = await UserStats.collect({ forceDetailsRefresh: true });
const parsed = opts.parsed || await this._getTiers();
if (!parsed) return { success: false, reason: '未找到兑换档位(无法解析 mybonus 页面)' };
const detail = opts.detail || this._estimateRepeatedCost(parsed, type, gb, opts);
if (!detail.success) return detail;
const effect = opts.effect || detail.effect;
const maxActualGB = effect === 'decrease' ? Math.min(opts.maxActualGB ?? Infinity, (stats.downloaded || 0) / (1024 ** 3)) : opts.maxActualGB;
return await this._exchangeRepeated(parsed, type, gb, parsed.pageBonus ?? stats.bonus, { ...opts, effect, detail, maxActualGB });
},
/** 获取兑换档位与当前余额:优先禁缓存 GET,失败时才回退到当前 mybonus 页面。 */
async _getTiers() {
try {
const url = new URL('mybonus.php', window.location.href);
url.searchParams.set('np_tiers', String(Date.now()));
const resp = await fetch(url.href, { credentials: 'same-origin', cache: 'no-store' });
if (!resp.ok) return null;
const doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
const parsed = AutoExchange._parseExchangeTiers(doc);
if (parsed) parsed.pageBonus = AutoExchange._extractPageBonus(doc);
return parsed;
} catch (e) {
Log.warn('fetch mybonus.php 失败:', e);
if (/mybonus\.php|bonus\.php/.test(getPath())) {
const parsed = AutoExchange._parseExchangeTiers(document);
if (parsed && parsed.tiers.length) {
parsed.pageBonus = AutoExchange._extractPageBonus(document);
return parsed;
}
}
return null;
}
},
};
// ============================================================
// 12.5 AUTO EXCHANGE — 自动兑换调度器(后台执行,不依赖当前页面)
// ============================================================
const AutoExchange = {
_timer: null,
_running: false,
/** 启动调度:首次 10 秒后检查;后续按任务需要重新安排下一次检查。 */
start() {
if (this._timer) return;
this._schedule(10000);
Log.info('自动兑换调度器已启动');
},
_schedule(delayMs) {
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(async () => {
this._timer = null;
let nextDelay = 60000;
try {
nextDelay = await this.checkAndRun();
} catch (e) {
Log.error('自动兑换调度器异常:', e);
}
this._schedule(Math.max(1000, Number(nextDelay) || 60000));
}, Math.max(1000, delayMs));
},
scheduleSoon() {
if (!this._running) this._schedule(1000);
},
/** 检查条件并视情况执行自动兑换 */
async checkAndRun() {
if (this._running || BonusExchange.taskState.running) return 1000;
const settings = Storage.getSettings();
if (!settings.autoExchange) return 60000;
if (!settings.autoExchangeAccountId || settings.autoExchangeAccountId !== Storage.getAccount()) {
const reason = '自动任务所属账号已变化,请重新核对设置后开启';
Storage.setSettings({ ...settings, autoExchange: false });
BonusExchange._setTaskState({ phase: 'idle', reason, message: '', result: { success: false, reason } });
return 60000;
}
const result = await BonusExchange._runTask('automatic', () => this._checkAndRun(), true);
return Number.isFinite(result?.nextDelay) ? result.nextDelay : 60000;
},
async _checkAndRun() {
const settings = Storage.getSettings();
if (BonusExchange._stopReason()) return { success: false, reason: BonusExchange._stopReason(), nextDelay: 60000 };
if (!settings.autoExchange) return { success: true, idle: true, reason: '自动兑换已关闭', nextDelay: 60000 };
// 按量完成模式使用站点冷却,不能被旧版的一小时全局冷却拦住。
if (settings.autoExchangeMode !== 'amount' && BonusExchange.isOnCooldown()) {
return { success: true, idle: true, reason: '等待站点兑换冷却', nextDelay: Math.min(60000, Math.max(1000, BonusExchange.getCooldownRemaining())) };
}
if (settings.autoExchangeMode !== 'amount') {
const intervalMin = settings.autoExchangeIntervalMin || 60;
const last = Storage.getLastExchange();
if (last && Date.now() - last < intervalMin * 60000) {
return { success: true, idle: true, reason: '等待下次间隔', nextDelay: Math.min(60000, Math.max(1000, intervalMin * 60000 - (Date.now() - last))) };
}
}
this._running = true;
try {
const stats = await UserStats.collect();
if (!stats.bonus || stats.bonus <= 0) {
Log.warn('自动兑换: 魔力值未知或为 0,跳过本次');
return { success: true, idle: true, reason: '余额不足或未知', nextDelay: 60000 };
}
if (settings.autoExchangeMode === 'amount') {
return await this._runAmountExchange(settings, stats);
}
const maxGB = Number(settings.autoExchangeMaxGBPerRun);
if (!Number.isFinite(maxGB) || maxGB <= 0) return { success: false, reason: '本次上限须大于 0 GB,未提交兑换' };
const parsed = await BonusExchange._getTiers();
if (!parsed) return { success: false, reason: '无法读取本站兑换档位' };
let estimate;
if (settings.autoExchangeMode === 'fixed') {
const gb = Math.max(0, settings.autoExchangeFixedGB || 0);
const type = settings.autoExchangeFixedType === 'download' ? 'download' : 'upload';
if (!gb || gb > maxGB) return { success: false, reason: '定额须大于 0 GB 且不能超过本次上限' };
const detail = BonusExchange._estimateRepeatedCost(parsed, type, gb, { maxActualGB: maxGB });
if (!detail.success) return detail;
estimate = { [type]: detail, totalRuns: detail.runs };
} else {
const evaluation = RuleEngine.evaluate();
if (!evaluation.needsExchange) return { success: true, idle: true, reason: evaluation.messages?.[0] || '当前无流量缺口,无需兑换', nextDelay: 60000 };
const plan = evaluation.exchangePlan
|| { downloadGB: 0, uploadGB: Math.ceil((evaluation.exchangeAmount || 0) / (1024 * 1024 * 1024)) };
if (plan.requiresTimeConfirmation) {
Log.warn('自动兑换: 注册时间尚未达标,必须由用户在面板中确认,跳过本次');
return { success: true, idle: true, reason: '注册时间尚未达标,请手动确认提前兑换', nextDelay: 60000 };
}
estimate = BonusExchange._estimatePlanCost(parsed, plan);
if (!estimate.success) return { ...estimate, idle: true, nextDelay: 60000 };
}
let remainingRunLimit = maxGB;
const done = [];
for (const type of ['upload', 'download']) {
const detail = estimate[type];
if (!detail?.runs) continue;
const steps = [];
let actualGB = 0, cost = 0, runs = 0;
for (const step of detail.steps) {
const count = Math.min(step.count, Math.floor((remainingRunLimit - actualGB + 1e-8) / step.tier.gb));
if (count <= 0) break;
steps.push({ tier: step.tier, count });
actualGB += count * step.tier.gb;
cost += count * step.tier.cost;
runs += count;
if (count < step.count) break;
}
if (!runs) return { success: false, partial: done.length > 0, reason: '本次上限不足以容纳下一档;未提交后续兑换' };
const limited = { ...detail, steps, actualGB, cost, runs, deltaGB: detail.deltaGB < 0 ? -actualGB : actualGB, unitCost: steps[0].tier.cost };
const result = await BonusExchange._exchangeRepeated(parsed, type, actualGB, stats.bonus, { detail: limited, maxActualGB: remainingRunLimit });
if (result.success) {
done.push(`${type === 'download' ? (limited.deltaGB < 0 ? '减少下载' : '增加下载') : '增加上传'} ${result.gb} GB(-${formatInteger(result.cost)})`);
remainingRunLimit -= result.gb;
stats.bonus = result.afterBonus;
} else {
Log.warn('自动兑换失败:', result.reason);
showNotification(`⚠️ 自动兑换失败: ${result.reason}`, 'warning', 5000);
return result;
}
if (actualGB + 1e-8 < detail.actualGB) return { success: false, partial: true, reason: '已达到本次上限;剩余计划将在下次检查时重算' };
}
if (done.length) {
showNotification(`✅ 自动兑换成功: ${done.join(' + ')}`, 'success', 6000);
Panel.update();
}
return { success: true, message: done.join(' + '), nextDelay: 60000 };
} catch (e) {
Log.error('自动兑换异常:', e);
return { success: false, reason: e.message || '自动兑换异常', nextDelay: 60000 };
} finally {
this._running = false;
}
},
_tierKey(tier) {
const form = tier.submission || {};
return JSON.stringify([tier.type, tier.effect, tier.gb, tier.cost, tier.value,
form.tierFieldName || 'option', form.action || 'mybonus.php', form.method || 'post',
form.submitName || '', form.submitValue || '']);
},
_detectCooldownSeconds(doc) {
const text = (doc.body?.innerText || doc.body?.textContent || '').replace(/\s+/g, ' ');
const patterns = [
/(?:系统|站点)?\s*(?:限制|冷却|间隔)[^\d]{0,24}(\d+(?:\.\d+)?)\s*(秒|分钟|分|min(?:ute)?s?|seconds?|secs?)/i,
/(\d+(?:\.\d+)?)\s*(秒|分钟|分|min(?:ute)?s?|seconds?|secs?)[^。!?]{0,40}(?:点击|交换|兑换|exchange)/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match) continue;
const multiplier = /^(?:分钟|分|min)/i.test(match[2]) ? 60 : 1;
const seconds = parseFloat(match[1]) * multiplier;
if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds);
}
return 0;
},
_configuredCooldownSeconds(settings, doc) {
if (settings.autoExchangeCooldownMode === 'manual') {
return Math.max(1, Math.ceil(Number(settings.autoExchangeCooldownSeconds) || 0));
}
return this._detectCooldownSeconds(doc);
},
_extractPageBonus(doc) {
const pageStats = { bonus: null };
UserStats._extractBonus(pageStats, doc);
return Number.isFinite(pageStats.bonus) && pageStats.bonus >= 0 ? pageStats.bonus : null;
},
_pauseAmountExchange(settings, reason) {
const current = Storage.getSettings();
if (BonusExchange._taskAccount === Storage.getAccount() && current.autoExchangeAccountId === settings.autoExchangeAccountId && current.autoExchangeTaskVersion === settings.autoExchangeTaskVersion) Storage.setSettings({ ...current, autoExchange: false });
BonusExchange._setTaskState({ reason, message: reason });
Log.warn(`自动按量兑换已暂停: ${reason}`);
showNotification(`⚠️ 自动按量兑换已暂停:${reason}`, 'warning', 7000);
return { success: false, reason, nextDelay: 60000 };
},
/**
* 按量完成:每次只提交用户指定的一档;成功后保存实际完成量与剩余目标,
* 再由调度器在站点冷却结束后执行下一次,避免单页长循环与重复提交。
*/
async _runAmountExchange(settings, stats) {
const targetGB = Math.max(0, Number(settings.autoExchangeAmountGB) || 0);
const storedRemainingGB = Number(settings.autoExchangeAmountRemainingGB);
const remainingGB = Number.isFinite(storedRemainingGB)
? Math.max(0, storedRemainingGB)
: targetGB;
if (!targetGB || !remainingGB) {
return this._pauseAmountExchange(settings, '没有可执行的剩余兑换量');
}
if (!settings.autoExchangeAmountTierKey) {
return this._pauseAmountExchange(settings, '请选择本站兑换档位');
}
const resp = await fetch('mybonus.php', { credentials: 'same-origin' });
if (!resp.ok) return this._pauseAmountExchange(settings, `读取 mybonus.php 失败(HTTP ${resp.status})`);
const doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
const parsed = this._parseExchangeTiers(doc);
if (!parsed?.tiers?.length) {
return this._pauseAmountExchange(settings, '未在 mybonus 页面找到兑换档位');
}
const beforeBonus = await BonusExchange._fetchCurrentBonus();
if (BonusExchange._stopReason()) return { success: false, stopped: true, reason: BonusExchange._stopReason() };
if (beforeBonus === null) return this._pauseAmountExchange(settings, '无法确认服务器当前余额');
const cooldownSeconds = this._configuredCooldownSeconds(settings, doc);
if (!cooldownSeconds) {
return this._pauseAmountExchange(settings, '无法自动识别站点兑换冷却,请改为手动填写秒数');
}
const last = Storage.getLastExchange();
const waitMs = last ? cooldownSeconds * 1000 - (Date.now() - last) : 0;
if (waitMs > 0) return { success: true, idle: true, reason: '等待站点兑换冷却', nextDelay: waitMs + 250 };
const tier = parsed.tiers.find(t =>
this._tierKey(t) === settings.autoExchangeAmountTierKey
&& (t.type === 'upload' || t.type === 'download')
&& (t.type !== 'upload' || t.effect === 'increase')
&& t.priceKnown && t.effect !== 'unknown'
&& Math.abs(t.gb - 100) < 0.001
);
if (!tier) {
return this._pauseAmountExchange(settings, '所选 100 GB 档位已不在本站兑换页面中');
}
const keepBonus = Math.max(0, Number(Storage.getSettings().autoExchangeMinBonusKeep) || 0);
const budget = Math.max(0, beforeBonus - keepBonus);
if (tier.cost > budget) {
return this._pauseAmountExchange(settings, `魔力值不足以兑换所选档位(需 ${tier.cost},可用 ${budget.toFixed(1)})`);
}
const beforeSnapshot = await BonusExchange._fetchExchangeSnapshot();
if (BonusExchange._stopReason()) return { success: false, stopped: true, reason: BonusExchange._stopReason() };
if (tier.type === 'download' && tier.effect === 'decrease' && (!Number.isFinite(beforeSnapshot?.downloaded) || beforeSnapshot.downloaded < tier.gb * 1024 ** 3)) {
return this._pauseAmountExchange(settings, '无法确认当前下载量足够执行本站下载减免');
}
Log.info(`自动按量兑换: ${tier.type} ${tier.gb} GB,剩余目标 ${remainingGB} GB`);
BonusExchange._setTaskState({ phase: 'submitting', type: tier.type, totalRuns: Math.ceil(remainingGB / 100) });
const submitted = await this._submitTier(parsed, tier);
BonusExchange._setTaskState({ phase: 'verifying' });
const result = await BonusExchange._verifyBonusDeduction(
beforeBonus,
submitted,
tier.cost,
{ beforeSnapshot, tier }
);
if (!result.success) {
if (result.cooldown) {
Log.warn('站点仍处于兑换冷却,等待下一轮调度');
return { success: true, idle: true, reason: '等待站点兑换冷却', nextDelay: cooldownSeconds * 1000 + 250 };
}
return this._pauseAmountExchange(settings, result.reason || '站点拒绝了兑换请求');
}
const nextRemainingGB = Math.max(0, remainingGB - tier.gb);
const completedGB = Math.max(0, Number(settings.autoExchangeAmountCompletedGB) || 0) + tier.gb;
if (BonusExchange._taskAccount !== Storage.getAccount()) return { success: false, partial: true, gb: tier.gb, cost: result.deducted ?? tier.cost, reason: '账号已切换,已完成兑换未写入新账号缓存' };
Storage.setLastExchange(Date.now());
const current = Storage.getSettings();
if (current.autoExchangeAccountId === settings.autoExchangeAccountId && current.autoExchangeTaskVersion === settings.autoExchangeTaskVersion) {
Storage.setSettings({ ...current, autoExchange: current.autoExchange && nextRemainingGB > 0 && !result.stopAfterSuccess, autoExchangeAmountRemainingGB: nextRemainingGB, autoExchangeAmountCompletedGB: completedGB });
}
const stored = Storage.getUserStats() || {};
const updated = { ...stored, bonus: result.afterBonus };
const field = tier.type === 'download' ? 'downloaded' : 'uploaded';
updated[field] = Math.max(0, (stored[field] || 0) + (tier.effect === 'decrease' ? -1 : 1) * tier.gb * 1024 ** 3);
if (Number.isFinite(result.afterSnapshot?.uploaded) && Number.isFinite(result.afterSnapshot?.downloaded)) {
updated.uploaded = result.afterSnapshot.uploaded;
updated.downloaded = result.afterSnapshot.downloaded;
}
updated.ratio = updated.downloaded > 0 ? updated.uploaded / updated.downloaded : (updated.uploaded > 0 ? Infinity : 0);
Storage.setUserStats(updated);
BonusExchange._setTaskState({ completedRuns: 1, totalRuns: Math.ceil(remainingGB / 100), completedGB: tier.gb, cost: result.deducted ?? tier.cost });
if (result.stopAfterSuccess) return { success: false, partial: true, gb: tier.gb, cost: result.deducted ?? tier.cost, reason: result.reason || '本档已完成,但余额变化异常,已停止后续兑换' };
if (nextRemainingGB > 0) {
showNotification(`✅ 自动按量兑换:已兑换 ${tier.gb} GB,剩余目标 ${nextRemainingGB} GB`, 'success', 5000);
} else {
showNotification(`✅ 自动按量兑换完成:实际兑换 ${completedGB} GB`, 'success', 7000);
}
Panel.update();
const stopped = BonusExchange.taskState.stopRequested || Storage.get('EXCHANGE_STOP', null) !== BonusExchange._taskStopToken;
return { success: !stopped, partial: stopped, gb: tier.gb, cost: result.deducted ?? tier.cost, nextDelay: cooldownSeconds * 1000 + 250, reason: stopped ? '用户已停止兑换,当前已提交轮次完成' : '' };
},
/**
* 后台兑换:fetch mybonus.php 解析兑换表单与档位,再提交
* 不依赖当前停留在 mybonus 页面
* @param {'upload'|'download'} type 兑换类型
* @param {number} targetGB 目标 GB 数
*/
async _backgroundExchange(type, targetGB, stats, settings) {
if (!Number.isFinite(targetGB) || targetGB <= 0 || targetGB > Number(settings.autoExchangeMaxGBPerRun)) return { success: false, reason: '自动兑换目标须大于 0 GB 且不超过上限' };
const parsed = await BonusExchange._getTiers();
if (!parsed) return { success: false, reason: '未在 mybonus 页面找到兑换档位' };
return BonusExchange._exchangeRepeated(parsed, type, targetGB, parsed.pageBonus, { maxActualGB: settings.autoExchangeMaxGBPerRun });
},
/**
* 收集下拉档位及独立表单档位,保留各自提交参数和可验证价格。
*/
_parseExchangeTiers(doc) {
const tiers = [];
const submissionFor = (form, scope, fieldName) => {
const controls = Array.from(new Set([
...Array.from(form.elements || []),
...scope.querySelectorAll('input, button'),
])).filter(control => !control.disabled && (!control.form || control.form === form));
const submit = controls.find(control => control.type === 'submit');
return {
tierFieldName: fieldName,
action: form.getAttribute('action') || 'mybonus.php',
method: (form.getAttribute('method') || 'post').toLowerCase(),
submitName: submit ? (submit.name || '') : '',
submitValue: submit ? (submit.value || '') : '',
extraFields: controls.filter(control => control.type === 'hidden' && control.name && control.name !== fieldName)
.map(control => ({ name: control.name, value: control.value })),
};
};
for (const select of doc.querySelectorAll('select')) {
if (select.disabled) continue;
const form = select.form || select.closest('form');
if (!form) continue;
const scope = select.closest('tr') || form;
const submission = submissionFor(form, scope, select.name || 'option');
const contextNode = scope.cloneNode(true);
for (const control of contextNode.querySelectorAll('select,input,button')) control.remove();
const context = contextNode.textContent || '';
for (const option of select.options) {
if (option.disabled || option.value === '') continue;
const tier = this._parseTierText(option.textContent || '', option.value, context);
if (tier) tiers.push({ ...tier, submission });
}
}
for (const hidden of doc.querySelectorAll('input[type="hidden"][name="option"]')) {
if (hidden.disabled || hidden.value === '') continue;
const row = hidden.closest('tr');
// 无效 table/form HTML 会把控件移出 form;只恢复当前行,不借用其他档位表单。
const form = hidden.form || hidden.closest('form') || (row && row.querySelector('form'));
if (!form || form.querySelector('select')) continue;
const scope = row || form;
const cells = row ? Array.from(row.cells) : Array.from(form.querySelectorAll('td'));
const sourceCell = cells.find(cell => this._parseTierText(cell.textContent || '', hidden.value));
const source = (sourceCell || scope).querySelector('h1, h2, h3') || sourceCell || scope;
const tier = this._parseTierText(source.textContent || '', hidden.value, scope.textContent || '');
if (!tier) continue;
const description = this._parseTierText((sourceCell || scope).textContent || '', hidden.value);
if (!tier.priceKnown && description && description.priceKnown) {
tier.cost = description.cost;
tier.priceKnown = true;
}
// 编号位于简介前,价格只从简介后不含控件的纯数字列读取。
const priceCell = sourceCell && cells.slice(cells.indexOf(sourceCell) + 1).find(cell =>
!cell.querySelector('input, select, button') && /^\d[\d,]*(?:\.\d+)?$/.test((cell.textContent || '').trim()));
if (priceCell) {
tier.cost = Number(priceCell.textContent.trim().replace(/,/g, ''));
tier.priceKnown = Number.isFinite(tier.cost);
}
tier.submission = submissionFor(form, scope, hidden.name);
tiers.push(tier);
}
return tiers.length ? { tiers } : null;
},
/**
* 从文本解析单个兑换档位(GB 数 + 价格 + 类型)
* 容量后紧邻"上传"→ upload,紧邻"下载"→ download;其余(邀请/头衔/H&R)返回 null
* 文本例: "1 GB 上传量 - 100 魔力值"、"1.0 GB上传量 ..."、"10.0 GB 下载量 ..."
*/
_parseTierText(rawText, value, context = '') {
const text = String(rawText).replace(/\s+/g, ' ').trim();
// 容量 + 类型(紧邻判断,排除"GB 下载量"混入上传档等)
const m = text.match(/(\d+(?:\.\d+)?)\s*(GB|GiB|TB|TiB)\s*(上传|上[传傳]|download|下载|下[载載]|upload)/i);
if (!m) return null;
const type = /download|下载|下[载載]/i.test(m[3]) ? 'download' : 'upload';
let gb = parseFloat(m[1]);
if (/^T/i.test(m[2])) gb *= 1024;
const costM = text.match(/(\d[\d,]*(?:\.\d+)?)\s*(?:魔力值|魔力|积分|G值|G(?![a-z])|points?\b|Karma\b)/i);
const cost = costM ? Number(costM[1].replace(/,/g, '')) : null;
const directionText = `${text} ${context}`.replace(/\s+/g, ' ');
const subject = type === 'download' ? '(?:下载|下載|download(?:ed)?)' : '(?:上传|上傳|upload(?:ed)?)';
const modifier = '(?:\\s|你的|您的|你|您|的|your\\s+|the\\s+|total\\s+)*(?:\\d+(?:\\.\\d+)?\\s*(?:GiB|GB|TiB|TB)\\s*)?';
const auxiliary = '(?:量|amount|traffic|data)?\\s*(?:(?:则|則|会|會|将|將|可|以|被)|(?:will|is|are|be|can|may)\\s*)*';
const nextSubject = `(?!${modifier}(?:上传|上傳|下载|下載|upload|download))`;
const decreaseVerb = '(?:减少|減少|减免|減免|扣除|降低|reduce[sd]?(?![a-z])|decrease[sd]?(?![a-z])|subtract(?![a-z])|remove[sd]?(?![a-z]))';
const increaseVerb = '(?:增加|增长|增長|添加|increase[sd]?(?![a-z])|adds?(?![a-z]))';
const decrease = new RegExp(`${decreaseVerb}${modifier}${subject}|${subject}\\s*${auxiliary}${decreaseVerb}${nextSubject}`, 'i').test(directionText);
const increase = new RegExp(`${increaseVerb}${modifier}${subject}|${subject}\\s*${auxiliary}${increaseVerb}${nextSubject}`, 'i').test(directionText);
const effect = decrease && increase ? 'unknown' : decrease ? 'decrease' : (increase || type === 'upload' ? 'increase' : 'unknown');
return { value, gb, cost, priceKnown: cost !== null && Number.isFinite(cost), type, effect, text };
},
/**
* 提交指定兑换档位(POST/GET 均可)
* action 可能是 "?action=exchange"(GGPT)——基址必须取 mybonus.php 而非当前页
*/
async _submitTier(parsed, tier) {
const stopReason = BonusExchange._stopReason();
if (!BonusExchange.taskState.running || stopReason) return { success: false, stopped: true, reason: stopReason || '没有持有兑换任务锁' };
const submission = tier.submission || parsed;
const postData = new URLSearchParams();
for (const f of submission.extraFields || []) postData.append(f.name, f.value);
postData.set(submission.tierFieldName || 'option', tier.value);
if (submission.submitName) postData.set(submission.submitName, submission.submitValue);
const baseUrl = new URL('mybonus.php', window.location.href).href;
let submitUrl;
try {
const target = new URL(submission.action || 'mybonus.php', baseUrl);
if (target.origin !== window.location.origin || !/^https?:$/.test(target.protocol) || target.username || target.password) {
return { success: false, reason: '拒绝向站外地址提交兑换' };
}
submitUrl = target.href;
} catch (_) {
return { success: false, reason: '兑换地址无效' };
}
const method = (submission.method || 'post').toLowerCase();
if (method !== 'post' && method !== 'get') return { success: false, reason: '兑换提交方式无效' };
const requestUrl = method === 'get'
? `${submitUrl}${submitUrl.includes('?') ? '&' : '?'}${postData.toString()}`
: submitUrl;
Log.info(`兑换提交: ${method.toUpperCase()} ${requestUrl} 数据: ${postData.toString()}`);
return await new Promise((resolve) => {
GM_xmlhttpRequest({
method: method.toUpperCase(),
url: requestUrl,
data: method === 'get' ? undefined : postData.toString(),
headers: method === 'get' ? undefined : { 'Content-Type': 'application/x-www-form-urlencoded' },
onload: (r) => {
const responseText = r.responseText || '';
const text = responseText.substring(0, 1500);
// 说明文字也可能写着“限制 10 秒”或“魔力值不足”,不能据此判失败。
// 这里只识别明确的服务器拒绝;按量模式还会核对返回页的余额是否实际扣减。
const cooldown = /(?:冷却(?:中|尚未结束)|操作过于频繁|请.{0,16}(?:等待|稍后)|too\s*(?:soon|fast)|cooldown|wait\s+\d+)/i.test(text);
const explicitFailure = /(?:兑换|交换).{0,16}(?:失败|错误)|(?:失败|错误).{0,16}(?:兑换|交换)|exchange.{0,16}(?:failed|error)|(?:failed|error).{0,16}exchange|not\s+enough/i.test(text);
const failed = cooldown || explicitFailure;
resolve(r.status === 200 && !failed
? { success: true, responseText }
: {
success: false,
cooldown,
reason: cooldown
? '站点提示兑换冷却尚未结束'
: '站点拒绝了兑换请求(参数不匹配或魔力值不足)',
});
},
onerror: () => resolve({ success: false, reason: '网络请求失败' }),
ontimeout: () => resolve({ success: false, reason: '请求超时' }),
});
});
},
};
// ============================================================
// 13. UI — 浮动面板
// ============================================================
const Panel = {
panel: null,
isMinimized: false,
taskTimer: null,
/** 初始化面板 */
init() {
if (document.getElementById('np-panel')) return;
GM_addStyle(STYLES);
const panel = document.createElement('div');
panel.id = 'np-panel';
panel.className = 'np-top-right';
panel.innerHTML = `
`;
document.body.appendChild(panel);
this.panel = panel;
// 绑定事件
this._bindEvents();
// 使面板可拖动
this._makeDraggable();
// 加载设置
const settings = Storage.getSettings();
if (settings.panelPosition) {
this.setPosition(settings.panelPosition);
}
// 初始更新
this.update();
this.taskTimer = setInterval(() => this.updateTaskState(), 1000);
},
/** 绑定事件 */
_bindEvents() {
// 最小化/展开
const toggle = document.getElementById('np-panel-toggle');
if (toggle) {
toggle.addEventListener('click', () => {
this.isMinimized = !this.isMinimized;
const body = document.getElementById('np-panel-body');
if (body) {
body.style.display = this.isMinimized ? 'none' : 'block';
}
toggle.textContent = this.isMinimized ? '+' : '−';
toggle.title = this.isMinimized ? '展开面板' : '收起面板';
toggle.setAttribute('aria-label', toggle.title);
toggle.setAttribute('aria-expanded', String(!this.isMinimized));
});
}
// 刷新按钮
const refreshBtn = document.getElementById('np-btn-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
try {
await UserStats.collect({ forceDetailsRefresh: true });
this.update();
showNotification('状态已刷新', 'info', 2000);
} catch (error) {
showNotification(`刷新失败:${error.message || error}`, 'error');
} finally {
refreshBtn.disabled = false;
}
});
}
// 晋级兑换按钮(按 evaluate 算出的缺口计划执行,任意页面可用)
const exchangeBtn = document.getElementById('np-btn-exchange');
if (exchangeBtn) {
exchangeBtn.addEventListener('click', async () => {
const restoreFocus = document.activeElement === exchangeBtn;
exchangeBtn.disabled = true;
try {
const result = await BonusExchange.executeExchange({
confirmExchange: confirmPromotionExchange,
onProgress: () => this.updateTaskState(),
});
if (result.success) {
showNotification(`✅ 兑换完成: ${result.message || '成功'}`, 'success', 6000);
} else if (result.cancelled) {
showNotification('已取消兑换', 'info', 3000);
} else {
showNotification(`兑换未完成: ${result.reason || '未知错误'}`, 'warning', 6000);
}
} catch (e) {
Log.error('晋级兑换异常:', e);
showNotification(`兑换异常: ${e.message || e}`, 'error', 6000);
} finally {
exchangeBtn.disabled = false;
exchangeBtn.textContent = '晋级兑换';
this.update();
if (restoreFocus && document.activeElement === document.body) {
(exchangeBtn.disabled ? document.getElementById('np-btn-refresh') : exchangeBtn)?.focus();
}
}
});
}
// 手动兑换按钮(指定 GB + 类型,立即执行,不被评估拦截)
const manualBtn = document.getElementById('np-btn-manual-exchange');
if (manualBtn) {
manualBtn.addEventListener('click', async () => {
const gb = parseFloat(document.getElementById('np-manual-gb')?.value);
const mode = document.getElementById('np-manual-type')?.value || 'upload';
const type = mode.startsWith('download') ? 'download' : 'upload';
const effect = mode === 'download-decrease' ? 'decrease' : 'increase';
const typeName = type === 'download' ? (effect === 'decrease' ? '减少下载量' : '增加下载量') : '增加上传量';
if (!Number.isFinite(gb) || gb <= 0) {
showNotification('请输入有效的 GB 数', 'warning', 3000);
return;
}
const restoreFocus = document.activeElement === manualBtn;
manualBtn.disabled = true;
this.pendingAction = true;
try {
manualBtn.textContent = '计算费用';
const estimate = await BonusExchange.estimateManual(gb, type, { effect });
if (!estimate.success) {
showNotification(`无法估算费用: ${estimate.reason}`, 'error', 5000);
return;
}
const detail = type === 'download' ? estimate.download : estimate.upload;
const stats = Storage.getUserStats();
const actualBytes = detail.deltaGB * 1024 ** 3;
const downloaded = Number(stats?.downloaded) + (type === 'download' ? actualBytes : 0);
const uploaded = Number(stats?.uploaded) + (type === 'upload' ? actualBytes : 0);
const projectedRatio = downloaded > 0 ? uploaded / downloaded : (uploaded > 0 ? Infinity : null);
if (!await this._confirmExchange({
title: `确认${typeName}兑换`, estimate, currentBonus: estimate.pageBonus,
minBonusKeep: Storage.getSettings().autoExchangeMinBonusKeep,
projectedRatio, targetGB: gb,
})) return;
manualBtn.textContent = '执行中';
showNotification(`已开始后台兑换:共 ${detail.runs} 批,页面无需逐批点击`, 'info', 4000);
const result = await BonusExchange.executeManual(gb, type, {
parsed: estimate.parsed,
detail,
effect,
onProgress: () => this.updateTaskState(),
});
if (result.success) {
showNotification(`成功兑换 ${result.gb} GB ${typeName}(${result.runs} 次,消耗 ${formatInteger(result.cost)} 魔力值)`, 'success', 6000);
} else {
const partial = result.partial ? `,已完成 ${result.gb || 0} GB` : '';
showNotification(`兑换未完成${partial}: ${result.reason}`, 'error', 6000);
}
} catch (e) {
Log.error('手动兑换异常:', e);
showNotification(`兑换异常: ${e.message || e}`, 'error', 6000);
} finally {
this.pendingAction = false;
manualBtn.disabled = false;
manualBtn.textContent = '核对兑换';
this.update();
if (restoreFocus && document.activeElement === document.body) manualBtn.focus();
}
});
}
document.getElementById('np-btn-stop')?.addEventListener('click', () => {
BonusExchange.requestStop();
this.updateTaskState();
});
// FAQ 按钮
const faqBtn = document.getElementById('np-btn-faq');
if (faqBtn) {
faqBtn.addEventListener('click', () => {
const faqUrl = window.location.origin + '/faq.php';
if (getPath().endsWith('faq.php')) {
FAQParser.parse();
this.update();
showNotification('FAQ 规则已重新解析', 'info');
} else {
window.open(faqUrl, '_blank');
}
});
}
// 设置按钮
const settingsBtn = document.getElementById('np-btn-settings');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this._showSettings();
});
}
document.getElementById('np-btn-calculator')?.addEventListener('click', () => this._showCalculator());
},
updateTaskState() {
const element = document.getElementById('np-task-status');
const stop = document.getElementById('np-btn-stop');
const auto = document.getElementById('np-auto-state');
const task = BonusExchange.taskState || {};
const settings = Storage.getSettings();
const modes = { promotion: '晋级驱动', fixed: '定时定额', amount: '按量完成' };
if (auto) {
auto.textContent = settings.autoExchange
? `自动兑换已开启 · ${modes[settings.autoExchangeMode] || '晋级驱动'}`
+ (settings.autoExchangeMode === 'amount' ? ` · 已完成 ${settings.autoExchangeAmountCompletedGB || 0} GB,剩余 ${settings.autoExchangeAmountRemainingGB || 0} GB` : '')
: '自动兑换已关闭';
}
const labels = { idle: '自动任务等待中', preparing: '正在核对数据', confirming: '等待确认', cooldown: '等待站点冷却', submitting: '正在提交兑换', verifying: '正在核验结果', success: '兑换完成', partial: '部分完成', stopped: '已停止', error: '兑换未完成' };
if (element) {
element.hidden = !task.running && (!task.phase || (task.phase === 'idle' && !task.reason));
const remaining = Math.max(0, Math.ceil(((task.waitUntil || 0) - Date.now()) / 1000));
const content = `${escapeHTML(task.stopRequested && task.running ? '正在停止后续兑换' : labels[task.phase] || '任务处理中')}`
+ (task.phase === 'cooldown' ? `还需等待 ${remaining} 秒 ` : '')
+ (task.totalRuns ? `已确认 ${task.completedRuns || 0} / ${task.totalRuns} 次 · ${task.completedGB || 0} GB ` : '')
+ (task.cost ? `已消耗 ${formatInteger(task.cost)} G ` : '')
+ (task.reason || task.message ? `${escapeHTML(task.reason || task.message)} ` : '');
if (element.innerHTML !== content) element.innerHTML = content;
}
if (stop) {
stop.hidden = !task.running && !settings.autoExchange;
stop.disabled = !!task.stopRequested && !!task.running;
stop.textContent = task.stopRequested && task.running ? '已请求停止' : '停止后续兑换';
}
for (const id of ['np-btn-exchange', 'np-btn-manual-exchange']) {
const button = document.getElementById(id);
if (button) {
const unavailable = id === 'np-btn-exchange' && (!this.currentEvaluation?.needsExchange || !!this.promotionEstimateUnavailable);
button.disabled = !!task.running || !!this.confirmationOverlay || !!this.pendingAction || unavailable;
if (id === 'np-btn-exchange') button.title = unavailable ? (this.promotionEstimateUnavailable || '当前没有可执行的晋级兑换,请核对条件清单') : '核对实际数量和费用后执行';
}
}
},
_confirmExchange({ title, estimate, evaluation, currentBonus, minBonusKeep = 0, projectedRatio, targetGB }) {
if (document.querySelector('.np-overlay')) return Promise.resolve(false);
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'np-overlay';
this.confirmationOverlay = overlay;
const dialog = document.createElement('div');
dialog.className = 'np-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'np-confirm-title');
const knownBalance = Number.isFinite(currentBonus);
const reserve = Math.max(0, Number(minBonusKeep) || 0);
const cost = Number(estimate.totalCost) || 0;
const available = knownBalance ? Math.max(0, currentBonus - reserve) : null;
const shortage = knownBalance ? Math.max(0, cost - available) : null;
const reg = evaluation?.details?.nextPromotion?.regWeeks;
const ratioText = projectedRatio === Infinity ? '∞' : (Number.isFinite(projectedRatio) ? projectedRatio.toFixed(3) : '未知');
const details = formatExchangeEstimateParts(estimate).map(part => `${escapeHTML(part)} `).join('');
const combinations = [estimate.upload, estimate.download].filter(detail => detail?.runs).map(detail =>
`${escapeHTML(detail.steps.map(step => `${step.tier.gb} GB × ${step.count}(${step.tier.cost} G/次)`).join(' + '))} `
).join('');
const timeWarning = reg && !reg.met
? `${reg.known ? `注册时长还需 ${escapeHTML(reg.remainingText)},预计 ${escapeHTML(reg.expectedDate)} 达标。` : '注册时间未知。'}本次仅提前准备流量,兑换后不会立即晋级。 ` : '';
const waitSeconds = Math.ceil((Math.max(0, estimate.totalRuns - 1) * (CONFIG.EXCHANGE_COOLDOWN + 250) + BonusExchange.getCooldownRemaining()) / 1000);
dialog.innerHTML = `${escapeHTML(title || '确认兑换')}`
+ (targetGB ? `目标 ${escapeHTML(targetGB)} GB · 实际兑换 ${estimate.totalActualGB} GB ` : '')
+ details
+ combinations
+ (estimate.optimizationScope === 'increase-only' ? '增加流量方案最低成本,不包含下载减免方案。 ' : '')
+ (estimate.savings > 0 ? `比固定 100 GB 档少花 ${formatInteger(estimate.savings)} G ` : '')
+ `预计总消耗 ${formatInteger(cost)} G `
+ `当前余额 ${knownBalance ? formatInteger(currentBonus) + ' G' : '未知'} · 保留 ${formatInteger(reserve)} G `
+ `预计剩余 ${knownBalance && currentBonus >= cost ? formatInteger(currentBonus - cost) + ' G' : '不足以完成全部任务'} `
+ `预计兑换后分享率(全部完成) ${ratioText} `
+ `冷却等待约 ${waitSeconds} 秒,不含网络提交与核验。 `
+ timeWarning
+ (shortage > 0 ? `可用余额还差 ${formatInteger(shortage)} G。继续后仅完成预算内的部分,后续兑换会停止。 ` : '')
+ ``;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
this.updateTaskState();
return new Promise(resolve => {
const close = confirmed => {
document.removeEventListener('keydown', onKey);
overlay.remove();
this.confirmationOverlay = null;
this.updateTaskState();
if (previousFocus?.isConnected) previousFocus.focus();
resolve(confirmed);
};
const onKey = event => {
if (event.key === 'Escape') {
event.preventDefault();
close(false);
} else if (event.key === 'Tab') {
const buttons = Array.from(dialog.querySelectorAll('button:not(:disabled)'));
const first = buttons[0], last = buttons[buttons.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
};
dialog.querySelector('#np-confirm-cancel').addEventListener('click', () => close(false));
dialog.querySelector('#np-confirm-submit').addEventListener('click', () => close(true));
overlay.addEventListener('click', event => { if (event.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
dialog.querySelector('#np-confirm-cancel').focus();
});
},
/** 设置面板位置 */
setPosition(pos) {
const positions = {
'top-right': 'np-top-right',
'top-left': 'np-top-left',
'bottom-right': 'np-bottom-right',
'bottom-left': 'np-bottom-left',
};
const className = positions[pos] || 'np-top-right';
this.panel.className = className;
for (const property of ['left', 'top', 'right', 'bottom']) this.panel.style.removeProperty(property);
},
/** 实现拖动 */
_makeDraggable() {
const header = document.getElementById('np-panel-header');
if (!header || !this.panel) return;
let isDragging = false;
let startX, startY, origX, origY;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
isDragging = true;
const rect = this.panel.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
origX = rect.left;
origY = rect.top;
this.panel.style.left = origX + 'px';
this.panel.style.top = origY + 'px';
this.panel.style.right = 'auto';
this.panel.style.bottom = 'auto';
this.panel.classList.remove('np-top-right', 'np-top-left', 'np-bottom-right', 'np-bottom-left');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const onMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
this.panel.style.left = Math.max(0, Math.min(window.innerWidth - this.panel.offsetWidth, origX + dx)) + 'px';
this.panel.style.top = Math.max(0, Math.min(window.innerHeight - header.offsetHeight, origY + dy)) + 'px';
};
const onUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
},
/** 更新面板内容 */
update() {
if (!this.panel) return;
const stats = Storage.getUserStats();
const rules = Storage.getRules();
// 每次刷新都重新评估,确保注册时长倒计时不会停留在旧缓存。
const evaluation = RuleEngine.evaluate();
this.currentEvaluation = evaluation;
// 状态内容
const statusContent = document.getElementById('np-status-content');
if (statusContent) {
if (stats) {
const ratioDisplay = stats.ratio === Infinity ? '∞' : (Number.isFinite(stats.ratio) ? stats.ratio.toFixed(3) : '未知');
const demotion = evaluation.details?.demotion;
const ratioClass = demotion ? (demotion.status === 'danger' ? 'danger' : demotion.status === 'warning' ? 'warning' : demotion.status === 'unknown' ? '' : 'good') : '';
const ratioStatus = demotion ? (demotion.status === 'exempt' ? '当前等级保级' : demotion.status === 'unknown' ? demotion.reason :
`${demotion.status === 'danger' ? '有降级风险' : demotion.status === 'warning' ? '接近降级线' : '高于降级线'}(${demotion.threshold})`) : '降级线未评估';
statusContent.innerHTML = `
等级
${escapeHTML(formatClassDisplayName(stats.className, stats.classOrder))}
上传
↑ ${formatBytes(stats.uploaded)}
下载
↓ ${formatBytes(stats.downloaded)}
分享率
${ratioDisplay} ${ratioStatus}
魔力值
${Number.isFinite(stats.bonus) ? stats.bonus.toLocaleString('zh-CN', { maximumFractionDigits: 1 }) : '未知'}
注册
${parseDateFlexible(stats.joinTime) ? `${stats.registeredDays || 0} 天` : '未知'}
`;
} else {
statusContent.innerHTML = '无法获取用户数据 请刷新页面重试 ';
}
}
// 规则/晋级内容
const rulesContent = document.getElementById('np-rules-content');
if (rulesContent) {
const promotion = evaluation?.details?.nextPromotion;
if (evaluation.nextClass && promotion) {
const names = { regWeeks: '注册时长', downloadReq: '下载量', uploadReq: '上传量', ratioReq: '分享率', bonusReq: '做种积分' };
const conditions = Object.entries(promotion).map(([key, condition]) => {
let detail;
if (key === 'regWeeks') {
detail = condition.known
? `${condition.current} / ${condition.required} 周 · ${condition.met ? '已达标' : `还需 ${condition.remainingText},预计 ${condition.expectedDate} 达标`}`
: '注册时间未知,请刷新账号数据';
} else if (key === 'downloadReq' || key === 'uploadReq') {
detail = `${condition.current.toFixed(1)} / ${condition.required} GB` + (!condition.met ? ' · 可通过兑换补齐' : '');
} else if (key === 'bonusReq') {
detail = `${formatInteger(condition.current)} / ${formatInteger(condition.required)}` + (!condition.met ? ' · 需继续做种,流量兑换不能补齐' : '');
} else {
detail = `${stats?.ratio === Infinity ? '∞' : Number(condition.current).toFixed(3)} / ${condition.required}` + (!condition.met ? ' · 可补上传量' : '');
}
return `${names[key] || escapeHTML(key)}${condition.met ? '已达标' : '未达标'}${escapeHTML(detail)} `;
}).join('');
rulesContent.innerHTML = `${escapeHTML(formatClassDisplayName(evaluation.nextClass.className, evaluation.nextClass.order))}` + conditions
+ (evaluation.promotionStatus === 'met' ? '条件已达标,等待站点更新等级。 ' : '');
} else {
rulesContent.textContent = evaluation.message || evaluation.messages?.[0] || (rules.length ? '没有下一等级可评估' : '尚未读取等级规则,请打开 FAQ。');
}
}
this._updatePromotionEstimate(evaluation);
this.updateTaskState();
},
_updatePromotionEstimate(evaluation) {
const element = document.getElementById('np-promotion-estimate');
const plan = evaluation?.exchangePlan;
if (!element || !plan || (!plan.uploadGB && !plan.downloadGB)) {
if (element) element.style.display = 'none';
this.promotionEstimateUnavailable = '';
return;
}
const knownBonus = Math.round(Number(Storage.getUserStats()?.bonus) || 0);
const planKey = `${JSON.stringify(plan)}:${knownBonus}:${Storage.getSettings().autoExchangeMinBonusKeep || 0}`;
if (element.dataset.planKey === planKey && element.dataset.loaded === 'true' && Date.now() - Number(element.dataset.loadedAt) < 30000) return;
element.dataset.planKey = planKey;
element.dataset.loaded = 'false';
element.style.display = 'block';
element.textContent = '预计魔力值消耗:正在读取本站价格与最优组合…';
BonusExchange.estimatePromotionPlan(evaluation).then(estimate => {
if (element.dataset.planKey !== planKey) return;
element.dataset.loaded = 'true';
element.dataset.loadedAt = String(Date.now());
if (!estimate.success) {
element.textContent = `预计魔力值消耗:暂时无法计算(${estimate.reason})`;
this.promotionEstimateUnavailable = estimate.reason;
this.updateTaskState();
return;
}
this.promotionEstimateUnavailable = '';
this.updateTaskState();
const detail = formatExchangeEstimateParts(estimate).join(';');
const keep = Storage.getSettings().autoExchangeMinBonusKeep || 0;
const shortage = Number.isFinite(estimate.pageBonus)
? Math.max(0, estimate.totalCost - Math.max(0, estimate.pageBonus - keep)) : null;
const stats = Storage.getUserStats();
const downloaded = (stats?.downloaded || 0) / (1024 ** 3) + estimate.download.deltaGB;
const projectedRatio = downloaded > 0 ? ((stats?.uploaded || 0) / (1024 ** 3) + estimate.upload.deltaGB) / downloaded : Infinity;
element.textContent = `预计消耗 ${formatInteger(estimate.totalCost)} G · ${detail}`
+ ` · 预计分享率 ${projectedRatio === Infinity ? '∞' : projectedRatio.toFixed(3)}`
+ ' · 增加流量最低成本,不含下载减免'
+ (shortage > 0 ? ` · 保留余额后还差 ${formatInteger(shortage)} G` : '')
+ (shortage === null ? ' · 余额未知,请刷新' : '');
}).catch(error => {
if (element.dataset.planKey !== planKey) return;
element.dataset.loaded = 'true';
element.textContent = `预计魔力值消耗:计算失败(${error?.message || error})`;
this.promotionEstimateUnavailable = '计算失败,请刷新重试';
this.updateTaskState();
});
},
/** 获取缓存的评估结果 */
_getCachedEvaluation() {
const stats = Storage.getUserStats();
if (!stats) return null;
// 如果内存中有上次评估结果,使用它
return stats.lastEvaluation || null;
},
async _showCalculator() {
if (document.querySelector('.np-overlay')) return;
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'np-overlay';
const dialog = document.createElement('div');
dialog.className = 'np-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'np-calc-title');
dialog.id = 'np-calculator';
dialog.innerHTML = `魔力值计算
正在读取本站价格…
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('#np-calc-type').value = document.getElementById('np-manual-type')?.value || 'upload';
const close = () => {
document.removeEventListener('keydown', onKey);
overlay.remove();
if (previousFocus?.isConnected) previousFocus.focus();
};
const onKey = event => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'Tab') {
const controls = Array.from(dialog.querySelectorAll('input,select,button')).filter(el => !el.disabled && el.getClientRects().length);
if (event.shiftKey && document.activeElement === controls[0]) { event.preventDefault(); controls.at(-1).focus(); }
else if (!event.shiftKey && document.activeElement === controls.at(-1)) { event.preventDefault(); controls[0].focus(); }
}
};
document.addEventListener('keydown', onKey);
dialog.querySelector('#np-calc-close').addEventListener('click', close);
overlay.addEventListener('click', event => { if (event.target === overlay) close(); });
dialog.querySelector('#np-calc-gb').focus();
const result = dialog.querySelector('#np-calc-result');
const use = dialog.querySelector('#np-calc-use');
let calculation = null;
const invalidate = () => { calculation = null; use.disabled = true; result.textContent = '目标已变化,请重新计算。'; };
dialog.querySelector('#np-calc-gb').addEventListener('input', invalidate);
dialog.querySelector('#np-calc-type').addEventListener('change', invalidate);
use.addEventListener('click', () => {
if (!calculation) return;
document.getElementById('np-manual-gb').value = calculation.target;
document.getElementById('np-manual-type').value = calculation.mode;
close();
document.getElementById('np-btn-manual-exchange')?.focus();
});
const parsed = await BonusExchange._getTiers();
if (!overlay.isConnected) return;
if (!parsed?.tiers?.length) { result.textContent = '无法读取本站价格,请刷新站点页面后重试。'; return; }
const number = value => Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 3 });
dialog.querySelector('#np-calc-tiers').innerHTML = '本站兑换价格| 兑换档位 | 价格(G) | G / GB | '
+ parsed.tiers.map(tier => `| ${tier.effect === 'decrease' ? '减少' : tier.effect === 'increase' ? '增加' : '方向未知:'}${tier.type === 'upload' ? '上传' : '下载'} ${number(tier.gb)} GB | ${tier.priceKnown ? number(tier.cost) : '价格未知'} | ${tier.priceKnown ? number(tier.cost / tier.gb) : '—'} | `).join('') + ' ';
result.textContent = `当前余额 ${Number.isFinite(parsed.pageBonus) ? number(parsed.pageBonus) + ' G' : '未知'} · 保留 ${number(Storage.getSettings().autoExchangeMinBonusKeep || 0)} G`;
const run = dialog.querySelector('#np-calc-run');
run.disabled = false;
run.addEventListener('click', () => {
const target = Number(dialog.querySelector('#np-calc-gb').value);
const mode = dialog.querySelector('#np-calc-type').value;
const type = mode.startsWith('download') ? 'download' : 'upload';
const effect = mode === 'download-decrease' ? 'decrease' : 'increase';
const stats = Storage.getUserStats();
const cap = effect === 'decrease' ? (stats ? stats.downloaded / (1024 ** 3) : 0) : Infinity;
const detail = BonusExchange._estimateRepeatedCost(parsed, type, target, { effect, maxActualGB: cap });
calculation = null;
use.disabled = true;
if (!(target > 0) || !Number.isFinite(target)) { result.textContent = '请输入大于 0 的目标量。'; return; }
if (!detail.success) { result.textContent = detail.reason; return; }
const reserve = Math.max(0, Number(Storage.getSettings().autoExchangeMinBonusKeep) || 0);
const available = Number.isFinite(parsed.pageBonus) ? Math.max(0, parsed.pageBonus - reserve) : null;
const ratio = stats ? (stats.uploaded + (type === 'upload' ? detail.deltaGB * (1024 ** 3) : 0)) / (stats.downloaded + (type === 'download' ? detail.deltaGB * (1024 ** 3) : 0)) : null;
result.innerHTML = `最低总成本 ${number(detail.cost)} G · 实际 ${number(detail.actualGB)} GB · ${detail.runs} 次 `
+ `${escapeHTML(detail.steps.map(step => `${step.tier.gb} GB × ${step.count}(${step.tier.cost} G/次)`).join(' + '))} `
+ (detail.savings > 0 ? `比固定 100 GB 档少花 ${number(detail.savings)} G ` : '')
+ `${available === null ? '余额未知,执行前需重新核对。' : available >= detail.cost ? `保留余额后可支付,预计剩余 ${number(parsed.pageBonus - detail.cost)} G` : `保留余额后还差 ${number(detail.cost - available)} G`} `
+ `预计分享率 ${ratio === Infinity ? '∞' : Number.isFinite(ratio) ? ratio.toFixed(3) : '未知'} `;
calculation = { target, mode };
use.disabled = false;
});
},
/** 显示设置对话框 */
_showSettings() {
if (document.querySelector('.np-overlay')) return;
const settings = Storage.getSettings();
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'np-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 100001;
display: flex; align-items: center; justify-content: center;
`;
const dialog = document.createElement('div');
dialog.className = 'np-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'np-settings-title');
dialog.innerHTML = `
本站设置
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 绑定按钮事件
const close = () => {
document.removeEventListener('keydown', onKey);
overlay.remove();
if (previousFocus?.isConnected) previousFocus.focus();
};
const onKey = event => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'Tab') {
const controls = Array.from(dialog.querySelectorAll('button, input, select')).filter(el => !el.disabled && el.getClientRects().length);
const first = controls[0], last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
};
document.addEventListener('keydown', onKey);
document.getElementById('np-settings-cancel')?.addEventListener('click', close);
document.getElementById('np-settings-stop')?.addEventListener('click', () => {
BonusExchange.requestStop();
const error = dialog.querySelector('#np-settings-error');
error.hidden = false;
error.textContent = '已请求停止。当前已发出的请求仍会核验,核验结束后再保存设置。';
});
// 自动兑换开关切换时显示/隐藏详细配置
document.getElementById('np-set-auto')?.addEventListener('change', (e) => {
const cfg = document.getElementById('np-auto-config');
if (cfg) cfg.style.display = e.target.checked ? 'block' : 'none';
});
const modeSelect = document.getElementById('np-set-automode');
const legacyConfig = document.getElementById('np-auto-legacy-config');
const amountConfig = document.getElementById('np-auto-amount-config');
let amountTiersLoaded = false;
let amountTiersLoading = false;
const ensureAmountTiers = async () => {
if (amountTiersLoaded || amountTiersLoading) return;
amountTiersLoading = true;
await this._loadAmountTierOptions(dialog, settings.autoExchangeAmountTierKey);
amountTiersLoaded = !dialog.querySelector('#np-set-amounttier')?.disabled;
amountTiersLoading = false;
};
const syncModeConfig = () => {
const isAmount = modeSelect?.value === 'amount';
if (legacyConfig) legacyConfig.style.display = isAmount ? 'none' : 'block';
if (amountConfig) amountConfig.style.display = isAmount ? 'block' : 'none';
const fixed = modeSelect?.value === 'fixed';
const fixedGB = dialog.querySelector('#np-set-fixedgb')?.parentElement;
const fixedType = dialog.querySelector('#np-set-fixedtype')?.parentElement;
if (fixedGB) fixedGB.style.display = fixed ? 'block' : 'none';
if (fixedType) fixedType.style.display = fixed ? 'block' : 'none';
if (isAmount) ensureAmountTiers();
};
modeSelect?.addEventListener('change', syncModeConfig);
const cooldownMode = document.getElementById('np-set-cooldownmode');
const cooldownSeconds = document.getElementById('np-set-cooldownsecs');
const syncCooldownInput = () => {
if (cooldownSeconds) cooldownSeconds.disabled = cooldownMode?.value !== 'manual';
};
cooldownMode?.addEventListener('change', syncCooldownInput);
document.getElementById('np-settings-save')?.addEventListener('click', async () => {
const save = () => {
const num = (id, fallback) => {
const v = parseFloat(document.getElementById(id)?.value);
return Number.isFinite(v) ? v : fallback;
};
const autoExchange = document.getElementById('np-set-auto').checked;
const autoExchangeMode = modeSelect?.value || 'promotion';
const amountGB = Math.max(0, num('np-set-amountgb', 0));
const amountTierSelect = document.getElementById('np-set-amounttier');
const amountTierKey = amountTierSelect?.value || '';
const amountTierGB = Number(amountTierSelect?.selectedOptions?.[0]?.dataset.gb) || 0;
const error = dialog.querySelector('#np-settings-error');
if (autoExchange && !Storage.getAccount()) {
error.hidden = false;
error.textContent = '未识别登录账号,请关闭设置并刷新账号数据后再开启自动兑换。';
return;
}
const maxGB = num('np-set-maxgb', 100);
const fixedGB = num('np-set-fixedgb', 100);
if (autoExchange && autoExchangeMode !== 'amount' && (maxGB <= 0 || (autoExchangeMode === 'fixed' && (fixedGB <= 0 || fixedGB > maxGB)))) {
error.hidden = false;
error.textContent = '定额和上限须大于 0 GB,且定额不能超过上限。实际档位组合也不能超过上限。';
return;
}
if (autoExchange && autoExchangeMode === 'amount') {
if (amountGB < 100) {
error.hidden = false;
error.textContent = '目标兑换量至少为 100 GB。';
return;
}
if (!amountTierKey || Math.abs(amountTierGB - 100) >= 0.001) {
error.hidden = false;
error.textContent = '请等待本站 100 GB 档位读取完成,并选择上传量或下载量。';
return;
}
}
const latestSettings = Storage.getSettings();
const priorRemaining = Math.max(0, Number(latestSettings.autoExchangeAmountRemainingGB) || 0);
const resetAmountTask = autoExchangeMode === 'amount' && (
settings.autoExchangeMode !== 'amount'
|| Number(settings.autoExchangeAmountGB) !== amountGB
|| settings.autoExchangeAmountTierKey !== amountTierKey
|| (!settings.autoExchange && priorRemaining <= 0)
);
const newSettings = {
...latestSettings,
autoExchange,
autoExchangeAccountId: autoExchange ? Storage.getAccount() : latestSettings.autoExchangeAccountId,
autoExchangeTaskVersion: (Number(latestSettings.autoExchangeTaskVersion) || 0) + 1,
autoExchangeMode,
autoExchangeIntervalMin: Math.max(10, num('np-set-interval', 60)),
autoExchangeFixedGB: fixedGB,
autoExchangeFixedType: document.getElementById('np-set-fixedtype')?.value === 'download' ? 'download' : 'upload',
autoExchangeAmountGB: amountGB,
autoExchangeAmountTierKey: amountTierKey,
autoExchangeAmountRemainingGB: autoExchangeMode === 'amount'
? (resetAmountTask ? amountGB : priorRemaining)
: priorRemaining,
autoExchangeAmountCompletedGB: autoExchangeMode === 'amount'
? (resetAmountTask ? 0 : Math.max(0, Number(latestSettings.autoExchangeAmountCompletedGB) || 0))
: Math.max(0, Number(latestSettings.autoExchangeAmountCompletedGB) || 0),
autoExchangeCooldownMode: cooldownMode?.value === 'manual' ? 'manual' : 'auto',
autoExchangeCooldownSeconds: Math.max(1, num('np-set-cooldownsecs', 10)),
autoExchangeMaxGBPerRun: maxGB,
autoExchangeMinBonusKeep: Math.max(0, num('np-set-keepbonus', 0)),
demotionWarning: document.getElementById('np-set-demotion').checked,
panelPosition: document.getElementById('np-set-position').value,
};
if (newSettings.autoExchange && !settings.autoExchange) {
showNotification('自动兑换已开启:将在后台检查兑换条件', 'info', 5000);
}
Storage.setSettings(newSettings);
AutoExchange.scheduleSoon();
if (newSettings.panelPosition) {
this.setPosition(newSettings.panelPosition);
}
close();
this.update();
showNotification('设置已保存', 'success');
};
if (globalThis.navigator?.locks?.request) {
try {
await navigator.locks.request(`np-exchange:${window.location.origin}`, { ifAvailable: true }, lock => {
if (lock) save();
else {
const error = dialog.querySelector('#np-settings-error');
error.hidden = false;
error.textContent = '本站有兑换任务正在执行。请先停止并等待核验结束,再保存设置。';
}
});
} catch (error) {
const element = dialog.querySelector('#np-settings-error');
element.hidden = false;
element.textContent = `保存失败:${error.message || error}`;
}
} else save();
});
document.getElementById('np-btn-reparse')?.addEventListener('click', () => {
close();
if (getPath().endsWith('faq.php')) {
FAQParser.parse();
this.update();
showNotification('FAQ 已重新解析', 'info');
} else {
window.open(window.location.origin + '/faq.php', '_blank');
}
});
document.getElementById('np-btn-reset')?.addEventListener('click', () => {
if (confirm('清除本站规则和当前账号缓存?自动兑换将停止,设置保留。')) {
BonusExchange.requestStop();
Storage.delete('RULES');
Storage.delete('USER_STATS');
Storage.delete('PARSED_FAQ');
this.update();
close();
showNotification('数据已重置', 'info');
}
});
// 点击遮罩关闭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
syncModeConfig();
dialog.querySelector('#np-set-auto').focus();
},
/** 读取站点档位仅使用 GET;选项值不由页面 HTML 直接拼接,避免注入。 */
async _loadAmountTierOptions(dialog, selectedKey) {
const select = dialog.querySelector('#np-set-amounttier');
if (!select) return;
try {
const parsed = await BonusExchange._getTiers();
const tiers = parsed?.tiers?.filter(t =>
(t.type === 'upload' || t.type === 'download')
&& (t.type !== 'upload' || t.effect === 'increase')
&& t.priceKnown && t.effect !== 'unknown'
&& Math.abs(t.gb - 100) < 0.001
) || [];
select.replaceChildren();
if (!tiers.length) throw new Error('未找到 100 GB 上传量或下载量兑换档位');
for (const tier of tiers) {
const option = document.createElement('option');
option.value = AutoExchange._tierKey(tier);
option.dataset.gb = String(tier.gb);
option.dataset.cost = String(tier.cost);
option.textContent = `${tier.type === 'download' ? (tier.effect === 'decrease' ? '减少下载' : '增加下载') : '增加上传'} ${tier.gb} GB(${tier.cost} 魔力值)`;
option.selected = option.value === selectedKey;
select.appendChild(option);
}
if (!select.value) select.selectedIndex = 0;
select.disabled = false;
const refreshPreview = () => this._updateAmountPlanPreview(dialog);
dialog.querySelector('#np-set-amountgb')?.addEventListener('input', refreshPreview);
select.addEventListener('change', refreshPreview);
refreshPreview();
} catch (e) {
select.replaceChildren();
const option = document.createElement('option');
option.value = '';
option.textContent = '读取本站档位失败';
select.appendChild(option);
select.disabled = true;
Log.warn('读取按量兑换档位失败:', e);
}
},
_updateAmountPlanPreview(dialog) {
const preview = dialog.querySelector('#np-set-amount-preview');
const amount = Number(dialog.querySelector('#np-set-amountgb')?.value) || 0;
const option = dialog.querySelector('#np-set-amounttier')?.selectedOptions?.[0];
const tierGB = Number(option?.dataset.gb) || 0;
const cost = Number(option?.dataset.cost) || 0;
if (!preview) return;
if (!amount || !tierGB) {
preview.textContent = '';
return;
}
const count = Math.ceil(amount / tierGB);
preview.textContent = `指定档位:${count} 次,实际兑换 ${count * tierGB} GB,约消耗 ${count * cost} 魔力值(不进行组合优化)`;
},
};
// ============================================================
// 14. MAIN ENTRY POINT
// ============================================================
async function main() {
if (window.top !== window.self) return;
Log.info('NexusPHP PT 自动晋级助手 启动');
// 检查是否为 NexusPHP 站点
if (!isNexusPHP()) {
Log.debug('非 NexusPHP 站点,跳过');
return;
}
// 已知站点在任何页面都先加载权威等级 metadata,不要求用户先访问 FAQ。
const canonicalRules = ensureCanonicalSiteRules();
if (canonicalRules.length > 0) {
Log.info(`已加载 ${canonicalRules.length} 个站点等级规则`);
}
const pageType = getPageType();
Log.info(`页面类型: ${pageType}`);
// 注册菜单命令(样式由 Panel.init() 注入)
GM_registerMenuCommand('📊 显示/隐藏面板', () => {
const panel = document.getElementById('np-panel');
if (panel) {
panel.classList.toggle('np-hidden');
} else {
Panel.init();
}
});
GM_registerMenuCommand('📖 解析 FAQ 规则', () => {
if (getCanonicalSiteRules(window.location.hostname).length > 0 || pageType === 'faq') {
FAQParser.parse();
Panel.update();
showNotification(
getCanonicalSiteRules(window.location.hostname).length > 0 ? '站点等级规则已加载' : 'FAQ 规则已解析',
'info'
);
} else {
showNotification('请先访问 FAQ 页面 (faq.php)', 'warning', 3000);
}
});
GM_registerMenuCommand('💱 执行晋级兑换', async () => {
const result = await BonusExchange.executeExchange({
confirmExchange: confirmPromotionExchange,
});
if (result.success) {
showNotification(`✅ 兑换完成: ${result.message || '成功'}`, 'success', 6000);
} else if (result.cancelled) {
showNotification('已取消兑换', 'info', 3000);
} else {
showNotification(`兑换未执行: ${result.reason}`, 'warning', 5000);
}
Panel.update();
});
// 根据页面类型执行不同操作
// 注意:每个 case 使用 {} 包裹以避免 const 声明冲突
switch (pageType) {
case 'faq': {
Log.info('FAQ 页面 - 解析晋级/降级规则');
await sleep(500);
const rules = FAQParser.parse();
if (rules.length > 0) {
showNotification(`已解析 ${rules.length} 个等级规则`, 'success', 3000);
} else {
showNotification('未找到晋级规则,请确认 FAQ 中包含等级说明', 'warning', 4000);
}
await UserStats.collect();
Panel.init();
break;
}
case 'index':
case 'userdetails': {
Log.info('用户页面 - 收集状态并评估');
await sleep(300);
await UserStats.collect();
const hasRules = Storage.getRules();
if (hasRules && hasRules.length > 0) {
const evalResult = RuleEngine.evaluate();
if (evalResult.messages.length > 0) {
const importantMsg = evalResult.messages.find(m => m.includes('⚠️') || m.includes('✅'));
if (importantMsg) {
const notifType = importantMsg.includes('⚠️') ? 'warning' : 'success';
showNotification(importantMsg, notifType, 5000);
}
}
}
Panel.init();
break;
}
case 'bonus': {
Log.info('魔力值页面 - 准备兑换');
// 站点原生兑换成功会导航到 ?do=upload / ?do=download。
// 将这次真实操作同步到脚本的 10 秒冷却,避免页面刚加载就重复提交。
const pageText = document.body?.innerText || document.body?.textContent || '';
const nativeExchangeSuccess = /祝贺你[^。!\n]*(?:成功增加|成功兑换)[^。!\n]*(?:上传值|下载值|上传量|下载量)/.test(pageText);
await sleep(300);
await UserStats.collect();
if (nativeExchangeSuccess) Storage.setLastExchange(Date.now());
Panel.init();
const evalResult = RuleEngine.evaluate();
if (evalResult.needsExchange && evalResult.exchangePlan) {
const p = evalResult.exchangePlan;
const parts = [];
if (p.downloadGB > 0) parts.push(`下载量 ${p.downloadGB.toFixed(2)} GB`);
if (p.uploadGB > 0) parts.push(`上传量 ${p.uploadGB.toFixed(2)} GB`);
showNotification(`检测到晋级缺口: ${parts.join(' + ')},点击面板"🎯 晋级兑换"执行`, 'info', 6000);
}
break;
}
default: {
await UserStats.collect();
Panel.init();
break;
}
}
// 如果页面有面板,定期刷新
if (document.getElementById('np-panel')) {
setInterval(async () => {
await UserStats.collect();
Panel.update();
}, CONFIG.STATS_REFRESH_INTERVAL);
}
// 启动自动兑换调度器(开关在设置面板,默认关闭)
AutoExchange.start();
}
// ============================================================
// 15. STARTUP
// ============================================================
// 等待 DOM 就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();
|