Enhanced NanI

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

当前为 2025-09-12 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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.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);
    }
  });
})();