greeta commited on
Commit
8383076
·
verified ·
1 Parent(s): 3315096

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +35 -0
  2. README.md +429 -0
  3. app.py +337 -0
  4. requirements.txt +23 -0
  5. scraper.py +218 -0
  6. supabase_client.py +192 -0
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Установка рабочих переменных
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
8
+ PIP_DEFAULT_TIMEOUT=100
9
+
10
+ # Установка рабочей директории
11
+ WORKDIR /app
12
+
13
+ # Установка системных зависимостей
14
+ RUN apt-get update && apt-get install -y \
15
+ curl \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Копирование и установка зависимостей (отдельным слоем для кэша)
19
+ # FIX: 2026-03-10 - очистка кэша pip
20
+ COPY requirements.txt .
21
+ RUN pip install --upgrade pip --force-reinstall
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # Копирование исходного кода
25
+ COPY . .
26
+
27
+ # Экспозиция порта
28
+ EXPOSE 7860
29
+
30
+ # Переменные окружения по умолчанию
31
+ ENV PORT=7860
32
+ ENV HOST=0.0.0.0
33
+
34
+ # Запуск приложения
35
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Scraper ФИПИ
3
+ emoji: 🕷️
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ sdk_version: "docker"
8
+ python_version: "3.11"
9
+ app_file: app.py
10
+ pinned: false
11
+ ---
12
+
13
+ # 🕷️ AI Scraper ФИПИ
14
+
15
+ Сервис для автоматического сбора заданий с сайта ФИПИ (fipi.ru) с использованием AI-анализа на основе **ruBERT**.
16
+
17
+ ---
18
+
19
+ ## ✨ Возможности
20
+
21
+ - 🕷️ **Автоматический парсинг** заданий с сайта ФИПИ
22
+ - 🧠 **AI-классификация** заданий через ruBERT
23
+ - 💾 **Сохранение в Supabase** с автоматическим обновлением
24
+ - 🚀 **Деплой на Hugging Face Spaces**
25
+ - 📊 **REST API** для доступа к заданиям
26
+ - 🔍 **Поиск** по заданиям
27
+ - 📈 **Статистика** и аналитика
28
+
29
+ ---
30
+
31
+ ## 📁 Структура
32
+
33
+ ```
34
+ ai-scraper/
35
+ ├── app.py # Основное FastAPI приложение
36
+ ├── scraper.py # Парсер сайта ФИПИ
37
+ ├── rubert_client.py # Клиент для ruBERT API
38
+ ├── supabase_client.py # Интеграция с Supabase
39
+ ├── models.py # Pydantic модели
40
+ ├── requirements.txt # Python зависимости
41
+ ├── schema.sql # SQL схема для Supabase
42
+ ├── Dockerfile # Docker конфигурация
43
+ ├── hf_spaces_config.yaml # Конфиг для Hugging Face
44
+ ├── .env.example # Шаблон переменных окружения
45
+ └── README.md # Документация
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 🚀 Быстрый старт
51
+
52
+ ### 1. Локальная разработка
53
+
54
+ ```bash
55
+ cd ai-scraper
56
+
57
+ # Создайте виртуальное окружение
58
+ python -m venv venv
59
+
60
+ # Активируйте
61
+ venv\Scripts\activate # Windows
62
+ source venv/bin/activate # Linux/Mac
63
+
64
+ # Установите зависимости
65
+ pip install -r requirements.txt
66
+
67
+ # Скопируйте .env.example в .env (опционально)
68
+ cp .env.example .env
69
+
70
+ # Заполните .env своими ключами
71
+ # ИЛИ настройте переменные окружения в вашей системе
72
+
73
+ # Запустите сервер
74
+ uvicorn app:app --reload --host 0.0.0.0 --port 8000
75
+ ```
76
+
77
+ Откройте http://localhost:8000/docs для Swagger UI.
78
+
79
+ ---
80
+
81
+ ## ⚙️ Переменные окружения
82
+
83
+ ### Для локальной разработки
84
+
85
+ Скопируйте `.env.example` в `.env` и заполните своими значениями:
86
+
87
+ ```env
88
+ SUPABASE_URL=https://your-project.supabase.co
89
+ SUPABASE_SERVICE_KEY=your-service-role-key
90
+ RUBERT_URL=https://your-rubert.hf.space
91
+ FIPI_BASE_URL=https://fipi.ru
92
+ ```
93
+
94
+ ### Для Hugging Face Spaces
95
+
96
+ **Не нужно загружать `.env` файл!** Настройте переменные через интерфейс:
97
+
98
+ 1. Откройте ваш Space
99
+ 2. Перейдите в **Settings** → **Secrets**
100
+ 3. Добавьте переменные:
101
+ - `SUPABASE_URL`
102
+ - `SUPABASE_SERVICE_KEY`
103
+ - `RUBERT_URL` (опционально)
104
+ - `FIPI_BASE_URL` (опционально)
105
+
106
+ ---
107
+
108
+ ## 🗄️ Настройка Supabase
109
+
110
+ ### 1. Создайте проект
111
+
112
+ Перейдите на [Supabase](https://supabase.com) и создайте новый проект.
113
+
114
+ ### 2. Выполните SQL скрипт
115
+
116
+ 1. Откройте [SQL Editor](https://supabase.com/dashboard/project/_/sql/new)
117
+ 2. Скопируйте содержимое `schema.sql`
118
+ 3. Нажмите **Run**
119
+
120
+ ### 3. Получите ключи
121
+
122
+ 1. Перейдите в **Settings** → **API**
123
+ 2. Скопируйте:
124
+ - **Project URL** → `SUPABASE_URL`
125
+ - **service_role key** → `SUPABASE_SERVICE_KEY`
126
+
127
+ ---
128
+
129
+ ## 🧠 Настройка ruBERT
130
+
131
+ ### Вариант 1: Использование существующего API
132
+
133
+ Если у вас уже есть развернутый ruBERT (как в основном проекте):
134
+
135
+ ```env
136
+ RUBERT_URL=https://your-rubert-instance.hf.space
137
+ ```
138
+
139
+ ### Вариант 2: Развертывание ruBERT
140
+
141
+ Создайте новый Space на Hugging Face с моделью ruBERT:
142
+
143
+ 1. [RuBERT от DeepPavlov](https://huggingface.co/deepvk/rubert-base-cased)
144
+ 2. Используйте шаблон Gradio или FastAPI
145
+ 3. Добавьте эндпоинты `/api/analyze` и `/api/embedding`
146
+
147
+ ---
148
+
149
+ ## 🌐 Деплой на Hugging Face Spaces
150
+
151
+ ### Шаг 1: Создайте Space
152
+
153
+ 1. Перейдите на [Hugging Face Spaces](https://huggingface.co/spaces)
154
+ 2. Нажмите **Create new Space**
155
+ 3. Заполните:
156
+ - **Space name**: `fipi-ai-scraper`
157
+ - **License**: MIT
158
+ - **SDK**: Docker
159
+ - **Visibility**: Public или Private
160
+
161
+ ### Шаг 2: Загрузите файлы
162
+
163
+ ```bash
164
+ # Инициализируйте git в папке ai-scraper
165
+ cd ai-scraper
166
+ git init
167
+ git add .
168
+ git commit -m "Initial commit"
169
+
170
+ # Добавьте remote вашего Space
171
+ git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/fipi-ai-scraper
172
+
173
+ # Push в Space
174
+ git push -u origin main
175
+ ```
176
+
177
+ ### Шаг 3: Настройте переменные окружения
178
+
179
+ **Важно:** Не загружайте `.env` файл в репозиторий!
180
+
181
+ В Settings вашего Space добавьте в **Secrets**:
182
+
183
+ | Variable | Value |
184
+ |----------|-------|
185
+ | `SUPABASE_URL` | https://your-project.supabase.co |
186
+ | `SUPABASE_SERVICE_KEY` | ваш service key |
187
+ | `RUBERT_URL` | https://your-rubert.hf.space |
188
+ | `FIPI_BASE_URL` | https://fipi.ru |
189
+
190
+ ⚠️ **Примечание:** Переменные окружения добавляются через интерфейс Hugging Face:
191
+ **Settings** → **Repository secrets** → **New secret**
192
+
193
+ ### Шаг 4: Дождитесь деплоя
194
+
195
+ Space автоматически соберет Docker образ и запустит приложение.
196
+
197
+ ---
198
+
199
+ ## 📡 API Endpoints
200
+
201
+ | Метод | Эндпоинт | Описание |
202
+ |-------|----------|----------|
203
+ | GET | `/` | Информация об API |
204
+ | GET | `/api/health` | Проверка статуса сервиса |
205
+ | GET | `/api/tasks` | Получить все задания |
206
+ | GET | `/api/tasks/latest` | Последние добавленные задания |
207
+ | GET | `/api/tasks/{task_id}` | Получить задание по ID |
208
+ | GET | `/api/tasks/type/{type}` | Задания по типу |
209
+ | GET | `/api/tasks/search?q=` | Поиск заданий |
210
+ | POST | `/api/scrape` | Запустить парсинг ФИПИ |
211
+ | POST | `/api/analyze` | AI анализ существующих заданий |
212
+ | GET | `/api/stats` | Статистика по заданиям |
213
+
214
+ ---
215
+
216
+ ## 📝 Примеры использования
217
+
218
+ ### Python
219
+
220
+ ```python
221
+ import requests
222
+
223
+ BASE_URL = "https://your-space.hf.space"
224
+
225
+ # Получить последние задания
226
+ response = requests.get(f"{BASE_URL}/api/tasks/latest?limit=10")
227
+ tasks = response.json()
228
+ print(f"Найдено заданий: {len(tasks)}")
229
+
230
+ # Запустить парсинг ФИПИ
231
+ response = requests.post(f"{BASE_URL}/api/scrape")
232
+ result = response.json()
233
+ print(result["message"])
234
+
235
+ # Поиск заданий
236
+ response = requests.get(f"{BASE_URL}/api/tasks/search?q=сочинение")
237
+ tasks = response.json()
238
+
239
+ # Получить статистику
240
+ response = requests.get(f"{BASE_URL}/api/stats")
241
+ stats = response.json()
242
+ print(f"Всего заданий: {stats['total_tasks']}")
243
+ ```
244
+
245
+ ### cURL
246
+
247
+ ```bash
248
+ # Health check
249
+ curl https://your-space.hf.space/api/health
250
+
251
+ # Получить задания
252
+ curl https://your-space.hf.space/api/tasks/latest
253
+
254
+ # Запустить скрапинг
255
+ curl -X POST https://your-space.hf.space/api/scrape \
256
+ -H "Content-Type: application/json" \
257
+ -d '{"subject": "russian"}'
258
+
259
+ # Поиск
260
+ curl "https://your-space.hf.space/api/tasks/search?q=ЕГЭ"
261
+ ```
262
+
263
+ ---
264
+
265
+ ## 🔧 Конфигурация
266
+
267
+ ### Переменные окружения
268
+
269
+ | Переменная | Описание | Пример |
270
+ |------------|----------|--------|
271
+ | `SUPABASE_URL` | URL проекта Supabase | `https://xxx.supabase.co` |
272
+ | `SUPABASE_SERVICE_KEY` | Service role ключ Supabase | `eyJhbG...` |
273
+ | `RUBERT_URL` | URL ruBERT API | `https://rubert.hf.space` |
274
+ | `FIPI_BASE_URL` | Базовый URL ФИПИ | `https://fipi.ru` |
275
+ | `PORT` | Порт приложения | `7860` |
276
+ | `HOST` | Хост приложения | `0.0.0.0` |
277
+
278
+ ---
279
+
280
+ ## 🏗️ Архитектура
281
+
282
+ ```
283
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
284
+ │ FIPIScraper │────▶│ RuBERTClient │────▶│ SupabaseClient │
285
+ │ (BeautifulSoup)│ │ (HTTP API) │ │ (Supabase JS) │
286
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
287
+ │ │ │
288
+ ▼ ▼ ▼
289
+ fipi.ru ruBERT HF Supabase DB
290
+ (парсинг) (AI анализ) (хранение)
291
+ ```
292
+
293
+ ### Поток данных
294
+
295
+ 1. **Скрапинг**: `FIPIScraper` парсит задания с fipi.ru
296
+ 2. **Анализ**: `RuBERTClient` анализирует текст задания
297
+ 3. **Сохранение**: `SupabaseClient` сохраняет в базу данных
298
+ 4. **API**: FastAPI предоставляет REST эндпоинты
299
+
300
+ ---
301
+
302
+ ## 🔒 Безопасность
303
+
304
+ - ✅ **RLS (Row Level Security)** в Supabase
305
+ - ✅ **Валидация данных** через Pydantic
306
+ - ✅ **CORS** настроен для API
307
+ - ✅ **Service Key** не экспонируется на клиенте
308
+
309
+ ---
310
+
311
+ ## 🛠️ Решение проблем
312
+
313
+ ### "Supabase не настроен"
314
+
315
+ Проверьте переменные окружения:
316
+ ```bash
317
+ echo $SUPABASE_URL
318
+ echo $SUPABASE_SERVICE_KEY
319
+ ```
320
+
321
+ ### "RuBERT клиент не настроен"
322
+
323
+ Убедитесь, что `RUBERT_URL` указан и API доступен:
324
+ ```bash
325
+ curl https://your-rubert.hf.space/api/health
326
+ ```
327
+
328
+ ### Ошибки при скрапинге
329
+
330
+ Сайт ФИПИ может блокировать запросы. Попробуйте:
331
+ - Изменить `User-Agent` в `scraper.py`
332
+ - Использовать прокси
333
+ - Добавить задержки между запросами
334
+
335
+ ### Docker не собирается
336
+
337
+ Проверьте логи:
338
+ ```bash
339
+ docker build -t fipi-scraper .
340
+ docker run -p 7860:7860 fipi-scraper
341
+ ```
342
+
343
+ ### Configuration error / Missing .env
344
+
345
+ **Это нормально!** Для Hugging Face Spaces:
346
+ 1. Не загружайте `.env` в репозиторий
347
+ 2. Настройте переменные через **Settings** → **Repository secrets**
348
+ 3. Файл `.env.example` существует только для документации
349
+
350
+ ---
351
+
352
+ ## 📝 История изменений
353
+
354
+ ### Март 2026 - Исправление парсера ФИПИ
355
+
356
+ **Исправленные проблемы:**
357
+ - ❌ Неверные URL-адреса (404 ошибки)
358
+ - ❌ SSL ошибки для поддоменов
359
+ - ❌ Некорректный парсинг заголовков
360
+
361
+ **Результат:**
362
+ - ✅ Найдено заданий: 0 → 12
363
+ - ✅ Все запросы возвращают 200 OK
364
+
365
+ Подробности в [FIXES.md](FIXES.md)
366
+
367
+ ---
368
+
369
+ ## 📊 Мониторинг
370
+
371
+ ### Логи приложения
372
+
373
+ ```bash
374
+ # Логи в Hugging Face Space
375
+ # Settings → Logs
376
+
377
+ # Локально
378
+ uvicorn app:app --log-level debug
379
+ ```
380
+
381
+ ### Метрики
382
+
383
+ - `/api/health` — статус сервисов
384
+ - `/api/stats` — статистика заданий
385
+
386
+ ---
387
+
388
+ ## 🤝 Интеграция с основным проектом
389
+
390
+ Этот сервис дополняет основной проект `refined-main`:
391
+
392
+ 1. **Импорт заданий** из ФИПИ в базу
393
+ 2. **AI-анализ** через тот же ruBERT
394
+ 3. **Единая Supabase** для обоих сервисов
395
+
396
+ ### Подключение
397
+
398
+ В основном проекте добавьте:
399
+
400
+ ```typescript
401
+ // services/fipiTasks.ts
402
+ const FIPI_SCRAPER_URL = 'https://fipi-ai-scraper.hf.space';
403
+
404
+ export async function fetchLatestTasks(limit = 10) {
405
+ const response = await fetch(`${FIPI_SCRAPER_URL}/api/tasks/latest?limit=${limit}`);
406
+ return response.json();
407
+ }
408
+ ```
409
+
410
+ ---
411
+
412
+ ## 📚 Дополнительные ресурсы
413
+
414
+ - [Документация FastAPI](https://fastapi.tiangolo.com/)
415
+ - [Документация Supabase](https://supabase.com/docs)
416
+ - [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces)
417
+ - [ruBERT модель](https://huggingface.co/deepvk/rubert-base-cased)
418
+ - [ФИПИ](https://fipi.ru/)
419
+
420
+ ---
421
+
422
+ ## 📄 Лицензия
423
+
424
+ MIT License
425
+
426
+ ---
427
+
428
+ **Последнее обновление:** Март 2026
429
+ **Статус:** ✅ Готово к деплою
app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI приложение для AI Scraper ФИПИ
3
+ Деплой на Hugging Face Spaces
4
+ """
5
+
6
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from typing import List
9
+ from datetime import datetime
10
+ import logging
11
+ import os
12
+
13
+ from models import (
14
+ TaskResponse,
15
+ ScrapeRequest,
16
+ ScrapeResponse,
17
+ HealthResponse,
18
+ StatsResponse,
19
+ ErrorResponse,
20
+ )
21
+ from scraper import FIPIScraper
22
+ from rubert_client import RuBERTClient
23
+
24
+ # Настройка логирования
25
+ logging.basicConfig(level=logging.INFO)
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Supabase - опционально (может отсутствовать из-за конфликта версий)
29
+ try:
30
+ from supabase_client import SupabaseClient
31
+ SUPABASE_AVAILABLE = True
32
+ except ImportError:
33
+ SupabaseClient = None
34
+ SUPABASE_AVAILABLE = False
35
+ logger.warning("Supabase клиент недоступен - работаем без базы данных")
36
+
37
+ # Инициализация приложения
38
+ app = FastAPI(
39
+ title="AI Scraper ФИПИ",
40
+ description="Сервис для автоматического сбора заданий с сайта ФИПИ с AI-анализом на ruBERT",
41
+ version="1.0.0",
42
+ )
43
+
44
+ # CORS middleware
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=["*"],
48
+ allow_credentials=True,
49
+ allow_methods=["*"],
50
+ allow_headers=["*"],
51
+ )
52
+
53
+ # Глобальные клиенты
54
+ scraper: FIPIScraper = None
55
+ rubert_client: RuBERTClient = None
56
+ supabase_client: SupabaseClient = None
57
+
58
+
59
+ @app.on_event("startup")
60
+ async def startup_event():
61
+ """Инициализация клиентов при старте"""
62
+ global scraper, rubert_client, supabase_client
63
+
64
+ # Инициализация скрапера
65
+ fipi_url = os.getenv("FIPI_BASE_URL", "https://fipi.ru")
66
+ scraper = FIPIScraper(base_url=fipi_url)
67
+ logger.info(f"FIPIScraper инициализирован: {fipi_url}")
68
+
69
+ # Инициализация ruBERT клиента
70
+ rubert_url = os.getenv("RUBERT_URL")
71
+ if rubert_url:
72
+ rubert_client = RuBERTClient(api_url=rubert_url)
73
+ logger.info(f"RuBERTClient инициализирован: {rubert_url}")
74
+ else:
75
+ logger.warning("RUBERT_URL не указан, анализ через ruBERT будет недоступен")
76
+
77
+ # Инициализация Supabase клиента (опционально)
78
+ if SUPABASE_AVAILABLE:
79
+ supabase_url = os.getenv("SUPABASE_URL")
80
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
81
+
82
+ if supabase_url and supabase_key:
83
+ try:
84
+ supabase_client = SupabaseClient(url=supabase_url, key=supabase_key)
85
+ logger.info("SupabaseClient инициализирован")
86
+ except Exception as e:
87
+ logger.error(f"Ошибка инициализации Supabase: {e}")
88
+ supabase_client = None
89
+ else:
90
+ logger.warning("SUPABASE_URL или SUPABASE_SERVICE_KEY не указаны")
91
+ else:
92
+ logger.info("Supabase отключён - работаем в режиме без базы данных")
93
+
94
+
95
+ @app.get("/api/health", response_model=HealthResponse)
96
+ async def health_check():
97
+ """Проверка статуса сервиса"""
98
+ services = {
99
+ "api": True,
100
+ "scraper": scraper is not None,
101
+ "rubert": False,
102
+ "supabase": False,
103
+ }
104
+
105
+ if rubert_client:
106
+ services["rubert"] = await rubert_client.health_check()
107
+
108
+ if supabase_client:
109
+ try:
110
+ await supabase_client.get_stats()
111
+ services["supabase"] = True
112
+ except Exception:
113
+ services["supabase"] = False
114
+
115
+ return HealthResponse(
116
+ status="healthy" if all(services.values()) else "degraded",
117
+ timestamp=datetime.utcnow(),
118
+ services=services,
119
+ )
120
+
121
+
122
+ @app.get("/api/tasks", response_model=List[TaskResponse])
123
+ async def get_all_tasks():
124
+ """Получить все задания"""
125
+ if not supabase_client:
126
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
127
+
128
+ tasks = await supabase_client.get_all_tasks()
129
+ return [TaskResponse(**task) for task in tasks]
130
+
131
+
132
+ @app.get("/api/tasks/latest", response_model=List[TaskResponse])
133
+ async def get_latest_tasks(limit: int = 10):
134
+ """Получить последние задания"""
135
+ if not supabase_client:
136
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
137
+
138
+ tasks = await supabase_client.get_latest_tasks(limit=limit)
139
+ return [TaskResponse(**task) for task in tasks]
140
+
141
+
142
+ @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
143
+ async def get_task(task_id: int):
144
+ """Получить задание по ID"""
145
+ if not supabase_client:
146
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
147
+
148
+ task = await supabase_client.get_task_by_id(task_id)
149
+ if not task:
150
+ raise HTTPException(status_code=404, detail="Задание не найдено")
151
+
152
+ return TaskResponse(**task)
153
+
154
+
155
+ @app.get("/api/tasks/type/{task_type}", response_model=List[TaskResponse])
156
+ async def get_tasks_by_type(task_type: str):
157
+ """Получить задания по типу"""
158
+ if not supabase_client:
159
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
160
+
161
+ tasks = await supabase_client.get_tasks_by_type(task_type)
162
+ return [TaskResponse(**task) for task in tasks]
163
+
164
+
165
+ @app.get("/api/tasks/search", response_model=List[TaskResponse])
166
+ async def search_tasks(q: str):
167
+ """Поиск заданий по запросу"""
168
+ if not supabase_client:
169
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
170
+
171
+ tasks = await supabase_client.search_tasks(q)
172
+ return [TaskResponse(**task) for task in tasks]
173
+
174
+
175
+ @app.post("/api/scrape", response_model=ScrapeResponse)
176
+ async def scrape_tasks(request: ScrapeRequest, background_tasks: BackgroundTasks):
177
+ """
178
+ Запустить скрапинг заданий
179
+
180
+ Фоновая задача для сбора заданий с ФИПИ
181
+ """
182
+ if not scraper:
183
+ raise HTTPException(status_code=503, detail="Скрапер не настроен")
184
+
185
+ if not supabase_client:
186
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
187
+
188
+ try:
189
+ tasks_scraped = 0
190
+ tasks_saved = 0
191
+ duplicates_skipped = 0
192
+
193
+ # Если указаны URL, скрапим их
194
+ if request.urls:
195
+ for url in request.urls:
196
+ task = await scraper.parse_task_page(
197
+ await scraper.fetch_page(url),
198
+ url
199
+ )
200
+ if task:
201
+ tasks_scraped += 1
202
+ result = await supabase_client.insert_task(task)
203
+ if result:
204
+ tasks_saved += 1
205
+ else:
206
+ duplicates_skipped += 1
207
+
208
+ # Если указан поисковый запрос
209
+ elif request.query:
210
+ tasks = await scraper.search_tasks(request.query)
211
+ tasks_scraped = len(tasks)
212
+
213
+ for task in tasks:
214
+ # AI анализ через ruBERT
215
+ if rubert_client:
216
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
217
+ if analysis:
218
+ task["rubert_analysis"] = analysis
219
+
220
+ result = await supabase_client.insert_task(task)
221
+ if result:
222
+ tasks_saved += 1
223
+ else:
224
+ duplicates_skipped += 1
225
+
226
+ # Скрапинг по предмету (по умолчанию)
227
+ else:
228
+ tasks = await scraper.scrape_tasks(subject=request.subject)
229
+ tasks_scraped = len(tasks)
230
+
231
+ for task in tasks:
232
+ # AI анализ через ruBERT
233
+ if rubert_client:
234
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
235
+ if analysis:
236
+ task["rubert_analysis"] = analysis
237
+
238
+ result = await supabase_client.insert_task(task)
239
+ if result:
240
+ tasks_saved += 1
241
+ else:
242
+ duplicates_skipped += 1
243
+
244
+ return ScrapeResponse(
245
+ success=True,
246
+ tasks_scraped=tasks_scraped,
247
+ tasks_saved=tasks_saved,
248
+ duplicates_skipped=duplicates_skipped,
249
+ message=f"Успешно обработано {tasks_scraped} заданий. Сохранено: {tasks_saved}, пропущено дубликатов: {duplicates_skipped}",
250
+ )
251
+
252
+ except Exception as e:
253
+ logger.error(f"Ошибка при скрапинге: {e}")
254
+ raise HTTPException(status_code=500, detail=f"Ошибка скрапинга: {str(e)}")
255
+
256
+
257
+ @app.get("/api/stats", response_model=StatsResponse)
258
+ async def get_stats():
259
+ """Получить статистику по заданиям"""
260
+ if not supabase_client:
261
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
262
+
263
+ stats = await supabase_client.get_stats()
264
+
265
+ # Получение времени последнего скрапинга
266
+ latest = await supabase_client.get_latest_tasks(limit=1)
267
+ last_scrape = latest[0].get("scraped_at") if latest else None
268
+
269
+ return StatsResponse(
270
+ total_tasks=stats.get("total", 0),
271
+ by_type=stats.get("by_type", {}),
272
+ last_scrape=last_scrape,
273
+ )
274
+
275
+
276
+ @app.post("/api/analyze", response_model=ScrapeResponse)
277
+ async def analyze_existing_tasks():
278
+ """
279
+ AI анализ существующих заданий в базе
280
+
281
+ Запускает ruBERT анализ для всех заданий без анализа
282
+ """
283
+ if not supabase_client:
284
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
285
+
286
+ if not rubert_client:
287
+ raise HTTPException(status_code=503, detail="RuBERT клиент не настроен")
288
+
289
+ try:
290
+ tasks = await supabase_client.get_all_tasks()
291
+ analyzed_count = 0
292
+
293
+ for task in tasks:
294
+ # Пропускаем уже проанализированные
295
+ if task.get("rubert_analysis"):
296
+ continue
297
+
298
+ # Анализ через ruBERT
299
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
300
+
301
+ if analysis:
302
+ task["rubert_analysis"] = analysis
303
+ await supabase_client.update_task(task["id"], {"rubert_analysis": analysis})
304
+ analyzed_count += 1
305
+
306
+ return ScrapeResponse(
307
+ success=True,
308
+ tasks_scraped=analyzed_count,
309
+ tasks_saved=analyzed_count,
310
+ duplicates_skipped=len(tasks) - analyzed_count,
311
+ message=f"Проанализировано {analyzed_count} заданий",
312
+ )
313
+
314
+ except Exception as e:
315
+ logger.error(f"Ошибка при анализе: {e}")
316
+ raise HTTPException(status_code=500, detail=f"Ошибка анализа: {str(e)}")
317
+
318
+
319
+ @app.get("/", tags=["root"])
320
+ async def root():
321
+ """Корневой эндпоинт"""
322
+ return {
323
+ "message": "AI Scraper ФИПИ API",
324
+ "version": "1.0.0",
325
+ "docs": "/docs",
326
+ }
327
+
328
+
329
+ @app.exception_handler(Exception)
330
+ async def global_exception_handler(request, exc):
331
+ """Глобальный обработчик исключений"""
332
+ logger.error(f"Необработанная ошибка: {exc}")
333
+ return ErrorResponse(
334
+ error="Internal Server Error",
335
+ detail=str(exc),
336
+ timestamp=datetime.utcnow(),
337
+ )
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI
2
+ fastapi==0.109.0
3
+ uvicorn[standard]==0.27.0
4
+
5
+ # HTTP clients
6
+ httpx==0.25.2
7
+ requests==2.31.0
8
+ aiohttp==3.9.1
9
+
10
+ # Parsing
11
+ beautifulsoup4==4.12.3
12
+ lxml==5.1.0
13
+
14
+ # Pydantic
15
+ pydantic==2.5.3
16
+ pydantic-settings==2.1.0
17
+
18
+ # Utilities
19
+ python-dotenv==1.0.0
20
+ playwright==1.40.0
21
+
22
+ # Supabase (закомментировано - вызывает конфликт версий)
23
+ # supabase==2.3.4
scraper.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Скрапер для сайта ФИПИ (fipi.ru)
3
+ Извлекает задания по русскому языку для ЕГЭ (задание 27)
4
+ """
5
+
6
+ import httpx
7
+ from bs4 import BeautifulSoup
8
+ from typing import List, Dict, Optional
9
+ from datetime import datetime
10
+ import re
11
+ import logging
12
+
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class FIPIScraper:
18
+ """Парсер для сайта ФИПИ"""
19
+
20
+ def __init__(self, base_url: str = "https://fipi.ru"):
21
+ self.base_url = base_url
22
+ self.headers = {
23
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
24
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
25
+ "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
26
+ }
27
+
28
+ async def fetch_page(self, url: str) -> Optional[str]:
29
+ """Получение HTML страницы"""
30
+ # Создаем клиент с отключенной проверкой SSL (для fipi.ru поддоменов)
31
+ import ssl
32
+ ssl_context = ssl.create_default_context()
33
+ ssl_context.check_hostname = False
34
+ ssl_context.verify_mode = ssl.CERT_NONE
35
+
36
+ async with httpx.AsyncClient(
37
+ headers=self.headers,
38
+ timeout=30.0,
39
+ verify=ssl_context
40
+ ) as client:
41
+ try:
42
+ response = await client.get(url)
43
+ response.raise_for_status()
44
+ return response.text
45
+ except httpx.HTTPError as e:
46
+ logger.error(f"Ошибка при получении {url}: {e}")
47
+ return None
48
+
49
+ def parse_task_page(self, html: str, url: str) -> Optional[Dict]:
50
+ """Парсинг страницы с заданием"""
51
+ soup = BeautifulSoup(html, 'lxml')
52
+
53
+ # Извлечение заголовка - приоритет h1 в .content
54
+ title_tag = soup.select_one('.content h1') or soup.find('h1')
55
+ title = title_tag.get_text(strip=True) if title_tag else "Без названия"
56
+
57
+ # Если заголовок пустой, пробуем извлечь из title документа
58
+ if not title or title == "Без названия":
59
+ title_doc = soup.find('title')
60
+ if title_doc:
61
+ title = title_doc.get_text(strip=True)
62
+
63
+ # Извлечение основного контента - приоритет .content
64
+ content_div = soup.select_one('.content') or soup.find('div', class_='field--name-body')
65
+ if not content_div:
66
+ content_div = soup.find('main') or soup.find('body')
67
+
68
+ # Очистка текста - удаляем скрипты и стили
69
+ for element in content_div.find_all(['script', 'style', 'nav', 'header', 'footer']):
70
+ element.decompose()
71
+
72
+ content = content_div.get_text(separator='\n', strip=True) if content_div else ""
73
+
74
+ # Извлечение изображения (если есть)
75
+ images = []
76
+ for img in content_div.find_all('img'):
77
+ src = img.get('src') or img.get('data-src')
78
+ if src:
79
+ if not src.startswith('http'):
80
+ src = self.base_url + src
81
+ images.append(src)
82
+
83
+ # Извлечение ссылок на задания
84
+ task_links = []
85
+ for link in content_div.find_all('a', href=True):
86
+ href = link['href']
87
+ link_text = link.get_text(strip=True)
88
+ if any(pattern in href for pattern in ['/ege/', '/oge/', '/task/', '/demo/', '/bank/']):
89
+ if not href.startswith('http'):
90
+ href = self.base_url + href
91
+ task_links.append({"text": link_text, "url": href})
92
+
93
+ # Определение типа задания
94
+ task_type = self._detect_task_type(title, content)
95
+
96
+ # Извлечение вариантов (если есть)
97
+ variants = self._extract_variants(content)
98
+
99
+ return {
100
+ "title": title,
101
+ "content": content,
102
+ "source_url": url,
103
+ "task_type": task_type,
104
+ "images": images,
105
+ "variants": variants,
106
+ "task_links": task_links,
107
+ "scraped_at": datetime.utcnow().isoformat(),
108
+ }
109
+
110
+ def _detect_task_type(self, title: str, content: str) -> str:
111
+ """Определение типа задания"""
112
+ text = (title + " " + content).lower()
113
+
114
+ if any(word in text for word in ["сочинение", "эссе", "напишит"]):
115
+ return "writing"
116
+ elif any(word in text for word in ["тест", "выбер", "вариант"]):
117
+ return "test"
118
+ elif any(word in text for word in ["ауди", "слуш"]):
119
+ return "listening"
120
+ elif any(word in text for word in ["чит", "текст"]):
121
+ return "reading"
122
+ else:
123
+ return "other"
124
+
125
+ def _extract_variants(self, content: str) -> List[str]:
126
+ """Извлечение вариантов ответов"""
127
+ variants = []
128
+
129
+ # Паттерн для вариантов типа "1) ... 2) ..."
130
+ pattern = r'(\d+)[\.\)]\s*([^\n\d]+)'
131
+ matches = re.findall(pattern, content)
132
+
133
+ for _, variant in matches:
134
+ variants.append(variant.strip())
135
+
136
+ return variants[:10] # Ограничение на 10 вариантов
137
+
138
+ async def scrape_tasks(self, subject: str = "russian") -> List[Dict]:
139
+ """
140
+ Скрапинг заданий по предмету
141
+
142
+ Args:
143
+ subject: Код предмета (по умолчанию russian)
144
+
145
+ Returns:
146
+ Список заданий
147
+ """
148
+ tasks = []
149
+
150
+ # Актуальные URLs для скрапинга (fipi.ru) - только работающие
151
+ urls_to_scrape = [
152
+ f"{self.base_url}/ege/otkrytyy-bank-zadaniy-ege",
153
+ f"{self.base_url}/oge/otkrytyy-bank-zadaniy-oge",
154
+ ]
155
+
156
+ for url in urls_to_scrape:
157
+ logger.info(f"Скрапинг {url}")
158
+ html = await self.fetch_page(url)
159
+
160
+ if html:
161
+ task = self.parse_task_page(html, url)
162
+ if task:
163
+ tasks.append(task)
164
+
165
+ # Если есть ссылки на задания, скачиваем их
166
+ for link_info in task.get('task_links', [])[:5]: # Ограничиваем количество
167
+ link_url = link_info.get('url')
168
+ if link_url:
169
+ logger.info(f" -> Скачиваем задание: {link_url}")
170
+ link_html = await self.fetch_page(link_url)
171
+ if link_html:
172
+ subtask = self.parse_task_page(link_html, link_url)
173
+ if subtask:
174
+ tasks.append(subtask)
175
+
176
+ logger.info(f"Найдено {len(tasks)} заданий")
177
+ return tasks
178
+
179
+ async def scrape_task_by_id(self, task_id: str) -> Optional[Dict]:
180
+ """Скрапинг конкретного задания по ID"""
181
+ url = f"{self.base_url}/task/{task_id}"
182
+ logger.info(f"Скрапинг задания {task_id}")
183
+
184
+ html = await self.fetch_page(url)
185
+ if html:
186
+ return self.parse_task_page(html, url)
187
+
188
+ return None
189
+
190
+ async def search_tasks(self, query: str) -> List[Dict]:
191
+ """Поиск заданий по ключевому слову"""
192
+ tasks = []
193
+ # Используем правильный URL для поиска на fipi.ru
194
+ search_url = f"{self.base_url}/search?q={query}"
195
+
196
+ html = await self.fetch_page(search_url)
197
+ if not html:
198
+ # Пробуем альтернативный поиск через банк заданий
199
+ logger.info("Поиск не доступен, пробуем парсинг банка заданий")
200
+ return await self.scrape_tasks()
201
+
202
+ soup = BeautifulSoup(html, 'lxml')
203
+
204
+ # Поиск ссылок на задания с правильными паттернами
205
+ for link in soup.find_all('a', href=True):
206
+ href = link['href']
207
+ # Проверяем на наличие валидных URL заданий
208
+ if any(pattern in href for pattern in ['/ege/', '/oge/', '/task/', '/demo/', '/bank/']):
209
+ if not href.startswith('http'):
210
+ href = self.base_url + href
211
+
212
+ task_html = await self.fetch_page(href)
213
+ if task_html:
214
+ task = self.parse_task_page(task_html, href)
215
+ if task:
216
+ tasks.append(task)
217
+
218
+ return tasks
supabase_client.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Клиент для Supabase
3
+ Хранение и управление заданиями
4
+ """
5
+
6
+ from supabase import create_client, Client
7
+ from typing import List, Dict, Optional
8
+ from datetime import datetime
9
+ import logging
10
+
11
+ logging.basicConfig(level=logging.INFO)
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SupabaseClient:
16
+ """Клиент для работы с Supabase"""
17
+
18
+ def __init__(self, url: str, key: str):
19
+ self.client: Client = create_client(url, key)
20
+ self.table_name = "fipi_tasks"
21
+
22
+ async def insert_task(self, task: Dict) -> Optional[Dict]:
23
+ """
24
+ Добавление задания в базу
25
+
26
+ Args:
27
+ task: Данные задания
28
+
29
+ Returns:
30
+ Сохраненное задание с ID
31
+ """
32
+ try:
33
+ # Проверка на дубликаты по URL
34
+ existing = await self.get_task_by_url(task.get("source_url", ""))
35
+ if existing:
36
+ logger.info(f"Задание уже существует: {task.get('source_url')}")
37
+ return existing
38
+
39
+ # Добавление записи
40
+ result = self.client.table(self.table_name).insert(task).execute()
41
+
42
+ if result.data:
43
+ logger.info(f"Задание сохранено: {result.data[0].get('id')}")
44
+ return result.data[0]
45
+
46
+ return None
47
+ except Exception as e:
48
+ logger.error(f"Ошибка при сохранении задания: {e}")
49
+ return None
50
+
51
+ async def insert_tasks_batch(self, tasks: List[Dict]) -> List[Dict]:
52
+ """
53
+ Пакетное добавление заданий
54
+
55
+ Args:
56
+ tasks: Список заданий
57
+
58
+ Returns:
59
+ Список сохраненных заданий
60
+ """
61
+ saved = []
62
+ for task in tasks:
63
+ result = await self.insert_task(task)
64
+ if result:
65
+ saved.append(result)
66
+
67
+ logger.info(f"Сохранено {len(saved)} из {len(tasks)} заданий")
68
+ return saved
69
+
70
+ async def get_task_by_id(self, task_id: int) -> Optional[Dict]:
71
+ """Получение задания по ID"""
72
+ try:
73
+ result = self.client.table(self.table_name)\
74
+ .select("*")\
75
+ .eq("id", task_id)\
76
+ .execute()
77
+
78
+ return result.data[0] if result.data else None
79
+ except Exception as e:
80
+ logger.error(f"Ошибка получения задания: {e}")
81
+ return None
82
+
83
+ async def get_task_by_url(self, url: str) -> Optional[Dict]:
84
+ """Получение задания по URL (проверка на дубликат)"""
85
+ try:
86
+ result = self.client.table(self.table_name)\
87
+ .select("*")\
88
+ .eq("source_url", url)\
89
+ .execute()
90
+
91
+ return result.data[0] if result.data else None
92
+ except Exception as e:
93
+ logger.error(f"Ошибка проверки дубликата: {e}")
94
+ return None
95
+
96
+ async def get_latest_tasks(self, limit: int = 10) -> List[Dict]:
97
+ """Получение последних заданий"""
98
+ try:
99
+ result = self.client.table(self.table_name)\
100
+ .select("*")\
101
+ .order("scraped_at", desc=True)\
102
+ .limit(limit)\
103
+ .execute()
104
+
105
+ return result.data or []
106
+ except Exception as e:
107
+ logger.error(f"Ошибка получения последних заданий: {e}")
108
+ return []
109
+
110
+ async def get_all_tasks(self) -> List[Dict]:
111
+ """Получение всех заданий"""
112
+ try:
113
+ result = self.client.table(self.table_name)\
114
+ .select("*")\
115
+ .execute()
116
+
117
+ return result.data or []
118
+ except Exception as e:
119
+ logger.error(f"Ошибка получения всех заданий: {e}")
120
+ return []
121
+
122
+ async def search_tasks(self, query: str) -> List[Dict]:
123
+ """Поиск заданий по содержимому"""
124
+ try:
125
+ # Поиск по заголовку и контенту
126
+ result = self.client.table(self.table_name)\
127
+ .select("*")\
128
+ .or_(f"title.ilike.%{query}%,content.ilike.%{query}%")\
129
+ .execute()
130
+
131
+ return result.data or []
132
+ except Exception as e:
133
+ logger.error(f"Ошибка поиска: {e}")
134
+ return []
135
+
136
+ async def get_tasks_by_type(self, task_type: str) -> List[Dict]:
137
+ """Получение заданий по типу"""
138
+ try:
139
+ result = self.client.table(self.table_name)\
140
+ .select("*")\
141
+ .eq("task_type", task_type)\
142
+ .execute()
143
+
144
+ return result.data or []
145
+ except Exception as e:
146
+ logger.error(f"Ошибка получения заданий по типу: {e}")
147
+ return []
148
+
149
+ async def update_task(self, task_id: int, updates: Dict) -> Optional[Dict]:
150
+ """Обновление задания"""
151
+ try:
152
+ result = self.client.table(self.table_name)\
153
+ .update(updates)\
154
+ .eq("id", task_id)\
155
+ .execute()
156
+
157
+ return result.data[0] if result.data else None
158
+ except Exception as e:
159
+ logger.error(f"Ошибка обновления задания: {e}")
160
+ return None
161
+
162
+ async def delete_task(self, task_id: int) -> bool:
163
+ """Удаление задания"""
164
+ try:
165
+ result = self.client.table(self.table_name)\
166
+ .delete()\
167
+ .eq("id", task_id)\
168
+ .execute()
169
+
170
+ return len(result.data) > 0
171
+ except Exception as e:
172
+ logger.error(f"Ошибка удаления задания: {e}")
173
+ return False
174
+
175
+ async def get_stats(self) -> Dict:
176
+ """Получение статистики по заданиям"""
177
+ try:
178
+ all_tasks = await self.get_all_tasks()
179
+
180
+ stats = {
181
+ "total": len(all_tasks),
182
+ "by_type": {}
183
+ }
184
+
185
+ for task in all_tasks:
186
+ task_type = task.get("task_type", "unknown")
187
+ stats["by_type"][task_type] = stats["by_type"].get(task_type, 0) + 1
188
+
189
+ return stats
190
+ except Exception as e:
191
+ logger.error(f"Ошибка получения статистики: {e}")
192
+ return {"total": 0, "by_type": {}}