特定のページが更新されたら通知する仕組みを作ってみた

## はじめに
RSS対応のサイトだと、更新情報追いやすいけど、RSS非対応のページも追いたいよね。って人向けの記事です。
RSS対応しているサイトなら、RSSリーダーを使った方が早いです
また、Discordのチャンネルにも通知がしたかったので、メールとDiscord両方に通知を行っています。

Discord側にWebhook用のURLが必要ですが、本記事では紹介しません
参考サイトのZennの記事が細かく書かれていますので、そちらをご覧ください

なお、この仕組みは更新を検知したいサイトに確認リクエストを送ります。
高頻度で設定してしまうと、サーバーに負荷がかかる為、
**高頻度での設定**はしないようにお願いします

### 参考サイト
https://zenn.dev/lambta/articles/5edbda4ccb1ec6

https://qiita.com/Kalmari/items/bf07b8345054f4af605e

https://dev.classmethod.jp/articles/daily-check-for-non-rss-page/

## 構成図

![サイト更新検知_アーキテクチャー図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3747609/5f88df9f-278a-83a6-5239-260f116b5b98.jpeg)

## コードについて(Lambda)

コードについては、基本的に、クラスメソッドさんの記事を参考にしています
Discordの通知部分については、AmazonBedrock (claude3 sonnet)で、調整しながらコーディングをしています
おかしなところがあったら、教えてください(笑)
また、**バージニア指定**となっています。
他リージョンでの展開する場合は、SNSのARNとLambdaのlayer部分の調整が必要になりますので、ご注意ください

```python
import json
import hashlib
import requests
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb')
sns = boto3.client('sns')
TABLE_NAME = 'website-monitor'
SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456781234:website-monitor'

URLS = ["https://xxx","https://yyy","https://zzz",]

def lambda_handler(event, context):
    changes_detected = []
    for URL in URLS:
        # URLからコンテンツを取得
        response = requests.get(URL)
        content = response.text
        # コンテンツをハッシュ化
        current_hash = hashlib.sha256(content.encode()).hexdigest()
        # DynamoDBから前回のハッシュを取得
        table = dynamodb.Table(TABLE_NAME)
        try:
            response = table.get_item(Key={'URL': URL})
            previous_hash = response['Item']['hash'] if 'Item' in response else None
        except ClientError as e:
            print(e.response['Error']['Message'])
            previous_hash = None
        # ハッシュを比較
        print("Hashed:", current_hash, previous_hash)
        if current_hash != previous_hash:

            url = "https://discord.com/api/webhooks/1234567890123456789/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  # URLを入力する

            # response = requests.post(url, data=json.dumps(payload), headers=headers)
            MESSAGE_CONTENT = f"ページが更新されています。\n確認しましょう。\n{URL}"
            REQUEST_BODY = {"content": MESSAGE_CONTENT}
            response = requests.post(url, json=REQUEST_BODY)

            if response.status_code == 204:
                print("Webhook送信に成功しました。")
            else:
                print(f"Webhook送信に失敗しました。ステータスコード: {response.status_code}")


            # ハッシュが異なる場合は、SNSに通知
            message = f'Website content at {URL} has changed.'
            sns.publish(TopicArn=SNS_TOPIC_ARN, Message=message)
            # 新しいハッシュをDynamoDBに保存
            table.put_item(Item={'URL': URL, 'hash': current_hash})
            changes_detected.append(URL)
    if changes_detected:
        return {
            'statusCode': 200,
            'body': json.dumps(f'Changes detected in: {", ".join(changes_detected)}; notifications sent.')
        }
    else:
        # ハッシュが同じ場合は何もしない
        return {
            'statusCode': 200,
            'body': json.dumps('No change in website content for all URLs.')
        }
```

## IaC化してみた

簡単に展開できるよう、AWS CloudFormationに落とし込んでみた

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Website monitoring Lambda function, DynamoDB table, and SNS topic

Parameters:
  NameTag:
    Type: String
    Description: 'Common resource name'
    Default: 'website-monitor'
  EmailAddress:
    Type: String
    Description: 'your email address'
    Default: 'xxx@xxx.yyy.zzz'
  DiscordWebhookURL:
    Type: String
    Description: 'Discord Webhook URL'
    Default: 'https://discord.com/api/webhooks/1234567890123456789/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
  ScheduleInterval:
    Type: String
    Description: 'Crawl interval'
    Default: 'rate(12 hours)'
  MonitoringURLs:
    Type: String
    Description: 'URL to monitor (multiple settings possible)'
    Default: '"https://xxx","https://yyy","https://zzz"'

