aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2025-07-07 21:44:03 -0700
committerPinapelz <yukais@pinapelz.com>2025-07-07 21:44:03 -0700
commit7ffc3d1ee454587f9ede43dd95d33313d0854a64 (patch)
tree77798b023255c6d63d525b9dcefc1d62bec5e988
parentdfa18eead3f241a8dffa954863091969df3b7e16 (diff)
merge tachi_to_tachi scripts into universal userscript
-rw-r--r--chuni/tachi/README.md2
-rw-r--r--mai2/tachi/README.md1
-rw-r--r--ongeki/tachi/README.md1
-rw-r--r--tachi_to_tachi/README.md13
-rw-r--r--tachi_to_tachi/tachi_universal_export.user.js518
-rw-r--r--wacca/tachi/README.md1
6 files changed, 536 insertions, 0 deletions
diff --git a/chuni/tachi/README.md b/chuni/tachi/README.md
index a145e4f..c11178b 100644
--- a/chuni/tachi/README.md
+++ b/chuni/tachi/README.md
@@ -1,3 +1,5 @@
+# Not Maintained. Use the universal script for sessions
+
# CHUNITHM Tachi to Tachi
This is a bookmarklet that will allow you to download data shown in the Web UI of Tachi into a `Batch-Manual` compatible form again.
diff --git a/mai2/tachi/README.md b/mai2/tachi/README.md
index 2a206db..ac01b10 100644
--- a/mai2/tachi/README.md
+++ b/mai2/tachi/README.md
@@ -1,3 +1,4 @@
+# Not Maintained. Use the universal script for sessions
# maimai DX Tachi to Tachi
This is a bookmarklet that will allow you to download data shown in the Web UI of Tachi into a `Batch-Manual` compatible form again.
diff --git a/ongeki/tachi/README.md b/ongeki/tachi/README.md
new file mode 100644
index 0000000..bc1c4c2
--- /dev/null
+++ b/ongeki/tachi/README.md
@@ -0,0 +1 @@
+# Not Maintained. Use the universal script for sessions
diff --git a/tachi_to_tachi/README.md b/tachi_to_tachi/README.md
new file mode 100644
index 0000000..3a3b4be
--- /dev/null
+++ b/tachi_to_tachi/README.md
@@ -0,0 +1,13 @@
+# Tachi to Tachi
+Supports:
+- ONGEKI
+- WACCA
+- CHUNITHM
+- maimai DX
+
+Visit the session page for a particular game i.e:
+`https://TACHI_INSTANCE.com/u/{USERNAME}/games/{GAME}/Single/sessions/{SESSION ID}/scores`
+
+Then refresh the page, a button will show on the top right that exports all scores for the current session.
+
+** Ensure that all scores are displayed on screen when scraping. (Suggested to always scroll down and change the display amount to 100)
diff --git a/tachi_to_tachi/tachi_universal_export.user.js b/tachi_to_tachi/tachi_universal_export.user.js
new file mode 100644
index 0000000..f4f2a32
--- /dev/null
+++ b/tachi_to_tachi/tachi_universal_export.user.js
@@ -0,0 +1,518 @@
+// ==UserScript==
+// @name Tachi Universal Export
+// @namespace https://tachi.pinapelz.com/
+// @version 1.0
+// @description Universal export script for Tachi scores (Chunithm, Ongeki, Wacca, MaiMaiDX)
+// @author pinapelz
+// @match https://tachi.pinapelz.com/u/*/games/chunithm/Single/sessions/*/scores
+// @match https://tachi.pinapelz.com/u/*/games/ongeki/Single/sessions/*/scores
+// @match https://tachi.pinapelz.com/u/*/games/wacca/Single/sessions/*/scores
+// @match https://tachi.pinapelz.com/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("<br>");
+ 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.table tbody tr");
+ const difficultyMap = {
+ E: "Expert",
+ A: "Advanced",
+ B: "Basic",
+ M: "Master"
+ };
+ const scores = [];
+
+ for (let i = 0; i < rows.length; i += 3) {
+ const row = rows[i];
+ if (!row) continue;
+
+ const cells = row.querySelectorAll("td");
+ if (cells.length < 11) continue;
+
+ let difficulty = cells[1].innerText.trim().replace(/\n/, " ").split(" ")[0];
+ if (difficulty.length === 1) {
+ difficulty = difficultyMap[difficulty] || difficulty;
+ }
+
+ const songAnchor = cells[3].querySelector("a");
+ const title = songAnchor?.childNodes[0]?.textContent.trim() || "";
+ const artist = songAnchor?.querySelector("small")?.textContent.trim() || "";
+
+ const scoreRank = cells[5].querySelector("strong")?.innerText.trim() || "";
+ const scoreValue = parseInt(
+ cells[5].innerText.replace(scoreRank, "").trim().replace(/,/g, "")
+ );
+
+ const judgementText = cells[6].innerText.trim();
+ const parts = judgementText.split("-").map((x) => parseInt(x.trim()));
+ const [jcrit, justice, attack, miss] = parts;
+
+ const fastSlowMatch = judgementText.match(/\(F:(\d+)\s+S:(\d+)\)/);
+ const fast = fastSlowMatch ? parseInt(fastSlowMatch[1]) : undefined;
+ const slow = fastSlowMatch ? parseInt(fastSlowMatch[2]) : undefined;
+
+ const lamp = cells[7].innerText.trim();
+ let clearLamp = "FAILED";
+ let noteLamp = "NONE";
+
+ if (lamp.includes("FULL COMBO")) {
+ noteLamp = "FULL COMBO";
+ clearLamp = "CLEAR";
+ }
+ if (lamp.includes("CLEAR")) {
+ clearLamp = "CLEAR";
+ }
+ if (lamp.includes("ALL JUSTICE")) {
+ noteLamp = "ALL JUSTICE";
+ clearLamp = "CLEAR";
+ }
+ if (lamp.includes("ALL JUSTICE CRITICAL")) {
+ noteLamp = "ALL JUSTICE CRITICAL";
+ clearLamp = "CLEAR";
+ }
+ if (lamp.includes("HARD")) {
+ clearLamp = "HARD";
+ }
+ if (lamp.includes("BRAVE")) {
+ clearLamp = "BRAVE";
+ }
+ if (lamp.includes("ABSOLUTE")) {
+ clearLamp = "ABSOLUTE";
+ }
+ if (lamp.includes("CATASTROPHY")) {
+ clearLamp = "CATASTROPHY";
+ }
+
+ const timestampText = cells[10].innerText.trim().split("\n");
+ const timestampString = timestampText[1]?.trim() || "";
+ const timeAchieved = timestampString ? new Date(timestampString).getTime() : 0;
+
+ const score = {
+ score: scoreValue,
+ clearLamp,
+ noteLamp,
+ matchType: "songTitle",
+ difficulty,
+ identifier: title,
+ artist,
+ judgements: {
+ jcrit,
+ justice,
+ attack,
+ miss,
+ },
+ timeAchieved
+ };
+
+ if (fast !== undefined && slow !== undefined) {
+ score.judgements.fast = fast;
+ score.judgements.slow = slow;
+ }
+
+ 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"
+ };
+
+ 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 (using Ongeki PB structure as base)
+ function parseWaccaScores() {
+ const rows = document.querySelectorAll("table tbody tr");
+ const scores = [];
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ const cells = row.querySelectorAll("td");
+ if (cells.length < 11) continue;
+
+ let chart = cells[0].innerText.trim().split(/\s+/)[0].toUpperCase();
+ if (chart === "EXP") chart = "EXPERT";
+ if (!["BASIC", "ADVANCED", "EXPERT", "MASTER", "LUNATIC"].includes(chart)) continue;
+
+ const anchor = cells[2].querySelector("a");
+ if (!anchor) continue;
+
+ const parts = anchor.innerHTML.split("<br>");
+ const temp = document.createElement("div");
+ temp.innerHTML = parts[0];
+ const title = temp.textContent.trim();
+ temp.innerHTML = parts[1] || "";
+ const artist = temp.textContent.trim();
+
+ const scoreText = cells[4].innerText.trim().split("\n").pop().replace(/,/g, "");
+ const score = parseInt(scoreText, 10);
+ if (isNaN(score)) continue;
+
+ const noteLamp = (cells[7].innerText.trim() || "UNKNOWN").toUpperCase();
+
+ const small = cells[10].querySelector("small");
+ const dateText = small?.textContent.trim();
+ const timeAchieved = dateText ? toUnixMillis(dateText) : null;
+
+ const judgementDiv = cells[6].querySelector("div");
+ let cbreak = 0, breaks = 0, hit = 0, miss = 0, bellCount = 0, totalBellCount = 0, damage = 0;
+
+ if (judgementDiv) {
+ const spans = judgementDiv.parentElement.querySelectorAll("span");
+ if (spans.length >= 4) {
+ cbreak = parseInt(spans[0].textContent) || 0;
+ breaks = parseInt(spans[1].textContent) || 0;
+ hit = parseInt(spans[2].textContent) || 0;
+ miss = parseInt(spans[3].textContent) || 0;
+ }
+ const bellDamageSpans = judgementDiv.parentElement.parentElement.querySelectorAll("span");
+ if (bellDamageSpans.length >= 6) {
+ const bellMatch = bellDamageSpans[4].textContent.match(/(\d+)\/(\d+)/);
+ if (bellMatch) {
+ bellCount = parseInt(bellMatch[1]);
+ totalBellCount = parseInt(bellMatch[2]);
+ }
+ damage = parseInt(bellDamageSpans[5].textContent) || 0;
+ }
+ }
+
+ scores.push({
+ score,
+ noteLamp,
+ bellLamp: "NONE",
+ matchType: "songTitle",
+ identifier: title,
+ artist,
+ difficulty: chart,
+ timeAchieved,
+ judgements: { cbreak, break: breaks, hit, miss },
+ optional: { bellCount, totalBellCount, damage }
+ });
+ }
+
+ 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();
+ }
+})();
diff --git a/wacca/tachi/README.md b/wacca/tachi/README.md
new file mode 100644
index 0000000..bc1c4c2
--- /dev/null
+++ b/wacca/tachi/README.md
@@ -0,0 +1 @@
+# Not Maintained. Use the universal script for sessions
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage