班固米人物别名本地 API

从 wiki archive 自动生成,支持远程更新和本地 .json.gz 文件导入

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         班固米人物别名本地 API
// @namespace    https://bgm.tv/
// @version      1.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      github.com
// @connect      api.github.com
// @connect      objects.githubusercontent.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 配置
    const CONFIG = {
        githubRepo: "inchei/bangumi-wiki-scripts",
        dbName: "bangumiPersonDB",
        storeName: "personAlias",
        dbVersion: 2,
        compressedFile: "person_alias.json.gz"
    };

    let menuCommandId;
    let dbInitialized = false;
    let fileInput = null; // 全局存储文件选择器,避免重复创建


    // -------------------------- 关键修复:可见按钮触发文件选择 --------------------------
    /**
     * 创建可见的临时按钮(用户主动点击触发文件选择,规避浏览器禁用)
     * 按钮点击后自动移除,避免页面干扰
     */
    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 release = await getLatestRelease();
            if (!release) throw new Error("无法获取最新发布信息");

            const asset = release.assets.find(a => a.name === CONFIG.compressedFile);
            if (!asset) throw new Error(`未找到文件: ${CONFIG.compressedFile}`);

            const data = await downloadAndDecompress(asset.browser_download_url);
            await importToIndexedDB(data);

            const prevVersion = GM_getValue('lastPersonAliasVersion', '从未更新');
            GM_setValue('lastPersonAliasVersion', release.tag_name);

            registerMainMenu();
            showGMNotification({
                title: "远程更新成功",
                text: `旧版本: ${prevVersion}\n新版本: ${release.tag_name}\n共导入 ${data[1] ? Object.keys(data[1]).length : 0} 条别名记录`
            });

        } catch (err) {
            registerMainMenu();
            showGMNotification({
                title: "远程更新失败",
                text: `错误详情: ${err.message}`
            });
            console.error("远程更新错误:", err);
        }
    }

    // 获取GitHub最新发布(原有功能,逻辑不变)
    function getLatestRelease() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://api.github.com/repos/${CONFIG.githubRepo}/releases/latest`,
                headers: { "Accept": "application/vnd.github.v3+json" },
                onload: (res) => {
                    if (res.status === 200) {
                        try {
                            resolve(JSON.parse(res.responseText));
                        } catch (e) {
                            reject(new Error(`解析发布信息失败: ${e.message}`));
                        }
                    } else {
                        reject(new Error(`获取发布信息失败 (HTTP ${res.status}): ${res.responseText.substring(0, 100)}`));
                    }
                },
                onerror: (err) => {
                    reject(new Error(`请求发布信息出错: ${err}`));
                }
            });
        });
    }

    // 下载并解压远程文件(原有功能,逻辑不变)
    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;
                const oldVersion = e.oldVersion;

                // 版本升级时删除旧存储
                if (oldVersion > 0 && oldVersion < CONFIG.dbVersion) {
                    if (db.objectStoreNames.contains(CONFIG.storeName)) {
                        db.deleteObjectStore(CONFIG.storeName);
                    }
                }

                // 创建新存储结构
                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 => {
                e.target.result.close();
                dbInitialized = true;
                resolve();
            };
            request.onerror = e => {
                reject(new Error(`数据库初始化失败: ${e.target.error.message}`));
            };
        });
    }

    // 导入数据到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, personIndex]) => {
                            aliasesStore.put({
                                alias: alias,
                                personIndex: personIndex
                            });
                        });
                    }

                    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) {
        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(aliasName);

                aliasReq.onsuccess = () => {
                    if (aliasReq.result) {
                        const personIndex = aliasReq.result.personIndex;
                        const personReq = personsStore.get(personIndex);

                        personReq.onsuccess = () => {
                            db.close();
                            resolve(personReq.result ? {
                                name: personReq.result.name,
                                id: personReq.result.id
                            } : null);
                        };

                        personReq.onerror = () => {
                            db.close();
                            resolve(null);
                            console.error("查询人物信息失败:", personReq.error);
                        };
                    } else {
                        db.close();
                        resolve(null);
                    }
                };

                aliasReq.onerror = () => {
                    db.close();
                    resolve(null);
                    console.error("查询别名失败:", aliasReq.error);
                };
            };
            request.onerror = () => {
                resolve(null);
                console.error("打开数据库出错:", request.error);
            };
        });
    };

    // 脚本初始化(原有逻辑,不变)
    initDB().catch(err => console.log("数据库初始化:", err));
    registerMainMenu();
})();