bagscript

bag script with anti bot features + more

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

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

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

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

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

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

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        bagscript
// @description bag script with anti bot features + more
// @version     0.9.0.3
// @license     MIT
// @namespace   9e7f6239-592e-409b-913f-06e11cc5e545
// @include     https://8chan.moe/v/res/*
// @include     https://8chan.se/v/res/*
// @include     https://8chan.moe/barchive/res/*
// @include     https://8chan.se/barchive/res/*
// @include     https://8chan.moe/test/res/*
// @include     https://8chan.se/test/res/*
// @grant       unsafeWindow
// @run-at      document-idle
// ==/UserScript==

// Script settings
const FUN_TEXT_SELECTOR = ".doomText, .moeText, .redText, .pinkText, .diceRoll, .echoText";
const RUDE_FORMATS = ["JPEG", "JPG", "PNG"];
const THREAD_LOCKED_AT = 1500;
const URL_PREFIX = window.location.href.split("/").slice(0, 4).join("/");

// Colors
const BAN_BUTTON_BORDER = "1px solid red";
const SPOILER_BORDER = "3px solid red";
const THREAD_FOUND_BORDER = "5px solid green";
const THREAD_NOT_FOUND_BORDER = "5px solid red";

// Janny tool settings
const BOT_BAN_DURATION = "3d";
const BOT_BAN_REASON = "bot";
const PIZZA_BAN_DURATION = "";
const PIZZA_BAN_REASON = "pizza";

// Debug settings
const DEBUG_TOOLS_VISIBLE = false;
const FORCE_NEXT_THREAD_FAIL = false;

// Tooltips / Info / Etc
const NOT_A_JANNY = "You aren't a janny dumbass."
const BOT_BAN_BUTTON_WARNING = "WARNING: The ban buttons will immediately issue a ban + "
  + "delete by IP for the poster WITH NO CONFIRMATION. The ban reason and duration can be "
  + "set in the script (refresh after modifying). Are you sure you want to turn this on?";

// State
let checkedJannyStatus = false;
let manualBypass;
let defaultSpoilerSrc;
let loggedInAsJanny = false;
const settings = {};
let threadsClosed = false;
let menuVisible = false;

// Loading
loadSettings();
loadMenu();

const loaderObserver = new MutationObserver((_, observer) => {
  const loaded = document.querySelector("div.opHead");
  if (loaded) {
    observer.disconnect();

    const initialPosts = document.querySelectorAll(".postCell");
    if (initialPosts.length >= THREAD_LOCKED_AT) {
      threadsClosed = true;
      addNextThreadFakePost(0, true);
    }

    processAllPosts();

    checkIfJanny((isJanny) => {
      if (isJanny) {
        document.querySelectorAll(".jannyTab, .jannyTools").forEach((e) => e.style.display = "flex");
      }
    });

    postObserver.observe(document, {childList: true, subtree: true});
  }
});

if (settings.enabled) {
  loaderObserver.observe(document, {childList: true, subtree: true});
}

// New post observer
const postObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === 1) {
        const isPost = node.classList.contains("postCell");
        if (isPost) {
          if (settings.findNextThread && !threadsClosed) {
            const totalPostCount = document.querySelector("#postCount").innerText;
            if (totalPostCount >= THREAD_LOCKED_AT) {
              threadsClosed = true;
              addNextThreadFakePost();
            }
          }

          const id = postId(node);
          unsafeWindow.posting.idsRelation[id].forEach((innerPost) => {
            processAllPostsById(id);
          });

          node.querySelectorAll(".quoteLink").forEach((quoteLink) => {
            const quotedId = quoteLink.innerText.substring(2);
            const quotedPost = document.getElementById(quotedId);
            if (quotedPost) {
              processSinglePost(quotedPost);
            }
          });
        } else {
          const isHoverPost = node.classList.contains("quoteTooltip");
          const isInlineQuote = node.classList.contains("inlineQuote");
          if (isHoverPost || isInlineQuote) {
            processSinglePost(node);
          }
        }
      }
    }
  }
});

