Repository: KotatsuApp/kotatsu-parsers
Pull request #1812: [Manhuagui] Add source
Author: login: LittleYe233
Created: 2025-05-25T07:54:43Z | Merged: 2025-05-26T19:48:57Z

Adds a parser for www.manhuagui.com.

## Features

- Implements all search filters except alphabetical order.
- Adds all fields of manga details.
- Supports choosing an image server mirror.

## Limitations

- Now language field is useless.
- Sometimes there are redundant filter tags after fully fetched from remote by `fetchAvailableTags()`.
- Still fails some tests ("tags_multiple" and "locale").

## Notes

- This source has a version for mobile devices (m.manhuagui.com), but it contains the fewer details and harder to parser than the desktop version, so the parser chooses the latter one, thus an user agent for **desktop platform** is recommended to use. It currently provides ten built-in UAs and randomly selects one when initialized.
- The parser introduces two **external** utilities which are originated on other repos of GitHub. Licenses are included.


Diff:
diff --git a/.github/summary.yaml b/.github/summary.yaml
index edffc3f7d..3d7b08d04 100644
--- a/.github/summary.yaml
+++ b/.github/summary.yaml
@@ -1 +1 @@
-total: 1230
\ No newline at end of file
+total: 1231
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zh/ManhuaguiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zh/ManhuaguiParser.kt
new file mode 100644
index 000000000..4731a3461
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zh/ManhuaguiParser.kt
@@ -0,0 +1,448 @@
+package org.koitharu.kotatsu.parsers.site.zh
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.json.JSONObject
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.LegacyPagedMangaParser
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.ContentRating
+import org.koitharu.kotatsu.parsers.model.Demographic
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
+import org.koitharu.kotatsu.parsers.network.UserAgents
+import org.koitharu.kotatsu.parsers.util.attrOrThrow
+import org.koitharu.kotatsu.parsers.util.generateUid
+import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
+import org.koitharu.kotatsu.parsers.util.json.asTypedList
+import org.koitharu.kotatsu.parsers.util.mapChapters
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany
+import org.koitharu.kotatsu.parsers.util.parseHtml
+import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
+import org.koitharu.kotatsu.parsers.util.selectOrThrow
+import org.koitharu.kotatsu.parsers.util.src
+import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
+import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.koitharu.kotatsu.parsers.util.urlEncoded
+import java.util.EnumSet
+import java.util.Locale
+
+@MangaSourceParser("MANHUAGUI", "Manhuagui", "zh")
+internal class ManhuaguiParser(context: MangaLoaderContext) :
+	LegacyPagedMangaParser(context, MangaParserSource.MANHUAGUI, pageSize = 42) {
+
+	override val configKeyDomain = ConfigKey.Domain("www.manhuagui.com")
+    	override val userAgentKey = ConfigKey.UserAgent(UserAgents.CHROME_MOBILE)
+
+	val configKeyImgServer = ConfigKey.PreferredImageServer(
+		presetValues = arrayOf("us", "us2", "us3", "eu", "eu2", "eu3").associateWith { it },
+		defaultValue = "us",
+	)
+
+	val imgServer = "${config[configKeyImgServer]}.hamreus.com"
+
+    	override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
+		super.onCreateConfig(keys)
+		keys.add(userAgentKey)
+		keys.add(configKeyImgServer)
+	}
+
+	override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder()
+		.add("Referer", "https://$domain")
+		.build()
+
+    	override val defaultSortOrder: SortOrder
+		get() = SortOrder.UPDATED
+
+	override val availableSortOrders: Set<SortOrder>
+		get() = EnumSet.of(
+			SortOrder.NEWEST, // 最新发布
+			SortOrder.UPDATED, // 最新更新
+			SortOrder.POPULARITY, // 人气最旺
+			SortOrder.RATING, // 评分最高
+		)
+
+	override val filterCapabilities: MangaListFilterCapabilities
+		get() = MangaListFilterCapabilities(
+			isSearchSupported = true,
+			isYearSupported = true,
+			isOriginalLocaleSupported = true,
+		)
+
+    	private val fetchedTags = suspendLazy(initializer = ::fetchAvailableTags)
+
+    	override suspend fun getFilterOptions() = MangaListFilterOptions(
+		availableLocales = setOf(
+			Locale.JAPAN, Locale.TRADITIONAL_CHINESE, Locale.ROOT,
+			Locale.US, Locale.SIMPLIFIED_CHINESE, Locale.KOREA,
+		),
+		availableTags = fetchedTags.get(),
+		availableDemographics = EnumSet.complementOf(EnumSet.of(Demographic.JOSEI)),
+		availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
+	)
+
+	private val listUrl = "/list"
+	private val searchUrl = "/s"
+	private val ratingUrl = "/tools/vote.ashx"
+	private val sectionChaptersSelector = ".chapter-list"
+
+	private fun Any?.toQueryParam(): String? = when (this) {
+
+		is String -> urlEncoded() // Title
+
+		is Locale -> when (this) {
+			Locale.JAPAN -> "japan" // 日本
+			Locale.TRADITIONAL_CHINESE -> "hongkong" // 港台
+			Locale.ROOT -> "other" // 其他 (I do not know if it is sure to use it)
+			Locale.US -> "europe" // 欧美
+			Locale.SIMPLIFIED_CHINESE -> "china" // 内地
+			Locale.KOREA -> "korea" // 韩国
+			else -> null
+		}
+
+		is MangaTag -> key
+
+		is Demographic -> when (this) {
+			Demographic.SHOUJO -> "shaonv" // 少女
+			Demographic.SHOUNEN -> "shaonian" // 少年
+			Demographic.SEINEN -> "qingnian" // 青年
+			Demographic.KODOMO -> "ertong" // 儿童
+			Demographic.NONE -> "tongyong" // 通用
+			else -> null
+		}
+
+		is Int -> when { // Year
+			this >= 2010 && this <= 2025 -> toString()
+			this >= 2000 && this < 2010 -> "200x"
+			this >= 1990 && this < 2000 -> "199x"
+			this >= 1980 && this < 1990 -> "198x"
+			this == YEAR_UNKNOWN -> null
+			this < 1980 -> "197x"
+			else -> null
+		}
+
+		is MangaState -> when (this) {
+			MangaState.ONGOING -> "lianzai" // 连载
+			MangaState.FINISHED -> "wanjie" // 完结
+			else -> null
+		}
+
+		else -> null
+	}
+
+	private fun String.addQueryParameters(params: JSONObject): String {
+		val builder = this.toHttpUrl().newBuilder()
+		val keys = params.keys()
+		while (keys.hasNext()) {
+			val key = keys.next()
+			val value = params.get(key).toString()
+			builder.addQueryParameter(key, value)
+		}
+		return builder.build().toString()
+	}
+
+	override suspend fun getListPage(
+		page: Int,
+		order: SortOrder,
+		filter: MangaListFilter,
+	): List<Manga> {
+        
+        // Flag of whether there is title query param
+		var flagHasTitleQuery = false 
+
+		val url = buildString {
+			var queryFull: String?
+			var orderStr: String?
+			var pageStr: String?
+			if (filter.query == null) {
+				append(listUrl.toAbsoluteUrl(domain))
+				queryFull = listOfNotNull(
+					filter.locale,
+					filter.tags.oneOrThrowIfMany(),
+					filter.demographics.oneOrThrowIfMany(),
+					filter.year.takeUnless { it == YEAR_UNKNOWN },
+					filter.states.oneOrThrowIfMany(),
+				).joinToString("_") { it.toQueryParam().toString() }
+				orderStr = when (order) {
+					SortOrder.UPDATED -> "update"
+					SortOrder.POPULARITY -> "view"
+					SortOrder.RATING -> "rate"
+					else -> "index"
+				}
+				pageStr = "/${orderStr}_p${page}.html"
+			} else {
+				flagHasTitleQuery = true
+				append(searchUrl.toAbsoluteUrl(domain))
+				queryFull = filter.query
+				orderStr = when (order) {
+					SortOrder.POPULARITY -> "_o1"
+					SortOrder.NEWEST -> "_o2"
+					SortOrder.RATING -> "_o3"
+					else -> ""
+				}
+				pageStr = "${orderStr}_p${page}.html"
+			}
+
+			append("/${queryFull}${pageStr}")
+		}
+
+		val doc = webClient.httpGet(url.toHttpUrl()).parseHtml()
+
+		return doc.select(if (flagHasTitleQuery) "div.book-result > ul > li" else "div.book-list > ul#contList > li")
+			.map { li ->
+				val a = li.selectFirstOrThrow("a")
+				val em =
+					li.selectFirst(if (flagHasTitleQuery) "div.book-score > p:first-child > strong" else "span.updateon > em")
+				val href = a.attrOrThrow("href")
+				val rating = em?.text()?.toFloat()?.div(10) ?: RATING_UNKNOWN
+				Manga(
+					id = generateUid(href),
+					title = a.attr("title"),
+					altTitles = emptySet(),
+					url = href,
+					publicUrl = href.toAbsoluteUrl(domain),
+					rating = rating,
+					contentRating = null,
+					coverUrl = a.selectFirst("img")?.src(),
+					tags = emptySet(),
+					state = null,
+					authors = emptySet(),
+					source = source,
+				)
+			}
+	}
+
+	override suspend fun getDetails(manga: Manga): Manga {
+		val doc = webClient.httpGet(manga.publicUrl).parseHtml()
+
+		val altTitles = doc.select(".book-title h2").eachText().toSet()
+		val contentRating: ContentRating = doc.selectFirst("input#__VIEWSTATE").let {
+			when (it) {
+				null -> ContentRating.SAFE
+				else -> ContentRating.ADULT
+			}
+		}
+		val tags = doc.select("ul.detail-list > li:nth-child(2) > span:first-child > a").mapToSet { e ->
+			MangaTag(
+				title = e.text(),
+				key = e.attr("href").removePrefix(listUrl).removeSurrounding("/"),
+				source = source,
+			)
+		}
+		val state = doc.selectFirst("li.status > span > span")?.className()?.let { className ->
+			when (className) {
+				"red" -> MangaState.ONGOING
+				"dgreen" -> MangaState.FINISHED
+				else -> null
+			}
+		}
+		val authors = doc.select("a[href^=\"/author\"]").eachText().toSet()
+		val description = doc.selectFirst("div.book-intro > #intro-all > p")?.text()
+		val chapters = parseChapters(doc)
+
+		return manga.copy(
+			altTitles = altTitles,
+			contentRating = contentRating,
+			tags = tags,
+			state = state,
+			authors = authors,
+			description = description,
+			chapters = chapters,
+		)
+	}
+
+	override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
+		val chapUrl = chapter.url.toAbsoluteUrl(domain)
+		val doc = webClient.httpGet(chapUrl).parseHtml()
+		val regex = Regex("""^.*\}\('(.*)',(\d*),(\d*),'([\w\+/=]*)'.*${'$'}""", RegexOption.MULTILINE)
+		val result = regex.find(doc.html()) ?: throw ParseException("Cannot find chapter metadata", chapUrl)
+		val metadataRaw = decompressLZStringFromBase64(result.groupValues[4]) ?: throw ParseException(
+			"Cannot decompress chapter metadata",
+			chapUrl,
+		)
+		val json = unpack(result.groupValues[1], metadataRaw.split("|"))
+
+		val files = json.getJSONArray("files")
+		val semiFullUrl = json.getString("path").toAbsoluteUrl(imgServer)
+		val signature = json.getJSONObject("sl")
+
+		return files.asTypedList<String>().map { it ->
+			val fullUrl = (semiFullUrl + it).addQueryParameters(signature)
+			MangaPage(
+				id = generateUid(fullUrl),
+				url = fullUrl,
+				preview = fullUrl,
+				source = source,
+			)
+		}
+	}
+
+    	// private funs
+
+    	private suspend fun fetchAvailableTags(): Set<MangaTag> {
+		val doc = webClient.httpGet(listUrl.toAbsoluteUrl(domain)).parseHtml()
+		val tags = doc.selectOrThrow("div.filter-nav > .filter.genre > ul > li > a").drop(1)
+		return tags.mapToSet { a ->
+			val title = a.text()
+			val key = a.attr("href").removePrefix(listUrl).removeSurrounding("/")
+			MangaTag(
+				title = title,
+				key = key,
+				source = source,
+			)
+		}
+	}
+
+	private fun parseChapters(doc: Document, url: String? = null): List<MangaChapter> {
+		val (sectionTitles, sectionChapters) = doc.selectFirst("#__VIEWSTATE").let {
+			if (it != null) {
+				val viewStateStr = decompressLZStringFromBase64(it.attrOrThrow("value"))
+					?: throw ParseException("Cannot decompress __VIEWSTATE", url.ifNullOrEmpty { "" })
+				val doc1 = Jsoup.parse(viewStateStr)
+				Pair(
+					doc1.select("h4 span"),
+					doc1.select(sectionChaptersSelector),
+				)
+			} else {
+				Pair(
+					doc.select(".chapter h4 span"),
+					doc.select(sectionChaptersSelector),
+				)
+			}
+		}
+
+		// Parse chapters from each section
+		val chapters = sectionTitles
+			.zip(sectionChapters)
+			.flatMapIndexed { index, (title, section) ->
+				val chaps = section.select("ul").flatMap {
+					it.select("li a").asReversed()
+				}
+				chaps.mapChapters { chapIdx, chap ->
+					MangaChapter(
+						url = chap.attrOrThrow("href"),
+						id = generateUid(chap.attrOrThrow("href")),
+						title = chap.attrOrThrow("title"),
+						number = (chapIdx + 1).toFloat(),
+						volume = index + 1,
+						scanlator = null,
+						uploadDate = 0,
+						branch = title.text(),
+						source = source,
+					)
+				}
+			}
+
+		return chapters
+	}
+
+    	private fun decompressLZStringFromBase64(input: String): String? {
+	        if (input.isBlank()) return null
+	
+	        data class Data(var value: Char = '0', var position: Int = 0, var index: Int = 1)
+	
+	        val keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
+	        val getNextValue = { it: Int -> keyStr.indexOf(input[it]).toChar() }
+	        val builder = StringBuilder()
+	        val dictionary = mutableListOf("0", "1", "2")
+	        val data = Data(getNextValue(0), 32, 1)
+	        var (bits, numBits, enlargeIn, dictSize) = listOf(0, 3, 4, 4)
+	        var (c, w, entry) = listOf("", "", "")
+	        
+	        fun Int.power() = 1 shl this
+	        fun Int.string() = this.toChar().toString()
+	        
+	        fun doPower(initBits: Int, initPower: Int, initMaxPower: Int, mode: Int = 0) {
+	            bits = initBits
+	            var power = initPower
+	            val maxpower = initMaxPower.power()
+	            while (power != maxpower) {
+	                val resb = data.value.code and data.position
+	                data.position = data.position shr 1
+	                if (data.position == 0) {
+	                    data.position = 32
+	                    data.value = getNextValue(data.index++)
+	                }
+	                bits = bits or ((if (resb > 0) 1 else 0) * power)
+	                power = power shl 1
+	            }
+	            when (mode) {
+	                1 -> c = bits.string()
+	                2 -> dictionary.add(dictSize++.also { enlargeIn-- }, bits.string())
+	            }
+	        }
+	
+	        fun checkEnlargeIn() {
+	            if (enlargeIn-- == 0) {
+	                enlargeIn = numBits.power()
+	                numBits++
+	            }
+	        }
+	
+	        doPower(bits, 1, 2)
+	        when (bits) {
+	            0 -> doPower(0, 1, 8, 1)
+	            1 -> doPower(0, 1, 16, 1)
+	            2 -> return ""
+	        }
+	        
+	        dictionary.add(3, c)
+	        w = c
+	        builder.append(w)
+	        
+	        while (true) {
+	            if (data.index > input.length) return ""
+	            doPower(0, 1, numBits)
+	            when (bits) {
+	                0 -> doPower(0, 1, 8, 2).also { checkEnlargeIn() }
+	                1 -> doPower(0, 1, 16, 2).also { checkEnlargeIn() }
+	                2 -> return builder.toString()
+	            }
+	            entry = when {
+	                dictionary.size > bits -> dictionary[bits]
+	                bits == dictSize -> w + w[0]
+	                else -> return null
+	            }
+	            builder.append(entry)
+	            dictionary.add(dictSize++, w + entry[0])
+	            w = entry
+	            checkEnlargeIn()
+        	}
+    	}
+
+	private fun unpack(src: String, syms: List<String>): JSONObject {
+	        fun base62(n: Int) = when {
+	            n < 10 -> n.toString()
+	            n < 36 -> ('a' + (n - 10)).toString()
+	            else -> ('A' + (n - 36)).toString()
+	        }
+	        
+	        fun encode62(num: Int): String = if (num >= 62) encode62(num / 62) + base62(num % 62) else base62(num)
+	
+	        val working = syms.foldRightIndexed(src) { idx, replacement, acc ->
+	            if (replacement.isNotEmpty()) {
+	                val token = encode62(idx)
+	                Regex("\\b${Regex.escape(token)}\\b").replace(acc, replacement)
+	            } else acc
+	        }
+	
+	        return JSONObject(Regex("""\((\{.+\})\)""", RegexOption.DOT_MATCHES_ALL)
+	            .find(working)?.groupValues?.get(1)
+	            ?: throw IllegalArgumentException("JSON payload not found after unpacking."))
+	    }
+}


