1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CHUNITHM-NET上で、所持キャラクターのRANKや目標到達までの曲数を表示するブックマークレット

Posted at

使い方

  1. 適当なページをブラウザのブックマークに登録
  2. ブックマークの編集→URL(アドレス)を以下に変更し保存
  3. CHUNITHM-NETの「コレクション」>「キャラクター」>「キャラクターを変更」ページでブックマークを実行
javascript:;(()=>{;!async function(){"use strict";const RANK_EXP_TABLE=[{min:1,max:10,exp:2},{min:10,max:15,exp:10},{min:15,max:20,exp:15},{min:20,max:25,exp:20},{min:25,max:30,exp:30},{min:30,max:35,exp:40},{min:35,max:40,exp:50},{min:40,max:45,exp:60},{min:45,max:50,exp:70},{min:50,max:55,exp:90},{min:55,max:60,exp:110},{min:60,max:65,exp:130},{min:65,max:70,exp:150},{min:70,max:75,exp:170},{min:75,max:80,exp:190},{min:80,max:85,exp:210},{min:85,max:90,exp:230},{min:90,max:95,exp:250},{min:95,max:100,exp:270},{min:100,max:105,exp:300},{min:105,max:110,exp:330},{min:110,max:115,exp:360},{min:115,max:120,exp:390},{min:120,max:125,exp:420},{min:125,max:130,exp:450},{min:130,max:135,exp:480},{min:135,max:140,exp:510},{min:140,max:145,exp:540},{min:145,max:150,exp:570},{min:150,max:155,exp:610},{min:155,max:160,exp:650},{min:160,max:165,exp:690},{min:165,max:170,exp:730},{min:170,max:175,exp:770},{min:175,max:180,exp:810},{min:180,max:185,exp:850},{min:185,max:190,exp:890},{min:190,max:195,exp:930},{min:195,max:201,exp:970}],RANK_IMG_REG=/num_s_lv_(\d+)\.png/,$=(sel,root=document)=>root.querySelector(sel);function clampInt(n,min,max,fallback){const v=parseInt(n,10);return Number.isFinite(v)?Math.max(min,Math.min(max,v)):fallback}function expPerRank(rankInt){for(const row of RANK_EXP_TABLE)if(rankInt>=row.min&&rankInt<row.max)return row.exp;return 970}function rankToProgress(rankFloat,isMaxGage=!1){if(!Number.isFinite(rankFloat))return{rankInt:NaN,songsDone:NaN,toNextSongs:NaN,textDone:""};const base=Math.floor(rankFloat),rankInt=clampInt(base,1,200,1),per=expPerRank(rankInt);if(isMaxGage){const songsDone=0;return{rankInt:rankInt,songsDone:songsDone,toNextSongs:per,textDone:`${rankInt} + ♪${songsDone}`}}const fracRaw=rankFloat-base,frac=Math.max(0,Math.min(.999999999,fracRaw)),songsDone=Math.floor(frac*per+1e-9);return{rankInt:rankInt,songsDone:songsDone,toNextSongs:Math.max(0,per-songsDone),textDone:`${rankInt} + ♪${songsDone}`}}function songsToTarget(rankFloat,targetRankInt,isMaxGage=!1){if(!Number.isFinite(rankFloat))return NaN;const target=clampInt(targetRankInt,1,200,200);if(rankFloat>=target)return 0;const prog=rankToProgress(rankFloat,isMaxGage);if(!Number.isFinite(prog.rankInt))return NaN;let total=0;total+=prog.toNextSongs;for(let r=prog.rankInt+1;r<target;r++)total+=expPerRank(r);return total}function targetExtraText(need){const n=function(n){return Number.isFinite(n)?n:NaN}(need);return`≦ 3×${parseInt(.9+n/3,10)} = 4×${parseInt(.9+n/4,10)}`}function readCharacter(block){const name=block.querySelector(".character_name_block a")?.textContent.trim()??"",imgEl=block.querySelector(".list_chara_img img"),imgUrl=imgEl?.getAttribute("data-original")||imgEl?.getAttribute("src")||"",rankImgs=block.querySelectorAll(".character_list_rank_num img"),digits=[];rankImgs.forEach(img=>{const m=RANK_IMG_REG.exec(img.src||"");m&&digits.push(m[1])});const rankInt=digits.length?parseInt(digits.join(""),10):NaN,gageBaseEl=block.querySelector(".character_list_gage_base"),gage_px_str=gageBaseEl&&gageBaseEl.children.length>0?gageBaseEl.children[0].getAttribute("width")?.replace("px",""):null,gage_px=gage_px_str?parseInt(gage_px_str,10):NaN,isMaxGage=Number.isFinite(gage_px)&&0===gage_px;return{name:name,rank:Number.isFinite(rankInt)&&Number.isFinite(gage_px)?isMaxGage?rankInt:rankInt+1-gage_px/270:NaN,imgUrl:imgUrl,isMaxGage:isMaxGage}}if("https://new.chunithm-net.com/chuni-mobile/html/mobile/collection/characterList/"!==location.href)return void alert("このページでは実行できません");const list=$("#list");if(!list)return void alert("#list が見つかりません");!function(){const style=document.createElement("style");style.textContent='.cr_controls {\nwidth: 416px;\nmargin: 12px auto 12px auto;\npadding: 10px;\nbox-sizing: border-box;\ntext-align: left;\n}\n\n.cr_controls_inner {\ndisplay: flex;\nalign-items: center;\ngap: 8px;\n}\n\n.cr_controls_note {\nfont-size: 14px;\nfont-weight: bold;\ncolor: red;\nmargin-bottom: 8px;\n}\n\n.cr_controls label {\nfont-size: 14px;\nwhite-space: nowrap;\n}\n\n.cr_controls input[type="number"] {\nwidth: 84px;\npadding: 6px 8px;\nfont-size: 14px;\nbox-sizing: border-box;\nborder-radius: 6px;\nborder: 1px solid #999;\noutline: none;\n}\n\n.cr_controls button {\npadding: 6px 10px;\nfont-size: 14px;\nborder-radius: 6px;\nborder: 1px solid #999;\nbackground: #f3f3f3;\n}\n\n.cr_controls button:active {\ntransform: translateY(1px);\n}\n\n.cr_box01 {\ndisplay: block;\nposition: relative;\nmargin: 0px auto 10px auto;\npadding: 1px 1px 1px 1px;\nz-index: 20;\nbackground: #f9f9db;\ntext-align: center;\n}\n\n.cr_character_list_block {\ndisplay: grid;\ngrid-template-columns: auto 1fr;\ncolumn-gap: 10px;\nalign-items: center;\nwidth: 416px;\npadding: 10px;\ntext-align: left;\nmargin: 0 auto 0 auto;\nposition: relative;\nbackground-size: auto auto;\nbox-sizing: border-box;\n}\n\n.cr_character_list_block img {\nheight: 54px;\nobject-fit: contain;\ndisplay: block;\nmargin: 0px 5px 0px 5px;\nbackground: #f0f0f0;\n}\n\n.cr_text {\ndisplay: grid;\ngrid-template-rows: auto auto auto;\nrow-gap: 4px;\n}\n\n.cr_text p {\nmargin: 0;\nfont-size: 14px;\nline-height: 18px;\nwhite-space: nowrap;\noverflow: hidden;\ntext-overflow: ellipsis;\n}\n\n.cr_name {\nfont-weight: 600;\n}\n\n.cr_rankline,\n.cr_targetline {\nfont-variant-numeric: tabular-nums;\n}\n',document.head.appendChild(style)}(),function(listEl){const controls=document.createElement("div");controls.className="box01 w420 mt_25 cr_controls",controls.innerHTML='<div class="cr_controls_note">\nRANK100以上のキャラクターでは、目標達成までの曲数に誤差が生じます(CHUNITHM-NETの仕様)。\n</div>\n<div class="cr_controls_inner">\n<label for="chuniTargetRank">目標RANK</label>\n<input id="chuniTargetRank" type="number" min="1" max="200" step="1" value="200" />\n<button id="chuniRenderButton" type="button">再計算</button>\n</div>',listEl.parentNode.insertBefore(controls,listEl)}(list),function(){const sel=$('select[name="idx"]');if(sel&&"9999"!==sel.value&&(sel.value="9999","function"==typeof changeSelect))try{changeSelect()}catch(e){}}();const chars=function(listEl){const blocks=((sel,root=document)=>Array.from(root.querySelectorAll(sel)))("div.character_list_block",listEl);if(0===blocks.length)return alert("キャラ要素(.character_list_block)が見つかりません"),[];const chars=blocks.map(readCharacter).filter(c=>c.name&&Number.isFinite(c.rank)&&c.imgUrl);return 0===chars.length?(alert("取得できたキャラが 0 件でした(ランク画像やDOM構造を確認してください)"),[]):chars}(list);if(0===chars.length)return;const view=window.__chuniCharView=window.__chuniCharView||{};view.chars=chars,view.render=function(){const targetEl=$("#chuniTargetRank"),targetRank=clampInt(targetEl?.value,1,200,200);!function(listEl,chars,targetRank){const sorted=chars.slice().sort((a,b)=>b.rank-a.rank||a.name.localeCompare(b.name,"ja"));listEl.textContent="";const frag=document.createDocumentFragment();for(const c of sorted){const prog=rankToProgress(c.rank,c.isMaxGage),need=songsToTarget(c.rank,targetRank,c.isMaxGage);let targetline_text="";targetline_text=prog.rankInt>=targetRank?"達成済み":`♪${need} <span style="color: #a0a0a0;">${targetExtraText(need)}</span>`;const node=document.createElement("div");node.innerHTML=`<div class="cr_box01 w420">\n<div class="cr_character_list_block">\n<img src="${c.imgUrl}" />\n<div class="cr_text">\n<p class="cr_name">${c.name}</p>\n<p class="cr_rankline">RANK ${prog.textDone} (Next: ♪${prog.toNextSongs})</p>\n<p class="cr_targetline">${targetRank}まで: ${targetline_text}</p>\n</div>\n</div>\n</div>`,frag.appendChild(node)}listEl.appendChild(frag)}(list,view.chars,targetRank)};const renderButton=$("#chuniRenderButton");renderButton&&renderButton.addEventListener("click",()=>view.render()),view.render()}();})()

