Lambda初心者がLambda×SNS×Notion連携でQiita記事を保存するサーバーレスアプリを作ってみた

## はじめに
Lambda(Python)を使用してQiitaの特定のタグのついた記事を取得し、Notion DBへ保存するサーバーレスアプリケーションを構築したので、その内容についてまとめます。

本アプリケーションを構築しようとした背景として以下が理由となります
- Lambda(Python)を使用したことがなかったので個人で学習してみたかったため
- EKS, Kubernetes関連のQiita記事で参考になる記事を読みたいが、検索から拾うのが面倒だったため、タグから自動で取得したかったため
- EventBridge Scheduler -> Lambda -> SNSの連携を業務上使用したことがなかったため

構成図は以下です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/dd0e70cc-9e82-442e-a09f-467fc896f0e0.png)


### 使用した技術
- AWS Lambda
- Amazon EventBridge Scheduler
- Amazon SNS
- Python (Runtime: 3.12)

Pythonコードの全体は以下にあります。

https://github.com/Kyrieee-ops/lambda_notion_connect

## 前提
- Pythonがインストールされていること (Lambdaのサーバーレス環境を使用しますが、外部ライブラリ(`notion_client`ライブラリ)をLambdaレイヤーに追加する必要があるためOSにPythonをインストールし`pip intall`が必要なためです)
- 使用端末がWindowsOSの場合、Git BashをインストールもしくはWSL2環境でLinuxコマンドが使用できること
- Notion Secretsキーが発行済みで、対象のNotionページに連携していること
- Notion DBに必要カラムが準備されていること(今回の場合はTitle, Tags, URL, Date, 作成者のカラム)
- Qiita アクセストークンが発行されていること

### Notion Secretsキーの発行方法

https://tektektech.com/notion-api/#Notionintegration

### Qiita アクセストークンの発行方法

https://qiita.com/miyuki_samitani/items/bfe89abb039a07e652e6

### Slack Webhook URLの発行方法

https://slack.com/services/new/incoming-webhook

上記リンクから`Incoming Webhook`の管理画面へ移動し、「チャンネルを選択」から通知したいチャンネルを選択し、`Incoming Webhook` インテグレーションの追加をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/9dc9b7e2-ef23-4c81-b237-a6935c85ab34.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/921f69ca-884f-4013-a193-88dd99012af6.png)

インテグレーションの設定項目の中に `Webhook URL`が表示されるため、このURLをコピーしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/56baec31-6047-4547-aeb4-b5b487b01ce0.png)


### 標準ライブラリと外部ライブラリ
Pythonには標準ライブラリと外部ライブラリが存在します。
標準ライブラリはPythonインストールとともに自動的に提供されるライブラリです。
つまりサーバーレス環境で使用する場合、LambdaのRuntimeを指定してPythonの関数を作成した場合、Pythonをインストールしたのと同じ状況であることから、標準ライブラリは`import`さえしてしまえば使用できます。

逆に外部ライブラリは標準ライブラリには含まれていない特定の機能を使う外部で開発されたライブラリのことです。
標準ライブラリには含まれていないため、ローカルでインストールしたライブラリをZip化し、Lambdaのレイヤーとして含める必要があります。


## 構築

AWSリソースについては全てをIaC化しても良かったのですが、Lambdaなどはコンソールからでもすぐに作成できましたので一部IaC化、一部手動で作成しています。

## notion_clientライブラリをLambdaレイヤーに追加
1. notion_clientライブラリのインストール
※私の場合はGit Bashを使用して以下コマンドを実行しています。
```bash
mkdir python
cd python
# 作成したディレクトリにnotion-clientをインストール
pip install notion-client -t .
cd ../
# pythonディレクトリを再帰的にzipし、Lambdaレイヤーを作成
zip -r notion_layer.zip python
```
外部ライブラリをレイヤーに追加する場合、外部ライブラリを含むディレクトリ名は`python`である必要があります。
ライブラリを含めた`python`ディレクトリをzip化し、Lambdaレイヤーに追加します。

