Fishhawk Enhancement

让轻小说机翻站真正好用!

// ==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();
    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,
}