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

<channel>

<title>Блог об аналитике, визуализации данных, data science и BI, заметки с тегом: github</title>
<link>http://test.leftjoin.ru/tags/github/</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>Пишем скрипт для автоматизации коммитов GitHub</title>
<guid isPermaLink="false">106</guid>
<link>http://test.leftjoin.ru/all/github-api/</link>
<comments>http://test.leftjoin.ru/all/github-api/</comments>
<description>
&lt;p&gt;У GitHub есть API, позволяющий делать всё, что можно сделать руками: создавать репозитории, делать коммиты файлов и переключаться между ветками. Не все используют GUI при работе с системой контроля версий, и для коммита файлов приходится вводить не одну команду в терминал, а смена веток нередко приводит к запутанности действий. Сегодня мы поэкспериментируем с GitHub API и напишем скрипт, который сам собирает все файлы текущей директории раз в час и отправляет в отдельную ветку на GitHub при помощи get, post и put-запросов к методу /repos.&lt;/p&gt;
&lt;h2&gt;Получение access token&lt;/h2&gt;
&lt;p&gt;Для работы с GitHub API нужно получить токен, который выдаётся каждому пользователю в &lt;a href="https://github.com/settings/tokens"&gt;настройках&lt;/a&gt;. Для создания нового токена нажимаем на «Generate new token»:&lt;/p&gt;
&lt;p class="note"&gt;В процессе работы с GitHub API токен может просрочиться и в ответ придёт ошибка «Bad credentials». Для решения проблемы можно создать новый токен или &lt;a href="https://docs.github.com/en/developers/apps/about-apps"&gt;получить токен приложения&lt;/a&gt;&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/1-29.png" width="1035" height="345" alt="" /&gt;
&lt;/div&gt;
&lt;p&gt;Следом нужно оставить название для токена и отметить области доступа ключу. Для наших целей достаточно дать доступ только к разделу repo.&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/2-28.png" width="763" height="454" 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-24.png" width="781" height="288" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Отправляем запросы&lt;/h2&gt;
&lt;p&gt;Подключим нужные библиотеки и введём некоторые константные значения: логин, токен, название для репозитория и ветки, в которую в автоматическом режиме будут отправляться файлы:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import requests
import base64
import json
import os

username = 'leftjoin'
repo = 'leftjoin'
token = &amp;quot;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&amp;quot;
new_branch_name = 'automatic'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Создадим новый репозиторий post-запросом. В поле data передаем название репозитория, в auth — логин и токен:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;r = requests.post('https://api.github.com/user/repos',
                  data=json.dumps({'name':repo}),
                  auth=(username, token),
                  headers={&amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot;})&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь загрузим файл README.md, в котором будет строка “Hello, world!”. Для этого строку нужно перекодировать в base64 — GitHub принимает данные в такой кодировке:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;content = &amp;quot;Hello, world!&amp;quot;

b_content = content.encode('utf-8')
base64_content = base64.b64encode(b_content)
base64_content_str = base64_content.decode('utf-8')&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь создадим коммит в формате словаря: он состоит из пути (пока его оставим пустым), сообщения коммита и содержания:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;f = {'path':'',
     'message': 'Automatic update',
     'content': base64_content_str}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Отправим файл в репозиторий put-запросом, передав путь до README.md прямо в ссылке запроса:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;f_resp = requests.put(f'https://api.github.com/repos/{username}/{repo}/contents/README.md',
                auth=(username, token),
                headers={ &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot; },
                data=json.dumps(f))&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Теперь в репозитории в ветке main есть один файл README.md, который содержит строку «Hello, world!»:&lt;/p&gt;
