您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
让轻小说机翻站真正好用!
当前为
// ==UserScript==; // @name Fishhawk Enhancement // @namespace http://tampermonkey.net/ // @version 2024-01-10 // @description 让轻小说机翻站真正好用! // @author VoltaXTY // @match https://books.fishhawk.top/* // @icon http://fishhawk.top/favicon.ico // @grant none // ==/UserScript== //一些CSS,主要用于在线阅读页的单/双栏切换 const WaitUntilSuccess = async (func, args, options = {}) => { const {isSuccess, interval, count} = { isSuccess: (result) => result, interval: 1000, count: 9999, ...options, }; let counter = 0; while(counter++ < count){ try{ const result = await func(...args); if(isSuccess(result)) return result; else if(interval > 0) await new Promise(res => setTimeout(_ => res(), interval)); } catch(err){ console.error(err); await new Promise(res => setTimeout(_ => res(), interval)); } } }; const Fetch = (...args) => { if(args.length === 1){ return fetch(args[0], { headers: { "authorization": "Bearer " + GetAuth(), } }) } else if(args.length === 2){ return fetch(args[0], { ...args[1], ...(args[1].headers ? {headers: {...args[1].headers, ...{"authorization": "Bearer " + GetAuth()}}} : {headers: {"authorization" : "Bearer " + GetAuth()}}), }) } }; const origin = "https://books.fishhawk.top"; const css = String.raw` #chapter-content{ display: grid; grid-template-columns: 1fr 1fr; gap: 5px; } #chapter-content > *{ grid-column: 1 / 3; height: 0px; } #chapter-content > p.n-p { grid-column: revert; height: revert; margin: 0px; } div.n-flex.always-working > button:nth-child(1){ background-color: #18a058; color: #fff; } `; //插入上面的CSS const InsertStyleSheet = (style) => { const s = new CSSStyleSheet(); s.replaceSync(style); document.adoptedStyleSheets = [...document.adoptedStyleSheets, s]; }; InsertStyleSheet(css); //调试用函数暴露在这个object里面 window.ujsConsole = {}; //创建新Element的便携函数 const HTML = (tagname, attrs, ...children) => { if(attrs === undefined) return document.createTextNode(tagname); const ele = document.createElement(tagname); if(attrs) for(const [key, value] of Object.entries(attrs)){ if(value === null || value === undefined) continue; if(key.charAt(0) === "_"){ const type = key.slice(1); ele.addEventListener(type, value); } else if(key === "eventListener"){ for(const listener of value){ ele.addEventListener(listener.type, listener.listener, listener.options); } } else ele.setAttribute(key, value); } for(const child of children) if(child) ele.append(child); return ele; }; const GetSakuraWorkspace = () => JSON.parse(localStorage.getItem("sakura-workspace")); const SortWorkspace = (workspace) => (workspace.jobs.sort((job1, job2) => (job1.priority ?? 20) - (job2.priority ?? 20)), workspace); const SetSakuraWorkspace = (workspace) => { workspace = SortWorkspace(workspace); const event = new StorageEvent("storage", { key: "sakura-workspace", oldValue: JSON.stringify(GetSakuraWorkspace()), newValue: JSON.stringify(workspace), url: window.location.toString(), storageArea: localStorage, }); localStorage.setItem("sakura-workspace", JSON.stringify(workspace)); window.dispatchEvent(event); }; const InsertNewJob = async (tasks, insertPos = 0) => { const workspace = GetSakuraWorkspace(); if(!(tasks instanceof Array)) tasks = [tasks]; const workspaceTasks = new Set(workspace.jobs.map(job => job.task)); workspace.jobs.splice(insertPos, 0, ...tasks.map(task => { const taskstr = StringifyTask(task); if(workspaceTasks.has(taskstr)){ console.log("已有任务", taskstr); return null; } return { task: taskstr, createdAt: new Date().getTime(), ...task.options, }; }).filter(result => result)); SetSakuraWorkspace(workspace); } const GetAuth = () => isServer ? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2ZjAxYWVlNGU4MTkwM2JiZGUzZTFkYiIsImVtYWlsIjoieGlhdGlhbnl1MjAwMkAxNjMuY29tIiwidXNlcm5hbWUiOiJWb2x0YSIsInJvbGUiOiJub3JtYWwiLCJjcmVhdGVBdCI6MTcyNzAxMTU2NiwiZXhwIjoxNzM1NTYzMTc0fQ.zUrcId4N59bhMh7I_FiduFY0Qva-ABLcmFHTaz3sA0k" :JSON.parse(localStorage.getItem("authInfo"))?.profile?.token; const GetBlackList = () => JSON.parse((localStorage.getItem("blacklist") ?? "[]")); const CheckForUntranslated = async (type = 1, limit = 50) => { const auth = GetAuth(); const pageSize = 48; let page = null, pageCount = 1; for(let pageNumber = 1; pageNumber <= pageCount && pageNumber <= limit; pageNumber += 1){ page = await (await Fetch(`https://books.fishhawk.top/api/wenku?page=${pageNumber - 1}&pageSize=${pageSize}&query=&level=${type}`, { headers: { "Accept": "application/json", "authorization": "Bearer " + auth, } })).json(); pageCount = page.pageNumber; const blackList = new Set(GetBlackList()); const itemWorker = async (item) => { const id = item.id; const detail = await (await Fetch(`https://books.fishhawk.top/api/wenku/${id}`, { headers: { "Accept": "application/json", "authorization": "Bearer " + auth, } })).json(); for(const volume of detail.volumeJp){ if(volume.sakura < volume.total && volume.gpt < volume.total && !blackList.has(id)){ InsertNewJob({ type: "wenku", id: id, bookname: volume.volumeId, options: { description: volume.volumeId, priority: type * 10 + 10 - 10 / pageNumber, }, }, 0); } } } await Promise.allSettled(page.items.map(item => itemWorker(item))); } }; ujsConsole.CheckForUntranslated = CheckForUntranslated; const minimumWebCheckInterval = 7200_000; const CheckUntranslatedPopularWeb = async (limit = 100, dry = false) => { const auth = GetAuth(); const lastChecked = Number(localStorage.getItem("web-checked-timestamp") ?? 0); const currTime = new Date().getTime(); if(currTime - lastChecked < minimumWebCheckInterval) return; const pageSize = 100; let pageCount = 1; const jobs = []; const pointsMult = new Map([ ["kakuyomu", x => x], ["syosetu", x => x * 0.1], ["novelup", x => x * 0.01], ["hameln", x => x * 0.1], ["alphapolis", x => x * 0.001], ]) for(let pageNumber = 0; pageNumber < pageCount && pageNumber < limit; pageNumber += 1){ const pageReq = await Fetch(`https://books.fishhawk.top/api/novel?${new URLSearchParams({ page: pageNumber, pageSize: pageSize, query: "", provider: "kakuyomu,syosetu,novelup,hameln,alphapolis", type: 0, level: 1, translate: 0, sort: 1, })}`,{ headers: { "authorization": "Bearer " + auth, } }); const page = await pageReq.json(); pageCount = page.pageNumber; await Promise.allSettled(page.items.map(async (novel) =>{ if(!(novel.sakura < novel.jp && novel.gpt < novel.jp)) return; const detailReq = await Fetch(`https://books.fishhawk.top/api/novel/${novel.providerId}/${novel.novelId}`); const detail = await detailReq.json(); jobs.push({ points: detail.points, visited: detail.visited, load: novel.jp - novel.sakura, job: { task: `web/${novel.providerId}/${novel.novelId}?level=normal&forceMetadata=false&startIndex=0&endIndex=65536`, description: detail.titleZh ?? detail.titleJp, createdAt: new Date().getTime(), priority: 50, } }) })) } const EvalPriority = (job) => job.points + job.visited * 10; jobs.sort((a, b) => EvalPriority(b) - EvalPriority(a)); if(dry){ console.log(jobs); return; } }; ujsConsole.CheckUntranslatedPopularWeb = CheckUntranslatedPopularWeb; const CheckForumUntranslated = async () => { const pageSize = 20; const forumPageReq = await Fetch(`https://books.fishhawk.top/api/article?${new URLSearchParams({ page: 0, pageSize: 20, category: "General", }).toString()}`); const forumPage = await forumPageReq.json(); for(const post of forumPage){ if(post.pinned) continue; const pid = post.id; //TODO } } //添加「检查未翻译条目」按钮 const AddWenkuCheckerButton = () => { if(window.location.pathname !== "/wenku") return; document.querySelectorAll("h1").forEach((ele) => { if(ele.hasAttribute("modified") || ele.textContent !== "文库小说") return; ele.setAttribute("modified", ""); ele.insertAdjacentElement("afterend", HTML("button", {class: "n-button n-button--default-type n-button--medium-type", "_click": CheckForUntranslated}, "检查未翻译条目") ); }); } const ExtendWorkerItem = () => { if(window.location.pathname !== "/workspace/sakura") return; document.querySelectorAll("button.__button-131ezvy-dfltmd.n-button.n-button--default-type.n-button--tiny-type.n-button--secondary").forEach((ele) => { const parent = ele.parentElement; if(parent.hasAttribute("modified")) return; parent.setAttribute("modified", ""); ele.parentElement.insertAdjacentElement("afterbegin", HTML("button", {class: "__button-131ezvy-dfltmd n-button n-button--default-type n-button--tiny-type n-button--secondary", tabindex: 0, type: "button", _click: () => { if(parent.classList.contains("always-working")) parent.classList.remove("always-working"); else parent.classList.add("always-working"); }}, "始终工作") ); }); } const RunStalledWorker = () => { if(window.location.pathname !== "/workspace/sakura"){ return; } let runningCount = GetRunningCount(); const workspace = GetSakuraWorkspace(); const jobCount = (workspace.jobs?.length) ?? 0; document.querySelectorAll("div.n-flex.always-working").forEach((ele) => { if(runningCount >= jobCount) return; const child = ele.children[1]; if(child.textContent !== " 停止 "){ runningCount += 1; child.click(); } }); if(runningCount >= 5) fetch("http://localhost:17353/end-sharing"); else if(runningCount < 5) fetch("http://localhost:17353/start-sharing"); }; const GetRunningCount = () => { let ret = 0; document.querySelectorAll("div.n-flex.always-working").forEach((ele) => { if(ele.children[1].textContent === " 停止 ") ret += 1; }); return ret; } const RetryFailedTasks = () => { if(window.location.pathname !== "/workspace/sakura") return; const workspace = GetSakuraWorkspace(); const workspaceClone = structuredClone(workspace); if(!workspace.uncompletedJobs) return; for(let i = 0; i < workspace.uncompletedJobs.length;){ const completed = workspace.uncompletedJobs[i]; if(!completed.progress || completed.progress.finished < completed.progress.total){ console.log("发现未完成任务:", completed); workspace.uncompletedJobs.splice(i, 1); workspace.jobs.splice(0, 0, { task: completed.task, description: completed.description, createdAt: new Date().getTime(), priority: 0, ...completed.progress ? {progress: { finished: 0, error: 0, total: completed.progress.total - completed.progress.finished, }} : {}, }); } else i++; } SetSakuraWorkspace(workspace); const event = new StorageEvent("storage", { key: "sakura-workspace", oldValue: JSON.stringify(workspaceClone), newValue: JSON.stringify(workspace), url: window.location.toString(), storageArea: localStorage, }); window.dispatchEvent(event); }; const TaskDetailAPI = (job) => { const task = job.task ?? job; const taskURL = new URL(`${origin}/${task}`); const path = taskURL.pathname.split("/"); if(path[1] === "wenku"){ return queryURL = `${origin}/api/wenku/${path[2]}/translate-v2/sakura/${path[3]}`; } else if(path[1] === "web"){ return queryURL = `${origin}/api/novel/${path[2]}/${path[3]}/translate-v2/sakura`; } }; let RemoveFinishedLock = false; const RemoveFinishedTasks = async () => { if(window.location.pathname !== "/workspace/sakura") return; if(RemoveFinishedLock) return; RemoveFinishedLock = true; try{ const workspace = GetSakuraWorkspace(); const toRemove = new Set(); if(!workspace.jobs) return; const querys = new Set(workspace.jobs.map(TaskDetailAPI).filter(url => url)); const queryResults = [...querys.keys()].map(async url => { try{ const response = await Fetch(url, {headers: {"Accept": "application/json"}}); if(response.status === 404) return [url, 404]; else return [url, await response.json()]; } catch(e){ return [url, "error"]; } }); const queryResultMap = new Map(await Promise.all(queryResults)); workspace.jobs.forEach((job) => { if(job.progress && job.progress.finished >= job.progress.total){ console.log("发现已完成任务:", job); toRemove.add(job.task); return; } const query = TaskDetailAPI(job); const result = queryResultMap.get(query); if(!result){ console.warn("???", job); return; } else if(result === "error") return; else if(result === 404){ console.log("发现不存在任务", job); toRemove.add(job.task); return; } const hasUnfinished = GetUntranslated(job.task, result); if(hasUnfinished) return; console.log("发现已完成任务:", job); toRemove.add(job.task); }); const currWorkspace = GetSakuraWorkspace(); for(let i = 0; i < currWorkspace.jobs.length;){ const job = currWorkspace.jobs[i]; if(toRemove.has(job.task)){ currWorkspace.jobs.splice(i, 1); currWorkspace.uncompletedJobs.splice(-1, 0, { task: job.task, description: job.description, createdAt: job.createdAt, finishedAt: new Date().getTime(), progress: { finished: 999, error: 0, total: 999, }, priority: 0 , }); } else i++; } SetSakuraWorkspace(currWorkspace); }finally{ setTimeout(() => RemoveFinishedLock = false, 5000); } }; //在线小说阅读器里,存在一部分<br>元素非常麻烦,替换为空的<p class="line-break">元素 const ReplaceBrElement = () => { if(!window.location.pathname.startsWith("/novel")) return; console.log("ReplaceBr"); document.querySelectorAll("#chapter-content > br").forEach((br) => { br.replaceWith(HTML("p", {class: "line-break"})); }) }; let _CheckNewWenkuLockLock = false; let _SkipNextCheckNewWenkuCall = false; const CheckNewWenkuChannel = new BroadcastChannel("CheckNewWenku"); CheckNewWenkuChannel.addEventListener("message", (ev) => { if(ev.data === "Checked" && ev.origin === origin){ _SkipNextCheckNewWenkuCall = true; } }) const GetUntranslated = (task, query, getIndex = false) => { const taskURL = new URL(`${origin}/${task}`); const isNormal = taskURL.searchParams.has("level", "normal"); const isRetranslate = !isNormal && !taskURL.searchParams.has("level", "expire"); const startIndex = taskURL.searchParams.get("startIndex") ?? 0; const endIndex = taskURL.searchParams.get("endIndex") ?? 65535; const glossaryId = query.glossaryUuid ?? query.glossaryId; const indexes = !query.toc ? [] : query.toc .filter(chap => chap.chapterId !== undefined) .map((chap, index) => {return {...chap, index: index, ...(chap.glossaryUuid ? {glossaryId: chap.glossaryUuid} : {})}}) .filter((_, index) => index >= startIndex && index < endIndex) .filter(chap => isRetranslate ? true : (isNormal ? chap.glossaryId === undefined : (chap.glossaryId !== glossaryId && chap.glossaryId !== undefined))) if(getIndex) return indexes.map(chap => chap.index); else if(indexes.length > 0) return true; else return false; }; const CheckNewWenku = () => { if(_CheckNewWenkuLockLock || window.location.pathname !== "/workspace/sakura") return; const Worker = async () => { try{ if(_SkipNextCheckNewWenkuCall){ _SkipNextCheckNewWenkuCall = false; } else{ console.log("检查未翻译新文库本"); await CheckForUntranslated(1, 1); await CheckForUntranslated(2, 1); await CheckForUntranslated(3, 1); CheckNewWenkuChannel.postMessage("Checked"); } } finally{ setTimeout(Worker, 5000); } }; setTimeout(Worker, 5000); _CheckNewWenkuLockLock = true; }; const AddJobQueuer = () => { if(!window.location.pathname.startsWith("/novel")) return; const ele = document.querySelector("button.__button-131ezvy-lmmd.n-button.n-button--default-type.n-button--medium-type"); if(!ele || ele.hasAttribute("modified")) return; ele.setAttribute("modified", ""); const 范围 = [...document.querySelectorAll("span.n-text.__text-131ezvy-d3")].find(ele => ele.textContent === "范围"); if(!范围) return; const startInput = 范围.nextElementSibling.children[0].children[0].children[1].children[0].children[0].children[0].children[0]; const endInput = 范围.nextElementSibling.children[0].children[0].children[3].children[0].children[0].children[0].children[0]; ele.insertAdjacentElement("afterend", HTML("button", { class: "__button-131ezvy-lmmd n-button n-button--default-type n-button--medium-type", tabindex: "1", type: "button", _click: async () => { const paths = window.location.pathname.split("/"); const [ , , provider, id] = paths; const title = document.querySelector("h3 a.n-a.__a-131ezvy").textContent; let mode = "normal", metadata = false; document.querySelectorAll(".__tag-131ezvy-ssc,.__tag-131ezvy-wsc").forEach(div => {switch(div.textContent){ case "常规": mode = "normal"; break; case "过期": mode = "expire"; break; case "重翻": mode = "all"; break; case "源站同步": mode = "sync"; break; case "重翻目录": metadata = true; break; }}); const taskObj = { type: "web", provider: provider, id: id, startIndex: Number(startInput.value), endIndex: Number(endInput.value), mode: mode, forceMetadata: metadata, }; const queryURL = `${origin}/api/novel/${provider}/${id}/translate-v2/sakura`; const query = await Fetch(queryURL); const queryResult = await query.json(); InsertNewJob(GetUntranslated(StringifyTask(taskObj), queryResult, true).map(index => ({ ...taskObj, startIndex: index, endIndex: index + 1, options: { description: title, priority: 5 + index / 1000, }, })), 0); }, }, "逐章排队") ); } const ParseTask = (taskstr) => { const [pathname, paramstr] = taskstr.split("?") const paths = pathname.split("/"); const param = new URLSearchParams(paramstr ?? ""); return { ...(paths[0] === "web" ? { type: "web", provider: paths[1], id: paths[2], } : paths[0] === "wenku" ? { type: "wenku", id: paths[1], bookname: paths[2], } : { path: pathname, }), startIndex: Number(param.get("startIndex") ?? 0), endIndex: Number(param.get("endIndex") ?? 65535), mode: pathname.get("level") ?? "normal", forceMetadata: Boolean(pathname.get("forceMetadata") ?? false), }; }; const StringifyTask = (taskobj) => { return `${taskobj.type === "web" ? `web/${taskobj.provider}/${taskobj.id}` : taskobj.type === "wenku" ? `wenku/${taskobj.id}/${taskobj.bookname}` : taskobj.path}?level=${taskobj.mode ?? "normal"}&forceMetadata=${taskobj.forceMetadata ?? false}&startIndex=${taskobj.startIndex ?? 0}&endIndex=${taskobj.endIndex ?? 65535}` } const MergeFinishedTasks = () => { const workspace = GetSakuraWorkspace(); } const AddCustomSearchTag = () => { const target = document.querySelector("div.n-tag"); if(!target || target.hasAttribute("modified")) return; target.setAttribute("modified", ""); const text = "-TS -性転換 -男の娘 -TS"; target.insertAdjacentElement("beforebegin", HTML("div", {class: "n-tag __tag-131ezvy-ssc", style: "cursor: pointer;", modified: "", _click: () => { const input = document.querySelector("input.n-input__input-el"); input.value = input.value + "" + text; }}, HTML("span", {}, text) ) ) } const AdvancedSearch = () => { const loc = window.location.toString(); if(!(loc.includes("query") && loc.includes("novel"))) return; document.querySelectorAll(".n-list-item").forEach(item => { if(item.hasAttribute("modified")) return; item.setAttribute("modified", ""); const link = item.children[0].children[0].children[2]; const [provider, id] = link.textContent.split("."); const main = item.children[0]; main.insertAdjacentElement("afterend", HTML("button", {class: "expand-detail", _click: async (ev) => { const target = ev.target; const detailReq = await WaitUntilSuccess(Fetch, [`https://books.fishhawk.top/api/novel/${provider}/${id}`], {isSuccess: res => res.status === 200}); const detail = await detailReq.json(); target.replaceWith( HTML("div", {class: "detail-container"}, HTML("div", {class: "detail-meta"}, `${detail.points} pt / ${detail.visited} 点击 / ${detail.totalCharacters}`), HTML("div", {class: "detail-description"}, detail.introductionZh ?? detail.introductionJp) ) ) }}, "显示详情") ) }) } //页面变化时立刻调用上面的功能 const OnMutate = async (mutlist, observer) => { observer.disconnect(); //避免无限嵌套 if(isServer) return; ReplaceBrElement(); AdvancedSearch(); AddWenkuCheckerButton(); AddJobQueuer(); CheckNewWenku(); ExtendWorkerItem(); RunStalledWorker(); RetryFailedTasks(); RemoveFinishedTasks(); //MergeFinishedTasks(); //StartCustomTranslator(9); observer.observe(document, {subtree: true, childList: true}); }; new MutationObserver(OnMutate).observe(document, {subtree: true, childList: true}); console.log("hello world"); const Range = (start, end) => { if(end < start) throw new RangeError("end should >= start"); const arr = new Array(end - start); for(let i = start; i < end; i++){ arr[i - start] = i; } return arr; } const FetchForumPosts = async () => { const pageSize = 100; const MakePageLink = pageNum => `/api/article?page=${pageNum}&pageSize=${pageSize}&category=General`; const MakeTopicLink = topicId => `/api/article/${topicId}`; const MakeReplyLink = (pageNum, topicId) => `/api/comment?site=article-${topicId}&pageSize=${pageSize}&page=${pageNum}`; const firstPageReq = await Fetch(MakePageLink(0)); const firstPage = await firstPageReq.json(); const pageCount = firstPage.pageNumber; const topics = [firstPage.items]; topics.push(...(await Promise.all(Range(1, pageCount).map(async index => { const req = await Fetch(MakePageLink(index)); const res = await req.json(); return res.items; }))).flat()); console.log(topics); const replies = (await Promise.all(topics.map(async topic => { try{ const req = await Fetch(MakeTopicLink(topic.id)); const res = await req.json(); const repreq = await Fetch(MakeReplyLink(0, topic.id)); const rep = await repreq.json(); return [res, ...rep.items, ...rep.items.flatMap(item => item.replies)]; }catch(e){ return []; } }))).flat(); window.localStorage.setItem("result", JSON.stringify([topics, replies])); return [topics, replies]; } ujsConsole.GetItem = (key) => JSON.parse(window.localStorage.getItem(key)); ujsConsole.SetItem = (key, value) => window.localStorage.setItem(key, JSON.stringify(value)); ujsConsole = { ...ujsConsole, GetSakuraWorkspace: GetSakuraWorkspace, SetSakuraWorkspace: SetSakuraWorkspace, }