v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter

This commit is contained in:
2026-08-20 23:16:29 +00:00
parent b1f840b482
commit a3fe76326e
@@ -0,0 +1,168 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 아카라이브 핫딜 채널 스크래퍼
*/
class ArcaLiveScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "arcalive"
override val baseUrl: String = "https://arca.live"
override fun getBoardUrl(board: String): String {
return "$baseUrl/b/$board"
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d(TAG, "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초)
val delayTime = Random.nextLong(2000, 4000)
Log.d(TAG, "요청 대기: ${delayTime}ms")
delay(delayTime)
val userAgent = getRandomUserAgent()
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://arca.live/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d(TAG, "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
val seenPostIds = mutableSetOf<String>()
// 아카라이브 게시글 행 선택 (.vrow 중 공지 및 헤더 제외)
val elements: Elements = doc.select(".list-table .vrow, .article-list .vrow, div.vrow, a.vrow")
Log.d(TAG, "찾은 행 요소: ${elements.size}")
var count = 0
for (row in elements) {
if (count >= 20) break
try {
// 공지사항 및 헤더 제외
if (row.hasClass("notice") ||
row.hasClass("notice-service") ||
row.hasClass("notice-board") ||
row.hasClass("head")
) {
continue
}
val titleElement = row.selectFirst("a.title") ?: continue
val href = titleElement.attr("href")
val postId = extractPostId(href)
if (postId.isEmpty() || !seenPostIds.add(postId)) {
continue
}
val dealUrl = resolveUrl(baseUrl, href)
// 쇼핑몰/스토어 정보, 카테고리 뱃지, 가격, 배송비 추출
val store = row.selectFirst(".deal-store")?.text()?.trim().orEmpty()
val badge = row.selectFirst(".badge")?.text()?.trim().orEmpty()
val price = row.selectFirst(".deal-price")?.text()
?.replace("\\s+".toRegex(), " ")?.trim().orEmpty()
// 제목 텍스트 정리 (댓글 수, 미디어 아이콘 등 제거)
val rawTitle = getCleanTitle(titleElement)
if (rawTitle.isEmpty()) continue
// 핫딜 조합 제목 생성 ([스토어] 제목 (가격))
val prefix = if (store.isNotEmpty()) "[$store]" else if (badge.isNotEmpty()) "[$badge]" else ""
val suffix = if (price.isNotEmpty()) "($price)" else ""
val fullTitle = buildString {
if (prefix.isNotEmpty() && !rawTitle.startsWith("[")) {
append(prefix).append(" ")
}
append(rawTitle)
if (suffix.isNotEmpty() && !rawTitle.contains(price)) {
append(" ").append(suffix)
}
}
// 추천수 파싱 (10개 이상이면 인기 핫딜)
val rateText = row.selectFirst(".col-rate")?.text()?.trim().orEmpty()
val rate = rateText.toIntOrNull() ?: 0
val isPopular = rate >= 10
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = fullTitle,
url = dealUrl,
createdAt = System.currentTimeMillis(),
isPopular = isPopular
)
deals.add(deal)
count++
val popularMark = if (isPopular) " [인기]" else ""
Log.d(TAG, "[$count]$popularMark $fullTitle")
} catch (e: Exception) {
Log.e(TAG, "파싱 에러: ${e.message}")
}
}
Log.d(TAG, "파싱 완료: ${deals.size}")
Result.success(deals)
} catch (e: Exception) {
Log.e(TAG, "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun getCleanTitle(titleElement: Element): String {
val cloned = titleElement.clone()
cloned.select(".info, .media-icon, .comment-count").remove()
return cloned.text().trim()
}
private fun extractPostId(href: String): String {
val fromBoard = href.substringAfter("/b/hotdeal/", "")
val postId = fromBoard.takeWhile { it.isDigit() }
if (postId.isNotEmpty()) return postId
val fromPath = href.substringAfterLast("/").substringBefore("?")
if (fromPath.isNotEmpty() && fromPath.all { it.isDigit() }) {
return fromPath
}
return ""
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0"
)
return userAgents.random()
}
companion object {
private const val TAG = "ArcaLive"
}
}