Парсим HH.ru на Python

Эта статья описывает техническую реализацию ответа на вопрос к статье «Какие бывают аналитики». Поэтому выводов здесь не будет. А в этой статье мы:

Облако биграмм, составленных по названиям вакансий

Используем API HH.ru

HeadHunter имеет официальный API, что позволяет получать информацию по вакансия в удобном формате, без необходимости парсить web-страницы сайта. Чтобы получить вакансии достаточно выполнить http-запрос методом get по адресу https://api.hh.ru/vacancies и передать параметры для фильтра. Ответ вернется в формате JSON, который мы сохраним себе в виде файла для дальнейшей работы.

Каждый файл представляет из себя страницу, как если бы Вы переключались в веб-интерфейсе по постраничной навигации, содержащую названия вакансий и ссылки на страницы вакансий. Давайте получим их (см. пояснения в комментариях кода):

# Библиотека для работы с HTTP-запросами. Будем использовать ее для обращения к API HH
import requests

# Пакет для удобной работы с данными в формате json
import json

# Модуль для работы со значением времени
import time

# Модуль для работы с операционной системой. Будем использовать для работы с файлами
import os

 
def getPage(page = 0):
    """
    Создаем метод для получения страницы со списком вакансий.
    Аргументы:
        page - Индекс страницы, начинается с 0. Значение по умолчанию 0, т.е. первая страница
    """
    
    # Справочник для параметров GET-запроса
    params = {
        'text': 'NAME:Аналитик', # Текст фильтра. В имени должно быть слово "Аналитик"
        'area': 1, # Поиск ощуществляется по вакансиям города Москва
        'page': page, # Индекс страницы поиска на HH
        'per_page': 100 # Кол-во вакансий на 1 странице
    }
    
    
    req = requests.get('https://api.hh.ru/vacancies', params) # Посылаем запрос к API
    data = req.content.decode() # Декодируем его ответ, чтобы Кириллица отображалась корректно
    req.close()
    return data


# Считываем первые 2000 вакансий
for page in range(0, 20):
    
    # Преобразуем текст ответа запроса в справочник Python
    jsObj = json.loads(getPage(page))
    
    # Сохраняем файлы в папку {путь до текущего документа со скриптом}\docs\pagination
    # Определяем количество файлов в папке для сохранения документа с ответом запроса
    # Полученное значение используем для формирования имени документа
    nextFileName = './docs/pagination/{}.json'.format(len(os.listdir('./docs/pagination')))
    
    # Создаем новый документ, записываем в него ответ запроса, после закрываем
    f = open(nextFileName, mode='w', encoding='utf8')
    f.write(json.dumps(jsObj, ensure_ascii=False))
    f.close()
    
    # Проверка на последнюю страницу, если вакансий меньше 2000
    if (jsObj['pages'] - page) <= 1:
        break
    
    # Необязательная задержка, но чтобы не нагружать сервисы hh, оставим. 5 сек мы может подождать
    time.sleep(0.25)
    
print('Старницы поиска собраны')

После получения страниц со списком вакансий получим детальную информацию по каждой вакансии. Для этого разберем JSON в полученных документах и для каждой вакансии обратимся к API по готовой ссылке https://api.hh.ru/vacancies/{id вакансии}?host=hh.ru.

Пример json-данных со списком вакансий

Результатом работы следующего скрипта будет являться папка vacancies, наполненная файлами с информацией по вакансиям:

import json
import os
import requests
import time

# Получаем перечень ранее созданных файлов со списком вакансий и проходимся по нему в цикле 
for fl in os.listdir('./docs/pagination'):
    
    # Открываем файл, читаем его содержимое, закрываем файл
    f = open('./docs/pagination/{}'.format(fl), encoding='utf8')
    jsonText = f.read()
    f.close()
    
    # Преобразуем полученный текст в объект справочника
    jsonObj = json.loads(jsonText)
    
    # Получаем и проходимся по непосредственно списку вакансий
    for v in jsonObj['items']:
        
        # Обращаемся к API и получаем детальную информацию по конкретной вакансии
        req = requests.get(v['url'])
        data = req.content.decode()
        req.close()
        
        # Создаем файл в формате json с идентификатором вакансии в качестве названия
        # Записываем в него ответ запроса и закрываем файл
        fileName = './docs/vacancies/{}.json'.format(v['id'])
        f = open(fileName, mode='w', encoding='utf8')
        f.write(data)
        f.close()
        
        time.sleep(0.25)
        
