🪶

ChatGPT + Google検索APIで個人ブログ検索サイトを作る

2024/05/28に公開

はじめに

ガジェットのレビューを調べるときに、上位には業者のサイトばかりが出てきますよね。私は業者の金儲けのためのテキトーなレビューではなく、本当にそのガジェットを使ったユーザーのレビューが読みたいのです。いちいちドメイン名や記事タイトルをみて個人かどうかを確認するのは面倒なので、ChatGPTに判別してもらうことにしました。

概要

  • Google検索API(Custom Search API)を使って検索結果を取得する
  • ChatGPTにデータを渡して個人ブログかどうかを判定してもらう
  • 検索結果をNext UIをのCardコンポーネントで表示する

環境の準備

Custom Search APIのAPIキー&検索エンジンIDの取得

こちらの記事を参考に取得しました。
途中で設定する検索の対象については、私は「ウェブ全体を検索」を設定しています。
https://zenn.dev/eito_blog/articles/653d4d8bf20320

ChatGPTのAPIキーの取得

私はすでに取得済みのため、本記事では省略します。
記事によっては画面が古い場合があるため、最新の記事を参考にすると良いでしょう。
https://zenn.dev/mongonta/articles/09a3b0db893e27

.envファイルにAPIを記載

直下に.envファイルを作ります

.env
OPENAI_API_KEY=sk-proj-abcdefgh...
GOOGLE_API_KEY=abcdefgh...
GOOGLE_CX=abcdefgh...

Next UIのインストール

Next UIとはNext.js向けのスタイリッシュなUIコンポーネントライブラリです。
初期設定をしてから使いたいファイルにimportするだけで利用できます。
引数で色やアニメーションなどの設定を簡単に指定できるため非常に便利です。
https://nextui.org/

似たようなライブラリとしてshadcn/uiが大変有名です。両方とも使ってみて、デザイン面のかっこよさではNext UI、インストール手順を含めてより手軽かつシンプルなものが良いのであればshadcn/uiの印象です。
https://ui.shadcn.com/

公式の手順に従って、作成したプロジェクトにNext UIをインストールしましょう。
なおNext.jsの新規プロジェクト作成時に一緒にインストールすることもできます。

私は以下の手順に従って全てのコンポーネントをまとめてインストールしました。
必要なコンポーネントだけをインストールすることもできますが、特段こだわりがなければまとめてインストールした方が便利です。

↓Manual Installation の Global Installationの部分
https://nextui.org/docs/guide/installation#global-installation

インストール後は初期設定として以下三つが必要です。

  • tailwind.config.tsの書き換え
  • app/providers.tsxの作成
  • app/layout.tsxの書き換え

↓ 初期設定の参考
https://haramizu.com/ja-JP/blog/2023/10/02/nextjs-nextui

src/app/providers.tsx
"use client";

import { NextUIProvider } from "@nextui-org/react";

export function Providers({ children }: { children: React.ReactNode }) {
  return <NextUIProvider>{children}</NextUIProvider>;
}
src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

import { Providers } from "./providers";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "個人ブログ検索サイト",
  description: "個人ブログでのレビューだけを検索できます。",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        {/* Providerコンポーネントで挟む */}
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

コード

src以下の構成は以下のようになっています。

src
├── app
│   ├── api
│   │   └── search
│   │       └── route.ts(←新規作成)
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx(←Next UI設定時に書き換え)
│   ├── page.tsx(←書き換え)
│   └── providers.tsx(←Next UI設定時に新規作成)
└── components
    ├── Results.tsx(←新規作成)
    └── SearchBar.tsx(←新規作成)
src/app/api/search/route.ts
src/app/api/search/route.ts
import { NextResponse } from "next/server";
import OpenAI from "openai";
import fs from "fs/promises";

const openai = new OpenAI();

interface GoogleItem {
  title: string;
  link: string;
  snippet: string;
  pagemap?: {
    cse_image?: { src: string }[];
    cse_thumbnail?: { src: string }[];
    metatags?: { [key: string]: string }[];
  };
  displayLink?: string;
}

interface FilteredResult {
  title: string;
  url: string;
  snippet: string;
  image: string | null;
  domain: string;
  siteTitle: string;
  datePublished: string | null;
  isBlog: boolean;
}

