2026-02-07_12-32-45.png ADDED
2026-02-07_13-16-47.png ADDED
2026-02-07_19-13-16.png ADDED
2026-02-07_23-21-43.png ADDED
2026-02-08_00-16-10.png ADDED
Dockerfile CHANGED
@@ -18,9 +18,7 @@ RUN python -m spacy download en_core_web_sm && \
18
  python -m spacy download ru_core_news_sm && \
19
  python -m spacy download de_core_news_sm && \
20
  python -m spacy download es_core_news_sm && \
21
- python -m spacy download it_core_news_sm && \
22
- python -m spacy download pl_core_news_sm && \
23
- python -m spacy download pt_core_news_sm
24
 
25
  # Копирование приложения
26
  COPY . .
 
18
  python -m spacy download ru_core_news_sm && \
19
  python -m spacy download de_core_news_sm && \
20
  python -m spacy download es_core_news_sm && \
21
+ python -m spacy download it_core_news_sm
 
 
22
 
23
  # Копирование приложения
24
  COPY . .
README.md CHANGED
@@ -11,18 +11,15 @@ license: mit
11
 
12
  # SEO AI Editor
13
 
14
- Веб-приложение для анализа и оптимизации SEO-текстов с использованием искусственного интеллекта. Проект использует лингвистический анализ (spaCy), частотный анализ (BM25), BERT-семантику и модульный Semantic Core для сравнения вашего текста с текстами конкурентов.
15
 
16
  ## 🚀 Возможности
17
 
18
- - **Многоязычный анализ**: Поддержка русского, английского, немецкого, испанского, итальянского, польского и португальского языков
19
  - **N-граммный анализ**: Статистика по униграммам, биграммам, триграммам и квадриграммам
20
  - **BM25 рекомендации**: Автоматические рекомендации по добавлению/удалению слов и фраз
21
  - **BERT семантический анализ**: Глубокий анализ релевантности текста ключевым фразам с использованием нейронных сетей
22
  - **Сравнение с конкурентами**: Детальное сравнение вашего текста с текстами конкурентов
23
- - **Title Analyzer**: Анализ title-тегов (длина, покрытие ключей, n-граммы, BERT)
24
- - **Semantic Core (TextAnalyst Web Core)**: Семантический граф, разметка текста, реферат и смысловой поиск
25
- - **Таблица мощных терминов**: Сравнение слов и фраз с сортировкой по `Мой вес`, `Avg K` и `Freq (X/Y)`
26
  - **GPU ускорение**: Автоматическое использование GPU для ускорения BERT-анализа
27
 
28
  ## 📋 Требования
@@ -65,20 +62,18 @@ python -m spacy download ru_core_news_sm
65
  python -m spacy download de_core_news_sm
66
  python -m spacy download es_core_news_sm
67
  python -m spacy download it_core_news_sm
68
- python -m spacy download pl_core_news_sm
69
- python -m spacy download pt_core_news_sm
70
  ```
71
 
72
  ## 🏃 Запуск
73
 
74
  Запустите приложение:
75
  ```bash
76
- python app.py
77
  ```
78
 
79
  Или используйте uvicorn напрямую:
80
  ```bash
81
- uvicorn app:app --host 127.0.0.1 --port 8001 --reload
82
  ```
83
 
84
  Приложение будет доступно по адресу: `http://127.0.0.1:8001`
@@ -94,7 +89,7 @@ uvicorn app:app --host 127.0.0.1 --port 8001 --reload
94
 
95
  ### Интерфейс результатов
96
 
97
- Приложение предоставляет несколько вкладок с результатами:
98
 
99
  #### 🧠 BERT Семантика
100
  - **Общий рейтинг релевантности**: Сравнение вашего текста и конкурентов по среднему сходству с ключевыми фразами
@@ -118,49 +113,25 @@ uvicorn app:app --host 127.0.0.1 --port 8001 --reload
118
  - Сравнение с конкурентами
119
  - Подсветка важных различий
120
 
121
- #### 🏷️ Title
122
- - Длина title (ваш vs конкуренты)
123
- - Покрытие ключевых фраз и отсутствующие слова
124
- - N-граммы title и BERT-сходство
125
-
126
- #### 🧩 Semantic Core
127
- - Построение семантического графа (узлы и связи)
128
- - Разметка значимых понятий в тексте
129
- - Автоматический реферат (extractive summary)
130
- - Смысловой поиск по словам и фразам
131
- - Сравнительная таблица мощных терминов (слова + фразы)
132
-
133
  ## 🏗️ Архитектура проекта
134
 
135
  ```
136
  seo_ai_editor/
137
- ├── app.py # FastAPI приложение (актуальная точка входа)
138
- ├── main.py # Легаси MVP entrypoint
139
  ├── logic.py # Основная бизнес-логика анализа
140
  ├── models.py # Pydantic модели для API
141
- ├── nlp_processor.py # NLP-предобработка для Semantic Core
142
- ├── semantic_graph.py # Графовая семантика и веса терминов
143
- ├── highlighter.py # Гипертекстовая разметка
144
- ├── summarizer.py # Реферат (сжатие предложений)
145
- ├── search.py # Смысловой поиск
146
  ├── requirements.txt # Зависимости проекта
147
  ├── templates/
148
  │ └── index.html # Frontend интерфейс
149
  └── README.md # Документация
150
  ```
151
 
152
- ## 📚 Локальная документация
153
-
154
- - `docs/FULL_FUNCTIONAL_DOCUMENTATION.md` — полное описание функционала, алгоритмов и API
155
- - `docs/ARCHITECTURE.md` — архитектура системы и взаимодействие модулей
156
- - `docs/DEVELOPMENT.md` — практики разработки, процессы и заметки по изменениям
157
-
158
  ### Основные компоненты
159
 
160
- #### `app.py`
161
  - FastAPI приложение
162
- - Роутинг: `/`, `/analyze`, `/api/v1/semantic/analyze`, `/api/v1/semantic/search`
163
- - Оркестрация BM25/BERT/N-грамм/Title/Semantic Core анализа
164
 
165
  #### `logic.py`
166
  Содержит три основных модуля:
@@ -237,25 +208,10 @@ Pydantic модели:
237
  "bert_analysis": {
238
  "global_scores": [...],
239
  "detailed": [...]
240
- },
241
- "title_analysis": {
242
- "...": "..."
243
  }
244
  }
245
  ```
246
 
247
- ### Endpoint: `/api/v1/semantic/analyze`
248
-
249
- **Метод:** POST
250
-
251
- **Назначение:** Построение семантического графа, разметки, реферата и сравнения с конкурентами.
252
-
253
- ### Endpoint: `/api/v1/semantic/search`
254
-
255
- **Метод:** POST
256
-
257
- **Назначение:** Смысловой поиск по выбранному документу (поддержка слов и фраз).
258
-
259
  ## ⚙️ Настройки
260
 
261
  ### Языки
@@ -265,8 +221,6 @@ Pydantic модели:
265
  - `de` - немецкий
266
  - `es` - испанский
267
  - `it` - итальянский
268
- - `pl` - польский
269
- - `pt` - португальский
270
 
271
  ### BERT модель
272
  По умолчанию используется `paraphrase-multilingual-MiniLM-L12-v2`. Модель можно изменить в функции `get_bert_model()` в файле `logic.py`.
 
11
 
12
  # SEO AI Editor
13
 
14
+ Веб-приложение для анализа и оптимизации SEO-текстов с использованием искусственного интеллекта. Проект использует лингвистический анализ (spaCy), частотный анализ (BM25) и семантический анализ (BERT) для сравнения вашего текста с текстами конкурентов.
15
 
16
  ## 🚀 Возможности
17
 
18
+ - **Многоязычный анализ**: Поддержка русского, английского, немецкого, испанского и итальянского языков
19
  - **N-граммный анализ**: Статистика по униграммам, биграммам, триграммам и квадриграммам
20
  - **BM25 рекомендации**: Автоматические рекомендации по добавлению/удалению слов и фраз
21
  - **BERT семантический анализ**: Глубокий анализ релевантности текста ключевым фразам с использованием нейронных сетей
22
  - **Сравнение с конкурентами**: Детальное сравнение вашего текста с текстами конкурентов
 
 
 
23
  - **GPU ускорение**: Автоматическое использование GPU для ускорения BERT-анализа
24
 
25
  ## 📋 Требования
 
62
  python -m spacy download de_core_news_sm
63
  python -m spacy download es_core_news_sm
64
  python -m spacy download it_core_news_sm
 
 
65
  ```
66
 
67
  ## 🏃 Запуск
68
 
69
  Запустите приложение:
70
  ```bash
71
+ python main.py
72
  ```
73
 
74
  Или используйте uvicorn напрямую:
75
  ```bash
76
+ uvicorn main:app --host 127.0.0.1 --port 8001 --reload
77
  ```
78
 
79
  Приложение будет доступно по адресу: `http://127.0.0.1:8001`
 
89
 
90
  ### Интерфейс результатов
91
 
92
+ Приложение предоставляет три вкладки с результатами:
93
 
94
  #### 🧠 BERT Семантика
95
  - **Общий рейтинг релевантности**: Сравнение вашего текста и конкурентов по среднему сходству с ключевыми фразами
 
113
  - Сравнение с конкурентами
114
  - Подсветка важных различий
115
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  ## 🏗️ Архитектура проекта
117
 
118
  ```
119
  seo_ai_editor/
120
+ ├── main.py # FastAPI приложение и роутинг
 
121
  ├── logic.py # Основная бизнес-логика анализа
122
  ├── models.py # Pydantic модели для API
 
 
 
 
 
123
  ├── requirements.txt # Зависимости проекта
124
  ├── templates/
125
  │ └── index.html # Frontend интерфейс
126
  └── README.md # Документация
127
  ```
128
 
 
 
 
 
 
 
129
  ### Основные компоненты
130
 
131
+ #### `main.py`
132
  - FastAPI приложение
133
+ - Роутинг: `/` (главная страница) и `/analyze` (API анализа)
134
+ - Предзагрузка моделей при старте
135
 
136
  #### `logic.py`
137
  Содержит три основных модуля:
 
208
  "bert_analysis": {
209
  "global_scores": [...],
210
  "detailed": [...]
 
 
 
211
  }
212
  }
213
  ```
214
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  ## ⚙️ Настройки
216
 
217
  ### Языки
 
221
  - `de` - немецкий
222
  - `es` - испанский
223
  - `it` - итальянский
 
 
224
 
225
  ### BERT модель
226
  По умолчанию используется `paraphrase-multilingual-MiniLM-L12-v2`. Модель можно изменить в функции `get_bert_model()` в файле `logic.py`.
app.py CHANGED
@@ -1,50 +1,17 @@
1
  # app.py - Hugging Face Spaces entry point
2
 
3
- import json
4
- import threading
5
- import uuid
6
- from queue import Empty, Queue
7
-
8
  from fastapi import FastAPI, Request
9
- from fastapi.responses import HTMLResponse, StreamingResponse
10
- from fastapi.staticfiles import StaticFiles
11
  from fastapi.templating import Jinja2Templates
12
  import uvicorn
13
  import torch
14
  import os
15
 
16
- from models import (
17
- AnalysisRequest,
18
- AnalysisResponse,
19
- SemanticAnalyzeRequest,
20
- SemanticAnalyzeResponse,
21
- SemanticSearchRequest,
22
- SemanticSearchResponse,
23
- UrlFetchRequest,
24
- UrlFetchResponse,
25
- UserAgentsResponse,
26
- OptimizerRequest,
27
- OptimizerResponse,
28
- OptimizerCancelRequest,
29
- )
30
- import logic
31
- import nlp_processor
32
- import semantic_graph
33
- import highlighter
34
- import summarizer
35
- import search
36
- import url_fetcher
37
- import optimizer
38
 
39
  app = FastAPI(title="SEO AI Editor MVP")
40
 
41
- _static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
42
- if os.path.isdir(_static_dir):
43
- app.mount("/static", StaticFiles(directory=_static_dir), name="static")
44
-
45
- _OPTIMIZER_JOBS_LOCK = threading.Lock()
46
- _OPTIMIZER_CANCEL_EVENTS: dict = {}
47
-
48
  # Подключаем папку с шаблонами
49
  templates = Jinja2Templates(directory="templates")
50
 
@@ -52,13 +19,11 @@ templates = Jinja2Templates(directory="templates")
52
  async def startup_event():
53
  device = "cuda" if torch.cuda.is_available() else "cpu"
54
  print(f"🚀 Application starting. ML Device: {device}")
55
- # Важный фикс для HF cpu-basic:
56
- # не загружаем все spaCy модели на старте, чтобы не ловить OOM/Restarting.
57
- # Модели подгружаются лениво в logic.get_doc() по факту запроса.
58
 
59
  @app.get("/", response_class=HTMLResponse)
60
  async def read_root(request: Request):
61
- return templates.TemplateResponse(request, "index.html")
62
 
63
  @app.post("/analyze", response_model=AnalysisResponse)
64
  async def analyze_text(request: AnalysisRequest):
@@ -126,221 +91,6 @@ async def analyze_text(request: AnalysisRequest):
126
  title_analysis=title_data
127
  )
128
 
129
-
130
- @app.post("/api/v1/semantic/analyze", response_model=SemanticAnalyzeResponse)
131
- async def semantic_analyze(request: SemanticAnalyzeRequest):
132
- def _build_doc_semantic(text: str, doc_name: str, doc_id: int):
133
- sentences_data = nlp_processor.preprocess_text(text, request.language)
134
- graph, word_weights = semantic_graph.build_semantic_graph(
135
- sentences_data,
136
- lang=request.language,
137
- )
138
- graph_data = semantic_graph.get_graph_data_for_frontend(graph)
139
- markup_text = highlighter.generate_markup_for_frontend(
140
- sentences_data, word_weights, threshold=request.threshold
141
- )
142
- summary_data = summarizer.generate_summary(
143
- sentences_data, word_weights, compression_ratio=request.compression_ratio
144
- )
145
- top_keywords = semantic_graph.get_top_keywords(word_weights, top_n=20)
146
- return {
147
- "id": doc_id,
148
- "name": doc_name,
149
- "text": text,
150
- "graph": graph_data,
151
- "markup_text": markup_text,
152
- "summary": summary_data,
153
- "top_keywords": top_keywords,
154
- "word_weights": word_weights,
155
- "stats": {
156
- "nodes": len(graph_data.get("nodes", [])),
157
- "links": len(graph_data.get("links", [])),
158
- "summary_sentences": len(summary_data),
159
- },
160
- }
161
-
162
- target_doc = _build_doc_semantic(request.text, "Мой текст", 0)
163
-
164
- competitor_docs = []
165
- valid_competitors = [c for c in request.competitors if c.strip()]
166
- for idx, comp_text in enumerate(valid_competitors):
167
- competitor_docs.append(_build_doc_semantic(comp_text, f"Конкурент #{idx + 1}", idx + 1))
168
-
169
- if competitor_docs:
170
- avg_nodes = round(sum(c["stats"]["nodes"] for c in competitor_docs) / len(competitor_docs), 2)
171
- avg_links = round(sum(c["stats"]["links"] for c in competitor_docs) / len(competitor_docs), 2)
172
- else:
173
- avg_nodes = 0
174
- avg_links = 0
175
-
176
- # Таблица сравнения "мощных терминов" (слова + фразы).
177
- num_competitors = len(competitor_docs)
178
- target_weights = target_doc.get("word_weights", {})
179
- all_terms = set(target_weights.keys())
180
- for comp in competitor_docs:
181
- all_terms.update(comp.get("word_weights", {}).keys())
182
-
183
- term_power_table = []
184
- for term in all_terms:
185
- target_weight = int(target_weights.get(term, 0))
186
- comp_weights = [int(comp.get("word_weights", {}).get(term, 0)) for comp in competitor_docs]
187
- comp_avg_weight = round(sum(comp_weights) / max(num_competitors, 1), 2)
188
- comp_occurrence = sum(1 for w in comp_weights if w > 0)
189
-
190
- term_power_table.append(
191
- {
192
- "term": term,
193
- "term_type": "phrase" if " " in term else "word",
194
- "target_weight": target_weight,
195
- "competitor_avg_weight": comp_avg_weight,
196
- "competitor_weights": comp_weights,
197
- "comp_occurrence": comp_occurrence,
198
- "comp_total": num_competitors,
199
- }
200
- )
201
-
202
- term_power_table.sort(
203
- key=lambda x: (
204
- max([x["target_weight"]] + x["competitor_weights"]),
205
- x["comp_occurrence"],
206
- x["term"],
207
- ),
208
- reverse=True,
209
- )
210
-
211
- comparison = {
212
- "target_nodes": target_doc["stats"]["nodes"],
213
- "target_links": target_doc["stats"]["links"],
214
- "avg_comp_nodes": avg_nodes,
215
- "avg_comp_links": avg_links,
216
- "num_competitors": num_competitors,
217
- "term_power_table": term_power_table,
218
- }
219
-
220
- return SemanticAnalyzeResponse(
221
- target=target_doc,
222
- competitors=competitor_docs,
223
- comparison=comparison,
224
- )
225
-
226
-
227
- @app.post("/api/v1/semantic/search", response_model=SemanticSearchResponse)
228
- async def semantic_search_endpoint(request: SemanticSearchRequest):
229
- sentences_data = nlp_processor.preprocess_text(request.text, request.language)
230
- graph, word_weights = semantic_graph.build_semantic_graph(
231
- sentences_data,
232
- lang=request.language,
233
- )
234
- results = search.semantic_search(
235
- request.query_text,
236
- graph,
237
- word_weights,
238
- request.language,
239
- top_n=request.top_n,
240
- )
241
- return SemanticSearchResponse(results=results)
242
-
243
-
244
- @app.get("/api/v1/url/user-agents", response_model=UserAgentsResponse)
245
- async def get_user_agents():
246
- return UserAgentsResponse(user_agents=url_fetcher.get_user_agent_presets())
247
-
248
-
249
- @app.post("/api/v1/url/fetch", response_model=UrlFetchResponse)
250
- async def fetch_url_endpoint(request: UrlFetchRequest):
251
- try:
252
- parsed = url_fetcher.fetch_url_content(
253
- url=request.url,
254
- user_agent_key=request.user_agent,
255
- timeout_seconds=request.timeout_seconds,
256
- )
257
- return UrlFetchResponse(**parsed)
258
- except Exception as e:
259
- return UrlFetchResponse(
260
- ok=False,
261
- url=request.url or "",
262
- user_agent_key=request.user_agent or "",
263
- error=str(e),
264
- )
265
-
266
-
267
- @app.post("/api/v1/optimizer/run", response_model=OptimizerResponse)
268
- async def run_optimizer(request: OptimizerRequest):
269
- try:
270
- result = optimizer.optimize_text(request.model_dump())
271
- return OptimizerResponse(**result)
272
- except Exception as e:
273
- return OptimizerResponse(ok=False, error=str(e))
274
-
275
-
276
- @app.post("/api/v1/optimizer/cancel")
277
- async def optimizer_cancel(body: OptimizerCancelRequest):
278
- with _OPTIMIZER_JOBS_LOCK:
279
- ev = _OPTIMIZER_CANCEL_EVENTS.get(body.job_id)
280
- if ev is not None:
281
- ev.set()
282
- return {"ok": True}
283
-
284
-
285
- @app.post("/api/v1/optimizer/run-stream")
286
- async def run_optimizer_stream(request: OptimizerRequest):
287
- """SSE: события прогресса + финальный JSON. Клиент ведёт локальный лог, без глобального лоадера."""
288
- job_id = str(uuid.uuid4())
289
- cancel_ev = threading.Event()
290
- payload = request.model_dump()
291
- q: Queue = Queue()
292
-
293
- with _OPTIMIZER_JOBS_LOCK:
294
- _OPTIMIZER_CANCEL_EVENTS[job_id] = cancel_ev
295
-
296
- def worker():
297
- try:
298
- def progress_cb(data):
299
- q.put(("progress", data))
300
-
301
- result = optimizer.optimize_text(
302
- payload,
303
- progress_callback=progress_cb,
304
- cancel_event=cancel_ev,
305
- )
306
- q.put(("done", result))
307
- except Exception as e:
308
- q.put(("error", str(e)))
309
-
310
- threading.Thread(target=worker, daemon=True).start()
311
-
312
- def gen():
313
- try:
314
- yield f"data: {json.dumps({'event': 'job', 'job_id': job_id})}\n\n"
315
- while True:
316
- try:
317
- kind, data = q.get(timeout=0.3)
318
- except Empty:
319
- yield ": ping\n\n"
320
- continue
321
- if kind == "progress":
322
- yield f"data: {json.dumps(data)}\n\n"
323
- elif kind == "done":
324
- yield f"data: {json.dumps({'event': 'complete', 'result': data})}\n\n"
325
- break
326
- elif kind == "error":
327
- yield f"data: {json.dumps({'event': 'error', 'error': data})}\n\n"
328
- break
329
- finally:
330
- with _OPTIMIZER_JOBS_LOCK:
331
- _OPTIMIZER_CANCEL_EVENTS.pop(job_id, None)
332
-
333
- return StreamingResponse(
334
- gen(),
335
- media_type="text/event-stream",
336
- headers={
337
- "Cache-Control": "no-cache",
338
- "Connection": "keep-alive",
339
- "X-Accel-Buffering": "no",
340
- },
341
- )
342
-
343
-
344
  # Hugging Face Spaces использует порт 7860
345
  if __name__ == "__main__":
346
  port = int(os.environ.get("PORT", 7860))
 
1
  # app.py - Hugging Face Spaces entry point
2
 
 
 
 
 
 
3
  from fastapi import FastAPI, Request
4
+ from fastapi.responses import HTMLResponse
 
5
  from fastapi.templating import Jinja2Templates
6
  import uvicorn
7
  import torch
8
  import os
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
 
 
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
  @app.get("/", response_class=HTMLResponse)
25
  async def read_root(request: Request):
26
+ return templates.TemplateResponse("index.html", {"request": request})
27
 
28
  @app.post("/analyze", response_model=AnalysisResponse)
29
  async def analyze_text(request: AnalysisRequest):
 
91
  title_analysis=title_data
92
  )
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # Hugging Face Spaces использует порт 7860
95
  if __name__ == "__main__":
96
  port = int(os.environ.get("PORT", 7860))
docs/FULL_FUNCTIONAL_DOCUMENTATION.md DELETED
@@ -1,640 +0,0 @@
1
- # SEO AI Editor — исчерпывающая документация функционала, логики и алгоритмики
2
-
3
- Документ описывает приложение как инженерную спецификацию: что делает каждый модуль, какие данные принимает и возвращает, какие формулы использует, какие ограничения применяет и как воспроизвести поведение системы без чтения исходного кода.
4
-
5
- ---
6
-
7
- ## 1) Концепция приложения
8
-
9
- `SEO AI Editor` объединяет два аналитических контура и один контур улучшения текста:
10
-
11
- 1. **SEO-контур** (`POST /analyze`)
12
- - Word Count (total/significant)
13
- - N-gram анализ (1..4)
14
- - BM25-рекомендации (`add/remove/ok`)
15
- - BERT-семантика по ключам
16
- - Title-анализ (length/ngrams/coverage/BERT)
17
-
18
- 2. **Semantic Core** (`POST /api/v1/semantic/analyze`, `POST /api/v1/semantic/search`)
19
- - NLP-разбор и лемматизация
20
- - семантический граф (слова + фразы)
21
- - веса узлов и связей в шкале `1..100`
22
- - гипертекстовая разметка
23
- - реферат
24
- - смысловой поиск по словам и фразам
25
- - сравнение с конкурентами (включая таблицу мощных терминов)
26
-
27
- 3. **LLM Optimizer** (`POST /api/v1/optimizer/run` или в UI — SSE `run-stream`)
28
- - итеративная локальная оптимизация текста
29
- - многокритериальный скоринг с защитой от деградации
30
- - каскад уровней правок (от минимальных к более широким)
31
- - детализированный debug-лог по кандидатам
32
-
33
- ---
34
-
35
- ## 2) Архитектура и ответственность файлов
36
-
37
- - `app.py` — FastAPI оркестратор, endpoint-ы, связывание модулей.
38
- - `models.py` — Pydantic-модели входов/выходов.
39
- - `logic.py` — SEO-ядро: токены, n-grams, BM25, BERT, Title.
40
- - `nlp_processor.py` — NLP-предобработка для semantic-контура.
41
- - `semantic_graph.py` — построение графа и вычисление смысловых весов.
42
- - `highlighter.py` — разметка текста по semantic-весам.
43
- - `summarizer.py` — генерация реферата.
44
- - `search.py` — смысловой поиск в графе (фразы + слова).
45
- - `url_fetcher.py` — извлечение текста/title из URL с выбором user-agent.
46
- - `optimizer.py` — LLM-оптимизация с обратной связью от метрик.
47
- - `docs/TEXT_OPTIMIZER_PRINCIPLES.md` — живой регламент принципов оптимизатора (stage-пайплайн, допуски, guardrails).
48
- - `templates/index.html` — разметка UI.
49
- - `static/js/app.js` — вся клиентская логика (подключается как `/static/js/app.js`; без гигантского inline-скрипта — см. `docs/HF_SES_AND_UI.md`).
50
-
51
- ---
52
-
53
- ## 3) Поддерживаемые языки
54
-
55
- Поддерживаемые языки анализа:
56
- - `ru`, `en`, `de`, `es`, `it`, `pl`, `pt`
57
-
58
- Языки задаются кодом и сопоставляются с spaCy-моделями в `logic.py` (`MODEL_NAMES`).
59
-
60
- ---
61
-
62
- ## 4) Backend API и контракты
63
-
64
- ## 4.1 `POST /analyze`
65
-
66
- ### Назначение
67
- Комплексный SEO-анализ target-текста относительно конкурентов и ключевых фраз.
68
-
69
- ### Вход (`AnalysisRequest`)
70
- - `target_text: str`
71
- - `competitors: List[str]`
72
- - `keywords: List[str]`
73
- - `language: str`
74
- - `target_title: str`
75
- - `competitor_titles: List[str]`
76
-
77
- ### Выход (`AnalysisResponse`)
78
- - `ngram_stats`
79
- - `bm25_recommendations`
80
- - `bert_analysis`
81
- - `word_counts`
82
- - `title_analysis`
83
-
84
- ### Оркестрация в `app.py`
85
- 1. Word counts (`count_words`) для target и каждого competitor.
86
- 2. N-gram статистика (`calculate_ngram_stats`).
87
- 3. Нормализация ключей (`parse_keywords`) и BM25 (`calculate_bm25_recommendations`).
88
- 4. BERT-анализ (`perform_bert_analysis`).
89
- 5. Title-анализ (`analyze_title`) если `target_title` не пустой.
90
-
91
- ---
92
-
93
- ## 4.2 `POST /api/v1/semantic/analyze`
94
-
95
- ### Назначение
96
- Построение semantic-среза по target и конкурентам.
97
-
98
- ### Вход (`SemanticAnalyzeRequest`)
99
- - `text: str`
100
- - `competitors: List[str]`
101
- - `language: str`
102
- - `threshold: int` (порог подсветки)
103
- - `compression_ratio: float` (доля предложений �� реферате)
104
-
105
- ### Выход (`SemanticAnalyzeResponse`)
106
- - `target`:
107
- - `graph` (`nodes`, `links`)
108
- - `markup_text`
109
- - `summary`
110
- - `top_keywords`
111
- - `word_weights`
112
- - `stats`
113
- - `competitors[]`: тот же формат
114
- - `comparison`:
115
- - `target_nodes`, `target_links`
116
- - `avg_comp_nodes`, `avg_comp_links`
117
- - `num_competitors`
118
- - `term_power_table`
119
-
120
- ### Логика таблицы `term_power_table`
121
- Для каждого термина из объединения target + competitors:
122
- - `target_weight`
123
- - `competitor_avg_weight`
124
- - `competitor_weights` (`K1..Kn`)
125
- - `comp_occurrence` (`X` в `X/Y`)
126
- - `comp_total` (`Y`)
127
- - `term_type` (`word` или `phrase`)
128
-
129
- ---
130
-
131
- ## 4.3 `POST /api/v1/semantic/search`
132
-
133
- ### Назначение
134
- Смысловой поиск по документу через граф.
135
-
136
- ### Вход (`SemanticSearchRequest`)
137
- - `query_text`
138
- - `text`
139
- - `language`
140
- - `top_n`
141
-
142
- ### Выход (`SemanticSearchResponse`)
143
- - `results[]`: `lemma`, `score (1..100)`, `type (word|phrase)`
144
-
145
- ---
146
-
147
- ## 4.4 URL Import API
148
-
149
- ### `GET /api/v1/url/user-agents`
150
- Возвращает список пресетов user-agent для выбора в UI.
151
-
152
- ### `POST /api/v1/url/fetch`
153
- Извлекает `title` и основной `text` страницы:
154
- - вход: `url`, `user_agent`, `timeout_seconds`
155
- - выход: `ok`, `status_code`, `title`, `text`, `error`, `final_url`, agent-метаданные.
156
-
157
- Обработка ошибок не ломает UI: endpoint возвращает `ok=false` и `error`.
158
-
159
- ---
160
-
161
- ## 4.5 `POST /api/v1/optimizer/run`
162
-
163
- ### Назначение
164
- Итеративная локальная дооптимизация target-текста через LLM.
165
-
166
- ### Вход (`OptimizerRequest`)
167
- - аналитические данные: `target_text`, `competitors`, `keywords`, `language`, `target_title`, `competitor_titles`
168
- - LLM: `api_key`, `api_base_url`, `model`, `temperature`
169
- - стратегия: `max_iterations`, `candidates_per_iteration`, `optimization_mode`, `phrase_strategy_mode`, `bert_stage_target`
170
- - `phrase_strategy_mode`: `auto | distributed_preferred | exact_preferred | ensemble`
171
- - `ensemble`: в пределах итерации циклически пробует несколько phrase-стратегий и ранжирует кандидаты общей utility-функцией.
172
- - `bert_stage_target`: пользовательский порог завершения этапа A (BERT), например `0.61` вместо `0.70`.
173
-
174
- ### Выход (`OptimizerResponse`)
175
- - `optimized_text` — итоговый body (target)
176
- - `optimized_title` — итоговая строка для поля **Title**; в ответе она берётся из снимка `title_analysis.target_title` (тот же текст, что учитывался в метрике Title BERT), с запасным вариантом из переменной оптимизатора. В `final_metrics` дополнительно есть `resolved_title` с тем же смыслом (удобно для UI/fallback).
177
- - `baseline_metrics`, `final_metrics`
178
- - `iterations[]` (подробный лог шагов)
179
- - `applied_changes`
180
- - `optimization_mode`
181
- - `phrase_strategy_mode`
182
- - `bert_stage_target`
183
- - `stopped_early`, `stop_reason` — при ручной остановке (частичный результат)
184
- - `error` (если есть)
185
-
186
- ### `POST /api/v1/optimizer/run-stream` (SSE)
187
- Тело как у `run`. Поток `text/event-stream`, события `job` (с `job_id`), `preparing`, `started`, `step_start`, `llm_call`, затем `complete` с полем `result` или `error`.
188
-
189
- ### `POST /api/v1/optimizer/cancel`
190
- Тело: `{"job_id": "..."}`. Только флаг отмены; клиент дочитывает SSE до `complete`.
191
-
192
- ### UI и HF/SES
193
- Клиентский код: **`static/js/app.js`** + `GET /static/js/app.js`. Прогресс оптимизатора — **локальная панель с текстовым логом** (и тонкая полоса), **без** `#loader`. Подробности про SES и «мёртвые кнопки»: `docs/HF_SES_AND_UI.md`.
194
-
195
- ---
196
-
197
- ## 5) Подробная алгоритмика по модулям
198
-
199
- ## 5.1 `logic.py` — SEO-ядро
200
-
201
- ### `load_model_if_missing(lang)`
202
- Ленивая загрузка spaCy-модели конкретного языка. Цель: не загружать все модели на старте (критично для HF ресурсов).
203
-
204
- ### `load_models()`
205
- Служебная массовая загрузка моделей (используется ограниченно; основной путь в проде — lazy).
206
-
207
- ### `get_doc(text, lang)`
208
- Единая точка получения spaCy `Doc` с предобработкой языка/модели.
209
-
210
- ### `is_valid_token(t)`
211
- Фильтр значимых токенов (исключает шумовые категории: punctuation/space/часть stop и др.).
212
-
213
- ### `get_lemmas_flat(text, lang)`
214
- Плоский список лемм значимых токенов. Базовый строительный блок для метрик.
215
-
216
- ### `generate_ngrams_safe(text, lang, n)` — Smart Window
217
- Ключевой принцип:
218
- - размер окна задается по **значимым** словам;
219
- - stop-слова внутри валидного окна могут сохраняться для естественных фраз;
220
- - символные границы (punct/num/sym) не дают сшивать ложные фразы.
221
-
222
- Это гарантирует более естественные n-grams и согласованность между разными подсистемами.
223
-
224
- ### `count_words(text, lang)`
225
- Возвращает:
226
- - `total` — количество словоформ
227
- - `significant` — количество значимых токенов после фильтра
228
-
229
- ### `calculate_ngram_stats(target_text, competitor_texts, lang)`
230
- Строит частотные словари 1..4-грамм, агрегирует:
231
- - частоты target
232
- - средние частоты competitors
233
- - сигналы дефицита/избытка
234
- - детализацию по каждому конкуренту (для интерфейсных таблиц)
235
-
236
- ### `parse_keywords(raw_phrases, lang)`
237
- Нормализует сырые ключи пользователя в:
238
- - фразовые ключи
239
- - униграммы
240
- с учетом текущего языка и лемматизации.
241
-
242
- ### `calculate_bm25_recommendations(...)` — Mirror Principle
243
- BM25 использует тот же подход токенизации/фразогенерации, что и n-gram ядро.
244
- Смысл:
245
- - сравнить релевантность target и среднего competitor-профиля по тем же термам;
246
- - выдать действие:
247
- - `add` — недобор терма,
248
- - `remove` — вероятный переспам,
249
- - `ok` — баланс.
250
-
251
- ### `get_bert_model()`
252
- Ленивая инициализация sentence-transformers модели.
253
-
254
- ### `perform_bert_analysis(target_text, competitor_texts, key_phrases, lang)`
255
- Для каждой ключевой фразы:
256
- - ищет наиболее близкие чанки текста;
257
- - считает similarity для target и competitors;
258
- - формирует детализацию (`my_max_score`, competitor-сравнение, статусы).
259
-
260
- ### `analyze_title(target_title, competitor_titles, raw_keywords, lang)`
261
- Оркестратор Title-пайплайна:
262
- - `_title_length`
263
- - `_title_ngrams`
264
- - `_title_keyword_coverage`
265
- - `_title_bert`
266
-
267
- #### `_title_length(...)`
268
- Сравнивает длину target title с конкурентным диапазоном/средним.
269
-
270
- #### `_title_ngrams(...)`
271
- N-gram сопоставление title-уровня.
272
-
273
- #### `_title_keyword_coverage(...)`
274
- Проверяет покрытие пользовательских ключей в target и competitor title.
275
-
276
- #### `_title_bert(...)`
277
- Оценивает semantic-близость title к ключевому набору.
278
-
279
- ---
280
-
281
- ## 5.2 `nlp_processor.py`
282
-
283
- ### `preprocess_text(text, lang)`
284
- Преобразует текст в структуру предложений:
285
- - `raw_text`
286
- - `tokens[]` с полями `text`, `whitespace`, `lemma`, `is_significant`, `is_punct`, `is_space`
287
- - `lemmas_clean` (очищенный список лемм)
288
-
289
- Критично: сохранение `whitespace` и исходных токенов позволяет восстановить текст UI-послойно без потери форматирования.
290
-
291
- ---
292
-
293
- ## 5.3 `semantic_graph.py` — математическое ядро
294
-
295
- ### `_normalize_to_1_100(values)`
296
- Нормализация произвольных весов в целочисленную шкалу `1..100`.
297
-
298
- ### `_extract_significant_lemmas(sent)`
299
- Достает значимые леммы из предложения.
300
-
301
- ### `_is_noise_sentence(text)`
302
- Отбрасывает шумовые фрагменты (короткие CTA, boilerplate-паттерны).
303
-
304
- ### `_canonicalize_term(term)`
305
- Rule-based каноникализация термов (снижение дублей и вариативности).
306
-
307
- ### `_extract_phrase_candidates(sentence_text, lang)`
308
- Извлечение кандидатных фраз через `generate_ngrams_safe` и фильтры.
309
-
310
- ### `_normalize_lemma_sequence(lemmas)`
311
- Нормализация последовательностей лемм для устранения артефактов.
312
-
313
- ### `build_semantic_graph(sentences_data, lang)`
314
- Базовые шаги:
315
- 1. Сформировать множество терминов (слова + фразы).
316
- 2. Подсчитать частоты терминов и совместные появления.
317
- 3. Постр��ить направленный граф.
318
- 4. Рассчитать вес ребра:
319
- - `P(B|A) = cooc(A,B) / occ(A) * 100`
320
- - затем ограничение в `0..100`.
321
- 5. Рассчитать важность узлов:
322
- - PageRank как глобальная связность,
323
- - termness/coverage корректировки,
324
- - штрафы для слишком общих доменных токенов.
325
- 6. Вернуть граф и карту `word_weights`.
326
-
327
- ### `get_graph_data_for_frontend(graph, top_edges_per_node=8)`
328
- Сериализует `networkx` граф в плоский JSON:
329
- - `nodes[]`
330
- - `links[]`
331
- с ограничением числа ребер на узел для управляемого рендера.
332
-
333
- ### `get_top_keywords(node_weights, top_n=20)`
334
- Возвращает top-N терминов по весу.
335
-
336
- ---
337
-
338
- ## 5.4 `highlighter.py`
339
-
340
- ### `generate_markup_for_frontend(sentences_data, word_weights, threshold=50)`
341
- Маркирует важные блоки:
342
- - если вес леммы/фразы >= порога, блок становится `is_link=true`;
343
- - соседние значимые токены могут объединяться в один кликабельный сегмент;
344
- - возвращается структура, удобная для реактивного рендера в UI.
345
-
346
- ---
347
-
348
- ## 5.5 `summarizer.py`
349
-
350
- ### `generate_summary(sentences_data, word_weights, compression_ratio)`
351
- Скоринг предложения:
352
- - `score = sum(weight(unique_lemmas)) / sqrt(token_count)`
353
-
354
- Далее:
355
- 1. сортировка по score убыв.
356
- 2. выбор top по `compression_ratio`
357
- 3. восстановление хронологического порядка для читабельности.
358
-
359
- ---
360
-
361
- ## 5.6 `search.py`
362
-
363
- ### `_normalize_query_text(text)`
364
- Нормализует запрос для устойчивого поиска.
365
-
366
- ### `semantic_search(query_text, G, word_weights, language, top_n)`
367
- Алгоритм:
368
- 1. Нормализовать и лемматизировать запрос.
369
- 2. Приоритетно проверить фразы (tri/bi) из запроса.
370
- 3. Fallback на слова.
371
- 4. Для найденных точек входа добавить соседей по графу.
372
- 5. Собрать score из силы связи и веса узла.
373
- 6. Нормализовать score в `1..100`.
374
- 7. Вернуть top-N и тип (`phrase`/`word`).
375
-
376
- ---
377
-
378
- ## 5.7 `url_fetcher.py`
379
-
380
- ### `get_user_agent_presets()`
381
- Возвращает список пресетов (Googlebot, Bingbot, ChatGPT user-agent, GPTBot, Chrome Desktop и др.).
382
-
383
- ### `_normalize_whitespace(text)`
384
- Схлопывает лишние пробелы/переводы строк.
385
-
386
- ### `_normalize_url(url)`
387
- Приводит URL к валидному виду (схема, trimming).
388
-
389
- ### `_resolve_user_agent(user_agent_key)`
390
- По ключу выбирает фактическую строку user-agent.
391
-
392
- ### `_extract_main_text_and_title(html)`
393
- HTML extraction pipeline:
394
- - удалить `script/style/noscript/nav/footer/header/form/svg` и прочий boilerplate;
395
- - приоритетно извлекать `article/main`;
396
- - fallback на абзацы/списки;
397
- - final fallback на `body` текст;
398
- - вернуть очищенный `title` и основной `text`.
399
-
400
- ### `fetch_url_content(url, user_agent_key, timeout_seconds)`
401
- Выполняет HTTP-запрос и возвращает структурированный результат для UI/API.
402
-
403
- ---
404
-
405
- ## 5.8 `optimizer.py` — LLM-оптимизация текста
406
-
407
- ### Цель модуля
408
- Итеративно улучшать конкретные проблемные зоны из аналитики, избегая полной перегенерации текста и сохраняя стиль/повествование.
409
-
410
- ### Служебные функции подготовки
411
- - `_tokenize` — токенизация строки.
412
- - `_filter_stopwords` — удаление stop-слов.
413
- - `_split_sentences` — сегментация на предложения.
414
- - `_max_sentences_for_level` — лимиты длины кандидата по каскаду.
415
- - `_validate_candidate_text` — pre-check качества (пустота, дубль слова/сущности, подозрительные токен-склейки, превышение лимита предложений).
416
-
417
- ### Снимки аналитики
418
- - `_build_analysis_snapshot` — пересчет `/analyze` локально.
419
- - `_build_semantic_snapshot` — пересчет semantic среза локально.
420
-
421
- ### Скоринг и выбор цели
422
- - `_compute_metrics` — единый набор метрик состояния:
423
- - composite score
424
- - `bert_low_count`
425
- - `bert_phrase_scores`
426
- - `bm25_remove_count`
427
- - сигналы n-gram/semantic
428
- - `title_bert_score`
429
- - `semantic_gap_count`, `semantic_gap_sum`, `semantic_gap_terms`
430
- - `_choose_optimization_goal` — выбирает приоритетную проблему.
431
- - для BERT-целей используется порог `BERT_TARGET_THRESHOLD=0.7` (допустимый рабочий минимум может быть снижен до `0.65`), без конкурентного override;
432
- - `_choose_sentence_idx` — выбирает релевантный чанк для правки.
433
-
434
- ### Параметры Semantic Gap в оптимизаторе
435
- - `SEMANTIC_GAP_TOLERANCE_PCT = 0.15` — допускается отклонение до ~15% от target, без штрафа.
436
- - `SEMANTIC_GAP_MIN_ABS = 3.0` — игнорируются микрошумы с маленькой абсолютной разницей.
437
- - Gap считается значимым, если одновременно:
438
- - `competitor_avg_weight > target_weight * (1 + tolerance)`
439
- - `competitor_avg_weight - target_weight >= min_abs`
440
-
441
- Это убирает пере-жесткую подгонку к среднему конкуренту и снижает ложные колебания `semantic_gap_count`.
442
-
443
- ### Генерация кандидатов
444
- - `_llm_edit_chunk` — отправляет structured prompt в OpenAI-compatible API.
445
- - роль модели в prompt: **semantic-vector optimizer for SEO**, а не общий “copy editor”.
446
- - учитывает `cascade_level` и тип операции (`rewrite`/`insert`)
447
- - явно требует грамматически корректный и естественный текст
448
- - ограничивает число предложений по уровню
449
- - для BERT динамически выбирает стратегию по длине целевой фразы:
450
- - короткие цели: допустим один natural exact match;
451
- - длинные multi-word цели: приоритет у distributed semantic coverage (части фразы/леммы/близкие формулировки), без forced exact match.
452
- - exact phrase не должен повторяться: при неестественном звучании он запрещается в пользу распределённой формулировки.
453
- - для `rewrite` явно требует сохранить исходный смысл `sentence-by-sentence` и не менять субъект/ключевую сущность без необходимости.
454
-
455
- ### Применение правок
456
- - `_replace_span` — замена диапазона предложений.
457
- - `_insert_after` — вставка после диапазона.
458
-
459
- ### Принятие/отклонение кандидата
460
- - `_goal_improved`:
461
- - для BERT: улучшение score целевой фразы минимум на `BERT_GOAL_DELTA_MIN=0.005` **или** снижение `bert_low_count`;
462
- - для других целей: профильные метрики улучшения.
463
- - `_candidate_utility`:
464
- - многоцелевая функция полезности кандидата с динамическими весами;
465
- - учитывает одновременно `bert_phrase_delta`, `chunk_goal_delta`, `score_delta`;
466
- - добавляет мягкие штрафы за регрессии по BM25/BERT-low/N-gram/SemanticGap/Title;
467
- - в BERT-push режиме (когда фраза ниже порога) усиливает вес phrase-level прогресса.
468
- - `_is_candidate_valid`:
469
- - hard constraints (не ухудшать критичные метрики сверх допустимого);
470
- - режимы `conservative/balanced/aggressive` задают пороги регрессии;
471
- - решение учитывает и `goal_improved`, и общий `delta_score`.
472
- - `_is_stage_complete` для `bert`:
473
- - этап считается завершённым только когда **каждая** отслеживаемая ключевая фраза достигает `bert_stage_target` (проверка по `min(bert_phrase_scores)`);
474
- - достижение порога одной «сильной» фразой больше не завершает BERT-этап.
475
- - унифицированный цикл по целям: базовые параметры запроса `max_iterations` и `candidates_per_iteration` задают «якорь», но для **каждой** цели вычисляется эффективный бюджет (`_per_goal_budget`): число попыток и ширина пула кандидатов **масштабируются по дефициту** до таргета — для BERT по разрыву score до порога, для semantic по `semantic_gap`, для n-gram по отставанию/перегрузу относительно целевого счётчика, для BM25 по «лишним» вхождениям слова, для title по разрыву `title_bert_score`. После исчерпания лимита по текущей цели оптимизатор переходит к следующей цели той же стадии.
476
- - `_validate_candidate_text`:
477
- - отклоняет некачественные/спамные кандидаты (дубли слов/сущностей, подозрительные склейки токенов);
478
- - добавляет anti-stuffing фильтр для цели BERT (повторы exact phrase и чрезмерные повторы focus-термов).
479
-
480
- ### Главная функция `optimize_text`
481
- Итерационный цикл:
482
- 1. baseline metrics.
483
- - общий бюджет шагов оценивается как **сумма эффективных итераций по всем целям** (`_estimate_total_loop_budget`: для каждой цели — `_per_goal_budget`, затем сумма по стадиям с верхней отсечкой), то есть масштабируется и по числу целей, и по величине отставания от таргета. В SSE-событии `step_start` дополнительно передаются `goal_budget_iter` и `goal_budget_candidates` для текущей цели.
484
- 2. выбрать goal.
485
- 3. выбрать пул чанков и операцию каскада.
486
- - **Этап `title`:** если средняя BERT-близость Title к ключам (`title_bert_score`) ниже порога (`TITLE_TARGET_THRESHOLD` ≈ 0.65), цель — **только переписать текст из поля Title** (`target_title`), а не абзац основного текста. LLM получает текущий title, выдержку из body и ключевые слова; метрики пересчитываются с новым title. Пакетные правки по body с title не смешиваются.
487
- - **Проверка деплоя:** в debug кандидата для шага `title` в `llm_prompt_debug` должно быть `"operation": "title_rewrite"`, а `chunk_text` — короткая строка текущего Title. Если видите `"operation": "rewrite"` и длинный `chunk_text` из body — на сервере старая версия `optimizer.py` (или не пересобран образ).
488
- - на шаг выбирается несколько span-кандидатов (multi-chunk selection), а не один;
489
- - ранжирование учитывает `focus_terms/avoid_terms`, chunk-level relevance и шумовые эвристики (menu/CTA/header penalties);
490
- - для **n-gram** целей предложения ранжируются через **скользящие перекрывающиеся окна** из 2–4 предложений (шаг 1): каждому предложению присваивается лучший балл среди окон, оценка штрафует локальные повторы фразы и шумовые блоки;
491
- - для BERT-целей ранжирование не ограничивается участками с already-present вхождениями: дополнительно приоритизируются релевантные участки с недопредставленными core-термами, где их можно добавить естественно;
492
- - используется `attempt_cursor` по цели и `attempted_spans`, чтобы избежать циклов по одному и тому же участку.
493
- 4. сгенерировать `N` кандидатов для каждого выбранного span (`N` зависит от эффективного бюджета кандидатов для цели и каскада, см. `_per_goal_budget` и деление по span).
494
- 5. pre-validation (формат/качество/длины).
495
- 6. chunk-level оценка:
496
- - вычисляется `chunk_goal_delta` (релевантность чанка до/после к текущей цели);
497
- - кандидат помечается `local_chunk_improved`, если прирост выше порога цели.
498
- 7. document-level оценка:
499
- - полный пересчет метрик для всего текста;
500
- - проверка глобальных ограничений через `_is_candidate_valid`.
501
- 8. staged acceptance:
502
- - если кандидат улучшает цель/общий score и проходит глобальные ограничения — `applied`;
503
- - если локально улучшает чанк, но глобально не проходит — кандидат кладется в queue.
504
- - для BERT учитывается прямой документный `bert_phrase_delta` по целевой фразе: даже небольшой положительный рост считается полезным шагом при отсутствии регре��сий по guardrails.
505
- - если нет `promotable` кандидата, но есть guardrail-valid кандидат с `local_chunk_improved`, применяется режим `applied_local_progress`: правка принимается локально и оптимизация переходит к следующему чанку (накопительная стратегия).
506
- - ранжирование и выбор best-кандидата дополнительно учитывают `candidate_utility`, чтобы BERT-оптимизация не вредила следующим этапам по другим метрикам.
507
- 9. batch-логика queue:
508
- - optimizer пробует совместно применить комбинации из 2..4 локально сильных не конфликтующих правок;
509
- - batch принимается только при прохождении глобальных ограничений и положительном совокупном локальном приросте.
510
- 10. при серии неудач эскалировать каскад (`L1 -> L2 -> L3 -> L4`), при успехе сбрасывать на `L1`.
511
- - `L1`: локальный rewrite (обычно 1 предложение),
512
- - `L2`: rewrite расширенного окна (2-3 предложения, несколько вариантов радиусов),
513
- - `L3`: insert bridge (вставка) с возможным fallback на rewrite,
514
- - `L4`: более широкий rewrite окна (до 5 предложений с вариативным охватом).
515
- 11. вести подробный лог по каждому кандидату.
516
- - в debug-таблице фиксируются и chunk-level сигналы (`local+`, `chunk Δ`, `rel before->after`) наряду с глобальными (`Δ score`, `valid`, `goal+`);
517
- - для каждого кандидата сохраняется `llm_prompt_debug` (операция, цель, фокус-термы, chunk и ближайший контекст), что позволяет анализировать фактический вход в LLM;
518
- - LLM возвращает поле `rationale` (1 строка) — краткое объяснение, почему правка должна повысить релевантность цели.
519
- - также сохраняется `metrics_delta` (вклад BM25/BERT/Semantic/N-gram/Title в общий сдвиг), включая `semantic_gap_sum` и изменение состава gap-термов (`semantic_gap_terms_added/removed`), чтобы видеть, за счет чего падает или растет `score`.
520
-
521
- ---
522
-
523
- ## 6) Frontend (`templates/index.html` + `static/js/app.js`)
524
-
525
- ## 6.1 Ввод данных и URL import
526
- - `loadUserAgentOptions` — загрузка пресетов UA.
527
- - `fetchUrlPayload` — запрос к URL API.
528
- - `fetchTargetFromUrl` — заполнение target text/title из URL.
529
- - `fetchCompetitorsFromUrls` — массовое заполнение competitors.
530
-
531
- Ручной ввод всегда остается рабочим fallback-сценарием.
532
-
533
- ## 6.2 Локальное сохранение проекта
534
- - `saveProject` — экспорт JSON.
535
- - `loadProject` — загрузка JSON.
536
- - `applyProjectData` — восстановление полей и результатов.
537
- - `clearProject` — новый проект/сброс.
538
-
539
- API-ключ оптимизатора в persist-состояние не сохраняется.
540
-
541
- ## 6.3 Запуск аналитики и отрисовка
542
- - `runAnalysis`
543
- - `runSemanticAnalysis`
544
- - `runSemanticSearch`
545
- - `renderResults`
546
- - `renderSemanticResults`
547
- - `renderTitleResults`
548
- - `showNgramTable`
549
-
550
- ## 6.4 Сводка и оптимизатор
551
- - `renderActionSummary` — агрегирует рекомендации BERT/BM25/N-grams/Title/Semantic в табличный формат.
552
- - `runLlmOptimization` — `POST /api/v1/optimizer/run-stream` (SSE); локальная панель **лога** + тонкий progress bar; **без** `#loader`.
553
- - `requestStopOptimizer` — `POST /api/v1/optimizer/cancel`; поток дочитывается до `complete` (частичный результат).
554
- - `optimizerLogAppend` / `applyOptimizerStreamEvent` — текстовый ход работы.
555
- - `renderOptimizerResults` — итог и debug-лог; баннер при `stopped_early`.
556
- - `applyOptimizedText` — перенос optimized текста в `target_text`.
557
- - `nv(v, d)` — nullish-fallback без операторов `??` (SES на HF).
558
-
559
- ## 6.5 Сортировка таблицы мощных терминов
560
- - `setSemanticTermSortBy`
561
- - `toggleSemanticTermSortDir`
562
-
563
- Поддерживаются сортировки по:
564
- - `Мой вес`
565
- - `Avg K`
566
- - `Freq (X/Y)` (с приоритетом большего `X` при одинаковом `Y`)
567
-
568
- ---
569
-
570
- ## 7) Данные и модели (`models.py`)
571
-
572
- Ключевые модели:
573
- - `AnalysisRequest`, `AnalysisResponse`
574
- - `SemanticAnalyzeRequest`, `SemanticAnalyzeResponse`
575
- - `SemanticSearchRequest`, `SemanticSearchResponse`
576
- - `UrlFetchRequest`, `UrlFetchResponse`, `UserAgentInfo`, `UserAgentsResponse`
577
- - `OptimizerRequest`, `OptimizerResponse`
578
-
579
- Роль моделей:
580
- - жестко фиксируют API-контракты;
581
- - упрощают валидацию;
582
- - создают стабильный интерфейс между frontend/backend.
583
-
584
- ---
585
-
586
- ## 8) Практические вычислительные принципы
587
-
588
- 1. **Согласованная нормализация**
589
- Одни и те же правила токенизации/лемматизации используются в нескольких модулях, чтобы избежать рассинхронизации метрик.
590
-
591
- 2. **Локальные правки вместо полной перегенерации**
592
- Оптимизатор меняет только локальные участки текста и проверяет эффект после каждой правки.
593
-
594
- 3. **Многокритериальная защита**
595
- Кандидат не принимается, если улучшение одной метрики достигается ценой неприемлемой деградации других.
596
-
597
- 4. **Объяснимость**
598
- Подробный лог итераций фиксирует baseline шага, кандидатов, причины отклонения и примененный вариант.
599
-
600
- ---
601
-
602
- ## 9) Рекомендации по воспроизведению приложения по документации
603
-
604
- Минимальный путь воспроизведения:
605
- 1. Поднять FastAPI-приложение с endpoint-ами из раздела 4.
606
- 2. Реализовать `logic.py` и `semantic_*` модули с описанными формулами и пайплайнами.
607
- 3. Сделать frontend с соответствующими сценариями (`runAnalysis`, `runSemanticAnalysis`, `runLlmOptimization`).
608
- 4. Добавить URL extractor и LLM optimizer как отдельные backend сервисы.
609
- 5. Проверить контракты ответов, чтобы UI-таблицы и вкладки заполнялись без адаптеров.
610
-
611
- ---
612
-
613
- ## 10) Эксплуатация и деплой (Hugging Face)
614
-
615
- - Рекомендуемый режим: lazy загрузка моделей.
616
- - При проблемах типа `Could not resolve host: huggingface.co` рассматривать как внешнюю инфраструктурную проблему DNS/egress.
617
- - Для диагностики сверять:
618
- - `repo_sha` и `runtime_sha`
619
- - `runtime_stage`
620
- - При зависании сборки использовать повторный trigger-build.
621
-
622
- ---
623
-
624
- ## 11) Smoke-check после любых изменений
625
-
626
- 1. `python -m py_compile app.py logic.py semantic_graph.py search.py optimizer.py url_fetcher.py`
627
- 2. Проверка endpoint-ов:
628
- - `/analyze`
629
- - `/api/v1/semantic/analyze`
630
- - `/api/v1/semantic/search`
631
- - `/api/v1/url/user-agents`
632
- - `/api/v1/url/fetch`
633
- - `/api/v1/optimizer/run`
634
- 3. Проверка UI:
635
- - табы рендерятся;
636
- - сортировки и таблицы работают;
637
- - URL import заполняет text/title;
638
- - save/load/new project работают;
639
- - оптимизатор пишет лог и применяет текст.
640
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/HF_SES_AND_UI.md DELETED
@@ -1,28 +0,0 @@
1
- # Почему на Hugging Face «умирали» все кнопки (версии с прогресс-баром)
2
-
3
- По **фактам из консоли** (не гипотезы):
4
-
5
- ## 1. `SES_UNCAUGHT_EXCEPTION: SyntaxError: missing : in conditional expression`
6
-
7
- На странице Space подключается **SES lockdown** (`lockdown-install.js`). Его парсер/конвейер для кода страницы **не эквивалентен** последнему движку Firefox/Chrome в части синтаксиса ES2020+.
8
-
9
- - Операторы **`??` (nullish coalescing)** и **`?.` (optional chaining)** в **одном исходнике** с большим inline-`<script>` давали ошибку разбора, интерпретируемую как **сломанный тернарный `? … :`** → *missing ':'*.
10
- - После этой ошибки **весь** клиентский скрипт приложения **не выполняется** → не регистрируются **ни** `onclick`, **ни** делегирование `data-app-action` → кажется, что «сломались все кнопки».
11
-
12
- **Исправление:** не использовать `??` / `?.` в коде приложения; вместо них — функция `nv(v, d)` и проверки `obj && obj.prop`.
13
-
14
- ## 2. Глобальный `#loader`
15
-
16
- Если на время оптимизатора включать полноэкранный оверлей и по какой-то причине не снять `display` в `finally`, **все клики** уходят в оверлей. Это уже не SES, а логика UI.
17
-
18
- **Исправление:** во время LLM-оптимизации **не** трогать `#loader`; прогресс только в **локальной панели** под кнопками.
19
-
20
- ## 3. Строки CSP про `inpage.js`, Stripe, `content.js`
21
-
22
- В логе часто идут **расширения браузера** и iframe HF, а не ваш `index.html`. На диагностику кнопок приложения они обычно не влияют.
23
-
24
- ## Текущая схема (после правок)
25
-
26
- - Логика UI в **`/static/js/app.js`** (отдельный файл, не гигантский inline).
27
- - Прогресс оптимизатора: **панель с `<pre>`-логом** + тонкая полоса; **без** блокировки всего экрана.
28
- - Поток **`/api/v1/optimizer/run-stream`** (SSE) + **`/api/v1/optimizer/cancel`** для остановки с частичным результатом.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/TEXT_OPTIMIZER_PRINCIPLES.md DELETED
@@ -1,99 +0,0 @@
1
- # Text Optimizer Principles
2
-
3
- This document is a living spec for iterative text optimization behavior.
4
- Update it whenever optimization policy changes.
5
-
6
- ## 1) Multi-objective optimization model
7
-
8
- - **Primary objective (by stage):**
9
- - Stage A: BERT phrase relevance
10
- - Stage B: BM25 remove cleanup
11
- - Stage C: N-gram balancing
12
- - Stage D: Semantic gap balancing
13
- - Stage E: Title alignment
14
- - **Guardrails (always active):**
15
- - Do not allow critical metric regressions beyond mode tolerances.
16
- - Keep grammar, coherence, and non-spam writing.
17
-
18
- ## 2) Stage order and skipping
19
-
20
- - Stage order:
21
- - `bert -> bm25 -> ngram -> semantic -> title`
22
- - A stage is skipped if no actionable goal exists.
23
- - Plateau rule:
24
- - If no primary progress for 3 steps, move to next stage.
25
-
26
- ## 3) BERT stage policy
27
-
28
- - Default Stage A threshold: `0.70`.
29
- - User may set custom threshold via UI (`BERT target A-stage`), e.g. `0.61`.
30
- - Stage A is complete when max target phrase score reaches configured threshold.
31
-
32
- ## 4) BM25 stage policy
33
-
34
- - Main target: reduce/remove over-optimization signals.
35
- - A stage is considered healthy when `bm25_remove_count <= 3`.
36
-
37
- ## 5) N-gram stage policy (quantitative)
38
-
39
- - Goal: bring target counts closer to competitor average, not force exact equality.
40
- - Tolerance bands:
41
- - if `avg >= 4`: acceptable range is `avg +/- 20%`
42
- - if `avg < 4`: acceptable range is `avg +/- 50%`
43
- - N-gram signal is counted only when term is outside tolerance and present in enough competitors.
44
- - Selection rules (multi-competitor mode, `competitors > 1`):
45
- - bi-grams and tri-grams are eligible when present in `>= 2` competitors;
46
- - unigrams are eligible only if they are part of user keyword phrases and present in `>= 2` competitors.
47
- - Target ranking (which n-gram to work on next):
48
- - sort eligible **underrepresented** rows by **Freq(K)** (`comp_occurrence`) descending,
49
- then **Avg(K)** (`competitor_avg`) descending,
50
- then **deviation** from competitor average descending (larger gap first).
51
- - Iteration behavior:
52
- - optimizer works on one n-gram target at a time per step;
53
- - per eligible n-gram target it allocates `3` attempts, then moves to the next target;
54
- - if target list ends, stage advances to the next optimization stage.
55
- - **Global step budget:** the UI `max_iterations` cap still limits total loop iterations, but the
56
- optimizer **adds** extra steps reserved for the n-gram stage (`targets × 3`, capped) so a low
57
- `max_iterations` value does not stop the run after only three n-gram rows while many targets remain.
58
- - **Chunk selection (n-gram stage):** candidate sentences are ranked using **overlapping multi-sentence
59
- windows** (stride 1). Each sentence receives the best window score; windows favor low local phrase
60
- duplication, topical overlap with phrase tokens, and non-noisy prose. Document-level phrase count
61
- remains the primary acceptance signal.
62
-
63
- ## 5.1 Summary logic memory (current)
64
-
65
- - Summary recommendation triggers:
66
- - BERT warning when phrase score `< 0.70`;
67
- - BM25 warning when `REMOVE >= 4`;
68
- - N-gram warning when term is underrepresented among competitors;
69
- - Title warning when Title BERT `< 0.65`;
70
- - Semantic warning when keyword terms are weaker than competitor average.
71
- - For N-grams in summary:
72
- - summary renders top rows for readability, but optimizer runs against the full eligible candidate set.
73
-
74
- ## 6) Local acceptance and batch accumulation
75
-
76
- - First evaluate candidate locally (chunk-level), then globally (document-level).
77
- - Locally improved candidates may be queued when global score does not move yet.
78
- - Non-conflicting queued edits can be applied as a batch (2-4 edits) if guardrails pass.
79
-
80
- ## 7) Text quality constraints
81
-
82
- - Reject candidates with:
83
- - duplicated entities/words,
84
- - suspicious token joins,
85
- - excessive sentence count for current cascade level,
86
- - obvious stuffing/redundancy.
87
- - Keep narrative continuity and original subject/entity focus.
88
-
89
- ## 8) Diagnostics requirements
90
-
91
- - For every iteration, store:
92
- - stage, goal, cascade level,
93
- - candidate validity, local improvement, metric deltas,
94
- - selected strategy and prompt debug payload.
95
- - UI must show:
96
- - stage progression,
97
- - stage transitions,
98
- - candidate strategy and reason for rejection.
99
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
highlighter.py DELETED
@@ -1,63 +0,0 @@
1
- from typing import Dict, List, Any
2
-
3
-
4
- def generate_markup_for_frontend(
5
- sentences_data: List[Dict[str, Any]],
6
- word_weights: Dict[str, int],
7
- threshold: int = 50,
8
- ) -> List[Dict[str, Any]]:
9
- """
10
- Генерирует разметку текста для фронтенда.
11
- Возвращает массив предложений, где каждое предложение состоит из блоков:
12
- - обычный текст
13
- - кликабельное понятие (is_link = True)
14
- """
15
- output: List[Dict[str, Any]] = []
16
-
17
- for sent in sentences_data:
18
- blocks: List[Dict[str, Any]] = []
19
- buffer_text = ""
20
-
21
- for tok in sent.get("tokens", []):
22
- text_piece = f"{tok.get('text', '')}{tok.get('whitespace', '')}"
23
- lemma = tok.get("lemma", "")
24
- is_link = bool(tok.get("is_significant")) and word_weights.get(lemma, 0) >= threshold
25
-
26
- if is_link:
27
- if buffer_text:
28
- blocks.append({"text": buffer_text, "is_link": False})
29
- buffer_text = ""
30
- # Склеиваем подряд идущие значимые токены в одну "понятийную" фразу,
31
- # чтобы подсветка выглядела как словосочетание, а не набор одиночных слов.
32
- if blocks and blocks[-1].get("is_link"):
33
- blocks[-1]["text"] += text_piece
34
- blocks[-1]["lemmas"].append(lemma)
35
- blocks[-1]["weight"] = max(
36
- int(blocks[-1].get("weight", 0)),
37
- int(word_weights.get(lemma, 0)),
38
- )
39
- else:
40
- blocks.append(
41
- {
42
- "text": text_piece,
43
- "is_link": True,
44
- "lemmas": [lemma],
45
- "weight": int(word_weights.get(lemma, 0)),
46
- }
47
- )
48
- else:
49
- buffer_text += text_piece
50
-
51
- if buffer_text:
52
- blocks.append({"text": buffer_text, "is_link": False})
53
-
54
- output.append(
55
- {
56
- "index": sent.get("index", 0),
57
- "raw_text": sent.get("raw_text", ""),
58
- "blocks": blocks,
59
- "lemmas_clean": sent.get("lemmas_clean", []),
60
- }
61
- )
62
-
63
- return output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
logic.py CHANGED
@@ -18,9 +18,7 @@ MODEL_NAMES = {
18
  "ru": "ru_core_news_sm",
19
  "de": "de_core_news_sm",
20
  "es": "es_core_news_sm",
21
- "it": "it_core_news_sm",
22
- "pl": "pl_core_news_sm",
23
- "pt": "pt_core_news_sm",
24
  }
25
 
26
  # --- SPACY (Лингвистика) ---
 
18
  "ru": "ru_core_news_sm",
19
  "de": "de_core_news_sm",
20
  "es": "es_core_news_sm",
21
+ "it": "it_core_news_sm"
 
 
22
  }
23
 
24
  # --- SPACY (Лингвистика) ---
main.py CHANGED
@@ -25,7 +25,7 @@ async def startup_event():
25
  @app.get("/", response_class=HTMLResponse)
26
  async def read_root(request: Request):
27
  # Рендерим файл index.html
28
- return templates.TemplateResponse(request, "index.html")
29
 
30
  @app.post("/analyze", response_model=AnalysisResponse)
31
  async def analyze_text(request: AnalysisRequest):
 
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):
models.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, Field
2
  from typing import List, Dict, Optional, Any
3
 
4
 
@@ -10,128 +10,13 @@ class AnalysisRequest(BaseModel):
10
  target_text: str # Текст пользователя
11
  competitors: List[str] # Список текстов конкурентов
12
  keywords: List[str] # Список ключевых фраз (сырых)
13
- language: str = "en" # en, ru, de, es, it, pl, pt
14
  target_title: str = "" # Title пользователя
15
- competitor_titles: List[str] = Field(default_factory=list) # Title конкурентов
16
 
17
  class AnalysisResponse(BaseModel):
18
  ngram_stats: dict # Статистика униграм/биграм
19
  bm25_recommendations: List[dict] # Рекомендации "добавить/убрать"
20
  bert_analysis: Dict[str, Any] # Векторный анализ
21
  word_counts: Dict[str, Any] # {'target': 500, 'competitors': [600, 450], 'avg': 525}
22
- title_analysis: Dict[str, Any] = Field(default_factory=dict) # Анализ Title
23
-
24
-
25
- class SemanticAnalyzeRequest(BaseModel):
26
- text: str
27
- competitors: List[str] = Field(default_factory=list)
28
- language: str = "ru"
29
- threshold: int = 50
30
- compression_ratio: float = 0.1
31
-
32
-
33
- class SemanticAnalyzeResponse(BaseModel):
34
- target: Dict[str, Any]
35
- competitors: List[Dict[str, Any]]
36
- comparison: Dict[str, Any]
37
-
38
-
39
- class SemanticSearchRequest(BaseModel):
40
- query_text: str
41
- text: str
42
- language: str = "ru"
43
- top_n: int = 20
44
-
45
-
46
- class SemanticSearchResponse(BaseModel):
47
- results: List[Dict[str, Any]]
48
-
49
-
50
- class UrlFetchRequest(BaseModel):
51
- url: str
52
- user_agent: str = "chrome_desktop"
53
- timeout_seconds: int = 15
54
-
55
-
56
- class UrlFetchResponse(BaseModel):
57
- ok: bool = False
58
- url: str = ""
59
- final_url: str = ""
60
- status_code: int = 0
61
- user_agent_key: str = ""
62
- user_agent_value: str = ""
63
- title: str = ""
64
- text: str = ""
65
- error: str = ""
66
-
67
-
68
- class UserAgentInfo(BaseModel):
69
- key: str
70
- name: str
71
- value: str
72
-
73
-
74
- class UserAgentsResponse(BaseModel):
75
- user_agents: List[UserAgentInfo] = Field(default_factory=list)
76
-
77
-
78
- class OptimizerRequest(BaseModel):
79
- target_text: str
80
- competitors: List[str] = Field(default_factory=list)
81
- keywords: List[str] = Field(default_factory=list)
82
- language: str = "en"
83
- target_title: str = ""
84
- competitor_titles: List[str] = Field(default_factory=list)
85
- # Base for highlighting what changed in this optimization run.
86
- # - diff_from_input: compare with `target_text` passed in this request (snapshot before optimization)
87
- # - diff_from_original: compare with `original_target_text` from the first snapshot in session
88
- diff_mode: str = "diff_from_input" # diff_from_input | diff_from_original
89
- original_target_text: Optional[str] = None
90
- original_target_title: Optional[str] = None
91
-
92
- api_key: str
93
- api_base_url: str = "https://api.deepseek.com/v1"
94
- model: str = "deepseek-chat"
95
-
96
- max_iterations: int = 2
97
- candidates_per_iteration: int = 2
98
- temperature: float = 0.25
99
- optimization_mode: str = "balanced"
100
- phrase_strategy_mode: str = "auto" # auto | exact_preferred | distributed_preferred | ensemble
101
- bert_stage_target: float = 0.70
102
- # Optional stage control. If empty -> default full pipeline order.
103
- enabled_stages: List[str] = Field(default_factory=list) # bert|bm25|ngram|semantic|title
104
- # Per-stage manual goal selection and custom additions.
105
- # Example:
106
- # {
107
- # "bm25": {"mode":"mixed","selected":["canadian online casino"],"custom_add":["online casinos canada"]},
108
- # "bert": {"mode":"manual","selected":["best payout casinos"],"custom_add":[]}
109
- # }
110
- stage_goal_overrides: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
111
-
112
-
113
- class OptimizerResponse(BaseModel):
114
- ok: bool = True
115
- optimized_text: str = ""
116
- optimized_title: str = ""
117
- baseline_metrics: Dict[str, Any] = Field(default_factory=dict)
118
- final_metrics: Dict[str, Any] = Field(default_factory=dict)
119
- iterations: List[Dict[str, Any]] = Field(default_factory=list)
120
- applied_changes: int = 0
121
- optimization_mode: str = "balanced"
122
- phrase_strategy_mode: str = "auto"
123
- bert_stage_target: float = 0.70
124
- diff_mode: str = ""
125
- # HTML with <mark class="diff-changed"> around changed parts.
126
- diff_body_html: str = ""
127
- diff_title_html: str = ""
128
- # List of (type/from/to) blocks for "что именно поменять".
129
- diff_changes: List[Dict[str, str]] = Field(default_factory=list)
130
- diff_title_changes: List[Dict[str, str]] = Field(default_factory=list)
131
- error: str = ""
132
- stopped_early: bool = False
133
- stop_reason: str = ""
134
-
135
-
136
- class OptimizerCancelRequest(BaseModel):
137
- job_id: str = Field(..., min_length=8)
 
