{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Web-scraping: сбор данных из баз данных и интернет-источников\n", "\n", "*Алла Тамбовцева, НИУ ВШЭ*\n", "\n", "## Управление браузером с помощью Selenium: пример ВКонтакте" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Импортируем из библиотеки `selenium` модуль `webdriver` для доступа к браузеру:" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "from selenium import webdriver as wd" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Откроем новое окно браузера (укажите свой путь к файлу с драйвером для Chrome):" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "br = wd.Chrome(\"/Users/allat/Downloads/chromedriver\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В открывшемся окне в адресную строку запишем ссылку на главную страницу социальной сети:" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "br.get(\"https://vk.com/\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Найдём поле для ввода логина и введём туда свой логин. Чтобы понять, по какому тэгу/иному идентификатору обнаружить это поле, обратимся к исходному коду страницы. Заметим, что у поля для логина есть id – отлично, значит, вызывать его можно однозначным образом, все id уникальны:" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "login = br.find_element_by_id(\"index_email\")\n", "login.send_keys(\"allatambov@mail.ru\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Поле для пароля найдём аналогичным образом:" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "password = br.find_element_by_id(\"index_pass\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "С вводом пароля поступим хитро – импортируем функцию `getpass()` из модуля `getpass()`, которая запрашивает пароль с клавиатуры и скрывает введённые символы:" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "from getpass import getpass" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "········\n" ] } ], "source": [ "my_password = getpass()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "А теперь отправим это значение в соответствующее поле:" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "password.send_keys(my_password)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Осталось кликнуть на кнопку для входа! Тоже найдём её по id:" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "go = br.find_element_by_id(\"index_login_button\")\n", "go.click() " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Теперь, когда мы залогинены и действуем со своего аккаунта, мы можем приступить к работе. Перейдём на страницу поиска друзей:" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [], "source": [ "br.get(\"https://vk.com/friends\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Найдём на этой странице ссылку с текстом *Поиск друзей*, чтобы перейти к странице поиска пользователей по заданным критериям, и кликнем на неё:" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "search = br.find_element_by_link_text(\"Поиск друзей\")\n", "search.click() " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Чтобы формировать критерии для поиска, нам нужно открыть меню с фильтрами – развернуть меню *Параметры поиска*. Изучив исходный код страницы, видим, что параметры поиска можно найти по id:" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [], "source": [ "pars = br.find_element_by_id(\"friends_filters_block\")\n", "pars.click()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Для начала выберем страну (регион). В исходном коде страницы поле для ввода страны имеет тэг `` и id, равный `country`. Но давайте для разнообразия найдём это поле не по id, а более сложным, двухступенчатым способом, чтобы увидеть, как реализуется поиск элементов, которые вложены в другие. Раздел, который соответствует стране, имеет id, равный `cCountry`:" ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [], "source": [ "cnt = br.find_element_by_id(\"cCountry\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Объект `cnt` – это объект типа `webelement.WebElement`, то есть элемент страницы, с которым умеет работать модуль `webdriver` из `selenium`. По такому объекту тоже можно выполнять поиск с помощью методов вида `find_element_by_`. \n", "\n", "В нашем случае внутри этого элемента нужно найти другой, с тэгом ``, потому что нас интересует поле для ввода значения. Если этим шагом пренебречь и попытаться ввести название страны прямо в `cnt`, мы получим ошибку вида `element not interactable`, потому что сам по себе раздел со страной никакого взаимодействия с пользователем не предполагает, его нельзя редактировать, на него нельзя кликать и прочее.\n", "\n", "Поэтому найдём внутри `cnt` поле для ввода значения по тэгу:" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "cnt_inp = cnt.find_element_by_tag_name(\"input\")\n", "cnt_inp.send_keys(\"Россия\") " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Отлично! Значение выбрано. Но есть проблема – оно «повисло» в воздухе, опция с выбором страны отображается как выбранная в выпадающем меню, но в самом поле выбор не зафиксирован. Чтобы подтвердить выбор, нужно нажать на клавишу *Enter*. К счастью, `selenium` умеет имитировать действия клавиатуры и мышки. Импортируем «хранилище» клавиш `Keys`:" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [], "source": [ "from selenium.webdriver.common.keys import Keys" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "И теперь «введём» в поле для названия страны имитацию нажатия клавиши *Enter* (*Return*):" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [], "source": [ "cnt_inp.send_keys(Keys.RETURN)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Отлично! Продолжим работу с фильтрами и проделаем то же самое для выбора города, только с маленькой поправкой – так как ниже код уже готовый и запускается одним разом, а не поэтапно, чтобы необходимая информация успела прогрузиться на странице, перед нажатием на *Enter* немного подождём: " ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [], "source": [ "city = br.find_element_by_id(\"cCity\")\n", "city_inp = city.find_element_by_tag_name(\"input\")\n", "city_inp.send_keys(\"Москва\") " ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [], "source": [ "br.implicitly_wait(3) # задержка\n", "city_inp.send_keys(Keys.RETURN)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "А вот с полом всё поинтереснее: найти поле для ввода пола просто, а вот значения нужно выбирать, нажимая на радиокнопки (*radiobuttons*). Сначала найдём поле для выбора пола:" ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [], "source": [ "sex = br.find_element_by_id(\"cSex\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "А теперь – все опции внутри (согласно исходному коду, они имеют тэг `
`):" ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[,\n", " ,\n", " ]" ] }, "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ "values = sex.find_elements_by_tag_name(\"div\")\n", "values" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Обратите внимание: здесь метод `find_elements_`, не `find_element_`, потому что результатов ожидается несколько. Методы вида `find_element_` возвращают только первое совпадение на странице, методы вида `find_elements_` – все совпадения на странице (можно провести аналогию с `find` и `find_all` в `BeautifulSoup`)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Выбираем мужской пол – это второй элемент списка – и кликаем на него:" ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [], "source": [ "values[1].click()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "На этом закончим работу с фильтрами и перейдём к результатам. На этом этапе возможности `selenium` нам пока не понадобятся, нам нужно только запросить исходный код страницы, которая сейчас открыта в окне браузера, управляемом из Python, и продолжить работу с HTML с помощью BeautfulSoup." ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [], "source": [ "from bs4 import BeautifulSoup\n", "\n", "# забираем исходный код страницы\n", "# превращаем в объект BeautifulSoup\n", "\n", "html = br.page_source\n", "soup = BeautifulSoup(html)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Если мы внимательно изучим исходный код страницы с результатами в браузере, мы заметим, что краткая информация по пользователям (имя и ссылка на профиль) находится в тэгах `
` с классом `info`:" ] }, { "cell_type": "code", "execution_count": 23, "metadata": {}, "outputs": [], "source": [ "divs = soup.find_all(\"div\", {\"class\" : \"info\"})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Возьмём первого пользователя и поймём, как извлечь информацию по нему:" ] }, { "cell_type": "code", "execution_count": 24, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "source": [ "d = divs[0]\n", "d" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Найдём элемент, соответствующий имени и извлечём оттуда текст и ссылку на профиль:" ] }, { "cell_type": "code", "execution_count": 25, "metadata": {}, "outputs": [], "source": [ "person = d.find(\"div\", {\"class\" : \"labeled name\"})\n", "name = person.text\n", "href = person.find(\"a\")[\"href\"]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Напишем функцию для извлечения имени и ссылки на профиль:" ] }, { "cell_type": "code", "execution_count": 26, "metadata": {}, "outputs": [], "source": [ "def get_person(d):\n", " person = d.find(\"div\", {\"class\" : \"labeled name\"})\n", " name = person.text\n", " href = person.find(\"a\")[\"href\"]\n", " return name, href" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "И применим её ко всем пользователям в списке `divs`:" ] }, { "cell_type": "code", "execution_count": 27, "metadata": {}, "outputs": [], "source": [ "people = []\n", "for d in divs:\n", " p = get_person(d)\n", " people.append(p)" ] }, { "cell_type": "code", "execution_count": 28, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "[('Артемий Лебедев', '/temalebedev'),\n", " ('Jama Buldozer', '/jama_buldozer'),\n", " ('Павел Дуров', '/durov'),\n", " ('Вован Тарасов', '/id579521433'),\n", " ('Сергей Крылов', '/id11010073'),\n", " ('Ян Топлес', '/yanlapotkov'),\n", " ('Сергей Миронов', '/mironov_sergey'),\n", " ('Александр Кондрашов', '/justdoit150'),\n", " ('Денис Джиган', '/iamgeegun'),\n", " ('Стас Васильев', '/stas_satori'),\n", " ('Амиран Сардаров', '/amirans'),\n", " ('Константин Малофеев', '/kvmalofeev'),\n", " ('Митя Фомин', '/id87211536'),\n", " ('Сергей Домогацкий', '/domogatskiy'),\n", " ('Стас Костюшкин', '/stas_kostyushkin'),\n", " ('Сергей Жуков', '/sergeyzhukov.official'),\n", " ('Роберт Григорян', '/scorty'),\n", " ('Александр Соколовский', '/id8006'),\n", " ('Олег Абрамов', '/tapog'),\n", " ('Андрей Воробьев', '/andreyvorobiev'),\n", " ('Тимур Батрутдинов', '/batr'),\n", " ('Ранис Гайсин', '/ranto'),\n", " ('Алексей Русских', '/russkihay'),\n", " ('Мария Ивакова', '/maria_ivakova'),\n", " ('Борис Александрович', '/borune'),\n", " ('Samir Vishniakov', '/samir_vishniakov'),\n", " ('Владислав Плотников', '/donatworry'),\n", " ('Эдуард Перец', '/eduard_perets'),\n", " ('Дмитрий Глуховский', '/dg'),\n", " ('Данила Поперечный', '/spoontamer'),\n", " ('Тимур Юнусов', '/timatimusic'),\n", " ('Элвин Грей', '/elvingrey'),\n", " ('Денис Кукояка', '/denisque'),\n", " ('Константин Ивлев', '/ivlevchef'),\n", " ('Ратмир Мавлиев', '/ratmir.mavliev'),\n", " ('Стас Барецкий', '/stasbareckiy'),\n", " ('Сергей Сергеев', '/rusgamespc'),\n", " ('Геннадий Зюганов', '/gennadiy_zyuganov'),\n", " ('Сергей Червяков', '/id59443037'),\n", " ('Денис Ступаков', '/nexus26')]" ] }, "execution_count": 28, "metadata": {}, "output_type": "execute_result" } ], "source": [ "people" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Отлично! При желании ссылки на профили можно сделать полными и кликабельными:" ] }, { "cell_type": "code", "execution_count": 29, "metadata": {}, "outputs": [], "source": [ "people_new = []\n", "for p in people:\n", " new_link = \"https://vk.com\" + p[1] \n", " people_new.append((p[0], new_link)) " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Проверяем:" ] }, { "cell_type": "code", "execution_count": 30, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "('Артемий Лебедев', 'https://vk.com/temalebedev')" ] }, "execution_count": 30, "metadata": {}, "output_type": "execute_result" } ], "source": [ "people_new[0]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Осталась одна проблема – мы извлекли далеко не все результаты, а только те результаты, которые «видит» браузер, то есть то, что мы как пользователи видим до скроллинга. Библиотека `selenium` умеет скроллить страницы, точнее, активировать запуск кода на JavaScript, который отвечает за скроллинг. В общем виде строка с кодом для скроллинга выглядит так (`Y` – на сколько пикселей нужно проскроллить):\n", "\n", " br.execute_script(\"window.scrollTo(0, Y)\") \n", " \n", "Если нужно проскроллить до конца страницы, то тогда вместо `Y` нужно вписать значение, которое извлекается из тела документа HTML:\n", "\n", " document.body.scrollHeight" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Чтобы извлечь все результаты и проскроллить всё до самого конца, нам понадобится цикл. \n", "Аглоритм такой: \n", "\n", "1. Выгружаем исходный код страницы.\n", "2. Находим в этом коде необходимые элементы – извлекаем имена и ссылки на профили.\n", "3. Скроллим вниз до конца страницы.\n", "4. Повторяем пункты 1-3 до тех пор пока не достигнем последнего результата.\n", "\n", "Тут может возникнуть логичный вопрос: почему нельзя сразу проскроллить страницу до самого последнего результата, а потом собрать все данные? Причина проста: раз `webdriver` «видит» браузер глазами пользователя, при скроллинге вниз более ранние результаты будут пролистываться и уходить наверх, а значит, доступа к ним уже не будет. Поэтому нужно действовать поэтапно: проскроллили – сразу выгрузили информацию, ещё проскроллили – снова выгрузили." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Для упрощения задачи напишем функцию, которая сохраняет исходный код страницы, обращаясь к драйверу `br`, и извлекает информацию о пользователях." ] }, { "cell_type": "code", "execution_count": 31, "metadata": {}, "outputs": [], "source": [ "def get_users_info(br):\n", " html = br.page_source\n", " soup = BeautifulSoup(html)\n", " divs = soup.find_all(\"div\", {\"class\" : \"info\"})\n", " \n", " people = []\n", " for d in divs:\n", " p = get_person(d)\n", " people.append(p)\n", " \n", " return people" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Осталось запустить долгий цикл и на каждой итерации добавлять новые результаты после скроллинга. Импортируем функцию `sleep`, чтобы выставлять задержки между скроллингами:" ] }, { "cell_type": "code", "execution_count": 32, "metadata": {}, "outputs": [], "source": [ "from time import sleep" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Теперь напишем код (адаптированный ответ с [StackOverflow](https://stackoverflow.com/questions/20986631/how-can-i-scroll-a-web-page-using-selenium-webdriver-in-python)) для цикла. Какой тип цикла нам нужен? Нам нужен цикл, который умеет повторять операции до тех пор, пока мы не дойдём до последнего результата, то есть до того момента, когда скроллить будет некуда. Воспользуемся конструкцией `while True`, бесконечным вариантом цикла `while`, который будет запускаться до тех пор, пока не дойдёт до кода с оператором `break` (выход из цикла) или не столкнётся с ошибкой. " ] }, { "cell_type": "code", "execution_count": 34, "metadata": {}, "outputs": [], "source": [ "all_results = []\n", "last_height = br.execute_script(\"return document.body.scrollHeight\")\n", "\n", "while True:\n", " res = get_users_info(br)\n", " all_results.extend(res) \n", " \n", " br.execute_script(\"window.scrollTo(0, document.body.scrollHeight);\")\n", " new_height = br.execute_script(\"return document.body.scrollHeight\")\n", " sleep(1)\n", " \n", " if new_height == last_height:\n", " break\n", " \n", " last_height = new_height" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Пояснения к коду.\n", "\n", "1. Сохраняем в переменную `last_height` величину, на которую мы можем проскроллить страницу за один раз в данный момент времени, чтобы дойти до конца страницы. \n", "\n", "2. На каждой итерации цикла `while` мы выгружаем информацию, добавляем её в список `all_results` и скроллим страницу до самого низа. После скроллинга проверяем, на сколько ещё можно проскроллить, сохраняем полученное значение в `new_height`. \n", "\n", "3. Если скроллить уже некуда, если мы находимся в самом низу «бесконечной» страницы с результатами поиска, то `new_height` совпадает с `last_height`. Значит, нам нужно остановить исполнение кода – выходим из цикла с помощью `break`. \n", "\n", "4. Если мы ещё не закончили скроллить, обновляем значение `last_height`, заменяя его на `new_height` (теперь уже в нём хранится величина, на которую мы можем проскроллить страницу за один раз в данный момент времени). Продолжаем выполнять выгрузку информации и скроллинг." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "После недолгой работы кода с ячейке выше (у меня исполнение заняло примерно 2.5 минуты), мы получаем список `all_results` с именами пользователей и ссылками на их профили." ] }, { "cell_type": "code", "execution_count": 38, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "26297" ] }, "execution_count": 38, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# 26297 результатов\n", "\n", "len(all_results)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Преобразуем список в датафрейм:" ] }, { "cell_type": "code", "execution_count": 39, "metadata": {}, "outputs": [], "source": [ "import pandas as pd" ] }, { "cell_type": "code", "execution_count": 42, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
01
0Артемий Лебедев/temalebedev
1Jama Buldozer/jama_buldozer
2Павел Дуров/durov
3Вован Тарасов/id579521433
4Сергей Крылов/id11010073
\n", "
" ], "text/plain": [ " 0 1\n", "0 Артемий Лебедев /temalebedev\n", "1 Jama Buldozer /jama_buldozer\n", "2 Павел Дуров /durov\n", "3 Вован Тарасов /id579521433\n", "4 Сергей Крылов /id11010073" ] }, "execution_count": 42, "metadata": {}, "output_type": "execute_result" } ], "source": [ "dat = pd.DataFrame(all_results)\n", "dat.head(5) # первые 5 строк" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Переименуем столбцы и сделаем ссылки на профили полными:" ] }, { "cell_type": "code", "execution_count": 43, "metadata": {}, "outputs": [], "source": [ "dat.columns = [\"name\", \"link\"]" ] }, { "cell_type": "code", "execution_count": 44, "metadata": {}, "outputs": [], "source": [ "dat[\"link_full\"] = dat[\"link\"].apply(lambda x: \"https://vk.com/\" + x)" ] }, { "cell_type": "code", "execution_count": 46, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
namelinklink_full
0Артемий Лебедев/temalebedevhttps://vk.com//temalebedev
1Jama Buldozer/jama_buldozerhttps://vk.com//jama_buldozer
2Павел Дуров/durovhttps://vk.com//durov
3Вован Тарасов/id579521433https://vk.com//id579521433
4Сергей Крылов/id11010073https://vk.com//id11010073
\n", "
" ], "text/plain": [ " name link link_full\n", "0 Артемий Лебедев /temalebedev https://vk.com//temalebedev\n", "1 Jama Buldozer /jama_buldozer https://vk.com//jama_buldozer\n", "2 Павел Дуров /durov https://vk.com//durov\n", "3 Вован Тарасов /id579521433 https://vk.com//id579521433\n", "4 Сергей Крылов /id11010073 https://vk.com//id11010073" ] }, "execution_count": 46, "metadata": {}, "output_type": "execute_result" } ], "source": [ "dat.head(5)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Теперь можно писать другой код, код, который проходит по ссылкам на профили и выгружает оттуда доступную всем информацию :) Но это уже другая история, в следующий раз попробуем поработать с ВКонтакте уже с помощью API." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.4" } }, "nbformat": 4, "nbformat_minor": 2 }