немногожко тестов

This commit is contained in:
Dmitriy Kazimirov 2025-03-30 00:32:00 +06:00
parent 957362f0a8
commit b17fd050f3
14 changed files with 1020 additions and 123 deletions

View file

@ -1,8 +1,28 @@
{
"name": "Search Dev",
"dockerComposeFile": "docker-compose.yml",
"service": "backend",
"workspaceFolder": "/app",
"extensions": ["ms-python.python"],
"remoteUser": "root"
"name": "Document Search Dev",
"dockerComposeFile": "../docker-compose.yml", // Путь относительно .devcontainer папки
"service": "backend", // Сервис, к которому подключается VS Code
"workspaceFolder": "/app", // Рабочая папка внутри контейнера backend
"customizations": {
"vscode": {
"extensions": [
"ms-python.python", // Поддержка Python
"ms-python.vscode-pylance", // IntelliSense
"ms-python.flake8", // Линтер (или ruff)
"ms-python.mypy" // Проверка типов
// Можно добавить другие полезные расширения
],
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.linting.flake8Enabled": true,
"python.linting.pylintEnabled": false,
"python.formatting.provider": "black", // Или autopep8
"python.analysis.typeCheckingMode": "basic" // Включаем MyPy
}
}
},
// Запуск от не-root пользователя (если базовый образ python это позволяет)
// "remoteUser": "vscode" // Или другое имя пользователя, созданного в Dockerfile
"remoteUser": "root" // Оставляем root для простоты, т.к. базовый образ python его использует
}

View file

@ -1,10 +1,20 @@
# backend/Dockerfile
FROM python:3.11
WORKDIR /app
# Копируем сначала requirements, чтобы кэшировать слой установки зависимостей
COPY requirements.txt .
# Устанавливаем зависимости приложения и тестов
# Используем --no-cache-dir для уменьшения размера образа
RUN pip install --no-cache-dir -r requirements.txt
# Копируем остальной код приложения и тесты
COPY . .
# Команда по умолчанию для запуска приложения
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# Пример команды для запуска тестов (можно использовать при CI/CD или вручную)
# RUN pytest

View file

@ -1,24 +1,116 @@
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.responses import FileResponse
import requests
import os
from typing import List, Dict, Any, Optional
from dotenv import load_dotenv
import logging
# Загрузка переменных окружения (например, из .env)
load_dotenv()
FILES_DIR = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage")
SEARCH_ENGINE = "http://meilisearch:7700"
app = FastAPI()
# Настройка логирования
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@app.get("/search")
def search(q: str):
response = requests.get(f"{SEARCH_ENGINE}/indexes/documents/search", params={"q": q})
results = response.json()
return {"results": results.get("hits", [])}
# Конфигурация
FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") # Путь монтирования в Docker
SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Search API Key
INDEX_NAME: str = "documents"
app = FastAPI(
title="Document Search API",
description="API для поиска по локальным документам и их получения",
version="0.2.0",
)
# Зависимость для получения HTTP-клиента (лучше использовать один клиент)
# В реальном приложении можно использовать httpx.AsyncClient
# Для простоты пока оставляем requests
def get_search_session() -> requests.Session:
"""Создает сессию requests с заголовками для Meilisearch."""
session = requests.Session()
headers = {}
if MEILI_API_KEY:
headers["Authorization"] = f"Bearer {MEILI_API_KEY}"
session.headers.update(headers)
return session
@app.get("/search", response_model=Dict[str, List[Dict[str, Any]]], summary="Поиск документов")
async def search(
q: str = Query(..., description="Поисковый запрос"),
limit: int = Query(20, ge=1, le=100, description="Максимальное количество результатов"),
session: requests.Session = Depends(get_search_session)
) -> Dict[str, List[Dict[str, Any]]]:
"""
Выполняет поиск документов в индексе Meilisearch.
"""
search_url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/search"
params = {"q": q, "limit": limit, "attributesToHighlight": ["content"]} # Запрашиваем подсветку
try:
response = session.post(search_url, json=params) # Meilisearch рекомендует POST для поиска с параметрами
response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx
results = response.json()
logger.info(f"Поиск по запросу '{q}' вернул {len(results.get('hits', []))} результатов")
# Возвращаем только нужные поля, включая _formatted для подсветки
hits = []
for hit in results.get("hits", []):
# Убираем полный content, если он большой, оставляем только id и _formatted
formatted_hit = hit.get("_formatted", {"id": hit.get("id", "N/A"), "content": "..."})
formatted_hit["id"] = hit.get("id", "N/A") # Убедимся, что id всегда есть
hits.append(formatted_hit)
return {"results": hits}
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка при обращении к Meilisearch ({search_url}): {e}")
raise HTTPException(status_code=503, detail="Сервис поиска временно недоступен")
except Exception as e:
logger.error(f"Неожиданная ошибка при поиске: {e}")
raise HTTPException(status_code=500, detail="Внутренняя ошибка сервера при поиске")
@app.get("/files/{filename}", summary="Получение файла документа")
async def get_file(filename: str) -> FileResponse:
"""
Возвращает файл по его имени.
Используется для скачивания файлов, найденных через поиск.
"""
if not filename or ".." in filename or "/" in filename:
logger.warning(f"Попытка доступа к некорректному имени файла: {filename}")
raise HTTPException(status_code=400, detail="Некорректное имя файла")
@app.get("/files/{filename}")
def get_file(filename: str):
file_path = os.path.join(FILES_DIR, filename)
if os.path.exists(file_path):
# Проверка безопасности: убеждаемся, что путь действительно внутри FILES_DIR
if not os.path.abspath(file_path).startswith(os.path.abspath(FILES_DIR)):
logger.error(f"Попытка доступа за пределы разрешенной директории: {file_path}")
raise HTTPException(status_code=403, detail="Доступ запрещен")
if os.path.exists(file_path) and os.path.isfile(file_path):
logger.info(f"Отдаем файл: {filename}")
# media_type можно определять более точно, если нужно
return FileResponse(file_path, filename=filename)
return {"error": "Файл не найден"}
else:
logger.warning(f"Запрошенный файл не найден: {filename} (путь {file_path})")
raise HTTPException(status_code=404, detail="Файл не найден")
# Можно добавить эндпоинт для статуса системы, проверки подключения к MeiliSearch и т.д.
@app.get("/health", summary="Проверка состояния сервиса")
async def health_check(session: requests.Session = Depends(get_search_session)) -> Dict[str, str]:
"""Проверяет доступность бэкенда и Meilisearch."""
meili_status = "недоступен"
try:
health_url = f"{SEARCH_ENGINE_URL}/health"
response = session.get(health_url)
response.raise_for_status()
if response.json().get("status") == "available":
meili_status = "доступен"
except requests.exceptions.RequestException:
pass # Статус останется "недоступен"
except Exception as e:
logger.error(f"Неожиданная ошибка при проверке здоровья Meilisearch: {e}")
return {"status": "ok", "meilisearch_status": meili_status}

View file

@ -1,47 +1,297 @@
import os
import requests
from pdfminer.high_level import extract_text
from ebooklib import epub
import time
import logging
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Set
from pdfminer.high_level import extract_text as pdf_extract_text
from pdfminer.pdfparser import PDFSyntaxError
from ebooklib import epub, ITEM_DOCUMENT
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# Загрузка переменных окружения
load_dotenv()
FILES_DIR = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage")
SEARCH_ENGINE = "http://meilisearch:7700"
INDEX_NAME = "documents"
def extract_text_from_pdf(pdf_path):
return extract_text(pdf_path)
# Настройка логирования
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def extract_text_from_epub(epub_path):
book = epub.read_epub(epub_path)
text = []
for item in book.get_items():
if item.get_type() == 9:
# Конфигурация
FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage")
SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Index API Key
INDEX_NAME: str = "documents"
BATCH_SIZE: int = 100 # Количество документов для отправки в Meilisearch за раз
# --- Функции извлечения текста ---
def extract_text_from_txt(file_path: Path) -> str:
"""Извлекает текст из TXT файла, пробуя разные кодировки."""
encodings_to_try = ['utf-8', 'cp1251', 'latin-1']
for encoding in encodings_to_try:
try:
return file_path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
except Exception as e:
raise IOError(f"Не удалось прочитать TXT файл {file_path} даже после попыток смены кодировки.") from e
# Если ни одна кодировка не подошла
logger.warning(f"Не удалось определить кодировку для TXT файла: {file_path.name}. Пропускаем.")
raise ValueError(f"Unknown encoding for {file_path.name}")
def extract_text_from_pdf(file_path: Path) -> str:
"""Извлекает текст из PDF файла."""
try:
return pdf_extract_text(str(file_path))
except PDFSyntaxError as e:
raise ValueError(f"Ошибка синтаксиса PDF: {file_path.name}") from e
except Exception as e:
# Ловим другие возможные ошибки pdfminer
raise IOError(f"Не удалось обработать PDF файл {file_path.name}") from e
def extract_text_from_epub(file_path: Path) -> str:
"""Извлекает текст из EPUB файла."""
try:
book = epub.read_epub(str(file_path))
text_parts: List[str] = []
for item in book.get_items_of_type(ITEM_DOCUMENT):
soup = BeautifulSoup(item.content, "html.parser")
text.append(soup.get_text())
return "\n".join(text)
# Удаляем скрипты и стили, чтобы не индексировать их содержимое
for script_or_style in soup(["script", "style"]):
script_or_style.decompose()
# Получаем текст, разделяя блоки параграфами для лучшей читаемости
# Используем strip=True для удаления лишних пробелов по краям
block_text = soup.get_text(separator='\n', strip=True)
if block_text:
text_parts.append(block_text)
return "\n\n".join(text_parts) # Разделяем контент разных HTML-файлов двойным переносом строки
except KeyError as e:
# Иногда возникает при проблемах с оглавлением или структурой epub
raise ValueError(f"Ошибка структуры EPUB файла: {file_path.name}, KeyError: {e}") from e
except Exception as e:
raise IOError(f"Не удалось обработать EPUB файл {file_path.name}") from e
def index_files():
docs = []
for filename in os.listdir(FILES_DIR):
file_path = os.path.join(FILES_DIR, filename)
# --- Функции взаимодействия с Meilisearch ---
if filename.endswith(".txt"):
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
elif filename.endswith(".pdf"):
def get_meili_client() -> requests.Session:
"""Создает и настраивает HTTP клиент для Meilisearch."""
session = requests.Session()
headers = {}
if MEILI_API_KEY:
headers['Authorization'] = f'Bearer {MEILI_API_KEY}'
session.headers.update(headers)
return session
def get_indexed_files(client: requests.Session) -> Dict[str, float]:
"""Получает список ID и время модификации проиндексированных файлов из Meilisearch."""
indexed_files: Dict[str, float] = {}
offset = 0
limit = 1000 # Получаем по 1000 за раз
url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents"
params = {"limit": limit, "fields": "id,file_mtime"}
while True:
try:
params["offset"] = offset
response = client.get(url, params=params)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
break # Больше нет документов
for doc in results:
# Убедимся, что file_mtime существует и является числом
mtime = doc.get("file_mtime")
if isinstance(mtime, (int, float)):
indexed_files[doc['id']] = float(mtime)
else:
# Если времени модификации нет, считаем, что файл нужно переиндексировать
indexed_files[doc['id']] = 0.0
offset += len(results)
# Защита от бесконечного цикла, если API вернет некорректные данные
if len(results) < limit:
break
except requests.exceptions.HTTPError as e:
# Если индекс не найден (404), это нормально при первом запуске
if e.response.status_code == 404:
logger.info(f"Индекс '{INDEX_NAME}' не найден. Будет создан при первой индексации.")
return {} # Возвращаем пустой словарь
else:
logger.error(f"Ошибка получения документов из Meilisearch: {e}")
raise # Передаем ошибку дальше, т.к. не можем продолжить
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка соединения с Meilisearch ({url}): {e}")
raise
logger.info(f"Найдено {len(indexed_files)} документов в индексе '{INDEX_NAME}'.")
return indexed_files
def update_meili_index(client: requests.Session, documents: List[Dict[str, Any]]) -> None:
"""Отправляет пакет документов в Meilisearch для добавления/обновления."""
if not documents:
return
url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents"
try:
# Отправляем частями (батчами)
for i in range(0, len(documents), BATCH_SIZE):
batch = documents[i:i + BATCH_SIZE]
response = client.post(url, json=batch)
response.raise_for_status()
task_info = response.json()
logger.info(f"Отправлено {len(batch)} документов на индексацию. Task UID: {task_info.get('taskUid', 'N/A')}")
# В продакшене можно добавить мониторинг статуса задачи Meilisearch
time.sleep(0.1) # Небольшая пауза между батчами
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка при отправке документов в Meilisearch: {e}")
# Можно добавить логику повторной попытки или сохранения неудавшихся батчей
def delete_from_meili_index(client: requests.Session, file_ids: List[str]) -> None:
"""Удаляет документы из Meilisearch по списку ID."""
if not file_ids:
return
url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents/delete-batch"
try:
# Удаляем частями (батчами)
for i in range(0, len(file_ids), BATCH_SIZE):
batch_ids = file_ids[i:i + BATCH_SIZE]
response = client.post(url, json=batch_ids)
response.raise_for_status()
task_info = response.json()
logger.info(f"Отправлено {len(batch_ids)} ID на удаление. Task UID: {task_info.get('taskUid', 'N/A')}")
time.sleep(0.1) # Небольшая пауза
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка при удалении документов из Meilisearch: {e}")
# --- Основная логика индексации ---
def process_file(file_path: Path) -> Optional[Dict[str, Any]]:
"""Обрабатывает один файл: извлекает текст и формирует документ для Meilisearch."""
filename = file_path.name
file_mtime = os.path.getmtime(str(file_path))
content = None
file_ext = file_path.suffix.lower()
try:
logger.debug(f"Обработка файла: {filename}")
if file_ext == ".txt":
content = extract_text_from_txt(file_path)
elif file_ext == ".pdf":
content = extract_text_from_pdf(file_path)
elif filename.endswith(".epub"):
elif file_ext == ".epub":
content = extract_text_from_epub(file_path)
else:
continue
logger.debug(f"Неподдерживаемый формат файла: {filename}. Пропускаем.")
return None # Неподдерживаемый формат
docs.append({"id": filename, "content": content})
if content is None or not content.strip():
logger.warning(f"Не удалось извлечь текст или текст пуст: {filename}")
return None
# Формируем документ для Meilisearch
document = {
"id": filename, # Используем имя файла как уникальный ID
"content": content.strip(),
"file_mtime": file_mtime, # Сохраняем время модификации
"indexed_at": time.time() # Время последней индексации
}
return document
except (ValueError, IOError, Exception) as e:
# Ловим ошибки чтения, парсинга или другие проблемы с файлом
logger.error(f"❌ Ошибка обработки файла {filename}: {e}")
return None # Пропускаем этот файл
def scan_and_index_files() -> None:
"""Сканирует директорию, сравнивает с индексом и обновляет Meilisearch."""
logger.info(f"🚀 Запуск сканирования директории: {FILES_DIR}")
target_dir = Path(FILES_DIR)
if not target_dir.is_dir():
logger.error(f"Директория не найдена: {FILES_DIR}")
return
client = get_meili_client()
# 1. Получаем состояние индекса
try:
indexed_files_mtimes: Dict[str, float] = get_indexed_files(client)
except Exception as e:
logger.error(f"Не удалось получить состояние индекса. Прерывание: {e}")
return
# 2. Сканируем локальные файлы
local_files_mtimes: Dict[str, float] = {}
files_to_process: List[Path] = []
processed_extensions = {".txt", ".pdf", ".epub"}
for item in target_dir.rglob('*'): # Рекурсивно обходим все файлы
if item.is_file() and item.suffix.lower() in processed_extensions:
try:
local_files_mtimes[item.name] = item.stat().st_mtime
files_to_process.append(item)
except FileNotFoundError:
logger.warning(f"Файл был удален во время сканирования: {item.name}")
continue # Пропускаем, если файл исчез между листингом и stat()
logger.info(f"Найдено {len(local_files_mtimes)} поддерживаемых файлов локально.")
# 3. Определяем изменения
local_filenames: Set[str] = set(local_files_mtimes.keys())
indexed_filenames: Set[str] = set(indexed_files_mtimes.keys())
files_to_add: Set[str] = local_filenames - indexed_filenames
files_to_delete: Set[str] = indexed_filenames - local_filenames
files_to_check_for_update: Set[str] = local_filenames.intersection(indexed_filenames)
files_to_update: Set[str] = {
fname for fname in files_to_check_for_update
if local_files_mtimes[fname] > indexed_files_mtimes.get(fname, 0.0) # Сравниваем время модификации
}
logger.info(f"К добавлению: {len(files_to_add)}, к обновлению: {len(files_to_update)}, к удалению: {len(files_to_delete)}")
# 4. Обрабатываем и отправляем добавления/обновления
docs_for_meili: List[Dict[str, Any]] = []
files_requiring_processing: Set[str] = files_to_add.union(files_to_update)
processed_count = 0
skipped_count = 0
error_count = 0
for file_path in files_to_process:
if file_path.name in files_requiring_processing:
processed_count +=1
document = process_file(file_path)
if document:
docs_for_meili.append(document)
else:
error_count += 1 # Ошибка или не удалось извлечь текст
else:
skipped_count +=1 # Файл не изменился
logger.info(f"Обработано файлов: {processed_count} (пропущено без изменений: {skipped_count}, ошибки: {error_count})")
if docs_for_meili:
logger.info(f"Отправка {len(docs_for_meili)} документов в Meilisearch...")
update_meili_index(client, docs_for_meili)
else:
logger.info("Нет новых или обновленных файлов для индексации.")
# 5. Удаляем устаревшие документы
if files_to_delete:
logger.info(f"Удаление {len(files_to_delete)} устаревших документов из Meilisearch...")
delete_from_meili_index(client, list(files_to_delete))
else:
logger.info("Нет файлов для удаления из индекса.")
logger.info("✅ Индексация завершена.")
if docs:
requests.post(f"{SEARCH_ENGINE}/indexes/{INDEX_NAME}/documents", json=docs)
print(f"Индексировано {len(docs)} файлов!")
if __name__ == "__main__":
index_files()
scan_and_index_files()