2．AWSコンソールからLambda -> レイヤー -> [レイヤーを作成]を押下。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/4fb4f6ff-0efe-487e-bb10-982e0234105a.png)

3．名前、説明等は適宜入力し、.zipファイルをアップロードから作成したnotion_layer.zipをアップロードする。

互換性のあるアーキテクチャ、互換性のあるランタイムは適宜選択してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/26ce5de3-d1cc-4f20-ac24-be550e2aafa1.png)


AWS CLIでレイヤーを作成する場合

```bash
aws lambda publish-layer-version \
--layer-name notion-layer-v3_12 \
--zip-file fileb://notion_layer.zip \ # .zipが存在するディレクトリで実行
--compatible-runtimes python3.12
```

## main関数の作成
Lambdaの画面から[関数を作成] -> [一から作成] を選択し、関数名、ランタイム(Python3.12)、アーキテクチャを選択し、関数を作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/7b5a78bf-0fae-4935-8eab-f2d5ba3a9f1f.png)




## requestsライブラリをARN指定でレイヤーに追加
外部レイヤーを使用するのにローカルでインストールして、Zip化してレイヤーに追加って処理がちょっと面倒くさいな？と思い、もう少し良い方法はないかな、と思って探していたら以下記事でARNを指定すればレイヤー追加できるPythonのLambdaレイヤーを公開しているGitHubリポジトリが存在するとのことでこちらを使ってみました。
この記事にもある通り有名どころのライブラリはそろっているようなので、有名どころの`requests`ライブラリは存在していました。

https://zenn.dev/ndjndj/articles/2533d854d86902

https://github.com/keithrozario/Klayers

詳細の使い方はGitHubのREADME.mdを見ていただくと良いかと思いますが、`List of ARNs`から対象のPython Runtimeを選択すると、レイヤーのリージョンごとのリストが表示されるので、ここから対象リージョンのARNsの`json`, `csv`, `html`から確認します。
ブラウザで見る分には`html`から見た方が見やすいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/f0e70e53-211e-463f-8689-469e3be26898.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/7f214992-41ff-4c1d-9590-ad864bec83b6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/30f601ab-eec3-4a07-81c2-0140f50dd81d.png)

上記で確認したARNをコピーしておき、レイヤーを追加します。
指定の方法は簡単で、対象のLambda関数の画面からレイヤーを追加で[ARN を指定] -> [ARN を指定]箇所でコピーしたARNを貼り付け[検証]を押下 -> ARNに誤りが無ければ、説明、互換性のあるランタイム、互換性のあるアーキテクチャが表示されるため、[追加]を押下。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/10815a03-2e41-42a9-a841-2b711ec9215d.png)

## main関数のPythonコードおよびその他設定

<details open>
<summary>環境変数の定義</summary>

```markdown
- NOTION_DB_ID: Notion DBのIDを指定
- NOTION_TOKEN: Notion Secretsキーの値を指定
- QIITA_TOKEN: Qiitaアクセストークンの値を指定
- SNS_TOPIC_ARN: arn:aws:sns:ap-northeast-1:${AWS::AccountId}:slack-notifications-lambda-notion # SNS_TOPIC_ARNについてはSNS構築後に反映します。
```

</details>

<details open>
<summary>一般設定</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/ac1121f2-f099-4317-a5ad-5bf6a8092c2f.png)


</details>

<details><summary>main関数のPythonコード</summary>