// Post handling
function processAllPosts() {
  for (const id in unsafeWindow.posting.idsRelation) {
    processAllPostsById(id);
  }

  document.querySelectorAll(".inlineQuote").forEach((inlineQuote) => {
    processSinglePost(inlineQuote);
  });

  const hoverPost = document.querySelector(".quoteTooltip");
  if (hoverPost) {
    processSinglePost(hoverPost);
  }
}

function processAllPostsById(id) {
  const innerPostsById = unsafeWindow.posting.idsRelation[id];

  let isNice = false;
  for (const innerPost of innerPostsById) {
    if (isNicePost(innerPost.parentElement)) {
      isNice = true;
      break;
    }
  }

  unsafeWindow.posting.idsRelation[id].forEach((innerPost) => {
    processSinglePost(innerPost.parentElement, isNice);
  });
}

function processSinglePost(post, isNiceOverride) {
  // Handle spoilers
  const images = post.querySelectorAll(".uploadCell img");
  images.forEach((image) => {
    const isSpoiler = image.src.includes("spoiler") || image.getAttribute("data-spoiler");
    if (isSpoiler) {
      defaultSpoilerSrc ??= image.src;

      if (settings.enabled && settings.revealSpoilers) {
        image.setAttribute("data-spoiler", true);
        const sha256 = image.parentElement.href.split("/")[4].split(".")[0];
        image.src = `/.media/t_${sha256}`;
        image.style.border = SPOILER_BORDER;
      } else {
        image.src = defaultSpoilerSrc;
        image.style.border = "0";
      }
    }
  });

  if (post.classList.contains("opCell")) return;

  // Handle rude posts
  let bypassButton = post.querySelector(".bypassButton");
  let jannyTools = post.querySelector(".jannyTools");

  const isNice = isNiceOverride ? true : isNicePost(post);
  if (!settings.enabled || isNice) {
    // Unblur
    post.style.display = "block";
    post.querySelectorAll("img").forEach((img) => {
      img.style.filter = "";
    });

    if (bypassButton) {
      bypassButton.style.display = "none";
    }

    if (jannyTools) {
      //jannyTools.style.display = "none";
      jannyTools.remove();
    }
  } else {
    // Blur
    post.style.display = settings.hideFiltered ? "none" : "block";
    images.forEach((img) => {
      img.style.filter = `blur(${settings.blurStrength}px)`;
    });

    if (bypassButton) {
      bypassButton.style.display = "inline";
    } else {
      bypassButton = bypassButtonForPost(post);
      post.querySelector(".postInfo.title").appendChild(bypassButton);
    }

    addJannyToolsToPost(post);
  }
}

function isNicePost(post) {
  if (post.classList.contains("opCell")) {
    return false;
  }

  const id = postId(post);
  if (!id) return false;

  if (manualBypass[id]) return true;

  const innerPosts = unsafeWindow.posting.idsRelation[id];
  const idAboveThreshold = innerPosts.length >= settings.postThreshold;
  if (idAboveThreshold) return true;

  if (settings.whitelist.isYou) {
    const postIsByYou = post.querySelector(".youName");
    if (postIsByYou) return true;
  }

  if (settings.whitelist.isOp) {
    const isOp = document.querySelector(".opCell .labelId").innerText === id;
    if (isOp) return true;
  }

  const backlinks = post.querySelector(".postInfo").querySelectorAll(".panelBacklinks > a");
  const aboveBlThreshold = backlinks.length >= settings.backlinkThreshold;
  if (aboveBlThreshold) return true;

  if (settings.whitelist.hasFunText) {
    const hasFunText = post.querySelector(FUN_TEXT_SELECTOR);
    if (hasFunText) return true;
  }

  // Image heuristics
  const images = post.querySelectorAll(".uploadCell img:not(.imgExpanded)");

  if (settings.whitelist.hasNoImages) {
    const noImages = images.length === 0;
    if (noImages) return true;
  }

  /*
  if (settings.whitelist.hasMultipleImages) {
    const multipleImages = images.length > 1;
    if (multipleImages) return true;
  }
  */

  let spoilerCount = 0;
  for (const image of images) {
    if (settings.whitelist.hasSpoilerImage) {
      const spoilerImage = image.getAttribute("data-spoiler") === "true"
      if (spoilerImage) spoilerCount++;

    }

    if (settings.whitelist.hasGoodExtension) {
      const format = image?.parentElement?.href?.split("/")?.[4]?.split(".")?.[1]?.toUpperCase();
      if (format) {
        const notRudeImage = !RUDE_FORMATS.includes(format);
        if (notRudeImage) return true;
      }
    }
  }

  if (images.length > 0 && spoilerCount === images.length) return true;

  return false;
}

