// ==UserScript==
// @name Scryfall卡牌汉化
// @description 为Scryfall没有中文的卡牌添加汉化,所有汉化数据均来自中文卡查sbwsz.com
// @author lieyanqzu
// @license GPL
// @namespace http://github.com/lieyanqzu
// @icon https://scryfall.com/favicon.ico
// @version 1.2
// @match *://scryfall.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_openInTab
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
.print-langs-item.translate-toggle {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
`);
const API_BASE_URL = 'https://api.sbwsz.com/card';
const TYPE_NAME_TRANSLATIONS_URL = 'https://sbwsz.com/static/typeName.json';
let typeNameTranslations = null;
const cardLanguageStates = new Map();
GM_registerMenuCommand('默认显示中文: ' + (GM_getValue('defaultToChinese', false) ? '开' : '关'), toggleDefaultLanguage);
function getCardInfoFromDOM(cardProfile) {
const printsCurrentSet = cardProfile.querySelector('.prints-current-set');
if (!printsCurrentSet) return null;
const setMatch = printsCurrentSet.href.match(/\/sets\/(\w+)/);
if (!setMatch) return null;
const setCode = setMatch[1];
const detailsText = cardProfile.querySelector('.prints-current-set-details')?.textContent || '';
const numberMatch = detailsText.match(/#([^\s]+)/);
if (!numberMatch) return null;
const collectorNumber = numberMatch[1];
return { setCode, collectorNumber };
}
document.addEventListener('contextmenu', function(e) {
if (e.target.classList.contains('translate-toggle')) {
e.preventDefault();
e.stopPropagation();
const parent = e.target.closest('[data-card-id]') || document;
const cardInfo = getCardInfoFromDOM(parent);
if (cardInfo) {
setTimeout(() => {
const sbwszUrl = `https://sbwsz.com/card/${cardInfo.setCode}/${cardInfo.collectorNumber}`;
GM_openInTab(sbwszUrl, false);
}, 0);
}
return false;
}
}, {capture: true, passive: false});
function toggleDefaultLanguage() {
const newDefault = !GM_getValue('defaultToChinese', false);
GM_setValue('defaultToChinese', newDefault);
location.reload();
}
async function getChineseCardData(setCode, collectorNumber) {
const apiUrl = `${API_BASE_URL}/${setCode}/${collectorNumber}`;
try {
const response = await makeRequest('GET', apiUrl);
const data = JSON.parse(response.responseText);
const scryfallFaceCount = document.querySelectorAll('.card-text-title').length || 1;
if (scryfallFaceCount === 1) {
return processSingleFacedCard(data.data[0]);
} else if (data.type === 'double' && data.data.length === 2) {
return processDoubleFacedCard(data.data);
} else if (data.type === 'normal' && data.data.length > 0) {
return processSingleFacedCard(data.data[0]);
}
throw new Error('无法取中文卡牌数据');
} catch (error) {
console.error('获取中文卡牌数据失败:', error);
throw error;
}
}
function processCardFace(cardData) {
const name = cardData.zhs_faceName || cardData.translatedName || cardData.zhs_name || cardData.officialName || cardData.name;
return {
name,
text: processText(cardData.translatedText || cardData.zhs_text || cardData.officialText || cardData.text, name),
flavorText: processText(cardData.zhs_flavorText || cardData.translatedFlavorText || cardData.flavorText)
};
}
const processDoubleFacedCard = data => ({
front: processCardFace(data[0]),
back: processCardFace(data[1])
});
const processSingleFacedCard = cardData => processCardFace(cardData);
function processText(text, cardName) {
if (!text) return text;
text = text.replace(/\\n/g, '\n');
return cardName ? text.replace(/CARDNAME/g, cardName) : text;
}
async function getTypeNameTranslations() {
if (typeNameTranslations) return typeNameTranslations;
try {
const response = await makeRequest('GET', TYPE_NAME_TRANSLATIONS_URL);
typeNameTranslations = JSON.parse(response.responseText);
return typeNameTranslations;
} catch (error) {
console.error('获取类别翻译数据失败:', error);
throw error;
}
}
async function translateType(englishType) {
const translations = await getTypeNameTranslations();
return englishType.trim().split('—').map((part, index) => {
const words = part.trim().split(/\s+/);
const translatedWords = words.map(word => translations[word] || word);
return index === 0 ? translatedWords.join('') : translatedWords.join('/');
}).join(' ~ ');
}
async function main() {
const cardProfiles = document.querySelectorAll('.card-profile');
const containers = cardProfiles.length > 0 ? cardProfiles : [document];
for (const container of containers) {
const cardInfo = getCardInfoFromDOM(container);
if (!cardInfo) continue;
const cardId = `${cardInfo.setCode}_${cardInfo.collectorNumber}`;
container.dataset.cardId = cardId;
try {
saveOriginalContent(container);
addToggleButton(true, container);
const chineseData = await getChineseCardData(cardInfo.setCode, cardInfo.collectorNumber);
const scryfallFaceCount = container.querySelectorAll('.card-text-title').length || 1;
if (scryfallFaceCount === 1 || !chineseData.front) {
await saveSingleFacedCard(chineseData, container);
} else {
await saveDoubleFacedCard(chineseData, container);
}
updateToggleButton(false, container);
const defaultToChinese = GM_getValue('defaultToChinese', false);
cardLanguageStates.set(cardId, false);
if (defaultToChinese) {
await toggleLanguage({
preventDefault: () => {},
target: container.querySelector('.print-langs-item')
}, cardId);
}
} catch (error) {
console.error('处理卡牌时出错:', error);
updateToggleButton(true, container);
}
}
}
function addToggleButton(loading = false, parent = document) {
const printLangs = parent.querySelector('.print-langs');
if (!printLangs) return;
const cardId = parent.dataset.cardId || document.location.pathname;
if (!parent.dataset.cardId) {
parent.dataset.cardId = cardId;
}
const toggleLink = document.createElement('a');
toggleLink.className = 'print-langs-item translate-toggle';
toggleLink.href = 'javascript:void(0);';
toggleLink.textContent = loading ? '加载中...' : (cardLanguageStates.get(cardId) ? '原文' : '汉化');
toggleLink.style.cursor = loading ? 'wait' : 'pointer';
if (!loading) {
toggleLink.addEventListener('click', (e) => toggleLanguage(e, cardId));
}
printLangs.insertBefore(toggleLink, printLangs.firstChild);
}
function updateToggleButton(error = false, parent = document) {
const toggleLink = parent.querySelector('.print-langs-item');
if (toggleLink) {
const cardId = parent.dataset.cardId || document.location.pathname;
toggleLink.textContent = error ? '加载失败' : (cardLanguageStates.get(cardId) ? '原文' : '汉化');
toggleLink.style.cursor = error ? 'not-allowed' : 'pointer';
if (error) {
toggleLink.removeEventListener('click', (e) => toggleLanguage(e, cardId));
} else {
toggleLink.addEventListener('click', (e) => toggleLanguage(e, cardId));
}
}
}
async function toggleLanguage(event, cardId) {
event.preventDefault();
const cardContainer = cardId === document.location.pathname
? document
: document.querySelector(`[data-card-id="${cardId}"]`);
if (!cardContainer) {
console.error('找不到卡牌容器');
return;
}
const elements = cardContainer.querySelectorAll(
'.card-text-card-name, .card-text-type-line, .card-text-oracle, .card-text-flavor, ' +
'.card-legality-item dt, .card-legality-item dd'
);
const toggleLink = event.target;
if (elements.length === 0 || !elements[0].dataset.chineseContent) {
console.error('中文数据尚未加载完成');
return;
}
const currentState = cardLanguageStates.get(cardId) || false;
cardLanguageStates.set(cardId, !currentState);
toggleLink.textContent = cardLanguageStates.get(cardId) ? '原文' : '汉化';
elements.forEach(el => {
if (el.dataset.chineseContent) {
[el.innerHTML, el.dataset.chineseContent] = [el.dataset.chineseContent, el.innerHTML];
}
});
}
function saveOriginalContent(parent = document) {
parent.querySelectorAll('.card-text-card-name, .card-text-type-line, .card-text-oracle, .card-text-flavor').forEach(el => {
el.dataset.originalContent = el.innerHTML;
});
}
async function saveSingleFacedCard(chineseData, parent = document) {
await saveCardFace(parent, parent, chineseData, 0);
console.log('中文数据已保存');
}
async function saveDoubleFacedCard(chineseData, parent = document) {
const cardTextDiv = parent.querySelector('.card-text');
if (!cardTextDiv) {
console.error('无法找到卡牌文本元素');
return;
}
const cardFaces = cardTextDiv.querySelectorAll('.card-text-title');
if (cardFaces.length !== 2) {
console.error('无法找到双面卡牌的元素');
return;
}
await Promise.all([
saveCardFace(cardTextDiv, cardFaces[0], chineseData.front, 0),
saveCardFace(cardTextDiv, cardFaces[1], chineseData.back, 1)
]);
}
async function saveCardFace(cardTextDiv, cardFace, faceData, faceIndex) {
await Promise.all([
saveElementText('.card-text-card-name', faceData.name, cardFace),
saveType(cardTextDiv.querySelectorAll('.card-text-type-line')[faceIndex], faceData.name),
saveCardText('.card-text-oracle', faceData.text, cardTextDiv, faceIndex),
faceData.flavorText ? saveCardText('.card-text-flavor', faceData.flavorText, cardTextDiv, faceIndex) : Promise.resolve(),
saveLegality(cardTextDiv)
]);
}
async function saveElementText(selector, text, parent = document) {
const element = parent.querySelector(selector);
if (element) {
element.dataset.chineseContent = text;
}
}
async function saveType(typeLineElement, cardName) {
if (!typeLineElement) return;
const colorIndicator = typeLineElement.querySelector('.color-indicator');
const typeText = typeLineElement.textContent.replace(colorIndicator ? colorIndicator.textContent.trim() : '', '').trim();
try {
const translatedType = await translateType(typeText);
typeLineElement.dataset.chineseContent = colorIndicator
? `${colorIndicator.outerHTML} ${translatedType}`
: translatedType;
} catch (error) {
console.error('翻译类型时出错:', error);
}
}
async function saveCardText(selector, text, parent = document, index = 0) {
const elements = parent.querySelectorAll(selector);
if (elements[index]) {
const preservedHtml = await preserveManaSymbols(elements[index].innerHTML, text);
elements[index].dataset.chineseContent = `<p>${preservedHtml.replace(/\n/g, '</p><p>')}</p>`;
}
}
async function preserveManaSymbols(originalHtml, chineseText) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalHtml;
const manaSymbols = tempDiv.querySelectorAll('abbr.card-symbol');
const symbolMap = new Map();
manaSymbols.forEach(symbol => {
const symbolText = symbol.title.match(/\{(.+?)\}/)?.[0] || symbol.textContent;
symbolMap.set(symbolText, (symbolMap.get(symbolText) || []).concat(symbol.outerHTML));
});
return Array.from(symbolMap).reduce((result, [symbolText, htmls]) => {
let index = 0;
return result.replace(new RegExp(escapeRegExp(symbolText), 'g'), () => {
const html = htmls[index];
index = (index + 1) % htmls.length;
return html;
});
}, chineseText);
}
const escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function makeRequest(method, url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
onload: resolve,
onerror: reject
});
});
}
const FORMAT_TRANSLATIONS = {
'Standard': '标准',
'Alchemy': '炼金',
'Pioneer': '先驱',
'Explorer': '探险',
'Modern': '摩登',
'Historic': '史迹',
'Legacy': '薪传',
'Brawl': '争锋',
'Vintage': '特选',
'Timeless': '永恒',
'Commander': '指挥官',
'Pauper': '纯铁',
'Oathbreaker': '破誓',
'Penny': '便士'
};
const LEGALITY_TRANSLATIONS = {
'Legal': '合法',
'Not Legal': '不合法',
'Banned': '禁用',
'Restrict.': '限制'
};
async function saveLegality(parent = document) {
const legalityItems = parent.querySelectorAll('.card-legality-item');
legalityItems.forEach(item => {
const format = item.querySelector('dt');
const legality = item.querySelector('dd');
if (format && legality) {
const formatText = format.textContent.trim();
format.dataset.chineseContent = FORMAT_TRANSLATIONS[formatText] || formatText;
const legalityText = legality.textContent.trim();
legality.dataset.chineseContent = LEGALITY_TRANSLATIONS[legalityText] || legalityText;
}
});
}
main();
})();