|
|
脚本会根据当前搜索框内容查询并列出相关动画的各种名称,在列表中点击条目可以直接进行搜索,点击条目前的语言标识可以替换搜索框内容
查询服务部署在cloudflare workers上,数据用的是anidb提供的离线名称库,要自建的话可以按下面的步骤
1. 新建KV
2. 新建Workers并贴入代码
3. 绑定Workers和KV,变量名称填“ANIME_TITLES”
4. 添加“变量和机密”用作数据更新页面的密码,类型选“密钥”,变量名称填“PASSWORD”,值为要设置的密码
部署完成后,替换油猴中两处域名即可。首次查询会自动从anidb下载数据,后续如果需要更新数据可以自行添加定时任务或者直接打开workers地址手动更新。anidb提供的数据是每日更新,定时间隔也应当不小于一天。

油猴脚本:
- // ==UserScript==
- // [url=home.php?mod=space&uid=14588]@Name[/url] Nyaa 搜索建议(动画别名)
- // @namespace http://localhost/
- // @version 1.1
- // @description 在 Nyaa 页面快捷查询动画译名,方便切换关键词进行搜索
- // @author Claude & Gemini
- // @match *://*.nyaa.si/*
- // @grant GM_xmlhttpRequest
- // @connect anime.titles.workers.dev
- // ==/UserScript==
- (function () {
- 'use strict';
- const style = document.createElement('style');
- style.textContent = `
- #anime-search-btn {
- background: #337ab7;
- color: #fff;
- border: none;
- border-radius: 3px;
- margin-left: 5px;
- }
- #anime-search-btn:hover { background: #286090; }
- #anime-panel {
- position: absolute;
- z-index: 99999;
- background: #fff;
- border-radius: 3px;
- padding: 10px;
- width: 300px;
- max-width: 90vw;
- max-height: 70vh;
- overflow-y: auto;
- box-shadow: 0 2px 10px rgba(0,0,0,0.2);
- display: none;
- font-size: 14px;
- }
- #anime-panel.show { display: block; }
- #anime-panel-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
- padding-bottom: 8px;
- border-bottom: 1px solid #eee;
- }
- #anime-panel-header span { color: #595959; font-size: 12px; }
- #anime-close { cursor: pointer; font-size: 18px; color: #999; }
- #anime-close:hover { color: #333; }
- .anime-item {
- margin-bottom: 5px;
- padding-bottom: 5px;
- border-bottom: 1px solid #eee;
- }
- .anime-item:last-child { border-bottom: none; margin-bottom: 0; }
- .anime-aid { color: #707070; font-size: 11px; margin-bottom: 4px; text-decoration: none; }
- .anime-aid:hover { text-decoration: underline; }
- .anime-titles { padding-left: 0; margin: 0; list-style: none; }
- .anime-titles li { margin: 2px 0; }
- .anime-titles a {
- color: #337ab7;
- text-decoration: none;
- }
- .anime-titles a:hover { text-decoration: underline; }
- .anime-titles .lang {
- color: #707070;
- font-size: 11px;
- margin: 5px 5px;
- cursor: pointer;
- user-select: none;
- transition: color 0.2s;
- }
- .anime-titles .lang:hover {
- color: #000;
- /* font-weight: bold;*/
- }
- `;
- document.head.appendChild(style);
- function showError(msg) {
- panel.classList.add('show');
- document.getElementById('anime-info').textContent = msg;
- document.getElementById('anime-content').innerHTML = '';
- }
- // 搜索按钮
- const btn = document.createElement('button');
- btn.id = 'anime-search-btn';
- btn.className = 'btn btn-default';
- btn.type = 'button';
- // 创建 Font Awesome 图标
- const icon = document.createElement('i');
- icon.className = 'fa fa-info-circle fa-paw';
- btn.appendChild(icon);
- // 修改插入逻辑
- function insertBtn() {
- const target = document.querySelector('.search-btn');
- if (target) {
- // 创建一个符合 Bootstrap input-group 规范的包装器
- const wrapper = document.createElement('div');
- wrapper.className = 'input-group-btn';
- // 将按钮放入包装器
- wrapper.appendChild(btn);
- // 将包装器插入到搜索按钮组的后面
- target.insertAdjacentElement('afterend', wrapper);
- } else {
- // 兜底方案
- btn.style.cssText = 'position:fixed;top:80px;right:20px;z-index:99999;';
- document.body.appendChild(btn);
- }
- }
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', insertBtn);
- } else {
- insertBtn();
- }
- // 结果面板
- const panel = document.createElement('div');
- panel.id = 'anime-panel';
- panel.innerHTML = `
- <div id="anime-panel-header">
- <span id="anime-info"></span>
- <span id="anime-close">X</span>
- </div>
- <div id="anime-content"></div>
- `;
- document.body.appendChild(panel);
- panel.querySelector('#anime-close').onclick = () => panel.classList.remove('show');
- // 点击语言标签替换输入框内容
- panel.addEventListener('click', function(e) {
- // 检查点击的是不是 class="lang" 的元素
- if (e.target.classList.contains('lang')) {
- const title = e.target.getAttribute('data-title');
- const input = document.querySelector('.search-bar');
- if (title && input) {
- input.value = title; // 替换内容
- input.focus(); // 让输入框获得焦点
- // 视觉反馈,闪烁输入框背景
- const originalBg = input.style.backgroundColor;
- input.style.backgroundColor = '#88b5dd';
- setTimeout(() => {
- input.style.backgroundColor = originalBg;
- }, 200);
- }
- }
- });
- const langMap = { 'ja': '日', 'en': '英', 'zh-Hans': '简', 'zh-Hant': '繁', 'x-jat': '罗' };
- btn.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- const input = document.querySelector('.search-bar');
- if (!input) return;
- const rect = btn.getBoundingClientRect();
- const scrollY = window.scrollY || document.documentElement.scrollTop;
- const docWidth = document.documentElement.clientWidth;
- // 设置面板坐标
- panel.style.top = (rect.bottom + scrollY + 5) + 'px';
- panel.style.left = 'auto'; // 清除可能存在的左定位
- panel.style.right = (docWidth - rect.right) + 'px'; // 右对齐
- // 检查关键字
- const keyword = input.value.trim();
- if (!keyword) {
- showError('请输入关键字');
- return;
- }
- // 有关键字,开始搜索流程
- panel.classList.add('show');
- const content = document.getElementById('anime-content');
- const info = document.getElementById('anime-info');
- content.innerHTML = '搜索中...';
- info.textContent = '';
- // 发起请求
- GM_xmlhttpRequest({
- method: 'GET',
- url: `https://anime.titles.workers.dev/?q=${encodeURIComponent(keyword)}&limit=50`,
- onload: function (res) {
- try {
- const data = JSON.parse(res.responseText);
- if (data.results && data.results.length > 0) {
- info.textContent = `${data.count} 个结果`; // (${data.searchTime})
- content.innerHTML = data.results.map(item => `
- <div class="anime-item">
- <a class="anime-aid" href="https://anidb.net/anime/${item.aid}" target="_blank">AID: ${item.aid}</a>
- <ul class="anime-titles">
- ${item.titles.map(t => {
- // 处理标题中的双引号,防止破坏 HTML 结构
- const safeTitle = t.title.replace(/"/g, '"');
- // 在 span 中添加 data-title 属性和 title 提示
- return `
- <li>
- <span class="lang" data-title="${safeTitle}" title="填入搜索框">
- [${langMap[t.language] || t.language}]
- </span>
- <a href="/?f=0&c=0_0&q=${encodeURIComponent(t.title)}" target="_blank">${t.title}</a>
- </li>
- `;
- }).join('')}
- </ul>
- </div>
- `).join('');
- } else {
- content.innerHTML = '未找到结果';
- }
- } catch (e) {
- content.innerHTML = '解析失败: ' + e.message;
- }
- },
- onerror: () => { content.innerHTML = '请求失败'; }
- });
- };
- document.addEventListener('click', function(e) {
- // 点击空白处关闭面板
- if (!panel.contains(e.target) && e.target !== btn) {
- panel.classList.remove('show');
- }
- });
- })();
复制代码

