Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add KunMangaTo source #6192

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/en/kunmangato/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ext {
extName = 'KunMangaTo'
extClass = '.KunMangaTo'
extVersionCode = 1
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be marked as NSFW since they carry 'He Does a Body Good'.


apply from: "$rootDir/common.gradle"
Binary file added src/en/kunmangato/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
url = a.attr("href").removePrefix(baseUrl)
setUrlWithoutDomain(a.absUrl("href"))

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<UriPartFilter>().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() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

", " is already the default join operation, so no need to specify

status =
if (document.select(".text-success").size > 0) SManga.COMPLETED else SManga.ONGOING
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is somewhat flaky. Since it is so generic it will cause issues if the layout changes slightly. It would be preferable if it's SManga.UNKOWN by default, and only set to either completed or ongoing if found.

The document is a bit difficult to navigate, but you can rely on Jsoup's selectors:

*:has(~ #manga-description) p span:contains(Status:) ~ span

thumbnail_url = document.selectFirst("img.text-end")!!.attr("src")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thumbnail_url should be optional.

}
}

override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val chaptersDocument =
Jsoup.parse("<div>" + document.selectFirst(chapterListSelector())!!.attr("value") + "</div>")
Comment on lines +109 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use Jsoup.parseBodyFragment instead.
(won't need to add div tag manually i think)

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setUrlWithoutDomain here too

also use selectFirst instead of calling first()

name = element.getElementsByTag("h3").first()!!.text()
date_upload = element.selectFirst(".text-muted")!!.text().parseChapterDate()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

date_upload should be optional.

}
}

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<Page> {
val document = response.asJsoup()
val form = FormBody.Builder()
.add(
"chapterIdx",
response.request.url.toString().substringBefore(".html").substringAfterLast("-"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract to variable. Consider using regex.

val document = response.asJsoup()
val chapterIdx = response.request.url.toString().substringBefore(".html").substringAfterLast("-")
val form = FormBody.Builder()
    .add("chapterIdx", chapterIdx)
    .build()

val chaptersRequest = POST(
// ...

)
.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<KunMangaToChapterResourcesDto>(chaptersResponse.body.string())
return chapterResources.data.resources.map { resource ->
Page(resource.id, imageUrl = resource.thumb)
}
}

private var filterValues: Map<KunMangaToFilter, List<OptionValueOptionNamePair>>? = null

private var fetchFilterValuesAttempts: Int = 0

private fun parseFilters(document: Document): Map<KunMangaToFilter, List<OptionValueOptionNamePair>> {
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<Page> {
throw UnsupportedOperationException()
}
}
Original file line number Diff line number Diff line change
@@ -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<KunMangaToResourceDto>,
)

@Serializable
class KunMangaToResourceDto(
val id: Int,
val name: Int,
val thumb: String,
)
Original file line number Diff line number Diff line change
@@ -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<OptionValue, OptionName>
Original file line number Diff line number Diff line change
@@ -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<OptionValueOptionNamePair>,
) : Filter.Select<String>(filter.name, vals.map { it.second }.toTypedArray()) {
fun toQueryParam() = filter.queryParam

fun toUriPart() = vals[state].first
}