【Next.js】食べログのURLだけで情報をNotionデータベースに保存する

# はじめに
こんにちは！

今回は、食べログのURLを入力することで、飲食店の情報（店名、最寄り駅、ジャンル）を自動的にNotionのデータベースに保存するシンプルなWebアプリを作成しました。友人とおすすめの飲食店を共有したいときや、自分でお気に入りの飲食店を保存したいときはぜひ活用してください！すべてNext.jsで作成しています。

１．Notionデータベースを作成
２．NotionAPIの作成、データベースとの接続
３．インデックスページの作成
４．スクレイピングするAPIハンドラーの作成
５．Notionに登録するAPIハンドラーの作成

このよう流れで明記していきます！

# １．Notionデータベースを作成

まず、Notionのデータベースを作成しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3913424/a493e257-c899-b071-1cc5-bf448ab970e2.png)

新規ページを作成し、上のようなデータベースを用意してください。次に、必要な列見出しを作成していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3913424/8aa9098f-54b7-1222-7068-1866e834ed7c.png)

それぞれのプロパティは
店名：「名前」
場所：「選択」
ジャンル：「マルチセレクト」
URL：「URL」
メモ：「テキスト」
です！

ここで、複数のデータベースを作成したい方は作成しても構いません（同じページでも別のページでも可）今回は、データベースを２つとして進めていきます。


# ２．NotionAPIの作成

NotionAPIの作成にはいくつか手順があります。ここでは説明を簡単にします。より詳しく知りたい方は検索してみてください。以下に公式のドキュメントを貼り付けておきます。

https://developers.notion.com/docs/getting-started

まず、NotionのIntegrationを作成します。作成すると、トークンが発行されるので、それをメモしておきます。
次に、先ほど作成したデータベースにコネクトします。ここで、保存したいデータベースが複数ある場合は、それぞれコネクトしてください（別ページにある場合のみ）。
そして、データベースのリンクもコピーしてメモしておいてください。このリンクで利用するのはデータベースIDで、それは
```
https://www.notion.so/databaseid?v=something
```
この「databaseid」が使う値になりますのでメモしておいてください


# ３．インデックスページの作成

さて、Notionの下準備が終わったところで、いよいよコーディングに入っていきます。まずは、インデックスページを作成していきます。今回はApp Routerで作成しました。

```typescript:app/page.tsx
'use client'
import { useState, useEffect } from 'react'
import ToggleButton from '../components/ToggleButton'

interface ScrapedData {
  storeName: string
  location: string
  genres: string[]
  url: string
}

export default function Home() {
  const [url, setUrl] = useState<string>('')
  const [memo, setMemo] = useState<string>('')
  const [data, setData] = useState<ScrapedData | null>(null)
  const [error, setError] = useState<string>('')
  const [selectedDatabase, setSelectedDatabase] = useState<string>('DATABASE_NAME1')// 初期値をセット

  // ボタンの色を更新するuseEffect
  useEffect(() => {
    console.log('Selected Database Changed:', selectedDatabase)
  }, [selectedDatabase]) // selectedDatabaseが変更されるたびに色を更新

  // データベース選択
  const handleDatabaseToggle = (database: string) => {
    setSelectedDatabase(database)
  }

  const handleScrape = async () => {
    setError("")
    try {
      const res = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`)
      if (!res.ok) throw new Error("スクレイピング失敗")
      
      const result = await res.json()
      setData(result)
    } catch (error: unknown) {
      if (error instanceof Error) {
        setError(error.message)
      } else {
        setError("予期しないエラーが発生しました")
      }
    }
  };

  const handleAddToNotion = async () => {
    if (!data) return
    try {
      const res = await fetch("/api/notion", {
        method: 'POST',
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ ...data, memo: memo, database_id: selectedDatabase }), // selectedDatabaseを送信
      });
      if (!res.ok) throw new Error("Notionへの追加失敗")
      alert('Notionに登録されました！')
    } catch (error: unknown) {
      if (error instanceof Error) { 
        alert(error.message);
      } else { 
        alert("エラーが発生しました")
      }
    }
  };

  return (
    <div className="px-4 pt-8">
      <div>
        <h1 className="text-2xl font-bold text-center">食べログデータ取得</h1>
        <div className='pt-4 pb-10 text-center text-gray-500'>食べログのURLからスクレイピングして、Notionのデータベースに登録する</div>
      </div>

      {/* ToggleButtonを追加 */}
      <ToggleButton selectedDatabase={selectedDatabase} onToggle={handleDatabaseToggle} />

      <div>
        <input
          type="text"
          className="border p-2 w-full mt-4 text-black"
          placeholder="食べログのURLを入力"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
        />
        <button
          onClick={handleScrape}
          className="bg-gray-700 rounded text-white py-2 px-3 mt-4 hover:bg-gray-600"
        >
          データ取得
        </button>
      </div>

      {error && <p className="text-red-500 mt-2">{error}</p>}

      {data && (
        <div className="mt-4">
          <h2 className="text-lg font-bold">取得結果</h2>
          <p>店名: {data.storeName}</p>
          <p>場所: {data.location}</p>
          <p>ジャンル: {data.genres.join(", ")}</p>
          <p>URL: <a href={data.url}>{data.url}</a></p>
          <p>メモ: 
            <input 
              type='text'
              className='border rounded p-2 w-full text-black '
              placeholder='メモを入力'
              value={memo}
              onChange={(e) => setMemo(e.target.value)}
            />
          </p>

          <button
            onClick={handleAddToNotion}
            className="bg-gray-700 rounded text-white py-2 px-3 mt-4 hover:bg-gray-600"
          >
            Notion に登録
          </button>
        </div>
      )}
    </div>
  );
}

