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

静的サイトを自動生成するAIエージェントをvibeで作ってみる

Last updated at Posted at 2025-12-11

こんにちは。Voicyでフルスタックエンジニアとして働いております、たかまてぃー(@kyappamu)です。
Voicy Advent Calendar 2025, 11日目担当のまあやさんからバトンを受け取り、12日目を担当させていただきます。精一杯勤めを果たしたいと思います。

まあやさんの記事は以下になります!ぜひ読んでみてください!

はじめに

直近参加したAIエージェント構築ワークショップをきっかけに、自分なりのAIエージェントを開発することに興味を持ちました。
今回はその興味の延長で、静的サイトを自動で作ってくれるAIエージェントを作ってみたので開発の流れや出来上がったものについて紹介したいと思います。

出来上がったものの質は置いておいて(※悪いです :sweat_drops: )、当記事ではAIエージェントを開発する流れをざっくりとお伝えできればと思います。

ちなみにソースコードは以下にプッシュしていますので、よければ覗いてみてください。

対象読者

  • AIを使った業務効率化ネタを探している方
  • ざっくりとAIエージェント開発のイメージを掴みたい方

システム構成

実装はこちら
main.py
import os
import sys
import shutil
import re
import time
import argparse
import base64
import json
from datetime import datetime
from dotenv import load_dotenv
from openai import OpenAI
import PIL.Image

# Configuration
GENERATED_ROOT = "generated"

def encode_image(image_path):
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode('utf-8')

def generate_completion(client, messages, response_format=None):
    try:
        kwargs = {
            "model": "gpt-4o",
            "messages": messages,
            "max_tokens": 4096,
        }
        if response_format:
            kwargs["response_format"] = response_format
            
        response = client.chat.completions.create(**kwargs)
        return response.choices[0].message.content
    except Exception as e:
        print(f"Error generating content: {e}")
        return None

def determine_project_path():
    os.makedirs(GENERATED_ROOT, exist_ok=True)
    existing_dirs = [d for d in os.listdir(GENERATED_ROOT) if os.path.isdir(os.path.join(GENERATED_ROOT, d))]
    output_dirs = [d for d in existing_dirs if d.startswith("output") and d[6:].isdigit()]
    
    next_num = 1
    if output_dirs:
        nums = [int(d[6:]) for d in output_dirs]
        next_num = max(nums) + 1
    
    project_name = f"output{next_num}"
    return os.path.join(GENERATED_ROOT, project_name)

