mirror of
https://github.com/intari/search2_chatgpt.git
synced 2025-09-01 01:59:36 +00:00
немногожко тестов
This commit is contained in:
parent
957362f0a8
commit
b17fd050f3
14 changed files with 1020 additions and 123 deletions
|
@ -1,8 +1,28 @@
|
||||||
{
|
{
|
||||||
"name": "Search Dev",
|
"name": "Document Search Dev",
|
||||||
"dockerComposeFile": "docker-compose.yml",
|
"dockerComposeFile": "../docker-compose.yml", // Путь относительно .devcontainer папки
|
||||||
"service": "backend",
|
"service": "backend", // Сервис, к которому подключается VS Code
|
||||||
"workspaceFolder": "/app",
|
"workspaceFolder": "/app", // Рабочая папка внутри контейнера backend
|
||||||
"extensions": ["ms-python.python"],
|
"customizations": {
|
||||||
"remoteUser": "root"
|
"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 его использует
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
|
# backend/Dockerfile
|
||||||
|
|
||||||
FROM python:3.11
|
FROM python:3.11
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем сначала requirements, чтобы кэшировать слой установки зависимостей
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
# Устанавливаем зависимости приложения и тестов
|
||||||
|
# Используем --no-cache-dir для уменьшения размера образа
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем остальной код приложения и тесты
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Команда по умолчанию для запуска приложения
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
# Пример команды для запуска тестов (можно использовать при CI/CD или вручную)
|
||||||
|
# RUN pytest
|
||||||
|
|
118
backend/app.py
118
backend/app.py
|
@ -1,24 +1,116 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Query, Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import requests
|
import requests
|
||||||
import os
|
import os
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Загрузка переменных окружения (например, из .env)
|
||||||
load_dotenv()
|
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):
|
FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") # Путь монтирования в Docker
|
||||||
response = requests.get(f"{SEARCH_ENGINE}/indexes/documents/search", params={"q": q})
|
SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
|
||||||
results = response.json()
|
MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Search API Key
|
||||||
return {"results": results.get("hits", [])}
|
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)
|
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 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}
|
||||||
|
|
||||||
|
|
|
@ -1,47 +1,297 @@
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
from pdfminer.high_level import extract_text
|
import time
|
||||||
from ebooklib import epub
|
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 bs4 import BeautifulSoup
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Загрузка переменных окружения
|
||||||
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)
|
FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage")
|
||||||
text = []
|
SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
|
||||||
for item in book.get_items():
|
MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Index API Key
|
||||||
if item.get_type() == 9:
|
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")
|
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():
|
# --- Функции взаимодействия с Meilisearch ---
|
||||||
docs = []
|
|
||||||
for filename in os.listdir(FILES_DIR):
|
|
||||||
file_path = os.path.join(FILES_DIR, filename)
|
|
||||||
|
|
||||||
if filename.endswith(".txt"):
|
def get_meili_client() -> requests.Session:
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
"""Создает и настраивает HTTP клиент для Meilisearch."""
|
||||||
content = f.read()
|
session = requests.Session()
|
||||||
elif filename.endswith(".pdf"):
|
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)
|
content = extract_text_from_pdf(file_path)
|
||||||
elif filename.endswith(".epub"):
|
elif file_ext == ".epub":
|
||||||
content = extract_text_from_epub(file_path)
|
content = extract_text_from_epub(file_path)
|
||||||
else:
|
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__":
|
if __name__ == "__main__":
|
||||||
index_files()
|
scan_and_index_files()
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn[standard] # Включает поддержку websockets и др., [standard] рекомендован uvicorn
|
||||||
requests
|
requests
|
||||||
pdfminer.six
|
pdfminer.six
|
||||||
ebooklib
|
ebooklib
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
# Зависимости для тестов
|
||||||
|
pytest
|
||||||
|
httpx # Для FastAPI TestClient
|
||||||
|
|
1
backend/tests/init.py
Normal file
1
backend/tests/init.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Пустой файл, нужен чтобы Python распознал папку как пакет
|
108
backend/tests/test_app.py
Normal file
108
backend/tests/test_app.py
Normal 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 и создания временного файла)
|
174
backend/tests/test_indexer.py
Normal file
174
backend/tests/test_indexer.py
Normal 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
|
|
@ -1,22 +1,84 @@
|
||||||
|
# File: check_encoding.py
|
||||||
import os
|
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):
|
def is_likely_utf8(filename: str) -> bool:
|
||||||
with open(filename, "rb") as f:
|
"""
|
||||||
raw_data = f.read()
|
Проверяет, можно ли успешно декодировать файл как UTF-8.
|
||||||
result = chardet.detect(raw_data)
|
Возвращает True, если файл успешно декодирован или если возникла ошибка чтения файла.
|
||||||
return result["encoding"]
|
Возвращает 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 root, _, files in os.walk(directory):
|
||||||
for file in files:
|
for file in files:
|
||||||
filepath = os.path.join(root, file)
|
# Ограничимся проверкой текстовых файлов по расширению
|
||||||
encoding = detect_encoding(filepath)
|
if file.lower().endswith(('.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv')):
|
||||||
if encoding and "utf-16" in encoding.lower():
|
filepath = os.path.join(root, file)
|
||||||
print(f"❌ {filepath} - {encoding}")
|
checked_files += 1
|
||||||
|
if not is_likely_utf8(filepath):
|
||||||
|
problematic_files.append(filepath)
|
||||||
|
found_non_utf8 = True
|
||||||
|
|
||||||
print("🔍 Поиск файлов с UTF-16...")
|
logging.info(f"Проверено файлов с текстовыми расширениями: {checked_files}")
|
||||||
check_all_files(DIRECTORY)
|
if found_non_utf8:
|
||||||
print("✅ Готово!")
|
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("✅ Проверка завершена!")
|
||||||
|
|
|
@ -7,6 +7,8 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "7700:7700"
|
- "7700:7700"
|
||||||
environment:
|
environment:
|
||||||
|
# ВАЖНО: Для продакшена установите MEILI_MASTER_KEY!
|
||||||
|
# Например: MEILI_MASTER_KEY: 'your_strong_master_key'
|
||||||
- MEILI_NO_ANALYTICS=true
|
- MEILI_NO_ANALYTICS=true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -17,24 +19,19 @@ services:
|
||||||
container_name: backend
|
container_name: backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
env_file: .env
|
env_file: .env # Файл с переменными окружения (включая MEILI_API_KEY, если используется)
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOCAL_STORAGE_PATH}:/mnt/storage
|
# LOCAL_STORAGE_PATH должен быть определен в .env
|
||||||
|
- ${LOCAL_STORAGE_PATH}:/mnt/storage:ro # Монтируем только для чтения
|
||||||
depends_on:
|
depends_on:
|
||||||
- meilisearch
|
- meilisearch
|
||||||
deploy:
|
# Убрана секция deploy с резервированием GPU
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: cifs
|
|
||||||
count: 1
|
|
||||||
capabilities: [gpu]
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80" # Можно изменить на другой порт, если 8080 занят
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
@ -42,17 +39,21 @@ services:
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80" # Основной порт доступа к системе
|
||||||
volumes:
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
meili_data:
|
meili_data:
|
||||||
smb_share:
|
# Если SMB используется, убедитесь, что LOCAL_STORAGE_PATH в .env указывает на /mnt/smb_share
|
||||||
driver_opts:
|
# smb_share:
|
||||||
type: cifs
|
# driver_opts:
|
||||||
o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0
|
# type: cifs
|
||||||
device: ${SMB_STORAGE_PATH}
|
# o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0,uid=1000,gid=1000 # Добавьте uid/gid если нужно
|
||||||
|
# device: ${SMB_STORAGE_PATH}
|
||||||
|
|
||||||
|
|
107
fix_encoding.py
107
fix_encoding.py
|
@ -1,29 +1,92 @@
|
||||||
|
# File: fix_encoding.py
|
||||||
import os
|
import os
|
||||||
import chardet
|
import codecs # Используем codecs для явного указания кодировок при чтении/записи
|
||||||
import 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):
|
def ensure_utf8_encoding(filename: str, source_encoding: str = 'cp1251') -> bool:
|
||||||
with open(filename, "rb") as f:
|
"""
|
||||||
raw_data = f.read()
|
Проверяет кодировку файла. Если не UTF-8, пытается конвертировать из source_encoding.
|
||||||
result = chardet.detect(raw_data)
|
Возвращает True, если была произведена конвертация, False иначе.
|
||||||
encoding = result["encoding"]
|
"""
|
||||||
|
is_converted = False
|
||||||
if encoding and "utf-16" in encoding.lower():
|
try:
|
||||||
print(f"🔄 Конвертирую {filename} ({encoding}) в UTF-8...")
|
# 1. Пробуем прочитать как UTF-8, чтобы не трогать уже корректные файлы
|
||||||
with codecs.open(filename, "r", encoding=encoding) as f:
|
with codecs.open(filename, "r", encoding='utf-8') as f:
|
||||||
content = f.read()
|
f.read() # Просто читаем, чтобы проверить декодирование
|
||||||
with codecs.open(filename, "w", encoding="utf-8") as f:
|
logging.debug(f"Файл {filename} уже в UTF-8.")
|
||||||
f.write(content)
|
return False # Конвертация не требуется
|
||||||
print(f"✅ {filename} теперь в UTF-8!")
|
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 root, _, files in os.walk(directory):
|
||||||
for file in files:
|
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("🔍 Исправление кодировки...")
|
logging.info(f"Проверено текстовых файлов: {processed_count}. Конвертировано в UTF-8: {converted_count}.")
|
||||||
fix_all_files(DIRECTORY)
|
|
||||||
print("✅ Готово!")
|
|
||||||
|
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("✅ Исправление кодировки завершено!")
|
||||||
|
|
|
@ -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
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
|
|
||||||
|
# Открываем порт 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Команда для запуска Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
|
@ -2,25 +2,82 @@
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<input id="query" type="text" placeholder="Введите запрос">
|
<h1>Поиск по документам</h1>
|
||||||
|
<input id="query" type="text" placeholder="Введите поисковый запрос" onkeyup="handleKey(event)">
|
||||||
<button onclick="search()">Искать</button>
|
<button onclick="search()">Искать</button>
|
||||||
|
<div id="status"></div>
|
||||||
<ul id="results"></ul>
|
<ul id="results"></ul>
|
||||||
|
|
||||||
<script>
|
<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() {
|
async function search() {
|
||||||
let query = document.getElementById("query").value;
|
let query = searchInput.value.trim();
|
||||||
let res = await fetch(`/search?q=` + query);
|
if (!query) {
|
||||||
let data = await res.json();
|
resultsList.innerHTML = "";
|
||||||
let list = document.getElementById("results");
|
statusDiv.textContent = "Введите запрос для поиска.";
|
||||||
list.innerHTML = "";
|
return;
|
||||||
data.results.forEach(doc => {
|
}
|
||||||
let item = document.createElement("li");
|
|
||||||
item.innerHTML = `<a href="/files/${doc.id}">${doc.id}</a>: ${doc.content.substring(0, 200)}...`;
|
resultsList.innerHTML = ""; // Очищаем предыдущие результаты
|
||||||
list.appendChild(item);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -1,17 +1,58 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_name localhost; # Или ваш домен
|
||||||
|
|
||||||
|
# Корень для статики фронтенда (HTML, CSS, JS)
|
||||||
location / {
|
location / {
|
||||||
|
# Указываем путь, куда Nginx копирует файлы из frontend/Dockerfile
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
try_files $uri $uri/ /index.html; # Обслуживать index.html для SPA-подобного поведения
|
||||||
}
|
|
||||||
|
|
||||||
location /files/ {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
autoindex on;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Проксирование запросов поиска на бэкенд
|
||||||
location /search {
|
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;
|
||||||
|
# }
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue