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