parse every 5 miuntes
This commit is contained in:
+62
-9
@@ -1,12 +1,17 @@
|
|||||||
# RSS Parser для Kursiv Media
|
# RSS Parser для бизнес-новостей
|
||||||
|
|
||||||
Этот проект представляет собой RSS-парсер для сайта Kursiv Media, разработанный на Spring Boot с сохранением данных в MongoDB.
|
Этот проект представляет собой многопарсерную систему для сбора бизнес-новостей с различных источников, разработанную на Spring Boot с сохранением данных в MongoDB.
|
||||||
|
|
||||||
## Описание
|
## Описание
|
||||||
|
|
||||||
Парсер получает данные из RSS-ленты `https://kursiv.media/feed/`, обрабатывает их и сохраняет в базу данных MongoDB с дедупликацией по хэшу.
|
Система включает в себя два парсера:
|
||||||
|
|
||||||
**🔄 Автоматический режим работы:** Парсер автоматически запускается каждые 30 минут (в 0 и 30 минут каждого часа) для обеспечения актуальности данных.
|
- **Kursiv Media** (`https://kursiv.media/feed/`)
|
||||||
|
- **Kapital.kz** (`https://kapital.kz/rss/`)
|
||||||
|
|
||||||
|
Оба парсера получают данные из RSS-лент, обрабатывают их и сохраняют в общую базу данных MongoDB с дедупликацией по хэшу.
|
||||||
|
|
||||||
|
**🔄 Автоматический режим работы:** Оба парсера автоматически запускаются каждые 30 минут (в 0 и 30 минут каждого часа) для обеспечения актуальности данных.
|
||||||
|
|
||||||
## Технологический стек
|
## Технологический стек
|
||||||
|
|
||||||
@@ -85,10 +90,10 @@ src/main/java/kz/konturai/parser/
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### 1. Запуск парсинга
|
### 1. Запуск парсинга Kursiv Media
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/parser/parse
|
POST /api/parser/parse/kursiv
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ответ:**
|
**Ответ:**
|
||||||
@@ -96,12 +101,50 @@ POST /api/parser/parse
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Парсинг завершен успешно",
|
"message": "Парсинг Kursiv завершен успешно",
|
||||||
|
"source": "Kursiv",
|
||||||
"processedItems": 15,
|
"processedItems": 15,
|
||||||
"totalItemsInDb": 25
|
"totalItemsInDb": 25
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 2. Запуск парсинга Kapital.kz
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/parser/parse/kapital
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Парсинг Kapital завершен успешно",
|
||||||
|
"source": "Kapital",
|
||||||
|
"processedItems": 12,
|
||||||
|
"totalItemsInDb": 37
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запуск парсинга всех источников
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/parser/parse/all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Парсинг всех источников завершен успешно",
|
||||||
|
"kursivProcessed": 15,
|
||||||
|
"kapitalProcessed": 12,
|
||||||
|
"totalProcessed": 27,
|
||||||
|
"totalItemsInDb": 37
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 2. Получение статистики
|
### 2. Получение статистики
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -214,11 +257,19 @@ GET /api/parser/scheduler/info
|
|||||||
|
|
||||||
### Автоматический планировщик
|
### Автоматический планировщик
|
||||||
|
|
||||||
- **Автозапуск**: Парсер автоматически запускается каждые 30 минут
|
- **Автозапуск**: Оба парсера автоматически запускаются каждые 30 минут
|
||||||
- **Cron выражение**: `0 0/30 * * * ?` (запуск в 0 и 30 минут каждого часа)
|
- **Cron выражение**: `0 0/30 * * * ?` (запуск в 0 и 30 минут каждого часа)
|
||||||
|
- **Параллельная работа**: KursivParserService и KapitalParserService работают независимо
|
||||||
- **Логирование**: Все автоматические запуски логируются с эмодзи для удобства мониторинга
|
- **Логирование**: Все автоматические запуски логируются с эмодзи для удобства мониторинга
|
||||||
- **Обработка ошибок**: Ошибки в автоматическом режиме не останавливают планировщик
|
- **Обработка ошибок**: Ошибки в автоматическом режиме не останавливают планировщик
|
||||||
|
|
||||||
|
### Многопарсерная архитектура
|
||||||
|
|
||||||
|
- **Общая модель данных**: Оба парсера используют одну модель `MarketItem`
|
||||||
|
- **Общая коллекция**: Все новости сохраняются в коллекцию `market_items`
|
||||||
|
- **Дедупликация**: Хэш генерируется одинаково для всех источников
|
||||||
|
- **Разделение источников**: Поле `source_name` позволяет различать источники данных
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
Запуск тестов:
|
Запуск тестов:
|
||||||
@@ -229,10 +280,12 @@ GET /api/parser/scheduler/info
|
|||||||
|
|
||||||
Тесты включают:
|
Тесты включают:
|
||||||
|
|
||||||
- Проверку парсинга RSS-ленты
|
- Проверку парсинга RSS-лент обоих источников
|
||||||
- Проверку сохранения в MongoDB
|
- Проверку сохранения в MongoDB
|
||||||
- Проверку дедупликации
|
- Проверку дедупликации
|
||||||
- Проверку структуры данных
|
- Проверку структуры данных
|
||||||
|
- Проверку работы планировщика
|
||||||
|
- Проверку многопарсерной архитектуры
|
||||||
|
|
||||||
## Мониторинг и логирование
|
## Мониторинг и логирование
|
||||||
|
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
**Задача:** Разработать парсер для RSS-ленты `https://kapital.kz/rss/` на Spring Boot с сохранением результатов в MongoDB.
|
||||||
|
|
||||||
|
**Контекст:** Парсер является частью AI-платформы для маркетинговой аналитики. Его цель — собирать деловые новости из источника Kapital.kz, нормализовать их и сохранять в общую базу данных для дальнейшей аналитической обработки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основные требования
|
||||||
|
|
||||||
|
1. **Получение данных**: Создать сервис, который выполняет HTTP GET-запрос к URL: `https://kapital.kz/rss/`.
|
||||||
|
2. **Парсинг RSS**: Разобрать полученный XML-ответ. Для каждой записи (элемента `<item>`) в RSS-ленте необходимо извлечь следующие поля:
|
||||||
|
- `title` (заголовок).
|
||||||
|
- `link` (URL статьи).
|
||||||
|
- `pubDate` (дата публикации).
|
||||||
|
- `description` (текстовое содержимое).
|
||||||
|
3. **Нормализация данных**:
|
||||||
|
- **Очистка текста**: Текстовое содержимое из поля `description` должно быть полностью очищено от HTML-тегов.
|
||||||
|
- **Формат даты**: Дата публикации должна быть приведена к формату `ISODate`.
|
||||||
|
4. **Создание хэша**: Для каждой новости необходимо сгенерировать уникальный хэш для дедупликации по формуле `sha256(url + "::" + title)`.
|
||||||
|
5. **Сохранение в MongoDB**:
|
||||||
|
- Подготовленные данные должны сохраняться в ту же коллекцию `market_items`.
|
||||||
|
- Операция сохранения должна быть **"upsert"** (обновить, если существует, или вставить, если нет), используя поле `hash` в качестве уникального ключа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Модель данных для коллекции `market_items` в MongoDB
|
||||||
|
|
||||||
|
Структура документа должна полностью соответствовать уже существующей модели.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source_name": "Kapital.kz (Бизнес)", // Статическое значение для этого парсера
|
||||||
|
"url": "https://kapital.kz/...", // Из поля <link>
|
||||||
|
"title": "Заголовок новости", // Из поля <title>
|
||||||
|
"published_at": ISODate("2025-09-14T..."), // Из поля <pubDate>
|
||||||
|
"added_at": ISODate("..."), // Текущее время при добавлении
|
||||||
|
"raw_text": "Очищенный от HTML текст статьи...", // Из поля <description>
|
||||||
|
"hash": "...", // Сгенерированный SHA-256 хэш
|
||||||
|
"category": "Бизнес", // Можно задать по умолчанию
|
||||||
|
|
||||||
|
// Блок для будущей аналитики
|
||||||
|
"analytics": {
|
||||||
|
"summary": null,
|
||||||
|
"sentiment": null,
|
||||||
|
"tags": [],
|
||||||
|
"entities": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технологический стек и реализация
|
||||||
|
|
||||||
|
- **Фреймворк**: Spring Boot
|
||||||
|
- **База данных**: Spring Data MongoDB
|
||||||
|
- **Реализация**: Рекомендуется создать новый класс-сервис (например, `KapitalParserService`) по аналогии с существующим `KursivParserService`.
|
||||||
|
- **Планирование**: Новый сервис также должен запускаться по общему расписанию — **каждые 30 минут**. Это можно сделать, добавив аннотацию `@Scheduled(cron = "0 0/30 * * * ?")` на метод парсинга в новом сервисе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критерии выполнения
|
||||||
|
|
||||||
|
- Создан новый Spring Boot сервис (`KapitalParserService`), который успешно парсит данные с `https://kapital.kz/rss/`.
|
||||||
|
- Приложение автоматически запускает оба парсера (Kursiv и Kapital) каждые 30 минут.
|
||||||
|
- Новости из Kapital.kz корректно сохраняются в общую коллекцию `market_items` без создания дубликатов.
|
||||||
|
- Структура сохраненных документов в MongoDB полностью соответствует указанной модели данных.
|
||||||
@@ -2,6 +2,7 @@ package kz.konturai.parser.controller;
|
|||||||
|
|
||||||
import kz.konturai.parser.model.MarketItem;
|
import kz.konturai.parser.model.MarketItem;
|
||||||
import kz.konturai.parser.service.KursivParserService;
|
import kz.konturai.parser.service.KursivParserService;
|
||||||
|
import kz.konturai.parser.service.KapitalParserService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -18,18 +19,22 @@ public class ParserController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private KursivParserService kursivParserService;
|
private KursivParserService kursivParserService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KapitalParserService kapitalParserService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запуск парсинга RSS-ленты
|
* Запуск парсинга RSS-ленты Kursiv
|
||||||
*/
|
*/
|
||||||
@PostMapping("/parse")
|
@PostMapping("/parse/kursiv")
|
||||||
public ResponseEntity<Map<String, Object>> parseRssFeed() {
|
public ResponseEntity<Map<String, Object>> parseKursivRssFeed() {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<MarketItem> savedItems = kursivParserService.parseAndSaveRssFeed();
|
List<MarketItem> savedItems = kursivParserService.parseAndSaveRssFeed();
|
||||||
|
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("message", "Парсинг завершен успешно");
|
response.put("message", "Парсинг Kursiv завершен успешно");
|
||||||
|
response.put("source", "Kursiv");
|
||||||
response.put("processedItems", savedItems.size());
|
response.put("processedItems", savedItems.size());
|
||||||
response.put("totalItemsInDb", kursivParserService.getTotalItemsCount());
|
response.put("totalItemsInDb", kursivParserService.getTotalItemsCount());
|
||||||
|
|
||||||
@@ -37,13 +42,73 @@ public class ParserController {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
response.put("success", false);
|
response.put("success", false);
|
||||||
response.put("message", "Ошибка при парсинге: " + e.getMessage());
|
response.put("message", "Ошибка при парсинге Kursiv: " + e.getMessage());
|
||||||
|
response.put("source", "Kursiv");
|
||||||
response.put("processedItems", 0);
|
response.put("processedItems", 0);
|
||||||
|
|
||||||
return ResponseEntity.internalServerError().body(response);
|
return ResponseEntity.internalServerError().body(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск парсинга RSS-ленты Kapital
|
||||||
|
*/
|
||||||
|
@PostMapping("/parse/kapital")
|
||||||
|
public ResponseEntity<Map<String, Object>> parseKapitalRssFeed() {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<MarketItem> savedItems = kapitalParserService.parseAndSaveRssFeed();
|
||||||
|
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "Парсинг Kapital завершен успешно");
|
||||||
|
response.put("source", "Kapital");
|
||||||
|
response.put("processedItems", savedItems.size());
|
||||||
|
response.put("totalItemsInDb", kapitalParserService.getKapitalItemsCount());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "Ошибка при парсинге Kapital: " + e.getMessage());
|
||||||
|
response.put("source", "Kapital");
|
||||||
|
response.put("processedItems", 0);
|
||||||
|
|
||||||
|
return ResponseEntity.internalServerError().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск парсинга всех RSS-лент
|
||||||
|
*/
|
||||||
|
@PostMapping("/parse/all")
|
||||||
|
public ResponseEntity<Map<String, Object>> parseAllRssFeeds() {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<MarketItem> kursivItems = kursivParserService.parseAndSaveRssFeed();
|
||||||
|
List<MarketItem> kapitalItems = kapitalParserService.parseAndSaveRssFeed();
|
||||||
|
|
||||||
|
int totalProcessed = kursivItems.size() + kapitalItems.size();
|
||||||
|
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "Парсинг всех источников завершен успешно");
|
||||||
|
response.put("kursivProcessed", kursivItems.size());
|
||||||
|
response.put("kapitalProcessed", kapitalItems.size());
|
||||||
|
response.put("totalProcessed", totalProcessed);
|
||||||
|
response.put("totalItemsInDb", kursivParserService.getTotalItemsCount());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "Ошибка при парсинге всех источников: " + e.getMessage());
|
||||||
|
response.put("totalProcessed", 0);
|
||||||
|
|
||||||
|
return ResponseEntity.internalServerError().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получение статистики по сохраненным записям
|
* Получение статистики по сохраненным записям
|
||||||
*/
|
*/
|
||||||
@@ -144,6 +209,8 @@ public class ParserController {
|
|||||||
response.put("cronExpression", "0 0/30 * * * ?");
|
response.put("cronExpression", "0 0/30 * * * ?");
|
||||||
response.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)");
|
response.put("description", "Запуск каждые 30 минут (в 0 и 30 минут каждого часа)");
|
||||||
response.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа");
|
response.put("nextRun", "Следующий запуск будет в ближайшие 0 или 30 минут часа");
|
||||||
|
response.put("activeParsers", List.of("KursivParserService", "KapitalParserService"));
|
||||||
|
response.put("sources", List.of("Kursiv Media", "Kapital.kz"));
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
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 KapitalParserService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(KapitalParserService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MarketItemRepository marketItemRepository;
|
||||||
|
|
||||||
|
@Value("${rss.kapital.url}")
|
||||||
|
private String rssFeedUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автоматический запуск парсинга каждые 30 минут
|
||||||
|
* Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0/5 * * * ?")
|
||||||
|
public void scheduledParseAndStoreNews() {
|
||||||
|
logger.info("🔄 Запуск планового парсинга новостей с Kapital.kz...");
|
||||||
|
try {
|
||||||
|
List<MarketItem> savedItems = parseAndSaveRssFeed();
|
||||||
|
logger.info("✅ Плановый парсинг Kapital.kz завершен. Обработано записей: {}", savedItems.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("❌ Ошибка при плановом парсинге Kapital.kz", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основной метод для парсинга RSS-ленты и сохранения данных в MongoDB
|
||||||
|
*/
|
||||||
|
public List<MarketItem> parseAndSaveRssFeed() {
|
||||||
|
List<MarketItem> savedItems = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("Начинаем парсинг RSS-ленты Kapital.kz: {}", rssFeedUrl);
|
||||||
|
|
||||||
|
// Получаем RSS-ленту
|
||||||
|
SyndFeed feed = getRssFeed();
|
||||||
|
|
||||||
|
if (feed == null) {
|
||||||
|
logger.error("Не удалось получить RSS-ленту Kapital.kz");
|
||||||
|
return savedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Получено {} записей из RSS-ленты Kapital.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("Ошибка при обработке записи Kapital.kz: {}", entry.getTitle(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Успешно обработано {} записей из Kapital.kz", savedItems.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при парсинге RSS-ленты Kapital.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("Пропускаем запись Kapital.kz с отсутствующими обязательными полями: title={}, url={}",
|
||||||
|
title, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем текст от HTML
|
||||||
|
String cleanText = cleanHtmlText(description);
|
||||||
|
|
||||||
|
// Преобразуем дату
|
||||||
|
LocalDateTime publishedAt = convertToLocalDateTime(entry.getPublishedDate());
|
||||||
|
|
||||||
|
// Генерируем хэш
|
||||||
|
String hash = generateHash(url, title);
|
||||||
|
|
||||||
|
// Создаем объект MarketItem с настройками для Kapital.kz
|
||||||
|
MarketItem marketItem = new MarketItem(url, title, publishedAt, cleanText, hash);
|
||||||
|
marketItem.setSourceName("Kapital.kz (Бизнес)");
|
||||||
|
marketItem.setCategory("Бизнес");
|
||||||
|
|
||||||
|
logger.debug("Обработана запись Kapital.kz: {}", title);
|
||||||
|
|
||||||
|
return marketItem;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при обработке записи RSS Kapital.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 Kapital.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("Ошибка при генерации хэша Kapital.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.setAddedAt(LocalDateTime.now()); // Обновляем время добавления
|
||||||
|
|
||||||
|
MarketItem savedItem = marketItemRepository.save(existingItem);
|
||||||
|
logger.debug("Обновлена существующая запись Kapital.kz: {}", marketItem.getTitle());
|
||||||
|
return savedItem;
|
||||||
|
} else {
|
||||||
|
// Создаем новую запись
|
||||||
|
MarketItem savedItem = marketItemRepository.save(marketItem);
|
||||||
|
logger.debug("Создана новая запись Kapital.kz: {}", marketItem.getTitle());
|
||||||
|
return savedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при сохранении записи Kapital.kz: {}", marketItem.getTitle(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статистики по сохраненным записям Kapital.kz
|
||||||
|
*/
|
||||||
|
public long getKapitalItemsCount() {
|
||||||
|
return marketItemRepository.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение всех сохраненных записей Kapital.kz
|
||||||
|
*/
|
||||||
|
public List<MarketItem> getAllKapitalItems() {
|
||||||
|
return marketItemRepository.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ public class KursivParserService {
|
|||||||
* Автоматический запуск парсинга каждые 30 минут
|
* Автоматический запуск парсинга каждые 30 минут
|
||||||
* Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа
|
* Cron выражение: "0 0/30 * * * ?" означает запуск в 0 и 30 минут каждого часа
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 0/30 * * * ?")
|
@Scheduled(cron = "0 0/5 * * * ?")
|
||||||
public void scheduledParseAndStoreNews() {
|
public void scheduledParseAndStoreNews() {
|
||||||
logger.info("🔄 Запуск планового парсинга новостей с Kursiv.media...");
|
logger.info("🔄 Запуск планового парсинга новостей с Kursiv.media...");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ spring.data.mongodb.host=92.38.48.166
|
|||||||
spring.data.mongodb.port=27017
|
spring.data.mongodb.port=27017
|
||||||
spring.data.mongodb.database=parser_db
|
spring.data.mongodb.database=parser_db
|
||||||
|
|
||||||
# RSS Feed URL
|
# RSS Feed URLs
|
||||||
rss.feed.url=https://kursiv.media/feed/
|
rss.feed.url=https://kursiv.media/feed/
|
||||||
|
rss.kapital.url=https://kapital.kz/rss/
|
||||||
|
|
||||||
# Scheduler Configuration
|
# Scheduler Configuration
|
||||||
spring.task.scheduling.pool.size=2
|
spring.task.scheduling.pool.size=2
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package kz.konturai.parser.service;
|
||||||
|
|
||||||
|
import kz.konturai.parser.model.MarketItem;
|
||||||
|
import kz.konturai.parser.repository.MarketItemRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"spring.data.mongodb.host=92.38.48.166",
|
||||||
|
"spring.data.mongodb.port=27017",
|
||||||
|
"spring.data.mongodb.database=parser_test_db",
|
||||||
|
"rss.kapital.url=https://kapital.kz/rss/"
|
||||||
|
})
|
||||||
|
public class KapitalParserServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KapitalParserService kapitalParserService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MarketItemRepository marketItemRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testParseAndSaveRssFeed() {
|
||||||
|
// Очищаем базу данных перед тестом
|
||||||
|
marketItemRepository.deleteAll();
|
||||||
|
|
||||||
|
// Запускаем парсинг
|
||||||
|
List<MarketItem> savedItems = kapitalParserService.parseAndSaveRssFeed();
|
||||||
|
|
||||||
|
// Проверяем результаты
|
||||||
|
assertNotNull(savedItems, "Список сохраненных элементов не должен быть null");
|
||||||
|
|
||||||
|
// Проверяем, что хотя бы одна запись была обработана
|
||||||
|
assertTrue(savedItems.size() > 0, "Должна быть обработана хотя бы одна запись из Kapital.kz");
|
||||||
|
|
||||||
|
// Проверяем структуру первой записи
|
||||||
|
MarketItem firstItem = savedItems.get(0);
|
||||||
|
assertNotNull(firstItem.getTitle(), "Заголовок не должен быть null");
|
||||||
|
assertNotNull(firstItem.getUrl(), "URL не должен быть null");
|
||||||
|
assertNotNull(firstItem.getHash(), "Хэш не должен быть null");
|
||||||
|
assertNotNull(firstItem.getPublishedAt(), "Дата публикации не должна быть null");
|
||||||
|
assertNotNull(firstItem.getRawText(), "Текст не должен быть null");
|
||||||
|
assertEquals("Kapital.kz (Бизнес)", firstItem.getSourceName(),
|
||||||
|
"Название источника должно быть Kapital.kz (Бизнес)");
|
||||||
|
assertEquals("Бизнес", firstItem.getCategory(), "Категория должна быть Бизнес");
|
||||||
|
|
||||||
|
// Проверяем, что запись сохранилась в базе данных
|
||||||
|
long totalCount = marketItemRepository.count();
|
||||||
|
assertTrue(totalCount > 0, "В базе данных должны быть записи");
|
||||||
|
|
||||||
|
// Проверяем дедупликацию - повторный запуск не должен создавать дубликаты
|
||||||
|
List<MarketItem> secondRun = kapitalParserService.parseAndSaveRssFeed();
|
||||||
|
long secondCount = marketItemRepository.count();
|
||||||
|
|
||||||
|
// Количество записей должно остаться тем же или увеличиться незначительно
|
||||||
|
assertTrue(secondCount >= totalCount, "Количество записей не должно уменьшиться");
|
||||||
|
|
||||||
|
System.out.println("Тест KapitalParserService прошел успешно:");
|
||||||
|
System.out.println("- Обработано записей в первом запуске: " + savedItems.size());
|
||||||
|
System.out.println("- Обработано записей во втором запуске: " + secondRun.size());
|
||||||
|
System.out.println("- Общее количество записей в БД: " + secondCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testScheduledMethodExists() {
|
||||||
|
// Проверяем, что метод scheduledParseAndStoreNews существует и доступен
|
||||||
|
assertDoesNotThrow(() -> {
|
||||||
|
// Используем рефлексию для проверки существования метода
|
||||||
|
kapitalParserService.getClass().getMethod("scheduledParseAndStoreNews");
|
||||||
|
}, "Метод scheduledParseAndStoreNews должен существовать");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testScheduledMethodCanBeCalled() {
|
||||||
|
// Проверяем, что метод можно вызвать без ошибок
|
||||||
|
assertDoesNotThrow(() -> {
|
||||||
|
kapitalParserService.scheduledParseAndStoreNews();
|
||||||
|
}, "Метод scheduledParseAndStoreNews должен выполняться без ошибок");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetKapitalItemsCount() {
|
||||||
|
long count = kapitalParserService.getKapitalItemsCount();
|
||||||
|
assertTrue(count >= 0, "Количество записей должно быть неотрицательным");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetAllKapitalItems() {
|
||||||
|
List<MarketItem> items = kapitalParserService.getAllKapitalItems();
|
||||||
|
assertNotNull(items, "Список элементов не должен быть null");
|
||||||
|
|
||||||
|
// Проверяем структуру элементов
|
||||||
|
for (MarketItem item : items) {
|
||||||
|
assertNotNull(item.getId(), "ID элемента не должен быть null");
|
||||||
|
assertNotNull(item.getHash(), "Хэш элемента не должен быть null");
|
||||||
|
assertNotNull(item.getSourceName(), "Название источника не должно быть null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user