Development/Spring Boot3 (Kotlin)
[Kotlin][SpringBoot3] Kopring 서버 기본 실습 18 - 외부 api를 활용해 Scheduler로 정보 수집 실습 5 : yahoo finance에서 주요 환율 정보 크롤링해서 저장하기
Tradgineer
2023. 10. 16. 15:47
1. 이전 포스팅
https://growingsaja.tistory.com/989
2. 목표
- 야후 파이낸스에서 주요 환율 정보 크롤링 및 가공해서 저장하기
https://finance.yahoo.com/quote/KRW=X?p=KRW=X&.tsrc=fin-srch
fin-streamer 안에 주요 데이터들이 있습니다.
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="ES=F" data-field="regularMarketPrice" data-trend="none" value="4331.5" active="true">
4,331.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="ES=F" data-field="regularMarketChange" data-trend="txt" value="16.75" active="true">
<span class="C($positiveColor)">+16.75</span>
</fin-streamer>
<fin-streamer data-symbol="ES=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.38820326" active="true">
<span class="C($positiveColor)">(+0.39%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="YM=F" data-field="regularMarketPrice" data-trend="none" value="33984" active="true">
33,984.00
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="YM=F" data-field="regularMarketChange" data-trend="txt" value="109" active="true">
<span class="C($positiveColor)">+109.00</span>
</fin-streamer>
<fin-streamer data-symbol="YM=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.32177123" active="true">
<span class="C($positiveColor)">(+0.32%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="NQ=F" data-field="regularMarketPrice" data-trend="none" value="14770.5" active="true">
14,770.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="NQ=F" data-field="regularMarketChange" data-trend="txt" value="55.5" active="true">
<span class="C($positiveColor)">+55.50</span>
</fin-streamer>
<fin-streamer data-symbol="NQ=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.37716615" active="true">
<span class="C($positiveColor)">(+0.38%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="RTY=F" data-field="regularMarketPrice" data-trend="none" value="1786.5" active="true">
1,786.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="RTY=F" data-field="regularMarketChange" data-trend="txt" value="8.599976" active="true">
<span class="C($positiveColor)">+8.60</span>
</fin-streamer>
<fin-streamer data-symbol="RTY=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.48371536" active="true">
<span class="C($positiveColor)">(+0.48%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="CL=F" data-field="regularMarketPrice" data-trend="none" value="91.15" active="true">
91.15
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="CL=F" data-field="regularMarketChange" data-trend="txt" value="0.76000214" active="true">
<span class="C($positiveColor)">+0.76</span>
</fin-streamer>
<fin-streamer data-symbol="CL=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.8408033" active="true">
<span class="C($positiveColor)">(+0.84%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="GC=F" data-field="regularMarketPrice" data-trend="none" value="1915.1" active="true">
1,915.10
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="GC=F" data-field="regularMarketChange" data-trend="txt" value="-4.7000732" active="true">
<span class="C($negativeColor)">-4.70</span>
</fin-streamer>
<fin-streamer data-symbol="GC=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="-0.24482098" active="true">
<span class="C($negativeColor)">(-0.24%)</span>
</fin-streamer>
<fin-streamer class="Fw(b) Fz(36px) Mb(-4px) D(ib)" data-symbol="KRW=X" data-test="qsp-price" data-field="regularMarketPrice" data-trend="none" data-pricehint="4" value="1352.74" active="">
1,352.7400
</fin-streamer>
<fin-streamer class="Fw(500) Pstart(8px) Fz(24px)" data-symbol="KRW=X" data-test="qsp-price-change" data-field="regularMarketChange" data-trend="txt" data-pricehint="4" value="0.10998535" active="">
<span class="C($positiveColor)">+0.1100</span>
</fin-streamer>
<fin-streamer class="Fw(500) Pstart(8px) Fz(24px)" data-symbol="KRW=X" data-field="regularMarketChangePercent" data-trend="txt" data-pricehint="4" data-template="({fmt})" value="0.00008131223" active="">
<span class="C($positiveColor)">(+0.0081%)</span>
</fin-streamer>
<fin-streamer class="D(n)" data-symbol="KRW=X" changeev="regularTimeChange" data-field="regularMarketTime" data-trend="none" value="" active="true"></fin-streamer>
<fin-streamer class="D(n)" data-symbol="KRW=X" changeev="marketState" data-field="marketState" data-trend="none" value="" active="true"></fin-streamer>
1. USD/KRW 환율 가격: 4,331.50 +16.75 (+0.39%) 33,984.00 +109.00 (+0.32%) 14,770.50 +55.50 (+0.38%) 1,786.50 +8.60 (+0.48%) 91.15 +0.76 (+0.84%) 1,915.10 -4.70 (-0.24%) 1,352.7400 +0.1100 (+0.0081%)
2023-09-27T16:43:26.893+09:00 INFO 3273 --- [ scheduling-1] c.d.k.e.f.s.YahooFinanceRateScheduler : [ ExternalApi:OpenExchangeRate ] Success get Data from api
2023-09-27T16:43:26.894+09:00 INFO 3273 --- [ scheduling-1] c.d.k.e.f.s.YahooFinanceRateScheduler : [ ExternalApi:Yahoo Finance Rate ] Start get Data from web
2023-09-27T16:43:27.750+09:00 INFO 3273 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="ES=F" data-field="regularMarketPrice" data-trend="none" value="4331.5" active="true">
4,331.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="ES=F" data-field="regularMarketChange" data-trend="txt" value="16.75" active="true">
<span class="C($positiveColor)">+16.75</span>
</fin-streamer>
<fin-streamer data-symbol="ES=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.38820326" active="true">
<span class="C($positiveColor)">(+0.39%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="YM=F" data-field="regularMarketPrice" data-trend="none" value="33983" active="true">
33,983.00
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="YM=F" data-field="regularMarketChange" data-trend="txt" value="108" active="true">
<span class="C($positiveColor)">+108.00</span>
</fin-streamer>
<fin-streamer data-symbol="YM=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.3188192" active="true">
<span class="C($positiveColor)">(+0.32%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="NQ=F" data-field="regularMarketPrice" data-trend="none" value="14770.5" active="true">
14,770.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="NQ=F" data-field="regularMarketChange" data-trend="txt" value="55.5" active="true">
<span class="C($positiveColor)">+55.50</span>
</fin-streamer>
<fin-streamer data-symbol="NQ=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.37716615" active="true">
<span class="C($positiveColor)">(+0.38%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="RTY=F" data-field="regularMarketPrice" data-trend="none" value="1786.5" active="true">
1,786.50
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="RTY=F" data-field="regularMarketChange" data-trend="txt" value="8.599976" active="true">
<span class="C($positiveColor)">+8.60</span>
</fin-streamer>
<fin-streamer data-symbol="RTY=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.48371536" active="true">
<span class="C($positiveColor)">(+0.48%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="CL=F" data-field="regularMarketPrice" data-trend="none" value="91.15" active="true">
91.15
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="CL=F" data-field="regularMarketChange" data-trend="txt" value="0.76000214" active="true">
<span class="C($positiveColor)">+0.76</span>
</fin-streamer>
<fin-streamer data-symbol="CL=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="0.8408033" active="true">
<span class="C($positiveColor)">(+0.84%)</span>
</fin-streamer>
<fin-streamer class="Fz(s) Mt(4px) Mb(0px) Fw(b) D(ib)" data-symbol="GC=F" data-field="regularMarketPrice" data-trend="none" value="1915" active="true">
1,915.00
</fin-streamer>
<fin-streamer class="Mend(2px)" data-symbol="GC=F" data-field="regularMarketChange" data-trend="txt" value="-4.800049" active="true">
<span class="C($negativeColor)">-4.80</span>
</fin-streamer>
<fin-streamer data-symbol="GC=F" data-field="regularMarketChangePercent" data-trend="txt" data-template="({fmt})" value="-0.25002858" active="true">
<span class="C($negativeColor)">(-0.25%)</span>
</fin-streamer>
<fin-streamer class="Fw(b) Fz(36px) Mb(-4px) D(ib)" data-symbol="KRW=X" data-test="qsp-price" data-field="regularMarketPrice" data-trend="none" data-pricehint="4" value="1352.78" active="">
1,352.7800
</fin-streamer>
<fin-streamer class="Fw(500) Pstart(8px) Fz(24px)" data-symbol="KRW=X" data-test="qsp-price-change" data-field="regularMarketChange" data-trend="txt" data-pricehint="4" value="0.15002441" active="">
<span class="C($positiveColor)">+0.1500</span>
</fin-streamer>
<fin-streamer class="Fw(500) Pstart(8px) Fz(24px)" data-symbol="KRW=X" data-field="regularMarketChangePercent" data-trend="txt" data-pricehint="4" data-template="({fmt})" value="0.00011091311" active="">
<span class="C($positiveColor)">(+0.0111%)</span>
</fin-streamer>
<fin-streamer class="D(n)" data-symbol="KRW=X" changeev="regularTimeChange" data-field="regularMarketTime" data-trend="none" value="" active="true"></fin-streamer>
<fin-streamer class="D(n)" data-symbol="KRW=X" changeev="marketState" data-field="marketState" data-trend="none" value="" active="true"></fin-streamer>
3. 웹 크롤링 및 데이터 가공 추출 예제 소스코드 2가지
- 방법 1
// ...
import org.jsoup.Jsoup
// ...
fun getDataFromWeb(apiUrl: String) {
try {
val document = Jsoup.connect(apiUrl).get()
// 1. usdkrw 환율 가격 실시간 정보
val finData = document.select("fin-streamer")
val exchangeRateText = finData.select("[data-symbol='KRW=X'][data-field='regularMarketPrice']").first()?.text()
println("1. USD/KRW 환율 가격: $exchangeRateText")
} catch (e: Exception) {
// 오류 처리
println("환율 정보를 가져오는 데 실패했습니다: ${e.message}")
}
}
- 방법 2
// ...
import org.jsoup.Jsoup
// ...
fun getDataFromWeb(apiUrl: String) {
try {
val document = Jsoup.connect(apiUrl).get()
// 1. usdkrw 환율 가격 실시간 정보
val exchangeRateText = document.select("fin-streamer[data-symbol='KRW=X'][data-field='regularMarketPrice']").first()?.text()
println("1. USD/KRW 환율 가격: $exchangeRateText")
} catch (e: Exception) {
// 오류 처리
println("환율 정보를 가져오는 데 실패했습니다: ${e.message}")
}
}
4. 프로젝트 구조 및 소스코드 구성
5. entity, repository 소스코드 작성
// vim YahooRateData.kt
package com.dev.kopring00.external.fiat.entities
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document(collection = "yahooRate")
data class YahooRateData(
@Indexed
val code: String,
val price: Double?,
val changePrice: Double?,
val changeRate: Float?,
val bid: Double?,
val ask: Double?,
val lowPrice: Double?,
val highPrice: Double?,
val low52wPrice: Double?,
val high52wPrice: Double?,
val timestamp: Long = Instant.now().epochSecond
)
// vim YahooMainData.kt
package com.dev.kopring00.external.fiat.entities
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document(collection = "yahooMain")
data class YahooMainData(
@Indexed
val name: String,
val price: Float?,
val change: Float?,
val percent: Float?,
val timestamp: Long = Instant.now().epochSecond
)
// vim YahooUsdkrwAndMainData.kt
package com.dev.kopring00.external.fiat.entities
data class YahooUsdkrwAndMainData(
val usdkrw: YahooRateData?,
val mainData: List<YahooMainData>?,
val isSuccess: Boolean
)
6. service, scheduler 소스코드 작성
// vim YahooRateService.kt
package com.dev.kopring00.external.fiat.services
import com.dev.kopring00.external.fiat.entities.YahooMainData
import com.dev.kopring00.external.fiat.entities.YahooRateData
import com.dev.kopring00.external.fiat.entities.YahooUsdkrwAndMainData
import com.dev.kopring00.external.fiat.repositories.YahooMainRepository
import org.jsoup.Jsoup
import com.dev.kopring00.external.fiat.repositories.YahooRateRepository
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class YahooRateService(
private val yahooRateRepository: YahooRateRepository,
private val yahooMainRepository: YahooMainRepository
) {
fun getMainDataWithUsdkrwFromWeb(apiUrl: String): YahooUsdkrwAndMainData {
val logger: Logger = LoggerFactory.getLogger(YahooRateService::class.java)
try {
/* ===== 해당 화면 상단 개요 정보들 main data ===== */
// https://finance.yahoo.com/quote/KRW=X?p=KRW=X&.tsrc=fin-srch
val document = Jsoup.connect(apiUrl).get()
val finData = document.select("fin-streamer")
val mainDataList: List<YahooMainData> = setMainData(finData)
val rateData: YahooRateData = setRateData("USDKRW", "KRW=X", finData, document)
return YahooUsdkrwAndMainData(rateData, mainDataList, true)
} catch (e: Exception) {
// 오류 처리
logger.error("[ ExternalApi:Yahoo ] getMainDataWithUsdkrwFromWeb failed : ${e.message}")
return YahooUsdkrwAndMainData(null, null, false)
}
}
fun setMainData(finData: Elements): List<YahooMainData> {
val itemMap = mapOf(
"S&P500 Futures" to "ES=F", // 1번
"Dow Futures" to "YM=F",
"Nasdaq Futures" to "NQ=F",
"Russell 2000 Futures" to "RTY=F",
"Crude Oil" to "CL=F",
"Gold" to "GC=F",
"Silver" to "SI",
"EUR/USD" to "EURUSD"
)
val mainDataList = mutableListOf<YahooMainData>()
for ((name, symbol) in itemMap) {
val itemData = finData.select("[data-symbol='$symbol']")
val price: String? = itemData.select("[data-field='regularMarketPrice']").first()?.text() // 가격 = data-field : regularMarketPrice
val change: String? = itemData.select("[data-field='regularMarketChange']").first()?.text() // 변동가 = data-field : regularMarketChange
var percent: String? = itemData.select("[data-field='regularMarketChangePercent']").first()?.text() // 변동률 = data-field : regularMarketChangePercent
percent = percent?.split("%")?.first()
percent = percent?.split("(")?.last()
val mainData = YahooMainData(
name,
price?.replace(",", "")?.toFloat(),
change?.replace(",", "")?.toFloat(),
percent?.replace(",", "")?.toFloat(),
)
mainDataList.add(mainData)
}
return mainDataList
}
fun setRateData(code: String, symbol: String, finData: Elements, document: Element): YahooRateData {
val usdkrwData = finData.select("[data-symbol='$symbol']")
/* ===== usdkrw ===== */
// 가격
val price_tmp = usdkrwData.select("[data-field='regularMarketPrice']").first()?.text()
val price = price_tmp?.replace(",", "")
// 변동가
val changePrice_tmp = usdkrwData.select("[data-field='regularMarketChange']").first()?.text()
val changePrice = changePrice_tmp?.replace(",", "")
// 변동률
val changePercent_tmp = usdkrwData.select("[data-field='regularMarketChangePercent']").first()?.text()
?.split("%")
?.first()?.split("(")?.last()
val changePercent = changePercent_tmp?.replace(",", "")
// // 전일 종가 -> 저장 불필요하여 미진행 : 2023-10-04 cjy
// val prevClose_tmp = document.select("[data-test='PREV_CLOSE-value']").first()?.text()
// val prevClose = prevClose_tmp?.replace(",", "")
// 구매 주문 호가
val bid_tmp = document.select("[data-test='BID-value']").first()?.text()
val bid = bid_tmp?.replace(",", "")
// 판매 주문 호가
val ask_tmp = document.select("[data-test='ASK-value']").first()?.text()
val ask = ask_tmp?.replace(",", "")
val daysRange = document.select("[data-test='DAYS_RANGE-value']").first()?.text()
val lowPrice_tmp = daysRange?.split(" - ")?.first()
val lowPrice = lowPrice_tmp?.replace(",", "")
val highPrice_tmp = daysRange?.split(" - ")?.last()
val highPrice = highPrice_tmp?.replace(",", "")
val usdkrw_52weeksRange = document.select("[data-test='FIFTY_TWO_WK_RANGE-value']").first()?.text()
val low52wPrice_tmp = usdkrw_52weeksRange?.split(" - ")?.first()
val high52wPrice_tmp = usdkrw_52weeksRange?.split(" - ")?.last()
val low52wPrice = low52wPrice_tmp?.replace(",", "")
val high52wPrice = high52wPrice_tmp?.replace(",", "")
val result = YahooRateData(
code,
price?.toDouble(),
changePrice?.toDouble(),
changePercent?.toFloat(),
bid?.toDouble(),
ask?.toDouble(),
lowPrice?.toDouble(),
highPrice?.toDouble(),
low52wPrice?.toDouble(),
high52wPrice?.toDouble()
)
return result
}
fun saveMainData(result: YahooUsdkrwAndMainData) {
if (result.isSuccess) {
// 기준이 되는 rate data 저장
result.usdkrw?.let { yahooRateRepository.save(it) }
if (result.mainData != null) {
// main data 저장
for (mainData in result.mainData) {
yahooMainRepository.save(mainData)
}
}
}
}
}
// vim YahooRateScheduler.kt
package com.dev.kopring00.external.fiat.schedulers
import com.dev.kopring00.external.fiat.services.YahooRateService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import kotlin.random.Random
@Component
class YahooRateScheduler @Autowired constructor(
private val yahooRateService: YahooRateService
) {
// example : https://finance.yahoo.com/quote/KRW=X?p=KRW=X&.tsrc=fin-srch
@Value("\${api.url.yahooFinanceRate.usd1}")
private lateinit var apiUrl_1: String
@Value("\${api.url.yahooFinanceRate.usd2}")
private lateinit var apiUrl_2: String
@Value("\${api.url.yahooFinanceRate.usd3}")
private lateinit var apiUrl_3: String
val logger: Logger = LoggerFactory.getLogger(YahooRateScheduler::class.java)
// 미국 한국 환율과 일자별 주요 지수 정보 수집해 저장
fun updateMainDataWithUsdkrw() {
val apiUrl = apiUrl_1 + "KRW" + apiUrl_2 + "KRW" + apiUrl_3
val mainAndRateData = yahooRateService.getMainDataWithUsdkrwFromWeb(apiUrl)
if (mainAndRateData.isSuccess) {
yahooRateService.saveMainData(mainAndRateData)
} else {
logger.error("[ ExternalApi:YahooMain ] Fail save Data.")
}
}
// 매분 00초마다 크롤링 진행
@Scheduled(cron = "0 * * * * *")
fun scheduledUpdate() {
updateMainDataWithUsdkrw()
}
}
7. 데이터 수집 결과 예제