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