```python
import os
import requests
from datetime import datetime, timedelta
from urllib.parse import urlparse
from notion_client import Client
import boto3
import json

# 環境変数から認証情報取得
QIITA_TOKEN = os.getenv('QIITA_TOKEN')  # Qiitaのアクセストークン
NOTION_TOKEN = os.getenv('NOTION_TOKEN') # Notion Secretキー
NOTION_DB_ID = os.getenv('NOTION_DB_ID') # Notion DBキー
SNS_TOPIC_ARN = os.getenv('SNS_TOPIC_ARN') # SNSトピックARN

# AWSクライアント初期化（関数外で定義）
sns_client = boto3.client('sns')

def lambda_handler(event, context):
    try:
        # 1. Qiitaから記事取得
        articles = fetch_qiita_articles()
        
        # 2. Notionクライアント初期化
        notion = Client(auth=NOTION_TOKEN)
        
        # 3. 既存記事のURLを取得
        existing_urls = get_existing_article_urls(notion)
        
        # 4. 新規記事のみフィルタリング
        new_articles = [
            article for article in articles 
            if normalize_url(article["url"]) not in existing_urls
        ]
        
        # 5. Notionに書き込み
        results = []
        for article in new_articles:
            result = create_notion_page(notion, article)
            results.append(result)
        
        # 6. SNSで成功通知 (追加)
        send_sns_notification(
            status="SUCCESS",
            message=f"{len(new_articles)}件の新規記事を登録しました",
            details={
                "processed": len(new_articles),
                "skipped": len(articles) - len(new_articles)
            }
        )

        return {
            "statusCode": 200,
            "body": {
                "message": f"{len(new_articles)}件の新規記事を登録しました（スキップ: {len(articles) - len(new_articles)}件）",
                "details": results
            }
        }
        
    except Exception as e:
        # 7. SNSで失敗通知 (追加)
        send_sns_notification(
            status="FAILED",
            message="処理中にエラーが発生しました",
            details={"error": str(e)}
        )
        raise e

def send_sns_notification(status, message, details):
    """SNS通知を送信"""
    notification = {
        "status": status,
        "message": message,
        "details": details,
        "timestamp": datetime.now().isoformat()
    }
    print(f"SNSに送信: {notification}")  # send_sns_notification()内に追加
    
    sns_client.publish(
        TopicArn=SNS_TOPIC_ARN,
        Message=json.dumps(notification, ensure_ascii=False),
        Subject=f"Qiita-Notion Sync {status}"
    )

def get_existing_article_urls(notion):
    """NotionDBから既存記事のURL一覧を取得"""
    existing_pages = notion.databases.query(
        database_id=NOTION_DB_ID,
        filter={"property": "URL", "url": {"is_not_empty": True}}
    )
    
    return {
        normalize_url(page["properties"]["URL"]["url"])
        for page in existing_pages["results"]
    }

def normalize_url(url):
    """URLを正規化（クエリパラメータや末尾スラッシュを無視）"""
    parsed = urlparse(url)
    return f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/")

def fetch_qiita_articles():
    """QiitaからKubernetes/EKSタグの記事を取得"""
    url = "https://qiita.com/api/v2/items"
    
    # 検索条件（直近7日間、タグ指定）
    params = {
        "query": "tag:Kubernetes OR tag:EKS created:>={}".format(
            (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
        ),
        "per_page": 20  # 最大取得件数
    }
    
    headers = {}
    if QIITA_TOKEN:
        headers["Authorization"] = f"Bearer {QIITA_TOKEN}"

    response = requests.get(url, headers=headers, params=params)
    response.raise_for_status()

    return response.json()

def create_notion_page(notion, article):
    """Notionにページ作成"""
    # タグ情報を抽出
    tags = [tag["name"] for tag in article.get("tags", [])]
    
    # Notionプロパティ形式に変換
    properties = {
        "Title": {
            "title": [
                {
                    "text": {
                        "content": article["title"]
                    }
                }
            ]
        },
        "URL": {
            "url": article["url"]
        },
        "Date": {
            "date": {
                "start": article["created_at"] # Qiitaの記事作成日を設定
            }
        },
        "Tags": {
            "multi_select": [
                {"name": tag} for tag in tags
            ]
        },
        "作成者": {
            "rich_text": [
                {
                    "text": {
                        "content": article["user"]["id"]
                    }
                }
            ]
        }
    }
    
    # Notionにページ作成
    response = notion.pages.create(
        parent={"database_id": NOTION_DB_ID},
        properties=properties
    )
    print(f"Qiita記事作成日: {article['created_at']}")
    print(f"Notionページ作成日: {response['created_time']}")
    
    
    return {
        "qiita_id": article["id"],
        "notion_id": response["id"],
        "title": article["title"]
    }
```
</details>

