Jetpack Composeでスクレイピングしてみた

# はじめに
どうも、フリーランスエンジニアをしているモブ太郎です。
このの記事では、Jetpack Composeで「Jsoup」というライブラリを使用して、スクレイピングをしたいと思います。
細かい説明は省いていますので、参考程度、こういったものがあるんだな程度に見ていってください。

# 完成形
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3331610/7495d370-2592-5283-a253-2c8fa334ad5a.png)

# Jetpack Composeとは
Jetpack Composeは、ネイティブUIをビルドする際に推奨されるAndroidの最新ツールキットのことです。
Jetpack Composeを導入する理由は以下になります。
- **コードを削減**
少ないコードで多くの機能が可能になり、あらゆる種類のバグが避けられるので、コードがシンプルで維持しやすくなります。
- **直観的**
UIを記述するだけで、残りの部分はComposeで処理されます。
アプリの状態が変更されると、UIが自動的に更新されます。
- **開発を加速させる**
あらゆる既存のコードと互換性があり、必要に応じて利用できます。
ライブプレビューとAndroid Studioのフルサポートにより、反復処理を高速化できます。
- **パワフル**
AndroidプラットフォームAPIに直接アクセスし、マテリアルデザイン、ダークテーマ、アニメーションなどを組み込んだ美しいアプリを作成できます。

**参照**

https://developer.android.com/jetpack/compose?hl=ja

# Jsoupとは
URLを指定してhtmlの情報を取得し、jQueryっぽく要素にアクセスできるライブラリです。

https://github.com/jhy/jsoup

# 実装
## プロジェクト作成
1. Android Studioを起動して「New Project」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3331610/ab44b8fe-c356-2606-733f-6dbb60bf084a.png)
2. 「Empty Compose Activity」を選択し、「Next」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3331610/fdb20b0d-cdd9-2c59-12d4-aaadea56b7d2.png)
3. Nameを設定し、「Finish」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3331610/c6fbcffe-d156-5b25-a574-26b29ce00e21.png)
4. プロジェクト作成完了！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3331610/87051b4c-d719-9cbc-14fa-926b37b6169a.png)

## 必要なライブラリの追加
build.gradle(:app)に以下ライブラリを追加
```
// Jsoup スクレイピング用
implementation 'org.jsoup:jsoup:1.16.1'
// Coil 画像読み込み用
implementation 'io.coil-kt:coil-compose:2.4.0'
```
上記を追加したら一旦ビルドします。
SDKのバージョンがうんぬんかんぬんなどのエラーがでると思います。
私の場合は、以下の内容で修正してエラーを解決しました。
<details>
<summary>build.gradle(:app)</summary>

```gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    namespace 'com.example.kamenriderscrapingapp'
    compileSdk 33 // 32 -> 33

    defaultConfig {
        applicationId "com.example.kamenriderscrapingapp"
        minSdk 28
        targetSdk 33 // 32 -> 33
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.4.2' // 1.1.1 -> 1.4.2
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.3.1'
    implementation "androidx.compose.ui:ui:$compose_ui_version"
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
    implementation 'androidx.compose.material:material:1.1.1'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
    debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
    debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"

    // Jsoup
    implementation 'org.jsoup:jsoup:1.16.1'

    // Coil
    implementation 'io.coil-kt:coil-compose:2.4.0'
}
```

</details>

<details>
<summary>build.gradle(KamenRiderScrapingApp)</summary>

```gradle
buildscript {
    ext {
        compose_ui_version = '1.3.3' // 1.1.1 -> 1.3.3
    }
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    id 'com.android.application' version '7.3.1' apply false
    id 'com.android.library' version '7.3.1' apply false
    id 'org.jetbrains.kotlin.android' version '1.8.10' apply false // 1.6.10 -> 1.8.10
}
```

</details>

## インターネットアクセスへの許可
インターネットに接続するためには以下の内容を追記しないと接続できません。
```
<uses-permission android:name="android.permission.INTERNET" />
```
<details>
<summary>AndroidManifest.xml</summary>

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- インターネットアクセスへの許可 -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.KamenRiderScrapingApp"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.KamenRiderScrapingApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>
    </application>

</manifest>
```

</details>


## 仮面ライダーデータクラス
<details>
<summary>KamenRider.kt</summary>

```kotlin
data class KamenRider(
    val imageUrl: String,
    val title: String,
    val era: String,
)
```

</details>

仮面ライダーの情報を保持するためのデータクラスです。

## 仮面ライダー一覧画面
<details>
<summary>KamenRiderScreen.kt</summary>

```kotlin
@Composable
fun KamenRiderScreen() {
    var kamenRiderList by remember { mutableStateOf(emptyList<KamenRider>()) }
    thread {
        kamenRiderList = kamenRiderScraping()
    }
    Scaffold { paddingValue ->
        LazyColumn(modifier = Modifier.padding(paddingValue)) {
            items(kamenRiderList.sortedBy { it.era }) { item ->
                KamenRiderInfoItem(item)
            }
        }
    }
}

@Composable
fun KamenRiderInfoItem(item: KamenRider) {
    Box(
        modifier = Modifier
            .background(Color.White)
            .border(
                width = 1.dp,
                color = Color.DarkGray,
            ),
        contentAlignment = Alignment.TopCenter,
    ) {
        Row(
            modifier = Modifier.fillMaxWidth()
        ) {
            // 画像表示
            AsyncImage(
                model = item.imageUrl,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .width(100.dp)
                    .height(150.dp)
                    .padding(5.dp)
            )

            Column(modifier = Modifier.fillMaxWidth()) {
                // 年代
                Text(
                    text = item.era,
                    maxLines = 3,
                    fontWeight = FontWeight.Bold,
                    color = Color.Black,
                    fontSize = 20.sp,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(5.dp),
                )
                // 作品名
                Text(
                    text = item.title,
                    maxLines = 3,
                    fontWeight = FontWeight.Bold,
                    color = Color.Black,
                    fontSize = 15.sp,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(5.dp),
                )
            }
        }
    }
}

private fun kamenRiderScraping(): ArrayList<KamenRider> {
    /* 仮面ライダーポータルサイトをスクレイピングするための関数 */

    val targetUrl = "https://www.kamen-rider-official.com/series/"

    val kamenRiderList: ArrayList<KamenRider> = arrayListOf()
    try {
        if (!isConnect(targetUrl)) {
            Log.e("Error", "正常に接続できませんでした。")
            return arrayListOf()
        }

        val doc: Document = Jsoup.connect(targetUrl).get()
        val elements: Elements = doc.select(".styles_era_order_color__D0ePc")
        elements.forEach { element ->
            element.select(".styles_flex_content__Bygaa").forEach { item ->
                kamenRiderList.add(
                    KamenRider(
                        imageUrl = "https://www.kamen-rider-official.com" + item.select(".styles_series__Card__Img__85aon").attr("src"),
                        era = item.select(".styles_series__Card__Year__n5X_o").text(),
                        title = item.select(".styles_series__Card__Name__fg02u").text()
                    )
                )
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return kamenRiderList
}

private fun isConnect(targetUrl: String): Boolean {
    /* 接続可能なURLかチェックするための関数 */

    val url = URL(targetUrl)
    val connection = url.openConnection() as HttpURLConnection
    // 接続チェック
    connection.requestMethod = "GET"
    connection.instanceFollowRedirects = false
    connection.connect()
    val statusCode: Int = connection.responseCode
    connection.disconnect()
    return statusCode == HttpURLConnection.HTTP_OK
}
```

</details>

スクレイピングを行い、一覧を表示するためのクラスです。

## メインクラス
<details>
<summary>MainActivity.kt</summary>

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            KamenRiderScrapingAppTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    // 仮面ライダー一覧画面
                    KamenRiderScreen()
                }
            }
        }
    }
}
```

</details>

# さいごに
もともとスクレイピングに興味があったのですが、Pythonでスクレイピングするよりも今自分がハマっているAndroidでスクレイピングがしたい！
という思いで「Jsoup」なるものを見つけ、今回の記事を書きました。

他にもAndroidでpythonを動かすことができるライブラリもあるみたいなので、それも記事にできたらなと思います。

ここまで見てくだっさてありがとうございます。mm