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()