📚

GPT Engineerで作ってローカル編集しNetlifyで公開できた

2024/10/27に公開

単純に自分のポートフォリオとしてウェブサイトを作るとしても勿体ないので、楽天市場の商品検索APIを使って楽天市場のアフィリエイト広告を利用したウェブサイトを作りました。

本当はV0でモバイルアプリを作って、FlutterFlowで完成させようとしました。
でもV0を使ったモバイルアプリの方法が実現することが厳しい事情があり、GPTEngineerを使って続きは、GitHubでプライベートでリポジトリを公開して、自分のパソコンでクローンを作る流れで、ローカルで編集を続けました。

そしてリポジトリを使って、Netlifyでウェブサイトを独自ドメインで公開できました。

今回作ったウェブサイトは、こちらです。
使っていない私が保有しているドメインで、ウェブサイトを作りました。
https://osake.love/

今回のウェブサイト作りで、経験できたスキル

GPT Engineerを使って、ウェブサイト制作。
V0を使って、モバイルアプリ制作。
FlutterFlowの基本操作。
GitHubの操作。
Netlifyで独自ドメインを使い、GitHubを通じてウェブサイトを公開する手順そして、リポジトリを更新した際の自動更新。
楽天商品検索APIの利用方法。

リポジトリを使ってNetlifyでウェブサイトを作ると更新が楽

今回体験して知ったのですが、Netlifyでリポジトリを使ってウェブサイトを公開すると、リポジトリを更新したときに、自動でウェブサイトのビルドができて更新できることを知りました。
ただリポジトリに閉じ括弧忘れやvite.config.tsが認識されないことで、ビルドが失敗する経験もしました。

Netlifyでリポジトリを使ったビルドが失敗するときは、画像のように失敗した理由を確認できる赤いボタンがありました。

これを使って表示された英語のメッセージを、機械翻訳して理解しました。

楽天APIの使い方に苦労

生成AIを使っているのに、APIのJSONからのデータの取り出しに時間がかかることは、不思議に思われる人も多いと思います。
でも今回、楽天APIから商品情報を取り出すために、苦労しました。

Felo.AIを使って、楽天商品検索APIの使い方を調べました。
https://felo.ai/search/Q9ZWp5hdqVLuuW4D5bJYQp

APIの部分は、このようなコードになりました。

import { z } from "zod";
import debounce from 'lodash.debounce';

const RAKUTEN_BASE_URL = "https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601";
const RATE_LIMIT_MS = 1000;

// 環境変数
const APPLICATION_ID = import.meta.env.VITE_RAKUTEN_APP_ID;
const AFFILIATE_ID = import.meta.env.VITE_RAKUTEN_AFFILIATE_ID;





// カテゴリーコード
export const GenreId = {
  BEER_WINE: "510915",
  SAKE_SHOCHU: "510901",
} as const;

// ソートオプション
export type SortOrder = "+itemPrice" | "-itemPrice";

// 商品情報の型定義
export interface RakutenItem {
  Item: {
    affiliateRate: number;
    affiliateUrl: string;
    itemName: string;
    itemPrice: number;
    itemCode: string;
    itemUrl: string;
    shopName: string;
    mediumImageUrls: { imageUrl: string }[];
    smallImageUrls: { imageUrl: string }[];
  }
}


// APIリクエストパラメータの型
export type FetchItemsParams = {
  genreId: string;
  keyword?: string;
  page?: number;
  sort?: SortOrder;
};

// APIレスポンスの型
export interface ApiResponse {
  Items: RakutenItem[];
  count: number;
  page: number;
  pageCount: number;
  hits: number;
  carrier: number;
  GenreInformation: any[];
  TagInformation: any[];
}




// api.ts
export const fetchItems = async ({ genreId, keyword = "", page = 1, sort }: FetchItemsParams) => {
  if (!APPLICATION_ID) {
    throw new Error('アプリケーションIDが設定されていません');
  }
  
  try {
    // URLパラメータを構築
    const params = new URLSearchParams({
      applicationId: APPLICATION_ID,
      format: 'json',
      formatVersion: '2',
      hits: '20',
      page: String(page),
      imageFlag: '1',
      availability: '1'
    });

    // アフィリエイトIDがある場合のみ追加
    if (AFFILIATE_ID) {
      params.append('affiliateId', AFFILIATE_ID);
    }

    // キーワード検索がある場合のみ追加(空文字の場合は追加しない)
    if (keyword.trim()) {
      params.append('keyword', keyword.trim());
    } else if (genreId) {
      // キーワードがない場合はジャンルIDで検索
      params.append('genreId', genreId);
    }

    // ソートがある場合のみ追加
    if (sort) {
      params.append('sort', sort);
    }

    // URLを構築してリクエストを送信
    const url = `${RAKUTEN_BASE_URL}?${params.toString()}`;
    //console.log('Request URL:', url);

    const response = await fetch(url);
    if (!response.ok) {
      const errorText = await response.text();
      //console.error('API Error Response:', errorText);
      throw new Error(`API request failed: ${response.status}`);
    }

    const data = await response.json();
    //console.log('Raw API Response:', JSON.stringify(data, null, 2));

    if (!data.Items || !Array.isArray(data.Items)) {
      //console.error('Invalid API response structure:', data);
      throw new Error('Invalid API response structure');
    }

    return {
      Items: data.Items,
      count: data.count || 0,
      page: data.page || 1,
      pageCount: data.pageCount || 1,
      hits: data.hits || 0
    };
  } catch (error) {
    console.error('API Error:', error);
    throw error;
  }
};


