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

<channel>

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

<item>
<title>Парсим вакансии для аналитиков из Indeed</title>
<guid isPermaLink="false">110</guid>
<link>http://test.leftjoin.ru/all/parser-indeed-with-python/</link>
<comments>http://test.leftjoin.ru/all/parser-indeed-with-python/</comments>
<description>
&lt;p&gt;В этом материале мы расскажем, как парсить вакансии с сайта Indeed. Indeed — это крупнейший в мире поисковик вакансий. Этим текстом мы начинаем большой проект по анализу и визуализации показателей оплаты труда в области Data Science в разных странах.&lt;br /&gt;
Подобный анализ рынка вакансий, но только в России, мы проводили в материале &lt;a href="http://test.leftjoin.ru/all/hh-dashboard-bi-and-analysts-market/"&gt;Анализ рынка вакансий аналитики и BI: дашборд в Tableau&lt;/a&gt;, когда парсили данные с сайта HeadHunter.&lt;/p&gt;
&lt;p class="note"&gt;А еще у нас можно почитать материал  &lt;a href="http://test.leftjoin.ru/all/parsim-dannye-kataloga-sayta-ispolzuya-beautiful-soup-i-selenium/"&gt;Парсим данные каталога сайта, используя Beautiful Soup и Selenium&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;b&gt;Импорт библиотек&lt;/b&gt;&lt;br /&gt;
Библиотека &lt;span class="inline-code"&gt;fake_useragent&lt;/span&gt; имитирует реальный User-Agent, чтобы преодолеть защиту сайта от парсинга. Таким образом мы сможем пройти проверку HTTP заголовка User-Agent.&lt;br /&gt;
Модуль &lt;span class="inline-code"&gt;urllib.parse&lt;/span&gt; разбирает URL-адрес на компоненты и записывает его как кортеж. Он пригодится для перехода на карточки вакансий. BeautifulSoup поможет разобраться в структуре html-страницы и добыть нужную нам информацию.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import requests
from datetime import timedelta, datetime
import urllib.parse
from fake_useragent import UserAgent
from bs4 import BeautifulSoup
import pandas as pd
import time
from lxml.html import fromstring
from clickhouse_driver import Client
from clickhouse_driver import errors
import numpy as np
from funcs import check_title, get_skills_row, parse_salary, get_sheetname, create_table&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Создадим таблицу в Clickhouse&lt;/b&gt;&lt;br /&gt;
Данные, которые мы собираемся собрать, будем хранить в базе Clickhouse.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;create_table = '''CREATE TABLE if not exists indeed.vacancies (
    row_idx UInt16,
    query_string String,
    country String,
    title String,
    company String,
    city String,
    job_added Date,
    easy_apply UInt8,
    company_rating Nullable(Float32),
    remote UInt8,
    job_id String,
    job_link String,
    sheet String,
    skills String,
    added_date Date,
    month_salary_from_USD Float64,
    month_salary_to_USD Float64,
    year_salary_from_USD Float64,
    year_salary_to_USD Float64,
)
ENGINE = ReplacingMergeTree
SETTINGS index_granularity = 8192'''&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Обход блокировок&lt;/b&gt;&lt;br /&gt;
Нам нужно обойти защиту Indeed и избежать блокировки по IP. Для этого используем анонимные прокси адреса на сайте free-proxy-list.net. Как собрать свежие прокси, мы писали в нашем предыдущем тексте &lt;a href="http://test.leftjoin.ru/all/selenium-proxy/" class="nu"&gt;«&lt;u&gt;Пишем парсер свежих прокси на Python для Selenium&lt;/u&gt;»&lt;/a&gt;. Прокси адреса мы запишем в массив, который понадобится в момент обращения к Indeed, когда запрос будет проверять User-Agent.&lt;/p&gt;
&lt;p&gt;Данный метод удаляет IP из списка с прокси в том случае, если ответ от Indeed через него так и не пришел.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def remove_proxy_from_list_and_update_if_required(proxy):
    global _proxies
    _proxies.remove(proxy)
    if len(_proxies) == 0:
        update_proxy_list()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Функция, используя прокси, возвращает нам страницу Indeed, из которой мы впоследствии спарсим данные.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_page(updated_url, session):
    proxy = get_proxy()
    proxy_dict = {&amp;quot;http&amp;quot;: proxy, &amp;quot;https&amp;quot;: proxy}
    logger.info(f'try with proxy: {proxy}')
    try:
        session.proxies = proxy_dict
        return session.get(updated_url, timeout=15)
    except (requests.exceptions.RequestException, requests.exceptions.ProxyError, requests.exceptions.ConnectTimeout,
            requests.exceptions.ReadTimeout, requests.exceptions.SSLError,
            requests.exceptions.ConnectionError, url_ex.MaxRetryError, ConnectionResetError,
            socket.timeout, url_ex.ReadTimeoutError):
        remove_proxy_from_list_and_update_if_required(proxy)
        logger.info(f'try with proxy {proxy}')
        return get_page(updated_url, session)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;Методы для парсера&lt;/b&gt;&lt;br /&gt;
