Enhanced NanI

A userscript that improves existing features of NanI and adds new ones.

目前為 2025-09-11 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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.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";

  // src/h.ts
  var h = (tag, attrs, ...children) => {
    const elem = document.createElement(tag);
    if (attrs) {
      const { style, class: classes, ...extra } = attrs;
      Object.assign(elem.style, style);
      if (classes) {
        for (const cls of classes) {
          elem.classList.add(cls);
        }
      }
      for (const prop in extra) {
        const val = extra[prop];
        if (prop.startsWith("on")) {
          elem.addEventListener(prop.slice(2), val);
          continue;
        }
        elem[prop] = val;
      }
    }
    elem.append(...children);
    return elem;
  };
  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 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);
        },
      });
      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);
        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 = input.value
          .split("\n")
          .map((l) => l.trim())
          .filter((l) => !!l);
        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 = input.value
          .split("\n")
          .map((l) => l.trim())
          .filter((l) => !!l);
        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);
          },
        },
        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);
        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);
        if (url.hostname.endsWith(".supabase.co")) {
          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());
          }
        }
        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);
    }
  });
})();