From a3fe76326ecaf08dc818551ec813581ba6dfb7e8 Mon Sep 17 00:00:00 2001 From: sanjeok77 <1+sanjeok77@noreply.localhost> Date: Thu, 20 Aug 2026 23:16:29 +0000 Subject: [PATCH] =?UTF-8?q?v0.2.4:=20Add=20ArcaLive=20(=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C)=20hotdeal=20support=20&=20settings?= =?UTF-8?q?=20toggle=20&=20main=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/scraper/ArcaLiveScraper.kt | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 app/src/main/java/com/hotdeal/alarm/data/remote/scraper/ArcaLiveScraper.kt diff --git a/app/src/main/java/com/hotdeal/alarm/data/remote/scraper/ArcaLiveScraper.kt b/app/src/main/java/com/hotdeal/alarm/data/remote/scraper/ArcaLiveScraper.kt new file mode 100644 index 0000000..cd1ec3f --- /dev/null +++ b/app/src/main/java/com/hotdeal/alarm/data/remote/scraper/ArcaLiveScraper.kt @@ -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> = 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() + val seenPostIds = mutableSetOf() + + // 아카라이브 게시글 행 선택 (.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" + } +}