<?xml version="1.0" encoding="utf-8"?> 
<rss version="2.0">

<channel>

<title>Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: Analytics Engineering</title>
<link>http://test.leftjoin.ru/tags/analytics-engineering/</link>
<description></description>
<generator>E2 (v3365; Aegea)</generator>

<item>
<title>Сквозной идентификатор: решение проблемы мэтчинга персональных данных студентов Refocus</title>
<guid isPermaLink="false">148</guid>
<link>http://test.leftjoin.ru/all/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d/</link>
<comments>http://test.leftjoin.ru/all/skvoznoy-identifikator-reshenie-problemy-metchinga-personalnyh-d/</comments>
<description>
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/cover.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;В системах сквозной аналитики ключевую роль играет правильная модель атрибуции. Без нее данные невозможно интерпретировать, и их ценность для бизнеса невелика. При этом важно понимать, что любая модель напрямую зависит от качества данных.&lt;/p&gt;
&lt;p&gt;Частая проблема с сырыми данными в том, что информация об одном клиенте дублируется или, напротив, противоречит друг другу в разных источниках.&lt;/p&gt;
&lt;p&gt;Кроме того, что предобработка данных — база для аналитика, без правильного объединения персональных данных в принципе сложно отследить клиентский путь. Значит, нужно настраивать процессы объединения неоднородных персональных данных.&lt;/p&gt;
&lt;p&gt;Сегодня в любом клиентском бизнесе воронки регистрации устроены таким образом, что клиенты попадают в базу множеством способов — часто через маркетинговые каналы, которых всегда много (рассылки, реклама, соцсети). В каждом таком канале может быть ссылка на форму подписки, регистрацию на платформе или чат, и один клиент часто проходит все эти этапы. Сразу же образуется путаница в идентификации, которая сильно влияет на качество данных и результаты аналитики, если ее не лечить.&lt;/p&gt;
&lt;p&gt;Мы столкнулись с этой проблемой, работая с одним из наших клиентов, и решили ее, создав сквозной идентификатор. Это уникальный номер, который присваивается реальному клиенту и дублируется во все источники, где есть данные об этом клиенте, тем самым избавляя от путаницы.&lt;/p&gt;
&lt;h2&gt;Кейс Refocus: данные и путь клиента&lt;/h2&gt;
&lt;p&gt;Мы разрабатывали кастомную систему сквозной аналитики для эдтех-стартапа Refocus. Данные каждого студента в системы Refocus попадали из нескольких источников и были записаны несколько раз — как минимум при регистрации на курс, при первом входе на образовательную платформу и при входе в чат сопровождения.&lt;/p&gt;
&lt;p&gt;В нашем случае мэтчинг был важнее всего по трем источникам из тринадцати:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;b&gt;amoCRM,&lt;/b&gt; где фиксируется весь клиентский путь студента;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Discord,&lt;/b&gt; где проходило сопровождение студентов;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Thinkific,&lt;/b&gt; сама образовательная платформа с курсами.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Остальные источники, с которыми мы работали, либо не содержали данных студентов (например, цифры эффективности работы sales-менеджеров были завязаны на данных сотрудников и трекались через другие системы), либо дублировали информацию из указанных трех.&lt;/p&gt;
&lt;p&gt;В Discord и Thinkific данные попадали напрямую, от студентов при регистрации в системах, а затем подтягивались в amoCRM. Основные причины несовпадения клиентских данных как у Refocus, так и в похожих случаях — человеческий фактор (опечатки), наличие у людей более чем одного телефона или адреса почты и ограничения самих платформ, с которых приходят данные: разный заданный формат полей и их количество.&lt;/p&gt;
&lt;p&gt;Часть этих факторов может решаться корректировкой самой клиентской воронки. Правда, не все платформы позволяют одинаково настроить вводные поля, а просьбы вводить данные в конкретном формате не всегда работают и не страхуют от ошибок. Плюс, задача аналитиков — получить чистые данные в любом случае.&lt;/p&gt;
&lt;h2&gt;Задача и поиск решения&lt;/h2&gt;
&lt;p&gt;Данные в Refocus мы подгружали в хранилище в BigQuery напрямую из интересующих нас источников (рекламных кабинетов, LMS и т. д.), используя Python. В дальнейшем на этих данных строились дашборды в Tableau.&lt;/p&gt;
&lt;p&gt;Обнаружить проблему несложно — при создании хранилища и дальнейшей выгрузке данных из него мы в любом случае чистим датасет от дубликатов и несовпадений.&lt;/p&gt;
&lt;p&gt;Поля, в которых возникали ошибки и для которых нам важен был мэтчинг, чтобы правильно отследить клиентский путь:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;имя — да, люди иногда вводят разные вариации ФИО (Юлия, Юля и Бля — на деле один человек!);&lt;/li&gt;
&lt;li&gt;телефон — с кодом страны или без, с пробелами, дефисами или слитно;&lt;/li&gt;
&lt;li&gt;электронная почта — длинные строки сложного формата, в которых легко опечататься.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Поначалу, пока количество студентов Refocus было относительно небольшим, достаточно было скриптов, которые объединяли данные по одному из этих полей. В полученных таблицах в Tableau проводился поиск строк с пустым значением в соответствующем поле — и вот видно всех студентов, чьи данные не сошлись.&lt;/p&gt;
&lt;p&gt;Количество таких строк было в пределах пары десятков, и трекать и объединять их было несложно вручную. Это делалось прямо в первоисточниках сотрудниками Refocus, которые могли поправить опечатки и ошибки у себя в системах. После этого наш код выгрузки в хранилище перезапускался и тянул уже чистые данные. Если после этого что-то не сходилось, то наши аналитики правили информацию на уровне базы данных.&lt;/p&gt;
&lt;p&gt;Но при росте компании в какой-то момент число студентов, потерянных при мэтчинге, могло достигать сотни за месяц. Пока ошибка обнаружится, данные поправят в источниках, а мы перезапустим код выгрузки, могло пройти несколько часов — а это критичный интервал. Да и перезапускать выгрузку каждый день ради нескольких несовпадений — неэффективно. Стало понятно, что масштаб проблемы требует более точного и универсального решения.&lt;/p&gt;
&lt;p&gt;Вообще, в такой ситуации возможны несколько вариантов. Можно бесконечно править скрипты мэтчинга, учитывая новые и новые случаи и создавая костыли. А можно, например, настроить алерты в оркестраторе процессов (в нашем случае  — Airflow), которые позволят моментально узнавать о появившемся несовпадении и объединять “потерянные” клиентские сущности по паре за раз. Но это все еще неполная автоматизация, и она только ускоряет, а не упрощает процесс.&lt;/p&gt;
&lt;p&gt;Руководствуясь соображениями эффективности, мы предложили ввести сквозной идентификатор — одно значение ID, присваиваемое одному клиенту после автоматической интеграции его данных из разных источников.&lt;/p&gt;
&lt;h2&gt;Реализация решения и рабочий процесс&lt;/h2&gt;
&lt;p&gt;Чтобы понять масштаб проблемы, мы начали с того, что создали таблицы несовпадающих персональных данных. Для этого мы использовали скрипты на Python. Эти скрипты объединяли данные из разных источников и создавали из них большую сводную таблицу. Для того, чтобы свести данные о студенте в одну сущность, использовался мэтчинг по адресу электронной почты. Мы попробовали мэтчить по имени, фамилии, телефону (который сначала надо было привести к одному формату!) и почте, и именно последний вариант показал самую высокую точность. Возможно, дело в том, что из всех данных почта имеет самый однородный формат, поэтому остается учитывать только опечатки.&lt;/p&gt;
&lt;p&gt;Например, нам нужно было мэтчить данные для создания дашборда по возвратам, о которых информация объединялась как раз из наших трех основных источников. В ранней версии скрипта данные отбирались таким образом:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH snapshot_ AS (
      SELECT DISTINCT s.*,
        IFNULL(ae.name, ap.name) as contact_name,
        ap.phone, ae.email,
        split(replace(trim(lower(ae.email)),' ',''),'@')[OFFSET(0)] as email_first_part,
        ai.thinkific_id, ai.intercom_id, ac.student_id
      FROM (
        SELECT *,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,
        FROM `Differture.amocrm_leads_snapshot`
      ) s
      LEFT JOIN (
        SELECT DISTINCT lead_id, contact_id, name, email,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num1_
        FROM `Differture.amo_emails`
      ) ae using(lead_id)
      LEFT JOIN (
        SELECT DISTINCT lead_id, contact_id, name, phone,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY contact_id) as num2_
        FROM `Differture.amo_phones`
      ) ap using(lead_id)
      LEFT JOIN `Differture.amo_contact_thinkific_intercom_match` ai using(lead_id)
      LEFT JOIN `Differture.AmoContacts` ac on cast(ae.contact_id as string)=ac.amo_id
      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null) and (num2_=1 or num2_ is null)
        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Как можно заметить, идея сквозного идентификатора здесь уже присутствует — фигурирует &lt;tt&gt;student_id&lt;/tt&gt;. На самом деле, в этой версии скрипта это графа из AmoContacts — таблицы, в которой хранятся только данные из amoCRM. Никаких джойнов по &lt;tt&gt;student_id&lt;/tt&gt; пока не происходит. А происходят по &lt;tt&gt;email_first_part&lt;/tt&gt;, адресу почты до символа @:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;select distinct * from th_amo_ds_rf
    left join calendly ce using(email_first_part)
    left join typeform_live tfl on email_first_part=tf_email_first_part
    left join typeform tf using(email_first_part)
    left join csat using(email_first_part)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Первым шагом по практическому введению идентификатора была таблица &lt;b&gt;students_main_info&lt;/b&gt;, созданная в BigQuery in-house специалистом Refocus. К сожалению, у нас нет доступа к коду, который использовался для присвоения идентификатора. Зато мы можем показать вид этой таблицы:&lt;/p&gt;
&lt;div class="e2-text-table"&gt;
&lt;table cellpadding="0" cellspacing="0" border="0"&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_full_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_email&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_country_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_country_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_courses_ids&lt;/td&gt;
&lt;td style="text-align: center"&gt;array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_courses_names&lt;/td&gt;
&lt;td style="text-align: center"&gt;array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_cohort_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_cohort_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;cohort_community_manager_name&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;cohort_community_manager_email&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_time&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;student_onboarding_live_session_zoom_url&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;amo_contact_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;intercom_contact_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;thinkific_student_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_user_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_user_discord_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_guild_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_channel_id&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;discord_roles&lt;/td&gt;
&lt;td style="text-align: center"&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;В students_main_info хранились данные из нужных источников с общим идентификатором в первой строке, и объединение проходило через сравнение этого поля.&lt;/p&gt;
&lt;p&gt;При этом поле &lt;tt&gt;student_id&lt;/tt&gt; использовалось пока не везде; также использовались другие поля этой таблицы — например, &lt;tt&gt;thinkific_student_id&lt;/tt&gt; или &lt;tt&gt;discord_user_id&lt;/tt&gt;.&lt;/p&gt;
&lt;p&gt;После выгрузки и мэтчинга данных с помощью students_main_info студентов, которые потерялись при объединении, стало меньше, чем при первой схеме мэтчинга. Так мы убедились, что движемся в верном направлении. Тем не менее, использование одной таблицы, которая содержит больше десятка полей обо всех имеющихся персональных данных, не очень эффективно. Данные в ней уже обработаны скриптом специалиста Refocus, и если надо сверить их с сырыми источниками или ввести новый критерий отслеживания, все придется менять на бэкенде.&lt;/p&gt;
&lt;h2&gt;Что получилось в итоге&lt;/h2&gt;
&lt;p&gt;После теста сквозного идентификатора через одну большую таблицу мы продолжили улучшать структуру данных на бэке. Вместо students_main_info усилиями специалиста Refocus появилась подробная сеть более мелких таблиц, которые могут обращаться друг к другу и лежат в одном хранилище с нашими таблицами сырых данных.&lt;/p&gt;
&lt;p&gt;Вот так выглядела схема соотношения этих таблиц:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/image-2.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;А вот так выглядела основная таблица Students:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/students.png border=“0” width=40% height=40%&gt;&lt;/p&gt;
&lt;p&gt;В ней-то и находились основные персональные данные студентов с присвоенным идентификатором, и к ней можно было обращаться для мэтчинга из остальных источников.&lt;/p&gt;
&lt;p&gt;Остальные таблицы выглядели похоже: всегда было поле с идентификатором и информация о какой-то характеристике студента — когорта, курс, роль в дискорде и так далее.&lt;/p&gt;
&lt;p&gt;Финальный код, написанный нашими аналитиками,  объединял данные при выгрузке из хранилища, и больше не опирался на ненадежный мэтчинг через имейл.&lt;/p&gt;
&lt;p&gt;Сначала он отбирал собранные нами данные из amoCRM &lt;tt&gt;(amocrm_leads_snapshot)&lt;/tt&gt; и объединял их с контактной информацией клиентов. Затем в таблицу добавлялось поле &lt;tt&gt;student_id&lt;/tt&gt; и отбирались данные, которые понадобятся нам дальше.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;WITH snapshot_ AS (
      SELECT DISTINCT s.*,
        ac.name as contact_name, ac.phone, ac.email,
        split(replace(trim(lower(ac.email)),' ',''),'@')[OFFSET(0)] as email_first_part,
        ac.intercom_id, ac.student_id
      FROM (
        SELECT *,
          ROW_NUMBER() OVER(PARTITION BY lead_id ORDER BY updated_at DESC) as num_,
        FROM `Differture.amocrm_leads_snapshot`
      ) s
      LEFT JOIN (
        select cast(al.amo_id as INT64) as lead_id, cast(ac.amo_id as INT64) as contact_id,
          ac.name, emails as email, phone, student_id, ic.intercom_id,
          ROW_NUMBER() OVER(PARTITION BY al.amo_id ORDER BY ac.amo_id) as num1_
        from `Differture.AmoContacts` ac
        left join `Differture.AmoLeads` al on al.amo_contact_id=ac.id
        left join `Differture.IntercomContacts` ic using(student_id)
        , unnest(ac.emails) emails
      ) ac using(lead_id)
      WHERE (num_=1 or num_ is null) and (num1_=1 or num1_ is null)
        and s.pipeline_id in (4920421,5245535) and s.status_id=142 and lower(s.lead_name) not like '%test%'
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь при создании общей таблицы о возвратах с данными из amo, Thinkific и Discord объединение проходило через student_id:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;th_amo_ds_rf as (
      select distinct * except (channel_id, channel),
        ifnull(channel_id, 'Not in discord') as channel_id,
        ifnull(channel, 'Not in discord') as channel
      from thinkific_amo_refunds
      full outer join discord using(student_id)
    )&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Когда объединенные таблицы данных студентов были созданы, получить таблицы несовпадений можно было простой строкой кода в Tableau:&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/filter.png border=“0” width=50% height=50%&gt;&lt;/p&gt;
&lt;p&gt;Пустое значение поля student_id означает, что мэтча не случилось — где-то информация расходилась слишком сильно и не подтянулась в таблицы с идентификатором. Раньше, до введения идентификатора, поиск был таким же, но обращался к полям почты, телефона или имени-фамилии.&lt;/p&gt;
&lt;p&gt;Ниже можно увидеть таблицу, где данные из Thinkific не совпадали с amoCRM после перехода на Student ID. В этом случае студент есть в LMS, значит, на курсе учится — но его либо нет в системе учета, либо данные в ней разнятся с LMS.&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/unnamed.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;А вот таблица, где данные из Discord не совпадали с amoCRM. Все так же, как выше — студент есть в чатах сопровождения, но не ищется по своим данным в amoCRM.&lt;/p&gt;
&lt;p&gt;&lt;img src=http://test.leftjoin.ru/pictures/discord.png  border=“0” width=100% height=100%&gt;&lt;/p&gt;
&lt;p&gt;Оба скриншота показывают количество несовпадений примерно за месяц. Как видно по этим таблицам, количество несовпадений уменьшилось с 80-90 до пары десятков — примерно на 75%. Это позволило сократить количество перезапусков кода выгрузки вручную и уменьшить затраты времени и технических ресурсов на поддержание системы.&lt;/p&gt;
&lt;h2&gt;Выводы&lt;/h2&gt;
&lt;p&gt;Сквозной идентификатор — эффективное решение проблемы мэтчинга персональных данных. Он позволяет максимально автоматизировать процесс отслеживания и устранения несовпадений или дубликатов клиентских сущностей при выгрузке данных для анализа. В случаях, когда объем данных в системе невелик, а у компании нет возможности выделить ресурсы на реализацию такого решения, можно воспользоваться и другими вариантами. Например, алерты в оркестраторе процессов хорошо справятся в ситуации, когда объединить данные — вопрос ручного запуска одного скрипта раз в неделю. Но сквозной идентификатор — наверное, самое универсальное из доступных решений, которое покроет большинство ошибок и заметно уменьшит погрешность в качестве данных.&lt;/p&gt;
</description>
<pubDate>Mon, 16 Sep 2024 17:57:31 +0300</pubDate>
</item>

<item>
<title>Эффективное логирование в Python</title>
<guid isPermaLink="false">105</guid>
<link>http://test.leftjoin.ru/all/python-logger/</link>
<comments>http://test.leftjoin.ru/all/python-logger/</comments>
<description>
&lt;p&gt;В Python существует встроенный модуль logging, который позволяет журналировать этапы выполнения программы. Логирование полезно когда, например, нужно оставить большой скрипт сбора / обработки данных на длительное время, а в случае возникновения непредвиденных ошибок выяснить, с чем они могут быть связаны. Анализ логов позволяет быстро и эффективно выявлять проблемные места в коде, но для удобного использования модуля следует написать несколько функций по взаимодействию с ним и вынести их в отдельный файл — сегодня мы этим и займёмся.&lt;/p&gt;
&lt;h2&gt;Пишем логгер&lt;/h2&gt;
&lt;p&gt;Создадим файл loggers.py. Для начала импортируем модули и задаём пару значений по умолчанию — директорию для файла с логом и наименование конфигурационного файла, содержащего шаблоны логирования. Его мы опишем следом.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import os
import json
import logging
import logging.config

FOLDER_LOG = &amp;quot;log&amp;quot;
LOGGING_CONFIG_FILE = 'loggers.json'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Опишем функцию для создания папки с логом: она принимает наименование для папки, но по умолчанию будет называть её «log». Директорию создаём при помощи модуля os и только в том случае, если такой директории ещё не существует.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def create_log_folder(folder=FOLDER_LOG):
    if not os.path.exists(folder):
        os.mkdir(folder)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь опишем функцию создания нового логгера по заданному шаблону. Функция должна создать директорию для логирования, открыть конфигурационный файл и достать нужный шаблон. Затем по шаблону при помощи модуля logging создаём новый логгер:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_logger(name, template='default'):
    create_log_folder()
    with open(LOGGING_CONFIG_FILE, &amp;quot;r&amp;quot;) as f:
        dict_config = json.load(f)
        dict_config[&amp;quot;loggers&amp;quot;][name] = dict_config[&amp;quot;loggers&amp;quot;][template]
    logging.config.dictConfig(dict_config)
    return logging.getLogger(name)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для удобства опишем ещё одну функцию — получение стандартного лога. Она ничего не принимает и нужна только для инициализации лога с шаблоном default:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_default_logger():
    create_log_folder()
    with open(LOGGING_CONFIG_FILE, &amp;quot;r&amp;quot;) as f:
        logging.config.dictConfig(json.load(f))

    return logging.getLogger(&amp;quot;default&amp;quot;)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Описываем конфигурационный файл&lt;/h2&gt;
&lt;p&gt;Создадим по соседству файл loggers.json — он будет содержать настройки логгера. Внутри указываем такие настройки, как версию логгера, форматы логирования для разных уровней, наименование выходного файла и его максимальный размер:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;{
    &amp;quot;version&amp;quot;: 1,
    &amp;quot;disable_existing_loggers&amp;quot;: false,
    &amp;quot;formatters&amp;quot;: {
        &amp;quot;default&amp;quot;: {
            &amp;quot;format&amp;quot;: &amp;quot;%(asctime)s - %(processName)-10s - %(name)-10s - %(levelname)-8s - %(message)s&amp;quot;
        }
    },
    &amp;quot;handlers&amp;quot;: {
        &amp;quot;console&amp;quot;: {
            &amp;quot;class&amp;quot;: &amp;quot;logging.StreamHandler&amp;quot;,
            &amp;quot;level&amp;quot;: &amp;quot;INFO&amp;quot;,
            &amp;quot;formatter&amp;quot;: &amp;quot;default&amp;quot;
        },
        &amp;quot;rotating_file&amp;quot;: {
            &amp;quot;class&amp;quot;: &amp;quot;logging.handlers.RotatingFileHandler&amp;quot;,
            &amp;quot;level&amp;quot;: &amp;quot;DEBUG&amp;quot;,
            &amp;quot;formatter&amp;quot;: &amp;quot;default&amp;quot;,
            &amp;quot;filename&amp;quot;: &amp;quot;log/main.log&amp;quot;,
            &amp;quot;maxBytes&amp;quot;: 10485760,
            &amp;quot;backupCount&amp;quot;: 20
        }
    },
    &amp;quot;loggers&amp;quot;: {
        &amp;quot;default&amp;quot;: {
            &amp;quot;handlers&amp;quot;: [&amp;quot;console&amp;quot;, &amp;quot;rotating_file&amp;quot;],
            &amp;quot;level&amp;quot;: &amp;quot;DEBUG&amp;quot;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Использование логгера&lt;/h2&gt;
&lt;p&gt;Теперь давайте представим, что вы выгружаете данные по API и складываете их в базу данных на примере нашего материала про &lt;a href="http://test.leftjoin.ru/all/tranzakcii-v-sqlalchemy/"&gt;транзакции в SQLAlchemy&lt;/a&gt;. Рассмотрим заключительную часть кода: добавим строку с инициализацией стандартного логгера и изменим код так, чтобы сначала в лог выводился offset, затем в случае успеха предложение «Successfully inserted data», а в случае ошибки выводилась сама ошибка и предложение: «Error: tried to insert data but got an error».&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;logger = get_logger('main')

offset = 0
subs_count = get_subs_count(group_id)

while offset &amp;lt; subs_count:
    with engine.connect() as conn:
        transaction = conn.begin()
        try:
            logger.info(f&amp;quot;{offset} / {subs_count}&amp;quot;)
            df = get_subs_info(group_id, offset)
            df.to_sql('subscribers', con=conn, if_exists='append', index=False)
            if offset == 10:
                raise(ValueError(&amp;quot;This is a test errror&amp;quot;))
            transaction.commit()
            logger.info(f&amp;quot;Successfully inserted data&amp;quot;)
        except Exception as E:
            transaction.rollback()
            logger.error(f&amp;quot;Error: tried to insert {df} but got an error: {E}&amp;quot;)
    time.sleep(1)
    offset += 10&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь во время работы программы будет отображаться такой вывод, который также будет записан в файл main.log папки log в директории проекта. После завершения работы программы можно исследовать логи, посмотреть, на каких offset возникли проблемы, какие данные не удалось вставить и прочитать текст ошибки:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-03-26--11.14.34.png" width="994" height="468" alt="" /&gt;
&lt;/div&gt;
</description>
<pubDate>Tue, 30 Mar 2021 13:51:48 +0300</pubDate>
</item>

<item>
<title>Бот для преобразования данных из Coinkeeper</title>
<guid isPermaLink="false">101</guid>
<link>http://test.leftjoin.ru/all/coinkeeper-data-bot/</link>
<comments>http://test.leftjoin.ru/all/coinkeeper-data-bot/</comments>
<description>
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/@2x-copy.png.jpg" width="2560" height="1707" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://about.coinkeeper.me"&gt;Coinkeeper&lt;/a&gt; — кроссплатформенное приложение для учёта финансов. Внутри можно выпустить виртуальную банковскую карту Visa с бесплатным годовым обслуживанием, которая будет присылать уведомления, если вы тратите больше, чем запланировали. Помимо уведомлений, приложение ведёт историю трат и позволяет выгрузить сводный отчёт в формате csv. Данные, которое выгружает приложение ещё не готовы к анализу и выглядят так:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-27.png" width="846" height="647" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Азат Шарипов сделал скрипт обработки данных в пригодный для Tableau вид и подготовил &lt;a href="https://public.tableau.com/profile/azat3313#!/vizhome/CoinKeeperanalytics/Dashboard1"&gt;Tableau Public книгу&lt;/a&gt;, а &lt;a href="https://t.me/revealthedata"&gt;Рома Бунин&lt;/a&gt; в рамках своего проекта «Переверстка» &lt;a href="https://public.tableau.com/profile/roman4734#!/vizhome/CoinKeeper/CoinKeeper"&gt;переработал дашборд&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Мы решили тоже поучаствовать, и с нашей стороны Елизавета Мазурова сделала &lt;a href="https://t.me/coinkeeper_export_bot"&gt;чат-бота&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Чат-бот крутой! Помимо того, что он может как и прежде отдавать обратно .csv-файл, он позволяет автоматизировать рутину по обновлению отчета через Google-таблицы. Как, наверное, многие помнят, Tableau Public может работать на гугл-таблицах или csv файлах, но не разрешает подключение к данным. Бот умный: он создаст за вас гугл-таблицу и когда вы повторно отправите ему новый файл обновит ее.&lt;/p&gt;
&lt;h2&gt;Использование бота&lt;/h2&gt;
&lt;p&gt;Перейдите в диалог с &lt;a href="https://t.me/coinkeeper_export_bot"&gt;ботом&lt;/a&gt; и введите команду /start — в ответе бот расскажет немного о себе. Для продолжения работы нажмите на кнопку «Начать».&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-26.png" width="717" height="184" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Сразу после можно отправить csv-файл, выгруженный из Coinkeeper:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/3-22.png" width="489" height="58" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Выберите тип файла — csv или таблицу в Google Spreadsheets.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/4-13.png" width="782" height="174" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;В случае выбора csv-файла бот пришлёт его:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/5-14.png" width="481" height="115" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;А в случае ссылки в первый раз нужно будет пройти небольшую регистрацию — указать почту и наименование для файла.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/6-14.png" width="524" height="56" alt="" /&gt;
&lt;/div&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/7-8.png" width="477" height="57" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Затем бот пришлёт ссылку на файл:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/8-9.png" width="666" height="119" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Скрипт преобразовал данные, и таблицу можно указать в качестве источника данных в Tableau. А благодаря тому, что в случае загрузки нового файла создаётся не новая таблица, а обновляется старая, отчёт в Tableau тоже обновится. В результате открывается возможность еженедельно присылать боту новую таблицу и сразу переходить в обновлённый отчёт.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/9-8.png" width="1217" height="432" alt="" /&gt;
&lt;/div&gt;
</description>
<pubDate>Tue, 09 Mar 2021 17:28:36 +0300</pubDate>
</item>

<item>
<title>Python и тексты нового альбома Земфиры: анализируем суть песен</title>
<guid isPermaLink="false">100</guid>
<link>http://test.leftjoin.ru/all/borderline-text-analysis/</link>
<comments>http://test.leftjoin.ru/all/borderline-text-analysis/</comments>
<description>
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/Zemfira_borderline-2.jpg" width="600" height="600" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Неделю назад вышёл первый за 8 лет студийный альбом Земфиры «Бордерлайн». К работе помимо рок-певицы приложили руку разные люди, в том числе и её родственники — рифф для песни «таблетки» написал её племянник из Лондона. Альбом получился разнообразным: например, песня «остин» посвящена главному персонажу игры Homescapes российской студии Playrix (кстати, посмотрите свежие &lt;a href="https://youtu.be/SOx8afEUTnE"&gt;Бизнес-секреты с братьями Бухманами&lt;/a&gt;, там они тоже про это рассказывают) — Земфире нравится игра, и для трека она связалась со студией. А сингл «крым» был написан в качестве саундтрека к новой картине соратницы Земфиры — Ренаты Литвиновой.&lt;/p&gt;
&lt;p class="note"&gt;Послушать альбом в &lt;a href="https://music.apple.com/ru/album/бордерлайн/1554865105"&gt;Apple Music&lt;/a&gt; / &lt;a href="https://music.yandex.ru/album/14052981"&gt;Яндекс.Музыке&lt;/a&gt; / &lt;a href="https://open.spotify.com/album/6khBsXmKA1FKjYVCIBy9kt"&gt;Spotify&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Тем не менее, дух всего альбома довольно мрачен — в песнях часто повторяются слова «боль», «ад», «бесишь» и прочие по смыслу. Мы решили провести разведочный анализ нового альбома, а затем при помощи модели Word2Vec и косинусной меры посмотреть на семантическую близость песен между собой и вычислить общее настроение альбома.&lt;/p&gt;
&lt;p&gt;Для тех, кому скучно читать про подготовку данных и шаги анализа можно &lt;a href="http://test.leftjoin.ru/all/borderline-text-analysis/#result"&gt;перейти сразу к результатам&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Подготовка данных&lt;/h2&gt;
&lt;p&gt;Для начала работы напишем скрипт обработки данных. Цель скрипта — из множества текстовых файлов, в каждом из которых лежит по песне, собрать единую csv-таблицу. При этом текст треков очищаем от знаков пунктуации и ненужных слов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import pandas as pd
import re
import string
import pymorphy2
from nltk.corpus import stopwords&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Создаём морфологический анализатор и расширяем список всего, что нужно отбросить:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;morph = pymorphy2.MorphAnalyzer()
stopwords_list = stopwords.words('russian')
stopwords_list.extend(['куплет', 'это', 'я', 'мы', 'ты', 'припев', 'аутро', 'предприпев', 'lyrics', '1', '2', '3', 'то'])
string.punctuation += '—'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Названия песен приведены на английском — создадим словарь для перевода на русский и словарь, из которого позднее сделаем таблицу:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;result_dict = dict()

songs_dict = {
    'snow':'снег идёт',
    'crimea':'крым',
    'mother':'мама',
    'ostin':'остин',
    'abuse':'абьюз',
    'wait_for_me':'жди меня',
    'tom':'том',
    'come_on':'камон',
    'coat':'пальто',
    'this_summer':'этим летом',
    'ok':'ок',
    'pills':'таблетки'
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Опишем несколько функций. Первая читает целиком песню из файла и удаляет переносы строки, вторая очищает текст от ненужных символов и слов, а третья при помощи морфологического анализатора pymorphy2 приводит слова к нормальной форме. Модуль pymorphy2 не всегда хорошо справляется с неоднозначностью — для слов «ад» и «рай» потребуется дополнительная обработка.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def read_song(filename):
    f = open(f'{filename}.txt', 'r').read()
    f = f.replace('\n', ' ')
    return f

def clean_string(text):
    text = re.split(' |:|\.|\(|\)|,|&amp;quot;|;|/|\n|\t|-|\?|\[|\]|!', text)
    text = ' '.join([word for word in text if word not in string.punctuation])
    text = text.lower()
    text = ' '.join([word for word in text.split() if word not in stopwords_list])
    return text

def string_to_normal_form(string):
    string_lst = string.split()
    for i in range(len(string_lst)):
        string_lst[i] = morph.parse(string_lst[i])[0].normal_form
        if (string_lst[i] == 'аду'):
            string_lst[i] = 'ад'
        if (string_lst[i] == 'рая'):
            string_lst[i] = 'рай'
    string = ' '.join(string_lst)
    return string&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Проходим по каждой песне и читаем файл с соответствующим названием:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;name_list = []
text_list = []
for song, name in songs_dict.items():
    text = string_to_normal_form(clean_string(read_song(song)))
    name_list.append(name)
    text_list.append(text)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Затем объединяем всё в DataFrame и сохраняем в виде csv-файла.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df = pd.DataFrame()
df['name'] = name_list
df['text'] = text_list
df['time'] = [290, 220, 187, 270, 330, 196, 207, 188, 269, 189, 245, 244]
df.to_csv('borderline.csv', index=False)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Результат:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-26.png" width="477" height="365" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Облако слов по всему альбому&lt;/h2&gt;
&lt;p&gt;Начнём анализ с построения облака слов — оно отобразит, какие слова чаще всего встречаются в песнях. Импортируем нужные библиотеки, читаем csv-файл и устанавливаем конфигурации:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import nltk
from wordcloud import WordCloud
import pandas as pd
import matplotlib.pyplot as plt
from nltk import word_tokenize, ngrams

%matplotlib inline
nltk.download('punkt')
df = pd.read_csv('borderline.csv')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь создаём новую фигуру, устанавливаем параметры оформления и при помощи библиотеки wordcloud отображаем слова с размером прямо пропорциональным частоте упоминания слова. Над каждым графиком дополнительно указываем название песни.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;fig = plt.figure()
fig.patch.set_facecolor('white')
plt.subplots_adjust(wspace=0.3, hspace=0.2)
i = 1
for name, text in zip(df.name, df.text):
    tokens = word_tokenize(text)
    text_raw = &amp;quot; &amp;quot;.join(tokens)
    wordcloud = WordCloud(colormap='PuBu', background_color='white', contour_width=10).generate(text_raw)
    plt.subplot(4, 3, i, label=name,frame_on=True)
    plt.tick_params(labelsize=10)
    plt.imshow(wordcloud)
    plt.axis(&amp;quot;off&amp;quot;)
    plt.title(name,fontdict={'fontsize':7,'color':'grey'},y=0.93)
    plt.tick_params(labelsize=10)
    i += 1&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2@2x-2.png.jpg" width="2560" height="1707" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;EDA текстов альбома&lt;/h2&gt;
&lt;p&gt;Теперь проанализируем тексты песен — импортируем библиотеки для работы с данными и визуализации:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import plotly.graph_objects as go
import plotly.figure_factory as ff
from scipy import spatial
import collections
import pymorphy2
import gensim

morph = pymorphy2.MorphAnalyzer()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Сначала посчитаем число слов в каждой песне, число уникальных слов и процентное соотношение:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;songs = []
total = []
uniq = []
percent = []

for song, text in zip(df.name, df.text):
    songs.append(song)
    total.append(len(text.split()))
    uniq.append(len(set(text.split())))
    percent.append(round(len(set(text.split())) / len(text.split()), 2) * 100)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;А теперь составим из этого DataFrame и дополнительно посчитаем число слов в минуту для каждой песни:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df_words = pd.DataFrame()
df_words['song'] = songs
df_words['total words'] = total
df_words['uniq words'] = uniq
df_words['percent'] = percent
df_words['time'] = df['time']
df_words['words per minute'] = round(total / (df['time'] // 60))
df_words = df_words[::-1]&lt;/code&gt;&lt;/pre&gt;&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/3-21.png" width="480" height="369" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Данные хорошо бы визуализировать — построим две столбиковые диаграммы: одну для числа слов в песне, а другую для числа слов в минуту.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;colors_1 = ['rgba(101,181,205,255)'] * 12
colors_2 = ['rgba(62,142,231,255)'] * 12

fig = go.Figure(data=[
    go.Bar(name='📝 Всего слов',
           text=df_words['total words'],
           textposition='auto',
           x=df_words.song,
           y=df_words['total words'],
           marker_color=colors_1,
           marker=dict(line=dict(width=0)),),
    go.Bar(name='🌀 Уникальных слов',
           text=df_words['uniq words'].astype(str) + '&amp;lt;br&amp;gt;'+ df_words.percent.astype(int).astype(str) + '%' ,
           textposition='inside',
           x=df_words.song,
           y=df_words['uniq words'],
           textfont_color='white',
           marker_color=colors_2,
           marker=dict(line=dict(width=0)),),
])

fig.update_layout(barmode='group')

fig.update_layout(
    title = 
        {'text':'&amp;lt;b&amp;gt;Соотношение числа уникальных слов к общему количеству&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&amp;lt;span style=&amp;quot;color:#666666&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;'},
    showlegend = True,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)',
)
fig.update_layout(legend=dict(
    yanchor=&amp;quot;top&amp;quot;,
    xanchor=&amp;quot;right&amp;quot;,
))

fig.show()&lt;/code&gt;&lt;/pre&gt;&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/76.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;colors_1 = ['rgba(101,181,205,255)'] * 12
colors_2 = ['rgba(238,85,59,255)'] * 12

fig = go.Figure(data=[
    go.Bar(name='⏱️ Длина трека, мин.',
           text=round(df_words['time'] / 60, 1),
           textposition='auto',
           x=df_words.song,
           y=-df_words['time'] // 60,
           marker_color=colors_1,
           marker=dict(line=dict(width=0)),
          ),
    go.Bar(name='🔄 Слов в минуту',
           text=df_words['words per minute'],
           textposition='auto',
           x=df_words.song,
           y=df_words['words per minute'],
           marker_color=colors_2,
           textfont_color='white',
           marker=dict(line=dict(width=0)),
          ),
])

fig.update_layout(barmode='overlay')

fig.update_layout(
    title = 
        {'text':'&amp;lt;b&amp;gt;Длина трека и число слов в минуту&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&amp;lt;span style=&amp;quot;color:#666666&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;'},
    showlegend = True,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)'
)


fig.show()&lt;/code&gt;&lt;/pre&gt;&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/78.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;h2&gt;Работа с Word2Vec моделью&lt;/h2&gt;
&lt;p&gt;При помощи модуля gensim загружаем модель, указывая на бинарный файл:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;model = gensim.models.KeyedVectors.load_word2vec_format('model.bin', binary=True)&lt;/code&gt;&lt;/pre&gt;&lt;p class="note"&gt;Для материала мы использовали готовую обученную на Национальном Корпусе Русского Языка модель от сообщества &lt;a href="https://rusvectores.org/ru/models/"&gt;RusVectōrēs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Модель Word2Vec основана на нейронных сетях и позволяет представлять слова в виде векторов, учитывая семантическую составляющую. Это означает, что если мы возьмём два слова — например, «мама» и «папа», представим их в виде двух векторов и посчитаем косинус, значения будет близко к 1. Аналогично, у двух слов, не имеющих ничего общего по смыслу косинусная мера близка к 0.&lt;/p&gt;
&lt;p&gt;Опишем функцию get_vector: она будет принимать список слов, распознавать для каждого часть речи, а затем получать и суммировать вектора — так мы сможем находить вектора не для одного слова, а для целых предложений и текстов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_vector(word_list):
    vector = 0
    for word in word_list:
        pos = morph.parse(word)[0].tag.POS
        if pos == 'INFN':
            pos = 'VERB'
        if pos in ['ADJF', 'PRCL', 'ADVB', 'NPRO']:
            pos = 'NOUN'
        if word and pos:
            try:
                word_pos = word + '_' + pos
                this_vector = model.word_vec(word_pos)
                vector += this_vector
            except KeyError:
                continue
    return vector&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для каждой песни находим вектор и собираем соответствующий столбец в DataFrame:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;vec_list = []
for word in df['text']:
    vec_list.append(get_vector(word.split()))
df['vector'] = vec_list&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь сравним вектора между собой, посчитав их косинусную близость. Те песни, у которых косинусная метрика выше 0,5 запомним отдельно — так мы получим самые близкие пары песен. Данные о сравнении векторов запишем в двумерный список result.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;similar = dict()
result = []
for song_1, vector_1 in zip(df.name, df.vector):
    sub_list = []
    for song_2, vector_2 in zip(df.name.iloc[::-1], df.vector.iloc[::-1]):
        res = 1 - spatial.distance.cosine(vector_1, vector_2)
        if res &amp;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()):
            similar[song_1 + ' / ' + song_2] = round(res, 2)
        sub_list.append(round(res, 2))
    result.append(sub_list)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Самые похожие треки соберём в отдельный DataFrame:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df_top_sim = pd.DataFrame()
df_top_sim['name'] = list(similar.keys())
df_top_sim['value'] = list(similar.values())
df_top_sim.sort_values(by='value', ascending=False)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И построим такой же bar chart:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;colors = ['rgba(101,181,205,255)'] * 5

fig = go.Figure([go.Bar(x=df_top_sim['name'],
                        y=df_top_sim['value'],
                        marker_color=colors,
                        width=[0.4,0.4,0.4,0.4,0.4],
                        text=df_top_sim['value'],
                        textfont_color='white',
                        textposition='auto')])

fig.update_layout(
    title = 
        {'text':'&amp;lt;b&amp;gt;Топ-5 схожих песен&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&amp;lt;span style=&amp;quot;color:#666666&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;'},
    showlegend = False,
    height=650,
    font={
        'family':'Open Sans, light',
        'color':'black',
        'size':14
    },
    plot_bgcolor='rgba(0,0,0,0)',
    xaxis={'categoryorder':'total descending'}
)

fig.show()&lt;/code&gt;&lt;/pre&gt;&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/80.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Имея вектор каждой песни, давайте посчитаем вектор всего альбома — сложим вектора песен. Затем для такого вектора при помощи модели получим самые близкие по духу и смыслу слова.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_word_from_tlist(lst):
    for word in lst:
        word = word[0].split('_')[0]
        print(word, end=' ')

vec_sum = 0
for vec in df.vector:
    vec_sum += vec
sim_word = model.similar_by_vector(vec_sum)
get_word_from_tlist(sim_word)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;span style="color: '#65b5cd'; font-size: 1.2em"&gt;&lt;b&gt;небо тоска тьма пламень плакать горе печаль сердце солнце мрак&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Наверное, это ключевой результат и описание альбома Земфиры всего лишь в 10 словах.&lt;/p&gt;
&lt;p&gt;Наконец, построим общую тепловую карту, каждая ячейка которой — результат сравнения косинусной мерой текстов двух треков.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;colorscale=[[0.0, &amp;quot;rgba(255,255,255,255)&amp;quot;],
            [0.1, &amp;quot;rgba(229,232,237,255)&amp;quot;],
            [0.2, &amp;quot;rgba(216,222,232,255)&amp;quot;],
            [0.3, &amp;quot;rgba(205,214,228,255)&amp;quot;],
            [0.4, &amp;quot;rgba(182,195,218,255)&amp;quot;],
            [0.5, &amp;quot;rgba(159,178,209,255)&amp;quot;],
            [0.6, &amp;quot;rgba(137,161,200,255)&amp;quot;],
            [0.7, &amp;quot;rgba(107,137,188,255)&amp;quot;],
            [0.8, &amp;quot;rgba(96,129,184,255)&amp;quot;],
            [1.0, &amp;quot;rgba(76,114,176,255)&amp;quot;]]

font_colors = ['black']
x = list(df.name.iloc[::-1])
y = list(df.name)
fig = ff.create_annotated_heatmap(result, x=x, y=y, colorscale=colorscale, font_colors=font_colors)
fig.show()&lt;/code&gt;&lt;/pre&gt;&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/82.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;h2&gt;&lt;a name="result"&gt;Результаты анализа и интерпретация данных&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Давайте ещё раз посмотрим на всё, что у нас получилось — начнём с облака слов. Нетрудно заметить, что у слов «боль», «невозможно», «сорваться», «растерзаны», «сложно», «терпеть», «любить» размер весьма приличный — всё потому, что такие слова встречаются часто на протяжении всего текста песен:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2@2x-2.png-1.jpg" width="2560" height="1707" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Одной из самых «разнообразных» песен оказался сингл «крым» — в нём 74% уникальных слов. А в песне «снег идёт» слов совсем мало, поэтому большинство — 82% уникальны. Самой большой песней в альбоме получился трек «таблетки» — суммарно там около 150 слов.&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/76.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Как было выяснено на прошлом графике, самый «динамичный» трек — «таблетки», целых 37 слов в минуту — практически по слову на каждые две секунды. А самый длинный трек — «абъюз», в нём же и согласно предыдущему графику практически самый низкий процент уникальных слов — 46%.&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/78.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Топ-5 самых семантически похожих пар текстов:&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/80.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;p&gt;Ещё мы получили вектор всего альбома и подобрали самые близкие слова. Только посмотрите на них — «тьма», «тоска», «плакать», «горе», «печаль», «сердце» — это же ведь и есть тот перечень слов, который характеризует лирику Земфиры!&lt;/p&gt;
&lt;p&gt;&lt;span style="color: '#65b5cd'; font-size: 1.2em"&gt;&lt;b&gt;небо тоска тьма пламень плакать горе печаль сердце солнце мрак&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Финал — тепловая карта. По визуализации заметно, что практически все песни достаточно схожи между собой — косинусная мера у многих пар превышает значение в 0.4.&lt;/p&gt;
&lt;iframe id="igraph" scrolling="no" style="border:none;" seamless="seamless" src="https://plotly.com/~Elisejj/82.embed?showlink=false" height="650" width="100%"&gt;&lt;/iframe&gt;
&lt;h2&gt;Выводы&lt;/h2&gt;
&lt;p&gt;В материале мы провели EDA всего текста нового альбома и при помощи предобученной модели Word2Vec доказали гипотезу — большинство песен «бордерлайна» пронизывают довольно мрачные и тексты. И это нормально, ведь Земфиру мы любим именно за искренность и прямолинейность.&lt;/p&gt;
</description>
<pubDate>Fri, 05 Mar 2021 19:54:06 +0300</pubDate>
</item>

<item>
<title>Экспорт исторических данных Apple Health в Google Sheets</title>
<guid isPermaLink="false">99</guid>
<link>http://test.leftjoin.ru/all/apple-health-export/</link>
<comments>http://test.leftjoin.ru/all/apple-health-export/</comments>
<description>
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/pic.jpg" width="659" height="395" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Для устройств на базе iOS и watchOS существует приложение Health, которое ежедневно записывает все данные о здоровье носителя и синхронизирует их со сторонними приложениями. Все эти данные в любой момент можно получить прямо из приложения в виде XML-документа. Сегодня мы выгрузим исторические данные о здоровье из приложения Apple Health, обработаем их и отправим в Google Sheets для анализа и визуализации в будущем.&lt;/p&gt;
&lt;h2&gt;Экспорт архива из приложения&lt;/h2&gt;
&lt;p&gt;Зайдите в приложение Health на iPhone. Нажмите на аватарку своего профиля в верхнем правом углу — откроется меню приложения.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/i@2x.jpg" width="371" height="648" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Внизу нажмите на кнопку «Экспортировать медданные». Через некоторое время откроется меню экспорта — отправьте архив себе на компьютер любым способом, можно по AirDrop или даже по почте в письме самому себе. Из архива нужен только один файл — «экспорт.xml». Достаньте его и положите в папку с ноутбуком jupyter.&lt;/p&gt;
&lt;h2&gt;Парсер XML в DataFrame&lt;/h2&gt;
&lt;p&gt;При помощи библиотеки XML составляем дерево на основе документа из Health. Собирать в словарь будем следующие атрибуты: тип, единица измерения, дата создания, дата начала, дата конца, значение. Проходим по всему дереву и отправляем полученные значения атрибутов в records_dict.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;from xml.etree import ElementTree
import pandas as pd
import datetime

tree = ElementTree.parse('экспорт.xml')
root = tree.getroot()
records = root.findall('Record')

records_dict = {
    'type':[],
    'unit':[],
    'creationDate':[],
    'startDate':[],
    'endDate':[],
    'value':[]
}

for record in records:
    for attribute in records_dict.keys():
        attribute_value = record.get(attribute)
        records_dict[attribute].append(attribute_value)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;События записаны в нечитабельном виде — для перевода составим специальный словарь с нужными типами, где ключ — старое название, а значение — новое. Мы возьмём только 11 событий: минуты осознанности, дистанция на велосипеде, дистанция заплыва, дистанция ходьбы и бега, пройдено пролётов, пульс, пульс в покое, шаги, активная энергия, энергия покоя и средний пульс при ходьбе.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;types_dict = {
    'HKCategoryTypeIdentifierMindfulSession': 'Mindful Session',
    'HKQuantityTypeIdentifierDistanceCycling': 'Cycling Distance',
    'HKQuantityTypeIdentifierDistanceSwimming': 'Swimming Distance',
    'HKQuantityTypeIdentifierDistanceWalkingRunning': 'Walking + Running Distance',
    'HKQuantityTypeIdentifierFlightsClimbed': 'Flights Climbed',
    'HKQuantityTypeIdentifierHeartRate': 'Heart Rate',
    'HKQuantityTypeIdentifierRestingHeartRate': 'Resting Heart Rate',
    'HKQuantityTypeIdentifierStepCount': 'Steps',
    'HKQuantityTypeIdentifierActiveEnergyBurned': 'Active Calories',
    'HKQuantityTypeIdentifierBasalEnergyBurned': 'Resting Calories',
    'HKQuantityTypeIdentifierWalkingHeartRateAverage': 'Walking Heart Rate Average'
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для минут осознанности в поле значения записей нет — мы сами посчитаем позже это поле как разницу даты окончания и начала события. Разница будет представлена как timedelta, поэтому напишем функцию перевода timedelta в минуты:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def td_to_m(td):
    seconds = td.seconds + td.days * 24 * 60 * 60
    return seconds // 60&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Из словаря создаём DataFrame и задаём названия колонок. Оставляем только те 11 событий, которые есть в словаре types_dict и приводим все колонки к нужным типам данных:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df = pd.DataFrame(records_dict)
df.columns = ['type', 'unit', 'date', 'start', 'end', 'value']
df = df[df['type'].isin(types_dict.keys())]
df['value'] = df['value'].astype(float)
df['date'] = df['date'].astype('datetime64')
df['date'] = df['date'].dt.date
df['start'] = df['start'].astype('datetime64')
df['end'] = df['end'].astype('datetime64')
df['unit'] = df['unit'].astype(str)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Данные Health при экспорте никак не группируются — мы сделаем это самостоятельно. DataFrame можно поделить на три: в первом будут события, у которых единица измерения «количество в минуту» — для таких событий нужно искать среднее значение. В другой группе будут минуты осознанности — считаем число минут в каждой записи и суммируем. В последней группе находятся все остальные записи, связанные с количественными событиями — шаги, дистанция ходьбы и бега и так далее. Их тоже суммируем.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df_1 = df[df['unit'] == 'count/min']
df_1 = df_1.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                      'end':'max',
                                                                      'value':'mean'})

df_2 = df[df['type'] == 'HKCategoryTypeIdentifierMindfulSession']
df_2['value'] = df_2['end'] - df_2['start']
df_2['value'] = df_2['value'].map(td_to_m)
df_2 = df_2.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                     'end':'max',
                                                                     'value':'sum'})
df_3 = df[(df['unit'] != 'count/min') &amp;amp; (df['type'] != 'HKCategoryTypeIdentifierMindfulSession')]
df_3 = df_3.groupby(by=['date', 'type', 'unit'], as_index=False).agg({'start':'min',
                                                                      'end':'max',
                                                                      'value':'sum'})
df = pd.concat([df_1, df_2, df_3])&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Дату создания записи переводим в строковый тип. Все наименования типов событий заменяем согласно словарю types_dict. В переменную dates записываем все уникальные даты.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df['date'] = df['date'].astype(str)
df['type'] = df['type'].apply(lambda x: types_dict[x])
dates = df['date'].unique()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В результате нужен словарь с колонкой даты и отдельной колонкой под каждое из 11 событий:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;result = {
    'date': [],
    'Steps': [],
    'Walking + Running Distance': [],
    'Swimming Distance': [],
    'Cycling Distance': [],
    'Resting Calories': [],
    'Active Calories': [],
    'Flights Climbed': [],
    'Heart Rate': [],
    'Resting Heart Rate': [],
    'Walking Heart Rate Average': [],
    'Mindful Session': []
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Проходим по каждой дате и получаем кусок DataFrame за эту дату. Добавляем её в словарь и проходим по каждому ключу, пробуя добавить значение:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;for date in dates:
    part = df[df['date'] == date]
    result['date'].append(date)
    for key in result.keys():
        if key == 'date':
            continue
        else:
            field = 'value'
        try:
            result[key].append(part[part['type'] == key][field].values[0])
        except IndexError:
            result[key].append(None)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Из полученного словаря создаём DataFrame, округляем всё до двух знаков после запятой и сортируем по дате:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;result_df = pd.DataFrame(result)
result_df = result_df.round(2)
result_df = result_df.sort_values(by='date')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В результате получается такая таблица с историческими данными по 11 событиям:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-24.png" width="996" height="530" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Экспорт DataFrame в Google Sheets&lt;/h2&gt;
&lt;p class="note"&gt;Для экспорта в Google Docs необходим сервисный аккаунт и json-файл с ключом. О том, как его получить, мы писали в материале &lt;a href="http://test.leftjoin.ru/all/get-data-from-vk/" class="nu"&gt;«&lt;u&gt;Собираем данные по рекламным кампаниям ВКонтакте&lt;/u&gt;»&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Создайте новый документ в Google Sheets. Весь DataFrame можно вставить одним действием при помощи методов библиотеки gspread. Импортируйте её, а также укажите идентификатор документа и json-файл с ключом. В методе get_worksheet указывается порядковый номер листа в файле начиная с нуля.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import pandas as pd
import gspread
from gspread_dataframe import set_with_dataframe
gc = gspread.service_account(filename='serviceAccount.json')
sh = gc.open_by_key('1osKA63LQkUC0FC0eIZ63jEJwn1TeIkUvqCV6ur')
worksheet = sh.get_worksheet(0)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В итоге в Google Spreadsheets появится такая таблица:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-03-03--15.31.48.png" width="1375" height="761" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;А в следующем материале посмотрим, как наладить ежедневный экспорт данных Здоровья в эту таблицу при помощи шорткатов и Google AppScript!&lt;/p&gt;
</description>
<pubDate>Wed, 03 Mar 2021 16:57:03 +0300</pubDate>
</item>

<item>
<title>Транзакции в SQLAlchemy</title>
<guid isPermaLink="false">94</guid>
<link>http://test.leftjoin.ru/all/tranzakcii-v-sqlalchemy/</link>
<comments>http://test.leftjoin.ru/all/tranzakcii-v-sqlalchemy/</comments>
<description>
&lt;p&gt;Транзакция — последовательность действий, связанных с базой данных. Их основная польза заключается в том, что при возникновении какой-то ошибки или достижении других нужных условий всю транзакцию можно отменить, и все изменения, примененные к базе данных, будут отменены. Сегодня мы напишем небольшой скрипт, который при помощи транзакций SQLAlchemy пишет информацию о подписчиках сообщества в базу данных MySQL, а при возникновении ошибки отменяет текущую транзакцию.&lt;/p&gt;
&lt;h2&gt;Сбор информации об участниках через VK API&lt;/h2&gt;
&lt;p&gt;Для начала напишем пару маленьких функций — первая будет возвращать число подписчиков сообщества, а вторая — отправлять запрос и формировать датафрейм с информацией о подписчиках сообщества.&lt;/p&gt;
&lt;p class="note"&gt;Подробнее о том, как получить токен, можно прочитать в материале &lt;a href="http://test.leftjoin.ru/all/get-data-from-vk/" class="nu"&gt;«&lt;u&gt;Собираем данные по рекламным кампаниям ВКонтакте&lt;/u&gt;»&lt;/a&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;from sqlalchemy import create_engine
import pandas as pd
import requests
import time

token = '42hj2ehd3djdournf48fjurhf9r9o2eurnf48fjurhf9r9734'
group_id = 'leftjoin'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Чтобы узнать число подписчиков достаточно отправить метод groups.getMembers с любыми параметрами — в ответе всегда возвращается количество в поле count.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_subs_count(group_id):
    count = requests.get('https://api.vk.com/method/groups.getMembers', params={
        'access_token':token,
        'v':5.103,
        'group_id':group_id
    }).json()['response']['count']
    return count&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для примера будем брать имена, id, фамилии подписчиков, некоторую расширенную информацию и получать только по 10 подписчиков за раз, чтобы рассмотреть работу транзакций детально — каждые 10 подписчиков будут вставляться одной транзакцией. Введём дополнительное поле offset, чтобы знать, в какой итерации добавлены строки.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_subs_info(group_id, offset):
    response = requests.get('https://api.vk.com/method/groups.getMembers', params={
        'access_token':token,
        'v':5.103,
        'group_id':group_id,
        'offset':offset,
        'count':10,
        'fields':'sex, has_mobile, relation, can_post'
    }).json()['response']['items']
    df = pd.DataFrame(response)
    df['offset'] = offset
    return df&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Транзакции&lt;/h2&gt;
&lt;p&gt;Наконец, можем подсоединиться к базе данных при помощи SQLAlchemy:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;engine = create_engine('mysql+mysqlconnector://' +
                           'root' + ':' + '' + '@' +
                           'localhost' + '/' +
                           'transaction', echo=False)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У транзакций всегда должно быть начало — begin, и конец — commit. В случае, если произошла какая-то ошибка, можно сделать откат — rollback. Сперва получаем число подписчиков сообщество, и в каждой итерации цикла при помощи контекстного менеджера with ... as создаём новое подключение. Сразу после объявляем начало транзакции по этому подключению и с обработчиком исключений пробуем получить информацию о десяти подписчиках через функцию get_subs_info. Вставляем полученный датафрейм в таблицу методом to_sql и завершаем транзакцию при помощи метода commit(). В случае, если возникла какая-то ошибка — печатаем её на экран и отменяем транзакцию.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;offset = 0
subs_count = get_subs_count(group_id)
while offset &amp;lt; subs_count:
    with engine.connect() as conn:
        transaction = conn.begin()
        try:
            df = get_subs_info(group_id, offset)
            df.to_sql('subscribers', con=conn, if_exists='append', index=False)
            transaction.commit()
        except Exception as E:
            print(E)
            transaction.rollback()
    time.sleep(1)
    offset += 10&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Чтобы протестировать работу транзакций слегка обновим последний блок кода — добавим вызов ошибки ValueError после вставки данных в базу, если текущий offset равен 10.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;offset = 0
subs_count = get_subs_count(group_id)
while offset &amp;lt; subs_count:
    with engine.connect() as conn:
        transaction = conn.begin()
        try:
            df = get_subs_info(group_id, offset)
            df.to_sql('subscribers', con=conn, if_exists='append', index=False)
            if offset == 10:
                raise(ValueError)
            transaction.commit()
        except Exception as E:
            print(E)
            transaction.rollback()
    time.sleep(1)
    offset += 10&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Как и планировалось, данные за итерацию с offset = 10 не занесены в таблицу. Несмотря на то, что ошибка возникла уже после добавления новых данных, транзакция была прервана методом rollback() и завершение транзакции было отменено.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-23.png" width="759" height="562" alt="" /&gt;
&lt;/div&gt;
</description>
<pubDate>Fri, 12 Feb 2021 11:10:22 +0300</pubDate>
</item>

<item>
<title>Сбор информации о подписчиках Telegram-канала</title>
<guid isPermaLink="false">95</guid>
<link>http://test.leftjoin.ru/all/tg-api-parse/</link>
<comments>http://test.leftjoin.ru/all/tg-api-parse/</comments>
<description>
&lt;p&gt;На 2021 год боты в Telegram так и не имеют метода, позволяющего получать информацию о подписчиках канала. Тем не менее, существует достаточно сложное в освоении Telegram API и построенная на нём библиотека Telethon. Сегодня мы посмотрим, как при помощи библиотеки выгрузить информацию о подписчиках своего канала.&lt;/p&gt;
&lt;h2&gt;Создание приложения&lt;/h2&gt;
&lt;p&gt;Для начала необходимо создать приложение, через которое будут отправляться запросы к API. Перейдите на &lt;a href="https://my.telegram.org"&gt;https://my.telegram.org&lt;/a&gt; и авторизуйтесь в Telegram-аккаунте:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-24.png" width="532" height="299" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;После успешной авторизации перейдите на страницу API development tools:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-22.png" width="397" height="190" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Заполните все поля и жмите на создание приложения:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/3-20.png" width="645" height="478" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Из полученной конфигурации нам необходим app api_id и app api_hash:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/5-13.png" width="629" height="123" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Запрос к API&lt;/h2&gt;
&lt;p&gt;Импортируем telethon — он поможет сформировать запрос, и pandas — полученный ответ мы запишем в DataFrame.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;from telethon import TelegramClient
import pandas as pd&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Вводим api_id, api_hash, наш номер телефона и ссылку на канал, информацию о подписчиках которого хотим получить. Доступ к информации о подписчиках есть только у администраторов канала.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;api_id = 1234567
api_hash = '1b42hj25kd8jw42b234kwj242c'
phone = '+71234567890'
channel_href = 'https://t.me/leftjoin'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Создаём новую сессию — вместо session_name можно подставить любое другое название. Методы в библиотеке работают асинхронно, поэтому ответа от них требуется ожидать:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;client = TelegramClient('session_name', api_id, api_hash)
client = await client.start()
dialogs = await client.get_dialogs()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Собираем все каналы текущего пользователя. Из ссылки забираем часть с именем канала и вытаскиваем из словаря нужный:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;channels = {d.entity.username: d.entity
            for d in dialogs
            if d.is_channel}
my_channel = channel_href.split('/')[-1]
channel = channels[my_channel]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Подписчиков, доступ к которым не ограничен приватностью, можно получить методом get_participants. С 20 июля 2018 года Telegram установил ограничение в 200 подписчиков для вызова метода, и установка параметра aggressive на True поможет получить всех подписчиков за раз.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;members_telethon_list = await client.get_participants(channel, aggressive=True)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Из полученных библиотечных структур извлекаем информацию о пользователях — их имена и телефоны:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;username_list = [member.username for member in members_telethon_list]
first_name_list = [member.first_name for member in members_telethon_list]
last_name_list = [member.last_name for member in members_telethon_list]
phone_list = [member.phone for member in members_telethon_list]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Из четырёх списков собираем DataFrame и пишем его в csv-таблицу:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;df = pd.DataFrame()
df['username'] = username_list
df['first_name'] = first_name_list
df['last_name'] = last_name_list
df['phone'] = phone_list
df.to_csv('subscribers.csv', index=False)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Результат работы — такая таблица:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/6-13.png" width="327" height="191" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Для запуска в Jupyter Notebook описанный ниже код можно просто вставить в ячейку, но при запуске из Python-файла будет такая ошибка:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;SyntaxError: 'await' outside function&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Устранить проблему можно, записав весь код в асинхронную функцию. Целиком выглядеть код будет так:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;from telethon import TelegramClient
import pandas as pd
import asyncio

async def main():
        api_id = 1234567
        api_hash = '1b42hj25kd8jw42b234kwj242c'
        phone = '+71234567890'
        channel_href = 'https://t.me/leftjoin'

	client = TelegramClient('session_name', api_id, api_hash)
	client = await client.start()
	dialogs = await client.get_dialogs()

	channels = {d.entity.username: d.entity
				for d in dialogs
				if d.is_channel}
	my_channel = channel_href.split('/')[-1]
	channel = channels[my_channel]

	members_telethon_list = await client.get_participants(channel, aggressive=True)

	username_list = [member.username for member in members_telethon_list]
	first_name_list = [member.first_name for member in members_telethon_list]
	last_name_list = [member.last_name for member in members_telethon_list]
	phone_list = [member.phone for member in members_telethon_list]

	df = pd.DataFrame()
	df['username'] = username_list
	df['first_name'] = first_name_list
	df['last_name'] = last_name_list
	df['phone'] = phone_list
	df.to_csv('subscribers.csv', index=False)

if __name__ == '__main__':
	loop = asyncio.get_event_loop()
	loop.run_until_complete(main())&lt;/code&gt;&lt;/pre&gt;</description>
<pubDate>Mon, 08 Feb 2021 15:19:17 +0300</pubDate>
</item>

<item>
<title>Матемаркетинг: современный облачный Data Stack</title>
<guid isPermaLink="false">81</guid>
<link>http://test.leftjoin.ru/all/matemarketing-data-stack/</link>
<comments>http://test.leftjoin.ru/all/matemarketing-data-stack/</comments>
<description>
&lt;p&gt;С 9 по 13 ноября в онлайн-формате прошёл Матемаркетинг — крупнейшая конференция по маркетинговой аналитике в России, и в этом году мне посчастливилось стать одним из спикеров. Я выступил с двумя докладами, в этом материале обсудим первый — о современном облачном Data Stack.&lt;/p&gt;
&lt;p&gt;Внутри объясняю подход к проектированию аналитической инфраструктуры, обосновываю использование Clickhouse при построении облачной аналитики и рассказываю о его же нюансах и говорю про Redash с точки зрения инструмента для визуализации.&lt;/p&gt;
&lt;div class="e2-text-video"&gt;
&lt;iframe src="https://www.youtube.com/embed/DoX-6hcFKoA" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;&lt;/div&gt;
</description>
<pubDate>Fri, 29 Jan 2021 09:55:19 +0300</pubDate>
</item>

<item>
<title>Робот для автоматизированного просмотра Instagram на Python и Selenium</title>
<guid isPermaLink="false">92</guid>
<link>http://test.leftjoin.ru/all/instagram-python-selenium-bot/</link>
<comments>http://test.leftjoin.ru/all/instagram-python-selenium-bot/</comments>
<description>
&lt;p class="note"&gt;Недавно мы начали вести Instagram — &lt;a href="https://www.instagram.com/leftjoin.ru/"&gt;подписывайтесь&lt;/a&gt;, чтобы не пропустить контент, которого нет в блоге и Telegram!&lt;/p&gt;
&lt;p&gt;Многие из нас ежедневно заходят в Instagram, чтобы посмотреть истории друзей и полистать ленту постов и рекомендаций. Предлагаем действенный способ сохранить своё время — напишем на Python и Selenium робота, который возьмёт на себя рутинную задачу проверки свежих новостей друзей и подсчитает число новых историй и входящих сообщений.&lt;/p&gt;
&lt;h2&gt;Авторизация в аккаунт&lt;/h2&gt;
&lt;p&gt;При переходе в браузерную версию сайта, нас встречает такое окно:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-22.png" width="827" height="619" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Но просто вставить логин, пароль и нажать на кнопку «Войти» недостаточно: впереди будет ещё два окна. Во-первых, предложение сохранить данные — здесь мы тактично жмём «Не сейчас». Instagram тщательно следит за каждым нашим действием и малейшие аномалии в поведении приводят к блокировке, поэтому любые предложения по сохранению данных будем на всякий случай пропускать.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-21.png" width="414" height="344" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Следующим препятствием будет предложение включить уведомление, которое мы тоже пропустим:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/3-19.png" width="449" height="381" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Первым делом импортируем библиотеки:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from bs4 import BeautifulSoup as bs
import time
import random&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И описываем функцию authorize — она будет принимать driver в качестве аргумента, отправлять в нужные поля логин и пароль, нажимать на кнопку «Войти», затем ждать десять секунд на загрузку страницы, нажимать на кнопку «Не сейчас», снова ждать загрузки страницы и пропускать уведомления:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def authorize(driver):
    username = 'login'
    password = 'password'
    driver.get('https://www.instagram.com')
    time.sleep(5)
    driver.find_element_by_name(&amp;quot;username&amp;quot;).send_keys(username)
    driver.find_element_by_name(&amp;quot;password&amp;quot;).send_keys(password)
    driver.execute_script(&amp;quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[0].click()&amp;quot;)
    time.sleep(10)
    driver.execute_script(&amp;quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[0].click()&amp;quot;)
    time.sleep(10)
    driver.execute_script(&amp;quot;document.getElementsByClassName('aOOlW   HoLwm ')[0].click()&amp;quot;)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Новые сообщения&lt;/h2&gt;
&lt;p&gt;В Instagram могут прийти сообщения двух видов. В случае, если вы не подписаны на отправителя — придёт запрос на диалог. Если подписаны — придёт входящее сообщения. Оба случая обрабатываются по-разному. Число входящих сообщений можно получить с главной страницы — это число над иконкой бумажного самолётика:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-01-25--15.19.31.png" width="231" height="70" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;А число запросов можно забрать текстом заголовка h5 из раздела «Сообщения». Сперва перейдём в этот раздел и попробуем найти строку с запросами на сообщение. Затем вернёмся на главную страницу и возьмём то самое число новых сообщений.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def messages_count(driver):
    driver.get('https://www.instagram.com/direct/inbox/')
    time.sleep(2)
    inbox = bs(driver.page_source)
    try:
        queries_text = inbox.find_all('h5')[0].text
    except Exception:
        queries_text = None
    driver.get('https://www.instagram.com')
    time.sleep(2)
    content = bs(driver.page_source)
    try:
        messages_count = int(content.find_all('div', attrs={'class':'KdEwV'})[0].text)
    except Exception:
        messages_count = 0
    return queries_text, messages_count&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Подсчёт числа новых сторис&lt;/h2&gt;
&lt;p&gt;Все истории хранятся в одном блоке:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-01-25--15.02.05.png" width="637" height="201" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Это список с одинаковым классом, но в каждом элементе списка лежит ещё один div-блок. У новых историй это класс eebAO  h_uhZ, у просмотренных — eebAO.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-01-25--15.03.48.png" width="200" height="132" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Ещё есть такая кнопка, которая показывает следующую пачку историй:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-01-25--15.05.14.png" width="269" height="135" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;При этом Instagram динамически прогружает код страницы, и в нём не найти те элементы, которые вы не видите своими глазами. Поэтому мы возьмём первые 8 видимых новых историй, добавим в список, нажмём на кнопку «Показать следующие истории» и будем продолжать так, пока кнопка ещё отображается. А затем подсчитаем число уникальных элементов, чтобы избежать возможных дубликатов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_stories_count(driver):
    stories_divs = []
    scroll = True
    while scroll:
        try:
            content = bs(driver.page_source)
            stories_divs.extend(content.find_all('div', attrs={'class':'eebAO h_uhZ'}))
            driver.execute_script(&amp;quot;document.getElementsByClassName('  _6CZji oevZr  ')[0].click()&amp;quot;)
            time.sleep(1)
        except Exception as E:
            scroll = False
    return len(set(stories_divs))&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Просмотр сторис&lt;/h2&gt;
&lt;p&gt;Следующее, чем может заняться реальный пользователь после авторизации — просмотр свежих историй. Для того, чтобы зайти в блок историй, нужно просто нажать на кнопку класса OE3OK:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/4-12.png" width="285" height="164" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Есть еще две кнопки, о которых мы должны знать. Это кнопка для переключения на следующую историю — она в классе FhutL и кнопка закрытия блока историй — класс wpO6b. Пускай одна история будет отнимать у нас от 10 до 15 секунд, и с вероятностью 1/5 мы переключим на следующую. При этом зададим переменные counter и limit — пусть сейчас мы хотим посмотреть случайное число историй от 5 до 45, и если мы уже посмотрели столько, то выходим из функции и историй.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def watch_stories(driver):
    watching = True
    counter = 0
    limit = random.randint(5, 45)
    driver.execute_script(&amp;quot;document.getElementsByClassName('OE3OK ')[0].click()&amp;quot;)
    try:
        while watching:
            time.sleep(random.randint(10, 15))
            if random.randint(1, 5) == 5:
                driver.execute_script(&amp;quot;document.getElementsByClassName('FhutL')[0].click()&amp;quot;)
            counter += 1
            if counter &amp;gt; limit:
                driver.execute_script(&amp;quot;document.getElementsByClassName('wpO6b ')[1].click()&amp;quot;)
                watching = False
    except Exception as E:
        print(E)
        watching = False&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Скроллинг ленты&lt;/h2&gt;
&lt;p&gt;После просмотра актуальных историй можно поскроллить ленту — это действие ничем не отличается от классического скроллинга страниц в Selenium. Запоминаем последнюю доступную длину страницы, скроллим до неё, ожидаем прогрузки, получаем новую. Прекратим просматривать ленту в двух случаях — если в random.randint() сгенерировалась единица или если лента кончилась.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def scroll_feed(driver):
    scrolling = True
    last_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
    while scrolling:
        driver.execute_script(&amp;quot;window.scrollTo(0, document.body.scrollHeight);&amp;quot;)
        time.sleep(random.randint(4,10))
        new_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
        if new_height == last_height or random.randint(1, 10) == 1:
            scrolling = False
        last_height = new_height&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Просмотр рекомендуемых аккаунтов&lt;/h2&gt;
&lt;p&gt;Instagram в заглавной странице сам рекомендует нам для подписки некоторые аккаунты. Выглядит она так:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/5-12.png" width="645" height="573" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;И на ней тоже придётся скроллить, чтобы дойти до конца. Заходим на страницу и ожидаем 5 секунд прогрузки, затем снова получаем длину страницы и скроллим вниз. Выходим тоже с вероятностью 1/10 или если страница кончилась, но ещё с вероятностью 1/2 подписываемся на некоторые из первых 100 аккаунтов рекомендаций:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def scroll_recomendations(driver):
   driver.get('https://www.instagram.com/explore/people/suggested/')
    time.sleep(5)
    scrolling = True
    last_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
    while scrolling:
        driver.execute_script(&amp;quot;window.scrollTo(0, document.body.scrollHeight);&amp;quot;)
        time.sleep(random.randint(4,10))
        new_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
        if new_height == last_height or random.randint(1, 10) == 1:
            scrolling = False
        last_height = new_height
        if random.randint(0, 1):
            try:
                driver.execute_script(f&amp;quot;document.getElementsByClassName('sqdOP  L3NKy   y3zKF     ')[{random.randint(1,100)}].click()&amp;quot;)
            except Exception as E:
                print(E)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Просмотр рекомендуемых постов&lt;/h2&gt;
&lt;p&gt;Помимо ленты, которая сформирована из наших подписок, Instagram собирает ленту рекомендаций. Туда входят все посты, которые потенциально могут вам понравиться — мы просто пройдём вниз по этой ленте. Выйдем с вероятностью 1/5 или когда кончится, чтобы долго не засиживаться.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def scroll_explore(driver):
    driver.get('https://www.instagram.com/explore')
    time.sleep(3)
    scrolling = True
    last_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
    while scrolling:
        driver.execute_script(&amp;quot;window.scrollTo(0, document.body.scrollHeight);&amp;quot;)
        time.sleep(random.randint(4,10))
        new_height = driver.execute_script(&amp;quot;return document.body.scrollHeight&amp;quot;)
        if new_height == last_height or random.randint(1, 5) == 1:
            scrolling = False
        last_height = new_height&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Итог&lt;/h2&gt;
&lt;p&gt;Теперь можно собрать все функции вместе — создаём новый driver, проводим авторизацию, считаем число новых сторис и сообщений, просматриваем сторис, переходим в рекомендуемые подписки и листаем ленту. В конце печатаем полученные данные — число новых сообщений, запросов и историй друзей.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;driver = webdriver.Chrome(ChromeDriverManager().install())
authorize(driver)
queries_text, messages_count = messages_count(driver)
stories_count = get_stories_count(driver)
watch_stories(driver)
scroll_recomendations(driver)
scroll_feed(driver)
scroll_explore(driver)

if queries_text is not None:
    print(queries_text)
else:
    print('Нет новых запросов на диалог')
print('Новых сообщений:', messages_count)

print('Новых историй:', stories_count)&lt;/code&gt;&lt;/pre&gt;</description>
<pubDate>Mon, 25 Jan 2021 16:49:42 +0300</pubDate>
</item>

<item>
<title>Парсинг целевой аудитории ВКонтакте</title>
<guid isPermaLink="false">85</guid>
<link>http://test.leftjoin.ru/all/vk-api-parse-audience/</link>
<comments>http://test.leftjoin.ru/all/vk-api-parse-audience/</comments>
<description>
&lt;p&gt;При размещении рекламы некоторые площадки в настройках аудитории позволяют загрузить список конкретных людей, которые увидят рекламу. Для парсинга id по конкретным пабликам существуют специальные инструменты, но куда интереснее (и дешевле) сделать это собственноручно при помощи Python и VK API. Сегодня расскажем, как для рекламной кампании LEFTJOIN мы спарсили целевую аудиторию и загрузили её в рекламный кабинет.&lt;/p&gt;
&lt;p class="note"&gt;В материале &lt;a href="http://test.leftjoin.ru/all/get-data-from-vk/" class="nu"&gt;«&lt;u&gt;Собираем данные по рекламным кампаниям ВКонтакте&lt;/u&gt;»&lt;/a&gt; подробно описан процесс получения токена пользователя для VK API&lt;/p&gt;
&lt;h2&gt;Парсинг пользователей&lt;/h2&gt;
&lt;p&gt;Для отправки запросов потребуется токен пользователя и список пабликов, чьих участников мы хотим получить. Мы собрали около 30 сообществ, посвящённых аналитике, BI-инструментам и Data Science.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import requests
import time

group_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']

token = 'ваш_токен'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Запрос на получение участников сообщества к API ВКонтакте вернёт максимум 1000 строк — для получения последующих тысяч потребуется смещать параметр offset на единицу. Но нужно знать, до какого момента это делать — поэтому опишем функцию, которая принимает id сообщества, получает информацию о числе участников сообщества и возвращает максимальное значение для offset — отношение числа участников к 1000, ведь мы можем получить ровно тысячу человек за раз.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_offset(group_id):
    count = requests.get('https://api.vk.com/method/groups.getMembers', params={
            'access_token':token,
            'v':5.103,
            'group_id': group_id,
            'sort':'id_desc',
            'offset':0,
            'fields':'last_seen'
        }).json()['response']['count']
    return count // 1000&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Следующим этапом опишем функцию, которая принимает id сообщества, собирает в один список id всех подписчиков и возвращает его. Для этого отправляем запросы на получение 1000 человек, пока не кончается offset, вносим данные в список и возвращаем его. Проходя по каждому человеку дополнительно проверяем дату его последнего посещения социальной сети — если он не заходил с середины ноября, добавлять его не будем. Время указывается в формате unixtime.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_users(group_id):
    good_id_list = []
    offset = 0
    max_offset = get_offset(group_id)
    while offset &amp;lt; max_offset:
        response = requests.get('https://api.vk.com/method/groups.getMembers', params={
            'access_token':token,
            'v':5.103,
            'group_id': group_id,
            'sort':'id_desc',
            'offset':offset,
            'fields':'last_seen'
        }).json()['response']
        offset += 1
        for item in response['items']:
            try:
                if item['last_seen']['time'] &amp;gt;= 1605571200:
                    good_id_list.append(item['id'])
            except Exception as E:
                continue
    return good_id_list&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь пройдём по всем сообществам из списка и для каждого соберём участников, а затем внесём их в общий список all_users. В конце переводим сначала список в множество, а затем опять в список, чтобы избавиться от возможных дубликатов: одни и те же люди могли быть участниками разных пабликов. Лишним не будет после каждого паблика приостановить работу программы на секунду, чтобы не столкнуться с ограничениями на число запросов.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;all_users = []

for group in group_list:
    print(group)
    try:
        users = get_users(group)
        all_users.extend(users)
        time.sleep(1)
    except KeyError as E:
        print(group, E)
        continue

all_users = list(set(all_users))&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Последним шагом записываем каждого пользователя в файл с новой строки.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;with open('users.txt', 'w') as f:
    for item in all_users:
        f.write(&amp;quot;%s\n&amp;quot; % item)&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Аудитория в рекламном кабинете из файла&lt;/h2&gt;
&lt;p&gt;Переходим в свой рекламный кабинет ВКонтакте и заходим во вкладку «Ретаргетинг». Там будем кнопка «Создать аудиторию»:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-17.png" width="588" height="157" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;После нажатия на неё откроется новое окно, где можно будет выбрать в качестве источника файл и указать название для аудитории:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-17.png" width="477" height="633" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;После загрузки пройдёт несколько секунд и аудитория будет доступна. Первые минут 10 будет указано, что аудитория слишком мала: это не так и панель вскоре обновится, если в вашей аудитории действительно более 100 человек.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/3-15.png" width="1250" height="131" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Итоги&lt;/h2&gt;
&lt;p&gt;Сравним среднюю стоимость привлечённого в наше сообщество участника в объявлении с автоматической настройкой аудитории и в объявлении, аудиторию для которого мы спарсили. В первом случае получаем среднюю стоимость в 52,4 рубля, а во втором — в 33,2 рубля. Подбор качественной аудитории при помощи методов парсинга данных из ВКонтакте помог снизить среднюю стоимость на 37%.&lt;/p&gt;
&lt;p&gt;Для рекламной кампании мы подготовили такой пост (нажмите на картинку, чтобы перейти к нему):&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;a href="https://vk.com/wall-195051876_51" class="e2-text-picture-link"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2020-12-22--13.50.42.png" width="423" height="581" alt="" /&gt;
&lt;/a&gt;&lt;/div&gt;
</description>
<pubDate>Tue, 22 Dec 2020 13:59:41 +0300</pubDate>
</item>


</channel>
</rss>