您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Locate all BoosterCard instances and display delta winrate from 17lands data
// ==UserScript== // @name Draftmancer BoosterCard Inspector // @namespace http://tampermonkey.net/ // @version 0.10 // @description Locate all BoosterCard instances and display delta winrate from 17lands data // @homepage https://greasyfork.org/scripts/545265 // @supportURL https://greasyfork.org/scripts/545265/feedback // @author xiaoas // @match https://draftmancer.com/* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; let cardRatingsList = []; // Aggregated list of ratings across expansions let cardRatingsByName = {}; // Map: name -> rating entry let cardRatingsByMtgaId = {}; // Map: mtga_id -> rating entry let currentExpansion = 'EOE'; // Default to EOE let lastCardNames = []; // Track last known card names let refreshInterval = null; let isRequestInProgress = false; // Prevent overlapping requests let isLoopInProgress = false; // Prevent overlapping main loop runs const queriedExpansions = new Set(); // Track expansions whose data has been fetched const fetchPromisesByExpansion = {}; // Track in-flight fetches per expansion let activeFetchCount = 0; // Track number of active fetches // Expansion mapping from page names to 17lands parameters const expansionMapping = { "Edge of Eternities": "EOE", "Final Fantasy": "FIN", "Tarkir: Dragonstorm": "TDM", "Aetherdrift": "DFT", "Foundations": "FDN", }; let warnedUnknownExpansions = []; // Function to detect current expansion from page function detectExpansion() { try { const setElement = document.querySelector('.selected-set-name'); if (setElement) { const setText = setElement.innerText.trim(); currentExpansion = expansionMapping[setText]; if (!currentExpansion && setText && !warnedUnknownExpansions.includes(setText)) { console.warn(`⚠️ Unknown expansion detected: "${setText}". Please update the expansionMapping.`); warnedUnknownExpansions.push(setText); } return currentExpansion; } } catch (error) { console.error('❌ Error detecting expansion:', error); } return currentExpansion; } let warnedUnknownSetNames = []; function inferExpansionsFromSetName(setString) { const s = (setString || '').toLowerCase(); const inferred = new Set(); if (s.includes('eoe') || s.includes('eos')) inferred.add('EOE'); if (s.includes('fin')) inferred.add('FIN'); if (s.includes('tdm')) inferred.add('TDM'); if (s.includes('dft') || s.includes('spg')) inferred.add('DFT'); if (s.includes('fdn')) inferred.add('FDN'); if (inferred.size === 0 && setString && !warnedUnknownSetNames.includes(setString)) { console.warn(`⚠️ Failed to infer expansion for card with set name: "${setString}".`); warnedUnknownSetNames.push(setString); } return Array.from(inferred); } // Function to calculate weighted average win rate for an expansion function calculateWeightedAverageWinRate(expansionData) { if (!expansionData || expansionData.length === 0) return 0; let totalWeightedWinRate = 0; let totalGames = 0; expansionData.forEach(card => { if (card.ever_drawn_win_rate !== undefined && card.ever_drawn_game_count) { totalWeightedWinRate += card.ever_drawn_win_rate * card.ever_drawn_game_count; totalGames += card.ever_drawn_game_count; } }); return totalGames > 0 ? totalWeightedWinRate / totalGames : 0; } // Function to fetch card ratings data from 17lands for a specific expansion and merge async function fetchCardRatings(targetExpansion) { if (!targetExpansion) return null; if (queriedExpansions.has(targetExpansion)) { return null; } if (fetchPromisesByExpansion[targetExpansion]) { return await fetchPromisesByExpansion[targetExpansion]; } // Track global request state activeFetchCount += 1; isRequestInProgress = activeFetchCount > 0; try { // console.log(`📊 Fetching card ratings from 17lands for expansion: ${targetExpansion}`); let suffix = ''; if (targetExpansion == 'DFT') { suffix = '&start_date=2025-02-11&end_date=2025-08-19'; } else if (targetExpansion == 'FDN') { suffix = '&start_date=2024-11-12&end_date=2025-08-23'; } const url = `https://www.17lands.com/card_ratings/data?expansion=${targetExpansion}&event_type=PremierDraft${suffix}`; const fetchPromise = fetch(url).then(async (response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const expansionData = await response.json(); // Merge entries into list and maps (by mtga_id if available, otherwise by name) expansionData.forEach(entry => { if (!entry || !entry.name) return; const id = entry.mtga_id; let existing = null; if (id != null && cardRatingsByMtgaId[id]) { existing = cardRatingsByMtgaId[id]; } else if (cardRatingsByName[entry.name]) { existing = cardRatingsByName[entry.name]; } if (existing) { // Update existing entry in-place Object.assign(existing, entry); // Ensure maps are synced cardRatingsByName[existing.name] = existing; if (existing.mtga_id != null) { cardRatingsByMtgaId[existing.mtga_id] = existing; } } else { cardRatingsList.push(entry); cardRatingsByName[entry.name] = entry; if (id != null) { cardRatingsByMtgaId[id] = entry; } } }); queriedExpansions.add(targetExpansion); return expansionData; }); fetchPromisesByExpansion[targetExpansion] = fetchPromise; const result = await fetchPromise; return result; } catch (error) { console.error('❌ Error fetching card ratings:', error); return null; } finally { // Clear in-flight promise and update request tracking delete fetchPromisesByExpansion[targetExpansion]; activeFetchCount = Math.max(0, activeFetchCount - 1); isRequestInProgress = activeFetchCount > 0; } } // Function to find card rating by mtga_id (arena_id) first, then by name function findCardRating(card) { if (!card) return null; if (card.arena_id != null && cardRatingsByMtgaId[card.arena_id]) { return cardRatingsByMtgaId[card.arena_id]; } if (cardRatingsByName[card.name]) { return cardRatingsByName[card.name]; } return null; } // Function to get color based on delta winrate function getColorForDelta(deltaWinrate) { // Normalize delta to 0-1 range for color calculation const normalizedDelta = Math.max(-0.05, Math.min(0.05, deltaWinrate)); const normalizedValue = (normalizedDelta + 0.05) / 0.1; // Convert to 0-1 range let r, g, b; if (normalizedValue >= 0.5) { // yellow to green transition (0.5 to 1.0) const factor = (normalizedValue - 0.5) * 2; // 0 to 1 r = Math.round(192 * (1-factor)); g = Math.round(128 * (1+factor) - 1); b = Math.round(32 * (1+factor) - 1); } else { // red to yellow transition (0.0 to 0.5) const factor = normalizedValue * 2; // 0 to 1 r = Math.round(64 * (3+factor) - 1); g = Math.round(127 * factor); b = 0; } return `rgba(${r}, ${g}, ${b}, 0.8)`; } // Function to add delta winrate overlays above each card function addDeltaWinrateOverlays(boosterCards) { // Extract current card names for comparison const currentCardNames = boosterCards.map(card => card.props.card.name); // Check if card list has changed if (currentCardNames.length === 0) { console.log('⚠️ No cards found, skipping overlay refresh'); return; } // Check if the card list is the same as last time and overlays already present const existingOverlaysForSkipCheck = document.querySelectorAll('.card-name-overlay'); const isSameCardList = lastCardNames.length === currentCardNames.length && lastCardNames.every((name, index) => name === currentCardNames[index]); if (isSameCardList && existingOverlaysForSkipCheck.length === currentCardNames.length) { // console.log('🔄 Card list unchanged and overlays present, skipping overlay refresh'); return; } // console.log('🎨 Adding delta winrate overlays...'); // console.log(`📊 Cards changed from [${lastCardNames.join(', ')}] to [${currentCardNames.join(', ')}]`); // Update last known card names lastCardNames = [...currentCardNames]; // Remove any existing overlays first const existingOverlays = document.querySelectorAll('.card-name-overlay'); existingOverlays.forEach(overlay => overlay.remove()); // Try to match Vue components with DOM elements boosterCards.forEach((card, index) => { const cardData = card.props.card; let element = card.el; if (!element) { console.log(`❌ No DOM element found for card: ${cardData.name}`); return; } // Find the card rating const rating = findCardRating(cardData); let displayText = cardData.name; let backgroundColor = 'rgba(0, 0, 0, 0.8)'; if (!rating) { console.log(`⚠️ No rating data found for: ${cardData.name}`); return; } const cardWinRate = rating.ever_drawn_win_rate; const averageWinRate = 0.55; // hard code to allow multi pack scenario const deltaWinrate = cardWinRate - averageWinRate; const percentage = (deltaWinrate * 100).toFixed(1); displayText = deltaWinrate >= 0 ? `+${percentage}` : `${percentage}`; // Use gradual color transition based on delta winrate backgroundColor = getColorForDelta(deltaWinrate); // Create overlay element const overlay = document.createElement('div'); overlay.className = 'card-name-overlay'; overlay.textContent = displayText; overlay.title = `${cardData.name}: ${displayText}`; overlay.style.cssText = ` position: absolute; top: 20%; right: 5px; background: ${backgroundColor}; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; white-space: nowrap; z-index: 1000; pointer-events: none; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); cursor: help; `; // Append the overlay directly to the card element itself element.appendChild(overlay); // console.log(`✅ Added overlay for "${cardData.name}" (${displayText}) to element:`, element); }); } // Function to start monitoring for changes function startMonitoring() { if (refreshInterval) { clearInterval(refreshInterval); } // Monitor for changes in the main content area const targetNode = document.querySelector('.main-content') || document.body; refreshInterval = setInterval(() => { // Only run if no loop is currently in progress if (!isLoopInProgress) { refreshOverlaysLoop(); } else { // console.log('⏳ Skipping interval - loop in progress'); } }, 1000); // Check every 1000ms // console.log('👀 Started monitoring for card changes'); } // Synchronous function to find and return BoosterCard instances using Vue 3 app structure function findBoosterCards() { try { // console.log('🔍 Locating BoosterCard instances using Vue 3 app structure...'); if (!document.querySelector('.booster.card-container')) { // game has not started yet return []; } // Find the Vue 3 app instance const appElement = Array.from(document.querySelectorAll('*')).find((e) => e.__vue_app__); if (!appElement) { // console.log('❌ No Vue 3 app found'); return []; } const app = appElement.__vue_app__; // console.log('✅ Vue 3 app found:', app); // Check if BoosterCard component exists if (!app._component.components.BoosterCard) { // console.log('❌ BoosterCard component not found in app._component.components'); // console.log('Available components:', Object.keys(app._component.components)); return []; } // Navigate through the component tree as described let currentNode = app._container._vnode.component.subTree; if (!currentNode) { // console.log('❌ Could not access app._container._vnode.component.subTree'); return []; } // Find main-content const mainContentNode = currentNode.children?.find(child => child.props?.class === 'main-content' ); if (!mainContentNode) { // console.log('❌ Could not find main-content node'); // console.log('Available children:', currentNode.children); return []; } // Find generic-container const genericContainerNode = mainContentNode.children?.find(child => child.props?.class === 'generic-container' ); if (!genericContainerNode) { // console.log('❌ Could not find generic-container node'); // console.log('Available children:', mainContentNode.children); return []; } // Find node with type == 'Symbol(v-fgt)' const vFgtNode = genericContainerNode.children?.find(child => child.type && child.type.toString() === 'Symbol(v-fgt)' ); if (!vFgtNode) { // console.log('❌ Could not find v-fgt node'); // console.log('Available children:', genericContainerNode.children); return []; } // Find transition node and navigate to draft-picking container const draftPickingNode = vFgtNode.children?.find(child => { return child.el && child.el.nodeName === 'DIV' }); if (!draftPickingNode) { // console.log('❌ Could not find draft-picking container node'); // console.log('Available v-fgt children:', vFgtNode.children); return []; } // Get the actual draft-picking node from the component tree const actualDraftPickingNode = draftPickingNode.component.subTree.component.subTree; // Find booster-cards TransitionGroup const boosterCardsNode = actualDraftPickingNode.children?.find(child => child.type?.name === 'TransitionGroup' ); if (!boosterCardsNode) { // console.log('❌ Could not find booster-cards TransitionGroup'); // console.log('Available draft-picking children:', actualDraftPickingNode.children); return []; } // Get the list of BoosterCards if (boosterCardsNode.component?.subTree?.children) { const boosterCards = boosterCardsNode.component.subTree.children; return boosterCards || []; } else { // console.log('❌ Could not access booster-cards children'); return []; } } catch (error) { console.error('❌ Error while locating BoosterCards:', error); return []; } } // Main loop to: prefetch related data, then render overlays async function refreshOverlaysLoop() { if (isLoopInProgress) return; isLoopInProgress = true; try { // Prefetch based on detected expansion if possible (best-effort) const detected = detectExpansion(); if (detected && !queriedExpansions.has(detected)) { await fetchCardRatings(detected); } const boosterCards = findBoosterCards(); if (!boosterCards || boosterCards.length === 0) { return; } // Determine which expansions to fetch based on the sets of visible cards const expansionsToFetch = new Set(); boosterCards.forEach(cardNode => { const card = cardNode?.props?.card; if (!card) return; const hasRating = (card.arena_id != null && cardRatingsByMtgaId[card.arena_id]) || cardRatingsByName[card.name]; if (hasRating) return; const inferred = inferExpansionsFromSetName(card.set); inferred.forEach(exp => { if (!queriedExpansions.has(exp)) { expansionsToFetch.add(exp); } }); }); if (expansionsToFetch.size > 0) { await Promise.all(Array.from(expansionsToFetch).map(exp => fetchCardRatings(exp))); } // Now render overlays addDeltaWinrateOverlays(boosterCards); } catch (error) { console.error('❌ Error in refreshOverlaysLoop:', error); } finally { isLoopInProgress = false; } } // Register functions to window for easy access window.findDraftmancerBoosterCards = findBoosterCards; window.addDeltaWinrateOverlays = addDeltaWinrateOverlays; window.fetchCardRatings = fetchCardRatings; window.startMonitoring = startMonitoring; window.refreshOverlaysLoop = refreshOverlaysLoop; window.stopMonitoring = () => { if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; console.log('⏹️ Stopped monitoring for card changes'); } }; // Auto-run the search after a delay to allow Vue to initialize setTimeout(() => { console.log('🚀 Draftmancer BoosterCard Inspector script loaded!'); console.log('💡 Use window.findDraftmancerBoosterCards() to get BoosterCards'); console.log('💡 Use window.addDeltaWinrateOverlays(boosterCards) to add visual overlays'); console.log('💡 Use window.fetchCardRatings(expansion) to fetch 17lands data for a specific expansion'); console.log('💡 Use window.startMonitoring() to start monitoring for changes'); console.log('💡 Auto-running monitor in 1 second...'); setTimeout(() => { refreshOverlaysLoop(); startMonitoring(); }, 1000); }, 100); })();