Искомые данные нужно будет искать по тегам и атрибутам верстки с помощью BeautifulSoup. Мы заранее собрали ключевые слова, которые нас будут интересовать в вакансиях, и подготовили с ними отдельный датасет.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-05-26--10.28.08.png" width="1078" height="686" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;В карточках вакансий нет точной даты публикации, указано лишь сколько дней назад она была опубликована. Сохраним точную дату публикации в традиционном формате с помощью &lt;span class="inline-code"&gt;timedelta&lt;/span&gt;.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def raw_date_to_str(raw_date):
    raw_date = raw_date.lower()
    if '+' in raw_date or &amp;quot;более&amp;quot; in raw_date:
        delta = timedelta(days=32)
        return (datetime.now() - delta).strftime(&amp;quot;%Y-%m-%d&amp;quot;)
    else:
        parts = raw_date.split()
        for part in parts:
            if part.isdigit():
                delta = timedelta(days=part.isdigit())
                return (datetime.now() - delta).strftime(&amp;quot;%Y-%m-%d&amp;quot;)
    return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Сохраним id вакансии в системе Indeed. Подставляя id в URL страницы, мы сможем получить доступ к полному описанию вакансий.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_job_id_from_card(card):
    try:
        return card['id'].split('_')[1]
    except:
        return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Данный метод соберет названия вакансий.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_title_from_card(card):
    try:
        job_title = card.find('a', {'class': 'jobtitle'}).text
        return job_title.replace('\n', '')
    except:
        return ''&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Аналогичным образом напишем методы, которые будут собирать данные о названии компании, времени публикации объявления, местоположении работодателя и рейтинге работодателя на портале.&lt;/p&gt;
&lt;p&gt;URL сайта Indeed пишется для разных стран по-разному. Для США это будет просто indeed.com, а локализации для других стран получают префиксом xx.indeed.com. Список с префиксами мы собрали в массив заранее из &lt;a href=""&gt;&lt;a href="https://opensource.indeedeng.io/api-documentation/docs/supported-countries/"&gt;https://opensource.indeedeng.io/api-documentation/docs/supported-countries/&lt;/a&gt; списка&lt;/a&gt; Indeed.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_link_from_card(card, card_country):
    try:
        if card_country == 'us':
            return f&amp;quot;https://indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&amp;quot;
        else:
            return f&amp;quot;https://{card_country}.indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&amp;quot;
    except:
        return &amp;quot;&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Спарсим описание вакансии, которое можно найти по тегу ’summary’. Именно там содержатся требования, которые предъявляют к кандидату.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_summary_from_card_and_transform_to_skills(card):
    try:
        smr = card.find('div', {'class': 'summary'}).text
        return get_skills_row(smr)
    except:
        return &amp;quot;&amp;quot;
Необходимые hard-skills из описания вакансий будем сверять со списком 'skills'. 
skills = [&amp;quot;python&amp;quot;, &amp;quot;tableau&amp;quot;, &amp;quot;etl&amp;quot;, &amp;quot;power bi&amp;quot;, &amp;quot;d3.js&amp;quot;, &amp;quot;qlik&amp;quot;, &amp;quot;qlikview&amp;quot;, &amp;quot;qliksense&amp;quot;,
          &amp;quot;redash&amp;quot;, &amp;quot;metabase&amp;quot;, &amp;quot;numpy&amp;quot;, &amp;quot;pandas&amp;quot;, &amp;quot;congos&amp;quot;, &amp;quot;superset&amp;quot;, &amp;quot;matplotlib&amp;quot;, &amp;quot;plotly&amp;quot;,
          &amp;quot;airflow&amp;quot;, &amp;quot;spark&amp;quot;, &amp;quot;luigi&amp;quot;, &amp;quot;machine learning&amp;quot;, &amp;quot;amplitude&amp;quot;, &amp;quot;sql&amp;quot;, &amp;quot;nosql&amp;quot;, &amp;quot;clickhouse&amp;quot;,
          'sas', &amp;quot;hadoop&amp;quot;, &amp;quot;pytorch&amp;quot;, &amp;quot;tensorflow&amp;quot;, &amp;quot;bash&amp;quot;, &amp;quot;scala&amp;quot;, &amp;quot;git&amp;quot;, &amp;quot;aws&amp;quot;, &amp;quot;docker&amp;quot;,
          &amp;quot;linux&amp;quot;, &amp;quot;kafka&amp;quot;, &amp;quot;nifi&amp;quot;, &amp;quot;ozzie&amp;quot;, &amp;quot;ssas&amp;quot;, &amp;quot;ssis&amp;quot;, &amp;quot;redis&amp;quot;, 'olap', ' r ', 'bigquery', 'api', 'excel']&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Эта функция разобьет ’summary’ на слова пробелом и проверит их на соответствие нашему списку. В датасет будут возвращаться совпадения с нашим списком hard-skills.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_skills_row(summary):
    summary = summary.lower()
    row = []
    for sk in skills:
        if sk in summary:
            row.append(sk)
    return ','.join(row)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;На выходе мы получим таблицу с примерно 30 тысячами строк.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/--2021-05-21--17.29.19.png" width="1105" height="658" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Полный код проекта можно посмотреть в нашем &lt;a href="https://github.com/valiotti/leftjoin/tree/master/indeed"&gt; репозитории&lt;/a&gt; на GitHub.&lt;/p&gt;
</description>
<pubDate>Thu, 27 May 2021 10:10:46 +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>


</channel>
</rss>