// ==UserScript==
// @name NexusPHP PT 自动晋级助手
// @namespace https://github.com/493Arceus/nexusphp-auto-promote
// @version 1.5.13
// @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, // 自动兑换总开关(默认关,需手动开启)
autoExchangeMode: 'promotion',// 'promotion' 晋级驱动 | 'fixed' 定时定额 | 'amount' 按量完成
autoExchangeIntervalMin: 60, // 兑换间隔(分钟)
autoExchangeFixedGB: 10, // 定时定额模式:每次兑换 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',
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: /分享率(?:低于|小于或等于|小于等于|小于|≤|<=|<)?\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 Math.round(Number(value) || 0).toLocaleString('zh-CN');
}
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.actualGB} GB / ${estimate.download.runs} 次 / ${formatInteger(estimate.download.cost)} G`);
}
return parts;
}
function confirmTimeUnmetExchange({ evaluation, estimate, currentBonus }) {
const reg = evaluation?.details?.nextPromotion?.regWeeks;
const timeText = reg?.known
? `注册时间还需 ${reg.remainingText}(预计 ${reg.expectedDate} 达标)`
: '注册时间未知,无法计算还需多久';
const remainingBonus = Number(currentBonus) - Number(estimate?.totalCost || 0);
const balanceLines = remainingBonus >= 0
? [`预计兑换后剩余:${formatInteger(remainingBonus)} G`]
: [
`当前魔力值不足,完成全部计划还差:${formatInteger(-remainingBonus)} G`,
'确认后会按“先上传、后下载”执行,并在余额不足时停止。',
];
return confirm([
`${timeText}。`,
'现在兑换流量后仍不会立即晋级。',
'',
...formatExchangeEstimateParts(estimate),
`预计总消耗:${formatInteger(estimate?.totalCost)} G`,
`当前魔力值:${formatInteger(currentBonus)} G`,
...balanceLines,
'',
'是否仍要继续兑换?',
].join('\n'));
}
/** 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 = {
get(key, defaultValue) {
try {
const val = GM_getValue(CONFIG.STORAGE_KEYS[key] || key);
return val !== undefined && val !== null ? JSON.parse(val) : defaultValue;
} catch (e) {
return defaultValue;
}
},
set(key, value) {
try {
GM_setValue(CONFIG.STORAGE_KEYS[key] || key, JSON.stringify(value));
} catch (e) {
console.error('[NP Auto-Promote] Storage.set error:', e);
}
},
delete(key) {
try {
GM_deleteValue(CONFIG.STORAGE_KEYS[key] || key);
} catch (e) {
console.error('[NP Auto-Promote] Storage.delete error:', e);
}
},
getRules() {
return this.get('RULES', []);
},
setRules(rules) {
this.set('RULES', rules);
},
getUserStats() {
return this.get('USER_STATS', null);
},
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 merged = canonical;
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: 13px;
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);
min-width: 280px;
max-width: 380px;
user-select: none;
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: 400px;
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.5px;
}
.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: 12px;
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.4;
cursor: not-allowed;
}
.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;
}
.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); }
}
`;
// ============================================================
// 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 晋级/降级规则...');
// GGPT 等已知站点的晋级条件来自站点 metadata,不再从 FAQ 宽泛猜测。
const canonicalRules = getCanonicalSiteRules(window.location.hostname);
if (canonicalRules.length > 0) {
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);
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 ||
r.demotion?.ratioDemote
);
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.querySelectorAll ? container.querySelectorAll('table') : [];
const rules = [];
for (const table of tables) {
const rows = table.querySelectorAll('tr');
if (rows.length < 2) 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 (!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 = reqIdx >= 0 ? (cells[reqIdx].textContent || '').trim() : '';
if (!reqText || reqText.length < 10) continue;
// 提取每个列的内容用于完整分析
const allText = Array.from(cells).map(c => (c.textContent || '').trim()).join(' | ');
const parsed = this._extractRequirements(reqText || allText);
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);
// 注册时间
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.demotion.ratioDemote = parseFloat(ratioDemoteMatch[1]);
result.isDemotable = true;
}
// 做种积分要求
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 = {
/** 从页面收集用户状态(异步:本地信息不全时会主动抓取 userdetails 页面补齐) */
async collect({ forceDetailsRefresh = false } = {}) {
Log.info('收集用户状态...');
const cached = Storage.getUserStats() || {};
const stats = {
uploaded: 0,
downloaded: 0,
ratio: 0,
bonus: 0,
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);
// 从用户详情页或首页提取传输数据
this._extractTraffic(stats);
// 提取等级
this._extractClass(stats);
// 提取魔力值
this._extractBonus(stats);
// 提取注册时间
this._extractJoinTime(stats);
// mybonus 等页面通常没有 UID;沿用同一账号的已知 UID,
// 以便后续只读抓取 userdetails.php 补齐注册时间和做种积分。
if (!stats.userId && cached.userId) stats.userId = cached.userId;
// 已知站点默认优先使用用户详情页;用户手动刷新时绕过本地节流,
// 防止仍停留在旧页面 DOM 时覆盖掉刚验证过的兑换结果。
const forceAuthoritative = forceDetailsRefresh || !!getSiteLevelMetadata(window.location.hostname);
await this._maybeFetchUserDetails(stats, forceAuthoritative, forceDetailsRefresh);
// 个别页面既不含详情字段,也可能没有可用 UID。此时不能把已确认的数据写成 0。
// 如果当前页面识别到的是另一个 UID,则绝不复用旧账号缓存。
const sameUser = !cached.userId || !stats.userId || String(cached.userId) === String(stats.userId);
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 (!stats.bonus && 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);
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 || !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._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;
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]);
}
// 方法 2: 任意 userdetails 链接
if (!stats.userId) {
const anyLink = doc.querySelector('a[href*="userdetails.php"][href*="id="]');
if (anyLink) {
const match = anyLink.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;
// 方法 1: userdetails 页面 rowhead 旁的魔力值
const bonusRow = findRowheadSibling(['魔力值', '魔力', 'Karma'], doc);
if (bonusRow) {
const m = (bonusRow.textContent || '').match(/([\d,]+(?:\.\d+)?)/);
if (m) stats.bonus = parseFloat(m[1].replace(/,/g, ''));
}
// 方法 2: 顶栏 #info_block / mybonus 链接附近
// NexusPHP 顶栏典型结构: 魔力值: 1,234.5 —— 数字在链接外的文本节点
if (!stats.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) {
stats.bonus = parseFloat(m[1].replace(/,/g, ''));
break;
}
}
}
// 方法 3: 全局扫描兜底 —— 句式优先,防误匹配兑换价格表
// mybonus 页典型句式: "你现在有 1,234.5 个魔力值"(数字在关键词前)
// 价格表干扰项: "500 魔力值 = 1GB"、"1GB 需要 500 魔力值"(不能匹配)
if (!stats.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 (val > 0) {
stats.bonus = val;
break;
}
}
}
}
// 做种积分(独立字段,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 页面', needsExchange: false };
}
if (!stats) {
return { status: 'no-stats', message: '无法获取用户数据', 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' | 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;
// 找到降级规则(比当前等级低的最高等级)—— 等级未识别时不做降级判断,
// 否则 classOrder=99 会把所有规则都当作"比当前低",拿最高等级的阈值误报降级风险
const demotionCandidates = rules.filter(r => r.order < stats.classOrder);
const demotionRule = classIdentified && demotionCandidates.length > 0 ? demotionCandidates[demotionCandidates.length - 1] : null;
// 等级未识别时给出明确提示,而不是误报"最高等级"或"降级风险"
if (!statsClassRecognized) {
result.messages.push('❓ 未能识别当前等级,无法评估晋级/降级。请访问你的用户详情页 (userdetails.php) 后点击面板"刷新"');
} else if (currentIdx < 0) {
result.messages.push(`❓ 已识别当前等级“${stats.className}”,但 FAQ 规则中没有对应等级。请在 FAQ 页面重新解析规则后再刷新。`);
}
result.nextClass = nextRule;
result.prevClass = demotionRule;
// ---- 降级检查 ----
if (demotionRule && demotionRule.demotion && demotionRule.demotion.ratioDemote) {
const threshold = demotionRule.demotion.ratioDemote;
const ratio = stats.ratio === Infinity ? 999 : stats.ratio;
if (ratio <= threshold) {
result.demotionStatus = 'danger';
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';
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';
}
} else {
result.demotionStatus = 'safe';
}
// ---- 晋级检查 ----
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 = Math.ceil((prom.downloadBytes - stats.downloaded) / GB);
}
// 2) 上传量缺口 = max(上传量要求缺口, 分享率要求缺口)
// 分享率缺口基于"兑换下载量之后"的下载量计算(兑下载量会拉低分享率)
let uploadGB = 0;
if (prom.uploadBytes && stats.uploaded < prom.uploadBytes) {
uploadGB = Math.ceil((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, Math.ceil(neededUpload / GB));
}
}
if (downloadGB > 0 || uploadGB > 0) {
result.exchangePlan = {
downloadGB,
uploadGB,
blockedByTime: !!blockedByTime,
blockedByBonus: !!blockedByBonus,
// 注册时间不足允许用户手动确认后提前兑换;做种积分不足仍保持阻止。
requiresTimeConfirmation: !!blockedByTime,
blocked: !!blockedByBonus,
};
const parts = [];
if (downloadGB > 0) parts.push(`下载量 ${downloadGB} GB`);
if (uploadGB > 0) parts.push(`上传量 ${uploadGB} 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} GB`);
if (p.uploadGB > 0) parts.push(`上传量 ${p.uploadGB} GB`);
result.messages.push(`💡 建议兑换: ${parts.join(' + ')}(当前剩余魔力值: ${stats.bonus?.toFixed(0) || '未知'})`);
} else if (result.needsExchange && result.exchangeAmount > 0) {
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 = {
/** 检查是否在冷却中 */
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: 0 };
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) {
if (!result.success) return result;
if (!Number.isFinite(beforeBonus) || beforeBonus <= 0) {
return { success: false, reason: '无法读取兑换前的魔力值,未确认兑换成功' };
}
try {
for (let attempt = 0; attempt < 3; attempt++) {
const afterBonus = await this._fetchCurrentBonus('np_verify', `${Date.now()}-${attempt}`);
if (afterBonus === null) {
return { success: false, reason: '无法从站点返回页确认魔力值扣减' };
}
if (afterBonus < beforeBonus) {
const deducted = beforeBonus - afterBonus;
// 站点余额只显示到 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 小额残差,按已完成处理`);
}
return { ...result, afterBonus, deducted, costDelta, accountingWarning };
}
if (attempt < 2) await sleep((attempt + 1) * 500);
}
return {
success: false,
cooldown: true,
reason: '站点未扣除魔力值,可能仍在 10 秒兑换冷却中',
};
} catch (e) {
Log.warn('核验兑换结果失败:', e);
return { success: false, reason: '无法核验站点兑换结果' };
}
},
/** 按真实 100 GB 档位估算单类兑换;与实际循环使用完全相同的取整规则。 */
_estimateRepeatedCost(parsed, type, targetGB) {
const normalizedTarget = Math.max(0, Number(targetGB) || 0);
if (!normalizedTarget) {
return { success: true, runs: 0, targetGB: 0, actualGB: 0, unitCost: 0, cost: 0 };
}
const tier = parsed?.tiers?.find(t => t.type === type && Math.abs(t.gb - 100) < 0.001);
if (!tier) {
return {
success: false,
reason: `站点没有 100 GB ${type === 'download' ? '下载量' : '上传量'}兑换档位`,
};
}
const runs = Math.ceil(normalizedTarget / 100);
return {
success: true,
runs,
targetGB: normalizedTarget,
actualGB: runs * 100,
unitCost: tier.cost,
cost: runs * tier.cost,
};
},
_estimatePlanCost(parsed, plan) {
const upload = this._estimateRepeatedCost(parsed, 'upload', plan?.uploadGB || 0);
if (!upload.success) return upload;
const download = this._estimateRepeatedCost(parsed, 'download', plan?.downloadGB || 0);
if (!download.success) return download;
return {
success: true,
upload,
download,
totalCost: upload.cost + download.cost,
totalRuns: upload.runs + download.runs,
totalActualGB: upload.actualGB + download.actualGB,
};
},
_estimateCache: null,
/** 读取站点当前档位,供面板展示晋级方案预计魔力值。 */
async estimatePromotionPlan(evaluation = RuleEngine.evaluate()) {
const plan = evaluation?.exchangePlan;
if (!plan || (!plan.uploadGB && !plan.downloadGB)) {
return { success: false, reason: '当前没有需要兑换的流量缺口' };
}
const knownBonus = Math.round(Number(Storage.getUserStats()?.bonus) || 0);
const cacheKey = `${window.location.hostname}:${plan.uploadGB || 0}:${plan.downloadGB || 0}:${knownBonus}`;
if (this._estimateCache
&& this._estimateCache.key === cacheKey
&& Date.now() - this._estimateCache.time < 30000) {
return this._estimateCache.value;
}
const parsed = await this._getTiers();
if (!parsed) return { success: false, reason: '无法读取站点兑换档位' };
const estimate = this._estimatePlanCost(parsed, plan);
const value = {
...estimate,
pageBonus: parsed.pageBonus || 0,
plan,
};
this._estimateCache = { key: cacheKey, time: Date.now(), value };
return value;
},
async estimateManual(gb, type) {
const parsed = await this._getTiers();
if (!parsed) return { success: false, reason: '无法读取站点兑换档位' };
const plan = type === 'download'
? { uploadGB: 0, downloadGB: gb }
: { uploadGB: gb, downloadGB: 0 };
return { ...this._estimatePlanCost(parsed, plan), pageBonus: parsed.pageBonus || 0, parsed };
},
/**
* 晋级计划兑换:按 evaluate() 算出的缺口,先兑上传量、再兑下载量
* 不再被"无需兑换"拦截——只要存在流量缺口就会执行
*/
async executeExchange(opts = {}) {
Log.info('开始执行兑换(晋级计划驱动)...');
const stats = await UserStats.collect(); // 先刷新流量与魔力值,避免按旧状态生成计划
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: Math.ceil((evaluation.exchangeAmount || 0) / GB) };
const estimate = this._estimatePlanCost(parsed, plan);
if (!estimate.success) return estimate;
if (plan.requiresTimeConfirmation) {
const confirmationContext = {
evaluation,
plan,
estimate,
currentBonus: parsed.pageBonus || stats.bonus,
};
if (typeof opts.confirmTimeUnmet !== 'function') {
return {
success: false,
confirmationRequired: true,
reason: '注册时间尚未达标,需要用户确认后才能提前兑换',
...confirmationContext,
};
}
const confirmed = await opts.confirmTimeUnmet(confirmationContext);
if (!confirmed) {
return {
success: false,
cancelled: true,
reason: '用户取消了注册时间未达标时的提前兑换',
estimate,
};
}
}
const done = [];
let confirmedBonus = parsed.pageBonus || stats.bonus;
// 必须先补上传量,再补下载量;否则下载会先拉低分享率。
if (plan.uploadGB > 0) {
const r = await this._exchangeRepeated(parsed, 'upload', plan.uploadGB, confirmedBonus, opts);
if (!r.success) return r;
confirmedBonus = r.afterBonus;
done.push(`上传量 ${r.gb} GB(-${r.cost} 魔力值)`);
}
// 上传成功并经过站点冷却后,才兑换下载量。
if (plan.downloadGB > 0) {
const r = await this._exchangeRepeated(parsed, 'download', plan.downloadGB, confirmedBonus, opts);
if (!r.success) {
return done.length
? { success: true, partial: true, message: done.join(' + ') + `;下载量兑换失败: ${r.reason}` }
: r;
}
confirmedBonus = r.afterBonus;
done.push(`下载量 ${r.gb} GB(-${r.cost} 魔力值)`);
}
Storage.setUserStats({ ...stats, bonus: confirmedBonus });
return { success: true, message: done.join(' + '), plan, estimatedCost: estimate.totalCost };
},
/**
* 固定使用 100 GB 档位循环到目标量。目标不是 100 的整数倍时向上取整。
* 每轮成功后等待站点冷却;冷却拒绝最多自动重试两次。
*/
async _exchangeRepeated(parsed, type, targetGB, beforeBonus, opts = {}) {
const typeName = type === 'download' ? '下载量' : '上传量';
const tier = parsed.tiers.find(t => t.type === type && Math.abs(t.gb - 100) < 0.001);
if (!tier) return { success: false, reason: `站点没有 100 GB ${typeName}兑换档位` };
const totalRuns = Math.ceil(Math.max(0, Number(targetGB) || 0) / 100);
if (!totalRuns) return { success: false, reason: '无效的目标兑换量' };
let currentBonus = beforeBonus;
let totalCost = 0;
let completedRuns = 0;
for (let i = 0; i < totalRuns; i++) {
let retries = 0;
while (true) {
const waitMs = this.getCooldownRemaining();
if (waitMs > 0) {
opts.onProgress?.({ phase: 'cooldown', completedRuns, totalRuns, waitMs, type });
await sleep(waitMs + 250);
}
// 估算页与当前页面都可能是旧 DOM;每批提交前以服务器余额作唯一基准。
const freshBonus = await this._fetchCurrentBonus('np_before', `${Date.now()}-${i}-${retries}`);
if (freshBonus === null) {
return {
success: false,
partial: completedRuns > 0,
gb: completedRuns * 100,
cost: totalCost,
afterBonus: currentBonus,
reason: '无法读取提交前的服务器魔力值,已停止以避免误兑换',
};
}
currentBonus = freshBonus;
const minBonusKeep = Math.max(0, Number(opts.minBonusKeep) || 0);
if (tier.cost > currentBonus - minBonusKeep) {
return {
success: false,
partial: completedRuns > 0,
gb: completedRuns * 100,
cost: totalCost,
afterBonus: currentBonus,
reason: `魔力值不足(下一次需 ${tier.cost},当前 ${currentBonus.toFixed(1)},保留 ${minBonusKeep})`,
};
}
const result = await this._verifyBonusDeduction(
currentBonus,
await AutoExchange._submitTier(parsed, tier),
tier.cost
);
if (result.success) {
currentBonus = result.afterBonus;
totalCost += Number.isFinite(result.deducted) ? result.deducted : tier.cost;
completedRuns++;
Storage.setLastExchange(Date.now());
const stored = Storage.getUserStats() || {};
const GB = 1024 * 1024 * 1024;
const updated = { ...stored, bonus: currentBonus };
if (type === 'upload') updated.uploaded = (stored.uploaded || 0) + 100 * GB;
else updated.downloaded = (stored.downloaded || 0) + 100 * GB;
if (updated.downloaded > 0) updated.ratio = updated.uploaded / updated.downloaded;
Storage.setUserStats(updated);
opts.onProgress?.({
phase: 'success', completedRuns, totalRuns, gb: completedRuns * 100, type,
accountingWarning: result.accountingWarning,
});
Log.info(`循环兑换进度: ${typeName} ${completedRuns}/${totalRuns}(${completedRuns * 100} GB)`);
break;
}
if (result.cooldown && retries < 2) {
retries++;
Storage.setLastExchange(Date.now());
Log.warn(`站点冷却拒绝,等待后重试(${retries}/2)`);
continue;
}
return {
...result,
partial: completedRuns > 0,
gb: completedRuns * 100,
cost: totalCost,
};
}
}
return {
success: true,
gb: completedRuns * 100,
cost: totalCost,
type,
runs: completedRuns,
afterBonus: currentBonus,
};
},
/**
* 手动兑换:指定 GB 数和类型。使用后台请求并核验余额,避免 DOM 点击后虚报成功。
*/
async executeManual(gb, type = 'upload', opts = {}) {
gb = parseFloat(gb);
if (!gb || gb <= 0) return { success: false, reason: '无效的兑换量' };
const stats = await UserStats.collect(); // 刷新魔力值
const parsed = opts.parsed || await this._getTiers();
if (!parsed) return { success: false, reason: '未找到兑换档位(无法解析 mybonus 页面)' };
return await this._exchangeRepeated(parsed, type, gb, parsed.pageBonus || stats.bonus, opts);
},
/** 获取兑换档位与当前余额:优先禁缓存 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;
}
},
/**
* 按类型兑换一个档位
* 选档策略: 预算内 ≥targetGB 的最小档;都不够大则用能负担的最大档
*/
async _exchangeOneTier(parsed, type, targetGB, opts = {}) {
const stats = Storage.getUserStats();
const budget = Math.max(0, (stats?.bonus || 0) - (opts.keepBonus || 0));
const typeName = type === 'download' ? '下载量' : '上传量';
const pool = parsed.tiers.filter(t => t.type === type);
if (!pool.length) {
return { success: false, reason: `站点没有${typeName}兑换档位` };
}
const affordable = pool.filter(t => t.cost <= budget);
if (!affordable.length) {
const minCost = Math.min(...pool.map(t => t.cost));
return { success: false, reason: `魔力值不足(${typeName}最低档需 ${minCost},当前 ${budget.toFixed(0)})` };
}
let tier = affordable.filter(t => t.gb >= targetGB).sort((a, b) => a.gb - b.gb)[0];
if (!tier) tier = affordable.slice().sort((a, b) => b.gb - a.gb)[0];
Log.info(`将兑换 ${tier.gb} GB ${typeName},消耗 ${tier.cost} 魔力值(目标 ${targetGB} GB)`);
// 后台 POST(不跳转页面);调用者必须再核验返回页余额后才能提示成功。
const r = await AutoExchange._submitTier(parsed, tier);
if (r.success) {
return { success: true, gb: tier.gb, cost: tier.cost, type, method: 'xhr', responseText: r.responseText };
}
return { success: false, reason: r.reason };
},
};
// ============================================================
// 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) return 1000;
const settings = Storage.getSettings();
if (!settings.autoExchange) return 60000; // 总开关(默认关)
// 按量完成模式使用站点冷却,不能被旧版的一小时全局冷却拦住。
if (settings.autoExchangeMode !== 'amount' && BonusExchange.isOnCooldown()) {
return 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 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 60000;
}
if (settings.autoExchangeMode === 'amount') {
return await this._runAmountExchange(settings, stats);
}
// 计算兑换目标:promotion 用晋级计划(下载+上传两轮),fixed 用定额
/** @type {Array<{type:string, gb:number}>} */
const jobs = [];
if (settings.autoExchangeMode === 'fixed') {
const gb = Math.max(0, settings.autoExchangeFixedGB || 0);
if (gb >= 1) jobs.push({ type: settings.autoExchangeFixedType === 'download' ? 'download' : 'upload', gb });
} else {
const evaluation = RuleEngine.evaluate();
if (!evaluation.needsExchange) return;
const plan = evaluation.exchangePlan
|| { downloadGB: 0, uploadGB: Math.ceil((evaluation.exchangeAmount || 0) / (1024 * 1024 * 1024)) };
if (plan.requiresTimeConfirmation) {
Log.warn('自动兑换: 注册时间尚未达标,必须由用户在面板中确认,跳过本次');
return 60000;
}
if (plan.uploadGB > 0) jobs.push({ type: 'upload', gb: plan.uploadGB });
if (plan.downloadGB > 0) jobs.push({ type: 'download', gb: plan.downloadGB });
}
if (!jobs.length) return 60000;
const maxGB = settings.autoExchangeMaxGBPerRun || 100;
let remainingRunLimit = maxGB;
const done = [];
for (let i = 0; i < jobs.length && remainingRunLimit > 0; i++) {
const job = jobs[i];
const jobGB = Math.min(job.gb, remainingRunLimit);
Log.info(`自动兑换触发: ${job.type} ${jobGB} GB(模式: ${settings.autoExchangeMode || 'promotion'})`);
const result = await this._backgroundExchange(job.type, jobGB, stats, settings);
if (result.success) {
done.push(`${job.type === 'download' ? '下载量' : '上传量'} ${result.gb} GB(-${result.cost})`);
remainingRunLimit -= result.gb;
stats.bonus = result.afterBonus;
Storage.setUserStats({ ...Storage.getUserStats(), bonus: result.afterBonus });
} else {
Log.warn('自动兑换失败:', result.reason);
showNotification(`⚠️ 自动兑换失败: ${result.reason}`, 'warning', 5000);
break;
}
// 站点限制 10 秒一次,两轮之间等待一个冷却周期加缓冲。
if (i < jobs.length - 1 && remainingRunLimit > 0) {
await sleep(CONFIG.EXCHANGE_COOLDOWN + 1000);
}
}
if (done.length) {
showNotification(`✅ 自动兑换成功: ${done.join(' + ')}`, 'success', 6000);
Panel.update();
}
return 60000;
} catch (e) {
Log.error('自动兑换异常:', e);
return 60000;
} finally {
this._running = false;
}
},
_tierKey(tier) {
return `${tier.type}:${tier.value}`;
},
_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: 0 };
UserStats._extractBonus(pageStats, doc);
return Number.isFinite(pageStats.bonus) && pageStats.bonus > 0 ? pageStats.bonus : null;
},
_pauseAmountExchange(settings, reason) {
Storage.setSettings({ ...settings, autoExchange: false });
Log.warn(`自动按量兑换已暂停: ${reason}`);
showNotification(`⚠️ 自动按量兑换已暂停:${reason}`, 'warning', 7000);
return 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 = this._extractPageBonus(doc);
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 waitMs + 250;
const tier = parsed.tiers.find(t =>
this._tierKey(t) === settings.autoExchangeAmountTierKey
&& (t.type === 'upload' || t.type === 'download')
&& Math.abs(t.gb - 100) < 0.001
);
if (!tier) {
return this._pauseAmountExchange(settings, '所选 100 GB 档位已不在本站兑换页面中');
}
const keepBonus = Math.max(0, Number(settings.autoExchangeMinBonusKeep) || 0);
const budget = Math.max(0, (stats.bonus || 0) - keepBonus);
if (tier.cost > budget) {
return this._pauseAmountExchange(settings, `魔力值不足以兑换所选档位(需 ${tier.cost},可用 ${budget.toFixed(1)})`);
}
Log.info(`自动按量兑换: ${tier.type} ${tier.gb} GB,剩余目标 ${remainingGB} GB`);
const result = await BonusExchange._verifyBonusDeduction(
beforeBonus,
await this._submitTier(parsed, tier),
tier.cost
);
if (!result.success) {
if (result.cooldown) {
Log.warn('站点仍处于兑换冷却,等待下一轮调度');
return cooldownSeconds * 1000 + 250;
}
return this._pauseAmountExchange(settings, result.reason || '站点拒绝了兑换请求');
}
Storage.setLastExchange(Date.now());
const nextRemainingGB = Math.max(0, remainingGB - tier.gb);
const completedGB = Math.max(0, Number(settings.autoExchangeAmountCompletedGB) || 0) + tier.gb;
const nextSettings = {
...settings,
autoExchange: nextRemainingGB > 0,
autoExchangeAmountRemainingGB: nextRemainingGB,
autoExchangeAmountCompletedGB: completedGB,
};
Storage.setSettings(nextSettings);
Storage.setUserStats({ ...stats, bonus: result.afterBonus });
if (nextRemainingGB > 0) {
showNotification(`✅ 自动按量兑换:已兑换 ${tier.gb} GB,剩余目标 ${nextRemainingGB} GB`, 'success', 5000);
} else {
showNotification(`✅ 自动按量兑换完成:实际兑换 ${completedGB} GB`, 'success', 7000);
}
Panel.update();
return cooldownSeconds * 1000 + 250;
},
/**
* 后台兑换:fetch mybonus.php 解析兑换表单与档位,再提交
* 不依赖当前停留在 mybonus 页面
* @param {'upload'|'download'} type 兑换类型
* @param {number} targetGB 目标 GB 数
*/
async _backgroundExchange(type, targetGB, stats, settings) {
const resp = await fetch('mybonus.php', { credentials: 'same-origin' });
if (!resp.ok) return { success: false, reason: `mybonus.php HTTP ${resp.status}` };
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const parsed = this._parseExchangeTiers(doc);
if (!parsed || !parsed.tiers.length) {
return { success: false, reason: '未在 mybonus 页面找到兑换档位' };
}
// 所有自动路径固定使用 100 GB 档,避免误选更差汇率的小档。
const keepBonus = settings.autoExchangeMinBonusKeep || 0;
const budget = Math.max(0, (stats.bonus || 0) - keepBonus);
const pool = parsed.tiers.filter(t => t.type === type && Math.abs(t.gb - 100) < 0.001);
if (!pool.length) {
return { success: false, reason: `站点没有 100 GB ${type === 'download' ? '下载量' : '上传量'}兑换档位` };
}
const affordable = pool.filter(t => t.cost <= budget);
if (!affordable.length) {
return { success: false, reason: `魔力值预算不足(当前 ${stats.bonus.toFixed(1)},保留 ${keepBonus})` };
}
const tier = affordable[0];
const result = await BonusExchange._verifyBonusDeduction(
stats.bonus,
await this._submitTier(parsed, tier),
tier.cost
);
if (result.success) {
Storage.setLastExchange(Date.now());
return { success: true, gb: tier.gb, cost: tier.cost, afterBonus: result.afterBonus };
}
return { success: false, reason: result.reason, cooldown: result.cooldown };
},
/**
* 从 mybonus 页面文档解析"上传量"兑换档位
* 支持两种结构:
* A) 标准 NexusPHP: 单个 form + |