Resources:
  WebsiteMonitoringFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${NameTag}-lambda'
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          import json
          import hashlib
          import requests
          import boto3
          from botocore.exceptions import ClientError

          dynamodb = boto3.resource('dynamodb')
          sns = boto3.client('sns')
          TABLE_NAME = '${NameTag}'
          SNS_TOPIC_ARN = '${WebsiteMonitorTopic}'

          URLS = [${MonitoringURLs},]

          def lambda_handler(event, context):
              changes_detected = []
              for URL in URLS:
                  # URLからコンテンツを取得
                  response = requests.get(URL)
                  content = response.text
                  # コンテンツをハッシュ化
                  current_hash = hashlib.sha256(content.encode()).hexdigest()
                  # DynamoDBから前回のハッシュを取得
                  table = dynamodb.Table(TABLE_NAME)
                  try:
                      response = table.get_item(Key={'URL': URL})
                      previous_hash = response['Item']['hash'] if 'Item' in response else None
                  except ClientError as e:
                      print(e.response['Error']['Message'])
                      previous_hash = None
                  # ハッシュを比較
                  print("Hashed:", current_hash, previous_hash)
                  if current_hash != previous_hash:

                      url = "${DiscordWebhookURL}"  # URLを入力する

                      # response = requests.post(url, data=json.dumps(payload), headers=headers)
                      MESSAGE_CONTENT = f"ページが更新されています。\n確認しましょう。\n{URL}"
                      REQUEST_BODY = {"content": MESSAGE_CONTENT}
                      response = requests.post(url, json=REQUEST_BODY)

                      if response.status_code == 204:
                          print("Webhook送信に成功しました。")
                      else:
                          print(f"Webhook送信に失敗しました。ステータスコード: {response.status_code}")


                      # ハッシュが異なる場合は、SNSに通知
                      message = f'Website content at {URL} has changed.'
                      sns.publish(TopicArn=SNS_TOPIC_ARN, Message=message)
                      # 新しいハッシュをDynamoDBに保存
                      table.put_item(Item={'URL': URL, 'hash': current_hash})
                      changes_detected.append(URL)
              if changes_detected:
                  return {
                      'statusCode': 200,
                      'body': json.dumps(f'Changes detected in: {", ".join(changes_detected)}; notifications sent.')
                  }
              else:
                  # ハッシュが同じ場合は何もしない
                  return {
                      'statusCode': 200,
                      'body': json.dumps('No change in website content for all URLs.')
                  }
      Layers:
        - arn:aws:lambda:us-east-1:770693421928:layer:Klayers-p312-requests:3
      MemorySize: 512
      Timeout: 300
      Architectures: 
        - arm64
      Tags:
        - Key: Name
          Value: !Ref NameTag

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: DynamoDBAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'dynamodb:GetItem'
                  - 'dynamodb:PutItem'
                Resource: !GetAtt WebsiteHashesTable.Arn
        - PolicyName: SNSPublishAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'sns:Publish'
                Resource: !Ref WebsiteMonitorTopic
      Tags:
        - Key: Name
          Value: !Ref NameTag

  WebsiteHashesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: URL
          AttributeType: S
      KeySchema:
        - AttributeName: URL
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST
      TableName: !Ref NameTag
      Tags:
        - Key: Name
          Value: !Ref NameTag

  WebsiteMonitorTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Ref NameTag
      DisplayName: !Ref NameTag
      Tags:
        - Key: Name
          Value: !Ref NameTag

  EmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint: !Ref EmailAddress
      Protocol: email
      TopicArn: !Ref WebsiteMonitorTopic

  ScheduledRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Scheduled rule to run every 12 hours
      ScheduleExpression: !Ref ScheduleInterval
      State: ENABLED
      Targets:
        - Id: WebsiteMonitoringFunction
          Arn: !GetAtt WebsiteMonitoringFunction.Arn

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt WebsiteMonitoringFunction.Arn
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt ScheduledRule.Arn

Outputs:
  LambdaFunctionName:
    Description: Name of the Lambda function
    Value: !Ref WebsiteMonitoringFunction
    Export:
      Name: !Sub '${NameTag}-lambda-function-name'
  DynamoDBTableName:
    Description: Name of the DynamoDB table
    Value: !Ref WebsiteHashesTable
    Export:
      Name: !Sub '${NameTag}-dynamodb-table-name'
  SNSTopicArn:
    Description: ARN of the SNS topic
    Value: !Ref WebsiteMonitorTopic
    Export:
      Name: !Sub '${NameTag}-sns-topic-arn'
```

### パラメータについて
EmailAddress
→更新されたことを通知するメールアドレス

DiscordWebhookURL
→発行したDiscordWebhookURL

ScheduleInterval
→更新確認頻度(デフォルトでは12時間としてます)

MonitoringURLs
→更新情報をキャッチアップしたいURL(複数設定可)

## 最後に
毎日新しい情報が出る時代です
効率よく情報収集していきましょう！