export async function GET(request: Request) {
  try {
    const { searchParams } = new URL(request.url);
    const query = searchParams.get("query");

    if (!query) {
      return NextResponse.json({ error: "Query is required" }, { status: 400 });
    }

    const apiKey = process.env.GOOGLE_API_KEY as string;
    const cx = process.env.GOOGLE_CX as string;
    const maxResults = 30;
    const resultsPerPage = 10; // 1回のリクエストで取得する結果数 最大値の10で固定

    let concatGoogleData: GoogleItem[] = [];

    // Google Custom Search APIを使用して検索結果を取得
    // 一回のリクエストで10件の結果を取得するため、maxResultsの数になるまで繰り返す
    for (let start = 1; start <= maxResults; start += resultsPerPage) {
      const googleResponse = await fetch(
        `https://www.googleapis.com/customsearch/v1?q=${encodeURIComponent(
          query
        )}&key=${apiKey}&cx=${cx}&start=${start}`
      );
      const googleData = await googleResponse.json();

      if (googleData.items) {
        concatGoogleData = concatGoogleData.concat(googleData.items);
      } else {
        break; // エラーハンドリング、もしitemsが存在しない場合は終了
      }

      // 100ms待機
      await new Promise((resolve) => setTimeout(resolve, 100));
    }

    // ChatGPTで個人ブログを判別
    const results = await Promise.all(
      concatGoogleData.map(async (item): Promise<FilteredResult> => {
        const response = await openai.chat.completions.create({
          model: "gpt-4o",
          messages: [
            {
              role: "system",
              content: `次のウェブサイトが個人ブログかどうか判別してください:\n\n${item.title}\n\n${item.link}\n\n${item.snippet}\n\n個人ブログの場合、「はい」、そうでない場合「いいえ」と回答してください。`,
            },
          ],
          max_tokens: 10,
        });

        // サムネイル画像のリンクを取得
        const image =
          item.pagemap?.cse_image?.[0]?.src ||
          item.pagemap?.cse_thumbnail?.[0]?.src ||
          null;

        // ドメイン名、サイトのタイトルを取得
        const domain = item.displayLink || new URL(item.link).hostname;
        const siteTitle =
          item.pagemap?.metatags?.[0]?.["og:site_name"] ||
          item.displayLink ||
          domain;

        // 日時情報の取得
        const datePublished =
          item.pagemap?.metatags?.[0]?.["article:published_time"] || null;

        return {
          title: item.title,
          url: item.link,
          snippet: item.snippet,
          image: image,
          domain: domain,
          siteTitle: siteTitle,
          datePublished: datePublished,
          isBlog:
            response.choices[0]?.message?.content?.toLowerCase() === "はい",
        };
      })
    );

    // 個人ブログかどうかでフィルタリングされた結果を取得
    const filteredResults = results.filter((result) => result.isBlog);

    // デバッグ用 filteredResultsを保存する
    await fs.writeFile(
      "filteredResults.json",
      JSON.stringify(filteredResults, null, 2)
    );

    // json形式で結果を返す
    return NextResponse.json(filteredResults);
  } catch (error) {
    console.error("Error handling GET request", error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 }
    );
  }
}

src/app/layout.tsx
src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

import { Providers } from "./providers";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "個人ブログ検索サイト",
  description: "個人ブログでのレビューだけを検索できます。",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

src/app/page.tsx
src/app/page.tsx
"use client";

import { useState } from "react";

import SearchBar from "@/components/SearchBar";
import Results from "@/components/Results";

export default function Home() {
  const [results, setResults] = useState([]);
  const [isLoading, setisLoading] = useState(false);

  const handleSearch = async (query: string) => {
    setisLoading(true);
    const response = await fetch(
      `/api/search?query=${encodeURIComponent(query)}`
    );
    const data = await response.json();
    setResults(data);
    setisLoading(false);
    console.log(data);
  };

  return (
    <div className="min-h-screen flex flex-col">
      <div className="container mx-auto px-4 py-8 flex-grow">
        <h1 className="text-2xl font-bold mb-4">個人ブログ検索サイト</h1>
        <SearchBar onSearch={handleSearch} />
        <Results results={results} isLoading={isLoading} />
      </div>
      <p className="flex justify-center pb-8 font-extrabold text-gray-400">
        by k-zumi
      </p>
    </div>
  );
}