```

インデックスページはクライアントサイドで開発しているので、データベースが複数ある場合はトグルボタンを用意し、その状態をフックを使って制御しています。そして、NotionAPIにリクエストを送信し、データベースIDはサーバーサイドで取得しています。
データベースが一つの場合はこのトグルボタンを削除し、NotionAPIにデータを送信する際にもデータベースIDとなる仮の文字列を送信する必要はありません。

また、スクレイピングする際に、クエリパラメータとして食べログのURLを引き渡しています。
次に、トグルボタンとなるコンポーネントを作成していきます。

```typescript:app/components/ToggleButton.tsx
interface ToggleButtonProps {
  selectedDatabase: string;
  onToggle: (database: string) => void;
}

export default function ToggleButton({ selectedDatabase, onToggle }: ToggleButtonProps) {
  return (
    <div>
      <button
        onClick={() => onToggle('DATABASE_NAME1')}  
        className={`px-4 py-2 rounded ${
          selectedDatabase === 'DATABASE_NAME1'
            ? 'bg-gray-700 text-white'
            : 'bg-gray-300 text-gray-700'
        }`}
      >
        データベース１
      </button>
      <button
        onClick={() => onToggle('DATABASE_NAME2')}  
        className={`px-4 py-2 rounded ${
          selectedDatabase === 'DATABASE_NAME2'
            ? 'bg-gray-700 text-white'
            : 'bg-gray-300 text-gray-700'
        }`}
      >
        データベース２
      </button>
    </div>
  );
}

```

このコンポーネントにより、データベースを２つ管理できるようにしています。

# ４．スクレイピングするAPIハンドラーの作成

次に、スクレイピング用のAPIを作成していきます。

```typescript:api/scrape/route.ts
import * as cheerio from "cheerio"
import { NextRequest, NextResponse } from "next/server"

export async function GET(req: NextRequest) {
    //URLを取得
    const url = new URL(req.url).searchParams.get("url")
    if (!url || typeof url !== 'string'){
        return NextResponse.json({ error: "URLを指定してください" }, { status: 400 })
    }

    const response = await fetch(url)
    //htmlで取得
    const html = await response.text()
    const $ = cheerio.load(html)

    //店名を取得
    const storeName = $("h2.display-name").text().trim()

    //場所を取得
    const location = $("div.linktree__parent").text().split('[')[0].trim()

    //ジャンルを取得
    const genreTags = $("div.rdheader-subinfo dl").eq(1).find("span")
    const genres: string[] = genreTags.map((_, el) => $(el).text().trim()).get()

    //正常に取得できたときの処理
    return NextResponse.json({
        storeName,
        location,
        genres,
        url,
    })
}
```

typescriptでは、スクレイピングのためにcheerioというライブラリを使用しています。正常にデータが取得できた時はJSON形式で返しています。


# ５．Notionに登録するAPIハンドラーの作成

スクレイピングAPIが作成できたら、Notionデータベースに登録するAPIを作成します。まず、はじめにコピーしておいたデータベースIDとNotionのトークンを.env.localに貼り付けます。

```text:.env.local
NOTION_TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
DATABASE_NAME1 = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
DATABASE_NAME2 = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

