Add Roumanwu (#12050)

This commit is contained in:
kasperskier 2022-06-03 19:15:42 +08:00 committed by GitHub
parent 03568c33bc
commit 210441d05f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 294 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -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<Application>().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<HomePage>().getPopular().toMangasPage()
override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page)
override fun latestUpdatesParse(response: Response) = response.nextjsData<HomePage>().recentUpdatedBooks.toMangasPage()
override fun searchMangaParse(response: Response) = response.nextjsData<BookList>().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<UriPartFilter>().joinToString("") { it.toUriPart() }
GET("$baseUrl/books?page=${page - 1}$parts", headers)
}
override fun mangaDetailsParse(response: Response) = response.nextjsData<BookDetails>().book.toSManga()
override fun chapterListParse(response: Response) = response.nextjsData<BookDetails>().book.getChapterList().reversed()
override fun pageListParse(response: Response): List<Page> {
val chapter = response.nextjsData<Chapter>()
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<ChapterWrapper>(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<String>) : Filter.Select<String>(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 <reified T> Response.nextjsData() =
json.decodeFromString<NextData<T>>(this.asJsoup().select("#__NEXT_DATA__").html()).props.pageProps
}

View File

@ -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<T>(val props: Props<T>)
@Serializable
data class Props<T>(val pageProps: T)
@Serializable
data class Book(
val id: String,
val name: String,
// val alias: List<String>,
val description: String,
val coverUrl: String,
val author: String,
val continued: Boolean,
val tags: List<String>,
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<String>)
@Serializable
data class BookList(val books: List<Book>, val hasNextPage: Boolean) {
fun toMangasPage() = MangasPage(books.map(Book::toSManga), hasNextPage)
}
@Serializable
data class HomePage(
val headline: Book,
val best: List<Book>,
val hottest: List<Book>,
val daily: List<Book>,
val recentUpdatedBooks: List<Book>,
val endedBooks: List<Book>,
) {
fun getPopular() = (listOf(headline) + best + hottest + daily + endedBooks).distinct()
}
fun List<Book>.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<Image>? = 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"

View File

@ -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
}
}