LOL グラフの値を読み取る
目的:今クリックした LoLalytics の「Win Rate vs Game Length」グラフから、
0–15, 15–20, …, 40+ の各バケットの勝率%を正確に数値化してCSV保存する。
使い方(3ステップ)
- LoLalytics で目的のページを開き、該当グラフが画面に見えている状態にする
- DevTools Console で
allow pasting
→ Enter - 下の「実行コード」をまるごと貼って実行 → グラフの黒い領域を1回クリック
-
wr_vs_game_length_click.csv
がダウンロードされます
-
実行コード(そのままコピペ)
// ==== Lolalytics: 「Win Rate vs Game Length」を自動検出→抽出(クリック不要・画面ピクセル基準) ====
(() => {
const BUCKET_RE = /^(?:\d+\s*-\s*\d+|\d+\+)$/; // 0-15,15-20,…,40+
const MIN_W = 360, MIN_H = 160; // グラフらしい最小サイズの目安
// 画面座標ヘルパー(transform耐性)
const cx = el => { const r = el.getBoundingClientRect(); return r.left + r.width/2; };
const cy = el => { const r = el.getBoundingClientRect(); return r.top + r.height/2; };
const saveCSV = (rows, name) => {
const csv = 'bucket,win_rate\n' + rows.map(r => `${r[0]},${r[1]}`).join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type:'text/csv' }));
a.download = name;
a.click();
};
const getChampionName = () => {
// URL か 見出しから推測(ざっくり)
const m = location.pathname.match(/champion\/([^\/?#]+)/i);
if (m) return decodeURIComponent(m[1]);
const h = document.querySelector('h1,h2,[data-title]');
if (h) return (h.textContent||'').trim().split(/\s+/)[0];
return 'champion';
};
// 周辺のテキストから「Win Rate」「Game Length」を含む見出しが近いほど加点
const headerScore = (svg) => {
let score = 0;
let node = svg;
for (let depth=0; node && depth<4; depth++) {
const txt = (node.innerText || '').toLowerCase();
if (txt) {
if (/(^|\s)win\s*rate(\s|$)/i.test(txt)) score += 3;
if (/(^|\s)game\s*length(\s|$)/i.test(txt)) score += 3;
if (/vs/i.test(txt)) score += 1;
if (/win\s*rate\s*vs\s*game\s*length/i.test(txt)) { score += 6; break; }
}
node = node.parentElement;
}
return score;
};
const getTextItems = (svg) => [...svg.querySelectorAll('text')].map(t => ({
el: t, txt: (t.textContent||'').trim(), x: cx(t), y: cy(t)
}));
const findXTicks = (svg) => {
const texts = getTextItems(svg);
const x = texts.filter(t => BUCKET_RE.test(t.txt) && Number.isFinite(t.x))
.sort((a,b)=>a.x-b.x);
return { buckets: x.map(t=>t.txt), xTicks: x.map(({txt,x}) => ({txt,x})) };
};
const fitYScale = (svg) => {
const texts = getTextItems(svg);
const ticks = texts.map(t => {
const m = t.txt.match(/^(\d+(?:\.\d+)?)%?$/); // “55” or “55%”
if (!m) return null;
const val = parseFloat(m[1]);
return (Number.isFinite(val) && Number.isFinite(t.y)) ? { val, y: t.y } : null;
}).filter(Boolean);
if (ticks.length >= 2) {
const n=ticks.length;
const sumY=ticks.reduce((s,d)=>s+d.y,0);
const sumV=ticks.reduce((s,d)=>s+d.val,0);
const sumVY=ticks.reduce((s,d)=>s+d.val*d.y,0);
const sumVV=ticks.reduce((s,d)=>s+d.val*d.val,0);
const a=(n*sumVY - sumV*sumY)/(n*sumVV - sumV*sumV);
const b=(sumY - a*sumV)/n; // y = a*val + b
const yToPct = (y) => +(((y - b) / a).toFixed(2));
return { yToPct, mode: 'ticks' };
}
// フォールバック:描画枠の上下で線形補間(プロンプト無しで汎用値にするなら調整可)
const r = svg.getBoundingClientRect();
// デフォルトのレンジ(例: 42〜56)だとページによりズレることがあるため、
// 上下に書かれている最大/最小を推測できない場合は、中心付近に寄りすぎないよう 40〜60 を仮定
const yMin = 40, yMax = 60;
const yToPct = (y) => {
const t=(y - r.top)/r.height; // 上0→下1
return +(((1-t)*yMax + t*yMin).toFixed(2));
};
return { yToPct, mode: 'fallback' };
};
const collectPoints = (svg, xTicks) => {
// circle 優先
let pts = [...svg.querySelectorAll('circle')]
.map(c => ({ x: cx(c), y: cy(c) }))
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
.sort((a,b)=>a.x-b.x);
const useCircles = pts.length >= xTicks.length-1 && pts.length <= xTicks.length+2;
if (useCircles) return { pts, source: 'circle' };
// path/polyline/polygon フォールバック
const geoms = [...svg.querySelectorAll('path, polyline, polygon')]
.filter(el => typeof el.getTotalLength === 'function')
.map(el => {
const bb = el.getBBox?.();
return { el, L: el.getTotalLength(), bb };
})
.filter(g => g.bb && g.bb.width > 40 && g.bb.height > 10)
.sort((a,b) => b.L - a.L);
if (!geoms.length) return { pts: [], source: 'none' };
const path = geoms[0].el;
const L = path.getTotalLength();
const S = 9000; // ちょい高密度
const m = (path.getScreenCTM && path.getScreenCTM()) || (svg.getScreenCTM && svg.getScreenCTM());
if (!m || !svg.createSVGPoint) return { pts: [], source: 'none' };
const pt = svg.createSVGPoint();
const toScreen = (p) => { pt.x=p.x; pt.y=p.y; const s=pt.matrixTransform(m); return {x:s.x, y:s.y}; };
// サンプル点雲(画面座標)
const cloud = Array.from({length:S+1}, (_,k) => toScreen(path.getPointAtLength((L*k)/S)));
// 各 xTick に最も近い点
pts = xTicks.map(t => {
let best=cloud[0], bd=Math.abs(cloud[0].x - t.x);
for (let i=1;i<cloud.length;i++) {
const d = Math.abs(cloud[i].x - t.x);
if (d < bd) { bd = d; best = cloud[i]; }
}
return { x: best.x, y: best.y };
});
return { pts, source: 'path' };
};
// 候補SVGを列挙→スコアリングして最も「Win Rate vs Game Length」っぽいものを選ぶ
const pickTargetSvg = () => {
const svgs = [...document.querySelectorAll('svg')].filter(svg => {
const r = svg.getBoundingClientRect();
return r.width >= MIN_W && r.height >= MIN_H;
});
const scored = svgs.map((svg, idx) => {
const r = svg.getBoundingClientRect();
const { buckets, xTicks } = findXTicks(svg);
const yInfo = fitYScale(svg);
const texts = getTextItems(svg);
const yTickCount = texts.filter(t => /^(\d+(\.\d+)?)%?$/.test(t.txt)).length;
const circles = svg.querySelectorAll('circle').length;
const paths = [...svg.querySelectorAll('path,polyline,polygon')]
.filter(el=>typeof el.getTotalLength==='function').length;
// スコア設計:見出し一致 + バケット数の近さ(7推奨) + y目盛 + 図形の有無 + 面積
let score = 0;
score += headerScore(svg);
if (buckets.length) {
// 6〜8を理想とし、近いほど加点
const ideal = 7, diff = Math.abs(buckets.length - ideal);
score += Math.max(0, 6 - diff*2);
}
score += Math.min(yTickCount, 5); // y目盛が多いほど少し加点
score += Math.min(circles, 8) * 0.5; // マーカーが多いほど微加点
score += Math.min(paths, 4) * 1.0; // 折れ線があるほど加点
score += Math.min((r.width*r.height)/80000, 6); // 面積がある程度大きい
return { svg, idx, score, buckets, xTicks, rect: r, circles, paths };
});
scored.sort((a,b) => b.score - a.score);
if (!scored.length) return null;
// デバッグ表示
console.table(scored.slice(0,6).map(s => ({
idx: s.idx, score: +s.score.toFixed(2), w: +s.rect.width.toFixed(0), h: +s.rect.height.toFixed(0),
buckets: s.buckets.join('|').slice(0,40),
circles: s.circles, paths: s.paths
})));
return scored[0]; // ベスト
};
const runOnce = () => {
const picked = pickTargetSvg();
if (!picked) {
console.warn('候補となるSVGが見つかりませんでした。ページでグラフが見える位置までスクロールしてください。');
return;
}
const svg = picked.svg;
const { buckets, xTicks } = picked.xTicks.length ? picked : findXTicks(svg);
let xTicksUse = xTicks;
let bucketsUse = buckets;
if (!bucketsUse.length) {
// ラベルが拾えない時はデフォルトのバケットで等間隔に
const def = '0-15,15-20,20-25,25-30,30-35,35-40,40+';
bucketsUse = def.split(',');
const r = svg.getBoundingClientRect();
xTicksUse = Array.from({length:bucketsUse.length},(_,i)=>({txt:bucketsUse[i], x: r.left + (i/(bucketsUse.length-1))*r.width}));
}
const { yToPct, mode } = fitYScale(svg);
const { pts, source } = collectPoints(svg, xTicksUse.map(t=>t));
if (!pts.length) {
console.warn('データ点が取得できませんでした。グラフが画面内にあるか確認してください。');
return;
}
const rows = xTicksUse.map((t, i) => {
// 最近傍(安全のためもう一度最近傍検索)
let best = pts[0], bd = Math.abs(pts[0].x - t.x);
for (let k=1;k<pts.length;k++) {
const d = Math.abs(pts[k].x - t.x);
if (d < bd) { bd = d; best = pts[k]; }
}
return [t.txt, yToPct(best.y)];
});
console.log(`Target SVG idx: ${picked.idx}, score=${picked.score.toFixed(2)}, xBuckets=${bucketsUse.length}, yScale=${mode}, points=${source}`);
console.table(rows);
const champ = getChampionName();
const ts = new Date().toISOString().replace(/[:.]/g,'').slice(0,15);
saveCSV(rows, `wr_vs_game_length_auto_${champ}_${ts}.csv`);
console.log('✅ 保存完了');
};
// 実行
runOnce();
// (オプション)SPAでページ遷移後も自動で再取得したいなら、下を true にして使う
const AUTO_WATCH = false;
if (AUTO_WATCH) {
const obs = new MutationObserver(() => {
// カードが差し替わるのを軽くdebounce
clearTimeout(obs._t);
obs._t = setTimeout(() => {
try { runOnce(); } catch(e) { console.warn(e); }
}, 800);
});
obs.observe(document.body, { childList:true, subtree:true });
console.log('👀 監視モードON(DOM更新で自動再取得)');
}
})();
詳細に解説
const BUCKET_RE = /^(?:\d+\s*-\s*\d+|\d+\+)$/; // 0-15 / 40+ など
X軸ラベルの検出用。12-18 のような範囲表記、40+ の末尾プラス表記に対応。
\s* を挟んでいるので 0 - 15 のような空白混じりもOK。
const cx = el => { const r = el.getBoundingClientRect(); return r.left + r.width/2; };
const cy = el => { const r = el.getBoundingClientRect(); return r.top + r.height/2; };
すべての座標比較を 画面ピクセルで統一。
SVG の に左右されない、安定した比較ができます。
const saveCSV = (rows, name) => { /* Blob → a.download */ }
[['0-15', 50.1], …] を bucket,win_rate 形式でダウンロード。外部送信なし。
形 [['0-15', 50.1], …] とは?
これは JavaScript 内部の配列データです。
'0-15' → ゲーム時間のバケット(例:0〜15分の試合)
50.1 → そのバケットでの勝率(%)
bucket,win_rate 形式とは?
この配列を CSVファイルに書き出すときに、
1行目に「列名(ヘッダー)」をつけて「表形式」にする、という意味です。
(Excelやスプレッドシートにそのまま読み込める)
「外部送信なし」とは?
ここで言っているのは
サーバーやインターネットにデータを送ることはせず、
ブラウザ内で生成して 自分のPCに直接ダウンロードさせるという仕組みのことです。
つまりセキュリティ的に安心で、
「勝率データが外に漏れる」ことはありません。
const getChampionName = () => { /* URL / 見出しから推定 */ }
ファイル名に入れる用途。/champion/xxxx があればそれを使い、なければ見出しを採用。
const headerScore = (svg) => { /* 祖先方向に4階層まで見て "win rate", "game length", "vs" を加点 */ }
候補 SVG のスコアリング
近くのテキストに “Win Rate”/“Game Length” があるほど高得点。
直に “win rate vs game length” があればさらに加点して即break。
pickTargetSvg()
ページ中の
各SVGに対し以下を計測し、合計スコア化:
headerScore(svg)(見出し一致)
X軸バケット数(findXTicks の結果)。理想=7(6〜8が多い)に近いほど加点
Y目盛テキストの数(^(\d+(.\d+)?)%?$ にマッチ)
circle の数(丸マーカーが多いほど微加点)
path/polyline/polygon(getTotalLength があるもの)の数(折れ線があるほど加点)
面積(width*height を正規化して加点)
スコアの高い順に並べ 先頭を採用。上位候補は console.table でデバッグ表示。
✅ 誤検出が気になる場合は、ideal=7 や各加点係数を調整すると精度が上がります。
const def = '0-15,15-20,20-25,25-30,30-35,35-40,40+';
bucketsUse = def.split(',');
xTicksUse = 等間隔で r.left..r.right に配置
フォールバック(ラベルが取れないとき)
ラベル文字列が見つからない場合でも、等間隔のXを仮定して先に進めます。
Y軸:px ↔ % のスケール推定
fitYScale(svg)
自動フィット:text から **“55”/“55%”**のような数値を抽出 → 2点以上あれば
最小二乗で y = a*val + b を当て、逆変換 val = (y - b)/a で % を返す関数 yToPct を作る。
フォールバック:目盛が足りない場合
r = svg.getBoundingClientRect() の上下端を 40%/60% と仮定し、
線形補間で yToPct を作る(ページによりズレるリスクがあるため、ここは調整ポイント)。
🔧 より堅牢にしたいとき:
近傍の要素から “Min %/Max %” 的なヒントを拾う or 40–60 を 42–58 などに合わせる。
データ点の収集
collectPoints(svg, xTicks)
まず circle(丸マーカー)を優先:
circle の中心(画面座標)を取得し、バケット数±1〜2に収まっていれば採用。
→ これが最もズレが少ない。
足りなければ path を高密度サンプル:
getTotalLength が使える path/polyline/polygon から、
幅>40 & 高さ>10 を満たすものだけ抽出し、最長を採用(装飾除外)。
getPointAtLength を S=9000 点サンプルし、
getScreenCTM + SVGPoint.matrixTransform で 画面座標に射影。
各バケットXに対して、x差が最小のサンプル点を選び pts にする。
🔧 パフォーマンス/精度は S でトレードオフ(速く→小さく、滑らか→大きく)。
近傍対応 → % に変換 → CSV
xTicksUse を1つずつ見て、pts の中から Xが最も近い点 を選ぶ
→ その y を yToPct(y) に通して 勝率% に変換
→ [bucket, win_rate] を rows に蓄積
console.table(rows) で目視 → saveCSV で
wr_vs_game_length_auto_${champ}_${timestamp}.csv を保存。
ファイル名には getChampionName() が使われます。URLに champion/ があればそれ、無ければ見出しから推定。
実行とオプション監視
runOnce() を即実行。
AUTO_WATCH = false を true にすると、MutationObserver が DOMの差し替えを検知して
800ms デバウンス後に 自動再取得。
→ SPA(ページ内遷移)のたびに手動実行不要にできます。
結果
コンソール上で実行に成功