From 34ba7b5a0f470cc05a00781c26b7cd0851282a91 Mon Sep 17 00:00:00 2001 From: sanjeok77 <1+sanjeok77@noreply.localhost> Date: Tue, 25 Aug 2026 02:11:02 +0000 Subject: [PATCH] v0.3.5: add HotDealScraperManager.kt --- .../domain/usecase/HotDealScraperManager.kt | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 app/src/main/java/com/hotdeal/alarm/domain/usecase/HotDealScraperManager.kt diff --git a/app/src/main/java/com/hotdeal/alarm/domain/usecase/HotDealScraperManager.kt b/app/src/main/java/com/hotdeal/alarm/domain/usecase/HotDealScraperManager.kt new file mode 100644 index 0000000..a552e3a --- /dev/null +++ b/app/src/main/java/com/hotdeal/alarm/domain/usecase/HotDealScraperManager.kt @@ -0,0 +1,175 @@ +package com.hotdeal.alarm.domain.usecase + +import android.content.Context +import android.util.Log +import com.hotdeal.alarm.data.local.db.dao.HotDealDao +import com.hotdeal.alarm.data.local.db.dao.KeywordDao +import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao +import com.hotdeal.alarm.data.local.db.entity.HotDealEntity +import com.hotdeal.alarm.data.local.db.entity.KeywordEntity +import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity +import com.hotdeal.alarm.data.remote.scraper.ScraperFactory +import com.hotdeal.alarm.domain.model.HotDeal +import com.hotdeal.alarm.service.NotificationService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 핫딜 스크래핑, DB 저장, 키워드 매칭, 알림 발송을 총괄하는 핵심 매니저 + * + * 포그라운드(앱 진입/새로고침), 백그라운드(AlarmReceiver), WorkManager 모두에서 + * 공통으로 직접 호출하여 지연 없이 즉시 수집 및 알림을 발송합니다. + */ +@Singleton +class HotDealScraperManager @Inject constructor( + @ApplicationContext private val context: Context, + private val scraperFactory: ScraperFactory, + private val notificationService: NotificationService, + private val dealDao: HotDealDao, + private val siteConfigDao: SiteConfigDao, + private val keywordDao: KeywordDao +) { + companion object { + private const val TAG = "HotDealScraperManager" + } + + // 중복 동시 실행 방지 뮤텍스 + private val mutex = Mutex() + + /** + * 핫딜 수집 및 키워드 알림 발송 전체 프로세스 실행 + * @return 수집된 새로운 핫딜 목록 + */ + suspend fun fetchAndProcessDeals(): List = withContext(Dispatchers.IO) { + mutex.withLock { + try { + Log.d(TAG, "===== 🚀 핫딜 수집 프로세스 시작 =====") + + // 1. 활성화된 사이트 설정 조회 + val enabledConfigs = siteConfigDao.getEnabledConfigs() + Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}개") + + if (enabledConfigs.isEmpty()) { + Log.w(TAG, "활성화된 사이트 설정이 없습니다.") + return@withContext emptyList() + } + + // 2. 활성화된 키워드 조회 + val keywords = keywordDao.getEnabledKeywords() + Log.d(TAG, "활성화된 키워드: ${keywords.size}개 (${keywords.map { it.keyword }})") + + // 3. 각 사이트/게시판 병렬 스크래핑 + val allDeals = enabledConfigs.map { config -> + async { scrapeSite(config) } + }.awaitAll().flatten() + + Log.d(TAG, "스크래핑 완료: 총 ${allDeals.size}개 핫딜 수집됨") + if (allDeals.isEmpty()) { + return@withContext emptyList() + } + + // 4. 새로운 핫딜 필터링 (기존 DB에 없는 것) + val newDeals = filterNewDeals(allDeals) + Log.d(TAG, "새로운 핫딜: ${newDeals.size}개") + + // 5. 인기글 상태 일괄 업데이트 (기존 + 신규 모두) + val popularDealIds = allDeals.filter { it.isPopular }.map { it.id } + if (popularDealIds.isNotEmpty()) { + dealDao.markAsPopular(popularDealIds) + Log.d(TAG, "🔥 인기글 마킹: ${popularDealIds.size}개") + } + + if (newDeals.isNotEmpty()) { + // 6. 키워드 매칭 + val keywordMatchedDeals = if (keywords.isNotEmpty()) { + filterByKeywords(newDeals, keywords).also { + Log.d(TAG, "⭐ 키워드 매칭 핫딜: ${it.size}개 -> ${it.map { d -> d.title }}") + } + } else { + emptyList() + } + + // 7. DB에 새 핫딜 저장 + var insertedCount = 0 + newDeals.forEach { deal -> + try { + val entity = HotDealEntity.fromDomain(deal) + dealDao.insertDeal(entity) + insertedCount++ + } catch (e: Exception) { + Log.w(TAG, "핫딜 저장 실패: ${deal.id}") + } + } + Log.d(TAG, "DB 저장 완료: $insertedCount 개") + + // 8. 키워드 매칭 알림 즉시 발송! (시스템바 노티피케이션) + if (keywordMatchedDeals.isNotEmpty()) { + Log.d(TAG, "🔔 키워드 매칭 알림 발송: ${keywordMatchedDeals.size}개") + notificationService.showKeywordMatchNotification(keywordMatchedDeals) + dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id }) + } + + // 9. 알림 발송 상태 업데이트 + dealDao.markAsNotified(newDeals.map { it.id }) + } + + // 10. 오래된 데이터 정리 (3일 이상) + val threshold = System.currentTimeMillis() - (3 * 24 * 60 * 60 * 1000L) + dealDao.deleteOldDeals(threshold) + + Log.d(TAG, "===== ✅ 핫딜 수집 프로세스 종료 =====") + newDeals + } catch (e: Exception) { + Log.e(TAG, "핫딜 수집 오류: ${e.message}", e) + emptyList() + } + } + } + + private suspend fun scrapeSite(config: SiteConfigEntity): List { + val scraper = scraperFactory.getScraper(config.siteName) ?: return emptyList() + val result = scraper.scrape(config.boardName) + + result.fold( + onSuccess = { deals -> + try { + siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis()) + } catch (e: Exception) { + // 무시 + } + }, + onFailure = { error -> + Log.e(TAG, "${config.siteName}/${config.boardName} 실패: ${error.message}") + } + ) + + return result.getOrElse { emptyList() } + } + + private suspend fun filterNewDeals(deals: List): List { + return deals.filter { deal -> + val existingByUrl = dealDao.getDealByUrl(deal.url) + if (existingByUrl != null) return@filter false + + val existingById = dealDao.getDealById(deal.id) + if (existingById != null) return@filter false + + true + } + } + + private fun filterByKeywords(deals: List, keywords: List): List { + return deals.filter { deal -> + keywords.any { keyword -> + deal.title.contains(keyword.keyword, ignoreCase = true) + } + } + } +}