diff --git a/kotatsu-parsers-ksp/src/main/kotlin/org/koitharu/kotatsu/parsers/ksp/ParserProcessor.kt b/kotatsu-parsers-ksp/src/main/kotlin/org/koitharu/kotatsu/parsers/ksp/ParserProcessor.kt index 4e05b6c0..d95f99b8 100644 --- a/kotatsu-parsers-ksp/src/main/kotlin/org/koitharu/kotatsu/parsers/ksp/ParserProcessor.kt +++ b/kotatsu-parsers-ksp/src/main/kotlin/org/koitharu/kotatsu/parsers/ksp/ParserProcessor.kt @@ -10,7 +10,6 @@ import com.google.devtools.ksp.validate import java.io.File import java.io.Writer import java.util.* -import kotlin.math.log class ParserProcessor( private val codeGenerator: CodeGenerator, @@ -76,7 +75,6 @@ class ParserProcessor( """.trimIndent(), ) - //language=kotlin sourcesWriter?.write( """ package org.koitharu.kotatsu.parsers.model diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/exception/GraphQLException.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/exception/GraphQLException.kt index e8bb16a2..c36da842 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/exception/GraphQLException.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/exception/GraphQLException.kt @@ -3,11 +3,11 @@ package org.koitharu.kotatsu.parsers.exception import okio.IOException import org.json.JSONArray import org.koitharu.kotatsu.parsers.InternalParsersApi -import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull -public class GraphQLException @InternalParsersApi constructor(private val errors: JSONArray) : IOException() { +public class GraphQLException @InternalParsersApi constructor(errors: JSONArray) : IOException() { - public val messages = errors.mapJSON { + public val messages: List = errors.mapJSONNotNull { it.getString("message") } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt index a0296143..64285374 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/HitomiLaParser.kt @@ -410,9 +410,7 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context for (i in 0.until(numberOfKeys)) { val keySize = buffer.int - if (keySize == 0 || keySize > 32) { - throw Exception("fatal: !keySize || keySize > 32") - } + check(keySize in 1..32) { "Invalid key size $keySize" } keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize))) buffer.position(buffer.position() + keySize) @@ -482,14 +480,14 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context title = doc.selectFirstOrThrow("h1").text(), url = id.toString(), coverUrl = - "https:" + - doc.selectFirstOrThrow("picture > source") - .attr("data-srcset") - .substringBefore(" "), + "https:" + + doc.selectFirstOrThrow("picture > source") + .attr("data-srcset") + .substringBefore(" "), publicUrl = - doc.selectFirstOrThrow("h1 > a") - .attrAsRelativeUrl("href") - .toAbsoluteUrl(domain), + doc.selectFirstOrThrow("h1 > a") + .attrAsRelativeUrl("href") + .toAbsoluteUrl(domain), author = null, tags = emptySet(), isNsfw = true, @@ -512,37 +510,37 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context return manga.copy( title = json.getString("title"), largeCoverUrl = - json.getJSONArray("files").getJSONObject(0).let { - val hash = it.getString("hash") - val commonId = commonImageId() - val imageId = imageIdFromHash(hash) - val subDomain = 'a' + subdomainOffset(imageId) - - "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp" - }, + json.getJSONArray("files").getJSONObject(0).let { + val hash = it.getString("hash") + val commonId = commonImageId() + val imageId = imageIdFromHash(hash) + val subDomain = 'a' + subdomainOffset(imageId) + + "https://${getDomain("${subDomain}a")}/webp/$commonId$imageId/$hash.webp" + }, author = - json.optJSONArray("artists") - ?.mapJSON { it.getString("artist").toCamelCase() } - ?.joinToString(), + json.optJSONArray("artists") + ?.mapJSON { it.getString("artist").toCamelCase() } + ?.joinToString(), publicUrl = json.getString("galleryurl").toAbsoluteUrl(domain), tags = - buildSet { - json.optJSONArray("characters") - ?.mapToTags("character") - ?.let(::addAll) - json.optJSONArray("tags") - ?.mapToTags("tag") - ?.let(::addAll) - json.optJSONArray("artists") - ?.mapToTags("artist") - ?.let(::addAll) - json.optJSONArray("parodys") - ?.mapToTags("parody") - ?.let(::addAll) - json.optJSONArray("groups") - ?.mapToTags("group") - ?.let(::addAll) - }, + buildSet { + json.optJSONArray("characters") + ?.mapToTags("character") + ?.let(::addAll) + json.optJSONArray("tags") + ?.mapToTags("tag") + ?.let(::addAll) + json.optJSONArray("artists") + ?.mapToTags("artist") + ?.let(::addAll) + json.optJSONArray("parodys") + ?.mapToTags("parody") + ?.let(::addAll) + json.optJSONArray("groups") + ?.mapToTags("group") + ?.let(::addAll) + }, chapters = listOf( MangaChapter( id = generateUid(manga.url), @@ -566,15 +564,15 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context mapJSON { MangaTag( title = - it.getString(key).toCamelCase().let { title -> - if (it.getStringOrNull("female")?.toIntOrNull() == 1) { - "$title ♀" - } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) { - "$title ♂" - } else { - title - } - }, + it.getString(key).toCamelCase().let { title -> + if (it.getStringOrNull("female")?.toIntOrNull() == 1) { + "$title ♀" + } else if (it.getStringOrNull("male")?.toIntOrNull() == 1) { + "$title ♂" + } else { + title + } + }, key = it.getString("url").tagUrlToTag(), source = source, ).let(tags::add) @@ -681,11 +679,12 @@ internal class HitomiLaParser(context: MangaLoaderContext) : MangaParser(context return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1") } - private suspend fun subdomainFromURL(url: String, base: String? = null): String { + private suspend fun subdomainFromURL(url: String, base: String?): String { var retval = "b" - if (!base.isNullOrBlank()) + if (!base.isNullOrBlank()) { retval = base + } val regex = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""") val hashMatch = regex.find(url) ?: return "a" diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/NineNineNineHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/NineNineNineHentaiParser.kt index c0f90105..28c1a2bc 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/NineNineNineHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/NineNineNineHentaiParser.kt @@ -20,7 +20,7 @@ import java.util.* @MangaSourceParser("NINENINENINEHENTAI", "AnimeH", type = ContentType.HENTAI) internal class NineNineNineHentaiParser(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.NINENINENINEHENTAI, size), Interceptor { + PagedMangaParser(context, MangaParserSource.NINENINENINEHENTAI, PAGE_SIZE), Interceptor { override val configKeyDomain = ConfigKey.Domain("animeh.to") @@ -126,7 +126,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) : ): List { val query = """ queryPopularChapters( - size: $size + size: $PAGE_SIZE language: "${locale.getSiteLang()}" dateRange: 1 page: $page @@ -167,7 +167,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) : } val query = """ queryChapters( - limit: $size + limit: $PAGE_SIZE search: {$searchPayload} page: $page ) { @@ -322,7 +322,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) : _id: "${seed.url}" search: {sortBy:Popular} page: 1 - size: $size + size: $PAGE_SIZE ) { chapters { _id @@ -384,7 +384,7 @@ internal class NineNineNineHentaiParser(context: MangaLoaderContext) : } companion object { - private const val size = 20 + private const val PAGE_SIZE = 20 private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt index 15a06dab..c0cb6852 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.PagedMangaParser import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault @@ -61,9 +62,10 @@ internal class MangaMana(context: MangaLoaderContext) : PagedMangaParser(context return emptyList() } val domainCdn = "cdn" + domain.removePrefix("www") - val json = webClient.httpGet("https://$domain/search-live?q=${filter.query}").parseJsonArray() + val url = "https://$domain/search-live?q=${filter.query.urlEncoded()}" + val json = webClient.httpGet(url).parseJsonArray() return json.mapJSON { jo -> - val slug = jo.getString("slug") ?: throw Exception("Missing Slug") + val slug = jo.getString("slug") ?: throw ParseException("Missing Slug", url) val url = "https://$domain/m/$slug" val img = "https://$domainCdn/uploads/manga/$slug/cover/cover_thumb.jpg" Manga( diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/manga18/Manga18Parser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/manga18/Manga18Parser.kt index ce636f62..e67cfee0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/manga18/Manga18Parser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/manga18/Manga18Parser.kt @@ -80,14 +80,12 @@ internal abstract class Manga18Parser( } } - if (filter.query != null) { - filter.query.let { - append(listUrl) - append(page.toString()) - append("?search=") - append(filter.query.urlEncoded()) - append("&order_by=latest") - } + if (!filter.query.isNullOrEmpty()) { + append(listUrl) + append(page.toString()) + append("?search=") + append(filter.query.urlEncoded()) + append("&order_by=latest") } append("?order_by=") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt index a399bd99..7485f7fe 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt @@ -136,7 +136,7 @@ internal class HentaiVNParser(context: MangaLoaderContext) : MangaParser(context override suspend fun getPages(chapter: MangaChapter): List { val docs = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() return docs.select("#image > img").map { - val pageUrl = it.src() ?: throw Exception(it.html()) + val pageUrl = it.src() ?: it.parseFailed("Image src not found") MangaPage( id = generateUid(pageUrl), url = pageUrl, diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/SayHentai.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/SayHentai.kt index 5c483d6f..a18a985f 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/SayHentai.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/SayHentai.kt @@ -1,181 +1,184 @@ package org.koitharu.kotatsu.parsers.site.vi -import java.util.concurrent.atomic.AtomicReference import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.PagedMangaParser +import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.network.UserAgents import java.text.SimpleDateFormat import java.util.* @MangaSourceParser("SAYHENTAI", "SayHentai", "vi", ContentType.HENTAI) internal class SayHentai(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.SAYHENTAI, 20) { - override val configKeyDomain = ConfigKey.Domain("sayhentai.one") + override val configKeyDomain = ConfigKey.Domain("sayhentai.one") override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) } - private val tagsCache = AtomicReference?>(null) - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.RATING - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - availableTags = fetchTags(), - ) - } - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - if (!filter.query.isNullOrEmpty()) { - append("/search?s=") - append(filter.query.urlEncoded()) - append("&page=") - append(page.toString()) - } else { - if (filter.tags.isNotEmpty()) { - append("/genre/") - append(filter.tags.first().key) - append("/") - } else { - append("/") - } - append("?page=") - append(page.toString()) - val sortQuery = getSortOrderQuery(order, filter.tags.isNotEmpty()) - if (sortQuery.isNotEmpty()) { - append("&") - append(sortQuery) - } - } - } - - val doc = webClient.httpGet(url).parseHtml() - return doc.select(".page-item-detail").mapNotNull { element -> - val href = element.selectFirst(".item-summary a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null - Manga( - id = generateUid(href), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - title = element.selectFirst(".item-summary a")?.text().orEmpty(), - coverUrl = element.selectFirst(".item-thumb img")?.src().orEmpty(), - altTitle = null, - rating = RATING_UNKNOWN, - tags = emptySet(), - author = null, - state = null, - source = source, - isNsfw = isNsfwSource - ) - } - } - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - return manga.copy( - altTitle = doc.selectFirst("h2.other-name")?.text(), - author = doc.selectFirst("div.summary-heading:contains(Tác giả) + div.summary-content")?.text(), - tags = doc.select("div.genres-content a[rel=tag]").mapToSet { a -> - MangaTag( - key = a.attr("href").substringAfterLast('/'), - title = a.text().toTitleCase(sourceLocale), - source = source - ) - }, - description = doc.selectFirst("div.summary__content")?.html(), - state = when (doc.selectFirst("div.summary-heading:contains(Trạng thái) + div.summary-content")?.text()?.lowercase()) { - "đang ra" -> MangaState.ONGOING - "hoàn thành" -> MangaState.FINISHED - else -> null - }, - chapters = doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, element -> - val a = element.selectFirst("a") ?: return@mapChapters null - MangaChapter( - id = generateUid(a.attrAsRelativeUrl("href")), - name = a.text(), - number = i + 1f, - url = a.attrAsRelativeUrl("href"), - uploadDate = parseChapterDate(element.selectFirst("span.chapter-release-date")?.text()), - branch = null, - scanlator = null, - source = source, - volume = 0 - ) - } - ) - } - - override suspend fun getPages(chapter: MangaChapter): List { - val fullUrl = chapter.url.toAbsoluteUrl(domain) - val doc = webClient.httpGet(fullUrl).parseHtml() - return doc.select("div.page-break img").mapIndexed { i, img -> - val url = img.src().orEmpty() - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source - ) - } - } - - private fun parseChapterDate(date: String?): Long { - if (date == null) return 0 - return when { - date.contains("giây trước") -> System.currentTimeMillis() - date.removeSuffix(" giây trước").toLong() * 1000 - date.contains("phút trước") -> System.currentTimeMillis() - date.removeSuffix(" phút trước").toLong() * 60 * 1000 - date.contains("giờ trước") -> System.currentTimeMillis() - date.removeSuffix(" giờ trước").toLong() * 60 * 60 * 1000 - date.contains("ngày trước") -> System.currentTimeMillis() - date.removeSuffix(" ngày trước").toLong() * 24 * 60 * 60 * 1000 - date.contains("tuần trước") -> System.currentTimeMillis() - date.removeSuffix(" tuần trước").toLong() * 7 * 24 * 60 * 60 * 1000 - date.contains("tháng trước") -> System.currentTimeMillis() - date.removeSuffix(" tháng trước").toLong() * 30 * 24 * 60 * 60 * 1000 - date.contains("năm trước") -> System.currentTimeMillis() - date.removeSuffix(" năm trước").toLong() * 365 * 24 * 60 * 60 * 1000 - else -> SimpleDateFormat("dd/MM/yyyy", Locale.US).parse(date)?.time ?: 0L - } - } - - private fun getSortOrderQuery(order: SortOrder, hasTags: Boolean): String { - if (!hasTags) return "" - return when (order) { - SortOrder.UPDATED -> "m_orderby=latest" - SortOrder.POPULARITY -> "m_orderby=views" - SortOrder.ALPHABETICAL -> "m_orderby=alphabet" - SortOrder.RATING -> "m_orderby=rating" - else -> "m_orderby=latest" - } - } - - private suspend fun fetchTags(): Set { - return tagsCache.get() ?: run { - val tags = webClient.httpGet("https://$domain/genre").parseHtml() - .select("ul.page-genres li a") - .mapToSet { a -> - val title = a.ownText().toTitleCase(sourceLocale) - MangaTag( - key = a.attr("href").substringAfterLast("/"), - title = title, - source = source - ) - } - tagsCache.set(tags) - tags - } - } + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + SortOrder.RATING, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableTags = fetchTags(), + ) + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildString { + append("https://") + append(domain) + if (!filter.query.isNullOrEmpty()) { + append("/search?s=") + append(filter.query.urlEncoded()) + append("&page=") + append(page.toString()) + } else { + if (filter.tags.isNotEmpty()) { + append("/genre/") + append(filter.tags.first().key) + append("/") + } else { + append("/") + } + append("?page=") + append(page.toString()) + val sortQuery = getSortOrderQuery(order, filter.tags.isNotEmpty()) + if (sortQuery.isNotEmpty()) { + append("&") + append(sortQuery) + } + } + } + + val doc = webClient.httpGet(url).parseHtml() + return doc.select(".page-item-detail").mapNotNull { element -> + val href = element.selectFirst(".item-summary a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = element.selectFirst(".item-summary a")?.text().orEmpty(), + coverUrl = element.selectFirst(".item-thumb img")?.src().orEmpty(), + altTitle = null, + rating = RATING_UNKNOWN, + tags = emptySet(), + author = null, + state = null, + source = source, + isNsfw = isNsfwSource, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + return manga.copy( + altTitle = doc.selectFirst("h2.other-name")?.text(), + author = doc.selectFirst("div.summary-heading:contains(Tác giả) + div.summary-content")?.text(), + tags = doc.select("div.genres-content a[rel=tag]").mapToSet { a -> + MangaTag( + key = a.attr("href").substringAfterLast('/'), + title = a.text().toTitleCase(sourceLocale), + source = source, + ) + }, + description = doc.selectFirst("div.summary__content")?.html(), + state = when (doc.selectFirst("div.summary-heading:contains(Trạng thái) + div.summary-content")?.text() + ?.lowercase()) { + "đang ra" -> MangaState.ONGOING + "hoàn thành" -> MangaState.FINISHED + else -> null + }, + chapters = doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, element -> + val a = element.selectFirst("a") ?: return@mapChapters null + MangaChapter( + id = generateUid(a.attrAsRelativeUrl("href")), + name = a.text(), + number = i + 1f, + url = a.attrAsRelativeUrl("href"), + uploadDate = parseChapterDate(element.selectFirst("span.chapter-release-date")?.text()), + branch = null, + scanlator = null, + source = source, + volume = 0, + ) + }, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + return doc.selectOrThrow("div.page-break img").mapIndexed { i, img -> + val url = img.src().orEmpty() + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private fun parseChapterDate(date: String?): Long { + if (date == null) return 0 + return when { + date.contains("giây trước") -> System.currentTimeMillis() - date.removeSuffix(" giây trước").toLong() * 1000 + date.contains("phút trước") -> System.currentTimeMillis() - date.removeSuffix(" phút trước") + .toLong() * 60 * 1000 + + date.contains("giờ trước") -> System.currentTimeMillis() - date.removeSuffix(" giờ trước") + .toLong() * 60 * 60 * 1000 + + date.contains("ngày trước") -> System.currentTimeMillis() - date.removeSuffix(" ngày trước") + .toLong() * 24 * 60 * 60 * 1000 + + date.contains("tuần trước") -> System.currentTimeMillis() - date.removeSuffix(" tuần trước") + .toLong() * 7 * 24 * 60 * 60 * 1000 + + date.contains("tháng trước") -> System.currentTimeMillis() - date.removeSuffix(" tháng trước") + .toLong() * 30 * 24 * 60 * 60 * 1000 + + date.contains("năm trước") -> System.currentTimeMillis() - date.removeSuffix(" năm trước") + .toLong() * 365 * 24 * 60 * 60 * 1000 + + else -> SimpleDateFormat("dd/MM/yyyy", Locale.US).parse(date)?.time ?: 0L + } + } + + private fun getSortOrderQuery(order: SortOrder, hasTags: Boolean): String { + if (!hasTags) return "" + return when (order) { + SortOrder.UPDATED -> "m_orderby=latest" + SortOrder.POPULARITY -> "m_orderby=views" + SortOrder.ALPHABETICAL -> "m_orderby=alphabet" + SortOrder.RATING -> "m_orderby=rating" + else -> "m_orderby=latest" + } + } + + private suspend fun fetchTags(): Set = webClient.httpGet("https://$domain/genre").parseHtml() + .select("ul.page-genres li a") + .mapToSet { a -> + val title = a.ownText().toTitleCase(sourceLocale) + MangaTag( + key = a.attr("href").substringAfterLast("/"), + title = title, + source = source, + ) + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zeistmanga/ZeistMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zeistmanga/ZeistMangaParser.kt index 2953e077..5dbf1196 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zeistmanga/ZeistMangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zeistmanga/ZeistMangaParser.kt @@ -248,7 +248,7 @@ internal abstract class ZeistMangaParser( chapterRegex .find(script.html()) ?.groupValues?.get(1) - ?: throw Exception("Failed to find chapter feed") + ?: doc.parseFailed("Failed to find chapter feed") } else if (doc.selectFirst("#clwd > script") != null) { val chapterRegex = """clwd\.run\('([^']+)'""".toRegex() @@ -257,7 +257,7 @@ internal abstract class ZeistMangaParser( chapterRegex .find(script.html()) ?.groupValues?.get(1) - ?: throw Exception("Failed to find chapter feed") + ?: doc.parseFailed("Failed to find chapter feed") } else if (doc.selectFirst("#chapterlist") != null) { doc.selectFirstOrThrow("#chapterlist").attr("data-post-title") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Collection.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Collection.kt index f16691d7..03013deb 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Collection.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Collection.kt @@ -11,14 +11,14 @@ public fun MutableCollection.replaceWith(subject: Iterable) { addAll(subject) } -fun > Iterable>.flattenTo(destination: C): C { +public fun > Iterable>.flattenTo(destination: C): C { for (element in this) { destination.addAll(element) } return destination } -fun List.medianOrNull(): T? = when { +public fun List.medianOrNull(): T? = when { isEmpty() -> null else -> get((size / 2).coerceIn(indices)) } @@ -27,7 +27,7 @@ public inline fun Collection.mapToSet(transform: (T) -> R): Set { return mapTo(ArraySet(size), transform) } -inline fun Collection.mapNotNullToSet(transform: (T) -> R?): Set { +public inline fun Collection.mapNotNullToSet(transform: (T) -> R?): Set { val destination = ArraySet(size) for (item in this) { destination.add(transform(item) ?: continue) @@ -39,7 +39,7 @@ public inline fun Array.mapToArray(transform: (T) -> R): Array transform(get(i)) } -fun List>.toMutableMap(): MutableMap = toMap(ArrayMap(size)) +public fun List>.toMutableMap(): MutableMap = toMap(ArrayMap(size)) public fun MutableList.move(sourceIndex: Int, targetIndex: Int) { if (sourceIndex <= targetIndex) { @@ -49,23 +49,9 @@ public fun MutableList.move(sourceIndex: Int, targetIndex: Int) { } } -inline fun List.areItemsEquals(other: List, equals: (T, T) -> Boolean): Boolean { - if (size != other.size) { - return false - } - for (i in indices) { - val a = this[i] - val b = other[i] - if (!equals(a, b)) { - return false - } - } - return true -} - -fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null +public fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null -inline fun Collection.associateGrouping(transform: (T) -> Pair): Map> { +public inline fun Collection.associateGrouping(transform: (T) -> Pair): Map> { val result = LinkedHashMap>(size) for (item in this) { val (k, v) = transform(item) @@ -74,7 +60,7 @@ inline fun Collection.associateGrouping(transform: (T) -> Pair MutableMap.incrementAndGet(key: K): Int { +public fun MutableMap.incrementAndGet(key: K): Int { var value = get(key) ?: 0 value++ put(key, value) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/CryptoAES.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/CryptoAES.kt index 4a9e2946..d615d14a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/CryptoAES.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/CryptoAES.kt @@ -3,11 +3,9 @@ package org.koitharu.kotatsu.parsers.util import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.MangaLoaderContext import java.security.MessageDigest -import java.util.* import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec -import kotlin.Throws private const val HASH_CIPHER = "AES/CBC/PKCS7PADDING" private const val AES = "AES" @@ -32,8 +30,8 @@ public class CryptoAES( @Throws(Exception::class) public fun decrypt(cipherText: String, password: String): String { val ctBytes = context.decodeBase64(cipherText) - val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16) - val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size) + val saltBytes = ctBytes.copyOfRange(8, 16) + val cipherTextBytes = ctBytes.copyOfRange(16, ctBytes.size) val md5: MessageDigest = MessageDigest.getInstance(KDF_DIGEST) val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5) return decryptAES( diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/FaviconParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/FaviconParser.kt index 96b61b1d..e41f9931 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/FaviconParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/FaviconParser.kt @@ -78,19 +78,9 @@ public class FaviconParser( ) } - private fun String.resolveLink(): String { - return when { - startsWith("http:") || startsWith("https:") -> { - this - } - - startsWith('/') -> { - "https://$domain$this" - } - - else -> { - "https://$domain/$this" - } - } + private fun String.resolveLink(): String = when { + startsWith("http:") || startsWith("https:") -> this + startsWith('/') -> "https://$domain$this" + else -> "https://$domain/$this" } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Jsoup.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Jsoup.kt index 8f727b90..4ef870a0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Jsoup.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Jsoup.kt @@ -10,7 +10,7 @@ import org.jsoup.select.Selector import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.exception.ParseException -val Element.host: String? +public val Element.host: String? get() { val uri = baseUri() return if (uri.isEmpty()) { @@ -24,14 +24,14 @@ val Element.host: String? * Return an attribute value or null if it is missing or empty * @see [Element.attr] which returns empty string instead of null */ -fun Element.attrOrNull(attributeKey: String) = attr(attributeKey).takeUnless { it.isBlank() }?.trim() +public fun Element.attrOrNull(attributeKey: String) = attr(attributeKey).takeUnless { it.isBlank() }?.trim() /** * Return an attribute value or throw an exception if it is missing * @see [Element.attr] which returns empty string instead */ -fun Element.attrOrThrow(attributeKey: String): String = if (hasAttr(attributeKey)) { +public fun Element.attrOrThrow(attributeKey: String): String = if (hasAttr(attributeKey)) { attr(attributeKey) } else { throw ParseException("Attribute \"$attributeKey\" is missing at element \"$this\"", baseUri()) @@ -43,7 +43,7 @@ fun Element.attrOrThrow(attributeKey: String): String = if (hasAttr(attributeKey * @see attrAsAbsoluteUrlOrNull * @see attrAsAbsoluteUrl */ -fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? { +public fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? { val attr = attrOrNull(attributeKey) ?: return null if (attr.isEmpty() || attr.startsWith("data:")) { return null @@ -62,7 +62,7 @@ fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? { * @see attrAsAbsoluteUrlOrNull * @see attrAsAbsoluteUrl */ -fun Element.attrAsRelativeUrl(attributeKey: String): String { +public fun Element.attrAsRelativeUrl(attributeKey: String): String { return requireNotNull(attrAsRelativeUrlOrNull(attributeKey)) { "Cannot get relative url for $attributeKey: \"${attr(attributeKey)}\"" } @@ -74,7 +74,7 @@ fun Element.attrAsRelativeUrl(attributeKey: String): String { * @see attrAsRelativeUrl * @see attrAsRelativeUrlOrNull */ -fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? { +public fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? { val attr = attrOrNull(attributeKey) ?: return null if (attr.isEmpty() || attr.startsWith("data:")) { return null @@ -89,7 +89,7 @@ fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? { * @see attrAsRelativeUrl * @see attrAsRelativeUrlOrNull */ -fun Element.attrAsAbsoluteUrl(attributeKey: String): String { +public fun Element.attrAsAbsoluteUrl(attributeKey: String): String { return requireNotNull(attrAsAbsoluteUrlOrNull(attributeKey)) { "Cannot get absolute url for $attributeKey: \"${attr(attributeKey)}\"" } @@ -98,7 +98,7 @@ fun Element.attrAsAbsoluteUrl(attributeKey: String): String { /** * Return css value from `style` attribute or null if it is missing */ -fun Element.styleValueOrNull(property: String): String? { +public fun Element.styleValueOrNull(property: String): String? { val regex = Regex("${Regex.escape(property)}\\s*:\\s*[^;]+") val css = attr("style").find(regex) ?: return null return css.substringAfter(':').removeSuffix(';').trim() @@ -107,33 +107,33 @@ fun Element.styleValueOrNull(property: String): String? { /** * Like a `expectFirst` but with detailed error message */ -fun Element.selectFirstOrThrow(cssQuery: String): Element { +public fun Element.selectFirstOrThrow(cssQuery: String): Element { return Selector.selectFirst(cssQuery, this) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri()) } -fun Element.selectOrThrow(cssQuery: String): Elements { +public fun Element.selectOrThrow(cssQuery: String): Elements { return Selector.select(cssQuery, this).ifEmpty { throw ParseException("Empty result for \"$cssQuery\"", baseUri()) } } -fun Element.requireElementById(id: String): Element { +public fun Element.requireElementById(id: String): Element { return getElementById(id) ?: throw ParseException("Cannot find \"#$id\"", baseUri()) } -fun Element.selectLast(cssQuery: String): Element? { +public fun Element.selectLast(cssQuery: String): Element? { return select(cssQuery).lastOrNull() } -fun Element.selectLastOrThrow(cssQuery: String): Element { +public fun Element.selectLastOrThrow(cssQuery: String): Element { return selectLast(cssQuery) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri()) } -fun Element.textOrNull(): String? = text().takeUnless { it.isEmpty() } +public fun Element.textOrNull(): String? = text().takeUnless { it.isEmpty() } -fun Element.ownTextOrNull(): String? = ownText().takeUnless { it.isEmpty() } +public fun Element.ownTextOrNull(): String? = ownText().takeUnless { it.isEmpty() } -fun Element.selectFirstParent(query: String): Element? { +public fun Element.selectFirstParent(query: String): Element? { val selector = QueryParser.parse(query) val parents = parents() val root = parents.lastOrNull() ?: return null @@ -145,7 +145,7 @@ fun Element.selectFirstParent(query: String): Element? { /** * Return a first non-empty attribute value of [names] or null if it is missing or empty */ -fun Element.attrOrNull(vararg names: String): String? { +public fun Element.attrOrNull(vararg names: String): String? { for (name in names) { val value = attr(name) if (value.isNotEmpty()) { diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Number.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Number.kt index 390b3109..7eb3fdf9 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Number.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Number.kt @@ -5,6 +5,8 @@ package org.koitharu.kotatsu.parsers.util import java.text.DecimalFormat import java.text.NumberFormat import java.util.* +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract import kotlin.math.absoluteValue public fun Number.format(decimals: Int = 0, decPoint: Char = '.', thousandsSep: Char? = ' '): String { @@ -56,8 +58,14 @@ public fun Number.formatSimple(): String { } } -public inline fun Int.ifZero(defaultVale: () -> Int): Int = if (this == 0) { - defaultVale() -} else { - this +public inline fun Int.ifZero(defaultVale: () -> Int): Int { + contract { + callsInPlace(defaultVale, InvocationKind.AT_MOST_ONCE) + } + @Suppress("KotlinConstantConditions") + return if (this == 0) { + defaultVale() + } else { + this + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/OkHttp.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/OkHttp.kt index 6fde2a48..a5f48173 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/OkHttp.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/OkHttp.kt @@ -7,6 +7,8 @@ import okhttp3.Call import okhttp3.Headers import okhttp3.Response import okhttp3.ResponseBody +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract public suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> val callback = ContinuationCallCallback(this, continuation) @@ -39,8 +41,13 @@ public fun Response.Builder.setHeader(name: String, value: String?): Response.Bu header(name, value) } -public inline fun Response.map(mapper: (ResponseBody) -> ResponseBody): Response = body?.use { responseBody -> - newBuilder() - .body(mapper(responseBody)) - .build() -} ?: this +public inline fun Response.map(mapper: (ResponseBody) -> ResponseBody): Response { + contract { + callsInPlace(mapper, InvocationKind.AT_MOST_ONCE) + } + return body?.use { responseBody -> + newBuilder() + .body(mapper(responseBody)) + .build() + } ?: this +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Result.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Result.kt index 369bb018..3b4bc91a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Result.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Result.kt @@ -1,8 +1,14 @@ package org.koitharu.kotatsu.parsers.util import kotlinx.coroutines.CancellationException +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +@Suppress("WRONG_INVOCATION_KIND") // https://youtrack.jetbrains.com/issue/KT-70714 public inline fun T.runCatchingCancellable(block: T.() -> R): Result { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } return try { Result.success(block()) } catch (e: InterruptedException) { @@ -15,6 +21,9 @@ public inline fun T.runCatchingCancellable(block: T.() -> R): Result { } public inline fun Result.recoverCatchingCancellable(transform: (exception: Throwable) -> R): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } return when (val exception = exceptionOrNull()) { null -> this else -> runCatchingCancellable { transform(exception) } @@ -22,6 +31,9 @@ public inline fun Result.recoverCatchingCancellable(transform: (ex } public inline fun Result.recoverNotNull(transform: (exception: Throwable) -> R?): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } return when (val exception = exceptionOrNull()) { null -> this else -> transform(exception)?.let(Result.Companion::success) ?: this diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/json/JsonExt.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/json/JsonExt.kt index 396c158a..813e9039 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/json/JsonExt.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/json/JsonExt.kt @@ -6,7 +6,7 @@ import org.json.JSONObject import java.util.* import kotlin.contracts.contract -inline fun > JSONArray.mapJSONTo( +public inline fun > JSONArray.mapJSONTo( destination: C, block: (JSONObject) -> R, ): C { @@ -18,7 +18,7 @@ inline fun > JSONArray.mapJSONTo( return destination } -inline fun > JSONArray.mapJSONNotNullTo( +public inline fun > JSONArray.mapJSONNotNullTo( destination: C, block: (JSONObject) -> R?, ): C { @@ -30,15 +30,15 @@ inline fun > JSONArray.mapJSONNotNullTo( return destination } -inline fun JSONArray.mapJSON(block: (JSONObject) -> T): List { +public inline fun JSONArray.mapJSON(block: (JSONObject) -> T): List { return mapJSONTo(ArrayList(length()), block) } -inline fun JSONArray.mapJSONNotNull(block: (JSONObject) -> T?): List { +public inline fun JSONArray.mapJSONNotNull(block: (JSONObject) -> T?): List { return mapJSONNotNullTo(ArrayList(length()), block) } -fun JSONArray.mapJSONIndexed(block: (Int, JSONObject) -> T): List { +public fun JSONArray.mapJSONIndexed(block: (Int, JSONObject) -> T): List { val len = length() val result = ArrayList(len) for (i in 0 until len) { @@ -48,13 +48,13 @@ fun JSONArray.mapJSONIndexed(block: (Int, JSONObject) -> T): List { return result } -fun JSONObject.getStringOrNull(name: String): String? = opt(name)?.takeUnless { +public fun JSONObject.getStringOrNull(name: String): String? = opt(name)?.takeUnless { it === JSONObject.NULL }?.toString()?.takeUnless { it.isEmpty() } -fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean { +public fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean { return when (val rawValue = opt(name)) { null, JSONObject.NULL -> defaultValue is Boolean -> rawValue @@ -64,7 +64,7 @@ fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean } } -fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long { +public fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long { return when (val rawValue = opt(name)) { null, JSONObject.NULL -> defaultValue is Long -> rawValue @@ -74,7 +74,7 @@ fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long { } } -fun JSONObject.getIntOrDefault(name: String, defaultValue: Int): Int { +public fun JSONObject.getIntOrDefault(name: String, defaultValue: Int): Int { return when (val rawValue = opt(name)) { null, JSONObject.NULL -> defaultValue is Int -> rawValue @@ -84,7 +84,7 @@ fun JSONObject.getIntOrDefault(name: String, defaultValue: Int): Int { } } -fun JSONObject.getDoubleOrDefault(name: String, defaultValue: Double): Double { +public fun JSONObject.getDoubleOrDefault(name: String, defaultValue: Double): Double { return when (val rawValue = opt(name)) { null, JSONObject.NULL -> defaultValue is Double -> rawValue @@ -94,7 +94,7 @@ fun JSONObject.getDoubleOrDefault(name: String, defaultValue: Double): Double { } } -fun JSONObject.getFloatOrDefault(name: String, defaultValue: Float): Float { +public fun JSONObject.getFloatOrDefault(name: String, defaultValue: Float): Float { return when (val rawValue = opt(name)) { null, JSONObject.NULL -> defaultValue is Float -> rawValue @@ -104,23 +104,21 @@ fun JSONObject.getFloatOrDefault(name: String, defaultValue: Float): Float { } } -fun JSONArray.JSONIterator(): Iterator = JSONIterator(this) +@Deprecated("") +public fun JSONArray.JSONIterator(): Iterator = JSONIterator(this) -fun JSONArray.stringIterator(): Iterator = JSONStringIterator(this) +@Deprecated("") +public fun JSONArray.stringIterator(): Iterator = JSONStringIterator(this) -fun JSONArray.mapJSONToSet(block: (JSONObject) -> T): Set { - val len = length() - val result = ArraySet(len) - for (i in 0 until len) { - val jo = getJSONObject(i) - result.add(block(jo)) - } - return result +public inline fun JSONArray.mapJSONToSet(mapper: (JSONObject) -> T): Set { + return mapJSONTo(ArraySet(length()), mapper) } -fun JSONObject.values(): Iterator = JSONValuesIterator(this) +@Deprecated("") +public fun JSONObject.values(): Iterator = JSONValuesIterator(this) -fun JSONArray.associateByKey(key: String): Map { +@Deprecated("") +public fun JSONArray.associateByKey(key: String): Map { val destination = LinkedHashMap(length()) repeat(length()) { i -> val item = getJSONObject(i) @@ -138,10 +136,12 @@ public fun JSONArray?.isNullOrEmpty(): Boolean { return this == null || this.length() == 0 } -fun JSONArray.toJSONList(): List { +@Deprecated("") +public fun JSONArray.toJSONList(): List { return List(length()) { i -> getJSONObject(i) } } -inline fun JSONArray.asIterable(): Iterable = Iterable { +@Deprecated("") +public inline fun JSONArray.asIterable(): Iterable = Iterable { JSONTypedIterator(this, T::class.java) }