4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

カスタム可能な週間サマリー作りたくて、NotionAPIとNext.jsで実現

Last updated at Posted at 2023-07-02

概要

普段の業務とは関係なく、個人的にNotionを使って、やったことなどを日々記録しています。
その日々の記録を1週間単位でまとめてみよう!というのが始まりでした。画面から操作できる範囲でもかなりの機能があるNotionです。Notionを使いこなせば要らないのでは?と思いはしました。
以下のページにテンプレートがものすごい数ありました。

ですが、以下を理由にやってみることにしました。

  • Notion APIをを使った開発がこれまでなかった
  • 日数もそれほど要する感じはしなかった
  • コードベースなので、カスタマイズしやすいかも

Notion APIを用いたページ作成機能の開発

私のNotionの使い方

(概要に記載した内容と重なる部分はあります。)
特に業務とは関係ない部分で、2023年3月あたりから、Notionのカレンダー形式のデータベースに、学習した内容やちょっとしたつぶやきなどを日々残してきました。
4月に入って、1週間ごとにその週の記載内容を再確認して、、Weeklyふりかえりページとして新しくページを作成していました。しかし数回やってみて、一から手でまとめるのが面倒と思い始めました。日々の記録をいちいち開き、コピーしたり文章を作り直したり。。。
そこで、Notion APIを活用し、日々の記録から特定の情報を抽出して、Weeklyふりかえりページを自動で作成することにしました。

Notionに作成済のデータベース

以下、Notionで元々作成していたデータベースです。

1. 日々の記録を管理するカレンダー形式のデータベース

image.png

2. 週次の記録を管理するテーブル形式のデータベース


抽出内容
日々の記録について、この機会に記述ルールを設定しました。水平線で区切った上側が記述ルールで、下側が記述例です。
「気になった記事などがあったとき(重要度:低)」「その他の記載内容」以外はWeeklyふりかえりに出力することにしました。
image.png

Notion APIと実装方法について

notion APIを用いるとどんなことができるの?といった話は、以下のサイトのように、あちこちで紹介されています。

実装方法について、言語はJavascriptにしました。TypeScriptじゃないの?って思うところですが、
Notion APIで新しくページ作成するうえで必要な実装について、型定義をするのが大変という記事を見ました。Notion APIのメソッドの戻り値も同様です。まずは成果物を残すことを優先するため、Javascriptで進めました。

Next.js v13を用いた画面の開発

Nextjs 13.4が5月上旬にリリースされてから、約2カ月が経過しています。
その記事にはSince the release of Next.js 13 six months agoと書かれていました。Nextjs 12で作成していたアプリケーションが一段落した段階なので、遅れましたが、このタイミングでv13に触れてみることにしました。
v13になってから変わったことについて、既に様々記事で既に取り上げられていますが、その内容も眺めつつ、取り組んでみました。
今回は画面数が少ないです。ですので、体験する、といった表現が適切かもしれません!より規模が大きいアプリケーションは今後取り組んでいければ、といった気持ちです。

Supabase Authを用いた認証機能の開発

PostgresqlやStorageやFunctionなどの機能を提供しているサービスです。データ量やデータ転送量が多かったり、ストレージの使用量が大きかったりする場合には、コストがかかりますが、今回私が開発するような小規模なアプリケーションであれば、コスト0で使用できます。

https://supabase.com/NextjsとNotion APIだけでも機能自体は実現できますが、誰でもnotionのWeeklyReportの作成ボタンを押せる状態はどうなんでしょう?ということで、認証機能を追加しました。
(完成段階で新規ユーザの受け入れは考えていないので、signUpの機能は省き、signInとsignoutのみにしています。)
公式ドキュメントに色々記載されていますが、メールアドレスとパスワードによる認証を実装方法を採用しました。

Supabaseプロジェクトの作成方法

プロジェクト作成は、以下の記事がわかりやすいので、そちらをご確認ください。

使用する機能

認証機能に必要なSignIn・SignOut・SignUp(最初だけ)の3つを使用しました。

構成図

これまで記載した内容を整理しります。作成したアプリケーションの構成図が以下の通りです。
メインはNotionですが、ちょこっとフロントエンドを実装したり(Next.js)、認証機能をつけたり(Supabase Auth)、デプロイしたり(fly.io)、と要素が加わって、以下の通りです。
image.png

実装に進みます

フォルダ構成

フォルダ構成は以下の通りです。
image.png
追加した各フォルダ・ファイルに関する説明です。

  • actions/:notion APIでデータ取得・ページ取得処理を配置
  • auth/:認証成功後に表示するComponentを配置
  • unauthenticated/:認証前に表示するComponentを配置
  • commonLayout.jsx:認証前後ともに表示するComponent
  • middleware.js:supabaseのセッションリフレッシュ処理
  • .env:処理に必要な環境変数を記載

ライブラリのインストール

プロジェクトにライブラリをインストールします。

npm i @notionhq/client dotenv @supabase/supabase-js
  • @notionhq/client:NotionAPIの操作
  • dotenv:環境変数を読み込み
  • @supabase/auth-helpers-nextjs:supabaseの機能利用

環境変数の設定

※値は適宜設定してください。ブラウザ側(クライアントサイド)で公開する場合は、NEXT_PUBLIC_を頭に付けます。

.env
NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DAILY_DATABASE_ID=8dd15e3dff18437daec6fed2516bb09a
WEEKLY_DATABASE_ID=dd25f892c0a74se490ke2f33de5d396d
NEXT_PUBLIC_SUPABASE_URL=https://XXXXXXXXXXXXXXX.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

DATABASE_ID取得の設定

  1. カレンダービューのメニューから、「リンクをコピー」をクリック
    image.png
  2. テキストエディタに貼り付け
    https://www.notion.so/6dd15e3dff484379aec6fed2516bb06f?v=abb766453edd4b2fbd3a327f23505de8&pvs=4
    

最後のスラッシュの後ろから、クエリ(?v=)の前までの値:6dd15e3dff484379aec6fed2516bb06fをコピー

SUPABASE_URL・SUPABASE_ANON_KEYの設定
supabaseのコンソール上から取得します。左端のメニューからProject Settingをクリックし、APIを選択すると、URLとanonkeyを取得できます。
image.png

Next.js v13で発生するエラーを体験

v13に関する前提知識と、これまで実装してきたv12の実装経験をもとに書き進めました。v13になって、XXXしたらエラーになるよ~、と記載があったことを発生させつつ、また想定外とも戦いつつ、実装してみました。

notionClient定義ファイルでuse serverを記述したときのエラー

デフォルトではServer Componentsとして機能するため、不要だったのですが、記載してしまった状態で実行したときに、見つけたエラーです。
NotionのAPIを操作するために、こちらを参考にしてclient定義をしました。notionの操作をサーバサイドで行うから、Client定義のファイルに、use serverと記載しました。

Error: A "use server" file can only export async functions, found object.
sample.js
'use server'; //ここ
import { Client } from '@notionhq/client'

export const notion = new Client({
    auth: process.env.NOTION_TOKEN,
})

以下ページのWhen to use Server and Client Components?にある表に、どいう場合にどちらを定義すべきか確認できます。

Event handlersをServer Componentsで記載

直前にした話と重なりますが、これまでの感覚でComponentにEvent Handler(onClick(), onChange()など)を設定して発生したエラーです。表にも記載されている通り、EventHandlerを定義する場合は、use clientと定義したファイルで実装する必要があります。
image.png

Error: Event handlers cannot be passed to Client Component props.
  <div onClick={function} className=... children=...>
               ^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
sample.js
  const run = async () => {
    await createReport();
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <Head>
        <title>Create Weekly Report</title>
      </Head>
      <section className="bg-white dark:bg-gray-900">
        <div onClick={run}
          className="bg-lime-300 font-semibold rounded p-4">Create Report
        </div>
      </section>
    </main>
  )
}

router関係の実装

ログイン認証関係で、認証判定が終わった後に遷移先を指定していました。routerを定義した際に以下エラーが表示されました。

You have a Server Component that imports next/router. Use next/navigation instead.

next/routerの代わりに、next/navigationを使ってください、とこちらのディスカッションでも記載されていました。

Weeklyふりかえりページの実装

抽出方法は既に記載したので、流れを記載します。細かい部分は実装をご確認ください。

  • データベースを指定し、抽出するページの条件を設定して、ページ情報を取得
  • 各ページから、取得するとルール決めしたブロックの文言情報をMapに設定
  • Mapに設定した情報をもとにWeeklyふりかえりページの情報を整形
  • ページ作成実行
  • 作成時の画面表示
    • ふりかえりページ作成中、画面上で何もアクションがないと、作成中かどうか伝わらないので、以下ライブラリを使って、ローディング状態を表示することにしました。