// Menu
function loadMenu() {
  document.querySelector(".bagMenu")?.remove();

  // Menu container
  const menu = document.createElement("div");
  document.querySelector("body").appendChild(menu);
  menu.className = "bagMenu";
  menu.style.bottom = "0px";
  menu.style.color = "var(--navbar-text-color)";
  menu.style.display = "flex";
  menu.style.gap = "1px";
  menu.style.right = "0px";
  menu.style.padding = "1px";
  menu.style.position = "fixed";

  // Menu contents container
  const menuContents = document.createElement("div");
  menu.appendChild(menuContents);
  menuContents.style.backgroundColor = "var(--navbar-text-color)";
  menuContents.style.border = "1px solid var(--navbar-text-color)";
  menuContents.style.display = menuVisible ? "flex" : "none";
  menuContents.style.flexDirection = "column";
  menuContents.style.gap = "1px";

  // Tabs container
  const tabs = document.createElement("div");
  tabs.style.display = "flex";
  tabs.style.gap = "1px";

  buildGeneralTab(tabs, menuContents);
  buildFilterTab(tabs, menuContents);
  buildFinderTab(tabs, menuContents);
  buildJannyTab(tabs, menuContents);
  buildDebugTab(tabs, menuContents);

  menuContents.appendChild(tabs);
  addToggleButton(menu, menuContents);
}

function buildGeneralTab(tabsContainer, contentContainer) {
  const generalTab = makeTab("General");
  tabsContainer.appendChild(generalTab);

  const generalTabContainer = makeTabContainer("General");
  contentContainer.appendChild(generalTabContainer);

  // Enable checkbox
  const enableContainer = makeContainer();
  generalTabContainer.appendChild(enableContainer);

  const enableLabel = makeLabel("Enable Script");
  enableContainer.appendChild(enableLabel);

  const enableCheckbox = makeCheckbox(settings.enabled);
  enableContainer.appendChild(enableCheckbox);
  enableCheckbox.onchange = () => {
    settings.enabled = enableCheckbox.checked;
    unsafeWindow.localStorage.setItem("bag_enabled", settings.enabled);

    if (settings.enabled) {
      loaderObserver.observe(document, {childList: true, subtree: true});
    } else {
      postObserver.disconnect();
      processAllPosts();
    }
  };

  // Reveal spoilers checkbox
  const revealContainer = makeContainer();
  generalTabContainer.appendChild(revealContainer);

  const revealLabel = makeLabel("Reveal Spoilers");
  revealContainer.appendChild(revealLabel);

  const revealCheckbox = makeCheckbox(settings.revealSpoilers);
  revealContainer.appendChild(revealCheckbox);
  revealCheckbox.onchange = () => {
    settings.revealSpoilers = revealCheckbox.checked;
    setSetting("bag_revealSpoilers", settings.revealSpoilers);

    processAllPosts();
  };
}