src/components/Results.tsx
src/components/Results.tsx
"use client";

import React, { FC, useEffect, useState } from "react";
import {
  Card,
  CardHeader,
  CardBody,
  Image,
  Link,
  Spinner,
} from "@nextui-org/react";

// スニペットをデバイスに応じて整形する関数
const truncateSnippet = (snippet: string, maxLength: number): string => {
  return snippet.length <= maxLength
    ? snippet
    : `${snippet.substring(0, maxLength)}...`;
};

// 検索結果がない場合のカード
const NoResultsCard: FC = () => (
  <Card className="my-4">
    <CardBody className="font-bold items-center py-20">
      <p>No results found.</p>
    </CardBody>
  </Card>
);

// ResultCardPropsの型定義
interface ResultCardProps {
  result: {
    url: string;
    siteTitle: string;
    domain: string;
    title: string;
    snippet: string;
    image: string;
  };
  maxLength: number;
}

// 検索結果のカード
const ResultCard: FC<ResultCardProps> = ({ result, maxLength }) => (
  <Link href={result.url} isExternal>
    <Card className="py-4" isPressable>
      <CardHeader className="pb-0 pt-2 px-4 flex-col items-start">
        <p className="text-tiny uppercase font-bold">{result.siteTitle}</p>
        <small className="text-default-500">{result.domain}</small>
        <h4 className="font-bold text-large text-left">{result.title}</h4>
      </CardHeader>
      <CardBody className="overflow-visible py-2">
        <div className="grid grid-cols-10 gap-4">
          <div className="col-span-7">
            <p className="text-medium">
              {truncateSnippet(result.snippet, maxLength)}
            </p>
          </div>
          <div className="col-span-3">
            <Image
              alt="Card background"
              className="object-cover rounded-xl size-25"
              src={result.image}
            />
          </div>
        </div>
      </CardBody>
    </Card>
  </Link>
);

// ResultsPropsの型定義
interface ResultsProps {
  results: ResultCardProps["result"][];
  isLoading: boolean;
}

// 検索結果を表示するコンポーネント
const Results: FC<ResultsProps> = ({ results, isLoading }) => {
  // maxLengthの初期値を設定
  const [maxLength, setMaxLength] = useState(100);

  // デバイスの幅に応じてmaxLengthを更新する関数
  const updateMaxLength = () => {
    setMaxLength(window.innerWidth <= 768 ? 50 : 100);
  };

  // 初回実行時にmaxLengthを初期化し、リサイズ時に更新する
  useEffect(() => {
    updateMaxLength();
    window.addEventListener("resize", updateMaxLength);
    return () => window.removeEventListener("resize", updateMaxLength);
  }, []);

  // maxLengthが変わるたびにログを表示する
  useEffect(() => {
    console.log("maxLength updated:", maxLength);
  }, [maxLength]);

  // 入れ子の三項演算子で、ロード中でなく検索結果もない場合はNoResultsCard、ロード中はSpinner、検索結果がある場合は検索結果を表示
  return (
    <div>
      <div className="mt-4">
        {!isLoading && results.length === 0 ? (
          <NoResultsCard />
        ) : isLoading ? (
          <div className="flex justify-center pt-10">
            <Spinner size="lg" />
          </div>
        ) : (
          results.map((result, index) => (
            <div key={index} className="py-2">
              <ResultCard result={result} maxLength={maxLength} />
            </div>
          ))
        )}
      </div>
    </div>
  );
};

export default Results;

src/components/SearchBar.tsx
src/components/SearchBar.tsx
"use client";

import React, { useState, FC, FormEvent } from "react";

interface SearchBarProps {
  onSearch: (query: string) => void;
}

const SearchBar: FC<SearchBarProps> = ({ onSearch }) => {
  const [query, setQuery] = useState("");

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit} className="flex">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="border rounded px-4 py-2 w-full"
        placeholder="検索ワードを入力 / 例:ノースフェイス ビジネスバッグ レビュー"
      />
      <button
        type="submit"
        className="bg-blue-500 text-white rounded px-4 py-2 ml-2 font-bold w-20">
        検索
      </button>
    </form>
  );
};

export default SearchBar;

解説

一回あたり10件しか検索ができないので、start=${start}の数を変化させながら目的の個数になるまで、ループを繰り返します。得られた結果は随時concatGoogleData配列に格納していきます。

