[텃텃] Jsoup크롤링 with Flow & Compose WebView

안승우·2024년 5월 6일
0

텃텃

목록 보기
5/6

Flow를 이용한 크롤링

텃텃에선 작물별 레시피를 제공하기로 했다. (기획 의도)
그리고 Flow를 공부하고 적용해보는 것이 나의 목표 중 하나였기에 크롤링 데이터를 Flow로 제공하고 싶었고 적절한 방법을 고민해보았다.

레시피 제공 기능은

  1. 만개의레시피에서 작물 별 레시피 10개 크롤링

  2. 작물 정보, 작물 상세 페이지에서 레시피 데이터 매핑

  3. 클릭 시, WebView로 레시피 페이지 띄우기


크롤링을 위한 라이브러리는 Jsoup를 사용했다.
Java의 HTML 파싱 라이브러리 중 Jsoup 이외의 다른 대안을 찾을 수 없었다.

build.gradle.kts

dependencies {
    implementation('org.jsoup:jsoup:${최신 버전}')
}

AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />

//크롤링하려는 페이지 URL이 'https'가 아니라면 application 태그 내에 추가
android:usesCleartextTraffic="true"

이렇게 사전 준비가 끝났다면 단계별로 시작해보자


Step 👆 크롤링 DTO & Repository

Recipe.kt

data class Recipe(
    val title: String,
    val imgUrl: String,
    val link: String
)

내가 제공할 레시피 데이터는 제목과 이미지, 그리고 클릭 시 이동할 Web 링크 url이 필요하다.


CropsInfoRepository.kt

interface CropsInfoRepository {
    fun getCropsRecipes(keyword: String): Flow<List<Recipe>>
}

난 작물별 레시피를 제공하기 위해 keyword로 다른 URL에서 데이터를 크롤링할 것이다.


DataModule.kt

@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    @Singleton
    internal abstract fun bindCropsInfoRepository(
        cropsInfoRepository: CropsInfoRepositoryImpl
    ) : CropsInfoRepository
}

Hilt를 사용했기 때문에 Repository 종속성을 주입하기 위한 모듈을 만들어준다.


Step ✌ 크롤링할 페이지 파악 & Repository 구현하기


크롤링할 페이지의 Url은 q라는 쿼리를 사용해 데이터를 불러온다. 그래서 내 작물별 keyword를 여기에 넣어주기로 했다.

이제 개발자 도구를 켜서 원하는 데이터가 어디있는지 찾아보자

그리고 해당 HTML이 어떻게 구성되는지 파악해보자

  1. ul 태그 하위의 각 li를 가져온다.

  2. li의 첫 div의 a의 href 그리고 a>img이 src가 필요하다.

  3. li 두 번째 div의 첫 div의 text가 필요하다.


CropsInfoRepositoryImpl.kt

class CropsInfoRepositoryImpl @Inject constructor(): CropsInfoRepository {
    override fun getCropsRecipes(keyword: String): Flow<List<Recipe>> = flow {
        val crawlingUrl = "${CRAWLING_BASE_URL}/recipe/list.html?q=${keyword}"
        val doc = withContext(Dispatchers.IO) { Jsoup.connect(crawlingUrl).get() }
        val recipes = doc.select(".common_sp_list_ul.ea4 li").take(10).map { element ->
            val aTag = element.select(".common_sp_thumb a")
            val imgTag = aTag.select("img")
            val divTag = element.select(".common_sp_caption_tit.line2")
            Recipe(
                title = divTag.text(),
                imgUrl = imgTag.attr("src"),
                link = aTag.attr("href")
            )
        }
        emit(recipes)
    }.flowOn(Dispatchers.IO)
}
  1. flow 블록을 만들고 keyword를 바탕으로 크롤링할 Url을 선언한다.

  2. IO 환경에서 Jsoup과 연결을 먼저 수행하기 위해 withContext를 이용한다.

  3. takemap 중간 연산자로 10개의 Recipe을 반환하는 flow를 만든다.

  4. 10개의 List<Recipe>를 방출하고 flowOn으로 IO 환경에서 작업하게 한다.

네트워크 작업에 최적화된 환경은 IO 환경이다.


Step 🤟 Composable에 크롤링 Data 매핑하기

UiState

sealed interface CropsRecipeUiState {
    data object Loading : CropsRecipeUiState
    data class Success(
        val recipes: List<Recipe>
    ) : CropsRecipeUiState

CropsInfoDetailViewModel.kt

@HiltViewModel
class CropsInfoDetailViewModel @Inject constructor(
    val cropsInfoRepo: CropsInfoRepository
): BaseViewModel() {

    val recipeUiState: StateFlow<CropsRecipeUiState>
        = cropsInfoRepo
            .getCropsRecipes(cropsInfo.value.name)
            .map(CropsRecipeUiState::Success)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = CropsRecipeUiState.Loading
            )
     
    ...
}

CropsInfoDetailScreen.kt

//ViewModel을 생성하는 상위 Composable
val recipeUiState by viewModel.recipeUiState.collectAsStateWithLifecycle()

...

