// ==UserScript==
// @name Challonge智慧大头
// @namespace http://tampermonkey.net/
// @version 1.3.4
// @description 导出Challonge赛事结果为固定格式的CSV表格
// @author Kulanx
// @match https://challonge.com/*
// @connect api.challonge.com
// @require https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js
// @require https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js
// @require https://unpkg.com/element-ui/lib/index.js
// @resource ELEMENT_UI https://unpkg.com/element-ui/lib/theme-chalk/index.css
// @grant GM_getResourceText
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function () {
'use strict';
let nanoid = (t = 21) => { let e = "", r = crypto.getRandomValues(new Uint8Array(t)); for (; t--;) { let n = 63 & r[t]; e += n < 36 ? n.toString(36) : n < 62 ? (n - 26).toString(36).toUpperCase() : n < 63 ? "_" : "-" } return e };
const ELEMENT_UI = GM_getResourceText("ELEMENT_UI");
GM_addStyle(ELEMENT_UI);
$("body").append('<div id="vueroot"></div>');
const vm = new Vue({
el: "#vueroot",
template: `
<div>
<el-dialog
title="导出Challonge赛事结果为CSV表格 v1.3.4"
:visible.sync="dialogVisible"
:before-close = "handleClose"
:close-on-click-modal = "false"
width="860px" >
<el-tooltip content="接口密钥" placement="left-start">
<span><h3 style="color:#2b303d;">API Key</h3></span>
</el-tooltip>
<p>
获取方式:登录Challonge账号后,从<a href="https://challonge.com/settings/developer">https://challonge.com/settings/developer</a>获得。
</p>
<p>
示例:8ljkjzIs0Wvcc7ZY0Zt7diqEvSqWjGCjmGYe32e0
</p>
<br/>
<el-input v-model="config.apiKeySetTo" placeholder="请输入API Key"></el-input>
</br></br>
<hr/>
<br/>
<el-tooltip content="从赛事URL获取" placement="left-start">
<span><h3 style="color:#2b303d;">赛事码</h3></span>
</el-tooltip>
<p>常规赛事示例:7oau1ofl(比赛链接为https://challonge.com/zh_CN/7oau1ofl时,赛事码是7oau1ofl。)</p>
<p>建在"社区"中的赛事示例:511a323c8dc081f3bbd2dc93-6j4kx1bq。获取方式请参考<a href="https://docs.qq.com/doc/DT0NudVJod3RRdXFI">使用说明</a>的“使用Challonge社区”部分。</p>
<br/>
<el-input v-model="config.contestUrlSetTo" placeholder="请输入赛事码"></el-input>
</br></br><hr/>
<el-tooltip content="未进行的对局设置" placement="left-start">
<span><h3 style="color:#2b303d;">弃赛/轮空设置</h3></span>
</el-tooltip>
<p>Challonge不会记录轮空,由于数据是从接口读取的,所以<b>轮空的胜场需要手动加入</b></p>
<p>勾选此项可以避免破平表“是否出错”列由于缺少对手而出现“错误”,并不影响胜率计算。</p>
<el-switch v-model="config.sudoPlayerSwitch" active-text="加入0号选手作为占位符"></el-switch>
<span slot="footer" class="dialog-footer">
<span style="color:darkgrey;">保存后记得刷新一下网页</span>
<el-button type="primary" @click="saveConfig">保存设置</el-button>
<el-button type="success" @click="saveCsvFile">下载CSV</el-button>
</span>
</el-dialog>
</div>`,
data() {
return {
dialogVisible: false,
config: {
apiKeySetTo: "",
contestUrlSetTo: "",
sudoPlayerSwitch: false,
},
}
},
methods: {
handleClose() {
this.$confirm('是否保存当前设置?')
.then(_ => {
this.saveConfig();
})
.catch(_ => {
this.dialogVisible = false;
});
},
saveConfig() {
GM_setValue("config", this.config);
this.dialogVisible = false;
},
saveCsvFile() {
getCsvFile_outside();
},
showDialog() {
this.dialogVisible = true;
},
},
mounted() {
var oldConfig = GM_getValue("config");
if (oldConfig === undefined) {
GM_setValue("config", this.config);
} else {
var newConfigKeys = Object.keys(this.config);
var setOld = new Set(Object.keys(oldConfig));
var toBeUpdateKeys = newConfigKeys.filter(a => !setOld.has(a))
if (toBeUpdateKeys.length != 0) {
var that = this;
toBeUpdateKeys.forEach(function (key) {
oldConfig[key] = that.config[key];
})
}
this.config = oldConfig;
}
},
})
$('.is-hidden-mobile > .tabbed-navlist.varnish-logged-in').append('<li class="item" id="scriptConfig"><a class="link "></a></li>')
$('#scriptConfig').children().text("导出结果").click(function (e) {
vm.showDialog();
e.preventDefault();
});
$('.el-dialog__close').css("color", "black");
$('.el-dialog__close').text("X");
// Get match list then participants.
async function getCsvFile_outside() {
// Get matches
var matchList = await makeGetRequest(challongeUrl("matches"));
console.log(challongeUrl("matches"))
// Get participants
var participants = await makeGetRequest(challongeUrl("participants"));
console.log(challongeUrl("participants"))
try {
let m_obj = JSON.parse(matchList);
let p_obj = JSON.parse(participants);
generateCsvContent(m_obj, p_obj);
} catch (e) {
alert('请输入正确的API Key和赛事码 ' + e);
}
}
// player: {
// id: player_id(UI不可见),
// seed: <challonge排序>,
// name: <选手昵称>,
// matches: [{opponent:<对手seed>, score:<胜局数>, opponent_score:<负局数>} * 5],
// count: <参加场数> }
function generateCsvContent(matches, participants) {
const results = [];
// Fill in participants with empty matches
for (const p of participants) {
const cur = {
id: p.participant.id,
seed: p.participant.seed,
name: p.participant.name,
count: 0,
matches: Array(5),
};
results.push(cur);
}
const multipleGames = hasMultipleGames(matches)
// Fill in matches
for (const m of matches) {
fillInSinglePlayer(m.match, results);
}
console.log('finish parsing');
console.log(results);
// Generate file content based on the number of games
let fileContent;
if (multipleGames) {
fileContent = multipleGamesResultsToString(results)
} else {
fileContent= bo1ResultsToString(results)
}
console.log(fileContent);
downloadCsv(fileContent);
}
// Loop through input matches to find a complete game
// return true if the game is bo3
// return false otherwise
function hasMultipleGames(matches) {
// If the match is not finished, skip
for (const match of matches) {
const m = match.match;
if (m.state === "complete") {
// Extract player scores from csv_score
const scores = m.scores_csv.split('-');
const p1_score = parseInt(scores[0], 10);
const p2_score = parseInt(scores[1], 10);
return p1_score > 1 || p2_score > 1
}
}
return false;
}
// Download a csv file with filename 'challonge.csv'
// TODO: customize download file name
function downloadCsv(csvStr) {
var hiddenElement = document.createElement('a');
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csvStr);
hiddenElement.target = '_blank';
hiddenElement.download = 'challonge.csv';
hiddenElement.click();
}
// Convert header to a comma seperated string for a bo1 game
// append each player object as a row of csv
function bo1ResultsToString(results) {
// Append header
const headers = ["编号","参赛人员","参与场数","对手1","得分1","对手2","得分2","对手3","得分3","对手4","得分4","对手5","得分5"];
let csvContent = headers.join(',') + '\n';
for (const cur of results) {
csvContent += bo1ResultToRow(cur);
}
// placeholder for empty rounds
// Use player 33 if the sudo player switch is on.
if (vm.config.sudoPlayerSwitch) {
csvContent += '0,,0,,0,,0,,0,,0,,0'
}
return csvContent;
}
// Convert header to a comma seperated string for a bo1 game
// append each player object as a row of csv
function multipleGamesResultsToString(results) {
// Append header
const headers = ["编号","参赛人员","参与场数",
"对手1","得分1","胜局1","败局1",
"对手2","得分2","胜局2","败局2",
"对手3","得分3","胜局3","败局3",
"对手4","得分4","胜局4","败局4",
"对手5","得分5","胜局5","败局5"];
let csvContent = headers.join(',') + '\n';
for (const cur of results) {
csvContent += multipleGamesResultToRow(cur);
}
// placeholder for empty rounds
// Use player 33 if the sudo player switch is on.
if (vm.config.sudoPlayerSwitch) {
csvContent += '0,,0,,0,,,,0,,,,0,,,,0,,,,0'
}
return csvContent;
}
// convert the result object into a csv row
function bo1ResultToRow(result) {
let row = result.seed + ',' + result.name + ',' + result.count + ',';
for (let i = 0; i < result.matches.length; i++) {
const m = result.matches[i];
if (m != null) {
// If match exists
row += m.opponent + ',' + m.score + ',';
} else {
// If match doesn't exist
// Use player 33 if the sudo player switch is on, empty otherwise.
if (vm.config.sudoPlayerSwitch) {
row += '0,0,';
} else {
row += ',,';
}
}
}
return row + '\n';
}
// convert the result object into a csv row
function multipleGamesResultToRow(result) {
let row = result.seed + ',' + result.name + ',' + result.count + ',';
for (let i = 0; i < result.matches.length; i++) {
const m = result.matches[i];
if (m != null) {
// If match exists
const isWinner = m.score > m.opponent_score ? 1 : 0;
row += m.opponent + ',' + isWinner + ',' + m.score + ',' + m.opponent_score + ',';
} else {
// If match doesn't exist
// Use player 33 if the sudo player switch is on, empty otherwise.
if (vm.config.sudoPlayerSwitch) {
row += '0,0,0,0,';
} else {
row += ',,,,';
}
}
}
return row + '\n';
}
// Update player match results with the match array
function fillInSinglePlayer(match, players) {
// If the match is not finished, skip
if (match.state != "complete") {
return;
}
// Extract player scores from csv_score
const scores = match.scores_csv.split('-');
const p1_score = scores[0];
const p2_score = scores[1];
// Find the corresponding seed to each player
const p1 = findSeedById(match.player1_id, players);
let p1Assigned = false;
const p2 = findSeedById(match.player2_id, players);
let p2Assigned = false;
const round = match.round - 1;
// Assign opponents and scores
for (let i = 0; i < players.length; i++) {
let p = players[i];
// Assign p1
if (p.seed === p1) {
p.matches[round] = {"opponent": p2, "score": p1_score, "opponent_score": p2_score};
p.count += 1;
p1Assigned = true;
}
// Assign p2
if (p.seed === p2) {
p.matches[round] = {"opponent": p1, "score": p2_score, "opponent_score": p1_score};
p.count += 1;
p2Assigned = true;
}
// Return early if both players are found
if (p1Assigned && p2Assigned) {
return;
}
}
}
function findSeedById(id, players) {
for (let i = 0; i < players.length; i++) {
let p = players[i];
if (p.id === id) {
return p.seed;
}
}
}
// Input: A data type ("matches" or "participants")
// Output: https://api.challonge.com/v1/tournaments/{tournament}/{type}.{json|xml}
function challongeUrl(type) {
return "https://api.challonge.com/v1/tournaments/" + vm.config.contestUrlSetTo + "/" + type + ".json?api_key=" + vm.config.apiKeySetTo;
}
// A get request that guarentees eventual completion.
function makeGetRequest(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
if (response.status === 404) {
handleError(response);
return;
}
resolve(response.responseText);
},
onerror: function(error) {
reject(error.responseText)
},
});
});
}
// Handle API Key error and contest not found error
function handleError(error) {
try {
const errorText = JSON.parse(error.responseText).errors[0];
if (errorText === "Invalid API key") {
alert('API Key不存在,请根据说明正确获取喵!');
} else if (errorText === "Requested tournament not found") {
alert('赛事不存在或社区赛事码格式不正确。\n社区赛事请参考使用说明的“使用Challonge社区”部分:\nhttps://docs.qq.com/doc/DT0NudVJod3RRdXFI');
} else if (errorText.startsWith("Requested tournament not found for subdomain")){
alert('所提供的社区中无法找到对应赛事。\n赛事码格式请参考使用说明中的“使用Challonge社区”部分:\nhttps://docs.qq.com/doc/DT0NudVJod3RRdXFI');
} else {
alert("未知错误喵!请联系脚本提供者~" + errorText);
}
} catch(e) {
alert("未知错误喵!请联系脚本提供者~" + error.responseText);
}
}
})();