0
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?

Laravel グラフのスクレイピング

Posted at

LOL グラフの値を読み取る

目的:今クリックした LoLalytics の「Win Rate vs Game Length」グラフから、
0–15, 15–20, …, 40+ の各バケットの勝率%を正確に数値化してCSV保存する。


スクリーンショット 2025-08-17 160722.png
(この部分の情報を取りたい)

使い方(3ステップ)

  1. LoLalytics で目的のページを開き、該当グラフが画面に見えている状態にする
  2. DevTools Console で allow pasting → Enter
  3. 下の「実行コード」をまるごと貼って実行 → グラフの黒い領域を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(ページ内遷移)のたびに手動実行不要にできます。

結果

スクリーンショット 2025-08-17 161947.png

コンソール上で実行に成功

0
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
0
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?