print('Вакансии собраны')

На этом этапе работа с API HH.ru завершается.

Сохраняем вакансии в базу данных

Теперь сохраним некоторую информацию по вакансиям в БД. Этот шаг можно не выполнять и реализовать работу с данными сразу из файлов, но лично мне нравиться работать с SQL и «покрутить» данные именно в SQL. Тем более в дальнейшем их будет проще получать.

У меня локально установлена СУБД PostgreSQL, поэтому для подключения к БД из Python должен быть установлен драйвер psycopg2.

Создадим 2 таблицы следующей структуры:

Структура таблиц в БД для хранения вакансий

В таблице vacancies будут храниться идентификатор вакансии, ее наименование и описание. Первичный ключ (PK) – id.
В таблице skills идентификатор вакансии и наименование навыка, требуемого для этой вакансии. PK – все столбцы.

Следующий скрипт заполнит эти таблицы:

# Библиотека для анализа данных, представляющая данные в табличном виде называемом DataFrame
# Вся мощь данной библиотеки нам здесь не понадобиться, с ее помощью мы положим
# данные в БД. Можно было бы написать простые insert-ы
import pandas as pd

import json
import os

# Библиотека для работы с СУБД
from sqlalchemy import engine as sql

# Модуль для работы с отображением вывода Jupyter
from IPython import display

# Создаем списки для столбцов таблицы vacancies
IDs = [] # Список идентификаторов вакансий
names = [] # Список наименований вакансий
descriptions = [] # Список описаний вакансий

# Создаем списки для столбцов таблицы skills
skills_vac = [] # Список идентификаторов вакансий
skills_name = [] # Список названий навыков

# В выводе будем отображать прогресс
# Для этого узнаем общее количество файлов, которые надо обработать
# Счетчик обработанных файлов установим в ноль
cnt_docs = len(os.listdir('./docs/vacancies'))
i = 0

# Проходимся по всем файлам в папке vacancies
for fl in os.listdir('./docs/vacancies'):
    
    # Открываем, читаем и закрываем файл
    f = open('./docs/vacancies/{}'.format(fl), encoding='utf8')
    jsonText = f.read()
    f.close()
    
    # Текст файла переводим в справочник
    jsonObj = json.loads(jsonText)
    
    # Заполняем списки для таблиц
    IDs.append(jsonObj['id'])
    names.append(jsonObj['name'])
    descriptions.append(jsonObj['description'])
    
    # Т.к. навыки хранятся в виде массива, то проходимся по нему циклом
    for skl in jsonObj['key_skills']:
        skills_vac.append(jsonObj['id'])
        skills_name.append(skl['name'])
    
    # Увеличиваем счетчик обработанных файлов на 1, очищаем вывод ячейки и выводим прогресс
    i += 1
    display.clear_output(wait=True)
    display.display('Готово {} из {}'.format(i, cnt_docs))


# Создадим соединение с БД
eng = sql.create_engine('postgresql://{Пользователь}:{Пароль}@{Сервер}:{Port}/{База данных}')
conn = eng.connect()

# Создаем пандосовский датафрейм, который затем сохраняем в БД в таблицу vacancies
df = pd.DataFrame({'id': IDs, 'name': names, 'description': descriptions})
df.to_sql('vacancies', conn, schema='public', if_exists='append', index=False)

# Тоже самое, но для таблицы skills
df = pd.DataFrame({'vacancy': skills_vac, 'skill': skills_name})
df.to_sql('skills', conn, schema='public', if_exists='append', index=False)

# Закрываем соединение с БД
conn.close()

# Выводим сообщение об окончании программы
display.clear_output(wait=True)
display.display('Вакансии загружены в БД')

Строим отчет

