greeta commited on
Commit
3b50f03
·
verified ·
1 Parent(s): 37957c3

Upload 15 files

Browse files
Files changed (15) hide show
  1. .env.example +13 -0
  2. .gitignore +66 -0
  3. Dockerfile +34 -0
  4. FIXES.md +69 -0
  5. README.md +417 -0
  6. app.py +322 -0
  7. hf_spaces_config.yaml +18 -0
  8. models.py +80 -0
  9. requirements.txt +12 -0
  10. rubert_client.py +136 -0
  11. schema.sql +85 -0
  12. scraper.cpython-314.pyc +0 -0
  13. scraper.py +218 -0
  14. supabase_client.py +191 -0
  15. test_scraper.py +75 -0
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase (обязательно)
2
+ SUPABASE_URL=https://your-project.supabase.co
3
+ SUPABASE_SERVICE_KEY=your-service-role-key
4
+
5
+ # RuBERT API (опционально)
6
+ RUBERT_URL=https://your-rubert.hf.space
7
+
8
+ # Настройки скрапера
9
+ FIPI_BASE_URL=https://fipi.ru
10
+ SCRAPER_INTERVAL_HOURS=24
11
+
12
+ # Hugging Face (опционально)
13
+ HF_TOKEN=hf_your-token
.gitignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+ .venv
28
+
29
+ # IDE
30
+ .vscode/
31
+ .idea/
32
+ *.swp
33
+ *.swo
34
+ *~
35
+
36
+ # Environment variables
37
+ .env
38
+ .env.local
39
+
40
+ # Logs
41
+ *.log
42
+ logs/
43
+
44
+ # Database
45
+ *.db
46
+ *.sqlite
47
+ *.sqlite3
48
+
49
+ # Testing
50
+ .pytest_cache/
51
+ .coverage
52
+ htmlcov/
53
+ .tox/
54
+
55
+ # Jupyter Notebook
56
+ .ipynb_checkpoints
57
+
58
+ # Machine Learning
59
+ *.h5
60
+ *.pkl
61
+ *.pth
62
+ *.onnx
63
+
64
+ # OS
65
+ .DS_Store
66
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
9
+ # Установка рабочей директории
10
+ WORKDIR /app
11
+
12
+ # Установка системных зависимостей
13
+ RUN apt-get update && apt-get install -y \
14
+ curl \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Копирование зависимостей
18
+ COPY requirements.txt .
19
+
20
+ # Установка Python зависимостей
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Копирование исходного кода
24
+ COPY . .
25
+
26
+ # Экспозиция порта
27
+ EXPOSE 7860
28
+
29
+ # Переменные окружения по умолчанию
30
+ ENV PORT=7860
31
+ ENV HOST=0.0.0.0
32
+
33
+ # Запуск приложения
34
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
FIXES.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Исправления скрапера ФИПИ
2
+
3
+ ## Найденные проблемы
4
+
5
+ 1. **Неверные URL-адреса** - скрапер использовал несуществующие адреса:
6
+ - ❌ `/oge/demo/oge-russian` → 404
7
+ - ❌ `/ege/demo/ege-russian` → 404
8
+ - ✅ Исправлено на: `/ege/otkrytyy-bank-zadaniy-ege`, `/oge/otkrytyy-bank-zadaniy-oge`
9
+
10
+ 2. **SSL ошибки для поддоменов** - `ege.fipi.ru` и `oge.fipi.ru` требовали отключения проверки SSL
11
+
12
+ 3. **Парсинг заголовков** - заголовки не извлекались из-за неправильного селектора
13
+
14
+ ## Внесенные исправления
15
+
16
+ ### 1. Обновлены URL-адреса в `scrape_tasks()`
17
+ ```python
18
+ urls_to_scrape = [
19
+ "https://fipi.ru/ege/otkrytyy-bank-zadaniy-ege",
20
+ "https://fipi.ru/oge/otkrytyy-bank-zadaniy-oge",
21
+ ]
22
+ ```
23
+
24
+ ### 2. Отключена проверка SSL в `fetch_page()`
25
+ ```python
26
+ ssl_context = ssl.create_default_context()
27
+ ssl_context.check_hostname = False
28
+ ssl_context.verify_mode = ssl.CERT_NONE
29
+ ```
30
+
31
+ ### 3. Улучшен парсинг в `parse_task_page()`
32
+ - Добавлен селектор `.content h1` для заголовков
33
+ - Добавлено извлечение из `<title>` если h1 пустой
34
+ - Добавлено извлечение ссылок на задания
35
+ - Очистка от скриптов, стилей, nav, header, footer
36
+
37
+ ### 4. Добавлено скачивание вложенных заданий
38
+ Теперь скрапер переходит по найденным ссылкам и скачивает дополнительные задания.
39
+
40
+ ## Результат
41
+
42
+ | До исправления | После исправления |
43
+ |----------------|-------------------|
44
+ | 0 заданий | 12 заданий |
45
+ | 404 ошибки | 200 OK |
46
+ | "Без названия" | Правильные заголовки |
47
+
48
+ ## Запуск теста
49
+
50
+ ```bash
51
+ cd ai-scraper
52
+ python test_scraper.py
53
+ ```
54
+
55
+ ## Использование
56
+
57
+ ```python
58
+ from scraper import FIPIScraper
59
+ import asyncio
60
+
61
+ async def main():
62
+ scraper = FIPIScraper()
63
+ tasks = await scraper.scrape_tasks(subject="russian")
64
+ print(f"Найдено заданий: {len(tasks)}")
65
+ for task in tasks:
66
+ print(f"- {task['title']}: {task['source_url']}")
67
+
68
+ asyncio.run(main())
69
+ ```
README.md ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🕷️ AI Scraper ФИПИ
2
+
3
+ Сервис для автоматического сбора заданий с сайта ФИПИ (fipi.ru) с использованием AI-анализа на основе **ruBERT**.
4
+
5
+ ---
6
+
7
+ ## ✨ Возможности
8
+
9
+ - 🕷️ **Автоматический парсинг** заданий с сайта ФИПИ
10
+ - 🧠 **AI-классификация** заданий через ruBERT
11
+ - 💾 **Сохранение в Supabase** с автоматическим обновлением
12
+ - 🚀 **Деплой на Hugging Face Spaces**
13
+ - 📊 **REST API** для доступа к заданиям
14
+ - 🔍 **Поиск** по заданиям
15
+ - 📈 **Статистика** и аналитика
16
+
17
+ ---
18
+
19
+ ## 📁 Структура
20
+
21
+ ```
22
+ ai-scraper/
23
+ ├── app.py # Основное FastAPI приложение
24
+ ├── scraper.py # Парсер сайта ФИПИ
25
+ ├── rubert_client.py # Клиент для ruBERT API
26
+ ├── supabase_client.py # Интеграция с Supabase
27
+ ├── models.py # Pydantic модели
28
+ ├── requirements.txt # Python зависимости
29
+ ├── schema.sql # SQL схема для Supabase
30
+ ├── Dockerfile # Docker конфигурация
31
+ ├── hf_spaces_config.yaml # Конфиг для Hugging Face
32
+ ├── .env.example # Шаблон переменных окружения
33
+ └── README.md # Документация
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 🚀 Быстрый старт
39
+
40
+ ### 1. Локальная разработка
41
+
42
+ ```bash
43
+ cd ai-scraper
44
+
45
+ # Создайте виртуальное окружение
46
+ python -m venv venv
47
+
48
+ # Активируйте
49
+ venv\Scripts\activate # Windows
50
+ source venv/bin/activate # Linux/Mac
51
+
52
+ # Установите зависимости
53
+ pip install -r requirements.txt
54
+
55
+ # Скопируйте .env.example в .env (опционально)
56
+ cp .env.example .env
57
+
58
+ # Заполните .env своими ключами
59
+ # ИЛИ настройте переменные окружения в вашей системе
60
+
61
+ # Запустите сервер
62
+ uvicorn app:app --reload --host 0.0.0.0 --port 8000
63
+ ```
64
+
65
+ Откройте http://localhost:8000/docs для Swagger UI.
66
+
67
+ ---
68
+
69
+ ## ⚙️ Переменные окружения
70
+
71
+ ### Для локальной разработки
72
+
73
+ Скопируйте `.env.example` в `.env` и заполните своими значениями:
74
+
75
+ ```env
76
+ SUPABASE_URL=https://your-project.supabase.co
77
+ SUPABASE_SERVICE_KEY=your-service-role-key
78
+ RUBERT_URL=https://your-rubert.hf.space
79
+ FIPI_BASE_URL=https://fipi.ru
80
+ ```
81
+
82
+ ### Для Hugging Face Spaces
83
+
84
+ **Не нужно загружать `.env` файл!** Настройте переменные через интерфейс:
85
+
86
+ 1. Откройте ваш Space
87
+ 2. Перейдите в **Settings** → **Secrets**
88
+ 3. Добавьте переменные:
89
+ - `SUPABASE_URL`
90
+ - `SUPABASE_SERVICE_KEY`
91
+ - `RUBERT_URL` (опционально)
92
+ - `FIPI_BASE_URL` (опционально)
93
+
94
+ ---
95
+
96
+ ## 🗄️ Настройка Supabase
97
+
98
+ ### 1. Создайте проект
99
+
100
+ Перейдите на [Supabase](https://supabase.com) и создайте новый проект.
101
+
102
+ ### 2. Выполните SQL скрипт
103
+
104
+ 1. Откройте [SQL Editor](https://supabase.com/dashboard/project/_/sql/new)
105
+ 2. Скопируйте содержимое `schema.sql`
106
+ 3. Нажмите **Run**
107
+
108
+ ### 3. Получите ключи
109
+
110
+ 1. Перейдите в **Settings** → **API**
111
+ 2. Скопируйте:
112
+ - **Project URL** → `SUPABASE_URL`
113
+ - **service_role key** → `SUPABASE_SERVICE_KEY`
114
+
115
+ ---
116
+
117
+ ## 🧠 Настройка ruBERT
118
+
119
+ ### Вариант 1: Использование существующего API
120
+
121
+ Если у вас уже есть развернутый ruBERT (как в основном проекте):
122
+
123
+ ```env
124
+ RUBERT_URL=https://your-rubert-instance.hf.space
125
+ ```
126
+
127
+ ### Вариант 2: Развертывание ruBERT
128
+
129
+ Создайте новый Space на Hugging Face с моделью ruBERT:
130
+
131
+ 1. [RuBERT от DeepPavlov](https://huggingface.co/deepvk/rubert-base-cased)
132
+ 2. Используйте шаблон Gradio или FastAPI
133
+ 3. Добавьте эндпоинты `/api/analyze` и `/api/embedding`
134
+
135
+ ---
136
+
137
+ ## 🌐 Деплой на Hugging Face Spaces
138
+
139
+ ### Шаг 1: Создайте Space
140
+
141
+ 1. Перейдите на [Hugging Face Spaces](https://huggingface.co/spaces)
142
+ 2. Нажмите **Create new Space**
143
+ 3. Заполните:
144
+ - **Space name**: `fipi-ai-scraper`
145
+ - **License**: MIT
146
+ - **SDK**: Docker
147
+ - **Visibility**: Public или Private
148
+
149
+ ### Шаг 2: Загрузите файлы
150
+
151
+ ```bash
152
+ # Инициализируйте git в папке ai-scraper
153
+ cd ai-scraper
154
+ git init
155
+ git add .
156
+ git commit -m "Initial commit"
157
+
158
+ # Добавьте remote вашего Space
159
+ git remote add origin https://huggingface.co/spaces/YOUR_USERNAME/fipi-ai-scraper
160
+
161
+ # Push в Space
162
+ git push -u origin main
163
+ ```
164
+
165
+ ### Шаг 3: Настройте переменные окружения
166
+
167
+ **Важно:** Не загружайте `.env` файл в репозиторий!
168
+
169
+ В Settings вашего Space добавьте в **Secrets**:
170
+
171
+ | Variable | Value |
172
+ |----------|-------|
173
+ | `SUPABASE_URL` | https://your-project.supabase.co |
174
+ | `SUPABASE_SERVICE_KEY` | ваш service key |
175
+ | `RUBERT_URL` | https://your-rubert.hf.space |
176
+ | `FIPI_BASE_URL` | https://fipi.ru |
177
+
178
+ ⚠️ **Примечание:** Переменные окружения добавляются через интерфейс Hugging Face:
179
+ **Settings** → **Repository secrets** → **New secret**
180
+
181
+ ### Шаг 4: Дождитесь деплоя
182
+
183
+ Space автоматически соберет Docker образ и запустит приложение.
184
+
185
+ ---
186
+
187
+ ## 📡 API Endpoints
188
+
189
+ | Метод | Эндпоинт | Описание |
190
+ |-------|----------|----------|
191
+ | GET | `/` | Информация об API |
192
+ | GET | `/api/health` | Проверка статуса сервиса |
193
+ | GET | `/api/tasks` | Получить все задания |
194
+ | GET | `/api/tasks/latest` | Последние добавленные задания |
195
+ | GET | `/api/tasks/{task_id}` | Получить задание по ID |
196
+ | GET | `/api/tasks/type/{type}` | Задания по типу |
197
+ | GET | `/api/tasks/search?q=` | Поиск заданий |
198
+ | POST | `/api/scrape` | Запустить парсинг ФИПИ |
199
+ | POST | `/api/analyze` | AI анализ существующих заданий |
200
+ | GET | `/api/stats` | Статистика по заданиям |
201
+
202
+ ---
203
+
204
+ ## 📝 Примеры использования
205
+
206
+ ### Python
207
+
208
+ ```python
209
+ import requests
210
+
211
+ BASE_URL = "https://your-space.hf.space"
212
+
213
+ # Получить последние задания
214
+ response = requests.get(f"{BASE_URL}/api/tasks/latest?limit=10")
215
+ tasks = response.json()
216
+ print(f"Найдено заданий: {len(tasks)}")
217
+
218
+ # Запустить парсинг ФИПИ
219
+ response = requests.post(f"{BASE_URL}/api/scrape")
220
+ result = response.json()
221
+ print(result["message"])
222
+
223
+ # Поиск заданий
224
+ response = requests.get(f"{BASE_URL}/api/tasks/search?q=сочинение")
225
+ tasks = response.json()
226
+
227
+ # Получить статистику
228
+ response = requests.get(f"{BASE_URL}/api/stats")
229
+ stats = response.json()
230
+ print(f"Всего заданий: {stats['total_tasks']}")
231
+ ```
232
+
233
+ ### cURL
234
+
235
+ ```bash
236
+ # Health check
237
+ curl https://your-space.hf.space/api/health
238
+
239
+ # Получить задания
240
+ curl https://your-space.hf.space/api/tasks/latest
241
+
242
+ # Запустить скрапинг
243
+ curl -X POST https://your-space.hf.space/api/scrape \
244
+ -H "Content-Type: application/json" \
245
+ -d '{"subject": "russian"}'
246
+
247
+ # Поиск
248
+ curl "https://your-space.hf.space/api/tasks/search?q=ЕГЭ"
249
+ ```
250
+
251
+ ---
252
+
253
+ ## 🔧 Конфигурация
254
+
255
+ ### Переменные окружения
256
+
257
+ | Переменная | Описание | Пример |
258
+ |------------|----------|--------|
259
+ | `SUPABASE_URL` | URL проекта Supabase | `https://xxx.supabase.co` |
260
+ | `SUPABASE_SERVICE_KEY` | Service role ключ Supabase | `eyJhbG...` |
261
+ | `RUBERT_URL` | URL ruBERT API | `https://rubert.hf.space` |
262
+ | `FIPI_BASE_URL` | Базовый URL ФИПИ | `https://fipi.ru` |
263
+ | `PORT` | Порт приложения | `7860` |
264
+ | `HOST` | Хост приложения | `0.0.0.0` |
265
+
266
+ ---
267
+
268
+ ## 🏗️ Архитектура
269
+
270
+ ```
271
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
272
+ │ FIPIScraper │────▶│ RuBERTClient │────▶│ SupabaseClient │
273
+ │ (BeautifulSoup)│ │ (HTTP API) │ │ (Supabase JS) │
274
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
275
+ │ │ │
276
+ ▼ ▼ ▼
277
+ fipi.ru ruBERT HF Supabase DB
278
+ (парсинг) (AI анализ) (хранение)
279
+ ```
280
+
281
+ ### Поток данных
282
+
283
+ 1. **Скрапинг**: `FIPIScraper` парсит задания с fipi.ru
284
+ 2. **Анализ**: `RuBERTClient` анализирует текст задания
285
+ 3. **Сохранение**: `SupabaseClient` сохраняет в базу данных
286
+ 4. **API**: FastAPI предоставляет REST эндпоинты
287
+
288
+ ---
289
+
290
+ ## 🔒 Безопасность
291
+
292
+ - ✅ **RLS (Row Level Security)** в Supabase
293
+ - ✅ **Валидация данных** через Pydantic
294
+ - ✅ **CORS** настроен для API
295
+ - ✅ **Service Key** не экспон��руется на клиенте
296
+
297
+ ---
298
+
299
+ ## 🛠️ Решение проблем
300
+
301
+ ### "Supabase не настроен"
302
+
303
+ Проверьте переменные окружения:
304
+ ```bash
305
+ echo $SUPABASE_URL
306
+ echo $SUPABASE_SERVICE_KEY
307
+ ```
308
+
309
+ ### "RuBERT клиент не настроен"
310
+
311
+ Убедитесь, что `RUBERT_URL` указан и API доступен:
312
+ ```bash
313
+ curl https://your-rubert.hf.space/api/health
314
+ ```
315
+
316
+ ### Ошибки при скрапинге
317
+
318
+ Сайт ФИПИ может блокировать запросы. Попробуйте:
319
+ - Изменить `User-Agent` в `scraper.py`
320
+ - Использовать прокси
321
+ - Добавить задержки между запросами
322
+
323
+ ### Docker не собирается
324
+
325
+ Проверьте логи:
326
+ ```bash
327
+ docker build -t fipi-scraper .
328
+ docker run -p 7860:7860 fipi-scraper
329
+ ```
330
+
331
+ ### Configuration error / Missing .env
332
+
333
+ **Это нормально!** Для Hugging Face Spaces:
334
+ 1. Не загружайте `.env` в репозиторий
335
+ 2. Настройте переменные через **Settings** → **Repository secrets**
336
+ 3. Файл `.env.example` существует только для документации
337
+
338
+ ---
339
+
340
+ ## 📝 История изменений
341
+
342
+ ### Март 2026 - Исправление парсера ФИПИ
343
+
344
+ **Исправленные проблемы:**
345
+ - ❌ Неверные URL-адреса (404 ошибки)
346
+ - ❌ SSL ошибки для поддоменов
347
+ - ❌ Некорректный парсинг заголовков
348
+
349
+ **Результат:**
350
+ - ✅ Найдено заданий: 0 → 12
351
+ - ✅ Все запросы возвращают 200 OK
352
+
353
+ Подробности в [FIXES.md](FIXES.md)
354
+
355
+ ---
356
+
357
+ ## 📊 Мониторинг
358
+
359
+ ### Логи приложения
360
+
361
+ ```bash
362
+ # Логи в Hugging Face Space
363
+ # Settings → Logs
364
+
365
+ # Локально
366
+ uvicorn app:app --log-level debug
367
+ ```
368
+
369
+ ### Метрики
370
+
371
+ - `/api/health` — статус сервисов
372
+ - `/api/stats` — статистика заданий
373
+
374
+ ---
375
+
376
+ ## 🤝 Интеграция с основным проектом
377
+
378
+ Этот сервис дополняет основной проект `refined-main`:
379
+
380
+ 1. **Импорт заданий** из ФИПИ в базу
381
+ 2. **AI-анализ** через тот же ruBERT
382
+ 3. **Единая Supabase** для обоих сервисов
383
+
384
+ ### Подключение
385
+
386
+ В основном проекте добавьте:
387
+
388
+ ```typescript
389
+ // services/fipiTasks.ts
390
+ const FIPI_SCRAPER_URL = 'https://fipi-ai-scraper.hf.space';
391
+
392
+ export async function fetchLatestTasks(limit = 10) {
393
+ const response = await fetch(`${FIPI_SCRAPER_URL}/api/tasks/latest?limit=${limit}`);
394
+ return response.json();
395
+ }
396
+ ```
397
+
398
+ ---
399
+
400
+ ## 📚 Дополнительные ресурсы
401
+
402
+ - [Документация FastAPI](https://fastapi.tiangolo.com/)
403
+ - [Документация Supabase](https://supabase.com/docs)
404
+ - [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces)
405
+ - [ruBERT модель](https://huggingface.co/deepvk/rubert-base-cased)
406
+ - [ФИПИ](https://fipi.ru/)
407
+
408
+ ---
409
+
410
+ ## 📄 Лицензия
411
+
412
+ MIT License
413
+
414
+ ---
415
+
416
+ **Последнее обновление:** Март 2026
417
+ **Статус:** ✅ Готово к деплою
app.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from supabase_client import SupabaseClient
24
+
25
+ # Настройка логирования
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Инициализация приложения
30
+ app = FastAPI(
31
+ title="AI Scraper ФИПИ",
32
+ description="Сервис для автоматического сбора заданий с сайта ФИПИ с AI-анализом на ruBERT",
33
+ version="1.0.0",
34
+ )
35
+
36
+ # CORS middleware
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ # Глобальные клиенты
46
+ scraper: FIPIScraper = None
47
+ rubert_client: RuBERTClient = None
48
+ supabase_client: SupabaseClient = None
49
+
50
+
51
+ @app.on_event("startup")
52
+ async def startup_event():
53
+ """Инициализация клиентов при старте"""
54
+ global scraper, rubert_client, supabase_client
55
+
56
+ # Инициализация скрапера
57
+ fipi_url = os.getenv("FIPI_BASE_URL", "https://fipi.ru")
58
+ scraper = FIPIScraper(base_url=fipi_url)
59
+ logger.info(f"FIPIScraper инициализирован: {fipi_url}")
60
+
61
+ # Инициализация ruBERT клиента
62
+ rubert_url = os.getenv("RUBERT_URL")
63
+ if rubert_url:
64
+ rubert_client = RuBERTClient(api_url=rubert_url)
65
+ logger.info(f"RuBERTClient инициализирован: {rubert_url}")
66
+ else:
67
+ logger.warning("RUBERT_URL не указан, анализ через ruBERT будет недоступен")
68
+
69
+ # Инициализация Supabase клиента
70
+ supabase_url = os.getenv("SUPABASE_URL")
71
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
72
+
73
+ if supabase_url and supabase_key:
74
+ supabase_client = SupabaseClient(url=supabase_url, key=supabase_key)
75
+ logger.info("SupabaseClient инициализирован")
76
+ else:
77
+ logger.warning("SUPABASE_URL или SUPABASE_SERVICE_KEY не указаны, работа с БД будет недоступна")
78
+
79
+
80
+ @app.get("/api/health", response_model=HealthResponse)
81
+ async def health_check():
82
+ """Проверка статуса сервиса"""
83
+ services = {
84
+ "api": True,
85
+ "scraper": scraper is not None,
86
+ "rubert": False,
87
+ "supabase": False,
88
+ }
89
+
90
+ if rubert_client:
91
+ services["rubert"] = await rubert_client.health_check()
92
+
93
+ if supabase_client:
94
+ try:
95
+ await supabase_client.get_stats()
96
+ services["supabase"] = True
97
+ except Exception:
98
+ services["supabase"] = False
99
+
100
+ return HealthResponse(
101
+ status="healthy" if all(services.values()) else "degraded",
102
+ timestamp=datetime.utcnow(),
103
+ services=services,
104
+ )
105
+
106
+
107
+ @app.get("/api/tasks", response_model=List[TaskResponse])
108
+ async def get_all_tasks():
109
+ """Получить все задания"""
110
+ if not supabase_client:
111
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
112
+
113
+ tasks = await supabase_client.get_all_tasks()
114
+ return [TaskResponse(**task) for task in tasks]
115
+
116
+
117
+ @app.get("/api/tasks/latest", response_model=List[TaskResponse])
118
+ async def get_latest_tasks(limit: int = 10):
119
+ """Получить последние задания"""
120
+ if not supabase_client:
121
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
122
+
123
+ tasks = await supabase_client.get_latest_tasks(limit=limit)
124
+ return [TaskResponse(**task) for task in tasks]
125
+
126
+
127
+ @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
128
+ async def get_task(task_id: int):
129
+ """Получить задание по ID"""
130
+ if not supabase_client:
131
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
132
+
133
+ task = await supabase_client.get_task_by_id(task_id)
134
+ if not task:
135
+ raise HTTPException(status_code=404, detail="Задание не найдено")
136
+
137
+ return TaskResponse(**task)
138
+
139
+
140
+ @app.get("/api/tasks/type/{task_type}", response_model=List[TaskResponse])
141
+ async def get_tasks_by_type(task_type: str):
142
+ """Получить задания по типу"""
143
+ if not supabase_client:
144
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
145
+
146
+ tasks = await supabase_client.get_tasks_by_type(task_type)
147
+ return [TaskResponse(**task) for task in tasks]
148
+
149
+
150
+ @app.get("/api/tasks/search", response_model=List[TaskResponse])
151
+ async def search_tasks(q: str):
152
+ """Поиск заданий по запросу"""
153
+ if not supabase_client:
154
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
155
+
156
+ tasks = await supabase_client.search_tasks(q)
157
+ return [TaskResponse(**task) for task in tasks]
158
+
159
+
160
+ @app.post("/api/scrape", response_model=ScrapeResponse)
161
+ async def scrape_tasks(request: ScrapeRequest, background_tasks: BackgroundTasks):
162
+ """
163
+ Запустить скрапинг заданий
164
+
165
+ Фоновая задача для сбора заданий с ФИПИ
166
+ """
167
+ if not scraper:
168
+ raise HTTPException(status_code=503, detail="Скрапер не настроен")
169
+
170
+ if not supabase_client:
171
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
172
+
173
+ try:
174
+ tasks_scraped = 0
175
+ tasks_saved = 0
176
+ duplicates_skipped = 0
177
+
178
+ # Если указаны URL, скрапим их
179
+ if request.urls:
180
+ for url in request.urls:
181
+ task = await scraper.parse_task_page(
182
+ await scraper.fetch_page(url),
183
+ url
184
+ )
185
+ if task:
186
+ tasks_scraped += 1
187
+ result = await supabase_client.insert_task(task)
188
+ if result:
189
+ tasks_saved += 1
190
+ else:
191
+ duplicates_skipped += 1
192
+
193
+ # Если указан поисковый запрос
194
+ elif request.query:
195
+ tasks = await scraper.search_tasks(request.query)
196
+ tasks_scraped = len(tasks)
197
+
198
+ for task in tasks:
199
+ # AI анализ через ruBERT
200
+ if rubert_client:
201
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
202
+ if analysis:
203
+ task["rubert_analysis"] = analysis
204
+
205
+ result = await supabase_client.insert_task(task)
206
+ if result:
207
+ tasks_saved += 1
208
+ else:
209
+ duplicates_skipped += 1
210
+
211
+ # Скрапинг по предмету (по умолчанию)
212
+ else:
213
+ tasks = await scraper.scrape_tasks(subject=request.subject)
214
+ tasks_scraped = len(tasks)
215
+
216
+ for task in tasks:
217
+ # AI анализ через ruBERT
218
+ if rubert_client:
219
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
220
+ if analysis:
221
+ task["rubert_analysis"] = analysis
222
+
223
+ result = await supabase_client.insert_task(task)
224
+ if result:
225
+ tasks_saved += 1
226
+ else:
227
+ duplicates_skipped += 1
228
+
229
+ return ScrapeResponse(
230
+ success=True,
231
+ tasks_scraped=tasks_scraped,
232
+ tasks_saved=tasks_saved,
233
+ duplicates_skipped=duplicates_skipped,
234
+ message=f"Успешно обработано {tasks_scraped} заданий. Сохранено: {tasks_saved}, пропущено дубликатов: {duplicates_skipped}",
235
+ )
236
+
237
+ except Exception as e:
238
+ logger.error(f"Ошибка при скрапинге: {e}")
239
+ raise HTTPException(status_code=500, detail=f"Ошибка скрапинга: {str(e)}")
240
+
241
+
242
+ @app.get("/api/stats", response_model=StatsResponse)
243
+ async def get_stats():
244
+ """Получить статистику по заданиям"""
245
+ if not supabase_client:
246
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
247
+
248
+ stats = await supabase_client.get_stats()
249
+
250
+ # Получение времени последнего скрапинга
251
+ latest = await supabase_client.get_latest_tasks(limit=1)
252
+ last_scrape = latest[0].get("scraped_at") if latest else None
253
+
254
+ return StatsResponse(
255
+ total_tasks=stats.get("total", 0),
256
+ by_type=stats.get("by_type", {}),
257
+ last_scrape=last_scrape,
258
+ )
259
+
260
+
261
+ @app.post("/api/analyze", response_model=ScrapeResponse)
262
+ async def analyze_existing_tasks():
263
+ """
264
+ AI анализ существующих заданий в базе
265
+
266
+ Запускает ruBERT анализ для всех заданий без анализа
267
+ """
268
+ if not supabase_client:
269
+ raise HTTPException(status_code=503, detail="Supabase не настроен")
270
+
271
+ if not rubert_client:
272
+ raise HTTPException(status_code=503, detail="RuBERT клиент не настроен")
273
+
274
+ try:
275
+ tasks = await supabase_client.get_all_tasks()
276
+ analyzed_count = 0
277
+
278
+ for task in tasks:
279
+ # Пропускаем уже проанализированные
280
+ if task.get("rubert_analysis"):
281
+ continue
282
+
283
+ # Анализ через ruBERT
284
+ analysis = await rubert_client.analyze_text(task.get("content", ""))
285
+
286
+ if analysis:
287
+ task["rubert_analysis"] = analysis
288
+ await supabase_client.update_task(task["id"], {"rubert_analysis": analysis})
289
+ analyzed_count += 1
290
+
291
+ return ScrapeResponse(
292
+ success=True,
293
+ tasks_scraped=analyzed_count,
294
+ tasks_saved=analyzed_count,
295
+ duplicates_skipped=len(tasks) - analyzed_count,
296
+ message=f"Проанализировано {analyzed_count} заданий",
297
+ )
298
+
299
+ except Exception as e:
300
+ logger.error(f"Ошибка при анализе: {e}")
301
+ raise HTTPException(status_code=500, detail=f"Ошибка анализа: {str(e)}")
302
+
303
+
304
+ @app.get("/", tags=["root"])
305
+ async def root():
306
+ """Корневой эндпоинт"""
307
+ return {
308
+ "message": "AI Scraper ФИПИ API",
309
+ "version": "1.0.0",
310
+ "docs": "/docs",
311
+ }
312
+
313
+
314
+ @app.exception_handler(Exception)
315
+ async def global_exception_handler(request, exc):
316
+ """Глобальный обработчик исключений"""
317
+ logger.error(f"Необработанная ошибка: {exc}")
318
+ return ErrorResponse(
319
+ error="Internal Server Error",
320
+ detail=str(exc),
321
+ timestamp=datetime.utcnow(),
322
+ )
hf_spaces_config.yaml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Конфигурация для Hugging Face Spaces
2
+ # https://huggingface.co/docs/hub/spaces-sdks-docker
3
+
4
+ sdk: docker
5
+ python_version: 3.11
6
+
7
+ # Переменные окружения (опционально)
8
+ env:
9
+ - name: PORT
10
+ value: 7860
11
+ - name: HOST
12
+ value: 0.0.0.0
13
+
14
+ # Ресурсы (опционально, для CPU basic)
15
+ # hardware: cpu-basic
16
+
17
+ # Для использования GPU (если нужно)
18
+ # hardware: gpu-nvidia-t4
models.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic модели для API
3
+ """
4
+
5
+ from pydantic import BaseModel, Field
6
+ from typing import List, Optional, Dict, Any
7
+ from datetime import datetime
8
+
9
+
10
+ class TaskInput(BaseModel):
11
+ """Модель для входных данных задания"""
12
+ title: str = Field(..., description="Заголовок задания")
13
+ content: str = Field(..., description="Содержимое задания")
14
+ source_url: str = Field(..., description="URL источника")
15
+ task_type: Optional[str] = Field(None, description="Тип задания")
16
+ images: Optional[List[str]] = Field(default_factory=list, description="Изображения")
17
+ variants: Optional[List[str]] = Field(default_factory=list, description="Варианты ответов")
18
+
19
+
20
+ class TaskResponse(BaseModel):
21
+ """Модель для ответа с заданием"""
22
+ id: Optional[int] = None
23
+ title: str
24
+ content: str
25
+ source_url: str
26
+ task_type: Optional[str] = None
27
+ images: Optional[List[str]] = None
28
+ variants: Optional[List[str]] = None
29
+ scraped_at: Optional[datetime] = None
30
+ rubert_analysis: Optional[Dict[str, Any]] = None
31
+
32
+
33
+ class ScrapeRequest(BaseModel):
34
+ """Модель для запроса на скрапинг"""
35
+ subject: Optional[str] = Field("russian", description="Код предмета")
36
+ urls: Optional[List[str]] = Field(default_factory=list, description="Список URL для скрапинга")
37
+ query: Optional[str] = Field(None, description="Поисковый запрос")
38
+
39
+
40
+ class ScrapeResponse(BaseModel):
41
+ """Модель для ответа скрапинга"""
42
+ success: bool
43
+ tasks_scraped: int
44
+ tasks_saved: int
45
+ duplicates_skipped: int
46
+ message: str
47
+
48
+
49
+ class AnalysisRequest(BaseModel):
50
+ """Модель для запроса анализа"""
51
+ text: str = Field(..., description="Текст для анализа")
52
+
53
+
54
+ class AnalysisResponse(BaseModel):
55
+ """Модель для ответа анализа"""
56
+ category: str
57
+ keywords: List[str]
58
+ confidence: float
59
+ embedding: Optional[List[float]] = None
60
+
61
+
62
+ class HealthResponse(BaseModel):
63
+ """Модель для ответа health check"""
64
+ status: str
65
+ timestamp: datetime
66
+ services: Dict[str, bool]
67
+
68
+
69
+ class StatsResponse(BaseModel):
70
+ """Модель для ответа статистики"""
71
+ total_tasks: int
72
+ by_type: Dict[str, int]
73
+ last_scrape: Optional[datetime] = None
74
+
75
+
76
+ class ErrorResponse(BaseModel):
77
+ """Модель для ответа с ошибкой"""
78
+ error: str
79
+ detail: Optional[str] = None
80
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ python-dotenv==1.0.0
4
+ supabase==2.3.4
5
+ httpx==0.26.0
6
+ beautifulsoup4==4.12.3
7
+ lxml==5.1.0
8
+ requests==2.31.0
9
+ pydantic==2.5.3
10
+ pydantic-settings==2.1.0
11
+ aiohttp==3.9.1
12
+ playwright==1.40.0
rubert_client.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Клиент для RuBERT API
3
+ Анализ текстов заданий с использованием ruBERT
4
+ """
5
+
6
+ import httpx
7
+ from typing import Dict, List, Optional
8
+ import logging
9
+
10
+ logging.basicConfig(level=logging.INFO)
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class RuBERTClient:
15
+ """Клиент для взаимодействия с RuBERT API"""
16
+
17
+ def __init__(self, api_url: str):
18
+ self.api_url = api_url.rstrip('/')
19
+ self.timeout = 30.0
20
+
21
+ async def analyze_text(self, text: str) -> Optional[Dict]:
22
+ """
23
+ Анализ текста через ruBERT
24
+
25
+ Args:
26
+ text: Текст для анализа
27
+
28
+ Returns:
29
+ Результат анализа с категориями и метаданными
30
+ """
31
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
32
+ try:
33
+ response = await client.post(
34
+ f"{self.api_url}/api/analyze",
35
+ json={"text": text},
36
+ headers={"Content-Type": "application/json"}
37
+ )
38
+ response.raise_for_status()
39
+ return response.json()
40
+ except httpx.HTTPError as e:
41
+ logger.error(f"Ошибка RuBERT API: {e}")
42
+ return None
43
+
44
+ async def classify_task(self, task_text: str) -> Optional[str]:
45
+ """
46
+ Классификация типа задания
47
+
48
+ Args:
49
+ task_text: Текст задания
50
+
51
+ Returns:
52
+ Категория задания
53
+ """
54
+ result = await self.analyze_text(task_text)
55
+ if result:
56
+ return result.get("category", "unknown")
57
+ return None
58
+
59
+ async def extract_keywords(self, text: str) -> List[str]:
60
+ """
61
+ Извлечение ключевых слов из текста
62
+
63
+ Args:
64
+ text: Текст для анализа
65
+
66
+ Returns:
67
+ Список ключевых слов
68
+ """
69
+ result = await self.analyze_text(text)
70
+ if result:
71
+ return result.get("keywords", [])
72
+ return []
73
+
74
+ async def get_embedding(self, text: str) -> Optional[List[float]]:
75
+ """
76
+ Получение эмбеддинга текста
77
+
78
+ Args:
79
+ text: Текст для получения эмбеддинга
80
+
81
+ Returns:
82
+ Вектор эмбеддинга
83
+ """
84
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
85
+ try:
86
+ response = await client.post(
87
+ f"{self.api_url}/api/embedding",
88
+ json={"text": text},
89
+ headers={"Content-Type": "application/json"}
90
+ )
91
+ response.raise_for_status()
92
+ data = response.json()
93
+ return data.get("embedding")
94
+ except httpx.HTTPError as e:
95
+ logger.error(f"Ошибка получения эмбеддинга: {e}")
96
+ return None
97
+
98
+ async def similarity_search(
99
+ self,
100
+ query: str,
101
+ existing_texts: List[str]
102
+ ) -> List[Dict]:
103
+ """
104
+ Поиск похожих текстов
105
+
106
+ Args:
107
+ query: Поисковый запрос
108
+ existing_texts: Список текстов для поиска
109
+
110
+ Returns:
111
+ Список похожих текстов с оценками схожести
112
+ """
113
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
114
+ try:
115
+ response = await client.post(
116
+ f"{self.api_url}/api/similarity",
117
+ json={
118
+ "query": query,
119
+ "documents": existing_texts
120
+ },
121
+ headers={"Content-Type": "application/json"}
122
+ )
123
+ response.raise_for_status()
124
+ return response.json().get("results", [])
125
+ except httpx.HTTPError as e:
126
+ logger.error(f"Ошибка поиска похожих текстов: {e}")
127
+ return []
128
+
129
+ async def health_check(self) -> bool:
130
+ """Проверка доступности API"""
131
+ async with httpx.AsyncClient(timeout=5.0) as client:
132
+ try:
133
+ response = await client.get(f"{self.api_url}/api/health")
134
+ return response.status_code == 200
135
+ except Exception:
136
+ return False
schema.sql ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- SQL схема для таблицы заданий ФИПИ в Supabase
2
+ -- Выполните этот скрипт в SQL Editor вашего проекта Supabase
3
+
4
+ -- Создание таблицы для хранения заданий
5
+ CREATE TABLE IF NOT EXISTS fipi_tasks (
6
+ id BIGSERIAL PRIMARY KEY,
7
+ title TEXT NOT NULL,
8
+ content TEXT NOT NULL,
9
+ source_url TEXT UNIQUE NOT NULL,
10
+ task_type TEXT DEFAULT 'other',
11
+ images TEXT[] DEFAULT '{}',
12
+ variants TEXT[] DEFAULT '{}',
13
+ rubert_analysis JSONB DEFAULT '{}',
14
+ scraped_at TIMESTAMPTZ DEFAULT NOW(),
15
+ created_at TIMESTAMPTZ DEFAULT NOW(),
16
+ updated_at TIMESTAMPTZ DEFAULT NOW()
17
+ );
18
+
19
+ -- Индексы для ускорения поиска
20
+ CREATE INDEX IF NOT EXISTS idx_fipi_tasks_task_type ON fipi_tasks(task_type);
21
+ CREATE INDEX IF NOT EXISTS idx_fipi_tasks_scraped_at ON fipi_tasks(scraped_at DESC);
22
+ CREATE INDEX IF NOT EXISTS idx_fipi_tasks_source_url ON fipi_tasks(source_url);
23
+ CREATE INDEX IF NOT EXISTS idx_fipi_tasks_title ON fipi_tasks USING gin(title gin_trgm_ops);
24
+ CREATE INDEX IF NOT EXISTS idx_fipi_tasks_content ON fipi_tasks USING gin(content gin_trgm_ops);
25
+
26
+ -- Включение расширения для полнотекстового поиска
27
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
28
+
29
+ -- RLS (Row Level Security) политики
30
+ ALTER TABLE fipi_tasks ENABLE ROW LEVEL SECURITY;
31
+
32
+ -- Политика для чтения (публичный доступ)
33
+ CREATE POLICY "Public can view all tasks"
34
+ ON fipi_tasks
35
+ FOR SELECT
36
+ USING (true);
37
+
38
+ -- Политика для записи (только сервисный ключ)
39
+ CREATE POLICY "Service key can insert tasks"
40
+ ON fipi_tasks
41
+ FOR INSERT
42
+ WITH CHECK (true);
43
+
44
+ -- Политика для обновления
45
+ CREATE POLICY "Service key can update tasks"
46
+ ON fipi_tasks
47
+ FOR UPDATE
48
+ USING (true);
49
+
50
+ -- Политика для удаления
51
+ CREATE POLICY "Service key can delete tasks"
52
+ ON fipi_tasks
53
+ FOR DELETE
54
+ USING (true);
55
+
56
+ -- Триггер для автоматического обновления updated_at
57
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
58
+ RETURNS TRIGGER AS $$
59
+ BEGIN
60
+ NEW.updated_at = NOW();
61
+ RETURN NEW;
62
+ END;
63
+ $$ LANGUAGE plpgsql;
64
+
65
+ CREATE TRIGGER update_fipi_tasks_updated_at
66
+ BEFORE UPDATE ON fipi_tasks
67
+ FOR EACH ROW
68
+ EXECUTE FUNCTION update_updated_at_column();
69
+
70
+ -- Представление для статистики
71
+ CREATE OR REPLACE VIEW fipi_tasks_stats AS
72
+ SELECT
73
+ COUNT(*) as total_tasks,
74
+ COUNT(*) FILTER (WHERE task_type = 'writing') as writing_tasks,
75
+ COUNT(*) FILTER (WHERE task_type = 'test') as test_tasks,
76
+ COUNT(*) FILTER (WHERE task_type = 'listening') as listening_tasks,
77
+ COUNT(*) FILTER (WHERE task_type = 'reading') as reading_tasks,
78
+ COUNT(*) FILTER (WHERE task_type = 'other') as other_tasks,
79
+ MAX(scraped_at) as last_scrape
80
+ FROM fipi_tasks;
81
+
82
+ -- Комментарий к таблице
83
+ COMMENT ON TABLE fipi_tasks IS 'Задания с сайта ФИПИ (fipi.ru) для ЕГЭ и ОГЭ';
84
+ COMMENT ON COLUMN fipi_tasks.task_type IS 'Тип задания: writing, test, listening, reading, other';
85
+ COMMENT ON COLUMN fipi_tasks.rubert_analysis IS 'Результат анализа через ruBERT (категория, ключевые слова, эмбеддинги)';
scraper.cpython-314.pyc ADDED
Binary file (13.9 kB). View file
 
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,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)\n .execute()
155
+
156
+ return result.data[0] if result.data else None
157
+ except Exception as e:
158
+ logger.error(f"Ошибка обновления задания: {e}")
159
+ return None
160
+
161
+ async def delete_task(self, task_id: int) -> bool:
162
+ """Удаление задания"""
163
+ try:
164
+ result = self.client.table(self.table_name)\
165
+ .delete()\
166
+ .eq("id", task_id)\
167
+ .execute()
168
+
169
+ return len(result.data) > 0
170
+ except Exception as e:
171
+ logger.error(f"Ошибка удаления задания: {e}")
172
+ return False
173
+
174
+ async def get_stats(self) -> Dict:
175
+ """Получение статистики по заданиям"""
176
+ try:
177
+ all_tasks = await self.get_all_tasks()
178
+
179
+ stats = {
180
+ "total": len(all_tasks),
181
+ "by_type": {}
182
+ }
183
+
184
+ for task in all_tasks:
185
+ task_type = task.get("task_type", "unknown")
186
+ stats["by_type"][task_type] = stats["by_type"].get(task_type, 0) + 1
187
+
188
+ return stats
189
+ except Exception as e:
190
+ logger.error(f"Ошибка получения статистики: {e}")
191
+ return {"total": 0, "by_type": {}}
test_scraper.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Тест для проверки работы скрапера
3
+ """
4
+
5
+ import asyncio
6
+ import sys
7
+ from bs4 import BeautifulSoup
8
+ from scraper import FIPIScraper
9
+
10
+
11
+ async def test_scraper():
12
+ """Тестирование скрапера"""
13
+ scraper = FIPIScraper(base_url="https://fipi.ru")
14
+
15
+ print("=" * 50)
16
+ print("Тестирование скрапера ФИПИ")
17
+ print("=" * 50)
18
+
19
+ # Тест 1: Проверка главной страницы
20
+ print("\n1. Проверка главной страницы...")
21
+ html = await scraper.fetch_page("https://fipi.ru")
22
+ if html:
23
+ print(f" ✅ Главная страница получена (длина: {len(html)} символов)")
24
+ else:
25
+ print(" ❌ Не удалось получить главную страницу")
26
+ return
27
+
28
+ # Тест 2: Парсинг главной страницы
29
+ print("\n2. Парсинг главной страницы...")
30
+ task = scraper.parse_task_page(html, "https://fipi.ru")
31
+ if task:
32
+ print(f" ✅ Заголовок: {task['title'][:100] if task['title'] else 'N/A'}")
33
+ print(f" ✅ Тип задания: {task['task_type']}")
34
+ else:
35
+ print(" ❌ Не удалось распарсить страницу")
36
+
37
+ # Тест 3: Проверка страницы с ЕГЭ
38
+ print("\n3. Проверка страницы ЕГЭ...")
39
+ html = await scraper.fetch_page("https://fipi.ru/ege")
40
+ if html:
41
+ print(f" ✅ Страница ЕГЭ получена (длина: {len(html)} символов)")
42
+ task = scraper.parse_task_page(html, "https://fipi.ru/ege")
43
+ if task:
44
+ print(f" ✅ Заголовок: {task['title'][:100] if task['title'] else 'N/A'}")
45
+ else:
46
+ print(" ❌ Не удалось получить страницу ЕГЭ")
47
+
48
+ # Тест 4: Проверка банка заданий ЕГЭ
49
+ print("\n4. Проверка банка заданий ЕГЭ...")
50
+ html = await scraper.fetch_page("https://fipi.ru/ege/otkrytyy-bank-zadaniy-ege")
51
+ if html:
52
+ print(f" ✅ Страница банка заданий ЕГЭ получена (длина: {len(html)} символов)")
53
+ # Пробуем найти ссылки на задания
54
+ soup = BeautifulSoup(html, 'lxml')
55
+ links = [a['href'] for a in soup.find_all('a', href=True) if '/ege/' in a['href']]
56
+ print(f" ✅ Найдено ссылок в банке заданий: {len(links)}")
57
+ if links:
58
+ print(f" 📋 Примеры ссылок: {links[:5]}")
59
+ else:
60
+ print(" ❌ Не удалось получить страницу банка заданий ЕГЭ")
61
+
62
+ # Тест 5: Скрапинг заданий по предмету
63
+ print("\n5. Скрапинг заданий по русскому языку...")
64
+ tasks = await scraper.scrape_tasks(subject="russian")
65
+ print(f" {'✅' if tasks else '⚠️'} Найдено заданий: {len(tasks)}")
66
+ for i, task in enumerate(tasks[:3], 1):
67
+ print(f" {i}. {task.get('title', 'Без названия')[:80]}")
68
+
69
+ print("\n" + "=" * 50)
70
+ print("Тестирование завершено")
71
+ print("=" * 50)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ asyncio.run(test_scraper())