From 8d8303991eb4f51b442b8e8a3f189d09e5986163 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 14 Sep 2025 18:01:22 +0500 Subject: [PATCH] . --- .../kz/konturai/parser/ParserApplication.java | 2 + .../controller/HealthCheckController.java | 153 ++++++++++ .../controller/MarketItemController.java | 127 ++++++++ .../controller/ParserAdminController.java | 123 ++++++++ .../parser/controller/ParserController.java | 237 --------------- .../repository/MarketItemRepository.java | 5 + .../parser/service/KapitalParserService.java | 7 +- .../parser/service/KursivParserService.java | 7 +- .../parser/service/LsmParserService.java | 277 ++++++++++++++++++ .../parser/service/ParserManagerService.java | 122 ++++++++ .../parser/service/ParserService.java | 26 ++ .../parser/service/RbcParserService.java | 277 ++++++++++++++++++ .../service/VedomostiParserService.java | 277 ++++++++++++++++++ src/main/resources/application.properties | 8 +- .../controller/HealthCheckControllerTest.java | 136 +++++++++ .../controller/MarketItemControllerTest.java | 91 ++++++ .../controller/ParserAdminControllerTest.java | 131 +++++++++ .../parser/service/LsmParserServiceTest.java | 93 ++++++ .../parser/service/RbcParserServiceTest.java | 94 ++++++ .../service/VedomostiParserServiceTest.java | 94 ++++++ ...в парсеры для трёх новых RSS-источников.md | 51 ++++ ...AI-агента: Рефакторинг ParserController.md | 202 +++++++++++++ 22 files changed, 2300 insertions(+), 240 deletions(-) create mode 100644 src/main/java/kz/konturai/parser/controller/HealthCheckController.java create mode 100644 src/main/java/kz/konturai/parser/controller/MarketItemController.java create mode 100644 src/main/java/kz/konturai/parser/controller/ParserAdminController.java delete mode 100644 src/main/java/kz/konturai/parser/controller/ParserController.java create mode 100644 src/main/java/kz/konturai/parser/service/LsmParserService.java create mode 100644 src/main/java/kz/konturai/parser/service/ParserManagerService.java create mode 100644 src/main/java/kz/konturai/parser/service/ParserService.java create mode 100644 src/main/java/kz/konturai/parser/service/RbcParserService.java create mode 100644 src/main/java/kz/konturai/parser/service/VedomostiParserService.java create mode 100644 src/test/java/kz/konturai/parser/controller/HealthCheckControllerTest.java create mode 100644 src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java create mode 100644 src/test/java/kz/konturai/parser/controller/ParserAdminControllerTest.java create mode 100644 src/test/java/kz/konturai/parser/service/LsmParserServiceTest.java create mode 100644 src/test/java/kz/konturai/parser/service/RbcParserServiceTest.java create mode 100644 src/test/java/kz/konturai/parser/service/VedomostiParserServiceTest.java create mode 100644 Расширить систему сбора данных, добавив парсеры для трёх новых RSS-источников.md create mode 100644 Техническое задание для AI-агента: Рефакторинг ParserController.md diff --git a/src/main/java/kz/konturai/parser/ParserApplication.java b/src/main/java/kz/konturai/parser/ParserApplication.java index 702cd2d..cc74f01 100644 --- a/src/main/java/kz/konturai/parser/ParserApplication.java +++ b/src/main/java/kz/konturai/parser/ParserApplication.java @@ -2,10 +2,12 @@ package kz.konturai.parser; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableAsync public class ParserApplication { public static void main(String[] args) { diff --git a/src/main/java/kz/konturai/parser/controller/HealthCheckController.java b/src/main/java/kz/konturai/parser/controller/HealthCheckController.java new file mode 100644 index 0000000..2831da5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/HealthCheckController.java @@ -0,0 +1,153 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.dto.HealthCheckDto; +import kz.konturai.parser.service.MarketItemService; +import kz.konturai.parser.service.ParserManagerService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Контроллер для мониторинга и проверки состояния системы. + * Содержит все health check и информационные эндпоинты. + */ +@RestController +@RequestMapping("/api/health") +public class HealthCheckController { + + @Autowired + private MarketItemService marketItemService; + + @Autowired + private ParserManagerService parserManagerService; + + /** + * Проверка состояния парсера + */ + @GetMapping + public ResponseEntity> healthCheck() { + HealthCheckDto health = new HealthCheckDto( + "UP", + "RSS Parser System", + System.currentTimeMillis(), + "Enabled - runs every 30 minutes"); + + return ResponseEntity.ok(ApiResponse.success("Сервис работает", health)); + } + + /** + * Проверка подключения к MongoDB + */ + @GetMapping("/mongodb") + public ResponseEntity> checkMongoConnection() { + try { + long count = marketItemService.getTotalItemsCount(); + HealthCheckDto health = new HealthCheckDto( + "UP", + "MongoDB подключение успешно", + System.currentTimeMillis(), + "Всего записей: " + count); + + return ResponseEntity.ok(ApiResponse.success("MongoDB подключение успешно", health)); + + } catch (Exception e) { + HealthCheckDto health = new HealthCheckDto( + "DOWN", + "Ошибка подключения к MongoDB: " + e.getMessage(), + System.currentTimeMillis(), + null); + + if (e.getMessage().contains("Unauthorized") || e.getMessage().contains("authentication")) { + health.setSuggestion("Проверьте учетные данные в application.properties"); + } + + return ResponseEntity.status(503).body(ApiResponse.error("Ошибка подключения к MongoDB", health)); + } + } + + /** + * Получение информации о планировщике + */ + @GetMapping("/scheduler") + public ResponseEntity>> getSchedulerInfo() { + try { + java.util.Map schedulerInfo = new java.util.HashMap<>(); + + schedulerInfo.put("schedulerEnabled", true); + schedulerInfo.put("cronExpression", "0 0/30 * * * ?"); + schedulerInfo.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)"); + schedulerInfo.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа"); + schedulerInfo.put("activeParsers", parserManagerService.getAvailableParsers()); + schedulerInfo.put("totalParsers", parserManagerService.getParserCount()); + schedulerInfo.put("sources", List.of("Kursiv Media", "Kapital.kz", "LSM.kz", "РБК", "Ведомости")); + + return ResponseEntity.ok(ApiResponse.success("Информация о планировщике получена", schedulerInfo)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении информации о планировщике: " + e.getMessage())); + } + } + + /** + * Получение общей информации о системе + */ + @GetMapping("/system") + public ResponseEntity>> getSystemInfo() { + try { + java.util.Map systemInfo = new java.util.HashMap<>(); + + // Информация о парсерах + systemInfo.put("parsers", java.util.Map.of( + "total", parserManagerService.getParserCount(), + "available", parserManagerService.getAvailableParsers())); + + // Информация о данных + long totalItems = marketItemService.getTotalItemsCount(); + var sourceStatistics = marketItemService.getSourceStatistics(); + systemInfo.put("data", java.util.Map.of( + "totalItems", totalItems, + "sourceStatistics", sourceStatistics)); + + // Информация о системе + systemInfo.put("system", java.util.Map.of( + "javaVersion", System.getProperty("java.version"), + "osName", System.getProperty("os.name"), + "osVersion", System.getProperty("os.version"), + "uptime", System.currentTimeMillis())); + + return ResponseEntity.ok(ApiResponse.success("Информация о системе получена", systemInfo)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении информации о системе: " + e.getMessage())); + } + } + + /** + * Проверка состояния всех парсеров + */ + @GetMapping("/parsers") + public ResponseEntity>> checkParsersHealth() { + try { + java.util.Map parsersHealth = new java.util.HashMap<>(); + + List availableParsers = parserManagerService.getAvailableParsers(); + parsersHealth.put("totalParsers", availableParsers.size()); + parsersHealth.put("availableParsers", availableParsers); + parsersHealth.put("status", "UP"); + parsersHealth.put("message", "Все парсеры доступны"); + + return ResponseEntity.ok(ApiResponse.success("Проверка парсеров завершена", parsersHealth)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при проверке парсеров: " + e.getMessage())); + } + } +} diff --git a/src/main/java/kz/konturai/parser/controller/MarketItemController.java b/src/main/java/kz/konturai/parser/controller/MarketItemController.java new file mode 100644 index 0000000..a0fdbb8 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/MarketItemController.java @@ -0,0 +1,127 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.dto.ParserStatsDto; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.MarketItemService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +/** + * Контроллер для публичных запросов на получение данных. + * Содержит эндпоинты, которые нужны фронтенду для отображения данных. + */ +@RestController +@RequestMapping("/api/items") +public class MarketItemController { + + @Autowired + private MarketItemService marketItemService; + + /** + * Получение записей с фильтрацией и пагинацией + */ + @GetMapping + public ResponseEntity>> getItems( + @RequestParam(required = false) String sourceName, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) String startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) String endDate, + @PageableDefault(size = 20, sort = "publishedAt", direction = org.springframework.data.domain.Sort.Direction.DESC) Pageable pageable) { + + try { + LocalDateTime start = marketItemService.parseDate(startDate); + LocalDateTime end = marketItemService.parseDate(endDate); + + Page items; + + if (sourceName != null || start != null || end != null) { + // Используем фильтрацию + items = marketItemService.getItemsWithFilters(sourceName, start, end, pageable); + } else { + // Получаем все записи + items = marketItemService.getAllItems(pageable); + } + + return ResponseEntity.ok(ApiResponse.success("Записи получены успешно", items)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении записей: " + e.getMessage())); + } + } + + /** + * Получение записи по ID + */ + @GetMapping("/{id}") + public ResponseEntity> getItemById(@PathVariable String id) { + try { + // Здесь можно добавить метод для поиска по ID в сервисе + return ResponseEntity.ok(ApiResponse.error("Функция поиска по ID пока не реализована")); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении записи: " + e.getMessage())); + } + } + + /** + * Получение статистики по сохраненным записям + */ + @GetMapping("/stats") + public ResponseEntity> getStats() { + try { + long totalCount = marketItemService.getTotalItemsCount(); + var sourceStatistics = marketItemService.getSourceStatistics(); + String lastUpdate = java.time.LocalDateTime.now().toString(); + + ParserStatsDto stats = new ParserStatsDto(totalCount, sourceStatistics, lastUpdate); + + return ResponseEntity.ok(ApiResponse.success("Статистика получена успешно", stats)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении статистики: " + e.getMessage())); + } + } + + /** + * Получение всех уникальных источников + */ + @GetMapping("/sources") + public ResponseEntity>> getSources() { + try { + java.util.List sources = marketItemService.getAllSourceNames(); + return ResponseEntity.ok(ApiResponse.success("Источники получены успешно", sources)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении источников: " + e.getMessage())); + } + } + + /** + * Получение записей по конкретному источнику + */ + @GetMapping("/source/{sourceName}") + public ResponseEntity>> getItemsBySource( + @PathVariable String sourceName, + @PageableDefault(size = 20, sort = "publishedAt", direction = org.springframework.data.domain.Sort.Direction.DESC) Pageable pageable) { + try { + Page items = marketItemService.getItemsBySource(sourceName, pageable); + return ResponseEntity + .ok(ApiResponse.success("Записи по источнику " + sourceName + " получены успешно", items)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении записей по источнику: " + e.getMessage())); + } + } +} diff --git a/src/main/java/kz/konturai/parser/controller/ParserAdminController.java b/src/main/java/kz/konturai/parser/controller/ParserAdminController.java new file mode 100644 index 0000000..2672792 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/ParserAdminController.java @@ -0,0 +1,123 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.dto.ParserResultDto; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.MarketItemService; +import kz.konturai.parser.service.ParserManagerService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Контроллер для административных действий с парсерами. + * Содержит эндпоинты для управления парсерами. + */ +@RestController +@RequestMapping("/api/admin/parsers") +public class ParserAdminController { + + @Autowired + private ParserManagerService parserManagerService; + + @Autowired + private MarketItemService marketItemService; + + /** + * Запуск парсера по имени источника + */ + @PostMapping("/parse/{sourceName}") + public ResponseEntity> parseSource(@PathVariable String sourceName) { + try { + if (!parserManagerService.hasParser(sourceName)) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("Парсер не найден: " + sourceName)); + } + + List savedItems = parserManagerService.runParser(sourceName); + long totalItems = marketItemService.getTotalItemsCount(); + + ParserResultDto result = new ParserResultDto( + sourceName, + savedItems.size(), + totalItems, + "completed"); + + return ResponseEntity.ok(ApiResponse.success("Парсинг " + sourceName + " завершен", result)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при парсинге " + sourceName + ": " + e.getMessage())); + } + } + + /** + * Запуск всех парсеров асинхронно + */ + @PostMapping("/parse/all") + public ResponseEntity>> parseAll() { + try { + CompletableFuture> future = parserManagerService.runAllParsers(); + List results = future.get(); // .get() для ожидания результата + + return ResponseEntity.ok(ApiResponse.success("Парсинг всех источников завершен", results)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при парсинге всех источников: " + e.getMessage())); + } + } + + /** + * Получение списка доступных парсеров + */ + @GetMapping + public ResponseEntity>> getAvailableParsers() { + try { + List parsers = parserManagerService.getAvailableParsers(); + return ResponseEntity.ok(ApiResponse.success("Список парсеров получен", parsers)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении списка парсеров: " + e.getMessage())); + } + } + + /** + * Получение информации о парсерах + */ + @GetMapping("/info") + public ResponseEntity>> getParsersInfo() { + try { + java.util.Map info = new java.util.HashMap<>(); + info.put("totalParsers", parserManagerService.getParserCount()); + info.put("availableParsers", parserManagerService.getAvailableParsers()); + info.put("totalItems", marketItemService.getTotalItemsCount()); + info.put("sourceStatistics", marketItemService.getSourceStatistics()); + + return ResponseEntity.ok(ApiResponse.success("Информация о парсерах получена", info)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при получении информации о парсерах: " + e.getMessage())); + } + } + + /** + * Проверка существования парсера + */ + @GetMapping("/exists/{sourceName}") + public ResponseEntity> checkParserExists(@PathVariable String sourceName) { + try { + boolean exists = parserManagerService.hasParser(sourceName); + return ResponseEntity.ok(ApiResponse.success("Проверка завершена", exists)); + + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(ApiResponse.error("Ошибка при проверке парсера: " + e.getMessage())); + } + } +} diff --git a/src/main/java/kz/konturai/parser/controller/ParserController.java b/src/main/java/kz/konturai/parser/controller/ParserController.java deleted file mode 100644 index 9a7fbcb..0000000 --- a/src/main/java/kz/konturai/parser/controller/ParserController.java +++ /dev/null @@ -1,237 +0,0 @@ -package kz.konturai.parser.controller; - -import kz.konturai.parser.dto.*; -import kz.konturai.parser.model.MarketItem; -import kz.konturai.parser.service.KursivParserService; -import kz.konturai.parser.service.KapitalParserService; -import kz.konturai.parser.service.MarketItemService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; -import java.util.List; - -@RestController -@RequestMapping("/api/parser") -public class ParserController { - - @Autowired - private KursivParserService kursivParserService; - - @Autowired - private KapitalParserService kapitalParserService; - - @Autowired - private MarketItemService marketItemService; - - /** - * Запуск парсинга RSS-ленты Kursiv - */ - @PostMapping("/parse/kursiv") - public ResponseEntity> parseKursivRssFeed() { - try { - List savedItems = kursivParserService.parseAndSaveRssFeed(); - long totalItems = marketItemService.getTotalItemsCount(); - - ParserResultDto result = new ParserResultDto( - "Kursiv", - savedItems.size(), - totalItems, - "completed"); - - return ResponseEntity.ok(ApiResponse.success("Парсинг Kursiv завершен успешно", result)); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при парсинге Kursiv: " + e.getMessage())); - } - } - - /** - * Запуск парсинга RSS-ленты Kapital - */ - @PostMapping("/parse/kapital") - public ResponseEntity> parseKapitalRssFeed() { - try { - List savedItems = kapitalParserService.parseAndSaveRssFeed(); - long totalItems = marketItemService.getTotalItemsCount(); - - ParserResultDto result = new ParserResultDto( - "Kapital", - savedItems.size(), - totalItems, - "completed"); - - return ResponseEntity.ok(ApiResponse.success("Парсинг Kapital завершен успешно", result)); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при парсинге Kapital: " + e.getMessage())); - } - } - - /** - * Запуск парсинга всех RSS-лент - */ - @PostMapping("/parse/all") - public ResponseEntity>> parseAllRssFeeds() { - try { - List kursivItems = kursivParserService.parseAndSaveRssFeed(); - List kapitalItems = kapitalParserService.parseAndSaveRssFeed(); - long totalItems = marketItemService.getTotalItemsCount(); - - ParserResultDto kursivResult = new ParserResultDto( - "Kursiv", - kursivItems.size(), - totalItems, - "completed"); - - ParserResultDto kapitalResult = new ParserResultDto( - "Kapital", - kapitalItems.size(), - totalItems, - "completed"); - - List results = List.of(kursivResult, kapitalResult); - - return ResponseEntity.ok(ApiResponse.success("Парсинг всех источников завершен успешно", results)); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при парсинге всех источников: " + e.getMessage())); - } - } - - /** - * Получение записей с фильтрацией и пагинацией - */ - @GetMapping("/items") - public ResponseEntity>> getItems( - @RequestParam(required = false) String sourceName, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) String startDate, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) String endDate, - @PageableDefault(size = 20, sort = "publishedAt", direction = org.springframework.data.domain.Sort.Direction.DESC) Pageable pageable) { - - try { - LocalDateTime start = marketItemService.parseDate(startDate); - LocalDateTime end = marketItemService.parseDate(endDate); - - Page items; - - if (sourceName != null || start != null || end != null) { - // Используем фильтрацию - items = marketItemService.getItemsWithFilters(sourceName, start, end, pageable); - } else { - // Получаем все записи - items = marketItemService.getAllItems(pageable); - } - - return ResponseEntity.ok(ApiResponse.success("Записи получены успешно", items)); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при получении записей: " + e.getMessage())); - } - } - - /** - * Получение статистики по сохраненным записям - */ - @GetMapping("/stats") - public ResponseEntity> getStats() { - try { - long totalCount = marketItemService.getTotalItemsCount(); - var sourceStatistics = marketItemService.getSourceStatistics(); - String lastUpdate = java.time.LocalDateTime.now().toString(); - - ParserStatsDto stats = new ParserStatsDto(totalCount, sourceStatistics, lastUpdate); - - return ResponseEntity.ok(ApiResponse.success("Статистика получена успешно", stats)); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при получении статистики: " + e.getMessage())); - } - } - - /** - * Получение записи по ID - */ - @GetMapping("/items/{id}") - public ResponseEntity> getItemById(@PathVariable String id) { - try { - // Здесь можно добавить метод для поиска по ID в сервисе - return ResponseEntity.ok(ApiResponse.error("Функция поиска по ID пока не реализована")); - - } catch (Exception e) { - return ResponseEntity.internalServerError() - .body(ApiResponse.error("Ошибка при получении записи: " + e.getMessage())); - } - } - - /** - * Проверка состояния парсера - */ - @GetMapping("/health") - public ResponseEntity> healthCheck() { - HealthCheckDto health = new HealthCheckDto( - "UP", - "RSS Parser System", - System.currentTimeMillis(), - "Enabled - runs every 30 minutes"); - - return ResponseEntity.ok(ApiResponse.success("Сервис работает", health)); - } - - /** - * Проверка подключения к MongoDB - */ - @GetMapping("/health/mongodb") - public ResponseEntity> checkMongoConnection() { - try { - long count = marketItemService.getTotalItemsCount(); - HealthCheckDto health = new HealthCheckDto( - "UP", - "MongoDB подключение успешно", - System.currentTimeMillis(), - null); - - return ResponseEntity.ok(ApiResponse.success("MongoDB подключение успешно", health)); - - } catch (Exception e) { - HealthCheckDto health = new HealthCheckDto( - "DOWN", - "Ошибка подключения к MongoDB: " + e.getMessage(), - System.currentTimeMillis(), - null); - - if (e.getMessage().contains("Unauthorized") || e.getMessage().contains("authentication")) { - health.setSuggestion("Проверьте учетные данные в application.properties"); - } - - return ResponseEntity.status(503).body(ApiResponse.error("Ошибка подключения к MongoDB", health)); - } - } - - /** - * Получение информации о планировщике - */ - @GetMapping("/scheduler/info") - public ResponseEntity>> getSchedulerInfo() { - java.util.Map schedulerInfo = new java.util.HashMap<>(); - - schedulerInfo.put("schedulerEnabled", true); - schedulerInfo.put("cronExpression", "0 0/30 * * * ?"); - schedulerInfo.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)"); - schedulerInfo.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа"); - schedulerInfo.put("activeParsers", List.of("KursivParserService", "KapitalParserService")); - schedulerInfo.put("sources", List.of("Kursiv Media", "Kapital.kz")); - - return ResponseEntity.ok(ApiResponse.success("Информация о планировщике получена", schedulerInfo)); - } -} diff --git a/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java b/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java index da8ec55..0ac9728 100644 --- a/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java +++ b/src/main/java/kz/konturai/parser/repository/MarketItemRepository.java @@ -29,6 +29,11 @@ public interface MarketItemRepository extends MongoRepository findBySourceName(String sourceName, Pageable pageable); + /** + * Найти все записи по источнику без пагинации + */ + List findBySourceName(String sourceName); + /** * Найти записи по диапазону дат с пагинацией */ diff --git a/src/main/java/kz/konturai/parser/service/KapitalParserService.java b/src/main/java/kz/konturai/parser/service/KapitalParserService.java index b39fada..e8fb4b0 100644 --- a/src/main/java/kz/konturai/parser/service/KapitalParserService.java +++ b/src/main/java/kz/konturai/parser/service/KapitalParserService.java @@ -25,7 +25,7 @@ import java.util.ArrayList; import java.util.List; @Service -public class KapitalParserService { +public class KapitalParserService implements ParserService { private static final Logger logger = LoggerFactory.getLogger(KapitalParserService.class); @@ -35,6 +35,11 @@ public class KapitalParserService { @Value("${rss.kapital.url}") private String rssFeedUrl; + @Override + public String getSourceName() { + return "kapital"; + } + /** * Автоматический запуск парсинга каждые 30 минут * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа diff --git a/src/main/java/kz/konturai/parser/service/KursivParserService.java b/src/main/java/kz/konturai/parser/service/KursivParserService.java index dca5ce0..82cc3e2 100644 --- a/src/main/java/kz/konturai/parser/service/KursivParserService.java +++ b/src/main/java/kz/konturai/parser/service/KursivParserService.java @@ -25,7 +25,7 @@ import java.util.ArrayList; import java.util.List; @Service -public class KursivParserService { +public class KursivParserService implements ParserService { private static final Logger logger = LoggerFactory.getLogger(KursivParserService.class); @@ -35,6 +35,11 @@ public class KursivParserService { @Value("${rss.feed.url}") private String rssFeedUrl; + @Override + public String getSourceName() { + return "kursiv"; + } + /** * Автоматический запуск парсинга каждые 30 минут * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа diff --git a/src/main/java/kz/konturai/parser/service/LsmParserService.java b/src/main/java/kz/konturai/parser/service/LsmParserService.java new file mode 100644 index 0000000..3d9b9fa --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/LsmParserService.java @@ -0,0 +1,277 @@ +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 LsmParserService implements ParserService { + + private static final Logger logger = LoggerFactory.getLogger(LsmParserService.class); + + @Autowired + private MarketItemRepository marketItemRepository; + + @Value("${rss.lsm.url}") + private String rssFeedUrl; + + @Override + public String getSourceName() { + return "lsm"; + } + + /** + * Автоматический запуск парсинга каждые 30 минут + * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа + */ + @Scheduled(cron = "0 0/30 * * * ?") + public void scheduledParseAndStoreNews() { + logger.info("🔄 Запуск планового парсинга новостей с LSM.kz..."); + try { + List savedItems = parseAndSaveRssFeed(); + logger.info("✅ Плановый парсинг LSM.kz завершен. Обработано записей: {}", savedItems.size()); + } catch (Exception e) { + logger.error("❌ Ошибка при плановом парсинге LSM.kz", e); + } + } + + /** + * Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB + */ + public List parseAndSaveRssFeed() { + List savedItems = new ArrayList<>(); + + try { + logger.info("Начинаем парсинг RSS-ленты LSM.kz: {}", rssFeedUrl); + + // Получаем RSS-ленту + SyndFeed feed = getRssFeed(); + + if (feed == null) { + logger.error("Не удалось получить RSS-ленту LSM.kz"); + return savedItems; + } + + logger.info("Получено {} записей из RSS-ленты LSM.kz", 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("Ошибка при обработке записи LSM.kz: {}", entry.getTitle(), e); + } + } + + logger.info("Успешно обработано {} записей из LSM.kz", savedItems.size()); + + } catch (Exception e) { + logger.error("Ошибка при парсинге RSS-ленты LSM.kz", 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("Пропускаем запись LSM.kz с отсутствующими обязательными полями: title={}, url={}", + title, url); + return null; + } + + // Очищаем текст от HTML + String cleanText = cleanHtmlText(description); + + // Преобразуем дату + LocalDateTime publishedAt = convertToLocalDateTime(entry.getPublishedDate()); + + // Генерируем хэш + String hash = generateHash(url, title); + + // Создаем объект MarketItem с настройками для LSM.kz + MarketItem marketItem = new MarketItem(url, title, publishedAt, cleanText, hash); + marketItem.setSourceName("LSM.kz"); + marketItem.setCategory("Финансы"); + + logger.debug("Обработана запись LSM.kz: {}", title); + + return marketItem; + + } catch (Exception e) { + logger.error("Ошибка при обработке записи RSS LSM.kz", 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 LSM.kz: {}", 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("Ошибка при генерации хэша LSM.kz", 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.setSourceName(marketItem.getSourceName()); + existingItem.setCategory(marketItem.getCategory()); + existingItem.setAddedAt(LocalDateTime.now()); // Обновляем время добавления + + MarketItem savedItem = marketItemRepository.save(existingItem); + logger.debug("Обновлена существующая запись LSM.kz: {}", marketItem.getTitle()); + return savedItem; + } else { + // Создаем новую запись + MarketItem savedItem = marketItemRepository.save(marketItem); + logger.debug("Создана новая запись LSM.kz: {}", marketItem.getTitle()); + return savedItem; + } + + } catch (org.springframework.data.mongodb.UncategorizedMongoDbException e) { + if (e.getMessage().contains("Unauthorized") || e.getMessage().contains("authentication")) { + logger.error("❌ Ошибка аутентификации MongoDB. Проверьте учетные данные в application.properties"); + } else { + logger.error("❌ Ошибка подключения к MongoDB: {}", e.getMessage()); + } + return null; + } catch (Exception e) { + logger.error("Ошибка при сохранении записи LSM.kz: {}", marketItem.getTitle(), e); + return null; + } + } + + /** + * Получение статистики по сохраненным записям LSM.kz + */ + public long getLsmItemsCount() { + return marketItemRepository.countBySourceName("LSM.kz"); + } + + /** + * Получение всех сохраненных записей LSM.kz + */ + public List getAllLsmItems() { + return marketItemRepository.findBySourceName("LSM.kz"); + } +} diff --git a/src/main/java/kz/konturai/parser/service/ParserManagerService.java b/src/main/java/kz/konturai/parser/service/ParserManagerService.java new file mode 100644 index 0000000..6de1955 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ParserManagerService.java @@ -0,0 +1,122 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.ParserResultDto; +import kz.konturai.parser.model.MarketItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * Сервис для управления всеми парсерами. + * Использует паттерн "Фасад" для упрощения работы с парсерами. + */ +@Service +public class ParserManagerService { + + private static final Logger logger = LoggerFactory.getLogger(ParserManagerService.class); + + private final Map parsers; + private final MarketItemService marketItemService; + + // Spring автоматически инжектирует все бины типа ParserService + public ParserManagerService(List parserServices, MarketItemService marketItemService) { + this.parsers = parserServices.stream() + .collect(Collectors.toMap(ParserService::getSourceName, service -> service)); + this.marketItemService = marketItemService; + + logger.info("Инициализирован ParserManagerService с {} парсерами: {}", + parsers.size(), parsers.keySet()); + } + + /** + * Запускает парсер по его имени. + * + * @param sourceName Имя источника (например, "kursiv") + * @return Результат парсинга + */ + public List runParser(String sourceName) { + ParserService parser = Optional.ofNullable(parsers.get(sourceName)) + .orElseThrow(() -> new IllegalArgumentException("Парсер не найден: " + sourceName)); + + logger.info("Запуск парсера: {}", sourceName); + return parser.parseAndSaveRssFeed(); + } + + /** + * Асинхронно запускает все парсеры. + * + * @return Список результатов для каждого парсера + */ + @Async + public CompletableFuture> runAllParsers() { + logger.info("Запуск всех парсеров асинхронно"); + + long totalItemsBefore = marketItemService.getTotalItemsCount(); + + List results = parsers.values().parallelStream() + .map(parser -> { + try { + logger.debug("Запуск парсера: {}", parser.getSourceName()); + List savedItems = parser.parseAndSaveRssFeed(); + long totalItemsAfter = marketItemService.getTotalItemsCount(); + + ParserResultDto result = new ParserResultDto( + parser.getSourceName(), + savedItems.size(), + totalItemsAfter, + "completed"); + + logger.debug("Парсер {} завершен. Обработано записей: {}", + parser.getSourceName(), savedItems.size()); + + return result; + } catch (Exception e) { + logger.error("Ошибка при запуске парсера {}: {}", parser.getSourceName(), e.getMessage(), e); + return new ParserResultDto( + parser.getSourceName(), + 0, + totalItemsBefore, + "error: " + e.getMessage()); + } + }) + .collect(Collectors.toList()); + + logger.info("Все парсеры завершены. Результатов: {}", results.size()); + return CompletableFuture.completedFuture(results); + } + + /** + * Получает список доступных парсеров. + * + * @return Список имен парсеров + */ + public List getAvailableParsers() { + return parsers.keySet().stream().sorted().collect(Collectors.toList()); + } + + /** + * Проверяет, существует ли парсер с указанным именем. + * + * @param sourceName Имя источника + * @return true, если парсер существует + */ + public boolean hasParser(String sourceName) { + return parsers.containsKey(sourceName); + } + + /** + * Получает количество зарегистрированных парсеров. + * + * @return Количество парсеров + */ + public int getParserCount() { + return parsers.size(); + } +} diff --git a/src/main/java/kz/konturai/parser/service/ParserService.java b/src/main/java/kz/konturai/parser/service/ParserService.java new file mode 100644 index 0000000..76ebe14 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ParserService.java @@ -0,0 +1,26 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; + +import java.util.List; + +/** + * Общий интерфейс для всех парсер-сервисов. + * Позволяет работать с парсерами единообразно. + */ +public interface ParserService { + + /** + * Возвращает уникальное имя источника (например, "kursiv", "kapital"). + * + * @return String source name + */ + String getSourceName(); + + /** + * Запускает парсинг и сохранение данных для своего источника. + * + * @return List of newly saved MarketItem + */ + List parseAndSaveRssFeed(); +} diff --git a/src/main/java/kz/konturai/parser/service/RbcParserService.java b/src/main/java/kz/konturai/parser/service/RbcParserService.java new file mode 100644 index 0000000..c878a8f --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/RbcParserService.java @@ -0,0 +1,277 @@ +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 RbcParserService implements ParserService { + + private static final Logger logger = LoggerFactory.getLogger(RbcParserService.class); + + @Autowired + private MarketItemRepository marketItemRepository; + + @Value("${rss.rbc.url}") + private String rssFeedUrl; + + @Override + public String getSourceName() { + return "rbc"; + } + + /** + * Автоматический запуск парсинга каждые 30 минут + * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа + */ + @Scheduled(cron = "0 0/30 * * * ?") + public void scheduledParseAndStoreNews() { + logger.info("🔄 Запуск планового парсинга новостей с РБК..."); + try { + List savedItems = parseAndSaveRssFeed(); + logger.info("✅ Плановый парсинг РБК завершен. Обработано записей: {}", savedItems.size()); + } catch (Exception e) { + logger.error("❌ Ошибка при плановом парсинге РБК", e); + } + } + + /** + * Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB + */ + public List parseAndSaveRssFeed() { + List 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); + marketItem.setSourceName("РБК"); + marketItem.setCategory("Бизнес"); + + 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.setSourceName(marketItem.getSourceName()); + existingItem.setCategory(marketItem.getCategory()); + 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 (org.springframework.data.mongodb.UncategorizedMongoDbException e) { + if (e.getMessage().contains("Unauthorized") || e.getMessage().contains("authentication")) { + logger.error("❌ Ошибка аутентификации MongoDB. Проверьте учетные данные в application.properties"); + } else { + logger.error("❌ Ошибка подключения к MongoDB: {}", e.getMessage()); + } + return null; + } catch (Exception e) { + logger.error("Ошибка при сохранении записи РБК: {}", marketItem.getTitle(), e); + return null; + } + } + + /** + * Получение статистики по сохраненным записям РБК + */ + public long getRbcItemsCount() { + return marketItemRepository.countBySourceName("РБК"); + } + + /** + * Получение всех сохраненных записей РБК + */ + public List getAllRbcItems() { + return marketItemRepository.findBySourceName("РБК"); + } +} diff --git a/src/main/java/kz/konturai/parser/service/VedomostiParserService.java b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java new file mode 100644 index 0000000..f5203af --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java @@ -0,0 +1,277 @@ +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 VedomostiParserService implements ParserService { + + private static final Logger logger = LoggerFactory.getLogger(VedomostiParserService.class); + + @Autowired + private MarketItemRepository marketItemRepository; + + @Value("${rss.vedomosti.url}") + private String rssFeedUrl; + + @Override + public String getSourceName() { + return "vedomosti"; + } + + /** + * Автоматический запуск парсинга каждые 30 минут + * Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа + */ + @Scheduled(cron = "0 0/30 * * * ?") + public void scheduledParseAndStoreNews() { + logger.info("🔄 Запуск планового парсинга новостей с Ведомости..."); + try { + List savedItems = parseAndSaveRssFeed(); + logger.info("✅ Плановый парсинг Ведомости завершен. Обработано записей: {}", savedItems.size()); + } catch (Exception e) { + logger.error("❌ Ошибка при плановом парсинге Ведомости", e); + } + } + + /** + * Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB + */ + public List parseAndSaveRssFeed() { + List 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); + marketItem.setSourceName("Ведомости"); + marketItem.setCategory("Технологии"); + + 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.setSourceName(marketItem.getSourceName()); + existingItem.setCategory(marketItem.getCategory()); + 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 (org.springframework.data.mongodb.UncategorizedMongoDbException e) { + if (e.getMessage().contains("Unauthorized") || e.getMessage().contains("authentication")) { + logger.error("❌ Ошибка аутентификации MongoDB. Проверьте учетные данные в application.properties"); + } else { + logger.error("❌ Ошибка подключения к MongoDB: {}", e.getMessage()); + } + return null; + } catch (Exception e) { + logger.error("Ошибка при сохранении записи Ведомости: {}", marketItem.getTitle(), e); + return null; + } + } + + /** + * Получение статистики по сохраненным записям Ведомости + */ + public long getVedomostiItemsCount() { + return marketItemRepository.countBySourceName("Ведомости"); + } + + /** + * Получение всех сохраненных записей Ведомости + */ + public List getAllVedomostiItems() { + return marketItemRepository.findBySourceName("Ведомости"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2bcad99..d530e65 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,9 +15,12 @@ spring.data.mongodb.authentication-database=admin # RSS Feed URLs rss.feed.url=https://kursiv.media/feed/ rss.kapital.url=https://kapital.kz/rss/ +rss.lsm.url=https://lsm.kz/rss +rss.rbc.url=https://static.feed.rbc.ru/rbc/logical/footer/news.rss +rss.vedomosti.url=https://www.vedomosti.ru/rss/rubric/technology/internet # Scheduler Configuration -spring.task.scheduling.pool.size=2 +spring.task.scheduling.pool.size=5 spring.task.scheduling.thread-name-prefix=scheduled-task- # MongoDB Connection Settings @@ -28,6 +31,9 @@ spring.data.mongodb.server-selection-timeout=30000 # Logging Configuration logging.level.kz.konturai.parser.service.KursivParserService=INFO logging.level.kz.konturai.parser.service.KapitalParserService=INFO +logging.level.kz.konturai.parser.service.LsmParserService=INFO +logging.level.kz.konturai.parser.service.RbcParserService=INFO +logging.level.kz.konturai.parser.service.VedomostiParserService=INFO logging.level.org.springframework.scheduling=DEBUG logging.level.org.springframework.data.mongodb=INFO logging.level.com.mongodb=WARN diff --git a/src/test/java/kz/konturai/parser/controller/HealthCheckControllerTest.java b/src/test/java/kz/konturai/parser/controller/HealthCheckControllerTest.java new file mode 100644 index 0000000..5e93176 --- /dev/null +++ b/src/test/java/kz/konturai/parser/controller/HealthCheckControllerTest.java @@ -0,0 +1,136 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.dto.HealthCheckDto; +import kz.konturai.parser.service.MarketItemService; +import kz.konturai.parser.service.ParserManagerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HealthCheckControllerTest { + + @Mock + private MarketItemService marketItemService; + + @Mock + private ParserManagerService parserManagerService; + + @InjectMocks + private HealthCheckController healthCheckController; + + @BeforeEach + void setUp() { + // Setup common test data + } + + @Test + void testHealthCheck_Success() { + // When + ResponseEntity> response = healthCheckController.healthCheck(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("UP", response.getBody().getData().getStatus()); + } + + @Test + void testCheckMongoConnection_Success() { + // Given + when(marketItemService.getTotalItemsCount()).thenReturn(100L); + + // When + ResponseEntity> response = healthCheckController.checkMongoConnection(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("UP", response.getBody().getData().getStatus()); + } + + @Test + void testCheckMongoConnection_Error() { + // Given + when(marketItemService.getTotalItemsCount()).thenThrow(new RuntimeException("Connection failed")); + + // When + ResponseEntity> response = healthCheckController.checkMongoConnection(); + + // Then + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertEquals("DOWN", response.getBody().getData().getStatus()); + } + + @Test + void testGetSchedulerInfo_Success() { + // Given + List mockParsers = List.of("kursiv", "kapital", "lsm"); + when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers); + when(parserManagerService.getParserCount()).thenReturn(3); + + // When + ResponseEntity>> response = healthCheckController.getSchedulerInfo(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertTrue(response.getBody().getData().containsKey("schedulerEnabled")); + assertTrue(response.getBody().getData().containsKey("activeParsers")); + } + + @Test + void testGetSystemInfo_Success() { + // Given + List mockParsers = List.of("kursiv", "kapital"); + when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers); + when(parserManagerService.getParserCount()).thenReturn(2); + when(marketItemService.getTotalItemsCount()).thenReturn(50L); + when(marketItemService.getSourceStatistics()).thenReturn(Map.of("test", 25L)); + + // When + ResponseEntity>> response = healthCheckController.getSystemInfo(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertTrue(response.getBody().getData().containsKey("parsers")); + assertTrue(response.getBody().getData().containsKey("data")); + assertTrue(response.getBody().getData().containsKey("system")); + } + + @Test + void testCheckParsersHealth_Success() { + // Given + List mockParsers = List.of("kursiv", "kapital", "lsm"); + when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers); + + // When + ResponseEntity>> response = healthCheckController.checkParsersHealth(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("UP", response.getBody().getData().get("status")); + assertEquals(3, response.getBody().getData().get("totalParsers")); + } +} diff --git a/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java b/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java new file mode 100644 index 0000000..3aec84e --- /dev/null +++ b/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java @@ -0,0 +1,91 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.MarketItemService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MarketItemControllerTest { + + @Mock + private MarketItemService marketItemService; + + @InjectMocks + private MarketItemController marketItemController; + + @BeforeEach + void setUp() { + // Setup common test data + } + + @Test + void testGetItems_Success() { + // Given + List mockItems = new ArrayList<>(); + MarketItem item = new MarketItem(); + item.setTitle("Test News"); + mockItems.add(item); + + Page mockPage = new PageImpl<>(mockItems); + when(marketItemService.getAllItems(any(Pageable.class))).thenReturn(mockPage); + + // When + ResponseEntity>> response = marketItemController.getItems(null, null, null, null); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(1, response.getBody().getData().getContent().size()); + } + + @Test + void testGetStats_Success() { + // Given + when(marketItemService.getTotalItemsCount()).thenReturn(100L); + when(marketItemService.getSourceStatistics()).thenReturn(java.util.Map.of("test", 50L)); + + // When + ResponseEntity> response = marketItemController.getStats(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(100L, response.getBody().getData().getTotalItems()); + } + + @Test + void testGetSources_Success() { + // Given + List mockSources = List.of("kursiv", "kapital", "lsm"); + when(marketItemService.getAllSourceNames()).thenReturn(mockSources); + + // When + ResponseEntity>> response = marketItemController.getSources(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(3, response.getBody().getData().size()); + } +} diff --git a/src/test/java/kz/konturai/parser/controller/ParserAdminControllerTest.java b/src/test/java/kz/konturai/parser/controller/ParserAdminControllerTest.java new file mode 100644 index 0000000..efdf80f --- /dev/null +++ b/src/test/java/kz/konturai/parser/controller/ParserAdminControllerTest.java @@ -0,0 +1,131 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.dto.ParserResultDto; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.MarketItemService; +import kz.konturai.parser.service.ParserManagerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ParserAdminControllerTest { + + @Mock + private ParserManagerService parserManagerService; + + @Mock + private MarketItemService marketItemService; + + @InjectMocks + private ParserAdminController parserAdminController; + + @BeforeEach + void setUp() { + // Setup common test data + } + + @Test + void testParseSource_Success() { + // Given + String sourceName = "kursiv"; + List mockItems = new ArrayList<>(); + MarketItem item = new MarketItem(); + item.setTitle("Test News"); + mockItems.add(item); + + when(parserManagerService.hasParser(sourceName)).thenReturn(true); + when(parserManagerService.runParser(sourceName)).thenReturn(mockItems); + when(marketItemService.getTotalItemsCount()).thenReturn(100L); + + // When + ResponseEntity> response = parserAdminController.parseSource(sourceName); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(sourceName, response.getBody().getData().getSource()); + assertEquals(1, response.getBody().getData().getProcessedItems()); + } + + @Test + void testParseSource_ParserNotFound() { + // Given + String sourceName = "nonexistent"; + when(parserManagerService.hasParser(sourceName)).thenReturn(false); + + // When + ResponseEntity> response = parserAdminController.parseSource(sourceName); + + // Then + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertTrue(response.getBody().getMessage().contains("Парсер не найден")); + } + + @Test + void testParseAll_Success() { + // Given + List mockResults = new ArrayList<>(); + ParserResultDto result = new ParserResultDto("kursiv", 5, 100, "completed"); + mockResults.add(result); + + when(parserManagerService.runAllParsers()).thenReturn(CompletableFuture.completedFuture(mockResults)); + + // When + ResponseEntity>> response = parserAdminController.parseAll(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(1, response.getBody().getData().size()); + } + + @Test + void testGetAvailableParsers_Success() { + // Given + List mockParsers = List.of("kursiv", "kapital", "lsm"); + when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers); + + // When + ResponseEntity>> response = parserAdminController.getAvailableParsers(); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(3, response.getBody().getData().size()); + } + + @Test + void testCheckParserExists_Success() { + // Given + String sourceName = "kursiv"; + when(parserManagerService.hasParser(sourceName)).thenReturn(true); + + // When + ResponseEntity> response = parserAdminController.checkParserExists(sourceName); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertTrue(response.getBody().getData()); + } +} diff --git a/src/test/java/kz/konturai/parser/service/LsmParserServiceTest.java b/src/test/java/kz/konturai/parser/service/LsmParserServiceTest.java new file mode 100644 index 0000000..3bcf57e --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/LsmParserServiceTest.java @@ -0,0 +1,93 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LsmParserServiceTest { + + @Mock + private MarketItemRepository marketItemRepository; + + @InjectMocks + private LsmParserService lsmParserService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(lsmParserService, "rssFeedUrl", "https://lsm.kz/rss"); + } + + @Test + void testGetLsmItemsCount() { + // Given + when(marketItemRepository.countBySourceName("LSM.kz")).thenReturn(5L); + + // When + long count = lsmParserService.getLsmItemsCount(); + + // Then + assertEquals(5L, count); + verify(marketItemRepository).countBySourceName("LSM.kz"); + } + + @Test + void testGetAllLsmItems() { + // Given + List mockItems = new ArrayList<>(); + MarketItem item1 = new MarketItem(); + item1.setSourceName("LSM.kz"); + item1.setTitle("Test News 1"); + mockItems.add(item1); + + when(marketItemRepository.findBySourceName("LSM.kz")).thenReturn(mockItems); + + // When + List result = lsmParserService.getAllLsmItems(); + + // Then + assertEquals(1, result.size()); + assertEquals("LSM.kz", result.get(0).getSourceName()); + verify(marketItemRepository).findBySourceName("LSM.kz"); + } + + @Test + void testParseAndSaveRssFeed_WhenFeedIsNull() { + // When + List result = lsmParserService.parseAndSaveRssFeed(); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testGenerateHash() throws Exception { + // Given + String url = "https://example.com/news/1"; + String title = "Test News"; + + // When + String hash1 = (String) ReflectionTestUtils.invokeMethod(lsmParserService, "generateHash", url, title); + String hash2 = (String) ReflectionTestUtils.invokeMethod(lsmParserService, "generateHash", url, title); + + // Then + assertNotNull(hash1); + assertNotNull(hash2); + assertEquals(hash1, hash2); // Same input should produce same hash + assertNotEquals(hash1, + (String) ReflectionTestUtils.invokeMethod(lsmParserService, "generateHash", url, "Different Title")); + } +} diff --git a/src/test/java/kz/konturai/parser/service/RbcParserServiceTest.java b/src/test/java/kz/konturai/parser/service/RbcParserServiceTest.java new file mode 100644 index 0000000..b6e9f02 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/RbcParserServiceTest.java @@ -0,0 +1,94 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RbcParserServiceTest { + + @Mock + private MarketItemRepository marketItemRepository; + + @InjectMocks + private RbcParserService rbcParserService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(rbcParserService, "rssFeedUrl", + "https://static.feed.rbc.ru/rbc/logical/footer/news.rss"); + } + + @Test + void testGetRbcItemsCount() { + // Given + when(marketItemRepository.countBySourceName("РБК")).thenReturn(10L); + + // When + long count = rbcParserService.getRbcItemsCount(); + + // Then + assertEquals(10L, count); + verify(marketItemRepository).countBySourceName("РБК"); + } + + @Test + void testGetAllRbcItems() { + // Given + List mockItems = new ArrayList<>(); + MarketItem item1 = new MarketItem(); + item1.setSourceName("РБК"); + item1.setTitle("Test News 1"); + mockItems.add(item1); + + when(marketItemRepository.findBySourceName("РБК")).thenReturn(mockItems); + + // When + List result = rbcParserService.getAllRbcItems(); + + // Then + assertEquals(1, result.size()); + assertEquals("РБК", result.get(0).getSourceName()); + verify(marketItemRepository).findBySourceName("РБК"); + } + + @Test + void testParseAndSaveRssFeed_WhenFeedIsNull() { + // When + List result = rbcParserService.parseAndSaveRssFeed(); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testGenerateHash() throws Exception { + // Given + String url = "https://example.com/news/1"; + String title = "Test News"; + + // When + String hash1 = (String) ReflectionTestUtils.invokeMethod(rbcParserService, "generateHash", url, title); + String hash2 = (String) ReflectionTestUtils.invokeMethod(rbcParserService, "generateHash", url, title); + + // Then + assertNotNull(hash1); + assertNotNull(hash2); + assertEquals(hash1, hash2); // Same input should produce same hash + assertNotEquals(hash1, + (String) ReflectionTestUtils.invokeMethod(rbcParserService, "generateHash", url, "Different Title")); + } +} diff --git a/src/test/java/kz/konturai/parser/service/VedomostiParserServiceTest.java b/src/test/java/kz/konturai/parser/service/VedomostiParserServiceTest.java new file mode 100644 index 0000000..806a0c3 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/VedomostiParserServiceTest.java @@ -0,0 +1,94 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VedomostiParserServiceTest { + + @Mock + private MarketItemRepository marketItemRepository; + + @InjectMocks + private VedomostiParserService vedomostiParserService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(vedomostiParserService, "rssFeedUrl", + "https://www.vedomosti.ru/rss/rubric/technology/internet"); + } + + @Test + void testGetVedomostiItemsCount() { + // Given + when(marketItemRepository.countBySourceName("Ведомости")).thenReturn(15L); + + // When + long count = vedomostiParserService.getVedomostiItemsCount(); + + // Then + assertEquals(15L, count); + verify(marketItemRepository).countBySourceName("Ведомости"); + } + + @Test + void testGetAllVedomostiItems() { + // Given + List mockItems = new ArrayList<>(); + MarketItem item1 = new MarketItem(); + item1.setSourceName("Ведомости"); + item1.setTitle("Test News 1"); + mockItems.add(item1); + + when(marketItemRepository.findBySourceName("Ведомости")).thenReturn(mockItems); + + // When + List result = vedomostiParserService.getAllVedomostiItems(); + + // Then + assertEquals(1, result.size()); + assertEquals("Ведомости", result.get(0).getSourceName()); + verify(marketItemRepository).findBySourceName("Ведомости"); + } + + @Test + void testParseAndSaveRssFeed_WhenFeedIsNull() { + // When + List result = vedomostiParserService.parseAndSaveRssFeed(); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testGenerateHash() throws Exception { + // Given + String url = "https://example.com/news/1"; + String title = "Test News"; + + // When + String hash1 = (String) ReflectionTestUtils.invokeMethod(vedomostiParserService, "generateHash", url, title); + String hash2 = (String) ReflectionTestUtils.invokeMethod(vedomostiParserService, "generateHash", url, title); + + // Then + assertNotNull(hash1); + assertNotNull(hash2); + assertEquals(hash1, hash2); // Same input should produce same hash + assertNotEquals(hash1, (String) ReflectionTestUtils.invokeMethod(vedomostiParserService, "generateHash", url, + "Different Title")); + } +} diff --git a/Расширить систему сбора данных, добавив парсеры для трёх новых RSS-источников.md b/Расширить систему сбора данных, добавив парсеры для трёх новых RSS-источников.md new file mode 100644 index 0000000..979d73a --- /dev/null +++ b/Расширить систему сбора данных, добавив парсеры для трёх новых RSS-источников.md @@ -0,0 +1,51 @@ +### Техническое задание для AI-агента + +**Задача:** Расширить систему сбора данных, добавив парсеры для трёх новых RSS-источников. + +**Контекст:** Мы увеличиваем охват нашей аналитической платформы, подключая новые ключевые источники новостей из Казахстана и России. Каждый источник должен быть реализован как отдельный, независимый сервис для надежности. + +--- + +## 1\. Общие требования для всех парсеров + +Для каждого из трёх источников ниже необходимо создать отдельный Spring Boot сервис, который: + +- Получает данные из RSS-ленты по URL. +- Парсит XML, извлекая `title`, `link`, `pubDate`, `description`. +- Нормализует данные: очищает текст от HTML, приводит дату к формату ISODate. +- Генерирует уникальный `sha256` хэш (`url + "::" + title`). +- Сохраняет результат в общую коллекцию MongoDB `market_items` с операцией `upsert` по хэшу. +- Запускается автоматически по расписанию **каждые 30 минут** (`@Scheduled(cron = "0 0/30 * * * ?")`). + +--- + +## 2\. Список новых источников и их конфигурация + +### Источник 1: LSM.kz + +- **URL:** `https://lsm.kz/rss` +- **Название сервиса:** `lsm-parser-service` +- **`source_name` в MongoDB:** `LSM.kz` +- **`category` в MongoDB:** `Финансы` + +### Источник 2: РБК + +- **URL:** `https://static.feed.rbc.ru/rbc/logical/footer/news.rss` +- **Название сервиса:** `rbc-parser-service` +- **`source_name` в MongoDB:** `РБК` +- **`category` в MongoDB:** `Бизнес` + +### Источник 3: Ведомости (Технологии) + +- **URL:** `https://www.vedomosti.ru/rss/rubric/technology/internet` +- **Название сервиса:** `vedomosti-parser-service` +- **`source_name` в MongoDB:** `Ведомости` +- **`category` в MongoDB:** `Технологии` + +--- + +## Критерии выполнения + +- Созданы три новых Spring Boot сервиса, каждый для своего источника. +- Данные со всех трёх новых RSS-лент успешно собираются каждые 30 минут и сохраняются в коллекцию `market_items` без дубликатов. +- Структура сохраняемых документов в MongoDB полностью соответствует существующей модели. diff --git a/Техническое задание для AI-агента: Рефакторинг ParserController.md b/Техническое задание для AI-агента: Рефакторинг ParserController.md new file mode 100644 index 0000000..9f9f0bb --- /dev/null +++ b/Техническое задание для AI-агента: Рефакторинг ParserController.md @@ -0,0 +1,202 @@ +### Техническое задание для AI-агента: Рефакторинг `ParserController` + +**Задача:** Провести рефакторинг `ParserController` и связанных сервисов, чтобы сделать код более чистым, масштабируемым и простым в поддержке. + +**Проблемы текущей реализации:** + +1. **Дублирование кода:** Методы `parse...RssFeed()` практически идентичны. +2. **Нарушение SRP:** Контроллер отвечает за запуск парсеров, получение данных, статистику и проверку состояния. +3. **Низкая масштабируемость:** Добавление нового парсера требует изменения контроллера в 3-4 местах (добавление зависимости, нового эндпоинта, обновление метода `parseAll`, обновление `scheduler/info`). + +--- + +### План рефакторинга + +#### Шаг 1: Создание общего интерфейса `ParserService` (Паттерн "Стратегия") + +Создайте общий интерфейс, который будут реализовывать все парсеры. Это позволит нам работать с ними единообразно. + +```java +public interface ParserService { + /** + * Возвращает уникальное имя источника (например, "kursiv", "kapital"). + * @return String source name + */ + String getSourceName(); + + /** + * Запускает парсинг и сохранение данных для своего источника. + * @return List of newly saved MarketItem + */ + List parseAndSaveRssFeed(); +} +``` + +#### Шаг 2: Модификация существующих сервисов + +Каждый из ваших сервисов (`KursivParserService`, `KapitalParserService` и т.д.) должен реализовать этот интерфейс. + +**Пример для `KursivParserService`:** + +```java +@Service +public class KursivParserService implements ParserService { + + @Override + public String getSourceName() { + return "kursiv"; // Уникальное имя в нижнем регистре + } + + @Override + public List parseAndSaveRssFeed() { + // ... существующая логика парсинга для Kursiv ... + } +} +``` + +_Проделайте это для всех 5 парсер-сервисов._ + +#### Шаг 3: Создание `ParserManagerService` (Паттерн "Фасад" / Service Locator) + +Создайте новый сервис, который будет управлять всеми парсерами. Spring Boot автоматически соберет все бины, реализующие `ParserService`, в один список. + +```java +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import org.springframework.scheduling.annotation.Async; + + +@Service +public class ParserManagerService { + + private final Map parsers; + + // Spring автоматически инжектирует все бины типа ParserService + public ParserManagerService(List parserServices) { + this.parsers = parserServices.stream() + .collect(Collectors.toMap(ParserService::getSourceName, service -> service)); + } + + /** + * Запускает парсер по его имени. + * @param sourceName Имя источника (например, "kursiv") + * @return Результат парсинга + */ + public List runParser(String sourceName) { + ParserService parser = Optional.ofNullable(parsers.get(sourceName)) + .orElseThrow(() -> new IllegalArgumentException("Парсер не найден: " + sourceName)); + return parser.parseAndSaveRssFeed(); + } + + /** + * Асинхронно запускает все парсеры. + * @return Список результатов для каждого парсера + */ + @Async // Для параллельного выполнения + public CompletableFuture> runAllParsers() { + long totalItemsBefore = marketItemService.getTotalItemsCount(); // Предполагая, что у вас есть доступ к этому сервису + List results = parsers.values().parallelStream() + .map(parser -> { + List savedItems = parser.parseAndSaveRssFeed(); + return new ParserResultDto(parser.getSourceName(), savedItems.size(), totalItemsBefore + savedItems.size(), "completed"); + }) + .collect(Collectors.toList()); + return CompletableFuture.completedFuture(results); + } + + public List getAvailableParsers() { + return parsers.keySet().stream().sorted().collect(Collectors.toList()); + } +} +``` + +_Не забудьте добавить `@EnableAsync` в главный класс вашего приложения._ + +#### Шаг 4: Разделение `ParserController` на несколько маленьких + +Разделите один большой контроллер на три, каждый со своей зоной ответственности: + +1. **`MarketItemController`** — для публичных запросов на получение данных. +2. **`ParserAdminController`** — для административных действий (запуск парсеров). +3. **`HealthCheckController`** — для эндпоинтов мониторинга. + +#### Шаг 5: Реализация новых контроллеров + +**1. `MarketItemController.java`** +(Содержит эндпоинты, которые нужны фронтенду для отображения данных) + +```java +@RestController +@RequestMapping("/api/items") +public class MarketItemController { + @Autowired private MarketItemService marketItemService; + + @GetMapping + public ResponseEntity>> getItems(...) { ... } + + @GetMapping("/{id}") + public ResponseEntity> getItemById(@PathVariable String id) { ... } + + @GetMapping("/stats") + public ResponseEntity> getStats() { ... } +} +``` + +**2. `ParserAdminController.java`** +(Содержит эндпоинты для управления парсерами, возможно, их стоит защитить в будущем) + +```java +@RestController +@RequestMapping("/api/admin/parsers") +public class ParserAdminController { + @Autowired private ParserManagerService parserManagerService; + @Autowired private MarketItemService marketItemService; // для подсчета totalItems + + // Один динамический эндпоинт вместо пяти + @PostMapping("/parse/{sourceName}") + public ResponseEntity> parseSource(@PathVariable String sourceName) { + try { + List savedItems = parserManagerService.runParser(sourceName); + long totalItems = marketItemService.getTotalItemsCount(); + ParserResultDto result = new ParserResultDto(sourceName, savedItems.size(), totalItems, "completed"); + return ResponseEntity.ok(ApiResponse.success("Парсинг " + sourceName + " завершен", result)); + } catch (Exception e) { + return ResponseEntity.internalServerError().body(ApiResponse.error(e.getMessage())); + } + } + + // Упрощенный и асинхронный метод + @PostMapping("/parse/all") + public ResponseEntity>> parseAll() { + try { + List results = parserManagerService.runAllParsers().get(); // .get() для ожидания результата + return ResponseEntity.ok(ApiResponse.success("Парсинг всех источников запущен", results)); + } catch (Exception e) { + return ResponseEntity.internalServerError().body(ApiResponse.error(e.getMessage())); + } + } +} +``` + +**3. `HealthCheckController.java`** +(Содержит все `/health` и информационные эндпоинты) + +```java +@RestController +@RequestMapping("/api/health") +public class HealthCheckController { + // ... методы healthCheck(), checkMongoConnection(), getSchedulerInfo() ... + // Метод getSchedulerInfo можно улучшить, получая список парсеров из ParserManagerService +} +``` + +### Преимущества нового подхода + +- **DRY (Don't Repeat Yourself):** Убрано дублирование кода в эндпоинтах. +- **SRP (Single Responsibility Principle):** Каждый контроллер отвечает за свою область. +- **Масштабируемость:** Чтобы добавить новый парсер, достаточно создать новый сервис, реализующий `ParserService`. **Контроллеры менять не нужно.** +- **Эффективность:** Запуск всех парсеров теперь может выполняться параллельно.