// ==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 = `
NP 晋级助手
账号状态
加载中...
下一等级
加载中...
手动兑换
`; 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 = '' + parsed.tiers.map(tier => ``).join('') + '
本站兑换价格
兑换档位价格(G)G / GB
${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) : '—'}
'; 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(); } })();