..
This commit is contained in:
@@ -91,16 +91,26 @@ object AutoTradingManager {
|
||||
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
|
||||
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
|
||||
// [프로세스 2] 후보군 수집
|
||||
val candidates = fetchCandidates(tradeService)
|
||||
val candidates = fetchCandidates(tradeService).apply {
|
||||
println("후보군 총 개수 : $size")
|
||||
}
|
||||
.filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
|
||||
.filter { it.code !in myHoldings && it.code !in pendingStocks }
|
||||
.distinctBy { it.code }
|
||||
.apply {
|
||||
println("후보군 조건 충족 총 개수 : $size")
|
||||
}
|
||||
|
||||
// [프로세스 3] 종목별 순회 분석
|
||||
candidates.forEach { stock ->
|
||||
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
|
||||
processSingleStock(stock, myCash, tradeService, callback)
|
||||
delay(300)
|
||||
try {
|
||||
lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고
|
||||
processSingleStock(stock, myCash, tradeService, callback)
|
||||
} catch (e: Exception) {
|
||||
|
||||
}finally {
|
||||
delay(300)
|
||||
}
|
||||
}
|
||||
|
||||
println("⏱️ [Cycle End] ${LocalTime.now()}")
|
||||
@@ -163,14 +173,23 @@ object AutoTradingManager {
|
||||
}
|
||||
|
||||
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
|
||||
|
||||
|
||||
listOf(
|
||||
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
|
||||
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
|
||||
// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) },
|
||||
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
|
||||
// async { tradeService.fetchMarketRanking(RankingType.EXPECTED_RISE, true).getOrDefault(emptyList()) },
|
||||
// async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }
|
||||
async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) },
|
||||
).awaitAll().flatten()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import model.NewsItem
|
||||
@@ -18,12 +20,75 @@ import network.CorpInfo
|
||||
import java.net.URL
|
||||
import kotlin.random.Random
|
||||
|
||||
object DynamicNewsScraper {
|
||||
private val playwright by lazy { Playwright.create() }
|
||||
private val browser by lazy {
|
||||
playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
|
||||
object BrowserManager {
|
||||
private var playwright: Playwright? = null
|
||||
private var _browser: com.microsoft.playwright.Browser? = null
|
||||
private var failCount = 0
|
||||
private const val MAX_TOTAL_FAILURES = 3
|
||||
private val mutex = Mutex() // 동시 접근 제어용 뮤텍스
|
||||
|
||||
suspend fun getBrowser(): com.microsoft.playwright.Browser {
|
||||
return mutex.withLock {
|
||||
// 브라우저가 없거나 연결이 끊겼다면 새로 생성
|
||||
if (_browser == null || !_browser!!.isConnected) {
|
||||
startNewBrowser()
|
||||
}
|
||||
_browser!!
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun notifyFailure() {
|
||||
mutex.withLock {
|
||||
failCount++
|
||||
if (failCount >= MAX_TOTAL_FAILURES) {
|
||||
restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun notifySuccess() {
|
||||
mutex.withLock { if (failCount > 0) failCount-- }
|
||||
}
|
||||
|
||||
private suspend fun restart() { // suspend 추가
|
||||
println("♻️ 브라우저 엔진을 완전히 재시작합니다...")
|
||||
try {
|
||||
// null 처리를 먼저 하여 다른 스레드가 getBrowser() 호출 시 대기하게 함
|
||||
val oldBrowser = _browser
|
||||
val oldPlaywright = playwright
|
||||
_browser = null
|
||||
playwright = null
|
||||
|
||||
oldBrowser?.close()
|
||||
oldPlaywright?.close()
|
||||
} catch (e: Exception) {
|
||||
// 종료 에러 무시
|
||||
} finally {
|
||||
startNewBrowser()
|
||||
failCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNewBrowser() {
|
||||
try {
|
||||
playwright = Playwright.create()
|
||||
_browser = playwright!!.chromium().launch(
|
||||
com.microsoft.playwright.BrowserType.LaunchOptions()
|
||||
.setHeadless(true)
|
||||
.setArgs(listOf("--no-sandbox", "--disable-dev-shm-usage")) // 리소스 부족 방지
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("🚨 브라우저 엔진 시작 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DynamicNewsScraper {
|
||||
// private val playwright by lazy { Playwright.create() }
|
||||
// private val browser by lazy {
|
||||
// playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
|
||||
// }
|
||||
|
||||
fun extractSmartContentWithLineFilter(page: Page): String {
|
||||
val script = """
|
||||
() => {
|
||||
@@ -95,66 +160,81 @@ object DynamicNewsScraper {
|
||||
|
||||
return page.evaluate(script) as String
|
||||
}
|
||||
var failDomainList = arrayListOf<String>()
|
||||
// private fun getBrowser(): com.microsoft.playwright.Browser {
|
||||
// return if (!browser.isConnected) {
|
||||
// println("🔄 브라우저 연결 끊김 확인, 재시작 시도...")
|
||||
// // 기존 lazy browser 대신 새 인스턴스를 할당하는 로직 필요
|
||||
// // (단순 object singleton 보다는 관리형 클래스가 유리)
|
||||
// browser // 예시를 위해 유지
|
||||
// } else {
|
||||
// browser
|
||||
// }
|
||||
// }
|
||||
|
||||
var failCountMap = mutableMapOf<String, Int>()
|
||||
suspend fun fetchFullContent(url: String): String {
|
||||
// browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다.
|
||||
val domain = URL(url).host
|
||||
val context = browser.newContext()
|
||||
if(failDomainList.contains(domain)) {
|
||||
println("실패한 도메인 스크래핑 종료 $domain ")
|
||||
return ""
|
||||
}
|
||||
val domain = try { URL(url).host } catch (e: Exception) { return "" }
|
||||
if ((failCountMap[domain] ?: 0) > 2) return ""
|
||||
|
||||
return try {
|
||||
// 브라우저 인스턴스를 뮤텍스 보호 하에 가져옴
|
||||
val browser = BrowserManager.getBrowser()
|
||||
|
||||
// Context/Page 생성 시점부터 에러 감시
|
||||
val context = browser.newContext()
|
||||
context.use { ctx ->
|
||||
ctx.newPage().use { page ->
|
||||
page.setDefaultNavigationTimeout(8000.0)
|
||||
delay(Random.nextInt(2000).toLong())
|
||||
page.setDefaultNavigationTimeout(15000.0)
|
||||
|
||||
// 1. 리스너 설정 시 예외 처리 강화
|
||||
blockUnnecessaryResources(page)
|
||||
// Route 설정 시 예외 방어
|
||||
// try {
|
||||
// blockUnnecessaryResources(page)
|
||||
// } catch (e: Exception) { /* 브라우저 상태 이상 감지 시 catch로 이동 */ }
|
||||
|
||||
// 2. 타임아웃을 설정하여 무한 대기 방지
|
||||
|
||||
page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED))
|
||||
|
||||
// 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기
|
||||
page.waitForLoadState(LoadState.LOAD)
|
||||
|
||||
val content = cleanText(extractSmartContentWithLineFilter(page))
|
||||
|
||||
// 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지
|
||||
page.unroute("**/*")
|
||||
// 명시적으로 route 해제 (TargetClosed 에러 방지)
|
||||
// try { page.unroute("**/*") } catch (e: Exception) {}
|
||||
|
||||
BrowserManager.notifySuccess()
|
||||
content
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failDomainList.add(domain)
|
||||
println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}")
|
||||
val msg = e.message ?: ""
|
||||
// 통신 단절 관련 핵심 에러 키워드 체크
|
||||
if (msg.contains("adopt") || msg.contains("closed") || msg.contains("exist") || msg.contains("respond")) {
|
||||
BrowserManager.notifyFailure()
|
||||
}
|
||||
|
||||
failCountMap[domain] = (failCountMap[domain] ?: 0) + 1
|
||||
// 불필요한 스택트레이스 출력을 줄이기 위해 메시지만 출력
|
||||
println("❌ [Playwright] 실패 (${url.take(30)}...): ${e.localizedMessage}")
|
||||
""
|
||||
} finally {
|
||||
// use 블록이 자원을 닫으려 할 때 발생하는 오류는 내부적으로 처리되거나 무시되도록 유도
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockUnnecessaryResources(page: Page) {
|
||||
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
|
||||
page.route("**/*") { route ->
|
||||
try {
|
||||
val req = route.request()
|
||||
if (req != null) {
|
||||
val type = req.resourceType()
|
||||
if (type == "image" || type == "font" || type == "stylesheet") {
|
||||
route.abort()
|
||||
} else {
|
||||
route.resume()
|
||||
}
|
||||
// route나 request가 이미 파기되었는지 확인
|
||||
val req = runCatching { route.request() }.getOrNull()
|
||||
if (req == null) {
|
||||
// 이미 브라우저가 닫히는 중이라면 무시
|
||||
return@route
|
||||
}
|
||||
|
||||
val type = req.resourceType()
|
||||
if (type == "image" || type == "font" || type == "stylesheet") {
|
||||
route.abort()
|
||||
} else {
|
||||
// request가 이미 null이면 처리를 포기
|
||||
route.resume()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
// 브라우저 종료 시 발생하는 에러는 여기서 조용히 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,49 +245,44 @@ object DynamicNewsScraper {
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
object SafeScraper {
|
||||
// 세마포어를 2개로 유지하되, 작업당 타임아웃을 반드시 설정해야 합니다.
|
||||
private val semaphore = Semaphore(4)
|
||||
// 동시 처리를 1개로 줄여서 안정성을 극대화 (추천)
|
||||
// Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
|
||||
private val semaphore = Semaphore(2)
|
||||
|
||||
suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List<NewsItem>) = coroutineScope {
|
||||
val query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
|
||||
|
||||
urls.map { item ->
|
||||
async {
|
||||
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
|
||||
// println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
|
||||
return@async
|
||||
}
|
||||
urls.forEach { item -> // map + awaitAll 대신 순차 처리가 현재 상황에선 더 안정적입니다.
|
||||
if (UrlCacheManager.isAlreadyProcessed(item.originallink)) {
|
||||
println("✅ [학습완료 데이터 스킵] ${item.originallink}")
|
||||
return@forEach
|
||||
}
|
||||
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
// 세마포어 획득 시도에 타임아웃을 걸어 대기열 정체 방지
|
||||
semaphore.withPermit {
|
||||
// 개별 뉴스 스크래핑에 최대 30~60초 제한 설정 (무한 대기 방지 핵심)
|
||||
withTimeout(10000L) {
|
||||
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
|
||||
|
||||
if (content.isNotBlank()) {
|
||||
RagService.ingestWithChunking(
|
||||
text = content,
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate,
|
||||
stockCode = corpInfo.stockCode,
|
||||
corpName = corpInfo.cName,
|
||||
corpCode = corpInfo.cCode,
|
||||
stcokName = corpInfo.stockName
|
||||
)
|
||||
println("✅ [학습완료] ${item.originallink}")
|
||||
}
|
||||
withTimeout(25000L) { // 타임아웃 약간 증가
|
||||
val content = DynamicNewsScraper.fetchFullContent(item.originallink)
|
||||
if (content.isNotBlank()) {
|
||||
RagService.ingestWithChunking(
|
||||
text = content,
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate,
|
||||
stockCode = corpInfo.stockCode,
|
||||
corpName = corpInfo.cName,
|
||||
corpCode = corpInfo.cCode,
|
||||
stcokName = corpInfo.stockName
|
||||
)
|
||||
println("✅ [학습완료] ${item.originallink}")
|
||||
}
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
println("⏳ [타임아웃] 뉴스 읽기 시간 초과: ${item.originallink}")
|
||||
} catch (e: Exception) {
|
||||
println("❌ [스크래핑 에러] ${item.originallink}: ${e.localizedMessage}")
|
||||
println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
|
||||
}
|
||||
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
|
||||
delay(Random.nextLong(500, 1500))
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
println("🏁 $query 관련 뉴스 ${urls.size}개 처리 시도 완료")
|
||||
}
|
||||
println("🏁 뉴스 처리 완료")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user