您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Toggle overlay showing Game In Hand win rates for MTG cards on Draftmancer and 17Lands
// ==UserScript== // @name MTG Draft GIH WR Overlay // @namespace http://tampermonkey.net/ // @version 2.0 // @description Toggle overlay showing Game In Hand win rates for MTG cards on Draftmancer and 17Lands // @author You // @match https://draftmancer.com/* // @match https://www.17lands.com/* // @license MIT // @grant none // ==/UserScript== (function() { 'use strict'; let overlayEnabled = false; let cardData = {}; let scryfallToName = {}; // Map Scryfall ID to card name (Draftmancer only) let currentExpansion = null; let manualExpansion = null; let dataLoaded = false; let currentSite = null; // Site detection and configuration const SITES = { DRAFTMANCER: { name: 'Draftmancer', detect: () => window.location.hostname.includes('draftmancer.com'), cardSelector: '.card[data-arena-id]', imageContainerSelector: '.card-image', expansionDetector: detectExpansionDraftmancer, getCardName: getCardNameDraftmancer, needsScryfall: true }, SEVENTEENLANDS: { name: '17Lands', detect: () => window.location.hostname.includes('17lands.com'), cardSelector: 'img.sc-iFjrBz[alt]', imageContainerSelector: null, // Append directly to parent expansionDetector: detectExpansion17Lands, getCardName: getCardName17Lands, needsScryfall: false } }; // Detect which site we're on function detectSite() { for (const site of Object.values(SITES)) { if (site.detect()) { currentSite = site; console.log(`Detected site: ${site.name}`); return site; } } console.error('Unknown site - extension may not work correctly'); return null; } // Draftmancer: Fetch Scryfall card names for visible cards using collection endpoint async function loadScryfallMapping() { if (!currentSite || !currentSite.needsScryfall) return; try { const cardElements = document.querySelectorAll(currentSite.cardSelector); const scryfallIds = Array.from(cardElements).map(card => card.getAttribute('data-arena-id') ).filter(id => id && !scryfallToName[id]); if (scryfallIds.length === 0) { console.log('All visible cards already mapped'); return; } console.log(`Fetching Scryfall data for ${scryfallIds.length} cards...`); // Split into batches of 75 (Scryfall's limit) const batches = []; for (let i = 0; i < scryfallIds.length; i += 75) { batches.push(scryfallIds.slice(i, i + 75)); } for (const batch of batches) { const identifiers = batch.map(id => ({ id })); const response = await fetch('https://api.scryfall.com/cards/collection', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ identifiers }) }); if (!response.ok) { throw new Error(`Scryfall API error: ${response.status}`); } const data = await response.json(); data.data.forEach(card => { scryfallToName[card.id] = card.name; }); if (batches.length > 1) { await new Promise(resolve => setTimeout(resolve, 100)); } } console.log(`Loaded ${Object.keys(scryfallToName).length} Scryfall ID mappings`); } catch (error) { console.error('Error fetching Scryfall data:', error); } } // Draftmancer: Detect expansion from page function detectExpansionDraftmancer() { const cardPoolIcon = document.querySelector('.card-pool-controls .selected-sets .set-icon[alt]'); if (cardPoolIcon) { const alt = cardPoolIcon.getAttribute('alt'); if (alt) { const expansion = alt.toUpperCase(); console.log(`Detected expansion from card pool: ${expansion}`); return expansion; } } const selectedSets = document.querySelector('.selected-sets .set-icon[alt]'); if (selectedSets) { const alt = selectedSets.getAttribute('alt'); if (alt) { const expansion = alt.toUpperCase(); console.log(`Detected expansion from selected-sets: ${expansion}`); return expansion; } } console.warn('Could not detect expansion from page'); return null; } // 17Lands: Detect expansion from URL or page function detectExpansion17Lands() { // Try to get from URL first (e.g., /draft/FIN) const urlMatch = window.location.pathname.match(/\/draft\/([A-Z0-9]+)/i); if (urlMatch) { const expansion = urlMatch[1].toUpperCase(); console.log(`Detected expansion from URL: ${expansion}`); return expansion; } // Try to find it in the page title or heading const heading = document.querySelector('h1'); if (heading) { const text = heading.textContent; // Look for common set code patterns const setMatch = text.match(/\b([A-Z]{3})\b/); if (setMatch) { const expansion = setMatch[1].toUpperCase(); console.log(`Detected expansion from heading: ${expansion}`); return expansion; } } console.warn('Could not detect expansion from 17Lands page'); return null; } // Draftmancer: Get card name from Scryfall mapping function getCardNameDraftmancer(cardElement) { const scryfallId = cardElement.getAttribute('data-arena-id'); return scryfallToName[scryfallId]; } // 17Lands: Get card name from alt text function getCardName17Lands(cardElement) { return cardElement.getAttribute('alt'); } // Fetch card data from 17Lands API async function loadCardData(expansion = null) { if (!expansion) { expansion = currentSite.expansionDetector(); } if (!expansion) { console.error('Cannot load card data: no expansion detected'); return; } currentExpansion = expansion; try { console.log(`Fetching card data for ${expansion}...`); const url = `https://www.17lands.com/card_ratings/data?expansion=${expansion}&format=PremierDraft`; const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); data.forEach(card => { if (card.name && card.opening_hand_win_rate !== null) { const id = card.mtga_id || card.arena_id || card.name; cardData[id] = { gihWR: card.opening_hand_win_rate, name: card.name, color: card.color || '', rarity: card.rarity || '', gamesPlayed: card.game_count || 0 }; } }); dataLoaded = true; console.log(`Loaded data for ${Object.keys(cardData).length} cards from ${expansion}`); if (overlayEnabled) { hideOverlays(); showOverlays(); } } catch (error) { console.error('Error fetching card data:', error); console.log('Attempting to load from localStorage cache...'); loadFromCache(); } } // Cache data in localStorage function cacheData() { try { const cache = { data: cardData, expansion: currentExpansion, timestamp: Date.now() }; localStorage.setItem('gihWRCache', JSON.stringify(cache)); } catch (e) { console.warn('Failed to cache data:', e); } } // Load from localStorage cache function loadFromCache() { try { const cached = localStorage.getItem('gihWRCache'); if (cached) { const cache = JSON.parse(cached); const age = Date.now() - cache.timestamp; if (age < 24 * 60 * 60 * 1000) { cardData = cache.data; currentExpansion = cache.expansion; dataLoaded = true; console.log(`Loaded ${Object.keys(cardData).length} cards from cache (${currentExpansion})`); return true; } } } catch (e) { console.warn('Failed to load cache:', e); } return false; } // Create overlay element for a card function createOverlay(cardElement) { if (!currentSite) return null; let cardName = currentSite.getCardName(cardElement); if (!cardName) { return null; } // Handle double-faced cards - 17Lands only uses front face if (cardName.includes(' // ')) { cardName = cardName.split(' // ')[0]; } // Look up data by card name let data = Object.values(cardData).find(c => c.name === cardName); if (!data) { return null; } if (data.gihWR === null || data.gihWR === undefined) { return null; } const overlay = document.createElement('div'); overlay.className = 'gih-wr-overlay'; const sampleWarning = data.gamesPlayed < 500 ? '⚠️ ' : ''; // Different sizes for different sites const fontSize = currentSite === SITES.DRAFTMANCER ? '16px' : '12px'; const padding = currentSite === SITES.DRAFTMANCER ? '5px 10px' : '3px 6px'; overlay.style.cssText = ` position: absolute; top: 32px; right: 8px; background: rgba(0, 0, 0, 0.85); color: ${getWinrateColor(data.gihWR)}; padding: ${padding}; border-radius: 4px; font-weight: bold; font-size: ${fontSize}; z-index: 1000; pointer-events: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; overlay.textContent = `${sampleWarning}GIH: ${(data.gihWR * 100).toFixed(1)}%`; return overlay; } // Color code based on win rate (colorblind-friendly palette) function getWinrateColor(wr) { if (wr >= 0.58) return '#60a5fa'; // Blue - excellent if (wr >= 0.55) return '#93c5fd'; // Light blue - good if (wr >= 0.52) return '#e0e7ff'; // Very light blue/white - above average if (wr >= 0.50) return '#fbbf24'; // Amber/yellow - average return '#fb923c'; // Orange - below average } // Toggle overlays on/off function toggleOverlays() { overlayEnabled = !overlayEnabled; if (overlayEnabled) { showOverlays(); } else { hideOverlays(); } console.log(`GIH WR Overlay: ${overlayEnabled ? 'ON' : 'OFF'}`); } function showOverlays() { if (!currentSite) return; const cards = document.querySelectorAll(currentSite.cardSelector); console.log(`Looking for cards with selector '${currentSite.cardSelector}'`); console.log(`Found ${cards.length} cards`); cards.forEach(card => { // Skip if overlay already exists if (card.querySelector('.gih-wr-overlay')) return; const overlay = createOverlay(card); if (overlay) { // For 17Lands, we need to wrap the image in a positioned container if (currentSite === SITES.SEVENTEENLANDS) { const parent = card.parentElement; if (parent && !parent.querySelector('.gih-wr-overlay')) { parent.style.position = 'relative'; parent.appendChild(overlay); } } else { // Draftmancer - use the card-image container const imageContainer = card.querySelector(currentSite.imageContainerSelector); if (imageContainer) { imageContainer.style.position = 'relative'; imageContainer.appendChild(overlay); } } } }); } function hideOverlays() { const overlays = document.querySelectorAll('.gih-wr-overlay'); overlays.forEach(overlay => overlay.remove()); } // Watch for new cards being added to the DOM function observeCards() { let debounceTimer = null; const observer = new MutationObserver((mutations) => { if (!overlayEnabled) return; // Check if any mutations actually added/removed card elements const hasCardChanges = mutations.some(mutation => { if (mutation.type === 'childList') { const addedCards = Array.from(mutation.addedNodes).some(node => { return node.nodeType === 1 && ( node.matches && node.matches(currentSite.cardSelector) || node.querySelector && node.querySelector(currentSite.cardSelector) ); }); const removedCards = Array.from(mutation.removedNodes).some(node => { return node.nodeType === 1 && ( node.matches && node.matches(currentSite.cardSelector) || node.querySelector && node.querySelector(currentSite.cardSelector) ); }); return addedCards || removedCards; } return false; }); if (hasCardChanges) { // Debounce to avoid rapid updates clearTimeout(debounceTimer); debounceTimer = setTimeout(showOverlays, 200); } }); observer.observe(document.body, { childList: true, subtree: true }); } // Keyboard shortcut handler function handleKeyPress(e) { // Toggle with Ctrl+Shift+A (or Cmd+Shift+A on Mac) if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'A') { e.preventDefault(); toggleOverlays(); } // Reload data with Ctrl+Shift+R if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') { e.preventDefault(); console.log('Reloading card data...'); cardData = {}; scryfallToName = {}; dataLoaded = false; const expansion = manualExpansion || currentSite.expansionDetector(); if (expansion) { if (currentSite.needsScryfall) { loadScryfallMapping().then(() => { loadCardData(expansion); }); } else { loadCardData(expansion); } } } // Manual set override with Ctrl+Shift+S if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { e.preventDefault(); const expansion = prompt('Enter expansion code (e.g., FIN, MH3, BLB):'); if (expansion) { manualExpansion = expansion.toUpperCase(); console.log(`Manual expansion override set to: ${manualExpansion}`); cardData = {}; scryfallToName = {}; dataLoaded = false; if (currentSite.needsScryfall) { loadScryfallMapping().then(() => { loadCardData(manualExpansion); }); } else { loadCardData(manualExpansion); } } } } // Initialize async function init() { // Detect which site we're on detectSite(); if (!currentSite) { console.error('Could not detect site - extension will not work'); return; } console.log(`GIH WR Overlay script loaded for ${currentSite.name}.`); console.log('Hotkeys:'); console.log(' Ctrl+Shift+A - Toggle overlay'); console.log(' Ctrl+Shift+R - Reload data'); console.log(' Ctrl+Shift+S - Manually set expansion code'); document.addEventListener('keydown', handleKeyPress); observeCards(); // Wait for page to load await new Promise(resolve => setTimeout(resolve, 1000)); // Detect expansion const expansion = manualExpansion || currentSite.expansionDetector(); if (!expansion) { console.warn('No expansion detected. Press Ctrl+Shift+S to manually set it.'); return; } // Load Scryfall mapping only if needed (Draftmancer) if (currentSite.needsScryfall) { await loadScryfallMapping(); } // Try cache first const cacheLoaded = loadFromCache(); // Fetch fresh data await loadCardData(expansion); // Cache the fresh data if (dataLoaded) { cacheData(); } } // Wait for page to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();