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 logging
|
||||
import sys
|
||||
from os import getenv
|
||||
|
||||
from aiogram import Bot, Dispatcher, html, F
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.types import Message, InlineKeyboardButton, CallbackQuery
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from scheduler import VacancyScanner
|
||||
from config import TOKEN
|
||||
from database import Database
|
||||
|
||||
load_dotenv()
|
||||
TOKEN = getenv("BOT_TOKEN")
|
||||
dp = Dispatcher()
|
||||
db = Database("users.db")
|
||||
|
||||
# Добавили состояние для ручного ввода сферы
|
||||
class Registration(StatesGroup):
|
||||
waiting_for_sphere = State()
|
||||
waiting_for_custom_sphere = State() # <-- Новое состояние
|
||||
waiting_for_language = State()
|
||||
waiting_for_preferences = State()
|
||||
|
||||
@dp.message(CommandStart())
|
||||
async def command_start_handler(message: Message) -> None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="✅ Подписаться", callback_data="subscribe"))
|
||||
builder.row(InlineKeyboardButton(text="📄 Читать оферту", url="https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28"))
|
||||
|
||||
text = (
|
||||
"<b>👋 Привет! Я твой персональный агент по Kwork.</b>\n\n"
|
||||
"💻 ⚠️ <b>ВАЖНО:</b> Этот бот предназначен <b>исключительно для IT-специалистов</b>.\n\n"
|
||||
"🔍 Я мониторю биржу 24/7 и мгновенно присылаю тебе свежие заказы.\n\n"
|
||||
"Нажимая кнопку «Подписаться», вы принимаете условия "
|
||||
"<a href='https://telegra.ph/Polzovatelskoe-soglashenie-i-Oferta-qwork-parse-bot-03-28'>публичной оферты</a>."
|
||||
)
|
||||
|
||||
await message.answer(text, reply_markup=builder.as_markup(), disable_web_page_preview=True)
|
||||
|
||||
@dp.message(Command("profile"))
|
||||
async def show_profile(message: Message):
|
||||
user_data = await db.get_user(message.from_user.id)
|
||||
|
||||
if not user_data or user_data[0] is None:
|
||||
await message.answer("⚠️ Твой профиль еще не настроен. Нажми /start, чтобы начать.")
|
||||
return
|
||||
|
||||
sphere, lang, prefs = user_data
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="📝 Редактировать профиль", callback_data="subscribe")) # Используем тот же callback
|
||||
|
||||
text = (
|
||||
"<b>👤 Твой профиль IT-специалиста:</b>\n\n"
|
||||
f"<b>🌐 Сфера:</b> {sphere}\n"
|
||||
f"<b>🛠 Стек:</b> {lang}\n"
|
||||
f"<b>⚙️ Предпочтения:</b> {prefs}\n\n"
|
||||
"Хочешь что-то изменить? Нажми кнопку ниже."
|
||||
)
|
||||
|
||||
await message.answer(text, reply_markup=builder.as_markup())
|
||||
|
||||
@dp.callback_query(F.data == "subscribe")
|
||||
async def subscribe_handler(callback: CallbackQuery, state: FSMContext):
|
||||
await db.add_user(callback.from_user.id)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
spheres = ["Backend", "Frontend", "Mobile", "DevOps", "Design", "QA"]
|
||||
for sphere in spheres:
|
||||
builder.add(InlineKeyboardButton(text=sphere, callback_data=f"sphere_{sphere}"))
|
||||
|
||||
# Добавляем кнопку своего варианта
|
||||
builder.row(InlineKeyboardButton(text="⌨️ Свой вариант", callback_data="sphere_other"))
|
||||
builder.adjust(2)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"Отлично! Давай настроим профиль.\n<b>В какой сфере IT ты работаешь?</b>",
|
||||
reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(Registration.waiting_for_sphere)
|
||||
await callback.answer()
|
||||
|
||||
@dp.callback_query(Registration.waiting_for_sphere)
|
||||
async def sphere_chosen(callback: CallbackQuery, state: FSMContext):
|
||||
sphere = callback.data.split("_")[1]
|
||||
|
||||
if sphere == "other":
|
||||
await callback.message.edit_text("Напиши свою сферу деятельности (например: <i>Data Science</i> или <i>GameDev</i>):")
|
||||
await state.set_state(Registration.waiting_for_custom_sphere)
|
||||
else:
|
||||
await state.update_data(sphere=sphere)
|
||||
await callback.message.edit_text(
|
||||
f"Выбрано: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
|
||||
)
|
||||
await state.set_state(Registration.waiting_for_language)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Обработчик для текстового ввода своей сферы
|
||||
@dp.message(Registration.waiting_for_custom_sphere)
|
||||
async def custom_sphere_input(message: Message, state: FSMContext):
|
||||
sphere = message.text
|
||||
await state.update_data(sphere=sphere)
|
||||
await message.answer(
|
||||
f"Принято: <b>{sphere}</b>\n\nКакой основной <b>язык программирования</b> или стек технологий используешь?"
|
||||
)
|
||||
await state.set_state(Registration.waiting_for_language)
|
||||
|
||||
@dp.message(Registration.waiting_for_language)
|
||||
async def language_chosen(message: Message, state: FSMContext):
|
||||
await state.update_data(language=message.text)
|
||||
|
||||
# Создаем кнопку для пропуска
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏩ Пропустить", callback_data="skip_preferences"))
|
||||
|
||||
await message.answer(
|
||||
"Принято! И последнее: напиши свои <b>предпочтения по заказам</b> (фильтры).\n"
|
||||
"Например: 'чек от 5000р' или 'без правок'.\n\n"
|
||||
"<i>Если не хочешь заполнять сейчас, нажми кнопку ниже.</i>",
|
||||
reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(Registration.waiting_for_preferences)
|
||||
|
||||
@dp.callback_query(Registration.waiting_for_preferences, F.data == "skip_preferences")
|
||||
async def skip_preferences(callback: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(preferences="Не указано") # Устанавливаем значение по умолчанию
|
||||
user_data = await state.get_data()
|
||||
|
||||
await db.update_user_data(callback.from_user.id, user_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Профиль успешно настроен!</b> (Фильтры пропущены)\n\n"
|
||||
f"Сфера: {user_data['sphere']}\n"
|
||||
f"Стек: {user_data['language']}\n"
|
||||
f"Фильтры: {user_data['preferences']}"
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
async def on_startup():
|
||||
await db.create_tables()
|
||||
print("Database ready")
|
||||
from handlers import routers
|
||||
|
||||
async def main() -> None:
|
||||
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
dp.startup.register(on_startup)
|
||||
dp = Dispatcher()
|
||||
|
||||
# Регистрация всех роутеров
|
||||
dp.include_routers(*routers)
|
||||
|
||||
# Инициализация БД
|
||||
db = Database("users.db")
|
||||
await db.create_tables()
|
||||
|
||||
scanner = VacancyScanner(bot, db)
|
||||
asyncio.create_task(scanner.start_scanning())
|
||||
|
||||
await dp.start_polling(bot)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
7
config.py
Normal file
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
|
||||
)
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sent_vacancies (
|
||||
user_id INTEGER,
|
||||
vacancy_url TEXT,
|
||||
PRIMARY KEY (user_id, vacancy_url)
|
||||
)
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
async def is_vacancy_sent(self, user_id: int, url: str) -> bool:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT 1 FROM sent_vacancies WHERE user_id = ? AND vacancy_url = ?",
|
||||
(user_id, url)
|
||||
) as cursor:
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
async def mark_vacancy_as_sent(self, user_id: int, url: str):
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO sent_vacancies (user_id, vacancy_url) VALUES (?, ?)",
|
||||
(user_id, url)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def add_user(self, user_id: int):
|
||||
@@ -44,3 +67,18 @@ class Database:
|
||||
(user_id,)
|
||||
) as cursor:
|
||||
return await cursor.fetchone()
|
||||
|
||||
async def count_users(self) -> int:
|
||||
#Возвращает количество зарегистрированных пользователей
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute("SELECT COUNT(*) FROM users") as cursor:
|
||||
result = await cursor.fetchone()
|
||||
return result[0] if result else 0
|
||||
async def clear_sent_vacancies(self, user_id: int):
|
||||
#Очищает историю отправленных вакансий для пользователя
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"DELETE FROM sent_vacancies WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
5
handlers/__init__.py
Normal file
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 re
|
||||
import random
|
||||
from typing import Awaitable, Callable, Optional
|
||||
from typing import Awaitable, Callable, Optional, Tuple
|
||||
import time
|
||||
|
||||
from playwright.async_api import Locator, Page, async_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
# --- Константы ---
|
||||
BASE_URL = "https://kwork.ru"
|
||||
PROJECTS_URL = f"{BASE_URL}/projects?c=11"
|
||||
|
||||
@@ -18,50 +18,101 @@ USER_AGENT = (
|
||||
|
||||
VIEWPORT = {"width": 1920, "height": 1080}
|
||||
|
||||
# Задержка между запросами к страницам (в секундах), чтобы избежать блокировок
|
||||
PAGE_LOAD_DELAY = (3, 6)
|
||||
# Настройки задержек (мин, макс) в секундах
|
||||
DELAY_PAGE = (150, 240) # 2.5 - 4 минуты для страниц списка
|
||||
DELAY_PROJECT = (20, 45) # 20 - 45 секунд для детальных страниц (приоритетные)
|
||||
|
||||
Project = dict[str, str]
|
||||
Extractor = Callable[[Page, Locator], Awaitable[Optional[Project]]]
|
||||
|
||||
# --- Вспомогательные функции ---
|
||||
class RequestThrottler:
|
||||
def __init__(self):
|
||||
self._queue = []
|
||||
self._last_call_time = 0
|
||||
self._new_item_event = asyncio.Event()
|
||||
self._worker_task = None
|
||||
|
||||
#Умный фоновый воркер, умеющий прерывать долгое ожидание ради приоритетных задач
|
||||
async def _worker(self):
|
||||
while True:
|
||||
if not self._queue:
|
||||
self._new_item_event.clear()
|
||||
await self._new_item_event.wait()
|
||||
continue
|
||||
|
||||
# Сортируем: сначала проекты (priority=0), потом страницы (priority=1), затем по времени
|
||||
self._queue.sort(key=lambda x: (x["priority"], x["time"]))
|
||||
current_task = self._queue[0]
|
||||
|
||||
if "required_delay" not in current_task:
|
||||
min_d, max_d = current_task["delay"]
|
||||
current_task["required_delay"] = random.uniform(min_d, max_d)
|
||||
|
||||
required_delay = current_task["required_delay"]
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - self._last_call_time
|
||||
wait_time = required_delay - elapsed
|
||||
|
||||
if wait_time > 0:
|
||||
type_str = "ПРОЕКТ" if current_task["priority"] == 0 else "СПИСОК"
|
||||
print(f"[Throttler] Тип:{type_str}. Ждем {wait_time:.2f} сек... (в очереди: {len(self._queue)})")
|
||||
|
||||
self._new_item_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._new_item_event.wait(), timeout=wait_time)
|
||||
continue
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
self._queue.remove(current_task)
|
||||
self._last_call_time = time.time()
|
||||
|
||||
if not current_task["fut"].done():
|
||||
current_task["fut"].set_result(True)
|
||||
|
||||
|
||||
#Встает в очередь на выполнение запроса.
|
||||
#priority: 0 для высокого (проекты), 1 для низкого (страницы).
|
||||
async def wait(self, priority: int, delay_range: Tuple[float, float]):
|
||||
if self._worker_task is None:
|
||||
self._worker_task = asyncio.create_task(self._worker())
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = loop.create_future()
|
||||
|
||||
item = {
|
||||
"priority": priority,
|
||||
"delay": delay_range,
|
||||
"fut": fut,
|
||||
"time": time.time()
|
||||
}
|
||||
self._queue.append(item)
|
||||
|
||||
self._new_item_event.set()
|
||||
|
||||
await fut
|
||||
|
||||
throttler = RequestThrottler()
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Очищает текст от лишних пробельных символов и переносов строк."""
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
"""Извлекает только первую строку из переданного текста."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
return text.splitlines()[0].strip()
|
||||
|
||||
return text.splitlines()[0].strip() if text else ""
|
||||
|
||||
def normalize_url(href: str) -> str:
|
||||
"""Превращает относительную ссылку (напр. /projects/1) в полную (https://kwork.ru/...)."""
|
||||
return f"{BASE_URL}{href}" if href.startswith("/") else href
|
||||
|
||||
|
||||
async def safe_inner_text(locator: Locator, default: str = "") -> str:
|
||||
"""
|
||||
Безопасно извлекает внутренний текст элемента.
|
||||
Если элемент не найден или произошла ошибка, возвращает значение по умолчанию.
|
||||
"""
|
||||
try:
|
||||
text = await locator.inner_text(timeout=1500)
|
||||
text = text.replace("\xa0", " ")
|
||||
return text.strip()
|
||||
return text.replace("\xa0", " ").strip()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
async def first_text(root: Locator, selectors: list[str], default: str = "") -> str:
|
||||
"""
|
||||
Пробует по очереди список CSS-селекторов внутри корневого элемента.
|
||||
Возвращает очищенный текст первого найденного элемента.
|
||||
"""
|
||||
for selector in selectors:
|
||||
try:
|
||||
loc = root.locator(selector).first
|
||||
@@ -73,253 +124,138 @@ async def first_text(root: Locator, selectors: list[str], default: str = "") ->
|
||||
continue
|
||||
return default
|
||||
|
||||
|
||||
async def get_card_root(page: Page, href: str) -> Locator:
|
||||
"""
|
||||
Находит родительский контейнер (карточку проекта) на основе ссылки на проект.
|
||||
Использует поиск по XPath 'ancestor', чтобы подняться вверх по DOM-дереву.
|
||||
"""
|
||||
card = page.locator(
|
||||
f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card__top")][1]'
|
||||
)
|
||||
|
||||
if await card.count() > 0:
|
||||
return card.first
|
||||
|
||||
card = page.locator(
|
||||
f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card")][1]'
|
||||
)
|
||||
|
||||
if await card.count() > 0:
|
||||
return card.first
|
||||
|
||||
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card__top")][1]')
|
||||
if await card.count() > 0: return card.first
|
||||
|
||||
card = page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[contains(@class, "wants-card")][1]')
|
||||
if await card.count() > 0: return card.first
|
||||
|
||||
return page.locator(f'xpath=//a[@href="{href}"]/ancestor::div[1]').first
|
||||
|
||||
|
||||
async def extract_price(card: Locator) -> str:
|
||||
"""
|
||||
Извлекает стоимость проекта из карточки.
|
||||
Сначала ищет текст по ключевым фразам (бюджет), затем по CSS-классам цен.
|
||||
"""
|
||||
card_text = await safe_inner_text(card, "")
|
||||
card_text = normalize_text(card_text)
|
||||
|
||||
patterns = [
|
||||
r"(Желаемый бюджет:\s*до\s*[\d\s]+₽)",
|
||||
r"(Цена до:\s*[\d\s]+₽)",
|
||||
r"(Допустимый:\s*до\s*[\d\s]+₽)",
|
||||
]
|
||||
|
||||
found: list[str] = []
|
||||
card_text = normalize_text(await safe_inner_text(card, ""))
|
||||
patterns = [r"(Желаемый бюджет:\s*до\s*[\d\s]+₽)", r"(Цена до:\s*[\d\s]+₽)", r"(Допустимый:\s*до\s*[\d\s]+₽)"]
|
||||
|
||||
found = []
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, card_text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
value = normalize_text(match.group(1))
|
||||
if value and value not in found:
|
||||
found.append(value)
|
||||
val = normalize_text(match.group(1))
|
||||
if val not in found: found.append(val)
|
||||
|
||||
if found:
|
||||
return " | ".join(found)
|
||||
|
||||
primary = await first_text(
|
||||
card,
|
||||
[
|
||||
".wants-card__price",
|
||||
".wants-card__header-right-block .wants-card__price",
|
||||
"[class*='wants-card__price']",
|
||||
"[class*='price']",
|
||||
],
|
||||
"",
|
||||
)
|
||||
|
||||
higher = await first_text(
|
||||
card,
|
||||
[
|
||||
".wants-card__description-higher-price",
|
||||
"[class*='description-higher-price']",
|
||||
],
|
||||
"",
|
||||
)
|
||||
|
||||
parts = [part for part in [primary, higher] if part]
|
||||
if parts:
|
||||
return " | ".join(parts)
|
||||
|
||||
return "По договоренности"
|
||||
if found: return " | ".join(found)
|
||||
|
||||
primary = await first_text(card, [".wants-card__price", "[class*='wants-card__price']", "[class*='price']"])
|
||||
higher = await first_text(card, [".wants-card__description-higher-price", "[class*='description-higher-price']"])
|
||||
|
||||
parts = [p for p in [primary, higher] if p]
|
||||
return " | ".join(parts) if parts else "По договоренности"
|
||||
|
||||
async def extract_description(card: Locator) -> str:
|
||||
"""
|
||||
Извлекает описание проекта из карточки, удаляя лишние элементы управления
|
||||
('Показать полностью', 'Скрыть').
|
||||
"""
|
||||
description = await first_text(
|
||||
card,
|
||||
[
|
||||
".wants-card__description-text .overflow-hidden .d-inline",
|
||||
".wants-card__description-text .overflow-hidden",
|
||||
".wants-card__description-text",
|
||||
"[class*='description-text']",
|
||||
],
|
||||
"",
|
||||
)
|
||||
|
||||
description = description.replace("Показать полностью", "")
|
||||
description = description.replace("Скрыть", "")
|
||||
description = description.replace("\xa0", " ")
|
||||
description = normalize_text(description)
|
||||
|
||||
return first_line(description)
|
||||
|
||||
description = await first_text(card, [
|
||||
".wants-card__description-text .overflow-hidden .d-inline",
|
||||
".wants-card__description-text .overflow-hidden",
|
||||
".wants-card__description-text",
|
||||
"[class*='description-text']"
|
||||
])
|
||||
for word in ["Показать полностью", "Скрыть"]:
|
||||
description = description.replace(word, "")
|
||||
return first_line(normalize_text(description.replace("\xa0", " ")))
|
||||
|
||||
async def extract_kwork_project(page: Page, title_block: Locator) -> Optional[Project]:
|
||||
"""
|
||||
Функция-экстрактор: собирает все поля (заголовок, цена, ссылка, описание)
|
||||
для одного конкретного блока заголовка.
|
||||
"""
|
||||
link = title_block.locator("a").first
|
||||
if await link.count() == 0:
|
||||
return None
|
||||
if await link.count() == 0: return None
|
||||
|
||||
title_text = normalize_text(await safe_inner_text(link, ""))
|
||||
href = await link.get_attribute("href")
|
||||
if not href:
|
||||
return None
|
||||
if not href: return None
|
||||
|
||||
card = await get_card_root(page, href)
|
||||
|
||||
price = await extract_price(card)
|
||||
description = await extract_description(card)
|
||||
|
||||
return {
|
||||
"title": title_text,
|
||||
"price": price,
|
||||
"price": await extract_price(card),
|
||||
"url": normalize_url(href),
|
||||
"description": description,
|
||||
"description": await extract_description(card),
|
||||
}
|
||||
|
||||
|
||||
# --- Основная логика скрапинга ---
|
||||
|
||||
async def get_kwork_projects(max_pages: int = 1) -> list[Project]:
|
||||
"""
|
||||
Основная функция запуска браузера и обхода страниц.
|
||||
:param max_pages: Сколько страниц нужно просмотреть.
|
||||
"""
|
||||
#Парсинг списка (НИЗКИЙ приоритет, ДЛИННАЯ задержка)
|
||||
async def get_kwork_projects(start_page: int = 1, end_page: int = 1) -> list[Project]:
|
||||
all_results: list[Project] = []
|
||||
if start_page > end_page: start_page = end_page
|
||||
|
||||
async with Stealth().use_async(async_playwright()) as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
user_agent=USER_AGENT,
|
||||
viewport=VIEWPORT,
|
||||
)
|
||||
context = await browser.new_context(user_agent=USER_AGENT, viewport=VIEWPORT)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
for current_page in range(1, max_pages + 1):
|
||||
# Формируем URL с учетом номера страницы
|
||||
for current_page in range(start_page, end_page + 1):
|
||||
# Ожидание в очереди (Приоритет 1 = Низкий)
|
||||
await throttler.wait(priority=1, delay_range=DELAY_PAGE)
|
||||
|
||||
url = f"{PROJECTS_URL}&page={current_page}"
|
||||
print(f"Загрузка страницы {current_page}: {url}")
|
||||
|
||||
print(f"[Листинг] Загрузка страницы {current_page}...")
|
||||
await page.goto(url, wait_until="networkidle")
|
||||
|
||||
# Дополнительная задержка для рендеринга JS элементов
|
||||
await asyncio.sleep(2)
|
||||
|
||||
items = await page.locator(".wants-card__header-title").all()
|
||||
print(f"Найдено проектов на странице: {len(items)}")
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
data = await extract_kwork_project(page, item)
|
||||
if data:
|
||||
all_results.append(data)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при парсинге карточки: {e}")
|
||||
continue
|
||||
|
||||
# Ограничение частоты запросов (Rate Limiting)
|
||||
if current_page < max_pages:
|
||||
delay = random.uniform(*PAGE_LOAD_DELAY)
|
||||
print(f"Ожидаем {delay:.2f} сек. перед следующей страницей...")
|
||||
await asyncio.sleep(delay)
|
||||
if data: all_results.append(data)
|
||||
except: continue
|
||||
|
||||
return all_results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Произошла критическая ошибка: {e}")
|
||||
print(f"Ошибка скрапинга списка: {e}")
|
||||
return all_results
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def clean(text: str) -> str:
|
||||
"""Очищает текст от мусора, неразрывных пробелов и лишних пустот."""
|
||||
if not text: return ""
|
||||
text = text.replace("\xa0", " ")
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
||||
|
||||
async def get_text(page: Page, selector: str) -> str:
|
||||
"""Извлекает текст из элемента, если он существует."""
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
if await element.count() > 0:
|
||||
return await element.inner_text(timeout=3000)
|
||||
return ""
|
||||
except:
|
||||
return ""
|
||||
|
||||
return await element.inner_text(timeout=3000) if await element.count() > 0 else ""
|
||||
except: return ""
|
||||
|
||||
#Парсинг деталей вакансии (ВЫСОКИЙ приоритет, КОРОТКАЯ задержка)
|
||||
async def get_project_details(url: str) -> Optional[dict]:
|
||||
"""
|
||||
Открывает страницу проекта, парсит HTML и возвращает структурированные данные.
|
||||
"""
|
||||
|
||||
await throttler.wait(priority=0, delay_range=DELAY_PROJECT)
|
||||
|
||||
async with Stealth().use_async(async_playwright()) as p:
|
||||
# Запускаем браузер
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context(user_agent=USER_AGENT)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
print(f"Парсим проект: {url}")
|
||||
print(f"[Проект] Парсим детали: {url}")
|
||||
await page.goto(url, wait_until="networkidle")
|
||||
# Ждем немного, чтобы JS отработал до конца
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 1. Извлекаем заголовок
|
||||
title = await get_text(page, "h1.wants-card__header-title")
|
||||
|
||||
# 2. Извлекаем описание (сохраняем структуру)
|
||||
description_raw = await get_text(page, ".wants-card__description-text")
|
||||
|
||||
# 3. Бюджет (Желаемый и Допустимый)
|
||||
# Извлекаем только цифры через регулярку
|
||||
price_desired_raw = await get_text(page, ".wants-card__price")
|
||||
price_max_raw = await get_text(page, ".wants-card__description-higher-price")
|
||||
|
||||
# 4. Информация о заказчике
|
||||
buyer_block = await get_text(page, ".want-payer-statistic")
|
||||
buyer_name = await get_text(page, ".want-payer-statistic a")
|
||||
|
||||
# 5. Статистика проекта (предложения и время)
|
||||
informers_block = await get_text(page, ".want-card__informers")
|
||||
|
||||
# --- Парсинг данных через регулярные выражения для точности ---
|
||||
|
||||
# Чистим цену: оставляем только цифры
|
||||
def extract_digits(text):
|
||||
digits = "".join(re.findall(r'\d', text))
|
||||
return f"{digits} ₽" if digits else "По договоренности"
|
||||
|
||||
# Вырезаем статистику из текста блоков
|
||||
total_projects = re.search(r"Размещено проектов на бирже: (\d+)", buyer_block)
|
||||
hired_percent = re.search(r"Нанято: (\d+%)", buyer_block)
|
||||
offers_count = re.search(r"Предложений:\s*(\d+)", informers_block)
|
||||
time_left = re.search(r"Осталось:\s*(.*?)(?:\n|$)", informers_block)
|
||||
|
||||
# Формируем итоговый объект
|
||||
data = {
|
||||
return {
|
||||
"url": url,
|
||||
"title": clean(title),
|
||||
"description": description_raw.strip(),
|
||||
@@ -337,29 +273,8 @@ async def get_project_details(url: str) -> Optional[dict]:
|
||||
"time_left": clean(time_left.group(1)) if time_left else "н/д"
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при парсинге {url}: {e}")
|
||||
print(f"Ошибка парсинга проекта {url}: {e}")
|
||||
return None
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
#pages_to_scan = 2
|
||||
|
||||
#data = asyncio.run(get_kwork_projects(max_pages=pages_to_scan))
|
||||
|
||||
target_url = "https://kwork.ru/projects/3134701"
|
||||
|
||||
# Запуск парсера для одной ссылки
|
||||
result = asyncio.run(get_project_details(target_url))
|
||||
|
||||
if result:
|
||||
print("\n--- Данные проекта в формате JSON ---")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=4))
|
||||
|
||||
#print(f"\nВсего собрано проектов: {len(data)}")
|
||||
#print(json.dumps(data, ensure_ascii=False, indent=4))
|
||||
|
||||
89
scheduler.py
Normal file
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