diff --git a/.gitignore b/.gitignore index 767e7bf..9675935 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,57 @@ -.env +# Python __pycache__/ -*.pyc -users.db +*.py[cod] +*$py.class +*.so +.Python venv/ -cabinet_service/media/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ + +# База данных +*.db +*.sqlite +*.sqlite3 + +# Медиа файлы +media/ +*.jpg +*.jpeg +*.png +*.gif +*.webp + +# Логи +*.log +logs/ + +# Переменные окружения +.env +.env.local + +# IDE +.vscode/ .idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Секреты +secrets/ +*.key +*.pem + +# OpenWork +.opencode/ + +# Временные файлы +*.tmp +*.bak +*.orig \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..210d62b --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# ИИ Пресс-секретарь + +AI-powered content generation and publishing platform for entrepreneurs and community organizations. + +## 🚀 Быстрый старт + +### Локальный запуск (Windows) + +```powershell +# 1. Клонировать репозиторий +git clone https://github.com/username/pressa.git +cd pressa + +# 2. Создать виртуальное окружение +python -m venv venv +venv\Scripts\activate + +# 3. Установить зависимости +pip install -r requirements.txt + +# 4. Создать .env файл +# Скопируй config.py в .env и заполни переменные + +# 5. Запустить сервер +python -m uvicorn cabinet_service.app:app --host 127.0.0.1 --port 8001 +``` + +Открой в браузере: http://127.0.0.1:8001 + +### Деплой на сервер (Linux) + +Смотри папку `deploy/`: + +```bash +cd deploy +chmod +x *.sh +./install.sh +``` + +Или пошагово: +1. `./deploy.sh` — установка приложения +2. `./nginx-setup.sh` — настройка веб-сервера +3. `./ssl-setup.sh` — SSL сертификат + +## 📋 Требования + +- Python 3.12+ +- SQLite (встроен в Python) +- Nginx (для production) +- Доступ к AI API (PiAPI, OpenAI, OpenRouter) + +## 🔧 Переменные окружения + +Создай файл `.env` в корне проекта: + +```env +BOT_TOKEN=your_telegram_bot_token +PIAPI_API_KEY=your_piapi_key +PIAPI_MODEL=gpt-4o +PROXY_URL=http://user:pass@host:port +CABINET_URL=https://your-domain.com +``` + +## 📁 Структура проекта + +``` +pressa/ +├── cabinet_service/ # Веб-приложение (FastAPI) +│ ├── app.py # Основной файл +│ ├── templates/ # HTML шаблоны +│ ├── static/ # CSS/JS +│ └── media/ # Загруженные файлы +├── ai_core/ # AI модули +│ ├── neural_network.py # Генерация текста +│ ├── image_generator.py # Генерация изображений +│ ├── social_analyzer.py # Анализ соцсетей +│ └── telegram_publisher.py # Публикация в Telegram +├── deploy/ # Скрипты деплоя +├── config.py # Конфигурация +├── requirements.txt # Зависимости +└── README.md # Этот файл +``` + +## 🎯 Возможности + +- ✍️ Генерация постов с помощью AI +- 🎨 Генерация изображений +- 📅 Контент-план +- 📊 Медиа-образ (бренд-бук) +- 📤 Публикация в Telegram +- 🤖 Чат с AI для редактирования постов +- 📱 Адаптивный дизайн + +## 🛠️ Технологии + +- **Backend:** Python, FastAPI, SQLite +- **Frontend:** HTML, CSS, JavaScript (vanilla) +- **AI:** OpenAI GPT-4, PiAPI, OpenRouter +- **Deploy:** Gunicorn, Nginx, systemd + +## 📄 Лицензия + +MIT \ No newline at end of file diff --git a/_run_cabinet.py b/_run_cabinet.py new file mode 100644 index 0000000..f70d88b --- /dev/null +++ b/_run_cabinet.py @@ -0,0 +1,4 @@ +from cabinet_service.runner import run + +if __name__ == "__main__": + run() diff --git a/ai_core/image_generator.py b/ai_core/image_generator.py index 77ee336..78f18f9 100644 --- a/ai_core/image_generator.py +++ b/ai_core/image_generator.py @@ -7,13 +7,32 @@ from config import PIAPI_API_KEY, PIAPI_IMAGE_MODEL, get_proxies PIAPI_IMAGE_URL = "https://api.piapi.ai/v1/images/generations" -# Доступные размеры (gpt-image-1) -IMAGE_SIZES = { - "square": ("1024x1024", "Квадрат 1024x1024"), - "wide": ("1536x1024", "Горизонтальный 1536x1024"), - "tall": ("1024x1536", "Вертикальный 1024x1536"), +# Размеры по моделям +# gpt-image-1: 1024x1024, 1536x1024, 1024x1536 +# dall-e-3: 1024x1024, 1792x1024, 1024x1792 +_MODEL_SIZES = { + "gpt-image-1": { + "square": ("1024x1024", "Квадрат 1024×1024"), + "wide": ("1536x1024", "Горизонтальный 1536×1024"), + "tall": ("1024x1536", "Вертикальный 1024×1536"), + }, + "dall-e-3": { + "square": ("1024x1024", "Квадрат 1024×1024"), + "wide": ("1792x1024", "Горизонтальный 1792×1024"), + "tall": ("1024x1792", "Вертикальный 1024×1792"), + }, } +# Размеры по умолчанию (для неизвестных моделей — как у gpt-image-1) +DEFAULT_SIZES = { + "square": ("1024x1024", "Квадрат 1024×1024"), + "wide": ("1536x1024", "Горизонтальный 1536×1024"), + "tall": ("1024x1536", "Вертикальный 1024×1536"), +} + +# Обратная совместимость: IMAGE_SIZES для шаблонов +IMAGE_SIZES = DEFAULT_SIZES + # Пресеты стилей - дописываются к промпту STYLE_PRESETS = { "none": ("Без стиля", ""), @@ -24,6 +43,132 @@ STYLE_PRESETS = { "flat": ("Плоская графика", ", flat vector illustration, bold shapes, social media style"), } +# Шаблоны изображений — готовые пресеты для быстрой генерации +# prompt — для свободной генерации (подставляется в textarea) +# prompt_template — для генерации к посту ({post_text} заменяется на текст поста) +IMAGE_TEMPLATES = [ + { + "key": "product", + "name": "Товар", + "icon": "🛍️", + "prompt": "Professional product photo on a clean studio background, soft gradient lighting, high-end commercial photography", + "prompt_template": "Professional product photo on a clean studio background, soft gradient lighting, high-end commercial photography. Product related to: {post_text}", + "style": "photo", + "size": "square", + "hint": "Фото товара на чистом фоне — для карточек и каталогов", + }, + { + "key": "lifestyle", + "name": "Лайфстайл", + "icon": "📸", + "prompt": "Natural lifestyle scene, warm tones, candid feel, natural light, everyday setting", + "prompt_template": "Natural lifestyle scene related to: {post_text}. A person in a cozy everyday setting, warm tones, candid feel, natural light", + "style": "photo", + "size": "wide", + "hint": "Живая сцена из жизни — для постов и сторис", + }, + { + "key": "food", + "name": "Еда", + "icon": "🍕", + "prompt": "Beautiful food photography, overhead flat lay or 45-degree angle, garnished plate, rustic wooden table, soft natural light, appetizing colors", + "prompt_template": "Beautiful food photography of {post_text}, overhead flat lay or 45-degree angle, garnished plate, rustic wooden table, soft natural light, appetizing colors", + "style": "photo", + "size": "square", + "hint": "Вкусная еда — для кафе, ресторанов, рецептов", + }, + { + "key": "business", + "name": "Бизнес", + "icon": "🏢", + "prompt": "Professional business setting, modern office or meeting room, people in smart casual clothing, clean and bright atmosphere", + "prompt_template": "Professional business setting illustrating: {post_text}. Modern office, people in smart casual, clean bright atmosphere", + "style": "photo", + "size": "wide", + "hint": "Деловая атмосфера — для LinkedIn и презентаций", + }, + { + "key": "abstract", + "name": "Абстрактный фон", + "icon": "🎨", + "prompt": "Abstract gradient background with flowing organic shapes, vibrant modern color palette, smooth transitions, depth effect", + "prompt_template": "Abstract gradient background with flowing organic shapes and stylized text overlay reading: {post_text}. Vibrant modern color palette, smooth transitions, depth effect", + "style": "none", + "size": "square", + "hint": "Красивый фон — для цитат, анонсов, обложек", + }, + { + "key": "portrait", + "name": "Портрет", + "icon": "👤", + "prompt": "Professional portrait photo, soft studio lighting, blurred background, sharp focus on face, natural skin tones, confident expression", + "prompt_template": "Professional portrait photo of a person who {post_text}. Soft studio lighting, blurred background, sharp focus, natural skin tones, confident expression", + "style": "photo", + "size": "square", + "hint": "Портрет или аватар — для профилей и «О нас»", + }, + { + "key": "infographic", + "name": "Инфографика", + "icon": "📊", + "prompt": "Clean infographic layout with icons, charts, and data visualization on white background, modern flat design, bold readable typography", + "prompt_template": "Clean infographic design with the following text and data clearly rendered on it: {post_text}. White background, modern flat design, icons and charts, bold readable typography — the text from the post MUST be visible and legible on the image", + "style": "flat", + "size": "tall", + "hint": "Визуализация данных — для статистик и гайдов", + }, + { + "key": "banner", + "name": "Рекламный баннер", + "icon": "🎯", + "prompt": "Eye-catching advertising banner with bold typography, vibrant colors, modern promotional design, call to action", + "prompt_template": "Eye-catching advertising banner with the following text prominently displayed: {post_text}. Vibrant colors, modern promotional design, the text must be the main focus of the image, bold readable typography", + "style": "none", + "size": "wide", + "hint": "Баннер для рекламы — с текстом поста", + }, + { + "key": "nature", + "name": "Природа", + "icon": "🌄", + "prompt": "Breathtaking nature landscape, golden hour lighting, vivid colors, dramatic sky, professional landscape photography", + "prompt_template": "Breathtaking nature landscape illustrating the theme: {post_text}. Golden hour lighting, vivid colors, dramatic sky, professional landscape photography", + "style": "photo", + "size": "wide", + "hint": "Пейзажи и природа — для мотивации и фона", + }, + { + "key": "minimal", + "name": "Минимализм", + "icon": "✨", + "prompt": "Minimalist composition, single focal point, lots of white space, clean geometric shapes, soft muted colors, elegant simplicity", + "prompt_template": "Minimalist design with the following text as the centerpiece: {post_text}. Clean white background, elegant typography, lots of breathing space, soft muted accent color", + "style": "minimal", + "size": "square", + "hint": "Чистый минимализм — для премиальных брендов", + }, + { + "key": "story", + "name": "Сторис", + "icon": "📱", + "prompt": "Vertical social media story, gradient background, centered composition, modern aesthetic, bold colors", + "prompt_template": "Vertical social media story with the following text as the main element: {post_text}. Gradient background, centered composition, modern aesthetic, bold colors, the text must be clearly readable", + "style": "none", + "size": "tall", + "hint": "Вертикальный формат — для Instagram/TikTok сторис", + }, + { + "key": "before_after", + "name": "До / После", + "icon": "🔄", + "prompt": "Split composition before/after comparison, left side 'before', right side 'after', clean dividing line, matching lighting", + "prompt_template": "Split composition before/after comparison related to: {post_text}. Left side shows 'before' state, right side shows 'after' transformation, clean dividing line, matching lighting", + "style": "photo", + "size": "wide", + "hint": "Сравнение до/после — для кейсов и результатов", + }, +] + def detect_ext(data: bytes) -> str: """Определяет расширение файла по magic-байтам.""" @@ -38,24 +183,56 @@ def detect_ext(data: bytes) -> str: return ".png" -class ImageGenerator: - """Генерация изображений через PiAPI (OpenAI-совместимый /images/generations).""" +def get_sizes_for_model(model: str) -> dict: + """Возвращает доступные размеры для конкретной модели.""" + if not model: + return DEFAULT_SIZES + # Точное совпадение + if model in _MODEL_SIZES: + return _MODEL_SIZES[model] + # Частичное совпадение (напр. openai/dall-e-3) + for key, sizes in _MODEL_SIZES.items(): + if key in model: + return sizes + return DEFAULT_SIZES - def __init__(self, model: str | None = None): - self.model = model or PIAPI_IMAGE_MODEL - self.proxies = get_proxies() + +class ImageGenerator: + """Генерация изображений через OpenAI-совместимый /images/generations.""" + + def __init__(self, model=None, provider_settings=None): + """provider_settings — опциональный dict: + api_key, base_url, image_model + Если не передан — используются глобальные настройки PiAPI.""" + if provider_settings and provider_settings.get("api_key"): + self.api_key = provider_settings["api_key"] + base = provider_settings.get("base_url") or "https://api.piapi.ai/v1" + self.image_url = base.rstrip("/") + "/images/generations" + self.model = model or provider_settings.get("image_model") or PIAPI_IMAGE_MODEL + self.provider_type = provider_settings.get("provider_type", "custom") + # Прокси для всех провайдеров (доступ из России к зарубежным API) + self.proxies = get_proxies() + else: + self.api_key = PIAPI_API_KEY + self.image_url = PIAPI_IMAGE_URL + self.model = model or PIAPI_IMAGE_MODEL + self.provider_type = "piapi" + self.proxies = get_proxies() def generate(self, prompt: str, size: str = "square") -> bytes: """Генерирует картинку и возвращает её байты. Поднимает исключение при ошибке.""" - if size not in IMAGE_SIZES: + sizes = get_sizes_for_model(self.model) + if size not in sizes: size = "square" - size_value = IMAGE_SIZES[size][0] + size_value = sizes[size][0] + # DALL-E 3 больше не поддерживается OpenAI — используем gpt-image-1 + # response_format и quality не передаём (gpt-image-1 возвращает b64_json по умолчанию) response = requests.post( - PIAPI_IMAGE_URL, + self.image_url, headers={ "Content-Type": "application/json", - "Authorization": f"Bearer {PIAPI_API_KEY}", + "Authorization": f"Bearer {self.api_key}", }, json={ "model": self.model, @@ -87,3 +264,93 @@ class ImageGenerator: if style in STYLE_PRESETS: return prompt.strip() + STYLE_PRESETS[style][1] return prompt.strip() + + def generate_with_reference(self, prompt: str, reference_image_bytes: bytes, size: str = "square", chat_settings=None) -> bytes: + """Генерирует изображение на основе промпта + исходного изображения. + Пробует: 1) text-провайдер chat/completions, 2) image-провайдер chat/completions, 3) fallback без картинки. + """ + sizes = get_sizes_for_model(self.model) + if size not in sizes: + size = "square" + size_value = sizes[size][0] + + # Кодируем исходное изображение в base64 + img_b64 = base64.b64encode(reference_image_bytes).decode("utf-8") + mime = "image/png" + if reference_image_bytes[:2] == b"\xff\xd8": + mime = "image/jpeg" + elif reference_image_bytes[:4] == b"RIFF": + mime = "image/webp" + + # Список провайдеров для попытки chat/completions с image input + candidates = [] + if chat_settings and chat_settings.get("api_key"): + candidates.append((chat_settings["api_key"], chat_settings.get("base_url", "https://api.openai.com/v1"), "text")) + # image-провайдер тоже может уметь chat/completions (PiAPI/OpenAI) + if self.api_key and (not chat_settings or self.api_key != chat_settings.get("api_key")): + candidates.append((self.api_key, self.image_url.rsplit("/images/generations", 1)[0], "image")) + # Fallback: default OpenAI + if not candidates: + candidates.append((self.api_key, "https://api.openai.com/v1", "default")) + + last_error = None + for api_key, base_url, label in candidates: + chat_url = base_url.rstrip("/") + "/chat/completions" + print(f"[ImageGenerator] reference image -> {label} provider: {chat_url}") + try: + response = requests.post( + chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + json={ + "model": self.model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{img_b64}"} + } + ] + } + ], + "max_tokens": 4096, + }, + proxies=self.proxies, + timeout=240, + ) + if response.status_code >= 400: + last_error = f"{response.status_code} {response.reason}" + print(f"[ImageGenerator] {label} провайдер вернул {last_error}, пробуем следующий") + continue + + resp_json = response.json() + msg = resp_json.get("choices", [{}])[0].get("message", {}) + content = msg.get("content", "") + + # Парсим картинку из ответа + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "image_url": + url = part.get("image_url", {}).get("url", "") + if url.startswith("data:"): + return base64.b64decode(url.split(",", 1)[1]) + if isinstance(content, str) and len(content) > 1000: + try: + return base64.b64decode(content) + except Exception: + pass + + print(f"[ImageGenerator] {label} провайдер не вернул картинку") + last_error = "no image in response" + except Exception as e: + last_error = str(e) + print(f"[ImageGenerator] {label} провайдер ошибка: {last_error}") + + # Все провайдеры не сработали — fallback без картинки + print(f"[ImageGenerator] ни один провайдер не поддержал image input, генерируем без референса") + return self.generate(prompt, size) diff --git a/ai_core/neural_network.py b/ai_core/neural_network.py index ef437e2..2820bd8 100644 --- a/ai_core/neural_network.py +++ b/ai_core/neural_network.py @@ -6,20 +6,45 @@ from config import PIAPI_API_KEY, PIAPI_MODEL, get_proxies PIAPI_URL = "https://api.piapi.ai/v1/chat/completions" + class ContentGenerator: - def __init__(self): - """Инициализация генератора контента""" + def __init__(self, provider_settings=None): + """Инициализация генератора контента. + provider_settings — опциональный dict с ключами: + provider_type, api_key, base_url, text_model + Если не передан — используются глобальные настройки PiAPI из config.""" print("🔄 Инициализация ContentGenerator...") - # Проверяем, есть ли ключ PiAPI - if PIAPI_API_KEY: + if provider_settings and provider_settings.get("api_key"): + # Per-user настройки + self.api_key = provider_settings["api_key"] + self.base_url = provider_settings.get("base_url") or PIAPI_URL.replace("/chat/completions", "") + self.model = provider_settings.get("text_model") or PIAPI_MODEL + self.use_piapi = True + self.chat_url = self.base_url.rstrip("/") + "/chat/completions" + self.provider_type = provider_settings.get("provider_type", "custom") + # Прокси используется для всех провайдеров (если настроен в .env) + # Это нужно для доступа из России к зарубежным API + self.proxies = get_proxies() + print(f"✅ Провайдер: {self.provider_type} | модель: {self.model} | прокси: {'да' if self.proxies else 'нет'}") + elif PIAPI_API_KEY: + # Глобальные настройки из config + self.api_key = PIAPI_API_KEY + self.base_url = PIAPI_URL.replace("/chat/completions", "") self.model = PIAPI_MODEL self.use_piapi = True - print("✅ PiAPI подключен!") + self.chat_url = PIAPI_URL + self.provider_type = "piapi" + self.proxies = get_proxies() + print("✅ PiAPI подключен (глобальные настройки)!") else: - print("⚠️ PiAPI ключ не найден, использую шаблоны") + print("⚠️ API ключ не найден, использую шаблоны") self.use_piapi = False - + self.api_key = None + self.chat_url = PIAPI_URL + self.model = PIAPI_MODEL + self.provider_type = "piapi" + self.proxies = get_proxies() # Шаблоны для запасного варианта (если PiAPI не работает) self.templates = { 'instagram': [ @@ -159,10 +184,10 @@ class ContentGenerator: """ response = requests.post( - PIAPI_URL, + self.chat_url, headers={ "Content-Type": "application/json", - "Authorization": f"Bearer {PIAPI_API_KEY}" + "Authorization": f"Bearer {self.api_key}" }, json={ "model": self.model, @@ -173,7 +198,7 @@ class ContentGenerator: "temperature": 0.9, "max_tokens": 500 }, - proxies=get_proxies(), + proxies=self.proxies, timeout=30 ) response.raise_for_status() @@ -215,10 +240,10 @@ class ContentGenerator: """ response = requests.post( - PIAPI_URL, + self.chat_url, headers={ "Content-Type": "application/json", - "Authorization": f"Bearer {PIAPI_API_KEY}" + "Authorization": f"Bearer {self.api_key}" }, json={ "model": self.model, @@ -229,7 +254,7 @@ class ContentGenerator: "temperature": 0.9, "max_tokens": 500 }, - proxies=get_proxies(), + proxies=self.proxies, timeout=30 ) response.raise_for_status() @@ -244,6 +269,470 @@ class ContentGenerator: post = self._generate_with_templates(business_type, business_description, platform) return f"{post}\n\n💭 Пожелания учтены частично (без ИИ): {wishes}" + def generate_post_from_brand_profile(self, brand_data, platform, style, wishes): + """Генерирует пост на основе медиа-образа (данных опроса brand_survey). + brand_data — dict с полями опроса.""" + print(f"🔄 Генерация поста из медиа-образа для {platform}...") + + brand_type = brand_data.get("brand_type", "personal") + is_org = (brand_type == "organization") + is_community = (brand_type == "community") + + # Собираем ключевые поля медиа-образа + client_name = brand_data.get("client_name", "") + client_role = brand_data.get("client_role", "") + brand_essence = brand_data.get("brand_essence", "") + positioning_who = brand_data.get("positioning_who", "") + positioning_problem = brand_data.get("positioning_problem", "") + positioning_result = brand_data.get("positioning_result", "") + usp = brand_data.get("usp", "") + tov_description = brand_data.get("tov_description", "") + tov_references = brand_data.get("tov_references", "") + tov_axes = brand_data.get("tov_axes", "") + prompt_context = brand_data.get("prompt_context", "") + prompt_tov = brand_data.get("prompt_tov", "") + prompt_structure = brand_data.get("prompt_structure", "") + prompt_directions = brand_data.get("prompt_directions", "") + prompt_formats = brand_data.get("prompt_formats", "") + prompt_technical = brand_data.get("prompt_technical", "") + + # Поля организации + org_name = brand_data.get("org_name", "") + org_industry = brand_data.get("org_industry", "") + org_mission = brand_data.get("org_mission", "") + org_products = brand_data.get("org_products", "") + org_team_size = brand_data.get("org_team_size", "") + positioning_difference = brand_data.get("positioning_difference", "") + community_formats = brand_data.get("community_formats", "") + + platform_names = { + 'instagram': 'Instagram', + 'linkedin': 'LinkedIn', + 'telegram': 'Telegram', + 'twitter': 'Twitter/X', + 'ad': 'рекламный пост' + } + + if is_community: + identity_name = org_name + identity_role = org_industry + identity_block = f"""=== ОБЩЕСТВЕННАЯ ОРГАНИЗАЦИЯ === +Название: {org_name} +Сфера: {org_industry} +Миссия: {org_mission} +Направления деятельности: {org_products} +Участников: {org_team_size} +Формат взаимодействия: {community_formats} +Сущность бренда: {brand_essence} +УТП: {usp}""" + positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ === +Для кого создано: {positioning_who} +Какую проблему решает: {positioning_problem} +Ценность для участника: {positioning_result} +Чем отличаемся от других: {positioning_difference}""" + tone_intro = "Ты — профессиональный копирайтер, создающий контент для общественной организации / бизнес-сообщества." + post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от имени организации «{org_name}»." + system_msg = f"Ты профессиональный копирайтер. Пишешь контент для бизнес-сообщества «{org_name}» в соответствии с его миссией, ценностями и Tone of Voice." + elif is_org: + identity_name = org_name + identity_role = org_industry + identity_block = f"""=== ОРГАНИЗАЦИЯ === +Название: {org_name} +Сфера: {org_industry} +Миссия: {org_mission} +Продукты/услуги: {org_products} +Сотрудников: {org_team_size} +Сущность бренда: {brand_essence} +УТП: {usp}""" + positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ === +Кому помогаем: {positioning_who} +Какую проблему решаем: {positioning_problem} +Какой результат получает клиент: {positioning_result} +Почему выбирают нас: {positioning_difference}""" + tone_intro = "Ты — профессиональный копирайтер, создающий контент для организации." + post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от имени организации «{org_name}»." + system_msg = f"Ты профессиональный копирайтер. Пишешь контент для организации «{org_name}» в соответствии с её Tone of Voice и позиционированием." + else: + identity_name = client_name + identity_role = client_role + identity_block = f"""=== ЛИЧНОСТЬ И КОНТЕКСТ === +Имя: {client_name} +Роль: {client_role} +Сущность бренда: {brand_essence} +УТП: {usp}""" + positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ === +Кому помогаю: {positioning_who} +Какую проблему решаю: {positioning_problem} +Какой результат получает клиент: {positioning_result}""" + tone_intro = "Ты — профессиональный копирайтер-психолог, который глубоко понимает образ и стратегию личного бренда." + post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от лица автора медиа-образа." + system_msg = "Ты профессиональный копирайтер-психолог. Пишешь в точном соответствии с образом и Tone of Voice автора." + + if self.use_piapi: + try: + prompt = f""" +{tone_intro} + +{post_intro} + +{identity_block} + +{positioning_block} + +=== TONE OF VOICE === +Описание тона: {tov_description} +Референсы: {tov_references} +Оси ToV: {tov_axes} + +=== СТРУКТУРА МЫСЛИ === +{prompt_structure} + +=== КОНТЕНТ-НАПРАВЛЕНИЯ === +{prompt_directions} + +=== ФОРМАТЫ И ШАБЛОНЫ === +{prompt_formats} + +=== ТЕХНИЧЕСКИЕ ТРЕБОВАНИЯ === +{prompt_technical} + +=== ПАРАМЕТРЫ ЭТОГО ПОСТА === +Платформа: {platform_names.get(platform, 'соцсети')} +Стиль: {style} +Пожелания: {wishes} + +Напиши только текст поста, без лишних пояснений. Следуй структуре мысли и Tone of Voice. +""" + response = requests.post( + self.chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": prompt} + ], + "temperature": 0.9, + "max_tokens": 4000 + }, + proxies=self.proxies, + timeout=90 + ) + response.raise_for_status() + msg = response.json()["choices"][0]["message"] + post = (msg.get("content") or "").strip() + if post and len(post) > 20: + print("✅ Пост из медиа-образа сгенерирован через API!") + return post + # Если content пустой — возможно reasoning-модель не успела завершить + raise RuntimeError("API вернул пустой ответ (content пуст). Увеличьте max_tokens или попробуйте другую модель.") + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else "?" + body = e.response.text[:200] if e.response is not None else "" + raise RuntimeError(f"API вернул ошибку {status}: {body}") + except requests.exceptions.ConnectionError as e: + raise RuntimeError(f"Не удалось подключиться к API: {e}") + except requests.exceptions.Timeout: + raise RuntimeError("Превышен таймаут запроса к API (30 сек)") + except Exception as e: + raise RuntimeError(f"Ошибка API: {e}") + + # Fallback — шаблонный пост с данными медиа-образа + print("🔄 Использую шаблонный пост из медиа-образа...") + return self._generate_template_brand_post(brand_data, platform, wishes) + + def modify_post(self, original_post: str, instruction: str) -> str: + """Модифицирует существующий пост по инструкции пользователя. + original_post — текущий текст поста + instruction — что нужно сделать («сократи вдвое», «сделай официальнее» и т.д.) + Возвращает новый текст поста. + """ + print(f"🔄 Модификация поста: «{instruction[:60]}»...") + + prompt = f"""Ты — профессиональный копирайтер. Пользователь просит изменить пост. + +=== ТЕКУЩИЙ ПОСТ === +{original_post} + +=== ИНСТРУКЦИЯ === +{instruction} + +=== ЗАДАЧА === +Перепиши пост, выполнив инструкцию. Сохрани общий смысл и стиль. Верни ТОЛЬКО готовый пост без пояснений и markdown.""" + + if self.use_piapi: + try: + response = requests.post( + self.chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты профессиональный копирайтер. Ты редактируешь посты строго по инструкции."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.7, + "max_tokens": 4000 + }, + proxies=self.proxies, + timeout=90 + ) + response.raise_for_status() + post = response.json()["choices"][0]["message"]["content"].strip() + if post and len(post) > 20: + print("✅ Пост модифицирован!") + return post + except Exception as e: + print(f"⚠️ Ошибка API при модификации: {e}") + + # Если ИИ не сработал — возвращаем оригинал с пометкой + return f"{original_post}\n\n💬 [ИИ не смог изменить пост: {instruction}]" + + def _generate_template_brand_post(self, brand_data, platform, wishes): + """Простой пост из медиа-образа без обращения к ИИ (запасной вариант).""" + client_name = brand_data.get("client_name", "Автор") + brand_essence = brand_data.get("brand_essence", "") + usp = brand_data.get("usp", "") + positioning_result = brand_data.get("positioning_result", "") + + platform_emoji = { + "instagram": "📸", "linkedin": "💼", "telegram": "📱", + "twitter": "🐦", "ad": "📢" + } + emoji = platform_emoji.get(platform, "📝") + + post = ( + f"{emoji} {brand_essence}\n\n" + f"Меня зовут {client_name}.\n" + f"{usp}\n\n" + f"Я помогаю получить: {positioning_result}\n\n" + ) + if wishes: + post += f"💭 {wishes}\n\n" + post += "Напишите в комментариях, какая у вас сейчас задача 👇\n\n#личныйбренд #бизнес" + return post + + def suggest_image_concepts(self, post_text, brand_data=None): + """Анализирует текст поста и предлагает 4 варианта концепции изображения. + Возвращает список dict: [{title, description, style, size}, ...]""" + print(f"🔄 Анализ поста для подбора концепции изображения ({len(post_text)} символов)...") + + if not self.use_piapi: + # Fallback — базовые концепции без ИИ + return [ + {"title": "Тематическая фотография", "description": "Профессиональная фотография, иллюстрирующая ключевую идею поста", "style": "photo", "size": "wide"}, + {"title": "Минималистичный баннер", "description": "Чистый баннер с текстом поста на минималистичном фоне", "style": "minimal", "size": "wide"}, + {"title": "Абстрактная иллюстрация", "description": "Цветная абстрактная иллюстрация в тон поста", "style": "illustration", "size": "square"}, + {"title": "Инфографика", "description": "Визуализация данных и ключевых тезисов поста", "style": "flat", "size": "tall"}, + ] + + brand_context = "" + if brand_data: + brand_context = f""" +Контекст бренда: +- Имя: {brand_data.get('client_name', '')} +- Роль: {brand_data.get('client_role', '')} +- Сущность: {brand_data.get('brand_essence', '')} +- УТП: {brand_data.get('usp', '')} +""" + + prompt = f"""Ты — арт-директор и визуальный стратег. Проанализируй текст поста и предложи 4 разных варианта визуального оформления. + +=== ТЕКСТ ПОСТА === +{post_text} +{brand_context} +=== ЗАДАЧА === +Предложи 4 контрастных варианта изображения. Каждый вариант — это отдельная идея с уникальным настроением и стилем. + +Для каждого варианта укажи: +- title: краткое название (2-4 слова на русском) +- description: что изображено (1 предложение на русском) +- style: один из: photo, illustration, minimal, 3d, flat +- size: один из: square, wide, tall + +Верни ТОЛЬКО JSON-массив без markdown и пояснений: +[{{"title": "...", "description": "...", "style": "...", "size": "..."}}, ...]""" + + try: + response = requests.post( + self.chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты — арт-директор. Отвечай только валидным JSON без markdown-обёрток."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.8, + "max_tokens": 1000 + }, + proxies=self.proxies, + timeout=60 + ) + response.raise_for_status() + raw = response.json()["choices"][0]["message"]["content"].strip() + + # Убираем markdown-обёртку если есть + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] + if raw.endswith("```"): + raw = raw[:-3] + raw = raw.strip() + + concepts = json.loads(raw) + if isinstance(concepts, list) and len(concepts) > 0: + print(f"✅ Получено {len(concepts)} концепций от ИИ") + return concepts[:5] # макс 5 + except Exception as e: + print(f"⚠️ Ошибка при получении концепций: {e}") + + # Fallback + return [ + {"title": "Тематическая фотография", "description": "Профессиональная фотография, иллюстрирующая ключевую идею поста", "style": "photo", "size": "wide"}, + {"title": "Минималистичный баннер", "description": "Чистый баннер с текстом поста на минималистичном фоне", "style": "minimal", "size": "wide"}, + {"title": "Абстрактная иллюстрация", "description": "Цветная абстрактная иллюстрация в тон поста", "style": "illustration", "size": "square"}, + {"title": "Инфографика", "description": "Визуализация данных и ключевых тезисов поста", "style": "flat", "size": "tall"}, + ] + + def parse_brand_doc_to_survey(self, doc_text): + """Разбирает текст .docx документа стратегии личного бренда + и извлекает структурированные поля опроса через PiAPI. + Возвращает dict с полями brand_survey.data или None при ошибке.""" + print(f"🔄 Разбор документа медиа-образа ({len(doc_text)} символов)...") + + if not self.use_piapi: + print("⚠️ PiAPI недоступен — разбор документа невозможен") + return None + + # Ограничиваем длину, чтобы влезть в контекст + text = doc_text[:12000] + + prompt = f""" +Ты — аналитик. Извлеки структурированные данные из документа стратегии личного бренда и верни их в виде JSON. + +Документ: +--- +{text} +--- + +Извлеки следующие поля (если поле отсутствует в документе — оставь пустую строку ""): + +{{ + "client_name": "Имя клиента", + "client_role": "Профессия/роль", + "client_location": "Город/регион", + "current_situation": "Описание текущей ситуации", + "goal_audience_number": "число подписчиков", + "goal_audience_platform": "основная платформа", + "goal_audience_description": "описание аудитории", + "goal_expert_field": "область экспертизы", + "goal_expert_speaking": "число выступлений в год", + "goal_commercial_leads": "число заявок в месяц", + "goal_commercial_services": "услуги", + "goal_content_system": "описание контент-системы", + "brand_essence": "сущность бренда одной строкой", + "positioning_who": "кому помогаю", + "positioning_problem": "какую проблему решаю", + "positioning_result": "какой результат получает клиент", + "usp": "УТП", + "ideal_audiences": "идеальные подписчики, по строкам", + "visual_concept": "концепция визуала", + "color_main_name": "название основного цвета", + "color_main_hex": "HEX основного цвета", + "color_accent1_name": "название акцентного цвета 1", + "color_accent1_hex": "HEX акцентного цвета 1", + "color_accent2_name": "название акцентного цвета 2", + "color_accent2_hex": "HEX акцентного цвета 2", + "color_neutral": "нейтральная база", + "typography_headings": "шрифт заголовков", + "typography_body": "шрифт текста", + "typography_accents": "шрифт акцентов", + "tov_description": "описание тона", + "tov_references": "референсы", + "tov_axes": "оси ToV по строкам", + "pillar1_name": "название столпа 1", + "pillar1_goal": "цель столпа 1", + "pillar1_formats": "форматы столпа 1 по строкам", + "pillar1_frequency": "частота столпа 1", + "pillar2_name": "название столпа 2", + "pillar2_goal": "цель столпа 2", + "pillar2_formats": "форматы столпа 2 по строкам", + "pillar2_frequency": "частота столпа 2", + "pillar3_name": "название столпа 3", + "pillar3_goal": "цель столпа 3", + "pillar3_formats": "форматы столпа 3 по строкам", + "pillar3_frequency": "частота столпа 3", + "special_formats": "особые форматы", + "product_lead_magnet": "лид-магнит", + "product_simple": "простая услуга", + "product_converter": "услуга-конвертер", + "product_complex": "комплексные решения", + "product_expert": "экспертные продукты", + "metrics_quantitative": "количественные метрики, формат: Название | Значение | Измерение, по строкам", + "metrics_qualitative": "качественные метрики, тот же формат", + "metrics_commercial": "коммерческие метрики, тот же формат", + "risks": "риски, формат: Риск | Решение, по строкам", + "recommendations": "рекомендации по строкам", + "archetype_key_name": "название ключевого архетипа", + "archetype_key_essence": "суть ключевого архетипа", + "archetype_key_manifestations": "проявления по строкам", + "archetype_supporting": "вспомогательные архетипы, формат: Название | Суть | Проявления, по строкам", + "archetype_synthesis": "синтез архетипов", + "archetype_table": "таблица проявлений, формат: Ситуация | Архетип | Описание, по строкам", + "prompt_context": "контекст и личность для промпта", + "prompt_tov": "Tone of Voice для промпта", + "prompt_structure": "структура мысли", + "prompt_directions": "контент-направления по строкам", + "prompt_formats": "форматы и шаблоны", + "prompt_technical": "технические требования к тексту" +}} + +Верни ТОЛЬКО валидный JSON без пояснений и markdown. +""" + + try: + response = requests.post( + self.chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 3000, + "response_format": {"type": "json_object"} + }, + proxies=self.proxies, + timeout=60 + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"].strip() + parsed = json.loads(content) + print("✅ Документ медиа-образа разобран через PiAPI!") + # Гарантируем наличие channels и funnel + if "channels" not in parsed: + parsed["channels"] = [] + if "funnel" not in parsed: + parsed["funnel"] = [] + return parsed + except Exception as e: + print(f"⚠️ Ошибка разбора документа: {e}") + return None + def generate_content_plan(self, business_type, business_description, days): """Генерирует контент-план на несколько дней вперёд""" print(f"🔄 Генерация контент-плана на {days} дн...") @@ -263,10 +752,10 @@ class ContentGenerator: """ response = requests.post( - PIAPI_URL, + self.chat_url, headers={ "Content-Type": "application/json", - "Authorization": f"Bearer {PIAPI_API_KEY}" + "Authorization": f"Bearer {self.api_key}" }, json={ "model": self.model, @@ -278,7 +767,7 @@ class ContentGenerator: "max_tokens": 1000, "response_format": {"type": "json_object"} }, - proxies=get_proxies(), + proxies=self.proxies, timeout=30 ) response.raise_for_status() @@ -294,6 +783,100 @@ class ContentGenerator: print("🔄 Использую шаблонный план...") return self._generate_template_plan(business_type, days) + def generate_content_plan_from_brand_profile(self, brand_data, days): + """Генерирует контент-план на основе медиа-образа.""" + print(f"🔄 Генерация контент-плана из медиа-образа на {days} дн...") + + brand_type = brand_data.get("brand_type", "personal") + is_org = (brand_type == "organization") + is_community = (brand_type == "community") + + client_name = brand_data.get("client_name", "") + org_name = brand_data.get("org_name", "") + brand_essence = brand_data.get("brand_essence", "") + positioning_who = brand_data.get("positioning_who", "") + usp = brand_data.get("usp", "") + tov_description = brand_data.get("tov_description", "") + prompt_directions = brand_data.get("prompt_directions", "") + pillar1_formats = brand_data.get("pillar1_formats", "") + pillar2_formats = brand_data.get("pillar2_formats", "") + pillar3_formats = brand_data.get("pillar3_formats", "") + + if is_community: + entity_name = org_name + pillar1_label = "О миссии" + pillar2_label = "Мероприятия" + pillar3_label = "Участники" + entity_type = "Бизнес-сообщество / общественная организация" + elif is_org: + entity_name = org_name + pillar1_label = "О продукте" + pillar2_label = "Экспертиза" + pillar3_label = "Вовлечение" + entity_type = "Организация" + else: + entity_name = client_name + pillar1_label = "Кто я" + pillar2_label = "Решаю боль" + pillar3_label = "Втягиваю" + entity_type = "Автор" + + if self.use_piapi: + try: + prompt = f""" +Ты — SMM-стратег. Составь контент-план на {days} дней. + +{entity_type}: {entity_name} +Сущность бренда: {brand_essence} +УТП: {usp} +Аудитория: {positioning_who} +Tone of Voice: {tov_description} + +Контент-направления: +{prompt_directions} + +Три столпа контента: +1. «{pillar1_label}» (форматы): {pillar1_formats} +2. «{pillar2_label}» (форматы): {pillar2_formats} +3. «{pillar3_label}» (форматы): {pillar3_formats} + +Для каждого дня укажи платформу (instagram, linkedin, telegram, twitter или ad) и короткую идею поста (1 предложение), учитывая три столпа и Tone of Voice. + +Верни ТОЛЬКО валидный JSON без пояснений в формате: +{{"plan": [{{"day": 1, "platform": "instagram", "idea": "..."}}, ...]}} +""" + response = requests.post( + self.chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.7, + "max_tokens": 1200, + "response_format": {"type": "json_object"} + }, + proxies=self.proxies, + timeout=30 + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"].strip() + parsed = json.loads(content) + plan = parsed.get("plan") + if plan: + print("✅ Контент-план из медиа-образа сгенерирован через PiAPI!") + return plan + except Exception as e: + print(f"⚠️ Ошибка PiAPI: {e}, использую шаблонный план") + + print("🔄 Использую шаблонный план...") + return self._generate_template_plan(brand_essence or client_name or "бизнес", days) + def _generate_template_plan(self, business_type, days): """Простой контент-план без обращения к ИИ (запасной вариант)""" platforms = ['instagram', 'linkedin', 'telegram', 'twitter', 'ad'] diff --git a/ai_core/social_analyzer.py b/ai_core/social_analyzer.py new file mode 100644 index 0000000..710f524 --- /dev/null +++ b/ai_core/social_analyzer.py @@ -0,0 +1,187 @@ +""" +Анализатор профилей соцсетей. +Определяет платформу по URL, извлекает посты, отправляет на AI-анализ +для заполнения полей медиа-образа. +""" + +import re +import json +import requests +from config import get_proxies + + +# === Определение платформы === + +def detect_platform(url: str) -> dict: + """Определяет платформу по URL. + Возвращает {"platform": str, "channel": str, "fetchable": bool} + """ + url = url.strip() + + # Telegram: t.me/channel_name или t.me/s/channel_name + m = re.match(r'https?://t\.me/(?:s/)?([a-zA-Z0-9_]+)', url) + if m: + return {"platform": "telegram", "channel": m.group(1), "fetchable": True} + + # VK: vk.com/username or vk.ru/username + m = re.match(r'https?://(?:vk\.com|vk\.ru)/([a-zA-Z0-9_.]+)', url) + if m: + return {"platform": "vk", "channel": m.group(1), "fetchable": False} + + # Tenchat: tenchat.ru/username + m = re.match(r'https?://tenchat\.ru/([a-zA-Z0-9_]+)', url) + if m: + return {"platform": "tenchat", "channel": m.group(1), "fetchable": False} + + # Instagram + m = re.match(r'https?://(?:www\.)?instagram\.com/([a-zA-Z0-9_.]+)/?', url) + if m: + return {"platform": "instagram", "channel": m.group(1), "fetchable": False} + + return {"platform": "unknown", "channel": url, "fetchable": False} + + +# === Парсинг Telegram === + +def fetch_telegram_posts(channel_name: str, limit: int = 20) -> list: + """Загружает посты из публичного Telegram-канала через t.me/s/. + Возвращает список {"text": str, "date": str}. + """ + url = f"https://t.me/s/{channel_name}" + proxies = get_proxies() + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + try: + r = requests.get(url, proxies=proxies, timeout=15, headers=headers) + r.raise_for_status() + except Exception as e: + print(f"[SocialAnalyzer] Ошибка загрузки Telegram: {e}") + return [] + + html = r.text + + # Извлекаем тексты постов + texts = re.findall( + r'class="tgme_widget_message_text[^"]*"[^>]*>(.*?)', + html, re.DOTALL + ) + + # Извлекаем даты + dates = re.findall( + r'class="tgme_widget_message_date"[^>]*>.*?]*datetime="([^"]*)"', + html, re.DOTALL + ) + + posts = [] + for i, raw_text in enumerate(texts[:limit]): + clean = re.sub(r'<[^>]+>', ' ', raw_text).strip() + clean = re.sub(r'\s+', ' ', clean) + if len(clean) > 10: # Пропускаем служебные сообщения + date = dates[i] if i < len(dates) else "" + posts.append({"text": clean, "date": date}) + + print(f"[SocialAnalyzer] Telegram: загружено {len(posts)} постов из @{channel_name}") + return posts + + +# === AI-анализ постов === + +ANALYSIS_PROMPT_TEMPLATE = """ +Ты — эксперт по брендингу и SMM. Проанализируй {count} постов из профиля/канала и извлеки параметры для медиа-образа. + +=== ПОСТЫ ДЛЯ АНАЛИЗА === +{posts_text} + +=== ЗАДАЧА === +Проанализируй стиль, тон, темы, подачу и структуру постов. Верни JSON со следующими полями: + +{{ + "client_name": "Имя или название канала/профиля (если можно определить)", + "brand_essence": "Сущность бренда одной строкой (что этот человек/бренд транслирует)", + "tov_description": "Описание Tone of Voice (тон общения, манера подачи)", + "tov_axes": "Оси ToV (по строкам, 3-5 шт)", + "tov_references": "На кого/что похож стиль (референсы)", + "prompt_directions": "Контент-направления (по строкам, какие темы затрагиваются)", + "prompt_structure": "Типичная структура постов (как строются тексты)", + "prompt_formats": "Типичные форматы постов (экспертный, личный, вовлекающий и т.д.)", + "current_situation": "Краткое описание: кто этот человек/бренд, чем занимается", + "client_role": "Роль / профессия (если определяется)", + "positioning_who": "Для кого контент (целевая аудитория)", + "positioning_problem": "Какую проблему решает", + "positioning_result": "Какой результат получает аудитория" +}} + +Верни ТОЛЬКО валидный JSON без markdown и пояснений. +""" + + +def analyze_posts_with_ai(posts: list, provider_settings: dict = None) -> dict: + """Отправляет посты на AI-анализ и возвращает структурированные данные + для заполнения медиа-образа. + """ + if not posts: + return {"error": "Нет постов для анализа"} + + # Склеиваем посты + posts_text = "" + for i, p in enumerate(posts[:20], 1): + date_str = f" ({p['date']})" if p.get("date") else "" + posts_text += f"\n--- Пост {i}{date_str} ---\n{p['text']}\n" + + prompt = ANALYSIS_PROMPT_TEMPLATE.format( + count=len(posts[:20]), + posts_text=posts_text + ) + + # Определяем AI-провайдер + if provider_settings and provider_settings.get("api_key"): + api_key = provider_settings["api_key"] + base_url = provider_settings.get("base_url", "https://api.piapi.ai/v1") + model = provider_settings.get("text_model", "gpt-4o") + else: + from config import PIAPI_API_KEY, PIAPI_MODEL + api_key = PIAPI_API_KEY + base_url = "https://api.piapi.ai/v1" + model = PIAPI_MODEL + + if not api_key: + return {"error": "AI-провайдер не настроен. Настройте ИИ в разделе «ИИ»."} + + chat_url = base_url.rstrip("/") + "/chat/completions" + proxies = get_proxies() + + try: + response = requests.post( + chat_url, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + }, + json={ + "model": model, + "messages": [ + {"role": "system", "content": "Ты эксперт по брендингу. Отвечаешь только валидным JSON."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 2000, + "response_format": {"type": "json_object"} + }, + proxies=proxies, + timeout=90 + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"].strip() + + # Парсим JSON + result = json.loads(content) + print(f"[SocialAnalyzer] AI-анализ завершён, получено {len(result)} полей") + return result + + except json.JSONDecodeError as e: + print(f"[SocialAnalyzer] Ошибка парсинга JSON от AI: {e}") + return {"error": "AI вернул некорректный JSON. Попробуйте ещё раз."} + except Exception as e: + print(f"[SocialAnalyzer] Ошибка AI: {e}") + return {"error": f"Ошибка AI: {e}"} diff --git a/ai_core/telegram_publisher.py b/ai_core/telegram_publisher.py new file mode 100644 index 0000000..eb18827 --- /dev/null +++ b/ai_core/telegram_publisher.py @@ -0,0 +1,186 @@ +"""Публикация постов в Telegram-каналы и чаты через Bot API. + +Использует TOKEN из config — тот же токен, что и у бота. +Бот должен быть админом в канале/чате, куда публикуем.""" + +import re +import requests + +from config import TOKEN, get_proxies + +TELEGRAM_API = f"https://api.telegram.org/bot{TOKEN}" + + +def _md_to_html(text: str) -> str: + """Конвертирует простой Markdown в HTML для Telegram. + + Поддерживает: **жирный**, *курсив*, __подчёркнутый~~зачёркнутый~~, + `моноширинный`, ```блок кода```, заголовки ### в , + нумерованные/маркированные списки. + Telegram HTML: , , , , ,
, .
+    """
+    # Сначала экранируем HTML-символы в тексте (но не наши теги)
+    text = text.replace("&", "&").replace("<", "<").replace(">", ">")
+
+    # Блоки кода ```...```
+    text = re.sub(r'```(\w*)\n(.*?)```', r'
\2
', text, flags=re.DOTALL) + text = re.sub(r'```(.*?)```', r'
\1
', text, flags=re.DOTALL) + + # Inline code `...` + text = re.sub(r'`([^`]+)`', r'\1', text) + + # Заголовки ### **Текст** → Текст + text = re.sub(r'^#{1,6}\s+\*\*(.+?)\*\*\s*$', r'\1', text, flags=re.MULTILINE) + text = re.sub(r'^#{1,6}\s+(.+?)\s*$', r'\1', text, flags=re.MULTILINE) + + # Жирный **...** + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + + # Курсив *...* (но не внутри тегов) + text = re.sub(r'(?\1
', text) + + # Подчёркнутый __...__ + text = re.sub(r'__(.+?)__', r'\1', text) + + # Зачёркнутый ~~...~~ + text = re.sub(r'~~(.+?)~~', r'\1', text) + + return text + + +def _api_call(method, params): + """Вызывает Telegram Bot API метод. Возвращает (ok, result_or_error).""" + try: + resp = requests.post( + f"{TELEGRAM_API}/{method}", + data=params, + proxies=get_proxies(), + timeout=30, + ) + data = resp.json() + if data.get("ok"): + return True, data.get("result", {}) + return False, data.get("description", "Неизвестная ошибка") + except Exception as e: + return False, str(e) + + +def resolve_chat(username_or_id): + """Разрешает @username или числовой ID в информацию о чате. + Возвращает dict: {chat_id, title, username, type} или {error}.""" + # Нормализуем + identifier = username_or_id.strip() + if identifier.startswith("@"): + identifier = identifier # оставляем как есть для API + elif identifier.lstrip("-").isdigit(): + identifier = int(identifier) + + ok, result = _api_call("getChat", {"chat_id": identifier}) + if not ok: + return {"error": result} + + chat_id = result.get("id") + chat_type = result.get("type", "unknown") + title = result.get("title") or result.get("first_name", "") or result.get("username", "") + username = result.get("username", "") + + return { + "chat_id": chat_id, + "title": title, + "username": username, + "type": chat_type, + } + + +def check_bot_is_admin(chat_id): + """Проверяет, является ли бот админом в чате/канале. + Возвращает (is_admin, error_message).""" + ok, result = _api_call("getChatMember", { + "chat_id": chat_id, + "user_id": _get_bot_id(), + }) + if not ok: + return False, f"Не удалось проверить статус: {result}" + + status = result.get("status", "") + # administrator — для чатов, owner — для каналов + if status in ("administrator", "creator", "owner"): + return True, None + return False, f"Бот не админ в этом чате (статус: {status}). Добавьте бота в админы." + + +def _get_bot_id(): + """Возвращает ID бота (кешируется).""" + global _cached_bot_id + if _cached_bot_id is not None: + return _cached_bot_id + ok, result = _api_call("getMe", {}) + if ok: + _cached_bot_id = result.get("id") + return _cached_bot_id + return None + + +_cached_bot_id = None + + +def publish_post(chat_id, text, image_bytes=None): + """Публикует пост в канал/чат. + - text: текст поста (Markdown конвертируется в HTML) + - image_bytes: опционально, байты картинки + Возвращает (ok, message_or_message_id).""" + text = _md_to_html(text) + if image_bytes: + ok, result = _api_call_send_photo(chat_id, image_bytes, text) + else: + ok, result = _api_call("sendMessage", { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + }) + + if ok: + msg_id = result.get("message_id") + return True, f"Опубликовано (message_id={msg_id})" + return False, result + + +def _api_call_send_photo(chat_id, image_bytes, caption): + """Отправляет фото с подписью. + Если текст длиннее 1024 символов — фото без подписи + текст отдельным сообщением.""" + long_text = len(caption) > 1024 + try: + files = {"photo": ("image.png", image_bytes, "image/png")} + data = { + "chat_id": chat_id, + "caption": "" if long_text else caption, + "parse_mode": "HTML" if not long_text else None, + } + # Убираем None из data + data = {k: v for k, v in data.items() if v is not None} + resp = requests.post( + f"{TELEGRAM_API}/sendPhoto", + data=data, + files=files, + proxies=get_proxies(), + timeout=60, + ) + data = resp.json() + if not data.get("ok"): + return False, data.get("description", "Ошибка отправки фото") + + # Если текст длинный — отправляем его отдельным сообщением + if long_text: + ok2, result2 = _api_call("sendMessage", { + "chat_id": chat_id, + "text": caption, + "parse_mode": "HTML", + }) + if not ok2: + # Фото ушло, но текст не отправился — всё равно считаем успехом, + # но возвращаем предупреждение + return True, data.get("result", {}) + + return True, data.get("result", {}) + except Exception as e: + return False, str(e) diff --git a/cabinet_service/app.py b/cabinet_service/app.py index 4dd1691..2499bdb 100644 --- a/cabinet_service/app.py +++ b/cabinet_service/app.py @@ -1,40 +1,73 @@ from pathlib import Path +import json import os +import re +import threading import uuid import bcrypt -from fastapi import Depends, FastAPI, Form, HTTPException, Request -from fastapi.responses import RedirectResponse +from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -from ai_core.image_generator import IMAGE_SIZES, STYLE_PRESETS, ImageGenerator, detect_ext +from ai_core.image_generator import IMAGE_SIZES, IMAGE_TEMPLATES, STYLE_PRESETS, ImageGenerator, detect_ext, get_sizes_for_model from ai_core.neural_network import ContentGenerator +from ai_core.social_analyzer import analyze_posts_with_ai, detect_platform, fetch_telegram_posts +from ai_core.telegram_publisher import ( + check_bot_is_admin, + publish_post, + resolve_chat, +) +from cabinet_service.brand_doc_generator import generate_brand_docx from config import SECRET_KEY from database import ( + add_post_revision, + create_task, create_web_user, + delete_brand_survey, + delete_channel, delete_media_by_id, delete_plan_item, delete_post_by_id, + delete_post_image, delete_user_by_id, + get_brand_survey_by_id, + get_brand_surveys_for_user, + get_channel_by_id, + get_channels_for_user, + get_image_provider_settings, + get_latest_brand_survey_for_user, get_media_by_id_for_user, get_media_for_user, get_plan_for_user, get_plan_stats_for_user, get_post_by_id_for_user, + get_post_images, + get_post_revisions, get_posts_for_user, + get_provider_settings, get_stats_for_user, + get_task, + get_text_provider_settings, get_user_by_email, get_user_by_id, + is_channel_already_added, + mark_post_published, + PROVIDER_PRESETS, replace_plan_for_user, + save_brand_survey, + save_channel, save_media, save_post_for_user, + save_post_image, + save_provider_settings, update_plan_status, update_post_content, + update_task_status, update_user_field_by_id, ) -from keyboards.reply_keyboards import BUSINESS_TYPES BASE_DIR = Path(__file__).resolve().parent MEDIA_DIR = BASE_DIR / "media" @@ -49,6 +82,22 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) content_gen = ContentGenerator() image_gen = ImageGenerator() + +def get_user_content_gen(user: dict) -> ContentGenerator: + """Возвращает ContentGenerator с настройками text-провайдера пользователя.""" + settings = get_text_provider_settings(user["id"]) + if settings and settings.get("api_key"): + return ContentGenerator(provider_settings=settings) + return content_gen + + +def get_user_image_gen(user: dict) -> ImageGenerator: + """Возвращает ImageGenerator с настройками image-провайдера пользователя.""" + settings = get_image_provider_settings(user["id"]) + if settings and settings.get("api_key"): + return ImageGenerator(provider_settings=settings) + return image_gen + PLATFORMS = [ ("instagram", "Instagram"), ("linkedin", "LinkedIn"), @@ -94,7 +143,9 @@ def render(request: Request, template: str, status_code: int = 200, **context): def business_name_of(user: dict) -> str: - return BUSINESS_TYPES.get(str(user["business_type"]), {}).get("name", "Не указан") + """Возвращает название вида бизнеса. Оставлено для совместимости (профили без медиа-образа).""" + from keyboards.reply_keyboards import BUSINESS_TYPES + return BUSINESS_TYPES.get(str(user.get("business_type")), {}).get("name", "Не указан") @app.get("/health") @@ -115,7 +166,7 @@ def root(request: Request): def register_form(request: Request): if request.session.get("user_id"): return RedirectResponse("/dashboard", status_code=303) - return render(request, "register.html", business_types=BUSINESS_TYPES) + return render(request, "register.html") @app.post("/register") @@ -123,16 +174,12 @@ def register_submit( request: Request, name: str = Form(...), phone: str = Form(...), - business_type: str = Form(...), - description: str = Form(...), email: str = Form(...), password: str = Form(...), password_confirm: str = Form(...), ): email = email.strip().lower() errors = [] - if business_type not in BUSINESS_TYPES: - errors.append("Выбери вид бизнеса из списка.") if len(password) < 8: errors.append("Пароль должен быть не короче 8 символов.") if password != password_confirm: @@ -145,15 +192,12 @@ def register_submit( if errors: return render( request, "register.html", status_code=400, - business_types=BUSINESS_TYPES, errors=errors, - form={ - "name": name, "phone": phone, "business_type": business_type, - "description": description, "email": email, - }, + errors=errors, + form={"name": name, "phone": phone, "email": email}, ) password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - user_id = create_web_user(email, password_hash, name, phone, int(business_type), description) + user_id = create_web_user(email, password_hash, name, phone) request.session["user_id"] = user_id flash(request, "Регистрация завершена! Добро пожаловать.") return RedirectResponse("/dashboard", status_code=303) @@ -197,12 +241,15 @@ def logout(request: Request): @app.get("/dashboard") def dashboard(request: Request, user: dict = Depends(current_user)): - return render(request, "dashboard.html", user=user, business_name=business_name_of(user)) + latest_survey = get_latest_brand_survey_for_user(user["id"]) + return render(request, "dashboard.html", user=user, latest_survey=latest_survey) @app.get("/profile/edit") def profile_edit_form(request: Request, user: dict = Depends(current_user)): - return render(request, "profile_edit.html", user=user, business_types=BUSINESS_TYPES) + provider = get_provider_settings(user["id"]) + return render(request, "profile_edit.html", user=user, + provider=provider, provider_presets=PROVIDER_PRESETS) @app.post("/profile/edit") @@ -210,28 +257,107 @@ def profile_edit_submit( request: Request, name: str = Form(...), phone: str = Form(...), - business_type: str = Form(...), - description: str = Form(...), user: dict = Depends(current_user), ): - if business_type not in BUSINESS_TYPES: - return render( - request, "profile_edit.html", status_code=400, - user=user, business_types=BUSINESS_TYPES, - errors=["Выбери вид бизнеса из списка."], - ) - update_user_field_by_id(user["id"], "name", name) update_user_field_by_id(user["id"], "phone", phone) - update_user_field_by_id(user["id"], "business_type", int(business_type)) - update_user_field_by_id(user["id"], "description", description) flash(request, "Профиль обновлён.") return RedirectResponse("/dashboard", status_code=303) +# === НАСТРОЙКИ ПРОВАЙДЕРА ИИ === + +@app.get("/provider") +def provider_form(request: Request, user: dict = Depends(current_user)): + provider = get_provider_settings(user["id"]) + return render(request, "provider.html", user=user, + provider=provider, provider_presets=PROVIDER_PRESETS) + + +@app.post("/provider") +def provider_submit( + request: Request, + # Text provider + text_provider_type: str = Form(...), + text_api_key: str = Form(""), + text_base_url: str = Form(""), + text_model: str = Form(""), + text_use_global: str = Form(""), + # Image provider + image_provider_type: str = Form(...), + image_api_key: str = Form(""), + image_base_url: str = Form(""), + image_model: str = Form(""), + image_use_global: str = Form(""), + user: dict = Depends(current_user), +): + errors = [] + + # Валидация text-провайдера + if text_provider_type not in PROVIDER_PRESETS: + errors.append("Выберите провайдера для текста.") + text_preset = PROVIDER_PRESETS.get(text_provider_type, {}) + if not text_use_global and text_preset.get("needs_key") and not text_api_key.strip(): + errors.append("Укажите API ключ для текстового провайдера или выберите «Использовать глобальные настройки».") + if not text_use_global and text_provider_type == "custom" and not text_base_url.strip(): + errors.append("Для кастомного текстового провайдера укажите Base URL.") + + # Валидация image-провайдера + if image_provider_type not in PROVIDER_PRESETS: + errors.append("Выберите провайдера для изображений.") + image_preset = PROVIDER_PRESETS.get(image_provider_type, {}) + if not image_use_global and image_preset.get("needs_key") and not image_api_key.strip(): + errors.append("Укажите API ключ для провайдера изображений или выберите «Использовать глобальные настройки».") + if not image_use_global and image_provider_type == "custom" and not image_base_url.strip(): + errors.append("Для кастомного image-провайдера укажите Base URL.") + if not image_preset.get("supports_images", True) and not image_use_global: + errors.append(f"Провайдер «{image_preset.get('name')}» не поддерживает генерацию изображений. Выберите другой.") + + if errors: + return render( + request, "provider.html", status_code=400, + user=user, provider_presets=PROVIDER_PRESETS, errors=errors, + form={ + "text_provider_type": text_provider_type, "text_api_key": text_api_key, + "text_base_url": text_base_url, "text_model": text_model, + "image_provider_type": image_provider_type, "image_api_key": image_api_key, + "image_base_url": image_base_url, "image_model": image_model, + }, + ) + + # Если "использовать глобальные" — сохраняем пустой ключ (будет использован глобальный) + final_text_key = "" if text_use_global else text_api_key.strip() + final_image_key = "" if image_use_global else image_api_key.strip() + + # Подставляем дефолтные значения из пресетов + final_text_base = text_base_url.strip() or text_preset.get("base_url", "") + final_text_model = text_model.strip() or text_preset.get("text_model", "") + final_image_base = image_base_url.strip() or image_preset.get("base_url", "") + final_image_model = image_model.strip() or image_preset.get("image_model", "") + + save_provider_settings( + user["id"], + text_provider_type, final_text_key, final_text_base, final_text_model, + image_provider_type, final_image_key, final_image_base, final_image_model, + ) + flash(request, "Провайдеры ИИ настроены: текст — «%s», изображения — «%s»." % ( + text_preset.get("name", text_provider_type), + image_preset.get("name", image_provider_type), + )) + return RedirectResponse("/provider", status_code=303) + + @app.get("/generate") -def generate_form(request: Request, user: dict = Depends(current_user)): - return render(request, "generate.html", user=user, platforms=PLATFORMS) +def generate_form(request: Request, user: dict = Depends(current_user), + plan_id: str = Query(""), platform: str = Query(""), + idea: str = Query("")): + latest_survey = get_latest_brand_survey_for_user(user["id"]) + surveys = get_brand_surveys_for_user(user["id"]) + prefill = None + if plan_id: + prefill = {"plan_id": plan_id, "platform": platform, "idea": idea} + return render(request, "generate.html", user=user, platforms=PLATFORMS, + latest_survey=latest_survey, surveys=surveys, prefill=prefill) @app.post("/generate") @@ -240,22 +366,62 @@ def generate_submit( platform: str = Form(...), style: str = Form(...), wishes: str = Form(""), + survey_id: str = Form(""), user: dict = Depends(current_user), ): - description = user["description"] or "не указано" - content = content_gen.generate_post_with_style( - business_name_of(user), description, platform, style, wishes - ) + # Выбираем опрос медиа-образа: явно указанный или последний + brand_data = None + if survey_id: + try: + sid = int(survey_id) + except ValueError: + sid = None + if sid: + survey = get_brand_survey_by_id(sid, user["id"]) + if survey: + brand_data = survey["data"] + if not brand_data: + latest = get_latest_brand_survey_for_user(user["id"]) + if latest: + brand_data = latest["data"] + + if not brand_data: + # Нет медиа-образа — возвращаем форму с подсказкой + surveys = get_brand_surveys_for_user(user["id"]) + return render( + request, "generate.html", status_code=400, + user=user, platforms=PLATFORMS, surveys=surveys, + latest_survey=None, + errors=["Сначала создайте медиа-образ — посты генерируются на его основе."], + ) + + user_gen = get_user_content_gen(user) + try: + content = user_gen.generate_post_from_brand_profile(brand_data, platform, style, wishes) + except Exception as e: + surveys = get_brand_surveys_for_user(user["id"]) + latest_survey = get_latest_brand_survey_for_user(user["id"]) + return render( + request, "generate.html", status_code=502, + user=user, platforms=PLATFORMS, surveys=surveys, + latest_survey=latest_survey, + errors=[f"Ошибка генерации: {e}. Проверьте настройки провайдера в разделе «ИИ»."], + form={"platform": platform, "style": style, "wishes": wishes}, + ) save_post_for_user(user["id"], platform, content, style, wishes) + latest_survey = get_latest_brand_survey_for_user(user["id"]) return render( request, "generate.html", user=user, platforms=PLATFORMS, result=content, + latest_survey=latest_survey, form={"platform": platform, "style": style, "wishes": wishes}, ) - @app.get("/history") def history(request: Request, user: dict = Depends(current_user)): posts = get_posts_for_user(user["id"], limit=50) + # Добавляем изображения к каждому посту + for p in posts: + p["images"] = get_post_images(p["id"], user["id"]) return render(request, "history.html", user=user, posts=posts, platform_label=platform_label) @@ -264,7 +430,11 @@ def post_detail(request: Request, post_id: int, user: dict = Depends(current_use post = get_post_by_id_for_user(post_id, user["id"]) if not post: raise HTTPException(status_code=404, detail="Пост не найден") - return render(request, "post_detail.html", user=user, post=post, platform_label=platform_label) + images = get_post_images(post_id, user["id"]) + revisions = get_post_revisions(post_id, user["id"]) + return render(request, "post_detail.html", user=user, post=post, + images=images, revisions=revisions, + platform_label=platform_label, templates=IMAGE_TEMPLATES) @app.get("/posts/{post_id}/edit") @@ -293,20 +463,121 @@ def post_edit_submit( errors=["Текст поста не может быть пустым."], ) update_post_content(user["id"], post_id, content) + add_post_revision(post_id, user["id"], content) flash(request, "Пост обновлён.") return RedirectResponse(f"/posts/{post_id}", status_code=303) +@app.post("/api/posts/{post_id}/edit-text") +def api_post_edit_text( + post_id: int, + content: str = Form(...), + user: dict = Depends(current_user), +): + """AJAX-сохранение текста поста (inline-редактор). Сохраняет ревизию.""" + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + content = content.strip() + if not content: + return JSONResponse({"error": "Текст не может быть пустым"}, status_code=400) + + update_post_content(user["id"], post_id, content) + add_post_revision(post_id, user["id"], content) + + return JSONResponse({"ok": True, "content": content}) + + @app.post("/posts/{post_id}/delete") def post_delete(request: Request, post_id: int, user: dict = Depends(current_user)): post = get_post_by_id_for_user(post_id, user["id"]) if not post: raise HTTPException(status_code=404, detail="Пост не найден") + # Удаляем изображения поста + images = get_post_images(post_id, user["id"]) + for img in images: + fp = MEDIA_DIR / img["filename"] + if fp.exists(): + fp.unlink() delete_post_by_id(user["id"], post_id) flash(request, "Пост удалён.") return RedirectResponse("/history", status_code=303) +# === ИЗОБРАЖЕНИЯ ПОСТА === + +@app.post("/posts/{post_id}/images/upload") +async def post_image_upload(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + + form = await request.form() + files = form.getlist("files") + if not files: + flash(request, "Выберите файлы для загрузки.") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + uploaded = 0 + for upload in files: + if not upload or not upload.filename: + continue + raw = await upload.read() + if not raw: + continue + ext = detect_ext(raw) + filename = f"{uuid.uuid4().hex}{ext}" + (MEDIA_DIR / filename).write_bytes(raw) + save_post_image(post_id, user["id"], filename, source="upload") + uploaded += 1 + + if uploaded: + flash(request, f"Загружено изображений: {uploaded}") + else: + flash(request, "Не удалось загрузить изображения.") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + +@app.post("/posts/{post_id}/images/generate") +def post_image_generate(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + + # Генерируем промпт из текста поста + user_img_gen = get_user_image_gen(user) + # Берём первые 500 символов поста как основу для промпта + post_text = (post["content"] or "")[:500] + prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching." + + try: + image_bytes = user_img_gen.generate(prompt, "square") + except Exception as e: + flash(request, f"Ошибка генерации изображения: {e}") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + ext = detect_ext(image_bytes) + filename = f"{uuid.uuid4().hex}{ext}" + (MEDIA_DIR / filename).write_bytes(image_bytes) + save_post_image(post_id, user["id"], filename, source="generated") + flash(request, "Изображение сгенерировано.") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + +@app.post("/posts/{post_id}/images/{image_id}/delete") +def post_image_delete(request: Request, post_id: int, image_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + filename = delete_post_image(image_id, user["id"]) + if filename: + fp = MEDIA_DIR / filename + if fp.exists(): + fp.unlink() + flash(request, "Изображение удалено.") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + @app.get("/plan") def plan_form(request: Request, user: dict = Depends(current_user)): items = get_plan_for_user(user["id"]) @@ -320,8 +591,13 @@ def plan_form(request: Request, user: dict = Depends(current_user)): @app.post("/plan") def plan_submit(request: Request, days: int = Form(7), user: dict = Depends(current_user)): days = max(1, min(days, 14)) - description = user["description"] or "не указано" - plan = content_gen.generate_content_plan(business_name_of(user), description, days) + user_gen = get_user_content_gen(user) + latest = get_latest_brand_survey_for_user(user["id"]) + if latest: + plan = user_gen.generate_content_plan_from_brand_profile(latest["data"], days) + else: + # Fallback — простой шаблонный план без медиа-образа + plan = user_gen.generate_content_plan("бизнес", "не указано", days) replace_plan_for_user(user["id"], plan) flash(request, f"Контент-план на {days} дн. сохранён.") return RedirectResponse("/plan", status_code=303) @@ -380,9 +656,15 @@ def images_gallery(request: Request, user: dict = Depends(current_user)): @app.get("/images/generate") def image_generate_form(request: Request, user: dict = Depends(current_user)): + # Размеры зависят от модели image-провайдера + img_settings = get_image_provider_settings(user["id"]) + if img_settings and img_settings.get("image_model"): + sizes = get_sizes_for_model(img_settings["image_model"]) + else: + sizes = IMAGE_SIZES return render( request, "image_generate.html", - user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + user=user, sizes=sizes, styles=STYLE_PRESETS, templates=IMAGE_TEMPLATES, ) @@ -394,22 +676,30 @@ def image_generate_submit( style: str = Form("none"), user: dict = Depends(current_user), ): + # Размеры зависят от модели + img_settings = get_image_provider_settings(user["id"]) + if img_settings and img_settings.get("image_model"): + sizes = get_sizes_for_model(img_settings["image_model"]) + else: + sizes = IMAGE_SIZES + prompt = prompt.strip() if not prompt: return render( request, "image_generate.html", status_code=400, - user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + user=user, sizes=sizes, styles=STYLE_PRESETS, form={"prompt": prompt, "size": size, "style": style}, errors=["Опиши, что нужно нарисовать."], ) - full_prompt = image_gen.build_prompt(prompt, style) + user_img_gen = get_user_image_gen(user) + full_prompt = user_img_gen.build_prompt(prompt, style) try: - image_bytes = image_gen.generate(full_prompt, size) + image_bytes = user_img_gen.generate(full_prompt, size) except Exception as e: return render( request, "image_generate.html", status_code=502, - user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + user=user, sizes=sizes, styles=STYLE_PRESETS, form={"prompt": prompt, "size": size, "style": style}, errors=[f"Ошибка генерации картинки: {e}"], ) @@ -433,3 +723,613 @@ def image_delete(request: Request, media_id: int, user: dict = Depends(current_u file_path.unlink() flash(request, "Картинка удалена.") return RedirectResponse("/images", status_code=303) + + +# === ОПРОСЫ МЕДИА-ОБРАЗА === + +@app.get("/surveys") +def survey_list(request: Request, user: dict = Depends(current_user)): + surveys = get_brand_surveys_for_user(user["id"]) + return render(request, "survey_list.html", user=user, surveys=surveys) + + +@app.get("/survey") +def survey_form(request: Request, user: dict = Depends(current_user)): + return render(request, "survey.html", user=user) + + +@app.get("/survey/import") +def survey_import_form(request: Request, user: dict = Depends(current_user)): + return render(request, "survey_import.html", user=user) + + +@app.post("/survey/import") +async def survey_import_submit(request: Request, user: dict = Depends(current_user)): + import json as _json + from docx import Document as _DocxDocument + from io import BytesIO as _BytesIO + + form = await request.form() + upload = form.get("file") + if not upload or not upload.filename: + return render( + request, "survey_import.html", status_code=400, + user=user, errors=["Выберите файл для загрузки."], + ) + + filename = upload.filename.lower() + raw = await upload.read() + + # --- JSON: прямой импорт --- + if filename.endswith(".json"): + try: + data = _json.loads(raw.decode("utf-8")) + except Exception as e: + return render( + request, "survey_import.html", status_code=400, + user=user, errors=[f"Ошибка чтения JSON: {e}"], + ) + # Поддерживаем два формата: {"data": {...}, "title": "..."} или просто {...} + if isinstance(data, dict) and "data" in data and isinstance(data["data"], dict): + title = (data.get("title") or "Импортированный образ").strip()[:200] + survey_data = data["data"] + elif isinstance(data, dict): + title = (data.get("client_name") or data.get("title") or "Импортированный образ").strip()[:200] + survey_data = data + else: + return render( + request, "survey_import.html", status_code=400, + user=user, errors=["JSON должен быть объектом с полями опроса."], + ) + # Гарантируем структуры + survey_data.setdefault("channels", []) + survey_data.setdefault("funnel", []) + survey_id = save_brand_survey(user["id"], title, survey_data) + flash(request, f"Медиа-образ «{title}» импортирован из JSON.") + return RedirectResponse(f"/surveys/{survey_id}", status_code=303) + + # --- DOCX: разбор через PiAPI --- + if filename.endswith(".docx"): + try: + doc = _DocxDocument(_BytesIO(raw)) + except Exception as e: + return render( + request, "survey_import.html", status_code=400, + user=user, errors=[f"Ошибка чтения .docx: {e}"], + ) + + # Извлекаем весь текст + lines = [] + for para in doc.paragraphs: + text = para.text.strip() + if text: + lines.append(text) + # Текст из таблиц + for table in doc.tables: + for row in table.rows: + cells = [cell.text.strip() for cell in row.cells] + lines.append(" | ".join(cells)) + doc_text = "\n".join(lines) + + if not doc_text.strip(): + return render( + request, "survey_import.html", status_code=400, + user=user, errors=["Документ пустой — нет текста для разбора."], + ) + + # Разбор через PiAPI + survey_data = content_gen.parse_brand_doc_to_survey(doc_text) + if not survey_data: + return render( + request, "survey_import.html", status_code=502, + user=user, + errors=["Не удалось разобрать документ. Проверьте, что PiAPI ключ настроен, и попробуйте снова."], + ) + + title = (survey_data.get("client_name") or "Импортированный образ").strip()[:200] + survey_id = save_brand_survey(user["id"], title, survey_data) + flash(request, f"Медиа-образ «{title}» импортирован из .docx.") + return RedirectResponse(f"/surveys/{survey_id}", status_code=303) + + return render( + request, "survey_import.html", status_code=400, + user=user, errors=["Поддерживаются только файлы .docx и .json"], + ) + + +@app.post("/survey/submit") +async def survey_submit(request: Request, user: dict = Depends(current_user)): + body = await request.json() + title = (body.get("title") or "Без названия").strip()[:200] + data = body.get("data") or {} + survey_id = save_brand_survey(user["id"], title, data) + return {"id": survey_id, "ok": True} + + +@app.get("/surveys/{survey_id}") +def survey_view(request: Request, survey_id: int, user: dict = Depends(current_user)): + survey = get_brand_survey_by_id(survey_id, user["id"]) + if not survey: + raise HTTPException(status_code=404, detail="Опрос не найден") + return render(request, "survey_view.html", user=user, survey=survey) + + +@app.get("/surveys/{survey_id}/export") +def survey_export(request: Request, survey_id: int, user: dict = Depends(current_user)): + survey = get_brand_survey_by_id(survey_id, user["id"]) + if not survey: + raise HTTPException(status_code=404, detail="Опрос не найден") + docx_bytes = generate_brand_docx(survey["data"]) + # ASCII-имя для Content-Disposition (latin-1), кириллица не допускается в заголовках + safe_title = "".join(c if c.isascii() and c.isalnum() else "_" for c in survey["title"])[:80] + if not safe_title or safe_title == "_" * len(safe_title): + safe_title = "brand" + filename = f"Strategiya_brenda_{safe_title}.docx" + return Response( + content=docx_bytes, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.post("/surveys/{survey_id}/delete") +def survey_delete(request: Request, survey_id: int, user: dict = Depends(current_user)): + survey = get_brand_survey_by_id(survey_id, user["id"]) + if not survey: + raise HTTPException(status_code=404, detail="Опрос не найден") + delete_brand_survey(user["id"], survey_id) + flash(request, "Опрос удалён.") + return RedirectResponse("/surveys", status_code=303) + + +# === КАНАЛЫ ДЛЯ ПУБЛИКАЦИИ === + +@app.get("/channels") +def channels_list(request: Request, user: dict = Depends(current_user)): + channels = get_channels_for_user(user["id"]) + return render(request, "channels.html", user=user, channels=channels) + + +@app.get("/channels/add") +def channel_add_form(request: Request, user: dict = Depends(current_user)): + return render(request, "channel_add.html", user=user) + + +@app.post("/channels/add") +def channel_add_submit( + request: Request, + chat_input: str = Form(...), + user: dict = Depends(current_user), +): + chat_input = chat_input.strip() + if not chat_input: + return render( + request, "channel_add.html", status_code=400, + user=user, errors=["Введите @username или ID канала/чата."], + form={"chat_input": chat_input}, + ) + + # 1. Разрешаем чат через Telegram API + info = resolve_chat(chat_input) + if "error" in info: + return render( + request, "channel_add.html", status_code=400, + user=user, errors=[f"Не удалось найти чат: {info['error']}"], + form={"chat_input": chat_input}, + ) + + chat_id = info["chat_id"] + title = info["title"] + username = info["username"] + chat_type = info["type"] + + # 2. Проверяем, что бот — админ + is_admin, admin_error = check_bot_is_admin(chat_id) + if not is_admin: + return render( + request, "channel_add.html", status_code=400, + user=user, errors=[admin_error or "Бот не админ в этом чате."], + form={"chat_input": chat_input}, + ) + + # 3. Проверяем, не добавлен ли уже + if is_channel_already_added(user["id"], chat_id): + return render( + request, "channel_add.html", status_code=400, + user=user, errors=[f"Канал «{title}» уже добавлен."], + form={"chat_input": chat_input}, + ) + + # 4. Сохраняем + save_channel(user["id"], chat_id, title, username, chat_type) + flash(request, f"Канал «{title}» добавлен.") + return RedirectResponse("/channels", status_code=303) + + +@app.post("/channels/{channel_id}/delete") +def channel_delete(request: Request, channel_id: int, user: dict = Depends(current_user)): + channel = get_channel_by_id(channel_id, user["id"]) + if not channel: + raise HTTPException(status_code=404, detail="Канал не найден") + delete_channel(user["id"], channel_id) + flash(request, f"Канал «{channel['title']}» удалён.") + return RedirectResponse("/channels", status_code=303) + + +# === ПУБЛИКАЦИЯ ПОСТА === + +@app.get("/posts/{post_id}/publish") +def publish_form(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + channels = get_channels_for_user(user["id"]) + return render(request, "publish.html", user=user, post=post, channels=channels, + platform_label=platform_label) + + +@app.post("/posts/{post_id}/publish") +def publish_submit( + request: Request, + post_id: int, + channel_id: str = Form(...), + user: dict = Depends(current_user), +): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + + try: + cid = int(channel_id) + except ValueError: + cid = None + if not cid: + channels = get_channels_for_user(user["id"]) + return render( + request, "publish.html", status_code=400, + user=user, post=post, channels=channels, platform_label=platform_label, + errors=["Выберите канал для публикации."], + ) + + channel = get_channel_by_id(cid, user["id"]) + if not channel: + raise HTTPException(status_code=404, detail="Канал не найден") + + # Берём первую картинку поста (если есть) для публикации в Telegram + image_bytes = None + images = get_post_images(post_id, user["id"]) + if images: + fp = MEDIA_DIR / images[0]["filename"] + if fp.exists(): + image_bytes = fp.read_bytes() + + ok, message = publish_post(channel["chat_id"], post["content"], image_bytes=image_bytes) + if ok: + mark_post_published(post_id, user["id"], channel["title"]) + flash(request, f"✅ Пост опубликован в «{channel['title']}»") + else: + flash(request, f"❌ Ошибка публикации: {message}") + + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + +# === ФОНОВАЯ ГЕНЕРАЦИЯ (AJAX) === + +def _run_post_generation(task_id, user_id, platform, style, wishes, survey_id, plan_id=None): + """Фоновая генерация поста.""" + try: + update_task_status(task_id, 'running') + brand_data = None + if survey_id: + survey = get_brand_survey_by_id(survey_id, user_id) + if survey: + brand_data = survey["data"] + if not brand_data: + latest = get_latest_brand_survey_for_user(user_id) + if latest: + brand_data = latest["data"] + if not brand_data: + update_task_status(task_id, 'error', error="Нет медиа-образа. Создайте медиа-образ и повторите.") + return + settings = get_text_provider_settings(user_id) + gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen + content = gen.generate_post_from_brand_profile(brand_data, platform, style, wishes) + post_id = save_post_for_user(user_id, platform, content, style, wishes) + # Если пост создан из плана — ставим статус «опубликовано» + if plan_id: + try: + update_plan_status(user_id, int(plan_id), "published") + except (ValueError, Exception) as e: + print(f"⚠️ Не удалось обновить статус плана {plan_id}: {e}") + update_task_status(task_id, 'done', result_json=json.dumps({"post_id": post_id, "content": content})) + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_plan_generation(task_id, user_id, days): + """Фоновая генерация контент-плана.""" + try: + update_task_status(task_id, 'running') + settings = get_text_provider_settings(user_id) + gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen + latest = get_latest_brand_survey_for_user(user_id) + if latest: + plan = gen.generate_content_plan_from_brand_profile(latest["data"], days) + else: + plan = gen.generate_content_plan("бизнес", "не указано", days) + replace_plan_for_user(user_id, plan) + update_task_status(task_id, 'done', result_json=json.dumps({"days": days, "items": len(plan)})) + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_modify_post(task_id, user_id, post_id, instruction): + """Фоновая модификация поста по инструкции пользователя.""" + try: + update_task_status(task_id, 'running') + + post = get_post_by_id_for_user(post_id, user_id) + if not post: + update_task_status(task_id, 'error', error="Пост не найден") + return + + if not instruction.strip(): + update_task_status(task_id, 'error', error="Инструкция не может быть пустой") + return + + # Получаем AI-провайдер + settings = get_text_provider_settings(user_id) + gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen + + new_content = gen.modify_post(post["content"], instruction.strip()) + + # Сохраняем новую версию + update_post_content(user_id, post_id, new_content) + add_post_revision(post_id, user_id, new_content) + + update_task_status(task_id, 'done', result_json=json.dumps({ + "content": new_content, + "instruction": instruction.strip(), + }, ensure_ascii=False)) + + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_social_analysis(task_id, user_id, url, manual_posts): + """Фоновый анализ профиля соцсети.""" + try: + update_task_status(task_id, 'running') + + platform_info = detect_platform(url) + posts = [] + + # Если пользователь вставил тексты постов вручную + if manual_posts and manual_posts.strip(): + blocks = re.split(r'\n{2,}', manual_posts.strip()) + for block in blocks: + block = block.strip() + if len(block) > 20: + posts.append({"text": block, "date": ""}) + + # Если URL — Telegram, загружаем посты автоматически + if not posts and platform_info["platform"] == "telegram" and platform_info["fetchable"]: + posts = fetch_telegram_posts(platform_info["channel"]) + + if not posts: + update_task_status(task_id, 'error', + error="Не удалось загрузить посты. Для VK и Tenchat вставьте тексты постов вручную (по 2+ постов через пустую строку).") + return + + # AI-анализ + settings = get_text_provider_settings(user_id) + result = analyze_posts_with_ai(posts, provider_settings=settings) + + if "error" in result: + update_task_status(task_id, 'error', error=result["error"]) + return + + result["platform_info"] = platform_info + result["posts_count"] = len(posts) + update_task_status(task_id, 'done', result_json=json.dumps(result, ensure_ascii=False)) + + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_image_generation(task_id, user_id, prompt, size, style, reference_image_bytes=None): + """Фоновая генерация картинки.""" + try: + update_task_status(task_id, 'running') + settings = get_image_provider_settings(user_id) + gen = ImageGenerator(provider_settings=settings) if settings and settings.get("api_key") else image_gen + full_prompt = gen.build_prompt(prompt, style) + if reference_image_bytes: + # Для image-to-image нужен chat/completions — используем text-провайдер + text_settings = get_text_provider_settings(user_id) + image_bytes = gen.generate_with_reference(full_prompt, reference_image_bytes, size, chat_settings=text_settings) + else: + image_bytes = gen.generate(full_prompt, size) + filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}" + (MEDIA_DIR / filename).write_bytes(image_bytes) + save_media(user_id, prompt, style, filename) + update_task_status(task_id, 'done', result_json=json.dumps({"filename": filename})) + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_analyze_post_image(task_id, user_id, post_id): + """Фоновый анализ поста — предлагает концепции изображений.""" + try: + update_task_status(task_id, 'running') + post = get_post_by_id_for_user(post_id, user_id) + if not post: + update_task_status(task_id, 'error', error="Пост не найден") + return + post_text = (post["content"] or "")[:2000] + + # Получаем медиа-образ для контекста бренда + brand_data = None + latest = get_latest_brand_survey_for_user(user_id) + if latest: + brand_data = latest["data"] + + settings = get_text_provider_settings(user_id) + gen = ContentGenerator(provider_settings=settings) if settings and settings.get("api_key") else content_gen + concepts = gen.suggest_image_concepts(post_text, brand_data) + update_task_status(task_id, 'done', result_json=json.dumps({"concepts": concepts})) + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +def _run_post_image_generation(task_id, user_id, post_id, template_key=None, custom_prompt=None, custom_style=None, custom_size=None): + """Фоновая генерация картинки к посту.""" + try: + update_task_status(task_id, 'running') + post = get_post_by_id_for_user(post_id, user_id) + if not post: + update_task_status(task_id, 'error', error="Пост не найден") + return + settings = get_image_provider_settings(user_id) + gen = ImageGenerator(provider_settings=settings) if settings and settings.get("api_key") else image_gen + + post_text = (post["content"] or "")[:500] + + if custom_prompt: + # Концепт от ИИ — используем его промпт + текст поста + prompt = f"{custom_prompt}. Post text for context: {post_text}" + size = custom_size or "wide" + style = custom_style or "none" + elif template_key: + # Ищем шаблон по ключу + tpl = None + for t in IMAGE_TEMPLATES: + if t["key"] == template_key: + tpl = t + break + if tpl: + prompt = tpl["prompt_template"].replace("{post_text}", post_text) + size = tpl["size"] + style = tpl["style"] + else: + prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching." + size = "square" + style = None + else: + # Старое поведение — авто-промпт + prompt = f"Create a professional social media image for this post. Post text: {post_text}. Style: modern, clean, eye-catching." + size = "square" + style = None + + if style: + prompt = gen.build_prompt(prompt, style) + + image_bytes = gen.generate(prompt, size) + ext = detect_ext(image_bytes) + filename = f"{uuid.uuid4().hex}{ext}" + (MEDIA_DIR / filename).write_bytes(image_bytes) + save_post_image(post_id, user_id, filename, source="generated") + update_task_status(task_id, 'done', result_json=json.dumps({"filename": filename})) + except Exception as e: + update_task_status(task_id, 'error', error=str(e)) + + +@app.post("/api/tasks") +async def api_start_task(request: Request, user: dict = Depends(current_user)): + """Запускает фоновую задачу. Возвращает task_id.""" + uid = user["id"] + form = await request.form() + task_type_val = form.get("task_type", "") + task_id = create_task(uid, task_type_val) + + if task_type_val == "generate_post": + platform = form.get("platform", "telegram") + style = form.get("style", "") + wishes = form.get("wishes", "") + survey_id = form.get("survey_id", "") + plan_id = form.get("plan_id", "") or None + t = threading.Thread(target=_run_post_generation, args=(task_id, uid, platform, style, wishes, survey_id, plan_id), daemon=True) + t.start() + + elif task_type_val == "generate_plan": + days = int(form.get("days", 7)) + days = max(1, min(days, 14)) + t = threading.Thread(target=_run_plan_generation, args=(task_id, uid, days), daemon=True) + t.start() + + elif task_type_val == "generate_image": + prompt = form.get("prompt", "") + size = form.get("size", "square") + style = form.get("style", "none") + ref_file = form.get("reference_image") + ref_bytes = None + if ref_file and hasattr(ref_file, "read"): + ref_bytes = await ref_file.read() + if len(ref_bytes) < 100: + ref_bytes = None # слишком маленький, пропускаем + t = threading.Thread(target=_run_image_generation, args=(task_id, uid, prompt, size, style, ref_bytes), daemon=True) + t.start() + + elif task_type_val == "generate_post_image": + post_id = int(form.get("post_id", 0)) + template_key = form.get("template_key", "") or None + t = threading.Thread(target=_run_post_image_generation, args=(task_id, uid, post_id, template_key), daemon=True) + t.start() + + elif task_type_val == "analyze_post_image": + post_id = int(form.get("post_id", 0)) + t = threading.Thread(target=_run_analyze_post_image, args=(task_id, uid, post_id), daemon=True) + t.start() + + elif task_type_val == "generate_post_image_with_concept": + post_id = int(form.get("post_id", 0)) + concept_prompt = form.get("concept_prompt", "") + concept_style = form.get("concept_style", "none") + concept_size = form.get("concept_size", "square") + t = threading.Thread( + target=_run_post_image_generation, + args=(task_id, uid, post_id), + kwargs={"custom_prompt": concept_prompt, "custom_style": concept_style, "custom_size": concept_size}, + daemon=True, + ) + t.start() + + elif task_type_val == "analyze_social_profile": + url = form.get("url", "") + manual_posts = form.get("manual_posts", "") + t = threading.Thread( + target=_run_social_analysis, + args=(task_id, uid, url, manual_posts), + daemon=True, + ) + t.start() + + elif task_type_val == "modify_post": + post_id = int(form.get("post_id", 0)) + instruction = form.get("instruction", "") + t = threading.Thread( + target=_run_modify_post, + args=(task_id, uid, post_id, instruction), + daemon=True, + ) + t.start() + + return {"task_id": task_id, "status": "pending"} + + +@app.get("/api/tasks/{task_id}") +def api_task_status(task_id: int, user: dict = Depends(current_user)): + """Возвращает статус задачи.""" + task = get_task(task_id) + if not task or task["user_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Задача не найдена") + result = { + "task_id": task["id"], + "status": task["status"], + "task_type": task["task_type"], + } + if task["status"] == "done" and task["result_json"]: + result["result"] = json.loads(task["result_json"]) + if task["error"]: + result["error"] = task["error"] + return result diff --git a/cabinet_service/brand_doc_generator.py b/cabinet_service/brand_doc_generator.py new file mode 100644 index 0000000..c9a127a --- /dev/null +++ b/cabinet_service/brand_doc_generator.py @@ -0,0 +1,312 @@ +"""Генерация .docx документа стратегии личного бренда из данных опроса.""" + +import io +from datetime import datetime + +from docx import Document +from docx.shared import Pt, RGBColor, Inches +from docx.enum.text import WD_ALIGN_PARAGRAPH + + +def _split_lines(text): + """Разбивает текст на строки, удаляя пустые.""" + if not text: + return [] + return [line.strip() for line in text.strip().split("\n") if line.strip()] + + +def _split_pipe(text): + """Разбивает строки по символу '|' на колонки.""" + rows = [] + for line in _split_lines(text): + cols = [c.strip() for c in line.split("|")] + rows.append(cols) + return rows + + +def _add_table(doc, headers, rows): + """Добавляет таблицу с заголовками и строками.""" + table = doc.add_table(rows=1, cols=len(headers)) + table.style = "Table Grid" + hdr = table.rows[0].cells + for i, h in enumerate(headers): + hdr[i].text = h + for row_data in rows: + cells = table.add_row().cells + for i, val in enumerate(row_data): + cells[i].text = val if i < len(row_data) else "" + doc.add_paragraph() + + +def generate_brand_docx(data: dict) -> bytes: + """Генерирует .docx документ стратегии личного бренда или организации. + data — dict с полями опроса. + Возвращает байты документа.""" + doc = Document() + + brand_type = data.get("brand_type", "personal") + is_org = (brand_type == "organization") + is_community = (brand_type == "community") + + # === ТИТУЛ === + if is_community: + entity_name = data.get("org_name", "") + title = doc.add_heading(f"Стратегия развития бизнес-сообщества\n{entity_name}", 0) + elif is_org: + entity_name = data.get("org_name", "") + title = doc.add_heading(f"Стратегия бренд-коммуникаций\n{entity_name}", 0) + else: + entity_name = data.get("client_name", "") + title = doc.add_heading(f"Стратегия развития личного бренда\n{entity_name}", 0) + title.alignment = WD_ALIGN_PARAGRAPH.CENTER + + version = data.get("version", "1.0") + date_str = data.get("date_str") or datetime.now().strftime("%B %Y") + doc.add_paragraph(f"Версия {version} {date_str}") + doc.add_paragraph() + + # === 1. РЕЗЮМЕ И ЦЕЛИ === + doc.add_heading("1. Резюме и цели", level=1) + + if is_community: + doc.add_heading("Общественная организация:", level=3) + doc.add_paragraph(f"Название: {data.get('org_name', '—')}") + doc.add_paragraph(f"Сфера деятельности: {data.get('org_industry', '—')}") + doc.add_paragraph(f"Миссия: {data.get('org_mission', '—')}") + doc.add_paragraph(f"Направления деятельности: {data.get('org_products', '—')}") + doc.add_paragraph(f"Участников: {data.get('org_team_size', '—')}") + doc.add_paragraph(f"Год основания: {data.get('org_founded', '—')}") + doc.add_paragraph(f"Формат участия: {data.get('org_format', '—')}") + elif is_org: + doc.add_heading("Организация:", level=3) + doc.add_paragraph(f"Название: {data.get('org_name', '—')}") + doc.add_paragraph(f"Сфера деятельности: {data.get('org_industry', '—')}") + doc.add_paragraph(f"Миссия: {data.get('org_mission', '—')}") + doc.add_paragraph(f"Продукты/услуги: {data.get('org_products', '—')}") + doc.add_paragraph(f"Сотрудников: {data.get('org_team_size', '—')}") + doc.add_paragraph(f"Год основания: {data.get('org_founded', '—')}") + else: + doc.add_heading("Текущая ситуация:", level=3) + doc.add_paragraph(data.get("current_situation", "—")) + + doc.add_heading("Ключевые цели на 12 месяцев:", level=3) + + doc.add_paragraph( + f"Качественный рост аудитории: Набрать {data.get('goal_audience_number', '—')} " + f"подписчиков в основном {data.get('goal_audience_platform', '—')} " + f"(активная аудитория: {data.get('goal_audience_description', '—')})." + ) + doc.add_paragraph( + f"Укрепление экспертного статуса: Стать признанным федеральным экспертом " + f"в сфере {data.get('goal_expert_field', '—')} " + f"(не менее {data.get('goal_expert_speaking', '—')} приглашений выступить " + f"на федеральных площадках в год)." + ) + doc.add_paragraph( + f"Коммерциализация личного бренда: Обеспечить поток из не менее " + f"{data.get('goal_commercial_leads', '—')} заявок в месяц на платные услуги " + f"({data.get('goal_commercial_services', '—')}) через личный бренд." + ) + doc.add_paragraph( + f"Системный контент: {data.get('goal_content_system', '—')}" + ) + + # === 2. ПОЗИЦИОНИРОВАНИЕ И УТП === + doc.add_heading("2. Позиционирование и УТП", level=1) + + doc.add_heading("Сущность бренда:", level=3) + doc.add_paragraph(data.get("brand_essence", "—")) + + doc.add_heading("Позиционирование (Ответы на ключевые вопросы):", level=3) + if is_community: + doc.add_paragraph(f"Для кого создано: {data.get('positioning_who', '—')}") + doc.add_paragraph(f"Какую проблему решает: {data.get('positioning_problem', '—')}") + doc.add_paragraph(f"Ценность для участника: {data.get('positioning_result', '—')}") + doc.add_paragraph(f"Чем отличаемся от других: {data.get('positioning_difference', '—')}") + doc.add_paragraph(f"Форматы взаимодействия: {data.get('community_formats', '—')}") + elif is_org: + doc.add_paragraph(f"Кому помогаем: {data.get('positioning_who', '—')}") + doc.add_paragraph(f"Какую проблему решаем: {data.get('positioning_problem', '—')}") + doc.add_paragraph(f"Какой результат получает клиент: {data.get('positioning_result', '—')}") + doc.add_paragraph(f"Почему выбирают нас: {data.get('positioning_difference', '—')}") + else: + doc.add_paragraph(f"Кому я помогаю? {data.get('positioning_who', '—')}") + doc.add_paragraph(f"Какую проблему решаю? {data.get('positioning_problem', '—')}") + doc.add_paragraph(f"Какой результат получает клиент? {data.get('positioning_result', '—')}") + + doc.add_heading("Уникальное торговое предложение (УТП):", level=3) + doc.add_paragraph(data.get("usp", "—")) + + # === 3. АУДИТОРИИ И КАНАЛЫ === + doc.add_heading("3. Аудитории и каналы коммуникации", level=1) + + doc.add_heading("Идеальные подписчики/клиенты:", level=3) + for line in _split_lines(data.get("ideal_audiences", "")): + doc.add_paragraph(line, style="List Bullet") + + doc.add_heading("Карта каналов (по приоритету):", level=3) + channels = data.get("channels", []) + if channels: + _add_table(doc, ["Канал", "Тип", "Частота", "Описание"], [ + [ch.get("platform", ""), ch.get("type", ""), ch.get("frequency", ""), ch.get("description", "")] + for ch in channels + ]) + else: + doc.add_paragraph("—") + + # === 4. ВИЗУАЛЬНАЯ И ВЕРБАЛЬНАЯ ИДЕНТИЧНОСТЬ === + doc.add_heading("4. Визуальная и вербальная идентичность", level=1) + + doc.add_heading("А. Визуальная идентичность", level=3) + doc.add_paragraph(f"Концепция: {data.get('visual_concept', '—')}") + + doc.add_paragraph("Цветовая палитра:") + _add_table(doc, ["Роль", "Название", "HEX"], [ + ["Основной", data.get("color_main_name", ""), data.get("color_main_hex", "")], + ["Акцент 1", data.get("color_accent1_name", ""), data.get("color_accent1_hex", "")], + ["Акцент 2", data.get("color_accent2_name", ""), data.get("color_accent2_hex", "")], + ["Нейтральная база", data.get("color_neutral", ""), ""], + ]) + + doc.add_paragraph("Типографика:") + doc.add_paragraph(f"Заголовки: {data.get('typography_headings', '—')}", style="List Bullet") + doc.add_paragraph(f"Текст: {data.get('typography_body', '—')}", style="List Bullet") + doc.add_paragraph(f"Акценты: {data.get('typography_accents', '—')}", style="List Bullet") + + doc.add_heading("Б. Tone of Voice (Тон коммуникации)", level=3) + doc.add_paragraph(data.get("tov_description", "—")) + doc.add_paragraph(f"Референсы: {data.get('tov_references', '—')}") + + tov_axes = _split_lines(data.get("tov_axes", "")) + if tov_axes: + doc.add_paragraph("Оси ToV:") + for axis in tov_axes: + doc.add_paragraph(axis, style="List Number") + + # === 5. КОНТЕНТ-СТРАТЕГИЯ === + doc.add_heading("5. Контент-стратегия: Три столпа", level=1) + doc.add_paragraph( + "Контент строится на балансе трех направлений, работающих на разные этапы воронки доверия." + ) + + pillars = [ + ("1", data.get("pillar1_name", "Кто я"), data.get("pillar1_goal", ""), + data.get("pillar1_formats", ""), data.get("pillar1_frequency", "30%")), + ("2", data.get("pillar2_name", "Решаю боль"), data.get("pillar2_goal", ""), + data.get("pillar2_formats", ""), data.get("pillar2_frequency", "50%")), + ("3", data.get("pillar3_name", "Втягиваю"), data.get("pillar3_goal", ""), + data.get("pillar3_formats", ""), data.get("pillar3_frequency", "20%")), + ] + + _add_table(doc, ["Столп", "Цель", "Ключевые форматы", "Частота"], [ + [f"{p[1]}", p[2], p[3], p[4]] + for p in pillars + ]) + + special = data.get("special_formats", "") + if special: + doc.add_paragraph(f"Особые форматы: {special}") + + # === 6. ВОРОНКА И ПРОДУКТЫ === + doc.add_heading("6. Воронка вовлечения и продуктовый ряд", level=1) + + doc.add_heading("Воронка (путь от незнакомца к клиенту):", level=3) + funnel_stages = data.get("funnel", []) + if funnel_stages: + _add_table(doc, ["Этап", "Контент", "CTA"], [ + [s.get("name", ""), s.get("content", ""), s.get("cta", "")] + for s in funnel_stages + ]) + else: + doc.add_paragraph("—") + + doc.add_heading("Продуктовый ряд (от простого к сложному):", level=3) + doc.add_paragraph(f"Входная точка (Lead Magnet): {data.get('product_lead_magnet', '—')}") + doc.add_paragraph(f"Самая простая услуга: {data.get('product_simple', '—')}") + doc.add_paragraph(f"Услуга-конвертер: {data.get('product_converter', '—')}") + doc.add_paragraph(f"Комплексные решения: {data.get('product_complex', '—')}") + doc.add_paragraph(f"Экспертные продукты: {data.get('product_expert', '—')}") + + # === 7. МЕТРИКИ === + doc.add_heading("7. Метрики", level=1) + + quant_rows = _split_pipe(data.get("metrics_quantitative", "")) + if quant_rows: + doc.add_heading("Количественные:", level=3) + _add_table(doc, ["Метрика", "Целевое значение", "Измерение"], quant_rows) + + qual_rows = _split_pipe(data.get("metrics_qualitative", "")) + if qual_rows: + doc.add_heading("Качественные:", level=3) + _add_table(doc, ["Метрика", "Целевое значение", "Измерение"], qual_rows) + + comm_rows = _split_pipe(data.get("metrics_commercial", "")) + if comm_rows: + doc.add_heading("Коммерческие:", level=3) + _add_table(doc, ["Метрика", "Целевое значение", "Измерение"], comm_rows) + + # === 8. РИСКИ И РЕКОМЕНДАЦИИ === + doc.add_heading("8. Потенциальные риски и рекомендации", level=1) + + risk_rows = _split_pipe(data.get("risks", "")) + if risk_rows: + doc.add_heading("Потенциальные риски:", level=3) + _add_table(doc, ["Риск", "Решение"], risk_rows) + + recs = _split_lines(data.get("recommendations", "")) + if recs: + doc.add_heading("Рекомендации:", level=3) + for rec in recs: + doc.add_paragraph(rec, style="List Bullet") + + # === 9. АРХЕТИПИЧЕСКИЙ ОБРАЗ === + doc.add_heading("9. Архетипический образ", level=1) + + doc.add_heading(f"Ключевой архетип: {data.get('archetype_key_name', '—')}", level=3) + doc.add_paragraph(f"Суть: {data.get('archetype_key_essence', '—')}") + manifestations = _split_lines(data.get("archetype_key_manifestations", "")) + if manifestations: + doc.add_paragraph("Проявления:") + for m in manifestations: + doc.add_paragraph(m, style="List Bullet") + + supporting = _split_pipe(data.get("archetype_supporting", "")) + if supporting: + doc.add_heading("Вспомогательные архетипы:", level=3) + _add_table(doc, ["Архетип", "Суть", "Проявления"], supporting) + + doc.add_heading("Синтез архетипов:", level=3) + doc.add_paragraph(data.get("archetype_synthesis", "—")) + + table_rows = _split_pipe(data.get("archetype_table", "")) + if table_rows: + doc.add_heading("Таблица проявлений:", level=3) + _add_table(doc, ["Ситуация / Контент-формат", "Доминирующий архетип", "Как это выглядит и звучит"], table_rows) + + # === 10. ПРОМПТ ДЛЯ ГЕНЕРАЦИИ КОНТЕНТА === + doc.add_heading("10. Промпт для генерации контента", level=1) + + doc.add_heading("Контекст и личность:", level=3) + doc.add_paragraph(data.get("prompt_context", "—")) + + doc.add_heading("Tone of Voice:", level=3) + doc.add_paragraph(data.get("prompt_tov", "—")) + + doc.add_heading("Структура мысли:", level=3) + doc.add_paragraph(data.get("prompt_structure", "—")) + + doc.add_heading("Контент-направления:", level=3) + for line in _split_lines(data.get("prompt_directions", "")): + doc.add_paragraph(line, style="List Bullet") + + doc.add_heading("Форматы и шаблоны:", level=3) + doc.add_paragraph(data.get("prompt_formats", "—")) + + doc.add_heading("Технические требования к тексту:", level=3) + doc.add_paragraph(data.get("prompt_technical", "—")) + + # === Сериализация в байты === + buf = io.BytesIO() + doc.save(buf) + buf.seek(0) + return buf.read() diff --git a/cabinet_service/static/style.css b/cabinet_service/static/style.css index b704d74..176bddc 100644 --- a/cabinet_service/static/style.css +++ b/cabinet_service/static/style.css @@ -120,43 +120,162 @@ a:hover { text-decoration: underline; } -.topbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 20px; +/* === Sidebar === */ + +.sidebar { + position: fixed; + top: 0; + left: 0; + width: 220px; + height: 100vh; background: var(--surface); - border-bottom: 1px solid var(--border); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + z-index: 100; + overflow-y: auto; + transition: transform 0.25s ease, background-color 0.2s ease; } -.topbar .brand { - font-weight: 600; +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 18px 16px; + font-weight: 700; font-size: 1.1rem; color: var(--text); + text-decoration: none; + border-bottom: 1px solid var(--border); + flex-shrink: 0; } -.topbar nav { +.sidebar-brand:hover { + text-decoration: none; +} + +.sidebar-brand-icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 10px; + flex: 1; +} + +.sidebar-link { display: flex; - flex-wrap: wrap; align-items: center; - gap: 14px; -} - -.theme-toggle { - background: none; - border: 1px solid var(--border); + gap: 10px; + padding: 9px 12px; border-radius: 8px; - padding: 4px 10px; - font-size: 1rem; - line-height: 1.4; + font-size: 0.9rem; + color: var(--text-muted); + text-decoration: none; + transition: background-color 0.15s ease, color 0.15s ease; + border: none; + background: none; + font: inherit; cursor: pointer; + width: 100%; + text-align: left; } -.theme-toggle:hover { +.sidebar-link svg { + width: 18px; + height: 18px; + flex-shrink: 0; + stroke-width: 1.8; +} + +.sidebar-link:hover { background: var(--accent-soft); - border-color: var(--accent-soft-border); + color: var(--accent); + text-decoration: none; +} + +.sidebar-link.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.sidebar-divider { + height: 1px; + background: var(--border); + margin: 6px 12px; +} + +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; +} + +.sidebar-form { + margin: 0; + padding: 0; +} + +.sidebar-toggle { + display: none; + position: fixed; + top: 14px; + left: 14px; + z-index: 200; + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.sidebar-toggle svg { + width: 20px; + height: 20px; +} + +.sidebar-toggle:hover { + background: var(--accent-soft); +} + +.main-content { + margin-left: 220px; + max-width: 720px; + padding: 28px 24px 60px; +} + +@media (max-width: 820px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2); + } + + .sidebar-toggle { + display: flex; + } + + .main-content { + margin-left: 0; + padding: 60px 16px 40px; + } } .inline-form { @@ -164,25 +283,6 @@ a:hover { margin: 0; } -.link-button { - background: none; - border: none; - padding: 0; - font: inherit; - color: var(--accent); - cursor: pointer; -} - -.link-button:hover { - text-decoration: underline; -} - -.container { - max-width: 720px; - margin: 0 auto; - padding: 24px 20px 60px; -} - h1 { font-size: 1.5rem; margin-bottom: 16px; @@ -553,3 +653,557 @@ h2 { color: var(--text-faint); margin: 0; } + +/* === Опрос медиа-образа === */ + +.survey-wizard { + max-width: 760px; + margin: 0 auto; +} + +.survey-progress { + height: 6px; + background: var(--border); + border-radius: 999px; + overflow: hidden; + margin-bottom: 10px; +} + +.survey-progress-bar { + height: 100%; + background: var(--accent); + width: 0%; + transition: width 0.3s ease; + border-radius: 999px; +} + +.survey-steps-indicator { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 20px; +} + +.step-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 0.8rem; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-faint); + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; +} + +.step-dot.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.step-dot.done { + background: var(--success-bg); + border-color: var(--success-border); + color: var(--success-text); +} + +.survey-step h2 { + margin-top: 0; +} + +.survey-step h3 { + font-size: 1rem; + margin-top: 18px; + margin-bottom: 8px; + color: var(--text-muted); +} + +.survey-step .form { + gap: 10px; +} + +.funnel-stage { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-bottom: 10px; + background: var(--bg); +} + +.funnel-stage label { + margin-bottom: 6px; +} + +.survey-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 20px; + padding: 16px 0; +} + +.survey-nav #stepCounter { + font-size: 0.9rem; + color: var(--text-faint); +} + +.survey-nav button { + background: var(--accent); + color: #fff; + border: none; + padding: 10px 18px; + border-radius: 8px; + font: inherit; + cursor: pointer; +} + +.survey-nav button:hover { + background: var(--accent-hover); +} + +.survey-nav button:disabled { + opacity: 0.6; + cursor: default; +} + +#channelsTable input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--input-bg); + color: var(--text); + font: inherit; + font-size: 0.9rem; +} + +/* === Изображения в списке постов === */ + +.post-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.post-thumb { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--border); +} + +/* === Loader / Background Task === */ + +.task-loader { + display: none; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 20px; + text-align: center; +} + +.spinner { + width: 44px; + height: 44px; + border: 4px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.task-loader-text { + font-size: 1rem; + color: var(--text-muted); +} + +.task-loader-sub { + font-size: 0.85rem; + color: var(--text-faint); +} + +.task-error { + display: none; + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; +} + +.task-result { + display: none; +} + +/* === Image Templates === */ + +.template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.template-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 14px 10px 12px; + border: 2px solid var(--border); + border-radius: 12px; + background: var(--surface); + cursor: pointer; + text-align: center; + transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease; + font: inherit; +} + +.template-card:hover { + border-color: var(--accent); + background: var(--accent-soft); + transform: translateY(-1px); +} + +.template-card.selected { + border-color: var(--accent); + background: var(--accent-soft); + box-shadow: 0 0 0 3px var(--accent-soft-border); +} + +.template-icon { + font-size: 1.6rem; + line-height: 1; +} + +.template-name { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.template-hint { + font-size: 0.75rem; + color: var(--text-faint); + line-height: 1.3; +} + +.template-card-custom { + border-style: dashed; +} + +.selected-hint { + background: var(--accent-soft); + border: 1px solid var(--accent-soft-border); + border-radius: 8px; + padding: 10px 14px; + font-size: 0.9rem; + color: var(--accent); + margin-bottom: 16px; +} + +/* === Concept Cards === */ + +.concept-grid { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.concept-card { + display: block; + width: 100%; + text-align: left; + padding: 14px 16px; + border: 2px solid var(--border); + border-radius: 12px; + background: var(--surface); + cursor: pointer; + font: inherit; + transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease; +} + +.concept-card:hover { + border-color: var(--accent); + background: var(--accent-soft); + transform: translateY(-1px); +} + +.concept-title { + font-weight: 600; + font-size: 1rem; + color: var(--text); + margin-bottom: 4px; +} + +.concept-desc { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.4; + margin-bottom: 6px; +} + +.concept-meta { + display: flex; + gap: 6px; +} + +.concept-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + background: var(--badge-bg); + color: var(--badge-text); +} + +/* === Provider Tabs === */ +.provider-tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--border); + margin-bottom: 20px; +} +.provider-tab { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + font-size: 15px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.provider-tab:hover { + color: var(--text); +} +.provider-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} +.provider-tab-content { + display: none; +} +.provider-tab-content.active { + display: block; +} + +/* === Inline Post Editor === */ + +.post-editor { + width: 100%; + min-height: 200px; + padding: 14px 16px; + font-family: inherit; + font-size: 0.95rem; + line-height: 1.6; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + resize: vertical; + transition: border-color 0.2s; +} +.post-editor:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} +.post-editor-actions { + display: flex; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.task-success { + background: var(--success-bg, #d4edda); + color: var(--success-text, #155724); + border: 1px solid var(--success-border, #c3e6cb); + border-radius: 8px; + padding: 8px 14px; + margin-bottom: 10px; + font-size: 0.9rem; +} + +/* === Revision Chat === */ + +.revision-chat { + display: flex; + flex-direction: column; + gap: 8px; +} +.revision-msg { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 16px; + cursor: pointer; + transition: border-color 0.2s, background 0.15s; +} +.revision-msg:hover { + border-color: var(--accent); + background: var(--accent-bg); +} +.revision-msg--current { + border-color: var(--accent); + border-width: 2px; +} +.revision-msg-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.revision-msg-label { + font-weight: 600; + font-size: 0.85rem; + color: var(--accent); +} +.revision-msg-date { + font-size: 0.78rem; + color: var(--text-muted); +} +.revision-msg-preview { + font-size: 0.88rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.revision-msg-full { + margin-top: 8px; +} +.revision-msg-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px; + font-family: inherit; + font-size: 0.9rem; + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 400px; + overflow-y: auto; +} + +/* === AI Chat === */ + +.ai-chat { + max-height: 500px; + overflow-y: auto; + padding: 10px 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.ai-chat-msg { + display: flex; + flex-direction: column; +} +.ai-chat-msg--user { + align-items: flex-end; +} +.ai-chat-msg--ai { + align-items: flex-start; +} +.ai-chat-msg--system { + align-items: center; +} +.ai-chat-bubble { + max-width: 85%; + padding: 10px 16px; + border-radius: 14px; + font-size: 0.9rem; + line-height: 1.5; + word-wrap: break-word; + white-space: pre-wrap; +} +.ai-chat-bubble--user { + background: var(--accent); + color: #fff; + border-bottom-right-radius: 4px; +} +.ai-chat-bubble--ai { + background: var(--surface); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; +} +.ai-chat-bubble--system { + background: var(--bg); + color: var(--text-muted); + font-size: 0.85rem; + border: 1px dashed var(--border); +} +.ai-chat-time { + font-size: 0.72rem; + color: var(--text-faint); + margin-top: 3px; + padding: 0 4px; +} +.ai-chat-meta { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 4px; +} +.ai-chat-preview { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.ai-chat-full pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; + font-family: inherit; + font-size: 0.88rem; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow-y: auto; +} +.ai-chat-input { + display: flex; + gap: 8px; + align-items: flex-end; + margin-top: 12px; +} +.ai-chat-textarea { + flex: 1; + padding: 10px 14px; + font-family: inherit; + font-size: 0.9rem; + line-height: 1.4; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + resize: none; + transition: border-color 0.2s; +} +.ai-chat-textarea:focus { + outline: none; + border-color: var(--accent); +} +.ai-chat-textarea:disabled { + opacity: 0.5; +} diff --git a/cabinet_service/templates/base.html b/cabinet_service/templates/base.html index 09fb4da..b588824 100644 --- a/cabinet_service/templates/base.html +++ b/cabinet_service/templates/base.html @@ -4,18 +4,17 @@ {% block title %}Личный кабинет{% endblock %} - + -
- 🗂 Личный кабинет - - {% else %} - Вход - Регистрация - - {% endif %} -
+ + + + +
{% if flash_message %}
{{ flash_message }}
{% endif %} @@ -77,12 +143,27 @@ {% endif %} {% block content %}{% endblock %}
+ diff --git a/cabinet_service/templates/channel_add.html b/cabinet_service/templates/channel_add.html new file mode 100644 index 0000000..32c001d --- /dev/null +++ b/cabinet_service/templates/channel_add.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Добавить канал — Личный кабинет{% endblock %} +{% block content %} +

Добавить канал

+
+

+ Введите @username канала/чата или числовой ID.
+ Бот должен быть добавлен в админы канала, иначе публикация не сработает. +

+
+
+ + +
+

← Назад к каналам

+{% endblock %} diff --git a/cabinet_service/templates/channels.html b/cabinet_service/templates/channels.html new file mode 100644 index 0000000..4fcae41 --- /dev/null +++ b/cabinet_service/templates/channels.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Каналы публикации — Личный кабинет{% endblock %} +{% block content %} +

Каналы публикации

+

Добавьте Telegram-каналы и чаты, куда бот будет публиковать посты. Бот должен быть админом в канале.

+ + + +{% if channels %} + + + + + + {% for ch in channels %} + + + + + + + + {% endfor %} + +
НазваниеТипUsernameДобавлен
{{ ch.title }}{{ ch.chat_type }}{% if ch.username %}@{{ ch.username }}{% else %}—{% endif %}{{ ch.created_at }} +
+ +
+
+{% else %} +
+

Нет добавленных каналов. Добавьте первый →

+
+{% endif %} +{% endblock %} diff --git a/cabinet_service/templates/dashboard.html b/cabinet_service/templates/dashboard.html index 4a25230..f45062e 100644 --- a/cabinet_service/templates/dashboard.html +++ b/cabinet_service/templates/dashboard.html @@ -4,15 +4,36 @@

Привет, {{ user.name }}!

Телефон: {{ user.phone }}

-

Вид бизнеса: {{ business_name }}

-

Описание: {{ user.description }}

+

Email: {{ user.email }}

В кабинете с: {{ user.created_at }}

+ +
+

Медиа-образ

+ {% if latest_survey %} +

Активный: {{ latest_survey.title }}

+

Посты генерируются на основе этого медиа-образа.

+
+ Открыть + ➕ Создать новый + 📤 Загрузить из файла +
+ {% else %} +

Медиа-образ ещё не создан. Создайте его — посты будут генерироваться на его основе.

+
+ ➕ Создать медиа-образ + 📤 Загрузить из файла +
+ {% endif %} +
+
💡 Сгенерировать пост 📅 Контент-план - 📜 История постов + 📜 Мои посты 🖼 Медиа-библиотека + 📢 Каналы публикации + 📋 Мои медиа-образы 📈 Статистика ✏️ Редактировать профиль 🗑 Удалить аккаунт diff --git a/cabinet_service/templates/generate.html b/cabinet_service/templates/generate.html index 025690f..6675ac1 100644 --- a/cabinet_service/templates/generate.html +++ b/cabinet_service/templates/generate.html @@ -2,25 +2,99 @@ {% block title %}Сгенерировать пост — Личный кабинет{% endblock %} {% block content %}

Пост в своём стиле

-
- - - - -
+ +{% if latest_survey %} +
+

Медиа-образ: {{ latest_survey.title }}

+

Пост сгенерируется на основе этого медиа-образа (личность, УТП, Tone of Voice, структура мысли).

+
+{% else %} +
+

⚠️ Медиа-образ не найден. Посты генерируются на основе медиа-образа.

+ ➕ Создать медиа-образ +
+{% endif %} + +
+ +
+
+
Генерирую пост...
+
Это может занять 15–60 секунд
+
+ +
+ +
+
+ {% if surveys and surveys|length > 1 %} + + {% endif %} + + + + + {% if prefill %}{% endif %} + +
+
+ {% if result %}

Готовый пост

{{ result }}
+
+ +
+ {% endif %}

← Назад в кабинет

+ + {% endblock %} diff --git a/cabinet_service/templates/history.html b/cabinet_service/templates/history.html index cfa766b..e53c538 100644 --- a/cabinet_service/templates/history.html +++ b/cabinet_service/templates/history.html @@ -1,13 +1,22 @@ {% extends "base.html" %} -{% block title %}История постов — Личный кабинет{% endblock %} +{% block title %}Мои посты — Личный кабинет{% endblock %} {% block content %} -

История постов

+

Мои посты

{% if posts %}
    {% for post in posts %}
  • - +
    {{ post.content }}
    + {% if post.images %} +
    + {% for img in post.images %} + + {% endfor %} +
    + {% endif %}
    👁 Открыть diff --git a/cabinet_service/templates/image_generate.html b/cabinet_service/templates/image_generate.html index a7b8a88..13f3784 100644 --- a/cabinet_service/templates/image_generate.html +++ b/cabinet_service/templates/image_generate.html @@ -2,26 +2,173 @@ {% block title %}Сгенерировать картинку — Личный кабинет{% endblock %} {% block content %}

    Генерация картинки

    -
    - - - - -

    Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.

    -
    + +
    + +
    +
    +
    Генерирую картинку...
    +
    Это может занять 15–60 секунд
    +
    + +
    + +
    + +

    Выбери шаблон

    +
    + {% for t in templates %} + + {% endfor %} + +
    + + + + +
    + + + + + + + +

    Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.

    +
    +

    ← Назад в библиотеку

    + + + + + {% endblock %} diff --git a/cabinet_service/templates/plan.html b/cabinet_service/templates/plan.html index 8019c6a..7346328 100644 --- a/cabinet_service/templates/plan.html +++ b/cabinet_service/templates/plan.html @@ -24,6 +24,7 @@
    {{ item.idea }}
    + ✍️ Создать пост
    - - -
    + +
    + +
    +
    +
    Составляю контент-план...
    +
    Генерация занимает 20–60 секунд
    +
    + +
    + +
    +
    + + + +
    +

    ← Назад в кабинет

    + + {% endblock %} diff --git a/cabinet_service/templates/post_detail.html b/cabinet_service/templates/post_detail.html index b4c7b3d..f2bbae4 100644 --- a/cabinet_service/templates/post_detail.html +++ b/cabinet_service/templates/post_detail.html @@ -7,18 +7,443 @@ {{ platform_label(post.platform) }} · {{ post.created_at }} {% if post.style %} · Стиль: {{ post.style }}{% endif %}
    -
    {{ post.content }}
    + {% if post.published %} + + {% endif %} {% if post.wishes %}
    Пожелания: {{ post.wishes }}
    {% endif %}
+ + +
+

📄 Текст поста

+
{{ post.content }}
+
+ + 📤 Опубликовать + ✏️ Редактировать вручную +
+
+ + +
+

🤖 Редактировать с помощью ИИ

+

Напишите, что нужно изменить — нейросеть перепишет пост. Например: «сократи вдвое», «сделай официальнее», «добавь призыв к действию».

+ + +
+
+
Пост сгенерирован. Напишите инструкцию, чтобы изменить его.
+
+
+ + + +
+ + +
+ + +
+
+ + + + + +
+

🖼 Изображения

+ {% if images %} +
+ {% for img in images %} +
+ + + +
+ {{ img.source }} · {{ img.created_at }} +
+ +
+
+
+ {% endfor %} +
+ {% else %} +

Изображений пока нет.

+ {% endif %} + + +

📎 Загрузить изображения

+
+ + +
+ + +

🎨 Сгенерировать изображение для поста

+
+ + +
+
+
Анализирую пост...
+
ИИ подбирает лучшие варианты
+
+ + +
+
+
Генерирую изображение...
+
Это может занять 15–60 секунд
+
+ +
+ + +
+

ИИ проанализирует пост и предложит варианты:

+ + +
+

Или выбери шаблон вручную:

+
+ {% for t in templates %} + + {% endfor %} +
+
+
+ + + + + + + + +
+
- - ✏️ Изменить + 📤 Опубликовать + ✏️ Полное редактирование
-

← Назад к истории

+

← Назад к постам

{% endblock %} diff --git a/cabinet_service/templates/profile_edit.html b/cabinet_service/templates/profile_edit.html index 47cd14c..271b23e 100644 --- a/cabinet_service/templates/profile_edit.html +++ b/cabinet_service/templates/profile_edit.html @@ -9,17 +9,18 @@ - - + +
+

Провайдер ИИ

+ {% if provider %} +

Текущий: {{ provider_presets[provider.provider_type].name if provider.provider_type in provider_presets else provider.provider_type }}

+ {% else %} +

Используются глобальные настройки (PiAPI).

+ {% endif %} + ⚙ Настроить провайдера +
+

← Назад в кабинет

{% endblock %} diff --git a/cabinet_service/templates/provider.html b/cabinet_service/templates/provider.html new file mode 100644 index 0000000..31bcc42 --- /dev/null +++ b/cabinet_service/templates/provider.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% block title %}Провайдеры ИИ — Личный кабинет{% endblock %} +{% block content %} +

Провайдеры ИИ

+

Настройте провайдеров для генерации текста и изображений независимо. API ключи хранятся только у вас.

+ +{% if provider %} +
+

+ Текст: {{ provider_presets[provider.text_provider_type].name if provider.text_provider_type in provider_presets else provider.text_provider_type }} → {{ provider.text_model }} +  |  + Изображения: + {% if provider.image_api_key %} + {{ provider_presets[provider.image_provider_type].name if provider.image_provider_type in provider_presets else provider.image_provider_type }} → {{ provider.image_model or '(не указана)' }} + {% else %} + глобальный PiAPI + {% endif %} +  · {{ provider.updated_at }} +

+
+{% else %} +
+

Используются глобальные настройки (PiAPI из .env). Настройте свои провайдеры для независимости.

+
+{% endif %} + + +
+ + +
+ +
+ + +
+

✍️ Генерация текста

+ + + + + + + + + +
+

Популярные модели для текста:

+
    +
  • gpt-4o — OpenAI, OpenRouter, PiAPI
  • +
  • claude-sonnet-5 — Anthropic через OpenRouter
  • +
  • glm-5.2 — OpenCode Zen (платная)
  • +
  • deepseek-v4-flash-free — OpenCode Zen (бесплатная)
  • +
  • hy3-free — OpenCode Zen (бесплатная, reasoning)
  • +
+
+
+ + +
+

🎨 Генерация изображений

+ + + + + + + + + +
+

Популярные модели для изображений:

+
    +
  • gpt-image-1 — OpenAI, PiAPI (рекомендуется)
  • +
  • gpt-image-1-mini — OpenAI (быстрая, дешевле)
  • +
  • dall-e-3 — OpenAI (старая, через прямой API)
  • +
+
+
+ + +
+ +

← Назад в профиль · ← На главную

+ + +{% endblock %} diff --git a/cabinet_service/templates/publish.html b/cabinet_service/templates/publish.html new file mode 100644 index 0000000..d60122e --- /dev/null +++ b/cabinet_service/templates/publish.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Опубликовать пост — Личный кабинет{% endblock %} +{% block content %} +

Публикация поста

+ +
+

Платформа: {{ platform_label(post.platform) }}

+

Создан: {{ post.created_at }}

+ {% if post.published %} +

Статус: ✅ Опубликован в «{{ post.published_to }}» {{ post.published_at }}

+ {% else %} +

Статус: ⏳ Не опубликован

+ {% endif %} +
+ +
+

Текст поста

+
{{ post.content }}
+
+ +{% if channels %} +
+ + +
+{% else %} +
+

⚠️ Нет добавленных каналов. Добавьте канал →

+
+{% endif %} + +
+ ← Назад к посту + ⚙ Управление каналами +
+{% endblock %} diff --git a/cabinet_service/templates/register.html b/cabinet_service/templates/register.html index 58029a4..91934cd 100644 --- a/cabinet_service/templates/register.html +++ b/cabinet_service/templates/register.html @@ -9,17 +9,6 @@ - - diff --git a/cabinet_service/templates/survey.html b/cabinet_service/templates/survey.html new file mode 100644 index 0000000..acccea1 --- /dev/null +++ b/cabinet_service/templates/survey.html @@ -0,0 +1,660 @@ +{% extends "base.html" %} +{% block title %}Опрос медиа-образа — Личный кабинет{% endblock %} +{% block content %} +

Опрос для создания медиа-образа

+

Заполните поля по шагам. На последнем шаге можно сохранить и сгенерировать документ стратегии.

+ + +
+

🔍 Автозаполнение из профиля соцсети

+

Вставьте ссылку на профиль или канал — ИИ проанализирует посты и заполнит поля медиа-образа. Поддерживается Telegram (автоматически), VK, Tenchat, Instagram (вручную).

+ +
+ + + +
+ + + + + +
+
+ + + + +
+ +
+
+
+
+ +
+ + +
+

Шаг 1 — Основная информация

+
+ + + +
+ + +
+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+
+ + +{% endblock %} diff --git a/cabinet_service/templates/survey_import.html b/cabinet_service/templates/survey_import.html new file mode 100644 index 0000000..c5dac7a --- /dev/null +++ b/cabinet_service/templates/survey_import.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Загрузить медиа-образ — Личный кабинет{% endblock %} +{% block content %} +

Загрузить медиа-образ

+ +
+

Загрузите готовый медиа-образ файлом — он будет автоматически разобран и сохранён.

+

Поддерживаются два формата:

+
    +
  • .docx — документ стратегии (как образец). Разбирается через ИИ: извлекаются личность, УТП, Tone of Voice, столпы контента, архетипы и все поля опроса.
  • +
  • .json — структурированные данные опроса (прямой импорт полей).
  • +
+
+ +
+ + +
+ +
+ ➕ Заполнить форму вручную + ← К списку медиа-образов +
+{% endblock %} diff --git a/cabinet_service/templates/survey_list.html b/cabinet_service/templates/survey_list.html new file mode 100644 index 0000000..a0d6d15 --- /dev/null +++ b/cabinet_service/templates/survey_list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Медиа-образы — Личный кабинет{% endblock %} +{% block content %} +

Медиа-образы

+
+ ➕ Создать новый + 📤 Загрузить из файла +
+ +{% if surveys %} + + + + + + {% for s in surveys %} + + + + + + {% endfor %} + +
НазваниеДата
{{ s.title }}{{ s.created_at }} + 📄 .docx +
+ +
+
+{% else %} +
+

Пока нет ни одного медиа-образа. Создайте первый →

+
+{% endif %} +{% endblock %} diff --git a/cabinet_service/templates/survey_view.html b/cabinet_service/templates/survey_view.html new file mode 100644 index 0000000..0959de5 --- /dev/null +++ b/cabinet_service/templates/survey_view.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} +{% block title %}{{ survey.title }} — Личный кабинет{% endblock %} +{% block content %} +

{{ survey.title }}

+
+ 📄 Скачать .docx + ➕ Новый опрос + ← К списку +
+ +
+
+ +
+

Дата создания: {{ survey.created_at }}

+
+ +{% set d = survey.data %} + + +
+

1. Резюме и цели

+

Клиент: {{ d.client_name }} ({{ d.client_role }}), {{ d.client_location }}

+

Текущая ситуация: {{ d.current_situation }}

+

Цели на 12 месяцев:

+
    +
  • Аудитория: {{ d.goal_audience_number }} подписчиков в {{ d.goal_audience_platform }} ({{ d.goal_audience_description }})
  • +
  • Экспертный статус: {{ d.goal_expert_field }}, {{ d.goal_expert_speaking }} выступлений/год
  • +
  • Коммерциализация: {{ d.goal_commercial_leads }} заявок/мес ({{ d.goal_commercial_services }})
  • +
  • Контент: {{ d.goal_content_system }}
  • +
+
+ + +
+

2. Позиционирование и УТП

+

Сущность бренда: {{ d.brand_essence }}

+

Кому помогаю: {{ d.positioning_who }}

+

Проблема: {{ d.positioning_problem }}

+

Результат: {{ d.positioning_result }}

+

УТП: {{ d.usp }}

+
+ + +
+

3. Аудитории и каналы

+

Идеальные подписчики:

+
    + {% for line in (d.ideal_audiences or '').split('\n') if line.strip() %} +
  • {{ line.strip() }}
  • + {% endfor %} +
+ {% if d.channels %} +

Каналы:

+ + + + {% for ch in d.channels %} + + {% endfor %} + +
ПлатформаТипЧастотаОписание
{{ ch.platform }}{{ ch.type }}{{ ch.frequency }}{{ ch.description }}
+ {% endif %} +
+ + +
+

4. Визуальная идентичность

+

Концепция: {{ d.visual_concept }}

+

Цвета:

+
    +
  • Основной: {{ d.color_main_name }} ({{ d.color_main_hex }})
  • +
  • Акцент 1: {{ d.color_accent1_name }} ({{ d.color_accent1_hex }})
  • +
  • Акцент 2: {{ d.color_accent2_name }} ({{ d.color_accent2_hex }})
  • +
  • Нейтральная база: {{ d.color_neutral }}
  • +
+

Типографика:

+
    +
  • Заголовки: {{ d.typography_headings }}
  • +
  • Текст: {{ d.typography_body }}
  • +
  • Акценты: {{ d.typography_accents }}
  • +
+
+ + +
+

5. Tone of Voice

+

Описание: {{ d.tov_description }}

+

Референсы: {{ d.tov_references }}

+ {% if d.tov_axes %} +

Оси ToV:

+
    + {% for line in d.tov_axes.split('\n') if line.strip() %} +
  • {{ line.strip() }}
  • + {% endfor %} +
+ {% endif %} +
+ + +
+

6. Контент-стратегия: Три столпа

+ + + + + + + +
СтолпЦельФорматыЧастота
{{ d.pillar1_name }}{{ d.pillar1_goal }}{{ d.pillar1_formats }}{{ d.pillar1_frequency }}
{{ d.pillar2_name }}{{ d.pillar2_goal }}{{ d.pillar2_formats }}{{ d.pillar2_frequency }}
{{ d.pillar3_name }}{{ d.pillar3_goal }}{{ d.pillar3_formats }}{{ d.pillar3_frequency }}
+ {% if d.special_formats %} +

Особые форматы: {{ d.special_formats }}

+ {% endif %} +
+ + +
+

7. Воронка и продуктовый ряд

+ {% if d.funnel %} + + + + {% for s in d.funnel %} + + {% endfor %} + +
ЭтапКонтентCTA
{{ s.name }}{{ s.content }}{{ s.cta }}
+ {% endif %} +

Продуктовый ряд:

+
    +
  • Lead Magnet: {{ d.product_lead_magnet }}
  • +
  • Простая услуга: {{ d.product_simple }}
  • +
  • Услуга-конвертер: {{ d.product_converter }}
  • +
  • Комплексные решения: {{ d.product_complex }}
  • +
  • Экспертные продукты: {{ d.product_expert }}
  • +
+
+ + +
+

8. Метрики

+ {% if d.metrics_quantitative %} +

Количественные:

+
{{ d.metrics_quantitative }}
+ {% endif %} + {% if d.metrics_qualitative %} +

Качественные:

+
{{ d.metrics_qualitative }}
+ {% endif %} + {% if d.metrics_commercial %} +

Коммерческие:

+
{{ d.metrics_commercial }}
+ {% endif %} +
+ + +
+

9. Риски и рекомендации

+ {% if d.risks %} +

Риски:

+
{{ d.risks }}
+ {% endif %} + {% if d.recommendations %} +

Рекомендации:

+
{{ d.recommendations }}
+ {% endif %} +
+ + +
+

10. Архетипический образ

+

Ключевой архетип: {{ d.archetype_key_name }}

+

Суть: {{ d.archetype_key_essence }}

+ {% if d.archetype_key_manifestations %} +
{{ d.archetype_key_manifestations }}
+ {% endif %} + {% if d.archetype_supporting %} +

Вспомогательные:

+
{{ d.archetype_supporting }}
+ {% endif %} + {% if d.archetype_synthesis %} +

Синтез:

+

{{ d.archetype_synthesis }}

+ {% endif %} + {% if d.archetype_table %} +

Таблица проявлений:

+
{{ d.archetype_table }}
+ {% endif %} +
+ + +
+

11. Промпт для генерации контента

+ {% if d.prompt_context %}

Контекст: {{ d.prompt_context }}

{% endif %} + {% if d.prompt_tov %}

ToV: {{ d.prompt_tov }}

{% endif %} + {% if d.prompt_structure %}

Структура: {{ d.prompt_structure }}

{% endif %} + {% if d.prompt_directions %}

Направления:

{{ d.prompt_directions }}
{% endif %} + {% if d.prompt_formats %}

Форматы:

{{ d.prompt_formats }}
{% endif %} + {% if d.prompt_technical %}

Тех. требования:

{{ d.prompt_technical }}
{% endif %} +
+{% endblock %} diff --git a/database.py b/database.py index b425bde..6e65545 100644 --- a/database.py +++ b/database.py @@ -80,6 +80,113 @@ def create_db(): ) ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS brand_surveys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + title TEXT, + data TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + chat_id TEXT, + title TEXT, + username TEXT, + chat_type TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS provider_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE, + text_provider_type TEXT DEFAULT 'piapi', + text_api_key TEXT, + text_base_url TEXT, + text_model TEXT, + image_provider_type TEXT DEFAULT 'piapi', + image_api_key TEXT, + image_base_url TEXT, + image_model TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS post_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER, + user_id INTEGER, + filename TEXT, + source TEXT DEFAULT 'upload', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_type TEXT NOT NULL, + status TEXT DEFAULT 'pending', + result_json TEXT, + error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS post_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE + ) + ''') + + # Миграция со старой схемы (provider_type/api_key/base_url/text_model/image_model) + # на новую (text_* + image_*). Если старых колонок нет — пропускаем. + cursor.execute("PRAGMA table_info(provider_settings)") + ps_columns = [row[1] for row in cursor.fetchall()] + if 'provider_type' in ps_columns and 'text_provider_type' in ps_columns: + # Промежуточное состояние миграции — копируем данные из старых колонок в новые + try: + cursor.execute(''' + UPDATE provider_settings SET + text_provider_type = COALESCE(text_provider_type, provider_type), + text_api_key = COALESCE(text_api_key, api_key), + text_base_url = COALESCE(text_base_url, base_url), + image_provider_type = COALESCE(image_provider_type, provider_type), + image_api_key = COALESCE(image_api_key, api_key), + image_base_url = COALESCE(image_base_url, base_url), + image_model = COALESCE(image_model, image_model) + WHERE api_key IS NOT NULL + ''') + except Exception: + pass + + # Колонка published в posts + cursor.execute("PRAGMA table_info(posts)") + posts_columns = [row[1] for row in cursor.fetchall()] + if 'published' not in posts_columns: + cursor.execute('ALTER TABLE posts ADD COLUMN published BOOLEAN DEFAULT 0') + print("✅ Колонка 'published' добавлена в таблицу posts") + if 'published_at' not in posts_columns: + cursor.execute('ALTER TABLE posts ADD COLUMN published_at TIMESTAMP') + print("✅ Колонка 'published_at' добавлена в таблицу posts") + if 'published_to' not in posts_columns: + cursor.execute('ALTER TABLE posts ADD COLUMN published_to TEXT') + print("✅ Колонка 'published_to' добавлена в таблицу posts") + conn.commit() conn.close() print("✅ База данных создана!") @@ -209,8 +316,10 @@ def get_user_by_id(user_id): return dict(row) if row else None -def create_web_user(email, password_hash, name, phone, business_type, description): - """Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя.""" +def create_web_user(email, password_hash, name, phone, business_type=None, description=None): + """Создаёт пользователя веб-кабинета (без telegram_id). + business_type и description опциональны — посты генерируются на основе медиа-образа. + Возвращает id нового пользователя.""" conn = sqlite3.connect('users.db') cursor = conn.cursor() cursor.execute(''' @@ -253,7 +362,15 @@ def save_post_for_user(user_id, platform, content, style=None, wishes=None): VALUES (?, ?, ?, ?, ?) ''', (user_id, platform, content, style, wishes)) conn.commit() + post_id = cursor.lastrowid + # Сохраняем начальную ревизию + cursor.execute( + 'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)', + (post_id, user_id, content), + ) + conn.commit() conn.close() + return post_id def get_posts_for_user(user_id, limit=10): @@ -327,6 +444,35 @@ def delete_post_by_id(user_id, post_id): conn.close() +# === РЕВИЗИИ ПОСТОВ === + +def add_post_revision(post_id, user_id, content): + """Сохраняет ревизию (каждое изменение текста поста).""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)', + (post_id, user_id, content), + ) + conn.commit() + conn.close() + + +def get_post_revisions(post_id, user_id, limit=50): + """Возвращает последние N ревизий поста (от новых к старым).""" + conn = sqlite3.connect('users.db') + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + 'SELECT id, content, created_at FROM post_revisions ' + 'WHERE post_id = ? AND user_id = ? ORDER BY id DESC LIMIT ?', + (post_id, user_id, limit), + ) + rows = [dict(r) for r in cursor.fetchall()] + conn.close() + return rows + + # === КОНТЕНТ-ПЛАН (веб-кабинет) === PLAN_STATUSES = ('planned', 'published', 'postponed') @@ -438,5 +584,384 @@ def delete_media_by_id(user_id, media_id): return None +# === ОПРОСЫ МЕДИА-ОБРАЗА (веб-кабинет) === + +import json as _json + + +def save_brand_survey(user_id, title, data): + """Сохраняет опрос медиа-образа. data — dict (сериализуется в JSON). + Возвращает id новой записи.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO brand_surveys (user_id, title, data) VALUES (?, ?, ?)', + (user_id, title, _json.dumps(data, ensure_ascii=False)) + ) + conn.commit() + survey_id = cursor.lastrowid + conn.close() + return survey_id + + +def get_brand_surveys_for_user(user_id, limit=50): + """Возвращает список опросов пользователя (без поля data).""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT id, title, created_at FROM brand_surveys ' + 'WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', + (user_id, limit) + ) + rows = [dict(row) for row in cursor.fetchall()] + conn.close() + return rows + + +def get_latest_brand_survey_for_user(user_id): + """Возвращает последний опрос медиа-образа пользователя (с data) или None.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT * FROM brand_surveys WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + (user_id,) + ) + row = cursor.fetchone() + conn.close() + if not row: + return None + result = dict(row) + result['data'] = _json.loads(result['data']) if result['data'] else {} + return result + + +def get_brand_survey_by_id(survey_id, user_id): + """Возвращает один опрос (с data) только если он принадлежит user_id.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT * FROM brand_surveys WHERE id = ? AND user_id = ?', + (survey_id, user_id) + ) + row = cursor.fetchone() + conn.close() + if not row: + return None + result = dict(row) + result['data'] = _json.loads(result['data']) if result['data'] else {} + return result + + +def delete_brand_survey(user_id, survey_id): + """Удаляет опрос. Только если он принадлежит user_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'DELETE FROM brand_surveys WHERE id = ? AND user_id = ?', + (survey_id, user_id) + ) + conn.commit() + conn.close() + + +# === КАНАЛЫ ДЛЯ ПУБЛИКАЦИИ (веб-кабинет) === + +def save_channel(user_id, chat_id, title, username, chat_type): + """Сохраняет канал/чат для публикации. Возвращает id записи.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO channels (user_id, chat_id, title, username, chat_type) VALUES (?, ?, ?, ?, ?)', + (user_id, str(chat_id), title, username, chat_type) + ) + conn.commit() + channel_id = cursor.lastrowid + conn.close() + return channel_id + + +def get_channels_for_user(user_id): + """Возвращает список каналов пользователя.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT id, chat_id, title, username, chat_type, created_at ' + 'FROM channels WHERE user_id = ? ORDER BY created_at DESC', + (user_id,) + ) + rows = [dict(row) for row in cursor.fetchall()] + conn.close() + return rows + + +def get_channel_by_id(channel_id, user_id): + """Возвращает один канал только если он принадлежит user_id.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT * FROM channels WHERE id = ? AND user_id = ?', + (channel_id, user_id) + ) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + +def delete_channel(user_id, channel_id): + """Удаляет канал. Только если он принадлежит user_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'DELETE FROM channels WHERE id = ? AND user_id = ?', + (channel_id, user_id) + ) + conn.commit() + conn.close() + + +def is_channel_already_added(user_id, chat_id): + """Проверяет, добавлен ли уже канал с таким chat_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'SELECT id FROM channels WHERE user_id = ? AND chat_id = ?', + (user_id, str(chat_id)) + ) + row = cursor.fetchone() + conn.close() + return row is not None + + +# === ОТМЕТКА О ПУБЛИКАЦИИ ПОСТА === + +def mark_post_published(post_id, user_id, channel_title): + """Отмечает пост как опубликованный.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'UPDATE posts SET published = 1, published_at = CURRENT_TIMESTAMP, ' + 'published_to = ? WHERE id = ? AND user_id = ?', + (channel_title, post_id, user_id) + ) + conn.commit() + conn.close() + + +# === НАСТРОЙКИ ПРОВАЙДЕРА ИИ (веб-кабинет) === + +# Пресеты провайдеров +PROVIDER_PRESETS = { + 'piapi': { + 'name': 'PiAPI', + 'base_url': 'https://api.piapi.ai/v1', + 'text_model': 'gpt-4o', + 'image_model': 'gpt-image-1', + 'needs_key': True, + 'supports_images': True, + }, + 'openrouter': { + 'name': 'OpenRouter', + 'base_url': 'https://openrouter.ai/api/v1', + 'text_model': 'openai/gpt-4o', + 'image_model': 'openai/dall-e-3', + 'needs_key': True, + 'supports_images': True, + }, + 'opencode': { + 'name': 'OpenCode Zen', + 'base_url': 'https://opencode.ai/zen/v1', + 'text_model': 'nemotron-3-ultra-free', + 'image_model': '', + 'needs_key': True, + 'supports_images': False, + }, + 'openai': { + 'name': 'OpenAI', + 'base_url': 'https://api.openai.com/v1', + 'text_model': 'gpt-4o', + 'image_model': 'gpt-image-1', + 'needs_key': True, + 'supports_images': True, + }, + 'custom': { + 'name': 'Кастомный (OpenAI-совместимый)', + 'base_url': '', + 'text_model': '', + 'image_model': '', + 'needs_key': True, + 'supports_images': True, + }, +} + + +def get_provider_settings(user_id): + """Возвращает настройки провайдеров пользователя (text + image) или None.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT text_provider_type, text_api_key, text_base_url, text_model, + image_provider_type, image_api_key, image_base_url, image_model, + updated_at + FROM provider_settings WHERE user_id = ? + ''', (user_id,)) + row = cursor.fetchone() + conn.close() + if not row: + return None + d = dict(row) + return { + # Text provider + 'text_provider_type': d['text_provider_type'], + 'text_api_key': d['text_api_key'], + 'text_base_url': d['text_base_url'], + 'text_model': d['text_model'], + # Image provider + 'image_provider_type': d['image_provider_type'], + 'image_api_key': d['image_api_key'], + 'image_base_url': d['image_base_url'], + 'image_model': d['image_model'], + # Meta + 'updated_at': d['updated_at'], + } + + +def get_text_provider_settings(user_id): + """Возвращает настройки только text-провайдера в формате для ContentGenerator.""" + s = get_provider_settings(user_id) + if not s or not s.get('text_api_key'): + return None + return { + 'provider_type': s['text_provider_type'], + 'api_key': s['text_api_key'], + 'base_url': s['text_base_url'], + 'text_model': s['text_model'], + } + + +def get_image_provider_settings(user_id): + """Возвращает настройки только image-провайдера в формате для ImageGenerator.""" + s = get_provider_settings(user_id) + if not s or not s.get('image_api_key'): + return None + return { + 'provider_type': s['image_provider_type'], + 'api_key': s['image_api_key'], + 'base_url': s['image_base_url'], + 'image_model': s['image_model'], + } + + +def save_provider_settings(user_id, text_provider_type, text_api_key, text_base_url, text_model, + image_provider_type, image_api_key, image_base_url, image_model): + """Сохраняет или обновляет настройки обоих провайдеров.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO provider_settings ( + user_id, text_provider_type, text_api_key, text_base_url, text_model, + image_provider_type, image_api_key, image_base_url, image_model, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + text_provider_type = excluded.text_provider_type, + text_api_key = excluded.text_api_key, + text_base_url = excluded.text_base_url, + text_model = excluded.text_model, + image_provider_type = excluded.image_provider_type, + image_api_key = excluded.image_api_key, + image_base_url = excluded.image_base_url, + image_model = excluded.image_model, + updated_at = CURRENT_TIMESTAMP + ''', (user_id, text_provider_type, text_api_key, text_base_url, text_model, + image_provider_type, image_api_key, image_base_url, image_model)) + conn.commit() + conn.close() + + +# === ИЗОБРАЖЕНИЯ ПОСТОВ (веб-кабинет) === + +def save_post_image(post_id, user_id, filename, source='upload'): + """Сохраняет запись об изображении, прикреплённом к посту.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO post_images (post_id, user_id, filename, source) VALUES (?, ?, ?, ?)', + (post_id, user_id, filename, source) + ) + conn.commit() + img_id = cursor.lastrowid + conn.close() + return img_id + + +def get_post_images(post_id, user_id): + """Возвращает все изображения поста (только если пост принадлежит user_id).""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT id, filename, source, created_at FROM post_images ' + 'WHERE post_id = ? AND user_id = ? ORDER BY created_at ASC', + (post_id, user_id) + ) + rows = [dict(row) for row in cursor.fetchall()] + conn.close() + return rows + + +def delete_post_image(image_id, user_id): + """Удаляет изображение. Возвращает filename для удаления файла, или None.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'SELECT filename FROM post_images WHERE id = ? AND user_id = ?', + (image_id, user_id) + ) + row = cursor.fetchone() + if row: + cursor.execute('DELETE FROM post_images WHERE id = ? AND user_id = ?', (image_id, user_id)) + conn.commit() + conn.close() + return row[0] + conn.close() + return None + + +# === Задачи (background tasks) === + +def create_task(user_id, task_type): + """Создаёт задачу. Возвращает task_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO tasks (user_id, task_type, status) VALUES (?, ?, ?)', + (user_id, task_type, 'pending') + ) + conn.commit() + task_id = cursor.lastrowid + conn.close() + return task_id + + +def update_task_status(task_id, status, result_json=None, error=None): + """Обновляет статус задачи.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'UPDATE tasks SET status = ?, result_json = ?, error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (status, result_json, error, task_id) + ) + conn.commit() + conn.close() + + +def get_task(task_id): + """Возвращает задачу по ID.""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM tasks WHERE id = ?', (task_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + # Создаем базу при первом импорте create_db() \ No newline at end of file diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..d8fccee --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,57 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ + +# База данных +*.db +*.sqlite +*.sqlite3 + +# Медиа файлы +media/ +*.jpg +*.jpeg +*.png +*.gif +*.webp +*.pdf + +# Логи +*.log +logs/ + +# Переменные окружения +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Секреты +secrets/ +*.key +*.pem + +# Деплой +deploy.sh +nginx-setup.sh +ssl-setup.sh +dev.sh +README.md \ No newline at end of file diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..579d260 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,212 @@ +# 🚀 ИИ Пресс-секретарь — Скрипты деплоя + +## 📁 Файлы + +| Скрипт | Назначение | +|--------|-----------| +| `install.sh` | Полная установка (все в одном) | +| `deploy.sh` | Деплой приложения | +| `nginx-setup.sh` | Настройка Nginx | +| `ssl-setup.sh` | SSL сертификат (Let's Encrypt) | +| `backup.sh` | Бэкап проекта | +| `restore.sh` | Восстановление из бэкапа | +| `dev.sh` | Быстрый запуск в development | + +## 🎯 Быстрая установка (3 шага) + +### 1. Скопируй файлы на сервер + +```bash +scp -r deploy.sh nginx-setup.sh ssl-setup.sh backup.sh restore.sh dev.sh README.md user@server:/opt/pressa/ +``` + +### 2. Запусти установку + +```bash +cd /opt/pressa +chmod +x *.sh +./install.sh +``` + +### 3. Настрой .env + +```bash +nano /opt/pressa/.env +``` + +Заполни переменные: +```env +BOT_TOKEN=твой_токен_бота +PIAPI_API_KEY=твой_piapi_ключ +PIAPI_MODEL=gpt-4o +PROXY_URL=http://user:pass@host:port +CABINET_URL=https://твой_домен.com +``` + +## 📖 Использование отдельных скриптов + +### Деплой + +```bash +cd /opt/pressa +./deploy.sh +``` + +### Nginx + +```bash +./nginx-setup.sh +``` + +Замените `твой_домен.com` на свой домен. + +### SSL + +```bash +./ssl-setup.sh +``` + +### Бэкап + +```bash +./backup.sh +``` + +### Восстановление + +```bash +./restore.sh +``` + +### Development + +```bash +./dev.sh +``` + +## 🔧 Управление сервисом + +```bash +# Статус +sudo systemctl status pressa + +# Перезапуск +sudo systemctl restart pressa + +# Остановка +sudo systemctl stop pressa + +# Запуск +sudo systemctl start pressa + +# Логи +sudo journalctl -u pressa -f + +# Логи Nginx +sudo tail -f /var/log/nginx/pressa-error.log +``` + +## 🔄 Обновление + +```bash +cd /opt/pressa +git pull origin main +./deploy.sh +``` + +## 📊 Мониторинг + +```bash +# Приложение +sudo journalctl -u pressa -f + +# Nginx +sudo tail -f /var/log/nginx/pressa-error.log +sudo tail -f /var/log/nginx/pressa-access.log + +# Бэкапы +ls -lh /opt/backups/ +``` + +## 🆘 Troubleshooting + +### Сервис не запускается + +```bash +sudo systemctl status pressa +sudo journalctl -u pressa -n 50 +``` + +### Nginx не работает + +```bash +sudo nginx -t +sudo systemctl restart nginx +``` + +### 500 ошибка + +```bash +sudo journalctl -u pressa -n 100 +sudo tail -f /var/log/nginx/pressa-error.log +``` + +### Проблемы с зависимостями + +```bash +cd /opt/pressa +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +## 📦 Структура + +``` +/opt/pressa/ +├── cabinet_service/ # Основной код +│ ├── app.py # FastAPI +│ ├── templates/ # HTML +│ ├── static/ # CSS/JS +│ ├── media/ # Файлы +│ └── users.db # База данных +├── ai_core/ # AI модули +├── venv/ # Python venv +├── .env # Переменные +├── requirements.txt # Зависимости +├── deploy/ # Скрипты деплоя +│ ├── install.sh +│ ├── deploy.sh +│ ├── nginx-setup.sh +│ ├── ssl-setup.sh +│ ├── backup.sh +│ ├── restore.sh +│ └── dev.sh +└── README.md +``` + +## 🎓 Полезные команды + +```bash +# Проверка порта +sudo netstat -tlnp | grep 8001 + +# Проверка RAM +free -h + +# Проверка диска +df -h + +# Перезагрузка +sudo reboot + +# Остановка Nginx +sudo systemctl stop nginx + +# Запуск Nginx +sudo systemctl start nginx +``` + +## 📞 Поддержка + +Если что-то не работает — проверяй логи и убедись, что все переменные в `.env` заполнены. \ No newline at end of file diff --git a/deploy/WINDOWS-DEPLOY.md b/deploy/WINDOWS-DEPLOY.md new file mode 100644 index 0000000..5901c2c --- /dev/null +++ b/deploy/WINDOWS-DEPLOY.md @@ -0,0 +1,116 @@ +# Деплой с Windows на сервер + +## ⚡ Быстрый старт + +1. **Открой PowerShell** (от имени администратора не обязательно) + +2. **Перейди в папку проекта:** +```powershell +cd C:\python\pressa\deploy +``` + +3. **Запусти деплой:** +```powershell +.\deploy.ps1 +``` + +## 📋 Что делает скрипт + +1. ✅ Архивирует проект (исключая venv, __pycache__) +2. ✅ Подключается к серверу по SSH +3. ✅ Загружает файлы через SCP +4. ✅ Распаковывает на сервере +5. ✅ Запускает `install.sh` (установка Python, Nginx, SSL) +6. ✅ Настраивает домен `press.my-dpr.ru` +7. ✅ Получает SSL сертификат + +## ⏱️ Время выполнения + +- Загрузка: 1-2 минуты +- Установка на сервере: 5-10 минут +- **Итого: ~10 минут** + +## 🔧 Параметры (опционально) + +Если нужно изменить настройки: + +```powershell +.\deploy.ps1 -ServerIP "31.28.9.200" -Domain "press.my-dpr.ru" +``` + +Доступные параметры: +- `-ServerIP` — IP сервера +- `-Username` — логин (по умолчанию root) +- `-Password` — пароль +- `-Domain` — домен +- `-RemotePath` — путь на сервере (по умолчанию /opt/pressa) +- `-LocalProjectPath` — путь к проекту на компьютере + +## 🆘 Если не работает + +### Ошибка "SSH не найден" + +Установи OpenSSH Client: +1. Параметры → Приложения → Дополнительные компоненты +2. Добавить компонент → OpenSSH Client +3. Перезагрузи PowerShell + +### Ошибка подключения + +Проверь: +```powershell +# Пинг сервера +ping 31.28.9.200 + +# Проверка SSH +ssh root@31.28.9.200 +``` + +### Ручной деплой (если скрипт не сработал) + +1. Запакуй проект в zip +2. Загрузи через FileZilla / WinSCP на сервер +3. Подключись по SSH через PuTTY +4. Выполни команды из `install.sh` вручную + +## ✅ После деплоя + +1. **Настрой .env файл:** +```bash +ssh root@31.28.9.200 +nano /opt/pressa/.env +``` + +Заполни: +```env +BOT_TOKEN=твой_токен_бота +PIAPI_API_KEY=твой_piapi_ключ +PROXY_URL=http://user:pass@host:port +CABINET_URL=https://press.my-dpr.ru +``` + +2. **Перезапусти сервис:** +```bash +systemctl restart pressa +``` + +3. **Проверь статус:** +```bash +systemctl status pressa +journalctl -u pressa -f +``` + +## 🌐 Доступ + +После успешного деплоя: +- **Сайт:** https://press.my-dpr.ru +- **API:** https://press.my-dpr.ru/api/ + +## 📞 Поддержка + +Если что-то пошло не так — проверь логи: +```bash +# На сервере +journalctl -u pressa -n 50 +tail -f /var/log/nginx/pressa-error.log +``` \ No newline at end of file diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 0000000..364fa37 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# === Бэкап проекта === + +set -e + +APP_DIR="/opt/pressa" +BACKUP_DIR="/opt/backups" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_NAME="pressa_$DATE.tar.gz" + +echo "=== Создание бэкапа $BACKUP_NAME ===" + +# Создание папки для бэкапов +mkdir -p $BACKUP_DIR + +# Создание архива +cd $APP_DIR +tar -czf $BACKUP_DIR/$BACKUP_NAME \ + cabinet_service/users.db \ + cabinet_service/media \ + --exclude='cabinet_service/media/*' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='*.pyc' + +# Удаление старых бэкапов (сохранять последние 7) +find $BACKUP_DIR -name "pressa_*.tar.gz" -mtime +7 -delete + +echo "✓ Бэкап создан: $BACKUP_DIR/$BACKUP_NAME" +echo "Размер: $(du -h $BACKUP_DIR/$BACKUP_NAME | cut -f1)" +echo "" +echo "Список бэкапов:" +ls -lh $BACKUP_DIR/pressa_*.tar.gz | tail -5 + +# Опционально: копировать на другой сервер +# scp $BACKUP_DIR/$BACKUP_NAME user@backup-server:/backups/ \ No newline at end of file diff --git a/deploy/deploy-simple.ps1 b/deploy/deploy-simple.ps1 new file mode 100644 index 0000000..5f6cfc8 --- /dev/null +++ b/deploy/deploy-simple.ps1 @@ -0,0 +1,190 @@ +#!/usr/bin/env pwsh +#Requires -Version 5.1 +<# +.SYNOPSIS + Deploy AI Press Secretary to server from Windows computer +.DESCRIPTION + 1. Archives project + 2. Uploads to server via SCP + 3. Connects via SSH and runs installation + 4. Configures Nginx + SSL +.NOTES + Run: .\deploy-simple.ps1 +#> + +[CmdletBinding()] +param( + [string]$ServerIP = "31.28.9.200", + [string]$Username = "root", + [string]$Password = "123edcxzaqws", + [string]$Domain = "press.my-dpr.ru", + [string]$RemotePath = "/opt/pressa", + [string]$LocalProjectPath = "C:\python\pressa", + [int]$Port = 2324 +) + +Write-Host "=== AI Press Secretary Deployment ===" -ForegroundColor Green +Write-Host "" + +# Check SSH/SCP +Write-Host "[1/6] Checking SSH and SCP..." -ForegroundColor Yellow +$ssh = Get-Command ssh -ErrorAction SilentlyContinue +$scp = Get-Command scp -ErrorAction SilentlyContinue + +if (-not $ssh -or -not $scp) { + Write-Host "ERROR: SSH or SCP not found!" -ForegroundColor Red + Write-Host "Install OpenSSH Client: Settings -> Apps -> Optional Features -> OpenSSH Client" + exit 1 +} +Write-Host "OK" -ForegroundColor Green + +# Create archive +Write-Host "[2/6] Creating project archive..." -ForegroundColor Yellow +$TempDir = "$env:TEMP\pressa-deploy-$(Get-Random)" +$ArchiveName = "pressa-deploy.tar.gz" +$ArchivePath = "$env:TEMP\$ArchiveName" + +if (Test-Path $TempDir) { Remove-Item $TempDir -Recurse -Force } +New-Item -ItemType Directory -Path $TempDir | Out-Null + +$ItemsToCopy = @("cabinet_service", "ai_core", "config.py", "requirements.txt", "deploy") +foreach ($item in $ItemsToCopy) { + $src = Join-Path $LocalProjectPath $item + if (Test-Path $src) { + Copy-Item -Path $src -Destination $TempDir -Recurse -Force + } +} + +# Update domain in scripts +$nginxScript = Join-Path $TempDir "deploy\nginx-setup.sh" +if (Test-Path $nginxScript) { + $content = [System.IO.File]::ReadAllText($nginxScript) + $content = $content.Replace("твой_домен.com", $Domain) + [System.IO.File]::WriteAllText($nginxScript, $content) +} + +$sslScript = Join-Path $TempDir "deploy\ssl-setup.sh" +if (Test-Path $sslScript) { + $content = [System.IO.File]::ReadAllText($sslScript) + $content = $content.Replace("твой_домен.com", $Domain) + $content = $content.Replace("твой_email@example.com", "admin@$Domain") + [System.IO.File]::WriteAllText($sslScript, $content) +} + +# Create archive +Set-Location $env:TEMP +$tar = Get-Command tar -ErrorAction SilentlyContinue +if ($tar) { + tar -czf $ArchiveName -C $TempDir . +} else { + Compress-Archive -Path "$TempDir\*" -DestinationPath "$env:TEMP\pressa-deploy.zip" -Force + $ArchiveName = "pressa-deploy.zip" + $ArchivePath = "$env:TEMP\$ArchiveName" +} + +$size = [math]::Round((Get-Item $ArchivePath).Length / 1MB, 1) +Write-Host "Archive created: $ArchivePath ($size MB)" -ForegroundColor Green + +# Upload to server +Write-Host "[3/6] Uploading to server ${ServerIP}:${Port}..." -ForegroundColor Yellow +$sshOptions = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $Port" + +# Create directory +$createDir = "mkdir -p $RemotePath && rm -rf $RemotePath/*" +$createDir | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null + +if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Cannot connect to server!" -ForegroundColor Red + Write-Host "Check: IP address, login, password, port $Port" -ForegroundColor Yellow + exit 1 +} +Write-Host "Connected" -ForegroundColor Green + +# Upload archive +scp $sshOptions $ArchivePath "${Username}@${ServerIP}:${RemotePath}/" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Upload failed!" -ForegroundColor Red + exit 1 +} +Write-Host "Uploaded" -ForegroundColor Green + +# Extract and install +Write-Host "[4/6] Installing on server (this takes 5-10 minutes)..." -ForegroundColor Yellow +$installScript = @" +cd $RemotePath + +# Extract +if [ -f "$ArchiveName" ]; then + tar -xzf "$ArchiveName" + rm "$ArchiveName" +elif [ -f "pressa-deploy.zip" ]; then + apt-get install -y unzip >/dev/null 2>&1 + unzip -q "pressa-deploy.zip" + rm "pressa-deploy.zip" +fi + +# Fix directory structure +if [ ! -d "cabinet_service" ]; then + for dir in */; do + if [ -d "\$dir/cabinet_service" ]; then + mv "\$dir"/* . + rm -rf "\$dir" + break + fi + done +fi + +# Make scripts executable +chmod +x deploy/*.sh + +# Run install +cd deploy +./install.sh +"@ + +$installScript | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null + +if ($LASTEXITCODE -ne 0) { + Write-Host "WARNING: Installation may have errors. Check logs on server." -ForegroundColor Yellow +} + +# Configure Nginx and SSL +Write-Host "[5/6] Configuring Nginx and SSL..." -ForegroundColor Yellow +$nginxScript = @" +cd $RemotePath/deploy +sed -i "s/твой_домен.com/$Domain/g" nginx-setup.sh +./nginx-setup.sh +sed -i "s/твой_домен.com/$Domain/g" ssl-setup.sh +sed -i "s/твой_email@example.com/admin@$Domain/g" ssl-setup.sh +./ssl-setup.sh +"@ + +$nginxScript | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null + +# Cleanup +Write-Host "[6/6] Cleaning up..." -ForegroundColor Yellow +if (Test-Path $TempDir) { Remove-Item $TempDir -Recurse -Force } +if (Test-Path $ArchivePath) { Remove-Item $ArchivePath -Force } +Set-Location $LocalProjectPath + +# Final message +Write-Host "" +Write-Host "=== Deployment Complete! ===" -ForegroundColor Green +Write-Host "" +Write-Host "Website: https://$Domain" -ForegroundColor Cyan +Write-Host "Project: $RemotePath" -ForegroundColor Cyan +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Yellow +Write-Host "1. Configure .env file:" -ForegroundColor White +Write-Host " ssh ${Username}@${ServerIP}" -ForegroundColor Gray +Write-Host " nano $RemotePath/.env" -ForegroundColor Gray +Write-Host "" +Write-Host "2. Fill in API keys (BOT_TOKEN, PIAPI_API_KEY, etc.)" -ForegroundColor White +Write-Host "" +Write-Host "3. Restart service:" -ForegroundColor White +Write-Host " systemctl restart pressa" -ForegroundColor Gray +Write-Host "" +Write-Host "Useful commands:" -ForegroundColor Yellow +Write-Host " ssh ${Username}@${ServerIP}" -ForegroundColor Gray +Write-Host " sudo systemctl status pressa" -ForegroundColor Gray +Write-Host " sudo journalctl -u pressa -f" -ForegroundColor Gray \ No newline at end of file diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 new file mode 100644 index 0000000..cf62a8f --- /dev/null +++ b/deploy/deploy.ps1 @@ -0,0 +1,246 @@ +#!/usr/bin/env pwsh +#Requires -Version 5.1 +<# +.SYNOPSIS + Деплой ИИ Пресс-секретарь на сервер с Windows-компьютера +.DESCRIPTION + 1. Архивирует проект + 2. Загружает на сервер через SCP + 3. Подключается по SSH и запускает установку + 4. Настраивает Nginx + SSL +.NOTES + Запуск: .\deploy.ps1 +#> + +[CmdletBinding()] +param( + [string]$ServerIP = "31.28.9.200", + [string]$Username = "root", + [string]$Password = "123edcxzaqws", + [string]$Domain = "press.my-dpr.ru", + [string]$RemotePath = "/opt/pressa", + [string]$LocalProjectPath = "C:\python\pressa" +) + +# === ЦВЕТА === +$Red = "`e[31m" +$Green = "`e[32m" +$Yellow = "`e[33m" +$Blue = "`e[34m" +$NC = "`e[0m" + +function Write-Status($msg) { Write-Host "${Green}[✓]${NC} $msg" } +function Write-Info($msg) { Write-Host "${Blue}[i]${NC} $msg" } +function Write-Warning($msg) { Write-Host "${Yellow}[!]${NC} $msg" } +function Write-Error($msg) { Write-Host "${Red}[✗]${NC} $msg" } + +# === ПРОВЕРКА SSH/SCP === +Write-Info "Проверка наличия SSH и SCP..." + +$ssh = Get-Command ssh -ErrorAction SilentlyContinue +$scp = Get-Command scp -ErrorAction SilentlyContinue + +if (-not $ssh -or -not $scp) { + Write-Error "SSH или SCP не найдены!" + Write-Info "Установи OpenSSH Client:" + Write-Info " Параметры → Приложения → Дополнительные компоненты → OpenSSH Client" + exit 1 +} + +Write-Status "SSH и SCP найдены" + +# === СОЗДАНИЕ АРХИВА === +Write-Info "Создание архива проекта..." + +$TempDir = "$env:TEMP\pressa-deploy" +$ArchiveName = "pressa-deploy.tar.gz" +$ArchivePath = "$env:TEMP\$ArchiveName" + +# Удаляем старый архив +if (Test-Path $ArchivePath) { + Remove-Item $ArchivePath -Force +} + +# Создаём временную копию без лишнего +if (Test-Path $TempDir) { + Remove-Item $TempDir -Recurse -Force +} +New-Item -ItemType Directory -Path $TempDir | Out-Null + +# Копируем нужные файлы +$ItemsToCopy = @( + "cabinet_service", + "ai_core", + "config.py", + "requirements.txt", + "deploy" +) + +foreach ($item in $ItemsToCopy) { + $src = Join-Path $LocalProjectPath $item + if (Test-Path $src) { + Copy-Item -Path $src -Destination $TempDir -Recurse -Force + } +} + +# Обновляем домен в скриптах +$nginxScript = Join-Path $TempDir "deploy\nginx-setup.sh" +if (Test-Path $nginxScript) { + $content = Get-Content $nginxScript -Raw + $content = $content.Replace('твой_домен.com', $Domain) + Set-Content -Path $nginxScript -Value $content -NoNewline +} + +$sslScript = Join-Path $TempDir "deploy\ssl-setup.sh" +if (Test-Path $sslScript) { + $content = Get-Content $sslScript -Raw + $content = $content.Replace('твой_домен.com', $Domain) + $content = $content.Replace('твой_email@example.com', "admin@$Domain") + Set-Content -Path $sslScript -Value $content -NoNewline +} + +# Создаём архив +Write-Info "Архивирование..." +Set-Location $env:TEMP +# Используем tar если есть, иначе Compress-Archive +$tar = Get-Command tar -ErrorAction SilentlyContinue +if ($tar) { + tar -czf $ArchiveName -C $TempDir . +} else { + Compress-Archive -Path "$TempDir\*" -DestinationPath "$env:TEMP\pressa-deploy.zip" -Force + $ArchiveName = "pressa-deploy.zip" + $ArchivePath = "$env:TEMP\$ArchiveName" +} + +Write-Status "Архив создан: $ArchivePath ($((Get-Item $ArchivePath).Length / 1MB | Format-Number -Format '0.0') MB)" + +# === ЗАГРУЗКА НА СЕРВЕР === +Write-Info "Загрузка на сервер $ServerIP..." + +# Создаём директорию на сервере +$sshCmd = "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP}" +$createDir = "mkdir -p $RemotePath && rm -rf $RemotePath/*" + +Write-Info "Создание директории на сервере..." +$createDir | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s" + +if ($LASTEXITCODE -ne 0) { + Write-Error "Не удалось подключиться по SSH!" + Write-Info "Проверь:" + Write-Info " 1. IP-адрес сервера" + Write-Info " 2. Логин и пароль" + Write-Info " 3. Доступность порта 22" + exit 1 +} + +Write-Status "Подключение установлено" + +# Загружаем архив +Write-Info "Загрузка архива..." +scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $ArchivePath "${Username}@${ServerIP}:${RemotePath}/" + +if ($LASTEXITCODE -ne 0) { + Write-Error "Ошибка загрузки файлов!" + exit 1 +} + +Write-Status "Файлы загружены" + +# === РАСПАКОВКА И УСТАНОВКА === +Write-Info "Распаковка и установка на сервере..." + +$remoteCommands = @" +cd $RemotePath + +# Распаковка +if [ -f "$ArchiveName" ]; then + tar -xzf "$ArchiveName" + rm "$ArchiveName" +elif [ -f "pressa-deploy.zip" ]; then + apt-get install -y unzip + unzip -q "pressa-deploy.zip" + rm "pressa-deploy.zip" +fi + +# Перемещаем файлы из подпапки +if [ -d "cabinet_service" ]; then + # Уже в корне + echo "Файлы на месте" +else + # Ищем папку с файлами + for dir in */; do + if [ -d "\$dir/cabinet_service" ]; then + mv "\$dir"/* . + rm -rf "\$dir" + break + fi + done +fi + +# Установка прав +chmod +x deploy/*.sh + +# Запуск установки +echo "=== Запуск install.sh ===" +cd deploy +./install.sh + +echo "" +echo "=== Установка завершена ===" +echo "" +echo "Не забудь настроить .env файл:" +echo " nano $RemotePath/.env" +echo "" +echo "Перезапуск сервиса:" +echo " systemctl restart pressa" +"@ + +Write-Info "Запуск установочных скриптов (это займет 5-10 минут)..." +$remoteCommands | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s" + +if ($LASTEXITCODE -ne 0) { + Write-Warning "Возможны ошибки при установке. Проверь логи на сервере." +} + +# === НАСТРОЙКА NGINX И SSL === +Write-Info "Настройка Nginx и SSL..." + +$nginxCommands = @" +cd $RemotePath/deploy + +# Nginx +sed -i "s/твой_домен.com/$Domain/g" nginx-setup.sh +./nginx-setup.sh + +# SSL +sed -i "s/твой_домен.com/$Domain/g" ssl-setup.sh +sed -i "s/твой_email@example.com/admin@$Domain/g" ssl-setup.sh +./ssl-setup.sh + +echo "" +echo "=== Готово! ===" +echo "Сайт доступен по адресу: https://$Domain" +"@ + +$nginxCommands | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s" + +# === ФИНАЛ === +Write-Status "Деплой завершён!" +Write-Info "" +Write-Info "🌐 Сайт: https://$Domain" +Write-Info "📁 Проект: $RemotePath" +Write-Info "" +Write-Info "Полезные команды:" +Write-Info " ssh ${Username}@${ServerIP}" +Write-Info " sudo systemctl status pressa" +Write-Info " sudo journalctl -u pressa -f" +Write-Info "" +Write-Warning "⚠️ Не забудь настроить .env файл на сервере!" +Write-Info " ssh ${Username}@${ServerIP} 'nano $RemotePath/.env'" + +# Очистка +if (Test-Path $TempDir) { + Remove-Item $TempDir -Recurse -Force +} + +Set-Location $LocalProjectPath \ No newline at end of file diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..83d2d8e --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# === Полная установка ИИ Пресс-секретарь === + +set -e + +echo "=== Полная установка ИИ Пресс-секретарь ===" +echo "" + +# 1. Установка системных зависимостей +echo "[1/7] Установка системных зависимостей..." +sudo apt update +sudo apt install -y python3.12 python3.12-venv python3-pip nginx certbot python3-certbot-nginx + +# 2. Клонирование проекта +echo "[2/7] Клонирование проекта..." +sudo mkdir -p /opt/pressa +git clone https://github.com/твой_репозиторий/pressa.git /opt/pressa +cd /opt/pressa + +# 3. Деплой +echo "[3/7] Деплой приложения..." +chmod +x deploy.sh +./deploy.sh + +# 4. Nginx +echo "[4/7] Настройка Nginx..." +chmod +x nginx-setup.sh +./nginx-setup.sh + +# 5. SSL +echo "[5/7] Настройка SSL..." +read -p "Есть домен? (y/N): " HAS_DOMAIN +if [ "$HAS_DOMAIN" == "y" ] || [ "$HAS_DOMAIN" == "Y" ]; then + read -p "Введите домен (например, example.com): " DOMAIN + chmod +x ssl-setup.sh + ./ssl-setup.sh +fi + +# 6. Бэкап +echo "[6/7] Настройка бэкапов..." +chmod +x backup.sh +echo "0 3 * * * /opt/pressa/deploy/backup.sh" | crontab - + +# 7. Проверка +echo "[7/7] Проверка..." +sudo systemctl status pressa --no-pager -l + +echo "" +echo "=== Установка завершена! ===" +echo "" +echo "Доступ:" +echo " - Без Nginx: http://localhost:8001" +echo " - С Nginx: http://$(hostname -I | awk '{print $1}'):80" +echo "" +echo "Следующие шаги:" +echo " 1. Отредактируй .env файл: nano /opt/pressa/.env" +echo " 2. Заполни переменные окружения" +echo " 3. Перезапусти сервис: sudo systemctl restart pressa" +echo "" +echo "Управление:" +echo " - Статус: sudo systemctl status pressa" +echo " - Логи: sudo journalctl -u pressa -f" +echo " - Бэкап: sudo /opt/pressa/deploy/backup.sh" +echo " - Восстановление: sudo /opt/pressa/deploy/restore.sh" \ No newline at end of file diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100644 index 0000000..5c19509 --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# === Восстановление из бэкапа === + +set -e + +BACKUP_DIR="/opt/backups" +APP_DIR="/opt/pressa" + +echo "=== Восстановление из бэкапа ===" +echo "" +echo "Доступные бэкапы:" +ls -1 $BACKUP_DIR/pressa_*.tar.gz 2>/dev/null || echo "Бэкапы не найдены" + +echo "" +read -p "Введите имя файла бэкапа (например, pressa_20260827_143022.tar.gz): " BACKUP_FILE + +if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then + echo "❌ Файл не найден: $BACKUP_DIR/$BACKUP_FILE" + exit 1 +fi + +echo "" +echo "Восстановление: $BACKUP_DIR/$BACKUP_FILE" +read -p "Продолжить? (y/N): " CONFIRM + +if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then + echo "Отмена" + exit 0 +fi + +# Остановка сервиса +echo "Остановка сервиса..." +sudo systemctl stop pressa + +# Создание папки +mkdir -p $APP_DIR/cabinet_service + +# Восстановление базы данных +echo "Восстановление базы данных..." +tar -xzf $BACKUP_DIR/$BACKUP_FILE -C $APP_DIR cabinet_service/users.db + +# Восстановление медиа +echo "Восстановление медиа..." +tar -xzf $BACKUP_DIR/$BACKUP_FILE -C $APP_DIR cabinet_service/media + +# Установка прав +echo "Установка прав..." +sudo chown -R www-data:www-data $APP_DIR/cabinet_service + +# Перезапуск +echo "Перезапуск сервиса..." +sudo systemctl start pressa + +echo "" +echo "✓ Восстановление завершено!" +echo "Проверь статус: sudo systemctl status pressa" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e677e03..2644de8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ uvicorn>=0.29.0 bcrypt>=4.1.0 itsdangerous>=2.1.0 jinja2>=3.1.0 -python-multipart>=0.0.9 \ No newline at end of file +python-multipart>=0.0.9 +python-docx>=1.1.0 \ No newline at end of file