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:
Vetle Ledaal 2025-03-23 16:05:54 +01:00 committed by Draff
parent 414b6b8670
commit 3fb70c10de
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
8 changed files with 4146 additions and 550 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'SchaleNetwork' extName = 'SchaleNetwork'
extClass = '.KoharuFactory' extClass = '.KoharuFactory'
extVersionCode = 12 extVersionCode = 13
isNsfw = true isNsfw = true
} }

View File

@ -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)
} }
} }

View File

@ -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"

View File

@ -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

View File

@ -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)
}
}