([^<]*)<\/span>/);
if (!tm) continue;
rows.push({ appid: idm[1], cn: tm[1] });
}
return rows;
}
/** Steam appdetails:取英文名 */
async function steamEnglishName(appid) {
const url = 'https://store.steampowered.com/api/appdetails?appids=' + appid + '&l=english&filters=basic';
const res = await request({ url: url, timeout: 20000 });
if (res.status !== 200) return '';
let j;
try { j = JSON.parse(res.text); } catch (e) { return ''; }
const d = j && j[appid] && j[appid].data;
return (d && d.name) || '';
}
/** 英文名候选:去商标符号 / 去版本后缀,逐级收缩 */
function gameNameVariants(raw) {
const base = String(raw || '').replace(/[™®©]/g, '').trim();
const list = [];
const add = function (s) {
s = String(s || '').trim().replace(/\s+/g, ' ');
if (s && list.indexOf(s) < 0) list.push(s);
};
add(base);
add(base.split(/\s+[-—]\s+/)[0]);
add(base.split(':')[0]);
const words = base.split(/\s+/);
if (words.length > 3) add(words.slice(0, 3).join(' '));
if (words.length > 2) add(words.slice(0, 2).join(' '));
return list;
}
/** Nexus games 名称模糊查询 */
async function nexusGamesByName(name) {
const q = 'query GameSearch($filter: GamesSearchFilter) { games(count: 6, filter: $filter) { nodes { id name domainName modCount } } }';
const res = await fetch(API, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-graphql-operationname': 'GameSearch' },
credentials: 'include',
body: JSON.stringify({
query: q,
variables: { filter: { name: [{ op: 'WILDCARD', value: name }] } },
operationName: 'GameSearch'
})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
if (json.errors && json.errors.length) throw new Error(json.errors[0].message || 'GraphQL error');
return (json.data && json.data.games && json.data.games.nodes) || [];
}
/**
* 解析用户的输入 → 候选游戏列表。
* 中文:本地别名优先,再 Steam 国区搜索;英文/域名:直接 Nexus games 模糊查。
*/
async function resolveGame(term) {
const raw = String(term || '').trim();
if (!raw) return [];
const cacheKey = 'cn.' + normKey(raw);
const cached = gameCacheGet(cacheKey);
if (cached && Date.now() - cached.t < 7 * 24 * 3600 * 1000) return cached.v;
const out = [];
const seen = {};
const push = function (g) {
const d = String(g.domain || g.domainName || '').toLowerCase();
if (!d || seen[d]) return;
seen[d] = 1;
out.push({
name: g.name || d,
domain: d,
modCount: g.modCount == null ? null : g.modCount,
source: g.source || 'Nexus'
});
};
matchLocalAliases(raw).forEach(push);
// 本地别名命中就不必再走 Steam/GraphQL(快且稳定)
if (out.length) {
gameCacheSet(cacheKey, out);
return out;
}
if (CJK_RE.test(raw)) {
let rows = [];
try { rows = await steamSearchCn(raw); } catch (e) { rows = []; }
for (let i = 0; i < Math.min(rows.length, 3); i++) {
let en = '';
try { en = await steamEnglishName(rows[i].appid); } catch (e) { en = ''; }
if (!en) continue;
const variants = gameNameVariants(en);
for (let v = 0; v < variants.length; v++) {
let games = [];
try { games = await nexusGamesByName(variants[v]); } catch (e) { games = []; }
if (games.length) {
for (let k = 0; k < games.length && k < 3; k++) {
games[k].source = 'Steam→Nexus';
push(games[k]);
}
break;
}
}
}
} else {
let games = [];
try { games = await nexusGamesByName(raw); } catch (e) { games = []; }
games.forEach(push);
}
if (out.length) gameCacheSet(cacheKey, out);
return out;
}
// ==================== 模组详情:AI 翻译 / 分析 ====================
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
const Q_GAME_BY_DOMAIN = `query GameByDomain($domain: String!) { game(domainName: $domain) { id name domainName modCount } }`;
const Q_MOD_DETAIL = `query ModDetail($modId: ID!, $gameId: ID!) {
mod(modId: $modId, gameId: $gameId) {
modId name summary description version author createdAt updatedAt downloads endorsements fileSize
game { id domainName name }
modCategory { name }
tags { name }
}
}`;
async function gqlRequest(query, variables, operationName) {
const res = await fetch(API, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-graphql-operationname': operationName },
credentials: 'include',
body: JSON.stringify({ query: query, variables: variables, operationName: operationName })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
if (json.errors && json.errors.length) throw new Error(json.errors[0].message || 'GraphQL error');
return json.data;
}
/** 拉取模组详情:先用 domain 换 gameId(mod 查询必须 gameId + modId) */
async function fetchModDetail(domain, modId) {
const g = await gqlRequest(Q_GAME_BY_DOMAIN, { domain: domain }, 'GameByDomain');
const game = g && g.game;
if (!game) throw new Error('未找到游戏:' + domain);
const d = await gqlRequest(Q_MOD_DETAIL, { modId: modId, gameId: game.id }, 'ModDetail');
if (!d || !d.mod) throw new Error('未找到模组 #' + modId);
return d.mod;
}
/** Nexus 描述是 BBCode,转成适合喂给 AI 的纯文本 */
function bbcodeToText(src) {
let s = String(src || '');
s = s.replace(/
/gi, '\n');
s = s.replace(/\[img\][\s\S]*?\[\/img\]/gi, '');
s = s.replace(/\[img=[^\]]*\][\s\S]*?\[\/img\]/gi, '');
s = s.replace(/\[url=[^\]]*\]([\s\S]*?)\[\/url\]/gi, '$1');
s = s.replace(/\[url\]([\s\S]*?)\[\/url\]/gi, '$1');
s = s.replace(/\[\*\]/g, '- ');
s = s.replace(/\[\/(?:b|i|u|s|size|color|center|left|right|quote|spoiler|code|font|list|indent|caption)\]/gi, '');
s = s.replace(/\[(?:b|i|u|s|size=[^\]]*|color=[^\]]*|center|left|right|quote|spoiler|code|font=[^\]]*|list(?:=[^\]]*)?|indent|caption)\]/gi, '');
s = s.replace(/ /g, ' ');
s = s.replace(/&/g, '&');
s = s.replace(/\/g, '\\');
s = s.replace(/[ \t]+\n/g, '\n');
s = s.replace(/\n{3,}/g, '\n\n');
return s.trim();
}
function modMetaLine(mod) {
const parts = [];
if (mod.version) parts.push('版本 ' + mod.version);
if (mod.author) parts.push('作者 ' + mod.author);
if (mod.modCategory && mod.modCategory.name) parts.push('分类 ' + mod.modCategory.name);
if (mod.tags && mod.tags.length) parts.push('标签 ' + mod.tags.map(function (t) { return t.name; }).join('、'));
if (mod.downloads != null) parts.push('下载 ' + fmtNum(mod.downloads));
if (mod.endorsements != null) parts.push('点赞 ' + fmtNum(mod.endorsements));
if (mod.fileSize != null) parts.push('大小 ' + fmtSize(mod.fileSize));
if (mod.updatedAt) parts.push('更新 ' + fmtDate(mod.updatedAt));
return parts.join(' · ');
}
function aiStatus(text, kind) {
const el = $('#nme-ai-status');
if (!el) return;
el.textContent = text || '';
el.className = 'nme-ai-status' + (kind ? ' nme-ai-status-' + kind : '');
}
function aiOutput() {
const el = $('#nme-ai-out');
if (el) el.hidden = false;
return el;
}
function aiTextHtml(text) {
return '' + escapeHtml(text).replace(/\n/g, '
') + '
';
}
// ==================== 浮动按钮坞(AI / 下载)====================
// 原来 AI 条与下载条直接插在页面内容顶部(main / h1 之后),
// 正好压在模组头图上,浅色文字叠在风景图上几乎看不清,
// 也会把首屏版面顶乱。改成右下角一列浮动按钮 + 弹出式浮层:
// 浮层用不透明底色,文字始终清晰,也不再遮挡页面内容。
const FAB_SEC_AI = 'ai';
const FAB_SEC_DL = 'dl';
/**
* 右下角统一浮动坞(所有浮动入口的唯一容器)。
*
* 之前胶囊(#nme-capsule)和按钮坞(#nme-fab)是两个独立的 fixed 容器,
* 胶囊收起时面板只是 opacity:0,仍然占着 320x188 的布局空间,
* 于是这个「透明大盒子」盖在按钮坞上方,把所有点击都吃掉了 ——
* 表现为「⬇ 下载点了没反应」,实际命中的是 #nme-capsule 而不是按钮。
*
* 现在统一到一个 #nme-dock 里:
* - 按钮(AI / 下载 / 配置 / 增强视图)竖排在流内,永不重叠;
* - 弹出面板绝对定位在坞的上方,且两者互斥(开一个关另一个);
* - 坞本身没有多余尺寸,不会再出现「透明容器挡点击」。
*/
function ensureDock() {
let dock = document.getElementById('nme-dock');
if (dock) return dock;
dock = document.createElement('div');
dock.id = 'nme-dock';
document.body.appendChild(dock);
// 点击坞以外:同时收起两个弹出面板
document.addEventListener('click', function (e) {
if (!dock.contains(e.target)) {
closeFab();
closeCapsule();
}
});
return dock;
}
/** 收起胶囊快捷面板 */
function closeCapsule() {
const dock = document.getElementById('nme-dock');
if (dock) dock.classList.remove('nme-cap-open');
}
function mountFab(ctx) {
if (!ctx) return null;
if (ctx.mode !== 'detail' && ctx.mode !== 'collection') return null;
const dock = ensureDock();
// 模式变了(合集页 <-> 详情页 SPA 跳转):先清掉上一套按钮与浮层再重建
if (dock.getAttribute('data-fab-mode') !== ctx.mode) {
const oldBtns = $('#nme-fab-btns');
if (oldBtns) oldBtns.remove();
const oldCard = $('#nme-fab-card');
if (oldCard) oldCard.remove();
dock.setAttribute('data-fab-mode', ctx.mode);
}
if ($('#nme-fab-btns')) return $('#nme-fab-btns');
const card = document.createElement('div');
card.id = 'nme-fab-card';
card.className = 'nme-fab-card';
card.hidden = true;
card.innerHTML = [
'',
' ',
' ',
'
',
''
].join('');
const btns = document.createElement('div');
btns.id = 'nme-fab-btns';
btns.className = 'nme-fab-btns';
if (ctx.mode === 'detail') {
const b = document.createElement('button');
b.id = 'nme-fab-ai';
b.className = 'nme-fab-btn nme-fab-btn-ai';
b.type = 'button';
b.textContent = '✦ AI';
b.title = 'AI 翻译 / 分析';
btns.appendChild(b);
}
const d = document.createElement('button');
d.id = 'nme-fab-dl';
d.className = 'nme-fab-btn nme-fab-btn-dl';
d.type = 'button';
d.textContent = ctx.mode === 'collection' ? '⬇ 合集下载' : '⬇ 下载';
d.title = ctx.mode === 'collection' ? '合集批量下载' : '文件列表与一键下载';
btns.appendChild(d);
// AI 接口配置:独立浮动按钮,直接打开配置弹窗
const c = document.createElement('button');
c.id = 'nme-fab-cfg';
c.className = 'nme-fab-btn nme-fab-btn-cfg';
c.type = 'button';
c.textContent = '⚙ 配置';
c.title = 'AI 接口配置';
btns.appendChild(c);
dock.appendChild(card);
dock.appendChild(btns);
card.querySelector('#nme-fab-close').addEventListener('click', function () { closeFab(); });
if (ctx.mode === 'detail') {
btns.querySelector('#nme-fab-ai').addEventListener('click', function () { toggleFab(FAB_SEC_AI); });
}
btns.querySelector('#nme-fab-dl').addEventListener('click', function () { toggleFab(FAB_SEC_DL); });
btns.querySelector('#nme-fab-cfg').addEventListener('click', function () {
closeFab();
openAiSettings();
});
return btns;
}
/** 浮层里的小节(AI / 下载)之间的切换 */
function setFabSection(sec) {
const card = $('#nme-fab-card');
if (!card) return;
const secs = card.querySelectorAll('.nme-fab-sec');
for (let i = 0; i < secs.length; i++) {
secs[i].classList.toggle('nme-fab-sec-on', secs[i].getAttribute('data-sec') === sec);
}
const titles = { ai: 'N网 AI · 翻译与分析', dl: 'N网 下载 · 文件与直链' };
const t = $('#nme-fab-title');
if (t) t.textContent = titles[sec] || '';
const btns = $('#nme-fab-btns');
if (btns) {
const ba = btns.querySelector('#nme-fab-ai');
const bd = btns.querySelector('#nme-fab-dl');
if (ba) ba.classList.toggle('nme-fab-btn-on', sec === FAB_SEC_AI);
if (bd) bd.classList.toggle('nme-fab-btn-on', sec === FAB_SEC_DL);
}
}
function openFab(sec) {
const card = $('#nme-fab-card');
if (!card) return;
// 面板互斥:打开 AI/下载面板时收起快捷面板,避免两个浮层叠在一起
closeCapsule();
card.hidden = false;
setFabSection(sec);
}
function toggleFab(sec) {
const card = $('#nme-fab-card');
if (!card) return;
if (!card.hidden && card.querySelector('.nme-fab-sec-on[data-sec="' + sec + '"]')) {
closeFab();
return;
}
openFab(sec);
}
function closeFab() {
const card = $('#nme-fab-card');
if (card) card.hidden = true;
const btns = $('#nme-fab-btns');
if (btns) {
const ba = btns.querySelector('#nme-fab-ai');
const bd = btns.querySelector('#nme-fab-dl');
if (ba) ba.classList.remove('nme-fab-btn-on');
if (bd) bd.classList.remove('nme-fab-btn-on');
}
}
/** 把内容挂到浮层里的小节容器中(不再插进页面正文) */
function fabSection(sec) {
const body = $('#nme-fab-body');
if (!body) return null;
let el = body.querySelector('.nme-fab-sec[data-sec="' + sec + '"]');
if (!el) {
el = document.createElement('div');
el.className = 'nme-fab-sec';
el.setAttribute('data-sec', sec);
body.appendChild(el);
}
return el;
}
/** 模组页 AI 工具条(挂在浮动按钮坞的「AI」小节里) */
function mountDetailAi(ctx) {
if (!ctx || ctx.mode !== 'detail') return;
if ($('#nme-ai-bar')) return;
const sec = fabSection(FAB_SEC_AI);
if (!sec) return;
const bar = document.createElement('div');
bar.id = 'nme-ai-bar';
bar.innerHTML = [
'',
' N网 AI',
' 翻译描述 / 结构化分析 / 依赖与冲突提示',
'
',
'',
' ',
' ',
' ',
' ',
'
',
''
].join('');
sec.appendChild(bar);
const tr = bar.querySelector('#nme-ai-tr');
const an = bar.querySelector('#nme-ai-an');
const cfg = bar.querySelector('#nme-ai-cfg');
if (tr) tr.addEventListener('click', function () { runModTranslate(ctx); });
if (an) an.addEventListener('click', function () { runModAnalyze(ctx); });
if (cfg) cfg.addEventListener('click', openAiSettings);
if (!aiConfigured()) aiStatus('未配置 AI 接口,先点「AI 配置」', 'warn');
}
async function ensureModDetail(ctx) {
const want = Number(ctx.modId);
if (state.modCache && Number(state.modCache.modId) === want) return state.modCache;
// AI 条与下载条会同时挂载:复用同一次请求,避免重复打 GraphQL
if (state.modLoading && state.modLoading.id === want) return state.modLoading.p;
const p = fetchModDetail(ctx.domain, ctx.modId).then(function (mod) {
state.modCache = mod;
state.modLoading = null;
return mod;
}, function (e) {
state.modLoading = null;
throw e;
});
state.modLoading = { id: want, p: p };
return p;
}
async function runModTranslate(ctx) {
openFab(FAB_SEC_AI);
const trBtn = $('#nme-ai-tr');
const anBtn = $('#nme-ai-an');
const cacheKey = 'tr.' + ctx.domain + '.' + ctx.modId;
const cached = aiCacheGet(cacheKey);
if (cached) {
aiOutput().innerHTML = 'AI 翻译(缓存)
' + aiTextHtml(cached.v) + '
';
aiStatus('已使用缓存结果', 'ok');
return;
}
if (!aiConfigured()) { openAiSettings(); return; }
if (trBtn) trBtn.disabled = true;
if (anBtn) anBtn.disabled = true;
aiStatus('正在拉取描述并翻译…', 'busy');
try {
const mod = await ensureModDetail(ctx);
const raw = bbcodeToText(mod.description || '') || bbcodeToText(mod.summary || '');
if (!raw) throw new Error('该模组没有可翻译的描述');
if (raw.length > 2000) aiStatus('正在翻译 ' + raw.length + ' 字符(超出部分会截断)…', 'busy');
const prompt = '你是游戏模组文档翻译。把下面的 Nexus Mods 模组描述从英文翻译成简体中文。\n' +
'要求:\n' +
'1. 保留原文的段落、标题、列表、文件名、路径和链接;\n' +
'2. 专有名词(模组名、工具名、游戏内名词、文件路径)保留英文;\n' +
'3. 不要添加原文没有的内容,不要写前言或后记;\n' +
'4. 直接输出翻译后的正文。\n\n' +
'模组名:' + (mod.name || '') + '\n' +
'原文:\n"""\n' + raw.slice(0, AI_MAX_DESC) + '\n"""';
const text = await callAi(prompt, { temperature: 0.2, maxTokens: 4096 });
const clean = text.trim().replace(/^```[a-z]*\s*/i, '').replace(/```\s*$/i, '');
aiCacheSet(cacheKey, clean);
aiOutput().innerHTML = 'AI 翻译' +
'' + escapeHtml(modMetaLine(mod)) + '
' +
aiTextHtml(clean) + '
';
aiStatus('翻译完成', 'ok');
} catch (e) {
aiStatus((e && e.message) || String(e), 'err');
} finally {
if (trBtn) trBtn.disabled = false;
if (anBtn) anBtn.disabled = false;
}
}
function renderModAnalysis(data, mod) {
const features = (data.features || []).map(function (f) {
return '' + escapeHtml(typeof f === 'string' ? f : JSON.stringify(f)) + '';
}).join('');
const rows = [
['一句话', data.oneliner],
['安装', data.install],
['依赖', data.requirements],
['冲突/注意', data.conflicts],
['适合', data.audience],
['风险', data.risk]
].filter(function (r) { return r[1]; }).map(function (r) {
return '' + r[0] + '' + escapeHtml(r[1]) + '
';
}).join('');
return '' +
'
AI 分析' + escapeHtml(modMetaLine(mod)) + '
' +
rows +
(features ? '
主要功能
' : '') +
'
';
}
async function runModAnalyze(ctx) {
openFab(FAB_SEC_AI);
const trBtn = $('#nme-ai-tr');
const anBtn = $('#nme-ai-an');
const cacheKey = 'an.' + ctx.domain + '.' + ctx.modId;
const cached = aiCacheGet(cacheKey);
if (cached) {
aiOutput().innerHTML = renderModAnalysis(cached.v, state.modCache || { name: '' });
aiStatus('已使用缓存结果', 'ok');
return;
}
if (!aiConfigured()) { openAiSettings(); return; }
if (trBtn) trBtn.disabled = true;
if (anBtn) anBtn.disabled = true;
aiStatus('正在分析模组…', 'busy');
try {
const mod = await ensureModDetail(ctx);
const desc = bbcodeToText(mod.description || '').slice(0, AI_MAX_DESC);
const prompt = '你是资深模组安装顾问。根据下面的 Nexus Mods 模组信息,输出 JSON(只输出 JSON,不要 markdown 代码块):\n' +
'{\n' +
' "oneliner": "一句话说明这个 mod 做什么",\n' +
' "features": ["主要功能点", "..."],\n' +
' "install": "安装步骤(含目标路径)",\n' +
' "requirements": "前置依赖 / 游戏版本要求,没有就写 无",\n' +
' "conflicts": "已知冲突或注意事项,没有就写 无",\n' +
' "audience": "适合什么玩家",\n' +
' "risk": "低/中/高 + 一句原因"\n' +
'}\n\n' +
'只依据给到的信息,不要编造。信息:\n' +
'名称:' + (mod.name || '') + '\n' +
'简介:' + (mod.summary || '') + '\n' +
'元数据:' + modMetaLine(mod) + '\n' +
'描述:\n"""\n' + desc + '\n"""';
const text = await callAi(prompt, { temperature: 0.2, maxTokens: 1600 });
const data = parseAiJson(text);
aiCacheSet(cacheKey, data);
aiOutput().innerHTML = renderModAnalysis(data, mod);
aiStatus('分析完成', 'ok');
} catch (e) {
aiStatus((e && e.message) || String(e), 'err');
} finally {
if (trBtn) trBtn.disabled = false;
if (anBtn) anBtn.disabled = false;
}
}
// ==================== AI 配置弹窗 ====================
function openAiSettings() {
if ($('#nme-ai-cfg-modal')) return;
const modal = document.createElement('div');
modal.id = 'nme-ai-cfg-modal';
modal.innerHTML = [
''
].join('');
document.body.appendChild(modal);
const urlEl = modal.querySelector('#nme-cfg-url');
const keyEl = modal.querySelector('#nme-cfg-key');
const modelEl = modal.querySelector('#nme-cfg-model');
const statusEl = modal.querySelector('#nme-cfg-status');
urlEl.value = aiCfg.url || AI_DEFAULT_URL;
keyEl.value = aiCfg.key || '';
modelEl.value = aiCfg.model || AI_DEFAULT_MODEL;
const setStatus = function (t, kind) {
statusEl.textContent = t || '';
statusEl.className = 'nme-ai-status' + (kind ? ' nme-ai-status-' + kind : '');
};
const close = function () { modal.remove(); };
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
modal.querySelector('#nme-cfg-cancel').addEventListener('click', close);
modal.querySelector('#nme-cfg-save').addEventListener('click', function () {
aiCfg.url = urlEl.value.trim() || AI_DEFAULT_URL;
aiCfg.key = keyEl.value.trim();
aiCfg.model = modelEl.value.trim() || AI_DEFAULT_MODEL;
saveAiCfg();
close();
if (state.ctx && state.ctx.mode === 'detail') aiStatus(aiConfigured() ? 'AI 配置已保存' : '已保存(未填 Key)', 'ok');
});
modal.querySelector('#nme-cfg-test').addEventListener('click', async function () {
const btn = this;
btn.disabled = true;
setStatus('测试中…', 'busy');
try {
const txt = await callAi('只回复两个字:可用', {
maxTokens: 32,
temperature: 0,
cfg: {
url: urlEl.value.trim() || AI_DEFAULT_URL,
key: keyEl.value.trim(),
model: modelEl.value.trim() || AI_DEFAULT_MODEL
}
});
setStatus('连接成功:' + txt.trim().slice(0, 20), 'ok');
} catch (e) {
setStatus((e && e.message) || String(e), 'err');
} finally {
btn.disabled = false;
}
});
}
// ==================== 下载增强 ====================
// 参考 Greasy Fork 三个脚本的机制(见 reference/):
// - 官方 GenerateDownloadUrl 接口直接换直链,不抓下载页 DOM
// - 失败时回退到 ?tab=files&file_id=...&nmm=1 页面里的 data-download-url
// - 合集用 GraphQL collectionRevision(slug) 取 modFiles,逐个人工触发下载
// - 批量下载带间隔、连续下载上限、冷却、断点历史
const DL_GEN = '/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl';
const DL_HISTORY_KEY = 'nme.dlhistory';
const DL_RATE_LIMIT = 150; // 连续下载多少个后强制冷却
const DL_COOLDOWN = 300; // 冷却秒数
const DL_MAX_LOG = 80;
const DL_REPEAT_GUARD = 30000; // 同一文件 30 秒内不重复触发,避免重复扣次数
/**
* 下载错误分类(借鉴 Vortex 的边界处理):
* 可重试的(网络/接口偶发)与必须停下的(未登录/被拦截/限流/无权限)分开。
*/
class DlError extends Error {
constructor(code, message) {
super(message);
this.name = 'DlError';
this.code = code;
}
get fatal() {
return this.code === 'AUTH' || this.code === 'FORBIDDEN' ||
this.code === 'CLOUDFLARE' || this.code === 'RATE_LIMIT';
}
}
const dlRecent = {}; // fileId → 最近一次触发时间
function dlTooSoon(fileId) {
const t = dlRecent[fileId];
return !!t && (Date.now() - t) < DL_REPEAT_GUARD;
}
function markDl(fileId) { dlRecent[fileId] = Date.now(); }
const Q_MOD_FILES = `query ModFiles($modId: ID!, $gameId: ID!) {
modFiles(modId: $modId, gameId: $gameId) {
fileId name version sizeInBytes category primary totalDownloads uri
}
}`;
const Q_COLLECTION_MODS = `query CollectionRevisionMods($slug: String!, $domain: String) {
collectionRevision(slug: $slug, domainName: $domain, viewAdultContent: true) {
modCount revisionNumber totalSize
modFiles {
optional
fileId
file { name sizeInBytes mod { modId name game { id domainName } } }
}
}
}`;
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function isLoggedIn() {
// 页面未水合时 USER_ID 与登录链接都可能不存在,无法可靠判断;
// 只有能确定 USER_ID 时才拦截,其余交给接口 401/403 或文件页的登录链接提示。
if (typeof window.USER_ID !== 'undefined') return !!window.USER_ID;
return true;
}
function dlStatus(text, kind) {
const el = $('#nme-dl-status');
if (!el) return;
el.textContent = text || '';
el.className = 'nme-ai-status' + (kind ? ' nme-ai-status-' + kind : '');
}
/** 官方接口:fid + game_id → { url } */
async function generateDownloadUrl(fileId, gameId) {
let res;
try {
res = await fetch(DL_GEN, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
body: 'fid=' + encodeURIComponent(fileId) + '&game_id=' + encodeURIComponent(gameId)
});
} catch (e) {
throw new DlError('NETWORK', '网络错误,无法连接 Nexus');
}
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new DlError('AUTH', '没有下载权限(登录失效、需要 Premium,或作者未开放该文件)');
}
if (res.status === 429) throw new DlError('RATE_LIMIT', '请求过于频繁,Nexus 已限流');
throw new DlError('API', '生成下载链接失败(HTTP ' + res.status + ')');
}
let data;
try { data = await res.json(); } catch (e) { throw new DlError('API', '接口返回不是有效 JSON'); }
if (!data || !data.url) throw new DlError('API', '接口没有返回下载地址');
return data.url;
}
function decodeDlUrl(u) {
return String(u || '')
.replace(/\\\//g, '/')
.replace(/&/g, '&')
.replace(/&/g, '&')
.replace(/\\u0026/gi, '&');
}
/** 从文件页 HTML 兜底提取直链 */
function extractDownloadUrl(html) {
const patterns = [
/const downloadUrl\s*=\s*['"]([^'"]+)['"]/,
/id="slowDownloadButton"[^>]*data-download-url="([^"]+)"/,
/data-download-url\s*=\s*["']([^"']+)["']/,
/href\s*=\s*["'](nxm:\/\/[^"']+)["']/i
];
for (let i = 0; i < patterns.length; i++) {
const m = html.match(patterns[i]);
if (m && m[1]) return decodeDlUrl(m[1]);
}
return '';
}
/** 统一取直链:官方接口优先,HTML 兜底 */
async function resolveDownloadUrl(domain, modId, fileId, gameId) {
if (!isLoggedIn()) throw new DlError('AUTH', '请先登录 Nexus Mods 账号');
let apiError = null;
try {
return await generateDownloadUrl(fileId, gameId);
} catch (e) {
apiError = e;
if (e && e.code === 'NETWORK') await sleep(800);
}
const pageUrl = 'https://www.nexusmods.com/' + encodeURIComponent(domain) + '/mods/' + modId +
'?tab=files&file_id=' + encodeURIComponent(fileId) + '&nmm=1';
let html = '', status = 0;
try {
const res = await fetch(pageUrl, { credentials: 'include' });
status = res.status;
html = res.ok ? await res.text() : '';
} catch (e) {
throw new DlError('NETWORK', '网络错误,无法打开文件页');
}
if (!html) {
if (status === 429) throw new DlError('RATE_LIMIT', '请求过于频繁,Nexus 已限流');
if (status === 401 || status === 403) {
throw new DlError('FORBIDDEN', '没有下载权限(登录失效、需要 Premium,或作者未开放该文件)');
}
throw apiError || new DlError('API', '文件页返回空内容(HTTP ' + status + ')');
}
if (html.includes('class="replaced-login-link"')) throw new DlError('AUTH', '请先登录 Nexus Mods 账号');
if (html.includes('Just a moment')) {
throw new DlError('CLOUDFLARE', '触发 Cloudflare 校验,请手动打开该文件页完成校验');
}
if (html.includes('temporarily suspended')) throw new DlError('RATE_LIMIT', 'Nexus 临时限制了下载,请稍后再试');
const url = extractDownloadUrl(html);
if (!url) {
if (apiError) throw apiError;
throw new DlError('NO_URL', '未取到下载地址(该文件可能需要手动确认或 Premium)');
}
return url;
}
/** 网络类错误重试一次;被拦截/无权限不重试 */
async function resolveWithRetry(domain, modId, fileId, gameId) {
try {
return await resolveDownloadUrl(domain, modId, fileId, gameId);
} catch (e) {
if (e && e.code === 'NETWORK') {
await sleep(1500);
return await resolveDownloadUrl(domain, modId, fileId, gameId);
}
throw e;
}
}
/** 触发下载:a[download] 点击,不离开当前页;nxm:// 交给 Vortex */
function triggerDownload(url, fileName) {
if (/^nxm:\/\//i.test(url)) { location.href = url; return; }
const a = document.createElement('a');
a.href = url;
// 没有扩展名时交给 Content-Disposition 决定,避免存成无后缀文件
const name = (fileName || '').trim();
if (name && /\.[a-z0-9]{1,8}$/i.test(name)) a.download = name;
a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(function () { if (a.parentElement) a.remove(); }, 1000);
}
// ---------- 模组详情页:文件列表 + 一键下载 ----------
function mountDetailDownload(ctx) {
if (!ctx || ctx.mode !== 'detail' || $('#nme-dl-bar')) return;
const sec = fabSection(FAB_SEC_DL);
if (!sec) return;
const bar = document.createElement('div');
bar.id = 'nme-dl-bar';
bar.innerHTML = [
'',
' N网 下载',
' 官方接口直链 · 个人文件下载',
'
',
'',
' ',
' ',
'
',
''
].join('');
sec.appendChild(bar);
const refresh = bar.querySelector('#nme-dl-refresh');
if (refresh) refresh.addEventListener('click', function () { loadDetailFiles(ctx); });
const list = bar.querySelector('#nme-dl-list');
if (list) {
list.addEventListener('click', function (e) {
const btn = e.target.closest('.nme-dl-go');
if (!btn) return;
const idx = parseInt(btn.getAttribute('data-idx'), 10);
const f = state.dlFiles[idx];
if (f) runSingleDownload(ctx, f, btn);
});
}
loadDetailFiles(ctx);
}
async function loadDetailFiles(ctx) {
const list = $('#nme-dl-list');
if (!list) return;
list.innerHTML = '正在读取文件列表…
';
dlStatus('', '');
try {
const mod = await ensureModDetail(ctx);
const gameId = mod.game && mod.game.id;
if (!gameId) throw new Error('未取到 gameId');
const d = await gqlRequest(Q_MOD_FILES, { modId: ctx.modId, gameId: gameId }, 'ModFiles');
const files = (d && d.modFiles) || [];
state.dlFiles = files;
state.dlGameId = gameId;
renderDetailFiles(files);
} catch (e) {
list.innerHTML = '' + escapeHtml((e && e.message) || String(e)) + '
';
}
}
function renderDetailFiles(files) {
const list = $('#nme-dl-list');
if (!list) return;
if (!files.length) {
list.innerHTML = '该模组还没有公开文件。
';
return;
}
list.innerHTML = files.map(function (f, i) {
const tag = f.category ? '' + escapeHtml(f.category) + '' : '';
const meta = [
f.version ? '版本 ' + f.version : '',
f.sizeInBytes ? fmtSize(f.sizeInBytes) : '',
f.totalDownloads != null ? '下载 ' + fmtNum(f.totalDownloads) : ''
].filter(Boolean).join(' · ');
return '' +
'
' +
'
' + escapeHtml(f.name || ('文件 #' + f.fileId)) + tag + '
' +
'
' + escapeHtml(meta) + '
' +
'
' +
'
' +
'
';
}).join('');
}
async function runSingleDownload(ctx, file, btn) {
if (dlTooSoon(file.fileId)) {
dlStatus('刚刚已触发过该文件,请 30 秒后再试', 'warn');
return;
}
const old = btn.textContent;
btn.disabled = true;
btn.textContent = '获取中…';
dlStatus('正在获取「' + (file.name || file.fileId) + '」的下载地址…', 'busy');
try {
const url = await resolveWithRetry(ctx.domain, ctx.modId, file.fileId, state.dlGameId);
triggerDownload(url, file.name);
markDl(file.fileId);
dlStatus('已开始下载:' + (file.name || file.fileId), 'ok');
} catch (e) {
dlStatus((e && e.message) || String(e), 'err');
} finally {
btn.disabled = false;
btn.textContent = old;
}
}
// ---------- 合集页:批量下载 ----------
function dlHistoryLoad() {
try { return JSON.parse(GM_getValue(DL_HISTORY_KEY, '{}') || '{}'); } catch (e) { return {}; }
}
function dlHistorySave(h) {
try { GM_setValue(DL_HISTORY_KEY, JSON.stringify(h)); } catch (e) { /* 忽略 */ }
}
function dlHistoryKey(ctx) {
return (ctx.domain || '') + '/' + (ctx.collectionSlug || '');
}
function dlLog(text, kind) {
const box = $('#nme-dl-log');
if (!box) return;
const line = document.createElement('div');
if (kind) line.className = 'nme-dl-log-' + kind;
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + text;
box.appendChild(line);
while (box.children.length > DL_MAX_LOG) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
function mountCollectionDownload(ctx) {
if (!ctx || ctx.mode !== 'collection' || $('#nme-dl-bar')) return;
const sec = fabSection(FAB_SEC_DL);
if (!sec) return;
const bar = document.createElement('div');
bar.id = 'nme-dl-bar';
bar.innerHTML = [
'',
' N网 合集下载',
' 正在读取合集…',
'
',
'',
' ',
' ',
' ',
' ',
' ',
' ',
'
',
'',
''
].join('');
sec.appendChild(bar);
const gapEl = bar.querySelector('#nme-dl-gap');
gapEl.value = CFG.dlGap;
gapEl.addEventListener('change', function () {
const v = Math.max(1, Math.min(60, parseInt(gapEl.value, 10) || 5));
CFG.dlGap = v;
gapEl.value = v;
saveCfg();
});
bar.querySelector('#nme-dl-start').addEventListener('click', function () { startCollectionDownload(ctx); });
bar.querySelector('#nme-dl-pause').addEventListener('click', function () {
state.dlPaused = !state.dlPaused;
this.textContent = state.dlPaused ? '继续' : '暂停';
dlStatus(state.dlPaused ? '已暂停' : '继续下载…', state.dlPaused ? 'warn' : 'busy');
});
bar.querySelector('#nme-dl-stop').addEventListener('click', function () {
state.dlStop = true;
dlStatus('正在停止…', 'warn');
});
bar.querySelector('#nme-dl-clear').addEventListener('click', function () {
const h = dlHistoryLoad();
delete h[dlHistoryKey(ctx)];
dlHistorySave(h);
dlLog('已清空本合集下载历史', 'ok');
});
loadCollection(ctx);
}
async function loadCollection(ctx) {
const sub = $('#nme-dl-sub');
try {
const d = await gqlRequest(Q_COLLECTION_MODS, {
slug: ctx.collectionSlug,
domain: ctx.domain || null
}, 'CollectionRevisionMods');
const rev = d && d.collectionRevision;
if (!rev) throw new Error('未找到合集数据');
const mods = (rev.modFiles || []).map(function (mf) {
return {
fileId: mf.fileId,
name: (mf.file && mf.file.name) || '',
sizeInBytes: mf.file && mf.file.sizeInBytes,
modId: mf.file && mf.file.mod && mf.file.mod.modId,
domain: (mf.file && mf.file.mod && mf.file.mod.game && mf.file.mod.game.domainName) || ctx.domain,
gameId: mf.file && mf.file.mod && mf.file.mod.game && mf.file.mod.game.id,
optional: !!mf.optional
};
}).filter(function (m) { return m.fileId && m.modId && m.gameId; });
state.dlQueue = mods;
state.dlTotal = mods.length;
const mandatory = mods.filter(function (m) { return !m.optional; }).length;
if (sub) sub.textContent = mods.length + ' 个文件(必装 ' + mandatory + ' / 可选 ' + (mods.length - mandatory) + ')';
const start = $('#nme-dl-start');
if (start) start.disabled = mods.length === 0;
dlLog('已读取合集:' + mods.length + ' 个文件');
} catch (e) {
if (sub) sub.textContent = '读取合集失败';
dlStatus((e && e.message) || String(e), 'err');
}
}
function setDlProgress(done, total) {
const fill = $('#nme-dl-prog-fill');
if (fill) fill.style.width = (total ? Math.round(done / total * 100) : 0) + '%';
}
function setDlButtons(running) {
const start = $('#nme-dl-start');
const pause = $('#nme-dl-pause');
const stop = $('#nme-dl-stop');
if (start) { start.disabled = running; start.textContent = running ? '下载中…' : '开始下载'; }
if (pause) { pause.disabled = !running; pause.textContent = '暂停'; }
if (stop) stop.disabled = !running;
}
async function startCollectionDownload(ctx) {
openFab(FAB_SEC_DL);
if (state.dlRunning) return;
if (!state.dlQueue.length) { await loadCollection(ctx); }
const queue = state.dlQueue || [];
if (!queue.length) { dlStatus('合集里没有可下载的文件', 'err'); return; }
if (!isLoggedIn()) { dlStatus('请先登录 Nexus Mods 账号', 'err'); return; }
const ok = window.confirm(
'即将逐个请求下载合集里的 ' + queue.length + ' 个文件。\n\n' +
'· 会在当前页连续触发浏览器下载,出现「允许下载多个文件」时请允许;\n' +
'· 免费账号会走慢速通道,间隔太短容易触发站点限流;\n' +
'· 批量下载可能不符合 Nexus 的使用条款,请自行判断。\n\n' +
'继续?'
);
if (!ok) return;
const history = dlHistoryLoad();
const key = dlHistoryKey(ctx);
if ((history[key] || []).length) {
const skip = window.confirm('历史里已有 ' + history[key].length + ' 个已下载文件。\n\n确定 = 跳过它们,只下缺的;取消 = 重新下载全部。');
if (!skip) { history[key] = []; dlHistorySave(history); }
}
const done = history[key] || [];
state.dlRunning = true;
state.dlStop = false;
state.dlPaused = false;
state.dlDone = 0;
state.dlCount = 0;
let fails = 0;
setDlButtons(true);
dlStatus('开始下载…', 'busy');
setDlProgress(0, queue.length);
dlLog('开始下载,共 ' + queue.length + ' 个文件');
for (let i = 0; i < queue.length; i++) {
if (state.dlStop) break;
while (state.dlPaused && !state.dlStop) await sleep(500);
if (state.dlStop) break;
const f = queue[i];
if (done.indexOf(f.fileId) >= 0) {
dlLog('[' + (i + 1) + '/' + queue.length + '] 跳过(历史已下载):' + f.name);
state.dlDone++;
setDlProgress(state.dlDone, queue.length);
continue;
}
if (dlTooSoon(f.fileId)) {
dlLog('[' + (i + 1) + '/' + queue.length + '] 跳过(刚刚已手动触发):' + f.name);
state.dlDone++;
setDlProgress(state.dlDone, queue.length);
continue;
}
if (state.dlCount >= DL_RATE_LIMIT) {
dlLog('已连续下载 ' + DL_RATE_LIMIT + ' 个,冷却 ' + DL_COOLDOWN + ' 秒…', 'warn');
let left = DL_COOLDOWN;
while (left > 0 && !state.dlStop) {
dlStatus('安全冷却中,剩余 ' + left + ' 秒', 'warn');
await sleep(1000);
left--;
}
state.dlCount = 0;
}
if (state.dlStop) break;
dlStatus('[' + (i + 1) + '/' + queue.length + '] 正在获取:' + f.name, 'busy');
try {
const url = await resolveWithRetry(f.domain, f.modId, f.fileId, f.gameId);
triggerDownload(url, f.name);
markDl(f.fileId);
done.push(f.fileId);
history[key] = done;
dlHistorySave(history);
state.dlCount++;
state.dlDone++;
fails = 0;
dlLog('[' + (i + 1) + '/' + queue.length + '] 已开始下载:' + f.name, 'ok');
} catch (e) {
const msg = (e && e.message) || String(e);
const fatal = !!(e && e.fatal);
fails++;
dlLog('[' + (i + 1) + '/' + queue.length + '] 失败:' + f.name + ' — ' + msg, 'err');
dlStatus(msg, 'err');
if (fatal || fails >= 3) {
dlLog(fatal ? '遇到「' + msg + '」,已停止批量下载' : '连续 3 个文件失败,已停止批量下载', 'err');
state.dlStop = true;
break;
}
}
setDlProgress(state.dlDone, queue.length);
if (i < queue.length - 1 && !state.dlStop) {
const gap = Math.max(1, CFG.dlGap || 5) * 1000;
await sleep(gap);
}
}
state.dlRunning = false;
state.dlPaused = false;
setDlButtons(false);
if (state.dlStop) dlStatus('已停止(已下载的会记入历史)', 'warn');
else dlStatus('批量下载结束:' + state.dlDone + ' / ' + queue.length + '(含历史跳过)', 'ok');
setDlProgress(state.dlDone, queue.length);
}
// ---------- 下载等待页自动跳过 ----------
function setupDownloadSkip() {
if (!CFG.skipWait) return;
const isWaitPage =
/\/Core\/Libs\/Common\/Widgets\/DownloadPopUp/i.test(location.pathname + location.search) ||
(/[?&]file_id=\d+/i.test(location.search) && /[?&]nmm=1/i.test(location.search));
if (!isWaitPage) return;
const guard = 'nme.skip.' + location.href;
try { if (sessionStorage.getItem(guard)) return; } catch (e) { /* 忽略 */ }
let tries = 0;
const tick = function () {
const btn = document.querySelector('#slowDownloadButton[data-download-url], [data-download-url]');
const url = btn && decodeDlUrl(btn.getAttribute('data-download-url') || '');
if (url && /^(https?:|nxm:)/i.test(url)) {
try { sessionStorage.setItem(guard, '1'); } catch (e) { /* 忽略 */ }
location.href = url;
return;
}
tries++;
if (tries < 120) setTimeout(tick, 250);
};
tick();
}
// ==================== 列表页:AI 解读当前页 ====================
function renderPageDigest(data) {
const groups = (data.groups || []).map(function (g) {
const picks = (g.picks || []).map(function (p) {
const name = escapeHtml(p.name || ('#' + p.id));
const id = parseInt(p.id, 10);
const dom = state.ctx && state.ctx.domain;
const title = (dom && id > 0)
? '' + name + ''
: name;
return '' + title + '' + escapeHtml(p.why || '') + '';
}).join('');
return '' + escapeHtml(g.theme || '推荐') + '
';
}).join('');
return '' +
'
AI 解读本页
' +
(groups || '
AI 没有给出推荐。
') +
(data.tip ? '
提示
' + escapeHtml(data.tip) + '
' : '') +
'
';
}
async function runPageDigest() {
const btn = $('#nme-ai-digest');
const out = $('#nme-ai-digest-out');
if (!out) return;
if (!aiConfigured()) { openAiSettings(); return; }
if (!state.mods || !state.mods.length) {
out.hidden = false;
out.innerHTML = '当前还没有加载到模组,先滚动加载一些再试。
';
return;
}
const mods = state.mods.slice(0, 60);
const fingerprint = 'pg.' + normKey(state.ctx.domain || '') + '.' + mods.map(function (m) { return m.modId; }).join('-');
const cached = aiCacheGet(fingerprint);
if (cached) {
out.hidden = false;
out.innerHTML = renderPageDigest(cached.v);
return;
}
if (btn) btn.disabled = true;
out.hidden = false;
out.innerHTML = 'AI 正在解读本页 ' + mods.length + ' 个模组…
';
try {
const lines = mods.map(function (m) {
return m.modId + ' | ' + (m.name || '') + ' | ' + (m.summary || '').slice(0, 90) +
' | 下载 ' + (m.downloads || 0) + ' | 点赞 ' + (m.endorsements || 0) +
' | ' + (m.modCategory && m.modCategory.name || '');
}).join('\n');
const prompt = '下面是 Nexus Mods 一个游戏模组列表页当前加载的模组(格式:id | 名称 | 简介 | 下载 | 点赞 | 分类)。\n' +
'请从中挑选最值得安装的 5-8 个,并按用途分组。输出 JSON(只输出 JSON,不要 markdown 代码块):\n' +
'{"groups":[{"theme":"分组名","picks":[{"id":123,"name":"模组名","why":"一句话推荐理由"}]}],"tip":"一句总体建议"}\n\n' +
'只从给定列表里选,id 必须原样保留。列表:\n' + lines;
const text = await callAi(prompt, { temperature: 0.3, maxTokens: 1600 });
const data = parseAiJson(text);
aiCacheSet(fingerprint, data);
out.innerHTML = renderPageDigest(data);
} catch (e) {
out.innerHTML = '' + escapeHtml((e && e.message) || String(e)) + '
';
} finally {
if (btn) btn.disabled = false;
}
}
// ==================== 中文找游戏:工具条交互 ====================
function setCnMsg(text, kind) {
const el = $('#nme-cn-msg');
if (!el) return;
el.textContent = text || '';
el.className = 'nme-cn-msg' + (kind ? ' nme-cn-msg-' + kind : '');
}
function renderGameResults(list) {
const box = $('#nme-cn-results');
if (!box) return;
box.hidden = false;
if (!list || !list.length) {
box.innerHTML = '没有匹配到游戏。可以试试英文名或 Nexus 游戏域名(如 stellarblade)。
';
return;
}
box.innerHTML = list.map(function (g) {
const href = 'https://www.nexusmods.com/games/' + encodeURIComponent(g.domain) + '/mods';
const count = g.modCount == null ? '' : '' + fmtNum(g.modCount) + ' 个模组';
return '' +
'' + escapeHtml(g.name) + '' +
'' + escapeHtml(g.domain) + '' +
count +
'' + escapeHtml(g.source || '') + '' +
'';
}).join('');
}
async function runCnGameSearch() {
const input = $('#nme-cn');
const btn = $('#nme-cn-go');
const term = (input && input.value || '').trim();
if (!term) return;
if (btn) btn.disabled = true;
setCnMsg('正在解析「' + term + '」…', 'busy');
const box = $('#nme-cn-results');
if (box) { box.hidden = false; box.innerHTML = ''; }
try {
const list = await resolveGame(term);
renderGameResults(list);
if (!list.length) setCnMsg('未找到对应游戏', 'warn');
else setCnMsg('找到 ' + list.length + ' 个候选,点选进入游戏模组页', 'ok');
} catch (e) {
setCnMsg((e && e.message) || String(e), 'err');
} finally {
if (btn) btn.disabled = false;
}
}
// ==================== 页面上下文 ====================
/**
* 解析当前页面属于哪类列表页,以及 URL 上已有的筛选条件。
* 只认站点自己已经在用的 URL 参数,不臆造参数名。
*/
function getContext() {
const u = new URL(location.href);
const path = u.pathname;
let mode = null, domain = null, modId = 0, collectionSlug = null;
const mGame = path.match(/^\/games\/([^/]+)\/mods\/?$/);
const mLegacy = path.match(/^\/([^/]+)\/mods\/?$/);
const mDetail = path.match(/^\/games\/([^/]+)\/mods\/(\d+)/) || path.match(/^\/([^/]+)\/mods\/(\d+)/);
const mColl = path.match(/^\/games\/([^/]+)\/collections\/([^/?#]+)/) || path.match(/^\/([^/]+)\/collections\/([^/?#]+)/);
if (mDetail) { mode = 'detail'; domain = mDetail[1]; modId = parseInt(mDetail[2], 10) || 0; }
else if (mColl) { mode = 'collection'; domain = mColl[1]; collectionSlug = mColl[2]; }
else if (mGame) { mode = 'list'; domain = mGame[1]; }
else if (mLegacy) { mode = 'list'; domain = mLegacy[1]; }
else if (/^\/search\/?$/.test(path) || path.indexOf('/search') === 0) {
mode = 'search';
}
if (!mode) return null;
const sp = u.searchParams;
const categoryName = sp.getAll('categoryName').filter(Boolean);
const tagNames = sp.getAll('tag').filter(Boolean);
const languageName = sp.getAll('languageName').filter(Boolean);
const keyword = sp.get('keyword') || '';
return {
mode: mode,
domain: domain,
modId: modId,
collectionSlug: collectionSlug,
keyword: keyword,
categoryName: categoryName,
tags: tagNames,
languageName: languageName,
page: parseInt(sp.get('page') || '1', 10) || 1
};
}
// ==================== 增强视图 ====================
const SORTS = [
['createdAt', '发布日期'],
['updatedAt', '最近更新'],
['downloads', '下载量'],
['uniqueDownloads', '独立下载量'],
['endorsements', '点赞数'],
['name', '模组名称'],
['size', '文件大小'],
['lastComment', '最近评论'],
['random', '随机惊喜']
];
const PAGE_SIZES = [20, 40, 60, 80];
const SORT_KEYS = SORTS.map(function (s) { return s[0]; });
function normChips(c) {
c = (c && typeof c === 'object') ? c : {};
return { zh: !!c.zh, zht: !!c.zht, tr: !!c.tr, hot: !!c.hot };
}
/** 修正旧版本或手改过的配置,避免把非法参数发给 GraphQL */
function sanitizeCfg() {
const size = Number(CFG.pageSize);
CFG.pageSize = PAGE_SIZES.indexOf(size) >= 0 ? size : DEFAULTS.pageSize;
if (CFG.view !== 'native' && CFG.view !== 'enhanced') CFG.view = DEFAULTS.view;
if (SORT_KEYS.indexOf(CFG.sortBy) < 0) CFG.sortBy = DEFAULTS.sortBy;
if (CFG.sortDir !== 'ASC' && CFG.sortDir !== 'DESC') CFG.sortDir = DEFAULTS.sortDir;
CFG.minDownloads = Math.max(0, parseInt(CFG.minDownloads, 10) || 0);
CFG.minEndorsements = Math.max(0, parseInt(CFG.minEndorsements, 10) || 0);
CFG.maxSizeMB = Math.max(0, parseFloat(CFG.maxSizeMB) || 0);
CFG.dlGap = Math.min(60, Math.max(1, parseInt(CFG.dlGap, 10) || DEFAULTS.dlGap));
CFG.keyword = String(CFG.keyword || '');
CFG.zhUI = !!CFG.zhUI;
CFG.badges = !!CFG.badges;
CFG.skipWait = !!CFG.skipWait;
CFG.chips = normChips(CFG.chips);
state.chips = normChips(CFG.chips);
saveCfg();
}
const state = {
ctx: null,
offset: 0,
total: 0,
loaded: 0,
loading: false,
done: false,
error: '',
retried: false,
chips: normChips(CFG.chips),
tpl: null,
mods: [],
modCache: null,
modLoading: null,
io: null,
// 下载增强
dlFiles: [],
dlGameId: 0,
dlQueue: [],
dlTotal: 0,
dlRunning: false,
dlPaused: false,
dlStop: false,
dlDone: 0,
dlCount: 0,
// 下载历史
histOnly: false,
// React 水合:站点是否走 React、是否已提交、容器在哪
reactApp: false,
reactSeen: false,
hydrated: false,
reactContainer: null
};
function buildVariables(offset) {
const ctx = state.ctx;
const chips = state.chips;
const facets = {
categoryName: ctx.categoryName.slice(),
languageName: ctx.languageName.slice(),
tag: ctx.tags.slice()
};
if (chips.zh && facets.languageName.indexOf('Simplified Chinese') < 0) {
facets.languageName.push('Simplified Chinese');
}
if (chips.zht && facets.languageName.indexOf('Traditional Chinese') < 0) {
facets.languageName.push('Traditional Chinese');
}
if (chips.tr && facets.tag.indexOf('Translation') < 0) {
facets.tag.push('Translation');
}
const filter = {
adultContent: [{ op: 'EQUALS', value: false }],
filter: [],
gameDomainName: ctx.mode === 'list' ? [{ op: 'EQUALS', value: ctx.domain }] : [],
name: [],
op: 'AND'
};
const kw = (CFG.keyword || '').trim() || ctx.keyword;
if (kw) {
// 名称(WILDCARD)与描述(MATCHES)取并集,再与其它条件 AND 组合
filter.filter = [{
op: 'OR',
filter: [
{ name: [{ op: 'WILDCARD', value: kw }] },
{ description: [{ op: 'MATCHES', value: kw }] }
]
}];
}
if (ctx.mode === 'search') filter.status = [{ op: 'EQUALS', value: 'published' }];
const minDl = state.chips.hot ? Math.max(CFG.minDownloads || 0, 1000) : (CFG.minDownloads || 0);
if (minDl > 0) filter.downloads = [{ op: 'GTE', value: minDl }];
if (CFG.minEndorsements > 0) filter.endorsements = [{ op: 'GTE', value: CFG.minEndorsements }];
if (CFG.maxSizeMB > 0) filter.fileSize = [{ op: 'LTE', value: Math.round(CFG.maxSizeMB * 1024 * 1024) }];
const sort = {};
sort[CFG.sortBy] = { direction: CFG.sortDir };
return {
count: CFG.pageSize,
offset: offset,
facets: facets,
filter: filter,
postFilter: {},
sort: sort
};
}
function makeTile(mod) {
const tpl = state.tpl;
const tile = tpl.cloneNode(true);
// 去掉原生菜单按钮(headlessui 依赖 React,克隆后无功能且 id 重复)
const menuBtn = tile.querySelector('button[aria-label="Mod options"]');
if (menuBtn && menuBtn.parentElement) menuBtn.parentElement.removeChild(menuBtn);
// 模板可能已经被原生视图的徽章逻辑装饰过,克隆后要先清掉,避免徽章重复
$$('.nme-badges', tile).forEach(function (h) { h.remove(); });
tile.removeAttribute('data-nme-badged');
const setText = (sel, text) => {
const el = tile.querySelector(sel);
if (el) el.textContent = text;
return el;
};
// 缩略图
const thumbLink = tile.querySelector('a[href*="/mods/"]');
const thumbImg = tile.querySelector('img');
if (thumbLink) thumbLink.setAttribute('href', 'https://www.nexusmods.com/' + (mod.game && mod.game.domainName ? mod.game.domainName : '') + '/mods/' + mod.modId);
if (thumbImg) {
if (mod.thumbnailUrl) thumbImg.setAttribute('src', mod.thumbnailUrl);
thumbImg.setAttribute('alt', mod.name || '');
}
// 标题
const title = tile.querySelector('[data-e2eid="mod-tile-title"]');
if (title) {
title.textContent = mod.name || '';
title.setAttribute('href', 'https://www.nexusmods.com/' + (mod.game && mod.game.domainName ? mod.game.domainName : '') + '/mods/' + mod.modId);
}
// 上传者
const userLink = tile.querySelector('[data-e2eid="user-link"]');
if (userLink) {
const uname = (mod.uploader && mod.uploader.name) || '—';
userLink.setAttribute('href', 'https://www.nexusmods.com/profile/' + encodeURIComponent(uname));
const span = userLink.querySelector('span');
if (span) span.textContent = uname;
const avatar = userLink.querySelector('img[data-testid="user-link-avatar"]');
if (avatar && mod.uploader && mod.uploader.avatar) avatar.setAttribute('src', mod.uploader.avatar);
}
// 分类
const cat = tile.querySelector('[data-e2eid="mod-tile-category"]');
if (cat) {
const cname = (mod.modCategory && mod.modCategory.name) || '';
cat.textContent = cname;
if (cname && mod.game && mod.game.domainName) {
cat.setAttribute('href', 'https://www.nexusmods.com/games/' + mod.game.domainName + '/mods?categoryName=' + encodeURIComponent(cname));
}
}
// 时间
const upd = tile.querySelector('[data-e2eid="mod-tile-updated"] time');
if (upd) { upd.textContent = fmtRelative(mod.updatedAt); upd.setAttribute('datetime', mod.updatedAt || ''); }
const upl = tile.querySelector('[data-e2eid="mod-tile-uploaded"] time');
if (upl) { upl.textContent = fmtDate(mod.createdAt); upl.setAttribute('datetime', mod.createdAt || ''); }
// 摘要
const sum = tile.querySelector('[data-e2eid="mod-tile-summary"]');
if (sum) sum.textContent = mod.summary || '';
// 数值
setText('[data-e2eid="mod-tile-endorsements"]', fmtNum(mod.endorsements));
setText('[data-e2eid="mod-tile-downloads"]', fmtNum(mod.downloads));
setText('[data-e2eid="mod-tile-file-size"]', fmtSize(mod.fileSize));
// 语言徽章
const badges = detectBadges(mod);
if (badges.length && title && title.parentElement) {
const holder = document.createElement('span');
holder.className = 'nme-badges';
for (let i = 0; i < badges.length; i++) {
const b = document.createElement('span');
b.className = 'nme-badge ' + badges[i].cls;
b.textContent = badges[i].text;
holder.appendChild(b);
}
title.parentElement.insertBefore(holder, title.nextSibling);
}
return tile;
}
function renderStatus() {
const status = $('#nme-status');
const msg = $('#nme-msg');
const total = $('#nme-total');
const loaded = $('#nme-loaded');
if (!status) return;
if (total) total.textContent = state.total ? fmtNum(state.total) : '—';
if (loaded) loaded.textContent = fmtNum(state.loaded);
if (state.error) {
status.className = 'nme-status nme-status-err';
if (msg) {
msg.textContent = '';
msg.appendChild(document.createTextNode('加载失败:' + state.error + ' '));
const retry = document.createElement('button');
retry.className = 'nme-btn';
retry.type = 'button';
retry.textContent = '重试';
retry.addEventListener('click', function () {
state.retried = false;
loadPage(true);
});
msg.appendChild(retry);
}
} else if (state.done) {
status.className = 'nme-status';
if (msg) msg.textContent = '已全部加载。';
} else if (state.loading) {
status.className = 'nme-status';
if (msg) msg.textContent = '加载中…';
} else {
status.className = 'nme-status';
if (msg) msg.textContent = '继续滚动加载更多。';
}
}
async function loadPage(reset) {
if (state.loading) return;
if (reset) {
state.offset = 0;
state.loaded = 0;
state.total = 0;
state.mods = [];
state.done = false;
state.error = '';
state.retried = false;
const grid = $('#nme-grid');
if (grid) grid.innerHTML = '';
}
if (state.done) return;
state.loading = true;
renderStatus();
try {
const data = await queryMods(buildVariables(state.offset));
state.error = '';
state.total = data.totalCount || 0;
const nodes = data.nodes || [];
const grid = $('#nme-grid');
if (grid) {
const frag = document.createDocumentFragment();
for (let i = 0; i < nodes.length; i++) frag.appendChild(makeTile(nodes[i]));
grid.appendChild(frag);
}
state.loaded += nodes.length;
state.offset += nodes.length;
for (let i = 0; i < nodes.length; i++) state.mods.push(nodes[i]);
if (nodes.length === 0 || state.offset >= state.total) state.done = true;
} catch (e) {
state.error = (e && e.message) || String(e);
// 站点偶发限流/断连(实测批量请求后会出现 Failed to fetch),自动重试一次
if (!state.retried) {
state.retried = true;
setTimeout(function () { loadPage(false); }, 1500);
return; // finally 会统一收尾
}
} finally {
state.loading = false;
renderStatus();
}
}
// ==================== 工具条 ====================
function buildPanel(ctx) {
const panel = document.createElement('div');
panel.id = 'nme-panel';
const sortOptions = SORTS.map(function (s) {
return '';
}).join('');
panel.innerHTML = [
'',
'准备中… 共 — 条结果 · 已加载 0 条
',
'',
'滚动到底部自动加载更多
'
].join('');
// 快捷筛选状态回显
for (const k in state.chips) {
if (state.chips[k]) {
const b = panel.querySelector('.nme-chip[data-chip="' + k + '"]');
if (b) b.classList.add('nme-chip-on');
}
}
return panel;
}
function escapeAttr(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/"/g, '"')
.replace(//g, '>');
}
function bindPanel(panel) {
const on = (sel, ev, fn) => {
const el = panel.querySelector(sel);
if (el) el.addEventListener(ev, fn);
};
on('#nme-size', 'change', function () {
CFG.pageSize = parseInt(this.value, 10) || 20;
saveCfg();
refreshCapsule();
loadPage(true);
});
on('#nme-sort', 'change', function () {
CFG.sortBy = this.value;
saveCfg();
refreshCapsule();
loadPage(true);
});
on('#nme-dir', 'click', function () {
CFG.sortDir = CFG.sortDir === 'DESC' ? 'ASC' : 'DESC';
this.textContent = CFG.sortDir === 'DESC' ? '降序' : '升序';
saveCfg();
refreshCapsule();
loadPage(true);
});
const kw = panel.querySelector('#nme-kw');
if (kw) {
kw.addEventListener('search', function () {
CFG.keyword = this.value;
saveCfg();
loadPage(true);
});
kw.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
CFG.keyword = this.value;
saveCfg();
loadPage(true);
}
});
}
const numChange = debounce(function () {
CFG.minDownloads = parseInt(panel.querySelector('#nme-dl').value, 10) || 0;
CFG.minEndorsements = parseInt(panel.querySelector('#nme-ed').value, 10) || 0;
CFG.maxSizeMB = parseInt(panel.querySelector('#nme-mb').value, 10) || 0;
saveCfg();
loadPage(true);
}, 400);
['#nme-dl', '#nme-ed', '#nme-mb'].forEach(function (sel) {
const el = panel.querySelector(sel);
if (el) el.addEventListener('input', numChange);
});
panel.querySelectorAll('.nme-chip').forEach(function (btn) {
btn.addEventListener('click', function () {
const key = this.getAttribute('data-chip');
state.chips[key] = !state.chips[key];
this.classList.toggle('nme-chip-on', state.chips[key]);
CFG.chips = normChips(state.chips);
saveCfg();
refreshCapsule();
loadPage(true);
});
});
on('#nme-reset', 'click', function () {
CFG.keyword = '';
CFG.minDownloads = 0;
CFG.minEndorsements = 0;
CFG.maxSizeMB = 0;
state.chips = { zh: false, zht: false, tr: false, hot: false };
CFG.chips = normChips(state.chips);
panel.querySelectorAll('.nme-chip').forEach(function (b) { b.classList.remove('nme-chip-on'); });
const k = panel.querySelector('#nme-kw'); if (k) k.value = '';
const d = panel.querySelector('#nme-dl'); if (d) d.value = '0';
const e2 = panel.querySelector('#nme-ed'); if (e2) e2.value = '0';
const m = panel.querySelector('#nme-mb'); if (m) m.value = '0';
saveCfg();
refreshCapsule();
loadPage(true);
});
on('#nme-view', 'click', function () {
setView(CFG.view === 'enhanced' ? 'native' : 'enhanced');
});
// —— 中文找游戏 ——
const cn = panel.querySelector('#nme-cn');
if (cn) {
cn.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); runCnGameSearch(); }
});
}
on('#nme-cn-go', 'click', runCnGameSearch);
on('#nme-ai-cfg2', 'click', openAiSettings);
on('#nme-ai-digest', 'click', runPageDigest);
// 关键词里出现中文时,顺手提示走「中文找游戏」
const kwEl = panel.querySelector('#nme-kw');
if (kwEl && cn) {
kwEl.addEventListener('input', function () {
if (CJK_RE.test(this.value)) {
cn.value = this.value;
setCnMsg('中文关键词在 N 网搜不到,改按「中文找游戏」解析', 'warn');
} else {
setCnMsg('', '');
}
});
kwEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && CJK_RE.test(this.value)) {
e.preventDefault();
cn.value = this.value;
runCnGameSearch();
}
});
}
}
let toastTimer = null;
/** 右下角提示,用于视图切换与菜单操作的结果反馈 */
function showToast(title, detail, kind) {
if (!document.body) return;
let t = $('#nme-toast');
if (!t) {
t = document.createElement('div');
t.id = 'nme-toast';
t.setAttribute('role', 'status');
t.setAttribute('aria-live', 'polite');
document.body.appendChild(t);
}
t.className = 'nme-toast nme-toast-' + (kind || 'enhanced') + ' nme-toast-on';
t.innerHTML = '' + escapeHtml(title) + '' + escapeHtml(detail || '') + '';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.classList.remove('nme-toast-on'); }, 3200);
}
/** 切换视图后给一次明确反馈,避免用户不知道刚才动了什么 */
function showModeToast(view) {
if (view === 'enhanced') {
showToast('增强视图已开启', CFG.pageSize + ' 条/页 · 无限滚动 · 中文筛选 · 中文找游戏', 'enhanced');
} else {
showToast('已切回原生视图', '使用站点默认 20 条/页与自带筛选,增强工具条已隐藏', 'native');
}
}
function setView(view) {
const prev = CFG.view;
CFG.view = view;
saveCfg();
const body = document.body;
if (!body) return;
body.classList.toggle('nme-view-enhanced', view === 'enhanced');
body.classList.toggle('nme-view-native', view === 'native');
const btn = $('#nme-view');
if (btn) btn.textContent = (view === 'enhanced') ? '切换到原生视图' : '开启增强视图';
// 同步右下角浮动胶囊的视图状态
const capBtn = $('#nme-cap-btn');
const capText = $('#nme-cap-text');
const capDot = $('#nme-cap-dot');
if (capBtn) {
capBtn.classList.toggle('nme-cap-on', view === 'enhanced');
capBtn.setAttribute('aria-pressed', view === 'enhanced' ? 'true' : 'false');
capBtn.setAttribute('title', view === 'enhanced'
? '当前:增强视图(已开启)· 点击展开快捷操作'
: '当前:原生视图(增强已关闭)· 点击展开快捷操作');
}
if (capText) capText.textContent = (view === 'enhanced') ? '增强视图' : '原生视图';
if (capDot) capDot.classList.toggle('nme-cap-dot-native', view !== 'enhanced');
if (view === 'enhanced' && state.loaded === 0 && state.ctx &&
(state.ctx.mode === 'list' || state.ctx.mode === 'search')) {
loadPage(true);
}
if (prev !== view) {
if ($('#nme-panel')) showModeToast(view);
else showToast('视图设置已保存', '回到列表或搜索页后生效', 'native');
}
refreshMenu();
}
// ==================== 右下角浮动胶囊 ====================
/**
* 常驻右下角的浮动胶囊:
* - 收起态:一个胶囊按钮,显示当前视图状态(绿点=增强,琥珀点=原生)
* - 展开态:一个快捷操作面板,根据当前页面类型提供不同入口
* 列表/搜索页:视图切换、每页条数、快捷筛选、排序、AI 解读本页
* 模组详情页:视图切换、AI 翻译、AI 分析
* 合集页:视图切换、批量下载
* 其它页:视图切换、AI 配置
* 始终挂在 body 上,不依赖增强视图面板是否存在。
*/
function mountCapsule() {
if ($('#nme-cap-btn')) return;
const ctx = state.ctx;
const mode = ctx ? ctx.mode : null;
const isList = mode === 'list' || mode === 'search';
// 与浮动按钮坞共用同一个容器,避免两个独立 fixed 元素互相遮挡
const dock = ensureDock();
// 收起态按钮
const btn = document.createElement('button');
btn.id = 'nme-cap-btn';
btn.type = 'button';
btn.innerHTML =
'' +
'增强视图' +
'▾';
// 展开态面板
const panel = document.createElement('div');
panel.id = 'nme-cap-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'N网增强 快捷操作');
let body = '';
body += 'N网增强 · 快捷操作' +
'
';
// 视图切换(所有页面都有)
body += '';
body += '';
body += '';
body += '
';
body += '';
if (isList) {
// 每页条数
body += '每页';
PAGE_SIZES.forEach(function (s) {
body += '';
});
body += '
';
// 快捷筛选
body += '筛选';
body += '';
body += '';
body += '';
body += '';
body += '
';
// 排序
body += '排序';
body += '';
body += '';
body += '
';
body += '';
// AI 解读本页
body += '';
body += '';
body += '
';
}
// 详情页 / 合集页的 AI 与下载入口已移到右下角浮动按钮坞,这里不再重复
// 通用:AI 配置
body += '';
body += '';
body += '
';
body += '点击胶囊按钮可收起;列表/搜索页的快捷操作即时生效。
';
panel.innerHTML = body;
dock.appendChild(panel);
dock.appendChild(btn);
// —— 事件绑定 ——
btn.addEventListener('click', function (e) {
e.stopPropagation();
// 面板互斥:打开快捷面板时收起 AI/下载面板
if (!dock.classList.contains('nme-cap-open')) closeFab();
dock.classList.toggle('nme-cap-open');
});
const closeBtn = panel.querySelector('.nme-cap-close');
if (closeBtn) closeBtn.addEventListener('click', function () { dock.classList.remove('nme-cap-open'); });
// 视图切换
panel.querySelectorAll('.nme-cap-view').forEach(function (b) {
b.addEventListener('click', function () {
const v = this.getAttribute('data-view');
if (v === 'enhanced' || v === 'native') setView(v);
dock.classList.remove('nme-cap-open');
});
});
if (isList) {
// 每页条数
panel.querySelectorAll('.nme-cap-size').forEach(function (b) {
b.addEventListener('click', function () {
const s = parseInt(this.getAttribute('data-size'), 10);
if (PAGE_SIZES.indexOf(s) >= 0) {
CFG.pageSize = s;
saveCfg();
refreshCapsule();
loadPage(true);
}
});
});
// 快捷筛选
panel.querySelectorAll('.nme-cap-fchip').forEach(function (b) {
b.addEventListener('click', function () {
const k = this.getAttribute('data-chip');
state.chips[k] = !state.chips[k];
CFG.chips = normChips(state.chips);
saveCfg();
refreshCapsule();
// 同步顶部工具条的 chip 状态
const tb = $('#nme-panel .nme-chip[data-chip="' + k + '"]');
if (tb) tb.classList.toggle('nme-chip-on', state.chips[k]);
loadPage(true);
});
});
// 排序
const sortSel = panel.querySelector('.nme-cap-sort');
if (sortSel) sortSel.addEventListener('change', function () {
CFG.sortBy = this.value;
saveCfg();
// 同步顶部下拉
const tb = $('#nme-sort');
if (tb) tb.value = CFG.sortBy;
loadPage(true);
});
const dirBtn = panel.querySelector('.nme-cap-dir');
if (dirBtn) dirBtn.addEventListener('click', function () {
CFG.sortDir = CFG.sortDir === 'DESC' ? 'ASC' : 'DESC';
this.textContent = CFG.sortDir === 'DESC' ? '降序' : '升序';
saveCfg();
const tb = $('#nme-dir');
if (tb) tb.textContent = CFG.sortDir === 'DESC' ? '降序' : '升序';
loadPage(true);
});
// AI 解读本页
const digestBtn = panel.querySelector('.nme-cap-digest');
if (digestBtn) digestBtn.addEventListener('click', function () {
dock.classList.remove('nme-cap-open');
runPageDigest();
});
}
// AI 配置
const aicfgBtn = panel.querySelector('.nme-cap-aicfg');
if (aicfgBtn) aicfgBtn.addEventListener('click', function () {
dock.classList.remove('nme-cap-open');
openAiSettings();
});
refreshCapsule();
}
/** 同步胶囊里各按钮的激活态(视图、每页条数、筛选、排序方向) */
function refreshCapsule() {
const cap = $('#nme-dock');
if (!cap) return;
// 视图按钮
cap.querySelectorAll('.nme-cap-view').forEach(function (b) {
b.classList.toggle('nme-cap-active', b.getAttribute('data-view') === CFG.view);
});
// 每页条数
cap.querySelectorAll('.nme-cap-size').forEach(function (b) {
b.classList.toggle('nme-cap-active', parseInt(b.getAttribute('data-size'), 10) === CFG.pageSize);
});
// 快捷筛选
cap.querySelectorAll('.nme-cap-fchip').forEach(function (b) {
b.classList.toggle('nme-cap-on', !!state.chips[b.getAttribute('data-chip')]);
});
// 排序下拉与方向
const sortSel = cap.querySelector('.nme-cap-sort');
if (sortSel) sortSel.value = CFG.sortBy;
const dirBtn = cap.querySelector('.nme-cap-dir');
if (dirBtn) dirBtn.textContent = CFG.sortDir === 'DESC' ? '降序' : '升序';
// 胶囊按钮的视图点颜色
const dot = $('#nme-cap-dot');
if (dot) dot.classList.toggle('nme-cap-dot-native', CFG.view !== 'enhanced');
const capBtn = $('#nme-cap-btn');
if (capBtn) capBtn.classList.toggle('nme-cap-on', CFG.view === 'enhanced');
const capText = $('#nme-cap-text');
if (capText) capText.textContent = (CFG.view === 'enhanced') ? '增强视图' : '原生视图';
}
// ==================== 样式 ====================
function addStyle() {
const css = [
'body.nme-view-enhanced [data-nme-native="1"]{display:none !important;}',
'body.nme-view-enhanced .mods-grid{display:none !important;}',
'body.nme-view-native #nme-toolbar,body.nme-view-native #nme-grid,',
'body.nme-view-native #nme-status,body.nme-view-native #nme-sentinel{display:none !important;}',
'body.nme-view-native #nme-panel{margin:0;}',
'body.nme-view-enhanced nav[aria-label*="Pagination" i]{display:none !important;}',
'#nme-panel{margin:0 0 1.5rem;}',
'#nme-panel .nme-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-bottom:.5rem;}',
'#nme-panel .nme-label{font-size:.8125rem;opacity:.7;white-space:nowrap;}',
'#nme-panel .nme-sel, #nme-panel .nme-input{background:rgba(255,255,255,.06);color:inherit;border:1px solid rgba(255,255,255,.16);border-radius:.375rem;padding:.3rem .5rem;font-size:.8125rem;line-height:1.2;}',
// 原生下拉列表在 macOS 上默认是白底,继承文字色后变成白底浅字;声明深色配色并显式设置 option 颜色
'#nme-panel{color-scheme:dark;}',
'#nme-panel .nme-sel{color-scheme:dark;}',
'#nme-panel .nme-sel option{background:#1b1b1f;color:#f0f0f0;}',
'#nme-panel .nme-sel option:checked{background:#3a3a42;color:#fff;}',
'#nme-panel .nme-input{min-width:12rem;flex:1 1 12rem;}',
'#nme-panel .nme-num{width:6rem;flex:0 0 6rem;}',
'#nme-panel .nme-btn{background:rgba(255,255,255,.06);color:inherit;border:1px solid rgba(255,255,255,.16);border-radius:.375rem;padding:.3rem .6rem;font-size:.8125rem;cursor:pointer;}',
'#nme-panel .nme-btn:hover{background:rgba(255,255,255,.12);}',
'#nme-panel .nme-btn-accent{border-color:rgba(255,255,255,.32);}',
'#nme-panel .nme-chip{background:transparent;color:inherit;border:1px solid rgba(255,255,255,.16);border-radius:999px;padding:.2rem .7rem;font-size:.8125rem;cursor:pointer;}',
'#nme-panel .nme-chip-on{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'#nme-panel .nme-sep{width:1px;height:1.25rem;background:rgba(255,255,255,.16);margin:0 .25rem;}',
'#nme-panel .nme-status{font-size:.8125rem;opacity:.75;margin:.35rem 0 .75rem;}',
'#nme-panel .nme-status-err{color:#ff8a8a;opacity:1;}',
'#nme-panel .nme-grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));}',
'#nme-panel .nme-sentinel{text-align:center;font-size:.8125rem;opacity:.55;padding:1.25rem 0;}',
'#nme-panel .nme-badges{display:inline-flex;gap:.25rem;margin-left:.35rem;vertical-align:middle;}',
'#nme-panel .nme-badge{font-size:.6875rem;line-height:1.2;padding:.1rem .35rem;border-radius:.25rem;border:1px solid currentColor;}',
'#nme-panel .nme-badge-hans{color:#e05c5c;}',
'#nme-panel .nme-badge-hant{color:#d68b3c;}',
'#nme-panel .nme-badge-zh{color:#e05c5c;}',
'#nme-panel .nme-badge-tr{color:#5ca0e0;}',
'#nme-panel .nme-badge-direct{color:#3fa66a;}',
// —— 右下角统一浮动坞(AI / 下载 / 配置 / 增强视图)——
// 所有浮动入口共用一个容器:按钮在流内横竖排列,弹出面板绝对定位在坞的上方,
// 两个面板互斥(开一个关另一个)。结构上杜绝「两个浮层互相遮挡」。
// 坞本身 pointer-events:none,只有真正的按钮可点,
// 不会再出现「透明大容器盖住按钮、点击被吃掉」的情况。
'#nme-dock{position:fixed;right:16px;bottom:16px;z-index:10000;display:flex;flex-direction:column;',
' align-items:flex-end;gap:.45rem;pointer-events:none;}',
'#nme-fab-btns{order:1;display:flex;flex-direction:row;align-items:center;justify-content:flex-end;',
' gap:.4rem;flex-wrap:wrap;pointer-events:auto;}',
'#nme-cap-btn{order:2;pointer-events:auto;}',
'.nme-fab-btn{display:inline-flex;align-items:center;height:34px;padding:0 .85rem;border-radius:999px;',
' border:1px solid rgba(255,255,255,.32);background:rgba(16,16,18,.96);color:#f0f0f2;',
' font-weight:600;font-size:.8125rem;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.6);',
' transition:transform .15s ease,background .15s ease;}',
'.nme-fab-btn:hover{transform:translateY(-1px);background:rgba(36,36,42,.98);}',
'.nme-fab-btn-ai{border-color:rgba(211,74,74,.6);color:#f6c9c9;}',
'.nme-fab-btn-ai:hover{background:rgba(211,74,74,.28);}',
'.nme-fab-btn-dl{border-color:rgba(63,166,106,.55);color:#c6e8d4;}',
'.nme-fab-btn-dl:hover{background:rgba(63,166,106,.26);}',
'.nme-fab-btn-cfg{border-color:rgba(255,255,255,.28);color:#dcdce2;}',
'.nme-fab-btn-on{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'.nme-fab-btn-on:hover{background:#e05656;}',
'#nme-fab-card{position:absolute;right:0;bottom:calc(100% + .5rem);z-index:2;pointer-events:auto;',
' width:min(26rem,calc(100vw - 2rem));max-height:min(70vh,34rem);overflow-y:auto;',
' padding:.75rem .85rem;border-radius:.75rem;border:1px solid rgba(255,255,255,.2);',
' background:#17171a;color:#e9e9ec;box-shadow:0 14px 44px rgba(0,0,0,.68);',
' animation:nme-fab-in .16s ease;}',
'@keyframes nme-fab-in{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}',
'.nme-fab-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;',
' margin-bottom:.55rem;padding-bottom:.45rem;border-bottom:1px solid rgba(255,255,255,.12);}',
'.nme-fab-title{font-weight:700;font-size:.85rem;color:#e9e9ec;}',
'.nme-fab-close{background:none;border:none;color:rgba(255,255,255,.55);cursor:pointer;',
' font-size:1rem;line-height:1;padding:0 .2rem;}',
'.nme-fab-close:hover{color:#fff;}',
'.nme-fab-sec{display:none;}',
'.nme-fab-sec.nme-fab-sec-on{display:block;}',
// 浮层内的 AI / 下载条:改成紧凑版,不再是页面里的大块卡片
'#nme-fab-card #nme-ai-bar,#nme-fab-card #nme-dl-bar{margin:0;padding:0;border:none;',
' border-radius:0;background:none;}',
'#nme-fab-card #nme-ai-bar .nme-ai-head,#nme-fab-card #nme-dl-bar .nme-dl-head{display:none;}',
'#nme-fab-card #nme-ai-bar .nme-ai-actions,#nme-fab-card #nme-dl-bar .nme-dl-actions{margin-top:0;}',
'#nme-fab-card .nme-ai-btn,#nme-fab-card .nme-dl-btn{background:rgba(255,255,255,.09);color:#eee;',
' border:1px solid rgba(255,255,255,.22);}',
'#nme-fab-card .nme-ai-btn:hover,#nme-fab-card .nme-dl-btn:hover{background:rgba(255,255,255,.17);}',
'#nme-fab-card .nme-ai-btn-accent,#nme-fab-card .nme-dl-btn-accent{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'#nme-fab-card .nme-dl-list{max-height:16rem;}',
'#nme-fab-card .nme-dl-row{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);}',
'#nme-fab-card .nme-dl-meta,#nme-fab-card .nme-dl-sub{opacity:.75;}',
'#nme-fab-card .nme-dl-log{background:rgba(0,0,0,.35);}',
// —— 增强视图胶囊(常驻快捷操作,挂在统一浮动坞里)——
'#nme-cap-btn{display:inline-flex;align-items:center;gap:.5rem;height:38px;padding:0 .95rem;border-radius:999px;',
' border:1px solid rgba(255,255,255,.3);background:rgba(20,20,22,.94);color:#e8e8e8;',
' font-weight:600;font-size:.8125rem;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.55);backdrop-filter:blur(8px);',
' transition:transform .15s ease,box-shadow .15s ease;border-color:rgba(63,166,106,.5);}',
'#nme-cap-btn:hover{transform:translateY(-1px);box-shadow:0 6px 22px rgba(0,0,0,.6);}',
'#nme-cap-btn.nme-cap-on{border-color:rgba(63,166,106,.65);}',
'#nme-cap-btn .nme-cap-dot{width:9px;height:9px;border-radius:50%;background:#3fa66a;box-shadow:0 0 0 3px rgba(63,166,106,.2);transition:background .2s;}',
'#nme-cap-btn .nme-cap-dot.nme-cap-dot-native{background:#e0a33c;box-shadow:0 0 0 3px rgba(224,163,60,.22);}',
'#nme-cap-btn .nme-cap-caret{opacity:.55;font-size:.8em;line-height:1;transition:transform .2s;}',
'#nme-dock.nme-cap-open #nme-cap-btn .nme-cap-caret{transform:rotate(180deg);opacity:.9;}',
'#nme-cap-panel{position:absolute;right:0;bottom:calc(100% + .5rem);z-index:1;',
' width:min(20rem,82vw);max-height:70vh;overflow-y:auto;padding:.75rem;border-radius:1rem;',
' border:1px solid rgba(255,255,255,.22);background:rgba(22,22,26,.97);color:#eee;',
' box-shadow:0 10px 36px rgba(0,0,0,.6);backdrop-filter:blur(12px);',
' opacity:0;transform:translateY(6px) scale(.98);pointer-events:none;transition:opacity .18s ease,transform .18s ease;}',
'#nme-dock.nme-cap-open #nme-cap-panel{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}',
'#nme-cap-panel .nme-cap-row{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;margin-bottom:.55rem;}',
'#nme-cap-panel .nme-cap-row:last-child{margin-bottom:0;}',
'#nme-cap-panel .nme-cap-label{font-size:.75rem;opacity:.6;white-space:nowrap;min-width:3.2rem;}',
'#nme-cap-panel .nme-cap-btn2{background:rgba(255,255,255,.06);color:inherit;border:1px solid rgba(255,255,255,.18);',
' border-radius:999px;padding:.28rem .7rem;font-size:.78rem;cursor:pointer;line-height:1.2;white-space:nowrap;}',
'#nme-cap-panel .nme-cap-btn2:hover{background:rgba(255,255,255,.13);}',
'#nme-cap-panel .nme-cap-btn2.nme-cap-active{background:#3fa66a;border-color:#3fa66a;color:#06130b;font-weight:600;}',
'#nme-cap-panel .nme-cap-chip{background:transparent;color:inherit;border:1px solid rgba(255,255,255,.2);',
' border-radius:999px;padding:.24rem .65rem;font-size:.75rem;cursor:pointer;}',
'#nme-cap-panel .nme-cap-chip.nme-cap-on{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'#nme-cap-panel .nme-cap-ai{background:rgba(211,74,74,.16);border-color:rgba(211,74,74,.5);color:#f3b4b4;}',
'#nme-cap-panel .nme-cap-ai:hover{background:rgba(211,74,74,.28);}',
'#nme-cap-panel .nme-cap-sep{height:1px;background:rgba(255,255,255,.14);margin:.55rem 0;}',
'#nme-cap-panel .nme-cap-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.35rem;}',
'#nme-cap-panel .nme-cap-title{font-size:.8rem;font-weight:700;color:#d34a4a;}',
'#nme-cap-panel .nme-cap-close{background:none;border:none;color:rgba(255,255,255,.5);cursor:pointer;font-size:1rem;line-height:1;padding:0 .2rem;}',
'#nme-cap-panel .nme-cap-close:hover{color:#fff;}',
'#nme-cap-panel .nme-cap-mode{display:flex;gap:.3rem;}',
'#nme-cap-panel .nme-cap-mode button{flex:1;}',
'#nme-cap-panel .nme-cap-hint{font-size:.7rem;opacity:.5;margin-top:.3rem;line-height:1.4;}',
// 提示条放左下角:右下角被统一浮动坞占用,避免盖住按钮
'#nme-toast{position:fixed;left:16px;bottom:16px;z-index:10000;max-width:min(22rem,80vw);',
' padding:.6rem .8rem;border-radius:.55rem;border:1px solid rgba(255,255,255,.2);',
' background:rgba(20,20,22,.96);color:#eee;font-size:.8125rem;line-height:1.45;',
' box-shadow:0 6px 24px rgba(0,0,0,.5);opacity:0;transform:translateY(8px);',
' transition:opacity .18s ease,transform .18s ease;pointer-events:none;}',
'#nme-toast.nme-toast-on{opacity:1;transform:translateY(0);}',
'#nme-toast b{display:block;font-size:.875rem;margin-bottom:.15rem;color:#d34a4a;}',
'#nme-toast.nme-toast-native b{color:#e0a33c;}',
'#nme-toast span{opacity:.75;}',
// —— 下载历史更新检查 ——
// 用 !important 覆盖,避免其他脚本写入的内联背景色把高亮擦掉
'#nme-hist-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin:.6rem 0;padding:.55rem .7rem;',
' border:1px solid rgba(217,143,64,.45);border-radius:.4rem;background:rgba(217,143,64,.08);',
' color:#e6c79a;font-size:.8125rem;}',
'#nme-hist-bar b{color:#f0b267;font-size:.875rem;}',
'#nme-hist-bar #nme-hist-count{opacity:.8;}',
'#nme-hist-bar button{padding:.2rem .55rem;border:1px solid rgba(217,143,64,.5);border-radius:.3rem;',
' background:rgba(217,143,64,.14);color:#f0c37a;font-size:.75rem;cursor:pointer;}',
'#nme-hist-bar button:hover{background:rgba(217,143,64,.26);}',
'#nme-hist-bar .nme-hist-tip{flex-basis:100%;opacity:.6;font-size:.75rem;}',
'tr.nme-hist-row > td{background-color:rgba(217,143,64,.15)!important;}',
'tr.nme-hist-row > td:first-child{box-shadow:inset 3px 0 0 #d98f40;}',
'tr.nme-hist-row.nme-hist-noedge > td:first-child{box-shadow:none;}',
'.nme-hist-badge{display:inline-block;margin-left:.4rem;padding:0 .35rem;border-radius:.25rem;',
' background:#d98f40;color:#1b1206;font-size:.6875rem;font-weight:700;vertical-align:middle;}',
'table.datatable.nme-hist-only tbody tr:not(.nme-hist-row){display:none!important;}',
// —— 中文找游戏 ——
'#nme-panel .nme-bar-cn{margin-top:.1rem;}',
'#nme-panel .nme-cn-input{flex:1 1 18rem;}',
'#nme-panel .nme-cn-msg{font-size:.8125rem;opacity:.7;}',
'#nme-panel .nme-cn-msg-ok{color:#69c08a;opacity:1;}',
'#nme-panel .nme-cn-msg-busy{color:#e0a33c;opacity:1;}',
'#nme-panel .nme-cn-msg-warn{color:#e0a33c;opacity:1;}',
'#nme-panel .nme-cn-msg-err{color:#ff8a8a;opacity:1;}',
'#nme-panel .nme-cn-results{display:flex;flex-wrap:wrap;gap:.5rem;margin:.35rem 0 .85rem;}',
'#nme-panel .nme-cn-item{display:flex;flex-direction:column;gap:.1rem;padding:.45rem .7rem;min-width:11rem;',
' border:1px solid rgba(255,255,255,.18);border-radius:.45rem;color:inherit;text-decoration:none;background:rgba(255,255,255,.03);}',
'#nme-panel .nme-cn-item:hover{background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.3);}',
'#nme-panel .nme-cn-name{font-weight:600;font-size:.875rem;}',
'#nme-panel .nme-cn-domain{font-size:.75rem;opacity:.6;}',
'#nme-panel .nme-cn-count{font-size:.75rem;opacity:.75;}',
'#nme-panel .nme-cn-src{font-size:.6875rem;opacity:.5;}',
'#nme-panel .nme-cn-empty{font-size:.8125rem;opacity:.8;}',
// —— AI 工具条(列表页 + 详情页)——
'#nme-ai-bar{margin:1rem 0;padding:.8rem .95rem;border:1px solid rgba(255,255,255,.16);',
' border-radius:.6rem;background:rgba(255,255,255,.04);}',
'#nme-ai-bar .nme-ai-head{display:flex;align-items:baseline;gap:.55rem;flex-wrap:wrap;}',
'#nme-ai-bar .nme-ai-title{font-weight:700;color:#d34a4a;}',
'#nme-ai-bar .nme-ai-sub{font-size:.75rem;opacity:.6;}',
'#nme-ai-bar .nme-ai-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.6rem;}',
'#nme-ai-bar .nme-ai-btn,#nme-panel .nme-ai-btn{background:rgba(255,255,255,.06);color:inherit;',
' border:1px solid rgba(255,255,255,.16);border-radius:.375rem;padding:.3rem .6rem;font-size:.8125rem;cursor:pointer;}',
'#nme-ai-bar .nme-ai-btn:hover,#nme-panel .nme-ai-btn:hover{background:rgba(255,255,255,.12);}',
'#nme-ai-bar .nme-ai-btn:disabled,#nme-panel .nme-ai-btn:disabled{opacity:.5;cursor:progress;}',
'#nme-ai-bar .nme-ai-btn-accent,#nme-panel .nme-ai-btn-accent{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'#nme-ai-status{font-size:.75rem;opacity:.7;}',
'.nme-ai-status-ok{color:#69c08a !important;opacity:1;}',
'.nme-ai-status-err,.nme-ai-status-err .nme-ai-status{color:#ff8a8a !important;opacity:1;}',
'.nme-ai-status-busy{color:#e0a33c !important;opacity:1;}',
'.nme-ai-status-warn{color:#e0a33c !important;opacity:1;}',
'.nme-ai-out{margin:.35rem 0 .9rem;}',
'#nme-ai-bar .nme-ai-out{margin-top:.75rem;}',
'.nme-ai-card{border:1px solid rgba(255,255,255,.14);border-radius:.5rem;padding:.75rem .9rem;',
' background:rgba(255,255,255,.03);}',
'.nme-ai-card-head{font-weight:600;margin-bottom:.5rem;display:flex;flex-wrap:wrap;gap:.3rem .75rem;align-items:baseline;}',
'.nme-ai-tip{font-size:.75rem;font-weight:400;opacity:.6;}',
'.nme-ai-text{font-size:.875rem;line-height:1.65;word-break:break-word;}',
'.nme-ai-kv{display:flex;gap:.5rem;margin:.25rem 0;}',
'.nme-ai-k{flex:0 0 5.5rem;opacity:.6;font-size:.8125rem;}',
'.nme-ai-v{flex:1;font-size:.875rem;line-height:1.6;}',
'.nme-ai-sec-title{font-weight:600;margin:.65rem 0 .25rem;}',
'.nme-ai-list{margin:0;padding-left:1.1rem;}',
'.nme-ai-list li{margin:.2rem 0;font-size:.875rem;line-height:1.55;}',
'.nme-ai-list a{color:#7db4e6;text-decoration:none;}',
'.nme-ai-list a:hover{text-decoration:underline;}',
'.nme-ai-why{display:block;font-size:.75rem;opacity:.65;}',
// —— AI 配置弹窗 ——
'#nme-ai-cfg-modal{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.62);}',
'#nme-ai-cfg-modal .nme-ai-modal{width:min(560px,92vw);max-height:88vh;overflow:auto;background:#1b1b1f;color:#eee;',
' border:1px solid rgba(255,255,255,.18);border-radius:.7rem;padding:1rem 1.1rem;box-shadow:0 10px 40px rgba(0,0,0,.55);}',
'#nme-ai-cfg-modal .nme-ai-modal-title{font-weight:700;font-size:1rem;margin-bottom:.35rem;}',
'#nme-ai-cfg-modal .nme-ai-modal-help{font-size:.78rem;line-height:1.6;opacity:.7;margin-bottom:.8rem;}',
'#nme-ai-cfg-modal code{background:rgba(255,255,255,.1);padding:.05rem .3rem;border-radius:.2rem;}',
'#nme-ai-cfg-modal .nme-ai-field{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.6rem;}',
'#nme-ai-cfg-modal .nme-ai-field span{font-size:.78rem;opacity:.75;}',
'#nme-ai-cfg-modal .nme-ai-field input{background:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.22);',
' border-radius:.35rem;padding:.42rem .55rem;font-size:.85rem;}',
'#nme-ai-cfg-modal .nme-ai-modal-foot{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-top:.85rem;}',
'#nme-ai-cfg-modal .nme-ai-modal-foot .nme-ai-status{flex:1;font-size:.78rem;}',
'#nme-ai-cfg-modal .nme-ai-btn{background:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.22);',
' border-radius:.375rem;padding:.35rem .8rem;font-size:.8125rem;cursor:pointer;}',
'#nme-ai-cfg-modal .nme-ai-btn:hover{background:rgba(255,255,255,.16);}',
'#nme-ai-cfg-modal .nme-ai-btn-accent{background:#d34a4a;border-color:#d34a4a;}',
// —— 下载增强 ——
'#nme-dl-bar{margin:1rem 0;padding:.8rem .95rem;border:1px solid rgba(255,255,255,.16);',
' border-radius:.6rem;background:rgba(255,255,255,.04);}',
'#nme-dl-bar .nme-dl-head{display:flex;align-items:baseline;gap:.55rem;flex-wrap:wrap;}',
'#nme-dl-bar .nme-dl-title{font-weight:700;color:#d34a4a;}',
'#nme-dl-bar .nme-dl-sub{font-size:.75rem;opacity:.6;}',
'#nme-dl-bar .nme-ai-status{font-size:.75rem;opacity:.7;}',
'#nme-dl-bar .nme-dl-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.6rem;}',
'#nme-dl-bar .nme-dl-btn{background:rgba(255,255,255,.06);color:inherit;border:1px solid rgba(255,255,255,.16);',
' border-radius:.375rem;padding:.3rem .6rem;font-size:.8125rem;cursor:pointer;}',
'#nme-dl-bar .nme-dl-btn:hover{background:rgba(255,255,255,.12);}',
'#nme-dl-bar .nme-dl-btn:disabled{opacity:.5;cursor:not-allowed;}',
'#nme-dl-bar .nme-dl-btn-accent{background:#d34a4a;border-color:#d34a4a;color:#fff;}',
'#nme-dl-bar .nme-dl-gap{width:4.5rem;background:rgba(255,255,255,.06);color:inherit;',
' border:1px solid rgba(255,255,255,.16);border-radius:.375rem;padding:.25rem .4rem;font-size:.8125rem;}',
'#nme-dl-bar .nme-dl-list{margin-top:.6rem;display:flex;flex-direction:column;gap:.35rem;max-height:22rem;overflow:auto;}',
'#nme-dl-bar .nme-dl-row{display:flex;align-items:center;gap:.6rem;padding:.4rem .55rem;',
' border:1px solid rgba(255,255,255,.1);border-radius:.4rem;background:rgba(255,255,255,.02);}',
'#nme-dl-bar .nme-dl-main{flex:1;min-width:0;}',
'#nme-dl-bar .nme-dl-name{font-size:.875rem;font-weight:600;word-break:break-word;}',
'#nme-dl-bar .nme-dl-meta{font-size:.75rem;opacity:.6;margin-top:.1rem;}',
'#nme-dl-bar .nme-dl-tag{display:inline-block;font-size:.6875rem;border:1px solid currentColor;',
' border-radius:.2rem;padding:0 .25rem;margin-left:.35rem;opacity:.8;vertical-align:middle;}',
'#nme-dl-bar .nme-dl-empty{font-size:.8125rem;opacity:.7;}',
'#nme-dl-bar .nme-dl-err{color:#ff8a8a;opacity:1;}',
'#nme-dl-bar .nme-dl-prog{height:6px;border-radius:3px;background:rgba(255,255,255,.12);',
' overflow:hidden;margin-top:.6rem;}',
'#nme-dl-bar .nme-dl-prog-fill{height:100%;width:0;background:#d34a4a;transition:width .25s;}',
'#nme-dl-bar .nme-dl-log{margin-top:.5rem;max-height:9rem;overflow:auto;font-size:.75rem;line-height:1.55;',
' background:rgba(0,0,0,.22);border-radius:.35rem;padding:.4rem .55rem;}',
'#nme-dl-bar .nme-dl-log-err{color:#ff8a8a;}',
'#nme-dl-bar .nme-dl-log-ok{color:#69c08a;}',
'#nme-dl-bar .nme-dl-log-warn{color:#e0a33c;}'
].join('\n');
GM_addStyle(css);
}
// ==================== 启动 ====================
function nativeTileTemplate() {
const t = nativeTiles()[0];
return t || null;
}
/** 站点自己的卡片(排除增强视图里的) */
function nativeTiles() {
return $$('[data-e2eid="mod-tile"]').filter(function (t) {
return !t.closest('#nme-grid');
});
}
function countNative(root) {
if (!root || !root.querySelectorAll) return 0;
return Array.from(root.querySelectorAll('[data-e2eid="mod-tile"]')).filter(function (t) {
return !t.closest('#nme-grid');
}).length;
}
/**
* 原生网格容器。
* 列表页是 .mods-grid,搜索页是 div.grid.*,两者结构不同,
* 所以从第一张原生卡片向上找「原生卡片数最多」的那一层,不写死类名。
*/
function findNativeGrid() {
const tiles = nativeTiles();
if (!tiles.length) return null;
let node = tiles[0].parentElement;
let best = node;
let bestCount = countNative(node);
while (node && node !== document.body) {
const c = countNative(node);
if (c > bestCount) { bestCount = c; best = node; }
node = node.parentElement;
}
return best;
}
/** 需要在增强视图下隐藏的原生结果根节点 */
function nativeHideRoot(grid) {
const p = grid && grid.parentElement;
if (p && p.tagName === 'SECTION') return p; // 搜索页:连同「模组 (N)」分组一起隐藏
return grid;
}
/** React 重渲染会换掉原生网格节点,需要重新打上隐藏标记 */
function ensureNativeHidden() {
if (CFG.view !== 'enhanced') return;
const grid = findNativeGrid();
if (!grid) return;
const root = nativeHideRoot(grid);
if (root && root.getAttribute('data-nme-native') !== '1') {
root.setAttribute('data-nme-native', '1');
}
}
/**
* 站点筛选面板里,分类/语言/标签这些行只有左边那个小方框可点,
* 点文字没反应(HeadlessUI 的 role=checkbox 没有关联 label)。
* 这里把文字点击转发给方框,属于「更高效的筛选」范畴。
*/
function enhanceFilterClick() {
const rows = $$('[class*="group/checkbox"]');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.getAttribute('data-nme-click') === '1') continue;
const cb = row.querySelector('[role=checkbox]');
const label = row.querySelector('label');
if (!cb || !label) continue;
// 只有 label 指向真正的表单控件时,点击文字才会被浏览器转发;
// Nexus 的 HeadlessUI 把 for 指向 ,浏览器不认,
// 所以这里统一自己转发。
const forId = label.getAttribute('for');
const forEl = forId ? document.getElementById(forId) : null;
const labelable = forEl && /^(INPUT|SELECT|TEXTAREA|BUTTON|METER|OUTPUT|PROGRESS)$/.test(forEl.tagName);
if (labelable) continue;
label.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
cb.click();
});
row.setAttribute('data-nme-click', '1');
}
}
function mountEnhanced() {
const ctx = state.ctx;
if (!ctx) return;
if ($('#nme-panel')) return;
const tpl = nativeTileTemplate();
if (!tpl) return; // 原生列表还没渲染出来,稍后重试
state.tpl = tpl;
const grid = findNativeGrid();
if (!grid || !grid.parentElement) return;
// 隐藏根节点:列表页 = 网格本身;搜索页 = 包含「模组 (N)」的 section
const root = nativeHideRoot(grid);
root.setAttribute('data-nme-native', '1');
const panel = buildPanel(ctx);
root.parentElement.insertBefore(panel, root);
bindPanel(panel);
setView(CFG.view);
// 无限滚动
const sentinel = $('#nme-sentinel');
if (sentinel && window.IntersectionObserver) {
state.io = new IntersectionObserver(function (entries) {
for (let i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting && !state.loading && !state.done) loadPage(false);
}
}, { rootMargin: '600px 0px' });
state.io.observe(sentinel);
}
}
function decorateNativeTiles(root) {
if (!CFG.badges) return;
const scope = root && root.querySelectorAll ? root : document;
const tiles = scope.querySelectorAll('[data-e2eid="mod-tile"]');
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i];
if (tile.closest('#nme-grid')) continue; // 增强视图的卡片自带徽章
if (tile.getAttribute('data-nme-badged') === '1') continue;
const title = tile.querySelector('[data-e2eid="mod-tile-title"]');
if (!title) continue;
const name = title.textContent || '';
const badges = detectBadges({ name: name, tags: [] });
if (!badges.length) { tile.setAttribute('data-nme-badged', '1'); continue; }
const holder = document.createElement('span');
holder.className = 'nme-badges';
for (let j = 0; j < badges.length; j++) {
const b = document.createElement('span');
b.className = 'nme-badge ' + badges[j].cls;
b.textContent = badges[j].text;
holder.appendChild(b);
}
if (title.parentElement) title.parentElement.insertBefore(holder, title.nextSibling);
tile.setAttribute('data-nme-badged', '1');
}
}
/** 关闭徽章时清掉已插入的徽章,并重置标记,方便再次开启后重新装饰 */
function clearBadges(root) {
const scope = root && root.querySelectorAll ? root : document;
$$('.nme-badges', scope).forEach(function (h) { h.remove(); });
$$('[data-nme-badged]', scope).forEach(function (t) { t.removeAttribute('data-nme-badged'); });
}
// ==================== 下载历史更新检查 ====================
// 站点「下载历史」页:模组更新时间晚于你的最后下载时间时,标橙并加「有更新」徽章。
// 与同类脚本共存:不写内联样式(用 class + !important),不改表格结构,
// 也不读写对方使用的「最后下载 / 更新时间」两列文本,徽章只插到首列末尾。
const HIST_SEL = 'table.datatable';
let histObs = null;
let histTable = null;
/** 表头是否同时具备「下载时间」与「更新时间」两列 */
function histHasHeader(table) {
const ths = $$('thead th', table);
if (!ths.length) return false;
let dl = false, up = false;
for (let i = 0; i < ths.length; i++) {
const t = (ths[i].textContent || '').trim();
if (/(last\s*)?downloaded|下载时间|最后下载/i.test(t)) dl = true;
if (/(last\s*)?updated|file\s*updated|更新时间|最近更新/i.test(t)) up = true;
}
return dl && up;
}
function isDownloadHistoryPage() {
if (!/\/users\/myaccount/i.test(location.pathname)) return false;
const table = $(HIST_SEL);
if (!table) return false;
const tab = new URLSearchParams(location.search).get('tab') || '';
return /download/i.test(tab) || histHasHeader(table);
}
const HIST_MONTHS = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
};
/** 兼容 "12 November 2024, 3:45PM" / "Nov 12 2024" / "2024-11-12" */
function parseHistText(txt) {
const s = String(txt || '').replace(/,/g, ' ').replace(/\s+/g, ' ').trim();
if (!s) return null;
const quick = new Date(s);
if (isFinite(quick.getTime())) return quick;
const m = s.match(/(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})/);
if (m) {
const mon = HIST_MONTHS[m[2].slice(0, 3).toLowerCase()];
if (mon !== undefined) {
const time = s.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])?/);
let hh = time ? Number(time[1]) : 0;
const mm = time ? Number(time[2]) : 0;
if (time && time[4]) {
const pm = /p/i.test(time[4]);
if (pm && hh < 12) hh += 12;
if (!pm && hh === 12) hh = 0;
}
const d = new Date(Number(m[3]), mon, Number(m[1]), hh, mm);
return isFinite(d.getTime()) ? d : null;
}
}
return null;
}
/** 优先取 data-sort 时间戳,其次解析单元格文本 */
function histDate(td) {
if (!td) return null;
const raw = (td.getAttribute('data-sort') || td.getAttribute('data-date') || '').trim();
if (/^\d{10}$/.test(raw)) return new Date(Number(raw) * 1000);
if (/^\d{13}$/.test(raw)) return new Date(Number(raw));
if (raw) {
const d = new Date(raw);
if (isFinite(d.getTime())) return d;
}
return parseHistText(td.textContent);
}
/** 表头识别列;识别失败时退回旧站惯例(1=最后下载,4=更新时间) */
function histColumns(table) {
const ths = $$('thead th', table);
if (!ths.length) return { dl: 1, up: 4 };
const idxOf = function (re) {
for (let i = 0; i < ths.length; i++) {
if (re.test((ths[i].textContent || '').trim())) return i;
}
return -1;
};
const dl = idxOf(/(last\s*)?downloaded|下载时间|最后下载/i);
const up = idxOf(/(last\s*)?updated|file\s*updated|更新时间|最近更新/i);
return (dl >= 0 && up >= 0) ? { dl: dl, up: up } : { dl: 1, up: 4 };
}
function ensureHistBar(table) {
let bar = $('#nme-hist-bar');
if (bar) return bar;
if (!table.parentElement) return null;
bar = document.createElement('div');
bar.id = 'nme-hist-bar';
bar.innerHTML = '下载历史更新检查检查中…
' +
'' +
'' +
'橙色 = 模组更新时间晚于你的最后下载时间
';
table.parentElement.insertBefore(bar, table);
const only = $('#nme-hist-only', bar);
const recheck = $('#nme-hist-recheck', bar);
if (only) {
only.addEventListener('click', function () {
state.histOnly = !state.histOnly;
only.textContent = state.histOnly ? '显示全部' : '只看有更新';
const t = $(HIST_SEL);
if (t) t.classList.toggle('nme-hist-only', state.histOnly);
});
}
if (recheck) {
recheck.addEventListener('click', function () {
scanHistory();
showToast('下载历史检查完成', $('#nme-hist-count') ? $('#nme-hist-count').textContent : '', 'enhanced');
});
}
return bar;
}
function scanHistory() {
const table = $(HIST_SEL);
if (!table) return;
const cols = histColumns(table);
const rows = $$('tbody tr', table);
const minCells = Math.max(cols.dl, cols.up) + 1;
let updates = 0;
rows.forEach(function (row) {
const cells = row.children;
if (!cells || cells.length < minCells) return;
const dl = histDate(cells[cols.dl]);
const up = histDate(cells[cols.up]);
const isUpd = !!(dl && up && up.getTime() > dl.getTime());
row.classList.toggle('nme-hist-row', isUpd);
const first = cells[0];
const badge = first ? first.querySelector('.nme-hist-badge') : null;
if (isUpd) {
updates++;
row.setAttribute('data-nme-upd', '1');
// 同类脚本可能已经给首列加了内联左边框,避免出现两条竖线
if (first) row.classList.toggle('nme-hist-noedge', !!(first.style && first.style.borderLeftWidth));
if (first && !badge) {
const b = document.createElement('span');
b.className = 'nme-hist-badge';
b.textContent = '有更新';
first.appendChild(b);
}
} else {
row.removeAttribute('data-nme-upd');
if (badge) badge.remove();
}
});
const count = $('#nme-hist-count');
if (count) {
const now = new Date();
const hh = ('0' + now.getHours()).slice(-2) + ':' + ('0' + now.getMinutes()).slice(-2);
const text = '共 ' + rows.length + ' 条 · ' + updates + ' 个有更新 · ' + hh;
// 内容没变就不写,避免自身触发的 MutationObserver 反复扫描
if (count.textContent !== text) count.textContent = text;
}
return updates;
}
function mountHistoryCheck() {
if (!isDownloadHistoryPage()) return false;
const table = $(HIST_SEL);
if (!table) return false;
ensureHistBar(table);
if (state.histOnly) table.classList.add('nme-hist-only');
// 表被 React 重绘后需要重新挂钩
if (histTable !== table) {
if (histObs) histObs.disconnect();
histTable = table;
histObs = new MutationObserver(debounce(function () { scanHistory(); }, 300));
histObs.observe(table, { childList: true, subtree: true });
}
scanHistory();
return true;
}
// 菜单项要能反映当前状态,所以每次变更后反注册再注册。
// 个别管理器不返回菜单 id,此时退化为只注册一次,避免菜单堆叠。
const CAN_UNREG_MENU = typeof GM_unregisterMenuCommand === 'function';
let menuIds = [];
let menuRegistered = false;
let menuRefreshable = CAN_UNREG_MENU;
function refreshMenu() {
if (!menuRefreshable) return;
// 异步重建:避免在菜单回调里同步反注册正在执行的那一条
setTimeout(function () { setupMenu(); }, 0);
}
function setupMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
if (menuRegistered && !menuRefreshable) return;
try {
if (CAN_UNREG_MENU) {
for (let i = 0; i < menuIds.length; i++) {
try { GM_unregisterMenuCommand(menuIds[i]); } catch (e) { /* 忽略单个失败 */ }
}
}
menuIds = [];
const reg = function (label, fn) {
const id = GM_registerMenuCommand(label, fn);
if (id === undefined || id === null) menuRefreshable = false;
else menuIds.push(id);
};
const enhanced = CFG.view === 'enhanced';
reg(enhanced ? '视图:增强视图(点击切到原生)' : '视图:原生视图(点击切到增强)', function () {
setView(CFG.view === 'enhanced' ? 'native' : 'enhanced');
});
[20, 40, 60, 80].forEach(function (n) {
reg('每页条数:' + n + (CFG.pageSize === n ? ' ✓' : ''), function () {
CFG.pageSize = n;
saveCfg();
refreshCapsule();
refreshMenu();
const isList = state.ctx && (state.ctx.mode === 'list' || state.ctx.mode === 'search');
if (CFG.view !== 'enhanced') {
showToast('每页条数:' + n, '当前为原生视图,切回增强视图后生效', 'native');
} else if (isList) {
showToast('每页条数:' + n, '正在重新加载列表…', 'enhanced');
loadPage(true);
} else {
// 详情页/合集页没有列表上下文,避免直接调用 loadPage
showToast('每页条数:' + n, '回到列表或搜索页后生效', 'enhanced');
}
});
});
reg('界面中文化:' + (CFG.zhUI ? '开 ✓' : '关'), function () {
CFG.zhUI = !CFG.zhUI;
saveCfg();
refreshMenu();
if (CFG.zhUI) {
translateTree(document.body);
showToast('界面中文化:已开启', '站点界面文本已替换为中文', 'enhanced');
} else {
showToast('界面中文化:已关闭', '已翻译的文本需刷新页面才会恢复英文', 'native');
}
});
reg('语言徽章:' + (CFG.badges ? '开 ✓' : '关'), function () {
CFG.badges = !CFG.badges;
saveCfg();
refreshMenu();
if (!CFG.badges) {
clearBadges(document.body);
showToast('语言徽章:已关闭', '已移除当前页面上的徽章', 'native');
return;
}
const isList = state.ctx && (state.ctx.mode === 'list' || state.ctx.mode === 'search');
if (CFG.view === 'enhanced' && isList && $('#nme-grid')) {
// 增强视图的卡片徽章由 makeTile 生成,重新渲染才能补上
loadPage(true);
showToast('语言徽章:已开启', '正在重新渲染卡片…', 'enhanced');
} else {
decorateNativeTiles(document.body);
showToast('语言徽章:已开启', '已为当前卡片添加徽章', 'enhanced');
}
});
reg('下载等待页自动跳过:' + (CFG.skipWait ? '开 ✓' : '关'), function () {
CFG.skipWait = !CFG.skipWait;
saveCfg();
refreshMenu();
showToast('下载等待页自动跳过:' + (CFG.skipWait ? '开' : '关'),
CFG.skipWait ? '下次进入下载等待页时自动跳转' : '下次进入下载等待页时保持原样', 'enhanced');
});
reg('下载历史:重新检查更新', function () {
if (mountHistoryCheck()) {
showToast('下载历史:已重新检查', $('#nme-hist-count') ? $('#nme-hist-count').textContent : '', 'enhanced');
} else {
showToast('下载历史:请先打开下载历史页', '路径为 /users/myaccount?tab=downloads', 'native');
}
});
reg('AI 接口配置(翻译 / 分析)', openAiSettings);
menuRegistered = true;
} catch (e) { /* 忽略 */ }
}
function init() {
sanitizeCfg();
addStyle();
setupMenu();
// 下载等待页:尽早开始等待按钮
setupDownloadSkip();
const ctx = getContext();
state.ctx = ctx;
// React 站点(Next.js)服务端渲染英文文本后再水合。
// 在水合提交前改写文本或插入节点,都会让 React 判定「服务端与客户端不一致」,
// 抛出 #418 并把整棵树改成客户端重渲染 —— 我们的中文会被冲掉。
// 所以 React 页面统一推迟到水合提交后再动手。
state.reactApp = detectReactApp();
if (state.reactApp) {
waitHydration(function () { bootUi(ctx); });
} else {
bootUi(ctx);
}
console.log('%c' + LOG_PREFIX + ' v' + VERSION + ' 已启动', 'color:#d34a4a;font-weight:bold;font-size:13px');
}
/** 水合完成后(或非 React 站点立即)挂载界面 */
function bootUi(ctx) {
// 右下角浮动胶囊(所有页面常驻,不依赖增强视图面板)
mountCapsule();
if (CFG.zhUI) translateTree(document.body);
enhanceFilterClick();
if (ctx) {
if (ctx.mode === 'detail') {
// 模组详情页:浮动按钮坞(AI / 下载)
mountFab(ctx);
let tries = 0;
const tryMountAi = function () {
tries++;
mountDetailAi(ctx);
mountDetailDownload(ctx);
if ((!$('#nme-ai-bar') || !$('#nme-dl-bar')) && tries < 40) setTimeout(tryMountAi, 250);
};
tryMountAi();
} else if (ctx.mode === 'collection') {
// 合集页:浮动按钮坞 + 批量下载面板
mountFab(ctx);
let tries = 0;
const tryMountColl = function () {
tries++;
if ($('#nme-dl-bar')) return;
mountCollectionDownload(ctx);
if (!$('#nme-dl-bar') && tries < 40) setTimeout(tryMountColl, 250);
};
tryMountColl();
} else {
// 等原生列表渲染出第一张卡片再挂载增强视图(需要克隆原生卡片做样式模板)
let tries = 0;
const tryMount = function () {
tries++;
if ($('#nme-panel')) return;
mountEnhanced();
if (!$('#nme-panel') && tries < 40) setTimeout(tryMount, 250);
};
tryMount();
}
}
if (!ctx) {
// 下载历史页:表是异步渲染的,等它出现
let triesHist = 0;
const tryHist = function () {
triesHist++;
if (mountHistoryCheck()) return;
if (triesHist < 40 && /\/users\/myaccount/i.test(location.pathname)) setTimeout(tryHist, 250);
};
tryHist();
}
// 站点是 React 客户端渲染,后续内容需要持续处理(中文化 + 徽章 + 详情页 AI 条)
const obs = new MutationObserver(debounce(function () {
// 水合提交后再扫一遍:之前被跳过的文本这时才允许改写
if (state.reactApp && !state.hydrated) {
if (!reactHydrated()) return;
state.hydrated = true;
}
if (CFG.zhUI) translateTree(document.body);
if (CFG.badges) decorateNativeTiles(document.body);
enhanceFilterClick();
if (state.ctx && state.ctx.mode === 'detail') {
mountDetailAi(state.ctx);
mountDetailDownload(state.ctx);
} else if (state.ctx && state.ctx.mode === 'collection') {
mountCollectionDownload(state.ctx);
} else {
ensureNativeHidden();
if (isDownloadHistoryPage()) mountHistoryCheck();
}
}, 250));
try { obs.observe(document.body, { childList: true, subtree: true }); } catch (e) { /* 忽略 */ }
}
/**
* 等待 React 水合提交。
* 超时(站点并非 React 水合,或标记始终没出现)也放行,
* 此时按「没有服务端文本需要比对」处理,直接执行。
*/
function waitHydration(done) {
let tries = 0;
const iv = setInterval(function () {
tries++;
if (reactHydrated()) {
clearInterval(iv);
state.hydrated = true;
done(true);
return;
}
if (tries >= 60) {
clearInterval(iv);
// 没等到 React 标记:可能压根不是 React 站点,直接放行
state.hydrated = true;
done(false);
}
}, 150);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();