This commit is contained in:
root
2025-09-14 10:13:50 +05:00
parent 3f77c72e6f
commit 2ef51e43a3
14 changed files with 1334 additions and 10 deletions
@@ -2,8 +2,10 @@ package kz.konturai.parser;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class ParserApplication {
public static void main(String[] args) {
@@ -0,0 +1,150 @@
package kz.konturai.parser.controller;
import kz.konturai.parser.model.MarketItem;
import kz.konturai.parser.service.KursivParserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/parser")
@CrossOrigin(origins = "*")
public class ParserController {
@Autowired
private KursivParserService kursivParserService;
/**
* Запуск парсинга RSS-ленты
*/
@PostMapping("/parse")
public ResponseEntity<Map<String, Object>> parseRssFeed() {
Map<String, Object> response = new HashMap<>();
try {
List<MarketItem> savedItems = kursivParserService.parseAndSaveRssFeed();
response.put("success", true);
response.put("message", "Парсинг завершен успешно");
response.put("processedItems", savedItems.size());
response.put("totalItemsInDb", kursivParserService.getTotalItemsCount());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "Ошибка при парсинге: " + e.getMessage());
response.put("processedItems", 0);
return ResponseEntity.internalServerError().body(response);
}
}
/**
* Получение статистики по сохраненным записям
*/
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
Map<String, Object> response = new HashMap<>();
try {
long totalCount = kursivParserService.getTotalItemsCount();
response.put("success", true);
response.put("totalItems", totalCount);
response.put("message", "Статистика получена успешно");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "Ошибка при получении статистики: " + e.getMessage());
response.put("totalItems", 0);
return ResponseEntity.internalServerError().body(response);
}
}
/**
* Получение всех сохраненных записей
*/
@GetMapping("/items")
public ResponseEntity<Map<String, Object>> getAllItems() {
Map<String, Object> response = new HashMap<>();
try {
List<MarketItem> items = kursivParserService.getAllItems();
response.put("success", true);
response.put("items", items);
response.put("count", items.size());
response.put("message", "Записи получены успешно");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "Ошибка при получении записей: " + e.getMessage());
response.put("items", List.of());
response.put("count", 0);
return ResponseEntity.internalServerError().body(response);
}
}
/**
* Получение записи по ID
*/
@GetMapping("/items/{id}")
public ResponseEntity<Map<String, Object>> getItemById(@PathVariable String id) {
Map<String, Object> response = new HashMap<>();
try {
// Здесь можно добавить метод для поиска по ID в сервисе
response.put("success", false);
response.put("message", "Функция поиска по ID пока не реализована");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "Ошибка при получении записи: " + e.getMessage());
return ResponseEntity.internalServerError().body(response);
}
}
/**
* Проверка состояния парсера
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> healthCheck() {
Map<String, Object> response = new HashMap<>();
response.put("status", "UP");
response.put("service", "Kursiv RSS Parser");
response.put("timestamp", System.currentTimeMillis());
response.put("scheduler", "Enabled - runs every 30 minutes");
return ResponseEntity.ok(response);
}
/**
* Получение информации о планировщике
*/
@GetMapping("/scheduler/info")
public ResponseEntity<Map<String, Object>> getSchedulerInfo() {
Map<String, Object> response = new HashMap<>();
response.put("schedulerEnabled", true);
response.put("cronExpression", "0 0/30 * * * ?");
response.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)");
response.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа");
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,183 @@
package kz.konturai.parser.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
import java.util.Map;
@Document(collection = "market_items")
public class MarketItem {
@Id
private String id;
@Field("source_name")
private String sourceName;
@Field("url")
private String url;
@Field("title")
private String title;
@Field("published_at")
private LocalDateTime publishedAt;
@Field("added_at")
private LocalDateTime addedAt;
@Field("raw_text")
private String rawText;
@Field("hash")
private String hash;
@Field("category")
private String category;
@Field("analytics")
private Analytics analytics;
// Конструкторы
public MarketItem() {
this.addedAt = LocalDateTime.now();
this.sourceName = "Kursiv (Бизнес/экономика)";
this.category = "Бизнес";
this.analytics = new Analytics();
}
public MarketItem(String url, String title, LocalDateTime publishedAt, String rawText, String hash) {
this();
this.url = url;
this.title = title;
this.publishedAt = publishedAt;
this.rawText = rawText;
this.hash = hash;
}
// Getters and Setters
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getSourceName() {
return sourceName;
}
public void setSourceName(String sourceName) {
this.sourceName = sourceName;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public LocalDateTime getPublishedAt() {
return publishedAt;
}
public void setPublishedAt(LocalDateTime publishedAt) {
this.publishedAt = publishedAt;
}
public LocalDateTime getAddedAt() {
return addedAt;
}
public void setAddedAt(LocalDateTime addedAt) {
this.addedAt = addedAt;
}
public String getRawText() {
return rawText;
}
public void setRawText(String rawText) {
this.rawText = rawText;
}
public String getHash() {
return hash;
}
public void setHash(String hash) {
this.hash = hash;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public Analytics getAnalytics() {
return analytics;
}
public void setAnalytics(Analytics analytics) {
this.analytics = analytics;
}
// Вложенный класс для аналитики
public static class Analytics {
private String summary;
private String sentiment;
private String[] tags = {};
private Map<String, Object> entities = Map.of();
public Analytics() {
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getSentiment() {
return sentiment;
}
public void setSentiment(String sentiment) {
this.sentiment = sentiment;
}
public String[] getTags() {
return tags;
}
public void setTags(String[] tags) {
this.tags = tags;
}
public Map<String, Object> getEntities() {
return entities;
}
public void setEntities(Map<String, Object> entities) {
this.entities = entities;
}
}
}
@@ -0,0 +1,21 @@
package kz.konturai.parser.repository;
import kz.konturai.parser.model.MarketItem;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface MarketItemRepository extends MongoRepository<MarketItem, String> {
/**
* Найти запись по хэшу для дедупликации
*/
Optional<MarketItem> findByHash(String hash);
/**
* Проверить существование записи по хэшу
*/
boolean existsByHash(String hash);
}
@@ -0,0 +1,260 @@
package kz.konturai.parser.service;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.io.SyndFeedInput;
import com.rometools.rome.io.XmlReader;
import kz.konturai.parser.model.MarketItem;
import kz.konturai.parser.repository.MarketItemRepository;
import org.jsoup.Jsoup;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
@Service
public class KursivParserService {
private static final Logger logger = LoggerFactory.getLogger(KursivParserService.class);
@Autowired
private MarketItemRepository marketItemRepository;
@Value("${rss.feed.url}")
private String rssFeedUrl;
/**
* Автоматический запуск парсинга каждые 30 минут
* Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа
*/
@Scheduled(cron = "0 0/30 * * * ?")
public void scheduledParseAndStoreNews() {
logger.info("🔄 Запуск планового парсинга новостей с Kursiv.media...");
try {
List<MarketItem> savedItems = parseAndSaveRssFeed();
logger.info("✅ Плановый парсинг завершен. Обработано записей: {}", savedItems.size());
} catch (Exception e) {
logger.error("❌ Ошибка при плановом парсинге", e);
}
}
/**
* Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB
*/
public List<MarketItem> parseAndSaveRssFeed() {
List<MarketItem> savedItems = new ArrayList<>();
try {
logger.info("Начинаем парсинг RSS-ленты: {}", rssFeedUrl);
// Получаем RSS-ленту
SyndFeed feed = getRssFeed();
if (feed == null) {
logger.error("Не удалось получить RSS-ленту");
return savedItems;
}
logger.info("Получено {} записей из RSS-ленты", feed.getEntries().size());
// Обрабатываем каждую запись
for (SyndEntry entry : feed.getEntries()) {
try {
MarketItem marketItem = processRssEntry(entry);
if (marketItem != null) {
MarketItem savedItem = saveOrUpdateMarketItem(marketItem);
if (savedItem != null) {
savedItems.add(savedItem);
}
}
} catch (Exception e) {
logger.error("Ошибка при обработке записи: {}", entry.getTitle(), e);
}
}
logger.info("Успешно обработано {} записей", savedItems.size());
} catch (Exception e) {
logger.error("Ошибка при парсинге RSS-ленты", e);
}
return savedItems;
}
/**
* Получение RSS-ленты
*/
private SyndFeed getRssFeed() throws Exception {
URL feedUrl = URI.create(rssFeedUrl).toURL();
SyndFeedInput input = new SyndFeedInput();
try (XmlReader reader = new XmlReader(feedUrl.openStream())) {
return input.build(reader);
}
}
/**
* Обработка отдельной записи RSS
*/
private MarketItem processRssEntry(SyndEntry entry) {
try {
// Извлекаем основные поля
String title = entry.getTitle();
String url = entry.getLink();
String description = getDescription(entry);
if (title == null || url == null) {
logger.warn("Пропускаем запись с отсутствующими обязательными полями: title={}, url={}", title, url);
return null;
}
// Очищаем текст от HTML
String cleanText = cleanHtmlText(description);
// Преобразуем дату
LocalDateTime publishedAt = convertToLocalDateTime(entry.getPublishedDate());
// Генерируем хэш
String hash = generateHash(url, title);
// Создаем объект MarketItem
MarketItem marketItem = new MarketItem(url, title, publishedAt, cleanText, hash);
logger.debug("Обработана запись: {}", title);
return marketItem;
} catch (Exception e) {
logger.error("Ошибка при обработке записи RSS", e);
return null;
}
}
/**
* Извлечение описания из записи RSS
*/
private String getDescription(SyndEntry entry) {
if (entry.getDescription() != null) {
return entry.getDescription().getValue();
}
// Пробуем получить содержимое из других полей
if (entry.getContents() != null && !entry.getContents().isEmpty()) {
return entry.getContents().get(0).getValue();
}
return "";
}
/**
* Очистка HTML-тегов из текста
*/
private String cleanHtmlText(String htmlText) {
if (htmlText == null || htmlText.trim().isEmpty()) {
return "";
}
try {
return Jsoup.parse(htmlText).text();
} catch (Exception e) {
logger.warn("Ошибка при очистке HTML: {}", e.getMessage());
return htmlText;
}
}
/**
* Преобразование даты в LocalDateTime
*/
private LocalDateTime convertToLocalDateTime(java.util.Date date) {
if (date == null) {
return LocalDateTime.now();
}
return date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
}
/**
* Генерация SHA-256 хэша для дедупликации
*/
private String generateHash(String url, String title) {
try {
String input = url + "::" + title;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
logger.error("Ошибка при генерации хэша", e);
return String.valueOf((url + "::" + title).hashCode());
}
}
/**
* Сохранение или обновление записи в MongoDB (upsert)
*/
private MarketItem saveOrUpdateMarketItem(MarketItem marketItem) {
try {
// Проверяем существование записи по хэшу
MarketItem existingItem = marketItemRepository.findByHash(marketItem.getHash()).orElse(null);
if (existingItem != null) {
// Обновляем существующую запись
existingItem.setTitle(marketItem.getTitle());
existingItem.setUrl(marketItem.getUrl());
existingItem.setPublishedAt(marketItem.getPublishedAt());
existingItem.setRawText(marketItem.getRawText());
existingItem.setAddedAt(LocalDateTime.now()); // Обновляем время добавления
MarketItem savedItem = marketItemRepository.save(existingItem);
logger.debug("Обновлена существующая запись: {}", marketItem.getTitle());
return savedItem;
} else {
// Создаем новую запись
MarketItem savedItem = marketItemRepository.save(marketItem);
logger.debug("Создана новая запись: {}", marketItem.getTitle());
return savedItem;
}
} catch (Exception e) {
logger.error("Ошибка при сохранении записи: {}", marketItem.getTitle(), e);
return null;
}
}
/**
* Получение статистики по сохраненным записям
*/
public long getTotalItemsCount() {
return marketItemRepository.count();
}
/**
* Получение всех сохраненных записей
*/
public List<MarketItem> getAllItems() {
return marketItemRepository.findAll();
}
}
+16
View File
@@ -1 +1,17 @@
spring.application.name=parser
# MongoDB Configuration
spring.data.mongodb.host=92.38.48.166
spring.data.mongodb.port=27017
spring.data.mongodb.database=parser_db
# RSS Feed URL
rss.feed.url=https://kursiv.media/feed/
# Scheduler Configuration
spring.task.scheduling.pool.size=2
spring.task.scheduling.thread-name-prefix=scheduled-task-
# Logging Configuration
logging.level.kz.konturai.parser.service.KursivParserService=INFO
logging.level.org.springframework.scheduling=DEBUG