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 0000000..95c5320 Binary files /dev/null and b/handlers/__pycache__/__init__.cpython-314.pyc differ diff --git a/handlers/__pycache__/common.cpython-314.pyc b/handlers/__pycache__/common.cpython-314.pyc new file mode 100644 index 0000000..8d61708 Binary files /dev/null and b/handlers/__pycache__/common.cpython-314.pyc differ diff --git a/handlers/__pycache__/registration.cpython-314.pyc b/handlers/__pycache__/registration.cpython-314.pyc new file mode 100644 index 0000000..bd2e1b1 Binary files /dev/null and b/handlers/__pycache__/registration.cpython-314.pyc differ diff --git a/handlers/__pycache__/search.cpython-314.pyc b/handlers/__pycache__/search.cpython-314.pyc new file mode 100644 index 0000000..515e5cb Binary files /dev/null and b/handlers/__pycache__/search.cpython-314.pyc differ 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()