// ==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 // @namespace // @license MIT // @match http*://archiveofourown.org/* // @version 1.5 // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js // @grant GM.getValue // @grant GM.setValue // @run-at document-end // @namespace // @downloadURL none // ==/UserScript== /* globals $, GM_config */ ;(function () { "use strict"; window.ao3Blocker = {}; // Startup message try { console.log("[AO3: Advanced Blocker] loaded."); } catch (e) {} // Define the CSS namespace. All CSS classes are prefixed with this. const CSS_NAMESPACE = "ao3-blocker"; // Define default configuration values 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, primaryRelationships: "", primaryCharacters: "", primaryRelpad: "1", primaryCharpad: "5" }; // Storage key for single config object const STORAGE_KEY = "ao3_advanced_blocker_config"; // Define the 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: 900px; 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); if (stored) { const parsed = JSON.parse(stored); return {...DEFAULTS, ...parsed}; } } 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 GM_config with custom storage handlers GM_config.init({ "id": "ao3Blocker", "title": "Advanced Blocker", "fields": { "tagBlacklist": { "label": "Tag Blacklist", "type": "text", "default": DEFAULTS.tagBlacklist }, "tagWhitelist": { "label": "Tag Whitelist", "type": "text", "default": DEFAULTS.tagWhitelist }, "tagHighlights": { "label": "Highlighted Tags", "type": "text", "default": DEFAULTS.tagHighlights }, "highlightColor": { "label": "Highlight Color", "type": "text", "default": DEFAULTS.highlightColor }, "minWords": { "label": "Min Words", "title": "Hide works under this many words. Leave empty to ignore.", "type": "text", "default": DEFAULTS.minWords }, "maxWords": { "label": "Max Words", "title": "Hide works over this many words. Leave empty to ignore.", "type": "text", "default": DEFAULTS.maxWords }, "blockComplete": { "label": "Block Complete Works", "type": "checkbox", "default": DEFAULTS.blockComplete }, "blockOngoing": { "label": "Block Ongoing Works", "type": "checkbox", "default": DEFAULTS.blockOngoing }, "authorBlacklist": { "label": "Author Blacklist", "type": "text", "default": DEFAULTS.authorBlacklist }, "titleBlacklist": { "label": "Title Blacklist", "type": "text", "default": DEFAULTS.titleBlacklist }, "summaryBlacklist": { "label": "Summary Blacklist", "type": "text", "default": DEFAULTS.summaryBlacklist }, "showReasons": { "label": "Show Block Reason", "type": "checkbox", "default": DEFAULTS.showReasons }, "showPlaceholders": { "label": "Show Work Placeholder", "type": "checkbox", "default": DEFAULTS.showPlaceholders }, "debugMode": { "label": "Debug Mode", "type": "checkbox", "default": DEFAULTS.debugMode }, "allowedLanguages": { "label": "Allowed Languages (show only these; empty = allow all)", "type": "text", "default": DEFAULTS.allowedLanguages }, "maxCrossovers": { "label": "Max Fandoms (crossovers)", "type": "text", "default": DEFAULTS.maxCrossovers }, "disableOnBookmarks": { "label": "Disable Blocking on Bookmarks Pages", "type": "checkbox", "default": DEFAULTS.disableOnBookmarks }, "disableOnCollections": { "label": "Disable Blocking on Collections Pages", "type": "checkbox", "default": DEFAULTS.disableOnCollections }, "primaryRelationships": { "label": "Primary Relationships", "type": "text", "default": DEFAULTS.primaryRelationships }, "primaryCharacters": { "label": "Primary Characters", "type": "text", "default": DEFAULTS.primaryCharacters }, "primaryRelpad": { "label": "Relationship Tag Window", "type": "text", "default": DEFAULTS.primaryRelpad }, "primaryCharpad": { "label": "Character Tag Window", "type": "text", "default": DEFAULTS.primaryCharpad } }, "events": { "open": function() { // Load current settings into the configuration dialog const config = loadConfig(); Object.keys(config).forEach(key => { if (GM_config.fields[key]) { GM_config.set(key, config[key]); } }); }, "save": function() { // Save settings and reload page to apply changes const config = {}; Object.keys(GM_config.fields).forEach(key => { config[key] = GM_config.get(key); }); if (saveConfig(config)) { // Force hard reload with cache busting location.href = location.href + (location.search ? '&' : '?') + 't=' + Date.now(); } else { alert("Error saving settings."); } }, "close": function() { // Dialog closed without saving - no action needed }, "init": function() { // Config is now available const config = loadConfig(); // Process configuration for runtime use window.ao3Blocker.config = { "showReasons": config.showReasons, "showPlaceholders": config.showPlaceholders, "authorBlacklist": config.authorBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "titleBlacklist": config.titleBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "tagBlacklist": config.tagBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "tagWhitelist": config.tagWhitelist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "tagHighlights": config.tagHighlights.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "summaryBlacklist": config.summaryBlacklist.toLowerCase().split(/,(?:\s)?/g).map(i => i.trim()), "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); })() } addStyle(); setTimeout(() => { // Set the highlight color CSS variable globally document.documentElement.style.setProperty('--ao3-blocker-highlight-color', window.ao3Blocker.config.highlightColor || '#f6e3ca'); checkWorks(); }, 10); } }, "css": ".config_var {display: grid; grid-template-columns: repeat(2, 0.7fr);}" }); // --- 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 = $(``).html(STYLE); $("head").append(style); } // showBlockerMenu() - Show the improved settings menu function showBlockerMenu() { // Remove any existing menu $(`.${CSS_NAMESPACE}-menu-dialog`).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 const dialog = $(`
`); dialog.css({ 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: '900px', maxHeight: '80vh', overflowY: 'auto', fontFamily: 'inherit', fontSize: 'inherit', color: 'inherit', boxSizing: 'border-box' }); // --- Build the menu content --- dialog.html(`