<details open>
<summary>main関数のリソースベースポリシー</summary>

`${AWS::Region}`, `${AWS::AccountId}`については適宜自身の環境のリージョン、AWSアカウントIDに置き換えてください。

以下のリソースベースポリシーは、EventBridge Schedulerがmain関数を呼び出す許可を与えています。

```json
{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Sid": "eventbridge-scheduler",
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:notion-connect",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/*"
        }
      }
    }
  ]
}
```
</details>

<details open>
<summary>main関数にアタッチしているロールのポリシー</summary>

デフォルトで作成されたロールに対して"Action": "sns:Publish"を追加し、SNSへPublishする権限を与えています。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-${AWS::Region}:${AWS::AccountId}:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/notion-connect:*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:slack-notifications-lambda-notion"
        }
    ]
}
```
</details>


## EventBridge SchedulerのCloudFormationデプロイ

Lambdaを定期実行するEventBridge Schedulerについてはロールと合わせて作成する必要がありますので、こちらはIac化しました。
以下ロール -> EventBridge Schedulerの順でCloudFormationテンプレートをデプロイします。
EventBridge Schedulerの`Cron`設定は毎週月曜日12時に定期実行されるように設定しているため、実行時間を変えたい場合は適宜ここを変更する

<details open>
<summary>EventBridge Schedulerのロール</summary>

```yaml
#=======================================================================
# EventBridge SchedulerがLambda関数を実行するためのロール
#=======================================================================
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  NotionLambdaRole:
    Type: 'AWS::IAM::Role'
    Properties: 
      RoleName: 'lambda-eventbridge-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service: 'scheduler.amazonaws.com'
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyName: 'lambda-eventbridge-policy'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action: 
                  - 'lambda:InvokeFunction'
                Resource: '*'
```
</details>

<details open>
<summary>EventBridge SchedulerのCloudFormationテンプレート</summary>

```yaml
#=======================================================================
# Qiita APIを叩いて、NotionDBに連携するLambdaを定期実行
#=======================================================================
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  LambdaEventbridgeScheduler:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: "eventbridge-scheduler-lambda-notion"
      Description: "Qiita APIを叩いて対象タグの記事を取得しNotionDBに登録するLambdaを定期実行"
      ScheduleExpression: "cron(0 12 ? * 2 *)" # 毎週月曜日12時に定期実行
      ScheduleExpressionTimezone: "Asia/Tokyo"
      Target:
        Arn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:notion-connect
        RoleArn: !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:role/lambda-eventbridge-role
      FlexibleTimeWindow:
        Mode: "OFF"
```

</details>

## Slack通知用関数のPythonコードおよびその他設定



<details open>
<summary>環境変数の定義</summary>


```markdown
- SLACK_WEBHOOK_URL: Slack WebhookのURLを指定
```
</details>

<details open>
<summary>一般設定</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/7681a69a-3b60-496a-94db-78ab56f2038d.png)
</details>

<details open>
<summary>レイヤー追加</summary>

main関数と同様に[requests](#requestsライブラリをarn指定でレイヤーに追加)ライブラリを追加
</details>

<details><summary>Slack通知用関数のPythonコード</summary>

```python
import os
import json
import requests

SLACK_WEBHOOK_URL = os.getenv('SLACK_WEBHOOK_URL')

