A userscript that improves existing features of NanI and adds new ones.
当前为
// ==UserScript==
// @name Enhanced NanI
// @name:ja なんI+
// @description A userscript that improves existing features of NanI and adds new ones.
// @description:ja なんIの機能を改善したり新たに機能を追加したりするユーザースクリプトです。
// @version 1.0.0
// @namespace 65c9f364-2ddd-44f5-bbc4-716f44f91335
// @author MaxTachibana
// @license MIT
// @match https://openlive2ch.pages.dev/*
// @grant GM.setValue
// @grant GM.getValue
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(async () => {
"use strict";
const h = (tag, attrs, ...children) => {
const elem = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === "style") {
Object.assign(elem.style, attrs[k]);
continue;
}
if (k === "class") {
for (const cls of attrs[k].split(/\s+/)) {
elem.classList.add(cls);
}
continue;
}
if (k.startsWith("on")) {
elem.addEventListener(k.slice(2), attrs[k]);
continue;
}
elem[k] = attrs[k];
}
}
if (children.length > 0) {
elem.append(...children);
}
return elem;
};
// fetchを上書きしてスレッドの情報をキャッシュする
let threads;
(() => {
const interceptFetch = async (...args) => {
const res = await originalFetch(...args);
const url = new URL(res.url);
if (url.hostname.endsWith(".supabase.co") && url.pathname === "/rest/v1/threads") {
const cloned = res.clone();
threads = await cloned.json();
}
return res;
};
const originalFetch = unsafeWindow.fetch;
Object.defineProperty(unsafeWindow, "fetch", {
...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"),
value: interceptFetch
});
})();
const initializers = new Set();
initializers.add(() => {
// ナビUIのはみ出し対策
for (const panel of document.getElementsByClassName("nav-panel")) {
panel.style.boxSizing = "border-box";
}
})
await (async () => {
const PERIOD_1H = 1000 * 60 * 60;
const PERIOD_1D = 1000 * 60 * 60 * 24;
const period = await GM.getValue("period", "all");
const getThreadInfo = (thread) => {
const a = thread.getElementsByTagName("a")[0];
const url = new URL(a.href);
return {
id: Number(url.searchParams.get("thread")),
board: url.searchParams.get("board")
}
}
const getLastPostDate = (info) => {
const thread = threads.find(th => th.board === info.board && th.id === info.id);
const lastPost = thread.posts.at(-1);
return new Date(lastPost.created_at);
};
let select;
const updateDisplay = (thread, now) => {
if (select.value === "all") {
thread.style.display = "";
return;
}
const info = getThreadInfo(thread);
const lastPostDate = getLastPostDate(info);
const period = Number(select.value);
const diff = now - lastPostDate;
thread.style.display = diff >= period ? "none" : "";
};
initializers.add(() => {
const threadBox = document.getElementById("thread-box");
const sortOptions = document.getElementsByClassName("sort-options")[0];
const filterOptions = h("div");
select = h("select", {
value: period,
onchange: (event) => {
const period = event.target.value;
GM.setValue("period", period);
const now = new Date();
for (const thread of threadBox.children) {
updateDisplay(thread, now);
}
}
},
h("option", { value: "all" }, "全て"),
h("option", { value: PERIOD_1H.toString() }, "1時間"),
h("option", { value: PERIOD_1D.toString() }, "1日")
);
const label = h("label", {
style: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem"
}
},
h("strong", undefined, "期間: "),
select
);
// NOTE: なぜかh内での初期値指定が効かないので手動でする
select.value = period;
filterOptions.append(label);
sortOptions.parentElement.insertBefore(filterOptions, sortOptions.nextSibling);
const obs = new MutationObserver(records => {
const now = new Date();
for (const record of records) {
for (const thread of record.addedNodes) {
updateDisplay(thread, now)
}
}
});
obs.observe(threadBox, { childList: true })
})
})();
// 追加設定
await (async () => {
const savedBgcolor = await GM.getValue("bgcolor", "#fff");
initializers.add(() => {
document.body.style.backgroundColor = savedBgcolor;
const settingsPanel = document.getElementById("unhide-panel");
settingsPanel.append(h("hr"));
const extraSettings = h("div");
extraSettings.append(h("label", {
style: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem"
}
},
h("strong", undefined, "背景色: "),
h("input", {
type: "color",
value: savedBgcolor,
oninput: (event) => {
const bgcolor = event.target.value;
document.body.style.backgroundColor = bgcolor;
GM.setValue("bgcolor", bgcolor);
}
})
));
settingsPanel.append(extraSettings);
})
})();
initializers.add(() => {
const IMAGE_URL = /https:\/\/[a-z]+\.supabase\.co\/storage\/v1\/object\/public\/images\/\S+\.\S+|https:\/\/i\.imgur\.com\/\w+\.\w+/g;
const getImageUrls = () => {
const imageUrls = [];
for (const thread of threads) {
for (const post of thread.posts) {
const urls = post.content.match(IMAGE_URL);
if (urls) {
for (const url of urls) {
imageUrls.push({
url,
board: thread.board,
threadId: thread.id
});
}
}
}
}
return imageUrls;
};
const threadBox = document.getElementById("thread-box");
const backTop = document.getElementById("back-top");
const imagesButton = h("button", {
class: "viewAllImages"
}, "画像一覧");
backTop.parentNode.insertBefore(imagesButton.cloneNode(true), backTop);
threadBox.parentNode.insertBefore(imagesButton, threadBox);
for (const button of document.getElementsByClassName("viewAllImages")) {
button.addEventListener("click", () => {
if (document.getElementById("allImages")) return;
const imageUrls = getImageUrls();
const gallery = h("div", {
style: {
flex: 1,
overflow: "auto",
display: "grid",
gap: "0.25rem",
gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr"
}
});
for (const [i, url] of [...imageUrls.entries()].reverse()) {
gallery.append(h("div", {
style: {
display: "flex",
gap: "0.1rem",
flexDirection: "column",
alignItems: "flex-start"
}
},
h("span", undefined, `${i + 1}.`),
h("a", {
href: url.url,
target: "_blank",
rel: "noopener noreferrer"
}, h("img", {
src: url.url,
style: {
display: "block",
width: "100%",
height: "auto"
}
})),
h("a", { href: `?board=${url.board}&thread=${url.threadId}` }, "スレに移動")
))
}
const container = h("div", {
id: "allImages",
style: {
boxSizing: "border-box",
position: "fixed",
top: "calc(50svh - 40%)",
left: "calc(50svw - 40%)",
zIndex: "99999",
width: "80%",
height: "80%",
backgroundColor: "inherit",
border: "1px solid #000",
padding: "0.5rem",
display: "flex",
gap: "0.5rem",
flexDirection: "column"
}
},
h("button", {
onclick: () => {
controller.abort();
container.remove();
}
}, "閉じる"),
h("strong", undefined, `画像: ${imageUrls.length}件`),
gallery
);
const controller = new AbortController();
document.body.append(container);
// appendした後にリッスンしているにも関わらずbuttonのclickイベントが伝播してしまうので遅延
setTimeout(() => {
document.body.addEventListener("click", event => {
if (container.contains(event.target)) return;
controller.abort();
container.remove();
}, {
signal: controller.signal
})
})
})
}
});
document.addEventListener("DOMContentLoaded", () => {
for (const initializer of initializers) {
initializer();
}
});
})();