// ==UserScript==
// @name Tachi to Tachi Universal Export
// @namespace https://kamai.tachi.ac/
// @version 1.0
// @description Universal export script for Tachi scores (Chunithm, Ongeki, Wacca, MaiMaiDX)
// @author pinapelz
// @match https://kamai.tachi.ac/u/*/games/chunithm/Single/sessions/*/scores
// @match https://kamai.tachi.ac/u/*/games/ongeki/Single/sessions/*/scores
// @match https://kamai.tachi.ac/u/*/games/wacca/Single/sessions/*/scores
// @match https://kamai.tachi.ac/u/*/games/maimaidx/Single/sessions/*/scores
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Utility functions
function toUnixMillis(s) {
try { return new Date(s).getTime(); }
catch { return null; }
}
function detectGame() {
const url = window.location.href;
if (url.includes('/games/chunithm/')) return 'chunithm';
if (url.includes('/games/ongeki/')) return 'ongeki';
if (url.includes('/games/wacca/')) return 'wacca';
if (url.includes('/games/maimaidx/')) return 'maimaidx';
return null;
}
function waitForRows() {
return new Promise((resolve) => {
const check = () => {
const rows = document.querySelectorAll("table tbody tr");
if (rows.length > 0) resolve(rows);
else setTimeout(check, 500);
};
check();
});
}
function downloadJSON(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Ongeki parser
function parseOngekiScores() {
const rows = document.querySelectorAll("table tbody tr");
const scores = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.classList.contains("expandable-pseudo-row") || row.classList.contains("fake-row")) continue;
const cells = row.querySelectorAll("td");
if (cells.length < 10) continue;
// Chart info is in first column
let chartText = cells[0].querySelector("div.d-none.d-lg-block")?.textContent.trim() ||
cells[0].querySelector("div.d-lg-none")?.textContent.trim() || "";
let chart = chartText.split(/\s+/)[0].toUpperCase();
if (chart === "EXP") chart = "EXPERT";
if (!["BASIC", "ADVANCED", "EXPERT", "MASTER", "LUNATIC"].includes(chart)) continue;
// Song info is in third column (index 2)
const anchor = cells[2].querySelector("a");
if (!anchor) continue;
const [titleHtml, artistHtml] = anchor.innerHTML.split("
");
const temp = document.createElement("div");
temp.innerHTML = titleHtml;
const title = temp.textContent.trim();
temp.innerHTML = artistHtml || "";
const artist = temp.textContent.trim();
// Score info is in fourth column (index 3)
const scoreText = cells[3].innerText.trim().split("\n").pop().replace(/,/g, "");
const score = parseInt(scoreText, 10);
if (isNaN(score)) continue;
// Platinum score is in fifth column (index 4)
let platinumScore = 0;
const platinumText = cells[4].innerText.trim();
const platinumMatch = platinumText.match(/\[(\d+)\/(\d+)\]/);
if (platinumMatch) {
platinumScore = parseInt(platinumMatch[1], 10);
}
// Judgements are in sixth column (index 5)
let cbreak = 0, breaks = 0, hit = 0, miss = 0, bellCount = 0, totalBellCount = 0;
const judgementDiv = cells[5].querySelector("strong > div");
if (judgementDiv) {
const judgementSpans = judgementDiv.querySelectorAll("span");
if (judgementSpans.length >= 4) {
cbreak = parseInt(judgementSpans[0].textContent) || 0;
breaks = parseInt(judgementSpans[1].textContent) || 0;
hit = parseInt(judgementSpans[2].textContent) || 0;
miss = parseInt(judgementSpans[3].textContent) || 0;
}
// Bell count is in a separate div within the same cell
const bellDiv = judgementDiv.nextElementSibling;
if (bellDiv) {
const bellMatch = bellDiv.textContent.match(/(\d+)\/(\d+)/);
if (bellMatch) {
bellCount = parseInt(bellMatch[1]);
totalBellCount = parseInt(bellMatch[2]);
}
}
}
// Damage is in seventh column (index 6)
let damage = 0;
const damageText = cells[6].innerText.trim();
damage = parseInt(damageText, 10) || 0;
// Lamp is in eighth column (index 7)
const noteLamp = (cells[7].innerText.trim() || "UNKNOWN").toUpperCase();
// Timestamp is in tenth column (index 9)
let timeAchieved = null;
const smallTags = cells[9].querySelectorAll("small");
if (smallTags.length > 0) {
timeAchieved = toUnixMillis(smallTags[0].textContent.trim());
}
scores.push({
score,
platinumScore,
noteLamp,
bellLamp: "NONE",
matchType: "songTitle",
identifier: title,
artist,
difficulty: chart,
timeAchieved,
judgements: { cbreak, break: breaks, hit, miss },
optional: { bellCount, totalBellCount, damage }
});
}
return {
meta: {
game: "ongeki",
playtype: "Single",
service: "Tampermonkey Tachi Universal Export"
},
scores
};
}
// Chunithm parser
function parseChunithmScores() {
const rows = document.querySelectorAll("table tbody tr");
const scores = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.classList.contains("expandable-pseudo-row") || row.classList.contains("fake-row")) continue;
const cells = row.querySelectorAll("td");
if (cells.length < 8) continue;
// Chart info is in first column (index 0)
let difficultyText = cells[0].querySelector("div.d-none.d-lg-block")?.textContent.trim() ||
cells[0].querySelector("div.d-lg-none")?.textContent.trim() || "";
let difficulty = difficultyText.split(/\s+/)[0].toUpperCase();
// Map single letter difficulties
if (difficulty === "B") difficulty = "BASIC";
else if (difficulty === "A") difficulty = "ADVANCED";
else if (difficulty === "E") difficulty = "EXPERT";
else if (difficulty === "M") difficulty = "MASTER";
// Song info is in third column (index 2)
const songAnchor = cells[2].querySelector("a");
if (!songAnchor) continue;
const [titleHtml, artistHtml] = songAnchor.innerHTML.split("
");
const temp = document.createElement("div");
temp.innerHTML = titleHtml;
const title = temp.textContent.trim();
temp.innerHTML = artistHtml || "";
const artist = temp.textContent.trim();
// Score info is in fourth column (index 3)
const scoreText = cells[3].innerText.trim().split("\n").pop().replace(/,/g, "");
const scoreValue = parseInt(scoreText, 10);
if (isNaN(scoreValue)) continue;
// Judgements are in fifth column (index 4)
const judgementSpans = cells[4].querySelectorAll("span");
let jcrit = 0, justice = 0, attack = 0, miss = 0;
if (judgementSpans.length >= 4) {
jcrit = parseInt(judgementSpans[0].textContent) || 0;
justice = parseInt(judgementSpans[1].textContent) || 0;
attack = parseInt(judgementSpans[2].textContent) || 0;
miss = parseInt(judgementSpans[3].textContent) || 0;
}
// Lamp is in sixth column (index 5)
const lampText = cells[5].innerText.trim();
let clearLamp = "FAILED";
let noteLamp = "NONE";
if (lampText.includes("FULL COMBO")) {
noteLamp = "FULL COMBO";
clearLamp = "CLEAR";
}
if (lampText.includes("CLEAR")) {
clearLamp = "CLEAR";
}
if (lampText.includes("ALL JUSTICE")) {
noteLamp = "ALL JUSTICE";
clearLamp = "CLEAR";
}
if (lampText.includes("ALL JUSTICE CRITICAL")) {
noteLamp = "ALL JUSTICE CRITICAL";
clearLamp = "CLEAR";
}
if (lampText.includes("HARD")) {
clearLamp = "HARD";
}
if (lampText.includes("BRAVE")) {
clearLamp = "BRAVE";
}
if (lampText.includes("ABSOLUTE")) {
clearLamp = "ABSOLUTE";
}
if (lampText.includes("CATASTROPHY")) {
clearLamp = "CATASTROPHY";
}
// Timestamp is in eighth column (index 7)
let timeAchieved = null;
const smallTags = cells[7].querySelectorAll("small");
if (smallTags.length > 0) {
timeAchieved = toUnixMillis(smallTags[0].textContent.trim());
}
const score = {
score: scoreValue,
clearLamp,
noteLamp,
matchType: "songTitle",
difficulty,
identifier: title,
artist,
judgements: {
jcrit,
justice,
attack,
miss,
},
timeAchieved
};
scores.push(score);
}
return {
meta: {
game: "chunithm",
playtype: "Single",
service: "Tampermonkey Tachi Universal Export"
},
scores
};
}
// MaiMaiDX parser
async function parseMaimaidxScores() {
const difficultyMap = {
"DX Basic": "DX Basic",
"DX Advanced": "DX Advanced",
"DX Expert": "DX Expert",
"DX Master": "DX Master",
"DX Re:Master": "DX Re:Master",
"Expert": "Expert",
"Advanced": "Advanced",
"Basic": "Basic",
"Master": "Master"
};
const lampMap = {
"FAILED": "FAILED",
"CLEAR": "CLEAR",
"FULL COMBO": "FULL COMBO",
"FULL COMBO+": "FULL COMBO+",
"ALL PERFECT": "ALL PERFECT",
"ALL PERFECT+": "ALL PERFECT+"
};
const gradeList = ["D", "C", "B", "BB", "BBB", "A", "AA", "AAA", "S", "S+", "SS", "SS+", "SSS", "SSS+"];
const rows = await waitForRows();
const scores = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.classList.contains("expandable-pseudo-row") || row.classList.contains("fake-row")) continue;
const cells = row.querySelectorAll("td");
if (cells.length < 9) continue;
const difficultyText = cells[0].innerText.split("\n")[0].trim();
const difficulty = Object.keys(difficultyMap).find(d => difficultyText.includes(d)) || difficultyText;
const titleAnchor = cells[2].querySelector("a");
const title = titleAnchor?.childNodes[0]?.textContent.trim() || "";
const artist = titleAnchor?.querySelector("small")?.textContent.trim() || "";
const percentText = cells[3].innerText.trim();
const percent = parseFloat(percentText.match(/([\d.]+)%/)?.[1]) || 0;
const grade = gradeList.find(g => percentText.includes(g)) || "D";
const judgmentSpans = cells[4].querySelectorAll("span");
const [pcrit, perfect, great, good, miss] = Array.from(judgmentSpans).map(span => parseInt(span.textContent.trim()));
const lampText = cells[5].innerText.trim();
const lamp = lampMap[lampText] || "FAILED";
const timeText = cells[7].querySelector("small")?.textContent?.trim();
let timeAchieved = null;
if (timeText) {
const parsed = new Date(timeText);
if (!isNaN(parsed)) {
timeAchieved = parsed.getTime();
}
}
scores.push({
identifier: title,
artist,
difficulty,
percent,
lamp,
judgements: {
pcrit,
perfect,
great,
good,
miss
},
matchType: "songTitle",
timeAchieved: timeAchieved
});
}
return {
meta: {
game: "maimaidx",
playtype: "Single",
service: "Tampermonkey Tachi Universal Export"
},
scores
};
}
// Wacca parser
function parseWaccaScores() {
const rows = document.querySelectorAll("table tbody tr");
const scores = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Skip expandable/fake rows
if (row.classList.contains("expandable-pseudo-row") || row.classList.contains("fake-row")) continue;
const cells = row.querySelectorAll("td");
// Ensure we have enough columns (Wacca table has ~9 columns)
if (cells.length < 8) continue;
// --- 1. Chart/Difficulty (Column 0) ---
let chartText = cells[0].querySelector("div.d-none.d-lg-block")?.textContent.trim() ||
cells[0].querySelector("div.d-lg-none")?.textContent.trim() || "";
// Clean up difficulty text (e.g. "EXPERT 11" -> "EXPERT")
let chart = chartText.split(/\s+/)[0].toUpperCase();
// Map abbreviations to Tachi standard difficulties
if (chart === "EXP") chart = "EXPERT";
if (chart === "INF") chart = "INFERNO";
if (chart === "HARD") chart = "HARD";
if (chart === "NORM" || chart === "NORMAL") chart = "NORMAL";
// Skip if difficulty isn't recognized
if (!["NORMAL", "HARD", "EXPERT", "INFERNO"].includes(chart)) continue;
// --- 2. Song Info (Column 2) ---
const anchor = cells[2].querySelector("a");
if (!anchor) continue;
const titleNode = anchor.childNodes[0];
const title = titleNode ? titleNode.textContent.trim() : "";
const artistNode = anchor.querySelector("small");
const artist = artistNode ? artistNode.textContent.trim() : "";
// --- 3. Score (Column 3) ---
// Format: S+ (newline) 936,217
const scoreText = cells[3].innerText.trim().split("\n").pop().replace(/,/g, "");
const score = parseInt(scoreText, 10);
if (isNaN(score)) continue;
// --- 4. Judgements (Column 4) ---
// Format: Marvelous-Great-Good-Miss (inside spans)
const judgeSpans = cells[4].querySelectorAll("span");
let marvelous = 0, great = 0, good = 0, miss = 0;
if (judgeSpans.length >= 4) {
marvelous = parseInt(judgeSpans[0].textContent) || 0;
great = parseInt(judgeSpans[1].textContent) || 0;
good = parseInt(judgeSpans[2].textContent) || 0;
miss = parseInt(judgeSpans[3].textContent) || 0;
}
// --- 5. Lamp (Column 5) ---
// Tachi expects: "FAILED", "CLEAR", "MISSLESS", "FULL COMBO", "ALL MARVELOUS"
let lamp = cells[5].innerText.trim().toUpperCase();
// Normalize Lamp if necessary
if (lamp === "UNKNOWN" || lamp === "") lamp = "FAILED";
// Map "AM" to "ALL MARVELOUS" if the site abbreviates it,
// otherwise standard text like "MISSLESS" or "CLEAR" is valid.
// --- 6. Timestamp (Column 7) ---
let timeAchieved = null;
const smallTags = cells[7].querySelectorAll("small");
if (smallTags.length > 0) {
timeAchieved = toUnixMillis(smallTags[0].textContent.trim());
}
scores.push({
score,
lamp, // Correct key for Tachi (was noteLamp)
matchType: "songTitle",
identifier: title,
artist,
difficulty: chart,
timeAchieved,
judgements: {
marvelous,
great,
good,
miss
}
});
}
return {
meta: {
game: "wacca",
playtype: "Single",
service: "Tampermonkey Tachi Universal Export"
},
scores
};
}
// Add export button to page
function addExportButton() {
const button = document.createElement("button");
button.textContent = "Export Scores";
button.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
background: #007bff;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
`;
button.onmouseover = () => button.style.background = "#0056b3";
button.onmouseout = () => button.style.background = "#007bff";
button.onclick = async () => {
const game = detectGame();
if (!game) {
alert("Could not detect game type from URL");
return;
}
button.textContent = "Exporting...";
button.disabled = true;
try {
let result;
let filename;
switch (game) {
case 'ongeki':
result = parseOngekiScores();
filename = "ongeki_session_scores.json";
break;
case 'chunithm':
result = parseChunithmScores();
filename = "chunithm_session_scores.json";
break;
case 'maimaidx':
result = await parseMaimaidxScores();
filename = "maimaidx_session_scores.json";
break;
case 'wacca':
result = parseWaccaScores();
filename = "wacca_session_scores.json";
break;
default:
throw new Error("Unsupported game: " + game);
}
if (result.scores.length === 0) {
alert("No scores found to export");
return;
}
downloadJSON(result, filename);
alert(`Exported ${result.scores.length} scores for ${game.toUpperCase()}`);
} catch (error) {
console.error("Export error:", error);
alert("Error exporting scores: " + error.message);
} finally {
button.textContent = "Export Scores";
button.disabled = false;
}
};
document.body.appendChild(button);
}
// Initialize when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addExportButton);
} else {
addExportButton();
}
})();