parent
9fd4ff010d
commit
7c8f692488
|
@ -0,0 +1,8 @@
|
||||||
|
ext {
|
||||||
|
extName = 'BlackToon'
|
||||||
|
extClass = '.BlackToon'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -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<Json>()
|
||||||
|
|
||||||
|
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<List<SeriesItem>>(it) }
|
||||||
|
.onEach { it.listIndex = listIdx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<SeriesItem>.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<MangasPage> {
|
||||||
|
return Observable.just(
|
||||||
|
db.sortedByDescending { it.hot }.getPageChunk(page),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||||
|
return Observable.just(
|
||||||
|
db.sortedByDescending { it.updatedAt }.getPageChunk(page),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
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<ListFilter>().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<SChapter> {
|
||||||
|
val mangaId = response.request.url.pathSegments.last().removeSuffix(".js")
|
||||||
|
|
||||||
|
val data = response.body.string()
|
||||||
|
.substringAfter(" = ")
|
||||||
|
.removeSuffix(";")
|
||||||
|
.let { json.decodeFromString<List<Chapter>>(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<Page> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 "열흘",
|
||||||
|
)
|
|
@ -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)
|
|
@ -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<SeriesItem>): List<SeriesItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
class TriFilter(name: String, val id: Int) : Filter.TriState(name)
|
||||||
|
|
||||||
|
abstract class TriFilterGroup(
|
||||||
|
name: String,
|
||||||
|
values: Map<Int, String>,
|
||||||
|
) : Filter.Group<TriFilter>(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<Int>
|
||||||
|
override fun applyFilter(list: List<SeriesItem>): List<SeriesItem> {
|
||||||
|
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<Pair<Int, String>>,
|
||||||
|
) : Filter.Select<String>(
|
||||||
|
name,
|
||||||
|
options.map { it.second }.toTypedArray(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
val selected get() = options[state].first
|
||||||
|
}
|
||||||
|
|
||||||
|
class TagFilter : TriFilterGroup("Tag", tagsMap) {
|
||||||
|
override fun SeriesItem.getAttribute(): List<Int> {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformFilter :
|
||||||
|
SelectFilter(
|
||||||
|
"Platform",
|
||||||
|
buildList {
|
||||||
|
add(-1 to "")
|
||||||
|
platformsMap.forEach {
|
||||||
|
add(it.key to it.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListFilter {
|
||||||
|
override fun applyFilter(list: List<SeriesItem>): List<SeriesItem> {
|
||||||
|
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<SeriesItem>): List<SeriesItem> {
|
||||||
|
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<SeriesItem>): List<SeriesItem> {
|
||||||
|
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<SeriesItem>): List<SeriesItem> {
|
||||||
|
return when (selected) {
|
||||||
|
0 -> list.sortedByDescending { it.updatedAt }
|
||||||
|
1 -> list.sortedByDescending { it.hot }
|
||||||
|
else -> list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFilters() = FilterList(
|
||||||
|
Order(),
|
||||||
|
Status(),
|
||||||
|
PlatformFilter(),
|
||||||
|
PublishDayFilter(),
|
||||||
|
TagFilter(),
|
||||||
|
)
|
Loading…
Reference in New Issue