☰

Booru Auto Search Tags

Add persistent sidebar toggles to auto-append score sorting and video exclusion tags on Rule34, Danbooru, and Gelbooru.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Booru Auto Search Tags
// @namespace    https://booru.local/
// @version      1.0.7
// @description  Add persistent sidebar toggles to auto-append score sorting and video exclusion tags on Rule34, Danbooru, and Gelbooru.
// @author       icynic
// @match        https://rule34.xxx/*
// @match        https://www.rule34.xxx/*
// @match        https://danbooru.donmai.us/*
// @match        https://gelbooru.com/*
// @match        https://www.gelbooru.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const PANEL_ID = "tm-booru-auto-tags-panel";
  const SETTINGS_KEY = "tmBooruAutoSearchTagsV1";
  const ALL_SORT_TERMS = ["sort:score:desc", "sort:score", "order:score", "order:favcount"];
  const ALL_VIDEO_TERMS = ["-webm", "-mp4", "-video", "-filetype:webm", "-filetype:mp4"];
  const EMPTY_SEARCH_TERMS = ["all"];

  const SITE_CONFIGS = [
    {
      id: "rule34",
      label: "Rule34",
      hosts: ["rule34.xxx", "www.rule34.xxx"],
      sortTerm: "sort:score:desc",
      videoTerms: ["-video"],
      sidebarSelectors: ["#tag-sidebar", "#sidebar", ".sidebar", "aside"],
    },
    {
      id: "danbooru",
      label: "Danbooru",
      hosts: ["danbooru.donmai.us"],
      sortLabel: "Sort by favorites",
      sortTerm: "order:favcount",
      videoTerms: ["-video"],
      sidebarSelectors: ["#sidebar", "#tag-sidebar", "aside", ".sidebar"],
    },
    {
      id: "gelbooru",
      label: "Gelbooru",
      hosts: ["gelbooru.com", "www.gelbooru.com"],
      sortTerm: "sort:score:desc",
      videoTerms: ["-video"],
      sidebarSelectors: ["#tag-sidebar", "#sidebar", ".sidebar", "aside"],
    },
  ];

  const site = SITE_CONFIGS.find((config) => config.hosts.includes(location.hostname));
  if (!site) {
    return;
  }

  const state = {
    settings: loadSettings(),
    lastHref: location.href,
  };

  init();

  function init() {
    injectStyles();
    renderPanelWhenReady();
    bindSearchHandlers();
    observeUrlChanges();
  }

  function loadSettings() {
    const defaults = {};
    for (const config of SITE_CONFIGS) {
      defaults[config.id] = {
        sortByScore: false,
        hideVideos: false,
      };
    }

    try {
      const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
      return normalizeSettings({ ...defaults, ...stored });
    } catch {
      return defaults;
    }
  }

  function normalizeSettings(settings) {
    const normalized = {};
    for (const config of SITE_CONFIGS) {
      normalized[config.id] = {
        sortByScore: Boolean(settings && settings[config.id] && settings[config.id].sortByScore),
        hideVideos: Boolean(settings && settings[config.id] && settings[config.id].hideVideos),
      };
    }
    return normalized;
  }

  function saveSettings() {
    try {
      localStorage.setItem(SETTINGS_KEY, JSON.stringify(state.settings));
    } catch {
      // The toggles still work for the current page if storage is unavailable.
    }
  }

  function renderPanelWhenReady() {
    const existing = document.getElementById(PANEL_ID);
    if (existing) {
      existing.remove();
    }

    if (!isPanelPageUrl(new URL(location.href))) {
      return;
    }

    const sidebar = findSidebar();
    if (!sidebar) {
      window.setTimeout(renderPanelWhenReady, 300);
      return;
    }

    const panel = document.createElement("section");
    panel.id = PANEL_ID;
    panel.innerHTML = [
      renderToggle("sortByScore", site.sortLabel || "Sort by score", site.sortTerm),
      renderToggle("hideVideos", "Hide videos", site.videoTerms.join(" ")),
    ].join("");

    insertPanel(sidebar, panel);

    panel.addEventListener("change", (event) => {
      const checkbox = event.target;
      if (!(checkbox instanceof HTMLInputElement) || !checkbox.dataset.setting) {
        return;
      }

      state.settings[site.id][checkbox.dataset.setting] = checkbox.checked;
      saveSettings();
    });
  }

  function renderToggle(setting, label, hint) {
    const checked = state.settings[site.id][setting] ? " checked" : "";
    return [
      '<label class="tm-bat-toggle">',
      `  <input type="checkbox" data-setting="${escapeHtml(setting)}"${checked}>`,
      '  <span class="tm-bat-switch" aria-hidden="true"></span>',
      '  <span class="tm-bat-copy">',
      `    <span class="tm-bat-label">${escapeHtml(label)}</span>`,
      `    <span class="tm-bat-hint">${escapeHtml(hint)}</span>`,
      "  </span>",
      "</label>",
    ].join("");
  }

  function insertPanel(sidebar, panel) {
    if (site.id === "danbooru") {
      const searchInput = getSearchInput();
      const searchContainer = searchInput && (
        searchInput.closest("form") ||
        searchInput.closest("section") ||
        searchInput.closest("div")
      );

      if (searchContainer && sidebar.contains(searchContainer)) {
        searchContainer.insertAdjacentElement("afterend", panel);
        return;
      }
    }

    const insertionTarget = sidebar.firstElementChild || sidebar;
    if (insertionTarget === sidebar) {
      sidebar.insertBefore(panel, sidebar.firstChild);
    } else {
      sidebar.insertBefore(panel, insertionTarget);
    }
  }

  function findSidebar() {
    for (const selector of site.sidebarSelectors) {
      const element = document.querySelector(selector);
      if (element) {
        return element;
      }
    }

    const likelySidebar = Array.from(document.querySelectorAll("nav, aside, div, section")).find((element) => {
      const box = element.getBoundingClientRect();
      return box.width >= 120 && box.width <= 360 && box.left <= 40 && box.height >= 250;
    });

    return likelySidebar || document.body;
  }

  function isPanelPageUrl(url) {
    if (site.id === "danbooru") {
      return url.pathname === "/" || /^\/posts(?:\/\d+)?\/?$/.test(url.pathname);
    }

    return url.searchParams.get("page") === "post";
  }

  function isTagSearchUrl(url) {
    if (!site.hosts.includes(url.hostname) || !url.searchParams.has("tags")) {
      return false;
    }

    if (site.id === "danbooru") {
      return url.pathname === "/posts" || url.pathname === "/posts/";
    }

    return url.searchParams.get("page") === "post" && url.searchParams.get("s") !== "view";
  }

  function buildManagedTags(tags) {
    const settings = state.settings[site.id];
    const originalTerms = splitTags(tags);
    const suppressSort = shouldSuppressSortTerm(originalTerms);
    const terms = originalTerms.filter((term) => {
      if (EMPTY_SEARCH_TERMS.includes(term.toLowerCase())) {
        return false;
      }

      if (ALL_SORT_TERMS.includes(term)) {
        return false;
      }

      if (ALL_VIDEO_TERMS.includes(term)) {
        return false;
      }

      return true;
    });
    const managedTerms = [];

    if (settings.sortByScore && !suppressSort) {
      managedTerms.push(site.sortTerm);
    }

    if (settings.hideVideos) {
      managedTerms.push(...site.videoTerms);
    }

    return [...managedTerms, ...terms].join(" ").trim();
  }

  function shouldSuppressSortTerm(terms) {
    return site.id === "danbooru" && terms.some((term) => /^ordfav:/i.test(term));
  }

  function splitTags(tags) {
    return String(tags || "")
      .split(/\s+/)
      .map((term) => term.trim())
      .filter(Boolean);
  }

  function getSearchInput() {
    return (
      document.querySelector('input[name="tags"]') ||
      document.querySelector('textarea[name="tags"]') ||
      document.querySelector('input[type="search"]') ||
      document.querySelector('input#tags')
    );
  }

  function bindSearchHandlers() {
    const prepareClickedTagLink = (event) => {
      const target = event.target;
      if (!(target instanceof Element)) {
        return;
      }

      const link = target.closest("a[href]");
      if (link instanceof HTMLAnchorElement) {
        applySettingsToTagSearchLink(link);
      }
    };

    document.addEventListener("pointerdown", prepareClickedTagLink, true);
    document.addEventListener("auxclick", prepareClickedTagLink, true);

    document.addEventListener(
      "submit",
      (event) => {
        const form = event.target;
        if (form instanceof HTMLFormElement) {
          applySettingsToSearchInput(findSearchInputIn(form));
        }
      },
      true
    );

    document.addEventListener(
      "click",
      (event) => {
        const target = event.target;
        if (!(target instanceof Element)) {
          return;
        }

        prepareClickedTagLink(event);

        const submitter = target.closest('button, input[type="submit"], input[type="image"]');
        if (submitter) {
          const form = submitter.form instanceof HTMLFormElement ? submitter.form : null;
          applySettingsToSearchInput(form ? findSearchInputIn(form) : getSearchInput());
        }
      },
      true
    );

    document.addEventListener(
      "keydown",
      (event) => {
        if (event.key !== "Enter") {
          return;
        }

        const target = event.target;
        if (isSearchInput(target)) {
          applySettingsToSearchInput(target);
        }
      },
      true
    );
  }

  function findSearchInputIn(root) {
    return (
      root.querySelector('input[name="tags"]') ||
      root.querySelector('textarea[name="tags"]') ||
      root.querySelector('input[type="search"]') ||
      root.querySelector('input#tags')
    );
  }

  function isSearchInput(target) {
    return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
      ? target.matches('[name="tags"], input[type="search"], input#tags, textarea[name="tags"]')
      : false;
  }

  function applySettingsToSearchInput(input) {
    const targetInput = input || getSearchInput();
    if (targetInput && typeof targetInput.value === "string") {
      targetInput.value = buildManagedTags(targetInput.value);
    }
  }

  function applySettingsToTagSearchLink(link) {
    const url = new URL(link.href, location.href);
    if (!isTagSearchUrl(url)) {
      return;
    }

    url.searchParams.set("tags", buildManagedTags(url.searchParams.get("tags") || ""));
    link.href = url.toString();
  }

  function observeUrlChanges() {
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function (...args) {
      const result = originalPushState.apply(this, args);
      onPossibleUrlChange();
      return result;
    };

    history.replaceState = function (...args) {
      const result = originalReplaceState.apply(this, args);
      onPossibleUrlChange();
      return result;
    };

    window.addEventListener("popstate", onPossibleUrlChange);
    window.setInterval(onPossibleUrlChange, 500);
  }

  function onPossibleUrlChange() {
    if (state.lastHref === location.href) {
      return;
    }

    state.lastHref = location.href;
    window.setTimeout(() => {
      renderPanelWhenReady();
    }, 50);
  }

  function injectStyles() {
    if (document.getElementById(`${PANEL_ID}-style`)) {
      return;
    }

    const style = document.createElement("style");
    style.id = `${PANEL_ID}-style`;
    style.textContent = `
      #${PANEL_ID} {
        box-sizing: border-box;
        width: min(100%, 260px);
        margin: 0 0 12px;
        padding: 10px;
        border: 1px solid rgba(128, 128, 128, 0.35);
        border-radius: 6px;
        background: transparent;
        color: inherit;
        font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      #${PANEL_ID} * {
        box-sizing: border-box;
      }

      #${PANEL_ID} .tm-bat-hint {
        color: inherit;
        font-size: 12px;
        opacity: 0.72;
      }

      #${PANEL_ID} .tm-bat-toggle {
        display: grid;
        grid-template-columns: 34px minmax(0, 1fr);
        gap: 8px;
        align-items: center;
        margin-top: 10px;
        cursor: pointer;
      }

      #${PANEL_ID} .tm-bat-toggle input {
        position: absolute;
        opacity: 0;
        pointer-events: none;
      }

      #${PANEL_ID} .tm-bat-switch {
        position: relative;
        width: 34px;
        height: 20px;
        border-radius: 999px;
        background: rgba(128, 128, 128, 0.45);
        transition: background 0.15s ease;
      }

      #${PANEL_ID} .tm-bat-switch::after {
        content: "";
        position: absolute;
        top: 3px;
        left: 3px;
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: Canvas;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
        transition: transform 0.15s ease;
      }

      #${PANEL_ID} input:checked + .tm-bat-switch {
        background: #1f7a4d;
      }

      #${PANEL_ID} input:checked + .tm-bat-switch::after {
        transform: translateX(14px);
      }

      #${PANEL_ID} .tm-bat-copy {
        min-width: 0;
      }

      #${PANEL_ID} .tm-bat-label,
      #${PANEL_ID} .tm-bat-hint {
        display: block;
        overflow-wrap: anywhere;
      }
    `;
    document.head.appendChild(style);
  }

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }
})();