古の個人"ほぉむぺぇじ"をMarkdown AIで作ってみる

この記事は、[Markdown AIのサーバーAI機能を使ってWebサイトを作ってみよう by MarkdownAI Advent Calendar 2024](https://qiita.com/advent-calendar/2024/markdownai)の参加記事です。

https://qiita.com/advent-calendar/2024/markdownai

タイトルの通り、2000年前後に存在した、個人ホームページをMarkdown AIで作ってみたというものです。以下が実物です。

https://mdown.ai/content/8c0b781a-1c3f-4eca-8406-11bf99759378

作りかけの中途半端な雰囲気がいかにもそれっぽくないですか！　投稿までに間に合わなかったいいわけではない（爆）

# Markdown AIはHTMLタグも使える

文字が右から左に流れる懐かしのマーキーは、まんま、marqueeタグで動いています。Markdown AIでサポートされているとは思わなかったので驚きました。そして嬉しかったです。spanタグも使えるので、そこでスタイルを指定すれば、色を付けたりも自由自在です。マーキー部分は以下の様なソースになっています。

```html
<marquee><span style="color:red;">ここは電脳天使が運営する、まったり＆のんびりなホームページです。ゆっくりしていってね！</span></marquee>
```

# Markdown AIはheadタグ内にstyleも書ける

動いたので嬉しいです。ここで、壁紙をbodyに対して設定したり、文字色を変えたりしています。背景画像ではなく壁紙と言うのがレトロスタイルです！

# Markdown AIはscriptタグも使える

Markdown AIはその名の通り、gpt-4o-miniなどのAIを呼び出すことができるのがウリです。呼び出すにはscriptタグを使うので、つまりは、Javascriptでできることは大抵出来てしまいます。

そこで、マウスカーソルを★に変える遊びを入れてみました。これでますますレトロな雰囲気に！

# scriptタグが使えるということは、外部との通信も出来る

今回、アクセスカウンターを設置しています。これは実装としてはちょっと大げさで、

- Markdown AIで作ったページから、Cloudflare workersで作ったエンドポイントを叩く
- Cloudflare wordersでKVにアクセス数を保存するデータベースにアクセス数を保存する
- そのアクセス数を返して、カウンターとして表示する

ということをやっています。リロードする度に数字が増えるレトロ仕様です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199260/e8530367-a938-15f3-1bd5-dde4e691562a.png)



# 「占い」部分はちゃんとAIを使った！

昔のホームページには本日の運勢を表示していたりしました。毎日違う占いが表示されるCGIとか使っていました。せっかくなので、Markdown AIの機能を使って、AIで占いを生成してもらっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199260/84fd4a8f-61a8-ee73-fc63-180e2092c273.png)

↑ここの「占う」のボタンを押すと占いが表示されます。表示には30秒くらい待ちましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199260/6a23d791-53f7-b708-ef1e-e188d3b9a097.png)

こんな感じで、古のホームページを作ってみました。楽しいです！

# Markdown AIの真面目な使い方

今回は企画ものということでネタに走ってしまいましたが、本来的な使い方としては

- 学園祭の出し物の案内のように、必要とされる期間が短く対象も限定的だがあると便利なページを作る
- 家庭教師など、個人で請け負う小規模な仕事の告知ページを作って、チラシのQRコードから飛べるように設定する

といった使い方だとすごく便利だと思います。同様のサービスとしては、ペライチやWixなどがありますが、それらに比べると、Markdownでちょろっと書くだけ、というのはすごく便利です。

最近はWordファイルをMarkdownに変換することもできますし、Wordで作って変換してペタッと貼るだけで、上記のようなページを作れるのは便利なはず。そこにAIでちょっとした遊び心を入れられるのは楽しいですよね。

# ソース

一応、ソースを貼っておきます。

### Markdown部分

壁紙画像はCloudflare R2に置き、アクセスカウンターのエンドポイントはCloudflare Workersで立てています。

