diff --git a/src/ko/blacktoon/build.gradle b/src/ko/blacktoon/build.gradle new file mode 100644 index 000000000..dd87ca866 --- /dev/null +++ b/src/ko/blacktoon/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'BlackToon' + extClass = '.BlackToon' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ko/blacktoon/res/mipmap-hdpi/ic_launcher.png b/src/ko/blacktoon/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e625e5a35 Binary files /dev/null and b/src/ko/blacktoon/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ko/blacktoon/res/mipmap-mdpi/ic_launcher.png b/src/ko/blacktoon/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..9ced3b46a Binary files /dev/null and b/src/ko/blacktoon/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ko/blacktoon/res/mipmap-xhdpi/ic_launcher.png b/src/ko/blacktoon/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e6c06dc4c Binary files /dev/null and b/src/ko/blacktoon/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ko/blacktoon/res/mipmap-xxhdpi/ic_launcher.png b/src/ko/blacktoon/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..9366697b4 Binary files /dev/null and b/src/ko/blacktoon/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ko/blacktoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/ko/blacktoon/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..0137951ab Binary files /dev/null and b/src/ko/blacktoon/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/BlackToon.kt b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/BlackToon.kt new file mode 100644 index 000000000..d7dd1514b --- /dev/null +++ b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/BlackToon.kt @@ -0,0 +1,218 @@ +package eu.kanade.tachiyomi.extension.ko.blacktoon + +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 BlackToon : HttpSource() { + + override val name = "블랙툰" + + override val lang = "ko" + + private var currentBaseUrlHost = "" + override val baseUrl = "https://blacktoon.me" + + 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/webtoon/${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("/webtoon/") + 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("/webtoons/") + append(chapter.url) + append(".html") + } + } + + override fun pageListRequest(chapter: SChapter): Request { + return GET("$baseUrl/webtoons/${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/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Data.kt b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Data.kt new file mode 100644 index 000000000..56aac86a8 --- /dev/null +++ b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Data.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.extension.ko.blacktoon + +val platformsMap = mapOf( + 1 to "네이버", + 2 to "다음", + 3 to "카카오", + 4 to "레진", + 5 to "투믹스", + 6 to "탑툰", + 7 to "코미카", + 8 to "배틀코믹", + 9 to "코믹GT", + 10 to "케이툰", + 11 to "애니툰", + 12 to "폭스툰", + 13 to "피너툰", + 14 to "봄툰", + 15 to "코미코", + 16 to "무툰", + 17 to "지존신마", + 99 to "기타", +) + +val tagsMap = mapOf( + 1 to "학원", + 2 to "액션", + 3 to "SF", + 4 to "스토리", + 5 to "판타지", + 6 to "BL/백합", + 7 to "개그/코미디", + 8 to "연애/순정", + 9 to "드라마", + 10 to "로맨스", + 11 to "시대극", + 12 to "스포츠", + 13 to "일상", + 14 to "추리/미스터리", + 15 to "공포/스릴러", + 16 to "성인", + 17 to "옴니버스", + 18 to "에피소드", + 19 to "무협", + 20 to "소년", + 99 to "기타", +) + +val publishDayMap = mapOf( + 1 to "월", + 2 to "화", + 3 to "수", + 4 to "목", + 5 to "금", + 6 to "토", + 7 to "일", + 10 to "열흘", +) diff --git a/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Dto.kt b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Dto.kt new file mode 100644 index 000000000..15d03c0a7 --- /dev/null +++ b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Dto.kt @@ -0,0 +1,84 @@ +package eu.kanade.tachiyomi.extension.ko.blacktoon + +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/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Filters.kt b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Filters.kt new file mode 100644 index 000000000..85c61baec --- /dev/null +++ b/src/ko/blacktoon/src/eu/kanade/tachiyomi/extension/ko/blacktoon/Filters.kt @@ -0,0 +1,122 @@ +package eu.kanade.tachiyomi.extension.ko.blacktoon + +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(), +)