def plan_site_structure(client, user_input, images_to_process):
    print("\n--- Phase 1: Planning Site Structure & Copy ---")
    
    system_instruction = (
        "You are a Creative Director and UX Designer. "
        "Your goal is to plan a multi-page static website based on the user's request and visual assets. "
        "1. ANALYZE COMPLEXITY: Determine the appropriate scale based on user input. "
        "   - If the request is simple or vauge, default to a compact 1-3 page structure (e.g., Home + Contact) to ensure fast generation. "
        "   - Only create a complex 4-5 page structure if explicitly requested or clearly necessary. "
        "2. IMAGE USAGE (CRITICAL): You have a set of assets. You MUST plan enough content to display ALL of them. "
        "   - If you have 12 images of food, create a Menu page with 12 items, or a Gallery page. "
        "   - Do NOT pick just 3 images and ignore the rest. The user wants to see ALL assets used. "
        "3. Define a Sitemap. STRICTLY FOLLOW the user's request for page count and titles if specified. "
        "4. Write content that is effective but concise (avoid unnecessary fluff to save generation time). "
        "5. Output strictly valid JSON."
    )
    
    messages = [{"role": "system", "content": system_instruction}]
    
    user_content_parts = []
    
    # Create a list of available filenames to force usage
    available_files = [f"assets/{os.path.basename(p)}" for p in images_to_process]
    available_files_str = "\n".join([f"- {f}" for f in available_files])
    
    user_content_parts.append({"type": "text", "text": (
        f"User Request: {user_input}\n\n"
        "AVAILABLE ASSETS (TOTAL: {len(images_to_process)} files):\n"
        f"{available_files_str}\n\n"
        "INSTRUCTION: Assign specific 'assets/filename.ext' from the list above to the 'content_brief' of relevant pages. "
        "You MUST ensure EVERY file in the list above is assigned to at least one page. "
        "If there are too many images for the requested pages, add a Gallery section or extend the Menu page significantly. "
        "Do NOT ignore any images."
    )})
    
    if images_to_process:
        user_content_parts.append({"type": "text", "text": "Visual Context (Base the style and tone on these):"})
        for img_p in images_to_process:
            try:
                base64_image = encode_image(img_p)
                # Interleave filename to help AI map 'assets/foo.png' to the visual
                fname = os.path.basename(img_p)
                user_content_parts.append({
                    "type": "text", 
                    "text": f"Image Filename: assets/{fname}"
                })
                user_content_parts.append({
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
                })
            except:
                pass
    
    user_content_parts.append({
        "type": "text", 
        "text": (
            "Output JSON format:\n"
            "{\n"
            "  \"global_style_guide\": \"Brief description of colors, fonts, and vibe.\",\n"
            "  \"site_name\": \"The Name of the Website (consistent across all pages)\",\n"
            "  \"pages\": [\n"
            "    {\n"
            "      \"filename\": \"index.html\",\n"
            "      \"title\": \"Page Title\",\n"
            "      \"navigation_label\": \"Home\",\n"
            "      \"content_brief\": \"Detailed content outline including headlines, paragraphs using marketing copy, and instructions on image placement.\"\n"
            "    }\n"
            "  ]\n"
            "}"
        )
    })
    
    messages.append({"role": "user", "content": user_content_parts})
    
    json_response = generate_completion(client, messages, response_format={"type": "json_object"})
    
    if json_response:
        try:
            plan = json.loads(json_response)
            print(f"Plan created: {len(plan.get('pages', []))} pages.")
            return plan
        except json.JSONDecodeError:
            print("Error decoding JSON plan.")
            return None
    return None