๐Ÿ›ก๏ธ Advanced Blocker Settings ๐Ÿ›ก๏ธ

Tag Filtering ๐Ÿ”–

Matches any AO3 tag: ratings, warnings, fandoms, ships, characters, freeforms.
Always shows the work even if it matches the blacklist.

Primary Pairing Filtering ๐Ÿ’•

Work Filtering ๐Ÿ“

Author & Content Filtering โœ๏ธ

Display Options โš™๏ธ

`); // --- Export Settings --- dialog.find("#ao3-export").on("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 --- dialog.find("#ao3-import-btn").on("click", function() { dialog.find("#ao3-import").val(""); dialog.find("#ao3-import").trigger("click"); }); dialog.find("#ao3-import").on("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); }); $("body").append(dialog); // Save button handler - Use GM_config to save dialog.find("#blocker-save").on("click", () => { // Set values in GM_config which will trigger the save event GM_config.set("tagBlacklist", dialog.find("#tag-blacklist-input").val()); GM_config.set("tagWhitelist", dialog.find("#tag-whitelist-input").val()); GM_config.set("tagHighlights", dialog.find("#tag-highlights-input").val()); GM_config.set("authorBlacklist", dialog.find("#author-blacklist-input").val()); GM_config.set("titleBlacklist", dialog.find("#title-blacklist-input").val()); GM_config.set("summaryBlacklist", dialog.find("#summary-blacklist-input").val()); GM_config.set("showReasons", dialog.find("#show-reasons-checkbox").is(":checked")); GM_config.set("showPlaceholders", dialog.find("#show-placeholders-checkbox").is(":checked")); GM_config.set("debugMode", dialog.find("#debug-mode-checkbox").is(":checked")); GM_config.set("highlightColor", dialog.find("#highlight-color-input").val()); GM_config.set("allowedLanguages", dialog.find("#allowed-languages-input").val()); GM_config.set("maxCrossovers", dialog.find("#max-crossovers-input").val()); GM_config.set("minWords", dialog.find("#min-words-input").val()); GM_config.set("maxWords", dialog.find("#max-words-input").val()); GM_config.set("blockComplete", dialog.find("#block-complete-checkbox").is(":checked")); GM_config.set("blockOngoing", dialog.find("#block-ongoing-checkbox").is(":checked")); GM_config.set("disableOnBookmarks", dialog.find("#disable-on-bookmarks-checkbox").is(":checked")); GM_config.set("disableOnCollections", dialog.find("#disable-on-collections-checkbox").is(":checked")); // Primary Pairing Settings GM_config.set("primaryRelationships", dialog.find("#primary-relationships-input").val()); GM_config.set("primaryCharacters", dialog.find("#primary-characters-input").val()); GM_config.set("primaryRelpad", dialog.find("#primary-relpad-input").val()); GM_config.set("primaryCharpad", dialog.find("#primary-charpad-input").val()); window.ao3Blocker.showHelp = false; GM_config.save(); // This will trigger our custom save event dialog.remove(); }); // Cancel button handler dialog.find("#blocker-cancel").on("click", () => { dialog.remove(); }); // Reset link handler dialog.find("#resetBlockerSettingsLink").on("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(); } } }); } // === UPDATED BLOCKING LOGIC WITH CSS CLASSES === function getWordCount($work) { let txt = $work.find("dd.words").first().text().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(work) { const cut = $(`
`); work.children().each(function () { const $child = $(this); if ( !$child.hasClass(`${CSS_NAMESPACE}-fold`) && !$child.hasClass(`${CSS_NAMESPACE}-cut`) ) { cut.append($child.detach()); } }); return cut; } function getFold(reasons) { const fold = $(`
`); const 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(`${reason.completionStatus}`); } if (reason.wordCount) { parts.push(`${reason.wordCount}`); } if (reason.tags && reason.tags.length > 0) { parts.push(`Tags: ${reason.tags.join(", ")}`); } if (reason.authors && reason.authors.length > 0) { parts.push(`Author: ${reason.authors.join(", ")}`); } if (reason.titles && reason.titles.length > 0) { parts.push(`Title: ${reason.titles.join(", ")}`); } if (reason.summaryTerms && reason.summaryTerms.length > 0) { parts.push(`Summary: ${reason.summaryTerms.join(", ")}`); } if (reason.language) { parts.push(`Language: ${reason.language}`); } if (reason.crossovers !== undefined) { const max = (window.ao3Blocker && window.ao3Blocker.config && window.ao3Blocker.config.maxCrossovers) || 0; parts.push(`Too many fandoms: ${reason.crossovers} > ${max}`); } if (reason.primaryPairing) { parts.push(`${reason.primaryPairing}`); } }); message = parts.join('; '); const iconUrl = "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg"; iconHtml = ``; } note.html(`${iconHtml}${message}`); fold.html(note); fold.append(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 = ``; const hideIcon = ``; const button = $(``).html(showIcon + "Show"); const unhideClassFragment = `${CSS_NAMESPACE}-unhide`; button.on("click", (event) => { const work = $(event.target).closest(`.${CSS_NAMESPACE}-work`); const note = work.find(`.${CSS_NAMESPACE}-note`); let message = note.html(); const iconRegex = new RegExp(']*class=["\']' + CSS_NAMESPACE + '-icon["\'][^>]*><\\/span>\\s*', 'i'); message = message.replace(iconRegex, ""); if (work.hasClass(unhideClassFragment)) { work.removeClass(unhideClassFragment); note.html(`${message}`); $(event.target).html(showIcon + "Show"); } else { work.addClass(unhideClassFragment); note.html(`${message}`); $(event.target).html(hideIcon + "Hide"); } }); return button; } function getReasonSpan(reasons) { const span = $(``); 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 ${reason.tags[0]}`); } else { const tagList = reason.tags.map(tag => `${tag}`).join(', '); reasonTexts.push(`tags include ${tagList}`); } } if (reason.authors) { if (reason.authors.length === 1) { reasonTexts.push(`author is ${reason.authors[0]}`); } else { const authorList = reason.authors.map(author => `${author}`).join(', '); reasonTexts.push(`authors include ${authorList}`); } } if (reason.titles) { if (reason.titles.length === 1) { reasonTexts.push(`title matches ${reason.titles[0]}`); } else { const titleList = reason.titles.map(title => `${title}`).join(', '); reasonTexts.push(`title matches ${titleList}`); } } if (reason.summaryTerms) { if (reason.summaryTerms.length === 1) { reasonTexts.push(`summary includes ${reason.summaryTerms[0]}`); } else { const termList = reason.summaryTerms.map(term => `${term}`).join(', '); reasonTexts.push(`summary includes ${termList}`); } } if (reason.language) { reasonTexts.push(`language is ${reason.language}`); } if (reason.crossovers !== undefined) { const max = (window.ao3Blocker && window.ao3Blocker.config && window.ao3Blocker.config.maxCrossovers) || 0; reasonTexts.push(`too many fandoms: ${reason.crossovers} > ${max}`); } if (reason.primaryPairing) { reasonTexts.push(`${reason.primaryPairing}`); } }); if (reasonTexts.length > 0) { const reasonText = reasonTexts.join('; '); span.html(`(Reason: ${reasonText}.)`); } return span; } function blockWork(work, reasons, config) { if (!reasons) return; if (config.showPlaceholders) { const fold = getFold(reasons); const cut = getCut(work); work.addClass(`${CSS_NAMESPACE}-work`); work.html(fold); work.append(cut); if (!config.showReasons) { work.addClass(`${CSS_NAMESPACE}-hide-reasons`); } } else { work.addClass(`${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(); } // Match text with wildcard support and normalization function matchTermsWithWildCard(term0, pattern0) { const term = normalizeText(term0); const pattern = normalizeText(pattern0); // If no wildcard, do partial substring matching if (pattern.indexOf("*") === -1) { return term.includes(pattern); } // If wildcard present, use existing wildcard logic const lastMatchedIndex = pattern.split("*").filter(Boolean).reduce((prevIndex, chunk) => { const matchedIndex = term.indexOf(chunk); return prevIndex >= 0 && prevIndex <= matchedIndex ? matchedIndex : -1; }, 0); return lastMatchedIndex >= 0; } function isTagWhitelisted(tags, whitelist) { return tags.some((tag) => { const normalizedTag = tag.toLowerCase().trim(); return whitelist.some((whitelistedTag) => { if (!whitelistedTag.trim()) return false; const normalizedWhitelist = whitelistedTag.toLowerCase().trim(); // If wildcard present, use wildcard logic; otherwise use exact matching if (normalizedWhitelist.indexOf("*") !== -1) { // Use the same wildcard matching as the blacklist return matchTermsWithWildCard(normalizedTag, normalizedWhitelist); } else { // No wildcard - use exact matching return normalizedTag === normalizedWhitelist; } }); }); } // 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((blacklistedTag) => { if (blacklistedTag.trim()) { const normalizedTag = tag.toLowerCase().trim(); const normalizedBlacklist = blacklistedTag.toLowerCase().trim(); // If wildcard present, use wildcard logic; otherwise use exact matching if (normalizedBlacklist.indexOf("*") !== -1) { // Use wildcard matching if (matchTermsWithWildCard(normalizedTag, normalizedBlacklist)) { blockedTags.push(tag); // Changed from blacklistedTag to tag } } else { // No wildcard - use exact matching if (normalizedTag === normalizedBlacklist) { blockedTags.push(tag); // Changed from blacklistedTag to 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((blacklistedTitle) => { if (blacklistedTitle.trim() && matchTermsWithWildCard(title.toLowerCase(), blacklistedTitle.toLowerCase())) { blockedTitles.add(title); } }); if (blockedTitles.size > 0) { reasons.push({ titles: Array.from(blockedTitles) }); } // Check for blocked summary terms const blockedSummaryTerms = []; summaryBlacklist.forEach((summaryTerm) => { if (summaryTerm.trim() && matchTermsWithWildCard(summary, summaryTerm)) { // Remove wildcards for cleaner display but keep the base term const displayTerm = summaryTerm.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).text().replace(/^\s*|\s*$/g, ""); } function selectTextsIn(root, selector) { return $.makeArray($(root).find(selector)).map(getText); } // Extract work data from blurb elements function selectFromBlurb(blurb) { const fandoms = $(blurb).find('h5.fandoms.heading a.tag'); // Get completion status using the same unified parsing let completionStatus = null; const chaptersNode = $(blurb).find('dd.chapters').first(); if (chaptersNode.length) { let chaptersText = ""; const a = chaptersNode.find('a').first(); if (a.length) { // Blurb with link format chaptersText = a.text().trim(); let raw = chaptersNode.html(); raw = raw.replace(/]*>.*?<\/a>/, ''); raw = raw.replace(/ /gi, ' '); const match = raw.match(/\/\s*([\d\?]+)/); if (match) { chaptersText += '/' + match[1].trim(); } } else { // Simple blurb format chaptersText = chaptersNode.text().replace(/ /gi, ' ').trim(); } completionStatus = parseChaptersStatus(chaptersText); } // Use CSS class-based tag categorization const categorizedTags = getCategorizedTags(blurb); return { authors: selectTextsIn(blurb, "a[rel=author]"), categorizedTags: categorizedTags, tags: getAllTagsFlat(categorizedTags), title: selectTextsIn(blurb, ".header .heading a:first-child")[0], summary: selectTextsIn(blurb, "blockquote.summary")[0], language: selectTextsIn(blurb, "dd.language")[0], fandomCount: fandoms.length, wordCount: getWordCount($(blurb)), completionStatus: completionStatus }; } function checkWorks() { 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; } } // Exclude user dashboard and user works/drafts pages const isUserDashboard = ( /^\/users\/[^\/]+\/?$/.test(window.location.pathname) || /^\/users\/[^\/]+\/pseuds\/[^\/]+\/?$/.test(window.location.pathname) ); const isUserWorksOrDraftsPage = ( /^\/users\/[^\/]+\/works\/?$/.test(window.location.pathname) || /^\/users\/[^\/]+\/works\/drafts\/?$/.test(window.location.pathname) ); if (isUserDashboard || isUserWorksOrDraftsPage) { if (debugMode) { console.info("Advanced Blocker: Skipping user dashboard, user works, or user drafts 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; $.makeArray($("li.blurb")).forEach((blurb) => { blurb = $(blurb); const isWorkOrBookmark = (blurb.hasClass("work") || blurb.hasClass("bookmark")) && !blurb.hasClass("picture"); let reason = null; let blockables = selectFromBlurb(blurb); if (debugMode && isWorkOrBookmark) { console.log(`[Advanced Blocker][DEBUG] Work ID: ${blurb.attr("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(blurb, reason, config); blocked++; if (debugMode) { console.groupCollapsed(`- blocked ${blurb.attr("id")}`); console.log(blurb.html(), reason); console.groupEnd(); } } else if (debugMode && isWorkOrBookmark) { console.groupCollapsed(` skipped ${blurb.attr("id")}`); console.log(blurb.html()); console.groupEnd(); } // Highlighting is allowed for all blurbs - uses flat array (same behavior) const allTags = blockables.tags || getAllTagsFlat(blockables.categorizedTags || {}); allTags.forEach((tag) => { if (config.tagHighlights.includes(tag.toLowerCase())) { blurb.addClass("ao3-blocker-highlight"); const color = config.highlightColor || '#f6e3ca'; blurb[0].setAttribute('style', (blurb[0].getAttribute('style') || '') + `;background-color:${color} !important;`); if (blurb[0].id && blurb[0].id.trim() !== "") { const styleId = 'ao3-blocker-style-' + blurb[0].id; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = `#${blurb[0].id}.ao3-blocker-highlight { background-color: ${color} !important; }`; document.head.appendChild(style); } } if (debugMode) { console.groupCollapsed(`? highlighted ${blurb.attr("id")}`); console.log(blurb.html()); console.groupEnd(); } } }); }); if (debugMode) { console.log(`Blocked ${blocked} out of ${total} works`); console.groupEnd(); } } }());