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-", +)