このブックマークレットを実行しても「外部との通信」「認証情報へのアクセス」は一切行われません。

機能

  • 所持キャラをRANK降順ソートしてコンパクトに表示
  • 現在のRANKとRANK内進行度、次のランクまでの必要プレイ楽曲数を表示
  • 目標RANKを設定すると、達成までの必要プレイ楽曲数を表示

注意: RANK100以上のキャラクターでは、次RANKまで/目標RANKまでの曲数に誤差が生じます。1

image.png

何を行っているか

大まかに次のような流れで処理が進みます。
すべてブラウザ上の表示をこねくり回すことで行っているので、とても安全です。(再読み込みすればすべて戻ります)

  1. 実行してよいページ(=キャラクター変更画面)かどうか確認
  2. 見た目を整えるためのCSSをページに追加
  3. 操作UI(目標RANKの入力欄など)をページに追加
  4. 全キャラクター表示に切り替え
  5. キャラ一覧の表示部分から「キャラクター名」「キャラ画像のURL」「ランク」「ゲージの進み具合」を抽出
  6. 抽出した情報をもとに、次RANKまで/目標RANKまでの曲数を計算
  7. 抽出したキャラクター情報をランク順に降順ソート
  8. キャラ一覧の表示部分を置き換える

コード

(async function () {
    "use strict";

    // =========================
    // Constants
    // =========================
    const TARGET =
        "https://new.chunithm-net.com/chuni-mobile/html/mobile/collection/characterList/";

    const SELECT_ALL_VALUE = "9999";
    const DEFAULT_TARGET_RANK = 200;

    const RANK_EXP_TABLE = [
        { min: 1, max: 10, exp: 2 },
        { min: 10, max: 15, exp: 10 },
        { min: 15, max: 20, exp: 15 },
        { min: 20, max: 25, exp: 20 },
        { min: 25, max: 30, exp: 30 },
        { min: 30, max: 35, exp: 40 },
        { min: 35, max: 40, exp: 50 },
        { min: 40, max: 45, exp: 60 },
        { min: 45, max: 50, exp: 70 },
        { min: 50, max: 55, exp: 90 },
        { min: 55, max: 60, exp: 110 },
        { min: 60, max: 65, exp: 130 },
        { min: 65, max: 70, exp: 150 },
        { min: 70, max: 75, exp: 170 },
        { min: 75, max: 80, exp: 190 },
        { min: 80, max: 85, exp: 210 },
        { min: 85, max: 90, exp: 230 },
        { min: 90, max: 95, exp: 250 },
        { min: 95, max: 100, exp: 270 },
        { min: 100, max: 105, exp: 300 },
        { min: 105, max: 110, exp: 330 },
        { min: 110, max: 115, exp: 360 },
        { min: 115, max: 120, exp: 390 },
        { min: 120, max: 125, exp: 420 },
        { min: 125, max: 130, exp: 450 },
        { min: 130, max: 135, exp: 480 },
        { min: 135, max: 140, exp: 510 },
        { min: 140, max: 145, exp: 540 },
        { min: 145, max: 150, exp: 570 },
        { min: 150, max: 155, exp: 610 },
        { min: 155, max: 160, exp: 650 },
        { min: 160, max: 165, exp: 690 },
        { min: 165, max: 170, exp: 730 },
        { min: 170, max: 175, exp: 770 },
        { min: 175, max: 180, exp: 810 },
        { min: 180, max: 185, exp: 850 },
        { min: 185, max: 190, exp: 890 },
        { min: 190, max: 195, exp: 930 },
        { min: 195, max: 201, exp: 970 },
    ];

    const RANK_IMG_REG = /num_s_lv_(\d+)\.png/;

    // =========================
    // Utils
    // =========================
    const $ = (sel, root = document) => root.querySelector(sel);
    const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));

    function clampInt(n, min, max, fallback) {
        const v = parseInt(n, 10);
        if (!Number.isFinite(v)) return fallback;
        return Math.max(min, Math.min(max, v));
    }

    function safeNum(n) {
        return Number.isFinite(n) ? n : NaN;
    }

    // =========================
    // Styles
    // =========================
    function injectStyles() {
        const style = document.createElement("style");
        style.textContent = `.cr_controls {
width: 416px;
margin: 12px auto 12px auto;
padding: 10px;
box-sizing: border-box;
text-align: left;
}

.cr_controls_inner {
display: flex;
align-items: center;
gap: 8px;
}

.cr_controls_note {
font-size: 14px;
font-weight: bold;
color: red;
margin-bottom: 8px;
}

.cr_controls label {
font-size: 14px;
white-space: nowrap;
}

.cr_controls input[type="number"] {
width: 84px;
padding: 6px 8px;
font-size: 14px;
box-sizing: border-box;
border-radius: 6px;
border: 1px solid #999;
outline: none;
}

.cr_controls button {
padding: 6px 10px;
font-size: 14px;
border-radius: 6px;
border: 1px solid #999;
background: #f3f3f3;
}

.cr_controls button:active {
transform: translateY(1px);
}

.cr_box01 {
display: block;
position: relative;
margin: 0px auto 10px auto;
padding: 1px 1px 1px 1px;
z-index: 20;
background: #f9f9db;
text-align: center;
}

.cr_character_list_block {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 10px;
align-items: center;
width: 416px;
padding: 10px;
text-align: left;
margin: 0 auto 0 auto;
position: relative;
background-size: auto auto;
box-sizing: border-box;
}

.cr_character_list_block img {
height: 54px;
object-fit: contain;
display: block;
margin: 0px 5px 0px 5px;
background: #f0f0f0;
}

.cr_text {
display: grid;
grid-template-rows: auto auto auto;
row-gap: 4px;
}

.cr_text p {
margin: 0;
font-size: 14px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.cr_name {
font-weight: 600;
}

.cr_rankline,
.cr_targetline {
font-variant-numeric: tabular-nums;
}
`;
        document.head.appendChild(style);
    }

    // =========================
    // Rank math
    // =========================
    function expPerRank(rankInt) {
        for (const row of RANK_EXP_TABLE) {
            if (rankInt >= row.min && rankInt < row.max) return row.exp;
        }
        return 970;
    }

    function rankToProgress(rankFloat, isMaxGage = false) {
        if (!Number.isFinite(rankFloat)) {
            return { rankInt: NaN, songsDone: NaN, toNextSongs: NaN, textDone: "" };
        }

        const base = Math.floor(rankFloat);
        const rankInt = clampInt(base, 1, 200, 1);

        const per = expPerRank(rankInt);

        if (isMaxGage) {
            const songsDone = 0;
            const toNextSongs = per;
            return {
                rankInt,
                songsDone,
                toNextSongs,
                textDone: `${rankInt} + ♪${songsDone}`,
            };
        }

        const fracRaw = rankFloat - base;
        const frac = Math.max(0, Math.min(0.999999999, fracRaw)); // 小数誤差対策

        const songsDone = Math.floor(frac * per + 1e-9);
        const toNextSongs = Math.max(0, per - songsDone);

        return {
            rankInt,
            songsDone,
            toNextSongs,
            textDone: `${rankInt} + ♪${songsDone}`,
        };
    }

    function songsToTarget(rankFloat, targetRankInt, isMaxGage = false) {
        if (!Number.isFinite(rankFloat)) return NaN;

        const target = clampInt(targetRankInt, 1, 200, DEFAULT_TARGET_RANK);
        if (rankFloat >= target) return 0;

        const prog = rankToProgress(rankFloat, isMaxGage);
        if (!Number.isFinite(prog.rankInt)) return NaN;

        let total = 0;
        total += prog.toNextSongs;

        for (let r = prog.rankInt + 1; r < target; r++) {
            total += expPerRank(r);
        }
        return total;
    }


    function targetExtraText(need) {
        const n = safeNum(need);
        const a = parseInt(0.9 + n / 3, 10);
        const b = parseInt(0.9 + n / 4, 10);
        return `≦ 3×${a} = 4×${b}`;
    }

    // =========================
    // DOM build
    // =========================
    function buildControls(listEl) {
        const controls = document.createElement("div");
        controls.className = "box01 w420 mt_25 cr_controls";
        controls.innerHTML = `<div class="cr_controls_note">
RANK100以上のキャラクターでは、目標達成までの曲数に誤差が生じます(CHUNITHM-NETの仕様)。
</div>
<div class="cr_controls_inner">
<label for="chuniTargetRank">目標RANK</label>
<input id="chuniTargetRank" type="number" min="1" max="200" step="1" value="${DEFAULT_TARGET_RANK}" />
<button id="chuniRenderButton" type="button">再計算</button>
</div>`;
        listEl.parentNode.insertBefore(controls, listEl);
        return controls;
    }

    function trySelectAllCharacters() {
        const sel = $('select[name="idx"]');
        if (!sel || sel.value === SELECT_ALL_VALUE) return;

        sel.value = SELECT_ALL_VALUE;
        if (typeof changeSelect === "function") {
            try {
                changeSelect();
            } catch (e) { }
        }
    }

    function readCharacter(block) {
        const name = block.querySelector(".character_name_block a")?.textContent.trim() ?? "";

        const imgEl = block.querySelector(".list_chara_img img");
        const imgUrl =
            imgEl?.getAttribute("data-original") ||
            imgEl?.getAttribute("src") ||
            "";

        const rankImgs = block.querySelectorAll(".character_list_rank_num img");
        const digits = [];
        rankImgs.forEach((img) => {
            const m = RANK_IMG_REG.exec(img.src || "");
            if (m) digits.push(m[1]);
        });
        const rankInt = digits.length ? parseInt(digits.join(""), 10) : NaN;

        const gageBaseEl = block.querySelector(".character_list_gage_base");
        const gage_px_str =
            gageBaseEl && gageBaseEl.children.length > 0
                ? gageBaseEl.children[0].getAttribute("width")?.replace("px", "")
                : null;
        const gage_px = gage_px_str ? parseInt(gage_px_str, 10) : NaN;

        const isMaxGage = Number.isFinite(gage_px) && gage_px === 0;

        const rank =
            Number.isFinite(rankInt) && Number.isFinite(gage_px)
                ? (isMaxGage ? rankInt : rankInt + 1 - gage_px / 270)
                : NaN;

        return { name, rank, imgUrl, isMaxGage };
    }

    function collectCharacters(listEl) {
        const blocks = $$("div.character_list_block", listEl);
        if (blocks.length === 0) {
            alert("キャラ要素(.character_list_block)が見つかりません");
            return [];
        }

        const chars = blocks
            .map(readCharacter)
            .filter((c) => c.name && Number.isFinite(c.rank) && c.imgUrl);

        if (chars.length === 0) {
            alert("取得できたキャラが 0 件でした(ランク画像やDOM構造を確認してください)");
            return [];
        }
        return chars;
    }

    function renderList(listEl, chars, targetRank) {
        const sorted = chars
            .slice()
            .sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name, "ja"));

        listEl.textContent = "";

        const frag = document.createDocumentFragment();

        for (const c of sorted) {
            const prog = rankToProgress(c.rank, c.isMaxGage);
            const need = songsToTarget(c.rank, targetRank, c.isMaxGage);

            let targetline_text = "";

            if (prog.rankInt >= targetRank) {
                targetline_text = "達成済み";
            }
            else {
                targetline_text = `♪${need} <span style="color: #a0a0a0;">${targetExtraText(need)}</span>`;
            }

            const node = document.createElement("div");
            node.innerHTML = `<div class="cr_box01 w420">
<div class="cr_character_list_block">
<img src="${c.imgUrl}" />
<div class="cr_text">
<p class="cr_name">${c.name}</p>
<p class="cr_rankline">RANK ${prog.textDone} (Next: ♪${prog.toNextSongs})</p>
<p class="cr_targetline">${targetRank}まで: ${targetline_text}</p>
</div>
</div>
</div>`;
            frag.appendChild(node);
        }

        listEl.appendChild(frag);

    }

    // =========================
    // Main
    // =========================
    if (location.href !== TARGET) {
        alert("このページでは実行できません");
        return;
    }

    const list = $("#list");
    if (!list) {
        alert("#list が見つかりません");
        return;
    }

    injectStyles();
    buildControls(list);
    trySelectAllCharacters();

    const chars = collectCharacters(list);
    if (chars.length === 0) return;

    const view = (window.__chuniCharView = window.__chuniCharView || {});
    view.chars = chars;

    view.render = function render() {
        const targetEl = $("#chuniTargetRank");
        const targetRank = clampInt(targetEl?.value, 1, 200, DEFAULT_TARGET_RANK);
        renderList(list, view.chars, targetRank);
    };

    const renderButton = $("#chuniRenderButton");
    if (renderButton) {
        renderButton.addEventListener("click", () => view.render());
    }

    view.render();
})();

  1. CHUNITHM-NETがRANK内進行度を270段階で表示しているのに対し、RANK100からはRANKを1上げるのに271曲以上のプレイが必要になるからです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?