&lt;div class="e2-text-picture"&gt;
&lt;img src="http://test.leftjoin.ru/pictures/4-16.png" width="927" height="157" alt="" /&gt;
&lt;/div&gt;
&lt;h2&gt;Работа с ветками&lt;/h2&gt;
&lt;p&gt;Так как наш скрипт подразумевает работу в фоновом режиме, не всегда будет возможность контролировать, какие версии файлов он отправляет. Чтобы не засорять основную ветку будем отправлять все файлы в специальную, из которой при надобности можно будет достать обновления. Логика такая: проверяем, есть ли в репозитории такая ветка и, если нет, создаём её. Затем получаем все файлы текущей директории, построчно считываем, перекодируем в base64, коммитим и отправляем в ветку для автоматических обновлений.&lt;/p&gt;
&lt;p&gt;Начнём с функции, которая определяет, существует ли ветка с таким названием. Она отправит запрос на получение всех веток репозитория, и вернёт False, если ветки не существует:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def branch_exist(branch_name):
    branches = requests.get(f'https://api.github.com/repos/{username}/{repo}/git/refs').json()
    try:
        for branch in branches:
            if branch['ref'] == 'refs/heads/' + branch_name:
                return True
        return False
    except Exception:
        return False&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;У каждой ветки, файлов и репозиториев на GitHub есть уникальный идентификатор, получаемый путём хэш-суммы алгоритмом SHA. Он необходим как для модификации файлов, так и для смены веток — опишем функцию, которая по названию ветки получает SHA для неё:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_branch_sha(branch_name):
    try:
        branches = requests.get(f'https://api.github.com/repos/{username}/{repo}/git/refs').json()
        for branch in branches:
            sha = None
            if branch['ref'] == 'refs/heads/' + branch_name:
                sha = branch['object']['sha']
            return sha
    except Exception:
        return None&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Следом опишем функцию создания новой ветки с названием из переменной new_branch_name:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def create_branch():
    main_branch_sha = get_branch_sha('main')
    requests.post(f'https://api.github.com/repos/{username}/{repo}/git/refs',
             auth=(username, token),
             data=json.dumps({
                 'ref':f'refs/heads/{new_branch_name}',
                 'sha':main_branch_sha
             })).json()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Для модификации файлов тоже необходимо знать его идентификатор:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def get_sha(path):
    r = requests.get(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token),
                    data=json.dumps({
                        'branch': new_branch_name
                    }))
    sha = r.json()['sha']
    return sha&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Наконец, опишем ряд главных функций — первая по аналогии с примером из начала материала принимает содержимое файла и формирует коммит в отдельную ветку, а вторая отправляет файл в указанный репозиторий:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def make_file_and_commit(content, sha=None):
    b_content = content.encode('utf-8')
    base64_content = base64.b64encode(b_content)
    base64_content_str = base64_content.decode('utf-8')
    f = {'path':'',
     'message': 'Automatic update',
     'content': base64_content_str,
     'sha':sha,
     'branch': new_branch_name}
    return f

def send_file(path, data):
    f_resp = requests.put(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token),
                    headers={ &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot; },
                    data=json.dumps(data))
    print(f_resp.json())
    return f_resp&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И ещё две функции напоследок: первая нужна, потому что мы должны знать, модифицируем мы уже существующий файл или новый. В случае с новым нет необходимости получать SHA для файла — его просто не существует. Вторая функция считывает всё содержимое файла в строку.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def file_exist(path):
    r = requests.get(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token))
    return r.ok

def file_reader(path):
    lines = &amp;quot;&amp;quot;
    with open(path, 'r') as f:
        for line in f:
            lines += line
    return lines&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Соберём всё вместе в функцию main(). Проверяем, существует ли ветка для автоматических коммитов, затем получаем все файлы директории, проверяем их существование в репозитории и отправляем:&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;def main():
    if not branch_exist(new_branch_name):
        create_branch()
    files = [os.path.join(dp, f) for dp, dn, fn in os.walk(os.path.expanduser(&amp;quot;.&amp;quot;)) for f in fn]
    for path in files:
        print(path)
        sha = None
        if file_exist(path):
            sha = get_sha(path)
        lines = file_reader(path)
        f = make_file_and_commit(lines, sha)
        r = send_file(path, f)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Перевести скрипт в автоматический режим может помочь библиотека schedule, которую мы уже использовали в материале &lt;a href="http://test.leftjoin.ru/all/hh-dashboard-bi-and-analysts-market/" class="nu"&gt;«&lt;u&gt;Анализ рынка вакансий аналитики и BI: дашборд в Tableau&lt;/u&gt;»&lt;/a&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code&gt;import schedule

schedule.every().hour.do(main)

while True:
    schedule.run_pending()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Вот и всё: мы написали скрипт, который самостоятельно каждый час отправляет все файлы текущей директории в ветку на GitHub.&lt;/p&gt;
</description>
<pubDate>Wed, 07 Apr 2021 16:13:41 +0300</pubDate>
</item>


</channel>
</rss>