Add K Manga (#10498)
* Add K Manga * apply fixes, different and consistent thumbnail, better search * simplify * manifest, deeplink * exception message
This commit is contained in:
parent
b9c1a7afca
commit
a7408115ed
22
src/en/kmanga/AndroidManifest.xml
Normal file
22
src/en/kmanga/AndroidManifest.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name="eu.kanade.tachiyomi.extension.en.kmanga.KMangaUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:host="kmanga.kodansha.com"
|
||||||
|
android:pathPrefix="/title/"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
8
src/en/kmanga/build.gradle
Normal file
8
src/en/kmanga/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'K Manga'
|
||||||
|
extClass = '.KManga'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = false
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/en/kmanga/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/en/kmanga/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
BIN
src/en/kmanga/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/en/kmanga/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
BIN
src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
BIN
src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,64 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kmanga
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class RankingApiResponse(
|
||||||
|
@SerialName("ranking_title_list") val rankingTitleList: List<RankingTitleId>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class RankingTitleId(
|
||||||
|
val id: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TitleListResponse(
|
||||||
|
@SerialName("title_list") val titleList: List<TitleDetail>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TitleDetail(
|
||||||
|
@SerialName("title_id") val titleId: Int,
|
||||||
|
@SerialName("title_name") val titleName: String,
|
||||||
|
@SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null,
|
||||||
|
@SerialName("banner_image_url") val bannerImageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BirthdayCookie(val value: String, val expires: Long)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class EpisodeListResponse(
|
||||||
|
@SerialName("episode_list") val episodeList: List<Episode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Episode(
|
||||||
|
@SerialName("episode_id") val episodeId: Int,
|
||||||
|
@SerialName("episode_name") val episodeName: String,
|
||||||
|
@SerialName("start_time") val startTime: String,
|
||||||
|
val point: Int,
|
||||||
|
@SerialName("title_id") val titleId: Int,
|
||||||
|
val badge: Int,
|
||||||
|
@SerialName("rental_finish_time") val rentalFinishTime: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ViewerApiResponse(
|
||||||
|
@SerialName("page_list") val pageList: List<String>,
|
||||||
|
@SerialName("scramble_seed") val scrambleSeed: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchApiResponse(
|
||||||
|
@SerialName("title_list") val titleList: List<SearchTitleDetail>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchTitleDetail(
|
||||||
|
@SerialName("title_id") val titleId: Int,
|
||||||
|
@SerialName("title_name") val titleName: String,
|
||||||
|
@SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null,
|
||||||
|
)
|
@ -0,0 +1,95 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kmanga
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
// https://greasyfork.org/en/scripts/467901-k-manga-ripper
|
||||||
|
class ImageInterceptor : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val fragment = request.url.fragment
|
||||||
|
if (fragment.isNullOrEmpty() || !fragment.startsWith("scramble_seed=")) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val seed = fragment.substringAfter("scramble_seed=").toLong()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
val descrambledBody = descrambleImage(response.body, seed)
|
||||||
|
|
||||||
|
return response.newBuilder().body(descrambledBody).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Coord(val x: Int, val y: Int)
|
||||||
|
private class CoordPair(val source: Coord, val dest: Coord)
|
||||||
|
|
||||||
|
private fun xorshift32(seed: UInt): UInt {
|
||||||
|
var n = seed
|
||||||
|
n = n xor (n shl 13)
|
||||||
|
n = n xor (n shr 17)
|
||||||
|
n = n xor (n shl 5)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUnscrambledCoords(seed: Long): List<CoordPair> {
|
||||||
|
var seed32 = seed.toUInt()
|
||||||
|
val pairs = mutableListOf<Pair<UInt, Int>>()
|
||||||
|
|
||||||
|
for (i in 0 until 16) {
|
||||||
|
seed32 = xorshift32(seed32)
|
||||||
|
pairs.add(seed32 to i)
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs.sortBy { it.first }
|
||||||
|
val sortedVal = pairs.map { it.second }
|
||||||
|
|
||||||
|
return sortedVal.mapIndexed { i, e ->
|
||||||
|
CoordPair(
|
||||||
|
source = Coord(x = e % 4, y = e / 4),
|
||||||
|
dest = Coord(x = i % 4, y = i / 4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun descrambleImage(responseBody: ResponseBody, seed: Long): ResponseBody {
|
||||||
|
val unscrambledCoords = getUnscrambledCoords(seed)
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(responseBody.byteStream())
|
||||||
|
?: throw Exception("Failed to decode image stream")
|
||||||
|
|
||||||
|
val originalWidth = originalBitmap.width
|
||||||
|
val originalHeight = originalBitmap.height
|
||||||
|
|
||||||
|
val descrambledBitmap = Bitmap.createBitmap(originalWidth, originalHeight, originalBitmap.config)
|
||||||
|
val canvas = Canvas(descrambledBitmap)
|
||||||
|
|
||||||
|
val getTileDimension = { size: Int -> (size / 8 * 8) / 4 }
|
||||||
|
val tileWidth = getTileDimension(originalWidth)
|
||||||
|
val tileHeight = getTileDimension(originalHeight)
|
||||||
|
|
||||||
|
unscrambledCoords.forEach { coord ->
|
||||||
|
val sx = coord.source.x * tileWidth
|
||||||
|
val sy = coord.source.y * tileHeight
|
||||||
|
val dx = coord.dest.x * tileWidth
|
||||||
|
val dy = coord.dest.y * tileHeight
|
||||||
|
|
||||||
|
val srcRect = Rect(sx, sy, sx + tileWidth, sy + tileHeight)
|
||||||
|
val destRect = Rect(dx, dy, dx + tileWidth, dy + tileHeight)
|
||||||
|
|
||||||
|
canvas.drawBitmap(originalBitmap, srcRect, destRect, null)
|
||||||
|
}
|
||||||
|
originalBitmap.recycle()
|
||||||
|
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
descrambledBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
||||||
|
descrambledBitmap.recycle()
|
||||||
|
|
||||||
|
return outputStream.toByteArray().toResponseBody("image/jpeg".toMediaType())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,394 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kmanga
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
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 eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import keiyoushi.utils.firstInstance
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
import rx.Observable
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class KManga : HttpSource() {
|
||||||
|
|
||||||
|
override val name = "K Manga"
|
||||||
|
override val baseUrl = "https://kmanga.kodansha.com"
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = false
|
||||||
|
|
||||||
|
private val apiUrl = "https://api.kmanga.kodansha.com"
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
private val pageLimit = 25
|
||||||
|
private val searchLimit = 25
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
.add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.add("x-kmanga-platform", "3")
|
||||||
|
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.addInterceptor(ImageInterceptor())
|
||||||
|
.rateLimitHost(apiUrl.toHttpUrl(), 1)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Popular
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
val offset = (page - 1) * pageLimit
|
||||||
|
val url = apiUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("ranking/all")
|
||||||
|
.addQueryParameter("ranking_id", "12")
|
||||||
|
.addQueryParameter("offset", offset.toString())
|
||||||
|
.addQueryParameter("limit", (pageLimit + 1).toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return hashedGet(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val rankingResult = response.parseAs<RankingApiResponse>()
|
||||||
|
val titleIds = rankingResult.rankingTitleList.map { it.id.toString() }
|
||||||
|
|
||||||
|
if (titleIds.isEmpty()) {
|
||||||
|
return MangasPage(emptyList(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasNextPage = titleIds.size > pageLimit
|
||||||
|
val mangaIdsToFetch = if (hasNextPage) titleIds.dropLast(1) else titleIds
|
||||||
|
|
||||||
|
val detailsUrl = apiUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("title/list")
|
||||||
|
.addQueryParameter("title_id_list", mangaIdsToFetch.joinToString(","))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val detailsRequest = hashedGet(detailsUrl)
|
||||||
|
val detailsResponse = client.newCall(detailsRequest).execute()
|
||||||
|
|
||||||
|
if (!detailsResponse.isSuccessful) {
|
||||||
|
throw Exception("Failed to fetch title details: ${detailsResponse.code} - ${detailsResponse.body.string()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val detailsResult = detailsResponse.parseAs<TitleListResponse>()
|
||||||
|
val mangas = detailsResult.titleList.map { manga ->
|
||||||
|
SManga.create().apply {
|
||||||
|
url = "/title/${manga.titleId}"
|
||||||
|
title = manga.titleName
|
||||||
|
thumbnail_url = manga.thumbnailImageUrl ?: manga.bannerImageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(PREFIX_SEARCH)) {
|
||||||
|
val titleId = query.removePrefix(PREFIX_SEARCH)
|
||||||
|
fetchMangaDetails(
|
||||||
|
SManga.create().apply { url = "/title/$titleId" },
|
||||||
|
).map { manga ->
|
||||||
|
MangasPage(listOf(manga.apply { url = "/title/$titleId" }), false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val genreFilter = filters.firstInstance<GenreFilter>()
|
||||||
|
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
val offset = (page - 1) * searchLimit
|
||||||
|
val url = apiUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments("search/title")
|
||||||
|
.addQueryParameter("keyword", query)
|
||||||
|
.addQueryParameter("offset", offset.toString())
|
||||||
|
.addQueryParameter("limit", searchLimit.toString())
|
||||||
|
.build()
|
||||||
|
return hashedGet(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genreFilter.state != 0) {
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder()
|
||||||
|
.addPathSegments(genreFilter.toUriPart().removePrefix("/"))
|
||||||
|
.build()
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
return popularMangaRequest(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
if (response.request.url.host.contains("api.")) {
|
||||||
|
val result = response.parseAs<SearchApiResponse>()
|
||||||
|
val mangas = result.titleList.map { manga ->
|
||||||
|
SManga.create().apply {
|
||||||
|
url = "/title/${manga.titleId}"
|
||||||
|
title = manga.titleName
|
||||||
|
thumbnail_url = manga.thumbnailImageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, mangas.size >= searchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.request.url.toString().contains("/search/genre/")) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
|
||||||
|
?: return MangasPage(emptyList(), false)
|
||||||
|
|
||||||
|
val rootArray = nuxtData.parseAs<JsonArray>()
|
||||||
|
fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
|
||||||
|
|
||||||
|
val genreResultObject = rootArray.firstOrNull { it is JsonObject && "title_list" in it.jsonObject }
|
||||||
|
?: return MangasPage(emptyList(), false)
|
||||||
|
|
||||||
|
val mangaRefs = resolve(genreResultObject.jsonObject["title_list"]!!).jsonArray
|
||||||
|
|
||||||
|
val mangas = mangaRefs.map { ref ->
|
||||||
|
val mangaObject = resolve(ref).jsonObject
|
||||||
|
SManga.create().apply {
|
||||||
|
url = "/title/${resolve(mangaObject["title_id"]!!).jsonPrimitive.content}"
|
||||||
|
title = resolve(mangaObject["title_name"]!!).jsonPrimitive.content
|
||||||
|
thumbnail_url = mangaObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
return popularMangaParse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
|
||||||
|
?: throw Exception("Could not find Nuxt data")
|
||||||
|
|
||||||
|
val rootArray = nuxtData.parseAs<JsonArray>()
|
||||||
|
fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
|
||||||
|
|
||||||
|
val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("title_in_japanese") }.jsonObject
|
||||||
|
|
||||||
|
val genreMap = buildMap {
|
||||||
|
rootArray.forEach { element ->
|
||||||
|
if (element is JsonObject && element.jsonObject.containsKey("genre_id")) {
|
||||||
|
val genreObject = element.jsonObject
|
||||||
|
val id = resolve(genreObject["genre_id"]!!).jsonPrimitive.content.toInt()
|
||||||
|
val name = resolve(genreObject["genre_name"]!!).jsonPrimitive.content
|
||||||
|
put(id, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
title = resolve(titleDetailsObject["title_name"]!!).jsonPrimitive.content
|
||||||
|
author = resolve(titleDetailsObject["author_text"]!!).jsonPrimitive.content
|
||||||
|
description = resolve(titleDetailsObject["introduction_text"]!!).jsonPrimitive.content
|
||||||
|
thumbnail_url = titleDetailsObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content }
|
||||||
|
val genreIdRefs = resolve(titleDetailsObject["genre_id_list"]!!).jsonArray
|
||||||
|
val genreIds = genreIdRefs.map { resolve(it).jsonPrimitive.content.toInt() }
|
||||||
|
genre = genreIds.mapNotNull { genreMap[it] }.joinToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
return GET(baseUrl + manga.url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
|
||||||
|
?: throw Exception("Could not find Nuxt data")
|
||||||
|
|
||||||
|
val rootArray = nuxtData.parseAs<JsonArray>()
|
||||||
|
fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
|
||||||
|
|
||||||
|
val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("episode_id_list") }.jsonObject
|
||||||
|
val episodeIdRefs = resolve(titleDetailsObject["episode_id_list"]!!).jsonArray
|
||||||
|
val episodeIds = episodeIdRefs.map { resolve(it).jsonPrimitive.content }
|
||||||
|
|
||||||
|
val (birthday, expires) = getBirthdayCookie(response.request.url)
|
||||||
|
|
||||||
|
val formBody = FormBody.Builder()
|
||||||
|
.add("episode_id_list", episodeIds.joinToString(","))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val params = (0 until formBody.size).associate { formBody.name(it) to formBody.value(it) }
|
||||||
|
val hash = generateHash(params, birthday, expires)
|
||||||
|
|
||||||
|
val postHeaders = headersBuilder()
|
||||||
|
.add("Accept", "application/json")
|
||||||
|
.add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.add("Origin", baseUrl)
|
||||||
|
.add("x-kmanga-is-crawler", "false")
|
||||||
|
.add("x-kmanga-hash", hash)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val apiRequest = POST("$apiUrl/episode/list", postHeaders, formBody)
|
||||||
|
val apiResponse = client.newCall(apiRequest).execute()
|
||||||
|
|
||||||
|
if (!apiResponse.isSuccessful) {
|
||||||
|
throw Exception("API request failed with code ${apiResponse.code}: ${apiResponse.body.string()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = apiResponse.parseAs<EpisodeListResponse>()
|
||||||
|
|
||||||
|
return result.episodeList.map { chapter ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "/title/${chapter.titleId}/episode/${chapter.episodeId}"
|
||||||
|
name = if (chapter.point > 0 && chapter.badge != 3 && chapter.rentalFinishTime == null) {
|
||||||
|
"🔒 ${chapter.episodeName}"
|
||||||
|
} else {
|
||||||
|
chapter.episodeName
|
||||||
|
}
|
||||||
|
date_upload = dateFormat.tryParse(chapter.startTime)
|
||||||
|
}
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBirthdayCookie(url: HttpUrl): Pair<String, String> {
|
||||||
|
val cookies = client.cookieJar.loadForRequest(url)
|
||||||
|
val birthdayCookie = cookies.firstOrNull { it.name == "birthday" }?.value
|
||||||
|
|
||||||
|
return if (birthdayCookie != null) {
|
||||||
|
try {
|
||||||
|
val decoded = URLDecoder.decode(birthdayCookie, "UTF-8")
|
||||||
|
val cookieData = decoded.parseAs<BirthdayCookie>()
|
||||||
|
cookieData.value to cookieData.expires.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to default if cookie is malformed
|
||||||
|
"2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default for logged out users or users without the cookie to bypass age restrictions
|
||||||
|
"2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://kmanga.kodansha.com/_nuxt/vl9so/entry-CSwIbMdW.js
|
||||||
|
private fun generateHash(params: Map<String, String>, birthday: String, expires: String): String {
|
||||||
|
val paramStrings = params.toSortedMap().map { (key, value) ->
|
||||||
|
getHashedParam(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
val joinedParams = paramStrings.joinToString(",")
|
||||||
|
val hash1 = joinedParams.encodeUtf8().sha256().hex()
|
||||||
|
|
||||||
|
val cookieHash = getHashedParam(birthday, expires)
|
||||||
|
|
||||||
|
val finalString = "$hash1$cookieHash"
|
||||||
|
return finalString.encodeUtf8().sha512().hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getHashedParam(key: String, value: String): String {
|
||||||
|
val keyHash = key.encodeUtf8().sha256().hex()
|
||||||
|
val valueHash = value.encodeUtf8().sha512().hex()
|
||||||
|
return "${keyHash}_$valueHash"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return client.newCall(pageListRequest(chapter))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
if (response.code == 400) {
|
||||||
|
throw Exception("This chapter is locked. Log in via WebView and rent or purchase the chapter to read.")
|
||||||
|
}
|
||||||
|
throw Exception("HTTP error ${response.code}")
|
||||||
|
}
|
||||||
|
pageListParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val apiResponse = response.parseAs<ViewerApiResponse>()
|
||||||
|
val seed = apiResponse.scrambleSeed
|
||||||
|
return apiResponse.pageList.mapIndexed { index, imageUrl ->
|
||||||
|
Page(index = index, imageUrl = "$imageUrl#scramble_seed=$seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val episodeId = chapter.url.substringAfter("episode/")
|
||||||
|
val url = "$apiUrl/web/episode/viewer".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("episode_id", episodeId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return hashedGet(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashedGet(url: HttpUrl): Request {
|
||||||
|
val (birthday, expires) = getBirthdayCookie(url)
|
||||||
|
val queryParams = url.queryParameterNames.associateWith { url.queryParameter(it)!! }
|
||||||
|
val hash = generateHash(queryParams, birthday, expires)
|
||||||
|
val newHeaders = headersBuilder()
|
||||||
|
.add("x-kmanga-hash", hash)
|
||||||
|
.build()
|
||||||
|
return GET(url, newHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
Filter.Header("NOTE: Search query will ignore genre filter"),
|
||||||
|
GenreFilter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GenreFilter : UriPartFilter(
|
||||||
|
"Genre",
|
||||||
|
arrayOf(
|
||||||
|
Pair("All", "/ranking"),
|
||||||
|
Pair("Romance・Romcom", "/search/genre/1"),
|
||||||
|
Pair("Horror・Mystery・Suspense", "/search/genre/2"),
|
||||||
|
Pair("Gag・Comedy・Slice-of-Life", "/search/genre/3"),
|
||||||
|
Pair("SF・Fantasy", "/search/genre/4"),
|
||||||
|
Pair("Sports", "/search/genre/5"),
|
||||||
|
Pair("Drama", "/search/genre/6"),
|
||||||
|
Pair("Outlaws・Underworld・Punks", "/search/genre/7"),
|
||||||
|
Pair("Action・Battle", "/search/genre/8"),
|
||||||
|
Pair("Isekai・Super Powers", "/search/genre/9"),
|
||||||
|
Pair("One-off Books", "/search/genre/10"),
|
||||||
|
Pair("Shojo/josei", "/search/genre/11"),
|
||||||
|
Pair("Yaoi/BL", "/search/genre/12"),
|
||||||
|
Pair("LGBTQ", "/search/genre/13"),
|
||||||
|
Pair("Yuri/GL", "/search/genre/14"),
|
||||||
|
Pair("Anime", "/search/genre/15"),
|
||||||
|
Pair("Award Winner", "/search/genre/16"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||||
|
fun toUriPart() = vals[state].second
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_SEARCH = "id:"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported
|
||||||
|
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||||
|
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kmanga
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class KMangaUrlActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
if (pathSegments != null && pathSegments.size > 1) {
|
||||||
|
val titleId = pathSegments[1]
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", "${KManga.PREFIX_SEARCH}$titleId")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("KMangaUrlActivity", e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("KMangaUrlActivity", "Could not parse URI from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user