Last active
November 16, 2025 14:08
-
-
Save rama-adi/9f32e8b97fd8aed5b04fed8d0ace3eb8 to your computer and use it in GitHub Desktop.
Blue gate (or any gate idk) kaleidxscope checker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function () { | |
| if(window.location.hostname !== "maimaidx-eng.com" || window.location.pathname !== "/maimai-mobile/home/userOption/favorite/updateMusic") { | |
| const go = confirm("This bookmarklet should be run on the music favorite list page, do you want to go there and rerun the script"); | |
| if(go) { | |
| window.location.href = "https://maimaidx-eng.com/maimai-mobile/home/userOption/favorite/updateMusic" | |
| } | |
| return | |
| } | |
| // basically you just need to tweak this for the correct gate | |
| const GATE_CFG = { | |
| gateName: "青の扉", | |
| unlockDate: new Date("2025-01-16T10:00:00+09:00"), | |
| songs: [ | |
| 'STEREOSCAPE', | |
| 'Crazy Circle', | |
| 'Ututu', | |
| 'シエルブルーマルシェ', | |
| 'ブレインジャックシンドローム', | |
| '共鳴', | |
| 'REAL VOICE', | |
| 'オリフィス', | |
| 'ユメヒバナ', | |
| 'パラボラ', | |
| '星めぐり、果ての君へ。', | |
| 'スローアライズ', | |
| '生命不詳', | |
| 'チエルカ/エソテリカ', | |
| 'RIFFRAIN', | |
| 'Falling', | |
| 'ピリオドサイン', | |
| '群青シグナル', | |
| 'アンバークロニクル', | |
| 'Kairos', | |
| 'リフヴェイン', | |
| '宵の鳥', | |
| 'フタタビ', | |
| 'シックスプラン', | |
| 'ふらふらふら、', | |
| 'フェイクフェイス・フェイルセイフ', | |
| 'パラドクスイヴ', | |
| 'YKWTD', | |
| '184億回のマルチトニック' | |
| ] | |
| } | |
| const host = document.createElement('div'); | |
| document.body.appendChild(host); | |
| const shadow = host.attachShadow({ mode: 'open' }); | |
| shadow.innerHTML = ` | |
| <style>#modal,#modal button{border-radius:.375rem}:host{position:fixed;inset:0;z-index:999999; font-family:"ヒラギノ角ゴ Pro W3", メイリオ, Meiryo, "MS Pゴシック", "MS P Gothic", sans-serif;}#backdrop{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(17,24,39,.3);z-index:999998}#modal{width:26rem;background:#bee5fa;padding:1.5rem;border-width:3px;box-shadow:rgba(0,0,0,.4) 1px 3px 0 0;z-index:999999}#modal h1{margin-bottom:1rem;font-size:1.25rem;font-weight:700}#modal .section{margin-top:1rem;margin-bottom:1rem}#modal .loading,#modal ol{margin-top:.5rem}#modal .loading,#modal .section p,#modal li{margin-bottom:.5rem}#modal ol{list-style:decimal inside;margin-left:1.25rem;padding-left:0}#song-list{margin-top:.75rem;padding-left:0;max-height:12rem;overflow:auto;border:1px solid rgba(31,41,55,.25);border-radius:.5rem;background:#f8fdff;padding:.5rem .75rem}#song-list li{margin-bottom:.35rem;font-size:.9rem;display:flex;align-items:center;gap:.35rem}#song-list .icon{width:1.5rem;text-align:center}#modal .btn-row{display:flex;gap:1rem;margin-top:1rem}#modal button{padding:.5rem .75rem;cursor:pointer;font-size:.875rem}#modal .btn-light{background:#f3f4f6;color:#1f2937}#modal .btn-dark{background:#1f2937;color:#fff}</style> | |
| <div id="backdrop"> | |
| <div id="modal"> | |
| <h1>${GATE_CFG.gateName} Song checker</h1> | |
| <div class="section"> | |
| <p>Checking ${GATE_CFG.gateName} song played on or after ${GATE_CFG.unlockDate.toLocaleDateString()}</p> | |
| <p>Below are all songs with their current status:</p> | |
| <ol id="song-list"></ol> | |
| </div> | |
| <p id="current-checking" class="loading"></p> | |
| <p id="loading-indicator" class="loading"></p> | |
| <div class="btn-row" id="action-row"></div> | |
| </div> | |
| </div> | |
| `; | |
| const songStatuses = new Map(); | |
| GATE_CFG.songs.forEach(title => songStatuses.set(title, "unplayed")); | |
| function getUnplayedTitles() { | |
| return GATE_CFG.songs.filter(title => songStatuses.get(title) === "unplayed"); | |
| } | |
| function renderSongList(currentTitle = null) { | |
| const sl = shadow.getElementById("song-list"); | |
| if (!sl) return; | |
| sl.innerHTML = GATE_CFG.songs.map(title => { | |
| let icon = "❌"; | |
| if (title === currentTitle) { | |
| icon = "➡️"; | |
| } else if (songStatuses.get(title) === "completed") { | |
| icon = "✅"; | |
| } | |
| return `<li><span class="icon">${icon}</span><span>${title}</span></li>`; | |
| }).join(""); | |
| } | |
| renderSongList(); | |
| // :( | |
| async function htmlGetFetch(url) { | |
| const result = await fetch(url, { | |
| credentials: 'include' | |
| }); | |
| return await result.text(); | |
| } | |
| function searchSong(html, titles) { | |
| const base = 'https://maimaidx-eng.com/maimai-mobile/record/musicDetail/'; | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| // All song blocks on the page | |
| const blocks = [...doc.querySelectorAll('.w_450.m_15.p_r.f_0')]; | |
| const entries = blocks.map(block => { | |
| const titleEl = block.querySelector('.music_name_block'); | |
| const form = block.querySelector('form[action*="musicDetail"]'); | |
| const idx = form?.querySelector('input[name="idx"]')?.value; | |
| return { | |
| title: titleEl?.textContent.trim() || null, | |
| idx: idx || null | |
| }; | |
| }); | |
| // Normalize for matching | |
| const normalize = s => s.replace(/\s+/g, '').trim(); | |
| const results = titles.map(t => { | |
| const match = entries.find(e => e.title && normalize(e.title) === normalize(t)); | |
| if (!match || !match.idx) { | |
| return { title: t, url: null }; | |
| } | |
| return { | |
| title: t, | |
| url: `${base}?idx=${encodeURIComponent(match.idx)}` | |
| }; | |
| }); | |
| return results; | |
| } | |
| function parsePlay(html) { | |
| const doc = new DOMParser().parseFromString(html, "text/html"); | |
| const diffNames = { | |
| basic: "basic", | |
| advanced: "advanced", | |
| expert: "expert", | |
| master: "master", | |
| remaster: "remaster" | |
| }; | |
| const parseJST = (dateStr) => { | |
| // Expect "YYYY/MM/DD HH:mm" | |
| const m = dateStr.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/); | |
| if (!m) return null; | |
| const [_, y, mo, d, h, mi] = m; | |
| const iso = `${y}-${mo}-${d}T${h}:${mi}:00+09:00`; // Safe ISO with JST offset | |
| return new Date(iso); | |
| } | |
| const results = []; | |
| for (const id of Object.keys(diffNames)) { | |
| const block = doc.querySelector(`#${id}`); | |
| if (!block) continue; | |
| const level = block.querySelector(".music_lv_back")?.textContent.trim() || null; | |
| const isDX = !!block.querySelector('.music_kind_icon[src*="music_dx"]'); | |
| const scorePercent = | |
| block.querySelector(".music_score_block.w_120")?.textContent.trim() || null; | |
| const deluxeBlock = block.querySelector(".music_score_block.w_310"); | |
| let deluxeScore = null; | |
| let deluxeMax = null; | |
| if (deluxeBlock) { | |
| const text = deluxeBlock.textContent.replace(/\s+/g, " ").trim(); | |
| const match = text.match(/(\d+)\s*\/\s*(\d+)/); | |
| if (match) { | |
| deluxeScore = Number(match[1]); | |
| deluxeMax = Number(match[2]); | |
| } | |
| } | |
| let lastPlayedRaw = null; | |
| let playCount = null; | |
| const infoRows = block.querySelectorAll("table.collapse tr"); | |
| infoRows.forEach(tr => { | |
| const tds = [...tr.querySelectorAll("td")]; | |
| const label = tds[0]?.textContent.trim(); | |
| const val = tds[1]?.textContent.trim(); | |
| if (label?.startsWith("Last played")) lastPlayedRaw = val; | |
| if (label?.startsWith("PLAY COUNT")) playCount = Number(val); | |
| }); | |
| results.push({ | |
| difficulty: diffNames[id], | |
| level, | |
| isDX, | |
| scorePercent, | |
| deluxeScore, | |
| deluxeMax, | |
| lastPlayed: lastPlayedRaw ? parseJST(lastPlayedRaw) : null, // converted | |
| playCount | |
| }); | |
| } | |
| return results; | |
| } | |
| async function loadData() { | |
| const indicatorEl = shadow.getElementById("loading-indicator"); | |
| const statusEl = shadow.getElementById("current-checking"); | |
| indicatorEl.innerHTML = `Loading songs from ${GATE_CFG.gateName}...<br>`; | |
| statusEl.innerHTML = ""; | |
| renderSongList(); | |
| try { | |
| const songListHtml = await htmlGetFetch("https://maimaidx-eng.com/maimai-mobile/record/musicGenre/search/?genre=99&diff=0"); | |
| const searchResults = searchSong(songListHtml, GATE_CFG.songs); | |
| indicatorEl.innerHTML += `Fetched ${searchResults.length} songs. Checking play history...<br>`; | |
| const missingSongs = []; | |
| const failedSongs = []; | |
| let processed = 0; | |
| const total = searchResults.length || 1; | |
| for (const result of searchResults) { | |
| if (!result.url) { | |
| missingSongs.push(result.title); | |
| songStatuses.set(result.title, "unplayed"); | |
| processed += 1; | |
| indicatorEl.innerHTML = `Checked ${processed}/${total} songs...<br>`; | |
| renderSongList(); | |
| continue; | |
| } | |
| statusEl.innerHTML = `Currently checking ${result.title}...`; | |
| renderSongList(result.title); | |
| try { | |
| const playHtml = await htmlGetFetch(result.url); | |
| const parsedPlays = parsePlay(playHtml); | |
| const latestPlay = parsedPlays.reduce((latest, entry) => { | |
| if (!entry.lastPlayed) return latest; | |
| if (!latest || entry.lastPlayed > latest) return entry.lastPlayed; | |
| return latest; | |
| }, null); | |
| if (!latestPlay || latestPlay < GATE_CFG.unlockDate) { | |
| songStatuses.set(result.title, "unplayed"); | |
| } else { | |
| songStatuses.set(result.title, "completed"); | |
| } | |
| } catch (err) { | |
| console.error(`Failed to load play data for ${result.title}`, err); | |
| failedSongs.push(result.title); | |
| songStatuses.set(result.title, "unplayed"); | |
| } | |
| processed += 1; | |
| indicatorEl.innerHTML = `Checked ${processed}/${total} songs...<br>`; | |
| renderSongList(); | |
| } | |
| statusEl.innerHTML = "Finished checking songs."; | |
| const remainingSongs = getUnplayedTitles(); | |
| indicatorEl.innerHTML += `Found ${remainingSongs.length} song(s) that still need to be played.<br>`; | |
| if (missingSongs.length || failedSongs.length) { | |
| indicatorEl.innerHTML += `Skipped: ${(missingSongs.concat(failedSongs)).join(", ")}<br>`; | |
| } | |
| return { missingSongs, failedSongs }; | |
| } catch (error) { | |
| statusEl.innerHTML = "Failed to load songs."; | |
| indicatorEl.innerHTML += `An error occurred: ${error.message}`; | |
| throw error; | |
| } | |
| } | |
| loadData() | |
| .catch(err => { | |
| console.error("Unable to complete song checks.", err); | |
| }) | |
| .finally(() => { | |
| shadow.getElementById("action-row").innerHTML = ` | |
| <button class="btn-light" id="closeBtn">Close</button> | |
| <button class="btn-dark" id="addBtn">Add to favorites</button> | |
| ` | |
| shadow.getElementById('addBtn').addEventListener('click', () => { | |
| const remainingSongs = getUnplayedTitles(); | |
| if (!remainingSongs.length) { | |
| alert("All songs have already been played since the gate opened!"); | |
| return; | |
| } | |
| const nodes = document.querySelectorAll('.favorite_checkbox_frame.m_10'); | |
| nodes.forEach(node => { | |
| if (remainingSongs.some(song => node.innerText.includes(song))) { | |
| node.click(); | |
| } | |
| }); | |
| alert("Songs are checked, now you just need to save them :-)"); | |
| host.remove(); | |
| }); | |
| shadow.getElementById('closeBtn').addEventListener('click', () => { | |
| host.remove(); | |
| }); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment