feat: add subscribtion scheduler with ai, pagination
This commit is contained in:
112
ai.py
Normal file
112
ai.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import aiohttp
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from config import OPENROUTER
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_TEMPLATE = """
|
||||||
|
Ты — экспертный технический рекрутер. Твоя задача: отфильтровать IT-вакансии.
|
||||||
|
Предпочтения пользователя:
|
||||||
|
{preferences}
|
||||||
|
|
||||||
|
Верни ТОЛЬКО валидный JSON массив объектов, которые подходят под стек и грейд.
|
||||||
|
Если ничего не подходит, верни [].
|
||||||
|
Никаких пояснений и markdown-разметки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_forced_vacancies(vacancies: list, user_prefs: str) -> list:
|
||||||
|
stack_match = re.search(r'(?i)стек:(.*?)(?=доп:|$)', user_prefs, re.DOTALL)
|
||||||
|
if not stack_match:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stack_content = stack_match.group(1).lower()
|
||||||
|
|
||||||
|
all_keywords = re.findall(r'[a-zA-Z0-9.+#]{2,}', stack_content)
|
||||||
|
|
||||||
|
common_words = {'html', 'css', 'git', 'sql', 'api', 'rest', 'remote', 'work'}
|
||||||
|
strong_keys = list(set(word for word in all_keywords if word not in common_words))
|
||||||
|
|
||||||
|
if not strong_keys:
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"🔍 Ключи для авто-добавления: {strong_keys}")
|
||||||
|
|
||||||
|
forced = []
|
||||||
|
for vac in vacancies:
|
||||||
|
v_id = str(vac.get('id', ''))
|
||||||
|
vac_str = json.dumps(vac, ensure_ascii=False).lower()
|
||||||
|
vac_clean = re.sub(r'https?://\S+', '', vac_str)
|
||||||
|
|
||||||
|
if any(key in vac_clean for key in strong_keys):
|
||||||
|
forced.append(vac)
|
||||||
|
|
||||||
|
return forced
|
||||||
|
|
||||||
|
async def filter_vacancies_with_ai(vacancies: list, user_prefs: str) -> list:
|
||||||
|
if not vacancies:
|
||||||
|
return []
|
||||||
|
|
||||||
|
forced_vacancies = get_forced_vacancies(vacancies, user_prefs)
|
||||||
|
forced_ids = {str(v.get('id')) for v in forced_vacancies if v.get('id')}
|
||||||
|
|
||||||
|
remaining_vacancies = [v for v in vacancies if str(v.get('id')) not in forced_ids]
|
||||||
|
|
||||||
|
if not remaining_vacancies:
|
||||||
|
print(f"✅ Все {len(forced_vacancies)} вакансий одобрены автоматом.")
|
||||||
|
return forced_vacancies
|
||||||
|
|
||||||
|
print(f"✅ Авто-одобрено: {len(forced_vacancies)}")
|
||||||
|
print(f"🤖 Отправка на AI: {len(remaining_vacancies)} (было {len(vacancies)})")
|
||||||
|
|
||||||
|
api_url = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
|
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(preferences=user_prefs)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
try:
|
||||||
|
clean_token = str(OPENROUTER).strip()
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
url=api_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {clean_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "openrouter/free",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": json.dumps(remaining_vacancies, ensure_ascii=False)}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=60
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raw_err = await response.text()
|
||||||
|
print(f"❌ Ошибка API ({response.status}): {raw_err[:100]}")
|
||||||
|
return forced_vacancies
|
||||||
|
|
||||||
|
result_raw = await response.json()
|
||||||
|
|
||||||
|
if 'choices' not in result_raw:
|
||||||
|
return forced_vacancies
|
||||||
|
|
||||||
|
content = result_raw['choices'][0]['message']['content'].strip()
|
||||||
|
|
||||||
|
match = re.search(r'\[\s*\{.*\}\s*\]', content, re.DOTALL)
|
||||||
|
clean_json = match.group(0) if match else content.replace('```json', '').replace('```', '').strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ai_vacs = json.loads(clean_json)
|
||||||
|
final = forced_vacancies.copy()
|
||||||
|
for v in ai_vacs:
|
||||||
|
if str(v.get('id')) not in forced_ids:
|
||||||
|
final.append(v)
|
||||||
|
|
||||||
|
print(f"🏁 Итог: {len(final)} релевантных вакансий")
|
||||||
|
return final
|
||||||
|
except:
|
||||||
|
return forced_vacancies
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка в блоке запроса: {e}")
|
||||||
|
return forced_vacancies
|
||||||
159
bot.py
159
bot.py
@@ -1,156 +1,29 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from os import getenv
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher, html, F
|
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.enums import ParseMode
|
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
|
from database import Database
|
||||||
|
from handlers import routers
|
||||||
load_dotenv()
|
|
||||||
TOKEN = getenv("BOT_TOKEN")
|
|
||||||
dp = Dispatcher()
|
|
||||||
db = Database("users.db")
|
|
||||||
|
|
||||||
# Добавили состояние для ручного ввода сферы
|
|
||||||
class Registration(StatesGroup):
|
|
||||||
waiting_for_sphere = State()
|
|
||||||
waiting_for_custom_sphere = State() # <-- Новое состояние
|
|
||||||
waiting_for_language = State()
|
|
||||||
waiting_for_preferences = State()
|
|
||||||
|
|
||||||
@dp.message(CommandStart())
|
|
||||||
async def command_start_handler(message: Message) -> None:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.row(InlineKeyboardButton(text="✅ Подписаться", callback_data="subscribe"))
|
|
||||||
builder.row(InlineKeyboardButton(text="📄 Читать оферту", url="https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28"))
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"<b>👋 Привет! Я твой персональный агент по Kwork.</b>\n\n"
|
|
||||||
"💻 ⚠️ <b>ВАЖНО:</b> Этот бот предназначен <b>исключительно для IT-специалистов</b>.\n\n"
|
|
||||||
"🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы.\n\n"
|
|
||||||
"Нажимая кнопку «Подписаться», вы принимаете условия "
|
|
||||||
"<a href='https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28'>публичной оферты</a>."
|
|
||||||
)
|
|
||||||
|
|
||||||
await message.answer(text, reply_markup=builder.as_markup(), disable_web_page_preview=True)
|
|
||||||
|
|
||||||
@dp.message(Command("profile"))
|
|
||||||
async def show_profile(message: Message):
|
|
||||||
user_data = await db.get_user(message.from_user.id)
|
|
||||||
|
|
||||||
if not user_data or user_data[0] is None:
|
|
||||||
await message.answer("⚠️ Твой профиль еще не настроен. Нажми /start, чтобы начать.")
|
|
||||||
return
|
|
||||||
|
|
||||||
sphere, lang, prefs = user_data
|
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.row(InlineKeyboardButton(text="📝 Редактировать профиль", callback_data="subscribe")) # Используем тот же callback
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"<b>👤 Твой профиль IT-специалиста:</b>\n\n"
|
|
||||||
f"<b>🌐 Сфера:</b> {sphere}\n"
|
|
||||||
f"<b>🛠 Стек:</b> {lang}\n"
|
|
||||||
f"<b>⚙️ Предпочтения:</b> {prefs}\n\n"
|
|
||||||
"Хочешь что-то изменить? Нажми кнопку ниже."
|
|
||||||
)
|
|
||||||
|
|
||||||
await message.answer(text, reply_markup=builder.as_markup())
|
|
||||||
|
|
||||||
@dp.callback_query(F.data == "subscribe")
|
|
||||||
async def subscribe_handler(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await db.add_user(callback.from_user.id)
|
|
||||||
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
spheres = ["Backend", "Frontend", "Mobile", "DevOps", "Design", "QA"]
|
|
||||||
for sphere in spheres:
|
|
||||||
builder.add(InlineKeyboardButton(text=sphere, callback_data=f"sphere_{sphere}"))
|
|
||||||
|
|
||||||
# Добавляем кнопку своего варианта
|
|
||||||
builder.row(InlineKeyboardButton(text="⌨️ Свой вариант", callback_data="sphere_other"))
|
|
||||||
builder.adjust(2)
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"Отлично! Давай настроим профиль.\n<b>В какой сфере IT ты работаешь?</b>",
|
|
||||||
reply_markup=builder.as_markup()
|
|
||||||
)
|
|
||||||
await state.set_state(Registration.waiting_for_sphere)
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
@dp.callback_query(Registration.waiting_for_sphere)
|
|
||||||
async def sphere_chosen(callback: CallbackQuery, state: FSMContext):
|
|
||||||
sphere = callback.data.split("_")[1]
|
|
||||||
|
|
||||||
if sphere == "other":
|
|
||||||
await callback.message.edit_text("Напиши свою сферу деятельности (например: <i>Data Science</i> или <i>GameDev</i>):")
|
|
||||||
await state.set_state(Registration.waiting_for_custom_sphere)
|
|
||||||
else:
|
|
||||||
await state.update_data(sphere=sphere)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
f"Выбрано: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
|
|
||||||
)
|
|
||||||
await state.set_state(Registration.waiting_for_language)
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
# Обработчик для текстового ввода своей сферы
|
|
||||||
@dp.message(Registration.waiting_for_custom_sphere)
|
|
||||||
async def custom_sphere_input(message: Message, state: FSMContext):
|
|
||||||
sphere = message.text
|
|
||||||
await state.update_data(sphere=sphere)
|
|
||||||
await message.answer(
|
|
||||||
f"Принято: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
|
|
||||||
)
|
|
||||||
await state.set_state(Registration.waiting_for_language)
|
|
||||||
|
|
||||||
@dp.message(Registration.waiting_for_language)
|
|
||||||
async def language_chosen(message: Message, state: FSMContext):
|
|
||||||
await state.update_data(language=message.text)
|
|
||||||
|
|
||||||
# Создаем кнопку для пропуска
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
builder.row(InlineKeyboardButton(text="⏩ Пропустить", callback_data="skip_preferences"))
|
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
"Принято! И последнее: напиши свои <b>предпочтения по заказам</b> (фильтры).\n"
|
|
||||||
"Например: 'чек от 5000р' или 'без правок'.\n\n"
|
|
||||||
"<i>Если не хочешь заполнять сейчас, нажми кнопку ниже.</i>",
|
|
||||||
reply_markup=builder.as_markup()
|
|
||||||
)
|
|
||||||
await state.set_state(Registration.waiting_for_preferences)
|
|
||||||
|
|
||||||
@dp.callback_query(Registration.waiting_for_preferences, F.data == "skip_preferences")
|
|
||||||
async def skip_preferences(callback: CallbackQuery, state: FSMContext):
|
|
||||||
await state.update_data(preferences="Не указано") # Устанавливаем значение по умолчанию
|
|
||||||
user_data = await state.get_data()
|
|
||||||
|
|
||||||
await db.update_user_data(callback.from_user.id, user_data)
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"✅ <b>Профиль успешно настроен!</b> (Фильтры пропущены)\n\n"
|
|
||||||
f"Сфера: {user_data['sphere']}\n"
|
|
||||||
f"Стек: {user_data['language']}\n"
|
|
||||||
f"Фильтры: {user_data['preferences']}"
|
|
||||||
)
|
|
||||||
await state.clear()
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
async def on_startup():
|
|
||||||
await db.create_tables()
|
|
||||||
print("Database ready")
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
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)
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
7
config.py
Normal file
7
config.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from os import getenv
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TOKEN = getenv("BOT_TOKEN")
|
||||||
|
OPENROUTER = getenv("OPENROUTER_TOKEN")
|
||||||
38
database.py
38
database.py
@@ -15,6 +15,29 @@ class Database:
|
|||||||
preferences TEXT
|
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()
|
await db.commit()
|
||||||
|
|
||||||
async def add_user(self, user_id: int):
|
async def add_user(self, user_id: int):
|
||||||
@@ -44,3 +67,18 @@ class Database:
|
|||||||
(user_id,)
|
(user_id,)
|
||||||
) as cursor:
|
) as cursor:
|
||||||
return await cursor.fetchone()
|
return await cursor.fetchone()
|
||||||
|
|
||||||
|
async def count_users(self) -> int:
|
||||||
|
#Возвращает количество зарегистрированных пользователей
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute("SELECT COUNT(*) FROM users") as cursor:
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
async def clear_sent_vacancies(self, user_id: int):
|
||||||
|
#Очищает историю отправленных вакансий для пользователя
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM sent_vacancies WHERE user_id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
5
handlers/__init__.py
Normal file
5
handlers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .common import router as common_router
|
||||||
|
from .registration import router as reg_router
|
||||||
|
from .search import router as search_router
|
||||||
|
|
||||||
|
routers = [reg_router, common_router, search_router]
|
||||||
BIN
handlers/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
handlers/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
handlers/__pycache__/common.cpython-314.pyc
Normal file
BIN
handlers/__pycache__/common.cpython-314.pyc
Normal file
Binary file not shown.
BIN
handlers/__pycache__/registration.cpython-314.pyc
Normal file
BIN
handlers/__pycache__/registration.cpython-314.pyc
Normal file
Binary file not shown.
BIN
handlers/__pycache__/search.cpython-314.pyc
Normal file
BIN
handlers/__pycache__/search.cpython-314.pyc
Normal file
Binary file not shown.
42
handlers/common.py
Normal file
42
handlers/common.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import CommandStart, Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
from keyboards import get_start_kb, get_profile_edit_kb
|
||||||
|
from database import Database
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
db = Database("users.db")
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def command_start_handler(message: Message):
|
||||||
|
text = (
|
||||||
|
"<b>👋 Привет! Я твой персональный агент по Kwork.</b>\n\n"
|
||||||
|
"💻 ⚠️ <b>ВАЖНО:</b> Этот бот предназначен <b>исключительно для IT-специалистов</b>.\n\n"
|
||||||
|
"🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы."
|
||||||
|
)
|
||||||
|
await message.answer(text, reply_markup=get_start_kb(), disable_web_page_preview=True)
|
||||||
|
|
||||||
|
@router.message(Command("profile"))
|
||||||
|
async def show_profile(message: Message):
|
||||||
|
user_data = await db.get_user(message.from_user.id)
|
||||||
|
|
||||||
|
if not user_data or user_data[0] is None:
|
||||||
|
await message.answer("⚠️ Твой профиль еще не настроен. Нажми /start, чтобы начать.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sphere, lang, prefs = user_data
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"<b>👤 Твой профиль:</b>\n\n"
|
||||||
|
f"<b>🌐 Сфера:</b> {sphere}\n"
|
||||||
|
f"<b>🛠 Стек:</b> {lang}\n"
|
||||||
|
f"<b>⚙️ Предпочтения:</b> {prefs}\n\n"
|
||||||
|
"Хочешь что-то изменить? Нажми кнопку ниже."
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text, reply_markup=get_profile_edit_kb())
|
||||||
|
|
||||||
|
@router.message(Command("clear"))
|
||||||
|
async def command_clear_handler(message: Message):
|
||||||
|
await db.clear_sent_vacancies(message.from_user.id)
|
||||||
|
await message.answer("🧹 <b>История отправленных вакансий очищена!</b>\n Теперь я снова смогу прислать тебе те заказы, которые ты уже видел.")
|
||||||
80
handlers/registration.py
Normal file
80
handlers/registration.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from states import Registration
|
||||||
|
from keyboards import get_spheres_kb, get_skip_kb
|
||||||
|
from database import Database
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
db = Database("users.db")
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "subscribe")
|
||||||
|
async def subscribe_handler(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await db.add_user(callback.from_user.id)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Отлично! Давай настроим профиль.\n<b>В какой сфере IT ты работаешь?</b>",
|
||||||
|
reply_markup=get_spheres_kb()
|
||||||
|
)
|
||||||
|
await state.set_state(Registration.waiting_for_sphere)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.callback_query(Registration.waiting_for_sphere)
|
||||||
|
async def sphere_chosen(callback: CallbackQuery, state: FSMContext):
|
||||||
|
sphere = callback.data.split("_")[1]
|
||||||
|
if sphere == "other":
|
||||||
|
await callback.message.edit_text("Напиши свою сферу деятельности:")
|
||||||
|
await state.set_state(Registration.waiting_for_custom_sphere)
|
||||||
|
else:
|
||||||
|
await state.update_data(sphere=sphere)
|
||||||
|
await callback.message.edit_text(f"Выбрано: <b>{sphere}</b>\n\nКакой основной стек технологий?")
|
||||||
|
await state.set_state(Registration.waiting_for_language)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(Registration.waiting_for_custom_sphere)
|
||||||
|
async def custom_sphere_input(message: Message, state: FSMContext):
|
||||||
|
await state.update_data(sphere=message.text)
|
||||||
|
await message.answer(f"Принято: <b>{message.text}</b>\n\nКакой основной стек?")
|
||||||
|
await state.set_state(Registration.waiting_for_language)
|
||||||
|
|
||||||
|
@router.message(Registration.waiting_for_language)
|
||||||
|
async def language_chosen(message: Message, state: FSMContext):
|
||||||
|
await state.update_data(language=message.text)
|
||||||
|
await message.answer(
|
||||||
|
"Принято! И последнее: напиши свои <b>предпочтения по заказам</b> (фильтры).\n"
|
||||||
|
"Например: 'чек от 5000р' или 'без правок'.\n\n"
|
||||||
|
"<i>Если не хочешь заполнять сейчас, нажми кнопку ниже.</i>",
|
||||||
|
reply_markup=get_skip_kb()
|
||||||
|
)
|
||||||
|
await state.set_state(Registration.waiting_for_preferences)
|
||||||
|
|
||||||
|
@router.callback_query(Registration.waiting_for_preferences, F.data == "skip_preferences")
|
||||||
|
async def skip_preferences(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.update_data(preferences="Не указано")
|
||||||
|
data = await state.get_data()
|
||||||
|
|
||||||
|
await db.update_user_data(callback.from_user.id, data)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"✅ <b>Профиль успешно настроен!</b> (Фильтры пропущены)\n\n"
|
||||||
|
f"🌐 Сфера: {data['sphere']}\n"
|
||||||
|
f"🛠 Стек: {data['language']}\n"
|
||||||
|
f"⚙️ Фильтры: {data['preferences']}"
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(Registration.waiting_for_preferences)
|
||||||
|
async def preferences_input(message: Message, state: FSMContext):
|
||||||
|
await state.update_data(preferences=message.text)
|
||||||
|
data = await state.get_data()
|
||||||
|
|
||||||
|
# Сохраняем в базу данных
|
||||||
|
await db.update_user_data(message.from_user.id, data)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"✅ <b>Профиль успешно настроен!</b>\n\n"
|
||||||
|
f"🌐 Сфера: {data['sphere']}\n"
|
||||||
|
f"🛠 Стек: {data['language']}\n"
|
||||||
|
f"⚙️ Фильтры: {data['preferences']}"
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
121
handlers/search.py
Normal file
121
handlers/search.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import html
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from database import Database
|
||||||
|
from kwork import get_kwork_projects
|
||||||
|
from ai import filter_vacancies_with_ai
|
||||||
|
from aiogram import F
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
import logging
|
||||||
|
from keyboards import get_pagination_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
db = Database("users.db")
|
||||||
|
|
||||||
|
@router.message(Command("search"))
|
||||||
|
async def search_projects(message: Message):
|
||||||
|
args = message.text.split()
|
||||||
|
start_p, end_p = 1, 1
|
||||||
|
|
||||||
|
if len(args) == 3:
|
||||||
|
if args[1].isdigit() and args[2].isdigit():
|
||||||
|
start_p, end_p = int(args[1]), int(args[2])
|
||||||
|
elif len(args) == 2:
|
||||||
|
if args[1].isdigit():
|
||||||
|
start_p, end_p = int(args[1]), int(args[1])
|
||||||
|
|
||||||
|
user_data = await db.get_user(message.from_user.id)
|
||||||
|
if not user_data or user_data[0] is None:
|
||||||
|
await message.answer("⚠️ Твой профиль еще не настроен.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sphere, lang, prefs = user_data
|
||||||
|
user_preferences = f"- Сфера: {sphere}\n- Стек: {lang}\n- Доп: {prefs}"
|
||||||
|
|
||||||
|
msg = await message.answer(f"⏳ <b>Собираю свежие проекты и анализирую их нейросетью...</b>\n<i>Это может занять около минуты.</i>")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_vacancies = await get_kwork_projects(start_page=start_p, end_page=end_p)
|
||||||
|
|
||||||
|
if not raw_vacancies:
|
||||||
|
await msg.edit_text("❌ Проектов не найдено.")
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered_vacancies = await filter_vacancies_with_ai(raw_vacancies, user_preferences)
|
||||||
|
|
||||||
|
if not filtered_vacancies:
|
||||||
|
await msg.edit_text("😔 Подходящих проектов сейчас нет.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await msg.delete()
|
||||||
|
await message.answer(f"🎯 <b>Найдено {len(filtered_vacancies)} подходящих проектов:</b>")
|
||||||
|
for vac in filtered_vacancies:
|
||||||
|
title = html.escape(vac.get('title', 'Без названия'))
|
||||||
|
price = html.escape(vac.get('price', 'По договоренности'))
|
||||||
|
desc = html.escape(vac.get('description', ''))
|
||||||
|
url = vac.get('url', '#')
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"💼 <b>{title}</b>\n\n"
|
||||||
|
f"💰 <b>Бюджет:</b> {price}\n"
|
||||||
|
f"📝 <b>Описание:</b> {desc}...\n\n"
|
||||||
|
f"🔗 <a href='{url}'>Смотреть на Kwork</a>"
|
||||||
|
)
|
||||||
|
# Отправляем, отключая превью ссылок, чтобы не захламлять чат
|
||||||
|
await message.answer(text, disable_web_page_preview=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка в поиске: {e}")
|
||||||
|
await msg.edit_text("❌ Произошла ошибка при анализе проектов. Попробуй еще раз чуть позже.")
|
||||||
|
|
||||||
|
async def build_pages_text(page_num: int):
|
||||||
|
raw_vacancies = await get_kwork_projects(start_page=page_num, end_page=page_num)
|
||||||
|
|
||||||
|
if not raw_vacancies:
|
||||||
|
return "❌ На этой странице проектов не найдено."
|
||||||
|
|
||||||
|
text = f"📂 <b>Все проекты (Страница {page_num}):</b>\n\n"
|
||||||
|
for i, vac in enumerate(raw_vacancies, 1):
|
||||||
|
title = html.escape(vac.get('title', 'Без названия'))
|
||||||
|
price = html.escape(vac.get('price', 'По договоренности'))
|
||||||
|
url = vac.get('url', '#')
|
||||||
|
desc = html.escape(vac.get('description', ''))[:100] + "..."
|
||||||
|
|
||||||
|
text += f"{i}. <a href='{url}'>{title}</a>\n💰 {price}\n📝 {desc}\n\n"
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@router.message(Command("all"))
|
||||||
|
async def command_all_vancancies(message: Message):
|
||||||
|
wait_msg = await message.answer("⏳ Загружаю список проектов...")
|
||||||
|
try:
|
||||||
|
page = 1
|
||||||
|
content = await build_pages_text(page)
|
||||||
|
await wait_msg.edit_text(
|
||||||
|
content,
|
||||||
|
reply_markup=get_pagination_kb(page),
|
||||||
|
disable_web_page_preview=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error in /all: {e}")
|
||||||
|
await wait_msg.edit_text("⚠️ Ошибка при загрузке данных.")
|
||||||
|
|
||||||
|
# Обработка нажатий на кнопки пагинации
|
||||||
|
@router.callback_query(F.data.startswith("browse_"))
|
||||||
|
async def process_pagination(callback: CallbackQuery):
|
||||||
|
page = int(callback.data.split("_")[1])
|
||||||
|
|
||||||
|
await callback.message.edit_text("🔄 Обновляю список...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await build_pages_text(page)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
content,
|
||||||
|
reply_markup=get_pagination_kb(page),
|
||||||
|
disable_web_page_preview=True
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error in pagination: {e}")
|
||||||
|
await callback.answer("Ошибка при смене страницы", show_alert=True)
|
||||||
41
keyboards.py
Normal file
41
keyboards.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from aiogram.types import InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
def get_start_kb():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text="✅ Подписаться", callback_data="subscribe"))
|
||||||
|
builder.row(InlineKeyboardButton(text="📄 Читать оферту", url="https://telegra.ph/..."))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
def get_profile_edit_kb():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text="📝 Редактировать профиль", callback_data="subscribe"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
def get_spheres_kb():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
spheres = ["Backend", "Frontend", "Mobile", "DevOps", "Design", "QA"]
|
||||||
|
for sphere in spheres:
|
||||||
|
builder.add(InlineKeyboardButton(text=sphere, callback_data=f"sphere_{sphere}"))
|
||||||
|
builder.row(InlineKeyboardButton(text="⌨️ Свой вариант", callback_data="sphere_other"))
|
||||||
|
builder.adjust(2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
def get_skip_kb():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text="⏩ Пропустить", callback_data="skip_preferences"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
def get_pagination_kb(page: int):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
if page > 1:
|
||||||
|
buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"browse_{page-1}"))
|
||||||
|
|
||||||
|
buttons.append(InlineKeyboardButton(text=f"📄 Стр. {page}", callback_data="ignore"))
|
||||||
|
|
||||||
|
buttons.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"browse_{page+1}"))
|
||||||
|
|
||||||
|
builder.row(*buttons)
|
||||||
|
return builder.as_markup()
|
||||||
345
kwork.py
345
kwork.py
@@ -2,12 +2,12 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import random
|
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.async_api import Locator, Page, async_playwright
|
||||||
from playwright_stealth import Stealth
|
from playwright_stealth import Stealth
|
||||||
|
|
||||||
# --- Константы ---
|
|
||||||
BASE_URL = "https://kwork.ru"
|
BASE_URL = "https://kwork.ru"
|
||||||
PROJECTS_URL = f"{BASE_URL}/projects?c=11"
|
PROJECTS_URL = f"{BASE_URL}/projects?c=11"
|
||||||
|
|
||||||
@@ -18,50 +18,101 @@ USER_AGENT = (
|
|||||||
|
|
||||||
VIEWPORT = {"width": 1920, "height": 1080}
|
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]
|
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:
|
def normalize_text(text: str) -> str:
|
||||||
"""Очищает текст от лишних пробельных символов и переносов строк."""
|
|
||||||
return re.sub(r"\s+", " ", text).strip()
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
def first_line(text: str) -> str:
|
def first_line(text: str) -> str:
|
||||||
"""Извлекает только первую строку из переданного текста."""
|
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
return text.splitlines()[0].strip() if text else ""
|
||||||
return ""
|
|
||||||
return text.splitlines()[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_url(href: str) -> str:
|
def normalize_url(href: str) -> str:
|
||||||
"""Превращает относительную ссылку (напр. /projects/1) в полную (https://kwork.ru/...)."""
|
|
||||||
return f"{BASE_URL}{href}" if href.startswith("/") else href
|
return f"{BASE_URL}{href}" if href.startswith("/") else href
|
||||||
|
|
||||||
|
|
||||||
async def safe_inner_text(locator: Locator, default: str = "") -> str:
|
async def safe_inner_text(locator: Locator, default: str = "") -> str:
|
||||||
"""
|
|
||||||
Безопасно извлекает внутренний текст элемента.
|
|
||||||
Если элемент не найден или произошла ошибка, возвращает значение по умолчанию.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
text = await locator.inner_text(timeout=1500)
|
text = await locator.inner_text(timeout=1500)
|
||||||
text = text.replace("\xa0", " ")
|
return text.replace("\xa0", " ").strip()
|
||||||
return text.strip()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
async def first_text(root: Locator, selectors: list[str], default: str = "") -> str:
|
async def first_text(root: Locator, selectors: list[str], default: str = "") -> str:
|
||||||
"""
|
|
||||||
Пробует по очереди список CSS-селекторов внутри корневого элемента.
|
|
||||||
Возвращает очищенный текст первого найденного элемента.
|
|
||||||
"""
|
|
||||||
for selector in selectors:
|
for selector in selectors:
|
||||||
try:
|
try:
|
||||||
loc = root.locator(selector).first
|
loc = root.locator(selector).first
|
||||||
@@ -73,253 +124,138 @@ async def first_text(root: Locator, selectors: list[str], default: str = "") ->
|
|||||||
continue
|
continue
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
async def get_card_root(page: Page, href: str) -> Locator:
|
async def get_card_root(page: Page, href: str) -> Locator:
|
||||||
"""
|
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card__top")][1]')
|
||||||
Находит родительский контейнер (карточку проекта) на основе ссылки на проект.
|
if await card.count() > 0: return card.first
|
||||||
Использует поиск по XPath 'ancestor', чтобы подняться вверх по DOM-дереву.
|
|
||||||
"""
|
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card")][1]')
|
||||||
card = page.locator(
|
if await card.count() > 0: return card.first
|
||||||
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
|
return page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[1]').first
|
||||||
|
|
||||||
|
|
||||||
async def extract_price(card: Locator) -> str:
|
async def extract_price(card: Locator) -> 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]+₽)"]
|
||||||
Сначала ищет текст по ключевым фразам (бюджет), затем по CSS-классам цен.
|
|
||||||
"""
|
found = []
|
||||||
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] = []
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
match = re.search(pattern, card_text, flags=re.IGNORECASE)
|
match = re.search(pattern, card_text, flags=re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
value = normalize_text(match.group(1))
|
val = normalize_text(match.group(1))
|
||||||
if value and value not in found:
|
if val not in found: found.append(val)
|
||||||
found.append(value)
|
|
||||||
|
|
||||||
if found:
|
if found: return " | ".join(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 "По договоренности"
|
|
||||||
|
|
||||||
|
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:
|
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",
|
||||||
description = await first_text(
|
"[class*='description-text']"
|
||||||
card,
|
])
|
||||||
[
|
for word in ["Показать полностью", "Скрыть"]:
|
||||||
".wants-card__description-text .overflow-hidden .d-inline",
|
description = description.replace(word, "")
|
||||||
".wants-card__description-text .overflow-hidden",
|
return first_line(normalize_text(description.replace("\xa0", " ")))
|
||||||
".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)
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_kwork_project(page: Page, title_block: Locator) -> Optional[Project]:
|
async def extract_kwork_project(page: Page, title_block: Locator) -> Optional[Project]:
|
||||||
"""
|
|
||||||
Функция-экстрактор: собирает все поля (заголовок, цена, ссылка, описание)
|
|
||||||
для одного конкретного блока заголовка.
|
|
||||||
"""
|
|
||||||
link = title_block.locator("a").first
|
link = title_block.locator("a").first
|
||||||
if await link.count() == 0:
|
if await link.count() == 0: return None
|
||||||
return None
|
|
||||||
|
|
||||||
title_text = normalize_text(await safe_inner_text(link, ""))
|
title_text = normalize_text(await safe_inner_text(link, ""))
|
||||||
href = await link.get_attribute("href")
|
href = await link.get_attribute("href")
|
||||||
if not href:
|
if not href: return None
|
||||||
return None
|
|
||||||
|
|
||||||
card = await get_card_root(page, href)
|
card = await get_card_root(page, href)
|
||||||
|
|
||||||
price = await extract_price(card)
|
|
||||||
description = await extract_description(card)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title_text,
|
"title": title_text,
|
||||||
"price": price,
|
"price": await extract_price(card),
|
||||||
"url": normalize_url(href),
|
"url": normalize_url(href),
|
||||||
"description": description,
|
"description": await extract_description(card),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Основная логика скрапинга ---
|
#Парсинг списка (НИЗКИЙ приоритет, ДЛИННАЯ задержка)
|
||||||
|
async def get_kwork_projects(start_page: int = 1, end_page: int = 1) -> list[Project]:
|
||||||
async def get_kwork_projects(max_pages: int = 1) -> list[Project]:
|
|
||||||
"""
|
|
||||||
Основная функция запуска браузера и обхода страниц.
|
|
||||||
:param max_pages: Сколько страниц нужно просмотреть.
|
|
||||||
"""
|
|
||||||
all_results: list[Project] = []
|
all_results: list[Project] = []
|
||||||
|
if start_page > end_page: start_page = end_page
|
||||||
|
|
||||||
async with Stealth().use_async(async_playwright()) as p:
|
async with Stealth().use_async(async_playwright()) as p:
|
||||||
browser = await p.chromium.launch(headless=True)
|
browser = await p.chromium.launch(headless=True)
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(user_agent=USER_AGENT, viewport=VIEWPORT)
|
||||||
user_agent=USER_AGENT,
|
|
||||||
viewport=VIEWPORT,
|
|
||||||
)
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for current_page in range(1, max_pages + 1):
|
for current_page in range(start_page, end_page + 1):
|
||||||
# Формируем URL с учетом номера страницы
|
# Ожидание в очереди (Приоритет 1 = Низкий)
|
||||||
|
await throttler.wait(priority=1, delay_range=DELAY_PAGE)
|
||||||
|
|
||||||
url = f"{PROJECTS_URL}&page={current_page}"
|
url = f"{PROJECTS_URL}&page={current_page}"
|
||||||
print(f"Загрузка страницы {current_page}: {url}")
|
print(f"[Листинг] Загрузка страницы {current_page}...")
|
||||||
|
|
||||||
await page.goto(url, wait_until="networkidle")
|
await page.goto(url, wait_until="networkidle")
|
||||||
|
|
||||||
# Дополнительная задержка для рендеринга JS элементов
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
items = await page.locator(".wants-card__header-title").all()
|
items = await page.locator(".wants-card__header-title").all()
|
||||||
print(f"Найдено проектов на странице: {len(items)}")
|
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
try:
|
try:
|
||||||
data = await extract_kwork_project(page, item)
|
data = await extract_kwork_project(page, item)
|
||||||
if data:
|
if data: all_results.append(data)
|
||||||
all_results.append(data)
|
except: continue
|
||||||
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)
|
|
||||||
|
|
||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Произошла критическая ошибка: {e}")
|
print(f"Ошибка скрапинга списка: {e}")
|
||||||
return all_results
|
return all_results
|
||||||
finally:
|
finally:
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
def clean(text: str) -> str:
|
def clean(text: str) -> str:
|
||||||
"""Очищает текст от мусора, неразрывных пробелов и лишних пустот."""
|
|
||||||
if not text: return ""
|
if not text: return ""
|
||||||
text = text.replace("\xa0", " ")
|
return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
||||||
return re.sub(r"\s+", " ", text).strip()
|
|
||||||
|
|
||||||
async def get_text(page: Page, selector: str) -> str:
|
async def get_text(page: Page, selector: str) -> str:
|
||||||
"""Извлекает текст из элемента, если он существует."""
|
|
||||||
try:
|
try:
|
||||||
element = page.locator(selector).first
|
element = page.locator(selector).first
|
||||||
if await element.count() > 0:
|
return await element.inner_text(timeout=3000) if await element.count() > 0 else ""
|
||||||
return await element.inner_text(timeout=3000)
|
except: return ""
|
||||||
return ""
|
|
||||||
except:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
|
#Парсинг деталей вакансии (ВЫСОКИЙ приоритет, КОРОТКАЯ задержка)
|
||||||
async def get_project_details(url: str) -> Optional[dict]:
|
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:
|
async with Stealth().use_async(async_playwright()) as p:
|
||||||
# Запускаем браузер
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
browser = await p.chromium.launch(headless=True)
|
||||||
context = await browser.new_context(user_agent=USER_AGENT)
|
context = await browser.new_context(user_agent=USER_AGENT)
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Парсим проект: {url}")
|
print(f"[Проект] Парсим детали: {url}")
|
||||||
await page.goto(url, wait_until="networkidle")
|
await page.goto(url, wait_until="networkidle")
|
||||||
# Ждем немного, чтобы JS отработал до конца
|
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
# 1. Извлекаем заголовок
|
|
||||||
title = await get_text(page, "h1.wants-card__header-title")
|
title = await get_text(page, "h1.wants-card__header-title")
|
||||||
|
|
||||||
# 2. Извлекаем описание (сохраняем структуру)
|
|
||||||
description_raw = await get_text(page, ".wants-card__description-text")
|
description_raw = await get_text(page, ".wants-card__description-text")
|
||||||
|
|
||||||
# 3. Бюджет (Желаемый и Допустимый)
|
|
||||||
# Извлекаем только цифры через регулярку
|
|
||||||
price_desired_raw = await get_text(page, ".wants-card__price")
|
price_desired_raw = await get_text(page, ".wants-card__price")
|
||||||
price_max_raw = await get_text(page, ".wants-card__description-higher-price")
|
price_max_raw = await get_text(page, ".wants-card__description-higher-price")
|
||||||
|
|
||||||
# 4. Информация о заказчике
|
|
||||||
buyer_block = await get_text(page, ".want-payer-statistic")
|
buyer_block = await get_text(page, ".want-payer-statistic")
|
||||||
buyer_name = await get_text(page, ".want-payer-statistic a")
|
buyer_name = await get_text(page, ".want-payer-statistic a")
|
||||||
|
|
||||||
# 5. Статистика проекта (предложения и время)
|
|
||||||
informers_block = await get_text(page, ".want-card__informers")
|
informers_block = await get_text(page, ".want-card__informers")
|
||||||
|
|
||||||
# --- Парсинг данных через регулярные выражения для точности ---
|
|
||||||
|
|
||||||
# Чистим цену: оставляем только цифры
|
|
||||||
def extract_digits(text):
|
def extract_digits(text):
|
||||||
digits = "".join(re.findall(r'\d', text))
|
digits = "".join(re.findall(r'\d', text))
|
||||||
return f"{digits} ₽" if digits else "По договоренности"
|
return f"{digits} ₽" if digits else "По договоренности"
|
||||||
|
|
||||||
# Вырезаем статистику из текста блоков
|
|
||||||
total_projects = re.search(r"Размещено проектов на бирже: (\d+)", buyer_block)
|
total_projects = re.search(r"Размещено проектов на бирже: (\d+)", buyer_block)
|
||||||
hired_percent = re.search(r"Нанято: (\d+%)", buyer_block)
|
hired_percent = re.search(r"Нанято: (\d+%)", buyer_block)
|
||||||
offers_count = re.search(r"Предложений:\s*(\d+)", informers_block)
|
offers_count = re.search(r"Предложений:\s*(\d+)", informers_block)
|
||||||
time_left = re.search(r"Осталось:\s*(.*?)(?:\n|$)", informers_block)
|
time_left = re.search(r"Осталось:\s*(.*?)(?:\n|$)", informers_block)
|
||||||
|
|
||||||
# Формируем итоговый объект
|
return {
|
||||||
data = {
|
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": clean(title),
|
"title": clean(title),
|
||||||
"description": description_raw.strip(),
|
"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 "н/д"
|
"time_left": clean(time_left.group(1)) if time_left else "н/д"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при парсинге {url}: {e}")
|
print(f"Ошибка парсинга проекта {url}: {e}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
#pages_to_scan = 2
|
|
||||||
|
|
||||||
#data = asyncio.run(get_kwork_projects(max_pages=pages_to_scan))
|
|
||||||
|
|
||||||
target_url = "https://kwork.ru/projects/3134701"
|
|
||||||
|
|
||||||
# Запуск парсера для одной ссылки
|
|
||||||
result = asyncio.run(get_project_details(target_url))
|
|
||||||
|
|
||||||
if result:
|
|
||||||
print("\n--- Данные проекта в формате JSON ---")
|
|
||||||
print(json.dumps(result, ensure_ascii=False, indent=4))
|
|
||||||
|
|
||||||
#print(f"\nВсего собрано проектов: {len(data)}")
|
|
||||||
#print(json.dumps(data, ensure_ascii=False, indent=4))
|
|
||||||
|
|||||||
89
scheduler.py
Normal file
89
scheduler.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import html
|
||||||
|
from aiogram import Bot
|
||||||
|
from database import Database
|
||||||
|
from kwork import get_kwork_projects, get_project_details
|
||||||
|
from ai import filter_vacancies_with_ai
|
||||||
|
|
||||||
|
class VacancyScanner:
|
||||||
|
def __init__(self, bot: Bot, db: Database):
|
||||||
|
self.bot = bot
|
||||||
|
self.db = db
|
||||||
|
self.current_page = 1
|
||||||
|
self.last_reset_time = time.time()
|
||||||
|
|
||||||
|
async def start_scanning(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_reset_time >= 3600:
|
||||||
|
print("--- Прошел час: возврат на 1 страницу ---")
|
||||||
|
self.current_page = 1
|
||||||
|
self.last_reset_time = current_time
|
||||||
|
|
||||||
|
print(f"--- Сканирую страницу {self.current_page} ---")
|
||||||
|
summary_vacancies = await get_kwork_projects(start_page=self.current_page, end_page=self.current_page)
|
||||||
|
|
||||||
|
if not summary_vacancies:
|
||||||
|
print(f"Страница {self.current_page} пуста. Сброс на 1 стр.")
|
||||||
|
self.current_page = 1
|
||||||
|
else:
|
||||||
|
print(f"Найдено {len(summary_vacancies)} вакансий на странице.")
|
||||||
|
users = await self.db.get_all()
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
user_id = user[1]
|
||||||
|
if not user[2] or not user[3]:
|
||||||
|
print(f"Пропуск пользователя {user_id}: профиль не заполнен.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Отбираем новые вакансии
|
||||||
|
new_for_user = []
|
||||||
|
for v in summary_vacancies:
|
||||||
|
if not await self.db.is_vacancy_sent(user_id, v['url']):
|
||||||
|
new_for_user.append(v)
|
||||||
|
|
||||||
|
if not new_for_user:
|
||||||
|
print(f"Для пользователя {user_id} новых вакансий нет.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Отправка {len(new_for_user)} вакансий на анализ AI для юзера {user_id}...")
|
||||||
|
user_prefs = f"Сфера: {user[2]}, Стек: {user[3]}, Доп: {user[4]}"
|
||||||
|
|
||||||
|
filtered_vacancies = await filter_vacancies_with_ai(new_for_user, user_prefs)
|
||||||
|
print(f"AI отобрал {len(filtered_vacancies)} релевантных вакансий.")
|
||||||
|
|
||||||
|
for vac in filtered_vacancies:
|
||||||
|
# Получаем детали
|
||||||
|
full_info = await get_project_details(vac['url'])
|
||||||
|
description = full_info['description'] if full_info else vac.get('description', 'Нет описания')
|
||||||
|
|
||||||
|
title = html.escape(vac.get('title', 'Без названия'))
|
||||||
|
price = html.escape(vac.get('price', 'По договоренности'))
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"🎯 <b>Подходящий проект!</b>\n\n"
|
||||||
|
f"💼 <b>{title}</b>\n"
|
||||||
|
f"💰 <b>Бюджет:</b> {price}\n\n"
|
||||||
|
f"📝 <b>Описание:</b>\n{html.escape(description)}\n\n"
|
||||||
|
f"🔗 <a href='{vac['url']}'>Открыть на Kwork</a>"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(user_id, text, disable_web_page_preview=True)
|
||||||
|
await self.db.mark_vacancy_as_sent(user_id, vac['url'])
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки сообщения: {e}")
|
||||||
|
|
||||||
|
self.current_page += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Критическая ошибка сканера: {e}")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
wait_time = random.uniform(150, 240)
|
||||||
|
print(f"Цикл завершен. Ожидание {wait_time/60:.2f} мин...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
Reference in New Issue
Block a user