From 164acd6307a841c793663ffcbf7b074f7c20fd78 Mon Sep 17 00:00:00 2001 From: Faynot Date: Sun, 29 Mar 2026 11:25:31 +0300 Subject: [PATCH] feat: add subscribtion scheduler with ai, pagination --- ai.py | 112 ++++++ bot.py | 159 +------- config.py | 7 + database.py | 38 ++ handlers/__init__.py | 5 + handlers/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 303 bytes handlers/__pycache__/common.cpython-314.pyc | Bin 0 -> 3586 bytes .../__pycache__/registration.cpython-314.pyc | Bin 0 -> 8036 bytes handlers/__pycache__/search.cpython-314.pyc | Bin 0 -> 8883 bytes handlers/common.py | 42 +++ handlers/registration.py | 80 ++++ handlers/search.py | 121 ++++++ keyboards.py | 41 +++ kwork.py | 345 +++++++----------- scheduler.py | 89 +++++ states.py | 7 + 16 files changed, 688 insertions(+), 358 deletions(-) create mode 100644 ai.py create mode 100644 config.py create mode 100644 handlers/__init__.py create mode 100644 handlers/__pycache__/__init__.cpython-314.pyc create mode 100644 handlers/__pycache__/common.cpython-314.pyc create mode 100644 handlers/__pycache__/registration.cpython-314.pyc create mode 100644 handlers/__pycache__/search.cpython-314.pyc create mode 100644 handlers/common.py create mode 100644 handlers/registration.py create mode 100644 handlers/search.py create mode 100644 keyboards.py create mode 100644 scheduler.py create mode 100644 states.py diff --git a/ai.py b/ai.py new file mode 100644 index 0000000..ef4e519 --- /dev/null +++ b/ai.py @@ -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 diff --git a/bot.py b/bot.py index 96dcb14..9a71c4b 100644 --- a/bot.py +++ b/bot.py @@ -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 = ( - "👋 Привет! Я твой персональный агент по Kwork.\n\n" - "💻 ⚠️ ВАЖНО: Этот бот предназначен исключительно для IT-специалистов.\n\n" - "🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы.\n\n" - "Нажимая кнопку «Подписаться», вы принимаете условия " - "публичной оферты." - ) - - 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 = ( - "👤 Твой профиль IT-специалиста:\n\n" - f"🌐 Сфера: {sphere}\n" - f"🛠 Стек: {lang}\n" - f"⚙️ Предпочтения: {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В какой сфере IT ты работаешь?", - 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("Напиши свою сферу деятельности (например: Data Science или GameDev):") - await state.set_state(Registration.waiting_for_custom_sphere) - else: - await state.update_data(sphere=sphere) - await callback.message.edit_text( - f"Выбрано: {sphere}\n\nКакой основной язык программирования или стек технологий используешь?" - ) - 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"Принято: {sphere}\n\nКакой основной язык программирования или стек технологий используешь?" - ) - 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( - "Принято! И последнее: напиши свои предпочтения по заказам (фильтры).\n" - "Например: 'чек от 5000р' или 'без правок'.\n\n" - "Если не хочешь заполнять сейчас, нажми кнопку ниже.", - 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( - "✅ Профиль успешно настроен! (Фильтры пропущены)\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__": diff --git a/config.py b/config.py new file mode 100644 index 0000000..7eab7c0 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +from os import getenv +from dotenv import load_dotenv + +load_dotenv() + +TOKEN = getenv("BOT_TOKEN") +OPENROUTER = getenv("OPENROUTER_TOKEN") diff --git a/database.py b/database.py index 6d47c68..10100fa 100644 --- a/database.py +++ b/database.py @@ -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() diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..cc1a0b6 --- /dev/null +++ b/handlers/__init__.py @@ -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] diff --git a/handlers/__pycache__/__init__.cpython-314.pyc b/handlers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95c532046cc5195a6c5c70e7879caf5063838bb6 GIT binary patch literal 303 zcmdPqt(uIt*oyK?OHzycG&yduCFken=I0fGOuxko;lx9v zZ}AkRre_wH6eX5q=I7nw0`g!oY{jXGMadbrcp)@Qm>r_AcqPMUkYjJD>u2QWrs}69 zR_5iG=ogmf7iH^bB<7{$q!tzH$H!;pWtPOp>lIYq;;_lhPbtkwwJYKS>I1p5SPDpd hU}j`wyvv|{mq8CmKHwIeP`e=a0+&e>dl3&%8UT?nRlooM literal 0 HcmV?d00001 diff --git a/handlers/__pycache__/common.cpython-314.pyc b/handlers/__pycache__/common.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d61708abcb9428cf878264311f3764a9aa36516 GIT binary patch literal 3586 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 UnzJKF8&IYIWp@4xIsS|N7j5nRG5`Po literal 0 HcmV?d00001 diff --git a/handlers/__pycache__/search.cpython-314.pyc b/handlers/__pycache__/search.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..515e5cb97db506ac57d1dfa375a75773b9f14512 GIT binary patch literal 8883 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 JDn;p-{vTuJf*Ak+ literal 0 HcmV?d00001 diff --git a/handlers/common.py b/handlers/common.py new file mode 100644 index 0000000..24df89e --- /dev/null +++ b/handlers/common.py @@ -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 = ( + "👋 Привет! Я твой персональный агент по Kwork.\n\n" + "💻 ⚠️ ВАЖНО: Этот бот предназначен исключительно для IT-специалистов.\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 = ( + "👤 Твой профиль:\n\n" + f"🌐 Сфера: {sphere}\n" + f"🛠 Стек: {lang}\n" + f"⚙️ Предпочтения: {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("🧹 История отправленных вакансий очищена!\n Теперь я снова смогу прислать тебе те заказы, которые ты уже видел.") diff --git a/handlers/registration.py b/handlers/registration.py new file mode 100644 index 0000000..4d92367 --- /dev/null +++ b/handlers/registration.py @@ -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В какой сфере IT ты работаешь?", + 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"Выбрано: {sphere}\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"Принято: {message.text}\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( + "Принято! И последнее: напиши свои предпочтения по заказам (фильтры).\n" + "Например: 'чек от 5000р' или 'без правок'.\n\n" + "Если не хочешь заполнять сейчас, нажми кнопку ниже.", + 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( + "✅ Профиль успешно настроен! (Фильтры пропущены)\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( + "✅ Профиль успешно настроен!\n\n" + f"🌐 Сфера: {data['sphere']}\n" + f"🛠 Стек: {data['language']}\n" + f"⚙️ Фильтры: {data['preferences']}" + ) + await state.clear() diff --git a/handlers/search.py b/handlers/search.py new file mode 100644 index 0000000..68992f6 --- /dev/null +++ b/handlers/search.py @@ -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"⏳ Собираю свежие проекты и анализирую их нейросетью...\nЭто может занять около минуты.") + + 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"🎯 Найдено {len(filtered_vacancies)} подходящих проектов:") + 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"💼 {title}\n\n" + f"💰 Бюджет: {price}\n" + f"📝 Описание: {desc}...\n\n" + f"🔗 Смотреть на Kwork" + ) + # Отправляем, отключая превью ссылок, чтобы не захламлять чат + 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"📂 Все проекты (Страница {page_num}):\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}. {title}\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) diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..3f64187 --- /dev/null +++ b/keyboards.py @@ -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() diff --git a/kwork.py b/kwork.py index 6f8ddf2..14bddff 100644 --- a/kwork.py +++ b/kwork.py @@ -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)) diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..73eb3d0 --- /dev/null +++ b/scheduler.py @@ -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"🎯 Подходящий проект!\n\n" + f"💼 {title}\n" + f"💰 Бюджет: {price}\n\n" + f"📝 Описание:\n{html.escape(description)}\n\n" + f"🔗 Открыть на Kwork" + ) + + 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) diff --git a/states.py b/states.py new file mode 100644 index 0000000..239a088 --- /dev/null +++ b/states.py @@ -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()