diff --git a/data/novel/build.gradle.kts b/data/novel/build.gradle.kts
new file mode 100644
index 000000000..2b6edc225
--- /dev/null
+++ b/data/novel/build.gradle.kts
@@ -0,0 +1,20 @@
+import com.into.websoso.setNamespace
+
+plugins {
+ id("websoso.android.library")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ setNamespace("data.novel")
+}
+
+dependencies {
+ implementation(projects.core.network)
+
+ implementation(libs.paging.runtime)
+ implementation(libs.retrofit)
+ implementation(libs.serialization.json)
+
+ testImplementation(libs.junit)
+}
diff --git a/data/novel/src/main/AndroidManifest.xml b/data/novel/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..8072ee00d
--- /dev/null
+++ b/data/novel/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt
new file mode 100644
index 000000000..8eeed5e32
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt
@@ -0,0 +1,13 @@
+package com.into.websoso.data.novel
+
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+internal interface NovelSearchApi {
+ @GET("novels")
+ suspend fun getNovels(
+ @Query("query") query: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ ): NovelSearchResponseDto
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt
new file mode 100644
index 000000000..f20c102a8
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt
@@ -0,0 +1,16 @@
+package com.into.websoso.data.novel
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object NovelSearchApiModule {
+ @Provides
+ @Singleton
+ fun provideNovelSearchApi(retrofit: Retrofit): NovelSearchApi = retrofit.create(NovelSearchApi::class.java)
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt
new file mode 100644
index 000000000..e1e756bbc
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt
@@ -0,0 +1,37 @@
+package com.into.websoso.data.novel
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.into.websoso.data.novel.model.NovelSearchEntity
+
+internal class NovelSearchPagingSource(
+ private val query: String,
+ private val api: NovelSearchApi,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult =
+ runCatching {
+ val page = params.key ?: INITIAL_PAGE
+ val response = api.getNovels(
+ query = query,
+ page = page,
+ size = params.loadSize,
+ )
+
+ LoadResult.Page(
+ data = response.novels.map(NovelSearchResponseDto.NovelDto::toData),
+ prevKey = page.takeIf { it > INITIAL_PAGE }?.minus(1),
+ nextKey = (page + 1).takeIf { response.isLoadable },
+ )
+ }.getOrElse(LoadResult::Error)
+
+ override fun getRefreshKey(state: PagingState): Int? =
+ state.anchorPosition?.let { anchorPosition ->
+ state.closestPageToPosition(anchorPosition)?.let { page ->
+ page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
+ }
+ }
+
+ private companion object {
+ const val INITIAL_PAGE = 0
+ }
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt
new file mode 100644
index 000000000..baa0f49c4
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt
@@ -0,0 +1,33 @@
+package com.into.websoso.data.novel
+
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class NovelSearchResponseDto(
+ @SerialName("isLoadable")
+ val isLoadable: Boolean,
+ @SerialName("novels")
+ val novels: List,
+) {
+ @Serializable
+ data class NovelDto(
+ @SerialName("novelId")
+ val novelId: Long,
+ @SerialName("title")
+ val title: String,
+ @SerialName("author")
+ val author: String,
+ @SerialName("novelImage")
+ val imageUrl: String,
+ ) {
+ fun toData(): NovelSearchEntity =
+ NovelSearchEntity(
+ novelId = novelId,
+ title = title,
+ author = author,
+ imageUrl = imageUrl,
+ )
+ }
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt b/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt
new file mode 100644
index 000000000..f5ba6a98e
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt
@@ -0,0 +1,8 @@
+package com.into.websoso.data.novel.model
+
+data class NovelSearchEntity(
+ val novelId: Long,
+ val title: String,
+ val author: String,
+ val imageUrl: String,
+)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 81a0aa86b..e814f4de1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -33,6 +33,7 @@ include(
include(
":data:account",
":data:library",
+ ":data:novel",
":data:feed",
)