function buildFilterTab(tabsContainer, contentContainer) {
  const filterTab = makeTab("Filter");
  tabsContainer.appendChild(filterTab);

  const filterTabContainer = makeTabContainer("Filter");
  contentContainer.appendChild(filterTabContainer);

  // Blur input
  const blurContainer = makeContainer();
  filterTabContainer.appendChild(blurContainer);

  const blurLabel = makeLabel("Blur Strength");
  blurContainer.appendChild(blurLabel);

  const blurInput = makeInput(settings.blurStrength);
  blurContainer.appendChild(blurInput);
  blurInput.onchange = () => {
    settings.blurStrength = blurInput.value;
    unsafeWindow.localStorage.setItem("bag_blurStrength", settings.blurStrength);

    processAllPosts();
  };

  // Hide filtered checkbox
  const hideContainer = makeContainer();
  filterTabContainer.appendChild(hideContainer);

  const hideLabel = makeLabel("Hide Filtered");
  hideContainer.appendChild(hideLabel);

  const hideCheckbox = makeCheckbox(settings.hideFiltered);
  hideContainer.appendChild(hideCheckbox);
  hideCheckbox.onchange = () => {
    settings.hideFiltered = hideCheckbox.checked;
    unsafeWindow.localStorage.setItem("bag_hideFiltered", settings.hideFiltered);

    processAllPosts();
  };

  // Whitelist label
  const whitelistContainer = makeContainer();
  filterTabContainer.appendChild(whitelistContainer);

  const whitelistLabel = makeLabel("------- Auto Whitelist -------");
  whitelistContainer.appendChild(whitelistLabel);
  whitelistLabel.style.textAlign = "center";
  whitelistLabel.style.width = "100%";

  // Post threshold input
  const thresholdContainer = makeContainer();
  filterTabContainer.appendChild(thresholdContainer);

  const thresholdLabel = makeLabel("ID Post Count");
  thresholdContainer.appendChild(thresholdLabel);

  const thresholdInput = makeInput(settings.postThreshold);
  thresholdContainer.appendChild(thresholdInput);
  thresholdInput.onchange = () => {
    settings.postThreshold = thresholdInput.value;
    unsafeWindow.localStorage.setItem("bag_postThreshold", settings.postThreshold);

    processAllPosts();
  };

  // Backlink threshold input
  const blThresholdContainer = makeContainer();
  filterTabContainer.appendChild(blThresholdContainer);

  const blThresholdLabel = makeLabel("Post Quoted Count");
  blThresholdContainer.appendChild(blThresholdLabel);

  const blThresholdInput = makeInput(settings.backlinkThreshold);
  blThresholdContainer.appendChild(blThresholdInput);
  blThresholdInput.onchange = () => {
    settings.backlinkThreshold = blThresholdInput.value;
    setSetting("bag_backlinkThreshold", settings.backlinkThreshold);

    processAllPosts();
  };

  filterTabContainer.appendChild(makeHeuristicCheckbox("Is (You)", "isYou"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Is OP", "isOp"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Fun Text", "hasFunText"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has No Images", "hasNoImages"));
  //filterTabContainer.appendChild(makeHeuristicCheckbox("Has 2+ Images", "hasMultipleImages"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Only Spoiler Images", "hasSpoilerImage"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Good File Ext", "hasGoodExtension"));
}

function buildFinderTab(tabsContainer, contentContainer) {
  const finderTab = makeTab("Finder");
  tabsContainer.appendChild(finderTab);

  const finderTabContainer = makeTabContainer("Finder");
  contentContainer.appendChild(finderTabContainer);

  // Thread finder checkbox
  const nextThreadContainer = makeContainer();
  finderTabContainer.appendChild(nextThreadContainer);

  const nextThreadLabel = makeLabel("Enable Thread Finder");
  nextThreadContainer.appendChild(nextThreadLabel);

  const nextThreadCheckbox = makeCheckbox(settings.findNextThread);
  nextThreadContainer.appendChild(nextThreadCheckbox);
  nextThreadCheckbox.onchange = () => {
    settings.findNextThread = nextThreadCheckbox.checked;
    setSetting("bag_findNextThread", settings.findNextThread);
  };

  // Thread subject input
  const subjectContainer = makeContainer();
  finderTabContainer.appendChild(subjectContainer);

  const subjectLabel = makeLabel("Thread Subject");
  subjectContainer.append(subjectLabel);

  const subjectInput = makeInput(settings.threadSubject);
  subjectInput.className = "subjectInput";
  subjectInput.size = 10;
  subjectContainer.appendChild(subjectInput);
  subjectInput.onchange = () => {
    settings.threadSubject = subjectInput.value;
    setSetting("bag_threadSubject", settings.threadSubject);
  }
}

function buildJannyTab(tabsContainer, contentContainer) {
  const jannyTab = makeTab("Janny");
  jannyTab.classList.add("jannyTab");
  jannyTab.style.display = "none";
  tabsContainer.appendChild(jannyTab);

  const jannyTabContainer = makeTabContainer("Janny");
  contentContainer.appendChild(jannyTabContainer);

  // Bot ban checkbox
  const jannyToolsContainer = makeContainer();
  jannyTabContainer.appendChild(jannyToolsContainer);

  const jannyToolsLabel = makeLabel("Janny Tools");
  jannyToolsContainer.appendChild(jannyToolsLabel);

  const jannyToolsCheckbox = makeCheckbox(settings.showJannyTools);
  jannyToolsContainer.appendChild(jannyToolsCheckbox);
  jannyToolsCheckbox.onchange = () => {
    if (jannyToolsCheckbox.checked) {
      if (!loggedInAsJanny) {
        alert(NOT_A_JANNY);
        jannyToolsCheckbox.checked = false;
        return;
      }

      if (!confirm(BOT_BAN_BUTTON_WARNING)) {
        jannyToolsCheckbox.checked = false;
        return;
      }
    }

    settings.showJannyTools = jannyToolsCheckbox.checked;
    setSetting("bag_showJannyTools", settings.showJannyTools);

    processAllPosts();
  }
}

function buildDebugTab(tabsContainer, contentContainer) {
  if (!DEBUG_TOOLS_VISIBLE) return;

  const debugTab = makeTab("Debug");
  tabsContainer.appendChild(debugTab);

  const debugTabContainer = makeTabContainer("Debug");
  contentContainer.appendChild(debugTabContainer);

  const fakePostButton = makeButton();
  debugTabContainer.appendChild(fakePostButton);
  fakePostButton.innerText = "Test Fake Post";
  fakePostButton.style.backgroundColor = "var(--background-color)";
  fakePostButton.onclick = () => {
    const url = `${URL_PREFIX}/res/1289960.html`
    addFakePost(`fake post test\r\n<a href="${url}">${url}</a>`);
  }

  const triggerThreadCheckButton = makeButton();
  debugTabContainer.appendChild(triggerThreadCheckButton);
  triggerThreadCheckButton.innerText = "Test Thread Finder";
  triggerThreadCheckButton.style.backgroundColor = "var(--background-color)";
  triggerThreadCheckButton.onclick = () => {
    addNextThreadFakePost(0, true);
  }

  const clearStorageButton = makeButton();
  debugTabContainer.appendChild(clearStorageButton);
  clearStorageButton.innerText = "Clear Storage";
  clearStorageButton.style.backgroundColor = "var(--background-color)";
  clearStorageButton.onclick = () => {
    Object.keys(localStorage).filter(x => x.startsWith("bag_")).forEach((x) => localStorage.removeItem(x));
    location.reload();
  }
}

// Post helpers
function postId(post) {
  return post?.querySelector('.labelId')?.innerText;
}

function addFakePost(contents) {
  const outer = document.createElement("div");
  document.querySelector(".divPosts").appendChild(outer);
  outer.className = "fakePost";
  outer.style.marginBottom = "0.25em";

  const inner = document.createElement("div");
  outer.appendChild(inner);
  inner.className = "innerPost";

  const message = document.createElement("div");
  inner.appendChild(message);
  message.className = "divMessage";
  message.innerHTML = contents;

  return inner;
}

function addNextThreadFakePost(initialQueryDelay, includeAutoSage) {
  document.querySelector(".nextThread")?.remove();

  const fakePost = addFakePost(`Searching for next ${settings.threadSubject} thread...`);
  fakePost.classList.add("nextThread");

  const fakePostMessage = document.querySelector(".nextThread .divMessage");
  const delay = FORCE_NEXT_THREAD_FAIL ? 500 : 30000;

  setTimeout(async () => {
    const found = FORCE_NEXT_THREAD_FAIL
      ? false
      : await queryNextThread(fakePost, fakePostMessage, includeAutoSage);

    if (!found) {
      fakePostMessage.innerHTML += `\r\nThread not found, retrying in 30s`;

      let retryCount = 8;
      const interval = setInterval(async () => {
        if (retryCount-- < 0) {
          clearInterval(interval);
          fakePostMessage.innerHTML += "\r\nNEXT THREAD NOT FOUND"
          fakePost.style.border = THREAD_NOT_FOUND_BORDER;
          return;
        }

        const retryFound = await queryNextThread(fakePost, fakePostMessage, includeAutoSage);
        if (retryFound) {
          clearInterval(interval);
        } else {
          fakePostMessage.innerHTML += `\r\nThread not found, retrying in 30s`;
        }
      }, delay);
    }
  }, initialQueryDelay ?? 60000);
}

// returns true if no more retries should be attempted
async function queryNextThread(fakePost, fakePostMessage, includeAutoSage) {
  // Try to fix issues people were having where fakePostMessage was undefined even with the fake post present.
  // Not sure what the actual cause is, haven't been able to replicate
  if (!fakePost) fakePost = document.querySelector(".nextThread");
  if (!fakePostMessage) fakePostMessage = document.querySelector(".nextThread .divMessage");

  const catalogUrl = barchiveToV(`${URL_PREFIX}/catalog.json`);
  unsafeWindow.console.log("searching for next thread", catalogUrl);

  const catalog = FORCE_NEXT_THREAD_FAIL
    ? await mockEmptyCatalogResponse()
    : await fetch(catalogUrl);

  if (catalog.ok) {
    const threads = await catalog.json();
    for (const thread of threads) {
      const notAutoSage = includeAutoSage || !thread.autoSage;
      if (notAutoSage && thread.subject?.includes(settings.threadSubject)) {
        const url = barchiveToV(`${URL_PREFIX}/res/${thread.threadId}.html`);
        fakePostMessage.innerHTML = `${thread.subject} [${thread.postCount ?? 1} posts]:\r\n<a href=${url}>${url}</a>`;
        fakePost.style.border = THREAD_FOUND_BORDER;
        return true;
      }
    }

    return false;
  } else {
    fakePostMessage.innerHTML = "ERROR WHILE LOOKING FOR NEXT THREAD";
    fakePost.style.border = THREAD_NOT_FOUND_BORDER;
    return true;
  }
}

// LocalStorage Helpers
function loadSettings() {
  // State
  manualBypass = getManualBypass();
  settings.activeTab = getStringSetting("bag_activeTab", "General");

  // General settings
  settings.enabled = getBoolSetting("bag_enabled", true);
  settings.revealSpoilers = getBoolSetting("bag_revealSpoilers", false);

  // Filter settings
  settings.postThreshold = getIntSetting("bag_postThreshold", 4);
  settings.backlinkThreshold = getIntSetting("bag_backlinkThreshold", 3);
  settings.blurStrength = getIntSetting("bag_blurStrength", 10);
  settings.hideFiltered = getBoolSetting("bag_hideFiltered", false);

  // Heuristic settings
  settings.whitelist = {};
  settings.whitelist.isYou = getBoolSetting("bag_whitelist_isYou", true);
  settings.whitelist.isOp = getBoolSetting("bag_whitelist_isOp", true);
  settings.whitelist.hasFunText = getBoolSetting("bag_whitelist_hasFunText", true);
  settings.whitelist.hasNoImages = getBoolSetting("bag_whitelist_hasNoImages", true);
  settings.whitelist.hasMultipleImages = getBoolSetting("bag_whitelist_hasMultipleImages", false);
  settings.whitelist.hasSpoilerImage = getBoolSetting("bag_whitelist_hasSpoilerImage", true);
  settings.whitelist.hasGoodExtension = getBoolSetting("bag_whitelist_hasGoodExtension", true);

  // Thread finder settings
  settings.findNextThread = getBoolSetting("bag_findNextThread", true);
  settings.threadSubject = getStringSetting("bag_threadSubject", "/bag/");

  // Janny Settings
  settings.showJannyTools = getBoolSetting("bag_showJannyTools", false);
}

function setSetting(name, value) {
  unsafeWindow.localStorage.setItem(name, value);
}

function getSetting(name) {
  return unsafeWindow.localStorage.getItem(name);
}

function getBoolSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return value == "true";
}

function getIntSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return parseInt(value);
}

function getStringSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return value
}

function getManualBypass() {
  const bypassVar = `bag_bypass_${unsafeWindow.api.threadId}`;
  const bp = getSetting(bypassVar);
  return (!bp) ? {} : JSON.parse(bp);
}

function setManualBypass() {
  const bypassVar = `bag_bypass_${unsafeWindow.api.threadId}`;
  const bypassData = JSON.stringify(manualBypass);
  unsafeWindow.localStorage.setItem(bypassVar, bypassData);
}

// HTML Helpers
function makeContainer() {
  const container = document.createElement("div");
  container.style.alignItems = "center";
  container.style.backgroundColor = "var(--background-color)";
  container.style.display = "flex";
  container.style.gap = "0.25rem";
  container.style.justifyContent = "space-between";
  container.style.padding = "0.1rem";
  return container;
}

function makeLabel(text) {
  const label = document.createElement("div");
  label.innerText = text;
  label.style.color = "var(--text-color)";
  label.style.userSelect = "none";
  return label;
}

function makeCheckbox(initialValue) {
  const checkbox = document.createElement("input");
  checkbox.type = "checkbox";
  checkbox.style.cursor = "pointer";
  checkbox.checked = initialValue;
  return checkbox;
}

function makeHeuristicCheckbox(label, setting) {
  const container = makeContainer();

  const labelElement = makeLabel(label);
  container.appendChild(labelElement);

  const checkbox = makeCheckbox(settings.whitelist[setting]);
  container.appendChild(checkbox);
  checkbox.onchange = () => {
    settings.whitelist[setting] = checkbox.checked;
    localStorage.setItem(`bag_whitelist_${setting}`, settings.whitelist[setting]);

    processAllPosts();
  };

  return container;
}

