// ==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(); } })();