diff --git a/src/ko/toon11/build.gradle b/src/ko/toon11/build.gradle new file mode 100644 index 000000000..48b3f9a3e --- /dev/null +++ b/src/ko/toon11/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = '11toon' + extClass = '.Toon11' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ko/toon11/res/mipmap-hdpi/ic_launcher.png b/src/ko/toon11/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..398b19fb9 Binary files /dev/null and b/src/ko/toon11/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ko/toon11/res/mipmap-mdpi/ic_launcher.png b/src/ko/toon11/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..2bb44db86 Binary files /dev/null and b/src/ko/toon11/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ko/toon11/res/mipmap-xhdpi/ic_launcher.png b/src/ko/toon11/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b8a2b48a4 Binary files /dev/null and b/src/ko/toon11/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ko/toon11/res/mipmap-xxhdpi/ic_launcher.png b/src/ko/toon11/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..17ec634d6 Binary files /dev/null and b/src/ko/toon11/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ko/toon11/res/mipmap-xxxhdpi/ic_launcher.png b/src/ko/toon11/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..51d9e9d30 Binary files /dev/null and b/src/ko/toon11/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ko/toon11/src/eu/kanade/tachiyomi/extension/ko/toon11/Toon11.kt b/src/ko/toon11/src/eu/kanade/tachiyomi/extension/ko/toon11/Toon11.kt new file mode 100644 index 000000000..ff86dbed3 --- /dev/null +++ b/src/ko/toon11/src/eu/kanade/tachiyomi/extension/ko/toon11/Toon11.kt @@ -0,0 +1,265 @@ +package eu.kanade.tachiyomi.extension.ko.toon11 + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.ArrayList +import java.util.Date +import java.util.Locale + +class Toon11 : ParsedHttpSource() { + + override val name = "11toon" + + override val baseUrl = "https://www.11toon.com" + + override val lang = "ko" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient + + override fun popularMangaSelector() = "li[data-id]" + + override fun latestUpdatesSelector() = popularMangaSelector() + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/bbs/board.php?bo_table=toon_c&is_over=0", headers) + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/bbs/board.php?bo_table=toon_c&sord=&type=upd&page=$page", headers) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + return if (query.isNotBlank()) { + val url = "$baseUrl/bbs/search_stx.php".toHttpUrl().newBuilder().apply { + addQueryParameter("stx", query) + }.build() + GET(url, headers) + } else { + var urlString = "" + var isOver = "" + var genre = "" + + filters.forEach { filter -> + when (filter) { + is SortFilter -> urlString = filter.selected + is StatusFilter -> isOver = filter.selected + is GenreFilter -> genre = filter.selected + else -> {} + } + } + + val url = urlString.toHttpUrl().newBuilder().apply { + addQueryParameter("is_over", isOver) + if (page > 1) addQueryParameter("page", page.toString()) + if (genre.isNotEmpty()) addQueryParameter("sca", genre) + }.build() + + GET(url, headers) + } + } + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + title = element.selectFirst(".homelist-title")!!.text() + thumbnail_url = element.selectFirst(".homelist-thumb")?.absUrl("data-mobile-image") + } + + override fun latestUpdatesFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + title = element.selectFirst(".homelist-title")!!.text() + element.selectFirst(".homelist-thumb")?.also { + thumbnail_url = "https:" + it.attr("style").substringAfter("url('").substringBefore("')") + } + } + + override fun popularMangaNextPageSelector() = ".pg_end" + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + title = element.selectFirst(".homelist-title")!!.text() + val dataId = element.attr("data-id") + setUrlWithoutDomain("$baseUrl/bbs/board.php?bo_table=toons&stx=$title&is=$dataId") + element.selectFirst(".homelist-thumb")?.also { + thumbnail_url = "https:" + it.attr("style").substringAfter("url('").substringBefore("')") + } + } + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + override fun mangaDetailsParse(document: Document): SManga { + return SManga.create().apply { + title = document.selectFirst("h2.title")!!.text() + thumbnail_url = document.selectFirst("img.banner")?.absUrl("src") + document.selectFirst("span:contains(분류) + span")?.also { status = parseStatus(it.text()) } + document.selectFirst("span:contains(작가) + span")?.also { author = it.text() } + document.selectFirst("span:contains(소개) + span")?.also { description = it.text() } + document.selectFirst("span:contains(장르) + span")?.also { genre = it.text().split(",").joinToString() } + } + } + + private fun parseStatus(element: String): Int = when { + "완결" in element -> SManga.COMPLETED + "주간" in element || "월간" in element || "연재" in element || "격주" in element -> SManga.ONGOING + else -> SManga.UNKNOWN + } + + private tailrec fun parseChapters(nextURL: String, chapters: ArrayList) { + val newpage = fetchPagesFromNav(nextURL) + newpage.select(chapterListSelector()).forEach { + chapters.add(chapterFromElement(it)) + } + val newURL = newpage.selectFirst(".pg_current ~ .pg_page")?.absUrl("href") + if (!newURL.isNullOrBlank()) parseChapters(newURL, chapters) + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val nav = document.selectFirst("span.pg") + val chapters = ArrayList() + + document.select(chapterListSelector()).forEach { + chapters.add(chapterFromElement(it)) + } + + if (nav == null) { + return chapters + } + + val pg2url = nav.selectFirst(".pg_current ~ .pg_page")!!.absUrl("href") + + // recursively build the chapter list + + parseChapters(pg2url, chapters) + + return chapters + } + + private fun fetchPagesFromNav(url: String) = client.newCall(GET(url, headers)).execute().asJsoup() + + override fun chapterListSelector() = "#comic-episode-list > li" + + override fun chapterFromElement(element: Element): SChapter { + val urlEl = element.selectFirst("button") + val dateEl = element.selectFirst(".free-date") + + return SChapter.create().apply { + urlEl!!.also { + setUrlWithoutDomain(it.attr("onclick").substringAfter("location.href='.").substringBefore("'")) + name = it.selectFirst(".episode-title")!!.text() + } + dateEl?.also { date_upload = dateParse(it.text()) } + } + } + + private val dateFormat = SimpleDateFormat("yy.MM.dd", Locale.ENGLISH) + + private fun dateParse(dateAsString: String): Long { + val date: Date? = try { + dateFormat.parse(dateAsString) + } catch (e: ParseException) { + null + } + return date?.time ?: 0L + } + + override fun pageListRequest(chapter: SChapter): Request { + return GET(baseUrl + "/bbs" + chapter.url, headers) + } + + override fun pageListParse(document: Document): List { + val rawImageLinks = document.selectFirst("script + script[type^=text/javascript]:not([src])")!!.data() + val imgList = extractList(rawImageLinks) + + return imgList.mapIndexed { i, img -> + Page(i, imageUrl = "https:$img") + } + } + + private val imgListRegex = """img_list\s*=\s*(\[.*?])""".toRegex(RegexOption.DOT_MATCHES_ALL) + + private fun extractList(jsString: String): List { + val matchResult = imgListRegex.find(jsString) + val listString = matchResult?.groupValues?.get(1) ?: return emptyList() + return Json.decodeFromString>(listString) + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + + // Filters + + override fun getFilterList() = FilterList( + Filter.Header("Note: can't combine search query with filters, status filter only has effect in 인기만화"), + Filter.Separator(), + SortFilter(getSortList, 0), + StatusFilter(getStatusList, 0), + GenreFilter(getGenreList, 0), + ) + + class SelectFilterOption(val name: String, val value: String) + + abstract class SelectFilter(name: String, private val options: List, default: Int = 0) : Filter.Select(name, options.map { it.name }.toTypedArray(), default) { + val selected: String + get() = options[state].value + } + + class SortFilter(options: List, default: Int) : SelectFilter("Sort", options, default) + class StatusFilter(options: List, default: Int) : SelectFilter("Status", options, default) + class GenreFilter(options: List, default: Int) : SelectFilter("Genre", options, default) + + private val getSortList = listOf( + SelectFilterOption("인기만화", "$baseUrl/bbs/board.php?bo_table=toon_c"), + SelectFilterOption("최신만화", "$baseUrl/bbs/board.php?bo_table=toon_c&tablename=최신만화&type=upd"), + ) + + private val getStatusList = listOf( + SelectFilterOption("전체", "0"), + SelectFilterOption("완결", "1"), + ) + + private val getGenreList = listOf( + SelectFilterOption("전체", ""), + SelectFilterOption("SF", "SF"), + SelectFilterOption("무협", "무협"), + SelectFilterOption("TS", "TS"), + SelectFilterOption("개그", "개그"), + SelectFilterOption("드라마", "드라마"), + SelectFilterOption("러브코미디", "러브코미디"), + SelectFilterOption("먹방", "먹방"), + SelectFilterOption("백합", "백합"), + SelectFilterOption("붕탁", "붕탁"), + SelectFilterOption("스릴러", "스릴러"), + SelectFilterOption("스포츠", "스포츠"), + SelectFilterOption("시대", "시대"), + SelectFilterOption("액션", "액션"), + SelectFilterOption("순정", "순정"), + SelectFilterOption("일상+치유", "일상%2B치유"), + SelectFilterOption("추리", "추리"), + SelectFilterOption("판타지", "판타지"), + SelectFilterOption("학원", "학원"), + SelectFilterOption("호러", "호러"), + SelectFilterOption("BL", "BL"), + SelectFilterOption("17", "17"), + SelectFilterOption("이세계", "이세계"), + SelectFilterOption("전생", "전생"), + SelectFilterOption("라노벨", "라노벨"), + SelectFilterOption("애니화", "애니화"), + SelectFilterOption("TL", "TL"), + ) +}