NAME1およびNAME2はあくまで例ですので変更していただいて構いません。
<br />

```typescript:api/notion/route.ts
import { NextRequest, NextResponse } from "next/server"

//公式ドキュメントを参照
const NOTION_API_URL = "https://api.notion.com/v1/pages"
const NOTION_VERSION = "yyyy-mm-dd"
const NOTION_TOKEN = process.env.NOTION_TOKEN
const DATABASE_NAME1 = process.env.DATABASE_NAME1
const DATABASE_NAME2 = process.env.DATABASE_NAME2

export async function POST(req: NextRequest) {
  const { storeName, location, genres, url, memo, database_id } = await req.json()

  //ジャンルなしは許容
  if (!storeName || !location || !url || !database_id) {
    return NextResponse.json(
      { error: "読み込めないフィールドがあります" },
      { status: 400 }
    )
  }

  // 選択されたデータベースIDに応じて、対応するデータベースIDを設定
  let selectedDatabaseId = ''
  if (database_id === 'DATABASE_NAME1') {
    selectedDatabaseId = DATABASE_NAME1!
  } else if (database_id === 'DATABASE_NAME2') {
    selectedDatabaseId = DATABASE_NAME2!
  } else {
    return NextResponse.json(
      { error: "無効なデータベースIDです" },
      { status: 400 }
    )
  }

  // Notion APIにリクエストを送信
  const response = await fetch(NOTION_API_URL, {
    method: 'POST',
    headers: {
      "Authorization": `Bearer ${NOTION_TOKEN}`,
      "Content-Type": "application/json",
      "Notion-Version": NOTION_VERSION,
    },
    body: JSON.stringify({
      parent: { database_id: selectedDatabaseId }, // 選択されたデータベースIDを使用
      properties: {
        店名: { title: [{ text: { content: storeName } }] },
        場所: { select: { name: location } },
        ジャンル: { multi_select: genres.map((genre: string) => ({ name: genre })) },
        URL: { url },
        メモ: { rich_text: [{ text: { content: memo } }] },
      },
    }),
  })

  if (!response.ok) {
    throw new Error(`${response.status}`)
  }

  return NextResponse.json({ message: "データを登録しました" }, { status: 200 })
}

```

<br />
<br />
はじめのNOTION_VERSIONについては公式ドキュメントを参照して下さい。


https://developers.notion.com/reference/versioning

<br />
<br />
NotionAPIを叩くとき、スクレイピングしたデータに加えてdatabaseid（仮の文字列）とメモを送信しています。メモはスクレイピングした後、ユーザーが飲食店に対してメモを残せるようにしています。

bodyの部分はデータベースの列名と、それに対応するプロパティを指定しています。データベース登録が完了したら、データが登録できたことをユーザーに知らせます。

# ６．動作確認
<br/>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3913424/14be0026-0ce1-91ab-b52c-8d2070f0d02b.png)

まず、インデックスページは上のようになります。トグルをクリックすることで保存先のデータベースを選択できます。次に、食べログのURLを貼り付けてデータを取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3913424/35a68ff6-a0d1-27bc-b2b4-f6ecfe4d4321.png)

<br/>
「データ取得」ボタンをクリックすると、URLからスクレイピングしてデータを表示してくれます。最後に、「Notionに登録」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3913424/086d5340-246b-d616-107b-2671bb44406d.png)

<br/>
正常に登録できれば、上部にポップアップが表示されます。データベースを確認すると、スクレイピングしたデータが保存されていることが確認できます。

# おわりに
今回はNext.jsを用いてスクレイピングおよびNotionデータベースへの保存の２つを行ないました。内容自体はそこまで難しいものではないので、APIを使って制御してみたい人やスクレイピングをしてみたい人にはぜひおすすめです。しかし、雑感としてPythonの方がスクレイピングはやりやすさを感じました（ここでは書きませんでしたが、一応Pythonでもスクレイピングしていました）。