diff --git a/src/zh/roumanwu/AndroidManifest.xml b/src/zh/roumanwu/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/zh/roumanwu/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/zh/roumanwu/build.gradle b/src/zh/roumanwu/build.gradle new file mode 100644 index 000000000..6244ab8cc --- /dev/null +++ b/src/zh/roumanwu/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Roumanwu' + pkgNameSuffix = 'zh.roumanwu' + extClass = '.Roumanwu' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..9fa8c768d Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4bf9906ff Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..2a9e663bd Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..adb6dc920 Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..8bd5df0f8 Binary files /dev/null and b/src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/roumanwu/res/web_hi_res_512.png b/src/zh/roumanwu/res/web_hi_res_512.png new file mode 100644 index 000000000..d4ee5b66b Binary files /dev/null and b/src/zh/roumanwu/res/web_hi_res_512.png differ diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt new file mode 100644 index 000000000..4c5813d7a --- /dev/null +++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt @@ -0,0 +1,130 @@ +package eu.kanade.tachiyomi.extension.zh.roumanwu + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.ConfigurableSource +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.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import kotlin.math.max + +class Roumanwu : HttpSource(), ConfigurableSource { + override val name = "肉漫屋" + override val lang = "zh" + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override val baseUrl = MIRRORS[ + max(MIRRORS.size - 1, preferences.getString(MIRROR_PREF, MIRROR_DEFAULT)!!.toInt()) + ] + + override val client = network.client.newBuilder().addInterceptor(ScrambledImageInterceptor()).build() + + private val json: Json by injectLazy() + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers) + override fun popularMangaParse(response: Response) = response.nextjsData().getPopular().toMangasPage() + + override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page) + override fun latestUpdatesParse(response: Response) = response.nextjsData().recentUpdatedBooks.toMangasPage() + + override fun searchMangaParse(response: Response) = response.nextjsData().toMangasPage() + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = + if (query.isNotBlank()) { + GET("$baseUrl/search?term=$query&page=${page - 1}", headers) + } else { + val parts = filters.filterIsInstance().joinToString("") { it.toUriPart() } + GET("$baseUrl/books?page=${page - 1}$parts", headers) + } + + override fun mangaDetailsParse(response: Response) = response.nextjsData().book.toSManga() + + override fun chapterListParse(response: Response) = response.nextjsData().book.getChapterList().reversed() + + override fun pageListParse(response: Response): List { + val chapter = response.nextjsData() + if (chapter.statusCode != null) throw Exception("服务器错误: ${chapter.statusCode}") + return if (chapter.images != null) { + chapter.getPageList() + } else { + val response = client.newCall(GET(baseUrl + chapter.chapterAPIPath!!, headers)).execute() + if (!response.isSuccessful) throw Exception("服务器错误: ${response.code}") + json.decodeFromString(response.body!!.string()).chapter.getPageList() + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.") + + override fun getFilterList() = FilterList( + Filter.Header("提示:搜索时筛选无效"), + TagFilter(), + StatusFilter(), + SortFilter(), + ) + + private abstract class UriPartFilter(name: String, values: Array) : Filter.Select(name, values) { + abstract fun toUriPart(): String + } + + private class TagFilter : UriPartFilter("標籤", TAGS) { + override fun toUriPart() = if (state == 0) "" else "&tag=${TAGS[state]}" + } + + private class StatusFilter : UriPartFilter("狀態", arrayOf("全部", "連載中", "已完結")) { + override fun toUriPart() = + when (state) { + 1 -> "&continued=true" + 2 -> "&continued=false" + else -> "" + } + } + + private class SortFilter : UriPartFilter("排序", arrayOf("更新日期", "評分")) { + override fun toUriPart() = if (state == 0) "" else "&sort=rating" + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val mirrorPref = androidx.preference.ListPreference(screen.context).apply { + key = MIRROR_PREF + title = MIRROR_PREF_TITLE + entries = MIRRORS_DESC + entryValues = MIRRORS.indices.map(Int::toString).toTypedArray() + summary = MIRROR_PREF_SUMMARY + + setDefaultValue(MIRROR_DEFAULT) + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(MIRROR_PREF, newValue as String).commit() + } + } + screen.addPreference(mirrorPref) + } + + companion object { + private const val MIRROR_PREF = "MIRROR" + private const val MIRROR_PREF_TITLE = "使用镜像网址" + private const val MIRROR_PREF_SUMMARY = "使用镜像网址。重启软件生效。" + + // 地址: https://rou.pub/dizhi + private val MIRRORS = arrayOf("https://rouman5.com", "https://rouman01.xyz") + private val MIRRORS_DESC = arrayOf("主站", "镜像") + private const val MIRROR_DEFAULT = 1.toString() // use mirror + + private val TAGS = arrayOf("全部", "正妹", "恋爱", "出版漫画", "肉慾", "浪漫", "大尺度", "巨乳", "有夫之婦", "女大生", "狗血劇", "同居", "好友", "調教", "动作", "後宮", "不倫") + } + + private inline fun Response.nextjsData() = + json.decodeFromString>(this.asJsoup().select("#__NEXT_DATA__").html()).props.pageProps +} diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt new file mode 100644 index 000000000..b309f8998 --- /dev/null +++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/RoumanwuDto.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.extension.zh.roumanwu + +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class NextData(val props: Props) + +@Serializable +data class Props(val pageProps: T) + +@Serializable +data class Book( + val id: String, + val name: String, +// val alias: List, + val description: String, + val coverUrl: String, + val author: String, + val continued: Boolean, + val tags: List, + val updatedAt: String? = null, // TODO: 2022-06-02T00:00:00.000Z + val activeResource: Resource? = null, +) { + fun toSManga() = SManga.create().apply { + url = "/books/$id" + title = name + author = this@Book.author + description = this@Book.description + genre = tags.joinToString(", ") + status = if (continued) SManga.ONGOING else SManga.COMPLETED + thumbnail_url = coverUrl + } + + /** 正序 */ + fun getChapterList() = activeResource!!.chapters.mapIndexed { i, it -> + SChapter.create().apply { + url = "/books/$id/$i" + name = it + } + } + + private val uuid by lazy { UUID.fromString(id) } + override fun hashCode() = uuid.hashCode() + override fun equals(other: Any?) = other is Book && uuid == other.uuid +} + +@Serializable +data class Resource(val chapters: List) + +@Serializable +data class BookList(val books: List, val hasNextPage: Boolean) { + fun toMangasPage() = MangasPage(books.map(Book::toSManga), hasNextPage) +} + +@Serializable +data class HomePage( + val headline: Book, + val best: List, + val hottest: List, + val daily: List, + val recentUpdatedBooks: List, + val endedBooks: List, +) { + fun getPopular() = (listOf(headline) + best + hottest + daily + endedBooks).distinct() +} + +fun List.toMangasPage() = MangasPage(this.map(Book::toSManga), false) + +@Serializable +data class BookDetails(val book: Book) + +@Serializable +data class Chapter( + val statusCode: Int? = null, + val images: List? = null, + val chapterAPIPath: String? = null, +) { + fun getPageList() = images!!.mapIndexed { i, it -> + Page(i, imageUrl = it.src + if (it.scramble) SCRAMBLED_SUFFIX else "") + } +} + +@Serializable +data class ChapterWrapper(val chapter: Chapter) + +@Serializable +data class Image(val src: String, val scramble: Boolean) + +const val SCRAMBLED_SUFFIX = "?scrambled" diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt new file mode 100644 index 000000000..a87222682 --- /dev/null +++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.extension.zh.roumanwu + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import android.util.Base64 +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.ByteArrayOutputStream +import java.security.MessageDigest + +class ScrambledImageInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + val url = request.url.toString() + if (!url.endsWith(SCRAMBLED_SUFFIX)) return response + val image = BitmapFactory.decodeStream(response.body!!.byteStream()) + val width = image.width + val height = image.height + val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + + // https://rouman01.xyz/_next/static/chunks/pages/books/%5Bbookid%5D/%5Bid%5D-6f60a589e82dc8db.js + // Scrambled images are reversed by blocks. Remainder is included in the bottom (scrambled) block. + val blocks = url.removeSuffix(SCRAMBLED_SUFFIX).substringAfterLast('/').removeSuffix(".jpg") + .let { Base64.decode(it, Base64.DEFAULT) } + .let { MessageDigest.getInstance("MD5").digest(it) } // thread-safe + .let { it.last().toPositiveInt() % 10 + 5 } + val blockHeight = height / blocks + var iy = blockHeight * (blocks - 1) + var cy = 0 + for (i in 0 until blocks) { + val h = if (i == 0) height - iy else blockHeight + val src = Rect(0, iy, width, iy + h) + val dst = Rect(0, cy, width, cy + h) + canvas.drawBitmap(image, src, dst, null) + iy -= blockHeight + cy += h + } + + val output = ByteArrayOutputStream() + result.compress(Bitmap.CompressFormat.JPEG, 90, output) + val responseBody = output.toByteArray().toResponseBody(jpegMediaType) + return response.newBuilder().body(responseBody).build() + } + + companion object { + private val jpegMediaType = "image/jpeg".toMediaType() + private fun Byte.toPositiveInt() = toInt() and 0xFF + } +}