Теперь наконец-то можно перейти к построению непосредственно отчета. Что мы сделаем? Возьмем все наименования вакансий, создадим из них биграммы и построим из них облако. При этом облако будет интерактивным, клик по токену будет отображать таблицу со списком всех вакансий и график самых востребованных навыков по этим вакансиям.

Сначала получим биграммы из названий вакансий, еще их называют токенами. На выходе получим два пандосовских датафрейма, по которым будем строить облако токенов и перечень вакансий для конкретного токена:

import sqlalchemy
conn = sqlalchemy.create_engine('postgresql://{Пользователь}:{Пароль}@{Сервер}:{Port}/{База данных}').connect()

import pandas as pd

# Загружаем наименования вакансий
sql = 'select name from public.vacancies'    
vacancies_name = pd.read_sql(sql, conn).name

# Загружаем навыки по вакансиям
sql = """
    select
        v.name,
        skill
    from
        public.skills s
        join public.vacancies v
            on v.id = s.vacancy
"""

skills = pd.read_sql(sql, conn)

# Закрываем соединение с БД
conn.close()

# sklearn - библиотека, содержащая набор инструментов для машинного обучения.
# feature_extraction.text извлекает признаки из текста, которые затем можно будет 
# использовать в моделировании. В нашем случае моделирование не требуется. Нам нужно 
# получить биграммы и их оценку
from sklearn.feature_extraction.text import TfidfTransformer, CountVectorizer

# Получим матрицу встрчаемости биграмм (токенов) для каждой вакансии
# В качестве стоп-слов установим грейды сотрудников, т.е. нам 
# не важно Старший аналитик данных или Младший, нам важно, что он аналитик данных
# Также исключаем биграммы, которые встречаются реже, чем в 1-ой тысячной всех вакансий
cv=CountVectorizer(
    ngram_range=(2,2), 
    stop_words=['ведущий', 'главный', 'младший', 'эксперт', 'стажер', 'старший', 
                'middle', 'junior', 'senior'],
    min_df=0.001
)
word_vector=cv.fit_transform(vacancies_name)

# Рассчитаем меру idf (обратная частота документа)
# Чем выше мера для конкретного токена, тем реже он встречается
idf = TfidfTransformer().fit(word_vector)

# Строим датафрейм с оценкой idf для каждого токена. Далее он понадобится для построения облака 
df_idf = pd.DataFrame(idf.idf_, index=cv.get_feature_names(), columns=["idf"])

# Строим датафрейм из матрицы частоты токенов, где в качестве строк вакансии,
# токены в качестве столбцов. Далее используем его для получения списка вакансий, для
# которых встречается конкретный токен
pivot = pd.DataFrame(
            word_vector.toarray(), 
            columns=cv.get_feature_names(), 
            index=vacancies_name.values
        )

Теперь непосредственно строим отчет:

# Импортируем виджеты. Они позволяют более гибко управлять выводом jupyter и 
# создавать интерактивные элементы
import ipywidgets as widget

# Импортируем модуль вывода jupyter
from IPython import display

# Библиотека для визуализации данных
import matplotlib.pyplot as plt

# Модуль математических функций
import math

################################################

def click_back(b):
    """
       Метод, который вызывается при клике на кнопку "Назад".
       Метод скрывает информацию о перечне вакансий из левого сайдбара и
       диаграмму популярности навыков из правого сайдбара, а также отображает
       в центральной части шаблона облако токенов.
    """
    app_layout.left_sidebar = None
    app_layout.right_sidebar = None
    app_layout.center = out_words

################################################

