AtCoder Calendar

# AtCoder Calendarについて
AtCoderの予定をGoogle Calendarにまとめて追加できるアプリケーションを作りました。今回はAtCoderのAPIが公開されていなかったため、対象のURL(https://atcoder.jp/contests/)
からスクレイピングをしてデータを取得しています。
フロントエンドのコードはGitHub（https://github.com/Akito08/atcoder-calendar）
にのせているので合わせてご確認ください。

# アプリケーションの使用例
・Googleアカウントを使って、アプリにログインします。
・ユーザーの方はコンテストの開催年と月をベースにコンテストを検索します。
・「Google Calendarに予定を追加」というボタンを押すと、チェックボックスにチェックの入った予定がユーザーの方のGoogle Calendarに追加されます。


![スクリーンショット 2023-11-13 21.42.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1655661/08310f7e-564f-0214-3a8a-d3c005296cbb.png)



# アーキテクチャについて
アーキテクチャとしては EventBridge, Lambda, DynamoDB, API Gateway、フロントエンドでJavascript、ライブラリでreactを使っています。

![チームの文書 (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1655661/b8b78911-0223-438a-56bf-216198daac36.png)



# DBについて
データベースはDynamoDBを使いました。スクレイピングをしてきた予定を下記のように保存します。

| カラム名 | データ型 | 説明               |
| -------- | -------- | ------------------ |
| contest_year_month      | String      | コンテストの開催年と月     |
| contest_start_time| String  | コンテストの開始日時をISO8601形式で保存     |
| contest_category    | String  |  コンテストの分類(ABC、ARCなど）|
| contest_end_time   | String | コンテストの終了日時をISO8601形式で保存  |
| contest_name  | String  | コンテストの名前 |

使用例)
![スクリーンショット 2023-11-13 19.25.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1655661/d1da4c8c-1c62-8e47-f033-d92c6f7427d0.png)



# Webスクレイピングについて
Web スクレイピングツール（ライブラリ）として Beautiful Soup を使うため、Lambda ランタイムは Python を選びました。以下はスクレイピングをし、取得したデータをDynamoDBに保存するコードです。
```python
import os
import json
import boto3
from bs4 import BeautifulSoup
from datetime import datetime, timedelta
import requests

url = "https://atcoder.jp/contests/"
dynamodb = boto3.resource("dynamodb")
table_name = os.environ["TABLE_NAME"]
table = dynamodb.Table(table_name)

CONTEST_TYPES = {
    "AtCoder Beginner Contest": "ABC",
    "AtCoder Regular Contest": "ARC",
    "AtCoder Grand Contest": "AGC",
    "AtCoder Heuristic Contest": "AHC"
}

def add_contest_category(contest_name):
    for contest_type, category in CONTEST_TYPES.items():
        if contest_type in contest_name:
            return category
    return ""
        
def calculate_contest_end_time(start_time, duration):
    hours, minutes = map(int, duration.split(":"))
    end_time = start_time + timedelta(hours=hours, minutes=minutes)
    return end_time
    
def lambda_handler(event, context):
    scan_response = table.scan(ProjectionExpression="contest_name")
    existed_contest_names = set(item["contest_name"] for item in scan_response["Items"])

    try:
        response = requests.get(url)
        response.raise_for_status()
    except requests.RequestException as e:
        return {
            "statusCode": 500,
            "body": json.dumps(f"Failed to retrieve data: {str(e)}")
        }

    soup = BeautifulSoup(response.text, "html.parser")
    contests = soup.find_all("tr")
    
    for i in range(4, 30):
        contest = [x for x in contests[i].stripped_strings]
        
        if len(contest) == 5:
            break
        
        contest_name = contest[3]
        if contest_name in existed_contest_names:
            continue
    
        contest_year_month = contest[0][:7]
        contest_category = add_contest_category(contest_name)
        contest_start_time = datetime.fromisoformat(contest[0][:10] + "T" + contest[0][11:-2] + ":00")
        duration = contest[4]
        contest_end_time = calculate_contest_end_time(contest_start_time, duration)

        item = {
            "contest_year_month": contest_year_month, 
            "contest_start_time": contest_start_time.isoformat(),
            "contest_end_time" : contest_end_time.isoformat(),
            "contest_name": contest_name,
            "contest_category": contest_category
        }

        try:
            table.put_item(Item=item)
            print(f"Saved: {contest_name} - {contest_start_time}")
        except Exception as e:
            print(f"Error saving {contest_name}: {str(e)}")

    return {
        "statusCode": 200,
        "body": json.dumps("Data scraped and saved to DynamoDB")
    }

```

# EventBridge
定期的に、Webスクレイピング = 上記のLambda関数を実行するために EventBridgeを使用しています。
EventBridgeの設定で、毎日午前1時に上記の関数が実行されます。

# コンテストの予定をClientに返す関数

```python
import json
import boto3
import os
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource("dynamodb")
table_name = os.environ["TABLE_NAME"]
table = dynamodb.Table(table_name)

def lambda_handler(event, context):
    try:
        contest_year_month = event["queryStringParameters"]["contest_year_month"]
    except KeyError:
        return {
            "statusCode": 400,
            "body": json.dumps({"message": "contest_year_month parameter is missing"}),
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Content-Type": "application/json"
            },
        }

    try:
        response = table.query(
            KeyConditionExpression=Key("contest_year_month").eq(contest_year_month)
        )
    except Exception as e:
        return {
            "statusCode": 500,
            "body": json.dumps({"message": str(e)}),
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Content-Type": "application/json"
            },
        }

    items = response["Items"]
    return {
        "statusCode": 200,
        "body": json.dumps(items, ensure_ascii=False),
        "headers": {
            "Access-Control-Allow-Origin": "*",
            "Content-Type": "application/json"
        },
    }


```