☰

Booru Auto Search Tags

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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;");
  }
})();