Reviews:
user: login: dragonx943
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T19:04:15Z

Review comments:
diff_hunk: @@ -0,0 +1,526 @@
+package org.koitharu.kotatsu.parsers.site.zh
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.json.JSONObject
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.LegacyPagedMangaParser
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.ContentRating
+import org.koitharu.kotatsu.parsers.model.Demographic
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
+import org.koitharu.kotatsu.parsers.util.attrOrThrow
+import org.koitharu.kotatsu.parsers.util.generateUid
+import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
+import org.koitharu.kotatsu.parsers.util.json.asTypedList
+import org.koitharu.kotatsu.parsers.util.mapChapters
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany
+import org.koitharu.kotatsu.parsers.util.parseHtml
+import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
+import org.koitharu.kotatsu.parsers.util.selectOrThrow
+import org.koitharu.kotatsu.parsers.util.src
+import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
+import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.koitharu.kotatsu.parsers.util.urlEncoded
+import java.util.EnumSet
+import java.util.Locale
+
+/*******************************************************
+ * Parser class
+ ******************************************************/
+
+@MangaSourceParser("MANHUAGUI", "Manhuagui", "zh")
+internal class ManhuaguiParser(context: MangaLoaderContext) :
+	LegacyPagedMangaParser(context, MangaParserSource.MANHUAGUI, pageSize = 42) {
+
+	/*******************************************************
+	 * Important constants and helper functions
+	 ******************************************************/
+
+	override val configKeyDomain = ConfigKey.Domain("www.manhuagui.com")
+
+	val configKeyImgServer = ConfigKey.PreferredImageServer(
+		presetValues = arrayOf("us", "us2", "us3", "eu", "eu2", "eu3").associateWith { it },
+		defaultValue = "us",
+	)
+
+	val imgServer = "${config[configKeyImgServer]}.hamreus.com"
+
+	private val uaList = arrayOf(
path: src/main/kotlin/org/koitharu/kotatsu/parsers/site/zh/ManhuaguiParser.kt
user: login: dragonx943
body: replace it with `override val userAgentKey = ConfigKey.UserAgent(UserAgents.CHROME_DESKTOP)` ?
created_at: 2025-05-26T19:02:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,526 @@
+package org.koitharu.kotatsu.parsers.site.zh
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.json.JSONObject
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.LegacyPagedMangaParser
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.ContentRating
+import org.koitharu.kotatsu.parsers.model.Demographic
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
+import org.koitharu.kotatsu.parsers.util.attrOrThrow
+import org.koitharu.kotatsu.parsers.util.generateUid
+import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
+import org.koitharu.kotatsu.parsers.util.json.asTypedList
+import org.koitharu.kotatsu.parsers.util.mapChapters
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany
+import org.koitharu.kotatsu.parsers.util.parseHtml
+import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
+import org.koitharu.kotatsu.parsers.util.selectOrThrow
+import org.koitharu.kotatsu.parsers.util.src
+import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
+import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.koitharu.kotatsu.parsers.util.urlEncoded
+import java.util.EnumSet
+import java.util.Locale
+
+/*******************************************************
+ * Parser class
+ ******************************************************/
+
+@MangaSourceParser("MANHUAGUI", "Manhuagui", "zh")
+internal class ManhuaguiParser(context: MangaLoaderContext) :
+	LegacyPagedMangaParser(context, MangaParserSource.MANHUAGUI, pageSize = 42) {
+
+	/*******************************************************
+	 * Important constants and helper functions
+	 ******************************************************/
+
+	override val configKeyDomain = ConfigKey.Domain("www.manhuagui.com")
+
+	val configKeyImgServer = ConfigKey.PreferredImageServer(
+		presetValues = arrayOf("us", "us2", "us3", "eu", "eu2", "eu3").associateWith { it },
+		defaultValue = "us",
+	)
+
+	val imgServer = "${config[configKeyImgServer]}.hamreus.com"
+
+	private val uaList = arrayOf(
+		"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.2651.70 Safari/537.36 Edg/127.0.2651.70",
+		"Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.1774.54 Safari/537.36 Edg/113.0.1774.54",
+		"Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.193 Safari/537.36",
+		"Mozilla/5.0 (Windows NT 6.1; WOW64; rv:115.0.2) Gecko/20100101 Firefox/115.0.2",
+		"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.1245.66 Safari/537.36 Edg/102.0.1245.66",
+		"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:135.0.0) Gecko/20100101 Firefox/135.0.0",
+		"Mozilla/5.0 (X11; Linux x86_64; rv:128.2.0) Gecko/20100101 Firefox/128.2.0",
+		"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.158 Safari/537.36",
+		"Mozilla/5.0 (Macintosh; Intel Mac OS X 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.154 Safari/537.36",
+		"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.1343.79 Safari/537.36 Edg/105.0.1343.79",
+	)
+
+	val configKeyUserAgent: ConfigKey.UserAgent
+		get() = ConfigKey.UserAgent(uaList.random())
+
+	override val availableSortOrders: Set<SortOrder>
+		get() = EnumSet.of(
+			SortOrder.NEWEST, // 最新发布
+			SortOrder.UPDATED, // 最新更新
+			SortOrder.POPULARITY, // 人气最旺
+			SortOrder.RATING, // 评分最高
+		)
+
+	override val defaultSortOrder: SortOrder
+		get() = SortOrder.UPDATED
+
+	override val filterCapabilities: MangaListFilterCapabilities
+		get() = MangaListFilterCapabilities(
+			isSearchSupported = true,
+			isYearSupported = true,
+			isOriginalLocaleSupported = true,
+		)
+
+	override fun getRequestHeaders(): Headers = Headers.Builder()
path: src/main/kotlin/org/koitharu/kotatsu/parsers/site/zh/ManhuaguiParser.kt
user: login: dragonx943
body: i think there is too much unnecessary stuff for header, right?
created_at: 2025-05-26T19:03:31Z
author_association: MEMBER

PR comments:
user: login: dragonx943
created_at: 2025-05-25T18:43:26Z
body: > Adds a parser for [www.manhuagui.com](http://www.manhuagui.com).
> 
> ## Features
> * Implements all search filters except alphabetical order.
> * Adds all fields of manga details.
> * Supports choosing an image server mirror.
> 
> ## Limitations
> * Now language field is useless.
> * Sometimes there are redundant filter tags after fully fetched from remote by `fetchAvailableTags()`.
> * Still fails some tests ("tags_multiple" and "locale").
> 
> ## Notes
> * This source has a version for mobile devices (m.manhuagui.com), but it contains the fewer details and harder to parser than the desktop version, so the parser chooses the latter one, thus an user agent for **desktop platform** is recommended to use. It currently provides ten built-in UAs and randomly selects one when initialized.
> * The parser introduces two **external** utilities which are originated on other repos of GitHub. Licenses are included.

i read it and 858 lines of code is pretty crazy for a source that is only 150-300 lines long

and i noticed that u added "too many" irrelevant docs and license, which shouldnt exist in the parser. Furthermore, with current kotatsu-parsers utils, i think it has replaced most of the "external" private function u have attached, check ur parser and make full use of kotatsu-parsers utils

For details: https://dev.kotatsu.app

Furthermore, if its too complicated for u to implement like adding other languages / tags / filters, try `LegacyPagedMangaParser` (oldest, most basic) instead of `PagedMangaParser`
author_association: MEMBER
user: login: LittleYe233
created_at: 2025-05-26T01:43:52Z
body: Thanks for your review. I have no experience of formal coding projects and Kotlin, and I am really not sure if I am doing a good practice. Sorry for inconvenience.
author_association: CONTRIBUTOR
user: login: dragonx943
created_at: 2025-05-26T10:38:07Z
body: > Thanks for your review. I have no experience of formal coding projects and Kotlin, and I am really not sure if I am doing a good practice. Sorry for inconvenience.

try to convert `private object LZ4K` and `private object PACKERDecoder` to private functions inside `internal class ManhuaguiParser`
author_association: MEMBER
user: login: dragonx943
created_at: 2025-05-26T10:46:49Z
body: > Adds a parser for [www.manhuagui.com](http://www.manhuagui.com).
> 
> ## Features
> * Implements all search filters except alphabetical order.
> * Adds all fields of manga details.
> * Supports choosing an image server mirror.
> 
> ## Limitations
> * Now language field is useless.
> * Sometimes there are redundant filter tags after fully fetched from remote by `fetchAvailableTags()`.
> * Still fails some tests ("tags_multiple" and "locale").
> 
> ## Notes
> * This source has a version for mobile devices (m.manhuagui.com), but it contains the fewer details and harder to parser than the desktop version, so the parser chooses the latter one, thus an user agent for **desktop platform** is recommended to use. It currently provides ten built-in UAs and randomly selects one when initialized.
> * The parser introduces two **external** utilities which are originated on other repos of GitHub. Licenses are included.

and it didnt work, try to fix it and test by urself first

![image](https://github.com/user-attachments/assets/fdf23565-d331-43e3-80fa-95db454c9e37)
![image](https://github.com/user-attachments/assets/78c2d586-1864-4929-bbf0-ee059c5c379e)
author_association: MEMBER
user: login: LittleYe233
created_at: 2025-05-26T15:15:26Z
body: > try to convert private object LZ4K and private object PACKERDecoder to private functions inside internal class ManhuaguiParser

Fixed.

> and it didnt work, try to fix it and test by urself first

This manga site has **too** strict network firewall that needs a nearly purely clean network environment to use, so it is very hard to test frequently. But I am sure that the site still works and I have tested locally. Screenshots attached:

![image](https://github.com/user-attachments/assets/f0bebff7-6d0c-4383-9f27-839171ed36da)
![image](https://github.com/user-attachments/assets/817a1aa7-5443-4e3a-a2c3-378423984bf2)
![image](https://github.com/user-attachments/assets/ec79b8d2-ff83-4098-8526-53bb4d69869e)
![image](https://github.com/user-attachments/assets/f6d7da45-c30a-44e7-a264-7fe26a83d705)

author_association: CONTRIBUTOR
user: login: dragonx943
created_at: 2025-05-26T19:48:28Z
body: > Adds a parser for [www.manhuagui.com](http://www.manhuagui.com).
> 
> ## Features
> * Implements all search filters except alphabetical order.
> * Adds all fields of manga details.
> * Supports choosing an image server mirror.
> 
> ## Limitations
> * Now language field is useless.
> * Sometimes there are redundant filter tags after fully fetched from remote by `fetchAvailableTags()`.
> * Still fails some tests ("tags_multiple" and "locale").
> 
> ## Notes
> * This source has a version for mobile devices (m.manhuagui.com), but it contains the fewer details and harder to parser than the desktop version, so the parser chooses the latter one, thus an user agent for **desktop platform** is recommended to use. It currently provides ten built-in UAs and randomly selects one when initialized.
> * The parser introduces two **external** utilities which are originated on other repos of GitHub. Licenses are included.

well, it doesnt work for me (in vietnam) but at least it works for u, so i will merge it 👌

![image](https://github.com/user-attachments/assets/6db8c64d-914a-4b37-97c0-27536e6a1b04)
author_association: MEMBER
user: login: zhinu
created_at: 2025-06-02T08:11:24Z
body: doesn't work for me.
first error
![Screenshot_20250602_160338636_Kotatsu Nightly](https://github.com/user-attachments/assets/f3b6f89c-a6f3-4e4a-8067-87e17e57f5d9)

when changing the url to tw.manhuagui.com
![Screenshot_20250602_160433290_Kotatsu Nightly](https://github.com/user-attachments/assets/88287958-1e51-4894-98f7-cd58472a97c2)

second error
![Screenshot_20250602_160451150_Kotatsu Nightly](https://github.com/user-attachments/assets/e31e3c92-1455-4998-8117-8744fa1fb6b8)


'''
org.koitharu.kotatsu.parsers.exception.ParseException: Cannot decompress chapter metadata at https://tw.manhuagui.com/comic/30252/405318.html
	at org.koitharu.kotatsu.parsers.site.zh.ManhuaguiParser.getPages(SourceFile:439)
	at org.koitharu.kotatsu.parsers.site.zh.ManhuaguiParser$getPages$1.invokeSuspend(Unknown Source:12)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(SourceFile:9)
	at kotlinx.coroutines.DispatchedTask.run(SourceFile:117)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(SourceFile:121)

'''
author_association: NONE
user: login: dragonx943
created_at: 2025-06-02T08:24:16Z
body: > doesn't work for me.
> first error
> ![Screenshot_20250602_160338636_Kotatsu Nightly](https://github.com/user-attachments/assets/f3b6f89c-a6f3-4e4a-8067-87e17e57f5d9)
> 
> when changing the url to tw.manhuagui.com
> ![Screenshot_20250602_160433290_Kotatsu Nightly](https://github.com/user-attachments/assets/88287958-1e51-4894-98f7-cd58472a97c2)
> 
> second error
> ![Screenshot_20250602_160451150_Kotatsu Nightly](https://github.com/user-attachments/assets/e31e3c92-1455-4998-8117-8744fa1fb6b8)
> 
> 
> '''
> org.koitharu.kotatsu.parsers.exception.ParseException: Cannot decompress chapter metadata at https://tw.manhuagui.com/comic/30252/405318.html
> 	at org.koitharu.kotatsu.parsers.site.zh.ManhuaguiParser.getPages(SourceFile:439)
> 	at org.koitharu.kotatsu.parsers.site.zh.ManhuaguiParser$getPages$1.invokeSuspend(Unknown Source:12)
> 	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(SourceFile:9)
> 	at kotlinx.coroutines.DispatchedTask.run(SourceFile:117)
> 	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(SourceFile:121)
> 
> '''

Idk, it didnt work for me but worked for him @LittleYe233 
author_association: MEMBER
user: login: zhinu
created_at: 2025-06-02T13:37:39Z
body: i tried with both a taiwanese ip and a vpn, both ips culd see scans online with a cromiumbased browser but the parser failed for both. 
Also i recommend to change the default address to avoid getting the mobile version, since the user agent is random
author_association: NONE
user: login: dragonx943
created_at: 2025-06-02T16:38:15Z
body: > i tried with both a taiwanese ip and a vpn, both ips culd see scans online with a cromiumbased browser but the parser failed for both. Also i recommend to change the default address to avoid getting the mobile version, since the user agent is random

idk, @LittleYe233 he is the one who made sure to test this source because according to him, it only works within china with clean network 👌 
author_association: MEMBER
user: login: LittleYe233
created_at: 2025-06-02T16:58:21Z
body: @zhinu 
1. These errors were thrown due to the patch from my original dev branch (see 74d5438) to the final PR (see de265c3). To be specific, `decompressLZStringFromBase64()` has some bugs and the user agent should be for desktop, not mobile.
2. tw.manhuagui.com is not fully tested. In my recent fix, it has no significant error. You can mention it if you find anything incompatible to the "www" version.
3. I tested my fix based on kotatsu commit 0ed2232ac and latest commit of kotatsu-parsers, using debug variant configuration.

@dragonx943 
Sorry that I have not investigated into your following patch carefully. May I create a new PR to fix this issue?
author_association: CONTRIBUTOR
user: login: dragonx943
created_at: 2025-06-02T17:21:08Z
body: > @zhinu
> 
> 1. These errors were thrown due to the patch from my original dev branch (see [74d5438](https://github.com/KotatsuApp/kotatsu-parsers/commit/74d54382693025775ffc8da816378fb2dad60b61)) to the final PR (see [de265c3](https://github.com/KotatsuApp/kotatsu-parsers/commit/de265c3989588a5f564bc3afe05b0ccebcfa0450)). To be specific, `decompressLZStringFromBase64()` has some bugs and the user agent should be for desktop, not mobile.
> 2. tw.manhuagui.com is not fully tested. In my recent fix, it has no significant error. You can mention it if you find anything incompatible to the "www" version.
> 3. I tested my fix based on kotatsu commit 0ed2232ac and latest commit of kotatsu-parsers, using debug variant configuration.
> 
> @dragonx943 Sorry that I have not investigated into your following patch carefully. May I create a new PR to fix this issue?

ofc, and i will review it
author_association: MEMBER