diff --git a/src/en/kunmangato/build.gradle b/src/en/kunmangato/build.gradle new file mode 100644 index 0000000000..f6ebb4c211 --- /dev/null +++ b/src/en/kunmangato/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'KunMangaTo' + extClass = '.KunMangaTo' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/kunmangato/res/mipmap-hdpi/ic_launcher.png b/src/en/kunmangato/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..5fb609c1b8 Binary files /dev/null and b/src/en/kunmangato/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/kunmangato/res/mipmap-mdpi/ic_launcher.png b/src/en/kunmangato/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..ba86f16fdb Binary files /dev/null and b/src/en/kunmangato/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/kunmangato/res/mipmap-xhdpi/ic_launcher.png b/src/en/kunmangato/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..dcc9c69105 Binary files /dev/null and b/src/en/kunmangato/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/kunmangato/res/mipmap-xxhdpi/ic_launcher.png b/src/en/kunmangato/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..77eada0c40 Binary files /dev/null and b/src/en/kunmangato/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/kunmangato/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/kunmangato/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..1a29154b04 Binary files /dev/null and b/src/en/kunmangato/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaTo.kt b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaTo.kt new file mode 100644 index 0000000000..c8f78c55c2 --- /dev/null +++ b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaTo.kt @@ -0,0 +1,213 @@ +package eu.kanade.tachiyomi.extension.en.kunmangato + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.Filter +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.injectLazy +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.concurrent.thread + +class KunMangaTo : ParsedHttpSource() { + override val name = "KunMangaTo" + override val baseUrl = "https://kunmanga.to" + override val lang = "en" + override val supportsLatest = true + + private val json: Json by injectLazy() + + private val dateFormat by lazy { + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) + } + + override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) + + override fun popularMangaParse(response: Response): MangasPage { + val mangas = super.popularMangaParse(response).mangas.distinctBy { it.url } + return MangasPage(mangas, false) + } + + override fun popularMangaSelector(): String = ".sidebar-box-popular article" + + override fun popularMangaFromElement(element: Element): SManga { + val a = element.selectFirst("a.manga")!! + return SManga.create().apply { + title = a.text() + url = a.attr("href").removePrefix(baseUrl) + thumbnail_url = element.selectFirst("img")!!.attr("src") + } + } + + override fun popularMangaNextPageSelector(): String? = null + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val urlBuilder = "$baseUrl/search".toHttpUrl().newBuilder() + .addQueryParameter("term", query.trim()) + .addQueryParameter("page", page.toString()) + + filters.filterIsInstance().forEach { filter -> + urlBuilder.addQueryParameter( + filter.toQueryParam(), + filter.toUriPart(), + ) + } + + return GET(urlBuilder.build(), headers) + } + + override fun searchMangaSelector(): String = "article .card-manga" + + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector(): String = "ul.pagination-primary a[rel=next]" + + override fun latestUpdatesRequest(page: Int): Request { + val urlBuilder = "$baseUrl/latest".toHttpUrl().newBuilder() + .addQueryParameter("page", page.toString()) + + return GET(urlBuilder.build(), headers) + } + + override fun latestUpdatesSelector(): String = searchMangaSelector() + + override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element) + + override fun latestUpdatesNextPageSelector(): String = searchMangaNextPageSelector() + + override fun mangaDetailsParse(document: Document): SManga { + return SManga.create().apply { + title = document.selectFirst(".page-heading")!!.text() + author = document.selectFirst("p.mb-1:nth-child(1)")!!.text().drop(9) + description = document.getElementById("manga-description")!!.text() + genre = + document.select("a.manga-genre").joinToString(", ") { element -> element.text() } + status = + if (document.select(".text-success").size > 0) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = document.selectFirst("img.text-end")!!.attr("src") + } + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val chaptersDocument = + Jsoup.parse("
" + document.selectFirst(chapterListSelector())!!.attr("value") + "
") + val chapterItems = chaptersDocument.select(".chapter-item") + return chapterItems.map { element -> + chapterFromElement(element) + } + } + + override fun chapterListSelector(): String = "#chapterList" + + override fun chapterFromElement(element: Element): SChapter { + return SChapter.create().apply { + url = element.getElementsByTag("a").first()!!.attr("href").removePrefix(baseUrl) + name = element.getElementsByTag("h3").first()!!.text() + date_upload = element.selectFirst(".text-muted")!!.text().parseChapterDate() + } + } + + private fun String?.parseChapterDate(): Long { + if (this == null) return 0L + return try { + dateFormat.parse(this)!!.time + } catch (_: ParseException) { + 0L + } + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + val form = FormBody.Builder() + .add( + "chapterIdx", + response.request.url.toString().substringBefore(".html").substringAfterLast("-"), + ) + .build() + + val chaptersRequest = POST( + "$baseUrl/chapter-resources", + headers.newBuilder().add( + "X-CSRF-TOKEN", + document.selectFirst("head > meta[name=\"csrf-token\"]")!!.attr("content"), + ).add( + "Cookie", + response.headers.values("set-cookie") + .find { value -> value.startsWith("kunmanga_session") }!!, + ).build(), + form, + ) + val chaptersResponse = client.newCall(chaptersRequest).execute() + val chapterResources = + json.decodeFromString(chaptersResponse.body.string()) + return chapterResources.data.resources.map { resource -> + Page(resource.id, imageUrl = resource.thumb) + } + } + + private var filterValues: Map>? = null + + private var fetchFilterValuesAttempts: Int = 0 + + private fun parseFilters(document: Document): Map> { + return KunMangaToFilter.values().associateWith { + document.select("[name=\"${it.queryParam}\"] option") + .map { option -> option.attr("value") to option.text() } + } + } + + private fun fetchFiltersValues() { + if (fetchFilterValuesAttempts < 3 && filterValues == null) { + thread { + try { + filterValues = parseFilters( + client.newCall(searchMangaRequest(1, "", FilterList())).execute().asJsoup(), + ) + } finally { + fetchFilterValuesAttempts++ + } + } + } + } + + override fun getFilterList(): FilterList { + fetchFiltersValues() + return if (filterValues != null) { + FilterList( + filterValues!!.map { filterValue -> + UriPartFilter( + filterValue.key, + filterValue.value, + ) + }, + ) + } else { + FilterList(Filter.Header("Press 'Reset' to attempt to fetch the filters")) + } + } + + override fun imageUrlParse(document: Document): String { + throw UnsupportedOperationException() + } + + override fun pageListParse(document: Document): List { + throw UnsupportedOperationException() + } +} diff --git a/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToChapterResourcesDtos.kt b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToChapterResourcesDtos.kt new file mode 100644 index 0000000000..7d8c4bfa89 --- /dev/null +++ b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToChapterResourcesDtos.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.extension.en.kunmangato + +import kotlinx.serialization.Serializable + +@Serializable +class KunMangaToChapterResourcesDto( + val status: Int, + val data: KunMangaToDataDto, +) + +@Serializable +class KunMangaToDataDto( + val resources: List, +) + +@Serializable +class KunMangaToResourceDto( + val id: Int, + val name: Int, + val thumb: String, +) diff --git a/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToFilters.kt b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToFilters.kt new file mode 100644 index 0000000000..6943ec01d8 --- /dev/null +++ b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/KunMangaToFilters.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.extension.en.kunmangato + +enum class KunMangaToFilter(val queryParam: String) { + Genre("manga_genre_id"), + Type("manga_type_id"), + Status("status"), +} + +typealias OptionName = String + +typealias OptionValue = String + +typealias OptionValueOptionNamePair = Pair diff --git a/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/UriPartFilter.kt b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/UriPartFilter.kt new file mode 100644 index 0000000000..d485efa48a --- /dev/null +++ b/src/en/kunmangato/src/eu/kanade/tachiyomi/extension/en/kunmangato/UriPartFilter.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.extension.en.kunmangato + +import eu.kanade.tachiyomi.source.model.Filter + +open class UriPartFilter( + private val filter: KunMangaToFilter, + private val vals: List, +) : Filter.Select(filter.name, vals.map { it.second }.toTypedArray()) { + fun toQueryParam() = filter.queryParam + + fun toUriPart() = vals[state].first +}