feat: add subscribtion scheduler with ai, pagination

This commit is contained in:
Faynot
2026-03-29 11:25:31 +03:00
parent 1db524f757
commit 164acd6307
16 changed files with 688 additions and 358 deletions

112
ai.py Normal file
View File

@@ -0,0 +1,112 @@
import aiohttp
import json
import re
from config import OPENROUTER
SYSTEM_PROMPT_TEMPLATE = """
Ты — экспертный технический рекрутер. Твоя задача: отфильтровать IT-вакансии.
Предпочтения пользователя:
{preferences}
Верни ТОЛЬКО валидный JSON массив объектов, которые подходят под стек и грейд.
Если ничего не подходит, верни [].
Никаких пояснений и markdown-разметки.
"""
def get_forced_vacancies(vacancies: list, user_prefs: str) -> list:
stack_match = re.search(r'(?i)стек:(.*?)(?=доп:|$)', user_prefs, re.DOTALL)
if not stack_match:
return []
stack_content = stack_match.group(1).lower()
all_keywords = re.findall(r'[a-zA-Z0-9.+#]{2,}', stack_content)
common_words = {'html', 'css', 'git', 'sql', 'api', 'rest', 'remote', 'work'}
strong_keys = list(set(word for word in all_keywords if word not in common_words))
if not strong_keys:
return []
print(f"🔍 Ключи для авто-добавления: {strong_keys}")
forced = []
for vac in vacancies:
v_id = str(vac.get('id', ''))
vac_str = json.dumps(vac, ensure_ascii=False).lower()
vac_clean = re.sub(r'https?://\S+', '', vac_str)
if any(key in vac_clean for key in strong_keys):
forced.append(vac)
return forced
async def filter_vacancies_with_ai(vacancies: list, user_prefs: str) -> list:
if not vacancies:
return []
forced_vacancies = get_forced_vacancies(vacancies, user_prefs)
forced_ids = {str(v.get('id')) for v in forced_vacancies if v.get('id')}
remaining_vacancies = [v for v in vacancies if str(v.get('id')) not in forced_ids]
if not remaining_vacancies:
print(f"Все {len(forced_vacancies)} вакансий одобрены автоматом.")
return forced_vacancies
print(f"✅ Авто-одобрено: {len(forced_vacancies)}")
print(f"🤖 Отправка на AI: {len(remaining_vacancies)} (было {len(vacancies)})")
api_url = "https://openrouter.ai/api/v1/chat/completions"
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(preferences=user_prefs)
async with aiohttp.ClientSession() as session:
try:
clean_token = str(OPENROUTER).strip()
async with session.post(
url=api_url,
headers={
"Authorization": f"Bearer {clean_token}",
"Content-Type": "application/json",
},
json={
"model": "openrouter/free",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(remaining_vacancies, ensure_ascii=False)}
]
},
timeout=60
) as response:
if response.status != 200:
raw_err = await response.text()
print(f"❌ Ошибка API ({response.status}): {raw_err[:100]}")
return forced_vacancies
result_raw = await response.json()
if 'choices' not in result_raw:
return forced_vacancies
content = result_raw['choices'][0]['message']['content'].strip()
match = re.search(r'\[\s*\{.*\}\s*\]', content, re.DOTALL)
clean_json = match.group(0) if match else content.replace('```json', '').replace('```', '').strip()
try:
ai_vacs = json.loads(clean_json)
final = forced_vacancies.copy()
for v in ai_vacs:
if str(v.get('id')) not in forced_ids:
final.append(v)
print(f"🏁 Итог: {len(final)} релевантных вакансий")
return final
except:
return forced_vacancies
except Exception as e:
print(f"❌ Ошибка в блоке запроса: {e}")
return forced_vacancies

159
bot.py
View File

@@ -1,156 +1,29 @@
import asyncio
import logging
import sys
from os import getenv
from aiogram import Bot, Dispatcher, html, F
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command
from aiogram.types import Message, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from dotenv import load_dotenv
from scheduler import VacancyScanner
from config import TOKEN
from database import Database
load_dotenv()
TOKEN = getenv("BOT_TOKEN")
dp = Dispatcher()
db = Database("users.db")
# Добавили состояние для ручного ввода сферы
class Registration(StatesGroup):
waiting_for_sphere = State()
waiting_for_custom_sphere = State() # <-- Новое состояние
waiting_for_language = State()
waiting_for_preferences = State()
@dp.message(CommandStart())
async def command_start_handler(message: Message) -> None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Подписаться", callback_data="subscribe"))
builder.row(InlineKeyboardButton(text="📄 Читать оферту", url="https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28"))
text = (
"<b>👋 Привет! Я твой персональный агент по Kwork.</b>\n\n"
"💻 ⚠️ <b>ВАЖНО:</b> Этот бот предназначен <b>исключительно для IT-специалистов</b>.\n\n"
"🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы.\n\n"
"Нажимая кнопку «Подписаться», вы принимаете условия "
"<a href='https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28'>публичной оферты</a>."
)
await message.answer(text, reply_markup=builder.as_markup(), disable_web_page_preview=True)
@dp.message(Command("profile"))
async def show_profile(message: Message):
user_data = await db.get_user(message.from_user.id)
if not user_data or user_data[0] is None:
await message.answer("⚠️ Твой профиль еще не настроен. Нажми /start, чтобы начать.")
return
sphere, lang, prefs = user_data
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="📝 Редактировать профиль", callback_data="subscribe")) # Используем тот же callback
text = (
"<b>👤 Твой профиль IT-специалиста:</b>\n\n"
f"<b>🌐 Сфера:</b> {sphere}\n"
f"<b>🛠 Стек:</b> {lang}\n"
f"<b>⚙️ Предпочтения:</b> {prefs}\n\n"
"Хочешь что-то изменить? Нажми кнопку ниже."
)
await message.answer(text, reply_markup=builder.as_markup())
@dp.callback_query(F.data == "subscribe")
async def subscribe_handler(callback: CallbackQuery, state: FSMContext):
await db.add_user(callback.from_user.id)
builder = InlineKeyboardBuilder()
spheres = ["Backend", "Frontend", "Mobile", "DevOps", "Design", "QA"]
for sphere in spheres:
builder.add(InlineKeyboardButton(text=sphere, callback_data=f"sphere_{sphere}"))
# Добавляем кнопку своего варианта
builder.row(InlineKeyboardButton(text="⌨️ Свой вариант", callback_data="sphere_other"))
builder.adjust(2)
await callback.message.edit_text(
"Отлично! Давай настроим профиль.\n<b>В какой сфере IT ты работаешь?</b>",
reply_markup=builder.as_markup()
)
await state.set_state(Registration.waiting_for_sphere)
await callback.answer()
@dp.callback_query(Registration.waiting_for_sphere)
async def sphere_chosen(callback: CallbackQuery, state: FSMContext):
sphere = callback.data.split("_")[1]
if sphere == "other":
await callback.message.edit_text("Напиши свою сферу деятельности (например: <i>Data Science</i> или <i>GameDev</i>):")
await state.set_state(Registration.waiting_for_custom_sphere)
else:
await state.update_data(sphere=sphere)
await callback.message.edit_text(
f"Выбрано: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
)
await state.set_state(Registration.waiting_for_language)
await callback.answer()
# Обработчик для текстового ввода своей сферы
@dp.message(Registration.waiting_for_custom_sphere)
async def custom_sphere_input(message: Message, state: FSMContext):
sphere = message.text
await state.update_data(sphere=sphere)
await message.answer(
f"Принято: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
)
await state.set_state(Registration.waiting_for_language)
@dp.message(Registration.waiting_for_language)
async def language_chosen(message: Message, state: FSMContext):
await state.update_data(language=message.text)
# Создаем кнопку для пропуска
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏩ Пропустить", callback_data="skip_preferences"))
await message.answer(
"Принято! И последнее: напиши свои <b>предпочтения по заказам</b> (фильтры).\n"
"Например: 'чек от 5000р' или 'без правок'.\n\n"
"<i>Если не хочешь заполнять сейчас, нажми кнопку ниже.</i>",
reply_markup=builder.as_markup()
)
await state.set_state(Registration.waiting_for_preferences)
@dp.callback_query(Registration.waiting_for_preferences, F.data == "skip_preferences")
async def skip_preferences(callback: CallbackQuery, state: FSMContext):
await state.update_data(preferences="Не указано") # Устанавливаем значение по умолчанию
user_data = await state.get_data()
await db.update_user_data(callback.from_user.id, user_data)
await callback.message.edit_text(
"✅ <b>Профиль успешно настроен!</b> (Фильтры пропущены)\n\n"
f"Сфера: {user_data['sphere']}\n"
f"Стек: {user_data['language']}\n"
f"Фильтры: {user_data['preferences']}"
)
await state.clear()
await callback.answer()
async def on_startup():
await db.create_tables()
print("Database ready")
from handlers import routers
async def main() -> None:
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp.startup.register(on_startup)
dp = Dispatcher()
# Регистрация всех роутеров
dp.include_routers(*routers)
# Инициализация БД
db = Database("users.db")
await db.create_tables()
scanner = VacancyScanner(bot, db)
asyncio.create_task(scanner.start_scanning())
await dp.start_polling(bot)
if __name__ == "__main__":

7
config.py Normal file
View File

@@ -0,0 +1,7 @@
from os import getenv
from dotenv import load_dotenv
load_dotenv()
TOKEN = getenv("BOT_TOKEN")
OPENROUTER = getenv("OPENROUTER_TOKEN")

View File

