使い方
- 適当なページをブラウザのブックマークに登録
- ブックマークの編集→URL(アドレス)を以下に変更し保存
- 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
何を行っているか
大まかに次のような流れで処理が進みます。
すべてブラウザ上の表示をこねくり回すことで行っているので、とても安全です。(再読み込みすればすべて戻ります)
- 実行してよいページ(=キャラクター変更画面)かどうか確認
- 見た目を整えるためのCSSをページに追加
- 操作UI(目標RANKの入力欄など)をページに追加
- 全キャラクター表示に切り替え
- キャラ一覧の表示部分から「キャラクター名」「キャラ画像のURL」「ランク」「ゲージの進み具合」を抽出
- 抽出した情報をもとに、次RANKまで/目標RANKまでの曲数を計算
- 抽出したキャラクター情報をランク順に降順ソート
- キャラ一覧の表示部分を置き換える
コード
(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();
})();
-
CHUNITHM-NETがRANK内進行度を270段階で表示しているのに対し、RANK100からはRANKを1上げるのに271曲以上のプレイが必要になるからです。 ↩
