Сделал небольшую утилиту. Мб кому-то пригодится. Вам нужно расширение для браузера TamperMonkey. Работает так, что для размеченных карт (которые подсвечиваются после "Разметить Карты", например Opt) в темах выводит минимальную/среднюю/макс. цену на карту по Scryfall. Скрипт, который надо туда добавить.
П.с. Запускать на свое усмотрение. Ответственности за неполадки не несу.
// ==UserScript==
// @name Topdeck card prices tooltip (hover left of cursor)
// @namespace http://tampermonkey.net/
// @version 0.3
// @description Show min/avg/max prices in tooltip on hover
// @author GrannyCola
// @match https://topdeck.ru/*
// @grant GM_xmlhttpRequest
// @connect api.scryfall.com
// ==/UserScript==
(function () {
'use strict';
const TARGET_SELECTOR = 'a.topdeck_tooltipCard';
// If empty, tooltip shows for all cards.
// If not empty, only for names from this array.
const ALLOWED_CARD_NAMES = [
// 'Spare Supplies'
];
const SOURCES = [
{
name: 'usd',
extract: json => {
if (!json || !json.prices) return null;
const v = parseFloat(json.prices.usd);
return Number.isFinite(v) ? v : null;
}
},
{
name: 'eur',
extract: json => {
if (!json || !json.prices) return null;
const v = parseFloat(json.prices.eur);
return Number.isFinite(v) ? v : null;
}
},
{
name: 'usd_foil',
extract: json => {
if (!json || !json.prices) return null;
const v = parseFloat(json.prices.usd_foil);
return Number.isFinite(v) ? v : null;
}
}
];
const cardCache = new Map();
let tooltipEl = null;
let currentLink = null;
function gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: response => {
try {
const data = JSON.parse(response.responseText);
resolve(data);
} catch (e) {
console.error('Failed to parse JSON', e);
reject(e);
}
},
onerror: err => {
console.error('Request failed', err);
reject(err);
},
ontimeout: () => {
reject(new Error('Request timeout'));
}
});
});
}
async function fetchCardData(cardName) {
const url =
'https://api.scryfall.com/cards/named?exact=' +
encodeURIComponent(cardName);
return gmGetJson(url);
}
async function getCardPrices(cardName) {
if (cardCache.has(cardName)) {
return cardCache.get(cardName);
}
let json;
try {
json = await fetchCardData(cardName);
} catch (e) {
console.warn('Cannot fetch card data for', cardName, e);
cardCache.set(cardName, null);
return null;
}
const prices = SOURCES
.map(source => source.extract(json))
.filter(v => v !== null);
if (!prices.length) {
cardCache.set(cardName, null);
return null;
}
const minPrice = Math.min.apply(null, prices);
const maxPrice = Math.max.apply(null, prices);
const avgPrice =
prices.reduce((acc, v) => acc + v, 0) / prices.length;
const result = { min: minPrice, avg: avgPrice, max: maxPrice };
cardCache.set(cardName, result);
return result;
}
function formatPrice(value) {
return '$' + value.toFixed(2);
}
function createTooltip() {
if (tooltipEl) return;
tooltipEl = document.createElement('div');
tooltipEl.id = 'tm-card-price-tooltip';
tooltipEl.style.position = 'absolute';
tooltipEl.style.zIndex = '9999';
tooltipEl.style.background = 'rgba(20, 20, 20, 0.95)';
tooltipEl.style.color = '#fff';
tooltipEl.style.padding = '4px 6px';
tooltipEl.style.borderRadius = '4px';
tooltipEl.style.fontSize = '11px';
tooltipEl.style.whiteSpace = 'nowrap';
tooltipEl.style.pointerEvents = 'none';
tooltipEl.style.display = 'none';
document.body.appendChild(tooltipEl);
}
function showTooltipAt(mouseX, mouseY, text) {
if (!tooltipEl) createTooltip();
if (!tooltipEl) return;
tooltipEl.textContent = text;
tooltipEl.style.display = 'block';
const rect = tooltipEl.getBoundingClientRect();
const tooltipWidth = rect.width;
const tooltipHeight = rect.height;
const offset = 10;
const top = mouseY - tooltipHeight / 2;
const left = mouseX - tooltipWidth - offset;
tooltipEl.style.top = top + 'px';
tooltipEl.style.left = left + 'px';
}
function moveTooltip(mouseX, mouseY) {
if (!tooltipEl || tooltipEl.style.display === 'none') return;
const rect = tooltipEl.getBoundingClientRect();
const tooltipWidth = rect.width;
const tooltipHeight = rect.height;
const offset = 10;
const top = mouseY - tooltipHeight / 2;
const left = mouseX - tooltipWidth - offset;
tooltipEl.style.top = top + 'px';
tooltipEl.style.left = left + 'px';
}
function hideTooltip() {
if (tooltipEl) tooltipEl.style.display = 'none';
currentLink = null;
}
function isAllowedCardName(cardName) {
if (!ALLOWED_CARD_NAMES.length) return true;
return ALLOWED_CARD_NAMES.includes(cardName);
}
function attachHandlersToLink(link) {
if (link.dataset.tmPriceBound === '1') return;
link.dataset.tmPriceBound = '1';
link.addEventListener('mouseenter', async event => {
const cardName = link.textContent.trim();
if (!cardName || !isAllowedCardName(cardName)) return;
currentLink = link;
showTooltipAt(event.pageX, event.pageY, 'Loading prices...');
try {
const prices = await getCardPrices(cardName);
if (!prices) {
if (currentLink === link) {
showTooltipAt(event.pageX, event.pageY, 'No prices');
}
return;
}
const text =
'min ' +
formatPrice(prices.min) +
' / avg ' +
formatPrice(prices.avg) +
' / max ' +
formatPrice(prices.max);
if (currentLink === link) {
showTooltipAt(event.pageX, event.pageY, text);
}
} catch (e) {
console.error('Error processing card', cardName, e);
if (currentLink === link) {
showTooltipAt(event.pageX, event.pageY, 'Error');
}
}
});
link.addEventListener('mousemove', event => {
if (currentLink === link) {
moveTooltip(event.pageX, event.pageY);
}
});
link.addEventListener('mouseleave', () => {
if (currentLink === link) {
hideTooltip();
}
});
}
function processAllLinks(root) {
const links = (root || document).querySelectorAll(TARGET_SELECTOR);
links.forEach(attachHandlersToLink);
}
function init() {
createTooltip();
processAllLinks(document);
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (!(node instanceof HTMLElement)) return;
if (node.matches && node.matches(TARGET_SELECTOR)) {
attachHandlersToLink(node);
}
processAllLinks(node);
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
init();
})();