def print_words(x):
    """
        Метод, который вызывается при изменении значения слайдера.
        Метод отрисовывает в формате html облако токенов
        
        Аргумент "x" принимает справочник, который содержит состояние слайдера. Используем
        x.new, чтобы получить новое состояние слайдера.
    """
    
    # Получаем датафрейм, отсортированный в обратном порядке по мере idf, 
    # ограниченный диапазоном значений, заданным слайдером
    ds = df_idf.sort_values(by='idf', ascending=False)[x.new[0]: x.new[1]]
    
    # Получаем максимальное и минимальное значения idf. Будем использовать их для установки
    # размера шрифта токена в облаке
    mx = df_idf['idf'].max()
    mn = df_idf['idf'].min()
    
    # Задаем общие css-стили для облака токенов
    tags = """<style>
    .tagword{
        border:1px solid #bee5eb;
        padding:1px 5px;
        display:block;
        border-radius:4px;
        color:#0c5460;
        background:#d1ecf1;
        line-height:normal;
        cursor:pointer}
    .tagword:hover{
        background:#c1dce1}
    .tag-wrapper{
        float:left;
        margin:0 5px 5px 0}
    </style>"""
    
    # Пробегаемся по токенам, выбранным с помощью слайдера
    for r in ds.sort_index().itertuples():
        
        # Масштабируем значения idf от 0 до 1 и переворачиваем их, 
        # чтобы максимальное значение стало минимальным.
        if mx > mn:
            
            # Задаем размер шрифта и высоту токена в облаке
            fs = int( (((r.idf - mn) / (mx - mn)) * -1 + 1 ) * 30 + 10 )
            hd = math.ceil(fs / 10) * 10 + 8
        else:
            fs = 40
        
        # Добавляем токен в облако в формате html. Токену назначаем функцию tag_click для
        # события клика. Сама функция описана ниже за пределами данного метода
        tag_tmpl = """<div class="tag-wrapper" style="height:{height}px">
            <span onclick="tag_click(this)" class="tagword" style="font-size:{size}px">{name}</span>
        </div>"""
        tags += tag_tmpl.format(name=r.Index, size=fs, height=hd)
    
    # Отрисовываем облако токенов 
    click_back(None)
    out_words.clear_output(wait=True)
    with out_words:
        display.display_html(tags, raw=True)
        
################################################
        
def get_detail(w):
    """
        Метод отрисовывает детелизацию по токену, а именно перечень вакансий, 
        содержащих переданный токен, и топ-20 самых востребованных навыков по этим вакансиям.
        
        Аргумент "w" принимает значение токена, по которому кликнули
    """
    
    # Выводим пандосовский датафрейм со списком вакансий, содержащих токен
    out_details_vac.clear_output(wait=True)
    vacs = pivot[pivot[w] > 0].sort_index().index.unique()
    with out_details_vac:
        display.display_html(
                pd.DataFrame(data=vacs, columns=['Список вакансий:']).to_html(index=False), raw=True
        )
    
    # Выводим горизонтальный столбчатый график самых популярных навыков по выбранным вакансиям
    out_details_skl.clear_output(wait=True)
    skill_pvt = skills \
                .query('name in @vacs') \
                .groupby(by='skill', as_index=False) \
                .agg({'name':'count'}) \
                .sort_values(by='name', ascending=False).head(20)
    fig_h = skill_pvt.shape[0] / 2.5    # Корректируем размер графика на 
                                        # основании количества получившихся столбцов
    with out_details_skl:
        skill_pvt.sort_values(by='name').plot.barh(
                        x='skill', 
                        y='name', 
                        figsize=(5, fig_h), 
                        legend=None, 
                        title='ТОП20 Навыков'
        )
        plt.show()
    
    
    # Скрываем облако токенов и выводим детализацию для токена
    app_layout.center = None
    app_layout.left_sidebar = out_details_vac
    app_layout.right_sidebar = out_details_skl

################################################

# Создаем несколько виджетов вывода
out_header = widget.Output() # Сюда будем выводить виджет слайдера и кнопку возврата
out_words = widget.Output() # Сюда будем выводить облако
out_details_vac = widget.Output() # Сюда будем выводить перечень вакансий, содержащих токен
out_details_skl = widget.Output() # Сюда будем выводить график навыков по вакансиям, содержащим токен

# Создаем виджет макета. Весь отчет по сути располагается в данном макете.
# Пока только с выводом в центр виджета с облаком
app_layout = widget.AppLayout(
    header=None,
    left_sidebar=None,
    center=out_words,
    right_sidebar=None,
    footer=None,
    pane_heights=[1, 5, '60px']
)

# Создаем виджет слайдера, для указания диапазона рассматриваемых токенов
sld = widget.IntRangeSlider(
    value=[0, 0],
    min=0,
    max=df_idf['idf'].count(),
    step=1,
    description='Частота:',
    layout = {'width': '100%'}
)
# Назначаем слайдеру метод, который будет вызываться при изменении значения слайдера
sld.observe(print_words, names='value')

# Создаем виджет кнопки
bck_btn = widget.Button(
    description='Назад',
    button_style='info'
)
# Назначаем кнопке метод, который будет вызываться по событию клика по ней
bck_btn.on_click(click_back)

# В формате html подготовим строку, которая создаст функцию на языке javascript
# Данную функцию будем вызывать по событию клика по токену в облаке
html = """
<script>
    function tag_click(e){
        var kernel = Jupyter.notebook.kernel
        func = 'get_detail("' + e.innerText + '")'
        kernel.execute(func)
    }
</script>"""

# Выводим в виджет вывода виджеты слайдера и кнопки
with out_header:
    display.display(sld)
    display.display(bck_btn)

display.display_html(html, raw=True) # Отображаем в ячейке ранее подготовленный html c js-функцией
display.display(out_header) # Отображаем виджет, содержащий слайдер и кнопку
display.display(app_layout) # Отображаем виджет макета

# Задаем новые значения диапазона слайдера, чтобы отобразить ТОП-30 самых популярных токенов
# Данная строка спровоцирует изменения, что в свою очередь вызовет установленный метод print_words,
# который формирует облако
sld.value = [sld.max - 30, sld.max]

В результате у меня получилось облако, которые можно увидеть на картинке в начале статьи. По нему мы сразу видим токены, которые явно выделяются на фоне остальных.

Затем мы можем получить детализацию по токену, кликнув на него, чтобы узнать точные наименования вакансий, которые данный токен содержат, а также увидим самые востребованные по данным вакансиям навыки:

Детализация отчета со списком вакансий и графиком навыков

Заключение

Мне было интересно построить такой отчет в питоне, особенно интересно было узнать, что jupyter позволяет выводить содержимое в формате html с поддержкой стилей и js, а это значит, что можно строить даже самые причудливые визуализации для своих исследований. Но не стоит сразу сильно разгоняться, сначала попробуйте поискать готовые библиотеки.

Виджеты тоже оказались очень интересным инструментом, позволяющим строить макеты и поддерживать интерактивность.

Работу с токенами можно немного усложнить, например, привести все слова к нормальной форме с помощью библиотеки pymorphy2 и избавиться от предлогов, союзов и частиц. Можно перевести английские наименования вакансий на русский, чтобы рассматривать их как одинаковые. Для себя я посчитал это излишним, но Вы можете попробовать.

Если материалы office-menu.ru Вам помогли, то поддержите, пожалуйста, проект, чтобы я мог развивать его дальше.

Комментарии  

# Антон 01.05.2022 15:41
Здравствуйте! Подскажите, что нужно изменить в коде скрипта, добавляющего данные в БД, чтобы перед добавлением новой записи проверять есть ли уже в БД запись с таким же значением ключа? Спасибо!
Ответить | Ответить с цитатой | Цитировать
# Андрей 02.05.2022 13:00
Здравствуйте!
Несколько вариантов возможны.
1. Select на id, с последующей проверкой количества возвращенных строк.
2. Уникальный индекс в БД, который не позволит добавить дубль и вернет ошибку. Ошибку можно уже обработать. Можно также использовать оператор вставки UPSERT.
Ответить | Ответить с цитатой | Цитировать
# Николай 24.02.2022 12:59
Здравствуйте, спасибо за материал, а если я например хочу искать не только Аналитиков, но например еще и DevOps-инженеро в, если да, то как это должно выглядеть в GET - запросе?
Ответить | Ответить с цитатой | Цитировать
# Андрей 24.02.2022 14:58
Николай, здравствуйте!
У hh есть описание языка поисковых запросов. Рекомендую Вам обратиться к нему.
Ответить | Ответить с цитатой | Цитировать
# Андрей 05.12.2021 12:45
Спасибо. Интересно.
С чем может быть связана ошибка
max=df_idf['idf'].count(),
NameError: name 'df_idf' is not defined
Соответствующая библиотека подключена.
Ответить | Ответить с цитатой | Цитировать
# Андрей 05.12.2021 14:39
Андрей,
Вы пропустили определение датафрейма df_idf.
Ответить | Ответить с цитатой | Цитировать
# Артем 22.10.2021 18:11
Подскажите еще такой вопрос.
а последний блок кода с непосредственно й отрисовкой данных, он записывается в отдельный файл? или он идет после того как собрали биграммы?
и все это одним питоновским файлом? или все таки в разных?
Заранее спасибо за ответ
Ответить | Ответить с цитатой | Цитировать
# Андрей 22.10.2021 19:24
Файл не питоновский, если вы подразумеваете расширение .py. Файл для Jupyter. Вам главное добиться того, чтобы один "блок" видел переменные другого, иначе конкретные примеры работать не будут. Самый простой способ сделать в одном файле.
Ответить | Ответить с цитатой | Цитировать
# Артем 20.10.2021 06:46
Добрый день.
Подскажите пожалуйста, столкнулся с ошибкой при попытке заполнить таблицы в Базе Данных
выдает ошибку:
ValueError: invalid literal for int() with base 10: '{5432}'
строка обращения к БД:

eng = sql.create_engine('postgresql://{postgres}:{**********}@{PostgreSQL14}:{5432}/{Vacancies_HH}')

не могу понять где ошибся... Заранее благодарю за помощь
Ответить | Ответить с цитатой | Цитировать
# Андрей 20.10.2021 11:26
Здравствуйте!
Ошибка вам сообщает, что строка подключения неверно составлена. Уберите фигурные скобки.
Ответить | Ответить с цитатой | Цитировать
# Алексей 16.10.2021 00:02
Подскажи пожалуйста? из-за чего может быть ошибка "Traceback (most recent call last):
File "nn4.py", line 18, in
for v in jsonObj['items']:
KeyError: 'items'" во втором блоке кода? те там где заполняем vacancies
Ответить | Ответить с цитатой | Цитировать
# Виктория 21.09.2021 11:08
А дайте, пож-та, контакт Парсера, который может нам это сделать!
Ответить | Ответить с цитатой | Цитировать
# Андрей 21.09.2021 12:26
Виктория, сделать что?
Ответить | Ответить с цитатой | Цитировать
# Алексей 16.09.2021 12:40
Если убрать поиск по ключевому слову и попытаться вывести все вакансии, то будет лимит в 2000 вакансий.Как фильтровать по разделу вакансий, чтобы вывести все вакансии раздела, (например ит) я так и не понял ,похоже не получится.
Отсюда вопрос как вывести информацию по id вакансии? не пойму как правильно передать параметр id методом get, так как в документации тупо id передаётся и всё.
Ответить | Ответить с цитатой | Цитировать
# Андрей 16.09.2021 13:29
Алексей,
Может я вопрос не так понял. В статье есть пример обращения к api, для получения вакансии - https://api.hh.ru/vacancies/{id вакансии}?host=hh.ru.
Т.е. id вакансии не get-параметром передается, а частью пути в url.
Ответить | Ответить с цитатой | Цитировать
# Алексей 16.09.2021 20:13
спасибо за быстрый ответ. попробую так получить список вакансий, рандомно перебирая номера по циклу хз через сколько мой айпишник улетит в бан. получилось так же вроде бы прикрутить фильтр specializations но этого так же мало, чтобы обойти ограничение не прибегая к парсингу по ид методом перебора (плюс там надо будет ещё сортировать всё уже в бд-геморно) думаю ещё вариант ограничить выдачу по дате публикации, они правда там вроде могут скакать у проплаченных вакансий
Ответить | Ответить с цитатой | Цитировать
# Андрей 17.09.2021 14:57
Ребята, подскажите пжт, можно ли парсить резюме с контактами из hh?
Я готов платить он не те конские деньги, как там сейчас.
Мне нужно несколько сотен резюме.
Ответить | Ответить с цитатой | Цитировать
# Андрей 18.09.2021 01:39
К резюме доступ у hh только за деньги. И то очень аккуратно надо работать. Любой автоматизирован ный запрещен.
Ответить | Ответить с цитатой | Цитировать
# Егор 20.08.2021 15:49
Большое спасибо за статью. Очень крутой матрериал. Подскажите пожалуйста, почему не работает функция html. Я нажимаю на тег, но он не проваливается на дашборд((
Ответить | Ответить с цитатой | Цитировать
# Андрей 20.08.2021 16:50
Егор,
Причин, почему не работает функция, может быть много. Но конкретно в Вашем случае и на Вашей машине поможет разобраться консоль в инструментах разработчика браузера (f12). Если ошибка в JavaScript, то она там будет видна.
Ответить | Ответить с цитатой | Цитировать
# Егор 24.08.2021 13:05
Большое спасибо! Проверил консоль, ошибок при вызове функции не возникает. Однако, к сожалению, все равно при нажатии на тег, отображения ключевых навыков не происходит.
Ответить | Ответить с цитатой | Цитировать
# Михаил 06.12.2020 12:20
Спасибо за статью. Я тоже учусь доить hh.ru с помощью Питона. Поэтому статья очень хорошо "зашла".
С радостью дарю вам сотку.
Ответить | Ответить с цитатой | Цитировать

Добавить комментарий


Ваша милость собирайтесь усладиться с весомой проституткой с определенного региона? Между тем обязательно находите себе без ужиманий на нашем веб-сайте для совершеннолетних prostitutkimoskvy2020.com рекомендуемую шлюху из Москвы 2020. | Представляем вам проверенных проституток Хабаровска - самый лучший выбор для удовлетворения всех ваших фантазий, переходите по ссылке и ознакомьтесь с нашим широким ассортиментом. | Увлекательный опыт встреч с элитными проститутками Саратова ждет Вас 24 на 7! | Лучшие проститутки готовы предложить вам незабываемые моменты на сайте https://prostitutkiyaltyxxx.net. | У нас есть отличная подборка элитных индивидуалок Томска. Посетите сайт и убедитесь сами! | Проверенные проститутки Твери - Качество и безопасность гарантированы. | Качественные проститутки для анала в Ульяновске готовы ублажить вас этой особой услугой на высшем уровне. | Хотите найти проститутку Абакана? Мы поможем вам сделать выбор. Посетите наш сайт! | Гарантировано проверенные проститутки Адлера - выбирайте по ссылке. | Не отказывайте себе в удовольствии! Закажите проститутку Анапы сейчас и окунитесь в мир наслаждения: https://prostitutkianapy.stream | Самые красивые проститутки Бийска вас ждут на сайте https://prostitutkibiyskasexy.info/! | Проверенные проститутки Иваново - качественный выбор для тех, кто ищет безопасность и незабываемый сексуальный опыт. Закажите удовольствие прямо сейчас! | Ищете анального удовольствия в Калининграда? У нас есть специальные проститутки, которые готовы исполнить ваши желания! Заказывайте анальную проститутку Калининграда уже сегодня! | Хотите найти дешевых проституток Калуги? Посетите наш сайт и получайте максимальное удовольствие по выгодной цене. | Проститутки для анального секса в Кемерово - воплощение ваших фантазий. Посетите наш сайт и приготовьтесь к незабываемым ощущениям. | Предлагаем подлинные наслаждения! Встречайте опытных проституток для анального секса в Костроме: проститутки для анала в Костроме. Откройте новые грани удовольствия! | Проститутки индивидуалки Краснодара - мы предлагаем только лучших девушек, способных удовлетворить все ваши желания. Посетите наш сайт, чтобы найти свою идеальную спутницу уже сегодня! | Индивидуалки Магнитогорска - настоящие профессионалки своего дела https://prostitutkimagnitogorskax.com. | Проститутки для анала в Орле – желаете испытать новые ощущения? Закажите услугу на сайте и насладитесь полным удовлетворением! | Только проверенные проститутки Петрозаводска, которые гарантированно доставят удовольствие! Найдите их на сайте.

© 2011 - 2022 Office-Menu.ru - Уроки и статьи по Excel и SQL