1
+ from pydantic import BaseModel
2
  from typing import List, Dict, Optional, Any
3
 
4
 
 
10
  target_text: str # Текст пользователя
11
  competitors: List[str] # Список текстов конкурентов
12
  keywords: List[str] # Список ключевых фраз (сырых)
13
+ language: str = "en" # en, ru, de, es, it
14
  target_title: str = "" # Title пользователя
15
+ competitor_titles: List[str] = [] # Title конкурентов
16
 
17
  class AnalysisResponse(BaseModel):
18
  ngram_stats: dict # Статистика униграм/биграм
19
  bm25_recommendations: List[dict] # Рекомендации "добавить/убрать"
20
  bert_analysis: Dict[str, Any] # Векторный анализ
21
  word_counts: Dict[str, Any] # {'target': 500, 'competitors': [600, 450], 'avg': 525}
22
+ title_analysis: Dict[str, Any] = {} # Анализ Title
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nlp_processor.py DELETED
@@ -1,51 +0,0 @@
1
- from typing import Dict, List, Any
2
-
3
- import logic
4
-
5
-
6
- def preprocess_text(text: str, lang: str) -> List[Dict[str, Any]]:
7
- """
8
- Разбирает текст на предложения и токены.
9
- Сохраняет:
10
- - исходный текст токена (text)
11
- - пробел после токена (whitespace)
12
- - лемму (lemma)
13
- - флаг значимости (is_significant)
14
- """
15
- if not text or not text.strip():
16
- return []
17
-
18
- doc = logic.get_doc(text, lang)
19
- result: List[Dict[str, Any]] = []
20
-
21
- for idx, sent in enumerate(doc.sents):
22
- tokens: List[Dict[str, Any]] = []
23
- lemmas_clean: List[str] = []
24
-
25
- for t in sent:
26
- lemma = t.lemma_.lower().strip() if t.lemma_ else t.text.lower().strip()
27
- is_significant = bool(logic.is_valid_token(t))
28
-
29
- token_data = {
30
- "text": t.text,
31
- "whitespace": t.whitespace_,
32
- "lemma": lemma,
33
- "is_significant": is_significant,
34
- "is_punct": bool(t.is_punct),
35
- "is_space": bool(t.is_space),
36
- }
37
- tokens.append(token_data)
38
-
39
- if is_significant and lemma:
40
- lemmas_clean.append(lemma)
41
-
42
- result.append(
43
- {
44
- "index": idx,
45
- "raw_text": sent.text,
46
- "tokens": tokens,
47
- "lemmas_clean": lemmas_clean,
48
- }
49
- )
50
-
51
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
optimizer.py DELETED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -8,7 +8,4 @@ sentence-transformers>=2.2.0
8
  spacy>=3.7.0
9
  python-multipart>=0.0.6
10
  jinja2>=3.1.0
11
- torch>=2.0.0
12
- networkx>=3.2.0
13
- requests>=2.31.0
14
- beautifulsoup4>=4.12.0
 
8
  spacy>=3.7.0
9
  python-multipart>=0.0.6
10
  jinja2>=3.1.0
11
+ torch>=2.0.0
 
 
 
search.py DELETED
@@ -1,96 +0,0 @@
1
- from typing import Dict, List, Any
2
-
3
- import logic
4
- import networkx as nx
5
-
6
-
7
- def _normalize_query_text(text: str) -> str:
8
- return " ".join((text or "").lower().replace("ё", "е").replace("-", " ").split())
9
-
10
-
11
- def semantic_search(
12
- query_text: str,
13
- graph: nx.DiGraph,
14
- word_weights: Dict[str, int],
15
- lang: str,
16
- top_n: int = 20,
17
- ) -> List[Dict[str, Any]]:
18
- """
19
- Смысловой поиск по графу:
20
- - лемматизация запроса
21
- - добавление соседей (successors)
22
- - score = сила связи + вес узла
23
- """
24
- if not query_text.strip() or graph.number_of_nodes() == 0:
25
- return []
26
-
27
- query_text_norm = _normalize_query_text(query_text)
28
- query_lemmas = logic.get_lemmas_flat(query_text, lang)
29
- # Фразовые кандидаты для точного phrase-match в графе.
30
- phrase_terms: List[str] = []
31
- for n in (3, 2):
32
- phrase_terms.extend(logic.generate_ngrams_safe(query_text_norm, lang, n))
33
-
34
- # Удаляем дубли с сохранением порядка.
35
- phrase_terms = list(dict.fromkeys([_normalize_query_text(p) for p in phrase_terms if p.strip()]))
36
- query_lemmas = list(dict.fromkeys([_normalize_query_text(q) for q in query_lemmas if q.strip()]))
37
-
38
- if not query_lemmas and not phrase_terms:
39
- return []
40
-
41
- candidates: Dict[str, float] = {}
42
- candidate_type: Dict[str, str] = {}
43
-
44
- # 1) Сначала фразовый поиск.
45
- for p in phrase_terms:
46
- if p not in graph:
47
- continue
48
- base = float(word_weights.get(p, 0))
49
- candidates[p] = max(candidates.get(p, 0.0), base)
50
- candidate_type[p] = "phrase"
51
-
52
- for neigh in graph.successors(p):
53
- edge_data = graph.get_edge_data(p, neigh) or {}
54
- edge_weight = float(edge_data.get("weight", 0.0))
55
- node_weight = float(word_weights.get(neigh, 0))
56
- score = edge_weight * 0.65 + node_weight * 0.35
57
- if score > candidates.get(neigh, 0.0):
58
- candidates[neigh] = score
59
- candidate_type[neigh] = "phrase" if " " in neigh else "word"
60
-
61
- # 2) Потом словный fallback/расширение.
62
- for q in query_lemmas:
63
- if q not in graph:
64
- continue
65
-
66
- # Само понятие запроса.
67
- candidates[q] = max(candidates.get(q, 0.0), float(word_weights.get(q, 0)))
68
- if q not in candidate_type:
69
- candidate_type[q] = "word"
70
-
71
- for neigh in graph.successors(q):
72
- edge_data = graph.get_edge_data(q, neigh) or {}
73
- edge_weight = float(edge_data.get("weight", 0.0))
74
- node_weight = float(word_weights.get(neigh, 0))
75
- score = edge_weight * 0.6 + node_weight * 0.4
76
- if score > candidates.get(neigh, 0.0):
77
- candidates[neigh] = score
78
- candidate_type[neigh] = "phrase" if " " in neigh else "word"
79
-
80
- ranked = sorted(candidates.items(), key=lambda x: x[1], reverse=True)[:top_n]
81
-
82
- if not ranked:
83
- return []
84
-
85
- max_score = ranked[0][1]
86
- min_score = ranked[-1][1]
87
- denom = max(1e-9, max_score - min_score)
88
-
89
- return [
90
- {
91
- "lemma": lemma,
92
- "score": int(round(1 + ((score - min_score) / denom) * 99)),
93
- "type": candidate_type.get(lemma, "phrase" if " " in lemma else "word"),
94
- }
95
- for lemma, score in ranked
96
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
semantic_graph.py DELETED
@@ -1,304 +0,0 @@
1
- from collections import Counter, defaultdict
2
- from typing import Dict, List, Any, Tuple
3
- import math
4
-
5
- import networkx as nx
6
- import logic
7
-
8
-
9
- def _normalize_to_1_100(values: Dict[str, float]) -> Dict[str, int]:
10
- if not values:
11
- return {}
12
- v_min = min(values.values())
13
- v_max = max(values.values())
14
- if v_max == v_min:
15
- return {k: 50 for k in values.keys()}
16
- return {
17
- k: int(round(1 + ((v - v_min) / (v_max - v_min)) * 99))
18
- for k, v in values.items()
19
- }
20
-
21
-
22
- def _extract_significant_lemmas(sent: Dict[str, Any]) -> List[str]:
23
- lemmas: List[str] = []
24
- for tok in sent.get("tokens", []):
25
- if tok.get("is_significant") and tok.get("lemma"):
26
- lemmas.append(str(tok["lemma"]))
27
- return lemmas
28
-
29
-
30
- NOISE_PATTERNS = (
31
- "contact us",
32
- "responsible gaming",
33
- "play responsibly",
34
- "copyright",
35
- "terms of use",
36
- "new customers only",
37
- "t&c apply",
38
- "18+",
39
- "info@",
40
- )
41
-
42
-
43
- GENERIC_TERMS = {
44
- "игра", "играть", "казино", "ставка", "деньга", "деньги",
45
- "демо", "режим", "уровень", "шаг", "выигрыш", "риск",
46
- "game", "play", "casino", "bet", "demo", "mode", "level", "risk", "win",
47
- }
48
-
49
-
50
- def _is_noise_sentence(text: str) -> bool:
51
- t = " ".join((text or "").lower().split())
52
- if not t:
53
- return True
54
- if any(p in t for p in NOISE_PATTERNS):
55
- return True
56
- # Частые CTA/служебные короткие строки.
57
- if len(t.split()) <= 3 and t in {"играть", "play", "chicken road", "chicken road game"}:
58
- return True
59
- return False
60
-
61
-
62
- def _canonicalize_term(term: str) -> str:
63
- t = " ".join((term or "").lower().replace("ё", "е").replace("-", " ").split())
64
- if not t:
65
- return t
66
-
67
- # Бренд/наименование игры.
68
- if t in {"чикен роад", "chicken road", "chickenroad", "chiken road"}:
69
- return "chicken road"
70
- if t in {"игры inout", "inout games", "inout game", "inout"}:
71
- return "inout games"
72
-
73
- # Полезная нормализация русской фразы под один термин.
74
- if t in {"реальные деньги", "реальный деньги"}:
75
- return "реальные деньги"
76
- return t
77
-
78
-
79
- def _extract_phrase_candidates(sentence_text: str, lang: str) -> List[str]:
80
- """
81
- Извлекает фразовые кандидаты через существующую n-gram логику проекта,
82
- чтобы сохранить естественные сочетания со стоп-словами внутри.
83
- """
84
- candidates: List[str] = []
85
- for n in (2, 3):
86
- candidates.extend(logic.generate_ngrams_safe(sentence_text, lang, n))
87
- return candidates
88
-
89
-
90
- def _normalize_lemma_sequence(lemmas: List[str]) -> List[str]:
91
- """
92
- Убирает подряд идущие дубликаты, чтобы не рождать шумные
93
- термины вроде "дорожка дорожка" из технических повторов.
94
- """
95
- if not lemmas:
96
- return []
97
- normalized = [lemmas[0]]
98
- for item in lemmas[1:]:
99
- if item != normalized[-1]:
100
- normalized.append(item)
101
- return normalized
102
-
103
-
104
- def build_semantic_graph(
105
- sentences_data: List[Dict[str, Any]],
106
- window_size: int = 5,
107
- min_phrase_freq: int = 2,
108
- lang: str = "ru",
109
- ) -> Tuple[nx.DiGraph, Dict[str, int]]:
110
- """
111
- Строит направленный граф из лемм и считает веса:
112
- - edge_weight: условная вероятность P(B|A) * 100
113
- - node_weight: нормализованный PageRank 1..100
114
-
115
- Версия v5:
116
- - связи считаются в локальном скользящем окне (а не "все со всеми")
117
- - условная вероятность считается строго как cooc(a,b)/occ(a), поэтому <= 100
118
- - узлы графа: слова + устойчивые словосочетания (bi/tri-grams)
119
- - словосочетания извлекаются через Smart Window (как в основной логике n-gram)
120
- - повышаем вклад устойчивых фраз в итоговый смысловой вес понятия
121
- """
122
- term_occ = Counter()
123
- pair_cooc = defaultdict(int)
124
- phrase_occ = Counter()
125
- phrase_sent_ids = defaultdict(set)
126
- term_sent_ids = defaultdict(set)
127
- sentence_words: List[List[str]] = []
128
- sentence_phrases: List[List[str]] = []
129
-
130
- for sent_id, sent in enumerate(sentences_data):
131
- raw_text = sent.get("raw_text", "")
132
- if _is_noise_sentence(raw_text):
133
- sentence_words.append([])
134
- sentence_phrases.append([])
135
- continue
136
-
137
- lemmas_raw = _extract_significant_lemmas(sent)
138
- lemmas = [_canonicalize_term(x) for x in _normalize_lemma_sequence(lemmas_raw)]
139
- lemmas = [x for x in lemmas if x]
140
- sentence_words.append(lemmas)
141
-
142
- phrase_candidates = _extract_phrase_candidates(raw_text, lang)
143
- # Фильтр мусора фраз: минимум 2 слова, без дублирующегося подряд слова.
144
- clean_phrases = []
145
- for p in phrase_candidates:
146
- parts = [x.strip() for x in p.split() if x.strip()]
147
- if len(parts) < 2:
148
- continue
149
- bad_repeat = any(parts[i] == parts[i + 1] for i in range(len(parts) - 1))
150
- if bad_repeat:
151
- continue
152
- clean_phrases.append(_canonicalize_term(" ".join(parts)))
153
- sentence_phrases.append(clean_phrases)
154
-
155
- if not lemmas:
156
- continue
157
-
158
- # Для каждого вхождения a считаем его локальный контекст в окне.
159
- # cooc(a,b) увеличивается максимум на 1 на одно вхождение a,
160
- # что гарантирует cooc(a,b) <= occ(a) и P(B|A) <= 100.
161
- for i, a in enumerate(lemmas):
162
- term_occ[a] += 1
163
- left = max(0, i - max(1, window_size))
164
- right = min(len(lemmas), i + max(1, window_size) + 1)
165
- neighbors = set()
166
- for j in range(left, right):
167
- if j == i:
168
- continue
169
- b = lemmas[j]
170
- if not b or a == b:
171
- continue
172
- neighbors.add(b)
173
- for b in neighbors:
174
- pair_cooc[(a, b)] += 1
175
- for w in set(lemmas):
176
- term_sent_ids[w].add(sent_id)
177
-
178
- # Частоты/охват фраз по предложениям.
179
- for sent_id, phrases in enumerate(sentence_phrases):
180
- for phrase in phrases:
181
- phrase_occ[phrase] += 1
182
- phrase_sent_ids[phrase].add(sent_id)
183
-
184
- # Оставляем только устойчивые фразы.
185
- allowed_phrases = {
186
- p for p, c in phrase_occ.items()
187
- if c >= max(1, min_phrase_freq) and len(phrase_sent_ids[p]) >= max(1, min_phrase_freq)
188
- }
189
-
190
- # Добавляем связи phrase <-> word на уровне предложения.
191
- for sent_id, words in enumerate(sentence_words):
192
- if not words:
193
- continue
194
- phrases = [p for p in sentence_phrases[sent_id] if p in allowed_phrases]
195
- if not phrases:
196
- continue
197
- uniq_words = set(words)
198
- uniq_phrases = set(phrases)
199
-
200
- for phrase in uniq_phrases:
201
- term_occ[phrase] += 1
202
- term_sent_ids[phrase].add(sent_id)
203
- parts = set(phrase.split())
204
- # Связываем фразу с ее компонентами всегда (ядро термина).
205
- for w in parts:
206
- pair_cooc[(phrase, w)] += 1
207
- pair_cooc[(w, phrase)] += 1
208
- # И с остальными словами предложения (контекст термина).
209
- for w in uniq_words:
210
- if w in parts:
211
- continue
212
- pair_cooc[(phrase, w)] += 1
213
- pair_cooc[(w, phrase)] += 1
214
-
215
- graph = nx.DiGraph()
216
-
217
- for term, freq in term_occ.items():
218
- term_type = "phrase" if " " in term else "word"
219
- graph.add_node(term, frequency=int(freq), term_type=term_type)
220
-
221
- for (a, b), cooc in pair_cooc.items():
222
- base = term_occ.get(a, 0)
223
- if base <= 0:
224
- continue
225
- weight = (cooc / base) * 100.0
226
- weight = max(0.0, min(100.0, weight))
227
- if weight > 0:
228
- graph.add_edge(a, b, weight=round(weight, 3))
229
-
230
- if graph.number_of_nodes() == 0:
231
- return graph, {}
232
-
233
- try:
234
- pr = nx.pagerank(graph, weight="weight")
235
- except Exception:
236
- pr = {node: 1.0 for node in graph.nodes()}
237
-
238
- # Комбинируем centrality с termness-фактором.
239
- # Для слов: умеренный буст по контекстной связанности.
240
- # Для фраз: более сильный буст по частоте/охвату, чтобы устойчивые термины поднимались в топ.
241
- combined_scores = {}
242
- total_sent = max(1, len(sentence_words))
243
- for node, score in pr.items():
244
- out_deg = graph.out_degree(node)
245
- in_deg = graph.in_degree(node)
246
- connectivity_factor = 1.0 + 0.025 * (out_deg + in_deg)
247
- sent_df = len(term_sent_ids.get(node, set()))
248
- idf_factor = 1.0 + 0.35 * math.log((1.0 + total_sent) / (1.0 + max(1, sent_df)))
249
-
250
- if graph.nodes[node].get("term_type") == "phrase":
251
- freq = max(1, int(graph.nodes[node].get("frequency", 1)))
252
- sent_cover = len(phrase_sent_ids.get(node, set()))
253
- termness = (1.0 + 0.22 * math.log1p(freq)) * (1.0 + 0.12 * math.log1p(sent_cover))
254
- contains_generic = any(part in GENERIC_TERMS for part in node.split())
255
- generic_penalty = 0.85 if contains_generic else 1.0
256
- combined_scores[node] = score * connectivity_factor * termness * idf_factor * generic_penalty
257
- else:
258
- generic_penalty = 0.58 if node in GENERIC_TERMS else 1.0
259
- combined_scores[node] = score * connectivity_factor * idf_factor * generic_penalty
260
-
261
- node_weights = _normalize_to_1_100(combined_scores)
262
-
263
- for node, w in node_weights.items():
264
- graph.nodes[node]["weight"] = int(w)
265
-
266
- return graph, node_weights
267
-
268
-
269
- def get_graph_data_for_frontend(graph: nx.DiGraph, top_edges_per_node: int = 8) -> Dict[str, List[Dict[str, Any]]]:
270
- nodes = []
271
- links = []
272
-
273
- for node, attrs in graph.nodes(data=True):
274
- nodes.append(
275
- {
276
- "id": node,
277
- "label": node,
278
- "weight": int(attrs.get("weight", 1)),
279
- "frequency": int(attrs.get("frequency", 0)),
280
- }
281
- )
282
-
283
- for source in graph.nodes():
284
- out_edges = sorted(
285
- graph.out_edges(source, data=True),
286
- key=lambda e: e[2].get("weight", 0),
287
- reverse=True,
288
- )[:top_edges_per_node]
289
-
290
- for s, t, attrs in out_edges:
291
- links.append(
292
- {
293
- "source": s,
294
- "target": t,
295
- "weight": float(attrs.get("weight", 0)),
296
- }
297
- )
298
-
299
- return {"nodes": nodes, "links": links}
300
-
301
-
302
- def get_top_keywords(node_weights: Dict[str, int], top_n: int = 20) -> List[Dict[str, Any]]:
303
- items = sorted(node_weights.items(), key=lambda x: x[1], reverse=True)[:top_n]
304
- return [{"lemma": lemma, "weight": int(weight)} for lemma, weight in items]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/js/app.js DELETED
The diff for this file is too large to render. See raw diff
 
summarizer.py DELETED
@@ -1,41 +0,0 @@
1
- import math
2
- from typing import Dict, List, Any
3
-
4
-
5
- def generate_summary(
6
- sentences_data: List[Dict[str, Any]],
7
- word_weights: Dict[str, int],
8
- compression_ratio: float = 0.1,
9
- ) -> List[Dict[str, Any]]:
10
- """
11
- Реферат: отбирает самые значимые предложения.
12
- score = сумма весов уникальных лемм / sqrt(длина_предложения)
13
- """
14
- if not sentences_data:
15
- return []
16
-
17
- compression_ratio = max(0.05, min(0.8, compression_ratio))
18
- ranked: List[Dict[str, Any]] = []
19
-
20
- for sent in sentences_data:
21
- uniq = set(sent.get("lemmas_clean", []))
22
- if not uniq:
23
- score = 0.0
24
- else:
25
- numerator = float(sum(word_weights.get(l, 0) for l in uniq))
26
- penalty = math.sqrt(max(1, len(sent.get("tokens", []))))
27
- score = numerator / penalty
28
-
29
- ranked.append(
30
- {
31
- "index": sent.get("index", 0),
32
- "text": sent.get("raw_text", ""),
33
- "score": round(score, 3),
34
- }
35
- )
36
-
37
- ranked.sort(key=lambda x: x["score"], reverse=True)
38
- take_n = max(1, int(round(len(ranked) * compression_ratio)))
39
- chosen = ranked[:take_n]
40
- chosen.sort(key=lambda x: x["index"])
41
- return chosen
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/index.html CHANGED
@@ -9,8 +9,6 @@
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
- .diff-changed { background-color: #fff3cd; padding: 0 2px; border-radius: 4px; }
13
- .diff-unchanged { }
14
  .loading-overlay {
15
  display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
16
  background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
@@ -47,29 +45,9 @@
47
  <option value="de">🇩🇪 Deutsch</option>
48
  <option value="it">🇮🇹 Italiano</option>
49
  <option value="es">🇪🇸 Español</option>
50
- <option value="pl">🇵🇱 Polski</option>
51
- <option value="pt">🇵🇹 Português</option>
52
  </select>
53
  </div>
54
 
55
- <div class="mb-3 border rounded p-3 bg-light">
56
- <label class="form-label fw-bold mb-2">Импорт по URL (Title + текст)</label>
57
- <div class="mb-2">
58
- <label class="form-label small text-muted mb-1">User-Agent</label>
59
- <select class="form-select form-select-sm" id="urlUserAgentSelect">
60
- <option value="chrome_desktop">Chrome (Desktop)</option>
61
- </select>
62
- </div>
63
- <div class="input-group input-group-sm mb-2">
64
- <span class="input-group-text">Target URL</span>
65
- <input type="text" id="targetUrlInput" class="form-control" placeholder="https://example.com/page">
66
- <button class="btn btn-outline-primary" onclick="fetchTargetFromUrl()">Забрать</button>
67
- </div>
68
- <label class="form-label small text-muted mb-1">URL конкурентов (по одному в строке)</label>
69
- <textarea class="form-control form-control-sm mb-2" id="competitorUrlsInput" rows="3" placeholder="https://example.com/competitor-1&#10;https://example.com/competitor-2"></textarea>
70
- <button class="btn btn-sm btn-outline-primary" onclick="fetchCompetitorsFromUrls()">Забрать конкурентов</button>
71
- </div>
72
-
73
  <div class="mb-3">
74
  <label class="form-label fw-bold">Ваш текст (Target)</label>
75
  <textarea class="form-control editor-box" id="targetText" placeholder="Пишите текст здесь..."></textarea>
@@ -111,11 +89,6 @@
111
 
112
  <div class="d-grid gap-2">
113
  <button class="btn btn-primary btn-lg" onclick="runAnalysis()">⚡ Анализировать (GPU)</button>
114
- <div class="btn-group" role="group">
115
- <button class="btn btn-outline-success" onclick="saveProject()">💾 Сохранить проект</button>
116
- <button class="btn btn-outline-secondary" onclick="loadProject()">📂 Загрузить проект</button>
117
- <button class="btn btn-outline-danger" onclick="clearProject()">🗑 Новый проект</button>
118
- </div>
119
  </div>
120
  </div>
121
  </div>
@@ -142,15 +115,6 @@
142
  <li class="nav-item">
143
  <button class="nav-link" id="title-tab" data-bs-toggle="tab" data-bs-target="#titlePane" type="button">🏷️ Title</button>
144
  </li>
145
- <li class="nav-item">
146
- <button class="nav-link" id="semantic-tab" data-bs-toggle="tab" data-bs-target="#semanticPane" type="button">🧩 Semantic Core</button>
147
- </li>
148
- <li class="nav-item">
149
- <button class="nav-link" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summaryPane" type="button">✅ Сводка</button>
150
- </li>
151
- <li class="nav-item">
152
- <button class="nav-link" id="optimizer-tab" data-bs-toggle="tab" data-bs-target="#optimizerPane" type="button">🤖 LLM Optimizer</button>
153
- </li>
154
  </ul>
155
 
156
  <div class="tab-content" id="resultsContent">
@@ -223,177 +187,585 @@
223
  </div>
224
  </div>
225
 
226
- <!-- SEMANTIC CORE TAB -->
227
- <div class="tab-pane fade" id="semanticPane" role="tabpanel">
228
- <div class="stat-card">
229
- <h5 class="card-title mb-3">TextAnalyst Web Core</h5>
230
- <div class="row g-2 align-items-end mb-3">
231
- <div class="col-md-4">
232
- <label class="form-label small text-muted mb-1">Порог значимости понятий (1-100)</label>
233
- <input type="number" id="semanticThreshold" class="form-control" min="1" max="100" value="50">
234
- </div>
235
- <div class="col-md-4">
236
- <label class="form-label small text-muted mb-1">Доля предложений в реферате (0.05-0.8)</label>
237
- <input type="number" id="semanticCompression" class="form-control" min="0.05" max="0.8" step="0.05" value="0.1">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  </div>
239
- <div class="col-md-4 d-grid">
240
- <button class="btn btn-dark" onclick="runSemanticAnalysis()">Запустить Semantic Core</button>
241
  </div>
242
  </div>
243
- <p class="small text-muted mb-3">
244
- 50 = подсветка только наиболее значимых понятий. 0.1 = реферат из ~10% самых информативных предложений.
245
- </p>
246
 
247
- <div class="mb-3">
248
- <label class="form-label small text-muted mb-1">Документ для просмотра</label>
249
- <select id="semanticDocSelect" class="form-select" onchange="renderSemanticResults(semanticData)">
250
- <option value="target">Мой текст</option>
251
- </select>
252
  </div>
253
 
254
- <div class="mb-3">
255
- <label class="form-label small text-muted mb-1">Смысловой поиск</label>
256
- <div class="input-group">
257
- <input type="text" id="semanticQueryInput" class="form-control" placeholder="Введите запрос для ассоциативного поиска...">
258
- <button class="btn btn-outline-primary" onclick="runSemanticSearch()">Искать</button>
259
- </div>
260
- <div id="semanticSearchResults" class="border rounded p-2 text-break mt-2">
261
- <span class="text-muted small">Введите запрос и нажмите "Искать".</span>
262
  </div>
263
  </div>
264
- </div>
265
 
266
- <div id="semanticResultsContainer">
267
- <div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>
268
  </div>
269
  </div>
 
 
 
270
 
271
- <!-- SUMMARY TAB -->
272
- <div class="tab-pane fade" id="summaryPane" role="tabpanel">
273
- <div id="summaryResultsContainer">
274
- <div class="text-center text-muted py-5">Запустите анализ, чтобы увидеть итоговые рекомендации.</div>
275
- </div>
276
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
- <!-- OPTIMIZER TAB -->
279
- <div class="tab-pane fade" id="optimizerPane" role="tabpanel">
280
- <div class="stat-card">
281
- <h5 class="card-title mb-3">LLM Optimizer (итеративная доработка текста)</h5>
282
- <div class="row g-2">
283
- <div class="col-md-6">
284
- <label class="form-label small text-muted mb-1">API Key (пользовательский)</label>
285
- <input type="password" id="optimizerApiKey" class="form-control" placeholder="sk-...">
286
- </div>
287
- <div class="col-md-6">
288
- <label class="form-label small text-muted mb-1">Base URL (OpenAI-compatible)</label>
289
- <input type="text" id="optimizerBaseUrl" class="form-control" value="https://api.deepseek.com/v1">
290
- </div>
291
- <div class="col-md-4">
292
- <label class="form-label small text-muted mb-1">Model</label>
293
- <input type="text" id="optimizerModel" class="form-control" value="deepseek-chat">
294
- </div>
295
- <div class="col-md-3">
296
- <label class="form-label small text-muted mb-1">Итерации</label>
297
- <input type="number" id="optimizerIterations" class="form-control" min="1" max="8" value="2">
298
- </div>
299
- <div class="col-md-3">
300
- <label class="form-label small text-muted mb-1">Кандидатов/шаг</label>
301
- <input type="number" id="optimizerCandidates" class="form-control" min="1" max="5" value="2">
302
- </div>
303
- <div class="col-md-2">
304
- <label class="form-label small text-muted mb-1">Temp</label>
305
- <input type="number" id="optimizerTemp" class="form-control" min="0" max="1.2" step="0.05" value="0.25">
306
- </div>
307
- <div class="col-md-2">
308
- <label class="form-label small text-muted mb-1">BERT target A-stage</label>
309
- <input type="number" id="optimizerBertStageTarget" class="form-control" min="0" max="1" step="0.01" value="0.70">
310
  </div>
311
- <div class="col-md-3">
312
- <label class="form-label small text-muted mb-1">Режим оптимизации</label>
313
- <select id="optimizerMode" class="form-select">
314
- <option value="conservative">Conservative</option>
315
- <option value="balanced" selected>Balanced</option>
316
- <option value="aggressive">Aggressive</option>
317
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  </div>
319
- <div class="col-md-3">
320
- <label class="form-label small text-muted mb-1">
321
- Phrase Strategy
322
- <span class="text-secondary" title="Вы управляете стратегией вручную. Ensemble — опциональный режим, который циклически тестирует несколько стратегий и выбирает лучший кандидат.">ⓘ</span>
323
- </label>
324
- <select id="optimizerPhraseStrategy" class="form-select">
325
- <option value="auto" selected>Auto</option>
326
- <option value="distributed_preferred">Distributed preferred</option>
327
- <option value="exact_preferred">Exact phrase preferred</option>
328
- <option value="ensemble">Ensemble (cycle all)</option>
329
- </select>
330
- <div class="form-text">Выбор стратегии остается у пользователя.</div>
331
  </div>
332
  </div>
333
- <div class="row g-2 mt-1">
334
- <div class="col-12">
335
- <div class="border rounded p-2 bg-light">
336
- <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
337
- <span class="small fw-semibold text-secondary">Стадии и цели оптимизатора</span>
338
- <button type="button" class="btn btn-sm btn-outline-secondary" onclick="optimizerSelectAllStages(true)">Все</button>
339
- <button type="button" class="btn btn-sm btn-outline-secondary" onclick="optimizerSelectAllStages(false)">Снять</button>
340
- <button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshOptimizerStageGoalConfig()">Обновить авто-цели</button>
341
- </div>
342
- <div class="row g-2 mb-2">
343
- <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBert" data-stage="bert" checked><label class="form-check-label small" for="optStageBert">BERT</label></div></div>
344
- <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBm25" data-stage="bm25" checked><label class="form-check-label small" for="optStageBm25">BM25</label></div></div>
345
- <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageNgram" data-stage="ngram" checked><label class="form-check-label small" for="optStageNgram">N-gram</label></div></div>
346
- <div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageSemantic" data-stage="semantic" checked><label class="form-check-label small" for="optStageSemantic">Semantic</label></div></div>
347
- <div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageTitle" data-stage="title" checked><label class="form-check-label small" for="optStageTitle">Title</label></div></div>
348
- </div>
349
- <div id="optimizerStageGoalConfigContainer"></div>
350
  </div>
351
- </div>
352
- <div class="col-md-6">
353
- <label class="form-label small text-muted mb-1">Подсветка изменений (Target)</label>
354
- <select id="optimizerDiffMode" class="form-select">
355
- <option value="diff_from_input" selected>Относительно текста перед запуском</option>
356
- <option value="diff_from_original">Относительно оригинала (снимок)</option>
357
- </select>
358
- <div class="form-text">
359
- Если выбран режим «оригинал», при первом запуске автоматически сохраняется снимок текущего `Target`.
360
  </div>
361
  </div>
362
- <div class="col-md-3 d-grid align-items-end">
363
- <button type="button" class="btn btn-outline-secondary" onclick="resetOptimizerDiffOriginal()">Сбросить оригинал</button>
364
- </div>
365
- </div>
366
- <div class="d-flex gap-2 mt-3 flex-wrap align-items-center">
367
- <button type="button" class="btn btn-dark" id="btnRunLlmOpt" onclick="runLlmOptimization()">Запустить оптимизацию</button>
368
- <button type="button" class="btn btn-outline-danger" id="btnStopLlmOpt" disabled onclick="requestStopOptimizer()">Остановить</button>
369
- <button type="button" class="btn btn-outline-secondary" onclick="applyOptimizedText()">Применить в Target</button>
370
- </div>
371
- <div id="optimizerProgressPanel" class="mt-3 d-none border rounded bg-white p-3">
372
- <div class="d-flex justify-content-between align-items-center mb-2">
373
- <span class="small fw-semibold text-secondary">Ход оптимизации (лог)</span>
374
- <span id="optimizerElapsed" class="small text-muted"></span>
375
- </div>
376
- <div class="progress mb-2" style="height:6px;">
377
- <div id="optimizerProgressBar" class="progress-bar bg-dark" style="width:0%;" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
378
- </div>
379
- <pre id="optimizerRunLog" class="small mb-0 text-body" style="max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;background:#f8f9fa;border:1px solid #dee2e6;padding:8px;font-family:ui-monospace,monospace;"></pre>
380
- <p class="small text-muted mb-0 mt-2">Экран целиком не перекрывается. «Остановить» — частичный результат после завершения потока.</p>
381
  </div>
382
- <p class="small text-muted mt-2 mb-0">API key не сохраняется в проект и используется только для текущего запроса.</p>
383
  </div>
384
- <div id="optimizerResultsContainer">
385
- <div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>
386
- </div>
387
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  </div>
390
- </div>
391
- </div>
392
- </div>
 
 
 
 
393
 
394
- <input type="file" id="projectFileInput" accept=".json,application/json" style="display:none;">
 
 
 
 
 
 
 
395
 
396
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
397
- <script src="/static/js/app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  </body>
399
  </html>
 
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%;
 
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>
 
89
 
90
  <div class="d-grid gap-2">
91
  <button class="btn btn-primary btn-lg" onclick="runAnalysis()">⚡ Анализировать (GPU)</button>
 
 
 
 
 
92
  </div>
93
  </div>
94
  </div>
 
115
  <li class="nav-item">
116
  <button class="nav-link" id="title-tab" data-bs-toggle="tab" data-bs-target="#titlePane" type="button">🏷️ Title</button>
117
  </li>
 
 
 
 
 
 
 
 
 
118
  </ul>
119
 
120
  <div class="tab-content" id="resultsContent">
 
187
  </div>
188
  </div>
189
 
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
196
+ <script>
197
+ let currentData = null;
198
+
199
+ // --- ФУНКЦИИ ИНТЕРФЕЙСА ---
200
+
201
+ document.getElementById('targetTitle').addEventListener('input', function() {
202
+ const len = this.value.length;
203
+ const counter = document.getElementById('titleCharCount');
204
+ counter.textContent = len;
205
+ counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted');
206
+ });
207
+
208
+ function addCompetitorTitleField() {
209
+ const div = document.createElement('div');
210
+ div.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Ещё Title конкурента..." maxlength="200">';
211
+ document.getElementById('competitorTitlesList').appendChild(div);
212
+ }
213
+
214
+ function addCompetitorField() {
215
+ const div = document.createElement('div');
216
+ div.innerHTML = '<textarea class="form-control mb-2 competitor-input" rows="3" placeholder="Ещё конкурент..."></textarea>';
217
+ document.getElementById('competitorsList').appendChild(div);
218
+ }
219
+
220
+ async function runAnalysis() {
221
+ // Сбор данных
222
+ const targetText = document.getElementById('targetText').value;
223
+ const lang = document.getElementById('languageSelect').value;
224
+ const keywordsRaw = document.getElementById('keywordsInput').value.split('\n').filter(k => k.trim() !== '');
225
+
226
+ // Сбор конкурентов
227
+ const compInputs = document.querySelectorAll('#competitorsList textarea');
228
+ const competitors = [];
229
+ compInputs.forEach(input => {
230
+ if(input.value.trim() !== '') competitors.push(input.value);
231
+ });
232
+
233
+ if(!targetText) { alert("Введите ваш текст!"); return; }
234
+
235
+ // Title fields
236
+ const targetTitle = document.getElementById('targetTitle').value;
237
+ const compTitleInputs = document.querySelectorAll('#competitorTitlesList input');
238
+ const competitorTitles = [];
239
+ compTitleInputs.forEach(input => {
240
+ if(input.value.trim() !== '') competitorTitles.push(input.value);
241
+ });
242
+
243
+ // UI Loading
244
+ document.getElementById('loader').style.display = 'block';
245
+
246
+ const payload = {
247
+ target_text: targetText,
248
+ competitors: competitors,
249
+ keywords: keywordsRaw,
250
+ language: lang,
251
+ target_title: targetTitle,
252
+ competitor_titles: competitorTitles
253
+ };
254
+
255
+ try {
256
+ const response = await fetch('/analyze', {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify(payload)
260
+ });
261
+
262
+ if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
263
+
264
+ const data = await response.json();
265
+ currentData = data;
266
+ renderResults(data);
267
+
268
+ } catch (error) {
269
+ alert("Ошибка: " + error.message);
270
+ console.error(error);
271
+ } finally {
272
+ document.getElementById('loader').style.display = 'none';
273
+ }
274
+ }
275
+
276
+ function renderResults(data) {
277
+ // 0. General Stats Render (Word Count Dual Mode)
278
+ const statsContainer = document.getElementById('generalStats');
279
+ statsContainer.innerHTML = '';
280
+
281
+ if (data.word_counts) {
282
+ const myTotal = data.word_counts.target.total;
283
+ const mySig = data.word_counts.target.significant;
284
+
285
+ const avgTotal = data.word_counts.avg.total;
286
+ const avgSig = data.word_counts.avg.significant;
287
+
288
+ // Цвет зависит от ОБЩЕГО количества
289
+ let totalColor = 'text-dark';
290
+ if (avgTotal > 0) {
291
+ if (myTotal < avgTotal * 0.8) totalColor = 'text-danger';
292
+ else if (myTotal > avgTotal * 1.2) totalColor = 'text-success';
293
+ }
294
+
295
+ // HTML для конкурентов
296
+ let compsHtml = '';
297
+ data.word_counts.competitors.forEach((c, idx) => {
298
+ compsHtml += `
299
+ <div class="px-3 border-end text-center">
300
+ <small class="text-muted d-block mb-1">K${idx+1}</small>
301
+ <div class="fw-bold">${c.total}</div>
302
+ <div class="text-muted small" style="font-size: 0.75em;">(${c.significant})</div>
303
+ </div>`;
304
+ });
305
+
306
+ const html = `
307
+ <div class="card border-0 shadow-sm mb-4">
308
+ <div class="card-body py-3">
309
+ <div class="row align-items-center">
310
+
311
+ <!-- Мой результат -->
312
+ <div class="col-md-4 border-end">
313
+ <h6 class="text-uppercase text-muted small fw-bold mb-2">Мой текст</h6>
314
+ <div class="d-flex align-items-baseline">
315
+ <span class="display-6 fw-bold ${totalColor} me-2">${myTotal}</span>
316
+ <span class="text-muted">слов</span>
317
  </div>
318
+ <div class="text-muted small">
319
+ Значимых: <strong>${mySig}</strong>
320
  </div>
321
  </div>
 
 
 
322
 
323
+ <!-- Среднее -->
324
+ <div class="col-md-3 border-end text-center">
325
+ <h6 class="text-uppercase text-muted small fw-bold mb-2">Среднее (Рынок)</h6>
326
+ <div class="h3 fw-bold mb-0">${avgTotal}</div>
327
+ <div class="text-muted small">Значимых: ${avgSig}</div>
328
  </div>
329
 
330
+ <!-- Конкуренты -->
331
+ <div class="col-md-5">
332
+ <h6 class="text-uppercase text-muted small fw-bold mb-2 text-center">Детализация</h6>
333
+ <div class="d-flex justify-content-center align-items-center">
334
+ ${compsHtml}
 
 
 
335
  </div>
336
  </div>
 
337
 
 
 
338
  </div>
339
  </div>
340
+ </div>`;
341
+ statsContainer.innerHTML = html;
342
+ }
343
 
344
+ // 1. BERT Render (ИСПРАВЛЕННЫЙ ПОД НОВУЮ СТРУКТУРУ)
345
+ const bertContainer = document.getElementById('bertResultsContainer');
346
+ bertContainer.innerHTML = '';
347
+
348
+ // Получаем объект данных. Теперь это объект, а не массив!
349
+ const bertData = data.bert_analysis;
350
+
351
+ // Проверяем, есть ли поле detailed (список фраз)
352
+ if (!bertData || !bertData.detailed || bertData.detailed.length === 0) {
353
+ bertContainer.innerHTML = '<div class="alert alert-warning">Добавьте ключе��ые фразы для анализа.</div>';
354
+ } else {
355
+
356
+ // А. Рендерим ГЛОБАЛЬНЫЙ СЧЕТ (Global Score)
357
+ if (bertData.global_scores && bertData.global_scores.length > 0) {
358
+ let globalHtml = '<div class="card mb-4 border-primary"><div class="card-body">';
359
+ globalHtml += '<h6 class="card-title text-primary fw-bold mb-3">🏆 Общий рейтинг релевантности (Global Score)</h6>';
360
+
361
+ bertData.global_scores.forEach(gs => {
362
+ const scorePct = Math.round(gs.score * 100);
363
+ const isMe = gs.is_me;
364
+ const barColor = isMe ? 'bg-primary' : 'bg-secondary';
365
+ const rowBg = isMe ? 'bg-light border-start border-primary border-3' : '';
366
+ const nameLabel = isMe ? `<strong>${gs.name} (Вы)</strong>` : gs.name;
367
 
368
+ globalHtml += `
369
+ <div class="d-flex align-items-center mb-2 p-2 rounded ${rowBg}">
370
+ <div style="width: 150px;">${nameLabel}</div>
371
+ <div class="flex-grow-1 mx-3">
372
+ <div class="progress" style="height: 20px;">
373
+ <div class="progress-bar ${barColor}" role="progressbar" style="width: ${scorePct}%">${scorePct}%</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  </div>
375
+ </div>
376
+ <div class="fw-bold">${gs.score}</div>
377
+ </div>`;
378
+ });
379
+ globalHtml += '</div></div>';
380
+ bertContainer.insertAdjacentHTML('beforeend', globalHtml);
381
+ }
382
+
383
+ // Б. Рендерим ДЕТАЛИЗАЦИЮ (Аккордеоны)
384
+ // Итерируемся именно по .detailed, так как это массив
385
+ bertData.detailed.forEach((item, index) => {
386
+ let badgeClass = 'bg-secondary';
387
+ if(item.status === 'good') badgeClass = 'bg-success';
388
+ if(item.status === 'warning') badgeClass = 'bg-warning text-dark';
389
+ if(item.status === 'bad') badgeClass = 'bg-danger';
390
+
391
+ const collapseId = `collapseBert${index}`;
392
+
393
+ // Мои чанки
394
+ const myChunksHtml = item.my_top_chunks.map(c =>
395
+ `<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom">
396
+ <div class="small me-2">"${c.text}"</div>
397
+ <span class="badge bg-primary rounded-pill opacity-75">${c.score}</span>
398
+ </li>`
399
+ ).join('');
400
+
401
+ // Чанки конкурентов (С АТРИБУЦИЕЙ ИСТОЧНИКА)
402
+ const compChunksHtml = (item.comp_top_chunks && item.comp_top_chunks.length > 0)
403
+ ? item.comp_top_chunks.map(c =>
404
+ `<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom list-group-item-light">
405
+ <div class="me-2">
406
+ <span class="badge bg-secondary mb-1" style="font-size: 0.7em;">${c.source}</span>
407
+ <div class="small text-muted">"${c.text}"</div>
408
  </div>
409
+ <span class="badge bg-dark rounded-pill opacity-50">${c.score}</span>
410
+ </li>`
411
+ ).join('')
412
+ : '<li class="list-group-item text-muted small border-0">Нет данных</li>';
413
+
414
+ const html = `
415
+ <div class="card mb-3 border">
416
+ <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}">
417
+ <div>
418
+ <div class="fw-bold text-dark">${item.phrase}</div>
419
+ <div class="text-muted small">
420
+ My: <b>${item.my_max_score}</b> vs Best Comp: <b>${item.comp_max_score}</b>
421
  </div>
422
  </div>
423
+ <span class="badge ${badgeClass}">${item.status.toUpperCase()}</span>
424
+ </div>
425
+
426
+ <div id="${collapseId}" class="collapse">
427
+ <div class="card-body bg-light">
428
+ <p class="small mb-3"><strong>Совет:</strong> ${item.recommendation}</p>
429
+ <div class="row">
430
+ <div class="col-md-6">
431
+ <h6 class="small fw-bold text-primary">Мой текст</h6>
432
+ <ul class="list-group shadow-sm mb-3">${myChunksHtml || '<li class="list-group-item small">Нет вхождений</li>'}</ul>
 
 
 
 
 
 
 
433
  </div>
434
+ <div class="col-md-6 border-start">
435
+ <h6 class="small fw-bold text-secondary">Лучшее у конкурентов</h6>
436
+ <ul class="list-group shadow-sm">${compChunksHtml}</ul>
 
 
 
 
 
 
437
  </div>
438
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  </div>
 
440
  </div>
441
+ </div>`;
442
+ bertContainer.insertAdjacentHTML('beforeend', html);
443
+ });
444
+ }
445
+
446
+ // 2. BM25 Render (ОБНОВЛЕННЫЙ v2 - Полный список)
447
+ const bm25Body = document.getElementById('bm25TableBody');
448
+ bm25Body.innerHTML = '';
449
+ const bm25Msg = document.getElementById('bm25EmptyMsg');
450
+
451
+ // Теперь мы ожидаем, что список не пуст, если были введены ключи
452
+ if (data.bm25_recommendations && data.bm25_recommendations.length > 0) {
453
+ bm25Msg.style.display = 'none';
454
+
455
+ data.bm25_recommendations.forEach(item => {
456
+ let colorClass = '';
457
+ let actionText = '';
458
+ let countText = '';
459
+ let rowBg = '';
460
+
461
+ // Определяем стили в зависимости от действия
462
+ if (item.action === 'add') {
463
+ colorClass = 'text-success';
464
+ actionText = 'ДОБАВИТЬ';
465
+ countText = `+${item.count}`;
466
+ rowBg = 'table-success'; // Легкая зеленая подсветка всей строки (Bootstrap класс)
467
+ // Но лучше не красить всю строку, чтобы не рябило, покрасим только текст действия
468
+ rowBg = '';
469
+ } else if (item.action === 'remove') {
470
+ colorClass = 'text-danger';
471
+ actionText = 'УБРАТЬ';
472
+ countText = `-${item.count}`;
473
+ } else {
474
+ colorClass = 'text-muted'; // Серый цвет
475
+ actionText = 'НОРМА'; // Или OK
476
+ countText = '<span class="text-muted">-</span>';
477
+ }
478
+
479
+ // Жирный шрифт для важных действий
480
+ const weight = item.action === 'ok' ? 'fw-normal' : 'fw-bold';
481
+
482
+ const row = `
483
+ <tr>
484
+ <td class="fw-bold text-dark">${item.word}</td>
485
+ <td class="${colorClass} ${weight}">${actionText}</td>
486
+ <td class="${colorClass} ${weight}">${countText}</td>
487
+ <td>${item.my_score}</td>
488
+ <td>${item.avg_comp_score}</td>
489
+ </tr>`;
490
+ bm25Body.insertAdjacentHTML('beforeend', row);
491
+ });
492
+ } else {
493
+ // Если список пуст (например, не ввели ключевые слова)
494
+ bm25Msg.style.display = 'block';
495
+ bm25Msg.textContent = "Введите ключевые фразы для расчета BM25.";
496
+ }
497
+
498
+ // 3. N-grams
499
+ showNgramTable('unigrams');
500
+
501
+ // 4. Title
502
+ renderTitleResults(data);
503
+ }
504
+
505
+ function showNgramTable(type) {
506
+ if(!currentData) return;
507
+
508
+ document.querySelectorAll('#ngrams .btn').forEach(b => {
509
+ if(type === 'unigrams' && b.innerText.includes('1')) b.classList.add('active');
510
+ else if(type === 'bigrams' && b.innerText.includes('2')) b.classList.add('active');
511
+ else if(type === 'trigrams' && b.innerText.includes('3')) b.classList.add('active');
512
+ else b.classList.remove('active');
513
+ });
514
+
515
+ const table = document.querySelector('#ngrams table');
516
+ const thead = table.querySelector('thead');
517
+ const tbody = document.getElementById('ngramTableBody');
518
+
519
+ tbody.innerHTML = '';
520
+ thead.innerHTML = '';
521
+
522
+ const list = currentData.ngram_stats[type];
523
+
524
+ let numCompetitors = 0;
525
+ if (list && list.length > 0 && list[0].competitor_detailed) {
526
+ numCompetitors = list[0].competitor_detailed.length;
527
+ }
528
+
529
+ // ЗАГОЛОВОК
530
+ let headerRow = '<tr>';
531
+ headerRow += '<th style="width: 35%;">Фраза</th>';
532
+ headerRow += '<th>Target</th>';
533
+ headerRow += '<th class="table-light">AVG</th>';
534
+ // НОВАЯ КОЛОНКА
535
+ headerRow += '<th class="table-secondary" title="Встречаемость у конкурентов">Freq</th>';
536
+
537
+ for (let i = 0; i < numCompetitors; i++) {
538
+ headerRow += `<th>K${i + 1}</th>`;
539
+ }
540
+ headerRow += '</tr>';
541
+ thead.innerHTML = headerRow;
542
+
543
+ // ТЕЛО
544
+ if(list && list.length > 0) {
545
+ list.forEach(item => {
546
+ let rowClass = "";
547
+ let countClass = "";
548
+ let icon = "";
549
+
550
+ if (item.target_count === 0 && item.competitor_avg > 0) {
551
+ rowClass = "table-warning";
552
+ countClass = "text-danger fw-bold";
553
+ icon = "⚠️";
554
+ } else if (item.target_count > 0 && item.competitor_avg === 0) {
555
+ countClass = "text-success";
556
+ }
557
+
558
+ let compCells = "";
559
+ if (item.competitor_detailed) {
560
+ item.competitor_detailed.forEach(count => {
561
+ const style = count > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
562
+ compCells += `<td class="${style}">${count}</td>`;
563
+ });
564
+ }
565
+
566
+ // ЗНАЧЕНИЕ ДЛЯ НОВОЙ КОЛОНКИ (Например: "3/5")
567
+ const freqText = numCompetitors > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numCompetitors}</span>` : '-';
568
+
569
+ const row = `
570
+ <tr class="${rowClass}">
571
+ <td>${item.ngram} ${icon}</td>
572
+ <td class="fw-bold ${countClass} border-end">${item.target_count}</td>
573
+ <td class="table-light fw-bold border-end">${item.competitor_avg}</td>
574
+ <!-- НОВАЯ ЯЧЕЙКА -->
575
+ <td class="table-secondary fw-bold text-center border-end">${freqText}</td>
576
+ ${compCells}
577
+ </tr>`;
578
+ tbody.insertAdjacentHTML('beforeend', row);
579
+ });
580
+ } else {
581
+ tbody.innerHTML = `<tr><td colspan="${4 + numCompetitors}" class="text-center text-muted">Нет данных</td></tr>`;
582
+ }
583
+ }
584
+
585
+ function renderTitleResults(data) {
586
+ const container = document.getElementById('titleResultsContainer');
587
+ if (!data.title_analysis || !data.title_analysis.target_title) {
588
+ container.innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>';
589
+ return;
590
+ }
591
+
592
+ const ta = data.title_analysis;
593
+ let html = '';
594
 
595
+ // === BLOCK 1: Length ===
596
+ const len = ta.length;
597
+ let charBadge = 'bg-success';
598
+ let charHint = 'Оптимально';
599
+ if (len.status === 'too_long') { charBadge = 'bg-danger'; charHint = 'Слишком длинный (>60)'; }
600
+ else if (len.status === 'too_short') { charBadge = 'bg-warning text-dark'; charHint = 'Слишком короткий (<30)'; }
601
+
602
+ let compLenRows = '';
603
+ if (ta.competitor_titles && ta.competitor_titles.length > 0) {
604
+ ta.competitor_titles.forEach((ct, idx) => {
605
+ const cd = len.comp_data[idx];
606
+ compLenRows += `
607
+ <tr>
608
+ <td class="text-truncate" style="max-width: 300px;" title="${ct}">K${idx+1}: ${ct}</td>
609
+ <td class="text-center">${cd.chars}</td>
610
+ <td class="text-center">${cd.words.total}</td>
611
+ </tr>`;
612
+ });
613
+ }
614
+
615
+ html += `
616
+ <div class="stat-card">
617
+ <h5 class="card-title mb-3">Длина Title</h5>
618
+ <div class="d-flex align-items-center mb-3">
619
+ <span class="display-6 fw-bold me-3">${len.target_chars}</span>
620
+ <div>
621
+ <span class="badge ${charBadge}">${charHint}</span>
622
+ <div class="text-muted small mt-1">Слов: ${len.target_words.total} (значимых: ${len.target_words.significant})</div>
623
+ </div>
624
  </div>
625
+ ${compLenRows ? `
626
+ <table class="table table-sm table-bordered">
627
+ <thead><tr><th>Title</th><th class="text-center">Символы</th><th class="text-center">Слова</th></tr></thead>
628
+ <tbody>${compLenRows}</tbody>
629
+ <tfoot><tr class="table-light fw-bold"><td>Среднее</td><td class="text-center">${len.avg_chars}</td><td class="text-center">${len.avg_words_total}</td></tr></tfoot>
630
+ </table>` : ''}
631
+ </div>`;
632
 
633
+ // === BLOCK 2: Keyword Coverage ===
634
+ if (ta.keyword_coverage && ta.keyword_coverage.length > 0) {
635
+ let coverageRows = '';
636
+ ta.keyword_coverage.forEach(kw => {
637
+ let badge = 'bg-danger';
638
+ let label = 'Нет';
639
+ if (kw.exact_match) { badge = 'bg-success'; label = 'Точное'; }
640
+ else if (kw.word_coverage >= 50) { badge = 'bg-warning text-dark'; label = 'Частично'; }
641
 
642
+ const missingHtml = kw.words_missing.length > 0
643
+ ? `<span class="text-danger small">${kw.words_missing.join(', ')}</span>`
644
+ : '<span class="text-success small">-</span>';
645
+
646
+ const compPresence = kw.comp_total > 0 ? `${kw.comp_presence}/${kw.comp_total}` : '-';
647
+
648
+ coverageRows += `
649
+ <tr>
650
+ <td class="fw-bold">${kw.phrase}</td>
651
+ <td class="text-center"><span class="badge ${badge}">${label}</span></td>
652
+ <td class="text-center">${kw.word_coverage}%</td>
653
+ <td>${missingHtml}</td>
654
+ <td class="text-center">${compPresence}</td>
655
+ </tr>`;
656
+ });
657
+
658
+ html += `
659
+ <div class="stat-card">
660
+ <h5 class="card-title mb-3">Покрытие ключевых фраз</h5>
661
+ <table class="table table-sm table-hover">
662
+ <thead>
663
+ <tr>
664
+ <th>Фраза</th>
665
+ <th class="text-center">Вхождение</th>
666
+ <th class="text-center">Покрытие</th>
667
+ <th>Отсутствуют</th>
668
+ <th class="text-center">У конк.</th>
669
+ </tr>
670
+ </thead>
671
+ <tbody>${coverageRows}</tbody>
672
+ </table>
673
+ </div>`;
674
+ }
675
+
676
+ // === BLOCK 3: Title N-grams ===
677
+ if (ta.ngrams) {
678
+ ['unigrams', 'bigrams'].forEach(type => {
679
+ const list = ta.ngrams[type];
680
+ if (!list || list.length === 0) return;
681
+
682
+ const typeName = type === 'unigrams' ? '1 слово' : '2 слова';
683
+ let numComp = 0;
684
+ if (list[0].competitor_detailed) numComp = list[0].competitor_detailed.length;
685
+
686
+ let tHead = '<tr><th>Слово</th><th>Target</th><th class="table-light">AVG</th><th class="table-secondary">Freq</th>';
687
+ for (let i = 0; i < numComp; i++) tHead += `<th>K${i+1}</th>`;
688
+ tHead += '</tr>';
689
+
690
+ let tBody = '';
691
+ list.forEach(item => {
692
+ let rc = '';
693
+ let cc = '';
694
+ if (item.target_count === 0 && item.competitor_avg > 0) { rc = 'table-warning'; cc = 'text-danger fw-bold'; }
695
+ else if (item.target_count > 0 && item.competitor_avg === 0) { cc = 'text-success'; }
696
+
697
+ let cells = '';
698
+ if (item.competitor_detailed) {
699
+ item.competitor_detailed.forEach(cnt => {
700
+ const s = cnt > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
701
+ cells += `<td class="${s}">${cnt}</td>`;
702
+ });
703
+ }
704
+ const freq = numComp > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numComp}</span>` : '-';
705
+
706
+ tBody += `
707
+ <tr class="${rc}">
708
+ <td>${item.ngram}</td>
709
+ <td class="fw-bold ${cc} border-end">${item.target_count}</td>
710
+ <td class="table-light fw-bold border-end">${item.competitor_avg}</td>
711
+ <td class="table-secondary fw-bold text-center border-end">${freq}</td>
712
+ ${cells}
713
+ </tr>`;
714
+ });
715
+
716
+ html += `
717
+ <div class="stat-card">
718
+ <h6 class="card-title">N-граммы Title (${typeName})</h6>
719
+ <div class="scrollable-table">
720
+ <table class="table table-sm">
721
+ <thead>${tHead}</thead>
722
+ <tbody>${tBody}</tbody>
723
+ </table>
724
+ </div>
725
+ </div>`;
726
+ });
727
+ }
728
+
729
+ // === BLOCK 4: BERT Semantic ===
730
+ if (ta.bert && ta.bert.per_keyword && ta.bert.per_keyword.length > 0) {
731
+ let bertRows = '';
732
+ ta.bert.per_keyword.forEach(kw => {
733
+ const compScores = kw.comp_scores.map((s, i) => `<td>${s}</td>`).join('');
734
+ const best = Math.max(...kw.comp_scores, 0);
735
+ let cls = '';
736
+ if (kw.target_score >= best - 0.05) cls = 'text-success';
737
+ else if (kw.target_score < best - 0.15) cls = 'text-danger';
738
+ else cls = 'text-warning';
739
+
740
+ bertRows += `
741
+ <tr>
742
+ <td class="fw-bold">${kw.keyword}</td>
743
+ <td class="fw-bold ${cls} border-end">${kw.target_score}</td>
744
+ ${compScores}
745
+ </tr>`;
746
+ });
747
+
748
+ let compHeaders = '';
749
+ if (ta.bert.comp_scores) {
750
+ ta.bert.comp_scores.forEach((s, i) => { compHeaders += `<th>K${i+1}</th>`; });
751
+ }
752
+
753
+ html += `
754
+ <div class="stat-card">
755
+ <h5 class="card-title mb-3">Семантика Title (BERT)</h5>
756
+ <div class="d-flex align-items-baseline mb-3">
757
+ <span class="h4 fw-bold me-2">Мой: ${ta.bert.target_score}</span>
758
+ ${ta.bert.comp_scores ? ta.bert.comp_scores.map((s,i) => `<span class="badge bg-secondary me-1">K${i+1}: ${s}</span>`).join('') : ''}
759
+ </div>
760
+ <table class="table table-sm table-hover">
761
+ <thead><tr><th>Ключ</th><th class="border-end">Target</th>${compHeaders}</tr></thead>
762
+ <tbody>${bertRows}</tbody>
763
+ </table>
764
+ </div>`;
765
+ }
766
+
767
+ container.innerHTML = html;
768
+ }
769
+ </script>
770
  </body>
771
  </html>
url_fetcher.py DELETED
@@ -1,143 +0,0 @@
1
- from typing import Dict, List
2
- from urllib.parse import urlparse
3
- import re
4
-
5
- import requests
6
- from bs4 import BeautifulSoup
7
-
8
-
9
- USER_AGENT_PRESETS: List[Dict[str, str]] = [
10
- {
11
- "key": "googlebot",
12
- "name": "Googlebot",
13
- "value": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
14
- },
15
- {
16
- "key": "bingbot",
17
- "name": "Bingbot",
18
- "value": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
19
- },
20
- {
21
- "key": "chatgpt_user",
22
- "name": "ChatGPT-User",
23
- "value": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot",
24
- },
25
- {
26
- "key": "gptbot",
27
- "name": "GPTBot",
28
- "value": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot",
29
- },
30
- {
31
- "key": "chrome_desktop",
32
- "name": "Chrome (Desktop)",
33
- "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
34
- },
35
- ]
36
-
37
-
38
- def get_user_agent_presets() -> List[Dict[str, str]]:
39
- return USER_AGENT_PRESETS
40
-
41
-
42
- def _normalize_whitespace(text: str) -> str:
43
- text = re.sub(r"[ \t]+", " ", text)
44
- text = re.sub(r"\n{3,}", "\n\n", text)
45
- return text.strip()
46
-
47
-
48
- def _normalize_url(url: str) -> str:
49
- raw = (url or "").strip()
50
- if not raw:
51
- raise ValueError("URL пустой.")
52
-
53
- if not raw.startswith(("http://", "https://")):
54
- raw = "https://" + raw
55
-
56
- parsed = urlparse(raw)
57
- if not parsed.scheme or not parsed.netloc:
58
- raise ValueError("Некорректный URL.")
59
- return raw
60
-
61
-
62
- def _resolve_user_agent(user_agent_key: str) -> Dict[str, str]:
63
- key = (user_agent_key or "").strip()
64
- for ua in USER_AGENT_PRESETS:
65
- if ua["key"] == key:
66
- return ua
67
- for ua in USER_AGENT_PRESETS:
68
- if ua["key"] == "chrome_desktop":
69
- return ua
70
- return USER_AGENT_PRESETS[0]
71
-
72
-
73
- def _extract_main_text_and_title(html: str) -> Dict[str, str]:
74
- soup = BeautifulSoup(html or "", "html.parser")
75
-
76
- for bad in soup(["script", "style", "noscript", "svg", "nav", "footer", "header", "aside", "form", "iframe"]):
77
- bad.decompose()
78
-
79
- title = ""
80
- if soup.title:
81
- title = _normalize_whitespace(soup.title.get_text(" ", strip=True))
82
-
83
- best_text = ""
84
-
85
- # Priority 1: semantic containers usually containing article text.
86
- candidates = []
87
- for selector, boost in (("article", 1.2), ("main", 1.1)):
88
- for node in soup.select(selector):
89
- t = _normalize_whitespace(node.get_text("\n", strip=True))
90
- if len(t) >= 200:
91
- candidates.append((len(t) * boost, t))
92
-
93
- if candidates:
94
- best_text = max(candidates, key=lambda x: x[0])[1]
95
- else:
96
- # Priority 2: collect meaningful paragraphs and list content.
97
- paragraphs: List[str] = []
98
- for p in soup.find_all(["p", "li"]):
99
- txt = _normalize_whitespace(p.get_text(" ", strip=True))
100
- if len(txt) >= 40:
101
- paragraphs.append(txt)
102
- if len(paragraphs) >= 3:
103
- best_text = "\n\n".join(paragraphs)
104
-
105
- # Priority 3: fallback to body text.
106
- if not best_text:
107
- body = soup.body if soup.body else soup
108
- best_text = _normalize_whitespace(body.get_text("\n", strip=True))
109
-
110
- return {
111
- "title": title,
112
- "text": best_text,
113
- }
114
-
115
-
116
- def fetch_url_content(url: str, user_agent_key: str = "chrome_desktop", timeout_seconds: int = 15) -> Dict[str, str]:
117
- normalized_url = _normalize_url(url)
118
- ua = _resolve_user_agent(user_agent_key)
119
-
120
- headers = {
121
- "User-Agent": ua["value"],
122
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
123
- }
124
- response = requests.get(
125
- normalized_url,
126
- headers=headers,
127
- timeout=max(5, min(int(timeout_seconds or 15), 40)),
128
- allow_redirects=True,
129
- )
130
- response.raise_for_status()
131
-
132
- extracted = _extract_main_text_and_title(response.text or "")
133
- return {
134
- "ok": True,
135
- "url": normalized_url,
136
- "final_url": response.url,
137
- "status_code": response.status_code,
138
- "user_agent_key": ua["key"],
139
- "user_agent_value": ua["value"],
140
- "title": extracted["title"],
141
- "text": extracted["text"],
142
- "error": "",
143
- }