@@ -15,6 +15,29 @@ class Database:
preferences TEXT
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS sent_vacancies (
user_id INTEGER,
vacancy_url TEXT,
PRIMARY KEY (user_id, vacancy_url)
)
""")
await db.commit()
async def is_vacancy_sent(self, user_id: int, url: str) -> bool:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT 1 FROM sent_vacancies WHERE user_id = ? AND vacancy_url = ?",
(user_id, url)
) as cursor:
return await cursor.fetchone() is not None
async def mark_vacancy_as_sent(self, user_id: int, url: str):
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT OR IGNORE INTO sent_vacancies (user_id, vacancy_url) VALUES (?, ?)",
(user_id, url)
)
await db.commit()
async def add_user(self, user_id: int):
@@ -44,3 +67,18 @@ class Database:
(user_id,)
) as cursor:
return await cursor.fetchone()
async def count_users(self) -> int:
#Возвращает количество зарегистрированных пользователей
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT COUNT(*) FROM users") as cursor:
result = await cursor.fetchone()
return result[0] if result else 0
async def clear_sent_vacancies(self, user_id: int):
#Очищает историю отправленных вакансий для пользователя
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"DELETE FROM sent_vacancies WHERE user_id = ?",
(user_id,)
)
await db.commit()

5
handlers/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .common import router as common_router
from .registration import router as reg_router
from .search import router as search_router
routers = [reg_router, common_router, search_router]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
handlers/common.py Normal file
View File

@@ -0,0 +1,42 @@
from aiogram import Router
from aiogram.filters import CommandStart, Command
from aiogram.types import Message
from keyboards import get_start_kb, get_profile_edit_kb
from database import Database
router = Router()
db = Database("users.db")
@router.message(CommandStart())
async def command_start_handler(message: Message):
text = (
"<b>👋 Привет! Я твой персональный агент по Kwork.</b>\n\n"
"💻 ⚠️ <b>ВАЖНО:</b> Этот бот предназначен <b>исключительно для IT-специалистов</b>.\n\n"
"🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы."
)
await message.answer(text, reply_markup=get_start_kb(), disable_web_page_preview=True)
@router.message(Command("profile"))
async def show_profile(message: Message):
user_data = await db.get_user(message.from_user.id)
if not user_data or user_data[0] is None:
await message.answer("⚠️ Твой профиль еще не настроен. Нажми /start, чтобы начать.")
return
sphere, lang, prefs = user_data
text = (
"<b>👤 Твой профиль:</b>\n\n"
f"<b>🌐 Сфера:</b> {sphere}\n"
f"<b>🛠 Стек:</b> {lang}\n"
f"<b>⚙️ Предпочтения:</b> {prefs}\n\n"
"Хочешь что-то изменить? Нажми кнопку ниже."
)
await message.answer(text, reply_markup=get_profile_edit_kb())
@router.message(Command("clear"))
async def command_clear_handler(message: Message):
await db.clear_sent_vacancies(message.from_user.id)
await message.answer("🧹 <b>История отправленных вакансий очищена!</b>\n Теперь я снова смогу прислать тебе те заказы, которые ты уже видел.")

80
handlers/registration.py Normal file
View File

@@ -0,0 +1,80 @@
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext
from states import Registration
from keyboards import get_spheres_kb, get_skip_kb
from database import Database
router = Router()
db = Database("users.db")
@router.callback_query(F.data == "subscribe")
async def subscribe_handler(callback: CallbackQuery, state: FSMContext):
await db.add_user(callback.from_user.id)
await callback.message.edit_text(
"Отлично! Давай настроим профиль.\n<b>В какой сфере IT ты работаешь?</b>",
reply_markup=get_spheres_kb()
)
await state.set_state(Registration.waiting_for_sphere)
await callback.answer()
@router.callback_query(Registration.waiting_for_sphere)
async def sphere_chosen(callback: CallbackQuery, state: FSMContext):
sphere = callback.data.split("_")[1]
if sphere == "other":
await callback.message.edit_text("Напиши свою сферу деятельности:")
await state.set_state(Registration.waiting_for_custom_sphere)
else:
await state.update_data(sphere=sphere)
await callback.message.edit_text(f"Выбрано: <b>{sphere}</b>\n\nКакой основной стек технологий?")
await state.set_state(Registration.waiting_for_language)
await callback.answer()
@router.message(Registration.waiting_for_custom_sphere)
async def custom_sphere_input(message: Message, state: FSMContext):
await state.update_data(sphere=message.text)
await message.answer(f"Принято: <b>{message.text}</b>\n\nКакой основной стек?")
await state.set_state(Registration.waiting_for_language)
@router.message(Registration.waiting_for_language)
async def language_chosen(message: Message, state: FSMContext):
await state.update_data(language=message.text)
await message.answer(
"Принято! И последнее: напиши свои <b>предпочтения по заказам</b> (фильтры).\n"
"Например: 'чек от 5000р' или 'без правок'.\n\n"
"<i>Если не хочешь заполнять сейчас, нажми кнопку ниже.</i>",
reply_markup=get_skip_kb()
)
await state.set_state(Registration.waiting_for_preferences)
@router.callback_query(Registration.waiting_for_preferences, F.data == "skip_preferences")
async def skip_preferences(callback: CallbackQuery, state: FSMContext):
await state.update_data(preferences="Не указано")
data = await state.get_data()
await db.update_user_data(callback.from_user.id, data)
await callback.message.edit_text(
"✅ <b>Профиль успешно настроен!</b> (Фильтры пропущены)\n\n"
f"🌐 Сфера: {data['sphere']}\n"
f"🛠 Стек: {data['language']}\n"
f"⚙️ Фильтры: {data['preferences']}"
)
await state.clear()
await callback.answer()
@router.message(Registration.waiting_for_preferences)
async def preferences_input(message: Message, state: FSMContext):
await state.update_data(preferences=message.text)
data = await state.get_data()
# Сохраняем в базу данных
await db.update_user_data(message.from_user.id, data)
await message.answer(
"✅ <b>Профиль успешно настроен!</b>\n\n"
f"🌐 Сфера: {data['sphere']}\n"
f"🛠 Стек: {data['language']}\n"
f"⚙️ Фильтры: {data['preferences']}"
)
await state.clear()

121
handlers/search.py Normal file
View File

@@ -0,0 +1,121 @@
import html
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from database import Database
from kwork import get_kwork_projects
from ai import filter_vacancies_with_ai
from aiogram import F
from aiogram.types import CallbackQuery
import logging
from keyboards import get_pagination_kb
router = Router()
db = Database("users.db")
@router.message(Command("search"))
async def search_projects(message: Message):
args = message.text.split()
start_p, end_p = 1, 1
if len(args) == 3:
if args[1].isdigit() and args[2].isdigit():
start_p, end_p = int(args[1]), int(args[2])
elif len(args) == 2:
if args[1].isdigit():
start_p, end_p = int(args[1]), int(args[1])
user_data = await db.get_user(message.from_user.id)
if not user_data or user_data[0] is None:
await message.answer("⚠️ Твой профиль еще не настроен.")
return
sphere, lang, prefs = user_data
user_preferences = f"- Сфера: {sphere}\n- Стек: {lang}\n- Доп: {prefs}"
msg = await message.answer(f"⏳ <b>Собираю свежие проекты и анализирую их нейросетью...</b>\n<i>Это может занять около минуты.</i>")
try:
raw_vacancies = await get_kwork_projects(start_page=start_p, end_page=end_p)
if not raw_vacancies:
await msg.edit_text("❌ Проектов не найдено.")
return
filtered_vacancies = await filter_vacancies_with_ai(raw_vacancies, user_preferences)
if not filtered_vacancies:
await msg.edit_text("😔 Подходящих проектов сейчас нет.")
return
await msg.delete()
await message.answer(f"🎯 <b>Найдено {len(filtered_vacancies)} подходящих проектов:</b>")
for vac in filtered_vacancies:
title = html.escape(vac.get('title', 'Без названия'))
price = html.escape(vac.get('price', 'По договоренности'))
desc = html.escape(vac.get('description', ''))
url = vac.get('url', '#')
text = (
f"💼 <b>{title}</b>\n\n"
f"💰 <b>Бюджет:</b> {price}\n"
f"📝 <b>Описание:</b> {desc}...\n\n"
f"🔗 <a href='{url}'>Смотреть на Kwork</a>"
)
# Отправляем, отключая превью ссылок, чтобы не захламлять чат
await message.answer(text, disable_web_page_preview=True)
except Exception as e:
print(f"Ошибка в поиске: {e}")
await msg.edit_text("❌ Произошла ошибка при анализе проектов. Попробуй еще раз чуть позже.")
async def build_pages_text(page_num: int):
raw_vacancies = await get_kwork_projects(start_page=page_num, end_page=page_num)
if not raw_vacancies:
return "На этой странице проектов не найдено."
text = f"📂 <b>Все проекты (Страница {page_num}):</b>\n\n"
for i, vac in enumerate(raw_vacancies, 1):
title = html.escape(vac.get('title', 'Без названия'))
price = html.escape(vac.get('price', 'По договоренности'))
url = vac.get('url', '#')
desc = html.escape(vac.get('description', ''))[:100] + "..."
text += f"{i}. <a href='{url}'>{title}</a>\n💰 {price}\n📝 {desc}\n\n"
return text
@router.message(Command("all"))
async def command_all_vancancies(message: Message):
wait_msg = await message.answer("⏳ Загружаю список проектов...")
try:
page = 1
content = await build_pages_text(page)
await wait_msg.edit_text(
content,
reply_markup=get_pagination_kb(page),
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Error in /all: {e}")
await wait_msg.edit_text("⚠️ Ошибка при загрузке данных.")
# Обработка нажатий на кнопки пагинации
@router.callback_query(F.data.startswith("browse_"))
async def process_pagination(callback: CallbackQuery):
page = int(callback.data.split("_")[1])
await callback.message.edit_text("🔄 Обновляю список...")
try:
content = await build_pages_text(page)
await callback.message.edit_text(
content,
reply_markup=get_pagination_kb(page),
disable_web_page_preview=True
)
await callback.answer()
except Exception as e:
logging.error(f"Error in pagination: {e}")
await callback.answer("Ошибка при смене страницы", show_alert=True)

41
keyboards.py Normal file
View File

@@ -0,0 +1,41 @@
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
def get_start_kb():
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Подписаться", callback_data="subscribe"))
builder.row(InlineKeyboardButton(text="📄 Читать оферту", url="https://telegra.ph/..."))
return builder.as_markup()
def get_profile_edit_kb():
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="📝 Редактировать профиль", callback_data="subscribe"))
return builder.as_markup()
def get_spheres_kb():
builder = InlineKeyboardBuilder()
spheres = ["Backend", "Frontend", "Mobile", "DevOps", "Design", "QA"]
for sphere in spheres:
builder.add(InlineKeyboardButton(text=sphere, callback_data=f"sphere_{sphere}"))
builder.row(InlineKeyboardButton(text="⌨️ Свой вариант", callback_data="sphere_other"))
builder.adjust(2)
return builder.as_markup()
def get_skip_kb():
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏩ Пропустить", callback_data="skip_preferences"))
return builder.as_markup()
def get_pagination_kb(page: int):
builder = InlineKeyboardBuilder()
buttons = []
if page > 1:
buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"browse_{page-1}"))
buttons.append(InlineKeyboardButton(text=f"📄 Стр. {page}", callback_data="ignore"))
buttons.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"browse_{page+1}"))
builder.row(*buttons)
return builder.as_markup()

345
kwork.py
View File

@@ -2,12 +2,12 @@ import asyncio
import json
import re
import random
from typing import Awaitable, Callable, Optional
from typing import Awaitable, Callable, Optional, Tuple
import time
from playwright.async_api import Locator, Page, async_playwright
from playwright_stealth import Stealth
# --- Константы ---
BASE_URL = "https://kwork.ru"
PROJECTS_URL = f"{BASE_URL}/projects?c=11"
@@ -18,50 +18,101 @@ USER_AGENT = (
VIEWPORT = {"width": 1920, "height": 1080}
# Задержка между запросами к страницам (в секундах), чтобы избежать блокировок
PAGE_LOAD_DELAY = (3, 6)
# Настройки задержек (мин, макс) в секундах
DELAY_PAGE = (150, 240) # 2.5 - 4 минуты для страниц списка
DELAY_PROJECT = (20, 45) # 20 - 45 секунд для детальных страниц (приоритетные)
Project = dict[str, str]
Extractor = Callable[[Page, Locator], Awaitable[Optional[Project]]]
# --- Вспомогательные функции ---
class RequestThrottler:
def __init__(self):
self._queue = []
self._last_call_time = 0
self._new_item_event = asyncio.Event()
self._worker_task = None
#Умный фоновый воркер, умеющий прерывать долгое ожидание ради приоритетных задач
async def _worker(self):
while True:
if not self._queue:
self._new_item_event.clear()
await self._new_item_event.wait()
continue
# Сортируем: сначала проекты (priority=0), потом страницы (priority=1), затем по времени
self._queue.sort(key=lambda x: (x["priority"], x["time"]))
current_task = self._queue[0]
if "required_delay" not in current_task:
min_d, max_d = current_task["delay"]
current_task["required_delay"] = random.uniform(min_d, max_d)
required_delay = current_task["required_delay"]
now = time.time()
elapsed = now - self._last_call_time
wait_time = required_delay - elapsed
if wait_time > 0:
type_str = "ПРОЕКТ" if current_task["priority"] == 0 else "СПИСОК"
print(f"[Throttler] Тип:{type_str}. Ждем {wait_time:.2f} сек... (в очереди: {len(self._queue)})")
self._new_item_event.clear()
try:
await asyncio.wait_for(self._new_item_event.wait(), timeout=wait_time)
continue
except asyncio.TimeoutError:
pass
self._queue.remove(current_task)
self._last_call_time = time.time()
if not current_task["fut"].done():
current_task["fut"].set_result(True)
#Встает в очередь на выполнение запроса.
#priority: 0 для высокого (проекты), 1 для низкого (страницы).
async def wait(self, priority: int, delay_range: Tuple[float, float]):
if self._worker_task is None:
self._worker_task = asyncio.create_task(self._worker())
loop = asyncio.get_running_loop()
fut = loop.create_future()
item = {
"priority": priority,
"delay": delay_range,
"fut": fut,
"time": time.time()
}
self._queue.append(item)
self._new_item_event.set()
await fut
throttler = RequestThrottler()
def normalize_text(text: str) -> str:
"""Очищает текст от лишних пробельных символов и переносов строк."""
return re.sub(r"\s+", " ", text).strip()
def first_line(text: str) -> str:
"""Извлекает только первую строку из переданного текста."""
text = text.strip()
if not text:
return ""
return text.splitlines()[0].strip()
return text.splitlines()[0].strip() if text else ""
def normalize_url(href: str) -> str:
"""Превращает относительную ссылку (напр. /projects/1) в полную (https://kwork.ru/...)."""
return f"{BASE_URL}{href}" if href.startswith("/") else href
async def safe_inner_text(locator: Locator, default: str = "") -> str:
"""
Безопасно извлекает внутренний текст элемента.
Если элемент не найден или произошла ошибка, возвращает значение по умолчанию.
"""
try:
text = await locator.inner_text(timeout=1500)
text = text.replace("\xa0", " ")
return text.strip()
return text.replace("\xa0", " ").strip()
except Exception:
return default
async def first_text(root: Locator, selectors: list[str], default: str = "") -> str:
"""
Пробует по очереди список CSS-селекторов внутри корневого элемента.
Возвращает очищенный текст первого найденного элемента.
"""
for selector in selectors:
try:
loc = root.locator(selector).first
@@ -73,253 +124,138 @@ async def first_text(root: Locator, selectors: list[str], default: str = "") ->
continue
return default
async def get_card_root(page: Page, href: str) -> Locator:
"""
Находит родительский контейнер (карточку проекта) на основе ссылки на проект.
Использует поиск по XPath 'ancestor', чтобы подняться вверх по DOM-дереву.
"""
card = page.locator(
f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card__top")][1]'
)
if await card.count() > 0:
return card.first
card = page.locator(
f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card")][1]'
)
if await card.count() > 0:
return card.first
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card__top")][1]')
if await card.count() > 0: return card.first
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card")][1]')
if await card.count() > 0: return card.first
return page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[1]').first
async def extract_price(card: Locator) -> str:
"""
Извлекает стоимость проекта из карточки.
Сначала ищет текст по ключевым фразам (бюджет), затем по CSS-классам цен.
"""
card_text = await safe_inner_text(card, "")
card_text = normalize_text(card_text)
patterns = [
r"(Желаемый бюджет:\s*до\s*[\d\s]+₽)",
r"(Цена до:\s*[\d\s]+₽)",
r"(Допустимый:\s*до\s*[\d\s]+₽)",
]
found: list[str] = []
card_text = normalize_text(await safe_inner_text(card, ""))
patterns = [r"(Желаемый бюджет:\s*до\s*[\d\s]+₽)", r"(Цена до:\s*[\d\s]+₽)", r"(Допустимый:\s*до\s*[\d\s]+₽)"]
found = []
for pattern in patterns:
match = re.search(pattern, card_text, flags=re.IGNORECASE)
if match:
value = normalize_text(match.group(1))
if value and value not in found:
found.append(value)
val = normalize_text(match.group(1))
if val not in found: found.append(val)
if found:
return " | ".join(found)
primary = await first_text(
card,
[
".wants-card__price",
".wants-card__header-right-block .wants-card__price",
"[class*='wants-card__price']",
"[class*='price']",
],
"",
)
higher = await first_text(
card,
[
".wants-card__description-higher-price",
"[class*='description-higher-price']",
],
"",
)
parts = [part for part in [primary, higher] if part]
if parts:
return " | ".join(parts)
return "По договоренности"
if found: return " | ".join(found)
primary = await first_text(card, [".wants-card__price", "[class*='wants-card__price']", "[class*='price']"])
higher = await first_text(card, [".wants-card__description-higher-price", "[class*='description-higher-price']"])
parts = [p for p in [primary, higher] if p]
return " | ".join(parts) if parts else "По договоренности"
async def extract_description(card: Locator) -> str:
"""
Извлекает описание проекта из карточки, удаляя лишние элементы управления
('Показать полностью', 'Скрыть').
"""
description = await first_text(
card,
[
".wants-card__description-text .overflow-hidden .d-inline",
".wants-card__description-text .overflow-hidden",
".wants-card__description-text",
"[class*='description-text']",
],
"",
)
description = description.replace("Показать полностью", "")
description = description.replace("Скрыть", "")
description = description.replace("\xa0", " ")
description = normalize_text(description)
return first_line(description)
description = await first_text(card, [
".wants-card__description-text .overflow-hidden .d-inline",
".wants-card__description-text .overflow-hidden",
".wants-card__description-text",
"[class*='description-text']"
])
for word in ["Показать полностью", "Скрыть"]:
description = description.replace(word, "")
return first_line(normalize_text(description.replace("\xa0", " ")))
async def extract_kwork_project(page: Page, title_block: Locator) -> Optional[Project]:
"""
Функция-экстрактор: собирает все поля (заголовок, цена, ссылка, описание)
для одного конкретного блока заголовка.
"""
link = title_block.locator("a").first
if await link.count() == 0:
return None
if await link.count() == 0: return None
title_text = normalize_text(await safe_inner_text(link, ""))
href = await link.get_attribute("href")
if not href:
return None
if not href: return None
card = await get_card_root(page, href)
price = await extract_price(card)
description = await extract_description(card)
return {
"title": title_text,
"price": price,
"price": await extract_price(card),
"url": normalize_url(href),
"description": description,
"description": await extract_description(card),
}
# --- Основная логика скрапинга ---
async def get_kwork_projects(max_pages: int = 1) -> list[Project]:
"""
Основная функция запуска браузера и обхода страниц.
:param max_pages: Сколько страниц нужно просмотреть.
"""
#Парсинг списка (НИЗКИЙ приоритет, ДЛИННАЯ задержка)
async def get_kwork_projects(start_page: int = 1, end_page: int = 1) -> list[Project]:
all_results: list[Project] = []
if start_page > end_page: start_page = end_page
async with Stealth().use_async(async_playwright()) as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent=USER_AGENT,
viewport=VIEWPORT,
)
context = await browser.new_context(user_agent=USER_AGENT, viewport=VIEWPORT)
page = await context.new_page()
try:
for current_page in range(1, max_pages + 1):
# Формируем URL с учетом номера страницы
for current_page in range(start_page, end_page + 1):
# Ожидание в очереди (Приоритет 1 = Низкий)
await throttler.wait(priority=1, delay_range=DELAY_PAGE)
url = f"{PROJECTS_URL}&page={current_page}"
print(f"Загрузка страницы {current_page}: {url}")
print(f"[Листинг] Загрузка страницы {current_page}...")
await page.goto(url, wait_until="networkidle")
# Дополнительная задержка для рендеринга JS элементов
await asyncio.sleep(2)
items = await page.locator(".wants-card__header-title").all()
print(f"Найдено проектов на странице: {len(items)}")
for item in items:
try:
data = await extract_kwork_project(page, item)
if data:
all_results.append(data)
except Exception as e:
print(f"Ошибка при парсинге карточки: {e}")
continue
# Ограничение частоты запросов (Rate Limiting)
if current_page < max_pages:
delay = random.uniform(*PAGE_LOAD_DELAY)
print(f"Ожидаем {delay:.2f} сек. перед следующей страницей...")
await asyncio.sleep(delay)
if data: all_results.append(data)
except: continue
return all_results
except Exception as e:
print(f"Произошла критическая ошибка: {e}")
print(f"Ошибка скрапинга списка: {e}")
return all_results
finally:
await browser.close()
def clean(text: str) -> str:
"""Очищает текст от мусора, неразрывных пробелов и лишних пустот."""
if not text: return ""
text = text.replace("\xa0", " ")
return re.sub(r"\s+", " ", text).strip()
return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
async def get_text(page: Page, selector: str) -> str:
"""Извлекает текст из элемента, если он существует."""
try:
element = page.locator(selector).first
if await element.count() > 0:
return await element.inner_text(timeout=3000)
return ""
except:
return ""
return await element.inner_text(timeout=3000) if await element.count() > 0 else ""
except: return ""
#Парсинг деталей вакансии (ВЫСОКИЙ приоритет, КОРОТКАЯ задержка)
async def get_project_details(url: str) -> Optional[dict]:
"""
Открывает страницу проекта, парсит HTML и возвращает структурированные данные.
"""
await throttler.wait(priority=0, delay_range=DELAY_PROJECT)
async with Stealth().use_async(async_playwright()) as p:
# Запускаем браузер
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(user_agent=USER_AGENT)
page = await context.new_page()
try:
print(f"Парсим проект: {url}")
print(f"[Проект] Парсим детали: {url}")
await page.goto(url, wait_until="networkidle")
# Ждем немного, чтобы JS отработал до конца
await asyncio.sleep(2)
# 1. Извлекаем заголовок
title = await get_text(page, "h1.wants-card__header-title")
# 2. Извлекаем описание (сохраняем структуру)
description_raw = await get_text(page, ".wants-card__description-text")
# 3. Бюджет (Желаемый и Допустимый)
# Извлекаем только цифры через регулярку
price_desired_raw = await get_text(page, ".wants-card__price")
price_max_raw = await get_text(page, ".wants-card__description-higher-price")
# 4. Информация о заказчике
buyer_block = await get_text(page, ".want-payer-statistic")
buyer_name = await get_text(page, ".want-payer-statistic a")
# 5. Статистика проекта (предложения и время)
informers_block = await get_text(page, ".want-card__informers")
# --- Парсинг данных через регулярные выражения для точности ---
# Чистим цену: оставляем только цифры
def extract_digits(text):
digits = "".join(re.findall(r'\d', text))
return f"{digits}" if digits else "По договоренности"
# Вырезаем статистику из текста блоков
total_projects = re.search(r"Размещено проектов на бирже: (\d+)", buyer_block)
hired_percent = re.search(r"Нанято: (\d+%)", buyer_block)
offers_count = re.search(r"Предложений:\s*(\d+)", informers_block)
time_left = re.search(r"Осталось:\s*(.*?)(?:\n|$)", informers_block)
# Формируем итоговый объект
data = {
return {
"url": url,
"title": clean(title),
"description": description_raw.strip(),
@@ -337,29 +273,8 @@ async def get_project_details(url: str) -> Optional[dict]:
"time_left": clean(time_left.group(1)) if time_left else "н/д"
}
}
return data
except Exception as e:
print(f"Ошибка при парсинге {url}: {e}")
print(f"Ошибка парсинга проекта {url}: {e}")
return None
finally:
await browser.close()
if __name__ == "__main__":
#pages_to_scan = 2
#data = asyncio.run(get_kwork_projects(max_pages=pages_to_scan))
target_url = "https://kwork.ru/projects/3134701"
# Запуск парсера для одной ссылки
result = asyncio.run(get_project_details(target_url))
if result:
print("\n--- Данные проекта в формате JSON ---")
print(json.dumps(result, ensure_ascii=False, indent=4))
#print(f"\nВсего собрано проектов: {len(data)}")
#print(json.dumps(data, ensure_ascii=False, indent=4))

89
scheduler.py Normal file
View File

@@ -0,0 +1,89 @@
import asyncio
import random
import time
import html
from aiogram import Bot
from database import Database
from kwork import get_kwork_projects, get_project_details
from ai import filter_vacancies_with_ai
class VacancyScanner:
def __init__(self, bot: Bot, db: Database):
self.bot = bot
self.db = db
self.current_page = 1
self.last_reset_time = time.time()
async def start_scanning(self):
while True:
try:
current_time = time.time()
if current_time - self.last_reset_time >= 3600:
print("--- Прошел час: возврат на 1 страницу ---")
self.current_page = 1
self.last_reset_time = current_time
print(f"--- Сканирую страницу {self.current_page} ---")
summary_vacancies = await get_kwork_projects(start_page=self.current_page, end_page=self.current_page)
if not summary_vacancies:
print(f"Страница {self.current_page} пуста. Сброс на 1 стр.")
self.current_page = 1
else:
print(f"Найдено {len(summary_vacancies)} вакансий на странице.")
users = await self.db.get_all()
for user in users:
user_id = user[1]
if not user[2] or not user[3]:
print(f"Пропуск пользователя {user_id}: профиль не заполнен.")
continue
# Отбираем новые вакансии
new_for_user = []
for v in summary_vacancies:
if not await self.db.is_vacancy_sent(user_id, v['url']):
new_for_user.append(v)
if not new_for_user:
print(f"Для пользователя {user_id} новых вакансий нет.")
continue
print(f"Отправка {len(new_for_user)} вакансий на анализ AI для юзера {user_id}...")
user_prefs = f"Сфера: {user[2]}, Стек: {user[3]}, Доп: {user[4]}"
filtered_vacancies = await filter_vacancies_with_ai(new_for_user, user_prefs)
print(f"AI отобрал {len(filtered_vacancies)} релевантных вакансий.")
for vac in filtered_vacancies:
# Получаем детали
full_info = await get_project_details(vac['url'])
description = full_info['description'] if full_info else vac.get('description', 'Нет описания')
title = html.escape(vac.get('title', 'Без названия'))
price = html.escape(vac.get('price', 'По договоренности'))
text = (
f"🎯 <b>Подходящий проект!</b>\n\n"
f"💼 <b>{title}</b>\n"
f"💰 <b>Бюджет:</b> {price}\n\n"
f"📝 <b>Описание:</b>\n{html.escape(description)}\n\n"
f"🔗 <a href='{vac['url']}'>Открыть на Kwork</a>"
)
try:
await self.bot.send_message(user_id, text, disable_web_page_preview=True)
await self.db.mark_vacancy_as_sent(user_id, vac['url'])
await asyncio.sleep(1)
except Exception as e:
print(f"Ошибка отправки сообщения: {e}")
self.current_page += 1
except Exception as e:
print(f"Критическая ошибка сканера: {e}")
await asyncio.sleep(60)
wait_time = random.uniform(150, 240)
print(f"Цикл завершен. Ожидание {wait_time/60:.2f} мин...")
await asyncio.sleep(wait_time)

7
states.py Normal file
View File

@@ -0,0 +1,7 @@
from aiogram.fsm.state import State, StatesGroup
class Registration(StatesGroup):
waiting_for_sphere = State()
waiting_for_custom_sphere = State()
waiting_for_language = State()
waiting_for_preferences = State()