{
    "version": "https:\/\/jsonfeed.org\/version\/1",
    "title": "Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: Analytics Engineering",
    "home_page_url": "http:\/\/test.leftjoin.ru\/tags\/analytics-engineering\/",
    "feed_url": "http:\/\/test.leftjoin.ru\/tags\/analytics-engineering\/json\/",
    "icon": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg",
    "author": {
        "name": "Николай Валиотти",
        "url": "http:\/\/test.leftjoin.ru\/",
        "avatar": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg"
    },
    "items": [
        {
            "id": "148",
            "url": "http:\/\/test.leftjoin.ru\/all\/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d\/",
            "title": "Сквозной идентификатор: решение проблемы мэтчинга персональных данных студентов Refocus",
            "content_html": "<p><img src=http:\/\/test.leftjoin.ru\/pictures\/cover.png  border=“0” width=100% height=100%><\/p>\n<p>В системах сквозной аналитики ключевую роль играет правильная модель атрибуции. Без нее данные невозможно интерпретировать, и их ценность для бизнеса невелика. При этом важно понимать, что любая модель напрямую зависит от качества данных.<\/p>\n<p>Частая проблема с сырыми данными в том, что информация об одном клиенте дублируется или, напротив, противоречит друг другу в разных источниках.<\/p>\n<p>Кроме того, что предобработка данных — база для аналитика, без правильного объединения персональных данных в принципе сложно отследить клиентский путь. Значит, нужно настраивать процессы объединения неоднородных персональных данных.<\/p>\n<p>Сегодня в любом клиентском бизнесе воронки регистрации устроены таким образом, что клиенты попадают в базу множеством способов — часто через маркетинговые каналы, которых всегда много (рассылки, реклама, соцсети). В каждом таком канале может быть ссылка на форму подписки, регистрацию на платформе или чат, и один клиент часто проходит все эти этапы. Сразу же образуется путаница в идентификации, которая сильно влияет на качество данных и результаты аналитики, если ее не лечить.<\/p>\n<p>Мы столкнулись с этой проблемой, работая с одним из наших клиентов, и решили ее, создав сквозной идентификатор. Это уникальный номер, который присваивается реальному клиенту и дублируется во все источники, где есть данные об этом клиенте, тем самым избавляя от путаницы.<\/p>\n<h2>Кейс Refocus: данные и путь клиента<\/h2>\n<p>Мы разрабатывали кастомную систему сквозной аналитики для эдтех-стартапа Refocus. Данные каждого студента в системы Refocus попадали из нескольких источников и были записаны несколько раз — как минимум при регистрации на курс, при первом входе на образовательную платформу и при входе в чат сопровождения.<\/p>\n<p>В нашем случае мэтчинг был важнее всего по трем источникам из тринадцати:<\/p>\n<ul>\n<li><b>amoCRM,<\/b> где фиксируется весь клиентский путь студента;<\/li>\n<li><b>Discord,<\/b> где проходило сопровождение студентов;<\/li>\n<li><b>Thinkific,<\/b> сама образовательная платформа с курсами.<\/li>\n<\/ul>\n<p>Остальные источники, с которыми мы работали, либо не содержали данных студентов (например, цифры эффективности работы sales-менеджеров были завязаны на данных сотрудников и трекались через другие системы), либо дублировали информацию из указанных трех.<\/p>\n<p>В Discord и Thinkific данные попадали напрямую, от студентов при регистрации в системах, а затем подтягивались в amoCRM. Основные причины несовпадения клиентских данных как у Refocus, так и в похожих случаях — человеческий фактор (опечатки), наличие у людей более чем одного телефона или адреса почты и ограничения самих платформ, с которых приходят данные: разный заданный формат полей и их количество.<\/p>\n<p>Часть этих факторов может решаться корректировкой самой клиентской воронки. Правда, не все платформы позволяют одинаково настроить вводные поля, а просьбы вводить данные в конкретном формате не всегда работают и не страхуют от ошибок. Плюс, задача аналитиков — получить чистые данные в любом случае.<\/p>\n<h2>Задача и поиск решения<\/h2>\n<p>Данные в Refocus мы подгружали в хранилище в BigQuery напрямую из интересующих нас источников (рекламных кабинетов, LMS и т. д.), используя Python. В дальнейшем на этих данных строились дашборды в Tableau.<\/p>\n<p>Обнаружить проблему несложно — при создании хранилища и дальнейшей выгрузке данных из него мы в любом случае чистим датасет от дубликатов и несовпадений.<\/p>\n<p>Поля, в которых возникали ошибки и для которых нам важен был мэтчинг, чтобы правильно отследить клиентский путь:<\/p>\n<ul>\n<li>имя — да, люди иногда вводят разные вариации ФИО (Юлия, Юля и Бля — на деле один человек!);<\/li>\n<li>телефон — с кодом страны или без, с пробелами, дефисами или слитно;<\/li>\n<li>электронная почта — длинные строки сложного формата, в которых легко опечататься.<\/li>\n<\/ul>\n<p>Поначалу, пока количество студентов Refocus было относительно небольшим, достаточно было скриптов, которые объединяли данные по одному из этих полей. В полученных таблицах в Tableau проводился поиск строк с пустым значением в соответствующем поле — и вот видно всех студентов, чьи данные не сошлись.<\/p>\n<p>Количество таких строк было в пределах пары десятков, и трекать и объединять их было несложно вручную. Это делалось прямо в первоисточниках сотрудниками Refocus, которые могли поправить опечатки и ошибки у себя в системах. После этого наш код выгрузки в хранилище перезапускался и тянул уже чистые данные. Если после этого что-то не сходилось, то наши аналитики правили информацию на уровне базы данных.<\/p>\n<p>Но при росте компании в какой-то момент число студентов, потерянных при мэтчинге, могло достигать сотни за месяц. Пока ошибка обнаружится, данные поправят в источниках, а мы перезапустим код выгрузки, могло пройти несколько часов — а это критичный интервал. Да и перезапускать выгрузку каждый день ради нескольких несовпадений — неэффективно. Стало понятно, что масштаб проблемы требует более точного и универсального решения.<\/p>\n<p>Вообще, в такой ситуации возможны несколько вариантов. Можно бесконечно править скрипты мэтчинга, учитывая новые и новые случаи и создавая костыли. А можно, например, настроить алерты в оркестраторе процессов (в нашем случае  — Airflow), которые позволят моментально узнавать о появившемся несовпадении и объединять “потерянные” клиентские сущности по паре за раз. Но это все еще неполная автоматизация, и она только ускоряет, а не упрощает процесс.<\/p>\n<p>Руководствуясь соображениями эффективности, мы предложили ввести сквозной идентификатор — одно значение ID, присваиваемое одному клиенту после автоматической интеграции его данных из разных источников.<\/p>\n<h2>Реализация решения и рабочий процесс<\/h2>\n<p>Чтобы понять масштаб проблемы, мы начали с того, что создали таблицы несовпадающих персональных данных. Для этого мы использовали скрипты на Python. Эти скрипты объединяли данные из разных источников и создавали из них большую сводную таблицу. Для того, чтобы свести данные о студенте в одну сущность, использовался мэтчинг по адресу электронной почты. Мы попробовали мэтчить по имени, фамилии, телефону (который сначала надо было привести к одному формату!) и почте, и именно последний вариант показал самую высокую точность. Возможно, дело в том, что из всех данных почта имеет самый однородный формат, поэтому остается учитывать только опечатки.<\/p>\n<p>Например, нам нужно было мэтчить данные для создания дашборда по возвратам, о которых информация объединялась как раз из наших трех основных источников. В ранней версии скрипта данные отбирались таким образом:<\/p>\n<pre class=\"e2-text-code\"><code>WITH snapshot_ AS (\r\n      SELECT DISTINCT s.*,\r\n        IFNULL(ae.name, ap.name) as contact_name,\r\n        ap.phone, ae.email,\r\n        split(replace(trim(lower(ae.email)),' ',''),'@')[OFFSET(0)] as email_first_part,\r\n        ai.thinkific_id, ai.intercom_id, ac.student_id\r\n      FROM (\r\n        SELECT *,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,\r\n        FROM `Differture.amocrm_leads_snapshot`\r\n      ) s\r\n      LEFT JOIN (\r\n        SELECT DISTINCT lead_id, contact_id, name, email,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num1_\r\n        FROM `Differture.amo_emails`\r\n      ) ae using(lead_id)\r\n      LEFT JOIN (\r\n        SELECT DISTINCT lead_id, contact_id, name, phone,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num2_\r\n        FROM `Differture.amo_phones`\r\n      ) ap using(lead_id)\r\n      LEFT JOIN `Differture.amo_contact_thinkific_intercom_match` ai using(lead_id)\r\n      LEFT JOIN `Differture.AmoContacts` ac on cast(ae.contact_id as string)=ac.amo_id\r\n      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null) and (num2_=1 or num2_ is null)\r\n        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'\r\n    )<\/code><\/pre><p>Как можно заметить, идея сквозного идентификатора здесь уже присутствует — фигурирует <tt>student_id<\/tt>. На самом деле, в этой версии скрипта это графа из AmoContacts — таблицы, в которой хранятся только данные из amoCRM. Никаких джойнов по <tt>student_id<\/tt> пока не происходит. А происходят по <tt>email_first_part<\/tt>, адресу почты до символа @:<\/p>\n<pre class=\"e2-text-code\"><code>select distinct * from th_amo_ds_rf\r\n    left join calendly ce using(email_first_part)\r\n    left join typeform_live tfl on email_first_part=tf_email_first_part\r\n    left join typeform tf using(email_first_part)\r\n    left join csat using(email_first_part)<\/code><\/pre><p>Первым шагом по практическому введению идентификатора была таблица <b>students_main_info<\/b>, созданная в BigQuery in-house специалистом Refocus. К сожалению, у нас нет доступа к коду, который использовался для присвоения идентификатора. Зато мы можем показать вид этой таблицы:<\/p>\n<div class=\"e2-text-table\">\n<table cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n<tr>\n<td style=\"text-align: left\">student_full_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_email<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_country_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_country_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_courses_ids<\/td>\n<td style=\"text-align: center\">array<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_courses_names<\/td>\n<td style=\"text-align: center\">array<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_cohort_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_cohort_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">cohort_community_manager_name<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">cohort_community_manager_email<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_time<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">student_onboarding_live_session_zoom_url<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">amo_contact_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">intercom_contact_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">thinkific_student_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_user_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_user_discord_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_guild_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_channel_id<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<tr>\n<td style=\"text-align: left\">discord_roles<\/td>\n<td style=\"text-align: center\">string<\/td>\n<\/tr>\n<\/table>\n<\/div>\n<p>В students_main_info хранились данные из нужных источников с общим идентификатором в первой строке, и объединение проходило через сравнение этого поля.<\/p>\n<p>При этом поле <tt>student_id<\/tt> использовалось пока не везде; также использовались другие поля этой таблицы — например, <tt>thinkific_student_id<\/tt> или <tt>discord_user_id<\/tt>.<\/p>\n<p>После выгрузки и мэтчинга данных с помощью students_main_info студентов, которые потерялись при объединении, стало меньше, чем при первой схеме мэтчинга. Так мы убедились, что движемся в верном направлении. Тем не менее, использование одной таблицы, которая содержит больше десятка полей обо всех имеющихся персональных данных, не очень эффективно. Данные в ней уже обработаны скриптом специалиста Refocus, и если надо сверить их с сырыми источниками или ввести новый критерий отслеживания, все придется менять на бэкенде.<\/p>\n<h2>Что получилось в итоге<\/h2>\n<p>После теста сквозного идентификатора через одну большую таблицу мы продолжили улучшать структуру данных на бэке. Вместо students_main_info усилиями специалиста Refocus появилась подробная сеть более мелких таблиц, которые могут обращаться друг к другу и лежат в одном хранилище с нашими таблицами сырых данных.<\/p>\n<p>Вот так выглядела схема соотношения этих таблиц:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/image-2.png  border=“0” width=100% height=100%><\/p>\n<p>А вот так выглядела основная таблица Students:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/students.png border=“0” width=40% height=40%><\/p>\n<p>В ней-то и находились основные персональные данные студентов с присвоенным идентификатором, и к ней можно было обращаться для мэтчинга из остальных источников.<\/p>\n<p>Остальные таблицы выглядели похоже: всегда было поле с идентификатором и информация о какой-то характеристике студента — когорта, курс, роль в дискорде и так далее.<\/p>\n<p>Финальный код, написанный нашими аналитиками,  объединял данные при выгрузке из хранилища, и больше не опирался на ненадежный мэтчинг через имейл.<\/p>\n<p>Сначала он отбирал собранные нами данные из amoCRM <tt>(amocrm_leads_snapshot)<\/tt> и объединял их с контактной информацией клиентов. Затем в таблицу добавлялось поле <tt>student_id<\/tt> и отбирались данные, которые понадобятся нам дальше.<\/p>\n<pre class=\"e2-text-code\"><code>WITH snapshot_ AS (\r\n      SELECT DISTINCT s.*,\r\n        ac.name as contact_name, ac.phone, ac.email,\r\n        split(replace(trim(lower(ac.email)),' ',''),'@')[OFFSET(0)] as email_first_part,\r\n        ac.intercom_id, ac.student_id\r\n      FROM (\r\n        SELECT *,\r\n          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,\r\n        FROM `Differture.amocrm_leads_snapshot`\r\n      ) s\r\n      LEFT JOIN (\r\n        select cast(al.amo_id as INT64) as lead_id, cast(ac.amo_id as INT64) as contact_id,\r\n          ac.name, emails as email, phone, student_id, ic.intercom_id,\r\n          ROW_NUMBER() OVER(PARTITION BY al.amo_id ORDER BY ac.amo_id) as num1_\r\n        from `Differture.AmoContacts` ac\r\n        left join `Differture.AmoLeads` al on al.amo_contact_id=ac.id\r\n        left join `Differture.IntercomContacts` ic using(student_id)\r\n        , unnest(ac.emails) emails\r\n      ) ac using(lead_id)\r\n      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null)\r\n        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'\r\n    )<\/code><\/pre><p>Теперь при создании общей таблицы о возвратах с данными из amo, Thinkific и Discord объединение проходило через student_id:<\/p>\n<pre class=\"e2-text-code\"><code>th_amo_ds_rf as (\r\n      select distinct * except (channel_id, channel),\r\n        ifnull(channel_id, 'Not in discord') as channel_id,\r\n        ifnull(channel, 'Not in discord') as channel\r\n      from thinkific_amo_refunds\r\n      full outer join discord using(student_id)\r\n    )<\/code><\/pre><p>Когда объединенные таблицы данных студентов были созданы, получить таблицы несовпадений можно было простой строкой кода в Tableau:<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/filter.png border=“0” width=50% height=50%><\/p>\n<p>Пустое значение поля student_id означает, что мэтча не случилось — где-то информация расходилась слишком сильно и не подтянулась в таблицы с идентификатором. Раньше, до введения идентификатора, поиск был таким же, но обращался к полям почты, телефона или имени-фамилии.<\/p>\n<p>Ниже можно увидеть таблицу, где данные из Thinkific не совпадали с amoCRM после перехода на Student ID. В этом случае студент есть в LMS, значит, на курсе учится — но его либо нет в системе учета, либо данные в ней разнятся с LMS.<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/unnamed.png  border=“0” width=100% height=100%><\/p>\n<p>А вот таблица, где данные из Discord не совпадали с amoCRM. Все так же, как выше — студент есть в чатах сопровождения, но не ищется по своим данным в amoCRM.<\/p>\n<p><img src=http:\/\/test.leftjoin.ru\/pictures\/discord.png  border=“0” width=100% height=100%><\/p>\n<p>Оба скриншота показывают количество несовпадений примерно за месяц. Как видно по этим таблицам, количество несовпадений уменьшилось с 80-90 до пары десятков — примерно на 75%. Это позволило сократить количество перезапусков кода выгрузки вручную и уменьшить затраты времени и технических ресурсов на поддержание системы.<\/p>\n<h2>Выводы<\/h2>\n<p>Сквозной идентификатор — эффективное решение проблемы мэтчинга персональных данных. Он позволяет максимально автоматизировать процесс отслеживания и устранения несовпадений или дубликатов клиентских сущностей при выгрузке данных для анализа. В случаях, когда объем данных в системе невелик, а у компании нет возможности выделить ресурсы на реализацию такого решения, можно воспользоваться и другими вариантами. Например, алерты в оркестраторе процессов хорошо справятся в ситуации, когда объединить данные — вопрос ручного запуска одного скрипта раз в неделю. Но сквозной идентификатор — наверное, самое универсальное из доступных решений, которое покроет большинство ошибок и заметно уменьшит погрешность в качестве данных.<\/p>\n",
            "date_published": "2024-09-16T17:57:31+03:00",
            "date_modified": "2024-09-16T17:57:05+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/students.png",
            "_date_published_rfc2822": "Mon, 16 Sep 2024 17:57:31 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "148",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/students.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/image-2.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/filter.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/unnamed.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/discord.png"
                ]
            }
        },
        {
            "id": "105",
            "url": "http:\/\/test.leftjoin.ru\/all\/python-logger\/",
            "title": "Эффективное логирование в Python",
            "content_html": "<p>В Python существует встроенный модуль logging, который позволяет журналировать этапы выполнения программы. Логирование полезно когда, например, нужно оставить большой скрипт сбора \/ обработки данных на длительное время, а в случае возникновения непредвиденных ошибок выяснить, с чем они могут быть связаны. Анализ логов позволяет быстро и эффективно выявлять проблемные места в коде, но для удобного использования модуля следует написать несколько функций по взаимодействию с ним и вынести их в отдельный файл — сегодня мы этим и займёмся.<\/p>\n<h2>Пишем логгер<\/h2>\n<p>Создадим файл loggers.py. Для начала импортируем модули и задаём пару значений по умолчанию — директорию для файла с логом и наименование конфигурационного файла, содержащего шаблоны логирования. Его мы опишем следом.<\/p>\n<pre class=\"e2-text-code\"><code>import os\r\nimport json\r\nimport logging\r\nimport logging.config\r\n\r\nFOLDER_LOG = &quot;log&quot;\r\nLOGGING_CONFIG_FILE = 'loggers.json'<\/code><\/pre><p>Опишем функцию для создания папки с логом: она принимает наименование для папки, но по умолчанию будет называть её «log». Директорию создаём при помощи модуля os и только в том случае, если такой директории ещё не существует.<\/p>\n<pre class=\"e2-text-code\"><code>def create_log_folder(folder=FOLDER_LOG):\r\n    if not os.path.exists(folder):\r\n        os.mkdir(folder)<\/code><\/pre><p>Теперь опишем функцию создания нового логгера по заданному шаблону. Функция должна создать директорию для логирования, открыть конфигурационный файл и достать нужный шаблон. Затем по шаблону при помощи модуля logging создаём новый логгер:<\/p>\n<pre class=\"e2-text-code\"><code>def get_logger(name, template='default'):\r\n    create_log_folder()\r\n    with open(LOGGING_CONFIG_FILE, &quot;r&quot;) as f:\r\n        dict_config = json.load(f)\r\n        dict_config[&quot;loggers&quot;][name] = dict_config[&quot;loggers&quot;][template]\r\n    logging.config.dictConfig(dict_config)\r\n    return logging.getLogger(name)<\/code><\/pre><p>Для удобства опишем ещё одну функцию — получение стандартного лога. Она ничего не принимает и нужна только для инициализации лога с шаблоном default:<\/p>\n<pre class=\"e2-text-code\"><code>def get_default_logger():\r\n    create_log_folder()\r\n    with open(LOGGING_CONFIG_FILE, &quot;r&quot;) as f:\r\n        logging.config.dictConfig(json.load(f))\r\n\r\n    return logging.getLogger(&quot;default&quot;)<\/code><\/pre><h2>Описываем конфигурационный файл<\/h2>\n<p>Создадим по соседству файл loggers.json — он будет содержать настройки логгера. Внутри указываем такие настройки, как версию логгера, форматы логирования для разных уровней, наименование выходного файла и его максимальный размер:<\/p>\n<pre class=\"e2-text-code\"><code>{\r\n    &quot;version&quot;: 1,\r\n    &quot;disable_existing_loggers&quot;: false,\r\n    &quot;formatters&quot;: {\r\n        &quot;default&quot;: {\r\n            &quot;format&quot;: &quot;%(asctime)s - %(processName)-10s - %(name)-10s - %(levelname)-8s - %(message)s&quot;\r\n        }\r\n    },\r\n    &quot;handlers&quot;: {\r\n        &quot;console&quot;: {\r\n            &quot;class&quot;: &quot;logging.StreamHandler&quot;,\r\n            &quot;level&quot;: &quot;INFO&quot;,\r\n            &quot;formatter&quot;: &quot;default&quot;\r\n        },\r\n        &quot;rotating_file&quot;: {\r\n            &quot;class&quot;: &quot;logging.handlers.RotatingFileHandler&quot;,\r\n            &quot;level&quot;: &quot;DEBUG&quot;,\r\n            &quot;formatter&quot;: &quot;default&quot;,\r\n            &quot;filename&quot;: &quot;log\/main.log&quot;,\r\n            &quot;maxBytes&quot;: 10485760,\r\n            &quot;backupCount&quot;: 20\r\n        }\r\n    },\r\n    &quot;loggers&quot;: {\r\n        &quot;default&quot;: {\r\n            &quot;handlers&quot;: [&quot;console&quot;, &quot;rotating_file&quot;],\r\n            &quot;level&quot;: &quot;DEBUG&quot;\r\n        }\r\n    }\r\n}<\/code><\/pre><h2>Использование логгера<\/h2>\n<p>Теперь давайте представим, что вы выгружаете данные по API и складываете их в базу данных на примере нашего материала про <a href=\"http:\/\/test.leftjoin.ru\/all\/tranzakcii-v-sqlalchemy\/\">транзакции в SQLAlchemy<\/a>. Рассмотрим заключительную часть кода: добавим строку с инициализацией стандартного логгера и изменим код так, чтобы сначала в лог выводился offset, затем в случае успеха предложение «Successfully inserted data», а в случае ошибки выводилась сама ошибка и предложение: «Error: tried to insert data but got an error».<\/p>\n<pre class=\"e2-text-code\"><code>logger = get_logger('main')\r\n\r\noffset = 0\r\nsubs_count = get_subs_count(group_id)\r\n\r\nwhile offset &lt; subs_count:\r\n    with engine.connect() as conn:\r\n        transaction = conn.begin()\r\n        try:\r\n            logger.info(f&quot;{offset} \/ {subs_count}&quot;)\r\n            df = get_subs_info(group_id, offset)\r\n            df.to_sql('subscribers', con=conn, if_exists='append', index=False)\r\n            if offset == 10:\r\n                raise(ValueError(&quot;This is a test errror&quot;))\r\n            transaction.commit()\r\n            logger.info(f&quot;Successfully inserted data&quot;)\r\n        except Exception as E:\r\n            transaction.rollback()\r\n            logger.error(f&quot;Error: tried to insert {df} but got an error: {E}&quot;)\r\n    time.sleep(1)\r\n    offset += 10<\/code><\/pre><p>Теперь во время работы программы будет отображаться такой вывод, который также будет записан в файл main.log папки log в директории проекта. После завершения работы программы можно исследовать логи, посмотреть, на каких offset возникли проблемы, какие данные не удалось вставить и прочитать текст ошибки:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png\" width=\"994\" height=\"468\" alt=\"\" \/>\n<\/div>\n",
            "date_published": "2021-03-30T13:51:48+03:00",
            "date_modified": "2021-03-26T15:11:01+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png",
            "_date_published_rfc2822": "Tue, 30 Mar 2021 13:51:48 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "105",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png"
                ]
            }
        },
        {
            "id": "101",
            "url": "http:\/\/test.leftjoin.ru\/all\/coinkeeper-data-bot\/",
            "title": "Бот для преобразования данных из Coinkeeper",
            "content_html": "<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/@2x-copy.png.jpg\" width=\"2560\" height=\"1707\" alt=\"\" \/>\n<\/div>\n<p><a href=\"https:\/\/about.coinkeeper.me\">Coinkeeper<\/a> — кроссплатформенное приложение для учёта финансов. Внутри можно выпустить виртуальную банковскую карту Visa с бесплатным годовым обслуживанием, которая будет присылать уведомления, если вы тратите больше, чем запланировали. Помимо уведомлений, приложение ведёт историю трат и позволяет выгрузить сводный отчёт в формате csv. Данные, которое выгружает приложение ещё не готовы к анализу и выглядят так:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-27.png\" width=\"846\" height=\"647\" alt=\"\" \/>\n<\/div>\n<p>Азат Шарипов сделал скрипт обработки данных в пригодный для Tableau вид и подготовил <a href=\"https:\/\/public.tableau.com\/profile\/azat3313#!\/vizhome\/CoinKeeperanalytics\/Dashboard1\">Tableau Public книгу<\/a>, а <a href=\"https:\/\/t.me\/revealthedata\">Рома Бунин<\/a> в рамках своего проекта «Переверстка» <a href=\"https:\/\/public.tableau.com\/profile\/roman4734#!\/vizhome\/CoinKeeper\/CoinKeeper\">переработал дашборд<\/a>.<\/p>\n<p>Мы решили тоже поучаствовать, и с нашей стороны Елизавета Мазурова сделала <a href=\"https:\/\/t.me\/coinkeeper_export_bot\">чат-бота<\/a>.<\/p>\n<p>Чат-бот крутой! Помимо того, что он может как и прежде отдавать обратно .csv-файл, он позволяет автоматизировать рутину по обновлению отчета через Google-таблицы. Как, наверное, многие помнят, Tableau Public может работать на гугл-таблицах или csv файлах, но не разрешает подключение к данным. Бот умный: он создаст за вас гугл-таблицу и когда вы повторно отправите ему новый файл обновит ее.<\/p>\n<h2>Использование бота<\/h2>\n<p>Перейдите в диалог с <a href=\"https:\/\/t.me\/coinkeeper_export_bot\">ботом<\/a> и введите команду \/start — в ответе бот расскажет немного о себе. Для продолжения работы нажмите на кнопку «Начать».<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2-26.png\" width=\"717\" height=\"184\" alt=\"\" \/>\n<\/div>\n<p>Сразу после можно отправить csv-файл, выгруженный из Coinkeeper:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/3-22.png\" width=\"489\" height=\"58\" alt=\"\" \/>\n<\/div>\n<p>Выберите тип файла — csv или таблицу в Google Spreadsheets.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/4-13.png\" width=\"782\" height=\"174\" alt=\"\" \/>\n<\/div>\n<p>В случае выбора csv-файла бот пришлёт его:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/5-14.png\" width=\"481\" height=\"115\" alt=\"\" \/>\n<\/div>\n<p>А в случае ссылки в первый раз нужно будет пройти небольшую регистрацию — указать почту и наименование для файла.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/6-14.png\" width=\"524\" height=\"56\" alt=\"\" \/>\n<\/div>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/7-8.png\" width=\"477\" height=\"57\" alt=\"\" \/>\n<\/div>\n<p>Затем бот пришлёт ссылку на файл:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/8-9.png\" width=\"666\" height=\"119\" alt=\"\" \/>\n<\/div>\n<p>Скрипт преобразовал данные, и таблицу можно указать в качестве источника данных в Tableau. А благодаря тому, что в случае загрузки нового файла создаётся не новая таблица, а обновляется старая, отчёт в Tableau тоже обновится. В результате открывается возможность еженедельно присылать боту новую таблицу и сразу переходить в обновлённый отчёт.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/9-8.png\" width=\"1217\" height=\"432\" alt=\"\" \/>\n<\/div>\n",
            "date_published": "2021-03-09T17:28:36+03:00",
            "date_modified": "2021-03-09T17:13:54+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/0@2x.jpeg",
            "_date_published_rfc2822": "Tue, 09 Mar 2021 17:28:36 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "101",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/0@2x.jpeg",
                    "http:\/\/test.leftjoin.ru\/pictures\/@2x.png-1.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/@2x-copy.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/1-27.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2-26.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/3-22.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/4-13.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/5-14.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/6-14.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/7-8.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/8-9.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/9-8.png"
                ]
            }
        },
        {
            "id": "100",
            "url": "http:\/\/test.leftjoin.ru\/all\/borderline-text-analysis\/",
            "title": "Python и тексты нового альбома Земфиры: анализируем суть песен",
            "content_html": "<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/Zemfira_borderline-2.jpg\" width=\"600\" height=\"600\" alt=\"\" \/>\n<\/div>\n<p>Неделю назад вышёл первый за 8 лет студийный альбом Земфиры «Бордерлайн». К работе помимо рок-певицы приложили руку разные люди, в том числе и её родственники — рифф для песни «таблетки» написал её племянник из Лондона. Альбом получился разнообразным: например, песня «остин» посвящена главному персонажу игры Homescapes российской студии Playrix (кстати, посмотрите свежие <a href=\"https:\/\/youtu.be\/SOx8afEUTnE\">Бизнес-секреты с братьями Бухманами<\/a>, там они тоже про это рассказывают) — Земфире нравится игра, и для трека она связалась со студией. А сингл «крым» был написан в качестве саундтрека к новой картине соратницы Земфиры — Ренаты Литвиновой.<\/p>\n<p class=\"note\">Послушать альбом в <a href=\"https:\/\/music.apple.com\/ru\/album\/бордерлайн\/1554865105\">Apple Music<\/a> \/ <a href=\"https:\/\/music.yandex.ru\/album\/14052981\">Яндекс.Музыке<\/a> \/ <a href=\"https:\/\/open.spotify.com\/album\/6khBsXmKA1FKjYVCIBy9kt\">Spotify<\/a><\/p>\n<p>Тем не менее, дух всего альбома довольно мрачен — в песнях часто повторяются слова «боль», «ад», «бесишь» и прочие по смыслу. Мы решили провести разведочный анализ нового альбома, а затем при помощи модели Word2Vec и косинусной меры посмотреть на семантическую близость песен между собой и вычислить общее настроение альбома.<\/p>\n<p>Для тех, кому скучно читать про подготовку данных и шаги анализа можно <a href=\"http:\/\/test.leftjoin.ru\/all\/borderline-text-analysis\/#result\">перейти сразу к результатам<\/a>.<\/p>\n<h2>Подготовка данных<\/h2>\n<p>Для начала работы напишем скрипт обработки данных. Цель скрипта — из множества текстовых файлов, в каждом из которых лежит по песне, собрать единую csv-таблицу. При этом текст треков очищаем от знаков пунктуации и ненужных слов.<\/p>\n<pre class=\"e2-text-code\"><code>import pandas as pd\r\nimport re\r\nimport string\r\nimport pymorphy2\r\nfrom nltk.corpus import stopwords<\/code><\/pre><p>Создаём морфологический анализатор и расширяем список всего, что нужно отбросить:<\/p>\n<pre class=\"e2-text-code\"><code>morph = pymorphy2.MorphAnalyzer()\r\nstopwords_list = stopwords.words('russian')\r\nstopwords_list.extend(['куплет', 'это', 'я', 'мы', 'ты', 'припев', 'аутро', 'предприпев', 'lyrics', '1', '2', '3', 'то'])\r\nstring.punctuation += '—'<\/code><\/pre><p>Названия песен приведены на английском — создадим словарь для перевода на русский и словарь, из которого позднее сделаем таблицу:<\/p>\n<pre class=\"e2-text-code\"><code>result_dict = dict()\r\n\r\nsongs_dict = {\r\n    'snow':'снег идёт',\r\n    'crimea':'крым',\r\n    'mother':'мама',\r\n    'ostin':'остин',\r\n    'abuse':'абьюз',\r\n    'wait_for_me':'жди меня',\r\n    'tom':'том',\r\n    'come_on':'камон',\r\n    'coat':'пальто',\r\n    'this_summer':'этим летом',\r\n    'ok':'ок',\r\n    'pills':'таблетки'\r\n}<\/code><\/pre><p>Опишем несколько функций. Первая читает целиком песню из файла и удаляет переносы строки, вторая очищает текст от ненужных символов и слов, а третья при помощи морфологического анализатора pymorphy2 приводит слова к нормальной форме. Модуль pymorphy2 не всегда хорошо справляется с неоднозначностью — для слов «ад» и «рай» потребуется дополнительная обработка.<\/p>\n<pre class=\"e2-text-code\"><code>def read_song(filename):\r\n    f = open(f'{filename}.txt', 'r').read()\r\n    f = f.replace('\\n', ' ')\r\n    return f\r\n\r\ndef clean_string(text):\r\n    text = re.split(' |:|\\.|\\(|\\)|,|&quot;|;|\/|\\n|\\t|-|\\?|\\[|\\]|!', text)\r\n    text = ' '.join([word for word in text if word not in string.punctuation])\r\n    text = text.lower()\r\n    text = ' '.join([word for word in text.split() if word not in stopwords_list])\r\n    return text\r\n\r\ndef string_to_normal_form(string):\r\n    string_lst = string.split()\r\n    for i in range(len(string_lst)):\r\n        string_lst[i] = morph.parse(string_lst[i])[0].normal_form\r\n        if (string_lst[i] == 'аду'):\r\n            string_lst[i] = 'ад'\r\n        if (string_lst[i] == 'рая'):\r\n            string_lst[i] = 'рай'\r\n    string = ' '.join(string_lst)\r\n    return string<\/code><\/pre><p>Проходим по каждой песне и читаем файл с соответствующим названием:<\/p>\n<pre class=\"e2-text-code\"><code>name_list = []\r\ntext_list = []\r\nfor song, name in songs_dict.items():\r\n    text = string_to_normal_form(clean_string(read_song(song)))\r\n    name_list.append(name)\r\n    text_list.append(text)<\/code><\/pre><p>Затем объединяем всё в DataFrame и сохраняем в виде csv-файла.<\/p>\n<pre class=\"e2-text-code\"><code>df = pd.DataFrame()\r\ndf['name'] = name_list\r\ndf['text'] = text_list\r\ndf['time'] = [290, 220, 187, 270, 330, 196, 207, 188, 269, 189, 245, 244]\r\ndf.to_csv('borderline.csv', index=False)<\/code><\/pre><p>Результат:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-26.png\" width=\"477\" height=\"365\" alt=\"\" \/>\n<\/div>\n<h2>Облако слов по всему альбому<\/h2>\n<p>Начнём анализ с построения облака слов — оно отобразит, какие слова чаще всего встречаются в песнях. Импортируем нужные библиотеки, читаем csv-файл и устанавливаем конфигурации:<\/p>\n<pre class=\"e2-text-code\"><code>import nltk\r\nfrom wordcloud import WordCloud\r\nimport pandas as pd\r\nimport matplotlib.pyplot as plt\r\nfrom nltk import word_tokenize, ngrams\r\n\r\n%matplotlib inline\r\nnltk.download('punkt')\r\ndf = pd.read_csv('borderline.csv')<\/code><\/pre><p>Теперь создаём новую фигуру, устанавливаем параметры оформления и при помощи библиотеки wordcloud отображаем слова с размером прямо пропорциональным частоте упоминания слова. Над каждым графиком дополнительно указываем название песни.<\/p>\n<pre class=\"e2-text-code\"><code>fig = plt.figure()\r\nfig.patch.set_facecolor('white')\r\nplt.subplots_adjust(wspace=0.3, hspace=0.2)\r\ni = 1\r\nfor name, text in zip(df.name, df.text):\r\n    tokens = word_tokenize(text)\r\n    text_raw = &quot; &quot;.join(tokens)\r\n    wordcloud = WordCloud(colormap='PuBu', background_color='white', contour_width=10).generate(text_raw)\r\n    plt.subplot(4, 3, i, label=name,frame_on=True)\r\n    plt.tick_params(labelsize=10)\r\n    plt.imshow(wordcloud)\r\n    plt.axis(&quot;off&quot;)\r\n    plt.title(name,fontdict={'fontsize':7,'color':'grey'},y=0.93)\r\n    plt.tick_params(labelsize=10)\r\n    i += 1<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2@2x-2.png.jpg\" width=\"2560\" height=\"1707\" alt=\"\" \/>\n<\/div>\n<h2>EDA текстов альбома<\/h2>\n<p>Теперь проанализируем тексты песен — импортируем библиотеки для работы с данными и визуализации:<\/p>\n<pre class=\"e2-text-code\"><code>import plotly.graph_objects as go\r\nimport plotly.figure_factory as ff\r\nfrom scipy import spatial\r\nimport collections\r\nimport pymorphy2\r\nimport gensim\r\n\r\nmorph = pymorphy2.MorphAnalyzer()<\/code><\/pre><p>Сначала посчитаем число слов в каждой песне, число уникальных слов и процентное соотношение:<\/p>\n<pre class=\"e2-text-code\"><code>songs = []\r\ntotal = []\r\nuniq = []\r\npercent = []\r\n\r\nfor song, text in zip(df.name, df.text):\r\n    songs.append(song)\r\n    total.append(len(text.split()))\r\n    uniq.append(len(set(text.split())))\r\n    percent.append(round(len(set(text.split())) \/ len(text.split()), 2) * 100)<\/code><\/pre><p>А теперь составим из этого DataFrame и дополнительно посчитаем число слов в минуту для каждой песни:<\/p>\n<pre class=\"e2-text-code\"><code>df_words = pd.DataFrame()\r\ndf_words['song'] = songs\r\ndf_words['total words'] = total\r\ndf_words['uniq words'] = uniq\r\ndf_words['percent'] = percent\r\ndf_words['time'] = df['time']\r\ndf_words['words per minute'] = round(total \/ (df['time'] \/\/ 60))\r\ndf_words = df_words[::-1]<\/code><\/pre><div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/3-21.png\" width=\"480\" height=\"369\" alt=\"\" \/>\n<\/div>\n<p>Данные хорошо бы визуализировать — построим две столбиковые диаграммы: одну для числа слов в песне, а другую для числа слов в минуту.<\/p>\n<pre class=\"e2-text-code\"><code>colors_1 = ['rgba(101,181,205,255)'] * 12\r\ncolors_2 = ['rgba(62,142,231,255)'] * 12\r\n\r\nfig = go.Figure(data=[\r\n    go.Bar(name='📝 Всего слов',\r\n           text=df_words['total words'],\r\n           textposition='auto',\r\n           x=df_words.song,\r\n           y=df_words['total words'],\r\n           marker_color=colors_1,\r\n           marker=dict(line=dict(width=0)),),\r\n    go.Bar(name='🌀 Уникальных слов',\r\n           text=df_words['uniq words'].astype(str) + '&lt;br&gt;'+ df_words.percent.astype(int).astype(str) + '%' ,\r\n           textposition='inside',\r\n           x=df_words.song,\r\n           y=df_words['uniq words'],\r\n           textfont_color='white',\r\n           marker_color=colors_2,\r\n           marker=dict(line=dict(width=0)),),\r\n])\r\n\r\nfig.update_layout(barmode='group')\r\n\r\nfig.update_layout(\r\n    title = \r\n        {'text':'&lt;b&gt;Соотношение числа уникальных слов к общему количеству&lt;\/b&gt;&lt;br&gt;&lt;span style=&quot;color:#666666&quot;&gt;&lt;\/span&gt;'},\r\n    showlegend = True,\r\n    height=650,\r\n    font={\r\n        'family':'Open Sans, light',\r\n        'color':'black',\r\n        'size':14\r\n    },\r\n    plot_bgcolor='rgba(0,0,0,0)',\r\n)\r\nfig.update_layout(legend=dict(\r\n    yanchor=&quot;top&quot;,\r\n    xanchor=&quot;right&quot;,\r\n))\r\n\r\nfig.show()<\/code><\/pre><iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/76.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<pre class=\"e2-text-code\"><code>colors_1 = ['rgba(101,181,205,255)'] * 12\r\ncolors_2 = ['rgba(238,85,59,255)'] * 12\r\n\r\nfig = go.Figure(data=[\r\n    go.Bar(name='⏱️ Длина трека, мин.',\r\n           text=round(df_words['time'] \/ 60, 1),\r\n           textposition='auto',\r\n           x=df_words.song,\r\n           y=-df_words['time'] \/\/ 60,\r\n           marker_color=colors_1,\r\n           marker=dict(line=dict(width=0)),\r\n          ),\r\n    go.Bar(name='🔄 Слов в минуту',\r\n           text=df_words['words per minute'],\r\n           textposition='auto',\r\n           x=df_words.song,\r\n           y=df_words['words per minute'],\r\n           marker_color=colors_2,\r\n           textfont_color='white',\r\n           marker=dict(line=dict(width=0)),\r\n          ),\r\n])\r\n\r\nfig.update_layout(barmode='overlay')\r\n\r\nfig.update_layout(\r\n    title = \r\n        {'text':'&lt;b&gt;Длина трека и число слов в минуту&lt;\/b&gt;&lt;br&gt;&lt;span style=&quot;color:#666666&quot;&gt;&lt;\/span&gt;'},\r\n    showlegend = True,\r\n    height=650,\r\n    font={\r\n        'family':'Open Sans, light',\r\n        'color':'black',\r\n        'size':14\r\n    },\r\n    plot_bgcolor='rgba(0,0,0,0)'\r\n)\r\n\r\n\r\nfig.show()<\/code><\/pre><iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/78.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<h2>Работа с Word2Vec моделью<\/h2>\n<p>При помощи модуля gensim загружаем модель, указывая на бинарный файл:<\/p>\n<pre class=\"e2-text-code\"><code>model = gensim.models.KeyedVectors.load_word2vec_format('model.bin', binary=True)<\/code><\/pre><p class=\"note\">Для материала мы использовали готовую обученную на Национальном Корпусе Русского Языка модель от сообщества <a href=\"https:\/\/rusvectores.org\/ru\/models\/\">RusVectōrēs<\/a><\/p>\n<p>Модель Word2Vec основана на нейронных сетях и позволяет представлять слова в виде векторов, учитывая семантическую составляющую. Это означает, что если мы возьмём два слова — например, «мама» и «папа», представим их в виде двух векторов и посчитаем косинус, значения будет близко к 1. Аналогично, у двух слов, не имеющих ничего общего по смыслу косинусная мера близка к 0.<\/p>\n<p>Опишем функцию get_vector: она будет принимать список слов, распознавать для каждого часть речи, а затем получать и суммировать вектора — так мы сможем находить вектора не для одного слова, а для целых предложений и текстов.<\/p>\n<pre class=\"e2-text-code\"><code>def get_vector(word_list):\r\n    vector = 0\r\n    for word in word_list:\r\n        pos = morph.parse(word)[0].tag.POS\r\n        if pos == 'INFN':\r\n            pos = 'VERB'\r\n        if pos in ['ADJF', 'PRCL', 'ADVB', 'NPRO']:\r\n            pos = 'NOUN'\r\n        if word and pos:\r\n            try:\r\n                word_pos = word + '_' + pos\r\n                this_vector = model.word_vec(word_pos)\r\n                vector += this_vector\r\n            except KeyError:\r\n                continue\r\n    return vector<\/code><\/pre><p>Для каждой песни находим вектор и собираем соответствующий столбец в DataFrame:<\/p>\n<pre class=\"e2-text-code\"><code>vec_list = []\r\nfor word in df['text']:\r\n    vec_list.append(get_vector(word.split()))\r\ndf['vector'] = vec_list<\/code><\/pre><p>Теперь сравним вектора между собой, посчитав их косинусную близость. Те песни, у которых косинусная метрика выше 0,5 запомним отдельно — так мы получим самые близкие пары песен. Данные о сравнении векторов запишем в двумерный список result.<\/p>\n<pre class=\"e2-text-code\"><code>similar = dict()\r\nresult = []\r\nfor song_1, vector_1 in zip(df.name, df.vector):\r\n    sub_list = []\r\n    for song_2, vector_2 in zip(df.name.iloc[::-1], df.vector.iloc[::-1]):\r\n        res = 1 - spatial.distance.cosine(vector_1, vector_2)\r\n        if res &gt; 0.5 and song_1 != song_2 and (song_1 + ' \/ ' + song_2 not in similar.keys() and song_2 + ' \/ ' + song_1 not in similar.keys()):\r\n            similar[song_1 + ' \/ ' + song_2] = round(res, 2)\r\n        sub_list.append(round(res, 2))\r\n    result.append(sub_list)<\/code><\/pre><p>Самые похожие треки соберём в отдельный DataFrame:<\/p>\n<pre class=\"e2-text-code\"><code>df_top_sim = pd.DataFrame()\r\ndf_top_sim['name'] = list(similar.keys())\r\ndf_top_sim['value'] = list(similar.values())\r\ndf_top_sim.sort_values(by='value', ascending=False)<\/code><\/pre><p>И построим такой же bar chart:<\/p>\n<pre class=\"e2-text-code\"><code>colors = ['rgba(101,181,205,255)'] * 5\r\n\r\nfig = go.Figure([go.Bar(x=df_top_sim['name'],\r\n                        y=df_top_sim['value'],\r\n                        marker_color=colors,\r\n                        width=[0.4,0.4,0.4,0.4,0.4],\r\n                        text=df_top_sim['value'],\r\n                        textfont_color='white',\r\n                        textposition='auto')])\r\n\r\nfig.update_layout(\r\n    title = \r\n        {'text':'&lt;b&gt;Топ-5 схожих песен&lt;\/b&gt;&lt;br&gt;&lt;span style=&quot;color:#666666&quot;&gt;&lt;\/span&gt;'},\r\n    showlegend = False,\r\n    height=650,\r\n    font={\r\n        'family':'Open Sans, light',\r\n        'color':'black',\r\n        'size':14\r\n    },\r\n    plot_bgcolor='rgba(0,0,0,0)',\r\n    xaxis={'categoryorder':'total descending'}\r\n)\r\n\r\nfig.show()<\/code><\/pre><iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/80.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Имея вектор каждой песни, давайте посчитаем вектор всего альбома — сложим вектора песен. Затем для такого вектора при помощи модели получим самые близкие по духу и смыслу слова.<\/p>\n<pre class=\"e2-text-code\"><code>def get_word_from_tlist(lst):\r\n    for word in lst:\r\n        word = word[0].split('_')[0]\r\n        print(word, end=' ')\r\n\r\nvec_sum = 0\r\nfor vec in df.vector:\r\n    vec_sum += vec\r\nsim_word = model.similar_by_vector(vec_sum)\r\nget_word_from_tlist(sim_word)<\/code><\/pre><p><span style=\"color: '#65b5cd'; font-size: 1.2em\"><b>небо тоска тьма пламень плакать горе печаль сердце солнце мрак<\/b><\/span><\/p>\n<p>Наверное, это ключевой результат и описание альбома Земфиры всего лишь в 10 словах.<\/p>\n<p>Наконец, построим общую тепловую карту, каждая ячейка которой — результат сравнения косинусной мерой текстов двух треков.<\/p>\n<pre class=\"e2-text-code\"><code>colorscale=[[0.0, &quot;rgba(255,255,255,255)&quot;],\r\n            [0.1, &quot;rgba(229,232,237,255)&quot;],\r\n            [0.2, &quot;rgba(216,222,232,255)&quot;],\r\n            [0.3, &quot;rgba(205,214,228,255)&quot;],\r\n            [0.4, &quot;rgba(182,195,218,255)&quot;],\r\n            [0.5, &quot;rgba(159,178,209,255)&quot;],\r\n            [0.6, &quot;rgba(137,161,200,255)&quot;],\r\n            [0.7, &quot;rgba(107,137,188,255)&quot;],\r\n            [0.8, &quot;rgba(96,129,184,255)&quot;],\r\n            [1.0, &quot;rgba(76,114,176,255)&quot;]]\r\n\r\nfont_colors = ['black']\r\nx = list(df.name.iloc[::-1])\r\ny = list(df.name)\r\nfig = ff.create_annotated_heatmap(result, x=x, y=y, colorscale=colorscale, font_colors=font_colors)\r\nfig.show()<\/code><\/pre><iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/82.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<h2><a name=\"result\">Результаты анализа и интерпретация данных<\/a><\/h2>\n<p>Давайте ещё раз посмотрим на всё, что у нас получилось — начнём с облака слов. Нетрудно заметить, что у слов «боль», «невозможно», «сорваться», «растерзаны», «сложно», «терпеть», «любить» размер весьма приличный — всё потому, что такие слова встречаются часто на протяжении всего текста песен:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2@2x-2.png-1.jpg\" width=\"2560\" height=\"1707\" alt=\"\" \/>\n<\/div>\n<p>Одной из самых «разнообразных» песен оказался сингл «крым» — в нём 74% уникальных слов. А в песне «снег идёт» слов совсем мало, поэтому большинство — 82% уникальны. Самой большой песней в альбоме получился трек «таблетки» — суммарно там около 150 слов.<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/76.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Как было выяснено на прошлом графике, самый «динамичный» трек — «таблетки», целых 37 слов в минуту — практически по слову на каждые две секунды. А самый длинный трек — «абъюз», в нём же и согласно предыдущему графику практически самый низкий процент уникальных слов — 46%.<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/78.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Топ-5 самых семантически похожих пар текстов:<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/80.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<p>Ещё мы получили вектор всего альбома и подобрали самые близкие слова. Только посмотрите на них — «тьма», «тоска», «плакать», «горе», «печаль», «сердце» — это же ведь и есть тот перечень слов, который характеризует лирику Земфиры!<\/p>\n<p><span style=\"color: '#65b5cd'; font-size: 1.2em\"><b>небо тоска тьма пламень плакать горе печаль сердце солнце мрак<\/b><\/span><\/p>\n<p>Финал — тепловая карта. По визуализации заметно, что практически все песни достаточно схожи между собой — косинусная мера у многих пар превышает значение в 0.4.<\/p>\n<iframe id=\"igraph\" scrolling=\"no\" style=\"border:none;\" seamless=\"seamless\" src=\"https:\/\/plotly.com\/~Elisejj\/82.embed?showlink=false\" height=\"650\" width=\"100%\"><\/iframe>\n<h2>Выводы<\/h2>\n<p>В материале мы провели EDA всего текста нового альбома и при помощи предобученной модели Word2Vec доказали гипотезу — большинство песен «бордерлайна» пронизывают довольно мрачные и тексты. И это нормально, ведь Земфиру мы любим именно за искренность и прямолинейность.<\/p>\n",
            "date_published": "2021-03-05T19:54:06+03:00",
            "date_modified": "2021-03-08T17:47:56+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/Zemfira_borderline.jpg",
            "_date_published_rfc2822": "Fri, 05 Mar 2021 19:54:06 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "100",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/Zemfira_borderline.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/Zemfira_borderline-2.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/1-26.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2@2x-2.png.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/3-21.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2@2x-2.png-1.jpg"
                ]
            }
        },
        {
            "id": "99",
            "url": "http:\/\/test.leftjoin.ru\/all\/apple-health-export\/",
            "title": "Экспорт исторических данных Apple Health в Google Sheets",
            "content_html": "<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/pic.jpg\" width=\"659\" height=\"395\" alt=\"\" \/>\n<\/div>\n<p>Для устройств на базе iOS и watchOS существует приложение Health, которое ежедневно записывает все данные о здоровье носителя и синхронизирует их со сторонними приложениями. Все эти данные в любой момент можно получить прямо из приложения в виде XML-документа. Сегодня мы выгрузим исторические данные о здоровье из приложения Apple Health, обработаем их и отправим в Google Sheets для анализа и визуализации в будущем.<\/p>\n<h2>Экспорт архива из приложения<\/h2>\n<p>Зайдите в приложение Health на iPhone. Нажмите на аватарку своего профиля в верхнем правом углу — откроется меню приложения.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/i@2x.jpg\" width=\"371\" height=\"648\" alt=\"\" \/>\n<\/div>\n<p>Внизу нажмите на кнопку «Экспортировать медданные». Через некоторое время откроется меню экспорта — отправьте архив себе на компьютер любым способом, можно по AirDrop или даже по почте в письме самому себе. Из архива нужен только один файл — «экспорт.xml». Достаньте его и положите в папку с ноутбуком jupyter.<\/p>\n<h2>Парсер XML в DataFrame<\/h2>\n<p>При помощи библиотеки XML составляем дерево на основе документа из Health. Собирать в словарь будем следующие атрибуты: тип, единица измерения, дата создания, дата начала, дата конца, значение. Проходим по всему дереву и отправляем полученные значения атрибутов в records_dict.<\/p>\n<pre class=\"e2-text-code\"><code>from xml.etree import ElementTree\r\nimport pandas as pd\r\nimport datetime\r\n\r\ntree = ElementTree.parse('экспорт.xml')\r\nroot = tree.getroot()\r\nrecords = root.findall('Record')\r\n\r\nrecords_dict = {\r\n    'type':[],\r\n    'unit':[],\r\n    'creationDate':[],\r\n    'startDate':[],\r\n    'endDate':[],\r\n    'value':[]\r\n}\r\n\r\nfor record in records:\r\n    for attribute in records_dict.keys():\r\n        attribute_value = record.get(attribute)\r\n        records_dict[attribute].append(attribute_value)<\/code><\/pre><p>События записаны в нечитабельном виде — для перевода составим специальный словарь с нужными типами, где ключ — старое название, а значение — новое. Мы возьмём только 11 событий: минуты осознанности, дистанция на велосипеде, дистанция заплыва, дистанция ходьбы и бега, пройдено пролётов, пульс, пульс в покое, шаги, активная энергия, энергия покоя и средний пульс при ходьбе.<\/p>\n<pre class=\"e2-text-code\"><code>types_dict = {\r\n    'HKCategoryTypeIdentifierMindfulSession': 'Mindful Session',\r\n    'HKQuantityTypeIdentifierDistanceCycling': 'Cycling Distance',\r\n    'HKQuantityTypeIdentifierDistanceSwimming': 'Swimming Distance',\r\n    'HKQuantityTypeIdentifierDistanceWalkingRunning': 'Walking + Running Distance',\r\n    'HKQuantityTypeIdentifierFlightsClimbed': 'Flights Climbed',\r\n    'HKQuantityTypeIdentifierHeartRate': 'Heart Rate',\r\n    'HKQuantityTypeIdentifierRestingHeartRate': 'Resting Heart Rate',\r\n    'HKQuantityTypeIdentifierStepCount': 'Steps',\r\n    'HKQuantityTypeIdentifierActiveEnergyBurned': 'Active Calories',\r\n    'HKQuantityTypeIdentifierBasalEnergyBurned': 'Resting Calories',\r\n    'HKQuantityTypeIdentifierWalkingHeartRateAverage': 'Walking Heart Rate Average'\r\n}<\/code><\/pre><p>Для минут осознанности в поле значения записей нет — мы сами посчитаем позже это поле как разницу даты окончания и начала события. Разница будет представлена как timedelta, поэтому напишем функцию перевода timedelta в минуты:<\/p>\n<pre class=\"e2-text-code\"><code>def td_to_m(td):\r\n    seconds = td.seconds + td.days * 24 * 60 * 60\r\n    return seconds \/\/ 60<\/code><\/pre><p>Из словаря создаём DataFrame и задаём названия колонок. Оставляем только те 11 событий, которые есть в словаре types_dict и приводим все колонки к нужным типам данных:<\/p>\n<pre class=\"e2-text-code\"><code>df = pd.DataFrame(records_dict)\r\ndf.columns = ['type', 'unit', 'date', 'start', 'end', 'value']\r\ndf = df[df['type'].isin(types_dict.keys())]\r\ndf['value'] = df['value'].astype(float)\r\ndf['date'] = df['date'].astype('datetime64')\r\ndf['date'] = df['date'].dt.date\r\ndf['start'] = df['start'].astype('datetime64')\r\ndf['end'] = df['end'].astype('datetime64')\r\ndf['unit'] = df['unit'].astype(str)<\/code><\/pre><p>Данные Health при экспорте никак не группируются — мы сделаем это самостоятельно. DataFrame можно поделить на три: в первом будут события, у которых единица измерения «количество в минуту» — для таких событий нужно искать среднее значение. В другой группе будут минуты осознанности — считаем число минут в каждой записи и суммируем. В последней группе находятся все остальные записи, связанные с количественными событиями — шаги, дистанция ходьбы и бега и так далее. Их тоже суммируем.<\/p>\n<pre class=\"e2-text-code\"><code>df_1 = df[df['unit'] == 'count\/min']\r\ndf_1 = df_1.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',\r\n                                                                      'end':'max',\r\n                                                                      'value':'mean'})\r\n\r\ndf_2 = df[df['type'] == 'HKCategoryTypeIdentifierMindfulSession']\r\ndf_2['value'] = df_2['end'] - df_2['start']\r\ndf_2['value'] = df_2['value'].map(td_to_m)\r\ndf_2 = df_2.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',\r\n                                                                     'end':'max',\r\n                                                                     'value':'sum'})\r\ndf_3 = df[(df['unit'] != 'count\/min') &amp; (df['type'] != 'HKCategoryTypeIdentifierMindfulSession')]\r\ndf_3 = df_3.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',\r\n                                                                      'end':'max',\r\n                                                                      'value':'sum'})\r\ndf = pd.concat([df_1, df_2, df_3])<\/code><\/pre><p>Дату создания записи переводим в строковый тип. Все наименования типов событий заменяем согласно словарю types_dict. В переменную dates записываем все уникальные даты.<\/p>\n<pre class=\"e2-text-code\"><code>df['date'] = df['date'].astype(str)\r\ndf['type'] = df['type'].apply(lambda x: types_dict[x])\r\ndates = df['date'].unique()<\/code><\/pre><p>В результате нужен словарь с колонкой даты и отдельной колонкой под каждое из 11 событий:<\/p>\n<pre class=\"e2-text-code\"><code>result = {\r\n    'date': [],\r\n    'Steps': [],\r\n    'Walking + Running Distance': [],\r\n    'Swimming Distance': [],\r\n    'Cycling Distance': [],\r\n    'Resting Calories': [],\r\n    'Active Calories': [],\r\n    'Flights Climbed': [],\r\n    'Heart Rate': [],\r\n    'Resting Heart Rate': [],\r\n    'Walking Heart Rate Average': [],\r\n    'Mindful Session': []\r\n}<\/code><\/pre><p>Проходим по каждой дате и получаем кусок DataFrame за эту дату. Добавляем её в словарь и проходим по каждому ключу, пробуя добавить значение:<\/p>\n<pre class=\"e2-text-code\"><code>for date in dates:\r\n    part = df[df['date'] == date]\r\n    result['date'].append(date)\r\n    for key in result.keys():\r\n        if key == 'date':\r\n            continue\r\n        else:\r\n            field = 'value'\r\n        try:\r\n            result[key].append(part[part['type'] == key][field].values[0])\r\n        except IndexError:\r\n            result[key].append(None)<\/code><\/pre><p>Из полученного словаря создаём DataFrame, округляем всё до двух знаков после запятой и сортируем по дате:<\/p>\n<pre class=\"e2-text-code\"><code>result_df = pd.DataFrame(result)\r\nresult_df = result_df.round(2)\r\nresult_df = result_df.sort_values(by='date')<\/code><\/pre><p>В результате получается такая таблица с историческими данными по 11 событиям:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2-24.png\" width=\"996\" height=\"530\" alt=\"\" \/>\n<\/div>\n<h2>Экспорт DataFrame в Google Sheets<\/h2>\n<p class=\"note\">Для экспорта в Google Docs необходим сервисный аккаунт и json-файл с ключом. О том, как его получить, мы писали в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/get-data-from-vk\/\" class=\"nu\">«<u>Собираем данные по рекламным кампаниям ВКонтакте<\/u>»<\/a><\/p>\n<p>Создайте новый документ в Google Sheets. Весь DataFrame можно вставить одним действием при помощи методов библиотеки gspread. Импортируйте её, а также укажите идентификатор документа и json-файл с ключом. В методе get_worksheet указывается порядковый номер листа в файле начиная с нуля.<\/p>\n<pre class=\"e2-text-code\"><code>import pandas as pd\r\nimport gspread\r\nfrom gspread_dataframe import set_with_dataframe\r\ngc = gspread.service_account(filename='serviceAccount.json')\r\nsh = gc.open_by_key('1osKA63LQkUC0FC0eIZ63jEJwn1TeIkUvqCV6ur')\r\nworksheet = sh.get_worksheet(0)<\/code><\/pre><p>В итоге в Google Spreadsheets появится такая таблица:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-03-03--15.31.48.png\" width=\"1375\" height=\"761\" alt=\"\" \/>\n<\/div>\n<p>А в следующем материале посмотрим, как наладить ежедневный экспорт данных Здоровья в эту таблицу при помощи шорткатов и Google AppScript!<\/p>\n",
            "date_published": "2021-03-03T16:57:03+03:00",
            "date_modified": "2021-03-03T17:00:07+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/ios@2x-1.jpg",
            "_date_published_rfc2822": "Wed, 03 Mar 2021 16:57:03 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "99",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/ios@2x-1.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/ios.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/pic.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/i@2x.jpg",
                    "http:\/\/test.leftjoin.ru\/pictures\/2-24.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-03-03--15.31.48.png"
                ]
            }
        },
        {
            "id": "94",
            "url": "http:\/\/test.leftjoin.ru\/all\/tranzakcii-v-sqlalchemy\/",
            "title": "Транзакции в SQLAlchemy",
            "content_html": "<p>Транзакция — последовательность действий, связанных с базой данных. Их основная польза заключается в том, что при возникновении какой-то ошибки или достижении других нужных условий всю транзакцию можно отменить, и все изменения, примененные к базе данных, будут отменены. Сегодня мы напишем небольшой скрипт, который при помощи транзакций SQLAlchemy пишет информацию о подписчиках сообщества в базу данных MySQL, а при возникновении ошибки отменяет текущую транзакцию.<\/p>\n<h2>Сбор информации об участниках через VK API<\/h2>\n<p>Для начала напишем пару маленьких функций — первая будет возвращать число подписчиков сообщества, а вторая — отправлять запрос и формировать датафрейм с информацией о подписчиках сообщества.<\/p>\n<p class=\"note\">Подробнее о том, как получить токен, можно прочитать в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/get-data-from-vk\/\" class=\"nu\">«<u>Собираем данные по рекламным кампаниям ВКонтакте<\/u>»<\/a><\/p>\n<pre class=\"e2-text-code\"><code>from sqlalchemy import create_engine\r\nimport pandas as pd\r\nimport requests\r\nimport time\r\n\r\ntoken = '42hj2ehd3djdournf48fjurhf9r9o2eurnf48fjurhf9r9734'\r\ngroup_id = 'leftjoin'<\/code><\/pre><p>Чтобы узнать число подписчиков достаточно отправить метод groups.getMembers с любыми параметрами — в ответе всегда возвращается количество в поле count.<\/p>\n<pre class=\"e2-text-code\"><code>def get_subs_count(group_id):\r\n    count = requests.get('https:\/\/api.vk.com\/method\/groups.getMembers', params={\r\n        'access_token':token,\r\n        'v':5.103,\r\n        'group_id':group_id\r\n    }).json()['response']['count']\r\n    return count<\/code><\/pre><p>Для примера будем брать имена, id, фамилии подписчиков, некоторую расширенную информацию и получать только по 10 подписчиков за раз, чтобы рассмотреть работу транзакций детально — каждые 10 подписчиков будут вставляться одной транзакцией. Введём дополнительное поле offset, чтобы знать, в какой итерации добавлены строки.<\/p>\n<pre class=\"e2-text-code\"><code>def get_subs_info(group_id, offset):\r\n    response = requests.get('https:\/\/api.vk.com\/method\/groups.getMembers', params={\r\n        'access_token':token,\r\n        'v':5.103,\r\n        'group_id':group_id,\r\n        'offset':offset,\r\n        'count':10,\r\n        'fields':'sex, has_mobile, relation, can_post'\r\n    }).json()['response']['items']\r\n    df = pd.DataFrame(response)\r\n    df['offset'] = offset\r\n    return df<\/code><\/pre><h2>Транзакции<\/h2>\n<p>Наконец, можем подсоединиться к базе данных при помощи SQLAlchemy:<\/p>\n<pre class=\"e2-text-code\"><code>engine = create_engine('mysql+mysqlconnector:\/\/' +\r\n                           'root' + ':' + '' + '@' +\r\n                           'localhost' + '\/' +\r\n                           'transaction', echo=False)<\/code><\/pre><p>У транзакций всегда должно быть начало — begin, и конец — commit. В случае, если произошла какая-то ошибка, можно сделать откат — rollback. Сперва получаем число подписчиков сообщество, и в каждой итерации цикла при помощи контекстного менеджера with ... as создаём новое подключение. Сразу после объявляем начало транзакции по этому подключению и с обработчиком исключений пробуем получить информацию о десяти подписчиках через функцию get_subs_info. Вставляем полученный датафрейм в таблицу методом to_sql и завершаем транзакцию при помощи метода commit(). В случае, если возникла какая-то ошибка — печатаем её на экран и отменяем транзакцию.<\/p>\n<pre class=\"e2-text-code\"><code>offset = 0\r\nsubs_count = get_subs_count(group_id)\r\nwhile offset &lt; subs_count:\r\n    with engine.connect() as conn:\r\n        transaction = conn.begin()\r\n        try:\r\n            df = get_subs_info(group_id, offset)\r\n            df.to_sql('subscribers', con=conn, if_exists='append', index=False)\r\n            transaction.commit()\r\n        except Exception as E:\r\n            print(E)\r\n            transaction.rollback()\r\n    time.sleep(1)\r\n    offset += 10<\/code><\/pre><p>Чтобы протестировать работу транзакций слегка обновим последний блок кода — добавим вызов ошибки ValueError после вставки данных в базу, если текущий offset равен 10.<\/p>\n<pre class=\"e2-text-code\"><code>offset = 0\r\nsubs_count = get_subs_count(group_id)\r\nwhile offset &lt; subs_count:\r\n    with engine.connect() as conn:\r\n        transaction = conn.begin()\r\n        try:\r\n            df = get_subs_info(group_id, offset)\r\n            df.to_sql('subscribers', con=conn, if_exists='append', index=False)\r\n            if offset == 10:\r\n                raise(ValueError)\r\n            transaction.commit()\r\n        except Exception as E:\r\n            print(E)\r\n            transaction.rollback()\r\n    time.sleep(1)\r\n    offset += 10<\/code><\/pre><p>Как и планировалось, данные за итерацию с offset = 10 не занесены в таблицу. Несмотря на то, что ошибка возникла уже после добавления новых данных, транзакция была прервана методом rollback() и завершение транзакции было отменено.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-23.png\" width=\"759\" height=\"562\" alt=\"\" \/>\n<\/div>\n",
            "date_published": "2021-02-12T11:10:22+03:00",
            "date_modified": "2021-02-08T13:11:02+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/1-23.png",
            "_date_published_rfc2822": "Fri, 12 Feb 2021 11:10:22 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "94",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/1-23.png"
                ]
            }
        },
        {
            "id": "95",
            "url": "http:\/\/test.leftjoin.ru\/all\/tg-api-parse\/",
            "title": "Сбор информации о подписчиках Telegram-канала",
            "content_html": "<p>На 2021 год боты в Telegram так и не имеют метода, позволяющего получать информацию о подписчиках канала. Тем не менее, существует достаточно сложное в освоении Telegram API и построенная на нём библиотека Telethon. Сегодня мы посмотрим, как при помощи библиотеки выгрузить информацию о подписчиках своего канала.<\/p>\n<h2>Создание приложения<\/h2>\n<p>Для начала необходимо создать приложение, через которое будут отправляться запросы к API. Перейдите на <a href=\"https:\/\/my.telegram.org\">https:\/\/my.telegram.org<\/a> и авторизуйтесь в Telegram-аккаунте:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-24.png\" width=\"532\" height=\"299\" alt=\"\" \/>\n<\/div>\n<p>После успешной авторизации перейдите на страницу API development tools:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2-22.png\" width=\"397\" height=\"190\" alt=\"\" \/>\n<\/div>\n<p>Заполните все поля и жмите на создание приложения:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/3-20.png\" width=\"645\" height=\"478\" alt=\"\" \/>\n<\/div>\n<p>Из полученной конфигурации нам необходим app api_id и app api_hash:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/5-13.png\" width=\"629\" height=\"123\" alt=\"\" \/>\n<\/div>\n<h2>Запрос к API<\/h2>\n<p>Импортируем telethon — он поможет сформировать запрос, и pandas — полученный ответ мы запишем в DataFrame.<\/p>\n<pre class=\"e2-text-code\"><code>from telethon import TelegramClient\r\nimport pandas as pd<\/code><\/pre><p>Вводим api_id, api_hash, наш номер телефона и ссылку на канал, информацию о подписчиках которого хотим получить. Доступ к информации о подписчиках есть только у администраторов канала.<\/p>\n<pre class=\"e2-text-code\"><code>api_id = 1234567\r\napi_hash = '1b42hj25kd8jw42b234kwj242c'\r\nphone = '+71234567890'\r\nchannel_href = 'https:\/\/t.me\/leftjoin'<\/code><\/pre><p>Создаём новую сессию — вместо session_name можно подставить любое другое название. Методы в библиотеке работают асинхронно, поэтому ответа от них требуется ожидать:<\/p>\n<pre class=\"e2-text-code\"><code>client = TelegramClient('session_name', api_id, api_hash)\r\nclient = await client.start()\r\ndialogs = await client.get_dialogs()<\/code><\/pre><p>Собираем все каналы текущего пользователя. Из ссылки забираем часть с именем канала и вытаскиваем из словаря нужный:<\/p>\n<pre class=\"e2-text-code\"><code>channels = {d.entity.username: d.entity\r\n            for d in dialogs\r\n            if d.is_channel}\r\nmy_channel = channel_href.split('\/')[-1]\r\nchannel = channels[my_channel]<\/code><\/pre><p>Подписчиков, доступ к которым не ограничен приватностью, можно получить методом get_participants. С 20 июля 2018 года Telegram установил ограничение в 200 подписчиков для вызова метода, и установка параметра aggressive на True поможет получить всех подписчиков за раз.<\/p>\n<pre class=\"e2-text-code\"><code>members_telethon_list = await client.get_participants(channel, aggressive=True)<\/code><\/pre><p>Из полученных библиотечных структур извлекаем информацию о пользователях — их имена и телефоны:<\/p>\n<pre class=\"e2-text-code\"><code>username_list = [member.username for member in members_telethon_list]\r\nfirst_name_list = [member.first_name for member in members_telethon_list]\r\nlast_name_list = [member.last_name for member in members_telethon_list]\r\nphone_list = [member.phone for member in members_telethon_list]<\/code><\/pre><p>Из четырёх списков собираем DataFrame и пишем его в csv-таблицу:<\/p>\n<pre class=\"e2-text-code\"><code>df = pd.DataFrame()\r\ndf['username'] = username_list\r\ndf['first_name'] = first_name_list\r\ndf['last_name'] = last_name_list\r\ndf['phone'] = phone_list\r\ndf.to_csv('subscribers.csv', index=False)<\/code><\/pre><p>Результат работы — такая таблица:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/6-13.png\" width=\"327\" height=\"191\" alt=\"\" \/>\n<\/div>\n<p>Для запуска в Jupyter Notebook описанный ниже код можно просто вставить в ячейку, но при запуске из Python-файла будет такая ошибка:<\/p>\n<pre class=\"e2-text-code\"><code>SyntaxError: 'await' outside function<\/code><\/pre><p>Устранить проблему можно, записав весь код в асинхронную функцию. Целиком выглядеть код будет так:<\/p>\n<pre class=\"e2-text-code\"><code>from telethon import TelegramClient\r\nimport pandas as pd\r\nimport asyncio\r\n\r\nasync def main():\r\n        api_id = 1234567\r\n        api_hash = '1b42hj25kd8jw42b234kwj242c'\r\n        phone = '+71234567890'\r\n        channel_href = 'https:\/\/t.me\/leftjoin'\r\n\r\n\tclient = TelegramClient('session_name', api_id, api_hash)\r\n\tclient = await client.start()\r\n\tdialogs = await client.get_dialogs()\r\n\r\n\tchannels = {d.entity.username: d.entity\r\n\t\t\t\tfor d in dialogs\r\n\t\t\t\tif d.is_channel}\r\n\tmy_channel = channel_href.split('\/')[-1]\r\n\tchannel = channels[my_channel]\r\n\r\n\tmembers_telethon_list = await client.get_participants(channel, aggressive=True)\r\n\r\n\tusername_list = [member.username for member in members_telethon_list]\r\n\tfirst_name_list = [member.first_name for member in members_telethon_list]\r\n\tlast_name_list = [member.last_name for member in members_telethon_list]\r\n\tphone_list = [member.phone for member in members_telethon_list]\r\n\r\n\tdf = pd.DataFrame()\r\n\tdf['username'] = username_list\r\n\tdf['first_name'] = first_name_list\r\n\tdf['last_name'] = last_name_list\r\n\tdf['phone'] = phone_list\r\n\tdf.to_csv('subscribers.csv', index=False)\r\n\r\nif __name__ == '__main__':\r\n\tloop = asyncio.get_event_loop()\r\n\tloop.run_until_complete(main())<\/code><\/pre>",
            "date_published": "2021-02-08T15:19:17+03:00",
            "date_modified": "2021-08-30T13:46:59+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/1-24.png",
            "_date_published_rfc2822": "Mon, 08 Feb 2021 15:19:17 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "95",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/1-24.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2-22.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/3-20.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/5-13.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/6-13.png"
                ]
            }
        },
        {
            "id": "81",
            "url": "http:\/\/test.leftjoin.ru\/all\/matemarketing-data-stack\/",
            "title": "Матемаркетинг: современный облачный Data Stack",
            "content_html": "<p>С 9 по 13 ноября в онлайн-формате прошёл Матемаркетинг — крупнейшая конференция по маркетинговой аналитике в России, и в этом году мне посчастливилось стать одним из спикеров. Я выступил с двумя докладами, в этом материале обсудим первый — о современном облачном Data Stack.<\/p>\n<p>Внутри объясняю подход к проектированию аналитической инфраструктуры, обосновываю использование Clickhouse при построении облачной аналитики и рассказываю о его же нюансах и говорю про Redash с точки зрения инструмента для визуализации.<\/p>\n<div class=\"e2-text-video\">\n<iframe src=\"https:\/\/www.youtube.com\/embed\/DoX-6hcFKoA\" frameborder=\"0\" allowfullscreen><\/iframe><\/div>\n",
            "date_published": "2021-01-29T09:55:19+03:00",
            "date_modified": "2020-12-08T13:34:54+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/remote\/youtube-DoX-6hcFKoA-cover.jpg",
            "_date_published_rfc2822": "Fri, 29 Jan 2021 09:55:19 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "81",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/remote\/youtube-DoX-6hcFKoA-cover.jpg"
                ]
            }
        },
        {
            "id": "92",
            "url": "http:\/\/test.leftjoin.ru\/all\/instagram-python-selenium-bot\/",
            "title": "Робот для автоматизированного просмотра Instagram на Python и Selenium",
            "content_html": "<p class=\"note\">Недавно мы начали вести Instagram — <a href=\"https:\/\/www.instagram.com\/leftjoin.ru\/\">подписывайтесь<\/a>, чтобы не пропустить контент, которого нет в блоге и Telegram!<\/p>\n<p>Многие из нас ежедневно заходят в Instagram, чтобы посмотреть истории друзей и полистать ленту постов и рекомендаций. Предлагаем действенный способ сохранить своё время — напишем на Python и Selenium робота, который возьмёт на себя рутинную задачу проверки свежих новостей друзей и подсчитает число новых историй и входящих сообщений.<\/p>\n<h2>Авторизация в аккаунт<\/h2>\n<p>При переходе в браузерную версию сайта, нас встречает такое окно:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-22.png\" width=\"827\" height=\"619\" alt=\"\" \/>\n<\/div>\n<p>Но просто вставить логин, пароль и нажать на кнопку «Войти» недостаточно: впереди будет ещё два окна. Во-первых, предложение сохранить данные — здесь мы тактично жмём «Не сейчас». Instagram тщательно следит за каждым нашим действием и малейшие аномалии в поведении приводят к блокировке, поэтому любые предложения по сохранению данных будем на всякий случай пропускать.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2-21.png\" width=\"414\" height=\"344\" alt=\"\" \/>\n<\/div>\n<p>Следующим препятствием будет предложение включить уведомление, которое мы тоже пропустим:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/3-19.png\" width=\"449\" height=\"381\" alt=\"\" \/>\n<\/div>\n<p>Первым делом импортируем библиотеки:<\/p>\n<pre class=\"e2-text-code\"><code>from selenium import webdriver\r\nfrom webdriver_manager.chrome import ChromeDriverManager\r\nfrom bs4 import BeautifulSoup as bs\r\nimport time\r\nimport random<\/code><\/pre><p>И описываем функцию authorize — она будет принимать driver в качестве аргумента, отправлять в нужные поля логин и пароль, нажимать на кнопку «Войти», затем ждать десять секунд на загрузку страницы, нажимать на кнопку «Не сейчас», снова ждать загрузки страницы и пропускать уведомления:<\/p>\n<pre class=\"e2-text-code\"><code>def authorize(driver):\r\n    username = 'login'\r\n    password = 'password'\r\n    driver.get('https:\/\/www.instagram.com')\r\n    time.sleep(5)\r\n    driver.find_element_by_name(&quot;username&quot;).send_keys(username)\r\n    driver.find_element_by_name(&quot;password&quot;).send_keys(password)\r\n    driver.execute_script(&quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[0].click()&quot;)\r\n    time.sleep(10)\r\n    driver.execute_script(&quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[0].click()&quot;)\r\n    time.sleep(10)\r\n    driver.execute_script(&quot;document.getElementsByClassName('aOOlW   HoLwm ')[0].click()&quot;)<\/code><\/pre><h2>Новые сообщения<\/h2>\n<p>В Instagram могут прийти сообщения двух видов. В случае, если вы не подписаны на отправителя — придёт запрос на диалог. Если подписаны — придёт входящее сообщения. Оба случая обрабатываются по-разному. Число входящих сообщений можно получить с главной страницы — это число над иконкой бумажного самолётика:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.19.31.png\" width=\"231\" height=\"70\" alt=\"\" \/>\n<\/div>\n<p>А число запросов можно забрать текстом заголовка h5 из раздела «Сообщения». Сперва перейдём в этот раздел и попробуем найти строку с запросами на сообщение. Затем вернёмся на главную страницу и возьмём то самое число новых сообщений.<\/p>\n<pre class=\"e2-text-code\"><code>def messages_count(driver):\r\n    driver.get('https:\/\/www.instagram.com\/direct\/inbox\/')\r\n    time.sleep(2)\r\n    inbox = bs(driver.page_source)\r\n    try:\r\n        queries_text = inbox.find_all('h5')[0].text\r\n    except Exception:\r\n        queries_text = None\r\n    driver.get('https:\/\/www.instagram.com')\r\n    time.sleep(2)\r\n    content = bs(driver.page_source)\r\n    try:\r\n        messages_count = int(content.find_all('div', attrs={'class':'KdEwV'})[0].text)\r\n    except Exception:\r\n        messages_count = 0\r\n    return queries_text, messages_count<\/code><\/pre><h2>Подсчёт числа новых сторис<\/h2>\n<p>Все истории хранятся в одном блоке:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.02.05.png\" width=\"637\" height=\"201\" alt=\"\" \/>\n<\/div>\n<p>Это список с одинаковым классом, но в каждом элементе списка лежит ещё один div-блок. У новых историй это класс eebAO  h_uhZ, у просмотренных — eebAO.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.03.48.png\" width=\"200\" height=\"132\" alt=\"\" \/>\n<\/div>\n<p>Ещё есть такая кнопка, которая показывает следующую пачку историй:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.05.14.png\" width=\"269\" height=\"135\" alt=\"\" \/>\n<\/div>\n<p>При этом Instagram динамически прогружает код страницы, и в нём не найти те элементы, которые вы не видите своими глазами. Поэтому мы возьмём первые 8 видимых новых историй, добавим в список, нажмём на кнопку «Показать следующие истории» и будем продолжать так, пока кнопка ещё отображается. А затем подсчитаем число уникальных элементов, чтобы избежать возможных дубликатов.<\/p>\n<pre class=\"e2-text-code\"><code>def get_stories_count(driver):\r\n    stories_divs = []\r\n    scroll = True\r\n    while scroll:\r\n        try:\r\n            content = bs(driver.page_source)\r\n            stories_divs.extend(content.find_all('div', attrs={'class':'eebAO h_uhZ'}))\r\n            driver.execute_script(&quot;document.getElementsByClassName('  _6CZji oevZr  ')[0].click()&quot;)\r\n            time.sleep(1)\r\n        except Exception as E:\r\n            scroll = False\r\n    return len(set(stories_divs))<\/code><\/pre><h2>Просмотр сторис<\/h2>\n<p>Следующее, чем может заняться реальный пользователь после авторизации — просмотр свежих историй. Для того, чтобы зайти в блок историй, нужно просто нажать на кнопку класса OE3OK:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/4-12.png\" width=\"285\" height=\"164\" alt=\"\" \/>\n<\/div>\n<p>Есть еще две кнопки, о которых мы должны знать. Это кнопка для переключения на следующую историю — она в классе FhutL и кнопка закрытия блока историй — класс wpO6b. Пускай одна история будет отнимать у нас от 10 до 15 секунд, и с вероятностью 1\/5 мы переключим на следующую. При этом зададим переменные counter и limit — пусть сейчас мы хотим посмотреть случайное число историй от 5 до 45, и если мы уже посмотрели столько, то выходим из функции и историй.<\/p>\n<pre class=\"e2-text-code\"><code>def watch_stories(driver):\r\n    watching = True\r\n    counter = 0\r\n    limit = random.randint(5, 45)\r\n    driver.execute_script(&quot;document.getElementsByClassName('OE3OK ')[0].click()&quot;)\r\n    try:\r\n        while watching:\r\n            time.sleep(random.randint(10, 15))\r\n            if random.randint(1, 5) == 5:\r\n                driver.execute_script(&quot;document.getElementsByClassName('FhutL')[0].click()&quot;)\r\n            counter += 1\r\n            if counter &gt; limit:\r\n                driver.execute_script(&quot;document.getElementsByClassName('wpO6b ')[1].click()&quot;)\r\n                watching = False\r\n    except Exception as E:\r\n        print(E)\r\n        watching = False<\/code><\/pre><h2>Скроллинг ленты<\/h2>\n<p>После просмотра актуальных историй можно поскроллить ленту — это действие ничем не отличается от классического скроллинга страниц в Selenium. Запоминаем последнюю доступную длину страницы, скроллим до неё, ожидаем прогрузки, получаем новую. Прекратим просматривать ленту в двух случаях — если в random.randint() сгенерировалась единица или если лента кончилась.<\/p>\n<pre class=\"e2-text-code\"><code>def scroll_feed(driver):\r\n    scrolling = True\r\n    last_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n    while scrolling:\r\n        driver.execute_script(&quot;window.scrollTo(0, document.body.scrollHeight);&quot;)\r\n        time.sleep(random.randint(4,10))\r\n        new_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n        if new_height == last_height or random.randint(1, 10) == 1:\r\n            scrolling = False\r\n        last_height = new_height<\/code><\/pre><h2>Просмотр рекомендуемых аккаунтов<\/h2>\n<p>Instagram в заглавной странице сам рекомендует нам для подписки некоторые аккаунты. Выглядит она так:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/5-12.png\" width=\"645\" height=\"573\" alt=\"\" \/>\n<\/div>\n<p>И на ней тоже придётся скроллить, чтобы дойти до конца. Заходим на страницу и ожидаем 5 секунд прогрузки, затем снова получаем длину страницы и скроллим вниз. Выходим тоже с вероятностью 1\/10 или если страница кончилась, но ещё с вероятностью 1\/2 подписываемся на некоторые из первых 100 аккаунтов рекомендаций:<\/p>\n<pre class=\"e2-text-code\"><code>def scroll_recomendations(driver):\r\n   driver.get('https:\/\/www.instagram.com\/explore\/people\/suggested\/')\r\n    time.sleep(5)\r\n    scrolling = True\r\n    last_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n    while scrolling:\r\n        driver.execute_script(&quot;window.scrollTo(0, document.body.scrollHeight);&quot;)\r\n        time.sleep(random.randint(4,10))\r\n        new_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n        if new_height == last_height or random.randint(1, 10) == 1:\r\n            scrolling = False\r\n        last_height = new_height\r\n        if random.randint(0, 1):\r\n            try:\r\n                driver.execute_script(f&quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[{random.randint(1,100)}].click()&quot;)\r\n            except Exception as E:\r\n                print(E)<\/code><\/pre><h2>Просмотр рекомендуемых постов<\/h2>\n<p>Помимо ленты, которая сформирована из наших подписок, Instagram собирает ленту рекомендаций. Туда входят все посты, которые потенциально могут вам понравиться — мы просто пройдём вниз по этой ленте. Выйдем с вероятностью 1\/5 или когда кончится, чтобы долго не засиживаться.<\/p>\n<pre class=\"e2-text-code\"><code>def scroll_explore(driver):\r\n    driver.get('https:\/\/www.instagram.com\/explore')\r\n    time.sleep(3)\r\n    scrolling = True\r\n    last_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n    while scrolling:\r\n        driver.execute_script(&quot;window.scrollTo(0, document.body.scrollHeight);&quot;)\r\n        time.sleep(random.randint(4,10))\r\n        new_height = driver.execute_script(&quot;return document.body.scrollHeight&quot;)\r\n        if new_height == last_height or random.randint(1, 5) == 1:\r\n            scrolling = False\r\n        last_height = new_height<\/code><\/pre><h2>Итог<\/h2>\n<p>Теперь можно собрать все функции вместе — создаём новый driver, проводим авторизацию, считаем число новых сторис и сообщений, просматриваем сторис, переходим в рекомендуемые подписки и листаем ленту. В конце печатаем полученные данные — число новых сообщений, запросов и историй друзей.<\/p>\n<pre class=\"e2-text-code\"><code>driver = webdriver.Chrome(ChromeDriverManager().install())\r\nauthorize(driver)\r\nqueries_text, messages_count = messages_count(driver)\r\nstories_count = get_stories_count(driver)\r\nwatch_stories(driver)\r\nscroll_recomendations(driver)\r\nscroll_feed(driver)\r\nscroll_explore(driver)\r\n\r\nif queries_text is not None:\r\n    print(queries_text)\r\nelse:\r\n    print('Нет новых запросов на диалог')\r\nprint('Новых сообщений:', messages_count)\r\n\r\nprint('Новых историй:', stories_count)<\/code><\/pre>",
            "date_published": "2021-01-25T16:49:42+03:00",
            "date_modified": "2021-01-25T16:34:11+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/6-12.png",
            "_date_published_rfc2822": "Mon, 25 Jan 2021 16:49:42 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "92",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/6-12.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/7-7.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/8-8.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.19.08.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/1-22.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2-21.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/3-19.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.19.31.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.02.05.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.03.48.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-01-25--15.05.14.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/4-12.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/5-12.png"
                ]
            }
        },
        {
            "id": "85",
            "url": "http:\/\/test.leftjoin.ru\/all\/vk-api-parse-audience\/",
            "title": "Парсинг целевой аудитории ВКонтакте",
            "content_html": "<p>При размещении рекламы некоторые площадки в настройках аудитории позволяют загрузить список конкретных людей, которые увидят рекламу. Для парсинга id по конкретным пабликам существуют специальные инструменты, но куда интереснее (и дешевле) сделать это собственноручно при помощи Python и VK API. Сегодня расскажем, как для рекламной кампании LEFTJOIN мы спарсили целевую аудиторию и загрузили её в рекламный кабинет.<\/p>\n<p class=\"note\">В материале <a href=\"http:\/\/test.leftjoin.ru\/all\/get-data-from-vk\/\" class=\"nu\">«<u>Собираем данные по рекламным кампаниям ВКонтакте<\/u>»<\/a> подробно описан процесс получения токена пользователя для VK API<\/p>\n<h2>Парсинг пользователей<\/h2>\n<p>Для отправки запросов потребуется токен пользователя и список пабликов, чьих участников мы хотим получить. Мы собрали около 30 сообществ, посвящённых аналитике, BI-инструментам и Data Science.<\/p>\n<pre class=\"e2-text-code\"><code>import requests\r\nimport time\r\n\r\ngroup_list =  ['datacampus', '185023286', 'data_mining_in_action', '223456', '187222444', 'nta_ds_ai', 'business__intelligence', 'club1981711', 'datascience', 'ozonmasters', 'businessanalysts', 'datamining.team', 'club.shad', '174278716', 'sqlex', 'sql_helper', 'odssib', 'sapbi', 'sql_learn', 'hsespbcareer', 'smartdata', 'pomoshch_s_spss', 'dwhexpert', 'k0d_ds', 'sql_ex_ru', 'datascience_ai', 'data_club', 'mashinnoe_obuchenie_ai_big_data', 'womeninbigdata', 'introstats', 'smartdata', 'data_mining_in_action', 'dlschool_mipt']\r\n\r\ntoken = 'ваш_токен'<\/code><\/pre><p>Запрос на получение участников сообщества к API ВКонтакте вернёт максимум 1000 строк — для получения последующих тысяч потребуется смещать параметр offset на единицу. Но нужно знать, до какого момента это делать — поэтому опишем функцию, которая принимает id сообщества, получает информацию о числе участников сообщества и возвращает максимальное значение для offset — отношение числа участников к 1000, ведь мы можем получить ровно тысячу человек за раз.<\/p>\n<pre class=\"e2-text-code\"><code>def get_offset(group_id):\r\n    count = requests.get('https:\/\/api.vk.com\/method\/groups.getMembers', params={\r\n            'access_token':token,\r\n            'v':5.103,\r\n            'group_id': group_id,\r\n            'sort':'id_desc',\r\n            'offset':0,\r\n            'fields':'last_seen'\r\n        }).json()['response']['count']\r\n    return count \/\/ 1000<\/code><\/pre><p>Следующим этапом опишем функцию, которая принимает id сообщества, собирает в один список id всех подписчиков и возвращает его. Для этого отправляем запросы на получение 1000 человек, пока не кончается offset, вносим данные в список и возвращаем его. Проходя по каждому человеку дополнительно проверяем дату его последнего посещения социальной сети — если он не заходил с середины ноября, добавлять его не будем. Время указывается в формате unixtime.<\/p>\n<pre class=\"e2-text-code\"><code>def get_users(group_id):\r\n    good_id_list = []\r\n    offset = 0\r\n    max_offset = get_offset(group_id)\r\n    while offset &lt; max_offset:\r\n        response = requests.get('https:\/\/api.vk.com\/method\/groups.getMembers', params={\r\n            'access_token':token,\r\n            'v':5.103,\r\n            'group_id': group_id,\r\n            'sort':'id_desc',\r\n            'offset':offset,\r\n            'fields':'last_seen'\r\n        }).json()['response']\r\n        offset += 1\r\n        for item in response['items']:\r\n            try:\r\n                if item['last_seen']['time'] &gt;= 1605571200:\r\n                    good_id_list.append(item['id'])\r\n            except Exception as E:\r\n                continue\r\n    return good_id_list<\/code><\/pre><p>Теперь пройдём по всем сообществам из списка и для каждого соберём участников, а затем внесём их в общий список all_users. В конце переводим сначала список в множество, а затем опять в список, чтобы избавиться от возможных дубликатов: одни и те же люди могли быть участниками разных пабликов. Лишним не будет после каждого паблика приостановить работу программы на секунду, чтобы не столкнуться с ограничениями на число запросов.<\/p>\n<pre class=\"e2-text-code\"><code>all_users = []\r\n\r\nfor group in group_list:\r\n    print(group)\r\n    try:\r\n        users = get_users(group)\r\n        all_users.extend(users)\r\n        time.sleep(1)\r\n    except KeyError as E:\r\n        print(group, E)\r\n        continue\r\n\r\nall_users = list(set(all_users))<\/code><\/pre><p>Последним шагом записываем каждого пользователя в файл с новой строки.<\/p>\n<pre class=\"e2-text-code\"><code>with open('users.txt', 'w') as f:\r\n    for item in all_users:\r\n        f.write(&quot;%s\\n&quot; % item)<\/code><\/pre><h2>Аудитория в рекламном кабинете из файла<\/h2>\n<p>Переходим в свой рекламный кабинет ВКонтакте и заходим во вкладку «Ретаргетинг». Там будем кнопка «Создать аудиторию»:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/1-17.png\" width=\"588\" height=\"157\" alt=\"\" \/>\n<\/div>\n<p>После нажатия на неё откроется новое окно, где можно будет выбрать в качестве источника файл и указать название для аудитории:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/2-17.png\" width=\"477\" height=\"633\" alt=\"\" \/>\n<\/div>\n<p>После загрузки пройдёт несколько секунд и аудитория будет доступна. Первые минут 10 будет указано, что аудитория слишком мала: это не так и панель вскоре обновится, если в вашей аудитории действительно более 100 человек.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/3-15.png\" width=\"1250\" height=\"131\" alt=\"\" \/>\n<\/div>\n<h2>Итоги<\/h2>\n<p>Сравним среднюю стоимость привлечённого в наше сообщество участника в объявлении с автоматической настройкой аудитории и в объявлении, аудиторию для которого мы спарсили. В первом случае получаем среднюю стоимость в 52,4 рубля, а во втором — в 33,2 рубля. Подбор качественной аудитории при помощи методов парсинга данных из ВКонтакте помог снизить среднюю стоимость на 37%.<\/p>\n<p>Для рекламной кампании мы подготовили такой пост (нажмите на картинку, чтобы перейти к нему):<\/p>\n<div class=\"e2-text-picture\">\n<a href=\"https:\/\/vk.com\/wall-195051876_51\" class=\"e2-text-picture-link\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2020-12-22--13.50.42.png\" width=\"423\" height=\"581\" alt=\"\" \/>\n<\/a><\/div>\n",
            "date_published": "2020-12-22T13:59:41+03:00",
            "date_modified": "2020-12-22T13:52:58+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/1-17.png",
            "_date_published_rfc2822": "Tue, 22 Dec 2020 13:59:41 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "85",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/1-17.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/2-17.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/3-15.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2020-12-22--13.50.42.png"
                ]
            }
        }
    ],
    "_e2_version": 3365,
    "_e2_ua_string": "E2 (v3365; Aegea)"
}