def lambda_handler(event, context):
    try:
        print(f"受信イベント: {event}")  # lambda_handlerの先頭に追加
        # SNSメッセージを解析
        sns_message = json.loads(event['Records'][0]['Sns']['Message'])
        
        # Slack用メッセージを生成
        slack_msg = build_slack_message(sns_message)
        
        # Slackに送信
        response = requests.post(
            SLACK_WEBHOOK_URL,
            json=slack_msg,
            timeout=3
        )
        response.raise_for_status()
        
        return {"statusCode": 200}
    
    except Exception as e:
        print(f"Slack通知失敗: {str(e)}")
        # 必要に応じてCloudWatch LogsやDead Letter Queueへ
        raise e

def build_slack_message(sns_msg):
    """SNSメッセージからSlack用ブロックを生成"""
    if sns_msg["status"] == "SUCCESS":
        return {
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"✅ *同期成功*: {sns_msg['message']}"
                    }
                },
                {
                    "type": "section",
                    "fields": [
                        {
                            "type": "mrkdwn",
                            "text": f"*処理日時*\n{sns_msg['timestamp']}"
                        },
                        {
                            "type": "mrkdwn",
                            "text": f"*詳細*\n{sns_msg['details']}"
                        }
                    ]
                }
            ]
        }
    else:
        return {
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"❌ *同期失敗*: {sns_msg['message']}"
                    }
                },
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"```{sns_msg['details']['error']}```"
                    }
                }
            ]
        }
```

</details>

<details open>
<summary>Slack通知用Lambdaのリソースベースポリシー</summary>

`${AWS::Region}`, `${AWS::AccountId}`については適宜自身の環境のリージョン、AWSアカウントIDに置き換えてください。

以下のリソースベースポリシーは、SNSがSlack通知用関数を呼び出す許可を与えています。

```json
{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Sid": "sns-${AWS::Region}-${AWS::AccountId}-slack-notifications-lambda-notion",
      "Effect": "Allow",
      "Principal": {
        "Service": "sns.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:slack-notification",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:slack-notifications-lambda-notion"
        }
      }
    }
  ]
}
```

</details>


## SNSトピック/サブスクリプションの作成

SNSではメッセージ送信元を`Publisher`, メッセージ受信側を`Subscriber`と呼びますが、
今回のSNSの役目はLambdaのmain関数がQiitaから情報を取得し、何件の記事を取得できたか？を後続のSlack通知用関数に届ける橋渡しをするために使用します。

役割は以下となります。
`Publisher`: main関数
`Subscriber`: Slack通知用関数

```mermaid
graph LR
    A[EventBridge Scheduler] -->|Invoke| B(notion-connect Lambda)
    B -->|sns:Publish| C[SNS Topic]
    C -->|Invoke| D(slack-notification Lambda)
```

SNSトピックの役割が理解できたところで、コンソールからSNSトピックを作成します。
タイプはスタンダードとし、トピックを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/de43532b-b605-4170-a562-55ee1beecee4.png)


作成したSNSトピックのサブスクリプションを作成します。
- トピックARN: 作成したトピックARNを指定
- プロトコル: Lambda
- エンドポイント: slack-notificationのARNを選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/6d4f32c9-4192-4aec-9d59-4ebf937d5196.png)

これで`notion-connect Lambda`がメッセージをSNSへ`Publish`し、SNSが受け取ったメッセージを`slack-notification Lambda`に`Subscribe`することで、`slack-notification Lambda`内で記述している`Slack Webhook URL`に向けてメッセージを送信することができます。

実際にメッセージが通知されると、以下のようにSlackに通知されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/18545240-e277-4332-b37f-6008ac3789f3.png)

Notionには以下のように登録されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/3fc2be9c-8143-4ca2-99d9-dcc53cd13723.png)



## 最後に
Lambda初心者がサーバーレスアプリを作成してみましたが、Pythonのコードは生成AIを活用してコード生成してもらっています。
Pythonはまだまだ勉強中のため、理解を深めていきたいと思います。

Pythonスクレイピングに関する初心者向けの本は以下が非常に優しく解説しています。

[<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379725/5ef0eb81-c5b4-4a05-ae08-6c6284f9cad1.jpeg" width=70%>](https://amzn.to/44z50Uh)