def generate_page_html(client, page, sitemap_links, global_style, images_to_process, site_name):
    filename = page['filename']
    print(f"\n--- Phase 2: Generating {filename} ---")
    
    system_instruction = (
        "You are a skilled web developer using TailwindCSS. "
        "Create a single HTML file independent of others (but linking to them). "
        "Do NOT use external CSS/JS files. Embed everything. "
        f"Global Style Guide: {global_style}"
    )
    
    messages = [{"role": "system", "content": system_instruction}]
    
    user_content_parts = []
    
    # Navigation Context
    nav_html_hint = "Make sure to include a responsive navigation bar with these links: " + ", ".join([f"<a href='{p['filename']}'>{p['label']}</a>" for p in sitemap_links])
    
    # Content Context
    content_instruction = (
        f"Generate code for: {filename}\n"
        f"Page Title: {page['title']}\n"
        f"Content Requirements: {page['content_brief']}\n"
        f"Navigation Requirements: {nav_html_hint}\n"
        "UI_UX CRITICAL RULES:\n"
        "1. NO DEAD BUTTONS. All buttons/CTAs must be <a> tags linking to one of the sitemap pages or an anchor ID (e.g., #contact). Do not create buttons that do nothing.\n"
        "2. CONSISTENT LAYOUT. Use a standard <header>, <main class='container mx-auto px-4'>, and <footer> structure to prevent layout shifts between pages.\n"
        "3. RESPONSIVE DESIGN. Use Tailwind's mobile-first approach (e.g. 'grid-cols-1 md:grid-cols-3'). Ensure the navbar is responsive (hamburger menu or stackable).\n"
        f"4. HEADER CONSISTENCY: You MUST use the exact Site Name: '{site_name}' in the header/logo area. Do NOT vary it.\n"
        "5. BREADCRUMBS: If this is NOT the home page, you MUST display a breadcrumb trail (e.g., 'Home > Page Title') in a container directly below the navbar/header. "
        "   Use a consistent style/location for breadcrumbs across all subpages.\n"
        "6. NO IMAGE LINKS. Do NOT wrap <img> tags in <a> tags. Images should be static displays only. Do NOT link images to detailed pages unless those pages exist in the sitemap.\n"
        "7. DO NOT invent paths like 'assets/hero.jpg' or 'images/logo.png' if they are not in the list above. They will be broken.\n"
        "Ensure the copy is exactly as requested (or better). Make it look premium."
    )
    user_content_parts.append({"type": "text", "text": content_instruction})
    
    # Image Context (pass refs)
    if images_to_process:
        available_images = [f"assets/{os.path.basename(p)}" for p in images_to_process]
        img_list_str = ", ".join(available_images)
        user_content_parts.append({
            "type": "text",
            "text": (
                f"IMPORTANT - VISUAL ASSETS POLICY:\n"
                f"1. You have access ONLY to these local files: {img_list_str}.\n"
                "2. You MUST use these exact paths (e.g. 'assets/foo.jpg') for your main images.\n"
                "3. If you need MORE images than provided (e.g. for a gallery or background) and you cannot reuse the provided ones, "
                "you MUST use external placeholders (e.g. 'https://placehold.co/600x400').\n"
                "4. DO NOT invent paths like 'assets/hero.jpg' or 'images/logo.png' if they are not in the list above. They will be broken."
            )
        })
        
        # Also pass vision context again so it knows what the assets look like to place them well
        user_content_parts.append({"type": "text", "text": "Reference Visuals (Use these to match the coded design):"})
        for img_p in images_to_process:
            try:
                base64_image = encode_image(img_p)
                # Interleave filename!
                fname = os.path.basename(img_p)
                user_content_parts.append({
                    "type": "text", 
                    "text": f"Image Filename: assets/{fname}"
                })
                user_content_parts.append({
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
                })
            except:
                pass
        
        # Add instruction to try and use available images
        user_content_parts.append({
            "type": "text",
            "text": (
                "Try to use the provided 'assets/...' images as much as possible to make the site rich. "
                "If the content brief mentions a specific asset file, YOU MUST USE IT in the HTML."
            )
        })
    else:
        # No images provided case
        user_content_parts.append({
            "type": "text", 
            "text": (
                "IMPORTANT - NO LOCAL IMAGES AVAILABLE:\n"
                "1. You do not have access to any local image files.\n"
                "2. You MUST use external placeholders for ALL visuals (e.g. 'https://placehold.co/600x400' or Unsplash Source if stable).\n"
                "3. Do NOT use 'assets/...' paths as they do not exist.\n"
                "4. Focus on typography, layout, and colors to make the site attractive without custom photography."
            )
        })

    user_content_parts.append({"type": "text", "text": "Output ONLY the HTML code block."})
    
    messages.append({"role": "user", "content": user_content_parts})
    
    content_response = generate_completion(client, messages)
    
    if content_response:
        match = re.search(r'```html\s*(.*?)\s*```', content_response, re.DOTALL)
        if match:
            return match.group(1)
        elif "<html" in content_response:
             return content_response
    return None

def main():
    parser = argparse.ArgumentParser(description="AI Static Site Generator (Multi-Page)")
    parser.add_argument("--image", help="Path to a local image file OR directory", default=None)
    args = parser.parse_args()

    load_dotenv()
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("Error: OPENAI_API_KEY not found.")
        return

    client = OpenAI(api_key=api_key)
    
    # Input Loop
    print("AI Static Site Generator (Multi-Page Edition)")
    print("どんなサイトを作りますか? (入力終了は空行でEnter)")
    print("> ", end="", flush=True)
    
    lines = []
    while True:
        try:
            line = input()
            if not line:
                break
            lines.append(line)
        except EOFError:
            break
            
    user_input = "\n".join(lines)
    if not user_input.strip():
        print("入力がありませんでした。")
        return

    # Image Processing
    image_path = args.image
    if not image_path:
        print("画像のパス (任意):")
        inp = input("> ").strip()
        if inp: image_path = inp.replace("'", "").replace('"', "").strip()

    images_to_process = []
    if image_path and os.path.exists(image_path):
        if os.path.isdir(image_path):
            valid_exts = ('.png', '.jpg', '.jpeg', '.webp')
            for f in sorted(os.listdir(image_path)):
                if f.lower().endswith(valid_exts):
                    images_to_process.append(os.path.join(image_path, f))
        else:
            images_to_process.append(image_path)
    
    print(f"Found {len(images_to_process)} images.")

    start_time = time.time()
    
    # 1. Plan
    site_plan = plan_site_structure(client, user_input, images_to_process)
    if not site_plan:
        print("Planning failed.")
        return

    project_dir = determine_project_path()
    os.makedirs(project_dir, exist_ok=True)
    print(f"Target: {project_dir}")
    
    # Copy Assets
    if images_to_process:
        assets_dir = os.path.join(project_dir, "assets")
        os.makedirs(assets_dir, exist_ok=True)
        for img in images_to_process:
            try:
                shutil.copy(img, os.path.join(assets_dir, os.path.basename(img)))
            except: pass

    # Prepare Link Info
    sitemap_links = []
    for p in site_plan.get('pages', []):
        sitemap_links.append({'filename': p['filename'], 'label': p.get('navigation_label', p.get('title'))})

    # 2. Generate Pages
    for page in site_plan.get('pages', []):
        html = generate_page_html(client, page, sitemap_links, site_plan.get('global_style_guide', ''), images_to_process, site_plan.get('site_name', 'My Website'))
        if html:
            out_path = os.path.join(project_dir, page['filename'])
            with open(out_path, "w", encoding="utf-8") as f:
                f.write(html)
            print(f"Saved: {out_path}")
        else:
            print(f"Failed to generate {page['filename']}")

    elapsed = time.time() - start_time
    print(f"Done in {elapsed:.1f}s")