```markdown

<head>
    <meta charset="UTF-8">
    <style>
        html, body {
            height: auto;
            min-height: 100%;
        }
        body {
            margin: 0;
            padding: 0;
            background-image: url('https://■■■■■■■■■■.r2.dev/b071.jpg'); //画像はCloudflare R2に置いています
            background-attachment: fixed;
            background-repeat: repeat;
            background-color: #000040;
            color: #000000;
            font-family: "MS PGothic", sans-serif;
            cursor: none;
            min-height: 100%;
        }

        #retro-container {
            padding: 20px;
            text-align: center;
            background-color: rgba(0, 0, 0, 0.5);
            min-height: 100vh;
        }

        #content-wrapper {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: rgba(255, 255, 255, 0.9);
            border-radius: 10px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
        }

        #visitorCount {
            font-family: monospace;
            font-size: 24px;
            padding: 10px;
            border: 2px solid #FFD700;
            display: inline-block;
            background-color: rgba(0, 0, 0, 0.7);
            color: #FFD700;
        }

        #debug {
            display: none;
            font-family: monospace;
            white-space: pre-line;
            margin: 20px;
            padding: 10px;
            border: 2px solid #FFD700;
            background: rgba(0, 0, 0, 0.8);
            color: #FFFFFF;
            position: fixed;
            bottom: 10px;
            right: 10px;
            max-width: 400px;
            max-height: 300px;
            overflow: auto;
            z-index: 9999;
            font-size: 12px;
            border-radius: 5px;
            box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
        }

        #custom-cursor {
            position: fixed;
            pointer-events: none;
            z-index: 10000;
            font-size: 24px;
            color: #FFD700;
            text-shadow: 0 0 5px #FFD700;
        }

        /* Markdown content styles */
        h1, h2, h3 {
            text-shadow: 2px 2px 4px #000;
        }

        ul {
            list-style-type: none;
            padding-left: 20px;
        }

        img {
            margin: 10px;
            border: 2px solid #FFD700;
            border-radius: 5px;
        }

        marquee {
            background-color: rgba(0, 0, 0, 0.5);
            border-top: 2px solid #FFD700;
            border-bottom: 2px solid #FFD700;
            padding: 5px 0;
            margin: 20px 0;
        }
    </style>
</head>
<body>
    <div id="retro-container">
        <!-- デバッグパネル -->
        <div id="debug">
            Debug Log:
            <button onclick="toggleDebug()" style="position: absolute; top: 5px; right: 5px; cursor: pointer;">×</button>
        </div>

        <!-- カスタムカーソル -->
        <div id="custom-cursor">★</div>

        <!-- メインコンテンツ -->
        <div id="content-wrapper">
# <span style="color:blue;">ようこそ！★★★電脳天使の秘密基地へ★★★</span>

<marquee><span style="color:red;">ここは電脳天使が運営する、まったり＆のんびりなホームページです。ゆっくりしていってね！</span></marquee>

## 占い


<div style="display: inline-block;">
  <input type="text" id="text-■■■■■■" style="width: 200px;" value="今日の運勢を教えちゃいます！">
  <button type="button" id="button-■■■■■■">占う</button>
</div>
<div id="answer-■■■■■■"></div>

<script>
(() => {
  const button = document.getElementById('button-■■■■■■');
  button.addEventListener('click', async event => {
    button.disabled = true;
    
    const serverAi = new ServerAI();
    const message = document.getElementById('text-■■■■■■').value;
    const answer = await serverAi.getAnswerText('■■■■■■2', '', message);
    
    document.getElementById('answer-■■■■■■').innerText = answer;
    button.disabled = false;
  });
})();
</script>

## <span style="color:green;">更新情報</span>

-   **2000/05/05** 日記を更新しました。「ゴールデンウィークはゲーム三昧！」
-   **2000/04/28** キリ番報告を更新しました。1000HIT達成！
-   **2000/04/20** 趣味のページに「ガンプラコレクション」を追加しました。
-   **2000/04/15** 自己紹介を更新しました。
-   **2000/04/01** 電脳天使の秘密基地、開設しました！

## <span style="color:purple;">コンテンツ</span>

*   自己紹介- 電脳天使ってどんな人？
*   日記 - 毎日更新（するつもり）！
*   趣味のページ - ガンプラ、アニメ、ゲームなどなど
*   キリ番報告 - キリ番ゲットしたら報告してね！
*   リンク集 - お友達のサイトを紹介！
*   BBS - みんなで仲良くおしゃべりしよう！

<br>

## あくせすかうんたぁ

あなたは
<div id="visitorCount">Loading...</div>
人目の訪問者です☆彡

## <span style="color:orange;">キリ番報告</span>

祝！**10HIT**達成！<br>
1000HITを踏んだのは、**ハンドルネーム：勇者**さんです！おめでとうございます！<br>
BBSに記念カキコしてくれた方に、ささやかなプレゼントを差し上げます！

次は**100HIT**を目指します！

<br>

## <span style="color:pink;">日記</span>

### 2000/12/23 クリスマスはゲーム三昧！

もう今年も終わりかぁ。早いなぁ。<br>
ずっと家でゲームしてた！<br>
寝る間も惜しんでプレイしちゃったよ。<br>
そんな漏れもクリスマスは空いているらしいよ<br>
あー、お誘いとかないかなぁ（チラッ）。

<br>

---

<br>

<span style="color:gray;">Copyright (C) 2000 電脳天使. All Rights Reserved.</span>
        </div>
    </div>

    <script>
        // デバッグ関連の機能
        function debugLog(message) {
            console.log(message);
            const debugDiv = document.getElementById('debug');
            const timestamp = new Date().toLocaleTimeString();
            debugDiv.textContent += `\n[${timestamp}] ${message}`;
            debugDiv.scrollTop = debugDiv.scrollHeight;
        }

        function debugError(message, error) {
            console.error(message, error);
            const debugDiv = document.getElementById('debug');
            const timestamp = new Date().toLocaleTimeString();
            debugDiv.textContent += `\n[${timestamp}] [ERROR] ${message}`;
            if (error) {
                debugDiv.textContent += '\n' + error.toString();
            }
            debugDiv.style.display = 'block';
            debugDiv.scrollTop = debugDiv.scrollHeight;
        }

        function toggleDebug() {
            const debugDiv = document.getElementById('debug');
            debugDiv.style.display = debugDiv.style.display === 'none' ? 'block' : 'none';
        }

        // カスタムカーソルの制御
        document.addEventListener('mousemove', (e) => {
            const cursor = document.getElementById('custom-cursor');
            cursor.style.left = `${e.clientX}px`;
            cursor.style.top = `${e.clientY}px`;
            cursor.style.transform = 'translate(-50%, -50%)';
        });

        // デバッグパネルのショートカットキー
        document.addEventListener('keydown', function(e) {
            if (e.ctrlKey && e.shiftKey && e.key === 'D') {
                e.preventDefault();
                toggleDebug();
            }
        });

        // カウンター更新機能
        async function updateVisitorCount() {
            debugLog('Updating visitor count...');
            
            try {
                const response = await fetch('https://■■■■■■■■■■.workers.dev/increment', {
                    method: 'POST'
                });
                
                debugLog('Response status: ' + response.status);
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const data = await response.json();
                debugLog('Received count: ' + data.count);

                const countDisplay = data.count.toString().padStart(6, '0');
                document.getElementById('visitorCount').textContent = countDisplay;
                debugLog('Counter updated successfully');

            } catch (error) {
                debugError('Error updating visitor count', error);
                document.getElementById('visitorCount').textContent = 'Error';
                
                if (error instanceof TypeError) {
                    debugError('Network error - Could not connect to Worker. Please check:' +
                        '\n1. Worker URL is correct' +
                        '\n2. Worker is deployed and running' +
                        '\n3. CORS is properly configured');
                }
            }
        }

        // 初期化
        document.addEventListener('DOMContentLoaded', () => {
            debugLog('Page loaded');
            debugLog('Starting counter update...');
            updateVisitorCount();
        });
    </script>
</body>
```