function makeInput(initialValue) {
  const input = document.createElement("input");
  input.size = 4;
  input.value = initialValue;
  input.style.border = "1px solid var(--navbar-text-color)";
  return input;
}

function makeButton() {
  const button = document.createElement("div");
  button.style.alignItems = "center";
  button.style.color = "var(--link-color)";
  button.style.cursor = "pointer";
  button.style.display = "flex";
  button.style.padding = "0.25rem 0.75rem";
  button.style.userSelect = "none";
  return button;
}

function bypassButtonForPost(post) {
  const id = postId(post);
  if (!id) return;

  const bypassButton = makeButton();
  bypassButton.innerText = "+";
  bypassButton.className = "bypassButton";
  bypassButton.style.border = "1px solid var(--horizon-sep-color)";
  bypassButton.style.display = "inline";
  bypassButton.style.marginLeft = "1rem";

  bypassButton.onclick = () => {
    bypassButton.style.display = "none";
    manualBypass[id] = true;
    setManualBypass();

    processSinglePost(post);
    processAllPostsById(id);
  };

  return bypassButton;
}

function addJannyToolsToPost(post) {
  const innerPost = post.querySelector(".innerPost");
  const shouldShow = loggedInAsJanny && settings.showJannyTools;

  let tools = post.querySelector(".jannyTools");
  if (tools) {
    tools.style.display = shouldShow ? "flex" : "none";
  } else {
    tools = document.createElement("div");
    tools.className = "jannyTools";
    innerPost.appendChild(tools);

    tools.style.display = shouldShow ? "flex" : "none";
    tools.style.paddingTop = "0.25rem";
    tools.style.gap = "1rem";
    tools.style.justifyContent = "flex-end";
    tools.style.width = "100%";

    addBanButtonToTools(tools, post, "Bot Ban", "bot", "3d");
    addBanButtonToTools(tools, post, "🍕", "pizza", "");
  }

  return tools;
}