実装詳細

フロントエンドの実装については、Supabase認証機能実装の部分で記載しています。
フロントエンド側から直接呼び出す処理が以下の通りです。
getDailyReports()で日々の記録収集及び、ページ作成まで実施します。)
エラーハンドリング発生していますが、それ以外は特に処理はありません。

index.js
import { getDailyReports } from "./getDailyReports";

export async function createReport() {
    try {
        //記録収集
        const pageTitles = await getDailyReports();
        return pageTitles
    } catch (err) {
        if (err instanceof Error) {
            console.error(err.message);
            return err
        }
    }
}

日々の記録から、情報を収集する処理
最後の方に記載してあるコメント、//ページ作成でページ作成の処理を呼び出します。
(ちょっと長めなので折りたたんでます)

getDailyReports.js
getDailyReports.js
"use server";

import { notion } from './notionClient'
import dotenv from 'dotenv'
import { createContents } from "./createContents";
dotenv.config();

// 現在の日付を取得
const currentDate = new Date();
// 1週間前の日付を計算
const oneWeekAgo = new Date();
oneWeekAgo.setDate(currentDate.getDate() - 7);

// 出力情報を格納するMapを定義
let reportOutputMap = new Map();

export async function getDailyReports() {
    const databaseId = process.env.DAILY_DATABASE_ID;

    const dailyRecords = await notion.databases.query({
        database_id: databaseId,
        filter: {
            property: 'Date',
            date: {
                after: oneWeekAgo.toISOString().split('T')[0]//過去1週間分の記録を抽出
            }
        },
        // 日付の昇順でデータ取得
        sorts: [
            {
                property: 'Date',
                direction: 'ascending',
            }
        ],
    });
    // 各ページごとに、情報抽出
    for (const record of dailyRecords.results) {
        // ページごとのIndex管理
        let indexPerPage = 0;
        // 対象ページのタイトル取得
        const pageInfo = await notion.pages.retrieve({ page_id: record.id });
        // ページタイトル
        const pageName = pageInfo.properties['名前'].title[0].plain_text;
        // ページのURL情報取得
        const pageUrl = pageInfo.url
        console.log(`===ページ[${pageName}]整理開始===`)
        reportOutputMap.set(`${pageName}_pageInfo`, `${pageName}@@@${pageUrl}`) // Report:h2で表示
        // reportOutputMap.set(`${pageName}_pageUrl`, pageUrl) // Report:h3で表示

        // 各ページのBlock情報取得
        const pageBlocks = await notion.blocks.children.list({
            block_id: record.id,
            page_size: 50,
        });

        // ブロックごとに処理
        for (const block of pageBlocks.results) {
            // Block Typeを取得
            const type = block.type;
            // H3Blockの場合
            if (type === 'heading_3') {
                const plainText = block.heading_3.rich_text[0].plain_text;
                reportOutputMap.set(`${pageName}_${indexPerPage}_heading_3`, plainText);
            }
            // color-text,bold text,text linkの場合
            if (type === 'paragraph') {
                if (block.paragraph.rich_text.length > 0) {
                    const richText = block.paragraph.rich_text[0];
                    // テキストリンクの場合
                    if (richText.href !== null) {
                        reportOutputMap.set(`${pageName}_${indexPerPage}_plain_text_href`, `${richText.plain_text}@@@${richText.href}`);
                        indexPerPage++
                    }
                    // 太字の場合
                    if (richText.annotations.bold) {
                        reportOutputMap.set(`${pageName}_${indexPerPage}_block_bold`, block.paragraph.rich_text[0].plain_text);
                        indexPerPage++
                    }
                    // 文字色を設定している場合
                    if (block.paragraph.color !== 'default') {
                        reportOutputMap.set(`${pageName}_${indexPerPage}_block_color`, `${block.paragraph.rich_text[0].plain_text}@@@${block.paragraph.color}`);
                        indexPerPage++
                    }
                }
            }
            // Toggleを用いたBlockの場合
            if (type === 'toggle') {
                if (block.toggle.rich_text.length > 0) {
                    reportOutputMap.set(`${pageName}_${indexPerPage}_toggle`, block.toggle.rich_text[0].plain_text);
                    indexPerPage++
                }
            }
        }
        console.log(`===ページ[${pageName}]整理完了===`)
    }
    //ページ作成
    return await createContents(reportOutputMap);
}

