Skip to content

Commit

Permalink
강의동 배치 추가 및 search_query API에서 실 데이터가 내려가도록 작업 (#216)
Browse files Browse the repository at this point in the history
  • Loading branch information
subeenpark-io authored Jan 28, 2024
1 parent 74ef245 commit f78b4ce
Show file tree
Hide file tree
Showing 18 changed files with 517 additions and 13 deletions.
30 changes: 30 additions & 0 deletions batch/src/main/kotlin/lecturebuildings/SnuMapRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wafflestudio.snu4t.lecturebuildings

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.wafflestudio.snu4t.lecturebuildings.api.SnuMapApi
import com.wafflestudio.snu4t.lecturebuildings.data.SnuMapSearchResult
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.awaitBody

@Component
class SnuMapRepository(
private val snuMapApi: SnuMapApi,
private val objectMapper: ObjectMapper,
) {
companion object {
const val SNU_MAP_SEARCH_PATH = "/api/search.action"
const val DEFAULT_SEARCH_PARAMS = "lang_type=KOR"
}

suspend fun getLectureBuildingSearchResult(
buildingNum: String
): SnuMapSearchResult = snuMapApi.get().uri { builder ->
builder.path(SNU_MAP_SEARCH_PATH)
.query(DEFAULT_SEARCH_PARAMS)
.queryParam("search_word", buildingNum)
.build()
}.accept(MediaType.APPLICATION_JSON).retrieve().awaitBody<String>()
.let { objectMapper.readValue<SnuMapSearchResult>(it) }
}
34 changes: 34 additions & 0 deletions batch/src/main/kotlin/lecturebuildings/api/SnuMapApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.wafflestudio.snu4t.lecturebuildings.api

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.ExchangeStrategies
import org.springframework.web.reactive.function.client.WebClient

@Configuration
class SnuMapApiConfig() {
companion object {
const val SNU_MAP_BASE_URL = "https://map.snu.ac.kr"
}

@Bean
fun snuMapSnuApi(): SnuMapApi {
val exchangeStrategies: ExchangeStrategies = ExchangeStrategies.builder()
.codecs { it.defaultCodecs().maxInMemorySize(-1) } // to unlimited memory size
.build()

return WebClient.builder().baseUrl(SNU_MAP_BASE_URL)
.exchangeStrategies(exchangeStrategies)
.defaultHeaders {
it.setAll(
mapOf(
"User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36",
"Referrer" to "https://map.snu.ac.kr/web/main.action"
)
)
}
.build().let(::SnuMapApi)
}
}

class SnuMapApi(webClient: WebClient) : WebClient by webClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.wafflestudio.snu4t.lecturebuildings.data

import com.wafflestudio.snu4t.lectures.data.Lecture

class LectureBuildingUpdateResult(
val lecturesWithBuildingInfos: List<Lecture>,
val lecturesWithOutBuildingInfos: List<Lecture>,
val lecturesFailed: List<Lecture>
)
31 changes: 31 additions & 0 deletions batch/src/main/kotlin/lecturebuildings/data/PlaceInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.wafflestudio.snu4t.lecturebuildings.data

class PlaceInfo(
val rawString: String,
val campus: Campus,
val buildingNumber: String,
) {
companion object {
fun getValuesOf(places: String): List<PlaceInfo> = places.split("/").map { PlaceInfo(it) }.filterNotNull()
}
}

fun PlaceInfo(place: String): PlaceInfo? {
val campus: Campus = when (place.first()) {
'#' -> Campus.YEONGEON
'*' -> Campus.PYEONGCHANG
else -> Campus.GWANAK
}

val placeWithOutCampus = place.removePrefix("#").removePrefix("*")
val splits = placeWithOutCampus.split("-").filter { !it.matches("^[A-Za-z]*$".toRegex()) }

val buildingNumber = when (splits.count()) {
3 -> if (splits[1].count() == 1) splits.dropLast(1).joinToString("-") else splits.first()
else -> splits.firstOrNull()
}?.let {
it.trimStart { it == '0' }
} ?: return null

return PlaceInfo(place, campus, buildingNumber)
}
28 changes: 28 additions & 0 deletions batch/src/main/kotlin/lecturebuildings/data/SnuMapSearchResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.wafflestudio.snu4t.lecturebuildings.data

import com.fasterxml.jackson.annotation.JsonProperty

data class SnuMapSearchResult(
@JsonProperty("search_list")
val searchList: List<SnuMapSearchItem>,
)

data class SnuMapSearchItem(
@JsonProperty("lat_val")
val latitudeInDMS: Double,
@JsonProperty("lon_val")
val longitudeInDMS: Double,
@JsonProperty("lat_val1")
val latitudeInDecimal: Double,
@JsonProperty("lon_val1")
val longitudeInDecimal: Double,
@JsonProperty("vil_dong_nm")
val buildingNumber: String? = null,
val name: String,
@JsonProperty("ename")
val englishName: String? = null,
@JsonProperty("con_type")
val contentType: String,
@JsonProperty("fac_type")
val facType: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.wafflestudio.snu4t.lecturebuildings.job

import com.wafflestudio.snu4t.common.enum.Semester
import com.wafflestudio.snu4t.lecturebuildings.data.LectureBuildingUpdateResult
import com.wafflestudio.snu4t.lecturebuildings.service.LectureBuildingPopulateService
import com.wafflestudio.snu4t.lectures.data.Lecture
import com.wafflestudio.snu4t.lectures.repository.LectureRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.batch.core.Job
import org.springframework.batch.core.Step
import org.springframework.batch.core.configuration.annotation.JobScope
import org.springframework.batch.core.job.builder.JobBuilder
import org.springframework.batch.core.repository.JobRepository
import org.springframework.batch.core.step.builder.StepBuilder
import org.springframework.batch.repeat.RepeatStatus
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager

@Configuration
class LectureBuildingPopulateJobConfig(
private val lectureRepository: LectureRepository,
private val lectureBuildingPopulateService: LectureBuildingPopulateService,
) {
private val log = LoggerFactory.getLogger(javaClass)

private companion object {
const val JOB_NAME = "lectureBuildingPopulateJob"
const val STEP_NAME = "lectureBuildingPopulateStep"
}

@Bean
fun lectureBuildingPopulateJob(jobRepository: JobRepository, lectureBuildingPopulateStep: Step): Job {
return JobBuilder(JOB_NAME, jobRepository)
.start(lectureBuildingPopulateStep)
.build()
}

@Bean
@JobScope
fun lectureBuildingPopulateStep(
jobRepository: JobRepository,
transactionManager: PlatformTransactionManager,
@Value("#{jobParameters[year]}") year: Int,
@Value("#{jobParameters[semester]}") semester: Int,
): Step = StepBuilder(STEP_NAME, jobRepository).tasklet(
{ _, _ ->
runBlocking {
lectureRepository.findAllByYearAndSemester(year, Semester.getOfValue(semester)!!).toList()
.let { tryToUpdateLectureBuildings(it) }
.let { updateResult ->
updateTimetableLectures(updateResult.lecturesWithBuildingInfos)
}
}
RepeatStatus.FINISHED
},
transactionManager
).build()

private suspend fun tryToUpdateLectureBuildings(lectures: List<Lecture>): LectureBuildingUpdateResult = runBlocking {
val updateResult = lectureBuildingPopulateService.populateLectureBuildingsWithFetch(lectures)

log.info(
"강의 ${updateResult.lecturesWithBuildingInfos.count()}개의 강의동 정보를 업데이트 했습니다.\n${
updateResult.lecturesWithBuildingInfos
.map { "${it.courseTitle}(${it.lectureNumber})" }
.joinToString(", ")
}"
)
log.info(
"강의동 업데이트에 실패한 강의:\n ${
updateResult.lecturesFailed
.map { "${it.courseTitle}(${it.lectureNumber}): ${it.classPlaceAndTimes.map { it.place }.joinToString(", ")}" }
.joinToString("\n")
}"
)

return@runBlocking updateResult
}

private suspend fun updateTimetableLectures(lectures: List<Lecture>) = runBlocking {
lectures.map {
async {
val timetables = lectureBuildingPopulateService.populateLectureBuildingsOfTimetables(it)
log.info("강의 ${it.courseTitle}(${it.courseNumber})}가 포함된 시간표 ${timetables.count()}개를 업데이트 했습니다.")
}
}
}.awaitAll()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.wafflestudio.snu4t.lecturebuildings.service

import com.wafflestudio.snu4t.lecturebuildings.SnuMapRepository
import com.wafflestudio.snu4t.lecturebuildings.data.Campus
import com.wafflestudio.snu4t.lecturebuildings.data.GeoCoordinate
import com.wafflestudio.snu4t.lecturebuildings.data.LectureBuilding
import com.wafflestudio.snu4t.lecturebuildings.data.SnuMapSearchItem
import com.wafflestudio.snu4t.lecturebuildings.data.SnuMapSearchResult
import com.wafflestudio.snu4t.lecturebuildings.repository.LectureBuildingRepository
import org.springframework.stereotype.Service

interface LectureBuildingFetchService {
suspend fun getSnuMapLectureBuilding(campus: Campus, buildingNumber: String): LectureBuilding?
}
@Service
class LectureBuildingFetchServiceImpl(
private val snuMapRepository: SnuMapRepository,
private val lectureBuildingRepository: LectureBuildingRepository
) : LectureBuildingFetchService {
override suspend fun getSnuMapLectureBuilding(
campus: Campus,
buildingNumber: String
): LectureBuilding? = lectureBuildingRepository.findByBuildingNumber(buildingNumber)
?: selectMostProbableSearchItem(buildingNumber, snuMapRepository.getLectureBuildingSearchResult(buildingNumber))
?.let {
lectureBuildingRepository.save(
LectureBuilding(
buildingNumber = it.buildingNumber!!,
buildingNameKor = it.name,
buildingNameEng = it.englishName,
locationInDMS = GeoCoordinate(it.latitudeInDMS, it.longitudeInDMS),
locationInDecimal = GeoCoordinate(it.latitudeInDecimal, it.longitudeInDecimal),
campus = campus
)
)
}

private fun selectMostProbableSearchItem(
buildingNumber: String,
searchResult: SnuMapSearchResult
): SnuMapSearchItem? = searchResult.searchList
.filter { it.contentType == "F" && it.facType == "OTHER" && it.buildingNumber == buildingNumber }
.minByOrNull { it.name.count() }
}
Loading

0 comments on commit f78b4ce

Please sign in to comment.