function addBanButtonToTools(container, post, buttonText, banReason, banLength) {
  const innerPost = post.querySelector(".innerPost");

  // Bot ban button
  let banButton = post.querySelector(".banButton." + banReason);
  if (!banButton) {
    banButton = document.createElement("div");
    banButton.className = `banButton ${banReason}`;
    container.appendChild(banButton);

    banButton.innerText = buttonText;
    banButton.style.border = BAN_BUTTON_BORDER;
    banButton.style.cursor = "pointer";
    banButton.style.display = "block";
    banButton.style.margin = "0";
    banButton.style.padding = "0.25rem";
    banButton.style.userSelect = "none";

    banButton.onclick = () => {
      const postId = innerPost.querySelector("a.linkQuote").innerText;
      const dummy = document.createElement("div");
      postingMenu.applySingleBan(
        "", 3, banReason, false, 0, banLength, false,
        true, "v", api.threadId, postId, innerPost, dummy
      );
    }
  }

  return banButton;
}

function addToggleButton(menu, menuContents) {
  const toggleButton = makeButton();
  menu.appendChild(toggleButton);
  toggleButton.innerText = "<<"
  toggleButton.style.alignSelf = "flex-end";
  toggleButton.style.backgroundColor = "var(--background-color)";
  toggleButton.style.border = "1px solid var(--navbar-text-color)";
  toggleButton.onclick = () => {
    menuVisible = !menuVisible;
    menuContents.style.display = menuVisible ? "flex" : "none";
    toggleButton.innerText = menuVisible ? ">>" : "<<";
  }
}