Weekly振り返り用のデータベースにページを登録する処理
収集した情報をまとめたページを登録する処理です。
(ちょっと長めなので折りたたんでます)

createContents.js
createContents.js
import { notion } from './notionClient'
import dotenv from 'dotenv'
dotenv.config();

export async function createContents(reportOutputMap) {
    // 日々の記録情報をまとめたMapを取得
    const cloneMap = reportOutputMap;

    //ページ名一覧を格納
    const pageTitles = []

    const databaseId = process.env.WEEKLY_DATABASE_ID; //Weekly reportのテーブルのID
    const res = await notion.databases.query({
        database_id: databaseId,
    });

    // 何件目のレポートか計算(3桁0埋め)
    const reportIndex = ('000' + (res.results.length + 1)).slice(-3);

    // データベースに登録する情報の設定(まとめている期間の日付情報・作成するページのタイトル)
    const oneWeekAgo = new Date();
    const today = new Date();
    oneWeekAgo.setDate(today.getDate() - 7);

    // 作成するページの出力する情報を設定する配列
    const children = []
    cloneMap.forEach((value, key) => {
        if (String(key).includes('_pageInfo')) {
            const pageText = value.split('@@@')[0]
            const pageUrl = value.split('@@@')[1]
            const h2Block = {
                object: 'block',
                heading_2: {
                    rich_text: [{
                        text: {
                            content: pageText,
                            link: {
                                url: pageUrl
                            }
                        }
                    }],
                    color: 'brown_background'
                }
            };
            children.push(h2Block);
            pageTitles.push(pageText);
        }
        // H3blockの場合
        if (String(key).includes('heading_3')) {
            const h3Block = {
                object: 'block',
                heading_3: { rich_text: [{ text: { content: value } }] }
            };
            children.push(h3Block)
        }
        // テキストリンクの場合
        if (String(key).includes('_plain_text_href')) {
            const linkText = value.split('@@@')[0]
            const linkUrl = value.split('@@@')[1]
            const textLinkBlock = {
                object: "block",
                paragraph: {
                    rich_text: [{
                        text: {
                            content: linkText,
                            link: {
                                url: linkUrl
                            }
                        },
                        href: linkUrl
                    }
                    ],
                    color: "default"
                }
            }
            children.push(textLinkBlock)
        }
        if (String(key).includes('_toggle')) {
            const boldBlock = {
                object: 'block',
                paragraph: {
                    rich_text: [{
                        text: {
                            content: value,
                        },
                    }]
                }
            };
            children.push(boldBlock)
        }
        // コンテンツ
        if (String(key).includes('_block')) {
            if (String(key).includes('_bold')) {
                const boldBlock = {
                    object: 'block',
                    paragraph: {
                        rich_text: [{
                            text: {
                                content: value,
                            },
                        }]
                    }
                };
                children.push(boldBlock)
            }
            if (String(key).includes('_color')) {
                const text = value.split('@@@')[0]
                const color = value.split('@@@')[1]
                const colorBlock = {
                    object: 'block',
                    paragraph: {
                        rich_text: [
                            {
                                text: {
                                    content: text
                                },
                            }
                        ],
                        color: color
                    }
                };
                children.push(colorBlock)
            }
        }
    });


    await notion.pages.create({
        // parent:週次レポートのページ
        "parent": {
            "type": "database_id",
            "database_id": "ad25c892-c0a7-4ce4-90be-2f335e5d396d"
        },
        "properties": {
            "Number": {
                "rich_text": [
                    {
                        "text": {
                            "content": `${reportIndex}`
                        }
                    }
                ]
            },
            "Report": {
                "title": [
                    {
                        "text": {
                            "content": `ふりかえり_${oneWeekAgo.toISOString().split('T')[0].replace(/-/g, '')} - ${today.toISOString().split('T')[0].replace(/-/g, '')}`
                        }
                    }
                ]
            }
        },
        "children": children // 収集した情報を設定
    });
    console.log('============レポート作成完了============')
    return pageTitles
}

Supabaseによる認証機能の実装

nextjsを用いた認証について、こちらのページを確認し、ページ内の動画の概要欄にあった、以下リポジトリの実装を参考にしました。

page.jsx(ルートパスアクセス)の実装

ルートパスへのアクセス時、supabaseのsession有無を判定し、どのページに遷移するか制御します。

page.jsx
export default async function Home() {
  const supabase = createServerComponentClient({ cookies })
  const {
    data: { session },
  } = await supabase.auth.getSession();

  // session有無を判定
  if (!session) {
    // 未認証の状態で表示する内容
    redirect("/unauthenticated");
  }

  return (
    // 認証された状態で表示する内容
    <AuthContents />
  )
}
ログイン処理

EmailとPasswordによる認証で、認証失敗した場合は画面にメッセージを表示し、成功した場合は再度ルートパスへアクセスします。認証後はsession情報があるので、認証した状態で表示されるべき内容が画面に表示できます。

login.jsx
const handleSignIn = async () => {
    const { data, error } = await supabase.auth.signInWithPassword({
        email: email,
        password: password,
    });
    // error有無を判定
    if (error !== null) {
        // EmailとPasswordの組み合わせが一致しない場合
        if (error.status === 400) {
            alert('Invalid login credentials')
            setPassword('');
        } else {
            // それ以外
            alert('Other Errors Happened')
        }
    } else {
        // errorがない場合は、ルートパスへ遷移
        router.push('/');
    }
}

デプロイ

最初はVercelをデプロイ先にしていましたが、
Hobbyプランの場合、Execution Duration/Limitが10秒ということで、諦めました。(処理に30秒前後かかることを動作検証で確認)

image.png

今回はfly.ioにしました。タイムアウトがデフォルト値で1分なので、特に設定を変更せずに、今回のアプリケーションをデプロイできました。

デプロイ手順は以下を参考にしました。

以下コマンドを実行すると、アプリケーション名やデータベース有無など、何点か質問に答えた後、 Would you like to deploy now?と聞かれるので、yと答えれば、デプロイまで一気に行えます。

flyctl launch

動作確認

ブラウザで実行

  1. ブラウザで立ち上げると、以下の画面が表示されます。ログイン情報を入力します。
    image.png
  2. ログイン成功で、画面が切り替わります。Create Weekly Reportをクリックすると、作成が開始されます。
    image.png
  3. 作成中は、作成が進行していることを示す、ローディングのアニメーション、またボタンを非活性状態にします。
    image.png
  4. Weeklyふりかえりページ作成が完了すると、対象としたページを一覧で表示します。
    image.png
出力結果

以下画像のようにWeeklyふりかえりページを作成できました。
image.png

  • 日々の内容を把握するために作成しているので、文言は特に変えずに出力
  • ページごとの区切りがわかりやすいように、ページ名の部分には背景色
  • 各ページの詳細を確認しやすいように、ページ名を文言に対してそのページへのリンクを設定
fly.ioのログ確認

ログが出力されました。(見やすい!)
image.png

OOM対応

実はデプロイ直後の動作確認では正常に実行できたのですが、翌日に再度実行してみると、Out Of Memoryが発生しました・・・

[23818.543500] Out of memory: Killed process 562 (node) total-vm:31789576kB, anon-rss:47468kB, file-rss:0kB, shmem-rss:0kB, UID:1001 pgtables:1580kB oom_score_adj:0

状況確認

VMの状態確認
2つの仮想マシンでメモリサイズが異なるぞ・・・
image.png

Metrics
以下のように、メモリ使用量・データ転送量・負荷について確認ができるので、適宜確認
image.png

image.png

解決方法

以下を参考に、メモリをアップデートしました。

fly scale vm shared-cpu-2x

image.png

※定期実行で作成すればよかったのでは?
わざわざフロントエンド使わなくても、AWS Lambdaの定期実行で、LINE通知する方法などでもよかったのでは?という疑問は最初ありました。
ですがLINE通知の場合、サラッと流すかもな~という自分の性格を鑑みやめました。時間をとって、画面上から実行の上、その場で色々気になる内容をふりかえる方向でやることにした次第です。

感想と今後

便利さを実感しているNotionですが、APIとの連携を通じて、カスタマイズしたページ作成できる、と実感できました。今回トップページのみの作成なので、Nextjs 13に対する理解はまだまだなので、もう少し規模が大きいサイトなどを作成してみようと思います。

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?