Spaces:
Running
Running
Commit ·
e0ad138
0
Parent(s):
Initial commit: SEO AI Editor MVP with BERT, BM25 and N-gram analysis
Browse files- .gitignore +55 -0
- README.md +253 -0
- docs/API.md +257 -0
- docs/ARCHITECTURE.md +290 -0
- docs/DEVELOPMENT.md +333 -0
- logic.py +464 -0
- main.py +62 -0
- models.py +19 -0
- ps.sh +2 -0
- requirements.txt +10 -0
- templates/index.html +427 -0
.gitignore
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
env.bak/
|
| 28 |
+
venv.bak/
|
| 29 |
+
|
| 30 |
+
# IDE
|
| 31 |
+
.vscode/
|
| 32 |
+
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
| 36 |
+
.DS_Store
|
| 37 |
+
|
| 38 |
+
# Environment variables
|
| 39 |
+
.env
|
| 40 |
+
.env.local
|
| 41 |
+
|
| 42 |
+
# Temporary files
|
| 43 |
+
pip_temp/
|
| 44 |
+
*.log
|
| 45 |
+
*.tmp
|
| 46 |
+
|
| 47 |
+
# Jupyter Notebook
|
| 48 |
+
.ipynb_checkpoints
|
| 49 |
+
|
| 50 |
+
# PyTorch models cache (опционально, если хотите кэшировать модели локально)
|
| 51 |
+
# .cache/
|
| 52 |
+
|
| 53 |
+
# OS
|
| 54 |
+
Thumbs.db
|
| 55 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SEO AI Editor
|
| 2 |
+
|
| 3 |
+
Веб-приложение для анализа и оптимизации SEO-текстов с использованием искусственного интеллекта. Проект использует лингвистический анализ (spaCy), частотный анализ (BM25) и семантический анализ (BERT) для сравнения вашего текста с текстами конкурентов.
|
| 4 |
+
|
| 5 |
+
## 🚀 Возможности
|
| 6 |
+
|
| 7 |
+
- **Многоязычный анализ**: Поддержка русского, английского, немецкого, испанского и итальянского языков
|
| 8 |
+
- **N-граммный анализ**: Статистика по униграммам, биграммам, триграммам и квадриграммам
|
| 9 |
+
- **BM25 рекомендации**: Автоматические рекомендации по добавлению/удалению слов и фраз
|
| 10 |
+
- **BERT семантический анализ**: Глубокий анализ релевантности текста ключевым фразам с использованием нейронных сетей
|
| 11 |
+
- **Сравнение с конкурентами**: Детальное сравнение вашего текста с текстами конкурентов
|
| 12 |
+
- **GPU ускорение**: Автоматическое использование GPU для ускорения BERT-анализа
|
| 13 |
+
|
| 14 |
+
## 📋 Требования
|
| 15 |
+
|
| 16 |
+
- Python 3.8+
|
| 17 |
+
- CUDA (опционально, для GPU ускорения)
|
| 18 |
+
- 4+ GB RAM (рекомендуется 8+ GB)
|
| 19 |
+
|
| 20 |
+
## 🔧 Установка
|
| 21 |
+
|
| 22 |
+
1. Клонируйте репозиторий или перейдите в папку проекта:
|
| 23 |
+
```bash
|
| 24 |
+
cd seo_ai_editor
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
2. Создайте виртуальное окружение:
|
| 28 |
+
```bash
|
| 29 |
+
python -m venv venv
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
3. Активируйте виртуальное окружение:
|
| 33 |
+
- Windows:
|
| 34 |
+
```bash
|
| 35 |
+
venv\Scripts\activate
|
| 36 |
+
```
|
| 37 |
+
- Linux/Mac:
|
| 38 |
+
```bash
|
| 39 |
+
source venv/bin/activate
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
4. Установите зависимости:
|
| 43 |
+
```bash
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
5. Установите языковые модели spaCy:
|
| 48 |
+
```bash
|
| 49 |
+
python -m spacy download en_core_web_sm
|
| 50 |
+
python -m spacy download ru_core_news_sm
|
| 51 |
+
python -m spacy download de_core_news_sm
|
| 52 |
+
python -m spacy download es_core_news_sm
|
| 53 |
+
python -m spacy download it_core_news_sm
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## 🏃 Запуск
|
| 57 |
+
|
| 58 |
+
Запустите приложение:
|
| 59 |
+
```bash
|
| 60 |
+
python main.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
Или используйте uvicorn напрямую:
|
| 64 |
+
```bash
|
| 65 |
+
uvicorn main:app --host 127.0.0.1 --port 8001 --reload
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Приложение будет доступно по адресу: `http://127.0.0.1:8001`
|
| 69 |
+
|
| 70 |
+
## 📖 Использование
|
| 71 |
+
|
| 72 |
+
1. Откройте браузер и перейдите на `http://127.0.0.1:8001`
|
| 73 |
+
2. Выберите язык анализа
|
| 74 |
+
3. Введите ваш текст в поле "Ваш текст (Target)"
|
| 75 |
+
4. Введите ключевые фразы (каждая фраза с новой строки)
|
| 76 |
+
5. Добавьте тексты конкурентов (можно добавить несколько)
|
| 77 |
+
6. Нажмите кнопку "⚡ Анализировать (GPU)"
|
| 78 |
+
|
| 79 |
+
### Интерфейс результатов
|
| 80 |
+
|
| 81 |
+
Приложение предоставляет три вкладки с результатами:
|
| 82 |
+
|
| 83 |
+
#### 🧠 BERT Семантика
|
| 84 |
+
- **Общий рейтинг релевантности**: Сравнение вашего текста и конкурентов по среднему сходству с ключевыми фразами
|
| 85 |
+
- **Детальный анализ**: Для каждой ключевой фразы показывается:
|
| 86 |
+
- Максимальный score в вашем тексте
|
| 87 |
+
- Максимальный score у конкурентов
|
| 88 |
+
- Топ-5 наиболее релевантных предложений из вашего текста
|
| 89 |
+
- Топ-5 наиболее релевантных предложений у конкурентов
|
| 90 |
+
- Рекомендации по улучшению
|
| 91 |
+
|
| 92 |
+
#### 📊 BM25 Баланс
|
| 93 |
+
- Рекомендации по добавлению/удалению слов и фраз
|
| 94 |
+
- Показывает частотные различия между вашим текстом и конкурентами
|
| 95 |
+
- **Полная декомпозиция фраз**: Автоматически анализирует все возможные комбинации слов из ключевых фраз
|
| 96 |
+
- Например, для фразы "chicken road casino" анализируются: "chicken", "road", "casino", "chicken road", "road casino", "chicken road casino"
|
| 97 |
+
- Учитывает униграммы, биграммы и триграммы
|
| 98 |
+
- Умная сортировка: сначала показываются проблемные рекомендации, затем по длине фразы
|
| 99 |
+
|
| 100 |
+
#### 🔠 N-граммы
|
| 101 |
+
- Детальная статистика по частоте слов и фраз
|
| 102 |
+
- Сравнение с конкурентами
|
| 103 |
+
- Подсветка важных различий
|
| 104 |
+
|
| 105 |
+
## 🏗️ Архитектура проекта
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
seo_ai_editor/
|
| 109 |
+
├── main.py # FastAPI приложение и роутинг
|
| 110 |
+
├── logic.py # Основная бизнес-логика анализа
|
| 111 |
+
├── models.py # Pydantic модели для API
|
| 112 |
+
├── requirements.txt # Зависимости проекта
|
| 113 |
+
├── templates/
|
| 114 |
+
│ └── index.html # Frontend интерфейс
|
| 115 |
+
└── README.md # Документация
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Основные компоненты
|
| 119 |
+
|
| 120 |
+
#### `main.py`
|
| 121 |
+
- FastAPI приложение
|
| 122 |
+
- Роутинг: `/` (главная страница) и `/analyze` (API анализа)
|
| 123 |
+
- Предзагрузка моделей при старте
|
| 124 |
+
|
| 125 |
+
#### `logic.py`
|
| 126 |
+
Содержит три основных модуля:
|
| 127 |
+
|
| 128 |
+
1. **SPACY (Лингвистика)**
|
| 129 |
+
- `load_models()` - загрузка языковых моделей
|
| 130 |
+
- `get_doc()` - обработка текста через spaCy
|
| 131 |
+
- `get_lemmas_flat()` - получение лемматизированных токенов
|
| 132 |
+
- `generate_ngrams_safe()` - генерация N-грамм
|
| 133 |
+
|
| 134 |
+
2. **ANALYTICS (N-grams & BM25)**
|
| 135 |
+
- `calculate_ngram_stats()` - статистика N-грамм
|
| 136 |
+
- `parse_keywords()` - парсинг ключевых фраз
|
| 137 |
+
- `calculate_bm25_recommendations()` - BM25 рекомендации
|
| 138 |
+
|
| 139 |
+
3. **BERT / VECTOR ANALYSIS**
|
| 140 |
+
- `get_bert_model()` - загрузка BERT модели
|
| 141 |
+
- `perform_bert_analysis()` - семантический анализ
|
| 142 |
+
|
| 143 |
+
#### `models.py`
|
| 144 |
+
Pydantic модели:
|
| 145 |
+
- `AnalysisRequest` - запрос на анализ
|
| 146 |
+
- `AnalysisResponse` - ответ с результатами анализа
|
| 147 |
+
|
| 148 |
+
## 🔬 Технологии
|
| 149 |
+
|
| 150 |
+
- **FastAPI** - веб-фреймворк
|
| 151 |
+
- **spaCy** - лингвистический анализ
|
| 152 |
+
- **rank-bm25** - алгоритм BM25 для ранжирования
|
| 153 |
+
- **sentence-transformers** - BERT модели для семантического анализа
|
| 154 |
+
- **PyTorch** - глубокое обучение (для BERT)
|
| 155 |
+
- **Jinja2** - шаблонизация HTML
|
| 156 |
+
- **Bootstrap 5** - UI фреймворк
|
| 157 |
+
|
| 158 |
+
## 📝 API Документация
|
| 159 |
+
|
| 160 |
+
После запуска приложения доступна автоматическая документация API:
|
| 161 |
+
- Swagger UI: `http://127.0.0.1:8001/docs`
|
| 162 |
+
- ReDoc: `http://127.0.0.1:8001/redoc`
|
| 163 |
+
|
| 164 |
+
### Endpoint: `/analyze`
|
| 165 |
+
|
| 166 |
+
**Метод:** POST
|
| 167 |
+
|
| 168 |
+
**Тело запроса:**
|
| 169 |
+
```json
|
| 170 |
+
{
|
| 171 |
+
"target_text": "Ваш текст для анализа",
|
| 172 |
+
"competitors": ["Текст конкурента 1", "Текст конкурента 2"],
|
| 173 |
+
"keywords": ["ключевая фраза 1", "ключевая фраза 2"],
|
| 174 |
+
"language": "ru"
|
| 175 |
+
}
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
**Ответ:**
|
| 179 |
+
```json
|
| 180 |
+
{
|
| 181 |
+
"ngram_stats": {
|
| 182 |
+
"unigrams": [...],
|
| 183 |
+
"bigrams": [...],
|
| 184 |
+
"trigrams": [...],
|
| 185 |
+
"quadgrams": [...]
|
| 186 |
+
},
|
| 187 |
+
"bm25_recommendations": [
|
| 188 |
+
{
|
| 189 |
+
"word": "слово",
|
| 190 |
+
"type": "1-gram",
|
| 191 |
+
"my_score": 2.5,
|
| 192 |
+
"avg_comp_score": 3.0,
|
| 193 |
+
"action": "add",
|
| 194 |
+
"count": 2
|
| 195 |
+
}
|
| 196 |
+
],
|
| 197 |
+
"bert_analysis": {
|
| 198 |
+
"global_scores": [...],
|
| 199 |
+
"detailed": [...]
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
## ⚙️ Настройки
|
| 205 |
+
|
| 206 |
+
### Языки
|
| 207 |
+
Поддерживаемые языки задаются в `logic.py` в словаре `MODEL_NAMES`:
|
| 208 |
+
- `en` - английский
|
| 209 |
+
- `ru` - русский
|
| 210 |
+
- `de` - немецкий
|
| 211 |
+
- `es` - испанский
|
| 212 |
+
- `it` - итальянский
|
| 213 |
+
|
| 214 |
+
### BERT модель
|
| 215 |
+
По умолчанию используется `paraphrase-multilingual-MiniLM-L12-v2`. Модель можно изменить в функции `get_bert_model()` в файле `logic.py`.
|
| 216 |
+
|
| 217 |
+
### Пороги BM25
|
| 218 |
+
Пороги для рекомендаций можно настроить в функции `calculate_bm25_recommendations()`:
|
| 219 |
+
- Униграммы: `threshold = 0.5`
|
| 220 |
+
- Биграммы: `threshold = 0.25`
|
| 221 |
+
- Триграммы: `threshold = 0.15`
|
| 222 |
+
|
| 223 |
+
**Примечание:** Алгоритм BM25 автоматически выполняет полную декомпозицию ключевых фраз на все возможные под-н-граммы (1-3 слова). Это позволяет находить не только точные совпадения, но и частичные вхождения ключевых фраз в тексте.
|
| 224 |
+
|
| 225 |
+
## 🐛 Решение проблем
|
| 226 |
+
|
| 227 |
+
### Ошибка загрузки spaCy модели
|
| 228 |
+
Убедитесь, что модель установлена:
|
| 229 |
+
```bash
|
| 230 |
+
python -m spacy download <model_name>
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Медленная работа BERT
|
| 234 |
+
- Убедитесь, что CUDA установлена и доступна (��ля GPU ускорения)
|
| 235 |
+
- При первом запуске модель загружается, это может занять время
|
| 236 |
+
- Используйте GPU для значительного ускорения
|
| 237 |
+
|
| 238 |
+
### Проблемы с памятью
|
| 239 |
+
- Уменьшите количество конкурентов
|
| 240 |
+
- Разбейте длинные тексты на части
|
| 241 |
+
- Используйте более легкую BERT модель
|
| 242 |
+
|
| 243 |
+
## 📄 Лицензия
|
| 244 |
+
|
| 245 |
+
Проект создан для образовательных и коммерческих целей.
|
| 246 |
+
|
| 247 |
+
## 🤝 Вклад
|
| 248 |
+
|
| 249 |
+
Приветствуются улучшения и предложения! Создавайте issues и pull requests.
|
| 250 |
+
|
| 251 |
+
## 📧 Контакты
|
| 252 |
+
|
| 253 |
+
Для вопросов и предложений создавайте issues в репозитории проекта.
|
docs/API.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Документация
|
| 2 |
+
|
| 3 |
+
## Обзор
|
| 4 |
+
|
| 5 |
+
SEO AI Editor предоставляет REST API для анализа текстов. API построен на FastAPI и автоматически генерирует интерактивную документацию.
|
| 6 |
+
|
| 7 |
+
## Базовый URL
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
http://127.0.0.1:8001
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
## Endpoints
|
| 14 |
+
|
| 15 |
+
### GET `/`
|
| 16 |
+
|
| 17 |
+
Возвращает главную страницу приложения (HTML).
|
| 18 |
+
|
| 19 |
+
**Ответ:** HTML страница с интерфейсом
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
### POST `/analyze`
|
| 24 |
+
|
| 25 |
+
Выполняет комплексный анализ текста с использованием N-грамм, BM25 и BERT.
|
| 26 |
+
|
| 27 |
+
#### Запрос
|
| 28 |
+
|
| 29 |
+
**Content-Type:** `application/json`
|
| 30 |
+
|
| 31 |
+
**Тело запроса:**
|
| 32 |
+
|
| 33 |
+
```json
|
| 34 |
+
{
|
| 35 |
+
"target_text": "string (обязательно)",
|
| 36 |
+
"competitors": ["string"] (обязательно, может быть пустым массивом),
|
| 37 |
+
"keywords": ["string"] (обязательно, может быть пустым массивом),
|
| 38 |
+
"language": "string" (опционально, по умолчанию "en")
|
| 39 |
+
}
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
**Параметры:**
|
| 43 |
+
|
| 44 |
+
| Параметр | Тип | Обязательный | Описание |
|
| 45 |
+
|----------|-----|--------------|----------|
|
| 46 |
+
| `target_text` | string | Да | Текст пользователя для анализа |
|
| 47 |
+
| `competitors` | array[string] | Да | Массив текстов конкурентов |
|
| 48 |
+
| `keywords` | array[string] | Да | Массив ключевых фраз (каждая фраза - отдельный элемент) |
|
| 49 |
+
| `language` | string | Нет | Код языка: `en`, `ru`, `de`, `es`, `it` |
|
| 50 |
+
|
| 51 |
+
#### Ответ
|
| 52 |
+
|
| 53 |
+
**Статус:** 200 OK
|
| 54 |
+
|
| 55 |
+
**Content-Type:** `application/json`
|
| 56 |
+
|
| 57 |
+
```json
|
| 58 |
+
{
|
| 59 |
+
"ngram_stats": {
|
| 60 |
+
"unigrams": [
|
| 61 |
+
{
|
| 62 |
+
"ngram": "string",
|
| 63 |
+
"target_count": 0,
|
| 64 |
+
"competitor_avg": 0.0
|
| 65 |
+
}
|
| 66 |
+
],
|
| 67 |
+
"bigrams": [...],
|
| 68 |
+
"trigrams": [...],
|
| 69 |
+
"quadgrams": [...]
|
| 70 |
+
},
|
| 71 |
+
"bm25_recommendations": [
|
| 72 |
+
{
|
| 73 |
+
"word": "string",
|
| 74 |
+
"type": "1-gram" | "2-gram" | "3-gram",
|
| 75 |
+
"my_score": 0.0,
|
| 76 |
+
"avg_comp_score": 0.0,
|
| 77 |
+
"action": "ok" | "add" | "remove",
|
| 78 |
+
"count": 0
|
| 79 |
+
}
|
| 80 |
+
],
|
| 81 |
+
"bert_analysis": {
|
| 82 |
+
"global_scores": [
|
| 83 |
+
{
|
| 84 |
+
"name": "string",
|
| 85 |
+
"score": 0.0,
|
| 86 |
+
"is_me": true
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
"detailed": [
|
| 90 |
+
{
|
| 91 |
+
"phrase": "string",
|
| 92 |
+
"my_max_score": 0.0,
|
| 93 |
+
"comp_max_score": 0.0,
|
| 94 |
+
"status": "ok" | "good" | "warning" | "bad",
|
| 95 |
+
"recommendation": "string",
|
| 96 |
+
"my_top_chunks": [
|
| 97 |
+
{
|
| 98 |
+
"text": "string",
|
| 99 |
+
"score": 0.0
|
| 100 |
+
}
|
| 101 |
+
],
|
| 102 |
+
"comp_top_chunks": [
|
| 103 |
+
{
|
| 104 |
+
"text": "string",
|
| 105 |
+
"score": 0.0,
|
| 106 |
+
"source": "string"
|
| 107 |
+
}
|
| 108 |
+
]
|
| 109 |
+
}
|
| 110 |
+
]
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
#### Структура ответа
|
| 116 |
+
|
| 117 |
+
##### ngram_stats
|
| 118 |
+
|
| 119 |
+
Статистика по N-граммам (1-4 слова).
|
| 120 |
+
|
| 121 |
+
**Поля:**
|
| 122 |
+
- `ngram` - текст N-граммы (лемматизированный)
|
| 123 |
+
- `target_count` - количество вхождений в целевом тексте
|
| 124 |
+
- `competitor_avg` - среднее количество вхождений у конкурентов
|
| 125 |
+
|
| 126 |
+
**Сортировка:** По максимальному значению (target_count или competitor_avg)
|
| 127 |
+
|
| 128 |
+
##### bm25_recommendations
|
| 129 |
+
|
| 130 |
+
Рекомендации по оптимизации частоты слов/фраз с использованием алгоритма BM25.
|
| 131 |
+
|
| 132 |
+
**Особенности алгоритма:**
|
| 133 |
+
- **Полная декомпозиция фраз**: Каждая ключевая фраза автоматически разбивается на все возможные под-н-граммы длиной от 1 до 3 слов
|
| 134 |
+
- Пример: фраза "chicken road casino" анализируется как:
|
| 135 |
+
- Униграммы: "chicken", "road", "casino"
|
| 136 |
+
- Биграммы: "chicken road", "road casino"
|
| 137 |
+
- Триграммы: "chicken road casino"
|
| 138 |
+
- Это позволяет находить не только точные совпадения, но и частичные вхождения ключевых фраз
|
| 139 |
+
- Дубликаты автоматически удаляются
|
| 140 |
+
|
| 141 |
+
**Поля:**
|
| 142 |
+
- `word` - слово или фраза (лемматизированная)
|
| 143 |
+
- `type` - тип: "1-gram", "2-gram", "3-gram"
|
| 144 |
+
- `my_score` - BM25 score в целевом тексте
|
| 145 |
+
- `avg_comp_score` - средний BM25 score у конкурентов
|
| 146 |
+
- `action` - рекомендуемое действие:
|
| 147 |
+
- `"ok"` - частота в норме
|
| 148 |
+
- `"add"` - нужно добавить (ваш score ниже среднего конкурентов)
|
| 149 |
+
- `"remove"` - нужно убрать (ваш score значительно выше среднего конкурентов)
|
| 150 |
+
- `count` - рекомендуемое количество добавлений/удалений (рассчитывается на основе разницы скоров)
|
| 151 |
+
|
| 152 |
+
**Пороги для действий:**
|
| 153 |
+
- Униграммы: порог 0.5
|
| 154 |
+
- Биграммы: порог 0.25
|
| 155 |
+
- Триграммы: порог 0.15
|
| 156 |
+
|
| 157 |
+
**Сортировка:**
|
| 158 |
+
1. Сначала проблемные рекомендации (add/remove)
|
| 159 |
+
2. Затем по длине фразы (длинные фразы важнее)
|
| 160 |
+
3. Затем алфавитно
|
| 161 |
+
|
| 162 |
+
##### bert_analysis
|
| 163 |
+
|
| 164 |
+
Семантический анализ с использованием BERT.
|
| 165 |
+
|
| 166 |
+
**global_scores:**
|
| 167 |
+
- `name` - название текста ("Мой текст" или "Конкурент #N")
|
| 168 |
+
- `score` - средний максимальный score по всем ключевым фразам (0.0 - 1.0)
|
| 169 |
+
- `is_me` - флаг, является ли это целевым текстом
|
| 170 |
+
|
| 171 |
+
**detailed:**
|
| 172 |
+
Для каждой ключевой фразы:
|
| 173 |
+
- `phrase` - исходная ключевая фраза
|
| 174 |
+
- `my_max_score` - максимальный score в целевом тексте (0.0 - 1.0)
|
| 175 |
+
- `comp_max_score` - максимальный score у конкурентов
|
| 176 |
+
- `status` - статус:
|
| 177 |
+
- `"good"` - score >= 0.7
|
| 178 |
+
- `"ok"` - 0.5 <= score < 0.7
|
| 179 |
+
- `"warning"` - score < 0.5 или конкуренты лучше на 0.1+
|
| 180 |
+
- `"bad"` - score < 0.5
|
| 181 |
+
- `recommendation` - текстовое описание рекомендации
|
| 182 |
+
- `my_top_chunks` - топ-5 наиболее релевантных предложений из целевого текста
|
| 183 |
+
- `comp_top_chunks` - топ-5 наиболее релевантных предложений у конкурентов (с указанием источника)
|
| 184 |
+
|
| 185 |
+
#### Примеры запросов
|
| 186 |
+
|
| 187 |
+
**cURL:**
|
| 188 |
+
```bash
|
| 189 |
+
curl -X POST "http://127.0.0.1:8001/analyze" \
|
| 190 |
+
-H "Content-Type: application/json" \
|
| 191 |
+
-d '{
|
| 192 |
+
"target_text": "Это мой текст для анализа SEO.",
|
| 193 |
+
"competitors": ["Текст конкурента номер один.", "Текст конкурента номер два."],
|
| 194 |
+
"keywords": ["SEO анализ", "текст"],
|
| 195 |
+
"language": "ru"
|
| 196 |
+
}'
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
**Python:**
|
| 200 |
+
```python
|
| 201 |
+
import requests
|
| 202 |
+
|
| 203 |
+
response = requests.post(
|
| 204 |
+
"http://127.0.0.1:8001/analyze",
|
| 205 |
+
json={
|
| 206 |
+
"target_text": "Это мой текст для анализа SEO.",
|
| 207 |
+
"competitors": ["Текст конкурента номер один.", "Текст конкурента номер два."],
|
| 208 |
+
"keywords": ["SEO анализ", "текст"],
|
| 209 |
+
"language": "ru"
|
| 210 |
+
}
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
data = response.json()
|
| 214 |
+
print(data)
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
**JavaScript:**
|
| 218 |
+
```javascript
|
| 219 |
+
fetch('http://127.0.0.1:8001/analyze', {
|
| 220 |
+
method: 'POST',
|
| 221 |
+
headers: {
|
| 222 |
+
'Content-Type': 'application/json',
|
| 223 |
+
},
|
| 224 |
+
body: JSON.stringify({
|
| 225 |
+
target_text: "Это мой текст для анализа SEO.",
|
| 226 |
+
competitors: ["Текст конкурента номер один.", "Текст конкурента номер два."],
|
| 227 |
+
keywords: ["SEO анализ", "текст"],
|
| 228 |
+
language: "ru"
|
| 229 |
+
})
|
| 230 |
+
})
|
| 231 |
+
.then(response => response.json())
|
| 232 |
+
.then(data => console.log(data));
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
#### Ошибки
|
| 236 |
+
|
| 237 |
+
**400 Bad Request**
|
| 238 |
+
Неверный формат запроса или отсутствуют обязательные поля.
|
| 239 |
+
|
| 240 |
+
**422 Unprocessable Entity**
|
| 241 |
+
Ошибка валидации данных (например, неверный код языка).
|
| 242 |
+
|
| 243 |
+
**500 Internal Server Error**
|
| 244 |
+
Внутренняя ошибка сервера (проблемы с моделями, памятью и т.д.).
|
| 245 |
+
|
| 246 |
+
## Интерактивная документация
|
| 247 |
+
|
| 248 |
+
После запуска приложения доступны:
|
| 249 |
+
|
| 250 |
+
- **Swagger UI**: `http://127.0.0.1:8001/docs`
|
| 251 |
+
- **ReDoc**: `http://127.0.0.1:8001/redoc`
|
| 252 |
+
|
| 253 |
+
Эти интерфейсы позволяют:
|
| 254 |
+
- Просматривать все endpoints
|
| 255 |
+
- Тестировать API прямо в браузере
|
| 256 |
+
- Видеть схемы данных
|
| 257 |
+
- Просматривать примеры запросов и ответов
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Архитектура проекта
|
| 2 |
+
|
| 3 |
+
## Обзор
|
| 4 |
+
|
| 5 |
+
SEO AI Editor построен на архитектуре клиент-сервер с использованием FastAPI для backend и простого HTML/JavaScript для frontend.
|
| 6 |
+
|
| 7 |
+
## Структура проекта
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
seo_ai_editor/
|
| 11 |
+
├── main.py # Точка входа, FastAPI приложение
|
| 12 |
+
├── logic.py # Бизнес-логика и алгоритмы анализа
|
| 13 |
+
├── models.py # Pydantic модели данных
|
| 14 |
+
├── requirements.txt # Python зависимости
|
| 15 |
+
├── templates/
|
| 16 |
+
│ └── index.html # Frontend интерфейс
|
| 17 |
+
├── docs/ # Документация
|
| 18 |
+
│ ├── API.md
|
| 19 |
+
│ ├── ARCHITECTURE.md
|
| 20 |
+
│ └── DEVELOPMENT.md
|
| 21 |
+
└── README.md # Основная документация
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Компоненты системы
|
| 25 |
+
|
| 26 |
+
### 1. Backend (FastAPI)
|
| 27 |
+
|
| 28 |
+
#### `main.py` - Веб-сервер
|
| 29 |
+
|
| 30 |
+
**Ответственность:**
|
| 31 |
+
- Инициализация FastAPI приложения
|
| 32 |
+
- Роутинг HTTP запросов
|
| 33 |
+
- Предзагрузка моделей при старте
|
| 34 |
+
- Обработка запросов и формирование ответов
|
| 35 |
+
|
| 36 |
+
**Ключевые функции:**
|
| 37 |
+
- `startup_event()` - загрузка моделей при старте
|
| 38 |
+
- `read_root()` - отдача главной страницы
|
| 39 |
+
- `analyze_text()` - обработка запроса на анализ
|
| 40 |
+
|
| 41 |
+
#### `logic.py` - Бизнес-логика
|
| 42 |
+
|
| 43 |
+
Разделен на три модуля:
|
| 44 |
+
|
| 45 |
+
##### A. SPACY (Лингвистический анализ)
|
| 46 |
+
|
| 47 |
+
**Модели:**
|
| 48 |
+
- Глобальный словарь `LoadedModels` для кэширования загруженных spaCy моделей
|
| 49 |
+
- Поддержка 5 языков: en, ru, de, es, it
|
| 50 |
+
|
| 51 |
+
**Функции:**
|
| 52 |
+
- `load_model_if_missing(lang)` - ленивая загрузка моделей
|
| 53 |
+
- `load_models()` - предзагрузка всех моделей
|
| 54 |
+
- `get_doc(text, lang)` - получение spaCy документа
|
| 55 |
+
- `is_valid_token(t)` - фильтрация токенов (удаление мусора)
|
| 56 |
+
- `get_lemmas_flat(text, lang)` - получение списка лемм
|
| 57 |
+
- `generate_ngrams_safe(text, lang, n)` - генерация N-грамм с умной фильтрацией
|
| 58 |
+
|
| 59 |
+
**Особенности:**
|
| 60 |
+
- Сохранение стоп-слов внутри фраз для читаемости
|
| 61 |
+
- Фильтрация N-грамм, состоящих только из стоп-слов
|
| 62 |
+
- Обработка больших текстов (max_length = 2,000,000)
|
| 63 |
+
|
| 64 |
+
##### B. ANALYTICS (N-граммы и BM25)
|
| 65 |
+
|
| 66 |
+
**Функции:**
|
| 67 |
+
- `calculate_ngram_stats()` - статистика по N-граммам (1-4)
|
| 68 |
+
- `parse_keywords()` - парсинг ключевых фраз
|
| 69 |
+
- `calculate_bm25_recommendations()` - многоуровневый BM25 анализ
|
| 70 |
+
|
| 71 |
+
**Алгоритм BM25 (с полной декомпозицией фраз):**
|
| 72 |
+
1. **Декомпозиция ключевых фраз**: Для каждой ключевой фразы генерируются все возможные под-н-граммы длиной от 1 до 3 слов
|
| 73 |
+
- Пример: фраза "chicken road casino" разбивается на:
|
| 74 |
+
- Униграммы: "chicken", "road", "casino"
|
| 75 |
+
- Биграммы: "chicken road", "road casino"
|
| 76 |
+
- Триграммы: "chicken road casino"
|
| 77 |
+
- Используется скользящее окно по токенам фразы
|
| 78 |
+
- Дубликаты отслеживаются через set для оптимизации
|
| 79 |
+
2. Генерация N-грамм для целевого текста и конкурентов (униграммы, биграммы, триграммы)
|
| 80 |
+
3. Обучение BM25 модели на корпусе N-грамм для каждого уровня (1, 2, 3)
|
| 81 |
+
4. Расчет BM25 скоров для каждой декомпозированной фразы
|
| 82 |
+
5. Сравнение скоров целевого текста со средним скором конкурентов
|
| 83 |
+
6. Генерация рекомендаций (add/remove/ok) на основе пороговых значений
|
| 84 |
+
7. Сортировка результатов: сначала проблемные (add/remove), затем по длине фразы, затем алфавитно
|
| 85 |
+
|
| 86 |
+
**Пороги:**
|
| 87 |
+
- Униграммы: 0.5
|
| 88 |
+
- Биграммы: 0.25
|
| 89 |
+
- Триграммы: 0.15
|
| 90 |
+
|
| 91 |
+
**Особенности:**
|
| 92 |
+
- Полная декомпозиция позволяет анализировать не только целые фразы, но и их части
|
| 93 |
+
- Это особенно полезно для длинных ключевых фраз, которые могут встречаться в тексте частично
|
| 94 |
+
- Автоматическое удаление дубликатов при декомпозиции
|
| 95 |
+
|
| 96 |
+
##### C. BERT / VECTOR ANALYSIS
|
| 97 |
+
|
| 98 |
+
**Модель:**
|
| 99 |
+
- Глобальная переменная `BertModel` для кэширования
|
| 100 |
+
- Модель: `paraphrase-multilingual-MiniLM-L12-v2`
|
| 101 |
+
- Автоматическое определение устройства (CPU/GPU)
|
| 102 |
+
|
| 103 |
+
**Функции:**
|
| 104 |
+
- `get_bert_model()` - загрузка BERT модели
|
| 105 |
+
- `perform_bert_analysis()` - семантический анализ
|
| 106 |
+
|
| 107 |
+
**Алгоритм BERT анализа:**
|
| 108 |
+
1. Разбиение текстов на предложения (chunks)
|
| 109 |
+
2. Генерация эмбеддингов для всех chunks и ключевых фраз
|
| 110 |
+
3. Расчет косинусного сходства между ключевыми фразами и chunks
|
| 111 |
+
4. Global Score: средний максимальный score по всем ключам
|
| 112 |
+
5. Detailed Analysis: топ-5 наиболее релевантных chunks для каждой фразы
|
| 113 |
+
|
| 114 |
+
#### `models.py` - Модели данных
|
| 115 |
+
|
| 116 |
+
**Pydantic модели:**
|
| 117 |
+
- `AnalysisRequest` - входные данные для анализа
|
| 118 |
+
- `AnalysisResponse` - структура ответа API
|
| 119 |
+
|
| 120 |
+
### 2. Frontend
|
| 121 |
+
|
| 122 |
+
#### `templates/index.html`
|
| 123 |
+
|
| 124 |
+
**Технологии:**
|
| 125 |
+
- Bootstrap 5 для UI
|
| 126 |
+
- Vanilla JavaScript (без фреймворков)
|
| 127 |
+
- AJAX для взаимодействия с API
|
| 128 |
+
|
| 129 |
+
**Компоненты:**
|
| 130 |
+
- Форма ввода данных
|
| 131 |
+
- Табы для отображения результатов
|
| 132 |
+
- Динамическое добавление полей конкурентов
|
| 133 |
+
- Визуализация результатов анализа
|
| 134 |
+
|
| 135 |
+
## Поток данных
|
| 136 |
+
|
| 137 |
+
```
|
| 138 |
+
1. Пользователь вводит данные в форму
|
| 139 |
+
↓
|
| 140 |
+
2. JavaScript собирает данные и отправляет POST /analyze
|
| 141 |
+
↓
|
| 142 |
+
3. FastAPI получает запрос, валидирует через Pydantic
|
| 143 |
+
↓
|
| 144 |
+
4. main.py вызывает функции из logic.py:
|
| 145 |
+
- calculate_ngram_stats()
|
| 146 |
+
- parse_keywords()
|
| 147 |
+
- calculate_bm25_recommendations()
|
| 148 |
+
- perform_bert_analysis()
|
| 149 |
+
↓
|
| 150 |
+
5. Каждая функция использует:
|
| 151 |
+
- spaCy для лингвистики
|
| 152 |
+
- BM25 для частотного анализа
|
| 153 |
+
- BERT для семантики
|
| 154 |
+
↓
|
| 155 |
+
6. Результаты собираются в AnalysisResponse
|
| 156 |
+
↓
|
| 157 |
+
7. JSON ответ отправляется клиенту
|
| 158 |
+
↓
|
| 159 |
+
8. JavaScript рендерит результаты в UI
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
## Управление состоянием
|
| 163 |
+
|
| 164 |
+
### Backend
|
| 165 |
+
|
| 166 |
+
**Глобальные переменные:**
|
| 167 |
+
- `LoadedModels` - кэш загруженных spaCy моделей
|
| 168 |
+
- `BertModel` - кэш BERT модели
|
| 169 |
+
|
| 170 |
+
**Стратегия:**
|
| 171 |
+
- Модели загружаются один раз при первом использовании
|
| 172 |
+
- Предзагрузка spaCy моделей при старте (опционально)
|
| 173 |
+
- BERT модель загружается лениво при первом запросе
|
| 174 |
+
|
| 175 |
+
### Frontend
|
| 176 |
+
|
| 177 |
+
**Состояние:**
|
| 178 |
+
- `currentData` - последние результаты анализа
|
| 179 |
+
- DOM состояние для табов и форм
|
| 180 |
+
|
| 181 |
+
## Производительность
|
| 182 |
+
|
| 183 |
+
### Оптимизации
|
| 184 |
+
|
| 185 |
+
1. **Кэширование моделей:**
|
| 186 |
+
- spaCy модели загружаются один раз
|
| 187 |
+
- BERT модель загружается один раз
|
| 188 |
+
|
| 189 |
+
2. **Ленивая загрузка:**
|
| 190 |
+
- spaCy модели загружаются только для используемых языков
|
| 191 |
+
- BERT модель загружается при первом запросе
|
| 192 |
+
|
| 193 |
+
3. **GPU ускорение:**
|
| 194 |
+
- Автоматическое использование CUDA для BERT
|
| 195 |
+
- Значительное ускорение на GPU
|
| 196 |
+
|
| 197 |
+
4. **Ограничения:**
|
| 198 |
+
- N-граммы ограничены 150 элементами на тип
|
| 199 |
+
- Топ-5 chunks для BERT анализа
|
| 200 |
+
|
| 201 |
+
### Ограничения
|
| 202 |
+
|
| 203 |
+
- Максимальная длина текста для spaCy: 2,000,000 символов
|
| 204 |
+
- Память: зависит от размера моделей и длины текстов
|
| 205 |
+
- Время обработки: зависит от длины текстов и наличия GPU
|
| 206 |
+
|
| 207 |
+
## Масштабируемость
|
| 208 |
+
|
| 209 |
+
### Текущие ограничения
|
| 210 |
+
|
| 211 |
+
- Однопоточная обработка запросов
|
| 212 |
+
- Модели загружаются в память
|
| 213 |
+
- Нет кэширования результатов
|
| 214 |
+
|
| 215 |
+
### Возможные улучшения
|
| 216 |
+
|
| 217 |
+
1. **Асинхронность:**
|
| 218 |
+
- Использование async/await для I/O операций
|
| 219 |
+
- Параллельная обработка конкурентов
|
| 220 |
+
|
| 221 |
+
2. **Кэширование:**
|
| 222 |
+
- Redis для кэширования результатов
|
| 223 |
+
- Кэширование эмбеддингов
|
| 224 |
+
|
| 225 |
+
3. **Микросервисы:**
|
| 226 |
+
- Отдельный сервис для BERT
|
| 227 |
+
- Отдельный сервис для spaCy
|
| 228 |
+
|
| 229 |
+
4. **База данных:**
|
| 230 |
+
- Сохранение истории анализов
|
| 231 |
+
- Статистика использования
|
| 232 |
+
|
| 233 |
+
## Безопасность
|
| 234 |
+
|
| 235 |
+
### Текущее состояние
|
| 236 |
+
|
| 237 |
+
- Нет аутентификации
|
| 238 |
+
- Нет ограничений на размер запросов
|
| 239 |
+
- Нет валидации входных данных (кроме Pydantic)
|
| 240 |
+
|
| 241 |
+
### Рекомендации
|
| 242 |
+
|
| 243 |
+
1. **Валидация:**
|
| 244 |
+
- Ограничение размера текстов
|
| 245 |
+
- Санитизация входных данных
|
| 246 |
+
|
| 247 |
+
2. **Аутентификация:**
|
| 248 |
+
- API ключи
|
| 249 |
+
- OAuth 2.0
|
| 250 |
+
|
| 251 |
+
3. **Rate Limiting:**
|
| 252 |
+
- Ограничение количества запросов
|
| 253 |
+
- Защита от DDoS
|
| 254 |
+
|
| 255 |
+
## Зависимости
|
| 256 |
+
|
| 257 |
+
### Критические
|
| 258 |
+
|
| 259 |
+
- `fastapi` - веб-фреймворк
|
| 260 |
+
- `spacy` - NLP библиотека
|
| 261 |
+
- `sentence-transformers` - BERT модели
|
| 262 |
+
- `rank-bm25` - BM25 алгоритм
|
| 263 |
+
- `torch` - глубокое обучение
|
| 264 |
+
|
| 265 |
+
### Вспомогательные
|
| 266 |
+
|
| 267 |
+
- `uvicorn` - ASGI сервер
|
| 268 |
+
- `pydantic` - валидация данных
|
| 269 |
+
- `jinja2` - шаблонизация
|
| 270 |
+
- `numpy` - численные вычисления
|
| 271 |
+
|
| 272 |
+
## Расширяемость
|
| 273 |
+
|
| 274 |
+
### Добавление нового языка
|
| 275 |
+
|
| 276 |
+
1. Установить spaCy модель для языка
|
| 277 |
+
2. Добавить в `MODEL_NAMES` в `logic.py`
|
| 278 |
+
3. Добавить опцию в UI (`templates/index.html`)
|
| 279 |
+
|
| 280 |
+
### Добавление новой модели BERT
|
| 281 |
+
|
| 282 |
+
1. Изменить модель в `get_bert_model()`
|
| 283 |
+
2. Убедиться в совместимости с `sentence-transformers`
|
| 284 |
+
|
| 285 |
+
### Добавление нового типа анализа
|
| 286 |
+
|
| 287 |
+
1. Создать функцию в `logic.py`
|
| 288 |
+
2. Добавить вызов в `analyze_text()` в `main.py`
|
| 289 |
+
3. Добавить поле в `AnalysisResponse`
|
| 290 |
+
4. Обновить UI для отображения результатов
|
docs/DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Руководство для разработчиков
|
| 2 |
+
|
| 3 |
+
## Начало работы
|
| 4 |
+
|
| 5 |
+
### Настройка окружения разработки
|
| 6 |
+
|
| 7 |
+
1. Клонируйте репозиторий
|
| 8 |
+
2. Создайте виртуальное окружение:
|
| 9 |
+
```bash
|
| 10 |
+
python -m venv venv
|
| 11 |
+
venv\Scripts\activate # Windows
|
| 12 |
+
# или
|
| 13 |
+
source venv/bin/activate # Linux/Mac
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
3. Установите зависимости:
|
| 17 |
+
```bash
|
| 18 |
+
pip install -r requirements.txt
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
4. Установите spaCy модели:
|
| 22 |
+
```bash
|
| 23 |
+
python -m spacy download en_core_web_sm
|
| 24 |
+
python -m spacy download ru_core_news_sm
|
| 25 |
+
python -m spacy download de_core_news_sm
|
| 26 |
+
python -m spacy download es_core_news_sm
|
| 27 |
+
python -m spacy download it_core_news_sm
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Запуск в режиме разработки
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
python main.py
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Или с автоматической перезагрузкой:
|
| 37 |
+
```bash
|
| 38 |
+
uvicorn main:app --host 127.0.0.1 --port 8001 --reload
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Структура кода
|
| 42 |
+
|
| 43 |
+
### Стиль кода
|
| 44 |
+
|
| 45 |
+
- Следуйте PEP 8 для Python
|
| 46 |
+
- Используйте type hints где возможно
|
| 47 |
+
- Документируйте функции docstrings
|
| 48 |
+
- Используйте понятные имена переменных
|
| 49 |
+
|
| 50 |
+
### Организация кода
|
| 51 |
+
|
| 52 |
+
**main.py:**
|
| 53 |
+
- Только роутинг и обработка HTTP запросов
|
| 54 |
+
- Минимум бизнес-логики
|
| 55 |
+
- Делегирование в `logic.py`
|
| 56 |
+
|
| 57 |
+
**logic.py:**
|
| 58 |
+
- Вся бизнес-логика
|
| 59 |
+
- Разделение на модули (SPACY, ANALYTICS, BERT)
|
| 60 |
+
- Глобальные переменные для кэширования моделей
|
| 61 |
+
|
| 62 |
+
**models.py:**
|
| 63 |
+
- Только Pydantic модели
|
| 64 |
+
- Валидация данных
|
| 65 |
+
- Документация полей
|
| 66 |
+
|
| 67 |
+
## Тестирование
|
| 68 |
+
|
| 69 |
+
### Ручное тестирование
|
| 70 |
+
|
| 71 |
+
1. Используйте Swagger UI: `http://127.0.0.1:8001/docs`
|
| 72 |
+
2. Тестируйте через веб-интерфейс
|
| 73 |
+
3. Проверяйте различные языки и размеры текстов
|
| 74 |
+
|
| 75 |
+
### Примеры тестовых данных
|
| 76 |
+
|
| 77 |
+
**Русский язык:**
|
| 78 |
+
```json
|
| 79 |
+
{
|
| 80 |
+
"target_text": "Это пример текста для анализа SEO оптимизации.",
|
| 81 |
+
"competitors": ["Конкурентный текст номер один с похожим содержанием.", "Второй конкурентный текст."],
|
| 82 |
+
"keywords": ["SEO анализ", "оптимизация текста"],
|
| 83 |
+
"language": "ru"
|
| 84 |
+
}
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
**Английский язык:**
|
| 88 |
+
```json
|
| 89 |
+
{
|
| 90 |
+
"target_text": "This is an example text for SEO analysis.",
|
| 91 |
+
"competitors": ["Competitor text number one.", "Second competitor text."],
|
| 92 |
+
"keywords": ["SEO analysis", "text optimization"],
|
| 93 |
+
"language": "en"
|
| 94 |
+
}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Отладка
|
| 98 |
+
|
| 99 |
+
### Логирование
|
| 100 |
+
|
| 101 |
+
Добавьте логирование в ключевых местах:
|
| 102 |
+
|
| 103 |
+
```python
|
| 104 |
+
import logging
|
| 105 |
+
|
| 106 |
+
logging.basicConfig(level=logging.INFO)
|
| 107 |
+
logger = logging.getLogger(__name__)
|
| 108 |
+
|
| 109 |
+
logger.info("Loading model...")
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### Проверка моделей
|
| 113 |
+
|
| 114 |
+
Проверьте загрузку моделей:
|
| 115 |
+
```python
|
| 116 |
+
# В Python консоли
|
| 117 |
+
import logic
|
| 118 |
+
logic.load_models()
|
| 119 |
+
print(logic.LoadedModels.keys())
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Проверка BERT
|
| 123 |
+
|
| 124 |
+
```python
|
| 125 |
+
import logic
|
| 126 |
+
model = logic.get_bert_model()
|
| 127 |
+
print(model.device) # Должно показать 'cuda' или 'cpu'
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Производительность
|
| 131 |
+
|
| 132 |
+
### Профилирование
|
| 133 |
+
|
| 134 |
+
Используйте `cProfile` для профилирования:
|
| 135 |
+
```python
|
| 136 |
+
import cProfile
|
| 137 |
+
import pstats
|
| 138 |
+
|
| 139 |
+
profiler = cProfile.Profile()
|
| 140 |
+
profiler.enable()
|
| 141 |
+
|
| 142 |
+
# Ваш код
|
| 143 |
+
|
| 144 |
+
profiler.disable()
|
| 145 |
+
stats = pstats.Stats(profiler)
|
| 146 |
+
stats.sort_stats('cumulative')
|
| 147 |
+
stats.print_stats(10)
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Оптимизация
|
| 151 |
+
|
| 152 |
+
1. **Кэширование:**
|
| 153 |
+
- Модели уже кэшируются
|
| 154 |
+
- Рассмотрите кэширование результатов для одинаковых запросов
|
| 155 |
+
|
| 156 |
+
2. **Параллелизация:**
|
| 157 |
+
- Обработка конкурентов может быть параллельной
|
| 158 |
+
- Используйте `asyncio` или `multiprocessing`
|
| 159 |
+
|
| 160 |
+
3. **Батчинг:**
|
| 161 |
+
- BERT может обрабатывать несколько текстов одновременно
|
| 162 |
+
- Используйте батчи для эмбеддингов
|
| 163 |
+
|
| 164 |
+
## Добавление новых функций
|
| 165 |
+
|
| 166 |
+
### Добавление нового типа анализа
|
| 167 |
+
|
| 168 |
+
1. Создайте функцию в `logic.py`:
|
| 169 |
+
```python
|
| 170 |
+
def my_new_analysis(target_text: str, competitors: List[str], lang: str) -> Dict:
|
| 171 |
+
# Ваша логика
|
| 172 |
+
return {"result": "data"}
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
2. Добавьте вызов в `main.py`:
|
| 176 |
+
```python
|
| 177 |
+
my_result = logic.my_new_analysis(
|
| 178 |
+
request.target_text,
|
| 179 |
+
request.competitors,
|
| 180 |
+
request.language
|
| 181 |
+
)
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
3. Добавьте поле в `AnalysisResponse`:
|
| 185 |
+
```python
|
| 186 |
+
class AnalysisResponse(BaseModel):
|
| 187 |
+
# ... существующие поля
|
| 188 |
+
my_new_analysis: Dict
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
4. Обновите UI в `templates/index.html`
|
| 192 |
+
|
| 193 |
+
### Добавление нового языка
|
| 194 |
+
|
| 195 |
+
1. Установите spaCy модель:
|
| 196 |
+
```bash
|
| 197 |
+
python -m spacy download <lang>_core_news_sm
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
2. Добавьте в `MODEL_NAMES`:
|
| 201 |
+
```python
|
| 202 |
+
MODEL_NAMES = {
|
| 203 |
+
# ... существующие
|
| 204 |
+
"new_lang": "new_lang_core_news_sm"
|
| 205 |
+
}
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
3. Добавьте опцию в UI:
|
| 209 |
+
```html
|
| 210 |
+
<option value="new_lang">🇺🇸 New Language</option>
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
## Работа с зависимостями
|
| 214 |
+
|
| 215 |
+
### Обновление зависимостей
|
| 216 |
+
|
| 217 |
+
1. Обновите версии в `requirements.txt`
|
| 218 |
+
2. Установите:
|
| 219 |
+
```bash
|
| 220 |
+
pip install -r requirements.txt --upgrade
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
3. Протестируйте приложение
|
| 224 |
+
|
| 225 |
+
### Добавление новой зависимости
|
| 226 |
+
|
| 227 |
+
1. Установите пакет:
|
| 228 |
+
```bash
|
| 229 |
+
pip install new-package
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
2. Добавьте в `requirements.txt`:
|
| 233 |
+
```bash
|
| 234 |
+
pip freeze > requirements.txt
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
3. Или добавьте вручную:
|
| 238 |
+
```
|
| 239 |
+
new-package==1.0.0
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
## Git workflow
|
| 243 |
+
|
| 244 |
+
### Коммиты
|
| 245 |
+
|
| 246 |
+
Используйте понятные сообщения коммитов:
|
| 247 |
+
```
|
| 248 |
+
feat: добавлен анализ тональности
|
| 249 |
+
fix: исправлена ошибка в BM25 расчетах
|
| 250 |
+
docs: обновлена документация API
|
| 251 |
+
refactor: рефакторинг функции analyze_text
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
### Ветки
|
| 255 |
+
|
| 256 |
+
- `main` - стабильная версия
|
| 257 |
+
- `develop` - разработка
|
| 258 |
+
- `feature/название` - новая функция
|
| 259 |
+
- `fix/название` - исправление бага
|
| 260 |
+
|
| 261 |
+
## Развертывание
|
| 262 |
+
|
| 263 |
+
### Production настройки
|
| 264 |
+
|
| 265 |
+
1. Отключите debug режим:
|
| 266 |
+
```python
|
| 267 |
+
app = FastAPI(title="SEO AI Editor", debug=False)
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
2. Используйте production сервер:
|
| 271 |
+
```bash
|
| 272 |
+
uvicorn main:app --host 0.0.0.0 --port 8001 --workers 4
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
3. Настройте переменные окружения:
|
| 276 |
+
```python
|
| 277 |
+
import os
|
| 278 |
+
DEBUG = os.getenv("DEBUG", "False") == "True"
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
### Docker (опционально)
|
| 282 |
+
|
| 283 |
+
Создайте `Dockerfile`:
|
| 284 |
+
```dockerfile
|
| 285 |
+
FROM python:3.10-slim
|
| 286 |
+
|
| 287 |
+
WORKDIR /app
|
| 288 |
+
|
| 289 |
+
COPY requirements.txt .
|
| 290 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 291 |
+
|
| 292 |
+
COPY . .
|
| 293 |
+
|
| 294 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
## Известные проблемы
|
| 298 |
+
|
| 299 |
+
### Память
|
| 300 |
+
|
| 301 |
+
- Большие тексты могут потреблять много памяти
|
| 302 |
+
- BERT модель занимает ~400MB RAM
|
| 303 |
+
- spaCy модели занимают ~50-100MB каждая
|
| 304 |
+
|
| 305 |
+
**Решение:** Ограничьте размер входных текстов или используйте потоковую обработку
|
| 306 |
+
|
| 307 |
+
### Производительность
|
| 308 |
+
|
| 309 |
+
- Первый запрос медленнее (загрузка BERT)
|
| 310 |
+
- Длинные тексты обрабатываются дольше
|
| 311 |
+
|
| 312 |
+
**Решение:** Предзагрузка BERT модели, оптимизация алгоритмов
|
| 313 |
+
|
| 314 |
+
### Языковые модели
|
| 315 |
+
|
| 316 |
+
- Некоторые языки могут иметь ограниченную поддержку
|
| 317 |
+
- Качество анализа зависит от качества моделей
|
| 318 |
+
|
| 319 |
+
**Решение:** Используйте более качественные модели или обучите свои
|
| 320 |
+
|
| 321 |
+
## Полезные ресурсы
|
| 322 |
+
|
| 323 |
+
- [FastAPI документация](https://fastapi.tiangolo.com/)
|
| 324 |
+
- [spaCy документация](https://spacy.io/usage)
|
| 325 |
+
- [Sentence Transformers](https://www.sbert.net/)
|
| 326 |
+
- [BM25 алгоритм](https://en.wikipedia.org/wiki/Okapi_BM25)
|
| 327 |
+
|
| 328 |
+
## Контакты и поддержка
|
| 329 |
+
|
| 330 |
+
Для вопросов и предложений:
|
| 331 |
+
- Создавайте issues в репозитории
|
| 332 |
+
- Предлагайте улучшения через pull requests
|
| 333 |
+
- Документируйте найденные баги
|
logic.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import spacy
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
import numpy as np
|
| 5 |
+
from rank_bm25 import BM25Okapi
|
| 6 |
+
|
| 7 |
+
# Новые импорты для BERT
|
| 8 |
+
import torch
|
| 9 |
+
from sentence_transformers import SentenceTransformer, util
|
| 10 |
+
|
| 11 |
+
# --- Глобальные переменные ---
|
| 12 |
+
LoadedModels = {} # spaCy модели
|
| 13 |
+
BertModel = None # BERT модель (одна на все языки)
|
| 14 |
+
|
| 15 |
+
MODEL_NAMES = {
|
| 16 |
+
"en": "en_core_web_sm",
|
| 17 |
+
"ru": "ru_core_news_sm",
|
| 18 |
+
"de": "de_core_news_sm",
|
| 19 |
+
"es": "es_core_news_sm",
|
| 20 |
+
"it": "it_core_news_sm"
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
# --- SPACY (Лингвистика) ---
|
| 24 |
+
|
| 25 |
+
def load_model_if_missing(lang: str):
|
| 26 |
+
if lang in LoadedModels: return
|
| 27 |
+
model_name = MODEL_NAMES.get(lang)
|
| 28 |
+
if not model_name: return
|
| 29 |
+
|
| 30 |
+
print(f"⏳ Loading spaCy model for {lang}...")
|
| 31 |
+
try:
|
| 32 |
+
LoadedModels[lang] = spacy.load(model_name)
|
| 33 |
+
print(f"✅ Loaded spaCy: {lang}")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"❌ Failed to load spaCy {lang}: {e}")
|
| 36 |
+
|
| 37 |
+
def load_models():
|
| 38 |
+
"""
|
| 39 |
+
Функция для предзагрузки всех моделей при старте (вызывается из main.py).
|
| 40 |
+
"""
|
| 41 |
+
print("🚀 Pre-loading all spaCy models...")
|
| 42 |
+
for lang in MODEL_NAMES.keys():
|
| 43 |
+
load_model_if_missing(lang)
|
| 44 |
+
|
| 45 |
+
def get_doc(text: str, lang: str):
|
| 46 |
+
load_model_if_missing(lang)
|
| 47 |
+
nlp = LoadedModels.get(lang)
|
| 48 |
+
if not nlp:
|
| 49 |
+
load_model_if_missing("en")
|
| 50 |
+
nlp = LoadedModels.get("en")
|
| 51 |
+
|
| 52 |
+
if not nlp: raise RuntimeError("No NLP models loaded.")
|
| 53 |
+
nlp.max_length = 2000000
|
| 54 |
+
return nlp(text.lower())
|
| 55 |
+
|
| 56 |
+
# --- НОВАЯ ФУНКЦИЯ ФИЛЬТРАЦИИ ---
|
| 57 |
+
def is_valid_token(t):
|
| 58 |
+
"""
|
| 59 |
+
Проверяет, является ли токен полезным словом.
|
| 60 |
+
Исправленная версия: не удаляет слова из букв, даже если AI пометил их как символы.
|
| 61 |
+
"""
|
| 62 |
+
# 1. Базовые проверки spaCy (Стоп-слова, пунктуация, пробелы)
|
| 63 |
+
if t.is_stop or t.is_punct or t.is_space:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
# 2. Числа (удаляем "18", "2023", "5")
|
| 67 |
+
if t.is_digit or t.like_num:
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
# 3. СИМВОЛЫ (ИСПРАВЛЕНИЕ)
|
| 71 |
+
# Если spaCy говорит, что это символ (SYM), мы верим, ТОЛЬКО если это не буквы.
|
| 72 |
+
# Это спасет слова типа "cross", "apk", "bet", которые могут быть ложно помечены.
|
| 73 |
+
if t.pos_ == "SYM" and not t.text.isalpha():
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
# 4. Дополнительная страховка (явный мусор)
|
| 77 |
+
garbage_chars = {'|', '+', '-', '—', '–', '>', '<', '=', '/', '\\', '★', '▶', '●', '•', '€', '$', '£'}
|
| 78 |
+
if t.text.strip() in garbage_chars:
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
# 5. Длина: Удаляем одиночные буквы, которые не являются словами
|
| 82 |
+
# (опционально, но помогает чистить мусор типа "v", "s" если они не стоп-слова)
|
| 83 |
+
if len(t.text) == 1 and not t.text.isalpha():
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
return True
|
| 87 |
+
|
| 88 |
+
def get_lemmas_flat(text: str, lang: str) -> List[str]:
|
| 89 |
+
"""
|
| 90 |
+
Возвращает плоский список лемм для всего текста (нужен для BM25).
|
| 91 |
+
"""
|
| 92 |
+
if not text: return []
|
| 93 |
+
doc = get_doc(text, lang)
|
| 94 |
+
# Используем нашу новую функцию фильтрации
|
| 95 |
+
return [t.lemma_ for t in doc if is_valid_token(t)]
|
| 96 |
+
|
| 97 |
+
def generate_ngrams_safe(text: str, lang: str, n: int) -> List[str]:
|
| 98 |
+
"""
|
| 99 |
+
Генерирует n-граммы.
|
| 100 |
+
ИЗМЕНЕНИЕ: Оставляет стоп-слова внутри фраз, чтобы сохранить читаемость (gioco del pollo),
|
| 101 |
+
но фильтрует n-граммы, состоящие ТОЛЬКО из стоп-слов.
|
| 102 |
+
"""
|
| 103 |
+
if not text: return []
|
| 104 |
+
doc = get_doc(text, lang)
|
| 105 |
+
all_ngrams = []
|
| 106 |
+
|
| 107 |
+
for sent in doc.sents:
|
| 108 |
+
# 1. Собираем токены предложения.
|
| 109 |
+
# Мы НЕ удаляем стоп-слова сразу, чтобы не рвать связность фразы.
|
| 110 |
+
# Но мы все еще чистим пунктуацию и явный мусор.
|
| 111 |
+
|
| 112 |
+
sent_tokens = []
|
| 113 |
+
for t in sent:
|
| 114 |
+
# Пропускаем пунктуацию, пробелы и символы
|
| 115 |
+
if t.is_punct or t.is_space or t.pos_ == "SYM":
|
| 116 |
+
continue
|
| 117 |
+
# Пропускаем явный мусор из нашего списка
|
| 118 |
+
garbage_chars = {'|', '+', '-', '—', '–', '>', '<', '=', '/', '\\', '★', '▶', '●', '•', '€', '$', '£'}
|
| 119 |
+
if t.text.strip() in garbage_chars:
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
# Сохраняем токен: (Лемма, Является_ли_стоп_словом)
|
| 123 |
+
sent_tokens.append({
|
| 124 |
+
"lemma": t.lemma_,
|
| 125 |
+
"is_stop": t.is_stop
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
# 2. Генерируем N-граммы из очищенного списка
|
| 129 |
+
if len(sent_tokens) >= n:
|
| 130 |
+
# Скользящее окно
|
| 131 |
+
for i in range(len(sent_tokens) - n + 1):
|
| 132 |
+
window = sent_tokens[i : i+n]
|
| 133 |
+
|
| 134 |
+
# 3. ФИЛЬТР: Если ВСЕ слова в N-грамме - стоп-слова, пропускаем её.
|
| 135 |
+
# Пример: "e la" (биграмма из стоп-слов) -> мусор.
|
| 136 |
+
# Пример: "gioco del" (сущ + стоп) -> полезно.
|
| 137 |
+
if all(t["is_stop"] for t in window):
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
# Склеиваем леммы
|
| 141 |
+
ngram_str = " ".join([t["lemma"] for t in window])
|
| 142 |
+
all_ngrams.append(ngram_str)
|
| 143 |
+
|
| 144 |
+
return all_ngrams
|
| 145 |
+
|
| 146 |
+
# --- ANALYTICS (N-grams & BM25) ---
|
| 147 |
+
|
| 148 |
+
def calculate_ngram_stats(target_text: str, competitor_texts: List[str], lang: str) -> Dict:
|
| 149 |
+
stats = {}
|
| 150 |
+
|
| 151 |
+
for n in range(1, 5):
|
| 152 |
+
key = {1: "unigrams", 2: "bigrams", 3: "trigrams", 4: "quadgrams"}[n]
|
| 153 |
+
|
| 154 |
+
target_ngrams = generate_ngrams_safe(target_text, lang, n)
|
| 155 |
+
target_counts = Counter(target_ngrams)
|
| 156 |
+
|
| 157 |
+
comp_counts_total = Counter()
|
| 158 |
+
for t in competitor_texts:
|
| 159 |
+
c_ngrams = generate_ngrams_safe(t, lang, n)
|
| 160 |
+
comp_counts_total.update(c_ngrams)
|
| 161 |
+
|
| 162 |
+
all_unique = set(target_counts.keys()) | set(comp_counts_total.keys())
|
| 163 |
+
ngram_data = []
|
| 164 |
+
num_competitors = max(len(competitor_texts), 1)
|
| 165 |
+
|
| 166 |
+
for ngram in all_unique:
|
| 167 |
+
cnt_target = target_counts.get(ngram, 0)
|
| 168 |
+
avg_comp = round(comp_counts_total.get(ngram, 0) / num_competitors, 1)
|
| 169 |
+
|
| 170 |
+
# Фильтр мусора: если слово встречается крайне редко везде (<0.5 в среднем), не показываем.
|
| 171 |
+
# Но если у нас оно есть (cnt_target > 0) - показываем всегда.
|
| 172 |
+
if cnt_target > 0 or avg_comp >= 0.5:
|
| 173 |
+
ngram_data.append({
|
| 174 |
+
"ngram": ngram,
|
| 175 |
+
"target_count": cnt_target,
|
| 176 |
+
"competitor_avg": avg_comp
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
# --- СОРТИРОВКА (ГЛАВНОЕ ИЗМЕНЕНИЕ) ---
|
| 180 |
+
# Сортируем по "Важности". Важность = Максимум из (частота у нас, частота у них).
|
| 181 |
+
# Это значит:
|
| 182 |
+
# 1. Если у нас слово 10 раз -> оно наверху.
|
| 183 |
+
# 2. Если у нас 0, а у них 10 раз -> оно ТОЖЕ наверху.
|
| 184 |
+
ngram_data.sort(key=lambda x: max(x["target_count"], x["competitor_avg"]), reverse=True)
|
| 185 |
+
|
| 186 |
+
stats[key] = ngram_data[:150]
|
| 187 |
+
|
| 188 |
+
return stats
|
| 189 |
+
|
| 190 |
+
def parse_keywords(raw_phrases: List[str], lang: str):
|
| 191 |
+
key_phrases = []
|
| 192 |
+
keywords = set()
|
| 193 |
+
for phrase in raw_phrases:
|
| 194 |
+
if not phrase.strip(): continue
|
| 195 |
+
lemmas = get_lemmas_flat(phrase, lang)
|
| 196 |
+
if lemmas:
|
| 197 |
+
key_phrases.append(phrase.strip()) # Для BERT храним исходную фразу, а не леммы!
|
| 198 |
+
for w in lemmas: keywords.add(w)
|
| 199 |
+
return list(key_phrases), list(keywords)
|
| 200 |
+
|
| 201 |
+
def calculate_bm25_recommendations(target_text: str, competitor_texts: List[str], raw_keywords: List[str], lang: str):
|
| 202 |
+
"""
|
| 203 |
+
BM25 с полной декомпозицией фраз.
|
| 204 |
+
Если на входе "chicken road casino", мы анализируем:
|
| 205 |
+
1. chicken, road, casino (Unigrams)
|
| 206 |
+
2. chicken road, road casino (Bigrams)
|
| 207 |
+
3. chicken road casino (Trigram)
|
| 208 |
+
"""
|
| 209 |
+
if not target_text or not raw_keywords:
|
| 210 |
+
return []
|
| 211 |
+
|
| 212 |
+
recommendations = []
|
| 213 |
+
|
| 214 |
+
# 1. СБОР ВСЕХ ВОЗМОЖНЫХ КОМБИНАЦИЙ ИЗ КЛЮЧЕВЫХ ФРАЗ
|
| 215 |
+
analyzed_keys = []
|
| 216 |
+
|
| 217 |
+
# Используем set для отслеживания дубликатов на лету
|
| 218 |
+
seen_terms = set()
|
| 219 |
+
|
| 220 |
+
for phrase in raw_keywords:
|
| 221 |
+
if not phrase.strip(): continue
|
| 222 |
+
|
| 223 |
+
# Получаем токены (уже в нижнем регистре, без лемматизации, как мы исправили ранее)
|
| 224 |
+
tokens = get_lemmas_flat(phrase, lang)
|
| 225 |
+
if not tokens: continue
|
| 226 |
+
|
| 227 |
+
# Генерируем все под-н-граммы длиной от 1 до 3
|
| 228 |
+
# Если фраза длинная (5 слов), мы всё равно разобьем её на куски по 1, 2, 3 слова.
|
| 229 |
+
max_n = min(len(tokens), 3) # Анализируем не более чем триграммы
|
| 230 |
+
|
| 231 |
+
for n in range(1, max_n + 1):
|
| 232 |
+
# Скользящее окно по токенам фразы
|
| 233 |
+
for i in range(len(tokens) - n + 1):
|
| 234 |
+
window = tokens[i : i+n]
|
| 235 |
+
term = " ".join(window)
|
| 236 |
+
|
| 237 |
+
if term not in seen_terms:
|
| 238 |
+
analyzed_keys.append({
|
| 239 |
+
"n": n,
|
| 240 |
+
"term": term,
|
| 241 |
+
"original": phrase # Просто для справки, откуда пришло
|
| 242 |
+
})
|
| 243 |
+
seen_terms.add(term)
|
| 244 |
+
|
| 245 |
+
# 2. МНОГОУРОВНЕВЫЙ РАСЧЕТ BM25
|
| 246 |
+
for n in range(1, 4):
|
| 247 |
+
# Отбираем ключи текущей длины
|
| 248 |
+
current_n_keys = [k['term'] for k in analyzed_keys if k['n'] == n]
|
| 249 |
+
if not current_n_keys:
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
# Строим корпус из N-грамм (Наш текст + Конкуренты)
|
| 253 |
+
target_ngrams = generate_ngrams_safe(target_text, lang, n)
|
| 254 |
+
comp_ngrams_list = [generate_ngrams_safe(t, lang, n) for t in competitor_texts]
|
| 255 |
+
|
| 256 |
+
corpus = [target_ngrams] + comp_ngrams_list
|
| 257 |
+
|
| 258 |
+
# Обучаем BM25
|
| 259 |
+
bm25 = BM25Okapi(corpus)
|
| 260 |
+
|
| 261 |
+
for term in current_n_keys:
|
| 262 |
+
scores = bm25.get_scores([term])
|
| 263 |
+
score_target = scores[0]
|
| 264 |
+
score_avg_comp = np.mean(scores[1:]) if len(scores) > 1 else 0
|
| 265 |
+
|
| 266 |
+
# --- Динамический порог ---
|
| 267 |
+
if n == 1:
|
| 268 |
+
threshold = 0.5
|
| 269 |
+
elif n == 2:
|
| 270 |
+
threshold = 0.25
|
| 271 |
+
else:
|
| 272 |
+
threshold = 0.15
|
| 273 |
+
|
| 274 |
+
action = "ok"
|
| 275 |
+
count_rec = 0
|
| 276 |
+
|
| 277 |
+
# Логика рекомендаций
|
| 278 |
+
if score_target < score_avg_comp - threshold:
|
| 279 |
+
action = "add"
|
| 280 |
+
factor = 0.5 if n == 1 else 0.4
|
| 281 |
+
count_rec = max(1, int((score_avg_comp - score_target) * factor))
|
| 282 |
+
elif score_target > score_avg_comp + threshold * 2:
|
| 283 |
+
action = "remove"
|
| 284 |
+
factor = 0.5
|
| 285 |
+
count_rec = max(1, int((score_target - score_avg_comp) * factor))
|
| 286 |
+
|
| 287 |
+
recommendations.append({
|
| 288 |
+
"word": term,
|
| 289 |
+
"type": f"{n}-gram",
|
| 290 |
+
"my_score": round(score_target, 2),
|
| 291 |
+
"avg_comp_score": round(score_avg_comp, 2),
|
| 292 |
+
"action": action,
|
| 293 |
+
"count": count_rec
|
| 294 |
+
})
|
| 295 |
+
|
| 296 |
+
# 3. СОРТИРОВКА
|
| 297 |
+
# 1. Сначала действия (ADD/REMOVE)
|
| 298 |
+
# 2. Потом по длине фразы (длинные интереснее: "gioco del pollo" выше чем "pollo")
|
| 299 |
+
# 3. Потом алфавит
|
| 300 |
+
recommendations.sort(key=lambda x: (
|
| 301 |
+
0 if x["action"] != "ok" else 1,
|
| 302 |
+
-len(x["word"].split()),
|
| 303 |
+
x["word"]
|
| 304 |
+
))
|
| 305 |
+
|
| 306 |
+
return recommendations
|
| 307 |
+
|
| 308 |
+
# --- BERT / VECTOR ANALYSIS ---
|
| 309 |
+
|
| 310 |
+
def get_bert_model():
|
| 311 |
+
"""Загружает BERT на GPU, если он доступен"""
|
| 312 |
+
global BertModel
|
| 313 |
+
if BertModel is None:
|
| 314 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 315 |
+
print(f"🚀 Loading BERT model on {device}...")
|
| 316 |
+
# Используем легкую и мощную мультиязычную модель
|
| 317 |
+
BertModel = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2', device=device)
|
| 318 |
+
print("✅ BERT Loaded successfully.")
|
| 319 |
+
return BertModel
|
| 320 |
+
|
| 321 |
+
def perform_bert_analysis(target_text: str, competitor_texts: List[str], key_phrases: List[str], lang: str):
|
| 322 |
+
if not key_phrases:
|
| 323 |
+
return {"detailed": [], "global_scores": []}
|
| 324 |
+
|
| 325 |
+
model = get_bert_model()
|
| 326 |
+
|
| 327 |
+
# 1. Функция-помощник: Получить чанки и их эмбеддинги
|
| 328 |
+
def process_text(text):
|
| 329 |
+
if not text.strip(): return [], None
|
| 330 |
+
doc = get_doc(text, lang)
|
| 331 |
+
# Разбиваем на предложения > 10 символов
|
| 332 |
+
chunks = [sent.text.strip() for sent in doc.sents if len(sent.text.strip()) > 10]
|
| 333 |
+
if not chunks: return [], None
|
| 334 |
+
embeddings = model.encode(chunks, convert_to_tensor=True)
|
| 335 |
+
return chunks, embeddings
|
| 336 |
+
|
| 337 |
+
# 2. Обрабатываем Наш текст
|
| 338 |
+
target_chunks, target_emb = process_text(target_text)
|
| 339 |
+
|
| 340 |
+
# 3. Обрабатываем Конкурентов (сохраняем структуру)
|
| 341 |
+
competitors_data = []
|
| 342 |
+
for idx, comp_text in enumerate(competitor_texts):
|
| 343 |
+
chunks, emb = process_text(comp_text)
|
| 344 |
+
competitors_data.append({
|
| 345 |
+
"id": idx + 1,
|
| 346 |
+
"chunks": chunks,
|
| 347 |
+
"embeddings": emb
|
| 348 |
+
})
|
| 349 |
+
|
| 350 |
+
# Эмбеддинги ключей
|
| 351 |
+
keys_emb = model.encode(key_phrases, convert_to_tensor=True)
|
| 352 |
+
|
| 353 |
+
# --- РАСЧЕТ GLOBAL SCORE ---
|
| 354 |
+
# Global Score - это средний Max Score ��о всем ключевым словам.
|
| 355 |
+
# То есть, насколько хорошо текст покрывает ВСЕ ключи в среднем.
|
| 356 |
+
|
| 357 |
+
global_scores = []
|
| 358 |
+
|
| 359 |
+
# Считаем для нас
|
| 360 |
+
if target_emb is not None:
|
| 361 |
+
# Матрица [Key x Chunk]
|
| 362 |
+
sims = util.cos_sim(keys_emb, target_emb)
|
| 363 |
+
# Берем макс. сходство для каждого ключа (values), потом среднее по всем ключам
|
| 364 |
+
# torch.max возвращает (values, indices)
|
| 365 |
+
max_scores_per_key, _ = torch.max(sims, dim=1)
|
| 366 |
+
avg_relevance = torch.mean(max_scores_per_key).item()
|
| 367 |
+
global_scores.append({"name": "Мой текст", "score": round(avg_relevance, 3), "is_me": True})
|
| 368 |
+
else:
|
| 369 |
+
global_scores.append({"name": "Мой текст", "score": 0, "is_me": True})
|
| 370 |
+
|
| 371 |
+
# Считаем для конкурентов
|
| 372 |
+
for comp in competitors_data:
|
| 373 |
+
if comp["embeddings"] is not None:
|
| 374 |
+
sims = util.cos_sim(keys_emb, comp["embeddings"])
|
| 375 |
+
max_scores_per_key, _ = torch.max(sims, dim=1)
|
| 376 |
+
avg_relevance = torch.mean(max_scores_per_key).item()
|
| 377 |
+
global_scores.append({"name": f"Конкурент #{comp['id']}", "score": round(avg_relevance, 3), "is_me": False})
|
| 378 |
+
else:
|
| 379 |
+
global_scores.append({"name": f"Конкурент #{comp['id']}", "score": 0, "is_me": False})
|
| 380 |
+
|
| 381 |
+
# Сортируем глобальный рейтинг (победитель сверху)
|
| 382 |
+
global_scores.sort(key=lambda x: x["score"], reverse=True)
|
| 383 |
+
|
| 384 |
+
# --- ДЕТАЛЬНЫЙ АНАЛИЗ ПО ФРАЗАМ ---
|
| 385 |
+
detailed_results = []
|
| 386 |
+
|
| 387 |
+
for i, phrase in enumerate(key_phrases):
|
| 388 |
+
# 1. Анализ моего текста
|
| 389 |
+
my_top = []
|
| 390 |
+
my_max = 0
|
| 391 |
+
if target_emb is not None:
|
| 392 |
+
# Считаем снова локально или берем из матрицы (тут проще локально для чистоты кода)
|
| 393 |
+
# scores_target[i] уже посчитано выше в sims, но выше переменная sims переписывалась.
|
| 394 |
+
# Для надежности пересчитаем векторную близость для одной фразы (это мгновенно)
|
| 395 |
+
phrase_emb = keys_emb[i]
|
| 396 |
+
scores = util.cos_sim(phrase_emb, target_emb)[0] # вектор [chunks]
|
| 397 |
+
|
| 398 |
+
k = min(5, len(target_chunks))
|
| 399 |
+
vals, idxs = torch.topk(scores, k)
|
| 400 |
+
my_max = vals[0].item() if k > 0 else 0
|
| 401 |
+
|
| 402 |
+
for rank in range(k):
|
| 403 |
+
my_top.append({
|
| 404 |
+
"text": target_chunks[idxs[rank].item()],
|
| 405 |
+
"score": round(vals[rank].item(), 3)
|
| 406 |
+
})
|
| 407 |
+
|
| 408 |
+
# 2. Анализ конкурентов (Сборная солянка)
|
| 409 |
+
# Собираем все чанки всех конкурентов с их скорами и ID
|
| 410 |
+
all_comp_candidates = []
|
| 411 |
+
|
| 412 |
+
for comp in competitors_data:
|
| 413 |
+
if comp["embeddings"] is not None:
|
| 414 |
+
phrase_emb = keys_emb[i]
|
| 415 |
+
scores = util.cos_sim(phrase_emb, comp["embeddings"])[0]
|
| 416 |
+
|
| 417 |
+
# Берем топ-3 от каждого конкурента, чтобы добавить в общий пул
|
| 418 |
+
k = min(3, len(comp["chunks"]))
|
| 419 |
+
vals, idxs = torch.topk(scores, k)
|
| 420 |
+
|
| 421 |
+
for rank in range(k):
|
| 422 |
+
all_comp_candidates.append({
|
| 423 |
+
"text": comp["chunks"][idxs[rank].item()],
|
| 424 |
+
"score": vals[rank].item(),
|
| 425 |
+
"source": f"Конкурент #{comp['id']}" # <-- АТРИБУЦИЯ
|
| 426 |
+
})
|
| 427 |
+
|
| 428 |
+
# Сортируем общий пул конкурентов и берем ТОП-5 абсолютных лидеров
|
| 429 |
+
all_comp_candidates.sort(key=lambda x: x["score"], reverse=True)
|
| 430 |
+
comp_top_5 = all_comp_candidates[:5]
|
| 431 |
+
|
| 432 |
+
# Округляем скоры для вывода
|
| 433 |
+
for item in comp_top_5:
|
| 434 |
+
item["score"] = round(item["score"], 3)
|
| 435 |
+
|
| 436 |
+
comp_max = comp_top_5[0]["score"] if comp_top_5 else 0
|
| 437 |
+
|
| 438 |
+
# Статус
|
| 439 |
+
status = "ok"
|
| 440 |
+
rec = "Тема раскрыта хорошо."
|
| 441 |
+
if my_max < 0.5:
|
| 442 |
+
status = "bad"
|
| 443 |
+
rec = "Тема не раскрыта."
|
| 444 |
+
elif comp_max > my_max + 0.1:
|
| 445 |
+
status = "warning"
|
| 446 |
+
rec = "Конкуренты раскрыли тему заметно лучше."
|
| 447 |
+
elif my_max >= 0.7:
|
| 448 |
+
status = "good"
|
| 449 |
+
rec = "Отлично."
|
| 450 |
+
|
| 451 |
+
detailed_results.append({
|
| 452 |
+
"phrase": phrase,
|
| 453 |
+
"my_max_score": round(my_max, 2),
|
| 454 |
+
"comp_max_score": round(comp_max, 2),
|
| 455 |
+
"status": status,
|
| 456 |
+
"recommendation": rec,
|
| 457 |
+
"my_top_chunks": my_top,
|
| 458 |
+
"comp_top_chunks": comp_top_5
|
| 459 |
+
})
|
| 460 |
+
|
| 461 |
+
return {
|
| 462 |
+
"global_scores": global_scores,
|
| 463 |
+
"detailed": detailed_results
|
| 464 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# main.py
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.responses import HTMLResponse
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from fastapi.templating import Jinja2Templates
|
| 7 |
+
import uvicorn
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from models import AnalysisRequest, AnalysisResponse
|
| 11 |
+
import logic
|
| 12 |
+
|
| 13 |
+
app = FastAPI(title="SEO AI Editor MVP")
|
| 14 |
+
|
| 15 |
+
# Подключаем папку с шаблонами
|
| 16 |
+
templates = Jinja2Templates(directory="templates")
|
| 17 |
+
|
| 18 |
+
@app.on_event("startup")
|
| 19 |
+
async def startup_event():
|
| 20 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 21 |
+
print(f"🚀 Application starting. ML Device: {device}")
|
| 22 |
+
logic.load_models() # spaCy preload (optional)
|
| 23 |
+
|
| 24 |
+
# --- НОВЫЙ РОУТ ДЛЯ ГЛАВНОЙ СТРАНИЦЫ ---
|
| 25 |
+
@app.get("/", response_class=HTMLResponse)
|
| 26 |
+
async def read_root(request: Request):
|
| 27 |
+
# Рендерим файл index.html
|
| 28 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 29 |
+
|
| 30 |
+
@app.post("/analyze", response_model=AnalysisResponse)
|
| 31 |
+
async def analyze_text(request: AnalysisRequest):
|
| 32 |
+
# Логика та же самая, что и была
|
| 33 |
+
ngram_stats_result = logic.calculate_ngram_stats(
|
| 34 |
+
request.target_text,
|
| 35 |
+
request.competitors,
|
| 36 |
+
request.language
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
key_phrases, key_words_unigrams = logic.parse_keywords(request.keywords, request.language)
|
| 40 |
+
|
| 41 |
+
bm25_recs = logic.calculate_bm25_recommendations(
|
| 42 |
+
request.target_text,
|
| 43 |
+
request.competitors,
|
| 44 |
+
request.keywords, # <-- ИЗМЕНЕНИЕ ЗДЕСЬ (было key_words_unigrams)
|
| 45 |
+
request.language
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
bert_results = logic.perform_bert_analysis(
|
| 49 |
+
request.target_text,
|
| 50 |
+
request.competitors, # <-- ДОБАВИЛИ ЭТОТ АРГУМЕНТ
|
| 51 |
+
key_phrases,
|
| 52 |
+
request.language
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return AnalysisResponse(
|
| 56 |
+
ngram_stats=ngram_stats_result,
|
| 57 |
+
bm25_recommendations=bm25_recs,
|
| 58 |
+
bert_analysis=bert_results
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
uvicorn.run("main:app", host="127.0.0.1", port=8001, reload=True)
|
models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from typing import List, Dict, Optional, Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class CompetitorText(BaseModel):
|
| 7 |
+
id: int
|
| 8 |
+
text: str
|
| 9 |
+
|
| 10 |
+
class AnalysisRequest(BaseModel):
|
| 11 |
+
target_text: str # Текст пользователя
|
| 12 |
+
competitors: List[str] # Список текстов конкурентов
|
| 13 |
+
keywords: List[str] # Список ключевых фраз (сырых)
|
| 14 |
+
language: str = "en" # en, ru, de, es, it
|
| 15 |
+
|
| 16 |
+
class AnalysisResponse(BaseModel):
|
| 17 |
+
ngram_stats: dict # Статистика униграм/биграм
|
| 18 |
+
bm25_recommendations: List[dict] # Рекомендации "добавить/убрать"
|
| 19 |
+
bert_analysis: Dict[str, Any] # Векторный анализ
|
ps.sh
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
$env:temp = "D:\seo_ai_editor\pip_temp"
|
| 2 |
+
$env:tmp = "D:\seo_ai_editor\pip_temp"
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
numpy
|
| 5 |
+
scikit-learn
|
| 6 |
+
rank-bm25
|
| 7 |
+
sentence-transformers
|
| 8 |
+
spacy
|
| 9 |
+
python-multipart
|
| 10 |
+
jinja2
|
templates/index.html
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="ru">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>SEO AI Editor (GPU Powered)</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background-color: #f8f9fa; }
|
| 10 |
+
.editor-box { min-height: 300px; font-family: 'Georgia', serif; font-size: 1.1rem; }
|
| 11 |
+
.stat-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
|
| 12 |
+
.loading-overlay {
|
| 13 |
+
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 14 |
+
background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
|
| 15 |
+
}
|
| 16 |
+
/* Стили для скролла в таблицах */
|
| 17 |
+
.scrollable-table { max-height: 500px; overflow-y: auto; }
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
|
| 22 |
+
<!-- Лоадер -->
|
| 23 |
+
<div id="loader" class="loading-overlay">
|
| 24 |
+
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
|
| 25 |
+
<h3 class="mt-3">AI анализирует текст...</h3>
|
| 26 |
+
<p class="text-muted">Первый запуск BERT может занять пару секунд</p>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<nav class="navbar navbar-dark bg-dark mb-4">
|
| 30 |
+
<div class="container-fluid">
|
| 31 |
+
<span class="navbar-brand mb-0 h1">🚀 SEO AI Editor <small class="text-secondary" style="font-size: 0.6em;">v1.2 BERT+Comparisons</small></span>
|
| 32 |
+
</div>
|
| 33 |
+
</nav>
|
| 34 |
+
|
| 35 |
+
<div class="container-fluid">
|
| 36 |
+
<div class="row">
|
| 37 |
+
<!-- ЛЕВАЯ КОЛОНКА: ВВОД ДАННЫХ -->
|
| 38 |
+
<div class="col-md-5">
|
| 39 |
+
<div class="stat-card">
|
| 40 |
+
<div class="mb-3">
|
| 41 |
+
<label class="form-label fw-bold">Язык анализа</label>
|
| 42 |
+
<select class="form-select" id="languageSelect">
|
| 43 |
+
<option value="ru">🇷🇺 Русский</option>
|
| 44 |
+
<option value="en">🇺🇸 English</option>
|
| 45 |
+
<option value="de">🇩🇪 Deutsch</option>
|
| 46 |
+
<option value="it">🇮🇹 Italiano</option>
|
| 47 |
+
<option value="es">🇪🇸 Español</option>
|
| 48 |
+
</select>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="mb-3">
|
| 52 |
+
<label class="form-label fw-bold">Ваш текст (Target)</label>
|
| 53 |
+
<textarea class="form-control editor-box" id="targetText" placeholder="Пишите текст здесь..."></textarea>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="mb-3">
|
| 57 |
+
<label class="form-label fw-bold">Ключевые фразы</label>
|
| 58 |
+
<small class="text-muted d-block mb-1">Каждая фраза с новой строки</small>
|
| 59 |
+
<textarea class="form-control" id="keywordsInput" rows="5" placeholder="купить слона лучшие цены"></textarea>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="mb-3">
|
| 63 |
+
<label class="form-label fw-bold">Тексты конкурентов</label>
|
| 64 |
+
<div id="competitorsList">
|
| 65 |
+
<!-- Поля добавляются сюда -->
|
| 66 |
+
<textarea class="form-control mb-2" rows="3" placeholder="Текст конкурента 1..."></textarea>
|
| 67 |
+
</div>
|
| 68 |
+
<button class="btn btn-sm btn-outline-secondary mt-1" onclick="addCompetitorField()">+ Добавить конкурента</button>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="d-grid gap-2">
|
| 72 |
+
<button class="btn btn-primary btn-lg" onclick="runAnalysis()">⚡ Анализировать (GPU)</button>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<!-- ПРАВАЯ КОЛОНКА: РЕЗУЛЬТАТЫ -->
|
| 78 |
+
<div class="col-md-7">
|
| 79 |
+
|
| 80 |
+
<!-- Табы -->
|
| 81 |
+
<ul class="nav nav-tabs mb-3" id="resultsTab" role="tablist">
|
| 82 |
+
<li class="nav-item">
|
| 83 |
+
<button class="nav-link active" id="bert-tab" data-bs-toggle="tab" data-bs-target="#bert" type="button">🧠 BERT Семантика</button>
|
| 84 |
+
</li>
|
| 85 |
+
<li class="nav-item">
|
| 86 |
+
<button class="nav-link" id="bm25-tab" data-bs-toggle="tab" data-bs-target="#bm25" type="button">📊 BM25 Баланс</button>
|
| 87 |
+
</li>
|
| 88 |
+
<li class="nav-item">
|
| 89 |
+
<button class="nav-link" id="ngrams-tab" data-bs-toggle="tab" data-bs-target="#ngrams" type="button">🔠 N-граммы</button>
|
| 90 |
+
</li>
|
| 91 |
+
</ul>
|
| 92 |
+
|
| 93 |
+
<div class="tab-content" id="resultsContent">
|
| 94 |
+
|
| 95 |
+
<!-- BERT TAB (НОВЫЙ) -->
|
| 96 |
+
<div class="tab-pane fade show active" id="bert" role="tabpanel">
|
| 97 |
+
<div class="stat-card">
|
| 98 |
+
<h5 class="card-title">Семантический анализ (BERT)</h5>
|
| 99 |
+
<p class="text-muted small">Сравнение глубины раскрытия темы у вас и у конкурентов.</p>
|
| 100 |
+
<div id="bertResultsContainer">
|
| 101 |
+
<div class="text-center text-muted py-5">Нажмите "Анализировать", чтобы увидеть результаты.</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<!-- BM25 TAB -->
|
| 107 |
+
<div class="tab-pane fade" id="bm25" role="tabpanel">
|
| 108 |
+
<div class="stat-card">
|
| 109 |
+
<h5 class="card-title">Частотный баланс (BM25)</h5>
|
| 110 |
+
<p class="text-muted small">Рекомендации по добавлению/удалению слов.</p>
|
| 111 |
+
<div class="scrollable-table">
|
| 112 |
+
<table class="table table-hover">
|
| 113 |
+
<thead>
|
| 114 |
+
<tr>
|
| 115 |
+
<th>Слово</th>
|
| 116 |
+
<th>Действие</th>
|
| 117 |
+
<th>Кол-во</th>
|
| 118 |
+
<th>Мой Score</th>
|
| 119 |
+
<th>Avg Comp</th>
|
| 120 |
+
</tr>
|
| 121 |
+
</thead>
|
| 122 |
+
<tbody id="bm25TableBody">
|
| 123 |
+
</tbody>
|
| 124 |
+
</table>
|
| 125 |
+
</div>
|
| 126 |
+
<div id="bm25EmptyMsg" class="text-center text-muted py-3">Нет критических рекомендаций.</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<!-- N-GRAMS TAB -->
|
| 131 |
+
<div class="tab-pane fade" id="ngrams" role="tabpanel">
|
| 132 |
+
<div class="stat-card">
|
| 133 |
+
<h5 class="card-title">Статистика слов</h5>
|
| 134 |
+
<div class="mb-3">
|
| 135 |
+
<div class="btn-group" role="group">
|
| 136 |
+
<button type="button" class="btn btn-outline-primary active" onclick="showNgramTable('unigrams')">1 слово</button>
|
| 137 |
+
<button type="button" class="btn btn-outline-primary" onclick="showNgramTable('bigrams')">2 слова</button>
|
| 138 |
+
<button type="button" class="btn btn-outline-primary" onclick="showNgramTable('trigrams')">3 слова</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="scrollable-table">
|
| 142 |
+
<table class="table table-sm">
|
| 143 |
+
<thead>
|
| 144 |
+
<tr>
|
| 145 |
+
<th>Фраза</th>
|
| 146 |
+
<th>У меня</th>
|
| 147 |
+
<th>У конкурентов (avg)</th>
|
| 148 |
+
</tr>
|
| 149 |
+
</thead>
|
| 150 |
+
<tbody id="ngramTableBody"></tbody>
|
| 151 |
+
</table>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 162 |
+
<script>
|
| 163 |
+
let currentData = null;
|
| 164 |
+
|
| 165 |
+
// --- ФУНКЦИИ ИНТЕРФЕЙСА ---
|
| 166 |
+
|
| 167 |
+
function addCompetitorField() {
|
| 168 |
+
const div = document.createElement('div');
|
| 169 |
+
div.innerHTML = '<textarea class="form-control mb-2 competitor-input" rows="3" placeholder="Ещё конкурент..."></textarea>';
|
| 170 |
+
document.getElementById('competitorsList').appendChild(div);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function runAnalysis() {
|
| 174 |
+
// Сбор данных
|
| 175 |
+
const targetText = document.getElementById('targetText').value;
|
| 176 |
+
const lang = document.getElementById('languageSelect').value;
|
| 177 |
+
const keywordsRaw = document.getElementById('keywordsInput').value.split('\n').filter(k => k.trim() !== '');
|
| 178 |
+
|
| 179 |
+
// Сбор конкурентов
|
| 180 |
+
const compInputs = document.querySelectorAll('#competitorsList textarea');
|
| 181 |
+
const competitors = [];
|
| 182 |
+
compInputs.forEach(input => {
|
| 183 |
+
if(input.value.trim() !== '') competitors.push(input.value);
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
if(!targetText) { alert("Введите ваш текст!"); return; }
|
| 187 |
+
|
| 188 |
+
// UI Loading
|
| 189 |
+
document.getElementById('loader').style.display = 'block';
|
| 190 |
+
|
| 191 |
+
const payload = {
|
| 192 |
+
target_text: targetText,
|
| 193 |
+
competitors: competitors,
|
| 194 |
+
keywords: keywordsRaw,
|
| 195 |
+
language: lang
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
const response = await fetch('/analyze', {
|
| 200 |
+
method: 'POST',
|
| 201 |
+
headers: { 'Content-Type': 'application/json' },
|
| 202 |
+
body: JSON.stringify(payload)
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
|
| 206 |
+
|
| 207 |
+
const data = await response.json();
|
| 208 |
+
currentData = data;
|
| 209 |
+
renderResults(data);
|
| 210 |
+
|
| 211 |
+
} catch (error) {
|
| 212 |
+
alert("Ошибка: " + error.message);
|
| 213 |
+
console.error(error);
|
| 214 |
+
} finally {
|
| 215 |
+
document.getElementById('loader').style.display = 'none';
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function renderResults(data) {
|
| 220 |
+
// 1. BERT Render (ИСПРАВЛЕННЫЙ ПОД НОВУЮ СТРУКТУРУ)
|
| 221 |
+
const bertContainer = document.getElementById('bertResultsContainer');
|
| 222 |
+
bertContainer.innerHTML = '';
|
| 223 |
+
|
| 224 |
+
// Получаем объект данных. Теперь это объект, а не массив!
|
| 225 |
+
const bertData = data.bert_analysis;
|
| 226 |
+
|
| 227 |
+
// Проверяем, есть ли поле detailed (список фраз)
|
| 228 |
+
if (!bertData || !bertData.detailed || bertData.detailed.length === 0) {
|
| 229 |
+
bertContainer.innerHTML = '<div class="alert alert-warning">Добавьте ключевые фразы для анализа.</div>';
|
| 230 |
+
} else {
|
| 231 |
+
|
| 232 |
+
// А. Рендерим ГЛОБАЛЬНЫЙ СЧЕТ (Global Score)
|
| 233 |
+
if (bertData.global_scores && bertData.global_scores.length > 0) {
|
| 234 |
+
let globalHtml = '<div class="card mb-4 border-primary"><div class="card-body">';
|
| 235 |
+
globalHtml += '<h6 class="card-title text-primary fw-bold mb-3">🏆 Общий рейтинг релевантности (Global Score)</h6>';
|
| 236 |
+
|
| 237 |
+
bertData.global_scores.forEach(gs => {
|
| 238 |
+
const scorePct = Math.round(gs.score * 100);
|
| 239 |
+
const isMe = gs.is_me;
|
| 240 |
+
const barColor = isMe ? 'bg-primary' : 'bg-secondary';
|
| 241 |
+
const rowBg = isMe ? 'bg-light border-start border-primary border-3' : '';
|
| 242 |
+
const nameLabel = isMe ? `<strong>${gs.name} (Вы)</strong>` : gs.name;
|
| 243 |
+
|
| 244 |
+
globalHtml += `
|
| 245 |
+
<div class="d-flex align-items-center mb-2 p-2 rounded ${rowBg}">
|
| 246 |
+
<div style="width: 150px;">${nameLabel}</div>
|
| 247 |
+
<div class="flex-grow-1 mx-3">
|
| 248 |
+
<div class="progress" style="height: 20px;">
|
| 249 |
+
<div class="progress-bar ${barColor}" role="progressbar" style="width: ${scorePct}%">${scorePct}%</div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
<div class="fw-bold">${gs.score}</div>
|
| 253 |
+
</div>`;
|
| 254 |
+
});
|
| 255 |
+
globalHtml += '</div></div>';
|
| 256 |
+
bertContainer.insertAdjacentHTML('beforeend', globalHtml);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Б. Рендерим ДЕТАЛИЗАЦИЮ (Аккордеоны)
|
| 260 |
+
// Итерируемся именно по .detailed, так как это массив
|
| 261 |
+
bertData.detailed.forEach((item, index) => {
|
| 262 |
+
let badgeClass = 'bg-secondary';
|
| 263 |
+
if(item.status === 'good') badgeClass = 'bg-success';
|
| 264 |
+
if(item.status === 'warning') badgeClass = 'bg-warning text-dark';
|
| 265 |
+
if(item.status === 'bad') badgeClass = 'bg-danger';
|
| 266 |
+
|
| 267 |
+
const collapseId = `collapseBert${index}`;
|
| 268 |
+
|
| 269 |
+
// Мои чанки
|
| 270 |
+
const myChunksHtml = item.my_top_chunks.map(c =>
|
| 271 |
+
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom">
|
| 272 |
+
<div class="small me-2">"${c.text}"</div>
|
| 273 |
+
<span class="badge bg-primary rounded-pill opacity-75">${c.score}</span>
|
| 274 |
+
</li>`
|
| 275 |
+
).join('');
|
| 276 |
+
|
| 277 |
+
// Чанки конкурентов (С АТРИБУЦИЕЙ ИСТОЧНИКА)
|
| 278 |
+
const compChunksHtml = (item.comp_top_chunks && item.comp_top_chunks.length > 0)
|
| 279 |
+
? item.comp_top_chunks.map(c =>
|
| 280 |
+
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom list-group-item-light">
|
| 281 |
+
<div class="me-2">
|
| 282 |
+
<span class="badge bg-secondary mb-1" style="font-size: 0.7em;">${c.source}</span>
|
| 283 |
+
<div class="small text-muted">"${c.text}"</div>
|
| 284 |
+
</div>
|
| 285 |
+
<span class="badge bg-dark rounded-pill opacity-50">${c.score}</span>
|
| 286 |
+
</li>`
|
| 287 |
+
).join('')
|
| 288 |
+
: '<li class="list-group-item text-muted small border-0">Нет данных</li>';
|
| 289 |
+
|
| 290 |
+
const html = `
|
| 291 |
+
<div class="card mb-3 border">
|
| 292 |
+
<div class="card-header bg-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#${collapseId}">
|
| 293 |
+
<div>
|
| 294 |
+
<div class="fw-bold text-dark">${item.phrase}</div>
|
| 295 |
+
<div class="text-muted small">
|
| 296 |
+
My: <b>${item.my_max_score}</b> vs Best Comp: <b>${item.comp_max_score}</b>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
<span class="badge ${badgeClass}">${item.status.toUpperCase()}</span>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div id="${collapseId}" class="collapse">
|
| 303 |
+
<div class="card-body bg-light">
|
| 304 |
+
<p class="small mb-3"><strong>Совет:</strong> ${item.recommendation}</p>
|
| 305 |
+
<div class="row">
|
| 306 |
+
<div class="col-md-6">
|
| 307 |
+
<h6 class="small fw-bold text-primary">Мой текст</h6>
|
| 308 |
+
<ul class="list-group shadow-sm mb-3">${myChunksHtml || '<li class="list-group-item small">Нет вхождений</li>'}</ul>
|
| 309 |
+
</div>
|
| 310 |
+
<div class="col-md-6 border-start">
|
| 311 |
+
<h6 class="small fw-bold text-secondary">Лучшее у конкурентов</h6>
|
| 312 |
+
<ul class="list-group shadow-sm">${compChunksHtml}</ul>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>`;
|
| 318 |
+
bertContainer.insertAdjacentHTML('beforeend', html);
|
| 319 |
+
});
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// 2. BM25 Render (ОБНОВЛЕННЫЙ v2 - Полный список)
|
| 323 |
+
const bm25Body = document.getElementById('bm25TableBody');
|
| 324 |
+
bm25Body.innerHTML = '';
|
| 325 |
+
const bm25Msg = document.getElementById('bm25EmptyMsg');
|
| 326 |
+
|
| 327 |
+
// Теперь мы ожидаем, что список не пуст, если были введены ключи
|
| 328 |
+
if (data.bm25_recommendations && data.bm25_recommendations.length > 0) {
|
| 329 |
+
bm25Msg.style.display = 'none';
|
| 330 |
+
|
| 331 |
+
data.bm25_recommendations.forEach(item => {
|
| 332 |
+
let colorClass = '';
|
| 333 |
+
let actionText = '';
|
| 334 |
+
let countText = '';
|
| 335 |
+
let rowBg = '';
|
| 336 |
+
|
| 337 |
+
// Определяем стили в зависимости от действия
|
| 338 |
+
if (item.action === 'add') {
|
| 339 |
+
colorClass = 'text-success';
|
| 340 |
+
actionText = 'ДОБАВИТЬ';
|
| 341 |
+
countText = `+${item.count}`;
|
| 342 |
+
rowBg = 'table-success'; // Легкая зеленая подсветка всей строки (Bootstrap класс)
|
| 343 |
+
// Но лучше не красить всю строку, чтобы не рябило, покрасим только текст действия
|
| 344 |
+
rowBg = '';
|
| 345 |
+
} else if (item.action === 'remove') {
|
| 346 |
+
colorClass = 'text-danger';
|
| 347 |
+
actionText = 'УБРАТЬ';
|
| 348 |
+
countText = `-${item.count}`;
|
| 349 |
+
} else {
|
| 350 |
+
colorClass = 'text-muted'; // Серый цвет
|
| 351 |
+
actionText = 'НОРМА'; // Или OK
|
| 352 |
+
countText = '<span class="text-muted">-</span>';
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// Жирный шрифт для важных действий
|
| 356 |
+
const weight = item.action === 'ok' ? 'fw-normal' : 'fw-bold';
|
| 357 |
+
|
| 358 |
+
const row = `
|
| 359 |
+
<tr>
|
| 360 |
+
<td class="fw-bold text-dark">${item.word}</td>
|
| 361 |
+
<td class="${colorClass} ${weight}">${actionText}</td>
|
| 362 |
+
<td class="${colorClass} ${weight}">${countText}</td>
|
| 363 |
+
<td>${item.my_score}</td>
|
| 364 |
+
<td>${item.avg_comp_score}</td>
|
| 365 |
+
</tr>`;
|
| 366 |
+
bm25Body.insertAdjacentHTML('beforeend', row);
|
| 367 |
+
});
|
| 368 |
+
} else {
|
| 369 |
+
// Если список пуст (например, не ввели ключевые слова)
|
| 370 |
+
bm25Msg.style.display = 'block';
|
| 371 |
+
bm25Msg.textContent = "Введите ключевые фразы для расчета BM25.";
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// 3. N-grams (ОСТАВЛЯЕМ КАК ЕСТЬ)
|
| 375 |
+
showNgramTable('unigrams');
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function showNgramTable(type) {
|
| 379 |
+
if(!currentData) return;
|
| 380 |
+
|
| 381 |
+
// Логика переключения кнопок (visual state)
|
| 382 |
+
document.querySelectorAll('#ngrams .btn').forEach(b => {
|
| 383 |
+
// Простая проверка по тексту кнопки ("1 слово", "2 слова"...)
|
| 384 |
+
if(type === 'unigrams' && b.innerText.includes('1')) b.classList.add('active');
|
| 385 |
+
else if(type === 'bigrams' && b.innerText.includes('2')) b.classList.add('active');
|
| 386 |
+
else if(type === 'trigrams' && b.innerText.includes('3')) b.classList.add('active');
|
| 387 |
+
else b.classList.remove('active');
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
const tbody = document.getElementById('ngramTableBody');
|
| 391 |
+
tbody.innerHTML = '';
|
| 392 |
+
|
| 393 |
+
const list = currentData.ngram_stats[type];
|
| 394 |
+
|
| 395 |
+
if(list && list.length > 0) {
|
| 396 |
+
list.forEach(item => {
|
| 397 |
+
let rowClass = "";
|
| 398 |
+
let countClass = "";
|
| 399 |
+
let icon = "";
|
| 400 |
+
|
| 401 |
+
// Логика подсветки
|
| 402 |
+
if (item.target_count === 0 && item.competitor_avg > 0) {
|
| 403 |
+
// У НИХ ЕСТЬ, У НАС НЕТ -> ВАЖНО!
|
| 404 |
+
rowClass = "table-warning"; // Желтоватый фон (Bootstrap)
|
| 405 |
+
countClass = "text-danger fw-bold";
|
| 406 |
+
icon = "⚠️"; // Предупреждение
|
| 407 |
+
} else if (item.target_count > 0 && item.competitor_avg === 0) {
|
| 408 |
+
// У нас есть, у них нет (наша уникальность)
|
| 409 |
+
// Можно выделить зеленым, но не обязательно, это не проблема.
|
| 410 |
+
countClass = "text-success";
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
const row = `
|
| 414 |
+
<tr class="${rowClass}">
|
| 415 |
+
<td>${item.ngram} ${icon}</td>
|
| 416 |
+
<td class="fw-bold ${countClass}">${item.target_count}</td>
|
| 417 |
+
<td>${item.competitor_avg}</td>
|
| 418 |
+
</tr>`;
|
| 419 |
+
tbody.insertAdjacentHTML('beforeend', row);
|
| 420 |
+
});
|
| 421 |
+
} else {
|
| 422 |
+
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>';
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
</script>
|
| 426 |
+
</body>
|
| 427 |
+
</html>
|