.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<ApiResponse<HealthCheckDto>> 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<ApiResponse<HealthCheckDto>> 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<ApiResponse<java.util.Map<String, Object>>> getSchedulerInfo() {
|
||||
try {
|
||||
java.util.Map<String, Object> 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<ApiResponse<java.util.Map<String, Object>>> getSystemInfo() {
|
||||
try {
|
||||
java.util.Map<String, Object> 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<ApiResponse<java.util.Map<String, Object>>> checkParsersHealth() {
|
||||
try {
|
||||
java.util.Map<String, Object> parsersHealth = new java.util.HashMap<>();
|
||||
|
||||
List<String> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<Page<MarketItem>>> 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<MarketItem> 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<ApiResponse<MarketItem>> 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<ApiResponse<ParserStatsDto>> 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<ApiResponse<java.util.List<String>>> getSources() {
|
||||
try {
|
||||
java.util.List<String> 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<ApiResponse<Page<MarketItem>>> getItemsBySource(
|
||||
@PathVariable String sourceName,
|
||||
@PageableDefault(size = 20, sort = "publishedAt", direction = org.springframework.data.domain.Sort.Direction.DESC) Pageable pageable) {
|
||||
try {
|
||||
Page<MarketItem> items = marketItemService.getItemsBySource(sourceName, pageable);
|
||||
return ResponseEntity
|
||||
.ok(ApiResponse.success("Записи по источнику " + sourceName + " получены успешно", items));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResponse.error("Ошибка при получении записей по источнику: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<ParserResultDto>> parseSource(@PathVariable String sourceName) {
|
||||
try {
|
||||
if (!parserManagerService.hasParser(sourceName)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("Парсер не найден: " + sourceName));
|
||||
}
|
||||
|
||||
List<MarketItem> 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<ApiResponse<List<ParserResultDto>>> parseAll() {
|
||||
try {
|
||||
CompletableFuture<List<ParserResultDto>> future = parserManagerService.runAllParsers();
|
||||
List<ParserResultDto> results = future.get(); // .get() для ожидания результата
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("Парсинг всех источников завершен", results));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResponse.error("Ошибка при парсинге всех источников: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка доступных парсеров
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<String>>> getAvailableParsers() {
|
||||
try {
|
||||
List<String> parsers = parserManagerService.getAvailableParsers();
|
||||
return ResponseEntity.ok(ApiResponse.success("Список парсеров получен", parsers));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResponse.error("Ошибка при получении списка парсеров: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации о парсерах
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<ApiResponse<java.util.Map<String, Object>>> getParsersInfo() {
|
||||
try {
|
||||
java.util.Map<String, Object> 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<ApiResponse<Boolean>> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiResponse<ParserResultDto>> parseKursivRssFeed() {
|
||||
try {
|
||||
List<MarketItem> 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<ApiResponse<ParserResultDto>> parseKapitalRssFeed() {
|
||||
try {
|
||||
List<MarketItem> 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<ApiResponse<List<ParserResultDto>>> parseAllRssFeeds() {
|
||||
try {
|
||||
List<MarketItem> kursivItems = kursivParserService.parseAndSaveRssFeed();
|
||||
List<MarketItem> 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<ParserResultDto> 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<ApiResponse<Page<MarketItem>>> 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<MarketItem> 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<ApiResponse<ParserStatsDto>> 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<ApiResponse<MarketItem>> 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<ApiResponse<HealthCheckDto>> 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<ApiResponse<HealthCheckDto>> 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<ApiResponse<java.util.Map<String, Object>>> getSchedulerInfo() {
|
||||
java.util.Map<String, Object> 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));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ public interface MarketItemRepository extends MongoRepository<MarketItem, String
|
||||
*/
|
||||
Page<MarketItem> findBySourceName(String sourceName, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Найти все записи по источнику без пагинации
|
||||
*/
|
||||
List<MarketItem> findBySourceName(String sourceName);
|
||||
|
||||
/**
|
||||
* Найти записи по диапазону дат с пагинацией
|
||||
*/
|
||||
|
||||
@@ -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 минут каждого часа
|
||||
|
||||
@@ -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 минут каждого часа
|
||||
|
||||
@@ -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<MarketItem> savedItems = parseAndSaveRssFeed();
|
||||
logger.info("✅ Плановый парсинг LSM.kz завершен. Обработано записей: {}", savedItems.size());
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ Ошибка при плановом парсинге LSM.kz", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB
|
||||
*/
|
||||
public List<MarketItem> parseAndSaveRssFeed() {
|
||||
List<MarketItem> 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<MarketItem> getAllLsmItems() {
|
||||
return marketItemRepository.findBySourceName("LSM.kz");
|
||||
}
|
||||
}
|
||||
@@ -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<String, ParserService> parsers;
|
||||
private final MarketItemService marketItemService;
|
||||
|
||||
// Spring автоматически инжектирует все бины типа ParserService
|
||||
public ParserManagerService(List<ParserService> 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<MarketItem> runParser(String sourceName) {
|
||||
ParserService parser = Optional.ofNullable(parsers.get(sourceName))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Парсер не найден: " + sourceName));
|
||||
|
||||
logger.info("Запуск парсера: {}", sourceName);
|
||||
return parser.parseAndSaveRssFeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Асинхронно запускает все парсеры.
|
||||
*
|
||||
* @return Список результатов для каждого парсера
|
||||
*/
|
||||
@Async
|
||||
public CompletableFuture<List<ParserResultDto>> runAllParsers() {
|
||||
logger.info("Запуск всех парсеров асинхронно");
|
||||
|
||||
long totalItemsBefore = marketItemService.getTotalItemsCount();
|
||||
|
||||
List<ParserResultDto> results = parsers.values().parallelStream()
|
||||
.map(parser -> {
|
||||
try {
|
||||
logger.debug("Запуск парсера: {}", parser.getSourceName());
|
||||
List<MarketItem> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> parseAndSaveRssFeed();
|
||||
}
|
||||
@@ -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<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);
|
||||
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<MarketItem> getAllRbcItems() {
|
||||
return marketItemRepository.findBySourceName("РБК");
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
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<MarketItem> getAllVedomostiItems() {
|
||||
return marketItemRepository.findBySourceName("Ведомости");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ApiResponse<HealthCheckDto>> 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<ApiResponse<HealthCheckDto>> 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<ApiResponse<HealthCheckDto>> 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<String> mockParsers = List.of("kursiv", "kapital", "lsm");
|
||||
when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers);
|
||||
when(parserManagerService.getParserCount()).thenReturn(3);
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> 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<String> 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<ApiResponse<Map<String, Object>>> 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<String> mockParsers = List.of("kursiv", "kapital", "lsm");
|
||||
when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers);
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> mockItems = new ArrayList<>();
|
||||
MarketItem item = new MarketItem();
|
||||
item.setTitle("Test News");
|
||||
mockItems.add(item);
|
||||
|
||||
Page<MarketItem> mockPage = new PageImpl<>(mockItems);
|
||||
when(marketItemService.getAllItems(any(Pageable.class))).thenReturn(mockPage);
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<Page<MarketItem>>> 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<ApiResponse<kz.konturai.parser.dto.ParserStatsDto>> 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<String> mockSources = List.of("kursiv", "kapital", "lsm");
|
||||
when(marketItemService.getAllSourceNames()).thenReturn(mockSources);
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<List<String>>> response = marketItemController.getSources();
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().isSuccess());
|
||||
assertEquals(3, response.getBody().getData().size());
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> 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<ApiResponse<ParserResultDto>> 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<ApiResponse<ParserResultDto>> 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<ParserResultDto> mockResults = new ArrayList<>();
|
||||
ParserResultDto result = new ParserResultDto("kursiv", 5, 100, "completed");
|
||||
mockResults.add(result);
|
||||
|
||||
when(parserManagerService.runAllParsers()).thenReturn(CompletableFuture.completedFuture(mockResults));
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<List<ParserResultDto>>> 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<String> mockParsers = List.of("kursiv", "kapital", "lsm");
|
||||
when(parserManagerService.getAvailableParsers()).thenReturn(mockParsers);
|
||||
|
||||
// When
|
||||
ResponseEntity<ApiResponse<List<String>>> 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<ApiResponse<Boolean>> response = parserAdminController.checkParserExists(sourceName);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().isSuccess());
|
||||
assertTrue(response.getBody().getData());
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> 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<MarketItem> 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<MarketItem> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> mockItems = new ArrayList<>();
|
||||
MarketItem item1 = new MarketItem();
|
||||
item1.setSourceName("РБК");
|
||||
item1.setTitle("Test News 1");
|
||||
mockItems.add(item1);
|
||||
|
||||
when(marketItemRepository.findBySourceName("РБК")).thenReturn(mockItems);
|
||||
|
||||
// When
|
||||
List<MarketItem> result = rbcParserService.getAllRbcItems();
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("РБК", result.get(0).getSourceName());
|
||||
verify(marketItemRepository).findBySourceName("РБК");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseAndSaveRssFeed_WhenFeedIsNull() {
|
||||
// When
|
||||
List<MarketItem> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<MarketItem> mockItems = new ArrayList<>();
|
||||
MarketItem item1 = new MarketItem();
|
||||
item1.setSourceName("Ведомости");
|
||||
item1.setTitle("Test News 1");
|
||||
mockItems.add(item1);
|
||||
|
||||
when(marketItemRepository.findBySourceName("Ведомости")).thenReturn(mockItems);
|
||||
|
||||
// When
|
||||
List<MarketItem> result = vedomostiParserService.getAllVedomostiItems();
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("Ведомости", result.get(0).getSourceName());
|
||||
verify(marketItemRepository).findBySourceName("Ведомости");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseAndSaveRssFeed_WhenFeedIsNull() {
|
||||
// When
|
||||
List<MarketItem> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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 полностью соответствует существующей модели.
|
||||
@@ -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<MarketItem> parseAndSaveRssFeed();
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 2: Модификация существующих сервисов
|
||||
|
||||
Каждый из ваших сервисов (`KursivParserService`, `KapitalParserService` и т.д.) должен реализовать этот интерфейс.
|
||||
|
||||
**Пример для `KursivParserService`:**
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class KursivParserService implements ParserService {
|
||||
|
||||
@Override
|
||||
public String getSourceName() {
|
||||
return "kursiv"; // Уникальное имя в нижнем регистре
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MarketItem> 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<String, ParserService> parsers;
|
||||
|
||||
// Spring автоматически инжектирует все бины типа ParserService
|
||||
public ParserManagerService(List<ParserService> parserServices) {
|
||||
this.parsers = parserServices.stream()
|
||||
.collect(Collectors.toMap(ParserService::getSourceName, service -> service));
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает парсер по его имени.
|
||||
* @param sourceName Имя источника (например, "kursiv")
|
||||
* @return Результат парсинга
|
||||
*/
|
||||
public List<MarketItem> runParser(String sourceName) {
|
||||
ParserService parser = Optional.ofNullable(parsers.get(sourceName))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Парсер не найден: " + sourceName));
|
||||
return parser.parseAndSaveRssFeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Асинхронно запускает все парсеры.
|
||||
* @return Список результатов для каждого парсера
|
||||
*/
|
||||
@Async // Для параллельного выполнения
|
||||
public CompletableFuture<List<ParserResultDto>> runAllParsers() {
|
||||
long totalItemsBefore = marketItemService.getTotalItemsCount(); // Предполагая, что у вас есть доступ к этому сервису
|
||||
List<ParserResultDto> results = parsers.values().parallelStream()
|
||||
.map(parser -> {
|
||||
List<MarketItem> savedItems = parser.parseAndSaveRssFeed();
|
||||
return new ParserResultDto(parser.getSourceName(), savedItems.size(), totalItemsBefore + savedItems.size(), "completed");
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return CompletableFuture.completedFuture(results);
|
||||
}
|
||||
|
||||
public List<String> 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<ApiResponse<Page<MarketItem>>> getItems(...) { ... }
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<MarketItem>> getItemById(@PathVariable String id) { ... }
|
||||
|
||||
@GetMapping("/stats")
|
||||
public ResponseEntity<ApiResponse<ParserStatsDto>> 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<ApiResponse<ParserResultDto>> parseSource(@PathVariable String sourceName) {
|
||||
try {
|
||||
List<MarketItem> 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<ApiResponse<List<ParserResultDto>>> parseAll() {
|
||||
try {
|
||||
List<ParserResultDto> 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`. **Контроллеры менять не нужно.**
|
||||
- **Эффективность:** Запуск всех парсеров теперь может выполняться параллельно.
|
||||
Reference in New Issue
Block a user