feat: AI Press Secretary - content generation and publishing platform

Features:
- AI-powered post generation from brand profiles
- Image generation with templates
- Content planning
- Telegram publishing
- Social profile analyzer
- Community organization support
- Markdown to HTML conversion for Telegram
- Inline AI chat for post editing

Deploy:
- Add deployment scripts for Linux servers
- Add PowerShell deploy script for Windows
- Add Nginx and SSL setup scripts
- Add backup and restore scripts
This commit is contained in:
apuc committed 2026-08-27 15:24:15 +03:00
1 parent 81625b2e6f
commit c6048aeea0
37 files changed
+7006 -194

No files matched your search

+54 -4
View File
@@ -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
+103
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
from cabinet_service.runner import run
if __name__ == "__main__":
run()
+279 -12
View File
@@ -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):
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)
+599 -16
View File
@@ -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']
+187
View File
@@ -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[^"]*"[^>]*>(.*?)</div>',
html, re.DOTALL
)
# Извлекаем даты
dates = re.findall(
r'class="tgme_widget_message_date"[^>]*>.*?<time[^>]*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}"}
+186
View File
@@ -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.
Поддерживает: **жирный**, *курсив*, __подчёркнутый~~зачёркнутый~~,
`моноширинный`, ```блок кода```, заголовки ### в <b>,
нумерованные/маркированные списки.
Telegram HTML: <b>, <i>, <u>, <s>, <code>, <pre>, <a>.
"""
# Сначала экранируем HTML-символы в тексте (но не наши теги)
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# Блоки кода ```...```
text = re.sub(r'```(\w*)\n(.*?)```', r'<pre>\2</pre>', text, flags=re.DOTALL)
text = re.sub(r'```(.*?)```', r'<pre>\1</pre>', text, flags=re.DOTALL)
# Inline code `...`
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
# Заголовки ### **Текст** → <b>Текст</b>
text = re.sub(r'^#{1,6}\s+\*\*(.+?)\*\*\s*$', r'<b>\1</b>', text, flags=re.MULTILINE)
text = re.sub(r'^#{1,6}\s+(.+?)\s*$', r'<b>\1</b>', text, flags=re.MULTILINE)
# Жирный **...**
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
# Курсив *...* (но не внутри <b> тегов)
text = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<i>\1</i>', text)
# Подчёркнутый __...__
text = re.sub(r'__(.+?)__', r'<u>\1</u>', text)
# Зачёркнутый ~~...~~
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', 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)
+946 -46
View File
File diff suppressed because it is too large. Load diff
+312
View File
@@ -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()
+696 -42
View File
@@ -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;
}
+174 -27
View File
@@ -4,18 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Личный кабинет{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
<script>
(function () {
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function setIcon(btn) {
if (btn) btn.textContent = isDark() ? '☀️' : '🌙';
if (btn) btn.textContent = isDark() ? '\u2600\uFE0F' : '\uD83C\uDF19';
}
try {
var saved = localStorage.getItem('theme');
// Тёмная тема по умолчанию: dark включается всегда, кроме явного выбора 'light'
if (saved !== 'light') {
document.documentElement.setAttribute('data-theme', 'dark');
}
@@ -39,30 +38,97 @@
</script>
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ '/dashboard' if user else '/login' }}">🗂 Личный кабинет</a>
<nav>
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Переключить тему">🌙</button>
{% if user %}
<a href="/dashboard">Главная</a>
<a href="/generate">Пост</a>
<a href="/plan">Контент-план</a>
<a href="/history">История</a>
<a href="/images">Картинки</a>
<a href="/stats">Статистика</a>
<a href="/profile/edit">Профиль</a>
<form action="/logout" method="post" class="inline-form">
<button type="submit" class="link-button">Выйти</button>
</form>
</nav>
{% else %}
<a href="/login">Вход</a>
<a href="/register">Регистрация</a>
</nav>
{% endif %}
</header>
<aside class="sidebar" id="sidebar">
<a class="sidebar-brand" href="{{ '/dashboard' if user else '/login' }}">
<svg class="sidebar-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span>ИИ Пресс-секретарь</span>
</a>
<main class="container">
{% if user %}
<nav class="sidebar-nav">
<a href="/dashboard" class="sidebar-link {% if request.path == '/dashboard' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
<span>Главная</span>
</a>
<a href="/generate" class="sidebar-link {% if request.path == '/generate' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span>Создать пост</span>
</a>
<a href="/plan" class="sidebar-link {% if request.path == '/plan' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>План</span>
</a>
<a href="/history" class="sidebar-link {% if request.path == '/history' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span>Мои посты</span>
</a>
<a href="/images" class="sidebar-link {% if request.path == '/images' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Картинки</span>
</a>
<a href="/channels" class="sidebar-link {% if request.path == '/channels' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/></svg>
<span>Каналы</span>
</a>
<a href="/surveys" class="sidebar-link {% if '/surveys' in request.path %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M9 14l2 2 4-4"/></svg>
<span>Медиа-образы</span>
</a>
<a href="/stats" class="sidebar-link {% if request.path == '/stats' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
<span>Статистика</span>
</a>
<div class="sidebar-divider"></div>
<a href="/provider" class="sidebar-link {% if request.path == '/provider' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
<span>ИИ</span>
</a>
<a href="/profile/edit" class="sidebar-link {% if request.path == '/profile/edit' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Профиль</span>
</a>
</nav>
<div class="sidebar-footer">
<button type="button" class="sidebar-link" data-theme-toggle aria-label="Переключить тему">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<span>Тема</span>
</button>
<form action="/logout" method="post" class="sidebar-form">
<button type="submit" class="sidebar-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Выйти</span>
</button>
</form>
</div>
{% else %}
<nav class="sidebar-nav">
<a href="/login" class="sidebar-link {% if request.path == '/login' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
<span>Вход</span>
</a>
<a href="/register" class="sidebar-link {% if request.path == '/register' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
<span>Регистрация</span>
</a>
</nav>
<div class="sidebar-footer">
<button type="button" class="sidebar-link" data-theme-toggle aria-label="Переключить тему">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<span>Тема</span>
</button>
</div>
{% endif %}
</aside>
<button class="sidebar-toggle" id="sidebarToggle" aria-label="Меню">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<main class="main-content">
{% if flash_message %}
<div class="flash">{{ flash_message }}</div>
{% endif %}
@@ -77,12 +143,27 @@
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
(function () {
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
if (toggle && sidebar) {
toggle.addEventListener('click', function () {
sidebar.classList.toggle('open');
});
document.addEventListener('click', function (e) {
if (sidebar.classList.contains('open') && !sidebar.contains(e.target) && e.target !== toggle) {
sidebar.classList.remove('open');
}
});
}
})();
function copyText(text, btn) {
function done(ok) {
if (!btn) return;
var old = btn.textContent;
btn.textContent = ok ? '✅ Скопировано' : '❌ Ошибка';
btn.textContent = ok ? '\u2705 \u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e' : '\u274c \u041e\u0448\u0438\u0431\u043a\u0430';
setTimeout(function () { btn.textContent = old; }, 1500);
}
function fallback() {
@@ -109,6 +190,72 @@
var el = document.getElementById(btn.getAttribute('data-copy-target'));
if (el) copyText(el.textContent, btn);
});
/* === Background Task Engine === */
window.TaskEngine = {
start: function(formEl, opts) {
var fd = new FormData(formEl);
var loaderEl = document.getElementById(opts.loaderId || 'taskLoader');
var resultEl = document.getElementById(opts.resultId || 'taskResult');
var formContainer = document.getElementById(opts.formId || 'taskForm');
var btn = formEl.querySelector('button[type="submit"]');
var errEl = document.getElementById(opts.errorId || 'taskError');
// Hide form, show loader
if (formContainer) formContainer.style.display = 'none';
if (resultEl) resultEl.style.display = 'none';
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (loaderEl) loaderEl.style.display = 'flex';
fetch('/api/tasks', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'error' || data.error) {
if (loaderEl) loaderEl.style.display = 'none';
if (formContainer) formContainer.style.display = '';
if (errEl) { errEl.style.display = 'block'; errEl.textContent = data.error || 'Ошибка запуска задачи'; }
return;
}
TaskEngine.poll(data.task_id, opts);
})
.catch(function(err) {
if (loaderEl) loaderEl.style.display = 'none';
if (formContainer) formContainer.style.display = '';
if (errEl) { errEl.style.display = 'block'; errEl.textContent = 'Ошибка сети: ' + err.message; }
});
},
poll: function(taskId, opts) {
var loaderEl = document.getElementById(opts.loaderId || 'taskLoader');
var resultEl = document.getElementById(opts.resultId || 'taskResult');
var formContainer = document.getElementById(opts.formId || 'taskForm');
var errEl = document.getElementById(opts.errorId || 'taskError');
fetch('/api/tasks/' + taskId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'running' || data.status === 'pending') {
setTimeout(function() { TaskEngine.poll(taskId, opts); }, 1500);
return;
}
if (loaderEl) loaderEl.style.display = 'none';
if (data.status === 'error') {
if (formContainer) formContainer.style.display = '';
if (errEl) { errEl.style.display = 'block'; errEl.textContent = data.error || 'Ошибка генерации'; }
return;
}
if (data.status === 'done' && opts.onDone) {
opts.onDone(data.result || {});
}
})
.catch(function(err) {
if (loaderEl) loaderEl.style.display = 'none';
if (formContainer) formContainer.style.display = '';
if (errEl) { errEl.style.display = 'block'; errEl.textContent = 'Ошибка сети: ' + err.message; }
});
}
};
</script>
</body>
</html>
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Добавить канал — Личный кабинет{% endblock %}
{% block content %}
<h1>Добавить канал</h1>
<div class="card">
<p class="form-hint">
Введите <strong>@username</strong> канала/чата или числовой ID.<br>
Бот должен быть добавлен в админы канала, иначе публикация не сработает.
</p>
</div>
<form method="post" action="/channels/add" class="form">
<label>@username или ID
<input type="text" name="chat_input" value="{{ form.chat_input if form else '' }}"
placeholder="@my_channel или -1001234567890" required>
</label>
<button type="submit">Проверить и добавить</button>
</form>
<p><a href="/channels">← Назад к каналам</a></p>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Каналы публикации — Личный кабинет{% endblock %}
{% block content %}
<h1>Каналы публикации</h1>
<p class="form-hint">Добавьте Telegram-каналы и чаты, куда бот будет публиковать посты. Бот должен быть админом в канале.</p>
<div class="grid">
<a class="tile" href="/channels/add">➕ Добавить канал</a>
</div>
{% if channels %}
<table class="table">
<thead>
<tr><th>Название</th><th>Тип</th><th>Username</th><th>Добавлен</th><th></th></tr>
</thead>
<tbody>
{% for ch in channels %}
<tr>
<td>{{ ch.title }}</td>
<td>{{ ch.chat_type }}</td>
<td>{% if ch.username %}@{{ ch.username }}{% else %}—{% endif %}</td>
<td>{{ ch.created_at }}</td>
<td>
<form action="/channels/{{ ch.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить канал?')">
<button type="submit" class="button-link button-link-danger">🗑</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card">
<p>Нет добавленных каналов. <a href="/channels/add">Добавьте первый →</a></p>
</div>
{% endif %}
{% endblock %}
+24 -3
View File
@@ -4,15 +4,36 @@
<h1>Привет, {{ user.name }}!</h1>
<div class="card">
<p><strong>Телефон:</strong> {{ user.phone }}</p>
<p><strong>Вид бизнеса:</strong> {{ business_name }}</p>
<p><strong>Описание:</strong> {{ user.description }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
<p><strong>В кабинете с:</strong> {{ user.created_at }}</p>
</div>
<div class="card">
<h2>Медиа-образ</h2>
{% if latest_survey %}
<p><strong>Активный:</strong> <a href="/surveys/{{ latest_survey.id }}">{{ latest_survey.title }}</a></p>
<p class="form-hint">Посты генерируются на основе этого медиа-образа.</p>
<div class="post-actions">
<a href="/surveys/{{ latest_survey.id }}" class="copy-btn">Открыть</a>
<a href="/survey" class="copy-btn">➕ Создать новый</a>
<a href="/survey/import" class="copy-btn">📤 Загрузить из файла</a>
</div>
{% else %}
<p>Медиа-образ ещё не создан. Создайте его — посты будут генерироваться на его основе.</p>
<div class="post-actions">
<a href="/survey" class="copy-btn">➕ Создать медиа-образ</a>
<a href="/survey/import" class="copy-btn">📤 Загрузить из файла</a>
</div>
{% endif %}
</div>
<div class="grid">
<a class="tile" href="/generate">💡 Сгенерировать пост</a>
<a class="tile" href="/plan">📅 Контент-план</a>
<a class="tile" href="/history">📜 История постов</a>
<a class="tile" href="/history">📜 Мои посты</a>
<a class="tile" href="/images">🖼 Медиа-библиотека</a>
<a class="tile" href="/channels">📢 Каналы публикации</a>
<a class="tile" href="/surveys">📋 Мои медиа-образы</a>
<a class="tile" href="/stats">📈 Статистика</a>
<a class="tile" href="/profile/edit">✏️ Редактировать профиль</a>
<a class="tile tile-danger" href="/delete-account">🗑 Удалить аккаунт</a>
+79 -5
View File
@@ -2,11 +2,45 @@
{% block title %}Сгенерировать пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Пост в своём стиле</h1>
<form method="post" action="/generate" class="form">
{% if latest_survey %}
<div class="card">
<p><strong>Медиа-образ:</strong> <a href="/surveys/{{ latest_survey.id }}">{{ latest_survey.title }}</a></p>
<p class="form-hint">Пост сгенерируется на основе этого медиа-образа (личность, УТП, Tone of Voice, структура мысли).</p>
</div>
{% else %}
<div class="card">
<p>⚠️ Медиа-образ не найден. Посты генерируются на основе медиа-образа.</p>
<a href="/survey" class="copy-btn">➕ Создать медиа-образ</a>
</div>
{% endif %}
<div id="taskError" class="task-error"></div>
<div id="taskLoader" class="task-loader">
<div class="spinner"></div>
<div class="task-loader-text">Генерирую пост...</div>
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
</div>
<div id="taskResult" class="task-result"></div>
<div id="taskForm">
<form method="post" action="/generate" class="form" id="generateForm" onsubmit="return startPostGen(event)">
{% if surveys and surveys|length > 1 %}
<label>Медиа-образ
<select name="survey_id">
<option value="">Последний (по умолчанию)</option>
{% for s in surveys %}
<option value="{{ s.id }}">{{ s.title }} ({{ s.created_at }})</option>
{% endfor %}
</select>
</label>
{% endif %}
<label>Платформа
<select name="platform" required>
{% for key, label in platforms %}
<option value="{{ key }}" {% if form and form.platform == key %}selected{% endif %}>{{ label }}</option>
<option value="{{ key }}" {% if (prefill and prefill.platform == key) or (form and form.platform == key) %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
@@ -14,13 +48,53 @@
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
</label>
<label>Пожелания к посту
<textarea name="wishes" rows="3">{{ form.wishes if form else '' }}</textarea>
<textarea name="wishes" rows="3">{{ prefill.idea if prefill else (form.wishes if form else '') }}</textarea>
</label>
<button type="submit">Сгенерировать</button>
</form>
<input type="hidden" name="task_type" value="generate_post">
{% if prefill %}<input type="hidden" name="plan_id" value="{{ prefill.plan_id }}">{% endif %}
<button type="submit" {% if not latest_survey %}disabled{% endif %}>Сгенерировать</button>
</form>
</div>
{% if result %}
<h2>Готовый пост</h2>
<div class="card result">{{ result }}</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-text">📋 Копировать</button>
</div>
<div id="post-text" hidden>{{ result }}</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
<script>
function startPostGen(e) {
e.preventDefault();
var form = document.getElementById('generateForm');
TaskEngine.start(form, {
loaderId: 'taskLoader',
formId: 'taskForm',
errorId: 'taskError',
resultId: 'taskResult',
onDone: function(result) {
var html = '<h2>Готовый пост</h2>' +
'<div class="card result">' + escHtml(result.content) + '</div>' +
'<div class="post-actions">' +
'<button type="button" class="copy-btn" onclick="copyText(\'' + escAttr(result.content).replace(/'/g, "\\'") + '\', this)">📋 Копировать</button>' +
' <a href="/posts/' + result.post_id + '" class="copy-btn">Открыть пост →</a>' +
'</div>';
document.getElementById('taskResult').innerHTML = html;
document.getElementById('taskResult').style.display = 'block';
}
});
return false;
}
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function escAttr(s) {
return (s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;').replace(/\n/g,'\\n');
}
</script>
{% endblock %}
+12 -3
View File
@@ -1,13 +1,22 @@
{% extends "base.html" %}
{% block title %}История постов — Личный кабинет{% endblock %}
{% block title %}Мои посты — Личный кабинет{% endblock %}
{% block content %}
<h1>История постов</h1>
<h1>Мои посты</h1>
{% if posts %}
<ul class="post-list">
{% for post in posts %}
<li class="card">
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</div>
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}
{% if post.published %} · ✅ Опубликован{% endif %}
</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
{% if post.images %}
<div class="post-images">
{% for img in post.images %}
<img src="/media/{{ img.filename }}" class="post-thumb" alt="">
{% endfor %}
</div>
{% endif %}
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}">👁 Открыть</a>
+153 -6
View File
@@ -2,26 +2,173 @@
{% block title %}Сгенерировать картинку — Личный кабинет{% endblock %}
{% block content %}
<h1>Генерация картинки</h1>
<form method="post" action="/images/generate" class="form">
<div id="taskError" class="task-error"></div>
<div id="taskLoader" class="task-loader">
<div class="spinner"></div>
<div class="task-loader-text">Генерирую картинку...</div>
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
</div>
<div id="taskResult" class="task-result"></div>
<div id="taskForm">
<!-- Выбор шаблона -->
<h2>Выбери шаблон</h2>
<div class="template-grid" id="templateGrid">
{% for t in templates %}
<button type="button" class="template-card" data-key="{{ t.key }}"
data-prompt="{{ t.prompt }}" data-style="{{ t.style }}"
data-size="{{ t.size }}" data-hint="{{ t.hint }}">
<span class="template-icon">{{ t.icon }}</span>
<span class="template-name">{{ t.name }}</span>
<span class="template-hint">{{ t.hint }}</span>
</button>
{% endfor %}
<button type="button" class="template-card template-card-custom" data-key="custom">
<span class="template-icon">✏️</span>
<span class="template-name">Свой вариант</span>
<span class="template-hint">Напиши промпт сам</span>
</button>
</div>
<div id="selectedTemplateHint" class="selected-hint" style="display:none;"></div>
<!-- Форма генерации -->
<form method="post" action="/images/generate" class="form" id="imageForm" onsubmit="return startImageGen(event)" enctype="multipart/form-data">
<label>Что нарисовать?
<textarea name="prompt" rows="4" required placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
<textarea name="prompt" id="promptInput" rows="4" required
placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
</label>
<label>Исходное изображение <span class="form-hint">(необязательно) — ИИ использует его как основу</span>
<input type="file" name="reference_image" id="referenceImage" accept="image/*">
</label>
<div id="refImagePreview" style="display:none; margin: 5px 0 10px;">
<img id="refImageThumb" style="max-height:120px; border-radius:8px; border:1px solid var(--border);">
<button type="button" class="button-link button-link-danger" onclick="clearRefImage()" style="margin-left:8px">✕ Убрать</button>
</div>
<label>Размер
<select name="size" required>
<select name="size" id="sizeInput" required>
{% for key, (value, label) in sizes.items() %}
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Стиль
<select name="style">
<select name="style" id="styleInput">
{% for key, (label, suffix) in styles.items() %}
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">🎨 Сгенерировать</button>
<input type="hidden" name="task_type" value="generate_image">
<button type="submit" id="submitBtn" disabled>🎨 Сгенерировать</button>
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
</form>
</form>
</div>
<p><a href="/images">← Назад в библиотеку</a></p>
<script>
function clearRefImage() {
document.getElementById('referenceImage').value = '';
document.getElementById('refImagePreview').style.display = 'none';
}
document.getElementById('referenceImage').addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
document.getElementById('refImageThumb').src = ev.target.result;
document.getElementById('refImagePreview').style.display = '';
};
reader.readAsDataURL(file);
});
</script>
<script>
(function() {
var grid = document.getElementById('templateGrid');
var promptInput = document.getElementById('promptInput');
var sizeInput = document.getElementById('sizeInput');
var styleInput = document.getElementById('styleInput');
var hintEl = document.getElementById('selectedTemplateHint');
var submitBtn = document.getElementById('submitBtn');
var selectedKey = null;
grid.addEventListener('click', function(e) {
var card = e.target.closest('.template-card');
if (!card) return;
// Снять выделение со всех
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
card.classList.add('selected');
selectedKey = card.dataset.key;
if (selectedKey === 'custom') {
hintEl.style.display = 'none';
promptInput.value = '';
promptInput.focus();
promptInput.removeAttribute('readonly');
submitBtn.disabled = !promptInput.value.trim();
return;
}
var prompt = card.dataset.prompt;
var style = card.dataset.style;
var size = card.dataset.size;
var hint = card.dataset.hint;
promptInput.value = prompt;
if (styleInput.querySelector('option[value="' + style + '"]')) {
styleInput.value = style;
}
if (sizeInput.querySelector('option[value="' + size + '"]')) {
sizeInput.value = size;
}
hintEl.textContent = '💡 ' + hint;
hintEl.style.display = 'block';
submitBtn.disabled = false;
});
// Разрешить ввод вручную при изменении текста
promptInput.addEventListener('input', function() {
if (selectedKey && selectedKey !== 'custom') {
// Пользователь изменил текст — переключаем на "свой вариант"
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
var customCard = grid.querySelector('[data-key="custom"]');
if (customCard) customCard.classList.add('selected');
selectedKey = 'custom';
hintEl.style.display = 'none';
}
submitBtn.disabled = !promptInput.value.trim();
});
})();
</script>
<script>
function startImageGen(e) {
e.preventDefault();
var form = document.getElementById('imageForm');
TaskEngine.start(form, {
loaderId: 'taskLoader',
formId: 'taskForm',
errorId: 'taskError',
resultId: 'taskResult',
onDone: function(result) {
var html = '<div class="card">' +
'<p>✅ Картинка сгенерирована!</p>' +
'<div class="media-grid"><div class="media-card">' +
'<a href="/media/' + result.filename + '" class="media-link" target="_blank">' +
'<img class="media-thumb" src="/media/' + result.filename + '" alt="Картинка">' +
'</a></div></div>' +
'<p><a href="/images" class="copy-btn">Перейти в библиотеку →</a> ' +
'<a href="/images/generate" class="copy-btn">Ещё одна →</a></p></div>';
document.getElementById('taskResult').innerHTML = html;
document.getElementById('taskResult').style.display = 'block';
}
});
return false;
}
</script>
{% endblock %}
+37 -3
View File
@@ -24,6 +24,7 @@
</div>
<div class="plan-idea">{{ item.idea }}</div>
<div class="post-actions">
<a href="/generate?plan_id={{ item.id }}&platform={{ item.platform }}&idea={{ item.idea | urlencode }}" class="copy-btn">✍️ Создать пост</a>
<form method="post" action="/plan/{{ item.id }}/status" class="inline-form">
<select name="status" class="status-select">
<option value="planned" {% if item.status == 'planned' %}selected{% endif %}>📝 В плане</option>
@@ -47,12 +48,45 @@
{% endif %}
<h2>{% if plan %}Пересоставить план{% else %}Составить план{% endif %}</h2>
<form method="post" action="/plan" class="form"
onsubmit="{% if plan %}return confirm('Новый план заменит текущий (отметки статусов сбросятся). Продолжить?');{% endif %}">
<div id="taskError" class="task-error"></div>
<div id="taskLoader" class="task-loader">
<div class="spinner"></div>
<div class="task-loader-text">Составляю контент-план...</div>
<div class="task-loader-sub">Генерация занимает 20–60 секунд</div>
</div>
<div id="taskResult" class="task-result"></div>
<div id="taskForm">
<form method="post" action="/plan" class="form" id="planForm" onsubmit="return startPlanGen(event)">
<label>На сколько дней (1-14)
<input type="number" name="days" min="1" max="14" value="7" required>
</label>
<input type="hidden" name="task_type" value="generate_plan">
<button type="submit">📅 Составить план</button>
</form>
</form>
</div>
<p><a href="/dashboard">← Назад в кабинет</a></p>
<script>
function startPlanGen(e) {
e.preventDefault();
var form = document.getElementById('planForm');
TaskEngine.start(form, {
loaderId: 'taskLoader',
formId: 'taskForm',
errorId: 'taskError',
resultId: 'taskResult',
onDone: function(result) {
var html = '<div class="card"><p>✅ План на ' + result.days + ' дн. составлен! (' + result.items + ' пунктов)</p>' +
'<p><a href="/plan" class="copy-btn">Обновить страницу →</a></p></div>';
document.getElementById('taskResult').innerHTML = html;
document.getElementById('taskResult').style.display = 'block';
}
});
return false;
}
</script>
{% endblock %}
+429 -4
View File
@@ -7,18 +7,443 @@
{{ platform_label(post.platform) }} · {{ post.created_at }}
{% if post.style %} · Стиль: {{ post.style }}{% endif %}
</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
{% if post.published %}
<div class="post-meta" style="color: var(--success-text);">
✅ Опубликован в «{{ post.published_to }}» · {{ post.published_at }}
</div>
{% endif %}
{% if post.wishes %}
<div class="post-notes"><strong>Пожелания:</strong> {{ post.wishes }}</div>
{% endif %}
</div>
<!-- === ТЕКУЩИЙ ТЕКСТ ПОСТА === -->
<div class="card" id="postCard">
<h2>📄 Текст поста</h2>
<div class="post-content" id="postContent">{{ post.content }}</div>
<div class="post-editor-actions" style="margin-top: 12px;">
<button type="button" class="copy-btn" onclick="copyPostText()">📋 Копировать</button>
<a class="copy-btn" href="/posts/{{ post.id }}/publish">📤 Опубликовать</a>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Редактировать вручную</a>
</div>
</div>
<!-- === ЧАТ С НЕЙРОСЕТЬЮ === -->
<div class="card">
<h2>🤖 Редактировать с помощью ИИ</h2>
<p class="form-hint">Напишите, что нужно изменить — нейросеть перепишет пост. Например: «сократи вдвое», «сделай официальнее», «добавь призыв к действию».</p>
<!-- История чата -->
<div class="ai-chat" id="aiChat">
<div class="ai-chat-msg ai-chat-msg--system">
<div class="ai-chat-bubble ai-chat-bubble--system">Пост сгенерирован. Напишите инструкцию, чтобы изменить его.</div>
</div>
</div>
<!-- Лоадер -->
<div id="aiChatLoader" class="task-loader" style="display:none; padding: 15px;">
<div class="spinner"></div>
<div class="task-loader-text">Нейросеть переписывает пост...</div>
</div>
<div id="aiChatError" class="task-error"></div>
<!-- Поле ввода -->
<div class="ai-chat-input">
<textarea id="aiChatInput" class="ai-chat-textarea" rows="2"
placeholder="Например: сократи вдвое, сделай веселее, добавь эмодзи..."
onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault(); sendAiInstruction();}"></textarea>
<button type="button" class="copy-btn" onclick="sendAiInstruction()" id="aiChatSendBtn">→ Отправить</button>
</div>
</div>
<!-- === СКРИПТ ЧАТА === -->
<script>
var _currentPostContent = {{ post.content | tojson }};
function copyPostText() {
navigator.clipboard.writeText(document.getElementById('postContent').textContent);
}
function sendAiInstruction() {
var input = document.getElementById('aiChatInput');
var instruction = input.value.trim();
if (!instruction) return;
var errEl = document.getElementById('aiChatError');
var loader = document.getElementById('aiChatLoader');
var sendBtn = document.getElementById('aiChatSendBtn');
errEl.style.display = 'none';
loader.style.display = 'flex';
sendBtn.disabled = true;
input.disabled = true;
// Добавляем сообщение пользователя в чат
addChatMsg('user', instruction);
input.value = '';
var fd = new FormData();
fd.append('task_type', 'modify_post');
fd.append('post_id', '{{ post.id }}');
fd.append('instruction', instruction);
fetch('/api/tasks', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showAiError(errEl, data.error, loader, sendBtn, input); return; }
pollModifyTask(data.task_id, errEl, loader, sendBtn, input, instruction);
})
.catch(function(e) { showAiError(errEl, 'Ошибка сети: ' + e, loader, sendBtn, input); });
}
function pollModifyTask(taskId, errEl, loader, sendBtn, input, instruction) {
fetch('/api/tasks/' + taskId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'running' || data.status === 'pending') {
setTimeout(function() { pollModifyTask(taskId, errEl, loader, sendBtn, input, instruction); }, 1500);
return;
}
loader.style.display = 'none';
sendBtn.disabled = false;
input.disabled = false;
input.focus();
if (data.status === 'error') {
showAiError(errEl, data.error || 'Ошибка модификации', null, null, null);
return;
}
if (data.status === 'done' && data.result) {
var newContent = data.result.content;
_currentPostContent = newContent;
// Обновляем текст поста на странице
document.getElementById('postContent').textContent = newContent;
// Добавляем ответ ИИ в чат
addChatMsg('ai', newContent, instruction);
// Убираем ошибку
errEl.style.display = 'none';
}
})
.catch(function(e) { showAiError(errEl, 'Ошибка сети: ' + e, null, null, null); });
}
function addChatMsg(role, text, instruction) {
var chat = document.getElementById('aiChat');
var now = new Date();
var dateStr = now.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'});
var html = '';
if (role === 'user') {
html = '<div class="ai-chat-msg ai-chat-msg--user">' +
'<div class="ai-chat-bubble ai-chat-bubble--user">' + escH(text) + '</div>' +
'<div class="ai-chat-time">' + dateStr + '</div></div>';
} else {
var preview = text.substring(0, 200) + (text.length > 200 ? '…' : '');
var fullId = 'aifull-' + Date.now();
html = '<div class="ai-chat-msg ai-chat-msg--ai">' +
'<div class="ai-chat-meta">🤖 ИИ изменил пост по запросу: «' + escH(instruction || '') + '»</div>' +
'<div class="ai-chat-bubble ai-chat-bubble--ai">' +
'<div class="ai-chat-preview" id="aiprev-' + fullId + '">' + escH(preview) + '</div>' +
'<div class="ai-chat-full" id="' + fullId + '" style="display:none"><pre>' + escH(text) + '</pre></div>' +
'<button type="button" class="button-link" onclick="toggleAiFull(\'' + fullId + '\')" style="margin-top:6px;">Показать весь текст</button>' +
'</div>' +
'<div class="ai-chat-time">' + dateStr + '</div></div>';
}
chat.insertAdjacentHTML('beforeend', html);
chat.scrollTop = chat.scrollHeight;
}
function toggleAiFull(id) {
var prev = document.getElementById('aiprev-' + id);
var full = document.getElementById(id);
if (!prev || !full) return;
if (full.style.display === 'none') {
prev.style.display = 'none';
full.style.display = '';
} else {
full.style.display = 'none';
prev.style.display = '';
}
}
function showAiError(el, msg, loader, sendBtn, input) {
if (loader) loader.style.display = 'none';
if (sendBtn) { sendBtn.disabled = false; }
if (input) { input.disabled = false; input.focus(); }
el.textContent = msg;
el.style.display = 'block';
}
function escH(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
</script>
<!-- === ГАЛЕРЕЯ ИЗОБРАЖЕНИЙ === -->
<div class="card">
<h2>🖼 Изображения</h2>
{% if images %}
<div class="media-grid">
{% for img in images %}
<div class="media-card">
<a href="/media/{{ img.filename }}" class="media-link" target="_blank">
<img src="/media/{{ img.filename }}" class="media-thumb" alt="">
</a>
<div class="media-body">
<span class="form-hint">{{ img.source }} · {{ img.created_at }}</span>
<form action="/posts/{{ post.id }}/images/{{ img.id }}/delete" method="post" class="inline-form"
onsubmit="return confirm('Удалить изображение?')">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="form-hint">Изображений пока нет.</p>
{% endif %}
<!-- Загрузка файлов -->
<h3>📎 Загрузить изображения</h3>
<form method="post" action="/posts/{{ post.id }}/images/upload" enctype="multipart/form-data" class="form">
<label>Файлы (можно несколько)
<input type="file" name="files" accept="image/*" multiple required>
</label>
<button type="submit">📎 Загрузить</button>
</form>
<!-- Генерация изображения -->
<h3>🎨 Сгенерировать изображение для поста</h3>
<div id="postImgError" class="task-error"></div>
<!-- Лоадер анализа -->
<div id="postImgAnalyzeLoader" class="task-loader" style="padding: 20px;">
<div class="spinner"></div>
<div class="task-loader-text">Анализирую пост...</div>
<div class="task-loader-sub">ИИ подбирает лучшие варианты</div>
</div>
<!-- Лоадер генерации -->
<div id="postImgGenLoader" class="task-loader" style="padding: 20px;">
<div class="spinner"></div>
<div class="task-loader-text">Генерирую изображение...</div>
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
</div>
<div id="postImgResult" class="task-result"></div>
<!-- Шаг 1: Выбор концепции -->
<div id="postImgConceptsSection">
<p class="form-hint" style="margin-bottom: 10px;">ИИ проанализирует пост и предложит варианты:</p>
<button type="button" class="copy-btn" id="postImgAnalyzeBtn" onclick="startAnalyzeConcepts()">🤖 ИИ подберёт вариант</button>
<div style="margin-top: 12px;">
<p class="form-hint" style="margin-bottom: 8px;">Или выбери шаблон вручную:</p>
<div class="template-grid" id="postImgGrid">
{% for t in templates %}
<button type="button" class="template-card" data-key="{{ t.key }}"
data-prompt="{{ t.prompt }}" data-style="{{ t.style }}" data-size="{{ t.size }}">
<span class="template-icon">{{ t.icon }}</span>
<span class="template-name">{{ t.name }}</span>
<span class="template-hint">{{ t.hint }}</span>
</button>
{% endfor %}
</div>
</div>
</div>
<!-- Шаг 2: Концепции от ИИ (скрыт до анализа) -->
<div id="postImgConceptsList" style="display:none;">
<h4 style="margin-bottom: 10px;">Выбери вариант:</h4>
<div id="postImgConcepts" class="concept-grid"></div>
<button type="button" class="button-link" style="margin-top: 10px;" onclick="resetConcepts()">← Назад к шаблонам</button>
</div>
<!-- Скрытая форма для отправки -->
<form method="post" action="/posts/{{ post.id }}/images/generate" class="form" id="postImgForm" style="display:none;">
<input type="hidden" name="task_type" id="postImgTaskType" value="generate_post_image">
<input type="hidden" name="post_id" value="{{ post.id }}">
<input type="hidden" name="template_key" id="postImgTemplateKey" value="">
<input type="hidden" name="concept_prompt" id="postImgConceptPrompt" value="">
<input type="hidden" name="concept_style" id="postImgConceptStyle" value="">
<input type="hidden" name="concept_size" id="postImgConceptSize" value="">
<button type="submit" id="postImgSubmitBtn">🎨 Сгенерировать</button>
</form>
<script>
(function() {
var form = document.getElementById('postImgForm');
var grid = document.getElementById('postImgGrid');
if (!form || !grid) return;
// Клик по шаблону — сразу генерация
grid.addEventListener('click', function(e) {
var card = e.target.closest('.template-card');
if (!card) return;
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
card.classList.add('selected');
document.getElementById('postImgTaskType').value = 'generate_post_image';
document.getElementById('postImgTemplateKey').value = card.dataset.key || '';
document.getElementById('postImgConceptPrompt').value = '';
document.getElementById('postImgConceptStyle').value = '';
document.getElementById('postImgConceptSize').value = '';
runPostImgGeneration('postImgConceptsSection');
});
})();
function startAnalyzeConcepts() {
var errEl = document.getElementById('postImgError');
var loader = document.getElementById('postImgAnalyzeLoader');
var section = document.getElementById('postImgConceptsSection');
var conceptsList = document.getElementById('postImgConceptsList');
errEl.style.display = 'none';
section.style.display = 'none';
conceptsList.style.display = 'none';
loader.style.display = 'flex';
var fd = new FormData();
fd.append('task_type', 'analyze_post_image');
fd.append('post_id', '{{ post.id }}');
fetch('/api/tasks', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showError(errEl, data.error, section, loader); return; }
pollAnalyze(data.task_id, errEl, loader, section, conceptsList);
})
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, section, loader); });
}
function pollAnalyze(taskId, errEl, loader, section, conceptsList) {
fetch('/api/tasks/' + taskId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'running' || data.status === 'pending') {
setTimeout(function() { pollAnalyze(taskId, errEl, loader, section, conceptsList); }, 1500);
return;
}
loader.style.display = 'none';
if (data.status === 'error') { showError(errEl, data.error, section, null); return; }
if (data.status === 'done' && data.result && data.result.concepts) {
showConcepts(data.result.concepts, conceptsList, section);
}
})
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, section, null); });
}
function showConcepts(concepts, container, hiddenSection) {
var html = '';
concepts.forEach(function(c, i) {
var styleLabels = {photo:'Фото',illustration:'Иллюстрация',minimal:'Минимализм','3d':'3D',flat:'Плоская графика'};
var sizeLabels = {square:'Квадрат',wide:'Широкий',tall:'Вертикальный'};
html += '<button type="button" class="concept-card" onclick="selectConcept(' + i + ')">' +
'<div class="concept-title">' + escH(c.title) + '</div>' +
'<div class="concept-desc">' + escH(c.description) + '</div>' +
'<div class="concept-meta">' +
'<span class="concept-tag">' + (styleLabels[c.style] || c.style) + '</span> ' +
'<span class="concept-tag">' + (sizeLabels[c.size] || c.size) + '</span>' +
'</div></button>';
});
document.getElementById('postImgConcepts').innerHTML = html;
document.getElementById('postImgConceptsList').style.display = 'block';
// Сохраняем концепции для выбора
window._postImgConcepts = concepts;
}
function selectConcept(idx) {
var c = window._postImgConcepts[idx];
if (!c) return;
document.getElementById('postImgTaskType').value = 'generate_post_image_with_concept';
document.getElementById('postImgTemplateKey').value = '';
document.getElementById('postImgConceptPrompt').value = c.description;
document.getElementById('postImgConceptStyle').value = c.style || 'none';
document.getElementById('postImgConceptSize').value = c.size || 'wide';
runPostImgGeneration('postImgConceptsList');
}
function runPostImgGeneration(hideSectionId) {
var form = document.getElementById('postImgForm');
var hideSection = document.getElementById(hideSectionId);
var genLoader = document.getElementById('postImgGenLoader');
if (hideSection) hideSection.style.display = 'none';
genLoader.style.display = 'flex';
var fd = new FormData(form);
fetch('/api/tasks', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showError(document.getElementById('postImgError'), data.error, hideSection, genLoader); return; }
pollGenerate(data.task_id);
})
.catch(function(err) { showError(document.getElementById('postImgError'), 'Ошибка сети: ' + err.message, hideSection, genLoader); });
}
function pollGenerate(taskId) {
var genLoader = document.getElementById('postImgGenLoader');
var errEl = document.getElementById('postImgError');
fetch('/api/tasks/' + taskId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'running' || data.status === 'pending') {
setTimeout(function() { pollGenerate(taskId); }, 1500);
return;
}
genLoader.style.display = 'none';
if (data.status === 'error') {
showError(errEl, data.error, document.getElementById('postImgConceptsSection'), null);
return;
}
if (data.status === 'done' && data.result) {
var html = '<div class="media-grid"><div class="media-card">' +
'<a href="/media/' + data.result.filename + '" class="media-link" target="_blank">' +
'<img src="/media/' + data.result.filename + '" class="media-thumb" alt=""></a>' +
'<div class="media-body"><span class="form-hint">generated · только что</span></div></div></div>' +
'<p style="margin-top:10px;"><button type="button" class="copy-btn" onclick="resetConcepts()">← Ещё вариант</button> ' +
'<a href="/images" class="copy-btn">В библиотеку →</a></p>';
document.getElementById('postImgResult').innerHTML = html;
document.getElementById('postImgResult').style.display = 'block';
}
})
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, document.getElementById('postImgConceptsSection'), null); });
}
function resetConcepts() {
document.getElementById('postImgResult').style.display = 'none';
document.getElementById('postImgConceptsList').style.display = 'none';
document.getElementById('postImgConceptsSection').style.display = '';
document.getElementById('postImgError').style.display = 'none';
document.getElementById('postImgGenLoader').style.display = 'none';
document.getElementById('postImgAnalyzeLoader').style.display = 'none';
}
function showError(el, msg, showSection, hideLoader) {
if (hideLoader) hideLoader.style.display = 'none';
if (showSection) showSection.style.display = '';
el.textContent = msg;
el.style.display = 'block';
}
function escH(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
</script>
</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
<a class="copy-btn" href="/posts/{{ post.id }}/publish">📤 Опубликовать</a>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Полное редактирование</a>
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
<p><a href="/history">← Назад к истории</a></p>
<p><a href="/history">← Назад к постам</a></p>
{% endblock %}
+11 -10
View File
@@ -9,17 +9,18 @@
<label>Телефон
<input type="text" name="phone" value="{{ user.phone }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if key == user.business_type|string %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ user.description }}</textarea>
</label>
<button type="submit">Сохранить</button>
</form>
<div class="card">
<h2>Провайдер ИИ</h2>
{% if provider %}
<p><strong>Текущий:</strong> {{ provider_presets[provider.provider_type].name if provider.provider_type in provider_presets else provider.provider_type }}</p>
{% else %}
<p>Используются глобальные настройки (PiAPI).</p>
{% endif %}
<a href="/provider" class="copy-btn">⚙ Настроить провайдера</a>
</div>
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+192
View File
@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}Провайдеры ИИ — Личный кабинет{% endblock %}
{% block content %}
<h1>Провайдеры ИИ</h1>
<p class="form-hint">Настройте провайдеров для генерации текста и изображений независимо. API ключи хранятся только у вас.</p>
{% if provider %}
<div class="card">
<p class="form-hint" style="margin:0">
<strong>Текст:</strong> {{ provider_presets[provider.text_provider_type].name if provider.text_provider_type in provider_presets else provider.text_provider_type }} → {{ provider.text_model }}
&nbsp;|&nbsp;
<strong>Изображения:</strong>
{% 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 %}
<em>глобальный PiAPI</em>
{% endif %}
&nbsp;<span class="form-hint">· {{ provider.updated_at }}</span>
</p>
</div>
{% else %}
<div class="card">
<p style="margin:0">Используются глобальные настройки (PiAPI из .env). Настройте свои провайдеры для независимости.</p>
</div>
{% endif %}
<!-- Табы -->
<div class="provider-tabs">
<button type="button" class="provider-tab active" onclick="switchTab('text')">✍️ Генерация текста</button>
<button type="button" class="provider-tab" onclick="switchTab('image')">🎨 Генерация изображений</button>
</div>
<form method="post" action="/provider" class="form" id="providerForm">
<!-- === ТАБ: ТЕКСТ === -->
<div class="provider-tab-content active" id="tabText">
<h2>✍️ Генерация текста</h2>
<label>Провайдер
<select name="text_provider_type" id="textProviderType" required onchange="updatePresets('text')">
<option value="" disabled {% if not provider and not form %}selected{% endif %}>Выберите провайдера</option>
{% for key, preset in provider_presets.items() %}
<option value="{{ key }}"
{% if (provider and provider.text_provider_type == key) or (form and form.text_provider_type == key) %}selected{% endif %}>
{{ preset.name }}
</option>
{% endfor %}
</select>
</label>
<label>API ключ
<input type="password" name="text_api_key" id="textApiKey"
value="{{ form.text_api_key if form else '' }}"
placeholder="Ваш API ключ">
</label>
<label style="flex-direction: row; align-items: center; gap: 8px;">
<input type="checkbox" name="text_use_global" value="1" id="textUseGlobal"
onchange="toggleGlobal('text')">
<span>Использовать глобальные настройки (PiAPI из .env)</span>
</label>
<label>Base URL
<input type="text" name="text_base_url" id="textBaseUrl"
value="{{ form.text_base_url if form else (provider.text_base_url if provider else '') }}"
placeholder="https://...">
</label>
<label>Модель
<input type="text" name="text_model" id="textModel"
value="{{ form.text_model if form else (provider.text_model if provider else '') }}"
placeholder="напр. gpt-4o, glm-5.2, claude-sonnet-5">
</label>
<div class="card" style="margin-top:15px; background: var(--bg-secondary);">
<p style="margin:0; font-size: 13px;"><strong>Популярные модели для текста:</strong></p>
<ul style="margin:5px 0 0 18px; font-size: 13px;">
<li><code>gpt-4o</code> — OpenAI, OpenRouter, PiAPI</li>
<li><code>claude-sonnet-5</code> — Anthropic через OpenRouter</li>
<li><code>glm-5.2</code> — OpenCode Zen (платная)</li>
<li><code>deepseek-v4-flash-free</code> — OpenCode Zen (бесплатная)</li>
<li><code>hy3-free</code> — OpenCode Zen (бесплатная, reasoning)</li>
</ul>
</div>
</div>
<!-- === ТАБ: ИЗОБРАЖЕНИЯ === -->
<div class="provider-tab-content" id="tabImage">
<h2>🎨 Генерация изображений</h2>
<label>Провайдер
<select name="image_provider_type" id="imageProviderType" required onchange="updatePresets('image')">
<option value="" disabled {% if not provider and not form %}selected{% endif %}>Выберите провайдера</option>
{% for key, preset in provider_presets.items() %}
{% if preset.supports_images %}
<option value="{{ key }}"
{% if (provider and provider.image_provider_type == key) or (form and form.image_provider_type == key) %}selected{% endif %}>
{{ preset.name }}
</option>
{% endif %}
{% endfor %}
</select>
</label>
<label>API ключ
<input type="password" name="image_api_key" id="imageApiKey"
value="{{ form.image_api_key if form else '' }}"
placeholder="Ваш API ключ">
</label>
<label style="flex-direction: row; align-items: center; gap: 8px;">
<input type="checkbox" name="image_use_global" value="1" id="imageUseGlobal"
onchange="toggleGlobal('image')">
<span>Использовать глобальные настройки (PiAPI из .env)</span>
</label>
<label>Base URL
<input type="text" name="image_base_url" id="imageBaseUrl"
value="{{ form.image_base_url if form else (provider.image_base_url if provider else '') }}"
placeholder="https://...">
</label>
<label>Модель
<input type="text" name="image_model" id="imageModel"
value="{{ form.image_model if form else (provider.image_model if provider else '') }}"
placeholder="напр. gpt-image-1, dall-e-3">
</label>
<div class="card" style="margin-top:15px; background: var(--bg-secondary);">
<p style="margin:0; font-size: 13px;"><strong>Популярные модели для изображений:</strong></p>
<ul style="margin:5px 0 0 18px; font-size: 13px;">
<li><code>gpt-image-1</code> — OpenAI, PiAPI (рекомендуется)</li>
<li><code>gpt-image-1-mini</code> — OpenAI (быстрая, дешевле)</li>
<li><code>dall-e-3</code> — OpenAI (старая, через прямой API)</li>
</ul>
</div>
</div>
<button type="submit">Сохранить настройки</button>
</form>
<p style="margin-top:20px"><a href="/profile/edit">← Назад в профиль</a> · <a href="/dashboard">← На главную</a></p>
<script>
const PRESETS = {
{% for key, preset in provider_presets.items() %}
"{{ key }}": {"base_url": "{{ preset.base_url }}", "text_model": "{{ preset.text_model }}", "image_model": "{{ preset.image_model }}"},
{% endfor %}
};
function switchTab(tab) {
// Переключаем кнопки
document.querySelectorAll('.provider-tab').forEach(function(btn) { btn.classList.remove('active'); });
document.querySelectorAll('.provider-tab-content').forEach(function(c) { c.classList.remove('active'); });
// Активируем нужный таб
if (tab === 'text') {
document.querySelectorAll('.provider-tab')[0].classList.add('active');
document.getElementById('tabText').classList.add('active');
} else {
document.querySelectorAll('.provider-tab')[1].classList.add('active');
document.getElementById('tabImage').classList.add('active');
}
}
function updatePresets(kind) {
var typeEl = document.getElementById(kind + "ProviderType");
var preset = PRESETS[typeEl.value];
if (!preset) return;
document.getElementById(kind + "BaseUrl").value = preset.base_url;
if (kind === "text") document.getElementById(kind + "Model").value = preset.text_model;
if (kind === "image") document.getElementById(kind + "Model").value = preset.image_model;
var globalCb = document.getElementById(kind + "UseGlobal");
if (globalCb && globalCb.checked) {
globalCb.checked = false;
toggleGlobal(kind);
}
}
function toggleGlobal(kind) {
var cb = document.getElementById(kind + "UseGlobal");
var key = document.getElementById(kind + "ApiKey");
var baseUrl = document.getElementById(kind + "BaseUrl");
var model = document.getElementById(kind + "Model");
if (cb.checked) {
key.disabled = true;
key.placeholder = "Используются глобальные настройки";
key.value = "";
baseUrl.value = "https://api.piapi.ai/v1";
model.value = (kind === "text") ? "gpt-4o" : "gpt-image-1";
} else {
key.disabled = false;
key.placeholder = "Ваш API ключ";
}
}
</script>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Опубликовать пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Публикация поста</h1>
<div class="card">
<p><strong>Платформа:</strong> {{ platform_label(post.platform) }}</p>
<p><strong>Создан:</strong> {{ post.created_at }}</p>
{% if post.published %}
<p><strong>Статус:</strong> ✅ Опубликован в «{{ post.published_to }}» {{ post.published_at }}</p>
{% else %}
<p><strong>Статус:</strong> ⏳ Не опубликован</p>
{% endif %}
</div>
<div class="card">
<h2>Текст поста</h2>
<div class="result">{{ post.content }}</div>
</div>
{% if channels %}
<form method="post" action="/posts/{{ post.id }}/publish" class="form">
<label>Канал для публикации
<select name="channel_id" required>
<option value="" disabled selected>Выберите канал или чат</option>
{% for ch in channels %}
<option value="{{ ch.id }}">{{ ch.title }} ({{ ch.chat_type }}){% if ch.username %} @{{ ch.username }}{% endif %}</option>
{% endfor %}
</select>
</label>
<button type="submit">📤 Опубликовать</button>
</form>
{% else %}
<div class="card">
<p>⚠️ Нет добавленных каналов. <a href="/channels/add">Добавьте канал →</a></p>
</div>
{% endif %}
<div class="post-actions">
<a href="/posts/{{ post.id }}" class="copy-btn">← Назад к посту</a>
<a href="/channels" class="copy-btn">⚙ Управление каналами</a>
</div>
{% endblock %}
-11
View File
@@ -9,17 +9,6 @@
<label>Телефон
<input type="text" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
<option value="" disabled {% if not form %}selected{% endif %}>Выбери вид бизнеса</option>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if form and form.business_type == key %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ form.description if form else '' }}</textarea>
</label>
<label>Email
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
+660
View File
@@ -0,0 +1,660 @@
{% extends "base.html" %}
{% block title %}Опрос медиа-образа — Личный кабинет{% endblock %}
{% block content %}
<h1>Опрос для создания медиа-образа</h1>
<p class="form-hint">Заполните поля по шагам. На последнем шаге можно сохранить и сгенерировать документ стратегии.</p>
<!-- === АНАЛИЗ ПРОФИЛЯ СОЦСЕТИ === -->
<div class="card" id="socialAnalyzer">
<h2 style="margin-top:0">🔍 Автозаполнение из профиля соцсети</h2>
<p class="form-hint">Вставьте ссылку на профиль или канал — ИИ проанализирует посты и заполнит поля медиа-образа. Поддерживается Telegram (автоматически), VK, Tenchat, Instagram (вручную).</p>
<div id="socialTaskError" class="task-error"></div>
<div id="socialTaskLoader" class="task-loader" style="display:none; padding:15px">
<div class="spinner"></div>
<div class="task-loader-text">Анализирую посты...</div>
<div class="task-loader-sub">Это может занять 20–60 секунд</div>
</div>
<div id="socialTaskResult" class="task-result" style="display:none"></div>
<div id="socialForm">
<label>Ссылка на профиль/канал
<input type="url" id="socialUrl" placeholder="https://t.me/channel, https://vk.com/username, https://tenchat.ru/username">
</label>
<div id="manualPostsSection" style="display:none">
<label>Тексты постов <span class="form-hint">Вставьте 3-10 постов. Разделяйте пустой строкой.</span>
<textarea id="manualPosts" rows="8" placeholder="Пост 1 текст...&#10;&#10;Пост 2 текст...&#10;&#10;Пост 3 текст..."></textarea>
</label>
<p class="form-hint" id="manualHint"></p>
</div>
<button type="button" class="copy-btn" onclick="startSocialAnalysis()" id="socialAnalyzeBtn">🔍 Анализировать профиль</button>
</div>
</div>
<script>
document.getElementById('socialUrl').addEventListener('input', function() {
var url = this.value.trim().toLowerCase();
var isTG = /t\.me\//.test(url);
var section = document.getElementById('manualPostsSection');
var hint = document.getElementById('manualHint');
if (url && !isTG) {
section.style.display = '';
hint.textContent = 'Для VK, Tenchat и других платформ: откройте профиль, скопируйте 3-10 постов и вставьте сюда.';
} else {
section.style.display = 'none';
}
});
function startSocialAnalysis() {
var url = document.getElementById('socialUrl').value.trim();
if (!url) { alert('Вставьте ссылку на профиль'); return; }
var manualPosts = document.getElementById('manualPosts') ? document.getElementById('manualPosts').value : '';
var fd = new FormData();
fd.append('task_type', 'analyze_social_profile');
fd.append('url', url);
fd.append('manual_posts', manualPosts);
document.getElementById('socialForm').style.display = 'none';
document.getElementById('socialTaskLoader').style.display = 'flex';
document.getElementById('socialTaskError').style.display = 'none';
document.getElementById('socialTaskResult').style.display = 'none';
fetch('/api/tasks', {method: 'POST', body: fd})
.then(function(r) { return r.json(); })
.then(function(data) {
var taskId = data.task_id;
var poll = setInterval(function() {
fetch('/api/tasks/' + taskId)
.then(function(r) { return r.json(); })
.then(function(t) {
if (t.status === 'done') {
clearInterval(poll);
document.getElementById('socialTaskLoader').style.display = 'none';
onSocialAnalysisDone(t.result);
} else if (t.status === 'error') {
clearInterval(poll);
document.getElementById('socialTaskLoader').style.display = 'none';
document.getElementById('socialTaskError').textContent = t.error || 'Ошибка анализа';
document.getElementById('socialTaskError').style.display = 'block';
document.getElementById('socialForm').style.display = '';
}
});
}, 1500);
})
.catch(function(e) {
document.getElementById('socialTaskLoader').style.display = 'none';
document.getElementById('socialTaskError').textContent = 'Ошибка сети: ' + e;
document.getElementById('socialTaskError').style.display = 'block';
document.getElementById('socialForm').style.display = '';
});
}
function onSocialAnalysisDone(result) {
var info = result.platform_info || {};
var count = result.posts_count || 0;
var html = '<div class="card"><p>✅ Проанализировано <strong>' + count + ' постов</strong> из ' + (info.platform || 'профиля') + ' (@' + (info.channel || '') + ')</p>';
html += '<button type="button" class="copy-btn" onclick="applySocialData()">✅ Применить к медиа-образу</button>';
html += ' <button type="button" class="button-link" onclick="resetSocialForm()">↩ Отмена</button></div>';
document.getElementById('socialTaskResult').innerHTML = html;
document.getElementById('socialTaskResult').style.display = '';
window._socialData = result;
}
function resetSocialForm() {
document.getElementById('socialForm').style.display = '';
document.getElementById('socialTaskResult').style.display = 'none';
document.getElementById('socialTaskResult').innerHTML = '';
}
function applySocialData() {
var d = window._socialData;
if (!d) return;
// Заполняем поля формы
setVal('client_name', d.client_name);
setVal('client_role', d.client_role);
setVal('brand_essence', d.brand_essence);
setVal('current_situation', d.current_situation);
setVal('positioning_who', d.positioning_who);
setVal('positioning_problem', d.positioning_problem);
setVal('positioning_result', d.positioning_result);
setVal('tov_description', d.tov_description);
setVal('tov_references', d.tov_references);
setVal('tov_axes', d.tov_axes);
setVal('prompt_directions', d.prompt_directions);
setVal('prompt_structure', d.prompt_structure);
setVal('prompt_formats', d.prompt_formats);
// Сворачиваем анализатор
document.getElementById('socialAnalyzer').style.display = 'none';
// Переходим к шагу 1
showStep(1);
}
function setVal(name, value) {
if (!value) return;
// AI может вернуть массив — превращаем в строки через перенос
if (Array.isArray(value)) {
value = value.join('\n');
}
var el = document.querySelector('[name="' + name + '"]');
if (el) {
el.value = value;
el.style.borderColor = 'var(--accent)';
setTimeout(function() { el.style.borderColor = ''; }, 2000);
}
}
</script>
<!-- === КОНЕЦ АНАЛИЗАТОРА === -->
<div class="survey-wizard">
<!-- Прогресс -->
<div class="survey-progress">
<div class="survey-progress-bar" id="progressBar"></div>
</div>
<div class="survey-steps-indicator" id="stepsIndicator"></div>
<form id="surveyForm">
<!-- Шаг 1: Основная информация -->
<div class="survey-step" data-step="1">
<h2>Шаг 1 — Основная информация</h2>
<div class="form">
<label>Тип медиа-образа
<select name="brand_type" id="brandType" onchange="toggleBrandType()">
<option value="personal">Личный бренд</option>
<option value="organization">Компания / бренд</option>
<option value="community">Общественная организация</option>
</select>
</label>
<!-- Личный бренд -->
<div id="fieldsPersonal">
<label>Имя клиента <input type="text" name="client_name" placeholder="Напр. Кирилл Бойко"></label>
<label>Профессия / роль <input type="text" name="client_role" placeholder="Напр. IT-эксперт, предприниматель"></label>
</div>
<!-- Организация -->
<div id="fieldsOrganization" style="display:none">
<label>Название организации <input type="text" name="org_name" placeholder="Напр. ТехноСтарт"></label>
<label>Сфера деятельности <input type="text" name="org_industry" placeholder="Напр. IT-консалтинг, разработка ПО"></label>
<label>Миссия организации <textarea name="org_mission" rows="2" placeholder="Напр. Помогаем бизнесу расти через технологии"></textarea></label>
<label>Ключевые продукты / услуги <textarea name="org_products" rows="2" placeholder="Напр. Разработка мобильных приложений, IT-аудит"></textarea></label>
<label>Количество сотрудников <input type="text" name="org_team_size" placeholder="Напр. 15-30"></label>
<label>Год основания <input type="text" name="org_founded" placeholder="Напр. 2018"></label>
</div>
<!-- Общественная организация -->
<div id="fieldsCommunity" style="display:none">
<label>Название организации <input type="text" name="org_name" placeholder="Напр. Сообщество молодых предпринимателей"></label>
<label>Сфера деятельности <input type="text" name="org_industry" placeholder="Напр. Бизнес-сообщество, поддержка молодых предпринимателей"></label>
<label>Миссия организации <textarea name="org_mission" rows="2" placeholder="Напр. Объединять молодых предпринимателей для обмена опытом и совместного роста"></textarea></label>
<label>Ключевые направления деятельности <textarea name="org_products" rows="2" placeholder="Напр. Мероприятия, менторство, нетворкинг, образовательные программы"></textarea></label>
<label>Количество участников <input type="text" name="org_team_size" placeholder="Напр. 200+"></label>
<label>Год основания <input type="text" name="org_founded" placeholder="Напр. 2022"></label>
<label>Формат участия <input type="text" name="org_format" placeholder="Напр. Бесплатное членство, ежемесячные встречи"></label>
</div>
<label>Город / регион <input type="text" name="client_location" placeholder="Напр. Донецк"></label>
<label>Описание текущей ситуации <textarea name="current_situation" rows="5" placeholder="Кто он/оно, чем занимается, какой уникальный опыт, текущий статус..."></textarea></label>
</div>
</div>
<!-- Шаг 2: Цели -->
<div class="survey-step" data-step="2" hidden>
<h2>Шаг 2 — Цели на 12 месяцев</h2>
<div class="form">
<label>Целевое число подписчиков <input type="number" name="goal_audience_number" placeholder="10000"></label>
<label>Основная платформа <input type="text" name="goal_audience_platform" placeholder="Напр. Telegram-канал"></label>
<label>Описание целевой аудитории <textarea name="goal_audience_description" rows="3" placeholder="Кто активная аудитория..."></textarea></label>
<label>Область экспертизы <input type="text" name="goal_expert_field" placeholder="Напр. цифровизация МСП"></label>
<label>Целевое кол-во приглашений на выступления в год <input type="number" name="goal_expert_speaking" placeholder="5"></label>
<label>Целевое кол-во заявок в месяц <input type="number" name="goal_commercial_leads" placeholder="5"></label>
<label>Какие услуги <textarea name="goal_commercial_services" rows="2" placeholder="Напр. IT-аудит, внедрение"></textarea></label>
<label>Описание контент-системы <textarea name="goal_content_system" rows="3" placeholder="Напр. Ежедневный контент-план на трёх столпах..."></textarea></label>
</div>
</div>
<!-- Шаг 3: Позиционирование -->
<div class="survey-step" data-step="3" hidden>
<h2>Шаг 3 — Позиционирование и УТП</h2>
<div class="form">
<label>Сущность бренда (одной строкой) <input type="text" name="brand_essence" placeholder="Напр. Цифровой архитектор, строящий IT-устойчивость бизнеса"></label>
<!-- Личный бренд -->
<div class="brand-fields-personal">
<label>Кому я помогаю? <textarea name="positioning_who" rows="2"></textarea></label>
<label>Какую проблему решаю? <textarea name="positioning_problem" rows="2"></textarea></label>
<label>Какой результат получает клиент? <textarea name="positioning_result" rows="2"></textarea></label>
</div>
<!-- Организация -->
<div class="brand-fields-organization" style="display:none">
<label>Какую проблему решает наш продукт/услуга? <textarea name="positioning_problem" rows="2"></textarea></label>
<label>Ценность для клиента <textarea name="positioning_result" rows="2" placeholder="Какую ценность получает клиент от нашей организации?"></textarea></label>
<label>Кому мы помогаем (целевой сегмент)? <textarea name="positioning_who" rows="2" placeholder="B2B / B2C, отрасли, размер бизнеса..."></textarea></label>
<label>Почему выбирают нас, а не конкурентов? <textarea name="positioning_difference" rows="2"></textarea></label>
</div>
<!-- Общественная организация -->
<div class="brand-fields-community" style="display:none">
<label>Какую проблему решает наше сообщество? <textarea name="positioning_problem" rows="2" placeholder="Изоляция молодых предпринимателей, отсутствие наставничества, слабый нетворкинг..."></textarea></label>
<label>Ценность для участника <textarea name="positioning_result" rows="2" placeholder="Что получает каждый участник сообщества?"></textarea></label>
<label>Для кого создано сообщество? <textarea name="positioning_who" rows="2" placeholder="Молодые предприниматели, стартаперы, начинающие бизнесмены..."></textarea></label>
<label>Чем отличаемся от других сообществ и ассоциаций? <textarea name="positioning_difference" rows="2"></textarea></label>
<label>Форматы взаимодействия <textarea name="community_formats" rows="2" placeholder="Митапы, оффлайн-встречи, онлайн-чаты, менторские сессии..."></textarea></label>
</div>
<label>УТП <textarea name="usp" rows="3" placeholder="Уникальное торговое предложение..."></textarea></label>
</div>
</div>
<!-- Шаг 4: Аудитории и каналы -->
<div class="survey-step" data-step="4" hidden>
<h2>Шаг 4 — Аудитории и каналы</h2>
<div class="form">
<label>Идеальные подписчики / клиенты <span class="form-hint">По строкам</span>
<textarea name="ideal_audiences" rows="5" placeholder="Владельцы МСП...&#10;IT-специалисты...&#10;Активная молодёжь..."></textarea>
</label>
<div>
<strong>Каналы коммуникации</strong>
<table class="table" id="channelsTable">
<thead><tr><th>Платформа</th><th>Тип</th><th>Частота</th><th>Описание</th><th></th></tr></thead>
<tbody id="channelsBody"></tbody>
</table>
<button type="button" class="copy-btn" onclick="addChannel()">+ Добавить канал</button>
</div>
</div>
</div>
<!-- Шаг 5: Визуальная идентичность -->
<div class="survey-step" data-step="5" hidden>
<h2>Шаг 5 — Визуальная идентичность</h2>
<div class="form">
<label>Концепция <input type="text" name="visual_concept" placeholder="Напр. Цифровой архитектор"></label>
<h3>Цветовая палитра</h3>
<label>Основной цвет — название <input type="text" name="color_main_name" placeholder="Напр. Глубокий антрацитово-синий"></label>
<label>Основной цвет — HEX <input type="text" name="color_main_hex" placeholder="#0A1A2F"></label>
<label>Акцент 1 — название <input type="text" name="color_accent1_name" placeholder="Напр. Электрический бирюзовый"></label>
<label>Акцент 1 — HEX <input type="text" name="color_accent1_hex" placeholder="#00E5CC"></label>
<label>Акцент 2 — название <input type="text" name="color_accent2_name" placeholder="Напр. Терракотовый"></label>
<label>Акцент 2 — HEX <input type="text" name="color_accent2_hex" placeholder="#FF6B4A"></label>
<label>Нейтральная база <input type="text" name="color_neutral" placeholder="Напр. Светло-серый, белый, графит"></label>
<h3>Типографика</h3>
<label>Заголовки <input type="text" name="typography_headings" placeholder="Напр. Montserrat / Gilroy"></label>
<label>Текст <input type="text" name="typography_body" placeholder="Напр. Open Sans / Inter"></label>
<label>Акценты <input type="text" name="typography_accents" placeholder="Напр. Roboto Mono"></label>
</div>
</div>
<!-- Шаг 6: Tone of Voice -->
<div class="survey-step" data-step="6" hidden>
<h2>Шаг 6 — Tone of Voice</h2>
<div class="form">
<label>Описание тона <input type="text" name="tov_description" placeholder="Напр. Стратег-прагматик с душой"></label>
<label>Референсы / вдохновители <textarea name="tov_references" rows="3" placeholder="Напр. Уверенность Лаврова, глубина Гео, яркость Минаева..."></textarea></label>
<label>Оси ToV <span class="form-hint">По строкам</span>
<textarea name="tov_axes" rows="4" placeholder="1) Энергичная уверенность&#10;2) Структурированная спонтанность&#10;3) Искренность&#10;4) Ответственный взгляд лидера"></textarea>
</label>
</div>
</div>
<!-- Шаг 7: Контент-стратегия -->
<div class="survey-step" data-step="7" hidden>
<h2>Шаг 7 — Контент-стратегия: Три столпа</h2>
<div class="form">
<!-- Личный бренд -->
<div class="brand-fields-personal">
<h3>Столп 1 — «Кто я» (Личность, Доверие)</h3>
<label>Название <input type="text" name="pillar1_name" value="Кто я"></label>
</div>
<div class="brand-fields-organization" style="display:none">
<h3>Столп 1 — «О продукте» (Компания, Доверие)</h3>
<label>Название <input type="text" name="pillar1_name" value="О продукте"></label>
</div>
<div class="brand-fields-community" style="display:none">
<h3>Столп 1 — «О миссии» (Сообщество, Ценности)</h3>
<label>Название <input type="text" name="pillar1_name" value="О миссии"></label>
</div>
<label>Цель <textarea name="pillar1_goal" rows="2" placeholder="Создать эмоциональную связь..."></textarea></label>
<label>Ключевые форматы <span class="form-hint">По строкам</span>
<textarea name="pillar1_formats" rows="3" class="pillar1-formats-hint" placeholder="История пути...&#10;Закулисье...&#10;Общественная деятельность..."></textarea>
</label>
<label>Частота <input type="text" name="pillar1_frequency" value="30%"></label>
<!-- Личный бренд -->
<div class="brand-fields-personal">
<h3>Столп 2 — «Решаю боль» (Экспертиза, Польза)</h3>
<label>Название <input type="text" name="pillar2_name" value="Решаю боль"></label>
</div>
<div class="brand-fields-organization" style="display:none">
<h3>Столп 2 — «Экспертиза» (Знания, Авторитет)</h3>
<label>Название <input type="text" name="pillar2_name" value="Экспертиза"></label>
</div>
<div class="brand-fields-community" style="display:none">
<h3>Столп 2 — «Мероприятия» (Активности, Результат)</h3>
<label>Название <input type="text" name="pillar2_name" value="Мероприятия"></label>
</div>
<label>Цель <textarea name="pillar2_goal" rows="2" placeholder="Закрепить статус эксперта..."></textarea></label>
<label>Ключевые форматы <span class="form-hint">По строкам</span>
<textarea name="pillar2_formats" rows="3" class="pillar2-formats-hint" placeholder="Экспертные разборы...&#10;Практические советы...&#10;Кейсы..."></textarea>
</label>
<label>Частота <input type="text" name="pillar2_frequency" value="50%"></label>
<!-- Личный бренд -->
<div class="brand-fields-personal">
<h3>Столп 3 — «Втягиваю» (Вовлечение, Диалог)</h3>
<label>Название <input type="text" name="pillar3_name" value="Втягиваю"></label>
</div>
<div class="brand-fields-organization" style="display:none">
<h3>Столп 3 — «Вовлечение» (Сообщество, Лояльность)</h3>
<label>Название <input type="text" name="pillar3_name" value="Вовлечение"></label>
</div>
<div class="brand-fields-community" style="display:none">
<h3>Столп 3 — «Участники» (Истории, Вовлечение)</h3>
<label>Название <input type="text" name="pillar3_name" value="Участники"></label>
</div>
<label>Цель <textarea name="pillar3_goal" rows="2" placeholder="Превратить пассивную аудиторию в активное сообщество..."></textarea></label>
<label>Ключевые форматы <span class="form-hint">По строкам</span>
<textarea name="pillar3_formats" rows="3" class="pillar3-formats-hint" placeholder="Опросы...&#10;Прямые эфиры...&#10;Интерактивные челленджи..."></textarea>
</label>
<label>Частота <input type="text" name="pillar3_frequency" value="20%"></label>
<label>Особые форматы <textarea name="special_formats" rows="2" placeholder="Напр. Живые видеокружочки..."></textarea></label>
</div>
</div>
<!-- Шаг 8: Воронка и продукты -->
<div class="survey-step" data-step="8" hidden>
<h2>Шаг 8 — Воронка и продуктовый ряд</h2>
<div class="form">
<h3>Воронка вовлечения</h3>
<div id="funnelContainer">
<!-- 5 этапов -->
</div>
<h3>Продуктовый ряд</h3>
<label>Входная точка (Lead Magnet) <textarea name="product_lead_magnet" rows="2" placeholder="Напр. Бесплатный PDF..."></textarea></label>
<label>Самая простая услуга <textarea name="product_simple" rows="2"></textarea></label>
<label>Услуга-конвертер <textarea name="product_converter" rows="2"></textarea></label>
<label>Комплексные решения <textarea name="product_complex" rows="2"></textarea></label>
<label>Экспертные продукты <textarea name="product_expert" rows="2"></textarea></label>
</div>
</div>
<!-- Шаг 9: Метрики -->
<div class="survey-step" data-step="9" hidden>
<h2>Шаг 9 — Метрики</h2>
<div class="form">
<label>Количественные метрики <span class="form-hint">По строкам, формат: Название | Целевое значение | Измерение</span>
<textarea name="metrics_quantitative" rows="5" placeholder="Подписчики в Telegram | 10000 | Статистика канала&#10;Ежемесячный охват | 250000 | Аналитика соцсетей"></textarea>
</label>
<label>Качественные метрики <span class="form-hint">Тот же формат</span>
<textarea name="metrics_qualitative" rows="5" placeholder="Уровень вовлеченности (ER) | > 7% | Формула&#10;Количество приглашений как спикера | 5-7 в год | Календарь"></textarea>
</label>
<label>Коммерческие метрики <span class="form-hint">Тот же формат</span>
<textarea name="metrics_commercial" rows="5" placeholder="Заявки с пометкой «от Кирилла» | 5 в месяц | CRM&#10;Конверсия из лида в продажу | 20% | Отчёт"></textarea>
</label>
</div>
</div>
<!-- Шаг 10: Риски и рекомендации -->
<div class="survey-step" data-step="10" hidden>
<h2>Шаг 10 — Риски и рекомендации</h2>
<div class="form">
<label>Потенциальные риски <span class="form-hint">По строкам, формат: Риск | Решение</span>
<textarea name="risks" rows="5" placeholder="Выгорание от ежедневного контента | Заранее создать банк контента...&#10;Конфликт образов | Связать через общую миссию..."></textarea>
</label>
<label>Рекомендации <span class="form-hint">По строкам</span>
<textarea name="recommendations" rows="5" placeholder="Быть консистентным...&#10;Делегировать техническую часть...&#10;Измерять и адаптироваться..."></textarea>
</label>
</div>
</div>
<!-- Шаг 11: Архетипы -->
<div class="survey-step" data-step="11" hidden>
<h2>Шаг 11 — Архетипический образ</h2>
<div class="form">
<h3>Ключевой архетип</h3>
<label>Название <input type="text" name="archetype_key_name" placeholder="Напр. Ведущий-Герой"></label>
<label>Суть <textarea name="archetype_key_essence" rows="3"></textarea></label>
<label>Проявления <span class="form-hint">По строкам</span>
<textarea name="archetype_key_manifestations" rows="5" placeholder="Миссия: ...&#10;Роли: ...&#10;Действия: ...&#10;Принцип: ...&#10;Визуальные коды: ..."></textarea>
</label>
<h3>Вспомогательные архетипы</h3>
<label>Вспомогательные архетипы <span class="form-hint">По строкам, формат: Название | Суть | Проявления</span>
<textarea name="archetype_supporting" rows="5" placeholder="Творец | Инноватор... | Создание экосистемы...&#10;Заботливый | Альтруист... | Помощь молодым..."></textarea>
</label>
<label>Синтез архетипов <textarea name="archetype_synthesis" rows="3" placeholder="Итоговый образ..."></textarea></label>
<label>Таблица проявлений <span class="form-hint">По строкам, формат: Ситуация | Архетип | Как выглядит</span>
<textarea name="archetype_table" rows="5" placeholder="Выступление на форуме | Ведущий-Герой | Чёткая структура...&#10;Разбор провала | Творец+Герой | Анализ ошибок..."></textarea>
</label>
</div>
</div>
<!-- Шаг 12: Промпт -->
<div class="survey-step" data-step="12" hidden>
<h2>Шаг 12 — Промпт для генерации контента</h2>
<div class="form">
<div class="brand-fields-personal">
<label>Контекст и личность <textarea name="prompt_context" rows="5" placeholder="Кто я, мой бренд, мои роли..."></textarea></label>
</div>
<div class="brand-fields-organization" style="display:none">
<label>Контекст организации <textarea name="prompt_context" rows="5" placeholder="Наша компания, миссия, ключевые ценности, роль в отрасли..."></textarea></label>
</div>
<div class="brand-fields-community" style="display:none">
<label>Контекст сообщества <textarea name="prompt_context" rows="5" placeholder="Миссия, ценности и форматы деятельности нашего сообщества..."></textarea></label>
</div>
<label>Tone of Voice <textarea name="prompt_tov" rows="4" placeholder="Мой голос — это..."></textarea></label>
<label>Структура мысли <textarea name="prompt_structure" rows="4" placeholder="Зацепка → Тезис → Аргументация → Личный угол → Призыв..."></textarea></label>
<label>Контент-направления <span class="form-hint">По строкам</span>
<textarea name="prompt_directions" rows="4" placeholder="IT и бизнес...&#10;Предпринимательство...&#10;Развитие региона...&#10;Личностное развитие..."></textarea>
</label>
<label>Форматы и шаблоны <textarea name="prompt_formats" rows="5" placeholder="Экспертный пост...&#10;Личный пост...&#10;Вовлекающий пост..."></textarea></label>
<label>Технические требования <textarea name="prompt_technical" rows="4" placeholder="Для Telegram: 3-7 абзацев..."></textarea></label>
</div>
</div>
<!-- Навигация -->
<div class="survey-nav">
<button type="button" id="btnPrev" onclick="prevStep()" hidden>← Назад</button>
<span id="stepCounter"></span>
<button type="button" id="btnNext" onclick="nextStep()">Далее →</button>
<button type="submit" id="btnSubmit" hidden>💾 Сохранить и сгенерировать документ</button>
</div>
</form>
</div>
<script>
const TOTAL_STEPS = 12;
let currentStep = 1;
const FUNNEL_STAGES = [
{name: "Этап 0: ОСВЕДОМЛЕННОСТЬ", content: "", cta: ""},
{name: "Этап 1: ИНТЕРЕС", content: "", cta: ""},
{name: "Этап 2: РАССМОТРЕНИЕ", content: "", cta: ""},
{name: "Этап 3: РЕШЕНИЕ", content: "", cta: ""},
{name: "КЛИЕНТ (Покупка)", content: "", cta: ""},
];
// --- Переключение типа бренда ---
function toggleBrandType() {
var type = document.getElementById("brandType").value;
var isPersonal = (type === "personal");
var isOrg = (type === "organization");
var isCommunity = (type === "community");
document.querySelectorAll(".brand-fields-personal").forEach(function(el) {
el.style.display = isPersonal ? "" : "none";
});
document.querySelectorAll(".brand-fields-organization").forEach(function(el) {
el.style.display = isOrg ? "" : "none";
});
document.querySelectorAll(".brand-fields-community").forEach(function(el) {
el.style.display = isCommunity ? "" : "none";
});
document.getElementById("fieldsPersonal").style.display = isPersonal ? "" : "none";
document.getElementById("fieldsOrganization").style.display = isOrg ? "" : "none";
document.getElementById("fieldsCommunity").style.display = isCommunity ? "" : "none";
updatePillarHints(type);
}
function updatePillarHints(type) {
var p1 = document.querySelector(".pillar1-formats-hint");
var p2 = document.querySelector(".pillar2-formats-hint");
var p3 = document.querySelector(".pillar3-formats-hint");
if (type === "community") {
if (p1) p1.placeholder = "Миссия сообщества...\\nИстория создания...\\nЦенности и принципы...";
if (p2) p2.placeholder = "Мероприятия и активности...\\nОбзоры встреч...\\nИтоги и анонсы...";
if (p3) p3.placeholder = "Истории участников...\\nОтзывы и кейсы...\\nПриглашения к участию...";
} else if (type === "organization") {
if (p1) p1.placeholder = "Обзор продукта...\\nКейсы клиентов...\\nНовинки компании...";
if (p2) p2.placeholder = "Экспертные статьи...\\nАналитика рынка...\\nРазборы технологий...";
if (p3) p3.placeholder = "Опросы...\\nРозыгрыши...\\nОтзывы клиентов...";
} else {
if (p1) p1.placeholder = "История пути...\\nЗакулисье...\\nОбщественная деятельность...";
if (p2) p2.placeholder = "Экспертные разборы...\\nПрактические советы...\\nКейсы...";
if (p3) p3.placeholder = "Опросы...\\nПрямые эфиры...\\nИнтерактивные челленджи...";
}
}
// --- Инициализация воронки ---
function initFunnel() {
const container = document.getElementById("funnelContainer");
container.innerHTML = "";
FUNNEL_STAGES.forEach((stage, i) => {
container.innerHTML += `
<div class="funnel-stage">
<label><strong>${stage.name}</strong></label>
<label>Контент <input type="text" name="funnel_${i}_content" placeholder="Какой контент на этом этапе"></label>
<label>CTA <input type="text" name="funnel_${i}_cta" placeholder="Призыв к действию"></label>
<input type="hidden" name="funnel_${i}_name" value="${stage.name}">
</div>`;
});
}
// --- Динамические каналы ---
let channelIdx = 0;
function addChannel(platform = "", type = "", frequency = "", description = "") {
const tbody = document.getElementById("channelsBody");
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input type="text" name="channels_${channelIdx}_platform" value="${platform}" placeholder="Напр. Telegram-канал"></td>
<td><input type="text" name="channels_${channelIdx}_type" value="${type}" placeholder="Основной / доп."></td>
<td><input type="text" name="channels_${channelIdx}_frequency" value="${frequency}" placeholder="Напр. 1 пост в день"></td>
<td><input type="text" name="channels_${channelIdx}_description" value="${description}" placeholder="Описание"></td>
<td><button type="button" class="button-link button-link-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(tr);
channelIdx++;
}
// --- Навигация ---
function showStep(n) {
document.querySelectorAll(".survey-step").forEach(el => el.hidden = true);
const el = document.querySelector(`.survey-step[data-step="${n}"]`);
if (el) el.hidden = false;
document.getElementById("btnPrev").hidden = (n === 1);
document.getElementById("btnNext").hidden = (n === TOTAL_STEPS);
document.getElementById("btnSubmit").hidden = (n !== TOTAL_STEPS);
document.getElementById("stepCounter").textContent = `Шаг ${n} из ${TOTAL_STEPS}`;
// Прогресс
const pct = ((n - 1) / (TOTAL_STEPS - 1)) * 100;
document.getElementById("progressBar").style.width = pct + "%";
// Индикатор
const ind = document.getElementById("stepsIndicator");
ind.innerHTML = "";
for (let i = 1; i <= TOTAL_STEPS; i++) {
const dot = document.createElement("span");
dot.className = "step-dot" + (i === n ? " active" : "") + (i < n ? " done" : "");
dot.textContent = i;
dot.onclick = () => showStep(i);
ind.appendChild(dot);
}
window.scrollTo(0, 0);
}
function nextStep() {
if (currentStep < TOTAL_STEPS) { currentStep++; showStep(currentStep); }
}
function prevStep() {
if (currentStep > 1) { currentStep--; showStep(currentStep); }
}
// --- Сборка данных ---
function collectData() {
const fd = new FormData(document.getElementById("surveyForm"));
const data = {};
for (const [key, val] of fd.entries()) {
data[key] = val;
}
// Каналы
const channels = [];
for (let i = 0; i < channelIdx + 10; i++) {
const p = data[`channels_${i}_platform`];
if (p && p.trim()) {
channels.push({
platform: p,
type: data[`channels_${i}_type`] || "",
frequency: data[`channels_${i}_frequency`] || "",
description: data[`channels_${i}_description`] || "",
});
}
}
data["channels"] = channels;
// Воронка
const funnel = [];
for (let i = 0; i < FUNNEL_STAGES.length; i++) {
funnel.push({
name: data[`funnel_${i}_name`] || FUNNEL_STAGES[i].name,
content: data[`funnel_${i}_content`] || "",
cta: data[`funnel_${i}_cta`] || "",
});
}
data["funnel"] = funnel;
return data;
}
// --- Отправка ---
document.getElementById("surveyForm").addEventListener("submit", async function(e) {
e.preventDefault();
const data = collectData();
var brandType = data.brand_type || "personal";
var title = (brandType === "organization")
? (data.org_name || "Без названия")
: (data.client_name || "Без названия");
const btn = document.getElementById("btnSubmit");
btn.disabled = true;
btn.textContent = "⏳ Генерация...";
try {
const resp = await fetch("/survey/submit", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({title: title, data: data}),
});
if (resp.ok) {
const result = await resp.json();
window.location.href = "/surveys/" + result.id;
} else {
alert("Ошибка при сохранении: " + resp.status);
btn.disabled = false;
btn.textContent = "💾 Сохранить и сгенерировать документ";
}
} catch (err) {
alert("Ошибка сети: " + err);
btn.disabled = false;
btn.textContent = "💾 Сохранить и сгенерировать документ";
}
});
// --- Старт ---
initFunnel();
addChannel("Telegram-канал", "Основной", "1 пост в день", "Глубокий контент, экспертиза");
addChannel("ВКвидео / Рутуб", "Доп.", "1 видео в неделю", "Длинные разборы, интервью");
showStep(1);
</script>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Загрузить медиа-образ — Личный кабинет{% endblock %}
{% block content %}
<h1>Загрузить медиа-образ</h1>
<div class="card">
<p>Загрузите готовый медиа-образ файлом — он будет автоматически разобран и сохранён.</p>
<p class="form-hint">Поддерживаются два формата:</p>
<ul>
<li><strong>.docx</strong> — документ стратегии (как образец). Разбирается через ИИ: извлекаются личность, УТП, Tone of Voice, столпы контента, архетипы и все поля опроса.</li>
<li><strong>.json</strong> — структурированные данные опроса (прямой импорт полей).</li>
</ul>
</div>
<form method="post" action="/survey/import" enctype="multipart/form-data" class="form">
<label>Файл медиа-образа
<input type="file" name="file" accept=".docx,.json" required>
</label>
<button type="submit">📤 Загрузить</button>
</form>
<div class="post-actions">
<a href="/survey" class="copy-btn">➕ Заполнить форму вручную</a>
<a href="/surveys" class="copy-btn">← К списку медиа-образов</a>
</div>
{% endblock %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Медиа-образы — Личный кабинет{% endblock %}
{% block content %}
<h1>Медиа-образы</h1>
<div class="post-actions" style="margin-bottom:15px">
<a class="tile" href="/survey">➕ Создать новый</a>
<a class="tile" href="/survey/import">📤 Загрузить из файла</a>
</div>
{% if surveys %}
<table class="table">
<thead>
<tr><th>Название</th><th>Дата</th><th></th></tr>
</thead>
<tbody>
{% for s in surveys %}
<tr>
<td><a href="/surveys/{{ s.id }}">{{ s.title }}</a></td>
<td>{{ s.created_at }}</td>
<td>
<a href="/surveys/{{ s.id }}/export" class="button-link">📄 .docx</a>
<form action="/surveys/{{ s.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить медиа-образ?')">
<button type="submit" class="button-link button-link-danger">🗑</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card">
<p>Пока нет ни одного медиа-образа. <a href="/survey">Создайте первый →</a></p>
</div>
{% endif %}
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}{{ survey.title }} — Личный кабинет{% endblock %}
{% block content %}
<h1>{{ survey.title }}</h1>
<div class="post-actions">
<a href="/surveys/{{ survey.id }}/export" class="copy-btn">📄 Скачать .docx</a>
<a href="/survey" class="copy-btn">➕ Новый опрос</a>
<a href="/surveys" class="copy-btn">← К списку</a>
<form action="/surveys/{{ survey.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить медиа-образ?')">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
<div class="card">
<p><strong>Дата создания:</strong> {{ survey.created_at }}</p>
</div>
{% set d = survey.data %}
<!-- 1. Резюме и цели -->
<div class="card">
<h2>1. Резюме и цели</h2>
<p><strong>Клиент:</strong> {{ d.client_name }} ({{ d.client_role }}), {{ d.client_location }}</p>
<p><strong>Текущая ситуация:</strong> {{ d.current_situation }}</p>
<h3>Цели на 12 месяцев:</h3>
<ul>
<li><strong>Аудитория:</strong> {{ d.goal_audience_number }} подписчиков в {{ d.goal_audience_platform }} ({{ d.goal_audience_description }})</li>
<li><strong>Экспертный статус:</strong> {{ d.goal_expert_field }}, {{ d.goal_expert_speaking }} выступлений/год</li>
<li><strong>Коммерциализация:</strong> {{ d.goal_commercial_leads }} заявок/мес ({{ d.goal_commercial_services }})</li>
<li><strong>Контент:</strong> {{ d.goal_content_system }}</li>
</ul>
</div>
<!-- 2. Позиционирование -->
<div class="card">
<h2>2. Позиционирование и УТП</h2>
<p><strong>Сущность бренда:</strong> {{ d.brand_essence }}</p>
<p><strong>Кому помогаю:</strong> {{ d.positioning_who }}</p>
<p><strong>Проблема:</strong> {{ d.positioning_problem }}</p>
<p><strong>Результат:</strong> {{ d.positioning_result }}</p>
<p><strong>УТП:</strong> {{ d.usp }}</p>
</div>
<!-- 3. Аудитории и каналы -->
<div class="card">
<h2>3. Аудитории и каналы</h2>
<h3>Идеальные подписчики:</h3>
<ul>
{% for line in (d.ideal_audiences or '').split('\n') if line.strip() %}
<li>{{ line.strip() }}</li>
{% endfor %}
</ul>
{% if d.channels %}
<h3>Каналы:</h3>
<table class="table">
<thead><tr><th>Платформа</th><th>Тип</th><th>Частота</th><th>Описание</th></tr></thead>
<tbody>
{% for ch in d.channels %}
<tr><td>{{ ch.platform }}</td><td>{{ ch.type }}</td><td>{{ ch.frequency }}</td><td>{{ ch.description }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<!-- 4. Визуальная идентичность -->
<div class="card">
<h2>4. Визуальная идентичность</h2>
<p><strong>Концепция:</strong> {{ d.visual_concept }}</p>
<h3>Цвета:</h3>
<ul>
<li>Основной: {{ d.color_main_name }} ({{ d.color_main_hex }})</li>
<li>Акцент 1: {{ d.color_accent1_name }} ({{ d.color_accent1_hex }})</li>
<li>Акцент 2: {{ d.color_accent2_name }} ({{ d.color_accent2_hex }})</li>
<li>Нейтральная база: {{ d.color_neutral }}</li>
</ul>
<h3>Типографика:</h3>
<ul>
<li>Заголовки: {{ d.typography_headings }}</li>
<li>Текст: {{ d.typography_body }}</li>
<li>Акценты: {{ d.typography_accents }}</li>
</ul>
</div>
<!-- 5. ToV -->
<div class="card">
<h2>5. Tone of Voice</h2>
<p><strong>Описание:</strong> {{ d.tov_description }}</p>
<p><strong>Референсы:</strong> {{ d.tov_references }}</p>
{% if d.tov_axes %}
<h3>Оси ToV:</h3>
<ul>
{% for line in d.tov_axes.split('\n') if line.strip() %}
<li>{{ line.strip() }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<!-- 6. Контент-стратегия -->
<div class="card">
<h2>6. Контент-стратегия: Три столпа</h2>
<table class="table">
<thead><tr><th>Столп</th><th>Цель</th><th>Форматы</th><th>Частота</th></tr></thead>
<tbody>
<tr><td>{{ d.pillar1_name }}</td><td>{{ d.pillar1_goal }}</td><td>{{ d.pillar1_formats }}</td><td>{{ d.pillar1_frequency }}</td></tr>
<tr><td>{{ d.pillar2_name }}</td><td>{{ d.pillar2_goal }}</td><td>{{ d.pillar2_formats }}</td><td>{{ d.pillar2_frequency }}</td></tr>
<tr><td>{{ d.pillar3_name }}</td><td>{{ d.pillar3_goal }}</td><td>{{ d.pillar3_formats }}</td><td>{{ d.pillar3_frequency }}</td></tr>
</tbody>
</table>
{% if d.special_formats %}
<p><strong>Особые форматы:</strong> {{ d.special_formats }}</p>
{% endif %}
</div>
<!-- 7. Воронка и продукты -->
<div class="card">
<h2>7. Воронка и продуктовый ряд</h2>
{% if d.funnel %}
<table class="table">
<thead><tr><th>Этап</th><th>Контент</th><th>CTA</th></tr></thead>
<tbody>
{% for s in d.funnel %}
<tr><td>{{ s.name }}</td><td>{{ s.content }}</td><td>{{ s.cta }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h3>Продуктовый ряд:</h3>
<ul>
<li><strong>Lead Magnet:</strong> {{ d.product_lead_magnet }}</li>
<li><strong>Простая услуга:</strong> {{ d.product_simple }}</li>
<li><strong>Услуга-конвертер:</strong> {{ d.product_converter }}</li>
<li><strong>Комплексные решения:</strong> {{ d.product_complex }}</li>
<li><strong>Экспертные продукты:</strong> {{ d.product_expert }}</li>
</ul>
</div>
<!-- 8. Метрики -->
<div class="card">
<h2>8. Метрики</h2>
{% if d.metrics_quantitative %}
<h3>Количественные:</h3>
<pre class="result">{{ d.metrics_quantitative }}</pre>
{% endif %}
{% if d.metrics_qualitative %}
<h3>Качественные:</h3>
<pre class="result">{{ d.metrics_qualitative }}</pre>
{% endif %}
{% if d.metrics_commercial %}
<h3>Коммерческие:</h3>
<pre class="result">{{ d.metrics_commercial }}</pre>
{% endif %}
</div>
<!-- 9. Риски -->
<div class="card">
<h2>9. Риски и рекомендации</h2>
{% if d.risks %}
<h3>Риски:</h3>
<pre class="result">{{ d.risks }}</pre>
{% endif %}
{% if d.recommendations %}
<h3>Рекомендации:</h3>
<pre class="result">{{ d.recommendations }}</pre>
{% endif %}
</div>
<!-- 10. Архетипы -->
<div class="card">
<h2>10. Архетипический образ</h2>
<p><strong>Ключевой архетип:</strong> {{ d.archetype_key_name }}</p>
<p><strong>Суть:</strong> {{ d.archetype_key_essence }}</p>
{% if d.archetype_key_manifestations %}
<pre class="result">{{ d.archetype_key_manifestations }}</pre>
{% endif %}
{% if d.archetype_supporting %}
<h3>Вспомогательные:</h3>
<pre class="result">{{ d.archetype_supporting }}</pre>
{% endif %}
{% if d.archetype_synthesis %}
<h3>Синтез:</h3>
<p>{{ d.archetype_synthesis }}</p>
{% endif %}
{% if d.archetype_table %}
<h3>Таблица проявлений:</h3>
<pre class="result">{{ d.archetype_table }}</pre>
{% endif %}
</div>
<!-- 11. Промпт -->
<div class="card">
<h2>11. Промпт для генерации контента</h2>
{% if d.prompt_context %}<p><strong>Контекст:</strong> {{ d.prompt_context }}</p>{% endif %}
{% if d.prompt_tov %}<p><strong>ToV:</strong> {{ d.prompt_tov }}</p>{% endif %}
{% if d.prompt_structure %}<p><strong>Структура:</strong> {{ d.prompt_structure }}</p>{% endif %}
{% if d.prompt_directions %}<p><strong>Направления:</strong></p><pre class="result">{{ d.prompt_directions }}</pre>{% endif %}
{% if d.prompt_formats %}<p><strong>Форматы:</strong></p><pre class="result">{{ d.prompt_formats }}</pre>{% endif %}
{% if d.prompt_technical %}<p><strong>Тех. требования:</strong></p><pre class="result">{{ d.prompt_technical }}</pre>{% endif %}
</div>
{% endblock %}
+527 -2
View File
@@ -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()
+57
View File
@@ -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
+212
View File
@@ -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` заполнены.
+116
View File
@@ -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
```
+37
View File
@@ -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/
+190
View File
@@ -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
+246
View File
@@ -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
+65
View File
@@ -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"
+57
View File
@@ -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"
+1
View File
@@ -8,3 +8,4 @@ bcrypt>=4.1.0
itsdangerous>=2.1.0
jinja2>=3.1.0
python-multipart>=0.0.9
python-docx>=1.1.0