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