Koharu: fix DTO & bypass Cloudflare (#8175)
* Koharu - \r\n → \n * SchaleNetwork: fix loading & support related-manga * (SchaleNetwok/Koharu): Fix DTO & bypass Cloudflare (#128) * Fix DTO * Bypass CloudFlare Turnstile * Add tags filter select box which supports click on 'tag' to search * Allow permanent excluded tags * Revert fork specific Koharu changes * Bump version + lint --------- Co-authored-by: Cuong-Tran <cuongtran.tm@gmail.com>
This commit is contained in:
parent
414b6b8670
commit
3fb70c10de
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'SchaleNetwork'
|
extName = 'SchaleNetwork'
|
||||||
extClass = '.KoharuFactory'
|
extClass = '.KoharuFactory'
|
||||||
extVersionCode = 12
|
extVersionCode = 13
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,24 @@
|
|||||||
package eu.kanade.tachiyomi.extension.all.koharu
|
package eu.kanade.tachiyomi.extension.all.koharu
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.PreferenceScreen
|
import androidx.preference.PreferenceScreen
|
||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.artistList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.circleList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.femaleList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.genreList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.getFilters
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.maleList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.mixedList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.otherList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.parodyList
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.tagsFetchAttempts
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.tagsFetched
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
@ -12,9 +26,11 @@ import eu.kanade.tachiyomi.source.model.MangasPage
|
|||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import keiyoushi.utils.getPreferencesLazy
|
import keiyoushi.utils.getPreferencesLazy
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
@ -42,11 +58,9 @@ class Koharu(
|
|||||||
|
|
||||||
private val apiBooksUrl = "$apiUrl/books"
|
private val apiBooksUrl = "$apiUrl/books"
|
||||||
|
|
||||||
override val supportsLatest = true
|
private val authUrl = "${baseUrl.replace("://", "://auth.")}/clearance"
|
||||||
|
|
||||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
override val supportsLatest = true
|
||||||
.rateLimit(1)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
@ -59,6 +73,18 @@ class Koharu(
|
|||||||
|
|
||||||
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
|
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
|
||||||
|
|
||||||
|
private fun alwaysExcludeTags() = preferences.getString(PREF_EXCLUDE_TAGS, "")
|
||||||
|
|
||||||
|
private var _domainUrl: String? = null
|
||||||
|
internal val domainUrl: String
|
||||||
|
get() {
|
||||||
|
return _domainUrl ?: run {
|
||||||
|
val domain = getDomain()
|
||||||
|
_domainUrl = domain
|
||||||
|
domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getDomain(): String {
|
private fun getDomain(): String {
|
||||||
try {
|
try {
|
||||||
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||||
@ -72,20 +98,29 @@ class Koharu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val lazyHeaders by lazy {
|
private val lazyHeaders by lazy {
|
||||||
val domain = getDomain()
|
|
||||||
headersBuilder()
|
headersBuilder()
|
||||||
.set("Referer", "$domain/")
|
.set("Referer", "$domainUrl/")
|
||||||
.set("Origin", domain)
|
.set("Origin", domainUrl)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(3)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val interceptedClient: OkHttpClient
|
||||||
|
get() = network.cloudflareClient.newBuilder()
|
||||||
|
.addInterceptor(TurnstileInterceptor(client, domainUrl, authUrl, lazyHeaders["User-Agent"]))
|
||||||
|
.rateLimit(3)
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun getManga(book: Entry) = SManga.create().apply {
|
private fun getManga(book: Entry) = SManga.create().apply {
|
||||||
setUrlWithoutDomain("${book.id}/${book.public_key}")
|
setUrlWithoutDomain("${book.id}/${book.key}")
|
||||||
title = if (remadd()) book.title.shortenTitle() else book.title
|
title = if (remadd()) book.title.shortenTitle() else book.title
|
||||||
thumbnail_url = book.thumbnail.path
|
thumbnail_url = book.thumbnail.path
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getImagesByMangaEntry(entry: MangaEntry): Pair<ImagesInfo, String> {
|
private fun getImagesByMangaData(entry: MangaData, entryId: String, entryKey: String): Pair<ImagesInfo, String> {
|
||||||
val data = entry.data
|
val data = entry.data
|
||||||
fun getIPK(
|
fun getIPK(
|
||||||
ori: DataKey?,
|
ori: DataKey?,
|
||||||
@ -96,7 +131,7 @@ class Koharu(
|
|||||||
): Pair<Int?, String?> {
|
): Pair<Int?, String?> {
|
||||||
return Pair(
|
return Pair(
|
||||||
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
|
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
|
||||||
ori?.public_key ?: alt1?.public_key ?: alt2?.public_key ?: alt3?.public_key ?: alt4?.public_key,
|
ori?.key ?: alt1?.key ?: alt2?.key ?: alt3?.key ?: alt4?.key,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val (id, public_key) = when (quality()) {
|
val (id, public_key) = when (quality()) {
|
||||||
@ -119,35 +154,65 @@ class Koharu(
|
|||||||
else -> "0"
|
else -> "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
val imagesResponse = client.newCall(GET("$apiBooksUrl/data/${entry.id}/${entry.public_key}/$id/$public_key?v=${entry.updated_at ?: entry.created_at}&w=$realQuality", lazyHeaders)).execute()
|
val imagesResponse = interceptedClient.newCall(GET("$apiBooksUrl/data/$entryId/$entryKey/$id/$public_key/$realQuality?crt=$token", lazyHeaders)).execute()
|
||||||
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
|
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
|
||||||
return images
|
return images
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest
|
// Latest
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int) = GET("$apiBooksUrl?page=$page" + if (searchLang.isNotBlank()) "&s=language!:\"$searchLang\"" else "", lazyHeaders)
|
override fun latestUpdatesRequest(page: Int) = GET(
|
||||||
|
apiBooksUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
val terms: MutableList<String> = mutableListOf()
|
||||||
|
if (lang != "all") terms += "language:\"^$searchLang$\""
|
||||||
|
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
|
||||||
|
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
|
||||||
|
if (alwaysExcludeTags.isNotEmpty()) {
|
||||||
|
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
|
||||||
|
}
|
||||||
|
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||||
|
}.build(),
|
||||||
|
lazyHeaders,
|
||||||
|
)
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||||
|
|
||||||
// Popular
|
// Popular
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int) = GET("$apiBooksUrl?sort=8&page=$page" + if (searchLang.isNotBlank()) "&s=language!:\"$searchLang\"" else "", lazyHeaders)
|
override fun popularMangaRequest(page: Int) = GET(
|
||||||
|
apiBooksUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("sort", "8")
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
val terms: MutableList<String> = mutableListOf()
|
||||||
|
if (lang != "all") terms += "language:\"^$searchLang$\""
|
||||||
|
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
|
||||||
|
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
|
||||||
|
if (alwaysExcludeTags.isNotEmpty()) {
|
||||||
|
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
|
||||||
|
}
|
||||||
|
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||||
|
}.build(),
|
||||||
|
lazyHeaders,
|
||||||
|
)
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
val data = response.parseAs<Books>()
|
val data = response.parseAs<Books>()
|
||||||
|
|
||||||
return MangasPage(data.entries.map(::getManga), data.page * data.limit < data.total)
|
return MangasPage(data.entries.map(::getManga), data.page * data.limit < data.total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
|
|
||||||
override fun getFilterList(): FilterList = getFilters()
|
|
||||||
|
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
return when {
|
return when {
|
||||||
query.startsWith(PREFIX_ID_KEY_SEARCH) -> {
|
query.startsWith(PREFIX_ID_KEY_SEARCH) -> {
|
||||||
val ipk = query.removePrefix(PREFIX_ID_KEY_SEARCH)
|
val ipk = query.removePrefix(PREFIX_ID_KEY_SEARCH)
|
||||||
val response = client.newCall(GET("$apiBooksUrl/detail/$ipk", lazyHeaders)).execute()
|
val response = client.newCall(GET("$apiBooksUrl/detail/$ipk", lazyHeaders)).execute()
|
||||||
Observable.just(searchMangaParse2(response))
|
Observable.just(
|
||||||
|
MangasPage(listOf(mangaDetailsParse(response)), false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> super.fetchSearchManga(page, query, filters)
|
else -> super.fetchSearchManga(page, query, filters)
|
||||||
}
|
}
|
||||||
@ -156,30 +221,61 @@ class Koharu(
|
|||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
val url = apiBooksUrl.toHttpUrl().newBuilder().apply {
|
val url = apiBooksUrl.toHttpUrl().newBuilder().apply {
|
||||||
val terms: MutableList<String> = mutableListOf()
|
val terms: MutableList<String> = mutableListOf()
|
||||||
|
val includedTags: MutableList<Int> = mutableListOf()
|
||||||
|
val excludedTags: MutableList<Int> = mutableListOf()
|
||||||
|
|
||||||
|
if (lang != "all") terms += "language:\"^$searchLang$\""
|
||||||
|
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
|
||||||
|
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
|
||||||
|
if (alwaysExcludeTags.isNotEmpty()) {
|
||||||
|
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
|
||||||
|
}
|
||||||
|
|
||||||
if (lang != "all") terms += "language!:\"$searchLang\""
|
|
||||||
filters.forEach { filter ->
|
filters.forEach { filter ->
|
||||||
when (filter) {
|
when (filter) {
|
||||||
is SortFilter -> addQueryParameter("sort", filter.getValue())
|
is KoharuFilters.SortFilter -> addQueryParameter("sort", filter.getValue())
|
||||||
|
|
||||||
is CategoryFilter -> {
|
is KoharuFilters.CategoryFilter -> {
|
||||||
val activeFilter = filter.state.filter { it.state }
|
val activeFilter = filter.state.filter { it.state }
|
||||||
if (activeFilter.isNotEmpty()) {
|
if (activeFilter.isNotEmpty()) {
|
||||||
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
|
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TextFilter -> {
|
is KoharuFilters.TagFilter -> {
|
||||||
|
includedTags += filter.state
|
||||||
|
.filter { it.isIncluded() }
|
||||||
|
.map { it.id }
|
||||||
|
excludedTags += filter.state
|
||||||
|
.filter { it.isExcluded() }
|
||||||
|
.map { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
is KoharuFilters.GenreConditionFilter -> {
|
||||||
|
if (filter.state > 0) {
|
||||||
|
addQueryParameter(filter.param, filter.toUriPart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is KoharuFilters.TextFilter -> {
|
||||||
if (filter.state.isNotEmpty()) {
|
if (filter.state.isNotEmpty()) {
|
||||||
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
|
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
|
||||||
if (tags.isNotBlank()) {
|
if (tags.isNotBlank()) {
|
||||||
terms += "${filter.type}!:" + if (filter.type == "pages") tags else '"' + tags + '"'
|
terms += "${filter.type}:" + if (filter.type == "pages") tags else "\"$tags\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includedTags.isNotEmpty()) {
|
||||||
|
addQueryParameter("include", includedTags.joinToString(","))
|
||||||
|
}
|
||||||
|
if (excludedTags.isNotEmpty()) {
|
||||||
|
addQueryParameter("exclude", excludedTags.joinToString(","))
|
||||||
|
}
|
||||||
|
|
||||||
if (query.isNotEmpty()) terms.add("title:\"$query\"")
|
if (query.isNotEmpty()) terms.add("title:\"$query\"")
|
||||||
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
|
||||||
addQueryParameter("page", page.toString())
|
addQueryParameter("page", page.toString())
|
||||||
@ -190,20 +286,48 @@ class Koharu(
|
|||||||
|
|
||||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||||
|
|
||||||
private fun searchMangaParse2(response: Response): MangasPage {
|
override fun getFilterList(): FilterList {
|
||||||
val entry = response.parseAs<MangaEntry>()
|
launchIO { fetchTags() }
|
||||||
|
|
||||||
return MangasPage(
|
return getFilters()
|
||||||
listOf(
|
|
||||||
SManga.create().apply {
|
|
||||||
setUrlWithoutDomain("${entry.id}/${entry.public_key}")
|
|
||||||
title = if (remadd()) entry.title.shortenTitle() else entry.title
|
|
||||||
thumbnail_url = entry.thumbnails.base + entry.thumbnails.main.path
|
|
||||||
},
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
private fun launchIO(block: () -> Unit) = scope.launch { block() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the genres from the source to be used in the filters.
|
||||||
|
*/
|
||||||
|
private fun fetchTags() {
|
||||||
|
if (tagsFetchAttempts < 3 && !tagsFetched) {
|
||||||
|
try {
|
||||||
|
client.newCall(
|
||||||
|
GET("$apiBooksUrl/tags/filters", lazyHeaders),
|
||||||
|
).execute()
|
||||||
|
.use { it.parseAs<List<Filter>>() }
|
||||||
|
.also {
|
||||||
|
tagsFetched = true
|
||||||
|
}
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.map { it.toTag() }
|
||||||
|
?.also { tags ->
|
||||||
|
genreList = tags.filterIsInstance<KoharuFilters.Genre>()
|
||||||
|
femaleList = tags.filterIsInstance<KoharuFilters.Female>()
|
||||||
|
maleList = tags.filterIsInstance<KoharuFilters.Male>()
|
||||||
|
artistList = tags.filterIsInstance<KoharuFilters.Artist>()
|
||||||
|
circleList = tags.filterIsInstance<KoharuFilters.Circle>()
|
||||||
|
parodyList = tags.filterIsInstance<KoharuFilters.Parody>()
|
||||||
|
mixedList = tags.filterIsInstance<KoharuFilters.Mixed>()
|
||||||
|
otherList = tags.filterIsInstance<KoharuFilters.Other>()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally {
|
||||||
|
tagsFetchAttempts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Details
|
// Details
|
||||||
|
|
||||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
@ -211,96 +335,11 @@ class Koharu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
return response.parseAs<MangaEntry>().toSManga()
|
val mangaDetail = response.parseAs<MangaDetail>()
|
||||||
}
|
return mangaDetail.toSManga().apply {
|
||||||
|
setUrlWithoutDomain("${mangaDetail.id}/${mangaDetail.key}")
|
||||||
private val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
|
title = if (remadd()) mangaDetail.title.shortenTitle() else mangaDetail.title
|
||||||
private fun MangaEntry.toSManga() = SManga.create().apply {
|
|
||||||
val artists = mutableListOf<String>()
|
|
||||||
val circles = mutableListOf<String>()
|
|
||||||
val parodies = mutableListOf<String>()
|
|
||||||
val magazines = mutableListOf<String>()
|
|
||||||
val characters = mutableListOf<String>()
|
|
||||||
val cosplayers = mutableListOf<String>()
|
|
||||||
val females = mutableListOf<String>()
|
|
||||||
val males = mutableListOf<String>()
|
|
||||||
val mixed = mutableListOf<String>()
|
|
||||||
val other = mutableListOf<String>()
|
|
||||||
val uploaders = mutableListOf<String>()
|
|
||||||
val tags = mutableListOf<String>()
|
|
||||||
for (tag in this@toSManga.tags) {
|
|
||||||
when (tag.namespace) {
|
|
||||||
1 -> artists.add(tag.name)
|
|
||||||
2 -> circles.add(tag.name)
|
|
||||||
3 -> parodies.add(tag.name)
|
|
||||||
4 -> magazines.add(tag.name)
|
|
||||||
5 -> characters.add(tag.name)
|
|
||||||
6 -> cosplayers.add(tag.name)
|
|
||||||
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
|
|
||||||
8 -> males.add(tag.name + " ♂")
|
|
||||||
9 -> females.add(tag.name + " ♀")
|
|
||||||
10 -> mixed.add(tag.name)
|
|
||||||
12 -> other.add(tag.name)
|
|
||||||
else -> tags.add(tag.name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var appended = false
|
|
||||||
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
|
|
||||||
title = if (remadd()) this@toSManga.title.shortenTitle() else this@toSManga.title
|
|
||||||
|
|
||||||
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
|
|
||||||
artist = artists.joinToString { it.capitalizeEach() }
|
|
||||||
genre = (tags + males + females + mixed + other).joinToString { it.capitalizeEach() }
|
|
||||||
description = buildString {
|
|
||||||
circles.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Circles: ", it, "\n")
|
|
||||||
}
|
|
||||||
uploaders.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Uploaders: ", it, "\n")
|
|
||||||
}
|
|
||||||
magazines.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Magazines: ", it, "\n")
|
|
||||||
}
|
|
||||||
cosplayers.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Cosplayers: ", it, "\n")
|
|
||||||
}
|
|
||||||
parodies.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Parodies: ", it, "\n")
|
|
||||||
}
|
|
||||||
characters.joinAndCapitalizeEach()?.let {
|
|
||||||
append("Characters: ", it, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appended) append("\n")
|
|
||||||
|
|
||||||
try {
|
|
||||||
append("Posted: ", dateReformat.format(created_at), "\n")
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
val dataKey = when (quality()) {
|
|
||||||
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
|
|
||||||
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
|
|
||||||
"980" -> data.`980` ?: data.`1280` ?: data.`0`
|
|
||||||
"780" -> data.`780` ?: data.`980` ?: data.`0`
|
|
||||||
else -> data.`0`
|
|
||||||
}
|
|
||||||
append("Size: ", dataKey.readableSize(), "\n\n")
|
|
||||||
append("Pages: ", thumbnails.entries.size, "\n\n")
|
|
||||||
}
|
|
||||||
status = SManga.COMPLETED
|
|
||||||
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
|
|
||||||
s.replaceFirstChar { sr ->
|
|
||||||
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
|
|
||||||
return this.ifEmpty { null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
|
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
|
||||||
@ -312,11 +351,11 @@ class Koharu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun chapterListParse(response: Response): List<SChapter> {
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
val manga = response.parseAs<MangaEntry>()
|
val manga = response.parseAs<MangaDetail>()
|
||||||
return listOf(
|
return listOf(
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
name = "Chapter"
|
name = "Chapter"
|
||||||
url = "${manga.id}/${manga.public_key}"
|
url = "${manga.id}/${manga.key}"
|
||||||
date_upload = (manga.updated_at ?: manga.created_at)
|
date_upload = (manga.updated_at ?: manga.created_at)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -326,13 +365,24 @@ class Koharu(
|
|||||||
|
|
||||||
// Page List
|
// Page List
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return interceptedClient.newCall(pageListRequest(chapter))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
pageListParse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun pageListRequest(chapter: SChapter): Request {
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
return GET("$apiBooksUrl/detail/${chapter.url}", lazyHeaders)
|
return POST("$apiBooksUrl/detail/${chapter.url}?crt=$token", lazyHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
val mangaEntry = response.parseAs<MangaEntry>()
|
val mangaData = response.parseAs<MangaData>()
|
||||||
val imagesInfo = getImagesByMangaEntry(mangaEntry)
|
val url = response.request.url.toString()
|
||||||
|
val matches = Regex("""/detail/(\d+)/([a-z\d]+)""").find(url)
|
||||||
|
if (matches == null || matches.groupValues.size < 3) return emptyList()
|
||||||
|
val imagesInfo = getImagesByMangaData(mangaData, matches.groupValues[1], matches.groupValues[2])
|
||||||
|
|
||||||
return imagesInfo.first.entries.mapIndexed { index, image ->
|
return imagesInfo.first.entries.mapIndexed { index, image ->
|
||||||
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
|
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
|
||||||
@ -364,6 +414,13 @@ class Koharu(
|
|||||||
"Reload manga to apply changes to loaded manga."
|
"Reload manga to apply changes to loaded manga."
|
||||||
setDefaultValue(false)
|
setDefaultValue(false)
|
||||||
}.also(screen::addPreference)
|
}.also(screen::addPreference)
|
||||||
|
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = PREF_EXCLUDE_TAGS
|
||||||
|
title = "Tags to exclude from browse/search"
|
||||||
|
summary = "Separate tags with commas (,).\n" +
|
||||||
|
"Excluding: ${alwaysExcludeTags()}"
|
||||||
|
}.also(screen::addPreference)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun <reified T> Response.parseAs(): T {
|
private inline fun <reified T> Response.parseAs(): T {
|
||||||
@ -374,5 +431,11 @@ class Koharu(
|
|||||||
const val PREFIX_ID_KEY_SEARCH = "id:"
|
const val PREFIX_ID_KEY_SEARCH = "id:"
|
||||||
private const val PREF_IMAGERES = "pref_image_quality"
|
private const val PREF_IMAGERES = "pref_image_quality"
|
||||||
private const val PREF_REM_ADD = "pref_remove_additional"
|
private const val PREF_REM_ADD = "pref_remove_additional"
|
||||||
|
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
|
||||||
|
|
||||||
|
internal var token: String? = null
|
||||||
|
internal var authorization: String? = null
|
||||||
|
|
||||||
|
internal val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,36 @@
|
|||||||
package eu.kanade.tachiyomi.extension.all.koharu
|
package eu.kanade.tachiyomi.extension.all.koharu
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.dateReformat
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Tag(
|
class Tag(
|
||||||
var name: String,
|
val name: String,
|
||||||
var namespace: Int = 0,
|
val namespace: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Filter(
|
||||||
|
private val id: Int,
|
||||||
|
private val name: String,
|
||||||
|
private val namespace: Int = 0,
|
||||||
|
) {
|
||||||
|
fun toTag() = when (namespace) {
|
||||||
|
0 -> KoharuFilters.Genre(id, name)
|
||||||
|
1 -> KoharuFilters.Artist(id, name)
|
||||||
|
2 -> KoharuFilters.Circle(id, name)
|
||||||
|
3 -> KoharuFilters.Parody(id, name)
|
||||||
|
8 -> KoharuFilters.Male(id, name)
|
||||||
|
9 -> KoharuFilters.Female(id, name)
|
||||||
|
10 -> KoharuFilters.Mixed(id, name)
|
||||||
|
12 -> KoharuFilters.Other(id, name)
|
||||||
|
else -> KoharuFilters.Tag(id, name, namespace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Books(
|
class Books(
|
||||||
val entries: List<Entry> = emptyList(),
|
val entries: List<Entry> = emptyList(),
|
||||||
@ -19,22 +42,125 @@ class Books(
|
|||||||
@Serializable
|
@Serializable
|
||||||
class Entry(
|
class Entry(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val public_key: String,
|
val key: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val thumbnail: Thumbnail,
|
val thumbnail: Thumbnail,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class MangaEntry(
|
class MangaDetail(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val public_key: String,
|
val key: String,
|
||||||
val created_at: Long = 0L,
|
val created_at: Long = 0L,
|
||||||
val updated_at: Long?,
|
val updated_at: Long?,
|
||||||
val thumbnails: Thumbnails,
|
val thumbnails: Thumbnails,
|
||||||
val tags: List<Tag> = emptyList(),
|
val tags: List<Tag> = emptyList(),
|
||||||
|
) {
|
||||||
|
fun toSManga() = SManga.create().apply {
|
||||||
|
val artists = mutableListOf<String>()
|
||||||
|
val circles = mutableListOf<String>()
|
||||||
|
val parodies = mutableListOf<String>()
|
||||||
|
val magazines = mutableListOf<String>()
|
||||||
|
val characters = mutableListOf<String>()
|
||||||
|
val cosplayers = mutableListOf<String>()
|
||||||
|
val females = mutableListOf<String>()
|
||||||
|
val males = mutableListOf<String>()
|
||||||
|
val mixed = mutableListOf<String>()
|
||||||
|
val language = mutableListOf<String>()
|
||||||
|
val other = mutableListOf<String>()
|
||||||
|
val uploaders = mutableListOf<String>()
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
this@MangaDetail.tags.forEach { tag ->
|
||||||
|
when (tag.namespace) {
|
||||||
|
1 -> artists.add(tag.name)
|
||||||
|
2 -> circles.add(tag.name)
|
||||||
|
3 -> parodies.add(tag.name)
|
||||||
|
4 -> magazines.add(tag.name)
|
||||||
|
5 -> characters.add(tag.name)
|
||||||
|
6 -> cosplayers.add(tag.name)
|
||||||
|
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
|
||||||
|
8 -> males.add(tag.name + " ♂")
|
||||||
|
9 -> females.add(tag.name + " ♀")
|
||||||
|
10 -> mixed.add(tag.name)
|
||||||
|
11 -> language.add(tag.name)
|
||||||
|
12 -> other.add(tag.name)
|
||||||
|
else -> tags.add(tag.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var appended = false
|
||||||
|
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
|
||||||
|
|
||||||
|
thumbnail_url = thumbnails.base + thumbnails.main.path
|
||||||
|
|
||||||
|
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
|
||||||
|
artist = artists.joinToString { it.capitalizeEach() }
|
||||||
|
genre = (artists + circles + parodies + magazines + characters + cosplayers + tags + females + males + mixed + other).joinToString { it.capitalizeEach() }
|
||||||
|
description = buildString {
|
||||||
|
circles.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Circles: ", it, "\n")
|
||||||
|
}
|
||||||
|
uploaders.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Uploaders: ", it, "\n")
|
||||||
|
}
|
||||||
|
magazines.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Magazines: ", it, "\n")
|
||||||
|
}
|
||||||
|
cosplayers.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Cosplayers: ", it, "\n")
|
||||||
|
}
|
||||||
|
parodies.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Parodies: ", it, "\n")
|
||||||
|
}
|
||||||
|
characters.joinAndCapitalizeEach()?.let {
|
||||||
|
append("Characters: ", it, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appended) append("\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Posted: ", dateReformat.format(created_at), "\n")
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
append("Pages: ", thumbnails.entries.size, "\n\n")
|
||||||
|
}
|
||||||
|
status = SManga.COMPLETED
|
||||||
|
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
|
||||||
|
s.replaceFirstChar { sr ->
|
||||||
|
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
|
||||||
|
return this.ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaData(
|
||||||
val data: Data,
|
val data: Data,
|
||||||
)
|
val similar: List<Entry> = emptyList(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Return human-readable size of chapter.
|
||||||
|
* @param quality The quality set in PREF_IMAGERES
|
||||||
|
*/
|
||||||
|
fun size(quality: String): String {
|
||||||
|
val dataKey = when (quality) {
|
||||||
|
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
|
||||||
|
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
|
||||||
|
"980" -> data.`980` ?: data.`1280` ?: data.`0`
|
||||||
|
"780" -> data.`780` ?: data.`980` ?: data.`0`
|
||||||
|
else -> data.`0`
|
||||||
|
}
|
||||||
|
return dataKey.readableSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Thumbnails(
|
class Thumbnails(
|
||||||
@ -61,7 +187,7 @@ class Data(
|
|||||||
class DataKey(
|
class DataKey(
|
||||||
val id: Int? = null,
|
val id: Int? = null,
|
||||||
val size: Double = 0.0,
|
val size: Double = 0.0,
|
||||||
val public_key: String? = null,
|
val key: String? = null,
|
||||||
) {
|
) {
|
||||||
fun readableSize() = when {
|
fun readableSize() = when {
|
||||||
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
|
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
|
||||||
|
@ -3,49 +3,127 @@ package eu.kanade.tachiyomi.extension.all.koharu
|
|||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
|
||||||
fun getFilters(): FilterList {
|
object KoharuFilters {
|
||||||
return FilterList(
|
var genreList: List<Genre> = KoharuTags.genreList
|
||||||
SortFilter("Sort by", getSortsList),
|
var femaleList: List<Female> = KoharuTags.femaleList
|
||||||
CategoryFilter("Category"),
|
var maleList: List<Male> = KoharuTags.maleList
|
||||||
Filter.Separator(),
|
var artistList: List<Artist> = KoharuTags.artistList
|
||||||
Filter.Header("Separate tags with commas (,)"),
|
var circleList: List<Circle> = KoharuTags.circleList
|
||||||
Filter.Header("Prepend with dash (-) to exclude"),
|
var parodyList: List<Parody> = KoharuTags.parodyList
|
||||||
TextFilter("Artists", "artist"),
|
var mixedList: List<Mixed> = KoharuTags.mixedList
|
||||||
TextFilter("Magazines", "magazine"),
|
var otherList: List<Other> = KoharuTags.otherList
|
||||||
TextFilter("Publishers", "publisher"),
|
|
||||||
TextFilter("Characters", "character"),
|
/**
|
||||||
TextFilter("Cosplayers", "cosplayer"),
|
* Whether tags have been fetched
|
||||||
TextFilter("Parodies", "parody"),
|
*/
|
||||||
TextFilter("Circles", "circle"),
|
internal var tagsFetched: Boolean = false
|
||||||
TextFilter("Male Tags", "male"),
|
|
||||||
TextFilter("Female Tags", "female"),
|
/**
|
||||||
TextFilter("Tags ( Universal )", "tag"),
|
* Inner variable to control how much tries the tags request was called.
|
||||||
Filter.Header("Filter by pages, for example: (>20)"),
|
*/
|
||||||
TextFilter("Pages", "pages"),
|
internal var tagsFetchAttempts: Int = 0
|
||||||
|
|
||||||
|
fun getFilters(): FilterList {
|
||||||
|
return FilterList(
|
||||||
|
SortFilter("Sort by", getSortsList),
|
||||||
|
CategoryFilter("Category"),
|
||||||
|
Filter.Separator(),
|
||||||
|
TagFilter("Tags", genreList),
|
||||||
|
TagFilter("Female Tags", femaleList),
|
||||||
|
TagFilter("Male Tags", maleList),
|
||||||
|
TagFilter("Artists", artistList),
|
||||||
|
TagFilter("Circles", circleList),
|
||||||
|
TagFilter("Parodies", parodyList),
|
||||||
|
TagFilter("Mixed", mixedList),
|
||||||
|
TagFilter("Other", otherList),
|
||||||
|
GenreConditionFilter("Include condition", tagsConditionIncludeFilterOptions, "i"),
|
||||||
|
GenreConditionFilter("Exclude condition", tagsConditionExcludeFilterOptions, "e"),
|
||||||
|
Filter.Separator(),
|
||||||
|
Filter.Header("Separate tags with commas (,)"),
|
||||||
|
Filter.Header("Prepend with dash (-) to exclude"),
|
||||||
|
TextFilter("Magazines", "magazine"),
|
||||||
|
TextFilter("Publishers", "publisher"),
|
||||||
|
TextFilter("Characters", "character"),
|
||||||
|
TextFilter("Cosplayers", "cosplayer"),
|
||||||
|
Filter.Header("Filter by pages, for example: (>20)"),
|
||||||
|
TextFilter("Pages", "pages"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
||||||
|
internal open class SortFilter(
|
||||||
|
name: String,
|
||||||
|
private val vals: List<Pair<String, String>>,
|
||||||
|
state: Int = 0,
|
||||||
|
) :
|
||||||
|
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
||||||
|
fun getValue() = vals[state].second
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CategoryFilter(name: String) :
|
||||||
|
Filter.Group<CheckBoxFilter>(
|
||||||
|
name,
|
||||||
|
listOf(
|
||||||
|
Pair("Manga", 2),
|
||||||
|
Pair("Doujinshi", 4),
|
||||||
|
Pair("Illustration", 8),
|
||||||
|
).map { CheckBoxFilter(it.first, it.second, true) },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal open class CheckBoxFilter(name: String, val value: Int, state: Boolean) :
|
||||||
|
Filter.CheckBox(name, state)
|
||||||
|
|
||||||
|
private val getSortsList: List<Pair<String, String>> = listOf(
|
||||||
|
Pair("Recently Posted", "4"),
|
||||||
|
Pair("Title", "2"),
|
||||||
|
Pair("Pages", "3"),
|
||||||
|
Pair("Most Viewed", "8"),
|
||||||
|
Pair("Most Favorited", "9"),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
|
internal class GenreConditionFilter(
|
||||||
internal open class SortFilter(name: String, private val vals: List<Pair<String, String>>, state: Int = 0) :
|
title: String,
|
||||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
|
options: List<Pair<String, String>>,
|
||||||
fun getValue() = vals[state].second
|
val param: String,
|
||||||
}
|
) : UriPartFilter(
|
||||||
|
title,
|
||||||
|
options.toTypedArray(),
|
||||||
|
)
|
||||||
|
|
||||||
internal class CategoryFilter(name: String) :
|
open class Tag(val id: Int, val name: String, val namespace: Int)
|
||||||
Filter.Group<CheckBoxFilter>(
|
class Genre(id: Int, name: String) : Tag(id, name, namespace = 0)
|
||||||
name,
|
class Artist(id: Int, name: String) : Tag(id, name, namespace = 1)
|
||||||
|
class Circle(id: Int, name: String) : Tag(id, name, namespace = 2)
|
||||||
|
class Parody(id: Int, name: String) : Tag(id, name, namespace = 3)
|
||||||
|
class Male(id: Int, name: String) : Tag(id, name, namespace = 8)
|
||||||
|
class Female(id: Int, name: String) : Tag(id, name, namespace = 9)
|
||||||
|
class Mixed(id: Int, name: String) : Tag(id, name, namespace = 10)
|
||||||
|
class Other(id: Int, name: String) : Tag(id, name, namespace = 12)
|
||||||
|
|
||||||
|
internal class TagFilter(title: String, tags: List<Tag>) :
|
||||||
|
Filter.Group<TagTriState>(title, tags.map { TagTriState(it.name, it.id) })
|
||||||
|
|
||||||
|
internal class TagTriState(name: String, val id: Int) : Filter.TriState(name)
|
||||||
|
|
||||||
|
open class UriPartFilter(
|
||||||
|
displayName: String,
|
||||||
|
private val vals: Array<Pair<String, String>>,
|
||||||
|
state: Int = 0,
|
||||||
|
) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
|
||||||
|
fun toUriPart() = vals[state].second
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://api.schale.network/books?include=<id>,<id>&i=1&exclude=<id>,<id>&e=1
|
||||||
|
private val tagsConditionIncludeFilterOptions: List<Pair<String, String>> =
|
||||||
listOf(
|
listOf(
|
||||||
Pair("Manga", 2),
|
"AND" to "",
|
||||||
Pair("Doujinshi", 4),
|
"OR" to "1",
|
||||||
Pair("Illustration", 8),
|
)
|
||||||
).map { CheckBoxFilter(it.first, it.second, true) },
|
|
||||||
)
|
|
||||||
internal open class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
|
|
||||||
|
|
||||||
private val getSortsList: List<Pair<String, String>> = listOf(
|
private val tagsConditionExcludeFilterOptions: List<Pair<String, String>> =
|
||||||
Pair("Recently Posted", "4"),
|
listOf(
|
||||||
Pair("Title", "2"),
|
"OR" to "",
|
||||||
Pair("Pages", "3"),
|
"AND" to "1",
|
||||||
Pair("Most Viewed", "8"),
|
)
|
||||||
Pair("Most Favorited", "9"),
|
}
|
||||||
)
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,242 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.koharu
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.authorization
|
||||||
|
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.token
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Cloudflare Turnstile interceptor */
|
||||||
|
class TurnstileInterceptor(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val domainUrl: String,
|
||||||
|
private val authUrl: String,
|
||||||
|
private val userAgent: String?,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val context: Application by injectLazy()
|
||||||
|
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
private val lazyHeaders by lazy {
|
||||||
|
Headers.Builder().apply {
|
||||||
|
set("User-Agent", userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36")
|
||||||
|
set("Referer", "$domainUrl/")
|
||||||
|
set("Origin", domainUrl)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(authorization: String) =
|
||||||
|
Headers.Builder().apply {
|
||||||
|
set("User-Agent", userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36")
|
||||||
|
set("Referer", "$domainUrl/")
|
||||||
|
set("Origin", domainUrl)
|
||||||
|
set("Authorization", authorization)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
private val authorizedRequestRegex by lazy { Regex("""(.+\?crt=)(.*)""") }
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
val url = request.url.toString()
|
||||||
|
val matchResult = authorizedRequestRegex.find(url) ?: return chain.proceed(request)
|
||||||
|
if (matchResult.groupValues.size == 3) {
|
||||||
|
val requestingUrl = matchResult.groupValues[1]
|
||||||
|
val crt = matchResult.groupValues[2]
|
||||||
|
var newResponse: Response
|
||||||
|
|
||||||
|
if (crt.isNotBlank() && crt != "null") {
|
||||||
|
// Token already set in URL, just make the request
|
||||||
|
newResponse = chain.proceed(request)
|
||||||
|
if (newResponse.code !in listOf(400, 403)) return newResponse
|
||||||
|
} else {
|
||||||
|
// Token doesn't include, add token then make request
|
||||||
|
if (token.isNullOrBlank()) resolveInWebview()
|
||||||
|
val newRequest = if (request.method == "POST") {
|
||||||
|
POST("${requestingUrl}$token", lazyHeaders)
|
||||||
|
} else {
|
||||||
|
GET("${requestingUrl}$token", lazyHeaders)
|
||||||
|
}
|
||||||
|
newResponse = chain.proceed(newRequest)
|
||||||
|
if (newResponse.code !in listOf(400, 403)) return newResponse
|
||||||
|
}
|
||||||
|
newResponse.close()
|
||||||
|
|
||||||
|
// Request failed, refresh token then try again
|
||||||
|
clearToken()
|
||||||
|
token = null
|
||||||
|
resolveInWebview()
|
||||||
|
val newRequest = if (request.method == "POST") {
|
||||||
|
POST("${requestingUrl}$token", lazyHeaders)
|
||||||
|
} else {
|
||||||
|
GET("${requestingUrl}$token", lazyHeaders)
|
||||||
|
}
|
||||||
|
newResponse = chain.proceed(newRequest)
|
||||||
|
if (newResponse.code !in listOf(400, 403)) return newResponse
|
||||||
|
throw IOException("Open webview once to refresh token (${newResponse.code})")
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
fun resolveInWebview(): Pair<String?, String?> {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
var webView: WebView? = null
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
val webview = WebView(context)
|
||||||
|
webView = webview
|
||||||
|
with(webview.settings) {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
useWideViewPort = false
|
||||||
|
loadWithOverviewMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
webview.webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||||
|
val authHeader = request?.requestHeaders?.get("Authorization")
|
||||||
|
if (request?.url.toString().contains(authUrl) && authHeader != null) {
|
||||||
|
authorization = authHeader
|
||||||
|
if (request.method == "POST") {
|
||||||
|
// Authorize & requesting a new token.
|
||||||
|
// `authorization` here should be in format: Bearer <authorization>
|
||||||
|
try {
|
||||||
|
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||||
|
val authHeaders = authHeaders(authHeader)
|
||||||
|
val response = noRedirectClient.newCall(POST(authUrl, authHeaders)).execute()
|
||||||
|
response.use {
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
with(response) {
|
||||||
|
token = body.string()
|
||||||
|
.removeSurrounding("\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
} finally {
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request.method == "GET") {
|
||||||
|
// Site is trying to recheck old token validation here.
|
||||||
|
// If it fails then site will request a new one using POST method.
|
||||||
|
// But we will check it ourselves.
|
||||||
|
// Normally this might not occur because old token should already be acquired & rechecked via onPageFinished.
|
||||||
|
// `authorization` here should be in format: Bearer <token>
|
||||||
|
val oldToken = authorization
|
||||||
|
?.substringAfterLast(" ")
|
||||||
|
if (oldToken != null && recheckTokenValid(oldToken)) {
|
||||||
|
token = oldToken
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.shouldInterceptRequest(view, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the saved token in localStorage and use it.
|
||||||
|
* This token might already expired. Normally site will check token for expiration with a GET request.
|
||||||
|
* Here will will recheck it ourselves.
|
||||||
|
*/
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
if (view == null) return
|
||||||
|
val script = "javascript:localStorage['clearance']"
|
||||||
|
view.evaluateJavascript(script) {
|
||||||
|
// Avoid overwrite newly requested token
|
||||||
|
if (!it.isNullOrBlank() && it != "null" && token.isNullOrBlank()) {
|
||||||
|
val oldToken = it
|
||||||
|
.removeSurrounding("\"")
|
||||||
|
if (recheckTokenValid(oldToken)) {
|
||||||
|
token = oldToken
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recheckTokenValid(token: String): Boolean {
|
||||||
|
try {
|
||||||
|
val noRedirectClient = client.newBuilder().followRedirects(false).build()
|
||||||
|
val authHeaders = authHeaders("Bearer $token")
|
||||||
|
val response = noRedirectClient.newCall(GET(authUrl, authHeaders)).execute()
|
||||||
|
response.use {
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webview.loadUrl("$domainUrl/")
|
||||||
|
}
|
||||||
|
|
||||||
|
latch.await(20, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
// One last try to read the token from localStorage, in case it got updated last minute.
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
val script = "javascript:localStorage['clearance']"
|
||||||
|
webView?.evaluateJavascript(script) {
|
||||||
|
if (!it.isNullOrBlank() && it != "null") {
|
||||||
|
token = it
|
||||||
|
.removeSurrounding("\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webView?.stopLoading()
|
||||||
|
webView?.destroy()
|
||||||
|
webView = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return token to authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun clearToken() {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
handler.post {
|
||||||
|
val webView = WebView(context)
|
||||||
|
with(webView.settings) {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
}
|
||||||
|
webView.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
if (view == null) return
|
||||||
|
val script = "javascript:localStorage.clear()"
|
||||||
|
view.evaluateJavascript(script) {
|
||||||
|
token = null
|
||||||
|
view.stopLoading()
|
||||||
|
view.destroy()
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView.loadUrl(domainUrl)
|
||||||
|
}
|
||||||
|
latch.await(20, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user