Laravel Webスクレイピング機能の搭載 ①

今回からLaravelを使用したWebアプリにWebスクレイピング機能を搭載していく。今回はlol(League of Legends)というゲームにおける、自身の使用チャンピオン（キャラクターのことでこのゲームには現在170体もいる）ごとの勝率を掲示していく。勝率をゲームの統計サイトからスクレイピングすることを考える。背景として、このゲームの統計サイトは複数存在しており全ていちいち参照するのが面倒であった。そこで今回三つの統計サイト（Opgg,ugg,lolalytics）に掲示されている勝率をチャンピオンごと、またランクごと（ランクはiron,blonze,silver,goldという段階がある）に列挙したい。

以下は今回作成する機能の画像である。
![スクリーンショット 2025-03-22 19.54.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4034432/082d5011-4530-4a99-8afb-4918c9c657b7.png)

まだ完全に実装は完了していないので注意（後ほど更新していく）

[今回参照させていただいたopggサイト](https://www.op.gg/champions/garen/build?hl=ja_JP&tier=iron)

この画面では現在このように表示される。


![ああ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4034432/f1429101-fb51-4bee-a5ac-18479778f412.png)

このガレンというキャラクターはOPGGサイトによると右上に勝率が49.07%と表示されている。
この値が上の表のOPGGのロゴの行、Ironの列に表示されるようにしていく。
（実際に例で49.69%となってしまっているのは間違いですが気にしないでくださいすみません。。。また全部の列、行に値が入っていないが各自実装して欲しい。）

## 1. ビューの作成

```html
<x-app-layout>
    <!--header -->
    <x-slot name="title">
        lolのサイト
    </x-slot>

    <!-- 検索フォームをスタイリング -->
    <div class="max-w-7xl mx-auto px-6 mt-4">
        <form action="{{ route('post.search') }}" method="GET" class="flex gap-2 mb-6">
            <input type="text" name="query" placeholder="チャンピオン名を入力"
                   class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
            <button type="submit" class="px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg hover:bg-blue-600">
                検索
            </button>
        </form>
    </div>

    <!-- タイトル -->
    <h1 class="text-4xl font-bold text-center text-gray-800 my-6">チャンピオンの勝率</h1>

    <!-- 表を作る -->
    <div class="max-w-7xl mx-auto overflow-x-auto">
        <table class="table-auto border-collapse border border-gray-400 w-full text-left" style="table-layout: fixed;">

            <thead>
                <tr class="bg-gray-200">
                    <th class="border border-gray-400 px-4 py-2 w-24 text-blue-600 font-bold"> </th>
                    <th class="border border-gray-400 px-4 py-2 w-2/5 text-blue-600 font-bold">Iron</th>
                    <th class="border border-gray-400 px-4 py-2 w-1/5 text-[#cd7f32] font-bold ">Bronze</th>
                    <th class="border border-gray-400 px-4 py-2 w-1/5 text-gray-500 font-bold ">Silver</th>
                    <th class="border border-gray-400 px-4 py-2 w-1/5 text-yellow-600 font-bold ">Gold</th>
                </tr>
            </thead>

            <tbody>
                <tr>
                    <td class="border border-gray-400 px-4 py-2">
                        <img src="/img/opgg.png" class="w-12 h-12 object-cover mx-auto">
                    </td>
                    <td class="border border-gray-400 px-4 py-2 break-words" id="scrapedData">Loading...</td>
                    <td class="border border-gray-400 px-4 py-2">1</td>
                    <td class="border border-gray-400 px-4 py-2">テスト記事</td>
                    <td class="border border-gray-400 px-4 py-2">2025-03-21</td>
                </tr>
            </tbody>

            <tbody>
                <tr>
                    <td class="border border-gray-400 px-4 py-2">
                        <img src="/img/ugg.png" class="w-12 h-12 object-cover mx-auto">
                    </td>
                    <td class="border border-gray-400 px-4 py-2">1</td>
                    <td class="border border-gray-400 px-4 py-2">1</td>
                    <td class="border border-gray-400 px-4 py-2">テスト記事</td>
                    <td class="border border-gray-400 px-4 py-2">2025-03-21</td>
                </tr>
            </tbody>

            <tbody>
                <tr>
                    <td class="border border-gray-400 px-4 py-2">
                        <img src="/img/lolalytics.png" class="w-12 h-12 object-cover mx-auto">
                    </td>
                    <td class="border border-gray-400 px-4 py-2">1</td>
                    <td class="border border-gray-400 px-4 py-2">1</td>
                    <td class="border border-gray-400 px-4 py-2">2025-03-21</td>
                </tr>
            </tbody>

        </table>
    </div>

</x-app-layout>

<script>
document.addEventListener('DOMContentLoaded', function() {
    const scrapedDataElement = document.getElementById('scrapedData');

    fetch("{{ route('scrape.data') }}")
        .then(response => {
            if (!response.ok) {
                throw new Error('サーバーからのレスポンスが正常ではありません: ' + response.status);
            }
            return response.json();
        })
        .then(data => {
            if (data.error) {
                throw new Error(data.error);
            }
            
            // 取得したデータ
            const text = `${data.title} ${data.description}`;

            // 正規表現で最初に現れる `数字 + %` を取得
            const match = text.match(/(\d+(\.\d+)?)%/);

            if (match) {
                // `match[0]` は `45.90%` のような値
                scrapedDataElement.innerText = match[0];
            } else {
                scrapedDataElement.innerText = "データなし";
            }
        })
        .catch(error => {
            console.error('Error fetching data:', error);
            scrapedDataElement.innerText = 'データ取得エラー: ' + error.message;
        });
        
});

</script>

```

こちらがメインとなるビューのコードであるjsの部分を解説する。

### 以下説明

```js
document.addEventListener('DOMContentLoaded', function() {
```
これは 「ページのHTMLが読み込まれたらこの処理を実行する」 という意味。
JavaScriptはHTMLより先に実行されることがあるから、それを防ぐために **「HTMLの読み込みが終わったら実行」** という指定をしてる。

```js
fetch("{{ route('scrape.data') }}")
```

これは サーバーにデータを取りに行く処理 

🔹 {{ route('scrape.data') }} の部分はLaravelのルート（URL）を表していて、
サーバーの ScraperController にある getScrapedData() が実行される。

イメージ

Laravelの getScrapedData() が {"title": "タイトル", "description": "説明"} を返す

fetch() がそのデータを受け取る

```js
.then(response => {
    if (!response.ok) {
        throw new Error('サーバーからのレスポンスが正常ではありません: ' + response.status);
    }
    return response.json();
})
```

ここでやっているのは エラー処理。
もしサーバーがエラーを返した場合（404, 500 など）、throw new Error(...) で強制的にエラーにしてる。

response.json() はレスポンスをJSONデータに変換する処理。

```js
.then(data => {
    if (data.error) {
        throw new Error(data.error);
    }

    // 取得したデータ
    const text = `${data.title} ${data.description}`;

    // 正規表現で最初に現れる `数字 + %` を取得
    const match = text.match(/(\d+(\.\d+)?)%/);

    if (match) {
        // `match[0]` は `45.90%` のような値
        scrapedDataElement.innerText = match[0];
    } else {
        scrapedDataElement.innerText = "データなし";
    }
})
```
ここで、取得した title と description から % の直前の数字を探してる。

![あああ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4034432/a6a5598c-a12b-4634-99a8-b319984d319b.png)

例えば、
"勝率 45.90% で..." みたいな文章があったら 
match[0] = "45.90%" になる

```js
scrapedDataElement.innerText = match[0];
```
取得した % を、画面の  `<td id="scrapedData">`  に表示してる

## 2. ルート設定

```js
Route::get('/post/lol', [LolController::class, 'lol'])->name('lol.lol');
Route::get('/scrape-data', [App\Http\Controllers\ScraperController::class, 'getScrapedData'])->name('scrape.data');
```

ルート設定　上はただ表示するだけのルートで、下がスクレイピングするためのルート設定である。

## 3. コントローラーの設定

ここではスクレイピングするためだけのコントローラー設計のみ紹介する。（ScraperController.php）

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\DomCrawler\Crawler;
use Illuminate\Support\Facades\Log;

class ScraperController extends Controller
{
    public function getScrapedData()
    {
        try {
            $url = 'https://www.op.gg/champions/garen/build?hl=ja_JP&tier=iron'; // 取得するページのURL

            // HTTPクライアントを作成
            $client = HttpClient::create([
                'headers' => ['User-Agent' => 'Mozilla/5.0'],
                'verify_peer' => false
            ]);

            // ページ取得
            $response = $client->request('GET', $url);
            $html = $response->getContent();

            // HTMLをログに記録（デバッグ用）
            Log::info('Scraped HTML: ' . substr($html, 0, 500));

            // DOM解析
            $crawler = new Crawler($html);

            // h1タグのテキストを取得
            $title = $crawler->filter('h1')->count() > 0
                ? $crawler->filter('h1')->text()
                : 'タイトルが見つかりません';

            // pタグの最初の要素を取得
            $description = $crawler->filter('p')->count() > 0
                ? $crawler->filter('p')->first()->text()
                : '説明文が見つかりません';

            return response()->json([
                'title' => $title,
                'description' => $description,
            ]);
        } catch (\Exception $e) {
            Log::error('Scraping error: ' . $e->getMessage());
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }
}
```

この ScraperController は、op.gg からチャンピオン（Garen）の情報を取得して JSON 形式で返す API になっている。

### 以下解説

```js
$url = 'https://www.op.gg/champions/garen/build?hl=ja_JP&tier=iron'; // 取得するページのURL
```
まず、スクレイピング対象の URL を指定。
この場合、op.gg の「アイアン帯の Garen のビルド情報」ページを取得する。(さっきのurl)

```php
// HTTPクライアントを作成
$client = HttpClient::create([
    'headers' => ['User-Agent' => 'Mozilla/5.0'], // ブラウザっぽく見せる
    'verify_peer' => false  // SSL証明書の検証を無効化（必要なら有効にすべき）
]);

// ページ取得
$response = $client->request('GET', $url);
$html = $response->getContent();
```

HttpClient::create() で HTTP クライアントを作成。

headers で User-Agent を設定し、Bot ではなく普通のブラウザのふりをする（op.gg にブロックされにくくするため）。

verify_peer => false にして、SSL 証明書の検証を無効化（ローカル環境なら OK）。

`$client->request('GET', $url)` でページの HTML を取得

**よくわからん笑**

```php
// DOM解析
$crawler = new Crawler($html);

// h1タグのテキストを取得
$title = $crawler->filter('h1')->count() > 0
    ? $crawler->filter('h1')->text()
    : 'タイトルが見つかりません';

// pタグの最初の要素を取得
$description = $crawler->filter('p')->count() > 0
    ? $crawler->filter('p')->first()->text()
    : '説明文が見つかりません';
```

ここで Crawler クラスを使って、HTML を解析 している。

`$crawler->filter('h1') → h1` タグが存在すれば、そのテキストを取得

`$crawler->filter('p')->first() → p` タグの最初の要素を取得

:::note warn
注意
op.gg のページに勝率データが h1 や p の中にあるとは限らない！
勝率データが div.win-rate のようなタグの中にあるなら、正しいセレクタを探して変更する必要がある。
:::

```js
return response()->json([
    'title' => $title,
    'description' => $description,
]);
```
Laravel の response()->json() を使って、スクレイピングしたデータを JSON 形式で返している。