您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a button to DnDBeyond to filter sources you own.
// ==UserScript== // @name Beyond My Sources // @namespace Violentmonkey Scripts // @match https://www.dndbeyond.com/* // @grant none // @version 2.3.3 // @author Petr Gondek // @description Adds a button to DnDBeyond to filter sources you own. // @license MIT // ==/UserScript== window.addEventListener('load', function() { Main(); }, false); /** * Polls a callback function until it returns a truthy value or timeout occurs * @param {Function} callback - Function to poll (should return truthy when done) * @param {Object} options - Configuration options * @param {number} options.interval - Polling interval in milliseconds (default: 100) * @param {number} options.timeout - Timeout duration in milliseconds (default: 5000) * @returns {Promise} Resolves with callback's truthy value or rejects on timeout */ function pollUntilTruthy(callback, { interval = 100, timeout = 5000 } = {}) { return new Promise((resolve, reject) => { const startTime = Date.now(); let intervalId; // Cleanup function to stop intervals const cleanup = () => clearInterval(intervalId); const check = () => { try { const result = callback(); if (result) { cleanup(); resolve(result); } else if (Date.now() - startTime >= timeout) { cleanup(); reject(new Error(`Timeout after ${timeout}ms`)); } } catch (error) { cleanup(); reject(error); } }; // First immediate check check(); // Subsequent interval checks intervalId = setInterval(check, interval); }); } function safeSourceParse(sourceKey) { const jsonString = window.localStorage.getItem(sourceKey) try { const parsedSources = JSON.parse(jsonString) if (Array.isArray(parsedSources)) return parsedSources return [] } catch (error) { return [] } } // Execute this to get new sources array //Array.from(document.getElementById("filter-source")).map(e => e.id); const localStorageOwnedSourcesKey = 'DNDB_OWNED_SOURCES' const localStorageSharedSourcesKey = 'DNDB_SHARED_SOURCES' function getSourceFilters() { const ownedSources = safeSourceParse(localStorageOwnedSourcesKey) const sharedSources = safeSourceParse(localStorageSharedSourcesKey) const allSources = [...ownedSources, ...sharedSources] const uniqueSources = [...new Set(allSources)]; return uniqueSources.map(source => `filter-source-${source}`) } function getSourceFromTitle(title) { const sourceName = title .toLowerCase() .trim() .replace(/&/g, '-') .replace(/\s/g, '-') .replace(/[^a-zA-Z0-9-\s]/g, '') return sourceName } function saveOwnedSources() { // delete all rendered sources not in library const sourceListings = Array.from(document.querySelectorAll('.sources-listing .sources-listing--item-wrapper')) const filteredSourceListings = sourceListings.filter(listing => Boolean(listing.querySelector('.owned-content'))) const ownedSources = filteredSourceListings.map(listing => { const titleNode = listing.querySelector('.sources-listing--item--title') const title = Array.from(titleNode.childNodes) .filter(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim()) .map(node => node.textContent.trim()) .join(' '); return getSourceFromTitle(title) }) if (ownedSources.length) { ownedSources.sort() window.localStorage.setItem(localStorageOwnedSourcesKey, JSON.stringify(ownedSources)) } } async function saveSharedSources() { const sourceListings = await pollUntilTruthy(() => { const maybeListings = document.querySelectorAll('.listing__items .listing__list-item__column.listing__list-item__column--name') if (!maybeListings?.length) return false return maybeListings }) const sharedSources = [...sourceListings].map(listing => { const title = listing.textContent return getSourceFromTitle(title) }) if (sharedSources.length) { sharedSources.sort() window.localStorage.setItem(localStorageSharedSourcesKey, JSON.stringify(sharedSources)) } } const localStorageSources = getSourceFilters() // comment out sources you don't own const defaultSources = [ //"filter-source-acquisitions-incorporated", //"filter-source-adventure-atlas-the-mortuary", //"filter-source-against-the-giants", //"filter-source-baldurs-gate-gazetteer", //"filter-source-baldurs-gate-descent-into-avernus", "filter-source-basic-rules-2014", //"filter-source-bigby-presents-glory-of-the-giants", //"filter-source-book-of-ebon-tides", "filter-source-candlekeep-mysteries", //"filter-source-critical-role", //"filter-source-critical-role-call-of-the-netherdeep", //"filter-source-curse-of-strahd", //"filter-source-curse-of-strahd-character-options", "filter-source-d-d-free-rules-2024", //"filter-source-dead-in-thay", //"filter-source-descent-into-the-lost-caverns-of-tsojcanth", "filter-source-divine-contention", //"filter-source-domains-of-delight-a-feywild-accessory", "filter-source-dragon-of-icespire-peak", //"filter-source-dragonlance-shadow-of-the-dragon-queen", //"filter-source-dragons-of-stormwreck-isle", "filter-source-dungeon-masters-guide-2014", "filter-source-dungeon-masters-guide-2024", //"filter-source-dungeons-dragons-vs-rick-and-morty", //"filter-source-dungeons-of-drakkenheim", //"filter-source-eberron-rising-from-the-last-war", //"filter-source-elemental-evil-players-companion", //"filter-source-explorers-guide-to-wildemount", "filter-source-fizbans-treasury-of-dragons", //"filter-source-flee-mortals", "filter-source-frozen-sick", //"filter-source-ghosts-of-saltmarsh", //"filter-source-giants-of-the-star-forge", //"filter-source-grim-hollow-player-pack", //"filter-source-guildmasters-guide-to-ravnica", //"filter-source-heroes-feast-saving-the-childrens-menu", //"filter-source-hoard-of-the-dragon-queen", //filter-source-humblewood-campaign-setting", //"filter-source-hunt-for-the-thessalhydra", "filter-source-icewind-dale-rime-of-the-frostmaiden", //"filter-source-infernal-machine-rebuild", //"filter-source-intro-to-stormwreck-isle", //"filter-source-journeys-through-the-radiant-citadel", //"filter-source-keys-from-the-golden-vault", //"filter-source-lairs-of-etharis", "filter-source-legendary-magic-items", //"filter-source-lightning-keep", //"filter-source-locathah-rising", //"filter-source-lost-laboratory-of-kwalish", "filter-source-lost-mine-of-phandelver", //"filter-source-misplaced-monsters-volume-one", "filter-source-monster-manual-2014", "filter-source-monster-manual-2024", "filter-source-monstrous-compendium-vol-1-spelljammer-creatures", "filter-source-monstrous-compendium-vol-2-dragonlance-creatures", "filter-source-monstrous-compendium-vol-3-minecraft-creatures", "filter-source-monstrous-compendium-vol-4-eldraine-creatures", "filter-source-mordenkainen-presents-monsters-of-the-multiverse", "filter-source-mordenkainens-tome-of-foes", "filter-source-mordenkainens-fiendish-folio-volume-1", //"filter-source-mythic-odysseys-of-theros", "filter-source-one-grung-above", "filter-source-out-of-the-abyss", //"filter-source-phandelver-and-below-the-shattered-obelisk", //"filter-source-planescape-adventures-in-the-multiverse", "filter-source-players-handbook-2014", "filter-source-players-handbook-2024", "filter-source-princes-of-the-apocalypse", "filter-source-prisoner-13", //"filter-source-quests-from-the-infinite-staircase", //"filter-source-rise-of-tiamat", "filter-source-rrakkma", "filter-source-sleeping-dragons-wake", "filter-source-spelljammer-academy", //"filter-source-spelljammer-adventures-in-space", //"filter-source-storm-kings-thunder", //"filter-source-storm-lords-wrath", //"filter-source-strixhaven-a-curriculum-of-chaos", "filter-source-sword-coast-adventurers-guide", //"filter-source-taldorei-campaign-setting-reborn", //"filter-source-tales-from-the-shadows", //"filter-source-tales-from-the-yawning-portal", "filter-source-tashas-cauldron-of-everything", //"filter-source-the-book-of-many-things", //"filter-source-the-forge-of-fury", //"filter-source-the-hidden-shrine-of-tamoachan", //"filter-source-the-sunless-citadel", "filter-source-the-tortle-package", "filter-source-the-vecna-dossier", //"filter-source-the-wild-beyond-the-witchlight", //"filter-source-thieves-gallery", //"filter-source-tomb-of-annihilation", //"filter-source-tomb-of-horrors", //"filter-source-tome-of-beasts-1", //"filter-source-tyranny-of-dragons", //"filter-source-van-richtens-guide-to-ravenloft", //"filter-source-vecna-eve-of-ruin", //"filter-source-vecna-nest-of-the-eldritch-eye", "filter-source-volos-guide-to-monsters", //"filter-source-waterdeep-dragon-heist", //"filter-source-waterdeep-dungeon-of-the-mad-mage", //"filter-source-wayfinders-guide-to-eberron", //"filter-source-where-evil-lives", //"filter-source-white-plume-mountain", "filter-source-xanathars-guide-to-everything" ]; const mySources = localStorageSources?.length ? localStorageSources : defaultSources const FILTER_SOURCE_ID = "filter-source"; const LISTING_FILTERS_CLASS = "listing-filters"; const RESET_BUTTON_CONTAINER_CLASS = "reset-button-container"; const QA_MONSTER_FILTERS_SOURCE = "qa-monster-filters_source"; const INPUT_SELECT_DROPDOWN = "input-select__dropdown"; const INPUT_CHECKBOX_TEXT = "input-checkbox__text"; const QA_MONSTER_FILTERS_SHOW_ADVANCE = "qa-monster-filters_show-advanced"; const MY_SOURCES = "Filter my sources"; function Main() { if (IsGameRules()) { CreateButton(); return; } setTimeout(() => { if (IsEncounterBuilder()) { CreateButtonEB(); return; } console.error("beyond-my-sources: Can't find an element."); }, 2000); if (IsSourceList()) { saveOwnedSources() } if (IsCampaignContent()) { saveSharedSources() } } function IsSourceList() { const pageTitle = document.querySelector('h1.page-title')?.textContent.trim() return pageTitle == "Sources" } function IsCampaignContent() { const pathComponents = window.location.pathname.split('/').reverse().filter(i => i) const isContentManagementPage = pathComponents[0] == 'content-management' const campaignId = pathComponents[1] const isCampaign = pathComponents[2] === 'campaigns' return isCampaign && isContentManagementPage } function IsGameRules() { return (document.getElementById(FILTER_SOURCE_ID) != null && document.getElementsByClassName(LISTING_FILTERS_CLASS)[0] != null && document.getElementsByClassName(RESET_BUTTON_CONTAINER_CLASS)[0] != null); } function IsEncounterBuilder() { return (document.getElementsByClassName(QA_MONSTER_FILTERS_SOURCE)[0] != null && document.getElementsByClassName(INPUT_SELECT_DROPDOWN)[0] != null && document.getElementsByClassName(INPUT_CHECKBOX_TEXT)[0] != null && document.getElementsByClassName(QA_MONSTER_FILTERS_SHOW_ADVANCE)[0] != null); } function OnClickEncounterBuilder(){ let ele = document.getElementsByClassName(QA_MONSTER_FILTERS_SOURCE)[0].getElementsByClassName(INPUT_SELECT_DROPDOWN)[0]; let clickables = Array.from(ele.childNodes).map(e=> e.firstElementChild); clickables.forEach(e => { let bookName = getSourceFromTitle(e.getElementsByClassName(INPUT_CHECKBOX_TEXT)[0].firstChild.data) if (mySources.some(source => source.includes(bookName))) e.click(); }); } function CreateButtonEB() { let originalButton = document.getElementsByClassName(QA_MONSTER_FILTERS_SHOW_ADVANCE)[0]; let btn = document.createElement("button"); btn.innerHTML = MY_SOURCES; btn.id = btn.innerHTML; btn.onclick = OnClickEncounterBuilder; btn.style.cssText = GetCssText(originalButton); originalButton.parentElement.appendChild(btn); } function ButtonOnClick() { SelectAll(); document.getElementsByClassName(LISTING_FILTERS_CLASS)[0].submit(); } function GetCssText(element) { let styles = window.getComputedStyle(element); let cssText = styles.cssText; if (!cssText) { cssText = Array.from(styles).reduce((str, property) => { return `${str}${property}:${styles.getPropertyValue(property)};`; }, ''); } return cssText; } function CreateButton () { let container = document.getElementsByClassName(RESET_BUTTON_CONTAINER_CLASS)[0]; let btn = document.createElement("button"); btn.innerHTML = MY_SOURCES; btn.id = btn.innerHTML; btn.onclick = ButtonOnClick; btn.style.cssText = GetCssText(container.firstElementChild.firstElementChild); let div = document.createElement("div"); div.style.cssText = GetCssText(container.firstElementChild); div.appendChild(btn); container.appendChild(div); return true; }; function SelectAll () { Array.from(document.getElementById(FILTER_SOURCE_ID)).forEach(e => { if (mySources.includes(e.id)) { e.selected = true; } }); }