if __name__ == "__main__":
    main()

処理の流れ

  • ユーザー入力

    • ユーザーは「どんなサイトを作りたいか」というテキスト指示(プロンプト)と、素材となる画像があればその画像フォルダを与えます。
  • AI処理

    • Pythonスクリプトがローカルの画像を読み込み、AIが視覚的に認識できる形式(Base64バイナリ)に変換します。
  • フェーズ1:サイト・プランニング

    • AIが「クリエイティブ・ディレクター」として振る舞います。
    • ユーザーの要望と画像素材の雰囲気を分析し、サイト全体のページ構成(Home, About, Menuなど)や、各ページに掲載する文章(コピーライティング)を決定します。
    • この結果はJSON形式の設計図 (Sitemap) として出力されます。
  • フェーズ2:ページ生成ループ

    • 設計図(JSON)に基づき、ページの数だけ処理を繰り返します。
    • プランニングされた内容と画像を元に、実際のHTMLコードを生成します。
  • OpenAI API連携

    • プランニングとページ生成のそれぞれのフェーズで、OpenAI API(GPT-4o Vision)と通信を行います。
    • 画像のバイナリデータを直接送信することで、画像の内容(色味、構図、雰囲気)を理解した上でのサイト構築を行わせます。
  • ファイル出力

    • 生成されたHTMLファイルと、使用した画像アセットを整理して、generated フォルダに出力します。これでWebサイトが完成します。

開発の流れ

今回、antigravityを使ってフルvibeで開発を行いました。

antigravityとは

Google社が開発したAIエージェント開発のためのエディターになります。
AIエージェントが開発の主体となり、人間は指示やレビューを行うというエージェント・ファーストアプローチで開発を進めることができます。

今回は以下の流れで開発を進めました。

  1. antigravityのエージェントウィンドウ上で要件を雑にインプット
  2. (AI)インプットをもとに、AIが計画〜設計〜開発のフローを自律的に行い開発を進める
  3. (我)出来上がったものの動作確認を行い、AIにフィードバック
  4. 以降、2, 3をひたすら繰り返す

実行デモ:

今回作成した静的サイト自動生成AIエージェントを実行してみます。

Step1: 画像を用意する

任意の画像を用意します。画像準備のやり方は何でもOKですが、今回は Gemini CLI に入れた nanobanana extension を使ってみます。

・キービジュアル

/generate "レトロな隠れ家カフェのキービジュアル" --count=1

・メニュー:クロワッサン3つ

/generate "白い皿に乗った美味しそうな クロワッサン、カフェのテーブル、自然光、俯瞰撮影、統一されたミニマリストスタイル" --count=3

・メニュー:チーズケーキ3つ

/generate "白い皿に乗った美味しそうな チーズケーキ、カフェのテーブル、自然光、俯瞰撮影、統一されたミニマリストスタイル" --count=3

