diff --git a/lib-multisrc/manhwaz/assets/i18n/messages_en.properties b/lib-multisrc/manhwaz/assets/i18n/messages_en.properties new file mode 100644 index 000000000..70c76d5c1 --- /dev/null +++ b/lib-multisrc/manhwaz/assets/i18n/messages_en.properties @@ -0,0 +1,12 @@ +filter_ignored_warning=Ignored when using text search +cannot_use_order_by_warning=Cannot use "Order by" filter when genre is "%s" or "%s" +genre_fetch_failed=Failed to fetch genres +genre_missing_warning=Press "Reset" to attempt to show genres +genre_filter_title=Genre +genre_all=All +genre_completed=Completed +order_by_filter_title=Order by +order_by_latest=Latest +order_by_rating=Rating +order_by_most_views=Most views +order_by_new=New diff --git a/lib-multisrc/manhwaz/assets/i18n/messages_vi.properties b/lib-multisrc/manhwaz/assets/i18n/messages_vi.properties new file mode 100644 index 000000000..78d22cb30 --- /dev/null +++ b/lib-multisrc/manhwaz/assets/i18n/messages_vi.properties @@ -0,0 +1,12 @@ +filter_ignored_warning=Không thể dùng chung với tìm kiếm bằng từ khoá +cannot_use_order_by_warning=Không thể sắp xếp nếu chọn thể loại là "%s" hoặc "%s" +genre_fetch_failed=Đã có lỗi khi tải thể loại +genre_missing_warning=Chọn "Đặt lại" để hiển thị thể loại +genre_filter_title=Thể loại +genre_all=Tất cả +genre_completed=Hoàn thành +order_by_filter_title=Sắp xếp theo +order_by_latest=Mới nhất +order_by_rating=Đánh giá cao +order_by_most_views=Xem nhiều +order_by_new=Mới diff --git a/lib-multisrc/manhwaz/build.gradle.kts b/lib-multisrc/manhwaz/build.gradle.kts new file mode 100644 index 000000000..68c38b3d8 --- /dev/null +++ b/lib-multisrc/manhwaz/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 + +dependencies { + api(project(":lib:i18n")) +} diff --git a/lib-multisrc/manhwaz/src/eu/kanade/tachiyomi/multisrc/manhwaz/ManhwaZ.kt b/lib-multisrc/manhwaz/src/eu/kanade/tachiyomi/multisrc/manhwaz/ManhwaZ.kt new file mode 100644 index 000000000..f7bea18a7 --- /dev/null +++ b/lib-multisrc/manhwaz/src/eu/kanade/tachiyomi/multisrc/manhwaz/ManhwaZ.kt @@ -0,0 +1,275 @@ +package eu.kanade.tachiyomi.multisrc.manhwaz + +import android.util.Log +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.await +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.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.util.Calendar + +abstract class ManhwaZ( + override val name: String, + override val baseUrl: String, + final override val lang: String, + private val mangaDetailsAuthorHeading: String = "author(s)", + private val mangaDetailsStatusHeading: String = "status", +) : ParsedHttpSource() { + + override val supportsLatest = true + + override val client = network.cloudflareClient + + override fun headersBuilder() = super.headersBuilder() + .add("Origin", baseUrl) + .add("Referer", "$baseUrl/") + + protected val intl = Intl( + lang, + setOf("en", "vi"), + "en", + this::class.java.classLoader!!, + ) + + override fun popularMangaRequest(page: Int) = GET(baseUrl, headers) + + override fun popularMangaSelector() = "#slide-top > .item" + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + element.selectFirst(".info-item a")!!.let { + title = it.text() + setUrlWithoutDomain(it.attr("href")) + } + thumbnail_url = element.selectFirst(".img-item img")?.imgAttr() + } + + override fun popularMangaNextPageSelector(): String? = null + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/?page=$page", headers) + + override fun latestUpdatesSelector() = ".page-item-detail" + + override fun latestUpdatesFromElement(element: Element) = SManga.create().apply { + element.selectFirst(".item-summary a")!!.let { + title = it.text() + setUrlWithoutDomain(it.attr("href")) + } + thumbnail_url = element.selectFirst(".item-thumb img")?.imgAttr() + } + + override fun latestUpdatesNextPageSelector(): String? = "ul.pager a[rel=next]" + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotEmpty()) { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("search") + addQueryParameter("s", query) + addQueryParameter("page", page.toString()) + }.build() + + return GET(url, headers) + } + + val url = baseUrl.toHttpUrl().newBuilder().apply { + val filterList = filters.ifEmpty { getFilterList() } + val genreFilter = filterList.find { it is GenreFilter } as? GenreFilter + val orderByFilter = filterList.find { it is OrderByFilter } as? OrderByFilter + val genreId = genreFilter?.options?.get(genreFilter.state)?.id + + if (genreFilter != null && genreFilter.state != 0) { + addPathSegments(genreId!!) + } + + // Can't sort in "All" or "Completed" + if (orderByFilter != null && genreId?.startsWith("genre/") == true) { + addQueryParameter( + "m_orderby", + orderByFilter.options[orderByFilter.state].id, + ) + } + + addQueryParameter("page", page.toString()) + }.build() + + return GET(url, headers) + } + + override fun searchMangaSelector() = latestUpdatesSelector() + + override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element) + + override fun searchMangaNextPageSelector(): String? = latestUpdatesNextPageSelector() + + private val ongoingStatusList = listOf("ongoing", "đang ra") + private val completedStatusList = listOf("completed", "hoàn thành") + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + val statusText = document.selectFirst("div.summary-heading:contains($mangaDetailsStatusHeading) + div.summary-content") + ?.text() + ?.lowercase() + ?: "" + + title = document.selectFirst("div.post-title h1")!!.text() + author = document.selectFirst("div.summary-heading:contains($mangaDetailsAuthorHeading) + div.summary-content")?.text() + description = document.selectFirst("div.summary__content")?.text() + genre = document.select("div.genres-content a[rel=tag]").joinToString { it.text() } + status = when { + ongoingStatusList.contains(statusText) -> SManga.ONGOING + completedStatusList.contains(statusText) -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + thumbnail_url = document.selectFirst("div.summary_image img")?.imgAttr() + } + + override fun chapterListSelector() = "li.wp-manga-chapter" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + element.selectFirst("a")!!.let { + setUrlWithoutDomain(it.attr("href")) + name = it.text() + } + + element.selectFirst("span.chapter-release-date")?.text()?.let { + date_upload = parseRelativeDate(it) + } + } + + override fun pageListParse(document: Document) = + document.select("div.page-break img").mapIndexed { i, it -> + Page(i, imageUrl = it.imgAttr()) + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + + override fun getFilterList(): FilterList { + fetchGenreList() + + val filters = buildList { + add(Filter.Header(intl["filter_ignored_warning"])) + add(Filter.Header(intl.format("cannot_use_order_by_warning", intl["genre_all"], intl["genre_completed"]))) + + if (fetchGenreStatus == FetchGenreStatus.NOT_FETCHED && fetchGenreAttempts >= 3) { + add(Filter.Header(intl["genre_fetch_failed"])) + } else if (fetchGenreStatus != FetchGenreStatus.FETCHED) { + add(Filter.Header(intl["genre_missing_warning"])) + } + + add(Filter.Separator()) + if (genres.isNotEmpty()) { + add(GenreFilter(intl, genres)) + } + add(OrderByFilter(intl)) + } + + return FilterList(filters) + } + + private class GenreFilter( + intl: Intl, + genres: List, + ) : SelectFilter(intl["genre_filter_title"], genres) + + private class OrderByFilter(intl: Intl) : SelectFilter( + intl["order_by_filter_title"], + listOf( + SelectOption(intl["order_by_latest"], "latest"), + SelectOption(intl["order_by_rating"], "rating"), + SelectOption(intl["order_by_most_views"], "views"), + SelectOption(intl["order_by_new"], "new"), + ), + ) + + private var genres = emptyList() + private var fetchGenreStatus = FetchGenreStatus.NOT_FETCHED + private var fetchGenreAttempts = 0 + + private val scope = CoroutineScope(Dispatchers.IO) + + private fun fetchGenreList() { + if (fetchGenreStatus != FetchGenreStatus.NOT_FETCHED || fetchGenreAttempts >= 3) { + return + } + + fetchGenreStatus = FetchGenreStatus.FETCHING + fetchGenreAttempts++ + + scope.launch { + try { + val document = client.newCall(GET("$baseUrl/genre")).await().asJsoup() + + genres = buildList { + add(SelectOption(intl["genre_all"], "")) + add(SelectOption(intl["genre_completed"], "completed")) + document.select("ul.page-genres li a").forEach { + val path = it.absUrl("href").toHttpUrl().encodedPath.removePrefix("/") + + add(SelectOption(it.ownText(), path)) + } + } + fetchGenreStatus = FetchGenreStatus.FETCHED + } catch (e: Exception) { + Log.e("ManhwaZ/$name", "Error fetching genres", e) + fetchGenreStatus = FetchGenreStatus.NOT_FETCHED + } + } + } + + private enum class FetchGenreStatus { NOT_FETCHED, FETCHED, FETCHING } + + private class SelectOption(val name: String, val id: String) + + private open class SelectFilter( + name: String, + val options: List, + ) : Filter.Select(name, options.map { it.name }.toTypedArray()) + + private val secondsUnit = listOf("second", "seconds", "giây") + private val minutesUnit = listOf("minute", "minutes", "phút") + private val hourUnit = listOf("hour", "hours", "giờ") + private val dayUnit = listOf("day", "days", "ngày") + private val weekUnit = listOf("week", "weeks", "tuần") + private val monthUnit = listOf("month", "months", "tháng") + private val yearUnit = listOf("year", "years", "năm") + + private fun parseRelativeDate(date: String): Long { + val (valueString, unit) = date.substringBeforeLast(" ").split(" ", limit = 2) + val value = valueString.toInt() + + val calendar = Calendar.getInstance().apply { + val field = when { + secondsUnit.contains(unit) -> Calendar.SECOND + minutesUnit.contains(unit) -> Calendar.MINUTE + hourUnit.contains(unit) -> Calendar.HOUR_OF_DAY + dayUnit.contains(unit) -> Calendar.DAY_OF_MONTH + weekUnit.contains(unit) -> Calendar.WEEK_OF_MONTH + monthUnit.contains(unit) -> Calendar.MONTH + yearUnit.contains(unit) -> Calendar.YEAR + else -> return 0L + } + + add(field, -value) + } + + return calendar.timeInMillis + } + + protected fun Element.imgAttr(): String = when { + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ") + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + else -> attr("abs:src") + } +} diff --git a/src/en/manhwaz/build.gradle b/src/en/manhwaz/build.gradle index 013e91f88..9a2c75de9 100644 --- a/src/en/manhwaz/build.gradle +++ b/src/en/manhwaz/build.gradle @@ -1,9 +1,9 @@ ext { extName = 'ManhwaZ' - extClass = '.ManhwaZ' - themePkg = 'madara' + extClass = '.ManhwaZCom' + themePkg = 'manhwaz' baseUrl = 'https://manhwaz.com' - overrideVersionCode = 0 + overrideVersionCode = 35 isNsfw = true } diff --git a/src/en/manhwaz/src/eu/kanade/tachiyomi/extension/en/manhwaz/ManhwaZ.kt b/src/en/manhwaz/src/eu/kanade/tachiyomi/extension/en/manhwaz/ManhwaZ.kt deleted file mode 100644 index 88e080218..000000000 --- a/src/en/manhwaz/src/eu/kanade/tachiyomi/extension/en/manhwaz/ManhwaZ.kt +++ /dev/null @@ -1,200 +0,0 @@ -package eu.kanade.tachiyomi.extension.en.manhwaz - -import eu.kanade.tachiyomi.multisrc.madara.Madara -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.interceptor.rateLimit -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.SManga -import eu.kanade.tachiyomi.util.asJsoup -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Element - -class ManhwaZ : Madara( - "ManhwaZ", - "https://manhwaz.com", - "en", -) { - - override val client: OkHttpClient = super.client.newBuilder() - .rateLimit(2) - .build() - - override val fetchGenres = false - - override val useNewChapterEndpoint = true - - // Popular - - override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/", headers) - - override fun popularMangaSelector(): String = "div#slide-top > div.item" - - override fun popularMangaNextPageSelector(): String? = null - - override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { - thumbnail_url = element.selectFirst(".img-item img")?.let(::imageFromElement) ?: "" - element.selectFirst(".info-item a")!!.run { - title = text().trim() - setUrlWithoutDomain(attr("href")) - } - } - - // Latest - - override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/?page=$page", headers) - - override fun latestUpdatesSelector(): String = ".manga-content > div.row > div" - - override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { - thumbnail_url = element.selectFirst(".item-thumb img")?.let(::imageFromElement) ?: "" - element.selectFirst(".item-summary a")!!.run { - title = text().trim() - setUrlWithoutDomain(attr("href")) - } - } - - override fun latestUpdatesNextPageSelector(): String = "ul.pager > li.active + li:not(.disabled)" - - // Search - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = baseUrl.toHttpUrl().newBuilder().apply { - if (query.isNotBlank()) { - addPathSegment("search") - addQueryParameter("s", query) - } else { - filters.forEach { filter -> - when (filter) { - is GenreFilter -> { - if (filter.selected == null) throw Exception("Must select a genre") - addPathSegment("genre") - addPathSegment(filter.selected!!) - } - is OrderFilter -> { - addQueryParameter("m_orderby", filter.selected) - } - else -> {} - } - } - } - addQueryParameter("page", page.toString()) - }.build() - - return GET(url, headers) - } - - override fun searchMangaParse(response: Response): MangasPage { - return if (response.request.url.encodedPath.startsWith("/search")) { - searchParse(response) - } else { - super.searchMangaParse(response) - } - } - - override fun searchMangaSelector(): String = "div.listing > div" - - override fun searchMangaFromElement(element: Element): SManga = latestUpdatesFromElement(element) - - override fun searchMangaNextPageSelector(): String = latestUpdatesNextPageSelector() - - private fun searchParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangaList = document.select(".page-search > .container > .row > div") - .map(::searchMangaFromElement) - - val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null - - return MangasPage(mangaList, hasNextPage) - } - - // Filter - - abstract class SelectFilter( - name: String, - private val options: List>, - defaultValue: String? = null, - ) : Filter.Select( - name, - options.map { it.first }.toTypedArray(), - options.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0, - ) { - val selected get() = options[state].second.takeUnless { it.isEmpty() } - } - - class OrderFilter : SelectFilter( - "Order By", - listOf( - Pair("Latest", "latest"), - Pair("Rating", "rating"), - Pair("Most Views", "views"), - Pair("New", "new"), - ), - ) - - class GenreFilter : SelectFilter( - "Genre", - listOf( - Pair("