Пошаговое руководство по созданию Python-бота для сбора вакансий, фильтрации и рассылки. Автоматизируйте поиск работы и получайте только релевантные предложения.
Вы тратите часы на просмотр досок вакансий, но большинство предложений не подходят? Я столкнулся с этим, когда искал работу в Амстердаме. После 73 заявок и 61 игнора я понял: нужно автоматизировать процесс. В этой статье я расскажу, как написал Python-скрипт, который собирает вакансии, фильтрует их по моим критериям и присылает топ-3 в Telegram каждое утро. Вы сможете адаптировать код под себя и сэкономить десятки часов.
Когда я решил переехать в Амстердам и искать позиции Platform Engineer, я столкнулся с тремя проблемами:
Вот как устроен пайплайн:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ Парсеры │───►│ Обогащение │───►│ Ранжирование│───►│ Уведомление│
│ (async) │ │ (LLM + правила)│ │ (оценка) │ │ (Telegram) │
└─────────────┘ └──────────────┘ └──────────────┘ └─────────────┘
│ │
▼ ▼
LinkedIn Jobs SQLite кэш
Indeed (дедупликация)
Glassdoor
We Work Remotelyimport httpx
from bs4 import BeautifulSoup
from dataclasses import dataclass
@dataclass
class RawJob:
title: str
company: str
location: str
description: str
salary: str | None
url: str
posted_at: str
async def scrape_linkedin(query: str, location: str) -> list[RawJob]:
"""Парсит LinkedIn Jobs через JSON endpoint."""
url = f"https://www.linkedin.com/jobs-guest/jobs/api/jobs?keywords={query}&location={location}"
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=headers, timeout=30)
soup = BeautifulSoup(resp.text, "html.parser")
jobs = []
for li in soup.select(".job-result-card"):
title = li.select_one(".job-result-card__title").text.strip()
company = li.select_one(".job-result-card__company-name").text.strip()
location = li.select_one(".job-result-card__location").text.strip()
url = li.select_one("a")["href"]
jobs.append(RawJob(title, company, location, "", None, url, ""))
return jobs@dataclass
class Job:
title: str
company: str
location: str
seniority: str # Junior, Mid, Senior, Staff
remote: str # onsite, hybrid, remote
salary_min: int | None
salary_max: int | None
description: str
url: str
posted_at: str
def normalize(raw: RawJob) -> Job:
"""Приводит данные к единой схеме."""
# Определяем уровень
title_lower = raw.title.lower()
if "junior" in title_lower:
seniority = "Junior"
elif "senior" in title_lower or "staff" in title_lower:
seniority = "Senior"
elif "principal" in title_lower:
seniority = "Principal"
else:
seniority = "Mid"
# Парсим зарплату из описания (упрощённо)
salary = parse_salary(raw.description)
return Job(
title=raw.title,
company=raw.company,
location=raw.location.split(",")[0], # "Amsterdam, North Holland" → "Amsterdam"
seniority=seniority,
remote="hybrid", # заглушка
salary_min=salary[0] if salary else None,
salary_max=salary[1] if salary else None,
description=raw.description,
url=raw.url,
posted_at=raw.posted_at
)TARGET_CITIES = {"Amsterdam", "Berlin", "Dublin"}
TECH_KEYWORDS = ["go", "kubernetes", "k8s", "cilium", "ebpf", "terraform", "distributed systems"]
BLOCKLIST = {"Randstad", "Hays", "Michael Page"}
def passes_filter(job: Job) -> bool:
"""Жёсткие правила: локация, уровень, технологии, чёрный список."""
return (
job.location in TARGET_CITIES
and job.seniority in {"Senior", "Staff", "Principal"}
and any(kw in job.description.lower() for kw in TECH_KEYWORDS)
and job.company not in BLOCKLIST
)import openai
@dataclass
class EnrichedJob(Job):
llm_score: float
verdict: str # yes, no, maybe
def enrich_with_llm(job: Job) -> EnrichedJob:
"""LLM оценивает соответствие вакансии профилю."""
prompt = f"""
Роль: {job.title} в {job.company}
Описание: {job.description[:2000]}
Профиль кандидата: 6 лет в облаке/platform, K8s (Cilium, Talos),
изучает Go, Azure+AWS, комплаенс (NIS2/DORA).
Цель: Platform Engineer или Backend Engineer в Амстердаме/Берлине/Дублине.
Задачи:
1. Это действительно backend/platform работа (не поддержка, не MSP)?
2. В стеке есть K8s, Go, распределённые системы или cloud-native?
3. Вероятен ли спонсор визы? (большие компании — да, маленькие — нет)
4. Оценка соответствия от 0 до 100.
5. Стоит ли откликаться? yes/no/maybe.
Ответ в формате JSON: {{"score": число, "verdict": "yes/no/maybe", "is_backend": bool, "tech_match": bool, "visa_likely": bool}}
"""
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
result = json.loads(response.choices[0].message.content)
return EnrichedJob(
**job.__dict__,
llm_score=result["score"],
verdict=result["verdict"]
)def score(job: EnrichedJob) -> float:
"""Композитная оценка: LLM (50%), зарплата (20%), компания (20%), свежесть (10%)."""
return (
job.llm_score * 0.5
+ salary_score(job.salary_max) * 0.2
+ company_tier_score(job.company) * 0.2
+ recency_score(job.posted_at) * 0.1
)
def salary_score(max_salary: int | None) -> float:
if max_salary is None:
return 0.5
if max_salary >= 100000:
return 1.0
elif max_salary >= 80000:
return 0.8
else:
return 0.5
def company_tier_score(company: str) -> float:
TIER1 = {"Adyen", "Booking.com", "ASML", "Uber", "Spotify"}
return 1.0 if company in TIER1 else 0.5
def recency_score(posted_at: str) -> float:
# Упрощённо: чем свежее, тем выше
return 1.0 # заглушкаimport asyncio
from telegram import Bot
TELEGRAM_TOKEN = "ваш_токен"
CHAT_ID = "ваш_chat_id"
async def send_digest(jobs: list[EnrichedJob]):
"""Отправляет топ-3 вакансий в Telegram."""
bot = Bot(token=TELEGRAM_TOKEN)
top3 = sorted(jobs, key=score, reverse=True)[:3]
msg = "🔍 Ежедневный дайджест вакансий\n"
for i, job in enumerate(top3, 1):
msg += f"\n{i}. {job.title} — {job.company}\n Локация: {job.location}\n Оценка: {job.llm_score:.0f}/100\n {job.url}"
await bot.send_message(chat_id=CHAT_ID, text=msg)
# Запуск
asyncio.run(send_digest(enriched_jobs))Я использовал один сеанс httpx с задержкой 2 секунды. LinkedIn вернул 429 после ~30 запросов. Решение: парсить JSON endpoint /jobs-guest/jobs/api/jobs вместо HTML. Он стабильнее. Также я убрал прокси и просто соблюдал паузу 5 секунд.
GPT-4o-mini выдумал роль «Kubernetes Platform Engineer» с зарплатой €500K. URL не существовал. Решение: перед обогащением проверять URL через HEAD-запрос. Если статус не 200 — отбрасывать.
Ключ (title, company) не работал, когда компания перепубликовала вакансию с другим названием. Решение: хэшировать описание после удаления пробелов и дат. Одинаковое описание = одна вакансия.
Я запускал python main.py вручную и забывал. Решение: запустить на Raspberry Pi через cron. Pi работает 24/7, SQLite на внешнем SSD.
Я не откликался ни на одну, потому что не мог выбрать. Решение: показывать только топ-3. Остальные по команде /more. Это увеличило количество откликов.
Главное — я перестал тратить вечера на поиск. Система работает без меня.
git clone https://github.com/beltagyy/job-digest.gitpip install -r requirements.txtconfig.yaml: добавьте OpenAI API ключ, Telegram токен, города и ключевые слова.python main.py --run-nowКод открыт, и я жду ваших пул-реквестов. Добавьте парсер для AngelList или Hacker News — и помогите сообществу. Удачи в поиске!
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →