2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[WIP]乃木坂46の公式ホームページのスケジュールをスクレイピングして、自分のGoogleカレンダーに同期させる

Last updated at Posted at 2024-05-05

導入

最近乃木坂46のファンになり、毎日公式ホームページを開いてスケジュールを確認する日々を送っています。

ただ、この公式ホームページの動作が少し重たいため、毎回の確認に時間がかかり、少し面倒に感じていました。
そこで、毎回ホームページを開かなくても済むように、自分のGoogleカレンダーにスケジュール情報を同期させるコードを書いてみることにしました。

その方法としては、公式ホームページからスケジュール情報をスクレイピングし、Google Calendar APIを利用して自分のGoogleカレンダーに記入することにしました。

コードの作成にはこの記事を参考にしました。

ただし、乃木坂46の公式ホームページのスケジュール情報は動的に取得される上、HTMLの構造が異なっていたため、この記事をそのまま利用することはできませんでした。一部異なる点を調整しました。

とにかく使いたい人へ

一番下に記載している

  • get_schedule.py
  • write_calendar.py
    を使えば動きます

目次

  1. 環境の設定
    必要なライブラリのインストール
    Google APIの認証情報を取得し、プロジェクトフォルダ内に保存
  2. ホームページからスケジュール情報をスクレイピング
    SeleniumとBeautifulSoupを使用して乃木坂46の公式ページからスケジュール情報を取得するコードを書く
    スケジュール情報を整形してPythonリストに保存
  3. Googleカレンダーとの同期
    取得したスケジュール情報をGoogle Calendar APIを使用して自分のカレンダーにイベントとして追加

環境設定

コード実行に必要な環境設定を行います

実行環境

実行環境は以下の通りです

  • Python 3.12.2
  • google-api-python-client == 2.127.0
  • google-auth-httplib2 == 0.2.0
  • google-auth-oauthlib == 1.2.0

必要なライブラリのインストール

Google クライアント ライブラリをインストールします。

pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib

続きをまた書きます

ホームページからスケジュール情報をスクレイピング

取得したスケジュール情報をGoogleカレンダーに入力

実際にGoogleカレンダーを開いてみるとこんな感じになります。

スクリーンショット 2024-05-05 213134.png

完成版コード

最終的にはこの二つのコードを使用すれば、動きます。

get_schedule.py
from datetime import datetime,timedelta
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from dateutil.relativedelta import relativedelta
from urllib.parse import urlparse, parse_qs
import pytz

#スクレイピング用のクラス
class Scraper:
    def __init__(self, base_url): #base_urlは公式ホームページのURL
        #属性の設定
        self.base_url = base_url
        self.driver = None
        self.setup_driver()

    #WebDriverの設定をするメソッド
    def setup_driver(self):
        chrome_options = Options()
        chrome_options.add_argument("--headless") # GUIがない環境で動作するためのheadlessオプションを指定
        chrome_options.add_argument("--no-sandbox") # sandboxプロセスを無効化
        chrome_options.add_argument("--disable-dev-shm-usage") # メモリ不足を防ぐためのオプション
        self.driver = webdriver.Chrome(options = chrome_options ) # ChromeのWebDriverオブジェクトを作成

    def fetch_schedule(self, date):
        url = self.base_url + f"&dy={date}"
        self.driver.get(url)

        self.driver.implicitly_wait(20)
        html = self.driver.page_source
        return BeautifulSoup(html, 'html.parser')

    def close(self):
        if self.driver:
            self.driver

#nヶ月後の日付を指定したフォーマットで返す関数
def get_formatted_date_n_months_later(n, date_format='%Y%m'):
    """
    :param n: int, 加算する月数
    :param date_format: str, 出力する日付のフォーマット
    :return: str, フォーマットされた日付
    """
    current_date = datetime.now()
    future_date = current_date + relativedelta(months=n)
    formatted_future_date = future_date.strftime(date_format)
    return formatted_future_date


# 誕生日タグには日付情報が記載されていないので、親要素に移動してから日付情報を取得
def extract_date_from_parent(div): 
    #親要素の取得
    parent_day_div = div.find_parent('div',class_='sc--day')
    id_div = parent_day_div.find('div', class_='sc--day__hd js-pos a--tx')
    
    if parent_day_div:
        day = id_div.get('id') # IDから日付情報を取得
    return day

#イベントごとにまとめたHTMLのリストから、テキスト情報を抽出する関数
def extract_event(divs, formatted_future_date):
    # 空のリストを初期化
    events = []

    date_obj = datetime.strptime(formatted_future_date, '%Y%m')
    formatted_ym = datetime.strftime(date_obj, '%Y-%m')

    # 各div要素をループ処理
    for div in divs:

        # 各イベントのdivから必要な情報を抽出
        a_tag = div.find('a', class_='m--scone__a')
        if not a_tag:
            continue
        
        category = div.find('p', class_='m--scone__cat__name')
        title = div.find('p', class_='m--scone__ttl')
        time_info = div.find('p', class_='m--scone__st')
        link = a_tag['href']

        #誕生日の場合親要素から日付情報を取得
        if category.text.strip().replace('<br/>', ' ') == "誕生日":
            day = extract_date_from_parent(div)
        else:
            # リンクから日付情報を解析
            query_params = parse_qs(urlparse(link).query)
            day = query_params.get('wd02', [''])[0]

        date_str = f"{formatted_ym}-{day}"
        date =datetime.strptime(date_str, '%Y-%m-%d').date()

        # 各情報が存在するかチェックし、存在しない場合は適切に処理
        event_info = {
            "category": category.text.strip().replace('<br/>', ' ') if category else "No Category",
            "title": title.text.strip() if title else "No Title",
            "time": time_info.text.strip() if time_info else "All day",
            "link": link,
            "date": date
        }
        # イベント情報をメインのリストに追加
        events.append(event_info)
    return events

def adjust_over_midnight_time(time_str, date):
    # イベント時刻が24:00以上の場合、時間を24で割った余りと日付を繰り上げ
    hours, minutes = map(int, time_str.split(':'))
    if hours >= 24:
        hours -= 24
        date += timedelta(days=1)
    return datetime.combine(date, datetime.strptime(f"{hours}:{minutes}", "%H:%M").time())

#Googleカレンダー形式に変換する関数
def format_event_for_google_calendar(event):
    # タイムゾーンの設定
    timezone = pytz.timezone("Asia/Tokyo")

    # イベント開始と終了時間をパース
    if "time" in event and event["time"] != "All day":
        time_parts = event["time"].split("")
        start_time_str = time_parts[0]
        start_datetime = adjust_over_midnight_time(start_time_str, event["date"])

        # 終了時刻が記載されているか確認
        if len(time_parts) > 1 and time_parts[1]:
            end_time_str = time_parts[1]
            end_datetime = adjust_over_midnight_time(end_time_str, event["date"])
            end_time_provided = True
        else:
            # 終了時刻が記載されていない場合、1時間後を仮定
            end_datetime = start_datetime + timedelta(hours=1)
            end_time_provided = False


        # タイムゾーンを適用
        start_datetime = timezone.localize(start_datetime)
        end_datetime = timezone.localize(end_datetime)

        # Google カレンダー形式のイベントデータを作成
        description = f"カテゴリー: {event['category']} Link: {event['link']}"
        #終了時間が未定の場合、説明文に追記
        if not end_time_provided:
            description += " (終了時間未定)"

        google_event = {
            "summary": event["title"],
            "location": "",
            "description": description,
            "start": {
                "dateTime": start_datetime.isoformat(),
                "timeZone": "Asia/Tokyo"
            },
            "end": {
                "dateTime": end_datetime.isoformat(),
                "timeZone": "Asia/Tokyo"
            },
            "reminders": {
                "useDefault": False,
                "overrides": [
                    {"method": "popup", "minutes": 30}
                ]
            }
        }
    else:
        # 終日イベントの場合
        start_date = event["date"]
        end_date = start_date + timedelta(days=1)  # 終日イベントの終了日は次の日の0:00とする

        google_event = {
            "summary": event["title"],
            "location": "",
            "description": f"Category: {event['category']} Link: {event['link']}",
            "start": {
                "date": start_date.strftime('%Y-%m-%d'),
            },
            "end": {
                "date": end_date.strftime('%Y-%m-%d'),
            },
            "reminders": {
                "useDefault": False,
                "overrides": [
                    {"method": "popup", "minutes": 30}
                ]
            }
        }

    return google_event


def get_nogizaka_schedule(n_month):
    #乃木坂の公式ホームページのURL
    base_url = "https://www.nogizaka46.com/s/n46/media/list?"
    #Scraperクラスのインスタンスを作成
    nogizaka =Scraper(base_url)

    formatted_future_date = get_formatted_date_n_months_later(n_month, '%Y%m')

    soup = nogizaka.fetch_schedule(formatted_future_date)
    # イベントごとのHTMLをリストに格納
    divs = soup.find_all('div', class_='m--scone')

    # イベント情報の抽出
    events = extract_event(divs,formatted_future_date)
    # Google カレンダー形式に変換
    google_events = [format_event_for_google_calendar(event) for event in events]

    #ドライバーを閉じる
    nogizaka.close()
    return google_events

if __name__ == "__main__":
    # 3ヶ月後のイベント情報を取得
    num_month =3
    events = get_nogizaka_schedule(num_month)
    print(events)
write_calendar.py
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from get_schedule import get_nogizaka_schedule
from dotenv import load_dotenv
import os.path


# スコープを設定

#APIの構築
def build_api():
    SCOPES = ['https://www.googleapis.com/auth/calendar']
    creds = None
    if os.path.exists('secret_folder/token.json'):
        creds = Credentials.from_authorized_user_file('secret_folder/token.json', SCOPES)
    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('secret_folder/credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
            with open('secret_folder/token.json', 'w') as token:
                token.write(creds.to_json())
    service = build('calendar', 'v3', credentials=creds)
    if service is None:
        print("Failed to create the Google Calendar service")
    return service

#  イベントの追加
def add_event_to_calendar(event):
    service = build_api()
    #メインのカレンダーに追加する場合はcalenderID = "primary"を指定
    #別のカレンダーに追加する場合はそのカレンダーのIDを指定

    load_dotenv()  # .env ファイルから環境変数を読み込む
    # カレンダーIDを環境変数から取得
    calendar_id = os.getenv('GOOGLE_CALENDAR_ID')
    
    event_result = service.events().insert(calendarId = calendar_id, body=event).execute()
    print(f"Event created: {event_result.get('htmlLink')}")

events = get_nogizaka_schedule(0)
for event in events:
     add_event_to_calendar(event)

参考文献

2
3
0

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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?