// ==UserScript== // @name PoEDB / POE2DB 多语言信息助手 // @namespace http://tampermonkey.net/ // @version 3.5.2 // @lastUpdated 2026-07-26 04:12:00 +08:00 // @description PoEDB/POE2DB 多语言名称、三语搜索与复制助手 // @author 维克牛 // @contact https://nga.178.com/nuke.php?func=ucp&uid=6888984 // @contributor Codex optimized // @match https://poe2db.tw/* // @match https://poedb.tw/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect poe2db.tw // @connect cdn.poe2db.tw // @connect poedb.tw // @connect cdn.poedb.tw // @license MIT // @run-at document-end // ==/UserScript== (function () { 'use strict'; const LANGS = ['cn', 'tw', 'us']; const LANG_NAMES = { cn: '简体中文', tw: '繁体中文', us: '英文' }; const SITE_CONFIGS = { poe2: { id: 'poe2', host: 'poe2db.tw', cdnHost: 'cdn.poe2db.tw', name: 'POE2DB', title: 'POE2DB 助手', toggleTitle: 'POE2DB 信息助手', titlePattern: / - PoE2DB.*/i, autocompletePrefix: 'autocompletecb' }, poe1: { id: 'poe1', host: 'poedb.tw', cdnHost: 'cdn.poedb.tw', name: 'PoEDB', title: 'PoEDB 助手', toggleTitle: 'PoEDB 信息助手', titlePattern: / - (流亡編年史|流亡编年史|Path of Exile Wiki).*/i, autocompletePrefix: 'autocomplete' } }; const currentSite = Object.values(SITE_CONFIGS).find((site) => window.location.hostname === site.host) || SITE_CONFIGS.poe2; const STORAGE_PREFIX = `poe2db-helper-${currentSite.id}`; const PANEL_STATE_KEY = `${STORAGE_PREFIX}-panel-expanded`; const RECENT_ITEMS_KEY = `${STORAGE_PREFIX}-recent-items`; const VISITED_PAGES_KEY = `${STORAGE_PREFIX}-visited-pages`; const MAX_RECENT_ITEMS = 5; const MAX_VISITED_PAGES = 8; const MARKET_SERVERS = { cn: { name: '国服', url: 'https://poe.game.qq.com/trade2/search/poe2/', query: false }, tw: { name: '台服', url: 'https://pathofexile.tw/trade2', query: false }, us: { name: '国际服', url: 'https://www.pathofexile.com/trade2/search/poe2/', query: true } }; const state = { panel: null, toggle: null, autocomplete: {}, autocompleteByValue: {}, searchIndex: {}, autocompleteLoading: null, headerScriptUrl: null, autocompleteFiles: {} }; const buildSiteUrl = (lang = 'cn', path = '') => { const cleanPath = String(path || '').replace(/^\/+/, ''); return `https://${currentSite.host}/${lang}/${cleanPath}`; }; const buildCdnUrl = (path = '') => { const cleanPath = String(path || '').replace(/^\/+/, ''); return `https://${currentSite.cdnHost}/${cleanPath}`; }; const TC_TO_SC_MAP = { '裝': '装', '備': '备', '詞': '词', '條': '条', '寶': '宝', '鑽': '钻', '飾': '饰', '護': '护', '藥': '药', '劑': '剂', '術': '术', '輔': '辅', '助': '助', '輕': '轻', '擊': '击', '傷': '伤', '害': '害', '閃': '闪', '電': '电', '冰': '冰', '霜': '霜', '寒': '寒', '熱': '热', '燒': '烧', '燃': '燃', '焰': '焰', '熾': '炽', '圖': '图', '騰': '腾', '陷': '陷', '阱': '阱', '礦': '矿', '獄': '狱', '獵': '猎', '獸': '兽', '靈': '灵', '魂': '魂', '詛': '诅', '咒': '咒', '詠': '咏', '喚': '唤', '召': '召', '離': '离', '轉': '转', '傳': '传', '遞': '递', '進': '进', '退': '退', '變': '变', '異': '异', '狀': '状', '態': '态', '範': '范', '圍': '围', '區': '区', '域': '域', '強': '强', '化': '化', '增': '增', '減': '减', '緩': '缓', '速': '速', '時間': '时间', '時': '时', '間': '间', '啟': '启', '動': '动', '觸': '触', '發': '发', '導': '导', '聯': '联', '結': '结', '連': '连', '漣': '涟', '鏈': '链', '鎖': '锁', '釋': '释', '放': '放', '屬': '属', '性': '性', '點': '点', '勢': '势', '權': '权', '威': '威', '奧': '奥', '爾': '尔', '祕': '秘', '秘': '秘', '符': '符', '殘': '残', '片': '片', '啟': '启', '虛': '虚', '空': '空', '夢': '梦', '願': '愿', '望': '望', '統': '统', '禦': '御', '眾': '众', '聖': '圣', '殿': '殿', '堂': '堂', '覺': '觉', '醒': '醒', '範': '范', '識': '识', '實': '实', '體': '体', '氣': '气', '會': '会', '與': '与', '為': '为', '無': '无', '於': '于', '內': '内', '雙': '双', '萬': '万', '龍': '龙', '馬': '马', '魚': '鱼', '鳥': '鸟', '顱': '颅', '頭': '头', '髮': '发', '顏': '颜', '頁': '页', '顯': '显', '隱': '隐', '刪': '删', '過': '过', '還': '还', '這': '这', '個': '个', '開': '开', '關': '关', '門': '门', '風': '风', '雲': '云', '聲': '声', '響': '响', '學': '学', '習': '习', '戰': '战', '鬥': '斗', '衝': '冲', '機': '机', '構': '构', '標': '标', '記': '记', '號': '号', '級': '级', '階': '阶', '靈': '灵', '寵': '宠', '僕': '仆', '夥': '伙', '隊': '队', '總': '总', '經': '经', '驗': '验', '價': '价', '買': '买', '賣': '卖', '貪': '贪', '懼': '惧', '懲': '惩', '罰': '罚', '審': '审', '判': '判', '鋼': '钢', '鐵': '铁', '銅': '铜', '銀': '银', '鋒': '锋', '鋭': '锐', '銳': '锐', '錘': '锤', '劍': '剑', '弓': '弓', '槍': '枪', '杖': '杖', '錫': '锡', '鑄': '铸', '鍊': '炼', '煉': '炼', '淨': '净', '濾': '滤', '濃': '浓', '淺': '浅', '滅': '灭', '濕': '湿', '潛': '潜', '潑': '泼', '潔': '洁', '歲': '岁', '葉': '叶', '藍': '蓝', '蘇': '苏', '薩': '萨', '薔': '蔷', '蘊': '蕴', '蠻': '蛮', '蠍': '蝎', '蠟': '蜡', '蠅': '蝇', '蝕': '蚀', '蟲': '虫', '視': '视', '覺': '觉', '親': '亲', '觀': '观', '規': '规', '詭': '诡', '計': '计', '訓': '训', '試': '试', '證': '证', '詩': '诗', '話': '话', '說': '说', '誰': '谁', '讀': '读', '貴': '贵', '賊': '贼', '賦': '赋', '質': '质', '負': '负', '財': '财', '貨': '货', '貝': '贝', '贈': '赠', '趙': '赵', '趨': '趋', '軀': '躯', '車': '车', '軸': '轴', '載': '载', '轟': '轰', '達': '达', '遷': '迁', '遙': '遥', '選': '选', '邊': '边', '遺': '遗', '鄰': '邻', '醫': '医', '針': '针', '鈍': '钝', '鐘': '钟', '鐘': '钟', '鍾': '钟', '鎮': '镇', '鏡': '镜', '長': '长', '門': '门', '閃': '闪', '閉': '闭', '開': '开', '間': '间', '陣': '阵', '陽': '阳', '陰': '阴', '險': '险', '隨': '随', '雙': '双', '難': '难', '現': '现', '霧': '雾', '靜': '静', '韌': '韧', '響': '响', '頂': '顶', '領': '领', '頭': '头', '額': '额', '顫': '颤', '飛': '飞', '飢': '饥', '飲': '饮', '餘': '余', '騎': '骑', '騙': '骗', '驅': '驱', '驚': '惊', '驟': '骤', '骯': '肮', '體': '体', '髒': '脏', '鬱': '郁', '魘': '魇', '魯': '鲁', '鮮': '鲜', '鱗': '鳞', '鷹': '鹰', '鹽': '盐', '麗': '丽', '麵': '面', '麼': '么', '黃': '黄', '點': '点', '齊': '齐', '齒': '齿', '龍': '龙', '龜': '龟' }; const normalizeSearchText = (text) => String(text || '') .toLowerCase() .replace(/[,。.·]/g, ' ') .replace(/\s+/g, ' ') .replace(/[\u3400-\u9fff]/g, (char) => TC_TO_SC_MAP[char] || char); GM_addStyle(` .poe-helper-toggle { position: fixed; top: 8px; right: 8px; z-index: 9999; padding: 7px 12px; border-radius: 4px; border: 1px solid rgba(180, 137, 72, 0.5); background: rgba(19, 16, 13, 0.9); color: #d9c08a; font-size: 13px; cursor: pointer; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42); backdrop-filter: blur(10px); } .poe-helper-toggle:hover { background: rgba(45, 34, 23, 0.95); color: #f3d99b; } .poe-helper-panel { position: fixed; top: 8px; right: 8px; width: 280px; min-height: 420px; max-height: calc(100vh - 24px); box-sizing: border-box; overflow-y: auto; z-index: 10000; padding: 12px; border-radius: 6px; border: 1px solid rgba(154, 119, 70, 0.48); background: rgba(10, 9, 8, 0.94); color: #d7d0bd; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; box-shadow: 0 14px 34px rgba(0, 0, 0, 0.62), inset 0 1px 0 rgba(255, 255, 255, 0.04); backdrop-filter: blur(8px); } .poe-helper-panel *, .poe-helper-toggle { box-sizing: border-box; } .poe-helper-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid rgba(154, 119, 70, 0.32); } .poe-helper-title { font-size: 15px; font-weight: 700; color: #d9c08a; } .poe-helper-close { width: 26px; height: 26px; border-radius: 4px; border: 1px solid rgba(151, 75, 55, 0.62); background: rgba(58, 24, 18, 0.52); color: #d99a84; cursor: pointer; font-size: 18px; line-height: 1; } .poe-helper-label { color: #94a3b8; font-size: 12px; } .poe-helper-input { width: 100%; min-width: 0; box-sizing: border-box; border-radius: 4px; border: 1px solid rgba(154, 119, 70, 0.42); background: rgba(18, 16, 13, 0.82); color: #e2dccd; outline: none; } .poe-helper-search { display: grid; grid-template-columns: 1fr 52px; gap: 6px; } .poe-helper-input { padding: 7px 9px; font-size: 13px; } .poe-helper-input:focus { border-color: rgba(217, 176, 95, 0.7); box-shadow: 0 0 0 2px rgba(154, 119, 70, 0.18); } .poe-helper-search-btn, .poe-helper-btn { border-radius: 4px; border: 1px solid rgba(154, 119, 70, 0.48); background: rgba(48, 36, 24, 0.78); color: #e3c886; cursor: pointer; font-size: 12px; } .poe-helper-search-btn { padding: 0 8px; font-weight: 600; } .poe-helper-search-btn:hover, .poe-helper-btn:hover { background: rgba(85, 62, 34, 0.9); color: #ffe2a0; } .poe-helper-results { grid-column: 1 / -1; display: none; margin-top: 0; max-height: min(260px, calc(100vh - 190px)); overflow-y: auto; border-radius: 4px; border: 1px solid rgba(154, 119, 70, 0.45); background: rgba(12, 10, 8, 0.98); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.56); } .poe-helper-results.active { display: block; } .poe-helper-result { padding: 7px 9px; border-bottom: 1px solid rgba(154, 119, 70, 0.14); cursor: pointer; } .poe-helper-result:hover { background: rgba(154, 119, 70, 0.16); } .poe-helper-result-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; } .poe-helper-result-name { min-width: 0; color: #e7dfce; font-size: 13px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; } .poe-helper-type-badge { flex: none; padding: 2px 7px; border-radius: 999px; border: 1px solid rgba(154, 119, 70, 0.4); background: rgba(55, 40, 24, 0.6); color: #d9c08a; font-size: 11px; line-height: 1.35; } .poe-helper-result-langs { display: grid; gap: 4px; } .poe-helper-result-lang { display: grid; grid-template-columns: 30px 1fr auto auto; align-items: center; gap: 7px; padding: 3px 5px; border-radius: 4px; color: #d9d1bf; font-size: 12px; line-height: 1.35; cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } .poe-helper-result-lang:hover { background: rgba(154, 119, 70, 0.18); } .poe-helper-lang-badge { align-self: start; padding: 1px 4px; border-radius: 3px; border: 1px solid rgba(154, 119, 70, 0.4); background: rgba(55, 40, 24, 0.6); color: #d9c08a; font-size: 11px; line-height: 1.35; text-align: center; } .poe-helper-lang-text { min-width: 0; overflow-wrap: anywhere; } .poe-helper-open-mark { color: #9f9686; font-size: 12px; line-height: 1.35; opacity: 0.72; transition: color 0.12s ease, transform 0.12s ease, opacity 0.12s ease; } .poe-helper-result-lang:hover .poe-helper-lang-text { color: #ffe2a0; } .poe-helper-result-lang:hover .poe-helper-open-mark { color: #d9c08a; opacity: 1; transform: translateX(1px); } .poe-helper-mini-btn { min-width: 24px; height: 20px; padding: 0 5px; border-radius: 3px; border: 1px solid rgba(154, 119, 70, 0.42); background: rgba(48, 36, 24, 0.62); color: #d9c08a; cursor: pointer; font-size: 11px; line-height: 18px; } .poe-helper-mini-btn:hover { background: rgba(85, 62, 34, 0.9); color: #ffe2a0; } .poe-helper-danger-btn { border-color: transparent; background: transparent; color: #8f8574; } .poe-helper-danger-btn:hover { border-color: rgba(151, 75, 55, 0.45); background: rgba(58, 24, 18, 0.45); color: #d99a84; } .poe-helper-lang-badge.cn { border-color: rgba(88, 142, 111, 0.45); background: rgba(24, 63, 48, 0.48); color: #9fd6b4; } .poe-helper-lang-badge.tw { border-color: rgba(154, 119, 70, 0.45); background: rgba(55, 40, 24, 0.6); color: #d9c08a; } .poe-helper-lang-badge.us { border-color: rgba(121, 139, 166, 0.45); background: rgba(34, 42, 55, 0.5); color: #c9d4e6; } .poe-helper-result-desc { display: flex; justify-content: space-between; gap: 8px; margin-top: 3px; color: #9f9686; font-size: 11px; } .poe-helper-recent-title { padding: 8px 10px 4px; color: #d9c08a; font-size: 12px; font-weight: 700; } .poe-helper-empty, .poe-helper-loading { padding: 18px 10px; text-align: center; color: #9f9686; font-size: 12px; } .poe-helper-content { margin-top: 14px; } .poe-helper-content > .poe-helper-empty, .poe-helper-content > .poe-helper-loading { min-height: 220px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid rgba(154, 119, 70, 0.2); background: rgba(28, 24, 18, 0.42); } .poe-helper-module { margin-top: 12px; } .poe-helper-module:first-of-type { margin-top: 0; } .poe-helper-module-title { margin: 0 0 7px; color: #d9c08a; font-size: 12px; font-weight: 700; letter-spacing: 0.2px; } .poe-helper-module-body { display: grid; gap: 6px; } .poe-helper-section { margin-top: 0; padding: 8px 9px; border-radius: 4px; border: 1px solid rgba(154, 119, 70, 0.24); background: rgba(28, 24, 18, 0.6); } .poe-helper-section-title { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; color: #d9c08a; font-size: 13px; font-weight: 700; line-height: 1.25; } .poe-helper-current-mark { padding: 1px 5px; border-radius: 999px; border: 1px solid rgba(88, 142, 111, 0.42); background: rgba(24, 63, 48, 0.42); color: #9fd6b4; font-size: 10px; font-weight: 600; line-height: 1.35; } .poe-helper-name-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 6px; } .poe-helper-name { min-width: 0; color: #eee6d3; font-size: 13px; line-height: 1.3; overflow-wrap: anywhere; word-break: normal; } .poe-helper-name-switch { width: 100%; padding: 0; border: 0; background: transparent; text-align: left; cursor: pointer; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; text-decoration-color: rgba(217, 192, 138, 0.45); } .poe-helper-name-switch:hover { color: #ffe2a0; text-decoration-color: rgba(255, 226, 160, 0.9); } .poe-helper-name-current { cursor: default; } .poe-helper-actions { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } .poe-helper-btn { padding: 3px 7px; line-height: 1.35; } .poe-helper-buy { border-color: rgba(88, 142, 111, 0.46); background: rgba(17, 72, 52, 0.42); color: #9fd6b4; } .poe-helper-history { margin-top: 10px; } .poe-helper-history-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; } .poe-helper-history-clear { border: 0; background: transparent; color: #9f9686; cursor: pointer; font-size: 11px; padding: 2px 0; } .poe-helper-history-clear:hover { color: #d9c08a; } .poe-helper-history-list { display: grid; gap: 4px; } .poe-helper-history-row { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 5px; padding: 4px 5px; border-radius: 4px; color: #d9d1bf; cursor: pointer; } .poe-helper-history-row:hover { background: rgba(154, 119, 70, 0.16); color: #ffe2a0; } .poe-helper-history-name { min-width: 0; font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } .poe-helper-history-delete { width: 22px; min-width: 22px; padding: 0; } .poe-helper-toast { position: fixed; top: 50%; left: 50%; z-index: 10003; transform: translate(-50%, -50%); padding: 14px 20px; border-radius: 8px; background: rgba(34, 197, 94, 0.96); color: #fff; font-size: 14px; font-weight: 700; box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); animation: poeHelperToast 1.8s ease forwards; pointer-events: none; } .poe-helper-toast.error { background: rgba(239, 68, 68, 0.96); } .poe-helper-toast.info { background: rgba(99, 102, 241, 0.96); } @keyframes poeHelperToast { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } 12% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 88% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.94); } } @media (max-width: 768px) { .poe-helper-panel { top: 8px; right: 8px; left: auto; width: min(280px, calc(100vw - 16px)); max-height: calc(100vh - 20px); } .poe-helper-name-row { grid-template-columns: 1fr auto; } .poe-helper-actions { justify-content: flex-end; } } `); const requestText = (url) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 12000, headers: { Accept: 'application/json,text/javascript,text/html,*/*;q=0.8', Referer: `https://${currentSite.host}/` }, onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error(`HTTP ${response.status}: ${url}`)); } }, onerror: () => reject(new Error(`请求失败: ${url}`)), ontimeout: () => reject(new Error(`请求超时: ${url}`)) }); }); const escapeHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); const debounce = (fn, delay) => { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; const isPanelExpanded = () => { try { return window.localStorage.getItem(PANEL_STATE_KEY) !== '0'; } catch (error) { console.warn(`${currentSite.name} 助手读取面板状态失败`, error); return true; } }; const rememberPanelExpanded = (expanded) => { try { window.localStorage.setItem(PANEL_STATE_KEY, expanded ? '1' : '0'); } catch (error) { console.warn(`${currentSite.name} 助手保存面板状态失败`, error); } }; const getRecentItems = () => { try { const parsed = JSON.parse(window.localStorage.getItem(RECENT_ITEMS_KEY) || '[]'); return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_ITEMS) : []; } catch (error) { console.warn(`${currentSite.name} 助手读取最近搜索打开失败`, error); return []; } }; const saveRecentItems = (items) => { try { window.localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(items.slice(0, MAX_RECENT_ITEMS))); } catch (error) { console.warn(`${currentSite.name} 助手保存最近搜索打开失败`, error); } }; const rememberRecentItem = (item, lang) => { const recent = getRecentItems().filter((entry) => entry.path !== item.path); recent.unshift({ path: item.path, labels: item.labels, desc: item.desc || '', lastLang: lang }); saveRecentItems(recent); }; const getVisitedPages = () => { try { const parsed = JSON.parse(window.localStorage.getItem(VISITED_PAGES_KEY) || '[]'); return Array.isArray(parsed) ? parsed.slice(0, MAX_VISITED_PAGES) : []; } catch (error) { console.warn(`${currentSite.name} 助手读取最近访问失败`, error); return []; } }; const saveVisitedPages = (items) => { try { window.localStorage.setItem(VISITED_PAGES_KEY, JSON.stringify(items.slice(0, MAX_VISITED_PAGES))); } catch (error) { console.warn(`${currentSite.name} 助手保存最近访问失败`, error); } }; const rememberVisitedPage = (entry) => { if (!entry.path || ['cn', 'tw', 'us'].includes(entry.path)) return getVisitedPages(); const labels = Object.fromEntries( Object.entries(entry.labels || {}).filter(([, value]) => value && value !== 'N/A' && value !== '加载失败') ); if (!Object.keys(labels).length) return getVisitedPages(); const visited = getVisitedPages().filter((item) => item.path !== entry.path); visited.unshift({ path: entry.path, labels, lastLang: entry.lang, visitedAt: Date.now() }); saveVisitedPages(visited); return visited; }; const removeVisitedPage = (path) => { saveVisitedPages(getVisitedPages().filter((item) => item.path !== path)); }; const getCurrentLangAndPath = () => { const pattern = new RegExp(`^https://${currentSite.host.replace(/\./g, '\\.')}\\/(cn|tw|us)\\/?(.*)$`); const match = window.location.href.match(pattern); if (!match) return null; return { lang: match[1], path: match[2].replace(/^\/+/, '') }; }; const normalizeRelativePath = (value) => { const sitePattern = new RegExp(`^https://${currentSite.host.replace(/\./g, '\\.')}\\/(cn|tw|us)\\/`, 'i'); const cleaned = String(value || '') .replace(sitePattern, '') .replace(/^\/+/, '') .replace(/^(cn|tw|us)\//i, ''); return cleaned || ''; }; const getHeaderScriptUrl = async () => { if (state.headerScriptUrl) return state.headerScriptUrl; const currentScript = [...document.scripts] .map((script) => script.src) .find((src) => /\/js\/poedb_header\.[a-z0-9]+\.js(?:\?.*)?$/i.test(src) || src.includes('/js/poedb_header.')); if (currentScript) { state.headerScriptUrl = currentScript; return currentScript; } const html = await requestText(buildSiteUrl('cn')); const cdnPattern = new RegExp(`https://${currentSite.cdnHost.replace(/\./g, '\\.')}\\/js\\/poedb_header\\.[a-f0-9]+\\.js`, 'i'); const match = html.match(cdnPattern); if (!match) throw new Error('没有找到 poedb_header 脚本'); state.headerScriptUrl = match[0]; return state.headerScriptUrl; }; const parseAutocompleteFiles = (headerJs) => { const files = {}; for (const lang of LANGS) { const match = headerJs.match(new RegExp(`${currentSite.autocompletePrefix}_${lang}\\.[a-z0-9]+\\.json`, 'i')); if (match) files[lang] = buildCdnUrl(`json/${match[0]}`); } return files; }; const buildSearchIndex = (lang, data) => { const list = Array.isArray(data) ? data : []; state.autocomplete[lang] = list; state.autocompleteByValue[lang] = new Map( list.map((item) => [normalizeRelativePath(item.value), item]) ); state.searchIndex[lang] = list.map((item) => { const path = normalizeRelativePath(item.value); const label = item.label || path; const desc = item.desc || ''; const value = item.value || ''; return { lang, path, label, desc, labelLower: String(label).toLowerCase(), labelNormalized: normalizeSearchText(label), valueLower: String(value).toLowerCase(), valueNormalized: normalizeSearchText(value), searchText: `${label} ${desc} ${value}`.toLowerCase(), searchTextNormalized: normalizeSearchText(`${label} ${desc} ${value}`) }; }).filter((item) => item.path); }; const loadAutocompleteData = async () => { if (state.autocompleteLoading) return state.autocompleteLoading; state.autocompleteLoading = (async () => { try { if (!Object.keys(state.autocompleteFiles).length) { const headerUrl = await getHeaderScriptUrl(); const headerJs = await requestText(headerUrl); state.autocompleteFiles = parseAutocompleteFiles(headerJs); } const results = await Promise.allSettled(LANGS.map(async (lang) => { if (state.searchIndex[lang]?.length) return; const fileUrl = state.autocompleteFiles[lang]; if (!fileUrl) throw new Error(`没有找到 ${lang} 自动补全文件`); const raw = await requestText(fileUrl); buildSearchIndex(lang, JSON.parse(raw)); })); const failures = results .map((result, index) => ({ result, lang: LANGS[index] })) .filter((entry) => entry.result.status === 'rejected'); failures.forEach((entry) => console.warn(`${currentSite.name} ${entry.lang} 搜索数据加载失败`, entry.result.reason)); const loadedLangs = LANGS.filter((lang) => state.searchIndex[lang]?.length); if (!loadedLangs.length) { throw new Error('三种语言搜索数据都加载失败'); } return state.autocomplete; } catch (error) { state.autocompleteLoading = null; throw error; } })(); return state.autocompleteLoading; }; const findSearchResults = (query) => { const q = query.trim().toLowerCase(); const qNormalized = normalizeSearchText(query.trim()); if (!q) return []; const groups = new Map(); for (const lang of LANGS) { let langMatches = 0; for (const item of state.searchIndex[lang] || []) { if (!item.searchText.includes(q) && !item.searchTextNormalized.includes(qNormalized)) continue; const score = item.labelLower === q || item.labelNormalized === qNormalized ? 0 : item.labelLower.startsWith(q) || item.labelNormalized.startsWith(qNormalized) ? 1 : item.valueLower.includes(q) || item.valueNormalized.includes(qNormalized) ? 2 : 3; const existing = groups.get(item.path); if (!existing) { groups.set(item.path, { path: item.path, desc: item.desc || '', score, matchedLangs: new Set([lang]) }); } else { existing.score = Math.min(existing.score, score); existing.desc = existing.desc || item.desc || ''; existing.matchedLangs.add(lang); } langMatches++; if (langMatches >= 18) break; } } return [...groups.values()] .map((group) => { const labels = {}; const descs = []; for (const lang of LANGS) { const item = state.autocompleteByValue[lang]?.get(group.path); if (item?.label) labels[lang] = item.label; if (item?.desc) descs.push(item.desc); } return { path: group.path, labels, desc: group.desc || descs[0] || '', score: group.score, matchedLangCount: group.matchedLangs.size }; }) .sort((a, b) => a.score - b.score || b.matchedLangCount - a.matchedLangCount || (a.labels.cn || a.labels.tw || a.labels.us || a.path).length - (b.labels.cn || b.labels.tw || b.labels.us || b.path).length) .slice(0, 18); }; const extractInfoFromDocument = (doc) => { const selectors = [ '.itemName .lc', '.itemName', '[class*="itemName"]', 'h1', 'h3', 'title' ]; for (const selector of selectors) { const element = doc.querySelector(selector); const text = element ? element.textContent.trim().replace(/\s+/g, ' ') : ''; if (text) return { title: text.replace(currentSite.titlePattern, '') }; } return { title: 'N/A' }; }; const fetchLangInfo = async (lang, path, currentLang) => { if (lang === currentLang) { return extractInfoFromDocument(document); } const html = await requestText(buildSiteUrl(lang, path)); const doc = new DOMParser().parseFromString(html, 'text/html'); return extractInfoFromDocument(doc); }; const showNotification = (message, type = 'success') => { document.querySelectorAll('.poe-helper-toast').forEach((node) => node.remove()); const toast = document.createElement('div'); toast.className = `poe-helper-toast ${type}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), 1800); }; const copyText = async (text) => { try { await navigator.clipboard.writeText(text); showNotification('已复制到剪贴板'); } catch (error) { console.error(error); showNotification('复制失败', 'error'); } }; const buyItem = async (itemName, lang) => { const server = MARKET_SERVERS[lang] || MARKET_SERVERS.cn; try { await navigator.clipboard.writeText(itemName); } catch (error) { console.error(error); } showNotification(`已复制,正在打开${server.name}市集`, 'success'); const url = server.query ? `${server.url}${encodeURIComponent(itemName)}` : server.url; setTimeout(() => window.open(url, '_blank'), 800); }; const openSearchResult = (result, lang) => { rememberRecentItem(result, lang); window.location.href = buildSiteUrl(lang, result.path); }; const openVisitedPage = (entry) => { const lang = LANGS.includes(entry.lastLang) ? entry.lastLang : 'cn'; window.location.href = buildSiteUrl(lang, entry.path); }; const getResultLabel = (result, lang) => result.labels?.[lang] || result.labels?.cn || result.labels?.tw || result.labels?.us || result.path; const getResultDefaultLang = (result) => { if (LANGS.includes(result.lastLang) && result.labels?.[result.lastLang]) return result.lastLang; return LANGS.find((lang) => result.labels?.[lang]) || 'cn'; }; const renderSearchResults = (container, results, options = {}) => { container.innerHTML = ''; if (!results.length) { container.innerHTML = '