[PYTHON] Racler le calendrier de Hinatazaka 46 et le refléter dans Google Agenda

Conclusion

Si vous n'êtes pas intéressé par le code et que vous souhaitez simplement ajouter un calendrier ici. Si vous disposez d'un compte Google, vous pouvez l'ajouter immédiatement.

Dans cet article, nous visons à obtenir des informations auprès de HP et à générer automatiquement le calendrier suivant. スクリーンショット 2020-10-12 21.23.11.png

Cette volonté

Contexte

Hinatazaka 46 est l'un des groupes de pente, et est un groupe dont la devise est "Happy Aura". Bien sûr, il y a beaucoup de gens qui sont attirés par leur "** visuel ", " luminosité " et " attitude à travailler dur sur n'importe quoi **", et je suis l'un d'eux.

Le moyen le plus fiable de suivre leurs activités est de consulter la page «Calendrier» sur HP. Je le vois souvent.

cependant,

Aussi, en présentant le calendrier de ce site, il est possible de couvrir des événements majeurs, mais des événements détaillés (corrigé). Il semblait qu'il n'était pas couvert pour (activités irrégulières qui n'étaient pas faites).

Alors, afin d'éliminer ces insatisfactions, j'ai pensé à réaliser ** "Refléter leur emploi du temps dans mon Google Agenda" **.

la mise en oeuvre

version

Préparation

Vous devez obtenir l'API Google. Pour la procédure, veuillez vous référer à cet article pour une compréhension facile.

De plus, si vous souhaitez effectuer une exécution régulière, il est préférable d'utiliser cron ou Heroku. Personnellement, j'aime Heroku, qui n'a pas besoin de fonctionner sur mon ordinateur local, alors je l'utilise. En ce qui concerne Heroku, j'ai expliqué comment l'utiliser dans Mon blog hatena avant, veuillez donc vous y référer si vous le souhaitez.

procédure

  1. Récupération des informations nécessaires sur Schedule on HP
  2. Refléter les informations dans Google Agenda

① Récupération des informations nécessaires auprès de HP

Fonction pour obtenir des informations sur l'événement

Les informations à acquérir sont les quatre suivantes.

--Catégorie

Puisqu'il peut y avoir plusieurs événements d'apparition le même jour,

  1. Obtenez tous les événements pour chaque date (search_event_each_date)
  2. Obtenez l'événement d'un jour spécifique (search_event_info)
  3. Obtenez des informations détaillées sur un événement (search_detail_info)

Les informations sont acquises dans le flux.

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:Éliminez la charge sur le serveur

    return events_each_date


def search_event_info(event_each_date):
    event_date_text = remove_blank(event_each_date.contents[1].text)[
        :-1
    ]  # NOTE:Obtenez des informations autres que le jour
    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:Éliminez la charge du serveur
    except AttributeError:
        active_members = ""

    return active_members

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

** [Ajout] ** Dans la version du 14/10/2020, il n'était pas possible d'acquérir correctement autre chose que des événements liés aux médias. Par conséquent, modifiez-le comme suit. (Dans le code ci-dessus, c'est déjà reflété.)

** (avant correction) **

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

event_category_text = remove_blank(event_category.text)

(Modifié)

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

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

Désormais, les événements tels que "Anniversaire" et "EN DIRECT" peuvent être correctement reflétés dans le calendrier.

Fonctions liées au temps

Surtout en ce qui concerne le temps, selon la notation ――C'est le lendemain, comme "24: 20 ~ 25: 00"

def over24Hdatetime(year, month, day, times):
    """
Convertir le temps sur 24H en 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

② Refléter les informations dans Google Agenda

La procédure générale est la suivante.

  1. Créez une instance basée sur l'API
  2. Déterminez si l'événement a déjà été ajouté
  3. Ajouter un événement

Paramètres de l'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

Déterminer s'il s'agit d'un événement précédemment ajouté

Avant d'ajouter, vérifiez en vous basant sur "Event Name-Time" pour déterminer "s'il s'agit d'un événement précédemment ajouté". Obtenez la liste pour cela avec la fonction 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:Il est important de le mettre à 00. (Convertir UTC en JST)
            timeMax=end + "T23:59:00+09:00",  # NOTE;Période de recherche jusqu'au mois prochain.
        )
        .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

Ajouter un évènement

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)

    #Préparation des informations à refléter dans le calendrier
    (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:Passer si le même rendez-vous existe déjà
        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()

Texte intégral

Cette fois, j'essaie de refléter le calendrier de ce mois à 3 mois à l'avance dans Google Agenda. Seul calendarId doit définir l'identifiant de mon calendrier.


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:Éliminez la charge sur le serveur

    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:Obtenez des informations autres que le jour
    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:Élimine la charge du serveur
    except AttributeError:
        active_members = ""

    return active_members


def over24Hdatetime(year, month, day, times):
    """
Convertir le temps sur 24H en 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:Il est important de le mettre à 00. (Convertir UTC en JST)
            timeMax=end + "T23:59:00+09:00",  # NOTE;Période de recherche jusqu'au mois prochain.
        )
        .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)

    #Préparation des informations à refléter dans le calendrier
    (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:Passer si le même rendez-vous existe déjà
        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:divers paramètres-------------------------
    #Système API
    calendarId = (
        "〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜"  # NOTE:Mon identifiant de calendrier
    )
    service = build_calendar_api()

    #Gamme de recherche
    num_search_month = 3  # NOTE;Reflété dans le calendrier jusqu'à l'horaire 3 mois à l'avance
    current_search_date = datetime.datetime.now()
    year = current_search_date.year
    month = current_search_date.month

    # -------------------------step2.Obtenez des informations pour chaque date-------------------------
    for _ in range(num_search_month):
        events_each_date = search_event_each_date(year, month)
        for event_each_date in events_each_date:

            # step3:Obtenez les horaires pour un jour spécifique à la fois
            (
                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;Remplissez avec 0 pour qu'il devienne 2 chiffres (ex.0-> 01)
            start = f"{year}-{month}-{event_date_text}"
            previous_add_event_lists = search_events(service, calendarId, start)

            # step4:Ajouter des informations au calendrier
            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:Au mois suivant
        current_search_date = current_search_date + relativedelta(months=1)
        year = current_search_date.year
        month = current_search_date.month



finalement

Dans cet article, j'ai présenté comment refléter le calendrier de Hinatazaka 46 dans Google Agenda. Cette volonté

Cette fois, nous nous sommes concentrés sur Hinatazaka 46, mais si vous modifiez "(1) Récupération des informations nécessaires auprès de HP", vous pouvez réutiliser (2) et refléter le calendrier de toute personne dans Google Agenda.

━━━━━━━━━━

Si vous ne connaissez pas Hinatazaka 46, pourquoi ne vous intéressez-vous pas à cela? Personnellement, "Rendez-vous à Hinatazaka", qui est diffusé sur TV Tokyo tous les dimanches à partir de 25h05. ** est recommandé. Vous serez étonné et attiré par la grande variété de capacités que vous ne pouvez pas considérer comme une idole. De plus, je pense qu'il est bon de savoir à partir de la chanson sur Hyugazaka 46 OFFICIAL YouTube CHANNEL.

En passant, ma récente recommandation est M. Yoshika Matsuda, qui a un très beau sourire. Ce qui est bon?

matsudakonoka.png Blog de publication d'images

Site de référence

Comment extraire des événements arbitraires dans Google Agenda avec Python

Ajout d'un événement à Google Agenda avec Python

[Python] Obtenir / ajouter des rendez-vous Google Agenda à l'aide de l'API Google Agenda

À propos de python datetime

━━━━━━━━━━ Page d'accueil de Hyugazaka46

Rendez-vous à Hinatazaka

CHAÎNE YouTube OFFICIELLE Hyugazaka 46

Blog de Yoshika Matsuda

Recommended Posts

Racler le calendrier de Hinatazaka 46 et le refléter dans Google Agenda
Recevez le dernier rendez-vous de Google Agenda et notifiez-le sur LINE tous les matins
Gratter la liste des magasins membres Go To EAT dans la préfecture de Fukuoka et la convertir en CSV
Trouvez-le dans la file d'attente et modifiez-le
Gratter la liste des magasins membres Go To EAT dans la préfecture de Niigata et la convertir en CSV
Prédisez la quantité d'énergie utilisée en 2 jours et publiez-la au format CSV
Gratter l'holojour et l'afficher dans la CLI
[Python] Précautions lors de l'acquisition de données en grattant et en les mettant dans la liste
Gratter les données pluviométriques de l'Agence météorologique et les afficher sur M5Stack
Si vous définissez une méthode dans une classe Ruby, puis définissez une méthode dans celle-ci, elle devient une méthode de la classe d'origine.
Importez le calendrier obtenu à partir de "Schedule-kun" dans Google Agenda
Le résultat de la création d'un album de cartes de jeunes mariés italiens en Python et de son partage
Lisez le fichier csv et affichez-le dans le navigateur
Je veux un bot Slack qui calcule et me dit le salaire d'un emploi à temps partiel à partir du calendrier de Google Agenda!
Étude de la relation entre le prétraitement de la voix et la précision de la transcription dans l'API Google Cloud Speech
J'ai fait un calendrier qui met à jour automatiquement le calendrier de distribution de Vtuber (édition Google Calendar)
Scraping PDF du statut des personnes testées positives dans chaque préfecture du ministère de la Santé, du Travail et du Bien-être social
Scraping Go To EAT membres magasins dans la préfecture d'Osaka et conversion au format CSV
Mettre en œuvre le modèle mathématique «modèle SIR» des maladies infectieuses dans OpenModelica (reflétant le taux de mortalité et de réinfection)
Essayez de gratter les données COVID-19 Tokyo avec Python
Le processus de création et d'amélioration du code Python orienté objet
[Astuces] Problèmes et solutions dans le développement de python + kivy
Google recherche la chaîne sur la dernière ligne du fichier en Python
Grattage du résultat de "Schedule-kun"
[Python] Le rôle de l'astérisque devant la variable. Divisez la valeur d'entrée et affectez-la à une variable
J'ai essayé de gratter le classement du calendrier de l'avent Qiita avec Python
Comptez bien le nombre de caractères thaïlandais et arabes en Python
Probabilité des prix les plus élevés et les plus bas des louveteaux à Atsumori
Notifier le contenu de la tâche avant et après l'exécution de la tâche avec Fabric
Convertissez le résultat de python optparse en dict et utilisez-le
Vérifier le taux de compression et le temps de PIXZ utilisé en pratique
Obtenez le titre et la date de livraison de Yahoo! News en Python
Notez que je comprends l'algorithme du classificateur Naive Bayes. Et je l'ai écrit en Python.
[Rails 6] Intégrez Google Map dans l'application et ajoutez un marqueur à l'adresse saisie. [Confirmation des détails]
[Python / Jupyter] Traduisez le commentaire du programme copié dans le presse-papiers et insérez-le dans une nouvelle cellule.
Utilisez Cloud Dataflow pour modifier dynamiquement la destination en fonction de la valeur des données et enregistrez-la dans GCS
Comment copier et coller le contenu d'une feuille au format JSON avec une feuille de calcul Google (en utilisant Google Colab)
L'histoire de Python et l'histoire de NaN
L'histoire de la participation à AtCoder
L'histoire du "trou" dans le fichier
Extraits (scraping) enregistrés dans Google Colaboratory
Explication et implémentation du protocole XMPP utilisé dans Slack, HipChat et IRC
[Python] Explorez les caractéristiques des titres des meilleurs sites dans les résultats de recherche Google
J'ai fait un calendrier qui met à jour automatiquement le calendrier de distribution de Vtuber
Graphique de l'historique du nombre de couches de deep learning et du changement de précision
[Python] Doux Est-ce doux? À propos des suites et des expressions dans les documents officiels
Comparer la grammaire de base de Python et Go d'une manière facile à comprendre
Changer la saturation et la clarté des spécifications de couleur comme # ff000 dans python 2.5
Mettez le résultat du chat entre guillemets et mettez-le dans une variable
Celui qui divise le fichier csv, le lit et le traite en parallèle
J'ai défini des variables d'environnement dans Docker et je les ai affichées en Python.
J'ai vectorisé l'accord de la chanson avec word2vec et je l'ai visualisé avec t-SNE
Rechercher le nom et les données d'une variable libre dans un objet fonction
[Android] Afficher des images sur le Web dans la fenêtre info de Google Map
Le client API pour le plan du site dans la console de recherche Google est dans les webmasters au lieu de searchconsole
Ouvrez un fichier Excel en Python et coloriez la carte du Japon
Représenter des conteneurs dans un cadre imbriqué (schéma) dans Jupyter, et ce que j'ai étudié en cours de création
Un simple serveur simulé qui incorpore simplement l'en-tête de la requête HTTP dans le corps de la réponse et le renvoie.
Paramètres qui permettent de voir plus facilement l'échelle et l'étiquette de la figure même avec un thème sombre avec google Colaboratory