// ==UserScript== // @name POE2 NinjaBD增强 // @namespace local.codex.ninja.poe2 // @version 1.1.10 // @updated 2026-07-07 14:47:56 // @description 在 poe.ninja POE2 BD 页面底部展示可复制的技能表格,并支持技能名称语言切换 // @author 维克牛 // @license MIT // @updateURL https://gitee.com/aprilfool/Script/raw/main/Tampermonkey/POE2-NinjaBD-enhancer/POE2-NinjaBD-enhancer.user.js // @downloadURL https://gitee.com/aprilfool/Script/raw/main/Tampermonkey/POE2-NinjaBD-enhancer/POE2-NinjaBD-enhancer.user.js // @match *://*/poe2/builds/* // @match *://*/poe2/profile/*/*/character/* // @match *://*/poe2/pob/* // @run-at document-idle // @grant GM_setClipboard // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @connect poe.ninja // @connect poe2db.tw // @connect cdn.poe2db.tw // ==/UserScript== (function () { "use strict"; const SCRIPT_VERSION = "1.1.10"; const INSTANCE_KEY = "__POE2_NINJABD_ENHANCER_ACTIVE__"; const PANEL_ID = "codex-poe2-ninja-skill-panel"; const existingInstance = window[INSTANCE_KEY]; if (existingInstance && (document.getElementById(PANEL_ID) || (existingInstance.version === SCRIPT_VERSION && Date.now() - existingInstance.startedAt < 5000))) { console.info("POE2 NinjaBD增强已运行,跳过重复实例。"); return; } window[INSTANCE_KEY] = { version: SCRIPT_VERSION, startedAt: Date.now() }; const API_ROOT = "https://poe.ninja/poe2/api/profile/characters"; const STYLE_ID = "codex-poe2-ninja-skill-style"; const SCRIPT_UPDATED_AT = "2026-07-07 14:47:56"; const DEFAULT_HOSTS = ["poe.ninja", "www.poe.ninja", "poe.show", "www.poe.show", "ninja.710421059.xyz"]; const MIRROR_HOSTS_KEY = "codex_poe2_ninja_mirror_hosts"; const NAME_MAP_CACHE_KEY = "codex_poe2_ninja_name_maps_v1"; const DIRECT_NAME_CACHE_KEY = "codex_poe2_ninja_direct_names_v1"; const COLLAPSE_STORAGE_KEY = "codex_poe2_ninjabd_collapse_v1"; const NAME_MAP_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; const NAME_LANGS = ["us", "cn", "tw"]; const NAME_LANG_LABELS = { us: "EN", cn: "简体", tw: "繁体" }; let currentNameLang = "us"; let currentSort = "original"; let currentFilter = "all"; let lastRows = []; let lastEquipment = []; let lastJewels = []; let lastText = ""; let currentModel = null; let currentPobTalent = null; let currentPobSummary = ""; let currentRoute = null; let nameLangState = "ready"; let nameLangMessage = ""; let poe2dbNameMaps = null; let poe2dbNameMapsPromise = null; let directNameMaps = null; let collapsedSections = gmGet(COLLAPSE_STORAGE_KEY, {}); function cleanText(value) { return String(value ?? "") .replace(/\[([^|\]]+)\|([^\]]+)\]/g, "$2") .replace(/<[^>]+>/g, "") .replace(/\s+/g, " ") .trim(); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", })[char]); } function normalizeNameKey(value) { return cleanText(value) .replace(/[()]/g, (char) => (char === "(" ? "(" : ")")) .toLowerCase(); } function cleanPoe2dbLabel(value) { return cleanText(value).replace(/<[^>]+>/g, "").trim(); } function simplifiedToTraditionalFallback(value) { const map = { "战":"戰","飞":"飛","级":"級","灵":"靈","体":"體","击":"擊","发":"發","触":"觸","电":"電","净":"淨","虚":"虛","冲":"衝","践":"踐","护":"護","报":"報","复":"復","从":"從","军":"軍","扩":"擴","范":"範","围":"圍","过":"過","载":"載","罗":"羅","纳":"納","狙":"狙","记":"記","义":"義","惧":"懼","赠":"贈","猎":"獵","弹":"彈","回":"迴","斩":"斬","区":"區","间":"間","鱼":"魚","鸟":"鳥","龙":"龍","风":"風","云":"雲","会":"會","转":"轉","伤":"傷","强":"強","袭":"襲","双":"雙","释":"釋","压":"壓","绝":"絕","对":"對","应":"應","导":"導","连":"連","锁":"鎖","链":"鏈","扫":"掃","荡":"盪","圣":"聖","锤":"錘","锻":"鍛","炼":"煉","术":"術","师":"師","药":"藥","剂":"劑" }; return cleanText(value).replace(/[\u4e00-\u9fff]/g, (char) => map[char] || char); } function gmGet(key, fallback) { try { if (typeof GM_getValue === "function") return GM_getValue(key, fallback); } catch (_) {} try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (_) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === "function") { GM_setValue(key, value); return; } } catch (_) {} try { localStorage.setItem(key, JSON.stringify(value)); } catch (_) {} } function isSectionCollapsed(key) { return Boolean(key && collapsedSections?.[key]); } function setSectionCollapsed(key, collapsed) { if (!key) return; collapsedSections = { ...(collapsedSections || {}), [key]: Boolean(collapsed) }; gmSet(COLLAPSE_STORAGE_KEY, collapsedSections); } function applySectionCollapsed(section, key) { const collapsed = isSectionCollapsed(key); section.classList.toggle("codex-collapsed", collapsed); const button = section.querySelector("[data-collapse-section]"); if (button) button.textContent = collapsed ? "展开" : "折叠"; } function sectionTitleHtml(title, detail, key = "") { return `
${escapeHtml(title)} ${escapeHtml(detail || "")} ${key ? `` : ""}
`; } function mirrorHosts() { const saved = gmGet(MIRROR_HOSTS_KEY, []); const list = Array.isArray(saved) ? saved : []; return [...new Set([...DEFAULT_HOSTS, ...list].map(normalizeHost).filter(Boolean))]; } function customMirrorHosts() { const saved = gmGet(MIRROR_HOSTS_KEY, []); return (Array.isArray(saved) ? saved : []).map(normalizeHost).filter(Boolean); } function saveCustomMirrorHosts(hosts) { gmSet(MIRROR_HOSTS_KEY, [...new Set(hosts.map(normalizeHost).filter(Boolean))]); } function normalizeHost(value) { const text = cleanText(value); if (!text) return ""; try { return new URL(text.includes("://") ? text : `https://${text}`).hostname.toLowerCase(); } catch (_) { return text.replace(/^https?:\/\//, "").split("/")[0].toLowerCase(); } } function openMirrorSettings() { const currentHost = normalizeHost(location.hostname || ""); const custom = customMirrorHosts(); const existingText = custom.join("\n"); const hint = [ "每行一个镜像站域名或地址。", "默认已支持 poe.ninja、poe.show、ninja.710421059.xyz。", currentHost && !mirrorHosts().includes(currentHost) ? `当前域名:${currentHost}` : "", ].filter(Boolean).join("\n"); const input = prompt(`${hint}\n\n自定义镜像站:`, existingText || currentHost); if (input == null) return; const hosts = input.split(/\r?\n|,/).map(normalizeHost).filter(Boolean); saveCustomMirrorHosts(hosts); alert(`POE2 NinjaBD增强:已保存 ${hosts.length} 个自定义镜像站。刷新页面后生效。`); } function registerMenuCommands() { if (typeof GM_registerMenuCommand !== "function") return; GM_registerMenuCommand("设置 NinjaBD 镜像站", openMirrorSettings); } function requestText(url, responseType = "text", timeoutMs = 15000) { const gmRequest = typeof GM_xmlhttpRequest === "function" ? GM_xmlhttpRequest : (typeof GM === "object" && typeof GM.xmlHttpRequest === "function" ? GM.xmlHttpRequest : null); if (gmRequest) { return new Promise((resolve, reject) => { gmRequest({ method: "GET", url, responseType, headers: { "Accept": responseType === "json" ? "application/json" : "text/plain", "Referer": "https://poe.ninja/" }, timeout: timeoutMs, onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(responseType === "json" ? response.response : response.responseText); } else { reject(new Error(`${url} HTTP ${response.status}`)); } }, onerror: () => reject(new Error(`请求失败:${url}`)), ontimeout: () => reject(new Error(`请求超时:${url}`)), }); }); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); return fetch(url, { credentials: "omit", signal: controller.signal }).then(async (response) => { if (!response.ok) throw new Error(`${url} HTTP ${response.status}`); return responseType === "json" ? response.json() : response.text(); }).finally(() => { clearTimeout(timer); }); } async function requestJson(url) { const value = await requestText(url, "json"); return typeof value === "string" ? JSON.parse(value) : value; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function packNameMaps(maps) { const byValue = {}; for (const lang of NAME_LANGS) byValue[lang] = Array.from(maps.byValue?.[lang]?.entries?.() || []); return { createdAt: Date.now(), byValue, byEnName: Array.from(maps.byEnName?.entries?.() || []), }; } function unpackNameMaps(cache) { if (!cache || Date.now() - Number(cache.createdAt || 0) > NAME_MAP_CACHE_TTL_MS) return null; const byValue = {}; for (const lang of NAME_LANGS) byValue[lang] = new Map(Array.isArray(cache.byValue?.[lang]) ? cache.byValue[lang] : []); const byEnName = new Map(Array.isArray(cache.byEnName) ? cache.byEnName : []); if (!byEnName.size || !byValue.cn?.size || !byValue.tw?.size) return null; return { byValue, byEnName }; } function loadCachedNameMaps() { if (poe2dbNameMaps) return poe2dbNameMaps; const cached = unpackNameMaps(gmGet(NAME_MAP_CACHE_KEY, null)); if (cached) poe2dbNameMaps = cached; return poe2dbNameMaps; } function saveCachedNameMaps(maps) { try { gmSet(NAME_MAP_CACHE_KEY, packNameMaps(maps)); } catch (error) { console.warn("POE2DB 名称缓存保存失败", error); } } function unpackDirectNameMaps(cache) { const maps = { cn: new Map(), tw: new Map(), failed: new Map() }; if (!cache || Date.now() - Number(cache.createdAt || 0) > NAME_MAP_CACHE_TTL_MS) return maps; maps.cn = new Map(Array.isArray(cache.cn) ? cache.cn : []); maps.tw = new Map(Array.isArray(cache.tw) ? cache.tw : []); maps.failed = new Map(Array.isArray(cache.failed) ? cache.failed : []); return maps; } function loadDirectNameMaps() { if (!directNameMaps) directNameMaps = unpackDirectNameMaps(gmGet(DIRECT_NAME_CACHE_KEY, null)); return directNameMaps; } function saveDirectNameMaps() { if (!directNameMaps) return; try { gmSet(DIRECT_NAME_CACHE_KEY, { createdAt: Date.now(), cn: Array.from(directNameMaps.cn.entries()), tw: Array.from(directNameMaps.tw.entries()), failed: Array.from(directNameMaps.failed.entries()), }); } catch (error) { console.warn("POE2DB 按需名称缓存保存失败", error); } } function poe2dbSlugFromName(name) { return cleanText(name) .replace(/\s*\([^)]*\)\s*$/g, "") .replace(/[’']/g, "") .replace(/[^A-Za-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); } function localizedTitleFromPoe2dbHtml(html) { const raw = String(html || ""); const metaTitle = raw.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) || raw.match(/]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); const title = metaTitle?.[1] || raw.match(/]*>([\s\S]*?)<\/title>/i)?.[1] || ""; return cleanText(title) .replace(/\s*-\s*(PoE2DB|流亡2编年史|流亡編年史|Path of Exile 2).*/i, "") .replace(/\s*-\s*Path of Exile 2 Wiki.*/i, "") .trim(); } function collectTranslatableNames() { const names = []; for (const row of lastRows) { names.push(row.name); row.inserted.forEach((item) => names.push(item.name)); row.dpsRows.forEach((dps) => names.push(dps.name)); } for (const row of [...lastEquipment, ...lastJewels]) { names.push(row.name, row.baseType); row.socketed.forEach((name) => names.push(name)); } return [...new Set(names.map(cleanText).filter((name) => name && name !== "-" && /[A-Za-z]/.test(name)))]; } async function loadDirectTranslationsForCurrent(lang) { if (lang === "us") return; const maps = loadDirectNameMaps(); const target = maps[lang] || (maps[lang] = new Map()); const names = collectTranslatableNames().filter((name) => { const key = normalizeNameKey(name); return !target.has(key) && !maps.failed.has(`${lang}:${key}`); }); if (!names.length) return; let changed = false; const queue = names.slice(0, 40); const deadline = Date.now() + 12000; const workers = Array.from({ length: Math.min(6, queue.length) }, async () => { while (queue.length && Date.now() < deadline) { const name = queue.shift(); const key = normalizeNameKey(name); const slug = poe2dbSlugFromName(name); if (!slug) continue; try { const html = await requestText(`https://poe2db.tw/${lang}/${encodeURIComponent(slug)}`, "text", 3500); const translated = localizedTitleFromPoe2dbHtml(html); if (translated && normalizeNameKey(translated) !== key && !/^PoE2DB/i.test(translated)) { target.set(key, translated); } else { maps.failed.set(`${lang}:${key}`, Date.now()); } changed = true; } catch (_) { maps.failed.set(`${lang}:${key}`, Date.now()); changed = true; } } }); await Promise.allSettled(workers); if (changed) saveDirectNameMaps(); } function decodePart(value) { return decodeURIComponent(String(value || "")).replace(/^\/+|\/+$/g, ""); } function parseCharacterLink(value) { const parsed = new URL(value, location.href); const parts = parsed.pathname.split("/").map(decodePart).filter(Boolean); if (parts.length >= 6 && parts[0] === "poe2" && parts[1] === "profile" && parts[4] === "character") { return { account: parts[2], league: parts[3], character: parts[5] }; } if (parts.length >= 6 && parts[0] === "poe2" && parts[1] === "builds" && parts[3] === "character") { return { account: parts[4], league: parts[2], character: parts[5] }; } if (parts.length >= 3 && parts[0] === "poe2" && parts[1] === "pob") { return { pobCodeId: parts[2] }; } for (const candidate of [parsed.search, parsed.hash, value]) { const unquoted = decodeURIComponent(candidate || ""); let match = unquoted.match(/\/poe2\/profile\/([^/?#]+)\/([^/?#]+)\/character\/([^/?#]+)/); if (match) return { account: decodePart(match[1]), league: decodePart(match[2]), character: decodePart(match[3]) }; match = unquoted.match(/\/poe2\/builds\/([^/?#]+)\/character\/([^/?#]+)\/([^/?#]+)/); if (match) return { account: decodePart(match[2]), league: decodePart(match[1]), character: decodePart(match[3]) }; } return null; } function shouldActivate() { const route = parseCharacterLink(location.href); if (route) { currentRoute = route; return true; } return false; } function modelUrl(route, version) { const parts = [route.account, route.league, route.character].map((part) => encodeURIComponent(part)); return `${API_ROOT}/${parts[0]}/${parts[1]}/${parts[2]}/model/${version}`; } function pobRawUrl(route) { return `https://poe.ninja/poe2/pob/raw/${encodeURIComponent(route.pobCodeId)}`; } function base64UrlToBytes(value) { const normalized = cleanText(value).replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; } async function inflatePobCode(value) { const bytes = base64UrlToBytes(value); if (typeof DecompressionStream === "function") { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")); return new Response(stream).text(); } throw new Error("当前浏览器不支持 DecompressionStream,无法在脚本内解压 PoB。"); } function parseXml(value) { const doc = new DOMParser().parseFromString(value, "application/xml"); const error = doc.querySelector("parsererror"); if (error) throw new Error(cleanText(error.textContent) || "PoB XML 解析失败"); return doc; } function xmlAttr(node, name, fallback = "") { return cleanText(node?.getAttribute?.(name) ?? fallback); } function pobGemName(gem) { return xmlAttr(gem, "nameSpec") || xmlAttr(gem, "skillId") || xmlAttr(gem, "variantId") || "-"; } function isPobSupportGem(gem) { const gemId = xmlAttr(gem, "gemId"); const skillId = xmlAttr(gem, "skillId"); const variantId = xmlAttr(gem, "variantId"); return /SupportGem|^Support|Support/i.test(`${gemId} ${skillId} ${variantId}`); } function normalizePobSkillType(gem, inserted) { const name = pobGemName(gem); const idText = `${xmlAttr(gem, "gemId")} ${xmlAttr(gem, "skillId")} ${xmlAttr(gem, "variantId")}`; if (/CastOn|Meta|Blasphemy|Herald|Aura|Banner|Persistent/i.test(idText) || inserted.some((row) => row.rowType === "主动")) { return { label: "被动技能", className: "codex-type-passive" }; } if (/Minion|Spirit|Reservation/i.test(idText + " " + name)) return { label: "精魂技能", className: "codex-type-spirit" }; return { label: "主动技能", className: "codex-type-active" }; } function pobGemLevel(gem) { const level = xmlAttr(gem, "level"); return level && level !== "0" ? level : "-"; } function pobGemQuality(gem, isActiveLike) { const quality = xmlAttr(gem, "quality"); if (!quality || quality === "0") return isActiveLike ? "品质0%" : "-"; return `品质${quality}%`; } function normalizePobSkills(doc) { const skillSets = Array.from(doc.querySelectorAll("Skills > SkillSet")); const skillSet = skillSets.find((set) => xmlAttr(set, "id") === "1") || skillSets[0]; if (!skillSet) return []; return Array.from(skillSet.querySelectorAll(":scope > Skill")) .filter((skill) => xmlAttr(skill, "enabled", "true") !== "false") .map((skill, index) => { const gems = Array.from(skill.querySelectorAll(":scope > Gem")) .filter((gem) => xmlAttr(gem, "enabled", "true") !== "false"); const active = gems.find((gem) => !isPobSupportGem(gem)) || gems[0]; const inserted = gems.filter((gem) => gem !== active).map((gem) => { const rowType = isPobSupportGem(gem) ? "被动" : "主动"; const isActiveLike = rowType === "主动"; return { name: pobGemName(gem), rowType, level: pobGemLevel(gem), quality: pobGemQuality(gem, isActiveLike), sockets: "-", requirements: "-", details: [], }; }); const type = normalizePobSkillType(active, inserted); return { originalIndex: index, name: pobGemName(active) || `PoB Skill ${index + 1}`, level: pobGemLevel(active), quality: pobGemQuality(active, true), sockets: gems.length || "-", tags: "-", requirements: "-", details: [], skillType: type.label, skillTypeClass: type.className, inserted, dpsRows: [], }; }) .filter((row) => row.name && row.name !== "-"); } function pobSlotToInventoryId(slotName) { const clean = cleanText(slotName); const map = { "Weapon 1": "Weapon", "Weapon 2": "Offhand", "Weapon 1 Swap": "Weapon2", "Weapon 2 Swap": "Offhand2", "Body Armour": "BodyArmour", Helmet: "Helm", Gloves: "Gloves", Boots: "Boots", Belt: "Belt", Amulet: "Amulet", "Ring 1": "Ring", "Ring 2": "Ring2", "Ring 3": "Ring3", "Flask 1": "LifeFlask", "Flask 2": "ManaFlask", "Flask 3": "Flask", "Charm 1": "Charm", "Charm 2": "Charm", "Charm 3": "Charm", }; return map[clean] || clean.replace(/\s+/g, ""); } function splitPobItemLines(itemNode) { return String(itemNode?.textContent || "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .filter((line) => line !== "--------"); } function cleanPobModLine(line) { return cleanText(line).replace(/^(?:\{[^}]+\})+/, "").trim(); } function parsePobItemNode(itemNode) { const lines = splitPobItemLines(itemNode); const first = lines[0] || ""; const rarity = first.replace(/^Rarity:\s*/i, "") || "-"; const name = cleanText(lines[1] || "-"); const baseType = cleanText(lines[2] || ""); const properties = []; const socketed = []; const mods = []; let ilvl = ""; let corrupted = false; const modBuckets = new Map(); const addMod = (label, text) => { const clean = cleanPobModLine(text); if (!clean) return; if (!modBuckets.has(label)) modBuckets.set(label, []); modBuckets.get(label).push(clean); }; for (const line of lines.slice(3)) { if (/^Unique ID:/i.test(line)) continue; if (/^Item Level:/i.test(line)) { ilvl = cleanText(line.replace(/^Item Level:\s*/i, "")); continue; } if (/^LevelReq:/i.test(line)) { properties.push(`需求 等级 ${cleanText(line.replace(/^LevelReq:\s*/i, ""))}`); continue; } if (/^Quality:/i.test(line)) { properties.push(`品质 +${cleanText(line.replace(/^Quality:\s*/i, ""))}%`); continue; } if (/^Sockets:/i.test(line)) { properties.push(`插槽 ${cleanText(line.replace(/^Sockets:\s*/i, ""))}`); continue; } if (/^Rune:/i.test(line)) { socketed.push(cleanText(line.replace(/^Rune:\s*/i, ""))); continue; } if (/^(Strength|Dexterity|Intelligence)Req:/i.test(line)) { properties.push(`需求 ${cleanText(line.replace(/Req:/i, " "))}`); continue; } if (/^Implicits?:/i.test(line)) continue; if (/^Corrupted$/i.test(line)) { corrupted = true; continue; } if (/^Charm Slots:/i.test(line) || /^Energy Shield:|^Armour:|^Evasion:|^Ward:|^Physical Damage:|^Critical Strike Chance:|^Attacks per Second:/i.test(line)) { properties.push(cleanText(line)); continue; } if (/^\{enchant\}/i.test(line)) addMod("附魔/涂油", line); else if (/^\{desecrated\}/i.test(line)) addMod("特殊词缀", line); else if (/^\{crafted\}/i.test(line)) addMod("工艺", line); else if (/^\{fractured\}/i.test(line)) addMod("破裂", line); else if (/^\{rune\}/i.test(line)) addMod("符文/镶嵌", line); else addMod("装备词缀", line); } for (const [label, groupLines] of modBuckets.entries()) mods.push({ label, lines: groupLines }); return { id: xmlAttr(itemNode, "id"), rarity, name, baseType, properties, socketed, mods, ilvl, corrupted }; } function normalizePobItems(doc) { const itemMap = new Map(Array.from(doc.querySelectorAll("Items > Item")).map((node) => [xmlAttr(node, "id"), parsePobItemNode(node)])); const itemSet = doc.querySelector("Items > ItemSet"); const equipment = []; if (itemSet) { Array.from(itemSet.querySelectorAll(":scope > Slot")).forEach((slot, index) => { const itemId = xmlAttr(slot, "itemId"); if (!itemId || itemId === "0" || !itemMap.has(itemId)) return; const slotName = xmlAttr(slot, "name"); const inventoryId = pobSlotToInventoryId(slotName); const parsed = itemMap.get(itemId); const entry = { itemData: { inventoryId, name: parsed.name, typeLine: parsed.baseType, baseType: parsed.baseType, frameTypeId: parsed.rarity ? parsed.rarity[0] + parsed.rarity.slice(1).toLowerCase() : "", properties: parsed.properties.map((text) => ({ name: text, values: [] })), implicitMods: [], explicitMods: [], enchantMods: [], craftedMods: [], fracturedMods: [], runeMods: [], desecratedMods: [], socketedItems: parsed.socketed.map((name) => ({ name })), corrupted: parsed.corrupted, ilvl: parsed.ilvl, }, }; for (const group of parsed.mods) { const target = { "底材词缀": "implicitMods", "附魔/涂油": "enchantMods", "装备词缀": "explicitMods", "破裂": "fracturedMods", "工艺": "craftedMods", "符文/镶嵌": "runeMods", "特殊词缀": "desecratedMods", }[group.label] || "explicitMods"; entry.itemData[target] = group.lines; } equipment.push(normalizeItem(entry, index, itemKind(entry, "装备"))); }); } const jewelIds = new Map(); Array.from(doc.querySelectorAll("Tree > Spec > Sockets > Socket")).forEach((socket, index) => { const itemId = xmlAttr(socket, "itemId"); if (itemId && itemId !== "0" && itemMap.has(itemId)) jewelIds.set(itemId, { index, nodeId: xmlAttr(socket, "nodeId") }); }); const jewels = Array.from(jewelIds.entries()).map(([itemId, info]) => { const parsed = itemMap.get(itemId); const entry = { itemData: { inventoryId: "PassiveJewels", name: parsed.name, typeLine: parsed.baseType, baseType: parsed.baseType, frameTypeId: parsed.rarity ? parsed.rarity[0] + parsed.rarity.slice(1).toLowerCase() : "", properties: parsed.properties.map((text) => ({ name: text, values: [] })), explicitMods: parsed.mods.flatMap((group) => group.lines), corrupted: parsed.corrupted, ilvl: parsed.ilvl, }, }; const row = normalizeItem(entry, info.index, "珠宝"); row.position = info.nodeId ? `珠宝槽 ${info.nodeId}` : `珠宝槽 ${info.index + 1}`; return row; }); return { equipment: equipment.sort((a, b) => equipmentSortValue(a) - equipmentSortValue(b) || a.originalIndex - b.originalIndex), jewels, }; } function normalizePobTalent(doc) { const build = doc.querySelector("Build"); const spec = doc.querySelector("Tree > Spec"); const nodes = xmlAttr(spec, "nodes").split(",").map(cleanText).filter(Boolean); const sockets = Array.from(doc.querySelectorAll("Tree > Spec > Sockets > Socket")).filter((socket) => xmlAttr(socket, "itemId") && xmlAttr(socket, "itemId") !== "0"); if (!nodes.length && !sockets.length && !build) return null; return { treeName: [xmlAttr(build, "ascendClassName"), xmlAttr(build, "className"), xmlAttr(build, "level") ? `Lv${xmlAttr(build, "level")}` : ""].filter(Boolean).join(" / "), total: nodes.length, ascendancy: 0, anoints: 0, bonusPassives: 0, set1Count: 0, set2Count: 0, questStats: sockets.length ? [`已插入天赋珠宝 ${sockets.length} 个`] : [], keystones: [], }; } async function normalizePobBuild(code, codeId) { const xml = await inflatePobCode(code); const doc = parseXml(xml); const build = doc.querySelector("Build"); const items = normalizePobItems(doc); return { rows: normalizePobSkills(doc), equipment: items.equipment, jewels: items.jewels, talent: normalizePobTalent(doc), summary: `PoB ${codeId}${xmlAttr(build, "level") ? ` | Lv${xmlAttr(build, "level")}` : ""}${xmlAttr(build, "ascendClassName") ? ` ${xmlAttr(build, "ascendClassName")}` : ""}`, }; } function modelChar(data) { return data?.type === "found" ? data.charModel : (data?.charModel || data); } async function fetchLatestModel(route) { const candidates = [...Array.from({ length: 20 }, (_, index) => index + 1), 0]; let bestData = null; let bestVersion = null; let bestUpdated = ""; for (const version of candidates) { try { const data = await requestJson(modelUrl(route, version)); const char = modelChar(data); if (!char?.skills) continue; const updated = String(char.updatedUtc || char.lastCheckedUtc || ""); if (!bestData || updated > bestUpdated) { bestData = data; bestVersion = version; bestUpdated = updated; } } catch (error) { console.warn("poe.ninja model fetch failed", version, error); } } if (!bestData) throw new Error("未读取到 poe.ninja 角色数据"); currentModel = { data: bestData, version: bestVersion }; return bestData; } async function loadPoe2dbNameMaps(force = false) { if (!force && poe2dbNameMaps) return poe2dbNameMaps; if (!force) { const cached = loadCachedNameMaps(); if (cached) return cached; } if (!force && poe2dbNameMapsPromise) return poe2dbNameMapsPromise; if (force) poe2dbNameMapsPromise = null; poe2dbNameMapsPromise = (async () => { const html = await requestText("https://poe2db.tw/cn/", "text", 8000); const headerMatch = html.match(/https:\/\/cdn\.poe2db\.tw\/js\/poedb_header\.[a-f0-9]+\.js/); if (!headerMatch) throw new Error("未找到 POE2DB header 脚本"); const headerJs = await requestText(headerMatch[0], "text", 8000); const files = {}; for (const lang of NAME_LANGS) { const match = headerJs.match(new RegExp(`autocompletecb_${lang}\\.[a-z0-9]+\\.json`, "i")); if (match) files[lang] = `https://cdn.poe2db.tw/json/${match[0]}`; } const byValue = { ...(poe2dbNameMaps?.byValue || {}) }; const byEnName = new Map(poe2dbNameMaps?.byEnName || []); await Promise.allSettled(NAME_LANGS.map(async (lang) => { if (!files[lang]) return; const list = JSON.parse(await requestText(files[lang], "text", 8000)); byValue[lang] = new Map(); for (const item of Array.isArray(list) ? list : []) { const value = cleanText(item.value || ""); const label = cleanPoe2dbLabel(item.label || ""); if (!value || !label) continue; byValue[lang].set(value, label); if (lang === "us") { byEnName.set(label, value); byEnName.set(normalizeNameKey(label), value); } } })); if (!byEnName.size || !byValue.cn?.size || !byValue.tw?.size) { throw new Error("POE2DB 全量名称表为空"); } poe2dbNameMaps = { byValue, byEnName }; saveCachedNameMaps(poe2dbNameMaps); return poe2dbNameMaps; })(); return poe2dbNameMapsPromise; } function localizedName(name) { const cleanName = cleanText(name); if (!cleanName || currentNameLang === "us") return cleanName; const key = normalizeNameKey(cleanName); const direct = loadDirectNameMaps()?.[currentNameLang]?.get(key); if (direct) return currentNameLang === "tw" ? simplifiedToTraditionalFallback(direct) : direct; if (poe2dbNameMaps) { const value = poe2dbNameMaps.byEnName.get(cleanName) || poe2dbNameMaps.byEnName.get(key); if (value) { const translated = poe2dbNameMaps.byValue[currentNameLang]?.get(value); if (translated) return currentNameLang === "tw" ? simplifiedToTraditionalFallback(translated) : translated; } } return currentNameLang === "tw" ? simplifiedToTraditionalFallback(cleanName) : cleanName; } async function ensureNameLanguageLoaded() { if (currentNameLang === "us") { nameLangState = "ready"; nameLangMessage = ""; await renderRows(); return true; } nameLangState = "loading"; nameLangMessage = `${NAME_LANG_LABELS[currentNameLang]} 名称加载中`; updateControls(); try { const selectedLang = currentNameLang; const directPromise = loadDirectTranslationsForCurrent(selectedLang); await Promise.race([ directPromise, sleep(3500), ]); directPromise .then(() => currentNameLang === selectedLang ? renderRows() : null) .catch((error) => console.warn("POE2DB 按需名称后台加载失败", error)); loadPoe2dbNameMaps(false) .then(() => currentNameLang !== "us" ? renderRows() : null) .catch((error) => console.warn("POE2DB 全量名称表后台加载失败", error)); nameLangState = "ready"; nameLangMessage = ""; await renderRows(); return true; } catch (error) { nameLangState = "error"; nameLangMessage = "POE2DB 名称加载失败"; console.warn("POE2DB 技能名称加载失败", error); updateControls(); return false; } } function preloadNameMaps() { if (poe2dbNameMaps || poe2dbNameMapsPromise) return; setTimeout(() => { loadPoe2dbNameMaps(false).catch((error) => console.warn("POE2DB 名称预加载失败", error)); }, 500); } function propValue(item, names) { const wanted = new Set(names.map((name) => name.toLowerCase())); for (const prop of item?.properties || []) { const propName = cleanText(prop.name).toLowerCase(); if (!wanted.has(propName)) continue; return cleanText(prop.values?.[0]?.[0]); } return ""; } function fieldValueText(field) { if (typeof field === "string") return cleanText(field); const values = field?.values; if (Array.isArray(values)) { const parts = values.map((value) => Array.isArray(value) ? value[0] : value).map(cleanText).filter(Boolean); if (parts.length) return parts.join(" "); } return cleanText(field?.value ?? field?.displayValue ?? field?.text ?? ""); } function fieldText(field) { if (typeof field === "string") return cleanText(field); const name = cleanText(field?.name); const values = Array.isArray(field?.values) ? field.values.map((value) => Array.isArray(value) ? value[0] : value).map(cleanText).filter(Boolean) : []; if (name && values.length && /\{\d+\}/.test(name)) { return cleanText(name.replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? "")); } const value = values.length ? values.join(" ") : fieldValueText(field); return cleanText(name && value ? `${name} ${value}` : (name || value)); } function isSpiritRequirementText(text) { return /(Spirit|精魂).{0,24}(Reservation|Reserve|Cost|保留|消耗)|(?:Reservation|Reserve|Cost|保留|消耗).{0,24}(Spirit|精魂)/i.test(text); } function isSkillDetailText(text) { if (isSpiritRequirementText(text)) return false; return /(Cost|消耗|Reservation|Reserve|保留|Cooldown|冷却|Cast Time|施放时间|Attack Time|攻击速度|Attack Damage|攻击伤害|Critical|暴击|Multiplier|加成|Additional Reservation|额外保留)/i.test(text); } function spiritRequirementParts(item) { const fields = [ ...(item?.properties || []), ...(item?.requirements || []), ...(item?.explicitMods || []), ...(item?.implicitMods || []), ]; return fields .map(fieldText) .filter((text) => text && isSpiritRequirementText(text)); } function skillDetailParts(item) { return (item?.properties || []) .map(fieldText) .filter((text) => text && isSkillDetailText(text)); } function formatRequirementName(name) { return cleanText(name) .replace(/^\+?\d+\s*/, "") .replace(/\((\d+)\)/g, "$1") .replace("Strength", "Str") .replace("Dexterity", "Dex") .replace("Intelligence", "Int"); } function formatRequirements(item) { const rows = [...(item?.requirements || []), ...(item?.supportGemRequirements || []), ...(item?.weaponRequirements || [])]; const seen = new Set(); const parts = []; for (const row of rows) { if (typeof row === "string") { const text = cleanText(row); if (text && !seen.has(text)) { seen.add(text); parts.push(text); } continue; } const rawName = cleanText(row?.name || row?.type || row?.id || ""); const value = fieldValueText(row).replace(/[()]/g, ""); let text = ""; if (/^\+\d+/.test(rawName)) { text = rawName.replace(/\[(Strength|Dexterity|Intelligence)\|([^\]]+)\]/g, "$2").replace(/\s+/g, " "); } else { const name = formatRequirementName(rawName); text = name && value ? `${name} ${value}` : (name || value); } text = cleanText(text); if (text && !seen.has(text)) { seen.add(text); parts.push(text); } } for (const spiritPart of spiritRequirementParts(item)) { if (!seen.has(spiritPart)) { seen.add(spiritPart); parts.push(spiritPart); } } return parts.join(" / "); } function gemItem(gem) { return gem?.itemData || gem || {}; } function gemName(gem) { const item = gemItem(gem); return cleanText(gem?.name || item.typeLine || item.baseType || item.name || ""); } function gemLevel(gem) { const item = gemItem(gem); return cleanText(gem?.level || propValue(item, ["Level", "等级"]) || ""); } function gemQuality(gem) { const item = gemItem(gem); const value = gem?.quality ?? item.qualityProperty ?? propValue(item, ["Quality", "[Quality]", "品质"]); const text = cleanText(value).replace(/^\+/, "").replace(/%$/, ""); if (!text || text === "-") return ""; return `品质${text}%`; } function gemSockets(gem) { const item = gemItem(gem); if (Array.isArray(item.gemSockets)) return item.gemSockets.length; if (Array.isArray(item.sockets)) return item.sockets.filter((socket) => socket?.type === "gem").length; return ""; } function gemTags(gem) { const item = gemItem(gem); const prop = item.properties?.[0]?.name || ""; return cleanText(prop); } function isLineageSupport(gem) { const item = gemItem(gem); const fields = [ ...(item.properties || []), ...(item.gemTabs || []), ...(item.explicitMods || []), ]; return fields.some((field) => /LineageSupports|Lineage|血脉|血脈/i.test(cleanText(`${field?.name || ""} ${JSON.stringify(field?.values || "")}`))); } function hasSpiritReservation(gem) { const item = gemItem(gem); const fields = [...(item.properties || []), ...(item.requirements || []), ...(item.explicitMods || [])]; return fields.some((field) => isSpiritRequirementText(fieldText(field))); } function skillType(gem) { const item = gemItem(gem); if (hasSpiritReservation(gem)) return { label: "精魂技能", className: "codex-type-spirit" }; const tags = gemTags(gem); if (/\b(Persistent|Aura|Herald|Buff)\b|永久性|光环|捷/.test(tags)) return { label: "被动技能", className: "codex-type-passive" }; if (item.inventoryId && item.inventoryId !== "SkillSlots") return { label: "装备赋予", className: "codex-type-granted" }; return { label: "主动技能", className: "codex-type-active" }; } function normalizeSkillGroup(group, index) { const gems = group?.allGems || []; let active = gems.find((gem) => !gemItem(gem).support) || gems[0] || {}; const activeItem = gemItem(active); const inserted = []; const socketedItems = activeItem.socketedItems?.length ? activeItem.socketedItems.map((item) => ({ itemData: item, name: item.typeLine || item.baseType })) : gems.filter((gem) => gem !== active); for (const gem of socketedItems) { const item = gemItem(gem); const rowType = item.support ? (isLineageSupport(gem) ? "血脉" : "被动") : "主动"; const isActiveLike = rowType === "主动"; const quality = gemQuality(gem); const level = gemLevel(gem); inserted.push({ name: gemName(gem), rowType, level: level || "-", quality: quality || (isActiveLike ? "品质0%" : "-"), sockets: isActiveLike ? (gemSockets(gem) || "-") : "-", requirements: formatRequirements(item) || "-", details: skillDetailParts(item), }); } const type = skillType(active); const level = gemLevel(active); const quality = gemQuality(active); const dpsRows = (group?.dps || []).filter((row) => row?.dps != null || row?.dotDps != null); return { originalIndex: index, name: gemName(active) || `Skill ${index + 1}`, level: level || "-", quality: quality || "品质0%", sockets: gemSockets(active) || gems.length || "-", tags: gemTags(active), requirements: formatRequirements(activeItem), details: skillDetailParts(activeItem), skillType: type.label, skillTypeClass: type.className, inserted, dpsRows: dpsRows.map((row) => ({ name: cleanText(row.name), dps: row.dps, dotDps: row.dotDps })), }; } function itemData(entry) { return entry?.itemData || entry || {}; } function itemName(entry) { const item = itemData(entry); return cleanText([item.name, item.typeLine || item.baseType].filter(Boolean).join(" ")) || cleanText(item.typeLine || item.baseType || item.name || "-"); } function itemBaseType(entry) { const item = itemData(entry); return cleanText(item.typeLine || item.baseType || ""); } function itemRarity(entry) { const item = itemData(entry); if (item.frameTypeId) return cleanText(item.frameTypeId); const map = { 0: "Normal", 1: "Magic", 2: "Rare", 3: "Unique", 4: "Gem", 5: "Currency", 6: "Divination", 9: "Relic" }; return map[item.frameType] || cleanText(item.rarity || "-"); } function localizedRarity(rarity) { const clean = cleanText(rarity); if (currentNameLang === "us" || !clean || clean === "-") return clean || "-"; const map = { Normal: "普通", Magic: "魔法", Rare: "稀有", Unique: "传奇", Gem: "宝石", Currency: "通货", Divination: "命运卡", Relic: "遗物", RunicUnique: "符文传奇", RunicRare: "符文稀有", RunicMagic: "符文魔法", RunicNormal: "符文普通", }; const translated = map[clean] || clean; return currentNameLang === "tw" ? simplifiedToTraditionalFallback(translated) : translated; } function rarityClass(rarity) { const label = cleanText(rarity); if (/Unique/i.test(label)) return "codex-rarity-unique"; if (/Rare/i.test(label)) return "codex-rarity-rare"; if (/Magic/i.test(label)) return "codex-rarity-magic"; return "codex-rarity-normal"; } function itemCategory(entry) { const item = itemData(entry); const raw = cleanText(item.inventoryId || entry?.inventoryId || entry?.itemSlot || ""); const label = cleanText([item.name, item.typeLine, item.baseType, item.display_name].filter(Boolean).join(" ")); const propText = (item.properties || []).map((field) => cleanText(field?.name || fieldText(field))).filter(Boolean).join(" "); if (/^Amulet$/i.test(raw)) return "项链"; if (/^Charm/i.test(raw) || (/^Flask$/i.test(raw) && /(^|\s)(护符|咒符)(\s|$)|\bCharm\b(?!\s*Slots)/i.test(propText))) return "护符"; if (/^LifeFlask$/i.test(raw) || /生命|Life/i.test([label, propText].join(" "))) return "生命药剂"; if (/^ManaFlask$/i.test(raw) || /魔力|Mana/i.test([label, propText].join(" "))) return "魔力药剂"; if (/Flask/i.test(raw) || /药剂|\bFlask\b/i.test([label, propText].join(" "))) return "药剂"; if (!/^Amulet$/i.test(raw) && /(^|\s)(护符|咒符)(\s|$)|\bCharm\b/i.test(label)) return "护符"; return ""; } function itemSlot(entry, index) { const item = itemData(entry); const raw = cleanText(item.inventoryId || entry?.inventoryId || entry?.itemSlot || ""); const category = itemCategory(entry); const map = { BodyArmour: "胸甲", Helm: "头盔", Gloves: "手套", Boots: "鞋子", Belt: "腰带", Amulet: "项链", Ring: "戒指", Ring2: "戒指2", Ring3: "戒指3", Weapon: "武器", Weapon2: "武器2", Offhand: "副手", Offhand2: "副手2", LifeFlask: "生命药剂", ManaFlask: "魔力药剂", Flask: "药剂", Charm: "护符", Charms: "护符", PassiveJewels: "珠宝", }; if (/^(Flask|LifeFlask|ManaFlask|Charm|Charms)$/i.test(raw) && category) return category; if (map[raw]) return map[raw]; if (category) return category; return raw || `#${index + 1}`; } function itemProperties(entry) { const item = itemData(entry); return (item.properties || []) .map(fieldText) .filter(Boolean) .filter((text) => !/Stack Size|Limited to/i.test(text)) .join(" / "); } function itemMods(entry) { const item = itemData(entry); const groups = [ ["底材词缀", item.implicitMods], ["附魔/涂油", item.enchantMods], ["装备词缀", item.explicitMods], ["破裂", item.fracturedMods], ["工艺", item.craftedMods], ["符文/镶嵌", item.runeMods], ["特殊词缀", item.desecratedMods], ["绑定", item.bondedMods], ]; return groups.map(([label, mods]) => ({ label, lines: (mods || []).map(cleanText).filter(Boolean), })).filter((group) => group.lines.length); } function equipmentSortValue(row) { if (row.kind === "护符" || row.slot === "护符") return 92; if (row.kind === "生命药剂" || row.slot === "生命药剂") return 120; if (row.kind === "魔力药剂" || row.slot === "魔力药剂") return 121; if (row.kind === "药剂" || /药剂/.test(row.slot || "")) return 122; const order = { Weapon: 10, Offhand: 11, Weapon2: 20, Offhand2: 21, Helm: 30, BodyArmour: 40, Gloves: 50, Boots: 60, Amulet: 70, Ring: 80, Ring2: 81, Ring3: 82, Belt: 90, Charm: 92, Charms: 92, LifeFlask: 120, ManaFlask: 121, Flask: 122, }; if (order[row.inventoryId] != null) return order[row.inventoryId]; return order[row.inventoryId] ?? 999; } function socketedItemNames(entry) { const item = itemData(entry); return (item.socketedItems || []).map((socketed) => cleanText(socketed.typeLine || socketed.baseType || socketed.name || "")).filter(Boolean); } function normalizeItem(entry, index, kind) { const item = itemData(entry); const properties = itemProperties(entry); const requirements = formatRequirements(item); return { originalIndex: index, kind, slot: itemSlot(entry, index), inventoryId: cleanText(item.inventoryId || entry?.inventoryId || ""), name: itemName(entry), baseType: itemBaseType(entry), rarity: itemRarity(entry), properties: [properties, requirements ? `需求 ${requirements}` : ""].filter(Boolean).join(" / ") || "-", mods: itemMods(entry), socketed: socketedItemNames(entry), corrupted: Boolean(item.corrupted), ilvl: item.ilvl || "", position: [item.x != null ? `x${item.x}` : "", item.y != null ? `y${item.y}` : ""].filter(Boolean).join(" / "), }; } function itemKind(entry, fallbackKind) { const item = itemData(entry); const raw = cleanText(item.inventoryId || entry?.inventoryId || entry?.itemSlot || ""); const category = itemCategory(entry); if (category && !/^Amulet$/i.test(raw)) return category; return fallbackKind; } function normalizeEquipment(char) { const equipment = (char.items || []).map((entry, index) => normalizeItem(entry, index, "装备")); const flasks = (char.flasks || []).map((entry, index) => normalizeItem(entry, index, itemKind(entry, "药剂"))); return [...equipment, ...flasks] .filter((item) => item.name && item.name !== "-") .sort((a, b) => equipmentSortValue(a) - equipmentSortValue(b) || a.originalIndex - b.originalIndex); } function normalizeJewels(char) { return (char.jewels || []).map((entry, index) => normalizeItem(entry, index, "珠宝")).filter((item) => item.name && item.name !== "-"); } function formatNumber(value) { if (value == null || value === "") return ""; const num = Number(value); if (!Number.isFinite(num)) return cleanText(value); return num >= 1000 ? Math.round(num).toLocaleString("en-US") : String(num); } function formatRowText(row, index) { const lines = []; lines.push(`${index + 1}. ${localizedName(row.name)} | Lv${row.level} | ${row.quality} | ${row.sockets}孔`); lines.push(` 类型: ${row.skillType}`); if (row.tags) lines.push(` 标签: ${localizedModText(row.tags)}`); if (row.requirements) lines.push(` 需求: ${localizedModText(row.requirements)}`); if (row.details?.length) lines.push(` 属性: ${row.details.map(localizedModText).join(" / ")}`); if (row.dpsRows.length) lines.push(` DPS: ${row.dpsRows.map((dps) => `${localizedName(dps.name)}: ${formatNumber(dps.dps || dps.dotDps)}`).join(" / ")}`); lines.push(` 已插入技能: ${row.inserted.length ? row.inserted.map((item) => { const detail = item.details?.length ? ` ${item.details.map(localizedModText).join(" / ")}` : ""; return `${item.rowType}:${localizedName(item.name)}${item.level !== "-" ? ` Lv${item.level}` : ""} ${item.quality}${detail}`; }).join(" / ") : "-"}`); return lines.join("\n"); } function splitListText(text) { const clean = cleanText(text); if (!clean || clean === "-") return []; return clean.split(/\s+\/\s+/).map((part) => cleanText(part)).filter(Boolean); } function itemStatusText(row) { const parts = []; if (row.corrupted) parts.push(currentNameLang === "us" ? "corrupted" : "已腐化"); if (row.ilvl) parts.push(`${currentNameLang === "us" ? "ilvl" : "物品等级"} ${row.ilvl}`); const text = parts.join(" / "); return currentNameLang === "tw" ? simplifiedToTraditionalFallback(text) : text; } function formatItemText(row, index) { const lines = []; const status = itemStatusText(row); lines.push(`${index + 1}. [${row.kind}] ${row.slot} | ${localizedItemName(row)} | ${localizedRarity(row.rarity)}${status ? ` | ${status}` : ""}`); const props = splitListText(row.properties).map(localizedModText); if (props.length) { lines.push(" 属性/需求:"); props.forEach((prop) => lines.push(` - ${prop}`)); } if (row.position) lines.push(` 位置: ${row.position}`); if (row.socketed.length) { lines.push(" 插槽:"); row.socketed.forEach((name) => lines.push(` - ${localizedItemLabel(name)}`)); } for (const group of row.mods) { lines.push(` ${group.label}:`); group.lines.forEach((mod, modIndex) => lines.push(` ${modIndex + 1}. ${localizedModText(mod)}`)); } return lines.join("\n"); } function normalizeTalent(char) { const passiveCounts = char?.passiveCounts || {}; const passives = Array.isArray(char?.passiveSelection) ? char.passiveSelection : []; const set1 = Array.isArray(char?.passiveSelectionSet1) ? char.passiveSelectionSet1 : []; const set2 = Array.isArray(char?.passiveSelectionSet2) ? char.passiveSelectionSet2 : []; const questStats = Array.isArray(char?.questStats) ? char.questStats.map(cleanText).filter(Boolean) : []; const keystones = Array.isArray(char?.keystones) ? char.keystones.map(cleanText).filter(Boolean) : []; const treeName = cleanText(char?.passiveTreeName || ""); const total = Number(passiveCounts.passives || 0) || passives.length || 0; const ascendancy = Number(passiveCounts.ascendancy || 0) || 0; const anoints = Number(passiveCounts.anoints || 0) || 0; const bonusPassives = Number(passiveCounts.bonusPassives || 0) || 0; if (!total && !ascendancy && !set1.length && !set2.length && !questStats.length && !keystones.length && !treeName) return null; return { total, ascendancy, anoints, bonusPassives, set1Count: set1.length, set2Count: set2.length, questStats, keystones, treeName }; } function formatTalentText(talent) { const lines = []; if (talent.treeName) lines.push(`天赋树: ${talent.treeName}`); if (talent.total) lines.push(`天赋点: ${talent.total}`); lines.push(`升华点: ${talent.ascendancy || 0}`); if (talent.anoints) lines.push(`涂油/配置: ${talent.anoints}`); if (talent.bonusPassives) lines.push(`额外天赋: ${talent.bonusPassives}`); lines.push(`武器1: ${talent.set1Count || 0}`); lines.push(`武器2: ${talent.set2Count || 0}`); if (talent.keystones?.length) { lines.push("关键天赋:"); talent.keystones.forEach((name, index) => lines.push(` ${index + 1}. ${localizedName(name)}`)); } if (talent.questStats?.length) { lines.push("任务奖励:"); talent.questStats.forEach((stat, index) => lines.push(` ${index + 1}. ${localizedModText(stat)}`)); } return lines.join("\n"); } function currentCopyText() { const parts = []; const char = currentModel ? modelChar(currentModel.data) : null; const talent = currentPobTalent || normalizeTalent(char); if (lastEquipment.length) parts.push("【装备/药剂/护符】\n" + lastEquipment.map(formatItemText).join("\n\n")); if (lastJewels.length) parts.push("【珠宝】\n" + lastJewels.map(formatItemText).join("\n\n")); if (talent) parts.push("【天赋/任务奖励】\n" + formatTalentText(talent)); parts.push("【技能】\n" + visibleRows(lastRows).map((row, index) => formatRowText(row, index)).join("\n\n")); return parts.filter(Boolean).join("\n\n"); } function localizedItemName(row) { if (currentNameLang === "us") return row.name; const rarity = String(row.rarity || ""); const translatedFull = localizedName(row.name); if (translatedFull && translatedFull !== row.name) return translatedFull; const translatedBase = row.baseType ? localizedName(row.baseType) : ""; if (translatedBase && translatedBase !== row.baseType) { if (/Rare|Magic/i.test(rarity) && row.name.endsWith(row.baseType)) { return `${row.name.slice(0, -row.baseType.length).trim()} ${translatedBase}`.trim(); } return translatedBase; } return localizedItemLabel(row.name); } function localizedItemLabel(text) { const clean = cleanText(text); if (!clean || clean === "-" || currentNameLang === "us") return clean || "-"; const translated = clean .replace(/\bTranscendent Mana Flask\b/g, "超凡魔力药剂") .replace(/\bGargantuan Life Flask\b/g, "巨型生命药剂") .replace(/\bMana Flask\b/g, "魔力药剂") .replace(/\bLife Flask\b/g, "生命药剂") .replace(/\bFlask\b/g, "药剂") .replace(/\bCharm\b/g, "护符") .replace(/\bVaal Ring\b/g, "瓦尔戒指") .replace(/\bRing\b/g, "戒指") .replace(/\bBelt\b/g, "腰带") .replace(/\bAmulet\b/g, "项链") .replace(/\bWand\b/g, "法杖") .replace(/\bShield\b/g, "盾") .replace(/\bHelmet\b/g, "头盔") .replace(/\bBody Armour\b/g, "胸甲") .replace(/\bBody Armor\b/g, "胸甲") .replace(/\bGloves\b/g, "手套") .replace(/\bBoots\b/g, "鞋子") .replace(/\bJewel\b/g, "珠宝"); return currentNameLang === "tw" ? simplifiedToTraditionalFallback(translated) : translated; } function localizedModText(text) { const clean = cleanText(text); if (!clean || clean === "-" || currentNameLang === "us") return clean || "-"; let translated = clean .replace(/\bArmoured Shield\b/g, "护甲盾") .replace(/\bLevel\b/g, "等级") .replace(/\bStr\b/g, "力量") .replace(/\bDex\b/g, "敏捷") .replace(/\bInt\b/g, "智慧") .replace(/\bAoE\b/g, "范围") .replace(/\bBuff\b/g, "增益") .replace(/\bPersistent\b/g, "永久") .replace(/\bTrigger\b/g, "触发") .replace(/\bMeta\b/g, "元技能") .replace(/\bMinion\b/g, "召唤生物") .replace(/\bCompanion\b/g, "伙伴") .replace(/\bSustained\b/g, "持续吟唱") .replace(/\bChannelling\b/g, "吟唱") .replace(/\bPhysical\b/g, "物理") .replace(/\bCold\b/g, "冰霜") .replace(/\bFire\b/g, "火焰") .replace(/\bLightning\b/g, "闪电") .replace(/\bChaos\b/g, "混沌") .replace(/\bDurationSkill\b/g, "持续时间") .replace(/\bDuration\b/g, "持续时间") .replace(/\bRemnant\b/g, "残片") .replace(/\bRepeatable\b/g, "可重复") .replace(/\bJewel\b/g, "珠宝") .replace(/\bRadius\b/g, "半径") .replace(/\bSmall\b/g, "小") .replace(/\bVery Large\b/g, "非常大") .replace(/\bLarge\b/g, "大") .replace(/\bVariable\b/g, "可变") .replace(/\bCaster Modifiers\b/g, "施法词缀") .replace(/\bLasts ([0-9.]+) Seconds?\b/g, "持续 $1 秒") .replace(/\bRecovers ([0-9.]+) Life over ([0-9.]+) Seconds?\b/g, "回复 $1 生命,持续 $2 秒") .replace(/\bRecovers ([0-9.]+) Mana every ([0-9.]+) Seconds?\b/g, "每 $2 秒回复 $1 魔力") .replace(/\bConsumes ([0-9.]+) of ([0-9.]+) Charges on use\b/g, "使用时消耗 $1/$2 充能") .replace(/\bCurrently has ([0-9.]+) Charges\b/g, "当前拥有 $1 充能") .replace(/\bAttack\b/g, "攻击") .replace(/\bMelee\b/g, "近战") .replace(/\bSpell\b/g, "法术") .replace(/\bProjectile\b/g, "投射物") .replace(/^Only Runes can be Socketed in this item$/g, "此物品只能镶嵌符文") .replace(/^(\d+)% increased effect of Socketed Runes$/g, "已镶嵌符文效果提高 $1%") .replace(/^Gain (\d+)% of maximum Life as Extra maximum Runic Ward$/g, "获得最大生命 $1% 的额外最大符文护盾") .replace(/^(\d+)% less maximum Life$/g, "最大生命更少 $1%") .replace(/^Gain (\d+)% of Damage as Extra Damage of all Elements$/g, "获得伤害的 $1% 作为所有元素的额外伤害") .replace(/^(\d+)% chance for Spell Skills to fire (\d+) additional Projectiles$/g, "法术技能有 $1% 几率额外发射 $2 个投射物") .replace(/^Bonded: (.+)$/g, "绑定:$1") .replace(/^Recover (\d+) Runic Ward when you Block$/g, "格挡时回复 $1 点符文护盾") .replace(/^(\d+) Life gained when you Block$/g, "格挡时获得 $1 点生命") .replace(/^(\d+) Mana gained when you Block$/g, "格挡时获得 $1 点魔力") .replace(/^(\d+)% increased Block chance$/g, "格挡率提高 $1%") .replace(/^Chance to Block Damage is Lucky$/g, "格挡伤害的几率特别幸运") .replace(/^Allocates (.+)$/g, "配置 $1") .replace(/^Can have 1 additional Crafted Modifier$/g, "可拥有 1 条额外工艺词缀") .replace(/^Raven-Touched$/g, "鸦触") .replace(/^(\d+)% increased Runic Ward$/g, "符文护盾提高 $1%") .replace(/^(\d+)% increased Mana Cost Efficiency$/g, "魔力消耗效率提高 $1%") .replace(/^(\d+)% increased Energy Shield Recharge Rate$/g, "能量护盾充能速度提高 $1%") .replace(/^All Damage taken from Hits Contributes to Magnitude of Chill inflicted on you$/g, "所有来自击中的伤害都会影响你身上冰缓的幅度") .replace(/^The Effect of Chill on you is reversed$/g, "你身上的冰缓效果反转") .replace(/^Effect is not removed when Unreserved Life is Filled$/g, "未保留生命回满时效果不会移除") .replace(/^No Inherent loss of Rage during effect$/g, "效果期间怒火不会自然流失") .replace(/^Cannot be Stunned$/g, "不会被眩晕") .replace(/^Grants Immunity to Freeze$/g, "获得冻结免疫") .replace(/^Used when you become Frozen$/g, "冻结时自动使用") .replace(/^Used when you become Stunned$/g, "被眩晕时自动使用") .replace(/^Used when you take Cold damage from a Hit$/g, "受到冰霜击中伤害时自动使用") .replace(/^Used when you take Fire damage from a Hit$/g, "受到火焰击中伤害时自动使用") .replace(/^Used when you take Lightning damage from a Hit$/g, "受到闪电击中伤害时自动使用") .replace(/^Grants a Power Charge on use$/g, "使用时获得一个暴击球") .replace(/^(\+?\d+) to maximum Energy Shield$/g, "$1 最大能量护盾") .replace(/^(\+?\d+) to maximum Life$/g, "$1 最大生命") .replace(/^(\+?\d+) to maximum Mana$/g, "$1 最大魔力") .replace(/^(\+?\d+) to Strength$/g, "$1 力量") .replace(/^(\+?\d+) to Dexterity$/g, "$1 敏捷") .replace(/^(\+?\d+) to Intelligence$/g, "$1 智慧") .replace(/^(\+?\d+)% to Fire Resistance$/g, "$1% 火焰抗性") .replace(/^(\+?\d+)% to Cold Resistance$/g, "$1% 冰霜抗性") .replace(/^(\+?\d+)% to Lightning Resistance$/g, "$1% 闪电抗性") .replace(/^(\+?\d+)% to Chaos Resistance$/g, "$1% 混沌抗性") .replace(/^(\d+)% increased Energy Shield$/g, "$1% 提高能量护盾") .replace(/^(\d+)% increased maximum Energy Shield$/g, "$1% 提高最大能量护盾") .replace(/^(\d+)% increased maximum Life$/g, "$1% 提高最大生命") .replace(/^(\d+)% increased maximum Mana$/g, "$1% 提高最大魔力") .replace(/^(\d+)% increased Movement Speed$/g, "$1% 提高移动速度") .replace(/^(\d+)% increased Cast Speed$/g, "$1% 提高施法速度") .replace(/^(\d+)% increased Attack Speed$/g, "$1% 提高攻击速度") .replace(/^(\d+)% increased Spell Damage$/g, "$1% 提高法术伤害") .replace(/^(\d+)% increased Elemental Damage$/g, "$1% 提高元素伤害") .replace(/^(\d+)% increased Projectile Damage$/g, "$1% 提高投射物伤害") .replace(/^(\d+)% increased Duration$/g, "$1% 提高持续时间") .replace(/^(\d+)% reduced Charges per use$/g, "$1% 降低每次使用消耗充能") .replace(/\bmaximum Energy Shield\b/g, "最大能量护盾") .replace(/\bmaximum Runic Ward\b/g, "最大符文护盾") .replace(/\bRunic Ward\b/g, "符文护盾") .replace(/\bRunes\b/g, "符文") .replace(/\bRune\b/g, "符文") .replace(/\bEnergy Shield\b/g, "能量护盾") .replace(/\bRecharge Rate\b/g, "充能速度") .replace(/\bCost Efficiency\b/g, "消耗效率") .replace(/\bmaximum Life\b/g, "最大生命") .replace(/\bLife\b/g, "生命") .replace(/\bmaximum Mana\b/g, "最大魔力") .replace(/\bMana\b/g, "魔力") .replace(/\bEvasion Rating\b/g, "闪避值") .replace(/\bArmour\b/g, "护甲") .replace(/\bEvasion\b/g, "闪避") .replace(/\bFire Resistance\b/g, "火焰抗性") .replace(/\bCold Resistance\b/g, "冰霜抗性") .replace(/\bLightning Resistance\b/g, "闪电抗性") .replace(/\bChaos Resistance\b/g, "混沌抗性") .replace(/\ball Elemental Resistances\b/g, "所有元素抗性") .replace(/\bElemental Resistances\b/g, "元素抗性") .replace(/\bResistances\b/g, "抗性") .replace(/\bStrength\b/g, "力量") .replace(/\bDexterity\b/g, "敏捷") .replace(/\bIntelligence\b/g, "智慧") .replace(/\bSpirit\b/g, "精魂") .replace(/\bMovement Speed\b/g, "移动速度") .replace(/\bCast Speed\b/g, "施法速度") .replace(/\bAttack Speed\b/g, "攻击速度") .replace(/\bCritical Hit Chance\b/g, "暴击率") .replace(/\bCritical Damage Bonus\b/g, "暴击伤害加成") .replace(/\bProjectile Speed\b/g, "投射物速度") .replace(/\bProjectile Damage\b/g, "投射物伤害") .replace(/\bSpell Damage\b/g, "法术伤害") .replace(/\bElemental Damage\b/g, "元素伤害") .replace(/\bPhysical Damage\b/g, "物理伤害") .replace(/\bLightning damage\b/g, "闪电伤害") .replace(/\bCold damage\b/g, "冰霜伤害") .replace(/\bFire damage\b/g, "火焰伤害") .replace(/\bAttack Damage\b/g, "攻击伤害") .replace(/\bDamage\b/g, "伤害") .replace(/\bCharges per use\b/g, "每次使用消耗充能") .replace(/\bCharges gained\b/g, "获得的充能") .replace(/\bCharges\b/g, "充能") .replace(/\bDuration\b/g, "持续时间") .replace(/\bRecovery\b/g, "回复") .replace(/\bRarity of Items found\b/g, "物品稀有度") .replace(/\bRage\b/g, "怒火") .replace(/\bPower Charge\b/g, "暴击球") .replace(/\bFrenzy Charge\b/g, "狂怒球") .replace(/\bEndurance Charge\b/g, "耐力球") .replace(/\bFreeze\b/g, "冻结") .replace(/\bFrozen\b/g, "冻结") .replace(/\bChill\b/g, "冰缓") .replace(/\bShock\b/g, "感电") .replace(/\bIgnite\b/g, "点燃") .replace(/\bStun\b/g, "眩晕") .replace(/\bStunned\b/g, "被眩晕") .replace(/\bCurse\b/g, "诅咒") .replace(/\bFlasks\b/g, "药剂") .replace(/\bFlask\b/g, "药剂") .replace(/\bCharm\b/g, "护符") .replace(/\bSockets\b/g, "插槽") .replace(/\bSocketed\b/g, "已镶嵌") .replace(/\bSocket\b/g, "插槽") .replace(/\bGrants\b/g, "获得") .replace(/\bGain\b/g, "获得") .replace(/\bUsed when you become\b/g, "当你变为以下状态时使用:") .replace(/\bUsed when you take\b/g, "当你受到以下伤害时使用:") .replace(/\bfrom a Hit\b/g, "来自击中") .replace(/\bwhen Hit by an Enemy\b/g, "被敌人击中时") .replace(/\bduring effect\b/g, "效果期间") .replace(/\bon use\b/g, "使用时") .replace(/\bper use\b/g, "每次使用") .replace(/\bas Life\b/g, "为生命") .replace(/\bas Mana\b/g, "为魔力") .replace(/\bto maximum\b/g, "最大") .replace(/\bto\b/g, "至") .replace(/\bof\b/g, "的") .replace(/\bincreased\b/g, "提高") .replace(/\breduced\b/g, "降低") .replace(/\bmore\b/g, "更多") .replace(/\bless\b/g, "更少") .replace(/\bAdds\b/g, "附加") .replace(/\bAdded\b/g, "附加") .replace(/\bExtra\b/g, "额外") .replace(/\bCannot be\b/g, "不能被") .replace(/\bImmunity to\b/g, "免疫") .replace(/\bEffect is not removed when Unreserved Life is Filled\b/g, "未保留生命回满时效果不会移除") .replace(/\bNo Inherent loss of\b/g, "不会自然失去") .replace(/\bRecouped\b/g, "补偿") .replace(/\bLeech\b/g, "偷取") .replace(/\bEnemies\b/g, "敌人") .replace(/\bEnemy\b/g, "敌人") .replace(/\bAttack\b/g, "攻击") .replace(/\bAttacks\b/g, "攻击") .replace(/\bSpell\b/g, "法术") .replace(/\bSpells\b/g, "法术") .replace(/\bProjectile\b/g, "投射物") .replace(/\bProjectiles\b/g, "投射物") .replace(/\bTriggered\b/g, "触发") .replace(/\bMagnitude\b/g, "幅度") .replace(/\bBuff\b/g, "增益") .replace(/\bLow Mana\b/g, "低魔力") .replace(/\bUnreserved\b/g, "未保留"); translated = translated .replace(/\bQuality\b/g, "品质") .replace(/\bLevel\b/g, "等级") .replace(/\bStr\b/g, "力量") .replace(/\bDex\b/g, "敏捷") .replace(/\bInt\b/g, "智慧") .replace(/\bWand\b/g, "法杖") .replace(/\bShield\b/g, "盾") .replace(/\bHelmet\b/g, "头盔") .replace(/\bBody Armour\b/g, "胸甲") .replace(/\bBody Armor\b/g, "胸甲") .replace(/\bGloves\b/g, "手套") .replace(/\bBoots\b/g, "鞋子") .replace(/\bAmulet\b/g, "项链") .replace(/\bRing\b/g, "戒指") .replace(/\bBelt\b/g, "腰带") .replace(/\bBlock chance\b/g, "格挡率") .replace(/\bBlock\b/g, "格挡") .replace(/\bLucky\b/g, "幸运") .replace(/\bUnlucky\b/g, "不幸运") .replace(/\bElemental Infusion\b/g, "元素灌注") .replace(/\bElements\b/g, "元素") .replace(/\ball\b/g, "所有") .replace(/\bSkills\b/g, "技能") .replace(/\bSkill\b/g, "技能") .replace(/\bfire\b/g, "发射") .replace(/\badditional\b/g, "额外") .replace(/\bchance\b/g, "几率") .replace(/\bwhile on full\b/g, "满") .replace(/\bwhen collecting\b/g, "拾取时") .replace(/\bsame type\b/g, "同类型") .replace(/\brecovery period expires\b/g, "回复周期结束") .replace(/\bfaster\b/g, "更快") .replace(/\bBreak\b/g, "破坏") .replace(/\bdealt\b/g, "造成") .replace(/\bLeeches\b/g, "偷取") .replace(/\bCast\b/g, "施放") .replace(/\bEvery\b/g, "每个") .replace(/\balso\b/g, "也"); translated = translated .replace(/\bBody 护甲\b/g, "胸甲") .replace(/\bBody 护甲\b/g, "胸甲") .replace(/\b闪避 Rating\b/g, "闪避值") .replace(/\b格挡 几率\b/g, "格挡率") .replace(/\bVaal 戒指\b/g, "瓦尔戒指") .replace(/\[([^\]]+)\]/g, "$1"); translated = translated.replace(/\s+/g, " ").trim(); return currentNameLang === "tw" ? simplifiedToTraditionalFallback(translated) : translated; } function renderModGroups(groups) { if (!groups.length) return "-"; return groups.map((group) => `
${escapeHtml(group.label)}
${group.lines.map((line, index) => `
${index + 1}. ${escapeHtml(localizedModText(line))}
`).join("")}
`).join(""); } function renderCellList(items, ordered = false) { const lines = items.map((item) => cleanText(item)).filter(Boolean); if (!lines.length) return "-"; return `
${lines.map((line, index) => `
${ordered ? `${index + 1}.` : "•"} ${escapeHtml(line)}
`).join("")}
`; } function summarizeRows(rows) { return rows.reduce((summary, row) => { summary.total += 1; if (row.skillType === "主动技能") summary.active += 1; if (row.skillType === "被动技能") summary.passive += 1; if (row.skillType === "精魂技能") summary.spirit += 1; return summary; }, { total: 0, active: 0, passive: 0, spirit: 0 }); } function visibleRows(rows) { let result = [...rows]; if (currentFilter === "active") result = result.filter((row) => row.skillType === "主动技能"); if (currentFilter === "passive") result = result.filter((row) => row.skillType === "被动技能"); if (currentFilter === "spirit") result = result.filter((row) => row.skillType === "精魂技能"); if (currentSort === "type") { const order = { "主动技能": 0, "被动技能": 1, "精魂技能": 2, "装备赋予": 3 }; result.sort((a, b) => (order[a.skillType] ?? 9) - (order[b.skillType] ?? 9) || a.originalIndex - b.originalIndex); } return result; } function ensureStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${PANEL_ID} { box-sizing: border-box; width: min(1180px, calc(100vw - 32px)); margin: 28px auto; color: var(--color-coolgrey-100, #d7dde6); font-family: Inter, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; -webkit-user-select: text !important; user-select: text !important; } #${PANEL_ID} *, #${PANEL_ID} table, #${PANEL_ID} td, #${PANEL_ID} th { box-sizing: border-box; } #${PANEL_ID} button { cursor: pointer; border: 1px solid var(--color-coolgrey-700, #374151); border-radius: 4px; background: var(--color-coolgrey-850, #111827); color: var(--color-coolgrey-100, #d7dde6); padding: 5px 10px; font-size: 13px; } #${PANEL_ID} button:hover { background: var(--color-coolgrey-800, #1f2937); } #${PANEL_ID} button.codex-active-control { border-color: var(--color-emerald-400, #10d9a3); color: var(--color-emerald-300, #33e0b5); background: rgba(16, 217, 163, 0.12); } #${PANEL_ID} button:disabled { opacity: 0.55; cursor: default; } #${PANEL_ID} .codex-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; padding: 0 16px; border: 1px solid var(--color-coolgrey-700, #303846); border-bottom: 0; background: var(--color-coolgrey-850, #111827); } #${PANEL_ID} .codex-title { font-size: 18px; font-weight: 650; } #${PANEL_ID} .codex-updated { margin-left: 10px; color: var(--color-coolgrey-400, #8b95a7); font-size: 12px; font-weight: 400; } #${PANEL_ID} .codex-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } #${PANEL_ID} .codex-controls { display: flex; flex-direction: column; align-items: flex-start; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--color-coolgrey-700, #303846); background: var(--color-coolgrey-900, #0b1018); } #${PANEL_ID} .codex-summary { color: var(--color-coolgrey-200, #c8d2e1); } #${PANEL_ID} .codex-control-group { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } #${PANEL_ID} .codex-label { color: var(--color-coolgrey-400, #8b95a7); font-size: 12px; margin-right: 2px; } #${PANEL_ID} .codex-body { padding: 12px; border: 1px solid var(--color-coolgrey-700, #303846); border-top: 0; background: rgba(8, 13, 20, 0.82); } #${PANEL_ID} .codex-section { margin-bottom: 18px; padding: 0 12px 12px; border: 1px solid var(--color-coolgrey-700, #2a3341); border-left: 4px solid var(--color-emerald-400, #10d9a3); background: rgba(10, 16, 25, 0.58); box-shadow: inset 0 1px 0 rgba(255,255,255,0.025); } #${PANEL_ID} .codex-section-title { display: flex; align-items: center; justify-content: flex-start; gap: 10px; margin: 0 -12px 12px; padding: 9px 12px 9px 10px; border-bottom: 1px solid var(--color-coolgrey-700, #2a3341); background: rgba(16, 217, 163, 0.10); color: var(--color-coolgrey-100, #f2f4f7); font-size: 17px; font-weight: 750; } #${PANEL_ID} .codex-section-title-main { display: flex; align-items: baseline; gap: 10px; min-width: 0; flex-wrap: wrap; } #${PANEL_ID} .codex-section-title-text { color: var(--color-coolgrey-100, #f2f4f7); font-size: 17px; font-weight: 750; } #${PANEL_ID} .codex-section-title span { color: var(--color-coolgrey-400, #8b95a7); font-size: 12px; font-weight: 400; } #${PANEL_ID} .codex-collapse-button { flex: none; margin-left: 2px; padding: 2px 8px; font-size: 12px; line-height: 1.5; background: rgba(16, 217, 163, 0.10); border-color: rgba(16, 217, 163, 0.34); } #${PANEL_ID} .codex-section.codex-collapsed > :not(.codex-section-title) { display: none !important; } #${PANEL_ID} .codex-section.codex-collapsed { padding-bottom: 0; } #${PANEL_ID} .codex-section.codex-collapsed .codex-section-title { margin-bottom: 0; border-bottom: 0; } #${PANEL_ID} .codex-skill-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: -2px 0 10px; padding: 8px 10px; border: 1px solid var(--color-coolgrey-700, #2a3341); background: rgba(255,255,255,0.02); } #${PANEL_ID} .codex-skill-summary { color: var(--color-coolgrey-400, #8b95a7); font-size: 12px; font-weight: 400; } #${PANEL_ID} .codex-skill-controls { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; } #${PANEL_ID} .codex-skill-card { margin-bottom: 10px; padding: 11px 14px; border: 1px solid var(--color-coolgrey-700, #2a3341); background: rgba(255,255,255,0.02); } #${PANEL_ID} .codex-skill-card.codex-type-active { box-shadow: inset 0 3px 0 rgba(245, 199, 106, 0.72); } #${PANEL_ID} .codex-skill-card.codex-type-passive, #${PANEL_ID} .codex-skill-card.codex-type-spirit { box-shadow: inset 0 3px 0 rgba(16, 217, 163, 0.62); } #${PANEL_ID} .codex-skill-card.codex-type-granted { box-shadow: inset 0 3px 0 rgba(123, 182, 240, 0.62); } #${PANEL_ID} .codex-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 5px; } #${PANEL_ID} .codex-type-badge { border: 1px solid currentColor; border-radius: 3px; padding: 1px 6px; font-size: 12px; } #${PANEL_ID} .codex-type-badge.codex-type-active { color: #f5c76a; } #${PANEL_ID} .codex-type-badge.codex-type-passive, #${PANEL_ID} .codex-type-badge.codex-type-spirit { color: var(--color-emerald-300, #33e0b5); } #${PANEL_ID} .codex-type-badge.codex-type-granted { color: #7bb6f0; } #${PANEL_ID} .codex-name { font-size: 16px; font-weight: 650; color: #f2f4f7; } #${PANEL_ID} .codex-meta { color: #c8d2e1; border: 1px solid var(--color-coolgrey-700, #263244); background: rgba(255,255,255,0.03); padding: 1px 8px; font-size: 13px; } #${PANEL_ID} .codex-line { margin-top: 4px; color: var(--color-coolgrey-300, #aeb8c8); font-size: 13px; line-height: 1.45; word-break: break-word; } #${PANEL_ID} .codex-line-label { color: var(--color-coolgrey-500, #7f8ba0); margin-right: 6px; } #${PANEL_ID} table { width: 100%; margin-top: 0; border-collapse: collapse; table-layout: fixed; font-size: 13px; } #${PANEL_ID} th, #${PANEL_ID} td { border: 1px solid var(--color-coolgrey-700, #2a3341); padding: 5px 8px; text-align: left; vertical-align: middle; word-break: break-word; } #${PANEL_ID} th { color: var(--color-coolgrey-300, #9aa6b8); font-weight: 500; background: rgba(255,255,255,0.03); } #${PANEL_ID} .codex-index { width: 46px; text-align: center; } #${PANEL_ID} .codex-insert-type { width: 72px; text-align: center; font-weight: 700; } #${PANEL_ID} .codex-insert-type-active { color: #f5c76a; } #${PANEL_ID} .codex-insert-type-passive { color: #76d191; } #${PANEL_ID} .codex-insert-type-lineage { color: #c99cff; } #${PANEL_ID} .codex-level, #${PANEL_ID} .codex-quality, #${PANEL_ID} .codex-sockets { width: 96px; } #${PANEL_ID} .codex-details { width: 190px; } #${PANEL_ID} .codex-requirements { width: 190px; } #${PANEL_ID} .codex-item-slot { width: 86px; } #${PANEL_ID} .codex-item-rarity { width: 82px; } #${PANEL_ID} .codex-rarity-unique { color: #af6025; font-weight: 700; } #${PANEL_ID} .codex-rarity-rare { color: #d9c75f; font-weight: 700; } #${PANEL_ID} .codex-rarity-magic { color: #8888ff; font-weight: 700; } #${PANEL_ID} .codex-rarity-normal { color: var(--color-coolgrey-100, #f2f4f7); } #${PANEL_ID} .codex-item-props { width: 180px; } #${PANEL_ID} .codex-item-socketed { width: 170px; } #${PANEL_ID} .codex-item-mods { color: var(--color-coolgrey-200, #c8d2e1); } #${PANEL_ID} .codex-item-name { color: var(--color-coolgrey-100, #f2f4f7); font-weight: 600; } #${PANEL_ID} .codex-item-name.codex-rarity-unique { color: #af6025; } #${PANEL_ID} .codex-item-name.codex-rarity-rare { color: #d9c75f; } #${PANEL_ID} .codex-item-name.codex-rarity-magic { color: #8888ff; } #${PANEL_ID} .codex-mod-group + .codex-mod-group { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--color-coolgrey-700, #263244); } #${PANEL_ID} .codex-mod-label { margin-bottom: 2px; color: var(--color-amber-300, #f5c76a); font-size: 12px; } #${PANEL_ID} .codex-mod-list { margin: 2px 0 0 0; padding-left: 0; } #${PANEL_ID} .codex-cell-list { margin: 0; padding-left: 0; } #${PANEL_ID} .codex-list-row { display: grid; grid-template-columns: 24px minmax(0, 1fr); column-gap: 6px; margin: 2px 0; line-height: 1.42; } #${PANEL_ID} .codex-list-marker { color: var(--color-coolgrey-300, #9aa6b8); text-align: right; user-select: text; } @media (max-width: 760px) { #${PANEL_ID} { width: calc(100vw - 16px); margin: 16px 8px; } #${PANEL_ID} .codex-skill-toolbar { align-items: flex-start; } #${PANEL_ID} .codex-skill-controls { justify-content: flex-start; } } `; document.head.appendChild(style); } function panel() { return document.getElementById(PANEL_ID); } function panelMountTarget() { return document.querySelector("main") || document.querySelector("article"); } function waitForPanelMountTarget(timeoutMs = 6000) { const existing = panelMountTarget(); if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { const startedAt = Date.now(); const timer = setInterval(() => { const target = panelMountTarget(); if (target) { clearInterval(timer); resolve(target); return; } if (Date.now() - startedAt > timeoutMs) { clearInterval(timer); reject(new Error("页面主体尚未加载完成")); } }, 100); }); } function mountPanel(target = panelMountTarget()) { if (!target) return null; ensureStyle(); let root = panel(); if (!root) { root = document.createElement("section"); root.id = PANEL_ID; root.innerHTML = `
BD信息 更新:${SCRIPT_UPDATED_AT}
语言
`; root.addEventListener("click", onPanelClick); } target.appendChild(root); return root; } async function ensurePanel() { return mountPanel(await waitForPanelMountTarget()); } function updateControls() { const root = panel(); if (!root) return; const char = currentModel ? modelChar(currentModel.data) : null; root.querySelector(".codex-summary").textContent = currentPobSummary || (char ? `${cleanText(char.name)} Lv${char.level || "-"} ${cleanText(char.class || "")}` : ""); root.querySelectorAll("[data-lang]").forEach((button) => { button.classList.toggle("codex-active-control", button.dataset.lang === currentNameLang); button.disabled = nameLangState === "loading"; button.textContent = nameLangState === "loading" && button.dataset.lang === currentNameLang ? `${NAME_LANG_LABELS[button.dataset.lang]}...` : NAME_LANG_LABELS[button.dataset.lang]; }); root.querySelectorAll("[data-filter]").forEach((button) => button.classList.toggle("codex-active-control", button.dataset.filter === currentFilter)); root.querySelectorAll("[data-sort]").forEach((button) => button.classList.toggle("codex-active-control", button.dataset.sort === currentSort)); root.title = nameLangMessage; } async function renderRows() { const root = await ensurePanel(); const body = root.querySelector(".codex-body"); body.innerHTML = ""; updateControls(); const summaryNode = root.querySelector(".codex-summary"); if (summaryNode && currentPobSummary) summaryNode.textContent = currentPobSummary; renderItemSection(body, "装备/药剂/护符", lastEquipment, false, "equipment"); renderItemSection(body, "珠宝", lastJewels, true, "jewels"); renderTalentSection(body); const skillSection = document.createElement("section"); skillSection.className = "codex-section"; const summary = summarizeRows(lastRows); skillSection.innerHTML = ` ${sectionTitleHtml("技能", `${visibleRows(lastRows).length} 个`, "skills")}
总技能 ${summary.total} | 主动 ${summary.active} | 被动 ${summary.passive}${summary.spirit ? ` | 精魂 ${summary.spirit}` : ""}
筛选 排序
`; body.appendChild(skillSection); applySectionCollapsed(skillSection, "skills"); updateControls(); const rows = visibleRows(lastRows); if (!rows.length) { const empty = document.createElement("div"); empty.className = "codex-skill-card"; empty.textContent = "没有读取到技能数据。"; skillSection.appendChild(empty); } for (const row of rows) { const card = document.createElement("article"); card.className = `codex-skill-card ${row.skillTypeClass || ""}`.trim(); card.dataset.index = String(row.originalIndex); card.innerHTML = `
${row.skillType} Lv${row.level} / ${row.quality} / ${row.sockets}孔
标签${escapeHtml(localizedModText(row.tags || "-"))}
${row.requirements ? `
需求${escapeHtml(localizedModText(row.requirements))}
` : ""} ${row.details?.length ? `
属性${escapeHtml(row.details.map(localizedModText).join(" / "))}
` : ""} ${row.dpsRows.length ? `
DPS${escapeHtml(row.dpsRows.map((dps) => `${localizedName(dps.name)} ${formatNumber(dps.dps || dps.dotDps)}`).join(" / "))}
` : ""}
已插入技能${row.inserted.length} 个
`; card.querySelector(".codex-name").textContent = localizedName(row.name); if (row.inserted.length) { const table = document.createElement("table"); table.innerHTML = ` # 类型 技能石 等级 品质 孔数 属性 需求 `; const tbody = table.querySelector("tbody"); row.inserted.forEach((item, index) => { const typeClass = item.rowType === "主动" ? "active" : item.rowType === "血脉" ? "lineage" : "passive"; const tr = document.createElement("tr"); tr.innerHTML = ` ${index + 1} ${item.rowType} ${item.level !== "-" ? `Lv${item.level}` : "-"} ${item.quality || "-"} ${item.sockets || "-"} ${escapeHtml(item.details?.length ? item.details.map(localizedModText).join(" / ") : "-")} ${escapeHtml(localizedModText(item.requirements || "-"))} `; tr.querySelector(".codex-insert-name").textContent = localizedName(item.name); tbody.appendChild(tr); }); card.appendChild(table); } skillSection.appendChild(card); } } function renderItemSection(body, title, rows, isJewel, collapseKey) { if (!rows.length) return; const section = document.createElement("section"); section.className = "codex-section"; section.innerHTML = sectionTitleHtml(title, `${rows.length} 个`, collapseKey); const table = document.createElement("table"); table.innerHTML = ` # ${isJewel ? "位置" : "部位"} 名称 稀有度 属性/需求 插槽物 词缀 `; const tbody = table.querySelector("tbody"); rows.forEach((row, index) => { const tr = document.createElement("tr"); const slot = isJewel ? (row.position || row.slot || "-") : row.slot; const propertyItems = splitListText(row.properties).map(localizedModText); const socketedHtml = renderCellList(row.socketed.map(localizedItemLabel), false); const rarityClassName = rarityClass(row.rarity); tr.innerHTML = ` ${index + 1} ${escapeHtml(slot)} ${escapeHtml(localizedRarity(row.rarity))} ${renderCellList(propertyItems, false)} ${socketedHtml} ${renderModGroups(row.mods)} `; const status = itemStatusText(row); tr.querySelector(".codex-item-name").textContent = `${localizedItemName(row)}${status ? ` (${status})` : ""}`; tbody.appendChild(tr); }); section.appendChild(table); applySectionCollapsed(section, collapseKey); body.appendChild(section); } function renderTalentSection(body) { const char = currentModel ? modelChar(currentModel.data) : null; const talent = currentPobTalent || normalizeTalent(char); if (!talent) return; const summary = [ talent.treeName || "", talent.total ? `天赋点 ${talent.total}` : "", `升华点 ${talent.ascendancy || 0}`, `武器1 ${talent.set1Count || 0}`, `武器2 ${talent.set2Count || 0}`, ].filter(Boolean).join(" | "); const section = document.createElement("section"); section.className = "codex-section"; section.innerHTML = ` ${sectionTitleHtml("天赋/任务奖励", summary || "-", "talent")} ${talent.keystones?.length ? ` ` : ""}
天赋 ${escapeHtml(summary || "-")}
关键天赋 ${renderCellList(talent.keystones.map(localizedName), false)}
任务奖励 ${renderCellList((talent.questStats || []).map(localizedModText), false)}
`; applySectionCollapsed(section, "talent"); body.appendChild(section); } function updateRenderedLanguage() { const root = panel(); if (!root) return; const rowsByIndex = new Map(lastRows.map((row) => [String(row.originalIndex), row])); root.querySelectorAll(".codex-skill-card[data-index]").forEach((card) => { const row = rowsByIndex.get(card.dataset.index); if (!row) return; const name = card.querySelector(".codex-name"); if (name) name.textContent = localizedName(row.name); card.querySelectorAll(".codex-insert-name").forEach((cell, index) => { cell.textContent = localizedName(row.inserted[index]?.name || "-"); }); }); updateControls(); } async function copyText(value) { if (!value) return false; try { if (typeof GM_setClipboard === "function") { GM_setClipboard(value); return true; } await navigator.clipboard.writeText(value); return true; } catch (_) { const textarea = document.createElement("textarea"); textarea.value = value; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand("copy"); textarea.remove(); return ok; } } async function refresh({ showLoading = Boolean(panel()) } = {}) { let existingPanel = panel(); if (showLoading) { existingPanel = existingPanel || await ensurePanel(); existingPanel.querySelector(".codex-body").innerHTML = `
正在读取 poe.ninja BD 数据...
`; } const route = currentRoute || parseCharacterLink(location.href); if (!route) { const root = await ensurePanel(); const body = root.querySelector(".codex-body"); body.innerHTML = `
当前页面没有识别到 poe.ninja POE2 角色链接。可在 Tampermonkey 菜单里设置 NinjaBD 镜像站。
`; return; } currentRoute = route; if (route.pobCodeId) { const root = await ensurePanel(); const body = root.querySelector(".codex-body"); try { const code = await requestText(pobRawUrl(route)); const parsed = await normalizePobBuild(code, route.pobCodeId); currentModel = null; currentPobTalent = parsed.talent; currentPobSummary = parsed.summary; lastRows = parsed.rows; lastEquipment = parsed.equipment; lastJewels = parsed.jewels; lastText = currentCopyText(); await renderRows(); preloadNameMaps(); } catch (error) { currentPobTalent = null; currentPobSummary = ""; body.innerHTML = `
PoB短链读取失败:${escapeHtml(error.message || String(error))}
`; } return; } const data = await fetchLatestModel(route); const char = modelChar(data); currentPobTalent = null; currentPobSummary = ""; lastRows = (char.skills || []).map(normalizeSkillGroup).filter((row) => row.name); lastEquipment = normalizeEquipment(char); lastJewels = normalizeJewels(char); lastText = currentCopyText(); await renderRows(); preloadNameMaps(); } async function onPanelClick(event) { const button = event.target.closest("button"); if (!button) return; event.preventDefault(); if (button.dataset.collapseSection) { const key = button.dataset.collapseSection; setSectionCollapsed(key, !isSectionCollapsed(key)); button.blur(); await renderRows(); return; } if (button.dataset.lang) { currentNameLang = button.dataset.lang; await ensureNameLanguageLoaded(); return; } if (button.dataset.filter) { currentFilter = button.dataset.filter; await renderRows(); return; } if (button.dataset.sort) { currentSort = button.dataset.sort; await renderRows(); return; } if (button.dataset.action === "refresh") { await refresh(); return; } if (button.dataset.action === "copy") { const ok = await copyText(currentCopyText()); button.textContent = ok ? "已复制" : "复制失败"; setTimeout(() => { button.textContent = "复制"; }, 1200); } } function start() { registerMenuCommands(); if (!shouldActivate()) return; refresh({ showLoading: true }).catch((error) => { console.warn("POE2 NinjaBD增强读取失败", error); ensurePanel().then((root) => { root.querySelector(".codex-body").innerHTML = `
读取失败:${cleanText(error?.message || error)}
`; }).catch(console.warn); }); let lastHref = location.href; setInterval(() => { if (location.href === lastHref) return; lastHref = location.href; currentRoute = parseCharacterLink(location.href); if (shouldActivate()) refresh().catch(console.warn); }, 1200); } start(); })();