Add Roumanwu (#12050)
This commit is contained in:
parent
03568c33bc
commit
210441d05f
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -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 |
|
@ -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
|
||||
}
|
|
@ -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"
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue