AWSのアップデートをSlackに通知する"What’s New Summary Notifier"をSlackフリープランのWebhookで受信する

# はじめに
AWSの最新情報は[What’s New with AWS?](https://aws.amazon.com/new/)や[AWS News blog](https://aws.amazon.com/blogs/aws/)などで日々提供されていますが、これらから必要な情報を探し出し、内容を理解するのは大変な作業です。

そこで、AWSが情報の収集、日本語での要約、受信Webhookに対応したコラボレーションツールへの投稿を行うソリューション[What’s New Summary Notifier](https://github.com/aws-samples/whats-new-summary-notifier)を公開しました｡しかし､このコードはSlack有料プランのワークフローにあるWebhookを使う実装となっています｡WebhookなのでフリープランのIncoming Webhooksで受信できるよう少し手を加えてみました｡
さらに、得られた要約をXでシェアできるよう、シェアリンクも追加しました。

https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/

What's New Summary NotifierはSlackとTeamsの受信Webhookに対応する実装となっていますが、Webhook URLの登録とそのURLに対するリクエストボディを変更すれば他のツールにも応用できます。

# デモ画面

以下のスクリーンショットは、RSSから以下の記事のURLを取得し翻訳・要約したものがSkackにポストされた例です。

https://aws.amazon.com/jp/about-aws/whats-new/2024/08/amazon-cloudwatch-dimensions-ec2-on-demand-capacity-reservations/

![Incoming Webhooksで受信したメッセージの例。記事掲出の日時、記事タイトルが記事へのリンクとなっている。概要が短い文で示され、次に記事を箇条書きで整理したものが示される。末尾に、記事の要約を200文字以内にまとめたものをX(Twitter)でシェアするためのShare on Xというリンクが示されている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/f3cf0b9c-c2d1-9d92-de79-78a6e395b90c.png)

:::note info
Share on X のリンクは記事の要約を200文字以内にまとめたものをX(Twitter)でシェアするためのものです。"What’s New Summary Notifier"の実装にはそのようなものはなく、このソリューションを利用する際に自分の利便性のために実装しました。
:::

# 参考情報

https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/

https://github.com/aws-samples/whats-new-summary-notifier

https://techblog.nhn-techorus.com/archives/28031

https://docs.docker.com/engine/install/ubuntu/

https://blog.serverworks.co.jp/2023/08/31/130610


# 開発環境構築

作業環境のOSバージョン

```console
$ cat /etc/os-release | grep PRETTY_NAME
PRETTY_NAME="Ubuntu 24.04 LTS"
```

npmコマンドがない場合はインストールします｡

```console
$ sudo apt install npm
```

```console
$ npm --version
9.2.0
```

AWS CDKがない場合はインストールします｡
```console
$ sudo npm install -g aws-cdk
```

```console
$ cdk --version
2.150.0 (build 3f93027)
```

Docker がない場合はインストールします。

https://docs.docker.com/engine/install/

```console
$ docker --version
Docker version 27.1.2, build d01f264
```

# 構築手順

AWSの記事に手順が書かれているので､ここでは記事と手順が異なる箇所を中心に書きます｡

https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/

構築にあたって、[3-1. アーキテクチャの解説](https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/#03-01)に書かれているAWSリソースを作成、操作可能な権限が必要です。


## Slackの準備

### チャンネルを作成
Webhookを受信するチャンネルを作成します｡例えば､`whats-new-aws-summary`といった名称でチャンネルを作成します｡

![Slackチャンネル作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/5b8d0e6e-b6d1-a445-577e-0c5a1f6c5bd8.png)

### Slack appの作成

[slack api](https://api.slack.com/apps/)を開き､新規アプリケーションを作成します｡ここでは`From scratch`を選択します｡

![Create an app on Slack](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/b1eb530a-8510-ea68-9a17-8257c75f15fd.png)

`Incoming Webhooks`を開き､Activate Incoming WebhooksをOnにします｡
![Incoming Webhooks on Slack](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/2923fe62-c57b-aafa-c8e3-d4252c05fe4f.png)

このスクリーンショットではすでにひとつWebhook URLが作成されています｡`Add New Webhook for Your Workspace`をクリックします｡
![Webhook URLs for your Workspace](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/aea80366-0023-6c31-a5e5-71cdcca5da0b.png)

先ほど作成したWebhookの投稿先チャンネルを選択し､`許可する`をクリックします｡
![Request privilege for your Workspace](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/d73d1592-c19b-c328-6ffe-f9cfab8f8209.png)

Webhook URLが作成されると､以下のようにチャンネル名とともにURLが一覧に表示されます｡このURLをコピーします｡

![Webhook URLs for your Workspace](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/aea80366-0023-6c31-a5e5-71cdcca5da0b.png)

## デプロイ

ここからは、AWS記事の手順と同じです。[2-3. Webhook URL を AWS Systems Manager Parameter Store に格納](https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/#02-03) に従い、さきほどコピーしたWebhook URLをAWS Systems Manager Parameter Storeに登録します。

続いて､コードをGitリポジトリからクローンし､デプロイします｡


### Error: spawnSync docker ENOENT

デプロイの際に以下のエラーが発生した場合は､dockerがインストールされていないので､環境にあわせてdockerをインストールします｡

```console
$ cdk deploy
Error: spawnSync docker ENOENT
```

## カスタマイズ

### Incoming Webhooks対応

[2-2-1. Slack の場合](https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/#02-02-01)にあるように､ワークフローのWebhookで設定した変数にあわせたitemを作成しWebhook URLに送信しています｡元コードはこのようになっています。

```python:lambda/notify-to-app/index.py(変更前)
        else:  # Slack
            msg = item

        encoded_msg = json.dumps(msg).encode("utf-8")
```

https://github.com/aws-samples/whats-new-summary-notifier/blob/main/lambda/notify-to-app/index.py#L241-L244


Incoming Webhooksでは`{"text":"Hello, World!"}`のような形式でメッセージを送信する必要があるので､以下のように修正しました｡これにより、ひとつのWebhookに情報を詰め込むことができました。

```python:lambda/notify-to-app/index.py(変更後)
        else:  # Slack
            msg = create_slack_message(item)

        encoded_msg = json.dumps(msg).encode("utf-8")
```

```python
def create_slack_message(item):
    # URL encode the RSS link separately

    message = {
        "text": f"{item['rss_time']}\n" \
                f"<{item['rss_link']}|{item['rss_title']}>\n" \
                f"{item['summary']}\n" \
                f"{item['detail']}"
    }

    return message
```

以下のようなメッセージを組み立ててWebhookに送信します｡
|item名|内容|
|:-----------|:------------|
|item["rss_time"]|記事投稿時間|
|item["rss_link"]|記事URL|
|item["rss_title"]|記事タイトル|
|item["summary"]|item["detail"] をもとに要約した内容|
|item["detail"]|プロンプトに基づいて思考した過程を箇条書きにした内容|

lambda/notify-to-app/index.py全体の差分は以下のとおりです。

```diff
diff --git a/lambda/notify-to-app/index.py b/lambda/notify-to-app/index.py
index 1d31ded..05dbc0a 100644
--- a/lambda/notify-to-app/index.py
+++ b/lambda/notify-to-app/index.py
@@ -7,10 +7,12 @@ import os
 import time
 import traceback
 
+import urllib.parse
 import urllib.request
 
 from typing import Optional
 from botocore.config import Config
 from bs4 import BeautifulSoup
 from botocore.exceptions import ClientError
 import re
@@ -239,7 +241,7 @@ def push_notification(item_list):
             item["detail"] = item["detail"].replace("。\n", "。\r")
             msg = create_teams_message(item)
         else:  # Slack
-            msg = item
+            msg = create_slack_message(item)
 
         encoded_msg = json.dumps(msg).encode("utf-8")
         print("push_msg:{}".format(item))
@@ -277,6 +279,24 @@ def get_new_entries(blog_entries):
     return res_list
 
 
+def create_slack_message(item):
+
+    # URL encode the RSS link separately
+    encoded_rss_link = urllib.parse.quote(item["rss_link"])
+
+    message = {
+        "text": f"{item['rss_time']}\n" \
+                f"<{item['rss_link']}|{item['rss_title']}>\n" \
+                f"{item['summary']}\n" \
+                f"{item['detail']}\n"
+    }
+
+    return message
+
+
 def create_teams_message(item):
     message = {
         "type": "message",
```


Webhookを受信するとこのようなメッセージがチャンネルに投稿されます｡

![Incoming Webhooksで受信したメッセージの例。記事掲出の日時、記事タイトルが記事へのリンクとなっている。概要が短い文で示され、次に記事を箇条書きで整理したものが示される。末尾に、記事の要約を200文字以内にまとめたものをX(Twitter)でシェアするためのShare on Xというリンクが示されている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/f3cf0b9c-c2d1-9d92-de79-78a6e395b90c.png)

### さまざまなAWSブログのRSSを追加する

[4-2. What’s New with AWS? 以外の Web サイトから要約を作る](https://aws.amazon.com/jp/builders-flash/202406/catch-up-genai-update/#04-02) にあるように､他のAWSブログやニュースのRSSを追加可能です｡元のコードはこのように一つのRSSが設定されています｡

```json:cdk.json(変更前)
        "notifiers": {
            "AwsWhatsNew": {
                "destination": "slack",
                "summarizerName": "AwsSolutionsArchitectJapanese",
                "webhookUrlParameterName": "/WhatsNew/URL",
                "rssUrl": {
                    "What’s new": "https://aws.amazon.com/about-aws/whats-new/recent/feed/"
                }
            }
        }
```

https://github.com/aws-samples/whats-new-summary-notifier/blob/main/cdk.json#L32-L41

このように複数のRSSを設定することが可能です。これにより、日々追いかけたいブログの更新情報や要約を自動的に取得できます。

元の記事ではscheduleパラメータについて触れられていませんが､DEPLOY_ja.mdに｢ (オプション): CRON 形式の RSS フィード取得間隔。本パラメータの指定がない場合は、毎時 00 分にフィードを取得します。｣と書かれています｡ここでは毎時20分にクロールするように設定しています｡この値は､Amazon EventBridgeの設定に反映されます｡

```json:cdk.json(変更後)
        "notifiers": {
            "AwsWhatsNew": {
                "destination": "slack",
                "summarizerName": "AwsSolutionsArchitectJapanese",
                "webhookUrlParameterName": "/WhatsNew/URL",
                "rssUrl": {
                    "What's new": "https://aws.amazon.com/about-aws/whats-new/recent/feed/",
                    "AWS News Blog": "https://aws.amazon.com/blogs/aws/feed",
                    "AWS News Security Blog": "https://aws.amazon.com/blogs/security/feed/",
                    "AWS News Machine Learning Blog": "https://aws.amazon.com/blogs/machine-learning/feed/",
                    "AWS Web Services Blog (JP)": "https://aws.amazon.com/jp/blogs/news/feed/",
                    "AWS Web Services Blog (JP) - Generative AI": "https://aws.amazon.com/jp/blogs/news/category/artificial-intelligence/generative-ai/feed/",
                    "AWS Web Services Blog - Generative AI": "https://aws.amazon.com/blogs/aws/category/artificial-intelligence/generative-ai/feed/",
                    "AWS Web Services Blog (JP) - Generative AI 2": "https://aws.amazon.com/jp/blogs/news/category/generative-ai-2/feed/"
                },
                "schedule": {
                    "minute": "20",
                    "hour": "*",
                    "day": "*",
                    "month": "*",
                    "year": "*"
                }
            }
        }
```

以下のサイトなどを参考に､RSSを設定しました｡

https://techblog.nhn-techorus.com/archives/28031 


### AWSブログ以外のRSSを登録する
What’s New Summary Notifierが記事の内容を取得している部分の実装をみてみると､\<main\>タグ内のテキストを対象としていました｡RSS URLの形式や\<main\>タグに記事本文があることから､記事配信にWordPressを利用しているようです｡

```python:lambda/notify-to-app/index.py
            with urllib.request.urlopen(url) as response:
                html = response.read()
                if response.getcode() == 200:
                    soup = BeautifulSoup(html, "html.parser")
                    main = soup.find("main")

                    if main:
                        return main.text
```

https://github.com/aws-samples/whats-new-summary-notifier/blob/main/lambda/notify-to-app/index.py#L39-L46

そのため､WordPressを使っているブログであればRSS URLをcdn.jsonに追加するだけで記事を要約したものをSlackやTeamsに送信できると思います｡もしくは\<main\>タグ内に記事本文があればWordPress以外のブログでも対応できそうです｡

:::note warn
注意
クロールを行う際は、可否や方法についてサイトの利用規約などの確認が必要です。

先日､CloudflareがAIクローラーをブロックする設定を追加しました｡
[Cloudflare⁠⁠、ワンクリックでAIボット⁠⁠、AIクローラーをブロックする設定を追加](https://gihyo.jp/article/2024/07/cloudflare-ai-bot-blocking)

このように、機械的なクロールはブロックされることがあります｡
:::

技術的な視点だけでみると、Cloudflareを使っているサイトに対するアクセスは[cloudscraper](https://pypi.org/project/cloudscraper/)を使うことで成功します。get_blog_content()を以下のように実装すればよさそうです。

```python:lambda/notify-to-app/index.py(変更後)
import cloudscraper
def get_blog_content(url):
    """Retrieve the content of a blog post

    Args:
        url (str): The URL of the blog post

    Returns:
        str: The content of the blog post, or None if it cannot be retrieved.
    """

    if not url.lower().startswith(("http://", "https://")):
        print(f"Invalid URL: {url}")
        return None

    # create a cloudscraper instance
    scraper = cloudscraper.create_scraper()

    # dummy User-Agent
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
    }

    try:
        response = scraper.get(url, headers=headers, timeout=5)
        response.raise_for_status()

        soup = BeautifulSoup(response.text, "html.parser")
        main = soup.find("main")

        return main.text if main else None

    except Exception as e:
        print(f"Error accessing {url}: {e}")
        return None

```

元コードとの差分は以下のとおりです。

```diff
diff --git a/lambda/notify-to-app/index.py b/lambda/notify-to-app/index.py
index 1d31ded..4d05d60 100644
--- a/lambda/notify-to-app/index.py
+++ b/lambda/notify-to-app/index.py
@@ -7,10 +7,12 @@ import os
 import time
 import traceback
 
+import urllib.parse
 import urllib.request
 
 from typing import Optional
 from botocore.config import Config
+import cloudscraper
 from bs4 import BeautifulSoup
 from botocore.exceptions import ClientError
 import re
@@ -33,29 +35,33 @@ def get_blog_content(url):
         str: The content of the blog post, or None if it cannot be retrieved.
     """
 
+    if not url.lower().startswith(("http://", "https://")):
+        print(f"Invalid URL: {url}")
+        return None
+
+    # create a cloudscraper instance
+    scraper = cloudscraper.create_scraper()
+
+    # dummy User-Agent
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
+    }
+
     try:
-        if url.lower().startswith(("http://", "https://")):
-            # Use the `with` statement to ensure the response is properly closed
-            with urllib.request.urlopen(url) as response:
-                html = response.read()
-                if response.getcode() == 200:
-                    soup = BeautifulSoup(html, "html.parser")
-                    main = soup.find("main")
-
-                    if main:
-                        return main.text
-                    else:
-                        return None
+        response = scraper.get(url, headers=headers, timeout=5)
+        response.raise_for_status()
 
-        else:
-            print(f"Error accessing {url}, status code {response.getcode()}")
-            return None
+        soup = BeautifulSoup(response.text, "html.parser")
+        main = soup.find("main")
+
+        return main.text if main else None
 
-    except urllib.error.URLError as e:
-        print(f"Error accessing {url}: {e.reason}")
+    except Exception as e:
+        print(f"Error accessing {url}: {e}")
         return None
```

### X (Twitter) シェア用リンクを追加する

翻訳・要約された文章をX (Twitter)でシェアできるよう、`Share on X`リンクを追加します。リンクを追加した画面はいかのとおりです。文末にリンクを追加しました。

![Share on Xのリンクを追加した画面。これをクリックするとX(Twitter)の投稿画面が開く。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/a4e31615-2686-93ca-8305-8f1fd6ca0e5d.png)

`Share on X`リンクをクリックすると以下のように投稿画面が開きます。

![(Twitter)のポスト画面。さきほどのShare on Xリンクをクリックするとこの画面が開く。100文字程度の要約文と記事URL、ハッシュタグ2,3個で構成される投稿文が投稿フォームに入力済みの状態となりそのまま投稿できる。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/b845ef53-8212-3f7a-79d4-05c17d975963.png)

ポストの内容は以下のとおりです。
- 記事を100文字程度に要約した文章
- 自動生成したハッシュタグを2,3個
- 記事のURL
- ロボットのアイコンと吹き出しを模した記号、"(生成AIによる要約ポスト)"という注意書き

summarize_blog()のprompt_dataを以下のように変更しました。\<instruction\>タグ内に上記の要件を満たすX (Twitter)用ポストを作成する指示と、それを\<twitter\>タグに入れる指示を追加しました。

```python:lambda/notify-to-app/index.py(変更後)
    prompt_data = f"""
<input>{blog_body}</input>
<persona>You are a professional {persona}. </persona>
<instruction>
Describe a new update in <input></input> tags in bullet points to describe "What is the new feature?", and "Who is this update good for". The description shall be output in <thinking></thinking> tags, and each thinking sentence must start with the bullet point "- " and end with "\n".
Make the final summary as per <summaryRule></summaryRule> tags. Try to shorten output for easy reading. You are not allowed to utilize any information except in the input. output format shall be in accordance with <outputFormat></outputFormat> tags.
Summarize the description and 1 or 2 hashtags for posting to X (Twitter) within 100 characters and the description shall be output in <twitter></twitter> tag.
</instruction>
<outputLanguage>In {language}.</outputLanguage>
<summaryRule>The final summary must consists of 2 or 3 sentences. The output format is defined in <outputFormat></outputFormat> tags.</summaryRule>
<outputFormat><thinking>(bullet points of the input)</thinking><summary>(final summary)</summary><twitter>(summary to post X(Twitter))</twitter></outputFormat>
Follow the instructions.
"""
```

同じくsummarize_blog()で、さきほどの\<twitter\>タグを抽出しreturnで返すようにしました。

```python:lambda/notify-to-app/index.py(変更後)
    try:
        response = boto3_bedrock.invoke_model(
            body=body, modelId=MODEL_ID, accept=accept, contentType=contentType
        )
        response_body = json.loads(response.get("body").read().decode())
        outputText = beginning_word + response_body.get("content")[0]["text"]
        print(outputText)
        # extract contant inside <summary> tag
        summary = re.findall(r"<summary>([\s\S]*?)</summary>", outputText)[0]
        detail = re.findall(r"<thinking>([\s\S]*?)</thinking>", outputText)[0]
        twitter = re.findall(r"<twitter>([\s\S]*?)</twitter>", outputText)[0]
    except ClientError as error:
        if error.response["Error"]["Code"] == "AccessDeniedException":
            print(
                f"\x1b[41m{error.response['Error']['Message']}\
            \nTo troubeshoot this issue please refer to the following resources.\ \nhttps://docs.aws.amazon.com/IAM/latest/UserGuide/troubleshoot_access-denied.html\
            \nhttps://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html\x1b[0m\n"
            )
        else:
            raise error

    return summary, detail, twitter
```

push_notification()でsummarize_blog()の戻り値を受け取り、itemに追加します。最後にcreate_slack_message()に渡します。

```python:lambda/notify-to-app/index.py(変更後)
       summary, detail, twitter = summarize_blog(content, language=summarizer["outputLanguage"], persona=summarizer["persona"])

        # Add the summary text to notified message
        item["summary"] = summary
        item["detail"] = detail
        item["twitter"] = twitter

        if destination == "teams":
            item["detail"] = item["detail"].replace("。\n", "。\r")
            msg = create_teams_message(item)
        else:  # Slack
            item["twitter"] = item["twitter"].replace("\n", "")
            msg = create_slack_message(item)         
```

新しく追加したcreate_slack_message()でSlackにポストするWebhookのリクエストボディを組み立てます。
Share on Xリンクはここに登場します。他のシェア用リンクを追加することもできると思います。

```python:lambda/notify-to-app/index.py(変更後)
import urllib.parse
def create_slack_message(item):
    # URL encode the twitter text
    encoded_twitter_text = urllib.parse.quote("🤖 < " + item["twitter"] + " (生成AIによる要約ポスト)")

    # URL encode the RSS link separately
    encoded_rss_link = urllib.parse.quote(item["rss_link"])

    message = {
        "text": f"{item['rss_time']}\n" \
                f"<{item['rss_link']}|{item['rss_title']}>\n" \
                f"{item['summary']}\n" \
                f"{item['detail']}\n" \
                f"<https://x.com/intent/tweet?url={encoded_rss_link}&text={encoded_twitter_text}|Share on X>"
    }

    return message
```



# まとめ

What’s New Summary Notifierを利用してWhat’s New with AWS? やAWS News blogの内容を要約したものを､フリープランのSlackで受信することができました｡
AWSは様々なニュースやブログを配信しているで､それらのRSSをcdk.jsonに設定することで日々の情報収集が捗ります｡Webhookで受信できるので､他のコラボレーションツールとの相性も良さそうです｡

また、X(Twitter)などソーシャルメディアでシェアしやすいようシェアリンクを設けたことで、情報共有も容易となりました。プロンプトを工夫するなどして、よく使うツールにあわせてフォーマットを整えてシェアするなど使い道がひろがりそうです。