Workers代码:
- let cachedTitles = null;
- let cacheTime = 0;
- const CACHE_TTL = 300000; // 5分钟
- const ANIDB_URL = 'http://anidb.net/api/anime-titles.dat.gz';
- export default {
- async fetch(request, env) {
- const url = new URL(request.url);
- // 优先处理搜索逻辑
- if (url.searchParams.has('q')) {
- return handleSearch(request, env, url);
- }
- // 不带 q 参数时进入管理
- return handleManagement(request, env, url);
- },
- // 定时任务处理
- async scheduled(event, env, ctx) {
- console.log('开始定时更新任务。', new Date().toISOString());
- try {
- await autoImportData(env);
- console.log('定时更新完成');
- } catch (error) {
- console.error('定时更新失败!', error);
- }
- }
- };
- // 搜索逻辑
- async function handleSearch(request, env, url) {
- const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
- };
- if (request.method === 'OPTIONS') {
- return new Response(null, { headers: corsHeaders });
- }
- const query = url.searchParams.get('q');
- const limit = parseInt(url.searchParams.get('limit') || '100');
- try {
- const startTime = Date.now();
- const now = Date.now();
- // 缓存检查
- if (!cachedTitles || (now - cacheTime) > CACHE_TTL) {
- let allTitlesData;
-
- try {
- allTitlesData = await env.ANIME_TITLES.get('all_titles');
- } catch (error) {
- // KV 未绑定或访问失败
- return jsonResponse({
- error: 'KV 存储未配置或访问失败',
- detail: error.message,
- hint: '请检查 ANIME_TITLES KV 命名空间是否已正确绑定'
- }, 503, corsHeaders);
- }
-
- if (!allTitlesData) {
- // 数据未初始化,自动导入
- console.log('检测到数据未初始化,开始自动导入……');
- try {
- await autoImportData(env);
- // 导入后重新获取数据
- const newData = await env.ANIME_TITLES.get('all_titles');
- cachedTitles = JSON.parse(newData);
- cacheTime = now;
- } catch (error) {
- return jsonResponse({
- error: '数据未初始化且自动导入失败',
- detail: error.message
- }, 503, corsHeaders);
- }
- } else {
- cachedTitles = JSON.parse(allTitlesData);
- cacheTime = now;
- }
- }
-
- // 搜索逻辑
- const searchLower = query.toLowerCase();
-
- // 一次遍历完成搜索和聚合
- const results = Object.entries(cachedTitles)
- .filter(([aid, titles]) => {
- // 检查该 AID 下是否有任何标题匹配
- return titles.some(t => t.title?.toLowerCase().includes(searchLower));
- })
- .slice(0, limit) // 限制数量
- .map(([aid, titles]) => ({
- aid: parseInt(aid),
- titles: titles // 直接引用,无需重新构造
- }));
-
- return jsonResponse({
- query,
- count: results.length,
- searchTime: `${Date.now() - startTime}ms`,
- cached: cacheTime !== now,
- results
- }, 200, corsHeaders);
- } catch (error) {
- return jsonResponse({ error: error.message }, 500, corsHeaders);
- }
- }
- // 管理界面
- const COOKIE_NAME = 'AniDB_Updater_Auth';
- async function handleManagement(request, env, url) {
- // 仅在进入管理逻辑时才解析 Cookie
- const cookie = request.headers.get('Cookie');
- const authCookie = cookie && cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
- const isLogged = (authCookie && authCookie[1] === env.PASSWORD);
- // 1. 登录处理
- if (url.pathname === '/login' && request.method === 'POST') {
- const formData = await request.formData();
- if (formData.get('password') === env.PASSWORD) {
- const headers = new Headers();
- headers.append('Set-Cookie', `${COOKIE_NAME}=${env.PASSWORD}; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Strict`);
- headers.append('Location', '/');
- return new Response(null, { status: 302, headers });
- }
- return new Response(null, { status: 302, headers: { 'Location': '/' } });
- }
- // 2. 一键导入处理
- if (url.pathname === '/auto-import' && request.method === 'POST') {
- if (!isLogged) return new Response('Unauthorized', { status: 401 });
- try {
- const count = await autoImportData(env);
- return new Response(`自动导入完成,总条目数: ${count}`);
- } catch (e) {
- return new Response(`自动导入失败! ${e.message}`, { status: 500 });
- }
- }
- // 3. 手动上传处理
- if (request.method === 'POST') {
- if (!isLogged) return new Response('Unauthorized', { status: 401 });
- try {
- const fileBuffer = await request.arrayBuffer();
- if (!fileBuffer || fileBuffer.byteLength === 0) return new Response('无文件内容', { status: 400 });
-
- const count = await processAndSaveData(fileBuffer, env);
-
- // 上传成功后,强制清空搜索缓存
- cachedTitles = null;
-
- return new Response(`更新完成,总条目数: ${count}`);
- } catch (e) {
- return new Response(`失败! ${e.message}`, { status: 500 });
- }
- }
- // 4. 页面渲染
- const html = isLogged ? await renderUploadUI(env) : renderLoginUI();
- return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
- }
- // ================= 自动导入功能 =================
- async function autoImportData(env) {
- // 检查上次更新日期
- const metadata = await env.ANIME_TITLES.get('metadata');
- if (metadata) {
- const meta = JSON.parse(metadata);
- const lastUpdate = new Date(meta.lastUpdate);
- const now = new Date();
-
- // 获取日期部分(忽略时间)
- const lastUpdateDate = lastUpdate.toISOString().split('T')[0];
- const nowDate = now.toISOString().split('T')[0];
-
- // 如果是同一天,拒绝更新
- if (lastUpdateDate === nowDate) {
- throw new Error(`今日已更新(${lastUpdate.toLocaleString('zh-CN')}),请明日再试`);
- }
- }
-
- console.log('正在从 AniDB 获取数据……');
-
- // 从 AniDB 下载 .gz 文件,添加必要的请求头
- const response = await fetch(ANIDB_URL, {
- headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Language': 'en-US,en;q=0.9',
- 'Accept-Encoding': 'gzip, deflate, br, zstd',
- 'Connection': 'keep-alive',
- 'Host': 'anidb.net',
- },
- cf: {
- cacheTtl: 3600,
- cacheEverything: true
- }
- });
-
- if (!response.ok) {
- throw new Error(`下载失败! ${response.status} ${response.statusText}`);
- }
-
- const buffer = await response.arrayBuffer();
- console.log(`下载完成,文件大小: ${buffer.byteLength} 字节`);
-
- // 处理并保存数据
- const count = await processAndSaveData(buffer, env);
-
- // 清空缓存
- cachedTitles = null;
-
- console.log(`数据处理完成,总条目数: ${count}`);
- return count;
- }
- // ================= 数据处理 (仅上传时调用) =================
- async function processAndSaveData(buffer, env) {
- const ALLOWED_LANGS = new Set(['ja', 'en', 'x-jat', 'zh-Hans', 'zh-Hant']);
-
- // 定义语言优先级映射
- const LANG_PRIORITY = {
- 'zh-Hans': 1,
- 'zh-Hant': 2,
- 'ja': 3,
- 'x-jat': 4,
- 'en': 5
- };
-
- let text = '';
- const view = new Uint8Array(buffer);
-
- if (view[0] === 0x1f && view[1] === 0x8b) {
- try {
- const ds = new DecompressionStream('gzip');
- const stream = new Response(buffer).body.pipeThrough(ds);
- const decompressed = await new Response(stream).arrayBuffer();
- text = new TextDecoder('utf-8').decode(decompressed);
- } catch (e) { throw new Error('解压失败!'); }
- } else {
- text = new TextDecoder('utf-8').decode(buffer);
- }
-
- const titles = [];
- const lines = text.trim().split('\n');
-
- for (const line of lines) {
- if (line.startsWith('#')) continue;
- const parts = line.split('|');
- if (parts.length !== 4) continue;
- const [aid, typeStr, language, title] = parts;
- if (!aid || !title) continue;
- const type = parseInt(typeStr);
- if (type === 3) continue;
- if (ALLOWED_LANGS.has(language)) {
- titles.push({
- aid: parseInt(aid),
- type,
- language,
- title
- });
- }
- }
-
- if (titles.length === 0) throw new Error('无有效数据!');
- // 按 AID 分组
- const groupedByAid = {};
- for (const item of titles) {
- const aid = item.aid.toString(); // 转为字符串作为 key
- if (!groupedByAid[aid]) {
- groupedByAid[aid] = [];
- }
- groupedByAid[aid].push({
- type: item.type,
- language: item.language,
- title: item.title
- });
- }
- // 对每个 AID 的标题按语言优先级排序
- for (const aid in groupedByAid) {
- groupedByAid[aid].sort((a, b) => {
- const langA = LANG_PRIORITY[a.language] || 99;
- const langB = LANG_PRIORITY[b.language] || 99;
- return langA - langB;
- });
- }
-
- // 保存分组后的数据
- await env.ANIME_TITLES.put('all_titles', JSON.stringify(groupedByAid));
- await env.ANIME_TITLES.put('metadata', JSON.stringify({
- lastUpdate: new Date().toISOString(),
- totalTitles: titles.length,
- totalAnime: Object.keys(groupedByAid).length,
- source: 'auto'
- }));
-
- return titles.length;
- }
- // ================= 辅助函数 =================
- function jsonResponse(data, status = 200, headers = {}) {
- return new Response(JSON.stringify(data, null, 2), {
- status,
- headers: { ...headers, 'Content-Type': 'application/json; charset=utf-8' }
- });
- }
- function renderLoginUI() {
- return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login</title><style>body{display:flex;justify-content:center;align-items:center;height:100vh;margin:0;font-family:sans-serif;background:#fafafa}form{background:white;padding:2rem;border:1px solid #eaeaea;border-radius:2px;width:280px}input{width:100%;padding:10px;margin-bottom:10px;border:1px solid #ddd;border-radius:2px;box-sizing:border-box;outline:none}input:focus{border-color:#000}button{width:100%;padding:10px;background:#000;color:white;border:none;border-radius:2px;cursor:pointer;font-weight:bold}button:hover{opacity:0.8}.link-area{margin-bottom:15px;text-align:center;font-size:12px;word-break:break-all;}.link-area a{color:#555;}</style></head><body><form action="/login" method="POST"><div class="link-area"><a href="/?q=maruko&limit=50">Example</a></div><input type="password" name="password" placeholder="Password" required autofocus><button type="submit">Enter</button></form></body></html>`;
- }
- async function renderUploadUI(env) {
- // 获取上次更新信息
- let lastUpdateInfo = '';
- try {
- const metadata = await env.ANIME_TITLES.get('metadata');
- if (metadata) {
- const meta = JSON.parse(metadata);
- const updateDate = new Date(meta.lastUpdate);
- lastUpdateInfo = `<div class="info-box">上次更新: ${updateDate.toLocaleString('zh-CN')} | 总条目: ${meta.totalTitles.toLocaleString()}</div>`;
- }
- } catch (e) {
- lastUpdateInfo = '<div class="info-box">暂无更新记录</div>';
- }
- return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>导入数据</title><style>body{font-family:sans-serif;max-width:500px;margin:3rem auto;padding:0 1rem;color:#333}.card{border:1px solid #eaeaea;padding:1.5rem;border-radius:2px;background:#fff;margin-bottom:1rem}h3{margin-top:0;font-size:1.1rem;margin-bottom:1rem}.info-box{background:#f5f5f5;padding:10px;border-radius:2px;font-size:13px;color:#666;margin-bottom:1rem}.upload-area{position:relative;height:120px;border:2px dashed #ddd;border-radius:2px;background:#fafafa;display:flex;align-items:center;justify-content:center;text-align:center;transition:border-color 0.2s;margin-bottom:1rem}.upload-area:hover{border-color:#999}.upload-area input[type=file]{position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;cursor:pointer;z-index:2}.placeholder{pointer-events:none;color:#666;font-size:0.9rem}button{background:#000;color:white;border:none;padding:12px;border-radius:2px;cursor:pointer;width:100%;font-weight:bold;margin-bottom:8px}button:disabled{background:#ccc;cursor:not-allowed}button:hover:not(:disabled){opacity:0.8}#log{margin-top:15px;font-size:13px;color:#555;word-break:break-all}.links{font-size:12px;margin-bottom:1rem;color:#888}.links a{color:#555}.divider{border-top:1px solid #eaeaea;margin:1.5rem 0;position:relative}.divider span{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:white;padding:0 10px;color:#999;font-size:12px}</style></head><body><div class="card"><h3>数据状态</h3>${lastUpdateInfo}</div><div class="card"><h3>一键导入</h3><div class="links">从 AniDB 自动获取最新数据</div><button class="btn-auto" onclick="autoImport()">一键导入最新数据</button><div class="divider"><span>或</span></div><h3>手动上传</h3><div class="links">手动下载:<a href="http://anidb.net/api/anime-titles.dat.gz" target="_blank">anidb.net/api/anime-titles.dat.gz</a></div><div class="upload-area"><input type="file" id="f" accept=".gz,.dat"><div class="placeholder" id="p">点击或拖拽文件 (.gz)</div></div><button onclick="u()">开始上传</button><div id="l"></div></div><script>document.getElementById('f').onchange=e=>{document.getElementById('p').innerHTML='已选择:<br><b>'+(e.target.files[0]?e.target.files[0].name:'...')+'</b>'};async function autoImport(){const l=document.getElementById('l'),btns=document.querySelectorAll('button');btns.forEach(b=>b.disabled=1);l.innerText='正在从 AniDB 获取数据...';l.style.color='#0070f3';try{const r=await fetch('/auto-import',{method:'POST'});const t=await r.text();l.innerText=(r.ok?'成功!':'失败!')+t;l.style.color=r.ok?'#008000':'#d00';if(r.ok)setTimeout(()=>location.reload(),1500);if(r.status===401)location.reload()}catch(e){l.innerText='ERR: '+e;l.style.color='#d00'}finally{btns.forEach(b=>b.disabled=0)}}async function u(){const f=document.getElementById('f'),l=document.getElementById('l'),btns=document.querySelectorAll('button');if(!f.files.length)return alert('无文件');btns.forEach(b=>b.disabled=1);l.innerText='上传中...';l.style.color='#0070f3';try{const r=await fetch('/',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:f.files[0]});const t=await r.text();l.innerText=(r.ok?'成功!':'失败!')+t;l.style.color=r.ok?'#008000':'#d00';if(r.ok)setTimeout(()=>location.reload(),1500);if(r.status===401)location.reload()}catch(e){l.innerText='ERR: '+e;l.style.color='#d00'}finally{btns.forEach(b=>b.disabled=0)}}</script></body></html>`;
- }
复制代码
|
|