View file

@ -1,7 +1,10 @@
fastapi
uvicorn
uvicorn[standard] # Включает поддержку websockets и др., [standard] рекомендован uvicorn
requests
pdfminer.six
ebooklib
beautifulsoup4
python-dotenv
# Зависимости для тестов
pytest
httpx # Для FastAPI TestClient

1
backend/tests/init.py Normal file
View file

@ -0,0 +1 @@
# Пустой файл, нужен чтобы Python распознал папку как пакет

108
backend/tests/test_app.py Normal file
View file

@ -0,0 +1,108 @@
import pytest
from fastapi.testclient import TestClient
from httpx import Response # Используем Response из httpx, так как TestClient его возвращает
from unittest.mock import patch, MagicMock
# Важно: Импортируем 'app' из модуля, где он создан
from backend.app import app
# Фикстура для создания тестового клиента
@pytest.fixture(scope="module")
def client() -> TestClient:
return TestClient(app)
# Фикстура для мокирования сессии requests
@pytest.fixture
def mock_search_session():
with patch("backend.app.requests.Session") as mock_session_cls:
mock_session_instance = MagicMock()
# Настраиваем мок для POST запроса на /search
mock_response_search = MagicMock(spec=Response)
mock_response_search.status_code = 200
mock_response_search.json.return_value = {
"hits": [
{"id": "test.txt", "_formatted": {"id": "test.txt", "content": "Это <em>тест</em>"}},
{"id": "another.pdf", "_formatted": {"id": "another.pdf", "content": "Еще один <em>тест</em>овый файл"}}
],
"query": "тест",
"processingTimeMs": 10,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 2
}
# Настраиваем мок для GET запроса на /health
mock_response_health = MagicMock(spec=Response)
mock_response_health.status_code = 200
mock_response_health.json.return_value = {"status": "available"}
# Используем side_effect для разных ответов на разные URL
def side_effect(*args, **kwargs):
url = args[0] # Первый аргумент - URL
if 'search' in url:
if 'json' in kwargs and kwargs['json'].get('q') == "ошибка": # Имитируем ошибку Meili
mock_err_resp = MagicMock(spec=Response)
mock_err_resp.status_code = 500
mock_err_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("Meili Error")
return mock_err_resp
return mock_response_search
elif 'health' in url:
return mock_response_health
else: # Поведение по умолчанию
default_resp = MagicMock(spec=Response)
default_resp.status_code = 404
return default_resp
# Назначаем side_effect для разных методов
mock_session_instance.post.side_effect = side_effect
mock_session_instance.get.side_effect = side_effect
mock_session_cls.return_value = mock_session_instance
yield mock_session_instance
def test_search_success(client: TestClient, mock_search_session: MagicMock):
"""Тестирует успешный поиск."""
response = client.get("/search?q=тест")
assert response.status_code == 200
data = response.json()
assert "results" in data
assert len(data["results"]) == 2
assert data["results"][0]["id"] == "test.txt"
assert "<em>тест</em>" in data["results"][0]["content"]
# Проверяем, что был вызван POST к MeiliSearch (т.к. app.py использует POST)
mock_search_session.post.assert_called_once()
def test_search_empty_query(client: TestClient):
"""Тестирует поиск с пустым запросом (FastAPI вернет 422)."""
response = client.get("/search?q=")
assert response.status_code == 422 # Ошибка валидации FastAPI
def test_search_meili_error(client: TestClient, mock_search_session: MagicMock):
"""Тестирует обработку ошибки от Meilisearch."""
response = client.get("/search?q=ошибка") # Используем запрос, который вызовет ошибку в моке
assert response.status_code == 503 # Service Unavailable
assert response.json()["detail"] == "Сервис поиска временно недоступен"
def test_get_file_not_found(client: TestClient):
"""Тестирует запрос несуществующего файла."""
# Мы не мокируем os.path.exists, поэтому он вернет False
response = client.get("/files/nonexistent.txt")
assert response.status_code == 404
assert response.json()["detail"] == "Файл не найден"
def test_get_file_invalid_name(client: TestClient):
"""Тестирует запрос файла с некорректным именем."""
response = client.get("/files/../secret.txt")
assert response.status_code == 400 # Bad Request (изменен в app.py)
# Пример теста для /health (уже частично покрыт в mock_search_session)
def test_health_check(client: TestClient, mock_search_session: MagicMock):
"""Тестирует эндпоинт /health."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["meilisearch_status"] == "доступен"
mock_search_session.get.assert_called_once_with(f"{app.SEARCH_ENGINE_URL}/health")
# TODO: Добавить тест для успешного получения файла (требует мокирования os.path и создания временного файла)

View file

@ -0,0 +1,174 @@
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
from backend import indexer # Импортируем модуль для тестирования
# --- Тесты для функций извлечения текста ---
def test_extract_text_from_txt_success():
"""Тестирует успешное чтение UTF-8 TXT файла."""
mock_content = "Привет, мир!"
# Используем mock_open для имитации чтения файла
m = mock_open(read_data=mock_content.encode('utf-8'))
with patch('pathlib.Path.read_text', m):
result = indexer.extract_text_from_txt(Path("dummy.txt"))
assert result == mock_content
# Проверяем, что была попытка чтения с utf-8
m.assert_called_once_with(encoding='utf-8')
def test_extract_text_from_txt_cp1251():
"""Тестирует чтение TXT файла в CP1251 после неудачи с UTF-8."""
mock_content_cp1251 = "Тест CP1251".encode('cp1251')
# Имитируем ошибку при чтении UTF-8 и успешное чтение CP1251
m = MagicMock()
m.side_effect = [UnicodeDecodeError('utf-8', b'', 0, 1, 'reason'), mock_content_cp1251.decode('cp1251')]
# Важно: Мокаем read_text у экземпляра Path, а не сам метод класса
with patch('pathlib.Path.read_text', m):
result = indexer.extract_text_from_txt(Path("dummy.txt"))
assert result == "Тест CP1251"
assert m.call_count == 2 # Были вызовы для utf-8 и cp1251
assert m.call_args_list[0][1]['encoding'] == 'utf-8'
assert m.call_args_list[1][1]['encoding'] == 'cp1251'
def test_extract_text_from_txt_unknown_encoding():
"""Тестирует случай, когда ни одна кодировка не подходит."""
m = MagicMock()
m.side_effect = UnicodeDecodeError('dummy', b'', 0, 1, 'reason')
with patch('pathlib.Path.read_text', m), pytest.raises(ValueError, match="Unknown encoding"):
indexer.extract_text_from_txt(Path("dummy.txt"))
assert m.call_count == 3 # Попытки utf-8, cp1251, latin-1
@patch('backend.indexer.pdf_extract_text', return_value="PDF content here")
def test_extract_text_from_pdf_success(mock_pdf_extract):
"""Тестирует успешное извлечение текста из PDF."""
result = indexer.extract_text_from_pdf(Path("dummy.pdf"))
assert result == "PDF content here"
mock_pdf_extract.assert_called_once_with("dummy.pdf")
@patch('backend.indexer.pdf_extract_text', side_effect=indexer.PDFSyntaxError("Bad PDF"))
def test_extract_text_from_pdf_syntax_error(mock_pdf_extract):
"""Тестирует обработку PDFSyntaxError."""
with pytest.raises(ValueError, match="Ошибка синтаксиса PDF"):
indexer.extract_text_from_pdf(Path("dummy.pdf"))
@patch('backend.indexer.epub.read_epub')
def test_extract_text_from_epub_success(mock_read_epub):
"""Тестирует успешное извлечение текста из EPUB."""
# Создаем моки для epub объектов
mock_item1 = MagicMock()
mock_item1.get_type.return_value = indexer.ITEM_DOCUMENT
mock_item1.content = b"<html><body><p>First paragraph.</p></body></html>"
mock_item2 = MagicMock()
mock_item2.get_type.return_value = indexer.ITEM_DOCUMENT
mock_item2.content = b"<html><body><p>Second paragraph.</p><script>alert('hi');</script></body></html>"
mock_book = MagicMock()
mock_book.get_items_of_type.return_value = [mock_item1, mock_item2]
mock_read_epub.return_value = mock_book
result = indexer.extract_text_from_epub(Path("dummy.epub"))
assert result == "First paragraph.\n\nSecond paragraph." # Скрипт должен быть удален, параграфы разделены
mock_read_epub.assert_called_once_with("dummy.epub")
mock_book.get_items_of_type.assert_called_once_with(indexer.ITEM_DOCUMENT)
@patch('backend.indexer.epub.read_epub', side_effect=Exception("EPUB Read Error"))
def test_extract_text_from_epub_error(mock_read_epub):
"""Тестирует обработку общей ошибки при чтении EPUB."""
with pytest.raises(IOError, match="Не удалось обработать EPUB файл"):
indexer.extract_text_from_epub(Path("dummy.epub"))
# --- Тесты для process_file ---
@patch('backend.indexer.os.path.getmtime', return_value=12345.67)
@patch('backend.indexer.extract_text_from_txt', return_value="Текстовый контент")
def test_process_file_txt_success(mock_extract, mock_getmtime):
"""Тестирует успешную обработку TXT файла."""
# Используем mock_open чтобы Path("file.txt").suffix сработал
m_open = mock_open()
with patch('pathlib.Path.open', m_open):
p = Path("file.txt")
result = indexer.process_file(p)
assert result is not None
assert result["id"] == "file.txt"
assert result["content"] == "Текстовый контент"
assert result["file_mtime"] == 12345.67
assert "indexed_at" in result
mock_extract.assert_called_once_with(p)
mock_getmtime.assert_called_once_with("file.txt")
@patch('backend.indexer.os.path.getmtime', return_value=12345.67)
@patch('backend.indexer.extract_text_from_pdf', side_effect=IOError("PDF read failed"))
def test_process_file_pdf_error(mock_extract, mock_getmtime):
"""Тестирует обработку ошибки при извлечении текста из PDF."""
m_open = mock_open()
with patch('pathlib.Path.open', m_open):
p = Path("broken.pdf")
result = indexer.process_file(p)
assert result is None # Ожидаем None при ошибке
mock_extract.assert_called_once_with(p)
def test_process_file_unsupported_extension():
"""Тестирует обработку файла с неподдерживаемым расширением."""
m_open = mock_open()
with patch('pathlib.Path.open', m_open):
p = Path("image.jpg")
result = indexer.process_file(p)
assert result is None
# --- Тесты для основной логики (scan_and_index_files) ---
# Эти тесты сложнее, так как требуют мокирования os.walk, get_indexed_files, update/delete и т.д.
# Пример одного сценария:
@patch('backend.indexer.Path.is_dir', return_value=True)
@patch('backend.indexer.Path.rglob')
@patch('backend.indexer.get_meili_client')
@patch('backend.indexer.get_indexed_files')
@patch('backend.indexer.update_meili_index')
@patch('backend.indexer.delete_from_meili_index')
@patch('backend.indexer.process_file')
def test_scan_and_index_new_file(
mock_process_file, mock_delete, mock_update, mock_get_indexed, mock_client, mock_rglob, mock_is_dir
):
"""Тестирует сценарий добавления нового файла."""
# Настройка моков
mock_get_indexed.return_value = {} # Индекс пуст
# Имитация файла на диске
new_file_path = MagicMock(spec=Path)
new_file_path.name = "new.txt"
new_file_path.is_file.return_value = True
new_file_path.suffix = ".txt"
new_file_path.stat.return_value.st_mtime = 100.0
mock_rglob.return_value = [new_file_path] # rglob находит один файл
# Имитация успешной обработки файла
mock_process_file.return_value = {"id": "new.txt", "content": "new file", "file_mtime": 100.0, "indexed_at": 101.0}
# Запуск функции
indexer.scan_and_index_files()
# Проверки
mock_get_indexed.assert_called_once() # Проверили индекс
mock_process_file.assert_called_once_with(new_file_path) # Обработали файл
mock_update.assert_called_once() # Вызвали обновление
update_args, _ = mock_update.call_args
assert len(update_args[1]) == 1 # Обновляем один документ
assert update_args[1][0]["id"] == "new.txt"
mock_delete.assert_not_called() # Ничего не удаляли
# TODO: Добавить больше тестов для scan_and_index_files:
# - Обновление существующего файла (mtime изменился)
# - Удаление файла (есть в индексе, нет локально)
# - Файл не изменился (пропуск)
# - Ошибка при обработке файла
# - Ошибка при взаимодействии с Meilisearch

View file

@ -1,22 +1,84 @@
# File: check_encoding.py
import os
import chardet
import argparse
import logging
DIRECTORY = "./" # Замени на путь к папке с файлами
# Настройка логирования
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def detect_encoding(filename):
with open(filename, "rb") as f:
raw_data = f.read()
result = chardet.detect(raw_data)
return result["encoding"]
def is_likely_utf8(filename: str) -> bool:
"""
Проверяет, можно ли успешно декодировать файл как UTF-8.
Возвращает True, если файл успешно декодирован или если возникла ошибка чтения файла.
Возвращает False, если произошла ошибка UnicodeDecodeError.
"""
try:
with open(filename, "rb") as f:
# Читаем весь файл. Для очень больших файлов можно ограничить чтение.
f.read().decode('utf-8')
return True
except UnicodeDecodeError:
# Это точно не UTF-8
return False
except FileNotFoundError:
logging.error(f"Файл не найден при проверке UTF-8: {filename}")
# Не можем быть уверены, но для целей проверки считаем 'проблемным'
return False
except Exception as e:
logging.error(f"Не удалось прочитать файл {filename} для проверки UTF-8: {e}")
# Не можем быть уверены, но для целей проверки считаем 'проблемным'
return False
def check_all_files_not_utf8(directory: str) -> None:
"""Проверяет все файлы в директории, сообщая о тех, что не в UTF-8."""
found_non_utf8 = False
checked_files = 0
problematic_files = []
def check_all_files(directory):
for root, _, files in os.walk(directory):
for file in files:
filepath = os.path.join(root, file)
encoding = detect_encoding(filepath)
if encoding and "utf-16" in encoding.lower():
print(f"{filepath} - {encoding}")
# Ограничимся проверкой текстовых файлов по расширению
if file.lower().endswith(('.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv')):
filepath = os.path.join(root, file)
checked_files += 1
if not is_likely_utf8(filepath):
problematic_files.append(filepath)
found_non_utf8 = True
print("🔍 Поиск файлов с UTF-16...")
check_all_files(DIRECTORY)
print("✅ Готово!")
logging.info(f"Проверено файлов с текстовыми расширениями: {checked_files}")
if found_non_utf8:
logging.warning(f"Найдены файлы ({len(problematic_files)}), которые не удалось прочитать как UTF-8:")
# Попробуем угадать кодировку для проблемных файлов, если chardet доступен
try:
import chardet
logging.info("Попытка определить кодировку для проблемных файлов (требуется chardet)...")
for filepath in problematic_files:
try:
with open(filepath, "rb") as f:
raw_data = f.read(8192) # Читаем начало файла для chardet
if not raw_data: continue # Пропускаем пустые
result = chardet.detect(raw_data)
encoding = result["encoding"] or "N/A"
confidence = result["confidence"] or 0.0
logging.warning(f" - {filepath} (предположительно {encoding}, уверенность {confidence:.2f})")
except Exception as e:
logging.warning(f" - {filepath} (не удалось определить кодировку: {e})")
except ImportError:
logging.warning("Модуль 'chardet' не установлен. Установите его (`pip install chardet`), чтобы попытаться определить кодировку автоматически.")
for filepath in problematic_files:
logging.warning(f" - {filepath}")
else:
logging.info("Все проверенные файлы успешно читаются как UTF-8.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Проверка текстовых файлов на соответствие кодировке UTF-8.")
parser.add_argument("directory", type=str, help="Путь к директории для проверки.")
args = parser.parse_args()
if not os.path.isdir(args.directory):
logging.error(f"Указанный путь не является директорией: {args.directory}")
else:
logging.info(f"🔍 Поиск файлов не в UTF-8 в директории: {args.directory}...")
check_all_files_not_utf8(args.directory)
logging.info("✅ Проверка завершена!")

View file

@ -7,6 +7,8 @@ services:
ports:
- "7700:7700"
environment:
# ВАЖНО: Для продакшена установите MEILI_MASTER_KEY!
# Например: MEILI_MASTER_KEY: 'your_strong_master_key'
- MEILI_NO_ANALYTICS=true
restart: unless-stopped
volumes:
@ -17,24 +19,19 @@ services:
container_name: backend
ports:
- "8000:8000"
env_file: .env
env_file: .env # Файл с переменными окружения (включая MEILI_API_KEY, если используется)
volumes:
- ${LOCAL_STORAGE_PATH}:/mnt/storage
# LOCAL_STORAGE_PATH должен быть определен в .env
- ${LOCAL_STORAGE_PATH}:/mnt/storage:ro # Монтируем только для чтения
depends_on:
- meilisearch
deploy:
resources:
reservations:
devices:
- driver: cifs
count: 1
capabilities: [gpu]
# Убрана секция deploy с резервированием GPU
frontend:
build: ./frontend
container_name: frontend
ports:
- "8080:80"
- "8080:80" # Можно изменить на другой порт, если 8080 занят
depends_on:
- backend
@ -42,17 +39,21 @@ services:
image: nginx:latest
container_name: nginx
ports:
- "80:80"
- "80:80" # Основной порт доступа к системе
volumes:
- ${LOCAL_STORAGE_PATH}:/usr/share/nginx/html/files
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Файлы больше не раздаются напрямую через Nginx из LOCAL_STORAGE_PATH
# Фронтенд будет получать их через бэкенд /files/{filename}
depends_on:
- backend
- frontend
volumes:
meili_data:
smb_share:
driver_opts:
type: cifs
o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0
device: ${SMB_STORAGE_PATH}
# Если SMB используется, убедитесь, что LOCAL_STORAGE_PATH в .env указывает на /mnt/smb_share
# smb_share:
# driver_opts:
# type: cifs
# o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0,uid=1000,gid=1000 # Добавьте uid/gid если нужно
# device: ${SMB_STORAGE_PATH}

View file

@ -1,29 +1,92 @@
# File: fix_encoding.py
import os
import chardet
import codecs
import codecs # Используем codecs для явного указания кодировок при чтении/записи
import argparse
import logging
DIRECTORY = "./local_files" # Путь к папке с файлами
# Настройка логирования
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def convert_to_utf8(filename):
with open(filename, "rb") as f:
raw_data = f.read()
result = chardet.detect(raw_data)
encoding = result["encoding"]
if encoding and "utf-16" in encoding.lower():
print(f"🔄 Конвертирую {filename} ({encoding}) в UTF-8...")
with codecs.open(filename, "r", encoding=encoding) as f:
content = f.read()
with codecs.open(filename, "w", encoding="utf-8") as f:
f.write(content)
print(f"{filename} теперь в UTF-8!")
def ensure_utf8_encoding(filename: str, source_encoding: str = 'cp1251') -> bool:
"""
Проверяет кодировку файла. Если не UTF-8, пытается конвертировать из source_encoding.
Возвращает True, если была произведена конвертация, False иначе.
"""
is_converted = False
try:
# 1. Пробуем прочитать как UTF-8, чтобы не трогать уже корректные файлы
with codecs.open(filename, "r", encoding='utf-8') as f:
f.read() # Просто читаем, чтобы проверить декодирование
logging.debug(f"Файл {filename} уже в UTF-8.")
return False # Конвертация не требуется
except UnicodeDecodeError:
# Файл точно не UTF-8, пробуем конвертировать из предполагаемой source_encoding
logging.info(f"Файл {filename} не в UTF-8. Попытка конвертации из {source_encoding}...")
try:
# Читаем с исходной кодировкой
with codecs.open(filename, "r", encoding=source_encoding) as f:
content = f.read()
# Перезаписываем файл в UTF-8
# Важно: Используем 'w' режим, который перезапишет файл
with codecs.open(filename, "w", encoding='utf-8') as f:
f.write(content)
logging.info(f"{filename} успешно конвертирован из {source_encoding} в UTF-8!")
is_converted = True
except UnicodeDecodeError:
# Не удалось прочитать даже как source_encoding
logging.warning(f"⚠️ Не удалось прочитать {filename} как {source_encoding} (после неудачи с UTF-8). Файл не изменен.")
except Exception as e:
logging.error(f"❌ Ошибка конвертации файла {filename} из {source_encoding}: {e}. Файл не изменен.")
except FileNotFoundError:
logging.error(f"Файл не найден при попытке конвертации: {filename}")
except Exception as e:
# Ловим другие возможные ошибки чтения на этапе проверки UTF-8
logging.error(f"Не удалось прочитать файл {filename} для проверки/конвертации: {e}")
return is_converted
def fix_all_files(directory: str, source_encoding: str = 'cp1251') -> None:
"""
Обходит директорию и конвертирует текстовые файлы из source_encoding в UTF-8,
если они еще не в UTF-8.
"""
converted_count = 0
processed_count = 0
# Список расширений текстовых файлов для обработки
text_extensions = ('.txt', '.md', '.html', '.htm', '.css', '.js', '.json', '.xml', '.csv', '.log', '.srt') # Добавь нужные
def fix_all_files(directory):
for root, _, files in os.walk(directory):
for file in files:
filepath = os.path.join(root, file)
convert_to_utf8(filepath)
# Проверяем расширение файла
if file.lower().endswith(text_extensions):
filepath = os.path.join(root, file)
processed_count += 1
if ensure_utf8_encoding(filepath, source_encoding):
converted_count += 1
else:
logging.debug(f"Пропуск файла с нетекстовым расширением: {file}")
print("🔍 Исправление кодировки...")
fix_all_files(DIRECTORY)
print("✅ Готово!")
logging.info(f"Проверено текстовых файлов: {processed_count}. Конвертировано в UTF-8: {converted_count}.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Конвертация текстовых файлов из указанной кодировки (по умолч. cp1251) в UTF-8.")
parser.add_argument("directory", type=str, help="Путь к директории для конвертации.")
parser.add_argument(
"--source-encoding",
type=str,
default="cp1251",
help="Предполагаемая исходная кодировка файлов, не являющихся UTF-8 (по умолчанию: cp1251).",
)
args = parser.parse_args()
if not os.path.isdir(args.directory):
logging.error(f"Указанный путь не является директорией: {args.directory}")
else:
logging.info(f"🛠️ Запуск исправления кодировки в директории: {args.directory}...")
logging.info(f"Предполагаемая исходная кодировка для конвертации: {args.source_encoding}")
fix_all_files(args.directory, args.source_encoding)
logging.info("✅ Исправление кодировки завершено!")

View file

@ -1,2 +1,17 @@
FROM nginx:latest
# Используем официальный образ Nginx
FROM nginx:stable-alpine
# Удаляем стандартную конфигурацию Nginx
RUN rm /etc/nginx/conf.d/default.conf
# Копируем нашу конфигурацию (если она есть в папке frontend, иначе она берется из ./nginx)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Копируем статичные файлы фронтенда
COPY index.html /usr/share/nginx/html/index.html
# Открываем порт 80
EXPOSE 80
# Команда для запуска Nginx
CMD ["nginx", "-g", "daemon off;"]

View file

@ -2,25 +2,82 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Поиск</title>
<title>Поиск по документам</title>
<style>
body { font-family: sans-serif; line-height: 1.6; padding: 20px; }
#query { padding: 10px; width: 300px; margin-right: 5px; }
button { padding: 10px; cursor: pointer; }
#results { list-style: none; padding: 0; margin-top: 20px; }
#results li { border: 1px solid #ddd; margin-bottom: 15px; padding: 15px; border-radius: 4px; background-color: #f9f9f9; }
#results li a { font-weight: bold; color: #007bff; text-decoration: none; }
#results li a:hover { text-decoration: underline; }
.snippet { margin-top: 8px; color: #555; font-size: 0.9em; }
.snippet em { font-weight: bold; background-color: yellow; } /* Подсветка */
#status { margin-top: 15px; font-style: italic; color: #888; }
</style>
</head>
<body>
<input id="query" type="text" placeholder="Введите запрос">
<h1>Поиск по документам</h1>
<input id="query" type="text" placeholder="Введите поисковый запрос" onkeyup="handleKey(event)">
<button onclick="search()">Искать</button>
<div id="status"></div>
<ul id="results"></ul>
<script>
const searchInput = document.getElementById("query");
const resultsList = document.getElementById("results");
const statusDiv = document.getElementById("status");
function handleKey(event) {
// Запускаем поиск по нажатию Enter
if (event.key === "Enter") {
search();
}
}
async function search() {
let query = document.getElementById("query").value;
let res = await fetch(`/search?q=` + query);
let data = await res.json();
let list = document.getElementById("results");
list.innerHTML = "";
data.results.forEach(doc => {
let item = document.createElement("li");
item.innerHTML = `<a href="/files/${doc.id}">${doc.id}</a>: ${doc.content.substring(0, 200)}...`;
list.appendChild(item);
});
let query = searchInput.value.trim();
if (!query) {
resultsList.innerHTML = "";
statusDiv.textContent = "Введите запрос для поиска.";
return;
}
resultsList.innerHTML = ""; // Очищаем предыдущие результаты
statusDiv.textContent = "Идет поиск..."; // Показываем статус
try {
// Используем относительный путь, т.к. Nginx проксирует /search
const response = await fetch(`/search?q=${encodeURIComponent(query)}&limit=50`); // Запрашиваем до 50 результатов
if (!response.ok) {
throw new Error(`Ошибка сервера: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.results && data.results.length > 0) {
statusDiv.textContent = `Найдено результатов: ${data.results.length}`;
data.results.forEach(doc => {
const item = document.createElement("li");
// Ссылка ведет на эндпоинт бэкенда для скачивания файла
// Используем doc.id (имя файла)
const fileLink = `<a href="/files/${encodeURIComponent(doc.id)}" target="_blank">${doc.id}</a>`;
// Отображаем подсвеченный фрагмент (_formatted.content)
const snippetHTML = doc.content ? `<div class="snippet">${doc.content}</div>` : '<div class="snippet">(нет превью)</div>';
item.innerHTML = `${fileLink}${snippetHTML}`;
resultsList.appendChild(item);
});
} else {
statusDiv.textContent = "Ничего не найдено.";
}
} catch (error) {
console.error("Ошибка поиска:", error);
statusDiv.textContent = `Ошибка: ${error.message}. Попробуйте еще раз позже.`;
resultsList.innerHTML = ""; // Очищаем на случай ошибки
}
}
</script>
</body>

View file

@ -1,17 +1,58 @@
server {
listen 80;
server_name localhost; # Или ваш домен
# Корень для статики фронтенда (HTML, CSS, JS)
location / {
# Указываем путь, куда Nginx копирует файлы из frontend/Dockerfile
root /usr/share/nginx/html;
index index.html;
}
location /files/ {
root /usr/share/nginx/html;
autoindex on;
try_files $uri $uri/ /index.html; # Обслуживать index.html для SPA-подобного поведения
}
# Проксирование запросов поиска на бэкенд
location /search {
proxy_pass http://backend:8000;
proxy_pass http://backend:8000; # Имя сервиса из docker-compose
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Проксирование запросов на получение файлов на бэкенд
location /files/ {
proxy_pass http://backend:8000; # Перенаправляем на корень бэкенда
# FastAPI сам разберет /files/{filename}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Опционально: увеличить таймауты для больших файлов
# proxy_connect_timeout 600;
# proxy_send_timeout 600;
# proxy_read_timeout 600;
# send_timeout 600;
}
# Опционально: Проксирование health-check эндпоинта
location /health {
proxy_pass http://backend:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Чтобы только внутренняя сеть могла проверять health (если нужно)
# allow 172.16.0.0/12; # Пример диапазона Docker сети
# deny all;
}
# Отключить логи доступа для статики (опционально)
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# Обработка ошибок (опционально)
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# root /usr/share/nginx/html;
# }
}