#!/usr/bin/env python3 """Python 3.10+, только стандартная библиотека. Запуск: python3 research.py. Учебный пример темы № 19: начинающие аналитики данных в двух городах. Адаптируйте параметры под собственную закреплённую тему. Токен не вставляйте в код: задайте HH_STUDY_TOKEN в среде запуска или введите его по запросу программы. JSON и CSV — рабочие выгрузки. Для сдачи сохраните анализ в Calc (.ods). """ import csv import getpass import json import math import os import random import time from datetime import datetime, timezone from email.utils import parsedate_to_datetime from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen # Все условия исследования задаются здесь. В двух городах меняется только area. BASE = os.getenv('HH_STUDY_BASE', 'https://milushkoff.ru/hh-api').rstrip('/') COMMON_PARAMS = {'text': 'аналитик данных', 'search_field': 'name', 'experience': 'noExperience', 'period': 30, 'order_by': 'publication_time'} COHORTS = [ {'name': 'Москва', 'params': {**COMMON_PARAMS, 'area': '1'}}, {'name': 'Санкт-Петербург', 'params': {**COMMON_PARAMS, 'area': '2'}} ] PER_PAGE = 100 MAX_PAGES = 20 # Максимум 2000 вакансий на один поиск; уменьшите для пробного запуска. def now(): return datetime.now(timezone.utc).isoformat() def retry_delay(value, attempt): try: delay = float(value) except (ValueError, TypeError): try: delay = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds() except (ValueError, TypeError, OverflowError): delay = min(30, 2 ** attempt) if not math.isfinite(delay): delay = min(30, 2 ** attempt) if delay > 120: raise RuntimeError(f'Общая пауза: повторите запуск через {delay:.0f} секунд.') return max(1, delay) + random.uniform(0.2, 1.2) class Client: def __init__(self, token, base=BASE): if not token.strip(): raise ValueError('Нужен личный ключ из кабинета учебного портала.') self.token = token.strip() self.base = base self.last_request = 0.0 def get(self, path, params=None): url = self.base + path + ('?' + urlencode(params, doseq=True) if params else '') for attempt in range(5): time.sleep(max(0, 1.1 - (time.monotonic() - self.last_request))) self.last_request = time.monotonic() try: request = Request(url, headers={'Authorization': 'Bearer ' + self.token}) with urlopen(request, timeout=25) as response: return json.load(response) except HTTPError as error: if error.code not in (429, 502, 504) or attempt == 4: # Не выводим заголовки, ключ и непроверенное тело ответа. advice = {401: 'Проверьте личный ключ.', 403: 'Сообщите преподавателю.', 503: 'Учебный сервис недоступен; обратитесь к преподавателю.'} raise RuntimeError(f'HTTP {error.code}. ' + advice.get(error.code, 'Проверьте запрос или повторите позже.')) from None time.sleep(retry_delay(error.headers.get('Retry-After', ''), attempt)) except (URLError, TimeoutError): if attempt == 4: raise RuntimeError('Не удалось соединиться с учебным API за пять попыток.') from None time.sleep(min(30, 2 ** attempt) + random.random()) def collect(get_json, cohorts=None, maximum=MAX_PAGES): """Сохраняем исходные страницы, параметры и уникальные вакансии каждой выборки.""" if not isinstance(maximum, int) or not 1 <= maximum <= 20: raise ValueError('MAX_PAGES должен быть от 1 до 20.') result = {'started_at': now(), 'source': BASE, 'per_page': PER_PAGE, 'max_pages': maximum, 'cohorts': []} for cohort in COHORTS if cohorts is None else cohorts: records, raw, observed_found = {}, [], [] duplicate_count = 0 for page in range(maximum): params = {**cohort['params'], 'page': page, 'per_page': PER_PAGE} response = get_json('/vacancies', params) raw.append({'received_at': now(), 'path': '/vacancies', 'params': params, 'response': response}) observed_found.append(response['found']) for vacancy in response['items']: key = str(vacancy['id']) if key in records: duplicate_count += 1 records[key] = vacancy if not response['items'] or page + 1 >= response['pages']: break found = observed_found[0] changed = len(set(observed_found)) > 1 incomplete = changed or len(records) < max(observed_found) result['cohorts'].append({'name': cohort['name'], 'params': dict(cohort['params']), 'found': found, 'loaded_unique': len(records), 'duplicate_count': duplicate_count, 'incomplete': incomplete, 'found_changed': changed, 'raw_pages': raw, 'vacancies': list(records.values())}) result['finished_at'] = now() return result def cell(value): """Текст из вакансий не должен становиться формулой при открытии CSV.""" if value is None: return '' if isinstance(value, str) and value.lstrip().startswith(('=', '+', '-', '@')): return "'" + value return value def save(result, directory): directory = Path(directory) directory.mkdir(parents=True, exist_ok=False) (directory / 'raw.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') columns = ['выборка', 'id', 'название', 'город', 'опыт', 'зарплата_от', 'зарплата_до', 'валюта', 'до_вычета_налогов', 'период_оплаты', 'частота_выплат', 'опубликовано', 'ссылка', 'требования', 'обязанности'] with (directory / 'vacancies.csv').open('w', encoding='utf-8-sig', newline='') as file: writer = csv.writer(file, delimiter=';') writer.writerow(columns) for cohort in result['cohorts']: for vacancy in cohort['vacancies']: salary = vacancy.get('salary_range') or vacancy.get('salary') or {} snippet = vacancy.get('snippet') or {} values = [cohort['name'], vacancy['id'], vacancy['name'], (vacancy.get('area') or {}).get('name'), (vacancy.get('experience') or {}).get('name'), salary.get('from'), salary.get('to'), salary.get('currency'), salary.get('gross'), (salary.get('mode') or {}).get('id'), (salary.get('frequency') or {}).get('id'), vacancy.get('published_at'), vacancy.get('alternate_url'), snippet.get('requirement'), snippet.get('responsibility')] writer.writerow([cell(value) for value in values]) with (directory / 'summary.csv').open('w', encoding='utf-8-sig', newline='') as file: writer = csv.writer(file, delimiter=';') writer.writerow(['выборка', 'найдено_found', 'загружено_уникальных', 'неполная_выгрузка', 'found_менялся', 'дубликаты', 'параметры', 'начало_UTC', 'конец_UTC']) for c in result['cohorts']: writer.writerow([cell(c['name']), c['found'], c['loaded_unique'], c['incomplete'], c['found_changed'], c['duplicate_count'], json.dumps(c['params'], ensure_ascii=False), result['started_at'], result['finished_at']]) return directory def main(): token = os.getenv('HH_STUDY_TOKEN') or getpass.getpass('Личный API-ключ (ввод скрыт): ') result = collect(Client(token).get) directory = save(result, Path('results') / datetime.now().strftime('%Y%m%d-%H%M%S-%f')) for cohort in result['cohorts']: print(f"{cohort['name']}: found={cohort['found']}, загружено {cohort['loaded_unique']}") if cohort['incomplete']: print('ВНИМАНИЕ: выгрузка неполная или выдача менялась. Сузьте условия и укажите ограничение в статье.') print(f'Сохранено в {directory.resolve()}: raw.json, vacancies.csv, summary.csv') print('Откройте CSV в Calc: UTF-8, разделитель ;. Сохраните исходные данные и анализ в .ods.') print('Не смешивайте валюты, периоды оплаты и суммы до/после налогов. Пустая зарплата не равна нулю.') if __name__ == '__main__': try: main() except (RuntimeError, ValueError, OSError, EOFError) as error: raise SystemExit(str(error)) from None