// ==UserScript== // @name Fishhawk Redirect // @namespace http://tampermonkey.net/ // @version 0.1 // @description 跳转到轻小说机翻机器人 // @author otokoneko // @license MIT // @match https://kakuyomu.jp/works/* // @match https://ncode.syosetu.com/* // @match https://novel18.syosetu.com/* // @match https://novelup.plus/story/* // @match https://syosetu.org/novel/* // @match https://www.pixiv.net/novel/series/* // @match https://www.pixiv.net/novel/show.php* // @match https://www.alphapolis.co.jp/novel/*/* // @icon https://books.fishhawk.top/favicon.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @downloadURL none // ==/UserScript== (function() { 'use strict'; const defaultConfig = { openInNewWindow: true, redirectChapter: true, baseUrl: 'https://books.fishhawk.top/novel' }; const CONFIG = new Proxy(defaultConfig, { get(target, key) { if (key in target) { return GM_getValue(key, target[key]); } return undefined; }, set(target, key, value) { target[key] = value; GM_setValue(key, value); return true; } }); GM_registerMenuCommand("设置", () => { if (!document.getElementById('fishhawk-settings')) { createSettingsPanel(); } toggleSettings(); }); const mainButton = createButton(); document.body.appendChild(mainButton); let settingsPanel = null; function createButton() { const button = document.createElement('div'); button.role = 'button'; button.ariaLabel = 'Redirect to Fishhawk'; button.tabIndex = 0; button.style.cssText = ` position: fixed; bottom: 5%; left: -30px; z-index: 9999; cursor: pointer; width: 40px; height: 40px; transition: left 0.3s ease, opacity 0.3s; opacity: 0.8; `; const svg = ''; button.innerHTML = svg; // 交互逻辑 let hoverState = false; const edgeThreshold = 50; const updatePosition = (e) => { if (hoverState) return; const nearEdge = e.clientX <= edgeThreshold; button.style.left = nearEdge ? '10px' : '-30px'; }; document.addEventListener('mousemove', (e) => { requestAnimationFrame(() => updatePosition(e)); }); button.addEventListener('mouseenter', () => { hoverState = true; button.style.left = '10px'; button.style.opacity = '1'; }); button.addEventListener('mouseleave', (e) => { hoverState = false; button.style.opacity = '0.8'; if (e.clientX > edgeThreshold) { button.style.left = '-30px'; } }); button.addEventListener('click', performRedirect); button.addEventListener('keydown', (e) => { if (['Enter', ' '].includes(e.key)) performRedirect(); }); return button; } function toggleSettings() { if (!settingsPanel) return; const isVisible = settingsPanel.style.display === 'block'; settingsPanel.style.display = isVisible ? 'none' : 'block'; if (!isVisible) { const clickHandler = (e) => { if (!settingsPanel.contains(e.target)) { settingsPanel.style.display = 'none'; document.removeEventListener('click', clickHandler); } }; setTimeout(() => document.addEventListener('click', clickHandler), 10); } } function createSettingsPanel() { settingsPanel = document.createElement('div'); settingsPanel.id = 'fishhawk-settings'; settingsPanel.style.cssText = ` position: fixed; bottom: 60px; left: 10px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 10000; display: none; `; const title = document.createElement('h3'); title.textContent = 'Fishhawk Redirect 设置'; title.style.marginTop = '0'; const newWindowOption = createCheckbox( '在新窗口打开', CONFIG.openInNewWindow, (checked) => CONFIG.openInNewWindow = checked ); const redirectChapterOption = createCheckbox( '章节页面直接跳转至章节翻译', CONFIG.redirectChapter, (checked) => CONFIG.redirectChapter = checked ); const baseUrlInput = document.createElement('div'); baseUrlInput.innerHTML = ` `; const baseUrlInputElement = baseUrlInput.querySelector('#base-url'); baseUrlInputElement.addEventListener('input', (event) => { CONFIG.baseUrl = event.target.value; }); settingsPanel.append(title, newWindowOption, redirectChapterOption, baseUrlInput); document.body.appendChild(settingsPanel); } function createCheckbox(label, checked, callback) { const container = document.createElement('label'); container.style.display = 'flex'; container.style.alignItems = 'center'; container.style.margin = '8px 0'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = checked; input.addEventListener('change', () => callback(input.checked)); const text = document.createElement('span'); text.textContent = label; text.style.marginLeft = '8px'; container.append(input, text); return container; } function performRedirect() { const newUrl = generateFishhawkUrl(); if (!newUrl) return; if (CONFIG.openInNewWindow) { window.open(newUrl, '_blank'); } else { window.location.href = newUrl; } } const rules = { kakuyomu: { bookRegex: /^https?:\/\/kakuyomu\.jp\/works\/([^\/]+)/, chapterRegex: /^https?:\/\/kakuyomu\.jp\/works\/([^\/]+)\/episodes\/([^\/]+)/, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `kakuyomu/${match[1]}` : null; }, mapChapter: function(url) { const match = url.match(this.chapterRegex); return match ? `kakuyomu/${match[1]}/${match[2]}` : null; } }, syosetu: { bookRegex: /^https?:\/\/(ncode\.syosetu\.com|novel18\.syosetu\.com)\/([^\/]+)/, chapterRegex: /^https?:\/\/(ncode\.syosetu\.com|novel18\.syosetu\.com)\/([^\/]+)\/([^\/]+)/, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `syosetu/${match[2]}` : null; }, mapChapter: function(url) { const match = url.match(this.chapterRegex); return match ? `syosetu/${match[2]}/${match[3]}` : null; } }, novelup: { bookRegex: /^https?:\/\/novelup\.plus\/story\/([^\/]+)/, chapterRegex: /^https?:\/\/novelup\.plus\/story\/([^\/]+)\/([^\/]+)/, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `novelup/${match[1]}` : null; }, mapChapter: function(url) { const match = url.match(this.chapterRegex); return match ? `novelup/${match[1]}/${match[2]}` : null; } }, hameln: { bookRegex: /^https?:\/\/syosetu\.org\/novel\/([^\/]+)\//, chapterRegex: /^https?:\/\/syosetu\.org\/novel\/([^\/]+)\/([^\/]+)\.html/, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `hameln/${match[1]}` : null; }, mapChapter: function(url) { const match = url.match(this.chapterRegex); return match ? `hameln/${match[1]}/${match[2]}` : null; } }, pixivSeries: { bookRegex: /^https?:\/\/www\.pixiv\.net\/novel\/series\/([^\/]+)/, chapterRegex: null, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `pixiv/${match[1]}` : null; }, mapChapter: null }, pixivShow: { bookRegex: /^https?:\/\/www\.pixiv\.net\/novel\/show\.php/, chapterRegex: /^https?:\/\/www\.pixiv\.net\/novel\/show\.php/, mapBook: function(url) { const params = new URLSearchParams(url.split('?')[1]); const id = params.get('id'); return `pixiv/s${id}`; }, mapChapter: function(url) { const params = new URLSearchParams(url.split('?')[1]); const id = params.get('id'); return `pixiv/s${id}/${id}`; } }, alphapolis: { bookRegex: /^https?:\/\/www\.alphapolis\.co\.jp\/novel\/([^\/]+)\/([^\/]+)/, chapterRegex: /^https?:\/\/www\.alphapolis\.co\.jp\/novel\/([^\/]+)\/([^\/]+)\/episode\/([^\/]+)/, mapBook: function(url) { const match = url.match(this.bookRegex); return match ? `alphapolis/${match[1]}-${match[2]}` : null; }, mapChapter: function(url) { const match = url.match(this.chapterRegex); return match ? `alphapolis/${match[1]}-${match[2]}/${match[3]}` : null; } } }; function generateBookUrl(url) { for (const [site, rule] of Object.entries(rules)) { if (rule.bookRegex && url.match(rule.bookRegex)) { const path = rule.mapBook(url); return `${CONFIG.baseUrl}/${path}`; } } return null; } function generateChapterUrl(url) { for (const [site, rule] of Object.entries(rules)) { if (rule.chapterRegex && url.match(rule.chapterRegex)) { const path = rule.mapChapter(url); return `${CONFIG.baseUrl}/${path}`; } } return null; } function generateFishhawkUrl() { const url = window.location.href; if (CONFIG.redirectChapter) { return generateChapterUrl(url) ?? generateBookUrl(url); } else { return generateBookUrl(url); } } })();