for (初期化式; 条件式; 更新式) {
    // ループの本体
}
src/app/api/search/route.ts
    // Google Custom Search APIを使用して検索結果を取得
    // 一回のリクエストで10件の結果を取得するため、maxResultsの数になるまで繰り返す
    for (let start = 1; start <= maxResults; start += resultsPerPage) {
      const googleResponse = await fetch(
        `https://www.googleapis.com/customsearch/v1?q=${encodeURIComponent(
          query
        )}&key=${apiKey}&cx=${cx}&start=${start}`
      );
      const googleData = await googleResponse.json();

      if (googleData.items) {
        concatGoogleData = concatGoogleData.concat(googleData.items);
      } else {
        break; // エラーハンドリング、もしitemsが存在しない場合は終了
      }

      // 100ms待機
      await new Promise((resolve) => setTimeout(resolve, 100));
    }

なお、一回のリクエストごとにこのようなデータが返ってきます。itemsの中に10件の個別の記事の情報が格納されます。
concatGoogleDataの中には、itemsの情報のみを取り出して格納しています。

検索結果
{
  kind: 'customsearch#search',
  url: {
    type: 'application/json',
    template: 'https://www.googleapis.com/customsearch/v1?q={searchTerms}&num={count?}&start={startIndex?}&lr={language?}&safe={safe?}&cx={cx?}&sort={sort?}&filter={filter?}&gl={gl?}&cr={cr?}&googlehost={googleHost?}&c2coff={disableCnTwTranslation?}&hq={hq?}&hl={hl?}&siteSearch={siteSearch?}&siteSearchFilter={siteSearchFilter?}&exactTerms={exactTerms?}&excludeTerms={excludeTerms?}&linkSite={linkSite?}&orTerms={orTerms?}&dateRestrict={dateRestrict?}&lowRange={lowRange?}&highRange={highRange?}&searchType={searchType}&fileType={fileType?}&rights={rights?}&imgSize={imgSize?}&imgType={imgType?}&imgColorType={imgColorType?}&imgDominantColor={imgDominantColor?}&alt=json'
  },
  queries: { request: [ [Object] ], nextPage: [ [Object] ] },
  context: { title: 'blog-search' },
  searchInformation: {
    searchTime: 0.486224,
    formattedSearchTime: '0.49',
    totalResults: '7220000',
    formattedTotalResults: '7,220,000'
  },
  items: [
    {
      kind: 'customsearch#result',
      title: 'レビュー好評 THE NORTH FACE(ザ・ノースフェイス) NM82421 ...',
      htmlTitle: '<b>レビュー</b>好評 THE <b>NORTH FACE</b>(ザ・<b>ノースフェイス</b>) NM82421 ...',
      link: 'https://billionaireresort.com/?dyupo/j2373689.html',
      displayLink: 'billionaireresort.com',
      snippet: 'レビュー好評 THE NORTH FACE(ザ・ノースフェイス) NM82421 シャトル3ウェイデイパック リミテッド ビジネスバッグ ショルダー リュック | billionaireresort.com.',
      htmlSnippet: '<b>レビュー</b>好評 THE <b>NORTH FACE</b>(ザ・<b>ノースフェイス</b>) NM82421 シャトル3ウェイデイパック リミテッド <b>ビジネスバッグ</b> ショルダー リュック | billionaireresort.com.',
      formattedUrl: 'https://billionaireresort.com/?dyupo/j2373689.html',
      htmlFormattedUrl: 'https://billionaireresort.com/?dyupo/j2373689.html',
      pagemap: [Object]
    },
    {
      kind: 'customsearch#result',
      title: '評判のいいノースフェイスのビジネスリュック5選を実機ブログ ...',
      htmlTitle: '評判のいい<b>ノースフェイス</b>の<b>ビジネス</b>リュック5選を実機ブログ ...',
      link: 'https://biz-bag.com/tnf-buisiness-pac/',
      displayLink: 'biz-bag.com',
      snippet: 'Mar 12, 2024 ... アウトドアメーカーらしい機能性の高さ ... デザインだけではなく、使いやすさや背負やすさも兼ね揃えているのがノースフェイスのビジネスリュックの良さ ...',
      htmlSnippet: 'Mar 12, 2024 <b>...</b> アウトドアメーカーらしい機能性の高さ ... デザインだけではなく、使いやすさや背負やすさも兼ね揃えているのが<b>ノースフェイス</b>の<b>ビジネス</b>リュックの良さ&nbsp;...',
      formattedUrl: 'https://biz-bag.com/tnf-buisiness-pac/',
      htmlFormattedUrl: 'https://biz-bag.com/tnf-buisiness-pac/',
      pagemap: [Object]
    },

〜〜〜(省略)〜〜〜

得られたデータを元にChatGPTに個人ブログかどうかを判定しましょう。
記事タイトル(Title)、リンク先(link)、冒頭の文章(Snippet)を入れるだけで、概ね満足する精度で判定してくれました。

複数のワードで試してみたところ、体感として、誤った結果になった場合は、個人ブログでないサイトが含まれてしまう偽陽性パターンはなく、個人ブログだが弾かれる偽陰性のパターンでした。

出力は「true」「false」で出させると、stringとbooleanが混在してしまうため、「はい」「いいえ」で出力させて後でbooleanに変換しています。

src/app/api/search/route.ts

    // ChatGPTで個人ブログを判別
    const results = await Promise.all(
      concatGoogleData.map(async (item): Promise<FilteredResult> => {
        const response = await openai.chat.completions.create({
          model: "gpt-4o",
          messages: [
            {
              role: "system",
              content: `次のウェブサイトが個人ブログかどうか判別してください:\n\n${item.title}\n\n${item.link}\n\n${item.snippet}\n\n個人ブログの場合、「はい」、そうでない場合「いいえ」と回答してください。`,
            },
          ],
          max_tokens: 10,
        });

〜〜〜(中略)〜〜〜

        return {
          title: item.title,
          url: item.link,
          snippet: item.snippet,
          image: image,
          domain: domain,
          siteTitle: siteTitle,
          datePublished: datePublished,
          isBlog:
            response.choices[0]?.message?.content?.toLowerCase() === "はい",
        };
      })
    );

出力は三項演算子を使って、場合分けして表示します。

{条件1 ? (真の値1) : 条件2 ? (真の値2) : (偽の値2)}
src/components/Results.tsx
// 検索結果を表示するコンポーネント
const Results: FC<ResultsProps> = ({ results, isLoading }) => {

〜〜〜(中略)〜〜〜

  // 入れ子の三項演算子で、ロード中でなく検索結果もない場合はNoResultsCard、ロード中はSpinner、検索結果がある場合は検索結果を表示
  return (
    <div>
      <div className="mt-4">
        {!isLoading && results.length === 0 ? (
          <NoResultsCard />
        ) : isLoading ? (
          <div className="flex justify-center pt-10">
            <Spinner size="lg" />
          </div>
        ) : (
          results.map((result, index) => (
            <div key={index} className="py-2">
              <ResultCard result={result} maxLength={maxLength} />
            </div>
          ))
        )}
      </div>
    </div>
  );

isloadingの値はResults.tsxの親であるpage.tsxからPropsとして渡してあげましょう。

検索ボタンが押されるとhandleSearchが実行され、isLoadingがtrueになります。
検索結果をresultsとしてセットし終わると、再度isLoadingをfalseに戻します。

これにより、ロード中のみスピナーを表示できます。

src/app/page.tsx
export default function Home() {
  const [results, setResults] = useState([]);
  const [isLoading, setisLoading] = useState(false);

  const handleSearch = async (query: string) => {
    setisLoading(true);
    const response = await fetch(
      `/api/search?query=${encodeURIComponent(query)}`
    );
    const data = await response.json();
    setResults(data);
    setisLoading(false);
    console.log(data);
  };

  return (
    <div className="min-h-screen flex flex-col">
      <div className="container mx-auto px-4 py-8 flex-grow">
        <h1 className="text-2xl font-bold mb-4">個人ブログ検索サイト</h1>
        <SearchBar onSearch={handleSearch} />
        <Results results={results} isLoading={isLoading} />
      </div>
      <p className="flex justify-center pb-8 font-extrabold text-gray-400">
        by k-zumi
      </p>
    </div>
  );
}

今後追加したい機能

  • 「さらに検索する」ボタン
    • 表示される個数が少ない場合に追加する
  • 検索結果の履歴の保存機能

Discussion