### Cloudflare Workers部分

データベースであるKVはVISITORSという名前空間で作成し、それをバインドしています。今回はすぐに消すネタなので、`'Access-Control-Allow-Origin': '*',` のガバガバセキュリティでMarkdown AIからの接続を許可しています（イタズラしないでね！）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199260/78787cbd-5b9e-3e45-dd94-da9b9d787a71.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199260/ae60e230-3425-1ab4-a4ef-8032c9afc85e.png)

```javascript
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  // CORSヘッダーを設定
  const headers = {
    'Access-Control-Allow-Origin': '*',
    'Content-Type': 'application/json',
    'Access-Control-Allow-Methods': 'POST',
  }

  // OPTIONSリクエストへの対応
  if (request.method === 'OPTIONS') {
    return new Response(null, { headers })
  }

  // URLからパスを取得
  const url = new URL(request.url)
  const path = url.pathname

  // /increment パスのみを処理
  if (path === '/increment' && request.method === 'POST') {
    try {
      // KVから現在のカウント値を取得
      let count = await VISITORS.get('count')
      count = count ? parseInt(count) + 1 : 1

      // 新しい値をKVに保存
      await VISITORS.put('count', count.toString())

      // JSONレスポンスを返す
      return new Response(
        JSON.stringify({ count }),
        { headers }
      )
    } catch (err) {
      return new Response(
        JSON.stringify({ error: 'Failed to update counter' }), 
        { 
          status: 500,
          headers 
        }
      )
    }
  }

  // 不正なパスまたはメソッドの場合
  return new Response('Not found', { 
    status: 404,
    headers 
  })
}
```

というわけで、Markdown AIで遊んでみたという記事でした。みなさんのクオリティオブライフ（爆）が増進しますように！