Fix keyword alarm and add visual indicators

- Fix: Only keyword-matched deals send notifications
- Add: Visual indicator for keyword-matched deals (badge + border)
- Add: Polling interval setting with persistence
- Add: Version check in settings
- Add: Update dialog on app start
This commit is contained in:
sanjeok77
2026-03-04 01:57:25 +09:00
parent c22cfafe88
commit f9b4a4a90f
6 changed files with 214 additions and 145 deletions
@@ -0,0 +1,40 @@
package com.hotdeal.alarm.data.local.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* 앱 설정 저장 (DataStore)
*/
class AppSettings(private val context: Context) {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "app_settings")
companion object {
private val POLLING_INTERVAL_KEY = intPreferencesKey("polling_interval_minutes")
private const val DEFAULT_INTERVAL = 2
}
/**
* 폴 주기 (분)
*/
val pollingInterval: Flow<Int> = context.dataStore.data
.map { preferences ->
preferences[POLLING_INTERVAL_KEY] ?: DEFAULT_INTERVAL
}
/**
* 폴 주기 설정 저장
*/
suspend fun setPollingInterval(minutes: Int) {
context.dataStore.edit { preferences ->
preferences[POLLING_INTERVAL_KEY] = minutes
}
}
}
@@ -6,6 +6,7 @@ import com.hotdeal.alarm.data.local.db.AppDatabase
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.preferences.AppSettings
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -52,4 +53,12 @@ object DatabaseModule {
fun provideKeywordDao(database: AppDatabase): KeywordDao {
return database.keywordDao()
}
@Provides
@Singleton
fun provideAppSettings(
@ApplicationContext context: Context
): AppSettings {
return AppSettings(context)
}
}
@@ -2,6 +2,7 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -9,14 +10,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.domain.model.HotDeal
@@ -24,7 +27,6 @@ import com.hotdeal.alarm.ui.theme.Spacing
import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealItem(
deal: HotDeal,
@@ -45,10 +47,34 @@ fun DealItem(
val siteColor = getSiteColor(deal.siteType)
// 키워드 매칭된 핫딜은 다른 색상으로 강조
val cardColor = if (deal.isKeywordMatch) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
}
val borderColor = if (deal.isKeywordMatch) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
} else {
Color.Transparent
}
val cardModifier = if (deal.isKeywordMatch) {
modifier
.fillMaxWidth()
.border(2.dp, borderColor, MaterialTheme.shapes.large)
} else {
modifier.fillMaxWidth()
}
ElevatedCard(
modifier = modifier.fillMaxWidth(),
modifier = cardModifier,
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.elevatedCardColors(containerColor = cardColor),
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = if (deal.isKeywordMatch) 4.dp else 2.dp
),
onClick = onClick
) {
Column(
@@ -88,25 +114,43 @@ fun DealItem(
Spacer(modifier = Modifier.width(Spacing.sm))
// 게시판 이름 (ppomppu8 → 알리뽐뿌)
// 게시판 이름
Text(
text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (deal.isKeywordMatch) {
Spacer(modifier = Modifier.width(Spacing.xs))
Icon(
imageVector = Icons.Default.Star,
contentDescription = "키워드 매칭",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 배지 (있을 때만)
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(26.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.NotificationsActive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(14.dp)
)
Text(
text = "키워드 매칭",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Spacer(modifier = Modifier.width(Spacing.xs))
}
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
@@ -139,10 +183,14 @@ fun DealItem(
Spacer(modifier = Modifier.height(Spacing.sm))
// 제목
// 제목 (키워드 매칭 시 굵게)
Text(
text = deal.title,
style = MaterialTheme.typography.bodyLarge,
style = if (deal.isKeywordMatch) {
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
@@ -6,6 +6,7 @@ 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.SiteConfigEntity
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,12 +19,17 @@ class MainViewModel @Inject constructor(
private val hotDealDao: HotDealDao,
private val siteConfigDao: SiteConfigDao,
private val keywordDao: KeywordDao,
private val workerScheduler: WorkerScheduler
private val workerScheduler: WorkerScheduler,
private val appSettings: AppSettings
) : ViewModel() {
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
// 폴 주기
val pollingInterval: StateFlow<Int> = appSettings.pollingInterval
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2)
init {
initializeApp()
}
@@ -32,6 +38,9 @@ class MainViewModel @Inject constructor(
viewModelScope.launch {
initializeDefaultSiteConfigs()
loadState()
// 저장된 폴 주기로 시작
val savedInterval = appSettings.pollingInterval.first()
startPolling(savedInterval.toLong())
}
}
@@ -105,8 +114,14 @@ class MainViewModel @Inject constructor(
workerScheduler.executeOnce()
}
/**
* 폴 시작 (주기 저장)
*/
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
workerScheduler.schedulePeriodicPolling(intervalMinutes)
viewModelScope.launch {
appSettings.setPollingInterval(intervalMinutes.toInt())
workerScheduler.schedulePeriodicPolling(intervalMinutes)
}
}
fun stopPolling() {
@@ -26,7 +26,6 @@ import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel
import com.hotdeal.alarm.util.PermissionHelper
import com.hotdeal.alarm.util.VersionManager
import com.hotdeal.alarm.worker.WorkerScheduler
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -35,6 +34,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val currentPollingInterval by viewModel.pollingInterval.collectAsState()
var showPermissionDialog by remember { mutableStateOf(false) }
var permissionDialogTitle by remember { mutableStateOf("") }
@@ -47,28 +47,23 @@ fun SettingsScreen(viewModel: MainViewModel) {
if (!isGranted) {
permissionDialogTitle = "알림 권한 필요"
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다."
permissionDialogAction = {
PermissionHelper.openNotificationSettings(context)
}
permissionDialogAction = { PermissionHelper.openNotificationSettings(context) }
showPermissionDialog = true
}
}
val permissionStatus = PermissionHelper.checkAllPermissions(context)
val hasEnabledSites = (uiState as? MainUiState.Success)
?.siteConfigs?.any { it.isEnabled } ?: false
val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
PermissionStatusSection(
PermissionCard(
permissionStatus = permissionStatus,
hasEnabledSites = hasEnabledSites,
onRequestNotificationPermission = {
onRequestPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
@@ -77,29 +72,26 @@ fun SettingsScreen(viewModel: MainViewModel) {
}
item {
PollingIntervalSection(
currentInterval = 2,
PollingIntervalCard(
currentInterval = currentPollingInterval,
onIntervalChange = { minutes ->
viewModel.stopPolling()
viewModel.startPolling(minutes)
Toast.makeText(context, "${minutes}분으로 변경됨", Toast.LENGTH_SHORT).show()
}
)
}
item {
Text("사이트 선택", style = MaterialTheme.typography.titleMedium)
}
item { Text("사이트 선택", style = MaterialTheme.typography.titleMedium) }
when (val state = uiState) {
is MainUiState.Success -> {
SiteType.entries.forEach { site ->
item {
SiteSection(
SiteCard(
siteType = site,
configs = state.siteConfigs.filter { it.siteName == site.name },
onToggle = { key, enabled ->
viewModel.toggleSiteConfig(key, enabled)
}
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
)
}
}
@@ -111,10 +103,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
item {
var keywordText by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = keywordText,
onValueChange = { keywordText = it },
@@ -144,16 +133,14 @@ fun SettingsScreen(viewModel: MainViewModel) {
)
}
// 버전 정보 섹션
item {
Spacer(modifier = Modifier.height(24.dp))
VersionSection(
VersionCard(
currentVersion = VersionManager.getCurrentVersion(context),
onCheckUpdate = {
scope.launch {
val currentCode = VersionManager.getCurrentVersionCode(context)
val remoteInfo = VersionManager.checkForUpdate()
if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) {
Toast.makeText(context, "새 버전 ${remoteInfo.version} 사용 가능", Toast.LENGTH_LONG).show()
} else {
@@ -164,9 +151,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
)
}
}
else -> {
item { CircularProgressIndicator() }
}
else -> { item { CircularProgressIndicator() } }
}
}
@@ -181,33 +166,47 @@ fun SettingsScreen(viewModel: MainViewModel) {
}
@Composable
fun VersionSection(currentVersion: String, onCheckUpdate: () -> Unit) {
fun PermissionCard(
permissionStatus: PermissionHelper.PermissionStatus,
hasEnabledSites: Boolean,
onRequestPermission: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
}
Button(onClick = onCheckUpdate) {
Text("업데이트 확인")
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(12.dp))
Text("알림 권한", modifier = Modifier.weight(1f))
if (!permissionStatus.hasNotificationPermission) {
Button(onClick = onRequestPermission) { Text("설정") }
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(12.dp))
Text("사이트 선택")
}
}
}
}
@Composable
fun PollingIntervalSection(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
var selected by remember { mutableStateOf(currentInterval) }
fun PollingIntervalCard(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
var selected by remember(currentInterval) { mutableStateOf(currentInterval) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(" 주기", style = MaterialTheme.typography.titleMedium)
Text("2분 권장 (배터리/데이터 절약)", style = MaterialTheme.typography.bodySmall)
Text("2분 권장", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -234,44 +233,7 @@ fun PollingIntervalSection(currentInterval: Int, onIntervalChange: (Long) -> Uni
}
@Composable
fun PermissionStatusSection(
permissionStatus: PermissionHelper.PermissionStatus,
hasEnabledSites: Boolean,
onRequestNotificationPermission: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(12.dp))
Text("알림 권한", modifier = Modifier.weight(1f))
if (!permissionStatus.hasNotificationPermission) {
Button(onClick = onRequestNotificationPermission) { Text("설정") }
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(12.dp))
Text("사이트 선택", modifier = Modifier.weight(1f))
}
}
}
}
@Composable
fun SiteSection(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) {
fun SiteCard(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall)
@@ -305,3 +267,22 @@ fun KeywordItem(keyword: Keyword, onToggle: () -> Unit, onDelete: () -> Unit) {
}
)
}
@Composable
fun VersionCard(currentVersion: String, onCheckUpdate: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
}
Button(onClick = onCheckUpdate) { Text("업데이트 확인") }
}
}
}
}
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
/**
* 핫딜 폴 Worker
*
* 키워드 매칭된 핫딜만 알림 발송
*/
@HiltWorker
class HotDealPollingWorker @AssistedInject constructor(
@@ -47,20 +49,13 @@ class HotDealPollingWorker @AssistedInject constructor(
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}")
if (enabledConfigs.isEmpty()) {
Log.w(TAG, "활성화된 사이트 설정이 없습니다.을 건뜁니다.")
Log.w(TAG, "활성화된 사이트 설정이 없습니다.")
return@withContext Result.success()
}
enabledConfigs.forEach { config ->
Log.d(TAG, " - 사이트: ${config.siteName}, 게시판: ${config.boardName}")
}
// 2. 활성화된 키워드 조회
val keywords = keywordDao.getEnabledKeywords()
Log.d(TAG, "활성화된 키워드: ${keywords.size}")
keywords.forEach { kw ->
Log.d(TAG, " - 키워드: ${kw.keyword}")
}
// 3. 각 사이트/게시판 스크래핑 (병렬)
Log.d(TAG, "스크래핑 시작...")
@@ -74,11 +69,10 @@ class HotDealPollingWorker @AssistedInject constructor(
if (allDeals.isEmpty()) {
Log.w(TAG, "스크래핑된 핫딜이 없습니다.")
// 스크래핑은 성공했지만 결과가 없는 경우도 success로 처리
return@withContext Result.success()
}
// 4. 새로운 핫딜 필터링 (URL과 ID 모두로 중복 체크)
// 4. 새로운 핫딜 필터링
val newDeals = filterNewDeals(allDeals)
Log.d(TAG, "새로운 핫딜: ${newDeals.size}")
@@ -87,15 +81,17 @@ class HotDealPollingWorker @AssistedInject constructor(
return@withContext Result.success()
}
newDeals.take(5).forEach { deal ->
Log.d(TAG, " 새 핫딜: ${deal.title.take(50)}...")
// 5. 키워드 매칭 (키워드가 있을 때만)
val keywordMatchedDeals = if (keywords.isNotEmpty()) {
filterByKeywords(newDeals, keywords).also {
Log.d(TAG, "키워드 매칭 핫딜: ${it.size}")
}
} else {
Log.d(TAG, "키워드가 없어 매칭 체크를 건뜁니다.")
emptyList()
}
// 5. 키워드 매칭
val keywordMatchedDeals = filterByKeywords(newDeals, keywords)
Log.d(TAG, "키워드 매칭 핫딜: ${keywordMatchedDeals.size}")
// 6. DB 저장 (개별 삽입으로 변경하여 충돌 처리 개선)
// 6. DB 저장
var insertedCount = 0
newDeals.forEach { deal ->
try {
@@ -103,19 +99,18 @@ class HotDealPollingWorker @AssistedInject constructor(
dealDao.insertDeal(entity)
insertedCount++
} catch (e: Exception) {
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}, 오류: ${e.message}")
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}")
}
}
Log.d(TAG, "DB 저장 완료: $insertedCount/${newDeals.size}개 저장됨")
// 7. 알림 발송
// 7. 키워드 매칭 알림 발송 (키워드 매칭된 것만!)
if (keywordMatchedDeals.isNotEmpty()) {
Log.d(TAG, "키워드 매칭 알림 발송...")
Log.d(TAG, "키워드 매칭 알림 발송: ${keywordMatchedDeals.size}")
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
} else {
Log.d(TAG, "새 핫딜 알림 발송...")
notificationService.showNewDealsNotification(newDeals.take(5))
Log.d(TAG, "키워드 매칭된 핫딜이 없어 알림 발송하지 않습니다.")
}
// 8. 알림 발송 상태 업데이트
@@ -124,7 +119,6 @@ class HotDealPollingWorker @AssistedInject constructor(
// 9. 오래된 데이터 정리 (7일 이상)
val threshold = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
dealDao.deleteOldDeals(threshold)
Log.d(TAG, "오래된 데이터 정리 완료")
Log.d(TAG, "===== 핫딜 폴 완료 =====")
Result.success()
@@ -138,16 +132,10 @@ class HotDealPollingWorker @AssistedInject constructor(
}
}
/**
* 포그라운드 서비스 정보 (빈번한 폴용)
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
return notificationService.createForegroundInfo()
}
/**
* 사이트 스크래핑
*/
private suspend fun scrapeSite(config: SiteConfigEntity): List<HotDeal> {
val scraper = scraperFactory.getScraper(config.siteName)
if (scraper == null) {
@@ -161,7 +149,6 @@ class HotDealPollingWorker @AssistedInject constructor(
result.fold(
onSuccess = { deals ->
Log.d(TAG, " ${config.siteName}: ${deals.size}개 핫딜 파싱됨")
// 스크래핑 성공 시 lastScrapedAt 업데이트
try {
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
} catch (e: Exception) {
@@ -176,17 +163,11 @@ class HotDealPollingWorker @AssistedInject constructor(
return result.getOrElse { emptyList() }
}
/**
* 새로운 핫딜 필터링
* URL과 ID 모두로 중복 체크
*/
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
return deals.filter { deal ->
// URL로 먼저 체크
val existingByUrl = dealDao.getDealByUrl(deal.url)
if (existingByUrl != null) return@filter false
// ID로도 체크 (추가 안전장치)
val existingById = dealDao.getDealById(deal.id)
if (existingById != null) return@filter false
@@ -194,15 +175,10 @@ class HotDealPollingWorker @AssistedInject constructor(
}
}
/**
* 키워드 매칭 필터링
*/
private suspend fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
if (keywords.isEmpty()) return emptyList()
private fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
return deals.filter { deal ->
keywords.any { keyword ->
keyword.toDomain().matches(deal.title)
deal.title.contains(keyword.keyword, ignoreCase = true)
}
}
}