This commit is contained in:
root
2025-09-14 18:01:22 +05:00
parent 98042e62ae
commit 8d8303991e
22 changed files with 2300 additions and 240 deletions
@@ -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("Ведомости");
}
}
+7 -1
View File
@@ -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`. **Контроллеры менять не нужно.**
- **Эффективность:** Запуск всех парсеров теперь может выполняться параллельно.