diff --git a/src/ko/agitoon/build.gradle b/src/ko/agitoon/build.gradle new file mode 100644 index 000000000..9f7f83f8e --- /dev/null +++ b/src/ko/agitoon/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'AgiToon' + extClass = '.AgiToon' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ko/agitoon/res/mipmap-hdpi/ic_launcher.png b/src/ko/agitoon/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ac595ab56 Binary files /dev/null and b/src/ko/agitoon/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ko/agitoon/res/mipmap-mdpi/ic_launcher.png b/src/ko/agitoon/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..15ea8b9ff Binary files /dev/null and b/src/ko/agitoon/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ko/agitoon/res/mipmap-xhdpi/ic_launcher.png b/src/ko/agitoon/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..a97a89d0d Binary files /dev/null and b/src/ko/agitoon/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ko/agitoon/res/mipmap-xxhdpi/ic_launcher.png b/src/ko/agitoon/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f41de9c21 Binary files /dev/null and b/src/ko/agitoon/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ko/agitoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/ko/agitoon/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4e666aaa8 Binary files /dev/null and b/src/ko/agitoon/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/AgiToon.kt b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/AgiToon.kt new file mode 100644 index 000000000..9033a0604 --- /dev/null +++ b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/AgiToon.kt @@ -0,0 +1,218 @@ +package eu.kanade.tachiyomi.extension.ko.agitoon + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import okio.IOException +import rx.Observable +import uy.kohesive.injekt.injectLazy +import kotlin.math.min +import kotlin.random.Random + +class AgiToon : HttpSource() { + + override val name = "아지툰" + + override val lang = "ko" + + private var currentBaseUrlHost = "" + override val baseUrl = "https://agitoon.in" + + private val cdnUrl = "https://blacktoonimg.com/" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder().addInterceptor { chain -> + if (currentBaseUrlHost.isBlank()) { + noRedirectClient.newCall(GET(baseUrl, headers)).execute().use { + currentBaseUrlHost = it.headers["location"]?.toHttpUrlOrNull()?.host + ?: throw IOException("unable to get updated url") + } + } + + val request = chain.request().newBuilder().apply { + if (chain.request().url.toString().startsWith(baseUrl)) { + url( + chain.request().url.newBuilder() + .host(currentBaseUrlHost) + .build(), + ) + } + header("Referer", "https://$currentBaseUrlHost/") + header("Origin", "https://$currentBaseUrlHost") + }.build() + + return@addInterceptor chain.proceed(request) + }.build() + + private val noRedirectClient = network.cloudflareClient.newBuilder() + .followRedirects(false) + .build() + + private val json by injectLazy() + + private val db by lazy { + val doc = client.newCall(GET(baseUrl, headers)).execute().asJsoup() + doc.select("script[src*=data/webtoon]").flatMap { scriptEl -> + var listIdx: Int + client.newCall(GET(scriptEl.absUrl("src"), headers)) + .execute().body.string() + .also { + listIdx = it.substringBefore(" = ") + .substringAfter("data") + .toInt() + } + .substringAfter(" = ") + .removeSuffix(";") + .let { json.decodeFromString>(it) } + .onEach { it.listIndex = listIdx } + } + } + + private fun List.getPageChunk(page: Int): MangasPage { + return MangasPage( + mangas = subList((page - 1) * 24, min(page * 24, size)) + .map { it.toSManga(cdnUrl) }, + hasNextPage = (page + 1) * 24 <= size, + ) + } + + override fun fetchPopularManga(page: Int): Observable { + return Observable.just( + db.sortedByDescending { it.hot }.getPageChunk(page), + ) + } + + override fun fetchLatestUpdates(page: Int): Observable { + return Observable.just( + db.sortedByDescending { it.updatedAt }.getPageChunk(page), + ) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + var list = db + + if (query.isNotBlank()) { + val stdQuery = query.trim() + list = list.filter { + it.name.contains(stdQuery, true) || + it.author.contains(stdQuery, true) + } + } + + filters.filterIsInstance().forEach { + list = it.applyFilter(list) + } + + return Observable.just( + list.getPageChunk(page), + ) + } + + override fun getFilterList() = getFilters() + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("$baseUrl/azi_toon/${manga.url}.html#${manga.status}", headers) + } + + override fun getMangaUrl(manga: SManga): String { + return buildString { + if (currentBaseUrlHost.isBlank()) { + append(baseUrl) + } else { + append("https://") + append(currentBaseUrlHost) + } + append("/azi_toon/") + append(manga.url) + append(".html") + } + } + + override fun mangaDetailsParse(response: Response): SManga { + val doc = response.asJsoup() + return SManga.create().apply { + description = doc.select("p.mt-2").last()?.text() + thumbnail_url = doc.selectFirst("script:containsData(+img_domain+)")?.data()?.let { + cdnUrl + it.substringAfter("+'").substringBefore("'+") + } + status = response.request.url.fragment!!.toInt() + } + } + + override fun chapterListRequest(manga: SManga): Request { + val url = "$baseUrl/data/toonlist/${manga.url}.js?v=${"%.17f".format(Random.nextDouble())}" + + return GET(url, headers) + } + + override fun chapterListParse(response: Response): List { + val mangaId = response.request.url.pathSegments.last().removeSuffix(".js") + + val data = response.body.string() + .substringAfter(" = ") + .removeSuffix(";") + .let { json.decodeFromString>(it) } + + return data.map { it.toSChapter(mangaId) }.reversed() + } + + override fun getChapterUrl(chapter: SChapter): String { + return buildString { + if (currentBaseUrlHost.isBlank()) { + append(baseUrl) + } else { + append("https://") + append(currentBaseUrlHost) + } + append("/azi_toons/") + append(chapter.url) + append(".html") + } + } + + override fun pageListRequest(chapter: SChapter): Request { + return GET("$baseUrl/azi_toons/${chapter.url}.html", headers) + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + + return document.select("#toon_content_imgs img").map { + Page(0, imageUrl = cdnUrl + it.attr("o_src")) + } + } + + // unused + override fun popularMangaRequest(page: Int): Request { + throw UnsupportedOperationException() + } + override fun popularMangaParse(response: Response): MangasPage { + throw UnsupportedOperationException() + } + override fun latestUpdatesRequest(page: Int): Request { + throw UnsupportedOperationException() + } + override fun latestUpdatesParse(response: Response): MangasPage { + throw UnsupportedOperationException() + } + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + throw UnsupportedOperationException() + } + override fun searchMangaParse(response: Response): MangasPage { + throw UnsupportedOperationException() + } + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } +} diff --git a/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Data.kt b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Data.kt new file mode 100644 index 000000000..04e3b2b00 --- /dev/null +++ b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Data.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.extension.ko.agitoon + +val platformsMap = mapOf( + Pair(1, "네이버"), + Pair(2, "다음"), + Pair(3, "카카오"), + Pair(4, "레진"), + Pair(5, "투믹스"), + Pair(6, "탑툰"), + Pair(7, "코미카"), + Pair(8, "배틀코믹"), + Pair(9, "코믹GT"), + Pair(10, "케이툰"), + Pair(11, "애니툰"), + Pair(12, "폭스툰"), + Pair(13, "피너툰"), + Pair(14, "봄툰"), + Pair(15, "코미코"), + Pair(16, "무툰"), + Pair(17, "지존신마"), + Pair(99, "기타"), +) + +val tagsMap = mapOf( + Pair(1, "학원"), + Pair(2, "액션"), + Pair(3, "SF"), + Pair(4, "스토리"), + Pair(5, "판타지"), + Pair(6, "BL/백합"), + Pair(7, "개그/코미디"), + Pair(8, "연애/순정"), + Pair(9, "드라마"), + Pair(10, "로맨스"), + Pair(11, "시대극"), + Pair(12, "스포츠"), + Pair(13, "일상"), + Pair(14, "추리/미스터리"), + Pair(15, "공포/스릴러"), + Pair(16, "성인"), + Pair(17, "옴니버스"), + Pair(18, "에피소드"), + Pair(19, "무협"), + Pair(20, "소년"), + Pair(99, "기타"), +) + +val publishDayMap = mapOf( + Pair(1, "월"), + Pair(2, "화"), + Pair(3, "수"), + Pair(4, "목"), + Pair(5, "금"), + Pair(6, "토"), + Pair(7, "일"), + Pair(10, "열흘"), +) diff --git a/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Dto.kt b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Dto.kt new file mode 100644 index 000000000..ed5fb19b9 --- /dev/null +++ b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Dto.kt @@ -0,0 +1,84 @@ +package eu.kanade.tachiyomi.extension.ko.agitoon + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class SeriesItem( + @SerialName("x") + private val id: String, + @SerialName("t") + val name: String, + @SerialName("p") + private val poster: String = "", + @SerialName("au") + val author: String = "", + @SerialName("g") + val updatedAt: Long = 0, + @SerialName("tag") + private val tagIds: String = "", + @SerialName("c") + private val platformId: String = "-1", + @SerialName("d") + private val publishDayId: String = "-1", + @SerialName("h") + val hot: Int = 0, +) { + val tag get() = tagIds.split(",") + .filter(String::isNotBlank) + .map(String::toInt) + + val platform get() = platformId.toInt() + + val publishDay get() = publishDayId.toInt() + + var listIndex = -1 + + fun toSManga(cdnUrl: String) = SManga.create().apply { + url = id + title = name + thumbnail_url = poster.takeIf { it.isNotBlank() }?.let { + cdnUrl + it.replace("_x4", "").replace("_x3", "") + } + genre = buildList { + add(platformsMap[platform]) + add(publishDayMap[publishDay]) + tag.forEach { + add(tagsMap[it]) + } + }.filterNotNull().joinToString() + author = this@SeriesItem.author + status = when (listIndex) { + 0 -> SManga.COMPLETED + 1 -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } +} + +@Serializable +class Chapter( + @SerialName("id") + val id: String, + @SerialName("t") + val title: String, + @SerialName("d") + val date: String = "", +) { + fun toSChapter(mangaId: String) = SChapter.create().apply { + url = "$mangaId/$id" + name = title + date_upload = try { + dateFormat.parse(date)!!.time + } catch (_: ParseException) { + 0L + } + } +} + +private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) diff --git a/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Filters.kt b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Filters.kt new file mode 100644 index 000000000..8bf2e462f --- /dev/null +++ b/src/ko/agitoon/src/eu/kanade/tachiyomi/extension/ko/agitoon/Filters.kt @@ -0,0 +1,122 @@ +package eu.kanade.tachiyomi.extension.ko.agitoon + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +interface ListFilter { + fun applyFilter(list: List): List +} + +class TriFilter(name: String, val id: Int) : Filter.TriState(name) + +abstract class TriFilterGroup( + name: String, + values: Map, +) : Filter.Group(name, values.map { TriFilter(it.value, it.key) }), ListFilter { + private val included get() = state.filter { it.isIncluded() }.map { it.id } + private val excluded get() = state.filter { it.isExcluded() }.map { it.id } + + abstract fun SeriesItem.getAttribute(): List + override fun applyFilter(list: List): List { + return list.filter { series -> + included.all { + it in series.getAttribute() + } and excluded.all { + it !in series.getAttribute() + } + } + } +} + +abstract class SelectFilter( + name: String, + private val options: List>, +) : Filter.Select( + name, + options.map { it.second }.toTypedArray(), +) { + + val selected get() = options[state].first +} + +class TagFilter : TriFilterGroup("Tag", tagsMap) { + override fun SeriesItem.getAttribute(): List { + return tag + } +} + +class PlatformFilter : + SelectFilter( + "Platform", + buildList { + add(-1 to "") + platformsMap.forEach { + add(it.key to it.value) + } + }, + ), + ListFilter { + override fun applyFilter(list: List): List { + return list.filter { selected == -1 || it.platform == selected } + } +} + +class PublishDayFilter : + SelectFilter( + "Publishing Day", + buildList { + add(-1 to "") + publishDayMap.forEach { + add(it.key to it.value) + } + }, + ), + ListFilter { + override fun applyFilter(list: List): List { + return list.filter { selected == -1 || it.publishDay == state } + } +} + +class Status : + SelectFilter( + "Status", + listOf( + -1 to "All", + 1 to "연재", + 0 to "완결", + ), + ), + ListFilter { + override fun applyFilter(list: List): List { + return when (selected) { + 1, 0 -> list.filter { it.listIndex == selected } + else -> list + } + } +} + +class Order : + SelectFilter( + "Order by", + listOf( + 0 to "최신순", + 1 to "인기순", + ), + ), + ListFilter { + override fun applyFilter(list: List): List { + return when (selected) { + 0 -> list.sortedByDescending { it.updatedAt } + 1 -> list.sortedByDescending { it.hot } + else -> list + } + } +} + +fun getFilters() = FilterList( + Order(), + Status(), + PlatformFilter(), + PublishDayFilter(), + TagFilter(), +)