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.1.2
// @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";
// src/h.ts
var h = (tag, attrs, ...children) => {
const el = document.createElement(tag);
if (attrs) {
const { style, class: classes, ...extra } = attrs;
Object.assign(el.style, style);
if (classes) {
for (const cls of classes) {
el.classList.add(cls);
}
}
for (const prop in extra) {
const val = extra[prop];
if (prop.startsWith("on")) {
el.addEventListener(prop.slice(2), val);
continue;
}
el[prop] = val;
}
}
el.append(...children);
return el;
};
function q(query, parent) {
if (query[0] === "#") {
return document.getElementById(query.slice(1));
}
return parent?.getElementsByClassName(query.slice(1));
}
// src/nani.ts
var getThreadInfo = (threadEl) => {
let params;
if (threadEl) {
const a = threadEl.getElementsByTagName("a")[0];
params = new URL(a.href).searchParams;
} else {
params = new URLSearchParams(location.search);
}
const id = params.get("thread");
const board = params.get("board");
if (!id || !board) {
return;
}
return {
id: Number.parseInt(id, 10),
board,
};
};
// src/features/all-image.ts
var IMAGE_URL =
/https:\/\/[a-z]+\.supabase\.co\/storage\/v1\/object\/public\/images\/\S+\.\S+|https:\/\/i\.imgur\.com\/\w+\.\w+/g;
var allImage = () => ({
documentEnd: (ctx2) => {
const button = h("button", void 0, "⛰️画像一覧");
const button2 = button.cloneNode(true);
const handleClick = () => {
const gallery = h("div", {
style: {
display: "grid",
gap: "0.25rem",
gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr",
},
});
const images = [];
const info = getThreadInfo();
if (info) {
const posts = ctx2.threadCache.posts.get(info.id);
if (!posts) return;
for (const post of posts) {
const urls = post.content.match(IMAGE_URL);
if (!urls) continue;
for (const url of urls) {
images.push({
board: info.board,
thread: info.id.toString(),
timestamp: new Date(post.created_at).getTime(),
url,
});
}
}
} else if (ctx2.threadCache.threads) {
for (const thread of ctx2.threadCache.threads) {
for (const post of thread.posts) {
const urls = post.content.match(IMAGE_URL);
if (!urls) continue;
for (const url of urls) {
images.push({
board: thread.board,
thread: thread.id.toString(),
timestamp: new Date(post.created_at).getTime(),
url,
});
}
}
}
}
const labelPrefix = info ? `スレッド内の画像` : "画像";
const label = h("strong", void 0, `${labelPrefix}: ${images.length}件`);
for (const [i, image] of images
.sort((a, b) => b.timestamp - a.timestamp)
.entries()) {
const children = [
h("span", void 0, `${images.length - i}.`),
h(
"a",
{
href: image.url,
target: "_blank",
rel: "noopener noreferrer",
},
h("img", {
src: image.url,
style: {
display: "block",
width: "100%",
height: "auto",
},
}),
),
];
if (!info) {
children.push(
h(
"a",
{ href: `?board=${image.board}&thread=${image.thread}` },
"スレに移動",
),
);
}
gallery.append(
h(
"div",
{
style: {
display: "flex",
gap: "0.1rem",
flexDirection: "column",
alignItems: "flex-start",
},
},
...children,
),
);
}
ctx2.dialog.content.append(label);
ctx2.dialog.content.append(gallery);
ctx2.dialog.open(() => {
label.remove();
gallery.remove();
});
};
button.addEventListener("click", handleClick);
button2.addEventListener("click", handleClick);
ctx2.elems.appendNav.append(button);
ctx2.elems.posts.parentNode?.insertBefore(button2, ctx2.elems.posts);
},
});
// src/features/bgcolor.ts
var savedBgcolor = await GM.getValue("bgcolor", "#ffffff");
var bgcolor = () => ({
documentEnd: (ctx2) => {
document.body.style.backgroundColor = savedBgcolor;
const input = h("input", {
type: "color",
value: savedBgcolor,
oninput: async (ev) => {
if (!ev.target) return;
if (!(ev.target instanceof HTMLInputElement)) return;
const bgcolor2 = ev.target.value;
document.body.style.backgroundColor = bgcolor2;
await GM.setValue("bgcolor", bgcolor2);
},
});
const label = h(
"label",
{
style: {
display: "inline-flex",
gap: "0.5rem",
alignItems: "center",
},
},
h("strong", void 0, "背景色: "),
input,
);
ctx2.elems.settingsPanel.append(h("hr"));
ctx2.elems.settingsPanel.append(label);
},
});
// src/features/defined-elements.ts
var definedElements = () => ({
documentEnd: (ctx2) => {
const settingsPanel = q("#unhide-panel");
const threadBox = q("#thread-box");
const posts = q("#posts");
if (!settingsPanel || !threadBox || !posts) {
throw new TypeError("element not found");
}
const appendNav = h("div", {
style: {
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "8px",
},
});
threadBox.parentNode?.insertBefore(appendNav, threadBox);
ctx2.elems = {
settingsPanel,
threadBox,
posts,
appendNav,
};
},
});
// src/features/fix-sizing.ts
var fixSizing = () => ({
documentEnd: () => {
const s = h(
"style",
void 0,
`
.nav-panel {
box-sizing: border-box;
}
`,
);
document.head.append(s);
},
});
// src/features/history.ts
var savedHistory = JSON.parse(await GM.getValue("history", "{}"));
var history = () => ({
documentStart: (ctx2) => {
ctx2.history = savedHistory;
const origReplaceState = unsafeWindow.history.replaceState;
const interceptReplaceState = function (...args) {
if (args[2]) {
try {
const params =
typeof args[2] === "string"
? new URLSearchParams(args[2])
: args[2].searchParams;
const board = params.get("board");
const threadParam = params.get("thread");
const threadParamNumber = Number(threadParam);
const thread =
threadParam &&
ctx2.threadCache.threads?.find(
(th) => th.id === threadParamNumber,
);
if (board && threadParam && thread) {
const now = /* @__PURE__ */ new Date();
ctx2.history[`${board}_${threadParam}`] = {
timestamp: now.getTime(),
title: thread.title,
};
GM.setValue("history", JSON.stringify(ctx2.history));
}
} catch {}
}
return origReplaceState.call(this, ...args);
};
Object.defineProperty(unsafeWindow.history, "replaceState", {
...Object.getOwnPropertyDescriptor(
unsafeWindow.history,
"replaceState",
),
value: interceptReplaceState,
});
},
documentEnd: (ctx2) => {
const s = h(
"style",
void 0,
`
.historyList li:nth-of-type(odd) > div {
background-color: rgba(0, 0, 0, 0.1);
}
`,
);
document.head.append(s);
const threadTitleEl = q("#thread-title");
if (threadTitleEl) {
const obs = new MutationObserver((recs) => {
for (const rec of recs) {
for (const node of rec.addedNodes) {
const title = node.textContent;
const params = new URLSearchParams(location.search);
const board = params.get("board");
const thread = params.get("thread");
if (board && thread && title) {
const now = /* @__PURE__ */ new Date();
ctx2.history[`${board}_${thread}`] = {
timestamp: now.getTime(),
title,
};
GM.setValue("history", JSON.stringify(ctx2.history));
}
return;
}
}
});
obs.observe(threadTitleEl, {
childList: true,
});
}
const button = h(
"button",
{
onclick: () => {
if (ctx2.dialog.isOpen()) return;
const ul = h("ul", {
class: ["historyList"],
});
for (const [info, data] of Object.entries(ctx2.history).sort(
(a, b) => b[1].timestamp - a[1].timestamp,
)) {
const [board, thread] = info.split("_");
const li = h(
"li",
void 0,
h(
"div",
{
style: {
display: "flex",
gap: "0.5rem",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 1rem",
},
},
h(
"div",
{
style: {
display: "inline-flex",
flexDirection: "column",
alignItems: "flex-start",
},
},
h(
"a",
{ href: `?board=${board}&thread=${thread}` },
data.title,
),
h(
"span",
{
style: {
color: "#7c7c7c",
},
},
`${new Date(data.timestamp).toLocaleString()}にアクセス`,
),
),
h(
"button",
{
class: ["xbtn"],
style: { alignSelf: "center" },
onclick: () => {
delete ctx2.history[info];
GM.setValue("history", JSON.stringify(ctx2.history));
li.remove();
},
},
"x",
),
),
);
ul.append(li);
}
ctx2.dialog.content.append(ul);
ctx2.dialog.open(() => {
ul.remove();
});
},
},
"👀履歴",
);
ctx2.elems.appendNav.append(button);
},
});
// src/features/ngword.ts
var savedNgwords = await GM.getValue("ngwords", "");
var splitNgwords = (ngwords) =>
ngwords
.split("\n")
.map((l) => l.trim())
.filter((l) => !!l);
var ngword = () => ({
documentEnd: (ctx2) => {
const s = h(
"style",
void 0,
`
.ngwordHidden {
display: none;
}
`,
);
document.head.append(s);
const input = h("textarea", {
value: savedNgwords,
placeholder: "【画像】\n過疎",
onchange: async () => {
await GM.setValue("ngwords", input.value);
},
oninput: () => {
const ngwords = splitNgwords(input.value);
for (const threadEl of ctx2.elems.threadBox.children) {
updateDisplay(threadEl, ngwords);
}
},
});
const label = h(
"label",
{
style: {
display: "inline-flex",
flexDirection: "column",
gap: "0.5rem",
},
},
h("strong", void 0, "NGワード (改行区切り): "),
input,
);
ctx2.elems.settingsPanel.append(h("hr"), label);
const updateDisplay = (threadElem, ngwords) => {
const info = getThreadInfo(threadElem);
if (!info) return;
const thread = ctx2.threadCache.threads?.find(
(th) => th.board === info.board && th.id === info.id,
);
if (!thread) return;
if (ngwords.some((ngword2) => thread.title.includes(ngword2))) {
threadElem.classList.add("ngwordHidden");
return;
}
const firstPost = thread.posts[0];
if (!firstPost) return;
if (ngwords.some((ngword2) => firstPost.content.includes(ngword2))) {
threadElem.classList.add("ngwordHidden");
return;
}
threadElem.classList.remove("ngwordHidden");
};
const threadObs = new MutationObserver((recs) => {
const ngwords = splitNgwords(input.value);
if (ngwords.length <= 0) return;
for (const rec of recs) {
for (const threadElem of rec.addedNodes) {
if (!(threadElem instanceof HTMLElement)) continue;
updateDisplay(threadElem, ngwords);
}
}
});
threadObs.observe(ctx2.elems.threadBox, {
childList: true,
});
const postObs = new MutationObserver((recs) => {
const ngwords = splitNgwords(input.value);
if (ngwords.length <= 0) return;
for (const rec of recs) {
for (const postEl of rec.addedNodes) {
if (!(postEl instanceof HTMLElement)) continue;
const nameEl = postEl.querySelector('[class^="name-"]');
if (!nameEl) continue;
if (!(nameEl instanceof HTMLElement)) continue;
postEl.classList.remove("ngwordHidden");
if (ngwords.some((ngword2) => nameEl.innerText.includes(ngword2))) {
postEl.classList.add("ngwordHidden");
continue;
}
const bodyEl = postEl.getElementsByClassName("post-body")[0];
if (!bodyEl) continue;
if (!(bodyEl instanceof HTMLElement)) continue;
if (ngwords.some((ngword2) => bodyEl.innerText.includes(ngword2))) {
postEl.classList.add("ngwordHidden");
}
}
}
});
postObs.observe(ctx2.elems.posts, {
childList: true,
});
},
});
// src/features/period.ts
var PERIOD_1H = 1e3 * 60 * 60;
var PERIOD_1D = 1e3 * 60 * 60 * 24;
var savedPeriod = await GM.getValue("period", "all");
var period = () => ({
documentEnd: (ctx2) => {
const s = h(
"style",
void 0,
`
.periodHidden {
display: none;
}
`,
);
document.head.append(s);
const select = h(
"select",
{
value: savedPeriod,
onchange: async (ev) => {
if (!(ev.target instanceof HTMLSelectElement)) return;
const period2 = ev.target.value;
await GM.setValue("period", period2);
const now = /* @__PURE__ */ new Date();
for (const threadEl of ctx2.elems.threadBox.children) {
updateDisplay(threadEl, 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", void 0, "期間: "),
select,
);
select.value = savedPeriod;
ctx2.elems.threadBox.parentNode?.insertBefore(
label,
ctx2.elems.threadBox,
);
const updateDisplay = (threadElem, now) => {
if (select.value === "all") {
threadElem.classList.remove("periodHidden");
return;
}
const info = getThreadInfo(threadElem);
if (!info) return;
const thread = ctx2.threadCache.threads?.find(
(th) => th.board === info.board && th.id === info.id,
);
if (!thread) return;
const lastPost = thread.posts.at(-1);
if (!lastPost) return;
const lastPostDate = new Date(lastPost.created_at);
const period2 = Number.parseInt(select.value, 10);
const diff = now.getTime() - lastPostDate.getTime();
if (diff >= period2) {
threadElem.classList.add("periodHidden");
} else {
threadElem.classList.remove("periodHidden");
}
};
const obs = new MutationObserver((records) => {
const now = /* @__PURE__ */ new Date();
for (const record of records) {
for (const threadElem of record.addedNodes) {
if (!(threadElem instanceof HTMLElement)) continue;
updateDisplay(threadElem, now);
}
}
});
obs.observe(ctx2.elems.threadBox, {
childList: true,
});
},
});
// src/features/thread-cache.ts
var threadCache = () => ({
documentStart: (ctx2) => {
ctx2.threadCache = {
threads: void 0,
posts: /* @__PURE__ */ new Map(),
titles: /* @__PURE__ */ new Map(),
};
const origFetch = unsafeWindow.fetch;
const interceptFetch = async (...args) => {
const res = await origFetch(...args);
const url = new URL(res.url);
try {
if (
url.hostname.endsWith(".supabase.co") &&
args[1]?.method === "GET"
) {
if (url.pathname === "/rest/v1/threads") {
const id = url.searchParams.get("id")?.slice(3);
if (id) {
const json = await res.clone().json();
ctx2.threadCache.titles.set(id, json.title);
} else {
ctx2.threadCache.threads = await res.clone().json();
}
} else if (url.pathname === "/rest/v1/posts") {
const threadIdQuery = url.searchParams.get("thread_id");
const threadId = Number.parseInt(threadIdQuery.slice(3), 10);
ctx2.threadCache.posts.set(threadId, await res.clone().json());
}
}
} catch (err) {
console.warn(err);
}
return res;
};
Object.defineProperty(unsafeWindow, "fetch", {
...Object.getOwnPropertyDescriptor(unsafeWindow, "fetch"),
value: interceptFetch,
});
},
});
// src/features/dialog.ts
var dialog = () => ({
documentEnd: (ctx2) => {
const content = h("div", {
style: {
flex: "1",
overflow: "auto",
},
});
const dialog2 = h(
"div",
{
style: {
display: "none",
gap: "0.5rem",
flexDirection: "column",
zIndex: "99999",
border: "1px solid #000000",
backgroundColor: "inherit",
width: "80%",
height: "80%",
position: "fixed",
top: "calc(50svh - 40%)",
left: "calc(50svw - 40%)",
boxSizing: "border-box",
borderRadius: "5px",
padding: "0.5rem",
},
},
h(
"button",
{
onclick: () => {
ctx2.dialog.close();
},
},
"閉じる",
),
content,
);
let controller;
const destructors = /* @__PURE__ */ new Set();
ctx2.dialog = {
isOpen: () => dialog2.style.display !== "none",
open: (destructor) => {
dialog2.style.display = "flex";
requestAnimationFrame(() => {
controller = new AbortController();
document.body.addEventListener(
"click",
(ev) => {
if (!ev.composedPath().includes(dialog2)) {
ctx2.dialog.close();
}
},
{
signal: controller.signal,
},
);
});
if (destructor) {
destructors.add(destructor);
}
},
close: () => {
dialog2.style.display = "none";
controller?.abort();
for (const f of destructors) {
f();
destructors.delete(f);
}
},
content,
};
document.body.append(dialog2);
},
});
// src/index.ts
var features = [
definedElements(),
threadCache(),
fixSizing(),
dialog(),
bgcolor(),
period(),
allImage(),
ngword(),
history(),
];
var ctx = {};
for (const f of features) {
await f.documentStart?.(ctx);
}
document.addEventListener("DOMContentLoaded", async () => {
for (const f of features) {
await f.documentEnd?.(ctx);
}
});
})();