v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user