{
    "version": "https:\/\/jsonfeed.org\/version\/1",
    "title": "Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: логирование",
    "home_page_url": "http:\/\/test.leftjoin.ru\/tags\/logirovanie\/",
    "feed_url": "http:\/\/test.leftjoin.ru\/tags\/logirovanie\/json\/",
    "icon": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg",
    "author": {
        "name": "Николай Валиотти",
        "url": "http:\/\/test.leftjoin.ru\/",
        "avatar": "http:\/\/test.leftjoin.ru\/user\/userpic@2x.jpg"
    },
    "items": [
        {
            "id": "110",
            "url": "http:\/\/test.leftjoin.ru\/all\/parser-indeed-with-python\/",
            "title": "Парсим вакансии для аналитиков из Indeed",
            "content_html": "<p>В этом материале мы расскажем, как парсить вакансии с сайта Indeed. Indeed — это крупнейший в мире поисковик вакансий. Этим текстом мы начинаем большой проект по анализу и визуализации показателей оплаты труда в области Data Science в разных странах.<br \/>\nПодобный анализ рынка вакансий, но только в России, мы проводили в материале <a href=\"http:\/\/test.leftjoin.ru\/all\/hh-dashboard-bi-and-analysts-market\/\">Анализ рынка вакансий аналитики и BI: дашборд в Tableau<\/a>, когда парсили данные с сайта HeadHunter.<\/p>\n<p class=\"note\">А еще у нас можно почитать материал  <a href=\"http:\/\/test.leftjoin.ru\/all\/parsim-dannye-kataloga-sayta-ispolzuya-beautiful-soup-i-selenium\/\">Парсим данные каталога сайта, используя Beautiful Soup и Selenium<\/a><\/p>\n<p><b>Импорт библиотек<\/b><br \/>\nБиблиотека <span class=\"inline-code\">fake_useragent<\/span> имитирует реальный User-Agent, чтобы преодолеть защиту сайта от парсинга. Таким образом мы сможем пройти проверку HTTP заголовка User-Agent.<br \/>\nМодуль <span class=\"inline-code\">urllib.parse<\/span> разбирает URL-адрес на компоненты и записывает его как кортеж. Он пригодится для перехода на карточки вакансий. BeautifulSoup поможет разобраться в структуре html-страницы и добыть нужную нам информацию.<\/p>\n<pre class=\"e2-text-code\"><code>import requests\r\nfrom datetime import timedelta, datetime\r\nimport urllib.parse\r\nfrom fake_useragent import UserAgent\r\nfrom bs4 import BeautifulSoup\r\nimport pandas as pd\r\nimport time\r\nfrom lxml.html import fromstring\r\nfrom clickhouse_driver import Client\r\nfrom clickhouse_driver import errors\r\nimport numpy as np\r\nfrom funcs import check_title, get_skills_row, parse_salary, get_sheetname, create_table<\/code><\/pre><p><b>Создадим таблицу в Clickhouse<\/b><br \/>\nДанные, которые мы собираемся собрать, будем хранить в базе Clickhouse.<\/p>\n<pre class=\"e2-text-code\"><code>create_table = '''CREATE TABLE if not exists indeed.vacancies (\r\n    row_idx UInt16,\r\n    query_string String,\r\n    country String,\r\n    title String,\r\n    company String,\r\n    city String,\r\n    job_added Date,\r\n    easy_apply UInt8,\r\n    company_rating Nullable(Float32),\r\n    remote UInt8,\r\n    job_id String,\r\n    job_link String,\r\n    sheet String,\r\n    skills String,\r\n    added_date Date,\r\n    month_salary_from_USD Float64,\r\n    month_salary_to_USD Float64,\r\n    year_salary_from_USD Float64,\r\n    year_salary_to_USD Float64,\r\n)\r\nENGINE = ReplacingMergeTree\r\nSETTINGS index_granularity = 8192'''<\/code><\/pre><p><b>Обход блокировок<\/b><br \/>\nНам нужно обойти защиту Indeed и избежать блокировки по IP. Для этого используем анонимные прокси адреса на сайте free-proxy-list.net. Как собрать свежие прокси, мы писали в нашем предыдущем тексте <a href=\"http:\/\/test.leftjoin.ru\/all\/selenium-proxy\/\" class=\"nu\">«<u>Пишем парсер свежих прокси на Python для Selenium<\/u>»<\/a>. Прокси адреса мы запишем в массив, который понадобится в момент обращения к Indeed, когда запрос будет проверять User-Agent.<\/p>\n<p>Данный метод удаляет IP из списка с прокси в том случае, если ответ от Indeed через него так и не пришел.<\/p>\n<pre class=\"e2-text-code\"><code>def remove_proxy_from_list_and_update_if_required(proxy):\r\n    global _proxies\r\n    _proxies.remove(proxy)\r\n    if len(_proxies) == 0:\r\n        update_proxy_list()<\/code><\/pre><p>Функция, используя прокси, возвращает нам страницу Indeed, из которой мы впоследствии спарсим данные.<\/p>\n<pre class=\"e2-text-code\"><code>def get_page(updated_url, session):\r\n    proxy = get_proxy()\r\n    proxy_dict = {&quot;http&quot;: proxy, &quot;https&quot;: proxy}\r\n    logger.info(f'try with proxy: {proxy}')\r\n    try:\r\n        session.proxies = proxy_dict\r\n        return session.get(updated_url, timeout=15)\r\n    except (requests.exceptions.RequestException, requests.exceptions.ProxyError, requests.exceptions.ConnectTimeout,\r\n            requests.exceptions.ReadTimeout, requests.exceptions.SSLError,\r\n            requests.exceptions.ConnectionError, url_ex.MaxRetryError, ConnectionResetError,\r\n            socket.timeout, url_ex.ReadTimeoutError):\r\n        remove_proxy_from_list_and_update_if_required(proxy)\r\n        logger.info(f'try with proxy {proxy}')\r\n        return get_page(updated_url, session)<\/code><\/pre><p><b>Методы для парсера<\/b><br \/>\nИскомые данные нужно будет искать по тегам и атрибутам верстки с помощью BeautifulSoup. Мы заранее собрали ключевые слова, которые нас будут интересовать в вакансиях, и подготовили с ними отдельный датасет.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png\" width=\"1078\" height=\"686\" alt=\"\" \/>\n<\/div>\n<p>В карточках вакансий нет точной даты публикации, указано лишь сколько дней назад она была опубликована. Сохраним точную дату публикации в традиционном формате с помощью <span class=\"inline-code\">timedelta<\/span>.<\/p>\n<pre class=\"e2-text-code\"><code>def raw_date_to_str(raw_date):\r\n    raw_date = raw_date.lower()\r\n    if '+' in raw_date or &quot;более&quot; in raw_date:\r\n        delta = timedelta(days=32)\r\n        return (datetime.now() - delta).strftime(&quot;%Y-%m-%d&quot;)\r\n    else:\r\n        parts = raw_date.split()\r\n        for part in parts:\r\n            if part.isdigit():\r\n                delta = timedelta(days=part.isdigit())\r\n                return (datetime.now() - delta).strftime(&quot;%Y-%m-%d&quot;)\r\n    return &quot;&quot;<\/code><\/pre><p>Сохраним id вакансии в системе Indeed. Подставляя id в URL страницы, мы сможем получить доступ к полному описанию вакансий.<\/p>\n<pre class=\"e2-text-code\"><code>def get_job_id_from_card(card):\r\n    try:\r\n        return card['id'].split('_')[1]\r\n    except:\r\n        return &quot;&quot;<\/code><\/pre><p>Данный метод соберет названия вакансий.<\/p>\n<pre class=\"e2-text-code\"><code>def get_title_from_card(card):\r\n    try:\r\n        job_title = card.find('a', {'class': 'jobtitle'}).text\r\n        return job_title.replace('\\n', '')\r\n    except:\r\n        return ''<\/code><\/pre><p>Аналогичным образом напишем методы, которые будут собирать данные о названии компании, времени публикации объявления, местоположении работодателя и рейтинге работодателя на портале.<\/p>\n<p>URL сайта Indeed пишется для разных стран по-разному. Для США это будет просто indeed.com, а локализации для других стран получают префиксом xx.indeed.com. Список с префиксами мы собрали в массив заранее из <a href=\"\"><a href=\"https:\/\/opensource.indeedeng.io\/api-documentation\/docs\/supported-countries\/\">https:\/\/opensource.indeedeng.io\/api-documentation\/docs\/supported-countries\/<\/a> списка<\/a> Indeed.<\/p>\n<pre class=\"e2-text-code\"><code>def get_link_from_card(card, card_country):\r\n    try:\r\n        if card_country == 'us':\r\n            return f&quot;https:\/\/indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&quot;\r\n        else:\r\n            return f&quot;https:\/\/{card_country}.indeed.com{card.find('a', {'class': 'jobtitle'})['href']}&quot;\r\n    except:\r\n        return &quot;&quot;<\/code><\/pre><p>Спарсим описание вакансии, которое можно найти по тегу ’summary’. Именно там содержатся требования, которые предъявляют к кандидату.<\/p>\n<pre class=\"e2-text-code\"><code>def get_summary_from_card_and_transform_to_skills(card):\r\n    try:\r\n        smr = card.find('div', {'class': 'summary'}).text\r\n        return get_skills_row(smr)\r\n    except:\r\n        return &quot;&quot;\r\nНеобходимые hard-skills из описания вакансий будем сверять со списком 'skills'. \r\nskills = [&quot;python&quot;, &quot;tableau&quot;, &quot;etl&quot;, &quot;power bi&quot;, &quot;d3.js&quot;, &quot;qlik&quot;, &quot;qlikview&quot;, &quot;qliksense&quot;,\r\n          &quot;redash&quot;, &quot;metabase&quot;, &quot;numpy&quot;, &quot;pandas&quot;, &quot;congos&quot;, &quot;superset&quot;, &quot;matplotlib&quot;, &quot;plotly&quot;,\r\n          &quot;airflow&quot;, &quot;spark&quot;, &quot;luigi&quot;, &quot;machine learning&quot;, &quot;amplitude&quot;, &quot;sql&quot;, &quot;nosql&quot;, &quot;clickhouse&quot;,\r\n          'sas', &quot;hadoop&quot;, &quot;pytorch&quot;, &quot;tensorflow&quot;, &quot;bash&quot;, &quot;scala&quot;, &quot;git&quot;, &quot;aws&quot;, &quot;docker&quot;,\r\n          &quot;linux&quot;, &quot;kafka&quot;, &quot;nifi&quot;, &quot;ozzie&quot;, &quot;ssas&quot;, &quot;ssis&quot;, &quot;redis&quot;, 'olap', ' r ', 'bigquery', 'api', 'excel']<\/code><\/pre><p>Эта функция разобьет ’summary’ на слова пробелом и проверит их на соответствие нашему списку. В датасет будут возвращаться совпадения с нашим списком hard-skills.<\/p>\n<pre class=\"e2-text-code\"><code>def get_skills_row(summary):\r\n    summary = summary.lower()\r\n    row = []\r\n    for sk in skills:\r\n        if sk in summary:\r\n            row.append(sk)\r\n    return ','.join(row)<\/code><\/pre><p>На выходе мы получим таблицу с примерно 30 тысячами строк.<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-05-21--17.29.19.png\" width=\"1105\" height=\"658\" alt=\"\" \/>\n<\/div>\n<p>Полный код проекта можно посмотреть в нашем <a href=\"https:\/\/github.com\/valiotti\/leftjoin\/tree\/master\/indeed\"> репозитории<\/a> на GitHub.<\/p>\n",
            "date_published": "2021-05-27T10:10:46+03:00",
            "date_modified": "2021-05-26T12:43:15+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png",
            "_date_published_rfc2822": "Thu, 27 May 2021 10:10:46 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "110",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-26--10.28.08.png",
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-05-21--17.29.19.png"
                ]
            }
        },
        {
            "id": "105",
            "url": "http:\/\/test.leftjoin.ru\/all\/python-logger\/",
            "title": "Эффективное логирование в Python",
            "content_html": "<p>В Python существует встроенный модуль logging, который позволяет журналировать этапы выполнения программы. Логирование полезно когда, например, нужно оставить большой скрипт сбора \/ обработки данных на длительное время, а в случае возникновения непредвиденных ошибок выяснить, с чем они могут быть связаны. Анализ логов позволяет быстро и эффективно выявлять проблемные места в коде, но для удобного использования модуля следует написать несколько функций по взаимодействию с ним и вынести их в отдельный файл — сегодня мы этим и займёмся.<\/p>\n<h2>Пишем логгер<\/h2>\n<p>Создадим файл loggers.py. Для начала импортируем модули и задаём пару значений по умолчанию — директорию для файла с логом и наименование конфигурационного файла, содержащего шаблоны логирования. Его мы опишем следом.<\/p>\n<pre class=\"e2-text-code\"><code>import os\r\nimport json\r\nimport logging\r\nimport logging.config\r\n\r\nFOLDER_LOG = &quot;log&quot;\r\nLOGGING_CONFIG_FILE = 'loggers.json'<\/code><\/pre><p>Опишем функцию для создания папки с логом: она принимает наименование для папки, но по умолчанию будет называть её «log». Директорию создаём при помощи модуля os и только в том случае, если такой директории ещё не существует.<\/p>\n<pre class=\"e2-text-code\"><code>def create_log_folder(folder=FOLDER_LOG):\r\n    if not os.path.exists(folder):\r\n        os.mkdir(folder)<\/code><\/pre><p>Теперь опишем функцию создания нового логгера по заданному шаблону. Функция должна создать директорию для логирования, открыть конфигурационный файл и достать нужный шаблон. Затем по шаблону при помощи модуля logging создаём новый логгер:<\/p>\n<pre class=\"e2-text-code\"><code>def get_logger(name, template='default'):\r\n    create_log_folder()\r\n    with open(LOGGING_CONFIG_FILE, &quot;r&quot;) as f:\r\n        dict_config = json.load(f)\r\n        dict_config[&quot;loggers&quot;][name] = dict_config[&quot;loggers&quot;][template]\r\n    logging.config.dictConfig(dict_config)\r\n    return logging.getLogger(name)<\/code><\/pre><p>Для удобства опишем ещё одну функцию — получение стандартного лога. Она ничего не принимает и нужна только для инициализации лога с шаблоном default:<\/p>\n<pre class=\"e2-text-code\"><code>def get_default_logger():\r\n    create_log_folder()\r\n    with open(LOGGING_CONFIG_FILE, &quot;r&quot;) as f:\r\n        logging.config.dictConfig(json.load(f))\r\n\r\n    return logging.getLogger(&quot;default&quot;)<\/code><\/pre><h2>Описываем конфигурационный файл<\/h2>\n<p>Создадим по соседству файл loggers.json — он будет содержать настройки логгера. Внутри указываем такие настройки, как версию логгера, форматы логирования для разных уровней, наименование выходного файла и его максимальный размер:<\/p>\n<pre class=\"e2-text-code\"><code>{\r\n    &quot;version&quot;: 1,\r\n    &quot;disable_existing_loggers&quot;: false,\r\n    &quot;formatters&quot;: {\r\n        &quot;default&quot;: {\r\n            &quot;format&quot;: &quot;%(asctime)s - %(processName)-10s - %(name)-10s - %(levelname)-8s - %(message)s&quot;\r\n        }\r\n    },\r\n    &quot;handlers&quot;: {\r\n        &quot;console&quot;: {\r\n            &quot;class&quot;: &quot;logging.StreamHandler&quot;,\r\n            &quot;level&quot;: &quot;INFO&quot;,\r\n            &quot;formatter&quot;: &quot;default&quot;\r\n        },\r\n        &quot;rotating_file&quot;: {\r\n            &quot;class&quot;: &quot;logging.handlers.RotatingFileHandler&quot;,\r\n            &quot;level&quot;: &quot;DEBUG&quot;,\r\n            &quot;formatter&quot;: &quot;default&quot;,\r\n            &quot;filename&quot;: &quot;log\/main.log&quot;,\r\n            &quot;maxBytes&quot;: 10485760,\r\n            &quot;backupCount&quot;: 20\r\n        }\r\n    },\r\n    &quot;loggers&quot;: {\r\n        &quot;default&quot;: {\r\n            &quot;handlers&quot;: [&quot;console&quot;, &quot;rotating_file&quot;],\r\n            &quot;level&quot;: &quot;DEBUG&quot;\r\n        }\r\n    }\r\n}<\/code><\/pre><h2>Использование логгера<\/h2>\n<p>Теперь давайте представим, что вы выгружаете данные по API и складываете их в базу данных на примере нашего материала про <a href=\"http:\/\/test.leftjoin.ru\/all\/tranzakcii-v-sqlalchemy\/\">транзакции в SQLAlchemy<\/a>. Рассмотрим заключительную часть кода: добавим строку с инициализацией стандартного логгера и изменим код так, чтобы сначала в лог выводился offset, затем в случае успеха предложение «Successfully inserted data», а в случае ошибки выводилась сама ошибка и предложение: «Error: tried to insert data but got an error».<\/p>\n<pre class=\"e2-text-code\"><code>logger = get_logger('main')\r\n\r\noffset = 0\r\nsubs_count = get_subs_count(group_id)\r\n\r\nwhile offset &lt; subs_count:\r\n    with engine.connect() as conn:\r\n        transaction = conn.begin()\r\n        try:\r\n            logger.info(f&quot;{offset} \/ {subs_count}&quot;)\r\n            df = get_subs_info(group_id, offset)\r\n            df.to_sql('subscribers', con=conn, if_exists='append', index=False)\r\n            if offset == 10:\r\n                raise(ValueError(&quot;This is a test errror&quot;))\r\n            transaction.commit()\r\n            logger.info(f&quot;Successfully inserted data&quot;)\r\n        except Exception as E:\r\n            transaction.rollback()\r\n            logger.error(f&quot;Error: tried to insert {df} but got an error: {E}&quot;)\r\n    time.sleep(1)\r\n    offset += 10<\/code><\/pre><p>Теперь во время работы программы будет отображаться такой вывод, который также будет записан в файл main.log папки log в директории проекта. После завершения работы программы можно исследовать логи, посмотреть, на каких offset возникли проблемы, какие данные не удалось вставить и прочитать текст ошибки:<\/p>\n<div class=\"e2-text-picture\">\n<img src=\"http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png\" width=\"994\" height=\"468\" alt=\"\" \/>\n<\/div>\n",
            "date_published": "2021-03-30T13:51:48+03:00",
            "date_modified": "2021-03-26T15:11:01+03:00",
            "image": "http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png",
            "_date_published_rfc2822": "Tue, 30 Mar 2021 13:51:48 +0300",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "105",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": [
                    "http:\/\/test.leftjoin.ru\/pictures\/--2021-03-26--11.14.34.png"
                ]
            }
        }
    ],
    "_e2_version": 3365,
    "_e2_ua_string": "E2 (v3365; Aegea)"
}