// ==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
// ==/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 = '<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet" width="100%" height="100%"><g transform="translate(0,512) scale(0.1,-0.1)" stroke="none" fill="rgb(24, 160, 88)"><path d="M1438 4566 c-65 -34 -120 -64 -123 -67 -2 -3 96 -199 219 -435 l224 -429 -614 -3 -614 -2 0 -400 0 -400 -185 0 -185 0 0 -750 0 -750 185 0 185 0 0 -400 0 -400 2030 0 2030 0 0 400 0 400 185 0 185 0 0 750 0 750 -185 0 -185 0 -2 398 -3 397 -614 3 c-534 2 -612 4 -607 17 3 8 104 204 225 435 l219 420 -124 64 -124 63 -19 -31 c-10 -17 -128 -241 -261 -499 l-243 -467 -481 2 -481 3 -245 470 c-135 259 -252 482 -260 497 l-15 27 -117 -63z m349 -1669 c203 -106 213 -388 18 -498 -187 -105 -416 29 -414 241 1 118 50 199 150 249 59 30 70 32 139 29 43 -3 89 -12 107 -21z m1789 -5 c98 -50 162 -169 151 -279 -23 -224 -292 -330 -461 -182 -62 55 -88 108 -94 192 -8 119 44 213 150 267 58 29 69 31 138 27 50 -2 89 -11 116 -25z m-126 -1372 l0 -280 -890 0 -890 0 0 280 0 280 890 0 890 0 0 -280z"></path></g></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 = `
<label for="base-url">跳转至</label>
<input type="text" id="base-url" value="${CONFIG.baseUrl}" />
`;
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);
}
}
})();