// 検索用のデバウンス関数は別途用意
export const debouncedFetchItems = debounce(fetchItems, 300);

表示する部分

import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardFooter } from "./ui/card";
import { RakutenItem, fetchItems, SortOrder } from "@/lib/api";

interface ProductGridProps {
  genreId: string;
  keyword: string;
  page: number;
  sort?: SortOrder;
  onUpdateTotalPages: (total: number) => void;
}

export const ProductGrid: React.FC<ProductGridProps> = ({ 
  genreId, 
  keyword, 
  page, 
  sort, 
  onUpdateTotalPages 
}) => {
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [items, setItems] = useState<RakutenItem[]>([]);


  const fetchData = useCallback(async () => {
    try {
      const response = await fetchItems({ genreId, keyword, page, sort });
      //console.log("API Response:", response); // デバッグ用
      
      if (response?.Items && Array.isArray(response.Items)) {
        setItems(response.Items);

        if (response.count != null && response.hits != null) {
          const total = Math.ceil(response.count / response.hits);
          onUpdateTotalPages(Math.max(1, total));
        } else {
          onUpdateTotalPages(1);
        }
        setError(null);
      } else {
        setItems([]);
        onUpdateTotalPages(1);
        setError('APIからの応答が不正です');
      }
    } catch (error) {
      //console.error('Fetch error:', error);
      setError(`データの取得に失敗しました: ${error instanceof Error ? error.message : '不明なエラー'}`);
      onUpdateTotalPages(1);
    } finally {
      setLoading(false);
    }
  }, [genreId, keyword, page, sort, onUpdateTotalPages]);

  useEffect(() => {
    setLoading(true);
    setError(null);

    if (genreId) {
      fetchData();
    } else {
      setLoading(false);
      setError('カテゴリーを選択してください');
    }
  }, [genreId, keyword, page, sort, fetchData]);

  // 画像URLを処理する関数を修正
    const getImageUrl = (mediumImageUrls: string[]): string => {
      return mediumImageUrls.length > 0 ? mediumImageUrls[0] : 'defaultImage.png';
    };
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>{error}</div>;
  if (!items.length) return <div>商品が見つかりませんでした</div>;

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
      {items.map((item) => {
        // デバッグ用
        //console.log("Item being rendered:", item);
        
        return (
          <Card key={item.itemCode} className="flex flex-col">
            <CardContent className="p-4">
              <a
                href={item.affiliateUrl || item.itemUrl || '#'}
                target="_blank"
                rel="noopener noreferrer"
                className="block relative aspect-square overflow-hidden rounded-lg bg-gray-100"
              >
                <div className="w-full h-full relative">
                  <div className="absolute inset-0 bg-gray-100 animate-pulse" />
                  <img
                    src={getImageUrl(item.mediumImageUrls)}
                    alt={item.itemName}
                    className="w-full h-full object-contain absolute inset-0 transition-opacity duration-300"
                    loading="lazy"
                    onError={(e) => {
                      //console.error('Image load error:', e); // デバッグ用
                      const target = e.target as HTMLImageElement;
                      target.src = '/placeholder.png';
                    }}
                    onLoad={(e) => {
                      const target = e.target as HTMLImageElement;
                      target.style.opacity = '1';
                      const parent = target.parentElement;
                      if (parent) {
                        const loadingDiv = parent.querySelector('.animate-pulse');
                        if (loadingDiv) {
                          (loadingDiv as HTMLElement).style.display = 'none';
                        }
                      }
                    }}
                    style={{ opacity: 0 }}
                  />
                </div>
              </a>
              <h3 className="mt-4 text-sm font-medium line-clamp-2">
                {item.itemName}
              </h3>
              <p className="mt-2 text-sm text-gray-500">{item.shopName}</p>
            </CardContent>
            <CardFooter className="mt-auto p-4 pt-0">
              <div className="flex justify-between items-center w-full">
                <span className="text-lg font-bold">&yen;{item.itemPrice.toLocaleString()}</span>
                <a
                  href={item.affiliateUrl || item.itemUrl || '#'}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="text-sm text-blue-600 hover:underline"
                >
                  商品詳細
                </a>
              </div>
            </CardFooter>
          </Card>
        );
      })}
    </div>
  );
};

export default ProductGrid;

Perplexityを使ってコーディングしました

楽天商品検索APIの使い方を調べるために、Felo.AIとPerplexity、ChatGPT Plusやリートン、Claud3など活用しました。
その中で楽天商品検索APIなど楽天のAPIの利用制限の一秒に一回以内の制限を守るために、debouncedFetchItems「デバウンス関数」というものを知り、キーワード検索のときに「デバウンス関数」を使っています。
すべての動作にデバウンス関数を適用すると、操作に時差が生まれるのです。
私も、ワンテンポ遅れてボタンが動作したので戸惑いました。
それで検索のときのみデバウンス関数を使うように、コードを制作しました。
最終的に、その工夫は、残っていると思います。

Perplexityは、一度に複数のファイルをアップロードして質問できることや、ネット検索して調べてくれるので、役立ちました。

cursorも活用

今回GPT Engineerからcursorを使い、Netlifyでデブロイして、ウェブサイトを公開しました。
コーディングの際に英語で表示されるエラーメッセー、ターミナルに表示される英語のメッセージを理解するときに、cursorは、とても役立ちました。
私は、cursorがプロジェクト全体を見て複数のファイルを見てコーディング支援してくれるので、活用しています。

今回は基本的な動作のみ実装

今回は、楽天商品検索APIを使い、基本的な表示ができるように取り組みました。
ウェブサイトのデザインも基本的なものなので、これから工夫していきたいと思っています。

Discussion