Add Roumanwu (#12050)
This commit is contained in:
		
							parent
							
								
									03568c33bc
								
							
						
					
					
						commit
						210441d05f
					
				
							
								
								
									
										2
									
								
								src/zh/roumanwu/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/zh/roumanwu/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <manifest package="eu.kanade.tachiyomi.extension" /> | ||||
							
								
								
									
										13
									
								
								src/zh/roumanwu/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/zh/roumanwu/build.gradle
									
									
									
									
									
										Normal 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" | ||||
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 9.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/web_hi_res_512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/roumanwu/res/web_hi_res_512.png
									
									
									
									
									
										Normal file
									
								
							
										
											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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 kasperskier
						kasperskier