// ==UserScript== // @name 班固米人物别名本地 API // @namespace https://bgm.tv/ // @homepage https://bgm.tv/group/topic/439645 // @version 2.0.2 // @description 从 wiki archive 自动生成,支持远程更新和本地 .json.gz 文件导入,与其他脚本联合使用 // @author Your Name // @match http*://bgm.tv/* // @match http*://bangumi.tv/* // @match http*://chii.in/* // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM.notification // @grant unsafeWindow // @connect inchei.github.io // @require https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js // @license MIT // @gf https://greasyfork.org/zh-CN/scripts/552759 // ==/UserScript== /* global pako */ (function () { 'use strict'; // 配置 const CONFIG = { dataBaseUrl: 'https://inchei.github.io/bangumi-wiki-scripts/', dbName: 'bangumiPersonDB', dbVersion: 3, compressedFile: 'person_alias.json.gz', versionFile: 'data_version.txt', }; let menuCommandId; let dbInitialized = false; let fileInput = null; // 全局存储文件选择器,避免重复创建 // Source must be UTF-8; NFKC handles halfwidth katakana (U+FF66-FF9D) // -> fullwidth katakana and fullwidth alphanumerics (U+FF21-FF5A) -> ASCII. // Requires font covering CJK Unified/Compatibility Ideographs (e.g. 﨑 U+FA11). const normalize = (name) => name .normalize('NFKC') // halfwidth katakana/fullwidth alphanumerics -> compatibility decomposition .replace(/[\s-]/g, '') // 去空格/连字符 .replace(/[\u30A1-\u30F6]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60)) // 全角片假名→平假名 .toLowerCase(); // 字母统一小写 // -------------------------- 关键修复:可见按钮触发文件选择 -------------------------- /** * 创建可见的临时按钮(用户主动点击触发文件选择,规避浏览器禁用) * 按钮点击后自动移除,避免页面干扰 */ function createVisibleTriggerButton() { // 先移除已存在的按钮(防止重复) const oldBtn = document.getElementById('personAliasImportBtn'); if (oldBtn) oldBtn.remove(); // 创建可见按钮 const btn = document.createElement('button'); btn.id = 'personAliasImportBtn'; btn.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 12px 24px; font-size: 16px; background: #2c83fb; color: white; border: none; border-radius: 8px; cursor: pointer; z-index: 99999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); `; btn.textContent = '点击选择 .json.gz 别名文件(选择后按钮自动消失)'; // 初始化文件选择器(隐藏) if (!fileInput) { fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json.gz'; fileInput.style.display = 'none'; // 绑定文件选择后的处理逻辑 fileInput.onchange = handleFileSelect; document.body.appendChild(fileInput); } // 按钮点击 → 触发文件选择器(用户主动操作,规避禁用) btn.onclick = () => { fileInput.click(); btn.remove(); // 点击后移除按钮,不干扰页面 }; // 点击页面其他区域也移除按钮 const handleDocClick = (e) => { if (e.target !== btn && e.target !== fileInput) { btn.remove(); document.removeEventListener('click', handleDocClick); } }; document.addEventListener('click', handleDocClick); document.body.appendChild(btn); } async function handleFileSelect(e) { const file = e.target.files[0]; if (!file) return; // 重置文件选择器(允许重复选择同一文件) fileInput.value = ''; // 更新菜单状态 + 显示处理通知 menuCommandId = GM_registerMenuCommand('处理中...', () => {}); showGMNotification({ text: `正在处理文件: ${file.name}...` }); try { // 读取并解析本地文件 const data = await readLocalGzFile(file); // 导入数据库 await importToIndexedDB(data); // 记录本地导入版本(与远程更新区分) const prevVersion = GM_getValue('lastPersonAliasVersion', '从未更新'); const localVersion = `本地导入_${new Date().toLocaleString()}`; GM_setValue('lastPersonAliasVersion', localVersion); // 恢复菜单 + 显示成功通知 registerMainMenu(); showGMNotification({ title: '本地导入成功', text: `旧版本: ${prevVersion}\n新版本: ${localVersion}\n共导入 ${data[1] ? Object.keys(data[1]).length : 0} 条别名记录`, }); } catch (err) { // 恢复菜单 + 显示错误通知 registerMainMenu(); showGMNotification({ title: '本地导入失败', text: `错误详情: ${err.message}`, }); console.error('本地导入错误:', err); } } // -------------------------- 修复结束 -------------------------- // 读取本地 .json.gz 文件并解析(逻辑不变) function readLocalGzFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { // 解压文件(依赖pako库) const decompressed = pako.inflate(e.target.result); // 解码为JSON字符串并解析 const jsonStr = new TextDecoder('utf-8').decode(decompressed); const data = JSON.parse(jsonStr); // 严格验证数据格式(必须与远程数据结构一致:[persons数组, aliases对象]) if (!Array.isArray(data) || data.length !== 2) { throw new Error('文件格式错误:需为 [persons数组, aliases对象] 结构'); } if (!Array.isArray(data[0]) || typeof data[1] !== 'object') { throw new Error('文件内容错误:persons需为数组,aliases需为对象'); } resolve(data); } catch (e) { if (e instanceof pako.ZlibError) { reject(new Error(`文件解压失败: ${e.message}`)); } else if (e instanceof SyntaxError) { reject(new Error(`JSON解析失败: ${e.message}`)); } else { reject(new Error(`文件验证失败: ${e.message}`)); } } }; reader.onerror = () => reject(new Error('文件读取失败(可能是文件损坏或无读取权限)')); reader.readAsArrayBuffer(file); // 二进制读取压缩文件 }); } // 注册菜单(远程更新 + 本地导入,逻辑不变) function registerMainMenu() { // 先移除旧菜单,避免重复 if (menuCommandId !== undefined) { GM_unregisterMenuCommand(menuCommandId); } // 1. 远程更新菜单(原有功能) menuCommandId = GM_registerMenuCommand('更新人物别名映射表(远程)', startUpdate); // 2. 本地导入菜单(点击后创建可见触发按钮) GM_registerMenuCommand('手动导入 .json.gz 文件', createVisibleTriggerButton); } // 显示GM通知(原有功能,逻辑不变) function showGMNotification(options) { GM.notification({ text: options.text, title: options.title || '人物别名映射表', onclick: options.onclick || function () {}, ondone: options.ondone || function () {}, }); } // 远程更新(原有功能,逻辑不变) async function startUpdate() { menuCommandId = GM_registerMenuCommand('获取中...', () => {}); showGMNotification({ text: '更新人物别名数据中……' }); try { const data = await downloadAndDecompress(CONFIG.dataBaseUrl + CONFIG.compressedFile); await importToIndexedDB(data); const prevVersion = GM_getValue('lastPersonAliasVersion', '从未更新'); const newVersion = await fetchDataVersion(); GM_setValue('lastPersonAliasVersion', newVersion); registerMainMenu(); showGMNotification({ title: '远程更新成功', text: `旧版本: ${prevVersion}\n新版本: ${newVersion}\n共导入 ${data[1] ? Object.keys(data[1]).length : 0} 条别名记录`, }); } catch (err) { registerMainMenu(); showGMNotification({ title: '远程更新失败', text: `错误详情: ${err.message}`, }); console.error('远程更新错误:', err); } } // 获取数据版本日期(GitHub Pages 上的 data_version.txt) function fetchDataVersion() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: CONFIG.dataBaseUrl + CONFIG.versionFile, onload: (res) => { if (res.status === 200) { const v = String(res.responseText).trim(); resolve(v || '未知'); } else { resolve('未知'); } }, onerror: () => resolve('未知'), }); }); } // 下载并解压远程文件(原有功能,逻辑不变) function downloadAndDecompress(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', onload: (res) => { if (res.status !== 200) { reject(new Error(`下载文件失败 (HTTP ${res.status})`)); return; } try { const decompressed = pako.inflate(res.response); const jsonStr = new TextDecoder().decode(decompressed); const data = JSON.parse(jsonStr); resolve(data); } catch (e) { if (e instanceof pako.ZlibError) { reject(new Error(`解压失败: ${e.message}`)); } else if (e instanceof SyntaxError) { reject(new Error(`解析JSON失败: ${e.message}`)); } else { reject(new Error(`处理数据失败: ${e.message}`)); } } }, onerror: (err) => { reject(new Error(`下载请求出错: ${err.error || '未知错误'}`)); }, ontimeout: () => { reject(new Error('下载超时')); }, }); }); } // 初始化数据库 function initDB(force = false) { if (dbInitialized && !force) { return Promise.resolve(); } return new Promise((resolve, reject) => { const request = indexedDB.open(CONFIG.dbName, CONFIG.dbVersion); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('persons')) { const personsStore = db.createObjectStore('persons', { keyPath: 'index' }); personsStore.createIndex('by_index', 'index', { unique: true }); } if (!db.objectStoreNames.contains('aliases')) { db.createObjectStore('aliases', { keyPath: 'alias' }); } }; request.onsuccess = (e) => { const db = e.target.result; const migrationDone = GM_getValue('personAliasDataVersion', 0); if (migrationDone < CONFIG.dbVersion) { migrateAliasesToArray(db) .then(() => { GM_setValue('personAliasDataVersion', CONFIG.dbVersion); db.close(); dbInitialized = true; resolve(); }) .catch((err) => { db.close(); reject(err); }); } else { db.close(); dbInitialized = true; resolve(); } }; request.onerror = (e) => { reject(new Error(`数据库初始化失败: ${e.target.error.message}`)); }; }); } function migrateAliasesToArray(db) { return new Promise((resolve, reject) => { const tx = db.transaction('aliases', 'readwrite'); const store = tx.objectStore('aliases'); let count = 0; store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (cursor) { const record = cursor.value; if (record.personIndex !== undefined && !record.personIndexes) { record.personIndexes = [record.personIndex]; delete record.personIndex; cursor.update(record); count++; } cursor.continue(); } }; tx.oncomplete = () => { if (count > 0) { showGMNotification({ text: `已迁移 ${count} 条别名记录到新格式。`, }); } resolve(); }; tx.onerror = () => reject(tx.error); }); } // 导入数据到IndexedDB(原有功能,逻辑不变) function importToIndexedDB(data) { return new Promise((resolve, reject) => { initDB() .then(() => { const request = indexedDB.open(CONFIG.dbName, CONFIG.dbVersion); request.onsuccess = (e) => { const db = e.target.result; const tx = db.transaction(['persons', 'aliases'], 'readwrite'); const personsStore = tx.objectStore('persons'); const aliasesStore = tx.objectStore('aliases'); personsStore.clear(); aliasesStore.clear(); const [persons, aliases] = data; if (persons && Array.isArray(persons)) { persons.forEach((person, index) => { if (Array.isArray(person) && person.length >= 2) { const personObj = { index: index, name: person[0], id: person[1], }; personsStore.put(personObj); } }); } if (aliases && typeof aliases === 'object') { Object.entries(aliases).forEach(([alias, val]) => { const indexes = Array.isArray(val) ? val : [val]; aliasesStore.put({ alias: alias, personIndexes: indexes, }); }); } tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = (e) => { reject(new Error(`数据库写入失败: ${tx.error?.message || e.target.error}`)); }; }; request.onerror = (e) => { reject(new Error(`打开数据库失败: ${e.target.error.message}`)); }; }) .catch(reject); }); } unsafeWindow.personAliasQuery = async function (aliasName) { const all = await unsafeWindow.personAliasQueryAll(aliasName); if (!all || !all.length) return null; if (all.length > 1) { const nameList = all.map((p) => `${p.name} (${p.id})`).join('\n'); showGMNotification({ title: '别名匹配到多个人物', text: `别名 "${aliasName}" 匹配到 ${all.length} 个人物,已取第一个:\n${nameList}`, }); console.warn(`personAliasQuery: 别名 "${aliasName}" 匹配到多个人物:`, all); } return all[0]; }; unsafeWindow.personAliasQueryAll = async function (aliasName) { if (!dbInitialized) { await initDB(); } return new Promise((resolve) => { const request = indexedDB.open(CONFIG.dbName, CONFIG.dbVersion); request.onsuccess = (e) => { const db = e.target.result; const tx = db.transaction(['aliases', 'persons'], 'readonly'); const aliasesStore = tx.objectStore('aliases'); const personsStore = tx.objectStore('persons'); const aliasReq = aliasesStore.get(normalize(aliasName)); aliasReq.onsuccess = () => { if (aliasReq.result) { const indexes = aliasReq.result.personIndexes || []; const results = []; let remaining = indexes.length; if (remaining === 0) { db.close(); resolve([]); return; } indexes.forEach((idx) => { const personReq = personsStore.get(idx); personReq.onsuccess = () => { if (personReq.result) { results.push({ name: personReq.result.name, id: personReq.result.id, }); } remaining--; if (remaining === 0) { db.close(); resolve(results); } }; personReq.onerror = () => { remaining--; if (remaining === 0) { db.close(); resolve(results); } }; }); } else { db.close(); resolve([]); } }; aliasReq.onerror = () => { db.close(); resolve([]); console.error('查询别名失败:', aliasReq.error); }; }; request.onerror = () => { resolve([]); console.error('打开数据库出错:', request.error); }; }); }; // 脚本初始化(原有逻辑,不变) initDB().catch((err) => console.log('数据库初始化:', err)); registerMainMenu(); })();