function makeTab(tabName) {
  const isActive = settings.activeTab === tabName;

  const tab = document.createElement("div");
  tab.innerText = tabName;
  tab.className = "bagTab"
  tab.style.backgroundColor = "var(--background-color)";
  tab.style.color = isActive ? "var(--link-color)" : "var(--text-color)";
  tab.style.cursor = "pointer";
  tab.style.flexGrow = "1";
  tab.style.padding = "0.25rem 0.75rem";
  tab.style.userSelect = "none";

  tab.onclick = () => {
    settings.activeTab = tabName;
    setSetting("bag_activeTab", settings.activeTab);

    // Tab
    document.querySelectorAll(".bagTab").forEach((tab) => {
      tab.style.color = "var(--text-color)";
    });

    tab.style.color = "var(--link-color)";

    // Tab container
    document.querySelectorAll(".bagTabContainer").forEach((tabContainer) => {
      tabContainer.style.display = "none";
    });

    document.querySelector(`.bagTabContainer[data-tab="${tabName}"]`).style.display = "flex";
  };

  return tab;
}

function makeTabContainer(tabName) {
  const isActive = settings.activeTab === tabName;

  const tabContainer = document.createElement("div");
  tabContainer.className = "bagTabContainer";
  tabContainer.setAttribute("data-tab", tabName)
  tabContainer.style.display = isActive ? "flex" : "none";
  tabContainer.style.flexDirection = "column"
  tabContainer.style.gap = "1px";

  return tabContainer;
}

// Misc helpers
function barchiveToV(url) {
  return url.replace("barchive", "v");
}

function checkIfJanny(callback) {
  if (checkedJannyStatus) {
    if (callback) callback(loggedInAsJanny);
  } else {
    checkedJannyStatus = true;
    api.formApiRequest("account", {}, (status, data) => {
      if (status !== "ok") return;

      loggedInAsJanny =
        data.ownedBoards?.includes(api.boardUri)
        || data.volunteeredBoards?.includes(api.boardUri);

      if (callback) callback(loggedInAsJanny);
    }, true);
  }
}

// Debug/Test helpers
function mockEmptyCatalogResponse() {
  return Promise.resolve({
    ok: true,
    json: () => Promise.resolve([])
  });
}