・メニュー:エッグベネディクト3つ

/generate "白い皿に乗った美味しそうな エッグベネディクト、カフェのテーブル、自然光、俯瞰撮影、統一されたミニマリストスタイル" --count=3

こんな画像が出力されました :eyes:

キービジュアル クロワッサン チーズケーキ エッグベネディクト
generated_image.png __1.png __3.png __6.png

Step2: AIエージェントを起動

続いてAIエージェントを起動して静的サイトを作らせます。

python main.py --image testDir/nanobanana-output/
AI Static Site Generator (Multi-Page Edition)
どんなサイトを作りますか? (入力終了は空行でEnter)
> [レトロな隠れ家カフェ] のための、居心地の良さが伝わるウェブサイトを作成してください。与えられた画像を全て使ってサイトを作ってください。
ページ構成: Home, Menu
スタイル: ベージュや木目調をベースにした、落ち着いた温かみのあるデザイン。フォントは明朝体や手書き風を検討。
コンテンツ:
- Home: 店内の雰囲気がわかる大きな写真と、「都会の喧騒を忘れる場所」というキャッチコピー。
- Menu: おすすめのクロワッサン、チーズケーキ、エッグベネディクトを、写真付きで美味しそうに紹介。価格も記載。
トーン: 穏やか、丁寧、歓迎ムード。

Found 10 images.

--- Phase 1: Planning Site Structure & Copy ---
Plan created: 2 pages.
Target: generated/output1

--- Phase 2: Generating index.html ---
Saved: generated/output1/index.html

--- Phase 2: Generating menu.html ---
Saved: generated/output1/menu.html
Done in 171.7s

Step3: 動作確認

作成された generated/output1/index.html を開いてみます。
プロンプトで指示した、ホームとメニュー画面が作られています。

・PC表示

ホーム メニュー
1.html.png 2.html.png

・スマホ表示
※各メニューの説明文表示位置がおかしい。。

ホーム メニュー
3.png !4-min.png

苦労・断念した点

vibeコーディングでは以下のような点に苦労しました。一発で目的のAIエージェントを作れたわけではなく、何度もプロンプトを投げながら改修を続けていきました。

  • (断念)画像生成も自動化したかった(ユーザーは作りたいサイトの指示をするだけにしたかった)
    • Gemini CLI nanobanana extension をプログラムから呼び出し自動化を実現したかったですが、nanobanana extensionのオプションにユーザー承認なしで一気に画像を生成させるオプションがなく、自動化が難しかった
  • 以下は苦労した点です。いずれもシステムプロンプトでルールを明示することでAIの挙動を抑制する形で対応しました
    • AIが勝手に存在しない画像ファイルを指定してHTMLに記述してしまう
      • -> 実際にユーザーから与えられた画像のみを使うようにする
    • 簡単なサイトを作りたいだけでも、AIが気を利かせて5〜6ぺージあるような複雑なサイトを作る
      • -> ユーザーの入力内容から複雑さを判断させ、「指定がなければ1〜3ページのコンパクトな構成にする」というデフォルトルールを設定
    • 10枚以上の画像を渡しても、AIが勝手に数枚だけ選んで使い、残りが無視されてしまう
      • -> ユーザーインプットのプロンプトで全て画像を使うように指示しても一部しか使われないケースがあったため、システムプロンプトでもルールを設定
    • UI/UXの最低品質保証ーページ間でレイアウトが大きく異なっていたり、クリックしても何も起きないボタンが配置される
      • -> デッドボタン禁止(必ずどこかにリンクさせる)やコンテナ幅の統一といった具体的なコーディングルールをシステムプロンプトに埋め込み

終わりに

オリジナルのAIエージェントの開発って楽しいなと感じました。
今回使用したantigravityなどのAIエージェント開発用のエディターも使うことで、
突飛なアイディアも素早く試すことができます。
業務効率化、趣味、ライフハック・・、いろんなアイディアをAIを使って素早く試して実現していきたいですね。

Gemini_Generated_Image_fne5r3fne5r3fne5.png

Voicy Advent Calendar 2025はまだまだ続きます!
明日13日目の担当は、めいちゃんです。お楽しみに!

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