日向坂46のスケジュールをスクレイピングして、Google Calendarに反映させる


# 結論

codeに興味がなく、カレンダーの追加だけを行いたい方は[こちら](https://calendar.google.com/calendar/u/0?cid=dnExNGh0OW5pZWUydTIwMGc0cmtkc3VoN3NAZ3JvdXAuY2FsZW5kYXIuZ29vZ2xlLmNvbQ)から。
Google Accountを持っていれば、すぐさま追加できます。

本記事では、HPから情報を取得して、以下のようなカレンダーを自動生成することを目指します。
![スクリーンショット 2020-10-12 21.23.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/633018/c0d44f15-d4f9-7e5a-5c8e-2184d5e94061.png)

これにより、
- **Google Calendarの通知をオンにすれば、彼女らの活動を見逃すことが無くなる**
- **予め、活動予定が分かるため、他の予定を入れてしまい、見られなくなってしまうリスクを軽減できる**
といったメリットがあります。

# 背景

日向坂46は坂道グループの1つであり、「ハッピーオーラ」をモットーに活動されているグループです。
彼女らの「**ビジュアル**」はもちろん、「**明るさ**」・「**どんなことにも一生懸命に取り組む姿勢**」に惹かれる人は少なくなく、自分もその一人です。

彼女らの活動を追うためには、[HP](https://www.hinatazaka46.com/s/official/?ima=0000)での「スケジュール」のページを確認するのが最も確実であるので、自分もよく拝見させていただいています。

ただ、
- **どの日にどんな活動があるのかを確認するのに効率が悪い**（スクロールしてその日の場所まで移動しなければならないため、パッと見ではわからない）
- **必ずしも、時系列順に記述されているわけではない**（「22:00~」の予定の次の段落で「18:00~」の予定が記述されている場合がある）
という点が、個人的に不満でした。



また、すでに[こちらのサイト](https://kyoto24.life/hinatazaka46/ohisama-calendar.html) のカレンダーを導入することで、主要なイベントに関してカバーすることができますが、細かいイベント（固定化されていない不定期な活動）に関してはカバーされていないように見受けられました。

そこで、これらの不満を解消すべく、**「自分のGoogle Calendarに、彼女らのスケジュールを反映させる」**ことを実現しようと考えました。




# 実装

## バージョン

- Python 3.7.3
- bs4==0.0.1
- python-dateutil==2.8.1
- google-api-python-client==1.10.0
- google-auth-httplib2==0.0.4
- google-auth-oauthlib==0.4.1

## 準備

Google APIを取得する必要があります。
手順としましては、[こちらの記事](https://qiita.com/hajime_migi/items/d7d0a310995a99297e80)がわかりやすいので、ご参照ください。

また、定期実行を行いたい場合は、cronやHerokuを用いると良いです。
個人的にはローカルpcで実行する必要がないHerokuが好きなので、こちらを利用しています。
Herokuに関しましては、以前、[自分のhatenaブログ](https://ddnpaa.hatenablog.com/entry/2019/10/23/210541)で使い方を説明しているので、よろしければそちらをご参照ください。

## 手順

1. [HPにおけるスケジュール](https://www.hinatazaka46.com/s/official/media/list?ima=0000&dy=202010)から必要な情報をスクレイピング
2. Google Calendarに情報を反映させる

## ①HPから必要な情報をスクレイピング

### イベント情報を取得する関数

取得する情報は、以下の4つです。


- カテゴリ 
- イベント名
- 時間
- 出演メンバー

出演イベントは、同じ日に複数存在する場合があるので、

1. 各日付ごとのイベントをまとめて取得（search_event_each_date）
2. ある特定の日のイベントを取得（search_event_info）
3. ある1つのイベントの細かい情報の取得（search_detail_info）

という流れで情報を取得します。

```python
def search_event_each_date(year, month):
    url = (
        f"https://www.hinatazaka46.com/s/official/media/list?ima=0000&dy={year}{month}"
    )
    result = requests.get(url)
    soup = BeautifulSoup(result.content, features="lxml")
    events_each_date = soup.find_all("div", {"class": "p-schedule__list-group"})

    time.sleep(3)  # NOTE:サーバーへの負荷を解消

    return events_each_date


def search_event_info(event_each_date):
    event_date_text = remove_blank(event_each_date.contents[1].text)[
        :-1
    ]  # NOTE:曜日以外の情報を取得
    events_time = event_each_date.find_all("div", {"class": "c-schedule__time--list"})
    events_name = event_each_date.find_all("p", {"class": "c-schedule__text"})
    events_category = event_each_date.find_all("div", {"class": "p-schedule__head"},)
    events_link = event_each_date.find_all("li", {"class": "p-schedule__item"})

    return event_date_text, events_time, events_name, events_category, events_link


def search_detail_info(event_name, event_category, event_time, event_link):
    event_name_text = remove_blank(event_name.text)
    event_category_text = remove_blank(event_category.contents[1].text)
    event_time_text = remove_blank(event_time.text)
    event_link = event_link.find("a")["href"]
    active_members = search_active_member(event_link)

    return event_name_text, event_category_text, event_time_text, active_members


def search_active_member(link):
    try:
        url = f"https://www.hinatazaka46.com{link}"
        result = requests.get(url)
        soup = BeautifulSoup(result.content, features="lxml")
        active_members = soup.find("div", {"class": "c-article__tag"}).text
        time.sleep(3)  # NOTE:サーバー負荷の解消
    except AttributeError:
        active_members = ""

    return active_members

def remove_blank(text):
    text = text.replace("\n", "")
    text = text.replace(" ", "")
    return text
```

**【追記】** 2020/10/14のバージョンでは、メディア関係のevent以外を正しく取得することができていませんでした。
そこで、以下のように修正します。（上のコードでは、すでに反映されています。）


**（修正前）**

```python 
events_category = event_each_date.find_all(
     "div", {"class": "c-schedule__category category_media"}
)

event_category_text = remove_blank(event_category.text)
```

**（修正後）**

```python
events_category = event_each_date.find_all("div", {"class": "p-schedule__head"},)

event_category_text = remove_blank(event_category.contents[1].text)
```

これで、「誕生日」や「LIVE」のようなイベントも、正しくカレンダーに反映できます。


### 時間に関する関数

特に時間に関しては、表記によって、
- 「24:20~25:00」といったように、次の日になってしまっている
- そもそも、日付の情報しかない
といった場合が存在するため、それらに対応した関数を用意します。

```python
def over24Hdatetime(year, month, day, times):
    """
    24H以上の時刻をdatetimeに変換する
    """
    hour, minute = times.split(":")[:-1]

    # to minute
    minutes = int(hour) * 60 + int(minute)

    dt = datetime.datetime(year=int(year), month=int(month), day=int(day))
    dt += datetime.timedelta(minutes=minutes)

    return dt.strftime("%Y-%m-%dT%H:%M:%S")


def prepare_info_for_calendar(
    event_name_text, event_category_text, event_time_text, active_members
):
    event_title = f"({event_category_text}){event_name_text}"
    if event_time_text == "":
        event_start = f"{year}-{month}-{event_date_text}"
        event_end = f"{year}-{month}-{event_date_text}"
        is_date = True
    else:
        start, end = search_start_and_end_time(event_time_text)
        event_start = over24Hdatetime(year, month, event_date_text, start)
        event_end = over24Hdatetime(year, month, event_date_text, end)
        is_date = False
    return event_title, event_start, event_end, is_date

```

## ②Google Calendarに情報を反映させる

大まかな手順は、以下の通りです。

1. APIをもとに、インスタンスを生成
2. 以前追加したイベントかどうかの判定
3. イベントの追加

### APIの設定

```python
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request

def build_calendar_api():
    SCOPES = ["https://www.googleapis.com/auth/calendar"]
    creds = None
    if os.path.exists("token.pickle"):
        with open("token.pickle", "rb") as token:
            creds = pickle.load(token)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
            creds = flow.run_local_server(port=0)
        with open("token.pickle", "wb") as token:
            pickle.dump(creds, token)

    service = build("calendar", "v3", credentials=creds)

    return service
 ```
### 以前に追加したイベントかどうかの判定

追加する前に、「以前に追加したイベントであるかどうか」を判定するために、「イベント名-時刻」をもとに確認します。
そのためのリストを、search_events関数で取得します。

```python
def search_events(service, calendar_id, start):

    end_datetime = datetime.datetime.strptime(start, "%Y-%m-%d") + relativedelta(
        months=1
    )
    end = end_datetime.strftime("%Y-%m-%d")

    events_result = (
        service.events()
        .list(
            calendarId=calendar_id,
            timeMin=start + "T00:00:00+09:00",  # NOTE:+09:00とするのが肝。（UTCをJSTへ変換）
            timeMax=end + "T23:59:00+09:00",  # NOTE;来月までをサーチ期間に。
        )
        .execute()
    )
    events = events_result.get("items", [])

    if not events:
        return []
    else:
        events_starttime = change_event_starttime_to_jst(events)
        return [
            event["summary"] + "-" + event_starttime
            for event, event_starttime in zip(events, events_starttime)
        ]

def change_event_starttime_to_jst(events):
    events_starttime = []
    for event in events:
        if "date" in event["start"].keys():
            events_starttime.append(event["start"]["date"])
        else:
            str_event_uct_time = event["start"]["dateTime"]
            event_jst_time = datetime.datetime.strptime(
                str_event_uct_time, "%Y-%m-%dT%H:%M:%S+09:00"
            )
            str_event_jst_time = event_jst_time.strftime("%Y-%m-%dT%H:%M:%S")
            events_starttime.append(str_event_jst_time)
    return events_starttime
```

### イベントの追加

```python
def add_date_schedule(
    event_name, event_category, event_time, event_link, previous_add_event_lists
):
    (
        event_name_text,
        event_category_text,
        event_time_text,
        active_members,
    ) = search_detail_info(event_name, event_category, event_time, event_link)

    # カレンダーに反映させる情報の準備
    (event_title, event_start, event_end, is_date,) = prepare_info_for_calendar(
        event_name_text, event_category_text, event_time_text, active_members,
    )

    if (
        f"{event_title}-{event_start}" in previous_add_event_lists
    ):  # NOTE:同じ予定がすでに存在する場合はパス
        pass
    else:
        add_info_to_calendar(
            calendarId, event_title, event_start, event_end, active_members, is_date,
        )


def add_info_to_calendar(calendarId, summary, start, end, active_members, is_date):

    if is_date:
        event = {
            "summary": summary,
            "description": active_members,
            "start": {"date": start, "timeZone": "Japan",},
            "end": {"date": end, "timeZone": "Japan",},
        }
    else:
        event = {
            "summary": summary,
            "description": active_members,
            "start": {"dateTime": start, "timeZone": "Japan",},
            "end": {"dateTime": end, "timeZone": "Japan",},
        }

    event = service.events().insert(calendarId=calendarId, body=event,).execute()

```


## 全文

今回は、今月から3ヶ月先までの予定をGoogle Calendarに反映させるようにしています。
calendarIdだけは、自分のカレンダーのidを設定する必要があります。

```python

import time
import pickle
import os.path

import requests
from bs4 import BeautifulSoup

import datetime
from dateutil.relativedelta import relativedelta

from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request


def build_calendar_api():
    SCOPES = ["https://www.googleapis.com/auth/calendar"]
    creds = None
    if os.path.exists("token.pickle"):
        with open("token.pickle", "rb") as token:
            creds = pickle.load(token)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
            creds = flow.run_local_server(port=0)
        with open("token.pickle", "wb") as token:
            pickle.dump(creds, token)

    service = build("calendar", "v3", credentials=creds)

    return service


def remove_blank(text):
    text = text.replace("\n", "")
    text = text.replace(" ", "")
    return text


def search_event_each_date(year, month):
    url = (
        f"https://www.hinatazaka46.com/s/official/media/list?ima=0000&dy={year}{month}"
    )
    result = requests.get(url)
    soup = BeautifulSoup(result.content, features="lxml")
    events_each_date = soup.find_all("div", {"class": "p-schedule__list-group"})

    time.sleep(3)  # NOTE:サーバーへの負荷を解消

    return events_each_date


def search_start_and_end_time(event_time_text):
    has_end = event_time_text[-1] != "～"
    if has_end:
        start, end = event_time_text.split("～")
    else:
        start = event_time_text.split("～")[0]
        end = start
    start += ":00"
    end += ":00"
    return start, end


def search_event_info(event_each_date):
    event_date_text = remove_blank(event_each_date.contents[1].text)[
        :-1
    ]  # NOTE:曜日以外の情報を取得
    events_time = event_each_date.find_all("div", {"class": "c-schedule__time--list"})
    events_name = event_each_date.find_all("p", {"class": "c-schedule__text"})
    events_category = event_each_date.find_all("div", {"class": "p-schedule__head"},)
    events_link = event_each_date.find_all("li", {"class": "p-schedule__item"})

    return event_date_text, events_time, events_name, events_category, events_link


def search_detail_info(event_name, event_category, event_time, event_link):
    event_name_text = remove_blank(event_name.text)
    event_category_text = remove_blank(event_category.contents[1].text)
    event_time_text = remove_blank(event_time.text)
    event_link = event_link.find("a")["href"]
    active_members = search_active_member(event_link)

    return event_name_text, event_category_text, event_time_text, active_members

def search_active_member(link):
    try:
        url = f"https://www.hinatazaka46.com{link}"
        result = requests.get(url)
        soup = BeautifulSoup(result.content, features="lxml")
        active_members = soup.find("div", {"class": "c-article__tag"}).text
        time.sleep(3)  # NOTE:サーバー負荷の解消
    except AttributeError:
        active_members = ""

    return active_members


def over24Hdatetime(year, month, day, times):
    """
    24H以上の時刻をdatetimeに変換する
    """
    hour, minute = times.split(":")[:-1]

    # to minute
    minutes = int(hour) * 60 + int(minute)

    dt = datetime.datetime(year=int(year), month=int(month), day=int(day))
    dt += datetime.timedelta(minutes=minutes)

    return dt.strftime("%Y-%m-%dT%H:%M:%S")


def prepare_info_for_calendar(
    event_name_text, event_category_text, event_time_text, active_members
):
    event_title = f"({event_category_text}){event_name_text}"
    if event_time_text == "":
        event_start = f"{year}-{month}-{event_date_text}"
        event_end = f"{year}-{month}-{event_date_text}"
        is_date = True
    else:
        start, end = search_start_and_end_time(event_time_text)
        event_start = over24Hdatetime(year, month, event_date_text, start)
        event_end = over24Hdatetime(year, month, event_date_text, end)
        is_date = False
    return event_title, event_start, event_end, is_date


def change_event_starttime_to_jst(events):
    events_starttime = []
    for event in events:
        if "date" in event["start"].keys():
            events_starttime.append(event["start"]["date"])
        else:
            str_event_uct_time = event["start"]["dateTime"]
            event_jst_time = datetime.datetime.strptime(
                str_event_uct_time, "%Y-%m-%dT%H:%M:%S+09:00"
            )
            str_event_jst_time = event_jst_time.strftime("%Y-%m-%dT%H:%M:%S")
            events_starttime.append(str_event_jst_time)
    return events_starttime


def search_events(service, calendar_id, start):

    end_datetime = datetime.datetime.strptime(start, "%Y-%m-%d") + relativedelta(
        months=1
    )
    end = end_datetime.strftime("%Y-%m-%d")

    events_result = (
        service.events()
        .list(
            calendarId=calendar_id,
            timeMin=start + "T00:00:00+09:00",  # NOTE:+09:00とするのが肝。（UTCをJSTへ変換）
            timeMax=end + "T23:59:00+09:00",  # NOTE;来月までをサーチ期間に。
        )
        .execute()
    )
    events = events_result.get("items", [])

    if not events:
        return []
    else:
        events_starttime = change_event_starttime_to_jst(events)
        return [
            event["summary"] + "-" + event_starttime
            for event, event_starttime in zip(events, events_starttime)
        ]


def add_date_schedule(
    event_name, event_category, event_time, event_link, previous_add_event_lists
):
    (
        event_name_text,
        event_category_text,
        event_time_text,
        active_members,
    ) = search_detail_info(event_name, event_category, event_time, event_link)

    # カレンダーに反映させる情報の準備
    (event_title, event_start, event_end, is_date,) = prepare_info_for_calendar(
        event_name_text, event_category_text, event_time_text, active_members,
    )

    if (
        f"{event_title}-{event_start}" in previous_add_event_lists
    ):  # NOTE:同じ予定がすでに存在する場合はパス
        pass
    else:
        add_info_to_calendar(
            calendarId, event_title, event_start, event_end, active_members, is_date,
        )


def add_info_to_calendar(calendarId, summary, start, end, active_members, is_date):

    if is_date:
        event = {
            "summary": summary,
            "description": active_members,
            "start": {"date": start, "timeZone": "Japan",},
            "end": {"date": end, "timeZone": "Japan",},
        }
    else:
        event = {
            "summary": summary,
            "description": active_members,
            "start": {"dateTime": start, "timeZone": "Japan",},
            "end": {"dateTime": end, "timeZone": "Japan",},
        }

    event = service.events().insert(calendarId=calendarId, body=event,).execute()


if __name__ == "__main__":

    # -------------------------step1:各種設定-------------------------
    # API系
    calendarId = (
        "〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜"  # NOTE:自分のカレンダーID
    )
    service = build_calendar_api()

    # サーチ範囲
    num_search_month = 3  # NOTE;3ヶ月先の予定までカレンダーに反映
    current_search_date = datetime.datetime.now()
    year = current_search_date.year
    month = current_search_date.month

    # -------------------------step2.各日付ごとの情報を取得-------------------------
    for _ in range(num_search_month):
        events_each_date = search_event_each_date(year, month)
        for event_each_date in events_each_date:

            # step3: 特定の日の予定を一括で取得
            (
                event_date_text,
                events_time,
                events_name,
                events_category,
                events_link,
            ) = search_event_info(event_each_date)

            event_date_text = "{:0=2}".format(
                int(event_date_text)
            )  # NOTE;２桁になるように0埋め（ex.0-> 01）
            start = f"{year}-{month}-{event_date_text}"
            previous_add_event_lists = search_events(service, calendarId, start)

            # step4: カレンダーへ情報を追加
            for event_name, event_category, event_time, event_link in zip(
                events_name, events_category, events_time, events_link
            ):
                add_date_schedule(
                    event_name,
                    event_category,
                    event_time,
                    event_link,
                    previous_add_event_lists,
                )

        # step5:次の月へ
        current_search_date = current_search_date + relativedelta(months=1)
        year = current_search_date.year
        month = current_search_date.month



```




# 最後に

本記事では、日向坂46のスケジュールをGoogle Calendarに反映させる方法を紹介しました。
これにより、
- **Google Calendarの通知をオンにすれば、彼女らの活動を見逃すことが無くなる**
- **予め、活動予定が分かるため、他の予定を入れてしまい、見られなくなってしまうリスクを軽減できる**
といったメリットがあります。

今回は、日向坂46にフォーカスをおきましたが、「①HPから必要な情報をスクレイピング」を変更すれば、②を使い回して、任意の方のスケジュールをGoogle Calendarに反映させることができます。

━━━━━━━━━━

もし日向坂46を知らない方は、これを気に興味を持ってみては如何でしょうか。
個人的には、**毎週日曜日25:05〜からテレビ東京で放送されている、「[日向坂で会いましょう](https://www.tv-tokyo.co.jp/hinatazaka/)」**がオススメです。
アイドルとは思えない、バラエティ能力の高さに驚愕し、惹かれるはずです。
他にも、[日向坂46 OFFICIAL YouTube CHANNEL](https://www.youtube.com/channel/UCR0V48DJyWbwEAdxLL5FjxA)で曲から知ってみるのもいいと思います。

また、完全に余談になりますが、僕の最近の推しは、松田好花さんで、笑顔がとても素敵な方です。
ひとつよしなに。

![matsudakonoka.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/633018/b0ce4f50-5216-b878-c594-b73e9696a4cb.png)
[画像掲載ブログ](https://www.hinatazaka46.com/s/official/diary/detail/30908?ima=0000&cd=member)


# 参考サイト
[Googleカレンダーの任意の予定をPythonで抽出する方法](https://qiita.com/yuki_imamura/items/401200d3f65b68bf5912)

[PythonでGoogleカレンダーに予定を追加する](https://qiita.com/hajime_migi/items/d7d0a310995a99297e80)

[【Python】Google Calendar APIを使ってGoogle Calendarの予定を取得・追加する](https://non-dimension.com/python-googlecalendarapi/)

[pythonのdatetimeについて](https://qiita.com/t-iguchi/items/a0bb8a5f273b319e5755)

━━━━━━━━━━
[日向坂46ホームページ](https://www.hinatazaka46.com/s/official/?ima=0000)

[日向坂で会いましょう](https://www.tv-tokyo.co.jp/hinatazaka/)

[日向坂46 OFFICIAL YouTube CHANNEL](https://www.youtube.com/channel/UCR0V48DJyWbwEAdxLL5FjxA)

[松田好花さんのブログ](https://www.hinatazaka46.com/s/official/diary/member/list?ima=0000&ct=18)