Greasy Fork

Greasy Fork is available in English.

AO3: Advanced Blocker

Block works based off of tags, authors, word counts, languages, completion status and more. Now with primary pairing filtering!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name          AO3: Advanced Blocker
// @description   Block works based off of tags, authors, word counts, languages, completion status and more. Now with primary pairing filtering!
// @author        BlackBatCat
// @version       1.6
// @license       MIT
// @match         *://archiveofourown.org/tags/*/works*
// @match         *://archiveofourown.org/works
// @match         *://archiveofourown.org/works?*
// @match         *://archiveofourown.org/works/search*
// @match         *://archiveofourown.org/users/*
// @match         *://archiveofourown.org/collections/*
// @match         *://archiveofourown.org/bookmarks*
// @match         *://archiveofourown.org/series/*
// @grant         GM.getValue
// @grant         GM.setValue
// @run-at        document-idle
// @namespace 
// ==/UserScript==

; (function () {
  "use strict";
  window.ao3Blocker = {};
  // Startup message
  try {
    console.log("[AO3: Advanced Blocker] loaded.");
  } catch (e) { }

  // CSS namespace for all classes
  const CSS_NAMESPACE = "ao3-blocker";

  // Default configuration values and option definitions
  const DEFAULTS = {
    tagBlacklist: "",
    tagWhitelist: "",
    tagHighlights: "",
    highlightColor: "#f6e3ca",
    minWords: "",
    maxWords: "",
    blockComplete: false,
    blockOngoing: false,
    authorBlacklist: "",
    titleBlacklist: "",
    summaryBlacklist: "",
    showReasons: true,
    showPlaceholders: true,
    debugMode: false,
    allowedLanguages: "",
    maxCrossovers: "3",
    disableOnBookmarks: true,
    disableOnCollections: false,
    disableOnDashboards: true,
    primaryRelationships: "",
    primaryCharacters: "",
    primaryRelpad: "1",
    primaryCharpad: "5"
  };

  // Storage key for single config object
  const STORAGE_KEY = "ao3_advanced_blocker_config";

  // Custom styles for the script
  const STYLE = `
  html body .ao3-blocker-hidden {
    display: none;
  }

  .ao3-blocker-cut {
    display: none;
  }

  .ao3-blocker-cut::after {
    clear: both;
    content: '';
    display: block;
  }

  .ao3-blocker-reason {
    margin-left: 5px;
  }

  .ao3-blocker-hide-reasons .ao3-blocker-reason {
    display: none;
  }

  .ao3-blocker-unhide .ao3-blocker-cut {
    display: block;
  }

  .ao3-blocker-fold {
    align-items: center;
    display: flex;
    justify-content: space-between !important;
    gap: 10px !important;
    width: 100% !important;
  }

  .ao3-blocker-unhide .ao3-blocker-fold {
      border-bottom: 1px dashed;
      border-bottom-color: inherit;
      margin-bottom: 15px;
      padding-bottom: 5px;
  }

  button.ao3-blocker-toggle {
    margin-left: auto;
    min-width: inherit;
    min-height: inherit;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.2em;
    min-width: 80px !important;
    margin-left: 10px !important;
    flex-shrink: 0 !important;
    white-space: nowrap !important;
    padding: 4px 8px !important;
  }

  .ao3-blocker-note {
    flex: 1 !important;
    min-width: 0 !important;
    word-wrap: break-word !important;
    overflow-wrap: break-word !important;
    /* Create space for the icon on the left */
    margin-left: 2em !important;
    position: relative !important;
    display: block !important;
  }

  .ao3-blocker-fold .ao3-blocker-note .ao3-blocker-icon {
    position: absolute !important;
    left: -1.5em !important;
    margin-right: 0 !important;
    display: block !important;
    float: none !important;
    vertical-align: top !important;
    width: 1.2em !important;
    height: 1.2em !important;
  }

  .ao3-blocker-toggle span {
    width: 1em !important;
    height: 1em !important;
    display: inline-block;
    vertical-align: -0.15em;
    margin-right: 0.2em;
    background-color: currentColor;
  }

  /* Settings menu styles */
  .ao3-blocker-menu-dialog {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: #fffaf5;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 20px rgba(0,0,0,0.2);
    z-index: 10000;
    width: 90%;
    max-width: 700px;
    max-height: 80vh;
    overflow-y: auto;
    font-family: inherit;
    font-size: inherit;
    color: inherit;
    box-sizing: border-box;
  }

  .ao3-blocker-menu-dialog .settings-section {
    background: rgba(0,0,0,0.03);
    border-radius: 6px;
    padding: 15px;
    margin-bottom: 20px;
    border-left: 4px solid currentColor;
  }

  .ao3-blocker-menu-dialog .section-title {
    margin-top: 0;
    margin-bottom: 15px;
    font-size: 1.2em;
    font-weight: bold;
    font-family: inherit;
    color: inherit;
    opacity: 0.85;
  }

  .ao3-blocker-menu-dialog .setting-group {
    margin-bottom: 15px;
  }

  .ao3-blocker-menu-dialog .setting-label {
    display: block;
    margin-bottom: 6px;
    font-weight: bold;
    color: inherit;
    opacity: 0.9;
  }

  .ao3-blocker-menu-dialog .setting-description {
    display: block;
    margin-bottom: 8px;
    font-size: 0.9em;
    color: inherit;
    opacity: 0.6;
    line-height: 1.4;
  }

  .ao3-blocker-menu-dialog .two-column {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 15px;
  }

  .ao3-blocker-menu-dialog .button-group {
    display: flex;
    justify-content: space-between;
    gap: 10px;
    margin-top: 20px;
  }

  .ao3-blocker-menu-dialog .button-group button {
    flex: 1;
    padding: 10px;
    color: inherit;
    opacity: 0.9;
  }

  .ao3-blocker-menu-dialog .reset-link {
    text-align: center;
    margin-top: 10px;
    color: inherit;
    opacity: 0.7;
  }

  .ao3-blocker-menu-dialog textarea {
    width: 100%;
    min-height: 100px;
    resize: vertical;
    box-sizing: border-box;
  }

  /* Highlighted works (color set inline, but !important for override) */
  .ao3-blocker-highlight {
    background-color: var(--ao3-blocker-highlight-color, rgba(255,255,0,0.1)) !important;
  }
  /* Tooltip icon style for settings menu (scoped) */
  .ao3-blocker-menu-dialog .symbol.question {
    font-size: 0.5em;
    vertical-align: middle;
  }
  /* Lighter placeholder text for menu input fields */
  .ao3-blocker-menu-dialog input::placeholder,
  .ao3-blocker-menu-dialog textarea::placeholder {
    opacity: 0.6 !important;
  }
`;

  // Load configuration from single object storage
  function loadConfig() {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      let config = stored ? { ...DEFAULTS, ...JSON.parse(stored) } : { ...DEFAULTS };
      if (typeof config.disableOnDashboards === 'undefined') {
        config.disableOnDashboards = DEFAULTS.disableOnDashboards;
      }
      return config;
    } catch (e) {
      console.error("[AO3 Advanced Blocker] Failed to load config:", e);
    }
    return { ...DEFAULTS };
  }

  // Save configuration to single object storage
  function saveConfig(config) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
      return true;
    } catch (e) {
      console.error("[AO3 Advanced Blocker] Failed to save config:", e);
      return false;
    }
  }

  // Parse chapter status from text content
  function parseChaptersStatus(chaptersText) {
    if (!chaptersText) return null;

    // Clean the text and look for the pattern
    const cleaned = chaptersText.replace(/ /gi, ' ').trim();

    // Pattern for "current / total" or "current / ?"
    const match = cleaned.match(/^(\d+)\s*\/\s*([\d\?]+)/);
    if (match) {
      let chaptersNum = match[1].trim();
      let chaptersDenom = match[2].trim();

      if (chaptersDenom === '?') {
        return 'ongoing';
      } else {
        const current = parseInt(chaptersNum.replace(/\D/g, ''), 10);
        const total = parseInt(chaptersDenom.replace(/\D/g, ''), 10);
        if (!isNaN(current) && !isNaN(total)) {
          if (current < total) {
            return 'ongoing';
          } else if (current === total) {
            return 'complete';
          } else if (current > total) {
            return 'ongoing';
          }
        } else {
          return 'ongoing';
        }
      }
    }

    // If no match found, assume ongoing
    return 'ongoing';
  }

  // Extract tags by category using CSS class selectors
  function getCategorizedTags(container) {
    const tags = {
      ratings: [],
      warnings: [],
      categories: [],
      fandoms: [],
      relationships: [],
      characters: [],
      freeforms: []
    };

    // Work page structure - ALWAYS try these first
    tags.ratings = selectTextsIn(container, ".rating.tags a.tag, .rating.tags .text");
    tags.warnings = selectTextsIn(container, ".warning.tags a.tag, .warning.tags .text");
    tags.categories = selectTextsIn(container, ".category.tags a.tag, .category.tags .text");
    tags.fandoms = selectTextsIn(container, ".fandom.tags a.tag");
    tags.relationships = selectTextsIn(container, ".relationship.tags a.tag");
    tags.characters = selectTextsIn(container, ".character.tags a.tag");
    tags.freeforms = selectTextsIn(container, ".freeform.tags a.tag");

    // Only use blurb structure as fallback if NO tags found at all
    const hasAnyTags = tags.ratings.length > 0 || tags.warnings.length > 0 || tags.relationships.length > 0;
    if (!hasAnyTags) {
      tags.relationships = selectTextsIn(container, "li.relationships a.tag");
      tags.characters = selectTextsIn(container, "li.characters a.tag");
      tags.freeforms = selectTextsIn(container, "li.freeforms a.tag");

      // Required tags in blurbs
      tags.ratings = selectTextsIn(container, ".rating .text");
      tags.warnings = selectTextsIn(container, ".warnings .text");
      tags.categories = selectTextsIn(container, ".category .text");
      tags.fandoms = selectTextsIn(container, ".fandoms a.tag");
    }

    return tags;
  }

  // Convert categorized tags to flat array for filtering
  function getAllTagsFlat(categorizedTags) {
    return [
      ...categorizedTags.ratings,
      ...categorizedTags.warnings,
      ...categorizedTags.categories,
      ...categorizedTags.fandoms,
      ...categorizedTags.relationships,
      ...categorizedTags.characters,
      ...categorizedTags.freeforms
    ];
  }

  // Initialize configuration processing directly
  function initConfig() {
    // Config is now available
    const config = loadConfig();

    // Process configuration for runtime use with simple regex pre-compilation
    const compilePattern = (pattern) => {
      if (pattern.includes('*')) {
        const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
        return { text: pattern, regex: new RegExp(escaped, 'i'), hasWildcard: true };
      }
      return { text: pattern, hasWildcard: false };
    };

    window.ao3Blocker.config = {
      "showReasons": config.showReasons,
      "showPlaceholders": config.showPlaceholders,
      "authorBlacklist": config.authorBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean),
      "titleBlacklist": config.titleBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean).map(compilePattern),
      "tagBlacklist": config.tagBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean).map(compilePattern),
      "tagWhitelist": config.tagWhitelist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean).map(compilePattern),
      "tagHighlights": config.tagHighlights.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean),
      "summaryBlacklist": config.summaryBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()).filter(Boolean).map(compilePattern),

      "highlightColor": config.highlightColor,
      "debugMode": config.debugMode,
      "allowedLanguages": config.allowedLanguages
        .toLowerCase()
        .split(/,(?:\s)?/g)
        .map(s => s.trim())
        .filter(Boolean),
      "maxCrossovers": (function () {
        const val = config.maxCrossovers;
        const parsed = parseInt(val, 10);
        return (val === undefined || val === null || val === "" || isNaN(parsed)) ? null : parsed;
      })(),
      "minWords": (function () {
        const v = config.minWords;
        const n = parseInt((v || "").toString().replace(/[,_\s]/g, ""), 10);
        return Number.isFinite(n) ? n : null;
      })(),
      "maxWords": (function () {
        const v = config.maxWords;
        const n = parseInt((v || "").toString().replace(/[,_\s]/g, ""), 10);
        return Number.isFinite(n) ? n : null;
      })(),
      "disableOnBookmarks": config.disableOnBookmarks,
      "disableOnCollections": config.disableOnCollections,
      "blockComplete": config.blockComplete,
      "blockOngoing": config.blockOngoing,
      // Primary Pairing Config
      "primaryRelationships": config.primaryRelationships.split(",").map(s => s.trim()).filter(Boolean),
      "primaryCharacters": config.primaryCharacters.split(",").map(s => s.trim()).filter(Boolean),
      "primaryRelpad": (function () {
        const val = config.primaryRelpad;
        const parsed = parseInt(val, 10);
        return (val === undefined || val === null || val === "" || isNaN(parsed)) ? 1 : Math.max(1, parsed);
      })(),
      "primaryCharpad": (function () {
        const val = config.primaryCharpad;
        const parsed = parseInt(val, 10);
        return (val === undefined || val === null || val === "" || isNaN(parsed)) ? 5 : Math.max(1, parsed);
      })(),
      "disableOnDashboards": (typeof config.disableOnDashboards !== 'undefined' ? config.disableOnDashboards : DEFAULTS.disableOnDashboards)
    }

    addStyle();
    setTimeout(() => {
      // Set the highlight color CSS variable globally
      document.documentElement.style.setProperty('--ao3-blocker-highlight-color', window.ao3Blocker.config.highlightColor || '#f6e3ca');
      checkWorks();
    }, 10);
  }

  // Initialize when DOM is ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initConfig);
  } else {
    initConfig();
  }

  // --- SHARED INITIALIZATION ---
  function initBlockerMenu() {
    const menuContainer = document.getElementById('scriptconfig');
    if (!menuContainer) {
      const headerMenu = document.querySelector("ul.primary.navigation.actions");
      const searchItem = headerMenu ? headerMenu.querySelector("li.search") : null;
      if (!headerMenu || !searchItem) return;

      // Create menu container
      const newMenuContainer = document.createElement("li");
      newMenuContainer.className = "dropdown";
      newMenuContainer.id = "scriptconfig";

      const title = document.createElement("a");
      title.className = "dropdown-toggle";
      title.href = "/";
      title.setAttribute("data-toggle", "dropdown");
      title.setAttribute("data-target", "#");
      title.textContent = "Userscripts";
      newMenuContainer.appendChild(title);

      const menu = document.createElement("ul");
      menu.className = "menu dropdown-menu";
      newMenuContainer.appendChild(menu);

      // Insert before search item
      headerMenu.insertBefore(newMenuContainer, searchItem);
    }

    // Add Advanced Blocker menu item
    const menu = document.querySelector('#scriptconfig .dropdown-menu');
    if (menu) {
      const menuItem = document.createElement("li");
      const menuLink = document.createElement("a");
      menuLink.href = "javascript:void(0);";
      menuLink.id = "opencfg_advanced_blocker";
      menuLink.textContent = "Advanced Blocker";
      menuLink.addEventListener("click", showBlockerMenu);
      menuItem.appendChild(menuLink);
      menu.appendChild(menuItem);
    }
  }

  // Initialize menu when DOM is ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initBlockerMenu);
  } else {
    initBlockerMenu();
  }

  // addStyle() - Apply the custom stylesheet to AO3
  function addStyle() {
    const style = document.createElement('style');
    style.className = CSS_NAMESPACE;
    style.textContent = STYLE;
    document.head.appendChild(style);
  }

  // showBlockerMenu() - Show the settings menu
  function showBlockerMenu() {
    // Remove any existing menu using direct DOM method to avoid jQuery issues
    const existingMenus = document.querySelectorAll(`.${CSS_NAMESPACE}-menu-dialog`);
    existingMenus.forEach(menu => menu.remove());

    // Get AO3 input field background color
    let inputBg = "#fffaf5";
    const testInput = document.createElement("input");
    document.body.appendChild(testInput);
    try {
      const computedBg = window.getComputedStyle(testInput).backgroundColor;
      if (computedBg && computedBg !== "rgba(0, 0, 0, 0)" && computedBg !== "transparent") {
        inputBg = computedBg;
      }
    } catch (e) { }
    testInput.remove();

    // Load current config for the menu
    const config = loadConfig();

    // Create the dialog using vanilla DOM to avoid jQuery getElementById issues
    const dialog = document.createElement('div');
    dialog.className = `${CSS_NAMESPACE}-menu-dialog`;
    Object.assign(dialog.style, {
      position: 'fixed',
      top: '50%',
      left: '50%',
      transform: 'translate(-50%, -50%)',
      background: inputBg,
      padding: '20px',
      borderRadius: '8px',
      boxShadow: '0 0 20px rgba(0,0,0,0.2)',
      zIndex: '10000',
      width: '90%',
      maxWidth: '700px',
      maxHeight: '80vh',
      overflowY: 'auto',
      fontFamily: 'inherit',
      fontSize: 'inherit',
      color: 'inherit',
      boxSizing: 'border-box'
    });

    // --- Build the menu content ---
    dialog.innerHTML = `
      <h3 style="text-align: center; margin-top: 0; color: inherit;">🛡️ Advanced Blocker Settings 🛡️</h3>

      <!-- 1. Tag Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Tag Filtering 🔖</h4>
        <div class="setting-group">
          <label class="setting-label" for="tag-blacklist-input">Blacklist Tags</label>
          <span class="setting-description ao3-blocker-inline-help" style="display:block;">
            Matches any AO3 tag: ratings, warnings, fandoms, ships, characters, freeforms.
          </span>
          <textarea id="tag-blacklist-input" placeholder="Abandoned*, Reader, Podfic, Genderswap" title="Blocks if any tag matches. * is a wildcard.">${config.tagBlacklist}</textarea>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="tag-whitelist-input">Whitelist Tags</label>
          <span class="setting-description ao3-blocker-inline-help" style="display:block;">
            Always shows the work even if it matches the blacklist.
          </span>
          <textarea id="tag-whitelist-input" placeholder="Happy Ending, Fluff" title="Always shows the work, even if blacklisted.">${config.tagWhitelist}</textarea>
        </div>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="tag-highlights-input">Highlight Tags
              <span class="symbol question" title="Make these works stand out."><span>?</span></span>
            </label>
            <textarea id="tag-highlights-input" placeholder="Fix-It*, Enemies to Lovers" title="Keep and mark works with these tags.">${config.tagHighlights}</textarea>
          </div>
          <div class="setting-group">
            <label class="setting-label" for="highlight-color-input">Highlight Color
              <span class="symbol question" title="Pick a background color for these works."><span>?</span></span>
            </label>
            <input type="color" id="highlight-color-input" value="${config.highlightColor || "#f6e3ca"}" title="Pick the highlight color.">
          </div>
        </div>
      </div>

      <!-- 2. Primary Pairing Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Primary Pairing Filtering 💕</h4>
        <div class="setting-group">
          <label class="setting-label" for="primary-relationships-input">Primary Relationships
            <span class="symbol question" title="Only show works where these relationships are in the first few relationship tags."><span>?</span></span>
          </label>
          <textarea id="primary-relationships-input" placeholder="Luo Binghe/Shen Yuan | Shen Qingqiu, Lan Zhan | Lan Wangji/Wei Ying | Wei Wuxian" title="Case-sensitive. Comma separated.">${config.primaryRelationships}</textarea>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="primary-characters-input">Primary Characters
            <span class="symbol question" title="Only show works where these characters are in the first few character tags."><span>?</span></span>
          </label>
          <textarea id="primary-characters-input" placeholder="Shen Yuan | Shen Qingqiu, Luo Binghe" title="Case-sensitive. Comma separated.">${config.primaryCharacters}</textarea>
        </div>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="primary-relpad-input">Relationship Tag Window
              <span class="symbol question" title="Check only the first X relationship tags."><span>?</span></span>
            </label>
            <input type="number" id="primary-relpad-input" min="1" max="10" value="${config.primaryRelpad || 1}" title="Check only the first X relationship tags.">
          </div>
          <div class="setting-group">
            <label class="setting-label" for="primary-charpad-input">Character Tag Window
              <span class="symbol question" title="Check only the first X character tags."><span>?</span></span>
            </label>
            <input type="number" id="primary-charpad-input" min="1" max="10" value="${config.primaryCharpad || 5}" title="Check only the first X character tags.">
          </div>
        </div>
      </div>

      <!-- 3. Work Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Work Filtering 📝</h4>
        <div class="two-column">
          <div>
            <div class="setting-group">
              <label class="setting-label" for="allowed-languages-input">Allowed Languages
                <span class="symbol question" title="Only show these languages. Leave empty for all."><span>?</span></span>
              </label>
              <input id="allowed-languages-input" type="text"
                     placeholder="English, Русский, 中文-普通话 國語"
                     value="${config.allowedLanguages || ""}"
                     title="Only show these languages. Leave empty for all.">
            </div>
            <div class="setting-group">
              <label class="setting-label" for="min-words-input">Min Words
                <span class="symbol question" title="Hide works under this many words."><span>?</span></span>
              </label>
              <input id="min-words-input" type="text" style="width:100%;" placeholder="e.g. 1000" value="${config.minWords || ''}" title="Hide works under this many words.">
            </div>
            <div class="setting-group">
              <label class="checkbox-label" for="block-ongoing-checkbox">
                <input type="checkbox" id="block-ongoing-checkbox" ${config.blockOngoing ? "checked" : ""}>
                Block Ongoing Works
                <span class="symbol question" title="Hide works that are ongoing."><span>?</span></span>
              </label>
            </div>
          </div>
          <div>
            <div class="setting-group">
              <label class="setting-label" for="max-crossovers-input">Max Fandoms
                <span class="symbol question" title="Hide works with more than this many fandoms."><span>?</span></span>
              </label>
              <input id="max-crossovers-input" type="number" min="1" step="1"
                     value="${config.maxCrossovers || ''}"
                     title="Hide works with more than this many fandoms.">
            </div>
            <div class="setting-group">
              <label class="setting-label" for="max-words-input">Max Words
                <span class="symbol question" title="Hide works over this many words."><span>?</span></span>
              </label>
              <input id="max-words-input" type="text" style="width:100%;" placeholder="e.g. 100000" value="${config.maxWords || ''}" title="Hide works over this many words.">
            </div>
            <div class="setting-group">
              <label class="checkbox-label" for="block-complete-checkbox">
                <input type="checkbox" id="block-complete-checkbox" ${config.blockComplete ? "checked" : ""}>
                Block Complete Works
                <span class="symbol question" title="Hide works that are marked as complete."><span>?</span></span>
              </label>
            </div>
          </div>
        </div>
      </div>

      <!-- 4. Author & Content Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Author & Content Filtering ✍️</h4>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="author-blacklist-input">Blacklist Authors
              <span class="symbol question" title="Match the author name exactly. Commas or semicolons."><span>?</span></span>
            </label>
            <textarea id="author-blacklist-input" placeholder="DetectiveMittens, BlackBatCat" title="Match the author name exactly. Commas or semicolons.">${config.authorBlacklist}</textarea>
          </div>
          <div class="setting-group">
            <label class="setting-label" for="title-blacklist-input">Blacklist Titles
              <span class="symbol question" title="Blocks if the title contains your text. * works."><span>?</span></span>
            </label>
            <textarea id="title-blacklist-input" placeholder="oneshot, prompt, 2025" title="Blocks if the title contains your text. * works.">${config.titleBlacklist}</textarea>
          </div>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="summary-blacklist-input">Blacklist Summary
            <span class="symbol question" title="Blocks if the summary has these words/phrases."><span>?</span></span>
          </label>
          <textarea id="summary-blacklist-input" placeholder="oneshot, prompt, 2025" title="Blocks if the summary has these words/phrases.">${config.summaryBlacklist}</textarea>
        </div>
      </div>

      <!-- 5. Display Options -->
      <div class="settings-section">
        <h4 class="section-title">Display Options ⚙️</h4>
        <div class="two-column">
          <div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="show-reasons-checkbox" ${config.showReasons ? "checked" : ""}>
                Show Block Reason
                <span class="symbol question" title="List what triggered the block."><span>?</span></span>
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="show-placeholders-checkbox" ${config.showPlaceholders ? "checked" : ""}>
                Show Work Placeholder
                <span class="symbol question" title="Leave a stub you can click to reveal. If disabled, hides the work completely."><span>?</span></span>
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="debug-mode-checkbox" ${config.debugMode ? "checked" : ""}>
                Debug Mode
                <span class="symbol question" title="Log details to the console."><span>?</span></span>
              </label>
            </div>
          </div>
          <div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="disable-on-dashboards-checkbox" ${config.disableOnDashboards ? "checked" : ""}>
                Disable Blocking on Dashboards
                <span class="symbol question" title="Works will not be blocked on user dashboards. Highlighting still works."><span>?</span></span>
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="disable-on-bookmarks-checkbox" ${config.disableOnBookmarks ? "checked" : ""}>
                Disable Blocking on Bookmarks
                <span class="symbol question" title="Works will not be blocked on your bookmarks pages. Highlighting still works."><span>?</span></span>
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="disable-on-collections-checkbox" ${config.disableOnCollections ? "checked" : ""}>
                Disable Blocking on Collections
                <span class="symbol question" title="Works will not be blocked on collections pages. Highlighting still works."><span>?</span></span>
              </label>
            </div>
          </div>
        </div>
      </div>

      <!-- 6. Import/Export & Reset -->
      <div class="button-group">
        <button id="blocker-save">Save Settings</button>
        <button id="blocker-cancel">Cancel</button>
      </div>

      <div class="reset-link">
        <a href="#" id="resetBlockerSettingsLink">Reset to Default Settings</a>
      </div>

      <div class="reset-link" style="margin-top:18px;">
        <button id="ao3-export" style="margin-right:8px;">Export Settings</button>
        <input type="file" id="ao3-import" accept="application/json" style="display:none;">
        <button id="ao3-import-btn">Import Settings</button>
      </div>
    `;

    // --- Export Settings ---
    const exportButton = dialog.querySelector('#ao3-export');
    exportButton.addEventListener('click', function () {
      try {
        const config = loadConfig();
        const now = new Date();
        const pad = n => n.toString().padStart(2, '0');
        const yyyy = now.getFullYear();
        const mm = pad(now.getMonth() + 1);
        const dd = pad(now.getDate());
        const dateStr = `${yyyy}-${mm}-${dd}`;
        const filename = `ao3_advanced_blocker_config_${dateStr}.json`;
        const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
          document.body.removeChild(a);
          URL.revokeObjectURL(url);
        }, 100);
      } catch (e) {
        alert("Export failed: " + (e && e.message ? e.message : e));
      }
    });

    // --- Import Settings ---
    const importButton = dialog.querySelector('#ao3-import-btn');
    const importInput = dialog.querySelector('#ao3-import');
    importButton.addEventListener('click', function () {
      importInput.value = "";
      importInput.click();
    });
    importInput.addEventListener('change', function (e) {
      const file = e.target.files && e.target.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = function (evt) {
        try {
          const importedConfig = JSON.parse(evt.target.result);
          if (typeof importedConfig !== "object" || !importedConfig) throw new Error("Invalid JSON");

          // Validate and merge with defaults
          const validConfig = { ...DEFAULTS };
          Object.keys(validConfig).forEach(key => {
            if (importedConfig.hasOwnProperty(key)) {
              validConfig[key] = importedConfig[key];
            }
          });

          if (saveConfig(validConfig)) {
            alert("Settings imported! Reloading...");
            location.reload();
          } else {
            throw new Error("Failed to save imported settings");
          }
        } catch (err) {
          alert("Import failed: " + (err && err.message ? err.message : err));
        }
      };
      reader.readAsText(file);
    });

    document.body.appendChild(dialog);

    // Save button handler - Use direct DOM access to avoid jQuery getElementById issues
    const saveButton = dialog.querySelector('#blocker-save');
    saveButton.addEventListener('click', () => {
      // Collect values directly using DOM queries to avoid jQuery getElementById calls
      const config = {
        tagBlacklist: dialog.querySelector('#tag-blacklist-input').value || "",
        tagWhitelist: dialog.querySelector('#tag-whitelist-input').value || "",
        tagHighlights: dialog.querySelector('#tag-highlights-input').value || "",
        authorBlacklist: dialog.querySelector('#author-blacklist-input').value || "",
        titleBlacklist: dialog.querySelector('#title-blacklist-input').value || "",
        summaryBlacklist: dialog.querySelector('#summary-blacklist-input').value || "",
        showReasons: dialog.querySelector('#show-reasons-checkbox').checked,
        showPlaceholders: dialog.querySelector('#show-placeholders-checkbox').checked,
        debugMode: dialog.querySelector('#debug-mode-checkbox').checked,
        highlightColor: dialog.querySelector('#highlight-color-input').value || DEFAULTS.highlightColor,
        allowedLanguages: dialog.querySelector('#allowed-languages-input').value || "",
        maxCrossovers: dialog.querySelector('#max-crossovers-input').value || "",
        minWords: dialog.querySelector('#min-words-input').value || "",
        maxWords: dialog.querySelector('#max-words-input').value || "",
        blockComplete: dialog.querySelector('#block-complete-checkbox').checked,
        blockOngoing: dialog.querySelector('#block-ongoing-checkbox').checked,
        disableOnBookmarks: dialog.querySelector('#disable-on-bookmarks-checkbox').checked,
        disableOnCollections: dialog.querySelector('#disable-on-collections-checkbox').checked,
        disableOnDashboards: dialog.querySelector('#disable-on-dashboards-checkbox').checked,
        primaryRelationships: dialog.querySelector('#primary-relationships-input').value || "",
        primaryCharacters: dialog.querySelector('#primary-characters-input').value || "",
        primaryRelpad: dialog.querySelector('#primary-relpad-input').value || DEFAULTS.primaryRelpad,
        primaryCharpad: dialog.querySelector('#primary-charpad-input').value || DEFAULTS.primaryCharpad
      };

      // Save using our custom storage system
      if (saveConfig(config)) {
        // Force hard reload with cache busting
        location.href = location.href + (location.search ? '&' : '?') + 't=' + Date.now();
      } else {
        alert("Error saving settings.");
      }

      dialog.remove();
    });

    // Cancel button handler
    const cancelButton = dialog.querySelector('#blocker-cancel');
    cancelButton.addEventListener('click', () => {
      dialog.remove();
    });

    // Reset link handler
    const resetLink = dialog.querySelector('#resetBlockerSettingsLink');
    resetLink.addEventListener('click', function (e) {
      e.preventDefault();
      if (confirm("Are you sure you want to reset all settings to default?")) {
        if (saveConfig(DEFAULTS)) {
          alert("Settings reset! Reloading...");
          location.reload();
        }
      }
    });
  }

  // Blocking logic using CSS classes

  function getWordCount(workElement) {
    // Use vanilla DOM to avoid jQuery getElementById issues
    const wordsElement = workElement.querySelector('dd.words');
    if (!wordsElement) return null;

    let txt = wordsElement.textContent.trim();
    txt = txt.replace(/(?<=\d)[ ,](?=\d{3}(\D|$))/g, "");
    txt = txt.replace(/[^\d]/g, "");
    const n = parseInt(txt, 10);
    return Number.isFinite(n) ? n : null;
  }

  function violatesWordCount(cfg, count) {
    if (count == null) return null;
    if (cfg.minWords != null && count < cfg.minWords) return { over: false, limit: cfg.minWords };
    if (cfg.maxWords != null && count > cfg.maxWords) return { over: true, limit: cfg.maxWords };
    return null;
  }

  function getCut(workElement) {
    const cut = document.createElement('div');
    cut.className = `${CSS_NAMESPACE}-cut`;

    // Move all children that aren't fold or cut elements
    const children = Array.from(workElement.children);
    children.forEach(child => {
      if (!child.classList.contains(`${CSS_NAMESPACE}-fold`) &&
        !child.classList.contains(`${CSS_NAMESPACE}-cut`)) {
        cut.appendChild(child);
      }
    });

    return cut;
  }

  function getFold(reasons) {
    const fold = document.createElement('div');
    fold.className = `${CSS_NAMESPACE}-fold`;

    const note = document.createElement('span');
    note.className = `${CSS_NAMESPACE}-note`;

    let message = "";
    const config = window.ao3Blocker && window.ao3Blocker.config;
    const showReasons = config && config.showReasons !== false;
    let iconHtml = "";

    if (showReasons && reasons && reasons.length > 0) {
      const parts = [];
      reasons.forEach((reason) => {
        if (reason.completionStatus) {
          parts.push(`<em>${reason.completionStatus}</em>`);
        }
        if (reason.wordCount) {
          parts.push(`<em>${reason.wordCount}</em>`);
        }
        if (reason.tags && reason.tags.length > 0) {
          parts.push(`<em>Tags: ${reason.tags.join(", ")}</em>`);
        }
        if (reason.authors && reason.authors.length > 0) {
          parts.push(`<em>Author: ${reason.authors.join(", ")}</em>`);
        }
        if (reason.titles && reason.titles.length > 0) {
          parts.push(`<em>Title: ${reason.titles.join(", ")}</em>`);
        }
        if (reason.summaryTerms && reason.summaryTerms.length > 0) {
          parts.push(`<em>Summary: ${reason.summaryTerms.join(", ")}</em>`);
        }
        if (reason.language) {
          parts.push(`<em>Language: ${reason.language}</em>`);
        }
        if (reason.crossovers !== undefined) {
          const max = (window.ao3Blocker && window.ao3Blocker.config && window.ao3Blocker.config.maxCrossovers) || 0;
          parts.push(`<em>Too many fandoms: ${reason.crossovers} &gt; ${max}</em>`);
        }
        if (reason.primaryPairing) {
          parts.push(`<em>${reason.primaryPairing}</em>`);
        }
      });
      message = parts.join('; ');
      const iconUrl = "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg";
      iconHtml = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconUrl}') no-repeat center/contain;-webkit-mask:url('${iconUrl}') no-repeat center/contain;"></span>`;
    }

    note.innerHTML = `${iconHtml}${message}`;
    fold.appendChild(note);
    fold.appendChild(getToggleButton());

    return fold;
  }

  function getToggleButton() {
    const iconHide = "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg";
    const iconEye = "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-visible.svg";
    const showIcon = `<span style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.2em;background-color:currentColor;mask:url('${iconEye}') no-repeat center/contain;-webkit-mask:url('${iconEye}') no-repeat center/contain;"></span>`;
    const hideIcon = `<span style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.2em;background-color:currentColor;mask:url('${iconHide}') no-repeat center/contain;-webkit-mask:url('${iconHide}') no-repeat center/contain;"></span>`;

    const button = document.createElement('button');
    button.className = `${CSS_NAMESPACE}-toggle`;
    button.innerHTML = showIcon + "Show";

    const unhideClassFragment = `${CSS_NAMESPACE}-unhide`;

    button.addEventListener("click", (event) => {
      const work = event.target.closest(`.${CSS_NAMESPACE}-work`);
      const note = work.querySelector(`.${CSS_NAMESPACE}-note`);
      let message = note.innerHTML;
      const iconRegex = new RegExp('<span[^>]*class=["\']' + CSS_NAMESPACE + '-icon["\'][^>]*><\\/span>\\s*', 'i');
      message = message.replace(iconRegex, "");

      if (work.classList.contains(unhideClassFragment)) {
        work.classList.remove(unhideClassFragment);
        note.innerHTML = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconHide}') no-repeat center/contain;-webkit-mask:url('${iconHide}') no-repeat center/contain;"></span>${message}`;
        event.target.innerHTML = showIcon + "Show";
      } else {
        work.classList.add(unhideClassFragment);
        note.innerHTML = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconEye}') no-repeat center/contain;-webkit-mask:url('${iconEye}') no-repeat center/contain;"></span>${message}`;
        event.target.innerHTML = hideIcon + "Hide";
      }
    });

    return button;
  }

  function getReasonSpan(reasons) {
    const span = document.createElement('span');
    span.className = `${CSS_NAMESPACE}-reason`;

    if (!reasons || reasons.length === 0) {
      return span;
    }

    const reasonTexts = [];

    reasons.forEach((reason) => {
      if (reason.completionStatus) {
        reasonTexts.push(reason.completionStatus);
      }
      if (reason.wordCount) {
        reasonTexts.push(reason.wordCount);
      }
      if (reason.tags) {
        if (reason.tags.length === 1) {
          reasonTexts.push(`tags include <strong>${reason.tags[0]}</strong>`);
        } else {
          const tagList = reason.tags.map(tag => `<strong>${tag}</strong>`).join(', ');
          reasonTexts.push(`tags include ${tagList}`);
        }
      }
      if (reason.authors) {
        if (reason.authors.length === 1) {
          reasonTexts.push(`author is <strong>${reason.authors[0]}</strong>`);
        } else {
          const authorList = reason.authors.map(author => `<strong>${author}</strong>`).join(', ');
          reasonTexts.push(`authors include ${authorList}`);
        }
      }
      if (reason.titles) {
        if (reason.titles.length === 1) {
          reasonTexts.push(`title matches <strong>${reason.titles[0]}</strong>`);
        } else {
          const titleList = reason.titles.map(title => `<strong>${title}</strong>`).join(', ');
          reasonTexts.push(`title matches ${titleList}`);
        }
      }
      if (reason.summaryTerms) {
        if (reason.summaryTerms.length === 1) {
          reasonTexts.push(`summary includes <strong>${reason.summaryTerms[0]}</strong>`);
        } else {
          const termList = reason.summaryTerms.map(term => `<strong>${term}</strong>`).join(', ');
          reasonTexts.push(`summary includes ${termList}`);
        }
      }
      if (reason.language) {
        reasonTexts.push(`language is <strong>${reason.language}</strong>`);
      }
      if (reason.crossovers !== undefined) {
        const max = (window.ao3Blocker && window.ao3Blocker.config && window.ao3Blocker.config.maxCrossovers) || 0;
        reasonTexts.push(`too many fandoms: <strong>${reason.crossovers} &gt; ${max}</strong>`);
      }
      if (reason.primaryPairing) {
        reasonTexts.push(`<strong>${reason.primaryPairing}</strong>`);
      }
    });

    if (reasonTexts.length > 0) {
      const reasonText = reasonTexts.join('; ');
      span.innerHTML = `(Reason: ${reasonText}.)`;
    }

    return span;
  }

  function blockWork(workElement, reasons, config) {
    if (!reasons) return;

    if (config.showPlaceholders) {
      const fold = getFold(reasons);
      const cut = getCut(workElement);

      workElement.classList.add(`${CSS_NAMESPACE}-work`);
      workElement.innerHTML = '';
      workElement.appendChild(fold);
      workElement.appendChild(cut);

      if (!config.showReasons) {
        workElement.classList.add(`${CSS_NAMESPACE}-hide-reasons`);
      }
    } else {
      workElement.classList.add(`${CSS_NAMESPACE}-hidden`);
    }
  }

  // Normalize text by removing punctuation and standardizing whitespace
  function normalizeText(text) {
    return text.toLowerCase()
      .replace(/[^\w\s]/g, ' ')  // Replace punctuation with spaces
      .replace(/\s+/g, ' ')      // Normalize multiple spaces
      .trim();
  }

  // Fast pattern matching with pre-compiled regex for wildcards
  function matchPattern(text, pattern, exactMatch = false) {
    const normalizedText = normalizeText(text);

    // If it's a simple string pattern
    if (typeof pattern === 'string') {
      return exactMatch ? normalizedText === pattern : normalizedText.includes(pattern);
    }

    // If it's a compiled pattern object
    if (!pattern.hasWildcard) {
      return exactMatch ? normalizedText === pattern.text : normalizedText.includes(pattern.text);
    }

    // Use pre-compiled regex for wildcards
    if (exactMatch) {
      // For exact matching with wildcards, the regex should match the entire string
      const exactRegex = new RegExp('^' + pattern.regex.source + '$', 'i');
      return exactRegex.test(normalizedText);
    }

    return pattern.regex.test(normalizedText);
  }

  function isTagWhitelisted(tags, whitelist) {
    return tags.some((tag) => {
      const normalizedTag = tag.toLowerCase().trim();

      return whitelist.some((pattern) => {
        if ((typeof pattern === 'string' && !pattern.trim()) || (pattern && pattern.text && !pattern.text.trim())) return false;

        return matchPattern(normalizedTag, pattern, true); // Use exact matching for tags
      });
    });
  }

  // Check if work matches primary relationship/character requirements
  function checkPrimaryPairing(categorizedTags, config) {
    const primaryRelationships = config.primaryRelationships || [];
    const primaryCharacters = config.primaryCharacters || [];
    const relpad = config.primaryRelpad || 1;
    const charpad = config.primaryCharpad || 5;

    // If no primary pairing settings, skip check
    if (primaryRelationships.length === 0 && primaryCharacters.length === 0) {
      return null;
    }

    // Get relationship and character tags from categorized data
    const relationshipTags = categorizedTags.relationships.slice(0, relpad);
    const characterTags = categorizedTags.characters.slice(0, charpad);

    let missingRelationships = [];
    let missingCharacters = [];

    // Check relationships - OR logic: any match passes
    if (primaryRelationships.length > 0) {
      const hasPrimaryRelationship = primaryRelationships.some(rel =>
        relationshipTags.includes(rel)
      );
      if (!hasPrimaryRelationship) {
        missingRelationships = primaryRelationships;
      }
    }

    // Check characters - OR logic: any match passes
    if (primaryCharacters.length > 0) {
      const hasPrimaryCharacter = primaryCharacters.some(char =>
        characterTags.includes(char)
      );
      if (!hasPrimaryCharacter) {
        missingCharacters = primaryCharacters;
      }
    }

    // If both are missing, create combined reason
    if (missingRelationships.length > 0 && missingCharacters.length > 0) {
      return {
        primaryPairing: `Missing primary relationship(s) and character(s)`
      };
    } else if (missingRelationships.length > 0) {
      return {
        primaryPairing: `Missing primary relationship(s)`
      };
    } else if (missingCharacters.length > 0) {
      return {
        primaryPairing: `Missing primary character(s)`
      };
    }

    return null;
  }

  // Determine blocking reasons for a work based on all criteria
  function getBlockReason(_ref, _ref2) {
    const completionStatus = _ref.completionStatus;

    const authors = _ref.authors === undefined ? [] : _ref.authors,
      title = _ref.title === undefined ? "" : _ref.title,
      categorizedTags = _ref.categorizedTags === undefined ? { relationships: [], characters: [], freeforms: [], ratings: [], warnings: [], categories: [], fandoms: [] } : _ref.categorizedTags,
      summary = _ref.summary === undefined ? "" : _ref.summary,
      language = _ref.language === undefined ? "" : _ref.language,
      fandomCount = _ref.fandomCount === undefined ? 0 : _ref.fandomCount,
      wordCount = _ref.wordCount === undefined ? null : _ref.wordCount;
    const authorBlacklist = _ref2.authorBlacklist === undefined ? [] : _ref2.authorBlacklist,
      titleBlacklist = _ref2.titleBlacklist === undefined ? [] : _ref2.titleBlacklist,
      tagBlacklist = _ref2.tagBlacklist === undefined ? [] : _ref2.tagBlacklist,
      tagWhitelist = _ref2.tagWhitelist === undefined ? [] : _ref2.tagWhitelist,
      summaryBlacklist = _ref2.summaryBlacklist === undefined ? [] : _ref2.summaryBlacklist,
      allowedLanguages = _ref2.allowedLanguages === undefined ? [] : _ref2.allowedLanguages,
      maxCrossovers = _ref2.maxCrossovers === undefined ? 0 : _ref2.maxCrossovers,
      minWords = _ref2.minWords === undefined ? null : _ref2.minWords,
      maxWords = _ref2.maxWords === undefined ? null : _ref2.maxWords;
    const blockComplete = _ref2.blockComplete === undefined ? false : _ref2.blockComplete;
    const blockOngoing = _ref2.blockOngoing === undefined ? false : _ref2.blockOngoing;

    // Get flat array of all tags for blacklist/whitelist (same behavior as before)
    const allTags = getAllTagsFlat(categorizedTags);

    // If whitelisted, don't block regardless of other conditions
    if (isTagWhitelisted(allTags, tagWhitelist)) {
      return null;
    }

    const reasons = [];

    // Primary Pairing Check (uses categorized tags) - OR logic
    const primaryPairingReason = checkPrimaryPairing(categorizedTags, _ref2);
    if (primaryPairingReason) {
      reasons.push(primaryPairingReason);
    }

    // Completion status filter
    if (blockComplete && completionStatus === 'complete') {
      reasons.push({ completionStatus: 'Status: Complete' });
    }
    if (blockOngoing && completionStatus === 'ongoing') {
      reasons.push({ completionStatus: 'Status: Ongoing' });
    }

    // Language allowlist: if set and work language not included, block
    if (allowedLanguages.length > 0) {
      const lang = (language || "").toLowerCase().trim();
      const allowed = allowedLanguages.includes(lang);
      if (!allowed) {
        reasons.push({ language: language || "unknown" });  // Use the original text for display
      }
    }

    // Max crossovers: if set and fandomCount exceeds, block
    if (typeof maxCrossovers === 'number' && maxCrossovers > 0 && fandomCount > maxCrossovers) {
      reasons.push({ crossovers: fandomCount });
    }

    // Word count filter (after whitelist check, before other reasons)
    if (minWords != null || maxWords != null) {
      const wc = wordCount;
      const wcHit = (function () {
        if (wc == null) return null;
        if (minWords != null && wc < minWords) return { over: false, limit: minWords };
        if (maxWords != null && wc > maxWords) return { over: true, limit: maxWords };
        return null;
      })();
      if (wcHit) {
        const wcStr = wc?.toLocaleString?.() ?? wc;
        const limStr = wcHit.limit?.toLocaleString?.() ?? wcHit.limit;
        reasons.push({ wordCount: `Words: ${wcStr} ${wcHit.over ? '>' : '<'} ${limStr}` });
      }
    }

    // Check for blocked tags (collect all matching tags) - uses flat array
    const blockedTags = [];
    allTags.forEach((tag) => {
      tagBlacklist.forEach((pattern) => {
        if ((typeof pattern === 'string' && pattern.trim()) || (pattern && pattern.text && pattern.text.trim())) {
          const normalizedTag = tag.toLowerCase().trim();

          if (matchPattern(normalizedTag, pattern, true)) { // Use exact matching for tags
            blockedTags.push(tag);
          }
        }
      });
    });
    if (blockedTags.length > 0) {
      reasons.push({ tags: blockedTags });
    }

    // Check for blocked authors (collect all matching authors)
    const blockedAuthors = [];
    authors.forEach((author) => {
      authorBlacklist.forEach((blacklistedAuthor) => {
        if (blacklistedAuthor.trim() && author.toLowerCase() === blacklistedAuthor.toLowerCase()) {
          blockedAuthors.push(blacklistedAuthor);
        }
      });
    });
    if (blockedAuthors.length > 0) {
      reasons.push({ authors: blockedAuthors });
    }

    // Check for blocked title
    const blockedTitles = new Set();
    titleBlacklist.forEach((pattern) => {
      if ((typeof pattern === 'string' && pattern.trim()) || (pattern && pattern.text && pattern.text.trim())) {
        if (matchPattern(title, pattern, false)) { // Use substring matching for titles
          blockedTitles.add(title);
        }
      }
    });
    if (blockedTitles.size > 0) {
      reasons.push({ titles: Array.from(blockedTitles) });
    }

    // Check for blocked summary terms
    const blockedSummaryTerms = [];
    summaryBlacklist.forEach((pattern) => {
      if ((typeof pattern === 'string' && pattern.trim()) || (pattern && pattern.text && pattern.text.trim())) {
        if (matchPattern(summary, pattern, false)) { // Use substring matching for summaries
          // Use the original pattern text for display
          const displayTerm = (typeof pattern === 'string' ? pattern : pattern.text).replace(/\*/g, '');
          blockedSummaryTerms.push(displayTerm);
        }
      }
    });
    if (blockedSummaryTerms.length > 0) {
      reasons.push({ summaryTerms: blockedSummaryTerms });
    }

    // Helper function to find the actual matching text
    function findMatchingText(text, pattern) {
      if (pattern.indexOf('*') === -1) {
        // Exact match - return the pattern since it matches exactly
        return pattern;
      }

      // For wildcards, this is complex - we'd need regex matching
      // For now, return the pattern as fallback
      return pattern;
    }

    return reasons.length > 0 ? reasons : null;
  }

  const _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };

  function getText(element) {
    return (element.textContent || element.innerText || '').trim();
  }
  function selectTextsIn(root, selector) {
    const elements = root.querySelectorAll(selector);
    return Array.from(elements).map(getText);
  }

  // Extract work data from blurb elements
  function selectFromBlurb(blurbElement) {
    const fandoms = blurbElement.querySelectorAll('h5.fandoms.heading a.tag');

    // Get completion status using the same unified parsing
    let completionStatus = null;
    const chaptersNode = blurbElement.querySelector('dd.chapters');
    if (chaptersNode) {
      let chaptersText = "";
      const a = chaptersNode.querySelector('a');
      if (a) {
        // Blurb with link format
        chaptersText = a.textContent.trim();
        let raw = chaptersNode.innerHTML;
        raw = raw.replace(/<a[^>]*>.*?<\/a>/, '');
        raw = raw.replace(/&nbsp;/gi, ' ');
        const match = raw.match(/\/\s*([\d\?]+)/);
        if (match) {
          chaptersText += '/' + match[1].trim();
        }
      } else {
        // Simple blurb format
        chaptersText = chaptersNode.textContent.replace(/&nbsp;/gi, ' ').trim();
      }
      completionStatus = parseChaptersStatus(chaptersText);
    }

    // Use CSS class-based tag categorization
    const categorizedTags = getCategorizedTags(blurbElement);

    return {
      authors: selectTextsIn(blurbElement, "a[rel=author]"),
      categorizedTags: categorizedTags,
      tags: getAllTagsFlat(categorizedTags),
      title: selectTextsIn(blurbElement, ".header .heading a:first-child")[0],
      summary: selectTextsIn(blurbElement, "blockquote.summary")[0],
      language: selectTextsIn(blurbElement, "dd.language")[0],
      fandomCount: fandoms.length,
      wordCount: getWordCount(blurbElement),
      completionStatus: completionStatus
    };
  }

  function checkWorks() {
    // ...existing code...
    let path = window.location.pathname;
    let isUserDashboard = (
      /^\/users\/[^\/]+\/?$/.test(path) && !/^\/users\/[^\/]+\/.+/.test(path)
    ) || (
        /^\/users\/[^\/]+\/pseuds\/[^\/]+\/?$/.test(path) && !/^\/users\/[^\/]+\/pseuds\/[^\/]+\/.+/.test(path)
      );
    if (window.ao3Blocker && window.ao3Blocker.config && window.ao3Blocker.config.debugMode) {
      console.log('[AO3 Blocker] Dashboard detection:', path, 'isUserDashboard:', isUserDashboard, 'disableOnDashboards:', window.ao3Blocker.config.disableOnDashboards);
    }
    const debugMode = window.ao3Blocker.config.debugMode;
    const config = window.ao3Blocker.config;
    let blocked = 0;
    let total = 0;

    if (debugMode) {
      console.groupCollapsed("Advanced Blocker");
      if (!config) {
        console.warn("Exiting due to missing config.");
        return;
      }
    }

    // Optionally exclude user dashboard only
    // path and isUserDashboard already declared above
    // Always read from config object
    const disableOnDashboards = !!(window.ao3Blocker.config.disableOnDashboards !== undefined ? window.ao3Blocker.config.disableOnDashboards : false);
    if (disableOnDashboards && isUserDashboard) {
      if (debugMode) {
        console.info("Advanced Blocker: Skipping user dashboard page.");
      }
      return;
    }

    const isBookmarksPage = /\/users\/[^\/]+\/bookmarks(\/|$)/.test(window.location.pathname);
    const isCollectionsPage = /\/collections\/[^\/]+(\/|$)/.test(window.location.pathname);
    const disableOnBookmarks = !!config.disableOnBookmarks;
    const disableOnCollections = !!config.disableOnCollections;

    // Skip all blocking/highlighting if dashboard option is enabled and page is dashboard
    if (disableOnDashboards && isUserDashboard) {
      if (debugMode) {
        console.info("Advanced Blocker: Skipping all blocking/highlighting on user dashboard page.");
      }
      return;
    }

    const blurbs = document.querySelectorAll("li.blurb");
    blurbs.forEach((blurbEl) => {
      const isWorkOrBookmark = (blurbEl.classList.contains("work") || blurbEl.classList.contains("bookmark")) && !blurbEl.classList.contains("picture");
      let reason = null;
      let blockables = selectFromBlurb(blurbEl);

      if (debugMode && isWorkOrBookmark) {
        console.log(`[Advanced Blocker][DEBUG] Work ID: ${blurbEl.id || "(no id)"}`);
        console.log(`[Advanced Blocker][DEBUG] Parsed completionStatus:`, blockables.completionStatus);
        console.log(`[Advanced Blocker][DEBUG] blockComplete:`, config.blockComplete, `blockOngoing:`, config.blockOngoing);
        console.log(`[Advanced Blocker][DEBUG] All blockables:`, blockables);
      }

      if (isWorkOrBookmark && !((isBookmarksPage && disableOnBookmarks) || (isCollectionsPage && disableOnCollections))) {
        reason = getBlockReason(blockables, config);
        total++;
      }

      if (reason) {
        blockWork(blurbEl, reason, config);
        blocked++;
        if (debugMode) {
          console.groupCollapsed(`- blocked ${blurbEl.id}`);
          console.log(blurbEl.innerHTML, reason);
          console.groupEnd();
        }
      } else if (debugMode && isWorkOrBookmark) {
        console.groupCollapsed(`  skipped ${blurbEl.id}`);
        console.log(blurbEl.innerHTML);
        console.groupEnd();
      }

      // Highlighting uses exact tag matching
      const allTags = blockables.tags || getAllTagsFlat(blockables.categorizedTags || {});
      allTags.forEach((tag) => {
        // Use exact matching for tag highlights - compare normalized tag to normalized highlight patterns
        const normalizedTag = tag.toLowerCase().trim();
        if (config.tagHighlights.some(highlightPattern => normalizedTag === highlightPattern)) {
          blurbEl.classList.add("ao3-blocker-highlight");
          const color = config.highlightColor || '#f6e3ca';
          blurbEl.style.cssText += `;background-color:${color} !important;`;
          if (blurbEl.id && blurbEl.id.trim() !== "") {
            const styleId = 'ao3-blocker-style-' + blurbEl.id;
            if (!document.getElementById(styleId)) {
              const style = document.createElement('style');
              style.id = styleId;
              style.textContent = `#${blurbEl.id}.ao3-blocker-highlight { background-color: ${color} !important; }`;
              document.head.appendChild(style);
            }
          }
          if (debugMode) {
            console.groupCollapsed(`? highlighted ${blurbEl.id}`);
            console.log(blurbEl.innerHTML);
            console.groupEnd();
          }
        }
      });
    });

    if (debugMode) {
      console.log(`Blocked ${blocked} out of ${total} works`);
      console.groupEnd();
    }
  }
}());