//UiState을 매개변수로 받는 하위 Composable
when (recipeUiState) {
	CropsRecipeUiState.Loading -> {
		item(span = { GridItemSpan(maxLineSpan) }) {
                            TutTutLoadingScreen(Modifier.height(300.dp))
                        }
	}
	is CropsRecipeUiState.Success -> {
         items(
            count = recipeUiState.recipes.size,
            key = { it }
         ) { index ->
              RecipeItem(
                  recipe = recipeUiState.recipes[index],
                  isLeftItem = index % 2 == 0,
                  onItemClick = { onRecipe(recipeUiState.recipes[index].link) }
              )
        }
	}
}
  1. map 연산자를 이용해 Flow<List<Recipe>>Flow<UiState>으로 변형한다.

  2. stateIn 연산자를 이용해 Flow<UiState>StateFlow<UiState>으로 변환한다.

  3. collectAsStateWithLifecycle()StateFlow<UiState>State<UiState>으로 수집한다.

  4. 하위 Composable에서 UiState를 받아 상태에 따라 UI에 데이터를 매핑한다.


해당 방식으로 Flow를 이용한 크롤링 데이터 흐름을 만들고, 각 작물별 필요한 데이터를 받아 사용할 수 있다.


Step ✋ Compose WebView로 페이지 띄우기

WebView를 사용해 두 가지 페이지를 띄워야 했다.

  1. 작물별 전체 레시피 페이지

  2. 해당 작물 레시피 상세 페이지

그래서 작물 데이터용 모델에 이동할 Url을 저장하고 ViewModel에 주입해 사용하기로 했다.

CropsModel.kt

@Singleton
class CropsModel @Inject constructor() {
    private val _observedCrops = MutableStateFlow(Crops())
    val observedCrops: StateFlow<Crops> = _observedCrops
    private val _recipeLink = MutableStateFlow("")
    val recipeLink: StateFlow<String> = _recipeLink

    fun observeCrops(crops: Crops) {
        _observedCrops.value = crops
    }

    fun setRecipeLink(link: String) {
        _recipeLink.value = link
    }
}

RecipeWebViewModel.kt

@HiltViewModel
class RecipeWebViewModel @Inject constructor(
    cropsModel: CropsModel
) : BaseViewModel() {
    val crops = cropsModel.observedCrops
    val link = cropsModel.recipeLink
}

CropsModel은 @SingleTon으로 같은 인스턴스를 반환하도록 했다.

이제 Compose의 WebView가 필요한데, android.webkit의 WebView로 인스턴스를 생성하고 compose.ui의 AndroidView로 화면위에 띄울 수 있다.

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun rememberWebView(url: String): WebView {
    val context = LocalContext.current
    val webView = remember {
        WebView(context).apply {
            settings.javaScriptEnabled = true
            webViewClient = WebViewClient()
            loadUrl(url)
        }
    }
    return webView
}

리컴포지션 단계에서 WebView 인스턴스를 새로 생성하지 않기 위해 remember를 사용한다.

WebView는 기본적으로 javaScriptEnabled=false로 생성된다. 이것을 true로 설정하면 띄우는 페이지의 javaScript가 동작하게 허용하고, 보안의 위험성이 있다고 경고한다.

하지만 만 개의 레시피 페이지에 javaScript 동작을 빼버리면 상당히 이상하게 보여져 true로 설정했다.

보안 위험성 때문에 javaScriptEnabled=false로 설정하는 것이 권장된다.

RecipeWebScreen.kt

@Composable
fun RecipeWebRoute(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    viewModel: RecipeWebViewModel = hiltViewModel()
) {
    val crops by viewModel.crops.collectAsStateWithLifecycle()
    val link by viewModel.link.collectAsStateWithLifecycle()
    val webView = rememberWebView(url = "${CRAWLING_BASE_URL}${link}")

    RecipeWebScreen(
        modifier = modifier,
        cropsName = crops.name,
        webView = webView,
        onBack = onBack
    )
    BackHandler {
        if (webView.canGoBack()) webView.goBack()
        else onBack()
    }
}

@Composable
internal fun RecipeWebScreen(
    modifier: Modifier,
    cropsName: String,
    webView: WebView,
    onBack: () -> Unit
) {
    Column(
        modifier = modifier.fillMaxSize()
    ) {
        TutTutTopBar(
            title = "$cropsName ${stringResource(id = R.string.crops_recipe)}",
            needBack = true,
            onBack = onBack
        )
        AndroidView(
            modifier = Modifier.weight(1f),
            factory = { webView }
        )
    }
}

이렇게 생성한 WebView 인스턴스AndroidView의 factory에 사용하면 원하는 페이지를 띄울 수 있다.
여기서 중요한 점은 BackHandler에서 webView의 이전 페이지가 있다면, webView 이전 페이지로 돌아가고 이전 페이지가 없다면 앱의 이전 페이지로 돌아가게 만들었다.
그래서 기기의 뒤로 가기 버튼을 통해 띄운 페이지(만 개의 레시피)를 탐색할 수 있다.
대신 TopBar의 뒤로 가기 아이콘을 누른다면 바로 이전 페이지로 갈 수 있는 선택지도 제공한다.


마치며

크롤링은 예전부터 구현해보고 싶은 기능이었다. 마침 텃텃에서 필요했고 Flow를 공부하며 즐겁게 적용했던 경험이었다.

레시피 크롤링은 나름 빠르게 작동하지만, Flow를 올바르게 사용하는지 혹은 더 개선할 방법이 없는지를 찾아봐야할 것 같다. 그래서 이후 버전에선 성능 평가를 통해 개선된 로직을 적용해보고 공유하려 한다.

클린 아키텍처 + Hilt + Compose를 사용하는 프로젝트에서 크롤링 기능이 필요한 사람에게 이 글이 도움이 됐으면 좋겠다!

profile
안드로이드 개발자

0개의 댓글

관련 채용 정보