diff --git a/src/zh/sixmh/AndroidManifest.xml b/src/zh/sixmh/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/zh/sixmh/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/zh/sixmh/build.gradle b/src/zh/sixmh/build.gradle
new file mode 100644
index 000000000..0b6d45fbd
--- /dev/null
+++ b/src/zh/sixmh/build.gradle
@@ -0,0 +1,16 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = '6Manhua'
+ pkgNameSuffix = 'zh.sixmh'
+ extClass = '.SixMH'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation 'com.github.stevenyomi:unpacker:919be5cb30' // SHA of 1.0 tag
+}
diff --git a/src/zh/sixmh/res/mipmap-hdpi/ic_launcher.png b/src/zh/sixmh/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..8cc31db2a
Binary files /dev/null and b/src/zh/sixmh/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/sixmh/res/mipmap-mdpi/ic_launcher.png b/src/zh/sixmh/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..30698e630
Binary files /dev/null and b/src/zh/sixmh/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/sixmh/res/mipmap-xhdpi/ic_launcher.png b/src/zh/sixmh/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..9c63b4a0f
Binary files /dev/null and b/src/zh/sixmh/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/sixmh/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/sixmh/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..1fb689b6c
Binary files /dev/null and b/src/zh/sixmh/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/sixmh/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/sixmh/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f4711146d
Binary files /dev/null and b/src/zh/sixmh/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/sixmh/res/web_hi_res_512.png b/src/zh/sixmh/res/web_hi_res_512.png
new file mode 100644
index 000000000..46e087357
Binary files /dev/null and b/src/zh/sixmh/res/web_hi_res_512.png differ
diff --git a/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/NonblockingRateLimiter.kt b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/NonblockingRateLimiter.kt
new file mode 100644
index 000000000..abcc45e6d
--- /dev/null
+++ b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/NonblockingRateLimiter.kt
@@ -0,0 +1,58 @@
+package eu.kanade.tachiyomi.extension.zh.sixmh
+
+import android.os.SystemClock
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+// See https://github.com/tachiyomiorg/tachiyomi/pull/7389
+internal class NonblockingRateLimiter(
+ private val permits: Int,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+) : Interceptor {
+
+ private val requestQueue = ArrayList(permits)
+ private val rateLimitMillis = unit.toMillis(period)
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ // Ignore canceled calls, otherwise they would jam the queue
+ if (chain.call().isCanceled()) {
+ throw IOException()
+ }
+
+ synchronized(requestQueue) {
+ val now = SystemClock.elapsedRealtime()
+ val waitTime = if (requestQueue.size < permits) {
+ 0
+ } else {
+ val oldestReq = requestQueue[0]
+ val newestReq = requestQueue[permits - 1]
+
+ if (newestReq - oldestReq > rateLimitMillis) {
+ 0
+ } else {
+ oldestReq + rateLimitMillis - now // Remaining time
+ }
+ }
+
+ // Final check
+ if (chain.call().isCanceled()) {
+ throw IOException()
+ }
+
+ if (requestQueue.size == permits) {
+ requestQueue.removeAt(0)
+ }
+ if (waitTime > 0) {
+ requestQueue.add(now + waitTime)
+ Thread.sleep(waitTime) // Sleep inside synchronized to pause queued requests
+ } else {
+ requestQueue.add(now)
+ }
+ }
+
+ return chain.proceed(chain.request())
+ }
+}
diff --git a/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMH.kt b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMH.kt
new file mode 100644
index 000000000..8a35f4a36
--- /dev/null
+++ b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMH.kt
@@ -0,0 +1,170 @@
+package eu.kanade.tachiyomi.extension.zh.sixmh
+
+import com.github.stevenyomi.unpacker.Unpacker
+import eu.kanade.tachiyomi.AppInfo
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.FormBody
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import org.jsoup.select.Evaluator
+import rx.Observable
+import rx.Single
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class SixMH : ParsedHttpSource() {
+ override val name = "6漫画"
+ override val lang = "zh"
+ override val baseUrl = PC_URL
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ override val client = network.client.newBuilder()
+ .addInterceptor(NonblockingRateLimiter(2))
+ .build()
+
+ override fun popularMangaRequest(page: Int) = GET("$PC_URL/rank/1-$page.html", headers)
+ override fun popularMangaNextPageSelector() = "li.thisclass:not(:last-of-type)"
+ override fun popularMangaSelector() = "div.cy_list_mh > ul"
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ with(element.child(1).child(0)) {
+ url = attr("href")
+ title = ownText()
+ }
+ thumbnail_url = element.selectFirst(Evaluator.Tag("img")).attr("src")
+ }
+
+ override fun latestUpdatesRequest(page: Int) = GET("$PC_URL/rank/5-$page.html", headers)
+ override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
+ override fun latestUpdatesSelector() = popularMangaSelector()
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+ override fun searchMangaSelector() = popularMangaSelector()
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ if (query.isNotBlank()) {
+ return GET("$PC_URL/search.php?keyword=$query", headers)
+ } else {
+ filters.filterIsInstance().firstOrNull()?.run {
+ return GET("$PC_URL$path$page.html", headers)
+ }
+ return popularMangaRequest(page)
+ }
+ }
+
+ private fun pcRequest(manga: SManga) = GET("$PC_URL${manga.url}", headers)
+ private fun mobileRequest(manga: SManga) = GET("$MOBILE_URL${manga.url}", headers)
+
+ // for WebView
+ override fun mangaDetailsRequest(manga: SManga) = mobileRequest(manga)
+ override fun mangaDetailsParse(document: Document) = throw UnsupportedOperationException("Not used.")
+
+ // fetchMangaDetails fetches and parses PC page first, then mobile page
+ // fetchChapterList does in the opposite order, to make use of transparent cache
+ // in this way, the latter requests will be responded with 304 Not Modified (in most cases)
+
+ override fun fetchMangaDetails(manga: SManga): Observable = Single.create {
+ val document = client.newCall(pcRequest(manga)).execute().asJsoup()
+ val result = SManga.create().apply {
+ val box = document.selectFirst(Evaluator.Id("intro_l"))
+ val details = box.getElementsByTag("span")
+ author = details[0].text().removePrefix("作者:")
+ status = when (details[1].child(0).ownText()) {
+ "连载中" -> SManga.ONGOING
+ "已完结" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ genre = buildList {
+ add(details[2].ownText().removePrefix("类别:"))
+ details[3].ownText().removePrefix("标签:").split(Regex("[ -~]+"))
+ .filterTo(this) { it.isNotEmpty() }
+ }.joinToString()
+ description = box.selectFirst(Evaluator.Tag("p")).ownText()
+ thumbnail_url = box.selectFirst(Evaluator.Tag("img")).attr("src")
+ }
+ val mobileDocument = client.newCall(mobileRequest(manga)).execute().asJsoup()
+ val details = mobileDocument.selectFirst(Evaluator.Class("author"))
+ .ownText().trim().split(Regex(""" +"""))
+ if (details.size >= 3) {
+ result.description = details[2] + '\n' + result.description
+ }
+ it.onSuccess(result)
+ }.toObservable()
+
+ override fun chapterListSelector() = throw UnsupportedOperationException("Not used.")
+ override fun chapterFromElement(element: Element) = throw UnsupportedOperationException("Not used.")
+
+ override fun fetchChapterList(manga: SManga): Observable> = Single.create> {
+ val document = client.newCall(mobileRequest(manga)).execute().asJsoup()
+ val list = mutableListOf()
+
+ document.select(".chapter-list > a, dd[class^=gengduo]").forEach { element ->
+ if (element.tagName() == "a") {
+ val chapter = SChapter.create().apply {
+ url = element.attr("href")
+ name = element.text()
+ }
+ list.add(chapter)
+ } else {
+ val path = manga.url
+ val body = FormBody.Builder().apply {
+ addEncoded("id", element.attr("data-id"))
+ addEncoded("id2", element.attr("data-vid"))
+ }.build()
+ client.newCall(POST("$MOBILE_URL/bookchapter/", headers, body)).execute()
+ .parseAs>().mapTo(list) { it.toSChapter(path) }
+ }
+ }
+
+ if (isNewDateLogic && list.isNotEmpty()) {
+ val pcDocument = client.newCall(pcRequest(manga)).execute().asJsoup()
+ pcDocument.selectFirst(".cy_zhangjie_top font")?.run {
+ list[0].date_upload = dateFormat.parse(ownText())?.time ?: 0
+ }
+ }
+ it.onSuccess(list)
+ }.toObservable()
+
+ override fun pageListRequest(chapter: SChapter) = GET("$MOBILE_URL${chapter.url}", headers)
+
+ override fun pageListParse(response: Response): List {
+ val result = Unpacker.unpack(response.body!!.string(), "[", "]")
+ .ifEmpty { return emptyList() }
+ .replace("\\", "")
+ .removeSurrounding("\"").split("\",\"")
+ return result.mapIndexed { i, url -> Page(i, imageUrl = url) }
+ }
+
+ override fun pageListParse(document: Document) = throw UnsupportedOperationException("Not used.")
+ override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
+
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream(body!!.byteStream())
+ }
+
+ override fun getFilterList() = FilterList(listOf(PageFilter()))
+
+ companion object {
+ // redirect URL: http://www.6mh9.com/
+ private const val DOMAIN = "sixmh7.com"
+ private const val PC_URL = "http://www.$DOMAIN"
+ private const val MOBILE_URL = "http://m.$DOMAIN"
+
+ private val isNewDateLogic = AppInfo.getVersionCode() >= 81
+ private val dateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+ }
+}
diff --git a/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMHUtils.kt b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMHUtils.kt
new file mode 100644
index 000000000..0e25bcc6e
--- /dev/null
+++ b/src/zh/sixmh/src/eu/kanade/tachiyomi/extension/zh/sixmh/SixMHUtils.kt
@@ -0,0 +1,29 @@
+package eu.kanade.tachiyomi.extension.zh.sixmh
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.SChapter
+import kotlinx.serialization.Serializable
+
+@Serializable
+class ChapterDto(val chapterid: String, val chaptername: String) {
+ fun toSChapter(path: String) = SChapter.create().apply {
+ url = "$path$chapterid.html"
+ name = chaptername
+ }
+}
+
+internal class PageFilter : Filter.Select("排行榜/分类", PAGE_NAMES) {
+ val path get() = PAGE_PATHS[state]
+}
+
+private val PAGE_NAMES = arrayOf(
+ "人气榜", "周读榜", "月读榜", "火爆榜", "更新榜", "新漫榜",
+ "冒险热血", "武侠格斗", "科幻魔幻", "侦探推理", "耽美爱情", "生活漫画",
+ "推荐漫画", "完结漫画", "连载漫画",
+)
+
+private val PAGE_PATHS = arrayOf(
+ "/rank/1-", "/rank/2-", "/rank/3-", "/rank/4-", "/rank/5-", "/rank/6-",
+ "/sort/1-", "/sort/2-", "/sort/3-", "/sort/4-", "/sort/5-", "/sort/6-",
+ "/sort/11-", "/sort/12-", "/sort/13-",
+)