From 2ef51e43a373ca7a2294a0942b3b30bb3c9c933d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 14 Sep 2025 10:13:50 +0500 Subject: [PATCH] parser --- Dockerfile | 29 ++ PARSER_README.md | 291 ++++++++++++++++++ TASK.md | 67 ++++ pom.xml | 47 ++- run_parser.sh | 76 +++++ .../kz/konturai/parser/ParserApplication.java | 2 + .../parser/controller/ParserController.java | 150 +++++++++ .../kz/konturai/parser/model/MarketItem.java | 183 +++++++++++ .../repository/MarketItemRepository.java | 21 ++ .../parser/service/KursivParserService.java | 260 ++++++++++++++++ src/main/resources/application.properties | 16 + .../parser/scheduler/SchedulerTest.java | 50 +++ .../service/KursivParserServiceTest.java | 88 ++++++ ДОП_ТЗ.md | 64 ++++ 14 files changed, 1334 insertions(+), 10 deletions(-) create mode 100644 Dockerfile create mode 100644 PARSER_README.md create mode 100644 TASK.md create mode 100644 run_parser.sh create mode 100644 src/main/java/kz/konturai/parser/controller/ParserController.java create mode 100644 src/main/java/kz/konturai/parser/model/MarketItem.java create mode 100644 src/main/java/kz/konturai/parser/repository/MarketItemRepository.java create mode 100644 src/main/java/kz/konturai/parser/service/KursivParserService.java create mode 100644 src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java create mode 100644 src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java create mode 100644 ДОП_ТЗ.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4db24c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM maven:3.9.9-eclipse-temurin-21 AS build +WORKDIR /app +COPY ./pom.xml ./ +RUN mvn dependency:resolve +COPY . . +RUN mvn clean install -DskipTests + +FROM openjdk:21-jdk-slim + +# Create a non-root user and group for security +RUN groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser + +# Create a directory for heap dumps and give ownership to the new user +RUN mkdir /dumps && chown appuser:appgroup /dumps + +# Set the working directory +WORKDIR /app + +# Copy the application jar from the build stage +COPY --from=build /app/target/*.jar app.jar + +# Change ownership of the application files to the non-root user +RUN chown appuser:appgroup app.jar + +# Switch to the non-root user +USER appuser + +# Run the jar file with JVM flags for heap dump generation on OutOfMemoryError +ENTRYPOINT ["java", "-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=/dumps/heap.hprof", "-jar", "app.jar"] \ No newline at end of file diff --git a/PARSER_README.md b/PARSER_README.md new file mode 100644 index 0000000..ff3ac64 --- /dev/null +++ b/PARSER_README.md @@ -0,0 +1,291 @@ +# RSS Parser для Kursiv Media + +Этот проект представляет собой RSS-парсер для сайта Kursiv Media, разработанный на Spring Boot с сохранением данных в MongoDB. + +## Описание + +Парсер получает данные из RSS-ленты `https://kursiv.media/feed/`, обрабатывает их и сохраняет в базу данных MongoDB с дедупликацией по хэшу. + +**🔄 Автоматический режим работы:** Парсер автоматически запускается каждые 30 минут (в 0 и 30 минут каждого часа) для обеспечения актуальности данных. + +## Технологический стек + +- **Java 21** +- **Spring Boot 3.5.5** +- **Spring Data MongoDB** +- **Rome** - библиотека для парсинга RSS +- **JSoup** - для очистки HTML +- **MongoDB** - база данных + +## Структура проекта + +``` +src/main/java/kz/konturai/parser/ +├── ParserApplication.java # Главный класс приложения +├── controller/ +│ └── ParserController.java # REST API контроллер +├── model/ +│ └── MarketItem.java # Модель данных для MongoDB +├── repository/ +│ └── MarketItemRepository.java # Репозиторий для работы с MongoDB +└── service/ + └── KursivParserService.java # Основной сервис парсинга +``` + +## Установка и запуск + +### Предварительные требования + +1. **Java 21** или выше +2. **MongoDB** (локально или удаленно) +3. **Maven 3.6+** + +### Шаги установки + +1. **Клонируйте репозиторий:** + + ```bash + git clone + cd parser + ``` + +2. **Установите MongoDB:** + + - Скачайте и установите MongoDB с официального сайта + - Запустите MongoDB сервис + - По умолчанию MongoDB работает на порту 27017 + +3. **Настройте конфигурацию:** + + Отредактируйте файл `src/main/resources/application.properties`: + + ```properties + # MongoDB Configuration + spring.data.mongodb.host=localhost + spring.data.mongodb.port=27017 + spring.data.mongodb.database=parser_db + + # RSS Feed URL + rss.feed.url=https://kursiv.media/feed/ + ``` + +4. **Соберите проект:** + + ```bash + ./mvnw clean compile + ``` + +5. **Запустите приложение:** + + ```bash + ./mvnw spring-boot:run + ``` + + Приложение будет доступно по адресу: `http://localhost:8080` + +## API Endpoints + +### 1. Запуск парсинга + +```http +POST /api/parser/parse +``` + +**Ответ:** + +```json +{ + "success": true, + "message": "Парсинг завершен успешно", + "processedItems": 15, + "totalItemsInDb": 25 +} +``` + +### 2. Получение статистики + +```http +GET /api/parser/stats +``` + +**Ответ:** + +```json +{ + "success": true, + "totalItems": 25, + "message": "Статистика получена успешно" +} +``` + +### 3. Получение всех записей + +```http +GET /api/parser/items +``` + +**Ответ:** + +```json +{ + "success": true, + "items": [...], + "count": 25, + "message": "Записи получены успешно" +} +``` + +### 4. Проверка состояния + +```http +GET /api/parser/health +``` + +**Ответ:** + +```json +{ + "status": "UP", + "service": "Kursiv RSS Parser", + "timestamp": 1703123456789, + "scheduler": "Enabled - runs every 30 minutes" +} +``` + +### 5. Информация о планировщике + +```http +GET /api/parser/scheduler/info +``` + +**Ответ:** + +```json +{ + "schedulerEnabled": true, + "cronExpression": "0 0/30 * * * ?", + "description": "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)", + "nextRun": "Следующий запуск будет в ближайшие 0 или 30 минут часа" +} +``` + +## Структура данных в MongoDB + +Документы сохраняются в коллекции `market_items` со следующей структурой: + +```json +{ + "_id": "ObjectId", + "source_name": "Kursiv (Бизнес/экономика)", + "url": "https://kursiv.media/article/...", + "title": "Заголовок новости", + "published_at": "2025-01-14T10:30:00", + "added_at": "2025-01-14T12:00:00", + "raw_text": "Очищенный от HTML текст статьи...", + "hash": "sha256_hash_of_url_and_title", + "category": "Бизнес", + "analytics": { + "summary": null, + "sentiment": null, + "tags": [], + "entities": {} + } +} +``` + +## Особенности реализации + +### Дедупликация + +- Каждая запись имеет уникальный хэш, сгенерированный по формуле: `SHA256(url + "::" + title)` +- При повторном парсинге существующие записи обновляются (upsert операция) + +### Очистка данных + +- HTML-теги удаляются из текста описания +- Даты приводятся к формату `LocalDateTime` +- Текстовое содержимое нормализуется + +### Обработка ошибок + +- Логирование всех операций +- Graceful handling ошибок парсинга отдельных записей +- Продолжение работы при ошибках в отдельных элементах RSS + +### Автоматический планировщик + +- **Автозапуск**: Парсер автоматически запускается каждые 30 минут +- **Cron выражение**: `0 0/30 * * * ?` (запуск в 0 и 30 минут каждого часа) +- **Логирование**: Все автоматические запуски логируются с эмодзи для удобства мониторинга +- **Обработка ошибок**: Ошибки в автоматическом режиме не останавливают планировщик + +## Тестирование + +Запуск тестов: + +```bash +./mvnw test +``` + +Тесты включают: + +- Проверку парсинга RSS-ленты +- Проверку сохранения в MongoDB +- Проверку дедупликации +- Проверку структуры данных + +## Мониторинг и логирование + +Приложение использует SLF4J для логирования. Уровень логирования можно настроить в `application.properties`: + +```properties +logging.level.kz.konturai.parser=DEBUG +logging.level.org.springframework.data.mongodb=DEBUG +``` + +## Возможные проблемы и решения + +### MongoDB не запущен + +``` +Error: Could not connect to MongoDB +``` + +**Решение:** Убедитесь, что MongoDB запущен и доступен на указанном хосте и порту. + +### RSS-лента недоступна + +``` +Error: Could not fetch RSS feed +``` + +**Решение:** Проверьте доступность URL `https://kursiv.media/feed/` и интернет-соединение. + +### Проблемы с кодировкой + +Если возникают проблемы с кодировкой текста, убедитесь, что в системе установлена правильная локаль. + +## Развертывание в продакшене + +1. **Настройте переменные окружения:** + + ```bash + export SPRING_DATA_MONGODB_HOST=your-mongodb-host + export SPRING_DATA_MONGODB_PORT=27017 + export SPRING_DATA_MONGODB_DATABASE=parser_prod + ``` + +2. **Соберите JAR файл:** + + ```bash + ./mvnw clean package + ``` + +3. **Запустите приложение:** + ```bash + java -jar target/parser-0.0.1-SNAPSHOT.jar + ``` + +## Лицензия + +Этот проект разработан для внутреннего использования в рамках AI-платформы для маркетинговой аналитики. diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..e7790ed --- /dev/null +++ b/TASK.md @@ -0,0 +1,67 @@ +### Техническое задание для AI-агента + +**Задача:** Разработать парсер (парсер) для RSS-ленты `https://kursiv.media/feed/` на Spring Boot с сохранением результатов в MongoDB. + +[cite\_start]**Контекст:** Этот парсер является частью большой AI-платформы для маркетинговой аналитики[cite: 5]. Его цель — собирать новости из указанного источника, нормализовать их и сохранять в базу данных для дальнейшей обработки. + +--- + +## Основные требования + +1. **Получение данных**: Создать сервис, который выполняет HTTP GET-запрос к URL: `https://kursiv.media/feed/`. +2. **Парсинг RSS**: Разобрать полученный XML-ответ. Для каждой записи (элемента ``) в RSS-ленте необходимо извлечь следующие поля: + - [cite\_start]`title` (заголовок)[cite: 64]. + - [cite\_start]`link` или `guid` (URL статьи)[cite: 64]. + - [cite\_start]`pubDate` или `isoDate` (дата публикации)[cite: 64]. + - [cite\_start]`description` или `content` (текстовое содержимое)[cite: 65]. +3. **Нормализация данных**: + - [cite\_start]**Очистка текста**: Текстовое содержимое из `description`/`content` должно быть полностью очищено от HTML-тегов[cite: 65]. + - **Формат даты**: Дата публикации должна быть приведена к формату `ISODate`. +4. **Создание хэша**: Для каждой новости необходимо сгенерировать уникальный хэш для дедупликации. [cite\_start]Хэш формируется по формуле `sha256(url + "::" + title)`[cite: 54]. +5. **Сохранение в MongoDB**: + - Подготовленные данные должны сохраняться в коллекцию MongoDB с названием `market_items`. + - [cite\_start]Операция сохранения должна быть **"upsert"** (обновить, если существует, или вставить, если нет)[cite: 56]. [cite\_start]В качестве ключа для поиска существующей записи используйте поле `hash`[cite: 56]. + +--- + +## Модель данных для коллекции `market_items` в MongoDB + +Документ в коллекции должен иметь следующую структуру: + +```json +{ + "source_name": "Kursiv (Бизнес/экономика)", // Статическое значение для этого парсера + "url": "https://kursiv.media/article/...", // Из поля + "title": "Заголовок новости", // Из поля + "published_at": ISODate("2025-09-14T..."), // Из поля <pubDate> + "added_at": ISODate("..."), // Текущее время при добавлении + "raw_text": "Очищенный от HTML текст статьи...", // Из поля <description> + "hash": "...", // Сгенерированный SHA-256 хэш + "category": "Бизнес", // Можно задать по умолчанию + + // Этот блок будет заполняться на следующем этапе, но его нужно предусмотреть + "analytics": { + "summary": null, + "sentiment": null, + "tags": [], + "entities": {} + } +} +``` + +--- + +## Технологический стек и реализация + +- **Фреймворк**: Spring Boot +- **База данных**: Spring Data MongoDB +- **HTTP-клиент**: `RestTemplate` или `WebClient` +- **Парсинг XML/RSS**: Рекомендуется использовать библиотеку, например, **Rome** (`com.rometools:rome`), для удобной работы с RSS-лентами. +- **Логика**: Реализовать класс-сервис (например, `KursivParserService`), который будет содержать всю логику. Для взаимодействия с MongoDB использовать `MongoRepository` или `MongoTemplate`. + +## Критерии выполнения + +- Создан Spring Boot сервис, который успешно получает и парсит данные с `https://kursiv.media/feed/`. +- При вызове метода этого сервиса, новые записи появляются в коллекции `market_items` в MongoDB. +- При повторном вызове дубликаты новостей не создаются, а существующие записи могут быть обновлены (логика `upsert`). +- Структура сохраненных документов в MongoDB полностью соответствует указанной модели данных. diff --git a/pom.xml b/pom.xml index 4bdaf0c..c4dfe0b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,30 +1,31 @@ <?xml version="1.0" encoding="UTF-8"?> -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.5</version> - <relativePath/> <!-- lookup parent from repository --> + <relativePath /> <!-- lookup parent from repository --> </parent> <groupId>kz.konturai</groupId> <artifactId>parser</artifactId> <version>0.0.1-SNAPSHOT</version> <name>parser</name> <description>Demo project for Spring Boot</description> - <url/> + <url /> <licenses> - <license/> + <license /> </licenses> <developers> - <developer/> + <developer /> </developers> <scm> - <connection/> - <developerConnection/> - <tag/> - <url/> + <connection /> + <developerConnection /> + <tag /> + <url /> </scm> <properties> <java.version>21</java.version> @@ -35,6 +36,32 @@ <artifactId>spring-boot-starter</artifactId> </dependency> + <!-- Spring Boot Web Starter для REST API --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + + <!-- Spring Boot Data MongoDB Starter --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-mongodb</artifactId> + </dependency> + + <!-- Rome library для парсинга RSS --> + <dependency> + <groupId>com.rometools</groupId> + <artifactId>rome</artifactId> + <version>2.1.0</version> + </dependency> + + <!-- JSoup для очистки HTML --> + <dependency> + <groupId>org.jsoup</groupId> + <artifactId>jsoup</artifactId> + <version>1.17.2</version> + </dependency> + <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> @@ -51,4 +78,4 @@ </plugins> </build> -</project> +</project> \ No newline at end of file diff --git a/run_parser.sh b/run_parser.sh new file mode 100644 index 0000000..84d5955 --- /dev/null +++ b/run_parser.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +echo "🚀 Запуск RSS Parser для Kursiv Media" +echo "======================================" + +# Проверяем наличие Java +if ! command -v java &> /dev/null; then + echo "❌ Java не найдена. Убедитесь, что Java 21+ установлена." + exit 1 +fi + +# Проверяем версию Java +JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2 | cut -d'.' -f1) +if [ "$JAVA_VERSION" -lt 21 ]; then + echo "❌ Требуется Java 21 или выше. Текущая версия: $JAVA_VERSION" + exit 1 +fi + +echo "✅ Java версия: $(java -version 2>&1 | head -n 1)" + +# Проверяем наличие Maven wrapper +if [ ! -f "./mvnw" ]; then + echo "❌ Maven wrapper не найден" + exit 1 +fi + +echo "✅ Maven wrapper найден" + +# Собираем проект +echo "📦 Сборка проекта..." +./mvnw clean compile -q + +if [ $? -ne 0 ]; then + echo "❌ Ошибка при сборке проекта" + exit 1 +fi + +echo "✅ Проект собран успешно" + +# Проверяем доступность MongoDB (опционально) +echo "🔍 Проверка MongoDB..." +if command -v mongosh &> /dev/null; then + if mongosh --eval "db.runCommand('ping')" --quiet &> /dev/null; then + echo "✅ MongoDB доступен" + else + echo "⚠️ MongoDB может быть недоступен. Убедитесь, что MongoDB запущен." + fi +else + echo "⚠️ MongoDB клиент не найден. Убедитесь, что MongoDB установлен и запущен." +fi + +echo "" +echo "🎯 Доступные команды:" +echo "1. Запуск приложения: ./mvnw spring-boot:run" +echo "2. Запуск тестов: ./mvnw test" +echo "3. Сборка JAR: ./mvnw clean package" +echo "" +echo "📡 После запуска приложения API будет доступно по адресу:" +echo " http://localhost:8080/api/parser/" +echo "" +echo "🔧 Основные endpoints:" +echo " POST /api/parser/parse - Запуск парсинга" +echo " GET /api/parser/stats - Статистика" +echo " GET /api/parser/items - Все записи" +echo " GET /api/parser/health - Проверка состояния" +echo "" + +# Спрашиваем, хочет ли пользователь запустить приложение +read -p "🚀 Запустить приложение сейчас? (y/n): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "🚀 Запуск приложения..." + ./mvnw spring-boot:run +else + echo "👋 Для запуска используйте: ./mvnw spring-boot:run" +fi diff --git a/src/main/java/kz/konturai/parser/ParserApplication.java b/src/main/java/kz/konturai/parser/ParserApplication.java index 73d1dd7..702cd2d 100644 --- a/src/main/java/kz/konturai/parser/ParserApplication.java +++ b/src/main/java/kz/konturai/parser/ParserApplication.java @@ -2,8 +2,10 @@ package kz.konturai.parser; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class ParserApplication { public static void main(String[] args) { diff --git a/src/main/java/kz/konturai/parser/controller/ParserController.java b/src/main/java/kz/konturai/parser/controller/ParserController.java new file mode 100644 index 0000000..4279080 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/ParserController.java @@ -0,0 +1,150 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.KursivParserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/parser") +@CrossOrigin(origins = "*") +public class ParserController { + + @Autowired + private KursivParserService kursivParserService; + + /** + * Запуск парсинга RSS-ленты + */ + @PostMapping("/parse") + public ResponseEntity<Map<String, Object>> parseRssFeed() { + Map<String, Object> response = new HashMap<>(); + + try { + List<MarketItem> savedItems = kursivParserService.parseAndSaveRssFeed(); + + response.put("success", true); + response.put("message", "Парсинг завершен успешно"); + response.put("processedItems", savedItems.size()); + response.put("totalItemsInDb", kursivParserService.getTotalItemsCount()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + response.put("success", false); + response.put("message", "Ошибка при парсинге: " + e.getMessage()); + response.put("processedItems", 0); + + return ResponseEntity.internalServerError().body(response); + } + } + + /** + * Получение статистики по сохраненным записям + */ + @GetMapping("/stats") + public ResponseEntity<Map<String, Object>> getStats() { + Map<String, Object> response = new HashMap<>(); + + try { + long totalCount = kursivParserService.getTotalItemsCount(); + + response.put("success", true); + response.put("totalItems", totalCount); + response.put("message", "Статистика получена успешно"); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + response.put("success", false); + response.put("message", "Ошибка при получении статистики: " + e.getMessage()); + response.put("totalItems", 0); + + return ResponseEntity.internalServerError().body(response); + } + } + + /** + * Получение всех сохраненных записей + */ + @GetMapping("/items") + public ResponseEntity<Map<String, Object>> getAllItems() { + Map<String, Object> response = new HashMap<>(); + + try { + List<MarketItem> items = kursivParserService.getAllItems(); + + response.put("success", true); + response.put("items", items); + response.put("count", items.size()); + response.put("message", "Записи получены успешно"); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + response.put("success", false); + response.put("message", "Ошибка при получении записей: " + e.getMessage()); + response.put("items", List.of()); + response.put("count", 0); + + return ResponseEntity.internalServerError().body(response); + } + } + + /** + * Получение записи по ID + */ + @GetMapping("/items/{id}") + public ResponseEntity<Map<String, Object>> getItemById(@PathVariable String id) { + Map<String, Object> response = new HashMap<>(); + + try { + // Здесь можно добавить метод для поиска по ID в сервисе + response.put("success", false); + response.put("message", "Функция поиска по ID пока не реализована"); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + response.put("success", false); + response.put("message", "Ошибка при получении записи: " + e.getMessage()); + + return ResponseEntity.internalServerError().body(response); + } + } + + /** + * Проверка состояния парсера + */ + @GetMapping("/health") + public ResponseEntity<Map<String, Object>> healthCheck() { + Map<String, Object> response = new HashMap<>(); + + response.put("status", "UP"); + response.put("service", "Kursiv RSS Parser"); + response.put("timestamp", System.currentTimeMillis()); + response.put("scheduler", "Enabled - runs every 30 minutes"); + + return ResponseEntity.ok(response); + } + + /** + * Получение информации о планировщике + */ + @GetMapping("/scheduler/info") + public ResponseEntity<Map<String, Object>> getSchedulerInfo() { + Map<String, Object> response = new HashMap<>(); + + response.put("schedulerEnabled", true); + response.put("cronExpression", "0 0/30 * * * ?"); + response.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)"); + response.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа"); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/kz/konturai/parser/model/MarketItem.java b/src/main/java/kz/konturai/parser/model/MarketItem.java new file mode 100644 index 0000000..9a05a57 --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/MarketItem.java @@ -0,0 +1,183 @@ +package kz.konturai.parser.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.Map; + +@Document(collection = "market_items") +public class MarketItem { + + @Id + private String id; + + @Field("source_name") + private String sourceName; + + @Field("url") + private String url; + + @Field("title") + private String title; + + @Field("published_at") + private LocalDateTime publishedAt; + + @Field("added_at") + private LocalDateTime addedAt; + + @Field("raw_text") + private String rawText; + + @Field("hash") + private String hash; + + @Field("category") + private String category; + + @Field("analytics") + private Analytics analytics; + + // Конструкторы + public MarketItem() { + this.addedAt = LocalDateTime.now(); + this.sourceName = "Kursiv (Бизнес/экономика)"; + this.category = "Бизнес"; + this.analytics = new Analytics(); + } + + public MarketItem(String url, String title, LocalDateTime publishedAt, String rawText, String hash) { + this(); + this.url = url; + this.title = title; + this.publishedAt = publishedAt; + this.rawText = rawText; + this.hash = hash; + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSourceName() { + return sourceName; + } + + public void setSourceName(String sourceName) { + this.sourceName = sourceName; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDateTime getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(LocalDateTime publishedAt) { + this.publishedAt = publishedAt; + } + + public LocalDateTime getAddedAt() { + return addedAt; + } + + public void setAddedAt(LocalDateTime addedAt) { + this.addedAt = addedAt; + } + + public String getRawText() { + return rawText; + } + + public void setRawText(String rawText) { + this.rawText = rawText; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public Analytics getAnalytics() { + return analytics; + } + + public void setAnalytics(Analytics analytics) { + this.analytics = analytics; + } + + // Вложенный класс для аналитики + public static class Analytics { + private String summary; + private String sentiment; + private String[] tags = {}; + private Map<String, Object> entities = Map.of(); + + public Analytics() { + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getSentiment() { + return sentiment; + } + + public void setSentiment(String sentiment) { + this.sentiment = sentiment; + } + + public String[] getTags() { + return tags; + } + + public void setTags(String[] tags) { + this.tags = tags; + } + + public Map<String, Object> getEntities() { + return entities; + } + + public void setEntities(Map<String, Object> entities) { + this.entities = entities; + } + } +} diff --git a/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java b/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java new file mode 100644 index 0000000..5d976c2 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.MarketItem; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MarketItemRepository extends MongoRepository<MarketItem, String> { + + /** + * Найти запись по хэшу для дедупликации + */ + Optional<MarketItem> findByHash(String hash); + + /** + * Проверить существование записи по хэшу + */ + boolean existsByHash(String hash); +} diff --git a/src/main/java/kz/konturai/parser/service/KursivParserService.java b/src/main/java/kz/konturai/parser/service/KursivParserService.java new file mode 100644 index 0000000..0da0209 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/KursivParserService.java @@ -0,0 +1,260 @@ +package kz.konturai.parser.service; + +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import org.jsoup.Jsoup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +@Service +public class KursivParserService { + + private static final Logger logger = LoggerFactory.getLogger(KursivParserService.class); + + @Autowired + private MarketItemRepository marketItemRepository; + + @Value("${rss.feed.url}") + private String rssFeedUrl; + + /** + * Автоматический запуск парсинга каждые 30 минут + * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа + */ + @Scheduled(cron = "0 0/30 * * * ?") + public void scheduledParseAndStoreNews() { + logger.info("🔄 Запуск планового парсинга новостей с Kursiv.media..."); + try { + List<MarketItem> savedItems = parseAndSaveRssFeed(); + logger.info("✅ Плановый парсинг завершен. Обработано записей: {}", savedItems.size()); + } catch (Exception e) { + logger.error("❌ Ошибка при плановом парсинге", e); + } + } + + /** + * Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB + */ + public List<MarketItem> parseAndSaveRssFeed() { + List<MarketItem> savedItems = new ArrayList<>(); + + try { + logger.info("Начинаем парсинг RSS-ленты: {}", rssFeedUrl); + + // Получаем RSS-ленту + SyndFeed feed = getRssFeed(); + + if (feed == null) { + logger.error("Не удалось получить RSS-ленту"); + return savedItems; + } + + logger.info("Получено {} записей из RSS-ленты", feed.getEntries().size()); + + // Обрабатываем каждую запись + for (SyndEntry entry : feed.getEntries()) { + try { + MarketItem marketItem = processRssEntry(entry); + if (marketItem != null) { + MarketItem savedItem = saveOrUpdateMarketItem(marketItem); + if (savedItem != null) { + savedItems.add(savedItem); + } + } + } catch (Exception e) { + logger.error("Ошибка при обработке записи: {}", entry.getTitle(), e); + } + } + + logger.info("Успешно обработано {} записей", savedItems.size()); + + } catch (Exception e) { + logger.error("Ошибка при парсинге RSS-ленты", e); + } + + return savedItems; + } + + /** + * Получение RSS-ленты + */ + private SyndFeed getRssFeed() throws Exception { + URL feedUrl = URI.create(rssFeedUrl).toURL(); + SyndFeedInput input = new SyndFeedInput(); + try (XmlReader reader = new XmlReader(feedUrl.openStream())) { + return input.build(reader); + } + } + + /** + * Обработка отдельной записи RSS + */ + private MarketItem processRssEntry(SyndEntry entry) { + try { + // Извлекаем основные поля + String title = entry.getTitle(); + String url = entry.getLink(); + String description = getDescription(entry); + + if (title == null || url == null) { + logger.warn("Пропускаем запись с отсутствующими обязательными полями: title={}, url={}", title, url); + return null; + } + + // Очищаем текст от HTML + String cleanText = cleanHtmlText(description); + + // Преобразуем дату + LocalDateTime publishedAt = convertToLocalDateTime(entry.getPublishedDate()); + + // Генерируем хэш + String hash = generateHash(url, title); + + // Создаем объект MarketItem + MarketItem marketItem = new MarketItem(url, title, publishedAt, cleanText, hash); + + logger.debug("Обработана запись: {}", title); + + return marketItem; + + } catch (Exception e) { + logger.error("Ошибка при обработке записи RSS", e); + return null; + } + } + + /** + * Извлечение описания из записи RSS + */ + private String getDescription(SyndEntry entry) { + if (entry.getDescription() != null) { + return entry.getDescription().getValue(); + } + + // Пробуем получить содержимое из других полей + if (entry.getContents() != null && !entry.getContents().isEmpty()) { + return entry.getContents().get(0).getValue(); + } + + return ""; + } + + /** + * Очистка HTML-тегов из текста + */ + private String cleanHtmlText(String htmlText) { + if (htmlText == null || htmlText.trim().isEmpty()) { + return ""; + } + + try { + return Jsoup.parse(htmlText).text(); + } catch (Exception e) { + logger.warn("Ошибка при очистке HTML: {}", e.getMessage()); + return htmlText; + } + } + + /** + * Преобразование даты в LocalDateTime + */ + private LocalDateTime convertToLocalDateTime(java.util.Date date) { + if (date == null) { + return LocalDateTime.now(); + } + + return date.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } + + /** + * Генерация SHA-256 хэша для дедупликации + */ + private String generateHash(String url, String title) { + try { + String input = url + "::" + title; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + + } catch (NoSuchAlgorithmException e) { + logger.error("Ошибка при генерации хэша", e); + return String.valueOf((url + "::" + title).hashCode()); + } + } + + /** + * Сохранение или обновление записи в MongoDB (upsert) + */ + private MarketItem saveOrUpdateMarketItem(MarketItem marketItem) { + try { + // Проверяем существование записи по хэшу + MarketItem existingItem = marketItemRepository.findByHash(marketItem.getHash()).orElse(null); + + if (existingItem != null) { + // Обновляем существующую запись + existingItem.setTitle(marketItem.getTitle()); + existingItem.setUrl(marketItem.getUrl()); + existingItem.setPublishedAt(marketItem.getPublishedAt()); + existingItem.setRawText(marketItem.getRawText()); + existingItem.setAddedAt(LocalDateTime.now()); // Обновляем время добавления + + MarketItem savedItem = marketItemRepository.save(existingItem); + logger.debug("Обновлена существующая запись: {}", marketItem.getTitle()); + return savedItem; + } else { + // Создаем новую запись + MarketItem savedItem = marketItemRepository.save(marketItem); + logger.debug("Создана новая запись: {}", marketItem.getTitle()); + return savedItem; + } + + } catch (Exception e) { + logger.error("Ошибка при сохранении записи: {}", marketItem.getTitle(), e); + return null; + } + } + + /** + * Получение статистики по сохраненным записям + */ + public long getTotalItemsCount() { + return marketItemRepository.count(); + } + + /** + * Получение всех сохраненных записей + */ + public List<MarketItem> getAllItems() { + return marketItemRepository.findAll(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b8f8332..b1fb4eb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,17 @@ spring.application.name=parser + +# MongoDB Configuration +spring.data.mongodb.host=92.38.48.166 +spring.data.mongodb.port=27017 +spring.data.mongodb.database=parser_db + +# RSS Feed URL +rss.feed.url=https://kursiv.media/feed/ + +# Scheduler Configuration +spring.task.scheduling.pool.size=2 +spring.task.scheduling.thread-name-prefix=scheduled-task- + +# Logging Configuration +logging.level.kz.konturai.parser.service.KursivParserService=INFO +logging.level.org.springframework.scheduling=DEBUG diff --git a/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java b/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java new file mode 100644 index 0000000..6ff2bb8 --- /dev/null +++ b/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java @@ -0,0 +1,50 @@ +package kz.konturai.parser.scheduler; + +import kz.konturai.parser.service.KursivParserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.data.mongodb.host=localhost", + "spring.data.mongodb.port=27017", + "spring.data.mongodb.database=parser_test_db", + "rss.feed.url=https://kursiv.media/feed/" +}) +public class SchedulerTest { + + @Autowired + private KursivParserService kursivParserService; + + @Test + public void testScheduledMethodExists() { + // Проверяем, что метод scheduledParseAndStoreNews существует и доступен + assertDoesNotThrow(() -> { + // Используем рефлексию для проверки существования метода + kursivParserService.getClass().getMethod("scheduledParseAndStoreNews"); + }, "Метод scheduledParseAndStoreNews должен существовать"); + } + + @Test + public void testScheduledMethodCanBeCalled() { + // Проверяем, что метод можно вызвать без ошибок + assertDoesNotThrow(() -> { + kursivParserService.scheduledParseAndStoreNews(); + }, "Метод scheduledParseAndStoreNews должен выполняться без ошибок"); + } + + @Test + public void testSchedulerConfiguration() { + // Проверяем, что сервис существует и настроен + assertNotNull(kursivParserService, "KursivParserService должен быть настроен"); + + // Проверяем, что основной метод парсинга работает + assertDoesNotThrow(() -> { + kursivParserService.parseAndSaveRssFeed(); + }, "Основной метод парсинга должен работать"); + } +} diff --git a/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java b/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java new file mode 100644 index 0000000..c45ac06 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java @@ -0,0 +1,88 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.data.mongodb.host=localhost", + "spring.data.mongodb.port=27017", + "spring.data.mongodb.database=parser_test_db", + "rss.feed.url=https://kursiv.media/feed/" +}) +public class KursivParserServiceTest { + + @Autowired + private KursivParserService kursivParserService; + + @Autowired + private MarketItemRepository marketItemRepository; + + @Test + public void testParseAndSaveRssFeed() { + // Очищаем базу данных перед тестом + marketItemRepository.deleteAll(); + + // Запускаем парсинг + List<MarketItem> savedItems = kursivParserService.parseAndSaveRssFeed(); + + // Проверяем результаты + assertNotNull(savedItems, "Список сохраненных элементов не должен быть null"); + + // Проверяем, что хотя бы одна запись была обработана + assertTrue(savedItems.size() > 0, "Должна быть обработана хотя бы одна запись"); + + // Проверяем структуру первой записи + MarketItem firstItem = savedItems.get(0); + assertNotNull(firstItem.getTitle(), "Заголовок не должен быть null"); + assertNotNull(firstItem.getUrl(), "URL не должен быть null"); + assertNotNull(firstItem.getHash(), "Хэш не должен быть null"); + assertNotNull(firstItem.getPublishedAt(), "Дата публикации не должна быть null"); + assertNotNull(firstItem.getRawText(), "Текст не должен быть null"); + + // Проверяем, что запись сохранилась в базе данных + long totalCount = marketItemRepository.count(); + assertTrue(totalCount > 0, "В базе данных должны быть записи"); + + // Проверяем дедупликацию - повторный запуск не должен создавать дубликаты + List<MarketItem> secondRun = kursivParserService.parseAndSaveRssFeed(); + long secondCount = marketItemRepository.count(); + + // Количество записей должно остаться тем же или увеличиться незначительно + assertTrue(secondCount >= totalCount, "Количество записей не должно уменьшиться"); + + System.out.println("Тест прошел успешно:"); + System.out.println("- Обработано записей в первом запуске: " + savedItems.size()); + System.out.println("- Обработано записей во втором запуске: " + secondRun.size()); + System.out.println("- Общее количество записей в БД: " + secondCount); + } + + @Test + public void testGetTotalItemsCount() { + long count = kursivParserService.getTotalItemsCount(); + assertTrue(count >= 0, "Количество записей должно быть неотрицательным"); + } + + @Test + public void testGetAllItems() { + List<MarketItem> items = kursivParserService.getAllItems(); + assertNotNull(items, "Список элементов не должен быть null"); + + // Проверяем структуру элементов + for (MarketItem item : items) { + assertNotNull(item.getId(), "ID элемента не должен быть null"); + assertNotNull(item.getHash(), "Хэш элемента не должен быть null"); + assertNotNull(item.getSourceName(), "Название источника не должно быть null"); + assertEquals("Kursiv (Бизнес/экономика)", item.getSourceName(), + "Название источника должно соответствовать ожидаемому"); + } + } +} diff --git a/ДОП_ТЗ.md b/ДОП_ТЗ.md new file mode 100644 index 0000000..5ae1217 --- /dev/null +++ b/ДОП_ТЗ.md @@ -0,0 +1,64 @@ +### Дополнение к заданию для AI-агента + +**Задача:** Добавить автоматический запуск парсера каждые 30 минут. + +[cite\_start]**Контекст:** Согласно техническому заданию, сбор данных должен происходить автоматически и регулярно, чтобы обеспечивать актуальность информации в системе[cite: 47, 93]. + +--- + +### Реализация в Spring Boot + +Для реализации этой задачи необходимо использовать встроенный в Spring механизм планировщика задач (Task Scheduler). + +1. **Включить планировщик:** В главном классе приложения (с аннотацией `@SpringBootApplication`) необходимо добавить аннотацию `@EnableScheduling`. + +2. **Запланировать выполнение метода:** В сервисе `KursivParserService`, над методом, который запускает процесс парсинга, нужно добавить аннотацию `@Scheduled`. Для запуска каждые 30 минут можно использовать `cron` выражение. + +--- + +### Пример кода: + +**1. Главный класс приложения:** + +```java +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling // <-- ДОБАВИТЬ ЭТУ АННОТАЦИЮ +public class MarketingAnalyticsApplication { + + public static void main(String[] args) { + SpringApplication.run(MarketingAnalyticsApplication.class, args); + } +} +``` + +**2. Сервис-парсер:** + +```java +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class KursivParserService { + + // ... здесь существующий код и зависимости (MongoTemplate и т.д.) + + @Scheduled(cron = "0 0/30 * * * ?") // <-- ДОБАВИТЬ ЭТУ АННОТАЦИЮ + public void parseAndStoreNews() { + System.out.println("Запуск планового парсинга новостей с Kursiv.media..."); + // ... здесь вся логика парсинга, которая уже написана + } +} +``` + +**Объяснение `cron = "0 0/30 * * * ?"`:** Эта настройка означает, что метод `parseAndStoreNews()` будет запускаться **в 0 и 30 минут каждого часа, каждый день**. Это в точности соответствует требованию ТЗ. + +--- + +### Критерии выполнения: + +- После запуска приложения, парсер автоматически начинает свою работу в ближайшие 0 или 30 минут часа. +- В логах приложения видно, что метод парсинга вызывается каждые полчаса без какого-либо ручного вмешательства.