LoginSignup
7

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-10-13

結論

codeに興味がなく、カレンダーの追加だけを行いたい方はこちらから。
Google Accountを持っていれば、すぐさま追加できます。

本記事では、HPから情報を取得して、以下のようなカレンダーを自動生成することを目指します。
スクリーンショット 2020-10-12 21.23.11.png

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

背景

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

彼女らの活動を追うためには、HPでの「スケジュール」のページを確認するのが最も確実であるので、自分もよく拝見させていただいています。

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

また、すでにこちらのサイト のカレンダーを導入することで、主要なイベントに関してカバーすることができますが、細かいイベント(固定化されていない不定期な活動)に関してはカバーされていないように見受けられました。

そこで、これらの不満を解消すべく、「自分の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を取得する必要があります。
手順としましては、こちらの記事がわかりやすいので、ご参照ください。

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

手順

  1. HPにおけるスケジュールから必要な情報をスクレイピング
  2. Google Calendarに情報を反映させる

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

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

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

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

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

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

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

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以外を正しく取得することができていませんでした。
そこで、以下のように修正します。(上のコードでは、すでに反映されています。)

(修正前)

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

event_category_text = remove_blank(event_category.text)

(修正後)

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」といったように、次の日になってしまっている
- そもそも、日付の情報しかない
といった場合が存在するため、それらに対応した関数を用意します。

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の設定

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関数で取得します。

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

イベントの追加

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を設定する必要があります。


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;2桁になるように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〜からテレビ東京で放送されている、「日向坂で会いましょうがオススメです。
アイドルとは思えない、バラエティ能力の高さに驚愕し、惹かれるはずです。
他にも、日向坂46 OFFICIAL YouTube CHANNELで曲から知ってみるのもいいと思います。

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

matsudakonoka.png
画像掲載ブログ

参考サイト

Googleカレンダーの任意の予定をPythonで抽出する方法

PythonでGoogleカレンダーに予定を追加する

【Python】Google Calendar APIを使ってGoogle Calendarの予定を取得・追加する

pythonのdatetimeについて

━━━━━━━━━━
日向坂46ホームページ

日向坂で会いましょう

日向坂46 OFFICIAL YouTube CHANNEL

松田好花さんのブログ

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7