Nuxt3でスクレイピング

# はじめに
***Nuxt*** に触れたことがない方向けの記事です。
`netkeiba.com`のとあるレースから出走馬の情報を取得し、ランダムに1頭の馬名を表示するという簡易アプリを作成します。

まずは、公式のチュートリアルを実施することをお勧めします。
- [Vueのチュートリアル](https://ja.vuejs.org/tutorial/#step-1)
- [Nuxtのチュートリアル](https://learn-nuxt.vuejs-jp.org/)


# 環境
- ***macOS***
  - Sonoma 14.7.2
- ***Node.js***
  - v20.18.2
- ***npm***
  - 10.8.2
- ***Nuxt***
  - 3.15.4

※ ***Node.js*** や ***npm***、***Nuxt*** のインストール方法については、本記事に記載しています。


# Vue とは
>***Vue（発音は /vjuː/、view と同様）は、ユーザーインターフェースの構築のための JavaScript フレームワークです。標準的な HTML、CSS、JavaScript の上に構築され、あらゆる複雑さのユーザーインターフェースを効率的に開発するのに役立つ、宣言的でコンポーネントベースのプログラミングモデルを提供します。***

[Vue.jsガイド - Vue とは？](https://ja.vuejs.org/guide/introduction#what-is-vue) より引用

# Nuxt とは
>***Nuxt は、Vue.js を使用して、型安全でパフォーマンスが高く本番環境に適したフルスタックの Web アプリケーションやウェブサイトを作成するための直感的で拡張可能な方法を提供する、無料で オープンソースのフレームワーク です。***

[Nuxtチュートリアル - Nuxt とは?](https://learn-nuxt.vuejs-jp.org/#nuxt-%E3%81%A8%E3%81%AF) より引用


# 作成手順
本手順の「[5. スクレイピング実装❷（可変なURLにする）](https://qiita.com/royal_straight_flush/items/8939a6501b2a1a4366f6#5-%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0%E5%AE%9F%E8%A3%85%E5%8F%AF%E5%A4%89%E3%81%AAurl%E3%81%AB%E3%81%99%E3%82%8B)」までは、コピペと実行だけでアプリケーションを作成することが出来ます。

## 1. Nuxtをインストール
https://nuxt.com/docs/getting-started/installation

上記の公式手順で進めていただいても問題ありません。（本記事は公式手順に則っています）

:::note warn
`Node.js`のバージョンは**18**以上を使用する（※推奨）
:::

***Node.js*** 等がインストールされていない方は、以下の手順を追って ***Nuxt*** をインストールしてください。

### ① Homebrew をインストール（インストール済みの場合はSKIP）
https://brew.sh/ja/

- ターミナルで下記コマンドを実行
    ```terminal:terminal
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    ```

---
- インストールされていることを確認（バージョンが表示されること）
     ```terminal:terminal
     brew -v
     ```
---

### ② Node.js と npm のインストール（インストール済みの場合はSKIP）
- インストール実施
    ```terminal:terminal
    brew install node
    ```

---
- バージョンを確認する
    ```terminal:terminal
    $ node -v
    v23.7.0
    $ npm -v
    10.9.2
    ```
---


### ③ Node.js のバージョンを**20**に指定（任意）
コマンドは[こちら](https://nodejs.org/ja/download/current)から取得できます

- ***nvm*** をダウンロードしてインストールする
    ```terminal:terminal
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
    ```
---

- ***nvm*** コマンドを使えるようにする
    ```terminal:terminal
    source ~/.nvm/nvm.sh
    ```
---

- ***Node.js*** をダウンロードしてインストール
    ```terminal:terminal
    nvm install 20
    ```

---
- バージョンを確認する
    ```terminal:terminal
    $ node -v
    v20.18.2
    ```
---

### ④ プロジェクトを作成
- 任意のディレクトリへ移動
    ```terminal:terminal
    cd ~/Desktop/
    ```
---

- 新規プロジェクトを作成（プロジェクト名は任意）
    ```terminal:terminal
    npx nuxi@latest init horse-gacha
    ```

`Which package manager would you like to use?`と聞かれるので、`npm`を選択する。
他の質問には、問題がなければ`Yes`を選択しておく。

---

### ⑤ Visual Studio Code でプロジェクトフォルダを開く
- コマンドで開く方法
    ```terminal:terminal
    $ code horse-gacha
    ```

---


### ⑥ Nuxtアプリを開発モードで起動する

- 起動コマンド
    ```terminal:terminal
    npm run dev -- -o
    ```
- `-o`
  - ブラウザを自動で開くオプション
  - http://localhost:3000 のブラウザウィンドウが自動的に開く
- `npm run dev`に直接オプションは渡せない
  - `npm run dev`自体が`nuxi dev`コマンドのエイリアスとなっており、`-o`や`--open`は、この`nuxi dev`コマンドのオプションのため
  - このような`npm run`コマンドのエイリアスの本体に対してオプションを渡す場合は、間に`--`を記述する必要がある

:::note
以下のようなウェルカムページが表示されればOKです
![スクリーンショット 2025-02-07 16.49.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2689074/fc04f6df-1ee0-8485-63c4-356741d178e7.png)
:::


## 2. メインページを作成する（仮）
### ① `pages/index.vue` を作成
以下コマンドを実行（カレントディレクトリは`horse-gacha`）or 手動で作成し、適当な文字を記載します。
```terminal:terminal
mkdir pages
```
```terminal:terminal
touch pages/index.vue
```
```vuejs:pages/index.vue
<!-- 適当に編集 -->
<template>
  <h1>Hello world!</h1>
</template>
```

---
### ② `app.vue`を修正
Nuxtのルーティングでは`pages/index.vue` は自動的に `/` にマッピングされるため、以下に変更します。
```vuejs:app.vue
<template>
  <div>
    <NuxtPage />
  </div>
</template>
```

この状態でサーバーを起動し、ウェルカム画面から表示が変わったことを確認します。
※ファイルの変更分はしっかり保存しておくことを忘れずに！
```terminal:terminal
npm run dev -- -o
```

:::note
以下のような表示になっていればOK
![スクリーンショット 2025-02-07 17.11.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2689074/198535b1-635d-8db3-a8bf-695bbb2db738.png)
:::

---


## 3. スクレイピングをして問題ないサイトかどうかを確認
対象サイトでスクレイピングを実施しても問題ないかどうかをしっかりと確認します。
- ① `robots.txt`を確認
  - https://www.netkeiba.com/robots.txt
    - `404 Not Found`でした
    - これでは実施して良いか分かりません

```terminal:一応コマンドでも確認
$ curl -I https://www.netkeiba.com/robots.txt
HTTP/2 404 
content-type: text/html; charset=iso-8859-1
server: Apache
content-length: 0
date: Fri, 07 Feb 2025 08:18:32 GMT
```

- ② [利用規約](https://www.netkeiba.com/info/kiyaku.html) を確認
  - **<INS>スクレイピング禁止</INS>** の文言は無い
>第14条 (私的利用の範囲外の利用禁止)
1.当社が承認した場合 (当該情報に関して権利をもつ第三者がいる場合には、当社を通じ当該第三者の承諾を取得することを含みます。) を除き、サービスを通じて入手した いかなるデータ、情報、文章、発言、ソフトウェア等 (以下、併せて「データ等」といいます。) も、著作権法で認められた私的利用の範囲を超える複製、販売、出版のために 利用することはできません。なお、サービスの利用においては、著作物に該当しないデ ータ等であっても、サービスを通じて入手したものは著作物とみなします。また、データ等を不特定または多数の者が知りうる状態におく行為は、いかなる目的であっても私的利用の範囲を超える利用行為とみなします。
2.利用者は、前項に違反する行為を第三者にさせることはできません。

- ③ **よくある質問を確認 ⭐️**
  - [データベースの閲覧ができない・通信制限がかかった（スクレイビングについて）](https://support.keiba.netkeiba.com/hc/ja/articles/18841959592857-%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%81%AE%E9%96%B2%E8%A6%A7%E3%81%8C%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84-%E9%80%9A%E4%BF%A1%E5%88%B6%E9%99%90%E3%81%8C%E3%81%8B%E3%81%8B%E3%81%A3%E3%81%9F-%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%93%E3%83%B3%E3%82%B0%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
>スクレイピングによって多数のリクエスト（アクセス）を確認し、弊社サービスに対して支障があると判断した場合は、予告なく通信制限をかけさせていただくことがございます。
その場合、解除のご依頼をいただきましても解除できかねますこと、ご了承ください。
>
>
>＜利用規約＞
第１７条（その他の禁止事項）15項
>
>上記各号の他、法令、このメンバー規約もしくは公序良俗に違反（売春、暴力、残虐等）する行為、サービスの運営を妨害する行為、当社の信用を毀損し、もしくは当社の財産を侵害する行為、または他者もしくは当社に不利益を与える行為。
>
>
>＝＝＝＝＝
上記は、スクレイピングなどの行為によりサービスの運営に支障をきたす場合も含まれます。
スクレイピングはご自身の責任で、上記利用規約に抵触しないかを確認をお願いいたします。

:::note
禁止ではないが、過度なアクセスは制限対象（自己責任）
:::


## 4. Nuxt3でスクレイピング実装❶（対象URL指定ver）

### ① cheerio をインストール
```terminal:terminal
npm install cheerio
```

### ② axios をインストール
```terminal:terminal
npm install axios
```

### ③ iconv-lite を使用し、 Shift_JIS → UTF-8 に変換するために導入
文字化け防止のためにインストールします。
```terminal:terminal
npm install iconv-lite
```

### ④ Nuxt API ルート (server/api/scrape.ts) を作成
作業ディレクトリはアプリケーションディレクトリ（今回は`horse-gacha`）
```terminal:terminal
mkdir server/api
```
```terminal:terminal
touch server/api/scrape.ts
```

```ts:server/api/scrape.ts
import { defineEventHandler } from 'h3';
import axios from 'axios';
import * as cheerio from 'cheerio';
import iconv from 'iconv-lite';

export default defineEventHandler(async () => {
  try {
    const url = 'https://nar.netkeiba.com/race/shutuba.html?race_id=202554020410';

    // HTMLをバイナリデータで取得（エンコーディング設定なし）
    const { data } = await axios.get(url, {
      responseType: 'arraybuffer', // バイナリとして取得（文字化けを防止）
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', // ブラウザからのアクセスに見せかける（スクリプトからのアクセスは bot と見なされることがあるため）
        'Referer': 'https://www.netkeiba.com/', // 「netkeiba.com からの遷移」と認識され、アクセスしやすくなる
      }
    });

    // `EUC-JP` → `UTF-8` に変換
    const utf8Data = iconv.decode(Buffer.from(data), 'EUC-JP');

    // Cheerio で HTML を解析
    const $ = cheerio.load(utf8Data);

    // cheerio を使い HTML から馬の名前を抽出し、horses 配列に格納する
    const horses: string[] = []; // string[] 型で定義
    $('.HorseList .HorseName').each((_, el) => { // クラス名が HorseList の中にある HorseName 要素を全て取得し、eachでループし HorseName 要素を処理
      horses.push($(el).text().trim()); // $(el).text() で馬名のテキストを取得し、.trim()で不要な空白や改行を削除。pushで horses 配列に追加
    });

    console.log('取得した馬名:', horses);
    return { horses };
  } catch (error) {
    console.error('スクレイピングエラー:', error);
    return { error: 'スクレイピングに失敗しました' };
  }
});
```

:::note
最初はスクレイピング実施対象ページのURLをハードコーディングで決め打ちして実装します（後で可変に対応するようにしていけばOKです）
:::

### ⑤ composables/useScrapedHorses.ts を作成
```terminal:terminal
mkdir composables
```
```terminal:terminal
touch composables/useScrapedHorses.ts
```

```ts:composables/useScrapedHorses.ts
import { ref } from 'vue';

export function useScrapedHorses() {
  const scrapedHorseNames = ref<string[]>([]);
  // スクレイピング実行中のフラグ
  const isLoading = ref(true);

  const fetchScrapedHorses = async () => {
    try {
      isLoading.value = true; // スクレイピング開始
      const response = await fetch('/api/scrape');
      const data = await response.json();

      if (data.horses) {
        scrapedHorseNames.value = data.horses;
      } else {
        console.error('スクレイピング結果が取得できません:', data.error);
      }
    } catch (error) {
      console.error('スクレイピングAPIエラー:', error);
    } finally {
      isLoading.value = false; // スクレイピング完了
    }
  };

  return { scrapedHorseNames, isLoading, fetchScrapedHorses };
}
```

### ⑥ composables/useGacha.ts を作成
```terminal:terminal
touch composables/useGacha.ts
```

```ts:composables/useGacha.ts
import { ref } from 'vue';

export function useGacha(horseNames: Ref<string[]>) { // horseNamesに型を指定する
  // ガチャで選ばれた馬の名前を保持する
  const selectedHorse = ref<string | null>(null);
  // ガチャが回転中かどうかを示すフラグ（true: ガチャが回っている状態）
  const isRolling = ref(false);
  // setInterval() のタイマーIDを格納し、管理するための変数
  let timer: number | null = null;

  // ガチャを回す処理
  const startGacha = () => {
    if (horseNames.value.length === 0) return;

    isRolling.value = true;

    // 100ms（0.1秒）ごとにランダムな馬名を選択
    // window.setInterval() を使えば、ブラウザ（Nuxt3）環境で number を返すため型エラーが解消する
    timer = window.setInterval(() => { // setInterval()：指定された時間間隔（ミリ秒単位）で関数またはコードスニペットを繰り返し実行する
      selectedHorse.value = horseNames.value[Math.floor(Math.random() * horseNames.value.length)];
    }, 100);

    // 2秒後にガチャを停止
    setTimeout(() => {
      if (timer !== null) {
        // 現在動いている setInterval()を止める → 馬名がランダムに変わり続けてしまうため
        clearInterval(timer);
        timer = null;
        // ガチャの回転が終わったことを示す。falseにすることで、ボタンを再び押せる状態にする
        isRolling.value = false;
      }
    }, 2000);
  };

  return { selectedHorse, isRolling, startGacha };
}
```

### ⑦ pages/index.vue に統合
※理解のため、無駄にコメントを入れています
```vuejs:pages/index.vue
<!-- 単一ファイルコンポーネント（SFC） ※コンポーネントのロジック（JavaScript）、テンプレート（HTML）、およびスタイル（CSS）を単一のファイルに収めたもの -->

<!-- コンポーネントのロジック部分を定義 -->
<script setup>
// setup という属性を付けることで、Vue にコンパイル時の変形操作を実行してほしいというヒントが伝えられる
// これにより、定型的な書式の少ない Composition API を利用することが可能

// インデント不要（Vue公式の推奨スタイル）→ <script setup>内のコードは 通常のJavaScriptのように記述する のが推奨されているため

import { onMounted, onBeforeUnmount, onUpdated } from 'vue'
import { useScrapedHorses } from "~/composables/useScrapedHorses";
import { useGacha } from "~/composables/useGacha"

// スクレイピングデータを取得
const { scrapedHorseNames, isLoading, fetchScrapedHorses } = useScrapedHorses();
// ガチャのロジックを適用
const { selectedHorse, isRolling, startGacha } = useGacha(scrapedHorseNames);

// ライフサイクルフック
// コンポーネントがマウントされた後に実行
onMounted(() => {
  // ページがマウントされたらスクレイピングデータを取得
  fetchScrapedHorses();
  console.log('ガチャページがマウントされました！');
  console.log('スクレイピングデータを取得しました！');
});

// コンポーネントの状態が更新された時に実行
onUpdated(() => {
  console.log('ページの状態が更新されました！');
});

// コンポーネントが削除される直前に実行
onBeforeUnmount(() => {
  console.log('ページがアンマウントされます...');
  // clearInterval(): setInterval()でセットしたタイマーを解除する
  if (timer) clearInterval(timer);
});
</script>

<!-- コンポーネントのビュー部分を定義 -->
<template>
  <div class="container">
    <h1>🏇 本命馬決定ガチャ 🏇</h1>

    <!-- スクレイピング中は「loading...」を表示 -->
    <h2 v-if="isLoading" class="display-loading">🔄 loading...</h2>

    <!-- スクレイピングが完了したらボタンを表示 -->
    <template v-else>
      <!-- ガチャ結果を表示 -->
      <!-- v-if：要素を条件付きでレンダリングする -->
      <p v-if="selectedHorse">
        <span v-if="!isRolling">結果：</span><strong>{{ selectedHorse }}</strong>
      </p>
      <button @click="startGacha" :disabled="isRolling || scrapedHorseNames.length === 0">
        ガチャを回す
      </button>
    </template>
  </div>
</template>

<!-- コンポーネント固有のスタイルを定義 -->
<style scoped>
/* scoped属性を追加することで、このコンポーネントのスタイルが他のコンポーネントに影響を与えないようにする */

.container {
  text-align: center;
  margin-top: 50px;
}

.display-loading {
  padding: 10px 20px;
  margin-top: 20px;
}

button {
  padding: 10px 20px;
  font-size: 18px;
  cursor: pointer;
  margin-top: 20px;
}
</style>
```

### ⑧ 動作確認

すべての変更ファイルを保存し、サーバーを起動
```terminal:terminal
npm run dev -- -o
```

![horse-gacha.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2689074/43518cc2-a87d-eaf1-a02f-54795caadc87.gif)

:::note
スクレイピングし、ランダムに1頭の馬名を選出する簡易アプリを作成できました
:::



## 5. スクレイピング実装❷（可変なURLにする）
ハードコーディングでURLを指定するのではなく、当日のレースURLからスクレイピングできるよう可変的にします。

### ① puppeteer をインストール
JavaScript 実行後の HTML を取得出来るようにします。
```terminal:terminal
npm install puppeteer
```

### ② server/api/scrape.ts を修正
```ts:server/api/scrape.ts
import { defineEventHandler } from 'h3';
import axios from 'axios';
import puppeteer from 'puppeteer';
import * as cheerio from 'cheerio';
import iconv from 'iconv-lite';

// 【共通関数】PuppeteerでページのHTMLを取得
async function fetchHtmlWithPuppeteer(url: string): Promise<string> {
  try {
    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();

    console.log(`Puppeteerでページ取得中: ${url}`);
    // JavaScript の実行を待つ
    await page.goto(url, { waitUntil: 'networkidle2' });

    // JavaScript 実行後の HTML を取得
    const content = await page.content();
    await browser.close();

    return content;
  } catch (error) {
    console.error(`Puppeteerエラー: ${error}`);
    throw new Error('Puppeteerでページを取得できませんでした');
  }
}

// 【共通関数】AxiosでページのHTMLを取得し、EUC-JP → UTF-8 に変換
async function fetchHtmlWithAxios(url: string): Promise<ReturnType<typeof cheerio.load>> {
  try {
    console.log(`Axiosでページ取得中: ${url}`);

    const { data } = await axios.get(url, {
      responseType: 'arraybuffer',
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
        'Referer': 'https://nar.netkeiba.com/',
      }
    });
    const utf8Data = iconv.decode(Buffer.from(data), 'EUC-JP');

    // Cheerio で HTML を解析して返却
    return cheerio.load(utf8Data);
  } catch (error) {
    console.error(`Axiosエラー: ${error}`);
    throw new Error('Axiosでページを取得できませんでした');
  }
}

// 【共通関数】高知ファイナルレースの番号を取得
function getFinalRaceNumber($: ReturnType<typeof cheerio.load>): string {
  const kochiRaceList = $('dl.RaceList_DataList').filter((_, dl) =>
    $(dl).find('.RaceList_DataHeader').text().includes('高知')
  );
  const finalRace = kochiRaceList.find('.RaceList_ItemTitle:contains("ファイナルレース")').closest('.RaceList_DataItem');

  // ex) "9R" → "9" の変換
  return finalRace.find('.Race_Num').text().trim().replace('R', '');
}

// 【共通関数】出走馬のリストを取得
function getHorseNames($: ReturnType<typeof cheerio.load>): string[] {
  return $('.HorseList .HorseName').map((_, el) => $(el).text().trim()).get();
}

export default defineEventHandler(async () => {
  try {
    // 日付フォーマット
    const [year, month, day] = new Date().toISOString().slice(0, 10).split('-'); // [yyyy, MM, dd]
    const formattedDate = `${year}${month}${day}`

    // 高知競馬のレース一覧ページ
    const raceListUrl = `https://nar.netkeiba.com/top/race_list.html?kaisai_id=${year}54${month}${day}&kaisai_date=${formattedDate}&rf=race_list`;

    console.log(`高知競馬のレース一覧URL: ${raceListUrl}`);

    // 高知ファイナルレースの番号を取得
    const listHtml = await fetchHtmlWithPuppeteer(raceListUrl);
    const $listPage = cheerio.load(listHtml);
    const finalRaceNumber = getFinalRaceNumber($listPage);

    if (!finalRaceNumber) {
      console.log("高知ファイナルレースが見つかりませんでした");
      return { error: '本日は高知競馬は開催されていません' };
    }

    console.log(`高知ファイナルレース番号: ${finalRaceNumber}R`);

    // 高知ファイナル出走馬リストのページURL
    const raceId = `${year}54${month}${day}${finalRaceNumber.padStart(2, '0')}`;
    const raceUrl = `https://nar.netkeiba.com/race/shutuba.html?race_id=${raceId}&rf=race_list`;
    console.log(`スクレイピング対象レースURL: ${raceUrl}`);

    // 出走馬名を取得
    const $racePage = await fetchHtmlWithAxios(raceUrl);
    const horses = getHorseNames($racePage);

    if (horses.length === 0) {
      console.log("出走馬が取得できませんでした");
      return { error: '本日は高知競馬は開催されておりません' };
    }

    console.log(`出走馬一覧: ${horses}`);

    return { horses };
  } catch (error) {
    console.error('スクレイピングエラー:', error);
    return { error: 'スクレイピングに失敗しました' };
  }
});
```

### ③ composables/useScrapedHorses.ts を修正


```ts:composables/useScrapedHorses.ts
import { ref } from 'vue';

export function useScrapedHorses() {
  const scrapedHorseNames = ref<string[]>([]);
  // スクレイピング実行中のフラグ
  const isLoading = ref(true);
  // エラーメッセージ用
  const errorMessage = ref<string | null>(null);

  const fetchScrapedHorses = async () => {
    try {
      // スクレイピング開始
      isLoading.value = true;
      // エラーをリセット
      errorMessage.value = null;

      // APIリクエスト
      console.log('スクレイピングAPIリクエスト中...');
      const response = await fetch('/api/scrape');
      const data = await response.json();

      if (data.horses) {
        scrapedHorseNames.value = data.horses;
      } else {
        errorMessage.value = data.error;
      }
    } catch (error) {
      errorMessage.value = 'スクレイピングに失敗しました';
    } finally {
      isLoading.value = false; // スクレイピング完了
      console.log('スクレイピング処理完了');
    }
  };

  return { scrapedHorseNames, isLoading, errorMessage, fetchScrapedHorses };
}
```

### ④ pages/index.vue を修正
```vuejs:pages/index.vue
<!-- 単一ファイルコンポーネント（SFC） ※コンポーネントのロジック（JavaScript）、テンプレート（HTML）、およびスタイル（CSS）を単一のファイルに収めたもの -->

<!-- コンポーネントのロジック部分を定義 -->
<script setup>
// setup という属性を付けることで、Vue にコンパイル時の変形操作を実行してほしいというヒントが伝えられる
// これにより、定型的な書式の少ない Composition API を利用することが可能

// インデント不要（Vue公式の推奨スタイル）→ <script setup>内のコードは 通常のJavaScriptのように記述する のが推奨されているため

import { onMounted } from 'vue';
import { useScrapedHorses } from "~/composables/useScrapedHorses";
import { useGacha } from "~/composables/useGacha";

// スクレイピングデータを取得するカスタムフック
const { scrapedHorseNames, isLoading, errorMessage, fetchScrapedHorses } = useScrapedHorses(); // スクレイピングデータを取得
const { selectedHorse, isRolling, startGacha } = useGacha(scrapedHorseNames); // ガチャのロジックを適用

// ライフサイクルフック
onMounted(() => {
  console.log('ガチャページがマウントされました！');
  fetchScrapedHorses(); // ページがマウントされたらスクレイピングを実行
});
</script>

<!-- コンポーネントのビュー部分を定義 -->
<template>
  <div class="container">
    <h1>🏇 本命馬決定ガチャ 🏇</h1>

    <!-- スクレイピング中は「loading...」を表示 -->
    <h2 v-if="isLoading" class="display-loading">🔄 loading...</h2>

    <!-- エラーメッセージの表示 -->
    <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>

    <!-- スクレイピングが完了したらボタンを表示 -->
    <template v-else>
      <!-- ガチャ結果を表示 -->
      <!-- v-if：要素を条件付きでレンダリングする -->
      <p v-if="selectedHorse">
        <span v-if="!isRolling">結果：</span><strong>{{ selectedHorse }}</strong>
      </p>
      <button @click="startGacha" :disabled="isRolling || scrapedHorseNames.length === 0">
        ガチャを回す
      </button>
    </template>
  </div>
</template>

<!-- コンポーネント固有のスタイルを定義 -->
<style scoped>
/* scoped属性を追加することで、このコンポーネントのスタイルが他のコンポーネントに影響を与えないようにする */

/* コンテナ全体を中央に配置 */
.container {
  text-align: center;
  margin-top: 50px;
}

.display-loading {
  padding: 10px 20px;
  margin-top: 20px;
}

.error-message {
  color: red;
  font-weight: bold;
}

button {
  padding: 10px 20px;
  font-size: 18px;
  cursor: pointer;
  margin-top: 50px;
}
</style>
```

### ⑤ 動作確認
サーバー起動
```terminal:terminal
npm run dev -- -o
```

:::note
URLを可変にし、当日のレースページからスクレイピング出来るようになりました
:::

ちなみに、レースが無い日は Loading後 に以下のような表示になります。
![スクリーンショット 2025-02-07 18.58.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2689074/d5ebb1ee-2325-6655-4562-29e6414981a6.png)

## 6. おまけ
- 取得してきたデータをローカルストレージに保存しガチャ履歴を表示/削除
- 様々なライフサイクルを使用
- 子コンポーネントに分けてページの肥大化を防止し、保守性を高める

などといったことも実現できます。

### 実装例
※子コンポーネントを実装した上で修正する必要があります
```vuejs:pages/index.vue
<!-- 単一ファイルコンポーネント（SFC） ※コンポーネントのロジック（JavaScript）、テンプレート（HTML）、およびスタイル（CSS）を単一のファイルに収めたもの -->

<!-- コンポーネントのロジック部分を定義 -->
<script setup>
// setup という属性を付けることで、Vue にコンパイル時の変形操作を実行してほしいというヒントが伝えられる
// これにより、定型的な書式の少ない Composition API を利用することが可能

// インデント不要（Vue公式の推奨スタイル）→ <script setup>内のコードは 通常のJavaScriptのように記述する のが推奨されているため

import { ref, onMounted, onBeforeUpdate, onUpdated } from 'vue';
import { useScrapedHorses } from "~/composables/useScrapedHorses";
import { useGacha } from "~/composables/useGacha";

// コンポーネントのインポート
import GachaButton from "~/components/GachaButton.vue";
import GachaResult from "~/components/GachaResult.vue";
import LoadingIndicator from "~/components/LoadingIndicator.vue";
import ErrorMessage from "~/components/ErrorMessage.vue";
import GachaHistory from "~/components/GachaHistory.vue";

// スクレイピングデータを取得するカスタムフック
const { scrapedHorseNames, isLoading, errorMessage, fetchScrapedHorses } = useScrapedHorses(); // スクレイピングデータを取得
const { selectedHorse, isRolling, startGacha } = useGacha(scrapedHorseNames); // ガチャのロジックを適用

// Now Loading... のアニメーション用
const msg = 'Now Loading'
const loadingText = ref(msg);

// ローディングアニメーションを開始する関数
const startLoadingAnimation = () => {
  const states = [msg, `${msg}.`, `${msg}..`, `${msg}...`];
  let index = 0;
  setInterval(() => {
    loadingText.value = states[index];
    index = (index + 1) % states.length; // 0,1,2,3 のループ
  }, 500);
};

// ガチャ履歴（選ばれた1頭ずつ追加）
const gachaHistory = ref([]);

// ガチャ履歴の最大件数
const HISTORY_LIMIT = 5;

// ガチャ結果をローカルストレージに保存（選ばれた1頭だけ追加）
const saveToLocalStorage = (horseName) => {
  if (!horseName) return;

  console.log("選ばれた馬:", horseName);

  // すでに同じ馬が履歴にある場合は追加しない（重複防止）
  gachaHistory.value.push(horseName);

  console.log("履歴リスト:", gachaHistory.value);

  // 履歴をローカルストレージに保存
  localStorage.setItem('gachaHistory', JSON.stringify(gachaHistory.value));
};

// ガチャ履歴の削除（1件ずつ）
const deleteHorse = (index) => {
  gachaHistory.value.splice(index, 1); // 指定したインデックスの履歴を削除

  // ローカルストレージを更新
  localStorage.setItem('gachaHistory', JSON.stringify(gachaHistory.value));
};

// isRolling の変化を監視し、ガチャが止まったら履歴に追加
watch(isRolling, (newState) => {
  if (!newState && selectedHorse.value) {
    console.log("ガチャ終了！選ばれたのは:", selectedHorse.value);
    saveToLocalStorage(selectedHorse.value);
  }
});

// ガチャを回したらリセットメッセージを消す
watch(selectedHorse, () => {
  resetMessage.value = "";
});

// ローカルストレージから履歴を取得
const loadFromLocalStorage = () => {
  const savedData = localStorage.getItem('gachaHistory');
  if (savedData) {
    gachaHistory.value = JSON.parse(savedData);
    console.log("ローカルストレージから復元:", gachaHistory.value);
  }
};

// 履歴リセット時のメッセージ
const resetMessage = ref("");
// リセット処理中かどうか
const isResetting = ref(false);

// 全履歴をリセットする関数
const resetHistory = () => {
  // リセット処理開始フラグ
  isResetting.value = true;
};

// ライフサイクルフック
// コンポーネントがマウントされた後に実行
onMounted(() => {
  console.log('ガチャページがマウントされました！');

  fetchScrapedHorses(); // スクレイピングを実行
  startLoadingAnimation(); // ローディングアニメーションを開始
  loadFromLocalStorage(); // ローカルストレージの履歴を復元
});

// onBeforeUpdate()：コンポーネントがリアクティブな状態変更により「仮想DOMの更新を実行する直前」に呼び出されるフックを登録する。このフックの後、実際の DOM 更新が行われる。
// リセット確認ダイアログを出す
onBeforeUpdate(() => {
  if (isResetting.value) {
    console.log("onBeforeUpdate: 確認ダイアログを表示");
    const confirmation = window.confirm("ガチャ結果の履歴がすべて消えますがよろしいですか？");
    if (confirmation) {
      gachaHistory.value = []; // メモリ上の履歴をリセット
      localStorage.removeItem('gachaHistory'); // ローカルストレージの履歴も削除
      resetMessage.value = "ガチャ履歴をリセットしました"; // メッセージを表示
      console.log("履歴がリセットされました");
    }

    // 3秒後にメッセージを消す
    setTimeout(() => {
      resetMessage.value = "";
    }, 3000);

    isResetting.value = false; // フラグをリセット
  }
});

// onUpdated()：コンポーネントの状態が更新された時に実行
// 履歴が変更されたら最新5件に制限
onUpdated(() => {
  if (gachaHistory.value.length > HISTORY_LIMIT) {
    console.log(`履歴が${gachaHistory.value.length}件になったので、古いデータを削除します`);
    
    // 古い履歴を削除（最も古いものを削除）
    gachaHistory.value.splice(0, gachaHistory.value.length - HISTORY_LIMIT);
    
    // ローカルストレージを更新
    localStorage.setItem('gachaHistory', JSON.stringify(gachaHistory.value));
  }
});
</script>

<!-- コンポーネントのビュー部分を定義 -->
<template>
  <div class="container">
    <h1 id="index-page-title">🏇 高知ファイナル 本命馬決定ガチャ 🏇</h1>

    <div class="message-area">
      <!-- スクレイピング中は「Now Loading...」を表示 -->
      <LoadingIndicator :isLoading="isLoading" :loadingText="loadingText" />

      <!-- エラーメッセージを表示 -->
      <ErrorMessage :errorMessage="errorMessage" />

      <!-- ガチャ結果を表示 -->
      <GachaResult :selectedHorse="selectedHorse" :isRolling="isRolling" />
    </div>
    
    <!-- ガチャを回すボタン -->
    <GachaButton :startGacha="startGacha" :isRolling="isRolling" :isDisabled="scrapedHorseNames.length === 0" />

    <!-- ガチャ履歴表示 -->
    <GachaHistory :gachaHistory="gachaHistory" :deleteHorse="deleteHorse" :resetMessage="resetMessage" :resetHistory="resetHistory" :gachaHistoryLength="gachaHistory.length" :isResetting="isResetting" />
  </div>
</template>

<!-- コンポーネント固有のスタイルを定義 -->
<style scoped>
/* scoped属性を追加することで、このコンポーネントのスタイルが他のコンポーネントに影響を与えないようにする */

/* ページ全体の背景を黒にする */
body {
  text-align: center;
  background: #000;
  background-color: black;
  color: white; /* 文字色も調整（黒背景だと見づらいため） */
}

#index-page-title {
  margin: 0;
  font-size: 60px;
  font-family: 'ヒラギノ明朝 Pro W3', 'Hiragino Mincho Pro', 'Hiragino Mincho ProN', 'HGS明朝E', 'ＭＳ Ｐ明朝', serif;
  position: relative;
  padding: 1.5rem 2rem;
  -webkit-box-shadow: 0 2px 14px rgba(0, 0, 0, .1);
  box-shadow: 0 2px 14px rgba(0, 0, 0, .1);
}

#index-page-title:before,
#index-page-title:after {
  position: absolute;
  left: 0;
  width: 100%;
  height: 6px;
  content: '';
  background-image: -webkit-linear-gradient(315deg, #704308 0%, #ffce08 40%, #e1ce08 60%, #704308 100%);
  background-image: linear-gradient(135deg, #704308 0%, #ffce08 40%, #e1ce08 60%, #704308 100%);
}

#index-page-title:before {
  top: 0;
}

#index-page-title:after {
  bottom: 0;
}

/* コンテナ全体を中央に配置 */
.container {
  margin: 0;
  text-align: center;
  background-color: black;
  color: white; /* 文字色も白に */
  min-height: 100vh; /* 画面全体を黒にするため */
}

/* メッセージエリアの高さを固定 */
.message-area {
  min-height: 200px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  font-size: 40px;
  margin-top: 40px;
  margin-bottom: 20px;
}

/* button */
*,
*:before,
*:after {
  -webkit-box-sizing: inherit;
  box-sizing: inherit;
}

html {
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  font-size: 62.5%;
}
</style>
```

# 最後に
初めて公式のチュートリアルに触れた時は「難しい」というイメージでしたが、実際に簡単なアプリを作成してからもう一度チュートリアルを行うと、初回と解像度が全然違うので、ぜひアプリ作成をされた後にも実施することをお勧めします。

フロントエンドなので実装していて見た目がどんどん変わっていくのが楽しいですし、インポートやルーティングも自動なので開発がすごく楽でした。

また、スクレイピングは今まで ***Python*** でしか扱ったことがありませんでしたが、***Nuxt*** でも簡単に実装できることに驚きました。

今後、もっと ***Nuxt*** の理解を深めていく予定です。